preflight-dev 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/cli.js +11 -0
  4. package/dist/cli/init.d.ts +2 -0
  5. package/dist/cli/init.js +154 -0
  6. package/dist/cli/init.js.map +1 -0
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.js +122 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lib/config.d.ts +34 -0
  11. package/dist/lib/config.js +118 -0
  12. package/dist/lib/config.js.map +1 -0
  13. package/dist/lib/embeddings.d.ts +11 -0
  14. package/dist/lib/embeddings.js +88 -0
  15. package/dist/lib/embeddings.js.map +1 -0
  16. package/dist/lib/files.d.ts +15 -0
  17. package/dist/lib/files.js +60 -0
  18. package/dist/lib/files.js.map +1 -0
  19. package/dist/lib/git-extractor.d.ts +9 -0
  20. package/dist/lib/git-extractor.js +116 -0
  21. package/dist/lib/git-extractor.js.map +1 -0
  22. package/dist/lib/git.d.ts +29 -0
  23. package/dist/lib/git.js +86 -0
  24. package/dist/lib/git.js.map +1 -0
  25. package/dist/lib/session-parser.d.ts +45 -0
  26. package/dist/lib/session-parser.js +267 -0
  27. package/dist/lib/session-parser.js.map +1 -0
  28. package/dist/lib/state.d.ts +21 -0
  29. package/dist/lib/state.js +86 -0
  30. package/dist/lib/state.js.map +1 -0
  31. package/dist/lib/timeline-db.d.ts +67 -0
  32. package/dist/lib/timeline-db.js +380 -0
  33. package/dist/lib/timeline-db.js.map +1 -0
  34. package/dist/lib/triage.d.ts +29 -0
  35. package/dist/lib/triage.js +193 -0
  36. package/dist/lib/triage.js.map +1 -0
  37. package/dist/profiles.d.ts +3 -0
  38. package/dist/profiles.js +65 -0
  39. package/dist/profiles.js.map +1 -0
  40. package/dist/tools/audit-workspace.d.ts +2 -0
  41. package/dist/tools/audit-workspace.js +86 -0
  42. package/dist/tools/audit-workspace.js.map +1 -0
  43. package/dist/tools/checkpoint.d.ts +2 -0
  44. package/dist/tools/checkpoint.js +108 -0
  45. package/dist/tools/checkpoint.js.map +1 -0
  46. package/dist/tools/clarify-intent.d.ts +2 -0
  47. package/dist/tools/clarify-intent.js +180 -0
  48. package/dist/tools/clarify-intent.js.map +1 -0
  49. package/dist/tools/enrich-agent-task.d.ts +2 -0
  50. package/dist/tools/enrich-agent-task.js +97 -0
  51. package/dist/tools/enrich-agent-task.js.map +1 -0
  52. package/dist/tools/generate-scorecard.d.ts +2 -0
  53. package/dist/tools/generate-scorecard.js +617 -0
  54. package/dist/tools/generate-scorecard.js.map +1 -0
  55. package/dist/tools/log-correction.d.ts +2 -0
  56. package/dist/tools/log-correction.js +76 -0
  57. package/dist/tools/log-correction.js.map +1 -0
  58. package/dist/tools/onboard-project.d.ts +2 -0
  59. package/dist/tools/onboard-project.js +179 -0
  60. package/dist/tools/onboard-project.js.map +1 -0
  61. package/dist/tools/preflight-check.d.ts +2 -0
  62. package/dist/tools/preflight-check.js +229 -0
  63. package/dist/tools/preflight-check.js.map +1 -0
  64. package/dist/tools/prompt-score.d.ts +2 -0
  65. package/dist/tools/prompt-score.js +132 -0
  66. package/dist/tools/prompt-score.js.map +1 -0
  67. package/dist/tools/scan-sessions.d.ts +2 -0
  68. package/dist/tools/scan-sessions.js +182 -0
  69. package/dist/tools/scan-sessions.js.map +1 -0
  70. package/dist/tools/scope-work.d.ts +2 -0
  71. package/dist/tools/scope-work.js +214 -0
  72. package/dist/tools/scope-work.js.map +1 -0
  73. package/dist/tools/search-history.d.ts +2 -0
  74. package/dist/tools/search-history.js +130 -0
  75. package/dist/tools/search-history.js.map +1 -0
  76. package/dist/tools/sequence-tasks.d.ts +2 -0
  77. package/dist/tools/sequence-tasks.js +165 -0
  78. package/dist/tools/sequence-tasks.js.map +1 -0
  79. package/dist/tools/session-handoff.d.ts +2 -0
  80. package/dist/tools/session-handoff.js +113 -0
  81. package/dist/tools/session-handoff.js.map +1 -0
  82. package/dist/tools/session-health.d.ts +2 -0
  83. package/dist/tools/session-health.js +111 -0
  84. package/dist/tools/session-health.js.map +1 -0
  85. package/dist/tools/session-stats.d.ts +2 -0
  86. package/dist/tools/session-stats.js +112 -0
  87. package/dist/tools/session-stats.js.map +1 -0
  88. package/dist/tools/sharpen-followup.d.ts +2 -0
  89. package/dist/tools/sharpen-followup.js +192 -0
  90. package/dist/tools/sharpen-followup.js.map +1 -0
  91. package/dist/tools/timeline-view.d.ts +2 -0
  92. package/dist/tools/timeline-view.js +165 -0
  93. package/dist/tools/timeline-view.js.map +1 -0
  94. package/dist/tools/token-audit.d.ts +2 -0
  95. package/dist/tools/token-audit.js +227 -0
  96. package/dist/tools/token-audit.js.map +1 -0
  97. package/dist/tools/verify-completion.d.ts +2 -0
  98. package/dist/tools/verify-completion.js +154 -0
  99. package/dist/tools/verify-completion.js.map +1 -0
  100. package/dist/tools/what-changed.d.ts +2 -0
  101. package/dist/tools/what-changed.js +40 -0
  102. package/dist/tools/what-changed.js.map +1 -0
  103. package/dist/types.d.ts +78 -0
  104. package/dist/types.js +2 -0
  105. package/dist/types.js.map +1 -0
  106. package/package.json +52 -0
  107. package/src/cli/init.ts +133 -0
  108. package/src/index.ts +135 -0
  109. package/src/lib/config.ts +157 -0
  110. package/src/lib/embeddings.ts +118 -0
  111. package/src/lib/files.ts +59 -0
  112. package/src/lib/git-extractor.ts +137 -0
  113. package/src/lib/git.ts +89 -0
  114. package/src/lib/session-parser.ts +325 -0
  115. package/src/lib/state.ts +86 -0
  116. package/src/lib/timeline-db.ts +490 -0
  117. package/src/lib/triage.ts +255 -0
  118. package/src/profiles.ts +70 -0
  119. package/src/templates/config.yml +23 -0
  120. package/src/templates/triage.yml +27 -0
  121. package/src/tools/audit-workspace.ts +97 -0
  122. package/src/tools/checkpoint.ts +119 -0
  123. package/src/tools/clarify-intent.ts +191 -0
  124. package/src/tools/enrich-agent-task.ts +108 -0
  125. package/src/tools/generate-scorecard.ts +673 -0
  126. package/src/tools/log-correction.ts +89 -0
  127. package/src/tools/onboard-project.ts +214 -0
  128. package/src/tools/preflight-check.ts +263 -0
  129. package/src/tools/prompt-score.ts +150 -0
  130. package/src/tools/scan-sessions.ts +209 -0
  131. package/src/tools/scope-work.ts +238 -0
  132. package/src/tools/search-history.ts +145 -0
  133. package/src/tools/sequence-tasks.ts +182 -0
  134. package/src/tools/session-handoff.ts +125 -0
  135. package/src/tools/session-health.ts +107 -0
  136. package/src/tools/session-stats.ts +134 -0
  137. package/src/tools/sharpen-followup.ts +200 -0
  138. package/src/tools/timeline-view.ts +181 -0
  139. package/src/tools/token-audit.ts +259 -0
  140. package/src/tools/verify-completion.ts +159 -0
  141. package/src/tools/what-changed.ts +48 -0
  142. package/src/types.ts +87 -0
