mover-os 4.7.6 → 4.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/install.js +763 -70
- package/package.json +15 -3
- package/src/dashboard/build.js +1503 -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 +177 -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 +462 -0
- package/src/dashboard/lib/anti-identity-detector.js +116 -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 +211 -0
- package/src/dashboard/lib/date-utils.js +35 -0
- package/src/dashboard/lib/distribution-parser.js +78 -0
- package/src/dashboard/lib/dossier-parser.js +64 -0
- package/src/dashboard/lib/drift-history.js +83 -0
- package/src/dashboard/lib/drift-score.js +119 -0
- package/src/dashboard/lib/engine-health.js +170 -0
- package/src/dashboard/lib/engine-writer.js +1685 -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 +304 -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 +212 -0
- package/src/dashboard/lib/library-indexer.js +105 -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 +470 -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/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 +226 -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/strategy-parser.js +245 -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 +331 -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 +2024 -0
- package/src/dashboard/shortcut.js +0 -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-598CSGOZ.js +157 -0
- package/src/dashboard/ui/dist/assets/index-BP--M69H.css +1 -0
- package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +34 -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,294 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// feed-parser.js — the live work feed. A plain sentence per real work event.
|
|
4
|
+
//
|
|
5
|
+
// SOURCE (live, local, never telemetry):
|
|
6
|
+
// 1. ~/.claude/history.jsonl — one line per real prompt the user typed into a
|
|
7
|
+
// Claude Code session: { display, timestamp(ms), project, sessionId }.
|
|
8
|
+
// Each line IS a real work event (a thing that actually happened in a real
|
|
9
|
+
// session under this vault). We tail the last N and turn each into a plain
|
|
10
|
+
// sentence.
|
|
11
|
+
// 2. ~/.claude/sessions/*.json — live session descriptors:
|
|
12
|
+
// { pid, sessionId, cwd, startedAt(ms), updatedAt(ms), status, version,
|
|
13
|
+
// name, ... }. These tell us which sessions are open right now.
|
|
14
|
+
//
|
|
15
|
+
// SCOPING (Codex must-fix / v6 amendment fix 8 nice-to-have): resolve the
|
|
16
|
+
// project slug AND each session cwd to a REALPATH and test containment against
|
|
17
|
+
// THIS vault's realpath — NOT a loose `project` string match. A symlinked or
|
|
18
|
+
// case-differing vault path still scopes correctly; an unrelated project that
|
|
19
|
+
// happens to share a path prefix string does not leak in.
|
|
20
|
+
//
|
|
21
|
+
// HARD HONESTY (v6-PLAN-AMENDMENTS fix 8 + Fable Finding C — binding):
|
|
22
|
+
// - NO `cost` field. There is no per-step token source. We do not invent one.
|
|
23
|
+
// - NO `diff` field. There is no per-step diff source. The only file signal we
|
|
24
|
+
// emit is, at most, a real path that already appears in the user's own text.
|
|
25
|
+
// Each event is a plain sentence + ids. Nothing more.
|
|
26
|
+
//
|
|
27
|
+
// FALSE-ZERO (T261 discipline): if nothing real is happening under this vault —
|
|
28
|
+
// no in-vault history events AND no in-vault sessions — return
|
|
29
|
+
// { available:false, events:[], activeSessions:[], caption:'' }.
|
|
30
|
+
// We never fabricate scripted demo steps to fill an empty feed.
|
|
31
|
+
|
|
32
|
+
const fs = require("fs");
|
|
33
|
+
const os = require("os");
|
|
34
|
+
const path = require("path");
|
|
35
|
+
const { redactText } = require("./redaction");
|
|
36
|
+
|
|
37
|
+
const HISTORY_FILE = path.join(os.homedir(), ".claude", "history.jsonl");
|
|
38
|
+
const SESSIONS_DIR = path.join(os.homedir(), ".claude", "sessions");
|
|
39
|
+
|
|
40
|
+
// A session counts as "active" only if its descriptor was touched recently.
|
|
41
|
+
// Claude writes `updatedAt` on every turn; a stale file is a dead process whose
|
|
42
|
+
// descriptor was never cleaned up. 30 min is generous for "this is live".
|
|
43
|
+
const ACTIVE_WINDOW_MS = 30 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// realpath-based vault scoping
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
// Resolve a path to its realpath when it exists on disk; fall back to a
|
|
50
|
+
// lexical normalize (resolves `..`/`.` without touching disk) so a recorded
|
|
51
|
+
// path for a since-deleted dir still scopes sanely. Never throws.
|
|
52
|
+
function resolveReal(p) {
|
|
53
|
+
if (!p || typeof p !== "string") return null;
|
|
54
|
+
try {
|
|
55
|
+
return fs.realpathSync.native(p);
|
|
56
|
+
} catch (_) {
|
|
57
|
+
try {
|
|
58
|
+
return path.resolve(p);
|
|
59
|
+
} catch (_) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// True when `child` is the same path as, or nested under, `root`. Compares on a
|
|
66
|
+
// path-segment boundary (root + sep) so `/a/Mover OS` does NOT match
|
|
67
|
+
// `/a/Mover OS Bundle 2`. Case-insensitive on darwin/win32 where the FS is.
|
|
68
|
+
function isUnder(child, root) {
|
|
69
|
+
if (!child || !root) return false;
|
|
70
|
+
const ci = process.platform === "darwin" || process.platform === "win32";
|
|
71
|
+
let c = ci ? child.toLowerCase() : child;
|
|
72
|
+
let r = ci ? root.toLowerCase() : root;
|
|
73
|
+
if (c === r) return true;
|
|
74
|
+
const withSep = r.endsWith(path.sep) ? r : r + path.sep;
|
|
75
|
+
return c.startsWith(withSep);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// event classification — plain, honest, source-backed
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
// A leading slash means the user invoked a workflow / command — a deliberate
|
|
83
|
+
// "move". Everything else is a typed prompt to the agent terminal.
|
|
84
|
+
function classify(display) {
|
|
85
|
+
const text = (display || "").trim();
|
|
86
|
+
if (text.startsWith("/")) {
|
|
87
|
+
// /plan-tomorrow, /log, /compact ... → kind is the bare command token.
|
|
88
|
+
const cmd = text.slice(1).split(/[\s\n]/, 1)[0].toLowerCase();
|
|
89
|
+
return { lane: "move", kind: cmd ? `/${cmd}` : "command" };
|
|
90
|
+
}
|
|
91
|
+
return { lane: "term", kind: "prompt" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Turn a raw multi-line prompt into one clean redacted sentence. We keep the
|
|
95
|
+
// user's own words (that's the honest signal) — only collapse whitespace, trim
|
|
96
|
+
// to a readable length, and run the privacy scrubber over the result.
|
|
97
|
+
function toSentence(display) {
|
|
98
|
+
let t = (display || "").replace(/\s+/g, " ").trim();
|
|
99
|
+
if (!t) return "";
|
|
100
|
+
// Trim to a feed-readable length on a word boundary; mark truncation with an
|
|
101
|
+
// ellipsis so it never reads as a fabricated complete thought.
|
|
102
|
+
const MAX = 180;
|
|
103
|
+
if (t.length > MAX) {
|
|
104
|
+
const cut = t.slice(0, MAX);
|
|
105
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
106
|
+
t = (lastSpace > 80 ? cut.slice(0, lastSpace) : cut).trim() + "…";
|
|
107
|
+
}
|
|
108
|
+
return redactText(t).text;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// session descriptors
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
function readSessions(vaultReal) {
|
|
116
|
+
let names;
|
|
117
|
+
try {
|
|
118
|
+
names = fs.readdirSync(SESSIONS_DIR);
|
|
119
|
+
} catch (_) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
const sessions = [];
|
|
124
|
+
for (const name of names) {
|
|
125
|
+
if (!name.endsWith(".json")) continue;
|
|
126
|
+
let desc;
|
|
127
|
+
try {
|
|
128
|
+
desc = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, name), "utf8"));
|
|
129
|
+
} catch (_) {
|
|
130
|
+
continue; // skip mid-write / corrupt descriptor
|
|
131
|
+
}
|
|
132
|
+
if (!desc || typeof desc !== "object") continue;
|
|
133
|
+
const cwdReal = resolveReal(desc.cwd);
|
|
134
|
+
const isThisVault = isUnder(cwdReal, vaultReal);
|
|
135
|
+
const updatedAt = Number(desc.updatedAt) || Number(desc.startedAt) || 0;
|
|
136
|
+
// status: trust the descriptor when present; otherwise infer from recency.
|
|
137
|
+
// A descriptor with no `status`/`updatedAt` that hasn't been touched in the
|
|
138
|
+
// active window is a leftover from a closed process — mark it "idle".
|
|
139
|
+
let status = typeof desc.status === "string" && desc.status ? desc.status : null;
|
|
140
|
+
const fresh = updatedAt > 0 && now - updatedAt <= ACTIVE_WINDOW_MS;
|
|
141
|
+
if (!status) status = fresh ? "active" : "idle";
|
|
142
|
+
sessions.push({
|
|
143
|
+
name: desc.name || "",
|
|
144
|
+
cwd: desc.cwd || "",
|
|
145
|
+
status,
|
|
146
|
+
version: desc.version || "",
|
|
147
|
+
startedAt: Number(desc.startedAt) ? new Date(Number(desc.startedAt)).toISOString() : null,
|
|
148
|
+
updatedAt: updatedAt ? new Date(updatedAt).toISOString() : null,
|
|
149
|
+
isThisVault: Boolean(isThisVault),
|
|
150
|
+
_fresh: fresh,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
// Most-recently-active first.
|
|
154
|
+
sessions.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
|
|
155
|
+
return sessions;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Map a sessionId → a friendly name from the live descriptors, so a feed row
|
|
159
|
+
// can show "landing-polar-overhaul" instead of a bare uuid.
|
|
160
|
+
function buildSessionNameMap() {
|
|
161
|
+
const map = new Map();
|
|
162
|
+
let names;
|
|
163
|
+
try {
|
|
164
|
+
names = fs.readdirSync(SESSIONS_DIR);
|
|
165
|
+
} catch (_) {
|
|
166
|
+
return map;
|
|
167
|
+
}
|
|
168
|
+
for (const name of names) {
|
|
169
|
+
if (!name.endsWith(".json")) continue;
|
|
170
|
+
try {
|
|
171
|
+
const desc = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, name), "utf8"));
|
|
172
|
+
if (desc && desc.sessionId && desc.name) map.set(desc.sessionId, desc.name);
|
|
173
|
+
} catch (_) {
|
|
174
|
+
/* skip */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return map;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// history events (tailed, scoped, redacted)
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
function readEvents(vaultReal, limit, sessionNames) {
|
|
185
|
+
let raw;
|
|
186
|
+
try {
|
|
187
|
+
raw = fs.readFileSync(HISTORY_FILE, "utf8");
|
|
188
|
+
} catch (_) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
const lines = raw.split("\n");
|
|
192
|
+
// Memoize realpath per distinct `project` string — history has thousands of
|
|
193
|
+
// lines but only a handful of distinct projects, and realpath hits disk.
|
|
194
|
+
const projectScopeCache = new Map();
|
|
195
|
+
const inVault = (project) => {
|
|
196
|
+
if (projectScopeCache.has(project)) return projectScopeCache.get(project);
|
|
197
|
+
const real = resolveReal(project);
|
|
198
|
+
const ok = isUnder(real, vaultReal);
|
|
199
|
+
projectScopeCache.set(project, ok);
|
|
200
|
+
return ok;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const events = [];
|
|
204
|
+
// Walk newest → oldest, collect up to `limit` in-vault events, then flip to
|
|
205
|
+
// chronological. Tailing from the end keeps this cheap on a large history.
|
|
206
|
+
for (let i = lines.length - 1; i >= 0 && events.length < limit; i--) {
|
|
207
|
+
const line = lines[i];
|
|
208
|
+
if (!line) continue;
|
|
209
|
+
let obj;
|
|
210
|
+
try {
|
|
211
|
+
obj = JSON.parse(line);
|
|
212
|
+
} catch (_) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const project = obj.project;
|
|
216
|
+
if (!project || !inVault(project)) continue;
|
|
217
|
+
|
|
218
|
+
const display = obj.display;
|
|
219
|
+
const text = toSentence(display);
|
|
220
|
+
if (!text) continue; // nothing real to say
|
|
221
|
+
|
|
222
|
+
const tsMs = Number(obj.timestamp) || 0;
|
|
223
|
+
const { lane, kind } = classify(display);
|
|
224
|
+
const sessionId = obj.sessionId || "";
|
|
225
|
+
events.push({
|
|
226
|
+
ts: tsMs ? new Date(tsMs).toISOString() : null,
|
|
227
|
+
sessionId,
|
|
228
|
+
sessionName: (sessionId && sessionNames.get(sessionId)) || "",
|
|
229
|
+
lane, // 'move' | 'term'
|
|
230
|
+
kind,
|
|
231
|
+
text,
|
|
232
|
+
project,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
events.reverse(); // chronological (oldest of the tail → newest)
|
|
236
|
+
return events;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// public API
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
// compute({ vault, limit }) → live work-feed payload.
|
|
244
|
+
// available false when nothing real is happening under this vault
|
|
245
|
+
// caption one honest line summarizing the feed (or '')
|
|
246
|
+
// events [{ ts, sessionId, sessionName, lane, kind, text, project }]
|
|
247
|
+
// activeSessions [{ name, cwd, status, version, startedAt, updatedAt, isThisVault }]
|
|
248
|
+
//
|
|
249
|
+
// No `cost`, no `diff` — there is no per-step source for either (binding).
|
|
250
|
+
function compute({ vault, limit = 40 } = {}) {
|
|
251
|
+
const EMPTY = { available: false, caption: "", events: [], activeSessions: [] };
|
|
252
|
+
if (!vault || typeof vault !== "string") return EMPTY;
|
|
253
|
+
|
|
254
|
+
const vaultReal = resolveReal(vault);
|
|
255
|
+
if (!vaultReal) return EMPTY;
|
|
256
|
+
|
|
257
|
+
const lim = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 40;
|
|
258
|
+
|
|
259
|
+
const sessions = readSessions(vaultReal);
|
|
260
|
+
const sessionNames = buildSessionNameMap();
|
|
261
|
+
const events = readEvents(vaultReal, lim, sessionNames);
|
|
262
|
+
|
|
263
|
+
// Active sessions surfaced to the UI = sessions running under THIS vault.
|
|
264
|
+
const activeSessions = sessions
|
|
265
|
+
.filter((s) => s.isThisVault)
|
|
266
|
+
.map(({ _fresh, ...s }) => s); // drop internal field
|
|
267
|
+
|
|
268
|
+
// False-zero discipline: a feed with no in-vault events AND no in-vault
|
|
269
|
+
// sessions is genuinely "nothing running" → unavailable, never a demo.
|
|
270
|
+
if (events.length === 0 && activeSessions.length === 0) return EMPTY;
|
|
271
|
+
|
|
272
|
+
// Honest caption from real counts only. "live" = a descriptor this vault owns
|
|
273
|
+
// that is currently active/busy (recently touched).
|
|
274
|
+
const liveNow = sessions.filter((s) => s.isThisVault && (s.status === "active" || s.status === "busy")).length;
|
|
275
|
+
let caption;
|
|
276
|
+
if (events.length === 0) {
|
|
277
|
+
caption = liveNow > 0 ? `${liveNow} session${liveNow === 1 ? "" : "s"} open — no events yet` : "";
|
|
278
|
+
} else {
|
|
279
|
+
const moves = events.filter((e) => e.lane === "move").length;
|
|
280
|
+
const parts = [`${events.length} recent event${events.length === 1 ? "" : "s"}`];
|
|
281
|
+
if (moves > 0) parts.push(`${moves} move${moves === 1 ? "" : "s"}`);
|
|
282
|
+
if (liveNow > 0) parts.push(`${liveNow} live`);
|
|
283
|
+
caption = parts.join(" · ");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
available: true,
|
|
288
|
+
caption,
|
|
289
|
+
events,
|
|
290
|
+
activeSessions,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
module.exports = { compute };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// forked-future.js — Two-path drift projection for the Briefing module.
|
|
4
|
+
// Each scenario is a confidence-coned sparkline:
|
|
5
|
+
// path A: "if you hold the pattern" — extrapolates current drift trajectory
|
|
6
|
+
// path B: "if you break the pattern" — drift falls toward baseline (15-20)
|
|
7
|
+
//
|
|
8
|
+
// Pure function: takes current drift score + recent trajectory + active
|
|
9
|
+
// patterns, returns two arrays of {value, lo, hi} points across N days.
|
|
10
|
+
|
|
11
|
+
function compute({ driftScore = null, recent7d = [], activePatterns = [], horizonDays = 14 } = {}) {
|
|
12
|
+
if (!driftScore) return null;
|
|
13
|
+
|
|
14
|
+
const current = driftScore.value;
|
|
15
|
+
const baseline = 15; // healthy floor target
|
|
16
|
+
const ceiling = 100;
|
|
17
|
+
const trendDelta = computeTrend(recent7d);
|
|
18
|
+
const patternWeight = Math.min(1, (activePatterns || []).filter(p => p.confidence >= 3).length * 0.15);
|
|
19
|
+
|
|
20
|
+
// Path A: hold the pattern. Trajectory continues at observed slope, with
|
|
21
|
+
// pattern weight pulling toward ceiling (active Conf 3+ patterns add drift).
|
|
22
|
+
const slopeA = trendDelta + 0.6 + patternWeight * 0.8;
|
|
23
|
+
const holdPath = [];
|
|
24
|
+
for (let i = 0; i <= horizonDays; i++) {
|
|
25
|
+
const v = Math.min(ceiling, Math.max(0, current + slopeA * i));
|
|
26
|
+
const spread = 3 + i * 0.8; // widening confidence cone
|
|
27
|
+
holdPath.push({ day: i, value: round(v), lo: round(v - spread), hi: round(Math.min(ceiling, v + spread)) });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Path B: break the pattern. Drift decays toward baseline at -2/day (faster
|
|
31
|
+
// initially), with smaller confidence cone (the action is causal).
|
|
32
|
+
const breakPath = [];
|
|
33
|
+
for (let i = 0; i <= horizonDays; i++) {
|
|
34
|
+
const v = baseline + (current - baseline) * Math.pow(0.85, i);
|
|
35
|
+
const spread = 2 + i * 0.4;
|
|
36
|
+
breakPath.push({ day: i, value: round(v), lo: round(Math.max(0, v - spread)), hi: round(v + spread) });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
horizonDays,
|
|
41
|
+
current,
|
|
42
|
+
baseline,
|
|
43
|
+
holdPath,
|
|
44
|
+
breakPath,
|
|
45
|
+
interpretation: {
|
|
46
|
+
hold: `${slopeA >= 0.6 ? 'Drift climbs' : 'Drift drifts'} toward ${holdPath[holdPath.length - 1].value}/100 in ${horizonDays} days if pattern continues.`,
|
|
47
|
+
break: `Drift falls toward ${breakPath[breakPath.length - 1].value}/100 if the next 3 sessions break the pattern.`
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function computeTrend(values) {
|
|
53
|
+
if (!values || values.length < 2) return 0;
|
|
54
|
+
// Simple slope: last - first / count
|
|
55
|
+
return (values[values.length - 1] - values[0]) / Math.max(1, values.length - 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function round(v) { return Math.round(v * 10) / 10; }
|
|
59
|
+
|
|
60
|
+
module.exports = { compute };
|
|
@@ -0,0 +1,304 @@
|
|
|
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;
|
|
19
|
+
const target = parsed.target;
|
|
20
|
+
const needed = Math.max(0, target - current);
|
|
21
|
+
const deadline = parsed.deadline;
|
|
22
|
+
const daysRemaining = deadline ? daysBetween(localDateKey(now), deadline) : null;
|
|
23
|
+
const daysSafe = daysRemaining == null ? null : Math.max(0, daysRemaining);
|
|
24
|
+
const requiredPerDay = daysSafe == null
|
|
25
|
+
? null
|
|
26
|
+
: daysSafe === 0 ? needed : round(needed / daysSafe, 2);
|
|
27
|
+
const progressPct = target > 0 ? Math.min(100, Math.round((current / target) * 100)) : 0;
|
|
28
|
+
|
|
29
|
+
let status = "unknown";
|
|
30
|
+
if (target && current >= target) status = "complete";
|
|
31
|
+
else if (daysRemaining != null && daysRemaining < 0) status = "failed";
|
|
32
|
+
else if (daysRemaining != null && daysRemaining <= 1 && needed > 2) status = "unlikely";
|
|
33
|
+
else if (requiredPerDay != null && requiredPerDay > 1) status = "at-risk";
|
|
34
|
+
else if (target) status = "on-track";
|
|
35
|
+
|
|
36
|
+
// Only act on distribution we actually measured. Unavailable (null) distribution
|
|
37
|
+
// must NOT trigger a "0 actions logged" experiment. [false-zero — Codex, T261]
|
|
38
|
+
const total7dKnown = !!(distribution && distribution.available !== false && Number.isFinite(distribution.total7d));
|
|
39
|
+
const total7d = total7dKnown ? distribution.total7d : 0;
|
|
40
|
+
const adjustments = [];
|
|
41
|
+
const workflows = [];
|
|
42
|
+
|
|
43
|
+
if (engineHealth && engineHealth.analyseDay && engineHealth.analyseDay.status === "RED") {
|
|
44
|
+
pushAction(adjustments, workflows, {
|
|
45
|
+
workflow: "/analyse-day",
|
|
46
|
+
title: "Run a day review before more strategy edits",
|
|
47
|
+
detail: `You haven't reviewed a day in ${engineHealth.analyseDay.overdueDays} days. Run /analyse-day to get the dashboard current.`,
|
|
48
|
+
priority: "critical",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
// Only treat strategy health as "trouble" when the scan actually ran and
|
|
52
|
+
// returned a concrete RED/YELLOW. A null status from an unavailable scan must
|
|
53
|
+
// NOT trigger /pivot-strategy. [false-zero regression fix — Codex P1, T261]
|
|
54
|
+
const strategyTrouble = engineHealth && engineHealth.available !== false
|
|
55
|
+
&& engineHealth.strategy && (engineHealth.strategy.status === "RED" || engineHealth.strategy.status === "YELLOW");
|
|
56
|
+
if (status === "failed" || status === "unlikely" || strategyTrouble) {
|
|
57
|
+
pushAction(adjustments, workflows, {
|
|
58
|
+
workflow: "/pivot-strategy",
|
|
59
|
+
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.`,
|
|
61
|
+
priority: status === "failed" ? "critical" : "high",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (needed > 0 && total7dKnown && total7d < 10) {
|
|
65
|
+
pushAction(adjustments, workflows, {
|
|
66
|
+
workflow: "/experiment",
|
|
67
|
+
title: "Open a distribution experiment",
|
|
68
|
+
detail: `Only ${total7d} distribution actions logged in 7 days. Test one channel loop with a falsifiable metric instead of adding more dashboard surface.`,
|
|
69
|
+
priority: "high",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (daily && daily.today && !daily.today.exists) {
|
|
73
|
+
pushAction(adjustments, workflows, {
|
|
74
|
+
workflow: "/plan-tomorrow",
|
|
75
|
+
title: "Set today's execution list",
|
|
76
|
+
detail: "No Daily Note found for today, so the dashboard is missing the live plan it should enforce.",
|
|
77
|
+
priority: "medium",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// available:false when no real target parsed → null out the numerals so the UI
|
|
82
|
+
// shows its honest "no measurable test" / "calibrating" state, never a
|
|
83
|
+
// fabricated "0/10". [false-zero discipline, T261]
|
|
84
|
+
const available = !!target;
|
|
85
|
+
return {
|
|
86
|
+
available,
|
|
87
|
+
singleTest: parsed.singleTest || null,
|
|
88
|
+
current: available ? current : null,
|
|
89
|
+
target: available ? target : null,
|
|
90
|
+
unit: parsed.unit,
|
|
91
|
+
deadline,
|
|
92
|
+
daysRemaining,
|
|
93
|
+
needed,
|
|
94
|
+
requiredPerDay,
|
|
95
|
+
progressPct,
|
|
96
|
+
status,
|
|
97
|
+
source: parsed.source,
|
|
98
|
+
distribution7d: total7dKnown ? total7d : null,
|
|
99
|
+
adjustments,
|
|
100
|
+
workflows,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pushAction(adjustments, workflows, action) {
|
|
105
|
+
adjustments.push(action);
|
|
106
|
+
if (action.workflow && !workflows.includes(action.workflow)) workflows.push(action.workflow);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseGoalProgress({ singleTest, sourceText, targetDate, now }) {
|
|
110
|
+
const out = {
|
|
111
|
+
singleTest: singleTest || null,
|
|
112
|
+
current: 0,
|
|
113
|
+
target: 0,
|
|
114
|
+
unit: "customers",
|
|
115
|
+
deadline: null,
|
|
116
|
+
source: "strategy",
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (singleTest) {
|
|
120
|
+
// Regex captures count-based phrasing (e.g. "10 paying customers/sales")
|
|
121
|
+
// AND activation phrasing ("N activated installs / referrals"). The
|
|
122
|
+
// extended unit list keeps backwards-compat and makes an activation-style
|
|
123
|
+
// 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);
|
|
125
|
+
if (targetMatch) {
|
|
126
|
+
out.target = Number(targetMatch[1]);
|
|
127
|
+
// Set unit from the matched phrase so the hero strip says
|
|
128
|
+
// "activated installs" instead of the default "customers" on V4.
|
|
129
|
+
const phrase = targetMatch[0].toLowerCase();
|
|
130
|
+
if (/install/.test(phrase)) out.unit = "activated installs";
|
|
131
|
+
else if (/referral/.test(phrase)) out.unit = "referrals";
|
|
132
|
+
else if (/user/.test(phrase)) out.unit = "users";
|
|
133
|
+
else out.unit = "customers";
|
|
134
|
+
}
|
|
135
|
+
const deadline = extractDate(singleTest, now) || targetDate;
|
|
136
|
+
if (deadline) out.deadline = deadline;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
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
|
+
|
|
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";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// No fabricated target: if the Single Test carries no parseable number,
|
|
161
|
+
// target stays 0 → computeGoalForecast reports available:false (honest "no
|
|
162
|
+
// measurable test") instead of a made-up "0/10". [false-zero discipline, T261]
|
|
163
|
+
if (!out.deadline) out.deadline = targetDate || extractDate(text, now);
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function extractDate(text, now) {
|
|
168
|
+
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];
|
|
171
|
+
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
|
+
const m = text.match(new RegExp(`\\b(?:by\\s+)?(${months.join("|")})\\s+(\\d{1,2})(?:,?\\s*(20\\d{2}))?`, "i"));
|
|
173
|
+
if (!m) return null;
|
|
174
|
+
const month = monthIndex(m[1]);
|
|
175
|
+
const day = Number(m[2]);
|
|
176
|
+
const year = m[3] ? Number(m[3]) : now.getFullYear();
|
|
177
|
+
if (month < 0 || !Number.isFinite(day)) return null;
|
|
178
|
+
return localDateKey(new Date(year, month, day));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function monthIndex(name) {
|
|
182
|
+
const n = String(name || "").toLowerCase().slice(0, 3);
|
|
183
|
+
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;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// T242 bug hunt fix (B#16): previously `new Date(`${iso}T00:00:00`)` parsed
|
|
187
|
+
// as LOCAL time. Across DST transitions (Mar/Nov in US, Mar/Oct in EU)
|
|
188
|
+
// two consecutive days are 23 or 25 hours apart, so the ceil-divide by a
|
|
189
|
+
// fixed 86400000ms returned 0 or 2 for what should be 1. Goal countdown
|
|
190
|
+
// chips flickered around DST. Use UTC midnight + calendar-day math.
|
|
191
|
+
function daysBetween(fromIso, toIso) {
|
|
192
|
+
const fromUtc = Date.parse(`${fromIso}T00:00:00Z`);
|
|
193
|
+
const toUtc = Date.parse(`${toIso}T00:00:00Z`);
|
|
194
|
+
if (!Number.isFinite(fromUtc) || !Number.isFinite(toUtc)) return null;
|
|
195
|
+
return Math.round((toUtc - fromUtc) / (24 * 3600 * 1000));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function round(n, d) {
|
|
199
|
+
const p = Math.pow(10, d);
|
|
200
|
+
return Math.round(n * p) / p;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Per-domain measurement dispatcher ─────────────────────────────────────────
|
|
204
|
+
// Each domain declares a measurement SHAPE in Strategy.md (single | chain | scalar
|
|
205
|
+
// | streak). This routes the declaration + whatever live source exists into a
|
|
206
|
+
// render-ready progress object, honest-null wherever a domain has not logged data
|
|
207
|
+
// yet. The dashboard renders the shape; the fuel arrives once the user logs it.
|
|
208
|
+
function parseDomainProgress({ domain, decl, goalForecast, streak, metricsLog } = {}) {
|
|
209
|
+
if (!decl || !decl.type) return { available: false, type: null };
|
|
210
|
+
const type = decl.type;
|
|
211
|
+
|
|
212
|
+
if (type === "single") {
|
|
213
|
+
// Work's single test is already computed by computeGoalForecast. Other domains
|
|
214
|
+
// declaring single have no per-domain count source yet -> honest-null.
|
|
215
|
+
if (domain === "work" && goalForecast && goalForecast.available) {
|
|
216
|
+
return {
|
|
217
|
+
available: true, type: "single",
|
|
218
|
+
current: goalForecast.current, target: goalForecast.target,
|
|
219
|
+
unit: goalForecast.unit || null, progressPct: goalForecast.progressPct ?? null,
|
|
220
|
+
status: goalForecast.status || null,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return { available: false, type: "single", current: null, target: numFrom(decl.target), unit: null };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (type === "scalar") {
|
|
227
|
+
const baseline = numFrom(decl.baseline);
|
|
228
|
+
const target = numFrom(decl.target);
|
|
229
|
+
const current = scalarCurrent(decl, metricsLog); // best-effort; null until logged
|
|
230
|
+
const progressPct =
|
|
231
|
+
baseline != null && target != null && current != null && target !== baseline
|
|
232
|
+
? clampPct(Math.round(((current - baseline) / (target - baseline)) * 100))
|
|
233
|
+
: null;
|
|
234
|
+
return {
|
|
235
|
+
available: current != null, type: "scalar",
|
|
236
|
+
baseline, target, current,
|
|
237
|
+
baselineText: decl.baseline || null, targetText: decl.target || null,
|
|
238
|
+
progressPct,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (type === "streak") {
|
|
243
|
+
// streak-parser tracks day-PRESENCE (a daily note exists), NOT whether the
|
|
244
|
+
// declared practice was actually done. Presenting that as the practice streak
|
|
245
|
+
// would be a false signal, so the count stays honest-null until a per-practice
|
|
246
|
+
// source exists; the ring renders its ready/empty state.
|
|
247
|
+
return {
|
|
248
|
+
available: false, type: "streak",
|
|
249
|
+
currentStreak: null, longestStreak: null, todayDone: null,
|
|
250
|
+
note: "activates once the practice is logged",
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (type === "chain") {
|
|
255
|
+
const stages = parseChainStages(decl.chain); // [] when no chain math declared
|
|
256
|
+
return {
|
|
257
|
+
available: false, type: "chain",
|
|
258
|
+
stages, bottleneck: null,
|
|
259
|
+
note: stages.length ? "awaiting per-stage numbers" : "declare the chain (revenue -> ... -> daily action)",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { available: false, type };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Pull a leading number out of a prose value ("7.5h sleep" -> 7.5, "78 kg" -> 78).
|
|
267
|
+
function numFrom(text) {
|
|
268
|
+
if (text == null) return null;
|
|
269
|
+
const m = String(text).match(/-?\d+(?:\.\d+)?/);
|
|
270
|
+
return m ? Number(m[0]) : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function clampPct(n) {
|
|
274
|
+
if (!Number.isFinite(n)) return null;
|
|
275
|
+
return Math.max(0, Math.min(100, n));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Best-effort current value for a scalar domain from the metrics log's latest
|
|
279
|
+
// readings, matched by keyword. Null (honest) when nothing matches.
|
|
280
|
+
function scalarCurrent(decl, metricsLog) {
|
|
281
|
+
if (!metricsLog || metricsLog.available === false) return null;
|
|
282
|
+
const hay = ((decl.target || "") + " " + (decl.baseline || "")).toLowerCase();
|
|
283
|
+
const pick = /sleep/.test(hay) ? metricsLog.sleepLatest
|
|
284
|
+
: /weight|kg|\blb/.test(hay) ? metricsLog.weightLatest
|
|
285
|
+
: /hrv/.test(hay) ? metricsLog.hrvLatest
|
|
286
|
+
: /rhr|resting|heart/.test(hay) ? metricsLog.rhrLatest
|
|
287
|
+
: null;
|
|
288
|
+
return numFrom(pick);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Split a declared chain ("$10k = 100 sales = 1k calls at 10% = 50 DMs/day") into
|
|
292
|
+
// ordered stages. Each stage is a label only; the live per-stage numbers arrive
|
|
293
|
+
// from the logged funnel, so `current` is honest-null here.
|
|
294
|
+
function parseChainStages(chain) {
|
|
295
|
+
if (!chain) return [];
|
|
296
|
+
return String(chain)
|
|
297
|
+
.split(/\s*(?:=|->|→|>)\s*/)
|
|
298
|
+
.map((s) => s.trim())
|
|
299
|
+
.filter(Boolean)
|
|
300
|
+
.slice(0, 6)
|
|
301
|
+
.map((label) => ({ label, current: null }));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = { computeGoalForecast, parseDomainProgress };
|