diff --git a/README.md b/README.md index 6d03f5d..feb5778 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,66 @@ The pattern is always the same: vague prompt → Claude guesses → wrong output → 4,000 tokens, done right the first time ``` +### What Tool Output Looks Like + +When you run `preflight_check` on a real prompt, here's what you get back: + +**Ambiguous prompt** — `"update the user model"`: + +``` +┌─ preflight_check ────────────────────────────────────────────┐ +│ Classification: ⚠️ AMBIGUOUS │ +│ Confidence: 0.85 │ +│ │ +│ Issues: │ +│ • "user model" matches 3 files: User (prisma/schema.prisma),│ +│ UserProfile (src/types/user.ts), UserDTO (src/api/types.ts)│ +│ • No indication of what fields to update or why │ +│ │ +│ Suggested clarification: │ +│ "Which user model? What fields are changing? │ +│ e.g. 'Add emailVerified boolean to the User model │ +│ in prisma/schema.prisma and regenerate the client'" │ +│ │ +│ Correction history: You've had 2 past corrections on │ +│ schema changes — both were missing migration steps. │ +│ Consider including 'and create a migration' in your prompt. │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Well-specified prompt** — `"add a retryCount integer field to the Job model in prisma/schema.prisma, default 0, and create a migration"`: + +``` +┌─ preflight_check ────────────────────────────────────────────┐ +│ Classification: ✅ CLEAR │ +│ Confidence: 0.95 │ +│ │ +│ Scope: 1 file (prisma/schema.prisma) + migration │ +│ Estimated tokens: ~2,000 │ +│ Proceeding without intervention. │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Cross-service prompt** — `"change the webhook payload format"`: + +``` +┌─ preflight_check ────────────────────────────────────────────┐ +│ Classification: 🔗 CROSS-SERVICE │ +│ │ +│ Contracts found: │ +│ • WebhookPayload (src/events/types.ts) │ +│ • WebhookPayload consumer (auth-service/src/handlers/ │ +│ webhook.ts:42) │ +│ • WebhookPayload consumer (billing-api/src/lib/events.ts:18)│ +│ │ +│ ⚠️ 2 downstream consumers depend on this type. │ +│ Changing the payload shape will break auth-service and │ +│ billing-api unless they're updated too. │ +└──────────────────────────────────────────────────────────────┘ +``` + +These outputs appear inline in your Claude Code session — no extra windows or dashboards. + --- ## Quick Start diff --git a/src/index.ts b/src/index.ts index e7e9d00..c2a525a 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 { registerExportTimeline } from "./tools/export-timeline.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_timeline", registerExportTimeline], ]; let registered = 0; diff --git a/src/tools/audit-workspace.ts b/src/tools/audit-workspace.ts index d4306bd..6330dac 100644 --- a/src/tools/audit-workspace.ts +++ b/src/tools/audit-workspace.ts @@ -35,6 +35,7 @@ export function registerAuditWorkspace(server: McpServer): void { `Audit workspace documentation freshness vs actual project state. Compares .claude/ workspace docs against recent git commits to find stale or missing documentation. Call after completing a batch of work or at session end.`, {}, async () => { + try { const docs = findWorkspaceDocs(); const recentFiles = run("git diff --name-only HEAD~10 2>/dev/null || echo ''").split("\n").filter(Boolean); const sections: string[] = []; @@ -92,6 +93,15 @@ export function registerAuditWorkspace(server: McpServer): void { sections.push(`## Recommendation\n${recs.join("\n")}`); return { content: [{ type: "text" as const, text: sections.join("\n\n") }] }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + content: [{ + type: "text" as const, + text: `## Workspace Audit — Error ❌\n\n**Error**: ${message}\n\nCould not audit workspace. Ensure you're in a git repository with a .claude/ directory.`, + }], + }; + } } ); } diff --git a/src/tools/checkpoint.ts b/src/tools/checkpoint.ts index e086f01..560fbf0 100644 --- a/src/tools/checkpoint.ts +++ b/src/tools/checkpoint.ts @@ -17,6 +17,7 @@ export function registerCheckpoint(server: McpServer): void { commit_mode: z.enum(["staged", "tracked", "all"]).optional().describe("What to commit: 'staged' (only staged files), 'tracked' (modified tracked files), 'all' (git add -A). Default: 'tracked'"), }, async ({ summary, next_steps, current_blockers, commit_mode }) => { + try { const mode = commit_mode || "tracked"; const branch = getBranch(); const dirty = getStatus(); @@ -114,6 +115,15 @@ ${current_blockers ? "- Current blockers\n" : ""}- Working tree state at checkpo Tell the next session/continuation: "Read .claude/last-checkpoint.md for where I left off"`, }], }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + content: [{ + type: "text" as const, + text: `## Checkpoint Failed ❌\n\n**Error**: ${message}\n\n**What to do**: Your work is NOT lost — files are still on disk. Try:\n1. Manually commit: \`git add -u && git commit -m "manual checkpoint"\`\n2. Check git status: \`git status\`\n3. Re-run checkpoint after fixing the issue`, + }], + }; + } } ); } diff --git a/src/tools/export-timeline.ts b/src/tools/export-timeline.ts new file mode 100644 index 0000000..4d89e43 --- /dev/null +++ b/src/tools/export-timeline.ts @@ -0,0 +1,342 @@ +// ============================================================================= +// export_timeline — Generate markdown reports from timeline data (Issue #5) +// ============================================================================= + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getTimeline, listIndexedProjects } from "../lib/timeline-db.js"; +import { getRelatedProjects } from "../lib/config.js"; +import { writeFileSync, mkdirSync, existsSync } from "fs"; +import { join } from "path"; +import { homedir } from "os"; +import type { SearchScope } from "../types.js"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +const RELATIVE_DATE_RE = /^(\d+)(days?|weeks?|months?|years?)$/; + +function parseRelativeDate(input: string): string { + const match = input.match(RELATIVE_DATE_RE); + if (!match) return input; + const [, numStr, unit] = match; + const num = parseInt(numStr, 10); + const d = new Date(); + if (unit.startsWith("day")) d.setDate(d.getDate() - num); + else if (unit.startsWith("week")) d.setDate(d.getDate() - num * 7); + else if (unit.startsWith("month")) d.setMonth(d.getMonth() - num); + else if (unit.startsWith("year")) d.setFullYear(d.getFullYear() - num); + return d.toISOString(); +} + +function defaultSince(period: string): string { + const d = new Date(); + switch (period) { + case "daily": + d.setDate(d.getDate() - 1); + break; + case "weekly": + d.setDate(d.getDate() - 7); + break; + case "monthly": + d.setMonth(d.getMonth() - 1); + break; + default: + d.setDate(d.getDate() - 7); + } + return d.toISOString(); +} + +const TYPE_ICONS: Record = { + prompt: "💬", + assistant: "🤖", + tool_call: "🔧", + correction: "❌", + commit: "📦", + compaction: "🗜️", + sub_agent_spawn: "🚀", + error: "⚠️", +}; + +async function getSearchProjects(scope: SearchScope): Promise { + const currentProject = process.env.CLAUDE_PROJECT_DIR; + switch (scope) { + case "current": + return currentProject ? [currentProject] : []; + case "related": { + const related = getRelatedProjects(); + return currentProject ? [currentProject, ...related] : related; + } + case "all": { + const projects = await listIndexedProjects(); + return projects.map((p) => p.project); + } + default: + return currentProject ? [currentProject] : []; + } +} + +// ── Report generators ────────────────────────────────────────────────────── + +interface ReportEvent { + timestamp: string; + type: string; + content?: string; + summary?: string; + commit_hash?: string; + tool_name?: string; + metadata?: string; + session_id?: string; + project?: string; + project_name?: string; +} + +function generateSummaryStats(events: ReportEvent[]): string[] { + const counts: Record = {}; + for (const e of events) { + counts[e.type] = (counts[e.type] || 0) + 1; + } + + const lines: string[] = ["## Summary", ""]; + lines.push(`| Metric | Count |`); + lines.push(`|--------|-------|`); + lines.push(`| Total events | ${events.length} |`); + + for (const [type, count] of Object.entries(counts).sort( + (a, b) => b[1] - a[1] + )) { + const icon = TYPE_ICONS[type] || "❓"; + lines.push(`| ${icon} ${type} | ${count} |`); + } + + // Correction rate + const prompts = counts["prompt"] || 0; + const corrections = counts["correction"] || 0; + if (prompts > 0) { + const rate = ((corrections / prompts) * 100).toFixed(1); + lines.push(`| Correction rate | ${rate}% |`); + } + + // Unique sessions + const sessions = new Set(events.map((e) => e.session_id).filter(Boolean)); + lines.push(`| Sessions | ${sessions.size} |`); + + lines.push(""); + return lines; +} + +function generateDailyBreakdown(events: ReportEvent[]): string[] { + const days = new Map(); + for (const event of events) { + const day = event.timestamp + ? new Date(event.timestamp).toISOString().slice(0, 10) + : "unknown"; + if (!days.has(day)) days.set(day, []); + days.get(day)!.push(event); + } + + const lines: string[] = ["## Daily Breakdown", ""]; + const sortedDays = [...days.keys()].sort().reverse(); + + for (const day of sortedDays) { + const dayEvents = days.get(day)!; + const counts: Record = {}; + for (const e of dayEvents) { + counts[e.type] = (counts[e.type] || 0) + 1; + } + + const badges = Object.entries(counts) + .map(([t, c]) => `${TYPE_ICONS[t] || "❓"}${c}`) + .join(" "); + + lines.push(`### ${day} (${dayEvents.length} events)`); + lines.push(`${badges}`); + lines.push(""); + + // Show commits for the day + const commits = dayEvents.filter((e) => e.type === "commit"); + if (commits.length > 0) { + lines.push("**Commits:**"); + for (const c of commits) { + const hash = c.commit_hash ? c.commit_hash.slice(0, 7) : "???????"; + const msg = (c.content || c.summary || "").slice(0, 100).replace(/\n/g, " "); + lines.push(`- \`${hash}\` ${msg}`); + } + lines.push(""); + } + + // Show corrections for the day + const corrections = dayEvents.filter((e) => e.type === "correction"); + if (corrections.length > 0) { + lines.push("**Corrections:**"); + for (const c of corrections) { + const msg = (c.content || c.summary || "").slice(0, 120).replace(/\n/g, " "); + lines.push(`- ${msg}`); + } + lines.push(""); + } + + // Show errors + const errors = dayEvents.filter((e) => e.type === "error"); + if (errors.length > 0) { + lines.push("**Errors:**"); + for (const e of errors) { + const msg = (e.content || e.summary || "").slice(0, 120).replace(/\n/g, " "); + lines.push(`- ⚠️ ${msg}`); + } + lines.push(""); + } + } + + return lines; +} + +function generateToolUsageSection(events: ReportEvent[]): string[] { + const toolCalls = events.filter((e) => e.type === "tool_call"); + if (toolCalls.length === 0) return []; + + const toolCounts: Record = {}; + for (const tc of toolCalls) { + const name = tc.tool_name || "unknown"; + toolCounts[name] = (toolCounts[name] || 0) + 1; + } + + const sorted = Object.entries(toolCounts).sort((a, b) => b[1] - a[1]); + const lines: string[] = ["## Tool Usage", ""]; + lines.push("| Tool | Calls |"); + lines.push("|------|-------|"); + for (const [tool, count] of sorted.slice(0, 20)) { + lines.push(`| ${tool} | ${count} |`); + } + lines.push(""); + return lines; +} + +function buildReport( + events: ReportEvent[], + period: string, + projectLabel: string, + since: string, + until?: string +): string { + const now = new Date().toISOString().slice(0, 10); + const sinceDate = new Date(since).toISOString().slice(0, 10); + const untilDate = until ? new Date(until).toISOString().slice(0, 10) : now; + + const lines: string[] = [ + `# ${period.charAt(0).toUpperCase() + period.slice(1)} Report: ${projectLabel}`, + "", + `**Period:** ${sinceDate} → ${untilDate} `, + `**Generated:** ${now}`, + "", + ...generateSummaryStats(events), + ...generateDailyBreakdown(events), + ...generateToolUsageSection(events), + "---", + `_Generated by preflight export_timeline_`, + ]; + + return lines.join("\n"); +} + +// ── Registration ─────────────────────────────────────────────────────────── + +export function registerExportTimeline(server: McpServer) { + server.tool( + "export_timeline", + "Generate a markdown report from timeline data. Summarizes activity, commits, corrections, tool usage, and daily breakdown for a given period.", + { + scope: z + .enum(["current", "related", "all"]) + .default("current") + .describe("Search scope"), + project: z + .string() + .optional() + .describe("Filter to a specific project (overrides scope)"), + period: z + .enum(["daily", "weekly", "monthly"]) + .default("weekly") + .describe("Report period — sets default date range if since is omitted"), + since: z + .string() + .optional() + .describe("Start date (ISO or relative like '7days')"), + until: z + .string() + .optional() + .describe("End date (ISO or relative)"), + save: z + .boolean() + .default(false) + .describe("Save report to ~/.preflight/reports/"), + }, + async (params) => { + const since = params.since + ? parseRelativeDate(params.since) + : defaultSince(params.period); + const until = params.until + ? parseRelativeDate(params.until) + : undefined; + + let projectDirs: string[]; + if (params.project) { + projectDirs = [params.project]; + } else { + projectDirs = await getSearchProjects(params.scope); + } + + if (projectDirs.length === 0) { + return { + content: [ + { + type: "text" as const, + text: `No projects found for scope "${params.scope}". Make sure CLAUDE_PROJECT_DIR is set or projects are onboarded.`, + }, + ], + }; + } + + const events = (await getTimeline({ + project_dirs: projectDirs, + project: undefined, + since, + until, + limit: 5000, + offset: 0, + })) as ReportEvent[]; + + if (events.length === 0) { + return { + content: [ + { + type: "text" as const, + text: "No events found for the given period. Nothing to report.", + }, + ], + }; + } + + const projectLabel = + params.project || (params.scope === "current" ? "current project" : params.scope); + const report = buildReport(events, params.period, projectLabel, since, until); + + // Optionally save to disk + let savedPath: string | undefined; + if (params.save) { + const reportsDir = join(homedir(), ".preflight", "reports"); + if (!existsSync(reportsDir)) { + mkdirSync(reportsDir, { recursive: true }); + } + const filename = `${params.period}-${new Date().toISOString().slice(0, 10)}.md`; + savedPath = join(reportsDir, filename); + writeFileSync(savedPath, report, "utf-8"); + } + + const footer = savedPath ? `\n\n_Saved to \`${savedPath}\`_` : ""; + + return { + content: [{ type: "text" as const, text: report + footer }], + }; + } + ); +} diff --git a/src/tools/session-health.ts b/src/tools/session-health.ts index bd6a819..ce8b400 100644 --- a/src/tools/session-health.ts +++ b/src/tools/session-health.ts @@ -20,6 +20,7 @@ export function registerSessionHealth(server: McpServer): void { stale_threshold_hours: z.number().optional().describe("Hours before a doc is considered stale. Default: 2"), }, async ({ stale_threshold_hours }) => { + try { const config = getConfig(); const staleHours = stale_threshold_hours ?? (config.thresholds.session_stale_minutes / 60); const branch = getBranch(); @@ -102,6 +103,15 @@ ${issues.length ? issues.join("\n") : "None — session is healthy"} ${recommendation}`, }], }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + content: [{ + type: "text" as const, + text: `## Session Health — Error ❌\n\n**Error**: ${message}\n\nCould not determine session health. If git is unavailable or the workspace isn't a repo, this tool won't work. Try running \`git status\` manually.`, + }], + }; + } } ); } diff --git a/src/tools/what-changed.ts b/src/tools/what-changed.ts index 913dfa2..bca60b8 100644 --- a/src/tools/what-changed.ts +++ b/src/tools/what-changed.ts @@ -10,6 +10,7 @@ export function registerWhatChanged(server: McpServer): void { since: z.string().optional().describe("Git ref: 'HEAD~5', 'HEAD~3', etc. Default: HEAD~5"), }, async ({ since }) => { + try { const ref = since || "HEAD~5"; const diffStat = getDiffStat(ref); const diffFiles = run(`git diff ${ref} --name-only 2>/dev/null || git diff HEAD~3 --name-only`); @@ -43,6 +44,15 @@ ${diffStat || "no changes"} \`\`\``, }], }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + content: [{ + type: "text" as const, + text: `## What Changed — Error ❌\n\n**Error**: ${message}\n\nThis can happen if the git ref is invalid or the repo has too few commits. Try \`what_changed\` with \`since: "HEAD~3"\` or \`since: "HEAD~1"\`.`, + }], + }; + } } ); }