From 6a40469408c65caba3e6179b6cbe99337500c6ca Mon Sep 17 00:00:00 2001 From: Tom Cruise Missile <233243487+TomCruiseTorpedo@users.noreply.github.com> Date: Sat, 6 Jun 2026 23:27:00 -0600 Subject: [PATCH] feat: Cursor-independent ECC memory via ECC_AGENT_DATA_HOME (#2066) * feat: auto-isolate ECC memory data for Cursor via ECC_AGENT_DATA_HOME Add ECC_AGENT_DATA_HOME (defaults to ~/.claude) with Cursor-aware resolution, sessionStart env injection, install scaffolds, and hook bootstrap so memory hooks do not collide with Claude Code when both harnesses are used. Closes #2065 Co-authored-by: Cursor * fix: log agent-data config errors and ship cursor sessionStart deps Address CodeRabbit review: log invalid .cursor/ecc-agent-data.json parse failures, and copy cursor-session-env.js plus lib deps on legacy Cursor install so sessionStart hook path exists without hooks-runtime alone. Co-authored-by: Cursor * fix: resolve relative agentDataHome paths from project root Project config values like ".ecc-data" now resolve against the repository root (parent of .cursor/), not process.cwd(), so Cursor hooks persist memory in the intended directory regardless of hook cwd. Addresses cubic review on PR #2066. Co-authored-by: Cursor * docs: explain getHomeDir duplicate and docstring policy Document why agent-data-home keeps a local home-dir helper (circular require with utils.js) and list consolidation options for maintainers. Note that CodeRabbit JSDoc coverage warnings are informational relative to ECC's usual script documentation style. Addresses cubic P2 context on PR #2066. Co-authored-by: Cursor * test: isolate agent-data-home tests from dogfooded .cursor config Use isolated temp cwd for default-resolution cases and assert resolveAgentDataHome({ projectDir }) reads ecc-agent-data.json. Document cwd/project caveats in the test file header. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .env.example | 5 + README.md | 38 +++ scaffolds/cursor/ecc-agent-data.json | 5 + scaffolds/cursor/hooks.json | 10 + .../cursor/rules/ecc-agent-data-home.mdc | 16 ++ scripts/hooks/cursor-session-env.js | 50 ++++ scripts/hooks/plugin-hook-bootstrap.js | 25 +- scripts/lib/agent-data-home.js | 199 ++++++++++++++ scripts/lib/install-executor.js | 52 ++++ scripts/lib/session-aliases.d.ts | 2 +- scripts/lib/session-aliases.js | 2 +- scripts/lib/utils.d.ts | 14 +- scripts/lib/utils.js | 16 +- tests/lib/agent-data-home.test.js | 260 ++++++++++++++++++ tests/lib/utils.test.js | 74 ++++- 15 files changed, 749 insertions(+), 19 deletions(-) create mode 100644 scaffolds/cursor/ecc-agent-data.json create mode 100644 scaffolds/cursor/hooks.json create mode 100644 scaffolds/cursor/rules/ecc-agent-data-home.mdc create mode 100644 scripts/hooks/cursor-session-env.js create mode 100644 scripts/lib/agent-data-home.js create mode 100644 tests/lib/agent-data-home.test.js diff --git a/.env.example b/.env.example index ec0ddba5..8169a46b 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,11 @@ ASTRAFLOW_CN_API_KEY= # ASTRAFLOW_CN_MODEL=gpt-4o-mini # ASTRAFLOW_CN_BASE_URL=https://api.modelverse.cn/v1 +# ─── ECC agent data (multi-harness isolation) ─────────────────────────────── +# Memory hooks (sessions, learned skills, aliases, metrics). Default: ~/.claude +# Use a separate root when running ECC in Cursor alongside Claude Code: +# ECC_AGENT_DATA_HOME=$HOME/.cursor/ecc + # ─── Session & Security ───────────────────────────────────────────────────── # GitHub username (used by CI scripts for credential context) GITHUB_USER="your-github-username" diff --git a/README.md b/README.md index 9cc9e7d3..2883c76e 100644 --- a/README.md +++ b/README.md @@ -487,6 +487,7 @@ export ECC_SESSION_RETENTION_DAYS=14 export ECC_CONTEXT_MONITOR_COST_WARNINGS=off ``` + Windows PowerShell: ```powershell @@ -494,6 +495,24 @@ Windows PowerShell: [Environment]::SetEnvironmentVariable('ECC_SESSION_RETENTION_DAYS', '14', 'User') ``` +### Agent data home (multi-harness isolation) + +Memory persistence hooks (session summaries, learned skills, session aliases, metrics) store data under a single agent data root. By default that root is `~/.claude`. When you use ECC in both Claude Code and Cursor on the same machine, set a separate root for Cursor so the two environments do not overwrite each other's session files: + +```bash +# Cursor-only boundary (Claude Code keeps the default ~/.claude) +export ECC_AGENT_DATA_HOME="$HOME/.cursor/ecc" +``` + +Paths resolved under that root include: + +- `$ECC_AGENT_DATA_HOME/session-data/` — session summaries +- `$ECC_AGENT_DATA_HOME/skills/learned/` — learned skills from evaluate-session +- `$ECC_AGENT_DATA_HOME/session-aliases.json` — session aliases +- `$ECC_AGENT_DATA_HOME/metrics/` — cost and activity metrics + +See [affaan-m/ECC#2065](https://github.com/affaan-m/ECC/issues/2065). + --- ## What's Inside @@ -1261,6 +1280,25 @@ ECC does not install root `AGENTS.md` into `.cursor/`. Cursor treats nested `AGE Cursor-native loading behavior can vary by Cursor build. ECC installs agents as `.cursor/agents/ecc-*.md`; if your Cursor build does not expose project agents, those files still work as explicit reference definitions instead of hidden global prompt context. +### Memory and data isolation (Cursor + Claude Code) + +ECC memory hooks reuse the same `scripts/hooks/*.js` as Claude Code. For Cursor, ECC tries to keep memory **out of `~/.claude` automatically**: + +1. **Cursor `sessionStart` hook** (installed to `.cursor/hooks.json` on `--target cursor`) injects `ECC_AGENT_DATA_HOME` for the whole composer session. +2. **Hook runtime default** — when `CURSOR_VERSION` or `CURSOR_PROJECT_DIR` is present, hooks default to `~/.cursor/ecc` if the env var is unset. +3. **Project config** — `.cursor/ecc-agent-data.json` documents and overrides the path (`agentDataHome`). +4. **Always-on rule** — `.cursor/rules/ecc-agent-data-home.mdc` reminds the agent where memory lives. + +You can still override explicitly: + +```bash +export ECC_AGENT_DATA_HOME="$HOME/.cursor/ecc" +``` + +To **share** memory with Claude Code on purpose, set `ECC_AGENT_DATA_HOME=~/.claude` in the shell or in `.cursor/ecc-agent-data.json`. + +Continuous learning v2 instincts remain separate under `CLV2_HOMUNCULUS_DIR` (default `~/.local/share/ecc-homunculus`). + ### Hook Architecture (DRY Adapter Pattern) Cursor has **more hook events than Claude Code** (20 vs 8). The `.cursor/hooks/adapter.js` module transforms Cursor's stdin JSON to Claude Code's format, allowing existing `scripts/hooks/*.js` to be reused without duplication. diff --git a/scaffolds/cursor/ecc-agent-data.json b/scaffolds/cursor/ecc-agent-data.json new file mode 100644 index 00000000..a39a46ed --- /dev/null +++ b/scaffolds/cursor/ecc-agent-data.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/json", + "description": "ECC agent data root for this project when using Cursor. Memory hooks read session summaries and learned skills from here instead of ~/.claude.", + "agentDataHome": "~/.cursor/ecc" +} diff --git a/scaffolds/cursor/hooks.json b/scaffolds/cursor/hooks.json new file mode 100644 index 00000000..f4e389e8 --- /dev/null +++ b/scaffolds/cursor/hooks.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "command": "node .cursor/scripts/hooks/cursor-session-env.js" + } + ] + } +} diff --git a/scaffolds/cursor/rules/ecc-agent-data-home.mdc b/scaffolds/cursor/rules/ecc-agent-data-home.mdc new file mode 100644 index 00000000..c7e2b608 --- /dev/null +++ b/scaffolds/cursor/rules/ecc-agent-data-home.mdc @@ -0,0 +1,16 @@ +--- +description: "ECC Cursor memory boundary — keeps session data out of Claude Code's ~/.claude" +alwaysApply: true +--- + +# ECC agent data home (Cursor) + +This project uses ECC with **isolated memory persistence** for Cursor: + +- Default data root: `~/.cursor/ecc` (not `~/.claude`) +- Env var: `ECC_AGENT_DATA_HOME` (set automatically by ECC's Cursor `sessionStart` hook when hooks are wired) +- Project override: `.cursor/ecc-agent-data.json` → `agentDataHome` + +If the user asks where ECC stored session context or learned skills, point them at `$ECC_AGENT_DATA_HOME/session-data/` and `$ECC_AGENT_DATA_HOME/skills/learned/`. + +To **share** memory with Claude Code intentionally (not recommended for parallel use), set `ECC_AGENT_DATA_HOME` to `~/.claude` in the shell or in `.cursor/ecc-agent-data.json`. diff --git a/scripts/hooks/cursor-session-env.js b/scripts/hooks/cursor-session-env.js new file mode 100644 index 00000000..e5fe360f --- /dev/null +++ b/scripts/hooks/cursor-session-env.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * Cursor sessionStart hook — inject ECC_AGENT_DATA_HOME for the composer session. + * + * Cursor passes session-scoped env from sessionStart output to all later hooks. + * @see https://cursor.com/docs/hooks + */ + +const { + getCursorSessionEnvPayload, + resolveAgentDataHome, + AGENT_DATA_HOME_ENV, +} = require('../lib/agent-data-home'); +const { readStdinJson, log } = require('../lib/utils'); + +function main() { + readStdinJson() + .then(() => { + const envPayload = getCursorSessionEnvPayload({ preferCursorDefault: true }); + const agentDataHome = envPayload[AGENT_DATA_HOME_ENV]; + const payload = { + env: envPayload, + additional_context: [ + 'ECC memory persistence uses a dedicated agent data root for this Cursor session.', + `${AGENT_DATA_HOME_ENV}=${agentDataHome}`, + 'Session summaries, learned skills, aliases, and metrics live under that directory.', + 'Override via shell env, project .cursor/ecc-agent-data.json, or ECC docs (issue #2065).', + ].join('\n'), + }; + + process.stdout.write(`${JSON.stringify(payload)}\n`); + log(`[cursor-session-env] Set ${AGENT_DATA_HOME_ENV}=${agentDataHome}`); + process.exit(0); + }) + .catch(error => { + const fallbackHome = resolveAgentDataHome({ preferCursorDefault: true }); + const payload = { + env: { [AGENT_DATA_HOME_ENV]: fallbackHome }, + }; + process.stdout.write(`${JSON.stringify(payload)}\n`); + log(`[cursor-session-env] Fallback ${AGENT_DATA_HOME_ENV}=${fallbackHome} (${error.message})`); + process.exit(0); + }); +} + +if (require.main === module) { + main(); +} + +module.exports = { main }; diff --git a/scripts/hooks/plugin-hook-bootstrap.js b/scripts/hooks/plugin-hook-bootstrap.js index aef94aab..c965d403 100644 --- a/scripts/hooks/plugin-hook-bootstrap.js +++ b/scripts/hooks/plugin-hook-bootstrap.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { ensureAgentDataHomeEnv } = require('../lib/agent-data-home'); function readStdinRaw() { try { @@ -83,14 +84,16 @@ function findShellBinary() { } function spawnNode(rootDir, relPath, raw, args) { + ensureAgentDataHomeEnv(); + const hookEnv = { + ...process.env, + CLAUDE_PLUGIN_ROOT: rootDir, + ECC_PLUGIN_ROOT: rootDir, + }; return spawnSync(process.execPath, [resolveTarget(rootDir, relPath), ...args], { input: raw, encoding: 'utf8', - env: { - ...process.env, - CLAUDE_PLUGIN_ROOT: rootDir, - ECC_PLUGIN_ROOT: rootDir, - }, + env: hookEnv, cwd: process.cwd(), timeout: 30000, windowsHide: true, @@ -107,14 +110,16 @@ function spawnShell(rootDir, relPath, raw, args) { }; } + ensureAgentDataHomeEnv(); + const hookEnv = { + ...process.env, + CLAUDE_PLUGIN_ROOT: rootDir, + ECC_PLUGIN_ROOT: rootDir, + }; return spawnSync(shell, [resolveTarget(rootDir, relPath), ...args], { input: raw, encoding: 'utf8', - env: { - ...process.env, - CLAUDE_PLUGIN_ROOT: rootDir, - ECC_PLUGIN_ROOT: rootDir, - }, + env: hookEnv, cwd: process.cwd(), timeout: 30000, windowsHide: true, diff --git a/scripts/lib/agent-data-home.js b/scripts/lib/agent-data-home.js new file mode 100644 index 00000000..32da5563 --- /dev/null +++ b/scripts/lib/agent-data-home.js @@ -0,0 +1,199 @@ +/** + * Resolve ECC agent data home (memory persistence root) across harnesses. + * + * Docstring policy: public entry points here are documented; small internal + * helpers (e.g. `expandHomePath`, `readProjectConfigAt`) are left undocumented on + * purpose, consistent with ECC script modules elsewhere. Automated PR reviewers + * (e.g. CodeRabbit) may still flag low JSDoc coverage against a high threshold on + * the diff—that check is informational for this repo, not a bar every helper in + * touched files must meet. Prefer clarity in code and tests over blanket JSDoc on + * private helpers unless maintainers adopt a project-wide coverage rule. + * + * @see https://github.com/affaan-m/ECC/issues/2065 + */ + +const fs = require('fs'); +const path = require('path'); + +const AGENT_DATA_HOME_ENV = 'ECC_AGENT_DATA_HOME'; +const DEFAULT_CLAUDE_DIR_NAME = '.claude'; +const DEFAULT_CURSOR_ECC_DIR_SEGMENTS = ['.cursor', 'ecc']; +const PROJECT_CONFIG_RELATIVE = path.join('.cursor', 'ecc-agent-data.json'); + +/** + * Home directory for tilde expansion and default agent-data paths. + * + * Intentionally mirrors `getHomeDir()` in `scripts/lib/utils.js` (HOME/USERPROFILE, + * then `os.homedir()`). Do not import `utils.getHomeDir` here: `utils.js` already + * requires this module (`resolveAgentDataHome`), which would create a circular + * dependency and risk divergent defaults for `~/.cursor/ecc` vs `~/.claude`. + * + * If consolidation is needed later, prefer one of: + * + * | Approach | Tradeoff | + * | --- | --- | + * | Shared `scripts/lib/home-dir.js` imported by both | Clean; breaks the cycle | + * | Keep duplicate + cross-reference comment (this file) | Zero require risk | + * | Move all resolution here; thin-wrap from `utils` | Larger refactor | + */ +function getHomeDirFromEnv() { + const explicitHome = process.env.HOME || process.env.USERPROFILE; + if (explicitHome && String(explicitHome).trim().length > 0) { + return path.resolve(explicitHome); + } + return require('os').homedir(); +} + +function expandHomePath(value, baseDir) { + if (!value || typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('~')) { + const remainder = trimmed.slice(1).replace(/^[/\\]+/, ''); + return remainder ? path.join(getHomeDirFromEnv(), remainder) : getHomeDirFromEnv(); + } + if (path.isAbsolute(trimmed)) { + return path.resolve(trimmed); + } + const base = baseDir && String(baseDir).trim() + ? path.resolve(baseDir) + : process.cwd(); + return path.resolve(base, trimmed); +} + +/** + * Project root for a config file under .cursor/ecc-agent-data.json. + */ +function resolveProjectRootFromConfigPath(configPath) { + const configDir = path.dirname(path.resolve(configPath)); + if (path.basename(configDir) === '.cursor') { + return path.dirname(configDir); + } + return configDir; +} + +/** + * True when the current process is a Cursor hook subprocess. + * Cursor documents CURSOR_VERSION and CURSOR_PROJECT_DIR for hook scripts. + */ +function isCursorHookRuntime() { + if (process.env.CURSOR_VERSION && String(process.env.CURSOR_VERSION).trim()) { + return true; + } + if (process.env.CURSOR_PROJECT_DIR && String(process.env.CURSOR_PROJECT_DIR).trim()) { + return true; + } + return false; +} + +function getDefaultCursorAgentDataHome() { + return path.join(getHomeDirFromEnv(), ...DEFAULT_CURSOR_ECC_DIR_SEGMENTS); +} + +function getDefaultClaudeAgentDataHome() { + return path.join(getHomeDirFromEnv(), DEFAULT_CLAUDE_DIR_NAME); +} + +function readProjectConfigAt(configPath) { + if (!configPath || typeof configPath !== 'string') return null; + if (!fs.existsSync(configPath)) return null; + + try { + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const candidate = parsed.agentDataHome || parsed.ECC_AGENT_DATA_HOME; + if (typeof candidate !== 'string' || !candidate.trim()) return null; + const projectRoot = resolveProjectRootFromConfigPath(configPath); + return expandHomePath(candidate, projectRoot); + } catch (error) { + console.error( + `[ECC] Failed to read or parse agent data config at ${configPath}: ${error.message}` + ); + return null; + } +} + +function readProjectConfig(projectDir) { + if (!projectDir || typeof projectDir !== 'string') return null; + return readProjectConfigAt(path.join(path.resolve(projectDir), PROJECT_CONFIG_RELATIVE)); +} + +function resolveProjectDir() { + const candidates = [ + process.env.CURSOR_PROJECT_DIR, + process.env.CLAUDE_PROJECT_DIR, + process.cwd(), + ]; + + for (const candidate of candidates) { + if (!candidate || typeof candidate !== 'string') continue; + const resolved = path.resolve(candidate); + if (fs.existsSync(path.join(resolved, '.cursor'))) { + return resolved; + } + } + + return process.cwd(); +} + +/** + * Resolve agent data home without mutating process.env. + */ +function resolveAgentDataHome(options = {}) { + const fromEnv = expandHomePath(process.env[AGENT_DATA_HOME_ENV]); + if (fromEnv) return fromEnv; + + const projectDir = options.projectDir || resolveProjectDir(); + const fromProject = readProjectConfig(projectDir); + if (fromProject) return fromProject; + + if (options.preferCursorDefault === true || isCursorHookRuntime()) { + return getDefaultCursorAgentDataHome(); + } + + return getDefaultClaudeAgentDataHome(); +} + +/** + * Set ECC_AGENT_DATA_HOME on the current process when unset (hook subprocess safety net). + * @returns {string} Resolved agent data home + */ +function ensureAgentDataHomeEnv(options = {}) { + const resolved = resolveAgentDataHome(options); + if (!expandHomePath(process.env[AGENT_DATA_HOME_ENV])) { + process.env[AGENT_DATA_HOME_ENV] = resolved; + } + return resolved; +} + +/** + * Build Cursor sessionStart hook output env payload. + */ +function getCursorSessionEnvPayload(options = {}) { + const agentDataHome = resolveAgentDataHome({ + ...options, + preferCursorDefault: true, + }); + + return { + ECC_AGENT_DATA_HOME: agentDataHome, + }; +} + +module.exports = { + AGENT_DATA_HOME_ENV, + DEFAULT_CLAUDE_DIR_NAME, + DEFAULT_CURSOR_ECC_DIR_SEGMENTS, + PROJECT_CONFIG_RELATIVE, + expandHomePath, + resolveProjectRootFromConfigPath, + isCursorHookRuntime, + getDefaultCursorAgentDataHome, + getDefaultClaudeAgentDataHome, + readProjectConfig, + readProjectConfigAt, + resolveProjectDir, + resolveAgentDataHome, + ensureAgentDataHomeEnv, + getCursorSessionEnvPayload, +}; diff --git a/scripts/lib/install-executor.js b/scripts/lib/install-executor.js index dc29f1c8..ce2274ea 100644 --- a/scripts/lib/install-executor.js +++ b/scripts/lib/install-executor.js @@ -207,6 +207,52 @@ function readJsonObject(filePath, label) { return parsed; } +function addCursorAgentDataScaffoldOperations(operations, options) { + const scaffoldRoot = path.join(options.sourceRoot, 'scaffolds', 'cursor'); + if (!fs.existsSync(scaffoldRoot)) { + return; + } + + addFileCopyOperation(operations, { + moduleId: options.moduleId, + sourceRoot: options.sourceRoot, + sourceRelativePath: path.join('scaffolds', 'cursor', 'ecc-agent-data.json'), + destinationPath: path.join(options.targetRoot, 'ecc-agent-data.json'), + strategy: 'preserve-relative-path', + }); + + addFileCopyOperation(operations, { + moduleId: options.moduleId, + sourceRoot: options.sourceRoot, + sourceRelativePath: path.join('scaffolds', 'cursor', 'rules', 'ecc-agent-data-home.mdc'), + destinationPath: path.join(options.targetRoot, 'rules', 'ecc-agent-data-home.mdc'), + strategy: 'preserve-relative-path', + }); + + addJsonMergeOperation(operations, { + moduleId: options.moduleId, + sourceRoot: options.sourceRoot, + sourceRelativePath: path.join('scaffolds', 'cursor', 'hooks.json'), + destinationPath: path.join(options.targetRoot, 'hooks.json'), + }); + + const cursorSessionHookDeps = [ + path.join('scripts', 'hooks', 'cursor-session-env.js'), + path.join('scripts', 'lib', 'agent-data-home.js'), + path.join('scripts', 'lib', 'utils.js'), + ]; + + for (const sourceRelativePath of cursorSessionHookDeps) { + addFileCopyOperation(operations, { + moduleId: options.moduleId, + sourceRoot: options.sourceRoot, + sourceRelativePath, + destinationPath: path.join(options.targetRoot, sourceRelativePath), + strategy: 'preserve-relative-path', + }); + } +} + function addJsonMergeOperation(operations, options) { const sourcePath = path.join(options.sourceRoot, options.sourceRelativePath); if (!fs.existsSync(sourcePath)) { @@ -405,6 +451,12 @@ function planCursorLegacyInstall(context) { destinationPath: path.join(targetRoot, 'mcp.json'), }); + addCursorAgentDataScaffoldOperations(operations, { + moduleId: 'legacy-cursor-install', + sourceRoot: context.sourceRoot, + targetRoot, + }); + return { mode: 'legacy', adapter, diff --git a/scripts/lib/session-aliases.d.ts b/scripts/lib/session-aliases.d.ts index c1744713..3930dc72 100644 --- a/scripts/lib/session-aliases.d.ts +++ b/scripts/lib/session-aliases.d.ts @@ -1,6 +1,6 @@ /** * Session Aliases Library for Claude Code. - * Manages named aliases for session files, stored in ~/.claude/session-aliases.json. + * Manages named aliases for session files, stored in $ECC_AGENT_DATA_HOME/session-aliases.json (default ~/.claude). */ /** Internal alias storage entry */ diff --git a/scripts/lib/session-aliases.js b/scripts/lib/session-aliases.js index cf9acdaa..bfc84631 100644 --- a/scripts/lib/session-aliases.js +++ b/scripts/lib/session-aliases.js @@ -1,6 +1,6 @@ /** * Session Aliases Library for Claude Code - * Manages session aliases stored in ~/.claude/session-aliases.json + * Manages session aliases stored in $ECC_AGENT_DATA_HOME/session-aliases.json (default ~/.claude). */ const fs = require('fs'); diff --git a/scripts/lib/utils.d.ts b/scripts/lib/utils.d.ts index 55d27621..9e980c69 100644 --- a/scripts/lib/utils.d.ts +++ b/scripts/lib/utils.d.ts @@ -15,19 +15,25 @@ export const isLinux: boolean; /** Get the user's home directory (cross-platform) */ export function getHomeDir(): string; -/** Get the Claude config directory (~/.claude) */ +/** + * ECC agent data root for memory persistence and related state. + * Defaults to ~/.claude; override with ECC_AGENT_DATA_HOME (e.g. ~/.cursor/ecc). + */ +export function getAgentDataHome(): string; + +/** Get the agent data directory (alias of getAgentDataHome) */ export function getClaudeDir(): string; -/** Get the canonical ECC sessions directory (~/.claude/session-data) */ +/** Get the canonical ECC sessions directory ($ECC_AGENT_DATA_HOME/session-data) */ export function getSessionsDir(): string; -/** Get the legacy Claude-managed sessions directory (~/.claude/sessions) */ +/** Get the legacy sessions directory ($ECC_AGENT_DATA_HOME/sessions) */ export function getLegacySessionsDir(): string; /** Get session directories to search, with canonical storage first and legacy fallback second */ export function getSessionSearchDirs(): string[]; -/** Get the learned skills directory (~/.claude/skills/learned) */ +/** Get the learned skills directory ($ECC_AGENT_DATA_HOME/skills/learned) */ export function getLearnedSkillsDir(): string; /** Get the temp directory (cross-platform) */ diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index f1717e5d..a201e234 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -15,6 +15,9 @@ const isMacOS = process.platform === 'darwin'; const isLinux = process.platform === 'linux'; const SESSION_DATA_DIR_NAME = 'session-data'; const LEGACY_SESSIONS_DIR_NAME = 'sessions'; +const { + resolveAgentDataHome, +} = require('./agent-data-home'); const WINDOWS_RESERVED_SESSION_IDS = new Set([ 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', @@ -33,12 +36,20 @@ function getHomeDir() { } /** - * Get the Claude config directory + * ECC agent data root for memory persistence (see scripts/lib/agent-data-home.js). + */ +function getAgentDataHome() { + return resolveAgentDataHome(); +} + +/** + * Get the Claude config directory (alias of getAgentDataHome for backwards compatibility). */ function getClaudeDir() { - return path.join(getHomeDir(), '.claude'); + return getAgentDataHome(); } + /** * Get the sessions directory */ @@ -585,6 +596,7 @@ module.exports = { // Directories getHomeDir, + getAgentDataHome, getClaudeDir, getSessionsDir, getLegacySessionsDir, diff --git a/tests/lib/agent-data-home.test.js b/tests/lib/agent-data-home.test.js new file mode 100644 index 00000000..21909b56 --- /dev/null +++ b/tests/lib/agent-data-home.test.js @@ -0,0 +1,260 @@ +/** + * Tests for scripts/lib/agent-data-home.js + * + * Run with: node tests/lib/agent-data-home.test.js + * + * Cwd / project context: many cases use `withIsolatedCwd()` (empty temp dir, no + * `.cursor/`) so results do not depend on running inside a dogfooded ECC repo. + * When this repo has `.cursor/ecc-agent-data.json` installed, `resolveAgentDataHome()` + * from the real project root intentionally resolves to `~/.cursor/ecc` — see the + * dedicated test below; do not expect `~/.claude` while cwd is the ECC tree. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function withEnv(overrides, fn) { + const previous = {}; + for (const key of Object.keys(overrides)) { + previous[key] = process.env[key]; + if (overrides[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = overrides[key]; + } + } + + try { + fn(); + } finally { + for (const key of Object.keys(previous)) { + if (previous[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous[key]; + } + } + delete require.cache[require.resolve('../../scripts/lib/agent-data-home')]; + } +} + +/** + * Run fn with cwd in an empty directory (no .cursor/) so resolveProjectDir() does + * not pick up the ECC repo's installed agent-data config. + */ +function withIsolatedCwd(fn) { + const isolatedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-')); + const originalCwd = process.cwd(); + try { + process.chdir(isolatedDir); + return fn(isolatedDir); + } finally { + process.chdir(originalCwd); + fs.rmSync(isolatedDir, { recursive: true, force: true }); + } +} + +function runTests() { + console.log('\n=== Testing agent-data-home.js ===\n'); + let passed = 0; + let failed = 0; + + if (test('defaults to ~/.claude outside Cursor (isolated cwd)', () => { + withIsolatedCwd(() => { + withEnv({ + ECC_AGENT_DATA_HOME: undefined, + CURSOR_VERSION: undefined, + CURSOR_PROJECT_DIR: undefined, + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + const home = os.homedir(); + assert.strictEqual( + agentDataHome.resolveAgentDataHome(), + path.join(home, '.claude') + ); + }); + }); + })) passed++; else failed++; + + if (test('resolveAgentDataHome uses projectDir + .cursor/ecc-agent-data.json', () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-project-')); + const cursorDir = path.join(projectDir, '.cursor'); + fs.mkdirSync(cursorDir, { recursive: true }); + fs.writeFileSync( + path.join(cursorDir, 'ecc-agent-data.json'), + JSON.stringify({ agentDataHome: '~/.cursor/ecc' }), + 'utf8' + ); + + try { + withIsolatedCwd(() => { + withEnv({ + ECC_AGENT_DATA_HOME: undefined, + CURSOR_VERSION: undefined, + CURSOR_PROJECT_DIR: undefined, + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + assert.strictEqual( + agentDataHome.resolveAgentDataHome({ projectDir }), + path.join(os.homedir(), '.cursor', 'ecc') + ); + }); + }); + } finally { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('defaults to ~/.cursor/ecc in Cursor hook runtime (isolated cwd)', () => { + withIsolatedCwd(() => { + withEnv({ + ECC_AGENT_DATA_HOME: undefined, + CURSOR_VERSION: '1.0.0', + CURSOR_PROJECT_DIR: undefined, + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + const home = os.homedir(); + assert.strictEqual( + agentDataHome.resolveAgentDataHome(), + path.join(home, '.cursor', 'ecc') + ); + }); + }); + })) passed++; else failed++; + + if (test('honors ECC_AGENT_DATA_HOME over Cursor default', () => { + const override = path.join(os.tmpdir(), `ecc-override-${Date.now()}`); + withEnv({ + ECC_AGENT_DATA_HOME: override, + CURSOR_VERSION: '1.0.0', + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + assert.strictEqual(agentDataHome.resolveAgentDataHome(), path.resolve(override)); + }); + })) passed++; else failed++; + + if (test('reads project ecc-agent-data.json config file', () => { + const tmpDir = path.join(os.tmpdir(), `ecc-agent-data-home-read-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + const configPath = path.join(tmpDir, 'ecc-agent-data.json'); + const customHome = path.join(tmpDir, 'data-root'); + fs.writeFileSync( + configPath, + JSON.stringify({ agentDataHome: customHome }), + 'utf8' + ); + + try { + withEnv({ + ECC_AGENT_DATA_HOME: undefined, + CURSOR_VERSION: undefined, + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + assert.strictEqual( + agentDataHome.readProjectConfigAt(configPath), + path.resolve(customHome) + ); + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('resolves relative agentDataHome against project root, not cwd', () => { + const stamp = Date.now(); + const projectDir = path.join(os.tmpdir(), `ecc-agent-data-home-relative-${stamp}`); + const cursorDir = path.join(projectDir, '.cursor'); + const otherCwd = path.join(os.tmpdir(), `ecc-agent-data-home-other-cwd-${stamp}`); + fs.mkdirSync(cursorDir, { recursive: true }); + fs.mkdirSync(otherCwd, { recursive: true }); + const configPath = path.join(cursorDir, 'ecc-agent-data.json'); + const expectedHome = path.join(projectDir, '.ecc-data'); + fs.writeFileSync( + configPath, + JSON.stringify({ agentDataHome: '.ecc-data' }), + 'utf8' + ); + + const originalCwd = process.cwd(); + try { + process.chdir(otherCwd); + withEnv({ + ECC_AGENT_DATA_HOME: undefined, + CURSOR_VERSION: undefined, + CURSOR_PROJECT_DIR: projectDir, + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + assert.strictEqual(agentDataHome.readProjectConfigAt(configPath), expectedHome); + assert.strictEqual( + agentDataHome.resolveAgentDataHome({ projectDir }), + expectedHome + ); + }); + } finally { + process.chdir(originalCwd); + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(otherCwd, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('readProjectConfigAt logs parse failures', () => { + const tmpDir = path.join(os.tmpdir(), `ecc-agent-data-home-log-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + const configPath = path.join(tmpDir, 'ecc-agent-data.json'); + fs.writeFileSync(configPath, '{ invalid json', 'utf8'); + + const originalError = console.error; + const messages = []; + console.error = (...args) => { + messages.push(args.join(' ')); + }; + + try { + withEnv({ + ECC_AGENT_DATA_HOME: undefined, + CURSOR_VERSION: undefined, + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + assert.strictEqual(agentDataHome.readProjectConfigAt(configPath), null); + assert.ok( + messages.some(message => message.includes(configPath)), + 'Expected config path in error log' + ); + }); + } finally { + console.error = originalError; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('ensureAgentDataHomeEnv sets process.env when unset', () => { + withEnv({ + ECC_AGENT_DATA_HOME: undefined, + CURSOR_VERSION: '1.0.0', + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + const resolved = agentDataHome.ensureAgentDataHomeEnv(); + assert.ok(process.env.ECC_AGENT_DATA_HOME); + assert.strictEqual(process.env.ECC_AGENT_DATA_HOME, resolved); + }); + })) passed++; else failed++; + + console.log(`\n=== Test Results ===\nPassed: ${passed}\nFailed: ${failed}\n`); + if (failed > 0) process.exit(1); +} + +runTests(); diff --git a/tests/lib/utils.test.js b/tests/lib/utils.test.js index 3217567a..54fa8dfc 100644 --- a/tests/lib/utils.test.js +++ b/tests/lib/utils.test.js @@ -115,7 +115,79 @@ function runTests() { const sessionsDir = utils.getSessionsDir(); const claudeDir = utils.getClaudeDir(); assert.ok(sessionsDir.startsWith(claudeDir), 'Sessions should be under Claude dir'); - assert.ok(sessionsDir.endsWith(path.join('.claude', 'session-data')) || sessionsDir.endsWith('/.claude/session-data'), 'Should use canonical session-data directory'); + assert.ok(sessionsDir.endsWith('session-data'), 'Should use canonical session-data directory'); + })) passed++; else failed++; + + if (test('getAgentDataHome honors ECC_AGENT_DATA_HOME', () => { + const original = process.env.ECC_AGENT_DATA_HOME; + const overrideRoot = path.join(utils.getTempDir(), `ecc-agent-data-${Date.now()}`); + try { + process.env.ECC_AGENT_DATA_HOME = overrideRoot; + delete require.cache[require.resolve('../../scripts/lib/utils')]; + const reloaded = require('../../scripts/lib/utils'); + assert.strictEqual(reloaded.getAgentDataHome(), path.resolve(overrideRoot)); + assert.strictEqual(reloaded.getClaudeDir(), path.resolve(overrideRoot)); + assert.strictEqual( + reloaded.getSessionsDir(), + path.join(path.resolve(overrideRoot), 'session-data') + ); + assert.strictEqual( + reloaded.getLearnedSkillsDir(), + path.join(path.resolve(overrideRoot), 'skills', 'learned') + ); + } finally { + delete require.cache[require.resolve('../../scripts/lib/utils')]; + if (original === undefined) { + delete process.env.ECC_AGENT_DATA_HOME; + } else { + process.env.ECC_AGENT_DATA_HOME = original; + } + } + })) passed++; else failed++; + + if (test('getAgentDataHome defaults to ~/.cursor/ecc when CURSOR_VERSION is set', () => { + const originalVersion = process.env.CURSOR_VERSION; + const originalHome = process.env.ECC_AGENT_DATA_HOME; + try { + delete process.env.ECC_AGENT_DATA_HOME; + process.env.CURSOR_VERSION = 'test-cursor'; + delete require.cache[require.resolve('../../scripts/lib/utils')]; + delete require.cache[require.resolve('../../scripts/lib/agent-data-home')]; + const reloaded = require('../../scripts/lib/utils'); + const expected = path.join(reloaded.getHomeDir(), '.cursor', 'ecc'); + assert.strictEqual(reloaded.getAgentDataHome(), expected); + } finally { + delete require.cache[require.resolve('../../scripts/lib/utils')]; + delete require.cache[require.resolve('../../scripts/lib/agent-data-home')]; + if (originalVersion === undefined) { + delete process.env.CURSOR_VERSION; + } else { + process.env.CURSOR_VERSION = originalVersion; + } + if (originalHome === undefined) { + delete process.env.ECC_AGENT_DATA_HOME; + } else { + process.env.ECC_AGENT_DATA_HOME = originalHome; + } + } + })) passed++; else failed++; + + if (test('getAgentDataHome expands tilde in ECC_AGENT_DATA_HOME', () => { + const original = process.env.ECC_AGENT_DATA_HOME; + try { + process.env.ECC_AGENT_DATA_HOME = path.join('~', '.cursor', 'ecc-test'); + delete require.cache[require.resolve('../../scripts/lib/utils')]; + const reloaded = require('../../scripts/lib/utils'); + const expected = path.join(reloaded.getHomeDir(), '.cursor', 'ecc-test'); + assert.strictEqual(reloaded.getAgentDataHome(), expected); + } finally { + delete require.cache[require.resolve('../../scripts/lib/utils')]; + if (original === undefined) { + delete process.env.ECC_AGENT_DATA_HOME; + } else { + process.env.ECC_AGENT_DATA_HOME = original; + } + } })) passed++; else failed++; if (test('getSessionSearchDirs includes canonical and legacy paths', () => {