From bc519e5b8ed42f26c0a5a611756e04351c323f21 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 19 May 2026 12:26:08 -0400 Subject: [PATCH] fix(learning): add project registry maintenance --- .../continuous-learning-v2/hooks/observe.sh | 8 +- .../scripts/detect-project.sh | 42 ++- .../scripts/instinct-cli.py | 331 +++++++++++++++++- tests/hooks/detect-project-worktree.test.js | 84 +++-- tests/hooks/hooks.test.js | 11 +- .../observe-subdirectory-detection.test.js | 21 +- tests/scripts/instinct-cli-projects.test.js | 260 ++++++++++++++ 7 files changed, 706 insertions(+), 51 deletions(-) create mode 100644 tests/scripts/instinct-cli-projects.test.js diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index ce65b8e0..0410912c 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -116,7 +116,13 @@ except(KeyError, TypeError, ValueError): # If cwd was provided in stdin, use it for project detection if [ -n "$STDIN_CWD" ] && [ -d "$STDIN_CWD" ]; then _GIT_ROOT=$(git -C "$STDIN_CWD" rev-parse --show-toplevel 2>/dev/null || true) - export CLAUDE_PROJECT_DIR="${_GIT_ROOT:-$STDIN_CWD}" + if [ -n "$_GIT_ROOT" ]; then + export CLAUDE_PROJECT_DIR="$_GIT_ROOT" + unset CLV2_NO_PROJECT + else + unset CLAUDE_PROJECT_DIR + export CLV2_NO_PROJECT=1 + fi fi # ───────────────────────────────────────────── diff --git a/skills/continuous-learning-v2/scripts/detect-project.sh b/skills/continuous-learning-v2/scripts/detect-project.sh index 66e541c4..dbe9c5ed 100755 --- a/skills/continuous-learning-v2/scripts/detect-project.sh +++ b/skills/continuous-learning-v2/scripts/detect-project.sh @@ -75,16 +75,42 @@ _clv2_normalize_remote_url() { fi } +_clv2_main_worktree_root() { + local root="$1" + [ -z "$root" ] && return 0 + command -v git >/dev/null 2>&1 || return 0 + + git -C "$root" worktree list --porcelain 2>/dev/null | while IFS= read -r line; do + case "$line" in + worktree\ *) + printf '%s\n' "${line#worktree }" + break + ;; + esac + done +} + _clv2_detect_project() { local project_root="" local project_name="" local project_id="" local source_hint="" + if [ "${CLV2_NO_PROJECT:-0}" = "1" ]; then + _CLV2_PROJECT_ID="global" + _CLV2_PROJECT_NAME="global" + _CLV2_PROJECT_ROOT="" + _CLV2_PROJECT_DIR="${_CLV2_HOMUNCULUS_DIR}" + mkdir -p "$_CLV2_PROJECT_DIR" + return 0 + fi + # 1. Try CLAUDE_PROJECT_DIR env var - if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ]; then - project_root="$CLAUDE_PROJECT_DIR" - source_hint="env" + 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" + fi fi # 2. Try git repo root from CWD (only if git is available) @@ -101,6 +127,7 @@ _clv2_detect_project() { _CLV2_PROJECT_NAME="global" _CLV2_PROJECT_ROOT="" _CLV2_PROJECT_DIR="${_CLV2_HOMUNCULUS_DIR}" + mkdir -p "$_CLV2_PROJECT_DIR" return 0 fi @@ -133,7 +160,14 @@ _clv2_detect_project() { normalized_remote=$(_clv2_normalize_remote_url "$remote_url") fi - local hash_input="${normalized_remote:-${remote_url:-$project_root}}" + local fallback_root="$project_root" + if [ -z "$remote_url" ]; then + local main_worktree_root + main_worktree_root=$(_clv2_main_worktree_root "$project_root") + [ -n "$main_worktree_root" ] && fallback_root="$main_worktree_root" + fi + + local hash_input="${normalized_remote:-${remote_url:-$fallback_root}}" # Prefer Python for consistent SHA256 behavior across shells/platforms. # Pass the value via env var and encode as UTF-8 inside Python so the hash # is locale-independent (shells vary between UTF-8 / CP932 / CP1252, which diff --git a/skills/continuous-learning-v2/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index 7b03bc0e..710f4c69 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -22,6 +22,7 @@ import os import subprocess import sys import re +import shutil import urllib.request from pathlib import Path from datetime import datetime, timedelta, timezone @@ -194,26 +195,64 @@ def _yaml_quote(value: str) -> str: # Project Detection (Python equivalent of detect-project.sh) # ───────────────────────────────────────────── +def _git_repo_root(cwd: Optional[str] = None) -> Optional[str]: + args = ["git"] + if cwd: + args.extend(["-C", cwd]) + args.extend(["rev-parse", "--show-toplevel"]) + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + return None + + +def _main_worktree_root(project_root: str) -> str: + """Return the main worktree root when project_root is a linked worktree.""" + try: + result = subprocess.run( + ["git", "-C", project_root, "worktree", "list", "--porcelain"], + capture_output=True, text=True, timeout=5 + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + return project_root + + if result.returncode != 0: + return project_root + + for line in result.stdout.splitlines(): + if line.startswith("worktree "): + main_root = line.split(" ", 1)[1].strip() + return main_root or project_root + return project_root + + def detect_project() -> dict: """Detect current project context. Returns dict with id, name, root, project_dir.""" project_root = None + if os.environ.get("CLV2_NO_PROJECT") == "1": + return { + "id": "global", + "name": "global", + "root": "", + "project_dir": HOMUNCULUS_DIR, + "instincts_personal": GLOBAL_PERSONAL_DIR, + "instincts_inherited": GLOBAL_INHERITED_DIR, + "evolved_dir": GLOBAL_EVOLVED_DIR, + "observations_file": GLOBAL_OBSERVATIONS_FILE, + } + # 1. CLAUDE_PROJECT_DIR env var env_dir = os.environ.get("CLAUDE_PROJECT_DIR") if env_dir and os.path.isdir(env_dir): - project_root = env_dir + project_root = _git_repo_root(env_dir) # 2. git repo root if not project_root: - try: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, text=True, timeout=5 - ) - if result.returncode == 0: - project_root = result.stdout.strip() - except (subprocess.TimeoutExpired, FileNotFoundError): - pass + project_root = _git_repo_root() # Normalize: strip trailing slashes to keep basename and hash stable if project_root: @@ -250,9 +289,10 @@ def detect_project() -> dict: if remote_url: remote_url = _strip_remote_credentials(remote_url) + fallback_root = _main_worktree_root(project_root) if not remote_url else project_root legacy_hash_source = remote_url if remote_url else project_root normalized_remote = _normalize_remote_url(remote_url) if remote_url else "" - hash_source = normalized_remote if normalized_remote else legacy_hash_source + hash_source = normalized_remote if normalized_remote else (remote_url if remote_url else fallback_root) project_id = _project_hash(hash_source) project_dir = PROJECTS_DIR / project_id @@ -352,6 +392,26 @@ def load_registry() -> dict: return {} +def _write_registry(registry: dict) -> None: + """Write the project registry atomically.""" + REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp_file = REGISTRY_FILE.parent / f".{REGISTRY_FILE.name}.tmp.{os.getpid()}" + with open(tmp_file, "w", encoding="utf-8") as f: + json.dump(registry, f, indent=2) + f.write("\n") + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_file, REGISTRY_FILE) + + +def _validate_project_id(project_id: str) -> bool: + if not project_id or len(project_id) > 128: + return False + if "/" in project_id or "\\" in project_id or ".." in project_id: + return False + return bool(re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*$", project_id)) + + # ───────────────────────────────────────────── # Instinct Parser # ───────────────────────────────────────────── @@ -436,6 +496,96 @@ def _load_instincts_from_dir(directory: Path, source_type: str, scope_label: str return instincts +def _project_counts(project_id: str) -> dict: + project_dir = PROJECTS_DIR / project_id + personal_dir = project_dir / "instincts" / "personal" + inherited_dir = project_dir / "instincts" / "inherited" + observations_file = project_dir / "observations.jsonl" + + personal_count = len(_load_instincts_from_dir(personal_dir, "personal", "project")) + inherited_count = len(_load_instincts_from_dir(inherited_dir, "inherited", "project")) + observations_count = 0 + if observations_file.exists(): + try: + with open(observations_file, encoding="utf-8") as f: + observations_count = sum(1 for _ in f) + except OSError: + observations_count = 0 + + return { + "personal": personal_count, + "inherited": inherited_count, + "observations": observations_count, + "total": personal_count + inherited_count + observations_count, + } + + +def _remove_project_storage(project_id: str) -> None: + project_dir = PROJECTS_DIR / project_id + if project_dir.exists(): + shutil.rmtree(project_dir) + + +def _project_instinct_ids(project_dir: Path, source_type: str) -> set[str]: + instinct_dir = project_dir / "instincts" / source_type + return { + inst.get("id") + for inst in _load_instincts_from_dir(instinct_dir, source_type, "project") + if inst.get("id") + } + + +def _merge_instinct_dir(from_dir: Path, into_dir: Path, existing_ids: set[str]) -> tuple[int, int]: + moved = 0 + skipped = 0 + if not from_dir.exists(): + return moved, skipped + + into_dir.mkdir(parents=True, exist_ok=True) + for file_path in sorted(from_dir.iterdir()): + if not file_path.is_file() or file_path.suffix.lower() not in ALLOWED_INSTINCT_EXTENSIONS: + continue + try: + instincts = parse_instinct_file(file_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError): + instincts = [] + instinct_ids = [inst.get("id") for inst in instincts if inst.get("id")] + if any(instinct_id in existing_ids for instinct_id in instinct_ids): + skipped += 1 + continue + + target_path = into_dir / file_path.name + if target_path.exists(): + target_path = into_dir / f"{file_path.stem}-{_project_hash(str(file_path))}{file_path.suffix}" + shutil.copy2(file_path, target_path) + existing_ids.update(instinct_ids) + moved += 1 + + return moved, skipped + + +def _append_observations(from_project_dir: Path, into_project_dir: Path) -> int: + from_file = from_project_dir / "observations.jsonl" + if not from_file.exists(): + return 0 + + into_file = into_project_dir / "observations.jsonl" + into_file.parent.mkdir(parents=True, exist_ok=True) + try: + lines = from_file.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + return 0 + + if not lines: + return 0 + + with open(into_file, "a", encoding="utf-8") as f: + for line in lines: + if line.strip(): + f.write(line.rstrip("\n") + "\n") + return len([line for line in lines if line.strip()]) + + def load_all_instincts(project: dict, include_global: bool = True) -> list[dict]: """Load all instincts: project-scoped + global. @@ -1180,7 +1330,14 @@ def _promote_auto(project: dict, force: bool, dry_run: bool) -> int: # ───────────────────────────────────────────── def cmd_projects(args) -> int: - """List all known projects and their instinct counts.""" + """List or maintain known projects and their instinct counts.""" + if getattr(args, "project_action", None) == "delete": + return _cmd_projects_delete(args) + if getattr(args, "project_action", None) == "merge": + return _cmd_projects_merge(args) + if getattr(args, "project_action", None) == "gc": + return _cmd_projects_gc(args) + registry = load_registry() if not registry: @@ -1225,6 +1382,143 @@ def cmd_projects(args) -> int: return 0 +def _cmd_projects_delete(args) -> int: + registry = load_registry() + project_id = args.project_id + + if not _validate_project_id(project_id): + print(f"Invalid project ID: {project_id}", file=sys.stderr) + return 1 + if project_id not in registry and not (PROJECTS_DIR / project_id).exists(): + print(f"Project '{project_id}' not found.", file=sys.stderr) + return 1 + + counts = _project_counts(project_id) + print(f"Project: {project_id}") + print(f" Instincts: {counts['personal']} personal, {counts['inherited']} inherited") + print(f" Observations: {counts['observations']} events") + + if args.dry_run: + print(f"\n[DRY RUN] Would delete project '{project_id}' from registry and storage.") + return 0 + + if not args.force: + if counts["total"] > 0: + print("\nWarning: this project has instincts or observations.") + response = input(f"Delete project '{project_id}'? [y/N] ") + if response.lower() != "y": + print("Cancelled.") + return 0 + + registry.pop(project_id, None) + _write_registry(registry) + _remove_project_storage(project_id) + print(f"\nDeleted project '{project_id}'.") + return 0 + + +def _cmd_projects_gc(args) -> int: + registry = load_registry() + candidates = [ + project_id + for project_id in sorted(registry) + if _validate_project_id(project_id) and _project_counts(project_id)["total"] == 0 + ] + + if not candidates: + print("No zero-value project entries found.") + return 0 + + print(f"Zero-value project entries: {len(candidates)}") + for project_id in candidates: + pinfo = registry.get(project_id, {}) + print(f" - {pinfo.get('name', project_id)} [{project_id}]") + + if args.dry_run: + print(f"\n[DRY RUN] Would delete {len(candidates)} project entr{'y' if len(candidates) == 1 else 'ies'}.") + return 0 + + if not args.force: + response = input(f"\nDelete {len(candidates)} zero-value project entr{'y' if len(candidates) == 1 else 'ies'}? [y/N] ") + if response.lower() != "y": + print("Cancelled.") + return 0 + + for project_id in candidates: + registry.pop(project_id, None) + _remove_project_storage(project_id) + _write_registry(registry) + print(f"\nDeleted {len(candidates)} zero-value project entr{'y' if len(candidates) == 1 else 'ies'}.") + return 0 + + +def _cmd_projects_merge(args) -> int: + from_id = args.from_id + into_id = args.into_id + + if not _validate_project_id(from_id) or not _validate_project_id(into_id): + print("Invalid project ID.", file=sys.stderr) + return 1 + if from_id == into_id: + print("Cannot merge a project into itself.", file=sys.stderr) + return 1 + + registry = load_registry() + if from_id not in registry: + print(f"Source project '{from_id}' not found.", file=sys.stderr) + return 1 + if into_id not in registry: + print(f"Destination project '{into_id}' not found.", file=sys.stderr) + return 1 + + from_counts = _project_counts(from_id) + into_counts = _project_counts(into_id) + print(f"Merge: {from_id} -> {into_id}") + print(f" Source: {from_counts['personal']} personal, {from_counts['inherited']} inherited, {from_counts['observations']} observations") + print(f" Destination before merge: {into_counts['personal']} personal, {into_counts['inherited']} inherited, {into_counts['observations']} observations") + + if args.dry_run: + print("\n[DRY RUN] Would merge source project into destination and remove source.") + return 0 + + if not args.force: + response = input(f"\nMerge '{from_id}' into '{into_id}' and remove source? [y/N] ") + if response.lower() != "y": + print("Cancelled.") + return 0 + + from_project_dir = PROJECTS_DIR / from_id + into_project_dir = PROJECTS_DIR / into_id + into_project_dir.mkdir(parents=True, exist_ok=True) + + personal_existing = _project_instinct_ids(into_project_dir, "personal") + inherited_existing = _project_instinct_ids(into_project_dir, "inherited") + personal_moved, personal_skipped = _merge_instinct_dir( + from_project_dir / "instincts" / "personal", + into_project_dir / "instincts" / "personal", + personal_existing, + ) + inherited_moved, inherited_skipped = _merge_instinct_dir( + from_project_dir / "instincts" / "inherited", + into_project_dir / "instincts" / "inherited", + inherited_existing, + ) + observations_moved = _append_observations(from_project_dir, into_project_dir) + + registry.pop(from_id, None) + destination = registry.get(into_id, {}) + destination["last_seen"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + registry[into_id] = destination + _write_registry(registry) + _remove_project_storage(from_id) + + print("\nMerged project registry entry.") + print(f" Moved instincts: {personal_moved + inherited_moved}") + print(f" Skipped duplicate instincts: {personal_skipped + inherited_skipped}") + print(f" Appended observations: {observations_moved}") + return 0 + + # ───────────────────────────────────────────── # Generate Evolved Structures # ───────────────────────────────────────────── @@ -1486,6 +1780,19 @@ def main() -> int: # Projects (new in v2.1) projects_parser = subparsers.add_parser('projects', help='List known projects and instinct counts') + projects_subparsers = projects_parser.add_subparsers(dest='project_action') + projects_delete = projects_subparsers.add_parser('delete', help='Delete a project registry entry') + projects_delete.add_argument('project_id', help='Project ID to delete') + projects_delete.add_argument('--dry-run', action='store_true', help='Preview without deleting') + projects_delete.add_argument('--force', action='store_true', help='Skip confirmation') + projects_merge = projects_subparsers.add_parser('merge', help='Merge one project registry entry into another') + projects_merge.add_argument('from_id', help='Source project ID') + projects_merge.add_argument('into_id', help='Destination project ID') + projects_merge.add_argument('--dry-run', action='store_true', help='Preview without merging') + projects_merge.add_argument('--force', action='store_true', help='Skip confirmation') + projects_gc = projects_subparsers.add_parser('gc', help='Delete zero-value project registry entries') + projects_gc.add_argument('--dry-run', action='store_true', help='Preview without deleting') + projects_gc.add_argument('--force', action='store_true', help='Skip confirmation') # Prune (pending instinct TTL) prune_parser = subparsers.add_parser('prune', help='Delete pending instincts older than TTL') diff --git a/tests/hooks/detect-project-worktree.test.js b/tests/hooks/detect-project-worktree.test.js index 9c0a8fa2..a82a6f73 100644 --- a/tests/hooks/detect-project-worktree.test.js +++ b/tests/hooks/detect-project-worktree.test.js @@ -181,29 +181,14 @@ test('detect-project.sh sets PROJECT_NAME and non-global PROJECT_ID for worktree } }); - // Create a worktree-like directory with .git as a file const worktreeDir = path.join(testDir, 'my-worktree'); - fs.mkdirSync(worktreeDir, { recursive: true }); - - // Set up the worktree directory structure in the main repo - const worktreesDir = path.join(mainRepo, '.git', 'worktrees', 'my-worktree'); - fs.mkdirSync(worktreesDir, { recursive: true }); - - // Create the gitdir file and commondir in the worktree metadata - const mainGitDir = path.join(mainRepo, '.git'); - fs.writeFileSync( - path.join(worktreesDir, 'commondir'), - '../..\n' - ); - fs.writeFileSync( - path.join(worktreesDir, 'HEAD'), - fs.readFileSync(path.join(mainGitDir, 'HEAD'), 'utf8') - ); - - // Write .git file in the worktree directory (this is what git worktree creates) - fs.writeFileSync( - path.join(worktreeDir, '.git'), - `gitdir: ${worktreesDir}\n` + execSync(`git worktree add "${worktreeDir}" -b feature/project-id`, { + cwd: mainRepo, + stdio: 'pipe' + }); + assert.ok( + fs.statSync(path.join(worktreeDir, '.git')).isFile(), + 'linked worktree should expose .git as a file' ); // Source detect-project.sh from the worktree directory and capture results @@ -248,6 +233,61 @@ test('detect-project.sh sets PROJECT_NAME and non-global PROJECT_ID for worktree } }); +test('detect-project.sh uses the main worktree hash when no remote exists', () => { + const testDir = createTempDir(); + + try { + const mainRepo = path.join(testDir, 'main-repo'); + const worktreeDir = path.join(testDir, 'feature-worktree'); + const homeDir = path.join(testDir, 'home'); + fs.mkdirSync(mainRepo, { recursive: true }); + fs.mkdirSync(homeDir, { recursive: true }); + execSync('git init', { cwd: mainRepo, stdio: 'pipe' }); + execSync('git commit --allow-empty -m "init"', { + cwd: mainRepo, + stdio: 'pipe', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'Test', + GIT_AUTHOR_EMAIL: 'test@test.com', + GIT_COMMITTER_NAME: 'Test', + GIT_COMMITTER_EMAIL: 'test@test.com' + } + }); + execSync(`git worktree add "${worktreeDir}" -b feature/no-remote`, { + cwd: mainRepo, + stdio: 'pipe' + }); + + function detectId(targetDir) { + const script = ` + export HOME="${toBashPath(homeDir)}" + export USERPROFILE="${toBashPath(homeDir)}" + export CLAUDE_PROJECT_DIR="${toBashPath(targetDir)}" + source "${toBashPath(detectProjectPath)}" >/dev/null + printf "%s" "$PROJECT_ID" + `; + return execFileSync('bash', ['-lc', script], { + cwd: targetDir, + timeout: 10000, + env: { + ...process.env, + HOME: toBashPath(homeDir), + USERPROFILE: toBashPath(homeDir), + CLAUDE_PROJECT_DIR: toBashPath(targetDir) + } + }).toString(); + } + + const mainId = detectId(mainRepo); + const worktreeId = detectId(worktreeDir); + assert.ok(mainId && mainId !== 'global', 'main repo should get a project id'); + assert.strictEqual(worktreeId, mainId, 'linked worktree should share the main worktree project id'); + } finally { + cleanupDir(testDir); + } +}); + // ────────────────────────────────────────────────────── // Summary // ────────────────────────────────────────────────────── diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 6475fe54..7c69f988 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -3300,11 +3300,14 @@ async function runTests() { assert.strictEqual(result.code, 0, `observe.sh should exit successfully, stderr: ${result.stderr}`); - const projectsDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects'); - const projectIds = fs.readdirSync(projectsDir); - assert.strictEqual(projectIds.length, 1, 'observe.sh should create one project-scoped observation directory'); + const homunculusDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus'); + const projectsDir = path.join(homunculusDir, 'projects'); + assert.ok( + !fs.existsSync(projectsDir) || fs.readdirSync(projectsDir).length === 0, + 'observe.sh should not create a project-scoped directory for a non-git cwd' + ); - const observationsPath = path.join(projectsDir, projectIds[0], 'observations.jsonl'); + const observationsPath = path.join(homunculusDir, 'observations.jsonl'); const observations = fs.readFileSync(observationsPath, 'utf8').trim().split('\n').filter(Boolean); assert.ok(observations.length > 0, 'observe.sh should append at least one observation'); diff --git a/tests/hooks/observe-subdirectory-detection.test.js b/tests/hooks/observe-subdirectory-detection.test.js index 5081efa8..559f0cff 100644 --- a/tests/hooks/observe-subdirectory-detection.test.js +++ b/tests/hooks/observe-subdirectory-detection.test.js @@ -135,8 +135,8 @@ test('observe.sh resolves cwd to git root before setting CLAUDE_PROJECT_DIR', () 'observe.sh should resolve STDIN_CWD to git repo root' ); assert.ok( - content.includes('${_GIT_ROOT:-$STDIN_CWD}'), - 'observe.sh should fall back to raw cwd when git root is unavailable' + content.includes('export CLV2_NO_PROJECT=1'), + 'observe.sh should mark non-git cwd payloads as global instead of registering raw cwd' ); }); @@ -250,7 +250,7 @@ test('observe.sh falls back to CLAUDE_HOOK_EVENT_NAME when no phase argument is } }); -test('observe.sh keeps the raw cwd when the directory is not inside a git repo', () => { +test('observe.sh records non-git cwd payloads globally without project registry side effects', () => { const testRoot = createTempDir(); try { @@ -262,12 +262,17 @@ test('observe.sh keeps the raw cwd when the directory is not inside a git repo', const result = runObserve({ homeDir, cwd: nonGitDir }); assert.strictEqual(result.status, 0, result.stderr); - const { metadata } = readSingleProjectMetadata(homeDir); - assert.strictEqual( - normalizeComparablePath(metadata.root), - normalizeComparablePath(nonGitDir), - 'project metadata root should stay on the non-git cwd' + const homunculusDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus'); + const projectsDir = path.join(homunculusDir, 'projects'); + const registryPath = path.join(homunculusDir, 'projects.json'); + const observationsPath = path.join(homunculusDir, 'observations.jsonl'); + + assert.ok(!fs.existsSync(registryPath), 'non-git cwd should not create projects.json'); + assert.ok( + !fs.existsSync(projectsDir) || fs.readdirSync(projectsDir).length === 0, + 'non-git cwd should not create project directories' ); + assert.ok(fs.existsSync(observationsPath), 'non-git cwd should still record a global observation'); } finally { cleanupDir(testRoot); } diff --git a/tests/scripts/instinct-cli-projects.test.js b/tests/scripts/instinct-cli-projects.test.js new file mode 100644 index 00000000..3ebd8651 --- /dev/null +++ b/tests/scripts/instinct-cli-projects.test.js @@ -0,0 +1,260 @@ +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); + } +}); + +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}`); + +process.exit(failed > 0 ? 1 : 0);