From 12e1bc424db45011baa5ee7adb5f246369f1a452 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 11 May 2026 03:35:42 -0400 Subject: [PATCH] fix: port continuous-learning observer fixes Ports continuous-learning observer signal, storage, remote normalization, and v1 deprecation fixes onto current main. --- scripts/hooks/session-start.js | 5 +- scripts/lib/observer-sessions.js | 43 +++++- skills/continuous-learning-v2/SKILL.md | 24 +++- .../agents/observer-loop.sh | 24 +++- .../continuous-learning-v2/agents/observer.md | 8 +- .../agents/start-observer.sh | 6 +- .../continuous-learning-v2/hooks/observe.sh | 10 +- .../scripts/detect-project.sh | 71 ++++++++-- .../scripts/instinct-cli.py | 71 +++++++++- .../scripts/lib/homunculus-dir.sh | 31 ++++ .../scripts/migrate-homunculus.sh | 62 ++++++++ skills/continuous-learning/SKILL.md | 12 +- tests/hooks/hooks.test.js | 11 +- .../observe-subdirectory-detection.test.js | 2 +- tests/hooks/observer-memory.test.js | 22 ++- tests/integration/hooks.test.js | 32 +++-- tests/lib/observer-sessions.test.js | 134 ++++++++++++++++++ 17 files changed, 512 insertions(+), 56 deletions(-) create mode 100644 skills/continuous-learning-v2/scripts/lib/homunculus-dir.sh create mode 100755 skills/continuous-learning-v2/scripts/migrate-homunculus.sh create mode 100644 tests/lib/observer-sessions.test.js diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index 3994d9c2..7b5192dc 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -10,7 +10,6 @@ */ const { - getClaudeDir, getSessionsDir, getSessionSearchDirs, getLearnedSkillsDir, @@ -21,7 +20,7 @@ const { stripAnsi, log } = require('../lib/utils'); -const { resolveProjectContext, writeSessionLease, resolveSessionId } = require('../lib/observer-sessions'); +const { resolveProjectContext, writeSessionLease, resolveSessionId, getHomunculusDir } = require('../lib/observer-sessions'); const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager'); const { listAliases } = require('../lib/session-aliases'); const { detectProjectType } = require('../lib/project-detect'); @@ -325,7 +324,7 @@ function extractInstinctAction(content) { } function summarizeActiveInstincts(observerContext) { - const homunculusDir = path.join(getClaudeDir(), 'homunculus'); + const homunculusDir = getHomunculusDir(); const globalDirs = [ { dir: path.join(homunculusDir, 'instincts', 'personal'), scope: 'global' }, { dir: path.join(homunculusDir, 'instincts', 'inherited'), scope: 'global' }, diff --git a/scripts/lib/observer-sessions.js b/scripts/lib/observer-sessions.js index 44742a3c..08296da3 100644 --- a/scripts/lib/observer-sessions.js +++ b/scripts/lib/observer-sessions.js @@ -1,11 +1,28 @@ const fs = require('fs'); +const os = require('os'); const path = require('path'); const crypto = require('crypto'); const { spawnSync } = require('child_process'); -const { getClaudeDir, ensureDir, sanitizeSessionId } = require('./utils'); +const { ensureDir, sanitizeSessionId } = require('./utils'); function getHomunculusDir() { - return path.join(getClaudeDir(), 'homunculus'); + const override = process.env.CLV2_HOMUNCULUS_DIR; + if (override) { + if (path.isAbsolute(override)) { + return override; + } + process.stderr.write(`[ecc] CLV2_HOMUNCULUS_DIR=${override} is not absolute; ignoring\n`); + } + + const xdgDataHome = process.env.XDG_DATA_HOME; + if (xdgDataHome) { + if (path.isAbsolute(xdgDataHome)) { + return path.join(xdgDataHome, 'ecc-homunculus'); + } + process.stderr.write(`[ecc] XDG_DATA_HOME=${xdgDataHome} is not absolute; ignoring\n`); + } + + return path.join(os.homedir(), '.local', 'share', 'ecc-homunculus'); } function getProjectsDir() { @@ -39,6 +56,23 @@ function stripRemoteCredentials(remoteUrl) { return String(remoteUrl).replace(/:\/\/[^@]+@/, '://'); } +function normalizeRemoteUrl(remoteUrl) { + if (!remoteUrl) return ''; + const raw = String(remoteUrl); + const isNetwork = !raw.startsWith('file://') && (raw.includes('://') || /^[^@/:]+@[^:/]+:/.test(raw)); + let normalized = stripRemoteCredentials(raw) + .replace(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//, '') + .replace(/^[^@/:]+@([^:/]+):/, '$1/') + .replace(/\.git\/?$/, '') + .replace(/\/+$/, ''); + + if (isNetwork) { + normalized = normalized.toLowerCase(); + } + + return normalized; +} + function resolveProjectRoot(cwd = process.cwd()) { const envRoot = process.env.CLAUDE_PROJECT_DIR; if (envRoot && fs.existsSync(envRoot)) { @@ -53,7 +87,8 @@ function resolveProjectRoot(cwd = process.cwd()) { function computeProjectId(projectRoot) { const remoteUrl = stripRemoteCredentials(runGit(['remote', 'get-url', 'origin'], projectRoot)); - return crypto.createHash('sha256').update(remoteUrl || projectRoot).digest('hex').slice(0, 12); + const hashInput = normalizeRemoteUrl(remoteUrl) || remoteUrl || projectRoot; + return crypto.createHash('sha256').update(hashInput).digest('hex').slice(0, 12); } function resolveProjectContext(cwd = process.cwd()) { @@ -163,6 +198,8 @@ function stopObserverForContext(context) { } module.exports = { + getHomunculusDir, + normalizeRemoteUrl, resolveProjectContext, getObserverActivityFile, getObserverPidFile, diff --git a/skills/continuous-learning-v2/SKILL.md b/skills/continuous-learning-v2/SKILL.md index 5153a2aa..5604ed27 100644 --- a/skills/continuous-learning-v2/SKILL.md +++ b/skills/continuous-learning-v2/SKILL.md @@ -26,7 +26,7 @@ An advanced learning system that turns your Claude Code sessions into reusable k | Feature | v2.0 | v2.1 | |---------|------|------| -| Storage | Global (~/.claude/homunculus/) | Project-scoped (projects//) | +| Storage | Global (`~/.claude/homunculus/`) | Project-scoped (`${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects//`) | | Scope | All instincts apply everywhere | Project-scoped + global | | Detection | None | git remote URL / repo path | | Promotion | N/A | Project → global when seen in 2+ projects | @@ -132,7 +132,21 @@ The system automatically detects your current project: 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 -Each project gets a 12-character hash ID (e.g., `a1b2c3d4e5f6`). A registry file at `~/.claude/homunculus/projects.json` maps IDs to human-readable names. +Each project gets a 12-character hash ID (e.g., `a1b2c3d4e5f6`). A registry file at `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects.json` maps IDs to human-readable names. + +### Data Directory + +Continuous-learning-v2 stores observer data outside `~/.claude` so Claude Code's sensitive-path guard does not block background instinct writes: + +1. `CLV2_HOMUNCULUS_DIR` when set to an absolute path +2. `$XDG_DATA_HOME/ecc-homunculus` +3. `$HOME/.local/share/ecc-homunculus` + +Existing users with data at `~/.claude/homunculus` can migrate once: + +```bash +bash skills/continuous-learning-v2/scripts/migrate-homunculus.sh +``` ## Quick Start @@ -173,7 +187,7 @@ The system creates directories automatically on first use, but you can also crea ```bash # Global directories -mkdir -p ~/.claude/homunculus/{instincts/{personal,inherited},evolved/{agents,skills,commands},projects} +mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/ecc-homunculus"/{instincts/{personal,inherited},evolved/{agents,skills,commands},projects} # Project directories are auto-created when the hook first runs in a git repo ``` @@ -226,7 +240,7 @@ Other behavior (observation capture, instinct thresholds, project scoping, promo ## File Structure ``` -~/.claude/homunculus/ +${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/ +-- identity.json # Your profile, technical level +-- projects.json # Registry: project hash -> name/path/remote +-- observations.jsonl # Global observations (fallback) @@ -322,7 +336,7 @@ Hooks fire **100% of the time**, deterministically. This means: ## Backward Compatibility v2.1 is fully compatible with v2.0 and v1: -- Existing global instincts in `~/.claude/homunculus/instincts/` still work as global instincts +- Existing global instincts can be migrated from `~/.claude/homunculus/instincts/` with `scripts/migrate-homunculus.sh` - Existing `~/.claude/skills/learned/` skills from v1 still work - Stop hook still runs (but now also feeds into v2) - Gradual migration: run both in parallel diff --git a/skills/continuous-learning-v2/agents/observer-loop.sh b/skills/continuous-learning-v2/agents/observer-loop.sh index f18d8ae5..a899124a 100755 --- a/skills/continuous-learning-v2/agents/observer-loop.sh +++ b/skills/continuous-learning-v2/agents/observer-loop.sh @@ -10,6 +10,7 @@ unset CLAUDECODE SLEEP_PID="" USR1_FIRED=0 +PENDING_ANALYSIS=0 ANALYZING=0 LAST_ANALYSIS_EPOCH=0 # Minimum seconds between analyses (prevents rapid re-triggering) @@ -258,14 +259,17 @@ PROMPT on_usr1() { [ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null SLEEP_PID="" - USR1_FIRED=1 - # Re-entrancy guard: skip if analysis is already running (#521) + # Re-entrancy guard: defer the nudge so the main loop runs a follow-up + # analysis immediately after the current analysis finishes. if [ "$ANALYZING" -eq 1 ]; then - echo "[$(date)] Analysis already in progress, skipping signal" >> "$LOG_FILE" + PENDING_ANALYSIS=1 + echo "[$(date)] Analysis already in progress, deferring signal" >> "$LOG_FILE" return fi + USR1_FIRED=1 + # Cooldown: skip if last analysis was too recent (#521) now_epoch=$(date +%s) elapsed=$(( now_epoch - LAST_ANALYSIS_EPOCH )) @@ -290,6 +294,17 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" while true; do exit_if_idle_without_sessions + + if [ "$PENDING_ANALYSIS" -eq 1 ]; then + PENDING_ANALYSIS=0 + USR1_FIRED=0 + ANALYZING=1 + analyze_observations + LAST_ANALYSIS_EPOCH=$(date +%s) + ANALYZING=0 + continue + fi + sleep "$OBSERVER_INTERVAL_SECONDS" & SLEEP_PID=$! wait "$SLEEP_PID" 2>/dev/null @@ -299,6 +314,9 @@ while true; do if [ "$USR1_FIRED" -eq 1 ]; then USR1_FIRED=0 else + ANALYZING=1 analyze_observations + LAST_ANALYSIS_EPOCH=$(date +%s) + ANALYZING=0 fi done diff --git a/skills/continuous-learning-v2/agents/observer.md b/skills/continuous-learning-v2/agents/observer.md index f0062688..e03845e5 100644 --- a/skills/continuous-learning-v2/agents/observer.md +++ b/skills/continuous-learning-v2/agents/observer.md @@ -17,8 +17,8 @@ A background agent that analyzes observations from Claude Code sessions to detec ## Input Reads observations from the **project-scoped** observations file: -- Project: `~/.claude/homunculus/projects//observations.jsonl` -- Global fallback: `~/.claude/homunculus/observations.jsonl` +- Project: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects//observations.jsonl` +- Global fallback: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/observations.jsonl` ```jsonl {"timestamp":"2025-01-22T10:30:00Z","event":"tool_start","session":"abc123","tool":"Edit","input":"...","project_id":"a1b2c3d4e5f6","project_name":"my-react-app"} @@ -66,8 +66,8 @@ When certain tools are consistently preferred: ## Output Creates/updates instincts in the **project-scoped** instincts directory: -- Project: `~/.claude/homunculus/projects//instincts/personal/` -- Global: `~/.claude/homunculus/instincts/personal/` (for universal patterns) +- Project: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects//instincts/personal/` +- Global: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/instincts/personal/` (for universal patterns) ### Project-Scoped Instinct (default) diff --git a/skills/continuous-learning-v2/agents/start-observer.sh b/skills/continuous-learning-v2/agents/start-observer.sh index e9418a5c..c3ada314 100755 --- a/skills/continuous-learning-v2/agents/start-observer.sh +++ b/skills/continuous-learning-v2/agents/start-observer.sh @@ -35,9 +35,13 @@ PYTHON_CMD="${CLV2_PYTHON_CMD:-}" # Configuration # ───────────────────────────────────────────── -CONFIG_DIR="${HOME}/.claude/homunculus" +# shellcheck disable=SC1091 +. "${SKILL_ROOT}/scripts/lib/homunculus-dir.sh" +CONFIG_DIR="$(_ecc_resolve_homunculus_dir)" if [ -n "${CLV2_CONFIG:-}" ]; then CONFIG_FILE="$CLV2_CONFIG" +elif [ -f "${CONFIG_DIR}/config.json" ]; then + CONFIG_FILE="${CONFIG_DIR}/config.json" else CONFIG_FILE="${SKILL_ROOT}/config.json" fi diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index 9ed6cf8b..6631f702 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -115,7 +115,9 @@ fi # Sourcing detect-project.sh creates project-scoped directories and updates # projects.json, so automated sessions must return before that point. -CONFIG_DIR="${HOME}/.claude/homunculus" +# shellcheck disable=SC1091 +. "$(dirname "$0")/../scripts/lib/homunculus-dir.sh" +CONFIG_DIR="$(_ecc_resolve_homunculus_dir)" # Skip if disabled (check both default and CLV2_CONFIG-derived locations) if [ -f "$CONFIG_DIR/disabled" ]; then @@ -344,10 +346,12 @@ if [ -f "${CONFIG_DIR}/disabled" ]; then OBSERVER_ENABLED=false else OBSERVER_ENABLED=false - CONFIG_FILE="${SKILL_ROOT}/config.json" - # Allow CLV2_CONFIG override if [ -n "${CLV2_CONFIG:-}" ]; then CONFIG_FILE="$CLV2_CONFIG" + elif [ -f "${CONFIG_DIR}/config.json" ]; then + CONFIG_FILE="${CONFIG_DIR}/config.json" + else + CONFIG_FILE="${SKILL_ROOT}/config.json" fi # Use effective config path for both existence check and reading EFFECTIVE_CONFIG="$CONFIG_FILE" diff --git a/skills/continuous-learning-v2/scripts/detect-project.sh b/skills/continuous-learning-v2/scripts/detect-project.sh index 8cf1be2d..66e541c4 100755 --- a/skills/continuous-learning-v2/scripts/detect-project.sh +++ b/skills/continuous-learning-v2/scripts/detect-project.sh @@ -19,7 +19,9 @@ # 3. git repo root path (fallback, machine-specific) # 4. "global" (no project context detected) -_CLV2_HOMUNCULUS_DIR="${HOME}/.claude/homunculus" +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib/homunculus-dir.sh" +_CLV2_HOMUNCULUS_DIR="$(_ecc_resolve_homunculus_dir)" _CLV2_PROJECTS_DIR="${_CLV2_HOMUNCULUS_DIR}/projects" _CLV2_REGISTRY_FILE="${_CLV2_HOMUNCULUS_DIR}/projects.json" @@ -49,6 +51,30 @@ export CLV2_PYTHON_CMD CLV2_OBSERVER_PROMPT_PATTERN='Can you confirm|requires permission|Awaiting (user confirmation|confirmation|approval|permission)|confirm I should proceed|once granted access|grant.*access' export CLV2_OBSERVER_PROMPT_PATTERN +_clv2_normalize_remote_url() { + local url="$1" + [ -z "$url" ] && return 0 + + local is_network=0 + case "$url" in + file://*) is_network=0 ;; + *://*) is_network=1 ;; + *@*:*) is_network=1 ;; + *) is_network=0 ;; + esac + + url=$(printf '%s' "$url" | sed -E 's|://[^@]+@|://|') + url=$(printf '%s' "$url" | sed -E 's|^[A-Za-z][A-Za-z0-9+.-]*://||') + url=$(printf '%s' "$url" | sed -E 's|^[^@/:]+@([^:/]+):|\1/|') + url=$(printf '%s' "$url" | sed -E 's|\.git/?$||; s|/+$||') + + if [ "$is_network" = "1" ]; then + printf '%s' "$url" | tr '[:upper:]' '[:lower:]' + else + printf '%s' "$url" + fi +} + _clv2_detect_project() { local project_root="" local project_name="" @@ -94,15 +120,20 @@ _clv2_detect_project() { fi fi - # Compute hash from the original remote URL (legacy, for backward compatibility) - local legacy_hash_input="${remote_url:-$project_root}" + local raw_remote_url="$remote_url" # Strip embedded credentials from remote URL (e.g., https://ghp_xxxx@github.com/...) if [ -n "$remote_url" ]; then remote_url=$(printf '%s' "$remote_url" | sed -E 's|://[^@]+@|://|') fi - local hash_input="${remote_url:-$project_root}" + local legacy_hash_input="${remote_url:-$project_root}" + local normalized_remote="" + if [ -n "$remote_url" ]; then + normalized_remote=$(_clv2_normalize_remote_url "$remote_url") + fi + + local hash_input="${normalized_remote:-${remote_url:-$project_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 @@ -122,19 +153,33 @@ print(hashlib.sha256(s.encode("utf-8")).hexdigest()[:12]) echo "fallback") fi - # Backward compatibility: if credentials were stripped and the hash changed, - # check if a project dir exists under the legacy hash and reuse it - if [ "$legacy_hash_input" != "$hash_input" ] && [ -n "$_CLV2_PYTHON_CMD" ]; then - local legacy_id="" - legacy_id=$(_CLV2_HASH_INPUT="$legacy_hash_input" "$_CLV2_PYTHON_CMD" -c ' + # Backward compatibility: migrate a single legacy project directory from + # credential-stripped or raw remote hashes to the normalized remote hash. + if [ -n "$_CLV2_PYTHON_CMD" ] && [ ! -d "${_CLV2_PROJECTS_DIR}/${project_id}" ]; then + local legacy_inputs=() + [ -n "$legacy_hash_input" ] && [ "$legacy_hash_input" != "$hash_input" ] \ + && legacy_inputs+=("$legacy_hash_input") + [ -n "$raw_remote_url" ] && [ "$raw_remote_url" != "$hash_input" ] \ + && [ "$raw_remote_url" != "$legacy_hash_input" ] \ + && legacy_inputs+=("$raw_remote_url") + + local legacy_input legacy_id + for legacy_input in "${legacy_inputs[@]}"; do + legacy_id=$(_CLV2_HASH_INPUT="$legacy_input" "$_CLV2_PYTHON_CMD" -c ' import os, hashlib s = os.environ["_CLV2_HASH_INPUT"] print(hashlib.sha256(s.encode("utf-8")).hexdigest()[:12]) ' 2>/dev/null) - if [ -n "$legacy_id" ] && [ -d "${_CLV2_PROJECTS_DIR}/${legacy_id}" ] && [ ! -d "${_CLV2_PROJECTS_DIR}/${project_id}" ]; then - # Migrate legacy directory to new hash - mv "${_CLV2_PROJECTS_DIR}/${legacy_id}" "${_CLV2_PROJECTS_DIR}/${project_id}" 2>/dev/null || project_id="$legacy_id" - fi + if [ -n "$legacy_id" ] && [ "$legacy_id" != "$project_id" ] \ + && [ -d "${_CLV2_PROJECTS_DIR}/${legacy_id}" ]; then + if mv "${_CLV2_PROJECTS_DIR}/${legacy_id}" "${_CLV2_PROJECTS_DIR}/${project_id}" 2>/dev/null; then + break + else + project_id="$legacy_id" + break + fi + fi + done fi # Export results diff --git a/skills/continuous-learning-v2/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index 22cfc968..e5611424 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -38,7 +38,48 @@ except ImportError: # Configuration # ───────────────────────────────────────────── -HOMUNCULUS_DIR = Path.home() / ".claude" / "homunculus" +def _resolve_homunculus_dir() -> Path: + override = os.environ.get("CLV2_HOMUNCULUS_DIR") + if override: + if Path(override).is_absolute(): + return Path(override) + print(f"[ecc] CLV2_HOMUNCULUS_DIR={override!r} is not absolute; ignoring", file=sys.stderr) + + xdg = os.environ.get("XDG_DATA_HOME") + if xdg: + if Path(xdg).is_absolute(): + return Path(xdg) / "ecc-homunculus" + print(f"[ecc] XDG_DATA_HOME={xdg!r} is not absolute; ignoring", file=sys.stderr) + + return Path.home() / ".local" / "share" / "ecc-homunculus" + + +def _strip_remote_credentials(remote_url: str) -> str: + return re.sub(r"://[^@]+@", "://", remote_url or "") + + +def _normalize_remote_url(remote_url: str) -> str: + if not remote_url: + return "" + + is_network = ( + not remote_url.startswith("file://") + and ("://" in remote_url or re.match(r"^[^@/:]+@[^:/]+:", remote_url) is not None) + ) + normalized = _strip_remote_credentials(remote_url) + normalized = re.sub(r"^[A-Za-z][A-Za-z0-9+.-]*://", "", normalized) + normalized = re.sub(r"^[^@/:]+@([^:/]+):", r"\1/", normalized) + normalized = re.sub(r"\.git/?$", "", normalized) + normalized = re.sub(r"/+$", "", normalized) + + return normalized.lower() if is_network else normalized + + +def _project_hash(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12] + + +HOMUNCULUS_DIR = _resolve_homunculus_dir() PROJECTS_DIR = HOMUNCULUS_DIR / "projects" REGISTRY_FILE = HOMUNCULUS_DIR / "projects.json" @@ -177,11 +218,35 @@ def detect_project() -> dict: except (subprocess.TimeoutExpired, FileNotFoundError): pass - hash_source = remote_url if remote_url else project_root - project_id = hashlib.sha256(hash_source.encode()).hexdigest()[:12] + raw_remote_url = remote_url + if remote_url: + remote_url = _strip_remote_credentials(remote_url) + + 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 + project_id = _project_hash(hash_source) project_dir = PROJECTS_DIR / project_id + if not project_dir.exists(): + legacy_sources = [] + if legacy_hash_source and legacy_hash_source != hash_source: + legacy_sources.append(legacy_hash_source) + if raw_remote_url and raw_remote_url not in {hash_source, legacy_hash_source}: + legacy_sources.append(raw_remote_url) + + for legacy_source in legacy_sources: + legacy_id = _project_hash(legacy_source) + legacy_dir = PROJECTS_DIR / legacy_id + if legacy_id != project_id and legacy_dir.exists(): + try: + legacy_dir.rename(project_dir) + except OSError: + project_id = legacy_id + project_dir = legacy_dir + break + # Ensure project directory structure for d in [ project_dir / "instincts" / "personal", diff --git a/skills/continuous-learning-v2/scripts/lib/homunculus-dir.sh b/skills/continuous-learning-v2/scripts/lib/homunculus-dir.sh new file mode 100644 index 00000000..9f1e926a --- /dev/null +++ b/skills/continuous-learning-v2/scripts/lib/homunculus-dir.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Shared continuous-learning-v2 data-directory resolver. +# +# Resolution precedence: +# 1. CLV2_HOMUNCULUS_DIR, when absolute +# 2. XDG_DATA_HOME/ecc-homunculus, when XDG_DATA_HOME is absolute +# 3. HOME/.local/share/ecc-homunculus + +_ecc_resolve_homunculus_dir() { + if [ -n "${CLV2_HOMUNCULUS_DIR:-}" ]; then + case "$CLV2_HOMUNCULUS_DIR" in + /*) printf '%s\n' "$CLV2_HOMUNCULUS_DIR"; return 0 ;; + *) printf '[ecc] CLV2_HOMUNCULUS_DIR=%s is not absolute; ignoring\n' "$CLV2_HOMUNCULUS_DIR" >&2 ;; + esac + fi + + if [ -n "${XDG_DATA_HOME:-}" ]; then + case "$XDG_DATA_HOME" in + /*) printf '%s/ecc-homunculus\n' "$XDG_DATA_HOME"; return 0 ;; + *) printf '[ecc] XDG_DATA_HOME=%s is not absolute; ignoring\n' "$XDG_DATA_HOME" >&2 ;; + esac + fi + + case "${HOME:-}" in + /*) printf '%s/.local/share/ecc-homunculus\n' "$HOME" ;; + *) + printf '[ecc] HOME=%s is not absolute; cannot resolve homunculus dir\n' "${HOME:-}" >&2 + return 1 + ;; + esac +} diff --git a/skills/continuous-learning-v2/scripts/migrate-homunculus.sh b/skills/continuous-learning-v2/scripts/migrate-homunculus.sh new file mode 100755 index 00000000..9358fc7b --- /dev/null +++ b/skills/continuous-learning-v2/scripts/migrate-homunculus.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# One-shot migration from the legacy Claude config tree into the +# continuous-learning-v2 data directory. +set -euo pipefail + +OLD="${HOME}/.claude/homunculus" + +# shellcheck disable=SC1091 +. "$(dirname "$0")/lib/homunculus-dir.sh" +NEW="$(_ecc_resolve_homunculus_dir)" + +if [ "$NEW" = "$OLD" ]; then + echo "Resolved destination equals source ($OLD); nothing to migrate." + exit 0 +fi + +if [ ! -d "$OLD" ]; then + echo "Nothing to migrate (no $OLD)." + exit 0 +fi + +if command -v pgrep >/dev/null 2>&1; then + if pgrep -f "${HOME}.*observer-loop\\.sh" >/dev/null 2>&1; then + echo "Refusing to migrate: observer-loop.sh is running." >&2 + echo "Exit all Claude Code sessions, then re-run." >&2 + exit 1 + fi +else + echo "Warning: pgrep not available; skipping running-observer check." >&2 +fi + +mkdir -p "$(dirname "$NEW")" + +if [ ! -d "$NEW" ]; then + mv "$OLD" "$NEW" + echo "Moved $OLD -> $NEW" +elif [ -z "$(ls -A "$NEW" 2>/dev/null || true)" ]; then + rmdir "$NEW" + mv "$OLD" "$NEW" + echo "Moved $OLD -> $NEW (replaced empty destination)" +else + old_count="$(find "$OLD" -type f 2>/dev/null | wc -l | tr -d ' ')" + new_count="$(find "$NEW" -type f 2>/dev/null | wc -l | tr -d ' ')" + echo "Refusing to migrate: both paths exist with content." >&2 + echo " Old: $OLD ($old_count files)" >&2 + echo " New: $NEW ($new_count files)" >&2 + echo "Resolve manually, then re-run." >&2 + exit 1 +fi + +settings="${HOME}/.claude/settings.json" +if [ -f "$settings" ] && grep -q '"CLV2_CONFIG"' "$settings" 2>/dev/null; then + if grep -q '\.claude/homunculus' "$settings" 2>/dev/null; then + cat >&2 < **DEPRECATED 2026-04-28.** Use `continuous-learning-v2` instead. v2 is a strict superset: stop-hook observation becomes PreToolUse/PostToolUse observation, full skills become atomic instincts with confidence scoring, and global-only storage becomes project-scoped plus global promotion. +> +> This file is kept for archival reference and backward compatibility with existing installs. + +--- + +## Original v1 Documentation (archival) Automatically evaluates Claude Code sessions on end to extract reusable patterns that can be saved as learned skills. diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index c5a6554e..0142a876 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -248,7 +248,7 @@ function withPrependedPath(binDir, env = {}) { } function assertNoProjectDetectionSideEffects(homeDir, testName) { - const homunculusDir = path.join(homeDir, '.claude', 'homunculus'); + const homunculusDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus'); const registryPath = path.join(homunculusDir, 'projects.json'); const projectsDir = path.join(homunculusDir, 'projects'); @@ -2885,11 +2885,12 @@ async function runTests() { assert.strictEqual(code, 0, `detect-project should source cleanly, stderr: ${stderr}`); const [projectId, projectDir] = stdout.trim().split(/\r?\n/); - const registryPath = path.join(homeDir, '.claude', 'homunculus', 'projects.json'); + const registryPath = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects.json'); const expectedProjectDir = path.join( homeDir, - '.claude', - 'homunculus', + '.local', + 'share', + 'ecc-homunculus', 'projects', projectId ); @@ -2963,7 +2964,7 @@ async function runTests() { assert.strictEqual(result.code, 0, `observe.sh should exit successfully, stderr: ${result.stderr}`); - const projectsDir = path.join(homeDir, '.claude', 'homunculus', 'projects'); + 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'); diff --git a/tests/hooks/observe-subdirectory-detection.test.js b/tests/hooks/observe-subdirectory-detection.test.js index 75258628..7a5723ff 100644 --- a/tests/hooks/observe-subdirectory-detection.test.js +++ b/tests/hooks/observe-subdirectory-detection.test.js @@ -112,7 +112,7 @@ function runObserve({ homeDir, cwd }) { } function readSingleProjectMetadata(homeDir) { - const projectsDir = path.join(homeDir, '.claude', 'homunculus', 'projects'); + const projectsDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects'); const projectIds = fs.readdirSync(projectsDir); assert.strictEqual(projectIds.length, 1, 'Expected exactly one project directory'); const projectDir = path.join(projectsDir, projectIds[0]); diff --git a/tests/hooks/observer-memory.test.js b/tests/hooks/observer-memory.test.js index 9436db67..2857d208 100644 --- a/tests/hooks/observer-memory.test.js +++ b/tests/hooks/observer-memory.test.js @@ -96,7 +96,8 @@ test('observer-loop.sh defines ANALYZING guard variable', () => { test('on_usr1 checks ANALYZING before starting analysis', () => { const content = fs.readFileSync(observerLoopPath, 'utf8'); assert.ok(content.includes('if [ "$ANALYZING" -eq 1 ]'), 'on_usr1 should check ANALYZING flag'); - assert.ok(content.includes('Analysis already in progress, skipping signal'), 'on_usr1 should log when skipping due to re-entrancy'); + assert.ok(content.includes('Analysis already in progress, deferring signal'), 'on_usr1 should log when deferring due to re-entrancy'); + assert.ok(content.includes('PENDING_ANALYSIS=1'), 'on_usr1 should preserve re-entrant nudges for the next loop iteration'); }); test('on_usr1 sets ANALYZING=1 before and ANALYZING=0 after analysis', () => { @@ -110,6 +111,15 @@ test('on_usr1 sets ANALYZING=1 before and ANALYZING=0 after analysis', () => { assert.ok(analyzeReset > analyzeObsCall, 'ANALYZING=0 should follow analyze_observations'); }); +test('observer-loop checks pending analysis before sleeping', () => { + const content = fs.readFileSync(observerLoopPath, 'utf8'); + assert.ok(/^PENDING_ANALYSIS=0$/m.test(content), 'PENDING_ANALYSIS should initialize to 0'); + assert.ok( + /if \[ "\$PENDING_ANALYSIS" -eq 1 \]; then[\s\S]*?analyze_observations[\s\S]*?continue[\s\S]*?sleep "\$OBSERVER_INTERVAL_SECONDS"/.test(content), + 'observer-loop should process deferred analysis before the interval sleep' + ); +}); + // ────────────────────────────────────────────────────── // Test group 3: observer-loop.sh cooldown throttle // ────────────────────────────────────────────────────── @@ -334,8 +344,10 @@ test('observe.sh creates counter file and increments on each call', () => { // Create a minimal detect-project.sh that sets required vars const skillRoot = path.join(testDir, 'skill'); const scriptsDir = path.join(skillRoot, 'scripts'); + const scriptsLibDir = path.join(scriptsDir, 'lib'); const hooksDir = path.join(skillRoot, 'hooks'); fs.mkdirSync(scriptsDir, { recursive: true }); + fs.mkdirSync(scriptsLibDir, { recursive: true }); fs.mkdirSync(hooksDir, { recursive: true }); // Minimal detect-project.sh stub @@ -351,6 +363,14 @@ test('observe.sh creates counter file and increments on each call', () => { '' ].join('\n') ); + fs.writeFileSync( + path.join(scriptsLibDir, 'homunculus-dir.sh'), + [ + '#!/bin/bash', + '_ecc_resolve_homunculus_dir() { printf "%s\\n" "$HOME/.local/share/ecc-homunculus"; }', + '' + ].join('\n') + ); // Copy observe.sh but patch SKILL_ROOT to our test dir let observeContent = fs.readFileSync(observeShPath, 'utf8'); diff --git a/tests/integration/hooks.test.js b/tests/integration/hooks.test.js index 7c4d06a2..e05b4e49 100644 --- a/tests/integration/hooks.test.js +++ b/tests/integration/hooks.test.js @@ -226,6 +226,15 @@ function cleanupTestDir(testDir) { fs.rmSync(testDir, { recursive: true, force: true }); } +function getTestHomunculusEnv(testDir) { + const xdgDataHome = path.join(testDir, '.local', 'share'); + return { + HOME: testDir, + XDG_DATA_HOME: xdgDataHome, + homunculusDir: path.join(xdgDataHome, 'ecc-homunculus'), + }; +} + function writeInstinctFile(filePath, entries) { const body = entries.map(entry => `--- id: ${entry.id} @@ -380,19 +389,20 @@ async function runTests() { try { const sessionId = `session-${Date.now()}`; + const homunculusEnv = getTestHomunculusEnv(testDir); const result = await runHookWithInput( path.join(scriptsDir, 'session-start.js'), {}, { - HOME: testDir, + HOME: homunculusEnv.HOME, + XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME, CLAUDE_PROJECT_DIR: projectDir, CLAUDE_SESSION_ID: sessionId } ); assert.strictEqual(result.code, 0, 'SessionStart should exit 0'); - const homunculusDir = path.join(testDir, '.claude', 'homunculus'); - const projectsDir = path.join(homunculusDir, 'projects'); + const projectsDir = path.join(homunculusEnv.homunculusDir, 'projects'); const projectEntries = fs.existsSync(projectsDir) ? fs.readdirSync(projectsDir) : []; assert.ok(projectEntries.length > 0, 'SessionStart should create a homunculus project directory'); const leaseDir = path.join(projectsDir, projectEntries[0], '.observer-sessions'); @@ -410,7 +420,8 @@ async function runTests() { try { const projectId = crypto.createHash('sha256').update(projectDir).digest('hex').slice(0, 12); - const homunculusDir = path.join(testDir, '.claude', 'homunculus'); + const homunculusEnv = getTestHomunculusEnv(testDir); + const homunculusDir = homunculusEnv.homunculusDir; const projectInstinctDir = path.join(homunculusDir, 'projects', projectId, 'instincts', 'personal'); const globalInstinctDir = path.join(homunculusDir, 'instincts', 'inherited'); @@ -445,7 +456,8 @@ async function runTests() { path.join(scriptsDir, 'session-start.js'), {}, { - HOME: testDir, + HOME: homunculusEnv.HOME, + XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME, CLAUDE_PROJECT_DIR: projectDir, } ); @@ -474,18 +486,19 @@ async function runTests() { }); try { + const homunculusEnv = getTestHomunculusEnv(testDir); await runHookWithInput( path.join(scriptsDir, 'session-start.js'), {}, { - HOME: testDir, + HOME: homunculusEnv.HOME, + XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME, CLAUDE_PROJECT_DIR: projectDir, CLAUDE_SESSION_ID: sessionId } ); - const homunculusDir = path.join(testDir, '.claude', 'homunculus'); - const projectsDir = path.join(homunculusDir, 'projects'); + const projectsDir = path.join(homunculusEnv.homunculusDir, 'projects'); const projectEntries = fs.existsSync(projectsDir) ? fs.readdirSync(projectsDir) : []; assert.ok(projectEntries.length > 0, 'Expected SessionStart to create a homunculus project directory'); const projectStorageDir = path.join(projectsDir, projectEntries[0]); @@ -497,7 +510,8 @@ async function runTests() { path.join(scriptsDir, 'session-end-marker.js'), markerInput, { - HOME: testDir, + HOME: homunculusEnv.HOME, + XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME, CLAUDE_PROJECT_DIR: projectDir, CLAUDE_SESSION_ID: sessionId } diff --git a/tests/lib/observer-sessions.test.js b/tests/lib/observer-sessions.test.js new file mode 100644 index 00000000..d9487e01 --- /dev/null +++ b/tests/lib/observer-sessions.test.js @@ -0,0 +1,134 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const { + getHomunculusDir, + normalizeRemoteUrl, + resolveProjectContext, +} = require('../../scripts/lib/observer-sessions'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed += 1; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` ${error.message}`); + failed += 1; + } +} + +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-observer-sessions-')); +} + +function cleanup(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } +} + +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 { + return fn(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +function initRepo(repoDir, remoteUrl) { + fs.mkdirSync(repoDir, { recursive: true }); + spawnSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' }); + spawnSync('git', ['remote', 'add', 'origin', remoteUrl], { cwd: repoDir, stdio: 'ignore' }); +} + +console.log('\n=== observer-sessions tests ===\n'); + +test('getHomunculusDir prefers absolute CLV2_HOMUNCULUS_DIR', () => { + const root = createTempDir(); + try { + const override = path.join(root, 'custom-store'); + withEnv({ CLV2_HOMUNCULUS_DIR: override, XDG_DATA_HOME: path.join(root, 'xdg') }, () => { + assert.strictEqual(getHomunculusDir(), override); + }); + } finally { + cleanup(root); + } +}); + +test('getHomunculusDir ignores relative overrides and uses XDG_DATA_HOME', () => { + const root = createTempDir(); + try { + const xdg = path.join(root, 'xdg'); + withEnv({ CLV2_HOMUNCULUS_DIR: 'relative-store', XDG_DATA_HOME: xdg }, () => { + assert.strictEqual(getHomunculusDir(), path.join(xdg, 'ecc-homunculus')); + }); + } finally { + cleanup(root); + } +}); + +test('normalizeRemoteUrl collapses common network remote variants', () => { + const expected = 'github.com/owner/repo'; + assert.strictEqual(normalizeRemoteUrl('git@github.com:Owner/Repo.git'), expected); + assert.strictEqual(normalizeRemoteUrl('https://github.com/owner/repo.git'), expected); + assert.strictEqual(normalizeRemoteUrl('ssh://git@github.com/Owner/Repo.git'), expected); + assert.strictEqual(normalizeRemoteUrl('https://token@github.com/owner/repo.git'), expected); +}); + +test('normalizeRemoteUrl preserves local path case', () => { + assert.strictEqual(normalizeRemoteUrl('/tmp/Repos/MyProject'), '/tmp/Repos/MyProject'); + assert.strictEqual(normalizeRemoteUrl('file:///tmp/Repos/MyProject.git'), '/tmp/Repos/MyProject'); +}); + +test('resolveProjectContext gives SSH and HTTPS clones the same project id', () => { + const root = createTempDir(); + try { + const storage = path.join(root, 'store'); + const sshRepo = path.join(root, 'ssh-clone'); + const httpsRepo = path.join(root, 'https-clone'); + initRepo(sshRepo, 'git@github.com:Owner/Repo.git'); + initRepo(httpsRepo, 'https://github.com/owner/repo.git'); + + withEnv({ + CLV2_HOMUNCULUS_DIR: storage, + XDG_DATA_HOME: undefined, + CLAUDE_PROJECT_DIR: undefined, + }, () => { + const sshContext = resolveProjectContext(sshRepo); + const httpsContext = resolveProjectContext(httpsRepo); + assert.strictEqual(sshContext.projectId, httpsContext.projectId); + assert.strictEqual(sshContext.projectDir, httpsContext.projectDir); + }); + } finally { + cleanup(root); + } +}); + +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0);