mover-os 4.7.7 → 4.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/install.js +688 -68
  2. package/package.json +15 -3
  3. package/src/dashboard/build.js +1503 -0
  4. package/src/dashboard/dashboard.js +276 -0
  5. package/src/dashboard/index.js +319 -0
  6. package/src/dashboard/lib/activation-log.js +297 -0
  7. package/src/dashboard/lib/active-context-parser.js +177 -0
  8. package/src/dashboard/lib/agent-command.js +93 -0
  9. package/src/dashboard/lib/agent-detect.js +255 -0
  10. package/src/dashboard/lib/agent-detector.js +92 -0
  11. package/src/dashboard/lib/agent-session.js +462 -0
  12. package/src/dashboard/lib/anti-identity-detector.js +116 -0
  13. package/src/dashboard/lib/auto-learnings-parser.js +183 -0
  14. package/src/dashboard/lib/cli-usage-parser.js +92 -0
  15. package/src/dashboard/lib/config-parser.js +109 -0
  16. package/src/dashboard/lib/connect-recommender.js +131 -0
  17. package/src/dashboard/lib/correlations-parser.js +231 -0
  18. package/src/dashboard/lib/daily-note-resolver.js +211 -0
  19. package/src/dashboard/lib/date-utils.js +35 -0
  20. package/src/dashboard/lib/distribution-parser.js +78 -0
  21. package/src/dashboard/lib/dossier-parser.js +64 -0
  22. package/src/dashboard/lib/drift-history.js +83 -0
  23. package/src/dashboard/lib/drift-score.js +119 -0
  24. package/src/dashboard/lib/engine-health.js +170 -0
  25. package/src/dashboard/lib/engine-writer.js +1685 -0
  26. package/src/dashboard/lib/execution-plan.js +125 -0
  27. package/src/dashboard/lib/experiments-parser.js +429 -0
  28. package/src/dashboard/lib/feed-parser.js +294 -0
  29. package/src/dashboard/lib/forked-future.js +60 -0
  30. package/src/dashboard/lib/goal-forecast.js +304 -0
  31. package/src/dashboard/lib/goals-parser.js +67 -0
  32. package/src/dashboard/lib/health-protocols-parser.js +133 -0
  33. package/src/dashboard/lib/hook-activity.js +48 -0
  34. package/src/dashboard/lib/hook-indexer.js +169 -0
  35. package/src/dashboard/lib/hourly-activity-parser.js +143 -0
  36. package/src/dashboard/lib/identity-parser.js +85 -0
  37. package/src/dashboard/lib/ingestion.js +418 -0
  38. package/src/dashboard/lib/library-indexer-v2.js +212 -0
  39. package/src/dashboard/lib/library-indexer.js +105 -0
  40. package/src/dashboard/lib/log-activation.sh +61 -0
  41. package/src/dashboard/lib/memory-curator.js +97 -0
  42. package/src/dashboard/lib/memory-gardener.js +177 -0
  43. package/src/dashboard/lib/memory-gepa.js +102 -0
  44. package/src/dashboard/lib/memory-index.js +470 -0
  45. package/src/dashboard/lib/memory-rerank.js +72 -0
  46. package/src/dashboard/lib/memory-text.js +136 -0
  47. package/src/dashboard/lib/metrics-log-parser.js +76 -0
  48. package/src/dashboard/lib/moves-usage-parser.js +184 -0
  49. package/src/dashboard/lib/onboarding-forge.js +70 -0
  50. package/src/dashboard/lib/override-outcome-parser.js +68 -0
  51. package/src/dashboard/lib/override-summary.js +73 -0
  52. package/src/dashboard/lib/paths.js +192 -0
  53. package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
  54. package/src/dashboard/lib/phantom-strategy.js +129 -0
  55. package/src/dashboard/lib/project-scanner.js +121 -0
  56. package/src/dashboard/lib/promise-wall.js +88 -0
  57. package/src/dashboard/lib/record-score.js +173 -0
  58. package/src/dashboard/lib/redaction.js +140 -0
  59. package/src/dashboard/lib/refusal-parser.js +44 -0
  60. package/src/dashboard/lib/regenerate-manifest.js +206 -0
  61. package/src/dashboard/lib/rewind-snapshots.js +689 -0
  62. package/src/dashboard/lib/roast-wall-parser.js +200 -0
  63. package/src/dashboard/lib/run-registry.js +226 -0
  64. package/src/dashboard/lib/safe-write.js +63 -0
  65. package/src/dashboard/lib/session-log-parser.js +145 -0
  66. package/src/dashboard/lib/session-time-parser.js +158 -0
  67. package/src/dashboard/lib/skill-index.js +171 -0
  68. package/src/dashboard/lib/skill-indexer.js +118 -0
  69. package/src/dashboard/lib/skill-recommender.js +689 -0
  70. package/src/dashboard/lib/strategy-parser.js +245 -0
  71. package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
  72. package/src/dashboard/lib/streak-parser.js +95 -0
  73. package/src/dashboard/lib/suggested-now.js +254 -0
  74. package/src/dashboard/lib/tool-awareness.js +125 -0
  75. package/src/dashboard/lib/transcript-parser.js +331 -0
  76. package/src/dashboard/lib/vault-graph-parser.js +205 -0
  77. package/src/dashboard/lib/view-generator.js +163 -0
  78. package/src/dashboard/lib/voice-dna-parser.js +57 -0
  79. package/src/dashboard/lib/walkthrough-script.js +140 -0
  80. package/src/dashboard/lib/workflow-graph-parser.js +170 -0
  81. package/src/dashboard/lib/workflow-library-parser.js +146 -0
  82. package/src/dashboard/server.js +2024 -0
  83. package/src/dashboard/shortcut.js +0 -0
  84. package/src/dashboard/static/setup-poc.html +306 -0
  85. package/src/dashboard/static/walkthrough-poc.html +580 -0
  86. package/src/dashboard/styles.css +1201 -0
  87. package/src/dashboard/templates/index.html +278 -0
  88. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  89. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  90. package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  91. package/src/dashboard/ui/dist/assets/hero.png +0 -0
  92. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +157 -0
  93. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +1 -0
  94. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +34 -0
  95. package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  96. package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  97. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  98. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  99. package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  100. package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
  101. package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
  102. package/src/dashboard/ui/dist/icon.svg +4 -0
  103. package/src/dashboard/ui/dist/index.html +18 -0
  104. package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
  105. package/src/dashboard/ui/dist/registerSW.js +1 -0
  106. package/src/dashboard/ui/dist/sw.js +1 -0
  107. package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
