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
@@ -2,8 +2,11 @@ import { z } from "zod";
2
2
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { run, getDiffFiles } from "../lib/git.js";
4
4
  import { PROJECT_DIR } from "../lib/files.js";
5
- import { existsSync } from "fs";
6
- import { join } from "path";
5
+ import { getConfig, type RelatedProject } from "../lib/config.js";
6
+ import { existsSync, readFileSync } from "fs";
7
+ import { execFileSync } from "child_process";
8
+ import { join, basename } from "path";
9
+ import { createHash } from "crypto";
7
10
 
8
11
  /** Sanitize user input for safe use in shell commands */
9
12
  function shellEscape(s: string): string {
@@ -53,6 +56,148 @@ function getExamplePattern(files: string): string {
53
56
  return run(`head -30 '${shellEscape(firstFile)}' 2>/dev/null || echo 'could not read file'`);
54
57
  }
55
58
 
59
+ // ---------------------------------------------------------------------------
60
+ // Cross-service awareness helpers
61
+ // ---------------------------------------------------------------------------
62
+
63
+ interface ContractEntry {
64
+ name: string;
65
+ kind: string; // e.g. "interface", "enum", "function", "type"
66
+ file: string;
67
+ summary?: string;
68
+ }
69
+
70
+ interface ContractFile {
71
+ entries?: ContractEntry[];
72
+ }
73
+
74
+ /** Extract meaningful keywords from a task description */
75
+ function extractKeywords(text: string): string[] {
76
+ const stopWords = new Set([
77
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
78
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
79
+ "should", "may", "might", "shall", "can", "need", "must", "to", "of",
80
+ "in", "for", "on", "with", "at", "by", "from", "as", "into", "about",
81
+ "that", "this", "it", "and", "or", "but", "not", "if", "then", "else",
82
+ "when", "up", "out", "so", "no", "all", "any", "each", "every",
83
+ "add", "create", "update", "delete", "remove", "fix", "implement",
84
+ "make", "change", "modify", "file", "files", "code", "test", "tests",
85
+ ]);
86
+ return text
87
+ .toLowerCase()
88
+ .replace(/[^a-z0-9]+/g, " ")
89
+ .split(/\s+/)
90
+ .filter(w => w.length > 2 && !stopWords.has(w));
91
+ }
92
+
93
+ /** Hash a project path the same way preflight indexes projects */
94
+ function projectHash(projectPath: string): string {
95
+ return createHash("sha256").update(projectPath).digest("hex").slice(0, 12);
96
+ }
97
+
98
+ /** Load contracts for a project if they exist */
99
+ function loadContracts(projectPath: string): ContractEntry[] {
100
+ const hash = projectHash(projectPath);
101
+ const homedir = process.env.HOME || process.env.USERPROFILE || "";
102
+ const contractsPath = join(homedir, ".preflight", "projects", hash, "contracts.json");
103
+ if (!existsSync(contractsPath)) return [];
104
+ try {
105
+ const data = JSON.parse(readFileSync(contractsPath, "utf-8")) as ContractFile;
106
+ return data.entries ?? [];
107
+ } catch {
108
+ return [];
109
+ }
110
+ }
111
+
112
+ /** Search git-tracked files in a related project for keyword matches */
113
+ function searchRelatedProjectFiles(projectPath: string, keywords: string[]): string[] {
114
+ if (!existsSync(projectPath)) return [];
115
+ try {
116
+ const allFiles = execFileSync("git", ["ls-files"], {
117
+ cwd: projectPath,
118
+ encoding: "utf-8",
119
+ timeout: 5000,
120
+ stdio: ["pipe", "pipe", "pipe"],
121
+ }).trim();
122
+ if (!allFiles) return [];
123
+ const fileList = allFiles.split("\n");
124
+ const matches: string[] = [];
125
+ for (const f of fileList) {
126
+ const lower = f.toLowerCase();
127
+ if (keywords.some(kw => lower.includes(kw))) {
128
+ matches.push(f);
129
+ if (matches.length >= 10) break;
130
+ }
131
+ }
132
+ return matches;
133
+ } catch {
134
+ return [];
135
+ }
136
+ }
137
+
138
+ /** Build cross-service context string for related projects */
139
+ function buildCrossServiceContext(taskDescription: string): string {
140
+ let relatedProjects: RelatedProject[];
141
+ try {
142
+ relatedProjects = getConfig().related_projects;
143
+ } catch {
144
+ relatedProjects = [];
145
+ }
146
+
147
+ // Fallback to env var if no config-based projects
148
+ if (relatedProjects.length === 0) {
149
+ const envRelated = process.env.PREFLIGHT_RELATED;
150
+ if (envRelated) {
151
+ relatedProjects = envRelated
152
+ .split(",")
153
+ .map(p => p.trim())
154
+ .filter(Boolean)
155
+ .map(p => ({ path: p, alias: basename(p) }));
156
+ }
157
+ }
158
+
159
+ if (relatedProjects.length === 0) return "";
160
+
161
+ const keywords = extractKeywords(taskDescription);
162
+ if (keywords.length === 0) return "";
163
+
164
+ const sections: string[] = [];
165
+
166
+ for (const project of relatedProjects) {
167
+ const items: string[] = [];
168
+
169
+ // Search contracts
170
+ const contracts = loadContracts(project.path);
171
+ for (const entry of contracts) {
172
+ const nameLower = entry.name.toLowerCase();
173
+ const fileLower = (entry.file || "").toLowerCase();
174
+ const summaryLower = (entry.summary || "").toLowerCase();
175
+ if (keywords.some(kw => nameLower.includes(kw) || fileLower.includes(kw) || summaryLower.includes(kw))) {
176
+ const label = entry.kind ? `${entry.kind} ${entry.name}` : entry.name;
177
+ items.push(` - ${label}${entry.file ? ` (${entry.file})` : ""}`);
178
+ if (items.length >= 8) break;
179
+ }
180
+ }
181
+
182
+ // Search files
183
+ const matchedFiles = searchRelatedProjectFiles(project.path, keywords);
184
+ for (const f of matchedFiles) {
185
+ const already = items.some(i => i.includes(f));
186
+ if (!already) {
187
+ items.push(` - file: ${f}`);
188
+ if (items.length >= 12) break;
189
+ }
190
+ }
191
+
192
+ if (items.length > 0) {
193
+ sections.push(`From ${project.alias}:\n${items.join("\n")}`);
194
+ }
195
+ }
196
+
197
+ if (sections.length === 0) return "";
198
+ return `\n\n📡 Cross-service context:\n${sections.join("\n\n")}`;
199
+ }
200
+
56
201
  export function registerEnrichAgentTask(server: McpServer): void {
57
202
  server.tool(
58
203
  "enrich_agent_task",
@@ -68,6 +213,8 @@ export function registerEnrichAgentTask(server: McpServer): void {
68
213
  const testFiles = findRelatedTests(area);
69
214
  const pattern = getExamplePattern(area.includes("test") ? testFiles : fileList);
70
215
 
216
+ const crossServiceContext = buildCrossServiceContext(task_description);
217
+
71
218
  const fileSummary = fileList
72
219
  ? fileList.split("\n").filter(Boolean).slice(0, 5).join(", ")
73
220
  : "Specify exact files";
@@ -100,7 +247,7 @@ Original: "${task_description}"
100
247
  - **Pattern**: Follow existing pattern above
101
248
  - **Tests**: ${testSummary}
102
249
  - **Scope**: Do NOT modify files outside target area
103
- - **Done when**: All relevant tests pass + \`${pm} tsc --noEmit\` clean`,
250
+ - **Done when**: All relevant tests pass + \`${pm} tsc --noEmit\` clean${crossServiceContext}`,
104
251
  }],
105
252
  };
106
253
  }
