preflight-dev 3.1.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 (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/cli.js +11 -0
  4. package/dist/cli/init.d.ts +2 -0
  5. package/dist/cli/init.js +154 -0
  6. package/dist/cli/init.js.map +1 -0
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.js +122 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lib/config.d.ts +34 -0
  11. package/dist/lib/config.js +118 -0
  12. package/dist/lib/config.js.map +1 -0
  13. package/dist/lib/embeddings.d.ts +11 -0
  14. package/dist/lib/embeddings.js +88 -0
  15. package/dist/lib/embeddings.js.map +1 -0
  16. package/dist/lib/files.d.ts +15 -0
  17. package/dist/lib/files.js +60 -0
  18. package/dist/lib/files.js.map +1 -0
  19. package/dist/lib/git-extractor.d.ts +9 -0
  20. package/dist/lib/git-extractor.js +116 -0
  21. package/dist/lib/git-extractor.js.map +1 -0
  22. package/dist/lib/git.d.ts +29 -0
  23. package/dist/lib/git.js +86 -0
  24. package/dist/lib/git.js.map +1 -0
  25. package/dist/lib/session-parser.d.ts +45 -0
  26. package/dist/lib/session-parser.js +267 -0
  27. package/dist/lib/session-parser.js.map +1 -0
  28. package/dist/lib/state.d.ts +21 -0
  29. package/dist/lib/state.js +86 -0
  30. package/dist/lib/state.js.map +1 -0
  31. package/dist/lib/timeline-db.d.ts +67 -0
  32. package/dist/lib/timeline-db.js +380 -0
  33. package/dist/lib/timeline-db.js.map +1 -0
  34. package/dist/lib/triage.d.ts +29 -0
  35. package/dist/lib/triage.js +193 -0
  36. package/dist/lib/triage.js.map +1 -0
  37. package/dist/profiles.d.ts +3 -0
  38. package/dist/profiles.js +65 -0
  39. package/dist/profiles.js.map +1 -0
  40. package/dist/tools/audit-workspace.d.ts +2 -0
  41. package/dist/tools/audit-workspace.js +86 -0
  42. package/dist/tools/audit-workspace.js.map +1 -0
  43. package/dist/tools/checkpoint.d.ts +2 -0
  44. package/dist/tools/checkpoint.js +108 -0
  45. package/dist/tools/checkpoint.js.map +1 -0
  46. package/dist/tools/clarify-intent.d.ts +2 -0
  47. package/dist/tools/clarify-intent.js +180 -0
  48. package/dist/tools/clarify-intent.js.map +1 -0
  49. package/dist/tools/enrich-agent-task.d.ts +2 -0
  50. package/dist/tools/enrich-agent-task.js +97 -0
  51. package/dist/tools/enrich-agent-task.js.map +1 -0
  52. package/dist/tools/generate-scorecard.d.ts +2 -0
  53. package/dist/tools/generate-scorecard.js +617 -0
  54. package/dist/tools/generate-scorecard.js.map +1 -0
  55. package/dist/tools/log-correction.d.ts +2 -0
  56. package/dist/tools/log-correction.js +76 -0
  57. package/dist/tools/log-correction.js.map +1 -0
  58. package/dist/tools/onboard-project.d.ts +2 -0
  59. package/dist/tools/onboard-project.js +179 -0
  60. package/dist/tools/onboard-project.js.map +1 -0
  61. package/dist/tools/preflight-check.d.ts +2 -0
  62. package/dist/tools/preflight-check.js +229 -0
  63. package/dist/tools/preflight-check.js.map +1 -0
  64. package/dist/tools/prompt-score.d.ts +2 -0
  65. package/dist/tools/prompt-score.js +132 -0
  66. package/dist/tools/prompt-score.js.map +1 -0
  67. package/dist/tools/scan-sessions.d.ts +2 -0
  68. package/dist/tools/scan-sessions.js +182 -0
  69. package/dist/tools/scan-sessions.js.map +1 -0
  70. package/dist/tools/scope-work.d.ts +2 -0
  71. package/dist/tools/scope-work.js +214 -0
  72. package/dist/tools/scope-work.js.map +1 -0
  73. package/dist/tools/search-history.d.ts +2 -0
  74. package/dist/tools/search-history.js +130 -0
  75. package/dist/tools/search-history.js.map +1 -0
  76. package/dist/tools/sequence-tasks.d.ts +2 -0
  77. package/dist/tools/sequence-tasks.js +165 -0
  78. package/dist/tools/sequence-tasks.js.map +1 -0
  79. package/dist/tools/session-handoff.d.ts +2 -0
  80. package/dist/tools/session-handoff.js +113 -0
  81. package/dist/tools/session-handoff.js.map +1 -0
  82. package/dist/tools/session-health.d.ts +2 -0
  83. package/dist/tools/session-health.js +111 -0
  84. package/dist/tools/session-health.js.map +1 -0
  85. package/dist/tools/session-stats.d.ts +2 -0
  86. package/dist/tools/session-stats.js +112 -0
  87. package/dist/tools/session-stats.js.map +1 -0
  88. package/dist/tools/sharpen-followup.d.ts +2 -0
  89. package/dist/tools/sharpen-followup.js +192 -0
  90. package/dist/tools/sharpen-followup.js.map +1 -0
  91. package/dist/tools/timeline-view.d.ts +2 -0
  92. package/dist/tools/timeline-view.js +165 -0
  93. package/dist/tools/timeline-view.js.map +1 -0
  94. package/dist/tools/token-audit.d.ts +2 -0
  95. package/dist/tools/token-audit.js +227 -0
  96. package/dist/tools/token-audit.js.map +1 -0
  97. package/dist/tools/verify-completion.d.ts +2 -0
  98. package/dist/tools/verify-completion.js +154 -0
  99. package/dist/tools/verify-completion.js.map +1 -0
  100. package/dist/tools/what-changed.d.ts +2 -0
  101. package/dist/tools/what-changed.js +40 -0
  102. package/dist/tools/what-changed.js.map +1 -0
  103. package/dist/types.d.ts +78 -0
  104. package/dist/types.js +2 -0
  105. package/dist/types.js.map +1 -0
  106. package/package.json +52 -0
  107. package/src/cli/init.ts +133 -0
  108. package/src/index.ts +135 -0
  109. package/src/lib/config.ts +157 -0
  110. package/src/lib/embeddings.ts +118 -0
  111. package/src/lib/files.ts +59 -0
  112. package/src/lib/git-extractor.ts +137 -0
  113. package/src/lib/git.ts +89 -0
  114. package/src/lib/session-parser.ts +325 -0
  115. package/src/lib/state.ts +86 -0
  116. package/src/lib/timeline-db.ts +490 -0
  117. package/src/lib/triage.ts +255 -0
  118. package/src/profiles.ts +70 -0
  119. package/src/templates/config.yml +23 -0
  120. package/src/templates/triage.yml +27 -0
  121. package/src/tools/audit-workspace.ts +97 -0
  122. package/src/tools/checkpoint.ts +119 -0
  123. package/src/tools/clarify-intent.ts +191 -0
  124. package/src/tools/enrich-agent-task.ts +108 -0
  125. package/src/tools/generate-scorecard.ts +673 -0
  126. package/src/tools/log-correction.ts +89 -0
  127. package/src/tools/onboard-project.ts +214 -0
  128. package/src/tools/preflight-check.ts +263 -0
  129. package/src/tools/prompt-score.ts +150 -0
  130. package/src/tools/scan-sessions.ts +209 -0
  131. package/src/tools/scope-work.ts +238 -0
  132. package/src/tools/search-history.ts +145 -0
  133. package/src/tools/sequence-tasks.ts +182 -0
  134. package/src/tools/session-handoff.ts +125 -0
  135. package/src/tools/session-health.ts +107 -0
  136. package/src/tools/session-stats.ts +134 -0
  137. package/src/tools/sharpen-followup.ts +200 -0
  138. package/src/tools/timeline-view.ts +181 -0
  139. package/src/tools/token-audit.ts +259 -0
  140. package/src/tools/verify-completion.ts +159 -0
  141. package/src/tools/what-changed.ts +48 -0
  142. package/src/types.ts +87 -0
@@ -0,0 +1,673 @@
1
+ // =============================================================================
2
+ // generate_scorecard — 12-category prompt discipline report cards (PDF/Markdown)
3
+ // =============================================================================
4
+
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { z } from "zod";
7
+ import {
8
+ findSessionDirs,
9
+ findSessionFiles,
10
+ parseSession,
11
+ type TimelineEvent,
12
+ } from "../lib/session-parser.js";
13
+
14
+ // ── Types ──────────────────────────────────────────────────────────────────
15
+
16
+ interface CategoryScore {
17
+ name: string;
18
+ score: number;
19
+ grade: string;
20
+ evidence: string;
21
+ examples?: { good?: string[]; bad?: string[] };
22
+ }
23
+
24
+ interface Scorecard {
25
+ project: string;
26
+ period: string;
27
+ date: string;
28
+ overall: number;
29
+ overallGrade: string;
30
+ categories: CategoryScore[];
31
+ highlights: { best: CategoryScore; worst: CategoryScore };
32
+ }
33
+
34
+ // ── Grading ────────────────────────────────────────────────────────────────
35
+
36
+ function letterGrade(score: number): string {
37
+ if (score >= 95) return "A+";
38
+ if (score >= 90) return "A";
39
+ if (score >= 85) return "A-";
40
+ if (score >= 80) return "B+";
41
+ if (score >= 75) return "B";
42
+ if (score >= 70) return "B-";
43
+ if (score >= 65) return "C+";
44
+ if (score >= 60) return "C";
45
+ if (score >= 55) return "C-";
46
+ if (score >= 50) return "D";
47
+ return "F";
48
+ }
49
+
50
+ function clamp(v: number): number {
51
+ return Math.max(0, Math.min(100, Math.round(v)));
52
+ }
53
+
54
+ // ── Helpers ────────────────────────────────────────────────────────────────
55
+
56
+ const PATH_RE = /(?:\/[\w./-]+\.\w{1,6}|\b\w+\.\w{2,6}\b)/;
57
+ const FILE_EXT_RE = /\.\b(?:ts|tsx|js|jsx|py|rs|go|rb|java|c|cpp|h|css|scss|html|json|yaml|yml|toml|md|sql|sh)\b/;
58
+ const CORRECTION_PATTERNS = [/\bno\b/i, /\bwrong\b/i, /\bnot that\b/i, /\bi meant\b/i, /\bactually\b/i, /\binstead\b/i, /\bundo\b/i, /\brevert\b/i];
59
+
60
+ interface ParsedSession {
61
+ id: string;
62
+ events: TimelineEvent[];
63
+ userMessages: TimelineEvent[];
64
+ assistantMessages: TimelineEvent[];
65
+ toolCalls: TimelineEvent[];
66
+ corrections: TimelineEvent[];
67
+ compactions: TimelineEvent[];
68
+ commits: TimelineEvent[];
69
+ subAgentSpawns: TimelineEvent[];
70
+ durationMinutes: number;
71
+ }
72
+
73
+ function classifyEvents(events: TimelineEvent[]): ParsedSession {
74
+ const userMessages = events.filter((e) => e.type === "user_prompt");
75
+ const assistantMessages = events.filter((e) => e.type === "assistant_response");
76
+ const toolCalls = events.filter((e) => e.type === "tool_call");
77
+ const corrections = events.filter((e) => e.type === "correction");
78
+ const compactions = events.filter((e) => e.type === "compaction");
79
+ const commits = events.filter((e) => e.type === "git_commit");
80
+ const subAgentSpawns = events.filter((e) => e.type === "sub_agent_spawn");
81
+
82
+ let durationMinutes = 0;
83
+ if (events.length >= 2) {
84
+ const first = new Date(events[0].timestamp).getTime();
85
+ const last = new Date(events[events.length - 1].timestamp).getTime();
86
+ if (!isNaN(first) && !isNaN(last)) {
87
+ durationMinutes = (last - first) / 60000;
88
+ }
89
+ }
90
+
91
+ return {
92
+ id: events[0]?.session_id ?? "unknown",
93
+ events,
94
+ userMessages,
95
+ assistantMessages,
96
+ toolCalls,
97
+ corrections,
98
+ compactions,
99
+ commits,
100
+ subAgentSpawns,
101
+ durationMinutes,
102
+ };
103
+ }
104
+
105
+ function hasFileRef(text: string): boolean {
106
+ return PATH_RE.test(text) || FILE_EXT_RE.test(text);
107
+ }
108
+
109
+ function pct(num: number, den: number): number {
110
+ return den === 0 ? 100 : Math.round((num / den) * 100);
111
+ }
112
+
113
+ // ── Scoring Functions ──────────────────────────────────────────────────────
114
+
115
+ function scorePlans(sessions: ParsedSession[]): CategoryScore {
116
+ if (sessions.length === 0) return { name: "Plans", score: 75, grade: "B", evidence: "No sessions to analyze" };
117
+
118
+ let planned = 0;
119
+ for (const s of sessions) {
120
+ const first3 = s.userMessages.slice(0, 3);
121
+ const hasPlanning = first3.some((m) => m.content.length > 100 && hasFileRef(m.content));
122
+ if (hasPlanning) planned++;
123
+ }
124
+ const score = clamp(pct(planned, sessions.length));
125
+ return {
126
+ name: "Plans",
127
+ score,
128
+ grade: letterGrade(score),
129
+ evidence: `${planned}/${sessions.length} sessions began with file-specific planning prompts (>100 chars with file references).`,
130
+ };
131
+ }
132
+
133
+ function scoreClarification(sessions: ParsedSession[]): CategoryScore {
134
+ let specific = 0, total = 0;
135
+ for (const s of sessions) {
136
+ for (const m of s.userMessages) {
137
+ total++;
138
+ if (hasFileRef(m.content)) specific++;
139
+ }
140
+ }
141
+ const score = clamp(pct(specific, total));
142
+ return {
143
+ name: "Clarification",
144
+ score,
145
+ grade: letterGrade(score),
146
+ evidence: `${specific}/${total} user prompts contained file paths or specific identifiers.`,
147
+ };
148
+ }
149
+
150
+ function scoreDelegation(sessions: ParsedSession[]): CategoryScore {
151
+ let total = 0, quality = 0;
152
+ for (const s of sessions) {
153
+ for (const e of s.subAgentSpawns) {
154
+ total++;
155
+ if (e.content.length > 200) quality++;
156
+ }
157
+ }
158
+ if (total === 0) return { name: "Delegation", score: 75, grade: "B", evidence: "No sub-agent spawns detected. Default score." };
159
+ const score = clamp(pct(quality, total));
160
+ return {
161
+ name: "Delegation",
162
+ score,
163
+ grade: letterGrade(score),
164
+ evidence: `${quality}/${total} sub-agent tasks had detailed descriptions (>200 chars).`,
165
+ };
166
+ }
167
+
168
+ function scoreFollowUpSpecificity(sessions: ParsedSession[]): CategoryScore {
169
+ let followUps = 0, specific = 0;
170
+ const badExamples: string[] = [];
171
+ const goodExamples: string[] = [];
172
+
173
+ for (const s of sessions) {
174
+ for (let i = 0; i < s.events.length; i++) {
175
+ const ev = s.events[i];
176
+ if (ev.type !== "user_prompt") continue;
177
+ // Check if preceded by assistant
178
+ const prev = s.events.slice(0, i).reverse().find((e) => e.type === "assistant_response" || e.type === "user_prompt");
179
+ if (prev?.type !== "assistant_response") continue;
180
+
181
+ followUps++;
182
+ if (hasFileRef(ev.content) || ev.content.length >= 50) {
183
+ specific++;
184
+ if (goodExamples.length < 3 && hasFileRef(ev.content)) goodExamples.push(ev.content.slice(0, 120));
185
+ } else {
186
+ if (badExamples.length < 3) badExamples.push(ev.content.slice(0, 80));
187
+ }
188
+ }
189
+ }
190
+ const score = clamp(pct(specific, followUps));
191
+ return {
192
+ name: "Follow-up Specificity",
193
+ score,
194
+ grade: letterGrade(score),
195
+ evidence: `${specific}/${followUps} follow-up prompts had specific file references or sufficient detail.`,
196
+ examples: { good: goodExamples.length ? goodExamples : undefined, bad: badExamples.length ? badExamples : undefined },
197
+ };
198
+ }
199
+
200
+ function scoreTokenEfficiency(sessions: ParsedSession[]): CategoryScore {
201
+ let totalCalls = 0, totalFiles = 0;
202
+ for (const s of sessions) {
203
+ totalCalls += s.toolCalls.length;
204
+ const files = new Set<string>();
205
+ for (const tc of s.toolCalls) {
206
+ const match = tc.content.match(/(?:file_path|path)["']?\s*[:=]\s*["']([^"']+)/);
207
+ if (match) files.add(match[1]);
208
+ }
209
+ totalFiles += files.size || 1;
210
+ }
211
+ // Ratio: lower tool_calls per file = better. Ideal ~5-10 calls per file.
212
+ const ratio = totalCalls / totalFiles;
213
+ let score: number;
214
+ if (ratio <= 5) score = 100;
215
+ else if (ratio <= 10) score = 90;
216
+ else if (ratio <= 20) score = 75;
217
+ else if (ratio <= 40) score = 60;
218
+ else score = 40;
219
+
220
+ // Deduct for sessions with >200 tool calls
221
+ const bloated = sessions.filter((s) => s.toolCalls.length > 200).length;
222
+ if (bloated > 0) score = clamp(score - bloated * 10);
223
+
224
+ return {
225
+ name: "Token Efficiency",
226
+ score: clamp(score),
227
+ grade: letterGrade(clamp(score)),
228
+ evidence: `${totalCalls} tool calls across ${totalFiles} unique files (ratio: ${ratio.toFixed(1)}). ${bloated} session(s) exceeded 200 tool calls.`,
229
+ };
230
+ }
231
+
232
+ function scoreSequencing(sessions: ParsedSession[]): CategoryScore {
233
+ let totalSwitches = 0, totalPrompts = 0;
234
+ for (const s of sessions) {
235
+ let lastArea = "";
236
+ for (const m of s.userMessages) {
237
+ totalPrompts++;
238
+ const pathMatch = m.content.match(/(?:\/[\w./-]+)/);
239
+ const area = pathMatch ? pathMatch[0].split("/").slice(0, -1).join("/") : "";
240
+ if (area && lastArea && area !== lastArea) totalSwitches++;
241
+ if (area) lastArea = area;
242
+ }
243
+ }
244
+ // Fewer switches = better. Target: <10% switch rate
245
+ const switchRate = totalPrompts > 0 ? totalSwitches / totalPrompts : 0;
246
+ let score: number;
247
+ if (switchRate <= 0.05) score = 100;
248
+ else if (switchRate <= 0.1) score = 90;
249
+ else if (switchRate <= 0.2) score = 75;
250
+ else if (switchRate <= 0.35) score = 60;
251
+ else score = 45;
252
+
253
+ return {
254
+ name: "Sequencing",
255
+ score: clamp(score),
256
+ grade: letterGrade(clamp(score)),
257
+ evidence: `${totalSwitches} topic switches across ${totalPrompts} prompts (${(switchRate * 100).toFixed(0)}% switch rate).`,
258
+ };
259
+ }
260
+
261
+ function scoreCompactionManagement(sessions: ParsedSession[]): CategoryScore {
262
+ let totalCompactions = 0, covered = 0;
263
+ for (const s of sessions) {
264
+ if (s.compactions.length === 0) continue;
265
+ for (const c of s.compactions) {
266
+ totalCompactions++;
267
+ const cIdx = s.events.indexOf(c);
268
+ const nearby = s.events.slice(Math.max(0, cIdx - 10), cIdx);
269
+ if (nearby.some((e) => e.type === "git_commit")) covered++;
270
+ }
271
+ }
272
+ if (totalCompactions === 0) return { name: "Compaction Management", score: 100, grade: "A+", evidence: "No compactions needed — sessions stayed manageable." };
273
+ const score = clamp(pct(covered, totalCompactions));
274
+ return {
275
+ name: "Compaction Management",
276
+ score,
277
+ grade: letterGrade(score),
278
+ evidence: `${covered}/${totalCompactions} compactions were preceded by a commit within 10 messages.`,
279
+ };
280
+ }
281
+
282
+ function scoreSessionLifecycle(sessions: ParsedSession[]): CategoryScore {
283
+ if (sessions.length === 0) return { name: "Session Lifecycle", score: 75, grade: "B", evidence: "No sessions." };
284
+ let good = 0;
285
+ for (const s of sessions) {
286
+ if (s.durationMinutes <= 0) { good++; continue; }
287
+ if (s.durationMinutes > 180 && s.commits.length === 0) continue; // bad
288
+ const commitInterval = s.commits.length > 0 ? s.durationMinutes / s.commits.length : s.durationMinutes;
289
+ if (commitInterval <= 30) good++;
290
+ else if (commitInterval <= 60) good += 0.5;
291
+ }
292
+ const score = clamp(pct(Math.round(good), sessions.length));
293
+ return {
294
+ name: "Session Lifecycle",
295
+ score,
296
+ grade: letterGrade(score),
297
+ evidence: `${Math.round(good)}/${sessions.length} sessions had healthy commit frequency (every 15-30 min).`,
298
+ };
299
+ }
300
+
301
+ function scoreErrorRecovery(sessions: ParsedSession[]): CategoryScore {
302
+ let totalCorrections = 0, fastRecoveries = 0, totalMessages = 0;
303
+ for (const s of sessions) {
304
+ totalMessages += s.events.length;
305
+ for (const c of s.corrections) {
306
+ totalCorrections++;
307
+ const cIdx = s.events.indexOf(c);
308
+ const after = s.events.slice(cIdx + 1, cIdx + 3);
309
+ if (after.some((e) => e.type === "tool_call" || e.type === "assistant_response")) fastRecoveries++;
310
+ }
311
+ }
312
+ if (totalCorrections === 0) return { name: "Error Recovery", score: 95, grade: "A", evidence: "No corrections needed." };
313
+ const correctionRate = totalMessages > 0 ? totalCorrections / totalMessages : 0;
314
+ let score = clamp(100 - correctionRate * 500);
315
+ if (totalCorrections > 0) {
316
+ const recoveryBonus = pct(fastRecoveries, totalCorrections) * 0.2;
317
+ score = clamp(score + recoveryBonus);
318
+ }
319
+ return {
320
+ name: "Error Recovery",
321
+ score,
322
+ grade: letterGrade(score),
323
+ evidence: `${totalCorrections} corrections (${(correctionRate * 100).toFixed(1)}% of messages). ${fastRecoveries} recovered within 2 messages.`,
324
+ };
325
+ }
326
+
327
+ function scoreWorkspaceHygiene(sessions: ParsedSession[]): CategoryScore {
328
+ let bonus = 0;
329
+ for (const s of sessions) {
330
+ const allContent = s.events.map((e) => e.content).join(" ");
331
+ if (/\.claude\//.test(allContent) || /CLAUDE\.md/.test(allContent)) bonus++;
332
+ }
333
+ const score = clamp(75 + (bonus > 0 ? Math.min(bonus * 5, 20) : 0));
334
+ return {
335
+ name: "Workspace Hygiene",
336
+ score,
337
+ grade: letterGrade(score),
338
+ evidence: `Default baseline 75. ${bonus} session(s) referenced .claude/ workspace docs (+bonus).`,
339
+ };
340
+ }
341
+
342
+ function scoreCrossSessionContinuity(sessions: ParsedSession[]): CategoryScore {
343
+ if (sessions.length === 0) return { name: "Cross-Session Continuity", score: 75, grade: "B", evidence: "No sessions." };
344
+ let good = 0;
345
+ for (const s of sessions) {
346
+ const first3Tools = s.toolCalls.slice(0, 3);
347
+ const readsContext = first3Tools.some((tc) =>
348
+ /CLAUDE\.md|\.claude\/|checkpoint|context|README/i.test(tc.content)
349
+ );
350
+ if (readsContext) good++;
351
+ }
352
+ const score = clamp(pct(good, sessions.length));
353
+ return {
354
+ name: "Cross-Session Continuity",
355
+ score,
356
+ grade: letterGrade(score),
357
+ evidence: `${good}/${sessions.length} sessions started by reading project context docs.`,
358
+ };
359
+ }
360
+
361
+ function scoreVerification(sessions: ParsedSession[]): CategoryScore {
362
+ if (sessions.length === 0) return { name: "Verification", score: 75, grade: "B", evidence: "No sessions." };
363
+ let verified = 0;
364
+ for (const s of sessions) {
365
+ const totalEvents = s.events.length;
366
+ const tail = s.events.slice(Math.max(0, Math.floor(totalEvents * 0.9)));
367
+ const hasVerification = tail.some((e) =>
368
+ e.type === "tool_call" && /test|build|lint|check|verify|jest|vitest|pytest|cargo.test/i.test(e.content)
369
+ );
370
+ if (hasVerification) verified++;
371
+ }
372
+ const score = clamp(pct(verified, sessions.length));
373
+ return {
374
+ name: "Verification",
375
+ score,
376
+ grade: letterGrade(score),
377
+ evidence: `${verified}/${sessions.length} sessions ran tests/builds in the final 10% of events.`,
378
+ };
379
+ }
380
+
381
+ // ── Main Scoring ───────────────────────────────────────────────────────────
382
+
383
+ function computeScorecard(
384
+ sessions: ParsedSession[],
385
+ project: string,
386
+ period: string,
387
+ ): Scorecard {
388
+ const categories: CategoryScore[] = [
389
+ scorePlans(sessions),
390
+ scoreClarification(sessions),
391
+ scoreDelegation(sessions),
392
+ scoreFollowUpSpecificity(sessions),
393
+ scoreTokenEfficiency(sessions),
394
+ scoreSequencing(sessions),
395
+ scoreCompactionManagement(sessions),
396
+ scoreSessionLifecycle(sessions),
397
+ scoreErrorRecovery(sessions),
398
+ scoreWorkspaceHygiene(sessions),
399
+ scoreCrossSessionContinuity(sessions),
400
+ scoreVerification(sessions),
401
+ ];
402
+
403
+ const overall = clamp(Math.round(categories.reduce((s, c) => s + c.score, 0) / categories.length));
404
+ const sorted = [...categories].sort((a, b) => b.score - a.score);
405
+
406
+ return {
407
+ project,
408
+ period,
409
+ date: new Date().toISOString().slice(0, 10),
410
+ overall,
411
+ overallGrade: letterGrade(overall),
412
+ categories,
413
+ highlights: { best: sorted[0], worst: sorted[sorted.length - 1] },
414
+ };
415
+ }
416
+
417
+ // ── Markdown Output ────────────────────────────────────────────────────────
418
+
419
+ function toMarkdown(sc: Scorecard): string {
420
+ const lines: string[] = [];
421
+ lines.push(`# 📊 Prompt Discipline Scorecard`);
422
+ lines.push(`**Project:** ${sc.project} | **Period:** ${sc.period} (${sc.date}) | **Overall: ${sc.overallGrade} (${sc.overall}/100)**\n`);
423
+
424
+ lines.push(`## Category Scores`);
425
+ lines.push(`| # | Category | Score | Grade |`);
426
+ lines.push(`|---|----------|-------|-------|`);
427
+ sc.categories.forEach((c, i) => {
428
+ lines.push(`| ${i + 1} | ${c.name} | ${c.score} | ${c.grade} |`);
429
+ });
430
+
431
+ lines.push(`\n## Highlights`);
432
+ lines.push(`- 🏆 **Best:** ${sc.highlights.best.name} (${sc.highlights.best.grade}) — ${sc.highlights.best.evidence}`);
433
+ lines.push(`- ⚠️ **Worst:** ${sc.highlights.worst.name} (${sc.highlights.worst.grade}) — ${sc.highlights.worst.evidence}`);
434
+
435
+ lines.push(`\n## Detailed Breakdown`);
436
+ sc.categories.forEach((c, i) => {
437
+ lines.push(`\n### ${i + 1}. ${c.name} — ${c.grade} (${c.score}/100)`);
438
+ lines.push(`Evidence: ${c.evidence}`);
439
+ if (c.examples?.bad?.length) {
440
+ lines.push(`\nExamples of vague follow-ups:`);
441
+ c.examples.bad.forEach((e) => lines.push(`- ❌ "${e}"`));
442
+ }
443
+ if (c.examples?.good?.length) {
444
+ lines.push(`\nExamples of specific follow-ups:`);
445
+ c.examples.good.forEach((e) => lines.push(`- ✅ "${e}"`));
446
+ }
447
+ });
448
+
449
+ return lines.join("\n");
450
+ }
451
+
452
+ // ── HTML / PDF Output ──────────────────────────────────────────────────────
453
+
454
+ function gradeColor(grade: string): string {
455
+ if (grade.startsWith("A")) return "#22c55e";
456
+ if (grade.startsWith("B")) return "#eab308";
457
+ if (grade.startsWith("C")) return "#f97316";
458
+ return "#ef4444";
459
+ }
460
+
461
+ function generateRadarSVG(categories: CategoryScore[]): string {
462
+ const cx = 200, cy = 200, r = 150;
463
+ const n = categories.length;
464
+ const points = categories.map((c, i) => {
465
+ const angle = (Math.PI * 2 * i) / n - Math.PI / 2;
466
+ const dist = (c.score / 100) * r;
467
+ return { x: cx + dist * Math.cos(angle), y: cy + dist * Math.sin(angle) };
468
+ });
469
+ const gridLines = [0.25, 0.5, 0.75, 1].map((f) => {
470
+ const gr = r * f;
471
+ const pts = Array.from({ length: n }, (_, i) => {
472
+ const angle = (Math.PI * 2 * i) / n - Math.PI / 2;
473
+ return `${cx + gr * Math.cos(angle)},${cy + gr * Math.sin(angle)}`;
474
+ }).join(" ");
475
+ return `<polygon points="${pts}" fill="none" stroke="#e5e7eb" stroke-width="1"/>`;
476
+ }).join("");
477
+
478
+ const labels = categories.map((c, i) => {
479
+ const angle = (Math.PI * 2 * i) / n - Math.PI / 2;
480
+ const lx = cx + (r + 30) * Math.cos(angle);
481
+ const ly = cy + (r + 30) * Math.sin(angle);
482
+ const anchor = Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1 ? "middle" : angle > -Math.PI / 2 && angle < Math.PI / 2 ? "start" : "end";
483
+ return `<text x="${lx}" y="${ly}" text-anchor="${anchor}" font-size="10" fill="#6b7280">${c.name.slice(0, 12)}</text>`;
484
+ }).join("");
485
+
486
+ const polygon = points.map((p) => `${p.x},${p.y}`).join(" ");
487
+
488
+ return `<svg viewBox="0 0 400 400" width="400" height="400" xmlns="http://www.w3.org/2000/svg">
489
+ ${gridLines}
490
+ <polygon points="${polygon}" fill="rgba(59,130,246,0.2)" stroke="#3b82f6" stroke-width="2"/>
491
+ ${points.map((p) => `<circle cx="${p.x}" cy="${p.y}" r="4" fill="#3b82f6"/>`).join("")}
492
+ ${labels}
493
+ </svg>`;
494
+ }
495
+
496
+ function toHTML(sc: Scorecard): string {
497
+ const radar = generateRadarSVG(sc.categories);
498
+ const rows = sc.categories.map((c, i) => `
499
+ <tr>
500
+ <td style="padding:8px;border-bottom:1px solid #e5e7eb">${i + 1}</td>
501
+ <td style="padding:8px;border-bottom:1px solid #e5e7eb;font-weight:600">${c.name}</td>
502
+ <td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:center">${c.score}</td>
503
+ <td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:center">
504
+ <span style="background:${gradeColor(c.grade)};color:white;padding:2px 8px;border-radius:4px;font-weight:700">${c.grade}</span>
505
+ </td>
506
+ </tr>`).join("");
507
+
508
+ const details = sc.categories.map((c, i) => {
509
+ let html = `<div style="margin-bottom:16px"><h3 style="margin:0 0 4px">${i + 1}. ${c.name} — <span style="color:${gradeColor(c.grade)}">${c.grade}</span> (${c.score}/100)</h3><p style="color:#6b7280;margin:0">${c.evidence}</p>`;
510
+ if (c.examples?.bad?.length) {
511
+ html += `<div style="margin-top:6px">${c.examples.bad.map((e) => `<div style="color:#ef4444;font-size:13px">❌ "${e}"</div>`).join("")}</div>`;
512
+ }
513
+ if (c.examples?.good?.length) {
514
+ html += `<div style="margin-top:4px">${c.examples.good.map((e) => `<div style="color:#22c55e;font-size:13px">✅ "${e}"</div>`).join("")}</div>`;
515
+ }
516
+ return html + `</div>`;
517
+ }).join("");
518
+
519
+ 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">
520
+ <div style="background:linear-gradient(135deg,#1e293b,#0f172a);color:white;padding:32px 40px;display:flex;align-items:center;justify-content:space-between">
521
+ <div>
522
+ <h1 style="margin:0;font-size:28px">📊 Prompt Discipline Scorecard</h1>
523
+ <p style="margin:8px 0 0;opacity:0.8">Project: <strong>${sc.project}</strong> | Period: ${sc.period} | ${sc.date}</p>
524
+ </div>
525
+ <div style="width:100px;height:100px;border-radius:50%;background:${gradeColor(sc.overallGrade)};display:flex;align-items:center;justify-content:center;flex-direction:column">
526
+ <div style="font-size:28px;font-weight:800;line-height:1">${sc.overallGrade}</div>
527
+ <div style="font-size:14px;opacity:0.9">${sc.overall}/100</div>
528
+ </div>
529
+ </div>
530
+ <div style="padding:32px 40px;display:flex;gap:40px;flex-wrap:wrap">
531
+ <div style="flex:1;min-width:300px">
532
+ <h2 style="margin:0 0 12px">Category Scores</h2>
533
+ <table style="width:100%;border-collapse:collapse;font-size:14px">
534
+ <thead><tr style="background:#f9fafb"><th style="padding:8px;text-align:left">#</th><th style="padding:8px;text-align:left">Category</th><th style="padding:8px;text-align:center">Score</th><th style="padding:8px;text-align:center">Grade</th></tr></thead>
535
+ <tbody>${rows}</tbody>
536
+ </table>
537
+ </div>
538
+ <div style="flex:0 0 auto">${radar}</div>
539
+ </div>
540
+ <div style="padding:0 40px 20px">
541
+ <div style="background:#f0fdf4;border-left:4px solid #22c55e;padding:12px 16px;margin-bottom:8px;border-radius:4px">🏆 <strong>Best:</strong> ${sc.highlights.best.name} (${sc.highlights.best.grade}) — ${sc.highlights.best.evidence}</div>
542
+ <div style="background:#fef2f2;border-left:4px solid #ef4444;padding:12px 16px;border-radius:4px">⚠️ <strong>Needs work:</strong> ${sc.highlights.worst.name} (${sc.highlights.worst.grade}) — ${sc.highlights.worst.evidence}</div>
543
+ </div>
544
+ <div style="padding:20px 40px 40px">
545
+ <h2 style="margin:0 0 16px">Detailed Breakdown</h2>
546
+ ${details}
547
+ </div>
548
+ </body></html>`;
549
+ }
550
+
551
+ async function generatePDF(html: string, outputPath: string): Promise<void> {
552
+ const { chromium } = await import("playwright" as string) as any;
553
+ const browser = await chromium.launch();
554
+ const page = await browser.newPage();
555
+ await page.setContent(html, { waitUntil: "networkidle" });
556
+ await page.pdf({
557
+ path: outputPath,
558
+ format: "A4",
559
+ margin: { top: "1cm", bottom: "1cm", left: "1cm", right: "1cm" },
560
+ });
561
+ await browser.close();
562
+ }
563
+
564
+ // ── Session Loading ────────────────────────────────────────────────────────
565
+
566
+ function loadSessions(opts: {
567
+ project?: string;
568
+ sessionId?: string;
569
+ since?: string;
570
+ period: string;
571
+ }): ParsedSession[] {
572
+ const dirs = findSessionDirs();
573
+ let targetDirs = dirs;
574
+
575
+ if (opts.project) {
576
+ targetDirs = dirs.filter((d) =>
577
+ d.projectName.toLowerCase().includes(opts.project!.toLowerCase()) ||
578
+ d.project.toLowerCase().includes(opts.project!.toLowerCase())
579
+ );
580
+ }
581
+
582
+ // Determine time filter
583
+ let sinceDate: Date | null = null;
584
+ if (opts.since) {
585
+ const relMatch = opts.since.match(/^(\d+)\s*days?$/i);
586
+ if (relMatch) {
587
+ sinceDate = new Date(Date.now() - parseInt(relMatch[1]) * 86400000);
588
+ } else {
589
+ sinceDate = new Date(opts.since);
590
+ }
591
+ } else {
592
+ const now = new Date();
593
+ switch (opts.period) {
594
+ case "day": sinceDate = new Date(now.getTime() - 86400000); break;
595
+ case "week": sinceDate = new Date(now.getTime() - 7 * 86400000); break;
596
+ case "month": sinceDate = new Date(now.getTime() - 30 * 86400000); break;
597
+ }
598
+ }
599
+
600
+ const sessions: ParsedSession[] = [];
601
+
602
+ for (const dir of targetDirs) {
603
+ const files = findSessionFiles(dir.sessionDir);
604
+ for (const f of files) {
605
+ if (opts.sessionId && f.sessionId !== opts.sessionId) continue;
606
+ if (sinceDate && f.mtime < sinceDate) continue;
607
+
608
+ try {
609
+ const events = parseSession(f.path, dir.project, dir.projectName);
610
+ if (events.length > 0) {
611
+ sessions.push(classifyEvents(events));
612
+ }
613
+ } catch {
614
+ // Skip unparseable files
615
+ }
616
+ }
617
+ }
618
+
619
+ return sessions;
620
+ }
621
+
622
+ // ── Tool Registration ──────────────────────────────────────────────────────
623
+
624
+ export function registerGenerateScorecard(server: McpServer): void {
625
+ server.tool(
626
+ "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.",
628
+ {
629
+ project: z.string().optional().describe("Project name to score. If omitted, scores current project."),
630
+ period: z.enum(["session", "day", "week", "month"]).default("day"),
631
+ session_id: z.string().optional().describe("Score a specific session by ID"),
632
+ since: z.string().optional().describe("Start date (ISO or relative like '7days')"),
633
+ output: z.enum(["pdf", "markdown"]).default("markdown"),
634
+ output_path: z.string().optional().describe("Where to save PDF. Default: /tmp/scorecard-{date}.pdf"),
635
+ },
636
+ async (params) => {
637
+ const sessions = loadSessions({
638
+ project: params.project,
639
+ sessionId: params.session_id,
640
+ since: params.since,
641
+ period: params.period,
642
+ });
643
+
644
+ 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
+ };
648
+ }
649
+
650
+ const projectName = params.project ?? sessions[0]?.events[0]?.project_name ?? "unknown";
651
+ const scorecard = computeScorecard(sessions, projectName, params.period);
652
+
653
+ if (params.output === "pdf") {
654
+ const html = toHTML(scorecard);
655
+ const outputPath = params.output_path ?? `/tmp/scorecard-${scorecard.date}.pdf`;
656
+ try {
657
+ await generatePDF(html, outputPath);
658
+ return {
659
+ content: [{ type: "text" as const, text: `✅ PDF scorecard saved to ${outputPath}\n\n${toMarkdown(scorecard)}` }],
660
+ };
661
+ } catch (err) {
662
+ return {
663
+ content: [{ type: "text" as const, text: `⚠️ PDF generation failed (${err}). Falling back to markdown:\n\n${toMarkdown(scorecard)}` }],
664
+ };
665
+ }
666
+ }
667
+
668
+ return {
669
+ content: [{ type: "text" as const, text: toMarkdown(scorecard) }],
670
+ };
671
+ },
672
+ );
673
+ }