From b3e519680177172936909585f6d16fcc13e5330a Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Mon, 16 Mar 2026 08:45:19 -0700 Subject: [PATCH 1/4] docs: add usage walkthrough with realistic tool output examples Shows 4 concrete scenarios: vague prompt clarification, multi-step scoping, correction pattern matching, and cross-service awareness. Each example includes the prompt, triage classification, and the actual output users can expect. --- README.md | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/README.md b/README.md index 6d03f5d..7e8b3f5 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,129 @@ The pattern is always the same: vague prompt → Claude guesses → wrong output --- +## Usage Walkthrough + +Here's what preflight actually looks like in practice — real tool calls with realistic output. + +### 1. Vague prompt → automatic clarification + +You type into Claude Code: + +``` +> update the dashboard +``` + +Claude calls `preflight_check` with `{ "prompt": "update the dashboard" }`. Triage classifies this as **ambiguous** (< 50 chars, no file refs). You get back: + +``` +⚠️ AMBIGUOUS — needs clarification before proceeding + +📂 Git State: + Branch: feat/analytics + 3 uncommitted files: src/components/Dashboard.tsx, src/hooks/useDashboard.ts, src/api/metrics.ts + +🔍 Ambiguity Signals: + - "update" is vague — add data? change layout? fix a bug? restyle? + - "dashboard" matches 4 files across the project + - No specific file path or line number referenced + +💡 Try something like: + - "add the new revenue chart to src/components/Dashboard.tsx using the metrics API from src/api/metrics.ts" + - "fix the loading spinner that won't dismiss in the analytics dashboard" + - "update dashboard layout to 3-column grid per the Figma mock" +``` + +### 2. Multi-step task → scoped execution plan + +``` +> refactor the payments module to support Stripe and add webhook handling +``` + +Triage: **multi-step** (multiple verbs + cross-concern). `scope_work` and `sequence_tasks` fire automatically: + +``` +🔶 MULTI-STEP — breaking this down before starting + +📋 Execution Plan: + Step 1: Extract payment interface (src/payments/provider.ts) + → Define PaymentProvider interface with charge(), refund(), webhook() + → Risk: LOW — new file, no existing consumers yet + + Step 2: Implement Stripe adapter (src/payments/stripe.ts) + → Implement PaymentProvider using stripe SDK + → Dependencies: stripe npm package + → Risk: MEDIUM — needs API keys, error handling for network failures + + Step 3: Add webhook endpoint (src/api/webhooks/stripe.ts) + → POST /api/webhooks/stripe with signature verification + → Risk: HIGH — security-sensitive, needs crypto verification + → ⚠️ Checkpoint recommended before this step + + Step 4: Update consumers (src/checkout/*, src/subscriptions/*) + → Swap direct calls to new PaymentProvider interface + → 6 files affected + +🧪 Suggested checkpoints: after Step 1, after Step 3 +⏱️ Estimated tokens: ~45,000 across all steps +``` + +### 3. Correction pattern matching + +You previously logged a correction with `log_correction`: + +``` +> log_correction: "When I said 'update types', I meant only the shared types in packages/shared, not the local type files in each service" +``` + +Next time you say: + +``` +> update the types +``` + +Preflight catches it: + +``` +⚠️ PATTERN MATCH — you've corrected this before + +🔄 Previous correction (2 days ago): + "When I said 'update types', I meant only the shared types in packages/shared, + not the local type files in each service" + +Did you mean shared types in packages/shared? If so, try: + "update shared types in packages/shared/types.ts" +``` + +### 4. Cross-service awareness + +``` +> add a loyalty points field to the user profile +``` + +Triage: **cross-service** (detected `user` + field change pattern, config has related projects). Contracts are searched: + +``` +🔗 CROSS-SERVICE — this change affects multiple projects + +📂 Current project: web-app + - src/types/user.ts → UserProfile interface (line 12) + - prisma/schema.prisma → User model (line 34) + +🔗 Related projects: + - mobile-app: src/api/types.ts → UserProfile (mirrors web-app, line 8) + - rewards-service: src/models/user.ts → UserRecord (includes loyalty_points already?) + - analytics-pipeline: src/schemas/user-events.avro → UserEvent schema + +⚠️ Changing UserProfile requires updates in 3 services. + Suggested order: + 1. prisma/schema.prisma (source of truth) + migrate + 2. web-app src/types/user.ts + 3. mobile-app src/api/types.ts (notify mobile team) + 4. analytics-pipeline schema (may need backfill) +``` + +--- + ## Quick Start ### Option A: npx (fastest — no install) From c9707b940beec124a27c60a568b6350629df168e Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Mon, 16 Mar 2026 08:56:35 -0700 Subject: [PATCH 2/4] examples: add .preflight/ starter config files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README and examples/README.md referenced examples/.preflight/ but the actual config files didn't exist. Added: - config.yml — profile, related projects, thresholds, embeddings - triage.yml — keyword rules and strictness settings - contracts/api.yml — manual contract definition examples All files are heavily commented so users can copy them into their project root and customize without needing to reference the docs. --- examples/.preflight/config.yml | 63 ++++++++++++++++----------- examples/.preflight/contracts/api.yml | 59 ++++++++++--------------- examples/.preflight/triage.yml | 44 +++++++++---------- 3 files changed, 81 insertions(+), 85 deletions(-) diff --git a/examples/.preflight/config.yml b/examples/.preflight/config.yml index f59170f..297d8d7 100644 --- a/examples/.preflight/config.yml +++ b/examples/.preflight/config.yml @@ -1,35 +1,46 @@ -# .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. - -# Profile controls how much detail preflight returns. -# "minimal" — only flags ambiguous+ prompts, skips clarification detail +# .preflight/config.yml — Drop this in your project root. +# Every field is optional. Preflight works out of the box with sensible defaults. +# Commit this to your repo so your whole team gets the same behavior. + +# Profile controls how much detail preflight surfaces. +# "minimal" — only flag ambiguous+, skip clarification detail # "standard" — balanced (default) # "full" — maximum detail on every non-trivial prompt 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 awareness. +# Preflight scans these for shared types, interfaces, and API routes +# so it can warn when a change in one project might break another. related_projects: - - path: /Users/you/code/auth-service - alias: auth - - path: /Users/you/code/billing-api - alias: billing - - path: /Users/you/code/shared-types - alias: types - -# Behavioral thresholds — tune these to your workflow + # Example: a backend API that your frontend talks to + # - path: /Users/you/projects/my-api + # alias: api + + # Example: a shared types package + # - path: /Users/you/projects/shared-types + # alias: shared + +# Behavioral thresholds — tune these to your workflow. 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 + # Warn if no session activity for this many minutes + 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). + # Suggest a checkpoint after this many tool calls in one session + max_tool_calls_before_checkpoint: 100 + + # Minimum correction occurrences before flagging as a pattern + # (e.g., if you keep correcting "use the auth module" 3+ times, + # preflight learns to warn you proactively) + correction_pattern_threshold: 3 + +# Embedding configuration for vector search (timeline tools). +# Local embeddings work offline with no API key — they just need +# a one-time ~90MB model download on first use. embeddings: + # "local" — Xenova/all-MiniLM-L6-v2, runs on-device, no API key needed + # "openai" — OpenAI text-embedding-3-small, needs OPENAI_API_KEY + # "ollama" — Local Ollama server, needs `ollama serve` running provider: local - # openai_api_key: sk-... # Uncomment if using openai provider + + # Only needed if provider is "openai": + # openai_api_key: sk-... diff --git a/examples/.preflight/contracts/api.yml b/examples/.preflight/contracts/api.yml index 512543f..7a2ebd3 100644 --- a/examples/.preflight/contracts/api.yml +++ b/examples/.preflight/contracts/api.yml @@ -1,17 +1,15 @@ -# .preflight/contracts/api.yml — Manual contract definitions +# .preflight/contracts/api.yml — Manual contract definitions. # -# Define shared types and interfaces that preflight should know about. -# These supplement auto-extracted contracts from your codebase. -# Manual definitions win on name conflicts with auto-extracted ones. +# These supplement auto-extraction. Use them when: +# - A related service isn't in TypeScript (preflight can't auto-extract) +# - You want to pin a specific contract shape for stability +# - You're documenting an external API your project depends on # -# Why manual contracts? -# - Document cross-service interfaces that live in docs, not code -# - Define contracts for external APIs your services consume -# - Pin down types that are implicit (e.g., event payloads) +# Preflight uses these to warn when a prompt might affect shared types. - name: User kind: interface - description: Core user model shared across all services + description: Core user object shared between frontend and API fields: - name: id type: string @@ -19,40 +17,29 @@ - name: email type: string required: true - - name: tier - type: "'free' | 'pro' | 'enterprise'" + - name: role + type: "'admin' | 'member' | 'viewer'" required: true - name: createdAt type: Date required: true -- name: AuthToken +- name: ApiResponse kind: interface - description: JWT payload structure from auth-service + description: Standard API response envelope fields: - - name: userId - type: string - required: true - - name: permissions - type: string[] - required: true - - name: expiresAt - type: number - required: true - -- name: WebhookPayload - kind: interface - description: Standard webhook envelope for inter-service events - fields: - - name: event - type: string - required: true - - name: timestamp - type: string + - name: success + type: boolean required: true - name: data - type: Record - required: true - - name: source + type: T + required: false + - name: error type: string - required: true + required: false + +# Add your own shared types below: +# - name: OrderStatus +# kind: enum +# description: Order lifecycle states +# values: [pending, confirmed, shipped, delivered, cancelled] diff --git a/examples/.preflight/triage.yml b/examples/.preflight/triage.yml index b3d394e..4aa02d1 100644 --- a/examples/.preflight/triage.yml +++ b/examples/.preflight/triage.yml @@ -1,45 +1,43 @@ -# .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 classifies your prompts. +# Customize these rules to match your project's domain vocabulary. rules: - # Prompts containing these words are always flagged as AMBIGUOUS. - # Add domain-specific terms that tend to produce vague prompts. + # Prompts containing these keywords are always flagged as AMBIGUOUS or higher. + # Add domain-specific terms that tend to produce vague requests. always_check: - rewards - permissions - migration - schema - - pricing # example: your billing domain - - onboarding # example: multi-step user flows + # Add your own: + # - billing + # - deployment + # - auth - # Prompts containing these words skip checks entirely (TRIVIAL). - # These are safe, mechanical tasks that don't need guardrails. + # Prompts containing these keywords pass through as TRIVIAL. + # These are routine commands where clarification adds no value. skip: - commit - format - lint - - prettier - - "git push" + # Add your own: + # - typecheck + # - "git status" - # Prompts containing these words trigger CROSS-SERVICE classification. - # Preflight will search related_projects for relevant types and routes. + # Prompts containing these keywords trigger CROSS-SERVICE checks. + # These scan related_projects (from config.yml) for shared contracts. cross_service_keywords: - auth - notification - event - webhook - - billing # matches the related_project alias + # Add your own: + # - graphql + # - grpc + # - queue # How aggressively to classify prompts. -# "relaxed" — more prompts pass as clear (experienced users) +# "relaxed" — more prompts pass as clear (less interruption) # "standard" — balanced (default) -# "strict" — more prompts flagged as ambiguous (new teams, complex codebases) +# "strict" — more prompts flagged as ambiguous (maximum savings) strictness: standard From 211246a552a96a25abd5f65f3159983866a45a1f Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Mon, 16 Mar 2026 09:11:10 -0700 Subject: [PATCH 3/4] feat: add export_timeline tool for markdown reports (closes #5) Adds a new MCP tool that generates structured markdown reports from timeline data. Supports three formats: - summary: stats and activity breakdown by type - detailed: full daily breakdown with event-level detail - weekly: week-over-week trend visualization Includes optional save_to parameter for writing reports to disk. 6 tests covering all formats and edge cases. --- src/index.ts | 2 + src/tools/export-timeline.ts | 369 ++++++++++++++++++++++++++++++++++ tests/export-timeline.test.ts | 171 ++++++++++++++++ 3 files changed, 542 insertions(+) create mode 100644 src/tools/export-timeline.ts create mode 100644 tests/export-timeline.test.ts 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..9bcddff --- /dev/null +++ b/src/tools/export-timeline.ts @@ -0,0 +1,369 @@ +// ============================================================================= +// export_timeline — Generate markdown reports from timeline data (closes #5) +// ============================================================================= + +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 { writeFile, mkdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { homedir } from "node:os"; +import type { SearchScope } from "../types.js"; + +const TYPE_LABELS: Record = { + prompt: "Prompt", + assistant: "Response", + tool_call: "Tool Call", + correction: "Correction", + commit: "Commit", + compaction: "Compaction", + sub_agent_spawn: "Sub-agent Spawn", + error: "Error", +}; + +const TYPE_ICONS: Record = { + prompt: "💬", + assistant: "🤖", + tool_call: "🔧", + correction: "❌", + commit: "📦", + compaction: "🗜️", + sub_agent_spawn: "🚀", + error: "⚠️", +}; + +interface ReportStats { + totalEvents: number; + byType: Record; + byDay: Record; + firstEvent: string; + lastEvent: string; + activeDays: number; +} + +function computeStats(events: any[]): ReportStats { + const byType: Record = {}; + const byDay: Record = {}; + let firstEvent = ""; + let lastEvent = ""; + + 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"; + byDay[day] = (byDay[day] || 0) + 1; + if (!firstEvent || e.timestamp < firstEvent) firstEvent = e.timestamp; + if (!lastEvent || e.timestamp > lastEvent) lastEvent = e.timestamp; + } + + return { + totalEvents: events.length, + byType, + byDay, + firstEvent, + lastEvent, + activeDays: Object.keys(byDay).filter((d) => d !== "unknown").length, + }; +} + +function formatSummarySection(stats: ReportStats): string { + const lines: string[] = [ + "## Summary", + "", + `| Metric | Value |`, + `|--------|-------|`, + `| Total Events | ${stats.totalEvents} |`, + `| Active Days | ${stats.activeDays} |`, + `| Date Range | ${stats.firstEvent.slice(0, 10)} → ${stats.lastEvent.slice(0, 10)} |`, + `| Avg Events/Day | ${(stats.totalEvents / Math.max(stats.activeDays, 1)).toFixed(1)} |`, + "", + "### Activity by Type", + "", + "| Type | Count | % |", + "|------|-------|---|", + ]; + + const sorted = Object.entries(stats.byType).sort((a, b) => b[1] - a[1]); + for (const [type, count] of sorted) { + const pct = ((count / stats.totalEvents) * 100).toFixed(1); + const icon = TYPE_ICONS[type] || "❓"; + lines.push(`| ${icon} ${TYPE_LABELS[type] || type} | ${count} | ${pct}% |`); + } + + return lines.join("\n"); +} + +function formatDailyBreakdown(events: any[]): 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 sortedDays = [...days.keys()].sort().reverse(); + const lines: string[] = ["## Daily Breakdown", ""]; + + for (const day of sortedDays) { + const dayEvents = days.get(day)!; + dayEvents.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; + }); + + // Day header with counts + const typeCounts = dayEvents.reduce( + (acc: Record, e: any) => { + acc[e.type] = (acc[e.type] || 0) + 1; + return acc; + }, + {}, + ); + const countStr = Object.entries(typeCounts) + .map(([t, c]) => `${TYPE_ICONS[t] || "❓"}${c}`) + .join(" "); + + lines.push(`### ${day} (${dayEvents.length} events: ${countStr})`); + lines.push(""); + + for (const event of dayEvents) { + const time = event.timestamp + ? new Date(event.timestamp).toISOString().slice(11, 16) + : "??:??"; + const icon = TYPE_ICONS[event.type] || "❓"; + let content = (event.content || event.summary || "").slice(0, 200).replace(/\n/g, " "); + + if (event.type === "commit") { + const hash = event.commit_hash ? event.commit_hash.slice(0, 7) + " " : ""; + content = `\`${hash}\` ${content}`; + } else if (event.type === "tool_call") { + const tool = event.tool_name || ""; + content = tool + (content ? ` → ${content}` : ""); + } + + lines.push(`- **${time}** ${icon} ${content}`); + } + lines.push(""); + } + + return lines.join("\n"); +} + +function formatWeeklySummary(stats: ReportStats): string { + // Group days into weeks + const weeks = new Map(); + for (const [day, count] of Object.entries(stats.byDay)) { + if (day === "unknown") continue; + const d = new Date(day); + // Get Monday of that week + const dayOfWeek = d.getDay(); + const monday = new Date(d); + monday.setDate(d.getDate() - ((dayOfWeek + 6) % 7)); + const weekKey = monday.toISOString().slice(0, 10); + weeks.set(weekKey, (weeks.get(weekKey) || 0) + count); + } + + if (weeks.size === 0) return ""; + + const sorted = [...weeks.entries()].sort((a, b) => b[0].localeCompare(a[0])); + const lines: string[] = [ + "## Weekly Trend", + "", + "| Week Starting | Events | Bar |", + "|---------------|--------|-----|", + ]; + + const maxCount = Math.max(...sorted.map(([, c]) => c)); + for (const [week, count] of sorted) { + const barLen = Math.round((count / maxCount) * 20); + const bar = "█".repeat(barLen) + "░".repeat(20 - barLen); + lines.push(`| ${week} | ${count} | \`${bar}\` |`); + } + + return lines.join("\n"); +} + +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] : []; + } +} + +export function registerExportTimeline(server: McpServer) { + server.tool( + "export_timeline", + "Export timeline data as a structured markdown report. Generates summaries, daily breakdowns, activity stats, and weekly trends. Optionally saves to a file.", + { + scope: z + .enum(["current", "related", "all"]) + .default("current") + .describe("Search scope"), + project: z + .string() + .optional() + .describe("Filter to a specific project (overrides scope)"), + since: z + .string() + .optional() + .describe("Start date (ISO or relative like '7days', '2weeks')"), + until: z.string().optional().describe("End date"), + type: z + .enum([ + "prompt", + "assistant", + "correction", + "commit", + "tool_call", + "compaction", + "sub_agent_spawn", + "error", + "all", + ]) + .default("all"), + format: z + .enum(["summary", "detailed", "weekly"]) + .default("detailed") + .describe( + "Report format: summary (stats only), detailed (full daily breakdown), weekly (week-level trends)", + ), + save_to: z + .string() + .optional() + .describe( + "File path to save the report. If omitted, returns inline.", + ), + limit: z.number().default(500).describe("Max events to include"), + }, + async (params) => { + 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(); + } + + 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" as const, + text: `No projects found for scope "${params.scope}".`, + }, + ], + }; + } + + const events = await getTimeline({ + project_dirs: projectDirs, + project: undefined, + type: params.type === "all" ? undefined : params.type, + since, + until, + limit: params.limit, + offset: 0, + }); + + if (events.length === 0) { + return { + content: [ + { + type: "text" as const, + text: "No events found for the given filters.", + }, + ], + }; + } + + const stats = computeStats(events); + const now = new Date().toISOString().slice(0, 19).replace("T", " "); + const proj = params.project || projectDirs.join(", "); + + // Build report + const sections: string[] = [ + `# Preflight Timeline Report`, + ``, + `> Generated: ${now} `, + `> Project: ${proj} `, + `> Period: ${stats.firstEvent.slice(0, 10)} → ${stats.lastEvent.slice(0, 10)} `, + `> Events: ${stats.totalEvents}`, + "", + "---", + "", + ]; + + // Always include summary + sections.push(formatSummarySection(stats)); + sections.push(""); + + if (params.format === "detailed") { + sections.push(formatDailyBreakdown(events)); + } + + if (params.format === "weekly" || params.format === "detailed") { + const weekly = formatWeeklySummary(stats); + if (weekly) { + sections.push(weekly); + sections.push(""); + } + } + + sections.push("---"); + sections.push("_Report generated by [Preflight](https://github.com/TerminalGravity/preflight)_"); + + const report = sections.join("\n"); + + // Optionally save to file + if (params.save_to) { + const filePath = resolve(params.save_to); + const dir = join(filePath, ".."); + await mkdir(dir, { recursive: true }); + await writeFile(filePath, report, "utf-8"); + return { + content: [ + { + type: "text" as const, + text: `Report saved to \`${filePath}\` (${report.length} chars, ${stats.totalEvents} events).\n\n${report}`, + }, + ], + }; + } + + return { content: [{ type: "text" as const, text: report }] }; + }, + ); +} diff --git a/tests/export-timeline.test.ts b/tests/export-timeline.test.ts new file mode 100644 index 0000000..79fee31 --- /dev/null +++ b/tests/export-timeline.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock timeline-db before importing the module +vi.mock("../src/lib/timeline-db.js", () => ({ + getTimeline: vi.fn(), + listIndexedProjects: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../src/lib/config.js", () => ({ + getRelatedProjects: vi.fn().mockReturnValue([]), +})); + +import { getTimeline } from "../src/lib/timeline-db.js"; + +// We test the tool by calling its handler directly via the server mock +describe("export_timeline", () => { + const mockEvents = [ + { + id: "1", + timestamp: "2026-03-10T10:00:00Z", + type: "prompt", + project: "/test", + content: "How do I fix this bug?", + session_id: "s1", + }, + { + id: "2", + timestamp: "2026-03-10T10:01:00Z", + type: "assistant", + project: "/test", + content: "Here is the fix...", + session_id: "s1", + }, + { + id: "3", + timestamp: "2026-03-10T14:00:00Z", + type: "commit", + project: "/test", + content: "fix: resolve null pointer", + commit_hash: "abc1234def", + session_id: "s1", + }, + { + id: "4", + timestamp: "2026-03-11T09:00:00Z", + type: "tool_call", + project: "/test", + content: "file.ts", + tool_name: "Read", + session_id: "s2", + }, + { + id: "5", + timestamp: "2026-03-11T09:05:00Z", + type: "error", + project: "/test", + content: "Permission denied", + session_id: "s2", + }, + ]; + + let registeredTools: Map; + + beforeEach(async () => { + process.env.CLAUDE_PROJECT_DIR = "/test"; + vi.mocked(getTimeline).mockResolvedValue(mockEvents as any); + registeredTools = new Map(); + + // Mock MCP server + const mockServer = { + tool: (name: string, desc: string, schema: any, handler: Function) => { + registeredTools.set(name, { handler }); + }, + }; + + const { registerExportTimeline } = await import( + "../src/tools/export-timeline.js" + ); + registerExportTimeline(mockServer as any); + }); + + async function callTool(params: Record) { + const tool = registeredTools.get("export_timeline")!; + return tool.handler(params); + } + + it("generates a summary report", async () => { + const result = await callTool({ + scope: "current", + format: "summary", + type: "all", + limit: 500, + offset: 0, + }); + const text = result.content[0].text; + expect(text).toContain("# Preflight Timeline Report"); + expect(text).toContain("## Summary"); + expect(text).toContain("Total Events"); + expect(text).toContain("5"); + // Summary format should NOT include daily breakdown + expect(text).not.toContain("## Daily Breakdown"); + }); + + it("generates a detailed report with daily breakdown", async () => { + const result = await callTool({ + scope: "current", + format: "detailed", + type: "all", + limit: 500, + offset: 0, + }); + const text = result.content[0].text; + expect(text).toContain("## Daily Breakdown"); + expect(text).toContain("2026-03-11"); + expect(text).toContain("2026-03-10"); + expect(text).toContain("## Weekly Trend"); + }); + + it("generates a weekly report", async () => { + const result = await callTool({ + scope: "current", + format: "weekly", + type: "all", + limit: 500, + offset: 0, + }); + const text = result.content[0].text; + expect(text).toContain("## Weekly Trend"); + expect(text).not.toContain("## Daily Breakdown"); + }); + + it("shows correct type breakdown", async () => { + const result = await callTool({ + scope: "current", + format: "summary", + type: "all", + limit: 500, + offset: 0, + }); + const text = result.content[0].text; + // Should show prompt, assistant, commit, tool_call, error + expect(text).toContain("Prompt"); + expect(text).toContain("Commit"); + expect(text).toContain("Tool Call"); + expect(text).toContain("Error"); + }); + + it("returns empty message when no events", async () => { + vi.mocked(getTimeline).mockResolvedValue([]); + const result = await callTool({ + scope: "current", + format: "detailed", + type: "all", + limit: 500, + offset: 0, + }); + expect(result.content[0].text).toContain("No events found"); + }); + + it("returns no projects message when no project dir set", async () => { + delete process.env.CLAUDE_PROJECT_DIR; + const result = await callTool({ + scope: "current", + format: "summary", + type: "all", + limit: 500, + offset: 0, + }); + expect(result.content[0].text).toContain("No projects found"); + }); +}); From 66d34000e2810b6b38c5e32affd5f41ad2368511 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Mon, 16 Mar 2026 09:56:35 -0700 Subject: [PATCH 4/4] feat: add JSON export format + validate save_to path (PR #258 review) - Add 'json' format option for CI/machine-readable export - Validate save_to resolves within project root (prevents path traversal) - Add 3 new tests: JSON output, field inclusion, path escape rejection --- src/tools/export-timeline.ts | 77 ++++++++++++++++++++++++++++++++--- tests/export-timeline.test.ts | 45 ++++++++++++++++++++ 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/src/tools/export-timeline.ts b/src/tools/export-timeline.ts index 9bcddff..325e08d 100644 --- a/src/tools/export-timeline.ts +++ b/src/tools/export-timeline.ts @@ -234,10 +234,10 @@ export function registerExportTimeline(server: McpServer) { ]) .default("all"), format: z - .enum(["summary", "detailed", "weekly"]) + .enum(["summary", "detailed", "weekly", "json"]) .default("detailed") .describe( - "Report format: summary (stats only), detailed (full daily breakdown), weekly (week-level trends)", + "Report format: summary (stats only), detailed (full daily breakdown), weekly (week-level trends), json (machine-readable)", ), save_to: z .string() @@ -345,25 +345,90 @@ export function registerExportTimeline(server: McpServer) { sections.push("---"); sections.push("_Report generated by [Preflight](https://github.com/TerminalGravity/preflight)_"); + // JSON format returns structured data instead of markdown + if (params.format === "json") { + const jsonReport = { + generated: now, + project: proj, + period: { from: stats.firstEvent.slice(0, 10), to: stats.lastEvent.slice(0, 10) }, + stats: { + totalEvents: stats.totalEvents, + activeDays: stats.activeDays, + avgEventsPerDay: +(stats.totalEvents / Math.max(stats.activeDays, 1)).toFixed(1), + byType: stats.byType, + byDay: stats.byDay, + }, + events: events.map((e: any) => ({ + timestamp: e.timestamp, + type: e.type, + content: e.content || e.summary || null, + ...(e.commit_hash ? { commitHash: e.commit_hash } : {}), + ...(e.tool_name ? { toolName: e.tool_name } : {}), + })), + }; + const output = JSON.stringify(jsonReport, null, 2); + + if (params.save_to) { + const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const filePath = resolve(projectRoot, params.save_to); + if (!filePath.startsWith(resolve(projectRoot))) { + return { + content: [ + { + type: "text" as const, + text: `Error: save_to path "${params.save_to}" resolves outside the project root. Use a relative path within your project.`, + }, + ], + }; + } + const dir = join(filePath, ".."); + await mkdir(dir, { recursive: true }); + await writeFile(filePath, output, "utf-8"); + return { + content: [ + { + type: "text" as const, + text: `JSON report saved to \`${filePath}\` (${output.length} chars, ${stats.totalEvents} events).`, + }, + ], + }; + } + + return { content: [{ type: "text" as const, text: output }] }; + } + const report = sections.join("\n"); + const output = report; // Optionally save to file if (params.save_to) { - const filePath = resolve(params.save_to); + const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const filePath = resolve(projectRoot, params.save_to); + // Prevent writes outside the project root + if (!filePath.startsWith(resolve(projectRoot))) { + return { + content: [ + { + type: "text" as const, + text: `Error: save_to path "${params.save_to}" resolves outside the project root. Use a relative path within your project.`, + }, + ], + }; + } const dir = join(filePath, ".."); await mkdir(dir, { recursive: true }); - await writeFile(filePath, report, "utf-8"); + await writeFile(filePath, output, "utf-8"); return { content: [ { type: "text" as const, - text: `Report saved to \`${filePath}\` (${report.length} chars, ${stats.totalEvents} events).\n\n${report}`, + text: `Report saved to \`${filePath}\` (${output.length} chars, ${stats.totalEvents} events).\n\n${output}`, }, ], }; } - return { content: [{ type: "text" as const, text: report }] }; + return { content: [{ type: "text" as const, text: output }] }; }, ); } diff --git a/tests/export-timeline.test.ts b/tests/export-timeline.test.ts index 79fee31..15de740 100644 --- a/tests/export-timeline.test.ts +++ b/tests/export-timeline.test.ts @@ -168,4 +168,49 @@ describe("export_timeline", () => { }); expect(result.content[0].text).toContain("No projects found"); }); + + it("generates a JSON report with structured data", async () => { + const result = await callTool({ + scope: "current", + format: "json", + type: "all", + limit: 500, + offset: 0, + }); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.stats.totalEvents).toBe(5); + expect(parsed.events).toHaveLength(5); + expect(parsed.events[0]).toHaveProperty("timestamp"); + expect(parsed.events[0]).toHaveProperty("type"); + expect(parsed.stats.byType).toHaveProperty("prompt"); + expect(parsed.period).toHaveProperty("from"); + expect(parsed.period).toHaveProperty("to"); + }); + + it("includes commit hash and tool name in JSON events", async () => { + const result = await callTool({ + scope: "current", + format: "json", + type: "all", + limit: 500, + offset: 0, + }); + const parsed = JSON.parse(result.content[0].text); + const commit = parsed.events.find((e: any) => e.type === "commit"); + expect(commit.commitHash).toBe("abc1234def"); + const toolCall = parsed.events.find((e: any) => e.type === "tool_call"); + expect(toolCall.toolName).toBe("Read"); + }); + + it("rejects save_to paths that escape project root", async () => { + const result = await callTool({ + scope: "current", + format: "detailed", + type: "all", + limit: 500, + offset: 0, + save_to: "../../../etc/passwd", + }); + expect(result.content[0].text).toContain("resolves outside the project root"); + }); });