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,125 @@
1
+ "use strict";
2
+
3
+ function computeExecutionPlan({
4
+ suggestions = [],
5
+ goalForecast = null,
6
+ healthProtocols = null,
7
+ projects = null,
8
+ engineHealth = null,
9
+ daily = null,
10
+ overrides = null,
11
+ } = {}) {
12
+ const items = [];
13
+ const seen = new Set();
14
+ const add = (item) => {
15
+ if (!item || !item.title) return;
16
+ const key = item.workflow ? `workflow:${item.workflow}` : (item.id || `${item.lane}:${item.title}`);
17
+ if (seen.has(key)) return;
18
+ seen.add(key);
19
+ items.push({
20
+ id: key,
21
+ lane: item.lane || "work",
22
+ title: item.title,
23
+ detail: item.detail || "",
24
+ workflow: item.workflow || null,
25
+ filePath: item.filePath || null,
26
+ priority: item.priority || "medium",
27
+ source: item.source || "",
28
+ checked: !!item.checked,
29
+ });
30
+ };
31
+
32
+ if (engineHealth && engineHealth.analyseDay && engineHealth.analyseDay.status === "RED") {
33
+ add({
34
+ lane: "engine",
35
+ title: "Audit today before the system keeps compounding",
36
+ detail: `/analyse-day is ${engineHealth.analyseDay.overdueDays} days overdue.`,
37
+ workflow: "/analyse-day",
38
+ priority: "critical",
39
+ source: "engine-health",
40
+ });
41
+ }
42
+
43
+ if (goalForecast && goalForecast.available) {
44
+ for (const a of goalForecast.adjustments || []) {
45
+ add({
46
+ lane: a.workflow === "/experiment" ? "experiment" : "strategy",
47
+ title: a.title,
48
+ detail: a.detail,
49
+ workflow: a.workflow,
50
+ priority: a.priority,
51
+ source: "goal-forecast",
52
+ });
53
+ }
54
+ }
55
+
56
+ for (const s of suggestions || []) {
57
+ add({
58
+ lane: "workflow",
59
+ title: s.reason || `Run ${s.workflow}`,
60
+ detail: `${s.workflow}${s.signal ? ` · ${s.signal}` : ""}`,
61
+ workflow: s.workflow || s.command || null,
62
+ priority: (s.urgency || "").toUpperCase() === "RED" ? "critical" : "high",
63
+ source: "suggested-now",
64
+ });
65
+ }
66
+
67
+ const primaryProject = projects && Array.isArray(projects.projects) ? projects.projects[0] : null;
68
+ if (primaryProject && primaryProject.openTasks > 0) {
69
+ add({
70
+ lane: "work",
71
+ title: `Move ${primaryProject.name}`,
72
+ detail: `${primaryProject.openTasks} open tasks · ${primaryProject.zombieTasks || 0} zombie · ${primaryProject.unverifiedTasks || 0} unverified.`,
73
+ filePath: primaryProject.planPath,
74
+ priority: primaryProject.zombieTasks > 0 ? "high" : "medium",
75
+ source: "project-plan",
76
+ });
77
+ }
78
+
79
+ const protocol = healthProtocols && healthProtocols.protocols && healthProtocols.protocols.find(p => p.current);
80
+ if (protocol && protocol.current) {
81
+ const nextHealth = (protocol.current.items || []).find(i => !i.checked);
82
+ if (nextHealth) {
83
+ add({
84
+ lane: "health",
85
+ title: clean(nextHealth.text).slice(0, 120),
86
+ detail: `${protocol.title} · ${protocol.current.done}/${protocol.current.total} complete.`,
87
+ filePath: protocol.filePath,
88
+ priority: "medium",
89
+ source: "health-protocol",
90
+ });
91
+ }
92
+ }
93
+
94
+ const todayMissing = daily && daily.today && !daily.today.exists;
95
+ if (todayMissing) {
96
+ add({
97
+ lane: "engine",
98
+ title: "Create today's Daily Note",
99
+ detail: "Most live dashboard stats depend on the current Daily Note.",
100
+ workflow: "/plan-tomorrow",
101
+ priority: "high",
102
+ source: "daily",
103
+ });
104
+ }
105
+
106
+ const checked = items.filter(i => i.checked).length;
107
+ const total = Math.max(1, items.length);
108
+ const overrideRecent = overrides && Number.isFinite(overrides.last7d) ? overrides.last7d : 0;
109
+ const capacityScore = Math.max(0, Math.min(100, 100 - items.filter(i => i.priority === "critical").length * 20 - items.filter(i => i.priority === "high").length * 9 - overrideRecent * 4));
110
+
111
+ return {
112
+ available: true,
113
+ capacityScore,
114
+ checked,
115
+ total: items.length,
116
+ items: items.slice(0, 12),
117
+ lanes: ["strategy", "experiment", "work", "health", "engine", "workflow"],
118
+ };
119
+ }
120
+
121
+ function clean(text) {
122
+ return String(text || "").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\s+/g, " ").trim();
123
+ }
124
+
125
+ module.exports = { computeExecutionPlan };
@@ -0,0 +1,429 @@
1
+ "use strict";
2
+
3
+ // Lab Parser — reads `02_Areas/Engine/Experiments/*.md` and returns the
4
+ // hypothesis register the dashboard renders. Each file is one experiment.
5
+ // Schema: YAML frontmatter (hypothesis, domain, status, sample_target,
6
+ // sample_current, variables.primary, variables.controls, kpis[], started,
7
+ // deadline, parent_strategy) + markdown body with `## Daily log` and
8
+ // `## Result` sections.
9
+ //
10
+ // Experiment-discipline principles enforced via FRICTION objects emitted
11
+ // per experiment — the UI surfaces these as warning chips so users see
12
+ // what makes a real experiment vs a wish.
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+
17
+ const DOMAINS = new Set(["work", "vitality", "faith"]);
18
+ const VALID_STATUS = new Set(["active", "pass", "fail", "paused", "inconclusive"]);
19
+ const MAX_CONCURRENT_PER_DOMAIN = 3;
20
+
21
+ // Detects whether a string contains a numeric target. Hypothesis without
22
+ // a number is unverifiable — the rule: "if you can't write a number,
23
+ // you don't have an experiment yet."
24
+ const NUMERIC_RE = /(?:\d+(?:\.\d+)?%|\d+\s*\/\s*\d+|>=?\s*\d|<=?\s*\d|\d+\s*(?:min|hr|hour|day|week|kg|lbs|reps|sessions|sales|customers|calls|replies|posts|visits|clicks))/i;
25
+
26
+ function parseFrontmatter(text) {
27
+ // Audit pass 2 (find-bugs #13): normalize CRLF so Windows-edited files
28
+ // match the same regex as Unix. Previously CRLF files returned empty
29
+ // frontmatter and all hypothesis/status/domain fields were blank.
30
+ text = String(text).replace(/\r\n/g, "\n");
31
+ const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
32
+ if (!m) return { frontmatter: {}, body: text };
33
+ const fmText = m[1];
34
+ const body = text.slice(m[0].length);
35
+ const fm = {};
36
+
37
+ // Minimal YAML — flat keys, nested map for variables, list for kpis +
38
+ // variables.controls. Built for our schema, not general YAML.
39
+ const lines = fmText.split("\n");
40
+ let currentKey = null;
41
+ let listBuffer = null;
42
+ let mapBuffer = null;
43
+
44
+ for (let i = 0; i < lines.length; i++) {
45
+ const raw = lines[i];
46
+ if (!raw.trim() || raw.trim().startsWith("#")) continue;
47
+
48
+ // Top-level key: value
49
+ const topMatch = raw.match(/^([a-z_][a-z0-9_]*)\s*:\s*(.*)$/i);
50
+ const indented = /^\s+/.test(raw);
51
+
52
+ if (!indented && topMatch) {
53
+ // Flush prior buffers
54
+ if (currentKey && listBuffer) fm[currentKey] = listBuffer;
55
+ if (currentKey && mapBuffer) fm[currentKey] = mapBuffer;
56
+ listBuffer = null; mapBuffer = null;
57
+
58
+ const [, key, valRaw] = topMatch;
59
+ const val = stripQuotes((valRaw || "").trim());
60
+ currentKey = key;
61
+ if (val === "") {
62
+ // Block-style start
63
+ // Lookahead to determine list-of-maps or map
64
+ const next = lines[i + 1] || "";
65
+ if (next.match(/^\s+-\s/)) listBuffer = [];
66
+ else if (next.match(/^\s+[a-z_]+\s*:/i)) mapBuffer = {};
67
+ else listBuffer = []; // safest default
68
+ } else {
69
+ fm[key] = val;
70
+ currentKey = null;
71
+ }
72
+ continue;
73
+ }
74
+
75
+ // List item under current key
76
+ const listItem = raw.match(/^\s+-\s*(.*)$/);
77
+ if (listItem && listBuffer != null) {
78
+ const itemRaw = listItem[1];
79
+ if (itemRaw.match(/^[a-z_]+\s*:/i)) {
80
+ // List of maps — collect this object
81
+ const obj = {};
82
+ const firstKv = itemRaw.match(/^([a-z_][a-z0-9_]*)\s*:\s*(.*)$/i);
83
+ if (firstKv) obj[firstKv[1]] = stripQuotes(firstKv[2].trim());
84
+ // Lookahead for more keys at deeper indent
85
+ while (i + 1 < lines.length) {
86
+ const next = lines[i + 1];
87
+ if (!/^\s{4,}[a-z_]+\s*:/i.test(next)) break;
88
+ i++;
89
+ const kv = next.match(/^\s+([a-z_][a-z0-9_]*)\s*:\s*(.*)$/i);
90
+ if (kv) obj[kv[1]] = stripQuotes(kv[2].trim());
91
+ }
92
+ listBuffer.push(obj);
93
+ } else {
94
+ listBuffer.push(stripQuotes(itemRaw));
95
+ }
96
+ continue;
97
+ }
98
+
99
+ // Map key under current key
100
+ if (indented && mapBuffer != null) {
101
+ const kv = raw.match(/^\s+([a-z_][a-z0-9_]*)\s*:\s*(.*)$/i);
102
+ if (kv) {
103
+ const [, k, v] = kv;
104
+ const vt = stripQuotes((v || "").trim());
105
+ if (vt === "") {
106
+ // Nested list (e.g. controls under variables)
107
+ const nestedList = [];
108
+ while (i + 1 < lines.length) {
109
+ const next = lines[i + 1];
110
+ if (!/^\s{4,}-\s/.test(next)) break;
111
+ i++;
112
+ const m2 = next.match(/^\s+-\s*(.*)$/);
113
+ if (m2) nestedList.push(stripQuotes(m2[1]));
114
+ }
115
+ mapBuffer[k] = nestedList;
116
+ } else {
117
+ mapBuffer[k] = vt;
118
+ }
119
+ }
120
+ continue;
121
+ }
122
+ }
123
+ // Final flush
124
+ if (currentKey && listBuffer) fm[currentKey] = listBuffer;
125
+ if (currentKey && mapBuffer) fm[currentKey] = mapBuffer;
126
+
127
+ return { frontmatter: fm, body };
128
+ }
129
+
130
+ function stripQuotes(s) {
131
+ if (!s) return s;
132
+ const t = s.trim();
133
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
134
+ return t.slice(1, -1);
135
+ }
136
+ return t;
137
+ }
138
+
139
+ function parseDailyLog(body) {
140
+ // Extract entries like:
141
+ // - 2026-05-14 — 12 visitors, 0 buys.
142
+ // - 2026-05-14 - 12 visitors
143
+ // Returns [{ date, text }] sorted ASC.
144
+ const out = [];
145
+ const re = /^\s*-\s*(\d{4}-\d{2}-\d{2})\s*[—\-:]\s*(.+)$/gm;
146
+ let m;
147
+ while ((m = re.exec(body)) !== null) {
148
+ out.push({ date: m[1], text: m[2].trim() });
149
+ }
150
+ out.sort((a, b) => a.date.localeCompare(b.date));
151
+ return out;
152
+ }
153
+
154
+ function detectFrictions(fm, body, allByDomain) {
155
+ // Experiment-discipline checks — surfaced as user-visible warning chips.
156
+ const frictions = [];
157
+
158
+ if (!fm.hypothesis || fm.hypothesis.length < 12) {
159
+ frictions.push({
160
+ level: "crit",
161
+ label: "no hypothesis",
162
+ detail: "Write a measurable claim. If you can't, you don't have an experiment.",
163
+ });
164
+ } else if (!NUMERIC_RE.test(fm.hypothesis)) {
165
+ frictions.push({
166
+ level: "warn",
167
+ label: "no number in hypothesis",
168
+ detail: "Add a numeric target (%, count, time). Unfalsifiable hypotheses can't fail.",
169
+ });
170
+ }
171
+
172
+ if (!fm.sample_target || Number(fm.sample_target) <= 0) {
173
+ frictions.push({
174
+ level: "warn",
175
+ label: "no sample target",
176
+ detail: "Define how many measurements before you judge. No floor = endless pivoting.",
177
+ });
178
+ }
179
+
180
+ if (!fm.variables || !fm.variables.primary) {
181
+ frictions.push({
182
+ level: "warn",
183
+ label: "no primary variable",
184
+ detail: "Name the ONE thing changing. Ceteris paribus.",
185
+ });
186
+ } else if (Array.isArray(fm.variables.primary) && fm.variables.primary.length > 1) {
187
+ frictions.push({
188
+ level: "warn",
189
+ label: "multiple primary variables",
190
+ detail: "Pick one. Two variables changing = correlation, not experiment.",
191
+ });
192
+ }
193
+
194
+ // Section presence
195
+ if (!/##\s+Daily log/i.test(body)) {
196
+ frictions.push({
197
+ level: "info",
198
+ label: "no daily log section",
199
+ detail: "Add `## Daily log` for time-series tracking.",
200
+ });
201
+ }
202
+
203
+ // Per-domain concurrent cap
204
+ const activeCount = (allByDomain[fm.domain] || []).filter(e => e.status === "active").length;
205
+ if (activeCount > MAX_CONCURRENT_PER_DOMAIN) {
206
+ frictions.push({
207
+ level: "warn",
208
+ label: `${activeCount} concurrent in ${fm.domain}`,
209
+ detail: `Cap is ${MAX_CONCURRENT_PER_DOMAIN}. Close one before starting another.`,
210
+ });
211
+ }
212
+
213
+ // Status sanity
214
+ if (fm.status && !VALID_STATUS.has(fm.status)) {
215
+ frictions.push({
216
+ level: "info",
217
+ label: `unknown status: ${fm.status}`,
218
+ detail: `Valid: ${[...VALID_STATUS].join(", ")}`,
219
+ });
220
+ }
221
+
222
+ return frictions;
223
+ }
224
+
225
+ function dateDays(iso) {
226
+ if (!iso) return null;
227
+ const t = Date.parse(iso);
228
+ if (!Number.isFinite(t)) return null;
229
+ const diff = Math.floor((Date.now() - t) / 86400000);
230
+ return diff;
231
+ }
232
+
233
+ function buildExperiments(vault) {
234
+ const expDir = path.join(vault, "02_Areas", "Engine", "Experiments");
235
+ if (!fs.existsSync(expDir)) {
236
+ return { available: false, byDomain: { work: [], vitality: [], faith: [] }, all: [], active: [], past: [], count: 0 };
237
+ }
238
+
239
+ let entries;
240
+ try { entries = fs.readdirSync(expDir, { withFileTypes: true }); } catch {
241
+ return { available: false, byDomain: { work: [], vitality: [], faith: [] }, all: [], active: [], past: [], count: 0 };
242
+ }
243
+
244
+ const all = [];
245
+ for (const e of entries) {
246
+ if (!e.isFile() || !e.name.endsWith(".md")) continue;
247
+ if (e.name.startsWith("_TEMPLATE")) continue; // skip template
248
+ const filePath = path.join(expDir, e.name);
249
+ let text;
250
+ try { text = fs.readFileSync(filePath, "utf8"); } catch { continue; }
251
+
252
+ const { frontmatter: fm, body } = parseFrontmatter(text);
253
+
254
+ const status = (fm.status || "active").toLowerCase();
255
+ const domain = (fm.domain || "work").toLowerCase();
256
+ const sampleTarget = Number(fm.sample_target) || 0;
257
+ const sampleCurrent = Number(fm.sample_current) || 0;
258
+ const kpis = Array.isArray(fm.kpis) ? fm.kpis.map(k => ({
259
+ name: k.name || "—",
260
+ target: k.target || "—",
261
+ current: k.current || "—",
262
+ met: kpiMet(k.target, k.current),
263
+ })) : [];
264
+
265
+ const dailyLog = parseDailyLog(body);
266
+ const lastObservation = dailyLog.length ? dailyLog[dailyLog.length - 1] : null;
267
+
268
+ // Result + transition copy
269
+ const resultMatch = body.match(/##\s+Result\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
270
+ const ifPassMatch = body.match(/##\s+If PASS\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
271
+ const ifFailMatch = body.match(/##\s+If FAIL\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
272
+ const whyMatch = body.match(/##\s+Why this test\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
273
+
274
+ const startDays = dateDays(fm.started);
275
+ const deadlineDate = fm.deadline ? Date.parse(fm.deadline) : null;
276
+ const deadlineDaysLeft = deadlineDate
277
+ ? Math.ceil((deadlineDate - Date.now()) / 86400000)
278
+ : null;
279
+
280
+ const slug = e.name.replace(/\.md$/, "");
281
+
282
+ all.push({
283
+ slug,
284
+ path: filePath,
285
+ title: extractTitle(body) || slug,
286
+ hypothesis: fm.hypothesis || "(no hypothesis)",
287
+ domain,
288
+ status,
289
+ started: fm.started || null,
290
+ deadline: fm.deadline || null,
291
+ startedDaysAgo: startDays,
292
+ deadlineDaysLeft,
293
+ sampleTarget,
294
+ sampleCurrent,
295
+ samplePct: sampleTarget > 0 ? Math.min(100, (sampleCurrent / sampleTarget) * 100) : 0,
296
+ kpis,
297
+ primaryVariable: fm.variables?.primary || null,
298
+ controls: Array.isArray(fm.variables?.controls) ? fm.variables.controls : [],
299
+ parentStrategy: fm.parent_strategy || null,
300
+ // T210: structural Single Test linkage. Prefer the new dedicated field,
301
+ // fall back to parent_strategy so old experiment files still parse.
302
+ // Cross-reference against the current Strategy.md singleTest happens
303
+ // after the main loop (need the live single-test text available).
304
+ linkedSingleTest: fm.linked_single_test || fm.parent_strategy || null,
305
+ linkedToCurrentTest: false,
306
+ why: whyMatch ? whyMatch[1].trim().slice(0, 400) : null,
307
+ result: resultMatch ? resultMatch[1].trim().slice(0, 600) : null,
308
+ ifPass: ifPassMatch ? ifPassMatch[1].trim().slice(0, 300) : null,
309
+ ifFail: ifFailMatch ? ifFailMatch[1].trim().slice(0, 300) : null,
310
+ dailyLog,
311
+ lastObservation,
312
+ frictions: [], // populated after grouping (concurrent-cap needs the full set)
313
+ // Preserve raw body so detectFrictions can check section presence.
314
+ // Audit pass 2 (find-bugs #9): previously body was dropped, so
315
+ // detectFrictions received the hardcoded string "## Daily log"
316
+ // which always matches the test — the missing-section friction
317
+ // never surfaced for experiments that genuinely lacked the section.
318
+ _body: body,
319
+ });
320
+ }
321
+
322
+ // Group by domain for concurrent-cap detection
323
+ const byDomain = { work: [], vitality: [], faith: [] };
324
+ for (const exp of all) {
325
+ if (DOMAINS.has(exp.domain)) byDomain[exp.domain].push(exp);
326
+ }
327
+
328
+ // Now compute frictions with awareness of grouped state
329
+ for (const exp of all) {
330
+ exp.frictions = detectFrictions(
331
+ {
332
+ hypothesis: exp.hypothesis,
333
+ sample_target: exp.sampleTarget,
334
+ variables: { primary: exp.primaryVariable, controls: exp.controls },
335
+ status: exp.status,
336
+ domain: exp.domain,
337
+ },
338
+ // Pass the real body so section-presence checks fire (find-bugs #9).
339
+ exp._body || "",
340
+ byDomain
341
+ );
342
+ // Drop the internal field — UI doesn't need raw body in the payload.
343
+ delete exp._body;
344
+ }
345
+
346
+ // T210: cross-reference experiments against the vault's CURRENT Single
347
+ // Test. An experiment is "linked to current test" when its declared
348
+ // linked_single_test (or parent_strategy fallback) overlaps materially
349
+ // with the live Strategy.md singleTest text. Loose match by keyword
350
+ // intersection — full string equality is brittle when users paraphrase.
351
+ let currentTestKeywords = null;
352
+ try {
353
+ const stratFs = require("fs");
354
+ const stratPath = require("path").join(vault, "02_Areas", "Engine", "Strategy.md");
355
+ const stratText = stratFs.existsSync(stratPath) ? stratFs.readFileSync(stratPath, "utf8") : "";
356
+ const milestoneMatch = stratText.match(/\*\*Mid-term Milestone:\*\*\s+(.+?)(?=\n\*\*|\n##|$)/is);
357
+ const legacyMatch = stratText.match(/\*\*(?:Single Test|The Bet):\*\*\s+(.+?)(?=\n\*\*|\n##|$)/is);
358
+ const current = (milestoneMatch && milestoneMatch[1]) || (legacyMatch && legacyMatch[1]) || "";
359
+ if (current) {
360
+ currentTestKeywords = current.toLowerCase().match(/\b[a-z][a-z0-9-]{4,}\b/g) || [];
361
+ }
362
+ } catch (_) { /* tolerate missing Strategy.md */ }
363
+
364
+ if (currentTestKeywords && currentTestKeywords.length) {
365
+ const sigSet = new Set(currentTestKeywords.filter(w =>
366
+ !/^(the|and|for|with|that|this|from|into|over|about|test|active|installs|referral|deadline|operational|gate)$/.test(w)
367
+ ));
368
+ for (const exp of all) {
369
+ if (!exp.linkedSingleTest) continue;
370
+ const expWords = String(exp.linkedSingleTest).toLowerCase().match(/\b[a-z][a-z0-9-]{4,}\b/g) || [];
371
+ const overlap = expWords.filter(w => sigSet.has(w)).length;
372
+ // 2+ significant keyword overlap = same Single Test
373
+ exp.linkedToCurrentTest = overlap >= 2;
374
+ }
375
+ }
376
+
377
+ const active = all.filter(e => e.status === "active");
378
+ const past = all.filter(e => e.status !== "active");
379
+ const linkedActive = active.filter(e => e.linkedToCurrentTest);
380
+ const orphanedActive = active.filter(e => !e.linkedToCurrentTest);
381
+
382
+ return {
383
+ available: true,
384
+ all,
385
+ active,
386
+ past,
387
+ byDomain,
388
+ linkedActive,
389
+ orphanedActive,
390
+ count: all.length,
391
+ activeCount: active.length,
392
+ pastCount: past.length,
393
+ linkedActiveCount: linkedActive.length,
394
+ orphanedActiveCount: orphanedActive.length,
395
+ };
396
+ }
397
+
398
+ function extractTitle(body) {
399
+ const m = body.match(/^#\s+(.+)$/m);
400
+ return m ? m[1].trim() : null;
401
+ }
402
+
403
+ function kpiMet(target, current) {
404
+ // Parse simple targets: ">= 2%", "< 30", "= 100", ">= 5/day"
405
+ if (!target || !current || current === "—") return null;
406
+ const tNum = parseFirstNumber(target);
407
+ const cNum = parseFirstNumber(current);
408
+ if (tNum == null || cNum == null) return null;
409
+ const op = /<=/.test(target) ? "lte"
410
+ : /<\s/.test(target) ? "lt"
411
+ : />=/.test(target) ? "gte"
412
+ : />\s/.test(target) ? "gt"
413
+ : "eq";
414
+ switch (op) {
415
+ case "lte": return cNum <= tNum;
416
+ case "lt": return cNum < tNum;
417
+ case "gte": return cNum >= tNum;
418
+ case "gt": return cNum > tNum;
419
+ case "eq": return cNum === tNum;
420
+ }
421
+ return null;
422
+ }
423
+
424
+ function parseFirstNumber(s) {
425
+ const m = String(s).match(/-?\d+(?:\.\d+)?/);
426
+ return m ? Number(m[0]) : null;
427
+ }
428
+
429
+ module.exports = { buildExperiments, parseFrontmatter, parseDailyLog, kpiMet };