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.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/cli.js +11 -0
- package/dist/cli/init.d.ts +2 -0
- package/dist/cli/init.js +154 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +122 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/config.d.ts +34 -0
- package/dist/lib/config.js +118 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/embeddings.d.ts +11 -0
- package/dist/lib/embeddings.js +88 -0
- package/dist/lib/embeddings.js.map +1 -0
- package/dist/lib/files.d.ts +15 -0
- package/dist/lib/files.js +60 -0
- package/dist/lib/files.js.map +1 -0
- package/dist/lib/git-extractor.d.ts +9 -0
- package/dist/lib/git-extractor.js +116 -0
- package/dist/lib/git-extractor.js.map +1 -0
- package/dist/lib/git.d.ts +29 -0
- package/dist/lib/git.js +86 -0
- package/dist/lib/git.js.map +1 -0
- package/dist/lib/session-parser.d.ts +45 -0
- package/dist/lib/session-parser.js +267 -0
- package/dist/lib/session-parser.js.map +1 -0
- package/dist/lib/state.d.ts +21 -0
- package/dist/lib/state.js +86 -0
- package/dist/lib/state.js.map +1 -0
- package/dist/lib/timeline-db.d.ts +67 -0
- package/dist/lib/timeline-db.js +380 -0
- package/dist/lib/timeline-db.js.map +1 -0
- package/dist/lib/triage.d.ts +29 -0
- package/dist/lib/triage.js +193 -0
- package/dist/lib/triage.js.map +1 -0
- package/dist/profiles.d.ts +3 -0
- package/dist/profiles.js +65 -0
- package/dist/profiles.js.map +1 -0
- package/dist/tools/audit-workspace.d.ts +2 -0
- package/dist/tools/audit-workspace.js +86 -0
- package/dist/tools/audit-workspace.js.map +1 -0
- package/dist/tools/checkpoint.d.ts +2 -0
- package/dist/tools/checkpoint.js +108 -0
- package/dist/tools/checkpoint.js.map +1 -0
- package/dist/tools/clarify-intent.d.ts +2 -0
- package/dist/tools/clarify-intent.js +180 -0
- package/dist/tools/clarify-intent.js.map +1 -0
- package/dist/tools/enrich-agent-task.d.ts +2 -0
- package/dist/tools/enrich-agent-task.js +97 -0
- package/dist/tools/enrich-agent-task.js.map +1 -0
- package/dist/tools/generate-scorecard.d.ts +2 -0
- package/dist/tools/generate-scorecard.js +617 -0
- package/dist/tools/generate-scorecard.js.map +1 -0
- package/dist/tools/log-correction.d.ts +2 -0
- package/dist/tools/log-correction.js +76 -0
- package/dist/tools/log-correction.js.map +1 -0
- package/dist/tools/onboard-project.d.ts +2 -0
- package/dist/tools/onboard-project.js +179 -0
- package/dist/tools/onboard-project.js.map +1 -0
- package/dist/tools/preflight-check.d.ts +2 -0
- package/dist/tools/preflight-check.js +229 -0
- package/dist/tools/preflight-check.js.map +1 -0
- package/dist/tools/prompt-score.d.ts +2 -0
- package/dist/tools/prompt-score.js +132 -0
- package/dist/tools/prompt-score.js.map +1 -0
- package/dist/tools/scan-sessions.d.ts +2 -0
- package/dist/tools/scan-sessions.js +182 -0
- package/dist/tools/scan-sessions.js.map +1 -0
- package/dist/tools/scope-work.d.ts +2 -0
- package/dist/tools/scope-work.js +214 -0
- package/dist/tools/scope-work.js.map +1 -0
- package/dist/tools/search-history.d.ts +2 -0
- package/dist/tools/search-history.js +130 -0
- package/dist/tools/search-history.js.map +1 -0
- package/dist/tools/sequence-tasks.d.ts +2 -0
- package/dist/tools/sequence-tasks.js +165 -0
- package/dist/tools/sequence-tasks.js.map +1 -0
- package/dist/tools/session-handoff.d.ts +2 -0
- package/dist/tools/session-handoff.js +113 -0
- package/dist/tools/session-handoff.js.map +1 -0
- package/dist/tools/session-health.d.ts +2 -0
- package/dist/tools/session-health.js +111 -0
- package/dist/tools/session-health.js.map +1 -0
- package/dist/tools/session-stats.d.ts +2 -0
- package/dist/tools/session-stats.js +112 -0
- package/dist/tools/session-stats.js.map +1 -0
- package/dist/tools/sharpen-followup.d.ts +2 -0
- package/dist/tools/sharpen-followup.js +192 -0
- package/dist/tools/sharpen-followup.js.map +1 -0
- package/dist/tools/timeline-view.d.ts +2 -0
- package/dist/tools/timeline-view.js +165 -0
- package/dist/tools/timeline-view.js.map +1 -0
- package/dist/tools/token-audit.d.ts +2 -0
- package/dist/tools/token-audit.js +227 -0
- package/dist/tools/token-audit.js.map +1 -0
- package/dist/tools/verify-completion.d.ts +2 -0
- package/dist/tools/verify-completion.js +154 -0
- package/dist/tools/verify-completion.js.map +1 -0
- package/dist/tools/what-changed.d.ts +2 -0
- package/dist/tools/what-changed.js +40 -0
- package/dist/tools/what-changed.js.map +1 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
- package/src/cli/init.ts +133 -0
- package/src/index.ts +135 -0
- package/src/lib/config.ts +157 -0
- package/src/lib/embeddings.ts +118 -0
- package/src/lib/files.ts +59 -0
- package/src/lib/git-extractor.ts +137 -0
- package/src/lib/git.ts +89 -0
- package/src/lib/session-parser.ts +325 -0
- package/src/lib/state.ts +86 -0
- package/src/lib/timeline-db.ts +490 -0
- package/src/lib/triage.ts +255 -0
- package/src/profiles.ts +70 -0
- package/src/templates/config.yml +23 -0
- package/src/templates/triage.yml +27 -0
- package/src/tools/audit-workspace.ts +97 -0
- package/src/tools/checkpoint.ts +119 -0
- package/src/tools/clarify-intent.ts +191 -0
- package/src/tools/enrich-agent-task.ts +108 -0
- package/src/tools/generate-scorecard.ts +673 -0
- package/src/tools/log-correction.ts +89 -0
- package/src/tools/onboard-project.ts +214 -0
- package/src/tools/preflight-check.ts +263 -0
- package/src/tools/prompt-score.ts +150 -0
- package/src/tools/scan-sessions.ts +209 -0
- package/src/tools/scope-work.ts +238 -0
- package/src/tools/search-history.ts +145 -0
- package/src/tools/sequence-tasks.ts +182 -0
- package/src/tools/session-handoff.ts +125 -0
- package/src/tools/session-health.ts +107 -0
- package/src/tools/session-stats.ts +134 -0
- package/src/tools/sharpen-followup.ts +200 -0
- package/src/tools/timeline-view.ts +181 -0
- package/src/tools/token-audit.ts +259 -0
- package/src/tools/verify-completion.ts +159 -0
- package/src/tools/what-changed.ts +48 -0
- package/src/types.ts +87 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { searchSemantic, 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; // assume ISO already
|
|
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_BADGES: Record<string, string> = {
|
|
23
|
+
prompt: "š¬ prompt",
|
|
24
|
+
assistant: "š¤ assistant",
|
|
25
|
+
correction: "ā correction",
|
|
26
|
+
commit: "š¦ commit",
|
|
27
|
+
tool_call: "š§ tool_call",
|
|
28
|
+
compaction: "šļø compaction",
|
|
29
|
+
sub_agent_spawn: "š sub_agent_spawn",
|
|
30
|
+
error: "ā ļø 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 registerSearchHistory(server: McpServer) {
|
|
55
|
+
server.tool(
|
|
56
|
+
"search_history",
|
|
57
|
+
"Semantic search across the unified timeline of prompts, commits, corrections, and tool calls. Find relevant events using natural language queries.",
|
|
58
|
+
{
|
|
59
|
+
query: z.string().describe("Natural language search query"),
|
|
60
|
+
scope: z.enum(["current", "related", "all"]).default("current").describe("Search scope: current project, related projects (PREFLIGHT_RELATED), or all indexed projects"),
|
|
61
|
+
project: z.string().optional().describe("Filter to a specific project name (overrides scope)"),
|
|
62
|
+
branch: z.string().optional(),
|
|
63
|
+
author: z.string().optional().describe("Filter commits to this author (partial match, case-insensitive)"),
|
|
64
|
+
type: z.enum(["prompt", "assistant", "correction", "commit", "tool_call", "compaction", "sub_agent_spawn", "error", "all"]).default("all"),
|
|
65
|
+
since: z.string().optional().describe("ISO date or relative: '2025-06-01', '3months'"),
|
|
66
|
+
until: z.string().optional().describe("ISO date or relative"),
|
|
67
|
+
limit: z.number().default(10),
|
|
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: `## Search Results for "${params.query}"\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 results = await searchSemantic(params.query, {
|
|
92
|
+
project_dirs: projectDirs,
|
|
93
|
+
project: undefined, // Don't filter by single project when using project_dirs
|
|
94
|
+
branch: params.branch,
|
|
95
|
+
type: params.type === "all" ? undefined : params.type,
|
|
96
|
+
since,
|
|
97
|
+
until,
|
|
98
|
+
limit: params.author ? params.limit * 3 : params.limit, // over-fetch if filtering by author
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Post-filter by author (stored in metadata JSON)
|
|
102
|
+
if (params.author) {
|
|
103
|
+
const authorLower = params.author.toLowerCase();
|
|
104
|
+
results = results.filter((r: any) => {
|
|
105
|
+
try {
|
|
106
|
+
const meta = JSON.parse(r.metadata || "{}");
|
|
107
|
+
return (meta.author || "").toLowerCase().includes(authorLower);
|
|
108
|
+
} catch { return false; }
|
|
109
|
+
}).slice(0, params.limit);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (results.length === 0) {
|
|
113
|
+
return { content: [{ type: "text", text: `## Search Results for "${params.query}"\n_No results found._` }] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const projects = new Set(results.map((r: any) => r.project || "unknown"));
|
|
117
|
+
const lines: string[] = [
|
|
118
|
+
`## Search Results for "${params.query}"`,
|
|
119
|
+
`_${results.length} result${results.length !== 1 ? "s" : ""} across ${projects.size} project${projects.size !== 1 ? "s" : ""}_`,
|
|
120
|
+
"",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
results.forEach((event: any, i: number) => {
|
|
124
|
+
const badge = TYPE_BADGES[event.type] || event.type;
|
|
125
|
+
const ts = event.timestamp ? new Date(event.timestamp).toISOString().replace("T", " ").slice(0, 16) : "unknown";
|
|
126
|
+
const proj = event.project || "unknown";
|
|
127
|
+
const branch = event.branch ? ` / ${event.branch}` : "";
|
|
128
|
+
const score = event._distance != null ? (1 - event._distance).toFixed(2) : "?";
|
|
129
|
+
|
|
130
|
+
lines.push(`### ${i + 1}. [${badge}] ${proj}${branch} ā ${ts}`);
|
|
131
|
+
|
|
132
|
+
const content = (event.content || event.summary || "").slice(0, 200);
|
|
133
|
+
lines.push(`> ${content.replace(/\n/g, "\n> ")}`);
|
|
134
|
+
|
|
135
|
+
const meta: string[] = [`Score: ${score}`];
|
|
136
|
+
if (event.session_id) meta.push(`Session: ${event.session_id.slice(0, 8)}`);
|
|
137
|
+
if (event.commit_hash) meta.push(`Hash: ${event.commit_hash.slice(0, 7)}`);
|
|
138
|
+
lines.push(meta.join(" | "));
|
|
139
|
+
lines.push("");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
143
|
+
}
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// CATEGORY 6: sequence_tasks ā Sequencing
|
|
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
|
+
import { PROJECT_DIR } from "../lib/files.js";
|
|
7
|
+
import { existsSync } from "fs";
|
|
8
|
+
import { join, resolve } from "path";
|
|
9
|
+
|
|
10
|
+
type Cat = "schema" | "config" | "api" | "ui" | "test" | "other";
|
|
11
|
+
|
|
12
|
+
const CATEGORIES: Record<Exclude<Cat, "other">, RegExp> = {
|
|
13
|
+
schema: /\b(schema|migrat|database|db|table|column|index|alter|foreign.?key)\b/i,
|
|
14
|
+
config: /\b(config|env|\.env|settings|secrets?|dotenv|yaml|toml)\b/i,
|
|
15
|
+
api: /\b(api|route|endpoint|controller|handler|middleware|graphql|rest|rpc)\b/i,
|
|
16
|
+
ui: /\b(ui|component|page|view|layout|template|css|style|frontend|react|vue|svelte)\b/i,
|
|
17
|
+
test: /\b(test|spec|e2e|cypress|playwright|jest|vitest|assert|fixture)\b/i,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const CAT_DIR_MAP: Record<string, string> = {
|
|
21
|
+
schema: "db/", config: "config/", api: "api/", ui: "src/", test: "test/", other: "src/",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Dependency order: earlier items must complete before later ones
|
|
25
|
+
const DEP_ORDER: Cat[] = ["config", "schema", "api", "ui", "test", "other"];
|
|
26
|
+
|
|
27
|
+
function classify(task: string): Cat[] {
|
|
28
|
+
const cats = (Object.entries(CATEGORIES) as [Exclude<Cat, "other">, RegExp][])
|
|
29
|
+
.filter(([, re]) => re.test(task))
|
|
30
|
+
.map(([k]) => k as Cat);
|
|
31
|
+
// Default to "other" instead of "ui" to avoid misclassifying unrelated tasks
|
|
32
|
+
return cats.length > 0 ? cats : ["other"];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function riskScore(cats: Cat[]): number {
|
|
36
|
+
let s = 0;
|
|
37
|
+
if (cats.includes("schema")) s += 10;
|
|
38
|
+
if (cats.includes("config")) s += 7;
|
|
39
|
+
if (cats.includes("api")) s += 4;
|
|
40
|
+
if (cats.includes("ui")) s += 2;
|
|
41
|
+
if (cats.includes("test")) s += 1;
|
|
42
|
+
if (cats.includes("other")) s += 3;
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Validate a path is within PROJECT_DIR */
|
|
47
|
+
function isSafePath(dir: string): boolean {
|
|
48
|
+
const resolved = resolve(PROJECT_DIR, dir);
|
|
49
|
+
return resolved.startsWith(resolve(PROJECT_DIR) + "/") || resolved === resolve(PROJECT_DIR);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Detect circular dependencies among categorized tasks */
|
|
53
|
+
function detectCircularDeps(tasks: { task: string; cats: Cat[] }[]): string[] {
|
|
54
|
+
const warnings: string[] = [];
|
|
55
|
+
// Simple heuristic: if a task mentions output of another task, flag it
|
|
56
|
+
// More importantly, check if dependency order would create contradictions
|
|
57
|
+
const catSets = tasks.map((t) => new Set(t.cats));
|
|
58
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
59
|
+
for (let j = i + 1; j < tasks.length; j++) {
|
|
60
|
+
const iCats = catSets[i];
|
|
61
|
+
const jCats = catSets[j];
|
|
62
|
+
// Check if i should come before j AND j before i
|
|
63
|
+
const iBeforeJ = [...iCats].some((c) => [...jCats].some((d) => DEP_ORDER.indexOf(c) < DEP_ORDER.indexOf(d)));
|
|
64
|
+
const jBeforeI = [...jCats].some((c) => [...iCats].some((d) => DEP_ORDER.indexOf(c) < DEP_ORDER.indexOf(d)));
|
|
65
|
+
if (iBeforeJ && jBeforeI) {
|
|
66
|
+
warnings.push(`ā ļø Potential circular dependency: "${tasks[i].task.slice(0, 50)}" and "${tasks[j].task.slice(0, 50)}" have cross-layer categories ā consider splitting.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return warnings;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function registerSequenceTasks(server: McpServer): void {
|
|
74
|
+
server.tool(
|
|
75
|
+
"sequence_tasks",
|
|
76
|
+
`Order a set of tasks to minimize context switches, reduce re-reads, and batch related work. Supports dependency-order, file-locality, and risk-first strategies. Call when you have multiple tasks to execute in a session.`,
|
|
77
|
+
{
|
|
78
|
+
tasks: z.array(z.string()).min(1).describe("Tasks to sequence (natural language descriptions)"),
|
|
79
|
+
strategy: z.enum(["dependency", "locality", "risk-first"]).default("locality").describe("Sequencing strategy"),
|
|
80
|
+
},
|
|
81
|
+
async ({ tasks, strategy }) => {
|
|
82
|
+
const ts = now();
|
|
83
|
+
|
|
84
|
+
const classified = tasks.map((t) => ({
|
|
85
|
+
task: t,
|
|
86
|
+
cats: classify(t),
|
|
87
|
+
dir: null as string | null,
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
// For locality: infer directories from path-like tokens in task text
|
|
91
|
+
if (strategy === "locality") {
|
|
92
|
+
// Use git ls-files with a depth limit instead of find for performance
|
|
93
|
+
const gitFiles = run("git ls-files 2>/dev/null | head -1000");
|
|
94
|
+
const knownDirs = new Set<string>();
|
|
95
|
+
for (const f of gitFiles.split("\n").filter(Boolean)) {
|
|
96
|
+
const parts = f.split("/");
|
|
97
|
+
if (parts.length >= 2) knownDirs.add(parts.slice(0, 2).join("/"));
|
|
98
|
+
if (parts.length >= 1) knownDirs.add(parts[0]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const item of classified) {
|
|
102
|
+
const pathTokens = item.task.match(/[\w\-\/]+\.\w+|[\w\-]+\/[\w\-\/]*/g) || [];
|
|
103
|
+
for (const token of pathTokens) {
|
|
104
|
+
const dir = token.split("/").slice(0, 2).join("/");
|
|
105
|
+
// Validate: must be a known git directory and safe path
|
|
106
|
+
if (isSafePath(dir) && knownDirs.has(dir)) {
|
|
107
|
+
item.dir = dir;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!item.dir) {
|
|
112
|
+
item.dir = CAT_DIR_MAP[item.cats[0]] ?? "src/";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let ordered: typeof classified;
|
|
118
|
+
let reasoning: string[] = [];
|
|
119
|
+
|
|
120
|
+
if (strategy === "dependency") {
|
|
121
|
+
ordered = [...classified].sort((a, b) => {
|
|
122
|
+
const aIndices = a.cats.map((c) => DEP_ORDER.indexOf(c)).filter((i) => i >= 0);
|
|
123
|
+
const bIndices = b.cats.map((c) => DEP_ORDER.indexOf(c)).filter((i) => i >= 0);
|
|
124
|
+
const aIdx = aIndices.length > 0 ? Math.min(...aIndices) : DEP_ORDER.length;
|
|
125
|
+
const bIdx = bIndices.length > 0 ? Math.min(...bIndices) : DEP_ORDER.length;
|
|
126
|
+
return aIdx - bIdx;
|
|
127
|
+
});
|
|
128
|
+
reasoning = ordered.map(
|
|
129
|
+
(item, i) => `${i + 1}. **[${item.cats.join(",")}]** ā ${DEP_ORDER.indexOf(item.cats[0]) <= 1 ? "foundational change, must come early" : "depends on earlier layers"}`
|
|
130
|
+
);
|
|
131
|
+
} else if (strategy === "risk-first") {
|
|
132
|
+
ordered = [...classified].sort((a, b) => riskScore(b.cats) - riskScore(a.cats));
|
|
133
|
+
reasoning = ordered.map(
|
|
134
|
+
(item, i) => `${i + 1}. **[${item.cats.join(",")}]** risk=${riskScore(item.cats)} ā ${riskScore(item.cats) >= 7 ? "high-risk, do while context is fresh" : "lower risk, safe to do later"}`
|
|
135
|
+
);
|
|
136
|
+
} else {
|
|
137
|
+
ordered = [...classified].sort((a, b) => (a.dir ?? "").localeCompare(b.dir ?? ""));
|
|
138
|
+
reasoning = ordered.map(
|
|
139
|
+
(item, i) => `${i + 1}. dir=\`${item.dir}\` **[${item.cats.join(",")}]** ā grouped by proximity`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Estimate context switches
|
|
144
|
+
let switches = 0;
|
|
145
|
+
for (let i = 1; i < ordered.length; i++) {
|
|
146
|
+
const prevCats = new Set(ordered[i - 1].cats);
|
|
147
|
+
const currCats = ordered[i].cats;
|
|
148
|
+
const overlap = currCats.some((c) => prevCats.has(c));
|
|
149
|
+
if (!overlap) switches++;
|
|
150
|
+
if (strategy === "locality" && ordered[i].dir !== ordered[i - 1].dir) switches++;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Parallelization warnings
|
|
154
|
+
const warnings: string[] = [];
|
|
155
|
+
const hasSchema = classified.some((t) => t.cats.includes("schema"));
|
|
156
|
+
const hasTest = classified.some((t) => t.cats.includes("test"));
|
|
157
|
+
const hasApi = classified.some((t) => t.cats.includes("api"));
|
|
158
|
+
if (hasSchema && hasTest) warnings.push("ā ļø Schema changes and tests should NOT run in parallel ā tests depend on schema state.");
|
|
159
|
+
if (hasSchema && hasApi) warnings.push("ā ļø Schema migrations and API changes should be sequential ā API may reference new columns/tables.");
|
|
160
|
+
if (hasSchema) warnings.push("ā ļø Schema/migration tasks are non-parallelizable with anything that touches the DB.");
|
|
161
|
+
|
|
162
|
+
// Circular dependency check
|
|
163
|
+
const circularWarnings = detectCircularDeps(classified);
|
|
164
|
+
warnings.push(...circularWarnings);
|
|
165
|
+
|
|
166
|
+
const result = [
|
|
167
|
+
`## Sequenced Tasks (strategy: ${strategy})`,
|
|
168
|
+
`_Generated ${ts}_`,
|
|
169
|
+
"",
|
|
170
|
+
...ordered.map((item, i) => `${i + 1}. ${item.task}`),
|
|
171
|
+
"",
|
|
172
|
+
"### Reasoning",
|
|
173
|
+
...reasoning,
|
|
174
|
+
"",
|
|
175
|
+
`**Estimated context switches:** ${switches}`,
|
|
176
|
+
...(warnings.length ? ["", "### Warnings", ...warnings] : []),
|
|
177
|
+
].join("\n");
|
|
178
|
+
|
|
179
|
+
return { content: [{ type: "text" as const, text: result }] };
|
|
180
|
+
}
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { existsSync, readFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { run, getBranch, getRecentCommits, getStatus } from "../lib/git.js";
|
|
6
|
+
import { readIfExists, findWorkspaceDocs } from "../lib/files.js";
|
|
7
|
+
import { STATE_DIR, now } from "../lib/state.js";
|
|
8
|
+
|
|
9
|
+
/** Check if a CLI tool is available */
|
|
10
|
+
function hasCommand(cmd: string): boolean {
|
|
11
|
+
const result = run(`command -v ${cmd} 2>/dev/null`);
|
|
12
|
+
return !!result && !result.startsWith("[command failed");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registerSessionHandoff(server: McpServer): void {
|
|
16
|
+
server.tool(
|
|
17
|
+
"session_handoff",
|
|
18
|
+
`Generate a handoff brief for the next session. Reads last checkpoint, recent commits, open PRs, workspace state, and correction patterns to create a "here's where we are" document. Call at session end or when starting a new session to catch up on what happened.`,
|
|
19
|
+
{
|
|
20
|
+
direction: z.enum(["outgoing", "incoming"]).describe("'outgoing' = ending this session, 'incoming' = starting a new one"),
|
|
21
|
+
},
|
|
22
|
+
async ({ direction }) => {
|
|
23
|
+
const branch = getBranch();
|
|
24
|
+
const sections: string[] = [];
|
|
25
|
+
|
|
26
|
+
if (direction === "incoming") {
|
|
27
|
+
const lastCheckpoint = readIfExists(".claude/last-checkpoint.md", 50);
|
|
28
|
+
const recentLog = getRecentCommits(10);
|
|
29
|
+
const dirty = getStatus();
|
|
30
|
+
|
|
31
|
+
sections.push(`## Session Handoff ā INCOMING\n**Branch**: ${branch}\n**Time**: ${now()}`);
|
|
32
|
+
|
|
33
|
+
if (lastCheckpoint) {
|
|
34
|
+
sections.push(`## Last Checkpoint\n${lastCheckpoint}`);
|
|
35
|
+
} else {
|
|
36
|
+
sections.push(`## Last Checkpoint\nNone found. This may be the first session or checkpoints weren't saved.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
sections.push(`## Recent Commits\n\`\`\`\n${recentLog}\n\`\`\``);
|
|
40
|
+
|
|
41
|
+
if (dirty) {
|
|
42
|
+
sections.push(`## Uncommitted Work\n\`\`\`\n${dirty}\n\`\`\``);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Only try gh if it exists
|
|
46
|
+
if (hasCommand("gh")) {
|
|
47
|
+
const openPRs = run("gh pr list --state open --json number,title,headRefName 2>/dev/null || echo '[]'");
|
|
48
|
+
if (openPRs && openPRs !== "[]") {
|
|
49
|
+
sections.push(`## Open PRs\n\`\`\`json\n${openPRs}\n\`\`\``);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const docs = findWorkspaceDocs();
|
|
54
|
+
const freshDocs = Object.entries(docs)
|
|
55
|
+
.sort((a, b) => b[1].mtime.getTime() - a[1].mtime.getTime())
|
|
56
|
+
.slice(0, 5);
|
|
57
|
+
if (freshDocs.length > 0) {
|
|
58
|
+
sections.push(`## Most Recently Updated Workspace Docs\n${freshDocs.map(([n, d]) =>
|
|
59
|
+
`- .claude/${n} (updated ${Math.round((Date.now() - d.mtime.getTime()) / 3600000)}h ago)`
|
|
60
|
+
).join("\n")}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Correction patterns
|
|
64
|
+
const correctionFile = join(STATE_DIR, "corrections.jsonl");
|
|
65
|
+
if (existsSync(correctionFile)) {
|
|
66
|
+
try {
|
|
67
|
+
const raw = readFileSync(correctionFile, "utf-8").trim();
|
|
68
|
+
if (raw) {
|
|
69
|
+
const corr = raw.split("\n").filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
70
|
+
if (corr.length > 0) {
|
|
71
|
+
const cats: Record<string, number> = {};
|
|
72
|
+
for (const c of corr) cats[c.category] = (cats[c.category] || 0) + 1;
|
|
73
|
+
sections.push(`## Known Error Patterns\n${Object.entries(cats).map(([k, v]) => `- ${k}: ${v}x`).join("\n")}\n\n**Watch out for these patterns.**`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch { /* ignore parse errors */ }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
sections.push(`## Recommendation\n1. Read the last checkpoint to understand where previous session left off\n2. Check git status for uncommitted work\n3. Read the most recently updated workspace docs\n4. Start with a specific task ā don't try to "continue where we left off" without reading state first`);
|
|
80
|
+
|
|
81
|
+
} else {
|
|
82
|
+
// OUTGOING
|
|
83
|
+
const dirty = getStatus();
|
|
84
|
+
const dirtyCount = dirty ? dirty.split("\n").filter(Boolean).length : 0;
|
|
85
|
+
const recentLog = getRecentCommits(5);
|
|
86
|
+
|
|
87
|
+
sections.push(`## Session Handoff ā OUTGOING\n**Branch**: ${branch}\n**Time**: ${now()}`);
|
|
88
|
+
|
|
89
|
+
if (dirtyCount > 0) {
|
|
90
|
+
sections.push(`## ā ļø Uncommitted Work (${dirtyCount} files)\n\`\`\`\n${dirty}\n\`\`\`\n\n**Action**: Commit this work or it will be lost to the next session.`);
|
|
91
|
+
|
|
92
|
+
// Suggest stash if there's dirty work
|
|
93
|
+
const stashSuggestion = dirtyCount > 10
|
|
94
|
+
? "\nš” **Tip**: Consider `git stash` if you want to save work without committing."
|
|
95
|
+
: "";
|
|
96
|
+
if (stashSuggestion) sections.push(stashSuggestion);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
sections.push(`## Recent Commits This Session\n\`\`\`\n${recentLog}\n\`\`\``);
|
|
100
|
+
|
|
101
|
+
// Check for today's checkpoint using date comparison
|
|
102
|
+
const lastCheckpoint = readIfExists(".claude/last-checkpoint.md", 10);
|
|
103
|
+
const hasRecentCheckpoint = (() => {
|
|
104
|
+
if (!lastCheckpoint) return false;
|
|
105
|
+
// Look for a timestamp line and compare dates
|
|
106
|
+
const match = lastCheckpoint.match(/\*\*Time\*\*:\s*(\S+)/);
|
|
107
|
+
if (!match) return false;
|
|
108
|
+
try {
|
|
109
|
+
const cpDate = new Date(match[1]);
|
|
110
|
+
// Consider "recent" if within last 4 hours
|
|
111
|
+
return (Date.now() - cpDate.getTime()) < 4 * 60 * 60 * 1000;
|
|
112
|
+
} catch { return false; }
|
|
113
|
+
})();
|
|
114
|
+
|
|
115
|
+
if (!hasRecentCheckpoint) {
|
|
116
|
+
sections.push(`## ā ļø No recent checkpoint\nRun the \`checkpoint\` tool to save session state for the next session.`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
sections.push(`## Before ending:\n1. Commit all work\n2. Run \`checkpoint\` with summary + next steps\n3. Update any stale workspace docs (run \`audit_workspace\`)\n4. Push to remote`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { content: [{ type: "text" as const, text: sections.join("\n\n") }] };
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getBranch, getStatus, getLastCommit, getLastCommitTime, run } from "../lib/git.js";
|
|
4
|
+
import { readIfExists, findWorkspaceDocs } from "../lib/files.js";
|
|
5
|
+
import { loadState, saveState } from "../lib/state.js";
|
|
6
|
+
import { getConfig } from "../lib/config.js";
|
|
7
|
+
|
|
8
|
+
/** Parse a git date string safely, returning null on failure */
|
|
9
|
+
function parseGitDate(dateStr: string): Date | null {
|
|
10
|
+
if (!dateStr || dateStr.startsWith("[command failed")) return null;
|
|
11
|
+
const d = new Date(dateStr);
|
|
12
|
+
return isNaN(d.getTime()) ? null : d;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registerSessionHealth(server: McpServer): void {
|
|
16
|
+
server.tool(
|
|
17
|
+
"check_session_health",
|
|
18
|
+
`Check session health and recommend whether to continue, checkpoint, or start fresh. Tracks session depth, uncommitted work, workspace staleness, and time since last commit. Call periodically during long sessions.`,
|
|
19
|
+
{
|
|
20
|
+
stale_threshold_hours: z.number().optional().describe("Hours before a doc is considered stale. Default: 2"),
|
|
21
|
+
},
|
|
22
|
+
async ({ stale_threshold_hours }) => {
|
|
23
|
+
const config = getConfig();
|
|
24
|
+
const staleHours = stale_threshold_hours ?? (config.thresholds.session_stale_minutes / 60);
|
|
25
|
+
const branch = getBranch();
|
|
26
|
+
const dirty = getStatus();
|
|
27
|
+
const dirtyCount = dirty ? dirty.split("\n").filter(Boolean).length : 0;
|
|
28
|
+
const lastCommit = getLastCommit();
|
|
29
|
+
const lastCommitTimeStr = getLastCommitTime();
|
|
30
|
+
const uncommittedDiff = run("git diff --stat | tail -1");
|
|
31
|
+
|
|
32
|
+
// Parse commit time safely
|
|
33
|
+
const commitDate = parseGitDate(lastCommitTimeStr);
|
|
34
|
+
const minutesSinceCommit = commitDate
|
|
35
|
+
? Math.round((Date.now() - commitDate.getTime()) / 60000)
|
|
36
|
+
: null;
|
|
37
|
+
|
|
38
|
+
// Track session start time
|
|
39
|
+
const sessionState = loadState("session-health");
|
|
40
|
+
if (!sessionState.sessionStart) {
|
|
41
|
+
sessionState.sessionStart = Date.now();
|
|
42
|
+
sessionState.checkCount = 0;
|
|
43
|
+
saveState("session-health", sessionState);
|
|
44
|
+
}
|
|
45
|
+
sessionState.checkCount = (sessionState.checkCount || 0) + 1;
|
|
46
|
+
saveState("session-health", sessionState);
|
|
47
|
+
|
|
48
|
+
const sessionMinutes = Math.round((Date.now() - sessionState.sessionStart) / 60000);
|
|
49
|
+
|
|
50
|
+
const lastCheckpoint = readIfExists(".claude/last-checkpoint.md", 20);
|
|
51
|
+
|
|
52
|
+
const docs = findWorkspaceDocs();
|
|
53
|
+
const staleThresholdMs = staleHours * 60 * 60 * 1000;
|
|
54
|
+
const staleDocs = Object.entries(docs)
|
|
55
|
+
.filter(([, d]) => (Date.now() - d.mtime.getTime()) > staleThresholdMs)
|
|
56
|
+
.map(([n]) => n);
|
|
57
|
+
|
|
58
|
+
const issues: string[] = [];
|
|
59
|
+
let severity = "healthy";
|
|
60
|
+
|
|
61
|
+
if (dirtyCount > 15) { issues.push(`šØ ${dirtyCount} uncommitted files ā commit now`); severity = "critical"; }
|
|
62
|
+
else if (dirtyCount > 5) { issues.push(`ā ļø ${dirtyCount} uncommitted files ā consider committing`); severity = "warning"; }
|
|
63
|
+
|
|
64
|
+
const staleMinutes = config.thresholds.session_stale_minutes;
|
|
65
|
+
if (minutesSinceCommit !== null) {
|
|
66
|
+
if (minutesSinceCommit > staleMinutes * 4) { issues.push(`šØ ${minutesSinceCommit}min since last commit ā checkpoint immediately`); severity = "critical"; }
|
|
67
|
+
else if (minutesSinceCommit > staleMinutes * 2) { issues.push(`ā ļø ${minutesSinceCommit}min since last commit ā commit soon`); if (severity !== "critical") severity = "warning"; }
|
|
68
|
+
} else {
|
|
69
|
+
issues.push("ā ļø Could not determine last commit time");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (sessionMinutes > 180) { issues.push(`šØ Session running ${sessionMinutes}min ā consider starting fresh`); severity = "critical"; }
|
|
73
|
+
else if (sessionMinutes > 90) { issues.push(`ā ļø Session running ${sessionMinutes}min ā checkpoint soon`); if (severity !== "critical") severity = "warning"; }
|
|
74
|
+
|
|
75
|
+
if (staleDocs.length > 3) { issues.push(`š ${staleDocs.length} workspace docs are >${staleHours}h stale: ${staleDocs.slice(0, 3).join(", ")}`); }
|
|
76
|
+
|
|
77
|
+
const recommendation = severity === "critical"
|
|
78
|
+
? "šØ **STOP and checkpoint.** Run `checkpoint` tool now. Commit all work, save state, consider starting fresh."
|
|
79
|
+
: severity === "warning"
|
|
80
|
+
? "ā ļø **Checkpoint soon.** Commit current batch, update workspace docs if needed."
|
|
81
|
+
: "ā
**Session is healthy.** Continue working.";
|
|
82
|
+
|
|
83
|
+
const commitTimeStr = minutesSinceCommit !== null ? `${minutesSinceCommit}min ago` : "unknown";
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
content: [{
|
|
87
|
+
type: "text" as const,
|
|
88
|
+
text: `## Session Health Report
|
|
89
|
+
|
|
90
|
+
**Branch**: ${branch}
|
|
91
|
+
**Session duration**: ${sessionMinutes}min (check #${sessionState.checkCount})
|
|
92
|
+
**Uncommitted**: ${dirtyCount} files
|
|
93
|
+
**Last commit**: ${lastCommit} (${commitTimeStr})
|
|
94
|
+
**Changes**: ${uncommittedDiff || "none"}
|
|
95
|
+
**Stale docs**: ${staleDocs.length > 0 ? staleDocs.join(", ") : "none"}
|
|
96
|
+
**Last checkpoint**: ${lastCheckpoint ? "exists" : "none"}
|
|
97
|
+
|
|
98
|
+
### Issues
|
|
99
|
+
${issues.length ? issues.join("\n") : "None ā session is healthy"}
|
|
100
|
+
|
|
101
|
+
### Recommendation
|
|
102
|
+
${recommendation}`,
|
|
103
|
+
}],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
}
|