diff --git a/skills/continuous-learning-v2/SKILL.md b/skills/continuous-learning-v2/SKILL.md index b00d4eeb0..e364f00df 100644 --- a/skills/continuous-learning-v2/SKILL.md +++ b/skills/continuous-learning-v2/SKILL.md @@ -128,7 +128,7 @@ Session Activity (in a git repo) The system automatically detects your current project: -1. **`CLAUDE_PROJECT_DIR` env var** (highest priority) +1. **`CLAUDE_PROJECT_DIR` env var** (highest priority) -- honored as an explicit override even when the directory is not a git repo (hashed by its absolute path) 2. **`git remote get-url origin`** -- hashed to create a portable project ID (same repo on different machines gets the same ID) 3. **`git rev-parse --show-toplevel`** -- fallback using repo path (machine-specific) 4. **Global fallback** -- if no project is detected, instincts go to global scope diff --git a/skills/continuous-learning-v2/scripts/detect-project.sh b/skills/continuous-learning-v2/scripts/detect-project.sh index 05bc20852..ddf8f4150 100755 --- a/skills/continuous-learning-v2/scripts/detect-project.sh +++ b/skills/continuous-learning-v2/scripts/detect-project.sh @@ -105,11 +105,23 @@ _clv2_detect_project() { return 0 fi - # 1. Try CLAUDE_PROJECT_DIR env var - if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ] && command -v git &>/dev/null; then - project_root=$(git -C "$CLAUDE_PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null || true) - if [ -n "$project_root" ]; then - source_hint="env" + # 1. Try CLAUDE_PROJECT_DIR env var (explicit override) + if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ]; then + if command -v git &>/dev/null; then + project_root=$(git -C "$CLAUDE_PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null || true) + if [ -n "$project_root" ]; then + source_hint="env" + fi + fi + # Non-git directory explicitly pointed at by CLAUDE_PROJECT_DIR: honor it as + # a project root (path-hash identity) rather than collapsing to the shared + # `global` bucket. Gated on the explicit env var so an arbitrary non-git cwd + # never becomes a "project" — priority 2 below stays git-only on purpose. + if [ -z "$project_root" ]; then + project_root=$(cd "$CLAUDE_PROJECT_DIR" 2>/dev/null && pwd -P) + if [ -n "$project_root" ]; then + source_hint="env-nogit" + fi fi fi diff --git a/skills/continuous-learning-v2/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index 2274a852b..0a99f1fa0 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -298,10 +298,19 @@ def detect_project() -> dict: "observations_file": GLOBAL_OBSERVATIONS_FILE, } - # 1. CLAUDE_PROJECT_DIR env var + # 1. CLAUDE_PROJECT_DIR env var (explicit override) env_dir = os.environ.get("CLAUDE_PROJECT_DIR") if env_dir and os.path.isdir(env_dir): project_root = _git_repo_root(env_dir) + # Non-git directory explicitly pointed at by CLAUDE_PROJECT_DIR: honor it + # as a project root (path-hash identity) rather than collapsing to the + # shared `global` bucket. Mirrors detect-project.sh so the observer + # (shell) and this CLI agree on the project id for the same directory; + # os.path.realpath matches the shell's `cd ... && pwd -P`. Gated on the + # explicit env var so an arbitrary non-git cwd (priority 2) never + # becomes a "project". + if not project_root: + project_root = os.path.realpath(env_dir) # 2. git repo root if not project_root: diff --git a/tests/hooks/detect-project-nongit.test.js b/tests/hooks/detect-project-nongit.test.js new file mode 100644 index 000000000..e3f4613c6 --- /dev/null +++ b/tests/hooks/detect-project-nongit.test.js @@ -0,0 +1,290 @@ +/** + * Tests for non-git CLAUDE_PROJECT_DIR project detection (issue #2469) + * + * Validates that detect-project.sh honors an explicitly-provided + * CLAUDE_PROJECT_DIR that is NOT a git repository, deriving a stable + * path-based PROJECT_ID instead of collapsing to the shared `global` + * bucket. A bare non-git cwd (no CLAUDE_PROJECT_DIR) must still fall + * back to `global` — the fix is gated on the explicit env var so an + * arbitrary working directory never becomes a "project". + * + * Run with: node tests/hooks/detect-project-nongit.test.js + */ + +// Skip on Windows — these tests invoke bash scripts directly +if (process.platform === 'win32') { + console.log('Skipping bash-dependent non-git detection tests on Windows\n'); + process.exit(0); +} + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { execFileSync, spawnSync } = require('child_process'); + +// Locate a Python interpreter for the cross-implementation consistency check. +// Absent Python just skips that one case (the bash path is the primary fix). +function findPython() { + for (const bin of ['python3', 'python']) { + const r = spawnSync(bin, ['--version'], { encoding: 'utf8' }); + if (!r.error && r.status === 0) return bin; + } + return null; +} +const PYTHON = findPython(); + +let passed = 0; +let failed = 0; +let skipped = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + failed++; + } +} + +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nongit-test-')); +} + +function cleanupDir(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } +} + +const repoRoot = path.resolve(__dirname, '..', '..'); +const detectProjectPath = path.join( + repoRoot, + 'skills', + 'continuous-learning-v2', + 'scripts', + 'detect-project.sh' +); +const instinctCliPath = path.join( + repoRoot, + 'skills', + 'continuous-learning-v2', + 'scripts', + 'instinct-cli.py' +); + +// Resolve the project id the Python CLI (instinct-cli.py) assigns, so we can +// prove it agrees with the shell observer for the same non-git directory. +function pythonProjectId(projectDir, homeDir) { + const code = + 'import importlib.util as u, sys;' + + `s=u.spec_from_file_location("icli", ${JSON.stringify(instinctCliPath)});` + + 'm=u.module_from_spec(s); s.loader.exec_module(m);' + + 'print(m.detect_project()["id"])'; + const r = spawnSync(PYTHON, ['-c', code], { + cwd: projectDir, + timeout: 10000, + encoding: 'utf8', + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + CLAUDE_PROJECT_DIR: projectDir, + }, + }); + if (r.status !== 0) { + throw new Error(`instinct-cli.py detect_project failed: ${r.stderr || r.error}`); + } + return r.stdout.trim(); +} + +// Source detect-project.sh with an isolated HOME and the given +// CLAUDE_PROJECT_DIR, returning the exported PROJECT_* vars. +function detect(projectDir, homeDir, { setProjectDir = true } = {}) { + const env = { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + }; + if (setProjectDir) { + env.CLAUDE_PROJECT_DIR = projectDir; + } else { + delete env.CLAUDE_PROJECT_DIR; + } + + // Suppress only stdout; keep stderr so a sourcing failure (e.g. a syntax + // error or missing _CLV2_PYTHON_CMD) surfaces instead of silently leaving + // the PROJECT_* vars empty and producing confusing assertion messages. + const script = ` + source "${detectProjectPath}" >/dev/null + printf 'PROJECT_ID=%s\\n' "$PROJECT_ID" + printf 'PROJECT_NAME=%s\\n' "$PROJECT_NAME" + printf 'PROJECT_ROOT=%s\\n' "$PROJECT_ROOT" + `; + + const out = execFileSync('bash', ['-lc', script], { + // Run from the project dir; it is not a git repo, so the cwd-git + // branch (priority 2) cannot fire and interfere with the assertions. + cwd: projectDir, + timeout: 10000, + env, + }).toString(); + + const vars = {}; + for (const line of out.trim().split('\n')) { + const m = line.match(/^(PROJECT_ID|PROJECT_NAME|PROJECT_ROOT)=(.*)$/); + if (m) vars[m[1]] = m[2]; + } + return vars; +} + +console.log('\n=== Non-git CLAUDE_PROJECT_DIR detection (issue #2469) ===\n'); + +console.log('--- Content check ---'); + +test('detect-project.sh has an env-nogit fallback for non-git dirs', () => { + const content = fs.readFileSync(detectProjectPath, 'utf8'); + assert.ok( + content.includes('env-nogit'), + 'detect-project.sh should set source_hint="env-nogit" for non-git CLAUDE_PROJECT_DIR' + ); +}); + +console.log('\n--- Behavior: non-git CLAUDE_PROJECT_DIR ---'); + +test('non-git CLAUDE_PROJECT_DIR yields a non-global, path-derived PROJECT_ID', () => { + const testDir = createTempDir(); + try { + const homeDir = path.join(testDir, 'home'); + const projectDir = path.join(testDir, 'my-plain-project'); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + assert.ok(!fs.existsSync(path.join(projectDir, '.git')), 'guard: project dir must not be a git repo'); + + const vars = detect(projectDir, homeDir); + assert.ok( + vars.PROJECT_ID && vars.PROJECT_ID !== 'global', + `PROJECT_ID should not be "global", got: "${vars.PROJECT_ID || ''}"` + ); + assert.ok( + /^[0-9a-f]{12}$/.test(vars.PROJECT_ID), + `PROJECT_ID should be a 12-char hex hash, got: "${vars.PROJECT_ID || ''}"` + ); + assert.strictEqual( + vars.PROJECT_NAME, + 'my-plain-project', + `PROJECT_NAME should be the directory basename, got: "${vars.PROJECT_NAME || ''}"` + ); + assert.strictEqual( + vars.PROJECT_ROOT, + fs.realpathSync(projectDir), + `PROJECT_ROOT should be the canonicalized project dir, got: "${vars.PROJECT_ROOT || ''}"` + ); + } finally { + cleanupDir(testDir); + } +}); + +test('PROJECT_ID is stable across repeated invocations of the same dir', () => { + const testDir = createTempDir(); + try { + const homeDir = path.join(testDir, 'home'); + const projectDir = path.join(testDir, 'stable-project'); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + + const first = detect(projectDir, homeDir).PROJECT_ID; + const second = detect(projectDir, homeDir).PROJECT_ID; + assert.ok(first && first !== 'global', 'first run should produce a real id'); + assert.strictEqual(second, first, 'the same non-git dir must hash to the same PROJECT_ID'); + } finally { + cleanupDir(testDir); + } +}); + +test('distinct non-git dirs produce distinct PROJECT_IDs', () => { + const testDir = createTempDir(); + try { + const homeDir = path.join(testDir, 'home'); + const dirA = path.join(testDir, 'project-a'); + const dirB = path.join(testDir, 'project-b'); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(dirA, { recursive: true }); + fs.mkdirSync(dirB, { recursive: true }); + + const idA = detect(dirA, homeDir).PROJECT_ID; + const idB = detect(dirB, homeDir).PROJECT_ID; + assert.ok(idA && idA !== 'global' && idB && idB !== 'global', 'both dirs should get real ids'); + assert.notStrictEqual(idA, idB, 'different non-git dirs must not share a project id'); + } finally { + cleanupDir(testDir); + } +}); + +console.log('\n--- Gating: bare non-git cwd stays global ---'); + +test('non-git cwd with no CLAUDE_PROJECT_DIR still falls back to global', () => { + const testDir = createTempDir(); + try { + const homeDir = path.join(testDir, 'home'); + const projectDir = path.join(testDir, 'unregistered'); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + + const vars = detect(projectDir, homeDir, { setProjectDir: false }); + assert.strictEqual( + vars.PROJECT_ID, + 'global', + `without CLAUDE_PROJECT_DIR an arbitrary non-git cwd must stay "global", got: "${vars.PROJECT_ID || ''}"` + ); + } finally { + cleanupDir(testDir); + } +}); + +console.log('\n--- Cross-impl consistency: shell observer vs Python CLI ---'); + +// Guard the Python-dependent case OUTSIDE test() so an unmet prerequisite is +// counted as skipped, never as a silent pass. This is not a hard failure: +// detect-project.sh itself degrades gracefully without Python (it falls back to +// shasum/sha256sum), so a Python-less host is a supported environment where the +// cross-check simply cannot run. +if (!PYTHON) { + console.log(' ⊘ instinct-cli.py cross-impl check (skipped — no Python interpreter available)'); + skipped++; +} else { + test('instinct-cli.py assigns the same non-global id as detect-project.sh', () => { + const testDir = createTempDir(); + try { + const homeDir = path.join(testDir, 'home'); + const projectDir = path.join(testDir, 'shared-nongit-project'); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + + const shellId = detect(projectDir, homeDir).PROJECT_ID; + const pyId = pythonProjectId(projectDir, homeDir); + assert.ok(shellId && shellId !== 'global', `shell id should be real, got "${shellId}"`); + assert.ok(pyId && pyId !== 'global', `python id should be real, got "${pyId}"`); + assert.strictEqual( + pyId, + shellId, + `observer (${shellId}) and CLI (${pyId}) must agree so observations/instincts stay grouped` + ); + } finally { + cleanupDir(testDir); + } + }); +} + +console.log('\n=== Test Results ==='); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +console.log(`Skipped: ${skipped}`); +console.log(`Total: ${passed + failed + skipped}\n`); + +process.exit(failed > 0 ? 1 : 0);