mover-os 4.7.6 → 4.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/install.js +763 -70
- package/package.json +15 -3
- package/src/dashboard/build.js +1503 -0
- package/src/dashboard/dashboard.js +276 -0
- package/src/dashboard/index.js +319 -0
- package/src/dashboard/lib/activation-log.js +297 -0
- package/src/dashboard/lib/active-context-parser.js +177 -0
- package/src/dashboard/lib/agent-command.js +93 -0
- package/src/dashboard/lib/agent-detect.js +255 -0
- package/src/dashboard/lib/agent-detector.js +92 -0
- package/src/dashboard/lib/agent-session.js +462 -0
- package/src/dashboard/lib/anti-identity-detector.js +116 -0
- package/src/dashboard/lib/auto-learnings-parser.js +183 -0
- package/src/dashboard/lib/cli-usage-parser.js +92 -0
- package/src/dashboard/lib/config-parser.js +109 -0
- package/src/dashboard/lib/connect-recommender.js +131 -0
- package/src/dashboard/lib/correlations-parser.js +231 -0
- package/src/dashboard/lib/daily-note-resolver.js +211 -0
- package/src/dashboard/lib/date-utils.js +35 -0
- package/src/dashboard/lib/distribution-parser.js +78 -0
- package/src/dashboard/lib/dossier-parser.js +64 -0
- package/src/dashboard/lib/drift-history.js +83 -0
- package/src/dashboard/lib/drift-score.js +119 -0
- package/src/dashboard/lib/engine-health.js +170 -0
- package/src/dashboard/lib/engine-writer.js +1685 -0
- package/src/dashboard/lib/execution-plan.js +125 -0
- package/src/dashboard/lib/experiments-parser.js +429 -0
- package/src/dashboard/lib/feed-parser.js +294 -0
- package/src/dashboard/lib/forked-future.js +60 -0
- package/src/dashboard/lib/goal-forecast.js +304 -0
- package/src/dashboard/lib/goals-parser.js +67 -0
- package/src/dashboard/lib/health-protocols-parser.js +133 -0
- package/src/dashboard/lib/hook-activity.js +48 -0
- package/src/dashboard/lib/hook-indexer.js +169 -0
- package/src/dashboard/lib/hourly-activity-parser.js +143 -0
- package/src/dashboard/lib/identity-parser.js +85 -0
- package/src/dashboard/lib/ingestion.js +418 -0
- package/src/dashboard/lib/library-indexer-v2.js +212 -0
- package/src/dashboard/lib/library-indexer.js +105 -0
- package/src/dashboard/lib/log-activation.sh +61 -0
- package/src/dashboard/lib/memory-curator.js +97 -0
- package/src/dashboard/lib/memory-gardener.js +177 -0
- package/src/dashboard/lib/memory-gepa.js +102 -0
- package/src/dashboard/lib/memory-index.js +470 -0
- package/src/dashboard/lib/memory-rerank.js +72 -0
- package/src/dashboard/lib/memory-text.js +136 -0
- package/src/dashboard/lib/metrics-log-parser.js +76 -0
- package/src/dashboard/lib/moves-usage-parser.js +184 -0
- package/src/dashboard/lib/onboarding-forge.js +70 -0
- package/src/dashboard/lib/override-outcome-parser.js +68 -0
- package/src/dashboard/lib/override-summary.js +73 -0
- package/src/dashboard/lib/paths.js +192 -0
- package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
- package/src/dashboard/lib/phantom-strategy.js +129 -0
- package/src/dashboard/lib/project-scanner.js +121 -0
- package/src/dashboard/lib/promise-wall.js +88 -0
- package/src/dashboard/lib/record-score.js +173 -0
- package/src/dashboard/lib/redaction.js +140 -0
- package/src/dashboard/lib/refusal-parser.js +44 -0
- package/src/dashboard/lib/regenerate-manifest.js +206 -0
- package/src/dashboard/lib/rewind-snapshots.js +689 -0
- package/src/dashboard/lib/roast-wall-parser.js +200 -0
- package/src/dashboard/lib/run-registry.js +226 -0
- package/src/dashboard/lib/safe-write.js +63 -0
- package/src/dashboard/lib/session-log-parser.js +145 -0
- package/src/dashboard/lib/session-time-parser.js +158 -0
- package/src/dashboard/lib/skill-index.js +171 -0
- package/src/dashboard/lib/skill-indexer.js +118 -0
- package/src/dashboard/lib/skill-recommender.js +689 -0
- package/src/dashboard/lib/strategy-parser.js +245 -0
- package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
- package/src/dashboard/lib/streak-parser.js +95 -0
- package/src/dashboard/lib/suggested-now.js +254 -0
- package/src/dashboard/lib/tool-awareness.js +125 -0
- package/src/dashboard/lib/transcript-parser.js +331 -0
- package/src/dashboard/lib/vault-graph-parser.js +205 -0
- package/src/dashboard/lib/view-generator.js +163 -0
- package/src/dashboard/lib/voice-dna-parser.js +57 -0
- package/src/dashboard/lib/walkthrough-script.js +140 -0
- package/src/dashboard/lib/workflow-graph-parser.js +170 -0
- package/src/dashboard/lib/workflow-library-parser.js +146 -0
- package/src/dashboard/server.js +2024 -0
- package/src/dashboard/shortcut.js +0 -0
- package/src/dashboard/static/setup-poc.html +306 -0
- package/src/dashboard/static/walkthrough-poc.html +580 -0
- package/src/dashboard/styles.css +1201 -0
- package/src/dashboard/templates/index.html +278 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hero.png +0 -0
- package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +157 -0
- package/src/dashboard/ui/dist/assets/index-BP--M69H.css +1 -0
- package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +34 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
- package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
- package/src/dashboard/ui/dist/icon.svg +4 -0
- package/src/dashboard/ui/dist/index.html +18 -0
- package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
- package/src/dashboard/ui/dist/registerSW.js +1 -0
- package/src/dashboard/ui/dist/sw.js +1 -0
- 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 };
|