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.
- package/README.md +34 -24
- package/install.js +2868 -251
- package/package.json +15 -3
- package/src/dashboard/build.js +1541 -0
- package/src/dashboard/dashboard.js +276 -0
- package/src/dashboard/index.js +319 -0
- package/src/dashboard/lib/activation-log.js +297 -0
- package/src/dashboard/lib/active-context-parser.js +189 -0
- package/src/dashboard/lib/agent-command.js +93 -0
- package/src/dashboard/lib/agent-detect.js +255 -0
- package/src/dashboard/lib/agent-detector.js +92 -0
- package/src/dashboard/lib/agent-session.js +483 -0
- package/src/dashboard/lib/anti-identity-detector.js +116 -0
- package/src/dashboard/lib/approval-registry.js +170 -0
- package/src/dashboard/lib/auto-learnings-parser.js +183 -0
- package/src/dashboard/lib/cli-usage-parser.js +92 -0
- package/src/dashboard/lib/config-parser.js +109 -0
- package/src/dashboard/lib/connect-recommender.js +131 -0
- package/src/dashboard/lib/correlations-parser.js +231 -0
- package/src/dashboard/lib/daily-note-resolver.js +228 -0
- package/src/dashboard/lib/date-utils.js +43 -0
- package/src/dashboard/lib/distribution-parser.js +137 -0
- package/src/dashboard/lib/dossier-parser.js +64 -0
- package/src/dashboard/lib/drift-history.js +88 -0
- package/src/dashboard/lib/drift-score.js +119 -0
- package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
- package/src/dashboard/lib/engine-health.js +173 -0
- package/src/dashboard/lib/engine-writer.js +1831 -0
- package/src/dashboard/lib/execution-plan.js +125 -0
- package/src/dashboard/lib/experiments-parser.js +429 -0
- package/src/dashboard/lib/feed-parser.js +294 -0
- package/src/dashboard/lib/forked-future.js +60 -0
- package/src/dashboard/lib/goal-forecast.js +427 -0
- package/src/dashboard/lib/goals-parser.js +67 -0
- package/src/dashboard/lib/health-protocols-parser.js +133 -0
- package/src/dashboard/lib/hook-activity.js +48 -0
- package/src/dashboard/lib/hook-indexer.js +169 -0
- package/src/dashboard/lib/hourly-activity-parser.js +143 -0
- package/src/dashboard/lib/identity-parser.js +85 -0
- package/src/dashboard/lib/ingestion.js +418 -0
- package/src/dashboard/lib/library-indexer-v2.js +226 -0
- package/src/dashboard/lib/library-indexer.js +105 -0
- package/src/dashboard/lib/library-search.js +290 -0
- package/src/dashboard/lib/log-activation.sh +61 -0
- package/src/dashboard/lib/memory-curator.js +97 -0
- package/src/dashboard/lib/memory-gardener.js +177 -0
- package/src/dashboard/lib/memory-gepa.js +102 -0
- package/src/dashboard/lib/memory-index.js +519 -0
- package/src/dashboard/lib/memory-rerank.js +72 -0
- package/src/dashboard/lib/memory-text.js +136 -0
- package/src/dashboard/lib/metrics-log-parser.js +76 -0
- package/src/dashboard/lib/moves-usage-parser.js +184 -0
- package/src/dashboard/lib/onboarding-forge.js +70 -0
- package/src/dashboard/lib/override-outcome-parser.js +68 -0
- package/src/dashboard/lib/override-summary.js +73 -0
- package/src/dashboard/lib/paths.js +192 -0
- package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
- package/src/dashboard/lib/phantom-strategy.js +129 -0
- package/src/dashboard/lib/pid-markers.js +80 -0
- package/src/dashboard/lib/project-scanner.js +121 -0
- package/src/dashboard/lib/promise-wall.js +88 -0
- package/src/dashboard/lib/record-score.js +173 -0
- package/src/dashboard/lib/redaction.js +140 -0
- package/src/dashboard/lib/refusal-parser.js +44 -0
- package/src/dashboard/lib/regenerate-manifest.js +206 -0
- package/src/dashboard/lib/rewind-snapshots.js +689 -0
- package/src/dashboard/lib/roast-wall-parser.js +200 -0
- package/src/dashboard/lib/run-registry.js +286 -0
- package/src/dashboard/lib/safe-write.js +63 -0
- package/src/dashboard/lib/session-log-parser.js +145 -0
- package/src/dashboard/lib/session-time-parser.js +158 -0
- package/src/dashboard/lib/skill-index.js +171 -0
- package/src/dashboard/lib/skill-indexer.js +118 -0
- package/src/dashboard/lib/skill-recommender.js +689 -0
- package/src/dashboard/lib/state-core/backfill.js +298 -0
- package/src/dashboard/lib/state-core/dryrun.js +188 -0
- package/src/dashboard/lib/state-core/event-log.js +615 -0
- package/src/dashboard/lib/state-core/events.js +265 -0
- package/src/dashboard/lib/state-core/projections.js +376 -0
- package/src/dashboard/lib/state-core/start-close.js +162 -0
- package/src/dashboard/lib/state-core/trial.js +96 -0
- package/src/dashboard/lib/strategy-parser.js +248 -0
- package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
- package/src/dashboard/lib/streak-parser.js +95 -0
- package/src/dashboard/lib/suggested-now.js +254 -0
- package/src/dashboard/lib/tool-awareness.js +125 -0
- package/src/dashboard/lib/transcript-parser.js +371 -0
- package/src/dashboard/lib/vault-graph-parser.js +205 -0
- package/src/dashboard/lib/view-generator.js +163 -0
- package/src/dashboard/lib/voice-dna-parser.js +57 -0
- package/src/dashboard/lib/walkthrough-script.js +140 -0
- package/src/dashboard/lib/workflow-graph-parser.js +170 -0
- package/src/dashboard/lib/workflow-library-parser.js +146 -0
- package/src/dashboard/server.js +2402 -0
- package/src/dashboard/shortcut.js +284 -0
- package/src/dashboard/static/setup-poc.html +306 -0
- package/src/dashboard/static/walkthrough-poc.html +580 -0
- package/src/dashboard/styles.css +1201 -0
- package/src/dashboard/templates/index.html +278 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hero.png +0 -0
- package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
- package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
- package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
- package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
- package/src/dashboard/ui/dist/icon.svg +4 -0
- package/src/dashboard/ui/dist/index.html +18 -0
- package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
- package/src/dashboard/ui/dist/registerSW.js +1 -0
- package/src/dashboard/ui/dist/sw.js +1 -0
- package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { localDateKey } = require("./date-utils");
|
|
4
|
+
|
|
5
|
+
function computeGoalForecast({ strategy, activeContext, distribution, engineHealth, daily, now = new Date() } = {}) {
|
|
6
|
+
const sourceText = [
|
|
7
|
+
activeContext && activeContext.raw,
|
|
8
|
+
strategy && strategy.singleTest,
|
|
9
|
+
].filter(Boolean).join("\n");
|
|
10
|
+
|
|
11
|
+
const parsed = parseGoalProgress({
|
|
12
|
+
singleTest: strategy && strategy.singleTest,
|
|
13
|
+
sourceText,
|
|
14
|
+
targetDate: strategy && strategy.targetDate,
|
|
15
|
+
now,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const current = parsed.current; // null when progress is unmeasured
|
|
19
|
+
const target = parsed.target;
|
|
20
|
+
const hasProgress = current != null;
|
|
21
|
+
const needed = (target && hasProgress) ? Math.max(0, target - current) : null;
|
|
22
|
+
const deadline = parsed.deadline;
|
|
23
|
+
const daysRemaining = deadline ? daysBetween(localDateKey(now), deadline) : null;
|
|
24
|
+
const daysSafe = daysRemaining == null ? null : Math.max(0, daysRemaining);
|
|
25
|
+
const requiredPerDay = (needed == null || daysSafe == null)
|
|
26
|
+
? null
|
|
27
|
+
: daysSafe === 0 ? needed : round(needed / daysSafe, 2);
|
|
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;
|
|
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]
|
|
34
|
+
let status = "unknown";
|
|
35
|
+
if (!target) status = "unknown";
|
|
36
|
+
else if (hasProgress && current >= target) status = "complete";
|
|
37
|
+
else if (daysRemaining != null && daysRemaining < 0) status = "failed";
|
|
38
|
+
else if (!hasProgress) status = "unknown";
|
|
39
|
+
else if (daysRemaining != null && daysRemaining <= 1 && needed > 2) status = "unlikely";
|
|
40
|
+
else if (requiredPerDay != null && requiredPerDay > 1) status = "at-risk";
|
|
41
|
+
else status = "on-track";
|
|
42
|
+
|
|
43
|
+
// Only act on distribution we actually measured. Unavailable (null) distribution
|
|
44
|
+
// must NOT trigger a "0 actions logged" experiment. [false-zero — Codex, T261]
|
|
45
|
+
const total7dKnown = !!(distribution && distribution.available !== false && Number.isFinite(distribution.total7d));
|
|
46
|
+
const total7d = total7dKnown ? distribution.total7d : 0;
|
|
47
|
+
const adjustments = [];
|
|
48
|
+
const workflows = [];
|
|
49
|
+
|
|
50
|
+
if (engineHealth && engineHealth.analyseDay && engineHealth.analyseDay.status === "RED") {
|
|
51
|
+
pushAction(adjustments, workflows, {
|
|
52
|
+
workflow: "/analyse-day",
|
|
53
|
+
title: "Run a day review before more strategy edits",
|
|
54
|
+
detail: `You haven't reviewed a day in ${engineHealth.analyseDay.overdueDays} days. Run /analyse-day to get the dashboard current.`,
|
|
55
|
+
priority: "critical",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
// Only treat strategy health as "trouble" when the scan actually ran and
|
|
59
|
+
// returned a concrete RED/YELLOW. A null status from an unavailable scan must
|
|
60
|
+
// NOT trigger /pivot-strategy. [false-zero regression fix — Codex P1, T261]
|
|
61
|
+
const strategyTrouble = engineHealth && engineHealth.available !== false
|
|
62
|
+
&& engineHealth.strategy && (engineHealth.strategy.status === "RED" || engineHealth.strategy.status === "YELLOW");
|
|
63
|
+
if (status === "failed" || status === "unlikely" || strategyTrouble) {
|
|
64
|
+
pushAction(adjustments, workflows, {
|
|
65
|
+
workflow: "/pivot-strategy",
|
|
66
|
+
title: status === "failed" ? "Close the failed Single Test" : "Recalibrate the current strategy",
|
|
67
|
+
detail: `${current ?? "—"}/${target} against the active target. Keep the test, kill it, or replace it with a dated operating gate.`,
|
|
68
|
+
priority: status === "failed" ? "critical" : "high",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (needed > 0 && total7dKnown && total7d < 10) {
|
|
72
|
+
pushAction(adjustments, workflows, {
|
|
73
|
+
workflow: "/experiment",
|
|
74
|
+
title: "Open a distribution experiment",
|
|
75
|
+
detail: `Only ${total7d} distribution actions logged in 7 days. Test one channel loop with a falsifiable metric instead of adding more dashboard surface.`,
|
|
76
|
+
priority: "high",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (daily && daily.today && !daily.today.exists) {
|
|
80
|
+
pushAction(adjustments, workflows, {
|
|
81
|
+
workflow: "/plan-tomorrow",
|
|
82
|
+
title: "Set today's execution list",
|
|
83
|
+
detail: "No Daily Note found for today, so the dashboard is missing the live plan it should enforce.",
|
|
84
|
+
priority: "medium",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// available:false when no real target parsed → null out the numerals so the UI
|
|
89
|
+
// shows its honest "no measurable test" / "calibrating" state, never a
|
|
90
|
+
// fabricated "0/10". [false-zero discipline, T261]
|
|
91
|
+
const available = !!target;
|
|
92
|
+
return {
|
|
93
|
+
available,
|
|
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".
|
|
97
|
+
current: available ? current : null,
|
|
98
|
+
target: available ? target : null,
|
|
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),
|
|
115
|
+
deadline,
|
|
116
|
+
daysRemaining,
|
|
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,
|
|
123
|
+
status,
|
|
124
|
+
source: parsed.source,
|
|
125
|
+
distribution7d: total7dKnown ? total7d : null,
|
|
126
|
+
adjustments,
|
|
127
|
+
workflows,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function pushAction(adjustments, workflows, action) {
|
|
132
|
+
adjustments.push(action);
|
|
133
|
+
if (action.workflow && !workflows.includes(action.workflow)) workflows.push(action.workflow);
|
|
134
|
+
}
|
|
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
|
+
|
|
174
|
+
function parseGoalProgress({ singleTest, sourceText, targetDate, now }) {
|
|
175
|
+
const out = {
|
|
176
|
+
singleTest: singleTest || null,
|
|
177
|
+
current: null, // measured ONLY from a test-scoped record; null = unmeasured
|
|
178
|
+
target: 0,
|
|
179
|
+
unit: "customers",
|
|
180
|
+
counterKind: null,
|
|
181
|
+
shape: "unparseable",
|
|
182
|
+
progressMeasured: false,
|
|
183
|
+
deadline: null,
|
|
184
|
+
source: "strategy",
|
|
185
|
+
};
|
|
186
|
+
|
|
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
|
+
}
|
|
208
|
+
// Regex captures count-based phrasing (e.g. "10 paying customers/sales")
|
|
209
|
+
// AND activation phrasing ("N activated installs / referrals"). The
|
|
210
|
+
// extended unit list keeps backwards-compat and makes an activation-style
|
|
211
|
+
// mid-term metric ("N activated installs + M referrals by <date>") parse correctly.
|
|
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"));
|
|
217
|
+
if (targetMatch) {
|
|
218
|
+
out.target = Number(targetMatch[1]);
|
|
219
|
+
// Set unit from the matched phrase so the hero strip says
|
|
220
|
+
// "activated installs" instead of the default "customers" on V4.
|
|
221
|
+
const phrase = targetMatch[0].toLowerCase();
|
|
222
|
+
if (/install/.test(phrase)) out.unit = "activated installs";
|
|
223
|
+
else if (/referral/.test(phrase)) out.unit = "referrals";
|
|
224
|
+
else if (/user/.test(phrase)) out.unit = "users";
|
|
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
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const deadline = extractDate(singleTest, now) || targetDate;
|
|
241
|
+
if (deadline) out.deadline = deadline;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const text = sourceText || "";
|
|
245
|
+
|
|
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
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// No fabricated target: if the Single Test carries no parseable number,
|
|
269
|
+
// target stays 0 → computeGoalForecast reports available:false (honest "no
|
|
270
|
+
// measurable test") instead of a made-up "0/10". [false-zero discipline, T261]
|
|
271
|
+
if (!out.deadline) out.deadline = targetDate || extractDate(text, now);
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function extractDate(text, now) {
|
|
276
|
+
if (!text) return null;
|
|
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
|
+
}
|
|
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"];
|
|
284
|
+
const m = text.match(new RegExp(`\\b(?:by\\s+)?(${months.join("|")})\\s+(\\d{1,2})(?:,?\\s*(20\\d{2}))?`, "i"));
|
|
285
|
+
if (!m) return null;
|
|
286
|
+
const month = monthIndex(m[1]);
|
|
287
|
+
const day = Number(m[2]);
|
|
288
|
+
const year = m[3] ? Number(m[3]) : now.getFullYear();
|
|
289
|
+
if (month < 0 || !Number.isFinite(day)) return null;
|
|
290
|
+
if (!validCalendarDay(year, month, day)) return null; // "Feb 30" rolls over too
|
|
291
|
+
return localDateKey(new Date(year, month, day));
|
|
292
|
+
}
|
|
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
|
+
|
|
301
|
+
function monthIndex(name) {
|
|
302
|
+
const n = String(name || "").toLowerCase().slice(0, 3);
|
|
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;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// T242 bug hunt fix (B#16): previously `new Date(`${iso}T00:00:00`)` parsed
|
|
307
|
+
// as LOCAL time. Across DST transitions (Mar/Nov in US, Mar/Oct in EU)
|
|
308
|
+
// two consecutive days are 23 or 25 hours apart, so the ceil-divide by a
|
|
309
|
+
// fixed 86400000ms returned 0 or 2 for what should be 1. Goal countdown
|
|
310
|
+
// chips flickered around DST. Use UTC midnight + calendar-day math.
|
|
311
|
+
function daysBetween(fromIso, toIso) {
|
|
312
|
+
const fromUtc = Date.parse(`${fromIso}T00:00:00Z`);
|
|
313
|
+
const toUtc = Date.parse(`${toIso}T00:00:00Z`);
|
|
314
|
+
if (!Number.isFinite(fromUtc) || !Number.isFinite(toUtc)) return null;
|
|
315
|
+
return Math.round((toUtc - fromUtc) / (24 * 3600 * 1000));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function round(n, d) {
|
|
319
|
+
const p = Math.pow(10, d);
|
|
320
|
+
return Math.round(n * p) / p;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Per-domain measurement dispatcher ─────────────────────────────────────────
|
|
324
|
+
// Each domain declares a measurement SHAPE in Strategy.md (single | chain | scalar
|
|
325
|
+
// | streak). This routes the declaration + whatever live source exists into a
|
|
326
|
+
// render-ready progress object, honest-null wherever a domain has not logged data
|
|
327
|
+
// yet. The dashboard renders the shape; the fuel arrives once the user logs it.
|
|
328
|
+
function parseDomainProgress({ domain, decl, goalForecast, streak, metricsLog } = {}) {
|
|
329
|
+
if (!decl || !decl.type) return { available: false, type: null };
|
|
330
|
+
const type = decl.type;
|
|
331
|
+
|
|
332
|
+
if (type === "single") {
|
|
333
|
+
// Work's single test is already computed by computeGoalForecast. Other domains
|
|
334
|
+
// declaring single have no per-domain count source yet -> honest-null.
|
|
335
|
+
if (domain === "work" && goalForecast && goalForecast.available) {
|
|
336
|
+
return {
|
|
337
|
+
available: true, type: "single",
|
|
338
|
+
current: goalForecast.current, target: goalForecast.target,
|
|
339
|
+
unit: goalForecast.unit || null, progressPct: goalForecast.progressPct ?? null,
|
|
340
|
+
status: goalForecast.status || null,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
return { available: false, type: "single", current: null, target: numFrom(decl.target), unit: null };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (type === "scalar") {
|
|
347
|
+
const baseline = numFrom(decl.baseline);
|
|
348
|
+
const target = numFrom(decl.target);
|
|
349
|
+
const current = scalarCurrent(decl, metricsLog); // best-effort; null until logged
|
|
350
|
+
const progressPct =
|
|
351
|
+
baseline != null && target != null && current != null && target !== baseline
|
|
352
|
+
? clampPct(Math.round(((current - baseline) / (target - baseline)) * 100))
|
|
353
|
+
: null;
|
|
354
|
+
return {
|
|
355
|
+
available: current != null, type: "scalar",
|
|
356
|
+
baseline, target, current,
|
|
357
|
+
baselineText: decl.baseline || null, targetText: decl.target || null,
|
|
358
|
+
progressPct,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (type === "streak") {
|
|
363
|
+
// streak-parser tracks day-PRESENCE (a daily note exists), NOT whether the
|
|
364
|
+
// declared practice was actually done. Presenting that as the practice streak
|
|
365
|
+
// would be a false signal, so the count stays honest-null until a per-practice
|
|
366
|
+
// source exists; the ring renders its ready/empty state.
|
|
367
|
+
return {
|
|
368
|
+
available: false, type: "streak",
|
|
369
|
+
currentStreak: null, longestStreak: null, todayDone: null,
|
|
370
|
+
note: "activates once the practice is logged",
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (type === "chain") {
|
|
375
|
+
const stages = parseChainStages(decl.chain); // [] when no chain math declared
|
|
376
|
+
return {
|
|
377
|
+
available: false, type: "chain",
|
|
378
|
+
stages, bottleneck: null,
|
|
379
|
+
note: stages.length ? "awaiting per-stage numbers" : "declare the chain (revenue -> ... -> daily action)",
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return { available: false, type };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Pull a leading number out of a prose value ("7.5h sleep" -> 7.5, "78 kg" -> 78).
|
|
387
|
+
function numFrom(text) {
|
|
388
|
+
if (text == null) return null;
|
|
389
|
+
const m = String(text).match(/-?\d+(?:\.\d+)?/);
|
|
390
|
+
return m ? Number(m[0]) : null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function clampPct(n) {
|
|
394
|
+
if (!Number.isFinite(n)) return null;
|
|
395
|
+
return Math.max(0, Math.min(100, n));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Best-effort current value for a scalar domain from the metrics log's latest
|
|
399
|
+
// readings, matched by keyword. Null (honest) when nothing matches.
|
|
400
|
+
function scalarCurrent(decl, metricsLog) {
|
|
401
|
+
if (!metricsLog || metricsLog.available === false) return null;
|
|
402
|
+
const hay = ((decl.target || "") + " " + (decl.baseline || "")).toLowerCase();
|
|
403
|
+
const pick = /sleep/.test(hay) ? metricsLog.sleepLatest
|
|
404
|
+
: /weight|kg|\blb/.test(hay) ? metricsLog.weightLatest
|
|
405
|
+
: /hrv/.test(hay) ? metricsLog.hrvLatest
|
|
406
|
+
: /rhr|resting|heart/.test(hay) ? metricsLog.rhrLatest
|
|
407
|
+
: null;
|
|
408
|
+
return numFrom(pick);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Split a declared chain ("$10k = 100 sales = 1k calls at 10% = 50 DMs/day") into
|
|
412
|
+
// ordered stages. Each stage is a label only; the live per-stage numbers arrive
|
|
413
|
+
// from the logged funnel, so `current` is honest-null here.
|
|
414
|
+
function parseChainStages(chain) {
|
|
415
|
+
if (!chain) return [];
|
|
416
|
+
return String(chain)
|
|
417
|
+
.split(/\s*(?:=|->|→|>)\s*/)
|
|
418
|
+
.map((s) => s.trim())
|
|
419
|
+
.filter(Boolean)
|
|
420
|
+
.slice(0, 6)
|
|
421
|
+
.map((label) => ({ label, current: null }));
|
|
422
|
+
}
|
|
423
|
+
|
|
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 };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { goalsPath, safeRead, safeStat } = require("./paths");
|
|
4
|
+
|
|
5
|
+
// Parse Goals.md. Extracts 3-month, 1-year, 10-year goals + expiration dates.
|
|
6
|
+
|
|
7
|
+
function parseGoals(vault) {
|
|
8
|
+
const filePath = goalsPath(vault);
|
|
9
|
+
const text = safeRead(filePath);
|
|
10
|
+
const stat = safeStat(filePath);
|
|
11
|
+
if (!text) return { available: false, filePath };
|
|
12
|
+
|
|
13
|
+
const out = {
|
|
14
|
+
available: true,
|
|
15
|
+
filePath,
|
|
16
|
+
lastUpdated: stat ? stat.mtime : null,
|
|
17
|
+
raw: text,
|
|
18
|
+
horizons: []
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Split on ## headers
|
|
22
|
+
const lines = text.split("\n");
|
|
23
|
+
let current = null;
|
|
24
|
+
let buf = [];
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
const h = line.match(/^##\s+(.+?)\s*$/);
|
|
27
|
+
if (h) {
|
|
28
|
+
if (current) {
|
|
29
|
+
out.horizons.push({ title: current, body: buf.join("\n"), ...analyzeHorizon(buf.join("\n")) });
|
|
30
|
+
}
|
|
31
|
+
current = h[1];
|
|
32
|
+
buf = [];
|
|
33
|
+
} else if (current) {
|
|
34
|
+
buf.push(line);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (current) {
|
|
38
|
+
out.horizons.push({ title: current, body: buf.join("\n"), ...analyzeHorizon(buf.join("\n")) });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Identify 3-month, 1-year, 10-year via title heuristic
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
out.expired = [];
|
|
44
|
+
for (const h of out.horizons) {
|
|
45
|
+
if (h.targetDate) {
|
|
46
|
+
const dt = new Date(h.targetDate);
|
|
47
|
+
if (!isNaN(dt.getTime()) && dt.getTime() < now) {
|
|
48
|
+
out.expired.push({ title: h.title, targetDate: h.targetDate, expiredDays: Math.floor((now - dt.getTime()) / 86400000) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function analyzeHorizon(body) {
|
|
57
|
+
const dateMatch = body.match(/\*\*(?:Target Date|Date|By):?\*\*\s*([\d\-/]{8,10})/i);
|
|
58
|
+
const goalsList = body.split("\n")
|
|
59
|
+
.map(l => l.match(/^\s*[-*\d.]+\s+(.+)$/))
|
|
60
|
+
.filter(Boolean).map(m => m[1].trim());
|
|
61
|
+
return {
|
|
62
|
+
targetDate: dateMatch ? dateMatch[1] : null,
|
|
63
|
+
goals: goalsList.slice(0, 12)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { parseGoals };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const { safeListDir, safeRead, safeStat } = require("./paths");
|
|
6
|
+
|
|
7
|
+
// Parse 02_Areas/Health/*.md as actionable recovery/maintenance protocols.
|
|
8
|
+
// A "protocol" file looks like:
|
|
9
|
+
// # <Title>
|
|
10
|
+
// ## PHASE 1 — <name> (Days 1-7: <date range>)
|
|
11
|
+
// ### Every day (10 min total)
|
|
12
|
+
// - [ ] action one
|
|
13
|
+
// - [ ] action two
|
|
14
|
+
// ## PHASE 2 — ...
|
|
15
|
+
//
|
|
16
|
+
// Output for the dashboard:
|
|
17
|
+
// - title
|
|
18
|
+
// - currentPhase (detected by date range vs today, or first uncompleted)
|
|
19
|
+
// - today's checklist (unchecked items in current phase)
|
|
20
|
+
// - completedToday (checked items in current phase — for progress)
|
|
21
|
+
// - daysIntoPhase / daysRemaining (if dates present)
|
|
22
|
+
//
|
|
23
|
+
// Forgiving: never throws. Empty/missing → { available: false }.
|
|
24
|
+
|
|
25
|
+
function healthDir(vault) {
|
|
26
|
+
return path.join(vault, "02_Areas", "Health");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseDateRange(line) {
|
|
30
|
+
// Match "(May 12-19)" or "(Days 1-7: May 12-19)" or "(May 20 → Jun 30)"
|
|
31
|
+
// Returns { startISO, endISO } or null
|
|
32
|
+
const m = line.match(/\((?:[^:)]*?:)?\s*([A-Z][a-z]{2})\s+(\d{1,2})(?:\s*[-–→]\s*(?:([A-Z][a-z]{2})\s+)?(\d{1,2}))?\)/);
|
|
33
|
+
if (!m) return null;
|
|
34
|
+
const monthMap = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
|
|
35
|
+
const year = new Date().getFullYear();
|
|
36
|
+
const sm = monthMap[m[1]];
|
|
37
|
+
const sd = parseInt(m[2], 10);
|
|
38
|
+
const em = m[3] ? monthMap[m[3]] : sm;
|
|
39
|
+
const ed = m[4] ? parseInt(m[4], 10) : sd;
|
|
40
|
+
if (sm === undefined || em === undefined) return null;
|
|
41
|
+
return {
|
|
42
|
+
start: new Date(year, sm, sd),
|
|
43
|
+
end: new Date(year, em, ed)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseProtocolFile(filePath) {
|
|
48
|
+
const text = safeRead(filePath);
|
|
49
|
+
if (!text) return null;
|
|
50
|
+
const stat = safeStat(filePath);
|
|
51
|
+
|
|
52
|
+
// Title from first H1, fallback to filename
|
|
53
|
+
const titleMatch = text.match(/^#\s+(.+)$/m);
|
|
54
|
+
const title = titleMatch ? titleMatch[1].trim() : path.basename(filePath, ".md");
|
|
55
|
+
|
|
56
|
+
// Find all PHASE blocks. Phases are H2 headings that contain "PHASE" or similar.
|
|
57
|
+
const lines = text.split("\n");
|
|
58
|
+
const phases = [];
|
|
59
|
+
let currentPhase = null;
|
|
60
|
+
|
|
61
|
+
for (const raw of lines) {
|
|
62
|
+
const line = raw.trimEnd();
|
|
63
|
+
const phaseMatch = line.match(/^##\s+(.*PHASE.*)$/i);
|
|
64
|
+
if (phaseMatch) {
|
|
65
|
+
if (currentPhase) phases.push(currentPhase);
|
|
66
|
+
currentPhase = {
|
|
67
|
+
heading: phaseMatch[1].trim(),
|
|
68
|
+
dates: parseDateRange(phaseMatch[1]),
|
|
69
|
+
items: [],
|
|
70
|
+
done: 0,
|
|
71
|
+
total: 0
|
|
72
|
+
};
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!currentPhase) continue;
|
|
76
|
+
// Skip subheadings — items only
|
|
77
|
+
if (line.startsWith("##")) continue;
|
|
78
|
+
// Checkbox lines
|
|
79
|
+
const cb = line.match(/^[-*]\s+\[([ xX])\]\s+(.+)$/);
|
|
80
|
+
if (cb) {
|
|
81
|
+
const checked = cb[1].toLowerCase() === "x";
|
|
82
|
+
currentPhase.items.push({ text: cb[2].trim(), checked });
|
|
83
|
+
currentPhase.total++;
|
|
84
|
+
if (checked) currentPhase.done++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (currentPhase) phases.push(currentPhase);
|
|
88
|
+
|
|
89
|
+
// Determine the current phase: one whose date range contains today,
|
|
90
|
+
// OR the first phase with any unchecked items if no date hits.
|
|
91
|
+
const today = new Date();
|
|
92
|
+
today.setHours(0, 0, 0, 0);
|
|
93
|
+
let current = phases.find(p => p.dates && p.dates.start <= today && today <= p.dates.end);
|
|
94
|
+
if (!current) current = phases.find(p => p.done < p.total);
|
|
95
|
+
if (!current && phases.length) current = phases[phases.length - 1];
|
|
96
|
+
|
|
97
|
+
// Days remaining in current phase
|
|
98
|
+
let daysRemaining = null;
|
|
99
|
+
let daysIntoPhase = null;
|
|
100
|
+
if (current && current.dates) {
|
|
101
|
+
daysRemaining = Math.max(0, Math.ceil((current.dates.end - today) / (1000 * 60 * 60 * 24)));
|
|
102
|
+
daysIntoPhase = Math.max(0, Math.ceil((today - current.dates.start) / (1000 * 60 * 60 * 24)));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
available: true,
|
|
107
|
+
title,
|
|
108
|
+
filePath,
|
|
109
|
+
lastUpdated: stat ? stat.mtime : null,
|
|
110
|
+
phases,
|
|
111
|
+
current,
|
|
112
|
+
daysRemaining,
|
|
113
|
+
daysIntoPhase
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseHealthProtocols(vault) {
|
|
118
|
+
const dir = healthDir(vault);
|
|
119
|
+
const entries = safeListDir(dir, { filesOnly: true, extension: ".md", skipUnderscore: true });
|
|
120
|
+
const protocols = [];
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const full = path.join(dir, entry.name);
|
|
123
|
+
const p = parseProtocolFile(full);
|
|
124
|
+
if (p && p.available) protocols.push(p);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
available: protocols.length > 0,
|
|
128
|
+
dir,
|
|
129
|
+
protocols
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { parseHealthProtocols };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const { exists, safeListDir, safeStat } = require("./paths");
|
|
7
|
+
|
|
8
|
+
// Read recent hook activity from ~/.claude/tmp/mover_* markers + session-history if available.
|
|
9
|
+
|
|
10
|
+
function readHookActivity() {
|
|
11
|
+
const home = os.homedir();
|
|
12
|
+
const claudeTmp = path.join(home, ".claude", "tmp");
|
|
13
|
+
const codexTmp = path.join(home, ".codex", "tmp");
|
|
14
|
+
const geminiTmp = path.join(home, ".gemini", "tmp");
|
|
15
|
+
|
|
16
|
+
const out = { available: true, recentMarkers: [], hookStats: { sessionStart: 0, log: 0, engineHealth: 0, planSync: 0 }, lastFire: null };
|
|
17
|
+
|
|
18
|
+
for (const dir of [claudeTmp, codexTmp, geminiTmp]) {
|
|
19
|
+
if (!exists(dir)) continue;
|
|
20
|
+
const entries = safeListDir(dir, { filesOnly: true });
|
|
21
|
+
for (const e of entries) {
|
|
22
|
+
if (!e.name.startsWith("mover_")) continue;
|
|
23
|
+
const stat = safeStat(e.path);
|
|
24
|
+
if (!stat) continue;
|
|
25
|
+
const ageDays = (Date.now() - stat.mtime.getTime()) / 86400000;
|
|
26
|
+
if (ageDays > 7) continue;
|
|
27
|
+
out.recentMarkers.push({
|
|
28
|
+
name: e.name,
|
|
29
|
+
agent: dir.includes(".claude") ? "Claude" : dir.includes(".codex") ? "Codex" : "Gemini",
|
|
30
|
+
mtime: stat.mtime,
|
|
31
|
+
ageDays: Math.floor(ageDays * 24 * 60) / (24 * 60),
|
|
32
|
+
ageHours: Math.floor(ageDays * 24 * 10) / 10
|
|
33
|
+
});
|
|
34
|
+
// Categorize
|
|
35
|
+
if (e.name.includes("logprompt") || e.name.includes("logdone")) out.hookStats.log++;
|
|
36
|
+
else if (e.name.includes("session-start") || e.name.includes("sessionstart")) out.hookStats.sessionStart++;
|
|
37
|
+
else if (e.name.includes("engine") || e.name.includes("health")) out.hookStats.engineHealth++;
|
|
38
|
+
else if (e.name.includes("plan-sync")) out.hookStats.planSync++;
|
|
39
|
+
if (!out.lastFire || stat.mtime > out.lastFire) out.lastFire = stat.mtime;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
out.recentMarkers.sort((a, b) => b.mtime - a.mtime);
|
|
43
|
+
out.recentMarkers = out.recentMarkers.slice(0, 10);
|
|
44
|
+
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { readHookActivity };
|