preflight-dev 3.1.0 → 3.2.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.
Files changed (57) hide show
  1. package/README.md +77 -16
  2. package/dist/cli/init.js +0 -48
  3. package/dist/cli/init.js.map +1 -1
  4. package/dist/index.js +7 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/contracts.d.ts +27 -0
  7. package/dist/lib/contracts.js +309 -0
  8. package/dist/lib/contracts.js.map +1 -0
  9. package/dist/lib/patterns.d.ts +38 -0
  10. package/dist/lib/patterns.js +176 -0
  11. package/dist/lib/patterns.js.map +1 -0
  12. package/dist/lib/triage.d.ts +2 -0
  13. package/dist/lib/triage.js.map +1 -1
  14. package/dist/profiles.js +4 -0
  15. package/dist/profiles.js.map +1 -1
  16. package/dist/tools/check-patterns.d.ts +2 -0
  17. package/dist/tools/check-patterns.js +33 -0
  18. package/dist/tools/check-patterns.js.map +1 -0
  19. package/dist/tools/clarify-intent.js +9 -1
  20. package/dist/tools/clarify-intent.js.map +1 -1
  21. package/dist/tools/enrich-agent-task.js +132 -3
  22. package/dist/tools/enrich-agent-task.js.map +1 -1
  23. package/dist/tools/estimate-cost.d.ts +2 -0
  24. package/dist/tools/estimate-cost.js +261 -0
  25. package/dist/tools/estimate-cost.js.map +1 -0
  26. package/dist/tools/generate-scorecard.js +466 -14
  27. package/dist/tools/generate-scorecard.js.map +1 -1
  28. package/dist/tools/log-correction.js +7 -1
  29. package/dist/tools/log-correction.js.map +1 -1
  30. package/dist/tools/onboard-project.js +10 -1
  31. package/dist/tools/onboard-project.js.map +1 -1
  32. package/dist/tools/preflight-check.js +16 -0
  33. package/dist/tools/preflight-check.js.map +1 -1
  34. package/dist/tools/scope-work.js +6 -0
  35. package/dist/tools/scope-work.js.map +1 -1
  36. package/dist/tools/search-contracts.d.ts +2 -0
  37. package/dist/tools/search-contracts.js +46 -0
  38. package/dist/tools/search-contracts.js.map +1 -0
  39. package/dist/tools/session-stats.js +2 -0
  40. package/dist/tools/session-stats.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/index.ts +7 -0
  43. package/src/lib/contracts.ts +354 -0
  44. package/src/lib/patterns.ts +210 -0
  45. package/src/lib/triage.ts +2 -0
  46. package/src/profiles.ts +4 -0
  47. package/src/tools/check-patterns.ts +43 -0
  48. package/src/tools/clarify-intent.ts +10 -1
  49. package/src/tools/enrich-agent-task.ts +150 -3
  50. package/src/tools/estimate-cost.ts +332 -0
  51. package/src/tools/generate-scorecard.ts +541 -14
  52. package/src/tools/log-correction.ts +8 -1
  53. package/src/tools/onboard-project.ts +10 -1
  54. package/src/tools/preflight-check.ts +19 -0
  55. package/src/tools/scope-work.ts +7 -0
  56. package/src/tools/search-contracts.ts +61 -0
  57. package/src/tools/session-stats.ts +2 -0
@@ -1,5 +1,6 @@
1
1
  // =============================================================================
2
2
  // generate_scorecard — 12-category prompt discipline report cards (PDF/Markdown)
3
+ // With trend reports, comparative reports, and historical baselines
3
4
  // =============================================================================
4
5
 
5
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -10,6 +11,10 @@ import {
10
11
  parseSession,
11
12
  type TimelineEvent,
12
13
  } from "../lib/session-parser.js";
14
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
15
+ import { join } from "path";
16
+ import { homedir } from "os";
17
+ import { createHash } from "crypto";
13
18
 
14
19
  // ── Types ──────────────────────────────────────────────────────────────────
15
20
 
