Initial commit
This commit is contained in:
39
hooks/bun.lock
Normal file
39
hooks/bun.lock
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "git-hooks",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.1.50", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.24.1" } }, "sha512-vHOLohUeiVadWl4eTAbw12ACIG1wZ/NN4ScLe8P/yrsldT1QkYwn6ndkoilaFBB2gIHECEx7wRAtSfCLefge4Q=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
}
|
||||
}
|
||||
46
hooks/git-context-loader.test.ts
Normal file
46
hooks/git-context-loader.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseGitStatus } from "./git-context-loader";
|
||||
|
||||
describe("git-context-loader", () => {
|
||||
test("parseGitStatus parses clean status", () => {
|
||||
const output = "## main...origin/main";
|
||||
const result = parseGitStatus(output);
|
||||
expect(result.branch).toBe("main");
|
||||
expect(result.status).toEqual({ staged: 0, modified: 0, untracked: 0 });
|
||||
});
|
||||
|
||||
test("parseGitStatus parses dirty status", () => {
|
||||
const output = `## feature/test...origin/feature/test [ahead 1]
|
||||
M modified-file.ts
|
||||
A staged-file.ts
|
||||
?? untracked-file.ts
|
||||
D deleted-file.ts`;
|
||||
|
||||
const result = parseGitStatus(output);
|
||||
expect(result.branch).toBe("feature/test");
|
||||
// M (modified), A (staged), ?? (untracked), D (unstaged delete -> modified)
|
||||
// M -> index: M, worktree: space -> staged
|
||||
// A -> index: A, worktree: space -> staged
|
||||
// ?? -> untracked
|
||||
// D -> index: space, worktree: D -> modified
|
||||
|
||||
// Let's check the logic in parseGitStatus:
|
||||
// if (code.startsWith("?") || code === "??") untracked++
|
||||
// else:
|
||||
// if (code[0] !== " " && code[0] !== "?") staged++
|
||||
// if (code[1] !== " " && code[1] !== "?") modified++
|
||||
|
||||
// M -> code="M " -> staged++
|
||||
// A -> code="A " -> staged++
|
||||
// ?? -> untracked++
|
||||
// D -> code=" D" -> modified++
|
||||
|
||||
expect(result.status).toEqual({ staged: 2, modified: 1, untracked: 1 });
|
||||
});
|
||||
|
||||
test("parseGitStatus handles detached head", () => {
|
||||
const output = "## HEAD (no branch)";
|
||||
const result = parseGitStatus(output);
|
||||
expect(result.branch).toBe("HEAD (no branch)");
|
||||
});
|
||||
});
|
||||
182
hooks/git-context-loader.ts
Normal file
182
hooks/git-context-loader.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Git Context Loader Hook
|
||||
*
|
||||
* SessionStart hook that loads git context at the beginning of a session.
|
||||
* Outputs recent commits, status, and open issues for Claude's awareness.
|
||||
*/
|
||||
|
||||
import type { SessionStartHookInput } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
interface GitContext {
|
||||
branch: string;
|
||||
status: {
|
||||
staged: number;
|
||||
modified: number;
|
||||
untracked: number;
|
||||
};
|
||||
recentCommits: string[];
|
||||
openIssues?: string[];
|
||||
}
|
||||
|
||||
async function exec(
|
||||
command: string,
|
||||
cwd: string,
|
||||
): Promise<{ stdout: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(["sh", "-c", command], {
|
||||
cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
return { stdout: stdout.trim(), exitCode };
|
||||
}
|
||||
|
||||
async function isGitRepo(cwd: string): Promise<boolean> {
|
||||
const { exitCode } = await exec("git rev-parse --git-dir", cwd);
|
||||
return exitCode === 0;
|
||||
}
|
||||
|
||||
export function parseGitStatus(statusOut: string) {
|
||||
const lines = statusOut.split("\n");
|
||||
const branchLine = lines.find((l) => l.startsWith("##"));
|
||||
let branch = "(detached)";
|
||||
if (branchLine) {
|
||||
const parsed = branchLine.substring(3).split("...")[0];
|
||||
if (parsed) branch = parsed.trim();
|
||||
}
|
||||
|
||||
let staged = 0;
|
||||
let modified = 0;
|
||||
let untracked = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("##") || !line.trim()) continue;
|
||||
|
||||
const code = line.substring(0, 2);
|
||||
if (code.startsWith("?") || code === "??") {
|
||||
untracked++;
|
||||
} else {
|
||||
if (code[0] !== " " && code[0] !== "?") staged++;
|
||||
if (code[1] !== " " && code[1] !== "?") modified++;
|
||||
}
|
||||
}
|
||||
|
||||
return { branch, status: { staged, modified, untracked } };
|
||||
}
|
||||
|
||||
async function getGitContext(cwd: string): Promise<GitContext | null> {
|
||||
if (!(await isGitRepo(cwd))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get status and branch in one go
|
||||
const { stdout: statusOut } = await exec(
|
||||
"git status --porcelain -b 2>/dev/null",
|
||||
cwd,
|
||||
);
|
||||
|
||||
const { branch, status } = parseGitStatus(statusOut);
|
||||
|
||||
// Get recent commits
|
||||
const { stdout: commitsOut } = await exec(
|
||||
'git log --oneline -5 --format="%h %s (%ar)" 2>/dev/null',
|
||||
cwd,
|
||||
);
|
||||
const recentCommits = commitsOut
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
|
||||
const context: GitContext = {
|
||||
branch: branch || "(detached)",
|
||||
status,
|
||||
recentCommits,
|
||||
};
|
||||
|
||||
// Check for open issues if gh is available and authenticated
|
||||
const { exitCode: ghAuthCheck } = await exec("gh auth status", cwd);
|
||||
if (ghAuthCheck === 0) {
|
||||
const { stdout: issuesOut, exitCode: issuesCode } = await exec(
|
||||
"gh issue list --limit 3 --state open 2>/dev/null",
|
||||
cwd,
|
||||
);
|
||||
if (issuesCode === 0 && issuesOut.trim()) {
|
||||
context.openIssues = issuesOut
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function formatContext(context: GitContext): string {
|
||||
const { branch, status, recentCommits, openIssues } = context;
|
||||
|
||||
let output = "Git Context:\n";
|
||||
output += ` Branch: ${branch}\n`;
|
||||
output += ` Status: ${status.staged} staged, ${status.modified} modified, ${status.untracked} untracked\n`;
|
||||
output += "\nRecent commits:\n";
|
||||
|
||||
if (recentCommits.length > 0) {
|
||||
recentCommits.forEach((commit) => {
|
||||
output += ` ${commit}\n`;
|
||||
});
|
||||
} else {
|
||||
output += " (no commits yet)\n";
|
||||
}
|
||||
|
||||
if (openIssues && openIssues.length > 0) {
|
||||
output += "\nOpen issues (top 3):\n";
|
||||
openIssues.forEach((issue) => {
|
||||
output += ` ${issue}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function formatSystemMessage(context: GitContext): string {
|
||||
const { branch, status, recentCommits } = context;
|
||||
const totalChanges = status.staged + status.modified + status.untracked;
|
||||
const changesStr = totalChanges > 0 ? `, ${totalChanges} changes` : "";
|
||||
const lastCommit =
|
||||
recentCommits[0]?.split(" ").slice(1).join(" ") || "no commits";
|
||||
return `Git: ${branch}${changesStr} | Last: ${lastCommit}`;
|
||||
}
|
||||
|
||||
interface HookOutput {
|
||||
systemMessage?: string;
|
||||
hookSpecificOutput: {
|
||||
hookEventName: string;
|
||||
additionalContext: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Main execution
|
||||
if (import.meta.main) {
|
||||
const input = (await Bun.stdin.json()) as SessionStartHookInput;
|
||||
const { cwd, source } = input;
|
||||
|
||||
// Only run on startup, not on resume/clear/compact
|
||||
if (source === "startup") {
|
||||
const context = await getGitContext(cwd);
|
||||
|
||||
if (context) {
|
||||
const output: HookOutput = {
|
||||
systemMessage: formatSystemMessage(context),
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "SessionStart",
|
||||
additionalContext: formatContext(context),
|
||||
},
|
||||
};
|
||||
console.log(JSON.stringify(output));
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
32
hooks/hooks.json
Normal file
32
hooks/hooks.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"description": "Git intelligence hooks for session context and summaries",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/install-deps.sh",
|
||||
"timeout": 30
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bun run ${CLAUDE_PLUGIN_ROOT}/hooks/git-context-loader.ts",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bun run ${CLAUDE_PLUGIN_ROOT}/hooks/session-summary.ts",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
200
hooks/hooks.test.ts
Normal file
200
hooks/hooks.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type {
|
||||
PreCompactHookInput,
|
||||
SessionStartHookInput,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
// Test the hook input types and basic functionality
|
||||
|
||||
describe("SessionStartHookInput type", () => {
|
||||
test("has correct shape", () => {
|
||||
const input: SessionStartHookInput = {
|
||||
session_id: "test-session-123",
|
||||
transcript_path: "/tmp/transcript",
|
||||
cwd: "/Users/test/code",
|
||||
hook_event_name: "SessionStart",
|
||||
source: "startup",
|
||||
};
|
||||
|
||||
expect(input.session_id).toBe("test-session-123");
|
||||
expect(input.cwd).toBe("/Users/test/code");
|
||||
expect(input.hook_event_name).toBe("SessionStart");
|
||||
expect(input.source).toBe("startup");
|
||||
});
|
||||
|
||||
test("source can be startup, resume, clear, or compact", () => {
|
||||
const sources: SessionStartHookInput["source"][] = [
|
||||
"startup",
|
||||
"resume",
|
||||
"clear",
|
||||
"compact",
|
||||
];
|
||||
|
||||
sources.forEach((source) => {
|
||||
const input: SessionStartHookInput = {
|
||||
session_id: "test",
|
||||
transcript_path: "/tmp",
|
||||
cwd: "/tmp",
|
||||
hook_event_name: "SessionStart",
|
||||
source,
|
||||
};
|
||||
expect(input.source).toBe(source);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PreCompactHookInput type", () => {
|
||||
test("has correct shape", () => {
|
||||
const input: PreCompactHookInput = {
|
||||
session_id: "test-session-123",
|
||||
transcript_path: "/tmp/transcript",
|
||||
cwd: "/Users/test/code",
|
||||
hook_event_name: "PreCompact",
|
||||
trigger: "auto",
|
||||
custom_instructions: null,
|
||||
};
|
||||
|
||||
expect(input.session_id).toBe("test-session-123");
|
||||
expect(input.hook_event_name).toBe("PreCompact");
|
||||
expect(input.trigger).toBe("auto");
|
||||
expect(input.custom_instructions).toBeNull();
|
||||
});
|
||||
|
||||
test("trigger can be manual or auto", () => {
|
||||
const triggers: PreCompactHookInput["trigger"][] = ["manual", "auto"];
|
||||
|
||||
triggers.forEach((trigger) => {
|
||||
const input: PreCompactHookInput = {
|
||||
session_id: "test",
|
||||
transcript_path: "/tmp",
|
||||
cwd: "/tmp",
|
||||
hook_event_name: "PreCompact",
|
||||
trigger,
|
||||
custom_instructions: null,
|
||||
};
|
||||
expect(input.trigger).toBe(trigger);
|
||||
});
|
||||
});
|
||||
|
||||
test("custom_instructions can be string or null", () => {
|
||||
const withInstructions: PreCompactHookInput = {
|
||||
session_id: "test",
|
||||
transcript_path: "/tmp",
|
||||
cwd: "/tmp",
|
||||
hook_event_name: "PreCompact",
|
||||
trigger: "manual",
|
||||
custom_instructions: "Focus on the auth changes",
|
||||
};
|
||||
|
||||
const withoutInstructions: PreCompactHookInput = {
|
||||
session_id: "test",
|
||||
transcript_path: "/tmp",
|
||||
cwd: "/tmp",
|
||||
hook_event_name: "PreCompact",
|
||||
trigger: "auto",
|
||||
custom_instructions: null,
|
||||
};
|
||||
|
||||
expect(withInstructions.custom_instructions).toBe(
|
||||
"Focus on the auth changes",
|
||||
);
|
||||
expect(withoutInstructions.custom_instructions).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("git-context-loader", () => {
|
||||
test("can be run with valid input", async () => {
|
||||
const input: SessionStartHookInput = {
|
||||
session_id: "test-123",
|
||||
transcript_path: "/tmp/transcript",
|
||||
cwd: import.meta.dir, // Use the hooks directory (which is in a git repo)
|
||||
hook_event_name: "SessionStart",
|
||||
source: "startup",
|
||||
};
|
||||
|
||||
// Run the actual hook script using echo to pipe input
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
`echo '${JSON.stringify(input)}' | bun run git-context-loader.ts`,
|
||||
],
|
||||
{
|
||||
cwd: import.meta.dir,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for process to complete
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
// Should output git context
|
||||
expect(stdout).toContain("Git Context:");
|
||||
expect(stdout).toContain("Branch:");
|
||||
});
|
||||
|
||||
test("skips non-startup sources", async () => {
|
||||
const input: SessionStartHookInput = {
|
||||
session_id: "test-123",
|
||||
transcript_path: "/tmp/transcript",
|
||||
cwd: import.meta.dir,
|
||||
hook_event_name: "SessionStart",
|
||||
source: "resume", // Not startup
|
||||
};
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
`echo '${JSON.stringify(input)}' | bun run git-context-loader.ts`,
|
||||
],
|
||||
{
|
||||
cwd: import.meta.dir,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
// Should NOT output git context for resume
|
||||
expect(stdout).not.toContain("Git Context:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session-summary", () => {
|
||||
test("can be run with valid input", async () => {
|
||||
const input: PreCompactHookInput = {
|
||||
session_id: "test-123",
|
||||
transcript_path: "/tmp/transcript",
|
||||
cwd: import.meta.dir,
|
||||
hook_event_name: "PreCompact",
|
||||
trigger: "manual",
|
||||
custom_instructions: null,
|
||||
};
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
`echo '${JSON.stringify(input)}' | bun run session-summary.ts`,
|
||||
],
|
||||
{
|
||||
cwd: import.meta.dir,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Session summary saved");
|
||||
});
|
||||
});
|
||||
23
hooks/install-deps.sh
Executable file
23
hooks/install-deps.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Install MCP server and hooks dependencies on session start
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PLUGIN_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
MCP_DIR="$PLUGIN_ROOT/mcp-servers/git-intelligence"
|
||||
HOOKS_DIR="$PLUGIN_ROOT/hooks"
|
||||
|
||||
# Install MCP server dependencies
|
||||
if [ ! -d "$MCP_DIR/node_modules" ]; then
|
||||
cd "$MCP_DIR"
|
||||
bun install --silent 2>/dev/null || bun install
|
||||
fi
|
||||
|
||||
# Install hooks dependencies (for TypeScript types)
|
||||
if [ ! -d "$HOOKS_DIR/node_modules" ]; then
|
||||
cd "$HOOKS_DIR"
|
||||
bun install --silent 2>/dev/null || bun install
|
||||
fi
|
||||
|
||||
exit 0
|
||||
15
hooks/package.json
Normal file
15
hooks/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "git-hooks",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.3.3"
|
||||
}
|
||||
}
|
||||
158
hooks/session-summary.ts
Normal file
158
hooks/session-summary.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Session Summary Hook
|
||||
*
|
||||
* PreCompact hook that saves a session summary before context compaction.
|
||||
* Helps maintain continuity across context windows.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { PreCompactHookInput } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
interface SessionSummary {
|
||||
timestamp: string;
|
||||
branch: string;
|
||||
trigger: "manual" | "auto";
|
||||
sessionCommits: string[];
|
||||
uncommittedChanges: {
|
||||
staged: string;
|
||||
modified: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function exec(
|
||||
command: string,
|
||||
cwd: string,
|
||||
): Promise<{ stdout: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(["sh", "-c", command], {
|
||||
cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
return { stdout: stdout.trim(), exitCode };
|
||||
}
|
||||
|
||||
async function isGitRepo(cwd: string): Promise<boolean> {
|
||||
const { exitCode } = await exec("git rev-parse --git-dir", cwd);
|
||||
return exitCode === 0;
|
||||
}
|
||||
|
||||
async function getGitRoot(cwd: string): Promise<string | null> {
|
||||
const { stdout, exitCode } = await exec("git rev-parse --show-toplevel", cwd);
|
||||
return exitCode === 0 ? stdout : null;
|
||||
}
|
||||
|
||||
async function getSessionSummary(
|
||||
cwd: string,
|
||||
trigger: "manual" | "auto",
|
||||
): Promise<SessionSummary | null> {
|
||||
if (!(await isGitRepo(cwd))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get branch
|
||||
const { stdout: branch } = await exec(
|
||||
"git branch --show-current 2>/dev/null || echo '(detached)'",
|
||||
cwd,
|
||||
);
|
||||
|
||||
// Get commits from the last hour (approximate session length)
|
||||
const { stdout: commitsOut } = await exec(
|
||||
'git log --oneline --since="1 hour ago" 2>/dev/null | head -10',
|
||||
cwd,
|
||||
);
|
||||
const sessionCommits = commitsOut
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
|
||||
// Get staged changes summary
|
||||
const { stdout: stagedStat } = await exec(
|
||||
"git diff --cached --stat 2>/dev/null | tail -1",
|
||||
cwd,
|
||||
);
|
||||
|
||||
// Get modified changes summary
|
||||
const { stdout: modifiedStat } = await exec(
|
||||
"git diff --stat 2>/dev/null | tail -1",
|
||||
cwd,
|
||||
);
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
branch: branch || "(detached)",
|
||||
trigger,
|
||||
sessionCommits,
|
||||
uncommittedChanges: {
|
||||
staged: stagedStat || "none",
|
||||
modified: modifiedStat || "none",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatSummary(summary: SessionSummary): string {
|
||||
let output = "# Claude Session Summary\n";
|
||||
output += `# Generated: ${summary.timestamp}\n`;
|
||||
output += `# Branch: ${summary.branch}\n`;
|
||||
output += `# Trigger: ${summary.trigger}\n\n`;
|
||||
|
||||
output += "## Session Activity\n\n";
|
||||
|
||||
if (summary.sessionCommits.length > 0) {
|
||||
output += "### Commits this session:\n";
|
||||
summary.sessionCommits.forEach((commit) => {
|
||||
output += `- ${commit}\n`;
|
||||
});
|
||||
output += "\n";
|
||||
}
|
||||
|
||||
if (
|
||||
summary.uncommittedChanges.staged !== "none" ||
|
||||
summary.uncommittedChanges.modified !== "none"
|
||||
) {
|
||||
output += "### Uncommitted changes:\n";
|
||||
if (summary.uncommittedChanges.staged !== "none") {
|
||||
output += `Staged: ${summary.uncommittedChanges.staged}\n`;
|
||||
}
|
||||
if (summary.uncommittedChanges.modified !== "none") {
|
||||
output += `Modified: ${summary.uncommittedChanges.modified}\n`;
|
||||
}
|
||||
output += "\n";
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const input = (await Bun.stdin.json()) as PreCompactHookInput;
|
||||
const { cwd, trigger } = input;
|
||||
|
||||
const gitRoot = await getGitRoot(cwd);
|
||||
|
||||
if (gitRoot) {
|
||||
const summary = await getSessionSummary(cwd, trigger);
|
||||
|
||||
if (summary) {
|
||||
// Write to ~/.claude/session-summaries/ to avoid polluting user repos
|
||||
const claudeDir = join(homedir(), ".claude", "session-summaries");
|
||||
if (!existsSync(claudeDir)) {
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Use repo name as filename to keep summaries separate per project
|
||||
const repoName = gitRoot.split("/").pop() || "unknown";
|
||||
const summaryPath = join(claudeDir, `${repoName}.md`);
|
||||
const content = formatSummary(summary);
|
||||
|
||||
await Bun.write(summaryPath, content);
|
||||
console.log(`Session summary saved to ${summaryPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
Reference in New Issue
Block a user