@@ -0,0 +1,332 @@
1
+ // =============================================================================
2
+ // estimate_cost — Estimate token usage and cost for a Claude Code session
3
+ // =============================================================================
4
+
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { z } from "zod";
7
+ import { readFileSync, statSync } from "node:fs";
8
+ import { basename } from "node:path";
9
+ import { findSessionDirs, findSessionFiles } from "../lib/session-parser.js";
10
+
11
+ // ── Pricing (per 1M tokens) ────────────────────────────────────────────────
12
+
13
+ const PRICING: Record<string, { input: number; output: number }> = {
14
+ "claude-sonnet-4": { input: 3.0, output: 15.0 },
15
+ "claude-opus-4": { input: 15.0, output: 75.0 },
16
+ "claude-haiku-3.5": { input: 0.8, output: 4.0 },
17
+ };
18
+
19
+ const DEFAULT_MODEL = "claude-sonnet-4";
20
+
21
+ const CORRECTION_SIGNALS = /\b(no[,.\s]|wrong|not that|i meant|actually|try again|revert|undo|that's not|not what i)\b/i;
22
+
23
+ const PREFLIGHT_TOOLS = new Set([
24
+ "preflight_check",
25
+ "clarify_intent",
26
+ "scope_work",
27
+ "sharpen_followup",
28
+ "token_audit",
29
+ "prompt_score",
30
+ ]);
31
+
32
+ // ── Helpers ─────────────────────────────────────────────────────────────────
33
+
34
+ function estimateTokens(text: string): number {
35
+ return Math.ceil(text.length / 4);
36
+ }
37
+
38
+ function extractText(content: unknown): string {
39
+ if (typeof content === "string") return content;
40
+ if (Array.isArray(content)) {
41
+ return content
42
+ .filter((b: any) => typeof b.text === "string")
43
+ .map((b: any) => b.text)
44
+ .join("\n");
45
+ }
46
+ return "";
47
+ }
48
+
49
+ function extractToolNames(content: unknown): string[] {
50
+ if (!Array.isArray(content)) return [];
51
+ return content
52
+ .filter((b: any) => b.type === "tool_use" && b.name)
53
+ .map((b: any) => b.name as string);
54
+ }
55
+
56
+ function formatTokens(n: number): string {
57
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
58
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
59
+ return String(n);
60
+ }
61
+
62
+ function formatCost(dollars: number): string {
63
+ if (dollars < 0.01) return `<$0.01`;
64
+ return `$${dollars.toFixed(2)}`;
65
+ }
66
+
67
+ function formatDuration(ms: number): string {
68
+ const mins = Math.floor(ms / 60_000);
69
+ if (mins < 60) return `${mins}m`;
70
+ const hours = Math.floor(mins / 60);
71
+ const rem = mins % 60;
72
+ return `${hours}h ${rem}m`;
73
+ }
74
+
75
+ interface SessionAnalysis {
76
+ inputTokens: number;
77
+ outputTokens: number;
78
+ promptCount: number;
79
+ toolCallCount: number;
80
+ corrections: number;
81
+ wastedOutputTokens: number;
82
+ preflightCalls: number;
83
+ preflightTokens: number;
84
+ firstTimestamp: string | null;
85
+ lastTimestamp: string | null;
86
+ }
87
+
88
+ function analyzeSessionFile(filePath: string): SessionAnalysis {
89
+ const content = readFileSync(filePath, "utf-8");
90
+ const lines = content.trim().split("\n").filter(Boolean);
91
+
92
+ const result: SessionAnalysis = {
93
+ inputTokens: 0,
94
+ outputTokens: 0,
95
+ promptCount: 0,
96
+ toolCallCount: 0,
97
+ corrections: 0,
98
+ wastedOutputTokens: 0,
99
+ preflightCalls: 0,
100
+ preflightTokens: 0,
101
+ firstTimestamp: null,
102
+ lastTimestamp: null,
103
+ };
104
+
105
+ let lastType = "";
106
+ let lastAssistantTokens = 0;
107
+
108
+ for (const line of lines) {
109
+ let obj: any;
110
+ try {
111
+ obj = JSON.parse(line);
112
+ } catch {
113
+ continue;
114
+ }
115
+
116
+ // Track timestamps
117
+ const ts = obj.timestamp;
118
+ if (ts) {
119
+ const tsStr = typeof ts === "string" ? ts : new Date(ts < 1e12 ? ts * 1000 : ts).toISOString();
120
+ if (!result.firstTimestamp) result.firstTimestamp = tsStr;
121
+ result.lastTimestamp = tsStr;
122
+ }
123
+
124
+ if (obj.type === "user") {
125
+ const text = extractText(obj.message?.content);
126
+ const tokens = estimateTokens(text);
127
+ result.inputTokens += tokens;
128
+ result.promptCount++;
129
+
130
+ // Correction detection
131
+ if (lastType === "assistant" && CORRECTION_SIGNALS.test(text)) {
132
+ result.corrections++;
133
+ result.wastedOutputTokens += lastAssistantTokens;
134
+ }
135
+ lastType = "user";
136
+ } else if (obj.type === "assistant") {
137
+ const msgContent = obj.message?.content;
138
+ const text = extractText(msgContent);
139
+ const tokens = estimateTokens(text);
140
+ result.outputTokens += tokens;
141
+ lastAssistantTokens = tokens;
142
+
143
+ // Tool calls
144
+ const toolNames = extractToolNames(msgContent);
145
+ result.toolCallCount += toolNames.length;
146
+
147
+ for (const name of toolNames) {
148
+ if (PREFLIGHT_TOOLS.has(name)) {
149
+ result.preflightCalls++;
150
+ // Estimate tool call tokens (name + args)
151
+ const toolBlocks = (msgContent as any[]).filter(
152
+ (b: any) => b.type === "tool_use" && b.name === name,
153
+ );
154
+ for (const tb of toolBlocks) {
155
+ result.preflightTokens += estimateTokens(
156
+ JSON.stringify(tb.input ?? {}),
157
+ );
158
+ }
159
+ }
160
+ }
161
+ lastType = "assistant";
162
+ } else if (obj.type === "tool_result") {
163
+ const text = extractText(obj.content);
164
+ const tokens = estimateTokens(text);
165
+ result.inputTokens += tokens;
166
+
167
+ // Check if this is a preflight tool result
168
+ if (obj.tool_use_id) {
169
+ // We can't perfectly match tool_use_id to name, so count tokens as preflight
170
+ // if they're small (typical preflight responses)
171
+ }
172
+ }
173
+ }
174
+
175
+ return result;
176
+ }
177
+
178
+ // ── Registration ────────────────────────────────────────────────────────────
179
+
180
+ export function registerEstimateCost(server: McpServer): void {
181
+ server.tool(
182
+ "estimate_cost",
183
+ "Estimate token usage and cost for the current session. Shows waste from vague prompts and savings from preflight checks.",
184
+ {
185
+ session_dir: z
186
+ .string()
187
+ .optional()
188
+ .describe("Path to session JSONL file (optional, uses latest if omitted)"),
189
+ model: z
190
+ .string()
191
+ .optional()
192
+ .describe(
193
+ "Pricing model to use (claude-sonnet-4, claude-opus-4, claude-haiku-3.5). Default: claude-sonnet-4",
194
+ ),
195
+ },
196
+ async ({ session_dir, model }) => {
197
+ const pricingModel = model && PRICING[model] ? model : DEFAULT_MODEL;
198
+ const pricing = PRICING[pricingModel]!;
199
+
200
+ // Find session file
201
+ let filePath: string;
202
+ if (session_dir) {
203
+ filePath = session_dir;
204
+ } else {
205
+ // Find latest session file
206
+ const dirs = findSessionDirs();
207
+ let latest: { path: string; mtime: Date } | null = null;
208
+ for (const dir of dirs) {
209
+ const files = findSessionFiles(dir.sessionDir);
210
+ for (const f of files) {
211
+ if (!latest || f.mtime > latest.mtime) {
212
+ latest = f;
213
+ }
214
+ }
215
+ }
216
+ if (!latest) {
217
+ return {
218
+ content: [
219
+ {
220
+ type: "text" as const,
221
+ text: "No session files found in ~/.claude/projects/",
222
+ },
223
+ ],
224
+ };
225
+ }
226
+ filePath = latest.path;
227
+ }
228
+
229
+ // Verify file exists
230
+ try {
231
+ statSync(filePath);
232
+ } catch {
233
+ return {
234
+ content: [
235
+ {
236
+ type: "text" as const,
237
+ text: `Session file not found: ${filePath}`,
238
+ },
239
+ ],
240
+ };
241
+ }
242
+
243
+ const analysis = analyzeSessionFile(filePath);
244
+ const totalTokens = analysis.inputTokens + analysis.outputTokens;
245
+ const inputCost = (analysis.inputTokens / 1_000_000) * pricing.input;
246
+ const outputCost = (analysis.outputTokens / 1_000_000) * pricing.output;
247
+ const totalCost = inputCost + outputCost;
248
+ const wasteCost =
249
+ (analysis.wastedOutputTokens / 1_000_000) * pricing.output;
250
+ const wastePercent =
251
+ totalCost > 0 ? ((wasteCost / totalCost) * 100).toFixed(1) : "0";
252
+
253
+ // Duration
254
+ let durationStr = "unknown";
255
+ if (analysis.firstTimestamp && analysis.lastTimestamp) {
256
+ const ms =
257
+ new Date(analysis.lastTimestamp).getTime() -
258
+ new Date(analysis.firstTimestamp).getTime();
259
+ if (ms > 0) durationStr = formatDuration(ms);
260
+ }
261
+
262
+ // Preflight impact
263
+ const preflightCost =
264
+ (analysis.preflightTokens / 1_000_000) * pricing.input;
265
+ // Estimate: each preflight check prevents ~0.5 corrections on average
266
+ const estimatedPrevented = Math.round(analysis.preflightCalls * 0.5);
267
+ // Average wasted tokens per correction
268
+ const avgWastePerCorrection =
269
+ analysis.corrections > 0
270
+ ? analysis.wastedOutputTokens / analysis.corrections
271
+ : 500;
272
+ const estimatedSavingsTokens = estimatedPrevented * avgWastePerCorrection;
273
+ const estimatedSavingsCost =
274
+ (estimatedSavingsTokens / 1_000_000) * pricing.output;
275
+
276
+ // Build report
277
+ const lines: string[] = [
278
+ `📊 Session Cost Estimate`,
279
+ `━━━━━━━━━━━━━━━━━━━━━━`,
280
+ `Duration: ${durationStr} | ${analysis.promptCount} prompts | ${analysis.toolCallCount} tool calls`,
281
+ `File: ${basename(filePath)}`,
282
+ ``,
283
+ `Token Usage (estimated):`,
284
+ ` Input: ~${formatTokens(analysis.inputTokens)} tokens`,
285
+ ` Output: ~${formatTokens(analysis.outputTokens)} tokens`,
286
+ ` Total: ~${formatTokens(totalTokens)} tokens`,
287
+ ``,
288
+ `Estimated Cost: ~${formatCost(totalCost)} (${pricingModel.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())})`,
289
+ ];
290
+
291
+ if (analysis.corrections > 0) {
292
+ lines.push(
293
+ ``,
294
+ `Waste Analysis:`,
295
+ ` Corrections detected: ${analysis.corrections}`,
296
+ ` Wasted output tokens: ~${formatTokens(analysis.wastedOutputTokens)}`,
297
+ ` Estimated waste: ~${formatCost(wasteCost)} (${wastePercent}% of total)`,
298
+ );
299
+ } else {
300
+ lines.push(``, `Waste Analysis:`, ` No corrections detected 🎯`);
301
+ }
302
+
303
+ if (analysis.preflightCalls > 0) {
304
+ lines.push(
305
+ ``,
306
+ `Preflight Impact:`,
307
+ ` Preflight checks: ${analysis.preflightCalls} calls (~${formatTokens(analysis.preflightTokens)} tokens)`,
308
+ ` Preflight cost: ~${formatCost(preflightCost)}`,
309
+ );
310
+ if (estimatedPrevented > 0) {
311
+ lines.push(
312
+ ` Estimated corrections prevented: ${estimatedPrevented}`,
313
+ ` Estimated savings: ~${formatCost(estimatedSavingsCost)}`,
314
+ ``,
315
+ `💡 Net benefit: preflight saved ~${formatCost(estimatedSavingsCost - preflightCost)} this session`,
316
+ );
317
+ }
318
+ } else {
319
+ lines.push(
320
+ ``,
321
+ `Preflight Impact:`,
322
+ ` No preflight checks used this session`,
323
+ ` 💡 Tip: Use preflight_check to catch issues before they cost tokens`,
324
+ );
325
+ }
326
+
327
+ return {
328
+ content: [{ type: "text" as const, text: lines.join("\n") }],
329
+ };
330
+ },
331
+ );
332
+ }