-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathsession-handoff.ts
More file actions
133 lines (113 loc) · 5.99 KB
/
session-handoff.ts
File metadata and controls
133 lines (113 loc) · 5.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { existsSync, readFileSync } from "fs";
import { execFileSync } from "child_process";
import { join } from "path";
import { run, getBranch, getRecentCommits, getStatus } from "../lib/git.js";
import { readIfExists, findWorkspaceDocs } from "../lib/files.js";
import { STATE_DIR, now } from "../lib/state.js";
/** Check if a CLI tool is available */
function hasCommand(cmd: string): boolean {
try {
execFileSync("which", [cmd], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
return true;
} catch { return false; }
}
export function registerSessionHandoff(server: McpServer): void {
server.tool(
"session_handoff",
`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.`,
{
direction: z.enum(["outgoing", "incoming"]).describe("'outgoing' = ending this session, 'incoming' = starting a new one"),
},
async ({ direction }) => {
const branch = getBranch();
const sections: string[] = [];
if (direction === "incoming") {
const lastCheckpoint = readIfExists(".claude/last-checkpoint.md", 50);
const recentLog = getRecentCommits(10);
const dirty = getStatus();
sections.push(`## Session Handoff — INCOMING\n**Branch**: ${branch}\n**Time**: ${now()}`);
if (lastCheckpoint) {
sections.push(`## Last Checkpoint\n${lastCheckpoint}`);
} else {
sections.push(`## Last Checkpoint\nNone found. This may be the first session or checkpoints weren't saved.`);
}
sections.push(`## Recent Commits\n\`\`\`\n${recentLog}\n\`\`\``);
if (dirty) {
sections.push(`## Uncommitted Work\n\`\`\`\n${dirty}\n\`\`\``);
}
// Only try gh if it exists
if (hasCommand("gh")) {
let openPRs = "[]";
try {
openPRs = execFileSync("gh", ["pr", "list", "--state", "open", "--json", "number,title,headRefName"], {
encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch { /* gh not available or not in a repo */ }
if (openPRs && openPRs !== "[]") {
sections.push(`## Open PRs\n\`\`\`json\n${openPRs}\n\`\`\``);
}
}
const docs = findWorkspaceDocs();
const freshDocs = Object.entries(docs)
.sort((a, b) => b[1].mtime.getTime() - a[1].mtime.getTime())
.slice(0, 5);
if (freshDocs.length > 0) {
sections.push(`## Most Recently Updated Workspace Docs\n${freshDocs.map(([n, d]) =>
`- .claude/${n} (updated ${Math.round((Date.now() - d.mtime.getTime()) / 3600000)}h ago)`
).join("\n")}`);
}
// Correction patterns
const correctionFile = join(STATE_DIR, "corrections.jsonl");
if (existsSync(correctionFile)) {
try {
const raw = readFileSync(correctionFile, "utf-8").trim();
if (raw) {
const corr = raw.split("\n").filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
if (corr.length > 0) {
const cats: Record<string, number> = {};
for (const c of corr) cats[c.category] = (cats[c.category] || 0) + 1;
sections.push(`## Known Error Patterns\n${Object.entries(cats).map(([k, v]) => `- ${k}: ${v}x`).join("\n")}\n\n**Watch out for these patterns.**`);
}
}
} catch { /* ignore parse errors */ }
}
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`);
} else {
// OUTGOING
const dirty = getStatus();
const dirtyCount = dirty ? dirty.split("\n").filter(Boolean).length : 0;
const recentLog = getRecentCommits(5);
sections.push(`## Session Handoff — OUTGOING\n**Branch**: ${branch}\n**Time**: ${now()}`);
if (dirtyCount > 0) {
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.`);
// Suggest stash if there's dirty work
const stashSuggestion = dirtyCount > 10
? "\n💡 **Tip**: Consider `git stash` if you want to save work without committing."
: "";
if (stashSuggestion) sections.push(stashSuggestion);
}
sections.push(`## Recent Commits This Session\n\`\`\`\n${recentLog}\n\`\`\``);
// Check for today's checkpoint using date comparison
const lastCheckpoint = readIfExists(".claude/last-checkpoint.md", 10);
const hasRecentCheckpoint = (() => {
if (!lastCheckpoint) return false;
// Look for a timestamp line and compare dates
const match = lastCheckpoint.match(/\*\*Time\*\*:\s*(\S+)/);
if (!match) return false;
try {
const cpDate = new Date(match[1]);
// Consider "recent" if within last 4 hours
return (Date.now() - cpDate.getTime()) < 4 * 60 * 60 * 1000;
} catch { return false; }
})();
if (!hasRecentCheckpoint) {
sections.push(`## ⚠️ No recent checkpoint\nRun the \`checkpoint\` tool to save session state for the next session.`);
}
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`);
}
return { content: [{ type: "text" as const, text: sections.join("\n\n") }] };
}
);
}