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,1541 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+ const {
7
+ dashboardOutDir,
8
+ dashboardIndexPath,
9
+ dashboardBuildLogPath
10
+ } = require("./lib/paths");
11
+
12
+ const { parseActiveContext } = require("./lib/active-context-parser");
13
+ const { parseAutoLearnings } = require("./lib/auto-learnings-parser");
14
+ const { loadPatternManifest } = require("./lib/pattern-manifest-loader");
15
+ const { readStats: readActivationStats } = require("./lib/activation-log");
16
+ const { scanProjects } = require("./lib/project-scanner");
17
+ const { computeEngineHealth } = require("./lib/engine-health");
18
+ const { resolveDailyNotes } = require("./lib/daily-note-resolver");
19
+ const { parseIdentity } = require("./lib/identity-parser");
20
+ const { parseStrategy } = require("./lib/strategy-parser");
21
+ const { parseGoals } = require("./lib/goals-parser");
22
+ const { computeSuggestions } = require("./lib/suggested-now");
23
+ const { indexLibrary } = require("./lib/library-indexer");
24
+ const { parseVoiceDna } = require("./lib/voice-dna-parser");
25
+ const { parseMetricsLog } = require("./lib/metrics-log-parser");
26
+ const { parseHealthProtocols } = require("./lib/health-protocols-parser");
27
+ const { detectAgents } = require("./lib/agent-detector");
28
+ const { readHookActivity } = require("./lib/hook-activity");
29
+ const { parseDossier } = require("./lib/dossier-parser");
30
+ const driftScore = require("./lib/drift-score");
31
+ const driftHistory = require("./lib/drift-history");
32
+ const phantomStrategy = require("./lib/phantom-strategy");
33
+ const promiseWall = require("./lib/promise-wall");
34
+ const forkedFuture = require("./lib/forked-future");
35
+ const workflowLibrary = require("./lib/workflow-library-parser");
36
+ const hourlyActivity = require("./lib/hourly-activity-parser");
37
+ const sessionTime = require("./lib/session-time-parser");
38
+ // Wave 2 — wiring previously-built parsers into StateJson.
39
+ const strategyParser = require("./lib/strategy-parser");
40
+ const identityParser = require("./lib/identity-parser");
41
+ const goalsParser = require("./lib/goals-parser");
42
+ const projectScanner = require("./lib/project-scanner");
43
+ const metricsLog = require("./lib/metrics-log-parser");
44
+ const libraryIndexer = require("./lib/library-indexer");
45
+ // Wave 5 — newest parsers
46
+ const distributionParser = require("./lib/distribution-parser");
47
+ const streakParser = require("./lib/streak-parser");
48
+ const overrideOutcomeParser = require("./lib/override-outcome-parser");
49
+ const refusalParser = require("./lib/refusal-parser");
50
+ const { normalizeOverrides, computeOverrideSummary } = require("./lib/override-summary");
51
+ const { computeGoalForecast, parseDomainProgress } = require("./lib/goal-forecast");
52
+ const { computeExecutionPlan } = require("./lib/execution-plan");
53
+ const antiIdentityDetector = require("./lib/anti-identity-detector");
54
+ const vaultGraphParser = require("./lib/vault-graph-parser");
55
+
56
+ // v2 dashboard indexers — net additions for the holistic rebuild.
57
+ // Surfaces Skills (60+ AI skill packs), Hooks (~23 scripts), CLI usage,
58
+ // and a richer Library index with file paths + frontmatter + previews.
59
+ const { indexSkills } = require("./lib/skill-indexer");
60
+ const { indexHooks } = require("./lib/hook-indexer");
61
+ const { parseCliUsage } = require("./lib/cli-usage-parser");
62
+ // v6 W1 parsers (Wave 1 foundation)
63
+ const movesUsageParser = require("./lib/moves-usage-parser");
64
+ const feedParser = require("./lib/feed-parser");
65
+ const rewindSnapshots = require("./lib/rewind-snapshots");
66
+ const recordScoreLib = require("./lib/record-score");
67
+ const { indexLibraryV2 } = require("./lib/library-indexer-v2");
68
+
69
+ // Execution Protocol parser — pulls "Daily floor" / "Daily minimum output"
70
+ // items per domain from Strategy.md (Work / Vitality / Faith). This is the
71
+ // SOURCE OF TRUTH for the Briefing route's Execution Protocol checkboxes.
72
+ const { parseStrategyProtocol } = require("./lib/strategy-protocol-parser");
73
+
74
+ // Lab parser — walks 02_Areas/Engine/Experiments/*.md and emits the
75
+ // hypothesis register: active experiments, KPI bars, sample progress,
76
+ // friction chips. Drives the /lab route.
77
+ const { buildExperiments } = require("./lib/experiments-parser");
78
+
79
+ function ensureDir(p) { fs.mkdirSync(p, { recursive: true }); }
80
+
81
+ function readTemplate(filename) {
82
+ const tplPath = path.join(__dirname, "templates", filename);
83
+ return fs.readFileSync(tplPath, "utf8");
84
+ }
85
+
86
+ function copyStatic(outDir) {
87
+ const cssSrc = path.join(__dirname, "styles.css");
88
+ const jsSrc = path.join(__dirname, "dashboard.js");
89
+ if (fs.existsSync(cssSrc)) fs.copyFileSync(cssSrc, path.join(outDir, "styles.css"));
90
+ if (fs.existsSync(jsSrc)) fs.copyFileSync(jsSrc, path.join(outDir, "dashboard.js"));
91
+ }
92
+
93
+ function escape(s) {
94
+ if (s === null || s === undefined || s === "") return "—";
95
+ return String(s).replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" }[c]));
96
+ }
97
+
98
+ function rawHtml(s) { return s == null ? "" : String(s); }
99
+
100
+ function formatTime(d) { return `${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`; }
101
+ function formatDate(d) {
102
+ const days = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
103
+ const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
104
+ return `${days[d.getDay()]} ${months[d.getMonth()]} ${d.getDate()}`;
105
+ }
106
+
107
+ function frictionClass(level) {
108
+ const num = parseInt(String(level || "L0").replace(/[^\d]/g,""), 10) || 0;
109
+ if (num >= 3) return "l3";
110
+ if (num >= 2) return "l2";
111
+ if (num >= 1) return "l1";
112
+ return "l0";
113
+ }
114
+
115
+ function frictionPct(level) {
116
+ const num = parseInt(String(level || "L0").replace(/[^\d]/g,""), 10) || 0;
117
+ return Math.min(100, num * 33);
118
+ }
119
+
120
+ async function build({ vault, version }) {
121
+ const startedAt = Date.now();
122
+ const errors = [];
123
+
124
+ function tryParse(name, fn, fallback = null) {
125
+ try { return fn(); } catch (e) { errors.push(`[${name}] ${e.message}`); return fallback; }
126
+ }
127
+
128
+ // Parse all data sources
129
+ const ac = tryParse("active-context", () => parseActiveContext(vault), { available: false });
130
+ const al = tryParse("auto-learnings", () => parseAutoLearnings(vault), { available: false, entries: [], stats: {}, activePatternsConf3Plus: [], growingPatterns: [], escalatedPatterns: [] });
131
+ const manifest = tryParse("pattern-manifest", () => loadPatternManifest(), { available: false });
132
+ const projects = tryParse("projects", () => scanProjects(vault, { limit: 8 }), { available: false, projects: [] });
133
+ const engineHealth = tryParse("engine-health", () => computeEngineHealth(vault), null);
134
+ const daily = tryParse("daily-note", () => resolveDailyNotes(vault, { lookback: 30 }), null);
135
+ const identity = tryParse("identity", () => parseIdentity(vault), { available: false });
136
+ const strategy = tryParse("strategy", () => parseStrategy(vault), { available: false });
137
+ const goals = tryParse("goals", () => parseGoals(vault), { available: false });
138
+ const library = tryParse("library", () => indexLibrary(vault), { available: false });
139
+ const voiceDna = tryParse("voice-dna", () => parseVoiceDna(vault), { available: false });
140
+ const metrics = tryParse("metrics", () => parseMetricsLog(vault), { available: false });
141
+ const healthProtocols = tryParse("health-protocols", () => parseHealthProtocols(vault), { available: false, protocols: [] });
142
+ const agents = tryParse("agents", () => detectAgents(), { available: false, agents: [] });
143
+ const hookActivity = tryParse("hook-activity", () => readHookActivity(), { available: false, recentMarkers: [] });
144
+ const dossier = tryParse("dossier", () => parseDossier(vault), { available: false });
145
+
146
+ // V5 wiring (wishlist #49 — Codex viral pick #1). Parser already shipped
147
+ // but the UI module + state.json field never landed. Walks Daily Notes
148
+ // for FLAG/VERDICT/ROAST markers + Auto_Learnings escalations.
149
+ const { compute: computeRoastWall } = require("./lib/roast-wall-parser");
150
+ const roastWall = tryParse(
151
+ "roast-wall",
152
+ () => computeRoastWall({ vault, autoLearnings: al, daysLookback: 60 }),
153
+ { available: false, count: 0, roasts: [], stats: { high: 0, med: 0, low: 0 } }
154
+ );
155
+
156
+ // Phase 41.7 — Workflow read/write graph. Scans src/workflows/*.md for
157
+ // reads/writes annotations + engine file references; builds the
158
+ // directed graph powering Library → Workflow Graph tab.
159
+ const { scan: scanWorkflowGraph } = require("./lib/workflow-graph-parser");
160
+ const bundleRoot = path.join(__dirname, "..", "..");
161
+ const workflowGraph = tryParse(
162
+ "workflow-graph",
163
+ () => scanWorkflowGraph({ bundleRoot }),
164
+ { available: false, workflowCount: 0, workflows: [], edges: [], files: [] }
165
+ );
166
+
167
+ // Black Box Correlations (V5_CLOSED_LOOP wishlist). Pearson r between
168
+ // Battery signals (sleep/protein/energy) and output signals
169
+ // (tasksDone/sessionCount/energy). Honest empty states when data sparse.
170
+ const { compute: computeCorrelations } = require("./lib/correlations-parser");
171
+ const correlations = tryParse(
172
+ "correlations",
173
+ () => computeCorrelations({ vault, windowDays: 30 }),
174
+ { available: false, reason: "parser_error" }
175
+ );
176
+
177
+ const now = new Date();
178
+
179
+ // Audit pass 2 (find-bugs #4): snoozes are loaded later into state.json
180
+ // but computeSuggestions ran without them, so a snoozed workflow stayed
181
+ // pinned to suggestedNow[0] until the rebuild after expiry. Load snoozes
182
+ // here and pass through so live snoozes filter out of recommendations.
183
+ const snoozeList = (() => {
184
+ try {
185
+ const snoozePath = path.join(os.homedir(), ".mover", "dashboard", "snoozes.json");
186
+ if (!fs.existsSync(snoozePath)) return [];
187
+ const raw = JSON.parse(fs.readFileSync(snoozePath, "utf8"));
188
+ return Array.isArray(raw) ? raw : [];
189
+ } catch (_) { return []; }
190
+ })();
191
+ const suggestions = tryParse("suggested-now", () => computeSuggestions({
192
+ now, ac, manifest, daily, engineHealth, strategy, projects, snoozes: snoozeList
193
+ }, { limit: 3 }), []);
194
+
195
+ // Compute "Today's Call" from energy + Single Test
196
+ const todayCall = computeTodayCall({ ac, manifest, engineHealth, friction: manifest ? manifest.frictionLevel : "L0" });
197
+
198
+ // Days to Single Test target
199
+ const daysToTest = computeDaysToTarget(strategy, manifest);
200
+
201
+ // Compute today's progress percentage
202
+ const todayProgress = computeTodayProgress(daily);
203
+ const studioVerdict = computeStudioVerdict({
204
+ todayCall,
205
+ manifest,
206
+ engineHealth,
207
+ daysToTest,
208
+ strategy
209
+ });
210
+
211
+ // Render template
212
+ const template = readTemplate("index.html");
213
+
214
+ const tokens = {
215
+ // Header
216
+ header_date: formatDate(now),
217
+ header_time: formatTime(now),
218
+ header_completion: dailyCompletionString(daily),
219
+ header_streak: daily ? String(daily.streak || 0) : "—",
220
+ header_friction: manifest ? (manifest.frictionLevel || "L0") : "—",
221
+ header_friction_class: frictionClass(manifest ? manifest.frictionLevel : "L0"),
222
+ header_engine_health: engineHealth ? engineHealth.overallStatus : "—",
223
+ header_engine_health_class: engineHealth ? engineHealth.overallStatus.toLowerCase() : "unknown",
224
+ header_days_to_test: daysToTest != null ? String(daysToTest) : "—",
225
+ studio_verdict_title: escape(studioVerdict.title),
226
+ studio_verdict_subline: escape(studioVerdict.subline),
227
+
228
+ // Hero
229
+ hero_archetype: identity && identity.archetype ? escape(identity.archetype.split("(")[0].trim()) : "Mover OS Operator",
230
+ hero_law: identity && identity.theLaw ? escape(identity.theLaw.replace(/^["']|["']$/g, "")) : "your AI knows you",
231
+ hero_core_drive: identity && identity.coreDrive ? escape(identity.coreDrive.split("(")[0].trim()) : "—",
232
+ hero_fatal_flaw: identity && identity.fatalFlaw ? escape(identity.fatalFlaw.split("(")[0].trim()) : "—",
233
+ hero_single_test: manifest && manifest.singleTest ? escape(manifest.singleTest) : (strategy && strategy.singleTest ? escape(strategy.singleTest) : "Define a Single Test in Strategy.md"),
234
+ hero_sacrifice: manifest && manifest.sacrifice ? escape(shortenForCallout(manifest.sacrifice)) : "—",
235
+
236
+ // Today's Call
237
+ today_call_class: todayCall.class,
238
+ today_call_status: todayCall.status,
239
+ today_call_text: escape(todayCall.text),
240
+ today_progress_pct: todayProgress.pct,
241
+ ring_offset: String(todayProgress.ringOffset),
242
+
243
+ // Strategy header
244
+ strategy_target_date: strategy && strategy.targetDate ? escape(strategy.targetDate) : "no date",
245
+
246
+ // Suggested Now
247
+ suggested_now_cards: rawHtml(renderSuggestedNow(suggestions, manifest)),
248
+
249
+ // Tactical
250
+ tactical_focus: shortenForMetric(extractFromAC(ac, "primary_metric") || extractFromAC(ac, "today_s_focus")) || "—",
251
+ tactical_hard_thing: shortenForMetric(extractFromAC(ac, "active_sacrifices") || extractFromAC(ac, "hard_thing"), 100) || "—",
252
+ tactical_floor: shortenForMetric(extractFromAC(ac, "current_floors") || extractFromAC(ac, "floor"), 100) || "—",
253
+ tactical_mode: shortenForMetric(extractFromAC(ac, "oscillation_trend") || extractFromAC(ac, "mode")) || "—",
254
+
255
+ // Energy
256
+ energy_battery: shortBattery(ac) || "—",
257
+ energy_zone: shortZone(ac) || "—",
258
+ energy_oscillation: shortOscillation(ac) || "—",
259
+ energy_session_age: ac && ac.workflowState && ac.workflowState.log_last_run ? humanizeTimeAgo(new Date(ac.workflowState.log_last_run), now) : "—",
260
+
261
+ // Patterns
262
+ patterns_list: rawHtml(renderPatterns(al)),
263
+ patterns_stat_count: al && al.activePatternsConf3Plus ? String(al.activePatternsConf3Plus.length) : "0",
264
+ patterns_stat_growing: al && al.growingPatterns ? String(al.growingPatterns.length) : "0",
265
+ patterns_stat_escalated: al && al.escalatedPatterns ? String(al.escalatedPatterns.length) : "0",
266
+
267
+ // Friction
268
+ friction_level: manifest ? (manifest.frictionLevel || "L0") : "L0",
269
+ friction_class: frictionClass(manifest ? manifest.frictionLevel : "L0"),
270
+ friction_pct: String(frictionPct(manifest ? manifest.frictionLevel : "L0")),
271
+ friction_zombie_count: manifest ? String(manifest.zombieCount || 0) : "0",
272
+ friction_override_count: manifest ? recentOverrideCount(manifest.overrideHistory, 14) : "0",
273
+ friction_zombies: manifest && manifest.zombieTasks && manifest.zombieTasks.length > 0
274
+ ? manifest.zombieTasks.slice(0, 3).map(t => escape(t)).join(" · ")
275
+ : "no zombie tasks",
276
+
277
+ // Vitality
278
+ vitality_hrv: metrics && metrics.hrvLatest ? metrics.hrvLatest + " ms" : (extractFromAC(ac, "hrv") || "—"),
279
+ vitality_sleep: metrics && metrics.sleepLatest ? metrics.sleepLatest + " h" : "—",
280
+ vitality_rhr: metrics && metrics.rhrLatest ? metrics.rhrLatest + " bpm" : "—",
281
+ vitality_weight: metrics && metrics.weightLatest ? metrics.weightLatest + " kg" : "—",
282
+ health_protocols_block: rawHtml(renderHealthProtocols(healthProtocols) || renderHealthProtocolEmpty()),
283
+
284
+ // Engine Health
285
+ engine_health_items: rawHtml(renderEngineHealth(engineHealth)),
286
+
287
+ // Agents
288
+ agents_grid: rawHtml(renderAgentsGrid(agents)),
289
+ agents_summary: agents && agents.installedCount ? `${agents.installedCount} installed · ${agents.runnableCount} runnable · ${agents.hookSupportedCount} hook-enabled` : "no agents detected",
290
+
291
+ // Streaks
292
+ streak_current: daily ? String(daily.streak || 0) : "—",
293
+ streak_month: daily ? String(daily.totalThisMonth || 0) : "—",
294
+ streak_year: daily ? String(daily.totalThisYear || 0) : "—",
295
+ heatmap: rawHtml(renderHeatmap(daily)),
296
+
297
+ // Projects
298
+ projects_list: rawHtml(renderProjects(projects)),
299
+
300
+ // Strategy
301
+ strategy_version: strategy && strategy.version ? "V" + escape(strategy.version) : "—",
302
+ strategy_updated: strategy && strategy.lastUpdatedField ? escape(strategy.lastUpdatedField) : "—",
303
+ strategy_hypothesis: strategy && strategy.hypothesisBody ? escape(extractFirstSentences(strategy.hypothesisBody, 2)) : "—",
304
+ strategy_validation: strategy && strategy.validationCriteria ? escape(strategy.validationCriteria) : "—",
305
+ strategy_fail: strategy && strategy.failCondition ? escape(strategy.failCondition) : "—",
306
+
307
+ // Goals
308
+ goals_list: rawHtml(renderGoals(goals)),
309
+
310
+ // Library
311
+ library_counts: rawHtml(renderLibraryCounts(library)),
312
+ library_recent: rawHtml(renderLibraryRecent(library)),
313
+
314
+ // Entities
315
+ entities_grid: rawHtml(renderEntities(library)),
316
+
317
+ // Voice DNA
318
+ voice_samples: voiceDna && voiceDna.totalSamples ? String(voiceDna.totalSamples) : "0",
319
+ voice_banned: voiceDna && voiceDna.bannedPhrases ? String(voiceDna.bannedPhrases.length) : "0",
320
+ voice_traits: voiceDna && voiceDna.available
321
+ ? renderVoiceTraits(voiceDna)
322
+ : "no voice dna file",
323
+
324
+ // Identity
325
+ identity_evolved: identity && identity.evolvedSelf ? escape(extractFirstSentences(identity.evolvedSelf, 2)) : (identity && identity.theLaw ? escape(identity.theLaw) : "—"),
326
+ identity_anti: identity && identity.antiIdentityBody ? escape(extractFirstSentences(identity.antiIdentityBody, 2)) : "—",
327
+
328
+ // Active_Context lists
329
+ backlog_list: rawHtml(renderBulletList((ac && ac.backlog) || [])),
330
+ waiting_list: rawHtml(renderBulletList((ac && ac.waitingFor) || [])),
331
+ commitments_list: rawHtml(renderBulletList((ac && ac.commitments) || [])),
332
+ override_list: rawHtml(renderOverrides(manifest)),
333
+
334
+ // Hook activity
335
+ hook_stats: rawHtml(renderHookStats(hookActivity)),
336
+ hook_recent: rawHtml(renderHookRecent(hookActivity)),
337
+
338
+ // Dossier
339
+ dossier_list: rawHtml(renderDossier(dossier)),
340
+
341
+ // Footer
342
+ build_time: formatTime(now),
343
+ version: version || "unknown",
344
+ vault_short: vault ? path.basename(vault) : "—"
345
+ };
346
+
347
+ let html = template;
348
+ for (const [key, val] of Object.entries(tokens)) {
349
+ // Audit M8 — keys are developer-controlled today, but escaping
350
+ // regex metacharacters before embedding into `new RegExp(...)`
351
+ // prevents ReDoS / mis-replacement if any key ever contains a `.`,
352
+ // `*`, `+`, `(`, etc. (e.g. a parser leaks a user field into the
353
+ // tokens map).
354
+ const safeKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
355
+ html = html.replace(new RegExp("{{\\s*" + safeKey + "\\s*}}", "g"), val == null ? "—" : String(val));
356
+ }
357
+ // Replace any remaining tokens
358
+ html = html.replace(/{{\s*[a-z_]+\s*}}/g, "—");
359
+
360
+ // Write output
361
+ const outDir = dashboardOutDir();
362
+ ensureDir(outDir);
363
+ fs.writeFileSync(dashboardIndexPath(), html, "utf8");
364
+ copyStatic(outDir);
365
+
366
+ if (errors.length > 0) {
367
+ const log = `[${new Date().toISOString()}] errors=${errors.length}\n` + errors.map(e => " - " + e).join("\n") + "\n";
368
+ fs.appendFileSync(dashboardBuildLogPath(), log);
369
+ }
370
+
371
+ const builtAt = new Date();
372
+
373
+ // ───────── StateJson — the typed contract the React UI reads ─────────
374
+ // Matches src/dashboard/ui/src/lib/types.ts. Single source of truth for v3.
375
+ // Per-section fallbacks keep the API alive even when individual parsers fail.
376
+
377
+ // Compute phantom rows up front so drift-score can incorporate them as
378
+ // its "contradictions" component (Codex v3-fidelity #10 — 5th breakdown axis).
379
+ const phantomRowsForDrift = phantomRowsForState(strategy, al, manifest);
380
+ // Look up yesterday's persisted value to compute a real delta (was always 0
381
+ // because we passed yesterdayScore: null). drift-history.js stores one
382
+ // snapshot per calendar day in ~/.mover/drift-history.json.
383
+ const driftHistEntries = tryParse("drift-history-read", () => driftHistory.getHistory(90), []);
384
+ const yesterdayKey = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
385
+ const yesterdayEntry = driftHistEntries.find(e => e.date === yesterdayKey);
386
+ // Distribution computed up-front so drift derives the gap from REAL per-day
387
+ // counts instead of a fabricated default. [false-zero discipline, T261]
388
+ const distributionForStateJson = tryParse("distribution", () => distributionParser.compute({ vault, daysLookback: 365 }), { available: false, days: [], todayCount: null, total7d: null, total30d: null, delta7d: null });
389
+ const drift = tryParse("drift-score", () => driftScore.compute({
390
+ manifest,
391
+ activeContext: ac,
392
+ engineHealth: engineHealthForState(engineHealth),
393
+ distribution: distributionForStateJson,
394
+ todayPlanRanDistribution: !!(daily && daily.today && daily.today.exists),
395
+ yesterdayScore: yesterdayEntry ? yesterdayEntry.value : null,
396
+ phantomCount: phantomRowsForDrift.length,
397
+ }), null);
398
+ // Persist today's value back to history so tomorrow's run has a delta
399
+ // and the dashboard sparkline gets a real time-series.
400
+ if (drift && Number.isFinite(drift.value)) {
401
+ tryParse("drift-history-write", () => driftHistory.recordSnapshot({
402
+ value: drift.value,
403
+ components: drift.components,
404
+ }), null);
405
+ }
406
+ // Re-read after the write so the history array we expose includes today.
407
+ const driftHistoryArr = tryParse("drift-history-final", () => driftHistory.getHistory(30), []);
408
+ if (drift) drift.history = driftHistoryArr;
409
+
410
+ const forked = tryParse("forked-future", () => forkedFuture.compute({
411
+ driftScore: drift,
412
+ recent7d: [],
413
+ activePatterns: (al && al.activePatternsConf3Plus) || [],
414
+ horizonDays: 14,
415
+ }), null);
416
+
417
+ // distributionForStateJson computed earlier (before drift). [T261]
418
+ const overrideEntries = normalizeOverrides(manifest);
419
+ const overrideSummary = computeOverrideSummary(overrideEntries, builtAt);
420
+ const engineHealthState = engineHealthForState(engineHealth);
421
+ const goalForecast = tryParse("goal-forecast", () => computeGoalForecast({
422
+ strategy,
423
+ activeContext: ac,
424
+ distribution: distributionForStateJson,
425
+ engineHealth: engineHealthState,
426
+ daily,
427
+ now: builtAt
428
+ }), { available: false, adjustments: [], workflows: [] });
429
+ // Per-domain measurement forecast — dispatches each domain on its declared shape
430
+ // (single | chain | scalar | streak), honest-null until that domain logs data.
431
+ const domainForecasts = tryParse("domain-forecasts", () => {
432
+ const decl = (strategy && strategy.measurement) || {};
433
+ return {
434
+ work: parseDomainProgress({ domain: "work", decl: decl.work, goalForecast, metricsLog: metrics }),
435
+ vitality: parseDomainProgress({ domain: "vitality", decl: decl.vitality, goalForecast, metricsLog: metrics }),
436
+ faith: parseDomainProgress({ domain: "faith", decl: decl.faith, goalForecast, metricsLog: metrics }),
437
+ };
438
+ }, { work: { available: false, type: null }, vitality: { available: false, type: null }, faith: { available: false, type: null } });
439
+ const executionPlan = tryParse("execution-plan", () => computeExecutionPlan({
440
+ suggestions,
441
+ goalForecast,
442
+ healthProtocols,
443
+ projects,
444
+ engineHealth: engineHealthState,
445
+ daily,
446
+ overrides: overrideSummary,
447
+ }), { available: false, items: [], capacityScore: 0, checked: 0, total: 0, lanes: [] });
448
+
449
+ // What to call the operator: config userName (user-editable in Configure)
450
+ // wins, then the Identity_Prime H1 name written by onboarding. Never a
451
+ // folder or account name — an honest null greets namelessly instead.
452
+ // Plausibility gate: pre-4.7.9 onboarding could store a whole identity
453
+ // sentence as the name ("I'm a 24-year-old freelance photographer...").
454
+ // A greeting name reads as a callsign; anything sentence-shaped greets
455
+ // namelessly instead (wrong name is worse than no name).
456
+ const plausibleName = (s) =>
457
+ typeof s === "string" && s.trim() && s.trim().length <= 32 &&
458
+ !/[.!?\n:;]/.test(s.trim()) && s.trim().split(/\s+/).length <= 3
459
+ ? s.trim() : null;
460
+ const cfgForName = tryParse("config", () => require("./lib/config-parser").read(), {});
461
+ const profileName =
462
+ plausibleName(cfgForName && cfgForName.userName) ||
463
+ plausibleName(identity && identity.name) || null;
464
+
465
+ const stateJson = {
466
+ builtAt: builtAt.toISOString(),
467
+ vaultPath: vault,
468
+ profileName,
469
+ version: version || "unknown",
470
+ engineHealth: engineHealthState,
471
+ activeContext: (function() {
472
+ // Inject typed active patterns from Auto_Learnings parser before mapping
473
+ const typed = ((al && al.activePatternsConf3Plus) || []).map(p => ({
474
+ id: p.name || p.id || "",
475
+ description: extractPatternHeadline(p),
476
+ confidence: typeof p.confidence === "number" ? p.confidence : 0,
477
+ status: p.status || "",
478
+ severity: p.severity || "",
479
+ polarity: p.polarity || "neutral",
480
+ category: p.category || "watching",
481
+ firstDetected: p.firstDetected || "",
482
+ lastUpdated: p.lastUpdated || "",
483
+ sourcePath: (al && al.filePath) || ""
484
+ }));
485
+ const acWithTyped = ac ? { ...ac, __activePatternsTyped: typed } : ac;
486
+ return activeContextForState(acWithTyped, manifest);
487
+ })(),
488
+ driftScore: drift,
489
+ forkedFuture: forked,
490
+ phantomRows: phantomRowsForDrift,
491
+ suggestedNow: (suggestions || []).map(s => ({
492
+ workflow: s.workflow || s.command || "",
493
+ reason: s.reason || s.explanation || "",
494
+ score: typeof s.score === "number" ? s.score : 0,
495
+ signalKey: s.signal || s.key || ""
496
+ })),
497
+ // V5.0 activation stats (Codex #3 wiring fix, 2026-05-24). Reads from
498
+ // ~/.mover/activation-events.jsonl. Local-only, telemetry-never.
499
+ // Cheap (~1ms typical) — re-read on every build.
500
+ activation: tryParse("activation-log", () => readActivationStats(), {
501
+ available: false,
502
+ installDays: 0,
503
+ dashboardOpens: 0,
504
+ dashboardOpens7d: 0,
505
+ workflowRuns: 0,
506
+ workflowRuns7d: 0,
507
+ logCompletions7d: 0,
508
+ distinctOpenDays: 0,
509
+ activated: false,
510
+ firstEventAt: null,
511
+ }),
512
+ agents: (agents && agents.agents ? agents.agents : []).map(a => ({
513
+ id: a.id || a.name || "",
514
+ name: a.name || a.id || "",
515
+ // agent-detector.js now intersects config-dir + binary-on-PATH. Pass through.
516
+ installed: !!a.installed,
517
+ runnable: !!a.runnable,
518
+ hookSupport: !!a.hookSupport,
519
+ detectedPath: a.configPath || a.path || a.detectedPath || undefined
520
+ })),
521
+ workflows: (() => {
522
+ try {
523
+ // bundleRoot = src/dashboard/build.js → ../../ = bundle root
524
+ const wl = workflowLibrary.indexWorkflows(path.resolve(__dirname, "..", ".."));
525
+ return (wl.workflows || []).map(w => ({
526
+ name: w.name,
527
+ description: w.description || "",
528
+ risk: w.risk || "LOW",
529
+ target: w.target || "",
530
+ reads: w.reads || [],
531
+ writes: w.writes || [],
532
+ handsOffTo: w.handsOffTo || [],
533
+ // v6 SURFACE 1 — Say-it card copy + structured io contract
534
+ does: w.does,
535
+ long: w.long,
536
+ needsYou: w.needsYou,
537
+ io: w.io ?? null
538
+ }));
539
+ } catch (e) {
540
+ errors.push(`[workflow-library] ${e.message}`);
541
+ return [];
542
+ }
543
+ })(),
544
+ // Wave 2 — newly-wired parsers
545
+ strategy: (() => {
546
+ const out = tryParse("strategy", () => strategyParser.parseStrategy(vault), { available: false });
547
+ if (!out || !out.available) return { available: false };
548
+ return {
549
+ available: true,
550
+ version: out.version || null,
551
+ primeObjective: out.primeObjective || null,
552
+ stretchTarget: out.stretchTarget || null,
553
+ currentState: out.currentState || null,
554
+ singleTest: out.singleTest || null,
555
+ targetDate: out.targetDate || null,
556
+ measurement: out.measurement || null,
557
+ lastUpdated: out.lastUpdated ? out.lastUpdated.toISOString() : (out.lastUpdatedField || null),
558
+ hypotheses: (out.hypotheses || []).map(h => ({
559
+ domain: h.domain,
560
+ body: (h.body || '').slice(0, 1200) // cap body so StateJson stays lean
561
+ }))
562
+ };
563
+ })(),
564
+ goalForecast,
565
+ domainForecasts,
566
+ executionPlan,
567
+ identity: (() => {
568
+ const out = tryParse("identity", () => identityParser.parseIdentity(vault), { available: false });
569
+ if (!out || !out.available) return { available: false };
570
+ return {
571
+ available: true,
572
+ archetype: out.archetype || null,
573
+ coreDrive: out.coreDrive || null,
574
+ fatalFlaw: out.fatalFlaw || null,
575
+ theLaw: out.theLaw || null,
576
+ antiIdentity: out.antiIdentity || null,
577
+ antiIdentityBody: out.antiIdentityBody || null,
578
+ psychFirewall: out.psychFirewall || null
579
+ };
580
+ })(),
581
+ // F19 — Anti-Identity Detector hits, cross-referencing patterns +
582
+ // overrides against the user's anti-identity statement.
583
+ antiIdentityHits: (() => {
584
+ const identity = tryParse("identity", () => identityParser.parseIdentity(vault), { available: false });
585
+ // ac is the parsed active-context. Patterns live in its activePatterns array.
586
+ const patterns = (ac && ac.activePatterns) || [];
587
+ return tryParse(
588
+ "anti-identity-detect",
589
+ () => antiIdentityDetector.detect({ identity, patterns, overrides: overrideEntries }),
590
+ { available: false }
591
+ );
592
+ })(),
593
+ // F39 — Vault wiki-link graph (distinct from PublicProofGraph which
594
+ // shows behavioral patterns). Walks every md file in the vault to
595
+ // build a node-edge map of [[wiki-links]]. Capped at 200 nodes for
596
+ // payload reasonableness. Connections route renders this.
597
+ vaultGraph: tryParse(
598
+ "vault-graph",
599
+ () => vaultGraphParser.buildVaultGraph(vault, { limit: 200 }),
600
+ { available: false, nodes: [], edges: [] }
601
+ ),
602
+ // Voice DNA — surface the REAL Voice_DNA.md calibration to the dashboard
603
+ // (the Library's "Your writing voice" panel). The parser already ran above
604
+ // (voiceDna); it was only feeding flat CLI strings, never the state tree,
605
+ // so the dashboard showed "harvest your voice" even though the file is
606
+ // already calibrated. honest-empty when the file is absent. No capped
607
+ // numbers leak out: sampleCount is the true total; bannedPhrases is capped
608
+ // in the parser so we don't surface it as a count.
609
+ voice: (() => {
610
+ if (!voiceDna || !voiceDna.available) return { available: false };
611
+ const traits = [];
612
+ if (voiceDna.lowercaseI) traits.push("lowercase 'i'");
613
+ if (voiceDna.commaSpliced) traits.push("comma-spliced");
614
+ if (voiceDna.usesBrother) traits.push('says "brother"');
615
+ if (voiceDna.urlInline) traits.push("inline urls");
616
+ if ((voiceDna.totalSamples || 0) > 0 && Number.isFinite(voiceDna.shortestSample)) {
617
+ traits.push(`${voiceDna.shortestSample}-${voiceDna.longestSample} char range`);
618
+ }
619
+ // Count writing rules + banned phrases UNCAPPED from the raw file (the
620
+ // parser caps bannedPhrases at 12, which would under-report). A voice is
621
+ // "calibrated" when the file defines rules / banned phrases / traits —
622
+ // sample passages are a bonus, not the bar (the owner's file has rules
623
+ // but no fenced samples, and is correctly already calibrated).
624
+ const raw = voiceDna.raw || "";
625
+ // Pull the actual bullet TEXT under a heading (until the next h2), so the
626
+ // Library can SHOW the voice (how it's defined + what it avoids), not just
627
+ // a status badge. Real lines only; never invented.
628
+ const sectionBullets = (headingRe) => {
629
+ const m = raw.match(headingRe);
630
+ if (!m) return [];
631
+ const rest = raw.slice(m.index + m[0].length);
632
+ const end = rest.search(/\n##\s/); // stop at the next h2
633
+ const section = end >= 0 ? rest.slice(0, end) : rest;
634
+ return (section.match(/^\s*[-*]\s+.+$/gm) || [])
635
+ .map((l) => l.replace(/^\s*[-*]\s+/, "").trim())
636
+ .filter((l) => l && !l.startsWith("#"));
637
+ };
638
+ const ruleLines = sectionBullets(/##\s*Writing Rules[^\n]*\n/i);
639
+ const bannedLines = sectionBullets(/##\s*Banned[^\n]*\n/i);
640
+ const rulesCount = ruleLines.length;
641
+ const bannedCount = bannedLines.length;
642
+ return {
643
+ available: true,
644
+ calibrated: rulesCount > 0 || bannedCount > 0 || (voiceDna.totalSamples || 0) > 0 || traits.length > 0,
645
+ lastUpdated: voiceDna.lastUpdated || null,
646
+ rulesCount,
647
+ bannedCount,
648
+ sampleCount: voiceDna.totalSamples || 0,
649
+ traits,
650
+ // a few REAL rules / banned phrases — the voice's actual definition
651
+ rules: ruleLines.slice(0, 6).map((s) => (s.length > 130 ? s.slice(0, 130).trim() + "…" : s)),
652
+ banned: bannedLines.slice(0, 10).map((s) => s.replace(/^["'""]|["'""]$/g, "").trim()),
653
+ // up to 3 real writing samples (trimmed) — literally the user's own voice
654
+ samples: (voiceDna.samples || [])
655
+ .filter((s) => s && s.trim().length > 0)
656
+ .slice(0, 3)
657
+ .map((s) => (s.length > 220 ? s.slice(0, 220).trim() + "…" : s.trim())),
658
+ path: voiceDna.filePath || null,
659
+ };
660
+ })(),
661
+ goals: (() => {
662
+ const out = tryParse("goals", () => goalsParser.parseGoals(vault), { available: false });
663
+ if (!out || !out.available) return { available: false, horizons: [] };
664
+ return {
665
+ available: true,
666
+ horizons: (out.horizons || []).slice(0, 5).map(h => ({
667
+ title: h.title || "",
668
+ body: (h.body || "").slice(0, 600)
669
+ }))
670
+ };
671
+ })(),
672
+ projects: (() => {
673
+ // limit 1000 = effectively all. The old 12 silently hid 20 of the
674
+ // owner's 32 projects from the Library shelf; he ruled: show all,
675
+ // split active/dormant (2026-07-12). Consumers that want fewer slice.
676
+ const out = tryParse("projects", () => projectScanner.scanProjects(vault, { limit: 1000 }), { available: false, projects: [] });
677
+ if (!out || !out.available) return { available: false, projects: [], totalCount: 0 };
678
+ return {
679
+ available: true,
680
+ totalCount: out.totalCount || 0,
681
+ activeCount: typeof out.activeCount === "number" ? out.activeCount : undefined,
682
+ projects: (out.projects || []).map(p => ({
683
+ name: p.name || "",
684
+ path: p.path || "",
685
+ planPath: p.planPath || "",
686
+ totalTasks: p.totalTasks || 0,
687
+ doneTasks: p.doneTasks || 0,
688
+ openTasks: p.openTasks || 0,
689
+ unverifiedTasks: p.unverifiedTasks || 0,
690
+ zombieTasks: p.zombieTasks || 0,
691
+ lastModified: p.lastModified ? p.lastModified.toISOString() : null,
692
+ stateExcerpt: p.stateExcerpt || null,
693
+ recentExecutionLog: (p.recentExecutionLog || []).slice(0, 3)
694
+ }))
695
+ };
696
+ })(),
697
+ metrics: (() => {
698
+ const out = tryParse("metrics-log", () => metricsLog.parseMetricsLog(vault), { available: false });
699
+ if (!out || !out.available) return { available: false };
700
+ return {
701
+ available: true,
702
+ sleepLatest: out.sleepLatest || null,
703
+ rhrLatest: out.rhrLatest || null,
704
+ weightLatest: out.weightLatest || null,
705
+ rolling30: out.rolling30 || null,
706
+ days: (out.days || []).slice(-30)
707
+ };
708
+ })(),
709
+ library: (() => {
710
+ const out = tryParse("library", () => libraryIndexer.indexLibrary(vault), { available: false });
711
+ if (!out || !out.available) return { available: false };
712
+ return {
713
+ available: true,
714
+ mocs: out.mocs || 0,
715
+ principles: out.principles || 0,
716
+ cheatsheets: out.cheatsheets || 0,
717
+ sops: out.sops || 0,
718
+ entities: out.entities || 0,
719
+ inputs: out.inputs || 0,
720
+ scripts: out.scripts || 0,
721
+ recentHarvests: (out.recentHarvests || []).slice(0, 5)
722
+ };
723
+ })(),
724
+ // v2 indexers (net additions for the holistic dashboard rebuild)
725
+ libraryV2: tryParse("library-v2", () => indexLibraryV2(vault), { available: false }),
726
+ skills: tryParse("skills", () => indexSkills(), { available: false, count: 0, byCategory: { system: [], specialist: [], domain: [] }, all: [] }),
727
+ hooks: tryParse("hooks", () => indexHooks(), { available: false, count: 0, byEvent: {}, registered: [], unregistered: [] }),
728
+ cliUsage: tryParse("cli-usage", () => parseCliUsage(), { available: false, commands: [] }),
729
+ // Briefing route's Execution Protocol — daily floor items per domain
730
+ // pulled from Strategy.md (not Daily Note tasks, which were the wrong
731
+ // source in the previous iteration).
732
+ strategyProtocol: tryParse("strategy-protocol", () => parseStrategyProtocol(vault), { available: false }),
733
+ experiments: tryParse("experiments", () => buildExperiments(vault), { available: false, all: [], active: [], past: [], byDomain: { work: [], vitality: [], faith: [] }, count: 0, activeCount: 0, pastCount: 0 }),
734
+ // v6 W1 — live work feed, moves-usage lighting, rewind keyframes, journal calibration gap.
735
+ // False-zero: every fallback matches the full shape; absent source -> available:false, never a fabricated 0.
736
+ feed: tryParse("feed", () => feedParser.compute({ vault, limit: 40 }), { available: false, caption: "", events: [], activeSessions: [] }),
737
+ movesUsage: tryParse("moves-usage", () => movesUsageParser.computeMovesUsage({ vault }), { available: false, source: "", totalRuns: null, byCommand: {} }),
738
+ rewind: tryParse("rewind", () => rewindSnapshots.buildRewind({ vault }), { available: false, keyframes: [], dailies: {}, gapBefore: "", gapAfter: "" }),
739
+ journalGap: (() => {
740
+ const ev = daily && daily.today && daily.today.journal ? daily.today.journal.evening : null;
741
+ const selfScore = ev && ev.score != null ? ev.score : null;
742
+ let rec = { score: null, why: null, basis: "not enough logged to score today" };
743
+ try { rec = recordScoreLib.computeRecordScore({ daily, distribution: distributionForStateJson }) || rec; } catch (_) {}
744
+ const recordScore = rec && rec.score != null ? rec.score : null;
745
+ // Trend reads the RAW resolver output (daily.recentNotes carries journal via
746
+ // analyzeNoteText); honest self-score history, empty on day 1.
747
+ const trend = (daily && daily.recentNotes ? daily.recentNotes : [])
748
+ .filter(n => n && n.journal && n.journal.evening && n.journal.evening.score != null)
749
+ .slice(0, 7)
750
+ .map(n => ({ date: n.date, score: n.journal.evening.score }));
751
+ return {
752
+ available: selfScore != null || recordScore != null,
753
+ selfScore,
754
+ recordScore,
755
+ gap: selfScore != null && recordScore != null ? selfScore - recordScore : null,
756
+ recordWhy: rec.why || null,
757
+ trend,
758
+ basis: rec.basis || null
759
+ };
760
+ })(),
761
+ // Wave 5 parsers
762
+ distribution: (() => {
763
+ return distributionForStateJson || { available: false, days: [], todayCount: null, total7d: null, total30d: null, delta7d: null };
764
+ })(),
765
+ streak: (() => {
766
+ // False-zero discipline (T261): the tryParse fallback must match the FULL
767
+ // StreakData shape — a bare { available:false } leaves days/currentStreak/
768
+ // coverage undefined and a consumer that reads them before checking
769
+ // `available` crashes (or worse, treats undefined as a clean streak).
770
+ const FULL_STREAK_FALLBACK = { available: false, days: [], currentStreak: 0, streakThroughYesterday: 0, todayExists: false, longestStreak: 0, totalDays: 0, coverage: 0 };
771
+ const out = tryParse("streak", () => streakParser.compute({ vault, daysLookback: 365 }), FULL_STREAK_FALLBACK);
772
+ return out || FULL_STREAK_FALLBACK;
773
+ })(),
774
+ overrideOutcomes: (() => {
775
+ try {
776
+ const out = tryParse("override-outcomes", () => overrideOutcomeParser.compute({ vault, overrides: overrideEntries.slice(-100), windowDays: 90 }), { available: false });
777
+ return out || { available: false, outcomes: [] };
778
+ } catch (_) { return { available: false, outcomes: [] }; }
779
+ })(),
780
+ overrideSummary,
781
+ // Core-loop TRIAL MODE (SCHEMA-SPEC v0.2 section 2, wired 2026-07-12):
782
+ // compares the legacy override count against override.logged events and
783
+ // logs movement to State/trial-deltas.jsonl. The UI keeps rendering the
784
+ // LEGACY values everywhere; this field only makes the gap observable.
785
+ // Best-effort: unmigrated vaults report available:false and nothing runs.
786
+ stateCoreTrial: (() => {
787
+ try {
788
+ const { runTrial } = require("./lib/state-core/trial");
789
+ return runTrial({ vault, manifestOverrideCount: overrideEntries.length });
790
+ } catch (_) { return { available: false, reason: "trial module unavailable" }; }
791
+ })(),
792
+ refusals: (() => {
793
+ // False-zero discipline (T261): full RefusalsData shape on parse failure
794
+ // so count/count7d/recent are never undefined under available:false.
795
+ const FULL_REFUSALS_FALLBACK = { available: false, count: 0, count7d: 0, delta7d: 0, recent: [] };
796
+ const out = tryParse("refusals", () => refusalParser.compute({ daysLookback: 30 }), FULL_REFUSALS_FALLBACK);
797
+ return out || FULL_REFUSALS_FALLBACK;
798
+ })(),
799
+ hourlyActivity: (() => {
800
+ // False-zero discipline (T261): full HourlyActivity shape on both parse
801
+ // failure and the !available guard — previous empty-state was missing
802
+ // daysScanned + peakCount, so PerformanceCurve read undefined for the
803
+ // "last N days" label and the peak-band math under available:false.
804
+ const FULL_HOURLY_FALLBACK = { available: false, daysScanned: 0, hours: [], peakHour: null, peakCount: 0, todayHours: [], events: [] };
805
+ const out = tryParse("hourly-activity", () => hourlyActivity.compute({ vault, daysLookback: 14 }), FULL_HOURLY_FALLBACK);
806
+ if (!out || !out.available) return FULL_HOURLY_FALLBACK;
807
+ return {
808
+ available: true,
809
+ daysScanned: out.daysScanned || 0,
810
+ hours: out.hours || [],
811
+ peakHour: out.peakHour,
812
+ peakCount: out.peakCount || 0,
813
+ todayHours: out.todayHours || [],
814
+ events: out.events || []
815
+ };
816
+ })(),
817
+ // REAL tracked time from Session Log start-end ranges (the honest core of
818
+ // "did hours"). NOT a per-domain split — sessions tag a [project], never a
819
+ // Work/Vitality/Faith domain, so a domain breakdown would be fabricated.
820
+ sessionTime: (() => {
821
+ const FULL_SESSIONTIME_FALLBACK = {
822
+ available: false, daysScanned: 0,
823
+ today: { minutes: 0, sessions: 0 },
824
+ last7d: { minutes: 0, sessions: 0, untimed: 0 },
825
+ last30d: { minutes: 0, sessions: 0, untimed: 0 },
826
+ byProject7d: [], longest: null,
827
+ src: "Dailies session-log start-end ranges",
828
+ };
829
+ const out = tryParse("session-time", () => sessionTime.compute({ vault, daysLookback: 30 }), FULL_SESSIONTIME_FALLBACK);
830
+ return out || FULL_SESSIONTIME_FALLBACK;
831
+ })(),
832
+ daily: (() => {
833
+ if (!daily) {
834
+ return {
835
+ available: false,
836
+ today: null,
837
+ streak: 0,
838
+ recentNotes: [],
839
+ totalThisMonth: 0,
840
+ totalThisYear: 0
841
+ };
842
+ }
843
+ return {
844
+ available: true,
845
+ today: daily.today ? {
846
+ date: daily.today.date || null,
847
+ path: daily.today.path || null,
848
+ exists: !!daily.today.exists,
849
+ tasksTotal: daily.today.tasksTotal || 0,
850
+ tasksDone: daily.today.tasksDone || 0,
851
+ // W12: per-task lines (text + checked) so Home checkboxes can write
852
+ // back through /api/checkbox/toggle. null on older parser output.
853
+ tasks: daily.today.tasks ?? null,
854
+ length: daily.today.length || 0,
855
+ // v6 SURFACE 1 — the real plan focus for the Say-it friction rule.
856
+ // Was dropped here; the parser computes it (daily-note-resolver).
857
+ focus: daily.today.focus ?? null,
858
+ // v6 W1 — today's-call + `## Journal` sub-anchors + plan/got. All
859
+ // false-zero: null when the section is absent, never a guessed value.
860
+ todaysCall: daily.today.todaysCall ?? null,
861
+ journal: daily.today.journal ?? null,
862
+ plan: daily.today.plan ?? null,
863
+ got: daily.today.got ?? null
864
+ } : null,
865
+ streak: daily.streak || 0,
866
+ recentNotes: (daily.recentNotes || []).slice(0, 14).map(n => ({
867
+ date: n.date,
868
+ exists: !!n.exists,
869
+ tasksTotal: n.tasksTotal || 0,
870
+ tasksDone: n.tasksDone || 0,
871
+ length: n.length || 0,
872
+ // v6 W1 — per-date journal + plan for the calibration trend + Rewind
873
+ journal: n.journal ?? null,
874
+ todaysCall: n.todaysCall ?? null,
875
+ plan: n.plan ?? null
876
+ })),
877
+ totalThisMonth: daily.totalThisMonth || 0,
878
+ totalThisYear: daily.totalThisYear || 0
879
+ };
880
+ })(),
881
+ healthProtocols: (() => {
882
+ if (!healthProtocols || !healthProtocols.available) return { available: false, protocols: [] };
883
+ return {
884
+ available: true,
885
+ protocols: healthProtocols.protocols.map(p => ({
886
+ title: p.title || "",
887
+ filePath: p.filePath || "",
888
+ lastUpdated: p.lastUpdated ? p.lastUpdated.toISOString() : null,
889
+ current: p.current ? {
890
+ heading: p.current.heading || "",
891
+ daysIntoPhase: p.daysIntoPhase != null ? p.daysIntoPhase : null,
892
+ daysRemaining: p.daysRemaining != null ? p.daysRemaining : null,
893
+ items: (p.current.items || []).map(it => ({ text: it.text, checked: !!it.checked })),
894
+ done: p.current.done || 0,
895
+ total: p.current.total || 0
896
+ } : null
897
+ }))
898
+ };
899
+ })(),
900
+ snoozes: (() => {
901
+ try {
902
+ const snoozePath = path.join(os.homedir(), ".mover", "dashboard", "snoozes.json");
903
+ if (!fs.existsSync(snoozePath)) return [];
904
+ const raw = JSON.parse(fs.readFileSync(snoozePath, "utf8"));
905
+ return Array.isArray(raw) ? raw : [];
906
+ } catch (_) { return []; }
907
+ })(),
908
+ roastWall: roastWall || { available: false, count: 0, roasts: [], stats: { high: 0, med: 0, low: 0 } },
909
+ workflowGraph: workflowGraph || { available: false, workflowCount: 0, workflows: [], edges: [], files: [] },
910
+ correlations: correlations || { available: false, reason: "parser_error" },
911
+ overrides: (() => {
912
+ try {
913
+ return overrideEntries.slice(-30);
914
+ } catch (_) { return []; }
915
+ })(),
916
+ // Strip absolute filesystem paths from error messages before they reach
917
+ // /api/state.json (code-bug-audit H5). Node's ENOENT errors include the
918
+ // full vault/home path which leaked filesystem layout to any browser tab.
919
+ errors: errors.map(msg => ({
920
+ source: msg.match(/^\[([^\]]+)\]/)?.[1] || "unknown",
921
+ message: msg.replace(/['"]?\/[^\s'":,]+/g, "[path]"),
922
+ }))
923
+ };
924
+
925
+ return {
926
+ builtAt,
927
+ durationMs: Date.now() - startedAt,
928
+ sectionCount: 6,
929
+ errors,
930
+ state: stateJson
931
+ };
932
+ }
933
+
934
+ // ───────── StateJson shape mappers ─────────
935
+
936
+ function engineHealthForState(engineHealth) {
937
+ if (!engineHealth) {
938
+ // No-blackbox: the scan couldn't run. Emit nulls + available:false so the UI
939
+ // shows "health unavailable", NOT a fabricated all-OK / 0-days-stale vault.
940
+ // [false-zero discipline, T261]
941
+ return {
942
+ available: false,
943
+ unavailableReason: "parser_error",
944
+ scannedAt: new Date().toISOString(),
945
+ strategy: { staleDays: null, status: null, lastUpdated: "" },
946
+ dossier: { staleDays: null, status: null, lastUpdated: "" },
947
+ goals: { status: null, expired: [] },
948
+ analyseDay: { overdueDays: null, status: null, lastRun: null },
949
+ weeklyReview: { overdueDays: null, status: null, lastRun: null }
950
+ };
951
+ }
952
+ const findItem = (key) => (engineHealth.items || []).find(i => i.file === key) || null;
953
+ const findWf = (lbl) => (engineHealth.workflowStaleness || []).find(w => w.label === lbl) || null;
954
+ // B1 fix: engine-health.js emits `file: "Strategy"` (capital, no .md) — see
955
+ // engine-health.js:45-53. Look up by THAT key first, then fall back to variants.
956
+ // A missing item now yields null (honest "no data"), never 0/"OK" — the old
957
+ // `|| 0` masked the 2026-05-14 "0D STALE on a weeks-old file" key-mismatch bug.
958
+ const strat = findItem("Strategy") || findItem("strategy") || findItem("Strategy.md") || {};
959
+ const doss = findItem("Mover_Dossier") || findItem("dossier") || findItem("Mover_Dossier.md") || findItem("Architect_Dossier.md") || {};
960
+ const ad = findWf("/analyse-day") || {};
961
+ const rw = findWf("/review-week") || {};
962
+ return {
963
+ available: true,
964
+ unavailableReason: null,
965
+ scannedAt: (engineHealth.scannedAt instanceof Date) ? engineHealth.scannedAt.toISOString() : String(engineHealth.scannedAt || ""),
966
+ strategy: { staleDays: strat.ageDays != null ? strat.ageDays : null, status: strat.status || null, lastUpdated: strat.mtime ? String(strat.mtime) : "" },
967
+ dossier: { staleDays: doss.ageDays != null ? doss.ageDays : null, status: doss.status || null, lastUpdated: doss.mtime ? String(doss.mtime) : "" },
968
+ goals: { status: (engineHealth.expiredGoals || []).length > 0 ? "RED" : "OK", expired: engineHealth.expiredGoals || [] },
969
+ analyseDay: { overdueDays: ad.ageDays != null ? ad.ageDays : null, status: ad.status || null, lastRun: ad.lastRun || null },
970
+ weeklyReview: { overdueDays: rw.ageDays != null ? rw.ageDays : null, status: rw.status || null, lastRun: rw.lastRun || null }
971
+ };
972
+ }
973
+
974
+ // Derive a concise headline from an Auto_Learnings pattern entry.
975
+ // Prefers the entry's "name" (header), falling back to first non-empty
976
+ // evidence line, then to a body excerpt.
977
+ function extractPatternHeadline(p) {
978
+ if (!p) return "";
979
+ if (p.name) return p.name;
980
+ if (p.evidence && p.evidence.length) {
981
+ const first = p.evidence[0];
982
+ return typeof first === "string" ? first : (first.text || first.summary || "");
983
+ }
984
+ return (p.body || "").split("\n").find(l => l.trim().length > 12) || "";
985
+ }
986
+
987
+ function activeContextForState(ac, manifest) {
988
+ if (!ac || !ac.available) {
989
+ return {
990
+ available: false, lastUpdated: "", singleTest: null, sacrifice: [],
991
+ frictionLevel: null, zombieCount: null,
992
+ waitingFor: [], activeSessions: [], backlog: [], commitments: [], activePatterns: [],
993
+ graveyard: []
994
+ };
995
+ }
996
+ // T225: derive deadline LIVE from the manifest's Single Test prose
997
+ // instead of hardcoding null. Codex root-cause: TheBet + StudioShell
998
+ // T-MINUS chip + formatDeadline all key off `deadline`. With deadline
999
+ // null, the UI falls back to regex parsing the same text again, which
1000
+ // it did correctly — but the deadline ISO was unavailable for any code
1001
+ // path that needed a real Date object (countdown math, sorting,
1002
+ // analytics). The parser already has battle-tested extractDateFromText
1003
+ // that handles "by Month Day, Year" + ISO + month-name variants.
1004
+ const { extractDateFromText } = require("./lib/strategy-parser");
1005
+ const liveDeadline = manifest && manifest.singleTest
1006
+ ? extractDateFromText(manifest.singleTest)
1007
+ : null;
1008
+ // T242 bug hunt fix (C#20): status was hardcoded "unknown" so every UI
1009
+ // surface keyed off `singleTest.status` always showed the gray branch.
1010
+ // Derive from deadline distance to now: failed | at-risk | on-track.
1011
+ // (Matches the types.ts union; goalForecast.status is computed elsewhere
1012
+ // for richer signal but the singleTest carries a self-sufficient one.)
1013
+ let liveStatus = "unknown";
1014
+ if (liveDeadline) {
1015
+ const deadlineMs = Date.parse(liveDeadline + "T23:59:59Z");
1016
+ const nowMs = Date.now();
1017
+ if (Number.isFinite(deadlineMs)) {
1018
+ if (nowMs > deadlineMs) liveStatus = "failed";
1019
+ else if (nowMs > deadlineMs - 14 * 24 * 3600 * 1000) liveStatus = "at-risk";
1020
+ else liveStatus = "on-track";
1021
+ }
1022
+ }
1023
+ const singleTest = manifest && manifest.singleTest
1024
+ ? { text: manifest.singleTest, deadline: liveDeadline || null, status: liveStatus }
1025
+ : null;
1026
+ const sacrifice = manifest && manifest.sacrifice
1027
+ ? (Array.isArray(manifest.sacrifice) ? manifest.sacrifice : [manifest.sacrifice])
1028
+ : [];
1029
+ // false-zero (F5/T261): friction is only known from the manifest, and we read
1030
+ // the RAW field — the loader (pattern-manifest-loader.js) defaults a missing
1031
+ // friction_level to "L0", which would erase the absent-vs-real-0 distinction
1032
+ // before we can act on it. A present friction_level (incl. a real 0) → its
1033
+ // number; an absent field, or no manifest at all → null.
1034
+ const rawManifest = manifest && manifest.manifest ? manifest.manifest : null;
1035
+ const frictionLevel = rawManifest && rawManifest.friction_level != null
1036
+ ? (parseInt(String(rawManifest.friction_level).replace(/[^\d]/g, ""), 10) || 0)
1037
+ : null;
1038
+ return {
1039
+ available: true,
1040
+ // T242 bug hunt fix (C#10): String(Date) emits Date.toString() which
1041
+ // is non-spec for cross-browser Date.parse. Force canonical ISO.
1042
+ lastUpdated: ac.lastUpdated ? new Date(ac.lastUpdated).toISOString() : "",
1043
+ singleTest,
1044
+ sacrifice,
1045
+ frictionLevel,
1046
+ // false-zero (F11/T261): read the RAW zombie_count — the loader defaults a
1047
+ // missing field to 0, erasing absent-vs-real-0. Present 0 → real 0; absent → null.
1048
+ zombieCount: rawManifest && Number.isFinite(rawManifest.zombie_count) ? rawManifest.zombie_count : null,
1049
+ waitingFor: ac.waitingFor || [],
1050
+ activeSessions: ac.activeSessions || [],
1051
+ backlog: ac.backlog || [],
1052
+ commitments: ac.commitments || [],
1053
+ // Workflow State bus timestamps (log_last_run etc). Evidence needs these:
1054
+ // activation-log only sees DASHBOARD-launched runs, so it claimed "0
1055
+ // workflow runs this week" while /log had run three times that day in
1056
+ // the terminal (owner-caught, 2026-07-10). These timestamps are written
1057
+ // by the workflows themselves, so they cover both paths. Whitelisted to
1058
+ // *_last_run keys: the section parser also slurps stray session-log
1059
+ // fields (a "latest_delta_..." key carried a whole session summary blob).
1060
+ workflowState: (() => {
1061
+ const ws = ac.workflowState;
1062
+ if (!ws || typeof ws !== "object") return null;
1063
+ const out = {};
1064
+ for (const [k, v] of Object.entries(ws)) {
1065
+ if (/_last_run$/.test(k) && typeof v === "string" && v.length <= 32) out[k] = v;
1066
+ }
1067
+ return Object.keys(out).length ? out : null;
1068
+ })(),
1069
+ // v6 SURFACE 5 — buried ideas, for the Say-it graveyard echo. Exposed
1070
+ // raw; the client extracts name/reason/date/cost (strict gate: no stanza
1071
+ // unless all parse).
1072
+ graveyard: (ac.graveyard || []).map(g => ({ text: g.text, raw: g.raw, label: g.label })),
1073
+ // Switched from ac.activePatterns (descriptions only, no structured
1074
+ // confidence/status) to al.activePatternsConf3Plus which carries the
1075
+ // typed fields from Auto_Learnings parser. Fixes CONF 0 bug where the
1076
+ // UI confidence badge always showed 0.
1077
+ activePatterns: (function () {
1078
+ const fromAL = (manifest && manifest._auto_learnings) || null; // not set, fall back
1079
+ if (Array.isArray(ac.__activePatternsTyped)) return ac.__activePatternsTyped;
1080
+ return (ac.activePatterns || []).map(p => ({
1081
+ id: p.id || p.name || "",
1082
+ description: p.description || p.text || "",
1083
+ confidence: p.confidence || 0,
1084
+ status: p.status || ""
1085
+ }));
1086
+ })()
1087
+ };
1088
+ }
1089
+
1090
+ function phantomRowsForState(strategy, autoLearnings, manifest) {
1091
+ // Now delegates to phantom-strategy.js (built Phase 6.2). Pure function.
1092
+ try {
1093
+ return phantomStrategy.compute({ strategy, autoLearnings, manifest });
1094
+ } catch (e) {
1095
+ return [];
1096
+ }
1097
+ }
1098
+
1099
+ // ─────────── Renderers ───────────
1100
+
1101
+ function renderSuggestedNow(suggestions, manifest) {
1102
+ if (!suggestions || suggestions.length === 0) {
1103
+ return `<div class="suggest-card" style="grid-column:span 3;">
1104
+ <div class="suggest-workflow">All clear</div>
1105
+ <div class="suggest-reason">No urgent workflows surface right now. Keep shipping.</div>
1106
+ <div class="suggest-meta"><span class="status-pill status-green">CLEAR</span><span class="suggest-score">—</span></div>
1107
+ </div>`;
1108
+ }
1109
+ return suggestions.map(s => `
1110
+ <div class="suggest-card" data-workflow="${escape(s.workflow)}" data-reason="${escape(s.reason)}">
1111
+ <div class="suggest-workflow">${escape(s.workflow)}</div>
1112
+ <div class="suggest-reason">${escape(s.reason)}</div>
1113
+ <div class="suggest-meta">
1114
+ <span class="status-pill status-${escape((s.urgency||"info").toLowerCase())}">${escape(s.urgency||"info")}</span>
1115
+ <span class="suggest-score">score ${s.score}</span>
1116
+ </div>
1117
+ <div class="suggest-actions">
1118
+ <button class="suggest-action-btn" data-action="run" data-workflow="${escape(s.workflow)}">run</button>
1119
+ <button class="suggest-action-btn" data-action="snooze" data-workflow="${escape(s.workflow)}">snooze 4h</button>
1120
+ <button class="suggest-action-btn" data-action="skip" data-workflow="${escape(s.workflow)}">skip today</button>
1121
+ </div>
1122
+ </div>
1123
+ `).join("");
1124
+ }
1125
+
1126
+ function renderPatterns(al) {
1127
+ const list = (al && al.activePatternsConf3Plus) || [];
1128
+ if (list.length === 0) {
1129
+ return `<div class="pattern"><div class="pattern-name" style="color:var(--text-dim);">No active patterns at Confidence 3+</div></div>`;
1130
+ }
1131
+ return list.slice(0, 6).map(p => `
1132
+ <div class="pattern">
1133
+ <div class="pattern-head">
1134
+ <span class="pattern-name">${escape(p.name)}</span>
1135
+ <span class="pattern-conf pattern-conf-${p.confidence||0}">Conf ${p.confidence||"?"}</span>
1136
+ </div>
1137
+ <div class="pattern-meta">
1138
+ ${p.status ? `<span>· ${escape(p.status.replace(/_\(.*$/,"").replace(/_/g," "))}</span>` : ""}
1139
+ ${p.severity ? `<span>severity ${escape(p.severity)}</span>` : ""}
1140
+ ${p.domain ? `<span>${escape(p.domain)}</span>` : ""}
1141
+ ${p.evidence && p.evidence.length ? `<span>${p.evidence.length} evidence</span>` : ""}
1142
+ </div>
1143
+ </div>
1144
+ `).join("");
1145
+ }
1146
+
1147
+ function renderHealthProtocols(hp) {
1148
+ if (!hp || !hp.available || !hp.protocols.length) return "";
1149
+ // For now render the first protocol's current phase as a checklist.
1150
+ // Multi-protocol UI can come later.
1151
+ const p = hp.protocols[0];
1152
+ if (!p.current) return "";
1153
+ const items = p.current.items;
1154
+ const checked = items.filter(i => i.checked).length;
1155
+ const total = items.length;
1156
+ const pct = total > 0 ? Math.round((checked / total) * 100) : 0;
1157
+ const phaseName = p.current.heading.replace(/^PHASE\s+\d+\s*—\s*/i, "").replace(/\s*\(.*\)\s*$/, "");
1158
+ const dayLabel = (p.daysRemaining !== null && p.daysIntoPhase !== null)
1159
+ ? `Day ${p.daysIntoPhase + 1} · ${p.daysRemaining}d left`
1160
+ : "";
1161
+ const listHtml = items.slice(0, 8).map(i => `
1162
+ <div class="health-item ${i.checked ? "checked" : ""}">
1163
+ <span class="health-check">${i.checked ? "✓" : "○"}</span>
1164
+ <span class="health-text">${escape(i.text.replace(/\*\*/g, "").slice(0, 90))}</span>
1165
+ </div>
1166
+ `).join("");
1167
+ return `
1168
+ <div class="health-protocol">
1169
+ <div class="health-header">
1170
+ <span class="health-title">${escape(p.title)}</span>
1171
+ <span class="health-meta">${escape(phaseName)} · ${dayLabel}</span>
1172
+ </div>
1173
+ <div class="health-progress"><span style="width: ${pct}%"></span></div>
1174
+ <div class="health-progress-label">${checked}/${total} done</div>
1175
+ <div class="health-list">${listHtml}</div>
1176
+ </div>
1177
+ `;
1178
+ }
1179
+
1180
+ function renderHealthProtocolEmpty() {
1181
+ return `
1182
+ <div class="health-protocol empty">
1183
+ <div class="health-header">
1184
+ <span class="health-title">No protocol loaded</span>
1185
+ <span class="health-meta">local markdown</span>
1186
+ </div>
1187
+ <div class="health-progress"><span style="width: 0%"></span></div>
1188
+ <div class="health-progress-label">0/0 done</div>
1189
+ <div class="health-list">
1190
+ <div class="health-item"><span class="health-check">○</span><span class="health-text">Add a protocol in 02_Areas/Health</span></div>
1191
+ </div>
1192
+ </div>
1193
+ `;
1194
+ }
1195
+
1196
+ function renderEngineHealth(eh) {
1197
+ if (!eh || !eh.items) return `<div class="engine-health-row"><span>—</span></div>`;
1198
+ return eh.items.map(i => `
1199
+ <div class="engine-health-row">
1200
+ <span class="file">${escape(i.file)}</span>
1201
+ <span class="age">${i.exists ? escape(i.label) : "missing"}</span>
1202
+ <span class="status-pill status-${i.status.toLowerCase()}">${escape(i.status)}</span>
1203
+ </div>
1204
+ `).join("");
1205
+ }
1206
+
1207
+ function renderAgentsGrid(agents) {
1208
+ if (!agents || !agents.agents) return `<div class="agent-card"><span>—</span></div>`;
1209
+ return agents.agents.slice(0, 8).map(a => `
1210
+ <div class="agent-card${a.installed ? ' installed' : ''}">
1211
+ <span class="agent-name">${escape(a.name)}</span>
1212
+ <span class="agent-meta">${a.installed ? `${a.runnable ? "runnable" : "installed"}${a.hookSupport ? " · hooks" : ""}` : "not detected"}</span>
1213
+ <span class="agent-dot${a.installed ? ' installed' : ''}"></span>
1214
+ </div>
1215
+ `).join("");
1216
+ }
1217
+
1218
+ function renderHeatmap(daily) {
1219
+ if (!daily || !daily.heatmap) return "";
1220
+ return daily.heatmap.map(d => `<div class="heatmap-day${d.exists ? ' filled' : ''}" title="${d.date}${d.exists ? ' · ✓' : ''}"></div>`).join("");
1221
+ }
1222
+
1223
+ function renderProjects(scan) {
1224
+ if (!scan || !scan.projects || scan.projects.length === 0) return `<div class="project-row"><span class="project-name" style="color:var(--text-dim);">No active projects detected.</span></div>`;
1225
+ return scan.projects.map(p => {
1226
+ const total = p.totalTasks || 0;
1227
+ const done = (p.doneTasks || 0) + (p.unverifiedTasks || 0);
1228
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
1229
+ return `<div class="project-row">
1230
+ <span class="project-name">${escape(p.name)}</span>
1231
+ <div class="project-progress"><div class="project-progress-bar" style="width:${pct}%"></div></div>
1232
+ <span class="project-counts">${done}/${total} (${pct}%)</span>
1233
+ </div>`;
1234
+ }).join("");
1235
+ }
1236
+
1237
+ function renderGoals(g) {
1238
+ if (!g || !g.horizons || g.horizons.length === 0) return `<div class="goal-horizon"><div class="goal-horizon-title" style="color:var(--text-dim);">No goals defined.</div></div>`;
1239
+ return g.horizons.slice(0, 4).map(h => {
1240
+ const isExpired = h.targetDate && new Date(h.targetDate).getTime() < Date.now();
1241
+ return `<div class="goal-horizon">
1242
+ <div class="goal-horizon-title">${escape(h.title)}</div>
1243
+ ${h.targetDate ? `<div class="goal-horizon-date">${escape(h.targetDate)}${isExpired ? ' · <span class="status-pill status-red">EXPIRED</span>' : ''}</div>` : ""}
1244
+ <ul class="goal-list">${h.goals.slice(0, 4).map(x => `<li>${escape(x)}</li>`).join("")}</ul>
1245
+ </div>`;
1246
+ }).join("");
1247
+ }
1248
+
1249
+ function renderLibraryCounts(lib) {
1250
+ if (!lib || !lib.sections) return `<div class="library-count-item"><span class="library-count-num">—</span><span class="library-count-label">library</span></div>`;
1251
+ return lib.sections.map(s => `
1252
+ <div class="library-count-item">
1253
+ <span class="library-count-num">${s.count}</span>
1254
+ <span class="library-count-label">${escape(s.name)}</span>
1255
+ </div>
1256
+ `).join("");
1257
+ }
1258
+
1259
+ function renderLibraryRecent(lib) {
1260
+ if (!lib || !lib.sections) return "no recent harvests";
1261
+ const recent = [];
1262
+ for (const s of lib.sections) {
1263
+ for (const r of (s.recent || [])) recent.push({ ...r, section: s.name });
1264
+ }
1265
+ recent.sort((a, b) => b.mtime - a.mtime);
1266
+ if (recent.length === 0) return "no recent harvests";
1267
+ return recent.slice(0, 5).map(r => `
1268
+ <div>· <strong>${escape(r.section)}</strong> · ${escape(r.name)} <span style="color:var(--text-muted);">${r.ageDays}d</span></div>
1269
+ `).join("");
1270
+ }
1271
+
1272
+ function renderEntities(lib) {
1273
+ if (!lib || !lib.entities) return `<div class="entity-block"><span>—</span></div>`;
1274
+ return lib.entities.map(e => {
1275
+ const recent = (e.recent || []).slice(0, 4).map(r => escape(r.name)).join(", ");
1276
+ return `<div class="entity-block">
1277
+ <div class="kicker-inline">${escape(e.name)} (${e.count})</div>
1278
+ <div class="entity-name-list">${recent || "no entries"}</div>
1279
+ </div>`;
1280
+ }).join("");
1281
+ }
1282
+
1283
+ function renderVoiceTraits(voiceDna) {
1284
+ const traits = [];
1285
+ if (voiceDna.lowercaseI) traits.push("lowercase 'i'");
1286
+ if (voiceDna.commaSpliced) traits.push("comma-spliced");
1287
+ if (voiceDna.usesBrother) traits.push('"brother"');
1288
+ if (voiceDna.urlInline) traits.push("url inline");
1289
+ if (voiceDna.totalSamples > 0) traits.push(`${voiceDna.shortestSample}-${voiceDna.longestSample} chars`);
1290
+ return traits.length > 0 ? traits.map(t => escape(t)).join(" · ") : "no traits extracted";
1291
+ }
1292
+
1293
+ function renderBulletList(items) {
1294
+ if (!items || items.length === 0) return `<div class="bullet" style="color:var(--text-muted);">empty</div>`;
1295
+ return items.slice(0, 6).map(i => {
1296
+ const cls = i.checked === true ? "checked" : "open";
1297
+ return `<div class="bullet ${cls}"><span class="text">${escape(i.text)}</span></div>`;
1298
+ }).join("");
1299
+ }
1300
+
1301
+ function renderOverrides(manifest) {
1302
+ if (!manifest || !manifest.overrideHistory) return `<div style="color:var(--text-muted);font-family:var(--font-mono);font-size:11px;">no recent overrides</div>`;
1303
+ const cutoff = Date.now() - 14 * 86400000;
1304
+ const recent = manifest.overrideHistory.filter(o => {
1305
+ if (!o.date) return false;
1306
+ const d = new Date(o.date);
1307
+ return !isNaN(d.getTime()) && d.getTime() >= cutoff;
1308
+ }).slice(0, 8);
1309
+ if (recent.length === 0) return `<div style="color:var(--text-muted);font-family:var(--font-mono);font-size:11px;">no overrides in last 14 days · L0 friction</div>`;
1310
+ return recent.map(o => `
1311
+ <div class="override-row">
1312
+ <span class="date">${escape(o.date)}</span>
1313
+ <span>${escape((o.what || "").slice(0, 40))}</span>
1314
+ <span>over: ${escape((o.over || "").slice(0, 40))}</span>
1315
+ <span style="color:var(--text-dim);">${escape((o.reason || "").slice(0, 80))}</span>
1316
+ </div>
1317
+ `).join("");
1318
+ }
1319
+
1320
+ function renderHookStats(hooks) {
1321
+ if (!hooks || !hooks.hookStats) return "—";
1322
+ const s = hooks.hookStats;
1323
+ return `
1324
+ <div class="hook-stat"><div class="hook-stat-name">/log</div><div class="hook-stat-num">${s.log}</div></div>
1325
+ <div class="hook-stat"><div class="hook-stat-name">session start</div><div class="hook-stat-num">${s.sessionStart}</div></div>
1326
+ <div class="hook-stat"><div class="hook-stat-name">engine health</div><div class="hook-stat-num">${s.engineHealth}</div></div>
1327
+ <div class="hook-stat"><div class="hook-stat-name">plan sync</div><div class="hook-stat-num">${s.planSync}</div></div>
1328
+ `;
1329
+ }
1330
+
1331
+ function renderHookRecent(hooks) {
1332
+ if (!hooks || !hooks.recentMarkers || hooks.recentMarkers.length === 0) return "no hook firings in last 7 days";
1333
+ return hooks.recentMarkers.slice(0, 5).map(m => `
1334
+ <div>· ${escape(m.agent)} · ${escape(m.name.replace(/^mover_/, "").slice(0, 40))} · ${m.ageHours.toFixed(1)}h</div>
1335
+ `).join("");
1336
+ }
1337
+
1338
+ function renderDossier(d) {
1339
+ if (!d || !d.available) return `<div class="dossier-section">no dossier data</div>`;
1340
+ const sections = [
1341
+ { title: "Skills", items: d.skills },
1342
+ { title: "Capital", items: d.capital },
1343
+ { title: "Audience", items: d.audience },
1344
+ { title: "Network", items: d.network }
1345
+ ].filter(s => s.items && s.items.length > 0);
1346
+ if (sections.length === 0) return `<div class="dossier-section">no dossier sections found</div>`;
1347
+ return sections.map(s => `
1348
+ <div class="dossier-section">
1349
+ <div class="dossier-section-title">${escape(s.title)}</div>
1350
+ ${s.items.slice(0, 3).map(i => `<div>· ${escape(i)}</div>`).join("")}
1351
+ </div>
1352
+ `).join("");
1353
+ }
1354
+
1355
+ // ─────────── Helpers ───────────
1356
+
1357
+ function dailyCompletionString(daily) {
1358
+ if (!daily || !daily.today || !daily.today.exists) return "no daily note";
1359
+ const t = daily.today;
1360
+ if (!t.tasksTotal) return "no tasks";
1361
+ return `${t.tasksDone}/${t.tasksTotal} done`;
1362
+ }
1363
+
1364
+ function extractFromAC(ac, keyRegex) {
1365
+ if (!ac) return null;
1366
+ const candidates = [];
1367
+ if (ac.sections) for (const sec of Object.values(ac.sections)) if (sec.fields) candidates.push(sec.fields);
1368
+ if (ac.fields) candidates.push(ac.fields);
1369
+ // Audit pass 2 (find-bugs #6): every current caller passes a literal
1370
+ // string, but the param is typed as `keyRegex` and would happily compile
1371
+ // a future user-controlled key. Escape regex metacharacters so a value
1372
+ // like `[*` from a parsed section header can't ReDoS or partial-match
1373
+ // unintended keys. Callers needing real regex behavior can opt-in with
1374
+ // a leading sentinel later if the use case appears.
1375
+ const safeKey = String(keyRegex).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1376
+ const re = new RegExp(safeKey, "i");
1377
+ for (const fields of candidates) for (const [k, v] of Object.entries(fields)) if (re.test(k)) return v;
1378
+ return null;
1379
+ }
1380
+
1381
+ function recentOverrideCount(history, days) {
1382
+ if (!history || history.length === 0) return "0";
1383
+ const cutoff = Date.now() - days * 86400000;
1384
+ const c = history.filter(o => {
1385
+ if (!o.date) return false;
1386
+ const d = new Date(o.date);
1387
+ return !isNaN(d.getTime()) && d.getTime() >= cutoff;
1388
+ }).length;
1389
+ return String(c);
1390
+ }
1391
+
1392
+ function humanizeTimeAgo(then, now) {
1393
+ if (!then || !now) return "—";
1394
+ const ms = now.getTime() - then.getTime();
1395
+ const min = Math.round(ms / 60000);
1396
+ if (min < 1) return "now";
1397
+ if (min < 60) return `${min}m`;
1398
+ const hr = Math.round(min / 60);
1399
+ if (hr < 24) return `${hr}h`;
1400
+ const day = Math.round(hr / 24);
1401
+ return `${day}d`;
1402
+ }
1403
+
1404
+ function shortBattery(ac) {
1405
+ const v = extractFromAC(ac, "battery_state") || extractFromAC(ac, "battery");
1406
+ if (!v) return null;
1407
+ return v.split(/[(,]/)[0].trim();
1408
+ }
1409
+
1410
+ function shortZone(ac) {
1411
+ const v = extractFromAC(ac, "discipline_zone") || extractFromAC(ac, "zone");
1412
+ if (!v) return null;
1413
+ const m = v.match(/^([0-9]+)/);
1414
+ return m ? m[1] : v.split(/[(,]/)[0].trim();
1415
+ }
1416
+
1417
+ function shortOscillation(ac) {
1418
+ const v = extractFromAC(ac, "oscillation_trend") || extractFromAC(ac, "oscillation");
1419
+ if (!v) return null;
1420
+ return v.split(/[(,—]/)[0].trim().slice(0, 24);
1421
+ }
1422
+
1423
+ function shortenForMetric(text, maxLen = 60) {
1424
+ if (!text) return null;
1425
+ const cleaned = String(text).replace(/\s+/g, " ").trim();
1426
+ if (cleaned.length <= maxLen) return cleaned;
1427
+ return cleaned.slice(0, maxLen - 1) + "…";
1428
+ }
1429
+
1430
+ function shortenForCallout(text) {
1431
+ if (!text) return "—";
1432
+ // First sentence
1433
+ const cleaned = String(text).replace(/\s+/g, " ").trim();
1434
+ const firstSentence = cleaned.split(/(?<=\.)\s/)[0];
1435
+ return firstSentence.slice(0, 100);
1436
+ }
1437
+
1438
+ function extractFirstSentences(text, n) {
1439
+ if (!text) return "";
1440
+ const cleaned = text.replace(/^[#*\-]+\s*/gm, "").replace(/\n+/g, " ").trim();
1441
+ const parts = cleaned.split(/(?<=[.!?])\s+/);
1442
+ return parts.slice(0, n).join(" ").slice(0, 280);
1443
+ }
1444
+
1445
+ function computeTodayCall({ ac, manifest, engineHealth, friction }) {
1446
+ const fLevel = parseInt(String(friction || "L0").replace(/[^\d]/g, ""), 10) || 0;
1447
+ // null = health scan unavailable — NOT "OK". Don't claim "All clear" from
1448
+ // absence. [false-zero — Codex P1, T261]
1449
+ const ehStatus = engineHealth ? engineHealth.overallStatus : null;
1450
+ if (ehStatus === "RED" || fLevel >= 3) {
1451
+ return {
1452
+ class: "red", status: "RED",
1453
+ text: "Engine drift detected. Address what your AI keeps catching before adding more output."
1454
+ };
1455
+ }
1456
+ if (ehStatus === "YELLOW" || fLevel >= 1) {
1457
+ return {
1458
+ class: "amber", status: "AMBER",
1459
+ text: "Caution. Some signals stale or friction firing. Run a workflow before deeper work."
1460
+ };
1461
+ }
1462
+ if (ehStatus == null) {
1463
+ return {
1464
+ class: "amber", status: "UNKNOWN",
1465
+ text: "Engine health unavailable — can't read your Engine files to confirm the brief is clear."
1466
+ };
1467
+ }
1468
+ return {
1469
+ class: "green", status: "OK",
1470
+ text: "All clear. Push hard on the Single Test."
1471
+ };
1472
+ }
1473
+
1474
+ function computeStudioVerdict({ todayCall, manifest, engineHealth, daysToTest, strategy }) {
1475
+ const fLevel = parseInt(String((manifest && manifest.frictionLevel) || "L0").replace(/[^\d]/g, ""), 10) || 0;
1476
+ // null = health scan unavailable — NOT "OK". [false-zero — Codex P1, T261]
1477
+ const engineStatus = engineHealth ? engineHealth.overallStatus : null;
1478
+ const zombieCount = manifest ? (manifest.zombieCount || 0) : 0;
1479
+ const test = manifest && manifest.singleTest ? manifest.singleTest : (strategy && strategy.singleTest ? strategy.singleTest : "the current Single Test");
1480
+ const days = daysToTest == null ? "the deadline" : `${daysToTest}d`;
1481
+
1482
+ if (engineStatus === "RED" || fLevel >= 3) {
1483
+ return {
1484
+ title: zombieCount > 0
1485
+ ? `You're drifting. ${zombieCount} zombie ${zombieCount === 1 ? "task is" : "tasks are"} still open.`
1486
+ : "You're drifting. The system is catching repeated avoidance.",
1487
+ subline: `${todayCall.text} ${shortenForMetric(test, 86)} is ${days} away.`
1488
+ };
1489
+ }
1490
+
1491
+ if (engineStatus === "YELLOW" || fLevel >= 1) {
1492
+ return {
1493
+ title: "Caution. The brief found friction before output.",
1494
+ subline: `${todayCall.text} Run the workflow before opening another build surface.`
1495
+ };
1496
+ }
1497
+
1498
+ if (engineStatus == null) {
1499
+ return {
1500
+ title: "Brief incomplete — engine health unavailable.",
1501
+ subline: `${todayCall.text} Can't confirm a clear brief without reading your Engine files.`
1502
+ };
1503
+ }
1504
+
1505
+ return {
1506
+ title: "Clear brief. Push the Single Test.",
1507
+ subline: `${todayCall.text} The next move is distribution, not more system surface.`
1508
+ };
1509
+ }
1510
+
1511
+ function computeDaysToTarget(strategy, manifest) {
1512
+ // Try strategy.targetDate first
1513
+ let date = null;
1514
+ if (strategy && strategy.targetDate) {
1515
+ const d = new Date(strategy.targetDate);
1516
+ if (!isNaN(d.getTime())) date = d;
1517
+ }
1518
+ // Try to extract date from singleTest text e.g. "by May 15"
1519
+ if (!date && manifest && manifest.singleTest) {
1520
+ const m = manifest.singleTest.match(/by ([A-Za-z]+ \d+)(?:,? (\d{4}))?/);
1521
+ if (m) {
1522
+ const year = m[2] || new Date().getFullYear();
1523
+ const d = new Date(`${m[1]}, ${year}`);
1524
+ if (!isNaN(d.getTime())) date = d;
1525
+ }
1526
+ }
1527
+ if (!date) return null;
1528
+ return Math.max(0, Math.ceil((date.getTime() - Date.now()) / 86400000));
1529
+ }
1530
+
1531
+ function computeTodayProgress(daily) {
1532
+ if (!daily || !daily.today || !daily.today.exists) return { pct: 0, ringOffset: 0 };
1533
+ const t = daily.today;
1534
+ if (!t.tasksTotal) return { pct: 0, ringOffset: 0 };
1535
+ const pct = Math.round((t.tasksDone / t.tasksTotal) * 100);
1536
+ const circumference = 2 * Math.PI * 70; // matches r=70 in template
1537
+ const offset = Math.round((pct / 100) * circumference);
1538
+ return { pct: String(pct), ringOffset: offset };
1539
+ }
1540
+
1541
+ module.exports = { build };