diff --git a/README.md b/README.md index 6d03f5d..cdd5a58 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 @@ -734,6 +744,16 @@ curl http://localhost:11434/api/embed -d '{"model":"all-minilm","input":"test"}' --- +## Troubleshooting + +Having issues? Check **[TROUBLESHOOTING.md](TROUBLESHOOTING.md)** for solutions to common problems including: +- LanceDB setup and platform issues +- Embedding provider configuration +- `.preflight/` config parsing errors +- Profile selection guide + +--- + ## Contributing This project is young and there's plenty to do. Check the [issues](https://github.com/TerminalGravity/preflight/issues) — several are tagged `good first issue`. diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 0000000..77171fc --- /dev/null +++ b/TROUBLESHOOTING.md @@ -0,0 +1,165 @@ +# Troubleshooting + +Common issues and fixes for preflight. + +--- + +## Installation & Setup + +### `npx preflight-dev-serve` fails with module errors + +**Symptoms:** `ERR_MODULE_NOT_FOUND` or `Cannot find module` when running via npx. + +**Fix:** Preflight requires **Node 20+**. Check your version: +```bash +node --version +``` +If you're on Node 18 or below, upgrade via [nvm](https://github.com/nvm-sh/nvm): +```bash +nvm install 20 +nvm use 20 +``` + +### Tools don't appear in Claude Code after `claude mcp add` + +**Fix:** Restart Claude Code completely after adding the MCP server. The tool list is loaded at startup. + +If tools still don't appear, verify the server starts without errors: +```bash +npx preflight-dev-serve +``` +You should see MCP protocol output (JSON on stdout). If you see errors, check the sections below. + +--- + +## LanceDB & Timeline Search + +### `Error: Failed to open LanceDB` or LanceDB crashes on startup + +**Symptoms:** Timeline tools (`search_timeline`, `index_sessions`, etc.) fail. Other tools work fine. + +**Cause:** LanceDB uses native binaries that may not be available for your platform, or the database directory has permission issues. + +**Fixes:** +1. Make sure `~/.preflight/projects/` is writable: + ```bash + mkdir -p ~/.preflight/projects + ls -la ~/.preflight/ + ``` +2. If on an unsupported platform, use the **minimal** or **standard** profile (no LanceDB required): + ```bash + npx preflight-dev + # Choose "minimal" or "standard" when prompted + ``` +3. Clear corrupted LanceDB data: + ```bash + rm -rf ~/.preflight/projects/*/timeline.lance + ``` + Then re-index with `index_sessions`. + +### Timeline search returns no results + +**Cause:** Sessions haven't been indexed yet. Preflight doesn't auto-index — you need to run `index_sessions` first. + +**Fix:** In Claude Code, run: +``` +index my sessions +``` +Or use the `index_sessions` tool directly. Indexing reads your `~/.claude/projects/` session files. + +--- + +## Embeddings + +### `OpenAI API key required for openai embedding provider` + +**Cause:** You selected OpenAI embeddings but didn't set the API key. + +**Fixes:** + +Option A — Set the environment variable when adding the MCP server: +```bash +claude mcp add preflight \ + -e OPENAI_API_KEY=sk-... \ + -- npx -y preflight-dev-serve +``` + +Option B — Switch to local embeddings (no API key needed). Create or edit `~/.preflight/config.json`: +```json +{ + "embedding_provider": "local", + "embedding_model": "Xenova/all-MiniLM-L6-v2" +} +``` + +### Local embeddings are slow on first run + +**Expected.** The model (~80MB) downloads on first use and is cached afterward. Subsequent runs are fast. + +--- + +## `.preflight/` Config + +### `warning - failed to parse .preflight/config.yml` + +**Cause:** YAML syntax error in your project's `.preflight/config.yml`. + +**Fix:** Validate your YAML: +```bash +npx yaml-lint .preflight/config.yml +``` +Or check for common issues: wrong indentation, tabs instead of spaces, missing colons. + +### Config changes not taking effect + +**Cause:** Preflight reads config at MCP server startup, not on every tool call. + +**Fix:** Restart Claude Code after editing `.preflight/config.yml` or `.preflight/triage.yml`. + +--- + +## Profiles + +### Which profile should I use? + +| Profile | Tools | Best for | +|---------|-------|----------| +| **minimal** | 4 | Try it out, low overhead | +| **standard** | 16 | Daily use, no vector search needed | +| **full** | 20 | Power users who want timeline search | + +You can change profiles by re-running the setup wizard: +```bash +npx preflight-dev +``` + +--- + +## Platform-Specific + +### Apple Silicon (M1/M2/M3/M4): LanceDB build fails + +LanceDB ships prebuilt binaries for Apple Silicon. If `npm install` fails on the native module: +```bash +# Ensure you're using the ARM64 version of Node +node -p process.arch # should print "arm64" + +# If it prints "x64", reinstall Node natively (not via Rosetta) +``` + +### Linux: Permission denied on `~/.preflight/` + +```bash +chmod -R u+rwX ~/.preflight/ +``` + +--- + +## Still stuck? + +1. Check [open issues](https://github.com/TerminalGravity/preflight/issues) — someone may have hit the same problem +2. [Open a new issue](https://github.com/TerminalGravity/preflight/issues/new) with: + - Your Node version (`node --version`) + - Your OS and architecture (`uname -a`) + - The full error message + - Which profile you selected diff --git a/examples/.preflight/config.yml b/examples/.preflight/config.yml index f59170f..dde3cff 100644 --- a/examples/.preflight/config.yml +++ b/examples/.preflight/config.yml @@ -1,35 +1,35 @@ -# .preflight/config.yml — Drop this in your project root -# -# This is an example config for a typical Next.js + microservices setup. -# Every field is optional — preflight works with sensible defaults out of the box. -# Commit this to your repo so the whole team gets the same preflight behavior. +# .preflight/config.yml +# Drop this directory in your project root to configure preflight per-project. +# All fields are optional — defaults are used for anything you omit. -# Profile controls how much detail preflight returns. -# "minimal" — only flags ambiguous+ prompts, skips clarification detail -# "standard" — balanced (default) -# "full" — maximum detail on every non-trivial prompt +# Tool profile: how many tools to expose +# minimal — 4 tools (preflight_check, prompt_score, clarify_intent, scope_work) +# standard — 16 tools (adds scorecards, cost estimation, corrections, contracts) +# full — 20 tools (adds LanceDB timeline search — requires Node 20+) profile: standard -# Related projects for cross-service awareness. -# Preflight will search these for shared types, routes, and contracts -# so it can warn you when a change might break a consumer. +# Related projects for cross-service contract search. +# Preflight scans these for shared types, API routes, and schemas. related_projects: - - path: /Users/you/code/auth-service - alias: auth - - path: /Users/you/code/billing-api - alias: billing - - path: /Users/you/code/shared-types + - path: ../api-service + alias: api + - path: ../shared-types alias: types -# Behavioral thresholds — tune these to your workflow +# Tuning knobs thresholds: - session_stale_minutes: 30 # Warn if no activity for this long - max_tool_calls_before_checkpoint: 100 # Suggest a checkpoint after N tool calls - correction_pattern_threshold: 3 # Min corrections before flagging a pattern + # Minutes before a session is considered "stale" (triggers session_health warnings) + session_stale_minutes: 30 -# Embedding provider for semantic search over session history. -# "local" uses Xenova transformers (no API key needed, runs on CPU). -# "openai" uses text-embedding-3-small (faster, needs OPENAI_API_KEY). + # Tool calls before preflight nudges you to checkpoint progress + max_tool_calls_before_checkpoint: 100 + + # How many times a correction pattern repeats before it becomes a warning + correction_pattern_threshold: 3 + +# Embedding config (only matters for "full" profile with timeline search) embeddings: + # "local" — runs Xenova/all-MiniLM-L6-v2 in-process, no API key needed + # "openai" — uses text-embedding-3-small, requires openai_api_key or OPENAI_API_KEY env var provider: local - # openai_api_key: sk-... # Uncomment if using openai provider + # openai_api_key: sk-... # or set OPENAI_API_KEY env var instead diff --git a/examples/.preflight/triage.yml b/examples/.preflight/triage.yml index b3d394e..32261d3 100644 --- a/examples/.preflight/triage.yml +++ b/examples/.preflight/triage.yml @@ -1,45 +1,37 @@ -# .preflight/triage.yml — Controls how preflight classifies your prompts -# -# The triage engine routes prompts into categories: -# TRIVIAL → pass through (commit, format, lint) -# CLEAR → well-specified, no intervention needed -# AMBIGUOUS → needs clarification before proceeding -# MULTI-STEP → complex task, preflight suggests a plan -# CROSS-SERVICE → touches multiple projects, pulls in contracts -# -# Customize the keywords below to match your domain. +# .preflight/triage.yml +# Controls how preflight_check triages your prompts. +# Separate from config.yml so you can tune triage rules independently. + +# Strictness level: +# relaxed — only flags clearly ambiguous prompts +# standard — balanced (default) +# strict — flags anything that could be more specific +strictness: standard rules: - # Prompts containing these words are always flagged as AMBIGUOUS. - # Add domain-specific terms that tend to produce vague prompts. + # Keywords that ALWAYS trigger a full preflight check, even for short prompts. + # Use this for high-risk areas of your codebase. always_check: - rewards - permissions - migration - schema - - pricing # example: your billing domain - - onboarding # example: multi-step user flows + - billing + - auth - # Prompts containing these words skip checks entirely (TRIVIAL). - # These are safe, mechanical tasks that don't need guardrails. + # Keywords that SKIP preflight checks entirely. + # Use this for low-risk, routine commands. skip: - commit - format - lint - prettier - - "git push" - # Prompts containing these words trigger CROSS-SERVICE classification. - # Preflight will search related_projects for relevant types and routes. + # Keywords that trigger cross-service contract scanning. + # When these appear, preflight also checks related_projects for shared types. cross_service_keywords: - auth - notification - event - webhook - - billing # matches the related_project alias - -# How aggressively to classify prompts. -# "relaxed" — more prompts pass as clear (experienced users) -# "standard" — balanced (default) -# "strict" — more prompts flagged as ambiguous (new teams, complex codebases) -strictness: standard + - payment 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..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..24894f5 --- /dev/null +++ b/src/tools/export-report.ts @@ -0,0 +1,468 @@ +// ============================================================================= +// export_report — Export timeline data to structured markdown reports +// Addresses: https://github.com/TerminalGravity/preflight/issues/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 } from "fs"; +import { dirname } from "path"; +import type { SearchScope } from "../types.js"; + +// ── Types ────────────────────────────────────────────────────────────────── + +interface DaySummary { + date: string; + events: any[]; + prompts: number; + responses: number; + toolCalls: number; + corrections: number; + commits: number; + errors: number; + compactions: number; + subAgentSpawns: number; +} + +interface ReportData { + project: string; + period: { since: string; until: string }; + days: DaySummary[]; + totals: { + events: number; + prompts: number; + responses: number; + toolCalls: number; + corrections: number; + commits: number; + errors: number; + compactions: number; + subAgentSpawns: number; + activeDays: number; + }; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +const RELATIVE_DATE_RE = /^(\d+)(days?|weeks?|months?)$/; + +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); + 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] : []; + } +} + +// ── Data Collection ──────────────────────────────────────────────────────── + +async function collectReportData(opts: { + scope: SearchScope; + project?: string; + since?: string; + until?: string; +}): Promise { + const sinceISO = opts.since ? parseRelativeDate(opts.since) : undefined; + const untilISO = opts.until ? parseRelativeDate(opts.until) : undefined; + + let projectDirs: string[]; + if (opts.project) { + projectDirs = [opts.project]; + } else { + projectDirs = await getSearchProjects(opts.scope); + } + + const events = await getTimeline({ + project_dirs: projectDirs, + since: sinceISO, + until: untilISO, + limit: 5000, + offset: 0, + }); + + // Group by day + const dayMap = new Map(); + for (const ev of events) { + const day = ev.timestamp + ? new Date(ev.timestamp).toISOString().slice(0, 10) + : "unknown"; + if (!dayMap.has(day)) dayMap.set(day, []); + dayMap.get(day)!.push(ev); + } + + const days: DaySummary[] = []; + for (const [date, dayEvents] of [...dayMap.entries()].sort()) { + const count = (type: string) => dayEvents.filter((e: any) => e.type === type).length; + days.push({ + date, + events: dayEvents, + prompts: count("prompt"), + responses: count("assistant"), + toolCalls: count("tool_call"), + corrections: count("correction"), + commits: count("commit"), + errors: count("error"), + compactions: count("compaction"), + subAgentSpawns: count("sub_agent_spawn"), + }); + } + + const totals = { + events: events.length, + prompts: days.reduce((s, d) => s + d.prompts, 0), + responses: days.reduce((s, d) => s + d.responses, 0), + toolCalls: days.reduce((s, d) => s + d.toolCalls, 0), + corrections: days.reduce((s, d) => s + d.corrections, 0), + commits: days.reduce((s, d) => s + d.commits, 0), + errors: days.reduce((s, d) => s + d.errors, 0), + compactions: days.reduce((s, d) => s + d.compactions, 0), + subAgentSpawns: days.reduce((s, d) => s + d.subAgentSpawns, 0), + activeDays: days.length, + }; + + const projectName = + opts.project || + events[0]?.project_name || + events[0]?.project || + "unknown"; + + return { + project: projectName, + period: { + since: sinceISO || (days[0]?.date ?? "unknown"), + until: untilISO || (days[days.length - 1]?.date ?? "unknown"), + }, + days, + totals, + }; +} + +// ── Report Formatters ────────────────────────────────────────────────────── + +function formatWeeklySummary(data: ReportData): string { + const lines: string[] = []; + const { totals, days, project, period } = data; + + lines.push(`# 📋 Weekly Summary Report`); + lines.push(`**Project:** ${project}`); + lines.push( + `**Period:** ${period.since.slice(0, 10)} → ${period.until.slice(0, 10)} (${totals.activeDays} active days)`, + ); + lines.push(`**Generated:** ${new Date().toISOString().slice(0, 10)}`); + lines.push(""); + + // Overview stats + lines.push(`## Overview`); + lines.push(""); + lines.push(`| Metric | Count |`); + lines.push(`|--------|-------|`); + lines.push(`| Total events | ${totals.events} |`); + lines.push(`| Prompts | ${totals.prompts} |`); + lines.push(`| Tool calls | ${totals.toolCalls} |`); + lines.push(`| Commits | ${totals.commits} |`); + lines.push(`| Corrections | ${totals.corrections} |`); + lines.push(`| Errors | ${totals.errors} |`); + lines.push(`| Compactions | ${totals.compactions} |`); + lines.push(`| Sub-agent spawns | ${totals.subAgentSpawns} |`); + lines.push(""); + + // Correction rate + const correctionRate = + totals.prompts > 0 + ? ((totals.corrections / totals.prompts) * 100).toFixed(1) + : "0.0"; + lines.push( + `**Correction rate:** ${correctionRate}% (${totals.corrections} corrections / ${totals.prompts} prompts)`, + ); + lines.push(""); + + // Daily breakdown + lines.push(`## Daily Activity`); + lines.push(""); + lines.push(`| Date | 💬 | 🔧 | 📦 | ❌ | ⚠️ |`); + lines.push(`|------|-----|-----|-----|-----|-----|`); + for (const day of days) { + lines.push( + `| ${day.date} | ${day.prompts} | ${day.toolCalls} | ${day.commits} | ${day.corrections} | ${day.errors} |`, + ); + } + lines.push(""); + + // Busiest day + const busiest = [...days].sort((a, b) => b.events.length - a.events.length)[0]; + if (busiest) { + lines.push( + `**Busiest day:** ${busiest.date} (${busiest.events.length} events, ${busiest.commits} commits)`, + ); + } + + // Key commits + const allCommits = days.flatMap((d) => + d.events.filter((e: any) => e.type === "commit"), + ); + if (allCommits.length > 0) { + lines.push(""); + lines.push(`## Key Commits`); + lines.push(""); + for (const c of allCommits.slice(0, 20)) { + 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})_`); + } + } + + // Errors + const allErrors = days.flatMap((d) => + d.events.filter((e: any) => e.type === "error"), + ); + if (allErrors.length > 0) { + lines.push(""); + lines.push(`## Errors Encountered`); + lines.push(""); + for (const e of allErrors.slice(0, 10)) { + const msg = (e.content || "").slice(0, 200).replace(/\n/g, " "); + lines.push(`- ⚠️ ${msg}`); + } + } + + return lines.join("\n"); +} + +function formatDetailedTimeline(data: ReportData): string { + const lines: string[] = []; + + lines.push(`# 🕐 Detailed Timeline Report`); + lines.push(`**Project:** ${data.project}`); + lines.push( + `**Period:** ${data.period.since.slice(0, 10)} → ${data.period.until.slice(0, 10)}`, + ); + lines.push(`**Generated:** ${new Date().toISOString().slice(0, 10)}`); + lines.push(""); + + for (const day of data.days) { + lines.push(`## ${day.date}`); + lines.push( + `_${day.events.length} events: ${day.prompts} prompts, ${day.toolCalls} tool calls, ${day.commits} commits_`, + ); + lines.push(""); + + // Sort events chronologically + const sorted = [...day.events].sort((a: any, b: any) => { + 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 ev of sorted) { + const time = ev.timestamp + ? new Date(ev.timestamp).toISOString().slice(11, 16) + : "??:??"; + const icon = TYPE_ICONS[ev.type] || "❓"; + let content = (ev.content || ev.summary || "") + .slice(0, 150) + .replace(/\n/g, " "); + + if (ev.type === "commit") { + const hash = ev.commit_hash ? ev.commit_hash.slice(0, 7) + ": " : ""; + content = `\`${hash}\`${content}`; + } else if (ev.type === "tool_call") { + const tool = ev.tool_name || ""; + content = tool ? `**${tool}** ${content}` : content; + } + + lines.push(`- ${time} ${icon} ${content}`); + } + lines.push(""); + } + + return lines.join("\n"); +} + +function formatActivityDigest(data: ReportData): string { + const lines: string[] = []; + const { totals, days } = data; + + lines.push(`# 📊 Activity Digest`); + lines.push(`**${data.project}** — ${data.period.since.slice(0, 10)} to ${data.period.until.slice(0, 10)}`); + lines.push(""); + + // Sparkline-style daily activity + const maxEvents = Math.max(...days.map((d) => d.events.length), 1); + lines.push(`## Daily Activity`); + lines.push(""); + for (const day of days) { + const bar = "█".repeat(Math.max(1, Math.round((day.events.length / maxEvents) * 20))); + lines.push(`${day.date} ${bar} ${day.events.length}`); + } + lines.push(""); + + // Prompt quality indicators + const avgPromptsPerDay = + totals.activeDays > 0 + ? (totals.prompts / totals.activeDays).toFixed(1) + : "0"; + const toolsPerPrompt = + totals.prompts > 0 + ? (totals.toolCalls / totals.prompts).toFixed(1) + : "0"; + const correctionRate = + totals.prompts > 0 + ? ((totals.corrections / totals.prompts) * 100).toFixed(1) + : "0"; + + lines.push(`## Quality Indicators`); + lines.push(""); + lines.push(`- **Avg prompts/day:** ${avgPromptsPerDay}`); + lines.push(`- **Tool calls per prompt:** ${toolsPerPrompt}`); + lines.push(`- **Correction rate:** ${correctionRate}%`); + lines.push( + `- **Commit frequency:** ${totals.commits} commits over ${totals.activeDays} days`, + ); + + if (totals.errors > 0) { + lines.push(`- **Errors:** ${totals.errors} ⚠️`); + } + if (totals.compactions > 0) { + lines.push(`- **Compactions:** ${totals.compactions} (consider shorter sessions)`); + } + + return lines.join("\n"); +} + +// ── Tool Registration ────────────────────────────────────────────────────── + +export function registerExportReport(server: McpServer): void { + server.tool( + "export_report", + "Export timeline data as a structured markdown report. Generates weekly summaries, detailed timelines, or activity digests with prompt quality trends, commit history, and error analysis.", + { + scope: z + .enum(["current", "related", "all"]) + .default("current") + .describe( + "Search scope: current project, related projects, or all indexed", + ), + project: z + .string() + .optional() + .describe("Filter to a specific project (overrides scope)"), + since: z + .string() + .optional() + .describe( + "Start date — ISO string or relative like '7days', '2weeks', '1month'", + ), + until: z.string().optional().describe("End date (ISO or relative)"), + format: z + .enum(["weekly", "detailed", "digest"]) + .default("weekly") + .describe( + "Report format: weekly (summary stats + commits), detailed (full timeline), digest (compact activity overview)", + ), + save_to: z + .string() + .optional() + .describe( + "File path to save the report. If omitted, returns inline.", + ), + }, + async (params) => { + const data = await collectReportData({ + scope: params.scope, + project: params.project, + since: params.since || "7days", + until: params.until, + }); + + if (data.days.length === 0) { + return { + content: [ + { + type: "text" as const, + text: "No timeline data found for the given filters. Try broadening the time range or checking that projects are onboarded.", + }, + ], + }; + } + + let report: string; + switch (params.format) { + case "detailed": + report = formatDetailedTimeline(data); + break; + case "digest": + report = formatActivityDigest(data); + break; + case "weekly": + default: + report = formatWeeklySummary(data); + break; + } + + // Save to file if requested + if (params.save_to) { + try { + mkdirSync(dirname(params.save_to), { recursive: true }); + writeFileSync(params.save_to, report, "utf-8"); + return { + content: [ + { + type: "text" as const, + text: `✅ Report saved to \`${params.save_to}\`\n\n${report}`, + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: "text" as const, + text: `⚠️ Failed to save report: ${err}\n\n${report}`, + }, + ], + }; + } + } + + return { content: [{ type: "text" as const, text: report }] }; + }, + ); +}