mover-os 4.7.8 → 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 (42) hide show
  1. package/README.md +34 -24
  2. package/install.js +2197 -200
  3. package/package.json +1 -1
  4. package/src/dashboard/build.js +41 -3
  5. package/src/dashboard/lib/active-context-parser.js +16 -4
  6. package/src/dashboard/lib/agent-session.js +70 -49
  7. package/src/dashboard/lib/approval-registry.js +170 -0
  8. package/src/dashboard/lib/daily-note-resolver.js +20 -3
  9. package/src/dashboard/lib/date-utils.js +9 -1
  10. package/src/dashboard/lib/distribution-parser.js +69 -10
  11. package/src/dashboard/lib/drift-history.js +6 -1
  12. package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
  13. package/src/dashboard/lib/engine-health.js +4 -1
  14. package/src/dashboard/lib/engine-writer.js +157 -11
  15. package/src/dashboard/lib/goal-forecast.js +154 -31
  16. package/src/dashboard/lib/library-indexer-v2.js +14 -0
  17. package/src/dashboard/lib/library-search.js +290 -0
  18. package/src/dashboard/lib/log-activation.sh +0 -0
  19. package/src/dashboard/lib/memory-index.js +61 -12
  20. package/src/dashboard/lib/pid-markers.js +80 -0
  21. package/src/dashboard/lib/regenerate-manifest.js +0 -0
  22. package/src/dashboard/lib/run-registry.js +75 -15
  23. package/src/dashboard/lib/state-core/backfill.js +298 -0
  24. package/src/dashboard/lib/state-core/dryrun.js +188 -0
  25. package/src/dashboard/lib/state-core/event-log.js +615 -0
  26. package/src/dashboard/lib/state-core/events.js +265 -0
  27. package/src/dashboard/lib/state-core/projections.js +376 -0
  28. package/src/dashboard/lib/state-core/start-close.js +162 -0
  29. package/src/dashboard/lib/state-core/trial.js +96 -0
  30. package/src/dashboard/lib/strategy-parser.js +3 -0
  31. package/src/dashboard/lib/suggested-now.js +2 -2
  32. package/src/dashboard/lib/transcript-parser.js +48 -8
  33. package/src/dashboard/server.js +422 -44
  34. package/src/dashboard/shortcut.js +0 -0
  35. package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
  36. package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
  37. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  38. package/src/dashboard/ui/dist/index.html +2 -2
  39. package/src/dashboard/ui/dist/sw.js +1 -1
  40. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
  41. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
  42. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +0 -34
@@ -9,12 +9,17 @@
9
9
  const fs = require("fs");
10
10
  const path = require("path");
11
11
  const os = require("os");
12
+ const { localDateKey } = require("./date-utils");
12
13
 
13
14
  const HISTORY_PATH = path.join(os.homedir(), ".mover", "drift-history.json");
14
15
  const MAX_DAYS = 90;
15
16
 
17
+ // LOCAL calendar day, not UTC. The bare toISOString().slice(0,10) bucketed
18
+ // by UTC while every other parser (streak, distribution, session-time) keys
19
+ // by local time via date-utils, so near midnight in any non-UTC zone the
20
+ // drift sparkline's day disagreed with the rest of the dashboard by one day.
16
21
  function todayKey() {
17
- return new Date().toISOString().slice(0, 10);
22
+ return localDateKey();
18
23
  }
19
24
 
