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,8 +1,13 @@
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
  import { z } from "zod";
5
6
  import { findSessionDirs, findSessionFiles, parseSession, } from "../lib/session-parser.js";
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
8
+ import { join } from "path";
9
+ import { homedir } from "os";
10
+ import { createHash } from "crypto";
6
11
  // ── Grading ────────────────────────────────────────────────────────────────
7
12
  function letterGrade(score) {
8
13
  if (score >= 95)
@@ -571,16 +576,442 @@ function loadSessions(opts) {
571
576
  }
572
577
  return sessions;
573
578
  }
579
+ // ── Historical Baseline ────────────────────────────────────────────────────
580
+ const PREFLIGHT_DIR = join(homedir(), ".preflight", "projects");
581
+ function baselinePath(project) {
582
+ const hash = createHash("md5").update(project).digest("hex").slice(0, 12);
583
+ return join(PREFLIGHT_DIR, hash, "baseline.json");
584
+ }
585
+ function loadBaseline(project) {
586
+ const p = baselinePath(project);
587
+ if (!existsSync(p))
588
+ return null;
589
+ try {
590
+ return JSON.parse(readFileSync(p, "utf-8"));
591
+ }
592
+ catch {
593
+ return null;
594
+ }
595
+ }
596
+ function saveBaseline(project, data) {
597
+ const p = baselinePath(project);
598
+ const dir = p.replace(/\/[^/]+$/, "");
599
+ mkdirSync(dir, { recursive: true });
600
+ writeFileSync(p, JSON.stringify(data, null, 2));
601
+ }
602
+ function updateBaseline(project, scorecard) {
603
+ const existing = loadBaseline(project);
604
+ if (!existing) {
605
+ const categoryAverages = {};
606
+ for (const c of scorecard.categories)
607
+ categoryAverages[c.name] = c.score;
608
+ saveBaseline(project, {
609
+ categoryAverages,
610
+ overallAverage: scorecard.overall,
611
+ sessionCount: 1,
612
+ lastUpdated: new Date().toISOString(),
613
+ });
614
+ return;
615
+ }
616
+ const n = existing.sessionCount;
617
+ const newN = n + 1;
618
+ existing.overallAverage = Math.round((existing.overallAverage * n + scorecard.overall) / newN);
619
+ for (const c of scorecard.categories) {
620
+ const prev = existing.categoryAverages[c.name] ?? c.score;
621
+ existing.categoryAverages[c.name] = Math.round((prev * n + c.score) / newN);
622
+ }
623
+ existing.sessionCount = newN;
624
+ existing.lastUpdated = new Date().toISOString();
625
+ saveBaseline(project, existing);
626
+ }
627
+ function trendArrow(current, previous) {
628
+ const diff = current - previous;
629
+ if (diff > 5)
630
+ return "↑";
631
+ if (diff < -5)
632
+ return "↓";
633
+ return "→";
634
+ }
635
+ function generateTrendSVG(dailyScores) {
636
+ const W = 400, H = 200, pad = 40;
637
+ const plotW = W - pad * 2, plotH = H - pad * 2;
638
+ const n = dailyScores.length;
639
+ if (n === 0)
640
+ 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>`;
641
+ const points = dailyScores.map((d, i) => ({
642
+ x: pad + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW),
643
+ y: pad + plotH - (d.score / 100) * plotH,
644
+ }));
645
+ const gridLines = [0, 25, 50, 75, 100].map((v) => {
646
+ const y = pad + plotH - (v / 100) * plotH;
647
+ 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>`;
648
+ }).join("");
649
+ const labels = dailyScores.map((d, i) => {
650
+ const x = pad + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW);
651
+ const label = d.date.slice(5); // MM-DD
652
+ return `<text x="${x}" y="${H - 8}" text-anchor="middle" font-size="9" fill="#9ca3af">${label}</text>`;
653
+ }).join("");
654
+ const pathD = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
655
+ const dots = points.map((p) => `<circle cx="${p.x}" cy="${p.y}" r="3" fill="#3b82f6"/>`).join("");
656
+ return `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">
657
+ <rect width="${W}" height="${H}" fill="white" rx="4"/>
658
+ ${gridLines}
659
+ <path d="${pathD}" fill="none" stroke="#3b82f6" stroke-width="2"/>
660
+ ${dots}
661
+ ${labels}
662
+ </svg>`;
663
+ }
664
+ function groupSessionsByDay(sessions) {
665
+ const map = new Map();
666
+ for (const s of sessions) {
667
+ const ts = s.events[0]?.timestamp;
668
+ if (!ts)
669
+ continue;
670
+ const day = new Date(ts).toISOString().slice(0, 10);
671
+ if (!map.has(day))
672
+ map.set(day, []);
673
+ map.get(day).push(s);
674
+ }
675
+ return map;
676
+ }
677
+ function scoreDailyData(sessions) {
678
+ const byDay = groupSessionsByDay(sessions);
679
+ const days = [...byDay.keys()].sort();
680
+ return days.map((date) => {
681
+ const daySessions = byDay.get(date);
682
+ const sc = computeScorecard(daySessions, "", date);
683
+ return {
684
+ date,
685
+ score: sc.overall,
686
+ categories: sc.categories,
687
+ sessionCount: daySessions.length,
688
+ promptCount: daySessions.reduce((s, d) => s + d.userMessages.length, 0),
689
+ toolCallCount: daySessions.reduce((s, d) => s + d.toolCalls.length, 0),
690
+ correctionCount: daySessions.reduce((s, d) => s + d.corrections.length, 0),
691
+ compactionCount: daySessions.reduce((s, d) => s + d.compactions.length, 0),
692
+ };
693
+ });
694
+ }
695
+ function findBestWorstPrompt(sessions) {
696
+ let best = "", worst = "";
697
+ let bestScore = -1, worstScore = Infinity;
698
+ for (const s of sessions) {
699
+ for (const m of s.userMessages) {
700
+ const text = m.content;
701
+ if (text.length < 5)
702
+ continue;
703
+ // Score: length + file refs bonus
704
+ const score = text.length + (hasFileRef(text) ? 200 : 0);
705
+ if (score > bestScore) {
706
+ bestScore = score;
707
+ best = text;
708
+ }
709
+ if (score < worstScore) {
710
+ worstScore = score;
711
+ worst = text;
712
+ }
713
+ }
714
+ }
715
+ return {
716
+ best: best.slice(0, 300),
717
+ worst: worst.slice(0, 200),
718
+ };
719
+ }
720
+ const IMPROVEMENT_TIPS = {
721
+ "Plans": "Start sessions with a detailed plan: list files to touch, expected changes, and success criteria before coding.",
722
+ "Clarification": "Always reference specific file paths and function names in your prompts instead of speaking abstractly.",
723
+ "Delegation": "When spawning sub-agents, provide detailed context: file paths, expected output format, and constraints.",
724
+ "Follow-up Specificity": "After receiving a response, reference specific lines/files rather than saying 'fix it' or 'try again'.",
725
+ "Token Efficiency": "Batch related changes into single prompts. Avoid asking for one small change at a time.",
726
+ "Sequencing": "Complete work in one area before moving to the next. Avoid jumping between unrelated files.",
727
+ "Compaction Management": "Commit before context compaction hits. Keep sessions focused to avoid hitting limits.",
728
+ "Session Lifecycle": "Commit every 15-30 minutes. Don't let sessions run 3+ hours without checkpoints.",
729
+ "Error Recovery": "When correcting the AI, be specific: 'In file X, line Y, change Z to W' not 'no, wrong'.",
730
+ "Workspace Hygiene": "Maintain CLAUDE.md and .claude/ workspace docs for project context.",
731
+ "Cross-Session Continuity": "Start each session by reading project context files (CLAUDE.md, README, etc.).",
732
+ "Verification": "Always run tests/build at the end of a session to verify changes work.",
733
+ };
734
+ function buildTrendReport(sessions, project, period) {
735
+ const dailyScores = scoreDailyData(sessions);
736
+ const baseline = loadBaseline(project);
737
+ // Category trends: compare first half vs second half
738
+ const mid = Math.floor(dailyScores.length / 2);
739
+ const firstHalf = dailyScores.slice(0, Math.max(1, mid));
740
+ const secondHalf = dailyScores.slice(Math.max(1, mid));
741
+ const categoryNames = dailyScores[0]?.categories.map((c) => c.name) ?? [];
742
+ const categoryTrends = categoryNames.map((name) => {
743
+ const avgFirst = firstHalf.reduce((s, d) => s + (d.categories.find((c) => c.name === name)?.score ?? 0), 0) / (firstHalf.length || 1);
744
+ const avgSecond = secondHalf.reduce((s, d) => s + (d.categories.find((c) => c.name === name)?.score ?? 0), 0) / (secondHalf.length || 1);
745
+ return { name, current: Math.round(avgSecond), previous: Math.round(avgFirst), arrow: trendArrow(avgSecond, avgFirst) };
746
+ });
747
+ // Top 3 to improve
748
+ const sorted = [...categoryTrends].sort((a, b) => a.current - b.current);
749
+ const top3Improve = sorted.slice(0, 3).map((t) => ({
750
+ category: t.name,
751
+ score: t.current,
752
+ recommendation: IMPROVEMENT_TIPS[t.name] ?? "Focus on improving this area.",
753
+ }));
754
+ const { best, worst } = findBestWorstPrompt(sessions);
755
+ const totalPrompts = sessions.reduce((s, d) => s + d.userMessages.length, 0);
756
+ const totalCorrections = sessions.reduce((s, d) => s + d.corrections.length, 0);
757
+ return {
758
+ project,
759
+ period,
760
+ dailyScores,
761
+ categoryTrends,
762
+ top3Improve,
763
+ bestPrompt: best,
764
+ worstPrompt: worst,
765
+ stats: {
766
+ sessions: sessions.length,
767
+ prompts: totalPrompts,
768
+ toolCalls: sessions.reduce((s, d) => s + d.toolCalls.length, 0),
769
+ correctionRate: totalPrompts > 0 ? Math.round((totalCorrections / totalPrompts) * 100) : 0,
770
+ compactions: sessions.reduce((s, d) => s + d.compactions.length, 0),
771
+ },
772
+ baseline,
773
+ svg: generateTrendSVG(dailyScores.map((d) => ({ date: d.date, score: d.score }))),
774
+ };
775
+ }
776
+ function trendToMarkdown(tr) {
777
+ const lines = [];
778
+ const periodLabel = tr.period === "week" ? "Weekly" : "Monthly";
779
+ lines.push(`# 📈 ${periodLabel} Trend Report`);
780
+ lines.push(`**Project:** ${tr.project} | **Period:** ${tr.period} | **Days:** ${tr.dailyScores.length}\n`);
781
+ lines.push(`## 📊 Stats`);
782
+ lines.push(`| Sessions | Prompts | Tool Calls | Correction Rate | Compactions |`);
783
+ lines.push(`|----------|---------|------------|-----------------|-------------|`);
784
+ lines.push(`| ${tr.stats.sessions} | ${tr.stats.prompts} | ${tr.stats.toolCalls} | ${tr.stats.correctionRate}% | ${tr.stats.compactions} |\n`);
785
+ lines.push(`## 📉 Score Trend`);
786
+ lines.push(`| Date | Score | Grade |`);
787
+ lines.push(`|------|-------|-------|`);
788
+ for (const d of tr.dailyScores) {
789
+ lines.push(`| ${d.date} | ${d.score} | ${letterGrade(d.score)} |`);
790
+ }
791
+ lines.push(`\n## Category Trends`);
792
+ lines.push(`| Category | Score | Trend |${tr.baseline ? " vs Avg |" : ""}`);
793
+ lines.push(`|----------|-------|-------|${tr.baseline ? "--------|" : ""}`);
794
+ for (const t of tr.categoryTrends) {
795
+ let row = `| ${t.name} | ${t.current} (${letterGrade(t.current)}) | ${t.arrow} |`;
796
+ if (tr.baseline) {
797
+ const avg = tr.baseline.categoryAverages[t.name];
798
+ if (avg != null)
799
+ row += ` ${trendArrow(t.current, avg)} vs ${letterGrade(avg)} (${avg}) |`;
800
+ else
801
+ row += ` — |`;
802
+ }
803
+ lines.push(row);
804
+ }
805
+ lines.push(`\n## 🎯 Top 3 Areas to Improve`);
806
+ for (const t of tr.top3Improve) {
807
+ lines.push(`- **${t.category}** (${letterGrade(t.score)}, ${t.score}/100): ${t.recommendation}`);
808
+ }
809
+ lines.push(`\n## 💬 Prompts of the ${tr.period === "week" ? "Week" : "Month"}`);
810
+ lines.push(`**Best prompt:**\n> ${tr.bestPrompt}\n`);
811
+ lines.push(`**Worst prompt:**\n> ${tr.worstPrompt}`);
812
+ return lines.join("\n");
813
+ }
814
+ function trendToHTML(tr) {
815
+ const periodLabel = tr.period === "week" ? "Weekly" : "Monthly";
816
+ const categoryRows = tr.categoryTrends.map((t) => {
817
+ const color = gradeColor(letterGrade(t.current));
818
+ 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>` : "";
819
+ 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>`;
820
+ }).join("");
821
+ 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">
822
+ <div style="background:linear-gradient(135deg,#1e293b,#0f172a);color:white;padding:32px 40px">
823
+ <h1 style="margin:0">📈 ${periodLabel} Trend Report</h1>
824
+ <p style="margin:8px 0 0;opacity:0.8">Project: <strong>${tr.project}</strong> | ${tr.dailyScores.length} days | ${tr.stats.sessions} sessions</p>
825
+ </div>
826
+ <div style="padding:24px 40px;display:flex;gap:20px;flex-wrap:wrap">
827
+ <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>
828
+ <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>
829
+ <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>
830
+ <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>
831
+ <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>
832
+ </div>
833
+ <div style="padding:0 40px">${tr.svg}</div>
834
+ <div style="padding:24px 40px">
835
+ <h2>Category Trends</h2>
836
+ <table style="width:100%;border-collapse:collapse;font-size:14px">
837
+ <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>
838
+ <tbody>${categoryRows}</tbody>
839
+ </table>
840
+ </div>
841
+ <div style="padding:0 40px 20px">
842
+ <h2>🎯 Top 3 Areas to Improve</h2>
843
+ ${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("")}
844
+ </div>
845
+ <div style="padding:0 40px 40px">
846
+ <h2>💬 Prompts of the ${tr.period === "week" ? "Week" : "Month"}</h2>
847
+ <div style="background:#f0fdf4;border-left:4px solid #22c55e;padding:10px 16px;margin-bottom:8px;border-radius:4px"><strong>Best:</strong> ${tr.bestPrompt}</div>
848
+ <div style="background:#fef2f2;border-left:4px solid #ef4444;padding:10px 16px;border-radius:4px"><strong>Worst:</strong> ${tr.worstPrompt}</div>
849
+ </div>
850
+ </body></html>`;
851
+ }
852
+ function buildComparativeReport(projectNames, period, since) {
853
+ const projects = [];
854
+ for (const pName of projectNames) {
855
+ const sessions = loadSessions({ project: pName, period, since });
856
+ if (sessions.length === 0)
857
+ continue;
858
+ const sc = computeScorecard(sessions, pName, period);
859
+ projects.push({ name: pName, scorecard: sc });
860
+ }
861
+ // Cross-project patterns
862
+ const patterns = [];
863
+ if (projects.length >= 2) {
864
+ const categoryNames = projects[0]?.scorecard.categories.map((c) => c.name) ?? [];
865
+ for (const catName of categoryNames) {
866
+ const scores = projects.map((p) => p.scorecard.categories.find((c) => c.name === catName)?.score ?? 0);
867
+ const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
868
+ const allLow = scores.every((s) => s < 65);
869
+ const allHigh = scores.every((s) => s >= 80);
870
+ if (allLow)
871
+ patterns.push(`⚠️ You're consistently weakest at ${catName} across all projects (avg: ${Math.round(avg)})`);
872
+ if (allHigh)
873
+ patterns.push(`✅ ${catName} score is strong everywhere — good habit (avg: ${Math.round(avg)})`);
874
+ }
875
+ }
876
+ return { period, date: new Date().toISOString().slice(0, 10), projects, patterns };
877
+ }
878
+ function comparativeToMarkdown(cr) {
879
+ const lines = [];
880
+ lines.push(`# 📊 Comparative Report — ${cr.date}`);
881
+ lines.push(`**Period:** ${cr.period} | **Projects:** ${cr.projects.length}\n`);
882
+ if (cr.projects.length === 0)
883
+ return lines.join("\n") + "\nNo projects with data found.";
884
+ const catNames = cr.projects[0].scorecard.categories.map((c) => c.name);
885
+ const nameHeader = cr.projects.map((p) => p.name.padEnd(14)).join(" ");
886
+ lines.push(`${"".padEnd(24)}${nameHeader}`);
887
+ // Overall
888
+ const overallRow = cr.projects.map((p) => `${p.scorecard.overallGrade} (${p.scorecard.overall})`.padEnd(14)).join(" ");
889
+ lines.push(`${"Overall:".padEnd(24)}${overallRow}`);
890
+ for (const catName of catNames) {
891
+ const row = cr.projects.map((p) => {
892
+ const c = p.scorecard.categories.find((x) => x.name === catName);
893
+ return c ? `${c.grade} (${c.score})`.padEnd(14) : "—".padEnd(14);
894
+ }).join(" ");
895
+ lines.push(`${(catName + ":").padEnd(24)}${row}`);
896
+ }
897
+ if (cr.patterns.length > 0) {
898
+ lines.push(`\n## Cross-project patterns`);
899
+ for (const p of cr.patterns)
900
+ lines.push(p);
901
+ }
902
+ return lines.join("\n");
903
+ }
904
+ function comparativeToHTML(cr) {
905
+ if (cr.projects.length === 0)
906
+ return "<html><body>No data</body></html>";
907
+ const catNames = cr.projects[0].scorecard.categories.map((c) => c.name);
908
+ const headerCols = cr.projects.map((p) => `<th style="padding:8px;text-align:center">${p.name}</th>`).join("");
909
+ const overallCols = cr.projects.map((p) => {
910
+ const c = gradeColor(p.scorecard.overallGrade);
911
+ 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>`;
912
+ }).join("");
913
+ const rows = catNames.map((catName) => {
914
+ const cols = cr.projects.map((p) => {
915
+ const cat = p.scorecard.categories.find((x) => x.name === catName);
916
+ if (!cat)
917
+ return `<td style="padding:6px;text-align:center">—</td>`;
918
+ const c = gradeColor(cat.grade);
919
+ 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>`;
920
+ }).join("");
921
+ return `<tr><td style="padding:6px;font-weight:600">${catName}</td>${cols}</tr>`;
922
+ }).join("");
923
+ 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("");
924
+ 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">
925
+ <div style="background:linear-gradient(135deg,#1e293b,#0f172a);color:white;padding:32px 40px">
926
+ <h1 style="margin:0">📊 Comparative Report</h1>
927
+ <p style="margin:8px 0 0;opacity:0.8">Period: ${cr.period} | ${cr.date}</p>
928
+ </div>
929
+ <div style="padding:24px 40px">
930
+ <table style="width:100%;border-collapse:collapse;font-size:14px">
931
+ <thead><tr style="background:#f9fafb"><th style="padding:8px;text-align:left">Category</th>${headerCols}</tr></thead>
932
+ <tbody>
933
+ <tr style="background:#f0f9ff"><td style="padding:8px;font-weight:700">Overall</td>${overallCols}</tr>
934
+ ${rows}
935
+ </tbody>
936
+ </table>
937
+ </div>
938
+ ${cr.patterns.length ? `<div style="padding:0 40px 40px"><h2>Cross-project Patterns</h2>${patternsHTML}</div>` : ""}
939
+ </body></html>`;
940
+ }
941
+ // ── Enhanced Markdown with baseline ────────────────────────────────────────
942
+ function toMarkdownWithBaseline(sc, baseline) {
943
+ const lines = [];
944
+ lines.push(`# 📊 Prompt Discipline Scorecard`);
945
+ lines.push(`**Project:** ${sc.project} | **Period:** ${sc.period} (${sc.date}) | **Overall: ${sc.overallGrade} (${sc.overall}/100)**\n`);
946
+ lines.push(`## Category Scores`);
947
+ if (baseline) {
948
+ lines.push(`| # | Category | Score | Grade | vs Avg |`);
949
+ lines.push(`|---|----------|-------|-------|--------|`);
950
+ sc.categories.forEach((c, i) => {
951
+ const avg = baseline.categoryAverages[c.name];
952
+ const avgCol = avg != null ? `${trendArrow(c.score, avg)} ${letterGrade(avg)} (${avg})` : "—";
953
+ lines.push(`| ${i + 1} | ${c.name} | ${c.score} | ${c.grade} | ${avgCol} |`);
954
+ });
955
+ }
956
+ else {
957
+ lines.push(`| # | Category | Score | Grade |`);
958
+ lines.push(`|---|----------|-------|-------|`);
959
+ sc.categories.forEach((c, i) => {
960
+ lines.push(`| ${i + 1} | ${c.name} | ${c.score} | ${c.grade} |`);
961
+ });
962
+ }
963
+ lines.push(`\n## Highlights`);
964
+ lines.push(`- 🏆 **Best:** ${sc.highlights.best.name} (${sc.highlights.best.grade}) — ${sc.highlights.best.evidence}`);
965
+ lines.push(`- ⚠️ **Worst:** ${sc.highlights.worst.name} (${sc.highlights.worst.grade}) — ${sc.highlights.worst.evidence}`);
966
+ lines.push(`\n## Detailed Breakdown`);
967
+ sc.categories.forEach((c, i) => {
968
+ lines.push(`\n### ${i + 1}. ${c.name} — ${c.grade} (${c.score}/100)`);
969
+ lines.push(`Evidence: ${c.evidence}`);
970
+ if (c.examples?.bad?.length) {
971
+ lines.push(`\nExamples of vague follow-ups:`);
972
+ c.examples.bad.forEach((e) => lines.push(`- ❌ "${e}"`));
973
+ }
974
+ if (c.examples?.good?.length) {
975
+ lines.push(`\nExamples of specific follow-ups:`);
976
+ c.examples.good.forEach((e) => lines.push(`- ✅ "${e}"`));
977
+ }
978
+ });
979
+ return lines.join("\n");
980
+ }
574
981
  // ── Tool Registration ──────────────────────────────────────────────────────
