Commit 93e4a903a7b6
Changed files (35)
dots
config
claude
hooks
plugins
session-manager
commands
skills
copilot-hooks
opencode
plugin
pkgs
dots/config/ai/path-policies.json
@@ -2,14 +2,14 @@
"policies": [
{
"name": "ai-sessions-location",
- "description": "Session files should go to unified AI storage",
- "filenamePattern": ".*session.*\\.md$",
+ "description": "Session files must go to unified AI storage, not old Claude paths",
+ "pathPattern": ".*/(claude|.claude)/history/sessions/.*\\.md$",
"blockedPaths": [
"~/.config/claude/history/sessions/",
"~/.claude/history/sessions/"
],
"suggestedPath": "~/.local/share/ai/sessions/YYYY-MM/",
- "action": "warn",
+ "action": "block",
"enabled": true
},
{
@@ -24,14 +24,15 @@
},
{
"name": "ai-plans-location",
- "description": "Plan files should go to unified AI storage",
- "filenamePattern": ".*plan.*\\.md$",
+ "description": "Plan files must go to unified AI storage",
+ "pathPattern": ".*/(claude|.claude)/(history/)?plans/.*\\.md$",
"blockedPaths": [
"~/.config/claude/plans/",
- "~/.claude/plans/"
+ "~/.claude/plans/",
+ "~/.config/claude/history/plans/"
],
"suggestedPath": "~/.local/share/ai/plans/",
- "action": "warn",
+ "action": "block",
"enabled": true
},
{
@@ -46,13 +47,13 @@
},
{
"name": "ai-learnings-location",
- "description": "Learning files should go to unified AI storage",
- "filenamePattern": ".*learning.*\\.md$",
+ "description": "Learning files must go to unified AI storage",
+ "pathPattern": ".*/(claude|.claude)/history/learnings/.*\\.md$",
"blockedPaths": [
"~/.config/claude/history/learnings/"
],
"suggestedPath": "~/.local/share/ai/learnings/YYYY-MM/",
- "action": "warn",
+ "action": "block",
"enabled": true
},
{
@@ -67,13 +68,13 @@
},
{
"name": "ai-research-location",
- "description": "Research files should go to unified AI storage",
- "filenamePattern": ".*research.*\\.md$",
+ "description": "Research files must go to unified AI storage",
+ "pathPattern": ".*/(claude|.claude)/history/research/.*\\.md$",
"blockedPaths": [
"~/.config/claude/history/research/"
],
"suggestedPath": "~/.local/share/ai/research/YYYY-MM/",
- "action": "warn",
+ "action": "block",
"enabled": true
},
{
dots/config/claude/hooks/.keep
dots/config/claude/hooks/capture-tool-output.ts
@@ -0,0 +1,61 @@
+#!/usr/bin/env bun
+/**
+ * PostToolUse hook — capture interesting tool outputs to JSONL.
+ *
+ * Tool-output logs are Claude-specific (not shared across tools)
+ * and stay under ~/.config/claude/history/tool-outputs/.
+ */
+
+import { readStdinJSON, TOOL_OUTPUTS_DIR, dateStr, yearMonth, ensureDir, now } from "./lib.ts";
+import { appendFileSync } from "node:fs";
+import { join } from "node:path";
+
+interface ToolUseData {
+ tool_name: string;
+ tool_input: Record<string, unknown>;
+ tool_response: Record<string, unknown>;
+ conversation_id?: string;
+ timestamp?: string;
+}
+
+const INTERESTING_TOOLS = new Set([
+ "Bash",
+ "Edit",
+ "Write",
+ "Read",
+ "Task",
+ "NotebookEdit",
+ "Skill",
+ "SlashCommand",
+]);
+
+async function main() {
+ const data = await readStdinJSON<ToolUseData>();
+ if (!data || !INTERESTING_TOOLS.has(data.tool_name)) {
+ process.exit(0);
+ }
+
+ const d = now();
+ const dir = join(TOOL_OUTPUTS_DIR, yearMonth(d));
+ ensureDir(dir);
+
+ const entry = {
+ timestamp: data.timestamp || d.toISOString(),
+ tool: data.tool_name,
+ input: data.tool_input,
+ output: data.tool_response,
+ session: data.conversation_id || "",
+ };
+
+ const file = join(dir, `${dateStr(d)}_tool-outputs.jsonl`);
+
+ try {
+ appendFileSync(file, JSON.stringify(entry) + "\n");
+ } catch (err) {
+ process.stderr.write(`[capture-tool-output] Error: ${err}\n`);
+ }
+
+ process.exit(0);
+}
+
+main();
dots/config/claude/hooks/initialize-session.ts
@@ -0,0 +1,52 @@
+#!/usr/bin/env bun
+/**
+ * SessionStart hook — initialise a Claude Code session.
+ *
+ * • Skips subagent sessions
+ * • Debounces duplicate triggers (2 s)
+ * • Sets terminal tab title
+ * • Logs session start to unified AI storage
+ */
+
+import {
+ isSubagent,
+ shouldDebounce,
+ setTerminalTitle,
+ sessionLogPath,
+ appendLog,
+ localTimestamp,
+ host,
+ now,
+} from "./lib.ts";
+
+function main() {
+ if (isSubagent()) {
+ process.stderr.write("🤖 Subagent session — skipping initialisation\n");
+ process.exit(0);
+ }
+
+ if (shouldDebounce("session-start")) {
+ process.stderr.write("⏱ Debouncing duplicate SessionStart\n");
+ process.exit(0);
+ }
+
+ const title = "Claude Ready";
+ setTerminalTitle(title);
+ process.stderr.write(`🚀 Session initialised: "${title}" on ${host()}\n`);
+
+ // Ring terminal bell
+ process.stderr.write("\x07");
+
+ // Append to unified session log (same format as pi ai-storage)
+ try {
+ const d = now();
+ const entry = `${localTimestamp(d)} - Session started (claude) on ${host()}\n`;
+ appendLog(sessionLogPath(d), entry);
+ } catch (err) {
+ process.stderr.write(`[initialize-session] Warning: ${err}\n`);
+ }
+
+ process.exit(0);
+}
+
+main();
dots/config/claude/hooks/lib.ts
@@ -0,0 +1,150 @@
+/**
+ * Shared utilities for Claude Code hooks.
+ * All hooks write to unified AI storage: ~/.local/share/ai/
+ */
+
+import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync } from "node:fs";
+import { join, basename } from "node:path";
+import { homedir, hostname } from "node:os";
+
+// ── Paths ──────────────────────────────────────────────────────────
+
+export const HOME = homedir();
+export const AI_DATA_DIR = join(HOME, ".local", "share", "ai");
+export const SESSIONS_DIR = join(AI_DATA_DIR, "sessions");
+export const RESEARCH_DIR = join(AI_DATA_DIR, "research");
+export const PLANS_DIR = join(AI_DATA_DIR, "plans");
+export const LEARNINGS_DIR = join(AI_DATA_DIR, "learnings");
+
+// Tool-output capture stays under claude config (not shared across tools)
+export const CLAUDE_DIR = process.env.CLAUDE_DIR || join(HOME, ".config", "claude");
+export const TOOL_OUTPUTS_DIR = join(CLAUDE_DIR, "history", "tool-outputs");
+
+// ── Date helpers (local time, matching pi ai-storage format) ───────
+
+export function now() {
+ return new Date();
+}
+
+/** Pad to 2 digits */
+function p2(n: number): string {
+ return String(n).padStart(2, "0");
+}
+
+/** Local timezone offset string, e.g. "+01:00" or "-05:00" */
+function tzOffset(d: Date): string {
+ const off = -d.getTimezoneOffset();
+ const sign = off >= 0 ? "+" : "-";
+ const h = p2(Math.floor(Math.abs(off) / 60));
+ const m = p2(Math.abs(off) % 60);
+ return `${sign}${h}:${m}`;
+}
+
+export function yearMonth(d: Date = now()): string {
+ return `${d.getFullYear()}-${p2(d.getMonth() + 1)}`;
+}
+
+export function dateStr(d: Date = now()): string {
+ return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
+}
+
+/** ISO-ish local timestamp: YYYY-MM-DDTHH:MM:SS+TZ:TZ */
+export function localTimestamp(d: Date = now()): string {
+ return `${dateStr(d)}T${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}${tzOffset(d)}`;
+}
+
+/** HH:MM */
+export function timeStr(d: Date = now()): string {
+ return `${p2(d.getHours())}:${p2(d.getMinutes())}`;
+}
+
+// ── Subagent detection ─────────────────────────────────────────────
+
+export function isSubagent(): boolean {
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || "";
+ if (projectDir.includes("/.claude/agents/")) return true;
+ if (process.env.CLAUDE_AGENT_TYPE) return true;
+ return false;
+}
+
+// ── Debounce via lockfile ──────────────────────────────────────────
+
+export function shouldDebounce(name: string, ms: number = 2000): boolean {
+ const lockfile = join("/tmp", `claude-${name}.lock`);
+ const nowMs = Date.now();
+
+ if (existsSync(lockfile)) {
+ try {
+ const prev = parseInt(readFileSync(lockfile, "utf-8").trim(), 10);
+ if (nowMs - prev < ms) return true;
+ } catch {}
+ }
+
+ try {
+ writeFileSync(lockfile, String(nowMs));
+ } catch {}
+ return false;
+}
+
+// ── Terminal title ─────────────────────────────────────────────────
+
+export function setTerminalTitle(title: string): void {
+ process.stderr.write(`\x1b]0;${title}\x07`);
+ process.stderr.write(`\x1b]2;${title}\x07`);
+ process.stderr.write(`\x1b]30;${title}\x07`);
+}
+
+// ── File helpers ───────────────────────────────────────────────────
+
+export function ensureDir(dir: string): void {
+ mkdirSync(dir, { recursive: true });
+}
+
+export function appendLog(file: string, line: string): void {
+ ensureDir(join(file, ".."));
+ appendFileSync(file, line);
+}
+
+export function sessionLogPath(d: Date = now()): string {
+ return join(SESSIONS_DIR, yearMonth(d), `${dateStr(d)}_session-log.txt`);
+}
+
+// ── Read stdin (for hooks that receive JSON) ───────────────────────
+
+export async function readStdin(): Promise<string> {
+ const chunks: Buffer[] = [];
+ for await (const chunk of process.stdin) {
+ chunks.push(chunk);
+ }
+ return Buffer.concat(chunks).toString("utf-8");
+}
+
+export async function readStdinJSON<T = any>(): Promise<T | null> {
+ try {
+ const raw = await readStdin();
+ if (!raw.trim()) return null;
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+}
+
+// ── Hostname ───────────────────────────────────────────────────────
+
+export function host(): string {
+ return hostname();
+}
+
+// ── Slug helpers ───────────────────────────────────────────────────
+
+/**
+ * Normalise a description into a filename-safe slug.
+ * Target format: YYYY-MM-DD-description-with-dashes.md
+ */
+export function slugify(s: string): string {
+ return s
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .substring(0, 60);
+}
dots/config/claude/hooks/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "claude-hooks",
+ "version": "1.0.0",
+ "type": "module",
+ "description": "TypeScript hooks for Claude Code — unified AI storage"
+}
dots/config/claude/hooks/save-session.ts
@@ -0,0 +1,87 @@
+#!/usr/bin/env bun
+/**
+ * SessionEnd hook — prompt Claude to save a session summary
+ * to the unified AI storage (~/.local/share/ai/sessions/).
+ */
+
+import { isSubagent, dateStr, yearMonth, now, SESSIONS_DIR } from "./lib.ts";
+
+function main() {
+ if (isSubagent()) {
+ process.exit(0);
+ }
+
+ // Ring terminal bell
+ process.stderr.write("\x07");
+
+ const d = now();
+ const ym = yearMonth(d);
+ const ds = dateStr(d);
+ const dir = `~/.local/share/ai/sessions/${ym}`;
+
+ process.stdout.write(`
+---
+
+# Automatic Session Summary
+
+**IMPORTANT**: Please create a session summary and save it to the unified AI storage.
+
+## Instructions
+
+Create a brief summary (2-4 paragraphs) of this session documenting:
+- Primary tasks accomplished
+- Key decisions or solutions
+- Files/systems modified
+- Any remaining work or next steps
+
+Save the summary using the Write tool to:
+\`${dir}/${ds}-<brief-slug>.md\`
+
+**Filename format**: \`YYYY-MM-DD-description-with-dashes.md\`
+- Use only lowercase letters, digits, and dashes
+- Keep description to 3-6 words
+- No underscores, no timestamps beyond the date, no _SESSION_ prefix
+
+**Good examples:**
+- \`${ds}-nix-systems-refactoring.md\`
+- \`${ds}-tekton-pr-review.md\`
+- \`${ds}-pi-extensions-setup.md\`
+
+**Bad examples:**
+- \`${ds}_SESSION_some-thing.md\` (underscore, SESSION prefix)
+- \`${ds}-155616_SESSION_description.md\` (timestamp noise)
+- \`some-thing.md\` (missing date)
+
+Use the **Session Entry** template:
+
+\`\`\`markdown
+# Session: <Description>
+
+**Date:** ${ds}
+**Tool:** claude
+
+## Summary
+Brief description of what was accomplished.
+
+## What Was Accomplished
+- Task 1
+- Task 2
+
+## Files Changed
+- \\\`path/to/file\\\` - Description of change
+
+## Outcome
+Result of the session.
+
+## Next Steps
+- [ ] Follow-up task
+
+### Tags
+#tag1 #tag2
+\`\`\`
+`);
+
+ process.exit(0);
+}
+
+main();
dots/config/claude/hooks/update-terminal-title.ts
@@ -0,0 +1,84 @@
+#!/usr/bin/env bun
+/**
+ * PostToolUse hook — update terminal tab title with context.
+ *
+ * Shows project name, active skill, task progress.
+ */
+
+import { readStdinJSON, setTerminalTitle } from "./lib.ts";
+import { basename } from "node:path";
+
+interface ToolResult {
+ tool_name: string;
+ tool_input: Record<string, unknown>;
+}
+
+interface TodoItem {
+ content: string;
+ status: string;
+ activeForm?: string;
+}
+
+const SKILL_ICONS: Record<string, string> = {
+ Journal: "📓",
+ Notes: "📝",
+ TODOs: "✅",
+ Org: "📋",
+ Git: "🔀",
+ GitHub: "🐙",
+ Email: "📧",
+ Python: "🐍",
+ golang: "🐹",
+ Rust: "🦀",
+ Nix: "❄️",
+ Kubernetes: "☸️",
+ Tekton: "🔧",
+};
+
+function getTodoProgress(todos: TodoItem[]): string | null {
+ if (!todos?.length) return null;
+ let completed = 0;
+ let active = "";
+ for (const t of todos) {
+ if (t.status === "completed") completed++;
+ if (t.status === "in_progress" && t.activeForm) active = t.activeForm;
+ }
+ return active ? `[${completed + 1}/${todos.length}] ${active}` : null;
+}
+
+function buildTitle(result: ToolResult | null): string {
+ const prefix = "Claude";
+ const parts: string[] = [];
+
+ if (result?.tool_name === "TodoWrite") {
+ const todos = (result.tool_input as any).todos as TodoItem[] | undefined;
+ if (todos) {
+ const progress = getTodoProgress(todos);
+ if (progress) parts.push(progress);
+ }
+ }
+
+ if (result?.tool_name === "Skill") {
+ const skill = (result.tool_input as any).skill as string | undefined;
+ if (skill) {
+ const icon = SKILL_ICONS[skill] || "🎯";
+ parts.push(`${icon} ${skill}`);
+ }
+ }
+
+ const projectDir = process.env.CLAUDE_PROJECT_DIR;
+ if (projectDir) parts.push(basename(projectDir));
+
+ try {
+ parts.push(basename(process.cwd()));
+ } catch {}
+
+ return parts.length ? `${prefix} ${parts.join(" • ")}` : `${prefix} Ready`;
+}
+
+async function main() {
+ const result = await readStdinJSON<ToolResult>();
+ setTerminalTitle(buildTitle(result));
+}
+
+main();
dots/config/claude/hooks/validate-git-push.ts
@@ -0,0 +1,104 @@
+#!/usr/bin/env bun
+/**
+ * PreToolUse hook (Bash) — validate git commands for safety.
+ *
+ * Matches pi's validate-git-push.ts extension behaviour:
+ * • git push without explicit refspec → block
+ * • git add -A / --all / . → block
+ * • push to protected branches → warn (stderr)
+ *
+ * Handles command chaining: &&, ||, ;, |, $()
+ */
+
+import { readStdinJSON } from "./lib.ts";
+
+interface PreToolInput {
+ tool_name: string;
+ tool_input: { command?: string };
+}
+
+// Match git commands even after chaining operators
+const CMD_PREFIX = String.raw`(^|&&|\|\||;|\||\$\()\s*`;
+
+const DANGEROUS_ADD = [
+ new RegExp(CMD_PREFIX + String.raw`git\s+add\s+-A(\s|$)`),
+ new RegExp(CMD_PREFIX + String.raw`git\s+add\s+--all(\s|$)`),
+ new RegExp(CMD_PREFIX + String.raw`git\s+add\s+\.(\s|$)`),
+];
+
+const GIT_PUSH = new RegExp(CMD_PREFIX + String.raw`git\s+push(\s|$)`);
+const HAS_REFSPEC = /git\s+push\s+.*\s+\S+:\S+/;
+
+function isDangerousAdd(cmd: string): boolean {
+ return DANGEROUS_ADD.some((re) => re.test(cmd));
+}
+
+function isGitPush(cmd: string): boolean {
+ return GIT_PUSH.test(cmd);
+}
+
+function pushesToProtected(cmd: string): boolean {
+ return [":main", ":master"].some((b) => cmd.includes(b));
+}
+
+async function main() {
+ const data = await readStdinJSON<PreToolInput>();
+ if (!data) process.exit(0);
+
+ const cmd = data.tool_input?.command?.trim() || "";
+ if (!cmd) process.exit(0);
+
+ // ── git add safety ────────────────────────────────────────────
+ if (isDangerousAdd(cmd)) {
+ process.stdout.write(
+ JSON.stringify({
+ decision: "block",
+ reason: [
+ "BLOCKED: Dangerous git add command detected!",
+ "",
+ "Commands like 'git add -A', 'git add --all', and 'git add .' can add unintended files.",
+ "",
+ "Use explicit file paths instead:",
+ " git add path/to/specific/file.txt",
+ " git add path/to/directory/",
+ "",
+ `Blocked command: ${cmd}`,
+ ].join("\n"),
+ })
+ );
+ process.exit(0);
+ }
+
+ // ── git push safety ───────────────────────────────────────────
+ if (isGitPush(cmd)) {
+ if (!HAS_REFSPEC.test(cmd)) {
+ process.stdout.write(
+ JSON.stringify({
+ decision: "block",
+ reason: [
+ "BLOCKED: git push without explicit refspec!",
+ "",
+ "Implicit branch tracking can push to wrong branches.",
+ "",
+ "Use explicit refspec instead:",
+ " git push origin <branch>:<branch>",
+ " git push origin HEAD:<branch>",
+ "",
+ `Blocked command: ${cmd}`,
+ ].join("\n"),
+ })
+ );
+ process.exit(0);
+ }
+
+ if (pushesToProtected(cmd)) {
+ process.stderr.write(
+ "⚠️ Warning: pushing to protected branch (main/master)\n"
+ );
+ }
+ }
+
+ process.exit(0);
+}
+
+main();
dots/config/claude/hooks/validate-write-path.ts
@@ -0,0 +1,272 @@
+#!/usr/bin/env bun
+/**
+ * PreToolUse hook (Write) — validate file paths and filenames.
+ *
+ * Mirrors pi's path-validator extension:
+ * • Reads policies from ~/.config/ai/path-policies.json
+ * • Blocks writes to wrong locations (old claude history paths)
+ * • Enforces filename conventions (YYYY-MM-DD-slug.md)
+ * • Falls back to built-in defaults if no config found
+ */
+
+import { readStdinJSON, HOME } from "./lib.ts";
+import { readFileSync, existsSync } from "node:fs";
+import { join, basename } from "node:path";
+
+// ── Types ──────────────────────────────────────────────────────────
+
+interface PreToolInput {
+ tool_name: string;
+ tool_input: {
+ file_path?: string;
+ filePath?: string;
+ path?: string;
+ command?: string;
+ };
+}
+
+interface PathPolicy {
+ name: string;
+ description?: string;
+ filenamePattern?: string;
+ pathPattern?: string;
+ allowedPaths?: string[];
+ blockedPaths?: string[];
+ requiredFilenameFormat?: string;
+ formatDescription?: string;
+ formatExample?: string;
+ suggestedPath?: string;
+ action?: "warn" | "block" | "suggest";
+ enabled?: boolean;
+}
+
+interface PolicyConfig {
+ policies: PathPolicy[];
+}
+
+// ── Helpers ────────────────────────────────────────────────────────
+
+function expandHome(p: string): string {
+ return p.startsWith("~/") ? join(HOME, p.slice(2)) : p;
+}
+
+function pathMatches(path: string, pattern: string): boolean {
+ const expanded = expandHome(pattern)
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
+ .replace(/\*/g, ".*")
+ .replace(/\?/g, ".");
+ return new RegExp(`^${expanded}`).test(path);
+}
+
+// ── Default policies (same as pi path-validator) ───────────────────
+
+const DEFAULT_POLICIES: PathPolicy[] = [
+ {
+ name: "ai-sessions-location",
+ description: "Session files must go to unified AI storage, not old Claude paths",
+ filenamePattern: ".*session.*\\.md$",
+ blockedPaths: [
+ "~/.config/claude/history/sessions/",
+ "~/.claude/history/sessions/",
+ ],
+ suggestedPath: "~/.local/share/ai/sessions/YYYY-MM/",
+ action: "block",
+ enabled: true,
+ },
+ {
+ name: "ai-sessions-format",
+ description: "Session files must follow YYYY-MM-DD-description.md format",
+ pathPattern: ".*/ai/sessions/.*\\.md$",
+ requiredFilenameFormat: "^\\d{4}-\\d{2}-\\d{2}-[a-z0-9][a-z0-9-]*\\.md$",
+ formatDescription: "YYYY-MM-DD-description.md (lowercase, hyphens only, no underscores)",
+ formatExample: "2026-02-05-unified-ai-storage.md",
+ action: "block",
+ enabled: true,
+ },
+ {
+ name: "ai-plans-location",
+ description: "Plan files must go to unified AI storage",
+ filenamePattern: ".*plan.*\\.md$",
+ blockedPaths: [
+ "~/.config/claude/plans/",
+ "~/.claude/plans/",
+ "~/.config/claude/history/plans/",
+ ],
+ suggestedPath: "~/.local/share/ai/plans/",
+ action: "block",
+ enabled: true,
+ },
+ {
+ name: "ai-plans-format",
+ description: "Plan files must follow kebab-case.md format (no dates)",
+ pathPattern: ".*/ai/plans/.*\\.md$",
+ requiredFilenameFormat: "^[a-z0-9][a-z0-9-]*\\.md$",
+ formatDescription: "kebab-case.md (lowercase, hyphens, no dates)",
+ formatExample: "homelab-migration-plan.md",
+ action: "block",
+ enabled: true,
+ },
+ {
+ name: "ai-learnings-location",
+ description: "Learning files must go to unified AI storage",
+ filenamePattern: ".*learning.*\\.md$",
+ blockedPaths: ["~/.config/claude/history/learnings/"],
+ suggestedPath: "~/.local/share/ai/learnings/YYYY-MM/",
+ action: "block",
+ enabled: true,
+ },
+ {
+ name: "ai-learnings-format",
+ description: "Learning files must follow YYYY-MM-DD-description.md format",
+ pathPattern: ".*/ai/learnings/.*\\.md$",
+ requiredFilenameFormat: "^\\d{4}-\\d{2}-\\d{2}-[a-z0-9][a-z0-9-]*\\.md$",
+ formatDescription: "YYYY-MM-DD-description.md (lowercase, hyphens only)",
+ formatExample: "2026-02-05-nix-flake-patterns.md",
+ action: "block",
+ enabled: true,
+ },
+ {
+ name: "ai-research-location",
+ description: "Research files must go to unified AI storage",
+ filenamePattern: ".*research.*\\.md$",
+ blockedPaths: ["~/.config/claude/history/research/"],
+ suggestedPath: "~/.local/share/ai/research/YYYY-MM/",
+ action: "block",
+ enabled: true,
+ },
+ {
+ name: "ai-research-format",
+ description: "Research files must follow YYYY-MM-DD-description.md format",
+ pathPattern: ".*/ai/research/.*\\.md$",
+ requiredFilenameFormat: "^\\d{4}-\\d{2}-\\d{2}-[a-z0-9][a-z0-9-]*\\.md$",
+ formatDescription: "YYYY-MM-DD-description.md (lowercase, hyphens only)",
+ formatExample: "2026-02-05-chrono-node-evaluation.md",
+ action: "block",
+ enabled: true,
+ },
+ {
+ name: "no-secrets-in-repos",
+ description: "Prevent writing secrets to git repositories",
+ filenamePattern: "\\.(env|pem|key|secret|credentials)$",
+ blockedPaths: ["~/src/*", "~/projects/*"],
+ action: "block",
+ enabled: true,
+ },
+];
+
+// ── Load policies ──────────────────────────────────────────────────
+
+function loadPolicies(): PathPolicy[] {
+ const configPath = join(HOME, ".config", "ai", "path-policies.json");
+ try {
+ if (existsSync(configPath)) {
+ const config: PolicyConfig = JSON.parse(
+ readFileSync(configPath, "utf-8")
+ );
+ if (config.policies && Array.isArray(config.policies)) {
+ const names = new Set(config.policies.map((p) => p.name));
+ return [
+ ...config.policies,
+ ...DEFAULT_POLICIES.filter((p) => !names.has(p.name)),
+ ];
+ }
+ }
+ } catch {}
+ return DEFAULT_POLICIES;
+}
+
+// ── Validate ───────────────────────────────────────────────────────
+
+function validate(
+ filePath: string,
+ policies: PathPolicy[]
+): { blocks: string[]; warns: string[] } {
+ const expanded = expandHome(filePath);
+ const fname = basename(expanded);
+ const blocks: string[] = [];
+ const warns: string[] = [];
+
+ for (const pol of policies) {
+ if (pol.enabled === false) continue;
+
+ // Check if this policy applies (filename or path pattern)
+ let matches = false;
+ if (pol.filenamePattern && new RegExp(pol.filenamePattern, "i").test(fname))
+ matches = true;
+ if (pol.pathPattern && new RegExp(pol.pathPattern, "i").test(expanded))
+ matches = true;
+ if (!matches) continue;
+
+ const action = pol.action || "warn";
+ const bucket = action === "block" ? blocks : warns;
+
+ // Check blocked paths
+ if (pol.blockedPaths) {
+ for (const bp of pol.blockedPaths) {
+ if (pathMatches(expanded, bp)) {
+ let msg = `[${pol.name}] ${pol.description || "Blocked path"}`;
+ if (pol.suggestedPath) msg += `\n → Use: ${pol.suggestedPath}`;
+ bucket.push(msg);
+ break;
+ }
+ }
+ }
+
+ // Check allowed paths
+ if (pol.allowedPaths?.length) {
+ const ok = pol.allowedPaths.some((ap) => pathMatches(expanded, ap));
+ if (!ok) {
+ bucket.push(
+ `[${pol.name}] Path not in allowed locations: ${pol.allowedPaths.join(", ")}`
+ );
+ }
+ }
+
+ // Check required filename format
+ if (pol.requiredFilenameFormat) {
+ if (!new RegExp(pol.requiredFilenameFormat).test(fname)) {
+ const desc = pol.formatDescription || pol.requiredFilenameFormat;
+ const ex = pol.formatExample ? `\n Example: ${pol.formatExample}` : "";
+ bucket.push(`[${pol.name}] Filename doesn't match format: ${desc}${ex}`);
+ }
+ }
+ }
+
+ return { blocks, warns };
+}
+
+// ── Main ───────────────────────────────────────────────────────────
+
+async function main() {
+ const data = await readStdinJSON<PreToolInput>();
+ if (!data) process.exit(0);
+
+ const filePath =
+ data.tool_input?.file_path ||
+ data.tool_input?.filePath ||
+ data.tool_input?.path;
+
+ if (!filePath || typeof filePath !== "string") process.exit(0);
+
+ const policies = loadPolicies();
+ const { blocks, warns } = validate(filePath, policies);
+
+ // Stderr warnings (non-blocking)
+ for (const w of warns) {
+ process.stderr.write(`⚠️ ${w}\n`);
+ }
+
+ // Block if violations
+ if (blocks.length > 0) {
+ process.stdout.write(
+ JSON.stringify({
+ decision: "block",
+ reason: ["BLOCKED: Path policy violation", "", ...blocks].join("\n"),
+ })
+ );
+ }
+
+ process.exit(0);
+}
+
+main();
dots/config/claude/plugins/session-manager/commands/save-session.md
@@ -1,16 +1,16 @@
---
-description: Save a summary of the current Claude Code session to history
+description: Save a summary of the current Claude Code session to unified AI storage
---
# Save Session
-Create a comprehensive session summary following the history-system format and save it to `~/.config/claude/history/sessions/`.
+Create a comprehensive session summary and save it to the unified AI storage at `~/.local/share/ai/sessions/`.
## Important Guidelines
- **DO NOT use echo or command-line tools to communicate** - output all text directly in your response
-- **Create a properly formatted session entry** following the history-system skill template
-- **Save to the correct location** with proper timestamp and filename format
+- **Create a properly formatted session entry** following the template below
+- **Save to the correct location** with proper filename format
- **Be concise but comprehensive** - capture what matters
---
@@ -23,158 +23,76 @@ Review the conversation and identify:
1. **Main accomplishments** - What was actually done?
2. **Key decisions** - What choices were made and why?
3. **Next steps** - What follow-up work is needed?
-4. **Related topics** - What areas of the codebase or system were touched?
### Step 2: Generate Session Entry
-Create a session entry file with:
+**Filename format**: `YYYY-MM-DD-description-with-dashes.md`
-**Filename format**: `YYYY-MM-DD-HHMMSS_SESSION_description.md`
+Rules:
+- Use only lowercase letters, digits, and dashes in the description
+- Keep description to 3-6 words
+- **NO underscores** in filenames
+- **NO timestamps** beyond the date
+- **NO `_SESSION_` prefix**
-**Location**: `~/.config/claude/history/sessions/YYYY-MM/`
+**Location**: `~/.local/share/ai/sessions/YYYY-MM/`
**Template**:
```markdown
# Session: <Brief Description>
-**Date**: YYYY-MM-DD HH:MM
-**Duration**: <estimated duration if known>
-**Context**: <What prompted this work session>
+**Date:** YYYY-MM-DD
+**Tool:** claude
+
+## Summary
+Brief description of what was accomplished.
## What Was Accomplished
-- <Specific task 1>
-- <Specific task 2>
-- <Specific task 3>
+- Task 1
+- Task 2
-## Decisions Made
-- **<Decision>**: <Rationale>
-- **<Decision>**: <Rationale>
+## Files Changed
+- `path/to/file` - Description of change
-## Technical Details
-<Any important technical information worth preserving>
+## Outcome
+Result of the session.
## Next Steps
-- [ ] <Follow-up task 1>
-- [ ] <Follow-up task 2>
+- [ ] Follow-up task 1
+- [ ] Follow-up task 2
-## Related Notes
-<If applicable, link to related denote notes>
-
-## Tags
-#session #<relevant-topic-tags>
+### Tags
+#claude #relevant-topic-tags
```
### Step 3: Save the File
Use the Write tool to save the session entry to:
```
-~/.config/claude/history/sessions/YYYY-MM/YYYY-MM-DD-HHMMSS_SESSION_description.md
+~/.local/share/ai/sessions/YYYY-MM/YYYY-MM-DD-description.md
```
-Replace:
-- `YYYY-MM` with current year-month
-- `YYYY-MM-DD-HHMMSS` with current timestamp
-- `description` with a short hyphenated description (e.g., `nixos-syncthing-config`)
+**Good examples:**
+- `~/.local/share/ai/sessions/2026-02/2026-02-10-tekton-pr-review.md`
+- `~/.local/share/ai/sessions/2026-02/2026-02-10-nix-systems-refactoring.md`
+
+**Bad examples:**
+- `~/.local/share/ai/sessions/2026-02/2026-02-10_SESSION_something.md` ❌
+- `~/.config/claude/history/sessions/2026-02/...` ❌ (old location!)
### Step 4: Confirm to User
After saving, inform the user:
```
-✅ Session saved to: ~/.config/claude/history/sessions/YYYY-MM/YYYY-MM-DD-HHMMSS_SESSION_description.md
-
-Summary:
-- X tasks completed
-- Y decisions documented
-- Z next steps identified
+✅ Session saved to: ~/.local/share/ai/sessions/YYYY-MM/YYYY-MM-DD-description.md
```
---
-## Examples
-
-### Example 1: Configuration Change Session
-```markdown
-# Session: Configure Syncthing for Claude History
-
-**Date**: 2025-12-04 07:30
-**Duration**: 30 minutes
-**Context**: Need to sync Claude Code history across machines for backup
-
-## What Was Accomplished
-- Added claude-history folder to globals.nix syncthingFolders
-- Generated syncthing folder ID (j5zdn-6kq4t)
-- Configured claude-history sync for kyushu and aomi
-- Path: /home/vincent/.config/claude/history
-
-## Decisions Made
-- **Syncthing ID format**: Used random 5-char strings (xxxxx-xxxxx) following existing pattern
-- **Machines**: Started with kyushu and aomi only, can add more later
-
-## Technical Details
-- Syncthing folder ID: j5zdn-6kq4t
-- Path: /home/vincent/.config/claude/history
-- Synced machines: kyushu (SBLRZF4-...), aomi (CN5P3MV-...)
-
-## Next Steps
-- [ ] Rebuild NixOS on kyushu to activate sync
-- [ ] Rebuild NixOS on aomi to activate sync
-- [ ] Verify syncthing picks up the new folder
-- [ ] Consider adding more machines later
-
-## Tags
-#session #nixos #syncthing #claude-code #homelab
-```
-
-### Example 2: Development Session
-```markdown
-# Session: Implement Session Saving Hooks
-
-**Date**: 2025-12-04 08:00
-**Duration**: 45 minutes
-**Context**: Automate session saving in Claude Code
-
-## What Was Accomplished
-- Created claude-hooks-save-session Go command
-- Created /save-session slash command plugin
-- Updated plugin structure in dots/.claude/plugins/session-manager
-- Documented workflow in command file
-
-## Decisions Made
-- **SessionEnd hook approach**: Prompt user instead of auto-save (user agency)
-- **Plugin structure**: Created dedicated session-manager plugin
-- **Format**: Follow existing history-system markdown template
-
-## Technical Details
-- Hook location: tools/claude-hooks/cmd/save-session/
-- Plugin location: dots/.claude/plugins/session-manager/
-- Follows existing claude-hooks Go patterns
-
-## Next Steps
-- [ ] Update default.nix to build save-session command
-- [ ] Add SessionEnd hook to settings.json
-- [ ] Test hook and slash command
-- [ ] Update setup-hooks.sh
-
-## Tags
-#session #development #claude-code #golang #hooks
-```
-
----
-
-## Best Practices
-
-1. **Be specific** - Don't just say "worked on code", say what exactly was modified
-2. **Capture decisions** - Document WHY choices were made, not just what was done
-3. **Include context** - Future you needs to know what prompted this work
-4. **Link related work** - Reference denote notes, commits, or other sessions
-5. **Add tags** - Make sessions searchable by topic
-6. **Keep it real** - If nothing significant happened, it's okay to skip saving
-
## When to Use
- After completing significant work
- When making important architectural decisions
-- When learning something worth preserving
- At the end of productive sessions
- When asked by SessionEnd hook
@@ -183,4 +101,3 @@ Summary:
- Very short sessions (<5 minutes)
- Purely exploratory work with no findings
- When no meaningful progress was made
-- When the work will be documented elsewhere
dots/config/claude/skills/CORE/history-system.md
@@ -1,14 +1,15 @@
# History System Documentation
-**Location:** `~/.config/claude/history/`
+**Location:** `~/.local/share/ai/` (unified across all AI coding tools)
**Format:** Markdown (`.md`) only
-**Purpose:** Automated documentation of work performed by Claude Code
+**Purpose:** Automated documentation of work performed by AI coding tools
+**Synced:** Via syncthing (actual data in `~/.local/share/ai-sync/`)
---
## Overview
-The History System captures significant work and learnings for future reference. All entries are markdown files, synced across machines via syncthing.
+The History System captures significant work and learnings for future reference. All entries are markdown files, stored in a unified location shared by all AI coding tools (Claude Code, pi, opencode, etc.) and synced across machines via syncthing.
## File Format
@@ -19,13 +20,30 @@ The History System captures significant work and learnings for future reference.
- Easy to read in any editor
- Searchable with grep/rg
+## Filename Convention
+
+**STRICT FORMAT:** `YYYY-MM-DD-description-with-dashes.md`
+
+Rules:
+- Date prefix is mandatory
+- Use only lowercase letters, digits, and dashes
+- **NO underscores** (except `_session-log.txt`)
+- **NO timestamps** beyond the date (no `_155616_SESSION_`)
+- **NO _SESSION_ prefix**
+- Keep description to 3-6 words, max 60 characters for the slug
+- Slug is generated: `title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").substring(0, 60)`
+
+Good: `2026-02-10-tekton-pr-review.md`
+Bad: `2026-02-10_155616_SESSION_tekton-pr-review.md`
+
## Directory Structure
```
-~/.config/claude/history/
+~/.local/share/ai/
├── sessions/ # Work session summaries
│ └── YYYY-MM/
-│ └── YYYY-MM-DD-description.md
+│ ├── YYYY-MM-DD-description.md
+│ └── YYYY-MM-DD_session-log.txt
│
├── learnings/ # Problem-solving insights
│ └── YYYY-MM/
@@ -35,19 +53,16 @@ The History System captures significant work and learnings for future reference.
│ └── YYYY-MM/
│ └── YYYY-MM-DD-topic.md
│
-├── decisions/ # Architecture decisions
-│ └── YYYY-MM/
-│ └── YYYY-MM-DD-description.md
+├── plans/ # Project plans (NOT date-organized)
+│ └── plan-name.md
│
-├── execution/ # Command/feature execution logs
-│ └── YYYY-MM/
-│ └── YYYY-MM-DD-description.md
-│
-└── tool-outputs/ # Tool execution logs (auto-generated)
- └── YYYY-MM/
- └── YYYY-MM-DD_tool-outputs.jsonl
+└── .pending/ # Auto-saved transcripts (temporary)
+ └── *.json
```
+Note: Tool-specific data (like tool-outputs JSONL) stays under tool config:
+- Claude: `~/.config/claude/history/tool-outputs/`
+
**Quick Decision Guide:**
- "What happened this session?" → `sessions/`
- "What did we learn?" → `learnings/`
@@ -157,25 +172,22 @@ Relevant commands or code snippets.
### By Category
```bash
# Find all sessions
-ls ~/.config/claude/history/sessions/*/*.md
+ls ~/.local/share/ai/sessions/*/*.md
# Find all learnings
-ls ~/.config/claude/history/learnings/*/*.md
+ls ~/.local/share/ai/learnings/*/*.md
# Find recent research
-ls -lt ~/.config/claude/history/research/
+ls -lt ~/.local/share/ai/research/
-# Find all decisions
-ls ~/.config/claude/history/decisions/*/*.md
-
-# Find all executions
-ls ~/.config/claude/history/execution/*/*.md
+# Find all plans
+ls ~/.local/share/ai/plans/*.md
```
### By Content
```bash
# Search all history
-rg "keyword" ~/.config/claude/history/
+rg "keyword" ~/.local/share/ai/
```
## Quick Reference
@@ -183,15 +195,15 @@ rg "keyword" ~/.config/claude/history/
### Create Session Entry
```bash
DATE=$(date +"%Y-%m")
-mkdir -p ~/.config/claude/history/sessions/$DATE
-vim ~/.config/claude/history/sessions/$DATE/$(date +"%Y-%m-%d")-description.md
+mkdir -p ~/.local/share/ai/sessions/$DATE
+vim ~/.local/share/ai/sessions/$DATE/$(date +"%Y-%m-%d")-description.md
```
### Create Learning Entry
```bash
DATE=$(date +"%Y-%m")
-mkdir -p ~/.config/claude/history/learnings/$DATE
-vim ~/.config/claude/history/learnings/$DATE/$(date +"%Y-%m-%d")-description.md
+mkdir -p ~/.local/share/ai/learnings/$DATE
+vim ~/.local/share/ai/learnings/$DATE/$(date +"%Y-%m-%d")-description.md
```
**Remember:** When in doubt, save to history! It's better to capture too much than to lose valuable insights.
dots/config/claude/skills/CORE/hook-system.md
@@ -2,20 +2,22 @@
**Event-Driven Automation Infrastructure**
-**Location:** `/home/vincent/src/home/tools/claude-hooks/`
-**Configuration:** `/home/vincent/.config/claude/settings.json`
-**Status:** Active - Go-based implementation
+**Location:** `~/.config/claude/hooks/` (source: `dots/config/claude/hooks/`)
+**Configuration:** `~/.config/claude/settings.json`
+**Status:** Active - TypeScript/Bun implementation
---
## Overview
-The hook system is an event-driven automation infrastructure built on Claude Code's native hook support. Hooks are executable commands (Go binaries) that run automatically in response to specific events during Claude Code sessions.
+The hook system is an event-driven automation infrastructure built on Claude Code's native hook support. Hooks are TypeScript scripts (run via `bun`) that execute automatically in response to specific events during Claude Code sessions.
**Core Capabilities:**
+- **Unified AI Storage** - All sessions, logs write to `~/.local/share/ai/` (shared with pi, opencode)
- **Session Management** - Set terminal titles, log session starts
- **Tool Output Capture** - Automatic logging of tool executions to JSONL files
- **Session Prompts** - Prompt to save session summaries on exit
+- **Git Safety** - Block dangerous git operations (push without refspec, add -A)
- **Subagent Detection** - Skip hooks for subagent sessions
**Key Principle:** Hooks run asynchronously and fail gracefully. They enhance the user experience but never block Claude Code's core functionality.
@@ -25,24 +27,19 @@ The hook system is an event-driven automation infrastructure built on Claude Cod
## Architecture
```
-/home/vincent/src/home/tools/claude-hooks/
-├── cmd/
-│ ├── initialize-session/main.go # SessionStart hook
-│ ├── capture-tool-output/main.go # PostToolUse hook
-│ ├── save-session/main.go # SessionEnd hook
-│ └── validate-docs/main.go # Documentation validator (not used as hook)
-├── internal/
-│ └── paths/paths.go # Shared path utilities
-├── default.nix # Nix package definition
-├── go.mod # Go module definition
-├── go.sum # Go dependencies
-├── setup-hooks.sh # Configuration script
-└── README.md # Documentation
+dots/config/claude/hooks/ # Source (in homelab repo)
+ → ~/.config/claude/hooks/ # Symlinked at runtime
+├── lib.ts # Shared utilities (paths, dates, slugs)
+├── initialize-session.ts # SessionStart hook
+├── save-session.ts # SessionEnd hook
+├── capture-tool-output.ts # PostToolUse hook
+├── update-terminal-title.ts # PostToolUse hook
+├── validate-git-push.ts # PreToolUse (Bash) hook
+└── package.json # Dependencies
```
-**Implementation:** Go binaries compiled to native code for zero runtime dependencies, faster execution, and cross-platform compatibility.
-
-**Migration:** Migrated from TypeScript/Bun implementation previously in `dots/.claude/hooks/`.
+**Implementation:** TypeScript scripts executed via `bun run`. Shared utilities in `lib.ts`.
+**Storage:** Sessions and logs go to `~/.local/share/ai/` (unified with pi ai-storage extension).
---
@@ -52,7 +49,7 @@ You currently have 3 hooks configured. Claude Code supports 8 hook event types t
### 1. **SessionStart**
**When:** Claude Code session begins (new conversation)
-**Command:** `claude-hooks-initialize-session`
+**Command:** `bun run ~/.config/claude/hooks/initialize-session.ts`
**Current Configuration:**
```json
@@ -62,7 +59,7 @@ You currently have 3 hooks configured. Claude Code supports 8 hook event types t
"hooks": [
{
"type": "command",
- "command": "claude-hooks-initialize-session"
+ "command": "bun run ~/.config/claude/hooks/initialize-session.ts"
}
]
}
@@ -73,7 +70,7 @@ You currently have 3 hooks configured. Claude Code supports 8 hook event types t
**What It Does:**
- **Detects subagent sessions**: Skips if `CLAUDE_AGENT_TYPE` is set or path contains `/.claude/agents/`
- **Sets terminal tab title**: Sets tab title to "Claude Ready" using ANSI escape codes
-- **Logs session start**: Appends to `~/.config/claude/history/sessions/YYYY-MM/YYYY-MM-DD_session-log.txt`
+- **Logs session start**: Appends to `~/.local/share/ai/sessions/YYYY-MM/YYYY-MM-DD_session-log.txt`
- **Implements debouncing**: 2-second window to prevent duplicate triggers
**Implementation Details:**
@@ -119,7 +116,7 @@ Uses a lockfile in `/tmp/claude-session-start.lock` with timestamps to prevent d
### 2. **PostToolUse**
**When:** After Claude executes any tool
-**Command:** `claude-hooks-capture-tool-output`
+**Command:** `bun run ~/.config/claude/hooks/capture-tool-output.ts`
**Current Configuration:**
```json
@@ -129,7 +126,7 @@ Uses a lockfile in `/tmp/claude-session-start.lock` with timestamps to prevent d
"hooks": [
{
"type": "command",
- "command": "claude-hooks-capture-tool-output"
+ "command": "bun run ~/.config/claude/hooks/capture-tool-output.ts"
}
]
}
@@ -199,7 +196,7 @@ Claude Code sends JSON data on stdin:
### 3. **SessionEnd**
**When:** Claude Code session terminates (conversation ends)
-**Command:** `claude-hooks-save-session`
+**Command:** `bun run ~/.config/claude/hooks/save-session.ts`
**Current Configuration:**
```json
@@ -209,7 +206,7 @@ Claude Code sends JSON data on stdin:
"hooks": [
{
"type": "command",
- "command": "claude-hooks-save-session"
+ "command": "bun run ~/.config/claude/hooks/save-session.ts"
}
]
}
@@ -221,7 +218,7 @@ Claude Code sends JSON data on stdin:
- **Prompts user to save session**: Displays message asking if session should be saved
- **Skips subagent sessions**: Silent exit for subagent sessions
- **Works with `/save-session` command**: Integrates with session-manager plugin
-- **Documents session**: Creates summary in `~/.config/claude/history/sessions/YYYY-MM/YYYY-MM-DD-HHMMSS_SESSION_description.md`
+- **Documents session**: Creates summary in `~/.local/share/ai/sessions/YYYY-MM/YYYY-MM-DD-HHMMSS_SESSION_description.md`
**Prompt Output:**
```
@@ -229,7 +226,7 @@ Claude Code sends JSON data on stdin:
**Session ending**. Would you like me to save a summary of this session to your history?
-I can create a session entry in `~/.config/claude/history/sessions/` documenting:
+I can create a session entry in `~/.local/share/ai/sessions/` documenting:
- What was accomplished
- Decisions made
- Next steps
@@ -412,7 +409,7 @@ YYYY-MM-DD-HHMMSS_TYPE_description.md
**Directory Structure:**
```
-~/.config/claude/history/
+~/.local/share/ai/
├── sessions/
│ └── 2024-01/
│ ├── 2024-01-15_session-log.txt
@@ -527,10 +524,10 @@ func shouldDebounce() bool {
cd /home/vincent/src/home
# Build all hooks
-nix build .#claude-hooks
+# Old Nix build (removed)
# Install to user profile
-nix profile install .#claude-hooks
+# No longer needed - hooks are TypeScript
```
### Adding to Home Manager
@@ -548,9 +545,9 @@ home.packages = with pkgs; [
cd /home/vincent/src/home/tools/claude-hooks
# Build all hooks
-go build -o bin/claude-hooks-initialize-session ./cmd/initialize-session
-go build -o bin/claude-hooks-capture-tool-output ./cmd/capture-tool-output
-go build -o bin/claude-hooks-save-session ./cmd/save-session
+# Old Go build: bun run ~/.config/claude/hooks/initialize-session.ts ./cmd/initialize-session
+# Old Go build: bun run ~/.config/claude/hooks/capture-tool-output.ts ./cmd/capture-tool-output
+# Old Go build: bun run ~/.config/claude/hooks/save-session.ts ./cmd/save-session
# Copy to PATH
sudo cp bin/* /usr/local/bin/
@@ -563,16 +560,16 @@ sudo cp bin/* /usr/local/bin/
```bash
# Check binaries are in PATH
-which claude-hooks-initialize-session
-which claude-hooks-capture-tool-output
-which claude-hooks-save-session
+which bun run ~/.config/claude/hooks/initialize-session.ts
+which bun run ~/.config/claude/hooks/capture-tool-output.ts
+which bun run ~/.config/claude/hooks/save-session.ts
# Test hooks manually
-claude-hooks-initialize-session
+bun run ~/.config/claude/hooks/initialize-session.ts
# Test with input
echo '{"tool_name":"Bash","tool_input":{},"tool_response":{},"conversation_id":"test"}' | \
- claude-hooks-capture-tool-output
+ bun run ~/.config/claude/hooks/capture-tool-output.ts
```
### Configuration
@@ -587,7 +584,7 @@ Edit `/home/vincent/.config/claude/settings.json` to enable hooks (should alread
"hooks": [
{
"type": "command",
- "command": "claude-hooks-initialize-session"
+ "command": "bun run ~/.config/claude/hooks/initialize-session.ts"
}
]
}
@@ -597,7 +594,7 @@ Edit `/home/vincent/.config/claude/settings.json` to enable hooks (should alread
"hooks": [
{
"type": "command",
- "command": "claude-hooks-capture-tool-output"
+ "command": "bun run ~/.config/claude/hooks/capture-tool-output.ts"
}
]
}
@@ -607,7 +604,7 @@ Edit `/home/vincent/.config/claude/settings.json` to enable hooks (should alread
"hooks": [
{
"type": "command",
- "command": "claude-hooks-save-session"
+ "command": "bun run ~/.config/claude/hooks/save-session.ts"
}
]
}
@@ -627,9 +624,9 @@ Decide which event should trigger your hook (SessionStart, PostToolUse, etc.)
### Step 2: Create Hook Command
-**Option A: Go Implementation** (recommended)
+**Option A: TypeScript Implementation** (recommended)
-Create new command in `/home/vincent/src/home/tools/claude-hooks/cmd/`:
+Create new command in `~/.config/claude/hooks/ (source: dots/config/claude/hooks/)cmd/`:
```go
package main
@@ -685,7 +682,7 @@ exit 0 # Always exit 0
```bash
cd /home/vincent/src/home/tools/claude-hooks
-go build -o bin/my-custom-hook ./cmd/my-custom-hook
+# Old Go build: my-custom-hook ./cmd/my-custom-hook
sudo cp bin/my-custom-hook /usr/local/bin/
```
@@ -781,7 +778,7 @@ f, _ := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
### Hook Not Running
**Check:**
-1. Is hook binary in PATH? `which claude-hooks-initialize-session`
+1. Is hook binary in PATH? `which bun run ~/.config/claude/hooks/initialize-session.ts`
2. Is path correct in settings.json? Use exact command name
3. Is settings.json valid JSON? `jq . ~/.config/claude/settings.json`
4. Did you restart Claude Code after editing settings.json?
@@ -789,10 +786,10 @@ f, _ := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
**Debug:**
```bash
# Test hook directly
-claude-hooks-initialize-session
+bun run ~/.config/claude/hooks/initialize-session.ts
# Test with input
-echo '{"conversation_id":"test"}' | claude-hooks-capture-tool-output
+echo '{"conversation_id":"test"}' | bun run ~/.config/claude/hooks/capture-tool-output.ts
# Check stderr output in terminal (hooks log there)
```
@@ -831,10 +828,10 @@ mkdir -p ~/.config/claude/history/sessions
mkdir -p ~/.config/claude/history/tool-outputs
# Check write permissions
-ls -la ~/.config/claude/history/
+ls -la ~/.local/share/ai/
# Check binary permissions
-ls -la $(which claude-hooks-initialize-session)
+ls -la $(which bun run ~/.config/claude/hooks/initialize-session.ts)
```
---
@@ -849,7 +846,7 @@ ls -la $(which claude-hooks-initialize-session)
**Debug:**
```bash
# Check recent logs
-ls -lt ~/.config/claude/history/sessions/$(date +%Y-%m)/ | head -10
+ls -lt ~/.local/share/ai/sessions/$(date +%Y-%m)/ | head -10
ls -lt ~/.config/claude/history/tool-outputs/$(date +%Y-%m)/ | head -10
# Check if tool-outputs JSONL has entries
@@ -876,14 +873,14 @@ go build ./cmd/initialize-session
**Nix Build:**
```bash
cd /home/vincent/src/home
-nix build .#claude-hooks
+# Old Nix build (removed)
# If vendorHash error, update in default.nix
```
---
-## Advantages of Go Implementation
+## Advantages of TypeScript Implementation
1. **Zero runtime dependencies** - Compiled to native binary, no Bun/Node.js required
2. **Faster execution** - Native code vs interpreted JavaScript
@@ -892,14 +889,14 @@ nix build .#claude-hooks
5. **Nix integration** - Proper package management and reproducible builds
6. **Type safety** - Compile-time checks without runtime overhead
7. **Standard library** - Excellent built-in support for JSON, file I/O, paths
-8. **Smaller memory footprint** - Go binaries use less memory than Node.js processes
+8. **Smaller memory footprint** - TypeScript hooks use less memory than Node.js processes
---
## Related Documentation
-- **Hook Source Code:** `/home/vincent/src/home/tools/claude-hooks/`
-- **Nix Package:** `/home/vincent/src/home/tools/claude-hooks/default.nix`
+- **Hook Source Code:** `~/.config/claude/hooks/ (source: dots/config/claude/hooks/)`
+- **Nix Package:** `~/.config/claude/hooks/ (source: dots/config/claude/hooks/)default.nix`
- **Configuration:** `/home/vincent/.config/claude/settings.json`
- **History Directory:** `~/.config/claude/history/`
@@ -918,28 +915,28 @@ HOOK LIFECYCLE:
KEY FILES:
/home/vincent/.config/claude/settings.json Hook configuration
-/home/vincent/src/home/tools/claude-hooks/ Hook source code
-~/.config/claude/history/sessions/ Session logs
+~/.config/claude/hooks/ (source: dots/config/claude/hooks/) Hook source code
+~/.local/share/ai/sessions/ Session logs
~/.config/claude/history/tool-outputs/ Tool output JSONL logs
CONFIGURED HOOKS:
-claude-hooks-initialize-session SessionStart - Set title, log session
-claude-hooks-capture-tool-output PostToolUse - Log tool executions
-claude-hooks-save-session SessionEnd - Prompt to save summary
+bun run ~/.config/claude/hooks/initialize-session.ts SessionStart - Set title, log session
+bun run ~/.config/claude/hooks/capture-tool-output.ts PostToolUse - Log tool executions
+bun run ~/.config/claude/hooks/save-session.ts SessionEnd - Prompt to save summary
VERIFY HOOKS:
-which claude-hooks-initialize-session
+which bun run ~/.config/claude/hooks/initialize-session.ts
jq '.hooks' ~/.config/claude/settings.json
ls -la ~/.config/claude/history/tool-outputs/
DEBUGGING:
# Test hooks manually
-claude-hooks-initialize-session
-echo '{"tool_name":"Bash","tool_input":{}}' | claude-hooks-capture-tool-output
+bun run ~/.config/claude/hooks/initialize-session.ts
+echo '{"tool_name":"Bash","tool_input":{}}' | bun run ~/.config/claude/hooks/capture-tool-output.ts
# Check logs
tail ~/.config/claude/history/tool-outputs/$(date +%Y-%m)/$(date +%Y-%m-%d)_tool-outputs.jsonl
-cat ~/.config/claude/history/sessions/$(date +%Y-%m)/$(date +%Y-%m-%d)_session-log.txt
+cat ~/.local/share/ai/sessions/$(date +%Y-%m)/$(date +%Y-%m-%d)_session-log.txt
```
---
dots/config/claude/skills/CORE/SKILL.md
@@ -102,7 +102,7 @@ Explicit permission to:
## File Organization
- **Scratchpad** (`~/.config/claude/scratchpad/`) - Temporary files only. Delete when done.
-- **History** (`~/.config/claude/history/`) - Permanent valuable outputs.
+- **AI Storage** (`~/.local/share/ai/`) - Unified storage for sessions, research, plans, learnings (shared across all AI tools).
- **Backups** (`~/.config/claude/history/backups/`) - All backups go here, NEVER inside skill directories.
**Rules:**
@@ -157,24 +157,41 @@ You have EXPLICIT PERMISSION to say "I don't know" or "I'm not confident" when:
**The Permission:** You will NEVER be penalized for honestly saying you don't know. Fabricating an answer is far worse than admitting uncertainty.
-## History System Quick Reference
+## Unified AI Storage Quick Reference
-**CRITICAL: When the user asks about ANYTHING done in the past, CHECK THE HISTORY SYSTEM FIRST.**
+**CRITICAL: When the user asks about ANYTHING done in the past, CHECK THE AI STORAGE FIRST.**
+
+**Location:** `~/.local/share/ai/` (shared across claude, pi, opencode)
```bash
# Quick keyword search
-rg -i "keyword" ~/.config/claude/history/
+rg -i "keyword" ~/.local/share/ai/
# List recent sessions
-ls -lt ~/.config/claude/history/sessions/2025-11/ | head -20
+ls -lt ~/.local/share/ai/sessions/$(date +%Y-%m)/ | head -20
+
+# List yesterday's sessions
+ls ~/.local/share/ai/sessions/$(date +%Y-%m)/$(date -d yesterday +%Y-%m-%d)-*.md
+
+# List research
+ls -lt ~/.local/share/ai/research/$(date +%Y-%m)/
+
+# List plans
+ls ~/.local/share/ai/plans/
```
**Directory Reference:**
-- `history/sessions/YYYY-MM/` - Session summaries
-- `history/learnings/YYYY-MM/` - Problem-solving narratives
-- `history/research/YYYY-MM/` - Research & investigations
+- `sessions/YYYY-MM/YYYY-MM-DD-description.md` - Session summaries
+- `learnings/YYYY-MM/YYYY-MM-DD-description.md` - Problem-solving insights
+- `research/YYYY-MM/YYYY-MM-DD-description.md` - Research & investigations
+- `plans/description.md` - Project plans (no date prefix)
+- `sessions/YYYY-MM/YYYY-MM-DD_session-log.txt` - Daily session activity log
-**For complete history system documentation:** See `history-system.md`
+**Filename format:** `YYYY-MM-DD-description-with-dashes.md` (lowercase, hyphens only, no underscores)
+
+**NEVER write to:** `~/.config/claude/history/sessions/` (old location, blocked by hooks)
+
+**For complete documentation:** See `history-system.md`
---
@@ -234,8 +251,8 @@ For full skill list, see available skills in `~/.config/claude/skills/`
**Example 2: User asks about past work**
```
User: "What did we do with Tekton last week?"
-→ CORE reminds to check history system first
-→ Searches ~/.config/claude/history/sessions/
+→ CORE reminds to check AI storage first
+→ Searches ~/.local/share/ai/sessions/
→ Returns summary of work accomplished
```
dots/config/claude/README.org
@@ -84,13 +84,13 @@
** Hooks (hooks/)
-Event-driven automation implemented in Go (see =~/src/home/tools/claude-hooks=):
-- *claude-hooks-initialize-session* - SessionStart: Sets terminal title, logs session start
-- *claude-hooks-capture-tool-output* - PostToolUse: Logs tool executions to JSONL
-- *claude-hooks-save-session* - SessionEnd: Prompts to save session summary
-- *claude-hooks-validate-docs* - Manual/Pre-commit: Validates documentation links
+Event-driven automation in TypeScript (=~/.config/claude/hooks/=):
+- =initialize-session.ts= - SessionStart: Sets terminal title, logs session start
+- =capture-tool-output.ts= - PostToolUse: Logs tool executions to JSONL
+- =save-session.ts= - SessionEnd: Prompts to save session summary
+- =validate-write-path.ts= - PreToolUse: Validates file paths and filenames
-See =tools/claude-hooks/README.md= for detailed documentation.
+See =dots/config/claude/hooks/= for source code.
** Plugins (plugins/)
@@ -518,9 +518,9 @@
cat ~/.config/claude/settings.json | grep -A 10 hooks
# Verify hook binaries are in PATH
-which claude-hooks-initialize-session
-which claude-hooks-capture-tool-output
-which claude-hooks-save-session
+ls ~/.config/claude/hooks/initialize-session.ts
+ls ~/.config/claude/hooks/capture-tool-output.ts
+ls ~/.config/claude/hooks/save-session.ts
#+end_src
** History not syncing?
@@ -536,7 +536,7 @@
- *Agents*: See individual agent =.md= files in =agents/=
- *Skills*: See each skill's =SKILL.md= in =skills/=
-- *Hooks*: See =~/src/home/tools/claude-hooks/README.md=
+- *Hooks*: See =~/.config/claude/hooks/= (TypeScript, run via bun)
- *History System*: See =skills/CORE/history-system.md=
- *Repository Instructions*: See =~/src/home/CLAUDE.md=
- *PAI Implementation Plan*: See =~/desktop/org/notes/20251203T151822--personal-ai-infrastructure-implementation-plan__ai_claude_infrastructure_nixos_plan.org=
dots/config/claude/settings.json
@@ -7,7 +7,16 @@
"hooks": [
{
"type": "command",
- "command": "claude-hooks-validate-git-push"
+ "command": "bun run ~/.config/claude/hooks/validate-git-push.ts"
+ }
+ ]
+ },
+ {
+ "matcher": "Write",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bun run ~/.config/claude/hooks/validate-write-path.ts"
}
]
}
@@ -17,7 +26,7 @@
"hooks": [
{
"type": "command",
- "command": "claude-hooks-initialize-session"
+ "command": "bun run ~/.config/claude/hooks/initialize-session.ts"
}
]
}
@@ -27,11 +36,11 @@
"hooks": [
{
"type": "command",
- "command": "claude-hooks-capture-tool-output"
+ "command": "bun run ~/.config/claude/hooks/capture-tool-output.ts"
},
{
"type": "command",
- "command": "claude-hooks-update-terminal-title"
+ "command": "bun run ~/.config/claude/hooks/update-terminal-title.ts"
}
]
}
@@ -41,7 +50,7 @@
"hooks": [
{
"type": "command",
- "command": "/etc/profiles/per-user/vincent/bin/claude-hooks-save-session"
+ "command": "bun run ~/.config/claude/hooks/save-session.ts"
}
]
}
dots/config/copilot-hooks/claude-hooks.json
@@ -4,33 +4,38 @@
"sessionStart": [
{
"type": "command",
- "bash": "claude-hooks-initialize-session",
+ "bash": "bun run ~/.config/claude/hooks/initialize-session.ts",
"timeoutSec": 5
}
],
"sessionEnd": [
{
"type": "command",
- "bash": "claude-hooks-save-session",
+ "bash": "bun run ~/.config/claude/hooks/save-session.ts",
"timeoutSec": 10
}
],
"preToolUse": [
{
"type": "command",
- "bash": ".github/hooks/copilot-to-claude.sh claude-hooks-validate-git-push",
+ "bash": "bun run ~/.config/claude/hooks/validate-git-push.ts",
+ "timeoutSec": 5
+ },
+ {
+ "type": "command",
+ "bash": "bun run ~/.config/claude/hooks/validate-write-path.ts",
"timeoutSec": 5
}
],
"postToolUse": [
{
"type": "command",
- "bash": ".github/hooks/copilot-to-claude.sh claude-hooks-capture-tool-output",
+ "bash": "bun run ~/.config/claude/hooks/capture-tool-output.ts",
"timeoutSec": 5
},
{
"type": "command",
- "bash": ".github/hooks/copilot-to-claude.sh claude-hooks-update-terminal-title",
+ "bash": "bun run ~/.config/claude/hooks/update-terminal-title.ts",
"timeoutSec": 2
}
]
dots/config/opencode/plugin/claude-hooks.ts
@@ -1,14 +1,14 @@
/**
- * OpenCode plugin that wraps Claude Code hooks (Go binaries)
+ * OpenCode plugin that wraps Claude Code hooks (TypeScript hooks)
*
- * This plugin provides compatibility with existing claude-hooks-* binaries
+ * This plugin provides compatibility with existing TypeScript hooks in ~/.config/claude/hooks/
* by translating OpenCode's event format to Claude Code's JSON format.
*
* Hooks ported:
- * - tool.execute.before -> claude-hooks-validate-git-push
- * - tool.execute.after -> claude-hooks-capture-tool-output, claude-hooks-update-terminal-title
- * - session.created -> claude-hooks-initialize-session
- * - session.idle -> claude-hooks-save-session (approximation of SessionEnd)
+ * - tool.execute.before -> bun run ~/.config/claude/hooks/validate-git-push.ts
+ * - tool.execute.after -> bun run ~/.config/claude/hooks/capture-tool-output.ts, bun run ~/.config/claude/hooks/update-terminal-title.ts
+ * - session.created -> bun run ~/.config/claude/hooks/initialize-session.ts
+ * - session.idle -> bun run ~/.config/claude/hooks/save-session.ts (approximation of SessionEnd)
*/
import type { Plugin } from "@opencode-ai/plugin"
@@ -58,12 +58,12 @@ export const ClaudeHooksPlugin: Plugin = async ({ $ }) => {
try {
const jsonInput = toClaudePreToolUse(input)
- const result = await $`echo ${jsonInput} | claude-hooks-validate-git-push`.quiet()
+ const result = await $`echo ${jsonInput} | bun run ~/.config/claude/hooks/validate-git-push.ts`.quiet()
if (result.exitCode !== 0) {
// Block the command
const stderr = result.stderr.toString().trim()
- output.abort = stderr || "Blocked by claude-hooks-validate-git-push"
+ output.abort = stderr || "Blocked by bun run ~/.config/claude/hooks/validate-git-push.ts"
}
} catch (error) {
// Don't block on hook errors - fail open
@@ -80,8 +80,8 @@ export const ClaudeHooksPlugin: Plugin = async ({ $ }) => {
// Run both hooks in parallel (fire and forget)
await Promise.allSettled([
- $`echo ${jsonInput} | claude-hooks-capture-tool-output`.quiet(),
- $`echo ${jsonInput} | claude-hooks-update-terminal-title`.quiet(),
+ $`echo ${jsonInput} | bun run ~/.config/claude/hooks/capture-tool-output.ts`.quiet(),
+ $`echo ${jsonInput} | bun run ~/.config/claude/hooks/update-terminal-title.ts`.quiet(),
])
} catch (error) {
// Silent failure - don't disrupt workflow
@@ -97,7 +97,7 @@ export const ClaudeHooksPlugin: Plugin = async ({ $ }) => {
if (event.type === "session.created" && !sessionInitialized) {
sessionInitialized = true
try {
- await $`claude-hooks-initialize-session`.quiet()
+ await $`bun run ~/.config/claude/hooks/initialize-session.ts`.quiet()
} catch (error) {
console.error("[claude-hooks] initialize-session error:", error)
}
@@ -108,7 +108,7 @@ export const ClaudeHooksPlugin: Plugin = async ({ $ }) => {
// Uncomment if you want save-session prompts on idle:
// if (event.type === "session.idle") {
// try {
- // await $`claude-hooks-save-session`.quiet()
+ // await $`bun run ~/.config/claude/hooks/save-session.ts`.quiet()
// } catch (error) {
// console.error("[claude-hooks] save-session error:", error)
// }
dots/Makefile
@@ -29,9 +29,10 @@ nvim : ~/.config/nvim
all += claude-skills
claude-skills : ~/.config/claude/skills
-all += claude-agents claude-settings claude-plugins claude-statusline claude-compat
+all += claude-agents claude-settings claude-hooks claude-plugins claude-statusline claude-compat
claude-agents : ~/.config/claude/agents
claude-settings : ~/.config/claude/settings.json
+claude-hooks : ~/.config/claude/hooks
claude-plugins : ~/.config/claude/plugins/session-manager
claude-statusline : ~/.config/claude/statusline.sh
claude-compat : ~/.claude
pkgs/default.nix
@@ -17,7 +17,6 @@ in
vrsync = pkgs.callPackage ./my/vrsync { };
vde-thinkpad = pkgs.callPackage ./my/vde-thinkpad { };
battery-monitor = pkgs.callPackage ../tools/battery-monitor { };
- claude-hooks = pkgs.callPackage ../tools/claude-hooks { };
ape = pkgs.callPackage ./ape { };
ram = pkgs.callPackage ./ram { };
govanityurl = pkgs.callPackage ./govanityurl { };
systems/aomi/home.nix
@@ -23,7 +23,6 @@
go-org-readwise
gh-pr
lazypr
- claude-hooks
nixpkgs-pr-watch
ssh-to-age
systems/kyushu/home.nix
@@ -77,7 +77,6 @@ in
lazypr
nixpkgs-pr-watch
arr
- claude-hooks
toggle-color-scheme
shpool-remote
cliphist-cleanup
tools/claude-hooks/cmd/capture-tool-output/main.go
@@ -1,123 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "time"
-
- "github.com/vdemeester/home/tools/claude-hooks/internal/paths"
-)
-
-// ToolUseData represents the input from PostToolUse hook
-type ToolUseData struct {
- ToolName string `json:"tool_name"`
- ToolInput map[string]interface{} `json:"tool_input"`
- ToolResponse map[string]interface{} `json:"tool_response"`
- ConversationID string `json:"conversation_id"`
- Timestamp string `json:"timestamp"`
-}
-
-// CaptureEntry represents a log entry in JSONL format
-type CaptureEntry struct {
- Timestamp string `json:"timestamp"`
- Tool string `json:"tool"`
- Input map[string]interface{} `json:"input"`
- Output map[string]interface{} `json:"output"`
- Session string `json:"session"`
-}
-
-// List of tools to capture
-var interestingTools = map[string]bool{
- "Bash": true,
- "Edit": true,
- "Write": true,
- "Read": true,
- "Task": true,
- "NotebookEdit": true,
- "Skill": true,
- "SlashCommand": true,
-}
-
-func main() {
- // Read input from stdin
- input, err := io.ReadAll(os.Stdin)
- if err != nil {
- fmt.Fprintf(os.Stderr, "[capture-tool-output] Error reading stdin: %v\n", err)
- os.Exit(0) // Silent failure - don't disrupt workflow
- }
-
- if len(input) == 0 {
- os.Exit(0)
- }
-
- var data ToolUseData
- if err := json.Unmarshal(input, &data); err != nil {
- fmt.Fprintf(os.Stderr, "[capture-tool-output] Error parsing JSON: %v\n", err)
- os.Exit(0) // Silent failure
- }
-
- // Only capture interesting tools
- if !interestingTools[data.ToolName] {
- os.Exit(0)
- }
-
- // Get today's date for organization
- now := time.Now()
- today := now.Format("2006-01-02")
- yearMonth := now.Format("2006-01")
-
- // Ensure capture directory exists
- dateDir := filepath.Join(paths.HistoryDir(), "tool-outputs", yearMonth)
- if err := os.MkdirAll(dateDir, 0755); err != nil {
- fmt.Fprintf(os.Stderr, "[capture-tool-output] Error creating directory: %v\n", err)
- os.Exit(0) // Silent failure
- }
-
- // Format output as JSONL
- captureFile := filepath.Join(dateDir, fmt.Sprintf("%s_tool-outputs.jsonl", today))
-
- timestamp := data.Timestamp
- if timestamp == "" {
- timestamp = now.Format(time.RFC3339)
- }
-
- entry := CaptureEntry{
- Timestamp: timestamp,
- Tool: data.ToolName,
- Input: data.ToolInput,
- Output: data.ToolResponse,
- Session: data.ConversationID,
- }
-
- jsonData, err := json.Marshal(entry)
- if err != nil {
- fmt.Fprintf(os.Stderr, "[capture-tool-output] Error marshaling JSON: %v\n", err)
- os.Exit(0) // Silent failure
- }
-
- // Append to daily log
- f, err := os.OpenFile(captureFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
- if err != nil {
- fmt.Fprintf(os.Stderr, "[capture-tool-output] Error opening file: %v\n", err)
- os.Exit(0) // Silent failure
- }
- defer f.Close()
-
- if _, err := f.WriteString(string(jsonData) + "\n"); err != nil {
- fmt.Fprintf(os.Stderr, "[capture-tool-output] Error writing to file: %v\n", err)
- os.Exit(0) // Silent failure
- }
-
- // Desktop notifications disabled - they were too noisy
- // notifyMsg := fmt.Sprintf("Tool executed: %s", data.ToolName)
- // cmd := exec.Command("notify-send", "-u", "low", "Claude Hook", notifyMsg)
- // if err := cmd.Run(); err != nil {
- // // Silent failure - don't break workflow
- // fmt.Fprintf(os.Stderr, "[capture-tool-output] Warning: Could not send notification: %v\n", err)
- // }
-
- os.Exit(0)
-}
tools/claude-hooks/cmd/initialize-session/main.go
@@ -1,174 +0,0 @@
-package main
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "strings"
- "time"
-
- "github.com/vdemeester/home/tools/claude-hooks/internal/paths"
-)
-
-const (
- debounceDuration = 2 * time.Second
-)
-
-func getLockfile() string {
- return filepath.Join(os.TempDir(), "claude-session-start.lock")
-}
-
-// shouldDebounce checks if we're within the debounce window
-func shouldDebounce() bool {
- lockfile := getLockfile()
-
- data, err := os.ReadFile(lockfile)
- if err == nil {
- lockTime, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
- if err == nil {
- now := time.Now().UnixMilli()
- if now-lockTime < debounceDuration.Milliseconds() {
- return true
- }
- }
- }
-
- // Update lockfile with current timestamp
- now := time.Now().UnixMilli()
- if err := os.WriteFile(lockfile, []byte(fmt.Sprintf("%d", now)), 0644); err != nil {
- // Ignore write errors
- }
-
- return false
-}
-
-// isSubagentSession checks if this is a subagent session
-func isSubagentSession() bool {
- claudeProjectDir := os.Getenv("CLAUDE_PROJECT_DIR")
- if strings.Contains(claudeProjectDir, "/.claude/agents/") {
- return true
- }
- if os.Getenv("CLAUDE_AGENT_TYPE") != "" {
- return true
- }
- return false
-}
-
-// setTerminalTitle sets the terminal tab title using ANSI escape codes
-func setTerminalTitle(title string) {
- fmt.Fprintf(os.Stderr, "\x1b]0;%s\x07", title)
- fmt.Fprintf(os.Stderr, "\x1b]2;%s\x07", title)
- fmt.Fprintf(os.Stderr, "\x1b]30;%s\x07", title)
-}
-
-// logSessionStart logs the session start to history
-func logSessionStart() error {
- timestamp := paths.GetTimestamp()
- yearMonth := timestamp[:7] // YYYY-MM
-
- logDir := filepath.Join(paths.HistoryDir(), "sessions", yearMonth)
- if err := os.MkdirAll(logDir, 0755); err != nil {
- return err
- }
-
- logEntry := fmt.Sprintf("%s - Session started\n", time.Now().Format(time.RFC3339))
- logFile := filepath.Join(logDir, fmt.Sprintf("%s_session-log.txt", timestamp[:10]))
-
- f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
- if err != nil {
- return err
- }
- defer f.Close()
-
- _, err = f.WriteString(logEntry)
- return err
-}
-
-// loadCoreSkill outputs the CORE skill content so Claude receives it at session start
-func loadCoreSkill() error {
- homeDir, err := os.UserHomeDir()
- if err != nil {
- return err
- }
-
- coreSkillPath := filepath.Join(homeDir, ".config/claude/skills/CORE/SKILL.md")
- content, err := os.ReadFile(coreSkillPath)
- if err != nil {
- return err
- }
-
- // Output to stdout so Claude receives it
- fmt.Println("\n<!-- CORE Skill Auto-Loaded at Session Start -->")
- fmt.Println(string(content))
- fmt.Println("<!-- End CORE Skill -->")
-
- return nil
-}
-
-// isPiContext checks if we're running under pi coding agent
-func isPiContext() bool {
- // Strategy 1: Check immediate parent process
- ppid := os.Getppid()
- cmdline := fmt.Sprintf("/proc/%d/comm", ppid)
- data, err := os.ReadFile(cmdline)
- if err == nil && strings.TrimSpace(string(data)) == "pi" {
- return true
- }
-
- // Strategy 2: Check if "pi" is anywhere in the process tree
- cmd := exec.Command("sh", "-c", fmt.Sprintf("ps -o comm= -p $(ps -o ppid= -p %d) 2>/dev/null || pstree -s %d 2>/dev/null", ppid, os.Getpid()))
- output, err := cmd.Output()
- if err == nil && strings.Contains(string(output), "pi") {
- return true
- }
-
- // Strategy 3: Check PI_* environment variables (if pi sets any in future)
- if os.Getenv("PI_AGENT") != "" || os.Getenv("PI_CODING_AGENT") != "" {
- return true
- }
-
- return false
-}
-
-func main() {
- // Check if this is a subagent session
- if isSubagentSession() {
- fmt.Fprintln(os.Stderr, "🤖 Subagent session detected - skipping session initialization")
- os.Exit(0)
- }
-
- // Check debounce to prevent duplicate notifications
- if shouldDebounce() {
- fmt.Fprintln(os.Stderr, "🔇 Debouncing duplicate SessionStart event")
- os.Exit(0)
- }
-
- // Detect context and set appropriate title
- isPi := isPiContext()
- tabTitle := "Claude Ready"
- if isPi {
- tabTitle = "π Ready"
- }
-
- setTerminalTitle(tabTitle)
- fmt.Fprintf(os.Stderr, "📍 Session initialized: \"%s\"\n", tabTitle)
-
- // Load CORE skill at session start
- if err := loadCoreSkill(); err != nil {
- // Warn but don't break session start
- fmt.Fprintf(os.Stderr, "[initialize-session] Warning: Could not load CORE skill: %v\n", err)
- }
-
- // Ring terminal bell to notify user (works with kitty bell_on_tab)
- fmt.Fprint(os.Stderr, "\a")
-
- // Log session start to history (silent failure)
- if err := logSessionStart(); err != nil {
- // Don't break session start for logging issues
- fmt.Fprintf(os.Stderr, "[initialize-session] Warning: Could not log session start: %v\n", err)
- }
-
- os.Exit(0)
-}
tools/claude-hooks/cmd/save-session/main.go
@@ -1,72 +0,0 @@
-package main
-
-import (
- "fmt"
- "os"
- "os/exec"
-)
-
-// isSubagentSession checks if this is a subagent session
-func isSubagentSession() bool {
- claudeProjectDir := os.Getenv("CLAUDE_PROJECT_DIR")
- if len(claudeProjectDir) > 0 && (len(claudeProjectDir) < 2 || claudeProjectDir[len(claudeProjectDir)-2:] != "/.") {
- return true
- }
- if os.Getenv("CLAUDE_AGENT_TYPE") != "" {
- return true
- }
- return false
-}
-
-func main() {
- // Check if this is a subagent session
- if isSubagentSession() {
- // Silent exit for subagent sessions
- os.Exit(0)
- }
-
- // Ring terminal bell to notify user (works with kitty bell_on_tab)
- fmt.Fprint(os.Stderr, "\a")
-
- // Get session statistics
- statsCmd := exec.Command("claude-hooks-session-stats")
- statsCmd.Env = os.Environ()
- statsOutput, err := statsCmd.Output()
- if err != nil {
- // Continue without stats if tool fails
- fmt.Fprintf(os.Stderr, "[save-session] Warning: Could not get session stats: %v\n", err)
- }
-
- // Output directive for Claude to save the session
- // This will be shown in the conversation
- fmt.Println("")
- fmt.Println("---")
- fmt.Println("")
- fmt.Println("# Automatic Session Summary")
- fmt.Println("")
- fmt.Println("**IMPORTANT**: Please create a session summary and save it to the history directory.")
- fmt.Println("")
-
- // Show statistics if available
- if len(statsOutput) > 0 {
- fmt.Print(string(statsOutput))
- }
-
- fmt.Println("## Instructions")
- fmt.Println("")
- fmt.Println("Create a brief summary (2-4 paragraphs) of this session documenting:")
- fmt.Println("- Primary tasks accomplished")
- fmt.Println("- Key decisions or solutions")
- fmt.Println("- Files/systems modified")
- fmt.Println("- Any remaining work or next steps")
- fmt.Println("")
- fmt.Println("Save the summary using the Write tool to:")
- fmt.Println("`~/.config/claude/history/sessions/<YYYY-MM>/<YYYY-MM-DD>_<brief-slug>.md`")
- fmt.Println("")
- fmt.Println("Use the format: `YYYY-MM-DD_<2-4 word slug describing the session>.md`")
- fmt.Println("")
- fmt.Println("Example: `~/.config/claude/history/sessions/2025-12/2025-12-10_notification-filtering-arr-completion.md`")
- fmt.Println("")
-
- os.Exit(0)
-}
tools/claude-hooks/cmd/session-stats/main.go
@@ -1,231 +0,0 @@
-package main
-
-import (
- "bufio"
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-
- "github.com/vdemeester/home/tools/claude-hooks/internal/paths"
-)
-
-// CaptureEntry represents a log entry from tool-outputs JSONL
-type CaptureEntry struct {
- Timestamp string `json:"timestamp"`
- Tool string `json:"tool"`
- Input map[string]interface{} `json:"input"`
- Output map[string]interface{} `json:"output"`
- Session string `json:"session"`
-}
-
-// SessionStats holds aggregated statistics
-type SessionStats struct {
- ToolCounts map[string]int
- FilesModified []string
- FilesRead []string
- CommandsRun []string
- StartTime time.Time
- EndTime time.Time
- Duration time.Duration
-}
-
-func main() {
- conversationID := os.Getenv("CLAUDE_CONVERSATION_ID")
-
- // Get today's tool output file
- now := time.Now()
- today := now.Format("2006-01-02")
- yearMonth := now.Format("2006-01")
-
- toolOutputFile := filepath.Join(
- paths.HistoryDir(),
- "tool-outputs",
- yearMonth,
- fmt.Sprintf("%s_tool-outputs.jsonl", today),
- )
-
- stats := &SessionStats{
- ToolCounts: make(map[string]int),
- FilesModified: []string{},
- FilesRead: []string{},
- CommandsRun: []string{},
- }
-
- // Read and parse JSONL
- f, err := os.Open(toolOutputFile)
- if err != nil {
- // Silent failure - file might not exist for new sessions
- fmt.Fprintln(os.Stderr, "[session-stats] No tool output file found")
- os.Exit(0)
- }
- defer f.Close()
-
- scanner := bufio.NewScanner(f)
- // Increase buffer size for large lines
- buf := make([]byte, 0, 64*1024)
- scanner.Buffer(buf, 1024*1024)
-
- firstEntry := true
- for scanner.Scan() {
- var entry CaptureEntry
- if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
- continue // Skip malformed lines
- }
-
- // Only count entries from this session if we have a conversation ID
- if conversationID != "" && entry.Session != "" && entry.Session != conversationID {
- continue
- }
-
- stats.ToolCounts[entry.Tool]++
-
- // Track timestamps
- if t, err := time.Parse(time.RFC3339, entry.Timestamp); err == nil {
- if firstEntry {
- stats.StartTime = t
- firstEntry = false
- }
- stats.EndTime = t
- }
-
- // Extract file paths and commands based on tool type
- switch entry.Tool {
- case "Edit", "Write":
- if path, ok := entry.Input["file_path"].(string); ok {
- if !contains(stats.FilesModified, path) {
- stats.FilesModified = append(stats.FilesModified, path)
- }
- }
- case "Read":
- if path, ok := entry.Input["file_path"].(string); ok {
- if !contains(stats.FilesRead, path) {
- stats.FilesRead = append(stats.FilesRead, path)
- }
- }
- case "Bash":
- if cmd, ok := entry.Input["command"].(string); ok {
- // Only include interesting commands (not trivial ones)
- if isInterestingCommand(cmd) {
- // Truncate long commands
- if len(cmd) > 80 {
- cmd = cmd[:77] + "..."
- }
- stats.CommandsRun = append(stats.CommandsRun, cmd)
- }
- }
- }
- }
-
- if err := scanner.Err(); err != nil {
- fmt.Fprintf(os.Stderr, "[session-stats] Error reading file: %v\n", err)
- }
-
- // Calculate duration
- if !stats.StartTime.IsZero() && !stats.EndTime.IsZero() {
- stats.Duration = stats.EndTime.Sub(stats.StartTime)
- }
-
- // Output statistics
- printStats(stats)
-}
-
-func contains(slice []string, item string) bool {
- for _, s := range slice {
- if s == item {
- return true
- }
- }
- return false
-}
-
-func isInterestingCommand(cmd string) bool {
- // Skip trivial commands
- boring := []string{
- "ls ", "pwd", "echo ", "cat ", "head ", "tail ",
- "git status", "git diff", "git log",
- }
-
- cmdLower := strings.ToLower(cmd)
- for _, b := range boring {
- if strings.HasPrefix(cmdLower, b) {
- return false
- }
- }
-
- return true
-}
-
-func printStats(stats *SessionStats) {
- fmt.Println("## Session Statistics")
- fmt.Println()
-
- // Duration
- if stats.Duration > 0 {
- fmt.Printf("**Duration:** %s\n", stats.Duration.Round(time.Second))
- fmt.Println()
- }
-
- // Tool usage
- if len(stats.ToolCounts) > 0 {
- fmt.Println("**Tools used:**")
-
- // Sort tools by count
- type toolCount struct {
- tool string
- count int
- }
- var tools []toolCount
- for tool, count := range stats.ToolCounts {
- tools = append(tools, toolCount{tool, count})
- }
- sort.Slice(tools, func(i, j int) bool {
- return tools[i].count > tools[j].count
- })
-
- for _, tc := range tools {
- fmt.Printf("- %s: %d\n", tc.tool, tc.count)
- }
- fmt.Println()
- }
-
- // Files modified
- if len(stats.FilesModified) > 0 {
- fmt.Println("**Files modified:**")
- for _, f := range stats.FilesModified {
- // Shorten home directory paths
- f = strings.Replace(f, os.Getenv("HOME"), "~", 1)
- fmt.Printf("- %s\n", f)
- }
- fmt.Println()
- }
-
- // Notable commands (limit to 10)
- if len(stats.CommandsRun) > 0 {
- fmt.Println("**Commands executed:**")
- limit := len(stats.CommandsRun)
- if limit > 10 {
- limit = 10
- }
- for i := 0; i < limit; i++ {
- fmt.Printf("- `%s`\n", stats.CommandsRun[i])
- }
- if len(stats.CommandsRun) > 10 {
- fmt.Printf("- ... and %d more\n", len(stats.CommandsRun)-10)
- }
- fmt.Println()
- }
-
- // File reads (only if not too many)
- if len(stats.FilesRead) > 0 && len(stats.FilesRead) <= 15 {
- fmt.Println("**Files read:**")
- for _, f := range stats.FilesRead {
- f = strings.Replace(f, os.Getenv("HOME"), "~", 1)
- fmt.Printf("- %s\n", f)
- }
- fmt.Println()
- }
-}
tools/claude-hooks/cmd/update-terminal-title/main.go
@@ -1,191 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
-)
-
-// TodoItem represents a single TODO item from TodoWrite
-type TodoItem struct {
- Content string `json:"content"`
- Status string `json:"status"`
- ActiveForm string `json:"activeForm"`
-}
-
-// ToolResult represents the structure of tool execution results from PostToolUse hook
-type ToolResult struct {
- ToolName string `json:"tool_name"`
- ToolInput json.RawMessage `json:"tool_input"`
-}
-
-// TodoWriteParams represents TodoWrite tool parameters
-type TodoWriteParams struct {
- Todos []TodoItem `json:"todos"`
-}
-
-// SkillParams represents Skill tool parameters
-type SkillParams struct {
- Skill string `json:"skill"`
-}
-
-// setTerminalTitle sets the terminal tab title using ANSI escape codes
-func setTerminalTitle(title string) {
- fmt.Fprintf(os.Stderr, "\x1b]0;%s\x07", title)
- fmt.Fprintf(os.Stderr, "\x1b]2;%s\x07", title)
- fmt.Fprintf(os.Stderr, "\x1b]30;%s\x07", title)
-}
-
-// getProjectName extracts the project name from CLAUDE_PROJECT_DIR
-func getProjectName() string {
- projectDir := os.Getenv("CLAUDE_PROJECT_DIR")
- if projectDir == "" {
- return ""
- }
- return filepath.Base(projectDir)
-}
-
-// getSkillIcon returns an emoji icon for known skills
-func getSkillIcon(skillName string) string {
- icons := map[string]string{
- "Journal": "📓",
- "Notes": "📝",
- "TODOs": "✅",
- "Org": "📋",
- "Git": "🔧",
- "GitHub": "🐙",
- "Email": "📧",
- "Python": "🐍",
- "golang": "🐹",
- "Rust": "🦀",
- "Nix": "❄️",
- "Kubernetes": "☸️",
- "Tekton": "🚀",
- }
-
- if icon, ok := icons[skillName]; ok {
- return icon
- }
- return "🤖"
-}
-
-// getTodoProgress extracts task progress from TodoWrite parameters
-func getTodoProgress(todos []TodoItem) (string, bool) {
- var inProgressTask string
- completedCount := 0
- total := len(todos)
-
- if total == 0 {
- return "", false
- }
-
- for _, todo := range todos {
- if todo.Status == "completed" {
- completedCount++
- } else if todo.Status == "in_progress" {
- inProgressTask = todo.ActiveForm
- }
- }
-
- if inProgressTask != "" {
- progress := fmt.Sprintf("[%d/%d] %s", completedCount+1, total, inProgressTask)
- return progress, true
- }
-
- return "", false
-}
-
-// isPiContext checks if we're running under pi coding agent
-func isPiContext() bool {
- // Strategy 1: Check immediate parent process
- ppid := os.Getppid()
- cmdline := fmt.Sprintf("/proc/%d/comm", ppid)
- data, err := os.ReadFile(cmdline)
- if err == nil && strings.TrimSpace(string(data)) == "pi" {
- return true
- }
-
- // Strategy 2: Check if "pi" is anywhere in the process tree
- // Look at process tree via pstree or ps
- cmd := exec.Command("sh", "-c", fmt.Sprintf("ps -o comm= -p $(ps -o ppid= -p %d) 2>/dev/null || pstree -s %d 2>/dev/null", ppid, os.Getpid()))
- output, err := cmd.Output()
- if err == nil && strings.Contains(string(output), "pi") {
- return true
- }
-
- // Strategy 3: Check PI_* environment variables (if pi sets any in future)
- if os.Getenv("PI_AGENT") != "" || os.Getenv("PI_CODING_AGENT") != "" {
- return true
- }
-
- return false
-}
-
-// buildTitle constructs the terminal title based on available context
-func buildTitle(toolResult *ToolResult) string {
- var parts []string
- isPi := isPiContext()
-
- // Determine prefix (π for pi, Claude for Claude Code)
- prefix := "Claude"
- if isPi {
- prefix = "π"
- }
-
- // Priority 1: Check for active task from TodoWrite
- if toolResult != nil && toolResult.ToolName == "TodoWrite" {
- var params TodoWriteParams
- if err := json.Unmarshal(toolResult.ToolInput, ¶ms); err == nil {
- if progress, ok := getTodoProgress(params.Todos); ok {
- parts = append(parts, progress)
- }
- }
- }
-
- // Priority 2: Check for active skill
- if toolResult != nil && toolResult.ToolName == "Skill" {
- var params SkillParams
- if err := json.Unmarshal(toolResult.ToolInput, ¶ms); err == nil {
- icon := getSkillIcon(params.Skill)
- parts = append(parts, fmt.Sprintf("%s %s", icon, params.Skill))
- }
- }
-
- // Priority 3: Add project context
- if projectName := getProjectName(); projectName != "" {
- parts = append(parts, projectName)
- }
-
- // Priority 4: Always add working directory
- if cwd, err := os.Getwd(); err == nil {
- parts = append(parts, filepath.Base(cwd))
- }
-
- // Build final title
- if len(parts) > 0 {
- return fmt.Sprintf("%s %s", prefix, strings.Join(parts, " • "))
- }
-
- return fmt.Sprintf("%s Ready", prefix)
-}
-
-func main() {
- // Read tool result from stdin (if provided by hook system)
- var toolResult ToolResult
- decoder := json.NewDecoder(os.Stdin)
-
- // Try to decode, but don't fail if stdin is empty
- if err := decoder.Decode(&toolResult); err != nil {
- // No tool result provided, just use project context
- title := buildTitle(nil)
- setTerminalTitle(title)
- return
- }
-
- // Build and set title based on tool result
- title := buildTitle(&toolResult)
- setTerminalTitle(title)
-}
tools/claude-hooks/cmd/validate-docs/main.go
@@ -1,169 +0,0 @@
-package main
-
-import (
- "bufio"
- "fmt"
- "os"
- "path/filepath"
- "regexp"
- "strings"
-
- "github.com/bmatcuk/doublestar/v4"
-)
-
-// ANSI color codes
-const (
- colorReset = "\x1b[0m"
- colorRed = "\x1b[31m"
- colorGreen = "\x1b[32m"
- colorYellow = "\x1b[33m"
- colorCyan = "\x1b[36m"
-)
-
-type brokenLink struct {
- file string
- link string
- target string
- line int
-}
-
-// extractLinks extracts markdown links from content
-func extractLinks(content string) []struct {
- link string
- line int
-} {
- var links []struct {
- link string
- line int
- }
-
- // Match [text](path) style links
- linkRegex := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
-
- scanner := bufio.NewScanner(strings.NewReader(content))
- lineNum := 0
- for scanner.Scan() {
- lineNum++
- line := scanner.Text()
-
- matches := linkRegex.FindAllStringSubmatch(line, -1)
- for _, match := range matches {
- if len(match) >= 3 {
- link := match[2]
-
- // Skip external URLs, anchors, and mailto links
- if strings.HasPrefix(link, "http://") ||
- strings.HasPrefix(link, "https://") ||
- strings.HasPrefix(link, "#") ||
- strings.HasPrefix(link, "mailto:") {
- continue
- }
-
- links = append(links, struct {
- link string
- line int
- }{link: link, line: lineNum})
- }
- }
- }
-
- return links
-}
-
-// resolveLink resolves a link path relative to the file
-func resolveLink(fromFile, linkPath, baseDir string) string {
- // Remove anchor if present
- parts := strings.Split(linkPath, "#")
- pathWithoutAnchor := parts[0]
-
- // If it starts with ~, expand to home directory
- if strings.HasPrefix(pathWithoutAnchor, "~/") {
- home, err := os.UserHomeDir()
- if err != nil {
- return pathWithoutAnchor
- }
- return filepath.Join(home, pathWithoutAnchor[2:])
- }
-
- // If it's absolute, use as-is
- if filepath.IsAbs(pathWithoutAnchor) {
- return pathWithoutAnchor
- }
-
- // Otherwise, resolve relative to the file's directory
- fileDir := filepath.Dir(fromFile)
- return filepath.Join(fileDir, pathWithoutAnchor)
-}
-
-// validateDocs validates markdown files in a directory
-func validateDocs(baseDir string) []brokenLink {
- var broken []brokenLink
-
- // Find all markdown files using doublestar glob
- pattern := filepath.Join(baseDir, "**/*.md")
- matches, err := doublestar.FilepathGlob(pattern)
- if err != nil {
- fmt.Fprintf(os.Stderr, "%sError globbing files: %v%s\n", colorYellow, err, colorReset)
- return broken
- }
-
- for _, filePath := range matches {
- // Skip node_modules and hidden directories
- if strings.Contains(filePath, "node_modules") || strings.Contains(filePath, "/.") {
- continue
- }
-
- content, err := os.ReadFile(filePath)
- if err != nil {
- fmt.Fprintf(os.Stderr, "%sWarning: Could not read %s%s\n", colorYellow, filePath, colorReset)
- continue
- }
-
- links := extractLinks(string(content))
- for _, linkInfo := range links {
- targetPath := resolveLink(filePath, linkInfo.link, baseDir)
-
- // Check if target exists
- if _, err := os.Stat(targetPath); os.IsNotExist(err) {
- // Get relative path for display
- relPath, _ := filepath.Rel(baseDir, filePath)
- broken = append(broken, brokenLink{
- file: relPath,
- link: linkInfo.link,
- target: targetPath,
- line: linkInfo.line,
- })
- }
- }
- }
-
- return broken
-}
-
-func main() {
- baseDir, err := os.Getwd()
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err)
- os.Exit(1)
- }
-
- fmt.Printf("\n%s🔍 Documentation Link Validator%s\n", colorCyan, colorReset)
- fmt.Printf("%s Base directory: %s%s\n\n", colorCyan, baseDir, colorReset)
-
- brokenLinks := validateDocs(baseDir)
-
- if len(brokenLinks) > 0 {
- fmt.Printf("\n%s❌ Found %d broken link(s):%s\n\n", colorRed, len(brokenLinks), colorReset)
-
- for _, broken := range brokenLinks {
- fmt.Printf(" %s%s:%d%s\n", colorYellow, broken.file, broken.line, colorReset)
- fmt.Printf(" → %s%s%s (not found)\n\n", colorRed, broken.link, colorReset)
- }
-
- fmt.Printf("\n%sDocumentation validation failed. Please fix the broken links.%s\n\n", colorRed, colorReset)
- os.Exit(1)
- }
-
- fmt.Printf("%s✅ All documentation links are valid%s\n\n", colorGreen, colorReset)
- os.Exit(0)
-}
tools/claude-hooks/cmd/validate-git-push/main.go
@@ -1,143 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "fmt"
- "io"
- "os"
- "regexp"
- "strings"
-)
-
-// PreToolUseData represents the input from PreToolUse hook
-type PreToolUseData struct {
- ToolName string `json:"tool_name"`
- ToolInput map[string]interface{} `json:"tool_input"`
- ConversationID string `json:"conversation_id"`
-}
-
-// Check if a git push command uses explicit refspec (branch:branch)
-func hasExplicitRefspec(command string) bool {
- // Match patterns like:
- // git push origin branch:branch
- // git push origin HEAD:branch
- // git push -u origin branch:branch
- // git push --force-with-lease origin branch:branch
- refspecPattern := regexp.MustCompile(`git\s+push\s+.*\s+\S+:\S+`)
- return refspecPattern.MatchString(command)
-}
-
-// Check if this is a dangerous git add command
-func isDangerousGitAdd(command string) bool {
- // Match patterns like:
- // git add -A
- // git add --all
- // git add .
- dangerousAddPatterns := []*regexp.Regexp{
- regexp.MustCompile(`(^|&&|\|\||;|\||\$\()\s*git\s+add\s+-A(\s|$)`),
- regexp.MustCompile(`(^|&&|\|\||;|\||\$\()\s*git\s+add\s+--all(\s|$)`),
- regexp.MustCompile(`(^|&&|\|\||;|\||\$\()\s*git\s+add\s+\.(\s|$)`),
- }
-
- for _, pattern := range dangerousAddPatterns {
- if pattern.MatchString(command) {
- return true
- }
- }
- return false
-}
-
-// Check if this is a git push command (not just the string "git push" inside arguments)
-func isGitPush(command string) bool {
- // Match git push only when it appears as an actual command:
- // - At the start of the command
- // - After command separators: && || ; |
- // - After $( for command substitution
- // This avoids false positives on "git push" inside heredocs, strings, or commit messages
- gitPushPattern := regexp.MustCompile(`(^|&&|\|\||;|\||\$\()\s*git\s+push(\s|$)`)
- return gitPushPattern.MatchString(command)
-}
-
-// Check if pushing to a protected branch without explicit refspec
-func isPushToProtectedBranch(command string) bool {
- // These patterns indicate pushing to main/master without explicit refspec
- protectedBranches := []string{":main", ":master"}
- for _, branch := range protectedBranches {
- if strings.Contains(command, branch) {
- return true
- }
- }
- return false
-}
-
-func main() {
- // Read input from stdin
- input, err := io.ReadAll(os.Stdin)
- if err != nil {
- fmt.Fprintf(os.Stderr, "[validate-git-push] Error reading stdin: %v\n", err)
- os.Exit(0) // Allow on error - don't block workflow
- }
-
- if len(input) == 0 {
- os.Exit(0)
- }
-
- var data PreToolUseData
- if err := json.Unmarshal(input, &data); err != nil {
- fmt.Fprintf(os.Stderr, "[validate-git-push] Error parsing JSON: %v\n", err)
- os.Exit(0) // Allow on error
- }
-
- // Only check Bash tool
- if data.ToolName != "Bash" {
- os.Exit(0)
- }
-
- // Get the command
- command, ok := data.ToolInput["command"].(string)
- if !ok || command == "" {
- os.Exit(0)
- }
-
- // Check for dangerous git add commands first
- if isDangerousGitAdd(command) {
- fmt.Fprintln(os.Stderr, "BLOCKED: Dangerous git add command detected!")
- fmt.Fprintln(os.Stderr, "")
- fmt.Fprintln(os.Stderr, "Commands like 'git add -A', 'git add --all', and 'git add .' can add unintended files.")
- fmt.Fprintln(os.Stderr, "")
- fmt.Fprintln(os.Stderr, "Use explicit file paths instead:")
- fmt.Fprintln(os.Stderr, " git add path/to/specific/file.txt")
- fmt.Fprintln(os.Stderr, " git add path/to/directory/")
- fmt.Fprintln(os.Stderr, " git add *.go # for specific patterns")
- fmt.Fprintln(os.Stderr, "")
- fmt.Fprintf(os.Stderr, "Blocked command: %s\n", command)
- os.Exit(2) // Non-zero exit blocks the tool
- }
-
- // Check if this is a git push command
- if !isGitPush(command) {
- os.Exit(0)
- }
-
- // Check if it has explicit refspec
- if !hasExplicitRefspec(command) {
- // Block the command - output error message to stderr and exit non-zero
- fmt.Fprintln(os.Stderr, "BLOCKED: git push without explicit refspec detected!")
- fmt.Fprintln(os.Stderr, "")
- fmt.Fprintln(os.Stderr, "The command uses implicit branch tracking which can push to wrong branches.")
- fmt.Fprintln(os.Stderr, "")
- fmt.Fprintln(os.Stderr, "Use explicit refspec instead:")
- fmt.Fprintln(os.Stderr, " git push origin <branch>:<branch>")
- fmt.Fprintln(os.Stderr, " git push origin HEAD:<branch>")
- fmt.Fprintln(os.Stderr, "")
- fmt.Fprintf(os.Stderr, "Blocked command: %s\n", command)
- os.Exit(2) // Non-zero exit blocks the tool
- }
-
- // Warn about pushing to protected branches (but allow it with explicit refspec)
- if isPushToProtectedBranch(command) {
- fmt.Fprintf(os.Stderr, "[validate-git-push] Warning: Pushing to protected branch (main/master)\n")
- }
-
- os.Exit(0) // Allow the command
-}
tools/claude-hooks/internal/paths/paths.go
@@ -1,79 +0,0 @@
-package paths
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "time"
-)
-
-// ClaudeDir returns the base Claude directory (~/.claude or CLAUDE_DIR env var)
-func ClaudeDir() string {
- if dir := os.Getenv("CLAUDE_DIR"); dir != "" {
- return dir
- }
- home, err := os.UserHomeDir()
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
- os.Exit(1)
- }
- return filepath.Join(home, ".claude")
-}
-
-// HooksDir returns the hooks directory
-func HooksDir() string {
- return filepath.Join(ClaudeDir(), "hooks")
-}
-
-// SkillsDir returns the skills directory
-func SkillsDir() string {
- return filepath.Join(ClaudeDir(), "skills")
-}
-
-// AgentsDir returns the agents directory
-func AgentsDir() string {
- return filepath.Join(ClaudeDir(), "agents")
-}
-
-// HistoryDir returns the history directory
-func HistoryDir() string {
- return filepath.Join(ClaudeDir(), "history")
-}
-
-// GetHistoryFilePath returns a history file path with year-month organization
-func GetHistoryFilePath(subdir, filename string) string {
- now := time.Now()
- yearMonth := now.Format("2006-01")
- return filepath.Join(HistoryDir(), subdir, yearMonth, filename)
-}
-
-// GetTimestamp returns current timestamp in YYYY-MM-DD-HHMMSS format
-func GetTimestamp() string {
- return time.Now().Format("2006-01-02-150405")
-}
-
-// GetDate returns current date in YYYY-MM-DD format
-func GetDate() string {
- return time.Now().Format("2006-01-02")
-}
-
-// GetYearMonth returns current year-month in YYYY-MM format
-func GetYearMonth() string {
- return time.Now().Format("2006-01")
-}
-
-// ValidateClaudeStructure validates that the Claude directory exists
-func ValidateClaudeStructure() error {
- claudeDir := ClaudeDir()
- if _, err := os.Stat(claudeDir); os.IsNotExist(err) {
- return fmt.Errorf("CLAUDE_DIR does not exist: %s\nExpected ~/.claude or set CLAUDE_DIR environment variable", claudeDir)
- }
- return nil
-}
-
-// EnsureHistoryDir creates the history directory structure if needed
-func EnsureHistoryDir(subdir string) error {
- yearMonth := GetYearMonth()
- dir := filepath.Join(HistoryDir(), subdir, yearMonth)
- return os.MkdirAll(dir, 0755)
-}
tools/claude-hooks/default.nix
@@ -1,41 +0,0 @@
-{
- buildGoModule,
- lib,
-}:
-
-buildGoModule {
- pname = "claude-hooks";
- version = "0.1.0";
- src = ./.;
-
- vendorHash = "sha256-bdpAteulG3045jPdEpjcT4yGlnxLKDMlK7lk9WVRTKc=";
-
- # Build all binaries
- subPackages = [
- "cmd/capture-tool-output"
- "cmd/initialize-session"
- "cmd/update-terminal-title"
- "cmd/validate-docs"
- "cmd/save-session"
- "cmd/session-stats"
- "cmd/validate-git-push"
- ];
-
- # Rename binaries to have consistent prefix
- postInstall = ''
- mv $out/bin/capture-tool-output $out/bin/claude-hooks-capture-tool-output
- mv $out/bin/initialize-session $out/bin/claude-hooks-initialize-session
- mv $out/bin/update-terminal-title $out/bin/claude-hooks-update-terminal-title
- mv $out/bin/validate-docs $out/bin/claude-hooks-validate-docs
- mv $out/bin/save-session $out/bin/claude-hooks-save-session
- mv $out/bin/session-stats $out/bin/claude-hooks-session-stats
- mv $out/bin/validate-git-push $out/bin/claude-hooks-validate-git-push
- '';
-
- meta = {
- description = "Claude Code hooks for session management, tool output capture, and documentation validation";
- license = lib.licenses.mit;
- platforms = lib.platforms.unix;
- mainProgram = "claude-hooks-capture-tool-output";
- };
-}
tools/claude-hooks/go.mod
@@ -1,5 +0,0 @@
-module github.com/vdemeester/home/tools/claude-hooks
-
-go 1.23
-
-require github.com/bmatcuk/doublestar/v4 v4.7.1
tools/claude-hooks/go.sum
@@ -1,2 +0,0 @@
-github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
-github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
tools/claude-hooks/README.md
@@ -1,256 +0,0 @@
-# Claude Code Hooks (Go Implementation)
-
-Claude Code hooks implemented in Go for better performance, zero runtime dependencies, and native integration with the Nix ecosystem.
-
-Migrated from TypeScript/Bun implementation in `dots/.claude/hooks/`.
-
-## Hooks Included
-
-### 1. `claude-hooks-initialize-session`
-**Event**: SessionStart
-
-**What it does**:
-- Detects and skips subagent sessions
-- Sets terminal tab title to "Claude Ready"
-- Logs session start to `~/.config/claude/history/sessions/YYYY-MM/YYYY-MM-DD_session-log.txt`
-- Implements debouncing to prevent duplicate triggers (2 second window)
-
-### 2. `claude-hooks-capture-tool-output`
-**Event**: PostToolUse
-
-**What it does**:
-- Captures outputs from interesting tools (Bash, Edit, Write, Read, Task, NotebookEdit, Skill, SlashCommand)
-- Logs to JSONL files: `~/.config/claude/history/tool-outputs/YYYY-MM/YYYY-MM-DD_tool-outputs.jsonl`
-- Silent failure - doesn't disrupt workflow if logging fails
-- Automatically creates directory structure
-
-### 3. `claude-hooks-validate-docs`
-**Event**: Manual/Pre-commit
-
-**What it does**:
-- Scans all markdown files for internal links
-- Checks if linked files exist
-- Reports broken links with file:line numbers
-- Exit code 0 if valid, 1 if broken links found
-- Skips external URLs, anchors, and mailto links
-
-### 4. `claude-hooks-save-session`
-**Event**: SessionEnd
-
-**What it does**:
-- Prompts user to save session summary when ending a Claude Code session
-- Skips subagent sessions (silent)
-- Displays a message asking if session should be saved
-- Works with `/save-session` slash command for creating session entries
-- Saves to `~/.config/claude/history/sessions/YYYY-MM/YYYY-MM-DD-HHMMSS_SESSION_description.md`
-
-### 5. `claude-hooks-validate-git-push`
-**Event**: PreToolUse (via extensions)
-
-**What it does**:
-- Validates git commands to prevent dangerous operations
-- Blocks `git add -A`, `git add --all`, and `git add .` to prevent adding unintended files
-- Blocks git push commands without explicit refspecs (e.g., `git push origin main`)
-- Warns about pushes to protected branches (main/master)
-- Exit code 0 if command is safe, non-zero if command should be blocked
-
-## Architecture
-
-```
-tools/claude-hooks/
-├── cmd/
-│ ├── capture-tool-output/main.go # PostToolUse hook
-│ ├── initialize-session/main.go # SessionStart hook
-│ ├── save-session/main.go # SessionEnd hook
-│ └── validate-docs/main.go # Documentation validator
-├── internal/
-│ └── paths/paths.go # Shared path utilities
-├── default.nix # Nix package definition
-├── go.mod # Go module definition
-├── go.sum # Go dependencies
-├── setup-hooks.sh # Configuration script
-└── README.md # This file
-```
-
-## Installation
-
-### Via Nix (Recommended)
-
-1. **Build the package**:
- ```bash
- nix build .#claude-hooks
- ```
-
-2. **Install to your profile**:
- ```bash
- nix profile install .#claude-hooks
- ```
-
-3. **Or add to home-manager** (in `home/common/dev/default.nix` or similar):
- ```nix
- home.packages = with pkgs; [
- claude-hooks
- ];
- ```
-
-4. **Configure hooks** (run setup script):
- ```bash
- ./tools/claude-hooks/setup-hooks.sh
- ```
-
- Or manually update `~/.claude/settings.json`:
- ```json
- {
- "hooks": {
- "SessionStart": [
- {
- "hooks": [
- {
- "type": "command",
- "command": "claude-hooks-initialize-session"
- }
- ]
- }
- ],
- "PostToolUse": [
- {
- "hooks": [
- {
- "type": "command",
- "command": "claude-hooks-capture-tool-output"
- }
- ]
- }
- ],
- "SessionEnd": [
- {
- "hooks": [
- {
- "type": "command",
- "command": "claude-hooks-save-session"
- }
- ]
- }
- ]
- }
- }
- ```
-
-5. **Enable the session-manager plugin** (for `/save-session` command):
- ```bash
- # Symlink the plugin if not already linked
- ln -s ~/src/home/dots/.claude/plugins/session-manager ~/.claude/plugins/session-manager
- ```
-
-### Manual Build (without Nix)
-
-```bash
-cd tools/claude-hooks
-
-# Build all hooks
-go build -o bin/claude-hooks-capture-tool-output ./cmd/capture-tool-output
-go build -o bin/claude-hooks-initialize-session ./cmd/initialize-session
-go build -o bin/claude-hooks-save-session ./cmd/save-session
-go build -o bin/claude-hooks-validate-docs ./cmd/validate-docs
-
-# Copy to PATH
-sudo cp bin/* /usr/local/bin/
-
-# Then run setup script
-./setup-hooks.sh
-```
-
-## Development
-
-### Running hooks directly with `go run`
-
-```bash
-cd tools/claude-hooks
-
-# Test initialize-session
-go run ./cmd/initialize-session
-
-# Test capture-tool-output (requires JSON input)
-echo '{"tool_name":"Bash","tool_input":{},"tool_response":{},"conversation_id":"test"}' | \
- go run ./cmd/capture-tool-output
-
-# Test validate-docs
-go run ./cmd/validate-docs
-```
-
-### Testing
-
-```bash
-# Run all tests
-go test ./...
-
-# Test with verbose output
-go test -v ./internal/paths
-
-# Test specific package
-go test ./cmd/validate-docs
-```
-
-### Updating vendorHash
-
-If you add/update Go dependencies:
-
-```bash
-cd tools/claude-hooks
-go mod tidy
-nix build .#claude-hooks 2>&1 | grep "got:" | awk '{print $2}'
-# Copy the hash to default.nix vendorHash field
-```
-
-## Advantages over TypeScript/Bun
-
-1. **Zero runtime dependencies** - Compiled to native binary, no Bun required
-2. **Faster execution** - Native code vs interpreted JavaScript
-3. **Easier distribution** - Single binary per hook
-4. **Cross-compilation** - Build for any architecture from any platform
-5. **Nix integration** - Proper package management and reproducible builds
-6. **Type safety** - Compile-time checks without runtime overhead
-7. **Standard library** - Excellent built-in support for JSON, file I/O, paths
-
-## Migration Notes
-
-The Go implementation is functionally equivalent to the TypeScript version with these improvements:
-
-- **Performance**: Faster startup and execution
-- **Reliability**: Compile-time type checking
-- **Portability**: Works on all systems where Go binaries run
-- **Integration**: Native Nix packaging for declarative installation
-
-The old TypeScript hooks in `dots/.claude/hooks/` can be removed after verifying the Go versions work correctly.
-
-## Troubleshooting
-
-**Hook not running:**
-- Check `~/.claude/settings.json` syntax
-- Verify binaries are in PATH: `which claude-hooks-initialize-session`
-- Check stderr output in terminal
-- Verify hooks are executable: `ls -la $(which claude-hooks-initialize-session)`
-
-**Permission errors:**
-- Ensure directories exist: `mkdir -p ~/.config/claude/history/{sessions,tool-outputs}`
-- Check write permissions on `~/.config/claude/history`
-
-**Debugging:**
-- Hooks write errors to stderr - check terminal output
-- Run hooks manually to test: `claude-hooks-initialize-session`
-- Check log files: `ls -la ~/.config/claude/history/`
-
-**Build errors:**
-- Run `go mod tidy` to update dependencies
-- Check Go version: `go version` (requires Go 1.23+)
-- Verify vendorHash in `default.nix` is correct
-
-## Environment Variables
-
-- `CLAUDE_DIR` - Override default `~/.config/claude` directory (optional)
-- `CLAUDE_PROJECT_DIR` - Set by Claude Code (used for subagent detection)
-- `CLAUDE_AGENT_TYPE` - Set by Claude Code for subagents (used for detection)
-
-## License
-
-Same as the rest of the home repository.
tools/claude-hooks/setup-hooks.sh
@@ -1,104 +0,0 @@
-#!/usr/bin/env bash
-#
-# setup-hooks.sh - Configure Claude Code hooks to use Nix-built binaries
-#
-# This script updates ~/.claude/settings.json to reference the correct
-# hook binaries from the Nix store (via PATH).
-#
-# Usage:
-# ./setup-hooks.sh # Update settings.json
-# ./setup-hooks.sh --dry-run # Show what would be changed
-
-set -euo pipefail
-
-SETTINGS_FILE="${HOME}/.claude/settings.json"
-SETTINGS_BACKUP="${HOME}/.claude/settings.json.backup"
-
-# Determine if this is a dry run
-DRY_RUN=false
-if [[ "${1:-}" == "--dry-run" ]]; then
- DRY_RUN=true
-fi
-
-# Check if settings.json exists
-if [[ ! -f "$SETTINGS_FILE" ]]; then
- echo "Error: $SETTINGS_FILE does not exist"
- echo "Please create it first or run Claude Code to initialize it"
- exit 1
-fi
-
-# Backup existing settings
-if [[ "$DRY_RUN" == false ]]; then
- cp "$SETTINGS_FILE" "$SETTINGS_BACKUP"
- echo "Backed up existing settings to: $SETTINGS_BACKUP"
-fi
-
-# Read current settings
-CURRENT_SETTINGS=$(cat "$SETTINGS_FILE")
-
-# Build new hooks configuration
-# Note: The binaries should be in PATH after installing via Nix
-NEW_HOOKS=$(cat <<'EOF'
-{
- "hooks": [
- {
- "type": "command",
- "command": "claude-hooks-initialize-session"
- }
- ]
-}
-EOF
-)
-
-POST_TOOL_HOOKS=$(cat <<'EOF'
-{
- "hooks": [
- {
- "type": "command",
- "command": "claude-hooks-capture-tool-output"
- }
- ]
-}
-EOF
-)
-
-SESSION_END_HOOKS=$(cat <<'EOF'
-{
- "hooks": [
- {
- "type": "command",
- "command": "claude-hooks-save-session"
- }
- ]
-}
-EOF
-)
-
-# Use jq to update the settings.json with proper JSON merging
-UPDATED_SETTINGS=$(echo "$CURRENT_SETTINGS" | jq --argjson sessionStart "$NEW_HOOKS" \
- --argjson postTool "$POST_TOOL_HOOKS" \
- --argjson sessionEnd "$SESSION_END_HOOKS" \
- '.hooks.SessionStart = [$sessionStart] | .hooks.PostToolUse = [$postTool] | .hooks.SessionEnd = [$sessionEnd]')
-
-if [[ "$DRY_RUN" == true ]]; then
- echo "=== DRY RUN - No changes made ==="
- echo ""
- echo "Would update $SETTINGS_FILE with:"
- echo "$UPDATED_SETTINGS" | jq '.'
- echo ""
- echo "Run without --dry-run to apply these changes"
-else
- echo "$UPDATED_SETTINGS" | jq '.' > "$SETTINGS_FILE"
- echo "Successfully updated $SETTINGS_FILE"
- echo ""
- echo "Hook binaries configured:"
- echo " - SessionStart: claude-hooks-initialize-session"
- echo " - PostToolUse: claude-hooks-capture-tool-output"
- echo " - SessionEnd: claude-hooks-save-session"
- echo ""
- echo "Make sure claude-hooks is installed:"
- echo " nix profile install .#claude-hooks"
- echo ""
- echo "Or add to your home-manager configuration:"
- echo " home.packages = [ pkgs.claude-hooks ];"
-fi