fix: port continuous-learning observer fixes

Ports continuous-learning observer signal, storage, remote normalization, and v1 deprecation fixes onto current main.
This commit is contained in:
Affaan Mustafa 2026-05-11 03:35:42 -04:00 committed by GitHub
parent e674a7dbd7
commit 12e1bc424d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 512 additions and 56 deletions

View File

@ -10,7 +10,6 @@
*/ */
const { const {
getClaudeDir,
getSessionsDir, getSessionsDir,
getSessionSearchDirs, getSessionSearchDirs,
getLearnedSkillsDir, getLearnedSkillsDir,
@ -21,7 +20,7 @@ const {
stripAnsi, stripAnsi,
log log
} = require('../lib/utils'); } = 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 { getPackageManager, getSelectionPrompt } = require('../lib/package-manager');
const { listAliases } = require('../lib/session-aliases'); const { listAliases } = require('../lib/session-aliases');
const { detectProjectType } = require('../lib/project-detect'); const { detectProjectType } = require('../lib/project-detect');
@ -325,7 +324,7 @@ function extractInstinctAction(content) {
} }
function summarizeActiveInstincts(observerContext) { function summarizeActiveInstincts(observerContext) {
const homunculusDir = path.join(getClaudeDir(), 'homunculus'); const homunculusDir = getHomunculusDir();
const globalDirs = [ const globalDirs = [
{ dir: path.join(homunculusDir, 'instincts', 'personal'), scope: 'global' }, { dir: path.join(homunculusDir, 'instincts', 'personal'), scope: 'global' },
{ dir: path.join(homunculusDir, 'instincts', 'inherited'), scope: 'global' }, { dir: path.join(homunculusDir, 'instincts', 'inherited'), scope: 'global' },

View File

@ -1,11 +1,28 @@
const fs = require('fs'); const fs = require('fs');
const os = require('os');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const { spawnSync } = require('child_process'); const { spawnSync } = require('child_process');
const { getClaudeDir, ensureDir, sanitizeSessionId } = require('./utils'); const { ensureDir, sanitizeSessionId } = require('./utils');
function getHomunculusDir() { 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() { function getProjectsDir() {
@ -39,6 +56,23 @@ function stripRemoteCredentials(remoteUrl) {
return String(remoteUrl).replace(/:\/\/[^@]+@/, '://'); 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()) { function resolveProjectRoot(cwd = process.cwd()) {
const envRoot = process.env.CLAUDE_PROJECT_DIR; const envRoot = process.env.CLAUDE_PROJECT_DIR;
if (envRoot && fs.existsSync(envRoot)) { if (envRoot && fs.existsSync(envRoot)) {
@ -53,7 +87,8 @@ function resolveProjectRoot(cwd = process.cwd()) {
function computeProjectId(projectRoot) { function computeProjectId(projectRoot) {
const remoteUrl = stripRemoteCredentials(runGit(['remote', 'get-url', 'origin'], 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()) { function resolveProjectContext(cwd = process.cwd()) {
@ -163,6 +198,8 @@ function stopObserverForContext(context) {
} }
module.exports = { module.exports = {
getHomunculusDir,
normalizeRemoteUrl,
resolveProjectContext, resolveProjectContext,
getObserverActivityFile, getObserverActivityFile,
getObserverPidFile, getObserverPidFile,

View File

@ -26,7 +26,7 @@ An advanced learning system that turns your Claude Code sessions into reusable k
| Feature | v2.0 | v2.1 | | Feature | v2.0 | v2.1 |
|---------|------|------| |---------|------|------|
| Storage | Global (~/.claude/homunculus/) | Project-scoped (projects/<hash>/) | | Storage | Global (`~/.claude/homunculus/`) | Project-scoped (`${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects/<hash>/`) |
| Scope | All instincts apply everywhere | Project-scoped + global | | Scope | All instincts apply everywhere | Project-scoped + global |
| Detection | None | git remote URL / repo path | | Detection | None | git remote URL / repo path |
| Promotion | N/A | Project → global when seen in 2+ projects | | 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) 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 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 ## Quick Start
@ -173,7 +187,7 @@ The system creates directories automatically on first use, but you can also crea
```bash ```bash
# Global directories # 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 # 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 ## File Structure
``` ```
~/.claude/homunculus/ ${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/
+-- identity.json # Your profile, technical level +-- identity.json # Your profile, technical level
+-- projects.json # Registry: project hash -> name/path/remote +-- projects.json # Registry: project hash -> name/path/remote
+-- observations.jsonl # Global observations (fallback) +-- observations.jsonl # Global observations (fallback)
@ -322,7 +336,7 @@ Hooks fire **100% of the time**, deterministically. This means:
## Backward Compatibility ## Backward Compatibility
v2.1 is fully compatible with v2.0 and v1: 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 - Existing `~/.claude/skills/learned/` skills from v1 still work
- Stop hook still runs (but now also feeds into v2) - Stop hook still runs (but now also feeds into v2)
- Gradual migration: run both in parallel - Gradual migration: run both in parallel

View File

@ -10,6 +10,7 @@ unset CLAUDECODE
SLEEP_PID="" SLEEP_PID=""
USR1_FIRED=0 USR1_FIRED=0
PENDING_ANALYSIS=0
ANALYZING=0 ANALYZING=0
LAST_ANALYSIS_EPOCH=0 LAST_ANALYSIS_EPOCH=0
# Minimum seconds between analyses (prevents rapid re-triggering) # Minimum seconds between analyses (prevents rapid re-triggering)
@ -258,14 +259,17 @@ PROMPT
on_usr1() { on_usr1() {
[ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null [ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null
SLEEP_PID="" 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 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 return
fi fi
USR1_FIRED=1
# Cooldown: skip if last analysis was too recent (#521) # Cooldown: skip if last analysis was too recent (#521)
now_epoch=$(date +%s) now_epoch=$(date +%s)
elapsed=$(( now_epoch - LAST_ANALYSIS_EPOCH )) elapsed=$(( now_epoch - LAST_ANALYSIS_EPOCH ))
@ -290,6 +294,17 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
while true; do while true; do
exit_if_idle_without_sessions 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 "$OBSERVER_INTERVAL_SECONDS" &
SLEEP_PID=$! SLEEP_PID=$!
wait "$SLEEP_PID" 2>/dev/null wait "$SLEEP_PID" 2>/dev/null
@ -299,6 +314,9 @@ while true; do
if [ "$USR1_FIRED" -eq 1 ]; then if [ "$USR1_FIRED" -eq 1 ]; then
USR1_FIRED=0 USR1_FIRED=0
else else
ANALYZING=1
analyze_observations analyze_observations
LAST_ANALYSIS_EPOCH=$(date +%s)
ANALYZING=0
fi fi
done done

View File

@ -17,8 +17,8 @@ A background agent that analyzes observations from Claude Code sessions to detec
## Input ## Input
Reads observations from the **project-scoped** observations file: Reads observations from the **project-scoped** observations file:
- Project: `~/.claude/homunculus/projects/<project-hash>/observations.jsonl` - Project: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects/<project-hash>/observations.jsonl`
- Global fallback: `~/.claude/homunculus/observations.jsonl` - Global fallback: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/observations.jsonl`
```jsonl ```jsonl
{"timestamp":"2025-01-22T10:30:00Z","event":"tool_start","session":"abc123","tool":"Edit","input":"...","project_id":"a1b2c3d4e5f6","project_name":"my-react-app"} {"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 ## Output
Creates/updates instincts in the **project-scoped** instincts directory: Creates/updates instincts in the **project-scoped** instincts directory:
- Project: `~/.claude/homunculus/projects/<project-hash>/instincts/personal/` - Project: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects/<project-hash>/instincts/personal/`
- Global: `~/.claude/homunculus/instincts/personal/` (for universal patterns) - Global: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/instincts/personal/` (for universal patterns)
### Project-Scoped Instinct (default) ### Project-Scoped Instinct (default)

View File

@ -35,9 +35,13 @@ PYTHON_CMD="${CLV2_PYTHON_CMD:-}"
# Configuration # 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 if [ -n "${CLV2_CONFIG:-}" ]; then
CONFIG_FILE="$CLV2_CONFIG" CONFIG_FILE="$CLV2_CONFIG"
elif [ -f "${CONFIG_DIR}/config.json" ]; then
CONFIG_FILE="${CONFIG_DIR}/config.json"
else else
CONFIG_FILE="${SKILL_ROOT}/config.json" CONFIG_FILE="${SKILL_ROOT}/config.json"
fi fi

View File

@ -115,7 +115,9 @@ fi
# Sourcing detect-project.sh creates project-scoped directories and updates # Sourcing detect-project.sh creates project-scoped directories and updates
# projects.json, so automated sessions must return before that point. # 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) # Skip if disabled (check both default and CLV2_CONFIG-derived locations)
if [ -f "$CONFIG_DIR/disabled" ]; then if [ -f "$CONFIG_DIR/disabled" ]; then
@ -344,10 +346,12 @@ if [ -f "${CONFIG_DIR}/disabled" ]; then
OBSERVER_ENABLED=false OBSERVER_ENABLED=false
else else
OBSERVER_ENABLED=false OBSERVER_ENABLED=false
CONFIG_FILE="${SKILL_ROOT}/config.json"
# Allow CLV2_CONFIG override
if [ -n "${CLV2_CONFIG:-}" ]; then if [ -n "${CLV2_CONFIG:-}" ]; then
CONFIG_FILE="$CLV2_CONFIG" 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 fi
# Use effective config path for both existence check and reading # Use effective config path for both existence check and reading
EFFECTIVE_CONFIG="$CONFIG_FILE" EFFECTIVE_CONFIG="$CONFIG_FILE"

View File

@ -19,7 +19,9 @@
# 3. git repo root path (fallback, machine-specific) # 3. git repo root path (fallback, machine-specific)
# 4. "global" (no project context detected) # 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_PROJECTS_DIR="${_CLV2_HOMUNCULUS_DIR}/projects"
_CLV2_REGISTRY_FILE="${_CLV2_HOMUNCULUS_DIR}/projects.json" _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' 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 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() { _clv2_detect_project() {
local project_root="" local project_root=""
local project_name="" local project_name=""
@ -94,15 +120,20 @@ _clv2_detect_project() {
fi fi
fi fi
# Compute hash from the original remote URL (legacy, for backward compatibility) local raw_remote_url="$remote_url"
local legacy_hash_input="${remote_url:-$project_root}"
# Strip embedded credentials from remote URL (e.g., https://ghp_xxxx@github.com/...) # Strip embedded credentials from remote URL (e.g., https://ghp_xxxx@github.com/...)
if [ -n "$remote_url" ]; then if [ -n "$remote_url" ]; then
remote_url=$(printf '%s' "$remote_url" | sed -E 's|://[^@]+@|://|') remote_url=$(printf '%s' "$remote_url" | sed -E 's|://[^@]+@|://|')
fi 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. # 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 # 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 # 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") echo "fallback")
fi fi
# Backward compatibility: if credentials were stripped and the hash changed, # Backward compatibility: migrate a single legacy project directory from
# check if a project dir exists under the legacy hash and reuse it # credential-stripped or raw remote hashes to the normalized remote hash.
if [ "$legacy_hash_input" != "$hash_input" ] && [ -n "$_CLV2_PYTHON_CMD" ]; then if [ -n "$_CLV2_PYTHON_CMD" ] && [ ! -d "${_CLV2_PROJECTS_DIR}/${project_id}" ]; then
local legacy_id="" local legacy_inputs=()
legacy_id=$(_CLV2_HASH_INPUT="$legacy_hash_input" "$_CLV2_PYTHON_CMD" -c ' [ -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 import os, hashlib
s = os.environ["_CLV2_HASH_INPUT"] s = os.environ["_CLV2_HASH_INPUT"]
print(hashlib.sha256(s.encode("utf-8")).hexdigest()[:12]) print(hashlib.sha256(s.encode("utf-8")).hexdigest()[:12])
' 2>/dev/null) ' 2>/dev/null)
if [ -n "$legacy_id" ] && [ -d "${_CLV2_PROJECTS_DIR}/${legacy_id}" ] && [ ! -d "${_CLV2_PROJECTS_DIR}/${project_id}" ]; then if [ -n "$legacy_id" ] && [ "$legacy_id" != "$project_id" ] \
# Migrate legacy directory to new hash && [ -d "${_CLV2_PROJECTS_DIR}/${legacy_id}" ]; then
mv "${_CLV2_PROJECTS_DIR}/${legacy_id}" "${_CLV2_PROJECTS_DIR}/${project_id}" 2>/dev/null || project_id="$legacy_id" if mv "${_CLV2_PROJECTS_DIR}/${legacy_id}" "${_CLV2_PROJECTS_DIR}/${project_id}" 2>/dev/null; then
fi break
else
project_id="$legacy_id"
break
fi
fi
done
fi fi
# Export results # Export results

View File

@ -38,7 +38,48 @@ except ImportError:
# Configuration # 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" PROJECTS_DIR = HOMUNCULUS_DIR / "projects"
REGISTRY_FILE = HOMUNCULUS_DIR / "projects.json" REGISTRY_FILE = HOMUNCULUS_DIR / "projects.json"
@ -177,11 +218,35 @@ def detect_project() -> dict:
except (subprocess.TimeoutExpired, FileNotFoundError): except (subprocess.TimeoutExpired, FileNotFoundError):
pass pass
hash_source = remote_url if remote_url else project_root raw_remote_url = remote_url
project_id = hashlib.sha256(hash_source.encode()).hexdigest()[:12] 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 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 # Ensure project directory structure
for d in [ for d in [
project_dir / "instincts" / "personal", project_dir / "instincts" / "personal",

View File

@ -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
}

View File

@ -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 <<WARN
Advisory: ~/.claude/settings.json still sets CLV2_CONFIG under the old path.
Update it to: ${NEW}/config.json
(Not editing settings.json automatically.)
WARN
fi
fi

View File

@ -1,10 +1,18 @@
--- ---
name: continuous-learning name: continuous-learning
description: Automatically extract reusable patterns from Claude Code sessions and save them as learned skills for future use. description: "[DEPRECATED - use continuous-learning-v2] Legacy v1 stop-hook skill extractor. v2 is a strict superset with instinct-based, project-scoped, hook-reliable learning. Do not invoke v1; route continuous learning, session learning, and pattern extraction requests to continuous-learning-v2."
origin: ECC origin: ECC
--- ---
# Continuous Learning Skill # Continuous Learning Skill - DEPRECATED
> **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. Automatically evaluates Claude Code sessions on end to extract reusable patterns that can be saved as learned skills.

View File

@ -248,7 +248,7 @@ function withPrependedPath(binDir, env = {}) {
} }
function assertNoProjectDetectionSideEffects(homeDir, testName) { 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 registryPath = path.join(homunculusDir, 'projects.json');
const projectsDir = path.join(homunculusDir, 'projects'); const projectsDir = path.join(homunculusDir, 'projects');
@ -2885,11 +2885,12 @@ async function runTests() {
assert.strictEqual(code, 0, `detect-project should source cleanly, stderr: ${stderr}`); assert.strictEqual(code, 0, `detect-project should source cleanly, stderr: ${stderr}`);
const [projectId, projectDir] = stdout.trim().split(/\r?\n/); 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( const expectedProjectDir = path.join(
homeDir, homeDir,
'.claude', '.local',
'homunculus', 'share',
'ecc-homunculus',
'projects', 'projects',
projectId projectId
); );
@ -2963,7 +2964,7 @@ async function runTests() {
assert.strictEqual(result.code, 0, `observe.sh should exit successfully, stderr: ${result.stderr}`); 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); const projectIds = fs.readdirSync(projectsDir);
assert.strictEqual(projectIds.length, 1, 'observe.sh should create one project-scoped observation directory'); assert.strictEqual(projectIds.length, 1, 'observe.sh should create one project-scoped observation directory');

View File

@ -112,7 +112,7 @@ function runObserve({ homeDir, cwd }) {
} }
function readSingleProjectMetadata(homeDir) { 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); const projectIds = fs.readdirSync(projectsDir);
assert.strictEqual(projectIds.length, 1, 'Expected exactly one project directory'); assert.strictEqual(projectIds.length, 1, 'Expected exactly one project directory');
const projectDir = path.join(projectsDir, projectIds[0]); const projectDir = path.join(projectsDir, projectIds[0]);

View File

@ -96,7 +96,8 @@ test('observer-loop.sh defines ANALYZING guard variable', () => {
test('on_usr1 checks ANALYZING before starting analysis', () => { test('on_usr1 checks ANALYZING before starting analysis', () => {
const content = fs.readFileSync(observerLoopPath, 'utf8'); const content = fs.readFileSync(observerLoopPath, 'utf8');
assert.ok(content.includes('if [ "$ANALYZING" -eq 1 ]'), 'on_usr1 should check ANALYZING flag'); 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', () => { 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'); 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 // 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 // Create a minimal detect-project.sh that sets required vars
const skillRoot = path.join(testDir, 'skill'); const skillRoot = path.join(testDir, 'skill');
const scriptsDir = path.join(skillRoot, 'scripts'); const scriptsDir = path.join(skillRoot, 'scripts');
const scriptsLibDir = path.join(scriptsDir, 'lib');
const hooksDir = path.join(skillRoot, 'hooks'); const hooksDir = path.join(skillRoot, 'hooks');
fs.mkdirSync(scriptsDir, { recursive: true }); fs.mkdirSync(scriptsDir, { recursive: true });
fs.mkdirSync(scriptsLibDir, { recursive: true });
fs.mkdirSync(hooksDir, { recursive: true }); fs.mkdirSync(hooksDir, { recursive: true });
// Minimal detect-project.sh stub // Minimal detect-project.sh stub
@ -351,6 +363,14 @@ test('observe.sh creates counter file and increments on each call', () => {
'' ''
].join('\n') ].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 // Copy observe.sh but patch SKILL_ROOT to our test dir
let observeContent = fs.readFileSync(observeShPath, 'utf8'); let observeContent = fs.readFileSync(observeShPath, 'utf8');

View File

@ -226,6 +226,15 @@ function cleanupTestDir(testDir) {
fs.rmSync(testDir, { recursive: true, force: true }); 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) { function writeInstinctFile(filePath, entries) {
const body = entries.map(entry => `--- const body = entries.map(entry => `---
id: ${entry.id} id: ${entry.id}
@ -380,19 +389,20 @@ async function runTests() {
try { try {
const sessionId = `session-${Date.now()}`; const sessionId = `session-${Date.now()}`;
const homunculusEnv = getTestHomunculusEnv(testDir);
const result = await runHookWithInput( const result = await runHookWithInput(
path.join(scriptsDir, 'session-start.js'), path.join(scriptsDir, 'session-start.js'),
{}, {},
{ {
HOME: testDir, HOME: homunculusEnv.HOME,
XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME,
CLAUDE_PROJECT_DIR: projectDir, CLAUDE_PROJECT_DIR: projectDir,
CLAUDE_SESSION_ID: sessionId CLAUDE_SESSION_ID: sessionId
} }
); );
assert.strictEqual(result.code, 0, 'SessionStart should exit 0'); assert.strictEqual(result.code, 0, 'SessionStart should exit 0');
const homunculusDir = path.join(testDir, '.claude', 'homunculus'); const projectsDir = path.join(homunculusEnv.homunculusDir, 'projects');
const projectsDir = path.join(homunculusDir, 'projects');
const projectEntries = fs.existsSync(projectsDir) ? fs.readdirSync(projectsDir) : []; const projectEntries = fs.existsSync(projectsDir) ? fs.readdirSync(projectsDir) : [];
assert.ok(projectEntries.length > 0, 'SessionStart should create a homunculus project directory'); assert.ok(projectEntries.length > 0, 'SessionStart should create a homunculus project directory');
const leaseDir = path.join(projectsDir, projectEntries[0], '.observer-sessions'); const leaseDir = path.join(projectsDir, projectEntries[0], '.observer-sessions');
@ -410,7 +420,8 @@ async function runTests() {
try { try {
const projectId = crypto.createHash('sha256').update(projectDir).digest('hex').slice(0, 12); 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 projectInstinctDir = path.join(homunculusDir, 'projects', projectId, 'instincts', 'personal');
const globalInstinctDir = path.join(homunculusDir, 'instincts', 'inherited'); const globalInstinctDir = path.join(homunculusDir, 'instincts', 'inherited');
@ -445,7 +456,8 @@ async function runTests() {
path.join(scriptsDir, 'session-start.js'), path.join(scriptsDir, 'session-start.js'),
{}, {},
{ {
HOME: testDir, HOME: homunculusEnv.HOME,
XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME,
CLAUDE_PROJECT_DIR: projectDir, CLAUDE_PROJECT_DIR: projectDir,
} }
); );
@ -474,18 +486,19 @@ async function runTests() {
}); });
try { try {
const homunculusEnv = getTestHomunculusEnv(testDir);
await runHookWithInput( await runHookWithInput(
path.join(scriptsDir, 'session-start.js'), path.join(scriptsDir, 'session-start.js'),
{}, {},
{ {
HOME: testDir, HOME: homunculusEnv.HOME,
XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME,
CLAUDE_PROJECT_DIR: projectDir, CLAUDE_PROJECT_DIR: projectDir,
CLAUDE_SESSION_ID: sessionId CLAUDE_SESSION_ID: sessionId
} }
); );
const homunculusDir = path.join(testDir, '.claude', 'homunculus'); const projectsDir = path.join(homunculusEnv.homunculusDir, 'projects');
const projectsDir = path.join(homunculusDir, 'projects');
const projectEntries = fs.existsSync(projectsDir) ? fs.readdirSync(projectsDir) : []; const projectEntries = fs.existsSync(projectsDir) ? fs.readdirSync(projectsDir) : [];
assert.ok(projectEntries.length > 0, 'Expected SessionStart to create a homunculus project directory'); assert.ok(projectEntries.length > 0, 'Expected SessionStart to create a homunculus project directory');
const projectStorageDir = path.join(projectsDir, projectEntries[0]); const projectStorageDir = path.join(projectsDir, projectEntries[0]);
@ -497,7 +510,8 @@ async function runTests() {
path.join(scriptsDir, 'session-end-marker.js'), path.join(scriptsDir, 'session-end-marker.js'),
markerInput, markerInput,
{ {
HOME: testDir, HOME: homunculusEnv.HOME,
XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME,
CLAUDE_PROJECT_DIR: projectDir, CLAUDE_PROJECT_DIR: projectDir,
CLAUDE_SESSION_ID: sessionId CLAUDE_SESSION_ID: sessionId
} }

View File

@ -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);