Compare commits
50 Commits
91fa0608d0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a9eaf0114f | |||
| f63172c4cf | |||
| ff111ef278 | |||
| bf5470ac66 | |||
| c1e3b2881d | |||
| 4024740b82 | |||
| fb4cf1b035 | |||
| d2daf74fca | |||
| e52e818686 | |||
| df6cf94dae | |||
| 7dcb8af1bb | |||
| 6be9bf5aff | |||
| 7c37e9adb3 | |||
| 0780b4c17d | |||
| a08dc505d9 | |||
| c82726b691 | |||
| c14c0d843d | |||
| 0fd0e74b67 | |||
| 1636784931 | |||
| c30ea2d679 | |||
| 769391640b | |||
| ecf375205f | |||
| f2f8a03a32 | |||
| 630893f047 | |||
| 928fa7191b | |||
| fae8730477 | |||
| 7e563bd334 | |||
| 9ae8ff85c3 | |||
| f9e9be62bc | |||
| 5b9a85cd37 | |||
| 91733f5460 | |||
| 380e2005c8 | |||
| 62050faedc | |||
| f3cb082c36 | |||
| db0d9f97b2 | |||
| 94603b19a5 | |||
| 45b7e4bcf7 | |||
| 7ca8caeecb | |||
| c21b152de8 | |||
| 4fe8957482 | |||
| 1b432f1c3f | |||
| 383e2cbf38 | |||
| 1f5029cbb0 | |||
| 89255cc6fa | |||
| 652ceb55f0 | |||
| 02f9cf7d8f | |||
| 2105803594 | |||
| 73400a21ab | |||
| 56b455a074 | |||
| f07022ca60 |
+15
-17
@@ -1,25 +1,23 @@
|
||||
---
|
||||
active: true
|
||||
iteration: 16
|
||||
max_iterations: 0
|
||||
completion_promise: "The morning-report skill is fully implemented, tested, and registered"
|
||||
started_at: "2026-01-03T08:16:44Z"
|
||||
iteration: 1
|
||||
max_iterations: 20
|
||||
completion_promise: "Guardrail hooks are fully implemented, tested, and registered"
|
||||
started_at: "2026-01-07T18:52:10Z"
|
||||
---
|
||||
|
||||
Build the morning-report skill following the design at ~/.claude/docs/plans/2025-01-02-morning-report-design.md
|
||||
Implement guardrail hooks following the design at ~/.claude/docs/plans/2025-01-06-guardrail-hooks-design.md
|
||||
|
||||
Implementation order:
|
||||
1. Create skill skeleton: ~/.claude/skills/morning-report/ with SKILL.md and config.json
|
||||
2. Build collectors: weather.py, stocks.py, infra.py (easy wins first)
|
||||
3. Build gtasks.py collector (Google Tasks API - add OAuth scope)
|
||||
4. Build news.py collector (RSS feeds)
|
||||
5. Build generate.py orchestrator and render.py templating
|
||||
6. Create systemd timer and /morning command
|
||||
7. Test end-to-end and verify output
|
||||
|
||||
Use appropriate LLM tiers:
|
||||
- Haiku: weather, stocks, infra formatting
|
||||
- Sonnet: email triage, news summarization
|
||||
- None: calendar, tasks (structured data)
|
||||
1. Create state/guardrails.json with starter rules config
|
||||
2. Create hooks/scripts/guardrail.py main logic
|
||||
3. Create hooks/scripts/guardrail-confirm.py confirm helper
|
||||
4. Modify hooks/hooks.json to add PreToolUse registration
|
||||
5. Modify hooks/scripts/session-end.sh to clear session allowlist
|
||||
6. Create logs/ directory
|
||||
7. Test: block scenario (catastrophic command pattern)
|
||||
8. Test: confirm scenario (operation outside safe paths)
|
||||
9. Test: allow scenario (operation in safe path)
|
||||
10. Test: git-aware detection
|
||||
|
||||
Register in component-registry.json when complete.
|
||||
|
||||
@@ -45,3 +45,10 @@ tmp_unused
|
||||
# Todos (managed by Claude Code)
|
||||
todos/
|
||||
repos/homelab
|
||||
|
||||
# RAG search data (generated vector stores and caches)
|
||||
data/
|
||||
skills/rag-search/venv/
|
||||
|
||||
# Telemetry (analytics tracking)
|
||||
telemetry/
|
||||
|
||||
@@ -45,6 +45,8 @@ See `agents/README.md` for details on agent files and execution.
|
||||
├── commands/ # Slash command definitions
|
||||
├── workflows/ # Workflow definitions (design docs)
|
||||
│ └── README.md
|
||||
├── plans/ # Implementation plans
|
||||
│ └── index.json # Plan status registry
|
||||
├── state/ # Shared state files (JSON)
|
||||
│ ├── sysadmin/
|
||||
│ ├── programmer/
|
||||
@@ -67,6 +69,7 @@ All agents MUST read and follow the processes defined in these files:
|
||||
| `state/personal-assistant-preferences.json` | PA persistent config | personal-assistant |
|
||||
| `state/personal-assistant/general-instructions.json` | User memory | personal-assistant |
|
||||
| `state/kb.json` | Shared knowledge base | personal-assistant |
|
||||
| `plans/index.json` | Plan status registry | any agent |
|
||||
|
||||
## Key Processes
|
||||
|
||||
@@ -80,6 +83,44 @@ All agents MUST read and follow the processes defined in these files:
|
||||
- Session overrides in `state/sysadmin/session-autonomy.json`
|
||||
- See `state/autonomy-levels.json` for level definitions
|
||||
|
||||
### Git Workflow
|
||||
This repo uses Gitea (not GitHub). Follow this workflow:
|
||||
|
||||
1. **Create feature branch**: `git checkout -b feature/descriptive-name`
|
||||
2. **Commit work**: Make atomic commits with clear messages
|
||||
3. **Rebase onto main**: `git rebase origin/main`
|
||||
4. **Push branch**: `git push -u origin feature/branch-name`
|
||||
5. **Create PR**: `~/.claude/automation/gitea-pr.sh "PR Title" "Description"`
|
||||
6. **Merge** (after user approval): `~/.claude/automation/gitea-merge.sh`
|
||||
- Rebases onto main and pushes
|
||||
- Deletes local and remote branch
|
||||
- Closes PR via API
|
||||
|
||||
Notes:
|
||||
- Gitea token stored at `~/.config/gitea-token`
|
||||
|
||||
### PR Review Policy
|
||||
|
||||
| Repo Type | Review Process |
|
||||
|-----------|----------------|
|
||||
| ~/.claude | Linting/validation only (shellcheck, JSON/YAML syntax, Python syntax) |
|
||||
| Code repos | Full review via code-reviewer agent (Sonnet) before user approval |
|
||||
|
||||
### Gitea API Commands
|
||||
|
||||
```bash
|
||||
# List all PRs
|
||||
curl -s -H "Authorization: token $(cat ~/.config/gitea-token)" \
|
||||
"https://gitea-http.taildb3494.ts.net/api/v1/repos/will/claude-code/pulls?state=all"
|
||||
|
||||
# Close a PR (after rebase merge)
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: token $(cat ~/.config/gitea-token)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state":"closed"}' \
|
||||
"https://gitea-http.taildb3494.ts.net/api/v1/repos/will/claude-code/pulls/{PR_NUMBER}"
|
||||
```
|
||||
|
||||
## Component Formats
|
||||
|
||||
| Component | Format | Location |
|
||||
@@ -88,6 +129,7 @@ All agents MUST read and follow the processes defined in these files:
|
||||
| **Skills** | SKILL.md + scripts/ + references/ | `skills/` |
|
||||
| **Commands** | Markdown + YAML frontmatter | `commands/` |
|
||||
| **Workflows** | YAML (design docs, not auto-executed) | `workflows/` |
|
||||
| **Plans** | Markdown + index.json | `plans/` |
|
||||
| **State** | JSON | `state/` |
|
||||
| **Hooks** | JSON | `hooks/` |
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ claude
|
||||
| **Personal Assistant** | Natural language interface via `/pa` |
|
||||
| **Gmail Integration** | Read emails, check urgent, search |
|
||||
| **Calendar Integration** | View agenda, check schedule |
|
||||
| **Tasks Integration** | List Google Tasks |
|
||||
| **Kubernetes Management** | Cluster health, deployments, diagnostics |
|
||||
| **System Administration** | Health checks, updates, autonomy control |
|
||||
| **Usage Tracking** | Session statistics and history |
|
||||
@@ -54,6 +55,7 @@ Each directory has its own README with details.
|
||||
|---------|-------------|
|
||||
| `/pa <request>` | Natural language request to personal assistant |
|
||||
| `/gcal [today\|tomorrow\|week]` | Calendar agenda |
|
||||
| `/tasks` | List Google Tasks |
|
||||
| `/usage [today\|week\|month]` | Usage statistics |
|
||||
| `/sysadmin:health` | System health check |
|
||||
| `/sysadmin:update` | Package updates |
|
||||
@@ -68,6 +70,7 @@ Skills are triggered automatically based on your request:
|
||||
|---------|-------|
|
||||
| "check my email" | gmail |
|
||||
| "what's on today" | gcal |
|
||||
| "my tasks" / "todos" | gtasks |
|
||||
| "cluster status" | k8s-quick-status |
|
||||
| "system health" | sysadmin-health |
|
||||
| "usage stats" | usage |
|
||||
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/bin/bash
|
||||
# Merge a feature branch with rebase, close PR, and cleanup
|
||||
# Usage: gitea-merge.sh [branch-name]
|
||||
# If no branch specified, uses current branch
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_URL="https://gitea-http.taildb3494.ts.net"
|
||||
REPO="will/claude-code"
|
||||
TOKEN_FILE="$HOME/.config/gitea-token"
|
||||
|
||||
if [[ ! -f "$TOKEN_FILE" ]]; then
|
||||
echo "Error: Gitea token not found at $TOKEN_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOKEN=$(cat "$TOKEN_FILE")
|
||||
|
||||
# Get branch to merge
|
||||
BRANCH="${1:-$(git rev-parse --abbrev-ref HEAD)}"
|
||||
|
||||
if [[ "$BRANCH" == "main" ]]; then
|
||||
echo "Error: Already on main, specify a feature branch" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Merging branch: $BRANCH"
|
||||
|
||||
# Find PR number for this branch
|
||||
PR_NUMBER=$(curl -s -H "Authorization: token $TOKEN" \
|
||||
"$GITEA_URL/api/v1/repos/$REPO/pulls?state=open" | \
|
||||
python3 -c "
|
||||
import sys, json
|
||||
prs = json.load(sys.stdin)
|
||||
for pr in prs:
|
||||
if pr.get('head', {}).get('ref') == '$BRANCH':
|
||||
print(pr['number'])
|
||||
break
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
# Stash any uncommitted changes
|
||||
STASHED=false
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "Stashing uncommitted changes..."
|
||||
git stash
|
||||
STASHED=true
|
||||
fi
|
||||
|
||||
# Checkout main and rebase
|
||||
echo "Checking out main..."
|
||||
git checkout main
|
||||
git pull origin main
|
||||
|
||||
echo "Rebasing $BRANCH onto main..."
|
||||
git rebase "$BRANCH"
|
||||
|
||||
echo "Pushing to origin..."
|
||||
git push origin main
|
||||
|
||||
# Delete local branch
|
||||
echo "Deleting local branch..."
|
||||
git branch -d "$BRANCH" 2>/dev/null || true
|
||||
|
||||
# Delete remote branch
|
||||
echo "Deleting remote branch..."
|
||||
git push origin --delete "$BRANCH" 2>/dev/null || true
|
||||
|
||||
# Close PR if found
|
||||
if [[ -n "$PR_NUMBER" ]]; then
|
||||
echo "Closing PR #$PR_NUMBER..."
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state":"closed"}' \
|
||||
"$GITEA_URL/api/v1/repos/$REPO/pulls/$PR_NUMBER" >/dev/null
|
||||
echo "PR #$PR_NUMBER closed"
|
||||
else
|
||||
echo "No open PR found for branch $BRANCH"
|
||||
fi
|
||||
|
||||
# Restore stashed changes
|
||||
if [[ "$STASHED" == "true" ]]; then
|
||||
echo "Restoring stashed changes..."
|
||||
git stash pop
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done! Branch $BRANCH merged to main"
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
# Create a PR in Gitea for the current branch
|
||||
# Usage: gitea-pr.sh [title] [body]
|
||||
# Runs validation before creating PR
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GITEA_URL="https://gitea-http.taildb3494.ts.net"
|
||||
REPO="will/claude-code"
|
||||
TOKEN_FILE="$HOME/.config/gitea-token"
|
||||
|
||||
if [[ ! -f "$TOKEN_FILE" ]]; then
|
||||
echo "Error: Gitea token not found at $TOKEN_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOKEN=$(cat "$TOKEN_FILE")
|
||||
|
||||
# Get current branch
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
if [[ "$BRANCH" == "main" ]]; then
|
||||
echo "Error: Cannot create PR from main branch" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run validation
|
||||
echo "Running pre-PR validation..."
|
||||
if ! "$SCRIPT_DIR/validate-pr.sh"; then
|
||||
echo "Error: Validation failed. Fix issues before creating PR." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Default title from branch name
|
||||
TITLE="${1:-$BRANCH}"
|
||||
BODY="${2:-Auto-generated PR for $BRANCH}"
|
||||
|
||||
# Create PR via API
|
||||
RESPONSE=$(curl -s -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"title\": \"$TITLE\",
|
||||
\"body\": \"$BODY\",
|
||||
\"head\": \"$BRANCH\",
|
||||
\"base\": \"main\"
|
||||
}" \
|
||||
"$GITEA_URL/api/v1/repos/$REPO/pulls")
|
||||
|
||||
# Extract PR URL or error
|
||||
PR_URL=$(echo "$RESPONSE" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
if 'html_url' in d:
|
||||
print(d['html_url'])
|
||||
elif 'message' in d:
|
||||
print(f\"Error: {d['message']}\", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f'Unexpected response: {d}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" 2>&1)
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo "PR created: $PR_URL"
|
||||
else
|
||||
echo "$PR_URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
# Validate changed files before PR creation
|
||||
# Runs: shellcheck, JSON validation, Python syntax check
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ERRORS=0
|
||||
|
||||
# Get changed files compared to main
|
||||
CHANGED_FILES=$(git diff --name-only origin/main 2>/dev/null || git diff --name-only HEAD~1)
|
||||
|
||||
echo "Validating changed files..."
|
||||
|
||||
for file in $CHANGED_FILES; do
|
||||
[[ -f "$file" ]] || continue
|
||||
|
||||
case "$file" in
|
||||
*.sh)
|
||||
if command -v shellcheck &>/dev/null; then
|
||||
if ! shellcheck -S warning "$file" 2>/dev/null; then
|
||||
echo "FAIL: shellcheck $file"
|
||||
((ERRORS++))
|
||||
else
|
||||
echo "OK: $file"
|
||||
fi
|
||||
else
|
||||
echo "SKIP: shellcheck not installed"
|
||||
fi
|
||||
;;
|
||||
*.json)
|
||||
if ! python3 -m json.tool "$file" >/dev/null 2>&1; then
|
||||
echo "FAIL: invalid JSON $file"
|
||||
((ERRORS++))
|
||||
else
|
||||
echo "OK: $file"
|
||||
fi
|
||||
;;
|
||||
*.yaml|*.yml)
|
||||
if command -v yamllint &>/dev/null; then
|
||||
if ! yamllint -d relaxed "$file" 2>/dev/null; then
|
||||
echo "FAIL: yamllint $file"
|
||||
((ERRORS++))
|
||||
else
|
||||
echo "OK: $file"
|
||||
fi
|
||||
elif python3 -c "import yaml" 2>/dev/null; then
|
||||
if ! python3 -c "import yaml; yaml.safe_load(open('$file'))" 2>/dev/null; then
|
||||
echo "FAIL: invalid YAML $file"
|
||||
((ERRORS++))
|
||||
else
|
||||
echo "OK: $file"
|
||||
fi
|
||||
else
|
||||
echo "SKIP: no YAML validator"
|
||||
fi
|
||||
;;
|
||||
*.py)
|
||||
if ! python3 -m py_compile "$file" 2>/dev/null; then
|
||||
echo "FAIL: Python syntax $file"
|
||||
((ERRORS++))
|
||||
else
|
||||
echo "OK: $file"
|
||||
fi
|
||||
;;
|
||||
*.md)
|
||||
echo "OK: $file (markdown, no validation)"
|
||||
;;
|
||||
*)
|
||||
echo "SKIP: $file (no validator)"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $ERRORS -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "Validation failed with $ERRORS error(s)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "All validations passed"
|
||||
exit 0
|
||||
@@ -81,7 +81,7 @@ echo ""
|
||||
|
||||
# Check skills
|
||||
echo "=== Skills ==="
|
||||
for skill in gmail gcal k8s-quick-status sysadmin-health usage programmer-add-project; do
|
||||
for skill in gmail gcal gtasks k8s-quick-status sysadmin-health usage programmer-add-project morning-report stock-lookup rag-search; do
|
||||
skill_dir="${CLAUDE_DIR}/skills/${skill}"
|
||||
if [[ -f "${skill_dir}/SKILL.md" ]]; then
|
||||
pass "${skill}/SKILL.md exists"
|
||||
@@ -126,6 +126,54 @@ for file in component-registry.json autonomy-levels.json model-policy.json; do
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Check hybrid format enforcement
|
||||
echo "=== Hybrid Format (md/json/yaml) ==="
|
||||
|
||||
# Agents must be .md
|
||||
non_md_agents=$(find "${CLAUDE_DIR}/agents" -type f ! -name "*.md" ! -name "README*" 2>/dev/null | wc -l)
|
||||
if [[ ${non_md_agents} -eq 0 ]]; then
|
||||
pass "All agent files are .md"
|
||||
else
|
||||
fail "Found ${non_md_agents} non-.md files in agents/"
|
||||
find "${CLAUDE_DIR}/agents" -type f ! -name "*.md" ! -name "README*" 2>/dev/null | while read f; do
|
||||
echo " - $(basename "$f")"
|
||||
done
|
||||
fi
|
||||
|
||||
# Commands must be .md
|
||||
non_md_commands=$(find "${CLAUDE_DIR}/commands" -type f ! -name "*.md" ! -name "README*" 2>/dev/null | wc -l)
|
||||
if [[ ${non_md_commands} -eq 0 ]]; then
|
||||
pass "All command files are .md"
|
||||
else
|
||||
fail "Found ${non_md_commands} non-.md files in commands/"
|
||||
find "${CLAUDE_DIR}/commands" -type f ! -name "*.md" ! -name "README*" 2>/dev/null | while read f; do
|
||||
echo " - $(basename "$f")"
|
||||
done
|
||||
fi
|
||||
|
||||
# Workflows must be .yaml
|
||||
non_yaml_workflows=$(find "${CLAUDE_DIR}/workflows" -type f ! -name "*.yaml" ! -name "*.yml" ! -name "README*" 2>/dev/null | wc -l)
|
||||
if [[ ${non_yaml_workflows} -eq 0 ]]; then
|
||||
pass "All workflow files are .yaml"
|
||||
else
|
||||
fail "Found ${non_yaml_workflows} non-.yaml files in workflows/"
|
||||
find "${CLAUDE_DIR}/workflows" -type f ! -name "*.yaml" ! -name "*.yml" ! -name "README*" 2>/dev/null | while read f; do
|
||||
echo " - $(basename "$f")"
|
||||
done
|
||||
fi
|
||||
|
||||
# State must be .json (excluding subdirectories with their own patterns)
|
||||
non_json_state=$(find "${CLAUDE_DIR}/state" -maxdepth 1 -type f ! -name "*.json" ! -name "README*" 2>/dev/null | wc -l)
|
||||
if [[ ${non_json_state} -eq 0 ]]; then
|
||||
pass "All top-level state files are .json"
|
||||
else
|
||||
fail "Found ${non_json_state} non-.json files in state/"
|
||||
find "${CLAUDE_DIR}/state" -maxdepth 1 -type f ! -name "*.json" ! -name "README*" 2>/dev/null | while read f; do
|
||||
echo " - $(basename "$f")"
|
||||
done
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check Gmail setup
|
||||
echo "=== Gmail Integration ==="
|
||||
if [[ -d "${CLAUDE_DIR}/mcp/gmail/venv" ]]; then
|
||||
|
||||
@@ -28,6 +28,7 @@ Slash commands for quick actions. User-invoked (type `/command` to trigger).
|
||||
| `/programmer` | | Code development tasks |
|
||||
| `/gcal` | `/calendar`, `/cal` | Google Calendar access |
|
||||
| `/usage` | `/stats` | View usage statistics |
|
||||
| `/external` | `/llm`, `/ext` | Toggle and use external LLM mode |
|
||||
|
||||
### Kubernetes (`/k8s:*`)
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: external
|
||||
description: Toggle and use external LLM mode (GPT-5.2, Gemini, etc.)
|
||||
aliases: [llm, ext, external-llm]
|
||||
---
|
||||
|
||||
# External LLM Mode
|
||||
|
||||
Route requests to external LLMs via opencode or gemini CLI.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/external # Show current status
|
||||
/external on [reason] # Enable external mode
|
||||
/external off # Disable external mode
|
||||
/external invoke <prompt> # Send prompt to default model
|
||||
/external invoke --model <model> <prompt> # Send to specific model
|
||||
/external invoke --task <task> <prompt> # Route by task type
|
||||
/external models # List available models
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
### Status
|
||||
```bash
|
||||
~/.claude/mcp/llm-router/toggle.py status
|
||||
```
|
||||
|
||||
### Toggle On/Off
|
||||
```bash
|
||||
~/.claude/mcp/llm-router/toggle.py on --reason "reason"
|
||||
~/.claude/mcp/llm-router/toggle.py off
|
||||
```
|
||||
|
||||
### Invoke
|
||||
```bash
|
||||
~/.claude/mcp/llm-router/invoke.py --model MODEL -p "prompt" [--json]
|
||||
~/.claude/mcp/llm-router/invoke.py --task TASK -p "prompt" [--json]
|
||||
```
|
||||
|
||||
## Available Models by Tier
|
||||
|
||||
### Frontier (strongest)
|
||||
| Model | Provider | Best For |
|
||||
|-------|----------|----------|
|
||||
| `github-copilot/gpt-5.2` | opencode | reasoning, fallback |
|
||||
| `github-copilot/gemini-3-pro-preview` | opencode | long context, reasoning |
|
||||
| `gemini/gemini-2.5-pro` | gemini | long context, reasoning |
|
||||
|
||||
### Mid-tier (general purpose)
|
||||
| Model | Provider | Best For |
|
||||
|-------|----------|----------|
|
||||
| `github-copilot/claude-sonnet-4.5` | opencode | general, fallback |
|
||||
| `github-copilot/gemini-3-flash-preview` | opencode | fast |
|
||||
| `zai-coding-plan/glm-4.7` | opencode | code generation |
|
||||
| `opencode/big-pickle` | opencode | general |
|
||||
| `gemini/gemini-2.5-flash` | gemini | fast |
|
||||
|
||||
### Lightweight (simple tasks)
|
||||
| Model | Provider | Best For |
|
||||
|-------|----------|----------|
|
||||
| `github-copilot/claude-haiku-4.5` | opencode | simple tasks |
|
||||
|
||||
## Task Routing
|
||||
|
||||
| Task | Routes To | Tier |
|
||||
|------|-----------|------|
|
||||
| `reasoning` | github-copilot/gpt-5.2 | frontier |
|
||||
| `code-generation` | github-copilot/gemini-3-pro-preview | frontier |
|
||||
| `long-context` | gemini/gemini-2.5-pro | frontier |
|
||||
| `fast` | github-copilot/gemini-3-flash-preview | mid-tier |
|
||||
| `general` (default) | github-copilot/claude-sonnet-4.5 | mid-tier |
|
||||
|
||||
## State Files
|
||||
|
||||
- Mode state: `~/.claude/state/external-mode.json`
|
||||
- Model policy: `~/.claude/state/model-policy.json`
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
/external on testing # Enable for testing
|
||||
/external invoke "Explain k8s pods" # Use default model (mid-tier)
|
||||
/external invoke --model github-copilot/gpt-5.2 "Complex analysis" # frontier
|
||||
/external invoke --task code-generation "Write a Python function" # routes to frontier
|
||||
/external invoke --task fast "Quick question" # routes to mid-tier
|
||||
/external off # Back to Claude
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"plan": "2026-01-05-workstation-monitoring-design.md",
|
||||
"status": "COMPLETE",
|
||||
"completed_at": "2026-01-05T14:09:00Z",
|
||||
"implementation": {
|
||||
"node_exporter": "installed and running (v1.10.2-1)",
|
||||
"scrape_config": "deployed (workstation-scrape)",
|
||||
"prometheus_rule": "deployed (workstation-alerts, 12 rules)",
|
||||
"prometheus_target": "UP and scraping",
|
||||
"git_commit": "9d17ac8",
|
||||
"network_solution": "Tailscale (100.90.159.78:9100)"
|
||||
},
|
||||
"verification": {
|
||||
"all_success_criteria_met": true,
|
||||
"verified_at": "2026-01-05T14:09:19Z"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
# Guardrail Hooks Design
|
||||
|
||||
**Date:** 2025-01-06
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
PreToolUse guardrail hooks that prevent dangerous operations by intercepting Bash, Write, and Edit tool calls before execution.
|
||||
|
||||
### Goals
|
||||
|
||||
- Prevent catastrophic operations (destructive file ops, dangerous system commands, infrastructure mistakes)
|
||||
- Contextual response: hard block for severe threats, confirmation for moderate risks
|
||||
- Path-aware: operations inside projects are more permissive than outside
|
||||
- Auditable: log all interventions for review
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Git command guardrails (future consideration)
|
||||
- Rate limiting or resource protection
|
||||
- Workflow compliance enforcement
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
~/.claude/
|
||||
├── hooks/
|
||||
│ └── hooks.json # Hook registration (add PreToolUse)
|
||||
├── hooks/scripts/
|
||||
│ ├── guardrail.py # Main PreToolUse logic
|
||||
│ └── guardrail-confirm.py # Adds operation to session allowlist
|
||||
├── state/
|
||||
│ ├── guardrails.json # Rules configuration
|
||||
│ └── guardrail-session.json # Session allowlist (cleared on session end)
|
||||
└── logs/
|
||||
└── guardrail.jsonl # Audit log (blocked/confirmed only)
|
||||
```
|
||||
|
||||
### Flow
|
||||
|
||||
1. Claude invokes Bash/Write/Edit
|
||||
2. `guardrail.py` runs via PreToolUse hook
|
||||
3. Check session allowlist - if present, allow
|
||||
4. Evaluate rules + path context - decide action
|
||||
5. Allow: return `{"decision": "allow"}`
|
||||
6. Block/Confirm: log, return `{"decision": "block", "reason": "..."}`
|
||||
7. For confirm: user approves, Claude calls `guardrail-confirm.py`, retries
|
||||
|
||||
## Configuration
|
||||
|
||||
**Location:** `~/.claude/state/guardrails.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"safe_paths": [
|
||||
"~/.claude",
|
||||
"~/projects"
|
||||
],
|
||||
"blocked_paths": [
|
||||
"/etc", "/usr", "/var", "/boot", "/sys", "/proc",
|
||||
"~/.ssh", "~/.gnupg", "~/.aws"
|
||||
],
|
||||
"rules": {
|
||||
"bash": [
|
||||
{"pattern": "rm -rf /", "action": "block"},
|
||||
{"pattern": "rm -rf ~", "action": "block"},
|
||||
{"pattern": "rm -rf \\*", "action": "block"},
|
||||
{"pattern": "chmod -R 777", "action": "block"},
|
||||
{"pattern": ":(){ :|:& };:", "action": "block"},
|
||||
{"pattern": "mkfs\\.", "action": "block"},
|
||||
{"pattern": "dd if=.* of=/dev/", "action": "block"},
|
||||
{"pattern": "> /dev/sda", "action": "block"},
|
||||
{"pattern": "shutdown", "action": "confirm"},
|
||||
{"pattern": "reboot", "action": "confirm"},
|
||||
{"pattern": "systemctl (stop|disable|mask)", "action": "confirm"},
|
||||
{"pattern": "rm ", "action": "confirm", "outside_safe_paths": true},
|
||||
{"pattern": "kubectl delete", "action": "confirm"},
|
||||
{"pattern": "docker rm", "action": "confirm"}
|
||||
],
|
||||
"write": [
|
||||
{"path_patterns": ["blocked_paths"], "action": "block"},
|
||||
{"path_patterns": ["outside_safe_paths"], "action": "confirm"}
|
||||
],
|
||||
"edit": [
|
||||
{"path_patterns": ["blocked_paths"], "action": "block"},
|
||||
{"path_patterns": ["outside_safe_paths"], "action": "confirm"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- `pattern`: regex matched against command/path
|
||||
- `action`: `block` (hard stop) or `confirm` (require approval)
|
||||
- `outside_safe_paths`: rule only applies when target is outside safe directories
|
||||
- Bash rules check command string; Write/Edit rules check file path
|
||||
|
||||
## Safe Paths Logic
|
||||
|
||||
**Evaluation order (first match wins):**
|
||||
|
||||
1. **Blocked paths check** - Is target in `blocked_paths`?
|
||||
- Yes: apply block/confirm regardless of other factors
|
||||
- Protects system-critical locations absolutely
|
||||
|
||||
2. **Explicit allowlist** - Is target under a `safe_paths` entry?
|
||||
- Yes: path is safe
|
||||
- Supports glob patterns (`~/projects/*`)
|
||||
|
||||
3. **Git-aware detection** - Is target inside a git repository?
|
||||
- Walk up from target path looking for `.git` directory
|
||||
- Found: treat as safe (it's an intentional project)
|
||||
|
||||
4. **Default** - Path is outside safe areas
|
||||
- Operations here trigger `outside_safe_paths` rules
|
||||
|
||||
### Path Normalization
|
||||
|
||||
- Expand `~` to actual home directory
|
||||
- Resolve symlinks to real paths
|
||||
- Handle relative paths by resolving against CWD
|
||||
|
||||
### Examples
|
||||
|
||||
| Target Path | Result | Reason |
|
||||
|-------------|--------|--------|
|
||||
| `/etc/hosts` | blocked/confirm | In `blocked_paths` |
|
||||
| `~/.ssh/config` | blocked/confirm | In `blocked_paths` |
|
||||
| `~/.claude/hooks/test.py` | safe | In `safe_paths` |
|
||||
| `~/projects/myapp/src/main.py` | safe | In `safe_paths` |
|
||||
| `~/random-repo/file.txt` | safe | Git repo detected |
|
||||
| `~/Downloads/file.txt` | outside | No match, triggers rules |
|
||||
|
||||
**Edge case:** If a git repo exists under a blocked path (unlikely), blocked path wins.
|
||||
|
||||
## Hook Implementation
|
||||
|
||||
### Registration
|
||||
|
||||
Add to `~/.claude/hooks/hooks.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.claude/hooks/scripts/guardrail.py",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Hook Input (stdin)
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {
|
||||
"command": "rm -rf ~/Downloads/old-project"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hook Output (stdout)
|
||||
|
||||
Allow:
|
||||
```json
|
||||
{"decision": "allow"}
|
||||
```
|
||||
|
||||
Block:
|
||||
```json
|
||||
{
|
||||
"decision": "block",
|
||||
"reason": "GUARDRAIL BLOCKED: 'rm' outside safe paths.\nTarget: ~/Downloads/old-project\nRule: rm_outside_safe\n\nTo proceed, confirm with user then run:\npython ~/.claude/hooks/scripts/guardrail-confirm.py 'rm -rf ~/Downloads/old-project'"
|
||||
}
|
||||
```
|
||||
|
||||
## Confirmation Flow
|
||||
|
||||
1. **Guardrail blocks** with detailed message including confirm command
|
||||
2. **Claude reports** to user, asks for confirmation
|
||||
3. **User confirms** in conversation
|
||||
4. **Claude runs confirm script:**
|
||||
```bash
|
||||
python ~/.claude/hooks/scripts/guardrail-confirm.py "rm -rf ~/Downloads/old-project"
|
||||
```
|
||||
5. **Confirm script** adds to session allowlist
|
||||
6. **Claude retries** the original command
|
||||
7. **Guardrail checks allowlist** - finds match - allows
|
||||
|
||||
### Session Allowlist
|
||||
|
||||
**Location:** `~/.claude/state/guardrail-session.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"confirmed": [
|
||||
{"tool": "Bash", "operation": "rm -rf ~/Downloads/old-project", "ts": "2025-01-06T10:24:00"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Cleanup:** `session-end.sh` clears this file so confirmations don't persist across sessions.
|
||||
|
||||
**Matching:** Exact match on tool + operation string.
|
||||
|
||||
## Logging
|
||||
|
||||
**Location:** `~/.claude/logs/guardrail.jsonl`
|
||||
|
||||
**Format:** JSON Lines (append-only)
|
||||
|
||||
**Logged events:**
|
||||
- Every `block` action
|
||||
- Every `confirm` action (required approval)
|
||||
- When confirmed operation is allowed
|
||||
|
||||
### Log Entry Structure
|
||||
|
||||
```json
|
||||
{"ts": "2025-01-06T10:23:45", "tool": "Bash", "operation": "rm -rf /", "action": "block", "rule": "rm_rf_root", "path_context": "n/a"}
|
||||
{"ts": "2025-01-06T10:24:01", "tool": "Bash", "operation": "rm ~/Downloads/old", "action": "confirm_required", "rule": "rm_outside_safe", "path_context": "outside"}
|
||||
{"ts": "2025-01-06T10:24:30", "tool": "Bash", "operation": "rm ~/Downloads/old", "action": "confirmed_allow", "rule": "session_allowlist"}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
- `ts`: ISO 8601 timestamp
|
||||
- `tool`: Bash, Write, or Edit
|
||||
- `operation`: Command or file path
|
||||
- `action`: `block`, `confirm_required`, or `confirmed_allow`
|
||||
- `rule`: Which rule triggered
|
||||
- `path_context`: `safe`, `outside`, `blocked`, or `n/a`
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Create `state/guardrails.json` with starter rules
|
||||
- [ ] Create `hooks/scripts/guardrail.py` main logic
|
||||
- [ ] Create `hooks/scripts/guardrail-confirm.py` confirm helper
|
||||
- [ ] Modify `hooks/hooks.json` to add PreToolUse registration
|
||||
- [ ] Modify `hooks/scripts/session-end.sh` to clear session allowlist
|
||||
- [ ] Create `logs/` directory
|
||||
- [ ] Test: block scenario (catastrophic command)
|
||||
- [ ] Test: confirm scenario (rm outside safe paths)
|
||||
- [ ] Test: allow scenario (operation in safe path)
|
||||
- [ ] Test: git-aware detection
|
||||
|
||||
## Starter Rules
|
||||
|
||||
| Category | Pattern | Action |
|
||||
|----------|---------|--------|
|
||||
| Catastrophic | `rm -rf /`, `rm -rf ~`, `mkfs.*`, `dd.*of=/dev/` | block |
|
||||
| Fork bomb | `:(){ :\|:& };:` | block |
|
||||
| Dangerous chmod | `chmod -R 777` | block |
|
||||
| System commands | `shutdown`, `reboot` | confirm |
|
||||
| Service control | `systemctl (stop\|disable\|mask)` | confirm |
|
||||
| Destructive outside safe | `rm ` (outside safe paths) | confirm |
|
||||
| K8s destructive | `kubectl delete` | confirm |
|
||||
| Docker destructive | `docker rm`, `docker system prune` | confirm |
|
||||
| Write to blocked paths | Any Write/Edit to `/etc`, `~/.ssh`, etc. | block |
|
||||
| Write outside safe | Any Write/Edit outside safe paths | confirm |
|
||||
@@ -0,0 +1,388 @@
|
||||
# Agentic RAG Design
|
||||
|
||||
**Date:** 2025-01-21
|
||||
**Status:** Ready for implementation
|
||||
**Category:** Agent memory / Knowledge retrieval
|
||||
|
||||
## Overview
|
||||
|
||||
Add semantic search to the existing Claude agent system, enabling multi-source reasoning that combines personal context (state files, memory, decisions) with external documentation.
|
||||
|
||||
### Goals
|
||||
|
||||
- Retrieve relevant past decisions and preferences when answering questions
|
||||
- Search external docs (k0s, ArgoCD, Prometheus, etc.) for technical reference
|
||||
- Cross-reference personal context with official documentation
|
||||
- Support iterative query refinement (agentic behavior)
|
||||
|
||||
### Non-Goals (Future Considerations)
|
||||
|
||||
Deferred to `future-considerations.json`:
|
||||
|
||||
- **fc-043**: Auto-sync on tool version change
|
||||
- **fc-044**: Broad doc indexing (hundreds of sources)
|
||||
- **fc-045**: K8s deployment
|
||||
- **fc-046**: Query caching
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
User question
|
||||
│
|
||||
▼
|
||||
Personal Assistant (existing)
|
||||
│
|
||||
├── Decides if RAG would help
|
||||
│
|
||||
▼
|
||||
rag-search skill (new)
|
||||
│
|
||||
├── Query embedding
|
||||
├── Vector similarity search
|
||||
├── Return ranked chunks with metadata
|
||||
│
|
||||
▼
|
||||
Claude reasons over results
|
||||
│
|
||||
├── Good enough? → Answer
|
||||
└── Need more? → Reformulate, search again
|
||||
```
|
||||
|
||||
### Two Indexes
|
||||
|
||||
| Index | Contents | Update Frequency |
|
||||
|-------|----------|------------------|
|
||||
| **personal** | `~/.claude/state/` files, memory, decisions, preferences | Daily |
|
||||
| **docs** | External documentation (k0s, ArgoCD, etc.) | Daily |
|
||||
|
||||
### Why Two Indexes
|
||||
|
||||
- Different update frequencies
|
||||
- Different retrieval strategies (personal may weight recency)
|
||||
- Can query one or both depending on the question
|
||||
|
||||
## Components
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ rag-search skill │
|
||||
│ (Claude invokes this) │
|
||||
└─────────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ Personal Index │ │ Docs Index │
|
||||
│ │ │ │
|
||||
│ ~/.claude/state/* │ │ External docs │
|
||||
│ memory/*.json │ │ (k0s, ArgoCD...) │
|
||||
│ kb.json │ │ │
|
||||
└────────┬──────────┘ └────────┬──────────┘
|
||||
│ │
|
||||
└──────────┬──────────────┘
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ Vector Store │
|
||||
│ (ChromaDB) │
|
||||
│ │
|
||||
│ Collections: │
|
||||
│ - personal │
|
||||
│ - docs │
|
||||
└────────┬──────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ Embedding Model │
|
||||
│ (sentence- │
|
||||
│ transformers) │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
### Stack
|
||||
|
||||
| Component | Choice | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Vector store | ChromaDB | Pure Python, no external deps |
|
||||
| Embeddings | sentence-transformers (all-MiniLM-L6-v2) | Runs on arm64, ~90MB |
|
||||
| Storage | `~/.claude/data/rag-search/` | Local to workstation |
|
||||
|
||||
## Skill Structure
|
||||
|
||||
**Location:** `~/.claude/skills/rag-search/`
|
||||
|
||||
```
|
||||
rag-search/
|
||||
├── SKILL.md # Instructions for Claude
|
||||
├── scripts/
|
||||
│ ├── search.py # Main search entry point
|
||||
│ ├── index_personal.py # Index state files
|
||||
│ ├── index_docs.py # Index external docs
|
||||
│ └── add_doc_source.py # Add new doc source
|
||||
└── references/
|
||||
└── sources.json # Configured doc sources
|
||||
```
|
||||
|
||||
## Skill Interface
|
||||
|
||||
### Invocation
|
||||
|
||||
```bash
|
||||
# Basic search (both indexes)
|
||||
~/.claude/skills/rag-search/scripts/search.py "how did I configure ArgoCD sync?"
|
||||
|
||||
# Search specific index
|
||||
~/.claude/skills/rag-search/scripts/search.py --index personal "past decisions about caching"
|
||||
~/.claude/skills/rag-search/scripts/search.py --index docs "k0s node maintenance"
|
||||
|
||||
# Control result count
|
||||
~/.claude/skills/rag-search/scripts/search.py --top-k 10 "prometheus alerting rules"
|
||||
```
|
||||
|
||||
### Output Format
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "how did I configure ArgoCD sync?",
|
||||
"results": [
|
||||
{
|
||||
"rank": 1,
|
||||
"score": 0.847,
|
||||
"source": "personal",
|
||||
"file": "memory/decisions.json",
|
||||
"chunk": "Decided to use ArgoCD auto-sync with self-heal disabled...",
|
||||
"metadata": {"date": "2025-01-15", "context": "k8s setup"}
|
||||
},
|
||||
{
|
||||
"rank": 2,
|
||||
"score": 0.823,
|
||||
"source": "docs",
|
||||
"file": "argocd/sync-options.md",
|
||||
"chunk": "Auto-sync can be configured with selfHeal and prune options...",
|
||||
"metadata": {"doc_version": "2.9", "url": "https://..."}
|
||||
}
|
||||
],
|
||||
"searched_collections": ["personal", "docs"],
|
||||
"total_chunks_searched": 1847
|
||||
}
|
||||
```
|
||||
|
||||
### SKILL.md Guidance
|
||||
|
||||
- Start with broad query, refine if results aren't relevant
|
||||
- Cross-reference personal decisions with docs when both appear
|
||||
- Cite sources in answers (file + date for personal, URL for docs)
|
||||
|
||||
## External Docs Management
|
||||
|
||||
### Source Registry
|
||||
|
||||
**Location:** `~/.claude/skills/rag-search/references/sources.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"id": "k0s",
|
||||
"name": "k0s Documentation",
|
||||
"type": "git",
|
||||
"url": "https://github.com/k0sproject/k0s.git",
|
||||
"path": "docs/",
|
||||
"glob": "**/*.md",
|
||||
"version": "v1.30.0",
|
||||
"last_indexed": "2025-01-20T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "argocd",
|
||||
"name": "ArgoCD Documentation",
|
||||
"type": "web",
|
||||
"base_url": "https://argo-cd.readthedocs.io/en/stable/",
|
||||
"pages": ["user-guide/sync-options/", "operator-manual/"],
|
||||
"last_indexed": "2025-01-18T14:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Sources
|
||||
|
||||
```bash
|
||||
~/.claude/skills/rag-search/scripts/add_doc_source.py \
|
||||
--id "cilium" \
|
||||
--name "Cilium Docs" \
|
||||
--type git \
|
||||
--url "https://github.com/cilium/cilium.git" \
|
||||
--path "Documentation/" \
|
||||
--glob "**/*.md"
|
||||
|
||||
# Then index it
|
||||
~/.claude/skills/rag-search/scripts/index_docs.py --source cilium
|
||||
```
|
||||
|
||||
### Update Strategies
|
||||
|
||||
| Strategy | Command | When |
|
||||
|----------|---------|------|
|
||||
| Manual | `index_docs.py --source <id>` | After version upgrade |
|
||||
| All sources | `index_docs.py --all` | Periodic refresh |
|
||||
|
||||
## Periodic Refresh
|
||||
|
||||
Daily systemd timer on workstation.
|
||||
|
||||
### Service
|
||||
|
||||
**Location:** `~/.config/systemd/user/rag-index.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Refresh RAG search indexes
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=%h/.claude/skills/rag-search/scripts/index_docs.py --all --quiet
|
||||
ExecStartPost=%h/.claude/skills/rag-search/scripts/index_personal.py --quiet
|
||||
Environment=PATH=%h/.claude/skills/rag-search/venv/bin:/usr/bin
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
### Timer
|
||||
|
||||
**Location:** `~/.config/systemd/user/rag-index.timer`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Daily RAG index refresh
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
Persistent=true
|
||||
RandomizedDelaySec=3600
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
### Enable
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now rag-index.timer
|
||||
```
|
||||
|
||||
### Manual Trigger
|
||||
|
||||
```bash
|
||||
systemctl --user start rag-index.service
|
||||
journalctl --user -u rag-index.service # View logs
|
||||
```
|
||||
|
||||
## Resource Requirements
|
||||
|
||||
**Target:** Workstation or Pi5 8GB
|
||||
|
||||
| Component | RAM | Disk | Notes |
|
||||
|-----------|-----|------|-------|
|
||||
| Embedding model (all-MiniLM-L6-v2) | ~256MB | ~90MB | Loaded on-demand |
|
||||
| ChromaDB | ~100-500MB | Varies | Scales with index size |
|
||||
| Index: personal (~50 files) | — | ~5MB | Small, fast to query |
|
||||
| Index: docs (10-20 sources) | — | ~100-500MB | Depends on doc volume |
|
||||
| Indexing process (peak) | ~1GB | — | During embedding generation |
|
||||
|
||||
**Pi3 1GB:** Not suitable for this workload.
|
||||
|
||||
## Chunking Strategy
|
||||
|
||||
| Index | Strategy |
|
||||
|-------|----------|
|
||||
| Personal | Per JSON key or logical section (decisions, preferences, facts as separate chunks) |
|
||||
| Docs | ~500 tokens per chunk with overlap, preserve headers as metadata |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Recommended: Ralph Loop
|
||||
|
||||
This design is suitable for Ralph loop implementation:
|
||||
- Clear success criteria (tests, functional checks)
|
||||
- Iterative refinement expected (tuning chunking, embeddings)
|
||||
- Automatic verification possible
|
||||
|
||||
### Model Delegation
|
||||
|
||||
Use appropriate models for each phase:
|
||||
|
||||
| Phase | Task | Model |
|
||||
|-------|------|-------|
|
||||
| 1 | Set up ChromaDB + embedding model | Haiku |
|
||||
| 2 | Write `index_personal.py` | Sonnet |
|
||||
| 3 | Write `index_docs.py` | Sonnet |
|
||||
| 4 | Write `search.py` | Sonnet |
|
||||
| 5 | Write SKILL.md | Haiku |
|
||||
| 6 | Integration tests | Sonnet |
|
||||
| 7 | End-to-end validation | Sonnet |
|
||||
|
||||
### Ralph Invocation
|
||||
|
||||
```bash
|
||||
/ralph-loop "Implement rag-search skill per docs/plans/2025-01-21-agentic-rag-design.md.
|
||||
|
||||
Delegate to appropriate models:
|
||||
- Haiku: setup, docs, simple scripts
|
||||
- Sonnet: implementation, tests, debugging
|
||||
- Opus: only if stuck on complex reasoning
|
||||
|
||||
Success criteria:
|
||||
1. ChromaDB + embeddings working
|
||||
2. Personal index populated from ~/.claude/state
|
||||
3. At least one external doc source indexed
|
||||
4. search.py returns relevant results
|
||||
5. All tests pass
|
||||
|
||||
Output <promise>COMPLETE</promise> when done." --max-iterations 30 --completion-promise "COMPLETE"
|
||||
```
|
||||
|
||||
### When NOT to use Ralph
|
||||
|
||||
- Design decisions still needed (use brainstorming first)
|
||||
- Requires human judgment mid-implementation
|
||||
- One-shot simple tasks
|
||||
|
||||
## Workflow Integration
|
||||
|
||||
```
|
||||
/superpowers:brainstorm
|
||||
│
|
||||
▼
|
||||
Design doc created
|
||||
(docs/plans/YYYY-MM-DD-*-design.md)
|
||||
│
|
||||
▼
|
||||
"Ready to implement?"
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
Simple Complex/Iterative
|
||||
│ │
|
||||
▼ ▼
|
||||
Manual /ralph-loop
|
||||
or TDD with design doc
|
||||
as spec
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
| Aspect | Decision |
|
||||
|--------|----------|
|
||||
| **Architecture** | Extend existing Claude skill system with semantic search |
|
||||
| **Indexes** | Two: personal (state files) + docs (external) |
|
||||
| **Vector store** | ChromaDB (local, no deps) |
|
||||
| **Embeddings** | sentence-transformers (all-MiniLM-L6-v2) |
|
||||
| **Skill interface** | `rag-search` skill with `search.py` CLI |
|
||||
| **Doc management** | `sources.json` registry, git/web fetching |
|
||||
| **Refresh** | systemd user timer, daily |
|
||||
| **Storage** | `~/.claude/data/rag-search/` |
|
||||
| **Hardware** | Runs on workstation (Pi5 8GB capable if needed) |
|
||||
| **Implementation** | Ralph loop with Haiku/Sonnet subagent delegation |
|
||||
@@ -0,0 +1,75 @@
|
||||
# Design: Google Tasks Skill
|
||||
|
||||
## Summary
|
||||
|
||||
Minimal `/tasks` skill to list Google Tasks. Thin wrapper around existing morning-report collector.
|
||||
|
||||
## Scope
|
||||
|
||||
- **In scope:** List pending tasks
|
||||
- **Out of scope (for now):** Complete, add, delete, filter by list
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
skills/gtasks/
|
||||
├── SKILL.md
|
||||
└── scripts/
|
||||
└── list.py
|
||||
```
|
||||
|
||||
## SKILL.md
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: gtasks
|
||||
description: Google Tasks read access — list pending tasks. Use when asked about tasks, todos, or what needs to be done.
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
---
|
||||
```
|
||||
|
||||
Quick command: `$GMAIL_PY ~/.claude/skills/gtasks/scripts/list.py`
|
||||
|
||||
Request routing:
|
||||
| User Request | Action |
|
||||
|--------------|--------|
|
||||
| "What are my tasks?" | `list.py` |
|
||||
| "Show my todos" | `list.py` |
|
||||
| "/tasks" | `list.py` |
|
||||
|
||||
Policy: Read-only, summarize results.
|
||||
|
||||
## list.py Script
|
||||
|
||||
Thin wrapper that imports from `morning-report/scripts/collectors/gtasks.py`.
|
||||
|
||||
- Default shows up to 10 tasks
|
||||
- Optional arg for more: `list.py 20`
|
||||
- Reuses existing auth and fetch logic
|
||||
|
||||
## Registry Updates
|
||||
|
||||
**skills:**
|
||||
```json
|
||||
"gtasks": {
|
||||
"description": "Google Tasks read access - list pending tasks",
|
||||
"triggers": ["tasks", "todo", "to do", "to-do", "pending"]
|
||||
}
|
||||
```
|
||||
|
||||
**commands:**
|
||||
```json
|
||||
"/tasks": {
|
||||
"description": "List Google Tasks",
|
||||
"aliases": ["/todo", "/todos"],
|
||||
"invokes": "skill:gtasks"
|
||||
}
|
||||
```
|
||||
|
||||
## Future Expansion
|
||||
|
||||
- Complete task by ID
|
||||
- Filter by task list
|
||||
- Show due dates
|
||||
@@ -0,0 +1,296 @@
|
||||
# Workstation Monitoring Design
|
||||
|
||||
## Overview
|
||||
|
||||
Deploy comprehensive monitoring for the Arch Linux workstation (willlaptop) by integrating with the existing k8s monitoring stack. This will enable proactive alerting for resource exhaustion, long-term capacity planning, and performance debugging.
|
||||
|
||||
**Reference:** Future consideration `fc-001` (workstation monitoring)
|
||||
|
||||
## Current Infrastructure
|
||||
|
||||
- **Workstation:** Arch Linux on MacBookPro9,2 (hostname: willlaptop)
|
||||
- **K8s Cluster:** kube-prometheus-stack deployed with Prometheus, Alertmanager, Grafana
|
||||
- **Network:** Direct network connectivity between workstation and cluster nodes
|
||||
- **Existing Monitoring:** 3 node_exporters on cluster nodes, cluster-level alerts configured
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
```
|
||||
┌─────────────────┐ HTTP/9100 ┌──────────────────────┐
|
||||
│ Workstation │ ──────────────────> │ K8s Prometheus │
|
||||
│ (willlaptop) │ scrape every 15s │ (monitoring ns) │
|
||||
│ │ │ │
|
||||
│ node_exporter │ │ workstation rules │
|
||||
│ systemd service│ │ + scrape config │
|
||||
└─────────────────┘ └──────────────────────┘
|
||||
│
|
||||
v
|
||||
┌──────────────────────┐
|
||||
│ Alertmanager │
|
||||
│ (existing setup) │
|
||||
│ unified routing │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **node_exporter** exposes metrics on `http://willlaptop:9100/metrics`
|
||||
2. **Prometheus** scrapes metrics every 15s via static target configuration
|
||||
3. **PrometheusRule** evaluates workstation-specific alert rules
|
||||
4. **Alertmanager** routes alerts to existing notification channels
|
||||
|
||||
## Workstation Deployment
|
||||
|
||||
### node_exporter Service
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
pacman -S prometheus-node-exporter
|
||||
```
|
||||
|
||||
**Systemd Configuration:**
|
||||
- Service: `node_exporter.service`
|
||||
- User: `node_exporter` (created by package)
|
||||
- Listen address: `0.0.0.0:9100`
|
||||
- Restart policy: `always` with 10s delay
|
||||
- Logging: systemd journal (`journalctl -u node_exporter`)
|
||||
|
||||
**ExecStart flags:**
|
||||
```bash
|
||||
/usr/bin/node_exporter --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/)
|
||||
```
|
||||
|
||||
Excludes system mounts to reduce noise.
|
||||
|
||||
**Firewall Configuration:**
|
||||
- Allow TCP 9100 from cluster nodes
|
||||
- Use ufw or iptables to restrict access
|
||||
|
||||
**Metrics Collected:**
|
||||
All default collectors except resource-intensive ones:
|
||||
- CPU, memory, filesystem, network
|
||||
- System stats (uptime, load average, systemd)
|
||||
- Thermal (if available on hardware)
|
||||
- Disk I/O
|
||||
|
||||
## Prometheus Configuration
|
||||
|
||||
### Static Scrape Target
|
||||
|
||||
**Job configuration:**
|
||||
- Job name: `workstation/willlaptop`
|
||||
- Target: `willlaptop:9100` (DNS resolution) or workstation IP
|
||||
- Scrape interval: `15s` (matches cluster node_exporter)
|
||||
- Scrape timeout: `10s`
|
||||
- Metrics path: `/metrics`
|
||||
- Honor labels: `true`
|
||||
|
||||
**Relabeling rules:**
|
||||
- Add `env: "workstation"` label for identification
|
||||
- Preserve `instance: "willlaptop"` from target
|
||||
|
||||
**Integration:**
|
||||
Add to existing Prometheus CRD configuration in kube-prometheus-stack. This can be done via:
|
||||
- PrometheusRule with additional scrape config, or
|
||||
- Direct modification of Prometheus configuration
|
||||
|
||||
## Alert Rules
|
||||
|
||||
### PrometheusRule Resource
|
||||
|
||||
**Namespace:** `monitoring`
|
||||
**Kind:** `PrometheusRule`
|
||||
**Labels:** Standard discovery labels for Prometheus operator
|
||||
|
||||
### Alert Categories
|
||||
|
||||
#### Critical Alerts (Paging)
|
||||
|
||||
1. **WorkstationDiskSpaceCritical**
|
||||
- Condition: `<5%` free on any mounted filesystem
|
||||
- Duration: 5m
|
||||
- Severity: critical
|
||||
|
||||
2. **WorkstationMemoryCritical**
|
||||
- Condition: `>95%` memory usage
|
||||
- Duration: 5m
|
||||
- Severity: critical
|
||||
|
||||
3. **WorkstationCPUCritical**
|
||||
- Condition: `>95%` CPU usage
|
||||
- Duration: 10m
|
||||
- Severity: critical
|
||||
|
||||
4. **WorkstationSystemdFailed**
|
||||
- Condition: Failed systemd units detected
|
||||
- Duration: 5m
|
||||
- Severity: critical
|
||||
|
||||
#### Warning Alerts (Email/Slack)
|
||||
|
||||
1. **WorkstationDiskSpaceWarning**
|
||||
- Condition: `<10%` free on any mounted filesystem
|
||||
- Duration: 10m
|
||||
- Severity: warning
|
||||
|
||||
2. **WorkstationMemoryWarning**
|
||||
- Condition: `>85%` memory usage
|
||||
- Duration: 10m
|
||||
- Severity: warning
|
||||
|
||||
3. **WorkstationCPUWarning**
|
||||
- Condition: `>80%` CPU usage
|
||||
- Duration: 15m
|
||||
- Severity: warning
|
||||
|
||||
4. **WorkstationLoadHigh**
|
||||
- Condition: 5m load average > # CPU cores
|
||||
- Duration: 10m
|
||||
- Severity: warning
|
||||
|
||||
5. **WorkstationDiskInodeWarning**
|
||||
- Condition: `<10%` inodes free
|
||||
- Duration: 10m
|
||||
- Severity: warning
|
||||
|
||||
6. **WorkstationNetworkErrors**
|
||||
- Condition: High packet loss or error rate
|
||||
- Duration: 10m
|
||||
- Severity: warning
|
||||
|
||||
#### Info Alerts (Log Only)
|
||||
|
||||
1. **WorkstationDiskSpaceInfo**
|
||||
- Condition: `<20%` free on any mounted filesystem
|
||||
- Duration: 15m
|
||||
- Severity: info
|
||||
|
||||
2. **WorkstationUptime**
|
||||
- Condition: System uptime metric (recording rule)
|
||||
- Severity: info
|
||||
|
||||
### Alert Annotations
|
||||
|
||||
Each alert includes:
|
||||
- `summary`: Brief description
|
||||
- `description`: Detailed explanation with metric values
|
||||
- `runbook_url`: Link to troubleshooting documentation (if available)
|
||||
|
||||
## Versioning
|
||||
|
||||
### Repository Structure
|
||||
|
||||
```
|
||||
~/.claude/repos/homelab/charts/willlaptop-monitoring/
|
||||
├── prometheus-rules.yaml # PrometheusRule for workstation alerts
|
||||
├── values.yaml # Configuration values
|
||||
└── README.md # Documentation
|
||||
```
|
||||
|
||||
### Values.yaml Configuration
|
||||
|
||||
Configurable parameters:
|
||||
```yaml
|
||||
workstation:
|
||||
hostname: willlaptop
|
||||
ip: <workstation_ip> # optional, fallback to DNS
|
||||
|
||||
scrape:
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
|
||||
alerts:
|
||||
disk:
|
||||
critical_percent: 5
|
||||
warning_percent: 10
|
||||
info_percent: 20
|
||||
memory:
|
||||
critical_percent: 95
|
||||
warning_percent: 85
|
||||
cpu:
|
||||
critical_percent: 95
|
||||
critical_duration: 10m
|
||||
warning_percent: 80
|
||||
warning_duration: 15m
|
||||
```
|
||||
|
||||
### Integration with ArgoCD
|
||||
|
||||
Follows existing GitOps pattern (charts/kube-prometheus-stack). Can be added to ArgoCD for automated deployments if desired.
|
||||
|
||||
## Testing and Verification
|
||||
|
||||
### Phase 1 - Workstation Deployment
|
||||
|
||||
1. Verify node_exporter installation:
|
||||
```bash
|
||||
pacman -Q prometheus-node-exporter
|
||||
```
|
||||
|
||||
2. Check systemd service status:
|
||||
```bash
|
||||
systemctl status node_exporter
|
||||
```
|
||||
|
||||
3. Verify metrics endpoint locally:
|
||||
```bash
|
||||
curl http://localhost:9100/metrics | head -20
|
||||
```
|
||||
|
||||
4. Test accessibility from cluster:
|
||||
```bash
|
||||
kubectl run -it --rm debug --image=curlimages/curl -- curl willlaptop:9100/metrics
|
||||
```
|
||||
|
||||
### Phase 2 - Prometheus Integration
|
||||
|
||||
1. Verify Prometheus target:
|
||||
- Access Prometheus UI → Targets → workstation/willlaptop
|
||||
- Confirm target is UP
|
||||
|
||||
2. Verify metric ingestion:
|
||||
```bash
|
||||
# Query in Prometheus UI
|
||||
node_up{instance="willlaptop"}
|
||||
```
|
||||
|
||||
3. Verify label injection:
|
||||
- Confirm `env="workstation"` label appears on metrics
|
||||
|
||||
### Phase 3 - Alert Verification
|
||||
|
||||
1. Review PrometheusRule:
|
||||
```bash
|
||||
kubectl get prometheusrule workstation-alerts -n monitoring -o yaml
|
||||
```
|
||||
|
||||
2. Verify rule evaluation:
|
||||
- Access Prometheus UI → Rules
|
||||
- Confirm workstation rules are active
|
||||
|
||||
3. Test critical alert:
|
||||
- Temporarily trigger a low disk alert (or simulate)
|
||||
- Verify alert fires in Prometheus UI
|
||||
|
||||
4. Verify Alertmanager integration:
|
||||
- Check Alertmanager UI → Alerts
|
||||
- Confirm workstation alerts are received
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] node_exporter running on workstation
|
||||
- [ ] Metrics accessible from cluster nodes
|
||||
- [ ] Prometheus scraping workstation metrics
|
||||
- [ ] Alert rules evaluated and firing correctly
|
||||
- [ ] Alerts routing through Alertmanager
|
||||
- [ ] Configuration versioned in homelab repository
|
||||
- [ ] Documentation complete
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Grafana dashboards for workstation metrics
|
||||
- Alert tuning based on observed patterns
|
||||
- Additional collectors (e.g., temperature sensors if available)
|
||||
- Integration with morning-report skill for health status
|
||||
@@ -1,5 +1,16 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.claude/hooks/scripts/prompt-context.py",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
@@ -19,6 +30,29 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.claude/hooks/scripts/session-end.sh",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.claude/hooks/scripts/guardrail.py",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Guardrail Confirm Helper
|
||||
|
||||
Adds an operation to the session allowlist so it can proceed on retry.
|
||||
|
||||
Usage:
|
||||
python3 guardrail-confirm.py "<tool>" "<operation>"
|
||||
|
||||
Example:
|
||||
python3 guardrail-confirm.py "Bash" "rm -rf ~/Downloads/old-project"
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path.home()
|
||||
STATE_DIR = HOME / ".claude" / "state"
|
||||
SESSION_FILE = STATE_DIR / "guardrail-session.json"
|
||||
|
||||
|
||||
def load_session():
|
||||
"""Load current session allowlist."""
|
||||
if not SESSION_FILE.exists():
|
||||
return {"confirmed": []}
|
||||
try:
|
||||
with open(SESSION_FILE) as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {"confirmed": []}
|
||||
|
||||
|
||||
def save_session(data: dict):
|
||||
"""Save session allowlist."""
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(SESSION_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: guardrail-confirm.py <tool> <operation>")
|
||||
print("Example: guardrail-confirm.py 'Bash' 'rm ~/Downloads/old'")
|
||||
sys.exit(1)
|
||||
|
||||
tool = sys.argv[1]
|
||||
operation = sys.argv[2]
|
||||
|
||||
# Load current session
|
||||
session = load_session()
|
||||
|
||||
# Check if already confirmed
|
||||
for item in session.get("confirmed", []):
|
||||
if item.get("tool") == tool and item.get("operation") == operation:
|
||||
print(f"Already confirmed: {tool} - {operation[:50]}...")
|
||||
sys.exit(0)
|
||||
|
||||
# Add to allowlist
|
||||
session["confirmed"].append({
|
||||
"tool": tool,
|
||||
"operation": operation,
|
||||
"ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
})
|
||||
|
||||
save_session(session)
|
||||
print(f"Confirmed: {tool} - {operation[:50]}...")
|
||||
print("You may now retry the operation.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+283
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Guardrail PreToolUse Hook
|
||||
|
||||
Intercepts Bash, Write, and Edit tool calls to prevent dangerous operations.
|
||||
Returns JSON decision: {"decision": "allow"} or {"decision": "block", "reason": "..."}
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Paths
|
||||
HOME = Path.home()
|
||||
STATE_DIR = HOME / ".claude" / "state"
|
||||
LOGS_DIR = HOME / ".claude" / "logs"
|
||||
CONFIG_FILE = STATE_DIR / "guardrails.json"
|
||||
SESSION_FILE = STATE_DIR / "guardrail-session.json"
|
||||
LOG_FILE = LOGS_DIR / "guardrail.jsonl"
|
||||
|
||||
|
||||
def load_config():
|
||||
"""Load guardrails configuration."""
|
||||
if not CONFIG_FILE.exists():
|
||||
return None
|
||||
with open(CONFIG_FILE) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_session_allowlist():
|
||||
"""Load session allowlist of confirmed operations."""
|
||||
if not SESSION_FILE.exists():
|
||||
return {"confirmed": []}
|
||||
try:
|
||||
with open(SESSION_FILE) as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {"confirmed": []}
|
||||
|
||||
|
||||
def is_in_allowlist(tool: str, operation: str) -> bool:
|
||||
"""Check if operation was previously confirmed."""
|
||||
allowlist = load_session_allowlist()
|
||||
for item in allowlist.get("confirmed", []):
|
||||
if item.get("tool") == tool and item.get("operation") == operation:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def expand_path(path: str) -> Path:
|
||||
"""Expand ~ and resolve path."""
|
||||
return Path(os.path.expanduser(path)).resolve()
|
||||
|
||||
|
||||
def is_under_path(target: Path, parent: str) -> bool:
|
||||
"""Check if target is under parent path."""
|
||||
try:
|
||||
parent_path = expand_path(parent)
|
||||
# Handle glob patterns like ~/projects/*
|
||||
if "*" in parent:
|
||||
# For ~/projects/*, check if under ~/projects
|
||||
parent_path = expand_path(parent.replace("/*", "").replace("*", ""))
|
||||
return parent_path in target.parents or target == parent_path
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def is_in_git_repo(path: Path) -> bool:
|
||||
"""Check if path is inside a git repository."""
|
||||
current = path if path.is_dir() else path.parent
|
||||
while current != current.parent:
|
||||
if (current / ".git").exists():
|
||||
return True
|
||||
current = current.parent
|
||||
return False
|
||||
|
||||
|
||||
def classify_path(target_path: str, config: dict) -> str:
|
||||
"""
|
||||
Classify a path as 'blocked', 'safe', or 'outside'.
|
||||
|
||||
Evaluation order:
|
||||
1. Blocked paths -> 'blocked'
|
||||
2. Safe paths -> 'safe'
|
||||
3. Git repo -> 'safe'
|
||||
4. Otherwise -> 'outside'
|
||||
"""
|
||||
try:
|
||||
target = expand_path(target_path)
|
||||
except (ValueError, OSError):
|
||||
return "outside"
|
||||
|
||||
# Check blocked paths first
|
||||
for blocked in config.get("blocked_paths", []):
|
||||
if is_under_path(target, blocked):
|
||||
return "blocked"
|
||||
|
||||
# Check safe paths
|
||||
for safe in config.get("safe_paths", []):
|
||||
if is_under_path(target, safe):
|
||||
return "safe"
|
||||
|
||||
# Check if in git repo
|
||||
if is_in_git_repo(target):
|
||||
return "safe"
|
||||
|
||||
return "outside"
|
||||
|
||||
|
||||
def extract_paths_from_command(command: str) -> list[str]:
|
||||
"""Extract potential file paths from a bash command."""
|
||||
paths = []
|
||||
# Simple heuristic: look for things that look like paths
|
||||
# This catches ~/..., /..., and relative paths
|
||||
tokens = command.split()
|
||||
for token in tokens:
|
||||
# Skip flags
|
||||
if token.startswith("-"):
|
||||
continue
|
||||
# Skip common commands
|
||||
if token in ("rm", "mv", "cp", "chmod", "chown", "mkdir", "rmdir", "touch"):
|
||||
continue
|
||||
# Check if it looks like a path
|
||||
if "/" in token or token.startswith("~"):
|
||||
paths.append(token)
|
||||
return paths
|
||||
|
||||
|
||||
def check_bash_rules(command: str, config: dict) -> tuple[str, str | None, str]:
|
||||
"""
|
||||
Check bash command against rules.
|
||||
Returns: (action, rule_name, path_context)
|
||||
action: 'allow', 'block', or 'confirm'
|
||||
"""
|
||||
rules = config.get("rules", {}).get("bash", [])
|
||||
|
||||
for rule in rules:
|
||||
pattern = rule.get("pattern", "")
|
||||
action = rule.get("action", "allow")
|
||||
name = rule.get("name", "unnamed")
|
||||
outside_safe_only = rule.get("outside_safe_paths", False)
|
||||
|
||||
# Check if pattern matches
|
||||
if re.search(pattern, command):
|
||||
if outside_safe_only:
|
||||
# Only apply rule if operating outside safe paths
|
||||
paths = extract_paths_from_command(command)
|
||||
for path in paths:
|
||||
path_class = classify_path(path, config)
|
||||
if path_class in ("blocked", "outside"):
|
||||
return (action, name, path_class)
|
||||
# All paths are safe, allow
|
||||
continue
|
||||
else:
|
||||
# Rule applies regardless of path
|
||||
return (action, name, "n/a")
|
||||
|
||||
return ("allow", None, "safe")
|
||||
|
||||
|
||||
def check_file_rules(file_path: str, tool: str, config: dict) -> tuple[str, str | None, str]:
|
||||
"""
|
||||
Check Write/Edit file path against rules.
|
||||
Returns: (action, rule_name, path_context)
|
||||
"""
|
||||
rules = config.get("rules", {}).get(tool.lower(), [])
|
||||
path_class = classify_path(file_path, config)
|
||||
|
||||
for rule in rules:
|
||||
path_match = rule.get("path_match", "")
|
||||
action = rule.get("action", "allow")
|
||||
name = rule.get("name", "unnamed")
|
||||
|
||||
if path_match == "blocked_paths" and path_class == "blocked":
|
||||
return (action, name, path_class)
|
||||
elif path_match == "outside_safe_paths" and path_class == "outside":
|
||||
return (action, name, path_class)
|
||||
|
||||
return ("allow", None, path_class)
|
||||
|
||||
|
||||
def log_action(tool: str, operation: str, action: str, rule: str | None, path_context: str):
|
||||
"""Log guardrail action to audit log."""
|
||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
entry = {
|
||||
"ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"tool": tool,
|
||||
"operation": operation[:200], # Truncate long operations
|
||||
"action": action,
|
||||
"rule": rule or "none",
|
||||
"path_context": path_context
|
||||
}
|
||||
|
||||
with open(LOG_FILE, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
|
||||
def allow():
|
||||
"""Return allow decision."""
|
||||
print(json.dumps({"decision": "allow"}))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def block(reason: str):
|
||||
"""Return block decision with reason."""
|
||||
print(json.dumps({"decision": "block", "reason": reason}))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
# Read input from stdin
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
allow() # If we can't parse input, allow (fail open)
|
||||
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
|
||||
# Only check Bash, Write, Edit
|
||||
if tool_name not in ("Bash", "Write", "Edit"):
|
||||
allow()
|
||||
|
||||
# Load config
|
||||
config = load_config()
|
||||
if not config:
|
||||
allow() # No config, allow everything
|
||||
|
||||
# Determine operation string for allowlist check
|
||||
if tool_name == "Bash":
|
||||
operation = tool_input.get("command", "")
|
||||
else:
|
||||
operation = tool_input.get("file_path", "")
|
||||
|
||||
# Check session allowlist first
|
||||
if is_in_allowlist(tool_name, operation):
|
||||
log_action(tool_name, operation, "confirmed_allow", "session_allowlist", "n/a")
|
||||
allow()
|
||||
|
||||
# Check rules based on tool type
|
||||
if tool_name == "Bash":
|
||||
action, rule_name, path_context = check_bash_rules(operation, config)
|
||||
else:
|
||||
action, rule_name, path_context = check_file_rules(operation, tool_name, config)
|
||||
|
||||
# Take action
|
||||
if action == "allow":
|
||||
allow()
|
||||
|
||||
# Log blocked/confirm actions
|
||||
log_action(tool_name, operation, action if action == "block" else "confirm_required", rule_name, path_context)
|
||||
|
||||
# Build block message
|
||||
if action == "block":
|
||||
reason = f"""GUARDRAIL BLOCKED: Operation not allowed.
|
||||
Tool: {tool_name}
|
||||
Operation: {operation}
|
||||
Rule: {rule_name}
|
||||
Path context: {path_context}
|
||||
|
||||
This operation is blocked by guardrail policy and cannot proceed."""
|
||||
else: # confirm
|
||||
confirm_cmd = f'python3 ~/.claude/hooks/scripts/guardrail-confirm.py "{tool_name}" "{operation}"'
|
||||
reason = f"""GUARDRAIL: User confirmation required.
|
||||
Tool: {tool_name}
|
||||
Operation: {operation}
|
||||
Rule: {rule_name}
|
||||
Path context: {path_context}
|
||||
|
||||
To proceed after user confirms, run:
|
||||
{confirm_cmd}
|
||||
|
||||
Then retry the original operation."""
|
||||
|
||||
block(reason)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+173
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
UserPromptSubmit hook - inject contextual information based on prompt.
|
||||
|
||||
Injects:
|
||||
- Time-aware context
|
||||
- Current git branch (if in a repo)
|
||||
- Relevant memory items based on prompt keywords
|
||||
- Pending decisions needing attention
|
||||
|
||||
Output goes to stdout and is added to Claude's context.
|
||||
Keep this fast (<5s) to not slow down prompts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Paths
|
||||
STATE_DIR = Path.home() / ".claude/state/personal-assistant"
|
||||
MEMORY_DIR = STATE_DIR / "memory"
|
||||
|
||||
|
||||
def get_time_context() -> str:
|
||||
"""Get time-aware greeting context."""
|
||||
hour = datetime.now().hour
|
||||
if 5 <= hour < 12:
|
||||
period = "morning"
|
||||
elif 12 <= hour < 17:
|
||||
period = "afternoon"
|
||||
elif 17 <= hour < 21:
|
||||
period = "evening"
|
||||
else:
|
||||
period = "night"
|
||||
|
||||
return f"Current time: {datetime.now().strftime('%Y-%m-%d %H:%M')} ({period})"
|
||||
|
||||
|
||||
def get_git_context(cwd: str) -> str | None:
|
||||
"""Get current git branch if in a repo."""
|
||||
if not cwd:
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
cwd=cwd
|
||||
)
|
||||
if result.returncode == 0:
|
||||
branch = result.stdout.strip()
|
||||
if branch:
|
||||
return f"Git branch: {branch}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_relevant_memory(prompt: str, limit: int = 3) -> list[str]:
|
||||
"""Find memory items relevant to the prompt."""
|
||||
relevant = []
|
||||
prompt_lower = prompt.lower()
|
||||
|
||||
# Keywords to look for
|
||||
keywords = set(re.findall(r'\b\w{4,}\b', prompt_lower))
|
||||
|
||||
if not keywords:
|
||||
return relevant
|
||||
|
||||
# Check each memory file
|
||||
for memory_file in ["decisions.json", "preferences.json", "projects.json"]:
|
||||
path = MEMORY_DIR / memory_file
|
||||
if not path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
for item in data.get("items", []):
|
||||
content = item.get("content", "").lower()
|
||||
context = item.get("context", "").lower()
|
||||
|
||||
# Check for keyword matches
|
||||
item_words = set(re.findall(r'\b\w{4,}\b', content + " " + context))
|
||||
matches = keywords & item_words
|
||||
|
||||
if len(matches) >= 2: # Require at least 2 matching keywords
|
||||
category = memory_file.replace(".json", "").rstrip("s")
|
||||
relevant.append(f"[{category}] {item.get('content', '')}")
|
||||
|
||||
if len(relevant) >= limit:
|
||||
return relevant
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return relevant
|
||||
|
||||
|
||||
def get_pending_decisions(limit: int = 2) -> list[str]:
|
||||
"""Get recent pending decisions."""
|
||||
pending = []
|
||||
decisions_path = MEMORY_DIR / "decisions.json"
|
||||
|
||||
if not decisions_path.exists():
|
||||
return pending
|
||||
|
||||
try:
|
||||
with open(decisions_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Get most recent decisions (they might need follow-up)
|
||||
items = data.get("items", [])
|
||||
for item in items[-limit:]:
|
||||
if item.get("status") == "pending":
|
||||
pending.append(f"Pending: {item.get('content', '')}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return pending
|
||||
|
||||
|
||||
def main():
|
||||
# Read hook input from stdin
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
|
||||
prompt = input_data.get("prompt", "")
|
||||
cwd = input_data.get("cwd", "")
|
||||
|
||||
# Skip context injection for very short prompts (likely commands)
|
||||
if len(prompt) < 10:
|
||||
return
|
||||
|
||||
# Gather context
|
||||
context_parts = []
|
||||
|
||||
# Time context (always include)
|
||||
context_parts.append(get_time_context())
|
||||
|
||||
# Git context (if in a repo)
|
||||
git_ctx = get_git_context(cwd)
|
||||
if git_ctx:
|
||||
context_parts.append(git_ctx)
|
||||
|
||||
# Relevant memory (if prompt has substance)
|
||||
if len(prompt) > 20:
|
||||
relevant = get_relevant_memory(prompt)
|
||||
if relevant:
|
||||
context_parts.append("Relevant memory:")
|
||||
context_parts.extend(f" - {item}" for item in relevant)
|
||||
|
||||
# Pending decisions (occasionally remind)
|
||||
pending = get_pending_decisions()
|
||||
if pending:
|
||||
context_parts.extend(pending)
|
||||
|
||||
# Output context (will be injected into Claude's context)
|
||||
if context_parts:
|
||||
print("\n".join(context_parts))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# Session end hook - triggers summarization of the conversation
|
||||
# Receives JSON via stdin with session_id, transcript_path, reason
|
||||
#
|
||||
# Uses Claude CLI with subscription credentials for LLM extraction.
|
||||
# Heuristic extraction (paths, facts) always runs.
|
||||
# LLM extraction (decisions, preferences) runs if claude CLI is available.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOG_FILE="${HOME}/.claude/logs/session-end.log"
|
||||
|
||||
# Ensure log directory exists
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
|
||||
log() {
|
||||
echo "[$(date -Iseconds)] $*" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Read JSON input from stdin
|
||||
INPUT=$(cat)
|
||||
|
||||
# Parse JSON fields
|
||||
SESSION_ID=$(echo "$INPUT" | python3 -c "import sys, json; print(json.load(sys.stdin).get('session_id', ''))" 2>/dev/null || echo "")
|
||||
TRANSCRIPT_PATH=$(echo "$INPUT" | python3 -c "import sys, json; print(json.load(sys.stdin).get('transcript_path', ''))" 2>/dev/null || echo "")
|
||||
REASON=$(echo "$INPUT" | python3 -c "import sys, json; print(json.load(sys.stdin).get('reason', ''))" 2>/dev/null || echo "")
|
||||
|
||||
log "SessionEnd triggered: session=$SESSION_ID reason=$REASON"
|
||||
|
||||
# Clear guardrail session allowlist (confirmations don't persist across sessions)
|
||||
GUARDRAIL_SESSION="${HOME}/.claude/state/guardrail-session.json"
|
||||
if [[ -f "$GUARDRAIL_SESSION" ]]; then
|
||||
rm -f "$GUARDRAIL_SESSION"
|
||||
log "Cleared guardrail session allowlist"
|
||||
fi
|
||||
|
||||
# Validate required fields
|
||||
if [[ -z "$SESSION_ID" || -z "$TRANSCRIPT_PATH" ]]; then
|
||||
log "ERROR: Missing session_id or transcript_path"
|
||||
exit 0 # Exit cleanly - don't break session exit
|
||||
fi
|
||||
|
||||
# Check if transcript exists
|
||||
if [[ ! -f "$TRANSCRIPT_PATH" ]]; then
|
||||
log "ERROR: Transcript not found at $TRANSCRIPT_PATH"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run summarization script in background to not block session exit
|
||||
# The script will handle its own error logging
|
||||
nohup python3 "${SCRIPT_DIR}/summarize-transcript.py" \
|
||||
--session-id "$SESSION_ID" \
|
||||
--transcript "$TRANSCRIPT_PATH" \
|
||||
--reason "$REASON" \
|
||||
>> "$LOG_FILE" 2>&1 &
|
||||
|
||||
log "Summarization started in background (PID: $!)"
|
||||
|
||||
# Return success - don't block session exit
|
||||
exit 0
|
||||
@@ -32,12 +32,24 @@ with open('${PA_DIR}/memory/decisions.json') as f:
|
||||
" 2>/dev/null || echo "0")
|
||||
fi
|
||||
|
||||
# Check external mode
|
||||
EXTERNAL_MODE="disabled"
|
||||
if [[ -f "${STATE_DIR}/external-mode.json" ]]; then
|
||||
EXTERNAL_ENABLED=$(jq -r '.enabled // false' "${STATE_DIR}/external-mode.json" 2>/dev/null || echo "false")
|
||||
if [[ "${EXTERNAL_ENABLED}" == "true" ]]; then
|
||||
EXTERNAL_MODE="enabled"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Output context as system reminder format
|
||||
echo "SessionStart:Callback hook success: Success"
|
||||
echo "SessionStart:resume hook success: Success"
|
||||
|
||||
# Add additional context if there's something noteworthy
|
||||
if [[ "${UNSUMMARIZED}" -gt 0 || "${PENDING_DECISIONS}" -gt 0 ]]; then
|
||||
if [[ "${UNSUMMARIZED}" -gt 0 || "${PENDING_DECISIONS}" -gt 0 || "${EXTERNAL_MODE}" == "enabled" ]]; then
|
||||
echo "SessionStart hook additional context: "
|
||||
if [[ "${EXTERNAL_MODE}" == "enabled" ]]; then
|
||||
echo "- EXTERNAL MODE ACTIVE: All requests routed to external LLMs"
|
||||
fi
|
||||
if [[ "${UNSUMMARIZED}" -gt 0 ]]; then
|
||||
echo "- ${UNSUMMARIZED} unsummarized session(s) available for review"
|
||||
fi
|
||||
|
||||
Executable
+406
@@ -0,0 +1,406 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Summarize a Claude Code session transcript and extract key information to memory.
|
||||
|
||||
This script:
|
||||
1. Loads the transcript from Claude's storage
|
||||
2. Checks if session is substantive (>= 3 user messages)
|
||||
3. Extracts facts/paths via heuristics
|
||||
4. Uses Claude CLI (with subscription auth) for decisions/preferences if substantive
|
||||
5. Updates memory files and marks session as summarized
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Paths
|
||||
STATE_DIR = Path.home() / ".claude/state/personal-assistant"
|
||||
MEMORY_DIR = STATE_DIR / "memory"
|
||||
HISTORY_INDEX = STATE_DIR / "history/index.json"
|
||||
|
||||
# Memory files
|
||||
MEMORY_FILES = {
|
||||
"decisions": MEMORY_DIR / "decisions.json",
|
||||
"preferences": MEMORY_DIR / "preferences.json",
|
||||
"projects": MEMORY_DIR / "projects.json",
|
||||
"facts": MEMORY_DIR / "facts.json",
|
||||
}
|
||||
|
||||
# Minimum threshold for substantive sessions
|
||||
MIN_USER_MESSAGES = 3
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
"""Log with timestamp."""
|
||||
print(f"[{datetime.now().isoformat()}] {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def load_transcript(path: str) -> list[dict]:
|
||||
"""Load transcript from jsonl file."""
|
||||
messages = []
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
messages.append(json.loads(line))
|
||||
except Exception as e:
|
||||
log(f"Error loading transcript: {e}")
|
||||
return messages
|
||||
|
||||
|
||||
def count_user_messages(transcript: list[dict]) -> int:
|
||||
"""Count the number of user messages in transcript."""
|
||||
count = 0
|
||||
for entry in transcript:
|
||||
# Claude Code format: type is "user" or "assistant" at top level
|
||||
if entry.get("type") == "user":
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def extract_conversation_text(transcript: list[dict]) -> str:
|
||||
"""Extract readable conversation text from transcript."""
|
||||
parts = []
|
||||
for entry in transcript:
|
||||
entry_type = entry.get("type", "")
|
||||
|
||||
# Skip non-message entries (like queue-operation)
|
||||
if entry_type not in ("user", "assistant"):
|
||||
continue
|
||||
|
||||
message = entry.get("message", {})
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
role = message.get("role", entry_type)
|
||||
content = message.get("content", "")
|
||||
|
||||
# Handle different content formats
|
||||
if isinstance(content, list):
|
||||
# Assistant messages have content as array of blocks
|
||||
text_parts = []
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "text":
|
||||
text_parts.append(block.get("text", ""))
|
||||
elif block.get("type") == "tool_use":
|
||||
text_parts.append(f"[Tool: {block.get('name', 'unknown')}]")
|
||||
elif isinstance(block, str):
|
||||
text_parts.append(block)
|
||||
content = "\n".join(text_parts)
|
||||
elif isinstance(content, str):
|
||||
# User messages have content as string
|
||||
pass
|
||||
else:
|
||||
continue
|
||||
|
||||
if content:
|
||||
parts.append(f"[{role}]: {content[:2000]}") # Truncate long messages
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def heuristic_extraction(transcript: list[dict]) -> dict[str, list[dict]]:
|
||||
"""Extract simple facts and paths using heuristics."""
|
||||
results = {"projects": [], "facts": []}
|
||||
|
||||
conversation = extract_conversation_text(transcript)
|
||||
|
||||
# Extract file paths mentioned
|
||||
path_pattern = r'(?:/[\w.-]+)+(?:/[\w.-]*)?'
|
||||
paths = set(re.findall(path_pattern, conversation))
|
||||
|
||||
# Filter to likely project paths
|
||||
project_paths = set()
|
||||
for p in paths:
|
||||
if any(x in p for x in ['/home/', '/Users/', '/.claude/', '/projects/']):
|
||||
if not any(x in p for x in ['/proc/', '/sys/', '/dev/', '/tmp/']):
|
||||
project_paths.add(p)
|
||||
|
||||
# Add unique project paths as context
|
||||
for path in list(project_paths)[:5]: # Limit to 5 paths
|
||||
results["projects"].append({
|
||||
"content": f"Worked with path: {path}",
|
||||
"context": "File path referenced in session"
|
||||
})
|
||||
|
||||
# Extract tool/environment facts
|
||||
tool_patterns = [
|
||||
(r'using\s+([\w-]+)\s+version\s+([\d.]+)', "Tool version: {0} {1}"),
|
||||
(r'(python|node|npm|pip)\s+.*?([\d.]+)', "Runtime: {0} {1}"),
|
||||
]
|
||||
|
||||
for pattern, template in tool_patterns:
|
||||
matches = re.findall(pattern, conversation, re.IGNORECASE)
|
||||
for match in matches[:2]: # Limit matches
|
||||
results["facts"].append({
|
||||
"content": template.format(*match),
|
||||
"context": "Environment fact from session"
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def llm_extraction(conversation_text: str, session_id: str) -> dict[str, list[dict]]:
|
||||
"""Use Claude CLI to extract decisions and preferences."""
|
||||
results = {"decisions": [], "preferences": []}
|
||||
|
||||
# Check if claude CLI is available
|
||||
claude_path = subprocess.run(
|
||||
["which", "claude"], capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
|
||||
if not claude_path:
|
||||
log("Claude CLI not found, skipping LLM extraction")
|
||||
return results
|
||||
|
||||
prompt = f"""Analyze this conversation excerpt and extract key information.
|
||||
|
||||
CONVERSATION:
|
||||
{conversation_text[:15000]}
|
||||
|
||||
Extract and return a JSON object with:
|
||||
1. "decisions": List of decisions made (choices, directions taken, approaches selected)
|
||||
2. "preferences": List of user preferences learned (likes, dislikes, preferred approaches)
|
||||
|
||||
For each item include:
|
||||
- "content": Brief description (1 sentence)
|
||||
- "context": Why this matters or additional context
|
||||
|
||||
Only include genuinely significant items. Skip trivial or obvious things.
|
||||
Return valid JSON only, no markdown formatting.
|
||||
|
||||
Example format:
|
||||
{{"decisions": [{{"content": "Use PostgreSQL for the database", "context": "Chosen for JSONB support"}}], "preferences": [{{"content": "Prefers explicit type annotations", "context": "For code clarity"}}]}}"""
|
||||
|
||||
try:
|
||||
# Use claude CLI in print mode with haiku for cost efficiency
|
||||
result = subprocess.run(
|
||||
[
|
||||
claude_path, "-p",
|
||||
"--model", "haiku",
|
||||
"--no-session-persistence",
|
||||
prompt
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=str(Path.home()) # Run from home to avoid project context
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
log(f"Claude CLI failed: {result.stderr[:500]}")
|
||||
return results
|
||||
|
||||
response_text = result.stdout.strip()
|
||||
|
||||
# Try to extract JSON from response
|
||||
try:
|
||||
# Handle potential markdown code blocks
|
||||
if "```" in response_text:
|
||||
json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', response_text, re.DOTALL)
|
||||
if json_match:
|
||||
response_text = json_match.group(1)
|
||||
|
||||
data = json.loads(response_text)
|
||||
|
||||
for key in ["decisions", "preferences"]:
|
||||
if key in data and isinstance(data[key], list):
|
||||
for item in data[key][:5]: # Limit to 5 per category
|
||||
if isinstance(item, dict) and "content" in item:
|
||||
results[key].append({
|
||||
"content": item["content"],
|
||||
"context": item.get("context", "")
|
||||
})
|
||||
except json.JSONDecodeError as e:
|
||||
log(f"Failed to parse LLM response as JSON: {e}")
|
||||
log(f"Response was: {response_text[:500]}")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
log("Claude CLI timed out")
|
||||
except Exception as e:
|
||||
log(f"LLM extraction error: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def load_memory_file(path: Path) -> dict:
|
||||
"""Load a memory file, creating default structure if needed."""
|
||||
if path.exists():
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Default structure
|
||||
return {
|
||||
"version": "1.0",
|
||||
"description": f"{path.stem.title()} extracted from sessions",
|
||||
"items": []
|
||||
}
|
||||
|
||||
|
||||
def save_memory_file(path: Path, data: dict) -> None:
|
||||
"""Save a memory file."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def add_to_memory(category: str, items: list[dict], session_id: str) -> int:
|
||||
"""Add items to a memory category. Returns count of items added."""
|
||||
if not items:
|
||||
return 0
|
||||
|
||||
path = MEMORY_FILES.get(category)
|
||||
if not path:
|
||||
return 0
|
||||
|
||||
data = load_memory_file(path)
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Check for duplicates based on content
|
||||
existing_content = {item.get("content", "").lower() for item in data.get("items", [])}
|
||||
|
||||
added = 0
|
||||
for item in items:
|
||||
content = item.get("content", "")
|
||||
if content.lower() not in existing_content:
|
||||
data["items"].append({
|
||||
"id": str(uuid.uuid4()),
|
||||
"date": today,
|
||||
"content": content,
|
||||
"context": item.get("context", ""),
|
||||
"session": session_id
|
||||
})
|
||||
existing_content.add(content.lower())
|
||||
added += 1
|
||||
|
||||
if added > 0:
|
||||
save_memory_file(path, data)
|
||||
|
||||
return added
|
||||
|
||||
|
||||
def update_history_index(session_id: str, transcript_path: str, topics: list[str]) -> None:
|
||||
"""Mark session as summarized in history index."""
|
||||
if not HISTORY_INDEX.exists():
|
||||
log(f"History index not found: {HISTORY_INDEX}")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(HISTORY_INDEX) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Find and update the session
|
||||
for session in data.get("sessions", []):
|
||||
if session.get("id") == session_id:
|
||||
session["summarized"] = True
|
||||
session["transcript_path"] = transcript_path
|
||||
session["topics"] = topics[:5] # Limit topics
|
||||
session["summarized_at"] = datetime.now().isoformat()
|
||||
break
|
||||
|
||||
with open(HISTORY_INDEX, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
log(f"Updated history index for session {session_id}")
|
||||
|
||||
except Exception as e:
|
||||
log(f"Error updating history index: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Summarize a Claude Code session")
|
||||
parser.add_argument("--session-id", required=True, help="Session ID")
|
||||
parser.add_argument("--transcript", required=True, help="Path to transcript file")
|
||||
parser.add_argument("--reason", default="", help="Session end reason")
|
||||
args = parser.parse_args()
|
||||
|
||||
log(f"Starting summarization for session {args.session_id}")
|
||||
|
||||
# Load transcript
|
||||
transcript = load_transcript(args.transcript)
|
||||
if not transcript:
|
||||
log("Empty or invalid transcript, skipping")
|
||||
return
|
||||
|
||||
# Check threshold
|
||||
user_msg_count = count_user_messages(transcript)
|
||||
log(f"Found {user_msg_count} user messages")
|
||||
|
||||
if user_msg_count < MIN_USER_MESSAGES:
|
||||
log(f"Session below threshold ({MIN_USER_MESSAGES}), marking as summarized without extraction")
|
||||
update_history_index(args.session_id, args.transcript, ["trivial"])
|
||||
return
|
||||
|
||||
# Extract conversation text
|
||||
conversation_text = extract_conversation_text(transcript)
|
||||
|
||||
# Heuristic extraction (always run)
|
||||
log("Running heuristic extraction...")
|
||||
heuristic_results = heuristic_extraction(transcript)
|
||||
|
||||
# LLM extraction (for substantive sessions)
|
||||
log("Running LLM extraction...")
|
||||
llm_results = llm_extraction(conversation_text, args.session_id)
|
||||
|
||||
# Combine results
|
||||
all_results = {
|
||||
"decisions": llm_results.get("decisions", []),
|
||||
"preferences": llm_results.get("preferences", []),
|
||||
"projects": heuristic_results.get("projects", []),
|
||||
"facts": heuristic_results.get("facts", []),
|
||||
}
|
||||
|
||||
# Save to memory files
|
||||
total_added = 0
|
||||
topics = []
|
||||
for category, items in all_results.items():
|
||||
count = add_to_memory(category, items, args.session_id)
|
||||
total_added += count
|
||||
if count > 0:
|
||||
topics.append(category)
|
||||
log(f"Added {count} items to {category}")
|
||||
|
||||
# Update history index
|
||||
update_history_index(args.session_id, args.transcript, topics)
|
||||
|
||||
log(f"Summarization complete: {total_added} total items added")
|
||||
|
||||
# Reindex RAG if we added items
|
||||
if total_added > 0:
|
||||
log("Triggering RAG reindex...")
|
||||
try:
|
||||
reindex_result = subprocess.run(
|
||||
[
|
||||
str(Path.home() / ".claude/skills/rag-search/venv/bin/python"),
|
||||
str(Path.home() / ".claude/skills/rag-search/scripts/index_personal.py"),
|
||||
"--quiet"
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
if reindex_result.returncode == 0:
|
||||
log("RAG reindex completed successfully")
|
||||
else:
|
||||
log(f"RAG reindex failed: {reindex_result.stderr[:200]}")
|
||||
except subprocess.TimeoutExpired:
|
||||
log("RAG reindex timed out after 120s")
|
||||
except Exception as e:
|
||||
log(f"RAG reindex error: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Agent delegation helper. Routes to external or Claude based on mode.
|
||||
|
||||
Usage:
|
||||
delegate.py --tier sonnet -p "prompt"
|
||||
delegate.py --tier opus -p "complex reasoning task" --json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
STATE_DIR = Path.home() / ".claude/state"
|
||||
ROUTER_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def is_external_mode() -> bool:
|
||||
"""Check if external-only mode is enabled."""
|
||||
mode_file = STATE_DIR / "external-mode.json"
|
||||
if mode_file.exists():
|
||||
with open(mode_file) as f:
|
||||
data = json.load(f)
|
||||
return data.get("enabled", False)
|
||||
return False
|
||||
|
||||
|
||||
def get_external_model(tier: str) -> str:
|
||||
"""Get the external model equivalent for a Claude tier."""
|
||||
policy_file = STATE_DIR / "model-policy.json"
|
||||
with open(policy_file) as f:
|
||||
policy = json.load(f)
|
||||
mapping = policy.get("claude_to_external_map", {})
|
||||
if tier not in mapping:
|
||||
raise ValueError(f"No external mapping for tier: {tier}")
|
||||
return mapping[tier]
|
||||
|
||||
|
||||
def delegate(tier: str, prompt: str, use_json: bool = False) -> str:
|
||||
"""
|
||||
Delegate to appropriate model based on mode.
|
||||
|
||||
Args:
|
||||
tier: Claude tier (opus, sonnet, haiku)
|
||||
prompt: The prompt text
|
||||
use_json: Return JSON output
|
||||
|
||||
Returns:
|
||||
Model response as string
|
||||
"""
|
||||
if is_external_mode():
|
||||
# Use external model
|
||||
model = get_external_model(tier)
|
||||
invoke_script = ROUTER_DIR / "invoke.py"
|
||||
|
||||
cmd = [sys.executable, str(invoke_script), "--model", model, "-p", prompt]
|
||||
if use_json:
|
||||
cmd.append("--json")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"External invoke failed: {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
else:
|
||||
# Use Claude
|
||||
cmd = ["claude", "--print", "--model", tier, prompt]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Claude failed: {result.stderr}")
|
||||
|
||||
response = result.stdout.strip()
|
||||
|
||||
if use_json:
|
||||
return json.dumps({
|
||||
"model": f"claude/{tier}",
|
||||
"response": response,
|
||||
"success": True
|
||||
}, indent=2)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Delegate to Claude or external model based on mode"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tier",
|
||||
required=True,
|
||||
choices=["opus", "sonnet", "haiku"],
|
||||
help="Claude tier (maps to external equivalent when in external mode)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--prompt",
|
||||
required=True,
|
||||
help="Prompt text"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
result = delegate(args.tier, args.prompt, args.json)
|
||||
print(result)
|
||||
except Exception as e:
|
||||
if args.json:
|
||||
print(json.dumps({"error": str(e), "success": False}, indent=2))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Invoke external LLM via configured provider.
|
||||
|
||||
Usage:
|
||||
invoke.py --model copilot/gpt-5.2 -p "prompt"
|
||||
invoke.py --task reasoning -p "prompt"
|
||||
invoke.py --task code-generation -p "prompt" --json
|
||||
|
||||
Model selection priority:
|
||||
1. Explicit --model flag
|
||||
2. Task-based routing (--task flag)
|
||||
3. Default from policy
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
STATE_DIR = Path.home() / ".claude/state"
|
||||
ROUTER_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def load_policy() -> dict:
|
||||
"""Load model policy from state file."""
|
||||
policy_file = STATE_DIR / "model-policy.json"
|
||||
with open(policy_file) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def resolve_model(args: argparse.Namespace, policy: dict) -> str:
|
||||
"""Determine which model to use based on args and policy."""
|
||||
if args.model:
|
||||
return args.model
|
||||
if args.task and args.task in policy.get("task_routing", {}):
|
||||
return policy["task_routing"][args.task]
|
||||
return policy.get("task_routing", {}).get("default", "copilot/sonnet-4.5")
|
||||
|
||||
|
||||
def invoke(model: str, prompt: str, policy: dict, timeout: int = 600) -> str:
|
||||
"""Invoke the appropriate provider for the given model."""
|
||||
external_models = policy.get("external_models", {})
|
||||
|
||||
if model not in external_models:
|
||||
raise ValueError(f"Unknown model: {model}. Available: {list(external_models.keys())}")
|
||||
|
||||
model_config = external_models[model]
|
||||
cli = model_config["cli"]
|
||||
cli_args = model_config.get("cli_args", [])
|
||||
|
||||
# Import and invoke appropriate provider
|
||||
if cli == "opencode":
|
||||
sys.path.insert(0, str(ROUTER_DIR))
|
||||
from providers.opencode import invoke as opencode_invoke
|
||||
return opencode_invoke(cli_args, prompt, timeout=timeout)
|
||||
elif cli == "gemini":
|
||||
sys.path.insert(0, str(ROUTER_DIR))
|
||||
from providers.gemini import invoke as gemini_invoke
|
||||
return gemini_invoke(cli_args, prompt, timeout=timeout)
|
||||
else:
|
||||
raise ValueError(f"Unknown CLI: {cli}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Invoke external LLM via configured provider"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--prompt",
|
||||
required=True,
|
||||
help="Prompt text"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
help="Explicit model (e.g., copilot/gpt-5.2)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--task",
|
||||
choices=["reasoning", "code-generation", "long-context", "general"],
|
||||
help="Task type for automatic model routing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON with model info"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=600,
|
||||
help="Timeout in seconds (default: 600)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
policy = load_policy()
|
||||
model = resolve_model(args, policy)
|
||||
result = invoke(model, args.prompt, policy, timeout=args.timeout)
|
||||
|
||||
if args.json:
|
||||
output = {
|
||||
"model": model,
|
||||
"response": result,
|
||||
"success": True
|
||||
}
|
||||
print(json.dumps(output, indent=2))
|
||||
else:
|
||||
print(result)
|
||||
|
||||
except Exception as e:
|
||||
if args.json:
|
||||
output = {
|
||||
"model": args.model or "unknown",
|
||||
"error": str(e),
|
||||
"success": False
|
||||
}
|
||||
print(json.dumps(output, indent=2))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Gemini CLI wrapper for Google models."""
|
||||
|
||||
import subprocess
|
||||
from typing import List
|
||||
|
||||
|
||||
def invoke(cli_args: List[str], prompt: str, timeout: int = 300) -> str:
|
||||
"""
|
||||
Invoke gemini CLI with given args and prompt.
|
||||
|
||||
Args:
|
||||
cli_args: Model args like ["-m", "gemini-3-pro"]
|
||||
prompt: The prompt text
|
||||
timeout: Timeout in seconds (default 5 minutes)
|
||||
|
||||
Returns:
|
||||
Model response as string
|
||||
|
||||
Raises:
|
||||
RuntimeError: If gemini CLI fails
|
||||
TimeoutError: If request exceeds timeout
|
||||
"""
|
||||
cmd = ["gemini"] + cli_args + ["-p", prompt]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise TimeoutError(f"gemini timed out after {timeout}s")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"gemini failed (exit {result.returncode}): {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick test
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
response = invoke(["-m", "gemini-3-pro"], sys.argv[1])
|
||||
print(response)
|
||||
else:
|
||||
print("Usage: gemini.py 'prompt'")
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenCode CLI wrapper for GitHub Copilot, Z.AI, and other providers."""
|
||||
|
||||
import subprocess
|
||||
from typing import List
|
||||
|
||||
# OpenCode binary path (linuxbrew installation)
|
||||
OPENCODE_BIN = "/home/linuxbrew/.linuxbrew/bin/opencode"
|
||||
|
||||
|
||||
def invoke(cli_args: List[str], prompt: str, timeout: int = 300) -> str:
|
||||
"""
|
||||
Invoke opencode CLI with given args and prompt.
|
||||
|
||||
Args:
|
||||
cli_args: Model args like ["-m", "github-copilot/gpt-5.2"]
|
||||
prompt: The prompt text
|
||||
timeout: Timeout in seconds (default 5 minutes)
|
||||
|
||||
Returns:
|
||||
Model response as string
|
||||
|
||||
Raises:
|
||||
RuntimeError: If opencode CLI fails
|
||||
TimeoutError: If request exceeds timeout
|
||||
|
||||
Example invocation:
|
||||
opencode run -m github-copilot/gpt-5.2 "Hello world"
|
||||
"""
|
||||
# Build command: opencode run -m MODEL "prompt"
|
||||
cmd = [OPENCODE_BIN, "run"] + cli_args + [prompt]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise TimeoutError(f"opencode timed out after {timeout}s")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"opencode failed (exit {result.returncode}): {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick test
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
response = invoke(["-m", "github-copilot/gpt-5.2"], sys.argv[1])
|
||||
print(response)
|
||||
else:
|
||||
print("Usage: opencode.py 'prompt'")
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Toggle external-only mode.
|
||||
|
||||
Usage:
|
||||
toggle.py on [--reason "user requested"]
|
||||
toggle.py off
|
||||
toggle.py status
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
STATE_FILE = Path.home() / ".claude/state/external-mode.json"
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
"""Load current state."""
|
||||
if STATE_FILE.exists():
|
||||
with open(STATE_FILE) as f:
|
||||
return json.load(f)
|
||||
return {"enabled": False, "activated_at": None, "reason": None}
|
||||
|
||||
|
||||
def save_state(state: dict):
|
||||
"""Save state to file."""
|
||||
with open(STATE_FILE, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
|
||||
def enable(reason: Optional[str] = None):
|
||||
"""Enable external-only mode."""
|
||||
state = {
|
||||
"enabled": True,
|
||||
"activated_at": datetime.now().isoformat(),
|
||||
"reason": reason or "user-requested"
|
||||
}
|
||||
save_state(state)
|
||||
print("External-only mode ENABLED")
|
||||
print(f" Activated: {state['activated_at']}")
|
||||
print(f" Reason: {state['reason']}")
|
||||
print("\nAll agent requests will now use external LLMs.")
|
||||
print("Run 'toggle.py off' or '/pa --external off' to disable.")
|
||||
|
||||
|
||||
def disable():
|
||||
"""Disable external-only mode."""
|
||||
state = {
|
||||
"enabled": False,
|
||||
"activated_at": None,
|
||||
"reason": None
|
||||
}
|
||||
save_state(state)
|
||||
print("External-only mode DISABLED")
|
||||
print("\nAll agent requests will now use Claude.")
|
||||
|
||||
|
||||
def status():
|
||||
"""Show current mode status."""
|
||||
state = load_state()
|
||||
if state.get("enabled"):
|
||||
print("External-only mode: ENABLED")
|
||||
print(f" Activated: {state.get('activated_at', 'unknown')}")
|
||||
print(f" Reason: {state.get('reason', 'unknown')}")
|
||||
else:
|
||||
print("External-only mode: DISABLED")
|
||||
print(" Using Claude for all requests.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Toggle external-only mode")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# on command
|
||||
on_parser = subparsers.add_parser("on", help="Enable external-only mode")
|
||||
on_parser.add_argument("--reason", help="Reason for enabling")
|
||||
|
||||
# off command
|
||||
subparsers.add_parser("off", help="Disable external-only mode")
|
||||
|
||||
# status command
|
||||
subparsers.add_parser("status", help="Show current mode")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "on":
|
||||
enable(args.reason)
|
||||
elif args.command == "off":
|
||||
disable()
|
||||
elif args.command == "status":
|
||||
status()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,74 @@
|
||||
# Plans
|
||||
|
||||
Implementation plans for features, enhancements, and investigations.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
plans/
|
||||
├── index.json # Status registry (source of truth)
|
||||
├── README.md # This file
|
||||
└── *.md # Individual plan files
|
||||
```
|
||||
|
||||
## Plan Naming
|
||||
|
||||
- **Dated plans**: `YYYY-MM-DD-descriptive-name.md` (design docs)
|
||||
- **Generated names**: `adjective-verb-scientist.md` (brainstorming outputs)
|
||||
|
||||
## Status Registry (index.json)
|
||||
|
||||
Central tracking for all plans:
|
||||
|
||||
```json
|
||||
{
|
||||
"plan-name": {
|
||||
"title": "Human readable title",
|
||||
"status": "pending|implemented|partial|abandoned|superseded",
|
||||
"created": "YYYY-MM-DD",
|
||||
"implemented": "YYYY-MM-DD",
|
||||
"category": "feature|enhancement|bugfix|diagnostic|design",
|
||||
"notes": "Optional notes"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Status Values
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `pending` | Not yet implemented |
|
||||
| `implemented` | Fully implemented |
|
||||
| `partial` | Partially implemented |
|
||||
| `abandoned` | Decided not to implement |
|
||||
| `superseded` | Replaced by another plan |
|
||||
|
||||
### Categories
|
||||
|
||||
| Category | Meaning |
|
||||
|----------|---------|
|
||||
| `feature` | New capability |
|
||||
| `enhancement` | Improve existing feature |
|
||||
| `bugfix` | Fix an issue |
|
||||
| `diagnostic` | One-time investigation |
|
||||
| `design` | Design document for reference |
|
||||
|
||||
## Querying Plans
|
||||
|
||||
```bash
|
||||
# List pending plans
|
||||
jq -r '.plans | to_entries[] | select(.value.status == "pending") | .key' index.json
|
||||
|
||||
# List by category
|
||||
jq '.plans | to_entries[] | select(.value.category == "feature")' index.json
|
||||
|
||||
# Count by status
|
||||
jq '.plans | to_entries | group_by(.value.status) | map({status: .[0].value.status, count: length})' index.json
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Create plan**: Write `plans/plan-name.md`
|
||||
2. **Register**: Add entry to `index.json` with `status: "pending"`
|
||||
3. **Implement**: Execute the plan
|
||||
4. **Update**: Set `status: "implemented"` and add `implemented` date
|
||||
@@ -0,0 +1,798 @@
|
||||
# Plan: Transpose Claude Code Setup to OpenCode (Parallel)
|
||||
|
||||
## Handoff Summary
|
||||
|
||||
**Goal**: Set up OpenCode in parallel with Claude Code, sharing state files and syncing agents/skills.
|
||||
|
||||
**Status**: ✅ **IMPLEMENTATION COMPLETE** (2026-01-07)
|
||||
|
||||
### Key Decisions Made
|
||||
|
||||
1. **Use built-in `build` agent** as primary (not porting `personal-assistant`)
|
||||
2. **All agents synced as subagents** (SKIP_AGENTS kept empty for flexibility)
|
||||
3. **Model inheritance** - subagents use runtime-selected model
|
||||
4. **Claude Code is source of truth** - OpenCode references state files via `instructions`
|
||||
5. **No JSON minification** needed (files too small, added to future considerations as fc-047)
|
||||
|
||||
### What Was Completed
|
||||
|
||||
| Step | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| 1 | ✅ | Backups created (Jan 7 12:01) |
|
||||
| 2 | ✅ | Sync script enhanced (mode:subagent, model removal) |
|
||||
| 3 | ✅ | Sync run: 10 skills, 13 agents, 27 commands, 10 workflows |
|
||||
| 4 | ✅ | opencode.json updated (instructions, permissions) |
|
||||
| 5 | ✅ | Automated tests passed, manual TUI testing pending |
|
||||
| 6 | ✅ | README.md created (4.7KB), fc-047 added |
|
||||
| 7 | ⏳ | Iterate as needed |
|
||||
|
||||
### Critical Files
|
||||
|
||||
**Modified:**
|
||||
- `~/.config/opencode/scripts/claude_sync.py` ✅ - Added mode:subagent, model removal, skip logic
|
||||
- `~/.config/opencode/opencode.json` ✅ - Added instructions, permissions
|
||||
|
||||
**Created:**
|
||||
- `~/.config/opencode/README.md` ✅ - Documentation (4.7KB)
|
||||
|
||||
**Referenced (not copied):**
|
||||
- `~/.claude/CLAUDE.md`
|
||||
- `~/.claude/state/kb.json`
|
||||
- `~/.claude/state/personal-assistant/memory/*.json`
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
OpenCode (after implementation)
|
||||
├── Primary: build (built-in), plan (built-in)
|
||||
├── Subagents: @linux-sysadmin, @k8s-orchestrator, @code-reviewer, etc.
|
||||
├── Skills: auto-discovered from ~/.claude/skills/
|
||||
└── State: referenced via instructions from ~/.claude/state/
|
||||
```
|
||||
|
||||
### Start Command
|
||||
|
||||
```bash
|
||||
# Exit plan mode and begin implementation
|
||||
# Step 1: Backup
|
||||
BACKUP_DATE=$(date +%Y%m%d_%H%M%S)
|
||||
tar -czvf ~/.config/opencode-backup-$BACKUP_DATE.tar.gz -C ~/.config opencode/
|
||||
tar -czvf ~/opencode-home-backup-$BACKUP_DATE.tar.gz -C ~ .opencode/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Create a parallel OpenCode configuration that shares/reuses as much of the existing Claude Code infrastructure as possible, focusing on:
|
||||
1. **Skills/scripts execution** (highest priority)
|
||||
2. **Agent hierarchy/delegation** (second priority)
|
||||
3. **State persistence** (if low complexity)
|
||||
|
||||
## Key Discovery: Native Compatibility
|
||||
|
||||
OpenCode **natively supports Claude-compatible skill paths**:
|
||||
- `~/.claude/skills/<name>/SKILL.md` - Already supported!
|
||||
- This means your 11 existing skills can work with minimal changes
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Backup Existing OpenCode Setup
|
||||
|
||||
### Current State Discovered
|
||||
|
||||
OpenCode is already installed with substantial configuration:
|
||||
|
||||
**`~/.config/opencode/`** (main config):
|
||||
- `opencode.json` - Has `claude-sync` command already!
|
||||
- `agent/` - 3 custom agents (coding-expert, k8s-expert, tdd-enforcer)
|
||||
- `agents/` - 12 synced Claude Code agents (already converted!)
|
||||
- `skills/` - 10 skills (some synced, one symlink to morning-report)
|
||||
- `scripts/claude_sync.py` - Existing sync script!
|
||||
|
||||
**`~/.opencode/`** (alternate config):
|
||||
- `agent/` - 4 different agents (openagent, system-builder, etc.)
|
||||
- `command/` - 12 commands (commit, optimize, validate-repo, etc.)
|
||||
|
||||
### Backup Commands
|
||||
|
||||
```bash
|
||||
# Create timestamped backups
|
||||
BACKUP_DATE=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# Backup ~/.config/opencode/
|
||||
tar -czvf ~/.config/opencode-backup-$BACKUP_DATE.tar.gz -C ~/.config opencode/
|
||||
|
||||
# Backup ~/.opencode/
|
||||
tar -czvf ~/opencode-home-backup-$BACKUP_DATE.tar.gz -C ~ .opencode/
|
||||
|
||||
# Verify backups
|
||||
ls -la ~/.config/opencode-backup-*.tar.gz ~/opencode-home-backup-*.tar.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Use Existing `claude_sync.py` Script
|
||||
|
||||
The existing sync script is **comprehensive** and handles:
|
||||
|
||||
| Category | Source | Destination | Transforms |
|
||||
|----------|--------|-------------|------------|
|
||||
| Skills | `~/.claude/skills/*/SKILL.md` | `~/.config/opencode/skills/*/SKILL.md` | `allowed-tools` → `metadata.claude_allowed_tools` |
|
||||
| Agents | `~/.claude/agents/*.md` | `~/.config/opencode/agents/*.md` | `tools: X, Y` → `tools: { x: true, y: true }` |
|
||||
| Commands | `~/.claude/commands/*.md` | `~/.config/opencode/claude/commands/*.md` | None |
|
||||
| Workflows | `~/.claude/workflows/*.yaml` | `~/.config/opencode/claude/workflows/*.yaml` | None |
|
||||
|
||||
### Sync Commands
|
||||
|
||||
```bash
|
||||
# Dry run - see what would change
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --dry-run
|
||||
|
||||
# Actually sync
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py
|
||||
|
||||
# Clean stale files (dry run first)
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --clean --dry-run
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --clean --apply
|
||||
|
||||
# Sync specific category only
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --only agents
|
||||
```
|
||||
|
||||
### Model Mapping Update Needed
|
||||
|
||||
Current script maps old models. May need to add:
|
||||
- `opus` → `anthropic/claude-opus-4`
|
||||
- `sonnet` → `anthropic/claude-sonnet-4-5`
|
||||
- `haiku` → `anthropic/claude-haiku-4-5`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1.5: OpenCode Optimization (NEW)
|
||||
|
||||
The current sync just copies/transforms files. It doesn't optimize for **how OpenCode works**.
|
||||
|
||||
### Key OpenCode Differences
|
||||
|
||||
| Concept | Claude Code | OpenCode | Optimization Needed |
|
||||
|---------|-------------|----------|---------------------|
|
||||
| **Agent hierarchy** | PA → MO → agents | Flat: primary + subagents | Add `mode` field |
|
||||
| **Agent invocation** | Delegation patterns | `@agent` mentions | Simplify prompts |
|
||||
| **Permissions** | Hooks + guardrails | `permission` config | Move to opencode.json |
|
||||
| **Model selection** | Per-agent in frontmatter | `model: inherit` option | Use inheritance |
|
||||
| **Auto-invocation** | Keyword triggers in registry | Rich `description` field | Enhance descriptions |
|
||||
|
||||
### Agent Mode Assignment
|
||||
|
||||
```yaml
|
||||
# PRIMARY - Use OpenCode's built-in agents
|
||||
build: (built-in) # Full access, default primary
|
||||
plan: (built-in) # Read-only analysis
|
||||
|
||||
# SKIP - Not needed in OpenCode's flat model
|
||||
personal-assistant: # Use built-in "build" instead
|
||||
master-orchestrator: # Intermediary not needed
|
||||
|
||||
# SUBAGENTS (invoked via @mention or Task tool)
|
||||
linux-sysadmin: mode: subagent
|
||||
k8s-orchestrator: mode: subagent
|
||||
k8s-diagnostician: mode: subagent
|
||||
argocd-operator: mode: subagent
|
||||
prometheus-analyst: mode: subagent
|
||||
git-operator: mode: subagent
|
||||
programmer-orchestrator: mode: subagent
|
||||
code-planner: mode: subagent
|
||||
code-implementer: mode: subagent
|
||||
code-reviewer: mode: subagent
|
||||
```
|
||||
|
||||
### Hierarchy Simplification
|
||||
|
||||
**Claude Code pattern** (complex, 3 layers):
|
||||
```
|
||||
User → Personal Assistant → Master Orchestrator → linux-sysadmin
|
||||
→ k8s-orchestrator → k8s-diagnostician
|
||||
```
|
||||
|
||||
**OpenCode pattern** (flat, 2 layers):
|
||||
```
|
||||
User → build (built-in) → @linux-sysadmin
|
||||
→ @k8s-orchestrator
|
||||
→ @k8s-diagnostician
|
||||
→ @code-reviewer
|
||||
→ etc.
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- No custom primary agent to maintain
|
||||
- Built-in `build` agent is optimized for OpenCode
|
||||
- Built-in `plan` agent available for read-only analysis
|
||||
- Subagents invoked directly via @mention
|
||||
|
||||
### Sync Script Enhancements Needed
|
||||
|
||||
Update `claude_sync.py` to add:
|
||||
|
||||
```python
|
||||
# In transform_frontmatter() for agents:
|
||||
|
||||
# 1. Skip agents not needed in OpenCode's flat model
|
||||
SKIP_AGENTS = {"personal-assistant", "master-orchestrator"}
|
||||
|
||||
if name in SKIP_AGENTS:
|
||||
return None # Signal to skip this file
|
||||
|
||||
# 2. All synced agents become subagents (built-in build/plan are primary)
|
||||
frontmatter["mode"] = "subagent"
|
||||
|
||||
# 3. Use model inheritance (subagents use parent's model)
|
||||
frontmatter["model"] = "inherit"
|
||||
|
||||
# 4. Map explicit models if not using inherit
|
||||
MODEL_MAP = {
|
||||
"opus": "anthropic/claude-opus-4",
|
||||
"sonnet": "anthropic/claude-sonnet-4-5",
|
||||
"haiku": "anthropic/claude-haiku-4-5",
|
||||
}
|
||||
if frontmatter.get("model") in MODEL_MAP:
|
||||
frontmatter["model"] = MODEL_MAP[frontmatter["model"]]
|
||||
```
|
||||
|
||||
Also update `sync_tree()` to handle `None` return (skip file).
|
||||
|
||||
### Description Enhancement
|
||||
|
||||
OpenCode uses descriptions for **auto-invocation**. Enhance with examples:
|
||||
|
||||
**Current** (basic):
|
||||
```yaml
|
||||
description: Manages Arch Linux workstation - system maintenance...
|
||||
```
|
||||
|
||||
**Optimized** (with examples):
|
||||
```yaml
|
||||
description: |
|
||||
Manages Arch Linux workstation. Use for system maintenance, updates,
|
||||
troubleshooting, and health checks.
|
||||
Examples:
|
||||
- "check system health" → @linux-sysadmin
|
||||
- "update packages" → @linux-sysadmin
|
||||
- "why is my disk full" → @linux-sysadmin
|
||||
```
|
||||
|
||||
### Permission Migration
|
||||
|
||||
Move guardrail logic to opencode.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"permission": {
|
||||
"edit": "ask",
|
||||
"bash": {
|
||||
"*": "ask",
|
||||
"pacman -Q*": "allow",
|
||||
"systemctl status*": "allow",
|
||||
"kubectl get*": "allow",
|
||||
"git status": "allow",
|
||||
"git diff": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Create OpenCode Config Structure
|
||||
|
||||
### Directory Layout
|
||||
|
||||
```
|
||||
~/.config/opencode/
|
||||
├── opencode.json # Main config
|
||||
├── AGENTS.md # Global rules (symlink or copy from CLAUDE.md)
|
||||
├── agent/ # Agent definitions
|
||||
│ ├── personal-assistant.md
|
||||
│ ├── linux-sysadmin.md
|
||||
│ ├── k8s-orchestrator.md
|
||||
│ └── ... (converted agents)
|
||||
├── tool/ # Custom tool wrappers (TypeScript)
|
||||
│ ├── gmail.ts # Wrapper for gmail scripts
|
||||
│ ├── gcal.ts # Wrapper for gcal scripts
|
||||
│ └── ...
|
||||
└── skill/ # OpenCode-native skills (optional)
|
||||
```
|
||||
|
||||
### Config File: `~/.config/opencode/opencode.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"small_model": "anthropic/claude-haiku-4-5",
|
||||
"autoupdate": true,
|
||||
|
||||
// OpenCode already searches ~/.claude/skills/ - no extra config needed!
|
||||
|
||||
// Agent definitions
|
||||
"agent": {
|
||||
// Override built-in agents or define custom via files
|
||||
},
|
||||
|
||||
// Default permissions (conservative like your current setup)
|
||||
"permission": {
|
||||
"edit": "ask",
|
||||
"bash": "ask"
|
||||
},
|
||||
|
||||
// Custom tools enabled
|
||||
"tools": {
|
||||
"gmail": true,
|
||||
"gcal": true,
|
||||
"gtasks": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Skills Migration
|
||||
|
||||
### Already Compatible (No Changes Needed)
|
||||
|
||||
OpenCode automatically discovers skills from:
|
||||
- `~/.claude/skills/*/SKILL.md`
|
||||
|
||||
Your existing skills should work if they have proper frontmatter:
|
||||
|
||||
| Skill | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| gmail | Check frontmatter | Needs `name` + `description` |
|
||||
| gcal | Check frontmatter | Needs `name` + `description` |
|
||||
| gtasks | Check frontmatter | Needs `name` + `description` |
|
||||
| sysadmin-health | Check frontmatter | |
|
||||
| k8s-quick-status | Check frontmatter | |
|
||||
| morning-report | Check frontmatter | |
|
||||
| stock-lookup | Check frontmatter | |
|
||||
| rag-search | Check frontmatter | |
|
||||
| usage | Check frontmatter | |
|
||||
| guardrails | N/A | Becomes permission config |
|
||||
|
||||
### Frontmatter Requirements
|
||||
|
||||
Each SKILL.md needs:
|
||||
```yaml
|
||||
---
|
||||
name: skill-name # Required, must match directory name
|
||||
description: Brief desc # Required, 1-1024 chars
|
||||
---
|
||||
```
|
||||
|
||||
### Audit Results (Already Compatible!)
|
||||
|
||||
Checked skills have proper frontmatter:
|
||||
- `gmail/SKILL.md` - Has `name: gmail`, `description: ...`
|
||||
- `sysadmin-health/SKILL.md` - Has `name: sysadmin-health`, `description: ...`
|
||||
- `morning-report/SKILL.md` - Has `name: morning-report`, `description: ...`
|
||||
|
||||
The `allowed-tools` field in some skills will be ignored by OpenCode (not in their schema), but this is fine.
|
||||
|
||||
### Action Items
|
||||
|
||||
1. ~~Audit each SKILL.md for required frontmatter~~ **Done - already compatible!**
|
||||
2. ~~Add missing `name`/`description` fields~~ **Not needed**
|
||||
3. Test skill discovery in OpenCode after install
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Agent Migration
|
||||
|
||||
### Mapping Strategy
|
||||
|
||||
| Claude Code | OpenCode | Notes |
|
||||
|------------|----------|-------|
|
||||
| `model: opus` | `model: anthropic/claude-opus-4` | Full provider/model path |
|
||||
| `model: sonnet` | `model: anthropic/claude-sonnet-4-5` | |
|
||||
| `model: haiku` | `model: anthropic/claude-haiku-4-5` | |
|
||||
| `tools: Read, Write...` | `tools: { write: true, ... }` | Boolean map |
|
||||
| Hierarchy (PA → MO → agent) | `mode: primary` + `mode: subagent` | Flattened |
|
||||
|
||||
### Agent Conversion Template
|
||||
|
||||
**From (Claude Code):**
|
||||
```yaml
|
||||
---
|
||||
name: linux-sysadmin
|
||||
description: Manages Arch Linux workstation...
|
||||
model: sonnet
|
||||
tools: Bash, Read, Write, Edit, Grep, Glob
|
||||
---
|
||||
```
|
||||
|
||||
**To (OpenCode):**
|
||||
```yaml
|
||||
---
|
||||
name: linux-sysadmin
|
||||
description: Manages Arch Linux workstation...
|
||||
mode: subagent
|
||||
model: anthropic/claude-sonnet-4-5
|
||||
tools:
|
||||
bash: true
|
||||
read: true
|
||||
write: true
|
||||
edit: true
|
||||
permission:
|
||||
bash:
|
||||
"*": ask
|
||||
"pacman -Q*": allow
|
||||
"systemctl status*": allow
|
||||
---
|
||||
```
|
||||
|
||||
### Priority Agents to Convert
|
||||
|
||||
1. **personal-assistant.md** → `mode: primary` (main interface)
|
||||
2. **linux-sysadmin.md** → `mode: subagent`
|
||||
3. **k8s-orchestrator.md** → `mode: subagent`
|
||||
4. **master-orchestrator.md** → May not be needed (OpenCode doesn't have same hierarchy)
|
||||
|
||||
### Hierarchy Adaptation
|
||||
|
||||
OpenCode doesn't have hierarchical agent delegation like your current setup. Options:
|
||||
- **Option A**: Flatten to primary + subagents, use `@agent` mentions
|
||||
- **Option B**: Use OpenCode's Task tool for agent invocation
|
||||
- **Option C**: Create a "dispatcher" primary agent that routes via @mentions
|
||||
|
||||
**Recommendation**: Option A (simplest) - personal-assistant as primary, others as subagents invokable via `@linux-sysadmin`, `@k8s-orchestrator`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Custom Tools (Scripts Execution)
|
||||
|
||||
### Wrapper Pattern
|
||||
|
||||
Create TypeScript wrappers that invoke your existing Python scripts:
|
||||
|
||||
**Example: `~/.config/opencode/tool/gmail.ts`**
|
||||
|
||||
```typescript
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
export const check_unread = tool({
|
||||
description: "Check unread emails from Gmail",
|
||||
args: {
|
||||
limit: tool.schema.number().optional().describe("Max emails to return"),
|
||||
},
|
||||
async execute(args) {
|
||||
const limit = args.limit ?? 10
|
||||
const result = await Bun.$`~/.claude/mcp/gmail/venv/bin/python ~/.claude/skills/gmail/scripts/check_unread.py --limit ${limit}`.text()
|
||||
return result.trim()
|
||||
},
|
||||
})
|
||||
|
||||
export const search = tool({
|
||||
description: "Search Gmail for specific emails",
|
||||
args: {
|
||||
query: tool.schema.string().describe("Search query"),
|
||||
},
|
||||
async execute(args) {
|
||||
const result = await Bun.$`~/.claude/mcp/gmail/venv/bin/python ~/.claude/skills/gmail/scripts/search.py "${args.query}"`.text()
|
||||
return result.trim()
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Tools to Create Wrappers For
|
||||
|
||||
| Script | Wrapper |
|
||||
|--------|---------|
|
||||
| `gmail/scripts/*.py` | `gmail.ts` |
|
||||
| `gcal/scripts/*.py` | `gcal.ts` |
|
||||
| `gtasks/scripts/*.py` | `gtasks.ts` |
|
||||
| `sysadmin-health/scripts/*.sh` | `sysadmin.ts` |
|
||||
| `morning-report/scripts/*.py` | `morning.ts` |
|
||||
| `stock-lookup/scripts/*.py` | `stocks.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Rules/Instructions
|
||||
|
||||
### Option A: Symlink CLAUDE.md
|
||||
|
||||
```bash
|
||||
ln -s ~/.claude/CLAUDE.md ~/.config/opencode/AGENTS.md
|
||||
```
|
||||
|
||||
### Option B: Create Minimal AGENTS.md + Reference
|
||||
|
||||
```markdown
|
||||
# OpenCode Agent Rules
|
||||
|
||||
Read @~/.claude/CLAUDE.md for shared conventions.
|
||||
|
||||
## OpenCode-Specific
|
||||
|
||||
- Use `@agent-name` to invoke subagents
|
||||
- Skills are loaded via the `skill` tool
|
||||
- Custom tools available: gmail, gcal, gtasks, sysadmin
|
||||
```
|
||||
|
||||
### Option C: Use instructions config
|
||||
|
||||
```json
|
||||
{
|
||||
"instructions": ["~/.claude/CLAUDE.md", "~/.claude/state/system-instructions.json"]
|
||||
}
|
||||
```
|
||||
|
||||
**Recommendation**: Option C - cleanest, no duplication
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: State Persistence (Claude Code as Source of Truth)
|
||||
|
||||
### Strategy
|
||||
|
||||
Claude Code owns the state files. OpenCode reads them via:
|
||||
1. `{file:path}` variable substitution in `opencode.json`
|
||||
2. `instructions` array for context files
|
||||
3. Skills that read state files directly
|
||||
|
||||
### What Can Be Shared
|
||||
|
||||
| File | Method | Notes |
|
||||
|------|--------|-------|
|
||||
| `~/.claude/CLAUDE.md` | `instructions` | Global rules |
|
||||
| `~/.claude/state/kb.json` | `instructions` or skill | Knowledge base |
|
||||
| `~/.claude/state/personal-assistant/memory/*.json` | `instructions` | Memory context |
|
||||
| `~/.claude/state/system-instructions.json` | `instructions` | Process definitions |
|
||||
|
||||
### Implementation in `opencode.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
|
||||
// Load Claude Code state as instructions (read at session start)
|
||||
"instructions": [
|
||||
"~/.claude/CLAUDE.md",
|
||||
"~/.claude/state/kb.json",
|
||||
"~/.claude/state/personal-assistant/memory/facts.json",
|
||||
"~/.claude/state/personal-assistant/memory/preferences.json"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### What Stays Separate
|
||||
|
||||
| Item | Reason |
|
||||
|------|--------|
|
||||
| Session history | Different formats, different storage |
|
||||
| Autonomy/permissions | OpenCode uses `permission` config instead |
|
||||
| Component registry | OpenCode discovers via file paths |
|
||||
|
||||
### Overhead Assessment
|
||||
|
||||
**Low overhead** - just config changes:
|
||||
- Add paths to `instructions` array
|
||||
- No symlinks or sync scripts needed
|
||||
- OpenCode reads files directly at session start
|
||||
- Claude Code continues to write/update normally
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: What Won't Transfer
|
||||
|
||||
| Feature | Claude Code | OpenCode Alternative |
|
||||
|---------|-------------|---------------------|
|
||||
| Hooks (SessionStart, etc.) | `hooks/hooks.json` | Plugins (future) |
|
||||
| Guardrails hook | PreToolUse script | `permission` config |
|
||||
| Component registry routing | Keyword triggers | Agent descriptions + @mentions |
|
||||
| Hierarchical delegation | PA → MO → agent | Flat subagent model |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Step 1: Backup (5 min) ✅ COMPLETE
|
||||
- [x] Create timestamped backup of `~/.config/opencode/` → `opencode-backup-20260107_120135.tar.gz`
|
||||
- [x] Create timestamped backup of `~/.opencode/` → `opencode-home-backup-20260107_120136.tar.gz`
|
||||
|
||||
### Step 2: Enhance Sync Script (45 min) ✅ COMPLETE
|
||||
- [x] Add skip list: `SKIP_AGENTS` (kept empty - all agents synced as subagents)
|
||||
- [x] Add `mode: subagent` to all synced agents
|
||||
- [x] Remove hardcoded model (agents inherit from runtime selection)
|
||||
- [x] Add model stripping from opencode.json
|
||||
- [x] Update `sync_tree()` to handle skipped files
|
||||
- [ ] ~~Optionally enhance descriptions with examples~~ (deferred)
|
||||
|
||||
### Step 3: Run Enhanced Sync (10 min) ✅ COMPLETE
|
||||
- [x] `python3 ~/.config/opencode/scripts/claude_sync.py --dry-run`
|
||||
- [x] Review output - verify mode/model changes
|
||||
- [x] `python3 ~/.config/opencode/scripts/claude_sync.py`
|
||||
- [x] All synced: 10 skills, 13 agents, 27 commands, 10 workflows
|
||||
|
||||
### Step 4: Update opencode.json (20 min) ✅ COMPLETE
|
||||
- [x] Add `instructions` array (CLAUDE.md, kb.json, memory files)
|
||||
- [x] Model defaults: intentionally omitted (user selects at runtime)
|
||||
- [x] Add permission config with safe command patterns
|
||||
|
||||
### Step 5: Testing (30 min) ✅ COMPLETE (automated)
|
||||
- [x] OpenCode v1.0.220 installed at `/home/linuxbrew/.linuxbrew/bin/opencode`
|
||||
- [x] `opencode agent list` shows 40 agents (built-in + synced)
|
||||
- [x] All Claude Code agents show as `(subagent)`
|
||||
- [x] 10 skills synced to `~/.config/opencode/skills/`
|
||||
- [x] Config verified: instructions, permissions, commands present
|
||||
- [ ] Manual TUI testing (user to verify interactively)
|
||||
|
||||
### Step 6: Documentation (20 min) ✅ COMPLETE
|
||||
- [x] Create `~/.config/opencode/README.md` (4.7KB)
|
||||
- [x] Document complete agent mapping table
|
||||
- [x] Document sync workflow with examples
|
||||
- [x] Add fc-047 to `~/.claude/state/future-considerations.json`
|
||||
|
||||
### Step 7: Iterate (as needed) ⏳ PENDING
|
||||
- [ ] Adjust agent descriptions if auto-invocation isn't working well
|
||||
- [ ] Tune permission patterns
|
||||
- [ ] Consider dropping/hiding agents that don't fit OpenCode model
|
||||
- [ ] Update documentation with lessons learned
|
||||
|
||||
**Status: IMPLEMENTATION COMPLETE** - Manual TUI testing recommended
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Documentation
|
||||
|
||||
### Documentation Deliverables
|
||||
|
||||
Create `~/.config/opencode/README.md` with:
|
||||
|
||||
1. **Architecture Overview**
|
||||
- Relationship between Claude Code and OpenCode
|
||||
- What's shared vs separate
|
||||
- Source of truth (Claude Code)
|
||||
|
||||
2. **Sync Workflow**
|
||||
- How `claude_sync.py` works
|
||||
- When to run it (after Claude Code changes)
|
||||
- Command reference
|
||||
|
||||
3. **Agent Mapping**
|
||||
- Which Claude Code agents map to OpenCode
|
||||
- Which are skipped and why
|
||||
- How to invoke subagents (@mentions)
|
||||
|
||||
4. **Skills**
|
||||
- Auto-discovery from `~/.claude/skills/`
|
||||
- How to add new skills
|
||||
- Skill invocation patterns
|
||||
|
||||
5. **State Sharing**
|
||||
- Files referenced via `instructions`
|
||||
- Claude Code as source of truth
|
||||
- What stays separate
|
||||
|
||||
6. **Permissions**
|
||||
- How guardrails translated to `permission` config
|
||||
- Safe vs prompted commands
|
||||
|
||||
### Documentation Template
|
||||
|
||||
```markdown
|
||||
# OpenCode Configuration
|
||||
|
||||
This OpenCode setup is synchronized from Claude Code (`~/.claude/`).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start OpenCode (uses built-in build agent)
|
||||
opencode
|
||||
|
||||
# Switch to read-only plan agent
|
||||
# Press Tab
|
||||
|
||||
# Invoke a subagent
|
||||
@linux-sysadmin check system health
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Claude Code (source of truth)
|
||||
├── ~/.claude/agents/ → synced to ~/.config/opencode/agents/
|
||||
├── ~/.claude/skills/ → synced to ~/.config/opencode/skills/
|
||||
├── ~/.claude/CLAUDE.md → referenced via instructions
|
||||
└── ~/.claude/state/ → referenced via instructions
|
||||
|
||||
OpenCode
|
||||
├── Built-in: build (primary), plan (read-only)
|
||||
├── Subagents: @linux-sysadmin, @k8s-orchestrator, etc.
|
||||
└── Skills: gmail, gcal, sysadmin-health, etc.
|
||||
```
|
||||
|
||||
## Sync Workflow
|
||||
|
||||
After making changes in Claude Code:
|
||||
|
||||
```bash
|
||||
# Preview changes
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --dry-run
|
||||
|
||||
# Apply changes
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py
|
||||
|
||||
# Clean stale files
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --clean --apply
|
||||
```
|
||||
|
||||
## Agents
|
||||
|
||||
| Claude Code | OpenCode | Notes |
|
||||
|-------------|----------|-------|
|
||||
| personal-assistant | (skipped) | Use built-in `build` |
|
||||
| master-orchestrator | (skipped) | Flat model, not needed |
|
||||
| linux-sysadmin | @linux-sysadmin | Subagent |
|
||||
| k8s-orchestrator | @k8s-orchestrator | Subagent |
|
||||
| ... | ... | ... |
|
||||
|
||||
## Skills
|
||||
|
||||
Skills are auto-discovered from:
|
||||
- `~/.claude/skills/*/SKILL.md`
|
||||
- `~/.config/opencode/skills/*/SKILL.md`
|
||||
|
||||
## State Files
|
||||
|
||||
Referenced via `instructions` in opencode.json:
|
||||
- `~/.claude/CLAUDE.md` - Global rules
|
||||
- `~/.claude/state/kb.json` - Knowledge base
|
||||
- `~/.claude/state/personal-assistant/memory/*.json` - Memory
|
||||
|
||||
## Permissions
|
||||
|
||||
Configured in opencode.json `permission` section.
|
||||
Migrated from Claude Code's guardrail hooks.
|
||||
```
|
||||
|
||||
### Implementation Step
|
||||
|
||||
Add to Step 6:
|
||||
- [ ] Create `~/.config/opencode/README.md`
|
||||
- [ ] Document sync workflow
|
||||
- [ ] Document agent mapping
|
||||
- [ ] Document any gotchas discovered during testing
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### Files to Create
|
||||
- `~/.config/opencode/README.md` - Documentation of setup, workflow, and requirements
|
||||
|
||||
### Files to Modify
|
||||
- `~/.config/opencode/opencode.json` - Add `instructions` array + model/permission config
|
||||
- `~/.config/opencode/scripts/claude_sync.py` - Add mode, model mappings, skip list
|
||||
|
||||
### Files Auto-Synced by Script
|
||||
These are created/updated by `claude_sync.py`:
|
||||
- `~/.config/opencode/agents/*.md` - From `~/.claude/agents/`
|
||||
- `~/.config/opencode/skills/*/SKILL.md` - From `~/.claude/skills/`
|
||||
- `~/.config/opencode/claude/commands/*.md` - From `~/.claude/commands/`
|
||||
- `~/.config/opencode/claude/workflows/*.yaml` - From `~/.claude/workflows/`
|
||||
|
||||
### Files Referenced (Not Copied)
|
||||
These stay in Claude Code, referenced via `instructions`:
|
||||
- `~/.claude/CLAUDE.md`
|
||||
- `~/.claude/state/kb.json`
|
||||
- `~/.claude/state/personal-assistant/memory/*.json`
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. `opencode` launches and shows available skills
|
||||
2. Can invoke `@linux-sysadmin` and get expected behavior
|
||||
3. Gmail/GCal/GTasks tools work via custom wrappers
|
||||
4. Can switch between build/plan agents + custom agents
|
||||
5. Both Claude Code and OpenCode can run in parallel without conflicts
|
||||
@@ -0,0 +1,150 @@
|
||||
# Plan: Session Summarization Hook
|
||||
|
||||
## Problem
|
||||
|
||||
Sessions are tracked in `~/.claude/state/personal-assistant/history/index.json` but:
|
||||
1. No conversation logs are captured to our history folder
|
||||
2. Sessions never get marked as summarized
|
||||
3. Memory files remain empty (decisions, preferences, projects, facts)
|
||||
|
||||
## Root Cause
|
||||
|
||||
Missing `SessionEnd` hook to trigger summarization when sessions end.
|
||||
|
||||
## Solution
|
||||
|
||||
Add a `SessionEnd` hook that:
|
||||
1. Reads the transcript from Claude's built-in storage (`transcript_path`)
|
||||
2. Extracts key information (decisions, preferences, project context, facts)
|
||||
3. Saves to appropriate memory files
|
||||
4. Updates history index to mark session as summarized
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `~/.claude/hooks/hooks.json` | Add `SessionEnd` hook entry |
|
||||
| `~/.claude/hooks/scripts/session-end.sh` | **Create** - orchestrates summarization |
|
||||
| `~/.claude/hooks/scripts/summarize-transcript.py` | **Create** - Python script to process transcript |
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Hook Configuration (`hooks.json`)
|
||||
|
||||
Add `SessionEnd` hook that calls the summarization script:
|
||||
|
||||
```json
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.claude/hooks/scripts/session-end.sh",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 2. Session End Script (`session-end.sh`)
|
||||
|
||||
- Receives JSON via stdin with `session_id`, `transcript_path`, `reason`
|
||||
- Calls Python summarization script
|
||||
- Handles errors gracefully (session end shouldn't fail)
|
||||
|
||||
### 3. Summarization Script (`summarize-transcript.py`)
|
||||
|
||||
The script will:
|
||||
|
||||
1. **Parse transcript** - Read the `.jsonl` file from `transcript_path`
|
||||
2. **Extract key items** - Use heuristics to identify:
|
||||
- Decisions: "let's use", "we decided", "I'll go with"
|
||||
- Preferences: "I prefer", "always", "never", "I like"
|
||||
- Project context: file paths, config references
|
||||
- Facts: environment info, tool locations
|
||||
3. **Deduplicate** - Check against existing memory items
|
||||
4. **Save to memory files** - Append new items with UUIDs
|
||||
5. **Update history index** - Mark session as summarized, add topics
|
||||
|
||||
### Processing Approach: Hybrid (Decision)
|
||||
|
||||
**Step 1: Threshold check**
|
||||
- Skip sessions with < 3 user messages
|
||||
- Skip sessions that are only quick commands (no substantive discussion)
|
||||
|
||||
**Step 2: Heuristic extraction (fast, no API)**
|
||||
- File paths mentioned → project context
|
||||
- Environment facts (tool locations, versions)
|
||||
- Simple preferences with clear keywords
|
||||
|
||||
**Step 3: LLM extraction (if substantive content)**
|
||||
- Complex decisions with rationale
|
||||
- Nuanced preferences
|
||||
- Project context requiring interpretation
|
||||
- Use Claude API (Haiku for cost efficiency)
|
||||
|
||||
### Transcript Storage (Decision)
|
||||
|
||||
Reference Claude's existing transcript location (`~/.claude/projects/.../[uuid].jsonl`) rather than copying to our history folder. The history index will store the transcript path for future reference.
|
||||
|
||||
### Memory File Format
|
||||
|
||||
Each item:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"date": "YYYY-MM-DD",
|
||||
"content": "Brief description",
|
||||
"context": "Additional context",
|
||||
"session": "session-id"
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Update hooks.json
|
||||
|
||||
Add `SessionEnd` entry to `~/.claude/hooks/hooks.json`
|
||||
|
||||
### Step 2: Create session-end.sh
|
||||
|
||||
Shell wrapper at `~/.claude/hooks/scripts/session-end.sh`:
|
||||
- Parse JSON input from stdin
|
||||
- Extract session_id, transcript_path, reason
|
||||
- Call Python summarization script
|
||||
- Handle errors silently (don't break session exit)
|
||||
|
||||
### Step 3: Create summarize-transcript.py
|
||||
|
||||
Python script at `~/.claude/hooks/scripts/summarize-transcript.py`:
|
||||
|
||||
```
|
||||
Arguments: --session-id <id> --transcript <path> [--reason <reason>]
|
||||
|
||||
1. Load transcript (.jsonl)
|
||||
2. Count user messages → skip if < 3
|
||||
3. Heuristic pass:
|
||||
- Extract file paths → projects.json
|
||||
- Extract env facts → facts.json
|
||||
4. If substantive content detected:
|
||||
- Call Claude API (Haiku) for decisions/preferences
|
||||
- Parse response → decisions.json, preferences.json
|
||||
5. Update history/index.json:
|
||||
- Set summarized: true
|
||||
- Add transcript_path
|
||||
- Add extracted topics
|
||||
```
|
||||
|
||||
### Step 4: Update history index schema
|
||||
|
||||
Add `transcript_path` field to session entries in `history/index.json`
|
||||
|
||||
## Testing
|
||||
|
||||
1. Start a test session with substantive discussion
|
||||
2. Exit session normally
|
||||
3. Verify:
|
||||
- Hook fired (check with `--debug`)
|
||||
- Memory files updated
|
||||
- History index marked summarized
|
||||
@@ -0,0 +1,903 @@
|
||||
# External LLM Integration Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Enable agents to use external LLMs (Copilot, Z.AI, Gemini) via CLI tools with a session toggle.
|
||||
|
||||
**Architecture:** Python router reads model-policy.json, invokes appropriate CLI (opencode/gemini), returns response. State file controls Claude vs external mode. PA exposes toggle commands.
|
||||
|
||||
**Tech Stack:** Python 3, subprocess, JSON state files, opencode CLI, gemini CLI
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create External Mode State File
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/state/external-mode.json`
|
||||
|
||||
**Step 1: Create state file**
|
||||
|
||||
```bash
|
||||
cat > ~/.claude/state/external-mode.json << 'EOF'
|
||||
{
|
||||
"enabled": false,
|
||||
"activated_at": null,
|
||||
"reason": null
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
**Step 2: Verify file**
|
||||
|
||||
Run: `cat ~/.claude/state/external-mode.json | jq .`
|
||||
Expected: Valid JSON with `enabled: false`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add state/external-mode.json
|
||||
git -C ~/.claude commit -m "feat(external-llm): add external-mode state file"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Extend Model Policy
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/state/model-policy.json`
|
||||
|
||||
**Step 1: Read current file**
|
||||
|
||||
Run: `cat ~/.claude/state/model-policy.json | jq .`
|
||||
|
||||
**Step 2: Add external_models section**
|
||||
|
||||
Add to model-policy.json (after `skill_delegation` section):
|
||||
|
||||
```json
|
||||
"external_models": {
|
||||
"copilot/gpt-5.2": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "copilot", "--model", "gpt-5.2"],
|
||||
"use_cases": ["reasoning", "fallback"],
|
||||
"tier": "opus-equivalent"
|
||||
},
|
||||
"copilot/sonnet-4.5": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "copilot", "--model", "sonnet-4.5"],
|
||||
"use_cases": ["general", "fallback"],
|
||||
"tier": "sonnet-equivalent"
|
||||
},
|
||||
"copilot/haiku-4.5": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "copilot", "--model", "haiku-4.5"],
|
||||
"use_cases": ["simple"],
|
||||
"tier": "haiku-equivalent"
|
||||
},
|
||||
"zai/glm-4.7": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "zai", "--model", "glm-4.7"],
|
||||
"use_cases": ["code-generation"],
|
||||
"tier": "sonnet-equivalent"
|
||||
},
|
||||
"gemini/gemini-3-pro": {
|
||||
"cli": "gemini",
|
||||
"cli_args": ["-m", "gemini-3-pro"],
|
||||
"use_cases": ["long-context"],
|
||||
"tier": "opus-equivalent"
|
||||
}
|
||||
},
|
||||
"claude_to_external_map": {
|
||||
"opus": "copilot/gpt-5.2",
|
||||
"sonnet": "copilot/sonnet-4.5",
|
||||
"haiku": "copilot/haiku-4.5"
|
||||
},
|
||||
"task_routing": {
|
||||
"reasoning": "copilot/gpt-5.2",
|
||||
"code-generation": "zai/glm-4.7",
|
||||
"long-context": "gemini/gemini-3-pro",
|
||||
"default": "copilot/sonnet-4.5"
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Validate JSON**
|
||||
|
||||
Run: `cat ~/.claude/state/model-policy.json | jq .`
|
||||
Expected: Valid JSON, no errors
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add state/model-policy.json
|
||||
git -C ~/.claude commit -m "feat(external-llm): add external model definitions to policy"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create Router Directory Structure
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/mcp/llm-router/`
|
||||
- Create: `~/.claude/mcp/llm-router/providers/`
|
||||
- Create: `~/.claude/mcp/llm-router/providers/__init__.py`
|
||||
|
||||
**Step 1: Create directories**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude/mcp/llm-router/providers
|
||||
```
|
||||
|
||||
**Step 2: Create __init__.py**
|
||||
|
||||
```bash
|
||||
touch ~/.claude/mcp/llm-router/providers/__init__.py
|
||||
```
|
||||
|
||||
**Step 3: Verify structure**
|
||||
|
||||
Run: `ls -la ~/.claude/mcp/llm-router/`
|
||||
Expected: `providers/` directory exists
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add mcp/llm-router/
|
||||
git -C ~/.claude commit -m "feat(external-llm): create llm-router directory structure"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create OpenCode Provider
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/mcp/llm-router/providers/opencode.py`
|
||||
|
||||
**Step 1: Write provider**
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""OpenCode CLI wrapper for Copilot, Z.AI, and other providers."""
|
||||
|
||||
import subprocess
|
||||
from typing import List
|
||||
|
||||
|
||||
def invoke(cli_args: List[str], prompt: str, timeout: int = 300) -> str:
|
||||
"""
|
||||
Invoke opencode CLI with given args and prompt.
|
||||
|
||||
Args:
|
||||
cli_args: Provider/model args like ["--provider", "copilot", "--model", "gpt-5.2"]
|
||||
prompt: The prompt text
|
||||
timeout: Timeout in seconds (default 5 minutes)
|
||||
|
||||
Returns:
|
||||
Model response as string
|
||||
|
||||
Raises:
|
||||
RuntimeError: If opencode CLI fails
|
||||
TimeoutError: If request exceeds timeout
|
||||
"""
|
||||
cmd = ["opencode", "--print"] + cli_args + ["-p", prompt]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise TimeoutError(f"opencode timed out after {timeout}s")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"opencode failed (exit {result.returncode}): {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick test
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
response = invoke(["--provider", "copilot", "--model", "gpt-5.2"], sys.argv[1])
|
||||
print(response)
|
||||
else:
|
||||
print("Usage: opencode.py 'prompt'")
|
||||
```
|
||||
|
||||
**Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x ~/.claude/mcp/llm-router/providers/opencode.py
|
||||
```
|
||||
|
||||
**Step 3: Verify syntax**
|
||||
|
||||
Run: `python3 -m py_compile ~/.claude/mcp/llm-router/providers/opencode.py`
|
||||
Expected: No output (success)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add mcp/llm-router/providers/opencode.py
|
||||
git -C ~/.claude commit -m "feat(external-llm): add opencode provider wrapper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Create Gemini Provider
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/mcp/llm-router/providers/gemini.py`
|
||||
|
||||
**Step 1: Write provider**
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Gemini CLI wrapper for Google models."""
|
||||
|
||||
import subprocess
|
||||
from typing import List
|
||||
|
||||
|
||||
def invoke(cli_args: List[str], prompt: str, timeout: int = 300) -> str:
|
||||
"""
|
||||
Invoke gemini CLI with given args and prompt.
|
||||
|
||||
Args:
|
||||
cli_args: Model args like ["-m", "gemini-3-pro"]
|
||||
prompt: The prompt text
|
||||
timeout: Timeout in seconds (default 5 minutes)
|
||||
|
||||
Returns:
|
||||
Model response as string
|
||||
|
||||
Raises:
|
||||
RuntimeError: If gemini CLI fails
|
||||
TimeoutError: If request exceeds timeout
|
||||
"""
|
||||
cmd = ["gemini"] + cli_args + ["-p", prompt]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise TimeoutError(f"gemini timed out after {timeout}s")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"gemini failed (exit {result.returncode}): {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick test
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
response = invoke(["-m", "gemini-3-pro"], sys.argv[1])
|
||||
print(response)
|
||||
else:
|
||||
print("Usage: gemini.py 'prompt'")
|
||||
```
|
||||
|
||||
**Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x ~/.claude/mcp/llm-router/providers/gemini.py
|
||||
```
|
||||
|
||||
**Step 3: Verify syntax**
|
||||
|
||||
Run: `python3 -m py_compile ~/.claude/mcp/llm-router/providers/gemini.py`
|
||||
Expected: No output (success)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add mcp/llm-router/providers/gemini.py
|
||||
git -C ~/.claude commit -m "feat(external-llm): add gemini provider wrapper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Create Main Router (invoke.py)
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/mcp/llm-router/invoke.py`
|
||||
|
||||
**Step 1: Write router**
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Invoke external LLM via configured provider.
|
||||
|
||||
Usage:
|
||||
invoke.py --model copilot/gpt-5.2 -p "prompt"
|
||||
invoke.py --task reasoning -p "prompt"
|
||||
invoke.py --task code-generation -p "prompt" --json
|
||||
|
||||
Model selection priority:
|
||||
1. Explicit --model flag
|
||||
2. Task-based routing (--task flag)
|
||||
3. Default from policy
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
STATE_DIR = Path.home() / ".claude/state"
|
||||
ROUTER_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def load_policy() -> dict:
|
||||
"""Load model policy from state file."""
|
||||
policy_file = STATE_DIR / "model-policy.json"
|
||||
with open(policy_file) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def resolve_model(args: argparse.Namespace, policy: dict) -> str:
|
||||
"""Determine which model to use based on args and policy."""
|
||||
if args.model:
|
||||
return args.model
|
||||
if args.task and args.task in policy.get("task_routing", {}):
|
||||
return policy["task_routing"][args.task]
|
||||
return policy.get("task_routing", {}).get("default", "copilot/sonnet-4.5")
|
||||
|
||||
|
||||
def invoke(model: str, prompt: str, policy: dict) -> str:
|
||||
"""Invoke the appropriate provider for the given model."""
|
||||
external_models = policy.get("external_models", {})
|
||||
|
||||
if model not in external_models:
|
||||
raise ValueError(f"Unknown model: {model}. Available: {list(external_models.keys())}")
|
||||
|
||||
model_config = external_models[model]
|
||||
cli = model_config["cli"]
|
||||
cli_args = model_config.get("cli_args", [])
|
||||
|
||||
# Import and invoke appropriate provider
|
||||
if cli == "opencode":
|
||||
sys.path.insert(0, str(ROUTER_DIR))
|
||||
from providers.opencode import invoke as opencode_invoke
|
||||
return opencode_invoke(cli_args, prompt)
|
||||
elif cli == "gemini":
|
||||
sys.path.insert(0, str(ROUTER_DIR))
|
||||
from providers.gemini import invoke as gemini_invoke
|
||||
return gemini_invoke(cli_args, prompt)
|
||||
else:
|
||||
raise ValueError(f"Unknown CLI: {cli}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Invoke external LLM via configured provider"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--prompt",
|
||||
required=True,
|
||||
help="Prompt text"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
help="Explicit model (e.g., copilot/gpt-5.2)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--task",
|
||||
choices=["reasoning", "code-generation", "long-context", "general"],
|
||||
help="Task type for automatic model routing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON with model info"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=300,
|
||||
help="Timeout in seconds (default: 300)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
policy = load_policy()
|
||||
model = resolve_model(args, policy)
|
||||
result = invoke(model, args.prompt, policy)
|
||||
|
||||
if args.json:
|
||||
output = {
|
||||
"model": model,
|
||||
"response": result,
|
||||
"success": True
|
||||
}
|
||||
print(json.dumps(output, indent=2))
|
||||
else:
|
||||
print(result)
|
||||
|
||||
except Exception as e:
|
||||
if args.json:
|
||||
output = {
|
||||
"model": args.model or "unknown",
|
||||
"error": str(e),
|
||||
"success": False
|
||||
}
|
||||
print(json.dumps(output, indent=2))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
**Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x ~/.claude/mcp/llm-router/invoke.py
|
||||
```
|
||||
|
||||
**Step 3: Verify syntax**
|
||||
|
||||
Run: `python3 -m py_compile ~/.claude/mcp/llm-router/invoke.py`
|
||||
Expected: No output (success)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add mcp/llm-router/invoke.py
|
||||
git -C ~/.claude commit -m "feat(external-llm): add main router invoke.py"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Create Delegation Helper
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/mcp/llm-router/delegate.py`
|
||||
|
||||
**Step 1: Write delegation helper**
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Agent delegation helper. Routes to external or Claude based on mode.
|
||||
|
||||
Usage:
|
||||
delegate.py --tier sonnet -p "prompt"
|
||||
delegate.py --tier opus -p "complex reasoning task" --json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
STATE_DIR = Path.home() / ".claude/state"
|
||||
ROUTER_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def is_external_mode() -> bool:
|
||||
"""Check if external-only mode is enabled."""
|
||||
mode_file = STATE_DIR / "external-mode.json"
|
||||
if mode_file.exists():
|
||||
with open(mode_file) as f:
|
||||
data = json.load(f)
|
||||
return data.get("enabled", False)
|
||||
return False
|
||||
|
||||
|
||||
def get_external_model(tier: str) -> str:
|
||||
"""Get the external model equivalent for a Claude tier."""
|
||||
policy_file = STATE_DIR / "model-policy.json"
|
||||
with open(policy_file) as f:
|
||||
policy = json.load(f)
|
||||
mapping = policy.get("claude_to_external_map", {})
|
||||
if tier not in mapping:
|
||||
raise ValueError(f"No external mapping for tier: {tier}")
|
||||
return mapping[tier]
|
||||
|
||||
|
||||
def delegate(tier: str, prompt: str, use_json: bool = False) -> str:
|
||||
"""
|
||||
Delegate to appropriate model based on mode.
|
||||
|
||||
Args:
|
||||
tier: Claude tier (opus, sonnet, haiku)
|
||||
prompt: The prompt text
|
||||
use_json: Return JSON output
|
||||
|
||||
Returns:
|
||||
Model response as string
|
||||
"""
|
||||
if is_external_mode():
|
||||
# Use external model
|
||||
model = get_external_model(tier)
|
||||
invoke_script = ROUTER_DIR / "invoke.py"
|
||||
|
||||
cmd = [sys.executable, str(invoke_script), "--model", model, "-p", prompt]
|
||||
if use_json:
|
||||
cmd.append("--json")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"External invoke failed: {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
else:
|
||||
# Use Claude
|
||||
cmd = ["claude", "--print", "--model", tier, prompt]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Claude failed: {result.stderr}")
|
||||
|
||||
response = result.stdout.strip()
|
||||
|
||||
if use_json:
|
||||
return json.dumps({
|
||||
"model": f"claude/{tier}",
|
||||
"response": response,
|
||||
"success": True
|
||||
}, indent=2)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Delegate to Claude or external model based on mode"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tier",
|
||||
required=True,
|
||||
choices=["opus", "sonnet", "haiku"],
|
||||
help="Claude tier (maps to external equivalent when in external mode)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--prompt",
|
||||
required=True,
|
||||
help="Prompt text"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
result = delegate(args.tier, args.prompt, args.json)
|
||||
print(result)
|
||||
except Exception as e:
|
||||
if args.json:
|
||||
print(json.dumps({"error": str(e), "success": False}, indent=2))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
**Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x ~/.claude/mcp/llm-router/delegate.py
|
||||
```
|
||||
|
||||
**Step 3: Verify syntax**
|
||||
|
||||
Run: `python3 -m py_compile ~/.claude/mcp/llm-router/delegate.py`
|
||||
Expected: No output (success)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add mcp/llm-router/delegate.py
|
||||
git -C ~/.claude commit -m "feat(external-llm): add delegation helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Create Toggle Script
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/mcp/llm-router/toggle.py`
|
||||
|
||||
**Step 1: Write toggle script**
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Toggle external-only mode.
|
||||
|
||||
Usage:
|
||||
toggle.py on [--reason "user requested"]
|
||||
toggle.py off
|
||||
toggle.py status
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
STATE_FILE = Path.home() / ".claude/state/external-mode.json"
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
"""Load current state."""
|
||||
if STATE_FILE.exists():
|
||||
with open(STATE_FILE) as f:
|
||||
return json.load(f)
|
||||
return {"enabled": False, "activated_at": None, "reason": None}
|
||||
|
||||
|
||||
def save_state(state: dict):
|
||||
"""Save state to file."""
|
||||
with open(STATE_FILE, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
|
||||
def enable(reason: str = None):
|
||||
"""Enable external-only mode."""
|
||||
state = {
|
||||
"enabled": True,
|
||||
"activated_at": datetime.now().isoformat(),
|
||||
"reason": reason or "user-requested"
|
||||
}
|
||||
save_state(state)
|
||||
print("External-only mode ENABLED")
|
||||
print(f" Activated: {state['activated_at']}")
|
||||
print(f" Reason: {state['reason']}")
|
||||
print("\nAll agent requests will now use external LLMs.")
|
||||
print("Run 'toggle.py off' or '/pa --external off' to disable.")
|
||||
|
||||
|
||||
def disable():
|
||||
"""Disable external-only mode."""
|
||||
state = {
|
||||
"enabled": False,
|
||||
"activated_at": None,
|
||||
"reason": None
|
||||
}
|
||||
save_state(state)
|
||||
print("External-only mode DISABLED")
|
||||
print("\nAll agent requests will now use Claude.")
|
||||
|
||||
|
||||
def status():
|
||||
"""Show current mode status."""
|
||||
state = load_state()
|
||||
if state.get("enabled"):
|
||||
print("External-only mode: ENABLED")
|
||||
print(f" Activated: {state.get('activated_at', 'unknown')}")
|
||||
print(f" Reason: {state.get('reason', 'unknown')}")
|
||||
else:
|
||||
print("External-only mode: DISABLED")
|
||||
print(" Using Claude for all requests.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Toggle external-only mode")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# on command
|
||||
on_parser = subparsers.add_parser("on", help="Enable external-only mode")
|
||||
on_parser.add_argument("--reason", help="Reason for enabling")
|
||||
|
||||
# off command
|
||||
subparsers.add_parser("off", help="Disable external-only mode")
|
||||
|
||||
# status command
|
||||
subparsers.add_parser("status", help="Show current mode")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "on":
|
||||
enable(args.reason)
|
||||
elif args.command == "off":
|
||||
disable()
|
||||
elif args.command == "status":
|
||||
status()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
**Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x ~/.claude/mcp/llm-router/toggle.py
|
||||
```
|
||||
|
||||
**Step 3: Test toggle**
|
||||
|
||||
Run: `~/.claude/mcp/llm-router/toggle.py status`
|
||||
Expected: Shows "External-only mode: DISABLED"
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add mcp/llm-router/toggle.py
|
||||
git -C ~/.claude commit -m "feat(external-llm): add toggle script"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Update Session Start Hook
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/hooks/scripts/session-start.sh`
|
||||
|
||||
**Step 1: Read current hook**
|
||||
|
||||
Run: `cat ~/.claude/hooks/scripts/session-start.sh`
|
||||
|
||||
**Step 2: Add external mode check**
|
||||
|
||||
Add before the final output section:
|
||||
|
||||
```bash
|
||||
# Check external mode
|
||||
if [ -f ~/.claude/state/external-mode.json ]; then
|
||||
EXTERNAL_ENABLED=$(jq -r '.enabled // false' ~/.claude/state/external-mode.json)
|
||||
if [ "$EXTERNAL_ENABLED" = "true" ]; then
|
||||
echo "external-mode:enabled"
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
**Step 3: Verify hook**
|
||||
|
||||
Run: `bash -n ~/.claude/hooks/scripts/session-start.sh`
|
||||
Expected: No output (success)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add hooks/scripts/session-start.sh
|
||||
git -C ~/.claude commit -m "feat(external-llm): announce external mode in session-start"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Add Component Registry Triggers
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/state/component-registry.json`
|
||||
|
||||
**Step 1: Read current registry**
|
||||
|
||||
Run: `cat ~/.claude/state/component-registry.json | jq '.skills'`
|
||||
|
||||
**Step 2: Add external-mode skill entry**
|
||||
|
||||
Add to skills array:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "external-mode-toggle",
|
||||
"name": "External Mode Toggle",
|
||||
"description": "Toggle between Claude and external LLMs",
|
||||
"path": "mcp/llm-router/toggle.py",
|
||||
"triggers": [
|
||||
"use external",
|
||||
"switch to external",
|
||||
"external models",
|
||||
"external only",
|
||||
"use copilot",
|
||||
"use opencode",
|
||||
"back to claude",
|
||||
"use claude again",
|
||||
"disable external",
|
||||
"external mode"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Validate JSON**
|
||||
|
||||
Run: `cat ~/.claude/state/component-registry.json | jq .`
|
||||
Expected: Valid JSON
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add state/component-registry.json
|
||||
git -C ~/.claude commit -m "feat(external-llm): add external-mode triggers to registry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Integration Test
|
||||
|
||||
**Step 1: Test toggle**
|
||||
|
||||
```bash
|
||||
~/.claude/mcp/llm-router/toggle.py status
|
||||
~/.claude/mcp/llm-router/toggle.py on --reason "testing"
|
||||
~/.claude/mcp/llm-router/toggle.py status
|
||||
~/.claude/mcp/llm-router/toggle.py off
|
||||
```
|
||||
|
||||
Expected: Status changes correctly
|
||||
|
||||
**Step 2: Test router (mock - will fail without actual CLIs)**
|
||||
|
||||
```bash
|
||||
~/.claude/mcp/llm-router/invoke.py --model copilot/gpt-5.2 -p "Say hello" --json 2>&1 || echo "Expected: CLI not found (normal if opencode not installed)"
|
||||
```
|
||||
|
||||
**Step 3: Test delegation helper status check**
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, '$HOME/.claude/mcp/llm-router')
|
||||
from delegate import is_external_mode
|
||||
print(f'External mode: {is_external_mode()}')
|
||||
"
|
||||
```
|
||||
|
||||
Expected: "External mode: False"
|
||||
|
||||
**Step 4: Final commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude commit --allow-empty -m "feat(external-llm): integration complete - gleaming-routing-mercury"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
After completing all tasks:
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| `state/external-mode.json` | Created |
|
||||
| `state/model-policy.json` | Extended |
|
||||
| `mcp/llm-router/invoke.py` | Created |
|
||||
| `mcp/llm-router/delegate.py` | Created |
|
||||
| `mcp/llm-router/toggle.py` | Created |
|
||||
| `mcp/llm-router/providers/opencode.py` | Created |
|
||||
| `mcp/llm-router/providers/gemini.py` | Created |
|
||||
| `hooks/scripts/session-start.sh` | Updated |
|
||||
| `state/component-registry.json` | Updated |
|
||||
|
||||
**To use:**
|
||||
```bash
|
||||
# Enable external mode
|
||||
~/.claude/mcp/llm-router/toggle.py on
|
||||
|
||||
# Or via PA
|
||||
/pa --external on
|
||||
/pa switch to external models
|
||||
|
||||
# Invoke directly
|
||||
~/.claude/mcp/llm-router/invoke.py --task reasoning -p "Explain quantum computing"
|
||||
|
||||
# Delegate (respects mode)
|
||||
~/.claude/mcp/llm-router/delegate.py --tier sonnet -p "Check disk space"
|
||||
```
|
||||
@@ -0,0 +1,375 @@
|
||||
# Plan: External LLM Integration
|
||||
|
||||
## Summary
|
||||
|
||||
Integrate external LLMs (via subscription-based access) into the agent system for cost optimization, specialized capabilities, and redundancy. Uses `opencode` CLI for Copilot/Z.AI models and `gemini` CLI for Google models.
|
||||
|
||||
## Motivation
|
||||
|
||||
- **Cost optimization** — Use cheaper models for simple tasks
|
||||
- **Specialized capabilities** — Access models with unique strengths (GPT-5.2 for reasoning, GLM 4.7 for code)
|
||||
- **Redundancy** — Fallback when Claude is unavailable
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| Provider type | Cloud APIs via subscription (not local) |
|
||||
| Providers | GitHub Copilot, Z.AI, Google Gemini |
|
||||
| CLIs | `opencode` (Copilot, Z.AI), `gemini` (Google) |
|
||||
| Integration | Task-specific routing + agent-level assignment |
|
||||
| Toggle | State file (persists across sessions) |
|
||||
| Toggle scope | All agents switch when enabled |
|
||||
|
||||
## Task Routing
|
||||
|
||||
| Task Type | Model |
|
||||
|-----------|-------|
|
||||
| Reasoning chains | copilot/gpt-5.2 |
|
||||
| Code generation | zai/glm-4.7 |
|
||||
| Long context | gemini/gemini-3-pro |
|
||||
| General/fallback | copilot/sonnet-4.5 |
|
||||
|
||||
## Claude-to-External Mapping
|
||||
|
||||
| Claude Tier | External Equivalent |
|
||||
|-------------|---------------------|
|
||||
| opus | copilot/gpt-5.2 |
|
||||
| sonnet | copilot/sonnet-4.5 |
|
||||
| haiku | copilot/haiku-4.5 |
|
||||
|
||||
## Files to Create
|
||||
|
||||
### `~/.claude/state/external-mode.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": false,
|
||||
"activated_at": null,
|
||||
"reason": null
|
||||
}
|
||||
```
|
||||
|
||||
### `~/.claude/mcp/llm-router/invoke.py`
|
||||
|
||||
Main entry point for invoking external LLMs.
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Invoke external LLM via configured provider.
|
||||
|
||||
Usage:
|
||||
invoke.py --model copilot/gpt-5.2 -p "prompt"
|
||||
invoke.py --task reasoning -p "prompt"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
STATE_DIR = Path.home() / ".claude/state"
|
||||
|
||||
def load_policy():
|
||||
with open(STATE_DIR / "model-policy.json") as f:
|
||||
return json.load(f)
|
||||
|
||||
def resolve_model(args, policy):
|
||||
if args.model:
|
||||
return args.model
|
||||
if args.task and args.task in policy["task_routing"]:
|
||||
return policy["task_routing"][args.task]
|
||||
return policy["task_routing"]["default"]
|
||||
|
||||
def invoke(model: str, prompt: str, policy: dict) -> str:
|
||||
model_config = policy["external_models"][model]
|
||||
cli = model_config["cli"]
|
||||
cli_args = model_config["cli_args"]
|
||||
|
||||
if cli == "opencode":
|
||||
from providers.opencode import invoke as opencode_invoke
|
||||
return opencode_invoke(cli_args, prompt)
|
||||
elif cli == "gemini":
|
||||
from providers.gemini import invoke as gemini_invoke
|
||||
return gemini_invoke(cli_args, prompt)
|
||||
else:
|
||||
raise ValueError(f"Unknown CLI: {cli}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("-p", "--prompt", required=True, help="Prompt text")
|
||||
parser.add_argument("--model", help="Explicit model (e.g., copilot/gpt-5.2)")
|
||||
parser.add_argument("--task", help="Task type for routing")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
policy = load_policy()
|
||||
model = resolve_model(args, policy)
|
||||
result = invoke(args.prompt, model, policy)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({"model": model, "response": result}))
|
||||
else:
|
||||
print(result)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
### `~/.claude/mcp/llm-router/providers/opencode.py`
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""OpenCode CLI wrapper."""
|
||||
|
||||
import subprocess
|
||||
|
||||
def invoke(cli_args: list, prompt: str) -> str:
|
||||
cmd = ["opencode", "--print"] + cli_args + ["-p", prompt]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"opencode failed: {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
```
|
||||
|
||||
### `~/.claude/mcp/llm-router/providers/gemini.py`
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Gemini CLI wrapper."""
|
||||
|
||||
import subprocess
|
||||
|
||||
def invoke(cli_args: list, prompt: str) -> str:
|
||||
cmd = ["gemini"] + cli_args + ["-p", prompt]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"gemini failed: {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
```
|
||||
|
||||
### `~/.claude/mcp/llm-router/delegate.py`
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Agent delegation helper. Routes to external or Claude based on mode.
|
||||
|
||||
Usage:
|
||||
delegate.py --tier sonnet -p "prompt"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
STATE_DIR = Path.home() / ".claude/state"
|
||||
|
||||
def is_external_mode():
|
||||
mode_file = STATE_DIR / "external-mode.json"
|
||||
if mode_file.exists():
|
||||
with open(mode_file) as f:
|
||||
return json.load(f).get("enabled", False)
|
||||
return False
|
||||
|
||||
def delegate(tier: str, prompt: str) -> str:
|
||||
if is_external_mode():
|
||||
policy = json.loads((STATE_DIR / "model-policy.json").read_text())
|
||||
model = policy["claude_to_external_map"][tier]
|
||||
|
||||
result = subprocess.run(
|
||||
[str(Path.home() / ".claude/mcp/llm-router/invoke.py"),
|
||||
"--model", model, "-p", prompt],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.stdout
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["claude", "--print", "--model", tier, prompt],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--tier", required=True, choices=["opus", "sonnet", "haiku"])
|
||||
parser.add_argument("-p", "--prompt", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(delegate(args.tier, args.prompt))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### `~/.claude/state/model-policy.json`
|
||||
|
||||
Add sections:
|
||||
|
||||
```json
|
||||
{
|
||||
"external_models": {
|
||||
"copilot/gpt-5.2": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "copilot", "--model", "gpt-5.2"],
|
||||
"use_cases": ["reasoning", "fallback"],
|
||||
"tier": "opus-equivalent"
|
||||
},
|
||||
"copilot/sonnet-4.5": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "copilot", "--model", "sonnet-4.5"],
|
||||
"use_cases": ["general", "fallback"],
|
||||
"tier": "sonnet-equivalent"
|
||||
},
|
||||
"copilot/haiku-4.5": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "copilot", "--model", "haiku-4.5"],
|
||||
"use_cases": ["simple"],
|
||||
"tier": "haiku-equivalent"
|
||||
},
|
||||
"zai/glm-4.7": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "zai", "--model", "glm-4.7"],
|
||||
"use_cases": ["code-generation"],
|
||||
"tier": "sonnet-equivalent"
|
||||
},
|
||||
"gemini/gemini-3-pro": {
|
||||
"cli": "gemini",
|
||||
"cli_args": ["-m", "gemini-3-pro"],
|
||||
"use_cases": ["long-context"],
|
||||
"tier": "opus-equivalent"
|
||||
}
|
||||
},
|
||||
"claude_to_external_map": {
|
||||
"opus": "copilot/gpt-5.2",
|
||||
"sonnet": "copilot/sonnet-4.5",
|
||||
"haiku": "copilot/haiku-4.5"
|
||||
},
|
||||
"task_routing": {
|
||||
"reasoning": "copilot/gpt-5.2",
|
||||
"code-generation": "zai/glm-4.7",
|
||||
"long-context": "gemini/gemini-3-pro",
|
||||
"default": "copilot/sonnet-4.5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `~/.claude/state/component-registry.json`
|
||||
|
||||
Add trigger for external mode:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "external-mode-toggle",
|
||||
"type": "skill",
|
||||
"triggers": [
|
||||
"use external", "switch to external", "external models",
|
||||
"stop using claude", "external only", "use copilot",
|
||||
"back to claude", "use claude again", "disable external"
|
||||
],
|
||||
"action": "toggle-external-mode"
|
||||
}
|
||||
```
|
||||
|
||||
### `~/.claude/hooks/scripts/session-start.sh`
|
||||
|
||||
Add external mode announcement:
|
||||
|
||||
```bash
|
||||
# Check external mode
|
||||
if [ -f ~/.claude/state/external-mode.json ]; then
|
||||
if [ "$(jq -r '.enabled' ~/.claude/state/external-mode.json)" = "true" ]; then
|
||||
echo "external-mode:enabled"
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
## Toggle Interface
|
||||
|
||||
### Command-based
|
||||
|
||||
```
|
||||
/pa --external on # Enable external-only mode
|
||||
/pa --external off # Disable, return to Claude
|
||||
/pa --external status # Show current mode
|
||||
```
|
||||
|
||||
### Natural language
|
||||
|
||||
| User says | Action |
|
||||
|-----------|--------|
|
||||
| "switch to external models" | Enable |
|
||||
| "use copilot for everything" | Enable |
|
||||
| "go back to claude" | Disable |
|
||||
| "are we using external?" | Status |
|
||||
| "use external for this" | One-shot (no persist) |
|
||||
|
||||
### Visual indicator
|
||||
|
||||
When external mode active, PA prefixes responses:
|
||||
|
||||
```
|
||||
🔌 [External: copilot/gpt-5.2]
|
||||
|
||||
<response>
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Create `external-mode.json` state file
|
||||
2. Extend `model-policy.json` with external models
|
||||
3. Create `llm-router/` directory and scripts
|
||||
4. Add provider wrappers (opencode, gemini)
|
||||
5. Create delegation helper
|
||||
6. Update PA with toggle commands
|
||||
7. Add component-registry triggers
|
||||
8. Update session-start hook
|
||||
9. Test each provider
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
# Test router directly
|
||||
~/.claude/mcp/llm-router/invoke.py --model copilot/gpt-5.2 -p "Say hello"
|
||||
|
||||
# Test delegation
|
||||
~/.claude/mcp/llm-router/delegate.py --tier sonnet -p "What is 2+2?"
|
||||
|
||||
# Test toggle
|
||||
/pa --external on
|
||||
/pa what time is it?
|
||||
/pa --external off
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
If issues arise:
|
||||
1. Set `external-mode.json` → `enabled: false`
|
||||
2. All operations revert to Claude immediately
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Auto-fallback to external when Claude rate-limited
|
||||
- Cost tracking per external model
|
||||
- Response quality comparison metrics
|
||||
- Additional providers (Mistral, local Ollama)
|
||||
@@ -0,0 +1,230 @@
|
||||
# Plan: Add Plan Status Tracking
|
||||
|
||||
## Problem
|
||||
|
||||
Plans in `~/.claude/plans/` have inconsistent status tracking:
|
||||
- Some have inline `**Status:** Implemented`
|
||||
- Most have no status marker
|
||||
- No central index to query plan statuses
|
||||
|
||||
## Solution
|
||||
|
||||
Create `~/.claude/plans/index.json` as a central registry for plan metadata and status.
|
||||
|
||||
## Design
|
||||
|
||||
### Index Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"plans": {
|
||||
"temporal-foraging-milner": {
|
||||
"title": "RAG JSON-to-text transformation",
|
||||
"status": "pending",
|
||||
"created": "2026-01-05",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"fizzy-puzzling-candy": {
|
||||
"title": "Session summarization hook",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-03",
|
||||
"implemented": "2026-01-03",
|
||||
"category": "feature"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Status Values
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `pending` | Not yet implemented |
|
||||
| `implemented` | Fully implemented |
|
||||
| `partial` | Partially implemented |
|
||||
| `abandoned` | Decided not to implement |
|
||||
| `superseded` | Replaced by another plan |
|
||||
|
||||
### Categories
|
||||
|
||||
| Category | Meaning |
|
||||
|----------|---------|
|
||||
| `feature` | New capability |
|
||||
| `enhancement` | Improve existing feature |
|
||||
| `bugfix` | Fix an issue |
|
||||
| `diagnostic` | One-time investigation (auto-complete) |
|
||||
| `design` | Design document for reference |
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `~/.claude/plans/index.json` | Central plan registry |
|
||||
|
||||
## Implementation
|
||||
|
||||
### Step 1: Create index.json with all current plans
|
||||
|
||||
Populate based on our verification:
|
||||
|
||||
**Implemented:**
|
||||
- wise-dazzling-marshmallow (k8s quick-status)
|
||||
- fizzy-puzzling-candy (session summarization)
|
||||
- shimmering-discovering-bonbon (linux sysadmin agent)
|
||||
- valiant-hugging-dahl (pi50 optimization)
|
||||
- cozy-strolling-nygaard (status line + keybind)
|
||||
- flickering-enchanting-fiddle (restructure components)
|
||||
- velvet-percolating-porcupine (no-redundancy rule)
|
||||
- 2025-01-02-gcal-design
|
||||
- 2026-01-01-component-registry-design
|
||||
- 2026-01-01-usage-tracking-design
|
||||
|
||||
**Diagnostic (complete):**
|
||||
- elegant-prancing-allen (vulkan verification)
|
||||
- pure-wishing-metcalfe (cluster diagnosis)
|
||||
- glistening-wondering-wadler (structure verification)
|
||||
|
||||
**Pending:**
|
||||
- temporal-foraging-milner (RAG improvement)
|
||||
- cosmic-frolicking-compass (Zed Wayland)
|
||||
|
||||
**Handoff doc (reference only):**
|
||||
- shimmering-discovering-bonbon-handoff
|
||||
|
||||
### Step 2: Update CLAUDE.md
|
||||
|
||||
Add plans index to state files table.
|
||||
|
||||
## Benefits
|
||||
|
||||
1. Single source of truth for plan statuses
|
||||
2. Easy to query: `jq '.plans | to_entries[] | select(.value.status == "pending")' index.json`
|
||||
3. No need to modify individual plan files
|
||||
4. Can track implementation dates
|
||||
|
||||
## Full index.json Content
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"description": "Plan status registry",
|
||||
"plans": {
|
||||
"temporal-foraging-milner": {
|
||||
"title": "RAG JSON-to-text transformation",
|
||||
"status": "pending",
|
||||
"created": "2026-01-05",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"cosmic-frolicking-compass": {
|
||||
"title": "Zed Wayland compilation",
|
||||
"status": "pending",
|
||||
"created": "2025-12-13",
|
||||
"category": "enhancement",
|
||||
"notes": "External task - compile Zed with Wayland support"
|
||||
},
|
||||
"wise-dazzling-marshmallow": {
|
||||
"title": "K8s quick-status skill",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-29",
|
||||
"category": "feature"
|
||||
},
|
||||
"fizzy-puzzling-candy": {
|
||||
"title": "Session summarization hook",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-03",
|
||||
"implemented": "2026-01-03",
|
||||
"category": "feature"
|
||||
},
|
||||
"shimmering-discovering-bonbon": {
|
||||
"title": "Linux sysadmin agent",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "feature"
|
||||
},
|
||||
"valiant-hugging-dahl": {
|
||||
"title": "Pi50 resource optimization",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-05",
|
||||
"implemented": "2026-01-05",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"cozy-strolling-nygaard": {
|
||||
"title": "Status line + keybind fix",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-29",
|
||||
"implemented": "2025-12-29",
|
||||
"category": "bugfix"
|
||||
},
|
||||
"flickering-enchanting-fiddle": {
|
||||
"title": "Restructure components",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"velvet-percolating-porcupine": {
|
||||
"title": "No-redundancy rule",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"elegant-prancing-allen": {
|
||||
"title": "Vulkan verification",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-12",
|
||||
"implemented": "2025-12-12",
|
||||
"category": "diagnostic"
|
||||
},
|
||||
"pure-wishing-metcalfe": {
|
||||
"title": "Cluster issue diagnosis",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-27",
|
||||
"implemented": "2025-12-27",
|
||||
"category": "diagnostic"
|
||||
},
|
||||
"glistening-wondering-wadler": {
|
||||
"title": "Structure verification report",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-03",
|
||||
"implemented": "2026-01-03",
|
||||
"category": "diagnostic"
|
||||
},
|
||||
"2025-01-02-gcal-design": {
|
||||
"title": "Google Calendar integration",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-31",
|
||||
"implemented": "2026-01-01",
|
||||
"category": "design"
|
||||
},
|
||||
"2026-01-01-component-registry-design": {
|
||||
"title": "Component registry",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-01",
|
||||
"implemented": "2026-01-01",
|
||||
"category": "design"
|
||||
},
|
||||
"2026-01-01-usage-tracking-design": {
|
||||
"title": "Usage tracking",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-31",
|
||||
"implemented": "2026-01-01",
|
||||
"category": "design"
|
||||
},
|
||||
"shimmering-discovering-bonbon-handoff": {
|
||||
"title": "Linux sysadmin handoff doc",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "design",
|
||||
"notes": "Reference document for shimmering-discovering-bonbon"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Commit
|
||||
|
||||
Single commit: "Add plans index.json for status tracking"
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"description": "Plan status registry",
|
||||
"plans": {
|
||||
"temporal-foraging-milner": {
|
||||
"title": "RAG JSON-to-text transformation",
|
||||
"status": "pending",
|
||||
"created": "2026-01-05",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"cosmic-frolicking-compass": {
|
||||
"title": "Zed Wayland compilation",
|
||||
"status": "pending",
|
||||
"created": "2025-12-13",
|
||||
"category": "enhancement",
|
||||
"notes": "External task - compile Zed with Wayland support"
|
||||
},
|
||||
"wise-dazzling-marshmallow": {
|
||||
"title": "K8s quick-status skill",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-29",
|
||||
"category": "feature"
|
||||
},
|
||||
"fizzy-puzzling-candy": {
|
||||
"title": "Session summarization hook",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-03",
|
||||
"implemented": "2026-01-03",
|
||||
"category": "feature"
|
||||
},
|
||||
"shimmering-discovering-bonbon": {
|
||||
"title": "Linux sysadmin agent",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "feature"
|
||||
},
|
||||
"valiant-hugging-dahl": {
|
||||
"title": "Pi50 resource optimization",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-05",
|
||||
"implemented": "2026-01-05",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"cozy-strolling-nygaard": {
|
||||
"title": "Status line + keybind fix",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-29",
|
||||
"implemented": "2025-12-29",
|
||||
"category": "bugfix"
|
||||
},
|
||||
"flickering-enchanting-fiddle": {
|
||||
"title": "Restructure components",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"velvet-percolating-porcupine": {
|
||||
"title": "No-redundancy rule",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"elegant-prancing-allen": {
|
||||
"title": "Vulkan verification",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-12",
|
||||
"implemented": "2025-12-12",
|
||||
"category": "diagnostic"
|
||||
},
|
||||
"pure-wishing-metcalfe": {
|
||||
"title": "Cluster issue diagnosis",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-27",
|
||||
"implemented": "2025-12-27",
|
||||
"category": "diagnostic"
|
||||
},
|
||||
"glistening-wondering-wadler": {
|
||||
"title": "Structure verification report",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-03",
|
||||
"implemented": "2026-01-03",
|
||||
"category": "diagnostic"
|
||||
},
|
||||
"2025-01-02-gcal-design": {
|
||||
"title": "Google Calendar integration",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-31",
|
||||
"implemented": "2026-01-01",
|
||||
"category": "design"
|
||||
},
|
||||
"2026-01-01-component-registry-design": {
|
||||
"title": "Component registry",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-01",
|
||||
"implemented": "2026-01-01",
|
||||
"category": "design"
|
||||
},
|
||||
"2026-01-01-usage-tracking-design": {
|
||||
"title": "Usage tracking",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-31",
|
||||
"implemented": "2026-01-01",
|
||||
"category": "design"
|
||||
},
|
||||
"shimmering-discovering-bonbon-handoff": {
|
||||
"title": "Linux sysadmin handoff doc",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "design",
|
||||
"notes": "Reference document for shimmering-discovering-bonbon"
|
||||
},
|
||||
"golden-imagining-engelbart": {
|
||||
"title": "Plan status tracking",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-07",
|
||||
"implemented": "2026-01-07",
|
||||
"category": "enhancement",
|
||||
"notes": "This plan - meta!"
|
||||
},
|
||||
"gleaming-routing-mercury": {
|
||||
"title": "External LLM integration",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-08",
|
||||
"implemented": "2026-01-08",
|
||||
"category": "feature",
|
||||
"notes": "fc-004 - Cloud API integration via opencode/gemini CLIs with session toggle"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# Implementation Plan: OpenCode Claude Sync Enhancements
|
||||
|
||||
## Overview
|
||||
|
||||
Transpose Claude Code agent/skill setup to OpenCode in parallel, per decisions from brainstorming session (`enumerated-giggling-scone.md`).
|
||||
|
||||
## Key Decisions (from brainstorming)
|
||||
|
||||
| Decision | Value |
|
||||
|----------|-------|
|
||||
| Primary agent | Use built-in `build` (don't port PA) |
|
||||
| Agents to skip | `personal-assistant`, `master-orchestrator` |
|
||||
| Other agents | All become `mode: subagent` |
|
||||
| Model inheritance | Use `model: inherit` for subagents |
|
||||
| State sharing | Reference via `instructions`, don't copy |
|
||||
| Source of truth | Claude Code (`~/.claude/`) |
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `~/.config/opencode/scripts/claude_sync.py` - Main sync script
|
||||
2. `~/.config/opencode/opencode.json` - Config file
|
||||
|
||||
## Files to Create
|
||||
|
||||
1. `~/.config/opencode/README.md` - Documentation
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Backup (DONE)
|
||||
|
||||
Created backups:
|
||||
- `~/.config/opencode-backup-20260107_120135.tar.gz`
|
||||
- `~/opencode-home-backup-20260107_120136.tar.gz`
|
||||
|
||||
### Step 2: Enhance `claude_sync.py`
|
||||
|
||||
**Location**: `~/.config/opencode/scripts/claude_sync.py`
|
||||
|
||||
**Modifications**:
|
||||
|
||||
1. Add constants near top of file:
|
||||
```python
|
||||
SKIP_AGENTS = {"personal-assistant", "master-orchestrator"}
|
||||
|
||||
MODEL_MAP = {
|
||||
"opus": "anthropic/claude-opus-4",
|
||||
"sonnet": "anthropic/claude-sonnet-4-5",
|
||||
"haiku": "anthropic/claude-haiku-4-5",
|
||||
}
|
||||
```
|
||||
|
||||
2. Modify `transform_frontmatter()` for agents:
|
||||
- Check if agent name in `SKIP_AGENTS`, return `None` to signal skip
|
||||
- Add `frontmatter["mode"] = "subagent"`
|
||||
- Set `frontmatter["model"] = "inherit"`
|
||||
- Map explicit models using `MODEL_MAP`
|
||||
|
||||
3. Modify `sync_tree()` to handle `None` return from transform (skip file)
|
||||
|
||||
4. Update `expected_dest_paths_for_tree()` to exclude skipped agents
|
||||
|
||||
### Step 3: Run Sync
|
||||
|
||||
```bash
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --dry-run
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --clean --apply
|
||||
```
|
||||
|
||||
### Step 4: Update `opencode.json`
|
||||
|
||||
Add to existing config:
|
||||
```json
|
||||
{
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"small_model": "anthropic/claude-haiku-4-5",
|
||||
"instructions": [
|
||||
"~/.claude/CLAUDE.md",
|
||||
"~/.claude/state/kb.json",
|
||||
"~/.claude/state/personal-assistant/memory/facts.json",
|
||||
"~/.claude/state/personal-assistant/memory/preferences.json"
|
||||
],
|
||||
"permission": {
|
||||
"edit": "ask",
|
||||
"bash": {
|
||||
"*": "ask",
|
||||
"pacman -Q*": "allow",
|
||||
"systemctl status*": "allow",
|
||||
"kubectl get*": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Test
|
||||
|
||||
- Run `opencode` and verify skill discovery
|
||||
- Test `@linux-sysadmin` subagent invocation
|
||||
- Verify permissions work
|
||||
|
||||
### Step 6: Create README.md
|
||||
|
||||
Document:
|
||||
- Architecture (Claude Code as source of truth)
|
||||
- Sync workflow
|
||||
- Agent mapping table
|
||||
- How to invoke subagents
|
||||
|
||||
### Step 7: Add Future Consideration
|
||||
|
||||
Add entry to `~/.claude/state/future-considerations.json` about JSON minification for large instruction files.
|
||||
|
||||
## Estimated Time
|
||||
|
||||
~2 hours total (Step 1 already done)
|
||||
@@ -0,0 +1,110 @@
|
||||
# Plan: Improve RAG Personal Index JSON-to-Natural-Language Transformation
|
||||
|
||||
## Problem
|
||||
|
||||
The RAG personal index produces low-quality matches for semantic queries because it indexes raw JSON structure rather than natural language.
|
||||
|
||||
**Example failure:**
|
||||
- Query: "how to add a new agent"
|
||||
- Expected: Match `system-instructions.json` → `processes.agent-lifecycle.add`
|
||||
- Actual: Score 0.479, returns generic agent mentions instead
|
||||
|
||||
**Root cause:** The chunker doesn't recognize process structures with `add`/`remove`/`rules`/`requirements` arrays, so they fall through to raw JSON stringification.
|
||||
|
||||
## Solution
|
||||
|
||||
Enhance `index_personal.py` to transform JSON structures into natural language at index time.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `~/.claude/skills/rag-search/scripts/index_personal.py` - Main changes
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Add Process Pattern Recognition (lines ~127-138)
|
||||
|
||||
Add handling for process objects with action arrays:
|
||||
|
||||
```python
|
||||
# Process with action arrays (add, remove, rules, requirements, etc.)
|
||||
action_keys = ["add", "remove", "rules", "requirements", "steps", "validate"]
|
||||
if any(key in item for key in action_keys):
|
||||
parts = []
|
||||
if context:
|
||||
parts.append(f"{context}:")
|
||||
if item.get("description"):
|
||||
parts.append(item["description"])
|
||||
|
||||
for action_key in action_keys:
|
||||
if action_key in item and isinstance(item[action_key], list):
|
||||
action_text = f"To {action_key}: " + ". ".join(item[action_key])
|
||||
parts.append(action_text)
|
||||
|
||||
if parts:
|
||||
yield (" ".join(parts), {**base_metadata, "process": context})
|
||||
return
|
||||
```
|
||||
|
||||
### 2. Improve Context Propagation
|
||||
|
||||
When processing nested dicts, pass richer context:
|
||||
|
||||
```python
|
||||
# In the top-level dict processing (line ~154-161)
|
||||
elif isinstance(value, dict):
|
||||
# Pass the key as context for better chunk text
|
||||
yield from process_item(value, context=key)
|
||||
```
|
||||
|
||||
Already done, but ensure action arrays get the context.
|
||||
|
||||
### 3. Handle Key-Value Pairs in Processes
|
||||
|
||||
For structures like:
|
||||
```json
|
||||
"content-principles": {
|
||||
"no-redundancy": "Information lives in one authoritative location",
|
||||
"lean-files": "Keep files concise..."
|
||||
}
|
||||
```
|
||||
|
||||
Transform to: `"content-principles: no-redundancy means information lives in one authoritative location. lean-files means keep files concise..."`
|
||||
|
||||
### 4. Add Tests
|
||||
|
||||
Create a simple test to verify transformation quality:
|
||||
|
||||
```bash
|
||||
# After reindex, verify the failing query now works
|
||||
~/.claude/skills/rag-search/scripts/search.py "how to add a new agent" --index personal
|
||||
# Should return system-instructions.json with score > 0.7
|
||||
```
|
||||
|
||||
## Expected Outcome
|
||||
|
||||
| Query | Before | After |
|
||||
|-------|--------|-------|
|
||||
| "how to add a new agent" | 0.479, wrong file | >0.7, system-instructions.json |
|
||||
| "agent lifecycle" | Similar | Better match to process |
|
||||
| "model selection rules" | Depends | Match model-selection process |
|
||||
|
||||
## Validation Steps
|
||||
|
||||
1. Run modified indexer
|
||||
2. Test the three queries above
|
||||
3. Compare scores and result relevance
|
||||
|
||||
## Rollback
|
||||
|
||||
If results degrade: `git checkout scripts/index_personal.py && reindex`
|
||||
|
||||
## Post-Implementation
|
||||
|
||||
Add to `future-considerations.json`:
|
||||
- RAG indexer debug/verbose mode to inspect what text is being indexed
|
||||
|
||||
## Future Considerations (Deferred)
|
||||
|
||||
- Natural language templates per JSON schema type
|
||||
- LLM-generated summaries of complex structures
|
||||
- Caching transformed text alongside original JSON
|
||||
@@ -0,0 +1,171 @@
|
||||
# Plan: Improve pi50 (Control Plane) Resource Usage
|
||||
|
||||
## Problem Summary
|
||||
|
||||
pi50 (control plane) is running at **73% CPU / 81% memory** while worker nodes have significant headroom:
|
||||
- pi3: 7% CPU / 65% memory (but only 800MB RAM - memory constrained)
|
||||
- pi51: 18% CPU / 64% memory (8GB RAM - plenty of capacity)
|
||||
|
||||
**Root cause**: pi50 has **NO control-plane taint**, so the scheduler treats it as a general worker node. It currently runs ~85 pods vs 38 on pi51.
|
||||
|
||||
## Current State
|
||||
|
||||
| Node | Role | CPUs | Memory | CPU Used | Mem Used | Pods |
|
||||
|------|------|------|--------|----------|----------|------|
|
||||
| pi50 | control-plane | 4 | 8GB | 73% | 81% | ~85 |
|
||||
| pi3 | worker | 4 | 800MB | 7% | 65% | 13 |
|
||||
| pi51 | worker | 4 | 8GB | 18% | 64% | 38 |
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
### Option A: Add PreferNoSchedule Taint (Recommended)
|
||||
|
||||
Add a soft taint to pi50 that tells the scheduler to prefer other nodes for new workloads, while allowing existing pods to remain.
|
||||
|
||||
```bash
|
||||
kubectl taint nodes pi50 node-role.kubernetes.io/control-plane=:PreferNoSchedule
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Non-disruptive - existing pods continue running
|
||||
- New pods will prefer pi51/pi3
|
||||
- Gradual rebalancing as pods are recreated
|
||||
- Easy to remove if needed
|
||||
|
||||
**Cons:**
|
||||
- Won't immediately reduce load
|
||||
- Existing pods stay where they are
|
||||
|
||||
### Option B: Move Heavy Workloads Immediately
|
||||
|
||||
Identify and relocate the heaviest workloads from pi50 to pi51:
|
||||
|
||||
**Top CPU consumers on pi50:**
|
||||
1. ArgoCD application-controller (157m CPU, 364Mi) - should stay (manages cluster)
|
||||
2. Longhorn instance-manager (139m CPU, 707Mi) - must stay (storage)
|
||||
3. ai-stack workloads (ollama, litellm, open-webui, etc.)
|
||||
|
||||
**Candidates to move to pi51:**
|
||||
- `ai-stack/ollama` - can run on any node with storage
|
||||
- `ai-stack/litellm` - stateless, can move
|
||||
- `ai-stack/open-webui` - can move
|
||||
- `ai-stack/claude-code`, `codex`, `gemini-cli`, `opencode` - can move
|
||||
- `minio` - can move (uses PVC)
|
||||
- `pihole2` - can move
|
||||
|
||||
**Method**: Add `nodeSelector` or `nodeAffinity` to deployments:
|
||||
```yaml
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: pi51
|
||||
```
|
||||
|
||||
Or use anti-affinity to avoid pi50:
|
||||
```yaml
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: node-role.kubernetes.io/control-plane
|
||||
operator: DoesNotExist
|
||||
```
|
||||
|
||||
### Option C: Combined Approach (Best)
|
||||
|
||||
1. Add `PreferNoSchedule` taint to pi50 (prevents future imbalance)
|
||||
2. Immediately move 2-3 heaviest moveable workloads to pi51
|
||||
3. Let remaining workloads naturally migrate over time
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: Add taint to pi50
|
||||
```bash
|
||||
kubectl taint nodes pi50 node-role.kubernetes.io/control-plane=:PreferNoSchedule
|
||||
```
|
||||
|
||||
### Step 2: Verify existing workloads still running
|
||||
```bash
|
||||
kubectl get pods -A -o wide --field-selector spec.nodeName=pi50 | grep -v Running
|
||||
```
|
||||
|
||||
### Step 3: Move heavy ai-stack workloads (optional, for immediate relief)
|
||||
|
||||
For each deployment to move, patch with node anti-affinity or selector:
|
||||
```bash
|
||||
kubectl patch deployment -n ai-stack ollama --type=merge -p '{"spec":{"template":{"spec":{"nodeSelector":{"kubernetes.io/hostname":"pi51"}}}}}'
|
||||
```
|
||||
|
||||
Or delete pods to trigger rescheduling (if PreferNoSchedule taint is set):
|
||||
```bash
|
||||
kubectl delete pod -n ai-stack <pod-name>
|
||||
```
|
||||
|
||||
### Step 4: Monitor
|
||||
```bash
|
||||
kubectl top nodes
|
||||
```
|
||||
|
||||
## Workloads That MUST Stay on pi50
|
||||
|
||||
- `kube-system/*` - Core cluster components
|
||||
- `longhorn-system/csi-*` - Storage controllers
|
||||
- `longhorn-system/longhorn-driver-deployer` - Storage management
|
||||
- `local-path-storage/*` - Local storage provisioner
|
||||
|
||||
## Expected Outcome
|
||||
|
||||
After changes:
|
||||
- pi50: ~50-60% CPU, ~65-70% memory (control plane + essential services)
|
||||
- pi51: ~40-50% CPU, ~70-75% memory (absorbs application workloads)
|
||||
- New pods prefer pi51 automatically
|
||||
|
||||
## Risks
|
||||
|
||||
- **Low**: PreferNoSchedule is a soft taint - pods with tolerations can still schedule on pi50
|
||||
- **Low**: Moving workloads may cause brief service interruption during pod recreation
|
||||
- **Note**: pi3 cannot absorb much due to 800MB RAM limit
|
||||
|
||||
## Selected Approach: A + B (Combined)
|
||||
|
||||
User selected combined approach:
|
||||
1. Add `PreferNoSchedule` taint to pi50
|
||||
2. Move heavy ai-stack workloads to pi51 immediately
|
||||
|
||||
## Execution Plan
|
||||
|
||||
### Phase 1: Add Taint
|
||||
```bash
|
||||
kubectl taint nodes pi50 node-role.kubernetes.io/control-plane=:PreferNoSchedule
|
||||
```
|
||||
|
||||
### Phase 2: Move Heavy Workloads to pi51
|
||||
|
||||
Target workloads (heaviest on pi50):
|
||||
- `ai-stack/ollama`
|
||||
- `ai-stack/open-webui`
|
||||
- `ai-stack/litellm`
|
||||
- `ai-stack/claude-code`
|
||||
- `ai-stack/codex`
|
||||
- `ai-stack/gemini-cli`
|
||||
- `ai-stack/opencode`
|
||||
- `ai-stack/searxng`
|
||||
- `minio/minio`
|
||||
|
||||
Method: Delete pods to trigger rescheduling (taint will push them to pi51):
|
||||
```bash
|
||||
kubectl delete pod -n ai-stack -l app.kubernetes.io/name=ollama
|
||||
# etc for each workload
|
||||
```
|
||||
|
||||
### Phase 3: Verify
|
||||
```bash
|
||||
kubectl top nodes
|
||||
kubectl get pods -A -o wide | grep -E "ollama|open-webui|litellm"
|
||||
```
|
||||
@@ -4,10 +4,10 @@
|
||||
"frontend-design@claude-plugins-official": [
|
||||
{
|
||||
"scope": "user",
|
||||
"installPath": "/home/will/.claude/plugins/cache/claude-plugins-official/frontend-design/6d3752c000e2",
|
||||
"version": "6d3752c000e2",
|
||||
"installPath": "/home/will/.claude/plugins/cache/claude-plugins-official/frontend-design/b97f6eadd929",
|
||||
"version": "b97f6eadd929",
|
||||
"installedAt": "2025-12-24T19:08:12.422Z",
|
||||
"lastUpdated": "2025-12-24T19:08:12.422Z",
|
||||
"lastUpdated": "2026-01-07T08:00:06.726Z",
|
||||
"gitCommitSha": "6d3752c000e2b3d0e6137bd7adb04895d6f40f14",
|
||||
"isLocal": true
|
||||
}
|
||||
@@ -26,10 +26,10 @@
|
||||
"commit-commands@claude-plugins-official": [
|
||||
{
|
||||
"scope": "user",
|
||||
"installPath": "/home/will/.claude/plugins/cache/claude-plugins-official/commit-commands/6d3752c000e2",
|
||||
"version": "6d3752c000e2",
|
||||
"installPath": "/home/will/.claude/plugins/cache/claude-plugins-official/commit-commands/b97f6eadd929",
|
||||
"version": "b97f6eadd929",
|
||||
"installedAt": "2025-12-24T19:10:05.451Z",
|
||||
"lastUpdated": "2025-12-24T19:10:36.843Z",
|
||||
"lastUpdated": "2026-01-07T08:00:06.734Z",
|
||||
"isLocal": true
|
||||
}
|
||||
],
|
||||
@@ -69,10 +69,10 @@
|
||||
"ralph-wiggum@claude-plugins-official": [
|
||||
{
|
||||
"scope": "user",
|
||||
"installPath": "/home/will/.claude/plugins/cache/claude-plugins-official/ralph-wiggum/6d3752c000e2",
|
||||
"version": "6d3752c000e2",
|
||||
"installPath": "/home/will/.claude/plugins/cache/claude-plugins-official/ralph-wiggum/883f2ba69e50",
|
||||
"version": "883f2ba69e50",
|
||||
"installedAt": "2026-01-02T19:47:02.395Z",
|
||||
"lastUpdated": "2026-01-02T19:47:11.472Z",
|
||||
"lastUpdated": "2026-01-06T20:00:15.709Z",
|
||||
"gitCommitSha": "de89f3066c68d7a2f2d4190173fa46c26e2f30fd",
|
||||
"isLocal": true
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"repo": "anthropics/claude-plugins-official"
|
||||
},
|
||||
"installLocation": "/home/will/.claude/plugins/marketplaces/claude-plugins-official",
|
||||
"lastUpdated": "2026-01-03T20:00:09.379Z"
|
||||
"lastUpdated": "2026-01-07T19:06:34.488Z"
|
||||
},
|
||||
"superpowers-marketplace": {
|
||||
"source": {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Morning Report - Sun Jan 04, 2026
|
||||
|
||||
## 🌤 Weather
|
||||
Seattle: 51°F, Partly cloudy | High 52° Low 43°
|
||||
|
||||
## 📧 Email
|
||||
15 unread
|
||||
• Capital One | Quicks - Your requested balance summary
|
||||
• Uber Receipts - [Personal] Your Saturday evening trip wi
|
||||
• Experian - William, it's time to check your utiliza
|
||||
• Experteer Search Age - William, we have 2 new opportunities for
|
||||
• Chase - You can start your mortgage preapproval
|
||||
|
||||
## 📅 Today
|
||||
• 2:00 PM - Seattle Saturday (SAM + QED + Lecosho) (5h)
|
||||
|
||||
## 📈 Stocks
|
||||
CRWV $79.32 +10.8% ▲ NVDA $188.85 +1.3% ▲ MSFT $472.94 -2.2% ▼
|
||||
|
||||
## 🖥 Infrastructure
|
||||
K8s: 🟢 | Workstation: 🟢
|
||||
|
||||
## 📰 Tech News
|
||||
• C-Sentinel: System prober that captures "system fingerprints... (Hacker News)
|
||||
• Show HN: An LLM-Powered PCB Schematic Checker (Major Update) (Hacker News)
|
||||
• Can I finally start using Wayland in 2026? (Lobsters)
|
||||
• Saying goodbye to the servers at our physical datacenter (Lobsters)
|
||||
|
||||
---
|
||||
*Generated: 2026-01-04 14:40:48 PT*
|
||||
@@ -0,0 +1,27 @@
|
||||
# Morning Report - Mon Jan 05, 2026
|
||||
|
||||
## 🌤 Weather
|
||||
Overcast, 44°F (feels 41°F), rain likely—bring umbrella ☔
|
||||
|
||||
## 📧 Email
|
||||
⚠️ Could not fetch emails: No module named 'pydantic_core._pydantic_core'
|
||||
|
||||
## 📅 Today
|
||||
⚠️ Could not fetch calendar: No module named 'pydantic_core._pydantic_core'
|
||||
|
||||
## 📈 Stocks
|
||||
CRWV $77.64 ▼2.1% | NVDA $187.84 ▼0.5% | MSFT $473.50 ▲0.1%
|
||||
|
||||
## 🖥 Infrastructure
|
||||
K8s: 🟡 | Workstation: 🟢
|
||||
└ K8s: 2 pods not running
|
||||
|
||||
## 📰 Tech News
|
||||
• O-Ring Automation (Hacker News)
|
||||
• Novo Nordisk launches Wegovy weight-loss pill in US, triggering price war (Hacker News)
|
||||
• Refactoring – Not on the backlog (Hacker News)
|
||||
• It's hard to justify Tahoe icons (Lobsters)
|
||||
• Databases in 2025: A Year in Review (Lobsters)
|
||||
|
||||
---
|
||||
*Generated: 2026-01-05 12:44:47 PT*
|
||||
@@ -0,0 +1,31 @@
|
||||
# Morning Report - Tue Jan 06, 2026
|
||||
|
||||
## 🌤 Weather
|
||||
Seattle: 43°F, Light rain, mist | High 45° Low 38°
|
||||
|
||||
## 📧 Email
|
||||
15 unread
|
||||
• Capital One | Quicks - Your requested balance summary
|
||||
• Chase - Your Chase Freedom Unlimited Visa balanc
|
||||
• Experian - William, check out these cards with an i
|
||||
• Delta Air Lines - Discover An Experience Curated For Membe
|
||||
• DoorDash - Save up to $10 on groceries and more eac
|
||||
|
||||
## 📅 Today
|
||||
No events today
|
||||
|
||||
## 📈 Stocks
|
||||
CRWV $77.94 +1.4% ▲ NVDA $187.24 -0.5% ▼ MSFT $478.51 +1.2% ▲
|
||||
|
||||
## 🖥 Infrastructure
|
||||
K8s: 🟡 | Workstation: 🟢
|
||||
└ K8s: 2 pods not running
|
||||
|
||||
## 📰 Tech News
|
||||
• Comparing AI agents to cybersecurity professionals in real-w... (Hacker News)
|
||||
• Oral microbiome sequencing after taking probiotics (Hacker News)
|
||||
• The Best Line Length is 88 (Lobsters)
|
||||
• There Were BGP Anomalies During The Venezuela Blackout (Lobsters)
|
||||
|
||||
---
|
||||
*Generated: 2026-01-06 13:40:51 PT*
|
||||
@@ -0,0 +1,42 @@
|
||||
# Morning Report - Wed Jan 07, 2026
|
||||
|
||||
## 🌤 Weather
|
||||
Overcast 40°F (feels 33°F), 86% humidity, rain possible — bring umbrella
|
||||
|
||||
## 📧 Email
|
||||
10 unread, no urgent items
|
||||
|
||||
• Experian Alerts - Your FICO® Score went up. Nice work!
|
||||
• Experteer - 3 new opportunities for "AWS Architect"
|
||||
• Experian - December spending report is here
|
||||
• Chase - Freedom Unlimited balance is $538.97
|
||||
• Chase - Rewards balance has reached 0 POINTS
|
||||
|
||||
## 📅 Today
|
||||
No events today
|
||||
|
||||
## 📈 Stocks
|
||||
▲ CRWV $78.98 (+1.3%) | ▲ NVDA $189.74 (+1.3%) | ▲ MSFT $488.96 (+2.2%)
|
||||
|
||||
## ✅ Tasks
|
||||
6 pending
|
||||
• 5:00 PM - Dinner at Lecosho or Japonessa
|
||||
• 3:00 PM - Snack at Le Panier or Mee Sum
|
||||
• 3:15 PM - Seattle Art Museum (Impressionism Exhibit)
|
||||
• 2:30 PM - Route 7 Bus to Downtown
|
||||
• 2:00 PM - Coffee at QED (Mt Baker)
|
||||
... and 1 more
|
||||
|
||||
## 🖥 Infrastructure
|
||||
K8s: 🟡 | Workstation: 🟢
|
||||
└ K8s: 1 pods not running
|
||||
|
||||
## 📰 Tech News
|
||||
• Eat Real Food – Introducing the New Pyramid (Hacker News)
|
||||
• The $14 Burrito: Why San Francisco Inflation Feels Higher Than 2.5% (Hacker News)
|
||||
• Health care data breach affects over 600k patients, Illinois agency says (Hacker News)
|
||||
• Why the trans flag emoji is the 5-codepoint sequence it is (Lobsters)
|
||||
• A4 Paper Stories (Lobsters)
|
||||
|
||||
---
|
||||
*Generated: 2026-01-07 09:58:25 PT*
|
||||
+25
-18
@@ -1,35 +1,42 @@
|
||||
# Morning Report - Sat Jan 03, 2026
|
||||
# Morning Report - Wed Jan 07, 2026
|
||||
|
||||
## 🌤 Weather
|
||||
Weather unavailable: <urlopen error _ssl.c:1063: The handshake operation timed out>
|
||||
Overcast 40°F (feels 33°F), 86% humidity, rain possible — bring umbrella
|
||||
|
||||
## 📧 Email
|
||||
10 unread, 2 attention-worthy
|
||||
10 unread, no urgent items
|
||||
|
||||
- [!] Google - Help strengthen security of your Account
|
||||
- [!] coreweave@myworkday.com - Security Alert: Signon from New Device (2x)
|
||||
- E*TRADE - Your Statement Is Now Available
|
||||
- Capital One - Your requested balance summary
|
||||
- Mindful Support Services - Your statement is now available
|
||||
• Experian Alerts - Your FICO® Score went up. Nice work!
|
||||
• Experteer - 3 new opportunities for "AWS Architect"
|
||||
• Experian - December spending report is here
|
||||
• Chase - Freedom Unlimited balance is $538.97
|
||||
• Chase - Rewards balance has reached 0 POINTS
|
||||
|
||||
## 📅 Today
|
||||
• 2:00 PM - Seattle Saturday (SAM + QED + Lecosho) (5h)
|
||||
Tomorrow: 1 event, first at 2:00 PM
|
||||
No events today
|
||||
|
||||
## 📈 Stocks
|
||||
CRWV $79.32 +10.8% ▲ NVDA $188.85 +1.3% ▲ MSFT $472.94 -2.2% ▼
|
||||
▲ CRWV $78.98 (+1.3%) | ▲ NVDA $189.74 (+1.3%) | ▲ MSFT $488.96 (+2.2%)
|
||||
|
||||
## ✅ Tasks
|
||||
⚠️ Could not fetch tasks: ('invalid_scope: Bad Request', {'error': 'invalid_scope', 'error_description': 'Bad Request'})
|
||||
6 pending
|
||||
• 5:00 PM - Dinner at Lecosho or Japonessa
|
||||
• 3:00 PM - Snack at Le Panier or Mee Sum
|
||||
• 3:15 PM - Seattle Art Museum (Impressionism Exhibit)
|
||||
• 2:30 PM - Route 7 Bus to Downtown
|
||||
• 2:00 PM - Coffee at QED (Mt Baker)
|
||||
... and 1 more
|
||||
|
||||
## 🖥 Infrastructure
|
||||
K8s: 🟢 | Workstation: 🟢
|
||||
K8s: 🟡 | Workstation: 🟢
|
||||
└ K8s: 1 pods not running
|
||||
|
||||
## 📰 Tech News
|
||||
• ParadeDB (YC S23) Is Hiring Database Engineers (Hacker News)
|
||||
• X-Clacks-Overhead (Hacker News)
|
||||
• I'm brave enough to say it: Linux is good now, and if you wa... (Lobsters)
|
||||
• Who's Hiring? Q1 2026 (Lobsters)
|
||||
• Eat Real Food – Introducing the New Pyramid (Hacker News)
|
||||
• The $14 Burrito: Why San Francisco Inflation Feels Higher Than 2.5% (Hacker News)
|
||||
• Health care data breach affects over 600k patients, Illinois agency says (Hacker News)
|
||||
• Why the trans flag emoji is the 5-codepoint sequence it is (Lobsters)
|
||||
• A4 Paper Stories (Lobsters)
|
||||
|
||||
---
|
||||
*Generated: 2026-01-03 08:00:20 PT*
|
||||
*Generated: 2026-01-07 09:58:25 PT*
|
||||
+2
-1
@@ -13,5 +13,6 @@
|
||||
"ralph-wiggum@claude-plugins-official": true
|
||||
},
|
||||
"alwaysThinkingEnabled": true,
|
||||
"_note": "Agent definitions moved to ~/.claude/agents/*.md with YAML frontmatter. Autonomy levels now in ~/.claude/state/autonomy-levels.json"
|
||||
"_note": "Agent definitions moved to ~/.claude/agents/*.md with YAML frontmatter. Autonomy levels now in ~/.claude/state/autonomy-levels.json",
|
||||
"model": "opus"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ Agent skills that extend Claude's capabilities. Model-invoked (Claude decides wh
|
||||
| `sysadmin-health` | Arch Linux health check | `health-check.sh` |
|
||||
| `usage` | Session usage tracking | `usage_report.py` |
|
||||
| `programmer-add-project` | Register projects | (workflow only) |
|
||||
| `rag-search` | Semantic search (state + docs) | `search.py`, `index_personal.py`, `index_docs.py` |
|
||||
|
||||
## Skill Structure
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: gtasks
|
||||
description: Google Tasks read access — list pending tasks. Use when asked about tasks, todos, or what needs to be done.
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
---
|
||||
|
||||
# Google Tasks Skill
|
||||
|
||||
List pending Google Tasks. Uses OAuth credentials at `~/.gmail-mcp/`.
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
GMAIL_PY=~/.claude/mcp/gmail/venv/bin/python
|
||||
SCRIPTS=~/.claude/skills/gtasks/scripts
|
||||
|
||||
# List tasks (default 10)
|
||||
$GMAIL_PY $SCRIPTS/list.py
|
||||
|
||||
# Show more tasks
|
||||
$GMAIL_PY $SCRIPTS/list.py 20
|
||||
```
|
||||
|
||||
## Script Reference
|
||||
|
||||
| Script | Purpose | Args |
|
||||
|--------|---------|------|
|
||||
| `list.py` | List pending tasks | `[max]` (default 10) |
|
||||
|
||||
## Request Routing
|
||||
|
||||
| User Request | Script |
|
||||
|--------------|--------|
|
||||
| "What are my tasks?" | `list.py` |
|
||||
| "Show my todos" | `list.py` |
|
||||
| "/tasks" | `list.py` |
|
||||
|
||||
## Output Format
|
||||
|
||||
```
|
||||
6 pending
|
||||
• 5:00 PM - Dinner at Lecosho
|
||||
• 3:00 PM - Snack at Le Panier
|
||||
• 2:00 PM - Coffee at QED
|
||||
```
|
||||
|
||||
## Policy
|
||||
|
||||
- **Read-only** operations only
|
||||
- **Summarize** results, don't dump raw data
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""List Google Tasks - thin wrapper around morning-report collector."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Import from morning-report collector
|
||||
sys.path.insert(0, str(Path.home() / ".claude/skills/morning-report/scripts/collectors"))
|
||||
|
||||
from gtasks import fetch_tasks, format_tasks
|
||||
|
||||
|
||||
def main():
|
||||
max_display = int(sys.argv[1]) if len(sys.argv) > 1 else 10
|
||||
tasks = fetch_tasks(max_results=max_display + 5)
|
||||
|
||||
if not tasks:
|
||||
print("No pending tasks.")
|
||||
return
|
||||
|
||||
# Check for error response
|
||||
if len(tasks) == 1 and "error" in tasks[0]:
|
||||
print(f"Error: {tasks[0]['error']}")
|
||||
return
|
||||
|
||||
print(format_tasks(tasks, max_display))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -25,6 +25,7 @@
|
||||
"show_tomorrow": true
|
||||
},
|
||||
"tasks": {
|
||||
"enabled": true,
|
||||
"max_display": 5,
|
||||
"show_due_dates": true
|
||||
},
|
||||
|
||||
@@ -9,11 +9,13 @@ from pathlib import Path
|
||||
|
||||
def fetch_events(mode: str = "today") -> list:
|
||||
"""Fetch calendar events directly using gmail_mcp library."""
|
||||
os.environ.setdefault('GMAIL_CREDENTIALS_PATH', os.path.expanduser('~/.gmail-mcp/credentials.json'))
|
||||
os.environ.setdefault(
|
||||
"GMAIL_CREDENTIALS_PATH", os.path.expanduser("~/.gmail-mcp/credentials.json")
|
||||
)
|
||||
|
||||
try:
|
||||
# Add gmail venv to path
|
||||
venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.13/site-packages"
|
||||
venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.14/site-packages"
|
||||
if str(venv_site) not in sys.path:
|
||||
sys.path.insert(0, str(venv_site))
|
||||
|
||||
@@ -22,26 +24,32 @@ def fetch_events(mode: str = "today") -> list:
|
||||
service = get_calendar_service()
|
||||
now = datetime.utcnow()
|
||||
|
||||
if mode == 'today':
|
||||
if mode == "today":
|
||||
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = start + timedelta(days=1)
|
||||
elif mode == 'tomorrow':
|
||||
start = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
elif mode == "tomorrow":
|
||||
start = (now + timedelta(days=1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
end = start + timedelta(days=1)
|
||||
else:
|
||||
start = now
|
||||
end = now + timedelta(days=7)
|
||||
|
||||
events_result = service.events().list(
|
||||
calendarId='primary',
|
||||
timeMin=start.isoformat() + 'Z',
|
||||
timeMax=end.isoformat() + 'Z',
|
||||
singleEvents=True,
|
||||
orderBy='startTime',
|
||||
maxResults=20
|
||||
).execute()
|
||||
events_result = (
|
||||
service.events()
|
||||
.list(
|
||||
calendarId="primary",
|
||||
timeMin=start.isoformat() + "Z",
|
||||
timeMax=end.isoformat() + "Z",
|
||||
singleEvents=True,
|
||||
orderBy="startTime",
|
||||
maxResults=20,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
return events_result.get('items', [])
|
||||
return events_result.get("items", [])
|
||||
|
||||
except Exception as e:
|
||||
return [{"error": str(e)}]
|
||||
@@ -62,7 +70,9 @@ def format_events(today_events: list, tomorrow_events: list = None) -> str:
|
||||
|
||||
if "dateTime" in start:
|
||||
# Timed event
|
||||
dt = datetime.fromisoformat(start["dateTime"].replace("Z", "+00:00"))
|
||||
dt = datetime.fromisoformat(
|
||||
start["dateTime"].replace("Z", "+00:00")
|
||||
)
|
||||
time_str = dt.strftime("%I:%M %p").lstrip("0")
|
||||
elif "date" in start:
|
||||
time_str = "All day"
|
||||
@@ -73,13 +83,19 @@ def format_events(today_events: list, tomorrow_events: list = None) -> str:
|
||||
# Calculate duration if end time available
|
||||
end = event.get("end", {})
|
||||
if "dateTime" in start and "dateTime" in end:
|
||||
start_dt = datetime.fromisoformat(start["dateTime"].replace("Z", "+00:00"))
|
||||
end_dt = datetime.fromisoformat(end["dateTime"].replace("Z", "+00:00"))
|
||||
start_dt = datetime.fromisoformat(
|
||||
start["dateTime"].replace("Z", "+00:00")
|
||||
)
|
||||
end_dt = datetime.fromisoformat(
|
||||
end["dateTime"].replace("Z", "+00:00")
|
||||
)
|
||||
mins = int((end_dt - start_dt).total_seconds() / 60)
|
||||
if mins >= 60:
|
||||
hours = mins // 60
|
||||
remaining = mins % 60
|
||||
duration = f" ({hours}h{remaining}m)" if remaining else f" ({hours}h)"
|
||||
duration = (
|
||||
f" ({hours}h{remaining}m)" if remaining else f" ({hours}h)"
|
||||
)
|
||||
else:
|
||||
duration = f" ({mins}m)"
|
||||
|
||||
@@ -92,17 +108,23 @@ def format_events(today_events: list, tomorrow_events: list = None) -> str:
|
||||
|
||||
# Tomorrow preview
|
||||
if tomorrow_events is not None:
|
||||
if tomorrow_events and (len(tomorrow_events) == 0 or "error" not in tomorrow_events[0]):
|
||||
if tomorrow_events and (
|
||||
len(tomorrow_events) == 0 or "error" not in tomorrow_events[0]
|
||||
):
|
||||
count = len(tomorrow_events)
|
||||
if count > 0:
|
||||
first = tomorrow_events[0]
|
||||
start = first.get("start", {})
|
||||
if "dateTime" in start:
|
||||
dt = datetime.fromisoformat(start["dateTime"].replace("Z", "+00:00"))
|
||||
dt = datetime.fromisoformat(
|
||||
start["dateTime"].replace("Z", "+00:00")
|
||||
)
|
||||
first_time = dt.strftime("%I:%M %p").lstrip("0")
|
||||
else:
|
||||
first_time = "All day"
|
||||
lines.append(f"Tomorrow: {count} event{'s' if count > 1 else ''}, first at {first_time}")
|
||||
lines.append(
|
||||
f"Tomorrow: {count} event{'s' if count > 1 else ''}, first at {first_time}"
|
||||
)
|
||||
else:
|
||||
lines.append("Tomorrow: No events")
|
||||
|
||||
@@ -126,7 +148,7 @@ def collect(config: dict) -> dict:
|
||||
"icon": "📅",
|
||||
"content": formatted,
|
||||
"raw": {"today": today_events, "tomorrow": tomorrow_events},
|
||||
"error": today_events[0].get("error") if has_error else None
|
||||
"error": today_events[0].get("error") if has_error else None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,37 +11,49 @@ from pathlib import Path
|
||||
def fetch_unread_emails(days: int = 7, max_results: int = 15) -> list:
|
||||
"""Fetch unread emails directly using gmail_mcp library."""
|
||||
# Set credentials path
|
||||
os.environ.setdefault('GMAIL_CREDENTIALS_PATH', os.path.expanduser('~/.gmail-mcp/credentials.json'))
|
||||
os.environ.setdefault(
|
||||
"GMAIL_CREDENTIALS_PATH", os.path.expanduser("~/.gmail-mcp/credentials.json")
|
||||
)
|
||||
|
||||
try:
|
||||
# Add gmail venv to path
|
||||
venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.13/site-packages"
|
||||
venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.14/site-packages"
|
||||
if str(venv_site) not in sys.path:
|
||||
sys.path.insert(0, str(venv_site))
|
||||
|
||||
from gmail_mcp.utils.GCP.gmail_auth import get_gmail_service
|
||||
|
||||
service = get_gmail_service()
|
||||
results = service.users().messages().list(
|
||||
userId='me',
|
||||
q=f'is:unread newer_than:{days}d',
|
||||
maxResults=max_results
|
||||
).execute()
|
||||
results = (
|
||||
service.users()
|
||||
.messages()
|
||||
.list(
|
||||
userId="me", q=f"is:unread newer_than:{days}d", maxResults=max_results
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
emails = []
|
||||
for msg in results.get('messages', []):
|
||||
detail = service.users().messages().get(
|
||||
userId='me',
|
||||
id=msg['id'],
|
||||
format='metadata',
|
||||
metadataHeaders=['From', 'Subject']
|
||||
).execute()
|
||||
headers = {h['name']: h['value'] for h in detail['payload']['headers']}
|
||||
emails.append({
|
||||
'from': headers.get('From', 'Unknown'),
|
||||
'subject': headers.get('Subject', '(no subject)'),
|
||||
'id': msg['id']
|
||||
})
|
||||
for msg in results.get("messages", []):
|
||||
detail = (
|
||||
service.users()
|
||||
.messages()
|
||||
.get(
|
||||
userId="me",
|
||||
id=msg["id"],
|
||||
format="metadata",
|
||||
metadataHeaders=["From", "Subject"],
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
headers = {h["name"]: h["value"] for h in detail["payload"]["headers"]}
|
||||
emails.append(
|
||||
{
|
||||
"from": headers.get("From", "Unknown"),
|
||||
"subject": headers.get("Subject", "(no subject)"),
|
||||
"id": msg["id"],
|
||||
}
|
||||
)
|
||||
|
||||
return emails
|
||||
|
||||
@@ -79,10 +91,17 @@ Output the formatted email section, nothing else."""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["/home/will/.local/bin/claude", "--print", "--model", "sonnet", "-p", prompt],
|
||||
[
|
||||
"/home/will/.local/bin/claude",
|
||||
"--print",
|
||||
"--model",
|
||||
"sonnet",
|
||||
"-p",
|
||||
prompt,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
@@ -131,7 +150,7 @@ def collect(config: dict) -> dict:
|
||||
"content": formatted,
|
||||
"raw": emails if not has_error else None,
|
||||
"count": len(emails) if not has_error else 0,
|
||||
"error": emails[0].get("error") if has_error else None
|
||||
"error": emails[0].get("error") if has_error else None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Add gmail venv to path for Google API libraries
|
||||
venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.13/site-packages"
|
||||
venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.14/site-packages"
|
||||
if str(venv_site) not in sys.path:
|
||||
sys.path.insert(0, str(venv_site))
|
||||
|
||||
@@ -18,6 +18,7 @@ try:
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from google.auth.transport.requests import Request
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
GOOGLE_API_AVAILABLE = True
|
||||
except ImportError:
|
||||
GOOGLE_API_AVAILABLE = False
|
||||
@@ -28,17 +29,26 @@ TOKEN_PATH = Path.home() / ".gmail-mcp/tasks_token.json"
|
||||
CREDS_PATH = Path.home() / ".gmail-mcp/credentials.json"
|
||||
|
||||
|
||||
def get_credentials():
|
||||
"""Get or refresh Google credentials for Tasks API."""
|
||||
def get_credentials(force_reauth: bool = False):
|
||||
"""Get or refresh Google credentials for Tasks API.
|
||||
|
||||
If ``force_reauth`` is True, skip refresh and run a new OAuth flow.
|
||||
This is useful when a stored refresh token is bound to a different
|
||||
scope set and refresh keeps failing with invalid_scope.
|
||||
"""
|
||||
creds = None
|
||||
|
||||
if TOKEN_PATH.exists():
|
||||
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
|
||||
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
else:
|
||||
if not creds or not creds.valid or force_reauth:
|
||||
if not force_reauth and creds and creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
except Exception:
|
||||
creds = None
|
||||
|
||||
if not creds or not creds.valid:
|
||||
if not CREDS_PATH.exists():
|
||||
return None
|
||||
flow = InstalledAppFlow.from_client_secrets_file(str(CREDS_PATH), SCOPES)
|
||||
@@ -57,7 +67,11 @@ def fetch_tasks(max_results: int = 10) -> list:
|
||||
try:
|
||||
creds = get_credentials()
|
||||
if not creds:
|
||||
return [{"error": "Tasks API not authenticated - run: ~/.claude/mcp/gmail/venv/bin/python ~/.claude/skills/morning-report/scripts/collectors/gtasks.py --auth"}]
|
||||
return [
|
||||
{
|
||||
"error": "Tasks API not authenticated - run: ~/.claude/mcp/gmail/venv/bin/python ~/.claude/skills/morning-report/scripts/collectors/gtasks.py --auth"
|
||||
}
|
||||
]
|
||||
|
||||
service = build("tasks", "v1", credentials=creds)
|
||||
|
||||
@@ -69,12 +83,16 @@ def fetch_tasks(max_results: int = 10) -> list:
|
||||
tasklist_id = tasklists["items"][0]["id"]
|
||||
|
||||
# Get tasks
|
||||
results = service.tasks().list(
|
||||
tasklist=tasklist_id,
|
||||
maxResults=max_results,
|
||||
showCompleted=False,
|
||||
showHidden=False
|
||||
).execute()
|
||||
results = (
|
||||
service.tasks()
|
||||
.list(
|
||||
tasklist=tasklist_id,
|
||||
maxResults=max_results,
|
||||
showCompleted=False,
|
||||
showHidden=False,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
tasks = results.get("items", [])
|
||||
return tasks
|
||||
@@ -150,7 +168,7 @@ def collect(config: dict) -> dict:
|
||||
"content": formatted,
|
||||
"raw": tasks if not has_error else None,
|
||||
"count": len(tasks) if not has_error else 0,
|
||||
"error": tasks[0].get("error") if has_error else None
|
||||
"error": tasks[0].get("error") if has_error else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +177,9 @@ if __name__ == "__main__":
|
||||
|
||||
if "--auth" in sys.argv:
|
||||
print("Starting Tasks API authentication...")
|
||||
creds = get_credentials()
|
||||
# Force a fresh OAuth flow so we can recover from invalid_scope
|
||||
# errors caused by stale refresh tokens.
|
||||
creds = get_credentials(force_reauth=True)
|
||||
if creds:
|
||||
print(f"✅ Authentication successful! Token saved to {TOKEN_PATH}")
|
||||
else:
|
||||
|
||||
@@ -18,6 +18,7 @@ from collectors import weather, stocks, infra, news
|
||||
# These may fail if gmail venv not activated
|
||||
try:
|
||||
from collectors import gmail, gcal, gtasks
|
||||
|
||||
GOOGLE_COLLECTORS = True
|
||||
except ImportError:
|
||||
GOOGLE_COLLECTORS = False
|
||||
@@ -29,10 +30,7 @@ LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_PATH),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
handlers=[logging.FileHandler(LOG_PATH), logging.StreamHandler()],
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,10 +56,17 @@ def collect_section(name: str, collector_func, config: dict) -> dict:
|
||||
"section": name,
|
||||
"icon": "❓",
|
||||
"content": f"⚠️ {name} unavailable: {e}",
|
||||
"error": str(e)
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
def is_section_enabled(name: str, config: dict) -> bool:
|
||||
"""Check if a section is enabled in config."""
|
||||
section_key = name.lower()
|
||||
section_config = config.get(section_key, {})
|
||||
return section_config.get("enabled", True)
|
||||
|
||||
|
||||
def collect_all(config: dict) -> list:
|
||||
"""Collect all sections in parallel."""
|
||||
collectors = [
|
||||
@@ -72,11 +77,12 @@ def collect_all(config: dict) -> list:
|
||||
]
|
||||
|
||||
if GOOGLE_COLLECTORS:
|
||||
collectors.extend([
|
||||
("Email", gmail.collect),
|
||||
("Calendar", gcal.collect),
|
||||
("Tasks", gtasks.collect),
|
||||
])
|
||||
if is_section_enabled("email", config):
|
||||
collectors.append(("Email", gmail.collect))
|
||||
if is_section_enabled("calendar", config):
|
||||
collectors.append(("Calendar", gcal.collect))
|
||||
if is_section_enabled("tasks", config):
|
||||
collectors.append(("Tasks", gtasks.collect))
|
||||
else:
|
||||
logger.warning("Google collectors not available - run with gmail venv")
|
||||
|
||||
@@ -95,12 +101,14 @@ def collect_all(config: dict) -> list:
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Future {name} exception: {e}")
|
||||
results.append({
|
||||
"section": name,
|
||||
"icon": "❓",
|
||||
"content": f"⚠️ {name} failed: {e}",
|
||||
"error": str(e)
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"section": name,
|
||||
"icon": "❓",
|
||||
"content": f"⚠️ {name} failed: {e}",
|
||||
"error": str(e),
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -111,13 +119,21 @@ def render_report(sections: list, config: dict) -> str:
|
||||
date_str = now.strftime("%a %b %d, %Y")
|
||||
time_str = now.strftime("%I:%M %p %Z").strip()
|
||||
|
||||
lines = [
|
||||
f"# Morning Report - {date_str}",
|
||||
""
|
||||
]
|
||||
lines = [f"# Morning Report - {date_str}", ""]
|
||||
|
||||
# Order sections
|
||||
order = ["Weather", "Email", "Calendar", "Today", "Stocks", "Tasks", "Infra", "Infrastructure", "News", "Tech News"]
|
||||
order = [
|
||||
"Weather",
|
||||
"Email",
|
||||
"Calendar",
|
||||
"Today",
|
||||
"Stocks",
|
||||
"Tasks",
|
||||
"Infra",
|
||||
"Infrastructure",
|
||||
"News",
|
||||
"Tech News",
|
||||
]
|
||||
|
||||
# Sort by order
|
||||
section_map = {s.get("section", ""): s for s in sections}
|
||||
@@ -137,10 +153,7 @@ def render_report(sections: list, config: dict) -> str:
|
||||
lines.append("")
|
||||
|
||||
# Footer
|
||||
lines.extend([
|
||||
"---",
|
||||
f"*Generated: {now.strftime('%Y-%m-%d %H:%M:%S')} PT*"
|
||||
])
|
||||
lines.extend(["---", f"*Generated: {now.strftime('%Y-%m-%d %H:%M:%S')} PT*"])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -148,7 +161,9 @@ def render_report(sections: list, config: dict) -> str:
|
||||
def save_report(content: str, config: dict) -> Path:
|
||||
"""Save report to file and archive."""
|
||||
output_config = config.get("output", {})
|
||||
output_path = Path(output_config.get("path", "~/.claude/reports/morning.md")).expanduser()
|
||||
output_path = Path(
|
||||
output_config.get("path", "~/.claude/reports/morning.md")
|
||||
).expanduser()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write main report
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: rag-search
|
||||
description: Semantic search across personal state files and external documentation
|
||||
triggers: [search, find, lookup, what did, how did, when did, past decisions, previous, documentation, docs]
|
||||
---
|
||||
|
||||
# RAG Search Skill
|
||||
|
||||
Semantic search across two indexes:
|
||||
- **personal**: Your state files, memory, decisions, preferences
|
||||
- **docs**: External documentation (k0s, ArgoCD, etc.)
|
||||
|
||||
## When to Use
|
||||
|
||||
- "What decisions did I make about X?"
|
||||
- "How did I configure Y?"
|
||||
- "What does the k0s documentation say about Z?"
|
||||
- "Find my past notes on..."
|
||||
- Cross-referencing personal context with official docs
|
||||
|
||||
## Scripts
|
||||
|
||||
All scripts use the venv at `~/.claude/skills/rag-search/venv/`.
|
||||
|
||||
### Search (Primary Interface)
|
||||
|
||||
```bash
|
||||
# Search both indexes
|
||||
~/.claude/skills/rag-search/venv/bin/python \
|
||||
~/.claude/skills/rag-search/scripts/search.py "query"
|
||||
|
||||
# Search specific index
|
||||
~/.claude/skills/rag-search/scripts/search.py --index personal "query"
|
||||
~/.claude/skills/rag-search/scripts/search.py --index docs "query"
|
||||
|
||||
# Control result count
|
||||
~/.claude/skills/rag-search/scripts/search.py --top-k 10 "query"
|
||||
```
|
||||
|
||||
### Index Management
|
||||
|
||||
```bash
|
||||
# Reindex personal state files
|
||||
~/.claude/skills/rag-search/venv/bin/python \
|
||||
~/.claude/skills/rag-search/scripts/index_personal.py
|
||||
|
||||
# Index all doc sources
|
||||
~/.claude/skills/rag-search/venv/bin/python \
|
||||
~/.claude/skills/rag-search/scripts/index_docs.py --all
|
||||
|
||||
# Index specific doc source
|
||||
~/.claude/skills/rag-search/scripts/index_docs.py --source k0s
|
||||
```
|
||||
|
||||
### Adding Doc Sources
|
||||
|
||||
```bash
|
||||
# Add a git-based doc source
|
||||
~/.claude/skills/rag-search/venv/bin/python \
|
||||
~/.claude/skills/rag-search/scripts/add_doc_source.py \
|
||||
--id "argocd" \
|
||||
--name "ArgoCD Documentation" \
|
||||
--type git \
|
||||
--url "https://github.com/argoproj/argo-cd.git" \
|
||||
--path "docs/" \
|
||||
--glob "**/*.md"
|
||||
|
||||
# List configured sources
|
||||
~/.claude/skills/rag-search/scripts/add_doc_source.py --list
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
Search returns JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "your search query",
|
||||
"results": [
|
||||
{
|
||||
"rank": 1,
|
||||
"score": 0.847,
|
||||
"source": "personal",
|
||||
"file": "memory/decisions.json",
|
||||
"chunk": "Relevant text content...",
|
||||
"metadata": {"date": "2025-01-15"}
|
||||
}
|
||||
],
|
||||
"searched_collections": ["personal", "docs"],
|
||||
"total_chunks_searched": 1847
|
||||
}
|
||||
```
|
||||
|
||||
## Search Strategy
|
||||
|
||||
1. **Start broad** - Use general terms first
|
||||
2. **Refine if needed** - Add specific keywords if results aren't relevant
|
||||
3. **Cross-reference** - When both personal and docs results appear, synthesize them
|
||||
4. **Cite sources** - Include file paths and dates in your answers
|
||||
|
||||
## Example Workflow
|
||||
|
||||
User asks: "How should I configure ArgoCD sync?"
|
||||
|
||||
1. Search both indexes:
|
||||
```bash
|
||||
search.py "ArgoCD sync configuration"
|
||||
```
|
||||
|
||||
2. If personal results exist, prioritize those (user's past decisions)
|
||||
|
||||
3. Supplement with docs results for official guidance
|
||||
|
||||
4. Synthesize answer:
|
||||
> Based on your previous decision (decisions.json, 2025-01-15), you configured ArgoCD with auto-sync enabled but self-heal disabled. The ArgoCD docs recommend this for production environments where you want automatic deployment but manual intervention for drift correction.
|
||||
|
||||
## Maintenance
|
||||
|
||||
Indexes should be refreshed periodically:
|
||||
- Personal: After significant state changes
|
||||
- Docs: After tool version upgrades
|
||||
|
||||
A systemd timer can automate this (see design doc for setup).
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"id": "k0s",
|
||||
"name": "k0s Documentation",
|
||||
"type": "git",
|
||||
"url": "https://github.com/k0sproject/k0s.git",
|
||||
"path": "docs/",
|
||||
"glob": "**/*.md",
|
||||
"version": "main",
|
||||
"last_indexed": "2026-01-04T23:27:40.175671"
|
||||
},
|
||||
{
|
||||
"id": "argocd",
|
||||
"name": "ArgoCD Documentation",
|
||||
"type": "git",
|
||||
"url": "https://github.com/argoproj/argo-cd.git",
|
||||
"path": "docs/",
|
||||
"glob": "**/*.md",
|
||||
"last_indexed": "2026-01-05T01:04:53.930441"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+205
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RAG Search - Add Documentation Source
|
||||
|
||||
Adds a new documentation source to the registry.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Constants
|
||||
SKILL_DIR = Path(__file__).parent.parent
|
||||
SOURCES_FILE = SKILL_DIR / "references" / "sources.json"
|
||||
|
||||
|
||||
def load_sources() -> list[dict]:
|
||||
"""Load configured documentation sources."""
|
||||
if not SOURCES_FILE.exists():
|
||||
return []
|
||||
with open(SOURCES_FILE) as f:
|
||||
data = json.load(f)
|
||||
return data.get("sources", [])
|
||||
|
||||
|
||||
def save_sources(sources: list[dict]) -> None:
|
||||
"""Save documentation sources."""
|
||||
SOURCES_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(SOURCES_FILE, "w") as f:
|
||||
json.dump({"sources": sources}, f, indent=2)
|
||||
|
||||
|
||||
def add_source(
|
||||
source_id: str,
|
||||
name: str,
|
||||
source_type: str,
|
||||
url: str = None,
|
||||
path: str = None,
|
||||
glob: str = "**/*.md",
|
||||
version: str = None,
|
||||
base_url: str = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Add a new documentation source.
|
||||
|
||||
Args:
|
||||
source_id: Unique identifier for the source
|
||||
name: Human-readable name
|
||||
source_type: "git" or "local"
|
||||
url: Git repository URL (for git type)
|
||||
path: Path within repo or local path
|
||||
glob: File pattern to match
|
||||
version: Git tag/branch (for git type)
|
||||
base_url: Base URL for documentation links
|
||||
|
||||
Returns:
|
||||
The created source configuration
|
||||
"""
|
||||
sources = load_sources()
|
||||
|
||||
# Check for existing source
|
||||
existing = [s for s in sources if s["id"] == source_id]
|
||||
if existing:
|
||||
raise ValueError(f"Source already exists: {source_id}")
|
||||
|
||||
# Build source config
|
||||
source = {
|
||||
"id": source_id,
|
||||
"name": name,
|
||||
"type": source_type,
|
||||
}
|
||||
|
||||
if source_type == "git":
|
||||
if not url:
|
||||
raise ValueError("Git sources require --url")
|
||||
source["url"] = url
|
||||
if version:
|
||||
source["version"] = version
|
||||
elif source_type == "local":
|
||||
if not path:
|
||||
raise ValueError("Local sources require --path")
|
||||
source["path"] = str(Path(path).expanduser())
|
||||
else:
|
||||
raise ValueError(f"Unknown source type: {source_type}")
|
||||
|
||||
if path and source_type == "git":
|
||||
source["path"] = path
|
||||
source["glob"] = glob
|
||||
if base_url:
|
||||
source["base_url"] = base_url
|
||||
|
||||
sources.append(source)
|
||||
save_sources(sources)
|
||||
|
||||
return source
|
||||
|
||||
|
||||
def remove_source(source_id: str) -> bool:
|
||||
"""Remove a documentation source."""
|
||||
sources = load_sources()
|
||||
original_count = len(sources)
|
||||
sources = [s for s in sources if s["id"] != source_id]
|
||||
|
||||
if len(sources) == original_count:
|
||||
return False
|
||||
|
||||
save_sources(sources)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Add or manage documentation sources for RAG search",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Add k0s documentation from GitHub
|
||||
%(prog)s --id k0s --name "k0s Documentation" --type git \\
|
||||
--url "https://github.com/k0sproject/k0s.git" \\
|
||||
--path "docs/" --version "v1.30.0"
|
||||
|
||||
# Add local documentation directory
|
||||
%(prog)s --id internal --name "Internal Docs" --type local \\
|
||||
--path "~/docs/internal" --glob "**/*.md"
|
||||
|
||||
# Remove a source
|
||||
%(prog)s --remove k0s
|
||||
|
||||
# List sources
|
||||
%(prog)s --list
|
||||
"""
|
||||
)
|
||||
parser.add_argument("--id", help="Unique source identifier")
|
||||
parser.add_argument("--name", help="Human-readable name")
|
||||
parser.add_argument(
|
||||
"--type", "-t",
|
||||
choices=["git", "local"],
|
||||
default="git",
|
||||
help="Source type (default: git)"
|
||||
)
|
||||
parser.add_argument("--url", help="Git repository URL")
|
||||
parser.add_argument("--path", help="Path within repo or local directory")
|
||||
parser.add_argument(
|
||||
"--glob", "-g",
|
||||
default="**/*.md",
|
||||
help="File pattern to match (default: **/*.md)"
|
||||
)
|
||||
parser.add_argument("--version", "-v", help="Git tag or branch")
|
||||
parser.add_argument("--base-url", help="Base URL for documentation links")
|
||||
parser.add_argument(
|
||||
"--remove", "-r",
|
||||
metavar="ID",
|
||||
help="Remove a source by ID"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list", "-l",
|
||||
action="store_true",
|
||||
help="List configured sources"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
sources = load_sources()
|
||||
if sources:
|
||||
print(json.dumps(sources, indent=2))
|
||||
else:
|
||||
print("No documentation sources configured")
|
||||
return
|
||||
|
||||
if args.remove:
|
||||
if remove_source(args.remove):
|
||||
print(f"Removed source: {args.remove}")
|
||||
else:
|
||||
print(f"Source not found: {args.remove}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
# Adding a new source
|
||||
if not args.id or not args.name:
|
||||
parser.error("--id and --name are required when adding a source")
|
||||
|
||||
try:
|
||||
source = add_source(
|
||||
source_id=args.id,
|
||||
name=args.name,
|
||||
source_type=args.type,
|
||||
url=args.url,
|
||||
path=args.path,
|
||||
glob=args.glob,
|
||||
version=args.version,
|
||||
base_url=args.base_url,
|
||||
)
|
||||
print(f"Added source: {args.id}")
|
||||
print(json.dumps(source, indent=2))
|
||||
print(f"\nTo index this source, run:")
|
||||
print(f" index_docs.py --source {args.id}")
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+419
@@ -0,0 +1,419 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RAG Search - Documentation Index Builder
|
||||
|
||||
Indexes external documentation sources for semantic search.
|
||||
Supports git repos and local directories.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Generator, Optional
|
||||
|
||||
# Add venv site-packages to path
|
||||
VENV_PATH = Path(__file__).parent.parent / "venv" / "lib" / "python3.13" / "site-packages"
|
||||
if str(VENV_PATH) not in sys.path:
|
||||
sys.path.insert(0, str(VENV_PATH))
|
||||
|
||||
import chromadb
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
# Constants
|
||||
SKILL_DIR = Path(__file__).parent.parent
|
||||
SOURCES_FILE = SKILL_DIR / "references" / "sources.json"
|
||||
DATA_DIR = Path.home() / ".claude" / "data" / "rag-search"
|
||||
CHROMA_DIR = DATA_DIR / "chroma"
|
||||
DOCS_CACHE_DIR = DATA_DIR / "docs-cache"
|
||||
MODEL_NAME = "all-MiniLM-L6-v2"
|
||||
COLLECTION_NAME = "docs"
|
||||
|
||||
# Chunking parameters
|
||||
CHUNK_SIZE = 500 # Target tokens (roughly 4 chars per token)
|
||||
CHUNK_OVERLAP = 50
|
||||
|
||||
|
||||
def load_sources() -> list[dict]:
|
||||
"""Load configured documentation sources."""
|
||||
if not SOURCES_FILE.exists():
|
||||
return []
|
||||
with open(SOURCES_FILE) as f:
|
||||
data = json.load(f)
|
||||
return data.get("sources", [])
|
||||
|
||||
|
||||
def save_sources(sources: list[dict]) -> None:
|
||||
"""Save documentation sources."""
|
||||
SOURCES_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(SOURCES_FILE, "w") as f:
|
||||
json.dump({"sources": sources}, f, indent=2)
|
||||
|
||||
|
||||
def fetch_git_source(source: dict, quiet: bool = False) -> Optional[Path]:
|
||||
"""
|
||||
Clone or update a git repository.
|
||||
|
||||
Returns:
|
||||
Path to the docs directory within the repo
|
||||
"""
|
||||
source_id = source["id"]
|
||||
url = source["url"]
|
||||
version = source.get("version", "HEAD")
|
||||
doc_path = source.get("path", "")
|
||||
|
||||
cache_dir = DOCS_CACHE_DIR / source_id
|
||||
|
||||
if cache_dir.exists():
|
||||
# Update existing repo
|
||||
if not quiet:
|
||||
print(f" Updating {source_id}...")
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "fetch", "--all"],
|
||||
cwd=cache_dir,
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", version],
|
||||
cwd=cache_dir,
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "pull", "--ff-only"],
|
||||
cwd=cache_dir,
|
||||
capture_output=True,
|
||||
check=False # May fail on tags
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" Warning: Could not update {source_id}: {e}", file=sys.stderr)
|
||||
else:
|
||||
# Clone new repo
|
||||
if not quiet:
|
||||
print(f" Cloning {source_id}...")
|
||||
cache_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "clone", "--depth", "1", url, str(cache_dir)],
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
if version != "HEAD":
|
||||
subprocess.run(
|
||||
["git", "fetch", "--depth", "1", "origin", version],
|
||||
cwd=cache_dir,
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", version],
|
||||
cwd=cache_dir,
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" Error: Could not clone {source_id}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
docs_dir = cache_dir / doc_path if doc_path else cache_dir
|
||||
return docs_dir if docs_dir.exists() else None
|
||||
|
||||
|
||||
def chunk_markdown(content: str, file_path: str) -> Generator[tuple[str, dict], None, None]:
|
||||
"""
|
||||
Chunk markdown content for embedding.
|
||||
|
||||
Strategy:
|
||||
- Split by headers to preserve context
|
||||
- Chunk sections that are too long
|
||||
- Preserve header hierarchy in metadata
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
current_chunk = []
|
||||
current_headers = []
|
||||
chunk_start_line = 0
|
||||
|
||||
def emit_chunk() -> Optional[tuple[str, dict]]:
|
||||
if not current_chunk:
|
||||
return None
|
||||
text = "\n".join(current_chunk).strip()
|
||||
if len(text) < 20:
|
||||
return None
|
||||
|
||||
metadata = {
|
||||
"file": file_path,
|
||||
"headers": " > ".join(current_headers) if current_headers else ""
|
||||
}
|
||||
return (text, metadata)
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
# Check for header
|
||||
header_match = re.match(r'^(#{1,6})\s+(.+)$', line)
|
||||
|
||||
if header_match:
|
||||
# Emit current chunk before new header
|
||||
chunk = emit_chunk()
|
||||
if chunk:
|
||||
yield chunk
|
||||
current_chunk = []
|
||||
|
||||
# Update header hierarchy
|
||||
level = len(header_match.group(1))
|
||||
header_text = header_match.group(2).strip()
|
||||
|
||||
# Trim headers to current level
|
||||
current_headers = current_headers[:level-1]
|
||||
current_headers.append(header_text)
|
||||
|
||||
chunk_start_line = i
|
||||
|
||||
current_chunk.append(line)
|
||||
|
||||
# Check if chunk is getting too large (rough token estimate)
|
||||
chunk_text = "\n".join(current_chunk)
|
||||
if len(chunk_text) > CHUNK_SIZE * 4:
|
||||
chunk = emit_chunk()
|
||||
if chunk:
|
||||
yield chunk
|
||||
# Start new chunk with overlap
|
||||
overlap_lines = current_chunk[-CHUNK_OVERLAP // 10:] if len(current_chunk) > CHUNK_OVERLAP // 10 else []
|
||||
current_chunk = overlap_lines
|
||||
|
||||
# Emit final chunk
|
||||
chunk = emit_chunk()
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
|
||||
def index_source(
|
||||
source: dict,
|
||||
model: SentenceTransformer,
|
||||
quiet: bool = False
|
||||
) -> tuple[list[str], list[list[float]], list[dict], list[str]]:
|
||||
"""
|
||||
Index a single documentation source.
|
||||
|
||||
Returns:
|
||||
(chunks, embeddings, metadatas, ids)
|
||||
"""
|
||||
source_id = source["id"]
|
||||
source_type = source.get("type", "git")
|
||||
glob_pattern = source.get("glob", "**/*.md")
|
||||
|
||||
if source_type == "git":
|
||||
docs_dir = fetch_git_source(source, quiet=quiet)
|
||||
if not docs_dir:
|
||||
return [], [], [], []
|
||||
elif source_type == "local":
|
||||
docs_dir = Path(source["path"]).expanduser()
|
||||
if not docs_dir.exists():
|
||||
print(f" Warning: Local path does not exist: {docs_dir}", file=sys.stderr)
|
||||
return [], [], [], []
|
||||
else:
|
||||
print(f" Warning: Unknown source type: {source_type}", file=sys.stderr)
|
||||
return [], [], [], []
|
||||
|
||||
chunks = []
|
||||
metadatas = []
|
||||
ids = []
|
||||
|
||||
# Find and process files
|
||||
files = list(docs_dir.glob(glob_pattern))
|
||||
if not quiet:
|
||||
print(f" Found {len(files)} files matching {glob_pattern}")
|
||||
|
||||
for file_path in files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
except IOError:
|
||||
continue
|
||||
|
||||
rel_path = str(file_path.relative_to(docs_dir))
|
||||
full_path = f"{source_id}/{rel_path}"
|
||||
|
||||
for chunk_text, metadata in chunk_markdown(content, full_path):
|
||||
chunk_id = f"docs_{source_id}_{len(chunks)}"
|
||||
chunks.append(chunk_text)
|
||||
metadata["source_id"] = source_id
|
||||
metadata["source_name"] = source.get("name", source_id)
|
||||
if source.get("version"):
|
||||
metadata["version"] = source["version"]
|
||||
if source.get("base_url"):
|
||||
metadata["url"] = source["base_url"]
|
||||
metadatas.append(metadata)
|
||||
ids.append(chunk_id)
|
||||
|
||||
if not quiet:
|
||||
print(f" Indexed {len(chunks)} chunks from {source_id}")
|
||||
|
||||
return chunks, [], metadatas, ids
|
||||
|
||||
|
||||
def index_docs(
|
||||
source_id: Optional[str] = None,
|
||||
all_sources: bool = False,
|
||||
quiet: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Index documentation sources.
|
||||
|
||||
Args:
|
||||
source_id: Index only this source
|
||||
all_sources: Index all configured sources
|
||||
quiet: Suppress progress output
|
||||
|
||||
Returns:
|
||||
Summary statistics
|
||||
"""
|
||||
sources = load_sources()
|
||||
if not sources:
|
||||
return {"error": "No documentation sources configured"}
|
||||
|
||||
# Filter sources
|
||||
if source_id:
|
||||
sources = [s for s in sources if s["id"] == source_id]
|
||||
if not sources:
|
||||
return {"error": f"Source not found: {source_id}"}
|
||||
elif not all_sources:
|
||||
return {"error": "Specify --source <id> or --all"}
|
||||
|
||||
if not quiet:
|
||||
print(f"Indexing {len(sources)} documentation source(s)")
|
||||
|
||||
# Initialize model and client
|
||||
model = SentenceTransformer(MODEL_NAME)
|
||||
CHROMA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
|
||||
|
||||
# Get or create collection
|
||||
try:
|
||||
collection = client.get_collection(COLLECTION_NAME)
|
||||
# If indexing all or specific source, we'll need to handle existing data
|
||||
if all_sources:
|
||||
client.delete_collection(COLLECTION_NAME)
|
||||
collection = client.create_collection(
|
||||
name=COLLECTION_NAME,
|
||||
metadata={"description": "External documentation"}
|
||||
)
|
||||
except Exception:
|
||||
collection = client.create_collection(
|
||||
name=COLLECTION_NAME,
|
||||
metadata={"description": "External documentation"}
|
||||
)
|
||||
|
||||
# Process each source
|
||||
all_chunks = []
|
||||
all_metadatas = []
|
||||
all_ids = []
|
||||
|
||||
for source in sources:
|
||||
if not quiet:
|
||||
print(f"\nProcessing: {source['name']}")
|
||||
|
||||
chunks, _, metadatas, ids = index_source(source, model, quiet=quiet)
|
||||
all_chunks.extend(chunks)
|
||||
all_metadatas.extend(metadatas)
|
||||
all_ids.extend(ids)
|
||||
|
||||
# Update last_indexed timestamp
|
||||
source["last_indexed"] = datetime.now().isoformat()
|
||||
|
||||
# Batch embed and add to collection
|
||||
if all_chunks:
|
||||
if not quiet:
|
||||
print(f"\nEmbedding {len(all_chunks)} chunks...")
|
||||
|
||||
embeddings = model.encode(all_chunks, show_progress_bar=not quiet).tolist()
|
||||
|
||||
# Add in batches
|
||||
batch_size = 100
|
||||
for i in range(0, len(all_chunks), batch_size):
|
||||
end_idx = min(i + batch_size, len(all_chunks))
|
||||
collection.add(
|
||||
documents=all_chunks[i:end_idx],
|
||||
embeddings=embeddings[i:end_idx],
|
||||
metadatas=all_metadatas[i:end_idx],
|
||||
ids=all_ids[i:end_idx]
|
||||
)
|
||||
|
||||
# Save updated sources with timestamps
|
||||
all_sources = load_sources()
|
||||
for source in sources:
|
||||
for s in all_sources:
|
||||
if s["id"] == source["id"]:
|
||||
s["last_indexed"] = source["last_indexed"]
|
||||
break
|
||||
save_sources(all_sources)
|
||||
|
||||
stats = {
|
||||
"collection": COLLECTION_NAME,
|
||||
"sources_processed": len(sources),
|
||||
"chunks_indexed": len(all_chunks),
|
||||
"indexed_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
if not quiet:
|
||||
print(f"\nIndexed {len(all_chunks)} chunks from {len(sources)} source(s)")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Index external documentation for RAG search"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source", "-s",
|
||||
help="Index only this source ID"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all", "-a",
|
||||
action="store_true",
|
||||
dest="all_sources",
|
||||
help="Index all configured sources"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quiet", "-q",
|
||||
action="store_true",
|
||||
help="Suppress progress output"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list", "-l",
|
||||
action="store_true",
|
||||
help="List configured sources"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stats",
|
||||
action="store_true",
|
||||
help="Output stats as JSON"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
sources = load_sources()
|
||||
if sources:
|
||||
print(json.dumps(sources, indent=2))
|
||||
else:
|
||||
print("No documentation sources configured")
|
||||
print(f"Add sources with: add_doc_source.py")
|
||||
return
|
||||
|
||||
stats = index_docs(
|
||||
source_id=args.source,
|
||||
all_sources=args.all_sources,
|
||||
quiet=args.quiet
|
||||
)
|
||||
|
||||
if args.stats or "error" in stats:
|
||||
print(json.dumps(stats, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+286
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RAG Search - Personal Index Builder
|
||||
|
||||
Indexes ~/.claude/state files for semantic search.
|
||||
Chunks JSON files by key for optimal retrieval.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
# Add venv site-packages to path
|
||||
VENV_PATH = Path(__file__).parent.parent / "venv" / "lib" / "python3.13" / "site-packages"
|
||||
if str(VENV_PATH) not in sys.path:
|
||||
sys.path.insert(0, str(VENV_PATH))
|
||||
|
||||
import chromadb
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
# Constants
|
||||
STATE_DIR = Path.home() / ".claude" / "state"
|
||||
DATA_DIR = Path.home() / ".claude" / "data" / "rag-search"
|
||||
CHROMA_DIR = DATA_DIR / "chroma"
|
||||
MODEL_NAME = "all-MiniLM-L6-v2"
|
||||
COLLECTION_NAME = "personal"
|
||||
|
||||
|
||||
def chunk_json_file(file_path: Path) -> Generator[tuple[str, dict], None, None]:
|
||||
"""
|
||||
Chunk a JSON file into searchable segments.
|
||||
|
||||
Strategy:
|
||||
- Arrays: Each item becomes a chunk
|
||||
- Objects with arrays: Each array item with parent context
|
||||
- Nested objects: Flatten with path prefix
|
||||
|
||||
Yields:
|
||||
(chunk_text, metadata) tuples
|
||||
"""
|
||||
try:
|
||||
with open(file_path) as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
print(f" Warning: Could not parse {file_path}: {e}", file=sys.stderr)
|
||||
return
|
||||
|
||||
rel_path = str(file_path.relative_to(STATE_DIR))
|
||||
base_metadata = {"file": rel_path}
|
||||
|
||||
def process_item(item: dict, context: str = "") -> Generator[tuple[str, dict], None, None]:
|
||||
"""Process a single item from JSON structure."""
|
||||
if isinstance(item, dict):
|
||||
# Check for common patterns in our state files
|
||||
|
||||
# Memory items (decisions, preferences, facts, projects)
|
||||
if "content" in item:
|
||||
text_parts = []
|
||||
if context:
|
||||
text_parts.append(f"[{context}]")
|
||||
text_parts.append(item.get("content", ""))
|
||||
if item.get("context"):
|
||||
text_parts.append(f"Context: {item['context']}")
|
||||
if item.get("rationale"):
|
||||
text_parts.append(f"Rationale: {item['rationale']}")
|
||||
|
||||
metadata = {**base_metadata}
|
||||
if item.get("date"):
|
||||
metadata["date"] = item["date"]
|
||||
if item.get("id"):
|
||||
metadata["id"] = item["id"]
|
||||
if item.get("status"):
|
||||
metadata["status"] = item["status"]
|
||||
|
||||
yield (" ".join(text_parts), metadata)
|
||||
return
|
||||
|
||||
# General instructions (memory)
|
||||
if "instruction" in item:
|
||||
text_parts = [item["instruction"]]
|
||||
metadata = {**base_metadata}
|
||||
if item.get("added"):
|
||||
metadata["date"] = item["added"]
|
||||
if item.get("status"):
|
||||
metadata["status"] = item["status"]
|
||||
yield (" ".join(text_parts), metadata)
|
||||
return
|
||||
|
||||
# Knowledge base entries
|
||||
if "fact" in item or "answer" in item:
|
||||
text = item.get("fact") or item.get("answer", "")
|
||||
if item.get("question"):
|
||||
text = f"Q: {item['question']} A: {text}"
|
||||
metadata = {**base_metadata}
|
||||
if item.get("category"):
|
||||
metadata["category"] = item["category"]
|
||||
yield (text, metadata)
|
||||
return
|
||||
|
||||
# Component registry entries
|
||||
if "name" in item and "description" in item:
|
||||
text = f"{item['name']}: {item['description']}"
|
||||
if item.get("triggers"):
|
||||
text += f" Triggers: {', '.join(item['triggers'])}"
|
||||
metadata = {**base_metadata, "type": item.get("type", "unknown")}
|
||||
yield (text, metadata)
|
||||
return
|
||||
|
||||
# Future considerations
|
||||
if "id" in item and "title" in item:
|
||||
text = f"{item.get('id', '')}: {item['title']}"
|
||||
if item.get("description"):
|
||||
text += f" - {item['description']}"
|
||||
if item.get("rationale"):
|
||||
text += f" Rationale: {item['rationale']}"
|
||||
metadata = {**base_metadata}
|
||||
if item.get("date_added"):
|
||||
metadata["date"] = item["date_added"]
|
||||
if item.get("status"):
|
||||
metadata["status"] = item["status"]
|
||||
yield (text, metadata)
|
||||
return
|
||||
|
||||
# System instructions - processes
|
||||
if "process" in item or "name" in item:
|
||||
parts = []
|
||||
if item.get("name"):
|
||||
parts.append(item["name"])
|
||||
if item.get("description"):
|
||||
parts.append(item["description"])
|
||||
if item.get("steps"):
|
||||
parts.append("Steps: " + " ".join(item["steps"]))
|
||||
if parts:
|
||||
yield (" - ".join(parts), {**base_metadata})
|
||||
return
|
||||
|
||||
# Fallback: stringify the whole object
|
||||
text = json.dumps(item, indent=None)
|
||||
if len(text) > 50: # Only index if substantial
|
||||
yield (text[:1000], {**base_metadata}) # Truncate very long items
|
||||
|
||||
elif isinstance(item, str) and len(item) > 20:
|
||||
yield (item, {**base_metadata})
|
||||
|
||||
# Process top-level structure
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
yield from process_item(item)
|
||||
elif isinstance(data, dict):
|
||||
# Handle nested arrays within objects
|
||||
for key, value in data.items():
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
yield from process_item(item, context=key)
|
||||
elif isinstance(value, dict):
|
||||
yield from process_item(value, context=key)
|
||||
elif isinstance(value, str) and len(value) > 20:
|
||||
yield (f"{key}: {value}", {**base_metadata})
|
||||
|
||||
|
||||
def find_json_files() -> list[Path]:
|
||||
"""Find all JSON files in the state directory."""
|
||||
files = []
|
||||
for pattern in ["*.json", "**/*.json"]:
|
||||
files.extend(STATE_DIR.glob(pattern))
|
||||
return sorted(set(files))
|
||||
|
||||
|
||||
def index_personal(quiet: bool = False, force: bool = False) -> dict:
|
||||
"""
|
||||
Index all personal state files.
|
||||
|
||||
Args:
|
||||
quiet: Suppress progress output
|
||||
force: Force reindex even if already exists
|
||||
|
||||
Returns:
|
||||
Summary statistics
|
||||
"""
|
||||
if not quiet:
|
||||
print(f"Indexing personal state from {STATE_DIR}")
|
||||
|
||||
# Initialize model and client
|
||||
model = SentenceTransformer(MODEL_NAME)
|
||||
CHROMA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
|
||||
|
||||
# Delete and recreate collection for clean reindex
|
||||
try:
|
||||
client.delete_collection(COLLECTION_NAME)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
collection = client.create_collection(
|
||||
name=COLLECTION_NAME,
|
||||
metadata={"description": "Personal state files from ~/.claude/state"}
|
||||
)
|
||||
|
||||
# Find and process files
|
||||
files = find_json_files()
|
||||
if not quiet:
|
||||
print(f"Found {len(files)} JSON files")
|
||||
|
||||
total_chunks = 0
|
||||
chunks = []
|
||||
metadatas = []
|
||||
ids = []
|
||||
|
||||
for file_path in files:
|
||||
if not quiet:
|
||||
print(f" Processing: {file_path.relative_to(STATE_DIR)}")
|
||||
|
||||
for chunk_text, metadata in chunk_json_file(file_path):
|
||||
# Skip empty or very short chunks
|
||||
if not chunk_text or len(chunk_text.strip()) < 10:
|
||||
continue
|
||||
|
||||
chunk_id = f"personal_{total_chunks}"
|
||||
chunks.append(chunk_text)
|
||||
metadatas.append(metadata)
|
||||
ids.append(chunk_id)
|
||||
total_chunks += 1
|
||||
|
||||
# Batch embed and add to collection
|
||||
if chunks:
|
||||
if not quiet:
|
||||
print(f"Embedding {len(chunks)} chunks...")
|
||||
|
||||
embeddings = model.encode(chunks, show_progress_bar=not quiet).tolist()
|
||||
|
||||
# Add in batches (ChromaDB has limits)
|
||||
batch_size = 100
|
||||
for i in range(0, len(chunks), batch_size):
|
||||
end_idx = min(i + batch_size, len(chunks))
|
||||
collection.add(
|
||||
documents=chunks[i:end_idx],
|
||||
embeddings=embeddings[i:end_idx],
|
||||
metadatas=metadatas[i:end_idx],
|
||||
ids=ids[i:end_idx]
|
||||
)
|
||||
|
||||
stats = {
|
||||
"collection": COLLECTION_NAME,
|
||||
"files_processed": len(files),
|
||||
"chunks_indexed": total_chunks,
|
||||
"indexed_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
if not quiet:
|
||||
print(f"\nIndexed {total_chunks} chunks from {len(files)} files")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Index personal state files for RAG search"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quiet", "-q",
|
||||
action="store_true",
|
||||
help="Suppress progress output"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force", "-f",
|
||||
action="store_true",
|
||||
help="Force reindex even if already indexed"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stats",
|
||||
action="store_true",
|
||||
help="Output stats as JSON"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
stats = index_personal(quiet=args.quiet, force=args.force)
|
||||
|
||||
if args.stats:
|
||||
print(json.dumps(stats, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+184
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RAG Search - Main search entry point
|
||||
|
||||
Searches personal and/or docs indexes for semantically similar content.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Add venv site-packages to path
|
||||
VENV_PATH = Path(__file__).parent.parent / "venv" / "lib" / "python3.13" / "site-packages"
|
||||
if str(VENV_PATH) not in sys.path:
|
||||
sys.path.insert(0, str(VENV_PATH))
|
||||
|
||||
import chromadb
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
# Constants
|
||||
DATA_DIR = Path.home() / ".claude" / "data" / "rag-search"
|
||||
CHROMA_DIR = DATA_DIR / "chroma"
|
||||
MODEL_NAME = "all-MiniLM-L6-v2"
|
||||
DEFAULT_TOP_K = 5
|
||||
|
||||
# Lazy-loaded globals
|
||||
_model: Optional[SentenceTransformer] = None
|
||||
_client: Optional[chromadb.PersistentClient] = None
|
||||
|
||||
|
||||
def get_model() -> SentenceTransformer:
|
||||
"""Lazy-load the embedding model."""
|
||||
global _model
|
||||
if _model is None:
|
||||
_model = SentenceTransformer(MODEL_NAME)
|
||||
return _model
|
||||
|
||||
|
||||
def get_client() -> chromadb.PersistentClient:
|
||||
"""Lazy-load the ChromaDB client."""
|
||||
global _client
|
||||
if _client is None:
|
||||
CHROMA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_client = chromadb.PersistentClient(path=str(CHROMA_DIR))
|
||||
return _client
|
||||
|
||||
|
||||
def search(
|
||||
query: str,
|
||||
index: Optional[str] = None,
|
||||
top_k: int = DEFAULT_TOP_K,
|
||||
) -> dict:
|
||||
"""
|
||||
Search for semantically similar content.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
index: Which index to search ("personal", "docs", or None for both)
|
||||
top_k: Number of results to return per collection
|
||||
|
||||
Returns:
|
||||
dict with query, results, and metadata
|
||||
"""
|
||||
client = get_client()
|
||||
model = get_model()
|
||||
|
||||
# Embed the query
|
||||
query_embedding = model.encode(query).tolist()
|
||||
|
||||
# Determine which collections to search
|
||||
collections_to_search = []
|
||||
if index is None or index == "personal":
|
||||
try:
|
||||
collections_to_search.append(("personal", client.get_collection("personal")))
|
||||
except Exception:
|
||||
pass # Collection doesn't exist
|
||||
if index is None or index == "docs":
|
||||
try:
|
||||
collections_to_search.append(("docs", client.get_collection("docs")))
|
||||
except Exception:
|
||||
pass # Collection doesn't exist
|
||||
|
||||
if not collections_to_search:
|
||||
return {
|
||||
"query": query,
|
||||
"results": [],
|
||||
"searched_collections": [],
|
||||
"total_chunks_searched": 0,
|
||||
"error": f"No collections found for index: {index or 'any'}"
|
||||
}
|
||||
|
||||
# Search each collection
|
||||
all_results = []
|
||||
total_chunks = 0
|
||||
searched_collections = []
|
||||
|
||||
for coll_name, collection in collections_to_search:
|
||||
searched_collections.append(coll_name)
|
||||
count = collection.count()
|
||||
total_chunks += count
|
||||
|
||||
if count == 0:
|
||||
continue
|
||||
|
||||
results = collection.query(
|
||||
query_embeddings=[query_embedding],
|
||||
n_results=min(top_k, count),
|
||||
include=["documents", "metadatas", "distances"]
|
||||
)
|
||||
|
||||
# Process results
|
||||
if results["documents"] and results["documents"][0]:
|
||||
for i, (doc, metadata, distance) in enumerate(zip(
|
||||
results["documents"][0],
|
||||
results["metadatas"][0],
|
||||
results["distances"][0]
|
||||
)):
|
||||
# Convert distance to similarity score (cosine distance to similarity)
|
||||
score = 1 - (distance / 2) # Normalized for cosine distance
|
||||
all_results.append({
|
||||
"source": coll_name,
|
||||
"file": metadata.get("file", "unknown"),
|
||||
"chunk": doc,
|
||||
"score": round(score, 3),
|
||||
"metadata": {k: v for k, v in metadata.items() if k != "file"}
|
||||
})
|
||||
|
||||
# Sort by score and add ranks
|
||||
all_results.sort(key=lambda x: x["score"], reverse=True)
|
||||
for i, result in enumerate(all_results[:top_k]):
|
||||
result["rank"] = i + 1
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"results": all_results[:top_k],
|
||||
"searched_collections": searched_collections,
|
||||
"total_chunks_searched": total_chunks
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Search the RAG index for relevant content",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s "how did I configure ArgoCD sync?"
|
||||
%(prog)s --index personal "past decisions about caching"
|
||||
%(prog)s --index docs "k0s node maintenance"
|
||||
%(prog)s --top-k 10 "prometheus alerting rules"
|
||||
"""
|
||||
)
|
||||
parser.add_argument("query", help="Search query")
|
||||
parser.add_argument(
|
||||
"--index", "-i",
|
||||
choices=["personal", "docs"],
|
||||
help="Search only this index (default: both)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--top-k", "-k",
|
||||
type=int,
|
||||
default=DEFAULT_TOP_K,
|
||||
help=f"Number of results to return (default: {DEFAULT_TOP_K})"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--raw",
|
||||
action="store_true",
|
||||
help="Output raw JSON (default: formatted)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
results = search(args.query, args.index, args.top_k)
|
||||
|
||||
if args.raw:
|
||||
print(json.dumps(results))
|
||||
else:
|
||||
print(json.dumps(results, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+230
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RAG Search - Test Suite
|
||||
|
||||
Tests all components of the RAG search skill.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Constants
|
||||
SKILL_DIR = Path(__file__).parent.parent
|
||||
SCRIPTS_DIR = SKILL_DIR / "scripts"
|
||||
VENV_PYTHON = SKILL_DIR / "venv" / "bin" / "python"
|
||||
DATA_DIR = Path.home() / ".claude" / "data" / "rag-search"
|
||||
|
||||
|
||||
def run_script(script_name: str, args: list[str] = None) -> tuple[int, str, str]:
|
||||
"""Run a script and return (returncode, stdout, stderr)."""
|
||||
cmd = [str(VENV_PYTHON), str(SCRIPTS_DIR / script_name)]
|
||||
if args:
|
||||
cmd.extend(args)
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def test_chromadb_embeddings():
|
||||
"""Test 1: ChromaDB + embeddings working."""
|
||||
print("Test 1: ChromaDB + embeddings...")
|
||||
|
||||
# Add venv to path and test imports
|
||||
venv_path = SKILL_DIR / "venv" / "lib" / "python3.13" / "site-packages"
|
||||
sys.path.insert(0, str(venv_path))
|
||||
|
||||
try:
|
||||
import chromadb
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
# Test ChromaDB
|
||||
client = chromadb.PersistentClient(path=str(DATA_DIR / "chroma"))
|
||||
assert client is not None, "Failed to create ChromaDB client"
|
||||
|
||||
# Test embedding model
|
||||
model = SentenceTransformer("all-MiniLM-L6-v2")
|
||||
embedding = model.encode("test query")
|
||||
assert len(embedding) == 384, f"Expected 384 dimensions, got {len(embedding)}"
|
||||
|
||||
print(" PASS: ChromaDB and embeddings working")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_personal_index():
|
||||
"""Test 2: Personal index populated from ~/.claude/state."""
|
||||
print("Test 2: Personal index populated...")
|
||||
|
||||
# Check if collection exists and has data
|
||||
venv_path = SKILL_DIR / "venv" / "lib" / "python3.13" / "site-packages"
|
||||
if str(venv_path) not in sys.path:
|
||||
sys.path.insert(0, str(venv_path))
|
||||
|
||||
try:
|
||||
import chromadb
|
||||
|
||||
client = chromadb.PersistentClient(path=str(DATA_DIR / "chroma"))
|
||||
collection = client.get_collection("personal")
|
||||
count = collection.count()
|
||||
|
||||
assert count > 0, f"Personal collection is empty (count={count})"
|
||||
print(f" PASS: Personal index has {count} chunks")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_docs_index():
|
||||
"""Test 3: At least one external doc source indexed."""
|
||||
print("Test 3: External docs indexed...")
|
||||
|
||||
# Check if collection exists and has data
|
||||
venv_path = SKILL_DIR / "venv" / "lib" / "python3.13" / "site-packages"
|
||||
if str(venv_path) not in sys.path:
|
||||
sys.path.insert(0, str(venv_path))
|
||||
|
||||
try:
|
||||
import chromadb
|
||||
|
||||
client = chromadb.PersistentClient(path=str(DATA_DIR / "chroma"))
|
||||
collection = client.get_collection("docs")
|
||||
count = collection.count()
|
||||
|
||||
assert count > 0, f"Docs collection is empty (count={count})"
|
||||
|
||||
# Also verify sources.json has at least one source
|
||||
sources_file = SKILL_DIR / "references" / "sources.json"
|
||||
with open(sources_file) as f:
|
||||
sources = json.load(f)
|
||||
assert len(sources.get("sources", [])) > 0, "No sources configured"
|
||||
|
||||
print(f" PASS: Docs index has {count} chunks from {len(sources['sources'])} source(s)")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_search_returns_results():
|
||||
"""Test 4: search.py returns relevant results."""
|
||||
print("Test 4: Search returns relevant results...")
|
||||
|
||||
# Test personal search
|
||||
returncode, stdout, stderr = run_script("search.py", ["--index", "personal", "decisions"])
|
||||
if returncode != 0:
|
||||
print(f" FAIL: Personal search failed: {stderr}")
|
||||
return False
|
||||
|
||||
try:
|
||||
result = json.loads(stdout)
|
||||
personal_results = result.get("results", [])
|
||||
if not personal_results:
|
||||
print(" WARN: No personal results found (may be expected if state is minimal)")
|
||||
except json.JSONDecodeError:
|
||||
print(f" FAIL: Invalid JSON output: {stdout}")
|
||||
return False
|
||||
|
||||
# Test docs search
|
||||
returncode, stdout, stderr = run_script("search.py", ["--index", "docs", "kubernetes"])
|
||||
if returncode != 0:
|
||||
print(f" FAIL: Docs search failed: {stderr}")
|
||||
return False
|
||||
|
||||
try:
|
||||
result = json.loads(stdout)
|
||||
docs_results = result.get("results", [])
|
||||
if not docs_results:
|
||||
print(" FAIL: No docs results found for 'kubernetes'")
|
||||
return False
|
||||
except json.JSONDecodeError:
|
||||
print(f" FAIL: Invalid JSON output: {stdout}")
|
||||
return False
|
||||
|
||||
# Test combined search
|
||||
returncode, stdout, stderr = run_script("search.py", ["configuration"])
|
||||
if returncode != 0:
|
||||
print(f" FAIL: Combined search failed: {stderr}")
|
||||
return False
|
||||
|
||||
try:
|
||||
result = json.loads(stdout)
|
||||
assert "query" in result, "Missing 'query' in output"
|
||||
assert "results" in result, "Missing 'results' in output"
|
||||
assert "searched_collections" in result, "Missing 'searched_collections'"
|
||||
assert len(result["searched_collections"]) == 2, "Should search both collections"
|
||||
except json.JSONDecodeError:
|
||||
print(f" FAIL: Invalid JSON output: {stdout}")
|
||||
return False
|
||||
|
||||
print(f" PASS: Search returns properly formatted results")
|
||||
return True
|
||||
|
||||
|
||||
def test_skill_structure():
|
||||
"""Test 5: All required files exist."""
|
||||
print("Test 5: Skill structure complete...")
|
||||
|
||||
required_files = [
|
||||
SKILL_DIR / "SKILL.md",
|
||||
SCRIPTS_DIR / "search.py",
|
||||
SCRIPTS_DIR / "index_personal.py",
|
||||
SCRIPTS_DIR / "index_docs.py",
|
||||
SCRIPTS_DIR / "add_doc_source.py",
|
||||
SKILL_DIR / "references" / "sources.json",
|
||||
]
|
||||
|
||||
missing = []
|
||||
for f in required_files:
|
||||
if not f.exists():
|
||||
missing.append(str(f.relative_to(SKILL_DIR)))
|
||||
|
||||
if missing:
|
||||
print(f" FAIL: Missing files: {', '.join(missing)}")
|
||||
return False
|
||||
|
||||
print(" PASS: All required files exist")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("RAG Search Test Suite")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
tests = [
|
||||
test_chromadb_embeddings,
|
||||
test_personal_index,
|
||||
test_docs_index,
|
||||
test_search_returns_results,
|
||||
test_skill_structure,
|
||||
]
|
||||
|
||||
results = []
|
||||
for test in tests:
|
||||
results.append(test())
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print("Summary")
|
||||
print("=" * 60)
|
||||
|
||||
passed = sum(results)
|
||||
total = len(results)
|
||||
print(f"Passed: {passed}/{total}")
|
||||
|
||||
if passed == total:
|
||||
print("\nAll tests passed!")
|
||||
return 0
|
||||
else:
|
||||
print(f"\n{total - passed} test(s) failed")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+369
-153
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.1",
|
||||
"generated": "2026-01-01T10:30:00.000000-08:00",
|
||||
"version": "1.0",
|
||||
"generated": "2026-01-04T14:29:44.138959-08:00",
|
||||
"description": "Component registry for PA session awareness. Read at session start for routing.",
|
||||
"skills": {
|
||||
"sysadmin-health": {
|
||||
@@ -78,6 +78,18 @@
|
||||
"history"
|
||||
]
|
||||
},
|
||||
"morning-report": {
|
||||
"description": "Generate daily morning dashboard with email, calendar, stocks, weather, tasks, infra, and news",
|
||||
"script": "~/.claude/skills/morning-report/scripts/generate.py",
|
||||
"triggers": [
|
||||
"morning report",
|
||||
"morning",
|
||||
"daily report",
|
||||
"dashboard",
|
||||
"briefing",
|
||||
"daily briefing"
|
||||
]
|
||||
},
|
||||
"stock-lookup": {
|
||||
"description": "Look up stock prices and quotes",
|
||||
"script": "~/.claude/skills/stock-lookup/scripts/quote.py",
|
||||
@@ -92,23 +104,71 @@
|
||||
"performance"
|
||||
]
|
||||
},
|
||||
"morning-report": {
|
||||
"description": "Generate daily morning dashboard with email, calendar, stocks, weather, tasks, infra, and news",
|
||||
"script": "~/.claude/skills/morning-report/scripts/generate.py",
|
||||
"rag-search": {
|
||||
"description": "Semantic search across personal state files and external documentation (k0s, etc.)",
|
||||
"script": "~/.claude/skills/rag-search/scripts/search.py",
|
||||
"triggers": [
|
||||
"morning report",
|
||||
"morning",
|
||||
"daily report",
|
||||
"dashboard",
|
||||
"briefing",
|
||||
"daily briefing"
|
||||
"search",
|
||||
"find",
|
||||
"lookup",
|
||||
"what did",
|
||||
"how did",
|
||||
"when did",
|
||||
"past decisions",
|
||||
"previous",
|
||||
"documentation",
|
||||
"docs",
|
||||
"remember",
|
||||
"history"
|
||||
]
|
||||
},
|
||||
"gtasks": {
|
||||
"description": "Google Tasks read access - list pending tasks",
|
||||
"script": "~/.claude/skills/gtasks/scripts/list.py",
|
||||
"triggers": [
|
||||
"tasks",
|
||||
"todo",
|
||||
"to do",
|
||||
"to-do",
|
||||
"pending"
|
||||
]
|
||||
},
|
||||
"guardrails": {
|
||||
"description": "PreToolUse hook that prevents dangerous operations (rm -rf, system commands, etc.)",
|
||||
"script": "~/.claude/hooks/scripts/guardrail.py",
|
||||
"config": "~/.claude/state/guardrails.json",
|
||||
"triggers": [
|
||||
"guardrail",
|
||||
"safety",
|
||||
"block dangerous",
|
||||
"protect"
|
||||
]
|
||||
},
|
||||
"external-mode": {
|
||||
"description": "Toggle between Claude and external LLMs (Copilot, Z.AI, Gemini)",
|
||||
"script": "~/.claude/mcp/llm-router/toggle.py",
|
||||
"triggers": [
|
||||
"external",
|
||||
"use external",
|
||||
"switch to external",
|
||||
"external models",
|
||||
"external only",
|
||||
"use copilot",
|
||||
"use opencode",
|
||||
"back to claude",
|
||||
"use claude again",
|
||||
"disable external",
|
||||
"external mode"
|
||||
]
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"/pa": {
|
||||
"description": "Personal assistant entrypoint",
|
||||
"aliases": ["/assistant", "/ask"],
|
||||
"aliases": [
|
||||
"/assistant",
|
||||
"/ask"
|
||||
],
|
||||
"invokes": "agent:personal-assistant"
|
||||
},
|
||||
"/programmer": {
|
||||
@@ -118,24 +178,177 @@
|
||||
},
|
||||
"/gcal": {
|
||||
"description": "Google Calendar access",
|
||||
"aliases": ["/calendar", "/cal"],
|
||||
"aliases": [
|
||||
"/calendar",
|
||||
"/cal"
|
||||
],
|
||||
"invokes": "skill:gcal"
|
||||
},
|
||||
"/stock": {
|
||||
"description": "Stock price lookup",
|
||||
"aliases": ["/quote", "/ticker"],
|
||||
"invokes": "skill:stock-lookup"
|
||||
},
|
||||
"/morning": {
|
||||
"description": "Generate morning report dashboard",
|
||||
"aliases": ["/briefing", "/daily"],
|
||||
"invokes": "skill:morning-report"
|
||||
"/tasks": {
|
||||
"description": "List Google Tasks",
|
||||
"aliases": [
|
||||
"/todo",
|
||||
"/todos"
|
||||
],
|
||||
"invokes": "skill:gtasks"
|
||||
},
|
||||
"/usage": {
|
||||
"description": "View usage statistics",
|
||||
"aliases": ["/stats"],
|
||||
"aliases": [
|
||||
"/stats"
|
||||
],
|
||||
"invokes": "skill:usage"
|
||||
},
|
||||
"/external": {
|
||||
"description": "Toggle and use external LLM mode (GPT-5.2, Gemini, etc.)",
|
||||
"aliases": [
|
||||
"/llm",
|
||||
"/ext",
|
||||
"/external-llm"
|
||||
],
|
||||
"invokes": "command:external"
|
||||
},
|
||||
"/README": {
|
||||
"description": "TODO",
|
||||
"aliases": [],
|
||||
"invokes": ""
|
||||
},
|
||||
"/agent-info": {
|
||||
"description": "Show agent information",
|
||||
"aliases": [
|
||||
"/agent",
|
||||
"/agents"
|
||||
],
|
||||
"invokes": "command:agent-info"
|
||||
},
|
||||
"/config": {
|
||||
"description": "View and manage configuration settings",
|
||||
"aliases": [
|
||||
"/settings",
|
||||
"/prefs"
|
||||
],
|
||||
"invokes": "command:config"
|
||||
},
|
||||
"/debug": {
|
||||
"description": "Debug and troubleshoot configuration",
|
||||
"aliases": [
|
||||
"/diag",
|
||||
"/diagnose"
|
||||
],
|
||||
"invokes": "command:debug"
|
||||
},
|
||||
"/diff": {
|
||||
"description": "Compare config with backup",
|
||||
"aliases": [
|
||||
"/config-diff",
|
||||
"/compare"
|
||||
],
|
||||
"invokes": "command:diff"
|
||||
},
|
||||
"/export": {
|
||||
"description": "Export session data for sharing",
|
||||
"aliases": [
|
||||
"/session-export",
|
||||
"/share"
|
||||
],
|
||||
"invokes": "command:export"
|
||||
},
|
||||
"/help": {
|
||||
"description": "Show available commands and skills",
|
||||
"aliases": [
|
||||
"/commands",
|
||||
"/skills"
|
||||
],
|
||||
"invokes": "command:help"
|
||||
},
|
||||
"/log": {
|
||||
"description": "View and analyze logs",
|
||||
"aliases": [
|
||||
"/logs",
|
||||
"/logview"
|
||||
],
|
||||
"invokes": "command:log"
|
||||
},
|
||||
"/maintain": {
|
||||
"description": "Configuration maintenance (backup, validate, etc.)",
|
||||
"aliases": [
|
||||
"/maintenance",
|
||||
"/admin"
|
||||
],
|
||||
"invokes": "command:maintain"
|
||||
},
|
||||
"/mcp-status": {
|
||||
"description": "Check MCP integration status",
|
||||
"aliases": [
|
||||
"/mcp",
|
||||
"/integrations"
|
||||
],
|
||||
"invokes": "command:mcp-status"
|
||||
},
|
||||
"/remember": {
|
||||
"description": "Quick shortcut to save something to memory",
|
||||
"aliases": [
|
||||
"/save",
|
||||
"/note"
|
||||
],
|
||||
"invokes": "command:remember"
|
||||
},
|
||||
"/search": {
|
||||
"description": "Search memory, history, and configuration",
|
||||
"aliases": [
|
||||
"/find",
|
||||
"/lookup"
|
||||
],
|
||||
"invokes": "command:search"
|
||||
},
|
||||
"/rag": {
|
||||
"description": "Semantic search across state files and documentation",
|
||||
"aliases": [
|
||||
"/rag-search",
|
||||
"/semantic-search"
|
||||
],
|
||||
"invokes": "skill:rag-search"
|
||||
},
|
||||
"/skill-info": {
|
||||
"description": "Show skill information",
|
||||
"aliases": [
|
||||
"/skill",
|
||||
"/skills-info"
|
||||
],
|
||||
"invokes": "command:skill-info"
|
||||
},
|
||||
"/status": {
|
||||
"description": "Quick status overview across all domains",
|
||||
"aliases": [
|
||||
"/overview",
|
||||
"/dashboard"
|
||||
],
|
||||
"invokes": "command:status"
|
||||
},
|
||||
"/summarize": {
|
||||
"description": "Summarize and save session to memory",
|
||||
"aliases": [
|
||||
"/save-session",
|
||||
"/session-summary"
|
||||
],
|
||||
"invokes": "command:summarize"
|
||||
},
|
||||
"/template": {
|
||||
"description": "Manage session templates",
|
||||
"aliases": [
|
||||
"/templates",
|
||||
"/session-template"
|
||||
],
|
||||
"invokes": "command:template"
|
||||
},
|
||||
"/workflow": {
|
||||
"description": "List and describe workflows",
|
||||
"aliases": [
|
||||
"/workflows",
|
||||
"/wf"
|
||||
],
|
||||
"invokes": "command:workflow"
|
||||
},
|
||||
"/sysadmin:health": {
|
||||
"description": "System health check",
|
||||
"aliases": [],
|
||||
@@ -166,137 +379,125 @@
|
||||
"aliases": [],
|
||||
"invokes": "agent:k8s-diagnostician"
|
||||
},
|
||||
"/help": {
|
||||
"description": "Show available commands and skills",
|
||||
"aliases": ["/commands", "/skills"],
|
||||
"invokes": "command:help"
|
||||
"/stock": {
|
||||
"description": "Stock price lookup",
|
||||
"aliases": [
|
||||
"/quote",
|
||||
"/ticker"
|
||||
],
|
||||
"invokes": "skill:stock-lookup",
|
||||
"status": "removed"
|
||||
},
|
||||
"/status": {
|
||||
"description": "Quick status overview across all domains",
|
||||
"aliases": ["/overview", "/dashboard"],
|
||||
"invokes": "command:status"
|
||||
},
|
||||
"/summarize": {
|
||||
"description": "Summarize and save session to memory",
|
||||
"aliases": ["/save-session", "/session-summary"],
|
||||
"invokes": "command:summarize"
|
||||
},
|
||||
"/maintain": {
|
||||
"description": "Configuration maintenance (backup, validate, etc.)",
|
||||
"aliases": ["/maintenance", "/admin"],
|
||||
"invokes": "command:maintain"
|
||||
},
|
||||
"/remember": {
|
||||
"description": "Quick shortcut to save something to memory",
|
||||
"aliases": ["/save", "/note"],
|
||||
"invokes": "command:remember"
|
||||
},
|
||||
"/config": {
|
||||
"description": "View and manage configuration settings",
|
||||
"aliases": ["/settings", "/prefs"],
|
||||
"invokes": "command:config"
|
||||
},
|
||||
"/search": {
|
||||
"description": "Search memory, history, and configuration",
|
||||
"aliases": ["/find", "/lookup"],
|
||||
"invokes": "command:search"
|
||||
},
|
||||
"/log": {
|
||||
"description": "View and analyze logs",
|
||||
"aliases": ["/logs", "/logview"],
|
||||
"invokes": "command:log"
|
||||
},
|
||||
"/debug": {
|
||||
"description": "Debug and troubleshoot configuration",
|
||||
"aliases": ["/diag", "/diagnose"],
|
||||
"invokes": "command:debug"
|
||||
},
|
||||
"/export": {
|
||||
"description": "Export session data for sharing",
|
||||
"aliases": ["/session-export", "/share"],
|
||||
"invokes": "command:export"
|
||||
},
|
||||
"/mcp-status": {
|
||||
"description": "Check MCP integration status",
|
||||
"aliases": ["/mcp", "/integrations"],
|
||||
"invokes": "command:mcp-status"
|
||||
},
|
||||
"/workflow": {
|
||||
"description": "List and describe workflows",
|
||||
"aliases": ["/workflows", "/wf"],
|
||||
"invokes": "command:workflow"
|
||||
},
|
||||
"/skill-info": {
|
||||
"description": "Show skill information",
|
||||
"aliases": ["/skill", "/skills-info"],
|
||||
"invokes": "command:skill-info"
|
||||
},
|
||||
"/agent-info": {
|
||||
"description": "Show agent information",
|
||||
"aliases": ["/agent", "/agents"],
|
||||
"invokes": "command:agent-info"
|
||||
},
|
||||
"/diff": {
|
||||
"description": "Compare config with backup",
|
||||
"aliases": ["/config-diff", "/compare"],
|
||||
"invokes": "command:diff"
|
||||
},
|
||||
"/template": {
|
||||
"description": "Manage session templates",
|
||||
"aliases": ["/templates", "/session-template"],
|
||||
"invokes": "command:template"
|
||||
"/morning": {
|
||||
"description": "Generate morning report dashboard",
|
||||
"aliases": [
|
||||
"/briefing",
|
||||
"/daily"
|
||||
],
|
||||
"invokes": "skill:morning-report",
|
||||
"status": "removed"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"linux-sysadmin": {
|
||||
"description": "Workstation management",
|
||||
"model": "sonnet",
|
||||
"triggers": ["system", "linux", "package", "service", "disk", "process"]
|
||||
"triggers": [
|
||||
"system",
|
||||
"linux",
|
||||
"package",
|
||||
"service",
|
||||
"disk",
|
||||
"process"
|
||||
]
|
||||
},
|
||||
"k8s-orchestrator": {
|
||||
"description": "Kubernetes cluster management",
|
||||
"model": "opus",
|
||||
"triggers": ["kubernetes", "k8s", "cluster", "deploy"]
|
||||
"triggers": [
|
||||
"kubernetes",
|
||||
"k8s",
|
||||
"cluster",
|
||||
"deploy"
|
||||
]
|
||||
},
|
||||
"k8s-diagnostician": {
|
||||
"description": "Kubernetes troubleshooting",
|
||||
"model": "sonnet",
|
||||
"triggers": ["pod issue", "crashloop", "k8s error", "deployment failed"]
|
||||
"triggers": [
|
||||
"pod issue",
|
||||
"crashloop",
|
||||
"k8s error",
|
||||
"deployment failed"
|
||||
]
|
||||
},
|
||||
"argocd-operator": {
|
||||
"description": "ArgoCD GitOps operations",
|
||||
"model": "sonnet",
|
||||
"triggers": ["argocd", "gitops", "sync", "app sync"]
|
||||
"triggers": [
|
||||
"argocd",
|
||||
"gitops",
|
||||
"sync",
|
||||
"app sync"
|
||||
]
|
||||
},
|
||||
"prometheus-analyst": {
|
||||
"description": "Metrics and alerting analysis",
|
||||
"model": "sonnet",
|
||||
"triggers": ["metrics", "prometheus", "alert", "grafana"]
|
||||
"triggers": [
|
||||
"metrics",
|
||||
"prometheus",
|
||||
"alert",
|
||||
"grafana"
|
||||
]
|
||||
},
|
||||
"git-operator": {
|
||||
"description": "Git repository operations",
|
||||
"model": "sonnet",
|
||||
"triggers": ["git", "commit", "branch", "merge", "repo"]
|
||||
"triggers": [
|
||||
"git",
|
||||
"commit",
|
||||
"branch",
|
||||
"merge",
|
||||
"repo"
|
||||
]
|
||||
},
|
||||
"programmer-orchestrator": {
|
||||
"description": "Code development coordination",
|
||||
"model": "opus",
|
||||
"triggers": ["code", "develop", "implement", "program"]
|
||||
"triggers": [
|
||||
"code",
|
||||
"develop",
|
||||
"implement",
|
||||
"program"
|
||||
]
|
||||
},
|
||||
"code-planner": {
|
||||
"description": "Code planning and design",
|
||||
"model": "sonnet",
|
||||
"triggers": ["plan code", "design", "architecture"]
|
||||
"triggers": [
|
||||
"plan code",
|
||||
"design",
|
||||
"architecture"
|
||||
]
|
||||
},
|
||||
"code-implementer": {
|
||||
"description": "Code implementation",
|
||||
"model": "sonnet",
|
||||
"triggers": ["write code", "implement", "build"]
|
||||
"triggers": [
|
||||
"write code",
|
||||
"implement",
|
||||
"build"
|
||||
]
|
||||
},
|
||||
"code-reviewer": {
|
||||
"description": "Code review",
|
||||
"model": "sonnet",
|
||||
"triggers": ["review", "code review", "check code"]
|
||||
"triggers": [
|
||||
"review",
|
||||
"code review",
|
||||
"check code"
|
||||
]
|
||||
},
|
||||
"master-orchestrator": {
|
||||
"description": "Coordinate and enforce policies",
|
||||
@@ -306,49 +507,94 @@
|
||||
"personal-assistant": {
|
||||
"description": "User interface, ultimate oversight",
|
||||
"model": "opus",
|
||||
"triggers": ["help", "assist", "question"]
|
||||
"triggers": [
|
||||
"help",
|
||||
"assist",
|
||||
"question"
|
||||
]
|
||||
},
|
||||
"README": {
|
||||
"description": "TODO",
|
||||
"model": "sonnet",
|
||||
"triggers": [
|
||||
"TODO"
|
||||
]
|
||||
}
|
||||
},
|
||||
"workflows": {
|
||||
"validate-agent-format": {
|
||||
"description": "Validate agent file format",
|
||||
"triggers": ["validate agent", "check agent format"]
|
||||
"triggers": [
|
||||
"validate agent",
|
||||
"check agent format"
|
||||
]
|
||||
},
|
||||
"health/cluster-health-check": {
|
||||
"description": "Kubernetes cluster health check",
|
||||
"triggers": ["cluster health", "k8s health"]
|
||||
"triggers": [
|
||||
"cluster health",
|
||||
"k8s health"
|
||||
]
|
||||
},
|
||||
"health/cluster-daily-summary": {
|
||||
"description": "Daily cluster health summary",
|
||||
"triggers": ["daily summary", "cluster summary"]
|
||||
"triggers": [
|
||||
"daily summary",
|
||||
"cluster summary"
|
||||
]
|
||||
},
|
||||
"deploy/deploy-app": {
|
||||
"description": "Deploy application to Kubernetes",
|
||||
"triggers": ["deploy app", "deploy to k8s"]
|
||||
"triggers": [
|
||||
"deploy app",
|
||||
"deploy to k8s"
|
||||
]
|
||||
},
|
||||
"incidents/pod-crashloop": {
|
||||
"description": "Handle pod crashloop",
|
||||
"triggers": ["crashloop", "pod crashing", "restart loop"]
|
||||
"triggers": [
|
||||
"crashloop",
|
||||
"pod crashing",
|
||||
"restart loop"
|
||||
]
|
||||
},
|
||||
"incidents/node-issue-response": {
|
||||
"description": "Respond to node issues",
|
||||
"triggers": ["node issue", "node down", "node problem"]
|
||||
"triggers": [
|
||||
"node issue",
|
||||
"node down",
|
||||
"node problem"
|
||||
]
|
||||
},
|
||||
"incidents/resource-pressure-response": {
|
||||
"description": "Handle resource pressure",
|
||||
"triggers": ["resource pressure", "out of memory", "disk full"]
|
||||
"triggers": [
|
||||
"resource pressure",
|
||||
"out of memory",
|
||||
"disk full"
|
||||
]
|
||||
},
|
||||
"incidents/argocd-sync-failure": {
|
||||
"description": "Handle ArgoCD sync failures",
|
||||
"triggers": ["sync failed", "argocd error"]
|
||||
"triggers": [
|
||||
"sync failed",
|
||||
"argocd error"
|
||||
]
|
||||
},
|
||||
"sysadmin/health-check": {
|
||||
"description": "System health check workflow",
|
||||
"triggers": ["system check", "health check"]
|
||||
"triggers": [
|
||||
"system check",
|
||||
"health check"
|
||||
]
|
||||
},
|
||||
"sysadmin/system-update": {
|
||||
"description": "System update workflow",
|
||||
"triggers": ["system update", "update packages", "upgrade"]
|
||||
"triggers": [
|
||||
"system update",
|
||||
"update packages",
|
||||
"upgrade"
|
||||
]
|
||||
}
|
||||
},
|
||||
"delegation_helpers": {
|
||||
@@ -360,35 +606,5 @@
|
||||
"description": "Calendar API with tiered delegation",
|
||||
"location": "~/.claude/mcp/delegation/gcal_delegate.py"
|
||||
}
|
||||
},
|
||||
"automation": {
|
||||
"scripts": {
|
||||
"validate-setup": "~/.claude/automation/validate-setup.sh",
|
||||
"quick-status": "~/.claude/automation/quick-status.sh",
|
||||
"backup": "~/.claude/automation/backup.sh",
|
||||
"restore": "~/.claude/automation/restore.sh",
|
||||
"clean": "~/.claude/automation/clean.sh",
|
||||
"install": "~/.claude/automation/install.sh",
|
||||
"test": "~/.claude/automation/test-scripts.sh",
|
||||
"memory-add": "~/.claude/automation/memory-add.py",
|
||||
"memory-list": "~/.claude/automation/memory-list.py",
|
||||
"search": "~/.claude/automation/search.py",
|
||||
"history-browser": "~/.claude/automation/history-browser.py",
|
||||
"log-viewer": "~/.claude/automation/log-viewer.py",
|
||||
"debug": "~/.claude/automation/debug.sh",
|
||||
"daily-maintenance": "~/.claude/automation/daily-maintenance.sh",
|
||||
"session-export": "~/.claude/automation/session-export.py",
|
||||
"mcp-status": "~/.claude/automation/mcp-status.sh",
|
||||
"upgrade": "~/.claude/automation/upgrade.sh",
|
||||
"workflow-info": "~/.claude/automation/workflow-info.py",
|
||||
"skill-info": "~/.claude/automation/skill-info.py",
|
||||
"agent-info": "~/.claude/automation/agent-info.py",
|
||||
"config-diff": "~/.claude/automation/config-diff.py",
|
||||
"session-template": "~/.claude/automation/session-template.py"
|
||||
},
|
||||
"completions": {
|
||||
"bash": "~/.claude/automation/completions.bash",
|
||||
"zsh": "~/.claude/automation/completions.zsh"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"activated_at": null,
|
||||
"reason": null
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"version": 1,
|
||||
"safe_paths": [
|
||||
"~/.claude",
|
||||
"~/projects"
|
||||
],
|
||||
"blocked_paths": [
|
||||
"/etc",
|
||||
"/usr",
|
||||
"/var",
|
||||
"/boot",
|
||||
"/sys",
|
||||
"/proc",
|
||||
"~/.ssh",
|
||||
"~/.gnupg",
|
||||
"~/.aws"
|
||||
],
|
||||
"rules": {
|
||||
"bash": [
|
||||
{"pattern": "rm -rf /($|[^a-zA-Z])", "action": "block", "name": "rm_rf_root"},
|
||||
{"pattern": "rm -rf ~($|[^a-zA-Z])", "action": "block", "name": "rm_rf_home"},
|
||||
{"pattern": "rm -rf \\*", "action": "block", "name": "rm_rf_wildcard"},
|
||||
{"pattern": "chmod -R 777", "action": "block", "name": "chmod_777"},
|
||||
{"pattern": ":\\(\\)\\{ :\\|:& \\};:", "action": "block", "name": "fork_bomb"},
|
||||
{"pattern": "mkfs\\.", "action": "block", "name": "mkfs"},
|
||||
{"pattern": "dd .* of=/dev/", "action": "block", "name": "dd_device"},
|
||||
{"pattern": "> /dev/sd[a-z]", "action": "block", "name": "overwrite_device"},
|
||||
{"pattern": "shutdown", "action": "confirm", "name": "shutdown"},
|
||||
{"pattern": "reboot", "action": "confirm", "name": "reboot"},
|
||||
{"pattern": "systemctl (stop|disable|mask)", "action": "confirm", "name": "systemctl_destructive"},
|
||||
{"pattern": "rm ", "action": "confirm", "name": "rm_outside_safe", "outside_safe_paths": true},
|
||||
{"pattern": "kubectl delete", "action": "confirm", "name": "kubectl_delete"},
|
||||
{"pattern": "docker rm", "action": "confirm", "name": "docker_rm"},
|
||||
{"pattern": "docker system prune", "action": "confirm", "name": "docker_prune"}
|
||||
],
|
||||
"write": [
|
||||
{"path_match": "blocked_paths", "action": "block", "name": "write_blocked_path"},
|
||||
{"path_match": "outside_safe_paths", "action": "confirm", "name": "write_outside_safe"}
|
||||
],
|
||||
"edit": [
|
||||
{"path_match": "blocked_paths", "action": "block", "name": "edit_blocked_path"},
|
||||
{"path_match": "outside_safe_paths", "action": "confirm", "name": "edit_outside_safe"}
|
||||
]
|
||||
}
|
||||
}
|
||||
+171
-1
@@ -1 +1,171 @@
|
||||
{"infra":{"cluster":"k0s","nodes":3,"arch":"arm64"},"svc":{"gitops":"argocd","mon":"prometheus","alerts":"alertmanager"},"net":{},"hw":{"pi5_8gb":2,"pi3_1gb":1}}
|
||||
{
|
||||
"infra": {
|
||||
"cluster": "k0s",
|
||||
"nodes": 3,
|
||||
"arch": "arm64",
|
||||
"storage": "longhorn",
|
||||
"storage_class": "longhorn",
|
||||
"backup": "longhorn-backup + minio-to-mega"
|
||||
},
|
||||
"hw": {
|
||||
"pi5_8gb": 2,
|
||||
"pi3_1gb": 1,
|
||||
"roles": {
|
||||
"control_plane": "pi5",
|
||||
"workers": ["pi5", "pi3"]
|
||||
}
|
||||
},
|
||||
"net": {
|
||||
"metallb_pool": "192.168.153.240-192.168.153.254",
|
||||
"ingress_nginx_ip": "192.168.153.240",
|
||||
"ingress_haproxy_ip": "192.168.153.241",
|
||||
"tailnet": "taildb3494.ts.net",
|
||||
"dns_pattern": "<app>.<ns>.<ip>.nip.io"
|
||||
},
|
||||
"svc": {
|
||||
"gitops": "argocd",
|
||||
"monitoring": {
|
||||
"metrics": "kube-prometheus-stack",
|
||||
"logs": "loki-stack",
|
||||
"alerts": "alertmanager",
|
||||
"dashboards": "grafana"
|
||||
},
|
||||
"ingress": ["nginx-ingress-controller", "haproxy-ingress"],
|
||||
"storage": ["longhorn", "local-path-storage", "minio"],
|
||||
"networking": ["metallb", "tailscale-operator"]
|
||||
},
|
||||
"apps": {
|
||||
"ai_stack": {
|
||||
"namespace": "ai-stack",
|
||||
"components": ["open-webui", "ollama", "litellm", "searxng", "n8n", "vllm"],
|
||||
"models": ["gpt-oss:120b", "qwen3-coder"],
|
||||
"ollama_host": "100.85.116.57:11434"
|
||||
},
|
||||
"home": ["home-assistant", "pihole", "plex"],
|
||||
"infra": ["gitea", "docker-registry", "kubernetes-dashboard"],
|
||||
"other": ["ghost", "tor-controller", "speedtest-tracker"]
|
||||
},
|
||||
"namespaces": [
|
||||
"ai-stack", "argocd", "monitoring", "loki-system", "longhorn-system",
|
||||
"metallb-system", "minio", "nginx-ingress-controller", "tailscale-operator",
|
||||
"gitea", "home-assistant", "pihole", "pihole2", "plex", "ghost",
|
||||
"kubernetes-dashboard", "docker-registry", "k8s-agent", "tools", "vpa"
|
||||
],
|
||||
"urls": {
|
||||
"grafana": "grafana.monitoring.192.168.153.240.nip.io",
|
||||
"longhorn": "ui.longhorn-system.192.168.153.240.nip.io",
|
||||
"open_webui": "oi.ai-stack.192.168.153.240.nip.io",
|
||||
"searxng": "sx.ai-stack.192.168.153.240.nip.io",
|
||||
"n8n": "n8n.ai-stack.192.168.153.240.nip.io",
|
||||
"minio_console": "console.minio.192.168.153.240.nip.io",
|
||||
"pihole": "pihole.192.168.153.240.nip.io",
|
||||
"k8s_dashboard": "dashboard.kubernetes-dashboards.192.168.153.240.nip.io",
|
||||
"home_assistant": "ha.home-assistant.192.168.153.241.nip.io",
|
||||
"plex": "player.plex.192.168.153.246.nip.io"
|
||||
},
|
||||
"external_llm": {
|
||||
"description": "Route requests to external LLMs via opencode or gemini CLI",
|
||||
"state_file": "~/.claude/state/external-mode.json",
|
||||
"router_dir": "~/.claude/mcp/llm-router/",
|
||||
"commands": {
|
||||
"toggle_on": "~/.claude/mcp/llm-router/toggle.py on --reason 'reason'",
|
||||
"toggle_off": "~/.claude/mcp/llm-router/toggle.py off",
|
||||
"status": "~/.claude/mcp/llm-router/toggle.py status",
|
||||
"invoke": "~/.claude/mcp/llm-router/invoke.py --model MODEL -p 'prompt'"
|
||||
},
|
||||
"providers": ["opencode", "gemini"],
|
||||
"tiers": {
|
||||
"frontier": ["github-copilot/gpt-5.2", "github-copilot/gemini-3-pro-preview", "gemini/gemini-2.5-pro"],
|
||||
"mid-tier": ["github-copilot/gpt-5-mini", "github-copilot/claude-sonnet-4.5", "github-copilot/gemini-3-flash-preview", "opencode/grok-code", "gemini/gemini-2.5-flash"],
|
||||
"lightweight": ["opencode/gpt-5-nano", "zai-coding-plan/glm-4.5-air", "github-copilot/claude-haiku-4.5"]
|
||||
},
|
||||
"task_routing": {
|
||||
"reasoning": "github-copilot/gpt-5.2",
|
||||
"code-generation": "github-copilot/gemini-3-pro-preview",
|
||||
"long-context": "gemini/gemini-2.5-pro",
|
||||
"fast": "github-copilot/gemini-3-flash-preview",
|
||||
"default": "github-copilot/claude-sonnet-4.5"
|
||||
},
|
||||
"notes": {
|
||||
"opencode_path": "/home/linuxbrew/.linuxbrew/bin/opencode (NOT /usr/bin/opencode which crashes)",
|
||||
"o3_removed": "github-copilot/o3 not available via GitHub Copilot"
|
||||
}
|
||||
},
|
||||
"workstation": {
|
||||
"hostname": "willlaptop",
|
||||
"ip": "192.168.153.117",
|
||||
"os": "Arch Linux",
|
||||
"desktop": "GNOME",
|
||||
"shell": "fish",
|
||||
"terminal": ["ghostty", "alacritty", "gnome-console"],
|
||||
"network": "systemd-networkd + iwd",
|
||||
"theme": "Dracula",
|
||||
"editors": ["vscode", "zed", "vim"],
|
||||
"browsers": ["firefox", "chromium", "google-chrome", "zen-browser", "epiphany"],
|
||||
"virtualization": ["docker", "podman", "distrobox", "virt-manager", "virtualbox", "gnome-boxes"],
|
||||
"k8s_tools": ["k9s", "k0s-bin", "k0sctl-bin", "argocd", "krew", "kubecolor"],
|
||||
"dev_langs": ["go", "rust", "python", "typescript", "zig", "bun", "node/npm/pnpm"],
|
||||
"ai_local": {
|
||||
"ollama": true,
|
||||
"llama_swap": true,
|
||||
"models": ["Qwen3-4b", "Gemma3-4b"]
|
||||
},
|
||||
"backup": ["restic", "timeshift", "btrbk", "chezmoi"],
|
||||
"dotfiles": "chezmoi"
|
||||
},
|
||||
"repos": {
|
||||
"willlaptop": {
|
||||
"path": "~/Code/active/devops/willlaptop",
|
||||
"remote": "git@gitea-gitea-ssh.taildb3494.ts.net:will/willlaptop.git",
|
||||
"purpose": "Workstation provisioning and config",
|
||||
"structure": {
|
||||
"ansible/": "Machine provisioning playbooks",
|
||||
"ansible/roles/common/": "Hostname, network, users, SSH config",
|
||||
"ansible/roles/packages/": "Package installation (pacman, AUR, flatpak, appimage)",
|
||||
"ansible/roles/packages/files/": "Package lists (pkglist.txt, aur_pkglist.txt, etc)",
|
||||
"docker/": "Local Docker stacks",
|
||||
"scripts/": "Utility scripts (backup, sync, networking)",
|
||||
"MCP/": "MCP server configs",
|
||||
"local_ollama/": "Local Ollama data"
|
||||
},
|
||||
"ansible_tags": ["network", "wifi", "ethernet", "users", "sshd", "pacman", "aur", "flatpak", "appimage"],
|
||||
"docker_stacks": ["file_browser", "minio-longhorn-backup", "rancher-cleanup"],
|
||||
"scripts": ["bridge-up.sh", "chezmoi-sync.sh", "curl-s3.sh", "kvm-bridge-setup.sh",
|
||||
"rclone-sync.sh", "restic-backup.sh", "restic-clean.sh"]
|
||||
},
|
||||
"homelab": {
|
||||
"path": "~/Code/active/devops/homelab/homelab",
|
||||
"remote": "git@github.com:will666/homelab.git",
|
||||
"symlink": "~/.claude/repos/homelab",
|
||||
"structure": {
|
||||
"ansible/": "Ansible playbooks and templates for node provisioning",
|
||||
"argocd/": "ArgoCD Application manifests (one per service)",
|
||||
"charts/": "Helm values and raw manifests per service",
|
||||
"charts/<svc>/values.yaml": "Helm chart values override",
|
||||
"charts/<svc>/manifests/": "Raw K8s manifests (non-Helm resources)",
|
||||
"docker/": "Docker Compose stacks for non-K8s workloads"
|
||||
},
|
||||
"charts": [
|
||||
"ai-stack", "argocd", "argo-workflow", "cdi-operator",
|
||||
"cloudflare-tunnel-ingress-controller", "docker-registry", "ghost",
|
||||
"gitea", "haproxy-ingress", "harbor", "home-assistant", "k0s-backup",
|
||||
"k8s-agent-dashboard", "kube-prometheus-stack", "kubernetes-dashboard",
|
||||
"kubevirt", "local-path-storage", "loki-stack", "longhorn",
|
||||
"longhorn-backup", "metallb", "minio", "minio-to-mega-backup",
|
||||
"nfs-server-longhorn", "nginx-ingress-controller", "pihole", "pihole2",
|
||||
"plex", "speedtest-tracker", "squareffect", "squareserver",
|
||||
"tailscale-operator", "tools", "tor-controller", "traefik-ingress-controller",
|
||||
"willlaptop-backup", "willlaptop-monitoring", "wills-portal"
|
||||
],
|
||||
"docker_stacks": [
|
||||
"protonvpn-proxy", "squareffect", "squareserver", "stable-diffusion-webui"
|
||||
],
|
||||
"conventions": {
|
||||
"argocd_app": "argocd/<name>.yaml points to charts/<name>/",
|
||||
"helm_values": "charts/<name>/values.yaml for Helm overrides",
|
||||
"raw_manifests": "charts/<name>/manifests/ for non-Helm K8s resources",
|
||||
"naming": "ArgoCD app name = namespace name (usually)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,5 +113,85 @@
|
||||
"default": "haiku",
|
||||
"escalate_on": ["insufficient_context", "reasoning_required", "user_dissatisfied"]
|
||||
}
|
||||
},
|
||||
"external_models": {
|
||||
"github-copilot/gpt-5.2": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["-m", "github-copilot/gpt-5.2"],
|
||||
"use_cases": ["reasoning", "fallback"],
|
||||
"tier": "frontier"
|
||||
},
|
||||
"github-copilot/claude-sonnet-4.5": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["-m", "github-copilot/claude-sonnet-4.5"],
|
||||
"use_cases": ["general", "fallback"],
|
||||
"tier": "mid-tier"
|
||||
},
|
||||
"github-copilot/claude-haiku-4.5": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["-m", "github-copilot/claude-haiku-4.5"],
|
||||
"use_cases": ["simple"],
|
||||
"tier": "lightweight"
|
||||
},
|
||||
"github-copilot/gemini-3-pro-preview": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["-m", "github-copilot/gemini-3-pro-preview"],
|
||||
"use_cases": ["long-context", "reasoning"],
|
||||
"tier": "frontier"
|
||||
},
|
||||
"github-copilot/gemini-3-flash-preview": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["-m", "github-copilot/gemini-3-flash-preview"],
|
||||
"use_cases": ["fast", "general"],
|
||||
"tier": "mid-tier"
|
||||
},
|
||||
"gemini/gemini-2.5-pro": {
|
||||
"cli": "gemini",
|
||||
"cli_args": ["-m", "gemini-2.5-pro"],
|
||||
"use_cases": ["long-context", "reasoning"],
|
||||
"tier": "frontier"
|
||||
},
|
||||
"gemini/gemini-2.5-flash": {
|
||||
"cli": "gemini",
|
||||
"cli_args": ["-m", "gemini-2.5-flash"],
|
||||
"use_cases": ["fast", "general"],
|
||||
"tier": "mid-tier"
|
||||
},
|
||||
"github-copilot/gpt-5-mini": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["-m", "github-copilot/gpt-5-mini"],
|
||||
"use_cases": ["fast", "general"],
|
||||
"tier": "mid-tier"
|
||||
},
|
||||
"opencode/gpt-5-nano": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["-m", "opencode/gpt-5-nano"],
|
||||
"use_cases": ["fast", "simple"],
|
||||
"tier": "lightweight"
|
||||
},
|
||||
"zai-coding-plan/glm-4.5-air": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["-m", "zai-coding-plan/glm-4.5-air"],
|
||||
"use_cases": ["simple", "fast"],
|
||||
"tier": "lightweight"
|
||||
},
|
||||
"opencode/grok-code": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["-m", "opencode/grok-code"],
|
||||
"use_cases": ["code-generation", "general"],
|
||||
"tier": "mid-tier"
|
||||
}
|
||||
},
|
||||
"tier_to_external_map": {
|
||||
"frontier": "github-copilot/gpt-5.2",
|
||||
"mid-tier": "github-copilot/claude-sonnet-4.5",
|
||||
"lightweight": "github-copilot/claude-haiku-4.5"
|
||||
},
|
||||
"task_routing": {
|
||||
"reasoning": "github-copilot/gpt-5.2",
|
||||
"code-generation": "github-copilot/gemini-3-pro-preview",
|
||||
"long-context": "gemini/gemini-2.5-pro",
|
||||
"fast": "github-copilot/gemini-3-flash-preview",
|
||||
"default": "github-copilot/claude-sonnet-4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,42 @@
|
||||
"instruction": "Delegate technical tasks to specialized agents. Use skills and slash commands for quick technical checks.",
|
||||
"status": "active",
|
||||
"added": "2025-01-21"
|
||||
},
|
||||
{
|
||||
"id": "d4e5f6a7-8901-23de-f012-444444444444",
|
||||
"instruction": "Use appropriate model for task: Haiku for simple extraction/formatting, Sonnet for code review/analysis, Opus for complex reasoning. Delegate to subagents with correct model.",
|
||||
"status": "active",
|
||||
"added": "2026-01-04"
|
||||
},
|
||||
{
|
||||
"id": "e5f6a7b8-9012-34ef-0123-555555555555",
|
||||
"instruction": "Keep git repos clean: always use gitea-merge.sh to merge (auto-deletes branches). Periodically run 'git branch -a' to check for stale branches and remove them.",
|
||||
"status": "active",
|
||||
"added": "2026-01-04"
|
||||
},
|
||||
{
|
||||
"id": "f6a7b8c9-0123-45fa-1234-666666666666",
|
||||
"instruction": "After reinstalling gmail-mcp package, run ~/.claude/patches/apply-gmail-auth-patch.sh to restore auto re-auth on token expiry.",
|
||||
"status": "active",
|
||||
"added": "2026-01-09"
|
||||
},
|
||||
{
|
||||
"id": "a7b8c9d0-1234-56ab-2345-777777777777",
|
||||
"instruction": "Homelab repo is at ~/Code/active/devops/homelab/homelab (canonical). ~/.claude/repos/homelab is a symlink to it. Always use the canonical path for new work.",
|
||||
"status": "active",
|
||||
"added": "2026-01-09"
|
||||
},
|
||||
{
|
||||
"id": "b2c3d4e5-6789-01bc-def0-222222222222",
|
||||
"instruction": "Git workflow: See CLAUDE.md for full process. Use rebase merges, not merge commits.",
|
||||
"status": "deprecated",
|
||||
"added": "2026-01-03"
|
||||
},
|
||||
{
|
||||
"id": "c3d4e5f6-7890-12cd-ef01-333333333333",
|
||||
"instruction": "Git workflow: See CLAUDE.md for full process.",
|
||||
"status": "deprecated",
|
||||
"added": "2026-01-03"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -182,6 +182,111 @@
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-03_13-58-37",
|
||||
"started": "2026-01-03T13:58:37-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-04_13-30-26",
|
||||
"started": "2026-01-04T13:30:26-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-04_14-20-18",
|
||||
"started": "2026-01-04T14:20:18-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-04_23-21-33",
|
||||
"started": "2026-01-04T23:21:33-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-04_23-22-43",
|
||||
"started": "2026-01-04T23:22:43-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-04_23-22-43",
|
||||
"started": "2026-01-04T23:22:43-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-04_23-56-03",
|
||||
"started": "2026-01-04T23:56:03-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-05_00-28-11",
|
||||
"started": "2026-01-05T00:28:11-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-05_00-50-41",
|
||||
"started": "2026-01-05T00:50:41-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-05_00-51-05",
|
||||
"started": "2026-01-05T00:51:05-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-05_12-10-41",
|
||||
"started": "2026-01-05T12:10:41-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-05_12-11-40",
|
||||
"started": "2026-01-05T12:11:40-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-06_08-49-25",
|
||||
"started": "2026-01-06T08:49:25-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-07_10-32-29",
|
||||
"started": "2026-01-07T10:32:29-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-07_11-06-31",
|
||||
"started": "2026-01-07T11:06:31-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user