feat: project-scoped instinct isolation

* feat: add project-scoped instinct isolation

* fix(continuous-learning-v2): harden instinct loading and promotion safety; sync v2.1 command docs

* fix(ci): make copilot-setup-steps a valid GitHub Actions workflow

* fix(hooks): stabilize docs warning inline JS regex parsing
This commit is contained in:
Harry Kwok
2026-03-02 04:07:13 +08:00
committed by GitHub
parent 2d3be88bb5
commit 5818e8adc7
26 changed files with 2476 additions and 713 deletions

View File

@@ -1,18 +1,30 @@
name: Copilot Setup Steps
on:
workflow_dispatch:
permissions:
contents: read
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Setup Go environment
uses: actions/setup-go@v6.2.0
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
# The Go version to download (if necessary) and use. Supports semver spec and ranges. Be sure to enclose this option in single quotation marks.
go-version: # optional
# Path to the go.mod, go.work, .go-version, or .tool-versions file.
go-version-file: # optional
# Set this option to true if you want the action to always check for the latest available version that satisfies the version spec
check-latest: # optional
# Used to pull Go distributions from go-versions. Since there's a default, this is typically not supplied by the user. When running this action on github.com, the default value is sufficient. When running on GHES, you can pass a personal access token for github.com if you are experiencing rate limiting.
token: # optional, default is ${{ github.server_url == 'https://github.com' && github.token || '' }}
# Used to specify whether caching is needed. Set to true, if you'd like to enable caching.
cache: # optional, default is true
# Used to specify the path to a dependency file - go.sum
cache-dependency-path: # optional
# Target architecture for Go to use. Examples: x86, x64. Will use system architecture by default.
architecture: # optional
node-version: "20"
cache: npm
- name: Install dependencies
run: npm ci
- name: Verify environment
run: |
node --version
npm --version
python3 --version

View File

@@ -258,6 +258,8 @@ After migration, ALL 23 commands are available:
| `/instinct-import` | Import instincts |
| `/instinct-export` | Export instincts |
| `/evolve` | Cluster instincts into skills |
| `/promote` | Promote project instincts to global scope |
| `/projects` | List known projects and instinct stats |
## Available Agents

View File

@@ -95,6 +95,8 @@ opencode
| `/instinct-import` | Import instincts |
| `/instinct-export` | Export instincts |
| `/evolve` | Cluster instincts |
| `/promote` | Promote project instincts |
| `/projects` | List known projects |
### Plugin Hooks

View File

@@ -1,112 +1,36 @@
---
description: Cluster instincts into skills
description: Analyze instincts and suggest or generate evolved structures
agent: build
---
# Evolve Command
Cluster related instincts into structured skills: $ARGUMENTS
Analyze and evolve instincts in continuous-learning-v2: $ARGUMENTS
## Your Task
Analyze instincts and promote clusters to skills.
Run:
## Evolution Process
### Step 1: Analyze Instincts
Group instincts by:
- Trigger similarity
- Action patterns
- Category tags
- Confidence levels
### Step 2: Identify Clusters
```
Cluster: Error Handling
├── Instinct: Catch specific errors (0.85)
├── Instinct: Wrap errors with context (0.82)
├── Instinct: Log errors with stack trace (0.78)
└── Instinct: Return meaningful error messages (0.80)
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" evolve $ARGUMENTS
```
### Step 3: Generate Skill
If `CLAUDE_PLUGIN_ROOT` is unavailable, use:
When cluster has:
- 3+ instincts
- Average confidence > 0.75
- Cohesive theme
Generate SKILL.md:
```markdown
# Error Handling Skill
## Overview
Patterns for robust error handling learned from session observations.
## Patterns
### 1. Catch Specific Errors
**Trigger**: When catching errors with generic catch
**Action**: Use specific error types
### 2. Wrap Errors with Context
**Trigger**: When re-throwing errors
**Action**: Add context with fmt.Errorf or Error.cause
### 3. Log with Stack Trace
**Trigger**: When logging errors
**Action**: Include stack trace for debugging
### 4. Meaningful Messages
**Trigger**: When returning errors to users
**Action**: Provide actionable error messages
```bash
python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py evolve $ARGUMENTS
```
### Step 4: Archive Instincts
## Supported Args (v2.1)
Move evolved instincts to `archived/` with reference to skill.
- no args: analysis only
- `--generate`: also generate files under `evolved/{skills,commands,agents}`
## Evolution Report
## Behavior Notes
```
Evolution Summary
=================
Clusters Found: X
Cluster 1: Error Handling
- Instincts: 5
- Avg Confidence: 0.82
- Status: ✅ Promoted to skill
Cluster 2: Testing Patterns
- Instincts: 3
- Avg Confidence: 0.71
- Status: ⏳ Needs more confidence
Cluster 3: Git Workflow
- Instincts: 2
- Avg Confidence: 0.88
- Status: ⏳ Needs more instincts
Skills Created:
- skills/error-handling/SKILL.md
Instincts Archived: 5
Remaining Instincts: 12
```
## Thresholds
| Metric | Threshold |
|--------|-----------|
| Min instincts per cluster | 3 |
| Min average confidence | 0.75 |
| Min cluster cohesion | 0.6 |
---
**TIP**: Run `/evolve` periodically to graduate instincts to skills as confidence grows.
- Uses project + global instincts for analysis.
- Shows skill/command/agent candidates from trigger and domain clustering.
- Shows project -> global promotion candidates.
- With `--generate`, output path is:
- project context: `~/.claude/homunculus/projects/<project-id>/evolved/`
- global fallback: `~/.claude/homunculus/evolved/`

View File

@@ -1,75 +1,29 @@
---
description: View learned instincts with confidence scores
description: Show learned instincts (project + global) with confidence
agent: build
---
# Instinct Status Command
Display learned instincts and their confidence scores: $ARGUMENTS
Show instinct status from continuous-learning-v2: $ARGUMENTS
## Your Task
Read and display instincts from the continuous-learning-v2 system.
Run:
## Instinct Location
Global: `~/.claude/instincts/`
Project: `.claude/instincts/`
## Status Display
### Instinct Summary
| Category | Count | Avg Confidence |
|----------|-------|----------------|
| Coding | X | 0.XX |
| Testing | X | 0.XX |
| Security | X | 0.XX |
| Git | X | 0.XX |
### High Confidence Instincts (>0.8)
```
[trigger] → [action] (confidence: 0.XX)
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" status
```
### Learning Progress
If `CLAUDE_PLUGIN_ROOT` is unavailable, use:
- Total instincts: X
- This session: X
- Promoted to skills: X
### Recent Instincts
Last 5 instincts learned:
1. **[timestamp]** - [trigger] → [action]
2. **[timestamp]** - [trigger] → [action]
...
## Instinct Structure
```json
{
"id": "instinct-123",
"trigger": "When I see a try-catch without specific error type",
"action": "Suggest using specific error types for better handling",
"confidence": 0.75,
"applications": 5,
"successes": 4,
"source": "session-observation",
"timestamp": "2025-01-15T10:30:00Z"
}
```bash
python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py status
```
## Confidence Calculation
## Behavior Notes
```
confidence = (successes + 1) / (applications + 2)
```
Bayesian smoothing ensures new instincts don't have extreme confidence.
---
**TIP**: Use `/evolve` to cluster related instincts into skills when confidence is high.
- Output includes both project-scoped and global instincts.
- Project instincts override global instincts when IDs conflict.
- Output is grouped by domain with confidence bars.
- This command does not support extra filters in v2.1.