@@ -0,0 +1,134 @@
1
+ // =============================================================================
2
+ // session_stats — Lightweight JSONL session analysis (no embeddings needed)
3
+ // =============================================================================
4
+
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { z } from "zod";
7
+ import { readdir, readFile } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+
11
+ interface SessionInfo {
12
+ file: string;
13
+ turns: number;
14
+ corrections: number;
15
+ compactions: number;
16
+ }
17
+
18
+ async function findSessionFiles(): Promise<string[]> {
19
+ const baseDir = join(homedir(), ".claude", "projects");
20
+ const files: string[] = [];
21
+
22
+ async function walk(dir: string): Promise<void> {
23
+ try {
24
+ const entries = await readdir(dir, { withFileTypes: true });
25
+ for (const entry of entries) {
26
+ const full = join(dir, entry.name);
27
+ if (entry.isDirectory()) {
28
+ await walk(full);
29
+ } else if (entry.name.endsWith(".jsonl")) {
30
+ files.push(full);
31
+ }
32
+ }
33
+ } catch {
34
+ // skip inaccessible dirs
35
+ }
36
+ }
37
+
38
+ await walk(baseDir);
39
+ return files;
40
+ }
41
+
42
+ async function analyzeSession(filePath: string): Promise<SessionInfo> {
43
+ const content = await readFile(filePath, "utf-8");
44
+ const lines = content.trim().split("\n").filter(Boolean);
45
+ let turns = 0;
46
+ let corrections = 0;
47
+ let compactions = 0;
48
+
49
+ for (const line of lines) {
50
+ try {
51
+ const obj = JSON.parse(line);
52
+ if (obj.type === "human" || obj.role === "human" || obj.role === "user") {
53
+ turns++;
54
+ }
55
+ // Detect corrections: messages containing "no", "wrong", "actually", "instead"
56
+ const text = (obj.message || obj.content || "").toString().toLowerCase();
57
+ if (turns > 0 && /\b(no[,.]|wrong|actually|instead|that's not|not what i)\b/.test(text)) {
58
+ corrections++;
59
+ }
60
+ // Detect compactions
61
+ if (obj.type === "summary" || obj.type === "compaction" || text.includes("compacted") || text.includes("context window")) {
62
+ compactions++;
63
+ }
64
+ } catch {
65
+ // skip malformed lines
66
+ }
67
+ }
68
+
69
+ return { file: filePath, turns, corrections, compactions };
70
+ }
71
+
72
+ export function registerSessionStats(server: McpServer): void {
73
+ server.tool(
74
+ "session_stats",
75
+ "Analyze Claude Code session history from JSONL files. Returns total sessions, prompts, correction rate, and more. No embeddings needed.",
76
+ {
77
+ projectFilter: z.string().optional().describe("Filter to sessions matching this project path substring"),
78
+ },
79
+ async ({ projectFilter }) => {
80
+ const files = await findSessionFiles();
81
+ const filtered = projectFilter
82
+ ? files.filter((f) => f.includes(projectFilter))
83
+ : files;
84
+
85
+ if (filtered.length === 0) {
86
+ return {
87
+ content: [
88
+ {
89
+ type: "text" as const,
90
+ text: "No session files found in ~/.claude/projects/",
91
+ },
92
+ ],
93
+ };
94
+ }
95
+
96
+ const sessions = await Promise.all(filtered.map(analyzeSession));
97
+ const totalTurns = sessions.reduce((s, x) => s + x.turns, 0);
98
+ const totalCorrections = sessions.reduce((s, x) => s + x.corrections, 0);
99
+ const totalCompactions = sessions.reduce((s, x) => s + x.compactions, 0);
100
+ const avgTurns = sessions.length > 0 ? (totalTurns / sessions.length).toFixed(1) : "0";
101
+ const correctionRate = totalTurns > 0 ? ((totalCorrections / totalTurns) * 100).toFixed(1) : "0";
102
+
103
+ // Find most active branches (group by parent dir)
104
+ const branchCounts = new Map<string, number>();
105
+ for (const s of sessions) {
106
+ const parts = s.file.split("/");
107
+ const branch = parts.slice(-2, -1)[0] || "unknown";
108
+ branchCounts.set(branch, (branchCounts.get(branch) || 0) + s.turns);
109
+ }
110
+ const topBranches = [...branchCounts.entries()]
111
+ .sort((a, b) => b[1] - a[1])
112
+ .slice(0, 5)
113
+ .map(([name, count]) => ` ${name}: ${count} prompts`)
114
+ .join("\n");
115
+
116
+ const report = [
117
+ `📊 Session Stats`,
118
+ `────────────────────────`,
119
+ `Sessions: ${sessions.length}`,
120
+ `Total prompts: ${totalTurns}`,
121
+ `Corrections: ${totalCorrections} (${correctionRate}% rate)`,
122
+ `Compactions: ${totalCompactions}`,
123
+ `Avg session len: ${avgTurns} turns`,
124
+ ``,
125
+ `Most active branches:`,
126
+ topBranches || " (none)",
127
+ ].join("\n");
128
+
129
+ return {
130
+ content: [{ type: "text" as const, text: report }],
131
+ };
132
+ }
133
+ );
134
+ }
@@ -0,0 +1,200 @@
1
+ // CATEGORY 4: sharpen_followup — Follow-up Specificity
2
+ import { z } from "zod";
3
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { run } from "../lib/git.js";
5
+ import { now } from "../lib/state.js";
6
+
7
+ /** Parse git porcelain output into deduplicated file paths, handling renames (R/C) */
8
+ function parsePortelainFiles(output: string): string[] {
9
+ if (!output.trim()) return [];
10
+ const files = new Set<string>();
11
+ for (const line of output.split("\n").filter(Boolean)) {
12
+ if (line.length < 4) continue;
13
+ const status = line.slice(0, 2);
14
+ const rest = line.slice(3);
15
+ if (status.startsWith("R") || status.startsWith("C")) {
16
+ // "R old -> new" — include both old and new
17
+ const parts = rest.split(" -> ");
18
+ parts.forEach((p) => { const t = p.trim(); if (t) files.add(t); });
19
+ } else {
20
+ const t = rest.trim();
21
+ if (t) files.add(t);
22
+ }
23
+ }
24
+ return [...files];
25
+ }
26
+
27
+ /** Get recently changed files, safe for first commit / shallow clones */
28
+ function getRecentChangedFiles(): string[] {
29
+ // Try HEAD~1..HEAD, fall back to just staged, then unstaged
30
+ const commands = [
31
+ "git diff --name-only HEAD~1 HEAD 2>/dev/null",
32
+ "git diff --name-only --cached 2>/dev/null",
33
+ "git diff --name-only 2>/dev/null",
34
+ ];
35
+ const results = new Set<string>();
36
+ for (const cmd of commands) {
37
+ const out = run(cmd);
38
+ if (out) out.split("\n").filter(Boolean).forEach((f) => results.add(f));
39
+ if (results.size > 0) break; // first successful source is enough
40
+ }
41
+ return [...results];
42
+ }
43
+
44
+ export function registerSharpenFollowup(server: McpServer): void {
45
+ server.tool(
46
+ "sharpen_followup",
47
+ `Detects vague follow-up prompts and sharpens them with specific files, scope, and context from previous actions and git state. Call when the user says things like "fix it", "do the same for the others", "now the tests" without specifying files or scope.`,
48
+ {
49
+ followup_message: z.string().describe("The user's follow-up message to analyze"),
50
+ previous_action: z.string().describe("Description of what was just done"),
51
+ previous_files: z.array(z.string()).optional().describe("Files involved in the previous action"),
52
+ },
53
+ async ({ followup_message, previous_action, previous_files }) => {
54
+ const msg = followup_message.trim();
55
+ const assumptions: string[] = [];
56
+ const questions: string[] = [];
57
+ let confidence: "high" | "medium" | "low" = "high";
58
+
59
+ // Vagueness detection
60
+ const pronounPattern = /\b(it|them|this|that|those|the others?|these)\b/gi;
61
+ const scopePattern = /\b(all|everything|the rest|everywhere|each one|every)\b/gi;
62
+ const hasPathRef = /[\/\\]|\.(?:ts|js|tsx|jsx|py|rs|go|md|json|yaml|yml|toml|css|html|sh)\b/.test(msg);
63
+ const isBareCommand = msg.length < 30 && !hasPathRef;
64
+
65
+ const pronounMatches = [...new Set([...msg.matchAll(pronounPattern)].map(m => m[0].toLowerCase()))];
66
+ const scopeMatches = [...new Set([...msg.matchAll(scopePattern)].map(m => m[0].toLowerCase()))];
67
+
68
+ const vagueSignals: string[] = [];
69
+ if (pronounMatches.length > 0) vagueSignals.push(`pronouns without antecedents: ${pronounMatches.join(", ")}`);
70
+ if (scopeMatches.length > 0) vagueSignals.push(`scope words without specifics: ${scopeMatches.join(", ")}`);
71
+ if (isBareCommand) vagueSignals.push("bare command with no file/path reference");
72
+
73
+ // If no vagueness detected, pass through
74
+ if (vagueSignals.length === 0) {
75
+ const output = [
76
+ "## Follow-up Analysis",
77
+ "",
78
+ `**Original:** ${msg}`,
79
+ `**Sharpened:** ${msg}`,
80
+ `**Confidence:** high`,
81
+ "",
82
+ "_Follow-up is already specific enough — no changes needed._",
83
+ ].join("\n");
84
+ return { content: [{ type: "text" as const, text: output }] };
85
+ }
86
+
87
+ // Gather context to resolve ambiguity
88
+ const contextFiles: string[] = [...(previous_files ?? [])];
89
+ const recentChanged = getRecentChangedFiles();
90
+ const porcelainOutput = run("git status --porcelain 2>/dev/null");
91
+ const untrackedOrModified = parsePortelainFiles(porcelainOutput);
92
+
93
+ const allKnownFiles = [...new Set([...contextFiles, ...recentChanged, ...untrackedOrModified])].filter(Boolean);
94
+
95
+ let sharpened = msg;
96
+
97
+ // Resolve singular pronouns: "it" / "this" / "that"
98
+ const singularPronouns = pronounMatches.filter(p => ["it", "this", "that"].includes(p));
99
+ if (singularPronouns.length > 0) {
100
+ if (contextFiles.length === 1) {
101
+ for (const p of singularPronouns) {
102
+ sharpened = sharpened.replace(new RegExp(`\\b${p}\\b`, "i"), contextFiles[0]);
103
+ }
104
+ assumptions.push(`Resolved ${singularPronouns.map(p => `"${p}"`).join(", ")} → ${contextFiles[0]} (only file from previous action)`);
105
+ } else if (contextFiles.length > 1) {
106
+ confidence = "low";
107
+ questions.push(`Which file do you mean? Previous action touched: ${contextFiles.join(", ")}`);
108
+ } else if (recentChanged.length === 1) {
109
+ for (const p of singularPronouns) {
110
+ sharpened = sharpened.replace(new RegExp(`\\b${p}\\b`, "i"), recentChanged[0]);
111
+ }
112
+ assumptions.push(`Resolved ${singularPronouns.map(p => `"${p}"`).join(", ")} → ${recentChanged[0]} (only recent git change)`);
113
+ confidence = "medium";
114
+ } else {
115
+ confidence = "low";
116
+ questions.push("Which file or component are you referring to? No single obvious target found.");
117
+ }
118
+ }
119
+
120
+ // Resolve plural pronouns: "them" / "the others" / "these" / "those"
121
+ const pluralPronouns = pronounMatches.filter(p => ["them", "the others", "those", "these"].includes(p));
122
+ if (pluralPronouns.length > 0) {
123
+ const otherFiles = allKnownFiles.filter(f => !contextFiles.slice(0, 1).includes(f));
124
+ if (otherFiles.length > 0 && otherFiles.length <= 10) {
125
+ for (const p of pluralPronouns) {
126
+ sharpened = sharpened.replace(new RegExp(`\\b${p.replace(/\s+/g, "\\s+")}\\b`, "i"), otherFiles.join(", "));
127
+ }
128
+ assumptions.push(`Resolved ${pluralPronouns.map(p => `"${p}"`).join(", ")} → remaining files: ${otherFiles.join(", ")}`);
129
+ confidence = otherFiles.length <= 3 ? "medium" : "low";
130
+ } else if (otherFiles.length > 10) {
131
+ confidence = "low";
132
+ questions.push(`Found ${otherFiles.length} candidate files — too many to assume. Which subset do you mean?`);
133
+ } else {
134
+ confidence = "low";
135
+ questions.push('What does "the others" refer to? No additional files found in context.');
136
+ }
137
+ }
138
+
139
+ // Resolve scope words
140
+ if (scopeMatches.length > 0 && !hasPathRef) {
141
+ if (allKnownFiles.length > 0 && allKnownFiles.length <= 8) {
142
+ assumptions.push(`Scope "${scopeMatches[0]}" interpreted as: ${allKnownFiles.join(", ")}`);
143
+ confidence = confidence === "high" ? "medium" : "low";
144
+ } else if (allKnownFiles.length > 8) {
145
+ confidence = "low";
146
+ questions.push(`"${scopeMatches[0]}" is ambiguous — ${allKnownFiles.length} files in scope. Please specify a directory or glob pattern.`);
147
+ } else {
148
+ confidence = "low";
149
+ questions.push(`What does "${scopeMatches[0]}" cover? No files found in recent context.`);
150
+ }
151
+ }
152
+
153
+ // Bare command enrichment
154
+ if (isBareCommand && contextFiles.length > 0) {
155
+ sharpened = `${sharpened} in ${contextFiles.join(", ")}`;
156
+ assumptions.push(`Added file scope from previous action: ${contextFiles.join(", ")}`);
157
+ if (confidence === "high") confidence = "medium";
158
+ }
159
+
160
+ // Build markdown output
161
+ const lines = [
162
+ "## Follow-up Analysis",
163
+ "",
164
+ `**Original:** ${msg}`,
165
+ `**Sharpened:** ${confidence === "low" && questions.length > 0 ? "(needs clarification)" : sharpened}`,
166
+ `**Confidence:** ${confidence}`,
167
+ `**Previous action:** ${previous_action}`,
168
+ "",
169
+ ];
170
+
171
+ if (vagueSignals.length > 0) {
172
+ lines.push("### Vague Signals Detected");
173
+ vagueSignals.forEach((s) => lines.push(`- ⚠️ ${s}`));
174
+ lines.push("");
175
+ }
176
+
177
+ if (assumptions.length > 0) {
178
+ lines.push("### Assumptions Made");
179
+ assumptions.forEach((a) => lines.push(`- ${a}`));
180
+ lines.push("");
181
+ }
182
+
183
+ if (questions.length > 0) {
184
+ lines.push("### Clarifying Questions");
185
+ questions.forEach((q) => lines.push(`- ❓ ${q}`));
186
+ lines.push("");
187
+ }
188
+
189
+ if (allKnownFiles.length > 0) {
190
+ lines.push("### Available Context Files");
191
+ allKnownFiles.slice(0, 20).forEach((f) => lines.push(`- \`${f}\``));
192
+ lines.push("");
193
+ }
194
+
195
+ lines.push(`_Generated ${now()}_`);
196
+
197
+ return { content: [{ type: "text" as const, text: lines.join("\n") }] };
198
+ }
199
+ );
200
+ }
@@ -0,0 +1,181 @@
1
+ import { z } from "zod";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { getTimeline, listIndexedProjects } from "../lib/timeline-db.js";
4
+ import { getRelatedProjects } from "../lib/config.js";
5
+ import type { SearchScope } from "../types.js";
6
+
7
+ const RELATIVE_DATE_RE = /^(\d+)(days?|weeks?|months?|years?)$/;
8
+
9
+ function parseRelativeDate(input: string): string {
10
+ const match = input.match(RELATIVE_DATE_RE);
11
+ if (!match) return input;
12
+ const [, numStr, unit] = match;
13
+ const num = parseInt(numStr, 10);
14
+ const d = new Date();
15
+ if (unit.startsWith("day")) d.setDate(d.getDate() - num);
16
+ else if (unit.startsWith("week")) d.setDate(d.getDate() - num * 7);
17
+ else if (unit.startsWith("month")) d.setMonth(d.getMonth() - num);
18
+ else if (unit.startsWith("year")) d.setFullYear(d.getFullYear() - num);
19
+ return d.toISOString();
20
+ }
21
+
22
+ const TYPE_ICONS: Record<string, string> = {
23
+ prompt: "💬",
24
+ assistant: "🤖",
25
+ tool_call: "🔧",
26
+ correction: "❌",
27
+ commit: "📦",
28
+ compaction: "🗜️",
29
+ sub_agent_spawn: "🚀",
30
+ error: "⚠️",
31
+ };
32
+
33
+ /** Get project directories to search based on scope */
34
+ async function getSearchProjects(scope: SearchScope): Promise<string[]> {
35
+ const currentProject = process.env.CLAUDE_PROJECT_DIR;
36
+
37
+ switch (scope) {
38
+ case "current":
39
+ return currentProject ? [currentProject] : [];
40
+
41
+ case "related":
42
+ const related = getRelatedProjects();
43
+ return currentProject ? [currentProject, ...related] : related;
44
+
45
+ case "all":
46
+ const projects = await listIndexedProjects();
47
+ return projects.map(p => p.project);
48
+
49
+ default:
50
+ return currentProject ? [currentProject] : [];
51
+ }
52
+ }
53
+
54
+ export function registerTimeline(server: McpServer) {
55
+ server.tool(
56
+ "timeline",
57
+ "Chronological view of project events grouped by day. Shows prompts, responses, tool calls, corrections, and commits in order.",
58
+ {
59
+ scope: z.enum(["current", "related", "all"]).default("current").describe("Search scope: current project, related projects (PREFLIGHT_RELATED), or all indexed projects"),
60
+ project: z.string().optional().describe("Filter to a specific project name (overrides scope)"),
61
+ branch: z.string().optional(),
62
+ author: z.string().optional().describe("Filter commits to this author (partial match, case-insensitive)"),
63
+ since: z.string().optional(),
64
+ until: z.string().optional(),
65
+ type: z.enum(["prompt", "assistant", "correction", "commit", "tool_call", "compaction", "sub_agent_spawn", "error", "all"]).default("all"),
66
+ limit: z.number().default(50),
67
+ offset: z.number().default(0),
68
+ },
69
+ async (params) => {
70
+ const since = params.since ? parseRelativeDate(params.since) : undefined;
71
+ const until = params.until ? parseRelativeDate(params.until) : undefined;
72
+
73
+ // Determine which projects to search
74
+ let projectDirs: string[];
75
+ if (params.project) {
76
+ // Specific project overrides scope
77
+ projectDirs = [params.project];
78
+ } else {
79
+ projectDirs = await getSearchProjects(params.scope);
80
+ }
81
+
82
+ if (projectDirs.length === 0) {
83
+ return {
84
+ content: [{
85
+ type: "text",
86
+ text: `## Timeline\n_No projects found for scope "${params.scope}". Make sure CLAUDE_PROJECT_DIR is set or projects are onboarded._`
87
+ }]
88
+ };
89
+ }
90
+
91
+ let events = await getTimeline({
92
+ project_dirs: projectDirs,
93
+ project: undefined, // Don't filter by single project when using project_dirs
94
+ branch: params.branch,
95
+ since,
96
+ until,
97
+ type: params.type === "all" ? undefined : params.type,
98
+ limit: params.limit,
99
+ offset: params.offset,
100
+ });
101
+
102
+ // Post-filter by author
103
+ if (params.author) {
104
+ const authorLower = params.author.toLowerCase();
105
+ events = events.filter((e: any) => {
106
+ if (e.type !== "commit") return true; // only filter commits
107
+ try {
108
+ const meta = JSON.parse(e.metadata || "{}");
109
+ return (meta.author || "").toLowerCase().includes(authorLower);
110
+ } catch { return true; }
111
+ });
112
+ }
113
+
114
+ if (events.length === 0) {
115
+ return { content: [{ type: "text", text: "## Timeline\n_No events found for the given filters._" }] };
116
+ }
117
+
118
+ // Group by day
119
+ const days = new Map<string, any[]>();
120
+ for (const event of events) {
121
+ const day = event.timestamp ? new Date(event.timestamp).toISOString().slice(0, 10) : "unknown";
122
+ if (!days.has(day)) days.set(day, []);
123
+ days.get(day)!.push(event);
124
+ }
125
+
126
+ // Header
127
+ const proj = params.project || "all projects";
128
+ const branch = params.branch ? ` / ${params.branch}` : "";
129
+ const sortedDays = [...days.keys()].sort().reverse();
130
+ const dateRange = sortedDays.length > 1
131
+ ? `${sortedDays[sortedDays.length - 1]} to ${sortedDays[0]}`
132
+ : sortedDays[0];
133
+
134
+ const lines: string[] = [
135
+ `## Timeline: ${proj}${branch}`,
136
+ `_${dateRange} (${events.length} events)_`,
137
+ "",
138
+ ];
139
+
140
+ for (const day of sortedDays) {
141
+ lines.push(`### ${day}`);
142
+ const dayEvents = days.get(day)!;
143
+ // Sort by timestamp within day
144
+ dayEvents.sort((a: any, b: any) => {
145
+ const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
146
+ const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
147
+ return ta - tb;
148
+ });
149
+
150
+ for (const event of dayEvents) {
151
+ const time = event.timestamp
152
+ ? new Date(event.timestamp).toISOString().slice(11, 16)
153
+ : "??:??";
154
+ const icon = TYPE_ICONS[event.type] || "❓";
155
+ let content = (event.content || event.summary || "").slice(0, 120).replace(/\n/g, " ");
156
+
157
+ // Format based on type
158
+ if (event.type === "commit") {
159
+ const hash = event.commit_hash ? event.commit_hash.slice(0, 7) + ": " : "";
160
+ content = `commit: "${hash}${content}"`;
161
+ } else if (event.type === "tool_call") {
162
+ const tool = event.tool_name || "";
163
+ const target = content ? ` → ${content}` : "";
164
+ content = `${tool}${target}`;
165
+ } else {
166
+ content = `"${content}"`;
167
+ }
168
+
169
+ lines.push(`- ${time} ${icon} ${content}`);
170
+ }
171
+ lines.push("");
172
+ }
173
+
174
+ if (events.length === params.limit) {
175
+ lines.push(`_Showing ${params.limit} events (offset ${params.offset}). Use offset=${params.offset + params.limit} for more._`);
176
+ }
177
+
178
+ return { content: [{ type: "text", text: lines.join("\n") }] };
179
+ }
180
+ );
181
+ }