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 ---