View File

@@ -0,0 +1,23 @@
---
description: List registered projects and instinct counts
agent: build
---
# Projects Command
Show continuous-learning-v2 project registry and stats: $ARGUMENTS
## Your Task
Run:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" projects
```
If `CLAUDE_PLUGIN_ROOT` is unavailable, use:
```bash
python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py projects
```

View File

@@ -0,0 +1,23 @@
---
description: Promote project instincts to global scope
agent: build
---
# Promote Command
Promote instincts in continuous-learning-v2: $ARGUMENTS
## Your Task
Run:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" promote $ARGUMENTS
```
If `CLAUDE_PLUGIN_ROOT` is unavailable, use:
```bash
python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py promote $ARGUMENTS
```

View File

@@ -303,6 +303,14 @@
"evolve": {
"description": "Cluster instincts into skills",
"template": "{file:.opencode/commands/evolve.md}\n\n$ARGUMENTS"
},
"promote": {
"description": "Promote project instincts to global scope",
"template": "{file:.opencode/commands/promote.md}\n\n$ARGUMENTS"
},
"projects": {
"description": "List known projects and instinct stats",
"template": "{file:.opencode/commands/projects.md}\n\n$ARGUMENTS"
}
},
"permission": {

View File

@@ -985,6 +985,8 @@ OpenCode's plugin system is MORE sophisticated than Claude Code with 20+ event t
| `/instinct-import` | Import instincts |
| `/instinct-export` | Export instincts |
| `/evolve` | Cluster instincts into skills |
| `/promote` | Promote project instincts to global scope |
| `/projects` | List known projects and instinct stats |
| `/learn-eval` | Extract and evaluate patterns before saving |
| `/setup-pm` | Configure package manager |

View File

@@ -287,6 +287,8 @@ everything-claude-code/
/instinct-import <file> # 从他人导入直觉
/instinct-export # 导出你的直觉以供分享
/evolve # 将相关直觉聚类到技能中
/promote # 将项目级直觉提升为全局直觉
/projects # 查看已识别项目与直觉统计
```
完整文档见 `skills/continuous-learning-v2/`

View File

@@ -1,6 +1,6 @@
---
name: evolve
description: Cluster related instincts into skills, commands, or agents
description: Analyze instincts and suggest or generate evolved structures
command: true
---
@@ -29,9 +29,7 @@ Analyzes instincts and clusters related ones into higher-level structures:
```
/evolve # Analyze all instincts and suggest evolutions
/evolve --domain testing # Only evolve instincts in testing domain
/evolve --dry-run # Show what would be created without creating
/evolve --threshold 5 # Require 5+ related instincts to cluster
/evolve --generate # Also generate files under evolved/{skills,commands,agents}
```
## Evolution Rules
@@ -78,63 +76,50 @@ Example:
## What to Do
1. Read all instincts from `~/.claude/homunculus/instincts/`
2. Group instincts by:
- Domain similarity
- Trigger pattern overlap
- Action sequence relationship
3. For each cluster of 3+ related instincts:
- Determine evolution type (command/skill/agent)
- Generate the appropriate file
- Save to `~/.claude/homunculus/evolved/{commands,skills,agents}/`
4. Link evolved structure back to source instincts
1. Detect current project context
2. Read project + global instincts (project takes precedence on ID conflicts)
3. Group instincts by trigger/domain patterns
4. Identify:
- Skill candidates (trigger clusters with 2+ instincts)
- Command candidates (high-confidence workflow instincts)
- Agent candidates (larger, high-confidence clusters)
5. Show promotion candidates (project -> global) when applicable
6. If `--generate` is passed, write files to:
- Project scope: `~/.claude/homunculus/projects/<project-id>/evolved/`
- Global fallback: `~/.claude/homunculus/evolved/`
## Output Format
```
🧬 Evolve Analysis
==================
============================================================
EVOLVE ANALYSIS - 12 instincts
Project: my-app (a1b2c3d4e5f6)
Project-scoped: 8 | Global: 4
============================================================
Found 3 clusters ready for evolution:
High confidence instincts (>=80%): 5
## Cluster 1: Database Migration Workflow
Instincts: new-table-migration, update-schema, regenerate-types
Type: Command
Confidence: 85% (based on 12 observations)
## SKILL CANDIDATES
1. Cluster: "adding tests"
Instincts: 3
Avg confidence: 82%
Domains: testing
Scopes: project
Would create: /new-table command
Files:
- ~/.claude/homunculus/evolved/commands/new-table.md
## COMMAND CANDIDATES (2)
/adding-tests
From: test-first-workflow [project]
Confidence: 84%
## Cluster 2: Functional Code Style
Instincts: prefer-functional, use-immutable, avoid-classes, pure-functions
Type: Skill
Confidence: 78% (based on 8 observations)
Would create: functional-patterns skill
Files:
- ~/.claude/homunculus/evolved/skills/functional-patterns.md
## Cluster 3: Debugging Process
Instincts: debug-check-logs, debug-isolate, debug-reproduce, debug-verify
Type: Agent
Confidence: 72% (based on 6 observations)
Would create: debugger agent
Files:
- ~/.claude/homunculus/evolved/agents/debugger.md
---
Run `/evolve --execute` to create these files.
## AGENT CANDIDATES (1)
adding-tests-agent
Covers 3 instincts
Avg confidence: 82%
```
## Flags
- `--execute`: Actually create the evolved structures (default is preview)
- `--dry-run`: Preview without creating
- `--domain <name>`: Only evolve instincts in specified domain
- `--threshold <n>`: Minimum instincts required to form cluster (default: 3)
- `--type <command|skill|agent>`: Only create specified type
- `--generate`: Generate evolved files in addition to analysis output
## Generated File Format

View File

@@ -1,6 +1,6 @@
---
name: instinct-export
description: Export instincts for sharing with teammates or other projects
description: Export instincts from project/global scope to a file
command: /instinct-export
---
@@ -18,17 +18,18 @@ Exports instincts to a shareable format. Perfect for:
/instinct-export --domain testing # Export only testing instincts
/instinct-export --min-confidence 0.7 # Only export high-confidence instincts
/instinct-export --output team-instincts.yaml
/instinct-export --scope project --output project-instincts.yaml
```
## What to Do
1. Read instincts from `~/.claude/homunculus/instincts/personal/`
2. Filter based on flags
3. Strip sensitive information:
- Remove session IDs
- Remove file paths (keep only patterns)
- Remove timestamps older than "last week"
4. Generate export file
1. Detect current project context
2. Load instincts by selected scope:
- `project`: current project only
- `global`: global only
- `all`: project + global merged (default)
3. Apply filters (`--domain`, `--min-confidence`)
4. Write YAML-style export to file (or stdout if no output path provided)
## Output Format
@@ -40,52 +41,26 @@ Creates a YAML file:
# Source: personal
# Count: 12 instincts
version: "2.0"
exported_by: "continuous-learning-v2"
export_date: "2025-01-22T10:30:00Z"
instincts:
- id: prefer-functional-style
---
id: prefer-functional-style
trigger: "when writing new functions"
action: "Use functional patterns over classes"
confidence: 0.8
domain: code-style
observations: 8
source: session-observation
scope: project
project_id: a1b2c3d4e5f6
project_name: my-app
---
- id: test-first-workflow
trigger: "when adding new functionality"
action: "Write test first, then implementation"
confidence: 0.9
domain: testing
observations: 12
# Prefer Functional Style
- id: grep-before-edit
trigger: "when modifying code"
action: "Search with Grep, confirm with Read, then Edit"
confidence: 0.7
domain: workflow
observations: 6
## Action
Use functional patterns over classes.
```
## Privacy Considerations
Exports include:
- ✅ Trigger patterns
- ✅ Actions
- ✅ Confidence scores
- ✅ Domains
- ✅ Observation counts
Exports do NOT include:
- ❌ Actual code snippets
- ❌ File paths
- ❌ Session transcripts
- ❌ Personal identifiers
## Flags
- `--domain <name>`: Export only specified domain
- `--min-confidence <n>`: Minimum confidence threshold (default: 0.3)
- `--output <file>`: Output file path (default: instincts-export-YYYYMMDD.yaml)
- `--format <yaml|json|md>`: Output format (default: yaml)
- `--include-evidence`: Include evidence text (default: excluded)
- `--min-confidence <n>`: Minimum confidence threshold
- `--output <file>`: Output file path (prints to stdout when omitted)
- `--scope <project|global|all>`: Export scope (default: `all`)

View File

@@ -1,6 +1,6 @@
---
name: instinct-import
description: Import instincts from teammates, Skill Creator, or other sources
description: Import instincts from file or URL into project/global scope
command: true
---
@@ -11,7 +11,7 @@ command: true
Run the instinct CLI using the plugin root path:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" import <file-or-url> [--dry-run] [--force] [--min-confidence 0.7]
python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" import <file-or-url> [--dry-run] [--force] [--min-confidence 0.7] [--scope project|global]
```
Or if `CLAUDE_PLUGIN_ROOT` is not set (manual installation):
@@ -20,18 +20,15 @@ Or if `CLAUDE_PLUGIN_ROOT` is not set (manual installation):
python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py import <file-or-url>
```
Import instincts from:
- Teammates' exports
- Skill Creator (repo analysis)
- Community collections
- Previous machine backups
Import instincts from local file paths or HTTP(S) URLs.
## Usage
```
/instinct-import team-instincts.yaml
/instinct-import https://github.com/org/repo/instincts.yaml
/instinct-import --from-skill-creator acme/webapp
/instinct-import team-instincts.yaml --dry-run
/instinct-import team-instincts.yaml --scope global --force
```
## What to Do
@@ -40,7 +37,9 @@ Import instincts from:
2. Parse and validate the format
3. Check for duplicates with existing instincts
4. Merge or add new instincts
5. Save to `~/.claude/homunculus/instincts/inherited/`
5. Save to inherited instincts directory:
- Project scope: `~/.claude/homunculus/projects/<project-id>/instincts/inherited/`
- Global scope: `~/.claude/homunculus/instincts/inherited/`
## Import Process
@@ -71,60 +70,33 @@ Already have similar instincts:
Import: 0.9 confidence
→ Update to import (higher confidence)
## Conflicting Instincts (1)
These contradict local instincts:
❌ use-classes-for-services
Conflicts with: avoid-classes
→ Skip (requires manual resolution)
---
Import 8 new, update 1, skip 3?
Import 8 new, update 1?
```
## Merge Strategies
## Merge Behavior
### For Duplicates
When importing an instinct that matches an existing one:
- **Higher confidence wins**: Keep the one with higher confidence
- **Merge evidence**: Combine observation counts
- **Update timestamp**: Mark as recently validated
### For Conflicts
When importing an instinct that contradicts an existing one:
- **Skip by default**: Don't import conflicting instincts
- **Flag for review**: Mark both as needing attention
- **Manual resolution**: User decides which to keep
When importing an instinct with an existing ID:
- Higher-confidence import becomes an update candidate
- Equal/lower-confidence import is skipped
- User confirms unless `--force` is used
## Source Tracking
Imported instincts are marked with:
```yaml
source: "inherited"
source: inherited
scope: project
imported_from: "team-instincts.yaml"
imported_at: "2025-01-22T10:30:00Z"
original_source: "session-observation" # or "repo-analysis"
project_id: "a1b2c3d4e5f6"
project_name: "my-project"
```
## Skill Creator Integration
When importing from Skill Creator:
```
/instinct-import --from-skill-creator acme/webapp
```
This fetches instincts generated from repo analysis:
- Source: `repo-analysis`
- Higher initial confidence (0.7+)
- Linked to source repository
## Flags
- `--dry-run`: Preview without importing
- `--force`: Import even if conflicts exist
- `--merge-strategy <higher|local|import>`: How to handle duplicates
- `--from-skill-creator <owner/repo>`: Import from Skill Creator analysis
- `--force`: Skip confirmation prompt
- `--min-confidence <n>`: Only import instincts above threshold
- `--scope <project|global>`: Select target scope (default: `project`)
## Output
@@ -134,7 +106,7 @@ After import:
Added: 8 instincts
Updated: 1 instinct
Skipped: 3 instincts (2 duplicates, 1 conflict)
Skipped: 3 instincts (equal/higher confidence already exists)
New instincts saved to: ~/.claude/homunculus/instincts/inherited/

View File

@@ -1,12 +1,12 @@
---
name: instinct-status
description: Show all learned instincts with their confidence levels
description: Show learned instincts (project + global) with confidence
command: true
---
# Instinct Status Command
Shows all learned instincts with their confidence scores, grouped by domain.
Shows learned instincts for the current project plus global instincts, grouped by domain.
## Implementation
@@ -26,61 +26,34 @@ python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py status
```
/instinct-status
/instinct-status --domain code-style
/instinct-status --low-confidence
```
## What to Do
1. Read all instinct files from `~/.claude/homunculus/instincts/personal/`
2. Read inherited instincts from `~/.claude/homunculus/instincts/inherited/`
3. Display them grouped by domain with confidence bars
1. Detect current project context (git remote/path hash)
2. Read project instincts from `~/.claude/homunculus/projects/<project-id>/instincts/`
3. Read global instincts from `~/.claude/homunculus/instincts/`
4. Merge with precedence rules (project overrides global when IDs collide)
5. Display grouped by domain with confidence bars and observation stats
## Output Format
```
📊 Instinct Status
==================
============================================================
INSTINCT STATUS - 12 total
============================================================
## Code Style (4 instincts)
Project: my-app (a1b2c3d4e5f6)
Project instincts: 8
Global instincts: 4
### prefer-functional-style
Trigger: when writing new functions
Action: Use functional patterns over classes
Confidence: ████████░░ 80%
Source: session-observation | Last updated: 2025-01-22
## PROJECT-SCOPED (my-app)
### WORKFLOW (3)
███████░░░ 70% grep-before-edit [project]
trigger: when modifying code
### use-path-aliases
Trigger: when importing modules
Action: Use @/ path aliases instead of relative imports
Confidence: ██████░░░░ 60%
Source: repo-analysis (github.com/acme/webapp)
## Testing (2 instincts)
### test-first-workflow
Trigger: when adding new functionality
Action: Write test first, then implementation
Confidence: █████████░ 90%
Source: session-observation
## Workflow (3 instincts)
### grep-before-edit
Trigger: when modifying code
Action: Search with Grep, confirm with Read, then Edit
Confidence: ███████░░░ 70%
Source: session-observation
---
Total: 9 instincts (4 personal, 5 inherited)
Observer: Running (last analysis: 5 min ago)
## GLOBAL (apply to all projects)
### SECURITY (2)
█████████░ 85% validate-user-input [global]
trigger: when handling user input
```
## Flags
- `--domain <name>`: Filter by domain (code-style, testing, git, etc.)
- `--low-confidence`: Show only instincts with confidence < 0.5
- `--high-confidence`: Show only instincts with confidence >= 0.7
- `--source <type>`: Filter by source (session-observation, repo-analysis, inherited)
- `--json`: Output as JSON for programmatic use

40
commands/projects.md Normal file
View File

@@ -0,0 +1,40 @@
---
name: projects
description: List known projects and their instinct statistics
command: true
---
# Projects Command
List project registry entries and per-project instinct/observation counts for continuous-learning-v2.
## Implementation
Run the instinct CLI using the plugin root path:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" projects
```
Or if `CLAUDE_PLUGIN_ROOT` is not set (manual installation):
```bash
python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py projects
```
## Usage
```bash
/projects
```
## What to Do
1. Read `~/.claude/homunculus/projects.json`
2. For each project, display:
- Project name, id, root, remote
- Personal and inherited instinct counts
- Observation event count
- Last seen timestamp
3. Also display global instinct totals

42
commands/promote.md Normal file
View File

@@ -0,0 +1,42 @@
---
name: promote
description: Promote project-scoped instincts to global scope
command: true
---
# Promote Command
Promote instincts from project scope to global scope in continuous-learning-v2.
## Implementation
Run the instinct CLI using the plugin root path:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" promote [instinct-id] [--force] [--dry-run]
```
Or if `CLAUDE_PLUGIN_ROOT` is not set (manual installation):
```bash
python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py promote [instinct-id] [--force] [--dry-run]
```
## Usage
```bash
/promote # Auto-detect promotion candidates
/promote --dry-run # Preview auto-promotion candidates
/promote --force # Promote all qualified candidates without prompt
/promote grep-before-edit # Promote one specific instinct from current project
```
## What to Do
1. Detect current project
2. If `instinct-id` is provided, promote only that instinct (if present in current project)
3. Otherwise, find cross-project candidates that:
- Appear in at least 2 projects
- Meet confidence threshold
4. Write promoted instincts to `~/.claude/homunculus/instincts/personal/` with `scope: global`

View File

@@ -37,7 +37,7 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/pre-write-doc-warn.js\""
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const i=JSON.parse(d);const p=i.tool_input?.file_path||'';if(/\\.(md|txt)$/.test(p)&&!/(README|CLAUDE|AGENTS|CONTRIBUTING|CHANGELOG|LICENSE|SKILL)\\.md$/i.test(p)&&!/\\.claude[\\/\\\\]plans[\\/\\\\]/.test(p)&&!/(^|[\\/\\\\])(docs|skills)[\\/\\\\]/.test(p)){console.error('[Hook] WARNING: Non-standard documentation file detected');console.error('[Hook] File: '+p);console.error('[Hook] Consider consolidating into README.md or docs/ directory')}}catch{}console.log(d)})\""
}
],
"description": "Doc file warning: warn about non-standard documentation files (exit code 0; warns only)"
@@ -51,6 +51,18 @@
}
],
"description": "Suggest manual compaction at logical intervals"
},
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh",
"async": true,
"timeout": 10
}
],
"description": "Capture tool use observations for continuous learning"
}
],
"PreCompact": [
@@ -129,6 +141,18 @@
}
],
"description": "Warn about console.log statements after edits"
},
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh",
"async": true,
"timeout": 10
}
],
"description": "Capture tool use results for continuous learning"
}
],
"Stop": [

View File

@@ -1,15 +1,16 @@
---
name: continuous-learning-v2
description: Instinct-based learning system that observes sessions via hooks, creates atomic instincts with confidence scoring, and evolves them into skills/commands/agents.
description: Instinct-based learning system that observes sessions via hooks, creates atomic instincts with confidence scoring, and evolves them into skills/commands/agents. v2.1 adds project-scoped instincts to prevent cross-project contamination.
origin: ECC
version: 2.0.0
version: 2.1.0
---
# Continuous Learning v2 - Instinct-Based Architecture
# 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.
Inspired in part by the Homunculus work from [humanplane](https://github.com/humanplane).
**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
@@ -18,8 +19,21 @@ Inspired in part by the Homunculus work from [humanplane](https://github.com/hum
- 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
## What's New in v2.1
| Feature | v2.0 | v2.1 |
|---------|------|------|
| Storage | Global (~/.claude/homunculus/) | Project-scoped (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 |
|---------|----|----|
@@ -27,7 +41,7 @@ Inspired in part by the Homunculus work from [humanplane](https://github.com/hum
| 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 |
| Evolution | Direct to skill | Instincts -> cluster -> skill/command/agent |
| Sharing | None | Export/import instincts |
## The Instinct Model
@@ -41,6 +55,9 @@ 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
@@ -54,51 +71,69 @@ Use functional patterns over classes when appropriate.
```
**Properties:**
- **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
- **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) or `global`
## How It Works
```
Session Activity
Hooks capture prompts + tool use (100% reliable)
┌─────────────────────────────────────────┐
│ observations.jsonl │
(prompts, tool calls, outcomes)
└─────────────────────────────────────────┘
│ Observer agent reads (background, Haiku)
┌─────────────────────────────────────────┐
│ PATTERN DETECTION │
│ • User corrections → instinct
• Error resolutions instinct
• Repeated workflows → instinct
└─────────────────────────────────────────┘
│ Creates/updates
┌─────────────────────────────────────────┐
instincts/personal/ │
│ • prefer-functional.md (0.7) │
│ • always-test-first.md (0.9) │
• use-zod-validation.md (0.6)
└─────────────────────────────────────────┘
│ /evolve clusters
┌─────────────────────────────────────────┐
│ evolved/ │
• commands/new-feature.md │
│ • skills/testing-workflow.md │
• agents/refactor-specialist.md │
└─────────────────────────────────────────┘
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 `~/.claude/homunculus/projects.json` maps IDs to human-readable names.
## Quick Start
### 1. Enable Observation Hooks
@@ -114,14 +149,14 @@ Add to your `~/.claude/settings.json`.
"matcher": "*",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh pre"
"command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh"
}]
}],
"PostToolUse": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh post"
"command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh"
}]
}]
}
@@ -137,14 +172,14 @@ Add to your `~/.claude/settings.json`.
"matcher": "*",
"hooks": [{
"type": "command",
"command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh pre"
"command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh"
}]
}],
"PostToolUse": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh post"
"command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh"
}]
}]
}
@@ -153,92 +188,124 @@ Add to your `~/.claude/settings.json`.
### 2. Initialize Directory Structure
The Python CLI will create these automatically, but you can also create them manually:
The system creates directories automatically on first use, but you can also create them manually:
```bash
mkdir -p ~/.claude/homunculus/{instincts/{personal,inherited},evolved/{agents,skills,commands}}
touch ~/.claude/homunculus/observations.jsonl
# Global directories
mkdir -p ~/.claude/homunculus/{instincts/{personal,inherited},evolved/{agents,skills,commands},projects}
# Project directories are auto-created when the hook first runs in a git repo
```
### 3. Use the Instinct Commands
```bash
/instinct-status # Show learned instincts with confidence scores
/instinct-status # Show learned instincts (project + global)
/evolve # Cluster related instincts into skills/commands
/instinct-export # Export instincts for sharing
/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 counts
```
## Commands
| Command | Description |
|---------|-------------|
| `/instinct-status` | Show all learned instincts with confidence |
| `/evolve` | Cluster related instincts into skills/commands |
| `/instinct-export` | Export instincts for sharing |
| `/instinct-import <file>` | Import instincts from others |
| `/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`:
Edit `config.json` to control the background observer:
```json
{
"version": "2.0",
"observation": {
"enabled": true,
"store_path": "~/.claude/homunculus/observations.jsonl",
"max_file_size_mb": 10,
"archive_after_days": 7
},
"instincts": {
"personal_path": "~/.claude/homunculus/instincts/personal/",
"inherited_path": "~/.claude/homunculus/instincts/inherited/",
"min_confidence": 0.3,
"auto_approve_threshold": 0.7,
"confidence_decay_rate": 0.05
},
"version": "2.1",
"observer": {
"enabled": true,
"model": "haiku",
"enabled": false,
"run_interval_minutes": 5,
"patterns_to_detect": [
"user_corrections",
"error_resolutions",
"repeated_workflows",
"tool_preferences"
]
},
"evolution": {
"cluster_threshold": 3,
"evolved_path": "~/.claude/homunculus/evolved/"
"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
```
~/.claude/homunculus/
├── identity.json # Your profile, technical level
├── observations.jsonl # Current session observations
├── observations.archive/ # Processed observations
├── instincts/
├── personal/ # Auto-learned instincts
└── inherited/ # Imported from others
└── evolved/
├── agents/ # Generated specialist agents
├── skills/ # Generated skills
└── commands/ # Generated commands
+-- 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)
| +-- observations.jsonl
| +-- observations.archive/
| +-- instincts/
| | +-- personal/ # Project-specific auto-learned
| | +-- inherited/ # Project-specific imported
| +-- evolved/
| +-- skills/
| +-- commands/
| +-- agents/
+-- f6e5d4c3b2a1/ # Another project
+-- ...
```
## Integration with Skill Creator
## Scope Decision Guide
When you use the [Skill Creator GitHub App](https://skill-creator.app), it now generates **both**:
- Traditional SKILL.md files (for backward compatibility)
- Instinct collections (for v2 learning system)
| 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" |
Instincts from repo analysis have `source: "repo-analysis"` and include the source repository URL.
## 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:**
```bash
# 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-run
```
The `/evolve` command also suggests promotion candidates.
## Confidence Scoring
@@ -263,7 +330,7 @@ Confidence evolves over time:
## Why Hooks vs Skills for Observation?
> "v1 relied on skills to observe. Skills are probabilisticthey fire ~50-80% of the time based on Claude's judgment."
> "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
@@ -272,17 +339,19 @@ Hooks fire **100% of the time**, deterministically. This means:
## Backward Compatibility
v2 is fully compatible with v1:
- Existing `~/.claude/skills/learned/` skills still work
v2.1 is fully compatible with v2.0 and v1:
- Existing global instincts in `~/.claude/homunculus/instincts/` still work as global instincts
- Existing `~/.claude/skills/learned/` skills from v1 still work
- Stop hook still runs (but now also feeds into v2)
- Gradual migration path: run both in parallel
- Gradual migration: run both in parallel
## Privacy
- Observations stay **local** on your machine
- Only **instincts** (patterns) can be exported
- 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
- You control what gets exported and promoted
## Related
@@ -292,4 +361,4 @@ v2 is fully compatible with v1:
---
*Instinct-based learning: teaching Claude your patterns, one observation at a time.*
*Instinct-based learning: teaching Claude your patterns, one project at a time.*

View File

@@ -1,8 +1,7 @@
---
name: observer
description: Background agent that analyzes session observations to detect patterns and create instincts. Uses Haiku for cost-efficiency.
description: Background agent that analyzes session observations to detect patterns and create instincts. Uses Haiku for cost-efficiency. v2.1 adds project-scoped instincts.
model: haiku
run_mode: background
---
# Observer Agent
@@ -11,20 +10,21 @@ A background agent that analyzes observations from Claude Code sessions to detec
## When to Run
- After significant session activity (20+ tool calls)
- When user runs `/analyze-patterns`
- After enough observations accumulate (configurable, default 20)
- On a scheduled interval (configurable, default 5 minutes)
- When triggered by observation hook (SIGUSR1)
- When triggered on demand via SIGUSR1 to the observer process
## Input
Reads observations from `~/.claude/homunculus/observations.jsonl`:
Reads observations from the **project-scoped** observations file:
- Project: `~/.claude/homunculus/projects/<project-hash>/observations.jsonl`
- Global fallback: `~/.claude/homunculus/observations.jsonl`
```jsonl
{"timestamp":"2025-01-22T10:30:00Z","event":"tool_start","session":"abc123","tool":"Edit","input":"..."}
{"timestamp":"2025-01-22T10:30:01Z","event":"tool_complete","session":"abc123","tool":"Edit","output":"..."}
{"timestamp":"2025-01-22T10:30:05Z","event":"tool_start","session":"abc123","tool":"Bash","input":"npm test"}
{"timestamp":"2025-01-22T10:30:10Z","event":"tool_complete","session":"abc123","tool":"Bash","output":"All tests pass"}
{"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
@@ -65,28 +65,75 @@ When certain tools are consistently preferred:
## Output
Creates/updates instincts in `~/.claude/homunculus/instincts/personal/`:
Creates/updates instincts in the **project-scoped** instincts directory:
- Project: `~/.claude/homunculus/projects/<project-hash>/instincts/personal/`
- Global: `~/.claude/homunculus/instincts/personal/` (for universal patterns)
### Project-Scoped Instinct (default)
```yaml
---
id: prefer-grep-before-edit
trigger: "when searching for code to modify"
id: use-react-hooks-pattern
trigger: "when creating React components"
confidence: 0.65
domain: "workflow"
domain: "code-style"
source: "session-observation"
scope: project
project_id: "a1b2c3d4e5f6"
project_name: "my-react-app"
---
# Prefer Grep Before Edit
# Use React Hooks Pattern
## Action
Always use Grep to find the exact location before using Edit.
Always use functional components with hooks instead of class components.
## Evidence
- Observed 8 times in session abc123
- Pattern: Grep → Read → Edit sequence
- Pattern: All new components use useState/useEffect
- Last observed: 2025-01-22
```
### Global Instinct (universal patterns)
```yaml
---
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-22
```
## Scope 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:
@@ -100,6 +147,15 @@ Confidence adjusts over time:
- -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)
@@ -107,31 +163,36 @@ Confidence adjusts over time:
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:
```jsonl
{"event":"tool_start","tool":"Grep","input":"pattern: useState"}
{"event":"tool_complete","tool":"Grep","output":"Found in 3 files"}
{"event":"tool_start","tool":"Read","input":"src/hooks/useAuth.ts"}
{"event":"tool_complete","tool":"Read","output":"[file content]"}
{"event":"tool_start","tool":"Edit","input":"src/hooks/useAuth.ts..."}
{"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+).

View File

@@ -4,26 +4,79 @@
# 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 in background
# start-observer.sh # Start observer for current project (or global)
# start-observer.sh stop # Stop running observer
# start-observer.sh status # Check if observer is running
set -e
CONFIG_DIR="${HOME}/.claude/homunculus"
PID_FILE="${CONFIG_DIR}/.observer.pid"
LOG_FILE="${CONFIG_DIR}/observer.log"
OBSERVATIONS_FILE="${CONFIG_DIR}/observations.jsonl"
# ─────────────────────────────────────────────
# Project detection
# ─────────────────────────────────────────────
mkdir -p "$CONFIG_DIR"
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"
# ─────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────
CONFIG_DIR="${HOME}/.claude/homunculus"
CONFIG_FILE="${SKILL_ROOT}/config.json"
# 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"
# Read config values from config.json
OBSERVER_INTERVAL_MINUTES=5
MIN_OBSERVATIONS=20
OBSERVER_ENABLED=false
if [ -f "$CONFIG_FILE" ]; then
_config=$(CLV2_CONFIG="$CONFIG_FILE" python3 -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
OBSERVER_INTERVAL_SECONDS=$((OBSERVER_INTERVAL_MINUTES * 60))
echo "Project: ${PROJECT_NAME} (${PROJECT_ID})"
echo "Storage: ${PROJECT_DIR}"
case "${1:-start}" in
stop)
if [ -f "$PID_FILE" ]; then
pid=$(cat "$PID_FILE")
if kill -0 "$pid" 2>/dev/null; then
echo "Stopping observer (PID: $pid)..."
echo "Stopping observer for ${PROJECT_NAME} (PID: $pid)..."
kill "$pid"
rm -f "$PID_FILE"
echo "Observer stopped."
@@ -44,6 +97,9 @@ case "${1:-start}" in
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)"
@@ -57,17 +113,24 @@ case "${1:-start}" in
;;
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 (PID: $pid)"
echo "Observer already running for ${PROJECT_NAME} (PID: $pid)"
exit 0
fi
rm -f "$PID_FILE"
fi
echo "Starting observer agent..."
echo "Starting observer agent for ${PROJECT_NAME}..."
# The observer loop
(
@@ -79,18 +142,43 @@ case "${1:-start}" in
return
fi
obs_count=$(wc -l < "$OBSERVATIONS_FILE" 2>/dev/null || echo 0)
if [ "$obs_count" -lt 10 ]; then
if [ "$obs_count" -lt "$MIN_OBSERVATIONS" ]; then
return
fi
echo "[$(date)] Analyzing $obs_count observations..." >> "$LOG_FILE"
echo "[$(date)] Analyzing $obs_count observations for project ${PROJECT_NAME}..." >> "$LOG_FILE"
# Use Claude Code with Haiku to analyze observations
# This spawns a quick analysis session
# The prompt now specifies project-scoped instinct creation
if command -v claude &> /dev/null; then
exit_code=0
claude --model haiku --max-turns 3 --print \
"Read $OBSERVATIONS_FILE and identify patterns. If you find 3+ occurrences of the same pattern, create an instinct file in $CONFIG_DIR/instincts/personal/ following the format in the observer agent spec. Be conservative - only create instincts for clear patterns." \
claude --model haiku --print \
"Read $OBSERVATIONS_FILE and identify patterns for the project '${PROJECT_NAME}'.
If you find 3+ occurrences of the same pattern, create an instinct file in $INSTINCTS_DIR/ following this format:
---
id: <kebab-case-id>
trigger: \"<when this happens>\"
confidence: <0.3-0.9>
domain: <code-style|testing|git|debugging|workflow|etc>
source: session-observation
scope: project
project_id: ${PROJECT_ID}
project_name: ${PROJECT_NAME}
---
# <Title>
## Action
<What to do>
## Evidence
<What observations led to this>
Be conservative - only create instincts for clear patterns.
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'." \
>> "$LOG_FILE" 2>&1 || exit_code=$?
if [ "$exit_code" -ne 0 ]; then
echo "[$(date)] Claude analysis failed (exit $exit_code)" >> "$LOG_FILE"
@@ -101,10 +189,9 @@ case "${1:-start}" in
# Archive processed observations
if [ -f "$OBSERVATIONS_FILE" ]; then
archive_dir="${CONFIG_DIR}/observations.archive"
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
touch "$OBSERVATIONS_FILE"
mv "$OBSERVATIONS_FILE" "$archive_dir/processed-$(date +%Y%m%d-%H%M%S)-$$.jsonl" 2>/dev/null || true
fi
}
@@ -112,11 +199,11 @@ case "${1:-start}" in
trap 'analyze_observations' USR1
echo "$$" > "$PID_FILE"
echo "[$(date)] Observer started (PID: $$)" >> "$LOG_FILE"
echo "[$(date)] Observer started for ${PROJECT_NAME} (PID: $$)" >> "$LOG_FILE"
while true; do
# Check every 5 minutes
sleep 300
# Check at configured interval (default: 5 minutes)
sleep "$OBSERVER_INTERVAL_SECONDS"
analyze_observations
done

View File

@@ -1,41 +1,8 @@
{
"version": "2.0",
"observation": {
"enabled": true,
"store_path": "~/.claude/homunculus/observations.jsonl",
"max_file_size_mb": 10,
"archive_after_days": 7,
"capture_tools": ["Edit", "Write", "Bash", "Read", "Grep", "Glob"],
"ignore_tools": ["TodoWrite"]
},
"instincts": {
"personal_path": "~/.claude/homunculus/instincts/personal/",
"inherited_path": "~/.claude/homunculus/instincts/inherited/",
"min_confidence": 0.3,
"auto_approve_threshold": 0.7,
"confidence_decay_rate": 0.02,
"max_instincts": 100
},
"version": "2.1",
"observer": {
"enabled": false,
"model": "haiku",
"run_interval_minutes": 5,
"min_observations_to_analyze": 20,
"patterns_to_detect": [
"user_corrections",
"error_resolutions",
"repeated_workflows",
"tool_preferences",
"file_patterns"
]
},
"evolution": {
"cluster_threshold": 3,
"evolved_path": "~/.claude/homunculus/evolved/",
"auto_evolve": false
},
"integration": {
"skill_creator_api": "https://skill-creator.app/api",
"backward_compatible_v1": true
"min_observations_to_analyze": 20
}
}

View File

@@ -4,52 +4,20 @@
# Captures tool use events for pattern analysis.
# Claude Code passes hook data via stdin as JSON.
#
# Hook config (in ~/.claude/settings.json):
# v2.1: Project-scoped observations — detects current project context
# and writes observations to project-specific directory.
#
# If installed as a plugin, use ${CLAUDE_PLUGIN_ROOT}:
# {
# "hooks": {
# "PreToolUse": [{
# "matcher": "*",
# "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh pre" }]
# }],
# "PostToolUse": [{
# "matcher": "*",
# "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh post" }]
# }]
# }
# }
#
# If installed manually to ~/.claude/skills:
# {
# "hooks": {
# "PreToolUse": [{
# "matcher": "*",
# "hooks": [{ "type": "command", "command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh pre" }]
# }],
# "PostToolUse": [{
# "matcher": "*",
# "hooks": [{ "type": "command", "command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh post" }]
# }]
# }
# }
# 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)
HOOK_PHASE="${1:-post}"
CONFIG_DIR="${HOME}/.claude/homunculus"
OBSERVATIONS_FILE="${CONFIG_DIR}/observations.jsonl"
MAX_FILE_SIZE_MB=10
# Ensure directory exists
mkdir -p "$CONFIG_DIR"
# Skip if disabled
if [ -f "$CONFIG_DIR/disabled" ]; then
exit 0
fi
# ─────────────────────────────────────────────
# Read stdin first (before project detection)
# ─────────────────────────────────────────────
# Read JSON from stdin (Claude Code hook format)
INPUT_JSON=$(cat)
@@ -59,6 +27,51 @@ if [ -z "$INPUT_JSON" ]; then
exit 0
fi
# ─────────────────────────────────────────────
# Extract cwd from stdin for project detection
# ─────────────────────────────────────────────
# Extract cwd from the hook JSON to use for project detection.
# This avoids spawning a separate git subprocess when cwd is available.
STDIN_CWD=$(echo "$INPUT_JSON" | python3 -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
export CLAUDE_PROJECT_DIR="$STDIN_CWD"
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"
# ─────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────
CONFIG_DIR="${HOME}/.claude/homunculus"
OBSERVATIONS_FILE="${PROJECT_DIR}/observations.jsonl"
MAX_FILE_SIZE_MB=10
# Skip if disabled
if [ -f "$CONFIG_DIR/disabled" ]; then
exit 0
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" python3 -c '
@@ -80,6 +93,8 @@ try:
tool_input = data.get("tool_input", data.get("input", {}))
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):
@@ -88,24 +103,26 @@ try:
tool_input_str = str(tool_input)[:5000]
if isinstance(tool_output, dict):
tool_output_str = json.dumps(tool_output)[:5000]
tool_response_str = json.dumps(tool_output)[:5000]
else:
tool_output_str = str(tool_output)[:5000]
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_output_str if event == "tool_complete" else None,
"session": session_id
"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" | python3 -c "import json,sys; print(json.load(sys.stdin).get('parsed', False))")
PARSED_OK=$(echo "$PARSED" | python3 -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
@@ -119,20 +136,23 @@ print(json.dumps({'timestamp': os.environ['TIMESTAMP'], 'event': 'parse_error',
exit 0
fi
# Archive if file too large
# 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="${CONFIG_DIR}/observations.archive"
archive_dir="${PROJECT_DIR}/observations.archive"
mkdir -p "$archive_dir"
mv "$OBSERVATIONS_FILE" "$archive_dir/observations-$(date +%Y%m%d-%H%M%S).jsonl"
mv "$OBSERVATIONS_FILE" "$archive_dir/observations-$(date +%Y%m%d-%H%M%S)-$$.jsonl" 2>/dev/null || true
fi
fi
# Build and write observation
# Build and write observation (now includes project context)
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" | python3 -c "
import json, sys, os
@@ -141,10 +161,12 @@ observation = {
'timestamp': os.environ['TIMESTAMP'],
'event': parsed['event'],
'tool': parsed['tool'],
'session': parsed['session']
'session': parsed['session'],
'project_id': os.environ.get('PROJECT_ID_ENV', 'global'),
'project_name': os.environ.get('PROJECT_NAME_ENV', 'global')
}
if parsed['input'] is not None:
if parsed['input']:
observation['input'] = parsed['input']
if parsed['output'] is not None:
observation['output'] = parsed['output']
@@ -152,13 +174,14 @@ if parsed['output'] is not None:
print(json.dumps(observation))
" >> "$OBSERVATIONS_FILE"
# Signal observer if running
OBSERVER_PID_FILE="${CONFIG_DIR}/.observer.pid"
if [ -f "$OBSERVER_PID_FILE" ]; then
observer_pid=$(cat "$OBSERVER_PID_FILE")
# Signal observer if running (check both project-scoped and global observer)
for pid_file in "${PROJECT_DIR}/.observer.pid" "${CONFIG_DIR}/.observer.pid"; do
if [ -f "$pid_file" ]; then
observer_pid=$(cat "$pid_file")
if kill -0 "$observer_pid" 2>/dev/null; then
kill -USR1 "$observer_pid" 2>/dev/null || true
fi
fi
done
exit 0

View File

@@ -0,0 +1,141 @@
#!/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)
_CLV2_HOMUNCULUS_DIR="${HOME}/.claude/homunculus"
_CLV2_PROJECTS_DIR="${_CLV2_HOMUNCULUS_DIR}/projects"
_CLV2_REGISTRY_FILE="${_CLV2_HOMUNCULUS_DIR}/projects.json"
_clv2_detect_project() {
local project_root=""
local project_name=""
local project_id=""
local source_hint=""
# 1. Try CLAUDE_PROJECT_DIR env var
if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ]; then
project_root="$CLAUDE_PROJECT_DIR"
source_hint="env"
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}"
return 0
fi
# Derive project name from directory basename
project_name=$(basename "$project_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" ] || [ -d "${project_root}/.git" ]; then
remote_url=$(git -C "$project_root" remote get-url origin 2>/dev/null || true)
fi
fi
local hash_input="${remote_url:-$project_root}"
# Use SHA256 via python3 (portable across macOS/Linux, no shasum/sha256sum divergence)
project_id=$(printf '%s' "$hash_input" | python3 -c "import sys,hashlib; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:12])" 2>/dev/null)
# Fallback if python3 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
# 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"
mkdir -p "$(dirname "$_CLV2_REGISTRY_FILE")"
# Pass values via env vars to avoid shell→python injection.
# python3 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_FILE="$_CLV2_REGISTRY_FILE" \
python3 -c '
import json, os
from datetime import datetime, timezone
registry_path = os.environ["_CLV2_REG_FILE"]
try:
with open(registry_path) as f:
registry = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
registry = {}
registry[os.environ["_CLV2_REG_PID"]] = {
"name": os.environ["_CLV2_REG_PNAME"],
"root": os.environ["_CLV2_REG_PROOT"],
"remote": os.environ["_CLV2_REG_PREMOTE"],
"last_seen": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
}
with open(registry_path, "w") as f:
json.dump(registry, f, indent=2)
' 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"

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,26 @@
"""Tests for parse_instinct_file() — verifies content after frontmatter is preserved."""
"""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 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(
@@ -10,8 +29,125 @@ _spec = importlib.util.spec_from_file_location(
)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
parse_instinct_file = _mod.parse_instinct_file
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
_update_registry = _mod._update_registry
# ─────────────────────────────────────────────
# 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 = """\
---
@@ -80,3 +216,741 @@ 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()
# ─────────────────────────────────────────────
# 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
# ─────────────────────────────────────────────
# 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_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_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 == []

View File

@@ -1183,7 +1183,7 @@ async function runTests() {
assert.ok(hooks.hooks.PreCompact, 'Should have PreCompact hooks');
})) passed++; else failed++;
if (test('all hook commands use node', () => {
if (test('all hook commands use node or are skill shell scripts', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
@@ -1191,9 +1191,14 @@ async function runTests() {
for (const entry of hookArray) {
for (const hook of entry.hooks) {
if (hook.type === 'command') {
const isNode = hook.command.startsWith('node');
const isSkillScript = hook.command.includes('/skills/') && (
/^(bash|sh)\s/.test(hook.command) ||
hook.command.startsWith('${CLAUDE_PLUGIN_ROOT}/skills/')
);
assert.ok(
hook.command.startsWith('node'),
`Hook command should start with 'node': ${hook.command.substring(0, 50)}...`
isNode || isSkillScript,
`Hook command should start with 'node' or be a skill shell script: ${hook.command.substring(0, 80)}...`
);
}
}