6 Commits

Author SHA1 Message Date
Harry Kwok
5818e8adc7 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
2026-03-01 12:07:13 -08:00
Affaan Mustafa
2d3be88bb5 docs: update positioning to performance optimization system, add Plankton reference 2026-02-28 10:09:51 -08:00
Affaan Mustafa
87a2ed51dc feat: add exa-web-search to MCP config template 2026-02-28 10:09:51 -08:00
Affaan Mustafa
b68558d749 feat: expand research-first mandate in development workflow 2026-02-28 10:09:51 -08:00
Affaan Mustafa
1fa22efd90 chore: clean up FUNDING.yml format 2026-02-28 10:09:51 -08:00
Codex
dc8455dd10 feat: separate core vs niche skills and enforce research-first default
* Initial plan

* docs: document core skill scope

---------

Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com>
2026-02-28 10:06:43 -08:00
31 changed files with 2532 additions and 736 deletions

15
.github/FUNDING.yml vendored
View File

@@ -1,15 +1,2 @@
# These are supported funding model platforms
github: [affaan-m]
# patreon: # Replace with a single Patreon username
# open_collective: # Replace with a single Open Collective username
# ko_fi: # Replace with a single Ko-fi username
# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-hierarchical-namespace-controller
# liberapay: # Replace with a single Liberapay username
# issuehunt: # Replace with a single IssueHunt username
# lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-hierarchical-namespace-controller
# polar: # Replace with a single Polar username
# buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
# thanks_dev: # Replace with a single thanks.dev username
github: affaan-m
custom: ['https://ecc.tools']

View File

@@ -1,18 +1,30 @@
steps:
- name: Setup Go environment
uses: actions/setup-go@v6.2.0
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
name: Copilot Setup Steps
on:
workflow_dispatch:
permissions:
contents: read
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
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

