
Continuous Learning V2
- 1.5k installs
- 238k repo stars
- Updated August 5, 2026
- affaan-m/ecc
This is a copy of continuous-learning-v2 by affaan-m - installs and ranking accrue to the original listing.
continuous-learning-v2 is a Claude Code meta-skill at version 2.1.0 that observes sessions through hooks, creates atomic instincts with confidence scoring, and evolves them into reusable skills, commands, and agents with
About
continuous-learning-v2 is an instinct-based learning system from affaan-m/ecc (Everything Claude Code) at version 2.1.0. It watches Claude Code sessions via hooks, records small learned behaviors as atomic instincts with confidence scores, and promotes mature instincts into skills, slash commands, and subagents. Version 2.1 adds project-scoped instincts so React conventions stay in React repos and Python patterns stay in Python repos, preventing cross-project contamination. Developers reach for continuous-learning-v2 when repeated session patterns should compound into reusable agent knowledge instead of being re-explained each chat. The architecture treats instincts as evolvable units rather than one-off prompt snippets.
- Observes Claude Code sessions through hooks to capture behaviors
- Creates atomic instincts with confidence scoring
- v2.1 project-scoped instincts prevent cross-project contamination
- Supports reviewing, exporting, importing, and promoting instincts from project to global scope
- Evolves instincts into full skills, commands or agents
Continuous Learning V2 by the numbers
- 1,454 all-time installs (skills.sh)
- +91 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/affaan-m/ecc --skill continuous-learning-v2Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.5k |
|---|---|
| repo stars | ★ 238k |
| Last updated | August 5, 2026 |
| Repository | affaan-m/ecc ↗ |
How do Claude Code sessions become reusable skills?
Automatically turn every Claude Code session into reusable atomic instincts that evolve into skills, commands, and agents.
Who is it for?
Claude Code developers who repeat the same project conventions and want hook-observed instincts to evolve into skills without cross-repo leakage.
Skip if: One-off tasks, non-Claude-Code agents, or teams that prohibit hook-based session observation on developer machines.
When should I use this skill?
The user wants Claude Code to learn from sessions, create instincts, evolve skills from hooks, or enable project-scoped continuous learning v2.1.
What you get
Project-scoped atomic instincts, promoted skills, slash commands, and agent definitions with confidence scores.
- atomic instinct files
- evolved skills and commands
- project-scoped agent definitions
By the numbers
- Version 2.1.0 with project-scoped instincts to prevent cross-project contamination
Files
Continuous Learning v2.1 - Instinct
-Based Architecture
An advanced learning system that turns your Claude Code sessions into reusable knowledge through atomic "instincts" - small learned behaviors with confidence scoring.
v2.1 adds project-scoped instincts — React patterns stay in your React project, Python conventions stay in your Python project, and universal patterns (like "always validate input") are shared globally.
When to Activate
- Setting up automatic learning from Claude Code sessions
- Configuring instinct-based behavior extraction via hooks
- Tuning confidence thresholds for learned behaviors
- Reviewing, exporting, or importing instinct libraries
- Evolving instincts into full skills, commands, or agents
- Managing project-scoped vs global instincts
- Promoting instincts from project to global scope
What's New in v2.1
| Feature | v2.0 | v2.1 |
|---|---|---|
| Storage | Global (~/.claude/homunculus/) | Project-scoped (${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects/<hash>/) |
| 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 |
| Commands | 4 (status/evolve/export/import) | 6 (+promote/projects) |
| Cross-project | Contamination risk | Isolated by default |
What's New in v2 (vs v1)
| Feature | v1 | v2 |
|---|---|---|
| Observation | Stop hook (session end) | PreToolUse/PostToolUse (100% reliable) |
| Analysis | Main context | Background agent (Haiku) |
| Granularity | Full skills | Atomic "instincts" |
| Confidence | None | 0.3-0.9 weighted |
| Evolution | Direct to skill | Instincts -> cluster -> skill/command/agent |
| Sharing | None | Export/import instincts |
The Instinct Model
An instinct is a small learned behavior:
---
id: prefer-functional-style
trigger: "when writing new functions"
confidence: 0.7
domain: "code-style"
source: "session-observation"
scope: project
project_id: "a1b2c3d4e5f6"
project_name: "my-react-app"
---
# Prefer Functional Style
## Action
Use functional patterns over classes when appropriate.
## Evidence
- Observed 5 instances of functional pattern preference
- User corrected class-based approach to functional on 2025-01-15Properties:
- Atomic -- one trigger, one action
- Confidence-weighted -- 0.3 = tentative, 0.9 = near certain
- Domain-tagged -- code-style, testing, git, debugging, workflow, etc.
- Evidence-backed -- tracks what observations created it
- Scope-aware --
project(default) orglobal
How It Works
Session Activity (in a git repo)
|
| Hooks capture prompts + tool use (100% reliable)
| + detect project context (git remote / repo path)
v
+---------------------------------------------+
| projects/<project-hash>/observations.jsonl |
| (prompts, tool calls, outcomes, project) |
+---------------------------------------------+
|
| Observer agent reads (background, Haiku)
v
+---------------------------------------------+
| PATTERN DETECTION |
| * User corrections -> instinct |
| * Error resolutions -> instinct |
| * Repeated workflows -> instinct |
| * Scope decision: project or global? |
+---------------------------------------------+
|
| Creates/updates
v
+---------------------------------------------+
| projects/<project-hash>/instincts/personal/ |
| * prefer-functional.yaml (0.7) [project] |
| * use-react-hooks.yaml (0.9) [project] |
+---------------------------------------------+
| instincts/personal/ (GLOBAL) |
| * always-validate-input.yaml (0.85) [global]|
| * grep-before-edit.yaml (0.6) [global] |
+---------------------------------------------+
|
| /evolve clusters + /promote
v
+---------------------------------------------+
| projects/<hash>/evolved/ (project-scoped) |
| evolved/ (global) |
| * commands/new-feature.md |
| * skills/testing-workflow.md |
| * agents/refactor-specialist.md |
+---------------------------------------------+Project Detection
The system automatically detects your current project:
1. `CLAUDE_PROJECT_DIR` env var (highest priority) 2. `git remote get-url origin` -- hashed to create a portable project ID (same repo on different machines gets the same ID) 3. `git rev-parse --show-toplevel` -- fallback using repo path (machine-specific) 4. Global fallback -- if no project is detected, instincts go to global scope
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 skills/continuous-learning-v2/scripts/migrate-homunculus.shQuick Start
1. Enable Observation Hooks
If installed as a plugin (recommended):
No extra settings.json hook block is required. Claude Code v2.1+ auto-loads the plugin hooks/hooks.json, and observe.sh is already registered there.
If you previously copied observe.sh into ~/.claude/settings.json, remove that duplicate PreToolUse / PostToolUse block. Duplicating the plugin hook causes double execution and ${CLAUDE_PLUGIN_ROOT} resolution errors because that variable is only available inside plugin-managed hooks/hooks.json entries.
If installed manually to ~/.claude/skills, add this to your ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh"
}]
}],
"PostToolUse": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh"
}]
}]
}
}2. Initialize Directory Structure
The system creates directories automatically on first use, but you can also create them manually:
# Global directories
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 repo3. Use the Instinct Commands
/instinct-status # Show learned instincts (project + global)
/evolve # Cluster related instincts into skills/commands
/instinct-export # Export instincts to file
/instinct-import # Import instincts from others
/promote # Promote project instincts to global scope
/projects # List all known projects and their instinct countsCommands
| Command | Description |
|---|---|
/instinct-status | Show all instincts (project-scoped + global) with confidence |
/evolve | Cluster related instincts into skills/commands, suggest promotions |
/instinct-export | Export instincts (filterable by scope/domain) |
/instinct-import <file> | Import instincts with scope control |
/promote [id] | Promote project instincts to global scope |
/projects | List all known projects and their instinct counts |
Configuration
Edit config.json to control the background observer:
{
"version": "2.1",
"observer": {
"enabled": false,
"run_interval_minutes": 5,
"min_observations_to_analyze": 20
}
}| Key | Default | Description |
|---|---|---|
observer.enabled | false | Enable the background observer agent |
observer.run_interval_minutes | 5 | How often the observer analyzes observations |
observer.min_observations_to_analyze | 20 | Minimum observations before analysis runs |
Other behavior (observation capture, instinct thresholds, project scoping, promotion criteria) is configured via code defaults in instinct-cli.py and observe.sh.
File Structure
${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)
+-- instincts/
| +-- personal/ # Global auto-learned instincts
| +-- inherited/ # Global imported instincts
+-- evolved/
| +-- agents/ # Global generated agents
| +-- skills/ # Global generated skills
| +-- commands/ # Global generated commands
+-- projects/
+-- a1b2c3d4e5f6/ # Project hash (from git remote URL)
| +-- project.json # Per-project metadata mirror (id/name/root/remote)
| +-- observations.jsonl
| +-- observations.archive/
| +-- instincts/
| | +-- personal/ # Project-specific auto-learned
| | +-- inherited/ # Project-specific imported
| +-- evolved/
| +-- skills/
| +-- commands/
| +-- agents/
+-- f6e5d4c3b2a1/ # Another project
+-- ...Scope Decision Guide
| Pattern Type | Scope | Examples |
|---|---|---|
| Language/framework conventions | project | "Use React hooks", "Follow Django REST patterns" |
| File structure preferences | project | "Tests in __tests__/", "Components in src/components/" |
| Code style | project | "Use functional style", "Prefer dataclasses" |
| Error handling strategies | project | "Use Result type for errors" |
| Security practices | global | "Validate user input", "Sanitize SQL" |
| General best practices | global | "Write tests first", "Always handle errors" |
| Tool workflow preferences | global | "Grep before Edit", "Read before Write" |
| Git practices | global | "Conventional commits", "Small focused commits" |
Instinct Promotion (Project -> Global)
When the same instinct appears in multiple projects with high confidence, it's a candidate for promotion to global scope.
Auto-promotion criteria:
- Same instinct ID in 2+ projects
- Average confidence >= 0.8
How to promote:
# Promote a specific instinct
python3 instinct-cli.py promote prefer-explicit-errors
# Auto-promote all qualifying instincts
python3 instinct-cli.py promote
# Preview without changes
python3 instinct-cli.py promote --dry-runThe /evolve command also suggests promotion candidates.
Confidence Scoring
Confidence evolves over time:
| Score | Meaning | Behavior |
|---|---|---|
| 0.3 | Tentative | Suggested but not enforced |
| 0.5 | Moderate | Applied when relevant |
| 0.7 | Strong | Auto-approved for application |
| 0.9 | Near-certain | Core behavior |
Confidence increases when:
- Pattern is repeatedly observed
- User doesn't correct the suggested behavior
- Similar instincts from other sources agree
Confidence decreases when:
- User explicitly corrects the behavior
- Pattern isn't observed for extended periods
- Contradicting evidence appears
Why Hooks vs Skills for Observation?
"v1 relied on skills to observe. Skills are probabilistic -- they fire ~50-80% of the time based on Claude's judgment."
Hooks fire 100% of the time, deterministically. This means:
- Every tool call is observed
- No patterns are missed
- Learning is comprehensive
Backward Compatibility
v2.1 is fully compatible with v2.0 and v1:
- Existing global instincts can be migrated from
~/.claude/homunculus/instincts/withscripts/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
Privacy
- Observations stay local on your machine
- Project-scoped instincts are isolated per project
- Only instincts (patterns) can be exported — not raw observations
- No actual code or conversation content is shared
- You control what gets exported and promoted
Related
- ECC-Tools GitHub App - Generate instincts from repo history
- Homunculus - Community project that inspired the v2 instinct-based architecture (atomic observations, confidence scoring, instinct evolution pipeline)
- The Longform Guide - Continuous learning section
---
Instinct-based learning: teaching Claude your patterns, one project at a time.
#!/usr/bin/env bash
# Continuous Learning v2 - Observer background loop
#
# Fix for #521: Added re-entrancy guard, cooldown throttle, and
# tail-based sampling to prevent memory explosion from runaway
# parallel Claude analysis processes.
set +e
unset CLAUDECODE
SLEEP_PID=""
USR1_FIRED=0
PENDING_ANALYSIS=0
ANALYZING=0
LAST_ANALYSIS_EPOCH=0
# Minimum seconds between analyses (prevents rapid re-triggering)
ANALYSIS_COOLDOWN="${ECC_OBSERVER_ANALYSIS_COOLDOWN:-60}"
IDLE_TIMEOUT_SECONDS="${ECC_OBSERVER_IDLE_TIMEOUT_SECONDS:-1800}"
SESSION_LEASE_DIR="${PROJECT_DIR}/.observer-sessions"
ACTIVITY_FILE="${PROJECT_DIR}/.observer-last-activity"
cleanup() {
[ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null
if [ -f "$PID_FILE" ] && [ "$(cat "$PID_FILE" 2>/dev/null)" = "$$" ]; then
rm -f "$PID_FILE"
fi
exit 0
}
trap cleanup TERM INT
file_mtime_epoch() {
local file="$1"
if [ ! -f "$file" ]; then
printf '0\n'
return
fi
if stat -c %Y "$file" >/dev/null 2>&1; then
stat -c %Y "$file" 2>/dev/null || printf '0\n'
return
fi
if stat -f %m "$file" >/dev/null 2>&1; then
stat -f %m "$file" 2>/dev/null || printf '0\n'
return
fi
printf '0\n'
}
has_active_session_leases() {
if [ ! -d "$SESSION_LEASE_DIR" ]; then
return 1
fi
find "$SESSION_LEASE_DIR" -type f -name '*.json' -print -quit 2>/dev/null | grep -q .
}
latest_activity_epoch() {
local observations_epoch activity_epoch
observations_epoch="$(file_mtime_epoch "$OBSERVATIONS_FILE")"
activity_epoch="$(file_mtime_epoch "$ACTIVITY_FILE")"
if [ "$activity_epoch" -gt "$observations_epoch" ] 2>/dev/null; then
printf '%s\n' "$activity_epoch"
else
printf '%s\n' "$observations_epoch"
fi
}
exit_if_idle_without_sessions() {
if has_active_session_leases; then
return
fi
local last_activity now_epoch idle_for
last_activity="$(latest_activity_epoch)"
now_epoch="$(date +%s)"
idle_for=$(( now_epoch - last_activity ))
if [ "$last_activity" -eq 0 ] || [ "$idle_for" -ge "$IDLE_TIMEOUT_SECONDS" ]; then
echo "[$(date)] Observer idle without active session leases for ${idle_for}s; exiting" >> "$LOG_FILE"
cleanup
fi
}
wait_for_claude_analysis() {
local child_pid="$1"
local wait_status=0
while true; do
wait "$child_pid"
wait_status=$?
if [ "$wait_status" -eq 0 ]; then
return 0
fi
# SIGUSR1 can interrupt wait while the Claude child is still running.
# Re-wait in that case so a signal is not logged as a false child failure.
if kill -0 "$child_pid" 2>/dev/null; then
continue
fi
return "$wait_status"
done
}
analyze_observations() {
if [ ! -f "$OBSERVATIONS_FILE" ]; then
return
fi
obs_count=$(wc -l < "$OBSERVATIONS_FILE" 2>/dev/null || echo 0)
if [ "$obs_count" -lt "$MIN_OBSERVATIONS" ]; then
return
fi
echo "[$(date)] Analyzing $obs_count observations for project ${PROJECT_NAME}..." >> "$LOG_FILE"
if [ "${CLV2_IS_WINDOWS:-false}" = "true" ] && [ "${ECC_OBSERVER_ALLOW_WINDOWS:-false}" != "true" ]; then
echo "[$(date)] Skipping claude analysis on Windows due to known non-interactive hang issue (#295). Set ECC_OBSERVER_ALLOW_WINDOWS=true to override." >> "$LOG_FILE"
return
fi
if ! command -v claude >/dev/null 2>&1; then
echo "[$(date)] claude CLI not found, skipping analysis" >> "$LOG_FILE"
return
fi
# session-guardian: gate observer cycle (active hours, cooldown, idle detection)
if ! bash "$(dirname "$0")/session-guardian.sh"; then
echo "[$(date)] Observer cycle skipped by session-guardian" >> "$LOG_FILE"
return
fi
# Sample recent observations instead of loading the entire file (#521).
# This prevents multi-MB payloads from being passed to the LLM.
MAX_ANALYSIS_LINES="${ECC_OBSERVER_MAX_ANALYSIS_LINES:-500}"
observer_tmp_dir="${PROJECT_DIR}/.observer-tmp"
mkdir -p "$observer_tmp_dir"
analysis_file="$(mktemp "${observer_tmp_dir}/ecc-observer-analysis.XXXXXX.jsonl")"
tail -n "$MAX_ANALYSIS_LINES" "$OBSERVATIONS_FILE" > "$analysis_file"
analysis_count=$(wc -l < "$analysis_file" 2>/dev/null || echo 0)
echo "[$(date)] Using last $analysis_count of $obs_count observations for analysis" >> "$LOG_FILE"
# Use relative path from PROJECT_DIR for cross-platform compatibility (#842).
# On Windows (Git Bash/MSYS2), absolute paths from mktemp may use MSYS-style
# prefixes (e.g. /c/Users/...) that the Claude subprocess cannot resolve.
analysis_relpath=".observer-tmp/$(basename "$analysis_file")"
prompt_file="$(mktemp "${observer_tmp_dir}/ecc-observer-prompt.XXXXXX")"
cat > "$prompt_file" <<PROMPT
IMPORTANT: You are running in non-interactive --print mode. You MUST use the Write tool directly to create files. Do NOT ask for permission, do NOT ask for confirmation, do NOT output summaries instead of writing. Just read, analyze, and write.
Read ${analysis_relpath} and identify patterns for the project ${PROJECT_NAME} (user corrections, error resolutions, repeated workflows, tool preferences).
If you find 3+ occurrences of the same pattern, you MUST write an instinct file directly to ${INSTINCTS_DIR}/<id>.md using the Write tool.
Do NOT ask for permission to write files, do NOT describe what you would write, and do NOT stop at analysis when a qualifying pattern exists.
CRITICAL: Every instinct file MUST use this exact format:
---
id: kebab-case-name
trigger: when <specific condition>
confidence: <0.3-0.85 based on frequency: 3-5 times=0.5, 6-10=0.7, 11+=0.85>
domain: <one of: code-style, testing, git, debugging, workflow, file-patterns>
source: session-observation
scope: project
project_id: ${PROJECT_ID}
project_name: ${PROJECT_NAME}
---
# Title
## Action
<what to do, one clear sentence>
## Evidence
- Observed N times in session <id>
- Pattern: <description>
- Last observed: <date>
Rules:
- Be conservative, only clear patterns with 3+ observations
- Use narrow, specific triggers
- Never include actual code snippets, only describe patterns
- When a qualifying pattern exists, write or update the instinct file in this run instead of asking for confirmation
- If a similar instinct already exists in ${INSTINCTS_DIR}/, update it instead of creating a duplicate
- The YAML frontmatter (between --- markers) with id field is MANDATORY
- If a pattern seems universal (not project-specific), set scope to global instead of project
- Examples of global patterns: always validate user input, prefer explicit error handling
- Examples of project patterns: use React functional components, follow Django REST framework conventions
PROMPT
# Read the prompt into memory before the Claude subprocess is spawned.
# On Windows/MSYS2, the mktemp path can differ from the shell's later path
# resolution, so relying on cat "$prompt_file" inside the claude invocation
# can fail even though the file was created successfully.
prompt_content="$(cat "$prompt_file" 2>/dev/null || true)"
rm -f "$prompt_file"
if [ -z "$prompt_content" ]; then
echo "[$(date)] Failed to load observer prompt content, skipping analysis" >> "$LOG_FILE"
rm -f "$analysis_file"
return
fi
timeout_seconds="${ECC_OBSERVER_TIMEOUT_SECONDS:-120}"
# Auto-scale max_turns proportional to analysis batch size when not explicitly set.
# The old hardcoded default of 20 is insufficient for the 500-line MAX_ANALYSIS_LINES
# default: Claude hits --max-turns before it can write all discovered instinct files.
# Formula: 1 turn per 10 analysis lines, floor 20, cap 100. (#2035)
if [ -n "${ECC_OBSERVER_MAX_TURNS:-}" ]; then
max_turns="${ECC_OBSERVER_MAX_TURNS}"
else
max_turns=$(( analysis_count / 10 ))
if [ "$max_turns" -lt 20 ]; then max_turns=20; fi
if [ "$max_turns" -gt 100 ]; then max_turns=100; fi
fi
exit_code=0
# Sanitize max_turns. The auto-scaled path above always yields a valid value >=20,
# but an explicit ECC_OBSERVER_MAX_TURNS override may be non-numeric, empty, or too
# small, so guard here and fall back to the safe default of 20.
case "$max_turns" in
''|*[!0-9]*)
max_turns=20
;;
esac
if [ "$max_turns" -lt 4 ]; then
max_turns=20
fi
# Ensure CWD is PROJECT_DIR so the relative analysis_relpath resolves correctly
# on all platforms, not just when the observer happens to be launched from the project root.
cd "$PROJECT_DIR" || { echo "[$(date)] Failed to cd to PROJECT_DIR ($PROJECT_DIR), skipping analysis" >> "$LOG_FILE"; rm -f "$analysis_file"; return; }
# Prevent observe.sh from recording this automated Haiku session as observations.
# Pass prompt via -p flag instead of stdin redirect for Windows compatibility (#842).
# prompt_content is already loaded in-memory so this no longer depends on the
# mktemp absolute path continuing to resolve after cwd changes (#1296).
ECC_SKIP_OBSERVE=1 ECC_HOOK_PROFILE=minimal claude --model haiku --max-turns "$max_turns" --print \
--allowedTools "Read,Write" \
-p "$prompt_content" >> "$LOG_FILE" 2>&1 &
claude_pid=$!
(
sleep "$timeout_seconds"
if kill -0 "$claude_pid" 2>/dev/null; then
echo "[$(date)] Claude analysis timed out after ${timeout_seconds}s; terminating process" >> "$LOG_FILE"
kill "$claude_pid" 2>/dev/null || true
fi
) &
watchdog_pid=$!
wait_for_claude_analysis "$claude_pid"
exit_code=$?
kill "$watchdog_pid" 2>/dev/null || true
rm -f "$analysis_file"
if [ "$exit_code" -ne 0 ]; then
echo "[$(date)] Claude analysis failed (exit $exit_code)" >> "$LOG_FILE"
fi
if [ -f "$OBSERVATIONS_FILE" ]; then
archive_dir="${PROJECT_DIR}/observations.archive"
mkdir -p "$archive_dir"
mv "$OBSERVATIONS_FILE" "$archive_dir/processed-$(date +%Y%m%d-%H%M%S)-$$.jsonl" 2>/dev/null || true
fi
}
on_usr1() {
[ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null
SLEEP_PID=""
# 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
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 ))
if [ "$elapsed" -lt "$ANALYSIS_COOLDOWN" ]; then
echo "[$(date)] Analysis cooldown active (${elapsed}s < ${ANALYSIS_COOLDOWN}s), skipping" >> "$LOG_FILE"
return
fi
ANALYZING=1
analyze_observations
LAST_ANALYSIS_EPOCH=$(date +%s)
ANALYZING=0
}
trap on_usr1 USR1
echo "$$" > "$PID_FILE"
echo "[$(date)] Observer started for ${PROJECT_NAME} (PID: $$)" >> "$LOG_FILE"
# Prune expired pending instincts before analysis
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
"${CLV2_PYTHON_CMD:-python3}" "${SCRIPT_DIR}/../scripts/instinct-cli.py" prune --quiet >> "$LOG_FILE" 2>&1 || echo "[$(date)] Warning: instinct prune failed (non-fatal)" >> "$LOG_FILE"
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
SLEEP_PID=""
exit_if_idle_without_sessions
if [ "$USR1_FIRED" -eq 1 ]; then
USR1_FIRED=0
else
ANALYZING=1
analyze_observations
LAST_ANALYSIS_EPOCH=$(date +%s)
ANALYZING=0
fi
done
Observer Agent
A background agent that analyzes observations from Claude Code sessions to detect patterns and create instincts.
When to Run
- After enough observations accumulate (configurable, default 20)
- On a scheduled interval (configurable, default 5 minutes)
- When triggered on demand via SIGUSR1 to the observer process
Input
Reads observations from the project-scoped observations file:
- Project:
${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects/<project-hash>/observations.jsonl - Global fallback:
${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/observations.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:01Z","event":"tool_complete","session":"abc123","tool":"Edit","output":"...","project_id":"a1b2c3d4e5f6","project_name":"my-react-app"}
{"timestamp":"2025-01-22T10:30:05Z","event":"tool_start","session":"abc123","tool":"Bash","input":"npm test","project_id":"a1b2c3d4e5f6","project_name":"my-react-app"}
{"timestamp":"2025-01-22T10:30:10Z","event":"tool_complete","session":"abc123","tool":"Bash","output":"All tests pass","project_id":"a1b2c3d4e5f6","project_name":"my-react-app"}Pattern Detection
Look for these patterns in observations:
1. User Corrections
When a user's follow-up message corrects Claude's previous action:
- "No, use X instead of Y"
- "Actually, I meant..."
- Immediate undo/redo patterns
→ Create instinct: "When doing X, prefer Y"
2. Error Resolutions
When an error is followed by a fix:
- Tool output contains error
- Next few tool calls fix it
- Same error type resolved similarly multiple times
→ Create instinct: "When encountering error X, try Y"
3. Repeated Workflows
When the same sequence of tools is used multiple times:
- Same tool sequence with similar inputs
- File patterns that change together
- Time-clustered operations
→ Create workflow instinct: "When doing X, follow steps Y, Z, W"
4. Tool Preferences
When certain tools are consistently preferred:
- Always uses Grep before Edit
- Prefers Read over Bash cat
- Uses specific Bash commands for certain tasks
→ Create instinct: "When needing X, use tool Y"
Output
Creates/updates instincts in the project-scoped instincts directory:
- Project:
${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects/<project-hash>/instincts/personal/ - Global:
${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/instincts/personal/(for universal patterns)
Project-Scoped Instinct (default)
---
id: use-react-hooks-pattern
trigger: "when creating React components"
confidence: 0.65
domain: "code-style"
source: "session-observation"
scope: project
project_id: "a1b2c3d4e5f6"
project_name: "my-react-app"
---
# Use React Hooks Pattern
## Action
Always use functional components with hooks instead of class components.
## Evidence
- Observed 8 times in session abc123
- Pattern: All new components use useState/useEffect
- Last observed: 2025-01-22Global Instinct (universal patterns)
---
id: always-validate-user-input
trigger: "when handling user input"
confidence: 0.75
domain: "security"
source: "session-observation"
scope: global
---
# Always Validate User Input
## Action
Validate and sanitize all user input before processing.
## Evidence
- Observed across 3 different projects
- Pattern: User consistently adds input validation
- Last observed: 2025-01-22Scope Decision Guide
When creating instincts, determine scope based on these heuristics:
| Pattern Type | Scope | Examples |
|---|---|---|
| Language/framework conventions | project | "Use React hooks", "Follow Django REST patterns" |
| File structure preferences | project | "Tests in __tests__/", "Components in src/components/" |
| Code style | project | "Use functional style", "Prefer dataclasses" |
| Error handling strategies | project (usually) | "Use Result type for errors" |
| Security practices | global | "Validate user input", "Sanitize SQL" |
| General best practices | global | "Write tests first", "Always handle errors" |
| Tool workflow preferences | global | "Grep before Edit", "Read before Write" |
| Git practices | global | "Conventional commits", "Small focused commits" |
When in doubt, default to `scope: project` — it's safer to be project-specific and promote later than to contaminate the global space.
Confidence Calculation
Initial confidence based on observation frequency:
- 1-2 observations: 0.3 (tentative)
- 3-5 observations: 0.5 (moderate)
- 6-10 observations: 0.7 (strong)
- 11+ observations: 0.85 (very strong)
Confidence adjusts over time:
- +0.05 for each confirming observation
- -0.1 for each contradicting observation
- -0.02 per week without observation (decay)
Instinct Promotion (Project → Global)
An instinct should be promoted from project-scoped to global when: 1. The same pattern (by id or similar trigger) exists in 2+ different projects 2. Each instance has confidence >= 0.8 3. The domain is in the global-friendly list (security, general-best-practices, workflow)
Promotion is handled by the instinct-cli.py promote command or the /evolve analysis.
Important Guidelines
1. Be Conservative: Only create instincts for clear patterns (3+ observations) 2. Be Specific: Narrow triggers are better than broad ones 3. Track Evidence: Always include what observations led to the instinct 4. Respect Privacy: Never include actual code snippets, only patterns 5. Merge Similar: If a new instinct is similar to existing, update rather than duplicate 6. Default to Project Scope: Unless the pattern is clearly universal, make it project-scoped 7. Include Project Context: Always set project_id and project_name for project-scoped instincts
Example Analysis Session
Given observations:
{"event":"tool_start","tool":"Grep","input":"pattern: useState","project_id":"a1b2c3","project_name":"my-app"}
{"event":"tool_complete","tool":"Grep","output":"Found in 3 files","project_id":"a1b2c3","project_name":"my-app"}
{"event":"tool_start","tool":"Read","input":"src/hooks/useAuth.ts","project_id":"a1b2c3","project_name":"my-app"}
{"event":"tool_complete","tool":"Read","output":"[file content]","project_id":"a1b2c3","project_name":"my-app"}
{"event":"tool_start","tool":"Edit","input":"src/hooks/useAuth.ts...","project_id":"a1b2c3","project_name":"my-app"}Analysis:
- Detected workflow: Grep → Read → Edit
- Frequency: Seen 5 times this session
- Scope decision: This is a general workflow pattern (not project-specific) → global
- Create instinct:
- trigger: "when modifying code"
- action: "Search with Grep, confirm with Read, then Edit"
- confidence: 0.6
- domain: "workflow"
- scope: "global"
Integration with Skill Creator
When instincts are imported from Skill Creator (repo analysis), they have:
source: "repo-analysis"source_repo: "https://github.com/..."scope: "project"(since they come from a specific repo)
These should be treated as team/project conventions with higher initial confidence (0.7+).
#!/usr/bin/env bash
# session-guardian.sh — Observer session guard
# Exit 0 = proceed. Exit 1 = skip this observer cycle.
# Called by observer-loop.sh before spawning any Claude session.
#
# Config (env vars, all optional):
# OBSERVER_INTERVAL_SECONDS default: 300 (per-project cooldown)
# OBSERVER_LAST_RUN_LOG default: ~/.claude/observer-last-run.log
# OBSERVER_ACTIVE_HOURS_START default: 800 (8:00 AM local, set to 0 to disable)
# OBSERVER_ACTIVE_HOURS_END default: 2300 (11:00 PM local, set to 0 to disable)
# OBSERVER_MAX_IDLE_SECONDS default: 1800 (30 min; set to 0 to disable)
#
# Gate execution order (cheapest first):
# Gate 1: Time window check (~0ms, string comparison)
# Gate 2: Project cooldown log (~1ms, file read + mkdir lock)
# Gate 3: Idle detection (~5-50ms, OS syscall; fail open)
set -euo pipefail
INTERVAL="${OBSERVER_INTERVAL_SECONDS:-300}"
LOG_PATH="${OBSERVER_LAST_RUN_LOG:-$HOME/.claude/observer-last-run.log}"
ACTIVE_START="${OBSERVER_ACTIVE_HOURS_START:-800}"
ACTIVE_END="${OBSERVER_ACTIVE_HOURS_END:-2300}"
MAX_IDLE="${OBSERVER_MAX_IDLE_SECONDS:-1800}"
# ── Gate 1: Time Window ───────────────────────────────────────────────────────
# Skip observer cycles outside configured active hours (local system time).
# Uses HHMM integer comparison. Works on BSD date (macOS) and GNU date (Linux).
# Supports overnight windows such as 2200-0600.
# Set both ACTIVE_START and ACTIVE_END to 0 to disable this gate.
if [ "$ACTIVE_START" -ne 0 ] || [ "$ACTIVE_END" -ne 0 ]; then
current_hhmm=$(date +%k%M | tr -d ' ')
current_hhmm_num=$(( 10#${current_hhmm:-0} ))
active_start_num=$(( 10#${ACTIVE_START:-800} ))
active_end_num=$(( 10#${ACTIVE_END:-2300} ))
within_active_hours=0
if [ "$active_start_num" -lt "$active_end_num" ]; then
if [ "$current_hhmm_num" -ge "$active_start_num" ] && [ "$current_hhmm_num" -lt "$active_end_num" ]; then
within_active_hours=1
fi
else
if [ "$current_hhmm_num" -ge "$active_start_num" ] || [ "$current_hhmm_num" -lt "$active_end_num" ]; then
within_active_hours=1
fi
fi
if [ "$within_active_hours" -ne 1 ]; then
echo "session-guardian: outside active hours (${current_hhmm}, window ${ACTIVE_START}-${ACTIVE_END})" >&2
exit 1
fi
fi
# ── Gate 2: Project Cooldown Log ─────────────────────────────────────────────
# Prevent the same project being observed faster than OBSERVER_INTERVAL_SECONDS.
# Key: PROJECT_DIR when provided by the observer, otherwise git root path.
# Uses mkdir-based lock for safe concurrent access. Skips the cycle on lock contention.
# stderr uses basename only — never prints the full absolute path.
project_root="${PROJECT_DIR:-}"
if [ -z "$project_root" ] || [ ! -d "$project_root" ]; then
project_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")"
fi
project_name="$(basename "$project_root")"
now="$(date +%s)"
mkdir -p "$(dirname "$LOG_PATH")" || {
echo "session-guardian: cannot create log dir, proceeding" >&2
exit 0
}
_lock_dir="${LOG_PATH}.lock"
if ! mkdir "$_lock_dir" 2>/dev/null; then
# Another observer holds the lock — skip this cycle to avoid double-spawns
echo "session-guardian: log locked by concurrent process, skipping cycle" >&2
exit 1
else
trap 'rm -rf "$_lock_dir"' EXIT INT TERM
last_spawn=0
last_spawn=$(awk -F '\t' -v key="$project_root" '$1 == key { value = $2 } END { if (value != "") print value }' "$LOG_PATH" 2>/dev/null) || true
last_spawn="${last_spawn:-0}"
[[ "$last_spawn" =~ ^[0-9]+$ ]] || last_spawn=0
elapsed=$(( now - last_spawn ))
if [ "$elapsed" -lt "$INTERVAL" ]; then
rm -rf "$_lock_dir"
trap - EXIT INT TERM
echo "session-guardian: cooldown active for '${project_name}' (last spawn ${elapsed}s ago, interval ${INTERVAL}s)" >&2
exit 1
fi
# Update log: remove old entry for this project, append new timestamp (tab-delimited)
tmp_log="$(mktemp "$(dirname "$LOG_PATH")/observer-last-run.XXXXXX")"
awk -F '\t' -v key="$project_root" '$1 != key' "$LOG_PATH" > "$tmp_log" 2>/dev/null || true
printf '%s\t%s\n' "$project_root" "$now" >> "$tmp_log"
mv "$tmp_log" "$LOG_PATH"
rm -rf "$_lock_dir"
trap - EXIT INT TERM
fi
# ── Gate 3: Idle Detection ────────────────────────────────────────────────────
# Skip cycles when no user input received for too long. Fail open if idle time
# cannot be determined (Linux without xprintidle, headless, unknown OS).
# Set OBSERVER_MAX_IDLE_SECONDS=0 to disable this gate.
get_idle_seconds() {
local _raw
case "$(uname -s)" in
Darwin)
_raw=$( { /usr/sbin/ioreg -c IOHIDSystem \
| /usr/bin/awk '/HIDIdleTime/ {print int($NF/1000000000); exit}'; } \
2>/dev/null ) || true
printf '%s\n' "${_raw:-0}" | head -n1
;;
Linux)
if command -v xprintidle >/dev/null 2>&1; then
_raw=$(xprintidle 2>/dev/null) || true
echo $(( ${_raw:-0} / 1000 ))
else
echo 0 # fail open: xprintidle not installed
fi
;;
*MINGW*|*MSYS*|*CYGWIN*)
_raw=$(powershell.exe -NoProfile -NonInteractive -Command \
"try { \
Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool GetLastInputInfo(ref LASTINPUTINFO p); [StructLayout(LayoutKind.Sequential)] public struct LASTINPUTINFO { public uint cbSize; public int dwTime; }' -Name WinAPI -Namespace PInvoke; \
\$l = New-Object PInvoke.WinAPI+LASTINPUTINFO; \$l.cbSize = 8; \
[PInvoke.WinAPI]::GetLastInputInfo([ref]\$l) | Out-Null; \
[int][Math]::Max(0, [long]([Environment]::TickCount - [long]\$l.dwTime) / 1000) \
} catch { 0 }" \
2>/dev/null | tr -d '\r') || true
printf '%s\n' "${_raw:-0}" | head -n1
;;
*)
echo 0 # fail open: unknown platform
;;
esac
}
if [ "$MAX_IDLE" -gt 0 ]; then
idle_seconds=$(get_idle_seconds)
if [ "$idle_seconds" -gt "$MAX_IDLE" ]; then
echo "session-guardian: user idle ${idle_seconds}s (threshold ${MAX_IDLE}s), skipping" >&2
exit 1
fi
fi
exit 0
#!/bin/bash
# Continuous Learning v2 - Observer Agent Launcher
#
# Starts the background observer agent that analyzes observations
# and creates instincts. Uses Haiku model for cost efficiency.
#
# v2.1: Project-scoped — detects current project and analyzes
# project-specific observations into project-scoped instincts.
#
# Usage:
# start-observer.sh # Start observer for current project (or global)
# start-observer.sh --reset # Clear lock and restart observer for current project
# start-observer.sh stop # Stop running observer
# start-observer.sh status # Check if observer is running
set -e
# NOTE: set -e is disabled inside the background subshell below
# to prevent claude CLI failures from killing the observer loop.
# ─────────────────────────────────────────────
# Project detection
# ─────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OBSERVER_LOOP_SCRIPT="${SCRIPT_DIR}/observer-loop.sh"
# Source shared project detection helper
# This sets: PROJECT_ID, PROJECT_NAME, PROJECT_ROOT, PROJECT_DIR
source "${SKILL_ROOT}/scripts/detect-project.sh"
PYTHON_CMD="${CLV2_PYTHON_CMD:-}"
# ─────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────
# 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
# PID file is project-scoped so each project can have its own observer
PID_FILE="${PROJECT_DIR}/.observer.pid"
LOG_FILE="${PROJECT_DIR}/observer.log"
OBSERVATIONS_FILE="${PROJECT_DIR}/observations.jsonl"
INSTINCTS_DIR="${PROJECT_DIR}/instincts/personal"
SENTINEL_FILE="${CLV2_OBSERVER_SENTINEL_FILE:-${PROJECT_ROOT:-$PROJECT_DIR}/.observer.lock}"
write_guard_sentinel() {
printf '%s\n' 'observer paused: confirmation or permission prompt detected; rerun start-observer.sh --reset after reviewing observer.log' > "$SENTINEL_FILE"
}
stop_observer_if_running() {
if [ -f "$PID_FILE" ]; then
pid=$(cat "$PID_FILE")
if kill -0 "$pid" 2>/dev/null; then
echo "Stopping observer for ${PROJECT_NAME} (PID: $pid)..."
kill "$pid"
rm -f "$PID_FILE"
echo "Observer stopped."
return 0
fi
echo "Observer not running (stale PID file)."
rm -f "$PID_FILE"
return 1
fi
echo "Observer not running."
return 1
}
# Read config values from config.json
OBSERVER_INTERVAL_MINUTES=5
MIN_OBSERVATIONS=20
OBSERVER_ENABLED=false
if [ -f "$CONFIG_FILE" ]; then
if [ -z "$PYTHON_CMD" ]; then
echo "No python interpreter found; using built-in observer defaults." >&2
else
_config=$(CLV2_CONFIG="$CONFIG_FILE" "$PYTHON_CMD" -c "
import json, os
with open(os.environ['CLV2_CONFIG']) as f:
cfg = json.load(f)
obs = cfg.get('observer', {})
print(obs.get('run_interval_minutes', 5))
print(obs.get('min_observations_to_analyze', 20))
print(str(obs.get('enabled', False)).lower())
" 2>/dev/null || echo "5
20
false")
_interval=$(echo "$_config" | sed -n '1p')
_min_obs=$(echo "$_config" | sed -n '2p')
_enabled=$(echo "$_config" | sed -n '3p')
if [ "$_interval" -gt 0 ] 2>/dev/null; then
OBSERVER_INTERVAL_MINUTES="$_interval"
fi
if [ "$_min_obs" -gt 0 ] 2>/dev/null; then
MIN_OBSERVATIONS="$_min_obs"
fi
if [ "$_enabled" = "true" ]; then
OBSERVER_ENABLED=true
fi
fi
fi
OBSERVER_INTERVAL_SECONDS=$((OBSERVER_INTERVAL_MINUTES * 60))
echo "Project: ${PROJECT_NAME} (${PROJECT_ID})"
echo "Storage: ${PROJECT_DIR}"
# Windows/Git-Bash detection (Issue #295)
UNAME_LOWER="$(uname -s 2>/dev/null | tr '[:upper:]' '[:lower:]')"
IS_WINDOWS=false
case "$UNAME_LOWER" in
*mingw*|*msys*|*cygwin*) IS_WINDOWS=true ;;
esac
ACTION="start"
RESET_OBSERVER=false
for arg in "$@"; do
case "$arg" in
start|stop|status)
ACTION="$arg"
;;
--reset)
RESET_OBSERVER=true
;;
*)
echo "Usage: $0 [start|stop|status] [--reset]"
exit 1
;;
esac
done
if [ "$RESET_OBSERVER" = "true" ]; then
rm -f "$SENTINEL_FILE"
fi
case "$ACTION" in
stop)
stop_observer_if_running || true
exit 0
;;
status)
if [ -f "$PID_FILE" ]; then
pid=$(cat "$PID_FILE")
if kill -0 "$pid" 2>/dev/null; then
echo "Observer is running (PID: $pid)"
echo "Log: $LOG_FILE"
echo "Observations: $(wc -l < "$OBSERVATIONS_FILE" 2>/dev/null || echo 0) lines"
# Also show instinct count
instinct_count=$(find "$INSTINCTS_DIR" -name "*.yaml" 2>/dev/null | wc -l)
echo "Instincts: $instinct_count"
exit 0
else
echo "Observer not running (stale PID file)"
rm -f "$PID_FILE"
exit 1
fi
else
echo "Observer not running"
exit 1
fi
;;
start)
# Check if observer is disabled in config
if [ "$OBSERVER_ENABLED" != "true" ]; then
echo "Observer is disabled in config.json (observer.enabled: false)."
echo "Set observer.enabled to true in config.json to enable."
exit 1
fi
# Check if already running
if [ -f "$PID_FILE" ]; then
pid=$(cat "$PID_FILE")
if kill -0 "$pid" 2>/dev/null; then
echo "Observer already running for ${PROJECT_NAME} (PID: $pid)"
exit 0
fi
rm -f "$PID_FILE"
fi
echo "Starting observer agent for ${PROJECT_NAME}..."
if [ ! -x "$OBSERVER_LOOP_SCRIPT" ]; then
echo "Observer loop script not found or not executable: $OBSERVER_LOOP_SCRIPT"
exit 1
fi
mkdir -p "$PROJECT_DIR"
touch "$LOG_FILE"
start_line=$(wc -l < "$LOG_FILE" 2>/dev/null || echo 0)
nohup env \
CONFIG_DIR="$CONFIG_DIR" \
PID_FILE="$PID_FILE" \
LOG_FILE="$LOG_FILE" \
OBSERVATIONS_FILE="$OBSERVATIONS_FILE" \
INSTINCTS_DIR="$INSTINCTS_DIR" \
PROJECT_DIR="$PROJECT_DIR" \
PROJECT_NAME="$PROJECT_NAME" \
PROJECT_ID="$PROJECT_ID" \
MIN_OBSERVATIONS="$MIN_OBSERVATIONS" \
OBSERVER_INTERVAL_SECONDS="$OBSERVER_INTERVAL_SECONDS" \
CLV2_IS_WINDOWS="$IS_WINDOWS" \
CLV2_OBSERVER_PROMPT_PATTERN="$CLV2_OBSERVER_PROMPT_PATTERN" \
"$OBSERVER_LOOP_SCRIPT" >> "$LOG_FILE" 2>&1 &
# Wait for PID file
sleep 2
# Check for confirmation-seeking output in the observer log
if tail -n +"$((start_line + 1))" "$LOG_FILE" 2>/dev/null | grep -E -i -q "$CLV2_OBSERVER_PROMPT_PATTERN"; then
echo "OBSERVER_ABORT: Confirmation or permission prompt detected in observer output. Failing closed."
stop_observer_if_running >/dev/null 2>&1 || true
write_guard_sentinel
exit 2
fi
if [ -f "$PID_FILE" ]; then
pid=$(cat "$PID_FILE")
if kill -0 "$pid" 2>/dev/null; then
echo "Observer started (PID: $pid)"
echo "Log: $LOG_FILE"
else
echo "Failed to start observer (process died immediately, check $LOG_FILE)"
exit 1
fi
else
echo "Failed to start observer"
exit 1
fi
;;
*)
echo "Usage: $0 [start|stop|status] [--reset]"
exit 1
;;
esac
{
"version": "2.1",
"observer": {
"enabled": false,
"run_interval_minutes": 5,
"min_observations_to_analyze": 20
}
}
#!/bin/bash
# Continuous Learning v2 - Observation Hook
#
# Captures tool use events for pattern analysis.
# Claude Code passes hook data via stdin as JSON.
#
# v2.1: Project-scoped observations — detects current project context
# and writes observations to project-specific directory.
#
# Registered via plugin hooks/hooks.json (auto-loaded when plugin is enabled).
# Can also be registered manually in ~/.claude/settings.json.
set -e
# Hook phase from CLI argument: "pre" (PreToolUse) or "post" (PostToolUse).
# Manual settings.json installs can call this script without the plugin
# wrapper's positional phase argument, but Claude Code still exposes the hook
# event name in CLAUDE_HOOK_EVENT_NAME. Fall back to that env var before
# defaulting to post so manually registered PreToolUse hooks are recorded as
# tool_start instead of being silently misclassified as tool_complete.
HOOK_PHASE="${1:-}"
if [ -z "$HOOK_PHASE" ]; then
case "${CLAUDE_HOOK_EVENT_NAME:-}" in
PreToolUse|pretooluse|pre_tool_use|pre) HOOK_PHASE="pre" ;;
PostToolUse|posttooluse|post_tool_use|post) HOOK_PHASE="post" ;;
*) HOOK_PHASE="post" ;;
esac
fi
# ─────────────────────────────────────────────
# Read stdin first (before project detection)
# ─────────────────────────────────────────────
# Read JSON from stdin (Claude Code hook format)
INPUT_JSON=$(cat)
# Exit if no input
if [ -z "$INPUT_JSON" ]; then
exit 0
fi
_is_windows_app_installer_stub() {
# Windows 10/11 ships an "App Execution Alias" stub at
# %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe
# %LOCALAPPDATA%\Microsoft\WindowsApps\python3.exe
# Both are symlinks to AppInstallerPythonRedirector.exe which, when Python
# is not installed from the Store, neither launches Python nor honors "-c".
# Calls to it hang or print a bare "Python " line, silently breaking every
# JSON-parsing step in this hook. Detect and skip such stubs here.
local _candidate="$1"
[ -z "$_candidate" ] && return 1
local _resolved
_resolved="$(command -v "$_candidate" 2>/dev/null || true)"
[ -z "$_resolved" ] && return 1
case "$_resolved" in
*AppInstallerPythonRedirector.exe|*AppInstallerPythonRedirector.EXE) return 0 ;;
esac
# Also resolve one level of symlink on POSIX-like shells (Git Bash, WSL).
if command -v readlink >/dev/null 2>&1; then
local _target
_target="$(readlink -f "$_resolved" 2>/dev/null || readlink "$_resolved" 2>/dev/null || true)"
case "$_target" in
*AppInstallerPythonRedirector.exe|*AppInstallerPythonRedirector.EXE) return 0 ;;
esac
fi
return 1
}
resolve_python_cmd() {
if [ -n "${CLV2_PYTHON_CMD:-}" ] && command -v "$CLV2_PYTHON_CMD" >/dev/null 2>&1; then
printf '%s\n' "$CLV2_PYTHON_CMD"
return 0
fi
if command -v python3 >/dev/null 2>&1 && ! _is_windows_app_installer_stub python3; then
printf '%s\n' python3
return 0
fi
if command -v python >/dev/null 2>&1 && ! _is_windows_app_installer_stub python; then
printf '%s\n' python
return 0
fi
return 1
}
PYTHON_CMD="$(resolve_python_cmd 2>/dev/null || true)"
if [ -z "$PYTHON_CMD" ]; then
echo "[observe] No python interpreter found, skipping observation" >&2
exit 0
fi
# Propagate our stub-aware selection so detect-project.sh (which is sourced
# below) does not re-resolve and silently fall back to the App Installer stub.
# detect-project.sh honors an already-set CLV2_PYTHON_CMD.
export CLV2_PYTHON_CMD="${CLV2_PYTHON_CMD:-$PYTHON_CMD}"
# ─────────────────────────────────────────────
# Extract cwd from stdin for project detection
# ─────────────────────────────────────────────
# Extract cwd from the hook JSON to use for project detection.
# If cwd is a subdirectory inside a git repo, resolve it to the repo root so
# observations attach to the project instead of a nested path.
STDIN_CWD=$(echo "$INPUT_JSON" | "$PYTHON_CMD" -c '
import json, sys
try:
data = json.load(sys.stdin)
cwd = data.get("cwd", "")
print(cwd)
except(KeyError, TypeError, ValueError):
print("")
' 2>/dev/null || echo "")
# 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)
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
# ─────────────────────────────────────────────
# Lightweight config and automated session guards
# ─────────────────────────────────────────────
#
# IMPORTANT: keep these guards above detect-project.sh.
# Sourcing detect-project.sh creates project-scoped directories and updates
# projects.json, so automated sessions must return before that point.
# 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
exit 0
fi
if [ -n "${CLV2_CONFIG:-}" ] && [ -f "$(dirname "$CLV2_CONFIG")/disabled" ]; then
exit 0
fi
# Prevent observe.sh from firing on non-human sessions to avoid:
# - ECC observing its own Haiku observer sessions (self-loop)
# - ECC observing other tools' automated sessions
# - automated sessions creating project-scoped homunculus metadata
# Layer 1: entrypoint. Only interactive terminal sessions should continue.
# sdk-ts: Agent SDK sessions can be human-interactive (e.g. via Happy).
# Non-interactive SDK automation is still filtered by Layers 2-5 below
# (ECC_HOOK_PROFILE=minimal, ECC_SKIP_OBSERVE=1, agent_id, path exclusions).
case "${CLAUDE_CODE_ENTRYPOINT:-cli}" in
cli|sdk-ts|claude-desktop|claude-vscode) ;;
*) exit 0 ;;
esac
# Layer 2: minimal hook profile suppresses non-essential hooks.
[ "${ECC_HOOK_PROFILE:-standard}" = "minimal" ] && exit 0
# Layer 3: cooperative skip env var for automated sessions.
[ "${ECC_SKIP_OBSERVE:-0}" = "1" ] && exit 0
# Layer 4: subagent sessions are automated by definition.
_ECC_AGENT_ID=$(echo "$INPUT_JSON" | "$PYTHON_CMD" -c "import json,sys; print(json.load(sys.stdin).get('agent_id',''))" 2>/dev/null || true)
[ -n "$_ECC_AGENT_ID" ] && exit 0
# Layer 5: known observer-session path exclusions.
_ECC_SKIP_PATHS="${ECC_OBSERVE_SKIP_PATHS:-observer-sessions,.claude-mem}"
if [ -n "$STDIN_CWD" ]; then
IFS=',' read -ra _ECC_SKIP_ARRAY <<< "$_ECC_SKIP_PATHS"
for _pattern in "${_ECC_SKIP_ARRAY[@]}"; do
_pattern="${_pattern#"${_pattern%%[![:space:]]*}"}"
_pattern="${_pattern%"${_pattern##*[![:space:]]}"}"
[ -z "$_pattern" ] && continue
case "$STDIN_CWD" in *"$_pattern"*) exit 0 ;; esac
done
fi
# ─────────────────────────────────────────────
# Project detection
# ─────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Source shared project detection helper
# This sets: PROJECT_ID, PROJECT_NAME, PROJECT_ROOT, PROJECT_DIR
source "${SKILL_ROOT}/scripts/detect-project.sh"
PYTHON_CMD="${CLV2_PYTHON_CMD:-$PYTHON_CMD}"
# ─────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────
OBSERVATIONS_FILE="${PROJECT_DIR}/observations.jsonl"
MAX_FILE_SIZE_MB=10
# Auto-purge observation files older than 30 days (runs once per session)
PURGE_MARKER="${PROJECT_DIR}/.last-purge"
if [ ! -f "$PURGE_MARKER" ] || [ "$(find "$PURGE_MARKER" -mtime +1 2>/dev/null)" ]; then
find "${PROJECT_DIR}" -name "observations-*.jsonl" -mtime +30 -delete 2>/dev/null || true
touch "$PURGE_MARKER" 2>/dev/null || true
fi
# Parse using Python via stdin pipe (safe for all JSON payloads)
# Pass HOOK_PHASE via env var since Claude Code does not include hook type in stdin JSON
PARSED=$(echo "$INPUT_JSON" | HOOK_PHASE="$HOOK_PHASE" "$PYTHON_CMD" -c '
import json
import sys
import os
try:
data = json.load(sys.stdin)
# Determine event type from CLI argument passed via env var.
# Claude Code does NOT include a "hook_type" field in the stdin JSON,
# so we rely on the shell argument ("pre" or "post") instead.
hook_phase = os.environ.get("HOOK_PHASE", "post")
event = "tool_start" if hook_phase == "pre" else "tool_complete"
# Extract fields - Claude Code hook format
tool_name = data.get("tool_name", data.get("tool", "unknown"))
tool_input = data.get("tool_input", data.get("input", {}))
tool_output = data.get("tool_response")
if tool_output is None:
tool_output = data.get("tool_output", data.get("output", ""))
session_id = data.get("session_id", "unknown")
tool_use_id = data.get("tool_use_id", "")
cwd = data.get("cwd", "")
# Truncate large inputs/outputs
if isinstance(tool_input, dict):
tool_input_str = json.dumps(tool_input)[:5000]
else:
tool_input_str = str(tool_input)[:5000]
if isinstance(tool_output, dict):
tool_response_str = json.dumps(tool_output)[:5000]
else:
tool_response_str = str(tool_output)[:5000]
print(json.dumps({
"parsed": True,
"event": event,
"tool": tool_name,
"input": tool_input_str if event == "tool_start" else None,
"output": tool_response_str if event == "tool_complete" else None,
"session": session_id,
"tool_use_id": tool_use_id,
"cwd": cwd
}))
except Exception as e:
print(json.dumps({"parsed": False, "error": str(e)}))
')
# Check if parsing succeeded
PARSED_OK=$(echo "$PARSED" | "$PYTHON_CMD" -c "import json,sys; print(json.load(sys.stdin).get('parsed', False))" 2>/dev/null || echo "False")
if [ "$PARSED_OK" != "True" ]; then
# Fallback: log raw input for debugging (scrub secrets before persisting)
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
export TIMESTAMP="$timestamp"
echo "$INPUT_JSON" | "$PYTHON_CMD" -c '
import json, sys, os, re
# Linear-time secret matcher. Bounded quantifiers and a fixed set of auth
# schemes (instead of a generic [A-Za-z]+\s+ that overlapped the value class)
# prevent the catastrophic backtracking that pegged python at 100% CPU (#2278).
_SECRET_RE = re.compile(
r"(?i)(api[_-]?key|token|secret|password|authorization|credentials?|auth)"
r"""(["'"'"'\s:=]{1,8})"""
r"((?:bearer|basic|token|bot)\s+)?"
r"([A-Za-z0-9_\-/.+=]{8,256})"
)
import signal
def _ecc_bail(*_):
sys.exit(0)
try:
signal.signal(signal.SIGALRM, _ecc_bail)
signal.alarm(8) # self-terminate before the async hook 10s timeout can orphan us (#2278)
except Exception:
pass
raw = sys.stdin.read()[:2000]
raw = _SECRET_RE.sub(lambda m: m.group(1) + m.group(2) + (m.group(3) or "") + "[REDACTED]", raw)
print(json.dumps({"timestamp": os.environ["TIMESTAMP"], "event": "parse_error", "raw": raw}))
' >> "$OBSERVATIONS_FILE"
exit 0
fi
# Archive if file too large (atomic: rename with unique suffix to avoid race)
if [ -f "$OBSERVATIONS_FILE" ]; then
file_size_mb=$(du -m "$OBSERVATIONS_FILE" 2>/dev/null | cut -f1)
if [ "${file_size_mb:-0}" -ge "$MAX_FILE_SIZE_MB" ]; then
archive_dir="${PROJECT_DIR}/observations.archive"
mkdir -p "$archive_dir"
mv "$OBSERVATIONS_FILE" "$archive_dir/observations-$(date +%Y%m%d-%H%M%S)-$$.jsonl" 2>/dev/null || true
fi
fi
# Build and write observation (now includes project context)
# Scrub common secret patterns from tool I/O before persisting
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
export PROJECT_ID_ENV="$PROJECT_ID"
export PROJECT_NAME_ENV="$PROJECT_NAME"
export TIMESTAMP="$timestamp"
echo "$PARSED" | "$PYTHON_CMD" -c '
import json, sys, os, re
import signal
def _ecc_bail(*_):
sys.exit(0)
try:
signal.signal(signal.SIGALRM, _ecc_bail)
signal.alarm(8) # self-terminate before the async hook 10s timeout can orphan us (#2278)
except Exception:
pass
parsed = json.load(sys.stdin)
observation = {
"timestamp": os.environ["TIMESTAMP"],
"event": parsed["event"],
"tool": parsed["tool"],
"session": parsed["session"],
"project_id": os.environ.get("PROJECT_ID_ENV", "global"),
"project_name": os.environ.get("PROJECT_NAME_ENV", "global")
}
# Scrub secrets: match common key=value, key: value, and key"value patterns
# Includes optional auth scheme (e.g., "Bearer", "Basic") before token
# Linear-time secret matcher. Bounded quantifiers and a fixed set of auth
# schemes (instead of a generic [A-Za-z]+\s+ that overlapped the value class)
# prevent the catastrophic backtracking that pegged python at 100% CPU (#2278).
_SECRET_RE = re.compile(
r"(?i)(api[_-]?key|token|secret|password|authorization|credentials?|auth)"
r"""(["'"'"'\s:=]{1,8})"""
r"((?:bearer|basic|token|bot)\s+)?"
r"([A-Za-z0-9_\-/.+=]{8,256})"
)
def scrub(val):
if val is None:
return None
return _SECRET_RE.sub(lambda m: m.group(1) + m.group(2) + (m.group(3) or "") + "[REDACTED]", str(val))
if parsed["input"]:
observation["input"] = scrub(parsed["input"])
if parsed["output"] is not None:
observation["output"] = scrub(parsed["output"])
print(json.dumps(observation))
' >> "$OBSERVATIONS_FILE"
# Lazy-start observer if enabled but not running (first-time setup)
# Use flock for atomic check-then-act to prevent race conditions
# Fallback for macOS (no flock): use lockfile or skip
LAZY_START_LOCK="${PROJECT_DIR}/.observer-start.lock"
_REMOVE_FILE_IF_PRESENT() {
local target="$1"
if [ -n "$target" ] && [ -e "$target" ]; then
rm -- "$target" 2>/dev/null || true
fi
}
_START_OBSERVER_LOGGED() {
local bootstrap_log="${PROJECT_DIR}/observer-start.log"
mkdir -p "$PROJECT_DIR"
"${SKILL_ROOT}/agents/start-observer.sh" start >> "$bootstrap_log" 2>&1 || true
}
_CHECK_OBSERVER_RUNNING() {
local pid_file="$1"
if [ -f "$pid_file" ]; then
local pid
pid=$(cat "$pid_file" 2>/dev/null)
# Validate PID is a positive integer (>1) to prevent signaling invalid targets
case "$pid" in
''|*[!0-9]*|0|1)
_REMOVE_FILE_IF_PRESENT "$pid_file"
return 1
;;
esac
if kill -0 "$pid" 2>/dev/null; then
return 0 # Process is alive
fi
# Stale PID file - remove it
_REMOVE_FILE_IF_PRESENT "$pid_file"
fi
return 1 # No PID file or process dead
}
if [ -f "${CONFIG_DIR}/disabled" ]; then
OBSERVER_ENABLED=false
else
OBSERVER_ENABLED=false
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"
if [ -f "$EFFECTIVE_CONFIG" ] && [ -n "$PYTHON_CMD" ]; then
_enabled=$(CLV2_CONFIG_PATH="$EFFECTIVE_CONFIG" "$PYTHON_CMD" -c "
import json, os
with open(os.environ['CLV2_CONFIG_PATH']) as f:
cfg = json.load(f)
print(str(cfg.get('observer', {}).get('enabled', False)).lower())
" 2>/dev/null || echo "false")
if [ "$_enabled" = "true" ]; then
OBSERVER_ENABLED=true
fi
fi
fi
# Check both project-scoped AND global PID files (with stale PID recovery)
if [ "$OBSERVER_ENABLED" = "true" ]; then
# Clean up stale PID files first
_CHECK_OBSERVER_RUNNING "${PROJECT_DIR}/.observer.pid" || true
_CHECK_OBSERVER_RUNNING "${CONFIG_DIR}/.observer.pid" || true
# Check if observer is now running after cleanup
if [ ! -f "${PROJECT_DIR}/.observer.pid" ] && [ ! -f "${CONFIG_DIR}/.observer.pid" ]; then
# Use flock if available (Linux), fallback for macOS
if command -v flock >/dev/null 2>&1; then
(
flock -n 9 || exit 0
# Double-check PID files after acquiring lock
_CHECK_OBSERVER_RUNNING "${PROJECT_DIR}/.observer.pid" || true
_CHECK_OBSERVER_RUNNING "${CONFIG_DIR}/.observer.pid" || true
if [ ! -f "${PROJECT_DIR}/.observer.pid" ] && [ ! -f "${CONFIG_DIR}/.observer.pid" ]; then
_START_OBSERVER_LOGGED
fi
) 9>"$LAZY_START_LOCK"
else
# macOS fallback: use lockfile if available, otherwise mkdir-based lock
if command -v lockfile >/dev/null 2>&1; then
# Use subshell to isolate exit and add trap for cleanup
(
trap '_REMOVE_FILE_IF_PRESENT "$LAZY_START_LOCK"' EXIT
lockfile -r 1 -l 30 "$LAZY_START_LOCK" 2>/dev/null || exit 0
_CHECK_OBSERVER_RUNNING "${PROJECT_DIR}/.observer.pid" || true
_CHECK_OBSERVER_RUNNING "${CONFIG_DIR}/.observer.pid" || true
if [ ! -f "${PROJECT_DIR}/.observer.pid" ] && [ ! -f "${CONFIG_DIR}/.observer.pid" ]; then
_START_OBSERVER_LOGGED
fi
_REMOVE_FILE_IF_PRESENT "$LAZY_START_LOCK"
)
else
# POSIX fallback: mkdir is atomic -- fails if dir already exists
(
trap 'rmdir "${LAZY_START_LOCK}.d" 2>/dev/null || true' EXIT
mkdir "${LAZY_START_LOCK}.d" 2>/dev/null || exit 0
_CHECK_OBSERVER_RUNNING "${PROJECT_DIR}/.observer.pid" || true
_CHECK_OBSERVER_RUNNING "${CONFIG_DIR}/.observer.pid" || true
if [ ! -f "${PROJECT_DIR}/.observer.pid" ] && [ ! -f "${CONFIG_DIR}/.observer.pid" ]; then
_START_OBSERVER_LOGGED
fi
)
fi
fi
fi
fi
# Throttle SIGUSR1: only signal observer every N observations (#521)
# This prevents rapid signaling when tool calls fire every second,
# which caused runaway parallel Claude analysis processes.
SIGNAL_EVERY_N="${ECC_OBSERVER_SIGNAL_EVERY_N:-20}"
SIGNAL_COUNTER_FILE="${PROJECT_DIR}/.observer-signal-counter"
ACTIVITY_FILE="${PROJECT_DIR}/.observer-last-activity"
touch "$ACTIVITY_FILE" 2>/dev/null || true
should_signal=0
if [ -f "$SIGNAL_COUNTER_FILE" ]; then
counter=$(cat "$SIGNAL_COUNTER_FILE" 2>/dev/null || echo 0)
counter=$((counter + 1))
if [ "$counter" -ge "$SIGNAL_EVERY_N" ]; then
should_signal=1
counter=0
fi
echo "$counter" > "$SIGNAL_COUNTER_FILE"
else
echo "1" > "$SIGNAL_COUNTER_FILE"
fi
# Signal observer if running and throttle allows (check both project-scoped and global observer, deduplicate)
if [ "$should_signal" -eq 1 ]; then
signaled_pids=" "
for pid_file in "${PROJECT_DIR}/.observer.pid" "${CONFIG_DIR}/.observer.pid"; do
if [ -f "$pid_file" ]; then
observer_pid=$(cat "$pid_file" 2>/dev/null || true)
# Validate PID is a positive integer (>1)
case "$observer_pid" in
''|*[!0-9]*|0|1)
_REMOVE_FILE_IF_PRESENT "$pid_file"
continue
;;
esac
# Deduplicate: skip if already signaled this pass
case "$signaled_pids" in
*" $observer_pid "*) continue ;;
esac
if kill -0 "$observer_pid" 2>/dev/null; then
kill -USR1 "$observer_pid" 2>/dev/null || true
signaled_pids="${signaled_pids}${observer_pid} "
fi
fi
done
fi
exit 0
#!/bin/bash
# Continuous Learning v2 - Project Detection Helper
#
# Shared logic for detecting current project context.
# Sourced by observe.sh and start-observer.sh.
#
# Exports:
# _CLV2_PROJECT_ID - Short hash identifying the project (or "global")
# _CLV2_PROJECT_NAME - Human-readable project name
# _CLV2_PROJECT_ROOT - Absolute path to project root
# _CLV2_PROJECT_DIR - Project-scoped storage directory under homunculus
#
# Also sets unprefixed convenience aliases:
# PROJECT_ID, PROJECT_NAME, PROJECT_ROOT, PROJECT_DIR
#
# Detection priority:
# 1. CLAUDE_PROJECT_DIR env var (if set)
# 2. git remote URL (hashed for uniqueness across machines)
# 3. git repo root path (fallback, machine-specific)
# 4. "global" (no project context detected)
# 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"
_clv2_resolve_python_cmd() {
if [ -n "${CLV2_PYTHON_CMD:-}" ] && command -v "$CLV2_PYTHON_CMD" >/dev/null 2>&1; then
printf '%s\n' "$CLV2_PYTHON_CMD"
return 0
fi
if command -v python3 >/dev/null 2>&1; then
printf '%s\n' python3
return 0
fi
if command -v python >/dev/null 2>&1; then
printf '%s\n' python
return 0
fi
return 1
}
_CLV2_PYTHON_CMD="$(_clv2_resolve_python_cmd 2>/dev/null || true)"
CLV2_PYTHON_CMD="$_CLV2_PYTHON_CMD"
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_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" ] && 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)
if [ -z "$project_root" ] && command -v git &>/dev/null; then
project_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
if [ -n "$project_root" ]; then
source_hint="git"
fi
fi
# 3. No project detected — fall back to global
if [ -z "$project_root" ]; 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
# Derive project name from directory basename
# Normalize Windows backslashes so basename works when CLAUDE_PROJECT_DIR
# is passed as e.g. C:\Users\...\project.
local _norm_root
_norm_root=$(printf '%s' "$project_root" | sed 's|\\|/|g')
project_name=$(basename "$_norm_root")
# Derive project ID: prefer git remote URL hash (portable across machines),
# fall back to path hash (machine-specific but still useful)
local remote_url=""
if command -v git &>/dev/null; then
if [ "$source_hint" = "git" ] || [ -e "${project_root}/.git" ]; then
remote_url=$(git -C "$project_root" remote get-url origin 2>/dev/null || true)
fi
fi
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 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 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
# would otherwise produce different hashes for the same non-ASCII path).
if [ -n "$_CLV2_PYTHON_CMD" ]; then
project_id=$(_CLV2_HASH_INPUT="$hash_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)
fi
# Fallback if Python is unavailable or hash generation failed.
if [ -z "$project_id" ]; then
project_id=$(printf '%s' "$hash_input" | shasum -a 256 2>/dev/null | cut -c1-12 || \
printf '%s' "$hash_input" | sha256sum 2>/dev/null | cut -c1-12 || \
echo "fallback")
fi
# 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" ] && [ "$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
_CLV2_PROJECT_ID="$project_id"
_CLV2_PROJECT_NAME="$project_name"
_CLV2_PROJECT_ROOT="$project_root"
_CLV2_PROJECT_DIR="${_CLV2_PROJECTS_DIR}/${project_id}"
# Ensure project directory structure exists
mkdir -p "${_CLV2_PROJECT_DIR}/instincts/personal"
mkdir -p "${_CLV2_PROJECT_DIR}/instincts/inherited"
mkdir -p "${_CLV2_PROJECT_DIR}/observations.archive"
mkdir -p "${_CLV2_PROJECT_DIR}/evolved/skills"
mkdir -p "${_CLV2_PROJECT_DIR}/evolved/commands"
mkdir -p "${_CLV2_PROJECT_DIR}/evolved/agents"
# Update project registry (lightweight JSON mapping)
_clv2_update_project_registry "$project_id" "$project_name" "$project_root" "$remote_url"
}
_clv2_update_project_registry() {
local pid="$1"
local pname="$2"
local proot="$3"
local premote="$4"
local pdir="$_CLV2_PROJECT_DIR"
mkdir -p "$(dirname "$_CLV2_REGISTRY_FILE")"
if [ -z "$_CLV2_PYTHON_CMD" ]; then
return 0
fi
# Pass values via env vars to avoid shell→python injection.
# Python reads them with os.environ, which is safe for any string content.
_CLV2_REG_PID="$pid" \
_CLV2_REG_PNAME="$pname" \
_CLV2_REG_PROOT="$proot" \
_CLV2_REG_PREMOTE="$premote" \
_CLV2_REG_PDIR="$pdir" \
_CLV2_REG_FILE="$_CLV2_REGISTRY_FILE" \
"$_CLV2_PYTHON_CMD" -c '
import json, os, tempfile
from datetime import datetime, timezone
registry_path = os.environ["_CLV2_REG_FILE"]
project_dir = os.environ["_CLV2_REG_PDIR"]
project_file = os.path.join(project_dir, "project.json")
os.makedirs(project_dir, exist_ok=True)
def atomic_write_json(path, payload):
fd, tmp_path = tempfile.mkstemp(
prefix=f".{os.path.basename(path)}.tmp.",
dir=os.path.dirname(path),
text=True,
)
try:
with os.fdopen(fd, "w") as f:
json.dump(payload, f, indent=2)
f.write("\n")
os.replace(tmp_path, path)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
try:
with open(registry_path) as f:
registry = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
registry = {}
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
entry = registry.get(os.environ["_CLV2_REG_PID"], {})
metadata = {
"id": os.environ["_CLV2_REG_PID"],
"name": os.environ["_CLV2_REG_PNAME"],
"root": os.environ["_CLV2_REG_PROOT"],
"remote": os.environ["_CLV2_REG_PREMOTE"],
"created_at": entry.get("created_at", now),
"last_seen": now,
}
registry[os.environ["_CLV2_REG_PID"]] = metadata
atomic_write_json(project_file, metadata)
atomic_write_json(registry_path, registry)
' 2>/dev/null || true
}
# Auto-detect on source
_clv2_detect_project
# Convenience aliases for callers (short names pointing to prefixed vars)
PROJECT_ID="$_CLV2_PROJECT_ID"
PROJECT_NAME="$_CLV2_PROJECT_NAME"
PROJECT_ROOT="$_CLV2_PROJECT_ROOT"
PROJECT_DIR="$_CLV2_PROJECT_DIR"
if [ -n "$PROJECT_ROOT" ]; then
CLV2_OBSERVER_SENTINEL_FILE="${PROJECT_ROOT}/.observer.lock"
else
CLV2_OBSERVER_SENTINEL_FILE="${PROJECT_DIR}/.observer.lock"
fi
export CLV2_OBSERVER_SENTINEL_FILE
#!/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
}
#!/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
"""Tests for continuous-learning-v2 instinct-cli.py
Covers:
- parse_instinct_file() — content preservation, edge cases
- _validate_file_path() — path traversal blocking
- detect_project() — project detection with mocked git/env
- load_all_instincts() — loading from project + global dirs, dedup
- _load_instincts_from_dir() — directory scanning
- cmd_projects() — listing projects from registry
- cmd_status() — status display
- _promote_specific() — single instinct promotion
- _promote_auto() — auto-promotion across projects
"""
import importlib.util
import io
import json
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
import pytest
# Load instinct-cli.py (hyphenated filename requires importlib)
_spec = importlib.util.spec_from_file_location(
"instinct_cli",
os.path.join(os.path.dirname(__file__), "instinct-cli.py"),
)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
parse_instinct_file = _mod.parse_instinct_file
_validate_file_path = _mod._validate_file_path
detect_project = _mod.detect_project
load_all_instincts = _mod.load_all_instincts
load_project_only_instincts = _mod.load_project_only_instincts
_load_instincts_from_dir = _mod._load_instincts_from_dir
cmd_status = _mod.cmd_status
cmd_projects = _mod.cmd_projects
_promote_specific = _mod._promote_specific
_promote_auto = _mod._promote_auto
_find_cross_project_instincts = _mod._find_cross_project_instincts
load_registry = _mod.load_registry
_validate_instinct_id = _mod._validate_instinct_id
_validate_import_url = _mod._validate_import_url
_update_registry = _mod._update_registry
_confidence_bar = _mod._confidence_bar
# ─────────────────────────────────────────────
# Fixtures
# ─────────────────────────────────────────────
SAMPLE_INSTINCT_YAML = """\
---
id: test-instinct
trigger: "when writing tests"
confidence: 0.8
domain: testing
scope: project
---
## Action
Always write tests first.
## Evidence
TDD leads to better design.
"""
SAMPLE_GLOBAL_INSTINCT_YAML = """\
---
id: global-instinct
trigger: "always"
confidence: 0.9
domain: security
scope: global
---
## Action
Validate all user input.
"""
@pytest.fixture
def project_tree(tmp_path):
"""Create a realistic project directory tree for testing."""
homunculus = tmp_path / ".claude" / "homunculus"
projects_dir = homunculus / "projects"
global_personal = homunculus / "instincts" / "personal"
global_inherited = homunculus / "instincts" / "inherited"
global_evolved = homunculus / "evolved"
for d in [
global_personal, global_inherited,
global_evolved / "skills", global_evolved / "commands", global_evolved / "agents",
projects_dir,
]:
d.mkdir(parents=True, exist_ok=True)
return {
"root": tmp_path,
"homunculus": homunculus,
"projects_dir": projects_dir,
"global_personal": global_personal,
"global_inherited": global_inherited,
"global_evolved": global_evolved,
"registry_file": homunculus / "projects.json",
}
@pytest.fixture
def patch_globals(project_tree, monkeypatch):
"""Patch module-level globals to use tmp_path-based directories."""
monkeypatch.setattr(_mod, "HOMUNCULUS_DIR", project_tree["homunculus"])
monkeypatch.setattr(_mod, "PROJECTS_DIR", project_tree["projects_dir"])
monkeypatch.setattr(_mod, "REGISTRY_FILE", project_tree["registry_file"])
monkeypatch.setattr(_mod, "GLOBAL_PERSONAL_DIR", project_tree["global_personal"])
monkeypatch.setattr(_mod, "GLOBAL_INHERITED_DIR", project_tree["global_inherited"])
monkeypatch.setattr(_mod, "GLOBAL_EVOLVED_DIR", project_tree["global_evolved"])
monkeypatch.setattr(_mod, "GLOBAL_OBSERVATIONS_FILE", project_tree["homunculus"] / "observations.jsonl")
return project_tree
def _make_project(tree, pid="abc123", pname="test-project"):
"""Create project directory structure and return a project dict."""
project_dir = tree["projects_dir"] / pid
personal_dir = project_dir / "instincts" / "personal"
inherited_dir = project_dir / "instincts" / "inherited"
for d in [personal_dir, inherited_dir,
project_dir / "evolved" / "skills",
project_dir / "evolved" / "commands",
project_dir / "evolved" / "agents",
project_dir / "observations.archive"]:
d.mkdir(parents=True, exist_ok=True)
return {
"id": pid,
"name": pname,
"root": str(tree["root"] / "fake-repo"),
"remote": "https://github.com/test/test-project.git",
"project_dir": project_dir,
"instincts_personal": personal_dir,
"instincts_inherited": inherited_dir,
"evolved_dir": project_dir / "evolved",
"observations_file": project_dir / "observations.jsonl",
}
# ─────────────────────────────────────────────
# parse_instinct_file tests
# ─────────────────────────────────────────────
MULTI_SECTION = """\
---
id: instinct-a
trigger: "when coding"
confidence: 0.9
domain: general
---
## Action
Do thing A.
## Examples
- Example A1
---
id: instinct-b
trigger: "when testing"
confidence: 0.7
domain: testing
---
## Action
Do thing B.
"""
def test_multiple_instincts_preserve_content():
result = parse_instinct_file(MULTI_SECTION)
assert len(result) == 2
assert "Do thing A." in result[0]["content"]
assert "Example A1" in result[0]["content"]
assert "Do thing B." in result[1]["content"]
def test_single_instinct_preserves_content():
content = """\
---
id: solo
trigger: "when reviewing"
confidence: 0.8
domain: review
---
## Action
Check for security issues.
## Evidence
Prevents vulnerabilities.
"""
result = parse_instinct_file(content)
assert len(result) == 1
assert "Check for security issues." in result[0]["content"]
assert "Prevents vulnerabilities." in result[0]["content"]
def test_empty_content_no_error():
content = """\
---
id: empty
trigger: "placeholder"
confidence: 0.5
domain: general
---
"""
result = parse_instinct_file(content)
assert len(result) == 1
assert result[0]["content"] == ""
def test_parse_no_id_skipped():
"""Instincts without an 'id' field should be silently dropped."""
content = """\
---
trigger: "when doing nothing"
confidence: 0.5
---
No id here.
"""
result = parse_instinct_file(content)
assert len(result) == 0
def test_parse_confidence_is_float():
content = """\
---
id: float-check
trigger: "when parsing"
confidence: 0.42
domain: general
---
Body.
"""
result = parse_instinct_file(content)
assert isinstance(result[0]["confidence"], float)
assert result[0]["confidence"] == pytest.approx(0.42)
def test_parse_trigger_strips_quotes():
content = """\
---
id: quote-check
trigger: "when quoting"
confidence: 0.5
domain: general
---
Body.
"""
result = parse_instinct_file(content)
assert result[0]["trigger"] == "when quoting"
def test_parse_empty_string():
result = parse_instinct_file("")
assert result == []
def test_parse_garbage_input():
result = parse_instinct_file("this is not yaml at all\nno frontmatter here")
assert result == []
# ─────────────────────────────────────────────
# _validate_file_path tests
# ─────────────────────────────────────────────
def test_validate_normal_path(tmp_path):
test_file = tmp_path / "test.yaml"
test_file.write_text("hello")
result = _validate_file_path(str(test_file), must_exist=True)
assert result == test_file.resolve()
def test_validate_rejects_etc():
with pytest.raises(ValueError, match="system directory"):
_validate_file_path("/etc/passwd")
def test_validate_rejects_var_log():
with pytest.raises(ValueError, match="system directory"):
_validate_file_path("/var/log/syslog")
def test_validate_rejects_usr():
with pytest.raises(ValueError, match="system directory"):
_validate_file_path("/usr/local/bin/foo")
def test_validate_rejects_proc():
with pytest.raises(ValueError, match="system directory"):
_validate_file_path("/proc/self/status")
def test_validate_must_exist_fails(tmp_path):
with pytest.raises(ValueError, match="does not exist"):
_validate_file_path(str(tmp_path / "nonexistent.yaml"), must_exist=True)
def test_validate_home_expansion(tmp_path):
"""Tilde expansion should work."""
result = _validate_file_path("~/test.yaml")
assert str(result).startswith(str(Path.home()))
def test_validate_relative_path(tmp_path, monkeypatch):
"""Relative paths should be resolved."""
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "rel.yaml"
test_file.write_text("content")
result = _validate_file_path("rel.yaml", must_exist=True)
assert result == test_file.resolve()
def test_validate_import_url_rejects_http():
"""Remote imports should not downgrade to plaintext HTTP."""
with pytest.raises(ValueError, match="require https"):
_validate_import_url("http://example.com/instincts.yaml")
def test_validate_import_url_rejects_private_hosts(monkeypatch):
"""Remote imports should not resolve to private or loopback addresses."""
monkeypatch.setattr(
_mod.socket,
"getaddrinfo",
lambda *args, **kwargs: [(None, None, None, None, ("127.0.0.1", 443))],
)
with pytest.raises(ValueError, match="non-public address"):
_validate_import_url("https://example.com/instincts.yaml")
def test_validate_import_url_allows_public_https(monkeypatch):
monkeypatch.setattr(
_mod.socket,
"getaddrinfo",
lambda *args, **kwargs: [(None, None, None, None, ("93.184.216.34", 443))],
)
assert _validate_import_url("https://example.com/instincts.yaml") == "https://example.com/instincts.yaml"
# ─────────────────────────────────────────────
# detect_project tests
# ─────────────────────────────────────────────
def test_detect_project_global_fallback(patch_globals, monkeypatch):
"""When no git and no env var, should return global project."""
monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
# Mock subprocess.run to simulate git not available
def mock_run(*args, **kwargs):
raise FileNotFoundError("git not found")
monkeypatch.setattr("subprocess.run", mock_run)
project = detect_project()
assert project["id"] == "global"
assert project["name"] == "global"
def test_detect_project_from_env(patch_globals, monkeypatch, tmp_path):
"""CLAUDE_PROJECT_DIR env var should be used as project root."""
fake_repo = tmp_path / "my-repo"
fake_repo.mkdir()
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(fake_repo))
# Mock git remote to return a URL
def mock_run(cmd, **kwargs):
if "rev-parse" in cmd:
return SimpleNamespace(returncode=0, stdout=str(fake_repo) + "\n", stderr="")
if "get-url" in cmd:
return SimpleNamespace(returncode=0, stdout="https://github.com/test/my-repo.git\n", stderr="")
return SimpleNamespace(returncode=1, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", mock_run)
project = detect_project()
assert project["id"] != "global"
assert project["name"] == "my-repo"
def test_detect_project_git_timeout(patch_globals, monkeypatch):
"""Git timeout should fall through to global."""
monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
import subprocess as sp
def mock_run(cmd, **kwargs):
raise sp.TimeoutExpired(cmd, 5)
monkeypatch.setattr("subprocess.run", mock_run)
project = detect_project()
assert project["id"] == "global"
def test_detect_project_creates_directories(patch_globals, monkeypatch, tmp_path):
"""detect_project should create the project dir structure."""
fake_repo = tmp_path / "structured-repo"
fake_repo.mkdir()
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(fake_repo))
def mock_run(cmd, **kwargs):
if "rev-parse" in cmd:
return SimpleNamespace(returncode=0, stdout=str(fake_repo) + "\n", stderr="")
if "get-url" in cmd:
return SimpleNamespace(returncode=1, stdout="", stderr="no remote")
return SimpleNamespace(returncode=1, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", mock_run)
project = detect_project()
assert project["instincts_personal"].exists()
assert project["instincts_inherited"].exists()
assert (project["evolved_dir"] / "skills").exists()
# ─────────────────────────────────────────────
# _load_instincts_from_dir tests
# ─────────────────────────────────────────────
def test_load_from_empty_dir(tmp_path):
result = _load_instincts_from_dir(tmp_path, "personal", "project")
assert result == []
def test_load_from_nonexistent_dir(tmp_path):
result = _load_instincts_from_dir(tmp_path / "does-not-exist", "personal", "project")
assert result == []
def test_load_annotates_metadata(tmp_path):
"""Loaded instincts should have _source_file, _source_type, _scope_label."""
yaml_file = tmp_path / "test.yaml"
yaml_file.write_text(SAMPLE_INSTINCT_YAML)
result = _load_instincts_from_dir(tmp_path, "personal", "project")
assert len(result) == 1
assert result[0]["_source_file"] == str(yaml_file)
assert result[0]["_source_type"] == "personal"
assert result[0]["_scope_label"] == "project"
def test_load_defaults_scope_from_label(tmp_path):
"""If an instinct has no 'scope' in frontmatter, it should default to scope_label."""
no_scope_yaml = """\
---
id: no-scope
trigger: "test"
confidence: 0.5
domain: general
---
Body.
"""
(tmp_path / "no-scope.yaml").write_text(no_scope_yaml)
result = _load_instincts_from_dir(tmp_path, "inherited", "global")
assert result[0]["scope"] == "global"
def test_load_preserves_explicit_scope(tmp_path):
"""If frontmatter has explicit scope, it should be preserved."""
yaml_file = tmp_path / "test.yaml"
yaml_file.write_text(SAMPLE_INSTINCT_YAML)
result = _load_instincts_from_dir(tmp_path, "personal", "global")
# Frontmatter says scope: project, scope_label is global
# The explicit scope should be preserved (not overwritten)
assert result[0]["scope"] == "project"
def test_load_handles_corrupt_file(tmp_path, capsys):
"""Corrupt YAML files should be warned about but not crash."""
# A file that will cause parse_instinct_file to return empty
(tmp_path / "good.yaml").write_text(SAMPLE_INSTINCT_YAML)
(tmp_path / "bad.yaml").write_text("not yaml\nno frontmatter")
result = _load_instincts_from_dir(tmp_path, "personal", "project")
# bad.yaml has no valid instincts (no id), so only good.yaml contributes
assert len(result) == 1
assert result[0]["id"] == "test-instinct"
def test_load_supports_yml_extension(tmp_path):
yml_file = tmp_path / "test.yml"
yml_file.write_text(SAMPLE_INSTINCT_YAML)
result = _load_instincts_from_dir(tmp_path, "personal", "project")
ids = {i["id"] for i in result}
assert "test-instinct" in ids
def test_load_supports_md_extension(tmp_path):
md_file = tmp_path / "legacy-instinct.md"
md_file.write_text(SAMPLE_INSTINCT_YAML)
result = _load_instincts_from_dir(tmp_path, "personal", "project")
ids = {i["id"] for i in result}
assert "test-instinct" in ids
def test_load_instincts_from_dir_uses_utf8_encoding(tmp_path, monkeypatch):
yaml_file = tmp_path / "test.yaml"
yaml_file.write_text("placeholder")
calls = []
def fake_read_text(self, *args, **kwargs):
calls.append(kwargs.get("encoding"))
return SAMPLE_INSTINCT_YAML
monkeypatch.setattr(Path, "read_text", fake_read_text)
result = _load_instincts_from_dir(tmp_path, "personal", "project")
assert result[0]["id"] == "test-instinct"
assert calls == ["utf-8"]
# ─────────────────────────────────────────────
# load_all_instincts tests
# ─────────────────────────────────────────────
def test_load_all_project_and_global(patch_globals):
"""Should load from both project and global directories."""
tree = patch_globals
project = _make_project(tree)
# Write a project instinct
(project["instincts_personal"] / "proj.yaml").write_text(SAMPLE_INSTINCT_YAML)
# Write a global instinct
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
result = load_all_instincts(project)
ids = {i["id"] for i in result}
assert "test-instinct" in ids
assert "global-instinct" in ids
def test_load_all_project_overrides_global(patch_globals):
"""When project and global have same ID, project wins."""
tree = patch_globals
project = _make_project(tree)
# Same ID but different confidence
proj_yaml = SAMPLE_INSTINCT_YAML.replace("id: test-instinct", "id: shared-id")
proj_yaml = proj_yaml.replace("confidence: 0.8", "confidence: 0.9")
glob_yaml = SAMPLE_GLOBAL_INSTINCT_YAML.replace("id: global-instinct", "id: shared-id")
glob_yaml = glob_yaml.replace("confidence: 0.9", "confidence: 0.3")
(project["instincts_personal"] / "shared.yaml").write_text(proj_yaml)
(tree["global_personal"] / "shared.yaml").write_text(glob_yaml)
result = load_all_instincts(project)
shared = [i for i in result if i["id"] == "shared-id"]
assert len(shared) == 1
assert shared[0]["_scope_label"] == "project"
assert shared[0]["confidence"] == 0.9
def test_load_all_global_only(patch_globals):
"""Global project should only load global instincts."""
tree = patch_globals
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
global_project = {
"id": "global",
"name": "global",
"root": "",
"project_dir": tree["homunculus"],
"instincts_personal": tree["global_personal"],
"instincts_inherited": tree["global_inherited"],
"evolved_dir": tree["global_evolved"],
"observations_file": tree["homunculus"] / "observations.jsonl",
}
result = load_all_instincts(global_project)
assert len(result) == 1
assert result[0]["id"] == "global-instinct"
def test_load_project_only_excludes_global(patch_globals):
"""load_project_only_instincts should NOT include global instincts."""
tree = patch_globals
project = _make_project(tree)
(project["instincts_personal"] / "proj.yaml").write_text(SAMPLE_INSTINCT_YAML)
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
result = load_project_only_instincts(project)
ids = {i["id"] for i in result}
assert "test-instinct" in ids
assert "global-instinct" not in ids
def test_load_project_only_global_fallback_loads_global(patch_globals):
"""Global fallback should return global instincts for project-only queries."""
tree = patch_globals
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
global_project = {
"id": "global",
"name": "global",
"root": "",
"project_dir": tree["homunculus"],
"instincts_personal": tree["global_personal"],
"instincts_inherited": tree["global_inherited"],
"evolved_dir": tree["global_evolved"],
"observations_file": tree["homunculus"] / "observations.jsonl",
}
result = load_project_only_instincts(global_project)
assert len(result) == 1
assert result[0]["id"] == "global-instinct"
def test_load_all_empty(patch_globals):
"""No instincts at all should return empty list."""
tree = patch_globals
project = _make_project(tree)
result = load_all_instincts(project)
assert result == []
# ─────────────────────────────────────────────
# cmd_status tests
# ─────────────────────────────────────────────
def test_cmd_status_no_instincts(patch_globals, monkeypatch, capsys):
"""Status with no instincts should print fallback message."""
tree = patch_globals
project = _make_project(tree)
monkeypatch.setattr(_mod, "detect_project", lambda: project)
args = SimpleNamespace()
ret = cmd_status(args)
assert ret == 0
out = capsys.readouterr().out
assert "No instincts found." in out
def test_cmd_status_with_instincts(patch_globals, monkeypatch, capsys):
"""Status should show project and global instinct counts."""
tree = patch_globals
project = _make_project(tree)
monkeypatch.setattr(_mod, "detect_project", lambda: project)
(project["instincts_personal"] / "proj.yaml").write_text(SAMPLE_INSTINCT_YAML)
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
args = SimpleNamespace()
ret = cmd_status(args)
assert ret == 0
out = capsys.readouterr().out
assert "INSTINCT STATUS" in out
assert "Project instincts: 1" in out
assert "Global instincts: 1" in out
assert "PROJECT-SCOPED" in out
assert "GLOBAL" in out
def test_confidence_bar_uses_unicode_when_supported():
"""Confidence bars should retain block glyphs on UTF-8 streams."""
stream = SimpleNamespace(encoding="utf-8")
assert _confidence_bar(0.8, stream=stream) == "\u2588" * 8 + "\u2591" * 2
def test_confidence_bar_uses_ascii_when_stream_rejects_block_glyphs():
"""Windows cp1252 streams cannot encode block glyphs."""
stream = SimpleNamespace(encoding="cp1252")
assert _confidence_bar(0.8, stream=stream) == "########.."
def test_print_instincts_by_domain_is_cp1252_safe(monkeypatch):
"""Status rendering should not crash on Windows cp1252 stdout."""
raw = io.BytesIO()
stream = io.TextIOWrapper(raw, encoding="cp1252")
monkeypatch.setattr(_mod.sys, "stdout", stream)
_mod._print_instincts_by_domain([{
"id": "windows-safe",
"trigger": "when stdout uses cp1252",
"confidence": 0.8,
"domain": "platform",
"scope": "project",
}])
stream.flush()
out = raw.getvalue().decode("cp1252")
assert "########.." in out
assert "\u2588" not in out
assert "\u2591" not in out
def test_cmd_status_returns_int(patch_globals, monkeypatch):
"""cmd_status should always return an int."""
tree = patch_globals
project = _make_project(tree)
monkeypatch.setattr(_mod, "detect_project", lambda: project)
args = SimpleNamespace()
ret = cmd_status(args)
assert isinstance(ret, int)
# ─────────────────────────────────────────────
# cmd_projects tests
# ─────────────────────────────────────────────
def test_cmd_projects_empty_registry(patch_globals, capsys):
"""No projects should print helpful message."""
args = SimpleNamespace()
ret = cmd_projects(args)
assert ret == 0
out = capsys.readouterr().out
assert "No projects registered yet." in out
def test_cmd_projects_with_registry(patch_globals, capsys):
"""Should list projects from registry."""
tree = patch_globals
# Create a project dir with instincts
pid = "test123abc"
project = _make_project(tree, pid=pid, pname="my-app")
(project["instincts_personal"] / "inst.yaml").write_text(SAMPLE_INSTINCT_YAML)
# Write registry
registry = {
pid: {
"name": "my-app",
"root": "/home/user/my-app",
"remote": "https://github.com/user/my-app.git",
"last_seen": "2025-01-15T12:00:00Z",
}
}
tree["registry_file"].write_text(json.dumps(registry))
args = SimpleNamespace()
ret = cmd_projects(args)
assert ret == 0
out = capsys.readouterr().out
assert "my-app" in out
assert pid in out
assert "1 personal" in out
# ─────────────────────────────────────────────
# _promote_specific tests
# ─────────────────────────────────────────────
def test_promote_specific_not_found(patch_globals, capsys):
"""Promoting nonexistent instinct should fail."""
tree = patch_globals
project = _make_project(tree)
ret = _promote_specific(project, "nonexistent", force=True)
assert ret == 1
out = capsys.readouterr().out
assert "not found" in out
def test_promote_specific_rejects_invalid_id(patch_globals, capsys):
"""Path-like instinct IDs should be rejected before file writes."""
tree = patch_globals
project = _make_project(tree)
ret = _promote_specific(project, "../escape", force=True)
assert ret == 1
err = capsys.readouterr().err
assert "Invalid instinct ID" in err
def test_promote_specific_already_global(patch_globals, capsys):
"""Promoting an instinct that already exists globally should fail."""
tree = patch_globals
project = _make_project(tree)
# Write same-id instinct in both project and global
(project["instincts_personal"] / "shared.yaml").write_text(SAMPLE_INSTINCT_YAML)
global_yaml = SAMPLE_INSTINCT_YAML # same id: test-instinct
(tree["global_personal"] / "shared.yaml").write_text(global_yaml)
ret = _promote_specific(project, "test-instinct", force=True)
assert ret == 1
out = capsys.readouterr().out
assert "already exists in global" in out
def test_promote_specific_success(patch_globals, capsys):
"""Promote a project instinct to global with --force."""
tree = patch_globals
project = _make_project(tree)
(project["instincts_personal"] / "inst.yaml").write_text(SAMPLE_INSTINCT_YAML)
ret = _promote_specific(project, "test-instinct", force=True)
assert ret == 0
out = capsys.readouterr().out
assert "Promoted" in out
# Verify file was created in global dir
promoted_file = tree["global_personal"] / "test-instinct.yaml"
assert promoted_file.exists()
content = promoted_file.read_text()
assert "scope: global" in content
assert "promoted_from: abc123" in content
# ─────────────────────────────────────────────
# _promote_auto tests
# ─────────────────────────────────────────────
def test_promote_auto_no_candidates(patch_globals, capsys):
"""Auto-promote with no cross-project instincts should say so."""
tree = patch_globals
project = _make_project(tree)
# Empty registry
tree["registry_file"].write_text("{}")
ret = _promote_auto(project, force=True, dry_run=False)
assert ret == 0
out = capsys.readouterr().out
assert "No instincts qualify" in out
def test_promote_auto_dry_run(patch_globals, capsys):
"""Dry run should list candidates but not write files."""
tree = patch_globals
# Create two projects with the same high-confidence instinct
p1 = _make_project(tree, pid="proj1", pname="project-one")
p2 = _make_project(tree, pid="proj2", pname="project-two")
high_conf_yaml = """\
---
id: cross-project-instinct
trigger: "when reviewing"
confidence: 0.95
domain: security
scope: project
---
## Action
Always review for injection.
"""
(p1["instincts_personal"] / "cross.yaml").write_text(high_conf_yaml)
(p2["instincts_personal"] / "cross.yaml").write_text(high_conf_yaml)
# Write registry
registry = {
"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
"proj2": {"name": "project-two", "root": "/b", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
}
tree["registry_file"].write_text(json.dumps(registry))
project = p1
ret = _promote_auto(project, force=True, dry_run=True)
assert ret == 0
out = capsys.readouterr().out
assert "DRY RUN" in out
assert "cross-project-instinct" in out
# Verify no file was created
assert not (tree["global_personal"] / "cross-project-instinct.yaml").exists()
def test_promote_auto_writes_file(patch_globals, capsys):
"""Auto-promote with force should write global instinct file."""
tree = patch_globals
p1 = _make_project(tree, pid="proj1", pname="project-one")
p2 = _make_project(tree, pid="proj2", pname="project-two")
high_conf_yaml = """\
---
id: universal-pattern
trigger: "when coding"
confidence: 0.85
domain: general
scope: project
---
## Action
Use descriptive variable names.
"""
(p1["instincts_personal"] / "uni.yaml").write_text(high_conf_yaml)
(p2["instincts_personal"] / "uni.yaml").write_text(high_conf_yaml)
registry = {
"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
"proj2": {"name": "project-two", "root": "/b", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
}
tree["registry_file"].write_text(json.dumps(registry))
ret = _promote_auto(p1, force=True, dry_run=False)
assert ret == 0
promoted = tree["global_personal"] / "universal-pattern.yaml"
assert promoted.exists()
content = promoted.read_text()
assert "scope: global" in content
assert "auto-promoted" in content
def test_promote_auto_skips_invalid_id(patch_globals, capsys):
tree = patch_globals
p1 = _make_project(tree, pid="proj1", pname="project-one")
p2 = _make_project(tree, pid="proj2", pname="project-two")
bad_id_yaml = """\
---
id: ../escape
trigger: "when coding"
confidence: 0.9
domain: general
scope: project
---
## Action
Invalid id should be skipped.
"""
(p1["instincts_personal"] / "bad.yaml").write_text(bad_id_yaml)
(p2["instincts_personal"] / "bad.yaml").write_text(bad_id_yaml)
registry = {
"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
"proj2": {"name": "project-two", "root": "/b", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
}
tree["registry_file"].write_text(json.dumps(registry))
ret = _promote_auto(p1, force=True, dry_run=False)
assert ret == 0
err = capsys.readouterr().err
assert "Skipping invalid instinct ID" in err
assert not (tree["global_personal"] / "../escape.yaml").exists()
# ─────────────────────────────────────────────
# _find_cross_project_instincts tests
# ─────────────────────────────────────────────
def test_find_cross_project_empty_registry(patch_globals):
tree = patch_globals
tree["registry_file"].write_text("{}")
result = _find_cross_project_instincts()
assert result == {}
def test_find_cross_project_single_project(patch_globals):
"""Single project should return nothing (need 2+)."""
tree = patch_globals
p1 = _make_project(tree, pid="proj1", pname="project-one")
(p1["instincts_personal"] / "inst.yaml").write_text(SAMPLE_INSTINCT_YAML)
registry = {"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"}}
tree["registry_file"].write_text(json.dumps(registry))
result = _find_cross_project_instincts()
assert result == {}
def test_find_cross_project_shared_instinct(patch_globals):
"""Same instinct ID in 2 projects should be found."""
tree = patch_globals
p1 = _make_project(tree, pid="proj1", pname="project-one")
p2 = _make_project(tree, pid="proj2", pname="project-two")
(p1["instincts_personal"] / "shared.yaml").write_text(SAMPLE_INSTINCT_YAML)
(p2["instincts_personal"] / "shared.yaml").write_text(SAMPLE_INSTINCT_YAML)
registry = {
"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
"proj2": {"name": "project-two", "root": "/b", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
}
tree["registry_file"].write_text(json.dumps(registry))
result = _find_cross_project_instincts()
assert "test-instinct" in result
assert len(result["test-instinct"]) == 2
# ─────────────────────────────────────────────
# load_registry tests
# ─────────────────────────────────────────────
def test_load_registry_missing_file(patch_globals):
result = load_registry()
assert result == {}
def test_load_registry_corrupt_json(patch_globals):
tree = patch_globals
tree["registry_file"].write_text("not json at all {{{")
result = load_registry()
assert result == {}
def test_load_registry_valid(patch_globals):
tree = patch_globals
data = {"abc": {"name": "test", "root": "/test"}}
tree["registry_file"].write_text(json.dumps(data))
result = load_registry()
assert result == data
def test_load_registry_uses_utf8_encoding(monkeypatch):
calls = []
def fake_open(path, mode="r", *args, **kwargs):
calls.append(kwargs.get("encoding"))
return io.StringIO("{}")
monkeypatch.setattr(_mod, "open", fake_open, raising=False)
assert load_registry() == {}
assert calls == ["utf-8"]
def test_validate_instinct_id():
assert _validate_instinct_id("good-id_1.0")
assert not _validate_instinct_id("../bad")
assert not _validate_instinct_id("bad/name")
assert not _validate_instinct_id(".hidden")
def test_update_registry_atomic_replaces_file(patch_globals):
tree = patch_globals
_update_registry("abc123", "demo", "/repo", "https://example.com/repo.git")
data = json.loads(tree["registry_file"].read_text())
assert "abc123" in data
leftovers = list(tree["registry_file"].parent.glob(".projects.json.tmp.*"))
assert leftovers == []
Related skills
How it compares
Use continuous-learning-v2 for hook-driven instinct evolution inside Claude Code; use static skill authoring when behaviors are fixed and session observation is unnecessary.
FAQ
What changed in continuous-learning-v2 version 2.1?
continuous-learning-v2 version 2.1.0 adds project-scoped instincts so learned behaviors stay inside the project where they were observed. React patterns remain in React repos and Python conventions remain in Python repos, reducing cross-project contamination.
How does continuous-learning-v2 create reusable knowledge?
continuous-learning-v2 observes Claude Code sessions through hooks, records atomic instincts with confidence scoring, and evolves high-confidence instincts into skills, slash commands, and subagents over time.