mover-os 4.7.7 → 4.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +34 -24
  2. package/install.js +2868 -251
  3. package/package.json +15 -3
  4. package/src/dashboard/build.js +1541 -0
  5. package/src/dashboard/dashboard.js +276 -0
  6. package/src/dashboard/index.js +319 -0
  7. package/src/dashboard/lib/activation-log.js +297 -0
  8. package/src/dashboard/lib/active-context-parser.js +189 -0
  9. package/src/dashboard/lib/agent-command.js +93 -0
  10. package/src/dashboard/lib/agent-detect.js +255 -0
  11. package/src/dashboard/lib/agent-detector.js +92 -0
  12. package/src/dashboard/lib/agent-session.js +483 -0
  13. package/src/dashboard/lib/anti-identity-detector.js +116 -0
  14. package/src/dashboard/lib/approval-registry.js +170 -0
  15. package/src/dashboard/lib/auto-learnings-parser.js +183 -0
  16. package/src/dashboard/lib/cli-usage-parser.js +92 -0
  17. package/src/dashboard/lib/config-parser.js +109 -0
  18. package/src/dashboard/lib/connect-recommender.js +131 -0
  19. package/src/dashboard/lib/correlations-parser.js +231 -0
  20. package/src/dashboard/lib/daily-note-resolver.js +228 -0
  21. package/src/dashboard/lib/date-utils.js +43 -0
  22. package/src/dashboard/lib/distribution-parser.js +137 -0
  23. package/src/dashboard/lib/dossier-parser.js +64 -0
  24. package/src/dashboard/lib/drift-history.js +88 -0
  25. package/src/dashboard/lib/drift-score.js +119 -0
  26. package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
  27. package/src/dashboard/lib/engine-health.js +173 -0
  28. package/src/dashboard/lib/engine-writer.js +1831 -0
  29. package/src/dashboard/lib/execution-plan.js +125 -0
  30. package/src/dashboard/lib/experiments-parser.js +429 -0
  31. package/src/dashboard/lib/feed-parser.js +294 -0
  32. package/src/dashboard/lib/forked-future.js +60 -0
  33. package/src/dashboard/lib/goal-forecast.js +427 -0
  34. package/src/dashboard/lib/goals-parser.js +67 -0
  35. package/src/dashboard/lib/health-protocols-parser.js +133 -0
  36. package/src/dashboard/lib/hook-activity.js +48 -0
  37. package/src/dashboard/lib/hook-indexer.js +169 -0
  38. package/src/dashboard/lib/hourly-activity-parser.js +143 -0
  39. package/src/dashboard/lib/identity-parser.js +85 -0
  40. package/src/dashboard/lib/ingestion.js +418 -0
  41. package/src/dashboard/lib/library-indexer-v2.js +226 -0
  42. package/src/dashboard/lib/library-indexer.js +105 -0
  43. package/src/dashboard/lib/library-search.js +290 -0
  44. package/src/dashboard/lib/log-activation.sh +61 -0
  45. package/src/dashboard/lib/memory-curator.js +97 -0
  46. package/src/dashboard/lib/memory-gardener.js +177 -0
  47. package/src/dashboard/lib/memory-gepa.js +102 -0
  48. package/src/dashboard/lib/memory-index.js +519 -0
  49. package/src/dashboard/lib/memory-rerank.js +72 -0
  50. package/src/dashboard/lib/memory-text.js +136 -0
  51. package/src/dashboard/lib/metrics-log-parser.js +76 -0
  52. package/src/dashboard/lib/moves-usage-parser.js +184 -0
  53. package/src/dashboard/lib/onboarding-forge.js +70 -0
  54. package/src/dashboard/lib/override-outcome-parser.js +68 -0
  55. package/src/dashboard/lib/override-summary.js +73 -0
  56. package/src/dashboard/lib/paths.js +192 -0
  57. package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
  58. package/src/dashboard/lib/phantom-strategy.js +129 -0
  59. package/src/dashboard/lib/pid-markers.js +80 -0
  60. package/src/dashboard/lib/project-scanner.js +121 -0
  61. package/src/dashboard/lib/promise-wall.js +88 -0
  62. package/src/dashboard/lib/record-score.js +173 -0
  63. package/src/dashboard/lib/redaction.js +140 -0
  64. package/src/dashboard/lib/refusal-parser.js +44 -0
  65. package/src/dashboard/lib/regenerate-manifest.js +206 -0
  66. package/src/dashboard/lib/rewind-snapshots.js +689 -0
  67. package/src/dashboard/lib/roast-wall-parser.js +200 -0
  68. package/src/dashboard/lib/run-registry.js +286 -0
  69. package/src/dashboard/lib/safe-write.js +63 -0
  70. package/src/dashboard/lib/session-log-parser.js +145 -0
  71. package/src/dashboard/lib/session-time-parser.js +158 -0
  72. package/src/dashboard/lib/skill-index.js +171 -0
  73. package/src/dashboard/lib/skill-indexer.js +118 -0
  74. package/src/dashboard/lib/skill-recommender.js +689 -0
  75. package/src/dashboard/lib/state-core/backfill.js +298 -0
  76. package/src/dashboard/lib/state-core/dryrun.js +188 -0
  77. package/src/dashboard/lib/state-core/event-log.js +615 -0
  78. package/src/dashboard/lib/state-core/events.js +265 -0
  79. package/src/dashboard/lib/state-core/projections.js +376 -0
  80. package/src/dashboard/lib/state-core/start-close.js +162 -0
  81. package/src/dashboard/lib/state-core/trial.js +96 -0
  82. package/src/dashboard/lib/strategy-parser.js +248 -0
  83. package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
  84. package/src/dashboard/lib/streak-parser.js +95 -0
  85. package/src/dashboard/lib/suggested-now.js +254 -0
  86. package/src/dashboard/lib/tool-awareness.js +125 -0
  87. package/src/dashboard/lib/transcript-parser.js +371 -0
  88. package/src/dashboard/lib/vault-graph-parser.js +205 -0
  89. package/src/dashboard/lib/view-generator.js +163 -0
  90. package/src/dashboard/lib/voice-dna-parser.js +57 -0
  91. package/src/dashboard/lib/walkthrough-script.js +140 -0
  92. package/src/dashboard/lib/workflow-graph-parser.js +170 -0
  93. package/src/dashboard/lib/workflow-library-parser.js +146 -0
  94. package/src/dashboard/server.js +2402 -0
  95. package/src/dashboard/shortcut.js +284 -0
  96. package/src/dashboard/static/setup-poc.html +306 -0
  97. package/src/dashboard/static/walkthrough-poc.html +580 -0
  98. package/src/dashboard/styles.css +1201 -0
  99. package/src/dashboard/templates/index.html +278 -0
  100. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  101. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  102. package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  103. package/src/dashboard/ui/dist/assets/hero.png +0 -0
  104. package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
  105. package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
  106. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  107. package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  108. package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  109. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  110. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  111. package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  112. package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
  113. package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
  114. package/src/dashboard/ui/dist/icon.svg +4 -0
  115. package/src/dashboard/ui/dist/index.html +18 -0
  116. package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
  117. package/src/dashboard/ui/dist/registerSW.js +1 -0
  118. package/src/dashboard/ui/dist/sw.js +1 -0
  119. package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
@@ -0,0 +1,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 };