@@ -27,9 +27,11 @@
---
**The complete collection of Claude Code configs from an Anthropic hackathon winner.**
**The performance optimization system for AI agent harnesses. From an Anthropic hackathon winner.**
Production-ready agents, skills, hooks, commands, rules, and MCP configurations evolved over 10+ months of intensive daily use building real products.
Not just configs. A complete system: skills, instincts, memory optimization, continuous learning, security scanning, and research-first development. Production-ready agents, hooks, commands, rules, and MCP configurations evolved over 10+ months of intensive daily use building real products.
Works across **Claude Code**, **Codex**, **Cowork**, and other AI agent harnesses.
---
@@ -440,6 +442,10 @@ Use `/security-scan` in Claude Code to run it, or add to CI with the [GitHub Act
[GitHub](https://github.com/affaan-m/agentshield) | [npm](https://www.npmjs.com/package/ecc-agentshield)
### 🔬 Plankton — Code Quality Integration
[Plankton](https://github.com/alexfazio/plankton) is a recommended companion for code quality enforcement. It provides automated code review, linting orchestration, and quality gates that pair well with the ECC skill and hook system. Use it alongside AgentShield for security + quality coverage.
### 🧠 Continuous Learning v2
The instinct-based learning system automatically learns your patterns:
@@ -557,8 +563,15 @@ cp -r everything-claude-code/rules/golang/* ~/.claude/rules/
# Copy commands
cp everything-claude-code/commands/*.md ~/.claude/commands/
# Copy skills
cp -r everything-claude-code/skills/* ~/.claude/skills/
# Copy skills (core vs niche)
# Recommended (new users): core/general skills only
cp -r everything-claude-code/.agents/skills/* ~/.claude/skills/
cp -r everything-claude-code/skills/search-first ~/.claude/skills/
# Optional: add niche/framework-specific skills only when needed
# for s in django-patterns django-tdd springboot-patterns; do
# cp -r everything-claude-code/skills/$s ~/.claude/skills/
# done
```
#### Add hooks to settings.json
@@ -972,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"
---
id: prefer-functional-style
trigger: "when writing new functions"
confidence: 0.8
domain: code-style
source: session-observation
scope: project
project_id: a1b2c3d4e5f6
project_name: my-app
---
instincts:
- id: prefer-functional-style
trigger: "when writing new functions"
action: "Use functional patterns over classes"
confidence: 0.8
domain: code-style
observations: 8
# Prefer Functional Style
- id: test-first-workflow
trigger: "when adding new functionality"
action: "Write test first, then implementation"
confidence: 0.9
domain: testing
observations: 12
- 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

@@ -66,6 +66,14 @@
"url": "https://mcp.clickhouse.cloud/mcp",
"description": "ClickHouse analytics queries"
},
"exa-web-search": {
"command": "npx",
"args": ["-y", "exa-mcp-server"],
"env": {
"EXA_API_KEY": "YOUR_EXA_API_KEY_HERE"
},
"description": "Web search, research, and data ingestion via Exa API — recommended for research-first development workflow"
},
"context7": {
"command": "npx",
"args": ["-y", "@context7/mcp-server"],

View File

@@ -2,12 +2,20 @@
> This file extends [common/git-workflow.md](./git-workflow.md) with the full feature development process that happens before git operations.
The Feature Implementation Workflow describes the development pipeline: planning, TDD, code review, and then committing to git.
The Feature Implementation Workflow describes the development pipeline: research, planning, TDD, code review, and then committing to git.
## Feature Implementation Workflow
0. **Research & Reuse** _(mandatory before any new implementation)_
- **GitHub code search first:** Run `gh search repos` and `gh search code` to find existing implementations, templates, and patterns before writing anything new.
- **Exa MCP for research:** Use `exa-web-search` MCP during the planning phase for broader research, data ingestion, and discovering prior art.
- **Check package registries:** Search npm, PyPI, crates.io, and other registries before writing utility code. Prefer battle-tested libraries over hand-rolled solutions.
- **Search for adaptable implementations:** Look for open-source projects that solve 80%+ of the problem and can be forked, ported, or wrapped.
- Prefer adopting or porting a proven approach over writing net-new code when it meets the requirement.
1. **Plan First**
- Use **planner** agent to create implementation plan
- Generate planning docs before coding: PRD, architecture, system_design, tech_doc, task_list
- Identify dependencies and risks
- Break down into phases

View File

@@ -64,7 +64,23 @@ mkdir -p $TARGET/skills $TARGET/rules
## Step 2: Select & Install Skills
### 2a: Choose Skill Categories
### 2a: Choose Scope (Core vs Niche)
Default to **Core (recommended for new users)** — copy `.agents/skills/*` plus `skills/search-first/` for research-first workflows. This bundle covers engineering, evals, verification, security, strategic compaction, frontend design, and Anthropic cross-functional skills (article-writing, content-engine, market-research, frontend-slides).
Use `AskUserQuestion` (single select):
```
Question: "Install core skills only, or include niche/framework packs?"
Options:
- "Core only (recommended)" — "tdd, e2e, evals, verification, research-first, security, frontend patterns, compacting, cross-functional Anthropic skills"
- "Core + selected niche" — "Add framework/domain-specific skills after core"
- "Niche only" — "Skip core, install specific framework/domain skills"
Default: Core only
```
If the user chooses niche or core + niche, continue to category selection below and only include those niche skills they pick.
### 2b: Choose Skill Categories
There are 27 skills organized into 4 categories. Use `AskUserQuestion` with `multiSelect: true`:
@@ -77,7 +93,7 @@ Options:
- "All skills" — "Install every available skill"
```
### 2b: Confirm Individual Skills
### 2c: Confirm Individual Skills
For each selected category, print the full list of skills below and ask the user to confirm or deselect specific ones. If the list exceeds 4 items, print the list as text and use `AskUserQuestion` with an "Install all listed" option plus "Other" for the user to paste specific names.
@@ -140,7 +156,7 @@ For each selected category, print the full list of skills below and ask the user
|-------|-------------|
| `project-guidelines-example` | Template for creating project-specific skills |
### 2c: Execute Installation
### 2d: Execute Installation
For each selected skill, copy the entire skill directory:
```bash

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")
if kill -0 "$observer_pid" 2>/dev/null; then
kill -USR1 "$observer_pid" 2>/dev/null || true
# 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
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

@@ -61,10 +61,11 @@ Use this skill when:
Before writing a utility or adding functionality, mentally run through:
0. Does this already exist in the repo? → `rg` through relevant modules/tests first
1. Is this a common problem? → Search npm/PyPI
2. Is there an MCP for this? → Check `~/.claude/settings.json` and search
3. Is there a skill for this? → Check `~/.claude/skills/`
4. Is there a GitHub template? → Search GitHub
4. Is there a GitHub implementation/template? → Run GitHub code search for maintained OSS before writing net-new code
### Full Mode (agent)

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)}...`
);
}
}