claude-session-insights 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,7 @@ Think "Spotify Wrapped" for your Claude Code usage — scores, summaries, badges
10
10
  - **Overall Summary** — natural-language assessment of your prompting habits with specific recommendations
11
11
  - **Per-Session Summary** — each session gets a plain-English breakdown of what happened, what went well, and what could improve
12
12
  - **Session Drill-down** — click any session to see the full conversation timeline with per-turn token counts, costs, tool calls, and prompt previews
13
+ - **Workflow Optimizer** — analyzes your session patterns and recommends Claude Code setup improvements: skills to create, CLAUDE.md files to write, and subagents to configure. Optional AI generation produces ready-to-copy artifact content (full CLAUDE.md text, skill prompt bodies, agent configs)
13
14
  - **AI Insights** — on-demand deeper analysis powered by the Claude CLI, with model picker (Sonnet, Opus, Haiku) and streaming output
14
15
  - **Heaviest Sessions** — top sessions ranked by cost for quick identification of expensive outliers
15
16
  - **Daily Score Chart** — trend visualization of your efficiency score, session count, tokens, and cost over time
@@ -76,6 +77,19 @@ The dashboard generates rule-based summaries at two levels:
76
77
 
77
78
  **Per-session** — classifies each session (quick fix, focused task, long refactor), identifies the main cost driver, and highlights strengths. Displayed at the top of the session detail view.
78
79
 
80
+ ## Workflow Optimizer
81
+
82
+ Closes the loop from "how am I doing?" to "here's what to build to do better." The optimizer runs two phases:
83
+
84
+ **Phase 1 — Rule-based suggestions (instant):** Detects patterns from your badges, tool usage, session titles, and project history to recommend:
85
+
86
+ - **Skills** — slash commands to create in `~/.claude/skills/`. Example: if you have the Vague Commander badge, it suggests a `/spec` skill to help you write thorough task specs before Claude starts working
87
+ - **CLAUDE.md** — project-scoped or global config files to reduce repeated explanations. Triggered by projects with 5+ sessions, the Context Hoarder badge, Opus overuse, etc.
88
+ - **Agents** — subagent configurations for offloading exploration or repetitive task types
89
+ - **Plugins** — MCP servers only where CLI tools have a genuine capability gap (e.g. Playwright for browser tasks, not tools with good CLI equivalents that would just add context overhead)
90
+
91
+ **Phase 2 — AI content generation (optional):** Click "Generate artifact content with AI" to stream ready-to-copy content for each suggestion — the actual CLAUDE.md file, the real skill prompt body, agent configuration — not just a description of what to build.
92
+
79
93
  ## AI Insights
80
94
 
81
95
  Click "Generate AI Insights" to run a deeper analysis using the Claude CLI. This streams a response via SSE that covers:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-session-insights",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Analyze Claude Code sessions for efficiency insights, scores, and team metrics",
5
5
  "type": "module",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -645,6 +645,129 @@
645
645
  }
646
646
  .ai-prompt-close:hover { color: var(--text); }
647
647
 
648
+ /* Workflow Optimizer */
649
+ .optimizer-card {
650
+ background: var(--surface); border: 1px solid var(--border);
651
+ border-radius: var(--radius); padding: 24px 28px; margin-bottom: 24px;
652
+ position: relative; overflow: visible; box-shadow: var(--shadow-card);
653
+ }
654
+ .optimizer-card::before {
655
+ content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
656
+ background: linear-gradient(90deg, var(--accent), var(--green));
657
+ opacity: 0.7; border-radius: var(--radius) var(--radius) 0 0;
658
+ }
659
+ .optimizer-card h3 {
660
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px;
661
+ color: var(--accent); margin-bottom: 14px; font-weight: 600;
662
+ display: flex; align-items: center; gap: 8px;
663
+ }
664
+ .optimizer-tabs {
665
+ display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap;
666
+ }
667
+ .optimizer-tab {
668
+ padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
669
+ background: var(--surface2); color: var(--text2); font-size: 11px;
670
+ font-weight: 500; cursor: pointer; transition: all 0.15s;
671
+ }
672
+ .optimizer-tab:hover { background: var(--surface3); color: var(--text); }
673
+ .optimizer-tab.active {
674
+ background: var(--accent-dim); border-color: rgba(37,99,235,0.3);
675
+ color: var(--accent); font-weight: 600;
676
+ }
677
+ .optimizer-tab .tab-count {
678
+ display: inline-block; min-width: 16px; height: 16px; border-radius: 8px;
679
+ background: var(--surface3); color: var(--text3); font-size: 10px;
680
+ text-align: center; line-height: 16px; margin-left: 4px;
681
+ }
682
+ .optimizer-tab.active .tab-count {
683
+ background: rgba(37,99,235,0.2); color: var(--accent);
684
+ }
685
+ .optimizer-panel { display: none; }
686
+ .optimizer-panel.active { display: block; }
687
+ .suggestion-empty {
688
+ color: var(--text3); font-size: 13px; padding: 16px 0;
689
+ display: flex; align-items: center; gap: 8px;
690
+ }
691
+ .suggestion-card {
692
+ border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px;
693
+ margin-bottom: 10px; background: var(--surface2);
694
+ transition: border-color 0.15s;
695
+ }
696
+ .suggestion-card:last-child { margin-bottom: 0; }
697
+ .suggestion-card:hover { border-color: var(--border-hover); }
698
+ .suggestion-header {
699
+ display: flex; align-items: flex-start; justify-content: space-between; gap: 10px;
700
+ margin-bottom: 6px;
701
+ }
702
+ .suggestion-title { font-size: 13px; font-weight: 600; color: var(--text); flex: 1; }
703
+ .suggestion-priority {
704
+ font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 10px;
705
+ white-space: nowrap; flex-shrink: 0;
706
+ }
707
+ .priority-high { background: var(--red-dim); color: var(--red); }
708
+ .priority-medium { background: var(--yellow-dim); color: var(--yellow); }
709
+ .priority-low { background: var(--surface3); color: var(--text3); }
710
+ .suggestion-rationale {
711
+ font-size: 12px; color: var(--text2); line-height: 1.6; margin-bottom: 8px;
712
+ }
713
+ .suggestion-meta {
714
+ font-size: 11px; color: var(--text3); font-family: var(--mono);
715
+ display: flex; gap: 12px; flex-wrap: wrap;
716
+ }
717
+ .suggestion-meta span { display: flex; align-items: center; gap: 4px; }
718
+ .optimizer-ai-section {
719
+ margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border);
720
+ }
721
+ .optimizer-ai-controls {
722
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px;
723
+ }
724
+ .optimizer-generate-btn {
725
+ background: var(--accent-dim); border: 1px solid rgba(37,99,235,0.2);
726
+ color: var(--accent); padding: 8px 16px; border-radius: 7px;
727
+ cursor: pointer; font-size: 12px; font-weight: 600; transition: all 0.2s;
728
+ }
729
+ .optimizer-generate-btn:hover {
730
+ background: rgba(37,99,235,0.15); border-color: rgba(37,99,235,0.3);
731
+ }
732
+ .optimizer-generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
733
+ .optimizer-ai-content {
734
+ font-size: 13px; line-height: 1.75; color: var(--text);
735
+ }
736
+ .optimizer-ai-content h3 {
737
+ font-size: 13px; font-weight: 700; color: var(--text);
738
+ margin: 16px 0 8px; text-transform: none; letter-spacing: normal;
739
+ display: flex; align-items: center; justify-content: space-between;
740
+ }
741
+ .optimizer-ai-content p { margin-bottom: 8px; }
742
+ .optimizer-ai-content ul { padding-left: 18px; margin-bottom: 8px; }
743
+ .optimizer-ai-content li { margin-bottom: 4px; list-style: disc; }
744
+ .optimizer-ai-content strong { color: var(--text); }
745
+ .optimizer-ai-content code {
746
+ font-family: var(--mono); font-size: 11px; background: var(--surface3);
747
+ padding: 1px 5px; border-radius: 3px;
748
+ }
749
+ .optimizer-ai-content pre {
750
+ background: var(--surface3); border-radius: 6px; padding: 12px 14px;
751
+ font-family: var(--mono); font-size: 11px; line-height: 1.6;
752
+ overflow-x: auto; margin-bottom: 10px; white-space: pre-wrap; word-break: break-word;
753
+ position: relative;
754
+ }
755
+ .optimizer-ai-content hr {
756
+ border: none; border-top: 1px solid var(--border); margin: 16px 0;
757
+ }
758
+ .copy-btn {
759
+ background: var(--surface); border: 1px solid var(--border); color: var(--text3);
760
+ padding: 2px 8px; border-radius: 5px; cursor: pointer; font-size: 10px;
761
+ font-family: var(--mono); transition: all 0.15s; white-space: nowrap;
762
+ }
763
+ .copy-btn:hover { color: var(--text); border-color: var(--border-hover); }
764
+ .copy-btn.copied { color: var(--green); border-color: rgba(22,163,74,0.3); }
765
+ .optimizer-timestamp {
766
+ font-size: 10px; color: var(--text3); font-family: var(--mono);
767
+ margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--border);
768
+ display: flex; align-items: center; gap: 10px;
769
+ }
770
+
648
771
  /* Share modal */
