diff --git a/README.md b/README.md index 6d03f5d..e7a385f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A 24-tool MCP server for Claude Code that catches ambiguous instructions before [![MCP](https://img.shields.io/badge/MCP-Compatible-blueviolet)](https://modelcontextprotocol.io/) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![npm](https://img.shields.io/npm/v/preflight-dev)](https://www.npmjs.com/package/preflight-dev) -[![Node 18+](https://img.shields.io/badge/node-18%2B-brightgreen?logo=node.js&logoColor=white)](https://nodejs.org/) +[![Node 20+](https://img.shields.io/badge/node-20%2B-brightgreen?logo=node.js&logoColor=white)](https://nodejs.org/) [Quick Start](#quick-start) · [How It Works](#how-it-works) · [Tool Reference](#tool-reference) · [Configuration](#configuration) · [Scoring](#the-12-category-scorecard) @@ -124,6 +124,16 @@ 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. +### Make Claude use preflight automatically + +Add preflight rules to your project's `CLAUDE.md` so Claude runs `preflight_check` on every prompt without you asking: + +```bash +cp /path/to/preflight/examples/CLAUDE.md your-project/CLAUDE.md +``` + +See [`examples/CLAUDE.md`](examples/CLAUDE.md) for a ready-to-use template with recommended rules for when to preflight, session hygiene, and skip-lists. + --- ## How It Works diff --git a/examples/CLAUDE.md b/examples/CLAUDE.md new file mode 100644 index 0000000..da55f40 --- /dev/null +++ b/examples/CLAUDE.md @@ -0,0 +1,30 @@ +# CLAUDE.md — Preflight Integration Example +# +# Drop this into your project's CLAUDE.md (or .claude/CLAUDE.md) to make +# Claude Code automatically use preflight tools during your sessions. +# Customize the rules below to match your workflow. + +## Preflight Rules + +Before starting any non-trivial task, run `preflight_check` with my prompt. This catches vague instructions before they waste tokens on wrong→fix cycles. + +### When to use preflight tools: + +- **Every prompt**: `preflight_check` triages automatically — let it decide what's needed +- **Before multi-file changes**: Run `scope_work` to get a phased plan +- **Before sub-agent tasks**: Use `enrich_agent_task` to add context +- **After making a mistake**: Use `log_correction` so preflight learns the pattern +- **Before ending a session**: Run `checkpoint` to save state for next time +- **When I say "fix it" or "do the others"**: Use `sharpen_followup` to resolve what I actually mean + +### Session hygiene: + +- Run `check_session_health` if we've been going for a while without committing +- If I ask about something we did before, use `search_history` to find it +- Before declaring a task done, run `verify_completion` (type check + tests) + +### Don't preflight these: + +- Simple git commands (commit, push, status) +- Formatting / linting +- Reading files I explicitly named diff --git a/examples/README.md b/examples/README.md index 778f15d..f2fafc1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,6 +12,22 @@ The `.preflight/` directory contains example configuration files you can copy in └── api.yml # Manual contract definitions for cross-service types ``` +## `CLAUDE.md` Integration + +The `CLAUDE.md` file tells Claude Code how to behave in your project. Adding preflight rules here makes Claude automatically use preflight tools without you having to ask. + +```bash +# Copy the example into your project: +cp /path/to/preflight/examples/CLAUDE.md my-project/CLAUDE.md + +# Or append to your existing CLAUDE.md: +cat /path/to/preflight/examples/CLAUDE.md >> my-project/CLAUDE.md +``` + +This is the **recommended way** to integrate preflight — once it's in your `CLAUDE.md`, every session automatically runs `preflight_check` on your prompts. + +--- + ### Quick setup ```bash diff --git a/src/cli/init.ts b/src/cli/init.ts index dfaaa25..d1b0021 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -9,6 +9,46 @@ import { join, dirname } from "node:path"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; +// Handle --help and --version before launching interactive wizard +const args = process.argv.slice(2); + +if (args.includes("--help") || args.includes("-h")) { + console.log(` +✈️ preflight-dev — MCP server for Claude Code prompt discipline + +Usage: + preflight-dev Interactive setup wizard (creates .mcp.json) + preflight-dev --help Show this help message + preflight-dev --version Show version + +The wizard will: + 1. Ask you to choose a profile (minimal / standard / full) + 2. Optionally create a .preflight/ config directory + 3. Write an .mcp.json so Claude Code auto-connects to preflight + +After setup, restart Claude Code and preflight tools will appear. + +Profiles: + minimal 4 tools — clarify_intent, check_session_health, session_stats, prompt_score + standard 16 tools — all prompt discipline + session_stats + prompt_score + full 20 tools — everything + timeline/vector search (needs LanceDB) + +More info: 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 { + console.log("preflight-dev (version unknown)"); + } + process.exit(0); +} + const rl = createInterface({ input: process.stdin, output: process.stdout }); function ask(question: string): Promise { 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/export-timeline.ts b/src/tools/export-timeline.ts new file mode 100644 index 0000000..81aa2c3 --- /dev/null +++ b/src/tools/export-timeline.ts @@ -0,0 +1,335 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { getTimeline, listIndexedProjects } from "../lib/timeline-db.js"; +import { getRelatedProjects } from "../lib/config.js"; +import type { SearchScope } from "../types.js"; + +const RELATIVE_DATE_RE = /^(\d+)(days?|weeks?|months?|years?)$/; + +export 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(); +} + +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] : []; + } +} + +interface TimelineEvent { + timestamp?: string; + type: string; + content?: string; + summary?: string; + commit_hash?: string; + tool_name?: string; + metadata?: string; +} + +interface ReportStats { + total: number; + byType: Record; + byDay: Map; + promptCount: number; + commitCount: number; + errorCount: number; + correctionCount: number; + toolCallCount: number; +} + +export function computeStats(events: TimelineEvent[]): ReportStats { + const byType: Record = {}; + const byDay = new Map(); + + for (const e of events) { + byType[e.type] = (byType[e.type] || 0) + 1; + const day = e.timestamp + ? new Date(e.timestamp).toISOString().slice(0, 10) + : "unknown"; + if (!byDay.has(day)) byDay.set(day, []); + byDay.get(day)!.push(e); + } + + return { + total: events.length, + byType, + byDay, + promptCount: byType["prompt"] || 0, + commitCount: byType["commit"] || 0, + errorCount: byType["error"] || 0, + correctionCount: byType["correction"] || 0, + toolCallCount: byType["tool_call"] || 0, + }; +} + +export function generateMarkdownReport( + events: TimelineEvent[], + stats: ReportStats, + options: { title: string; since?: string; until?: string; sections: string[] } +): string { + const lines: string[] = []; + const now = new Date().toISOString().slice(0, 10); + + lines.push(`# ${options.title}`); + lines.push(`_Generated ${now}_`); + if (options.since || options.until) { + const range = [options.since || "beginning", options.until || "now"].join( + " → " + ); + lines.push(`_Period: ${range}_`); + } + lines.push(""); + + // Summary section + if (options.sections.includes("summary")) { + lines.push("## Summary"); + lines.push(""); + lines.push(`| Metric | Count |`); + lines.push(`|--------|-------|`); + lines.push(`| Total events | ${stats.total} |`); + lines.push(`| Prompts | ${stats.promptCount} |`); + lines.push(`| Commits | ${stats.commitCount} |`); + lines.push(`| Tool calls | ${stats.toolCallCount} |`); + lines.push(`| Corrections | ${stats.correctionCount} |`); + lines.push(`| Errors | ${stats.errorCount} |`); + lines.push(""); + + if (stats.total > 0) { + const correctionRate = + stats.promptCount > 0 + ? ((stats.correctionCount / stats.promptCount) * 100).toFixed(1) + : "N/A"; + const errorRate = ((stats.errorCount / stats.total) * 100).toFixed(1); + lines.push( + `**Correction rate:** ${correctionRate === "N/A" ? correctionRate : correctionRate + "%"} of prompts` + ); + lines.push(`**Error rate:** ${errorRate}% of events`); + lines.push(""); + } + } + + // Activity breakdown + if (options.sections.includes("activity")) { + lines.push("## Daily Activity"); + lines.push(""); + const sortedDays = [...stats.byDay.keys()].sort().reverse(); + for (const day of sortedDays) { + const dayEvents = stats.byDay.get(day)!; + const dayCounts: Record = {}; + for (const e of dayEvents) { + dayCounts[e.type] = (dayCounts[e.type] || 0) + 1; + } + const parts = Object.entries(dayCounts) + .map(([t, c]) => `${TYPE_ICONS[t] || "❓"} ${t}: ${c}`) + .join(", "); + lines.push(`- **${day}** (${dayEvents.length} events) — ${parts}`); + } + lines.push(""); + } + + // Commits section + if (options.sections.includes("commits")) { + const commits = events.filter((e) => e.type === "commit"); + if (commits.length > 0) { + lines.push("## Commits"); + lines.push(""); + for (const c of commits) { + const hash = c.commit_hash ? c.commit_hash.slice(0, 7) : "???????"; + const msg = (c.content || c.summary || "").slice(0, 120).replace(/\n/g, " "); + const time = c.timestamp + ? new Date(c.timestamp).toISOString().slice(0, 16).replace("T", " ") + : ""; + lines.push(`- \`${hash}\` ${msg} _(${time})_`); + } + lines.push(""); + } + } + + // Corrections section + if (options.sections.includes("corrections")) { + const corrections = events.filter((e) => e.type === "correction"); + if (corrections.length > 0) { + lines.push("## Corrections"); + lines.push(""); + lines.push( + "_Patterns in corrections can reveal prompt quality issues._" + ); + lines.push(""); + for (const c of corrections) { + const msg = (c.content || c.summary || "").slice(0, 200).replace(/\n/g, " "); + lines.push(`- ${msg}`); + } + lines.push(""); + } + } + + // Errors section + if (options.sections.includes("errors")) { + const errors = events.filter((e) => e.type === "error"); + if (errors.length > 0) { + lines.push("## Errors"); + lines.push(""); + for (const e of errors) { + const msg = (e.content || e.summary || "").slice(0, 200).replace(/\n/g, " "); + lines.push(`- ⚠️ ${msg}`); + } + lines.push(""); + } + } + + // Timeline section + if (options.sections.includes("timeline")) { + lines.push("## Full Timeline"); + lines.push(""); + const sortedDays = [...stats.byDay.keys()].sort().reverse(); + for (const day of sortedDays) { + lines.push(`### ${day}`); + const dayEvents = stats.byDay.get(day)!; + dayEvents.sort((a, b) => { + const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0; + const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0; + return ta - tb; + }); + for (const event of dayEvents) { + const time = event.timestamp + ? new Date(event.timestamp).toISOString().slice(11, 16) + : "??:??"; + const icon = TYPE_ICONS[event.type] || "❓"; + const content = (event.content || event.summary || "") + .slice(0, 120) + .replace(/\n/g, " "); + lines.push(`- ${time} ${icon} ${content}`); + } + lines.push(""); + } + } + + return lines.join("\n"); +} + +export function registerExportTimeline(server: McpServer) { + server.tool( + "export_timeline", + "Generate a markdown report from timeline data. Produces session summaries with stats, commit logs, correction patterns, and daily activity breakdowns.", + { + scope: z + .enum(["current", "related", "all"]) + .default("current") + .describe("Search scope"), + project: z.string().optional().describe("Specific project (overrides scope)"), + since: z + .string() + .optional() + .describe("Start date (ISO or relative like '7days', '2weeks')"), + until: z.string().optional().describe("End date"), + title: z + .string() + .default("Session Report") + .describe("Report title"), + sections: z + .array( + z.enum([ + "summary", + "activity", + "commits", + "corrections", + "errors", + "timeline", + ]) + ) + .default(["summary", "activity", "commits", "corrections", "errors"]) + .describe("Which sections to include"), + limit: z.number().default(500).describe("Max events to include"), + }, + async (params) => { + const since = params.since + ? parseRelativeDate(params.since) + : undefined; + 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", + 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: params.limit, + offset: 0, + }); + + if (events.length === 0) { + return { + content: [ + { + type: "text", + text: "No events found for the given filters. Nothing to report.", + }, + ], + }; + } + + const stats = computeStats(events); + const report = generateMarkdownReport(events, stats, { + title: params.title, + since: params.since, + until: params.until, + sections: params.sections, + }); + + return { + content: [{ type: "text", text: report }], + }; + } + ); +} diff --git a/tests/export-timeline.test.ts b/tests/export-timeline.test.ts new file mode 100644 index 0000000..d375eef --- /dev/null +++ b/tests/export-timeline.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from "vitest"; +import { + parseRelativeDate, + computeStats, + generateMarkdownReport, +} from "../src/tools/export-timeline.js"; + +describe("parseRelativeDate", () => { + it("returns ISO strings as-is", () => { + expect(parseRelativeDate("2025-01-15T00:00:00Z")).toBe( + "2025-01-15T00:00:00Z" + ); + }); + + it("parses relative day format", () => { + const result = parseRelativeDate("7days"); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T/); + const parsed = new Date(result); + const diff = Date.now() - parsed.getTime(); + // Should be roughly 7 days ago (within a few seconds) + expect(diff).toBeGreaterThan(6.9 * 86400000); + expect(diff).toBeLessThan(7.1 * 86400000); + }); + + it("parses singular day", () => { + const result = parseRelativeDate("1day"); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("parses weeks", () => { + const result = parseRelativeDate("2weeks"); + const parsed = new Date(result); + const diff = Date.now() - parsed.getTime(); + expect(diff).toBeGreaterThan(13.9 * 86400000); + expect(diff).toBeLessThan(14.1 * 86400000); + }); +}); + +describe("computeStats", () => { + it("returns zeroes for empty input", () => { + const stats = computeStats([]); + expect(stats.total).toBe(0); + expect(stats.promptCount).toBe(0); + expect(stats.commitCount).toBe(0); + }); + + it("counts events by type", () => { + const events = [ + { type: "prompt", timestamp: "2025-01-15T10:00:00Z" }, + { type: "prompt", timestamp: "2025-01-15T11:00:00Z" }, + { type: "commit", timestamp: "2025-01-15T12:00:00Z" }, + { type: "error", timestamp: "2025-01-16T10:00:00Z" }, + ]; + const stats = computeStats(events); + expect(stats.total).toBe(4); + expect(stats.promptCount).toBe(2); + expect(stats.commitCount).toBe(1); + expect(stats.errorCount).toBe(1); + expect(stats.byDay.size).toBe(2); + }); + + it("handles events without timestamps", () => { + const stats = computeStats([{ type: "prompt" }]); + expect(stats.byDay.has("unknown")).toBe(true); + }); +}); + +describe("generateMarkdownReport", () => { + it("generates a report with summary section", () => { + const events = [ + { type: "prompt", timestamp: "2025-01-15T10:00:00Z", content: "hello" }, + { type: "commit", timestamp: "2025-01-15T12:00:00Z", content: "fix bug", commit_hash: "abc1234def" }, + ]; + const stats = computeStats(events); + const report = generateMarkdownReport(events, stats, { + title: "Test Report", + sections: ["summary"], + }); + + expect(report).toContain("# Test Report"); + expect(report).toContain("Total events | 2"); + expect(report).toContain("Prompts | 1"); + expect(report).toContain("Correction rate:** 0.0%"); + }); + + it("handles zero prompts without NaN", () => { + const events = [ + { type: "commit", timestamp: "2025-01-15T12:00:00Z" }, + ]; + const stats = computeStats(events); + const report = generateMarkdownReport(events, stats, { + title: "No Prompts", + sections: ["summary"], + }); + + expect(report).not.toContain("NaN"); + expect(report).toContain("N/A"); + }); + + it("includes commits section with truncated hashes", () => { + const events = [ + { + type: "commit", + timestamp: "2025-01-15T12:00:00Z", + content: "fix: resolve edge case", + commit_hash: "abc1234def5678", + }, + ]; + const stats = computeStats(events); + const report = generateMarkdownReport(events, stats, { + title: "Commits", + sections: ["commits"], + }); + + expect(report).toContain("`abc1234`"); + expect(report).toContain("fix: resolve edge case"); + }); + + it("includes date range when provided", () => { + const report = generateMarkdownReport([], computeStats([]), { + title: "Ranged", + since: "7days", + until: "1day", + sections: [], + }); + + expect(report).toContain("7days → 1day"); + }); +});