mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
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:
46
.github/workflows/copilot-setup-steps.yml
vendored
46
.github/workflows/copilot-setup-steps.yml
vendored
@@ -1,18 +1,30 @@
|
||||
steps:
|
||||
- name: Setup Go environment
|
||||
uses: actions/setup-go@v6.2.0
|
||||
name: Copilot Setup Steps
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
# The Go version to download (if necessary) and use. Supports semver spec and ranges. Be sure to enclose this option in single quotation marks.
|
||||
go-version: # optional
|
||||
# Path to the go.mod, go.work, .go-version, or .tool-versions file.
|
||||
go-version-file: # optional
|
||||
# Set this option to true if you want the action to always check for the latest available version that satisfies the version spec
|
||||
check-latest: # optional
|
||||
# Used to pull Go distributions from go-versions. Since there's a default, this is typically not supplied by the user. When running this action on github.com, the default value is sufficient. When running on GHES, you can pass a personal access token for github.com if you are experiencing rate limiting.
|
||||
token: # optional, default is ${{ github.server_url == 'https://github.com' && github.token || '' }}
|
||||
# Used to specify whether caching is needed. Set to true, if you'd like to enable caching.
|
||||
cache: # optional, default is true
|
||||
# Used to specify the path to a dependency file - go.sum
|
||||
cache-dependency-path: # optional
|
||||
# Target architecture for Go to use. Examples: x86, x64. Will use system architecture by default.
|
||||
architecture: # optional
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Verify environment
|
||||
run: |
|
||||
node --version
|
||||
npm --version
|
||||
python3 --version
|
||||
|
||||
@@ -258,6 +258,8 @@ After migration, ALL 23 commands are available:
|
||||
| `/instinct-import` | Import instincts |
|
||||
| `/instinct-export` | Export instincts |
|
||||
| `/evolve` | Cluster instincts into skills |
|
||||
| `/promote` | Promote project instincts to global scope |
|
||||
| `/projects` | List known projects and instinct stats |
|
||||
|
||||
## Available Agents
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ opencode
|
||||
| `/instinct-import` | Import instincts |
|
||||
| `/instinct-export` | Export instincts |
|
||||
| `/evolve` | Cluster instincts |
|
||||
| `/promote` | Promote project instincts |
|
||||
| `/projects` | List known projects |
|
||||
|
||||
### Plugin Hooks
|
||||
|
||||
|
||||
@@ -1,112 +1,36 @@
|
||||
---
|
||||
description: Cluster instincts into skills
|
||||
description: Analyze instincts and suggest or generate evolved structures
|
||||
agent: build
|
||||
---
|
||||
|
||||
# Evolve Command
|
||||
|
||||
Cluster related instincts into structured skills: $ARGUMENTS
|
||||
Analyze and evolve instincts in continuous-learning-v2: $ARGUMENTS
|
||||
|
||||
## Your Task
|
||||
|
||||
Analyze instincts and promote clusters to skills.
|
||||
Run:
|
||||
|
||||
## Evolution Process
|
||||
|
||||
### Step 1: Analyze Instincts
|
||||
|
||||
Group instincts by:
|
||||
- Trigger similarity
|
||||
- Action patterns
|
||||
- Category tags
|
||||
- Confidence levels
|
||||
|
||||
### Step 2: Identify Clusters
|
||||
|
||||
```
|
||||
Cluster: Error Handling
|
||||
├── Instinct: Catch specific errors (0.85)
|
||||
├── Instinct: Wrap errors with context (0.82)
|
||||
├── Instinct: Log errors with stack trace (0.78)
|
||||
└── Instinct: Return meaningful error messages (0.80)
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" evolve $ARGUMENTS
|
||||
```
|
||||
|
||||
### Step 3: Generate Skill
|
||||
If `CLAUDE_PLUGIN_ROOT` is unavailable, use:
|
||||
|
||||
When cluster has:
|
||||
- 3+ instincts
|
||||
- Average confidence > 0.75
|
||||
- Cohesive theme
|
||||
|
||||
Generate SKILL.md:
|
||||
|
||||
```markdown
|
||||
# Error Handling Skill
|
||||
|
||||
## Overview
|
||||
Patterns for robust error handling learned from session observations.
|
||||
|
||||
## Patterns
|
||||
|
||||
### 1. Catch Specific Errors
|
||||
**Trigger**: When catching errors with generic catch
|
||||
**Action**: Use specific error types
|
||||
|
||||
### 2. Wrap Errors with Context
|
||||
**Trigger**: When re-throwing errors
|
||||
**Action**: Add context with fmt.Errorf or Error.cause
|
||||
|
||||
### 3. Log with Stack Trace
|
||||
**Trigger**: When logging errors
|
||||
**Action**: Include stack trace for debugging
|
||||
|
||||
### 4. Meaningful Messages
|
||||
**Trigger**: When returning errors to users
|
||||
**Action**: Provide actionable error messages
|
||||
```bash
|
||||
python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py evolve $ARGUMENTS
|
||||
```
|
||||
|
||||
### Step 4: Archive Instincts
|
||||
## Supported Args (v2.1)
|
||||
|
||||
Move evolved instincts to `archived/` with reference to skill.
|
||||
- no args: analysis only
|
||||
- `--generate`: also generate files under `evolved/{skills,commands,agents}`
|
||||
|
||||
## Evolution Report
|
||||
## Behavior Notes
|
||||
|
||||
```
|
||||
Evolution Summary
|
||||
=================
|
||||
|
||||
Clusters Found: X
|
||||
|
||||
Cluster 1: Error Handling
|
||||
- Instincts: 5
|
||||
- Avg Confidence: 0.82
|
||||
- Status: ✅ Promoted to skill
|
||||
|
||||
Cluster 2: Testing Patterns
|
||||
- Instincts: 3
|
||||
- Avg Confidence: 0.71
|
||||
- Status: ⏳ Needs more confidence
|
||||
|
||||
Cluster 3: Git Workflow
|
||||
- Instincts: 2
|
||||
- Avg Confidence: 0.88
|
||||
- Status: ⏳ Needs more instincts
|
||||
|
||||
Skills Created:
|
||||
- skills/error-handling/SKILL.md
|
||||
|
||||
Instincts Archived: 5
|
||||
Remaining Instincts: 12
|
||||
```
|
||||
|
||||
## Thresholds
|
||||
|
||||
| Metric | Threshold |
|
||||
|--------|-----------|
|
||||
| Min instincts per cluster | 3 |
|
||||
| Min average confidence | 0.75 |
|
||||
| Min cluster cohesion | 0.6 |
|
||||
|
||||
---
|
||||
|
||||
**TIP**: Run `/evolve` periodically to graduate instincts to skills as confidence grows.
|
||||
- Uses project + global instincts for analysis.
|
||||
- Shows skill/command/agent candidates from trigger and domain clustering.
|
||||
- Shows project -> global promotion candidates.
|
||||
- With `--generate`, output path is:
|
||||
- project context: `~/.claude/homunculus/projects/<project-id>/evolved/`
|
||||
- global fallback: `~/.claude/homunculus/evolved/`
|
||||
|
||||
@@ -1,75 +1,29 @@
|
||||
---
|
||||
description: View learned instincts with confidence scores
|
||||
description: Show learned instincts (project + global) with confidence
|
||||
agent: build
|
||||
---
|
||||
|
||||
# Instinct Status Command
|
||||
|
||||
Display learned instincts and their confidence scores: $ARGUMENTS
|
||||
Show instinct status from continuous-learning-v2: $ARGUMENTS
|
||||
|
||||
## Your Task
|
||||
|
||||
Read and display instincts from the continuous-learning-v2 system.
|
||||
Run:
|
||||
|
||||
## Instinct Location
|
||||
|
||||
Global: `~/.claude/instincts/`
|
||||
Project: `.claude/instincts/`
|
||||
|
||||
## Status Display
|
||||
|
||||
### Instinct Summary
|
||||
|
||||
| Category | Count | Avg Confidence |
|
||||
|----------|-------|----------------|
|
||||
| Coding | X | 0.XX |
|
||||
| Testing | X | 0.XX |
|
||||
| Security | X | 0.XX |
|
||||
| Git | X | 0.XX |
|
||||
|
||||
### High Confidence Instincts (>0.8)
|
||||
|
||||
```
|
||||
[trigger] → [action] (confidence: 0.XX)
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" status
|
||||
```
|
||||
|
||||
### Learning Progress
|
||||
If `CLAUDE_PLUGIN_ROOT` is unavailable, use:
|
||||
|
||||
- Total instincts: X
|
||||
- This session: X
|
||||
- Promoted to skills: X
|
||||
|
||||
### Recent Instincts
|
||||
|
||||
Last 5 instincts learned:
|
||||
|
||||
1. **[timestamp]** - [trigger] → [action]
|
||||
2. **[timestamp]** - [trigger] → [action]
|
||||
...
|
||||
|
||||
## Instinct Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "instinct-123",
|
||||
"trigger": "When I see a try-catch without specific error type",
|
||||
"action": "Suggest using specific error types for better handling",
|
||||
"confidence": 0.75,
|
||||
"applications": 5,
|
||||
"successes": 4,
|
||||
"source": "session-observation",
|
||||
"timestamp": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
```bash
|
||||
python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py status
|
||||
```
|
||||
|
||||
## Confidence Calculation
|
||||
## Behavior Notes
|
||||
|
||||
```
|
||||
confidence = (successes + 1) / (applications + 2)
|
||||
```
|
||||
|
||||
Bayesian smoothing ensures new instincts don't have extreme confidence.
|
||||
|
||||
---
|
||||
|
||||
**TIP**: Use `/evolve` to cluster related instincts into skills when confidence is high.
|
||||
- Output includes both project-scoped and global instincts.
|
||||
- Project instincts override global instincts when IDs conflict.
|
||||
- Output is grouped by domain with confidence bars.
|
||||
- This command does not support extra filters in v2.1.
|
||||
|
||||
23
.opencode/commands/projects.md
Normal file
23
.opencode/commands/projects.md
Normal 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
|
||||
```
|
||||
|
||||
23
.opencode/commands/promote.md
Normal file
23
.opencode/commands/promote.md
Normal 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
|
||||
```
|
||||
|
||||
@@ -303,6 +303,14 @@
|
||||
"evolve": {
|
||||
"description": "Cluster instincts into skills",
|
||||
"template": "{file:.opencode/commands/evolve.md}\n\n$ARGUMENTS"
|
||||
},
|
||||
"promote": {
|
||||
"description": "Promote project instincts to global scope",
|
||||
"template": "{file:.opencode/commands/promote.md}\n\n$ARGUMENTS"
|
||||
},
|
||||
"projects": {
|
||||
"description": "List known projects and instinct stats",
|
||||
"template": "{file:.opencode/commands/projects.md}\n\n$ARGUMENTS"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
|
||||
@@ -985,6 +985,8 @@ OpenCode's plugin system is MORE sophisticated than Claude Code with 20+ event t
|
||||
| `/instinct-import` | Import instincts |
|
||||
| `/instinct-export` | Export instincts |
|
||||
| `/evolve` | Cluster instincts into skills |
|
||||
| `/promote` | Promote project instincts to global scope |
|
||||
| `/projects` | List known projects and instinct stats |
|
||||
| `/learn-eval` | Extract and evaluate patterns before saving |
|
||||
| `/setup-pm` | Configure package manager |
|
||||
|
||||
|
||||
@@ -287,6 +287,8 @@ everything-claude-code/
|
||||
/instinct-import <file> # 从他人导入直觉
|
||||
/instinct-export # 导出你的直觉以供分享
|
||||
/evolve # 将相关直觉聚类到技能中
|
||||
/promote # 将项目级直觉提升为全局直觉
|
||||
/projects # 查看已识别项目与直觉统计
|
||||
```
|
||||
|
||||
完整文档见 `skills/continuous-learning-v2/`。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: evolve
|
||||
description: Cluster related instincts into skills, commands, or agents
|
||||
description: Analyze instincts and suggest or generate evolved structures
|
||||
command: true
|
||||
---
|
||||
|
||||
@@ -29,9 +29,7 @@ Analyzes instincts and clusters related ones into higher-level structures:
|
||||
|
||||
```
|
||||
/evolve # Analyze all instincts and suggest evolutions
|
||||
/evolve --domain testing # Only evolve instincts in testing domain
|
||||
/evolve --dry-run # Show what would be created without creating
|
||||
/evolve --threshold 5 # Require 5+ related instincts to cluster
|
||||
/evolve --generate # Also generate files under evolved/{skills,commands,agents}
|
||||
```
|
||||
|
||||
## Evolution Rules
|
||||
@@ -78,63 +76,50 @@ Example:
|
||||
|
||||
## What to Do
|
||||
|
||||
1. Read all instincts from `~/.claude/homunculus/instincts/`
|
||||
2. Group instincts by:
|
||||
- Domain similarity
|
||||
- Trigger pattern overlap
|
||||
- Action sequence relationship
|
||||
3. For each cluster of 3+ related instincts:
|
||||
- Determine evolution type (command/skill/agent)
|
||||
- Generate the appropriate file
|
||||
- Save to `~/.claude/homunculus/evolved/{commands,skills,agents}/`
|
||||
4. Link evolved structure back to source instincts
|
||||
1. Detect current project context
|
||||
2. Read project + global instincts (project takes precedence on ID conflicts)
|
||||
3. Group instincts by trigger/domain patterns
|
||||
4. Identify:
|
||||
- Skill candidates (trigger clusters with 2+ instincts)
|
||||
- Command candidates (high-confidence workflow instincts)
|
||||
- Agent candidates (larger, high-confidence clusters)
|
||||
5. Show promotion candidates (project -> global) when applicable
|
||||
6. If `--generate` is passed, write files to:
|
||||
- Project scope: `~/.claude/homunculus/projects/<project-id>/evolved/`
|
||||
- Global fallback: `~/.claude/homunculus/evolved/`
|
||||
|
||||
## Output Format
|
||||
|
||||
```
|
||||
🧬 Evolve Analysis
|
||||
==================
|
||||
============================================================
|
||||
EVOLVE ANALYSIS - 12 instincts
|
||||
Project: my-app (a1b2c3d4e5f6)
|
||||
Project-scoped: 8 | Global: 4
|
||||
============================================================
|
||||
|
||||
Found 3 clusters ready for evolution:
|
||||
High confidence instincts (>=80%): 5
|
||||
|
||||
## Cluster 1: Database Migration Workflow
|
||||
Instincts: new-table-migration, update-schema, regenerate-types
|
||||
Type: Command
|
||||
Confidence: 85% (based on 12 observations)
|
||||
## SKILL CANDIDATES
|
||||
1. Cluster: "adding tests"
|
||||
Instincts: 3
|
||||
Avg confidence: 82%
|
||||
Domains: testing
|
||||
Scopes: project
|
||||
|
||||
Would create: /new-table command
|
||||
Files:
|
||||
- ~/.claude/homunculus/evolved/commands/new-table.md
|
||||
## COMMAND CANDIDATES (2)
|
||||
/adding-tests
|
||||
From: test-first-workflow [project]
|
||||
Confidence: 84%
|
||||
|
||||
## Cluster 2: Functional Code Style
|
||||
Instincts: prefer-functional, use-immutable, avoid-classes, pure-functions
|
||||
Type: Skill
|
||||
Confidence: 78% (based on 8 observations)
|
||||
|
||||
Would create: functional-patterns skill
|
||||
Files:
|
||||
- ~/.claude/homunculus/evolved/skills/functional-patterns.md
|
||||
|
||||
## Cluster 3: Debugging Process
|
||||
Instincts: debug-check-logs, debug-isolate, debug-reproduce, debug-verify
|
||||
Type: Agent
|
||||
Confidence: 72% (based on 6 observations)
|
||||
|
||||
Would create: debugger agent
|
||||
Files:
|
||||
- ~/.claude/homunculus/evolved/agents/debugger.md
|
||||
|
||||
---
|
||||
Run `/evolve --execute` to create these files.
|
||||
## AGENT CANDIDATES (1)
|
||||
adding-tests-agent
|
||||
Covers 3 instincts
|
||||
Avg confidence: 82%
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
- `--execute`: Actually create the evolved structures (default is preview)
|
||||
- `--dry-run`: Preview without creating
|
||||
- `--domain <name>`: Only evolve instincts in specified domain
|
||||
- `--threshold <n>`: Minimum instincts required to form cluster (default: 3)
|
||||
- `--type <command|skill|agent>`: Only create specified type
|
||||
- `--generate`: Generate evolved files in addition to analysis output
|
||||
|
||||
## Generated File Format
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: instinct-export
|
||||
description: Export instincts for sharing with teammates or other projects
|
||||
description: Export instincts from project/global scope to a file
|
||||
command: /instinct-export
|
||||
---
|
||||
|
||||
@@ -18,17 +18,18 @@ Exports instincts to a shareable format. Perfect for:
|
||||
/instinct-export --domain testing # Export only testing instincts
|
||||
/instinct-export --min-confidence 0.7 # Only export high-confidence instincts
|
||||
/instinct-export --output team-instincts.yaml
|
||||
/instinct-export --scope project --output project-instincts.yaml
|
||||
```
|
||||
|
||||
## What to Do
|
||||
|
||||
1. Read instincts from `~/.claude/homunculus/instincts/personal/`
|
||||
2. Filter based on flags
|
||||
3. Strip sensitive information:
|
||||
- Remove session IDs
|
||||
- Remove file paths (keep only patterns)
|
||||
- Remove timestamps older than "last week"
|
||||
4. Generate export file
|
||||
1. Detect current project context
|
||||
2. Load instincts by selected scope:
|
||||
- `project`: current project only
|
||||
- `global`: global only
|
||||
- `all`: project + global merged (default)
|
||||
3. Apply filters (`--domain`, `--min-confidence`)
|
||||
4. Write YAML-style export to file (or stdout if no output path provided)
|
||||
|
||||
## Output Format
|
||||
|
||||
@@ -40,52 +41,26 @@ Creates a YAML file:
|
||||
# Source: personal
|
||||
# Count: 12 instincts
|
||||
|
||||
version: "2.0"
|
||||
exported_by: "continuous-learning-v2"
|
||||
export_date: "2025-01-22T10:30:00Z"
|
||||
---
|
||||
id: prefer-functional-style
|
||||
trigger: "when writing new functions"
|
||||
confidence: 0.8
|
||||
domain: code-style
|
||||
source: session-observation
|
||||
scope: project
|
||||
project_id: a1b2c3d4e5f6
|
||||
project_name: my-app
|
||||
---
|
||||
|
||||
instincts:
|
||||
- id: prefer-functional-style
|
||||
trigger: "when writing new functions"
|
||||
action: "Use functional patterns over classes"
|
||||
confidence: 0.8
|
||||
domain: code-style
|
||||
observations: 8
|
||||
# Prefer Functional Style
|
||||
|
||||
- id: test-first-workflow
|
||||
trigger: "when adding new functionality"
|
||||
action: "Write test first, then implementation"
|
||||
confidence: 0.9
|
||||
domain: testing
|
||||
observations: 12
|
||||
|
||||
- id: grep-before-edit
|
||||
trigger: "when modifying code"
|
||||
action: "Search with Grep, confirm with Read, then Edit"
|
||||
confidence: 0.7
|
||||
domain: workflow
|
||||
observations: 6
|
||||
## Action
|
||||
Use functional patterns over classes.
|
||||
```
|
||||
|
||||
## Privacy Considerations
|
||||
|
||||
Exports include:
|
||||
- ✅ Trigger patterns
|
||||
- ✅ Actions
|
||||
- ✅ Confidence scores
|
||||
- ✅ Domains
|
||||
- ✅ Observation counts
|
||||
|
||||
Exports do NOT include:
|
||||
- ❌ Actual code snippets
|
||||
- ❌ File paths
|
||||
- ❌ Session transcripts
|
||||
- ❌ Personal identifiers
|
||||
|
||||
## Flags
|
||||
|
||||
- `--domain <name>`: Export only specified domain
|
||||
- `--min-confidence <n>`: Minimum confidence threshold (default: 0.3)
|
||||
- `--output <file>`: Output file path (default: instincts-export-YYYYMMDD.yaml)
|
||||
- `--format <yaml|json|md>`: Output format (default: yaml)
|
||||
- `--include-evidence`: Include evidence text (default: excluded)
|
||||
- `--min-confidence <n>`: Minimum confidence threshold
|
||||
- `--output <file>`: Output file path (prints to stdout when omitted)
|
||||
- `--scope <project|global|all>`: Export scope (default: `all`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: instinct-import
|
||||
description: Import instincts from teammates, Skill Creator, or other sources
|
||||
description: Import instincts from file or URL into project/global scope
|
||||
command: true
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ command: true
|
||||
Run the instinct CLI using the plugin root path:
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" import <file-or-url> [--dry-run] [--force] [--min-confidence 0.7]
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" import <file-or-url> [--dry-run] [--force] [--min-confidence 0.7] [--scope project|global]
|
||||
```
|
||||
|
||||
Or if `CLAUDE_PLUGIN_ROOT` is not set (manual installation):
|
||||
@@ -20,18 +20,15 @@ Or if `CLAUDE_PLUGIN_ROOT` is not set (manual installation):
|
||||
python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py import <file-or-url>
|
||||
```
|
||||
|
||||
Import instincts from:
|
||||
- Teammates' exports
|
||||
- Skill Creator (repo analysis)
|
||||
- Community collections
|
||||
- Previous machine backups
|
||||
Import instincts from local file paths or HTTP(S) URLs.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/instinct-import team-instincts.yaml
|
||||
/instinct-import https://github.com/org/repo/instincts.yaml
|
||||
/instinct-import --from-skill-creator acme/webapp
|
||||
/instinct-import team-instincts.yaml --dry-run
|
||||
/instinct-import team-instincts.yaml --scope global --force
|
||||
```
|
||||
|
||||
## What to Do
|
||||
@@ -40,7 +37,9 @@ Import instincts from:
|
||||
2. Parse and validate the format
|
||||
3. Check for duplicates with existing instincts
|
||||
4. Merge or add new instincts
|
||||
5. Save to `~/.claude/homunculus/instincts/inherited/`
|
||||
5. Save to inherited instincts directory:
|
||||
- Project scope: `~/.claude/homunculus/projects/<project-id>/instincts/inherited/`
|
||||
- Global scope: `~/.claude/homunculus/instincts/inherited/`
|
||||
|
||||
## Import Process
|
||||
|
||||
@@ -71,60 +70,33 @@ Already have similar instincts:
|
||||
Import: 0.9 confidence
|
||||
→ Update to import (higher confidence)
|
||||
|
||||
## Conflicting Instincts (1)
|
||||
These contradict local instincts:
|
||||
❌ use-classes-for-services
|
||||
Conflicts with: avoid-classes
|
||||
→ Skip (requires manual resolution)
|
||||
|
||||
---
|
||||
Import 8 new, update 1, skip 3?
|
||||
Import 8 new, update 1?
|
||||
```
|
||||
|
||||
## Merge Strategies
|
||||
## Merge Behavior
|
||||
|
||||
### For Duplicates
|
||||
When importing an instinct that matches an existing one:
|
||||
- **Higher confidence wins**: Keep the one with higher confidence
|
||||
- **Merge evidence**: Combine observation counts
|
||||
- **Update timestamp**: Mark as recently validated
|
||||
|
||||
### For Conflicts
|
||||
When importing an instinct that contradicts an existing one:
|
||||
- **Skip by default**: Don't import conflicting instincts
|
||||
- **Flag for review**: Mark both as needing attention
|
||||
- **Manual resolution**: User decides which to keep
|
||||
When importing an instinct with an existing ID:
|
||||
- Higher-confidence import becomes an update candidate
|
||||
- Equal/lower-confidence import is skipped
|
||||
- User confirms unless `--force` is used
|
||||
|
||||
## Source Tracking
|
||||
|
||||
Imported instincts are marked with:
|
||||
```yaml
|
||||
source: "inherited"
|
||||
source: inherited
|
||||
scope: project
|
||||
imported_from: "team-instincts.yaml"
|
||||
imported_at: "2025-01-22T10:30:00Z"
|
||||
original_source: "session-observation" # or "repo-analysis"
|
||||
project_id: "a1b2c3d4e5f6"
|
||||
project_name: "my-project"
|
||||
```
|
||||
|
||||
## Skill Creator Integration
|
||||
|
||||
When importing from Skill Creator:
|
||||
|
||||
```
|
||||
/instinct-import --from-skill-creator acme/webapp
|
||||
```
|
||||
|
||||
This fetches instincts generated from repo analysis:
|
||||
- Source: `repo-analysis`
|
||||
- Higher initial confidence (0.7+)
|
||||
- Linked to source repository
|
||||
|
||||
## Flags
|
||||
|
||||
- `--dry-run`: Preview without importing
|
||||
- `--force`: Import even if conflicts exist
|
||||
- `--merge-strategy <higher|local|import>`: How to handle duplicates
|
||||
- `--from-skill-creator <owner/repo>`: Import from Skill Creator analysis
|
||||
- `--force`: Skip confirmation prompt
|
||||
- `--min-confidence <n>`: Only import instincts above threshold
|
||||
- `--scope <project|global>`: Select target scope (default: `project`)
|
||||
|
||||
## Output
|
||||
|
||||
@@ -134,7 +106,7 @@ After import:
|
||||
|
||||
Added: 8 instincts
|
||||
Updated: 1 instinct
|
||||
Skipped: 3 instincts (2 duplicates, 1 conflict)
|
||||
Skipped: 3 instincts (equal/higher confidence already exists)
|
||||
|
||||
New instincts saved to: ~/.claude/homunculus/instincts/inherited/
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: instinct-status
|
||||
description: Show all learned instincts with their confidence levels
|
||||
description: Show learned instincts (project + global) with confidence
|
||||
command: true
|
||||
---
|
||||
|
||||
# Instinct Status Command
|
||||
|
||||
Shows all learned instincts with their confidence scores, grouped by domain.
|
||||
Shows learned instincts for the current project plus global instincts, grouped by domain.
|
||||
|
||||
## Implementation
|
||||
|
||||
@@ -26,61 +26,34 @@ python3 ~/.claude/skills/continuous-learning-v2/scripts/instinct-cli.py status
|
||||
|
||||
```
|
||||
/instinct-status
|
||||
/instinct-status --domain code-style
|
||||
/instinct-status --low-confidence
|
||||
```
|
||||
|
||||
## What to Do
|
||||
|
||||
1. Read all instinct files from `~/.claude/homunculus/instincts/personal/`
|
||||
2. Read inherited instincts from `~/.claude/homunculus/instincts/inherited/`
|
||||
3. Display them grouped by domain with confidence bars
|
||||
1. Detect current project context (git remote/path hash)
|
||||
2. Read project instincts from `~/.claude/homunculus/projects/<project-id>/instincts/`
|
||||
3. Read global instincts from `~/.claude/homunculus/instincts/`
|
||||
4. Merge with precedence rules (project overrides global when IDs collide)
|
||||
5. Display grouped by domain with confidence bars and observation stats
|
||||
|
||||
## Output Format
|
||||
|
||||
```
|
||||
📊 Instinct Status
|
||||
==================
|
||||
============================================================
|
||||
INSTINCT STATUS - 12 total
|
||||
============================================================
|
||||
|
||||
## Code Style (4 instincts)
|
||||
Project: my-app (a1b2c3d4e5f6)
|
||||
Project instincts: 8
|
||||
Global instincts: 4
|
||||
|
||||
### prefer-functional-style
|
||||
Trigger: when writing new functions
|
||||
Action: Use functional patterns over classes
|
||||
Confidence: ████████░░ 80%
|
||||
Source: session-observation | Last updated: 2025-01-22
|
||||
## PROJECT-SCOPED (my-app)
|
||||
### WORKFLOW (3)
|
||||
███████░░░ 70% grep-before-edit [project]
|
||||
trigger: when modifying code
|
||||
|
||||
### use-path-aliases
|
||||
Trigger: when importing modules
|
||||
Action: Use @/ path aliases instead of relative imports
|
||||
Confidence: ██████░░░░ 60%
|
||||
Source: repo-analysis (github.com/acme/webapp)
|
||||
|
||||
## Testing (2 instincts)
|
||||
|
||||
### test-first-workflow
|
||||
Trigger: when adding new functionality
|
||||
Action: Write test first, then implementation
|
||||
Confidence: █████████░ 90%
|
||||
Source: session-observation
|
||||
|
||||
## Workflow (3 instincts)
|
||||
|
||||
### grep-before-edit
|
||||
Trigger: when modifying code
|
||||
Action: Search with Grep, confirm with Read, then Edit
|
||||
Confidence: ███████░░░ 70%
|
||||
Source: session-observation
|
||||
|
||||
---
|
||||
Total: 9 instincts (4 personal, 5 inherited)
|
||||
Observer: Running (last analysis: 5 min ago)
|
||||
## GLOBAL (apply to all projects)
|
||||
### SECURITY (2)
|
||||
█████████░ 85% validate-user-input [global]
|
||||
trigger: when handling user input
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
- `--domain <name>`: Filter by domain (code-style, testing, git, etc.)
|
||||
- `--low-confidence`: Show only instincts with confidence < 0.5
|
||||
- `--high-confidence`: Show only instincts with confidence >= 0.7
|
||||
- `--source <type>`: Filter by source (session-observation, repo-analysis, inherited)
|
||||
- `--json`: Output as JSON for programmatic use
|
||||
|
||||
40
commands/projects.md
Normal file
40
commands/projects.md
Normal 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
42
commands/promote.md
Normal 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`
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/pre-write-doc-warn.js\""
|
||||
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const i=JSON.parse(d);const p=i.tool_input?.file_path||'';if(/\\.(md|txt)$/.test(p)&&!/(README|CLAUDE|AGENTS|CONTRIBUTING|CHANGELOG|LICENSE|SKILL)\\.md$/i.test(p)&&!/\\.claude[\\/\\\\]plans[\\/\\\\]/.test(p)&&!/(^|[\\/\\\\])(docs|skills)[\\/\\\\]/.test(p)){console.error('[Hook] WARNING: Non-standard documentation file detected');console.error('[Hook] File: '+p);console.error('[Hook] Consider consolidating into README.md or docs/ directory')}}catch{}console.log(d)})\""
|
||||
}
|
||||
],
|
||||
"description": "Doc file warning: warn about non-standard documentation files (exit code 0; warns only)"
|
||||
@@ -51,6 +51,18 @@
|
||||
}
|
||||
],
|
||||
"description": "Suggest manual compaction at logical intervals"
|
||||
},
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh",
|
||||
"async": true,
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Capture tool use observations for continuous learning"
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
@@ -129,6 +141,18 @@
|
||||
}
|
||||
],
|
||||
"description": "Warn about console.log statements after edits"
|
||||
},
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh",
|
||||
"async": true,
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Capture tool use results for continuous learning"
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
---
|
||||
name: continuous-learning-v2
|
||||
description: Instinct-based learning system that observes sessions via hooks, creates atomic instincts with confidence scoring, and evolves them into skills/commands/agents.
|
||||
description: Instinct-based learning system that observes sessions via hooks, creates atomic instincts with confidence scoring, and evolves them into skills/commands/agents. v2.1 adds project-scoped instincts to prevent cross-project contamination.
|
||||
origin: ECC
|
||||
version: 2.0.0
|
||||
version: 2.1.0
|
||||
---
|
||||
|
||||
# Continuous Learning v2 - Instinct-Based Architecture
|
||||
# Continuous Learning v2.1 - Instinct
|
||||
-Based Architecture
|
||||
|
||||
An advanced learning system that turns your Claude Code sessions into reusable knowledge through atomic "instincts" - small learned behaviors with confidence scoring.
|
||||
|
||||
Inspired in part by the Homunculus work from [humanplane](https://github.com/humanplane).
|
||||
**v2.1** adds **project-scoped instincts** — React patterns stay in your React project, Python conventions stay in your Python project, and universal patterns (like "always validate input") are shared globally.
|
||||
|
||||
## When to Activate
|
||||
|
||||
@@ -18,8 +19,21 @@ Inspired in part by the Homunculus work from [humanplane](https://github.com/hum
|
||||
- Tuning confidence thresholds for learned behaviors
|
||||
- Reviewing, exporting, or importing instinct libraries
|
||||
- Evolving instincts into full skills, commands, or agents
|
||||
- Managing project-scoped vs global instincts
|
||||
- Promoting instincts from project to global scope
|
||||
|
||||
## What's New in v2
|
||||
## What's New in v2.1
|
||||
|
||||
| Feature | v2.0 | v2.1 |
|
||||
|---------|------|------|
|
||||
| Storage | Global (~/.claude/homunculus/) | Project-scoped (projects/<hash>/) |
|
||||
| Scope | All instincts apply everywhere | Project-scoped + global |
|
||||
| Detection | None | git remote URL / repo path |
|
||||
| Promotion | N/A | Project → global when seen in 2+ projects |
|
||||
| Commands | 4 (status/evolve/export/import) | 6 (+promote/projects) |
|
||||
| Cross-project | Contamination risk | Isolated by default |
|
||||
|
||||
## What's New in v2 (vs v1)
|
||||
|
||||
| Feature | v1 | v2 |
|
||||
|---------|----|----|
|
||||
@@ -27,7 +41,7 @@ Inspired in part by the Homunculus work from [humanplane](https://github.com/hum
|
||||
| Analysis | Main context | Background agent (Haiku) |
|
||||
| Granularity | Full skills | Atomic "instincts" |
|
||||
| Confidence | None | 0.3-0.9 weighted |
|
||||
| Evolution | Direct to skill | Instincts → cluster → skill/command/agent |
|
||||
| Evolution | Direct to skill | Instincts -> cluster -> skill/command/agent |
|
||||
| Sharing | None | Export/import instincts |
|
||||
|
||||
## The Instinct Model
|
||||
@@ -41,6 +55,9 @@ trigger: "when writing new functions"
|
||||
confidence: 0.7
|
||||
domain: "code-style"
|
||||
source: "session-observation"
|
||||
scope: project
|
||||
project_id: "a1b2c3d4e5f6"
|
||||
project_name: "my-react-app"
|
||||
---
|
||||
|
||||
# Prefer Functional Style
|
||||
@@ -54,51 +71,69 @@ Use functional patterns over classes when appropriate.
|
||||
```
|
||||
|
||||
**Properties:**
|
||||
- **Atomic** — one trigger, one action
|
||||
- **Confidence-weighted** — 0.3 = tentative, 0.9 = near certain
|
||||
- **Domain-tagged** — code-style, testing, git, debugging, workflow, etc.
|
||||
- **Evidence-backed** — tracks what observations created it
|
||||
- **Atomic** -- one trigger, one action
|
||||
- **Confidence-weighted** -- 0.3 = tentative, 0.9 = near certain
|
||||
- **Domain-tagged** -- code-style, testing, git, debugging, workflow, etc.
|
||||
- **Evidence-backed** -- tracks what observations created it
|
||||
- **Scope-aware** -- `project` (default) or `global`
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Session Activity
|
||||
│
|
||||
│ Hooks capture prompts + tool use (100% reliable)
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ observations.jsonl │
|
||||
│ (prompts, tool calls, outcomes) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
│ Observer agent reads (background, Haiku)
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ PATTERN DETECTION │
|
||||
│ • User corrections → instinct │
|
||||
│ • Error resolutions → instinct │
|
||||
│ • Repeated workflows → instinct │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
│ Creates/updates
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ instincts/personal/ │
|
||||
│ • prefer-functional.md (0.7) │
|
||||
│ • always-test-first.md (0.9) │
|
||||
│ • use-zod-validation.md (0.6) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
│ /evolve clusters
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ evolved/ │
|
||||
│ • commands/new-feature.md │
|
||||
│ • skills/testing-workflow.md │
|
||||
│ • agents/refactor-specialist.md │
|
||||
└─────────────────────────────────────────┘
|
||||
Session Activity (in a git repo)
|
||||
|
|
||||
| Hooks capture prompts + tool use (100% reliable)
|
||||
| + detect project context (git remote / repo path)
|
||||
v
|
||||
+---------------------------------------------+
|
||||
| projects/<project-hash>/observations.jsonl |
|
||||
| (prompts, tool calls, outcomes, project) |
|
||||
+---------------------------------------------+
|
||||
|
|
||||
| Observer agent reads (background, Haiku)
|
||||
v
|
||||
+---------------------------------------------+
|
||||
| PATTERN DETECTION |
|
||||
| * User corrections -> instinct |
|
||||
| * Error resolutions -> instinct |
|
||||
| * Repeated workflows -> instinct |
|
||||
| * Scope decision: project or global? |
|
||||
+---------------------------------------------+
|
||||
|
|
||||
| Creates/updates
|
||||
v
|
||||
+---------------------------------------------+
|
||||
| projects/<project-hash>/instincts/personal/ |
|
||||
| * prefer-functional.yaml (0.7) [project] |
|
||||
| * use-react-hooks.yaml (0.9) [project] |
|
||||
+---------------------------------------------+
|
||||
| instincts/personal/ (GLOBAL) |
|
||||
| * always-validate-input.yaml (0.85) [global]|
|
||||
| * grep-before-edit.yaml (0.6) [global] |
|
||||
+---------------------------------------------+
|
||||
|
|
||||
| /evolve clusters + /promote
|
||||
v
|
||||
+---------------------------------------------+
|
||||
| projects/<hash>/evolved/ (project-scoped) |
|
||||
| evolved/ (global) |
|
||||
| * commands/new-feature.md |
|
||||
| * skills/testing-workflow.md |
|
||||
| * agents/refactor-specialist.md |
|
||||
+---------------------------------------------+
|
||||
```
|
||||
|
||||
## Project Detection
|
||||
|
||||
The system automatically detects your current project:
|
||||
|
||||
1. **`CLAUDE_PROJECT_DIR` env var** (highest priority)
|
||||
2. **`git remote get-url origin`** -- hashed to create a portable project ID (same repo on different machines gets the same ID)
|
||||
3. **`git rev-parse --show-toplevel`** -- fallback using repo path (machine-specific)
|
||||
4. **Global fallback** -- if no project is detected, instincts go to global scope
|
||||
|
||||
Each project gets a 12-character hash ID (e.g., `a1b2c3d4e5f6`). A registry file at `~/.claude/homunculus/projects.json` maps IDs to human-readable names.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Enable Observation Hooks
|
||||
@@ -114,14 +149,14 @@ Add to your `~/.claude/settings.json`.
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh pre"
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh"
|
||||
}]
|
||||
}],
|
||||
"PostToolUse": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh post"
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
@@ -137,14 +172,14 @@ Add to your `~/.claude/settings.json`.
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh pre"
|
||||
"command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh"
|
||||
}]
|
||||
}],
|
||||
"PostToolUse": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh post"
|
||||
"command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
@@ -153,92 +188,124 @@ Add to your `~/.claude/settings.json`.
|
||||
|
||||
### 2. Initialize Directory Structure
|
||||
|
||||
The Python CLI will create these automatically, but you can also create them manually:
|
||||
The system creates directories automatically on first use, but you can also create them manually:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude/homunculus/{instincts/{personal,inherited},evolved/{agents,skills,commands}}
|
||||
touch ~/.claude/homunculus/observations.jsonl
|
||||
# Global directories
|
||||
mkdir -p ~/.claude/homunculus/{instincts/{personal,inherited},evolved/{agents,skills,commands},projects}
|
||||
|
||||
# Project directories are auto-created when the hook first runs in a git repo
|
||||
```
|
||||
|
||||
### 3. Use the Instinct Commands
|
||||
|
||||
```bash
|
||||
/instinct-status # Show learned instincts with confidence scores
|
||||
/instinct-status # Show learned instincts (project + global)
|
||||
/evolve # Cluster related instincts into skills/commands
|
||||
/instinct-export # Export instincts for sharing
|
||||
/instinct-export # Export instincts to file
|
||||
/instinct-import # Import instincts from others
|
||||
/promote # Promote project instincts to global scope
|
||||
/projects # List all known projects and their instinct counts
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/instinct-status` | Show all learned instincts with confidence |
|
||||
| `/evolve` | Cluster related instincts into skills/commands |
|
||||
| `/instinct-export` | Export instincts for sharing |
|
||||
| `/instinct-import <file>` | Import instincts from others |
|
||||
| `/instinct-status` | Show all instincts (project-scoped + global) with confidence |
|
||||
| `/evolve` | Cluster related instincts into skills/commands, suggest promotions |
|
||||
| `/instinct-export` | Export instincts (filterable by scope/domain) |
|
||||
| `/instinct-import <file>` | Import instincts with scope control |
|
||||
| `/promote [id]` | Promote project instincts to global scope |
|
||||
| `/projects` | List all known projects and their instinct counts |
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `config.json`:
|
||||
Edit `config.json` to control the background observer:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"observation": {
|
||||
"enabled": true,
|
||||
"store_path": "~/.claude/homunculus/observations.jsonl",
|
||||
"max_file_size_mb": 10,
|
||||
"archive_after_days": 7
|
||||
},
|
||||
"instincts": {
|
||||
"personal_path": "~/.claude/homunculus/instincts/personal/",
|
||||
"inherited_path": "~/.claude/homunculus/instincts/inherited/",
|
||||
"min_confidence": 0.3,
|
||||
"auto_approve_threshold": 0.7,
|
||||
"confidence_decay_rate": 0.05
|
||||
},
|
||||
"version": "2.1",
|
||||
"observer": {
|
||||
"enabled": true,
|
||||
"model": "haiku",
|
||||
"enabled": false,
|
||||
"run_interval_minutes": 5,
|
||||
"patterns_to_detect": [
|
||||
"user_corrections",
|
||||
"error_resolutions",
|
||||
"repeated_workflows",
|
||||
"tool_preferences"
|
||||
]
|
||||
},
|
||||
"evolution": {
|
||||
"cluster_threshold": 3,
|
||||
"evolved_path": "~/.claude/homunculus/evolved/"
|
||||
"min_observations_to_analyze": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `observer.enabled` | `false` | Enable the background observer agent |
|
||||
| `observer.run_interval_minutes` | `5` | How often the observer analyzes observations |
|
||||
| `observer.min_observations_to_analyze` | `20` | Minimum observations before analysis runs |
|
||||
|
||||
Other behavior (observation capture, instinct thresholds, project scoping, promotion criteria) is configured via code defaults in `instinct-cli.py` and `observe.sh`.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
~/.claude/homunculus/
|
||||
├── identity.json # Your profile, technical level
|
||||
├── observations.jsonl # Current session observations
|
||||
├── observations.archive/ # Processed observations
|
||||
├── instincts/
|
||||
│ ├── personal/ # Auto-learned instincts
|
||||
│ └── inherited/ # Imported from others
|
||||
└── evolved/
|
||||
├── agents/ # Generated specialist agents
|
||||
├── skills/ # Generated skills
|
||||
└── commands/ # Generated commands
|
||||
+-- identity.json # Your profile, technical level
|
||||
+-- projects.json # Registry: project hash -> name/path/remote
|
||||
+-- observations.jsonl # Global observations (fallback)
|
||||
+-- instincts/
|
||||
| +-- personal/ # Global auto-learned instincts
|
||||
| +-- inherited/ # Global imported instincts
|
||||
+-- evolved/
|
||||
| +-- agents/ # Global generated agents
|
||||
| +-- skills/ # Global generated skills
|
||||
| +-- commands/ # Global generated commands
|
||||
+-- projects/
|
||||
+-- a1b2c3d4e5f6/ # Project hash (from git remote URL)
|
||||
| +-- observations.jsonl
|
||||
| +-- observations.archive/
|
||||
| +-- instincts/
|
||||
| | +-- personal/ # Project-specific auto-learned
|
||||
| | +-- inherited/ # Project-specific imported
|
||||
| +-- evolved/
|
||||
| +-- skills/
|
||||
| +-- commands/
|
||||
| +-- agents/
|
||||
+-- f6e5d4c3b2a1/ # Another project
|
||||
+-- ...
|
||||
```
|
||||
|
||||
## Integration with Skill Creator
|
||||
## Scope Decision Guide
|
||||
|
||||
When you use the [Skill Creator GitHub App](https://skill-creator.app), it now generates **both**:
|
||||
- Traditional SKILL.md files (for backward compatibility)
|
||||
- Instinct collections (for v2 learning system)
|
||||
| Pattern Type | Scope | Examples |
|
||||
|-------------|-------|---------|
|
||||
| Language/framework conventions | **project** | "Use React hooks", "Follow Django REST patterns" |
|
||||
| File structure preferences | **project** | "Tests in __tests__/", "Components in src/components/" |
|
||||
| Code style | **project** | "Use functional style", "Prefer dataclasses" |
|
||||
| Error handling strategies | **project** | "Use Result type for errors" |
|
||||
| Security practices | **global** | "Validate user input", "Sanitize SQL" |
|
||||
| General best practices | **global** | "Write tests first", "Always handle errors" |
|
||||
| Tool workflow preferences | **global** | "Grep before Edit", "Read before Write" |
|
||||
| Git practices | **global** | "Conventional commits", "Small focused commits" |
|
||||
|
||||
Instincts from repo analysis have `source: "repo-analysis"` and include the source repository URL.
|
||||
## Instinct Promotion (Project -> Global)
|
||||
|
||||
When the same instinct appears in multiple projects with high confidence, it's a candidate for promotion to global scope.
|
||||
|
||||
**Auto-promotion criteria:**
|
||||
- Same instinct ID in 2+ projects
|
||||
- Average confidence >= 0.8
|
||||
|
||||
**How to promote:**
|
||||
|
||||
```bash
|
||||
# Promote a specific instinct
|
||||
python3 instinct-cli.py promote prefer-explicit-errors
|
||||
|
||||
# Auto-promote all qualifying instincts
|
||||
python3 instinct-cli.py promote
|
||||
|
||||
# Preview without changes
|
||||
python3 instinct-cli.py promote --dry-run
|
||||
```
|
||||
|
||||
The `/evolve` command also suggests promotion candidates.
|
||||
|
||||
## Confidence Scoring
|
||||
|
||||
@@ -263,7 +330,7 @@ Confidence evolves over time:
|
||||
|
||||
## Why Hooks vs Skills for Observation?
|
||||
|
||||
> "v1 relied on skills to observe. Skills are probabilistic—they fire ~50-80% of the time based on Claude's judgment."
|
||||
> "v1 relied on skills to observe. Skills are probabilistic -- they fire ~50-80% of the time based on Claude's judgment."
|
||||
|
||||
Hooks fire **100% of the time**, deterministically. This means:
|
||||
- Every tool call is observed
|
||||
@@ -272,17 +339,19 @@ Hooks fire **100% of the time**, deterministically. This means:
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
v2 is fully compatible with v1:
|
||||
- Existing `~/.claude/skills/learned/` skills still work
|
||||
v2.1 is fully compatible with v2.0 and v1:
|
||||
- Existing global instincts in `~/.claude/homunculus/instincts/` still work as global instincts
|
||||
- Existing `~/.claude/skills/learned/` skills from v1 still work
|
||||
- Stop hook still runs (but now also feeds into v2)
|
||||
- Gradual migration path: run both in parallel
|
||||
- Gradual migration: run both in parallel
|
||||
|
||||
## Privacy
|
||||
|
||||
- Observations stay **local** on your machine
|
||||
- Only **instincts** (patterns) can be exported
|
||||
- Project-scoped instincts are isolated per project
|
||||
- Only **instincts** (patterns) can be exported — not raw observations
|
||||
- No actual code or conversation content is shared
|
||||
- You control what gets exported
|
||||
- You control what gets exported and promoted
|
||||
|
||||
## Related
|
||||
|
||||
@@ -292,4 +361,4 @@ v2 is fully compatible with v1:
|
||||
|
||||
---
|
||||
|
||||
*Instinct-based learning: teaching Claude your patterns, one observation at a time.*
|
||||
*Instinct-based learning: teaching Claude your patterns, one project at a time.*
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
---
|
||||
name: observer
|
||||
description: Background agent that analyzes session observations to detect patterns and create instincts. Uses Haiku for cost-efficiency.
|
||||
description: Background agent that analyzes session observations to detect patterns and create instincts. Uses Haiku for cost-efficiency. v2.1 adds project-scoped instincts.
|
||||
model: haiku
|
||||
run_mode: background
|
||||
---
|
||||
|
||||
# Observer Agent
|
||||
@@ -11,20 +10,21 @@ A background agent that analyzes observations from Claude Code sessions to detec
|
||||
|
||||
## When to Run
|
||||
|
||||
- After significant session activity (20+ tool calls)
|
||||
- When user runs `/analyze-patterns`
|
||||
- After enough observations accumulate (configurable, default 20)
|
||||
- On a scheduled interval (configurable, default 5 minutes)
|
||||
- When triggered by observation hook (SIGUSR1)
|
||||
- When triggered on demand via SIGUSR1 to the observer process
|
||||
|
||||
## Input
|
||||
|
||||
Reads observations from `~/.claude/homunculus/observations.jsonl`:
|
||||
Reads observations from the **project-scoped** observations file:
|
||||
- Project: `~/.claude/homunculus/projects/<project-hash>/observations.jsonl`
|
||||
- Global fallback: `~/.claude/homunculus/observations.jsonl`
|
||||
|
||||
```jsonl
|
||||
{"timestamp":"2025-01-22T10:30:00Z","event":"tool_start","session":"abc123","tool":"Edit","input":"..."}
|
||||
{"timestamp":"2025-01-22T10:30:01Z","event":"tool_complete","session":"abc123","tool":"Edit","output":"..."}
|
||||
{"timestamp":"2025-01-22T10:30:05Z","event":"tool_start","session":"abc123","tool":"Bash","input":"npm test"}
|
||||
{"timestamp":"2025-01-22T10:30:10Z","event":"tool_complete","session":"abc123","tool":"Bash","output":"All tests pass"}
|
||||
{"timestamp":"2025-01-22T10:30:00Z","event":"tool_start","session":"abc123","tool":"Edit","input":"...","project_id":"a1b2c3d4e5f6","project_name":"my-react-app"}
|
||||
{"timestamp":"2025-01-22T10:30:01Z","event":"tool_complete","session":"abc123","tool":"Edit","output":"...","project_id":"a1b2c3d4e5f6","project_name":"my-react-app"}
|
||||
{"timestamp":"2025-01-22T10:30:05Z","event":"tool_start","session":"abc123","tool":"Bash","input":"npm test","project_id":"a1b2c3d4e5f6","project_name":"my-react-app"}
|
||||
{"timestamp":"2025-01-22T10:30:10Z","event":"tool_complete","session":"abc123","tool":"Bash","output":"All tests pass","project_id":"a1b2c3d4e5f6","project_name":"my-react-app"}
|
||||
```
|
||||
|
||||
## Pattern Detection
|
||||
@@ -65,28 +65,75 @@ When certain tools are consistently preferred:
|
||||
|
||||
## Output
|
||||
|
||||
Creates/updates instincts in `~/.claude/homunculus/instincts/personal/`:
|
||||
Creates/updates instincts in the **project-scoped** instincts directory:
|
||||
- Project: `~/.claude/homunculus/projects/<project-hash>/instincts/personal/`
|
||||
- Global: `~/.claude/homunculus/instincts/personal/` (for universal patterns)
|
||||
|
||||
### Project-Scoped Instinct (default)
|
||||
|
||||
```yaml
|
||||
---
|
||||
id: prefer-grep-before-edit
|
||||
trigger: "when searching for code to modify"
|
||||
id: use-react-hooks-pattern
|
||||
trigger: "when creating React components"
|
||||
confidence: 0.65
|
||||
domain: "workflow"
|
||||
domain: "code-style"
|
||||
source: "session-observation"
|
||||
scope: project
|
||||
project_id: "a1b2c3d4e5f6"
|
||||
project_name: "my-react-app"
|
||||
---
|
||||
|
||||
# Prefer Grep Before Edit
|
||||
# Use React Hooks Pattern
|
||||
|
||||
## Action
|
||||
Always use Grep to find the exact location before using Edit.
|
||||
Always use functional components with hooks instead of class components.
|
||||
|
||||
## Evidence
|
||||
- Observed 8 times in session abc123
|
||||
- Pattern: Grep → Read → Edit sequence
|
||||
- Pattern: All new components use useState/useEffect
|
||||
- Last observed: 2025-01-22
|
||||
```
|
||||
|
||||
### Global Instinct (universal patterns)
|
||||
|
||||
```yaml
|
||||
---
|
||||
id: always-validate-user-input
|
||||
trigger: "when handling user input"
|
||||
confidence: 0.75
|
||||
domain: "security"
|
||||
source: "session-observation"
|
||||
scope: global
|
||||
---
|
||||
|
||||
# Always Validate User Input
|
||||
|
||||
## Action
|
||||
Validate and sanitize all user input before processing.
|
||||
|
||||
## Evidence
|
||||
- Observed across 3 different projects
|
||||
- Pattern: User consistently adds input validation
|
||||
- Last observed: 2025-01-22
|
||||
```
|
||||
|
||||
## Scope Decision Guide
|
||||
|
||||
When creating instincts, determine scope based on these heuristics:
|
||||
|
||||
| Pattern Type | Scope | Examples |
|
||||
|-------------|-------|---------|
|
||||
| Language/framework conventions | **project** | "Use React hooks", "Follow Django REST patterns" |
|
||||
| File structure preferences | **project** | "Tests in __tests__/", "Components in src/components/" |
|
||||
| Code style | **project** | "Use functional style", "Prefer dataclasses" |
|
||||
| Error handling strategies | **project** (usually) | "Use Result type for errors" |
|
||||
| Security practices | **global** | "Validate user input", "Sanitize SQL" |
|
||||
| General best practices | **global** | "Write tests first", "Always handle errors" |
|
||||
| Tool workflow preferences | **global** | "Grep before Edit", "Read before Write" |
|
||||
| Git practices | **global** | "Conventional commits", "Small focused commits" |
|
||||
|
||||
**When in doubt, default to `scope: project`** — it's safer to be project-specific and promote later than to contaminate the global space.
|
||||
|
||||
## Confidence Calculation
|
||||
|
||||
Initial confidence based on observation frequency:
|
||||
@@ -100,6 +147,15 @@ Confidence adjusts over time:
|
||||
- -0.1 for each contradicting observation
|
||||
- -0.02 per week without observation (decay)
|
||||
|
||||
## Instinct Promotion (Project → Global)
|
||||
|
||||
An instinct should be promoted from project-scoped to global when:
|
||||
1. The **same pattern** (by id or similar trigger) exists in **2+ different projects**
|
||||
2. Each instance has confidence **>= 0.8**
|
||||
3. The domain is in the global-friendly list (security, general-best-practices, workflow)
|
||||
|
||||
Promotion is handled by the `instinct-cli.py promote` command or the `/evolve` analysis.
|
||||
|
||||
## Important Guidelines
|
||||
|
||||
1. **Be Conservative**: Only create instincts for clear patterns (3+ observations)
|
||||
@@ -107,31 +163,36 @@ Confidence adjusts over time:
|
||||
3. **Track Evidence**: Always include what observations led to the instinct
|
||||
4. **Respect Privacy**: Never include actual code snippets, only patterns
|
||||
5. **Merge Similar**: If a new instinct is similar to existing, update rather than duplicate
|
||||
6. **Default to Project Scope**: Unless the pattern is clearly universal, make it project-scoped
|
||||
7. **Include Project Context**: Always set `project_id` and `project_name` for project-scoped instincts
|
||||
|
||||
## Example Analysis Session
|
||||
|
||||
Given observations:
|
||||
```jsonl
|
||||
{"event":"tool_start","tool":"Grep","input":"pattern: useState"}
|
||||
{"event":"tool_complete","tool":"Grep","output":"Found in 3 files"}
|
||||
{"event":"tool_start","tool":"Read","input":"src/hooks/useAuth.ts"}
|
||||
{"event":"tool_complete","tool":"Read","output":"[file content]"}
|
||||
{"event":"tool_start","tool":"Edit","input":"src/hooks/useAuth.ts..."}
|
||||
{"event":"tool_start","tool":"Grep","input":"pattern: useState","project_id":"a1b2c3","project_name":"my-app"}
|
||||
{"event":"tool_complete","tool":"Grep","output":"Found in 3 files","project_id":"a1b2c3","project_name":"my-app"}
|
||||
{"event":"tool_start","tool":"Read","input":"src/hooks/useAuth.ts","project_id":"a1b2c3","project_name":"my-app"}
|
||||
{"event":"tool_complete","tool":"Read","output":"[file content]","project_id":"a1b2c3","project_name":"my-app"}
|
||||
{"event":"tool_start","tool":"Edit","input":"src/hooks/useAuth.ts...","project_id":"a1b2c3","project_name":"my-app"}
|
||||
```
|
||||
|
||||
Analysis:
|
||||
- Detected workflow: Grep → Read → Edit
|
||||
- Frequency: Seen 5 times this session
|
||||
- **Scope decision**: This is a general workflow pattern (not project-specific) → **global**
|
||||
- Create instinct:
|
||||
- trigger: "when modifying code"
|
||||
- action: "Search with Grep, confirm with Read, then Edit"
|
||||
- confidence: 0.6
|
||||
- domain: "workflow"
|
||||
- scope: "global"
|
||||
|
||||
## Integration with Skill Creator
|
||||
|
||||
When instincts are imported from Skill Creator (repo analysis), they have:
|
||||
- `source: "repo-analysis"`
|
||||
- `source_repo: "https://github.com/..."`
|
||||
- `scope: "project"` (since they come from a specific repo)
|
||||
|
||||
These should be treated as team/project conventions with higher initial confidence (0.7+).
|
||||
|
||||
@@ -4,26 +4,79 @@
|
||||
# Starts the background observer agent that analyzes observations
|
||||
# and creates instincts. Uses Haiku model for cost efficiency.
|
||||
#
|
||||
# v2.1: Project-scoped — detects current project and analyzes
|
||||
# project-specific observations into project-scoped instincts.
|
||||
#
|
||||
# Usage:
|
||||
# start-observer.sh # Start observer in background
|
||||
# start-observer.sh # Start observer for current project (or global)
|
||||
# start-observer.sh stop # Stop running observer
|
||||
# start-observer.sh status # Check if observer is running
|
||||
|
||||
set -e
|
||||
|
||||
CONFIG_DIR="${HOME}/.claude/homunculus"
|
||||
PID_FILE="${CONFIG_DIR}/.observer.pid"
|
||||
LOG_FILE="${CONFIG_DIR}/observer.log"
|
||||
OBSERVATIONS_FILE="${CONFIG_DIR}/observations.jsonl"
|
||||
# ─────────────────────────────────────────────
|
||||
# Project detection
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Source shared project detection helper
|
||||
# This sets: PROJECT_ID, PROJECT_NAME, PROJECT_ROOT, PROJECT_DIR
|
||||
source "${SKILL_ROOT}/scripts/detect-project.sh"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Configuration
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
CONFIG_DIR="${HOME}/.claude/homunculus"
|
||||
CONFIG_FILE="${SKILL_ROOT}/config.json"
|
||||
# PID file is project-scoped so each project can have its own observer
|
||||
PID_FILE="${PROJECT_DIR}/.observer.pid"
|
||||
LOG_FILE="${PROJECT_DIR}/observer.log"
|
||||
OBSERVATIONS_FILE="${PROJECT_DIR}/observations.jsonl"
|
||||
INSTINCTS_DIR="${PROJECT_DIR}/instincts/personal"
|
||||
|
||||
# Read config values from config.json
|
||||
OBSERVER_INTERVAL_MINUTES=5
|
||||
MIN_OBSERVATIONS=20
|
||||
OBSERVER_ENABLED=false
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
_config=$(CLV2_CONFIG="$CONFIG_FILE" python3 -c "
|
||||
import json, os
|
||||
with open(os.environ['CLV2_CONFIG']) as f:
|
||||
cfg = json.load(f)
|
||||
obs = cfg.get('observer', {})
|
||||
print(obs.get('run_interval_minutes', 5))
|
||||
print(obs.get('min_observations_to_analyze', 20))
|
||||
print(str(obs.get('enabled', False)).lower())
|
||||
" 2>/dev/null || echo "5
|
||||
20
|
||||
false")
|
||||
_interval=$(echo "$_config" | sed -n '1p')
|
||||
_min_obs=$(echo "$_config" | sed -n '2p')
|
||||
_enabled=$(echo "$_config" | sed -n '3p')
|
||||
if [ "$_interval" -gt 0 ] 2>/dev/null; then
|
||||
OBSERVER_INTERVAL_MINUTES="$_interval"
|
||||
fi
|
||||
if [ "$_min_obs" -gt 0 ] 2>/dev/null; then
|
||||
MIN_OBSERVATIONS="$_min_obs"
|
||||
fi
|
||||
if [ "$_enabled" = "true" ]; then
|
||||
OBSERVER_ENABLED=true
|
||||
fi
|
||||
fi
|
||||
OBSERVER_INTERVAL_SECONDS=$((OBSERVER_INTERVAL_MINUTES * 60))
|
||||
|
||||
echo "Project: ${PROJECT_NAME} (${PROJECT_ID})"
|
||||
echo "Storage: ${PROJECT_DIR}"
|
||||
|
||||
case "${1:-start}" in
|
||||
stop)
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
pid=$(cat "$PID_FILE")
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "Stopping observer (PID: $pid)..."
|
||||
echo "Stopping observer for ${PROJECT_NAME} (PID: $pid)..."
|
||||
kill "$pid"
|
||||
rm -f "$PID_FILE"
|
||||
echo "Observer stopped."
|
||||
@@ -44,6 +97,9 @@ case "${1:-start}" in
|
||||
echo "Observer is running (PID: $pid)"
|
||||
echo "Log: $LOG_FILE"
|
||||
echo "Observations: $(wc -l < "$OBSERVATIONS_FILE" 2>/dev/null || echo 0) lines"
|
||||
# Also show instinct count
|
||||
instinct_count=$(find "$INSTINCTS_DIR" -name "*.yaml" 2>/dev/null | wc -l)
|
||||
echo "Instincts: $instinct_count"
|
||||
exit 0
|
||||
else
|
||||
echo "Observer not running (stale PID file)"
|
||||
@@ -57,17 +113,24 @@ case "${1:-start}" in
|
||||
;;
|
||||
|
||||
start)
|
||||
# Check if observer is disabled in config
|
||||
if [ "$OBSERVER_ENABLED" != "true" ]; then
|
||||
echo "Observer is disabled in config.json (observer.enabled: false)."
|
||||
echo "Set observer.enabled to true in config.json to enable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if already running
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
pid=$(cat "$PID_FILE")
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "Observer already running (PID: $pid)"
|
||||
echo "Observer already running for ${PROJECT_NAME} (PID: $pid)"
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
echo "Starting observer agent..."
|
||||
echo "Starting observer agent for ${PROJECT_NAME}..."
|
||||
|
||||
# The observer loop
|
||||
(
|
||||
@@ -79,18 +142,43 @@ case "${1:-start}" in
|
||||
return
|
||||
fi
|
||||
obs_count=$(wc -l < "$OBSERVATIONS_FILE" 2>/dev/null || echo 0)
|
||||
if [ "$obs_count" -lt 10 ]; then
|
||||
if [ "$obs_count" -lt "$MIN_OBSERVATIONS" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[$(date)] Analyzing $obs_count observations..." >> "$LOG_FILE"
|
||||
echo "[$(date)] Analyzing $obs_count observations for project ${PROJECT_NAME}..." >> "$LOG_FILE"
|
||||
|
||||
# Use Claude Code with Haiku to analyze observations
|
||||
# This spawns a quick analysis session
|
||||
# The prompt now specifies project-scoped instinct creation
|
||||
if command -v claude &> /dev/null; then
|
||||
exit_code=0
|
||||
claude --model haiku --max-turns 3 --print \
|
||||
"Read $OBSERVATIONS_FILE and identify patterns. If you find 3+ occurrences of the same pattern, create an instinct file in $CONFIG_DIR/instincts/personal/ following the format in the observer agent spec. Be conservative - only create instincts for clear patterns." \
|
||||
claude --model haiku --print \
|
||||
"Read $OBSERVATIONS_FILE and identify patterns for the project '${PROJECT_NAME}'.
|
||||
If you find 3+ occurrences of the same pattern, create an instinct file in $INSTINCTS_DIR/ following this format:
|
||||
|
||||
---
|
||||
id: <kebab-case-id>
|
||||
trigger: \"<when this happens>\"
|
||||
confidence: <0.3-0.9>
|
||||
domain: <code-style|testing|git|debugging|workflow|etc>
|
||||
source: session-observation
|
||||
scope: project
|
||||
project_id: ${PROJECT_ID}
|
||||
project_name: ${PROJECT_NAME}
|
||||
---
|
||||
|
||||
# <Title>
|
||||
|
||||
## Action
|
||||
<What to do>
|
||||
|
||||
## Evidence
|
||||
<What observations led to this>
|
||||
|
||||
Be conservative - only create instincts for clear patterns.
|
||||
If a pattern seems universal (not project-specific), set scope to 'global' instead of 'project'.
|
||||
Examples of global patterns: 'always validate user input', 'prefer explicit error handling'.
|
||||
Examples of project patterns: 'use React functional components', 'follow Django REST framework conventions'." \
|
||||
>> "$LOG_FILE" 2>&1 || exit_code=$?
|
||||
if [ "$exit_code" -ne 0 ]; then
|
||||
echo "[$(date)] Claude analysis failed (exit $exit_code)" >> "$LOG_FILE"
|
||||
@@ -101,10 +189,9 @@ case "${1:-start}" in
|
||||
|
||||
# Archive processed observations
|
||||
if [ -f "$OBSERVATIONS_FILE" ]; then
|
||||
archive_dir="${CONFIG_DIR}/observations.archive"
|
||||
archive_dir="${PROJECT_DIR}/observations.archive"
|
||||
mkdir -p "$archive_dir"
|
||||
mv "$OBSERVATIONS_FILE" "$archive_dir/processed-$(date +%Y%m%d-%H%M%S).jsonl" 2>/dev/null || true
|
||||
touch "$OBSERVATIONS_FILE"
|
||||
mv "$OBSERVATIONS_FILE" "$archive_dir/processed-$(date +%Y%m%d-%H%M%S)-$$.jsonl" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -112,11 +199,11 @@ case "${1:-start}" in
|
||||
trap 'analyze_observations' USR1
|
||||
|
||||
echo "$$" > "$PID_FILE"
|
||||
echo "[$(date)] Observer started (PID: $$)" >> "$LOG_FILE"
|
||||
echo "[$(date)] Observer started for ${PROJECT_NAME} (PID: $$)" >> "$LOG_FILE"
|
||||
|
||||
while true; do
|
||||
# Check every 5 minutes
|
||||
sleep 300
|
||||
# Check at configured interval (default: 5 minutes)
|
||||
sleep "$OBSERVER_INTERVAL_SECONDS"
|
||||
|
||||
analyze_observations
|
||||
done
|
||||
|
||||
@@ -1,41 +1,8 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"observation": {
|
||||
"enabled": true,
|
||||
"store_path": "~/.claude/homunculus/observations.jsonl",
|
||||
"max_file_size_mb": 10,
|
||||
"archive_after_days": 7,
|
||||
"capture_tools": ["Edit", "Write", "Bash", "Read", "Grep", "Glob"],
|
||||
"ignore_tools": ["TodoWrite"]
|
||||
},
|
||||
"instincts": {
|
||||
"personal_path": "~/.claude/homunculus/instincts/personal/",
|
||||
"inherited_path": "~/.claude/homunculus/instincts/inherited/",
|
||||
"min_confidence": 0.3,
|
||||
"auto_approve_threshold": 0.7,
|
||||
"confidence_decay_rate": 0.02,
|
||||
"max_instincts": 100
|
||||
},
|
||||
"version": "2.1",
|
||||
"observer": {
|
||||
"enabled": false,
|
||||
"model": "haiku",
|
||||
"run_interval_minutes": 5,
|
||||
"min_observations_to_analyze": 20,
|
||||
"patterns_to_detect": [
|
||||
"user_corrections",
|
||||
"error_resolutions",
|
||||
"repeated_workflows",
|
||||
"tool_preferences",
|
||||
"file_patterns"
|
||||
]
|
||||
},
|
||||
"evolution": {
|
||||
"cluster_threshold": 3,
|
||||
"evolved_path": "~/.claude/homunculus/evolved/",
|
||||
"auto_evolve": false
|
||||
},
|
||||
"integration": {
|
||||
"skill_creator_api": "https://skill-creator.app/api",
|
||||
"backward_compatible_v1": true
|
||||
"min_observations_to_analyze": 20
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,52 +4,20 @@
|
||||
# Captures tool use events for pattern analysis.
|
||||
# Claude Code passes hook data via stdin as JSON.
|
||||
#
|
||||
# Hook config (in ~/.claude/settings.json):
|
||||
# v2.1: Project-scoped observations — detects current project context
|
||||
# and writes observations to project-specific directory.
|
||||
#
|
||||
# If installed as a plugin, use ${CLAUDE_PLUGIN_ROOT}:
|
||||
# {
|
||||
# "hooks": {
|
||||
# "PreToolUse": [{
|
||||
# "matcher": "*",
|
||||
# "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh pre" }]
|
||||
# }],
|
||||
# "PostToolUse": [{
|
||||
# "matcher": "*",
|
||||
# "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh post" }]
|
||||
# }]
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# If installed manually to ~/.claude/skills:
|
||||
# {
|
||||
# "hooks": {
|
||||
# "PreToolUse": [{
|
||||
# "matcher": "*",
|
||||
# "hooks": [{ "type": "command", "command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh pre" }]
|
||||
# }],
|
||||
# "PostToolUse": [{
|
||||
# "matcher": "*",
|
||||
# "hooks": [{ "type": "command", "command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh post" }]
|
||||
# }]
|
||||
# }
|
||||
# }
|
||||
# Registered via plugin hooks/hooks.json (auto-loaded when plugin is enabled).
|
||||
# Can also be registered manually in ~/.claude/settings.json.
|
||||
|
||||
set -e
|
||||
|
||||
# Hook phase from CLI argument: "pre" (PreToolUse) or "post" (PostToolUse)
|
||||
HOOK_PHASE="${1:-post}"
|
||||
|
||||
CONFIG_DIR="${HOME}/.claude/homunculus"
|
||||
OBSERVATIONS_FILE="${CONFIG_DIR}/observations.jsonl"
|
||||
MAX_FILE_SIZE_MB=10
|
||||
|
||||
# Ensure directory exists
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
|
||||
# Skip if disabled
|
||||
if [ -f "$CONFIG_DIR/disabled" ]; then
|
||||
exit 0
|
||||
fi
|
||||
# ─────────────────────────────────────────────
|
||||
# Read stdin first (before project detection)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# Read JSON from stdin (Claude Code hook format)
|
||||
INPUT_JSON=$(cat)
|
||||
@@ -59,6 +27,51 @@ if [ -z "$INPUT_JSON" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Extract cwd from stdin for project detection
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# Extract cwd from the hook JSON to use for project detection.
|
||||
# This avoids spawning a separate git subprocess when cwd is available.
|
||||
STDIN_CWD=$(echo "$INPUT_JSON" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
cwd = data.get("cwd", "")
|
||||
print(cwd)
|
||||
except(KeyError, TypeError, ValueError):
|
||||
print("")
|
||||
' 2>/dev/null || echo "")
|
||||
|
||||
# If cwd was provided in stdin, use it for project detection
|
||||
if [ -n "$STDIN_CWD" ] && [ -d "$STDIN_CWD" ]; then
|
||||
export CLAUDE_PROJECT_DIR="$STDIN_CWD"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Project detection
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Source shared project detection helper
|
||||
# This sets: PROJECT_ID, PROJECT_NAME, PROJECT_ROOT, PROJECT_DIR
|
||||
source "${SKILL_ROOT}/scripts/detect-project.sh"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Configuration
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
CONFIG_DIR="${HOME}/.claude/homunculus"
|
||||
OBSERVATIONS_FILE="${PROJECT_DIR}/observations.jsonl"
|
||||
MAX_FILE_SIZE_MB=10
|
||||
|
||||
# Skip if disabled
|
||||
if [ -f "$CONFIG_DIR/disabled" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse using python via stdin pipe (safe for all JSON payloads)
|
||||
# Pass HOOK_PHASE via env var since Claude Code does not include hook type in stdin JSON
|
||||
PARSED=$(echo "$INPUT_JSON" | HOOK_PHASE="$HOOK_PHASE" python3 -c '
|
||||
@@ -80,6 +93,8 @@ try:
|
||||
tool_input = data.get("tool_input", data.get("input", {}))
|
||||
tool_output = data.get("tool_output", data.get("output", ""))
|
||||
session_id = data.get("session_id", "unknown")
|
||||
tool_use_id = data.get("tool_use_id", "")
|
||||
cwd = data.get("cwd", "")
|
||||
|
||||
# Truncate large inputs/outputs
|
||||
if isinstance(tool_input, dict):
|
||||
@@ -88,24 +103,26 @@ try:
|
||||
tool_input_str = str(tool_input)[:5000]
|
||||
|
||||
if isinstance(tool_output, dict):
|
||||
tool_output_str = json.dumps(tool_output)[:5000]
|
||||
tool_response_str = json.dumps(tool_output)[:5000]
|
||||
else:
|
||||
tool_output_str = str(tool_output)[:5000]
|
||||
tool_response_str = str(tool_output)[:5000]
|
||||
|
||||
print(json.dumps({
|
||||
"parsed": True,
|
||||
"event": event,
|
||||
"tool": tool_name,
|
||||
"input": tool_input_str if event == "tool_start" else None,
|
||||
"output": tool_output_str if event == "tool_complete" else None,
|
||||
"session": session_id
|
||||
"output": tool_response_str if event == "tool_complete" else None,
|
||||
"session": session_id,
|
||||
"tool_use_id": tool_use_id,
|
||||
"cwd": cwd
|
||||
}))
|
||||
except Exception as e:
|
||||
print(json.dumps({"parsed": False, "error": str(e)}))
|
||||
')
|
||||
|
||||
# Check if parsing succeeded
|
||||
PARSED_OK=$(echo "$PARSED" | python3 -c "import json,sys; print(json.load(sys.stdin).get('parsed', False))")
|
||||
PARSED_OK=$(echo "$PARSED" | python3 -c "import json,sys; print(json.load(sys.stdin).get('parsed', False))" 2>/dev/null || echo "False")
|
||||
|
||||
if [ "$PARSED_OK" != "True" ]; then
|
||||
# Fallback: log raw input for debugging
|
||||
@@ -119,20 +136,23 @@ print(json.dumps({'timestamp': os.environ['TIMESTAMP'], 'event': 'parse_error',
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Archive if file too large
|
||||
# Archive if file too large (atomic: rename with unique suffix to avoid race)
|
||||
if [ -f "$OBSERVATIONS_FILE" ]; then
|
||||
file_size_mb=$(du -m "$OBSERVATIONS_FILE" 2>/dev/null | cut -f1)
|
||||
if [ "${file_size_mb:-0}" -ge "$MAX_FILE_SIZE_MB" ]; then
|
||||
archive_dir="${CONFIG_DIR}/observations.archive"
|
||||
archive_dir="${PROJECT_DIR}/observations.archive"
|
||||
mkdir -p "$archive_dir"
|
||||
mv "$OBSERVATIONS_FILE" "$archive_dir/observations-$(date +%Y%m%d-%H%M%S).jsonl"
|
||||
mv "$OBSERVATIONS_FILE" "$archive_dir/observations-$(date +%Y%m%d-%H%M%S)-$$.jsonl" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build and write observation
|
||||
# Build and write observation (now includes project context)
|
||||
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
export PROJECT_ID_ENV="$PROJECT_ID"
|
||||
export PROJECT_NAME_ENV="$PROJECT_NAME"
|
||||
export TIMESTAMP="$timestamp"
|
||||
|
||||
echo "$PARSED" | python3 -c "
|
||||
import json, sys, os
|
||||
|
||||
@@ -141,10 +161,12 @@ observation = {
|
||||
'timestamp': os.environ['TIMESTAMP'],
|
||||
'event': parsed['event'],
|
||||
'tool': parsed['tool'],
|
||||
'session': parsed['session']
|
||||
'session': parsed['session'],
|
||||
'project_id': os.environ.get('PROJECT_ID_ENV', 'global'),
|
||||
'project_name': os.environ.get('PROJECT_NAME_ENV', 'global')
|
||||
}
|
||||
|
||||
if parsed['input'] is not None:
|
||||
if parsed['input']:
|
||||
observation['input'] = parsed['input']
|
||||
if parsed['output'] is not None:
|
||||
observation['output'] = parsed['output']
|
||||
@@ -152,13 +174,14 @@ if parsed['output'] is not None:
|
||||
print(json.dumps(observation))
|
||||
" >> "$OBSERVATIONS_FILE"
|
||||
|
||||
# Signal observer if running
|
||||
OBSERVER_PID_FILE="${CONFIG_DIR}/.observer.pid"
|
||||
if [ -f "$OBSERVER_PID_FILE" ]; then
|
||||
observer_pid=$(cat "$OBSERVER_PID_FILE")
|
||||
# Signal observer if running (check both project-scoped and global observer)
|
||||
for pid_file in "${PROJECT_DIR}/.observer.pid" "${CONFIG_DIR}/.observer.pid"; do
|
||||
if [ -f "$pid_file" ]; then
|
||||
observer_pid=$(cat "$pid_file")
|
||||
if kill -0 "$observer_pid" 2>/dev/null; then
|
||||
kill -USR1 "$observer_pid" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
exit 0
|
||||
|
||||
141
skills/continuous-learning-v2/scripts/detect-project.sh
Executable file
141
skills/continuous-learning-v2/scripts/detect-project.sh
Executable 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
@@ -1,7 +1,26 @@
|
||||
"""Tests for parse_instinct_file() — verifies content after frontmatter is preserved."""
|
||||
"""Tests for continuous-learning-v2 instinct-cli.py
|
||||
|
||||
Covers:
|
||||
- parse_instinct_file() — content preservation, edge cases
|
||||
- _validate_file_path() — path traversal blocking
|
||||
- detect_project() — project detection with mocked git/env
|
||||
- load_all_instincts() — loading from project + global dirs, dedup
|
||||
- _load_instincts_from_dir() — directory scanning
|
||||
- cmd_projects() — listing projects from registry
|
||||
- cmd_status() — status display
|
||||
- _promote_specific() — single instinct promotion
|
||||
- _promote_auto() — auto-promotion across projects
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
# Load instinct-cli.py (hyphenated filename requires importlib)
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
@@ -10,8 +29,125 @@ _spec = importlib.util.spec_from_file_location(
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
parse_instinct_file = _mod.parse_instinct_file
|
||||
|
||||
parse_instinct_file = _mod.parse_instinct_file
|
||||
_validate_file_path = _mod._validate_file_path
|
||||
detect_project = _mod.detect_project
|
||||
load_all_instincts = _mod.load_all_instincts
|
||||
load_project_only_instincts = _mod.load_project_only_instincts
|
||||
_load_instincts_from_dir = _mod._load_instincts_from_dir
|
||||
cmd_status = _mod.cmd_status
|
||||
cmd_projects = _mod.cmd_projects
|
||||
_promote_specific = _mod._promote_specific
|
||||
_promote_auto = _mod._promote_auto
|
||||
_find_cross_project_instincts = _mod._find_cross_project_instincts
|
||||
load_registry = _mod.load_registry
|
||||
_validate_instinct_id = _mod._validate_instinct_id
|
||||
_update_registry = _mod._update_registry
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Fixtures
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
SAMPLE_INSTINCT_YAML = """\
|
||||
---
|
||||
id: test-instinct
|
||||
trigger: "when writing tests"
|
||||
confidence: 0.8
|
||||
domain: testing
|
||||
scope: project
|
||||
---
|
||||
|
||||
## Action
|
||||
Always write tests first.
|
||||
|
||||
## Evidence
|
||||
TDD leads to better design.
|
||||
"""
|
||||
|
||||
SAMPLE_GLOBAL_INSTINCT_YAML = """\
|
||||
---
|
||||
id: global-instinct
|
||||
trigger: "always"
|
||||
confidence: 0.9
|
||||
domain: security
|
||||
scope: global
|
||||
---
|
||||
|
||||
## Action
|
||||
Validate all user input.
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_tree(tmp_path):
|
||||
"""Create a realistic project directory tree for testing."""
|
||||
homunculus = tmp_path / ".claude" / "homunculus"
|
||||
projects_dir = homunculus / "projects"
|
||||
global_personal = homunculus / "instincts" / "personal"
|
||||
global_inherited = homunculus / "instincts" / "inherited"
|
||||
global_evolved = homunculus / "evolved"
|
||||
|
||||
for d in [
|
||||
global_personal, global_inherited,
|
||||
global_evolved / "skills", global_evolved / "commands", global_evolved / "agents",
|
||||
projects_dir,
|
||||
]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return {
|
||||
"root": tmp_path,
|
||||
"homunculus": homunculus,
|
||||
"projects_dir": projects_dir,
|
||||
"global_personal": global_personal,
|
||||
"global_inherited": global_inherited,
|
||||
"global_evolved": global_evolved,
|
||||
"registry_file": homunculus / "projects.json",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_globals(project_tree, monkeypatch):
|
||||
"""Patch module-level globals to use tmp_path-based directories."""
|
||||
monkeypatch.setattr(_mod, "HOMUNCULUS_DIR", project_tree["homunculus"])
|
||||
monkeypatch.setattr(_mod, "PROJECTS_DIR", project_tree["projects_dir"])
|
||||
monkeypatch.setattr(_mod, "REGISTRY_FILE", project_tree["registry_file"])
|
||||
monkeypatch.setattr(_mod, "GLOBAL_PERSONAL_DIR", project_tree["global_personal"])
|
||||
monkeypatch.setattr(_mod, "GLOBAL_INHERITED_DIR", project_tree["global_inherited"])
|
||||
monkeypatch.setattr(_mod, "GLOBAL_EVOLVED_DIR", project_tree["global_evolved"])
|
||||
monkeypatch.setattr(_mod, "GLOBAL_OBSERVATIONS_FILE", project_tree["homunculus"] / "observations.jsonl")
|
||||
return project_tree
|
||||
|
||||
|
||||
def _make_project(tree, pid="abc123", pname="test-project"):
|
||||
"""Create project directory structure and return a project dict."""
|
||||
project_dir = tree["projects_dir"] / pid
|
||||
personal_dir = project_dir / "instincts" / "personal"
|
||||
inherited_dir = project_dir / "instincts" / "inherited"
|
||||
for d in [personal_dir, inherited_dir,
|
||||
project_dir / "evolved" / "skills",
|
||||
project_dir / "evolved" / "commands",
|
||||
project_dir / "evolved" / "agents",
|
||||
project_dir / "observations.archive"]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return {
|
||||
"id": pid,
|
||||
"name": pname,
|
||||
"root": str(tree["root"] / "fake-repo"),
|
||||
"remote": "https://github.com/test/test-project.git",
|
||||
"project_dir": project_dir,
|
||||
"instincts_personal": personal_dir,
|
||||
"instincts_inherited": inherited_dir,
|
||||
"evolved_dir": project_dir / "evolved",
|
||||
"observations_file": project_dir / "observations.jsonl",
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# parse_instinct_file tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
MULTI_SECTION = """\
|
||||
---
|
||||
@@ -80,3 +216,741 @@ domain: general
|
||||
result = parse_instinct_file(content)
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == ""
|
||||
|
||||
|
||||
def test_parse_no_id_skipped():
|
||||
"""Instincts without an 'id' field should be silently dropped."""
|
||||
content = """\
|
||||
---
|
||||
trigger: "when doing nothing"
|
||||
confidence: 0.5
|
||||
---
|
||||
|
||||
No id here.
|
||||
"""
|
||||
result = parse_instinct_file(content)
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_parse_confidence_is_float():
|
||||
content = """\
|
||||
---
|
||||
id: float-check
|
||||
trigger: "when parsing"
|
||||
confidence: 0.42
|
||||
domain: general
|
||||
---
|
||||
|
||||
Body.
|
||||
"""
|
||||
result = parse_instinct_file(content)
|
||||
assert isinstance(result[0]["confidence"], float)
|
||||
assert result[0]["confidence"] == pytest.approx(0.42)
|
||||
|
||||
|
||||
def test_parse_trigger_strips_quotes():
|
||||
content = """\
|
||||
---
|
||||
id: quote-check
|
||||
trigger: "when quoting"
|
||||
confidence: 0.5
|
||||
domain: general
|
||||
---
|
||||
|
||||
Body.
|
||||
"""
|
||||
result = parse_instinct_file(content)
|
||||
assert result[0]["trigger"] == "when quoting"
|
||||
|
||||
|
||||
def test_parse_empty_string():
|
||||
result = parse_instinct_file("")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_parse_garbage_input():
|
||||
result = parse_instinct_file("this is not yaml at all\nno frontmatter here")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# _validate_file_path tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_validate_normal_path(tmp_path):
|
||||
test_file = tmp_path / "test.yaml"
|
||||
test_file.write_text("hello")
|
||||
result = _validate_file_path(str(test_file), must_exist=True)
|
||||
assert result == test_file.resolve()
|
||||
|
||||
|
||||
def test_validate_rejects_etc():
|
||||
with pytest.raises(ValueError, match="system directory"):
|
||||
_validate_file_path("/etc/passwd")
|
||||
|
||||
|
||||
def test_validate_rejects_var_log():
|
||||
with pytest.raises(ValueError, match="system directory"):
|
||||
_validate_file_path("/var/log/syslog")
|
||||
|
||||
|
||||
def test_validate_rejects_usr():
|
||||
with pytest.raises(ValueError, match="system directory"):
|
||||
_validate_file_path("/usr/local/bin/foo")
|
||||
|
||||
|
||||
def test_validate_rejects_proc():
|
||||
with pytest.raises(ValueError, match="system directory"):
|
||||
_validate_file_path("/proc/self/status")
|
||||
|
||||
|
||||
def test_validate_must_exist_fails(tmp_path):
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
_validate_file_path(str(tmp_path / "nonexistent.yaml"), must_exist=True)
|
||||
|
||||
|
||||
def test_validate_home_expansion(tmp_path):
|
||||
"""Tilde expansion should work."""
|
||||
result = _validate_file_path("~/test.yaml")
|
||||
assert str(result).startswith(str(Path.home()))
|
||||
|
||||
|
||||
def test_validate_relative_path(tmp_path, monkeypatch):
|
||||
"""Relative paths should be resolved."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
test_file = tmp_path / "rel.yaml"
|
||||
test_file.write_text("content")
|
||||
result = _validate_file_path("rel.yaml", must_exist=True)
|
||||
assert result == test_file.resolve()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# detect_project tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_detect_project_global_fallback(patch_globals, monkeypatch):
|
||||
"""When no git and no env var, should return global project."""
|
||||
monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
|
||||
|
||||
# Mock subprocess.run to simulate git not available
|
||||
def mock_run(*args, **kwargs):
|
||||
raise FileNotFoundError("git not found")
|
||||
|
||||
monkeypatch.setattr("subprocess.run", mock_run)
|
||||
|
||||
project = detect_project()
|
||||
assert project["id"] == "global"
|
||||
assert project["name"] == "global"
|
||||
|
||||
|
||||
def test_detect_project_from_env(patch_globals, monkeypatch, tmp_path):
|
||||
"""CLAUDE_PROJECT_DIR env var should be used as project root."""
|
||||
fake_repo = tmp_path / "my-repo"
|
||||
fake_repo.mkdir()
|
||||
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(fake_repo))
|
||||
|
||||
# Mock git remote to return a URL
|
||||
def mock_run(cmd, **kwargs):
|
||||
if "rev-parse" in cmd:
|
||||
return SimpleNamespace(returncode=0, stdout=str(fake_repo) + "\n", stderr="")
|
||||
if "get-url" in cmd:
|
||||
return SimpleNamespace(returncode=0, stdout="https://github.com/test/my-repo.git\n", stderr="")
|
||||
return SimpleNamespace(returncode=1, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr("subprocess.run", mock_run)
|
||||
|
||||
project = detect_project()
|
||||
assert project["id"] != "global"
|
||||
assert project["name"] == "my-repo"
|
||||
|
||||
|
||||
def test_detect_project_git_timeout(patch_globals, monkeypatch):
|
||||
"""Git timeout should fall through to global."""
|
||||
monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
|
||||
import subprocess as sp
|
||||
|
||||
def mock_run(cmd, **kwargs):
|
||||
raise sp.TimeoutExpired(cmd, 5)
|
||||
|
||||
monkeypatch.setattr("subprocess.run", mock_run)
|
||||
|
||||
project = detect_project()
|
||||
assert project["id"] == "global"
|
||||
|
||||
|
||||
def test_detect_project_creates_directories(patch_globals, monkeypatch, tmp_path):
|
||||
"""detect_project should create the project dir structure."""
|
||||
fake_repo = tmp_path / "structured-repo"
|
||||
fake_repo.mkdir()
|
||||
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(fake_repo))
|
||||
|
||||
def mock_run(cmd, **kwargs):
|
||||
if "rev-parse" in cmd:
|
||||
return SimpleNamespace(returncode=0, stdout=str(fake_repo) + "\n", stderr="")
|
||||
if "get-url" in cmd:
|
||||
return SimpleNamespace(returncode=1, stdout="", stderr="no remote")
|
||||
return SimpleNamespace(returncode=1, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr("subprocess.run", mock_run)
|
||||
|
||||
project = detect_project()
|
||||
assert project["instincts_personal"].exists()
|
||||
assert project["instincts_inherited"].exists()
|
||||
assert (project["evolved_dir"] / "skills").exists()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# _load_instincts_from_dir tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_load_from_empty_dir(tmp_path):
|
||||
result = _load_instincts_from_dir(tmp_path, "personal", "project")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_load_from_nonexistent_dir(tmp_path):
|
||||
result = _load_instincts_from_dir(tmp_path / "does-not-exist", "personal", "project")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_load_annotates_metadata(tmp_path):
|
||||
"""Loaded instincts should have _source_file, _source_type, _scope_label."""
|
||||
yaml_file = tmp_path / "test.yaml"
|
||||
yaml_file.write_text(SAMPLE_INSTINCT_YAML)
|
||||
|
||||
result = _load_instincts_from_dir(tmp_path, "personal", "project")
|
||||
assert len(result) == 1
|
||||
assert result[0]["_source_file"] == str(yaml_file)
|
||||
assert result[0]["_source_type"] == "personal"
|
||||
assert result[0]["_scope_label"] == "project"
|
||||
|
||||
|
||||
def test_load_defaults_scope_from_label(tmp_path):
|
||||
"""If an instinct has no 'scope' in frontmatter, it should default to scope_label."""
|
||||
no_scope_yaml = """\
|
||||
---
|
||||
id: no-scope
|
||||
trigger: "test"
|
||||
confidence: 0.5
|
||||
domain: general
|
||||
---
|
||||
|
||||
Body.
|
||||
"""
|
||||
(tmp_path / "no-scope.yaml").write_text(no_scope_yaml)
|
||||
result = _load_instincts_from_dir(tmp_path, "inherited", "global")
|
||||
assert result[0]["scope"] == "global"
|
||||
|
||||
|
||||
def test_load_preserves_explicit_scope(tmp_path):
|
||||
"""If frontmatter has explicit scope, it should be preserved."""
|
||||
yaml_file = tmp_path / "test.yaml"
|
||||
yaml_file.write_text(SAMPLE_INSTINCT_YAML)
|
||||
|
||||
result = _load_instincts_from_dir(tmp_path, "personal", "global")
|
||||
# Frontmatter says scope: project, scope_label is global
|
||||
# The explicit scope should be preserved (not overwritten)
|
||||
assert result[0]["scope"] == "project"
|
||||
|
||||
|
||||
def test_load_handles_corrupt_file(tmp_path, capsys):
|
||||
"""Corrupt YAML files should be warned about but not crash."""
|
||||
# A file that will cause parse_instinct_file to return empty
|
||||
(tmp_path / "good.yaml").write_text(SAMPLE_INSTINCT_YAML)
|
||||
(tmp_path / "bad.yaml").write_text("not yaml\nno frontmatter")
|
||||
|
||||
result = _load_instincts_from_dir(tmp_path, "personal", "project")
|
||||
# bad.yaml has no valid instincts (no id), so only good.yaml contributes
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "test-instinct"
|
||||
|
||||
|
||||
def test_load_supports_yml_extension(tmp_path):
|
||||
yml_file = tmp_path / "test.yml"
|
||||
yml_file.write_text(SAMPLE_INSTINCT_YAML)
|
||||
|
||||
result = _load_instincts_from_dir(tmp_path, "personal", "project")
|
||||
ids = {i["id"] for i in result}
|
||||
assert "test-instinct" in ids
|
||||
|
||||
|
||||
def test_load_supports_md_extension(tmp_path):
|
||||
md_file = tmp_path / "legacy-instinct.md"
|
||||
md_file.write_text(SAMPLE_INSTINCT_YAML)
|
||||
|
||||
result = _load_instincts_from_dir(tmp_path, "personal", "project")
|
||||
ids = {i["id"] for i in result}
|
||||
assert "test-instinct" in ids
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# load_all_instincts tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_load_all_project_and_global(patch_globals):
|
||||
"""Should load from both project and global directories."""
|
||||
tree = patch_globals
|
||||
project = _make_project(tree)
|
||||
|
||||
# Write a project instinct
|
||||
(project["instincts_personal"] / "proj.yaml").write_text(SAMPLE_INSTINCT_YAML)
|
||||
# Write a global instinct
|
||||
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
|
||||
|
||||
result = load_all_instincts(project)
|
||||
ids = {i["id"] for i in result}
|
||||
assert "test-instinct" in ids
|
||||
assert "global-instinct" in ids
|
||||
|
||||
|
||||
def test_load_all_project_overrides_global(patch_globals):
|
||||
"""When project and global have same ID, project wins."""
|
||||
tree = patch_globals
|
||||
project = _make_project(tree)
|
||||
|
||||
# Same ID but different confidence
|
||||
proj_yaml = SAMPLE_INSTINCT_YAML.replace("id: test-instinct", "id: shared-id")
|
||||
proj_yaml = proj_yaml.replace("confidence: 0.8", "confidence: 0.9")
|
||||
glob_yaml = SAMPLE_GLOBAL_INSTINCT_YAML.replace("id: global-instinct", "id: shared-id")
|
||||
glob_yaml = glob_yaml.replace("confidence: 0.9", "confidence: 0.3")
|
||||
|
||||
(project["instincts_personal"] / "shared.yaml").write_text(proj_yaml)
|
||||
(tree["global_personal"] / "shared.yaml").write_text(glob_yaml)
|
||||
|
||||
result = load_all_instincts(project)
|
||||
shared = [i for i in result if i["id"] == "shared-id"]
|
||||
assert len(shared) == 1
|
||||
assert shared[0]["_scope_label"] == "project"
|
||||
assert shared[0]["confidence"] == 0.9
|
||||
|
||||
|
||||
def test_load_all_global_only(patch_globals):
|
||||
"""Global project should only load global instincts."""
|
||||
tree = patch_globals
|
||||
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
|
||||
|
||||
global_project = {
|
||||
"id": "global",
|
||||
"name": "global",
|
||||
"root": "",
|
||||
"project_dir": tree["homunculus"],
|
||||
"instincts_personal": tree["global_personal"],
|
||||
"instincts_inherited": tree["global_inherited"],
|
||||
"evolved_dir": tree["global_evolved"],
|
||||
"observations_file": tree["homunculus"] / "observations.jsonl",
|
||||
}
|
||||
|
||||
result = load_all_instincts(global_project)
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "global-instinct"
|
||||
|
||||
|
||||
def test_load_project_only_excludes_global(patch_globals):
|
||||
"""load_project_only_instincts should NOT include global instincts."""
|
||||
tree = patch_globals
|
||||
project = _make_project(tree)
|
||||
|
||||
(project["instincts_personal"] / "proj.yaml").write_text(SAMPLE_INSTINCT_YAML)
|
||||
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
|
||||
|
||||
result = load_project_only_instincts(project)
|
||||
ids = {i["id"] for i in result}
|
||||
assert "test-instinct" in ids
|
||||
assert "global-instinct" not in ids
|
||||
|
||||
|
||||
def test_load_project_only_global_fallback_loads_global(patch_globals):
|
||||
"""Global fallback should return global instincts for project-only queries."""
|
||||
tree = patch_globals
|
||||
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
|
||||
|
||||
global_project = {
|
||||
"id": "global",
|
||||
"name": "global",
|
||||
"root": "",
|
||||
"project_dir": tree["homunculus"],
|
||||
"instincts_personal": tree["global_personal"],
|
||||
"instincts_inherited": tree["global_inherited"],
|
||||
"evolved_dir": tree["global_evolved"],
|
||||
"observations_file": tree["homunculus"] / "observations.jsonl",
|
||||
}
|
||||
|
||||
result = load_project_only_instincts(global_project)
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "global-instinct"
|
||||
|
||||
|
||||
def test_load_all_empty(patch_globals):
|
||||
"""No instincts at all should return empty list."""
|
||||
tree = patch_globals
|
||||
project = _make_project(tree)
|
||||
|
||||
result = load_all_instincts(project)
|
||||
assert result == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# cmd_status tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_cmd_status_no_instincts(patch_globals, monkeypatch, capsys):
|
||||
"""Status with no instincts should print fallback message."""
|
||||
tree = patch_globals
|
||||
project = _make_project(tree)
|
||||
monkeypatch.setattr(_mod, "detect_project", lambda: project)
|
||||
|
||||
args = SimpleNamespace()
|
||||
ret = cmd_status(args)
|
||||
assert ret == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "No instincts found." in out
|
||||
|
||||
|
||||
def test_cmd_status_with_instincts(patch_globals, monkeypatch, capsys):
|
||||
"""Status should show project and global instinct counts."""
|
||||
tree = patch_globals
|
||||
project = _make_project(tree)
|
||||
monkeypatch.setattr(_mod, "detect_project", lambda: project)
|
||||
|
||||
(project["instincts_personal"] / "proj.yaml").write_text(SAMPLE_INSTINCT_YAML)
|
||||
(tree["global_personal"] / "glob.yaml").write_text(SAMPLE_GLOBAL_INSTINCT_YAML)
|
||||
|
||||
args = SimpleNamespace()
|
||||
ret = cmd_status(args)
|
||||
assert ret == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "INSTINCT STATUS" in out
|
||||
assert "Project instincts: 1" in out
|
||||
assert "Global instincts: 1" in out
|
||||
assert "PROJECT-SCOPED" in out
|
||||
assert "GLOBAL" in out
|
||||
|
||||
|
||||
def test_cmd_status_returns_int(patch_globals, monkeypatch):
|
||||
"""cmd_status should always return an int."""
|
||||
tree = patch_globals
|
||||
project = _make_project(tree)
|
||||
monkeypatch.setattr(_mod, "detect_project", lambda: project)
|
||||
|
||||
args = SimpleNamespace()
|
||||
ret = cmd_status(args)
|
||||
assert isinstance(ret, int)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# cmd_projects tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_cmd_projects_empty_registry(patch_globals, capsys):
|
||||
"""No projects should print helpful message."""
|
||||
args = SimpleNamespace()
|
||||
ret = cmd_projects(args)
|
||||
assert ret == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "No projects registered yet." in out
|
||||
|
||||
|
||||
def test_cmd_projects_with_registry(patch_globals, capsys):
|
||||
"""Should list projects from registry."""
|
||||
tree = patch_globals
|
||||
|
||||
# Create a project dir with instincts
|
||||
pid = "test123abc"
|
||||
project = _make_project(tree, pid=pid, pname="my-app")
|
||||
(project["instincts_personal"] / "inst.yaml").write_text(SAMPLE_INSTINCT_YAML)
|
||||
|
||||
# Write registry
|
||||
registry = {
|
||||
pid: {
|
||||
"name": "my-app",
|
||||
"root": "/home/user/my-app",
|
||||
"remote": "https://github.com/user/my-app.git",
|
||||
"last_seen": "2025-01-15T12:00:00Z",
|
||||
}
|
||||
}
|
||||
tree["registry_file"].write_text(json.dumps(registry))
|
||||
|
||||
args = SimpleNamespace()
|
||||
ret = cmd_projects(args)
|
||||
assert ret == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "my-app" in out
|
||||
assert pid in out
|
||||
assert "1 personal" in out
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# _promote_specific tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_promote_specific_not_found(patch_globals, capsys):
|
||||
"""Promoting nonexistent instinct should fail."""
|
||||
tree = patch_globals
|
||||
project = _make_project(tree)
|
||||
|
||||
ret = _promote_specific(project, "nonexistent", force=True)
|
||||
assert ret == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "not found" in out
|
||||
|
||||
|
||||
def test_promote_specific_rejects_invalid_id(patch_globals, capsys):
|
||||
"""Path-like instinct IDs should be rejected before file writes."""
|
||||
tree = patch_globals
|
||||
project = _make_project(tree)
|
||||
|
||||
ret = _promote_specific(project, "../escape", force=True)
|
||||
assert ret == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "Invalid instinct ID" in err
|
||||
|
||||
|
||||
def test_promote_specific_already_global(patch_globals, capsys):
|
||||
"""Promoting an instinct that already exists globally should fail."""
|
||||
tree = patch_globals
|
||||
project = _make_project(tree)
|
||||
|
||||
# Write same-id instinct in both project and global
|
||||
(project["instincts_personal"] / "shared.yaml").write_text(SAMPLE_INSTINCT_YAML)
|
||||
global_yaml = SAMPLE_INSTINCT_YAML # same id: test-instinct
|
||||
(tree["global_personal"] / "shared.yaml").write_text(global_yaml)
|
||||
|
||||
ret = _promote_specific(project, "test-instinct", force=True)
|
||||
assert ret == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "already exists in global" in out
|
||||
|
||||
|
||||
def test_promote_specific_success(patch_globals, capsys):
|
||||
"""Promote a project instinct to global with --force."""
|
||||
tree = patch_globals
|
||||
project = _make_project(tree)
|
||||
|
||||
(project["instincts_personal"] / "inst.yaml").write_text(SAMPLE_INSTINCT_YAML)
|
||||
|
||||
ret = _promote_specific(project, "test-instinct", force=True)
|
||||
assert ret == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Promoted" in out
|
||||
|
||||
# Verify file was created in global dir
|
||||
promoted_file = tree["global_personal"] / "test-instinct.yaml"
|
||||
assert promoted_file.exists()
|
||||
content = promoted_file.read_text()
|
||||
assert "scope: global" in content
|
||||
assert "promoted_from: abc123" in content
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# _promote_auto tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_promote_auto_no_candidates(patch_globals, capsys):
|
||||
"""Auto-promote with no cross-project instincts should say so."""
|
||||
tree = patch_globals
|
||||
project = _make_project(tree)
|
||||
|
||||
# Empty registry
|
||||
tree["registry_file"].write_text("{}")
|
||||
|
||||
ret = _promote_auto(project, force=True, dry_run=False)
|
||||
assert ret == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "No instincts qualify" in out
|
||||
|
||||
|
||||
def test_promote_auto_dry_run(patch_globals, capsys):
|
||||
"""Dry run should list candidates but not write files."""
|
||||
tree = patch_globals
|
||||
|
||||
# Create two projects with the same high-confidence instinct
|
||||
p1 = _make_project(tree, pid="proj1", pname="project-one")
|
||||
p2 = _make_project(tree, pid="proj2", pname="project-two")
|
||||
|
||||
high_conf_yaml = """\
|
||||
---
|
||||
id: cross-project-instinct
|
||||
trigger: "when reviewing"
|
||||
confidence: 0.95
|
||||
domain: security
|
||||
scope: project
|
||||
---
|
||||
|
||||
## Action
|
||||
Always review for injection.
|
||||
"""
|
||||
(p1["instincts_personal"] / "cross.yaml").write_text(high_conf_yaml)
|
||||
(p2["instincts_personal"] / "cross.yaml").write_text(high_conf_yaml)
|
||||
|
||||
# Write registry
|
||||
registry = {
|
||||
"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
|
||||
"proj2": {"name": "project-two", "root": "/b", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
|
||||
}
|
||||
tree["registry_file"].write_text(json.dumps(registry))
|
||||
|
||||
project = p1
|
||||
ret = _promote_auto(project, force=True, dry_run=True)
|
||||
assert ret == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "DRY RUN" in out
|
||||
assert "cross-project-instinct" in out
|
||||
|
||||
# Verify no file was created
|
||||
assert not (tree["global_personal"] / "cross-project-instinct.yaml").exists()
|
||||
|
||||
|
||||
def test_promote_auto_writes_file(patch_globals, capsys):
|
||||
"""Auto-promote with force should write global instinct file."""
|
||||
tree = patch_globals
|
||||
|
||||
p1 = _make_project(tree, pid="proj1", pname="project-one")
|
||||
p2 = _make_project(tree, pid="proj2", pname="project-two")
|
||||
|
||||
high_conf_yaml = """\
|
||||
---
|
||||
id: universal-pattern
|
||||
trigger: "when coding"
|
||||
confidence: 0.85
|
||||
domain: general
|
||||
scope: project
|
||||
---
|
||||
|
||||
## Action
|
||||
Use descriptive variable names.
|
||||
"""
|
||||
(p1["instincts_personal"] / "uni.yaml").write_text(high_conf_yaml)
|
||||
(p2["instincts_personal"] / "uni.yaml").write_text(high_conf_yaml)
|
||||
|
||||
registry = {
|
||||
"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
|
||||
"proj2": {"name": "project-two", "root": "/b", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
|
||||
}
|
||||
tree["registry_file"].write_text(json.dumps(registry))
|
||||
|
||||
ret = _promote_auto(p1, force=True, dry_run=False)
|
||||
assert ret == 0
|
||||
|
||||
promoted = tree["global_personal"] / "universal-pattern.yaml"
|
||||
assert promoted.exists()
|
||||
content = promoted.read_text()
|
||||
assert "scope: global" in content
|
||||
assert "auto-promoted" in content
|
||||
|
||||
|
||||
def test_promote_auto_skips_invalid_id(patch_globals, capsys):
|
||||
tree = patch_globals
|
||||
|
||||
p1 = _make_project(tree, pid="proj1", pname="project-one")
|
||||
p2 = _make_project(tree, pid="proj2", pname="project-two")
|
||||
|
||||
bad_id_yaml = """\
|
||||
---
|
||||
id: ../escape
|
||||
trigger: "when coding"
|
||||
confidence: 0.9
|
||||
domain: general
|
||||
scope: project
|
||||
---
|
||||
|
||||
## Action
|
||||
Invalid id should be skipped.
|
||||
"""
|
||||
(p1["instincts_personal"] / "bad.yaml").write_text(bad_id_yaml)
|
||||
(p2["instincts_personal"] / "bad.yaml").write_text(bad_id_yaml)
|
||||
|
||||
registry = {
|
||||
"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
|
||||
"proj2": {"name": "project-two", "root": "/b", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
|
||||
}
|
||||
tree["registry_file"].write_text(json.dumps(registry))
|
||||
|
||||
ret = _promote_auto(p1, force=True, dry_run=False)
|
||||
assert ret == 0
|
||||
err = capsys.readouterr().err
|
||||
assert "Skipping invalid instinct ID" in err
|
||||
assert not (tree["global_personal"] / "../escape.yaml").exists()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# _find_cross_project_instincts tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_find_cross_project_empty_registry(patch_globals):
|
||||
tree = patch_globals
|
||||
tree["registry_file"].write_text("{}")
|
||||
result = _find_cross_project_instincts()
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_find_cross_project_single_project(patch_globals):
|
||||
"""Single project should return nothing (need 2+)."""
|
||||
tree = patch_globals
|
||||
p1 = _make_project(tree, pid="proj1", pname="project-one")
|
||||
(p1["instincts_personal"] / "inst.yaml").write_text(SAMPLE_INSTINCT_YAML)
|
||||
|
||||
registry = {"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"}}
|
||||
tree["registry_file"].write_text(json.dumps(registry))
|
||||
|
||||
result = _find_cross_project_instincts()
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_find_cross_project_shared_instinct(patch_globals):
|
||||
"""Same instinct ID in 2 projects should be found."""
|
||||
tree = patch_globals
|
||||
p1 = _make_project(tree, pid="proj1", pname="project-one")
|
||||
p2 = _make_project(tree, pid="proj2", pname="project-two")
|
||||
|
||||
(p1["instincts_personal"] / "shared.yaml").write_text(SAMPLE_INSTINCT_YAML)
|
||||
(p2["instincts_personal"] / "shared.yaml").write_text(SAMPLE_INSTINCT_YAML)
|
||||
|
||||
registry = {
|
||||
"proj1": {"name": "project-one", "root": "/a", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
|
||||
"proj2": {"name": "project-two", "root": "/b", "remote": "", "last_seen": "2025-01-01T00:00:00Z"},
|
||||
}
|
||||
tree["registry_file"].write_text(json.dumps(registry))
|
||||
|
||||
result = _find_cross_project_instincts()
|
||||
assert "test-instinct" in result
|
||||
assert len(result["test-instinct"]) == 2
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# load_registry tests
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_load_registry_missing_file(patch_globals):
|
||||
result = load_registry()
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_load_registry_corrupt_json(patch_globals):
|
||||
tree = patch_globals
|
||||
tree["registry_file"].write_text("not json at all {{{")
|
||||
result = load_registry()
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_load_registry_valid(patch_globals):
|
||||
tree = patch_globals
|
||||
data = {"abc": {"name": "test", "root": "/test"}}
|
||||
tree["registry_file"].write_text(json.dumps(data))
|
||||
result = load_registry()
|
||||
assert result == data
|
||||
|
||||
|
||||
def test_validate_instinct_id():
|
||||
assert _validate_instinct_id("good-id_1.0")
|
||||
assert not _validate_instinct_id("../bad")
|
||||
assert not _validate_instinct_id("bad/name")
|
||||
assert not _validate_instinct_id(".hidden")
|
||||
|
||||
|
||||
def test_update_registry_atomic_replaces_file(patch_globals):
|
||||
tree = patch_globals
|
||||
_update_registry("abc123", "demo", "/repo", "https://example.com/repo.git")
|
||||
data = json.loads(tree["registry_file"].read_text())
|
||||
assert "abc123" in data
|
||||
leftovers = list(tree["registry_file"].parent.glob(".projects.json.tmp.*"))
|
||||
assert leftovers == []
|
||||
|
||||
@@ -1183,7 +1183,7 @@ async function runTests() {
|
||||
assert.ok(hooks.hooks.PreCompact, 'Should have PreCompact hooks');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('all hook commands use node', () => {
|
||||
if (test('all hook commands use node or are skill shell scripts', () => {
|
||||
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
|
||||
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
|
||||
|
||||
@@ -1191,9 +1191,14 @@ async function runTests() {
|
||||
for (const entry of hookArray) {
|
||||
for (const hook of entry.hooks) {
|
||||
if (hook.type === 'command') {
|
||||
const isNode = hook.command.startsWith('node');
|
||||
const isSkillScript = hook.command.includes('/skills/') && (
|
||||
/^(bash|sh)\s/.test(hook.command) ||
|
||||
hook.command.startsWith('${CLAUDE_PLUGIN_ROOT}/skills/')
|
||||
);
|
||||
assert.ok(
|
||||
hook.command.startsWith('node'),
|
||||
`Hook command should start with 'node': ${hook.command.substring(0, 50)}...`
|
||||
isNode || isSkillScript,
|
||||
`Hook command should start with 'node' or be a skill shell script: ${hook.command.substring(0, 80)}...`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user