@@ -0,0 +1,1503 @@
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
+ const cfgForName = tryParse("config", () => require("./lib/config-parser").read(), {});
453
+ const profileName =
454
+ (cfgForName && typeof cfgForName.userName === "string" && cfgForName.userName.trim()) ||
455
+ (identity && identity.name) || null;
456
+
457
+ const stateJson = {
458
+ builtAt: builtAt.toISOString(),
459
+ vaultPath: vault,
460
+ profileName,
461
+ version: version || "unknown",
462
+ engineHealth: engineHealthState,
463
+ activeContext: (function() {
464
+ // Inject typed active patterns from Auto_Learnings parser before mapping
465
+ const typed = ((al && al.activePatternsConf3Plus) || []).map(p => ({
466
+ id: p.name || p.id || "",
467
+ description: extractPatternHeadline(p),
468
+ confidence: typeof p.confidence === "number" ? p.confidence : 0,
469
+ status: p.status || "",
470
+ severity: p.severity || "",
471
+ polarity: p.polarity || "neutral",
472
+ category: p.category || "watching",
473
+ firstDetected: p.firstDetected || "",
474
+ lastUpdated: p.lastUpdated || "",
475
+ sourcePath: (al && al.filePath) || ""
476
+ }));
477
+ const acWithTyped = ac ? { ...ac, __activePatternsTyped: typed } : ac;
478
+ return activeContextForState(acWithTyped, manifest);
479
+ })(),
480
+ driftScore: drift,
481
+ forkedFuture: forked,
482
+ phantomRows: phantomRowsForDrift,
483
+ suggestedNow: (suggestions || []).map(s => ({
484
+ workflow: s.workflow || s.command || "",
485
+ reason: s.reason || s.explanation || "",
486
+ score: typeof s.score === "number" ? s.score : 0,
487
+ signalKey: s.signal || s.key || ""
488
+ })),
489
+ // V5.0 activation stats (Codex #3 wiring fix, 2026-05-24). Reads from
490
+ // ~/.mover/activation-events.jsonl. Local-only, telemetry-never.
491
+ // Cheap (~1ms typical) — re-read on every build.
492
+ activation: tryParse("activation-log", () => readActivationStats(), {
493
+ available: false,
494
+ installDays: 0,
495
+ dashboardOpens: 0,
496
+ dashboardOpens7d: 0,
497
+ workflowRuns: 0,
498
+ workflowRuns7d: 0,
499
+ logCompletions7d: 0,
500
+ distinctOpenDays: 0,
501
+ activated: false,
502
+ firstEventAt: null,
503
+ }),
504
+ agents: (agents && agents.agents ? agents.agents : []).map(a => ({
505
+ id: a.id || a.name || "",
506
+ name: a.name || a.id || "",
507
+ // agent-detector.js now intersects config-dir + binary-on-PATH. Pass through.
508
+ installed: !!a.installed,
509
+ runnable: !!a.runnable,
510
+ hookSupport: !!a.hookSupport,
511
+ detectedPath: a.configPath || a.path || a.detectedPath || undefined
512
+ })),
513
+ workflows: (() => {
514
+ try {
515
+ // bundleRoot = src/dashboard/build.js → ../../ = bundle root
516
+ const wl = workflowLibrary.indexWorkflows(path.resolve(__dirname, "..", ".."));
517
+ return (wl.workflows || []).map(w => ({
518
+ name: w.name,
519
+ description: w.description || "",
520
+ risk: w.risk || "LOW",
521
+ target: w.target || "",
522
+ reads: w.reads || [],
523
+ writes: w.writes || [],
524
+ handsOffTo: w.handsOffTo || [],
525
+ // v6 SURFACE 1 — Say-it card copy + structured io contract
526
+ does: w.does,
527
+ long: w.long,
528
+ needsYou: w.needsYou,
529
+ io: w.io ?? null
530
+ }));
531
+ } catch (e) {
532
+ errors.push(`[workflow-library] ${e.message}`);
533
+ return [];
534
+ }
535
+ })(),
536
+ // Wave 2 — newly-wired parsers
537
+ strategy: (() => {
538
+ const out = tryParse("strategy", () => strategyParser.parseStrategy(vault), { available: false });
539
+ if (!out || !out.available) return { available: false };
540
+ return {
541
+ available: true,
542
+ version: out.version || null,
543
+ primeObjective: out.primeObjective || null,
544
+ stretchTarget: out.stretchTarget || null,
545
+ currentState: out.currentState || null,
546
+ singleTest: out.singleTest || null,
547
+ targetDate: out.targetDate || null,
548
+ measurement: out.measurement || null,
549
+ lastUpdated: out.lastUpdated ? out.lastUpdated.toISOString() : (out.lastUpdatedField || null),
550
+ hypotheses: (out.hypotheses || []).map(h => ({
551
+ domain: h.domain,
552
+ body: (h.body || '').slice(0, 1200) // cap body so StateJson stays lean
553
+ }))
554
+ };
555
+ })(),
556
+ goalForecast,
557
+ domainForecasts,
558
+ executionPlan,
559
+ identity: (() => {
560
+ const out = tryParse("identity", () => identityParser.parseIdentity(vault), { available: false });
561
+ if (!out || !out.available) return { available: false };
562
+ return {
563
+ available: true,
564
+ archetype: out.archetype || null,
565
+ coreDrive: out.coreDrive || null,
566
+ fatalFlaw: out.fatalFlaw || null,
567
+ theLaw: out.theLaw || null,
568
+ antiIdentity: out.antiIdentity || null,
569
+ antiIdentityBody: out.antiIdentityBody || null,
570
+ psychFirewall: out.psychFirewall || null
571
+ };
572
+ })(),
573
+ // F19 — Anti-Identity Detector hits, cross-referencing patterns +
574
+ // overrides against the user's anti-identity statement.
575
+ antiIdentityHits: (() => {
576
+ const identity = tryParse("identity", () => identityParser.parseIdentity(vault), { available: false });
577
+ // ac is the parsed active-context. Patterns live in its activePatterns array.
578
+ const patterns = (ac && ac.activePatterns) || [];
579
+ return tryParse(
580
+ "anti-identity-detect",
581
+ () => antiIdentityDetector.detect({ identity, patterns, overrides: overrideEntries }),
582
+ { available: false }
583
+ );
584
+ })(),
585
+ // F39 — Vault wiki-link graph (distinct from PublicProofGraph which
586
+ // shows behavioral patterns). Walks every md file in the vault to
587
+ // build a node-edge map of [[wiki-links]]. Capped at 200 nodes for
588
+ // payload reasonableness. Connections route renders this.
589
+ vaultGraph: tryParse(
590
+ "vault-graph",
591
+ () => vaultGraphParser.buildVaultGraph(vault, { limit: 200 }),
592
+ { available: false, nodes: [], edges: [] }
593
+ ),
594
+ // Voice DNA — surface the REAL Voice_DNA.md calibration to the dashboard
595
+ // (the Library's "Your writing voice" panel). The parser already ran above
596
+ // (voiceDna); it was only feeding flat CLI strings, never the state tree,
597
+ // so the dashboard showed "harvest your voice" even though the file is
598
+ // already calibrated. honest-empty when the file is absent. No capped
599
+ // numbers leak out: sampleCount is the true total; bannedPhrases is capped
600
+ // in the parser so we don't surface it as a count.
601
+ voice: (() => {
602
+ if (!voiceDna || !voiceDna.available) return { available: false };
603
+ const traits = [];
604
+ if (voiceDna.lowercaseI) traits.push("lowercase 'i'");
605
+ if (voiceDna.commaSpliced) traits.push("comma-spliced");
606
+ if (voiceDna.usesBrother) traits.push('says "brother"');
607
+ if (voiceDna.urlInline) traits.push("inline urls");
608
+ if ((voiceDna.totalSamples || 0) > 0 && Number.isFinite(voiceDna.shortestSample)) {
609
+ traits.push(`${voiceDna.shortestSample}-${voiceDna.longestSample} char range`);
610
+ }
611
+ // Count writing rules + banned phrases UNCAPPED from the raw file (the
612
+ // parser caps bannedPhrases at 12, which would under-report). A voice is
613
+ // "calibrated" when the file defines rules / banned phrases / traits —
614
+ // sample passages are a bonus, not the bar (the owner's file has rules
615
+ // but no fenced samples, and is correctly already calibrated).
616
+ const raw = voiceDna.raw || "";
617
+ // Pull the actual bullet TEXT under a heading (until the next h2), so the
618
+ // Library can SHOW the voice (how it's defined + what it avoids), not just
619
+ // a status badge. Real lines only; never invented.
620
+ const sectionBullets = (headingRe) => {
621
+ const m = raw.match(headingRe);
622
+ if (!m) return [];
623
+ const rest = raw.slice(m.index + m[0].length);
624
+ const end = rest.search(/\n##\s/); // stop at the next h2
625
+ const section = end >= 0 ? rest.slice(0, end) : rest;
626
+ return (section.match(/^\s*[-*]\s+.+$/gm) || [])
627
+ .map((l) => l.replace(/^\s*[-*]\s+/, "").trim())
628
+ .filter((l) => l && !l.startsWith("#"));
629
+ };
630
+ const ruleLines = sectionBullets(/##\s*Writing Rules[^\n]*\n/i);
631
+ const bannedLines = sectionBullets(/##\s*Banned[^\n]*\n/i);
632
+ const rulesCount = ruleLines.length;
633
+ const bannedCount = bannedLines.length;
634
+ return {
635
+ available: true,
636
+ calibrated: rulesCount > 0 || bannedCount > 0 || (voiceDna.totalSamples || 0) > 0 || traits.length > 0,
637
+ lastUpdated: voiceDna.lastUpdated || null,
638
+ rulesCount,
639
+ bannedCount,
640
+ sampleCount: voiceDna.totalSamples || 0,
641
+ traits,
642
+ // a few REAL rules / banned phrases — the voice's actual definition
643
+ rules: ruleLines.slice(0, 6).map((s) => (s.length > 130 ? s.slice(0, 130).trim() + "…" : s)),
644
+ banned: bannedLines.slice(0, 10).map((s) => s.replace(/^["'""]|["'""]$/g, "").trim()),
645
+ // up to 3 real writing samples (trimmed) — literally the user's own voice
646
+ samples: (voiceDna.samples || [])
647
+ .filter((s) => s && s.trim().length > 0)
648
+ .slice(0, 3)
649
+ .map((s) => (s.length > 220 ? s.slice(0, 220).trim() + "…" : s.trim())),
650
+ path: voiceDna.filePath || null,
651
+ };
652
+ })(),
653
+ goals: (() => {
654
+ const out = tryParse("goals", () => goalsParser.parseGoals(vault), { available: false });
655
+ if (!out || !out.available) return { available: false, horizons: [] };
656
+ return {
657
+ available: true,
658
+ horizons: (out.horizons || []).slice(0, 5).map(h => ({
659
+ title: h.title || "",
660
+ body: (h.body || "").slice(0, 600)
661
+ }))
662
+ };
663
+ })(),
664
+ projects: (() => {
665
+ const out = tryParse("projects", () => projectScanner.scanProjects(vault, { limit: 12 }), { available: false, projects: [] });
666
+ if (!out || !out.available) return { available: false, projects: [], totalCount: 0 };
667
+ return {
668
+ available: true,
669
+ totalCount: out.totalCount || 0,
670
+ activeCount: typeof out.activeCount === "number" ? out.activeCount : undefined,
671
+ projects: (out.projects || []).map(p => ({
672
+ name: p.name || "",
673
+ path: p.path || "",
674
+ planPath: p.planPath || "",
675
+ totalTasks: p.totalTasks || 0,
676
+ doneTasks: p.doneTasks || 0,
677
+ openTasks: p.openTasks || 0,
678
+ unverifiedTasks: p.unverifiedTasks || 0,
679
+ zombieTasks: p.zombieTasks || 0,
680
+ lastModified: p.lastModified ? p.lastModified.toISOString() : null,
681
+ stateExcerpt: p.stateExcerpt || null,
682
+ recentExecutionLog: (p.recentExecutionLog || []).slice(0, 3)
683
+ }))
684
+ };
685
+ })(),
686
+ metrics: (() => {
687
+ const out = tryParse("metrics-log", () => metricsLog.parseMetricsLog(vault), { available: false });
688
+ if (!out || !out.available) return { available: false };
689
+ return {
690
+ available: true,
691
+ sleepLatest: out.sleepLatest || null,
692
+ rhrLatest: out.rhrLatest || null,
693
+ weightLatest: out.weightLatest || null,
694
+ rolling30: out.rolling30 || null,
695
+ days: (out.days || []).slice(-30)
696
+ };
697
+ })(),
698
+ library: (() => {
699
+ const out = tryParse("library", () => libraryIndexer.indexLibrary(vault), { available: false });
700
+ if (!out || !out.available) return { available: false };
701
+ return {
702
+ available: true,
703
+ mocs: out.mocs || 0,
704
+ principles: out.principles || 0,
705
+ cheatsheets: out.cheatsheets || 0,
706
+ sops: out.sops || 0,
707
+ entities: out.entities || 0,
708
+ inputs: out.inputs || 0,
709
+ scripts: out.scripts || 0,
710
+ recentHarvests: (out.recentHarvests || []).slice(0, 5)
711
+ };
712
+ })(),
713
+ // v2 indexers (net additions for the holistic dashboard rebuild)
714
+ libraryV2: tryParse("library-v2", () => indexLibraryV2(vault), { available: false }),
715
+ skills: tryParse("skills", () => indexSkills(), { available: false, count: 0, byCategory: { system: [], specialist: [], domain: [] }, all: [] }),
716
+ hooks: tryParse("hooks", () => indexHooks(), { available: false, count: 0, byEvent: {}, registered: [], unregistered: [] }),
717
+ cliUsage: tryParse("cli-usage", () => parseCliUsage(), { available: false, commands: [] }),
718
+ // Briefing route's Execution Protocol — daily floor items per domain
719
+ // pulled from Strategy.md (not Daily Note tasks, which were the wrong
720
+ // source in the previous iteration).
721
+ strategyProtocol: tryParse("strategy-protocol", () => parseStrategyProtocol(vault), { available: false }),
722
+ experiments: tryParse("experiments", () => buildExperiments(vault), { available: false, all: [], active: [], past: [], byDomain: { work: [], vitality: [], faith: [] }, count: 0, activeCount: 0, pastCount: 0 }),
723
+ // v6 W1 — live work feed, moves-usage lighting, rewind keyframes, journal calibration gap.
724
+ // False-zero: every fallback matches the full shape; absent source -> available:false, never a fabricated 0.
725
+ feed: tryParse("feed", () => feedParser.compute({ vault, limit: 40 }), { available: false, caption: "", events: [], activeSessions: [] }),
726
+ movesUsage: tryParse("moves-usage", () => movesUsageParser.computeMovesUsage({ vault }), { available: false, source: "", totalRuns: null, byCommand: {} }),
727
+ rewind: tryParse("rewind", () => rewindSnapshots.buildRewind({ vault }), { available: false, keyframes: [], dailies: {}, gapBefore: "", gapAfter: "" }),
728
+ journalGap: (() => {
729
+ const ev = daily && daily.today && daily.today.journal ? daily.today.journal.evening : null;
730
+ const selfScore = ev && ev.score != null ? ev.score : null;
731
+ let rec = { score: null, why: null, basis: "not enough logged to score today" };
732
+ try { rec = recordScoreLib.computeRecordScore({ daily, distribution: distributionForStateJson }) || rec; } catch (_) {}
733
+ const recordScore = rec && rec.score != null ? rec.score : null;
734
+ // Trend reads the RAW resolver output (daily.recentNotes carries journal via
735
+ // analyzeNoteText); honest self-score history, empty on day 1.
736
+ const trend = (daily && daily.recentNotes ? daily.recentNotes : [])
737
+ .filter(n => n && n.journal && n.journal.evening && n.journal.evening.score != null)
738
+ .slice(0, 7)
739
+ .map(n => ({ date: n.date, score: n.journal.evening.score }));
740
+ return {
741
+ available: selfScore != null || recordScore != null,
742
+ selfScore,
743
+ recordScore,
744
+ gap: selfScore != null && recordScore != null ? selfScore - recordScore : null,
745
+ recordWhy: rec.why || null,
746
+ trend,
747
+ basis: rec.basis || null
748
+ };
749
+ })(),
750
+ // Wave 5 parsers
751
+ distribution: (() => {
752
+ return distributionForStateJson || { available: false, days: [], todayCount: null, total7d: null, total30d: null, delta7d: null };
753
+ })(),
754
+ streak: (() => {
755
+ // False-zero discipline (T261): the tryParse fallback must match the FULL
756
+ // StreakData shape — a bare { available:false } leaves days/currentStreak/
757
+ // coverage undefined and a consumer that reads them before checking
758
+ // `available` crashes (or worse, treats undefined as a clean streak).
759
+ const FULL_STREAK_FALLBACK = { available: false, days: [], currentStreak: 0, streakThroughYesterday: 0, todayExists: false, longestStreak: 0, totalDays: 0, coverage: 0 };
760
+ const out = tryParse("streak", () => streakParser.compute({ vault, daysLookback: 365 }), FULL_STREAK_FALLBACK);
761
+ return out || FULL_STREAK_FALLBACK;
762
+ })(),
763
+ overrideOutcomes: (() => {
764
+ try {
765
+ const out = tryParse("override-outcomes", () => overrideOutcomeParser.compute({ vault, overrides: overrideEntries.slice(-100), windowDays: 90 }), { available: false });
766
+ return out || { available: false, outcomes: [] };
767
+ } catch (_) { return { available: false, outcomes: [] }; }
768
+ })(),
769
+ overrideSummary,
770
+ refusals: (() => {
771
+ // False-zero discipline (T261): full RefusalsData shape on parse failure
772
+ // so count/count7d/recent are never undefined under available:false.
773
+ const FULL_REFUSALS_FALLBACK = { available: false, count: 0, count7d: 0, delta7d: 0, recent: [] };
774
+ const out = tryParse("refusals", () => refusalParser.compute({ daysLookback: 30 }), FULL_REFUSALS_FALLBACK);
775
+ return out || FULL_REFUSALS_FALLBACK;
776
+ })(),
777
+ hourlyActivity: (() => {
778
+ // False-zero discipline (T261): full HourlyActivity shape on both parse
779
+ // failure and the !available guard — previous empty-state was missing
780
+ // daysScanned + peakCount, so PerformanceCurve read undefined for the
781
+ // "last N days" label and the peak-band math under available:false.
782
+ const FULL_HOURLY_FALLBACK = { available: false, daysScanned: 0, hours: [], peakHour: null, peakCount: 0, todayHours: [], events: [] };
783
+ const out = tryParse("hourly-activity", () => hourlyActivity.compute({ vault, daysLookback: 14 }), FULL_HOURLY_FALLBACK);
784
+ if (!out || !out.available) return FULL_HOURLY_FALLBACK;
785
+ return {
786
+ available: true,
787
+ daysScanned: out.daysScanned || 0,
788
+ hours: out.hours || [],
789
+ peakHour: out.peakHour,
790
+ peakCount: out.peakCount || 0,
791
+ todayHours: out.todayHours || [],
792
+ events: out.events || []
793
+ };
794
+ })(),
795
+ // REAL tracked time from Session Log start-end ranges (the honest core of
796
+ // "did hours"). NOT a per-domain split — sessions tag a [project], never a
797
+ // Work/Vitality/Faith domain, so a domain breakdown would be fabricated.
798
+ sessionTime: (() => {
799
+ const FULL_SESSIONTIME_FALLBACK = {
800
+ available: false, daysScanned: 0,
801
+ today: { minutes: 0, sessions: 0 },
802
+ last7d: { minutes: 0, sessions: 0, untimed: 0 },
803
+ last30d: { minutes: 0, sessions: 0, untimed: 0 },
804
+ byProject7d: [], longest: null,
805
+ src: "Dailies session-log start-end ranges",
806
+ };
807
+ const out = tryParse("session-time", () => sessionTime.compute({ vault, daysLookback: 30 }), FULL_SESSIONTIME_FALLBACK);
808
+ return out || FULL_SESSIONTIME_FALLBACK;
809
+ })(),
810
+ daily: (() => {
811
+ if (!daily) {
812
+ return {
813
+ available: false,
814
+ today: null,
815
+ streak: 0,
816
+ recentNotes: [],
817
+ totalThisMonth: 0,
818
+ totalThisYear: 0
819
+ };
820
+ }
821
+ return {
822
+ available: true,
823
+ today: daily.today ? {
824
+ date: daily.today.date || null,
825
+ path: daily.today.path || null,
826
+ exists: !!daily.today.exists,
827
+ tasksTotal: daily.today.tasksTotal || 0,
828
+ tasksDone: daily.today.tasksDone || 0,
829
+ // W12: per-task lines (text + checked) so Home checkboxes can write
830
+ // back through /api/checkbox/toggle. null on older parser output.
831
+ tasks: daily.today.tasks ?? null,
832
+ length: daily.today.length || 0,
833
+ // v6 SURFACE 1 — the real plan focus for the Say-it friction rule.
834
+ // Was dropped here; the parser computes it (daily-note-resolver).
835
+ focus: daily.today.focus ?? null,
836
+ // v6 W1 — today's-call + `## Journal` sub-anchors + plan/got. All
837
+ // false-zero: null when the section is absent, never a guessed value.
838
+ todaysCall: daily.today.todaysCall ?? null,
839
+ journal: daily.today.journal ?? null,
840
+ plan: daily.today.plan ?? null,
841
+ got: daily.today.got ?? null
842
+ } : null,
843
+ streak: daily.streak || 0,
844
+ recentNotes: (daily.recentNotes || []).slice(0, 14).map(n => ({
845
+ date: n.date,
846
+ exists: !!n.exists,
847
+ tasksTotal: n.tasksTotal || 0,
848
+ tasksDone: n.tasksDone || 0,
849
+ length: n.length || 0,
850
+ // v6 W1 — per-date journal + plan for the calibration trend + Rewind
851
+ journal: n.journal ?? null,
852
+ todaysCall: n.todaysCall ?? null,
853
+ plan: n.plan ?? null
854
+ })),
855
+ totalThisMonth: daily.totalThisMonth || 0,
856
+ totalThisYear: daily.totalThisYear || 0
857
+ };
858
+ })(),
859
+ healthProtocols: (() => {
860
+ if (!healthProtocols || !healthProtocols.available) return { available: false, protocols: [] };
861
+ return {
862
+ available: true,
863
+ protocols: healthProtocols.protocols.map(p => ({
864
+ title: p.title || "",
865
+ filePath: p.filePath || "",
866
+ lastUpdated: p.lastUpdated ? p.lastUpdated.toISOString() : null,
867
+ current: p.current ? {
868
+ heading: p.current.heading || "",
869
+ daysIntoPhase: p.daysIntoPhase != null ? p.daysIntoPhase : null,
870
+ daysRemaining: p.daysRemaining != null ? p.daysRemaining : null,
871
+ items: (p.current.items || []).map(it => ({ text: it.text, checked: !!it.checked })),
872
+ done: p.current.done || 0,
873
+ total: p.current.total || 0
874
+ } : null
875
+ }))
876
+ };
877
+ })(),
878
+ snoozes: (() => {
879
+ try {
880
+ const snoozePath = path.join(os.homedir(), ".mover", "dashboard", "snoozes.json");
881
+ if (!fs.existsSync(snoozePath)) return [];
882
+ const raw = JSON.parse(fs.readFileSync(snoozePath, "utf8"));
883
+ return Array.isArray(raw) ? raw : [];
884
+ } catch (_) { return []; }
885
+ })(),
886
+ roastWall: roastWall || { available: false, count: 0, roasts: [], stats: { high: 0, med: 0, low: 0 } },
887
+ workflowGraph: workflowGraph || { available: false, workflowCount: 0, workflows: [], edges: [], files: [] },
888
+ correlations: correlations || { available: false, reason: "parser_error" },
889
+ overrides: (() => {
890
+ try {
891
+ return overrideEntries.slice(-30);
892
+ } catch (_) { return []; }
893
+ })(),
894
+ // Strip absolute filesystem paths from error messages before they reach
895
+ // /api/state.json (code-bug-audit H5). Node's ENOENT errors include the
896
+ // full vault/home path which leaked filesystem layout to any browser tab.
897
+ errors: errors.map(msg => ({
898
+ source: msg.match(/^\[([^\]]+)\]/)?.[1] || "unknown",
899
+ message: msg.replace(/['"]?\/[^\s'":,]+/g, "[path]"),
900
+ }))
901
+ };
902
+
903
+ return {
904
+ builtAt,
905
+ durationMs: Date.now() - startedAt,
906
+ sectionCount: 6,
907
+ errors,
908
+ state: stateJson
909
+ };
910
+ }
911
+
912
+ // ───────── StateJson shape mappers ─────────
913
+
914
+ function engineHealthForState(engineHealth) {
915
+ if (!engineHealth) {
916
+ // No-blackbox: the scan couldn't run. Emit nulls + available:false so the UI
917
+ // shows "health unavailable", NOT a fabricated all-OK / 0-days-stale vault.
918
+ // [false-zero discipline, T261]
919
+ return {
920
+ available: false,
921
+ unavailableReason: "parser_error",
922
+ scannedAt: new Date().toISOString(),
923
+ strategy: { staleDays: null, status: null, lastUpdated: "" },
924
+ dossier: { staleDays: null, status: null, lastUpdated: "" },
925
+ goals: { status: null, expired: [] },
926
+ analyseDay: { overdueDays: null, status: null, lastRun: null },
927
+ weeklyReview: { overdueDays: null, status: null, lastRun: null }
928
+ };
929
+ }
930
+ const findItem = (key) => (engineHealth.items || []).find(i => i.file === key) || null;
931
+ const findWf = (lbl) => (engineHealth.workflowStaleness || []).find(w => w.label === lbl) || null;
932
+ // B1 fix: engine-health.js emits `file: "Strategy"` (capital, no .md) — see
933
+ // engine-health.js:45-53. Look up by THAT key first, then fall back to variants.
934
+ // A missing item now yields null (honest "no data"), never 0/"OK" — the old
935
+ // `|| 0` masked the 2026-05-14 "0D STALE on a weeks-old file" key-mismatch bug.
936
+ const strat = findItem("Strategy") || findItem("strategy") || findItem("Strategy.md") || {};
937
+ const doss = findItem("Mover_Dossier") || findItem("dossier") || findItem("Mover_Dossier.md") || findItem("Architect_Dossier.md") || {};
938
+ const ad = findWf("/analyse-day") || {};
939
+ const rw = findWf("/review-week") || {};
940
+ return {
941
+ available: true,
942
+ unavailableReason: null,
943
+ scannedAt: (engineHealth.scannedAt instanceof Date) ? engineHealth.scannedAt.toISOString() : String(engineHealth.scannedAt || ""),
944
+ strategy: { staleDays: strat.ageDays != null ? strat.ageDays : null, status: strat.status || null, lastUpdated: strat.mtime ? String(strat.mtime) : "" },
945
+ dossier: { staleDays: doss.ageDays != null ? doss.ageDays : null, status: doss.status || null, lastUpdated: doss.mtime ? String(doss.mtime) : "" },
946
+ goals: { status: (engineHealth.expiredGoals || []).length > 0 ? "RED" : "OK", expired: engineHealth.expiredGoals || [] },
947
+ analyseDay: { overdueDays: ad.ageDays != null ? ad.ageDays : null, status: ad.status || null, lastRun: ad.lastRun || null },
948
+ weeklyReview: { overdueDays: rw.ageDays != null ? rw.ageDays : null, status: rw.status || null, lastRun: rw.lastRun || null }
949
+ };
950
+ }
951
+
952
+ // Derive a concise headline from an Auto_Learnings pattern entry.
953
+ // Prefers the entry's "name" (header), falling back to first non-empty
954
+ // evidence line, then to a body excerpt.
955
+ function extractPatternHeadline(p) {
956
+ if (!p) return "";
957
+ if (p.name) return p.name;
958
+ if (p.evidence && p.evidence.length) {
959
+ const first = p.evidence[0];
960
+ return typeof first === "string" ? first : (first.text || first.summary || "");
961
+ }
962
+ return (p.body || "").split("\n").find(l => l.trim().length > 12) || "";
963
+ }
964
+
965
+ function activeContextForState(ac, manifest) {
966
+ if (!ac || !ac.available) {
967
+ return {
968
+ available: false, lastUpdated: "", singleTest: null, sacrifice: [],
969
+ frictionLevel: null, zombieCount: null,
970
+ waitingFor: [], activeSessions: [], backlog: [], commitments: [], activePatterns: [],
971
+ graveyard: []
972
+ };
973
+ }
974
+ // T225: derive deadline LIVE from the manifest's Single Test prose
975
+ // instead of hardcoding null. Codex root-cause: TheBet + StudioShell
976
+ // T-MINUS chip + formatDeadline all key off `deadline`. With deadline
977
+ // null, the UI falls back to regex parsing the same text again, which
978
+ // it did correctly — but the deadline ISO was unavailable for any code
979
+ // path that needed a real Date object (countdown math, sorting,
980
+ // analytics). The parser already has battle-tested extractDateFromText
981
+ // that handles "by Month Day, Year" + ISO + month-name variants.
982
+ const { extractDateFromText } = require("./lib/strategy-parser");
983
+ const liveDeadline = manifest && manifest.singleTest
984
+ ? extractDateFromText(manifest.singleTest)
985
+ : null;
986
+ // T242 bug hunt fix (C#20): status was hardcoded "unknown" so every UI
987
+ // surface keyed off `singleTest.status` always showed the gray branch.
988
+ // Derive from deadline distance to now: failed | at-risk | on-track.
989
+ // (Matches the types.ts union; goalForecast.status is computed elsewhere
990
+ // for richer signal but the singleTest carries a self-sufficient one.)
991
+ let liveStatus = "unknown";
992
+ if (liveDeadline) {
993
+ const deadlineMs = Date.parse(liveDeadline + "T23:59:59Z");
994
+ const nowMs = Date.now();
995
+ if (Number.isFinite(deadlineMs)) {
996
+ if (nowMs > deadlineMs) liveStatus = "failed";
997
+ else if (nowMs > deadlineMs - 14 * 24 * 3600 * 1000) liveStatus = "at-risk";
998
+ else liveStatus = "on-track";
999
+ }
1000
+ }
1001
+ const singleTest = manifest && manifest.singleTest
1002
+ ? { text: manifest.singleTest, deadline: liveDeadline || null, status: liveStatus }
1003
+ : null;
1004
+ const sacrifice = manifest && manifest.sacrifice
1005
+ ? (Array.isArray(manifest.sacrifice) ? manifest.sacrifice : [manifest.sacrifice])
1006
+ : [];
1007
+ // false-zero (F5/T261): friction is only known from the manifest, and we read
1008
+ // the RAW field — the loader (pattern-manifest-loader.js) defaults a missing
1009
+ // friction_level to "L0", which would erase the absent-vs-real-0 distinction
1010
+ // before we can act on it. A present friction_level (incl. a real 0) → its
1011
+ // number; an absent field, or no manifest at all → null.
1012
+ const rawManifest = manifest && manifest.manifest ? manifest.manifest : null;
1013
+ const frictionLevel = rawManifest && rawManifest.friction_level != null
1014
+ ? (parseInt(String(rawManifest.friction_level).replace(/[^\d]/g, ""), 10) || 0)
1015
+ : null;
1016
+ return {
1017
+ available: true,
1018
+ // T242 bug hunt fix (C#10): String(Date) emits Date.toString() which
1019
+ // is non-spec for cross-browser Date.parse. Force canonical ISO.
1020
+ lastUpdated: ac.lastUpdated ? new Date(ac.lastUpdated).toISOString() : "",
1021
+ singleTest,
1022
+ sacrifice,
1023
+ frictionLevel,
1024
+ // false-zero (F11/T261): read the RAW zombie_count — the loader defaults a
1025
+ // missing field to 0, erasing absent-vs-real-0. Present 0 → real 0; absent → null.
1026
+ zombieCount: rawManifest && Number.isFinite(rawManifest.zombie_count) ? rawManifest.zombie_count : null,
1027
+ waitingFor: ac.waitingFor || [],
1028
+ activeSessions: ac.activeSessions || [],
1029
+ backlog: ac.backlog || [],
1030
+ commitments: ac.commitments || [],
1031
+ // v6 SURFACE 5 — buried ideas, for the Say-it graveyard echo. Exposed
1032
+ // raw; the client extracts name/reason/date/cost (strict gate: no stanza
1033
+ // unless all parse).
1034
+ graveyard: (ac.graveyard || []).map(g => ({ text: g.text, raw: g.raw, label: g.label })),
1035
+ // Switched from ac.activePatterns (descriptions only, no structured
1036
+ // confidence/status) to al.activePatternsConf3Plus which carries the
1037
+ // typed fields from Auto_Learnings parser. Fixes CONF 0 bug where the
1038
+ // UI confidence badge always showed 0.
1039
+ activePatterns: (function () {
1040
+ const fromAL = (manifest && manifest._auto_learnings) || null; // not set, fall back
1041
+ if (Array.isArray(ac.__activePatternsTyped)) return ac.__activePatternsTyped;
1042
+ return (ac.activePatterns || []).map(p => ({
1043
+ id: p.id || p.name || "",
1044
+ description: p.description || p.text || "",
1045
+ confidence: p.confidence || 0,
1046
+ status: p.status || ""
1047
+ }));
1048
+ })()
1049
+ };
1050
+ }
1051
+
1052
+ function phantomRowsForState(strategy, autoLearnings, manifest) {
1053
+ // Now delegates to phantom-strategy.js (built Phase 6.2). Pure function.
1054
+ try {
1055
+ return phantomStrategy.compute({ strategy, autoLearnings, manifest });
1056
+ } catch (e) {
1057
+ return [];
1058
+ }
1059
+ }
1060
+
1061
+ // ─────────── Renderers ───────────
1062
+
1063
+ function renderSuggestedNow(suggestions, manifest) {
1064
+ if (!suggestions || suggestions.length === 0) {
1065
+ return `<div class="suggest-card" style="grid-column:span 3;">
1066
+ <div class="suggest-workflow">All clear</div>
1067
+ <div class="suggest-reason">No urgent workflows surface right now. Keep shipping.</div>
1068
+ <div class="suggest-meta"><span class="status-pill status-green">CLEAR</span><span class="suggest-score">—</span></div>
1069
+ </div>`;
1070
+ }
1071
+ return suggestions.map(s => `
1072
+ <div class="suggest-card" data-workflow="${escape(s.workflow)}" data-reason="${escape(s.reason)}">
1073
+ <div class="suggest-workflow">${escape(s.workflow)}</div>
1074
+ <div class="suggest-reason">${escape(s.reason)}</div>
1075
+ <div class="suggest-meta">
1076
+ <span class="status-pill status-${escape((s.urgency||"info").toLowerCase())}">${escape(s.urgency||"info")}</span>
1077
+ <span class="suggest-score">score ${s.score}</span>
1078
+ </div>
1079
+ <div class="suggest-actions">
1080
+ <button class="suggest-action-btn" data-action="run" data-workflow="${escape(s.workflow)}">run</button>
1081
+ <button class="suggest-action-btn" data-action="snooze" data-workflow="${escape(s.workflow)}">snooze 4h</button>
1082
+ <button class="suggest-action-btn" data-action="skip" data-workflow="${escape(s.workflow)}">skip today</button>
1083
+ </div>
1084
+ </div>
1085
+ `).join("");
1086
+ }
1087
+
1088
+ function renderPatterns(al) {
1089
+ const list = (al && al.activePatternsConf3Plus) || [];
1090
+ if (list.length === 0) {
1091
+ return `<div class="pattern"><div class="pattern-name" style="color:var(--text-dim);">No active patterns at Confidence 3+</div></div>`;
1092
+ }
1093
+ return list.slice(0, 6).map(p => `
1094
+ <div class="pattern">
1095
+ <div class="pattern-head">
1096
+ <span class="pattern-name">${escape(p.name)}</span>
1097
+ <span class="pattern-conf pattern-conf-${p.confidence||0}">Conf ${p.confidence||"?"}</span>
1098
+ </div>
1099
+ <div class="pattern-meta">
1100
+ ${p.status ? `<span>· ${escape(p.status.replace(/_\(.*$/,"").replace(/_/g," "))}</span>` : ""}
1101
+ ${p.severity ? `<span>severity ${escape(p.severity)}</span>` : ""}
1102
+ ${p.domain ? `<span>${escape(p.domain)}</span>` : ""}
1103
+ ${p.evidence && p.evidence.length ? `<span>${p.evidence.length} evidence</span>` : ""}
1104
+ </div>
1105
+ </div>
1106
+ `).join("");
1107
+ }
1108
+
1109
+ function renderHealthProtocols(hp) {
1110
+ if (!hp || !hp.available || !hp.protocols.length) return "";
1111
+ // For now render the first protocol's current phase as a checklist.
1112
+ // Multi-protocol UI can come later.
1113
+ const p = hp.protocols[0];
1114
+ if (!p.current) return "";
1115
+ const items = p.current.items;
1116
+ const checked = items.filter(i => i.checked).length;
1117
+ const total = items.length;
1118
+ const pct = total > 0 ? Math.round((checked / total) * 100) : 0;
1119
+ const phaseName = p.current.heading.replace(/^PHASE\s+\d+\s*—\s*/i, "").replace(/\s*\(.*\)\s*$/, "");
1120
+ const dayLabel = (p.daysRemaining !== null && p.daysIntoPhase !== null)
1121
+ ? `Day ${p.daysIntoPhase + 1} · ${p.daysRemaining}d left`
1122
+ : "";
1123
+ const listHtml = items.slice(0, 8).map(i => `
1124
+ <div class="health-item ${i.checked ? "checked" : ""}">
1125
+ <span class="health-check">${i.checked ? "✓" : "○"}</span>
1126
+ <span class="health-text">${escape(i.text.replace(/\*\*/g, "").slice(0, 90))}</span>
1127
+ </div>
1128
+ `).join("");
1129
+ return `
1130
+ <div class="health-protocol">
1131
+ <div class="health-header">
1132
+ <span class="health-title">${escape(p.title)}</span>
1133
+ <span class="health-meta">${escape(phaseName)} · ${dayLabel}</span>
1134
+ </div>
1135
+ <div class="health-progress"><span style="width: ${pct}%"></span></div>
1136
+ <div class="health-progress-label">${checked}/${total} done</div>
1137
+ <div class="health-list">${listHtml}</div>
1138
+ </div>
1139
+ `;
1140
+ }
1141
+
1142
+ function renderHealthProtocolEmpty() {
1143
+ return `
1144
+ <div class="health-protocol empty">
1145
+ <div class="health-header">
1146
+ <span class="health-title">No protocol loaded</span>
1147
+ <span class="health-meta">local markdown</span>
1148
+ </div>
1149
+ <div class="health-progress"><span style="width: 0%"></span></div>
1150
+ <div class="health-progress-label">0/0 done</div>
1151
+ <div class="health-list">
1152
+ <div class="health-item"><span class="health-check">○</span><span class="health-text">Add a protocol in 02_Areas/Health</span></div>
1153
+ </div>
1154
+ </div>
1155
+ `;
1156
+ }
1157
+
1158
+ function renderEngineHealth(eh) {
1159
+ if (!eh || !eh.items) return `<div class="engine-health-row"><span>—</span></div>`;
1160
+ return eh.items.map(i => `
1161
+ <div class="engine-health-row">
1162
+ <span class="file">${escape(i.file)}</span>
1163
+ <span class="age">${i.exists ? escape(i.label) : "missing"}</span>
1164
+ <span class="status-pill status-${i.status.toLowerCase()}">${escape(i.status)}</span>
1165
+ </div>
1166
+ `).join("");
1167
+ }
1168
+
1169
+ function renderAgentsGrid(agents) {
1170
+ if (!agents || !agents.agents) return `<div class="agent-card"><span>—</span></div>`;
1171
+ return agents.agents.slice(0, 8).map(a => `
1172
+ <div class="agent-card${a.installed ? ' installed' : ''}">
1173
+ <span class="agent-name">${escape(a.name)}</span>
1174
+ <span class="agent-meta">${a.installed ? `${a.runnable ? "runnable" : "installed"}${a.hookSupport ? " · hooks" : ""}` : "not detected"}</span>
1175
+ <span class="agent-dot${a.installed ? ' installed' : ''}"></span>
1176
+ </div>
1177
+ `).join("");
1178
+ }
1179
+
1180
+ function renderHeatmap(daily) {
1181
+ if (!daily || !daily.heatmap) return "";
1182
+ return daily.heatmap.map(d => `<div class="heatmap-day${d.exists ? ' filled' : ''}" title="${d.date}${d.exists ? ' · ✓' : ''}"></div>`).join("");
1183
+ }
1184
+
1185
+ function renderProjects(scan) {
1186
+ 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>`;
1187
+ return scan.projects.map(p => {
1188
+ const total = p.totalTasks || 0;
1189
+ const done = (p.doneTasks || 0) + (p.unverifiedTasks || 0);
1190
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
1191
+ return `<div class="project-row">
1192
+ <span class="project-name">${escape(p.name)}</span>
1193
+ <div class="project-progress"><div class="project-progress-bar" style="width:${pct}%"></div></div>
1194
+ <span class="project-counts">${done}/${total} (${pct}%)</span>
1195
+ </div>`;
1196
+ }).join("");
1197
+ }
1198
+
1199
+ function renderGoals(g) {
1200
+ 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>`;
1201
+ return g.horizons.slice(0, 4).map(h => {
1202
+ const isExpired = h.targetDate && new Date(h.targetDate).getTime() < Date.now();
1203
+ return `<div class="goal-horizon">
1204
+ <div class="goal-horizon-title">${escape(h.title)}</div>
1205
+ ${h.targetDate ? `<div class="goal-horizon-date">${escape(h.targetDate)}${isExpired ? ' · <span class="status-pill status-red">EXPIRED</span>' : ''}</div>` : ""}
1206
+ <ul class="goal-list">${h.goals.slice(0, 4).map(x => `<li>${escape(x)}</li>`).join("")}</ul>
1207
+ </div>`;
1208
+ }).join("");
1209
+ }
1210
+
1211
+ function renderLibraryCounts(lib) {
1212
+ if (!lib || !lib.sections) return `<div class="library-count-item"><span class="library-count-num">—</span><span class="library-count-label">library</span></div>`;
1213
+ return lib.sections.map(s => `
1214
+ <div class="library-count-item">
1215
+ <span class="library-count-num">${s.count}</span>
1216
+ <span class="library-count-label">${escape(s.name)}</span>
1217
+ </div>
1218
+ `).join("");
1219
+ }
1220
+
1221
+ function renderLibraryRecent(lib) {
1222
+ if (!lib || !lib.sections) return "no recent harvests";
1223
+ const recent = [];
1224
+ for (const s of lib.sections) {
1225
+ for (const r of (s.recent || [])) recent.push({ ...r, section: s.name });
1226
+ }
1227
+ recent.sort((a, b) => b.mtime - a.mtime);
1228
+ if (recent.length === 0) return "no recent harvests";
1229
+ return recent.slice(0, 5).map(r => `
1230
+ <div>· <strong>${escape(r.section)}</strong> · ${escape(r.name)} <span style="color:var(--text-muted);">${r.ageDays}d</span></div>
1231
+ `).join("");
1232
+ }
1233
+
1234
+ function renderEntities(lib) {
1235
+ if (!lib || !lib.entities) return `<div class="entity-block"><span>—</span></div>`;
1236
+ return lib.entities.map(e => {
1237
+ const recent = (e.recent || []).slice(0, 4).map(r => escape(r.name)).join(", ");
1238
+ return `<div class="entity-block">
1239
+ <div class="kicker-inline">${escape(e.name)} (${e.count})</div>
1240
+ <div class="entity-name-list">${recent || "no entries"}</div>
1241
+ </div>`;
1242
+ }).join("");
1243
+ }
1244
+
1245
+ function renderVoiceTraits(voiceDna) {
1246
+ const traits = [];
1247
+ if (voiceDna.lowercaseI) traits.push("lowercase 'i'");
1248
+ if (voiceDna.commaSpliced) traits.push("comma-spliced");
1249
+ if (voiceDna.usesBrother) traits.push('"brother"');
1250
+ if (voiceDna.urlInline) traits.push("url inline");
1251
+ if (voiceDna.totalSamples > 0) traits.push(`${voiceDna.shortestSample}-${voiceDna.longestSample} chars`);
1252
+ return traits.length > 0 ? traits.map(t => escape(t)).join(" · ") : "no traits extracted";
1253
+ }
1254
+
1255
+ function renderBulletList(items) {
1256
+ if (!items || items.length === 0) return `<div class="bullet" style="color:var(--text-muted);">empty</div>`;
1257
+ return items.slice(0, 6).map(i => {
1258
+ const cls = i.checked === true ? "checked" : "open";
1259
+ return `<div class="bullet ${cls}"><span class="text">${escape(i.text)}</span></div>`;
1260
+ }).join("");
1261
+ }
1262
+
1263
+ function renderOverrides(manifest) {
1264
+ if (!manifest || !manifest.overrideHistory) return `<div style="color:var(--text-muted);font-family:var(--font-mono);font-size:11px;">no recent overrides</div>`;
1265
+ const cutoff = Date.now() - 14 * 86400000;
1266
+ const recent = manifest.overrideHistory.filter(o => {
1267
+ if (!o.date) return false;
1268
+ const d = new Date(o.date);
1269
+ return !isNaN(d.getTime()) && d.getTime() >= cutoff;
1270
+ }).slice(0, 8);
1271
+ 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>`;
1272
+ return recent.map(o => `
1273
+ <div class="override-row">
1274
+ <span class="date">${escape(o.date)}</span>
1275
+ <span>${escape((o.what || "").slice(0, 40))}</span>
1276
+ <span>over: ${escape((o.over || "").slice(0, 40))}</span>
1277
+ <span style="color:var(--text-dim);">${escape((o.reason || "").slice(0, 80))}</span>
1278
+ </div>
1279
+ `).join("");
1280
+ }
1281
+
1282
+ function renderHookStats(hooks) {
1283
+ if (!hooks || !hooks.hookStats) return "—";
1284
+ const s = hooks.hookStats;
1285
+ return `
1286
+ <div class="hook-stat"><div class="hook-stat-name">/log</div><div class="hook-stat-num">${s.log}</div></div>
1287
+ <div class="hook-stat"><div class="hook-stat-name">session start</div><div class="hook-stat-num">${s.sessionStart}</div></div>
1288
+ <div class="hook-stat"><div class="hook-stat-name">engine health</div><div class="hook-stat-num">${s.engineHealth}</div></div>
1289
+ <div class="hook-stat"><div class="hook-stat-name">plan sync</div><div class="hook-stat-num">${s.planSync}</div></div>
1290
+ `;
1291
+ }
1292
+
1293
+ function renderHookRecent(hooks) {
1294
+ if (!hooks || !hooks.recentMarkers || hooks.recentMarkers.length === 0) return "no hook firings in last 7 days";
1295
+ return hooks.recentMarkers.slice(0, 5).map(m => `
1296
+ <div>· ${escape(m.agent)} · ${escape(m.name.replace(/^mover_/, "").slice(0, 40))} · ${m.ageHours.toFixed(1)}h</div>
1297
+ `).join("");
1298
+ }
1299
+
1300
+ function renderDossier(d) {
1301
+ if (!d || !d.available) return `<div class="dossier-section">no dossier data</div>`;
1302
+ const sections = [
1303
+ { title: "Skills", items: d.skills },
1304
+ { title: "Capital", items: d.capital },
1305
+ { title: "Audience", items: d.audience },
1306
+ { title: "Network", items: d.network }
1307
+ ].filter(s => s.items && s.items.length > 0);
1308
+ if (sections.length === 0) return `<div class="dossier-section">no dossier sections found</div>`;
1309
+ return sections.map(s => `
1310
+ <div class="dossier-section">
1311
+ <div class="dossier-section-title">${escape(s.title)}</div>
1312
+ ${s.items.slice(0, 3).map(i => `<div>· ${escape(i)}</div>`).join("")}
1313
+ </div>
1314
+ `).join("");
1315
+ }
1316
+
1317
+ // ─────────── Helpers ───────────
1318
+
1319
+ function dailyCompletionString(daily) {
1320
+ if (!daily || !daily.today || !daily.today.exists) return "no daily note";
1321
+ const t = daily.today;
1322
+ if (!t.tasksTotal) return "no tasks";
1323
+ return `${t.tasksDone}/${t.tasksTotal} done`;
1324
+ }
1325
+
1326
+ function extractFromAC(ac, keyRegex) {
1327
+ if (!ac) return null;
1328
+ const candidates = [];
1329
+ if (ac.sections) for (const sec of Object.values(ac.sections)) if (sec.fields) candidates.push(sec.fields);
1330
+ if (ac.fields) candidates.push(ac.fields);
1331
+ // Audit pass 2 (find-bugs #6): every current caller passes a literal
1332
+ // string, but the param is typed as `keyRegex` and would happily compile
1333
+ // a future user-controlled key. Escape regex metacharacters so a value
1334
+ // like `[*` from a parsed section header can't ReDoS or partial-match
1335
+ // unintended keys. Callers needing real regex behavior can opt-in with
1336
+ // a leading sentinel later if the use case appears.
1337
+ const safeKey = String(keyRegex).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1338
+ const re = new RegExp(safeKey, "i");
1339
+ for (const fields of candidates) for (const [k, v] of Object.entries(fields)) if (re.test(k)) return v;
1340
+ return null;
1341
+ }
1342
+
1343
+ function recentOverrideCount(history, days) {
1344
+ if (!history || history.length === 0) return "0";
1345
+ const cutoff = Date.now() - days * 86400000;
1346
+ const c = history.filter(o => {
1347
+ if (!o.date) return false;
1348
+ const d = new Date(o.date);
1349
+ return !isNaN(d.getTime()) && d.getTime() >= cutoff;
1350
+ }).length;
1351
+ return String(c);
1352
+ }
1353
+
1354
+ function humanizeTimeAgo(then, now) {
1355
+ if (!then || !now) return "—";
1356
+ const ms = now.getTime() - then.getTime();
1357
+ const min = Math.round(ms / 60000);
1358
+ if (min < 1) return "now";
1359
+ if (min < 60) return `${min}m`;
1360
+ const hr = Math.round(min / 60);
1361
+ if (hr < 24) return `${hr}h`;
1362
+ const day = Math.round(hr / 24);
1363
+ return `${day}d`;
1364
+ }
1365
+
1366
+ function shortBattery(ac) {
1367
+ const v = extractFromAC(ac, "battery_state") || extractFromAC(ac, "battery");
1368
+ if (!v) return null;
1369
+ return v.split(/[(,]/)[0].trim();
1370
+ }
1371
+
1372
+ function shortZone(ac) {
1373
+ const v = extractFromAC(ac, "discipline_zone") || extractFromAC(ac, "zone");
1374
+ if (!v) return null;
1375
+ const m = v.match(/^([0-9]+)/);
1376
+ return m ? m[1] : v.split(/[(,]/)[0].trim();
1377
+ }
1378
+
1379
+ function shortOscillation(ac) {
1380
+ const v = extractFromAC(ac, "oscillation_trend") || extractFromAC(ac, "oscillation");
1381
+ if (!v) return null;
1382
+ return v.split(/[(,—]/)[0].trim().slice(0, 24);
1383
+ }
1384
+
1385
+ function shortenForMetric(text, maxLen = 60) {
1386
+ if (!text) return null;
1387
+ const cleaned = String(text).replace(/\s+/g, " ").trim();
1388
+ if (cleaned.length <= maxLen) return cleaned;
1389
+ return cleaned.slice(0, maxLen - 1) + "…";
1390
+ }
1391
+
1392
+ function shortenForCallout(text) {
1393
+ if (!text) return "—";
1394
+ // First sentence
1395
+ const cleaned = String(text).replace(/\s+/g, " ").trim();
1396
+ const firstSentence = cleaned.split(/(?<=\.)\s/)[0];
1397
+ return firstSentence.slice(0, 100);
1398
+ }
1399
+
1400
+ function extractFirstSentences(text, n) {
1401
+ if (!text) return "";
1402
+ const cleaned = text.replace(/^[#*\-]+\s*/gm, "").replace(/\n+/g, " ").trim();
1403
+ const parts = cleaned.split(/(?<=[.!?])\s+/);
1404
+ return parts.slice(0, n).join(" ").slice(0, 280);
1405
+ }
1406
+
1407
+ function computeTodayCall({ ac, manifest, engineHealth, friction }) {
1408
+ const fLevel = parseInt(String(friction || "L0").replace(/[^\d]/g, ""), 10) || 0;
1409
+ // null = health scan unavailable — NOT "OK". Don't claim "All clear" from
1410
+ // absence. [false-zero — Codex P1, T261]
1411
+ const ehStatus = engineHealth ? engineHealth.overallStatus : null;
1412
+ if (ehStatus === "RED" || fLevel >= 3) {
1413
+ return {
1414
+ class: "red", status: "RED",
1415
+ text: "Engine drift detected. Address what your AI keeps catching before adding more output."
1416
+ };
1417
+ }
1418
+ if (ehStatus === "YELLOW" || fLevel >= 1) {
1419
+ return {
1420
+ class: "amber", status: "AMBER",
1421
+ text: "Caution. Some signals stale or friction firing. Run a workflow before deeper work."
1422
+ };
1423
+ }
1424
+ if (ehStatus == null) {
1425
+ return {
1426
+ class: "amber", status: "UNKNOWN",
1427
+ text: "Engine health unavailable — can't read your Engine files to confirm the brief is clear."
1428
+ };
1429
+ }
1430
+ return {
1431
+ class: "green", status: "OK",
1432
+ text: "All clear. Push hard on the Single Test."
1433
+ };
1434
+ }
1435
+
1436
+ function computeStudioVerdict({ todayCall, manifest, engineHealth, daysToTest, strategy }) {
1437
+ const fLevel = parseInt(String((manifest && manifest.frictionLevel) || "L0").replace(/[^\d]/g, ""), 10) || 0;
1438
+ // null = health scan unavailable — NOT "OK". [false-zero — Codex P1, T261]
1439
+ const engineStatus = engineHealth ? engineHealth.overallStatus : null;
1440
+ const zombieCount = manifest ? (manifest.zombieCount || 0) : 0;
1441
+ const test = manifest && manifest.singleTest ? manifest.singleTest : (strategy && strategy.singleTest ? strategy.singleTest : "the current Single Test");
1442
+ const days = daysToTest == null ? "the deadline" : `${daysToTest}d`;
1443
+
1444
+ if (engineStatus === "RED" || fLevel >= 3) {
1445
+ return {
1446
+ title: zombieCount > 0
1447
+ ? `You're drifting. ${zombieCount} zombie ${zombieCount === 1 ? "task is" : "tasks are"} still open.`
1448
+ : "You're drifting. The system is catching repeated avoidance.",
1449
+ subline: `${todayCall.text} ${shortenForMetric(test, 86)} is ${days} away.`
1450
+ };
1451
+ }
1452
+
1453
+ if (engineStatus === "YELLOW" || fLevel >= 1) {
1454
+ return {
1455
+ title: "Caution. The brief found friction before output.",
1456
+ subline: `${todayCall.text} Run the workflow before opening another build surface.`
1457
+ };
1458
+ }
1459
+
1460
+ if (engineStatus == null) {
1461
+ return {
1462
+ title: "Brief incomplete — engine health unavailable.",
1463
+ subline: `${todayCall.text} Can't confirm a clear brief without reading your Engine files.`
1464
+ };
1465
+ }
1466
+
1467
+ return {
1468
+ title: "Clear brief. Push the Single Test.",
1469
+ subline: `${todayCall.text} The next move is distribution, not more system surface.`
1470
+ };
1471
+ }
1472
+
1473
+ function computeDaysToTarget(strategy, manifest) {
1474
+ // Try strategy.targetDate first
1475
+ let date = null;
1476
+ if (strategy && strategy.targetDate) {
1477
+ const d = new Date(strategy.targetDate);
1478
+ if (!isNaN(d.getTime())) date = d;
1479
+ }
1480
+ // Try to extract date from singleTest text e.g. "by May 15"
1481
+ if (!date && manifest && manifest.singleTest) {
1482
+ const m = manifest.singleTest.match(/by ([A-Za-z]+ \d+)(?:,? (\d{4}))?/);
1483
+ if (m) {
1484
+ const year = m[2] || new Date().getFullYear();
1485
+ const d = new Date(`${m[1]}, ${year}`);
1486
+ if (!isNaN(d.getTime())) date = d;
1487
+ }
1488
+ }
1489
+ if (!date) return null;
1490
+ return Math.max(0, Math.ceil((date.getTime() - Date.now()) / 86400000));
1491
+ }
1492
+
1493
+ function computeTodayProgress(daily) {
1494
+ if (!daily || !daily.today || !daily.today.exists) return { pct: 0, ringOffset: 0 };
1495
+ const t = daily.today;
1496
+ if (!t.tasksTotal) return { pct: 0, ringOffset: 0 };
1497
+ const pct = Math.round((t.tasksDone / t.tasksTotal) * 100);
1498
+ const circumference = 2 * Math.PI * 70; // matches r=70 in template
1499
+ const offset = Math.round((pct / 100) * circumference);
1500
+ return { pct: String(pct), ringOffset: offset };
1501
+ }
1502
+
1503
+ module.exports = { build };