fix(continuous-learning-v2): honor non-git CLAUDE_PROJECT_DIR (#2488)

Honor an explicit non-git CLAUDE_PROJECT_DIR as an isolated project scope, canonicalize and hash it consistently in the shell observer and Python CLI, and preserve the global fallback for arbitrary non-git working directories.
This commit is contained in:
Gaurav Dubey
2026-07-26 00:04:35 -07:00
committed by GitHub
parent 28b922dee3
commit 5debb798c8
4 changed files with 318 additions and 7 deletions
+1 -1
View File
@@ -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
@@ -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
@@ -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:
+290
View File
@@ -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);