feat: project-scoped instinct isolation

* feat: add project-scoped instinct isolation

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

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

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

View File

@@ -1,18 +1,30 @@
steps: name: Copilot Setup Steps
- name: Setup Go environment
uses: actions/setup-go@v6.2.0 on:
with: workflow_dispatch:
# 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 permissions:
# Path to the go.mod, go.work, .go-version, or .tool-versions file. contents: read
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 jobs:
check-latest: # optional copilot-setup-steps:
# 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. runs-on: ubuntu-latest
token: # optional, default is ${{ github.server_url == 'https://github.com' && github.token || '' }} timeout-minutes: 10
# Used to specify whether caching is needed. Set to true, if you'd like to enable caching. steps:
cache: # optional, default is true - name: Checkout repository
# Used to specify the path to a dependency file - go.sum uses: actions/checkout@v4
cache-dependency-path: # optional
# Target architecture for Go to use. Examples: x86, x64. Will use system architecture by default. - name: Setup Node.js
architecture: # optional 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-import` | Import instincts |
| `/instinct-export` | Export instincts | | `/instinct-export` | Export instincts |
| `/evolve` | Cluster instincts into skills | | `/evolve` | Cluster instincts into skills |
| `/promote` | Promote project instincts to global scope |
| `/projects` | List known projects and instinct stats |
## Available Agents ## Available Agents

View File

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

View File

@@ -1,112 +1,36 @@
--- ---
description: Cluster instincts into skills description: Analyze instincts and suggest or generate evolved structures
agent: build agent: build
--- ---
# Evolve Command # Evolve Command
Cluster related instincts into structured skills: $ARGUMENTS Analyze and evolve instincts in continuous-learning-v2: $ARGUMENTS
## Your Task ## Your Task
Analyze instincts and promote clusters to skills. Run:
## Evolution Process ```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" evolve $ARGUMENTS
### 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)
``` ```
### Step 3: Generate Skill If `CLAUDE_PLUGIN_ROOT` is unavailable, use:
When cluster has: ```bash
- 3+ instincts python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py evolve $ARGUMENTS
- 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
``` ```
### 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
``` - Uses project + global instincts for analysis.
Evolution Summary - Shows skill/command/agent candidates from trigger and domain clustering.
================= - Shows project -> global promotion candidates.
- With `--generate`, output path is:
Clusters Found: X - project context: `~/.claude/homunculus/projects/<project-id>/evolved/`
- global fallback: `~/.claude/homunculus/evolved/`
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.

View File