575
982
  export function registerGenerateScorecard(server) {
576
- server.tool("generate_scorecard", "Generate a prompt discipline scorecard analyzing sessions across 12 categories. Produces markdown or PDF report cards with per-category scores, letter grades, and evidence.", {
983
+ server.tool("generate_scorecard", "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.", {
577
984
  project: z.string().optional().describe("Project name to score. If omitted, scores current project."),
578
985
  period: z.enum(["session", "day", "week", "month"]).default("day"),
579
986
  session_id: z.string().optional().describe("Score a specific session by ID"),
580
987
  since: z.string().optional().describe("Start date (ISO or relative like '7days')"),
581
988
  output: z.enum(["pdf", "markdown"]).default("markdown"),
582
989
  output_path: z.string().optional().describe("Where to save PDF. Default: /tmp/scorecard-{date}.pdf"),
990
+ report_type: z.enum(["scorecard", "trend", "comparative"]).default("scorecard").describe("Type of report: scorecard (default), trend (week/month analysis), or comparative (cross-project)"),
991
+ compare_projects: z.array(z.string()).optional().describe("Project names for comparative report"),
583
992
  }, async (params) => {
993
+ const date = new Date().toISOString().slice(0, 10);
994
+ // ── Comparative Report ──
995
+ if (params.report_type === "comparative") {
996
+ const projects = params.compare_projects ?? [];
997
+ if (projects.length < 2) {
998
+ return { content: [{ type: "text", text: "Comparative report requires at least 2 projects in compare_projects." }] };
999
+ }
1000
+ const cr = buildComparativeReport(projects, params.period, params.since);
1001
+ if (params.output === "pdf") {
1002
+ const html = comparativeToHTML(cr);
1003
+ const outputPath = params.output_path ?? `/tmp/comparative-${date}.pdf`;
1004
+ try {
1005
+ await generatePDF(html, outputPath);
1006
+ return { content: [{ type: "text", text: `✅ Comparative PDF saved to ${outputPath}\n\n${comparativeToMarkdown(cr)}` }] };
1007
+ }
1008
+ catch (err) {
1009
+ return { content: [{ type: "text", text: `⚠️ PDF failed (${err}). Markdown:\n\n${comparativeToMarkdown(cr)}` }] };
1010
+ }
1011
+ }
1012
+ return { content: [{ type: "text", text: comparativeToMarkdown(cr) }] };
1013
+ }
1014
+ // ── Load sessions ──
584
1015
  const sessions = loadSessions({
585
1016
  project: params.project,
586
1017
  sessionId: params.session_id,
@@ -588,30 +1019,51 @@ export function registerGenerateScorecard(server) {
588
1019
  period: params.period,
589
1020
  });
590
1021
  if (sessions.length === 0) {
591
- return {
592
- content: [{ type: "text", text: "No sessions found matching the criteria. Try broadening the time period or checking the project name." }],
593
- };
1022
+ return { content: [{ type: "text", text: "No sessions found matching the criteria. Try broadening the time period or checking the project name." }] };
594
1023
  }
595
1024
  const projectName = params.project ?? sessions[0]?.events[0]?.project_name ?? "unknown";
1025
+ // ── Trend Report ──
1026
+ if (params.report_type === "trend" || (params.report_type === "scorecard" && (params.period === "week" || params.period === "month") && !params.session_id)) {
1027
+ // For week/month periods, auto-generate trend if report_type is scorecard
1028
+ // but only if not requesting a specific session
1029
+ if (params.report_type !== "trend" && params.period !== "week" && params.period !== "month") {
1030
+ // Fall through to regular scorecard
1031
+ }
1032
+ else {
1033
+ const tr = buildTrendReport(sessions, projectName, params.period);
1034
+ // Also update baseline
1035
+ const overallSc = computeScorecard(sessions, projectName, params.period);
1036
+ updateBaseline(projectName, overallSc);
1037
+ if (params.output === "pdf") {
1038
+ const html = trendToHTML(tr);
1039
+ const outputPath = params.output_path ?? `/tmp/trend-${date}.pdf`;
1040
+ try {
1041
+ await generatePDF(html, outputPath);
1042
+ return { content: [{ type: "text", text: `✅ Trend PDF saved to ${outputPath}\n\n${trendToMarkdown(tr)}` }] };
1043
+ }
1044
+ catch (err) {
1045
+ return { content: [{ type: "text", text: `⚠️ PDF failed (${err}). Markdown:\n\n${trendToMarkdown(tr)}` }] };
1046
+ }
1047
+ }
1048
+ return { content: [{ type: "text", text: trendToMarkdown(tr) }] };
1049
+ }
1050
+ }
1051
+ // ── Regular Scorecard ──
596
1052
  const scorecard = computeScorecard(sessions, projectName, params.period);
1053
+ const baseline = loadBaseline(projectName);
1054
+ updateBaseline(projectName, scorecard);
597
1055
  if (params.output === "pdf") {
598
1056
  const html = toHTML(scorecard);
599
- const outputPath = params.output_path ?? `/tmp/scorecard-${scorecard.date}.pdf`;
1057
+ const outputPath = params.output_path ?? `/tmp/scorecard-${date}.pdf`;
600
1058
  try {
601
1059
  await generatePDF(html, outputPath);
602
- return {
603
- content: [{ type: "text", text: `✅ PDF scorecard saved to ${outputPath}\n\n${toMarkdown(scorecard)}` }],
604
- };
1060
+ return { content: [{ type: "text", text: `✅ PDF scorecard saved to ${outputPath}\n\n${toMarkdownWithBaseline(scorecard, baseline)}` }] };
605
1061
  }
606
1062
  catch (err) {
607
- return {
608
- content: [{ type: "text", text: `⚠️ PDF generation failed (${err}). Falling back to markdown:\n\n${toMarkdown(scorecard)}` }],
609
- };
1063
+ return { content: [{ type: "text", text: `⚠️ PDF generation failed (${err}). Falling back to markdown:\n\n${toMarkdownWithBaseline(scorecard, baseline)}` }] };
610
1064
  }
611
1065
  }
612
- return {
613
- content: [{ type: "text", text: toMarkdown(scorecard) }],
614
- };
1066
+ return { content: [{ type: "text", text: toMarkdownWithBaseline(scorecard, baseline) }] };
615
1067
  });
616
1068
  }
617
1069
  //# sourceMappingURL=generate-scorecard.js.map