20
25
  function ensureDir(filePath) {
@@ -0,0 +1,53 @@
1
+ {
2
+ "_comment": "Known-good hashes of shipped Engine DEFAULTS across versions (CRLF-normalized+trimmed, sha256). engine-writer allows overwriting a non-sentinel file ONLY if its hash matches one of these (a fresh/older install default); a hash NOT here means the user modified it = real data, refuse. Regenerate: node scripts/gen-engine-fingerprints.mjs.",
3
+ "generated": "git-history",
4
+ "files": {
5
+ "Goals.md": [
6
+ "27abf1d26d51525baa5a3ae9c8cc6f879a723f929334852b7ef9549a5fa11c57",
7
+ "30f3e24ad7e45bbb2053a0863164357b3e6cf718146540e685462495e38c996c"
8
+ ],
9
+ "Mover_Dossier.md": [
10
+ "c0d6817e390153f834d6a52e8d0e686cb3eb056e6457afecafd5e3abc0c47c49",
11
+ "21b1ecb65d7c7dbe7be03efa965fefd54f727663e3f0088be66dd453fd244677",
12
+ "72ca850dfd15972045918fcf0170315da72290a7ca7d034710362ca77e7cd1fa",
13
+ "e10ace7b0a72d17f342c70d03f015525e7029b13e43eae2c43e8c6ecd14483ee",
14
+ "bc568ebd12e25377274cf906d0569218c46d1c06e5dc9e857b98e3fdc128b667"
15
+ ],
16
+ "Active_Context.md": [
17
+ "433ef66c852de9a5c37bb65cc40c9d20e577fbcb46ecf91963c8f2d7b23f8a70",
18
+ "5dec535b04d4c7b11029222812e782a02dcbaea03faaa8979e0adb45bf9698b9",
19
+ "22b4855fc5f8337b2d1d2f077d12cf709333ea2cc3aa96c7fd1e380ea55ab7fc",
20
+ "de5b8133f2fd35ad70920bc1c08f2ab75c26edcf58f6b88fccf245a0e729c5aa",
21
+ "fc6f35e45f7b3934d0ab705b6e259ce195c637019e49381885dab7c963f3ccca",
22
+ "8eec472e3f451a1d8a121b3a3bd76065137921aa95824666fbb466fec6fb6443",
23
+ "f0bf0c38418e6c42be46b1546acd676e55aeb64cd1ba31c164dc7047b5ee6807",
24
+ "25f525f55fb86c638cd67792ef05f46de688e048586a3ccb2d980b8d1a78c7db",
25
+ "63e7db7db109bdfa78edf5df5d69e0286d614e84aa0165ae372576f6a816602c",
26
+ "52a8d57d45cafd662e3d106c2acb6fc07116572ca82bc04ff96f7e913ce47395",
27
+ "d79582e10cb0410554b9a1a669127cc42150805a1207f867edf90ce60f9effa2",
28
+ "829a755f53600266f60a8daaa9450e8e3516a7b03538eabeed9ff94ef5ec2b1b",
29
+ "aaa0e5bdac55f53db3bd36db526f841c5e6f62fb7cd8abf7626ff95b68818e14",
30
+ "4e2b064fe6d2391704a490020be147c507809a1c420da67eac34add5c85b3ebf",
31
+ "ae47ece41ab9c4dfa12df6e0c9b895de2e44ad1b788bcbc84728f43764a0a90a",
32
+ "52332d01a067494dc2cd2a5a642d741aa16cf847e49b5e73b744586e167a7faa"
33
+ ],
34
+ "Auto_Learnings.md": [
35
+ "2f02d8724b3283101be1dce7b14e69f478e841d36453ee990ede636cb0daaced",
36
+ "54fe4b57dd90bed94ef7c5d6af50ef290347afef55081d5c3ec51b4bc3181b60",
37
+ "a04fe481a5e79eaf0888c0fd71100fbbc709c1d2c8a1145b7d6fa6482d32a144",
38
+ "0d5bb1ed881384f1559b9fd2702199d28f7e151bd5ec9f9a559b9ece038e88e5",
39
+ "e1f5361fbd42cbfb655dbe4fd3a8129ec27e5d31c5bbc397615d0eb61c507438",
40
+ "10d443002d1056d6a939dea23231acaa0a5f01f1b3c6a86b808bb383efd34621",
41
+ "2ee52d9282a830ef34c52de5d38e78f19710769b1433391dc787fdfed8cf6093"
42
+ ],
43
+ "Metrics_Log.md": [
44
+ "3e3ab4011b65ddf8b435898ee31d136d31fa346cf408a741b6752f6bd9178537",
45
+ "4f3e7ba572b46cb7cf594640737badb5c2a742e8e2942a53d554ac0e4cddcb69",
46
+ "a840982311c4ad8300813316926b50a6bc6050b1c45a058ad146b6b695299dbf"
47
+ ],
48
+ "Someday_Maybe.md": [
49
+ "d9d7889a0923059c182d197aa3579a6d4ee78e19d980a7b144652bae7d7715a5",
50
+ "115ff4539c651ea87184b124a40ea75c874b5122c26d1ecfce3b5657c3d618da"
51
+ ]
52
+ }
53
+ }
@@ -153,7 +153,10 @@ function scanForExpiredGoals(text) {
153
153
  // Drop the bare `Date:` arm. Require explicit "Target Date" semantics.
154
154
  // Also: require ISO YYYY-MM-DD shape (was `[\d\-]{8,10}` which accepts
155
155
  // `2-3-2026`, `0-0-0`, etc).
156
- const re = /(?:\*\*Target Date:?\*\*|^\s*Target Date:)\s*(\d{4}-\d{2}-\d{2})\b/gim;
156
+ // Both bold conventions accepted: `**Target Date:** X` AND `**Target
157
+ // Date**: X` — the colon-outside-bold style made a 6-month-overdue goal
158
+ // silently invisible to the RED alert (R5-A false-negative twin of B#17).
159
+ const re = /(?:\*\*Target Date:?\*\*:?|^\s*Target Date:)\s*(\d{4}-\d{2}-\d{2})\b/gim;
157
160
  let m;
158
161
  const now = Date.now();
159
162
  while ((m = re.exec(text)) !== null) {
@@ -49,7 +49,24 @@ const os = require("os");
49
49
  const ENGINE_DIR_REL = path.join("02_Areas", "Engine");
50
50
 
51
51
  function assertSafeTarget(targetDir, allowEngineWrite = false) {
52
- const resolved = path.resolve(targetDir);
52
+ const lexical = path.resolve(targetDir);
53
+ // terra F2 (2026-07-11): path.resolve is purely LEXICAL — if 02_Areas/Engine (or
54
+ // a parent) is a SYMLINK, the endsWith check still matched "02_Areas/Engine" while
55
+ // the real writes escaped through the link to an external dir. Resolve the REAL
56
+ // path so the checks below see where writes actually land. realpath fails for a
57
+ // not-yet-created target (fresh vault) — that has no symlink to follow, so the
58
+ // lexical path is authoritative there.
59
+ let resolved = lexical;
60
+ try { resolved = fs.realpathSync(lexical); }
61
+ catch {
62
+ // Target doesn't exist yet; resolve the deepest existing ancestor so a
63
+ // symlinked PARENT is still caught, then re-append the remainder.
64
+ try {
65
+ let cur = lexical, tail = [];
66
+ while (cur && cur !== path.dirname(cur) && !fs.existsSync(cur)) { tail.unshift(path.basename(cur)); cur = path.dirname(cur); }
67
+ resolved = path.join(fs.realpathSync(cur), ...tail);
68
+ } catch { resolved = lexical; }
69
+ }
53
70
  // Compare case-INSENSITIVELY (Bug6): macOS/APFS is case-insensitive, so a lowercase
54
71
  // ".../02_areas/engine" points at the real Engine but a case-sensitive endsWith would miss it and
55
72
  // let the write through. Keep `resolved` (original case) for the error messages.
@@ -250,7 +267,19 @@ function parseProjectLine(line) {
250
267
  }
251
268
  const name = cleanProjectName(parts.shift() || "");
252
269
  const rest = sanitizeValue(parts.join(": "), 300);
253
- return { name, outcome: rest, state: "" };
270
+ // People answer in prose ("6 bookings, P1, next action portfolio site, blocker underpricing"),
271
+ // so lift the fields out of the text instead of leaving the brief full of [TBD].
272
+ const prio = rest.match(/\b(P[0-9])\b/i);
273
+ const blocker = rest.match(/\bblockers?\s*[:\-]?\s+([^,.;]+)/i);
274
+ const next = rest.match(/\bnext(?:\s+(?:action|step|move))?\s*[:\-]?\s+([^,.;]+)/i);
275
+ return {
276
+ name,
277
+ outcome: rest,
278
+ state: "",
279
+ priority: prio ? prio[1].toUpperCase() : "",
280
+ blocker: blocker ? blocker[1].trim() : "",
281
+ nextAction: next ? next[1].trim() : "",
282
+ };
254
283
  }
255
284
 
256
285
  function cleanProjectSources(raw) {
@@ -297,9 +326,17 @@ function cleanProjects(raw) {
297
326
  if (Array.isArray(raw)) {
298
327
  raw.forEach(push);
299
328
  } else if (typeof raw === "string") {
329
+ // Prose answers separate projects with sentences, not semicolons:
330
+ // "…blocker underpricing. Fitness comeback: gym 3x a week, P2." Split on a
331
+ // period only when the next sentence opens like a new project head
332
+ // ("Name: details"), so decimals and ordinary sentences stay intact.
333
+ // Field-label words never open a new project: "…root cause found. Next:
334
+ // ship the fix." is a continuation parseProjectLine LIFTS as nextAction —
335
+ // splitting there minted junk "Next"/"Priority" projects (R5-A).
300
336
  raw
301
337
  .split(/\n+|;+/)
302
- .map((x) => x.trim())
338
+ .flatMap((chunk) => chunk.split(/(?<=\.)\s+(?=(?!(?:Next(?:\s+(?:[Aa]ction|[Ss]tep|[Mm]ove))?|Blockers?|Priority|Action|Step|Status|State|Metric|Goal)\s*:)[A-Z][^.:;]{1,60}:\s)/))
339
+ .map((x) => x.trim().replace(/\.$/, ""))
303
340
  .filter(Boolean)
304
341
  .forEach(push);
305
342
  }
@@ -1416,14 +1453,82 @@ function writeEngine({ transcript, digest = "", targetDir, dryRun = true, force
1416
1453
  if (e.code !== "ENOENT") throw e; // ENOENT = fresh; anything else re-throws
1417
1454
  }
1418
1455
  }
1456
+ // terra F1 (2026-07-11): the sentinel check misses a PARTIAL Engine whose user
1457
+ // data lives only in non-sentinel files (a legacy or interrupted setup). Also
1458
+ // refuse when the Engine already holds USAGE artifacts that are never shipped
1459
+ // as install defaults — real Daily notes or Weekly/Monthly/Quarterly Reviews.
1460
+ // The installer ships those folders EMPTY (.gitkeep only), so this can never
1461
+ // false-refuse a fresh install (which is exactly why the sentinel-only guard
1462
+ // existed). Voice_DNA.md is NOT used here — it IS shipped as a default.
1463
+ const usageDirs = ["Dailies", "Weekly Reviews", "Monthly Reviews", "Quarterly Reviews"];
1464
+ const hasRealNote = (root) => {
1465
+ let found = false;
1466
+ const walk = (d) => {
1467
+ let entries;
1468
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
1469
+ for (const e of entries) {
1470
+ if (found) return;
1471
+ const full = path.join(d, e.name);
1472
+ if (e.isDirectory()) walk(full);
1473
+ else if (e.name.toLowerCase().endsWith(".md")) found = true;
1474
+ }
1475
+ };
1476
+ walk(path.join(targetDir, root));
1477
+ return found;
1478
+ };
1479
+ const usage = usageDirs.filter(hasRealNote);
1480
+ if (usage.length > 0) {
1481
+ throw new Error(
1482
+ `[engine-writer] SAFETY BLOCK: the Engine already contains usage history (${usage.join(", ")}). ` +
1483
+ `Refusing to overwrite a real prior Engine (a fresh install ships these folders empty).`
1484
+ );
1485
+ }
1486
+ // sol flaw #3 COMPLETION (2026-07-11): a partial Engine whose user data lives
1487
+ // ONLY in a non-sentinel file (Goals / Dossier / Active_Context /
1488
+ // Auto_Learnings / Metrics_Log) with no dailies. Fingerprint each existing
1489
+ // target against the KNOWN shipped defaults across versions
1490
+ // (engine-default-fingerprints.json, generated from git history). A file that
1491
+ // matches ANY known default is a fresh OR older-version install default ->
1492
+ // allow (this is what fixed the cross-version false-refuse that made the
1493
+ // current-only fingerprint unsafe). A non-empty file matching NONE was
1494
+ // modified by the user = real data -> refuse. If the manifest is missing, the
1495
+ // sentinel + usage-artifact checks above still stand.
1496
+ let fingerprints = null;
1497
+ try { fingerprints = require("./engine-default-fingerprints.json").files; } catch {}
1498
+ if (fingerprints) {
1499
+ const crypto = require("crypto");
1500
+ const dnHash = (s) => crypto.createHash("sha256").update(String(s).replace(/\r\n/g, "\n").trim()).digest("hex");
1501
+ for (const { path: filePath } of files) {
1502
+ const base = path.basename(filePath);
1503
+ if (SENTINEL_FILES.has(base)) continue;
1504
+ const known = fingerprints[base];
1505
+ if (!Array.isArray(known) || known.length === 0) continue; // no default tracked (e.g. a Daily)
1506
+ let onDisk;
1507
+ try { onDisk = fs.readFileSync(filePath, "utf8"); } catch { continue; } // absent = fresh
1508
+ if (String(onDisk).replace(/\r\n/g, "\n").trim().length === 0) continue; // empty = nothing to lose
1509
+ if (!known.includes(dnHash(onDisk))) {
1510
+ throw new Error(
1511
+ `[engine-writer] SAFETY BLOCK: ${base} differs from every shipped default and holds real user data. ` +
1512
+ `Refusing to overwrite an existing Engine.`
1513
+ );
1514
+ }
1515
+ }
1516
+ }
1419
1517
  }
1420
1518
 
1421
- // ── Atomic-ish multi-file commit (Bug1+2) ──────────────────────────────────
1422
- // Write EVERY file to its tmp first; only once all 8 succeed do we rename them in. A failure during
1423
- // the write phase (disk full, ENOTDIR, perms) unlinks every staged tmp and commits NOTHING — so a
1424
- // retry is never locked out by a half-seeded sentinel (Identity_Prime/Strategy), and no orphaned
1425
- // .engine-writer.tmp is left behind. (Per-file rename is itself atomic; once the bytes are on disk a
1426
- // same-dir rename effectively never fails, so the residual rename-phase window is negligible.)
1519
+ // ── Atomic-ish multi-file commit (Bug1+2, hardened R5-A) ────────────────────
1520
+ // Write EVERY file to its tmp first; only once all succeed do we rename them
1521
+ // in. A failure during the write phase (disk full, ENOTDIR, perms) unlinks
1522
+ // every staged tmp and commits NOTHING — so a retry is never locked out by a
1523
+ // half-seeded sentinel (Identity_Prime/Strategy), and no orphaned
1524
+ // .engine-writer.tmp is left behind.
1525
+ //
1526
+ // The rename phase is NOT assumed infallible: the target is an Obsidian
1527
+ // vault, and synced vaults (iCloud/OneDrive/Dropbox) intermittently lock
1528
+ // files mid-rename. Two defenses: (1) the SENTINEL files rename LAST, so a
1529
+ // failure on any other file can never seed the guard that locks retries out;
1530
+ // (2) on a rename failure every already-renamed file is rolled back to its
1531
+ // tmp, restoring the pre-write state exactly, before rethrowing.
1427
1532
  const staged = [];
1428
1533
  try {
1429
1534
  for (const { path: filePath, content } of files) {
@@ -1436,9 +1541,50 @@ function writeEngine({ transcript, digest = "", targetDir, dryRun = true, force
1436
1541
  for (const { tmp } of staged) { try { fs.unlinkSync(tmp); } catch (_) {} }
1437
1542
  throw e; // nothing renamed → no partial Engine, no force=false lockout, no dangling tmp
1438
1543
  }
1439
- for (const { tmp, filePath } of staged) {
1440
- fs.renameSync(tmp, filePath); // atomic: readers never see a partial file
1544
+ const SENTINELS = new Set(["Identity_Prime.md", "Strategy.md"]);
1545
+ const renameOrder = [
1546
+ ...staged.filter((s) => !SENTINELS.has(path.basename(s.filePath))),
1547
+ ...staged.filter((s) => SENTINELS.has(path.basename(s.filePath))),
1548
+ ];
1549
+ const renamed = [];
1550
+ try {
1551
+ for (const entry of renameOrder) {
1552
+ // A forward rename REPLACES any existing file (the structure installer
1553
+ // ships non-empty defaults for the non-sentinel targets) — park the
1554
+ // original first so a rollback can restore it, not just delete the new
1555
+ // bytes (Codex R5-C catch: the first rollback lost the pre-existing
1556
+ // Dossier default on a mid-commit rename failure).
1557
+ entry.bak = null;
1558
+ if (fs.existsSync(entry.filePath)) {
1559
+ // sol flaw #4 (2026-07-11): the old code SWALLOWED a park failure and then
1560
+ // overwrote the original anyway — so if the .bak rename failed, the
1561
+ // original bytes were replaced with no backup, and a later rollback had
1562
+ // nothing to restore (reproduced: Goals.md vanished after a later
1563
+ // Metrics_Log rename failure). Fail CLOSED instead: if we cannot back up
1564
+ // an existing file, do NOT overwrite it. The throw lands in the rollback
1565
+ // below with this file's original still in place, untouched.
1566
+ entry.bak = entry.filePath + ".engine-writer.bak";
1567
+ fs.renameSync(entry.filePath, entry.bak);
1568
+ }
1569
+ fs.renameSync(entry.tmp, entry.filePath); // atomic: readers never see a partial file
1570
+ renamed.push(entry);
1571
+ }
1572
+ } catch (e) {
1573
+ // Roll back. Order matters: move the new bytes out of the way, then
1574
+ // every parked original goes BACK to its path — including the entry
1575
+ // whose rename failed AFTER parking (its original sits at .bak with
1576
+ // nothing at filePath; deleting that bak would lose it).
1577
+ for (const { tmp, filePath } of renamed) {
1578
+ try { fs.renameSync(filePath, tmp); } catch (_) {}
1579
+ }
1580
+ for (const { filePath, bak } of staged) {
1581
+ if (bak && fs.existsSync(bak)) { try { fs.renameSync(bak, filePath); } catch (_) {} }
1582
+ }
1583
+ for (const { tmp } of staged) { try { fs.unlinkSync(tmp); } catch (_) {} }
1584
+ throw e;
1441
1585
  }
1586
+ // Success: clear the parked originals.
1587
+ for (const { bak } of renamed) { if (bak) { try { fs.unlinkSync(bak); } catch (_) {} } }
1442
1588
 
1443
1589
  return { files, written: true };
1444
1590
  }
@@ -15,23 +15,30 @@ function computeGoalForecast({ strategy, activeContext, distribution, engineHeal
15
15
  now,
16
16
  });
17
17
 
18
- const current = parsed.current;
18
+ const current = parsed.current; // null when progress is unmeasured
19
19
  const target = parsed.target;
20
- const needed = Math.max(0, target - current);
20
+ const hasProgress = current != null;
21
+ const needed = (target && hasProgress) ? Math.max(0, target - current) : null;
21
22
  const deadline = parsed.deadline;
22
23
  const daysRemaining = deadline ? daysBetween(localDateKey(now), deadline) : null;
23
24
  const daysSafe = daysRemaining == null ? null : Math.max(0, daysRemaining);
24
- const requiredPerDay = daysSafe == null
25
+ const requiredPerDay = (needed == null || daysSafe == null)
25
26
  ? null
26
27
  : daysSafe === 0 ? needed : round(needed / daysSafe, 2);
27
- const progressPct = target > 0 ? Math.min(100, Math.round((current / target) * 100)) : 0;
28
+ // progressPct is null (UNMEASURED), never a false 0, when no scoped progress exists.
29
+ const progressPct = (target > 0 && hasProgress) ? Math.min(100, Math.round((current / target) * 100)) : null;
28
30
 
31
+ // Status is scoped to the OUTCOME. A real dated target with no scoped progress
32
+ // reads "unknown" (-> "Not measured" in the UI), never a fabricated on-track. A
33
+ // scoped zero reads on-track (0 of 2, deadline ahead). [sol #11 cross-scope]
29
34
  let status = "unknown";
30
- if (target && current >= target) status = "complete";
35
+ if (!target) status = "unknown";
36
+ else if (hasProgress && current >= target) status = "complete";
31
37
  else if (daysRemaining != null && daysRemaining < 0) status = "failed";
38
+ else if (!hasProgress) status = "unknown";
32
39
  else if (daysRemaining != null && daysRemaining <= 1 && needed > 2) status = "unlikely";
33
40
  else if (requiredPerDay != null && requiredPerDay > 1) status = "at-risk";
34
- else if (target) status = "on-track";
41
+ else status = "on-track";
35
42
 
36
43
  // Only act on distribution we actually measured. Unavailable (null) distribution
37
44
  // must NOT trigger a "0 actions logged" experiment. [false-zero — Codex, T261]
@@ -57,7 +64,7 @@ function computeGoalForecast({ strategy, activeContext, distribution, engineHeal
57
64
  pushAction(adjustments, workflows, {
58
65
  workflow: "/pivot-strategy",
59
66
  title: status === "failed" ? "Close the failed Single Test" : "Recalibrate the current strategy",
60
- detail: `${current}/${target} against the active target. Keep the test, kill it, or replace it with a dated operating gate.`,
67
+ detail: `${current ?? "—"}/${target} against the active target. Keep the test, kill it, or replace it with a dated operating gate.`,
61
68
  priority: status === "failed" ? "critical" : "high",
62
69
  });
63
70
  }
@@ -85,14 +92,34 @@ function computeGoalForecast({ strategy, activeContext, distribution, engineHeal
85
92
  return {
86
93
  available,
87
94
  singleTest: parsed.singleTest || null,
95
+ // current stays null when the target is parseable but progress is unmeasured
96
+ // (a clean bet with no scoped record) — the UI shows "Progress not measured".
88
97
  current: available ? current : null,
89
98
  target: available ? target : null,
90
99
  unit: parsed.unit,
100
+ // kind rides with the parsed counter when one exists (the verdict must
101
+ // cite what the numbers actually count); keyword classification only
102
+ // labels the no-counter cases (revenue/vitality wording, available:false).
103
+ kind: parsed.counterKind || classifyGoalKind(strategy && strategy.singleTest),
104
+ // shape: "clean" (one metric + date), "compound" (a multi-signal gate), or
105
+ // "unparseable" (no target the dashboard can score). The hero renders each
106
+ // differently; a compound/legacy bet gets a "rewrite for clarity" affordance.
107
+ gate: parsed.gate === true,
108
+ shape: parsed.shape || (parsed.gate ? "compound" : (available ? "clean" : "unparseable")),
109
+ // whether `current` came from a real test-scoped record vs is just unmeasured.
110
+ progressMeasured: parsed.progressMeasured === true,
111
+ // the input commitment (daily quota) + the channel it names, both SEPARATE
112
+ // from the bet — Today renders these, never the hero.
113
+ commitment: (strategy && strategy.commitment) || null,
114
+ channel: parseChannel(strategy && strategy.commitment),
91
115
  deadline,
92
116
  daysRemaining,
93
- needed,
94
- requiredPerDay,
95
- progressPct,
117
+ // Derived pace numerals are honest-null when there is no measurable target
118
+ // (a gate/unparseable test) OR when the target is real but progress is
119
+ // unmeasured. Secondary readers that copy them raw must not report a false 0%.
120
+ needed: available ? needed : null,
121
+ requiredPerDay: available ? requiredPerDay : null,
122
+ progressPct: available ? progressPct : null,
96
123
  status,
97
124
  source: parsed.source,
98
125
  distribution7d: total7dKnown ? total7d : null,
@@ -106,22 +133,87 @@ function pushAction(adjustments, workflows, action) {
106
133
  if (action.workflow && !workflows.includes(action.workflow)) workflows.push(action.workflow);
107
134
  }
108
135
 
136
+ // The channel named in the Commitment ("...via X replies and DMs; ...") — the
137
+ // hero's support line uses it ("From X replies and DMs, by Jul 26"). Null-safe.
138
+ function parseChannel(commitment) {
139
+ if (!commitment) return null;
140
+ const m = String(commitment).match(/\b(?:via|through|on|using)\s+([^;.,]+?)(?=\s*[;.,]|$)/i);
141
+ return m ? m[1].trim() : null;
142
+ }
143
+
144
+ // A scoped progress record's unit must be the SAME kind of thing as the target.
145
+ // The HEAD noun (last word) must match, so "0/2 paying customers" counts toward a
146
+ // "customers" target, but "2/2 customer support calls" (head = calls) and
147
+ // "1/2 customer interviews" (head = interviews) do NOT — a shared adjective is not
148
+ // a unit match, or a same-denominator side metric fabricates "Complete". [sol #12]
149
+ function unitFamilyMatch(scopedUnit, targetUnit) {
150
+ const norm = (s) => String(s || "").toLowerCase().replace(/[^a-z ]+/g, " ").trim().replace(/s\b/g, "");
151
+ const t = norm(targetUnit).split(/\s+/).filter(Boolean);
152
+ const s = norm(scopedUnit).split(/\s+/).filter(Boolean);
153
+ if (!t.length || !s.length) return false;
154
+ return t[t.length - 1] === s[s.length - 1]; // head nouns must be identical
155
+ }
156
+
157
+ // What currency is the goal stated in, and does anything on file measure it?
158
+ // Drives the goal-anchored verdict on Evidence: an installs goal is pushed by
159
+ // distribution receipts; a distribution goal IS the receipts; a revenue or
160
+ // vitality goal has no parser yet, so the card must say "unmeasured" instead
161
+ // of pretending workflow counts answer it. Order matters: adoption phrasing
162
+ // ("15 activated installs") usually also names the channel, so adoption wins
163
+ // when both appear. Returns null when nothing matches (generic verdict).
164
+ function classifyGoalKind(singleTest) {
165
+ const t = String(singleTest || "").toLowerCase();
166
+ if (!t) return null;
167
+ if (/\b(installs?|activat|referrals?|sign-?ups?|customers?|users?|sales?|purchases?|buyers?|strangers?)/.test(t)) return "adoption";
168
+ if (/\b(dms?|outreach|repl(y|ies)|posts?|publish\w*|distribut\w*|articles?|reels?|videos?|content|newsletters?|followers?|subscribers?|views?|audience|impressions?)\b/.test(t)) return "distribution";
169
+ if (/(revenue|mrr\b|income|profit|£|\$|€)/.test(t)) return "revenue";
170
+ if (/\b(sleep|gym|weight|prayers?|fajr|salah|steps|health|energy|fasting)\b/.test(t)) return "vitality";
171
+ return null;
172
+ }
173
+
109
174
  function parseGoalProgress({ singleTest, sourceText, targetDate, now }) {
110
175
  const out = {
111
176
  singleTest: singleTest || null,
112
- current: 0,
177
+ current: null, // measured ONLY from a test-scoped record; null = unmeasured
113
178
  target: 0,
114
179
  unit: "customers",
180
+ counterKind: null,
181
+ shape: "unparseable",
182
+ progressMeasured: false,
115
183
  deadline: null,
116
184
  source: "strategy",
117
185
  };
118
186
 
119
187
  if (singleTest) {
188
+ // A multi-signal GATE ("Hit 2 of 3": 10 conversations / 5 install starts /
189
+ // 2 paid) is not one countable number. The single-counter regex below would
190
+ // grab "5 install starts" as target=5 and pair it with an unrelated sales
191
+ // count elsewhere on file, fabricating "4 of 5 installs". Detect the gate,
192
+ // refuse to collapse it, keep the deadline so the card can still pace to the
193
+ // date, and let the UI render the honest "scored by hand" state.
194
+ //
195
+ // The marker must be a gate VERDICT, not a bare "N of M": require a scoring
196
+ // verb (hit/pass/meet/clear, "of" or "out of") OR an "N of M =" verdict
197
+ // clause. A bare "N of M" would false-trigger on "Day 2 of 14: send 50 DMs"
198
+ // (a real distribution counter) and hide it; the verb/"=" gate keeps those
199
+ // parsing normally. A "+" rider ("15 installs + 1 referral") has no "of" at
200
+ // all and still parses its primary counter. [gate honesty, sol #10 hardening]
201
+ if (/\b(?:hit|hitting|pass(?:ed)?|meet|met|clear(?:ed)?)\s+\d+\s+(?:out\s+of|of)\s+\d+\b|\b\d+\s+(?:out\s+of|of)\s+\d+\s*=/i.test(singleTest)) {
202
+ out.gate = true;
203
+ out.counterKind = "gate";
204
+ out.shape = "compound";
205
+ out.deadline = extractDate(singleTest, now) || targetDate || null;
206
+ return out; // target stays 0 -> available:false -> honest-null, no fabricated counter
207
+ }
120
208
  // Regex captures count-based phrasing (e.g. "10 paying customers/sales")
121
209
  // AND activation phrasing ("N activated installs / referrals"). The
122
210
  // extended unit list keeps backwards-compat and makes an activation-style
123
211
  // mid-term metric ("N activated installs + M referrals by <date>") parse correctly.
124
- const targetMatch = singleTest.match(/\b(\d+)\s+(?:paying\s+|activated\s+)?(?:strangers?|customers?|sales|users?|installs?|referrals?|activations?)\b/i);
212
+ // The negative lookahead stops an adoption noun acting as a MODIFIER of a
213
+ // distribution noun from being read as the counter: "10 sales posts" is a
214
+ // publishing target, never a sales counter. [terra F3, 2026-07-10]
215
+ const DIST_NOUN = "posts?|dms?|reels?|articles?|videos?|emails?|threads?|newsletters?";
216
+ const targetMatch = singleTest.match(new RegExp("\\b(\\d+)\\s+(?:paying\\s+|activated\\s+)?(?:strangers?|customers?|sales|users?|installs?|referrals?|activations?)\\b(?!\\s+(?:" + DIST_NOUN + ")\\b)", "i"));
125
217
  if (targetMatch) {
126
218
  out.target = Number(targetMatch[1]);
127
219
  // Set unit from the matched phrase so the hero strip says
@@ -131,29 +223,45 @@ function parseGoalProgress({ singleTest, sourceText, targetDate, now }) {
131
223
  else if (/referral/.test(phrase)) out.unit = "referrals";
132
224
  else if (/user/.test(phrase)) out.unit = "users";
133
225
  else out.unit = "customers";
226
+ out.counterKind = "adoption";
227
+ out.shape = "clean";
228
+ } else {
229
+ // Distribution counter ("publish 10 posts", "send 50 DMs", "10 sales
230
+ // posts"): the kind rides with the SAME match that set the number, so
231
+ // the verdict can never cite a counter the parse didn't produce.
232
+ const distMatch = singleTest.match(new RegExp("\\b(\\d+)\\s+(?:[a-z-]+\\s+)?(" + DIST_NOUN + ")\\b", "i"));
233
+ if (distMatch) {
234
+ out.target = Number(distMatch[1]);
235
+ out.unit = distMatch[2].toLowerCase();
236
+ out.counterKind = "distribution";
237
+ out.shape = "clean";
238
+ }
134
239
  }
135
240
  const deadline = extractDate(singleTest, now) || targetDate;
136
241
  if (deadline) out.deadline = deadline;
137
242
  }
138
243
 
139
244
  const text = sourceText || "";
140
- const ratios = Array.from(text.matchAll(/\b(\d+)\s*\/\s*(\d+)\s*(?:sales|paying|customers?|strangers?|users?)\b/gi));
141
- if (ratios.length) {
142
- const latest = ratios[ratios.length - 1];
143
- out.current = Number(latest[1]);
144
- if (!out.target) out.target = Number(latest[2]);
145
- out.source = "active_context_ratio";
146
- }
147
245
 
148
- const sales = [
149
- ...Array.from(text.matchAll(/\b(?:LIVE,\s*|Status:\s*[^\n]{0,60}?|confirmed\s+|current:\s*)(\d+)\s+(?:SALES|sales|purchases)\b/g)),
150
- ...Array.from(text.matchAll(/\b(\d+)\s+sales\s+confirmed\b/gi)),
151
- ];
152
- if (sales.length) {
153
- const max = Math.max(...sales.map(m => Number(m[1])).filter(Number.isFinite));
154
- if (Number.isFinite(max) && max > out.current) {
155
- out.current = max;
156
- out.source = "active_context_sales";
246
+ // Progress is TEST-SCOPED, and ONLY test-scoped. A generic lifetime line
247
+ // ("LIVE, 4 SALES" — all from April) or a historical ratio ("4/10 sales" from
248
+ // a dead strategy) must NOT count as progress toward THIS bet; doing so
249
+ // fabricated "4 of 2 · complete" the moment the target became a clean 2. The
250
+ // ONLY thing that counts as current is an explicit scoped record whose
251
+ // denominator and unit family match the active target:
252
+ // "Current bet progress: 0/2 paying customers". [sol #11 cross-scope bug]
253
+ if (out.target) {
254
+ const scoped = text.match(/current\s+bet\s+progress:\s*(\d+)\s*\/\s*(\d+)\s*([a-z][a-z ]*)?/i);
255
+ if (scoped) {
256
+ const cur = Number(scoped[1]);
257
+ const den = Number(scoped[2]);
258
+ const scopedUnit = (scoped[3] || "").trim();
259
+ const unitOk = !scopedUnit || unitFamilyMatch(scopedUnit, out.unit);
260
+ if (Number.isFinite(cur) && den === out.target && unitOk) {
261
+ out.current = cur; // a scoped ZERO is honest progress, not "unmeasured"
262
+ out.progressMeasured = true;
263
+ out.source = "scoped_progress";
264
+ }
157
265
  }
158
266
  }
159
267
 
@@ -166,8 +274,12 @@ function parseGoalProgress({ singleTest, sourceText, targetDate, now }) {
166
274
 
167
275
  function extractDate(text, now) {
168
276
  if (!text) return null;
169
- const iso = text.match(/\b(20\d{2}-[01]\d-[0-3]\d)\b/);
170
- if (iso) return iso[1];
277
+ const iso = text.match(/\b(20\d{2})-([01]\d)-([0-3]\d)\b/);
278
+ if (iso) {
279
+ // Impossible dates (2026-02-30) must yield NO deadline, not a silent
280
+ // JS rollover to March 2 rendered as a real target. [terra F4, 2026-07-10]
281
+ return validCalendarDay(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])) ? iso[0] : null;
282
+ }
171
283
  const months = ["jan","feb","mar","apr","may","jun","jul","aug","sep","sept","oct","nov","dec","january","february","march","april","june","july","august","september","october","november","december"];
172
284
  const m = text.match(new RegExp(`\\b(?:by\\s+)?(${months.join("|")})\\s+(\\d{1,2})(?:,?\\s*(20\\d{2}))?`, "i"));
173
285
  if (!m) return null;
@@ -175,9 +287,17 @@ function extractDate(text, now) {
175
287
  const day = Number(m[2]);
176
288
  const year = m[3] ? Number(m[3]) : now.getFullYear();
177
289
  if (month < 0 || !Number.isFinite(day)) return null;
290
+ if (!validCalendarDay(year, month, day)) return null; // "Feb 30" rolls over too
178
291
  return localDateKey(new Date(year, month, day));
179
292
  }
180
293
 
294
+ // True only when (year, month0, day) names a day that exists: round-trip
295
+ // through UTC and require every component to survive unchanged.
296
+ function validCalendarDay(year, month0, day) {
297
+ const d = new Date(Date.UTC(year, month0, day));
298
+ return d.getUTCFullYear() === year && d.getUTCMonth() === month0 && d.getUTCDate() === day;
299
+ }
300
+
181
301
  function monthIndex(name) {
182
302
  const n = String(name || "").toLowerCase().slice(0, 3);
183
303
  return { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 }[n] ?? -1;
@@ -301,4 +421,7 @@ function parseChainStages(chain) {
301
421
  .map((label) => ({ label, current: null }));
302
422
  }
303
423
 
304
- module.exports = { computeGoalForecast, parseDomainProgress };
424
+ // parseGoalProgress exported for the menu-bar CLI (cmdPulseBar): the bar's
425
+ // mission number must come from the SAME parse the dashboard shows, never a
426
+ // second implementation that can drift. [owner: glance-number = the bet]
427
+ module.exports = { computeGoalForecast, parseGoalProgress, parseDomainProgress, classifyGoalKind };
@@ -3,6 +3,7 @@
3
3
  const path = require("path");
4
4
  const {
5
5
  archivesDir,
6
+ engineDir,
6
7
  libraryDir,
7
8
  libraryCheatsheetsDir,
8
9
  libraryPrinciplesDir,
@@ -189,6 +190,18 @@ function indexLibraryV2(vault) {
189
190
  .filter((e) => !e.name.startsWith(".") && !e.name.startsWith("_")).length;
190
191
  }
191
192
 
193
+ // Engine: count-only, top-level FILES of 02_Areas/Engine (subdirs like
194
+ // Dailies/ and State/ are their own worlds; the Library shelf browse lists
195
+ // exactly these files). Replaces a hardcoded 12 in the UI mapper — terra
196
+ // confirmed it fabricated (real vault held 15) and it contaminated the
197
+ // "indexed" headline sum (2026-07-12). Not added to totalCount, same
198
+ // reasoning as archives.
199
+ const engine = { count: 0 };
200
+ if (exists(engineDir(vault))) {
201
+ engine.count = safeListDir(engineDir(vault))
202
+ .filter((e) => e.isFile && !e.name.startsWith(".")).length;
203
+ }
204
+
192
205
  const totalCount =
193
206
  mocs.count + principles.count + cheatsheets.count + sops.count +
194
207
  scripts.count + inputs.count + entities.count;
@@ -205,6 +218,7 @@ function indexLibraryV2(vault) {
205
218
  inputs,
206
219
  entities,
207
220
  archives,
221
+ engine,
208
222
  },
209
223
  };
210
224
  }