649
772
  .share-btn {
650
773
  background: var(--surface2); border: 1px solid var(--border); color: var(--text2);
@@ -653,6 +776,65 @@
653
776
  transition: all 0.2s; font-size: 12px; font-weight: 500; white-space: nowrap;
654
777
  }
655
778
  .share-btn:hover { background: var(--surface3); color: var(--text); border-color: var(--border-hover); }
779
+ /* What's New */
780
+ .whatsnew-overlay {
781
+ display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.45);
782
+ z-index: 1100; align-items: center; justify-content: center;
783
+ backdrop-filter: blur(3px);
784
+ }
785
+ .whatsnew-overlay.open { display: flex; }
786
+ .whatsnew-modal {
787
+ background: var(--surface); border: 1px solid var(--border);
788
+ border-radius: var(--radius); padding: 28px 32px; max-width: 480px; width: 90%;
789
+ box-shadow: 0 12px 40px rgba(0,0,0,0.22); position: relative;
790
+ }
791
+ .whatsnew-modal::before {
792
+ content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
793
+ background: linear-gradient(90deg, var(--accent), var(--green));
794
+ border-radius: var(--radius) var(--radius) 0 0;
795
+ }
796
+ .whatsnew-version {
797
+ font-size: 10px; font-family: var(--mono); color: var(--accent);
798
+ font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px;
799
+ margin-bottom: 6px;
800
+ }
801
+ .whatsnew-modal h2 {
802
+ font-size: 17px; font-weight: 700; color: var(--text);
803
+ }
804
+ .whatsnew-items { list-style: none; margin-bottom: 22px; }
805
+ .whatsnew-items li {
806
+ display: flex; gap: 10px; align-items: flex-start;
807
+ font-size: 13px; color: var(--text2); line-height: 1.55; margin-bottom: 12px;
808
+ }
809
+ .whatsnew-items li:last-child { margin-bottom: 0; }
810
+ .whatsnew-items li .wi-icon {
811
+ flex-shrink: 0; width: 20px; height: 20px; border-radius: 5px;
812
+ background: var(--accent-dim); color: var(--accent);
813
+ display: flex; align-items: center; justify-content: center;
814
+ font-size: 11px; margin-top: 1px;
815
+ }
816
+ .whatsnew-items li strong { color: var(--text); }
817
+ .whatsnew-x {
818
+ position: absolute; top: 12px; right: 14px; background: none; border: none;
819
+ color: var(--text3); cursor: pointer; font-size: 18px; line-height: 1; padding: 4px;
820
+ }
821
+ .whatsnew-x:hover { color: var(--text); }
822
+ .whatsnew-actions { display: flex; gap: 8px; }
823
+ .whatsnew-dismiss {
824
+ flex: 1; padding: 9px; border-radius: 7px;
825
+ background: var(--accent-dim); border: 1px solid rgba(37,99,235,0.2);
826
+ color: var(--accent); font-size: 13px; font-weight: 600;
827
+ cursor: pointer; transition: all 0.15s;
828
+ }
829
+ .whatsnew-dismiss:hover { background: rgba(37,99,235,0.15); }
830
+ .whatsnew-showme {
831
+ padding: 3px 10px; border-radius: 5px;
832
+ background: transparent; border: 1px solid var(--border);
833
+ color: var(--text3); font-size: 11px; font-weight: 500;
834
+ cursor: pointer; transition: all 0.15s; white-space: nowrap;
835
+ }
836
+ .whatsnew-showme:hover { color: var(--accent); border-color: var(--accent); }
837
+
656
838
  .share-overlay {
657
839
  display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4);
658
840
  z-index: 1000; align-items: center; justify-content: center;
