From 5818e8adc71eaa83e8e24a00f3dda9694f1971e9 Mon Sep 17 00:00:00 2001 From: Harry Kwok <149236454+harrykwokdev@users.noreply.github.com> Date: Mon, 2 Mar 2026 04:07:13 +0800 Subject: [PATCH] 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 --- .github/workflows/copilot-setup-steps.yml | 48 +- .opencode/MIGRATION.md | 2 + .opencode/README.md | 2 + .opencode/commands/evolve.md | 112 +-- .opencode/commands/instinct-status.md | 72 +- .opencode/commands/projects.md | 23 + .opencode/commands/promote.md | 23 + .opencode/opencode.json | 8 + README.md | 2 + README.zh-CN.md | 2 + commands/evolve.md | 83 +- commands/instinct-export.md | 75 +- commands/instinct-import.md | 70 +- commands/instinct-status.md | 69 +- commands/projects.md | 40 + commands/promote.md | 42 + hooks/hooks.json | 26 +- skills/continuous-learning-v2/SKILL.md | 283 +++--- .../continuous-learning-v2/agents/observer.md | 105 ++- .../agents/start-observer.sh | 127 ++- skills/continuous-learning-v2/config.json | 37 +- .../continuous-learning-v2/hooks/observe.sh | 137 +-- .../scripts/detect-project.sh | 141 +++ .../scripts/instinct-cli.py | 771 +++++++++++++-- .../scripts/test_parse_instinct.py | 878 +++++++++++++++++- tests/hooks/hooks.test.js | 11 +- 26 files changed, 2476 insertions(+), 713 deletions(-) create mode 100644 .opencode/commands/projects.md create mode 100644 .opencode/commands/promote.md create mode 100644 commands/projects.md create mode 100644 commands/promote.md create mode 100755 skills/continuous-learning-v2/scripts/detect-project.sh diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index f9593bbf..a9b9ccb2 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -1,18 +1,30 @@ -steps: - - name: Setup Go environment - uses: actions/setup-go@v6.2.0 - with: - # The Go version to download (if necessary) and use. Supports semver spec and ranges. Be sure to enclose this option in single quotation marks. - go-version: # optional - # Path to the go.mod, go.work, .go-version, or .tool-versions file. - go-version-file: # optional - # Set this option to true if you want the action to always check for the latest available version that satisfies the version spec - check-latest: # optional - # Used to pull Go distributions from go-versions. Since there's a default, this is typically not supplied by the user. When running this action on github.com, the default value is sufficient. When running on GHES, you can pass a personal access token for github.com if you are experiencing rate limiting. - token: # optional, default is ${{ github.server_url == 'https://github.com' && github.token || '' }} - # Used to specify whether caching is needed. Set to true, if you'd like to enable caching. - cache: # optional, default is true - # Used to specify the path to a dependency file - go.sum - cache-dependency-path: # optional - # Target architecture for Go to use. Examples: x86, x64. Will use system architecture by default. - architecture: # optional +name: Copilot Setup Steps + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Verify environment + run: | + node --version + npm --version + python3 --version diff --git a/.opencode/MIGRATION.md b/.opencode/MIGRATION.md index 914b0e2f..2277d7ae 100644 --- a/.opencode/MIGRATION.md +++ b/.opencode/MIGRATION.md @@ -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 diff --git a/.opencode/README.md b/.opencode/README.md index 6ee8e272..2c20e781 100644 --- a/.opencode/README.md +++ b/.opencode/README.md @@ -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 diff --git a/.opencode/commands/evolve.md b/.opencode/commands/evolve.md index 5c4c75a1..6f344a53 100644 --- a/.opencode/commands/evolve.md +++ b/.opencode/commands/evolve.md @@ -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//evolved/` + - global fallback: `~/.claude/homunculus/evolved/` diff --git a/.opencode/commands/instinct-status.md b/.opencode/commands/instinct-status.md index 7890c413..5ca7ce8a 100644 --- a/.opencode/commands/instinct-status.md +++ b/.opencode/commands/instinct-status.md @@ -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. diff --git a/.opencode/commands/projects.md b/.opencode/commands/projects.md new file mode 100644 index 00000000..77785d01 --- /dev/null +++ b/.opencode/commands/projects.md @@ -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 +``` + diff --git a/.opencode/commands/promote.md b/.opencode/commands/promote.md new file mode 100644 index 00000000..566a662e --- /dev/null +++ b/.opencode/commands/promote.md @@ -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 +``` + diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 97a6d120..476aadce 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -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": { diff --git a/README.md b/README.md index 42cc7f7a..935d8dd2 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README.zh-CN.md b/README.zh-CN.md index 0952df8e..ef72d195 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -287,6 +287,8 @@ everything-claude-code/ /instinct-import # 从他人导入直觉 /instinct-export # 导出你的直觉以供分享 /evolve # 将相关直觉聚类到技能中 +/promote # 将项目级直觉提升为全局直觉 +/projects # 查看已识别项目与直觉统计 ``` 完整文档见 `skills/continuous-learning-v2/`。 diff --git a/commands/evolve.md b/commands/evolve.md index 8d4b96c4..467458e7 100644 --- a/commands/evolve.md +++ b/commands/evolve.md @@ -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//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 `: Only evolve instincts in specified domain -- `--threshold `: Minimum instincts required to form cluster (default: 3) -- `--type `: Only create specified type +- `--generate`: Generate evolved files in addition to analysis output ## Generated File Format diff --git a/commands/instinct-export.md b/commands/instinct-export.md index a93f4e23..6a47fa44 100644 --- a/commands/instinct-export.md +++ b/commands/instinct-export.md @@ -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 `: Export only specified domain -- `--min-confidence `: Minimum confidence threshold (default: 0.3) -- `--output `: Output file path (default: instincts-export-YYYYMMDD.yaml) -- `--format `: Output format (default: yaml) -- `--include-evidence`: Include evidence text (default: excluded) +- `--min-confidence `: Minimum confidence threshold +- `--output `: Output file path (prints to stdout when omitted) +- `--scope `: Export scope (default: `all`) diff --git a/commands/instinct-import.md b/commands/instinct-import.md index 0dea62ba..f56f7fb8 100644 --- a/commands/instinct-import.md +++ b/commands/instinct-import.md @@ -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 [--dry-run] [--force] [--min-confidence 0.7] +python3 "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/scripts/instinct-cli.py" import [--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 ``` -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//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 `: How to handle duplicates -- `--from-skill-creator `: Import from Skill Creator analysis +- `--force`: Skip confirmation prompt - `--min-confidence `: Only import instincts above threshold +- `--scope `: 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/ diff --git a/commands/instinct-status.md b/commands/instinct-status.md index 346ed476..c54f8022 100644 --- a/commands/instinct-status.md +++ b/commands/instinct-status.md @@ -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//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 `: 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 `: Filter by source (session-observation, repo-analysis, inherited) -- `--json`: Output as JSON for programmatic use diff --git a/commands/projects.md b/commands/projects.md new file mode 100644 index 00000000..8fa924ea --- /dev/null +++ b/commands/projects.md @@ -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 + diff --git a/commands/promote.md b/commands/promote.md new file mode 100644 index 00000000..4f02e27d --- /dev/null +++ b/commands/promote.md @@ -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` + diff --git a/hooks/hooks.json b/hooks/hooks.json index 26c8e6d7..7ff868a8 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -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": [ diff --git a/skills/continuous-learning-v2/SKILL.md b/skills/continuous-learning-v2/SKILL.md index 8e256386..ee1b3391 100644 --- a/skills/continuous-learning-v2/SKILL.md +++ b/skills/continuous-learning-v2/SKILL.md @@ -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//) | +| 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//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//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//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 ` | 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 ` | 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.* diff --git a/skills/continuous-learning-v2/agents/observer.md b/skills/continuous-learning-v2/agents/observer.md index 79bcd534..81abb9c1 100644 --- a/skills/continuous-learning-v2/agents/observer.md +++ b/skills/continuous-learning-v2/agents/observer.md @@ -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//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//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+). diff --git a/skills/continuous-learning-v2/agents/start-observer.sh b/skills/continuous-learning-v2/agents/start-observer.sh index 6ba6f11f..99b25099 100755 --- a/skills/continuous-learning-v2/agents/start-observer.sh +++ b/skills/continuous-learning-v2/agents/start-observer.sh @@ -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: +trigger: \"\" +confidence: <0.3-0.9> +domain: +source: session-observation +scope: project +project_id: ${PROJECT_ID} +project_name: ${PROJECT_NAME} +--- + +# + +## 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 diff --git a/skills/continuous-learning-v2/config.json b/skills/continuous-learning-v2/config.json index 1f6e0c8d..84f62209 100644 --- a/skills/continuous-learning-v2/config.json +++ b/skills/continuous-learning-v2/config.json @@ -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 } } diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index 86cfb22b..7f78f801 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -4,52 +4,20 @@ # Captures tool use events for pattern analysis. # Claude Code passes hook data via stdin as JSON. # -# Hook config (in ~/.claude/settings.json): +# v2.1: Project-scoped observations — detects current project context +# and writes observations to project-specific directory. # -# If installed as a plugin, use ${CLAUDE_PLUGIN_ROOT}: -# { -# "hooks": { -# "PreToolUse": [{ -# "matcher": "*", -# "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh pre" }] -# }], -# "PostToolUse": [{ -# "matcher": "*", -# "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/skills/continuous-learning-v2/hooks/observe.sh post" }] -# }] -# } -# } -# -# If installed manually to ~/.claude/skills: -# { -# "hooks": { -# "PreToolUse": [{ -# "matcher": "*", -# "hooks": [{ "type": "command", "command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh pre" }] -# }], -# "PostToolUse": [{ -# "matcher": "*", -# "hooks": [{ "type": "command", "command": "~/.claude/skills/continuous-learning-v2/hooks/observe.sh post" }] -# }] -# } -# } +# Registered via plugin hooks/hooks.json (auto-loaded when plugin is enabled). +# Can also be registered manually in ~/.claude/settings.json. set -e # Hook phase from CLI argument: "pre" (PreToolUse) or "post" (PostToolUse) HOOK_PHASE="${1:-post}" -CONFIG_DIR="${HOME}/.claude/homunculus" -OBSERVATIONS_FILE="${CONFIG_DIR}/observations.jsonl" -MAX_FILE_SIZE_MB=10 - -# Ensure directory exists -mkdir -p "$CONFIG_DIR" - -# Skip if disabled -if [ -f "$CONFIG_DIR/disabled" ]; then - exit 0 -fi +# ───────────────────────────────────────────── +# Read stdin first (before project detection) +# ───────────────────────────────────────────── # Read JSON from stdin (Claude Code hook format) INPUT_JSON=$(cat) @@ -59,6 +27,51 @@ if [ -z "$INPUT_JSON" ]; then exit 0 fi +# ───────────────────────────────────────────── +# Extract cwd from stdin for project detection +# ───────────────────────────────────────────── + +# Extract cwd from the hook JSON to use for project detection. +# This avoids spawning a separate git subprocess when cwd is available. +STDIN_CWD=$(echo "$INPUT_JSON" | python3 -c ' +import json, sys +try: + data = json.load(sys.stdin) + cwd = data.get("cwd", "") + print(cwd) +except(KeyError, TypeError, ValueError): + print("") +' 2>/dev/null || echo "") + +# If cwd was provided in stdin, use it for project detection +if [ -n "$STDIN_CWD" ] && [ -d "$STDIN_CWD" ]; then + export CLAUDE_PROJECT_DIR="$STDIN_CWD" +fi + +# ───────────────────────────────────────────── +# Project detection +# ───────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Source shared project detection helper +# This sets: PROJECT_ID, PROJECT_NAME, PROJECT_ROOT, PROJECT_DIR +source "${SKILL_ROOT}/scripts/detect-project.sh" + +# ───────────────────────────────────────────── +# Configuration +# ───────────────────────────────────────────── + +CONFIG_DIR="${HOME}/.claude/homunculus" +OBSERVATIONS_FILE="${PROJECT_DIR}/observations.jsonl" +MAX_FILE_SIZE_MB=10 + +# Skip if disabled +if [ -f "$CONFIG_DIR/disabled" ]; then + exit 0 +fi + # Parse using python via stdin pipe (safe for all JSON payloads) # Pass HOOK_PHASE via env var since Claude Code does not include hook type in stdin JSON PARSED=$(echo "$INPUT_JSON" | HOOK_PHASE="$HOOK_PHASE" python3 -c ' @@ -80,6 +93,8 @@ try: tool_input = data.get("tool_input", data.get("input", {})) tool_output = data.get("tool_output", data.get("output", "")) session_id = data.get("session_id", "unknown") + tool_use_id = data.get("tool_use_id", "") + cwd = data.get("cwd", "") # Truncate large inputs/outputs if isinstance(tool_input, dict): @@ -88,24 +103,26 @@ try: tool_input_str = str(tool_input)[:5000] if isinstance(tool_output, dict): - tool_output_str = json.dumps(tool_output)[:5000] + tool_response_str = json.dumps(tool_output)[:5000] else: - tool_output_str = str(tool_output)[:5000] + tool_response_str = str(tool_output)[:5000] print(json.dumps({ "parsed": True, "event": event, "tool": tool_name, "input": tool_input_str if event == "tool_start" else None, - "output": tool_output_str if event == "tool_complete" else None, - "session": session_id + "output": tool_response_str if event == "tool_complete" else None, + "session": session_id, + "tool_use_id": tool_use_id, + "cwd": cwd })) except Exception as e: print(json.dumps({"parsed": False, "error": str(e)})) ') # Check if parsing succeeded -PARSED_OK=$(echo "$PARSED" | python3 -c "import json,sys; print(json.load(sys.stdin).get('parsed', False))") +PARSED_OK=$(echo "$PARSED" | python3 -c "import json,sys; print(json.load(sys.stdin).get('parsed', False))" 2>/dev/null || echo "False") if [ "$PARSED_OK" != "True" ]; then # Fallback: log raw input for debugging @@ -119,20 +136,23 @@ print(json.dumps({'timestamp': os.environ['TIMESTAMP'], 'event': 'parse_error', exit 0 fi -# Archive if file too large +# Archive if file too large (atomic: rename with unique suffix to avoid race) if [ -f "$OBSERVATIONS_FILE" ]; then file_size_mb=$(du -m "$OBSERVATIONS_FILE" 2>/dev/null | cut -f1) if [ "${file_size_mb:-0}" -ge "$MAX_FILE_SIZE_MB" ]; then - archive_dir="${CONFIG_DIR}/observations.archive" + archive_dir="${PROJECT_DIR}/observations.archive" mkdir -p "$archive_dir" - mv "$OBSERVATIONS_FILE" "$archive_dir/observations-$(date +%Y%m%d-%H%M%S).jsonl" + mv "$OBSERVATIONS_FILE" "$archive_dir/observations-$(date +%Y%m%d-%H%M%S)-$$.jsonl" 2>/dev/null || true fi fi -# Build and write observation +# Build and write observation (now includes project context) timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +export PROJECT_ID_ENV="$PROJECT_ID" +export PROJECT_NAME_ENV="$PROJECT_NAME" export TIMESTAMP="$timestamp" + echo "$PARSED" | python3 -c " import json, sys, os @@ -141,10 +161,12 @@ observation = { 'timestamp': os.environ['TIMESTAMP'], 'event': parsed['event'], 'tool': parsed['tool'], - 'session': parsed['session'] + 'session': parsed['session'], + 'project_id': os.environ.get('PROJECT_ID_ENV', 'global'), + 'project_name': os.environ.get('PROJECT_NAME_ENV', 'global') } -if parsed['input'] is not None: +if parsed['input']: observation['input'] = parsed['input'] if parsed['output'] is not None: observation['output'] = parsed['output'] @@ -152,13 +174,14 @@ if parsed['output'] is not None: print(json.dumps(observation)) " >> "$OBSERVATIONS_FILE" -# Signal observer if running -OBSERVER_PID_FILE="${CONFIG_DIR}/.observer.pid" -if [ -f "$OBSERVER_PID_FILE" ]; then - observer_pid=$(cat "$OBSERVER_PID_FILE") - if kill -0 "$observer_pid" 2>/dev/null; then - kill -USR1 "$observer_pid" 2>/dev/null || true +# Signal observer if running (check both project-scoped and global observer) +for pid_file in "${PROJECT_DIR}/.observer.pid" "${CONFIG_DIR}/.observer.pid"; do + if [ -f "$pid_file" ]; then + observer_pid=$(cat "$pid_file") + if kill -0 "$observer_pid" 2>/dev/null; then + kill -USR1 "$observer_pid" 2>/dev/null || true + fi fi -fi +done exit 0 diff --git a/skills/continuous-learning-v2/scripts/detect-project.sh b/skills/continuous-learning-v2/scripts/detect-project.sh new file mode 100755 index 00000000..31703a21 --- /dev/null +++ b/skills/continuous-learning-v2/scripts/detect-project.sh @@ -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" diff --git a/skills/continuous-learning-v2/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index ed6c376b..0d0192b9 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -2,21 +2,28 @@ """ Instinct CLI - Manage instincts for Continuous Learning v2 +v2.1: Project-scoped instincts — different projects get different instincts, + with global instincts applied universally. + Commands: - status - Show all instincts and their status + status - Show all instincts (project + global) and their status import - Import instincts from file or URL export - Export instincts to file evolve - Cluster instincts into skills/commands/agents + promote - Promote project instincts to global scope + projects - List all known projects and their instinct counts """ import argparse import json +import hashlib import os +import subprocess import sys import re import urllib.request from pathlib import Path -from datetime import datetime +from datetime import datetime, timezone from collections import defaultdict from typing import Optional @@ -25,15 +32,188 @@ from typing import Optional # ───────────────────────────────────────────── HOMUNCULUS_DIR = Path.home() / ".claude" / "homunculus" -INSTINCTS_DIR = HOMUNCULUS_DIR / "instincts" -PERSONAL_DIR = INSTINCTS_DIR / "personal" -INHERITED_DIR = INSTINCTS_DIR / "inherited" -EVOLVED_DIR = HOMUNCULUS_DIR / "evolved" -OBSERVATIONS_FILE = HOMUNCULUS_DIR / "observations.jsonl" +PROJECTS_DIR = HOMUNCULUS_DIR / "projects" +REGISTRY_FILE = HOMUNCULUS_DIR / "projects.json" -# Ensure directories exist -for d in [PERSONAL_DIR, INHERITED_DIR, EVOLVED_DIR / "skills", EVOLVED_DIR / "commands", EVOLVED_DIR / "agents"]: - d.mkdir(parents=True, exist_ok=True) +# Global (non-project-scoped) paths +GLOBAL_INSTINCTS_DIR = HOMUNCULUS_DIR / "instincts" +GLOBAL_PERSONAL_DIR = GLOBAL_INSTINCTS_DIR / "personal" +GLOBAL_INHERITED_DIR = GLOBAL_INSTINCTS_DIR / "inherited" +GLOBAL_EVOLVED_DIR = HOMUNCULUS_DIR / "evolved" +GLOBAL_OBSERVATIONS_FILE = HOMUNCULUS_DIR / "observations.jsonl" + +# Thresholds for auto-promotion +PROMOTE_CONFIDENCE_THRESHOLD = 0.8 +PROMOTE_MIN_PROJECTS = 2 +ALLOWED_INSTINCT_EXTENSIONS = (".yaml", ".yml", ".md") + +# Ensure global directories exist (deferred to avoid side effects at import time) +def _ensure_global_dirs(): + for d in [GLOBAL_PERSONAL_DIR, GLOBAL_INHERITED_DIR, + GLOBAL_EVOLVED_DIR / "skills", GLOBAL_EVOLVED_DIR / "commands", GLOBAL_EVOLVED_DIR / "agents", + PROJECTS_DIR]: + d.mkdir(parents=True, exist_ok=True) + + +# ───────────────────────────────────────────── +# Path Validation +# ───────────────────────────────────────────── + +def _validate_file_path(path_str: str, must_exist: bool = False) -> Path: + """Validate and resolve a file path, guarding against path traversal. + + Raises ValueError if the path is invalid or suspicious. + """ + path = Path(path_str).expanduser().resolve() + + # Block paths that escape into system directories + # We block specific system paths but allow temp dirs (/var/folders on macOS) + blocked_prefixes = [ + "/etc", "/usr", "/bin", "/sbin", "/proc", "/sys", + "/var/log", "/var/run", "/var/lib", "/var/spool", + # macOS resolves /etc → /private/etc + "/private/etc", + "/private/var/log", "/private/var/run", "/private/var/db", + ] + path_s = str(path) + for prefix in blocked_prefixes: + if path_s.startswith(prefix + "/") or path_s == prefix: + raise ValueError(f"Path '{path}' targets a system directory") + + if must_exist and not path.exists(): + raise ValueError(f"Path does not exist: {path}") + + return path + + +def _validate_instinct_id(instinct_id: str) -> bool: + """Validate instinct IDs before using them in filenames.""" + if not instinct_id or len(instinct_id) > 128: + return False + if "/" in instinct_id or "\\" in instinct_id: + return False + if ".." in instinct_id: + return False + if instinct_id.startswith("."): + return False + return bool(re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*$", instinct_id)) + + +# ───────────────────────────────────────────── +# Project Detection (Python equivalent of detect-project.sh) +# ───────────────────────────────────────────── + +def detect_project() -> dict: + """Detect current project context. Returns dict with id, name, root, project_dir.""" + project_root = None + + # 1. CLAUDE_PROJECT_DIR env var + env_dir = os.environ.get("CLAUDE_PROJECT_DIR") + if env_dir and os.path.isdir(env_dir): + project_root = env_dir + + # 2. git repo root + if not project_root: + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + project_root = result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + # 3. No project — global fallback + if not project_root: + return { + "id": "global", + "name": "global", + "root": "", + "project_dir": HOMUNCULUS_DIR, + "instincts_personal": GLOBAL_PERSONAL_DIR, + "instincts_inherited": GLOBAL_INHERITED_DIR, + "evolved_dir": GLOBAL_EVOLVED_DIR, + "observations_file": GLOBAL_OBSERVATIONS_FILE, + } + + project_name = os.path.basename(project_root) + + # Derive project ID from git remote URL or path + remote_url = "" + try: + result = subprocess.run( + ["git", "-C", project_root, "remote", "get-url", "origin"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + remote_url = result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + hash_source = remote_url if remote_url else project_root + project_id = hashlib.sha256(hash_source.encode()).hexdigest()[:12] + + project_dir = PROJECTS_DIR / project_id + + # Ensure project directory structure + for d in [ + project_dir / "instincts" / "personal", + project_dir / "instincts" / "inherited", + project_dir / "observations.archive", + project_dir / "evolved" / "skills", + project_dir / "evolved" / "commands", + project_dir / "evolved" / "agents", + ]: + d.mkdir(parents=True, exist_ok=True) + + # Update registry + _update_registry(project_id, project_name, project_root, remote_url) + + return { + "id": project_id, + "name": project_name, + "root": project_root, + "remote": remote_url, + "project_dir": project_dir, + "instincts_personal": project_dir / "instincts" / "personal", + "instincts_inherited": project_dir / "instincts" / "inherited", + "evolved_dir": project_dir / "evolved", + "observations_file": project_dir / "observations.jsonl", + } + + +def _update_registry(pid: str, pname: str, proot: str, premote: str) -> None: + """Update the projects.json registry.""" + try: + with open(REGISTRY_FILE) as f: + registry = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + registry = {} + + registry[pid] = { + "name": pname, + "root": proot, + "remote": premote, + "last_seen": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + } + + REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp_file = REGISTRY_FILE.parent / f".{REGISTRY_FILE.name}.tmp.{os.getpid()}" + with open(tmp_file, "w") as f: + json.dump(registry, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_file, REGISTRY_FILE) + + +def load_registry() -> dict: + """Load the projects registry.""" + try: + with open(REGISTRY_FILE) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} # ───────────────────────────────────────────── @@ -81,107 +261,180 @@ def parse_instinct_file(content: str) -> list[dict]: return [i for i in instincts if i.get('id')] -def load_all_instincts() -> list[dict]: - """Load all instincts from personal and inherited directories.""" +def _load_instincts_from_dir(directory: Path, source_type: str, scope_label: str) -> list[dict]: + """Load instincts from a single directory.""" + instincts = [] + if not directory.exists(): + return instincts + files = [ + file for file in sorted(directory.iterdir()) + if file.is_file() and file.suffix.lower() in ALLOWED_INSTINCT_EXTENSIONS + ] + for file in files: + try: + content = file.read_text() + parsed = parse_instinct_file(content) + for inst in parsed: + inst['_source_file'] = str(file) + inst['_source_type'] = source_type + inst['_scope_label'] = scope_label + # Default scope if not set in frontmatter + if 'scope' not in inst: + inst['scope'] = scope_label + instincts.extend(parsed) + except Exception as e: + print(f"Warning: Failed to parse {file}: {e}", file=sys.stderr) + return instincts + + +def load_all_instincts(project: dict, include_global: bool = True) -> list[dict]: + """Load all instincts: project-scoped + global. + + Project-scoped instincts take precedence over global ones when IDs conflict. + """ instincts = [] - for directory in [PERSONAL_DIR, INHERITED_DIR]: - if not directory.exists(): - continue - yaml_files = sorted( - set(directory.glob("*.yaml")) - | set(directory.glob("*.yml")) - | set(directory.glob("*.md")) - ) - for file in yaml_files: - try: - content = file.read_text() - parsed = parse_instinct_file(content) - for inst in parsed: - inst['_source_file'] = str(file) - inst['_source_type'] = directory.name - instincts.extend(parsed) - except Exception as e: - print(f"Warning: Failed to parse {file}: {e}", file=sys.stderr) + # 1. Load project-scoped instincts (if not already global) + if project["id"] != "global": + instincts.extend(_load_instincts_from_dir( + project["instincts_personal"], "personal", "project" + )) + instincts.extend(_load_instincts_from_dir( + project["instincts_inherited"], "inherited", "project" + )) + + # 2. Load global instincts + if include_global: + global_instincts = [] + global_instincts.extend(_load_instincts_from_dir( + GLOBAL_PERSONAL_DIR, "personal", "global" + )) + global_instincts.extend(_load_instincts_from_dir( + GLOBAL_INHERITED_DIR, "inherited", "global" + )) + + # Deduplicate: project-scoped wins over global when same ID + project_ids = {i.get('id') for i in instincts} + for gi in global_instincts: + if gi.get('id') not in project_ids: + instincts.append(gi) return instincts +def load_project_only_instincts(project: dict) -> list[dict]: + """Load only project-scoped instincts (no global). + + In global fallback mode (no git project), returns global instincts. + """ + if project.get("id") == "global": + instincts = _load_instincts_from_dir(GLOBAL_PERSONAL_DIR, "personal", "global") + instincts += _load_instincts_from_dir(GLOBAL_INHERITED_DIR, "inherited", "global") + return instincts + return load_all_instincts(project, include_global=False) + + # ───────────────────────────────────────────── # Status Command # ───────────────────────────────────────────── -def cmd_status(args): - """Show status of all instincts.""" - instincts = load_all_instincts() +def cmd_status(args) -> int: + """Show status of all instincts (project + global).""" + project = detect_project() + instincts = load_all_instincts(project) if not instincts: print("No instincts found.") - print(f"\nInstinct directories:") - print(f" Personal: {PERSONAL_DIR}") - print(f" Inherited: {INHERITED_DIR}") - return + print(f"\nProject: {project['name']} ({project['id']})") + print(f" Project instincts: {project['instincts_personal']}") + print(f" Global instincts: {GLOBAL_PERSONAL_DIR}") + return 0 - # Group by domain - by_domain = defaultdict(list) - for inst in instincts: - domain = inst.get('domain', 'general') - by_domain[domain].append(inst) + # Split by scope + project_instincts = [i for i in instincts if i.get('_scope_label') == 'project'] + global_instincts = [i for i in instincts if i.get('_scope_label') == 'global'] # Print header print(f"\n{'='*60}") print(f" INSTINCT STATUS - {len(instincts)} total") print(f"{'='*60}\n") - # Summary by source - personal = [i for i in instincts if i.get('_source_type') == 'personal'] - inherited = [i for i in instincts if i.get('_source_type') == 'inherited'] - print(f" Personal: {len(personal)}") - print(f" Inherited: {len(inherited)}") + print(f" Project: {project['name']} ({project['id']})") + print(f" Project instincts: {len(project_instincts)}") + print(f" Global instincts: {len(global_instincts)}") print() - # Print by domain + # Print project-scoped instincts + if project_instincts: + print(f"## PROJECT-SCOPED ({project['name']})") + print() + _print_instincts_by_domain(project_instincts) + + # Print global instincts + if global_instincts: + print(f"## GLOBAL (apply to all projects)") + print() + _print_instincts_by_domain(global_instincts) + + # Observations stats + obs_file = project.get("observations_file") + if obs_file and Path(obs_file).exists(): + with open(obs_file) as f: + obs_count = sum(1 for _ in f) + print(f"-" * 60) + print(f" Observations: {obs_count} events logged") + print(f" File: {obs_file}") + + print(f"\n{'='*60}\n") + return 0 + + +def _print_instincts_by_domain(instincts: list[dict]) -> None: + """Helper to print instincts grouped by domain.""" + by_domain = defaultdict(list) + for inst in instincts: + domain = inst.get('domain', 'general') + by_domain[domain].append(inst) + for domain in sorted(by_domain.keys()): domain_instincts = by_domain[domain] - print(f"## {domain.upper()} ({len(domain_instincts)})") + print(f" ### {domain.upper()} ({len(domain_instincts)})") print() for inst in sorted(domain_instincts, key=lambda x: -x.get('confidence', 0.5)): conf = inst.get('confidence', 0.5) - conf_bar = '█' * int(conf * 10) + '░' * (10 - int(conf * 10)) + conf_bar = '\u2588' * int(conf * 10) + '\u2591' * (10 - int(conf * 10)) trigger = inst.get('trigger', 'unknown trigger') - source = inst.get('source', 'unknown') + scope_tag = f"[{inst.get('scope', '?')}]" - print(f" {conf_bar} {int(conf*100):3d}% {inst.get('id', 'unnamed')}") - print(f" trigger: {trigger}") + print(f" {conf_bar} {int(conf*100):3d}% {inst.get('id', 'unnamed')} {scope_tag}") + print(f" trigger: {trigger}") # Extract action from content content = inst.get('content', '') action_match = re.search(r'## Action\s*\n\s*(.+?)(?:\n\n|\n##|$)', content, re.DOTALL) if action_match: action = action_match.group(1).strip().split('\n')[0] - print(f" action: {action[:60]}{'...' if len(action) > 60 else ''}") + print(f" action: {action[:60]}{'...' if len(action) > 60 else ''}") print() - # Observations stats - if OBSERVATIONS_FILE.exists(): - obs_count = sum(1 for _ in open(OBSERVATIONS_FILE)) - print(f"─────────────────────────────────────────────────────────") - print(f" Observations: {obs_count} events logged") - print(f" File: {OBSERVATIONS_FILE}") - - print(f"\n{'='*60}\n") - # ───────────────────────────────────────────── # Import Command # ───────────────────────────────────────────── -def cmd_import(args): +def cmd_import(args) -> int: """Import instincts from file or URL.""" + project = detect_project() source = args.source + # Determine target scope + target_scope = args.scope or "project" + if target_scope == "project" and project["id"] == "global": + print("No project detected. Importing as global scope.") + target_scope = "global" + # Fetch content if source.startswith('http://') or source.startswith('https://'): print(f"Fetching from URL: {source}") @@ -192,9 +445,10 @@ def cmd_import(args): print(f"Error fetching URL: {e}", file=sys.stderr) return 1 else: - path = Path(source).expanduser() - if not path.exists(): - print(f"File not found: {path}", file=sys.stderr) + try: + path = _validate_file_path(source, must_exist=True) + except ValueError as e: + print(f"Invalid path: {e}", file=sys.stderr) return 1 content = path.read_text() @@ -204,10 +458,14 @@ def cmd_import(args): print("No valid instincts found in source.") return 1 - print(f"\nFound {len(new_instincts)} instincts to import.\n") + print(f"\nFound {len(new_instincts)} instincts to import.") + print(f"Target scope: {target_scope}") + if target_scope == "project": + print(f"Target project: {project['name']} ({project['id']})") + print() - # Load existing - existing = load_all_instincts() + # Load existing instincts for dedup + existing = load_all_instincts(project) existing_ids = {i.get('id') for i in existing} # Categorize @@ -218,7 +476,6 @@ def cmd_import(args): for inst in new_instincts: inst_id = inst.get('id') if inst_id in existing_ids: - # Check if we should update existing_inst = next((e for e in existing if e.get('id') == inst_id), None) if existing_inst: if inst.get('confidence', 0) > existing_inst.get('confidence', 0): @@ -229,7 +486,7 @@ def cmd_import(args): to_add.append(inst) # Filter by minimum confidence - min_conf = args.min_confidence or 0.0 + min_conf = args.min_confidence if args.min_confidence is not None else 0.0 to_add = [i for i in to_add if i.get('confidence', 0.5) >= min_conf] to_update = [i for i in to_update if i.get('confidence', 0.5) >= min_conf] @@ -266,13 +523,24 @@ def cmd_import(args): print("Cancelled.") return 0 - # Write to inherited directory + # Determine output directory based on scope + if target_scope == "global": + output_dir = GLOBAL_INHERITED_DIR + else: + output_dir = project["instincts_inherited"] + + output_dir.mkdir(parents=True, exist_ok=True) + + # Write timestamp = datetime.now().strftime('%Y%m%d-%H%M%S') source_name = Path(source).stem if not source.startswith('http') else 'web-import' - output_file = INHERITED_DIR / f"{source_name}-{timestamp}.yaml" + output_file = output_dir / f"{source_name}-{timestamp}.yaml" all_to_write = to_add + to_update - output_content = f"# Imported from {source}\n# Date: {datetime.now().isoformat()}\n\n" + output_content = f"# Imported from {source}\n# Date: {datetime.now().isoformat()}\n# Scope: {target_scope}\n" + if target_scope == "project": + output_content += f"# Project: {project['name']} ({project['id']})\n" + output_content += "\n" for inst in all_to_write: output_content += "---\n" @@ -281,7 +549,11 @@ def cmd_import(args): output_content += f"confidence: {inst.get('confidence', 0.5)}\n" output_content += f"domain: {inst.get('domain', 'general')}\n" output_content += f"source: inherited\n" + output_content += f"scope: {target_scope}\n" output_content += f"imported_from: \"{source}\"\n" + if target_scope == "project": + output_content += f"project_id: {project['id']}\n" + output_content += f"project_name: {project['name']}\n" if inst.get('source_repo'): output_content += f"source_repo: {inst.get('source_repo')}\n" output_content += "---\n\n" @@ -289,7 +561,8 @@ def cmd_import(args): output_file.write_text(output_content) - print(f"\n✅ Import complete!") + print(f"\nImport complete!") + print(f" Scope: {target_scope}") print(f" Added: {len(to_add)}") print(f" Updated: {len(to_update)}") print(f" Saved to: {output_file}") @@ -301,9 +574,18 @@ def cmd_import(args): # Export Command # ───────────────────────────────────────────── -def cmd_export(args): +def cmd_export(args) -> int: """Export instincts to file.""" - instincts = load_all_instincts() + project = detect_project() + + # Determine what to export based on scope filter + if args.scope == "project": + instincts = load_project_only_instincts(project) + elif args.scope == "global": + instincts = _load_instincts_from_dir(GLOBAL_PERSONAL_DIR, "personal", "global") + instincts += _load_instincts_from_dir(GLOBAL_INHERITED_DIR, "inherited", "global") + else: + instincts = load_all_instincts(project) if not instincts: print("No instincts to export.") @@ -322,11 +604,17 @@ def cmd_export(args): return 1 # Generate output - output = f"# Instincts export\n# Date: {datetime.now().isoformat()}\n# Total: {len(instincts)}\n\n" + output = f"# Instincts export\n# Date: {datetime.now().isoformat()}\n# Total: {len(instincts)}\n" + if args.scope: + output += f"# Scope: {args.scope}\n" + if project["id"] != "global": + output += f"# Project: {project['name']} ({project['id']})\n" + output += "\n" for inst in instincts: output += "---\n" - for key in ['id', 'trigger', 'confidence', 'domain', 'source', 'source_repo']: + for key in ['id', 'trigger', 'confidence', 'domain', 'source', 'scope', + 'project_id', 'project_name', 'source_repo']: if inst.get(key): value = inst[key] if key == 'trigger': @@ -338,8 +626,13 @@ def cmd_export(args): # Write to file or stdout if args.output: - Path(args.output).write_text(output) - print(f"Exported {len(instincts)} instincts to {args.output}") + try: + out_path = _validate_file_path(args.output) + except ValueError as e: + print(f"Invalid output path: {e}", file=sys.stderr) + return 1 + out_path.write_text(output) + print(f"Exported {len(instincts)} instincts to {out_path}") else: print(output) @@ -350,17 +643,23 @@ def cmd_export(args): # Evolve Command # ───────────────────────────────────────────── -def cmd_evolve(args): +def cmd_evolve(args) -> int: """Analyze instincts and suggest evolutions to skills/commands/agents.""" - instincts = load_all_instincts() + project = detect_project() + instincts = load_all_instincts(project) if len(instincts) < 3: print("Need at least 3 instincts to analyze patterns.") print(f"Currently have: {len(instincts)}") return 1 + project_instincts = [i for i in instincts if i.get('_scope_label') == 'project'] + global_instincts = [i for i in instincts if i.get('_scope_label') == 'global'] + print(f"\n{'='*60}") print(f" EVOLVE ANALYSIS - {len(instincts)} instincts") + print(f" Project: {project['name']} ({project['id']})") + print(f" Project-scoped: {len(project_instincts)} | Global: {len(global_instincts)}") print(f"{'='*60}\n") # Group by domain @@ -383,7 +682,7 @@ def cmd_evolve(args): trigger_key = trigger_key.replace(keyword, '').strip() trigger_clusters[trigger_key].append(inst) - # Find clusters with 3+ instincts (good skill candidates) + # Find clusters with 2+ instincts (good skill candidates) skill_candidates = [] for trigger, cluster in trigger_clusters.items(): if len(cluster) >= 2: @@ -392,7 +691,8 @@ def cmd_evolve(args): 'trigger': trigger, 'instincts': cluster, 'avg_confidence': avg_conf, - 'domains': list(set(i.get('domain', 'general') for i in cluster)) + 'domains': list(set(i.get('domain', 'general') for i in cluster)), + 'scopes': list(set(i.get('scope', 'project') for i in cluster)), }) # Sort by cluster size and confidence @@ -403,13 +703,15 @@ def cmd_evolve(args): if skill_candidates: print(f"\n## SKILL CANDIDATES\n") for i, cand in enumerate(skill_candidates[:5], 1): + scope_info = ', '.join(cand['scopes']) print(f"{i}. Cluster: \"{cand['trigger']}\"") print(f" Instincts: {len(cand['instincts'])}") print(f" Avg confidence: {cand['avg_confidence']:.0%}") print(f" Domains: {', '.join(cand['domains'])}") + print(f" Scopes: {scope_info}") print(f" Instincts:") for inst in cand['instincts'][:3]: - print(f" - {inst.get('id')}") + print(f" - {inst.get('id')} [{inst.get('scope', '?')}]") print() # Command candidates (workflow instincts with high confidence) @@ -418,11 +720,10 @@ def cmd_evolve(args): print(f"\n## COMMAND CANDIDATES ({len(workflow_instincts)})\n") for inst in workflow_instincts[:5]: trigger = inst.get('trigger', 'unknown') - # Suggest command name cmd_name = trigger.replace('when ', '').replace('implementing ', '').replace('a ', '') cmd_name = cmd_name.replace(' ', '-')[:20] print(f" /{cmd_name}") - print(f" From: {inst.get('id')}") + print(f" From: {inst.get('id')} [{inst.get('scope', '?')}]") print(f" Confidence: {inst.get('confidence', 0.5):.0%}") print() @@ -437,10 +738,14 @@ def cmd_evolve(args): print(f" Avg confidence: {cand['avg_confidence']:.0%}") print() + # Promotion candidates (project instincts that could be global) + _show_promotion_candidates(project) + if args.generate: - generated = _generate_evolved(skill_candidates, workflow_instincts, agent_candidates) + evolved_dir = project["evolved_dir"] if project["id"] != "global" else GLOBAL_EVOLVED_DIR + generated = _generate_evolved(skill_candidates, workflow_instincts, agent_candidates, evolved_dir) if generated: - print(f"\n✅ Generated {len(generated)} evolved structures:") + print(f"\nGenerated {len(generated)} evolved structures:") for path in generated: print(f" {path}") else: @@ -450,11 +755,261 @@ def cmd_evolve(args): return 0 +# ───────────────────────────────────────────── +# Promote Command +# ───────────────────────────────────────────── + +def _find_cross_project_instincts() -> dict: + """Find instincts that appear in multiple projects (promotion candidates). + + Returns dict mapping instinct ID → list of (project_id, instinct) tuples. + """ + registry = load_registry() + cross_project = defaultdict(list) + + for pid, pinfo in registry.items(): + project_dir = PROJECTS_DIR / pid + personal_dir = project_dir / "instincts" / "personal" + inherited_dir = project_dir / "instincts" / "inherited" + + for d, stype in [(personal_dir, "personal"), (inherited_dir, "inherited")]: + for inst in _load_instincts_from_dir(d, stype, "project"): + iid = inst.get('id') + if iid: + cross_project[iid].append((pid, pinfo.get('name', pid), inst)) + + # Filter to only those appearing in 2+ projects + return {iid: entries for iid, entries in cross_project.items() if len(entries) >= 2} + + +def _show_promotion_candidates(project: dict) -> None: + """Show instincts that could be promoted from project to global.""" + cross = _find_cross_project_instincts() + + if not cross: + return + + # Filter to high-confidence ones not already global + global_instincts = _load_instincts_from_dir(GLOBAL_PERSONAL_DIR, "personal", "global") + global_instincts += _load_instincts_from_dir(GLOBAL_INHERITED_DIR, "inherited", "global") + global_ids = {i.get('id') for i in global_instincts} + + candidates = [] + for iid, entries in cross.items(): + if iid in global_ids: + continue + avg_conf = sum(e[2].get('confidence', 0.5) for e in entries) / len(entries) + if avg_conf >= PROMOTE_CONFIDENCE_THRESHOLD: + candidates.append({ + 'id': iid, + 'projects': [(pid, pname) for pid, pname, _ in entries], + 'avg_confidence': avg_conf, + 'sample': entries[0][2], + }) + + if candidates: + print(f"\n## PROMOTION CANDIDATES (project -> global)\n") + print(f" These instincts appear in {PROMOTE_MIN_PROJECTS}+ projects with high confidence:\n") + for cand in candidates[:10]: + proj_names = ', '.join(pname for _, pname in cand['projects']) + print(f" * {cand['id']} (avg: {cand['avg_confidence']:.0%})") + print(f" Found in: {proj_names}") + print() + print(f" Run `instinct-cli.py promote` to promote these to global scope.\n") + + +def cmd_promote(args) -> int: + """Promote project-scoped instincts to global scope.""" + project = detect_project() + + if args.instinct_id: + # Promote a specific instinct + return _promote_specific(project, args.instinct_id, args.force) + else: + # Auto-detect promotion candidates + return _promote_auto(project, args.force, args.dry_run) + + +def _promote_specific(project: dict, instinct_id: str, force: bool) -> int: + """Promote a specific instinct by ID from current project to global.""" + if not _validate_instinct_id(instinct_id): + print(f"Invalid instinct ID: '{instinct_id}'.", file=sys.stderr) + return 1 + + project_instincts = load_project_only_instincts(project) + target = next((i for i in project_instincts if i.get('id') == instinct_id), None) + + if not target: + print(f"Instinct '{instinct_id}' not found in project {project['name']}.") + return 1 + + # Check if already global + global_instincts = _load_instincts_from_dir(GLOBAL_PERSONAL_DIR, "personal", "global") + global_instincts += _load_instincts_from_dir(GLOBAL_INHERITED_DIR, "inherited", "global") + if any(i.get('id') == instinct_id for i in global_instincts): + print(f"Instinct '{instinct_id}' already exists in global scope.") + return 1 + + print(f"\nPromoting: {instinct_id}") + print(f" From: project '{project['name']}'") + print(f" Confidence: {target.get('confidence', 0.5):.0%}") + print(f" Domain: {target.get('domain', 'general')}") + + if not force: + response = input(f"\nPromote to global? [y/N] ") + if response.lower() != 'y': + print("Cancelled.") + return 0 + + # Write to global personal directory + output_file = GLOBAL_PERSONAL_DIR / f"{instinct_id}.yaml" + output_content = "---\n" + output_content += f"id: {target.get('id')}\n" + output_content += f"trigger: \"{target.get('trigger', 'unknown')}\"\n" + output_content += f"confidence: {target.get('confidence', 0.5)}\n" + output_content += f"domain: {target.get('domain', 'general')}\n" + output_content += f"source: {target.get('source', 'promoted')}\n" + output_content += f"scope: global\n" + output_content += f"promoted_from: {project['id']}\n" + output_content += f"promoted_date: {datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')}\n" + output_content += "---\n\n" + output_content += target.get('content', '') + "\n" + + output_file.write_text(output_content) + print(f"\nPromoted '{instinct_id}' to global scope.") + print(f" Saved to: {output_file}") + return 0 + + +def _promote_auto(project: dict, force: bool, dry_run: bool) -> int: + """Auto-promote instincts found in multiple projects.""" + cross = _find_cross_project_instincts() + + global_instincts = _load_instincts_from_dir(GLOBAL_PERSONAL_DIR, "personal", "global") + global_instincts += _load_instincts_from_dir(GLOBAL_INHERITED_DIR, "inherited", "global") + global_ids = {i.get('id') for i in global_instincts} + + candidates = [] + for iid, entries in cross.items(): + if iid in global_ids: + continue + avg_conf = sum(e[2].get('confidence', 0.5) for e in entries) / len(entries) + if avg_conf >= PROMOTE_CONFIDENCE_THRESHOLD and len(entries) >= PROMOTE_MIN_PROJECTS: + candidates.append({ + 'id': iid, + 'entries': entries, + 'avg_confidence': avg_conf, + }) + + if not candidates: + print("No instincts qualify for auto-promotion.") + print(f" Criteria: appears in {PROMOTE_MIN_PROJECTS}+ projects, avg confidence >= {PROMOTE_CONFIDENCE_THRESHOLD:.0%}") + return 0 + + print(f"\n{'='*60}") + print(f" AUTO-PROMOTION CANDIDATES - {len(candidates)} found") + print(f"{'='*60}\n") + + for cand in candidates: + proj_names = ', '.join(pname for _, pname, _ in cand['entries']) + print(f" {cand['id']} (avg: {cand['avg_confidence']:.0%})") + print(f" Found in {len(cand['entries'])} projects: {proj_names}") + + if dry_run: + print(f"\n[DRY RUN] No changes made.") + return 0 + + if not force: + response = input(f"\nPromote {len(candidates)} instincts to global? [y/N] ") + if response.lower() != 'y': + print("Cancelled.") + return 0 + + promoted = 0 + for cand in candidates: + if not _validate_instinct_id(cand['id']): + print(f"Skipping invalid instinct ID during promotion: {cand['id']}", file=sys.stderr) + continue + + # Use the highest-confidence version + best_entry = max(cand['entries'], key=lambda e: e[2].get('confidence', 0.5)) + inst = best_entry[2] + + output_file = GLOBAL_PERSONAL_DIR / f"{cand['id']}.yaml" + output_content = "---\n" + output_content += f"id: {inst.get('id')}\n" + output_content += f"trigger: \"{inst.get('trigger', 'unknown')}\"\n" + output_content += f"confidence: {cand['avg_confidence']}\n" + output_content += f"domain: {inst.get('domain', 'general')}\n" + output_content += f"source: auto-promoted\n" + output_content += f"scope: global\n" + output_content += f"promoted_date: {datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')}\n" + output_content += f"seen_in_projects: {len(cand['entries'])}\n" + output_content += "---\n\n" + output_content += inst.get('content', '') + "\n" + + output_file.write_text(output_content) + promoted += 1 + + print(f"\nPromoted {promoted} instincts to global scope.") + return 0 + + +# ───────────────────────────────────────────── +# Projects Command +# ───────────────────────────────────────────── + +def cmd_projects(args) -> int: + """List all known projects and their instinct counts.""" + registry = load_registry() + + if not registry: + print("No projects registered yet.") + print("Projects are auto-detected when you use Claude Code in a git repo.") + return 0 + + print(f"\n{'='*60}") + print(f" KNOWN PROJECTS - {len(registry)} total") + print(f"{'='*60}\n") + + for pid, pinfo in sorted(registry.items(), key=lambda x: x[1].get('last_seen', ''), reverse=True): + project_dir = PROJECTS_DIR / pid + personal_dir = project_dir / "instincts" / "personal" + inherited_dir = project_dir / "instincts" / "inherited" + + personal_count = len(_load_instincts_from_dir(personal_dir, "personal", "project")) + inherited_count = len(_load_instincts_from_dir(inherited_dir, "inherited", "project")) + obs_file = project_dir / "observations.jsonl" + if obs_file.exists(): + with open(obs_file) as f: + obs_count = sum(1 for _ in f) + else: + obs_count = 0 + + print(f" {pinfo.get('name', pid)} [{pid}]") + print(f" Root: {pinfo.get('root', 'unknown')}") + if pinfo.get('remote'): + print(f" Remote: {pinfo['remote']}") + print(f" Instincts: {personal_count} personal, {inherited_count} inherited") + print(f" Observations: {obs_count} events") + print(f" Last seen: {pinfo.get('last_seen', 'unknown')}") + print() + + # Global stats + global_personal = len(_load_instincts_from_dir(GLOBAL_PERSONAL_DIR, "personal", "global")) + global_inherited = len(_load_instincts_from_dir(GLOBAL_INHERITED_DIR, "inherited", "global")) + print(f" GLOBAL") + print(f" Instincts: {global_personal} personal, {global_inherited} inherited") + + print(f"\n{'='*60}\n") + return 0 + + # ───────────────────────────────────────────── # Generate Evolved Structures # ───────────────────────────────────────────── -def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_candidates: list) -> list[str]: +def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_candidates: list, evolved_dir: Path) -> list[str]: """Generate skill/command/agent files from analyzed instinct clusters.""" generated = [] @@ -467,7 +1022,7 @@ def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_ca if not name: continue - skill_dir = EVOLVED_DIR / "skills" / name + skill_dir = evolved_dir / "skills" / name skill_dir.mkdir(parents=True, exist_ok=True) content = f"# {name}\n\n" @@ -493,7 +1048,7 @@ def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_ca if not cmd_name: continue - cmd_file = EVOLVED_DIR / "commands" / f"{cmd_name}.md" + cmd_file = evolved_dir / "commands" / f"{cmd_name}.md" content = f"# {cmd_name}\n\n" content += f"Evolved from instinct: {inst.get('id', 'unnamed')}\n" content += f"Confidence: {inst.get('confidence', 0.5):.0%}\n\n" @@ -509,7 +1064,7 @@ def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_ca if not agent_name: continue - agent_file = EVOLVED_DIR / "agents" / f"{agent_name}.md" + agent_file = evolved_dir / "agents" / f"{agent_name}.md" domains = ', '.join(cand['domains']) instinct_ids = [i.get('id', 'unnamed') for i in cand['instincts']] @@ -532,12 +1087,13 @@ def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_ca # Main # ───────────────────────────────────────────── -def main(): - parser = argparse.ArgumentParser(description='Instinct CLI for Continuous Learning v2') +def main() -> int: + _ensure_global_dirs() + parser = argparse.ArgumentParser(description='Instinct CLI for Continuous Learning v2.1 (Project-Scoped)') subparsers = parser.add_subparsers(dest='command', help='Available commands') # Status - status_parser = subparsers.add_parser('status', help='Show instinct status') + status_parser = subparsers.add_parser('status', help='Show instinct status (project + global)') # Import import_parser = subparsers.add_parser('import', help='Import instincts') @@ -545,17 +1101,30 @@ def main(): import_parser.add_argument('--dry-run', action='store_true', help='Preview without importing') import_parser.add_argument('--force', action='store_true', help='Skip confirmation') import_parser.add_argument('--min-confidence', type=float, help='Minimum confidence threshold') + import_parser.add_argument('--scope', choices=['project', 'global'], default='project', + help='Import scope (default: project)') # Export export_parser = subparsers.add_parser('export', help='Export instincts') export_parser.add_argument('--output', '-o', help='Output file') export_parser.add_argument('--domain', help='Filter by domain') export_parser.add_argument('--min-confidence', type=float, help='Minimum confidence') + export_parser.add_argument('--scope', choices=['project', 'global', 'all'], default='all', + help='Export scope (default: all)') # Evolve evolve_parser = subparsers.add_parser('evolve', help='Analyze and evolve instincts') evolve_parser.add_argument('--generate', action='store_true', help='Generate evolved structures') + # Promote (new in v2.1) + promote_parser = subparsers.add_parser('promote', help='Promote project instincts to global scope') + promote_parser.add_argument('instinct_id', nargs='?', help='Specific instinct ID to promote') + promote_parser.add_argument('--force', action='store_true', help='Skip confirmation') + promote_parser.add_argument('--dry-run', action='store_true', help='Preview without promoting') + + # Projects (new in v2.1) + projects_parser = subparsers.add_parser('projects', help='List known projects and instinct counts') + args = parser.parse_args() if args.command == 'status': @@ -566,10 +1135,14 @@ def main(): return cmd_export(args) elif args.command == 'evolve': return cmd_evolve(args) + elif args.command == 'promote': + return cmd_promote(args) + elif args.command == 'projects': + return cmd_projects(args) else: parser.print_help() return 1 if __name__ == '__main__': - sys.exit(main() or 0) + sys.exit(main()) diff --git a/skills/continuous-learning-v2/scripts/test_parse_instinct.py b/skills/continuous-learning-v2/scripts/test_parse_instinct.py index 10d487e5..41360ebc 100644 --- a/skills/continuous-learning-v2/scripts/test_parse_instinct.py +++ b/skills/continuous-learning-v2/scripts/test_parse_instinct.py @@ -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 == [] diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index e86633e8..f4afd036 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -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)}...` ); } }