From 3cad08116d0796020490a0bc4a8eda0875e96824 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Tue, 17 Mar 2026 08:08:00 -0700 Subject: [PATCH 1/2] feat: add export_report tool for markdown session reports Adds a new export_report MCP tool that generates markdown summary reports from session timeline data. Supports daily and weekly aggregation with: - Event statistics (prompts, corrections, commits, errors, etc.) - Correction rate trends over time - Per-period breakdowns with commit and error highlights - Optional save to ~/.preflight/reports/ Closes #5 --- src/index.ts | 2 + src/tools/export-report.ts | 226 +++++++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 src/tools/export-report.ts diff --git a/src/index.ts b/src/index.ts index e7e9d00..1528c44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,7 @@ import { registerScanSessions } from "./tools/scan-sessions.js"; import { registerGenerateScorecard } from "./tools/generate-scorecard.js"; import { registerSearchContracts } from "./tools/search-contracts.js"; import { registerEstimateCost } from "./tools/estimate-cost.js"; +import { registerExportReport } from "./tools/export-report.js"; // Validate related projects from config function validateRelatedProjects(): void { @@ -110,6 +111,7 @@ const toolRegistry: Array<[string, RegisterFn]> = [ ["generate_scorecard", registerGenerateScorecard], ["estimate_cost", registerEstimateCost], ["search_contracts", registerSearchContracts], + ["export_report", registerExportReport], ]; let registered = 0; diff --git a/src/tools/export-report.ts b/src/tools/export-report.ts new file mode 100644 index 0000000..7b68ca1 --- /dev/null +++ b/src/tools/export-report.ts @@ -0,0 +1,226 @@ +// ============================================================================= +// export_report — Generate markdown session reports from timeline data +// Closes #5: Export timeline to markdown reports +// ============================================================================= + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + findSessionDirs, + findSessionFiles, + parseSession, + type TimelineEvent, +} from "../lib/session-parser.js"; +import { writeFileSync, mkdirSync } from "fs"; +import { join } from "path"; +import { homedir } from "os"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function dayKey(ts: string): string { + return new Date(ts).toISOString().slice(0, 10); +} + +function weekKey(ts: string): string { + const d = new Date(ts); + // ISO week: Monday-based + const day = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`; +} + +interface EventStats { + prompts: number; + corrections: number; + toolCalls: number; + commits: number; + compactions: number; + errors: number; + subAgentSpawns: number; +} + +function emptyStats(): EventStats { + return { prompts: 0, corrections: 0, toolCalls: 0, commits: 0, compactions: 0, errors: 0, subAgentSpawns: 0 }; +} + +function tally(stats: EventStats, event: TimelineEvent): void { + switch (event.type) { + case "prompt": stats.prompts++; break; + case "correction": stats.corrections++; break; + case "tool_call": stats.toolCalls++; break; + case "commit": stats.commits++; break; + case "compaction": stats.compactions++; break; + case "error": stats.errors++; break; + case "sub_agent_spawn": stats.subAgentSpawns++; break; + } +} + +function correctionRate(stats: EventStats): string { + if (stats.prompts === 0) return "N/A"; + return ((stats.corrections / stats.prompts) * 100).toFixed(1) + "%"; +} + +function formatStats(stats: EventStats): string { + const lines: string[] = []; + lines.push(`| Metric | Count |`); + lines.push(`|--------|-------|`); + lines.push(`| Prompts | ${stats.prompts} |`); + lines.push(`| Tool calls | ${stats.toolCalls} |`); + lines.push(`| Commits | ${stats.commits} |`); + lines.push(`| Corrections | ${stats.corrections} (${correctionRate(stats)}) |`); + lines.push(`| Compactions | ${stats.compactions} |`); + lines.push(`| Errors | ${stats.errors} |`); + lines.push(`| Sub-agent spawns | ${stats.subAgentSpawns} |`); + return lines.join("\n"); +} + +// ── Tool registration ────────────────────────────────────────────────────── + +export function registerExportReport(server: McpServer) { + server.tool( + "export_report", + "Generate a markdown summary report from session timeline data. Supports daily and weekly summaries with prompt quality trends, correction rates, and activity breakdowns.", + { + period: z.enum(["day", "week"]).default("week").describe("Aggregation period"), + days: z.number().default(7).describe("How many days back to include"), + project: z.string().optional().describe("Filter to a specific project directory"), + save: z.boolean().default(false).describe("Save to ~/.preflight/reports/"), + }, + async (params) => { + // Collect events + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - params.days); + const cutoffISO = cutoff.toISOString(); + + const sessionDirs = findSessionDirs(); + let allEvents: TimelineEvent[] = []; + + for (const dir of sessionDirs) { + if (params.project && !dir.project.includes(params.project)) continue; + const files = findSessionFiles(dir.sessionDir); + for (const file of files) { + try { + const events = parseSession(file.path, dir.project, dir.projectName); + for (const ev of events) { + if (ev.timestamp >= cutoffISO) { + allEvents.push(ev); + } + } + } catch { + // skip unparseable files + } + } + } + + if (allEvents.length === 0) { + return { + content: [{ + type: "text", + text: `## Report\n_No events found in the last ${params.days} days._`, + }], + }; + } + + // Sort chronologically + allEvents.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + + // Group by period + const groupFn = params.period === "day" ? dayKey : weekKey; + const groups = new Map(); + for (const ev of allEvents) { + const key = groupFn(ev.timestamp); + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(ev); + } + + // Build report + const totalStats = emptyStats(); + allEvents.forEach((ev) => tally(totalStats, ev)); + + const sortedKeys = [...groups.keys()].sort(); + const periodLabel = params.period === "day" ? "Daily" : "Weekly"; + const dateRange = `${sortedKeys[0]} → ${sortedKeys[sortedKeys.length - 1]}`; + + const lines: string[] = [ + `# ${periodLabel} Session Report`, + `_Generated ${new Date().toISOString().slice(0, 10)} · ${dateRange} · ${allEvents.length} events_`, + "", + "## Summary", + "", + formatStats(totalStats), + "", + ]; + + // Correction trend + if (sortedKeys.length > 1) { + lines.push("## Correction Rate Trend"); + lines.push(""); + for (const key of sortedKeys) { + const periodEvents = groups.get(key)!; + const ps = emptyStats(); + periodEvents.forEach((ev) => tally(ps, ev)); + const bar = "█".repeat(Math.round(ps.corrections)); + lines.push(`- **${key}**: ${correctionRate(ps)} ${bar} (${ps.prompts} prompts, ${ps.corrections} corrections)`); + } + lines.push(""); + } + + // Per-period breakdown + lines.push(`## ${periodLabel} Breakdown`); + lines.push(""); + + for (const key of sortedKeys) { + const periodEvents = groups.get(key)!; + const ps = emptyStats(); + periodEvents.forEach((ev) => tally(ps, ev)); + + lines.push(`### ${key}`); + lines.push(""); + lines.push(`${periodEvents.length} events · ${ps.prompts} prompts · ${ps.commits} commits · ${correctionRate(ps)} correction rate`); + lines.push(""); + + // Top commits + const commits = periodEvents.filter((e) => e.type === "commit"); + if (commits.length > 0) { + lines.push("**Commits:**"); + for (const c of commits.slice(0, 10)) { + const preview = c.content_preview || c.content.slice(0, 80); + lines.push(`- ${preview}`); + } + lines.push(""); + } + + // Errors + const errors = periodEvents.filter((e) => e.type === "error"); + if (errors.length > 0) { + lines.push("**Errors:**"); + for (const e of errors.slice(0, 5)) { + lines.push(`- ⚠️ ${(e.content_preview || e.content).slice(0, 100)}`); + } + lines.push(""); + } + } + + const report = lines.join("\n"); + + // Optionally save + if (params.save) { + const reportDir = join(homedir(), ".preflight", "reports"); + mkdirSync(reportDir, { recursive: true }); + const filename = `report-${params.period}-${new Date().toISOString().slice(0, 10)}.md`; + const filepath = join(reportDir, filename); + writeFileSync(filepath, report, "utf-8"); + return { + content: [{ + type: "text", + text: report + `\n\n_Saved to \`${filepath}\`_`, + }], + }; + } + + return { content: [{ type: "text", text: report }] }; + } + ); +} From fae41f0d40dfbbdeac3c022a0644ee6a8cdc1a1f Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Tue, 17 Mar 2026 08:17:08 -0700 Subject: [PATCH 2/2] feat(cli): add --help, --version, and status subcommand - preflight-dev --help shows usage, quick start, and one-liner setup - preflight-dev --version prints package version - preflight-dev status checks .mcp.json, .preflight/ dir, and env vars - Updated README with CLI commands section --- README.md | 9 +++++ src/cli/init.ts | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/README.md b/README.md index 6d03f5d..3515249 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,15 @@ claude mcp add preflight -- preflight-dev-serve > **Note:** `preflight-dev` runs the interactive setup wizard. `preflight-dev-serve` starts the MCP server — that's what you want in your Claude Code config. +### CLI commands + +```bash +preflight-dev # Interactive setup wizard +preflight-dev status # Check if preflight is configured in this project +preflight-dev --version # Print version +preflight-dev --help # Show usage help +``` + --- ## How It Works diff --git a/src/cli/init.ts b/src/cli/init.ts index dfaaa25..5a3d653 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -9,6 +9,97 @@ import { join, dirname } from "node:path"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; +// --------------------------------------------------------------------------- +// CLI flags: --help, --version, status +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +if (args.includes("--help") || args.includes("-h")) { + console.log(` +✈️ preflight-dev — Stop burning tokens on vague prompts + +USAGE + preflight-dev Interactive setup wizard (creates .mcp.json) + preflight-dev status Check if preflight is configured in this project + preflight-dev --version Print version + preflight-dev --help Show this help + +QUICK START + cd your-project + npx preflight-dev # run the setup wizard + # restart Claude Code — done! + +ONE-LINER (skip the wizard) + claude mcp add preflight -- npx -y preflight-dev-serve + +DOCS + https://github.com/TerminalGravity/preflight +`); + process.exit(0); +} + +if (args.includes("--version") || args.includes("-v")) { + const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../package.json"); + try { + const pkg = JSON.parse(await readFile(pkgPath, "utf-8")); + console.log(`preflight-dev v${pkg.version}`); + } catch { + // Fallback when running from dist/ — package.json is one more level up + try { + const pkg2 = JSON.parse(await readFile(join(dirname(fileURLToPath(import.meta.url)), "../../../package.json"), "utf-8")); + console.log(`preflight-dev v${pkg2.version}`); + } catch { + console.log("preflight-dev (version unknown)"); + } + } + process.exit(0); +} + +if (args[0] === "status") { + const mcpPath = join(process.cwd(), ".mcp.json"); + const preflightDir = join(process.cwd(), ".preflight"); + + console.log("\n✈️ preflight status\n"); + + // Check .mcp.json + if (existsSync(mcpPath)) { + try { + const config = JSON.parse(await readFile(mcpPath, "utf-8")); + if (config.mcpServers?.preflight) { + const srv = config.mcpServers.preflight; + const profile = srv.env?.PROMPT_DISCIPLINE_PROFILE || "standard"; + const embeddings = srv.env?.EMBEDDING_PROVIDER || "local"; + console.log(` ✅ .mcp.json — preflight registered (profile: ${profile}, embeddings: ${embeddings})`); + } else { + console.log(" ❌ .mcp.json exists but no 'preflight' server configured"); + } + } catch { + console.log(" ⚠️ .mcp.json exists but failed to parse"); + } + } else { + console.log(" ❌ No .mcp.json found — run `npx preflight-dev` to set up"); + } + + // Check .preflight/ config dir + if (existsSync(preflightDir)) { + const files = ["config.yml", "triage.yml"].filter(f => existsSync(join(preflightDir, f))); + console.log(` ✅ .preflight/ directory (${files.length} config files: ${files.join(", ") || "none"})`); + } else { + console.log(" ℹ️ No .preflight/ directory (optional — sensible defaults apply)"); + } + + // Check environment + if (process.env.CLAUDE_PROJECT_DIR) { + console.log(` ✅ CLAUDE_PROJECT_DIR = ${process.env.CLAUDE_PROJECT_DIR}`); + } else { + console.log(" ℹ️ CLAUDE_PROJECT_DIR not set (some tools use cwd instead)"); + } + + console.log(""); + process.exit(0); +} + const rl = createInterface({ input: process.stdin, output: process.stdout }); function ask(question: string): Promise {