@@ -1,75 +1,29 @@
--- ---
description: View learned instincts with confidence scores description: Show learned instincts (project + global) with confidence
agent: build agent: build
--- ---
# Instinct Status Command # Instinct Status Command
Display learned instincts and their confidence scores: $ARGUMENTS Show instinct status from continuous-learning-v2: $ARGUMENTS
## Your Task ## Your Task
Read and display instincts from the continuous-learning-v2 system. Run:
## Instinct Location ```bash
python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" status
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)
``` ```
### Learning Progress If `CLAUDE_PLUGIN_ROOT` is unavailable, use:
- Total instincts: X ```bash
- This session: X python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py status
- 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"
}
``` ```
## Confidence Calculation ## Behavior Notes
``` - Output includes both project-scoped and global instincts.
confidence = (successes + 1) / (applications + 2) - 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.
Bayesian smoothing ensures new instincts don't have extreme confidence.
---
**TIP**: Use `/evolve` to cluster related instincts into skills when confidence is high.

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": { "evolve": {
"description": "Cluster instincts into skills", "description": "Cluster instincts into skills",
"template": "{file:.opencode/commands/evolve.md}\n\n$ARGUMENTS" "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": { "permission": {

View File

@@ -985,6 +985,8 @@ OpenCode's plugin system is MORE sophisticated than Claude Code with 20+ event t
| `/instinct-import` | Import instincts | | `/instinct-import` | Import instincts |
| `/instinct-export` | Export instincts | | `/instinct-export` | Export instincts |
| `/evolve` | Cluster instincts into skills | | `/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 | | `/learn-eval` | Extract and evaluate patterns before saving |
| `/setup-pm` | Configure package manager | | `/setup-pm` | Configure package manager |

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
--- ---
name: instinct-export 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 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 --domain testing # Export only testing instincts
/instinct-export --min-confidence 0.7 # Only export high-confidence instincts /instinct-export --min-confidence 0.7 # Only export high-confidence instincts
/instinct-export --output team-instincts.yaml /instinct-export --output team-instincts.yaml
/instinct-export --scope project --output project-instincts.yaml
``` ```
## What to Do ## What to Do
1. Read instincts from `~/.claude/homunculus/instincts/personal/` 1. Detect current project context
2. Filter based on flags 2. Load instincts by selected scope:
3. Strip sensitive information: - `project`: current project only
- Remove session IDs - `global`: global only
- Remove file paths (keep only patterns) - `all`: project + global merged (default)
- Remove timestamps older than "last week" 3. Apply filters (`--domain`, `--min-confidence`)
4. Generate export file 4. Write YAML-style export to file (or stdout if no output path provided)
## Output Format ## Output Format
@@ -40,52 +41,26 @@ Creates a YAML file:
# Source: personal # Source: personal
# Count: 12 instincts # Count: 12 instincts
version: "2.0" ---
exported_by: "continuous-learning-v2" id: prefer-functional-style
export_date: "2025-01-22T10:30:00Z" trigger: "when writing new functions"
confidence: 0.8
domain: code-style
source: session-observation
scope: project
project_id: a1b2c3d4e5f6
project_name: my-app
---
instincts: # Prefer Functional Style
- id: prefer-functional-style
trigger: "when writing new functions"
action: "Use functional patterns over classes"
confidence: 0.8
domain: code-style
observations: 8
- id: test-first-workflow ## Action
trigger: "when adding new functionality" Use functional patterns over classes.
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
``` ```
## 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 ## Flags
- `--domain <name>`: Export only specified domain - `--domain <name>`: Export only specified domain
- `--min-confidence <n>`: Minimum confidence threshold (default: 0.3) - `--min-confidence <n>`: Minimum confidence threshold
- `--output <file>`: Output file path (default: instincts-export-YYYYMMDD.yaml) - `--output <file>`: Output file path (prints to stdout when omitted)
- `--format <yaml|json|md>`: Output format (default: yaml) - `--scope <project|global|all>`: Export scope (default: `all`)
- `--include-evidence`: Include evidence text (default: excluded)

View File

@@ -1,6 +1,6 @@
--- ---
name: instinct-import 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 command: true
--- ---
@@ -11,7 +11,7 @@ command: true
Run the instinct CLI using the plugin root path: Run the instinct CLI using the plugin root path:
```bash ```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): 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> python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py import <file-or-url>
``` ```
Import instincts from: Import instincts from local file paths or HTTP(S) URLs.
- Teammates' exports
- Skill Creator (repo analysis)
- Community collections
- Previous machine backups
## Usage ## Usage
``` ```
/instinct-import team-instincts.yaml /instinct-import team-instincts.yaml
/instinct-import https://github.com/org/repo/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 ## What to Do
@@ -40,7 +37,9 @@ Import instincts from:
2. Parse and validate the format 2. Parse and validate the format
3. Check for duplicates with existing instincts 3. Check for duplicates with existing instincts
4. Merge or add new 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 ## Import Process
@@ -71,60 +70,33 @@ Already have similar instincts:
Import: 0.9 confidence Import: 0.9 confidence
→ Update to import (higher confidence) → Update to import (higher confidence)
## Conflicting Instincts (1) Import 8 new, update 1?
These contradict local instincts:
❌ use-classes-for-services
Conflicts with: avoid-classes
→ Skip (requires manual resolution)
---
Import 8 new, update 1, skip 3?
``` ```
## Merge Strategies ## Merge Behavior
### For Duplicates When importing an instinct with an existing ID:
When importing an instinct that matches an existing one: - Higher-confidence import becomes an update candidate
- **Higher confidence wins**: Keep the one with higher confidence - Equal/lower-confidence import is skipped
- **Merge evidence**: Combine observation counts - User confirms unless `--force` is used
- **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
## Source Tracking ## Source Tracking
Imported instincts are marked with: Imported instincts are marked with:
```yaml ```yaml
source: "inherited" source: inherited
scope: project
imported_from: "team-instincts.yaml" imported_from: "team-instincts.yaml"
imported_at: "2025-01-22T10:30:00Z" project_id: "a1b2c3d4e5f6"
original_source: "session-observation" # or "repo-analysis" 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 ## Flags
- `--dry-run`: Preview without importing - `--dry-run`: Preview without importing
- `--force`: Import even if conflicts exist - `--force`: Skip confirmation prompt
- `--merge-strategy <higher|local|import>`: How to handle duplicates
- `--from-skill-creator <owner/repo>`: Import from Skill Creator analysis
- `--min-confidence <n>`: Only import instincts above threshold - `--min-confidence <n>`: Only import instincts above threshold
- `--scope <project|global>`: Select target scope (default: `project`)
## Output ## Output
@@ -134,7 +106,7 @@ After import:
Added: 8 instincts Added: 8 instincts
Updated: 1 instinct 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/ New instincts saved to: ~/.claude/homunculus/instincts/inherited/

View File

@@ -1,12 +1,12 @@
--- ---
name: instinct-status name: instinct-status
description: Show all learned instincts with their confidence levels description: Show learned instincts (project + global) with confidence
command: true command: true
--- ---
# Instinct Status Command # 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 ## Implementation
@@ -26,61 +26,34 @@ python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py status
``` ```
/instinct-status /instinct-status
/instinct-status --domain code-style
/instinct-status --low-confidence
``` ```
## What to Do ## What to Do
1. Read all instinct files from `~/.claude/homunculus/instincts/personal/` 1. Detect current project context (git remote/path hash)
2. Read inherited instincts from `~/.claude/homunculus/instincts/inherited/` 2. Read project instincts from `~/.claude/homunculus/projects/<project-id>/instincts/`
3. Display them grouped by domain with confidence bars 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 ## 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 ## PROJECT-SCOPED (my-app)
Trigger: when writing new functions ### WORKFLOW (3)
Action: Use functional patterns over classes ███████░░░ 70% grep-before-edit [project]
Confidence: ████████░░ 80% trigger: when modifying code
Source: session-observation | Last updated: 2025-01-22
### use-path-aliases ## GLOBAL (apply to all projects)
Trigger: when importing modules ### SECURITY (2)
Action: Use @/ path aliases instead of relative imports █████████░ 85% validate-user-input [global]
Confidence: ██████░░░░ 60% trigger: when handling user input
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)
``` ```
## 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": [ "hooks": [
{ {
"type": "command", "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)" "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" "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": [ "PreCompact": [
@@ -129,6 +141,18 @@
} }
], ],
"description": "Warn about console.log statements after edits" "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": [ "Stop": [

View File

@@ -1,15 +1,16 @@
--- ---
name: continuous-learning-v2 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 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. 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 ## 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 - Tuning confidence thresholds for learned behaviors
- Reviewing, exporting, or importing instinct libraries - Reviewing, exporting, or importing instinct libraries
- Evolving instincts into full skills, commands, or agents - 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 | | 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) | | Analysis | Main context | Background agent (Haiku) |
| Granularity | Full skills | Atomic "instincts" | | Granularity | Full skills | Atomic "instincts" |
| Confidence | None | 0.3-0.9 weighted | | 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 | | Sharing | None | Export/import instincts |
## The Instinct Model ## The Instinct Model
@@ -41,6 +55,9 @@ trigger: "when writing new functions"
confidence: 0.7 confidence: 0.7
domain: "code-style" domain: "code-style"
source: "session-observation" source: "session-observation"
scope: project
project_id: "a1b2c3d4e5f6"
project_name: "my-react-app"
--- ---
# Prefer Functional Style # Prefer Functional Style
@@ -54,51 +71,69 @@ Use functional patterns over classes when appropriate.
``` ```
**Properties:** **Properties:**
- **Atomic** one trigger, one action - **Atomic** -- one trigger, one action
- **Confidence-weighted** 0.3 = tentative, 0.9 = near certain - **Confidence-weighted** -- 0.3 = tentative, 0.9 = near certain
- **Domain-tagged** code-style, testing, git, debugging, workflow, etc. - **Domain-tagged** -- code-style, testing, git, debugging, workflow, etc.
- **Evidence-backed** tracks what observations created it - **Evidence-backed** -- tracks what observations created it
- **Scope-aware** -- `project` (default) or `global`
## How It Works ## How It Works
``` ```
Session Activity Session Activity (in a git repo)
|
Hooks capture prompts + tool use (100% reliable) | Hooks capture prompts + tool use (100% reliable)
| + detect project context (git remote / repo path)
┌─────────────────────────────────────────┐ v
│ observations.jsonl │ +---------------------------------------------+
(prompts, tool calls, outcomes) | projects/<project-hash>/observations.jsonl |
└─────────────────────────────────────────┘ | (prompts, tool calls, outcomes, project) |
+---------------------------------------------+
│ Observer agent reads (background, Haiku) |
| Observer agent reads (background, Haiku)
┌─────────────────────────────────────────┐ v
│ PATTERN DETECTION │ +---------------------------------------------+
│ • User corrections → instinct | PATTERN DETECTION |
• Error resolutions instinct | * User corrections -> instinct |
• Repeated workflows → instinct | * Error resolutions -> instinct |
└─────────────────────────────────────────┘ | * Repeated workflows -> instinct |
| * Scope decision: project or global? |
│ Creates/updates +---------------------------------------------+
|
┌─────────────────────────────────────────┐ | Creates/updates
instincts/personal/ │ v
│ • prefer-functional.md (0.7) │ +---------------------------------------------+
│ • always-test-first.md (0.9) │ | projects/<project-hash>/instincts/personal/ |
• use-zod-validation.md (0.6) | * prefer-functional.yaml (0.7) [project] |
└─────────────────────────────────────────┘ | * use-react-hooks.yaml (0.9) [project] |
+---------------------------------------------+
│ /evolve clusters | instincts/personal/ (GLOBAL) |
| * always-validate-input.yaml (0.85) [global]|
┌─────────────────────────────────────────┐ | * grep-before-edit.yaml (0.6) [global] |
│ evolved/ │ +---------------------------------------------+
• commands/new-feature.md │ |
│ • skills/testing-workflow.md │ | /evolve clusters + /promote
• agents/refactor-specialist.md │ 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 ## Quick Start
### 1. Enable Observation Hooks ### 1. Enable Observation Hooks
@@ -114,14 +149,14 @@ Add to your `~/.claude/settings.json`.
"matcher": "*", "matcher": "*",
"hooks": [{ "hooks": [{
"type": "command", "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": [{ "PostToolUse": [{
"matcher": "*", "matcher": "*",
"hooks": [{ "hooks": [{
"type": "command", "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": "*", "matcher": "*",
"hooks": [{ "hooks": [{
"type": "command", "type": "command",
"command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh pre" "command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh"
}] }]
}], }],
"PostToolUse": [{ "PostToolUse": [{
"matcher": "*", "matcher": "*",
"hooks": [{ "hooks": [{
"type": "command", "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 ### 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 ```bash
mkdir -p ~/.claude/homunculus/{instincts/{personal,inherited},evolved/{agents,skills,commands}} # Global directories
touch ~/.claude/homunculus/observations.jsonl 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 ### 3. Use the Instinct Commands
```bash ```bash
/instinct-status # Show learned instincts with confidence scores /instinct-status # Show learned instincts (project + global)
/evolve # Cluster related instincts into skills/commands /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 /instinct-import # Import instincts from others
/promote # Promote project instincts to global scope
/projects # List all known projects and their instinct counts
``` ```
## Commands ## Commands
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `/instinct-status` | Show all learned instincts with confidence | | `/instinct-status` | Show all instincts (project-scoped + global) with confidence |
| `/evolve` | Cluster related instincts into skills/commands | | `/evolve` | Cluster related instincts into skills/commands, suggest promotions |
| `/instinct-export` | Export instincts for sharing | | `/instinct-export` | Export instincts (filterable by scope/domain) |
| `/instinct-import <file>` | Import instincts from others | | `/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 ## Configuration
Edit `config.json`: Edit `config.json` to control the background observer:
```json ```json
{ {
"version": "2.0", "version": "2.1",
"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
},
"observer": { "observer": {
"enabled": true, "enabled": false,
"model": "haiku",
"run_interval_minutes": 5, "run_interval_minutes": 5,
"patterns_to_detect": [ "min_observations_to_analyze": 20
"user_corrections",
"error_resolutions",
"repeated_workflows",
"tool_preferences"
]
},
"evolution": {
"cluster_threshold": 3,
"evolved_path": "~/.claude/homunculus/evolved/"
} }
} }
``` ```
| 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 ## File Structure
``` ```
~/.claude/homunculus/ ~/.claude/homunculus/
├── identity.json # Your profile, technical level +-- identity.json # Your profile, technical level
├── observations.jsonl # Current session observations +-- projects.json # Registry: project hash -> name/path/remote
├── observations.archive/ # Processed observations +-- observations.jsonl # Global observations (fallback)
├── instincts/ +-- instincts/
├── personal/ # Auto-learned instincts | +-- personal/ # Global auto-learned instincts
└── inherited/ # Imported from others | +-- inherited/ # Global imported instincts
└── evolved/ +-- evolved/
├── agents/ # Generated specialist agents | +-- agents/ # Global generated agents
├── skills/ # Generated skills | +-- skills/ # Global generated skills
└── commands/ # Generated commands | +-- 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**: | Pattern Type | Scope | Examples |
- Traditional SKILL.md files (for backward compatibility) |-------------|-------|---------|
- Instinct collections (for v2 learning system) | 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 ## Confidence Scoring
@@ -263,7 +330,7 @@ Confidence evolves over time:
## Why Hooks vs Skills for Observation? ## 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: Hooks fire **100% of the time**, deterministically. This means:
- Every tool call is observed - Every tool call is observed
@@ -272,17 +339,19 @@ Hooks fire **100% of the time**, deterministically. This means:
## Backward Compatibility ## Backward Compatibility
v2 is fully compatible with v1: v2.1 is fully compatible with v2.0 and v1:
- Existing `~/.claude/skills/learned/` skills still work - 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) - Stop hook still runs (but now also feeds into v2)
- Gradual migration path: run both in parallel - Gradual migration: run both in parallel
## Privacy ## Privacy
- Observations stay **local** on your machine - 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 - No actual code or conversation content is shared
- You control what gets exported - You control what gets exported and promoted
## Related ## 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 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 model: haiku
run_mode: background
--- ---
# Observer Agent # Observer Agent
@@ -11,20 +10,21 @@ A background agent that analyzes observations from Claude Code sessions to detec
## When to Run ## When to Run
- After significant session activity (20+ tool calls) - After enough observations accumulate (configurable, default 20)
- When user runs `/analyze-patterns`
- On a scheduled interval (configurable, default 5 minutes) - 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 ## 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 ```jsonl
{"timestamp":"2025-01-22T10:30:00Z","event":"tool_start","session":"abc123","tool":"Edit","input":"..."} {"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":"..."} {"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"} {"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"} {"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 ## Pattern Detection
@@ -65,28 +65,75 @@ When certain tools are consistently preferred:
## Output ## 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 ```yaml
--- ---
id: prefer-grep-before-edit id: use-react-hooks-pattern
trigger: "when searching for code to modify" trigger: "when creating React components"
confidence: 0.65 confidence: 0.65
domain: "workflow" domain: "code-style"
source: "session-observation" source: "session-observation"
scope: project
project_id: "a1b2c3d4e5f6"
project_name: "my-react-app"
--- ---
# Prefer Grep Before Edit # Use React Hooks Pattern
## Action ## Action
Always use Grep to find the exact location before using Edit. Always use functional components with hooks instead of class components.
## Evidence ## Evidence
- Observed 8 times in session abc123 - Observed 8 times in session abc123
- Pattern: Grep → Read → Edit sequence - Pattern: All new components use useState/useEffect
- Last observed: 2025-01-22 - 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 ## Confidence Calculation
Initial confidence based on observation frequency: Initial confidence based on observation frequency:
@@ -100,6 +147,15 @@ Confidence adjusts over time:
- -0.1 for each contradicting observation - -0.1 for each contradicting observation
- -0.02 per week without observation (decay) - -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 ## Important Guidelines
1. **Be Conservative**: Only create instincts for clear patterns (3+ observations) 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 3. **Track Evidence**: Always include what observations led to the instinct
4. **Respect Privacy**: Never include actual code snippets, only patterns 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 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 ## Example Analysis Session
Given observations: Given observations:
```jsonl ```jsonl
{"event":"tool_start","tool":"Grep","input":"pattern: useState"} {"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"} {"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"} {"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]"} {"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..."} {"event":"tool_start","tool":"Edit","input":"src/hooks/useAuth.ts...","project_id":"a1b2c3","project_name":"my-app"}
``` ```
Analysis: Analysis:
- Detected workflow: Grep → Read → Edit - Detected workflow: Grep → Read → Edit
- Frequency: Seen 5 times this session - Frequency: Seen 5 times this session
- **Scope decision**: This is a general workflow pattern (not project-specific) → **global**
- Create instinct: - Create instinct:
- trigger: "when modifying code" - trigger: "when modifying code"
- action: "Search with Grep, confirm with Read, then Edit" - action: "Search with Grep, confirm with Read, then Edit"
- confidence: 0.6 - confidence: 0.6
- domain: "workflow" - domain: "workflow"
- scope: "global"
## Integration with Skill Creator ## Integration with Skill Creator
When instincts are imported from Skill Creator (repo analysis), they have: When instincts are imported from Skill Creator (repo analysis), they have:
- `source: "repo-analysis"` - `source: "repo-analysis"`
- `source_repo: "https://github.com/..."` - `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+). 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 # Starts the background observer agent that analyzes observations
# and creates instincts. Uses Haiku model for cost efficiency. # 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: # 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 stop # Stop running observer
# start-observer.sh status # Check if observer is running # start-observer.sh status # Check if observer is running
set -e set -e
CONFIG_DIR="${HOME}/.claude/homunculus" # ─────────────────────────────────────────────
PID_FILE="${CONFIG_DIR}/.observer.pid" # Project detection
LOG_FILE="${CONFIG_DIR}/observer.log" # ─────────────────────────────────────────────
OBSERVATIONS_FILE="${CONFIG_DIR}/observations.jsonl"
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 case "${1:-start}" in
stop) stop)
if [ -f "$PID_FILE" ]; then if [ -f "$PID_FILE" ]; then
pid=$(cat "$PID_FILE") pid=$(cat "$PID_FILE")
if kill -0 "$pid" 2>/dev/null; then if kill -0 "$pid" 2>/dev/null; then
echo "Stopping observer (PID: $pid)..." echo "Stopping observer for ${PROJECT_NAME} (PID: $pid)..."
kill "$pid" kill "$pid"
rm -f "$PID_FILE" rm -f "$PID_FILE"
echo "Observer stopped." echo "Observer stopped."
@@ -44,6 +97,9 @@ case "${1:-start}" in
echo "Observer is running (PID: $pid)" echo "Observer is running (PID: $pid)"
echo "Log: $LOG_FILE" echo "Log: $LOG_FILE"
echo "Observations: $(wc -l < "$OBSERVATIONS_FILE" 2>/dev/null || echo 0) lines" 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 exit 0
else else
echo "Observer not running (stale PID file)" echo "Observer not running (stale PID file)"
@@ -57,17 +113,24 @@ case "${1:-start}" in
;; ;;
start) 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 # Check if already running
if [ -f "$PID_FILE" ]; then if [ -f "$PID_FILE" ]; then
pid=$(cat "$PID_FILE") pid=$(cat "$PID_FILE")
if kill -0 "$pid" 2>/dev/null; then 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 exit 0
fi fi
rm -f "$PID_FILE" rm -f "$PID_FILE"
fi fi
echo "Starting observer agent..." echo "Starting observer agent for ${PROJECT_NAME}..."
# The observer loop # The observer loop
( (
@@ -79,18 +142,43 @@ case "${1:-start}" in
return return
fi fi
obs_count=$(wc -l < "$OBSERVATIONS_FILE" 2>/dev/null || echo 0) 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 return
fi 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 # 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 if command -v claude &> /dev/null; then
exit_code=0 exit_code=0
claude --model haiku --max-turns 3 --print \ claude --model haiku --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." \ "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=$? >> "$LOG_FILE" 2>&1 || exit_code=$?
if [ "$exit_code" -ne 0 ]; then if [ "$exit_code" -ne 0 ]; then
echo "[$(date)] Claude analysis failed (exit $exit_code)" >> "$LOG_FILE" echo "[$(date)] Claude analysis failed (exit $exit_code)" >> "$LOG_FILE"
@@ -101,10 +189,9 @@ case "${1:-start}" in
# Archive processed observations # Archive processed observations
if [ -f "$OBSERVATIONS_FILE" ]; then if [ -f "$OBSERVATIONS_FILE" ]; then
archive_dir="${CONFIG_DIR}/observations.archive" archive_dir="${PROJECT_DIR}/observations.archive"
mkdir -p "$archive_dir" mkdir -p "$archive_dir"
mv "$OBSERVATIONS_FILE" "$archive_dir/processed-$(date +%Y%m%d-%H%M%S).jsonl" 2>/dev/null || true mv "$OBSERVATIONS_FILE" "$archive_dir/processed-$(date +%Y%m%d-%H%M%S)-$$.jsonl" 2>/dev/null || true
touch "$OBSERVATIONS_FILE"
fi fi
} }
@@ -112,11 +199,11 @@ case "${1:-start}" in
trap 'analyze_observations' USR1 trap 'analyze_observations' USR1
echo "$$" > "$PID_FILE" echo "$$" > "$PID_FILE"
echo "[$(date)] Observer started (PID: $$)" >> "$LOG_FILE" echo "[$(date)] Observer started for ${PROJECT_NAME} (PID: $$)" >> "$LOG_FILE"
while true; do while true; do
# Check every 5 minutes # Check at configured interval (default: 5 minutes)
sleep 300 sleep "$OBSERVER_INTERVAL_SECONDS"
analyze_observations analyze_observations
done done

View File

@@ -1,41 +1,8 @@
{ {
"version": "2.0", "version": "2.1",
"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
},
"observer": { "observer": {
"enabled": false, "enabled": false,
"model": "haiku",
"run_interval_minutes": 5, "run_interval_minutes": 5,
"min_observations_to_analyze": 20, "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
} }
} }

View File

@@ -4,52 +4,20 @@
# Captures tool use events for pattern analysis. # Captures tool use events for pattern analysis.
# Claude Code passes hook data via stdin as JSON. # 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}: # Registered via plugin hooks/hooks.json (auto-loaded when plugin is enabled).
# { # Can also be registered manually in ~/.claude/settings.json.
# "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" }]
# }]
# }
# }
set -e set -e
# Hook phase from CLI argument: "pre" (PreToolUse) or "post" (PostToolUse) # Hook phase from CLI argument: "pre" (PreToolUse) or "post" (PostToolUse)
HOOK_PHASE="${1:-post}" HOOK_PHASE="${1:-post}"
CONFIG_DIR="${HOME}/.claude/homunculus" # ─────────────────────────────────────────────
OBSERVATIONS_FILE="${CONFIG_DIR}/observations.jsonl" # Read stdin first (before project detection)
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 JSON from stdin (Claude Code hook format) # Read JSON from stdin (Claude Code hook format)
INPUT_JSON=$(cat) INPUT_JSON=$(cat)
@@ -59,6 +27,51 @@ if [ -z "$INPUT_JSON" ]; then
exit 0 exit 0
fi 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) # 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 # 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 ' 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_input = data.get("tool_input", data.get("input", {}))
tool_output = data.get("tool_output", data.get("output", "")) tool_output = data.get("tool_output", data.get("output", ""))
session_id = data.get("session_id", "unknown") session_id = data.get("session_id", "unknown")
tool_use_id = data.get("tool_use_id", "")
cwd = data.get("cwd", "")
# Truncate large inputs/outputs # Truncate large inputs/outputs
if isinstance(tool_input, dict): if isinstance(tool_input, dict):
@@ -88,24 +103,26 @@ try:
tool_input_str = str(tool_input)[:5000] tool_input_str = str(tool_input)[:5000]
if isinstance(tool_output, dict): if isinstance(tool_output, dict):
tool_output_str = json.dumps(tool_output)[:5000] tool_response_str = json.dumps(tool_output)[:5000]
else: else:
tool_output_str = str(tool_output)[:5000] tool_response_str = str(tool_output)[:5000]
print(json.dumps({ print(json.dumps({
"parsed": True, "parsed": True,
"event": event, "event": event,
"tool": tool_name, "tool": tool_name,
"input": tool_input_str if event == "tool_start" else None, "input": tool_input_str if event == "tool_start" else None,
"output": tool_output_str if event == "tool_complete" else None, "output": tool_response_str if event == "tool_complete" else None,
"session": session_id "session": session_id,
"tool_use_id": tool_use_id,
"cwd": cwd
})) }))
except Exception as e: except Exception as e:
print(json.dumps({"parsed": False, "error": str(e)})) print(json.dumps({"parsed": False, "error": str(e)}))
') ')
# Check if parsing succeeded # 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 if [ "$PARSED_OK" != "True" ]; then
# Fallback: log raw input for debugging # Fallback: log raw input for debugging
@@ -119,20 +136,23 @@ print(json.dumps({'timestamp': os.environ['TIMESTAMP'], 'event': 'parse_error',
exit 0 exit 0
fi fi
# Archive if file too large # Archive if file too large (atomic: rename with unique suffix to avoid race)
if [ -f "$OBSERVATIONS_FILE" ]; then if [ -f "$OBSERVATIONS_FILE" ]; then
file_size_mb=$(du -m "$OBSERVATIONS_FILE" 2>/dev/null | cut -f1) file_size_mb=$(du -m "$OBSERVATIONS_FILE" 2>/dev/null | cut -f1)
if [ "${file_size_mb:-0}" -ge "$MAX_FILE_SIZE_MB" ]; then 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" 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
fi fi
# Build and write observation # Build and write observation (now includes project context)
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
export PROJECT_ID_ENV="$PROJECT_ID"
export PROJECT_NAME_ENV="$PROJECT_NAME"
export TIMESTAMP="$timestamp" export TIMESTAMP="$timestamp"
echo "$PARSED" | python3 -c " echo "$PARSED" | python3 -c "
import json, sys, os import json, sys, os
@@ -141,10 +161,12 @@ observation = {
'timestamp': os.environ['TIMESTAMP'], 'timestamp': os.environ['TIMESTAMP'],
'event': parsed['event'], 'event': parsed['event'],
'tool': parsed['tool'], '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'] observation['input'] = parsed['input']
if parsed['output'] is not None: if parsed['output'] is not None:
observation['output'] = parsed['output'] observation['output'] = parsed['output']
@@ -152,13 +174,14 @@ if parsed['output'] is not None:
print(json.dumps(observation)) print(json.dumps(observation))
" >> "$OBSERVATIONS_FILE" " >> "$OBSERVATIONS_FILE"
# Signal observer if running # Signal observer if running (check both project-scoped and global observer)
OBSERVER_PID_FILE="${CONFIG_DIR}/.observer.pid" for pid_file in "${PROJECT_DIR}/.observer.pid" "${CONFIG_DIR}/.observer.pid"; do
if [ -f "$OBSERVER_PID_FILE" ]; then if [ -f "$pid_file" ]; then
observer_pid=$(cat "$OBSERVER_PID_FILE") observer_pid=$(cat "$pid_file")
if kill -0 "$observer_pid" 2>/dev/null; then if kill -0 "$observer_pid" 2>/dev/null; then
kill -USR1 "$observer_pid" 2>/dev/null || true kill -USR1 "$observer_pid" 2>/dev/null || true
fi
fi fi
fi done
exit 0 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 importlib.util
import json
import os 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) # Load instinct-cli.py (hyphenated filename requires importlib)
_spec = importlib.util.spec_from_file_location( _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) _mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod) _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 = """\ MULTI_SECTION = """\
--- ---
@@ -80,3 +216,741 @@ domain: general
result = parse_instinct_file(content) result = parse_instinct_file(content)
assert len(result) == 1 assert len(result) == 1
assert result[0]["content"] == "" assert result[0]["content"] == ""
def test_parse_no_id_skipped():
"""Instincts without an 'id' field should be silently dropped."""
content = """\
---
trigger: "when doing nothing"
confidence: 0.5
---
No id here.
"""
result = parse_instinct_file(content)
assert len(result) == 0
def test_parse_confidence_is_float():
content = """\
---
id: float-check
trigger: "when parsing"
confidence: 0.42
domain: general
---
Body.
"""
result = parse_instinct_file(content)
assert isinstance(result[0]["confidence"], float)
assert result[0]["confidence"] == pytest.approx(0.42)
def test_parse_trigger_strips_quotes():
content = """\
---
id: quote-check
trigger: "when quoting"
confidence: 0.5
domain: general
---
Body.
"""
result = parse_instinct_file(content)
assert result[0]["trigger"] == "when quoting"
def test_parse_empty_string():
result = parse_instinct_file("")
assert result == []
def test_parse_garbage_input():
result = parse_instinct_file("this is not yaml at all\nno frontmatter here")
assert result == []
# ─────────────────────────────────────────────
# _validate_file_path tests
# ─────────────────────────────────────────────
def test_validate_normal_path(tmp_path):
test_file = tmp_path / "test.yaml"
test_file.write_text("hello")
result = _validate_file_path(str(test_file), must_exist=True)
assert result == test_file.resolve()
def test_validate_rejects_etc():
with pytest.raises(ValueError, match="system directory"):
_validate_file_path("/etc/passwd")
def test_validate_rejects_var_log():
with pytest.raises(ValueError, match="system directory"):
_validate_file_path("/var/log/syslog")
def test_validate_rejects_usr():
with pytest.raises(ValueError, match="system directory"):
_validate_file_path("/usr/local/bin/foo")
def test_validate_rejects_proc():
with pytest.raises(ValueError, match="system directory"):
_validate_file_path("/proc/self/status")
def test_validate_must_exist_fails(tmp_path):
with pytest.raises(ValueError, match="does not exist"):
_validate_file_path(str(tmp_path / "nonexistent.yaml"), must_exist=True)
def test_validate_home_expansion(tmp_path):
"""Tilde expansion should work."""
result = _validate_file_path("~/test.yaml")
assert str(result).startswith(str(Path.home()))
def test_validate_relative_path(tmp_path, monkeypatch):
"""Relative paths should be resolved."""
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "rel.yaml"
test_file.write_text("content")
result = _validate_file_path("rel.yaml", must_exist=True)
assert result == test_file.resolve()
# ─────────────────────────────────────────────
# detect_project tests
# ─────────────────────────────────────────────
def test_detect_project_global_fallback(patch_globals, monkeypatch):
"""When no git and no env var, should return global project."""
monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
# Mock subprocess.run to simulate git not available
def mock_run(*args, **kwargs):
raise FileNotFoundError("git not found")
monkeypatch.setattr("subprocess.run", mock_run)
project = detect_project()
assert project["id"] == "global"
assert project["name"] == "global"
def test_detect_project_from_env(patch_globals, monkeypatch, tmp_path):
"""CLAUDE_PROJECT_DIR env var should be used as project root."""
fake_repo = tmp_path / "my-repo"
fake_repo.mkdir()
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(fake_repo))
# Mock git remote to return a URL
def mock_run(cmd, **kwargs):
if "rev-parse" in cmd:
return SimpleNamespace(returncode=0, stdout=str(fake_repo) + "\n", stderr="")
if "get-url" in cmd:
return SimpleNamespace(returncode=0, stdout="https://github.com/test/my-repo.git\n", stderr="")
return SimpleNamespace(returncode=1, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", mock_run)
project = detect_project()
assert project["id"] != "global"
assert project["name"] == "my-repo"
def test_detect_project_git_timeout(patch_globals, monkeypatch):
"""Git timeout should fall through to global."""
monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
import subprocess as sp
def mock_run(cmd, **kwargs):
raise sp.TimeoutExpired(cmd, 5)
monkeypatch.setattr("subprocess.run", mock_run)
project = detect_project()
assert project["id"] == "global"
def test_detect_project_creates_directories(patch_globals, monkeypatch, tmp_path):
"""detect_project should create the project dir structure."""
fake_repo = tmp_path / "structured-repo"
fake_repo.mkdir()
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(fake_repo))
def mock_run(cmd, **kwargs):
if "rev-parse" in cmd:
return SimpleNamespace(returncode=0, stdout=str(fake_repo) + "\n", stderr="")
if "get-url" in cmd:
return SimpleNamespace(returncode=1, stdout="", stderr="no remote")
return SimpleNamespace(returncode=1, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", mock_run)
project = detect_project()
assert project["instincts_personal"].exists()
assert project["instincts_inherited"].exists()
assert (project["evolved_dir"] / "skills").exists()
# ─────────────────────────────────────────────
# _load_instincts_from_dir tests
# ─────────────────────────────────────────────
def test_load_from_empty_dir(tmp_path):
result = _load_instincts_from_dir(tmp_path, "personal", "project")
assert result == []
def test_load_from_nonexistent_dir(tmp_path):
result = _load_instincts_from_dir(tmp_path / "does-not-exist", "personal", "project")
assert result == []
def test_load_annotates_metadata(tmp_path):
"""Loaded instincts should have _source_file, _source_type, _scope_label."""
yaml_file = tmp_path / "test.yaml"
yaml_file.write_text(SAMPLE_INSTINCT_YAML)
result = _load_instincts_from_dir(tmp_path, "personal", "project")
assert len(result) == 1
assert result[0]["_source_file"] == str(yaml_file)
assert result[0]["_source_type"] == "personal"
assert result[0]["_scope_label"] == "project"
def test_load_defaults_scope_from_label(tmp_path):
"""If an instinct has no 'scope' in frontmatter, it should default to scope_label."""
no_scope_yaml = """\
---
id: no-scope
trigger: "test"
confidence: 0.5
domain: general
---
Body.
"""
(tmp_path / "no-scope.yaml").write_text(no_scope_yaml)
result = _load_instincts_from_dir(tmp_path, "inherited", "global")
assert result[0]["scope"] == "global"
def test_load_preserves_explicit_scope(tmp_path):
"""If frontmatter has explicit scope, it should be preserved."""
yaml_file = tmp_path / "test.yaml"
yaml_file.write_text(SAMPLE_INSTINCT_YAML)
result = _load_instincts_from_dir(tmp_path, "personal", "global")
# Frontmatter says scope: project, scope_label is global
# The explicit scope should be preserved (not overwritten)
assert result[0]["scope"] == "project"
def test_load_handles_corrupt_file(tmp_path, capsys):
"""Corrupt YAML files should be warned about but not crash."""
# A file that will cause parse_instinct_file to return empty
(tmp_path / "good.yaml").write_text(SAMPLE_INSTINCT_YAML)
(tmp_path / "bad.yaml").write_text("not yaml\nno frontmatter")
result = _load_instincts_from_dir(tmp_path, "personal", "project")
# bad.yaml has no valid instincts (no id), so only good.yaml contributes
assert len(result) == 1
assert result[0]["id"] == "test-instinct"
def test_load_supports_yml_extension(tmp_path):
yml_file = tmp_path / "test.yml"
yml_file.write_text(SAMPLE_INSTINCT_YAML)
result = _load_instincts_from_dir(tmp_path, "personal", "project")
ids = {i["id"] for i in result}
assert "test-instinct" in ids
def test_load_supports_md_extension(tmp_path):
md_file = tmp_path / "legacy-instinct.md"
md_file.write_text(SAMPLE_INSTINCT_YAML)
result = _load_instincts_from_dir(tmp_path, "personal", "project")
ids = {i["id"] for i in result}
assert "test-instinct" in ids
# ─────────────────────────────────────────────
# load_all_instincts tests
# ─────────────────────────────────────────────
def test_load_all_project_and_global(patch_globals):
"""Should load from both project and global directories."""
tree = patch_globals
project = _make_project(tree)
# Write a project instinct
(project["instincts_personal"] / "proj.yaml").write_text(SAMPLE_INSTINCT_YAML)
# Write a global instinct
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
result = load_all_instincts(project)
ids = {i["id"] for i in result}
assert "test-instinct" in ids
assert "global-instinct" in ids
def test_load_all_project_overrides_global(patch_globals):
"""When project and global have same ID, project wins."""
tree = patch_globals
project = _make_project(tree)
# Same ID but different confidence
proj_yaml = SAMPLE_INSTINCT_YAML.replace("id: test-instinct", "id: shared-id")
proj_yaml = proj_yaml.replace("confidence: 0.8", "confidence: 0.9")
glob_yaml = SAMPLE_GLOBAL_INSTINCT_YAML.replace("id: global-instinct", "id: shared-id")
glob_yaml = glob_yaml.replace("confidence: 0.9", "confidence: 0.3")
(project["instincts_personal"] / "shared.yaml").write_text(proj_yaml)
(tree["global_personal"] / "shared.yaml").write_text(glob_yaml)
result = load_all_instincts(project)
shared = [i for i in result if i["id"] == "shared-id"]
assert len(shared) == 1
assert shared[0]["_scope_label"] == "project"
assert shared[0]["confidence"] == 0.9
def test_load_all_global_only(patch_globals):
"""Global project should only load global instincts."""
tree = patch_globals
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
global_project = {
"id": "global",
"name": "global",
"root": "",
"project_dir": tree["homunculus"],
"instincts_personal": tree["global_personal"],
"instincts_inherited": tree["global_inherited"],
"evolved_dir": tree["global_evolved"],
"observations_file": tree["homunculus"] / "observations.jsonl",
}
result = load_all_instincts(global_project)
assert len(result) == 1
assert result[0]["id"] == "global-instinct"
def test_load_project_only_excludes_global(patch_globals):
"""load_project_only_instincts should NOT include global instincts."""
tree = patch_globals
project = _make_project(tree)
(project["instincts_personal"] / "proj.yaml").write_text(SAMPLE_INSTINCT_YAML)
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
result = load_project_only_instincts(project)
ids = {i["id"] for i in result}
assert "test-instinct" in ids
assert "global-instinct" not in ids
def test_load_project_only_global_fallback_loads_global(patch_globals):
"""Global fallback should return global instincts for project-only queries."""
tree = patch_globals
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
global_project = {
"id": "global",
"name": "global",
"root": "",
"project_dir": tree["homunculus"],
"instincts_personal": tree["global_personal"],
"instincts_inherited": tree["global_inherited"],
"evolved_dir": tree["global_evolved"],
"observations_file": tree["homunculus"] / "observations.jsonl",
}
result = load_project_only_instincts(global_project)
assert len(result) == 1
assert result[0]["id"] == "global-instinct"
def test_load_all_empty(patch_globals):
"""No instincts at all should return empty list."""
tree = patch_globals
project = _make_project(tree)
result = load_all_instincts(project)
assert result == []
# ─────────────────────────────────────────────
# cmd_status tests
# ─────────────────────────────────────────────
def test_cmd_status_no_instincts(patch_globals, monkeypatch, capsys):
"""Status with no instincts should print fallback message."""
tree = patch_globals
project = _make_project(tree)
monkeypatch.setattr(_mod, "detect_project", lambda: project)
args = SimpleNamespace()
ret = cmd_status(args)
assert ret == 0
out = capsys.readouterr().out
assert "No instincts found." in out
def test_cmd_status_with_instincts(patch_globals, monkeypatch, capsys):
"""Status should show project and global instinct counts."""
tree = patch_globals
project = _make_project(tree)
monkeypatch.setattr(_mod, "detect_project", lambda: project)
(project["instincts_personal"] / "proj.yaml").write_text(SAMPLE_INSTINCT_YAML)
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
args = SimpleNamespace()
ret = cmd_status(args)
assert ret == 0
out = capsys.readouterr().out
assert "INSTINCT STATUS" in out
assert "Project instincts: 1" in out
assert "Global instincts: 1" in out
assert "PROJECT-SCOPED" in out
assert "GLOBAL" in out
def test_cmd_status_returns_int(patch_globals, monkeypatch):
"""cmd_status should always return an int."""
tree = patch_globals
project = _make_project(tree)
monkeypatch.setattr(_mod, "detect_project", lambda: project)
args = SimpleNamespace()
ret = cmd_status(args)
assert isinstance(ret, int)
# ─────────────────────────────────────────────
# cmd_projects tests
# ─────────────────────────────────────────────
def test_cmd_projects_empty_registry(patch_globals, capsys):
"""No projects should print helpful message."""
args = SimpleNamespace()
ret = cmd_projects(args)
assert ret == 0
out = capsys.readouterr().out
assert "No projects registered yet." in out
def test_cmd_projects_with_registry(patch_globals, capsys):
"""Should list projects from registry."""
tree = patch_globals
# Create a project dir with instincts
pid = "test123abc"
project = _make_project(tree, pid=pid, pname="my-app")
(project["instincts_personal"] / "inst.yaml").write_text(SAMPLE_INSTINCT_YAML)
# Write registry
registry = {
pid: {
"name": "my-app",
"root": "/home/user/my-app",
"remote": "https://github.com/user/my-app.git",
"last_seen": "2025-01-15T12:00:00Z",
}
}
tree["registry_file"].write_text(json.dumps(registry))
args = SimpleNamespace()
ret = cmd_projects(args)
assert ret == 0
out = capsys.readouterr().out
assert "my-app" in out
assert pid in out
assert "1 personal" in out
# ─────────────────────────────────────────────
# _promote_specific tests
# ─────────────────────────────────────────────
def test_promote_specific_not_found(patch_globals, capsys):
"""Promoting nonexistent instinct should fail."""
tree = patch_globals
project = _make_project(tree)
ret = _promote_specific(project, "nonexistent", force=True)
assert ret == 1
out = capsys.readouterr().out
assert "not found" in out
def test_promote_specific_rejects_invalid_id(patch_globals, capsys):
"""Path-like instinct IDs should be rejected before file writes."""
tree = patch_globals
project = _make_project(tree)
ret = _promote_specific(project, "../escape", force=True)
assert ret == 1
err = capsys.readouterr().err
assert "Invalid instinct ID" in err
def test_promote_specific_already_global(patch_globals, capsys):
"""Promoting an instinct that already exists globally should fail."""
tree = patch_globals
project = _make_project(tree)
# Write same-id instinct in both project and global
(project["instincts_personal"] / "shared.yaml").write_text(SAMPLE_INSTINCT_YAML)
global_yaml = SAMPLE_INSTINCT_YAML # same id: test-instinct
(tree["global_personal"] / "shared.yaml").write_text(global_yaml)
ret = _promote_specific(project, "test-instinct", force=True)
assert ret == 1
out = capsys.readouterr().out
assert "already exists in global" in out
def test_promote_specific_success(patch_globals, capsys):
"""Promote a project instinct to global with --force."""
tree = patch_globals
project = _make_project(tree)
(project["instincts_personal"] / "inst.yaml").write_text(SAMPLE_INSTINCT_YAML)
ret = _promote_specific(project, "test-instinct", force=True)
assert ret == 0
out = capsys.readouterr().out
assert "Promoted" in out
# Verify file was created in global dir
promoted_file = tree["global_personal"] / "test-instinct.yaml"
assert promoted_file.exists()
content = promoted_file.read_text()
assert "scope: global" in content
assert "promoted_from: abc123" in content
# ─────────────────────────────────────────────
# _promote_auto tests
# ─────────────────────────────────────────────
def test_promote_auto_no_candidates(patch_globals, capsys):
"""Auto-promote with no cross-project instincts should say so."""
tree = patch_globals
project = _make_project(tree)
# Empty registry
tree["registry_file"].write_text("{}")
ret = _promote_auto(project, force=True, dry_run=False)
assert ret == 0
out = capsys.readouterr().out
assert "No instincts qualify" in out
def test_promote_auto_dry_run(patch_globals, capsys):
"""Dry run should list candidates but not write files."""
tree = patch_globals
# Create two projects with the same high-confidence instinct
p1 = _make_project(tree, pid="proj1", pname="project-one")
p2 = _make_project(tree, pid="proj2", pname="project-two")
high_conf_yaml = """\
---
id: cross-project-instinct
trigger: "when reviewing"
confidence: 0.95
domain: security
scope: project
---
## Action
Always review for injection.
"""
(p1["instincts_personal"] / "cross.yaml").write_text(high_conf_yaml)
(p2["instincts_personal"] / "cross.yaml").write_text(high_conf_yaml)
# Write registry
registry = {
"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
"proj2": {"name": "project-two", "root": "/b", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
}
tree["registry_file"].write_text(json.dumps(registry))
project = p1
ret = _promote_auto(project, force=True, dry_run=True)
assert ret == 0
out = capsys.readouterr().out
assert "DRY RUN" in out
assert "cross-project-instinct" in out
# Verify no file was created
assert not (tree["global_personal"] / "cross-project-instinct.yaml").exists()
def test_promote_auto_writes_file(patch_globals, capsys):
"""Auto-promote with force should write global instinct file."""
tree = patch_globals
p1 = _make_project(tree, pid="proj1", pname="project-one")
p2 = _make_project(tree, pid="proj2", pname="project-two")
high_conf_yaml = """\
---
id: universal-pattern
trigger: "when coding"
confidence: 0.85
domain: general
scope: project
---
## Action
Use descriptive variable names.
"""
(p1["instincts_personal"] / "uni.yaml").write_text(high_conf_yaml)
(p2["instincts_personal"] / "uni.yaml").write_text(high_conf_yaml)
registry = {
"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
"proj2": {"name": "project-two", "root": "/b", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
}
tree["registry_file"].write_text(json.dumps(registry))
ret = _promote_auto(p1, force=True, dry_run=False)
assert ret == 0
promoted = tree["global_personal"] / "universal-pattern.yaml"
assert promoted.exists()
content = promoted.read_text()
assert "scope: global" in content
assert "auto-promoted" in content
def test_promote_auto_skips_invalid_id(patch_globals, capsys):
tree = patch_globals
p1 = _make_project(tree, pid="proj1", pname="project-one")
p2 = _make_project(tree, pid="proj2", pname="project-two")
bad_id_yaml = """\
---
id: ../escape
trigger: "when coding"
confidence: 0.9
domain: general
scope: project
---
## Action
Invalid id should be skipped.
"""
(p1["instincts_personal"] / "bad.yaml").write_text(bad_id_yaml)
(p2["instincts_personal"] / "bad.yaml").write_text(bad_id_yaml)
registry = {
"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
"proj2": {"name": "project-two", "root": "/b", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
}
tree["registry_file"].write_text(json.dumps(registry))
ret = _promote_auto(p1, force=True, dry_run=False)
assert ret == 0
err = capsys.readouterr().err
assert "Skipping invalid instinct ID" in err
assert not (tree["global_personal"] / "../escape.yaml").exists()
# ─────────────────────────────────────────────
# _find_cross_project_instincts tests
# ─────────────────────────────────────────────
def test_find_cross_project_empty_registry(patch_globals):
tree = patch_globals
tree["registry_file"].write_text("{}")
result = _find_cross_project_instincts()
assert result == {}
def test_find_cross_project_single_project(patch_globals):
"""Single project should return nothing (need 2+)."""
tree = patch_globals
p1 = _make_project(tree, pid="proj1", pname="project-one")
(p1["instincts_personal"] / "inst.yaml").write_text(SAMPLE_INSTINCT_YAML)
registry = {"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"}}
tree["registry_file"].write_text(json.dumps(registry))
result = _find_cross_project_instincts()
assert result == {}
def test_find_cross_project_shared_instinct(patch_globals):
"""Same instinct ID in 2 projects should be found."""
tree = patch_globals
p1 = _make_project(tree, pid="proj1", pname="project-one")
p2 = _make_project(tree, pid="proj2", pname="project-two")
(p1["instincts_personal"] / "shared.yaml").write_text(SAMPLE_INSTINCT_YAML)
(p2["instincts_personal"] / "shared.yaml").write_text(SAMPLE_INSTINCT_YAML)
registry = {
"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
"proj2": {"name": "project-two", "root": "/b", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
}
tree["registry_file"].write_text(json.dumps(registry))
result = _find_cross_project_instincts()
assert "test-instinct" in result
assert len(result["test-instinct"]) == 2
# ─────────────────────────────────────────────
# load_registry tests
# ─────────────────────────────────────────────
def test_load_registry_missing_file(patch_globals):
result = load_registry()
assert result == {}
def test_load_registry_corrupt_json(patch_globals):
tree = patch_globals
tree["registry_file"].write_text("not json at all {{{")
result = load_registry()
assert result == {}
def test_load_registry_valid(patch_globals):
tree = patch_globals
data = {"abc": {"name": "test", "root": "/test"}}
tree["registry_file"].write_text(json.dumps(data))
result = load_registry()
assert result == data
def test_validate_instinct_id():
assert _validate_instinct_id("good-id_1.0")
assert not _validate_instinct_id("../bad")
assert not _validate_instinct_id("bad/name")
assert not _validate_instinct_id(".hidden")
def test_update_registry_atomic_replaces_file(patch_globals):
tree = patch_globals
_update_registry("abc123", "demo", "/repo", "https://example.com/repo.git")
data = json.loads(tree["registry_file"].read_text())
assert "abc123" in data
leftovers = list(tree["registry_file"].parent.glob(".projects.json.tmp.*"))
assert leftovers == []

View File

@@ -1183,7 +1183,7 @@ async function runTests() {
assert.ok(hooks.hooks.PreCompact, 'Should have PreCompact hooks'); assert.ok(hooks.hooks.PreCompact, 'Should have PreCompact hooks');
})) passed++; else failed++; })) 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 hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
@@ -1191,9 +1191,14 @@ async function runTests() {
for (const entry of hookArray) { for (const entry of hookArray) {
for (const hook of entry.hooks) { for (const hook of entry.hooks) {
if (hook.type === 'command') { 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( assert.ok(
hook.command.startsWith('node'), isNode || isSkillScript,
`Hook command should start with 'node': ${hook.command.substring(0, 50)}...` `Hook command should start with 'node' or be a skill shell script: ${hook.command.substring(0, 80)}...`
); );
} }
} }