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,145 @@
1
+ "use strict";
2
+
3
+ // session-log-parser.js — Mover memory engine, curated tier.
4
+ // Parses the Daily Note "## Session Log" section that daily-note-resolver.js ignores.
5
+ // This is the HIGHEST-signal curated record: /log already distilled each session into
6
+ // timestamped arcs ("#### ~04:20 - Found PII leak"), commit hashes, and **Flag:** lines.
7
+ // One doc per sub-arc = the natural recall unit ("when did I fix X").
8
+
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const { dailiesDir } = require("./paths");
12
+
13
+ const DAILY_RE = /^Daily - (\d{4})-(\d{2})-(\d{2})\.md$/;
14
+ const COMMIT_HASH = /`([0-9a-f]{7,40})`/g;
15
+ const PROJECT_TAG = /\[([^\]]+)\]/g;
16
+
17
+ function listDailyNotes(vault) {
18
+ const root = dailiesDir(vault);
19
+ const out = [];
20
+ let months;
21
+ try { months = fs.readdirSync(root, { withFileTypes: true }); } catch { return out; }
22
+ for (const m of months) {
23
+ if (!m.isDirectory()) continue;
24
+ const mdir = path.join(root, m.name);
25
+ let files;
26
+ try { files = fs.readdirSync(mdir); } catch { continue; }
27
+ for (const f of files) {
28
+ const mm = f.match(DAILY_RE);
29
+ if (mm) out.push({ date: `${mm[1]}-${mm[2]}-${mm[3]}`, file: path.join(mdir, f) });
30
+ }
31
+ }
32
+ // Also support flat (no month subfolder) layout.
33
+ try {
34
+ for (const f of fs.readdirSync(root)) {
35
+ const mm = f.match(DAILY_RE);
36
+ if (mm) out.push({ date: `${mm[1]}-${mm[2]}-${mm[3]}`, file: path.join(root, f) });
37
+ }
38
+ } catch {}
39
+ out.sort((a, b) => (a.date < b.date ? 1 : -1)); // newest first
40
+ return out;
41
+ }
42
+
43
+ function extractCommits(text) {
44
+ const out = new Set();
45
+ let m;
46
+ COMMIT_HASH.lastIndex = 0;
47
+ while ((m = COMMIT_HASH.exec(text)) !== null) {
48
+ // commit hashes are 7-40 hex; skip obvious non-hashes (all digits, e.g. line refs)
49
+ if (/[a-f]/.test(m[1]) && m[1].length >= 7) out.add(m[1].slice(0, 10));
50
+ }
51
+ return [...out].slice(0, 12);
52
+ }
53
+
54
+ // Pull the project label out of a "### Session Log [ATTENDED] … [Mover OS Bundle] …" header.
55
+ function projectFromHeader(headerLine) {
56
+ let m, project = null;
57
+ PROJECT_TAG.lastIndex = 0;
58
+ while ((m = PROJECT_TAG.exec(headerLine)) !== null) {
59
+ const tag = m[1].trim();
60
+ if (/^(ATTENDED|UNATTENDED|COMPACTED|Claude Code|Antigravity|Gemini|Cursor|Codex|Background Agent)$/i.test(tag)) continue;
61
+ if (/^~?\d/.test(tag)) continue; // timestamps
62
+ project = tag; // last non-meta tag wins (it's usually the project)
63
+ }
64
+ return project;
65
+ }
66
+
67
+ // Parse one Daily Note's "## Session Log" into arc docs.
68
+ function parseNote(note) {
69
+ let raw;
70
+ try { raw = fs.readFileSync(note.file, "utf8"); } catch { return []; }
71
+ const idx = raw.search(/^##\s+Session Log\s*$/m);
72
+ if (idx < 0) return [];
73
+ // Section = from "## Session Log" to next top-level "## " (or EOF).
74
+ let section = raw.slice(idx);
75
+ const nextTop = section.slice(3).search(/^##\s+(?!#)/m);
76
+ if (nextTop >= 0) section = section.slice(0, nextTop + 3);
77
+
78
+ let curProject = null;
79
+ const docs = [];
80
+
81
+ // Split into blocks on "#### " arc headers; track the nearest "### " session header for project.
82
+ const lines = section.split(/\r?\n/);
83
+ let arc = null;
84
+ const flush = () => {
85
+ if (!arc) return;
86
+ const body = arc.bodyLines.join("\n").trim();
87
+ const text = (arc.title + "\n" + body).trim();
88
+ if (text.length > 8) {
89
+ docs.push({
90
+ id: `slog:${note.date}#${arc.time || docs.length}`,
91
+ type: "sessionlog",
92
+ date: note.date,
93
+ time: arc.time,
94
+ title: arc.title,
95
+ project: curProject,
96
+ commits: extractCommits(text),
97
+ text,
98
+ });
99
+ }
100
+ arc = null;
101
+ };
102
+
103
+ for (const line of lines) {
104
+ const sess = line.match(/^###\s+(.*)$/);
105
+ if (sess && !line.startsWith("####")) {
106
+ const p = projectFromHeader(sess[1]);
107
+ if (p) curProject = p;
108
+ continue;
109
+ }
110
+ const arcHead = line.match(/^####\s+(.*)$/);
111
+ if (arcHead) {
112
+ flush();
113
+ const titleRaw = arcHead[1].trim();
114
+ const tm = titleRaw.match(/~?(\d{1,2}:\d{2})/);
115
+ arc = { time: tm ? tm[1] : null, title: titleRaw.replace(/^~?\d{1,2}:\d{2}\s*-?\s*/, "").trim() || titleRaw, bodyLines: [] };
116
+ continue;
117
+ }
118
+ if (arc) arc.bodyLines.push(line);
119
+ }
120
+ flush();
121
+ return docs;
122
+ }
123
+
124
+ // Parse all Daily Note session logs → curated arc docs.
125
+ function parseSessionLogs(vault, opts = {}) {
126
+ const notes = listDailyNotes(vault);
127
+ const limited = opts.limit ? notes.slice(0, opts.limit) : notes;
128
+ const docs = [];
129
+ for (const note of limited) {
130
+ for (const d of parseNote(note)) docs.push(d);
131
+ }
132
+ return { docs, noteCount: limited.length, arcCount: docs.length };
133
+ }
134
+
135
+ module.exports = { parseSessionLogs, listDailyNotes };
136
+
137
+ // ── CLI ──
138
+ if (require.main === module) {
139
+ const vault = process.argv[2] || require("./paths").resolveVault();
140
+ const r = parseSessionLogs(vault, { limit: Number(process.argv[3]) || 0 });
141
+ console.log(`${r.noteCount} daily notes → ${r.arcCount} session-log arcs`);
142
+ for (const d of r.docs.slice(0, 10)) {
143
+ console.log(` ${d.date} ${d.time || " - "} [${d.project || "?"}] ${d.title.slice(0, 60)} ${d.commits.length ? "commits:" + d.commits.join(",") : ""}`);
144
+ }
145
+ }
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { dailiesDir } = require("./paths");
6
+ const { localDateKey } = require("./date-utils");
7
+
8
+ // session-time-parser.js — REAL tracked time from the daily-note Session Log
9
+ // headers. This is the honest core of "said vs did hours": every attended
10
+ // session header carries a start-end clock range, e.g.
11
+ // ### Session Log [ATTENDED] — ~13:51-16:31 [Claude Code] [Mover OS Bundle]
12
+ // from which a genuine DURATION is computable. We aggregate those durations
13
+ // into real totals (today / 7d / 30d) and a best-effort by-project split.
14
+ //
15
+ // What this is NOT: time per DOMAIN (Work/Vitality/Faith). Session headers
16
+ // tag a [project], never a domain — so a domain split would be fabricated and
17
+ // is deliberately not produced here (the Evidence Covenant says so honestly).
18
+ //
19
+ // Honesty rules:
20
+ // - Only sessions with BOTH a start AND an end contribute minutes. A header
21
+ // with only a start (an open/auto-stub session) is counted as `untimed`,
22
+ // never given a guessed duration.
23
+ // - A session whose end is earlier than its start is treated as crossing
24
+ // midnight (+24h) ONLY if the result is a sane length; otherwise it is
25
+ // dropped to `untimed` rather than inventing a number.
26
+ // - Sessions longer than SANE_CAP_MIN are treated as a parse artefact and
27
+ // dropped to `untimed` (never a fabricated multi-day block).
28
+ //
29
+ // Returns: {
30
+ // available, daysScanned,
31
+ // today: { minutes, sessions },
32
+ // last7d: { minutes, sessions, untimed },
33
+ // last30d:{ minutes, sessions, untimed },
34
+ // byProject7d: [{ project, minutes, sessions }], // best-effort, desc
35
+ // longest: { minutes, date, project } | null,
36
+ // src: "Dailies session-log start-end ranges"
37
+ // }
38
+
39
+ const SANE_CAP_MIN = 20 * 60; // 20h: a real long session is plausible; beyond is a parse artefact
40
+
41
+ // tokens that appear in session-header brackets but are NOT the project name
42
+ const NON_PROJECT = /^(claude code|claude-code|antigravity|gemini|cursor|cline|codex|background agent|attended|auto-stub|unattended|compacted|cross-midnight|delta|checkpoint|[0-9a-f]{6,}|[a-z]{3}\s+\d{1,2})$/i;
43
+
44
+ function cleanProject(brackets) {
45
+ for (const b of brackets) {
46
+ const t = (b || "").trim();
47
+ if (!t) continue;
48
+ if (NON_PROJECT.test(t)) continue;
49
+ if (/^\d{1,2}:\d{2}/.test(t)) continue; // a stray time
50
+ return t;
51
+ }
52
+ return "unlabelled";
53
+ }
54
+
55
+ function compute({ vault, daysLookback = 30 } = {}) {
56
+ const empty = {
57
+ available: false, daysScanned: 0,
58
+ today: { minutes: 0, sessions: 0 },
59
+ last7d: { minutes: 0, sessions: 0, untimed: 0 },
60
+ last30d: { minutes: 0, sessions: 0, untimed: 0 },
61
+ byProject7d: [], longest: null,
62
+ src: "Dailies session-log start-end ranges",
63
+ };
64
+ if (!vault) return empty;
65
+ const dDir = dailiesDir(vault);
66
+ if (!fs.existsSync(dDir)) return empty;
67
+
68
+ let monthDirs;
69
+ try { monthDirs = fs.readdirSync(dDir).filter((d) => /^\d{4}-\d{2}$/.test(d)).sort().reverse(); }
70
+ catch (_) { return empty; }
71
+
72
+ const todayKey = localDateKey();
73
+ const cutoff = Date.now() - daysLookback * 24 * 3600 * 1000;
74
+ const sevenAgo = Date.now() - 7 * 24 * 3600 * 1000;
75
+
76
+ const today = { minutes: 0, sessions: 0 };
77
+ const last7d = { minutes: 0, sessions: 0, untimed: 0 };
78
+ const last30d = { minutes: 0, sessions: 0, untimed: 0 };
79
+ const proj7d = new Map();
80
+ let longest = null;
81
+ let scanned = 0;
82
+
83
+ // capture the session header start/end + the trailing bracket tokens
84
+ const reSession = /^###\s+Session Log\s+\[[^\]]+\]\s+—\s+~?(\d{1,2}):(\d{2})(?:\s*[-–]\s*(\d{1,2}):(\d{2}))?\s*(\[[^\]]*\][^\n]*)?$/gm;
85
+
86
+ outer: for (const mDir of monthDirs) {
87
+ if (scanned >= daysLookback) break;
88
+ const monthPath = path.join(dDir, mDir);
89
+ let files;
90
+ try { files = fs.readdirSync(monthPath).filter((f) => /^Daily - \d{4}-\d{2}-\d{2}\.md$/.test(f)).sort().reverse(); }
91
+ catch (_) { continue; }
92
+ for (const f of files) {
93
+ if (scanned >= daysLookback) break outer;
94
+ const m = f.match(/(\d{4})-(\d{2})-(\d{2})/);
95
+ if (!m) continue;
96
+ const dayKey = `${m[1]}-${m[2]}-${m[3]}`;
97
+ const dayTs = Date.parse(dayKey);
98
+ if (!Number.isFinite(dayTs) || dayTs < cutoff) continue;
99
+ const isToday = dayKey === todayKey;
100
+ const inLast7 = dayTs >= sevenAgo - 24 * 3600 * 1000; // day-granularity 7d window
101
+ scanned++;
102
+
103
+ let text;
104
+ try { text = fs.readFileSync(path.join(monthPath, f), "utf8"); }
105
+ catch (_) { continue; }
106
+
107
+ let slMatch = text.match(/^##\s+Session Log/m) || text.match(/^###\s+Session Log/m);
108
+ if (!slMatch || typeof slMatch.index !== "number") continue;
109
+ const section = text.slice(slMatch.index);
110
+
111
+ let mm;
112
+ reSession.lastIndex = 0;
113
+ while ((mm = reSession.exec(section)) !== null) {
114
+ const sh = parseInt(mm[1], 10), sm = parseInt(mm[2], 10);
115
+ if (!Number.isFinite(sh) || sh < 0 || sh > 23) continue;
116
+ const hasEnd = mm[3] != null;
117
+ const brackets = mm[5] ? (mm[5].match(/\[([^\]]*)\]/g) || []).map((b) => b.slice(1, -1)) : [];
118
+ const project = cleanProject(brackets);
119
+
120
+ // count every session toward 7d/30d session COUNT
121
+ last30d.sessions++;
122
+ if (inLast7) last7d.sessions++;
123
+ if (isToday) today.sessions++;
124
+
125
+ if (!hasEnd) { last30d.untimed++; if (inLast7) last7d.untimed++; continue; }
126
+
127
+ const eh = parseInt(mm[3], 10), em = parseInt(mm[4], 10);
128
+ if (!Number.isFinite(eh) || eh < 0 || eh > 23) { last30d.untimed++; if (inLast7) last7d.untimed++; continue; }
129
+ let mins = (eh * 60 + em) - (sh * 60 + sm);
130
+ if (mins < 0) mins += 24 * 60; // crossed midnight
131
+ if (mins <= 0 || mins > SANE_CAP_MIN) { last30d.untimed++; if (inLast7) last7d.untimed++; continue; }
132
+
133
+ last30d.minutes += mins;
134
+ if (inLast7) {
135
+ last7d.minutes += mins;
136
+ proj7d.set(project, (proj7d.get(project) || { minutes: 0, sessions: 0 }));
137
+ const p = proj7d.get(project); p.minutes += mins; p.sessions++;
138
+ }
139
+ if (isToday) today.minutes += mins;
140
+ if (!longest || mins > longest.minutes) longest = { minutes: mins, date: dayKey, project };
141
+ }
142
+ }
143
+ }
144
+
145
+ const byProject7d = Array.from(proj7d.entries())
146
+ .map(([project, v]) => ({ project, minutes: v.minutes, sessions: v.sessions }))
147
+ .sort((a, b) => b.minutes - a.minutes)
148
+ .slice(0, 5);
149
+
150
+ return {
151
+ available: scanned > 0 && (last30d.minutes > 0 || last30d.untimed > 0),
152
+ daysScanned: scanned,
153
+ today, last7d, last30d, byProject7d, longest,
154
+ src: "Dailies session-log start-end ranges",
155
+ };
156
+ }
157
+
158
+ module.exports = { compute };
@@ -0,0 +1,171 @@
1
+ "use strict";
2
+ // skill-index.js — zero-dep index over SKILL.md descriptions so a UserPromptSubmit hook can
3
+ // DETERMINISTICALLY surface the right skill. Claude Code's own skill auto-activation is pure
4
+ // model judgment over descriptions, so topic-specific skills rarely fire. This mirrors the
5
+ // memory-index/recall pattern: build a trigger index, match the prompt, inject a factual notice.
6
+ //
7
+ // Surfaceable set = skills that rely on the model noticing: alwaysApply:false, not
8
+ // disable_model_invocation, not glob/path-scoped (those load deterministically via `paths:`).
9
+
10
+ const fs = require("fs");
11
+ const os = require("os");
12
+ const path = require("path");
13
+ const { tokenize } = require("./memory-text");
14
+
15
+ let moverDir;
16
+ try { ({ moverDir } = require("./paths")); } catch { moverDir = () => path.join(os.homedir(), ".mover"); }
17
+
18
+ const IDX_DIR = process.env.MOVER_SKILL_INDEX_DIR || moverDir();
19
+ const IDX_FILE = path.join(IDX_DIR, "skill-index.json");
20
+
21
+ // Small synonym map (group key + members) so a prompt verb matches a description phrased
22
+ // differently ("draft"/"copy" ↔ "write"). Expansion is applied to the SKILL triggers at build
23
+ // time (one-sided) — the prompt is only stemmed at query time, which keeps precision higher.
24
+ const SYN = {
25
+ write: ["draft", "compose", "copy", "post", "tweet", "article", "content", "blog", "email", "publish", "thread"],
26
+ search: ["recall", "history", "past", "previous", "find", "remember", "session"],
27
+ plan: ["roadmap", "phase", "milestone", "task"],
28
+ test: ["tdd", "spec", "coverage"],
29
+ research: ["investigate", "competitor", "explore", "market"],
30
+ debug: ["fix", "error", "broken", "bug", "failing", "crash"],
31
+ review: ["check", "inspect", "critique", "audit"],
32
+ };
33
+ const SYN_INDEX = (() => {
34
+ const m = Object.create(null);
35
+ for (const key in SYN) {
36
+ const group = [key, ...SYN[key]];
37
+ for (const w of group) { (m[w] || (m[w] = new Set([w]))); for (const g of group) m[w].add(g); }
38
+ }
39
+ return m;
40
+ })();
41
+
42
+ // Crude stemmer — plural/gerund normalization only. Keeps min length so short roots survive.
43
+ function stem(w) {
44
+ w = String(w || "").toLowerCase();
45
+ if (w.length > 5 && w.endsWith("ing")) return w.slice(0, -3);
46
+ if (w.length > 4 && w.endsWith("ed")) return w.slice(0, -2);
47
+ if (w.length > 4 && w.endsWith("es")) return w.slice(0, -2);
48
+ if (w.length > 3 && w.endsWith("s") && !w.endsWith("ss")) return w.slice(0, -1);
49
+ return w;
50
+ }
51
+
52
+ // Build a skill's trigger set from its description: tokenize → stem → synonym-expand.
53
+ function triggerSet(description) {
54
+ const out = new Set();
55
+ for (const tok of tokenize(description)) {
56
+ const s = stem(tok);
57
+ out.add(s);
58
+ const grp = SYN_INDEX[tok] || SYN_INDEX[s];
59
+ if (grp) for (const g of grp) out.add(stem(g));
60
+ }
61
+ return [...out];
62
+ }
63
+
64
+ // Parse a SKILL.md YAML-ish frontmatter (line-based; no YAML dep). Single-line values only,
65
+ // which matches Mover's SKILL.md convention.
66
+ function parseFrontmatter(md) {
67
+ const fm = { name: "", description: "", alwaysApply: false, disabled: false, globs: [] };
68
+ const m = String(md || "").match(/^---\s*\n([\s\S]*?)\n---/);
69
+ if (!m) return fm;
70
+ for (const line of m[1].split("\n")) {
71
+ const mm = line.match(/^([a-zA-Z_-]+):\s*(.*)$/);
72
+ if (!mm) continue;
73
+ const key = mm[1];
74
+ let val = mm[2].trim().replace(/^["']|["']$/g, "");
75
+ if (key === "name") fm.name = val;
76
+ else if (key === "description") fm.description = val;
77
+ else if (key === "alwaysApply") fm.alwaysApply = /^true$/i.test(val);
78
+ else if (key === "disable_model_invocation") fm.disabled = /^true$/i.test(val);
79
+ else if (key === "globs") fm.globs = val ? val.split(/[,\s]+/).filter(Boolean) : [];
80
+ }
81
+ return fm;
82
+ }
83
+
84
+ function discoverSkillMd(dirs) {
85
+ const files = [];
86
+ const walk = (d) => {
87
+ let ents; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
88
+ for (const e of ents) {
89
+ const p = path.join(d, e.name);
90
+ if (e.isDirectory()) walk(p);
91
+ else if (e.name === "SKILL.md") files.push(p);
92
+ }
93
+ };
94
+ for (const d of dirs) walk(d);
95
+ return files;
96
+ }
97
+
98
+ function defaultSkillDirs() {
99
+ return [path.join(os.homedir(), ".claude", "skills")];
100
+ }
101
+
102
+ function ensureDir(d) { try { fs.mkdirSync(d, { recursive: true }); } catch {} }
103
+ function atomicWrite(file, data, isText) {
104
+ const tmp = file + ".tmp";
105
+ fs.writeFileSync(tmp, isText ? data : JSON.stringify(data));
106
+ fs.renameSync(tmp, file);
107
+ }
108
+
109
+ function buildSkillIndex(dirs) {
110
+ dirs = dirs && dirs.length ? dirs : defaultSkillDirs();
111
+ const files = discoverSkillMd(dirs);
112
+ const skills = [];
113
+ const seenNames = new Set();
114
+ const df = Object.create(null);
115
+ for (const file of files) {
116
+ let md; try { md = fs.readFileSync(file, "utf8"); } catch { continue; }
117
+ const fm = parseFrontmatter(md);
118
+ if (!fm.name || !fm.description) continue;
119
+ if (fm.alwaysApply || fm.disabled) continue; // already loads every session / manual-only
120
+ if (fm.globs && fm.globs.length) continue; // path-scoped → handled by `paths:` natively
121
+ if (seenNames.has(fm.name)) continue; // dedupe (personal + bundle copies)
122
+ const triggers = triggerSet(fm.description);
123
+ if (!triggers.length) continue;
124
+ seenNames.add(fm.name);
125
+ const oneLine = fm.description.replace(/\s+/g, " ").trim().slice(0, 140);
126
+ skills.push({ name: fm.name, oneLine, triggers });
127
+ for (const t of new Set(triggers)) df[t] = (df[t] || 0) + 1;
128
+ }
129
+ // Distinctive triggers for the stage-1 bash gate: words present in <= half the skills, len>=4.
130
+ const half = Math.max(1, Math.ceil(skills.length / 2));
131
+ const triggers = Object.keys(df).filter((w) => w.length >= 4 && df[w] <= half).sort();
132
+ const idx = { builtAt: Date.now(), count: skills.length, skills, triggers };
133
+ // Never overwrite a populated index with an empty one (protects a real index from a misconfigured
134
+ // run / CI with no installed skills).
135
+ if (skills.length === 0 && loadSkillIndex()) return idx;
136
+ ensureDir(IDX_DIR);
137
+ atomicWrite(IDX_FILE, idx);
138
+ atomicWrite(path.join(IDX_DIR, "skill-triggers.txt"), triggers.join("\n") + "\n", true);
139
+ return idx;
140
+ }
141
+
142
+ function loadSkillIndex() {
143
+ try { return JSON.parse(fs.readFileSync(IDX_FILE, "utf8")); } catch { return null; }
144
+ }
145
+
146
+ // Score each skill by how many distinct (stemmed) prompt tokens hit its expanded trigger set.
147
+ function querySkills(prompt, idx) {
148
+ if (!idx || !idx.skills) return [];
149
+ const ptoks = new Set(tokenize(prompt).map(stem));
150
+ const hits = [];
151
+ for (const s of idx.skills) {
152
+ const tset = new Set(s.triggers);
153
+ let score = 0;
154
+ for (const w of ptoks) if (tset.has(w)) score++;
155
+ if (score > 0) hits.push({ name: s.name, oneLine: s.oneLine, score });
156
+ }
157
+ hits.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
158
+ return hits;
159
+ }
160
+
161
+ module.exports = {
162
+ parseFrontmatter, stem, triggerSet, buildSkillIndex, loadSkillIndex, querySkills,
163
+ discoverSkillMd, defaultSkillDirs, IDX_DIR, IDX_FILE,
164
+ };
165
+
166
+ // ── CLI: build the index from installed skills (or explicit dirs). Run at install/update. ──
167
+ if (require.main === module) {
168
+ const dirs = process.argv.slice(2).filter((a) => !a.startsWith("--"));
169
+ const r = buildSkillIndex(dirs.length ? dirs : defaultSkillDirs());
170
+ console.log(`skill-index: ${r.count} skills indexed, ${r.triggers.length} triggers → ${IDX_FILE}`);
171
+ }
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+ const { safeListDir, safeRead, safeStat } = require("./paths");
7
+
8
+ // Enumerate ~/.claude/skills/*/SKILL.md files, parse YAML frontmatter,
9
+ // and return a categorized inventory for the Library route's Skills sub-tab.
10
+ //
11
+ // Frontmatter shape (varies by skill):
12
+ // name: <slug>
13
+ // description: <one-liner trigger condition or blurb>
14
+ // model: sonnet | haiku | opus
15
+ // alwaysApply: false (optional)
16
+ //
17
+ // We also dedup across ~/.claude/skills, ~/.cursor/skills, ~/.gemini/skills
18
+ // — only the canonical Claude location is read (the others are mirrors).
19
+
20
+ // System skills that are always-on intelligence rather than domain skills
21
+ const SYSTEM_SKILLS = new Set([
22
+ "friction-enforcer",
23
+ "pattern-detector",
24
+ "plan-md-guardian",
25
+ "mover-os-context",
26
+ "workflow-router",
27
+ "strategic-compact",
28
+ "verification-before-completion",
29
+ "parallel-agents",
30
+ "daily-note-writer",
31
+ "skill-snapshot",
32
+ "find-skills",
33
+ ]);
34
+
35
+ function claudeSkillsDir() {
36
+ return path.join(os.homedir(), ".claude", "skills");
37
+ }
38
+
39
+ function parseFrontmatter(content) {
40
+ if (!content) return null;
41
+ const m = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
42
+ if (!m) return null;
43
+ const body = m[1];
44
+ const out = {};
45
+ // Parse simple key: value pairs. Quotes optional.
46
+ body.split(/\r?\n/).forEach(line => {
47
+ const km = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/);
48
+ if (km) {
49
+ const key = km[1].trim();
50
+ let val = km[2].trim();
51
+ // Strip wrapping quotes
52
+ if ((val.startsWith('"') && val.endsWith('"')) ||
53
+ (val.startsWith("'") && val.endsWith("'"))) {
54
+ val = val.slice(1, -1);
55
+ }
56
+ // Coerce booleans
57
+ if (val === "true") val = true;
58
+ else if (val === "false") val = false;
59
+ out[key] = val;
60
+ }
61
+ });
62
+ return out;
63
+ }
64
+
65
+ function categorize(slug, frontmatter) {
66
+ if (SYSTEM_SKILLS.has(slug)) return "system";
67
+ if (slug.startsWith("agent-")) return "specialist";
68
+ return "domain";
69
+ }
70
+
71
+ function indexSkills() {
72
+ const dir = claudeSkillsDir();
73
+ if (!safeStat(dir)) {
74
+ return { available: false, count: 0, byCategory: { system: [], specialist: [], domain: [] }, all: [] };
75
+ }
76
+
77
+ const skillDirs = safeListDir(dir, { dirsOnly: true });
78
+ const all = [];
79
+
80
+ for (const sd of skillDirs) {
81
+ if (sd.name.startsWith("_") || sd.name.startsWith(".") || sd.name.endsWith("-workspace")) continue;
82
+ const skillFile = path.join(sd.path, "SKILL.md");
83
+ const content = safeRead(skillFile);
84
+ if (!content) continue;
85
+ const fm = parseFrontmatter(content) || {};
86
+ const stat = safeStat(skillFile);
87
+
88
+ const slug = fm.name || sd.name;
89
+ const category = categorize(slug, fm);
90
+
91
+ all.push({
92
+ slug,
93
+ name: fm.name || sd.name,
94
+ description: fm.description || "",
95
+ model: fm.model || null,
96
+ alwaysApply: fm.alwaysApply === true || fm.alwaysApply === "true",
97
+ category,
98
+ path: skillFile,
99
+ mtime: stat ? stat.mtime.getTime() : null,
100
+ });
101
+ }
102
+
103
+ all.sort((a, b) => a.name.localeCompare(b.name));
104
+
105
+ const byCategory = { system: [], specialist: [], domain: [] };
106
+ for (const skill of all) {
107
+ byCategory[skill.category].push(skill);
108
+ }
109
+
110
+ return {
111
+ available: true,
112
+ count: all.length,
113
+ byCategory,
114
+ all,
115
+ };
116
+ }
117
+
118
+ module.exports = { indexSkills };