@@ -619,12 +624,487 @@ function loadSessions(opts: {
619
624
  return sessions;
620
625
  }
621
626
 
627
+ // ── Historical Baseline ────────────────────────────────────────────────────
628
+
629
+ const PREFLIGHT_DIR = join(homedir(), ".preflight", "projects");
630
+
631
+ function baselinePath(project: string): string {
632
+ const hash = createHash("md5").update(project).digest("hex").slice(0, 12);
633
+ return join(PREFLIGHT_DIR, hash, "baseline.json");
634
+ }
635
+
636
+ interface BaselineData {
637
+ categoryAverages: Record<string, number>;
638
+ overallAverage: number;
639
+ sessionCount: number;
640
+ lastUpdated: string;
641
+ }
642
+
643
+ function loadBaseline(project: string): BaselineData | null {
644
+ const p = baselinePath(project);
645
+ if (!existsSync(p)) return null;
646
+ try {
647
+ return JSON.parse(readFileSync(p, "utf-8")) as BaselineData;
648
+ } catch {
649
+ return null;
650
+ }
651
+ }
652
+
653
+ function saveBaseline(project: string, data: BaselineData): void {
654
+ const p = baselinePath(project);
655
+ const dir = p.replace(/\/[^/]+$/, "");
656
+ mkdirSync(dir, { recursive: true });
657
+ writeFileSync(p, JSON.stringify(data, null, 2));
658
+ }
659
+
660
+ function updateBaseline(project: string, scorecard: Scorecard): void {
661
+ const existing = loadBaseline(project);
662
+ if (!existing) {
663
+ const categoryAverages: Record<string, number> = {};
664
+ for (const c of scorecard.categories) categoryAverages[c.name] = c.score;
665
+ saveBaseline(project, {
666
+ categoryAverages,
667
+ overallAverage: scorecard.overall,
668
+ sessionCount: 1,
669
+ lastUpdated: new Date().toISOString(),
670
+ });
671
+ return;
672
+ }
673
+ const n = existing.sessionCount;
674
+ const newN = n + 1;
675
+ existing.overallAverage = Math.round((existing.overallAverage * n + scorecard.overall) / newN);
676
+ for (const c of scorecard.categories) {
677
+ const prev = existing.categoryAverages[c.name] ?? c.score;
678
+ existing.categoryAverages[c.name] = Math.round((prev * n + c.score) / newN);
679
+ }
680
+ existing.sessionCount = newN;
681
+ existing.lastUpdated = new Date().toISOString();
682
+ saveBaseline(project, existing);
683
+ }
684
+
685
+ function trendArrow(current: number, previous: number): string {
686
+ const diff = current - previous;
687
+ if (diff > 5) return "↑";
688
+ if (diff < -5) return "↓";
689
+ return "→";
690
+ }
691
+
692
+ // ── Trend Report ───────────────────────────────────────────────────────────
693
+
694
+ interface DailyScore {
695
+ date: string;
696
+ score: number;
697
+ categories: CategoryScore[];
698
+ sessionCount: number;
699
+ promptCount: number;
700
+ toolCallCount: number;
701
+ correctionCount: number;
702
+ compactionCount: number;
703
+ }
704
+
705
+ function generateTrendSVG(dailyScores: { date: string; score: number }[]): string {
706
+ const W = 400, H = 200, pad = 40;
707
+ const plotW = W - pad * 2, plotH = H - pad * 2;
708
+ const n = dailyScores.length;
709
+ if (n === 0) return `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg"><text x="${W / 2}" y="${H / 2}" text-anchor="middle" fill="#6b7280">No data</text></svg>`;
710
+
711
+ const points = dailyScores.map((d, i) => ({
712
+ x: pad + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW),
713
+ y: pad + plotH - (d.score / 100) * plotH,
714
+ }));
715
+
716
+ const gridLines = [0, 25, 50, 75, 100].map((v) => {
717
+ const y = pad + plotH - (v / 100) * plotH;
718
+ return `<line x1="${pad}" y1="${y}" x2="${W - pad}" y2="${y}" stroke="#e5e7eb" stroke-width="1"/><text x="${pad - 5}" y="${y + 4}" text-anchor="end" font-size="10" fill="#9ca3af">${v}</text>`;
719
+ }).join("");
720
+
721
+ const labels = dailyScores.map((d, i) => {
722
+ const x = pad + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW);
723
+ const label = d.date.slice(5); // MM-DD
724
+ return `<text x="${x}" y="${H - 8}" text-anchor="middle" font-size="9" fill="#9ca3af">${label}</text>`;
725
+ }).join("");
726
+
727
+ const pathD = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
728
+ const dots = points.map((p) => `<circle cx="${p.x}" cy="${p.y}" r="3" fill="#3b82f6"/>`).join("");
729
+
730
+ return `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">
731
+ <rect width="${W}" height="${H}" fill="white" rx="4"/>
732
+ ${gridLines}
733
+ <path d="${pathD}" fill="none" stroke="#3b82f6" stroke-width="2"/>
734
+ ${dots}
735
+ ${labels}
736
+ </svg>`;
737
+ }
738
+
739
+ function groupSessionsByDay(sessions: ParsedSession[]): Map<string, ParsedSession[]> {
740
+ const map = new Map<string, ParsedSession[]>();
741
+ for (const s of sessions) {
742
+ const ts = s.events[0]?.timestamp;
743
+ if (!ts) continue;
744
+ const day = new Date(ts).toISOString().slice(0, 10);
745
+ if (!map.has(day)) map.set(day, []);
746
+ map.get(day)!.push(s);
747
+ }
748
+ return map;
749
+ }
750
+
751
+ function scoreDailyData(sessions: ParsedSession[]): DailyScore[] {
752
+ const byDay = groupSessionsByDay(sessions);
753
+ const days = [...byDay.keys()].sort();
754
+ return days.map((date) => {
755
+ const daySessions = byDay.get(date)!;
756
+ const sc = computeScorecard(daySessions, "", date);
757
+ return {
758
+ date,
759
+ score: sc.overall,
760
+ categories: sc.categories,
761
+ sessionCount: daySessions.length,
762
+ promptCount: daySessions.reduce((s, d) => s + d.userMessages.length, 0),
763
+ toolCallCount: daySessions.reduce((s, d) => s + d.toolCalls.length, 0),
764
+ correctionCount: daySessions.reduce((s, d) => s + d.corrections.length, 0),
765
+ compactionCount: daySessions.reduce((s, d) => s + d.compactions.length, 0),
766
+ };
767
+ });
768
+ }
769
+
770
+ function findBestWorstPrompt(sessions: ParsedSession[]): { best: string; worst: string } {
771
+ let best = "", worst = "";
772
+ let bestScore = -1, worstScore = Infinity;
773
+
774
+ for (const s of sessions) {
775
+ for (const m of s.userMessages) {
776
+ const text = m.content;
777
+ if (text.length < 5) continue;
778
+ // Score: length + file refs bonus
779
+ const score = text.length + (hasFileRef(text) ? 200 : 0);
780
+ if (score > bestScore) { bestScore = score; best = text; }
781
+ if (score < worstScore) { worstScore = score; worst = text; }
782
+ }
783
+ }
784
+ return {
785
+ best: best.slice(0, 300),
786
+ worst: worst.slice(0, 200),
787
+ };
788
+ }
789
+
790
+ interface TrendReport {
791
+ project: string;
792
+ period: string;
793
+ dailyScores: DailyScore[];
794
+ categoryTrends: { name: string; current: number; previous: number; arrow: string }[];
795
+ top3Improve: { category: string; score: number; recommendation: string }[];
796
+ bestPrompt: string;
797
+ worstPrompt: string;
798
+ stats: { sessions: number; prompts: number; toolCalls: number; correctionRate: number; compactions: number };
799
+ baseline: BaselineData | null;
800
+ svg: string;
801
+ }
802
+
803
+ const IMPROVEMENT_TIPS: Record<string, string> = {
804
+ "Plans": "Start sessions with a detailed plan: list files to touch, expected changes, and success criteria before coding.",
805
+ "Clarification": "Always reference specific file paths and function names in your prompts instead of speaking abstractly.",
806
+ "Delegation": "When spawning sub-agents, provide detailed context: file paths, expected output format, and constraints.",
807
+ "Follow-up Specificity": "After receiving a response, reference specific lines/files rather than saying 'fix it' or 'try again'.",
808
+ "Token Efficiency": "Batch related changes into single prompts. Avoid asking for one small change at a time.",
809
+ "Sequencing": "Complete work in one area before moving to the next. Avoid jumping between unrelated files.",
810
+ "Compaction Management": "Commit before context compaction hits. Keep sessions focused to avoid hitting limits.",
811
+ "Session Lifecycle": "Commit every 15-30 minutes. Don't let sessions run 3+ hours without checkpoints.",
812
+ "Error Recovery": "When correcting the AI, be specific: 'In file X, line Y, change Z to W' not 'no, wrong'.",
813
+ "Workspace Hygiene": "Maintain CLAUDE.md and .claude/ workspace docs for project context.",
814
+ "Cross-Session Continuity": "Start each session by reading project context files (CLAUDE.md, README, etc.).",
815
+ "Verification": "Always run tests/build at the end of a session to verify changes work.",
816
+ };
817
+
818
+ function buildTrendReport(sessions: ParsedSession[], project: string, period: string): TrendReport {
819
+ const dailyScores = scoreDailyData(sessions);
820
+ const baseline = loadBaseline(project);
821
+
822
+ // Category trends: compare first half vs second half
823
+ const mid = Math.floor(dailyScores.length / 2);
824
+ const firstHalf = dailyScores.slice(0, Math.max(1, mid));
825
+ const secondHalf = dailyScores.slice(Math.max(1, mid));
826
+
827
+ const categoryNames = dailyScores[0]?.categories.map((c) => c.name) ?? [];
828
+ const categoryTrends = categoryNames.map((name) => {
829
+ const avgFirst = firstHalf.reduce((s, d) => s + (d.categories.find((c) => c.name === name)?.score ?? 0), 0) / (firstHalf.length || 1);
830
+ const avgSecond = secondHalf.reduce((s, d) => s + (d.categories.find((c) => c.name === name)?.score ?? 0), 0) / (secondHalf.length || 1);
831
+ return { name, current: Math.round(avgSecond), previous: Math.round(avgFirst), arrow: trendArrow(avgSecond, avgFirst) };
832
+ });
833
+
834
+ // Top 3 to improve
835
+ const sorted = [...categoryTrends].sort((a, b) => a.current - b.current);
836
+ const top3Improve = sorted.slice(0, 3).map((t) => ({
837
+ category: t.name,
838
+ score: t.current,
839
+ recommendation: IMPROVEMENT_TIPS[t.name] ?? "Focus on improving this area.",
840
+ }));
841
+
842
+ const { best, worst } = findBestWorstPrompt(sessions);
843
+
844
+ const totalPrompts = sessions.reduce((s, d) => s + d.userMessages.length, 0);
845
+ const totalCorrections = sessions.reduce((s, d) => s + d.corrections.length, 0);
846
+
847
+ return {
848
+ project,
849
+ period,
850
+ dailyScores,
851
+ categoryTrends,
852
+ top3Improve,
853
+ bestPrompt: best,
854
+ worstPrompt: worst,
855
+ stats: {
856
+ sessions: sessions.length,
857
+ prompts: totalPrompts,
858
+ toolCalls: sessions.reduce((s, d) => s + d.toolCalls.length, 0),
859
+ correctionRate: totalPrompts > 0 ? Math.round((totalCorrections / totalPrompts) * 100) : 0,
860
+ compactions: sessions.reduce((s, d) => s + d.compactions.length, 0),
861
+ },
862
+ baseline,
863
+ svg: generateTrendSVG(dailyScores.map((d) => ({ date: d.date, score: d.score }))),
864
+ };
865
+ }
866
+
867
+ function trendToMarkdown(tr: TrendReport): string {
868
+ const lines: string[] = [];
869
+ const periodLabel = tr.period === "week" ? "Weekly" : "Monthly";
870
+ lines.push(`# 📈 ${periodLabel} Trend Report`);
871
+ lines.push(`**Project:** ${tr.project} | **Period:** ${tr.period} | **Days:** ${tr.dailyScores.length}\n`);
872
+
873
+ lines.push(`## 📊 Stats`);
874
+ lines.push(`| Sessions | Prompts | Tool Calls | Correction Rate | Compactions |`);
875
+ lines.push(`|----------|---------|------------|-----------------|-------------|`);
876
+ lines.push(`| ${tr.stats.sessions} | ${tr.stats.prompts} | ${tr.stats.toolCalls} | ${tr.stats.correctionRate}% | ${tr.stats.compactions} |\n`);
877
+
878
+ lines.push(`## 📉 Score Trend`);
879
+ lines.push(`| Date | Score | Grade |`);
880
+ lines.push(`|------|-------|-------|`);
881
+ for (const d of tr.dailyScores) {
882
+ lines.push(`| ${d.date} | ${d.score} | ${letterGrade(d.score)} |`);
883
+ }
884
+
885
+ lines.push(`\n## Category Trends`);
886
+ lines.push(`| Category | Score | Trend |${tr.baseline ? " vs Avg |" : ""}`);
887
+ lines.push(`|----------|-------|-------|${tr.baseline ? "--------|" : ""}`);
888
+ for (const t of tr.categoryTrends) {
889
+ let row = `| ${t.name} | ${t.current} (${letterGrade(t.current)}) | ${t.arrow} |`;
890
+ if (tr.baseline) {
891
+ const avg = tr.baseline.categoryAverages[t.name];
892
+ if (avg != null) row += ` ${trendArrow(t.current, avg)} vs ${letterGrade(avg)} (${avg}) |`;
893
+ else row += ` — |`;
894
+ }
895
+ lines.push(row);
896
+ }
897
+
898
+ lines.push(`\n## 🎯 Top 3 Areas to Improve`);
899
+ for (const t of tr.top3Improve) {
900
+ lines.push(`- **${t.category}** (${letterGrade(t.score)}, ${t.score}/100): ${t.recommendation}`);
901
+ }
902
+
903
+ lines.push(`\n## 💬 Prompts of the ${tr.period === "week" ? "Week" : "Month"}`);
904
+ lines.push(`**Best prompt:**\n> ${tr.bestPrompt}\n`);
905
+ lines.push(`**Worst prompt:**\n> ${tr.worstPrompt}`);
906
+
907
+ return lines.join("\n");
908
+ }
909
+
910
+ function trendToHTML(tr: TrendReport): string {
911
+ const periodLabel = tr.period === "week" ? "Weekly" : "Monthly";
912
+ const categoryRows = tr.categoryTrends.map((t) => {
913
+ const color = gradeColor(letterGrade(t.current));
914
+ const baselineCol = tr.baseline ? `<td style="padding:6px;text-align:center">${tr.baseline.categoryAverages[t.name] != null ? `${trendArrow(t.current, tr.baseline.categoryAverages[t.name]!)} ${letterGrade(tr.baseline.categoryAverages[t.name]!)}` : "—"}</td>` : "";
915
+ return `<tr><td style="padding:6px">${t.name}</td><td style="padding:6px;text-align:center"><span style="background:${color};color:white;padding:2px 6px;border-radius:3px">${letterGrade(t.current)} (${t.current})</span></td><td style="padding:6px;text-align:center;font-size:18px">${t.arrow}</td>${baselineCol}</tr>`;
916
+ }).join("");
917
+
918
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"/></head><body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;margin:0;color:#1f2937">
919
+ <div style="background:linear-gradient(135deg,#1e293b,#0f172a);color:white;padding:32px 40px">
920
+ <h1 style="margin:0">📈 ${periodLabel} Trend Report</h1>
921
+ <p style="margin:8px 0 0;opacity:0.8">Project: <strong>${tr.project}</strong> | ${tr.dailyScores.length} days | ${tr.stats.sessions} sessions</p>
922
+ </div>
923
+ <div style="padding:24px 40px;display:flex;gap:20px;flex-wrap:wrap">
924
+ <div style="background:#f8fafc;padding:12px 20px;border-radius:8px;text-align:center"><div style="font-size:24px;font-weight:700">${tr.stats.sessions}</div><div style="color:#6b7280;font-size:12px">Sessions</div></div>
925
+ <div style="background:#f8fafc;padding:12px 20px;border-radius:8px;text-align:center"><div style="font-size:24px;font-weight:700">${tr.stats.prompts}</div><div style="color:#6b7280;font-size:12px">Prompts</div></div>
926
+ <div style="background:#f8fafc;padding:12px 20px;border-radius:8px;text-align:center"><div style="font-size:24px;font-weight:700">${tr.stats.toolCalls}</div><div style="color:#6b7280;font-size:12px">Tool Calls</div></div>
927
+ <div style="background:#f8fafc;padding:12px 20px;border-radius:8px;text-align:center"><div style="font-size:24px;font-weight:700">${tr.stats.correctionRate}%</div><div style="color:#6b7280;font-size:12px">Correction Rate</div></div>
928
+ <div style="background:#f8fafc;padding:12px 20px;border-radius:8px;text-align:center"><div style="font-size:24px;font-weight:700">${tr.stats.compactions}</div><div style="color:#6b7280;font-size:12px">Compactions</div></div>
929
+ </div>
930
+ <div style="padding:0 40px">${tr.svg}</div>
931
+ <div style="padding:24px 40px">
932
+ <h2>Category Trends</h2>
933
+ <table style="width:100%;border-collapse:collapse;font-size:14px">
934
+ <thead><tr style="background:#f9fafb"><th style="padding:6px;text-align:left">Category</th><th style="padding:6px;text-align:center">Score</th><th style="padding:6px;text-align:center">Trend</th>${tr.baseline ? '<th style="padding:6px;text-align:center">vs Avg</th>' : ""}</tr></thead>
935
+ <tbody>${categoryRows}</tbody>
936
+ </table>
937
+ </div>
938
+ <div style="padding:0 40px 20px">
939
+ <h2>🎯 Top 3 Areas to Improve</h2>
940
+ ${tr.top3Improve.map((t) => `<div style="background:#fef2f2;border-left:4px solid #f97316;padding:10px 16px;margin-bottom:8px;border-radius:4px"><strong>${t.category}</strong> (${t.score}/100): ${t.recommendation}</div>`).join("")}
941
+ </div>
942
+ <div style="padding:0 40px 40px">
943
+ <h2>💬 Prompts of the ${tr.period === "week" ? "Week" : "Month"}</h2>
944
+ <div style="background:#f0fdf4;border-left:4px solid #22c55e;padding:10px 16px;margin-bottom:8px;border-radius:4px"><strong>Best:</strong> ${tr.bestPrompt}</div>
945
+ <div style="background:#fef2f2;border-left:4px solid #ef4444;padding:10px 16px;border-radius:4px"><strong>Worst:</strong> ${tr.worstPrompt}</div>
946
+ </div>
947
+ </body></html>`;
948
+ }
949
+
950
+ // ── Comparative Report ─────────────────────────────────────────────────────
951
+
952
+ interface ComparativeReport {
953
+ period: string;
954
+ date: string;
955
+ projects: { name: string; scorecard: Scorecard }[];
956
+ patterns: string[];
957
+ }
958
+
959
+ function buildComparativeReport(projectNames: string[], period: string, since?: string): ComparativeReport {
960
+ const projects: { name: string; scorecard: Scorecard }[] = [];
961
+
962
+ for (const pName of projectNames) {
963
+ const sessions = loadSessions({ project: pName, period, since });
964
+ if (sessions.length === 0) continue;
965
+ const sc = computeScorecard(sessions, pName, period);
966
+ projects.push({ name: pName, scorecard: sc });
967
+ }
968
+
969
+ // Cross-project patterns
970
+ const patterns: string[] = [];
971
+ if (projects.length >= 2) {
972
+ const categoryNames = projects[0]?.scorecard.categories.map((c) => c.name) ?? [];
973
+ for (const catName of categoryNames) {
974
+ const scores = projects.map((p) => p.scorecard.categories.find((c) => c.name === catName)?.score ?? 0);
975
+ const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
976
+ const allLow = scores.every((s) => s < 65);
977
+ const allHigh = scores.every((s) => s >= 80);
978
+ if (allLow) patterns.push(`⚠️ You're consistently weakest at ${catName} across all projects (avg: ${Math.round(avg)})`);
979
+ if (allHigh) patterns.push(`✅ ${catName} score is strong everywhere — good habit (avg: ${Math.round(avg)})`);
980
+ }
981
+ }
982
+
983
+ return { period, date: new Date().toISOString().slice(0, 10), projects, patterns };
984
+ }
985
+
986
+ function comparativeToMarkdown(cr: ComparativeReport): string {
987
+ const lines: string[] = [];
988
+ lines.push(`# 📊 Comparative Report — ${cr.date}`);
989
+ lines.push(`**Period:** ${cr.period} | **Projects:** ${cr.projects.length}\n`);
990
+
991
+ if (cr.projects.length === 0) return lines.join("\n") + "\nNo projects with data found.";
992
+
993
+ const catNames = cr.projects[0].scorecard.categories.map((c) => c.name);
994
+ const nameHeader = cr.projects.map((p) => p.name.padEnd(14)).join(" ");
995
+ lines.push(`${"".padEnd(24)}${nameHeader}`);
996
+
997
+ // Overall
998
+ const overallRow = cr.projects.map((p) => `${p.scorecard.overallGrade} (${p.scorecard.overall})`.padEnd(14)).join(" ");
999
+ lines.push(`${"Overall:".padEnd(24)}${overallRow}`);
1000
+
1001
+ for (const catName of catNames) {
1002
+ const row = cr.projects.map((p) => {
1003
+ const c = p.scorecard.categories.find((x) => x.name === catName);
1004
+ return c ? `${c.grade} (${c.score})`.padEnd(14) : "—".padEnd(14);
1005
+ }).join(" ");
1006
+ lines.push(`${(catName + ":").padEnd(24)}${row}`);
1007
+ }
1008
+
1009
+ if (cr.patterns.length > 0) {
1010
+ lines.push(`\n## Cross-project patterns`);
1011
+ for (const p of cr.patterns) lines.push(p);
1012
+ }
1013
+
1014
+ return lines.join("\n");
1015
+ }
1016
+
1017
+ function comparativeToHTML(cr: ComparativeReport): string {
1018
+ if (cr.projects.length === 0) return "<html><body>No data</body></html>";
1019
+ const catNames = cr.projects[0].scorecard.categories.map((c) => c.name);
1020
+
1021
+ const headerCols = cr.projects.map((p) => `<th style="padding:8px;text-align:center">${p.name}</th>`).join("");
1022
+ const overallCols = cr.projects.map((p) => {
1023
+ const c = gradeColor(p.scorecard.overallGrade);
1024
+ return `<td style="padding:8px;text-align:center"><span style="background:${c};color:white;padding:2px 8px;border-radius:4px;font-weight:700">${p.scorecard.overallGrade} (${p.scorecard.overall})</span></td>`;
1025
+ }).join("");
1026
+
1027
+ const rows = catNames.map((catName) => {
1028
+ const cols = cr.projects.map((p) => {
1029
+ const cat = p.scorecard.categories.find((x) => x.name === catName);
1030
+ if (!cat) return `<td style="padding:6px;text-align:center">—</td>`;
1031
+ const c = gradeColor(cat.grade);
1032
+ return `<td style="padding:6px;text-align:center"><span style="background:${c};color:white;padding:1px 6px;border-radius:3px;font-size:13px">${cat.grade} (${cat.score})</span></td>`;
1033
+ }).join("");
1034
+ return `<tr><td style="padding:6px;font-weight:600">${catName}</td>${cols}</tr>`;
1035
+ }).join("");
1036
+
1037
+ const patternsHTML = cr.patterns.map((p) => `<div style="padding:8px 16px;margin-bottom:4px;background:${p.startsWith("⚠️") ? "#fef2f2" : "#f0fdf4"};border-radius:4px">${p}</div>`).join("");
1038
+
1039
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"/></head><body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;margin:0;color:#1f2937">
1040
+ <div style="background:linear-gradient(135deg,#1e293b,#0f172a);color:white;padding:32px 40px">
1041
+ <h1 style="margin:0">📊 Comparative Report</h1>
1042
+ <p style="margin:8px 0 0;opacity:0.8">Period: ${cr.period} | ${cr.date}</p>
1043
+ </div>
1044
+ <div style="padding:24px 40px">
1045
+ <table style="width:100%;border-collapse:collapse;font-size:14px">
1046
+ <thead><tr style="background:#f9fafb"><th style="padding:8px;text-align:left">Category</th>${headerCols}</tr></thead>
1047
+ <tbody>
1048
+ <tr style="background:#f0f9ff"><td style="padding:8px;font-weight:700">Overall</td>${overallCols}</tr>
1049
+ ${rows}
1050
+ </tbody>
1051
+ </table>
1052
+ </div>
1053
+ ${cr.patterns.length ? `<div style="padding:0 40px 40px"><h2>Cross-project Patterns</h2>${patternsHTML}</div>` : ""}
1054
+ </body></html>`;
1055
+ }
1056
+
1057
+ // ── Enhanced Markdown with baseline ────────────────────────────────────────
1058
+
1059
+ function toMarkdownWithBaseline(sc: Scorecard, baseline: BaselineData | null): string {
1060
+ const lines: string[] = [];
1061
+ lines.push(`# 📊 Prompt Discipline Scorecard`);
1062
+ lines.push(`**Project:** ${sc.project} | **Period:** ${sc.period} (${sc.date}) | **Overall: ${sc.overallGrade} (${sc.overall}/100)**\n`);
1063
+
1064
+ lines.push(`## Category Scores`);
1065
+ if (baseline) {
1066
+ lines.push(`| # | Category | Score | Grade | vs Avg |`);
1067
+ lines.push(`|---|----------|-------|-------|--------|`);
1068
+ sc.categories.forEach((c, i) => {
1069
+ const avg = baseline.categoryAverages[c.name];
1070
+ const avgCol = avg != null ? `${trendArrow(c.score, avg)} ${letterGrade(avg)} (${avg})` : "—";
1071
+ lines.push(`| ${i + 1} | ${c.name} | ${c.score} | ${c.grade} | ${avgCol} |`);
1072
+ });
1073
+ } else {
1074
+ lines.push(`| # | Category | Score | Grade |`);
1075
+ lines.push(`|---|----------|-------|-------|`);
1076
+ sc.categories.forEach((c, i) => {
1077
+ lines.push(`| ${i + 1} | ${c.name} | ${c.score} | ${c.grade} |`);
1078
+ });
1079
+ }
1080
+
1081
+ lines.push(`\n## Highlights`);
1082
+ lines.push(`- 🏆 **Best:** ${sc.highlights.best.name} (${sc.highlights.best.grade}) — ${sc.highlights.best.evidence}`);
1083
+ lines.push(`- ⚠️ **Worst:** ${sc.highlights.worst.name} (${sc.highlights.worst.grade}) — ${sc.highlights.worst.evidence}`);
1084
+
1085
+ lines.push(`\n## Detailed Breakdown`);
1086
+ sc.categories.forEach((c, i) => {
1087
+ lines.push(`\n### ${i + 1}. ${c.name} — ${c.grade} (${c.score}/100)`);
1088
+ lines.push(`Evidence: ${c.evidence}`);
1089
+ if (c.examples?.bad?.length) {
1090
+ lines.push(`\nExamples of vague follow-ups:`);
1091
+ c.examples.bad.forEach((e) => lines.push(`- ❌ "${e}"`));
1092
+ }
1093
+ if (c.examples?.good?.length) {
1094
+ lines.push(`\nExamples of specific follow-ups:`);
1095
+ c.examples.good.forEach((e) => lines.push(`- ✅ "${e}"`));
1096
+ }
1097
+ });
1098
+
1099
+ return lines.join("\n");
1100
+ }
1101
+
622
1102
  // ── Tool Registration ──────────────────────────────────────────────────────
