From 89b8a9c8cfdefe228d28b20fe5c9ebba0543cdd9 Mon Sep 17 00:00:00 2001 From: Mihael Bosnjak Date: Mon, 6 Apr 2026 18:57:17 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20memory=20placement=20=E2=80=94=20local?= =?UTF-8?q?=20vs=20shared=20config=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When setting up memory, users now choose where config goes: - Shared (default): CLAUDE.md + settings.json (committed, team-visible) - Local: .claude/CLAUDE.md + settings.local.json (gitignored, private) Solves co-dev conflict where one dev uses agentic-memory and the other uses built-in memory. Choice is persisted so doctor --fix won't re-ask. Also fixes: doctor --fix no longer injects memory guidance on projects without agentic-memory installed. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/lp-enhance/SKILL.md | 10 ++-- BACKLOG.md | 7 +++ CHANGELOG.md | 15 +++++ README.md | 7 +++ TASKS.md | 4 ++ docs/content/docs/changelog.mdx | 15 +++++ docs/content/docs/memory.mdx | 27 ++++++++- docs/content/docs/migrate-memory.mdx | 2 +- package.json | 2 +- src/cli.ts | 2 +- src/commands/doctor/analyzers/memory.ts | 25 ++++---- src/commands/doctor/analyzers/quality.ts | 3 +- src/commands/doctor/fixer.ts | 58 +++++++++++------- src/commands/init/index.ts | 1 + src/commands/memory/index.ts | 38 ++++++++---- src/commands/memory/subcommands/install.ts | 68 ++++++++++++++++------ src/lib/memory-placement.ts | 24 ++++++++ src/lib/parser.ts | 13 ++++- src/lib/settings.ts | 16 +++++ src/types/index.ts | 4 ++ tests/backlog.test.ts | 2 + tests/budget-analyzer.test.ts | 2 + tests/hooks-analyzer.test.ts | 2 + tests/mcp-analyzer.test.ts | 2 + tests/memory-analyzer.test.ts | 63 +++++++++++++++++--- tests/permissions-analyzer.test.ts | 2 + tests/quality-analyzer.test.ts | 2 + tests/settings-analyzer.test.ts | 2 + 28 files changed, 337 insertions(+), 81 deletions(-) create mode 100644 src/lib/memory-placement.ts diff --git a/.claude/skills/lp-enhance/SKILL.md b/.claude/skills/lp-enhance/SKILL.md index 810d74c..7b2b9cc 100644 --- a/.claude/skills/lp-enhance/SKILL.md +++ b/.claude/skills/lp-enhance/SKILL.md @@ -17,9 +17,11 @@ Read CLAUDE.md and the project's codebase, then update CLAUDE.md to fill in miss ## Phase 1: Research 1. Read CLAUDE.md (if it exists) -2. Read .claude/settings.json (hooks, permissions, MCP) -3. Read .claude/rules/*.md (existing rules) -4. Read .claudeignore (if it exists) +2. Read .claude/CLAUDE.md (local config, if it exists) +3. Read .claude/settings.json (hooks, permissions, MCP) +4. Read .claude/settings.local.json (local settings, if it exists) +5. Read .claude/rules/*.md (existing rules) +6. Read .claudeignore (if it exists) 5. Scan src/ directory structure (top-level dirs, key files) 6. Read package.json / go.mod / pyproject.toml for stack detection 7. Check for monorepo indicators (workspaces, nx.json, lerna.json) @@ -35,7 +37,7 @@ Count current CLAUDE.md actionable lines. Budget is 200 lines max. Plan which se 2. **## Architecture** - 3-5 bullets describing codebase shape 3. **## Conventions** - max 8 key patterns. Overflow to .claude/rules/conventions.md 4. **## Off-Limits** - max 8 guardrails specific to this project -5. **## Memory** - ONLY if agentic-memory is configured in settings.json. Max 6 bullets. +5. **## Memory** - ONLY if agentic-memory is configured in settings.json or settings.local.json. If memory config is in settings.local.json, write ## Memory to .claude/CLAUDE.md (not root CLAUDE.md). Max 6 bullets. 6. **## Key Decisions** - only decisions that affect how Claude works in this codebase 7. **Skill Authoring** - if .claude/rules/conventions.md lacks a Skill Authoring section, plan to add one diff --git a/BACKLOG.md b/BACKLOG.md index 2568b75..42aaab8 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -14,6 +14,13 @@ Maximal Marginal Relevance for injection. Prevents injecting 5 memories on the s ## [P2] Memory: Exploration Slots Reserve 1/8 injection slots for random discovery. Memories that get searched after injection rise in rank; ones that don't fade out. Multi-armed bandit without ML training. Matters at 100+ memories. +## [P2] Refactor: Immutability Violations in Fixer and Install +Pre-existing mutation patterns in `src/commands/doctor/fixer.ts` and `src/commands/memory/subcommands/install.ts`: +- `fixer.ts:438` — `delete hooks.Stop` mutates directly, should create new object without key +- `fixer.ts:122` — `hookList.push(entry)` mutates array before spread +- `install.ts:96,102,128,150` — direct assignment to settings/hooks objects (`settings['autoMemoryEnabled'] = false`, `allowList.push(tool)`) +All violate the immutability convention. Low risk (works fine), but should be cleaned up to match project standards. + ## [P1] Docs: Command Responsibility Matrix Add a matrix table to docs showing every command and skill with what it does: | Responsibility | `init` | `doctor --fix` | `/lp-enhance` | `eval` | `memory` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7c9be..5560218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [0.15.0] — 2026-04-06 + +### Added +- Memory placement prompt: choose shared (CLAUDE.md + settings.json) or local (.claude/CLAUDE.md + settings.local.json) when setting up memory +- Local placement keeps memory config gitignored — co-devs never see it +- `doctor --fix` and `memory install` respect placement choice for all memory-related writes +- Quality and memory analyzers check both shared and local files +- `.claude/.gitignore` now includes `CLAUDE.md` for local config support +- Doctor and analyzers now read `.claude/CLAUDE.md` and `settings.local.json` +- `isMemoryInstalled()` checks both settings files for memory hooks + +### Fixed +- `doctor --fix` injected memory guidance into CLAUDE.md on projects without agentic-memory installed — now only triggers when the MCP server is configured in project settings +- Skills (lp-migrate-memory) no longer installed when using local placement + ## [0.14.2] — 2026-04-06 ### Fixed diff --git a/README.md b/README.md index 5e233de..2eedc32 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,13 @@ claude-launchpad memory If memory is not installed, it runs interactive setup. If installed, it shows stats. Requires native deps first: `npm install better-sqlite3 sqlite-vec`. +During setup, you choose where memory config lives: + +- **Shared** (default) — config goes to `CLAUDE.md` + `settings.json` (committed, team sees it) +- **Local** — config goes to `.claude/CLAUDE.md` + `settings.local.json` (gitignored, only you) + +Use "local" when co-devs have different memory setups (e.g. you use agentic-memory, they use built-in). Your choice is persisted so `doctor --fix` won't re-ask. + Every session, Claude loads what it needs to know and stores new knowledge as it works. Stale facts fade on their own. Knowledge Claude actually uses gets reinforced. Each project has its own isolated memory, and you can sync it across machines via private GitHub Gist. Browse everything with `--dashboard` -- a terminal UI with vim navigation, filtering, and search. diff --git a/TASKS.md b/TASKS.md index e2e19c3..4e5f5a7 100644 --- a/TASKS.md +++ b/TASKS.md @@ -41,3 +41,7 @@ All planned work complete. Future features in BACKLOG.md. - Fix: memory retrieval truncation (500 char slice), store Zod max removal, MCP server version from package.json. - Feat: /lp-enhance eval scenario gen + .claudeignore review + skill auto-update via doctor --fix. Karpathy-inspired copy rewrite. SEO pass. - Published v0.12.2, v0.13.0, v0.13.1, v0.14.0. +### 2026-04-06 (session 25-26) +- Fix: doctor --fix injected memory guidance into CLAUDE.md on non-memory projects (v0.14.3). +- Feat: Memory placement — interactive local vs shared config routing. Parser reads both files, analyzers check both, fixer routes writes based on placement. +- Published v0.14.3, v0.15.0. diff --git a/docs/content/docs/changelog.mdx b/docs/content/docs/changelog.mdx index b2f4bc4..2f3a788 100644 --- a/docs/content/docs/changelog.mdx +++ b/docs/content/docs/changelog.mdx @@ -5,6 +5,21 @@ description: Release history for Claude Launchpad. {/* Auto-generated from CHANGELOG.md - do not edit directly */} +## [0.15.0] — 2026-04-06 + +### Added +- Memory placement prompt: choose shared (CLAUDE.md + settings.json) or local (.claude/CLAUDE.md + settings.local.json) when setting up memory +- Local placement keeps memory config gitignored — co-devs never see it +- `doctor --fix` and `memory install` respect placement choice for all memory-related writes +- Quality and memory analyzers check both shared and local files +- `.claude/.gitignore` now includes `CLAUDE.md` for local config support +- Doctor and analyzers now read `.claude/CLAUDE.md` and `settings.local.json` +- `isMemoryInstalled()` checks both settings files for memory hooks + +### Fixed +- `doctor --fix` injected memory guidance into CLAUDE.md on projects without agentic-memory installed — now only triggers when the MCP server is configured in project settings +- Skills (lp-migrate-memory) no longer installed when using local placement + ## [0.14.2] — 2026-04-06 ### Fixed diff --git a/docs/content/docs/memory.mdx b/docs/content/docs/memory.mdx index a136240..75bba62 100644 --- a/docs/content/docs/memory.mdx +++ b/docs/content/docs/memory.mdx @@ -52,8 +52,8 @@ Running `claude-launchpad memory` without flags uses a smart default: - SQLite database at `~/.agentic-memory/` (shared across projects, scoped per project internally) - SessionStart hook that injects relevant memories at session start - 7 MCP tools for storing, searching, and managing memories -- Memory guidance section in CLAUDE.md -- `/lp-migrate-memory` skill for porting legacy auto-memory files +- Memory guidance section in CLAUDE.md (or `.claude/CLAUDE.md` for local placement) +- `/lp-migrate-memory` skill for porting legacy auto-memory files (shared placement only) - Global MCP server registration via `claude mcp add --scope user` If auto-registration fails, run the command manually: @@ -62,6 +62,27 @@ If auto-registration fails, run the command manually: claude mcp add --scope user agentic-memory -- npx claude-launchpad memory serve ``` +## Shared vs local placement + +During setup, you choose where memory config lives: + +``` +? Where should memory config go? +❯ Shared (team sees it) — CLAUDE.md + settings.json + Local (only you) — .claude/CLAUDE.md + settings.local.json +``` + +**Shared** (default) writes memory guidance, hooks, and tool permissions to committed project files. Your whole team sees the memory setup. + +**Local** writes everything to gitignored files (`.claude/CLAUDE.md` and `.claude/settings.local.json`). Co-devs never see your memory config and can use their own setup (built-in auto-memory, a different MCP server, or nothing). + +Your choice is persisted in `settings.local.json` so `doctor --fix` applies memory fixes to the correct files without re-asking. + +Use local when: +- You share a repo with devs who use different memory setups +- You want to try agentic-memory without affecting the team config +- The project's shared CLAUDE.md is managed by someone else + ## Memory types Not all knowledge ages the same way. A bug you fixed last Tuesday matters less next month. Your project's architecture matters for years. Memory types reflect this: @@ -172,7 +193,7 @@ When memory is detected, `doctor` adds a Memory analyzer that checks: - CLAUDE.md guidance present - MCP tool permissions set -`doctor --fix` auto-repairs missing permissions and auto-memory settings. +`doctor --fix` auto-repairs missing permissions and auto-memory settings. The fixer respects your placement choice, writing to the local or shared files accordingly. ## How it works under the hood diff --git a/docs/content/docs/migrate-memory.mdx b/docs/content/docs/migrate-memory.mdx index 98b078b..ecf5233 100644 --- a/docs/content/docs/migrate-memory.mdx +++ b/docs/content/docs/migrate-memory.mdx @@ -3,7 +3,7 @@ title: /lp-migrate-memory description: Migrate legacy Claude Code auto-memory files into agentic-memory. --- -Skill that ports your existing `~/.claude/projects/*/memory/*.md` files into the agentic-memory system. Installed automatically when you run `claude-launchpad memory`. +Skill that ports your existing `~/.claude/projects/*/memory/*.md` files into the agentic-memory system. Installed automatically when you run `claude-launchpad memory` with shared placement. For local placement, run the skill manually with `/lp-migrate-memory` inside Claude Code. ``` /lp-migrate-memory diff --git a/package.json b/package.json index 453997c..77f6948 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-launchpad", - "version": "0.14.2", + "version": "0.15.0", "description": "CLI toolkit for Claude Code — scaffold CLAUDE.md, diagnose config, enforce hooks, test with eval, add persistent memory", "type": "module", "bin": { diff --git a/src/cli.ts b/src/cli.ts index 586e456..dfcb158 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,7 +10,7 @@ import { fileExists } from "./lib/fs-utils.js"; const program = new Command() .name("claude-launchpad") .description("CLI toolkit that makes Claude Code setups measurably good") - .version("0.14.2", "-v, --version") + .version("0.15.0", "-v, --version") .action(async () => { // Default behavior: detect existing config and route accordingly const hasConfig = await fileExists(join(process.cwd(), "CLAUDE.md")) diff --git a/src/commands/doctor/analyzers/memory.ts b/src/commands/doctor/analyzers/memory.ts index da26052..2cb4629 100644 --- a/src/commands/doctor/analyzers/memory.ts +++ b/src/commands/doctor/analyzers/memory.ts @@ -11,11 +11,7 @@ const MEMORY_MCP_TOOLS = [ ] as const; export function hasMemoryIndicators(config: ClaudeConfig): boolean { - const hasMcpServer = config.mcpServers.some((s) => s.name === "agentic-memory"); - const hasHookRef = config.hooks.some( - (h) => h.command?.includes("memory context"), - ); - return hasMcpServer || hasHookRef; + return config.mcpServers.some((s) => s.name === "agentic-memory"); } /** @@ -54,19 +50,22 @@ export async function analyzeMemory(config: ClaudeConfig): Promise | undefined) ?? {}; - const allowList = (permissions.allow as string[] | undefined) ?? []; + const localPermissions = (config.localSettings?.permissions as Record | undefined) ?? {}; + const allowList = [ + ...((permissions.allow as string[] | undefined) ?? []), + ...((localPermissions.allow as string[] | undefined) ?? []), + ]; const missingTools = MEMORY_MCP_TOOLS.filter((t) => !allowList.includes(t)); if (missingTools.length > 0) { issues.push({ diff --git a/src/commands/doctor/analyzers/quality.ts b/src/commands/doctor/analyzers/quality.ts index 40534b5..cf5bb2a 100644 --- a/src/commands/doctor/analyzers/quality.ts +++ b/src/commands/doctor/analyzers/quality.ts @@ -44,9 +44,10 @@ export async function analyzeQuality(config: ClaudeConfig): Promise { const detected = await detectProject(projectRoot); + const hasMemoryIssues = issues.some((i) => i.analyzer === "Memory"); + const placement = hasMemoryIssues ? await getMemoryPlacement(projectRoot) : "shared"; let fixed = 0; let skipped = 0; for (const issue of issues) { - const applied = await tryFix(issue, projectRoot, detected); + const applied = await tryFix(issue, projectRoot, detected, placement); if (applied) { fixed++; } else { @@ -39,7 +42,7 @@ export async function applyFixes( } // Fix lookup table: [analyzer, message substring] → fix function -type FixFn = (root: string, detected: DetectedProject) => Promise; +type FixFn = (root: string, detected: DetectedProject, placement: MemoryPlacement) => Promise; const FIX_TABLE: ReadonlyArray<{ analyzer: string; match: string; fix: FixFn }> = [ { analyzer: "Hooks", match: "No hooks configured", fix: async (root, detected) => { @@ -76,20 +79,25 @@ const FIX_TABLE: ReadonlyArray<{ analyzer: string; match: string; fix: FixFn }> { analyzer: "Settings", match: "Deprecated includeCoAuthoredBy", fix: (root) => migrateAttribution(root) }, { analyzer: "Hooks", match: "SessionStart", fix: (root) => addSessionStartHook(root) }, { analyzer: "Memory", match: "Deprecated Stop hook", fix: (root) => removeStaleStopHook(root) }, - { analyzer: "Memory", match: "autoMemoryEnabled not disabled", fix: (root) => disableAutoMemory(root) }, - { analyzer: "Memory", match: "MCP tool permission", fix: (root) => addMemoryToolPermissions(root) }, - { analyzer: "Memory", match: "CLAUDE.md missing memory guidance", fix: (root) => addClaudeMdSection(root, "## Memory", "Use agentic-memory to persist knowledge across sessions:\n- Memories are automatically injected at session start\n- STORE IMMEDIATELY when: a dependency strategy changes, an architecture decision is made, a convention is established, a bug pattern is discovered, or a feature is killed/added\n- Use memory_search before memory_store to check for duplicates\n- NEVER store credentials, API keys, tokens, or secrets in memories") }, + { analyzer: "Memory", match: "autoMemoryEnabled not disabled", fix: (root, _det, placement) => disableAutoMemory(root, placement) }, + { analyzer: "Memory", match: "MCP tool permission", fix: (root, _det, placement) => addMemoryToolPermissions(root, placement) }, + { analyzer: "Memory", match: "CLAUDE.md missing memory guidance", fix: (root, _det, placement) => { + const content = "Use agentic-memory to persist knowledge across sessions:\n- Memories are automatically injected at session start\n- STORE IMMEDIATELY when: a dependency strategy changes, an architecture decision is made, a convention is established, a bug pattern is discovered, or a feature is killed/added\n- Use memory_search before memory_store to check for duplicates\n- NEVER store credentials, API keys, tokens, or secrets in memories"; + const target = placement === "local" ? join(root, ".claude", "CLAUDE.md") : undefined; + return addClaudeMdSection(root, "## Memory", content, target); + }}, ]; async function tryFix( issue: DiagnosticIssue, root: string, detected: DetectedProject, + placement: MemoryPlacement, ): Promise { const entry = FIX_TABLE.find( (e) => e.analyzer === issue.analyzer && issue.message.includes(e.match), ); - return entry ? entry.fix(root, detected) : false; + return entry ? entry.fix(root, detected, placement) : false; } // ─── Hook Helper ─── @@ -250,13 +258,16 @@ async function addEnvToClaudeignore(root: string): Promise { return true; } -async function addClaudeMdSection(root: string, heading: string, content: string): Promise { - const claudeMdPath = join(root, "CLAUDE.md"); +async function addClaudeMdSection(root: string, heading: string, content: string, targetPath?: string): Promise { + const claudeMdPath = targetPath ?? join(root, "CLAUDE.md"); let existing: string; try { existing = await readFile(claudeMdPath, "utf-8"); } catch { - return false; // No CLAUDE.md to add to + if (!targetPath) return false; // No root CLAUDE.md to add to + // Create local .claude/CLAUDE.md + await mkdir(join(root, ".claude"), { recursive: true }); + existing = "# Local Claude Config\n"; } // Don't add if section already exists @@ -270,7 +281,8 @@ async function addClaudeMdSection(root: string, heading: string, content: string const updated = existing.slice(0, insertAt) + section + existing.slice(insertAt); await writeFile(claudeMdPath, updated); - log.success(`Added "${heading}" section to CLAUDE.md`); + const label = targetPath ? ".claude/CLAUDE.md" : "CLAUDE.md"; + log.success(`Added "${heading}" section to ${label}`); return true; } @@ -334,18 +346,23 @@ async function createStarterRules(root: string): Promise { return true; } -async function disableAutoMemory(root: string): Promise { - const settings = await readSettingsJson(root); +async function disableAutoMemory(root: string, placement: MemoryPlacement): Promise { + const read = placement === "local" ? readSettingsLocalJson : readSettingsJson; + const write = placement === "local" ? writeSettingsLocalJson : writeSettingsJson; + const settings = await read(root); if (settings.autoMemoryEnabled === false) return false; (settings as Record).autoMemoryEnabled = false; - await writeSettingsJson(root, settings); - log.success("Set autoMemoryEnabled: false (prevents conflict with agentic-memory)"); + await write(root, settings); + const target = placement === "local" ? "settings.local.json" : "settings.json"; + log.success(`Set autoMemoryEnabled: false in ${target}`); return true; } -async function addMemoryToolPermissions(root: string): Promise { - const settings = await readSettingsJson(root); +async function addMemoryToolPermissions(root: string, placement: MemoryPlacement): Promise { + const read = placement === "local" ? readSettingsLocalJson : readSettingsJson; + const write = placement === "local" ? writeSettingsLocalJson : writeSettingsJson; + const settings = await read(root); const permissions = (settings.permissions ?? {}) as Record; const allow = (permissions.allow as string[] | undefined) ?? []; @@ -363,8 +380,9 @@ async function addMemoryToolPermissions(root: string): Promise { if (missing.length === 0) return false; (settings as Record).permissions = { ...permissions, allow: [...allow, ...missing] }; - await writeSettingsJson(root, settings); - log.success("Added agentic-memory MCP tool permissions to allowedTools"); + await write(root, settings); + const target = placement === "local" ? "settings.local.json" : "settings.json"; + log.success(`Added agentic-memory MCP tool permissions to ${target}`); return true; } diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index a848093..fc7add4 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -111,6 +111,7 @@ async function scaffold(root: string, options: InitOptions, detected: DetectedPr if (!hasClaudeGitignore) { writes.push(writeFile(claudeGitignorePath, [ "# Local-only Claude Code files (never commit these)", + "CLAUDE.md", "settings.local.json", "plans/", "memory/", diff --git a/src/commands/memory/index.ts b/src/commands/memory/index.ts index 6eef3ca..8bb0fc0 100644 --- a/src/commands/memory/index.ts +++ b/src/commands/memory/index.ts @@ -5,9 +5,14 @@ import { confirm } from "@inquirer/prompts"; import { log } from "../../lib/output.js"; function isMemoryInstalled(): boolean { + const cwd = process.cwd(); + return hasMemoryHook(join(cwd, ".claude", "settings.json")) + || hasMemoryHook(join(cwd, ".claude", "settings.local.json")); +} + +function hasMemoryHook(path: string): boolean { try { - const settingsPath = join(process.cwd(), ".claude", "settings.json"); - const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record; + const settings = JSON.parse(readFileSync(path, "utf-8")) as Record; const hooks = settings.hooks as Record | undefined; if (!hooks) return false; const sessionStart = hooks.SessionStart as Record[] | undefined; @@ -39,14 +44,27 @@ export function createMemoryCommand(): Command { // Smart default: install or show stats if (!isMemoryInstalled()) { - log.blank(); - log.step("Claude doesn't have a knowledge base for this project yet."); - log.blank(); - log.info("After setup, Claude will:"); - log.info(" - Remember decisions, gotchas, and learnings across sessions"); - log.info(" - Automatically recall relevant context when you start a session"); - log.info(" - Save important facts as you work, so nothing gets lost"); - log.blank(); + // Check if config was already written (e.g. by doctor --fix) even though db isn't set up + const { detectExistingSetup } = await import("./subcommands/install.js"); + const existing = detectExistingSetup(process.cwd()); + if (existing) { + const location = existing === "local" + ? ".claude/CLAUDE.md + settings.local.json" + : "CLAUDE.md + settings.json"; + log.blank(); + log.success(`Memory config found (${location}) but database not set up.`); + log.info("Run the install to complete setup."); + log.blank(); + } else { + log.blank(); + log.step("Claude doesn't have a knowledge base for this project yet."); + log.blank(); + log.info("After setup, Claude will:"); + log.info(" - Remember decisions, gotchas, and learnings across sessions"); + log.info(" - Automatically recall relevant context when you start a session"); + log.info(" - Save important facts as you work, so nothing gets lost"); + log.blank(); + } const proceed = await confirm({ message: "Set up knowledge base?", diff --git a/src/commands/memory/subcommands/install.ts b/src/commands/memory/subcommands/install.ts index 0b0ed49..50acc8b 100644 --- a/src/commands/memory/subcommands/install.ts +++ b/src/commands/memory/subcommands/install.ts @@ -4,8 +4,10 @@ import { execSync } from 'node:child_process'; import { createDatabase, closeDatabase } from '../storage/database.js'; import { migrate } from '../storage/migrator.js'; import { loadConfig, resolveDataDir } from '../config.js'; -import { readSettingsJson, writeSettingsJson } from '../../../lib/settings.js'; +import { readSettingsJson, writeSettingsJson, readSettingsLocalJson, writeSettingsLocalJson } from '../../../lib/settings.js'; +import { getMemoryPlacement } from '../../../lib/memory-placement.js'; import { log } from '../../../lib/output.js'; +import type { MemoryPlacement } from '../../../types/index.js'; interface InstallOpts { readonly dbPath?: string; @@ -19,11 +21,14 @@ export async function runInstall(opts: InstallOpts): Promise { // Step 0: Ensure native deps are installed globally await ensureNativeDeps(); + // Prompt for placement before any config writes + const placement = await getMemoryPlacement(process.cwd()); + const config = loadConfig(opts.dbPath ? { dataDir: opts.dbPath } : undefined); const dataDir = resolveDataDir(config.dataDir); // Step 1: Database - log.step('[1/4] Creating knowledge base...'); + log.step('[1/5] Creating knowledge base...'); if (!existsSync(dataDir)) { mkdirSync(dataDir, { recursive: true }); } @@ -33,11 +38,11 @@ export async function runInstall(opts: InstallOpts): Promise { log.success(`Knowledge base created at ${dataDir}/memory.db`); // Step 2: Configure Claude Code settings - log.step('[2/4] Connecting to Claude Code...'); - await configureSettings(process.cwd()); + log.step('[2/5] Connecting to Claude Code...'); + await configureSettings(process.cwd(), placement); // Step 3: Register MCP server - log.step('[3/4] Enabling memory tools...'); + log.step('[3/5] Enabling memory tools...'); const registered = registerMcpServer(); if (registered) { log.success('Memory tools available in Claude Code'); @@ -47,14 +52,17 @@ export async function runInstall(opts: InstallOpts): Promise { } // Step 4: CLAUDE.md + skills - log.step('[4/4] Adding instructions...'); - const guidanceAdded = injectClaudeMdGuidance(process.cwd()); + log.step('[4/5] Adding instructions...'); + const guidanceAdded = injectClaudeMdGuidance(process.cwd(), placement); if (guidanceAdded) { - log.success('CLAUDE.md updated with memory instructions'); + const label = placement === "local" ? ".claude/CLAUDE.md" : "CLAUDE.md"; + log.success(`${label} updated with memory instructions`); } - const skillsInstalled = installSkills(process.cwd()); - if (skillsInstalled > 0) { - log.success(`Installed ${skillsInstalled} skill(s) to .claude/skills/`); + if (placement === "shared") { + const skillsInstalled = installSkills(process.cwd()); + if (skillsInstalled > 0) { + log.success(`Installed ${skillsInstalled} skill(s) to .claude/skills/`); + } } log.blank(); @@ -63,8 +71,26 @@ export async function runInstall(opts: InstallOpts): Promise { log.blank(); } -async function configureSettings(projectDir: string): Promise { - const settings = await readSettingsJson(projectDir); +export function detectExistingSetup(projectDir: string): MemoryPlacement | null { + // Check local CLAUDE.md + try { + const localClaude = readFileSync(join(projectDir, '.claude', 'CLAUDE.md'), 'utf-8'); + if (localClaude.includes('## Memory') || localClaude.includes('agentic-memory')) return "local"; + } catch { /* not found */ } + + // Check root CLAUDE.md + try { + const rootClaude = readFileSync(join(projectDir, 'CLAUDE.md'), 'utf-8'); + if (rootClaude.includes('## Memory (agentic-memory)')) return "shared"; + } catch { /* not found */ } + + return null; +} + +async function configureSettings(projectDir: string, placement: MemoryPlacement): Promise { + const read = placement === "local" ? readSettingsLocalJson : readSettingsJson; + const write = placement === "local" ? writeSettingsLocalJson : writeSettingsJson; + const settings = await read(projectDir); // Disable built-in auto-memory settings['autoMemoryEnabled'] = false; @@ -78,8 +104,9 @@ async function configureSettings(projectDir: string): Promise { // Auto-allow MCP tools addToolPermissions(settings); - await writeSettingsJson(projectDir, settings); - log.success('Claude Code configured for knowledge base'); + await write(projectDir, settings); + const target = placement === "local" ? "settings.local.json" : "settings.json"; + log.success(`Claude Code configured in ${target}`); } function addSessionStartHook(hooks: Record): void { @@ -184,14 +211,19 @@ This project uses **agentic-memory** for persistent memory across sessions. - **STORE IMMEDIATELY** when: a dependency strategy changes, an architecture decision is made, a convention is established, a bug pattern is discovered, or a feature is killed/added `; -function injectClaudeMdGuidance(projectDir: string): boolean { - const claudeMdPath = join(projectDir, 'CLAUDE.md'); +function injectClaudeMdGuidance(projectDir: string, placement: MemoryPlacement): boolean { + const claudeMdPath = placement === "local" + ? join(projectDir, '.claude', 'CLAUDE.md') + : join(projectDir, 'CLAUDE.md'); let content = ''; try { content = readFileSync(claudeMdPath, 'utf-8'); } catch { - return false; + if (placement !== "local") return false; + // Create local .claude/CLAUDE.md + mkdirSync(join(projectDir, '.claude'), { recursive: true }); + content = '# Local Claude Config\n'; } if (content.includes('## Memory (agentic-memory)')) { diff --git a/src/lib/memory-placement.ts b/src/lib/memory-placement.ts new file mode 100644 index 0000000..36301bd --- /dev/null +++ b/src/lib/memory-placement.ts @@ -0,0 +1,24 @@ +import { select } from "@inquirer/prompts"; +import { readSettingsLocalJson, writeSettingsLocalJson } from "./settings.js"; +import type { MemoryPlacement } from "../types/index.js"; + +export async function getMemoryPlacement(root: string, skipPrompt = false): Promise { + const local = await readSettingsLocalJson(root); + const persisted = local.memoryPlacement; + if (persisted === "shared" || persisted === "local") { + return persisted; + } + + if (skipPrompt) return "shared"; + + const choice = await select({ + message: "Where should memory config go?", + choices: [ + { value: "shared", name: "Shared (team sees it) — CLAUDE.md + settings.json" }, + { value: "local", name: "Local (only you) — .claude/CLAUDE.md + settings.local.json" }, + ], + }); + + await writeSettingsLocalJson(root, { ...local, memoryPlacement: choice }); + return choice; +} diff --git a/src/lib/parser.ts b/src/lib/parser.ts index 2000f16..5a765dd 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -6,15 +6,18 @@ import type { ClaudeConfig, HookConfig, McpServerConfig } from "../types/index.j const CLAUDE_MD = "CLAUDE.md"; const CLAUDE_DIR = ".claude"; const SETTINGS_FILE = "settings.json"; +const SETTINGS_LOCAL_FILE = "settings.local.json"; const RULES_DIR = "rules"; export async function parseClaudeConfig(projectRoot: string): Promise { const root = resolve(projectRoot); const claudeDir = join(root, CLAUDE_DIR); - const [claudeMd, settings, hooks, rules, mcpServers, skills, claudeignore] = await Promise.all([ + const [claudeMd, localClaudeMd, settings, localSettings, hooks, rules, mcpServers, skills, claudeignore] = await Promise.all([ readClaudeMd(root), + readFileOrNull(join(claudeDir, CLAUDE_MD)), readSettings(claudeDir), + readSettingsFromFile(claudeDir, SETTINGS_LOCAL_FILE), readHooks(claudeDir), readRules(claudeDir), readMcpServers(claudeDir), @@ -32,6 +35,8 @@ export async function parseClaudeConfig(projectRoot: string): Promise | null> { - const raw = await readFileOrNull(join(claudeDir, SETTINGS_FILE)); + return readSettingsFromFile(claudeDir, SETTINGS_FILE); +} + +async function readSettingsFromFile(claudeDir: string, filename: string): Promise | null> { + const raw = await readFileOrNull(join(claudeDir, filename)); if (raw === null) return null; try { return JSON.parse(raw) as Record; diff --git a/src/lib/settings.ts b/src/lib/settings.ts index b7226f9..b2794a7 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -16,3 +16,19 @@ export async function writeSettingsJson(root: string, settings: Record> { + const path = join(root, ".claude", "settings.local.json"); + try { + const content = await readFile(path, "utf-8"); + return JSON.parse(content) as Record; + } catch { + return {}; + } +} + +export async function writeSettingsLocalJson(root: string, settings: Record): Promise { + const dir = join(root, ".claude"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "settings.local.json"), JSON.stringify(settings, null, 2) + "\n"); +} diff --git a/src/types/index.ts b/src/types/index.ts index 871dd50..5fa2bad 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -91,12 +91,16 @@ export interface EvalReport { // ─── Config Parsing Types ─── +export type MemoryPlacement = "shared" | "local"; + export interface ClaudeConfig { readonly claudeMdPath: string | null; readonly claudeMdContent: string | null; readonly claudeMdInstructionCount: number; readonly settingsPath: string | null; readonly settings: Record | null; + readonly localClaudeMdContent: string | null; + readonly localSettings: Record | null; readonly hooks: ReadonlyArray; readonly rules: ReadonlyArray; readonly mcpServers: ReadonlyArray; diff --git a/tests/backlog.test.ts b/tests/backlog.test.ts index d81cb2b..8212dbf 100644 --- a/tests/backlog.test.ts +++ b/tests/backlog.test.ts @@ -16,6 +16,8 @@ function makeConfig(overrides: Partial = {}): ClaudeConfig { claudeMdInstructionCount: 10, settingsPath: null, settings: null, + localClaudeMdContent: null, + localSettings: null, hooks: [], rules: [], mcpServers: [], diff --git a/tests/budget-analyzer.test.ts b/tests/budget-analyzer.test.ts index 6f27866..aebea7f 100644 --- a/tests/budget-analyzer.test.ts +++ b/tests/budget-analyzer.test.ts @@ -9,6 +9,8 @@ function makeConfig(overrides: Partial = {}): ClaudeConfig { claudeMdInstructionCount: 2, settingsPath: null, settings: null, + localClaudeMdContent: null, + localSettings: null, hooks: [], rules: [], mcpServers: [], diff --git a/tests/hooks-analyzer.test.ts b/tests/hooks-analyzer.test.ts index 55c084f..23dd86e 100644 --- a/tests/hooks-analyzer.test.ts +++ b/tests/hooks-analyzer.test.ts @@ -9,6 +9,8 @@ function makeConfig(hooks: HookConfig[] = []): ClaudeConfig { claudeMdInstructionCount: 10, settingsPath: null, settings: null, + localClaudeMdContent: null, + localSettings: null, hooks, rules: [], mcpServers: [], diff --git a/tests/mcp-analyzer.test.ts b/tests/mcp-analyzer.test.ts index 6df8c17..bbc7410 100644 --- a/tests/mcp-analyzer.test.ts +++ b/tests/mcp-analyzer.test.ts @@ -9,6 +9,8 @@ function makeConfig(overrides: Partial = {}): ClaudeConfig { claudeMdInstructionCount: 10, settingsPath: null, settings: null, + localClaudeMdContent: null, + localSettings: null, hooks: [], rules: [], mcpServers: [], diff --git a/tests/memory-analyzer.test.ts b/tests/memory-analyzer.test.ts index 4810edc..6ff41ca 100644 --- a/tests/memory-analyzer.test.ts +++ b/tests/memory-analyzer.test.ts @@ -4,9 +4,11 @@ import type { ClaudeConfig, HookConfig, McpServerConfig } from "../src/types/ind function makeConfig(overrides: { settings?: Record | null; + localSettings?: Record | null; hooks?: ReadonlyArray; mcpServers?: ReadonlyArray; claudeMdContent?: string | null; + localClaudeMdContent?: string | null; } = {}): ClaudeConfig { return { claudeMdPath: "/test/CLAUDE.md", @@ -14,6 +16,8 @@ function makeConfig(overrides: { claudeMdInstructionCount: 10, settingsPath: "/test/.claude/settings.json", settings: overrides.settings ?? {}, + localClaudeMdContent: overrides.localClaudeMdContent ?? null, + localSettings: overrides.localSettings ?? null, hooks: overrides.hooks ?? [], rules: [], mcpServers: overrides.mcpServers ?? [], @@ -64,16 +68,9 @@ describe("analyzeMemory", () => { expect(result!.name).toBe("Memory"); }); - it("detects memory via hook command", async () => { + it("returns null when only hook references memory but no MCP server", async () => { const result = await analyzeMemory(makeConfig({ hooks: [sessionStartHook] })); - expect(result).not.toBeNull(); - }); - - it("does not flag MCP server (registered globally, not in project settings)", async () => { - const result = await analyzeMemory(makeConfig({ hooks: [sessionStartHook] })); - expect(result!.issues.some( - (i) => i.message.includes("MCP server not found"), - )).toBe(false); + expect(result).toBeNull(); }); it("flags missing SessionStart hook as high severity", async () => { @@ -164,6 +161,54 @@ describe("analyzeMemory", () => { expect(result!.issues).toHaveLength(0); }); + // ─── Local config detection ─── + + it("does not flag autoMemoryEnabled when set in local settings", async () => { + const result = await analyzeMemory(makeConfig({ + mcpServers: [memoryServer], + settings: {}, + localSettings: { autoMemoryEnabled: false }, + })); + expect(result!.issues.some( + (i) => i.message.includes("autoMemoryEnabled"), + )).toBe(false); + }); + + it("does not flag memory guidance when in local CLAUDE.md", async () => { + const result = await analyzeMemory(makeConfig({ + mcpServers: [memoryServer], + claudeMdContent: "# Test project", + localClaudeMdContent: "## Memory\nUse agentic-memory", + })); + expect(result!.issues.some( + (i) => i.message.includes("memory guidance"), + )).toBe(false); + }); + + it("does not flag tool permissions when in local settings", async () => { + const result = await analyzeMemory(makeConfig({ + mcpServers: [memoryServer], + settings: { permissions: { allow: [] } }, + localSettings: { permissions: { allow: ALL_TOOLS } }, + })); + expect(result!.issues.some( + (i) => i.message.includes("tool permission"), + )).toBe(false); + }); + + it("merges tool permissions from both settings files", async () => { + const half1 = ALL_TOOLS.slice(0, 4); + const half2 = ALL_TOOLS.slice(4); + const result = await analyzeMemory(makeConfig({ + mcpServers: [memoryServer], + settings: { permissions: { allow: half1 } }, + localSettings: { permissions: { allow: half2 } }, + })); + expect(result!.issues.some( + (i) => i.message.includes("tool permission"), + )).toBe(false); + }); + it("calculates score correctly with mixed severities", async () => { // Only MCP server, nothing else → high (SessionStart) + medium (autoMemory) + low (guidance) + low (tools) const result = await analyzeMemory(makeConfig({ mcpServers: [memoryServer] })); diff --git a/tests/permissions-analyzer.test.ts b/tests/permissions-analyzer.test.ts index 2eb2997..7001f61 100644 --- a/tests/permissions-analyzer.test.ts +++ b/tests/permissions-analyzer.test.ts @@ -9,6 +9,8 @@ function makeConfig(overrides: Partial = {}): ClaudeConfig { claudeMdInstructionCount: 10, settingsPath: null, settings: null, + localClaudeMdContent: null, + localSettings: null, hooks: [], rules: [], mcpServers: [], diff --git a/tests/quality-analyzer.test.ts b/tests/quality-analyzer.test.ts index 4f937cf..36e6eee 100644 --- a/tests/quality-analyzer.test.ts +++ b/tests/quality-analyzer.test.ts @@ -9,6 +9,8 @@ function makeConfig(content: string | null): ClaudeConfig { claudeMdInstructionCount: 50, settingsPath: null, settings: null, + localClaudeMdContent: null, + localSettings: null, hooks: [], rules: [], mcpServers: [], diff --git a/tests/settings-analyzer.test.ts b/tests/settings-analyzer.test.ts index 66b58b5..2d3ac90 100644 --- a/tests/settings-analyzer.test.ts +++ b/tests/settings-analyzer.test.ts @@ -9,6 +9,8 @@ function makeConfig(settings: Record | null = null): ClaudeConf claudeMdInstructionCount: 10, settingsPath: settings ? "/test/.claude/settings.json" : null, settings, + localClaudeMdContent: null, + localSettings: null, hooks: [], rules: [], mcpServers: [],