mover-os 4.7.7 → 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.
Files changed (107) hide show
  1. package/install.js +688 -68
  2. package/package.json +15 -3
  3. package/src/dashboard/build.js +1503 -0
  4. package/src/dashboard/dashboard.js +276 -0
  5. package/src/dashboard/index.js +319 -0
  6. package/src/dashboard/lib/activation-log.js +297 -0
  7. package/src/dashboard/lib/active-context-parser.js +177 -0
  8. package/src/dashboard/lib/agent-command.js +93 -0
  9. package/src/dashboard/lib/agent-detect.js +255 -0
  10. package/src/dashboard/lib/agent-detector.js +92 -0
  11. package/src/dashboard/lib/agent-session.js +462 -0
  12. package/src/dashboard/lib/anti-identity-detector.js +116 -0
  13. package/src/dashboard/lib/auto-learnings-parser.js +183 -0
  14. package/src/dashboard/lib/cli-usage-parser.js +92 -0
  15. package/src/dashboard/lib/config-parser.js +109 -0
  16. package/src/dashboard/lib/connect-recommender.js +131 -0
  17. package/src/dashboard/lib/correlations-parser.js +231 -0
  18. package/src/dashboard/lib/daily-note-resolver.js +211 -0
  19. package/src/dashboard/lib/date-utils.js +35 -0
  20. package/src/dashboard/lib/distribution-parser.js +78 -0
  21. package/src/dashboard/lib/dossier-parser.js +64 -0
  22. package/src/dashboard/lib/drift-history.js +83 -0
  23. package/src/dashboard/lib/drift-score.js +119 -0
  24. package/src/dashboard/lib/engine-health.js +170 -0
  25. package/src/dashboard/lib/engine-writer.js +1685 -0
  26. package/src/dashboard/lib/execution-plan.js +125 -0
  27. package/src/dashboard/lib/experiments-parser.js +429 -0
  28. package/src/dashboard/lib/feed-parser.js +294 -0
  29. package/src/dashboard/lib/forked-future.js +60 -0
  30. package/src/dashboard/lib/goal-forecast.js +304 -0
  31. package/src/dashboard/lib/goals-parser.js +67 -0
  32. package/src/dashboard/lib/health-protocols-parser.js +133 -0
  33. package/src/dashboard/lib/hook-activity.js +48 -0
  34. package/src/dashboard/lib/hook-indexer.js +169 -0
  35. package/src/dashboard/lib/hourly-activity-parser.js +143 -0
  36. package/src/dashboard/lib/identity-parser.js +85 -0
  37. package/src/dashboard/lib/ingestion.js +418 -0
  38. package/src/dashboard/lib/library-indexer-v2.js +212 -0
  39. package/src/dashboard/lib/library-indexer.js +105 -0
  40. package/src/dashboard/lib/log-activation.sh +61 -0
  41. package/src/dashboard/lib/memory-curator.js +97 -0
  42. package/src/dashboard/lib/memory-gardener.js +177 -0
  43. package/src/dashboard/lib/memory-gepa.js +102 -0
  44. package/src/dashboard/lib/memory-index.js +470 -0
  45. package/src/dashboard/lib/memory-rerank.js +72 -0
  46. package/src/dashboard/lib/memory-text.js +136 -0
  47. package/src/dashboard/lib/metrics-log-parser.js +76 -0
  48. package/src/dashboard/lib/moves-usage-parser.js +184 -0
  49. package/src/dashboard/lib/onboarding-forge.js +70 -0
  50. package/src/dashboard/lib/override-outcome-parser.js +68 -0
  51. package/src/dashboard/lib/override-summary.js +73 -0
  52. package/src/dashboard/lib/paths.js +192 -0
  53. package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
  54. package/src/dashboard/lib/phantom-strategy.js +129 -0
  55. package/src/dashboard/lib/project-scanner.js +121 -0
  56. package/src/dashboard/lib/promise-wall.js +88 -0
  57. package/src/dashboard/lib/record-score.js +173 -0
  58. package/src/dashboard/lib/redaction.js +140 -0
  59. package/src/dashboard/lib/refusal-parser.js +44 -0
  60. package/src/dashboard/lib/regenerate-manifest.js +206 -0
  61. package/src/dashboard/lib/rewind-snapshots.js +689 -0
  62. package/src/dashboard/lib/roast-wall-parser.js +200 -0
  63. package/src/dashboard/lib/run-registry.js +226 -0
  64. package/src/dashboard/lib/safe-write.js +63 -0
  65. package/src/dashboard/lib/session-log-parser.js +145 -0
  66. package/src/dashboard/lib/session-time-parser.js +158 -0
  67. package/src/dashboard/lib/skill-index.js +171 -0
  68. package/src/dashboard/lib/skill-indexer.js +118 -0
  69. package/src/dashboard/lib/skill-recommender.js +689 -0
  70. package/src/dashboard/lib/strategy-parser.js +245 -0
  71. package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
  72. package/src/dashboard/lib/streak-parser.js +95 -0
  73. package/src/dashboard/lib/suggested-now.js +254 -0
  74. package/src/dashboard/lib/tool-awareness.js +125 -0
  75. package/src/dashboard/lib/transcript-parser.js +331 -0
  76. package/src/dashboard/lib/vault-graph-parser.js +205 -0
  77. package/src/dashboard/lib/view-generator.js +163 -0
  78. package/src/dashboard/lib/voice-dna-parser.js +57 -0
  79. package/src/dashboard/lib/walkthrough-script.js +140 -0
  80. package/src/dashboard/lib/workflow-graph-parser.js +170 -0
  81. package/src/dashboard/lib/workflow-library-parser.js +146 -0
  82. package/src/dashboard/server.js +2024 -0
  83. package/src/dashboard/shortcut.js +0 -0
  84. package/src/dashboard/static/setup-poc.html +306 -0
  85. package/src/dashboard/static/walkthrough-poc.html +580 -0
  86. package/src/dashboard/styles.css +1201 -0
  87. package/src/dashboard/templates/index.html +278 -0
  88. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  89. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  90. package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  91. package/src/dashboard/ui/dist/assets/hero.png +0 -0
  92. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +157 -0
  93. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +1 -0
  94. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +34 -0
  95. package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  96. package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  97. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  98. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  99. package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  100. package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
  101. package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
  102. package/src/dashboard/ui/dist/icon.svg +4 -0
  103. package/src/dashboard/ui/dist/index.html +18 -0
  104. package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
  105. package/src/dashboard/ui/dist/registerSW.js +1 -0
  106. package/src/dashboard/ui/dist/sw.js +1 -0
  107. package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
@@ -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 };