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/lib/git.ts b/src/lib/git.ts index a32ee3c..e8e0eb2 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -1,6 +1,5 @@ import { execFileSync } from "child_process"; import { PROJECT_DIR } from "./files.js"; -import type { RunError } from "../types.js"; /** * Run a git command safely using execFileSync (no shell injection). @@ -30,11 +29,6 @@ export function run(argsOrCmd: string | string[], opts: { timeout?: number } = { } } -/** Convenience: run a raw command string (split on spaces). Only for simple, known-safe commands. */ -function gitCmd(cmdStr: string, opts?: { timeout?: number }): string { - return run(cmdStr.split(/\s+/), opts); -} - /** Get the current branch name. */ export function getBranch(): string { return run(["branch", "--show-current"]); diff --git a/src/tools/export-report.ts b/src/tools/export-report.ts new file mode 100644 index 0000000..71c66d2 --- /dev/null +++ b/src/tools/export-report.ts @@ -0,0 +1,350 @@ +// ============================================================================= +// export_report — Generate markdown session reports from timeline data +// Closes #5: Export timeline to markdown/PDF reports +// ============================================================================= + +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"; +import { writeFile, mkdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +const TYPE_ICONS: Record = { + prompt: "💬", + assistant: "🤖", + tool_call: "🔧", + correction: "❌", + commit: "📦", + compaction: "🗜️", + sub_agent_spawn: "🚀", + error: "⚠️", +}; + +interface DaySummary { + date: string; + prompts: number; + corrections: number; + commits: number; + compactions: number; + toolCalls: number; + subAgents: number; + errors: number; + events: any[]; +} + +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] : []; + } +} + +function getDateRange(period: string): { since: string; until: string; label: string } { + const now = new Date(); + const until = now.toISOString(); + const start = new Date(now); + + switch (period) { + case "day": + start.setDate(start.getDate() - 1); + return { since: start.toISOString(), until, label: "Daily Report" }; + case "week": + start.setDate(start.getDate() - 7); + return { since: start.toISOString(), until, label: "Weekly Report" }; + case "month": + start.setMonth(start.getMonth() - 1); + return { since: start.toISOString(), until, label: "Monthly Report" }; + default: + start.setDate(start.getDate() - 7); + return { since: start.toISOString(), until, label: "Weekly Report" }; + } +} + +function groupByDay(events: any[]): DaySummary[] { + const days = new Map(); + + for (const event of events) { + const date = event.timestamp + ? new Date(event.timestamp).toISOString().slice(0, 10) + : "unknown"; + if (!days.has(date)) { + days.set(date, { + date, + prompts: 0, + corrections: 0, + commits: 0, + compactions: 0, + toolCalls: 0, + subAgents: 0, + errors: 0, + events: [], + }); + } + const day = days.get(date)!; + day.events.push(event); + + switch (event.type) { + case "prompt": + day.prompts++; + break; + case "correction": + day.corrections++; + break; + case "commit": + day.commits++; + break; + case "compaction": + day.compactions++; + break; + case "tool_call": + day.toolCalls++; + break; + case "sub_agent_spawn": + day.subAgents++; + break; + case "error": + day.errors++; + break; + } + } + + return [...days.values()].sort((a, b) => b.date.localeCompare(a.date)); +} + +function renderMarkdown( + days: DaySummary[], + label: string, + projectName: string, + since: string, + until: string, + includeTrends: boolean, +): string { + const lines: string[] = []; + const sinceDate = since.slice(0, 10); + const untilDate = until.slice(0, 10); + + // Header + lines.push(`# ${label}: ${projectName}`); + lines.push(`> ${sinceDate} → ${untilDate}`); + lines.push(""); + lines.push(`_Generated ${new Date().toISOString().slice(0, 16)}_`); + lines.push(""); + + // Totals + const totals = days.reduce( + (acc, d) => ({ + prompts: acc.prompts + d.prompts, + corrections: acc.corrections + d.corrections, + commits: acc.commits + d.commits, + compactions: acc.compactions + d.compactions, + toolCalls: acc.toolCalls + d.toolCalls, + subAgents: acc.subAgents + d.subAgents, + errors: acc.errors + d.errors, + events: acc.events + d.events.length, + }), + { + prompts: 0, + corrections: 0, + commits: 0, + compactions: 0, + toolCalls: 0, + subAgents: 0, + errors: 0, + events: 0, + }, + ); + + lines.push("## Summary"); + lines.push(""); + lines.push(`| Metric | Count |`); + lines.push(`|--------|-------|`); + lines.push(`| Total events | ${totals.events} |`); + lines.push(`| Prompts | ${totals.prompts} |`); + lines.push(`| Corrections | ${totals.corrections} |`); + lines.push(`| Commits | ${totals.commits} |`); + lines.push(`| Tool calls | ${totals.toolCalls} |`); + lines.push(`| Sub-agents | ${totals.subAgents} |`); + lines.push(`| Compactions | ${totals.compactions} |`); + lines.push(`| Errors | ${totals.errors} |`); + lines.push(""); + + // Correction rate + if (totals.prompts > 0) { + const rate = ((totals.corrections / totals.prompts) * 100).toFixed(1); + lines.push(`**Correction rate:** ${rate}% (${totals.corrections}/${totals.prompts} prompts)`); + lines.push(""); + } + + // Trend table (daily breakdown) + if (includeTrends && days.length > 1) { + lines.push("## Daily Breakdown"); + lines.push(""); + lines.push("| Date | Prompts | Corrections | Commits | Errors |"); + lines.push("|------|---------|-------------|---------|--------|"); + for (const day of days) { + const corrMark = day.corrections > 0 ? ` ⚠️` : ""; + lines.push( + `| ${day.date} | ${day.prompts} | ${day.corrections}${corrMark} | ${day.commits} | ${day.errors} |`, + ); + } + lines.push(""); + } + + // Per-day event log + lines.push("## Event Log"); + lines.push(""); + + for (const day of days) { + lines.push(`### ${day.date}`); + lines.push(""); + + // Sort events by timestamp + 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 event of sorted) { + 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, 150) + .replace(/\n/g, " "); + + if (event.type === "commit") { + const hash = event.commit_hash ? event.commit_hash.slice(0, 7) : ""; + content = hash ? `\`${hash}\` ${content}` : content; + } + + lines.push(`- ${time} ${icon} **${event.type}** — ${content}`); + } + lines.push(""); + } + + return lines.join("\n"); +} + +export function registerExportReport(server: McpServer): void { + server.tool( + "export_report", + "Generate a markdown session report from timeline data. Creates weekly/daily/monthly summaries with prompt quality trends, correction rates, and activity breakdown. 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)"), + period: z + .enum(["day", "week", "month"]) + .default("week") + .describe("Report period"), + since: z + .string() + .optional() + .describe("Custom start date (ISO format, overrides period)"), + until: z + .string() + .optional() + .describe("Custom end date (ISO format, overrides period)"), + output: z + .string() + .optional() + .describe("File path to save the report (optional — returns inline if omitted)"), + trends: z + .boolean() + .default(true) + .describe("Include daily breakdown trend table"), + }, + async (params) => { + // Resolve date range + const range = getDateRange(params.period); + const since = params.since || range.since; + const until = params.until || range.until; + const label = params.since ? "Custom Report" : range.label; + + // Resolve projects + 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}". Onboard a project first with \`onboard_project\`.`, + }, + ], + }; + } + + // Fetch all events in range (high limit for reports) + const events = await getTimeline({ + project_dirs: projectDirs, + since, + until, + limit: 5000, + offset: 0, + }); + + if (events.length === 0) { + return { + content: [ + { + type: "text" as const, + text: `No events found for the period ${since.slice(0, 10)} to ${until.slice(0, 10)}. Make sure the project is onboarded.`, + }, + ], + }; + } + + const projectName = params.project || "All Projects"; + const days = groupByDay(events); + const markdown = renderMarkdown(days, label, projectName, since, until, params.trends); + + // Optionally save to file + if (params.output) { + const outputPath = resolve(params.output); + const dir = outputPath.substring(0, outputPath.lastIndexOf("/")); + if (dir) { + await mkdir(dir, { recursive: true }); + } + await writeFile(outputPath, markdown, "utf-8"); + return { + content: [ + { + type: "text" as const, + text: `Report saved to \`${outputPath}\`\n\n${markdown}`, + }, + ], + }; + } + + return { + content: [{ type: "text" as const, text: markdown }], + }; + }, + ); +} diff --git a/src/tools/prompt-score.ts b/src/tools/prompt-score.ts index 1cecf01..92f6d83 100644 --- a/src/tools/prompt-score.ts +++ b/src/tools/prompt-score.ts @@ -30,7 +30,7 @@ async function saveHistory(history: ScoreHistory): Promise { await writeFile(STATE_FILE, JSON.stringify(history, null, 2)); } -interface ScoreResult { +export interface ScoreResult { specificity: number; scope: number; actionability: number; @@ -40,7 +40,7 @@ interface ScoreResult { feedback: string[]; } -function scorePrompt(text: string): ScoreResult { +export function scorePrompt(text: string): ScoreResult { const feedback: string[] = []; let specificity: number; let scope: number; diff --git a/tests/tools/export-report.test.ts b/tests/tools/export-report.test.ts new file mode 100644 index 0000000..1c8f055 --- /dev/null +++ b/tests/tools/export-report.test.ts @@ -0,0 +1,135 @@ +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([]), + getConfig: vi.fn().mockReturnValue({ related_projects: [] }), + hasPreflightConfig: vi.fn().mockReturnValue(false), +})); + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerExportReport } from "../../src/tools/export-report.js"; +import { getTimeline } from "../../src/lib/timeline-db.js"; + +describe("export_report", () => { + let server: McpServer; + let toolHandler: any; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.CLAUDE_PROJECT_DIR = "/test/project"; + + // Capture the tool handler when registered + server = { + tool: vi.fn((name, desc, schema, handler) => { + toolHandler = handler; + }), + } as any; + + registerExportReport(server); + }); + + it("registers the export_report tool", () => { + expect(server.tool).toHaveBeenCalledWith( + "export_report", + expect.any(String), + expect.any(Object), + expect.any(Function), + ); + }); + + it("returns empty message when no events found", async () => { + vi.mocked(getTimeline).mockResolvedValue([]); + + const result = await toolHandler({ + scope: "current", + period: "week", + trends: true, + }); + + expect(result.content[0].text).toContain("No events found"); + }); + + it("generates a markdown report with summary table", async () => { + vi.mocked(getTimeline).mockResolvedValue([ + { + timestamp: "2026-03-17T10:00:00Z", + type: "prompt", + content: "fix the login bug", + project: "/test/project", + }, + { + timestamp: "2026-03-17T10:05:00Z", + type: "commit", + content: "fix: resolve login redirect issue", + commit_hash: "abc1234567890", + project: "/test/project", + }, + { + timestamp: "2026-03-17T11:00:00Z", + type: "correction", + content: "no, use the other auth provider", + project: "/test/project", + }, + { + timestamp: "2026-03-18T09:00:00Z", + type: "prompt", + content: "add tests for auth module", + project: "/test/project", + }, + ]); + + const result = await toolHandler({ + scope: "current", + period: "week", + trends: true, + }); + + const text = result.content[0].text; + + // Check header + expect(text).toContain("Weekly Report"); + + // Check summary table + expect(text).toContain("| Prompts | 2 |"); + expect(text).toContain("| Corrections | 1 |"); + expect(text).toContain("| Commits | 1 |"); + + // Check correction rate + expect(text).toContain("Correction rate:** 50.0%"); + + // Check daily breakdown + expect(text).toContain("Daily Breakdown"); + expect(text).toContain("2026-03-17"); + expect(text).toContain("2026-03-18"); + + // Check event log + expect(text).toContain("Event Log"); + expect(text).toContain("`abc1234`"); + expect(text).toContain("fix the login bug"); + }); + + it("skips trends table when disabled", async () => { + vi.mocked(getTimeline).mockResolvedValue([ + { + timestamp: "2026-03-17T10:00:00Z", + type: "prompt", + content: "test prompt", + project: "/test/project", + }, + ]); + + const result = await toolHandler({ + scope: "current", + period: "day", + trends: false, + }); + + expect(result.content[0].text).not.toContain("Daily Breakdown"); + }); +}); diff --git a/tests/tools/prompt-score.test.ts b/tests/tools/prompt-score.test.ts new file mode 100644 index 0000000..1398587 --- /dev/null +++ b/tests/tools/prompt-score.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest"; +import { scorePrompt } from "../../src/tools/prompt-score.js"; + +describe("scorePrompt", () => { + describe("specificity", () => { + it("gives max specificity for file paths", () => { + const result = scorePrompt("Fix the bug in src/lib/parser.ts"); + expect(result.specificity).toBe(25); + }); + + it("gives max specificity for backtick identifiers", () => { + const result = scorePrompt("Rename `handleClick` to `onClick`"); + expect(result.specificity).toBe(25); + }); + + it("gives partial specificity for generic component words", () => { + const result = scorePrompt("Update the component to handle errors"); + expect(result.specificity).toBe(15); + }); + + it("gives low specificity for vague prompts", () => { + const result = scorePrompt("Make it better"); + expect(result.specificity).toBe(5); + }); + }); + + describe("scope", () => { + it("gives max scope for bounded tasks with 'only'", () => { + const result = scorePrompt("Only change the header color"); + expect(result.scope).toBe(25); + }); + + it("gives max scope for long prompts (>100 chars)", () => { + const prompt = "a".repeat(101); + const result = scorePrompt(prompt); + expect(result.scope).toBe(25); + }); + + it("penalizes broad scope with 'all/every'", () => { + const result = scorePrompt("Fix all bugs"); + expect(result.scope).toBe(10); + }); + }); + + describe("actionability", () => { + it("gives max for specific action verbs", () => { + const result = scorePrompt("Refactor the auth module"); + expect(result.actionability).toBe(25); + }); + + it("gives partial for vague verbs like 'make'", () => { + const result = scorePrompt("Make the tests work"); + expect(result.actionability).toBe(15); + }); + + it("gives low for no action verb", () => { + const result = scorePrompt("The button is blue"); + expect(result.actionability).toBe(5); + }); + }); + + describe("done condition", () => { + it("gives max for prompts with verifiable outcomes", () => { + const result = scorePrompt("Fix it so it should return null on error"); + expect(result.doneCondition).toBe(25); + }); + + it("gives good score for questions", () => { + const result = scorePrompt("Why does this crash?"); + expect(result.doneCondition).toBe(20); + }); + + it("gives low for no done condition", () => { + const result = scorePrompt("Clean up the code"); + expect(result.doneCondition).toBe(5); + }); + }); + + describe("grading", () => { + it("gives A+ for perfect prompts", () => { + // Hits all 4 dimensions: backtick (25), 'only' (25), 'rename' (25), 'should' (25) + const result = scorePrompt("Only rename `foo` to `bar` — it should compile"); + expect(result.total).toBe(100); + expect(result.grade).toBe("A+"); + }); + + it("gives F for completely vague prompts", () => { + const result = scorePrompt("Do it"); + expect(result.total).toBeLessThanOrEqual(45); + expect(result.grade).toBe("F"); + }); + + it("includes feedback for imperfect prompts", () => { + const result = scorePrompt("Make it better"); + expect(result.feedback.length).toBeGreaterThan(0); + expect(result.feedback.some((f) => f.includes("📁"))).toBe(true); + }); + + it("gives congratulatory feedback for perfect scores", () => { + const result = scorePrompt("Only rename `foo` to `bar` — it should compile"); + expect(result.feedback[0]).toContain("🏆"); + }); + }); + + describe("total is sum of dimensions", () => { + it("total equals sum of all four scores", () => { + const result = scorePrompt("Add a test for the login function"); + expect(result.total).toBe( + result.specificity + result.scope + result.actionability + result.doneCondition, + ); + }); + }); +});