@@ -1079,6 +1261,14 @@ function renderDashboard(data) {
1079
1261
  </div>
1080
1262
  </div>
1081
1263
  </section>
1264
+ <section id="optimizer-section">
1265
+ <div class="optimizer-card">
1266
+ <h3>Workflow Optimizer</h3>
1267
+ <div id="optimizer-body">
1268
+ <div style="color:var(--text3); font-size:13px; padding: 4px 0 12px;">Loading suggestions<span id="optimizer-dots"></span></div>
1269
+ </div>
1270
+ </div>
1271
+ </section>
1082
1272
  <div class="score-breakdown-overlay" id="score-breakdown-overlay" onclick="if(event.target===this)toggleScoreBreakdown()">
1083
1273
  <div class="score-breakdown">
1084
1274
  <button class="score-breakdown-close" onclick="toggleScoreBreakdown()">&times;</button>
@@ -1120,6 +1310,33 @@ function renderDashboard(data) {
1120
1310
  <pre id="ai-prompt-content">Loading...</pre>
1121
1311
  </div>
1122
1312
  </div>
1313
+ <div class="whatsnew-overlay" id="whatsnew-overlay" onclick="if(event.target===this)closeWhatsNew(false)">
1314
+ <div class="whatsnew-modal">
1315
+ <button class="whatsnew-x" onclick="closeWhatsNew(false)">&times;</button>
1316
+ <div class="whatsnew-version">v0.4.0 — What's New</div>
1317
+ <div style="display:flex; align-items:baseline; gap:10px; margin-bottom:18px;">
1318
+ <h2 style="margin-bottom:0">Workflow Optimizer</h2>
1319
+ <button class="whatsnew-showme" onclick="showMeWhatsNew()">Take me there!</button>
1320
+ </div>
1321
+ <ul class="whatsnew-items">
1322
+ <li>
1323
+ <span class="wi-icon">&#9881;</span>
1324
+ <span><strong>Rule-based suggestions</strong> — analyzes your badges, tool patterns, and session history to recommend skills, CLAUDE.md files, and subagents to create</span>
1325
+ </li>
1326
+ <li>
1327
+ <span class="wi-icon">&#10024;</span>
1328
+ <span><strong>AI artifact generation</strong> — streams ready-to-copy content: full CLAUDE.md text, skill prompt bodies, and agent configs tailored to your workflow</span>
1329
+ </li>
1330
+ <li>
1331
+ <span class="wi-icon">&#128274;</span>
1332
+ <span><strong>Context-aware plugin advice</strong> — only suggests MCP plugins where CLI tools have a real gap, avoiding unnecessary context overhead</span>
1333
+ </li>
1334
+ </ul>
1335
+ <div class="whatsnew-actions">
1336
+ <button class="whatsnew-dismiss" onclick="closeWhatsNew(true)">OK. Don't show again.</button>
1337
+ </div>
1338
+ </div>
1339
+ </div>
1123
1340
  <div class="share-overlay" id="share-overlay" onclick="if(event.target===this)closeShareModal()">
1124
1341
  <div class="share-modal">
1125
1342
  <button class="share-close" onclick="closeShareModal()">&times;</button>
@@ -1590,6 +1807,309 @@ async function loadCachedAIInsights() {
1590
1807
  } catch {}
1591
1808
  }
1592
1809
 
1810
+ // --- Workflow Optimizer ---
1811
+
1812
+ let optimizerSuggestions = null;
1813
+ let activeOptimizerTab = 'all';
1814
+ let cachedOptimizerAI = null;
1815
+
1816
+ const OPTIMIZER_TABS = [
1817
+ { id: 'all', label: 'All' },
1818
+ { id: 'skills', label: 'Skills' },
1819
+ { id: 'claudeMd', label: 'CLAUDE.md' },
1820
+ { id: 'agents', label: 'Agents' },
1821
+ { id: 'plugins', label: 'Plugins' },
1822
+ ];
1823
+
1824
+ async function loadSuggestions() {
1825
+ let dotCount = 0;
1826
+ const dotsEl = document.getElementById('optimizer-dots');
1827
+ const dotTimer = setInterval(() => {
1828
+ dotCount = (dotCount + 1) % 4;
1829
+ if (dotsEl) dotsEl.textContent = '.'.repeat(dotCount);
1830
+ }, 400);
1831
+
1832
+ try {
1833
+ const res = await fetch('/api/suggestions');
1834
+ optimizerSuggestions = await res.json();
1835
+ clearInterval(dotTimer);
1836
+ renderOptimizerTabs();
1837
+ } catch {
1838
+ clearInterval(dotTimer);
1839
+ const body = document.getElementById('optimizer-body');
1840
+ if (body) body.innerHTML = '<div class="suggestion-empty">Could not load suggestions.</div>';
1841
+ }
1842
+ }
1843
+
1844
+ function renderOptimizerTabs() {
1845
+ const body = document.getElementById('optimizer-body');
1846
+ if (!body || !optimizerSuggestions) return;
1847
+
1848
+ const total = OPTIMIZER_TABS.reduce((s, t) => s + (optimizerSuggestions[t.id] || []).length, 0);
1849
+ if (total === 0) {
1850
+ body.innerHTML = '<div class="suggestion-empty">No suggestions — your workflow looks well-optimized!</div>';
1851
+ return;
1852
+ }
1853
+
1854
+ const tabsHtml = OPTIMIZER_TABS.map(t => {
1855
+ const count = t.id === 'all' ? total : (optimizerSuggestions[t.id] || []).length;
1856
+ return `<button class="optimizer-tab${t.id === activeOptimizerTab ? ' active' : ''}" onclick="switchOptimizerTab('${t.id}')">
1857
+ ${t.label}<span class="tab-count">${count}</span>
1858
+ </button>`;
1859
+ }).join('');
1860
+
1861
+ const allPanelHtml = OPTIMIZER_TABS.filter(t => t.id !== 'all').map(t => {
1862
+ const items = optimizerSuggestions[t.id] || [];
1863
+ if (!items.length) return '';
1864
+ return `<div style="margin-bottom:18px">
1865
+ <div style="font-size:10px;text-transform:uppercase;letter-spacing:0.7px;color:var(--text3);font-weight:600;margin-bottom:8px">${t.label}</div>
1866
+ ${renderSuggestionList(t.id, items)}
1867
+ </div>`;
1868
+ }).join('');
1869
+
1870
+ const panelsHtml = OPTIMIZER_TABS.map(t => `
1871
+ <div class="optimizer-panel${t.id === activeOptimizerTab ? ' active' : ''}" id="optimizer-panel-${t.id}">
1872
+ ${t.id === 'all' ? allPanelHtml : renderSuggestionList(t.id, optimizerSuggestions[t.id] || [])}
1873
+ </div>`).join('');
1874
+
1875
+ const aiSection = `
1876
+ <div class="optimizer-ai-section">
1877
+ <div class="optimizer-ai-controls">
1878
+ <button class="optimizer-generate-btn" onclick="generateOptimizerContent()" id="optimizer-gen-btn">
1879
+ Generate artifact content with AI
1880
+ </button>
1881
+ <span class="ai-model-picker" id="optimizer-model-picker">
1882
+ <span class="ai-model-pill" onclick="toggleModelMenu(event)" data-tip="Click to switch model"></span>
1883
+ <div class="ai-model-menu"></div>
1884
+ </span>
1885
+ </div>
1886
+ <div id="optimizer-ai-output"></div>
1887
+ </div>`;
1888
+
1889
+ body.innerHTML = `
1890
+ <p style="font-size:12px; color:var(--text3); margin-bottom:12px;">
1891
+ Based on ${(optimizerSuggestions.skills.length + optimizerSuggestions.claudeMd.length + optimizerSuggestions.agents.length + optimizerSuggestions.plugins.length)} patterns found in your sessions.
1892
+ </p>
1893
+ <div class="optimizer-tabs">${tabsHtml}</div>
1894
+ ${panelsHtml}
1895
+ ${aiSection}`;
1896
+
1897
+ // Sync model picker — reuse same aiModels
1898
+ const optimizerPicker = document.getElementById('optimizer-model-picker');
1899
+ if (optimizerPicker && aiModels.length > 0) {
1900
+ const current = aiModels.find(m => m.id === selectedModelId) ||
1901
+ aiModels.find(m => m.id === aiDefaultModel) || aiModels[0];
1902
+ const pill = optimizerPicker.querySelector('.ai-model-pill');
1903
+ const menu = optimizerPicker.querySelector('.ai-model-menu');
1904
+ if (pill) pill.innerHTML = `${current.label} <span class="pill-chevron">&#x25BE;</span>`;
1905
+ if (menu) menu.innerHTML = aiModels.map(m =>
1906
+ `<div class="ai-model-option${m.id === selectedModelId ? ' active' : ''}" data-model="${m.id}" onclick="selectModel('${m.id}')">` +
1907
+ `<span class="check">${m.id === selectedModelId ? '●' : ''}</span>${m.label}</div>`
1908
+ ).join('');
1909
+ }
1910
+
1911
+ if (cachedOptimizerAI) {
1912
+ renderOptimizerAIComplete(cachedOptimizerAI.content, cachedOptimizerAI.generatedAt, cachedOptimizerAI.model);
1913
+ }
1914
+ }
1915
+
1916
+ function renderSuggestionList(type, items) {
1917
+ if (!items.length) return '<div class="suggestion-empty">No suggestions for this category.</div>';
1918
+ return items.map(item => {
1919
+ const priorityClass = `priority-${item.priority || 'medium'}`;
1920
+ const priorityLabel = (item.priority || 'medium').charAt(0).toUpperCase() + (item.priority || 'medium').slice(1);
1921
+ let meta = '';
1922
+ if (type === 'skills' && item.trigger) meta = `<span>trigger: <strong>${escHtml(item.trigger)}</strong></span>`;
1923
+ if (type === 'claudeMd') meta = `<span>scope: <strong>${escHtml(item.scope)}${item.projectName ? ' · ' + escHtml(item.projectName) : ''}</strong></span>`;
1924
+ if (type === 'plugins' && item.installCmd) meta = `<span style="font-family:var(--mono)">${escHtml(item.installCmd)}</span>`;
1925
+ if (type === 'agents' && item.use_case) meta = `<span>${escHtml(item.use_case)}</span>`;
1926
+ return `
1927
+ <div class="suggestion-card">
1928
+ <div class="suggestion-header">
1929
+ <div class="suggestion-title">${escHtml(item.title || item.name || item.use_case || '')}</div>
1930
+ <span class="suggestion-priority ${priorityClass}">${priorityLabel}</span>
1931
+ </div>
1932
+ <div class="suggestion-rationale">${escHtml(item.rationale)}</div>
1933
+ ${meta ? `<div class="suggestion-meta">${meta}</div>` : ''}
1934
+ </div>`;
1935
+ }).join('');
1936
+ }
1937
+
1938
+ function switchOptimizerTab(tabId) {
1939
+ activeOptimizerTab = tabId;
1940
+ document.querySelectorAll('.optimizer-panel').forEach(p => {
1941
+ p.classList.toggle('active', p.id === `optimizer-panel-${tabId}`);
1942
+ });
1943
+ document.querySelectorAll('.optimizer-tab').forEach(t => {
1944
+ const onclick = t.getAttribute('onclick') || '';
1945
+ const match = onclick.match(/switchOptimizerTab\('([^']+)'\)/);
1946
+ if (match) t.classList.toggle('active', match[1] === tabId);
1947
+ });
1948
+ }
1949
+
1950
+ function generateOptimizerContent() {
1951
+ const btn = document.getElementById('optimizer-gen-btn');
1952
+ const output = document.getElementById('optimizer-ai-output');
1953
+ if (!btn || !output) return;
1954
+
1955
+ const wasActive = autoRefreshActive;
1956
+ if (autoRefreshActive) pauseAutoRefresh();
1957
+
1958
+ const modelId = selectedModelId;
1959
+ const modelParam = modelId ? `?model=${encodeURIComponent(modelId)}` : '';
1960
+
1961
+ btn.disabled = true;
1962
+ output.innerHTML = `
1963
+ <div class="ai-loading">
1964
+ <div class="ai-spinner"></div>
1965
+ <div>Generating with ${modelId ? modelLabel(modelId) : 'default model'}, please wait...</div>
1966
+ </div>
1967
+ <div class="optimizer-ai-content" id="optimizer-stream-content"></div>`;
1968
+
1969
+ streamOptimizerFetch(modelParam, output, wasActive);
1970
+ }
1971
+
1972
+ async function streamOptimizerFetch(modelParam, output, wasActive) {
1973
+ let fullContent = '';
1974
+ let usedModel = '';
1975
+
1976
+ try {
1977
+ const res = await fetch('/api/ai-suggestions' + modelParam, { method: 'POST' });
1978
+ const reader = res.body.getReader();
1979
+ const decoder = new TextDecoder();
1980
+ let buffer = '';
1981
+
1982
+ while (true) {
1983
+ const { done, value } = await reader.read();
1984
+ if (done) break;
1985
+
1986
+ buffer += decoder.decode(value, { stream: true });
1987
+ const lines = buffer.split('\n');
1988
+ buffer = lines.pop();
1989
+
1990
+ let currentEvent = '';
1991
+ for (const line of lines) {
1992
+ if (line.startsWith('event: ')) {
1993
+ currentEvent = line.slice(7);
1994
+ } else if (line.startsWith('data: ')) {
1995
+ const data = line.slice(6);
1996
+ if (currentEvent === 'model') {
1997
+ usedModel = JSON.parse(data);
1998
+ const loading = output.querySelector('.ai-loading div:last-child');
1999
+ if (loading) loading.textContent = `Generating with ${modelLabel(usedModel)}, please wait...`;
2000
+ } else if (currentEvent === 'chunk') {
2001
+ fullContent += JSON.parse(data);
2002
+ const el = document.getElementById('optimizer-stream-content');
2003
+ if (el) el.innerHTML = renderOptimizerMarkdown(escHtml(fullContent));
2004
+ } else if (currentEvent === 'done') {
2005
+ const { generatedAt, model } = JSON.parse(data);
2006
+ if (model) usedModel = model;
2007
+ const loading = output.querySelector('.ai-loading');
2008
+ if (loading) loading.remove();
2009
+ renderOptimizerAIComplete(fullContent, generatedAt, usedModel);
2010
+ if (wasActive) resumeAutoRefreshIfPaused();
2011
+ } else if (currentEvent === 'error') {
2012
+ const { message } = JSON.parse(data);
2013
+ const btn = document.getElementById('optimizer-gen-btn');
2014
+ if (btn) btn.disabled = false;
2015
+ output.innerHTML = `<div class="ai-error">${escHtml(message)}</div>
2016
+ <button class="optimizer-generate-btn" style="margin-top:8px" onclick="generateOptimizerContent()">Retry</button>`;
2017
+ if (wasActive) resumeAutoRefreshIfPaused();
2018
+ }
2019
+ }
2020
+ }
2021
+ }
2022
+ } catch (err) {
2023
+ const btn = document.getElementById('optimizer-gen-btn');
2024
+ if (btn) btn.disabled = false;
2025
+ output.innerHTML = `<div class="ai-error">Failed to connect to server</div>
2026
+ <button class="optimizer-generate-btn" style="margin-top:8px" onclick="generateOptimizerContent()">Retry</button>`;
2027
+ if (wasActive) resumeAutoRefreshIfPaused();
2028
+ }
2029
+ }
2030
+
2031
+ function renderOptimizerMarkdown(text) {
2032
+ // Handle code blocks first to protect their content
2033
+ const codeBlocks = [];
2034
+ text = text.replace(/```[\s\S]*?```/g, (match) => {
2035
+ const idx = codeBlocks.length;
2036
+ codeBlocks.push(match);
2037
+ return `__CODEBLOCK_${idx}__`;
2038
+ });
2039
+
2040
+ text = text
2041
+ .replace(/^### (.+)$/gm, (_, t) =>
2042
+ `<h3>${t} <button class="copy-btn" onclick="copySection(this)">copy</button></h3>`)
2043
+ .replace(/^## (.+)$/gm, '<h2>$1</h2>')
2044
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
2045
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
2046
+ .replace(/^\- (.+)$/gm, '<li>$1</li>')
2047
+ .replace(/(<li>.*<\/li>\n?)+/gs, m => '<ul>' + m + '</ul>')
2048
+ .replace(/^---$/gm, '<hr>')
2049
+ .replace(/\n{2,}/g, '</p><p>')
2050
+ .replace(/^(?!<[hul])/gm, s => s ? '<p>' + s : '')
2051
+ .replace(/<p><\/p>/g, '')
2052
+ .replace(/<p>(<[hul])/g, '$1');
2053
+
2054
+ // Restore code blocks as <pre>
2055
+ text = text.replace(/__CODEBLOCK_(\d+)__/g, (_, i) => {
2056
+ const raw = codeBlocks[parseInt(i)];
2057
+ const inner = raw.replace(/^```[^\n]*\n?/, '').replace(/```$/, '');
2058
+ return `<pre>${escHtml(inner)}<button class="copy-btn" style="position:absolute;top:6px;right:6px" onclick="copyPre(this)">copy</button></pre>`;
2059
+ });
2060
+
2061
+ return text;
2062
+ }
2063
+
2064
+ function renderOptimizerAIComplete(content, generatedAt, model) {
2065
+ cachedOptimizerAI = { content, generatedAt, model };
2066
+ const output = document.getElementById('optimizer-ai-output');
2067
+ const btn = document.getElementById('optimizer-gen-btn');
2068
+ if (!output) return;
2069
+ if (btn) btn.disabled = false;
2070
+ output.innerHTML = `
2071
+ <div class="optimizer-ai-content">${renderOptimizerMarkdown(escHtml(content))}</div>
2072
+ <div class="optimizer-timestamp">
2073
+ Generated ${new Date(generatedAt).toLocaleString()}${model ? ' · ' + modelLabel(model) : ''}
2074
+ <button class="optimizer-generate-btn" style="padding:4px 12px;font-size:11px;margin-left:8px" onclick="generateOptimizerContent()">Regenerate</button>
2075
+ <button class="optimizer-generate-btn" style="padding:4px 12px;font-size:11px;background:var(--surface2);color:var(--text2)" onclick="clearOptimizerAI()">Clear</button>
2076
+ </div>`;
2077
+ }
2078
+
2079
+ function clearOptimizerAI() {
2080
+ cachedOptimizerAI = null;
2081
+ fetch('/api/ai-suggestions', { method: 'DELETE' }).catch(() => {});
2082
+ const output = document.getElementById('optimizer-ai-output');
2083
+ const btn = document.getElementById('optimizer-gen-btn');
2084
+ if (output) output.innerHTML = '';
2085
+ if (btn) btn.disabled = false;
2086
+ }
2087
+
2088
+ function copySection(btn) {
2089
+ const h3 = btn.closest('h3');
2090
+ const section = [];
2091
+ let el = h3?.nextElementSibling;
2092
+ while (el && el.tagName !== 'H3' && el.tagName !== 'HR') {
2093
+ section.push(el.innerText || el.textContent);
2094
+ el = el.nextElementSibling;
2095
+ }
2096
+ const text = section.join('\n\n').trim();
2097
+ if (text) copyToClipboard(btn, text);
2098
+ }
2099
+
2100
+ function copyPre(btn) {
2101
+ const pre = btn.closest('pre');
2102
+ if (pre) copyToClipboard(btn, pre.textContent.replace('copy', '').trim());
2103
+ }
2104
+
2105
+ function copyToClipboard(btn, text) {
2106
+ navigator.clipboard.writeText(text).then(() => {
2107
+ btn.textContent = 'copied!';
2108
+ btn.classList.add('copied');
2109
+ setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('copied'); }, 2000);
2110
+ }).catch(() => {});
2111
+ }
2112
+
1593
2113
  // --- Share as PNG ---
