diff --git a/README.md b/README.md index 1523b29..3fa7886 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ GitMem is an [MCP server](https://modelcontextprotocol.io/) that gives your AI coding agent **persistent memory across sessions**. It remembers mistakes (scars), successes (wins), and decisions — so your agent learns from experience instead of starting from scratch every time. -Works with **Claude Code**, **Claude Desktop**, **Cursor**, and any MCP-compatible client. +Works with **Claude Code**, **Cursor**, **VS Code (Copilot)**, **Windsurf**, and any MCP-compatible client. ## Quick Start @@ -29,19 +29,19 @@ Works with **Claude Code**, **Claude Desktop**, **Cursor**, and any MCP-compatib npx gitmem-mcp init ``` -One command. The wizard sets up everything: -- `.gitmem/` directory with 3 starter scars -- `.mcp.json` with gitmem server entry -- `CLAUDE.md` with memory protocol instructions -- `.claude/settings.json` with tool permissions -- Lifecycle hooks for automatic session management +One command. The wizard auto-detects your IDE and sets up everything: +- `.gitmem/` directory with starter scars +- MCP server config (`.mcp.json`, `.vscode/mcp.json`, `.cursor/mcp.json`, etc.) +- Instructions file (`CLAUDE.md`, `.cursorrules`, `.windsurfrules`, `.github/copilot-instructions.md`) +- Lifecycle hooks (where supported) - `.gitignore` updated Already have existing config? The wizard merges without destroying anything. Re-running is safe. ```bash -npx gitmem-mcp init --yes # Non-interactive -npx gitmem-mcp init --dry-run # Preview changes +npx gitmem-mcp init --yes # Non-interactive +npx gitmem-mcp init --dry-run # Preview changes +npx gitmem-mcp init --client vscode # Force specific client ``` ## How It Works @@ -78,16 +78,22 @@ Every scar includes **counter-arguments** — reasons why someone might reasonab ## Supported Clients -| Client | Setup | -|--------|-------| -| **Claude Code** | `npx gitmem-mcp init` (auto-detected) | -| **Claude Desktop** | `npx gitmem-mcp init` or add to `claude_desktop_config.json` | -| **Cursor** | `npx gitmem-mcp init` or add to `.cursor/mcp.json` | -| **Any MCP client** | Add `npx -y gitmem-mcp` as an MCP server | +| Client | Setup | Hooks | +|--------|-------|-------| +| **Claude Code** | `npx gitmem-mcp init` | Full (session, recall, credential guard) | +| **Cursor** | `npx gitmem-mcp init --client cursor` | Partial (session, recall) | +| **VS Code (Copilot)** | `npx gitmem-mcp init --client vscode` | Instructions-based | +| **Windsurf** | `npx gitmem-mcp init --client windsurf` | Instructions-based | +| **Claude Desktop** | Add to `claude_desktop_config.json` | Manual | +| **Any MCP client** | `npx gitmem-mcp init --client generic` | Instructions-based | + +The wizard auto-detects your IDE. Use `--client` to override.
Manual MCP configuration +Add this to your MCP client's config file: + ```json { "mcpServers": { @@ -99,13 +105,21 @@ Every scar includes **counter-arguments** — reasons why someone might reasonab } ``` +| Client | Config file | +|--------|-------------| +| Claude Code | `.mcp.json` | +| Cursor | `.cursor/mcp.json` | +| VS Code | `.vscode/mcp.json` | +| Windsurf | `~/.codeium/windsurf/mcp_config.json` | +
## CLI Commands | Command | Description | |---------|-------------| -| `npx gitmem-mcp init` | Interactive setup wizard | +| `npx gitmem-mcp init` | Interactive setup wizard (auto-detects IDE) | +| `npx gitmem-mcp init --client ` | Setup for specific client (`claude`, `cursor`, `vscode`, `windsurf`, `generic`) | | `npx gitmem-mcp init --yes` | Non-interactive setup | | `npx gitmem-mcp init --dry-run` | Preview changes | | `npx gitmem-mcp uninstall` | Clean removal (preserves `.gitmem/` data) | diff --git a/bin/init-wizard.js b/bin/init-wizard.js index 5855a65..a08d147 100644 --- a/bin/init-wizard.js +++ b/bin/init-wizard.js @@ -6,7 +6,7 @@ * Interactive setup that detects existing config, prompts, and merges. * Supports Claude Code and Cursor IDE. * - * Usage: npx gitmem-mcp init [--yes] [--dry-run] [--project ] [--client ] + * Usage: npx gitmem-mcp init [--yes] [--dry-run] [--project ] [--client ] */ import { @@ -35,11 +35,15 @@ const clientFlag = clientIdx !== -1 ? args[clientIdx + 1]?.toLowerCase() : null; // ── Client Configuration ── +// Resolve user home directory for clients that use user-level config +const homeDir = process.env.HOME || process.env.USERPROFILE || "~"; + const CLIENT_CONFIGS = { claude: { name: "Claude Code", mcpConfigPath: join(cwd, ".mcp.json"), mcpConfigName: ".mcp.json", + mcpConfigScope: "project", instructionsFile: join(cwd, "CLAUDE.md"), instructionsName: "CLAUDE.md", templateFile: join(__dirname, "..", "CLAUDE.md.template"), @@ -50,12 +54,14 @@ const CLIENT_CONFIGS = { settingsLocalFile: join(cwd, ".claude", "settings.local.json"), hasPermissions: true, hooksInSettings: true, + hasHooks: true, completionMsg: "Setup complete! Start Claude Code \u2014 memory is active.", }, cursor: { name: "Cursor", mcpConfigPath: join(cwd, ".cursor", "mcp.json"), mcpConfigName: ".cursor/mcp.json", + mcpConfigScope: "project", instructionsFile: join(cwd, ".cursorrules"), instructionsName: ".cursorrules", templateFile: join(__dirname, "..", "cursorrules.template"), @@ -66,10 +72,66 @@ const CLIENT_CONFIGS = { settingsLocalFile: null, hasPermissions: false, hooksInSettings: false, + hasHooks: true, hooksFile: join(cwd, ".cursor", "hooks.json"), hooksFileName: ".cursor/hooks.json", completionMsg: "Setup complete! Open Cursor (Agent mode) \u2014 memory is active.", }, + vscode: { + name: "VS Code (Copilot)", + mcpConfigPath: join(cwd, ".vscode", "mcp.json"), + mcpConfigName: ".vscode/mcp.json", + mcpConfigScope: "project", + instructionsFile: join(cwd, ".github", "copilot-instructions.md"), + instructionsName: ".github/copilot-instructions.md", + templateFile: join(__dirname, "..", "copilot-instructions.template"), + startMarker: "", + endMarker: "", + configDir: join(cwd, ".vscode"), + settingsFile: null, + settingsLocalFile: null, + hasPermissions: false, + hooksInSettings: false, + hasHooks: false, + completionMsg: "Setup complete! Open VS Code \u2014 memory is active via Copilot.", + }, + windsurf: { + name: "Windsurf", + mcpConfigPath: join(homeDir, ".codeium", "windsurf", "mcp_config.json"), + mcpConfigName: "~/.codeium/windsurf/mcp_config.json", + mcpConfigScope: "user", + instructionsFile: join(cwd, ".windsurfrules"), + instructionsName: ".windsurfrules", + templateFile: join(__dirname, "..", "windsurfrules.template"), + startMarker: "# --- gitmem:start ---", + endMarker: "# --- gitmem:end ---", + configDir: null, + settingsFile: null, + settingsLocalFile: null, + hasPermissions: false, + hooksInSettings: false, + hasHooks: false, + completionMsg: "Setup complete! Open Windsurf \u2014 memory is active.", + }, + generic: { + name: "Generic MCP Client", + mcpConfigPath: join(cwd, ".mcp.json"), + mcpConfigName: ".mcp.json", + mcpConfigScope: "project", + instructionsFile: join(cwd, "CLAUDE.md"), + instructionsName: "CLAUDE.md", + templateFile: join(__dirname, "..", "CLAUDE.md.template"), + startMarker: "", + endMarker: "", + configDir: null, + settingsFile: null, + settingsLocalFile: null, + hasPermissions: false, + hooksInSettings: false, + hasHooks: false, + completionMsg: + "Setup complete! Configure your MCP client to use the gitmem server from .mcp.json.", + }, }; // Shared paths (client-agnostic) @@ -84,33 +146,49 @@ let cc; // shorthand for CLIENT_CONFIGS[client] // ── Client Detection ── +const VALID_CLIENTS = Object.keys(CLIENT_CONFIGS); + function detectClient() { // Explicit flag takes priority if (clientFlag) { - if (clientFlag !== "claude" && clientFlag !== "cursor") { - console.error(` Error: Unknown client "${clientFlag}". Use --client claude or --client cursor.`); + if (!VALID_CLIENTS.includes(clientFlag)) { + console.error(` Error: Unknown client "${clientFlag}". Use --client ${VALID_CLIENTS.join("|")}.`); process.exit(1); } return clientFlag; } - // Auto-detect based on directory presence + // Auto-detect based on directory/file presence const hasCursorDir = existsSync(join(cwd, ".cursor")); const hasClaudeDir = existsSync(join(cwd, ".claude")); const hasMcpJson = existsSync(join(cwd, ".mcp.json")); const hasClaudeMd = existsSync(join(cwd, "CLAUDE.md")); const hasCursorRules = existsSync(join(cwd, ".cursorrules")); const hasCursorMcp = existsSync(join(cwd, ".cursor", "mcp.json")); + const hasVscodeDir = existsSync(join(cwd, ".vscode")); + const hasVscodeMcp = existsSync(join(cwd, ".vscode", "mcp.json")); + const hasCopilotInstructions = existsSync(join(cwd, ".github", "copilot-instructions.md")); + const hasWindsurfRules = existsSync(join(cwd, ".windsurfrules")); + const hasWindsurfMcp = existsSync( + join(homeDir, ".codeium", "windsurf", "mcp_config.json") + ); // Strong Cursor signals if (hasCursorDir && !hasClaudeDir && !hasMcpJson && !hasClaudeMd) return "cursor"; - if (hasCursorRules && !hasClaudeMd) return "cursor"; - if (hasCursorMcp && !hasMcpJson) return "cursor"; + if (hasCursorRules && !hasClaudeMd && !hasCopilotInstructions) return "cursor"; + if (hasCursorMcp && !hasMcpJson && !hasVscodeMcp) return "cursor"; // Strong Claude signals - if (hasClaudeDir && !hasCursorDir) return "claude"; - if (hasMcpJson && !hasCursorMcp) return "claude"; - if (hasClaudeMd && !hasCursorRules) return "claude"; + if (hasClaudeDir && !hasCursorDir && !hasVscodeDir) return "claude"; + if (hasMcpJson && !hasCursorMcp && !hasVscodeMcp) return "claude"; + if (hasClaudeMd && !hasCursorRules && !hasCopilotInstructions) return "claude"; + + // VS Code signals + if (hasVscodeMcp && !hasMcpJson && !hasCursorMcp) return "vscode"; + if (hasCopilotInstructions && !hasClaudeMd && !hasCursorRules) return "vscode"; + + // Windsurf signals + if (hasWindsurfRules && !hasClaudeMd && !hasCursorRules && !hasCopilotInstructions) return "windsurf"; // Default to Claude Code (most common) return "claude"; @@ -439,6 +517,7 @@ async function stepMemoryStore() { async function stepMcpServer() { const mcpPath = cc.mcpConfigPath; const mcpName = cc.mcpConfigName; + const isUserLevel = cc.mcpConfigScope === "user"; const existing = readJson(mcpPath); const hasGitmem = @@ -453,9 +532,10 @@ async function stepMcpServer() { ? Object.keys(existing.mcpServers).length : 0; const tierLabel = process.env.SUPABASE_URL ? "pro" : "free"; + const scopeNote = isUserLevel ? " (user-level config)" : ""; const prompt = existing - ? `Add gitmem to ${mcpName}? (${serverCount} existing server${serverCount !== 1 ? "s" : ""} preserved)` - : `Create ${mcpName} with gitmem server?`; + ? `Add gitmem to ${mcpName}?${scopeNote} (${serverCount} existing server${serverCount !== 1 ? "s" : ""} preserved)` + : `Create ${mcpName} with gitmem server?${scopeNote}`; if (!(await confirm(prompt))) { console.log(" Skipped."); @@ -463,11 +543,11 @@ async function stepMcpServer() { } if (dryRun) { - console.log(` [dry-run] Would add gitmem entry to ${mcpName} (${tierLabel} tier)`); + console.log(` [dry-run] Would add gitmem entry to ${mcpName} (${tierLabel} tier${scopeNote})`); return; } - // Ensure parent directory exists (for .cursor/mcp.json) + // Ensure parent directory exists (for .cursor/mcp.json, .vscode/mcp.json, ~/.codeium/windsurf/) const parentDir = dirname(mcpPath); if (!existsSync(parentDir)) { mkdirSync(parentDir, { recursive: true }); @@ -481,7 +561,8 @@ async function stepMcpServer() { console.log( ` Added gitmem entry to ${mcpName} (${tierLabel} tier` + (process.env.SUPABASE_URL ? " \u2014 Supabase detected" : " \u2014 local storage") + - ")" + ")" + + (isUserLevel ? " [user-level]" : "") ); } @@ -525,6 +606,12 @@ async function stepInstructions() { block = `${cc.startMarker}\n${block}\n${cc.endMarker}`; } + // Ensure parent directory exists (for .github/copilot-instructions.md) + const instrParentDir = dirname(instrPath); + if (!existsSync(instrParentDir)) { + mkdirSync(instrParentDir, { recursive: true }); + } + if (exists) { content = content.trimEnd() + "\n\n" + block + "\n"; } else { @@ -598,6 +685,11 @@ function copyHookScripts() { } async function stepHooks() { + if (!cc.hasHooks) { + console.log(` ${cc.name} does not support lifecycle hooks. Skipping.`); + console.log(" Enforcement relies on system prompt instructions instead."); + return; + } if (cc.hooksInSettings) { return stepHooksClaude(); } @@ -821,7 +913,7 @@ async function main() { ); } - if (!cc.hooksInSettings && cc.hooksFile && existsSync(cc.hooksFile)) { + if (!cc.hooksInSettings && cc.hasHooks && cc.hooksFile && existsSync(cc.hooksFile)) { const hooks = readJson(cc.hooksFile); const hookCount = hooks?.hooks ? Object.values(hooks.hooks).flat().length @@ -850,8 +942,10 @@ async function main() { ); console.log(""); - // Run steps — step count depends on client - const stepCount = cc.hasPermissions ? 6 : 5; + // Run steps — step count depends on client capabilities + let stepCount = 4; // memory store + mcp server + instructions + gitignore + if (cc.hasPermissions) stepCount++; + if (cc.hasHooks) stepCount++; let step = 1; console.log(` Step ${step}/${stepCount} \u2014 Memory Store`); @@ -876,10 +970,12 @@ async function main() { step++; } - console.log(` Step ${step}/${stepCount} \u2014 Lifecycle Hooks`); - await stepHooks(); - console.log(""); - step++; + if (cc.hasHooks) { + console.log(` Step ${step}/${stepCount} \u2014 Lifecycle Hooks`); + await stepHooks(); + console.log(""); + step++; + } console.log(` Step ${step}/${stepCount} \u2014 Gitignore`); await stepGitignore(); diff --git a/copilot-instructions.template b/copilot-instructions.template new file mode 100644 index 0000000..be3c64f --- /dev/null +++ b/copilot-instructions.template @@ -0,0 +1,82 @@ + +# GitMem — Persistent Memory + +You have a persistent memory layer called gitmem. Every session starts from +zero — but gitmem carries forward lessons, mistakes, wins, decisions, and +open threads across sessions and projects. Search it. Test it. You might be +amazed at what's already there. If convinced, make it your own. + +## Red Lines + +### Credential Protection + +Credential exposure in conversation history is permanent and irreversible. + +1. **NEVER read credential files in full.** Files like `mcp-config.json`, `.env`, `.credentials.json`, `.netrc`, `.npmrc`, SSH keys, or `.pem`/`.key` files must not be read or dumped. +2. **NEVER print environment variable values that contain secrets.** Commands like `env | grep KEY`, `echo $API_KEY`, or `printenv TOKEN` expose credentials in output. +3. **NEVER display API keys, tokens, or secrets in conversation output.** + +Safe alternatives: `env | grep -c VARNAME` (count only), `[ -n "$VARNAME" ] && echo "set"` (existence check), `grep -c '"key"' config.json` (structure check). + +### Recall Before Consequential Actions + +1. **NEVER parallelize `recall()` with actions that expose, modify, or transmit sensitive data.** Recall must complete first. +2. **Confirm scars before acting.** Each recalled scar requires APPLYING (past-tense evidence), N_A (explanation), or REFUTED (risk acknowledgment). +3. **Parallel recall is only safe with benign reads** — source code, docs, non-sensitive config. + +## Tools + +| Tool | When to use | +|------|-------------| +| `recall` | Before any task — surfaces relevant warnings from past experience | +| `confirm_scars` | After recall — acknowledge each scar as APPLYING, N_A, or REFUTED | +| `search` | Explore institutional knowledge by topic | +| `log` | Browse recent learnings chronologically | +| `session_start` | Beginning of session — loads last session context and open threads | +| `session_close` | End of session — persists what you learned | +| `create_learning` | Capture a mistake (scar), success (win), or pattern | +| `create_decision` | Log an architectural or operational decision with rationale | +| `list_threads` | See unresolved work carrying over between sessions | +| `create_thread` | Track something that needs follow-up in a future session | +| `help` | Show all available commands | + +## Session Lifecycle + +**Start:** Call `session_start()` at the beginning of every session. + +**End:** On "closing", "done for now", or "wrapping up": + +1. **Answer these reflection questions** and display to the human: + - What broke that you didn't expect? + - What took longer than it should have? + - What would you do differently next time? + - What pattern or approach worked well? + - What assumption was wrong? + - Which scars did you apply? + - What should be captured as institutional memory? + +2. **Ask the human**: "Any corrections or additions?" Wait for their response. + +3. **Write payload** to `.gitmem/closing-payload.json`: + ```json + { + "closing_reflection": { + "what_broke": "...", + "what_took_longer": "...", + "do_differently": "...", + "what_worked": "...", + "wrong_assumption": "...", + "scars_applied": ["scar title 1", "scar title 2"], + "institutional_memory_items": "..." + }, + "human_corrections": "", + "scars_to_record": [], + "open_threads": [], + "decisions": [] + } + ``` + +4. **Call `session_close`** with `session_id` and `close_type: "standard"` + +For short exploratory sessions (< 30 min, no real work), use `close_type: "quick"` — no questions needed. + diff --git a/docs/architecture/local-storage.md b/docs/architecture/local-storage.md index 04cd39f..b6b5ba6 100644 --- a/docs/architecture/local-storage.md +++ b/docs/architecture/local-storage.md @@ -124,13 +124,13 @@ docker run -v gitmem-data:/app/.gitmem ... | **Pro/Dev** | **Works perfectly** | Local files are caches; Supabase is SOT | | **Free** | **Works** | Local files ARE the SOT; volume mount preserves them | -### Scenario: Shared container (long-running, like our Docker setup) +### Scenario: Shared container (long-running) ``` Container stays alive across multiple `claude` invocations ``` -Both tiers work. This is our current setup. `.gitmem/` persists because the container persists. +Both tiers work. `.gitmem/` persists because the container persists. ## Recommendations for Container Deployments @@ -160,7 +160,7 @@ volumes: ### What about Claude Code transcripts? -The `~/.claude/projects/` directory accumulates conversation transcripts (~2MB each, 167 files = 356MB in our container). These are: +The `~/.claude/projects/` directory accumulates conversation transcripts (~2MB each). In long-running containers, these can grow to hundreds of megabytes. These are: - Created by Claude Code, not GitMem - Read by GitMem during transcript capture (pro/dev `session_close`) - Never cleaned up automatically diff --git a/package.json b/package.json index 731da37..9607afa 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "schema", "CLAUDE.md.template", "cursorrules.template", + "copilot-instructions.template", + "windsurfrules.template", "README.md", "CHANGELOG.md" ], @@ -63,6 +65,10 @@ "gitmem", "claude", "cursor", + "vscode", + "copilot", + "windsurf", + "openclaw", "institutional-memory", "ai-coding", "scars", diff --git a/src/server.ts b/src/server.ts index 6e97dd6..2f2362d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -55,6 +55,7 @@ import { } from "./services/startup.js"; import { getEffectTracker } from "./services/effect-tracker.js"; import { getProject } from "./services/session-state.js"; +import { checkEnforcement } from "./services/enforcement.js"; import { getTier, hasSupabase, @@ -137,6 +138,9 @@ export function createServer(): Server { }; } + // Server-side enforcement: advisory warnings for protocol violations + const enforcement = checkEnforcement(name); + try { let result: unknown; @@ -385,6 +389,11 @@ export function createServer(): Server { responseText = JSON.stringify(result, null, 2); } + // Prepend enforcement warning if present (advisory, non-blocking) + if (enforcement.warning) { + responseText = enforcement.warning + "\n\n" + responseText; + } + return { content: [ { diff --git a/src/services/enforcement.ts b/src/services/enforcement.ts new file mode 100644 index 0000000..7a7fcc5 --- /dev/null +++ b/src/services/enforcement.ts @@ -0,0 +1,142 @@ +/** + * Server-Side Enforcement Layer + * + * Advisory warnings that surface in tool responses when the agent + * hasn't followed the recall → confirm → act protocol. + * + * Design principles: + * - Advisory, not blocking: warnings append to responses, never prevent execution + * - Zero overhead on compliant calls: only fires when state is missing + * - Universal: works in ALL MCP clients, no IDE hooks needed + * - Lightweight: pure in-memory checks, no I/O + */ + +import { getCurrentSession, hasUnconfirmedScars, getSurfacedScars } from "./session-state.js"; + +export interface EnforcementResult { + /** Warning text to prepend to tool response (null = clean, no warning) */ + warning: string | null; +} + +/** + * Tools that require an active session to function properly. + * Read-only/administrative tools are excluded. + */ +const SESSION_REQUIRED_TOOLS = new Set([ + "recall", "gitmem-r", + "confirm_scars", "gitmem-cs", "gm-confirm", + "session_close", "gitmem-sc", "gm-close", + "session_refresh", "gitmem-sr", "gm-refresh", + "create_learning", "gitmem-cl", "gm-scar", + "create_decision", "gitmem-cd", + "record_scar_usage", "gitmem-rs", + "record_scar_usage_batch", "gitmem-rsb", + "prepare_context", "gitmem-pc", "gm-pc", + "absorb_observations", "gitmem-ao", "gm-absorb", + "create_thread", "gitmem-ct", "gm-thread-new", + "resolve_thread", "gitmem-rt", "gm-resolve", + "save_transcript", "gitmem-st", +]); + +/** + * Tools that represent "consequential actions" — the agent is creating + * or modifying state. These should ideally happen after recall + confirm. + */ +const CONSEQUENTIAL_TOOLS = new Set([ + "create_learning", "gitmem-cl", "gm-scar", + "create_decision", "gitmem-cd", + "create_thread", "gitmem-ct", "gm-thread-new", + "session_close", "gitmem-sc", "gm-close", +]); + +/** + * Tools that are always safe — no enforcement checks needed. + * Includes session_start (which creates the session), read-only tools, + * and administrative tools. + */ +const EXEMPT_TOOLS = new Set([ + "session_start", "gitmem-ss", "gm-open", + "search", "gitmem-search", "gm-search", + "log", "gitmem-log", "gm-log", + "analyze", "gitmem-analyze", "gm-analyze", + "graph_traverse", "gitmem-graph", "gm-graph", + "list_threads", "gitmem-lt", "gm-threads", + "cleanup_threads", "gitmem-cleanup", "gm-cleanup", + "promote_suggestion", "gitmem-ps", "gm-promote", + "dismiss_suggestion", "gitmem-ds", "gm-dismiss", + "archive_learning", "gitmem-al", "gm-archive", + "get_transcript", "gitmem-gt", + "search_transcripts", "gitmem-stx", "gm-stx", + "gitmem-help", + "health", "gitmem-health", "gm-health", + "gitmem-cache-status", "gm-cache-s", + "gitmem-cache-health", "gm-cache-h", + "gitmem-cache-flush", "gm-cache-f", +]); + +/** + * Run pre-dispatch enforcement checks for a tool call. + * + * Returns a warning string to prepend to the response, or null if clean. + * Never blocks execution — always advisory. + */ +export function checkEnforcement(toolName: string): EnforcementResult { + // Exempt tools skip all checks + if (EXEMPT_TOOLS.has(toolName)) { + return { warning: null }; + } + + const session = getCurrentSession(); + + // Check 1: No active session + if (!session && SESSION_REQUIRED_TOOLS.has(toolName)) { + return { + warning: [ + "--- gitmem enforcement ---", + "No active session. Call session_start() first to initialize memory context.", + "Without a session, scars won't be tracked and the closing ceremony can't run.", + "---", + ].join("\n"), + }; + } + + // If no session and not session-required, skip remaining checks + if (!session) { + return { warning: null }; + } + + // Check 2: Unconfirmed scars before consequential action + if (CONSEQUENTIAL_TOOLS.has(toolName) && hasUnconfirmedScars()) { + const recallScars = getSurfacedScars().filter(s => s.source === "recall"); + const confirmedCount = session.confirmations?.length || 0; + const pendingCount = recallScars.length - confirmedCount; + + return { + warning: [ + "--- gitmem enforcement ---", + `${pendingCount} recalled scar(s) await confirmation.`, + "Call confirm_scars() with APPLYING/N_A/REFUTED for each before proceeding.", + "Unconfirmed scars may contain warnings relevant to what you're about to do.", + "---", + ].join("\n"), + }; + } + + // Check 3: No recall before consequential action + if (CONSEQUENTIAL_TOOLS.has(toolName)) { + const recallScars = getSurfacedScars().filter(s => s.source === "recall"); + if (recallScars.length === 0) { + return { + warning: [ + "--- gitmem enforcement ---", + "No recall() was run this session before this action.", + "Consider calling recall() first to check for relevant institutional memory.", + "Past mistakes and patterns may prevent repeating known issues.", + "---", + ].join("\n"), + }; + } + } + + return { warning: null }; +} diff --git a/tests/unit/services/enforcement.test.ts b/tests/unit/services/enforcement.test.ts new file mode 100644 index 0000000..53cad75 --- /dev/null +++ b/tests/unit/services/enforcement.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { checkEnforcement } from "../../../src/services/enforcement.js"; +import { + setCurrentSession, + clearCurrentSession, + addSurfacedScars, + addConfirmations, +} from "../../../src/services/session-state.js"; + +describe("enforcement", () => { + afterEach(() => { + clearCurrentSession(); + }); + + describe("exempt tools", () => { + it("returns no warning for session_start", () => { + const result = checkEnforcement("session_start"); + expect(result.warning).toBeNull(); + }); + + it("returns no warning for gitmem-ss alias", () => { + const result = checkEnforcement("gitmem-ss"); + expect(result.warning).toBeNull(); + }); + + it("returns no warning for search without session", () => { + const result = checkEnforcement("search"); + expect(result.warning).toBeNull(); + }); + + it("returns no warning for gitmem-help", () => { + const result = checkEnforcement("gitmem-help"); + expect(result.warning).toBeNull(); + }); + + it("returns no warning for health", () => { + const result = checkEnforcement("health"); + expect(result.warning).toBeNull(); + }); + + it("returns no warning for list_threads", () => { + const result = checkEnforcement("list_threads"); + expect(result.warning).toBeNull(); + }); + + it("returns no warning for log", () => { + const result = checkEnforcement("gitmem-log"); + expect(result.warning).toBeNull(); + }); + + it("returns no warning for cache tools", () => { + expect(checkEnforcement("gitmem-cache-status").warning).toBeNull(); + expect(checkEnforcement("gitmem-cache-health").warning).toBeNull(); + expect(checkEnforcement("gitmem-cache-flush").warning).toBeNull(); + }); + }); + + describe("no session warnings", () => { + it("warns when recall called without session", () => { + const result = checkEnforcement("recall"); + expect(result.warning).toContain("No active session"); + expect(result.warning).toContain("session_start()"); + }); + + it("warns for gitmem-r alias without session", () => { + const result = checkEnforcement("gitmem-r"); + expect(result.warning).toContain("No active session"); + }); + + it("warns when create_learning called without session", () => { + const result = checkEnforcement("create_learning"); + expect(result.warning).toContain("No active session"); + }); + + it("warns when session_close called without session", () => { + const result = checkEnforcement("session_close"); + expect(result.warning).toContain("No active session"); + }); + + it("warns for confirm_scars without session", () => { + const result = checkEnforcement("confirm_scars"); + expect(result.warning).toContain("No active session"); + }); + }); + + describe("no recall warnings", () => { + beforeEach(() => { + setCurrentSession({ + sessionId: "test-session-1", + agent: "cli", + project: "test", + startedAt: new Date(), + }); + }); + + it("warns when create_learning called without recall", () => { + const result = checkEnforcement("create_learning"); + expect(result.warning).toContain("No recall()"); + expect(result.warning).toContain("institutional memory"); + }); + + it("warns for gitmem-cl alias without recall", () => { + const result = checkEnforcement("gitmem-cl"); + expect(result.warning).toContain("No recall()"); + }); + + it("warns when create_decision called without recall", () => { + const result = checkEnforcement("create_decision"); + expect(result.warning).toContain("No recall()"); + }); + + it("warns when create_thread called without recall", () => { + const result = checkEnforcement("create_thread"); + expect(result.warning).toContain("No recall()"); + }); + + it("warns when session_close called without recall", () => { + const result = checkEnforcement("session_close"); + expect(result.warning).toContain("No recall()"); + }); + + it("does not warn for recall itself", () => { + const result = checkEnforcement("recall"); + expect(result.warning).toBeNull(); + }); + + it("does not warn for confirm_scars", () => { + const result = checkEnforcement("confirm_scars"); + expect(result.warning).toBeNull(); + }); + + it("does not warn for non-consequential session-required tools", () => { + // record_scar_usage is session-required but not consequential + const result = checkEnforcement("record_scar_usage"); + expect(result.warning).toBeNull(); + }); + }); + + describe("unconfirmed scars warnings", () => { + beforeEach(() => { + setCurrentSession({ + sessionId: "test-session-2", + agent: "cli", + project: "test", + startedAt: new Date(), + }); + + // Simulate recall surfacing scars + addSurfacedScars([ + { + scar_id: "scar-1", + title: "Test scar 1", + source: "recall", + surfaced_at: new Date().toISOString(), + }, + { + scar_id: "scar-2", + title: "Test scar 2", + source: "recall", + surfaced_at: new Date().toISOString(), + }, + ]); + }); + + it("warns when consequential action called with unconfirmed scars", () => { + const result = checkEnforcement("create_learning"); + expect(result.warning).toContain("2 recalled scar(s) await confirmation"); + expect(result.warning).toContain("confirm_scars()"); + }); + + it("warns for session_close with unconfirmed scars", () => { + const result = checkEnforcement("session_close"); + expect(result.warning).toContain("await confirmation"); + }); + + it("clears warning after all scars confirmed", () => { + addConfirmations([ + { scar_id: "scar-1", decision: "APPLYING", evidence: "Applied the lesson in my implementation approach for this task" }, + { scar_id: "scar-2", decision: "N_A", evidence: "This scar relates to database migrations which is not what we are doing here" }, + ]); + + const result = checkEnforcement("create_learning"); + // Should now show "no recall" warning (not "unconfirmed") since scars are confirmed + // Actually wait — scars WERE recalled, so no warning at all + expect(result.warning).toBeNull(); + }); + + it("still warns with partial confirmation", () => { + addConfirmations([ + { scar_id: "scar-1", decision: "APPLYING", evidence: "Applied the lesson in my implementation approach for this task" }, + ]); + + const result = checkEnforcement("create_learning"); + expect(result.warning).toContain("1 recalled scar(s) await confirmation"); + }); + + it("does not warn for session_start scars (no confirmation needed)", () => { + clearCurrentSession(); + setCurrentSession({ + sessionId: "test-session-3", + agent: "cli", + project: "test", + startedAt: new Date(), + }); + + // Session_start scars have source "session_start", not "recall" + addSurfacedScars([ + { + scar_id: "scar-3", + title: "Auto-surfaced scar", + source: "session_start", + surfaced_at: new Date().toISOString(), + }, + ]); + + // Has a session_start scar but no recall scars — should warn about no recall + const result = checkEnforcement("create_learning"); + expect(result.warning).toContain("No recall()"); + // Should NOT say "await confirmation" + expect(result.warning).not.toContain("await confirmation"); + }); + }); + + describe("clean pass (no warnings)", () => { + beforeEach(() => { + setCurrentSession({ + sessionId: "test-session-clean", + agent: "cli", + project: "test", + startedAt: new Date(), + }); + + addSurfacedScars([ + { + scar_id: "scar-a", + title: "Confirmed scar", + source: "recall", + surfaced_at: new Date().toISOString(), + }, + ]); + + addConfirmations([ + { scar_id: "scar-a", decision: "APPLYING", evidence: "Verified the enforcement layer handles this case correctly in the implementation" }, + ]); + }); + + it("returns null warning for create_learning after full compliance", () => { + const result = checkEnforcement("create_learning"); + expect(result.warning).toBeNull(); + }); + + it("returns null warning for create_decision after full compliance", () => { + const result = checkEnforcement("create_decision"); + expect(result.warning).toBeNull(); + }); + + it("returns null warning for session_close after full compliance", () => { + const result = checkEnforcement("session_close"); + expect(result.warning).toBeNull(); + }); + }); + + describe("unknown tools", () => { + it("returns no warning for completely unknown tools", () => { + // Unknown tools are handled by the tier guard in server.ts, not enforcement + const result = checkEnforcement("nonexistent_tool"); + expect(result.warning).toBeNull(); + }); + }); +}); diff --git a/windsurfrules.template b/windsurfrules.template new file mode 100644 index 0000000..cd37c23 --- /dev/null +++ b/windsurfrules.template @@ -0,0 +1,82 @@ +# --- gitmem:start --- +# GitMem — Persistent Memory + +You have a persistent memory layer called gitmem. Every session starts from +zero — but gitmem carries forward lessons, mistakes, wins, decisions, and +open threads across sessions and projects. Search it. Test it. You might be +amazed at what's already there. If convinced, make it your own. + +## Red Lines + +### Credential Protection + +Credential exposure in conversation history is permanent and irreversible. + +1. **NEVER read credential files in full.** Files like `mcp-config.json`, `.env`, `.credentials.json`, `.netrc`, `.npmrc`, SSH keys, or `.pem`/`.key` files must not be read or dumped. +2. **NEVER print environment variable values that contain secrets.** Commands like `env | grep KEY`, `echo $API_KEY`, or `printenv TOKEN` expose credentials in output. +3. **NEVER display API keys, tokens, or secrets in conversation output.** + +Safe alternatives: `env | grep -c VARNAME` (count only), `[ -n "$VARNAME" ] && echo "set"` (existence check), `grep -c '"key"' config.json` (structure check). + +### Recall Before Consequential Actions + +1. **NEVER parallelize `recall()` with actions that expose, modify, or transmit sensitive data.** Recall must complete first. +2. **Confirm scars before acting.** Each recalled scar requires APPLYING (past-tense evidence), N_A (explanation), or REFUTED (risk acknowledgment). +3. **Parallel recall is only safe with benign reads** — source code, docs, non-sensitive config. + +## Tools + +| Tool | When to use | +|------|-------------| +| `recall` | Before any task — surfaces relevant warnings from past experience | +| `confirm_scars` | After recall — acknowledge each scar as APPLYING, N_A, or REFUTED | +| `search` | Explore institutional knowledge by topic | +| `log` | Browse recent learnings chronologically | +| `session_start` | Beginning of session — loads last session context and open threads | +| `session_close` | End of session — persists what you learned | +| `create_learning` | Capture a mistake (scar), success (win), or pattern | +| `create_decision` | Log an architectural or operational decision with rationale | +| `list_threads` | See unresolved work carrying over between sessions | +| `create_thread` | Track something that needs follow-up in a future session | +| `help` | Show all available commands | + +## Session Lifecycle + +**Start:** Call `session_start()` at the beginning of every session. + +**End:** On "closing", "done for now", or "wrapping up": + +1. **Answer these reflection questions** and display to the human: + - What broke that you didn't expect? + - What took longer than it should have? + - What would you do differently next time? + - What pattern or approach worked well? + - What assumption was wrong? + - Which scars did you apply? + - What should be captured as institutional memory? + +2. **Ask the human**: "Any corrections or additions?" Wait for their response. + +3. **Write payload** to `.gitmem/closing-payload.json`: + ```json + { + "closing_reflection": { + "what_broke": "...", + "what_took_longer": "...", + "do_differently": "...", + "what_worked": "...", + "wrong_assumption": "...", + "scars_applied": ["scar title 1", "scar title 2"], + "institutional_memory_items": "..." + }, + "human_corrections": "", + "scars_to_record": [], + "open_threads": [], + "decisions": [] + } + ``` + +4. **Call `session_close`** with `session_id` and `close_type: "standard"` + +For short exploratory sessions (< 30 min, no real work), use `close_type: "quick"` — no questions needed. +# --- gitmem:end ---