623
1103
 
624
1104
  export function registerGenerateScorecard(server: McpServer): void {
625
1105
  server.tool(
626
1106
  "generate_scorecard",
627
- "Generate a prompt discipline scorecard analyzing sessions across 12 categories. Produces markdown or PDF report cards with per-category scores, letter grades, and evidence.",
1107
+ "Generate a prompt discipline scorecard, trend report, or comparative report. Analyzes sessions across 12 categories with PDF/Markdown output, trend lines, and cross-project comparisons.",
628
1108
  {
629
1109
  project: z.string().optional().describe("Project name to score. If omitted, scores current project."),
630
1110
  period: z.enum(["session", "day", "week", "month"]).default("day"),
@@ -632,8 +1112,33 @@ export function registerGenerateScorecard(server: McpServer): void {
632
1112
  since: z.string().optional().describe("Start date (ISO or relative like '7days')"),
633
1113
  output: z.enum(["pdf", "markdown"]).default("markdown"),
634
1114
  output_path: z.string().optional().describe("Where to save PDF. Default: /tmp/scorecard-{date}.pdf"),
1115
+ report_type: z.enum(["scorecard", "trend", "comparative"]).default("scorecard").describe("Type of report: scorecard (default), trend (week/month analysis), or comparative (cross-project)"),
1116
+ compare_projects: z.array(z.string()).optional().describe("Project names for comparative report"),
635
1117
  },
636
1118
  async (params) => {
1119
+ const date = new Date().toISOString().slice(0, 10);
1120
+
1121
+ // ── Comparative Report ──
1122
+ if (params.report_type === "comparative") {
1123
+ const projects = params.compare_projects ?? [];
1124
+ if (projects.length < 2) {
1125
+ return { content: [{ type: "text" as const, text: "Comparative report requires at least 2 projects in compare_projects." }] };
1126
+ }
1127
+ const cr = buildComparativeReport(projects, params.period, params.since);
1128
+ if (params.output === "pdf") {
1129
+ const html = comparativeToHTML(cr);
1130
+ const outputPath = params.output_path ?? `/tmp/comparative-${date}.pdf`;
1131
+ try {
1132
+ await generatePDF(html, outputPath);
1133
+ return { content: [{ type: "text" as const, text: `✅ Comparative PDF saved to ${outputPath}\n\n${comparativeToMarkdown(cr)}` }] };
1134
+ } catch (err) {
1135
+ return { content: [{ type: "text" as const, text: `⚠️ PDF failed (${err}). Markdown:\n\n${comparativeToMarkdown(cr)}` }] };
1136
+ }
1137
+ }
1138
+ return { content: [{ type: "text" as const, text: comparativeToMarkdown(cr) }] };
1139
+ }
1140
+
1141
+ // ── Load sessions ──
637
1142
  const sessions = loadSessions({
638
1143
  project: params.project,
639
1144
  sessionId: params.session_id,
@@ -642,32 +1147,54 @@ export function registerGenerateScorecard(server: McpServer): void {
642
1147
  });
643
1148
 
644
1149
  if (sessions.length === 0) {
645
- return {
646
- content: [{ type: "text" as const, text: "No sessions found matching the criteria. Try broadening the time period or checking the project name." }],
647
- };
1150
+ return { content: [{ type: "text" as const, text: "No sessions found matching the criteria. Try broadening the time period or checking the project name." }] };
648
1151
  }
649
1152
 
650
1153
  const projectName = params.project ?? sessions[0]?.events[0]?.project_name ?? "unknown";
1154
+
1155
+ // ── Trend Report ──
1156
+ if (params.report_type === "trend" || (params.report_type === "scorecard" && (params.period === "week" || params.period === "month") && !params.session_id)) {
1157
+ // For week/month periods, auto-generate trend if report_type is scorecard
1158
+ // but only if not requesting a specific session
1159
+ if (params.report_type !== "trend" && params.period !== "week" && params.period !== "month") {
1160
+ // Fall through to regular scorecard
1161
+ } else {
1162
+ const tr = buildTrendReport(sessions, projectName, params.period);
1163
+ // Also update baseline
1164
+ const overallSc = computeScorecard(sessions, projectName, params.period);
1165
+ updateBaseline(projectName, overallSc);
1166
+
1167
+ if (params.output === "pdf") {
1168
+ const html = trendToHTML(tr);
1169
+ const outputPath = params.output_path ?? `/tmp/trend-${date}.pdf`;
1170
+ try {
1171
+ await generatePDF(html, outputPath);
1172
+ return { content: [{ type: "text" as const, text: `✅ Trend PDF saved to ${outputPath}\n\n${trendToMarkdown(tr)}` }] };
1173
+ } catch (err) {
1174
+ return { content: [{ type: "text" as const, text: `⚠️ PDF failed (${err}). Markdown:\n\n${trendToMarkdown(tr)}` }] };
1175
+ }
1176
+ }
1177
+ return { content: [{ type: "text" as const, text: trendToMarkdown(tr) }] };
1178
+ }
1179
+ }
1180
+
1181
+ // ── Regular Scorecard ──
651
1182
  const scorecard = computeScorecard(sessions, projectName, params.period);
1183
+ const baseline = loadBaseline(projectName);
1184
+ updateBaseline(projectName, scorecard);
652
1185
 
653
1186
  if (params.output === "pdf") {
654
1187
  const html = toHTML(scorecard);
655
- const outputPath = params.output_path ?? `/tmp/scorecard-${scorecard.date}.pdf`;
1188
+ const outputPath = params.output_path ?? `/tmp/scorecard-${date}.pdf`;
656
1189
  try {
657
1190
  await generatePDF(html, outputPath);
658
- return {
659
- content: [{ type: "text" as const, text: `✅ PDF scorecard saved to ${outputPath}\n\n${toMarkdown(scorecard)}` }],
660
- };
1191
+ return { content: [{ type: "text" as const, text: `✅ PDF scorecard saved to ${outputPath}\n\n${toMarkdownWithBaseline(scorecard, baseline)}` }] };
661
1192
  } catch (err) {
662
- return {
663
- content: [{ type: "text" as const, text: `⚠️ PDF generation failed (${err}). Falling back to markdown:\n\n${toMarkdown(scorecard)}` }],
664
- };
1193
+ return { content: [{ type: "text" as const, text: `⚠️ PDF generation failed (${err}). Falling back to markdown:\n\n${toMarkdownWithBaseline(scorecard, baseline)}` }] };
665
1194
  }
666
1195
  }
667
1196
 
668
- return {
669
- content: [{ type: "text" as const, text: toMarkdown(scorecard) }],
670
- };
1197
+ return { content: [{ type: "text" as const, text: toMarkdownWithBaseline(scorecard, baseline) }] };
671
1198
  },
672
1199
  );
673
1200
  }
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { getBranch } from "../lib/git.js";
4
4
  import { appendLog, readLog, now } from "../lib/state.js";
5
+ import { refreshPatterns } from "../lib/patterns.js";
5
6
 
6
7
  const CATEGORIES = [
7
8
  "vague_prompt",
@@ -36,6 +37,9 @@ export function registerLogCorrection(server: McpServer): void {
36
37
  };
37
38
  appendLog("corrections.jsonl", entry);
38
39
 
40
+ // Re-extract patterns from all corrections
41
+ const updatedPatterns = refreshPatterns();
42
+
39
43
  const corrections = readLog("corrections.jsonl");
40
44
  const total = corrections.length;
41
45
 
@@ -81,7 +85,10 @@ ${sorted.map(([k, v]) => `- ${k}: ${v} (${Math.round(v / total * 100)}%)`).join(
81
85
 
82
86
  ### Most Common: ${topCategory[0]} (${topCategory[1]}x)
83
87
 
84
- ${hints[topCategory[0]] || ""}`,
88
+ ${hints[topCategory[0]] || ""}
89
+
90
+ ### Learned Patterns: ${updatedPatterns.length}
91
+ ${updatedPatterns.length > 0 ? updatedPatterns.map(p => `- "${p.pattern}" (${p.frequency}x)`).join("\n") : "_Not enough corrections yet to detect patterns._"}`,
85
92
  }],
86
93
  };
87
94
  }
@@ -15,6 +15,7 @@ import { findSessionDirs, parseAllSessions } from "../lib/session-parser.js";
15
15
  import { extractGitHistory } from "../lib/git-extractor.js";
16
16
  import { createEmbeddingProvider } from "../lib/embeddings.js";
17
17
  import { execSync } from "child_process";
18
+ import { extractAndSaveContracts } from "../lib/contracts.js";
18
19
 
19
20
  const GIT_DEPTH_MAP: Record<string, number | undefined> = {
20
21
  all: undefined,
@@ -194,7 +195,15 @@ export function registerOnboardProject(server: McpServer) {
194
195
  await insertEvents(allEvents, project_dir);
195
196
  progress.push("💾 Inserted into database");
196
197
 
197
- // 8. Summary
198
+ // 8. Extract contracts
199
+ try {
200
+ const contractResult = extractAndSaveContracts(project_dir);
201
+ progress.push(`📑 Extracted ${contractResult.count} contracts (types, interfaces, routes, schemas)`);
202
+ } catch (err) {
203
+ progress.push(`⚠️ Contract extraction failed: ${err}`);
204
+ }
205
+
206
+ // 9. Summary
198
207
  const prompts = allEvents.filter((e: any) => e.type === "prompt").length;
199
208
  const commits = allEvents.filter((e: any) => e.type === "commit").length;
200
209
  const corrections = allEvents.filter((e: any) => e.type === "correction").length;