From 6048821a0faa7737f91489d2692ef38e10dd0b1f Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 27 May 2026 00:26:27 -0700 Subject: [PATCH] fix: warn when populated legacy ~/.claude/homunculus exists (#2036) After the XDG migration the CLI reads from $XDG_DATA_HOME/ecc-homunculus/ (default ~/.local/share/ecc-homunculus/), but installs that retained an older manual copy at ~/.claude/homunculus/ saw observations land in the new location while `instinct-cli.py status` silently continued to look at the new directory only. The reporter spent hours tracing the divergence because nothing surfaced the legacy tree. Add `_detect_legacy_homunculus_dir()` which returns the legacy path when ~/.claude/homunculus/ contains real ECC state (projects/, instincts/, evolved/, or observations.jsonl) and the active HOMUNCULUS_DIR is different. `cmd_status` calls it at the bottom of its output and prints a one-time, scoped warning that names both paths and points at the existing migrate-homunculus.sh script. Empty placeholder directories (just ~/.claude/homunculus/ with no content) are ignored so users who have already migrated and left the empty shell behind don't get nagged on every status call. Tests cover three branches: populated legacy dir triggers the warning, absent legacy dir stays silent, and empty placeholder legacy dir stays silent. --- .../scripts/instinct-cli.py | 47 +++ tests/scripts/instinct-cli-projects.test.js | 316 ++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 tests/scripts/instinct-cli-projects.test.js diff --git a/skills/continuous-learning-v2/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index 22cfc968..1704c93a 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -67,6 +67,35 @@ def _ensure_global_dirs(): d.mkdir(parents=True, exist_ok=True) +def _detect_legacy_homunculus_dir() -> "Path | None": + """Detect a populated legacy ~/.claude/homunculus tree that the current + XDG-based CLI does NOT read from. + + Issue #2036: when ECC migrated to XDG_DATA_HOME (default + ~/.local/share/ecc-homunculus/), users with an older manual install at + ~/.claude/homunculus/ silently had observations written to the new path + while `instinct-cli.py status` continued to look at the old one. We warn + explicitly when both paths exist so the divergence is visible instead of + appearing as "system is broken". + """ + legacy = Path.home() / ".claude" / "homunculus" + if legacy == HOMUNCULUS_DIR: + return None + try: + if not legacy.is_dir(): + return None + # Treat the directory as populated only if it contains real ECC + # state -- projects/, instincts/, evolved/, or observations.jsonl. + # An empty placeholder dir is not worth warning about. + for marker in ("projects", "instincts", "evolved", "observations.jsonl"): + target = legacy / marker + if target.exists(): + return legacy + except OSError: + return None + return None + + # ───────────────────────────────────────────── # Path Validation # ───────────────────────────────────────────── @@ -460,6 +489,24 @@ def cmd_status(args) -> int: days_left = max(0, PENDING_TTL_DAYS - item["age_days"]) print(f" - {item['name']} ({days_left}d remaining)") + # Issue #2036: legacy ~/.claude/homunculus is invisible to the current + # XDG-based CLI. Surface the divergence so users debugging "no instincts + # found" don't spend hours tracing path mismatches. + legacy_dir = _detect_legacy_homunculus_dir() + if legacy_dir is not None: + migrate_script = ( + Path(__file__).resolve().parent / "migrate-homunculus.sh" + ) + print(f"\n{'-'*60}") + print(f" WARNING: Legacy data found at {legacy_dir}") + print(f" The current CLI reads from {HOMUNCULUS_DIR}") + print( " and does NOT see anything under the legacy path.") + if migrate_script.exists(): + print(f" Run: {migrate_script}") + else: + print(f" Migrate by moving {legacy_dir} into {HOMUNCULUS_DIR}") + print( " (or remove the legacy directory once you've confirmed it's empty).") + print(f"\n{'='*60}\n") return 0 diff --git a/tests/scripts/instinct-cli-projects.test.js b/tests/scripts/instinct-cli-projects.test.js new file mode 100644 index 00000000..41285a4f --- /dev/null +++ b/tests/scripts/instinct-cli-projects.test.js @@ -0,0 +1,316 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); +const { spawnSync } = require('child_process'); + +let passed = 0; +let failed = 0; + +const repoRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join( + repoRoot, + 'skills', + 'continuous-learning-v2', + 'scripts', + 'instinct-cli.py' +); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed += 1; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + failed += 1; + } +} + +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-instinct-cli-projects-')); +} + +function cleanupDir(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function writeJson(filePath, payload) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeInstinct(filePath, id, confidence = 0.9) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + [ + '---', + `id: ${id}`, + 'trigger: "when repeated"', + `confidence: ${confidence}`, + 'domain: workflow', + '---', + '', + `Action for ${id}.`, + '', + ].join('\n') + ); +} + +function seedProject(root, id, options = {}) { + const projectDir = path.join(root, 'projects', id); + const personalDir = path.join(projectDir, 'instincts', 'personal'); + const inheritedDir = path.join(projectDir, 'instincts', 'inherited'); + fs.mkdirSync(personalDir, { recursive: true }); + fs.mkdirSync(inheritedDir, { recursive: true }); + + for (const instinct of options.personal || []) { + writeInstinct(path.join(personalDir, `${instinct}.yaml`), instinct); + } + for (const instinct of options.inherited || []) { + writeInstinct(path.join(inheritedDir, `${instinct}.yaml`), instinct); + } + if (options.observations) { + fs.writeFileSync( + path.join(projectDir, 'observations.jsonl'), + options.observations.map(row => JSON.stringify(row)).join('\n') + '\n' + ); + } + + return projectDir; +} + +function projectHash(value) { + return crypto.createHash('sha256').update(value).digest('hex').slice(0, 12); +} + +function runGit(cwd, args) { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + }); + assert.strictEqual(result.status, 0, result.stderr); + return result.stdout.trim(); +} + +function runCli(root, args, options = {}) { + return spawnSync('python3', [cliPath, ...args], { + cwd: options.cwd || repoRoot, + encoding: 'utf8', + env: { + ...process.env, + CLV2_HOMUNCULUS_DIR: root, + HOME: path.join(root, 'home'), + USERPROFILE: path.join(root, 'home'), + CLAUDE_PROJECT_DIR: '', + ...(options.env || {}), + }, + }); +} + +console.log('\n=== Testing instinct-cli.py projects maintenance ===\n'); + +test('projects delete --dry-run preserves registry and project files', () => { + const root = createTempDir(); + try { + const registryPath = path.join(root, 'projects.json'); + seedProject(root, 'alpha123', { + personal: ['keep-me'], + observations: [{ event: 'tool_complete' }], + }); + writeJson(registryPath, { + alpha123: { name: 'alpha', root: '/repo/alpha', remote: '', last_seen: '2026-01-01T00:00:00Z' }, + }); + + const result = runCli(root, ['projects', 'delete', 'alpha123', '--dry-run']); + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /would delete/i); + assert.ok(fs.existsSync(path.join(root, 'projects', 'alpha123'))); + assert.ok(readJson(registryPath).alpha123); + } finally { + cleanupDir(root); + } +}); + +test('projects delete --force removes registry entry and project directory', () => { + const root = createTempDir(); + try { + const registryPath = path.join(root, 'projects.json'); + seedProject(root, 'alpha123', { personal: ['delete-me'] }); + writeJson(registryPath, { + alpha123: { name: 'alpha', root: '/repo/alpha', remote: '', last_seen: '2026-01-01T00:00:00Z' }, + }); + + const result = runCli(root, ['projects', 'delete', 'alpha123', '--force']); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(!fs.existsSync(path.join(root, 'projects', 'alpha123'))); + assert.ok(!readJson(registryPath).alpha123); + } finally { + cleanupDir(root); + } +}); + +test('projects gc --force removes only zero-value project entries', () => { + const root = createTempDir(); + try { + const registryPath = path.join(root, 'projects.json'); + seedProject(root, 'empty000'); + seedProject(root, 'active999', { personal: ['active'] }); + writeJson(registryPath, { + empty000: { name: 'empty', root: '/tmp/empty', remote: '', last_seen: '2026-01-01T00:00:00Z' }, + active999: { name: 'active', root: '/repo/active', remote: '', last_seen: '2026-01-02T00:00:00Z' }, + }); + + const result = runCli(root, ['projects', 'gc', '--force']); + assert.strictEqual(result.status, 0, result.stderr); + const registry = readJson(registryPath); + assert.ok(!registry.empty000); + assert.ok(registry.active999); + assert.ok(!fs.existsSync(path.join(root, 'projects', 'empty000'))); + assert.ok(fs.existsSync(path.join(root, 'projects', 'active999'))); + } finally { + cleanupDir(root); + } +}); + +test('projects merge deduplicates instincts, appends observations, and removes source', () => { + const root = createTempDir(); + try { + const registryPath = path.join(root, 'projects.json'); + seedProject(root, 'from111', { + personal: ['shared', 'from-only'], + observations: [{ event: 'from-event' }], + }); + seedProject(root, 'into222', { + personal: ['shared', 'into-only'], + observations: [{ event: 'into-event' }], + }); + writeJson(registryPath, { + from111: { name: 'from', root: '/repo/from', remote: '', last_seen: '2026-01-01T00:00:00Z' }, + into222: { name: 'into', root: '/repo/into', remote: '', last_seen: '2026-01-02T00:00:00Z' }, + }); + + const result = runCli(root, ['projects', 'merge', 'from111', 'into222', '--force']); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(!fs.existsSync(path.join(root, 'projects', 'from111'))); + assert.ok(!readJson(registryPath).from111); + assert.ok(readJson(registryPath).into222); + + const intoPersonal = path.join(root, 'projects', 'into222', 'instincts', 'personal'); + assert.ok(fs.existsSync(path.join(intoPersonal, 'shared.yaml'))); + assert.ok(fs.existsSync(path.join(intoPersonal, 'from-only.yaml'))); + assert.ok(fs.existsSync(path.join(intoPersonal, 'into-only.yaml'))); + + const observations = fs.readFileSync( + path.join(root, 'projects', 'into222', 'observations.jsonl'), + 'utf8' + ); + assert.match(observations, /from-event/); + assert.match(observations, /into-event/); + } finally { + cleanupDir(root); + } +}); + +test('status migrates legacy no-remote linked worktree project dirs to main worktree id', () => { + const root = createTempDir(); + const repoParent = createTempDir(); + try { + const mainWorktree = path.join(repoParent, 'main'); + const linkedWorktree = path.join(repoParent, 'linked'); + fs.mkdirSync(mainWorktree, { recursive: true }); + runGit(mainWorktree, ['init']); + runGit(mainWorktree, ['config', 'user.email', 'ecc@example.test']); + runGit(mainWorktree, ['config', 'user.name', 'ECC Test']); + fs.writeFileSync(path.join(mainWorktree, 'README.md'), 'test\n'); + runGit(mainWorktree, ['add', 'README.md']); + runGit(mainWorktree, ['commit', '-m', 'init']); + runGit(mainWorktree, ['worktree', 'add', linkedWorktree]); + + const mainRoot = runGit(mainWorktree, ['rev-parse', '--show-toplevel']); + const linkedRoot = runGit(linkedWorktree, ['rev-parse', '--show-toplevel']); + const oldLinkedId = projectHash(linkedRoot); + const mainId = projectHash(mainRoot); + seedProject(root, oldLinkedId, { personal: ['legacy-worktree'] }); + + const result = runCli(root, ['status'], { cwd: linkedRoot }); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(!fs.existsSync(path.join(root, 'projects', oldLinkedId))); + assert.ok(fs.existsSync(path.join(root, 'projects', mainId))); + assert.ok( + fs.existsSync(path.join(root, 'projects', mainId, 'instincts', 'personal', 'legacy-worktree.yaml')) + ); + assert.match(result.stdout, new RegExp(`\\(${mainId}\\)`)); + } finally { + cleanupDir(root); + cleanupDir(repoParent); + } +}); + +test('status warns when populated legacy ~/.claude/homunculus exists alongside XDG path (#2036)', () => { + const root = createTempDir(); + try { + const home = path.join(root, 'home'); + const legacyHomunculus = path.join(home, '.claude', 'homunculus', 'projects'); + fs.mkdirSync(legacyHomunculus, { recursive: true }); + // Drop a sentinel project dir so the helper sees a populated tree. + fs.mkdirSync(path.join(legacyHomunculus, 'deadbeef'), { recursive: true }); + + const result = runCli(root, ['status']); + assert.strictEqual(result.status, 0, result.stderr); + assert.match( + result.stdout, + /Legacy data found at .*\.claude\/homunculus/, + 'status should warn about the populated legacy directory' + ); + } finally { + cleanupDir(root); + } +}); + +test('status stays quiet when no legacy ~/.claude/homunculus exists (#2036)', () => { + const root = createTempDir(); + try { + const home = path.join(root, 'home'); + fs.mkdirSync(home, { recursive: true }); + const result = runCli(root, ['status']); + assert.strictEqual(result.status, 0, result.stderr); + assert.doesNotMatch( + result.stdout, + /Legacy data found/, + 'status should not warn when no legacy directory exists' + ); + } finally { + cleanupDir(root); + } +}); + +test('status stays quiet when legacy ~/.claude/homunculus exists but is empty (#2036)', () => { + const root = createTempDir(); + try { + const home = path.join(root, 'home'); + const legacy = path.join(home, '.claude', 'homunculus'); + fs.mkdirSync(legacy, { recursive: true }); + const result = runCli(root, ['status']); + assert.strictEqual(result.status, 0, result.stderr); + assert.doesNotMatch( + result.stdout, + /Legacy data found/, + 'empty placeholder legacy directory should not trigger the warning' + ); + } finally { + cleanupDir(root); + } +}); + +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}`); + +process.exit(failed > 0 ? 1 : 0);