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