1594
2114
 
1595
2115
  const BADGE_EMOJI = BADGE_ICONS;
@@ -1998,15 +2518,46 @@ function resumeAutoRefreshIfPaused() {
1998
2518
 
1999
2519
  document.addEventListener('keydown', e => {
2000
2520
  if (e.key === 'Escape' && currentView === 'session') closeSessionDrawer();
2521
+ if (e.key === 'Escape' && document.getElementById('whatsnew-overlay')?.classList.contains('open')) closeWhatsNew(false);
2001
2522
  });
2002
2523
 
2003
2524
  fetchData().then(data => {
2004
2525
  currentData = data;
2005
2526
  renderDashboard(data);
2527
+ maybeShowWhatsNew();
2006
2528
  loadCachedAIInsights();
2529
+ loadSuggestions();
2007
2530
  if (autoRefreshActive) startAutoRefresh();
2008
2531
  });
2009
2532
 
2533
+ // --- What's New ---
2534
+
2535
+ const WHATS_NEW_VERSION = '0.4.0';
2536
+
2537
+ function closeWhatsNew(persist) {
2538
+ document.getElementById('whatsnew-overlay').classList.remove('open');
2539
+ if (persist) localStorage.setItem('seenWhatsNew', WHATS_NEW_VERSION);
2540
+ }
2541
+
2542
+ function showMeWhatsNew() {
2543
+ closeWhatsNew(false);
2544
+ setTimeout(() => {
2545
+ const el = document.getElementById('optimizer-section');
2546
+ if (el) {
2547
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
2548
+ el.style.transition = 'box-shadow 0.3s';
2549
+ el.style.boxShadow = '0 0 0 2px var(--accent)';
2550
+ setTimeout(() => { el.style.boxShadow = ''; }, 1800);
2551
+ }
2552
+ }, 200);
2553
+ }
2554
+
2555
+ function maybeShowWhatsNew() {
2556
+ if (localStorage.getItem('seenWhatsNew') !== WHATS_NEW_VERSION) {
2557
+ document.getElementById('whatsnew-overlay').classList.add('open');
2558
+ }
2559
+ }
2560
+
2010
2561
  // Live reload via SSE
2011
2562
  const sse = new EventSource('/api/reload');
2012
2563
  sse.onmessage = async (e) => {
package/src/ai-analyze.js CHANGED
@@ -124,7 +124,79 @@ Keep the entire response under 400 words. Use markdown formatting.`;
124
124
 
125
125
  export { buildPrompt, buildDataSnapshot };
126
126
 
127
+ function buildSuggestionsPrompt(scoredData, suggestions) {
128
+ const { sessions, badges, overallScore } = scoredData;
129
+ const totalCost = sessions.reduce((s, x) => s + x.totals.estimatedCost, 0);
130
+ const projectNames = [...new Set(sessions.map((s) => s.project).filter(Boolean))].slice(0, 5);
131
+ const badgeNames = badges.map((b) => `${b.negative ? "⚠ " : "✓ "}${b.name}`).join(", ");
132
+
133
+ const topSkill = suggestions.skills[0];
134
+ const topClaudeMd = suggestions.claudeMd[0];
135
+ const topAgent = suggestions.agents[0];
136
+ const topPlugin = suggestions.plugins[0];
137
+
138
+ const parts = [];
139
+
140
+ if (topSkill) {
141
+ parts.push(`SKILL: "${topSkill.trigger}" — ${topSkill.title}
142
+ Template hint: ${topSkill.templateHint}`);
143
+ }
144
+ if (topClaudeMd) {
145
+ parts.push(`CLAUDE.MD (${topClaudeMd.scope}${topClaudeMd.projectName ? ` for ${topClaudeMd.projectName}` : ""}):
146
+ Sections to include: ${topClaudeMd.sections.join(", ")}`);
147
+ }
148
+ if (topAgent) {
149
+ parts.push(`AGENT: ${topAgent.name} — ${topAgent.use_case}
150
+ Example usage: ${topAgent.example || "n/a"}`);
151
+ }
152
+ if (topPlugin) {
153
+ parts.push(`PLUGIN: ${topPlugin.name}
154
+ Install: ${topPlugin.installCmd}`);
155
+ }
156
+
157
+ return `You are helping a developer improve their Claude Code workflow. Based on their session data, generate ready-to-use artifact content for the following recommendations.
158
+
159
+ ## Their Usage Context
160
+ - ${sessions.length} total sessions across ${projectNames.length} project(s): ${projectNames.join(", ")}
161
+ - Overall efficiency score: ${overallScore}/100
162
+ - Total spend: $${totalCost.toFixed(2)}
163
+ - Badges: ${badgeNames || "none"}
164
+
165
+ ## Recommendations to generate content for
166
+
167
+ ${parts.join("\n\n")}
168
+
169
+ ## Your output
170
+
171
+ For each recommendation above, generate the actual content the user can copy and use immediately. Use this exact structure:
172
+
173
+ ${topSkill ? `### Skill: ${topSkill.trigger}
174
+ Write the full skill prompt body (2-4 paragraphs). This is what Claude reads when the user types "${topSkill.trigger}". Make it specific, actionable, and tailored to the usage patterns above. Include what information the user should provide.
175
+
176
+ ---` : ""}
177
+
178
+ ${topClaudeMd ? `### CLAUDE.md${topClaudeMd.projectName ? ` (${topClaudeMd.projectName})` : " (global)"}
179
+ Write a complete, ready-to-use CLAUDE.md file. Include all the sections listed. Be specific about the patterns implied by their badges and session data. Use markdown formatting.
180
+
181
+ ---` : ""}
182
+
183
+ ${topAgent ? `### Agent: ${topAgent.name}
184
+ Describe exactly how to configure and invoke this agent in Claude Code. Include a sample prompt to use it effectively.
185
+
186
+ ---` : ""}
187
+
188
+ ${topPlugin ? `### Plugin: ${topPlugin.name}
189
+ Explain the 2-3 best use cases for this plugin given their workflow, and give a sample prompt that takes advantage of it.
190
+
191
+ ---` : ""}
192
+
193
+ Keep each artifact concise and immediately usable. No filler or generic advice.`;
194
+ }
195
+
196
+ export { buildSuggestionsPrompt };
197
+
127
198
  let cachedResult = null;
199
+ let cachedSuggestionResult = null;
128
200
  const activeChildren = new Set();
129
201
 
130
202
  /**
@@ -275,3 +347,114 @@ export function getAvailableModels() {
275
347
  defaultModelLabel: prettyModelName(detectedDefaultModel),
276
348
  };
277
349
  }
350
+
351
+ /**
352
+ * Stream AI-generated suggestion content (skills, CLAUDE.md, agents, plugins).
353
+ * Same SSE protocol as streamAIAnalysis.
354
+ */
355
+ export function streamAISuggestions(scoredData, suggestions, res, modelId) {
356
+ const prompt = buildSuggestionsPrompt(scoredData, suggestions);
357
+
358
+ const args = ["-p", "-", "--output-format", "stream-json", "--verbose"];
359
+ if (modelId) args.push("--model", modelId);
360
+
361
+ const resolvedModel = modelId || "default";
362
+ console.log(`[ai-suggestions] Starting (model: ${resolvedModel})`);
363
+ res.write(`event: model\ndata: ${JSON.stringify(resolvedModel)}\n\n`);
364
+
365
+ const child = spawn("claude", args, {
366
+ stdio: ["pipe", "pipe", "pipe"],
367
+ env: cleanEnv,
368
+ });
369
+ activeChildren.add(child);
370
+
371
+ child.stdin.write(prompt);
372
+ child.stdin.end();
373
+
374
+ let fullContent = "";
375
+ let errOutput = "";
376
+ let detectedModel = modelId || detectedDefaultModel || null;
377
+ let lineBuf = "";
378
+
379
+ child.stdout.on("data", (buf) => {
380
+ lineBuf += buf.toString();
381
+ const lines = lineBuf.split("\n");
382
+ lineBuf = lines.pop();
383
+ for (const line of lines) {
384
+ if (!line.trim()) continue;
385
+ try {
386
+ const obj = JSON.parse(line);
387
+ if (obj.type === "system" || obj.type === "rate_limit_event") continue;
388
+ if (obj.type === "result") {
389
+ const models = Object.keys(obj.modelUsage || {});
390
+ if (models.length > 0) {
391
+ detectedModel = models[0];
392
+ if (!modelId) detectedDefaultModel = models[0];
393
+ }
394
+ continue;
395
+ }
396
+ if (obj.type === "assistant" && obj.message?.content) {
397
+ for (const block of obj.message.content) {
398
+ if (block.type === "text" && block.text) {
399
+ fullContent += block.text;
400
+ res.write(`event: chunk\ndata: ${JSON.stringify(block.text)}\n\n`);
401
+ }
402
+ }
403
+ if (obj.message.model && !detectedModel) {
404
+ detectedModel = obj.message.model;
405
+ if (!modelId) detectedDefaultModel = obj.message.model;
406
+ }
407
+ }
408
+ } catch {
409
+ if (line.trim()) {
410
+ fullContent += line;
411
+ res.write(`event: chunk\ndata: ${JSON.stringify(line)}\n\n`);
412
+ }
413
+ }
414
+ }
415
+ });
416
+
417
+ child.stderr.on("data", (buf) => {
418
+ errOutput += buf.toString();
419
+ });
420
+
421
+ child.on("error", (err) => {
422
+ const message =
423
+ err.code === "ENOENT"
424
+ ? "Claude CLI not found. Make sure `claude` is installed and in your PATH."
425
+ : `Claude CLI error: ${err.message}`;
426
+ res.write(`event: error\ndata: ${JSON.stringify({ message })}\n\n`);
427
+ res.end();
428
+ });
429
+
430
+ child.on("close", (code) => {
431
+ activeChildren.delete(child);
432
+ if (code !== 0 && !fullContent) {
433
+ const message = errOutput.trim() || `Claude CLI exited with code ${code}`;
434
+ res.write(`event: error\ndata: ${JSON.stringify({ message })}\n\n`);
435
+ } else {
436
+ const finalModel = detectedModel || resolvedModel;
437
+ cachedSuggestionResult = {
438
+ content: fullContent.trim(),
439
+ generatedAt: new Date().toISOString(),
440
+ model: finalModel,
441
+ };
442
+ res.write(
443
+ `event: done\ndata: ${JSON.stringify({ generatedAt: cachedSuggestionResult.generatedAt, model: finalModel })}\n\n`
444
+ );
445
+ }
446
+ res.end();
447
+ });
448
+
449
+ res.on("close", () => {
450
+ if (child.exitCode === null) child.kill();
451
+ });
452
+ }
453
+
454
+ export function getCachedSuggestions() {
455
+ return cachedSuggestionResult;
456
+ }
457
+
458
+ export function clearCachedSuggestions() {
459
+ cachedSuggestionResult = null;
460
+ }
package/src/server.js CHANGED
@@ -7,7 +7,8 @@ import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { parseAllSessions } from "./parser.js";
9
9
  import { scoreAllSessions } from "./scorer.js";
10
- import { streamAIAnalysis, getCachedAnalysis, clearCachedAnalysis, getAvailableModels, killActiveProcesses, detectDefaultModel, buildPrompt, buildDataSnapshot } from "./ai-analyze.js";
10
+ import { streamAIAnalysis, getCachedAnalysis, clearCachedAnalysis, getAvailableModels, killActiveProcesses, detectDefaultModel, buildPrompt, buildDataSnapshot, streamAISuggestions, getCachedSuggestions, clearCachedSuggestions } from "./ai-analyze.js";
11
+ import { generateSuggestions } from "./suggestions.js";
11
12
 
12
13
  const execFileAsync = promisify(execFile);
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -177,6 +178,35 @@ export function startServer(port = 6543) {
177
178
  });
178
179
  }
179
180
 
181
+ if (url.pathname === "/api/suggestions" && req.method === "GET") {
182
+ const data = await getData();
183
+ const suggestions = generateSuggestions(data);
184
+ return json(res, suggestions);
185
+ }
186
+
187
+ if (url.pathname === "/api/ai-suggestions" && req.method === "POST") {
188
+ const data = await getData();
189
+ const suggestions = generateSuggestions(data);
190
+ const modelId = url.searchParams.get("model") || "";
191
+ res.writeHead(200, {
192
+ "Content-Type": "text/event-stream",
193
+ "Cache-Control": "no-cache",
194
+ Connection: "keep-alive",
195
+ });
196
+ streamAISuggestions(data, suggestions, res, modelId || undefined);
197
+ return;
198
+ }
199
+
200
+ if (url.pathname === "/api/ai-suggestions" && req.method === "DELETE") {
201
+ clearCachedSuggestions();
202
+ return json(res, { ok: true });
203
+ }
204
+
205
+ if (url.pathname === "/api/ai-suggestions" && req.method === "GET") {
206
+ const cached = getCachedSuggestions();
207
+ return json(res, cached || { content: null });
208
+ }
209
+
180
210
  if (url.pathname === "/api/refresh") {
181
211
  const data = await getData(true);
182
212
  return json(res, { sessions: data.sessions.length, overallScore: data.overallScore });
@@ -0,0 +1,280 @@
1
+ // Workflow Optimizer — rule-based suggestion engine
2
+ // Analyzes session patterns and produces suggestions for skills, CLAUDE.md, agents, and plugins.
3
+
4
+ // --- Helpers ---
5
+
6
+ function computeVagueRate(sessions) {
7
+ let total = 0;
8
+ let vague = 0;
9
+ for (const s of sessions) {
10
+ const userTurns = s.turns.filter((t) => t.role === "user");
11
+ total += userTurns.length;
12
+ for (const turn of userTurns) {
13
+ if (turn.promptLength < 30) {
14
+ const idx = s.turns.indexOf(turn);
15
+ const next = s.turns.slice(idx + 1).find((t) => t.role === "assistant");
16
+ if (next && next.tokens.input + next.tokens.output > 50_000) vague++;
17
+ }
18
+ }
19
+ }
20
+ return total >= 5 ? vague / total : 0;
21
+ }
22
+
23
+ function computeAvgToolRatio(sessions) {
24
+ const qualifying = sessions.filter((s) => s.totals.userMessages > 0);
25
+ if (!qualifying.length) return 0;
26
+ return qualifying.reduce((sum, s) => sum + s.totals.toolCalls / s.totals.userMessages, 0) / qualifying.length;
27
+ }
28
+
29
+ /**
30
+ * Returns a map of tool name → fraction of all tool usages.
31
+ * Only counts turns that actually have tool calls.
32
+ */
33
+ function detectToolPatterns(sessions) {
34
+ const counts = {};
35
+ let total = 0;
36
+ for (const s of sessions) {
37
+ for (const t of s.turns) {
38
+ for (const tool of t.toolCalls || []) {
39
+ const name = tool.toLowerCase();
40
+ counts[name] = (counts[name] || 0) + 1;
41
+ total++;
42
+ }
43
+ }
44
+ }
45
+ if (!total) return {};
46
+ const fractions = {};
47
+ for (const [k, v] of Object.entries(counts)) fractions[k] = v / total;
48
+ return fractions;
49
+ }
50
+
51
+ const TOPIC_PATTERNS = [
52
+ { topic: "git", re: /\b(git|github|commit|pull request|pr|merge|branch|rebase|stash)\b/i },
53
+ { topic: "test", re: /\b(test|spec|jest|vitest|cypress|playwright|coverage|assert)\b/i },
54
+ { topic: "deploy", re: /\b(deploy|release|build|ci|pipeline|docker|kubernetes|k8s|heroku|vercel)\b/i },
55
+ { topic: "debug", re: /\b(debug|fix|bug|error|crash|exception|trace|breakpoint)\b/i },
56
+ { topic: "review", re: /\b(review|refactor|cleanup|lint|format|code review)\b/i },
57
+ { topic: "api", re: /\b(api|endpoint|rest|graphql|backend|route|controller|service)\b/i },
58
+ { topic: "frontend", re: /\b(ui|component|css|style|react|vue|svelte|tailwind|design)\b/i },
59
+ { topic: "database", re: /\b(database|db|sql|query|migration|schema|postgres|mysql|sqlite|prisma|supabase)\b/i },
60
+ { topic: "docs", re: /\b(doc|readme|documentation|comment|jsdoc|typedoc)\b/i },
61
+ ];
62
+
63
+ /**
64
+ * Groups sessions by dominant topic, returns sorted by count descending.
65
+ */
66
+ function detectTitleClusters(sessions) {
67
+ const counts = {};
68
+ for (const s of sessions) {
69
+ const title = (s.title || "") + " " + (s.project || "");
70
+ for (const { topic, re } of TOPIC_PATTERNS) {
71
+ if (re.test(title)) {
72
+ counts[topic] = (counts[topic] || 0) + 1;
73
+ break; // one topic per session
74
+ }
75
+ }
76
+ }
77
+ return Object.entries(counts)
78
+ .map(([topic, count]) => ({ topic, count }))
79
+ .sort((a, b) => b.count - a.count);
80
+ }
81
+
82
+ /**
83
+ * Returns sessions-per-project, sorted by count descending.
84
+ */
85
+ function detectProjectPatterns(sessions) {
86
+ const counts = {};
87
+ for (const s of sessions) {
88
+ const p = s.project || "unknown";
89
+ counts[p] = (counts[p] || 0) + 1;
90
+ }
91
+ return Object.entries(counts)
92
+ .map(([name, count]) => ({ name, count }))
93
+ .sort((a, b) => b.count - a.count);
94
+ }
95
+
96
+ // --- Main export ---
97
+
98
+ /**
99
+ * Generates workflow suggestions from scored session data.
100
+ * Returns { skills, claudeMd, agents, plugins } — each an array of suggestion objects.
101
+ */
102
+ export function generateSuggestions(scoredData) {
103
+ const { sessions, badges } = scoredData;
104
+ if (!sessions || sessions.length === 0) {
105
+ return { skills: [], claudeMd: [], agents: [], plugins: [] };
106
+ }
107
+
108
+ const badgeIds = new Set(badges.map((b) => b.id));
109
+ const toolPatterns = detectToolPatterns(sessions);
110
+ const titleClusters = detectTitleClusters(sessions);
111
+ const projectPatterns = detectProjectPatterns(sessions);
112
+ const avgToolRatio = computeAvgToolRatio(sessions);
113
+ const vagueRate = computeVagueRate(sessions);
114
+ const topCluster = titleClusters[0] || null;
115
+
116
+ const skills = [];
117
+ const claudeMd = [];
118
+ const agents = [];
119
+ const plugins = [];
120
+
121
+ // --- Skills ---
122
+
123
+ if (badgeIds.has("vague-commander") || vagueRate > 0.2) {
124
+ skills.push({
125
+ id: "spec-skill",
126
+ title: "Create a /spec planning skill",
127
+ trigger: "/spec",
128
+ rationale: `${Math.round(vagueRate * 100)}% of your prompts are very short and trigger expensive, wide-ranging Claude responses. A /spec skill prompts you to write a thorough task spec before Claude starts — reducing ambiguity and costly back-and-forth.`,
129
+ priority: "high",
130
+ templateHint:
131
+ "Before starting any implementation task, define: what to build, constraints, files to change, what to leave alone, and success criteria. Then hand this spec to Claude.",
132
+ });
133
+ }
134
+
135
+ if (badgeIds.has("context-hoarder")) {
136
+ skills.push({
137
+ id: "checkpoint-skill",
138
+ title: "Create a /checkpoint context-reset skill",
139
+ trigger: "/checkpoint",
140
+ rationale:
141
+ "Your sessions frequently experience cost inflection without context resets. A /checkpoint skill saves progress, summarizes state, then clears the context window — keeping sessions lean.",
142
+ priority: "high",
143
+ templateHint:
144
+ "Summarize what has been accomplished so far. List remaining tasks. Note any important decisions made. Then run /clear so the next phase starts fresh with this summary.",
145
+ });
146
+ }
147
+
148
+ if (avgToolRatio > 5 && !badgeIds.has("surgical-prompter")) {
149
+ skills.push({
150
+ id: "focus-skill",
151
+ title: "Create a /focus task-scoping skill",
152
+ trigger: "/focus",
153
+ rationale: `Your average tool ratio is ${avgToolRatio.toFixed(1)}x per message — Claude spends a lot of time searching for files and context. A /focus skill primes your prompts with exact file paths and scope to eliminate unnecessary exploration.`,
154
+ priority: "medium",
155
+ templateHint:
156
+ "For every task, include: exact files to change (with paths), specific line numbers if known, what NOT to modify, and the precise expected output or behavior.",
157
+ });
158
+ }
159
+
160
+ if (topCluster && topCluster.count >= 5) {
161
+ skills.push({
162
+ id: `domain-skill-${topCluster.topic}`,
163
+ title: `Create a /${topCluster.topic} workflow skill`,
164
+ trigger: `/${topCluster.topic}`,
165
+ rationale: `${topCluster.count} of your sessions involve "${topCluster.topic}" tasks. A dedicated skill standardizes how you kick off these workflows and gives Claude the right context from the start.`,
166
+ priority: "medium",
167
+ templateHint: `Standard workflow for ${topCluster.topic} tasks: include relevant context, patterns to follow, and any project-specific conventions.`,
168
+ });
169
+ }
170
+
171
+ // --- CLAUDE.md ---
172
+
173
+ const heavyProjects = projectPatterns.filter((p) => p.count >= 5 && p.name !== "unknown");
174
+ for (const proj of heavyProjects.slice(0, 2)) {
175
+ claudeMd.push({
176
+ id: `claudemd-project-${proj.name.replace(/[^a-z0-9]/gi, "-").toLowerCase()}`,
177
+ scope: "project",
178
+ projectName: proj.name,
179
+ rationale: `You have ${proj.count} sessions in "${proj.name}". A CLAUDE.md file in that project gives Claude persistent context about your codebase, architecture, and conventions — reducing repeated explanations every session.`,
180
+ priority: "high",
181
+ sections: ["Project overview and purpose", "Key files and architecture", "Coding conventions and patterns", "Common tasks and workflows", "What NOT to do"],
182
+ });
183
+ }
184
+
185
+ if (badgeIds.has("context-hoarder")) {
186
+ claudeMd.push({
187
+ id: "claudemd-clear-guidance",
188
+ scope: "global",
189
+ projectName: null,
190
+ rationale:
191
+ "Your sessions frequently experience cost inflection without context resets. Adding /clear guidance to your global CLAUDE.md establishes the habit of resetting context after each major task.",
192
+ priority: "medium",
193
+ sections: ["Context management: use /clear after completing each major task to keep sessions efficient"],
194
+ });
195
+ }
196
+
197
+ if (badgeIds.has("opus-addict")) {
198
+ claudeMd.push({
199
+ id: "claudemd-model-guidance",
200
+ scope: "global",
201
+ projectName: null,
202
+ rationale:
203
+ "You frequently use Opus for tasks where Sonnet performs equally well. Documenting model selection guidelines in CLAUDE.md (or setting a default model via claude config) could significantly reduce cost.",
204
+ priority: "high",
205
+ sections: ["Model selection: default to Sonnet for routine coding tasks, use Opus only for complex architecture decisions or deep reasoning"],
206
+ });
207
+ }
208
+
209
+ if (avgToolRatio > 5 && !badgeIds.has("surgical-prompter")) {
210
+ claudeMd.push({
211
+ id: "claudemd-prompt-conventions",
212
+ scope: "global",
213
+ projectName: null,
214
+ rationale:
215
+ "Your high tool-call ratio suggests Claude frequently has to search for context you could provide upfront. Adding prompt conventions to your CLAUDE.md establishes expectations for how specific your prompts should be.",
216
+ priority: "medium",
217
+ sections: ["Prompt conventions: always include exact file paths, avoid vague references like 'the function' or 'that thing'"],
218
+ });
219
+ }
220
+
221
+ // --- Agents ---
222
+
223
+ const highToolSessions = sessions.filter(
224
+ (s) => s.totals.toolCalls > 20 && s.totals.userMessages > 0
225
+ );
226
+ if (highToolSessions.length >= 3) {
227
+ agents.push({
228
+ id: "explore-agent",
229
+ name: "Explore subagent",
230
+ use_case: "Offload codebase exploration and file discovery",
231
+ rationale: `${highToolSessions.length} of your sessions use 20+ tool calls for file searches and codebase navigation. An Explore subagent handles this research in parallel and in a clean context, keeping your main session focused.`,
232
+ priority: "medium",
233
+ example:
234
+ 'Launch with: Agent tool with subagent_type="Explore", prompt="Find all files related to authentication and explain the flow"',
235
+ });
236
+ }
237
+
238
+ if (topCluster && topCluster.count >= 8) {
239
+ agents.push({
240
+ id: `specialist-agent-${topCluster.topic}`,
241
+ name: `${topCluster.topic} specialist agent`,
242
+ use_case: `Handle ${topCluster.topic} tasks autonomously from a single high-level prompt`,
243
+ rationale: `You have ${topCluster.count} sessions focused on "${topCluster.topic}". A specialist subagent for this workflow could handle the entire task from a single high-level instruction, with expertise baked in.`,
244
+ priority: "low",
245
+ example: `Create a custom agent type optimized for ${topCluster.topic} tasks with relevant tools and context pre-loaded.`,
246
+ });
247
+ }
248
+
249
+ // --- Plugins ---
250
+ // Note: MCP servers load all their tool definitions into context on every message,
251
+ // adding token overhead even when unused. Only recommend MCPs where they provide
252
+ // a genuine capability gap over CLI tools (i.e., Bash can't do it well).
253
+
254
+ const bashFraction = toolPatterns["bash"] || 0;
255
+ const webSessions = sessions.filter((s) =>
256
+ /browser|playwright|scrape|screenshot|e2e|visual test/i.test(s.title)
257
+ );
258
+ if (bashFraction > 0.3 && webSessions.length >= 3) {
259
+ plugins.push({
260
+ id: "playwright-mcp",
261
+ name: "Playwright MCP",
262
+ rationale: `${webSessions.length} sessions involve browser tasks that curl/bash can't handle well — screenshots, JS-rendered pages, user interaction flows. Playwright MCP is one of the few cases where an MCP genuinely beats the CLI alternative.`,
263
+ installCmd: "claude mcp add playwright npx @playwright/mcp@latest",
264
+ url: "https://github.com/microsoft/playwright-mcp",
265
+ priority: "medium",
266
+ });
267
+ }
268
+
269
+ // Sort by priority
270
+ const priorityOrder = { high: 0, medium: 1, low: 2 };
271
+ const byPriority = (a, b) =>
272
+ (priorityOrder[a.priority] ?? 1) - (priorityOrder[b.priority] ?? 1);
273
+
274
+ return {
275
+ skills: skills.sort(byPriority),
276
+ claudeMd: claudeMd.sort(byPriority),
277
+ agents: agents.sort(byPriority),
278
+ plugins: plugins.sort(byPriority),
279
+ };
280
+ }