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,162 @@
1
+ "use strict";
2
+
3
+ // start-close.js — CORE-LOOP PHASE 2b (SCHEMA-SPEC v0.2 section 3, owner
4
+ // default #4). The two verbs the whole loop collapses into, as LIBRARY
5
+ // functions only: nothing here is wired to a workflow, hook, or server
6
+ // route yet. Wiring comes after the owner inspects the migration manifest.
7
+ //
8
+ // start(vaultRoot, ...) = reap orphaned sessions from before, write
9
+ // session.started, and (optionally) inventory
10
+ // today's plan (plan.observed + plan.ticked from
11
+ // the note's checkboxes). No other side effects.
12
+ // close(vaultRoot, ...) = write the session's gathered facts +
13
+ // session.closed, then rebuild and write the four
14
+ // projection files beside the legacy ones.
15
+ //
16
+ // Projections are rebuildable, never hand-edited (spec section 2), so
17
+ // writeProjections overwrites them wholesale on every Close. The file bytes
18
+ // use the SAME assembly as dryrun.js's manifest (projectionFileText), so a
19
+ // Close over an unchanged log at the same clock reading reproduces the
20
+ // migration's bytes exactly — tested in state-core-startclose.test.mjs.
21
+
22
+ const fs = require("fs");
23
+ const path = require("path");
24
+ const events = require("./events");
25
+ const { appendEvent, readEvents, acquireLock } = require("./event-log");
26
+
27
+ // A session younger than this is NEVER reaped by another Start's sweep:
28
+ // terra attack 5 reproduced session B (started 1ms after A) reaping live
29
+ // session A. The grace window makes parallel sessions safe without a
30
+ // liveness lease; a genuinely crashed session is reaped by the first Start
31
+ // that runs after the window. Wiring may later pass real liveness (agent
32
+ // pids) to tighten this.
33
+ const DEFAULT_REAP_GRACE_MS = 24 * 3600 * 1000;
34
+ const { rebuildProjections } = require("./projections");
35
+ const { projectionFileText, PROJECTION_NAMES, STATE_DIR, LOG_NAME } = require("./dryrun");
36
+ const { backfillDailyTasks } = require("./backfill");
37
+
38
+ function logPathFor(vaultRoot) {
39
+ return path.join(vaultRoot, STATE_DIR, LOG_NAME);
40
+ }
41
+
42
+ // Rebuilds from the log and overwrites the four projection files. Holds the
43
+ // log's advisory lock across the read-reduce-write so a concurrent append
44
+ // cannot land between the rebuild and the file writes (terra attack 6
45
+ // reproduced a stored projection with a stale watermark that way), and each
46
+ // file goes through a same-dir temp+rename so a crash never leaves a
47
+ // half-written projection. Returns the rebuild result.
48
+ function writeProjections(vaultRoot, { now, lockWaitMs } = {}) {
49
+ const nowMs = typeof now === "number" ? now : Date.now();
50
+ const logPath = logPathFor(vaultRoot);
51
+ const lock = acquireLock(logPath, lockWaitMs != null ? { lockWaitMs } : {});
52
+ try {
53
+ const rebuilt = rebuildProjections(logPath, { now: () => nowMs });
54
+ const watermark = { id: rebuilt.body.throughEventId, ts: rebuilt.body.throughEventTs };
55
+ for (const [kind, name] of Object.entries(PROJECTION_NAMES)) {
56
+ const dest = path.join(vaultRoot, STATE_DIR, name);
57
+ const tmp = `${dest}.tmp-${process.pid}`;
58
+ fs.writeFileSync(tmp, projectionFileText(kind, rebuilt.body[kind], watermark, rebuilt.body.eventCount, nowMs), "utf8");
59
+ fs.renameSync(tmp, dest);
60
+ }
61
+ return rebuilt;
62
+ } finally {
63
+ lock.release();
64
+ }
65
+ }
66
+
67
+ // Start = session.started + orphan sweep + (optional) plan inventory.
68
+ // The sweep runs BEFORE this session's own started event is appended, so a
69
+ // session can never reap itself. planNote is today's Daily Note content:
70
+ // its checkboxes become the honest denominator (plan.observed) plus a
71
+ // plan.ticked for boxes already [x] — both dedupe on identical re-runs, so
72
+ // a double Start is harmless.
73
+ // One TOTAL wall-clock budget across a whole start()/close() call (sol #8
74
+ // partial refutation 2: lockWaitMs bounded each acquisition, but a call
75
+ // performs one acquisition per orphan/fact, so a hook child had no single
76
+ // deadline). Returns append options for the NEXT acquisition: the per-append
77
+ // wait shrinks to the remaining budget; when the budget is spent it throws,
78
+ // and the caller's partial work stands (appends are individually atomic and
79
+ // deduped, so the next session's Start/Close catches up losslessly).
80
+ function budgetedOpts(deadlineAt, lockWaitMs) {
81
+ if (deadlineAt == null) return lockWaitMs != null ? { lockWaitMs } : {};
82
+ const remaining = deadlineAt - Date.now();
83
+ if (remaining <= 0) throw new Error("start-close: total budget exhausted (partial work stands; the next session catches up)");
84
+ return { lockWaitMs: lockWaitMs != null ? Math.min(lockWaitMs, remaining) : remaining };
85
+ }
86
+
87
+ function start(vaultRoot, { agent, project, focus, sessionId, planNote, now, reapGraceMs, lockWaitMs, totalBudgetMs } = {}) {
88
+ const nowMs = typeof now === "number" ? now : Date.now();
89
+ const graceMs = typeof reapGraceMs === "number" ? reapGraceMs : DEFAULT_REAP_GRACE_MS;
90
+ const logPath = logPathFor(vaultRoot);
91
+ // lockWaitMs bounds EACH append's wait for a contended lock (terra wiring
92
+ // finding 5); totalBudgetMs bounds the WHOLE call (sol #8 refutation 2).
93
+ // The deadline is real wall clock, never the caller's logical `now`.
94
+ const deadlineAt = typeof totalBudgetMs === "number" ? Date.now() + totalBudgetMs : null;
95
+
96
+ const { events: existing } = readEvents(logPath);
97
+ // Only sessions older than the grace window are treated as crashed; a
98
+ // parallel session that started recently is LIVE, not an orphan.
99
+ const orphans = events.findOrphanSessions(existing).filter((o) => nowMs - o.ts > graceMs);
100
+ let reaped = 0;
101
+ for (const orphan of orphans) {
102
+ const r = appendEvent(logPath, events.reapSession(orphan, { ts: nowMs }), budgetedOpts(deadlineAt, lockWaitMs));
103
+ if (r.appended) reaped += 1;
104
+ }
105
+
106
+ const startedPayload = { agent, project };
107
+ if (sessionId) startedPayload.sessionId = sessionId;
108
+ if (focus) startedPayload.focus = focus;
109
+ const started = events.sessionStarted(startedPayload, { ts: nowMs });
110
+ appendEvent(logPath, started, budgetedOpts(deadlineAt, lockWaitMs));
111
+
112
+ let planEvents = 0;
113
+ const skipped = [];
114
+ if (planNote && typeof planNote.text === "string") {
115
+ const r = backfillDailyTasks(planNote.text, {
116
+ noteDate: planNote.noteDate,
117
+ relPath: planNote.relPath || `Dailies/${planNote.noteDate}.md`,
118
+ });
119
+ for (const e of r.events) {
120
+ if (appendEvent(logPath, e, budgetedOpts(deadlineAt, lockWaitMs)).appended) planEvents += 1;
121
+ }
122
+ skipped.push(...r.skipped);
123
+ }
124
+
125
+ return { sessionId: started.payload.sessionId, reaped, planEvents, skipped };
126
+ }
127
+
128
+ // Close = the session's gathered facts + session.closed + projection
129
+ // rebuild. `facts` are prebuilt envelopes (events.js constructors) — Close
130
+ // appends them verbatim; duplicates dedupe by key, so retrying a crashed
131
+ // Close never double-counts.
132
+ function close(vaultRoot, { sessionId, agent, project, summary, mins, dateKey, facts = [], now, lockWaitMs, totalBudgetMs } = {}) {
133
+ const nowMs = typeof now === "number" ? now : Date.now();
134
+ const logPath = logPathFor(vaultRoot);
135
+ const deadlineAt = typeof totalBudgetMs === "number" ? Date.now() + totalBudgetMs : null;
136
+ const appendOpts = () => budgetedOpts(deadlineAt, lockWaitMs);
137
+
138
+ let appended = 0;
139
+ let duplicates = 0;
140
+ for (const fact of facts) {
141
+ const r = appendEvent(logPath, fact, appendOpts());
142
+ if (r.appended) appended += 1;
143
+ else if (r.duplicate) duplicates += 1;
144
+ }
145
+
146
+ const closedPayload = { sessionId, agent, project, summary };
147
+ if (typeof mins === "number") closedPayload.mins = mins;
148
+ // Vault-zone policy (SCHEMA-SPEC v0.2.7): live wiring passes the
149
+ // product-local day; events.sessionClosed validates the shape.
150
+ if (dateKey !== undefined) closedPayload.dateKey = dateKey;
151
+ const r = appendEvent(logPath, events.sessionClosed(closedPayload, { ts: nowMs }), appendOpts());
152
+ if (r.appended) appended += 1;
153
+ else if (r.duplicate) duplicates += 1;
154
+
155
+ // The projection rebuild is the one step that must not be skipped mid-call:
156
+ // it gets whatever budget remains (or the per-lock default when unbudgeted).
157
+ const rebuildOpts = appendOpts();
158
+ const rebuilt = writeProjections(vaultRoot, { now: nowMs, lockWaitMs: rebuildOpts.lockWaitMs });
159
+ return { appended, duplicates, hash: rebuilt.hash, eventCount: rebuilt.body.eventCount };
160
+ }
161
+
162
+ module.exports = { start, close, writeProjections, logPathFor };
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+
3
+ // trial.js — CORE-LOOP TRIAL MODE (SCHEMA-SPEC v0.2 section 2): "If a
4
+ // projection disagrees with its legacy sibling during the trial, the
5
+ // DASHBOARD shows the legacy value and logs the delta — discrepancies
6
+ // surface, never silently switch."
7
+ //
8
+ // Scope discipline: only pairs with IDENTICAL semantics are compared.
9
+ // Overrides qualify (pattern-manifest override_history and override.logged
10
+ // events share one source since the migration). Streak deliberately does
11
+ // NOT: the legacy streak counts Daily-Note-existence days, the projection
12
+ // counts session.closed days — different definitions, and logging their
13
+ // disagreement would be false-equivalence noise, not signal. When more
14
+ // event writers land (override capture at Close), this file grows pairs.
15
+ //
16
+ // The delta log is the honest gap meter: today nothing writes
17
+ // override.logged forward, so `legacy - events` is exactly the wiring
18
+ // backlog. Watching it shrink to zero across releases IS the trial.
19
+
20
+ const fs = require("fs");
21
+ const path = require("path");
22
+
23
+ const STATE_DIR = path.join("02_Areas", "Engine", "State");
24
+ const LOG_NAME = "events.jsonl";
25
+ const DELTA_NAME = "trial-deltas.jsonl";
26
+ const MAX_DELTA_BYTES = 512 * 1024; // rotate once, keep the tail honest
27
+
28
+ // Compares override counts. Returns null when the vault is unmigrated
29
+ // (beside-mode: no State dir means no trial), else a comparison record.
30
+ function checkOverrides({ vault, manifestOverrideCount }) {
31
+ const logPath = path.join(vault, STATE_DIR, LOG_NAME);
32
+ if (!fs.existsSync(logPath)) return null;
33
+ const { readEvents } = require("./event-log");
34
+ const { events } = readEvents(logPath);
35
+ const eventCount = events.filter((e) => e.type === "override.logged").length;
36
+ const legacy = Number.isFinite(manifestOverrideCount) ? manifestOverrideCount : 0;
37
+ return {
38
+ kind: "overrides",
39
+ legacy,
40
+ events: eventCount,
41
+ agrees: legacy === eventCount,
42
+ };
43
+ }
44
+
45
+ // Appends a record when the state MOVES: a new disagreement, a change in a
46
+ // standing disagreement, or a disagreement RESOLVING back to agreement (sol
47
+ // #7 partial refutation: returning on agrees left the last durable record
48
+ // claiming an open gap forever — the resolution is exactly the event the
49
+ // 14-day zero-delta switch criterion needs on the record). Standing states,
50
+ // agreeing or not, never re-log: the dashboard rebuilds often and spam
51
+ // would bury real movement.
52
+ function recordDelta(vault, record, { now } = {}) {
53
+ if (!record) return { logged: false, reason: "unmigrated" };
54
+ const deltaPath = path.join(vault, STATE_DIR, DELTA_NAME);
55
+ try {
56
+ let last = null;
57
+ if (fs.existsSync(deltaPath)) {
58
+ const stat = fs.statSync(deltaPath);
59
+ if (stat.size > MAX_DELTA_BYTES) {
60
+ fs.renameSync(deltaPath, deltaPath + ".1"); // one rotation, oldest history discarded
61
+ }
62
+ const lines = fs.readFileSync(deltaPath, "utf8").trim().split("\n").filter(Boolean);
63
+ for (let i = lines.length - 1; i >= 0; i--) {
64
+ try {
65
+ const parsed = JSON.parse(lines[i]);
66
+ if (parsed.kind === record.kind) { last = parsed; break; }
67
+ } catch { continue; }
68
+ }
69
+ }
70
+ if (record.agrees) {
71
+ // Agreement is durable news only when it RESOLVES a recorded gap.
72
+ if (!last || last.agrees) return { logged: false, reason: "agrees (no open gap on record)" };
73
+ } else if (last && !last.agrees && last.legacy === record.legacy && last.events === record.events) {
74
+ return { logged: false, reason: "unchanged since last record" };
75
+ }
76
+ const entry = { ts: typeof now === "number" ? now : Date.now(), ...record };
77
+ fs.appendFileSync(deltaPath, JSON.stringify(entry) + "\n", "utf8");
78
+ return { logged: true };
79
+ } catch (e) {
80
+ return { logged: false, reason: `delta log write failed: ${e.message}` };
81
+ }
82
+ }
83
+
84
+ // One-call convenience for the build path: best-effort, never throws.
85
+ function runTrial({ vault, manifestOverrideCount }) {
86
+ try {
87
+ const overrides = checkOverrides({ vault, manifestOverrideCount });
88
+ if (!overrides) return { available: false, reason: "vault not migrated" };
89
+ const logged = recordDelta(vault, overrides);
90
+ return { available: true, pairs: [overrides], logged: logged.logged };
91
+ } catch (e) {
92
+ return { available: false, reason: e.message };
93
+ }
94
+ }
95
+
96
+ module.exports = { checkOverrides, recordDelta, runTrial, DELTA_NAME };
@@ -0,0 +1,248 @@
1
+ "use strict";
2
+
3
+ const { strategyPath, safeRead, safeStat } = require("./paths");
4
+ const { localDateKey } = require("./date-utils");
5
+
6
+ // Parse Strategy.md. Extracts hypothesis, single test, sacrifice, validation criteria, fail condition,
7
+ // LAB REPORT changelog, current phase + version.
8
+
9
+ function parseStrategy(vault) {
10
+ const filePath = strategyPath(vault);
11
+ const text = safeRead(filePath);
12
+ const stat = safeStat(filePath);
13
+ if (!text) return { available: false, filePath };
14
+
15
+ const out = {
16
+ available: true,
17
+ filePath,
18
+ lastUpdated: stat ? stat.mtime : null,
19
+ raw: text,
20
+ fields: {}
21
+ };
22
+
23
+ const re = /\*\*([^*]+):\*\*\s*([^\n]+)/g;
24
+ let m;
25
+ while ((m = re.exec(text)) !== null) {
26
+ const k = m[1].trim().toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_|_$/g, "");
27
+ out.fields[k] = m[2].trim();
28
+ }
29
+
30
+ // Header-level fields
31
+ out.version = out.fields.version || null;
32
+ out.lastUpdatedField = out.fields.updated || out.fields.last_updated || null;
33
+ // Target Date — normalize to ISO YYYY-MM-DD so downstream T-MINUS math works.
34
+ // Field can be "June 30th, 2026" / "2026-05-15" / "by May 15" — all coerced.
35
+ const rawTarget = out.fields.target_date || null;
36
+ out.targetDate = rawTarget ? (extractDateFromText(rawTarget) || rawTarget) : null;
37
+ out.primeObjective = out.fields.prime_objective || null;
38
+ out.stretchTarget = out.fields.stretch_target || null;
39
+ out.currentState = out.fields.current_state || null;
40
+ // The input commitment (daily quota) is SEPARATE from the Single Test (the
41
+ // outcome bet). The hero renders the bet; Today renders the commitment.
42
+ out.commitment = out.fields.commitment || null;
43
+
44
+ // Single Test extraction — V4 uses **Mid-term Milestone:**, older versions
45
+ // used **Single Test:** / **The Bet:**. Try V4 first, fall back to legacy
46
+ // so the live dashboard parser keeps working across Strategy.md versions.
47
+ const milestoneMatch = text.match(/\*\*Mid-term Milestone:\*\*\s+(.+?)(?=\n\*\*|\n##|$)/is);
48
+ if (milestoneMatch) {
49
+ out.singleTest = milestoneMatch[1].trim().replace(/\s+/g, " ");
50
+ } else {
51
+ const stMatch = text.match(/\*\*(?:Single Test|The Bet):?\*\*\s*"?([^"\n]+)"?/i);
52
+ out.singleTest = stMatch ? stMatch[1].trim().replace(/^"|"$/g, "") : null;
53
+ }
54
+
55
+ // Pull deadline date out of the Single Test prose if Target Date is absent.
56
+ // V4 text says "by June 30, 2026" → ISO 2026-06-30. Older V3 said "by May 15".
57
+ if (!out.targetDate && out.singleTest) {
58
+ out.targetDate = extractDateFromText(out.singleTest);
59
+ }
60
+
61
+ // Sacrifice extraction — V4 uses "**The Sacrifice (V4 — hardened past V3.0):**"
62
+ // followed by a bulleted list. Old format was inline text after "**Sacrifice:**".
63
+ const sacrV4 = text.match(/\*\*The Sacrifice[^*]*\*\*\s*\n([\s\S]+?)(?=\n\*\*\w[^*]*\*\*|\n##\s|$)/i);
64
+ if (sacrV4) {
65
+ out.sacrificeBlock = sacrV4[1].trim();
66
+ out.sacrificeItems = sacrV4[1].split("\n")
67
+ .map(l => l.replace(/^\s*[*-]\s+/, "").trim())
68
+ .filter(l => l && !l.startsWith("**"))
69
+ .slice(0, 4);
70
+ } else {
71
+ const sacMatch = text.match(/\*\*The Sacrifice:?\*\*([\s\S]*?)(?=\n##|\n###|\n\*\*|$)/i);
72
+ out.sacrificeBlock = sacMatch ? sacMatch[1].trim() : null;
73
+ out.sacrificeItems = out.sacrificeBlock
74
+ ? out.sacrificeBlock.split("\n")
75
+ .map(l => l.match(/^\s*[-*]\s+(.+)$/))
76
+ .filter(Boolean).map(m => m[1].trim())
77
+ : [];
78
+ }
79
+
80
+ // Validation + Fail
81
+ const valMatch = text.match(/\*\*✅ Validation Criteria:?\*\*\s*([^\n]+)/i);
82
+ out.validationCriteria = valMatch ? valMatch[1].trim() : null;
83
+ const failMatch = text.match(/\*\*❌ Fail Condition:?\*\*\s*([^\n]+)/i);
84
+ out.failCondition = failMatch ? failMatch[1].trim() : null;
85
+
86
+ // Hypothesis section body. NOTE the `m` flag — without it `^` matches
87
+ // start of STRING not start of LINE, so the section headers below the
88
+ // first line never match. Found via dashboard CURRENT STRATEGIES empty.
89
+ // T222: Strategy V4+ leads Section 1 with a `**Context:**` block
90
+ // summarizing why the PRIOR version failed (literally "V3.0 Single Test
91
+ // FAILED (4/10 by May 15)..."). The dashboard was rendering this as if
92
+ // it were the current hypothesis, leaking the V3 May 15 date into the UI.
93
+ // Skip past the retrospective preamble — start from **Status:**, or
94
+ // **The Positioning** if Status is absent, falling back to the full
95
+ // section for older Strategy.md files that don't follow V4 layout.
96
+ let body = extractSection(text, /^##\s+1\.\s+BUSINESS HYPOTHESIS/im);
97
+ if (body) {
98
+ const statusIdx = body.search(/^\*\*Status:\*\*/m);
99
+ const positioningIdx = body.search(/^\*\*The Positioning/m);
100
+ const skipTo = statusIdx >= 0 ? statusIdx
101
+ : positioningIdx >= 0 ? positioningIdx
102
+ : -1;
103
+ if (skipTo > 0) {
104
+ body = body.slice(skipTo).trim();
105
+ }
106
+ }
107
+ out.hypothesisBody = body;
108
+
109
+ // Pivot Itch Defense
110
+ out.pivotItchDefense = extractSection(text, /\bPivot Itch\b/im);
111
+
112
+ // LAB REPORT changelog entries
113
+ out.labReport = extractLabReport(text);
114
+
115
+ // Bio-algorithm + Mindset hypotheses (for vitality/mindset domains)
116
+ out.bioAlgorithmBody = extractSection(text, /^##\s+2\.\s+THE BIO-ALGORITHM/im);
117
+ out.mindsetBody = extractSection(text, /^##\s+3\.\s+MINDSET HYPOTHESIS/im);
118
+
119
+ // Combined: per-domain strategies. Used by Briefing CURRENT STRATEGIES
120
+ // cards instead of Goals.md horizons (user feedback: should come from
121
+ // Strategy.md, not Goals.md).
122
+ out.hypotheses = [
123
+ { domain: 'WORK', body: out.hypothesisBody || '' },
124
+ { domain: 'VITALITY', body: out.bioAlgorithmBody || '' },
125
+ { domain: 'FAITH / PURPOSE', body: out.mindsetBody || '' },
126
+ ].filter(h => h.body && h.body.trim().length > 20);
127
+
128
+ // Per-domain MEASUREMENT declaration (single | chain | scalar | streak), written
129
+ // by the onboarding Scribe inside each domain section so the dashboard dispatches
130
+ // on the shape. Header NAMES differ between the hand-authored V4 file (BUSINESS
131
+ // HYPOTHESIS / THE BIO-ALGORITHM) and the fresh Scribe file (WORK / VITALITY
132
+ // HYPOTHESIS), so match on the section NUMBER, not the name.
133
+ out.measurement = {
134
+ work: readMeasurementDecl(extractSection(text, /^##\s+1\.\s+/im)),
135
+ vitality: readMeasurementDecl(extractSection(text, /^##\s+2\.\s+/im)),
136
+ faith: readMeasurementDecl(extractSection(text, /^##\s+3\.\s+/im)),
137
+ };
138
+
139
+ // Archived hypotheses (graveyard)
140
+ out.archived = extractGraveyardEntries(text);
141
+
142
+ return out;
143
+ }
144
+
145
+ // Read a "**Measurement:** <type>" declaration (plus a scalar baseline/target or a
146
+ // chain's reverse-engineering math) from a domain section body. Returns
147
+ // { type, baseline?, target?, chain? } or null. The honest placeholder
148
+ // "_(single | chain | scalar | streak, set once ...)_" contains all four words, so
149
+ // it is rejected: only a real, LEADING bare type counts as a declaration.
150
+ function readMeasurementDecl(body) {
151
+ if (!body) return null;
152
+ const mm = body.match(/\*\*Measurement:\*\*\s*([^\n]+)/i);
153
+ if (!mm) return null;
154
+ const tm = mm[1].trim().match(/^(single|chain|scalar|streak)\b/i); // anchored: the value IS the type
155
+ if (!tm) return null; // a "_(...)_" placeholder or no real declaration
156
+ const decl = { type: tm[1].toLowerCase() };
157
+ const real = (s) => (s && !/^_?\(/.test(s.trim()) ? s.trim() : null); // skip "_(...)_" placeholders
158
+ const base = body.match(/\*\*Baseline:\*\*\s*([^\n]+)/i);
159
+ const targ = body.match(/\*\*Target:\*\*\s*([^\n]+)/i);
160
+ const chain = body.match(/\*\*Chain:\*\*\s*([^\n]+)/i);
161
+ if (base && real(base[1])) decl.baseline = real(base[1]);
162
+ if (targ && real(targ[1])) decl.target = real(targ[1]);
163
+ if (chain && real(chain[1])) decl.chain = real(chain[1]);
164
+ return decl;
165
+ }
166
+
167
+ // Extract a date from arbitrary prose. Handles ISO YYYY-MM-DD, "by Month Day",
168
+ // "Month Day", "Month Day, YYYY". Returns ISO string or null. Assumes the
169
+ // current year if not specified.
170
+ function extractDateFromText(text) {
171
+ if (!text) return null;
172
+ // ISO date first — most specific
173
+ const iso = text.match(/\b(20\d{2}-[01]\d-[0-3]\d)\b/);
174
+ if (iso) return iso[1];
175
+
176
+ // "by Month Day(,? Year)?"
177
+ //
178
+ // T242 bug hunt fix (B#7): regex alternation is left-to-right. Previous
179
+ // order put short prefixes first ("jan", "feb", ...) so "january 5" only
180
+ // captured "jan" — then the day-capture group started at "uary 5", which
181
+ // luckily still found "5" via the lazy `\d{1,2}` advance. The bug surfaces
182
+ // when the input is "sept 5": short "sep" matched, capture continues at
183
+ // "t 5" — `\d` finds nothing, then engine backtracks. Fragile. Reorder
184
+ // to longest-first so "september", "sept" match before "sep".
185
+ const MONTHS = [
186
+ "january","february","march","april","may","june","july","august","september","october","november","december",
187
+ "sept","jan","feb","mar","apr","may","jun","jul","aug","sep","oct","nov","dec"
188
+ ];
189
+ const monthRe = MONTHS.join("|");
190
+ const m = text.match(new RegExp(`\\b(?:by\\s+)?(${monthRe})\\s+(\\d{1,2})(?:,?\\s*(20\\d{2}))?`, "i"));
191
+ if (m) {
192
+ const mIdx = monthIndex(m[1]);
193
+ if (mIdx < 0) return null;
194
+ const day = parseInt(m[2], 10);
195
+ if (!Number.isFinite(day) || day < 1 || day > 31) return null;
196
+ const year = m[3] ? parseInt(m[3], 10) : new Date().getFullYear();
197
+ const d = new Date(year, mIdx, day);
198
+ if (isNaN(d.getTime())) return null;
199
+ return localDateKey(d);
200
+ }
201
+ return null;
202
+ }
203
+
204
+ function monthIndex(name) {
205
+ const n = name.toLowerCase().slice(0, 3);
206
+ const map = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 };
207
+ return map[n] != null ? map[n] : -1;
208
+ }
209
+
210
+ function extractSection(text, headerRe) {
211
+ const m = text.match(headerRe);
212
+ if (!m) return null;
213
+ const startIdx = m.index;
214
+ const startLineEnd = text.indexOf("\n", startIdx);
215
+ const after = text.slice(startLineEnd + 1);
216
+ const nextHeader = after.search(/^##\s+/m);
217
+ return nextHeader === -1 ? after.trim() : after.slice(0, nextHeader).trim();
218
+ }
219
+
220
+ function extractLabReport(text) {
221
+ const idx = text.search(/^##\s*🔄?\s*LAB REPORT/im);
222
+ if (idx === -1) return [];
223
+ const after = text.slice(idx);
224
+ const next = after.slice(after.indexOf("\n") + 1).search(/^##\s+/m);
225
+ const block = next === -1 ? after : after.slice(0, after.indexOf("\n") + 1 + next);
226
+ const lines = block.split("\n");
227
+ const entries = [];
228
+ for (const line of lines) {
229
+ const m = line.match(/^[-*]\s+\*\*\[([\d\-]+)\]:?\*\*\s*(.+)$/);
230
+ if (m) entries.push({ date: m[1], summary: m[2].trim() });
231
+ }
232
+ return entries;
233
+ }
234
+
235
+ function extractGraveyardEntries(text) {
236
+ const idx = text.search(/^##\s*⚰️?\s*ARCHIVED HYPOTHESES|^##\s*The Graveyard/im);
237
+ if (idx === -1) return [];
238
+ const after = text.slice(idx);
239
+ const lines = after.split("\n");
240
+ const out = [];
241
+ for (const line of lines) {
242
+ const m = line.match(/^###\s+(.+?)\s*$/);
243
+ if (m) out.push({ name: m[1].trim() });
244
+ }
245
+ return out;
246
+ }
247
+
248
+ module.exports = { parseStrategy, extractDateFromText };
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+
3
+ const { strategyPath, safeRead } = require("./paths");
4
+
5
+ // strategy-protocol-parser — extracts the "Daily floor" / "Daily minimum
6
+ // output" items per domain from Strategy.md. This is the SOURCE OF TRUTH
7
+ // for the Execution Protocol module on the Briefing route.
8
+ //
9
+ // Strategy.md is organized in sections:
10
+ // ## 1. BUSINESS HYPOTHESIS (V3.x) → Work domain
11
+ // ## 2. THE BIO-ALGORITHM (Vitality) → Vitality domain
12
+ // ## 3. MINDSET HYPOTHESIS (V2.x — The Anchor Reset) → Faith domain
13
+ //
14
+ // Per section, we extract:
15
+ // - Daily minimum output (if present) — bullet of required actions
16
+ // - Daily floor (bad days) — single-line bullet of bare minimum
17
+ // Both lines are split into individual checkboxes (sentences / commas).
18
+
19
+ function parseStrategyProtocol(vault) {
20
+ const text = safeRead(strategyPath(vault));
21
+ if (!text) return { available: false };
22
+
23
+ const sections = splitByDomain(text);
24
+
25
+ return {
26
+ available: true,
27
+ work: extractDomainItems(sections.work, "Work"),
28
+ vitality: extractDomainItems(sections.vitality, "Vitality"),
29
+ faith: extractDomainItems(sections.faith, "Faith"),
30
+ };
31
+ }
32
+
33
+ // Slice Strategy.md into 3 buckets by the major section headings.
34
+ // Uses string indexOf instead of regex with emoji classes (which are
35
+ // fragile across multi-codepoint glyphs like ⚰️).
36
+ function splitByDomain(text) {
37
+ const out = { work: "", vitality: "", faith: "" };
38
+
39
+ const businessIdx = text.search(/##\s*1\.\s*BUSINESS HYPOTHESIS/i);
40
+ const bioIdx = text.search(/##\s*2\.\s*THE BIO-ALGORITHM/i);
41
+ const mindsetIdx = text.search(/##\s*3\.\s*MINDSET HYPOTHESIS/i);
42
+
43
+ // Next-section heuristic: any `\n##` after the mindset start, excluding
44
+ // the mindset header itself. We split on `\n##` and pick the slice
45
+ // starting with "MINDSET HYPOTHESIS" then stop at the next `\n##`.
46
+ let mindsetEnd = text.length;
47
+ if (mindsetIdx >= 0) {
48
+ const tail = text.slice(mindsetIdx + 5); // skip "## 3."
49
+ const next = tail.search(/\n##\s/);
50
+ if (next > 0) mindsetEnd = mindsetIdx + 5 + next;
51
+ }
52
+
53
+ if (businessIdx >= 0 && bioIdx > businessIdx) {
54
+ out.work = text.slice(businessIdx, bioIdx);
55
+ }
56
+ if (bioIdx >= 0 && mindsetIdx > bioIdx) {
57
+ out.vitality = text.slice(bioIdx, mindsetIdx);
58
+ }
59
+ if (mindsetIdx >= 0) {
60
+ out.faith = text.slice(mindsetIdx, mindsetEnd);
61
+ }
62
+
63
+ return out;
64
+ }
65
+
66
+ // Within a domain section, find "Daily floor" and "Daily minimum output" lines
67
+ // and produce checklist items.
68
+ function extractDomainItems(section, domainLabel) {
69
+ if (!section) return { available: false, items: [] };
70
+ const items = [];
71
+
72
+ // Daily minimum output — multi-item, often a few comma- or slash-separated.
73
+ const minMatch = section.match(/\*\*Daily minimum output:\*\*\s*([^\n]+)/i);
74
+ if (minMatch) {
75
+ splitIntoBullets(minMatch[1]).forEach(text => {
76
+ items.push({ text, source: "Daily minimum output", emphasis: "primary" });
77
+ });
78
+ }
79
+
80
+ // Daily floor (bad days) — single floor item, sometimes multi-clause.
81
+ const floorMatch = section.match(/\*\*Daily floor[^:]*:\*\*\s*([^\n]+)/i);
82
+ if (floorMatch) {
83
+ splitIntoBullets(floorMatch[1]).forEach(text => {
84
+ items.push({ text, source: "Daily floor", emphasis: "floor" });
85
+ });
86
+ }
87
+
88
+ return {
89
+ available: items.length > 0,
90
+ domain: domainLabel,
91
+ items,
92
+ };
93
+ }
94
+
95
+ // Split a content line like "3 tweets/day + 1 thread/week" or
96
+ // "Mg glycinate + Vit D3 taken. 3L water. No melatonin." into separate
97
+ // human-readable bullet items.
98
+ const QUALIFIER_BLACKLIST = new Set([
99
+ "non-negotiable", "non negotiable",
100
+ "no excuses", "always", "never",
101
+ "tbd", "etc", "etc.",
102
+ ]);
103
+
104
+ function splitIntoBullets(line) {
105
+ const cleaned = line
106
+ .replace(/\.$/, "")
107
+ .trim()
108
+ .replace(/\*\*/g, "");
109
+
110
+ // Split on:
111
+ // - periods between sentences (not inside numbers)
112
+ // - " + " when followed by an uppercase letter (separator vs equation)
113
+ // - " · " bullets
114
+ // - " | " pipes
115
+ const parts = cleaned
116
+ .split(/(?:\s*\.(?=\s+[A-Z0-9])|\s*[·|]\s*|\s+\+\s+(?=[A-Z]))/)
117
+ .map(p => p.trim().replace(/\.$/, ""))
118
+ .filter(p => p.length >= 3 && p.length <= 140)
119
+ // Reject pure qualifier strings — these aren't actionable items
120
+ .filter(p => !QUALIFIER_BLACKLIST.has(p.toLowerCase()));
121
+
122
+ // De-dupe and cap at 5 per domain
123
+ const seen = new Set();
124
+ const out = [];
125
+ for (const p of parts) {
126
+ const k = p.toLowerCase();
127
+ if (seen.has(k)) continue;
128
+ seen.add(k);
129
+ out.push(p);
130
+ if (out.length >= 5) break;
131
+ }
132
+ return out;
133
+ }
134
+
135
+ module.exports = { parseStrategyProtocol };