Compare commits

..

60 Commits

Author SHA1 Message Date
OpenCode Test a9eaf0114f fix(external-llm): align tier defaults with industry benchmarks
- frontier: gpt-5.2 (GPT)
- mid-tier: claude-sonnet-4.5 (Claude)
- lightweight: claude-haiku-4.5 (Claude)

Prioritizes correctness over speed, aligned with MMLU/GPQA/Arena rankings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:42:38 -08:00
OpenCode Test f63172c4cf feat(external-llm): standardize tiers and optimize model selection
- Rename tiers: opus/sonnet/haiku → frontier/mid-tier/lightweight
- Align with industry benchmarks (MMLU, GPQA, Chatbot Arena)
- Add /external command for LLM mode control
- Fix invoke.py timeout passthrough (now 600s default)

Tier changes:
- Promote gemini-2.5-pro to frontier (benchmark-validated)
- Demote glm-4.7 to mid-tier then removed (unreliable)
- Promote gemini-2.5-flash to mid-tier

New models added:
- gpt-5-mini, gpt-5-nano (GPT family coverage)
- grok-code (Grok/X family)
- glm-4.5-air (lightweight GLM)

Removed (redundant/unreliable):
- o3 (not available)
- glm-4.7 (timeouts)
- gpt-4o, big-pickle, glm-4.5-flash (redundant)

Final: 11 models across 3 tiers, 4 model families

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 03:30:51 -08:00
OpenCode Test ff111ef278 fix(external-llm): correct o3 and glm-4.7 tiers
- github-copilot/o3: opus -> sonnet-equivalent
- zai-coding-plan/glm-4.7: sonnet -> opus-equivalent

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 08:13:20 -08:00
OpenCode Test bf5470ac66 fix(external-llm): correct gemini CLI model tiers
- gemini/gemini-2.5-pro: opus -> sonnet-equivalent
- gemini/gemini-2.5-flash: sonnet -> haiku-equivalent

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 00:02:35 -08:00
OpenCode Test c1e3b2881d feat(external-llm): add gemini-3-flash-preview via OpenCode
Gemini 3 models via github-copilot provider (OpenCode):
- github-copilot/gemini-3-pro-preview (opus-tier)
- github-copilot/gemini-3-flash-preview (sonnet-tier)

Native Gemini CLI models unchanged:
- gemini/gemini-2.5-pro
- gemini/gemini-2.5-flash

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 23:52:32 -08:00
OpenCode Test 4024740b82 fix(external-llm): remove non-existent gemini-3 models
Removed gemini/gemini-3-pro and gemini/gemini-3-flash from native
gemini CLI - these models return 404 Not Found.

Remaining gemini models (via native CLI):
- gemini/gemini-2.5-pro
- gemini/gemini-2.5-flash

Note: github-copilot/gemini-3-pro-preview (via opencode) kept for now.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:49:47 -08:00
OpenCode Test fb4cf1b035 fix(external-llm): correct opencode CLI syntax and gemini routing
- OpenCode: use `opencode run -m MODEL "prompt"` syntax
- OpenCode: set correct binary path (/home/linuxbrew/.linuxbrew/bin/opencode)
- Gemini: route long-context to gemini-2.5-pro (gemini-3 not available yet)

Tested working:
- opencode/big-pickle
- github-copilot/claude-sonnet-4.5
- zai-coding-plan/glm-4.7
- gemini/gemini-2.5-pro
- gemini/gemini-2.5-flash

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:46:38 -08:00
OpenCode Test d2daf74fca feat(external-llm): add native gemini CLI models
Added gemini CLI models:
- gemini/gemini-3-pro (long-context, reasoning)
- gemini/gemini-3-flash (fast, general)
- gemini/gemini-2.5-pro (long-context, reasoning)
- gemini/gemini-2.5-flash (fast, general)

Updated long-context routing to use native gemini CLI (gemini-3-pro)
instead of opencode/github-copilot path.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:37:45 -08:00
OpenCode Test e52e818686 chore: mark fc-004 external LLM integration as resolved
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:34:56 -08:00
OpenCode Test df6cf94dae feat(external-llm): add external LLM integration (fc-004)
Implements external LLM routing via opencode CLI for:
- GitHub Copilot (gpt-5.2, claude-sonnet-4.5, claude-haiku-4.5, o3, gemini-3-pro)
- Z.AI (glm-4.7 for code generation)
- OpenCode native (big-pickle)

Components:
- mcp/llm-router/invoke.py: Main router with task-based model selection
- mcp/llm-router/delegate.py: Agent delegation helper (respects external mode)
- mcp/llm-router/toggle.py: Enable/disable external-only mode
- mcp/llm-router/providers/: CLI wrappers for opencode and gemini

Features:
- Persistent toggle via state/external-mode.json
- Task routing: reasoning -> gpt-5.2, code-gen -> glm-4.7, long-context -> gemini
- Claude tier mapping: opus -> gpt-5.2, sonnet -> claude-sonnet-4.5, haiku -> claude-haiku-4.5
- Session-start hook announces when external mode is active
- Natural language toggle support via component registry

Plan: gleaming-routing-mercury

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:34:35 -08:00
OpenCode Test 7dcb8af1bb Set default model to opus in settings.json
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 13:49:30 -08:00
OpenCode Test 6be9bf5aff Mark OpenCode transposition plan as complete
- Implementation complete (2026-01-07)
- All steps verified: backup, sync script, config, testing, docs
- 40 agents discovered (built-in + synced)
- 10 skills, 27 commands, 10 workflows synced
- Manual TUI testing pending

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 13:45:59 -08:00
OpenCode Test 7c37e9adb3 Add OpenCode sync enhancement plan and future consideration
- Add fc-047: Consider JSON minification for OpenCode instructions
- Add brainstorming plan for OpenCode Claude sync enhancements
- Add implementation status plan

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:15:33 -08:00
OpenCode Test 0780b4c17d Document plans index in CLAUDE.md and plans/README.md
- Add plans/ to directory structure
- Add plans/index.json to shared state files table
- Add Plans row to component formats table
- Create plans/README.md with schema and query examples

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 11:28:56 -08:00
OpenCode Test a08dc505d9 Add plans index.json for status tracking
Central registry tracking all 17 plans with status, category, and dates.
Enables easy querying of pending vs implemented plans.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 11:27:18 -08:00
OpenCode Test c82726b691 Add RAG JSON-to-text transformation plan
Design for improving semantic search quality by transforming JSON
structures into natural language at index time.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 11:11:34 -08:00
OpenCode Test c14c0d843d Update ralph-loop to guardrail hooks task
Switch from completed morning-report to guardrail hooks implementation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 11:11:27 -08:00
OpenCode Test 0fd0e74b67 Track new PA session entries
Add session records for Jan 06 and Jan 07 sessions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 11:11:21 -08:00
OpenCode Test 1636784931 Enable tasks section in morning report, add daily archives
- Enable tasks collector in morning-report config
- Update morning.md with Jan 07 report
- Archive Jan 06 and Jan 07 reports

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 11:11:15 -08:00
OpenCode Test c30ea2d679 Update plugin metadata from auto-updates
Plugins refreshed their cache versions and timestamps.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 11:11:08 -08:00
OpenCode Test 769391640b Add telemetry directory to gitignore
Analytics tracking data should not be version controlled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 11:10:58 -08:00
OpenCode Test ecf375205f Implement guardrail hooks for dangerous operation prevention
- Add PreToolUse hook intercepting Bash, Write, Edit
- Block catastrophic commands (rm -rf /, mkfs, etc.)
- Require confirmation for operations outside safe paths
- Git-aware: operations in git repos are allowed
- Session allowlist for user-confirmed operations
- Audit logging to logs/guardrail.jsonl
- Clear session allowlist on SessionEnd

Config: state/guardrails.json
Scripts: hooks/scripts/guardrail.py, guardrail-confirm.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 10:57:53 -08:00
OpenCode Test f2f8a03a32 Add guardrail hooks design document
PreToolUse hooks to prevent dangerous operations:
- Intercepts Bash, Write, Edit before execution
- Contextual response (block vs confirm)
- Path-aware with git repo detection
- Session allowlist for user confirmations
- Audit logging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 10:50:03 -08:00
OpenCode Test 630893f047 Add conditional RAG reindex after session summarization
When summarize-transcript.py extracts items to memory files, it now
triggers index_personal.py to update the RAG search index. Only runs
when items were actually added (total_added > 0) to avoid unnecessary
reindexing on trivial sessions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 10:32:04 -08:00
OpenCode Test 928fa7191b Add hybrid format validation to validate-setup.sh
- Check agents/*.md, commands/*.md, workflows/*.yaml, state/*.json
- Add gtasks and other missing skills to validation list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:47:46 -08:00
OpenCode Test fae8730477 Add /tasks to README documentation
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:45:17 -08:00
OpenCode Test 7e563bd334 Add /tasks skill for Google Tasks
- New gtasks skill with thin wrapper around morning-report collector
- Register /tasks command with /todo, /todos aliases
- Design doc at docs/plans/2026-01-05-gtasks-skill-design.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:15:15 -08:00
OpenCode Test 9ae8ff85c3 Update local config and plugin metadata
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:01:02 -08:00
OpenCode Test f9e9be62bc Add pi50 resource optimization plan, mark monitoring design complete
- New plan: Improve pi50 control plane resource usage
- Completed: Workstation monitoring design status file

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:00:57 -08:00
OpenCode Test 5b9a85cd37 Update state: format future-considerations, add session history
- future-considerations: Pretty-print JSON, update fc-001 to pending status
- history/index: Add recent session entries

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:00:52 -08:00
OpenCode Test 91733f5460 Fix gtasks OAuth scope handling and add ArgoCD docs to RAG
- gtasks: Add force_reauth option to recover from invalid_scope errors
- rag-search: Index ArgoCD documentation for semantic search

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:00:46 -08:00
OpenCode Test 380e2005c8 Regenerate morning report for 2026-01-05
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:00:40 -08:00
OpenCode Test 62050faedc Add workstation monitoring design 2026-01-05 01:31:10 -08:00
OpenCode Test f3cb082c36 Regenerate morning report for 2026-01-04
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:44:40 -08:00
OpenCode Test db0d9f97b2 Update plugin timestamps
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:44:33 -08:00
OpenCode Test 94603b19a5 Update session history index
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:44:29 -08:00
OpenCode Test 45b7e4bcf7 Improve morning report collectors and add section toggling
- Add is_section_enabled() to support per-section enable/disable in config
- Update Python path from 3.13 to 3.14 for gmail venv
- Disable tasks section by default (enabled: false in config)
- Apply code formatting improvements (black/ruff style)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:44:24 -08:00
OpenCode Test 7ca8caeecb Implement rag-search skill for semantic search
Add new skill for semantic search across personal state files and
external documentation using ChromaDB and sentence-transformers.

Components:
- search.py: Main search interface (--index, --top-k flags)
- index_personal.py: Index ~/.claude/state files
- index_docs.py: Index external docs (git repos)
- add_doc_source.py: Manage doc sources
- test_rag.py: Test suite (5/5 passing)

Features:
- Two indexes: personal (116 chunks) and docs (k0s: 846 chunks)
- all-MiniLM-L6-v2 embeddings (384 dimensions)
- ChromaDB persistent storage
- JSON output with ranked results and metadata

Documentation:
- Added to component-registry.json with triggers
- Added /rag command alias
- Updated skills/README.md
- Resolved fc-013 (vector database for agent memory)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:41:38 -08:00
OpenCode Test c21b152de8 Add Agentic RAG design document
Design for extending Claude agent system with semantic search:
- Two indexes: personal (state files) + external docs
- ChromaDB + sentence-transformers stack
- rag-search skill with search.py CLI
- Daily systemd timer for index refresh
- Ralph loop implementation with Haiku/Sonnet delegation

Added future considerations (fc-043 to fc-046):
- Auto-sync on tool version change
- Broad doc indexing
- K8s deployment
- Query caching

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 14:08:00 -08:00
OpenCode Test 4fe8957482 Add instruction to keep repos clean by removing old branches 2026-01-04 13:29:04 -08:00
OpenCode Test 1b432f1c3f Add session summarization hook plan 2026-01-04 13:22:28 -08:00
OpenCode Test 383e2cbf38 Add morning report for 2026-01-04 2026-01-04 13:22:28 -08:00
OpenCode Test 1f5029cbb0 Update plugin timestamps and session history 2026-01-04 13:22:27 -08:00
OpenCode Test 89255cc6fa Add gitea-merge.sh for complete merge workflow
- Creates gitea-merge.sh: rebase merge, delete branch, close PR
- Updates CLAUDE.md: simplified git workflow using new script
- Adds model selection reminder to general-instructions.json
- Documents Gitea API commands in CLAUDE.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:18:23 -08:00
OpenCode Test 652ceb55f0 Add PR validation before creating PRs
- Create validate-pr.sh: runs shellcheck, JSON/YAML/Python syntax checks
- Update gitea-pr.sh: runs validation before creating PR
- Update CLAUDE.md: document PR review policy
  - ~/.claude repo: linting/validation only
  - Code repos: full code-reviewer agent review

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:06:31 -08:00
OpenCode Test 02f9cf7d8f Add Gitea PR automation script
- Create automation/gitea-pr.sh for auto-creating PRs via Gitea API
- Update CLAUDE.md with git workflow using the new script
- Deprecate redundant git instructions in PA general-instructions.json
- Token stored securely at ~/.config/gitea-token

Usage: gitea-pr.sh "PR Title" "Description"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:52:54 -08:00
OpenCode Test 2105803594 Merge feature/user-prompt-context-injection: Add UserPromptSubmit hook for context injection 2026-01-04 12:39:59 -08:00
OpenCode Test 73400a21ab Add UserPromptSubmit hook for context injection
Injects contextual information when user submits a prompt:
- Current time with period (morning/afternoon/evening/night)
- Git branch if in a repository
- Relevant memory items based on prompt keywords (2+ matches)
- Pending decisions needing attention

Design:
- Skips short prompts (<10 chars) to not slow down commands
- 5s timeout to keep prompts responsive
- Lightweight keyword matching for memory relevance

Also updates general-instructions.json with git workflow notes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:34:39 -08:00
OpenCode Test 56b455a074 Merge feature/session-summarization-hook: Add SessionEnd hook for automatic session summarization 2026-01-04 12:28:19 -08:00
OpenCode Test f07022ca60 Add SessionEnd hook for automatic session summarization
Implements automatic memory extraction when Claude Code sessions end:
- Add SessionEnd hook to hooks.json with 120s timeout
- Create session-end.sh wrapper that parses hook input and runs summarizer
- Create summarize-transcript.py that:
  - Loads transcript from Claude's storage
  - Skips trivial sessions (<3 user messages)
  - Extracts paths/facts via heuristics
  - Uses Claude CLI (subscription auth) for decisions/preferences
  - Saves to memory files with deduplication
  - Updates history index with summarized flag

Uses `claude -p --model haiku` for LLM extraction, leveraging
existing subscription credentials instead of requiring API key.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 14:17:51 -08:00
OpenCode Test 91fa0608d0 Update known_marketplaces.json lastUpdated timestamp 2026-01-03 13:53:43 -08:00
OpenCode Test e43e052a32 Add design plans for dashboard integration
Add implementation plans for morning report, Claude ops dashboard, and realtime monitoring features.
2026-01-03 10:55:22 -08:00
OpenCode Test 6ef58472cf Add morning reports and local configuration
Add daily morning reports and loop configuration for ralph agent.
2026-01-03 10:55:18 -08:00
OpenCode Test 48a1c9cd1d Update settings configuration
Configure Claude integration settings.
2026-01-03 10:55:13 -08:00
OpenCode Test 343d2e4237 Update component registry and system state
Register new skills and update future considerations for Claude dashboard integration.
2026-01-03 10:55:07 -08:00
OpenCode Test c21665284a Update plugin cache and installation state
Refresh plugin install counts and update installed plugins registry.
2026-01-03 10:55:01 -08:00
OpenCode Test daa4de8832 Add morning-report and stock-lookup skills
Add comprehensive morning report skill with collectors for calendar, email, tasks,
infrastructure status, news, stocks, and weather. Add stock lookup skill for quote queries.
2026-01-03 10:54:54 -08:00
OpenCode Test ae958528a6 Add Claude integration to dashboard
Add comprehensive Claude Code monitoring and realtime streaming to the K8s dashboard.
Includes API endpoints for health, stats, summary, inventory, and live event streaming.
Frontend provides overview, usage, inventory, debug, and live feed views.
2026-01-03 10:54:48 -08:00
OpenCode Test de89f3066c Add /diff and /template commands
- /diff command to compare config with backups
  - Shows added/removed/changed files
  - JSON-aware comparison for config files
  - List available backups
- /template command for session templates
  - Built-in templates: daily-standup, code-review, troubleshoot, deploy
  - Each template includes checklist, initial commands, prompt
  - Create custom templates interactively or non-interactively
- Updated shell completions with 21 aliases total
- Test suite now covers 29 tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 19:06:14 -08:00
OpenCode Test 4169f5b9a4 Add /workflow, /skill-info, and /agent-info commands
- /workflow command to list and describe available workflows
  - Filter by category (health, deploy, incidents, sysadmin)
  - Show workflow steps and triggers
- /skill-info command for skill introspection
  - List scripts, triggers, and allowed tools
  - Show references and documentation
- /agent-info command with hierarchy visualization
  - Tree view of agent relationships
  - Model assignments (opus/sonnet/haiku) with visual indicators
  - Supervisor and subordinate information
- Updated shell completions with 19 aliases total
- Test suite now covers 27 tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 19:02:42 -08:00
123 changed files with 15701 additions and 223 deletions
+23
View File
@@ -0,0 +1,23 @@
---
active: true
iteration: 1
max_iterations: 20
completion_promise: "Guardrail hooks are fully implemented, tested, and registered"
started_at: "2026-01-07T18:52:10Z"
---
Implement guardrail hooks following the design at ~/.claude/docs/plans/2025-01-06-guardrail-hooks-design.md
Implementation order:
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.
+7
View File
@@ -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/
+42
View File
@@ -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/` |
+3
View File
@@ -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 |
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""
Show information about available agents.
Usage: python3 agent-info.py [--tree] [name]
"""
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional
CLAUDE_DIR = Path.home() / ".claude"
AGENTS_DIR = CLAUDE_DIR / "agents"
REGISTRY_PATH = CLAUDE_DIR / "state" / "component-registry.json"
# Agent hierarchy (from CLAUDE.md)
HIERARCHY = {
"personal-assistant": {
"supervisor": None,
"subordinates": ["master-orchestrator"]
},
"master-orchestrator": {
"supervisor": "personal-assistant",
"subordinates": ["linux-sysadmin", "k8s-orchestrator", "programmer-orchestrator"]
},
"linux-sysadmin": {
"supervisor": "master-orchestrator",
"subordinates": []
},
"k8s-orchestrator": {
"supervisor": "master-orchestrator",
"subordinates": ["k8s-diagnostician", "argocd-operator", "prometheus-analyst", "git-operator"]
},
"k8s-diagnostician": {
"supervisor": "k8s-orchestrator",
"subordinates": []
},
"argocd-operator": {
"supervisor": "k8s-orchestrator",
"subordinates": []
},
"prometheus-analyst": {
"supervisor": "k8s-orchestrator",
"subordinates": []
},
"git-operator": {
"supervisor": "k8s-orchestrator",
"subordinates": []
},
"programmer-orchestrator": {
"supervisor": "master-orchestrator",
"subordinates": ["code-planner", "code-implementer", "code-reviewer"]
},
"code-planner": {
"supervisor": "programmer-orchestrator",
"subordinates": []
},
"code-implementer": {
"supervisor": "programmer-orchestrator",
"subordinates": []
},
"code-reviewer": {
"supervisor": "programmer-orchestrator",
"subordinates": []
}
}
def load_registry() -> Dict:
"""Load component registry."""
try:
with open(REGISTRY_PATH) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def find_agent_files() -> List[Path]:
"""Find all agent markdown files."""
if not AGENTS_DIR.exists():
return []
return [f for f in AGENTS_DIR.glob("*.md")
if f.name != "README.md"]
def parse_agent_md(path: Path) -> Dict:
"""Parse an agent markdown file for metadata."""
try:
content = path.read_text()
result = {
"name": path.stem,
"path": str(path.relative_to(CLAUDE_DIR)),
"description": "",
"model": "unknown",
"tools": [],
}
# Parse YAML frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 2:
frontmatter = parts[1]
for line in frontmatter.strip().split("\n"):
if ":" in line:
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if key == "name":
result["name"] = value
elif key == "description":
result["description"] = value
elif key == "model":
result["model"] = value
elif key == "tools":
result["tools"] = [t.strip() for t in value.split(",")]
return result
except Exception as e:
return {"name": path.stem, "error": str(e)}
def get_model_emoji(model: str) -> str:
"""Get emoji for model type."""
return {
"opus": "🔷",
"sonnet": "🔶",
"haiku": "🔸"
}.get(model.lower(), "")
def list_agents():
"""List all available agents."""
registry = load_registry()
reg_agents = registry.get("agents", {})
print(f"\n🤖 Available Agents ({len(reg_agents)})\n")
# Group by model
by_model = {"opus": [], "sonnet": [], "haiku": [], "unknown": []}
for name, info in reg_agents.items():
model = info.get("model", "unknown")
by_model.get(model, by_model["unknown"]).append({
"name": name,
"description": info.get("description", "No description"),
"triggers": info.get("triggers", [])
})
for model in ["opus", "sonnet", "haiku"]:
agents = by_model[model]
if not agents:
continue
emoji = get_model_emoji(model)
print(f"=== {model.title()} {emoji} ===")
for agent in sorted(agents, key=lambda a: a["name"]):
print(f" {agent['name']}")
print(f" {agent['description']}")
if agent['triggers']:
print(f" Triggers: {', '.join(agent['triggers'][:3])}")
print("")
def show_tree():
"""Show agent hierarchy as a tree."""
print(f"\n🌳 Agent Hierarchy\n")
def print_tree(name: str, prefix: str = "", is_last: bool = True):
info = HIERARCHY.get(name, {})
registry = load_registry()
reg_info = registry.get("agents", {}).get(name, {})
model = reg_info.get("model", "?")
emoji = get_model_emoji(model)
connector = "└── " if is_last else "├── "
print(f"{prefix}{connector}{name} {emoji} ({model})")
new_prefix = prefix + (" " if is_last else "")
subordinates = info.get("subordinates", [])
for i, sub in enumerate(subordinates):
print_tree(sub, new_prefix, i == len(subordinates) - 1)
# Start from root
print_tree("personal-assistant")
print("")
print("Legend: 🔷 opus 🔶 sonnet 🔸 haiku")
print("")
def show_agent(name: str):
"""Show details for a specific agent."""
registry = load_registry()
reg_agents = registry.get("agents", {})
# Find matching agent
matches = [n for n in reg_agents.keys() if name.lower() in n.lower()]
if not matches:
print(f"Agent '{name}' not found.")
print("\nAvailable agents:")
for n in sorted(reg_agents.keys()):
print(f" - {n}")
return
if len(matches) > 1 and name not in matches:
print(f"Multiple matches for '{name}':")
for m in matches:
print(f" - {m}")
return
agent_name = name if name in matches else matches[0]
reg_info = reg_agents[agent_name]
print(f"\n🤖 Agent: {agent_name}\n")
model = reg_info.get("model", "unknown")
print(f"Model: {model} {get_model_emoji(model)}")
print(f"Description: {reg_info.get('description', 'No description')}")
# Triggers
triggers = reg_info.get("triggers", [])
if triggers:
print(f"\nTriggers:")
for t in triggers:
print(f" - {t}")
# Hierarchy
hier = HIERARCHY.get(agent_name, {})
supervisor = hier.get("supervisor")
subordinates = hier.get("subordinates", [])
print(f"\nHierarchy:")
if supervisor:
print(f" Supervisor: {supervisor}")
else:
print(f" Supervisor: (top-level)")
if subordinates:
print(f" Subordinates:")
for sub in subordinates:
sub_info = reg_agents.get(sub, {})
sub_model = sub_info.get("model", "?")
print(f" - {sub} ({sub_model})")
# Check for agent file
agent_file = AGENTS_DIR / f"{agent_name}.md"
if agent_file.exists():
print(f"\nFile: agents/{agent_name}.md")
file_info = parse_agent_md(agent_file)
if file_info.get("tools"):
print(f"Tools: {', '.join(file_info['tools'])}")
print("")
def main():
parser = argparse.ArgumentParser(description="Show agent information")
parser.add_argument("name", nargs="?", help="Agent name to show details")
parser.add_argument("--tree", "-t", action="store_true",
help="Show agent hierarchy tree")
parser.add_argument("--list", "-l", action="store_true", help="List all agents")
args = parser.parse_args()
if args.tree:
show_tree()
elif args.name and not args.list:
show_agent(args.name)
else:
list_agents()
if __name__ == "__main__":
main()
+43 -2
View File
@@ -51,6 +51,36 @@ _claude_upgrade() {
COMPREPLY=($(compgen -W "--check --backup --apply --help" -- "${cur}"))
}
_claude_workflow() {
local cur="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=($(compgen -W "--category --list" -- "${cur}"))
}
_claude_skill_info() {
local cur="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=($(compgen -W "--scripts --list" -- "${cur}"))
}
_claude_agent_info() {
local cur="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=($(compgen -W "--tree --list" -- "${cur}"))
}
_claude_diff() {
local cur="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=($(compgen -W "--backup --list --json" -- "${cur}"))
}
_claude_template() {
local cur="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=($(compgen -W "--list --create --use --delete" -- "${cur}"))
}
_claude_memory_add() {
local cur="${COMP_WORDS[COMP_CWORD]}"
local prev="${COMP_WORDS[COMP_CWORD-1]}"
@@ -87,6 +117,11 @@ complete -F _claude_debug debug.sh
complete -F _claude_export session-export.py
complete -F _claude_mcp_status mcp-status.sh
complete -F _claude_upgrade upgrade.sh
complete -F _claude_workflow workflow-info.py
complete -F _claude_skill_info skill-info.py
complete -F _claude_agent_info agent-info.py
complete -F _claude_diff config-diff.py
complete -F _claude_template session-template.py
# Alias completions for convenience
alias claude-validate='~/.claude/automation/validate-setup.sh'
@@ -106,7 +141,13 @@ alias claude-debug='~/.claude/automation/debug.sh'
alias claude-export='python3 ~/.claude/automation/session-export.py'
alias claude-mcp='~/.claude/automation/mcp-status.sh'
alias claude-upgrade='~/.claude/automation/upgrade.sh'
alias claude-workflow='python3 ~/.claude/automation/workflow-info.py'
alias claude-skill='python3 ~/.claude/automation/skill-info.py'
alias claude-agent='python3 ~/.claude/automation/agent-info.py'
alias claude-diff='python3 ~/.claude/automation/config-diff.py'
alias claude-template='python3 ~/.claude/automation/session-template.py'
echo "Claude Code completions loaded. Available aliases:"
echo " claude-{validate,status,backup,restore,clean,memory-add,memory-list}"
echo " claude-{search,history,install,test,maintenance,log,debug,export,mcp,upgrade}"
echo " claude-{validate,status,backup,restore,clean,memory-add,memory-list,search}"
echo " claude-{history,install,test,maintenance,log,debug,export,mcp,upgrade}"
echo " claude-{workflow,skill,agent,diff,template}"
+55 -2
View File
@@ -107,6 +107,48 @@ _claude_upgrade() {
'--help[Show help]'
}
# Workflow completion
_claude_workflow() {
_arguments \
'--category[Filter by category]:category:(health deploy incidents sysadmin)' \
'--list[List all workflows]' \
'*:name:'
}
# Skill info completion
_claude_skill_info() {
_arguments \
'--scripts[Show scripts in listing]' \
'--list[List all skills]' \
'*:name:'
}
# Agent info completion
_claude_agent_info() {
_arguments \
'--tree[Show hierarchy tree]' \
'--list[List all agents]' \
'*:name:'
}
# Config diff completion
_claude_diff() {
_arguments \
'--backup[Backup file]:file:_files' \
'--list[List backups]' \
'--json[JSON output]'
}
# Template completion
_claude_template() {
_arguments \
'--list[List templates]' \
'--create[Create template]:name:' \
'--use[Use template]:name:' \
'--delete[Delete template]:name:' \
'--non-interactive[Non-interactive mode]'
}
# Register completions
compdef _memory_add memory-add.py
compdef _memory_list memory-list.py
@@ -118,6 +160,11 @@ compdef _claude_debug debug.sh
compdef _claude_export session-export.py
compdef _claude_mcp_status mcp-status.sh
compdef _claude_upgrade upgrade.sh
compdef _claude_workflow workflow-info.py
compdef _claude_skill_info skill-info.py
compdef _claude_agent_info agent-info.py
compdef _claude_diff config-diff.py
compdef _claude_template session-template.py
# Aliases
alias claude-validate='~/.claude/automation/validate-setup.sh'
@@ -137,7 +184,13 @@ alias claude-debug='~/.claude/automation/debug.sh'
alias claude-export='python3 ~/.claude/automation/session-export.py'
alias claude-mcp='~/.claude/automation/mcp-status.sh'
alias claude-upgrade='~/.claude/automation/upgrade.sh'
alias claude-workflow='python3 ~/.claude/automation/workflow-info.py'
alias claude-skill='python3 ~/.claude/automation/skill-info.py'
alias claude-agent='python3 ~/.claude/automation/agent-info.py'
alias claude-diff='python3 ~/.claude/automation/config-diff.py'
alias claude-template='python3 ~/.claude/automation/session-template.py'
echo "Claude Code completions loaded (zsh)"
echo " Aliases: claude-{validate,status,backup,restore,clean,memory-add,memory-list}"
echo " claude-{search,history,install,test,maintenance,log,debug,export,mcp,upgrade}"
echo " Aliases: claude-{validate,status,backup,restore,clean,memory-add,memory-list,search}"
echo " claude-{history,install,test,maintenance,log,debug,export,mcp,upgrade}"
echo " claude-{workflow,skill,agent,diff,template}"
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""
Compare current configuration with backup or default.
Usage: python3 config-diff.py [--backup FILE] [--default] [--json]
"""
import argparse
import json
import os
import sys
import tarfile
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
CLAUDE_DIR = Path.home() / ".claude"
BACKUP_DIR = CLAUDE_DIR / "backups"
def load_json_safe(path: Path) -> Optional[Dict]:
"""Load JSON file safely."""
try:
with open(path) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return None
def get_config_files() -> List[Path]:
"""Get list of configuration files to compare."""
patterns = [
"CLAUDE.md",
"VERSION",
".claude-plugin/plugin.json",
"hooks/hooks.json",
"state/*.json",
"state/**/*.json",
]
files = []
for pattern in patterns:
files.extend(CLAUDE_DIR.glob(pattern))
return sorted(files)
def extract_backup(backup_path: Path) -> Path:
"""Extract backup to temporary directory."""
temp_dir = Path(tempfile.mkdtemp())
with tarfile.open(backup_path, "r:gz") as tar:
tar.extractall(temp_dir)
return temp_dir
def compare_files(current: Path, other: Path) -> Dict:
"""Compare two files and return differences."""
result = {
"current_exists": current.exists(),
"other_exists": other.exists(),
"same": False,
"diff": None
}
if not current.exists() and not other.exists():
result["same"] = True
return result
if not current.exists() or not other.exists():
return result
# Compare content
try:
current_content = current.read_text()
other_content = other.read_text()
if current_content == other_content:
result["same"] = True
return result
# For JSON files, do structural comparison
if current.suffix == ".json":
try:
current_json = json.loads(current_content)
other_json = json.loads(other_content)
# Find differences
result["diff"] = {
"type": "json",
"added": [],
"removed": [],
"changed": []
}
# Simple key comparison for top-level
current_keys = set(current_json.keys()) if isinstance(current_json, dict) else set()
other_keys = set(other_json.keys()) if isinstance(other_json, dict) else set()
result["diff"]["added"] = list(current_keys - other_keys)
result["diff"]["removed"] = list(other_keys - current_keys)
# Check for changed values
for key in current_keys & other_keys:
if current_json.get(key) != other_json.get(key):
result["diff"]["changed"].append(key)
return result
except json.JSONDecodeError:
pass
# Text comparison
current_lines = current_content.split("\n")
other_lines = other_content.split("\n")
result["diff"] = {
"type": "text",
"current_lines": len(current_lines),
"other_lines": len(other_lines),
"line_diff": len(current_lines) - len(other_lines)
}
except Exception as e:
result["error"] = str(e)
return result
def get_latest_backup() -> Optional[Path]:
"""Get the most recent backup file."""
if not BACKUP_DIR.exists():
return None
backups = list(BACKUP_DIR.glob("*.tar.gz"))
if not backups:
return None
return max(backups, key=lambda p: p.stat().st_mtime)
def compare_with_backup(backup_path: Path) -> Dict[str, Dict]:
"""Compare current config with a backup."""
temp_dir = extract_backup(backup_path)
try:
results = {}
config_files = get_config_files()
for current_file in config_files:
rel_path = current_file.relative_to(CLAUDE_DIR)
backup_file = temp_dir / rel_path
results[str(rel_path)] = compare_files(current_file, backup_file)
# Check for files in backup that don't exist in current
for backup_file in temp_dir.rglob("*.json"):
rel_path = backup_file.relative_to(temp_dir)
current_file = CLAUDE_DIR / rel_path
if str(rel_path) not in results:
results[str(rel_path)] = compare_files(current_file, backup_file)
return results
finally:
# Cleanup temp directory
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
def format_results(results: Dict[str, Dict], json_output: bool = False) -> str:
"""Format comparison results."""
if json_output:
return json.dumps(results, indent=2)
lines = ["\n📊 Configuration Diff\n"]
# Group by status
same = []
added = []
removed = []
changed = []
for path, info in results.items():
if info.get("same"):
same.append(path)
elif info.get("current_exists") and not info.get("other_exists"):
added.append(path)
elif not info.get("current_exists") and info.get("other_exists"):
removed.append(path)
else:
changed.append((path, info))
# Summary
lines.append(f"Unchanged: {len(same)}")
lines.append(f"Added: {len(added)}")
lines.append(f"Removed: {len(removed)}")
lines.append(f"Changed: {len(changed)}")
lines.append("")
# Details
if added:
lines.append("=== Added Files ===")
for path in added:
lines.append(f" + {path}")
lines.append("")
if removed:
lines.append("=== Removed Files ===")
for path in removed:
lines.append(f" - {path}")
lines.append("")
if changed:
lines.append("=== Changed Files ===")
for path, info in changed:
lines.append(f" ~ {path}")
diff = info.get("diff", {})
if diff:
if diff.get("type") == "json":
if diff.get("added"):
lines.append(f" Added keys: {', '.join(diff['added'][:5])}")
if diff.get("removed"):
lines.append(f" Removed keys: {', '.join(diff['removed'][:5])}")
if diff.get("changed"):
lines.append(f" Changed keys: {', '.join(diff['changed'][:5])}")
elif diff.get("type") == "text":
lines.append(f" Lines: {diff['other_lines']} -> {diff['current_lines']}")
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Compare configuration with backup")
parser.add_argument("--backup", "-b", type=str,
help="Backup file to compare with (default: latest)")
parser.add_argument("--list", "-l", action="store_true",
help="List available backups")
parser.add_argument("--json", "-j", action="store_true",
help="Output as JSON")
args = parser.parse_args()
if args.list:
if not BACKUP_DIR.exists():
print("No backups directory found.")
return 0
backups = sorted(BACKUP_DIR.glob("*.tar.gz"),
key=lambda p: p.stat().st_mtime, reverse=True)
if not backups:
print("No backups found.")
return 0
print("\n📦 Available Backups\n")
for b in backups[:10]:
mtime = datetime.fromtimestamp(b.stat().st_mtime)
size = b.stat().st_size
if size > 1024 * 1024:
size_str = f"{size / 1024 / 1024:.1f}M"
else:
size_str = f"{size / 1024:.1f}K"
print(f" {b.name:<40} {size_str:<8} {mtime:%Y-%m-%d %H:%M}")
return 0
# Get backup to compare
if args.backup:
backup_path = Path(args.backup)
if not backup_path.exists():
backup_path = BACKUP_DIR / args.backup
if not backup_path.exists():
print(f"Backup not found: {args.backup}")
return 1
else:
backup_path = get_latest_backup()
if not backup_path:
print("No backups found. Create one with: claude-backup")
return 1
print(f"Comparing with: {backup_path.name}")
results = compare_with_backup(backup_path)
print(format_results(results, args.json))
return 0
if __name__ == "__main__":
sys.exit(main())
+88
View File
@@ -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"
+71
View File
@@ -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
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""
Manage session templates for common workflows.
Usage: python3 session-template.py [--list|--create NAME|--use NAME|--delete NAME]
"""
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
CLAUDE_DIR = Path.home() / ".claude"
TEMPLATES_DIR = CLAUDE_DIR / "state" / "personal-assistant" / "templates"
def ensure_templates_dir():
"""Ensure templates directory exists."""
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
def load_template(name: str) -> Optional[Dict]:
"""Load a template by name."""
template_file = TEMPLATES_DIR / f"{name}.json"
try:
with open(template_file) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return None
def save_template(name: str, template: Dict):
"""Save a template."""
ensure_templates_dir()
template_file = TEMPLATES_DIR / f"{name}.json"
with open(template_file, "w") as f:
json.dump(template, f, indent=2)
def list_templates():
"""List available templates."""
ensure_templates_dir()
templates = list(TEMPLATES_DIR.glob("*.json"))
if not templates:
print("\nNo templates found. Create one with --create <name>")
print("\nBuilt-in templates you can create:")
print(" - daily-standup: Morning status check and planning")
print(" - code-review: Review code changes")
print(" - troubleshoot: Debug an issue")
print(" - deploy: Deployment workflow")
return
print(f"\n📋 Session Templates ({len(templates)})\n")
for t in sorted(templates):
template = load_template(t.stem)
if template:
desc = template.get("description", "No description")
category = template.get("category", "general")
print(f" {t.stem}")
print(f" Category: {category}")
print(f" {desc}")
print("")
def create_template(name: str, interactive: bool = True):
"""Create a new template."""
ensure_templates_dir()
# Check for built-in templates
builtin_templates = {
"daily-standup": {
"name": "daily-standup",
"description": "Morning status check and planning",
"category": "routine",
"context_level": "moderate",
"initial_commands": ["/status"],
"checklist": [
"Check system health",
"Review pending items",
"Check calendar",
"Plan priorities"
],
"prompt_template": "Good morning! Let's start with a quick status check and plan the day."
},
"code-review": {
"name": "code-review",
"description": "Review code changes in a project",
"category": "development",
"context_level": "comprehensive",
"initial_commands": [],
"checklist": [
"Understand the change scope",
"Check for issues",
"Verify tests",
"Suggest improvements"
],
"prompt_template": "Please review the recent changes in {project}. Focus on {focus_areas}."
},
"troubleshoot": {
"name": "troubleshoot",
"description": "Debug an issue systematically",
"category": "debugging",
"context_level": "comprehensive",
"initial_commands": ["/debug"],
"checklist": [
"Gather symptoms",
"Check logs",
"Identify root cause",
"Implement fix",
"Verify resolution"
],
"prompt_template": "I'm experiencing {issue}. Let's debug this systematically."
},
"deploy": {
"name": "deploy",
"description": "Deploy application to environment",
"category": "operations",
"context_level": "moderate",
"initial_commands": ["/k8s:cluster-status"],
"checklist": [
"Verify cluster health",
"Check application readiness",
"Perform deployment",
"Verify deployment",
"Monitor for issues"
],
"prompt_template": "Deploy {app} to {environment}."
}
}
if name in builtin_templates:
template = builtin_templates[name]
save_template(name, template)
print(f"✓ Created template from built-in: {name}")
print(f" {template['description']}")
return
if interactive:
print(f"\nCreating new template: {name}\n")
template = {
"name": name,
"created": datetime.now().isoformat(),
}
# Get details interactively
template["description"] = input("Description: ").strip() or "No description"
template["category"] = input("Category [general]: ").strip() or "general"
template["context_level"] = input("Context level [moderate]: ").strip() or "moderate"
print("Initial commands (comma-separated, e.g., /status,/debug): ")
commands = input("> ").strip()
template["initial_commands"] = [c.strip() for c in commands.split(",") if c.strip()]
print("Checklist items (one per line, empty line to finish):")
checklist = []
while True:
item = input(" - ").strip()
if not item:
break
checklist.append(item)
template["checklist"] = checklist
template["prompt_template"] = input("Prompt template: ").strip()
save_template(name, template)
print(f"\n✓ Template '{name}' created successfully")
else:
# Create minimal template
template = {
"name": name,
"description": "Custom template",
"category": "custom",
"context_level": "moderate",
"initial_commands": [],
"checklist": [],
"prompt_template": "",
"created": datetime.now().isoformat()
}
save_template(name, template)
print(f"✓ Created empty template: {name}")
print(f" Edit: {TEMPLATES_DIR / f'{name}.json'}")
def use_template(name: str):
"""Display template for use."""
template = load_template(name)
if not template:
print(f"Template '{name}' not found.")
list_templates()
return
print(f"\n📋 Template: {name}\n")
print(f"Description: {template.get('description', 'No description')}")
print(f"Category: {template.get('category', 'general')}")
print(f"Context: {template.get('context_level', 'moderate')}")
commands = template.get("initial_commands", [])
if commands:
print(f"\nInitial commands:")
for cmd in commands:
print(f" {cmd}")
checklist = template.get("checklist", [])
if checklist:
print(f"\nChecklist:")
for i, item in enumerate(checklist, 1):
print(f" [ ] {item}")
prompt = template.get("prompt_template", "")
if prompt:
print(f"\nPrompt template:")
print(f" {prompt}")
print("")
def delete_template(name: str):
"""Delete a template."""
template_file = TEMPLATES_DIR / f"{name}.json"
if not template_file.exists():
print(f"Template '{name}' not found.")
return
template_file.unlink()
print(f"✓ Deleted template: {name}")
def main():
parser = argparse.ArgumentParser(description="Manage session templates")
parser.add_argument("--list", "-l", action="store_true", help="List templates")
parser.add_argument("--create", "-c", type=str, help="Create a template")
parser.add_argument("--use", "-u", type=str, help="Use a template")
parser.add_argument("--delete", "-d", type=str, help="Delete a template")
parser.add_argument("--non-interactive", "-n", action="store_true",
help="Non-interactive mode for --create")
args = parser.parse_args()
if args.create:
create_template(args.create, interactive=not args.non_interactive)
elif args.use:
use_template(args.use)
elif args.delete:
delete_template(args.delete)
else:
list_templates()
if __name__ == "__main__":
main()
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
Show information about available skills.
Usage: python3 skill-info.py [--scripts] [name]
"""
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional
CLAUDE_DIR = Path.home() / ".claude"
SKILLS_DIR = CLAUDE_DIR / "skills"
REGISTRY_PATH = CLAUDE_DIR / "state" / "component-registry.json"
def load_registry() -> Dict:
"""Load component registry."""
try:
with open(REGISTRY_PATH) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def find_skills() -> List[Path]:
"""Find all skill directories with SKILL.md."""
if not SKILLS_DIR.exists():
return []
return [d for d in SKILLS_DIR.iterdir()
if d.is_dir() and (d / "SKILL.md").exists()]
def parse_skill_md(path: Path) -> Dict:
"""Parse a SKILL.md file for metadata."""
try:
content = path.read_text()
result = {
"name": path.parent.name,
"path": str(path.relative_to(CLAUDE_DIR)),
"description": "",
"allowed_tools": [],
}
# Parse YAML frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 2:
frontmatter = parts[1]
for line in frontmatter.strip().split("\n"):
if ":" in line:
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if key == "description":
result["description"] = value
elif key == "allowed-tools":
result["allowed_tools"] = [t.strip() for t in value.split(",")]
# Get first paragraph as description if not in frontmatter
if not result["description"]:
body = content.split("---")[-1] if "---" in content else content
lines = body.strip().split("\n\n")
for para in lines:
if para.strip() and not para.startswith("#"):
result["description"] = para.strip()[:200]
break
return result
except Exception as e:
return {"name": path.parent.name, "error": str(e)}
def get_skill_scripts(skill_dir: Path) -> List[str]:
"""Get list of scripts in a skill's scripts/ directory."""
scripts_dir = skill_dir / "scripts"
if not scripts_dir.exists():
return []
scripts = []
for f in scripts_dir.iterdir():
if f.is_file() and f.suffix in [".py", ".sh"]:
scripts.append(f.name)
return sorted(scripts)
def get_skill_references(skill_dir: Path) -> List[str]:
"""Get list of reference files in a skill's references/ directory."""
refs_dir = skill_dir / "references"
if not refs_dir.exists():
return []
refs = []
for f in refs_dir.iterdir():
if f.is_file():
refs.append(f.name)
return sorted(refs)
def list_skills(show_scripts: bool = False):
"""List all available skills."""
registry = load_registry()
reg_skills = registry.get("skills", {})
skills = find_skills()
if not skills:
print("No skills found.")
return
print(f"\n🎯 Available Skills ({len(skills)})\n")
for skill_dir in sorted(skills):
name = skill_dir.name
skill_info = parse_skill_md(skill_dir / "SKILL.md")
reg_info = reg_skills.get(name, {})
desc = skill_info.get("description", reg_info.get("description", "No description"))
if len(desc) > 80:
desc = desc[:77] + "..."
print(f" {name}")
print(f" {desc}")
if show_scripts:
scripts = get_skill_scripts(skill_dir)
if scripts:
print(f" Scripts: {', '.join(scripts)}")
triggers = reg_info.get("triggers", [])
if triggers:
trigger_str = ", ".join(triggers[:4])
if len(triggers) > 4:
trigger_str += f" (+{len(triggers)-4} more)"
print(f" Triggers: {trigger_str}")
print("")
def show_skill(name: str):
"""Show details for a specific skill."""
# Find matching skill
skills = find_skills()
matches = [s for s in skills if name.lower() in s.name.lower()]
if not matches:
print(f"Skill '{name}' not found.")
print("\nAvailable skills:")
for s in sorted(skills):
print(f" - {s.name}")
return
if len(matches) > 1 and not any(s.name == name for s in matches):
print(f"Multiple matches for '{name}':")
for s in matches:
print(f" - {s.name}")
return
skill_dir = next((s for s in matches if s.name == name), matches[0])
skill_info = parse_skill_md(skill_dir / "SKILL.md")
registry = load_registry()
reg_info = registry.get("skills", {}).get(skill_dir.name, {})
print(f"\n🎯 Skill: {skill_dir.name}\n")
print(f"Path: {skill_dir.relative_to(CLAUDE_DIR)}/")
print(f"Description: {skill_info.get('description', 'No description')}")
# Allowed tools
allowed = skill_info.get("allowed_tools", [])
if allowed:
print(f"\nAllowed Tools: {', '.join(allowed)}")
# Triggers
triggers = reg_info.get("triggers", [])
if triggers:
print(f"\nTriggers:")
for t in triggers:
print(f" - {t}")
# Scripts
scripts = get_skill_scripts(skill_dir)
if scripts:
print(f"\nScripts:")
for s in scripts:
script_path = skill_dir / "scripts" / s
executable = "" if script_path.stat().st_mode & 0o111 else ""
print(f" {executable} {s}")
# References
refs = get_skill_references(skill_dir)
if refs:
print(f"\nReferences:")
for r in refs:
print(f" - {r}")
# Registry script
if "script" in reg_info:
print(f"\nRegistry Script: {reg_info['script']}")
print("")
def main():
parser = argparse.ArgumentParser(description="Show skill information")
parser.add_argument("name", nargs="?", help="Skill name to show details")
parser.add_argument("--scripts", "-s", action="store_true",
help="Show scripts in listing")
parser.add_argument("--list", "-l", action="store_true", help="List all skills")
args = parser.parse_args()
if args.name and not args.list:
show_skill(args.name)
else:
list_skills(args.scripts)
if __name__ == "__main__":
main()
+35
View File
@@ -85,6 +85,41 @@ else
fail "session-export.py syntax error"
fi
# Test 10: workflow-info.py
if python3 -m py_compile "${AUTOMATION_DIR}/workflow-info.py" 2>/dev/null; then
pass "workflow-info.py syntax valid"
else
fail "workflow-info.py syntax error"
fi
# Test 11: skill-info.py
if python3 -m py_compile "${AUTOMATION_DIR}/skill-info.py" 2>/dev/null; then
pass "skill-info.py syntax valid"
else
fail "skill-info.py syntax error"
fi
# Test 12: agent-info.py
if python3 -m py_compile "${AUTOMATION_DIR}/agent-info.py" 2>/dev/null; then
pass "agent-info.py syntax valid"
else
fail "agent-info.py syntax error"
fi
# Test 13: config-diff.py
if python3 -m py_compile "${AUTOMATION_DIR}/config-diff.py" 2>/dev/null; then
pass "config-diff.py syntax valid"
else
fail "config-diff.py syntax error"
fi
# Test 14: session-template.py
if python3 -m py_compile "${AUTOMATION_DIR}/session-template.py" 2>/dev/null; then
pass "session-template.py syntax valid"
else
fail "session-template.py syntax error"
fi
echo ""
echo "=== Skill Scripts ==="
+82
View File
@@ -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
+49 -1
View File
@@ -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
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""
List and describe available workflows.
Usage: python3 workflow-info.py [--category CAT] [name]
"""
import argparse
import json
import sys
from pathlib import Path
from typing import Dict, List, Optional
import yaml
CLAUDE_DIR = Path.home() / ".claude"
WORKFLOWS_DIR = CLAUDE_DIR / "workflows"
REGISTRY_PATH = CLAUDE_DIR / "state" / "component-registry.json"
def load_registry() -> Dict:
"""Load component registry."""
try:
with open(REGISTRY_PATH) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def find_workflow_files() -> List[Path]:
"""Find all workflow YAML files."""
if not WORKFLOWS_DIR.exists():
return []
files = []
for pattern in ["*.yaml", "*.yml", "**/*.yaml", "**/*.yml"]:
files.extend(WORKFLOWS_DIR.glob(pattern))
# Filter out README and other non-workflow files
return [f for f in files if f.name not in ["README.md"]]
def parse_workflow(path: Path) -> Optional[Dict]:
"""Parse a workflow YAML file."""
try:
with open(path) as f:
content = f.read()
# Handle YAML front matter or full YAML
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 2:
return yaml.safe_load(parts[1])
return yaml.safe_load(content)
except Exception:
return None
def get_workflow_category(path: Path) -> str:
"""Get workflow category from path."""
rel_path = path.relative_to(WORKFLOWS_DIR)
if len(rel_path.parts) > 1:
return rel_path.parts[0]
return "general"
def list_workflows(category: Optional[str] = None):
"""List all available workflows."""
registry = load_registry()
workflows = registry.get("workflows", {})
# Group by category
categories: Dict[str, List] = {}
for name, info in workflows.items():
cat = name.split("/")[0] if "/" in name else "general"
if category and cat != category:
continue
if cat not in categories:
categories[cat] = []
categories[cat].append({
"name": name,
"description": info.get("description", "No description"),
"triggers": info.get("triggers", [])
})
if not categories:
print("No workflows found.")
return
print(f"\n📋 Available Workflows\n")
for cat in sorted(categories.keys()):
print(f"=== {cat.title()} ===")
for wf in categories[cat]:
print(f" {wf['name']}")
print(f" {wf['description']}")
if wf['triggers']:
print(f" Triggers: {', '.join(wf['triggers'][:3])}")
print("")
def show_workflow(name: str):
"""Show details for a specific workflow."""
registry = load_registry()
workflows = registry.get("workflows", {})
# Find matching workflow
matches = [n for n in workflows.keys() if name in n]
if not matches:
print(f"Workflow '{name}' not found.")
print("\nAvailable workflows:")
for n in sorted(workflows.keys()):
print(f" - {n}")
return
if len(matches) > 1 and name not in matches:
print(f"Multiple matches for '{name}':")
for m in matches:
print(f" - {m}")
return
wf_name = name if name in matches else matches[0]
wf_info = workflows[wf_name]
print(f"\n📋 Workflow: {wf_name}\n")
print(f"Description: {wf_info.get('description', 'No description')}")
triggers = wf_info.get("triggers", [])
if triggers:
print(f"\nTriggers:")
for t in triggers:
print(f" - {t}")
# Try to find and show the actual workflow file
possible_paths = [
WORKFLOWS_DIR / f"{wf_name}.yaml",
WORKFLOWS_DIR / f"{wf_name}.yml",
WORKFLOWS_DIR / wf_name / "workflow.yaml",
]
for path in possible_paths:
if path.exists():
wf_data = parse_workflow(path)
if wf_data:
print(f"\nFile: {path.relative_to(CLAUDE_DIR)}")
if "steps" in wf_data:
print(f"\nSteps:")
for i, step in enumerate(wf_data["steps"], 1):
step_name = step.get("name", f"Step {i}")
agent = step.get("agent", "unknown")
print(f" {i}. {step_name} (agent: {agent})")
if "trigger" in wf_data:
trigger = wf_data["trigger"]
if isinstance(trigger, dict):
if trigger.get("schedule"):
print(f"\nSchedule: {trigger['schedule']}")
if trigger.get("manual"):
print("Manual trigger: Yes")
break
print("")
def main():
parser = argparse.ArgumentParser(description="List and describe workflows")
parser.add_argument("name", nargs="?", help="Workflow name to show details")
parser.add_argument("--category", "-c", type=str, help="Filter by category")
parser.add_argument("--list", "-l", action="store_true", help="List all workflows")
args = parser.parse_args()
if args.name and not args.list:
show_workflow(args.name)
else:
list_workflows(args.category)
if __name__ == "__main__":
main()
+6
View File
@@ -19,10 +19,16 @@ Slash commands for quick actions. User-invoked (type `/command` to trigger).
| `/debug` | `/diag`, `/diagnose` | Debug and troubleshoot config |
| `/export` | `/session-export`, `/share` | Export session for sharing |
| `/mcp-status` | `/mcp`, `/integrations` | Check MCP integrations |
| `/workflow` | `/workflows`, `/wf` | List and describe workflows |
| `/skill-info` | `/skill`, `/skills-info` | Show skill information |
| `/agent-info` | `/agent`, `/agents` | Show agent information |
| `/diff` | `/config-diff`, `/compare` | Compare config with backup |
| `/template` | `/templates`, `/session-template` | Manage session templates |
| `/maintain` | `/maintenance`, `/admin` | Configuration maintenance |
| `/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:*`)
+36
View File
@@ -0,0 +1,36 @@
---
name: agent-info
description: Show information about available agents
aliases: [agent, agents]
invokes: command:agent-info
---
# Agent Info Command
Show information about available agents and their hierarchy.
## Usage
```
/agent-info # List all agents
/agent-info <name> # Show agent details
/agent-info --tree # Show agent hierarchy
```
## Implementation
Run the agent info script:
```bash
python3 ~/.claude/automation/agent-info.py [options] [name]
```
## Output Includes
| Field | Description |
|-------|-------------|
| Name | Agent identifier |
| Description | What the agent handles |
| Model | Assigned model (opus/sonnet/haiku) |
| Triggers | Keywords that route to this agent |
| Supervisor | Parent agent in hierarchy |
+34
View File
@@ -0,0 +1,34 @@
---
name: diff
description: Compare configuration with backup
aliases: [config-diff, compare]
invokes: command:diff
---
# Diff Command
Compare current configuration with a backup to see what changed.
## Usage
```
/diff # Compare with latest backup
/diff --backup <file> # Compare with specific backup
/diff --list # List available backups
/diff --json # Output as JSON
```
## Implementation
```bash
python3 ~/.claude/automation/config-diff.py [options]
```
## Output
Shows changes grouped by type:
- **Added** - New files in current config
- **Removed** - Files that existed in backup but not now
- **Changed** - Modified files with details
For JSON files, shows which keys were added/removed/changed.
+89
View File
@@ -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
```
+36
View File
@@ -0,0 +1,36 @@
---
name: skill-info
description: Show information about available skills
aliases: [skill, skills-info]
invokes: command:skill-info
---
# Skill Info Command
Show detailed information about available skills.
## Usage
```
/skill-info # List all skills
/skill-info <name> # Show skill details
/skill-info --scripts # List skills with scripts
```
## Implementation
Run the skill info script:
```bash
python3 ~/.claude/automation/skill-info.py [options] [name]
```
## Output Includes
| Field | Description |
|-------|-------------|
| Description | What the skill does |
| Scripts | Available executable scripts |
| Triggers | Keywords that invoke the skill |
| References | Documentation files |
| Allowed Tools | Tool restrictions (if any) |
+44
View File
@@ -0,0 +1,44 @@
---
name: template
description: Manage session templates for common workflows
aliases: [templates, session-template]
invokes: command:template
---
# Template Command
Create and use session templates for repeatable workflows.
## Usage
```
/template # List all templates
/template --use <name> # Display template for use
/template --create <name> # Create new template
/template --delete <name> # Delete a template
```
## Implementation
```bash
python3 ~/.claude/automation/session-template.py [options]
```
## Built-in Templates
| Name | Category | Description |
|------|----------|-------------|
| `daily-standup` | routine | Morning status check and planning |
| `code-review` | development | Review code changes in a project |
| `troubleshoot` | debugging | Debug an issue systematically |
| `deploy` | operations | Deploy application to environment |
## Template Contents
Each template includes:
- **Description** - What the template is for
- **Category** - Grouping for organization
- **Context level** - How much context to gather
- **Initial commands** - Commands to run at start
- **Checklist** - Steps to follow
- **Prompt template** - Suggested starting prompt
+40
View File
@@ -0,0 +1,40 @@
---
name: workflow
description: List and describe available workflows
aliases: [workflows, wf]
invokes: command:workflow
---
# Workflow Command
List and describe available workflows.
## Usage
```
/workflow # List all workflows
/workflow <name> # Show workflow details
/workflow --category <cat> # Filter by category
```
## Implementation
Run the workflow info script:
```bash
python3 ~/.claude/automation/workflow-info.py [options] [name]
```
## Categories
| Category | Examples |
|----------|----------|
| `health` | cluster-health-check, cluster-daily-summary |
| `deploy` | deploy-app |
| `incidents` | pod-crashloop, node-issue-response |
| `sysadmin` | health-check, system-update |
## Note
Workflows are design documents - they guide Claude's actions but aren't
auto-executed. Use this command to understand available procedures.
+2 -1
View File
@@ -26,7 +26,7 @@ Optimized for Raspberry Pi 3B+ (1GB RAM):
```bash
# Run locally
go run ./cmd/server --port 8080 --data ./data
go run ./cmd/server --port 8080 --data ./data --claude ~/.claude
# Build binary
go build -o server ./cmd/server
@@ -72,6 +72,7 @@ kubectl apply -k deploy/
|------|---------|-------------|
| --port | 8080 | Server port |
| --data | /data | Data directory for persistent state |
| --claude | ~/.claude | Claude Code directory |
## API Endpoints
+19
View File
@@ -0,0 +1,19 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultClaudeDir(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("UserHomeDir: %v", err)
}
want := filepath.Join(home, ".claude")
got := defaultClaudeDir()
if got != want {
t.Fatalf("defaultClaudeDir() = %q, want %q", got, want)
}
}
+31
View File
@@ -7,20 +7,31 @@ import (
"log"
"net/http"
"os"
"path/filepath"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/will/k8s-agent-dashboard/internal/api"
"github.com/will/k8s-agent-dashboard/internal/claude"
"github.com/will/k8s-agent-dashboard/internal/store"
)
//go:embed all:web
var webFS embed.FS
func defaultClaudeDir() string {
home, err := os.UserHomeDir()
if err != nil {
return "/home/will/.claude" // fallback; best-effort
}
return filepath.Join(home, ".claude")
}
func main() {
port := flag.String("port", "8080", "Server port")
dataDir := flag.String("data", "/data", "Data directory for state")
claudeDir := flag.String("claude", defaultClaudeDir(), "Claude Code directory")
flag.Parse()
// Initialize store
@@ -29,6 +40,12 @@ func main() {
log.Fatalf("Failed to initialize store: %v", err)
}
// Initialize Claude loader
claudeLoader := claude.NewLoader(*claudeDir)
// Initialize event hub
hub := claude.NewEventHub(1000)
// Create router
r := chi.NewRouter()
@@ -48,6 +65,16 @@ func main() {
// API routes
r.Route("/api", func(r chi.Router) {
r.Get("/health", api.HealthCheck)
r.Route("/claude", func(r chi.Router) {
r.Get("/health", api.GetClaudeHealth(claudeLoader))
r.Get("/stats", api.GetClaudeStats(claudeLoader))
r.Get("/summary", api.GetClaudeSummary(claudeLoader))
r.Get("/inventory", api.GetClaudeInventory(claudeLoader))
r.Get("/debug/files", api.GetClaudeDebugFiles(claudeLoader))
r.Get("/live/backlog", api.GetClaudeLiveBacklog(claudeLoader))
r.Get("/stream", api.GetClaudeStream(hub))
})
r.Get("/status", api.GetClusterStatus(s))
r.Get("/pending", api.GetPendingActions(s))
r.Post("/pending/{id}/approve", api.ApproveAction(s))
@@ -78,6 +105,10 @@ func main() {
log.Printf("Starting server on %s", addr)
log.Printf("Data directory: %s", *dataDir)
log.Printf("Claude directory: %s", *claudeDir)
stop := make(chan struct{})
go claude.TailHistoryFile(stop, hub, filepath.Join(*claudeDir, "history.jsonl"))
if err := http.ListenAndServe(addr, r); err != nil {
log.Fatalf("Server failed: %v", err)
+91
View File
@@ -16,6 +16,14 @@
</header>
<nav>
<!-- General -->
<button class="nav-btn" data-view="overview">Overview</button>
<button class="nav-btn" data-view="usage">Usage</button>
<button class="nav-btn" data-view="inventory">Inventory</button>
<button class="nav-btn" data-view="debug">Debug</button>
<button class="nav-btn" data-view="live">Live</button>
<!-- Existing K8s views (kept intact) -->
<button class="nav-btn active" data-view="status">Status</button>
<button class="nav-btn" data-view="pending">Pending <span id="pending-count" class="badge">0</span></button>
<button class="nav-btn" data-view="history">History</button>
@@ -23,6 +31,89 @@
</nav>
<main>
<!-- Overview View -->
<section id="overview-view" class="view">
<div class="card">
<h2>Overview</h2>
<div id="claude-overview">
<p class="empty-state">Loading Claude overview...</p>
</div>
</div>
</section>
<!-- Usage View -->
<section id="usage-view" class="view">
<div class="card">
<h2>Usage</h2>
<table id="claude-usage-table">
<thead>
<tr>
<th>Date</th>
<th>Sessions</th>
<th>Messages</th>
<th>Tool Calls</th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="empty-state">Loading usage...</td></tr>
</tbody>
</table>
</div>
</section>
<!-- Inventory View -->
<section id="inventory-view" class="view">
<div class="card">
<h2>Inventory</h2>
<div id="claude-inventory">
<p class="empty-state">Loading inventory...</p>
</div>
</div>
</section>
<!-- Debug View -->
<section id="debug-view" class="view">
<div class="card">
<h2>Debug</h2>
<table id="claude-debug-table">
<thead>
<tr>
<th>File</th>
<th>Status</th>
<th>MTime</th>
<th>Error</th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="empty-state">Loading debug info...</td></tr>
</tbody>
</table>
</div>
</section>
<!-- Live View -->
<section id="live-view" class="view">
<div class="card">
<h2>Live Feed</h2>
<div class="live-header">
<span id="claude-live-conn" class="conn-badge conn-badge-yellow">Connecting...</span>
</div>
<table id="claude-live-table">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Summary</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="empty-state">Waiting for events...</td></tr>
</tbody>
</table>
</div>
</section>
<!-- Status View -->
<section id="status-view" class="view active">
<div class="card">
@@ -63,6 +63,7 @@ nav {
background: var(--bg-secondary);
padding: 0.5rem 2rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
border-bottom: 1px solid var(--bg-card);
}
@@ -178,6 +179,47 @@ td {
color: var(--success);
}
/* Claude dashboard extra badges */
.status-ok {
background: rgba(74, 222, 128, 0.2);
color: var(--success);
}
.status-missing {
background: rgba(239, 68, 68, 0.2);
color: var(--danger);
}
.simple-list {
margin-left: 1.25rem;
}
.simple-list li {
margin: 0.25rem 0;
}
.inventory-section + .inventory-section {
margin-top: 1.25rem;
}
.metric {
font-size: 2rem;
font-weight: 700;
margin-top: 0.25rem;
}
/* Grid helper for Claude overview (keeps markup minimal) */
.grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
/* In some contexts we want a grid inside a card; remove card bottom margin in that case */
.grid .card {
margin-bottom: 0;
}
.alerts-list, .pending-list, .workflows-list {
display: flex;
flex-direction: column;
@@ -328,6 +370,59 @@ footer {
.progress-bar .fill.warning { background: var(--warning); }
.progress-bar .fill.danger { background: var(--danger); }
/* Live feed styles */
.live-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.conn-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
}
.conn-badge-connected {
background: rgba(74, 222, 128, 0.2);
color: var(--success);
}
.conn-badge-error {
background: rgba(239, 68, 68, 0.2);
color: var(--danger);
}
.conn-badge-yellow {
background: rgba(251, 191, 36, 0.2);
color: var(--warning);
}
.raw-json {
background: var(--bg-secondary);
padding: 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
overflow-x: auto;
margin-top: 0.5rem;
}
.btn-sm {
padding: 0.25rem 0.5rem;
border: 1px solid var(--bg-secondary);
background: transparent;
color: var(--text-primary);
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
}
.btn-sm:hover {
background: var(--bg-secondary);
}
/* Responsive */
@media (max-width: 768px) {
header {
+291 -1
View File
@@ -5,12 +5,34 @@ const API_BASE = '/api';
// State
let currentView = 'status';
// Live feed state
let pendingLiveEvents = [];
let liveEvents = [];
let liveEventSource = null;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
setupNavigation();
loadAllData();
// Refresh data every 30 seconds
setInterval(loadAllData, 30000);
// Initialize live feed
initLiveFeed();
// Batch render live events every 1s
setInterval(() => {
if (pendingLiveEvents.length > 0) {
liveEvents = [...pendingLiveEvents, ...liveEvents];
if (liveEvents.length > 500) {
liveEvents = liveEvents.slice(0, 500);
}
pendingLiveEvents = [];
if (currentView === 'live') {
renderLiveEvents();
}
}
}, 1000);
});
// Navigation
@@ -41,10 +63,16 @@ function switchView(view) {
async function loadAllData() {
try {
await Promise.all([
// Existing k8s dashboard data
loadClusterStatus(),
loadPendingActions(),
loadHistory(),
loadWorkflows()
loadWorkflows(),
// Claude dashboard data
loadClaudeStats(),
loadClaudeInventory(),
loadClaudeDebugFiles()
]);
updateLastUpdate();
} catch (error) {
@@ -52,6 +80,123 @@ async function loadAllData() {
}
}
async function initLiveFeed() {
try {
// Load initial backlog
const response = await fetch(`${API_BASE}/claude/live/backlog?limit=200`);
const data = await response.json();
liveEvents = data.events || [];
renderLiveEvents();
// Setup SSE
liveEventSource = new EventSource(`${API_BASE}/claude/stream`);
liveEventSource.onopen = () => {
updateLiveConnStatus('connected');
};
liveEventSource.onerror = () => {
updateLiveConnStatus('error');
};
liveEventSource.onmessage = (e) => {
try {
const ev = JSON.parse(e.data);
pendingLiveEvents.push(ev);
} catch (err) {
console.error('Error parsing SSE event:', err);
}
};
} catch (error) {
console.error('Error initializing live feed:', error);
updateLiveConnStatus('error');
}
}
function updateLiveConnStatus(status) {
const el = document.getElementById('claude-live-conn');
if (!el) return;
el.className = `conn-badge conn-badge-${status}`;
el.textContent = status === 'connected' ? 'Connected' : status === 'error' ? 'Disconnected' : 'Connecting...';
}
function renderLiveEvents() {
const tbody = document.querySelector('#claude-live-table tbody');
if (!tbody) return;
if (liveEvents.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Waiting for events...</td></tr>';
return;
}
tbody.innerHTML = liveEvents.map(ev => {
const summary = ev.data?.summary || {};
const summaryText = Object.values(summary).filter(Boolean).join(' ') || '-';
const display = ev.data?.json?.display || '';
return `
<tr>
<td>${formatDateTime(ev.ts)}</td>
<td><span class="badge">${ev.type}</span></td>
<td>${summaryText}</td>
<td>
<button class="btn-sm" onclick="toggleRawJson(this)">Show JSON</button>
<pre class="raw-json" style="display:none">${escapeHtml(JSON.stringify(ev.data?.json || ev.data?.rawLine || {}, null, 2))}</pre>
</td>
</tr>
`;
}).join('');
}
function toggleRawJson(btn) {
const pre = btn.nextElementSibling;
if (pre.style.display === 'none') {
pre.style.display = 'block';
btn.textContent = 'Hide JSON';
} else {
pre.style.display = 'none';
btn.textContent = 'Show JSON';
}
}
// Claude dashboard data loading
async function loadClaudeStats() {
try {
const response = await fetch(`${API_BASE}/claude/stats`);
const data = await response.json();
renderClaudeOverview(data);
renderClaudeUsage(data);
} catch (error) {
// Keep k8s dashboard working even if claude endpoints are unavailable
console.error('Error loading Claude stats:', error);
renderClaudeOverview(null);
renderClaudeUsage(null);
}
}
async function loadClaudeInventory() {
try {
const response = await fetch(`${API_BASE}/claude/inventory`);
const data = await response.json();
renderClaudeInventory(data);
} catch (error) {
console.error('Error loading Claude inventory:', error);
renderClaudeInventory(null);
}
}
async function loadClaudeDebugFiles() {
try {
const response = await fetch(`${API_BASE}/claude/debug/files`);
const data = await response.json();
renderClaudeDebugFiles(data);
} catch (error) {
console.error('Error loading Claude debug files:', error);
renderClaudeDebugFiles(null);
}
}
async function loadClusterStatus() {
try {
const response = await fetch(`${API_BASE}/status`);
@@ -226,6 +371,151 @@ function renderWorkflows(workflows) {
`).join('');
}
// Claude dashboard rendering
function renderClaudeOverview(stats) {
const el = document.getElementById('claude-overview');
if (!el) return;
if (!stats) {
el.innerHTML = '<p class="empty-state">Claude stats unavailable</p>';
return;
}
const lastComputedDate = stats.lastComputedDate || stats.lastComputed || stats.lastComputedAt || null;
// Support both shapes: {totals:{...}} and flat {totalSessions,totalMessages,...}
const totalSessions = (stats.totalSessions != null) ? stats.totalSessions : (stats.totals && stats.totals.sessions != null ? stats.totals.sessions : 0);
const totalMessages = (stats.totalMessages != null) ? stats.totalMessages : (stats.totals && stats.totals.messages != null ? stats.totals.messages : 0);
const totalToolCalls = (stats.totalToolCalls != null) ? stats.totalToolCalls : (stats.totals && (stats.totals.toolCalls != null ? stats.totals.toolCalls : (stats.totals.tools != null ? stats.totals.tools : 0)));
el.innerHTML = `
<div class="grid">
<div class="card">
<h3>Total Sessions</h3>
<div class="metric">${totalSessions}</div>
</div>
<div class="card">
<h3>Total Messages</h3>
<div class="metric">${totalMessages}</div>
</div>
<div class="card">
<h3>Total Tool Calls</h3>
<div class="metric">${totalToolCalls}</div>
</div>
<div class="card">
<h3>Last Computed</h3>
<div class="metric" style="font-size: 14px; font-weight: 600;">${lastComputedDate ? formatDateTime(lastComputedDate) : 'Unknown'}</div>
</div>
</div>
`;
}
function renderClaudeUsage(stats) {
const tbody = document.querySelector('#claude-usage-table tbody');
if (!tbody) return;
const daily = (stats && (stats.dailyActivity || stats.daily)) ? (stats.dailyActivity || stats.daily) : [];
if (!daily || daily.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">No usage data available</td></tr>';
return;
}
tbody.innerHTML = daily.map(d => {
const sessions = (d.sessionCount != null) ? d.sessionCount : (d.sessions != null ? d.sessions : 0);
const messages = (d.messageCount != null) ? d.messageCount : (d.messages != null ? d.messages : 0);
const toolCalls = (d.toolCallCount != null) ? d.toolCallCount : ((d.toolCalls != null) ? d.toolCalls : ((d.tools != null) ? d.tools : 0));
return `
<tr>
<td>${d.date || d.day || ''}</td>
<td>${sessions}</td>
<td>${messages}</td>
<td>${toolCalls}</td>
</tr>
`;
}).join('');
}
function renderClaudeInventory(inv) {
const el = document.getElementById('claude-inventory');
if (!el) return;
if (!inv) {
el.innerHTML = '<p class="empty-state">Claude inventory unavailable</p>';
return;
}
const agents = inv.agents || [];
const skills = inv.skills || [];
const commands = inv.commands || [];
el.innerHTML = `
<div class="inventory-section">
<h3>Agents (${agents.length})</h3>
${renderSimpleList(agents.map(a => a.name || a.path || a))}
</div>
<div class="inventory-section">
<h3>Skills (${skills.length})</h3>
${renderSimpleList(skills.map(s => s.name || s.path || s))}
</div>
<div class="inventory-section">
<h3>Commands (${commands.length})</h3>
${renderSimpleList(commands.map(c => c.name || c.path || c))}
</div>
`;
}
function renderClaudeDebugFiles(debug) {
const tbody = document.querySelector('#claude-debug-table tbody');
if (!tbody) return;
const files = (debug && (debug.files || debug.keyFiles)) ? (debug.files || debug.keyFiles) : [];
if (!files || files.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">No debug file info available</td></tr>';
return;
}
tbody.innerHTML = files.map(f => {
const exists = (f.exists != null) ? f.exists : !f.missing;
const status = (f.status || ((!exists) ? 'missing' : 'ok')).toLowerCase();
const badgeClass = status === 'ok' ? 'status-ok' : 'status-missing';
return `
<tr>
<td><code>${escapeHtml(f.name || f.path || '')}</code></td>
<td><span class="status-badge ${badgeClass}">${status}</span></td>
<td>${(f.mtime || f.modTime) ? formatDateTime(f.mtime || f.modTime) : ''}</td>
<td>${f.error ? escapeHtml(f.error) : ''}</td>
</tr>
`;
}).join('');
}
function renderSimpleList(items) {
const safeItems = (items || []).filter(Boolean);
if (safeItems.length === 0) return '<p class="empty-state">None</p>';
return `
<ul class="simple-list">
${safeItems.map(i => `<li>${escapeHtml(String(i))}</li>`).join('')}
</ul>
`;
}
function formatDateTime(value) {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
return d.toLocaleString();
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Actions
async function approveAction(id) {
try {
+162
View File
@@ -0,0 +1,162 @@
package api
import (
"net/http"
"path/filepath"
"github.com/will/k8s-agent-dashboard/internal/claude"
)
// ClaudeLoader is a minimal interface for Claude Ops endpoints.
//
// Keep it small so handlers are easy to test with fakes.
type ClaudeLoader interface {
ClaudeDir() string
LoadStatsCache() (*claude.StatsCache, error)
ListDir(name string) ([]claude.DirEntry, error)
FileMeta(relPath string) (claude.FileMeta, error)
PathExists(relPath string) bool
}
func GetClaudeStats(loader ClaudeLoader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
stats, err := loader.LoadStatsCache()
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, stats)
}
}
type ClaudeSummaryResponse struct {
Totals ClaudeSummaryTotals `json:"totals"`
PerModel map[string]claude.ModelUsage `json:"perModel"`
Derived ClaudeSummaryDerived `json:"derived"`
}
type ClaudeSummaryTotals struct {
TotalSessions int `json:"totalSessions"`
TotalMessages int `json:"totalMessages"`
}
type ClaudeSummaryDerived struct {
CacheHitRatioEstimate float64 `json:"cacheHitRatioEstimate"`
TopModelByOutputTokens string `json:"topModelByOutputTokens"`
}
func GetClaudeSummary(loader ClaudeLoader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
stats, err := loader.LoadStatsCache()
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
resp := ClaudeSummaryResponse{
Totals: ClaudeSummaryTotals{
TotalSessions: stats.TotalSessions,
TotalMessages: stats.TotalMessages,
},
PerModel: stats.ModelUsage,
}
var inputTokens, cacheRead, cacheCreate int
maxOut := -1
topModel := ""
for model, usage := range stats.ModelUsage {
inputTokens += usage.InputTokens
cacheRead += usage.CacheReadInputTokens
cacheCreate += usage.CacheCreationInputTokens
if usage.OutputTokens > maxOut {
maxOut = usage.OutputTokens
topModel = model
}
}
den := float64(inputTokens + cacheRead + cacheCreate)
ratio := 0.0
if den > 0 {
ratio = float64(cacheRead) / den
}
resp.Derived = ClaudeSummaryDerived{
CacheHitRatioEstimate: ratio,
TopModelByOutputTokens: topModel,
}
respondJSON(w, http.StatusOK, resp)
}
}
func GetClaudeHealth(loader ClaudeLoader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
checks := map[string]bool{}
missing := false
for _, rel := range []string{"stats-cache.json", "history.jsonl", filepath.Join("state", "component-registry.json")} {
exists := loader.PathExists(rel)
checks[rel] = exists
if !exists {
missing = true
}
}
status := "ok"
if missing {
status = "degraded"
}
respondJSON(w, http.StatusOK, map[string]any{
"status": status,
"claudeDir": loader.ClaudeDir(),
"fileChecks": checks,
})
}
}
func GetClaudeInventory(loader ClaudeLoader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
agents, err := loader.ListDir("agents")
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
skills, err := loader.ListDir("skills")
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
commands, err := loader.ListDir("commands")
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]any{
"agents": agents,
"skills": skills,
"commands": commands,
})
}
}
func GetClaudeDebugFiles(loader ClaudeLoader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var metas []claude.FileMeta
for _, rel := range []string{
"stats-cache.json",
"history.jsonl",
filepath.Join("state", "component-registry.json"),
} {
meta, err := loader.FileMeta(rel)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
metas = append(metas, meta)
}
respondJSON(w, http.StatusOK, map[string]any{
"files": metas,
})
}
}
@@ -0,0 +1,110 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/will/k8s-agent-dashboard/internal/claude"
)
type fakeLoader struct{}
func (f fakeLoader) ClaudeDir() string { return "/tmp/claude" }
func (f fakeLoader) LoadStatsCache() (*claude.StatsCache, error) {
return &claude.StatsCache{TotalSessions: 3}, nil
}
func (f fakeLoader) ListDir(name string) ([]claude.DirEntry, error) { return nil, nil }
func (f fakeLoader) FileMeta(relPath string) (claude.FileMeta, error) { return claude.FileMeta{}, nil }
func (f fakeLoader) PathExists(relPath string) bool { return true }
func TestGetClaudeStats(t *testing.T) {
r := chi.NewRouter()
r.Get("/api/claude/stats", GetClaudeStats(fakeLoader{}))
req := httptest.NewRequest(http.MethodGet, "/api/claude/stats", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
func TestGetClaudeSummary_IncludesDerivedCostSignals(t *testing.T) {
r := chi.NewRouter()
r.Get("/api/claude/summary", GetClaudeSummary(fakeSummaryLoader{}))
req := httptest.NewRequest(http.MethodGet, "/api/claude/summary", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
// Smoke-check that derived fields exist.
body := w.Body.String()
for _, want := range []string{"cacheHitRatioEstimate", "topModelByOutputTokens"} {
if !jsonContainsKey(body, want) {
t.Fatalf("expected response to include key %s, body=%s", want, body)
}
}
}
type fakeSummaryLoader struct{ fakeLoader }
func (f fakeSummaryLoader) LoadStatsCache() (*claude.StatsCache, error) {
return &claude.StatsCache{
TotalSessions: 3,
TotalMessages: 10,
ModelUsage: map[string]claude.ModelUsage{
"claude-3-5-sonnet": {
InputTokens: 100,
OutputTokens: 250,
CacheReadInputTokens: 50,
CacheCreationInputTokens: 25,
},
"claude-3-5-haiku": {
InputTokens: 80,
OutputTokens: 300,
CacheReadInputTokens: 20,
},
},
}, nil
}
func jsonContainsKey(body, key string) bool {
var m map[string]any
if err := json.Unmarshal([]byte(body), &m); err != nil {
return false
}
return mapContainsKey(m, key)
}
func mapContainsKey(v any, key string) bool {
switch vv := v.(type) {
case map[string]any:
if _, ok := vv[key]; ok {
return true
}
for _, child := range vv {
if mapContainsKey(child, key) {
return true
}
}
case []any:
for _, child := range vv {
if mapContainsKey(child, key) {
return true
}
}
}
return false
}
@@ -0,0 +1,79 @@
package api
import (
"encoding/json"
"net/http"
"path/filepath"
"strconv"
"time"
"github.com/will/k8s-agent-dashboard/internal/claude"
)
type BacklogResponse struct {
Limit int `json:"limit"`
Events []claude.Event `json:"events"`
}
func GetClaudeLiveBacklog(loader ClaudeLoader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
limit := 200
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 {
limit = n
if limit > 1000 {
limit = 1000
}
}
}
historyPath := filepath.Join(loader.ClaudeDir(), "history.jsonl")
lines, err := claude.TailLastNLines(historyPath, limit)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
events := make([]claude.Event, 0, len(lines))
for _, line := range lines {
ev := parseHistoryLine(line)
events = append(events, ev)
}
respondJSON(w, http.StatusOK, BacklogResponse{
Limit: limit,
Events: events,
})
}
}
func parseHistoryLine(line string) claude.Event {
data := map[string]any{
"rawLine": line,
}
var jsonData map[string]any
if err := json.Unmarshal([]byte(line), &jsonData); err != nil {
data["parseError"] = err.Error()
} else {
data["json"] = jsonData
summary := map[string]string{}
if v, ok := jsonData["sessionId"].(string); ok {
summary["sessionId"] = v
}
if v, ok := jsonData["project"].(string); ok {
summary["project"] = v
}
if v, ok := jsonData["display"].(string); ok {
summary["display"] = v
}
data["summary"] = summary
}
return claude.Event{
TS: time.Now(),
Type: claude.EventTypeHistoryAppend,
Data: data,
}
}
@@ -0,0 +1,48 @@
package api
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"github.com/will/k8s-agent-dashboard/internal/claude"
)
type fakeClaudeDirLoader struct{ dir string }
func (f fakeClaudeDirLoader) ClaudeDir() string { return f.dir }
func (f fakeClaudeDirLoader) LoadStatsCache() (*claude.StatsCache, error) {
return &claude.StatsCache{}, nil
}
func (f fakeClaudeDirLoader) ListDir(name string) ([]claude.DirEntry, error) { return nil, nil }
func (f fakeClaudeDirLoader) FileMeta(relPath string) (claude.FileMeta, error) {
return claude.FileMeta{}, nil
}
func (f fakeClaudeDirLoader) PathExists(relPath string) bool { return true }
func TestClaudeLiveBacklog_DefaultLimit(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "history.jsonl")
if err := os.WriteFile(p, []byte("{\"display\":\"/model\"}\n"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
loader := fakeClaudeDirLoader{dir: dir}
r := chi.NewRouter()
r.Get("/api/claude/live/backlog", GetClaudeLiveBacklog(loader))
req := httptest.NewRequest(http.MethodGet, "/api/claude/live/backlog", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if !jsonContainsKey(w.Body.String(), "events") {
t.Fatalf("expected events in response: %s", w.Body.String())
}
}
@@ -0,0 +1,89 @@
package api
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"github.com/will/k8s-agent-dashboard/internal/claude"
)
// Integration-style smoke test for the Claude endpoints.
//
// This does NOT start a server process; it wires chi routes directly and calls
// them via httptest.
func TestClaudeRoutes_Smoke(t *testing.T) {
tmp := t.TempDir()
// Minimal filesystem layout expected by endpoints.
mustMkdirAll(t, filepath.Join(tmp, "agents"))
mustMkdirAll(t, filepath.Join(tmp, "skills"))
mustMkdirAll(t, filepath.Join(tmp, "commands"))
mustMkdirAll(t, filepath.Join(tmp, "state"))
// Minimal stats-cache.json required by /stats, /summary, /debug/files.
// Keep it tiny and deterministic.
statsCache := `{
"totalSessions": 1,
"totalMessages": 1,
"modelUsage": {
"claude-test": {
"inputTokens": 1,
"outputTokens": 1,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0
}
}
}`
if err := os.WriteFile(filepath.Join(tmp, "stats-cache.json"), []byte(statsCache), 0o600); err != nil {
t.Fatalf("write stats-cache.json: %v", err)
}
if err := os.WriteFile(filepath.Join(tmp, "history.jsonl"), []byte("{}\n"), 0o600); err != nil {
t.Fatalf("write history.jsonl: %v", err)
}
if err := os.WriteFile(filepath.Join(tmp, "state", "component-registry.json"), []byte("{}"), 0o600); err != nil {
t.Fatalf("write state/component-registry.json: %v", err)
}
loader := claude.NewLoader(tmp)
r := chi.NewRouter()
// Mirror the /api/claude routes from cmd/server/main.go.
r.Route("/api", func(r chi.Router) {
r.Route("/claude", func(r chi.Router) {
r.Get("/health", GetClaudeHealth(loader))
r.Get("/stats", GetClaudeStats(loader))
r.Get("/summary", GetClaudeSummary(loader))
r.Get("/inventory", GetClaudeInventory(loader))
r.Get("/debug/files", GetClaudeDebugFiles(loader))
})
})
for _, path := range []string{
"/api/claude/health",
"/api/claude/stats",
"/api/claude/inventory",
"/api/claude/debug/files",
"/api/claude/summary",
} {
path := path
req := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET %s status=%d body=%s", path, w.Code, w.Body.String())
}
}
}
func mustMkdirAll(t *testing.T, p string) {
t.Helper()
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", p, err)
}
}
@@ -0,0 +1,51 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/will/k8s-agent-dashboard/internal/claude"
)
func GetClaudeStream(hub *claude.EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
ch, cancel := hub.Subscribe()
defer cancel()
notify := r.Context().Done()
for {
select {
case ev, ok := <-ch:
if !ok {
return
}
data, err := json.Marshal(ev)
if err != nil {
continue
}
fmt.Fprintf(w, "event: %s\n", ev.Type)
fmt.Fprintf(w, "id: %d\n", ev.ID)
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
case <-notify:
return
}
}
}
}
@@ -0,0 +1,40 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/will/k8s-agent-dashboard/internal/claude"
)
func TestClaudeStream_SendsEvent(t *testing.T) {
hub := claude.NewEventHub(10)
r := chi.NewRouter()
r.Get("/api/claude/stream", GetClaudeStream(hub))
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
req := httptest.NewRequest(http.MethodGet, "/api/claude/stream", nil).WithContext(ctx)
w := httptest.NewRecorder()
go func() {
time.Sleep(10 * time.Millisecond)
hub.Publish(claude.Event{Type: claude.EventTypeServerNotice, Data: map[string]any{"msg": "hi"}})
}()
r.ServeHTTP(w, req)
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
t.Fatalf("content-type=%q", ct)
}
if !strings.Contains(w.Body.String(), "event:") || !strings.Contains(w.Body.String(), "data:") {
t.Fatalf("body=%s", w.Body.String())
}
}
+85
View File
@@ -0,0 +1,85 @@
package claude
import (
"sync"
"sync/atomic"
"time"
)
type EventHub struct {
mu sync.RWMutex
buffer []Event
nextID int64
subscribers []chan Event
bufferSize int
}
func NewEventHub(bufferSize int) *EventHub {
return &EventHub{
buffer: make([]Event, 0, bufferSize),
subscribers: make([]chan Event, 0),
bufferSize: bufferSize,
}
}
func (h *EventHub) Publish(ev Event) Event {
if ev.ID == 0 {
ev.ID = atomic.AddInt64(&h.nextID, 1)
}
if ev.TS.IsZero() {
ev.TS = time.Now()
}
h.mu.Lock()
defer h.mu.Unlock()
if len(h.buffer) >= h.bufferSize {
h.buffer = h.buffer[1:]
}
h.buffer = append(h.buffer, ev)
for _, ch := range h.subscribers {
select {
case ch <- ev:
default:
}
}
return ev
}
func (h *EventHub) Subscribe() (chan Event, func()) {
ch := make(chan Event, 10)
h.mu.Lock()
defer h.mu.Unlock()
h.subscribers = append(h.subscribers, ch)
cancel := func() {
h.mu.Lock()
defer h.mu.Unlock()
for i, c := range h.subscribers {
if c == ch {
h.subscribers = append(h.subscribers[:i], h.subscribers[i+1:]...)
close(ch)
break
}
}
}
return ch, cancel
}
func (h *EventHub) ReplaySince(lastID int64) []Event {
h.mu.RLock()
defer h.mu.RUnlock()
var result []Event
for _, ev := range h.buffer {
if ev.ID > lastID {
result = append(result, ev)
}
}
return result
}
@@ -0,0 +1,41 @@
package claude
import (
"testing"
"time"
)
func TestEventHub_PublishSubscribe(t *testing.T) {
hub := NewEventHub(10)
ch, cancel := hub.Subscribe()
defer cancel()
hub.Publish(Event{TS: time.Unix(1, 0), Type: EventTypeServerNotice, Data: map[string]any{"msg": "hi"}})
select {
case ev := <-ch:
if ev.Type != EventTypeServerNotice {
t.Fatalf("type=%s", ev.Type)
}
if ev.ID == 0 {
t.Fatalf("expected id to be assigned")
}
default:
t.Fatalf("expected event")
}
}
func TestEventHub_ReplaySince(t *testing.T) {
hub := NewEventHub(3)
hub.Publish(Event{TS: time.Unix(1, 0), Type: EventTypeServerNotice}) // id 1
hub.Publish(Event{TS: time.Unix(2, 0), Type: EventTypeServerNotice}) // id 2
hub.Publish(Event{TS: time.Unix(3, 0), Type: EventTypeServerNotice}) // id 3
got := hub.ReplaySince(1)
if len(got) != 2 {
t.Fatalf("len=%d", len(got))
}
if got[0].ID != 2 || got[1].ID != 3 {
t.Fatalf("ids=%d,%d", got[0].ID, got[1].ID)
}
}
+19
View File
@@ -0,0 +1,19 @@
package claude
import "time"
type EventType string
const (
EventTypeHistoryAppend EventType = "history.append"
EventTypeFileChanged EventType = "file.changed"
EventTypeServerNotice EventType = "server.notice"
EventTypeServerError EventType = "server.error"
)
type Event struct {
ID int64 `json:"id"`
TS time.Time `json:"ts"`
Type EventType `json:"type"`
Data any `json:"data"`
}
+11
View File
@@ -0,0 +1,11 @@
package claude
import "testing"
func TestEventTypesCompile(t *testing.T) {
_ = Event{}
_ = EventTypeHistoryAppend
_ = EventTypeFileChanged
_ = EventTypeServerNotice
_ = EventTypeServerError
}
+105
View File
@@ -0,0 +1,105 @@
package claude
import (
"bufio"
"encoding/json"
"os"
"time"
)
func TailHistoryFile(stop <-chan struct{}, hub *EventHub, path string) {
var offset int64
for {
select {
case <-stop:
return
default:
}
stat, err := os.Stat(path)
if err != nil {
if !os.IsNotExist(err) {
hub.Publish(Event{
TS: time.Now(),
Type: EventTypeServerError,
Data: map[string]any{"error": err.Error()},
})
}
time.Sleep(1 * time.Second)
continue
}
size := stat.Size()
if size > offset {
if err := processNewBytes(path, offset, size, hub); err != nil {
hub.Publish(Event{
TS: time.Now(),
Type: EventTypeServerError,
Data: map[string]any{"error": err.Error()},
})
}
offset = size
} else if size < offset {
offset = 0
hub.Publish(Event{
TS: time.Now(),
Type: EventTypeServerNotice,
Data: map[string]any{"msg": "file truncated, resetting offset"},
})
}
time.Sleep(500 * time.Millisecond)
}
}
func processNewBytes(path string, oldSize, newSize int64, hub *EventHub) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
if _, err := f.Seek(oldSize, 0); err != nil {
return err
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
data := map[string]any{
"rawLine": line,
}
var jsonData map[string]any
if err := json.Unmarshal([]byte(line), &jsonData); err != nil {
data["parseError"] = err.Error()
} else {
data["json"] = jsonData
summary := map[string]string{}
if v, ok := jsonData["sessionId"].(string); ok {
summary["sessionId"] = v
}
if v, ok := jsonData["project"].(string); ok {
summary["project"] = v
}
if v, ok := jsonData["display"].(string); ok {
summary["display"] = v
}
data["summary"] = summary
}
hub.Publish(Event{
TS: time.Now(),
Type: EventTypeHistoryAppend,
Data: data,
})
}
return scanner.Err()
}
@@ -0,0 +1,40 @@
package claude
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestHistoryTailer_EmitsOnAppend(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "history.jsonl")
if err := os.WriteFile(p, []byte(""), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
hub := NewEventHub(10)
ch, cancel := hub.Subscribe()
defer cancel()
stop := make(chan struct{})
go TailHistoryFile(stop, hub, p)
time.Sleep(600 * time.Millisecond)
if err := os.WriteFile(p, []byte("{\"display\":\"/status\"}\n"), 0o600); err != nil {
t.Fatalf("append: %v", err)
}
select {
case ev := <-ch:
if ev.Type != EventTypeHistoryAppend {
t.Fatalf("type=%s", ev.Type)
}
case <-time.After(700 * time.Millisecond):
t.Fatalf("timed out waiting for event")
}
close(stop)
}
+100
View File
@@ -0,0 +1,100 @@
package claude
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// Loader reads Claude Code state files from a local claude directory (typically ~/.claude).
//
// Keep this minimal for now; more helpers (e.g. ListDir / FileInfo) can be added later.
type Loader struct {
claudeDir string
}
type DirEntry struct {
Name string `json:"name"`
IsDir bool `json:"isDir"`
}
type FileMeta struct {
Path string `json:"path"`
Exists bool `json:"exists"`
Size int64 `json:"size"`
ModTime string `json:"modTime"`
}
func NewLoader(claudeDir string) *Loader {
return &Loader{claudeDir: claudeDir}
}
func (l *Loader) ClaudeDir() string { return l.claudeDir }
func (l *Loader) LoadStatsCache() (*StatsCache, error) {
if l.claudeDir == "" {
return nil, fmt.Errorf("claude dir is empty")
}
p := filepath.Join(l.claudeDir, "stats-cache.json")
b, err := os.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("read stats cache %q: %w", p, err)
}
var stats StatsCache
if err := json.Unmarshal(b, &stats); err != nil {
return nil, fmt.Errorf("parse stats cache %q: %w", p, err)
}
return &stats, nil
}
func (l *Loader) ListDir(name string) ([]DirEntry, error) {
if l.claudeDir == "" {
return nil, fmt.Errorf("claude dir is empty")
}
entries, err := os.ReadDir(filepath.Join(l.claudeDir, name))
if err != nil {
return nil, fmt.Errorf("read dir %q: %w", name, err)
}
out := make([]DirEntry, 0, len(entries))
for _, e := range entries {
out = append(out, DirEntry{Name: e.Name(), IsDir: e.IsDir()})
}
return out, nil
}
func (l *Loader) PathExists(relPath string) bool {
if l.claudeDir == "" {
return false
}
_, err := os.Stat(filepath.Join(l.claudeDir, relPath))
return err == nil
}
func (l *Loader) FileMeta(relPath string) (FileMeta, error) {
if l.claudeDir == "" {
return FileMeta{}, fmt.Errorf("claude dir is empty")
}
p := filepath.Join(l.claudeDir, relPath)
st, err := os.Stat(p)
if err != nil {
if os.IsNotExist(err) {
return FileMeta{Path: relPath, Exists: false}, nil
}
return FileMeta{}, fmt.Errorf("stat %q: %w", p, err)
}
return FileMeta{
Path: relPath,
Exists: true,
Size: st.Size(),
ModTime: st.ModTime().UTC().Format(time.RFC3339),
}, nil
}
+25
View File
@@ -0,0 +1,25 @@
package claude
import (
"os"
"path/filepath"
"testing"
)
func TestLoadStatsCache(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "stats-cache.json")
err := os.WriteFile(p, []byte(`{"version":1,"lastComputedDate":"2025-12-31","totalSessions":1,"totalMessages":2}`), 0644)
if err != nil {
t.Fatalf("WriteFile: %v", err)
}
loader := NewLoader(dir)
stats, err := loader.LoadStatsCache()
if err != nil {
t.Fatalf("LoadStatsCache: %v", err)
}
if stats.TotalSessions != 1 {
t.Fatalf("TotalSessions=%d", stats.TotalSessions)
}
}
+33
View File
@@ -0,0 +1,33 @@
package claude
type DailyActivity struct {
Date string `json:"date"`
MessageCount int `json:"messageCount"`
SessionCount int `json:"sessionCount"`
ToolCallCount int `json:"toolCallCount"`
}
type DailyModelTokens struct {
Date string `json:"date"`
TokensByModel map[string]int `json:"tokensByModel"`
}
type ModelUsage struct {
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
CacheReadInputTokens int `json:"cacheReadInputTokens"`
CacheCreationInputTokens int `json:"cacheCreationInputTokens"`
WebSearchRequests int `json:"webSearchRequests"`
CostUSD float64 `json:"costUSD"`
ContextWindow int `json:"contextWindow"`
}
type StatsCache struct {
Version int `json:"version"`
LastComputedDate string `json:"lastComputedDate"`
DailyActivity []DailyActivity `json:"dailyActivity"`
DailyModelTokens []DailyModelTokens `json:"dailyModelTokens"`
ModelUsage map[string]ModelUsage `json:"modelUsage"`
TotalSessions int `json:"totalSessions"`
TotalMessages int `json:"totalMessages"`
}
+9
View File
@@ -0,0 +1,9 @@
package claude
import "testing"
func TestModelTypesCompile(t *testing.T) {
_ = StatsCache{}
_ = DailyActivity{}
_ = ModelUsage{}
}
+24
View File
@@ -0,0 +1,24 @@
package claude
import (
"os"
"strings"
)
func TailLastNLines(path string, n int) ([]string, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
lines := strings.Split(string(content), "\n")
var result []string
for i := len(lines) - 1; i >= 0 && len(result) < n; i-- {
if lines[i] != "" {
result = append(result, lines[i])
}
}
return result, nil
}
+34
View File
@@ -0,0 +1,34 @@
package claude
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestTailLastNLines_NewestFirst(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "history.jsonl")
var b strings.Builder
for i := 1; i <= 5; i++ {
b.WriteString("line")
b.WriteString([]string{"1", "2", "3", "4", "5"}[i-1])
b.WriteString("\n")
}
if err := os.WriteFile(p, []byte(b.String()), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
lines, err := TailLastNLines(p, 2)
if err != nil {
t.Fatalf("TailLastNLines: %v", err)
}
if len(lines) != 2 {
t.Fatalf("len=%d", len(lines))
}
if lines[0] != "line5" || lines[1] != "line4" {
t.Fatalf("got=%v", lines)
}
}
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
go test ./...
@@ -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,213 @@
# Morning Report System Design
**Date:** 2025-01-02
**Status:** Approved
**Author:** PA + User collaboration
## Overview
A daily morning dashboard that aggregates useful information into a single Markdown file, generated automatically via systemd timer and refreshable on-demand.
## Output
- **Format:** Markdown
- **Location:** `~/.claude/reports/morning.md`
- **Archive:** `~/.claude/reports/archive/YYYY-MM-DD.md` (30 days retention)
## Schedule
- **Automatic:** Systemd timer at 8:00 AM Pacific
- **On-demand:** `/morning` command for manual refresh
## Report Sections
### 1. Weather
- **Source:** wttr.in (no API key)
- **Location:** Seattle, WA, USA
- **LLM:** Haiku (parse output, add hints like "bring umbrella")
### 2. Email
- **Source:** Gmail skill (existing)
- **Display:** Unread count, urgent highlights, top 5 emails
- **LLM:** Sonnet (triage urgency, summarize)
### 3. Calendar
- **Source:** gcal skill (existing)
- **Display:** Today's events + tomorrow preview
- **LLM:** None (structured JSON, Python formatting)
### 4. Stocks
- **Source:** stock-lookup skill (existing)
- **Watchlist:** CRWV, NVDA, MSFT
- **Display:** Price, daily change, trend indicator
- **LLM:** Haiku (format table, light commentary)
### 5. Tasks
- **Source:** Google Tasks API (new integration)
- **Display:** Pending items, due dates, top 5
- **LLM:** None (structured JSON, Python formatting)
### 6. Infrastructure
- **Source:** k8s-quick-status + sysadmin-health skills (existing)
- **Display:** Traffic light status (green/yellow/red)
- **LLM:** Haiku (interpret health output)
- **Future:** Enhanced detail levels (fc-042)
### 7. News
- **Source:** RSS feeds (Hacker News, Lobsters)
- **Display:** Top 5 headlines with scores
- **LLM:** Sonnet (summarize headlines)
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ morning-report skill │
├─────────────────────────────────────────────────────────────┤
│ scripts/generate.py # Main orchestrator │
│ scripts/collectors/ # Data fetchers │
│ ├── gmail.py # Reuse existing gmail skill │
│ ├── gcal.py # Reuse existing gcal skill │
│ ├── gtasks.py # New: Google Tasks API │
│ ├── stocks.py # Reuse stock-lookup skill │
│ ├── weather.py # wttr.in integration │
│ ├── infra.py # K8s + workstation health │
│ └── news.py # RSS/Hacker News feeds │
│ scripts/render.py # Markdown templating │
│ config.json # Watchlist, location, feeds │
└─────────────────────────────────────────────────────────────┘
```
## Configuration
File: `~/.claude/skills/morning-report/config.json`
```json
{
"version": "1.0",
"schedule": {
"time": "08:00",
"timezone": "America/Los_Angeles"
},
"output": {
"path": "~/.claude/reports/morning.md",
"archive": true,
"archive_days": 30
},
"stocks": {
"watchlist": ["CRWV", "NVDA", "MSFT"],
"show_trend": true
},
"weather": {
"location": "Seattle,WA,USA",
"provider": "wttr.in"
},
"email": {
"max_display": 5,
"triage": true
},
"calendar": {
"show_tomorrow": true
},
"tasks": {
"max_display": 5,
"show_due_dates": true
},
"infra": {
"check_k8s": true,
"check_workstation": true,
"detail_level": "traffic_light"
},
"news": {
"feeds": [
{"name": "Hacker News", "url": "https://hnrss.org/frontpage", "limit": 3},
{"name": "Lobsters", "url": "https://lobste.rs/rss", "limit": 2}
],
"summarize": true
}
}
```
## LLM Delegation
| Section | LLM Tier | Reason |
|---------|----------|--------|
| Weather | Haiku | Parse wttr.in, add hints |
| Email | Sonnet | Triage urgency, summarize |
| Calendar | None | Structured JSON, template |
| Stocks | Haiku | Format, light commentary |
| Tasks | None | Structured JSON, template |
| Infra | Haiku | Interpret health output |
| News | Sonnet | Summarize headlines |
## Error Handling
Each collector is isolated - failures don't break the whole report.
| Collector | Timeout | Retries | Fallback |
|-----------|---------|---------|----------|
| Weather | 5s | 1 | "Weather unavailable" |
| Email | 10s | 2 | Show error + auth hint |
| Calendar | 10s | 2 | Show error |
| Stocks | 5s | 1 | Partial results per-symbol |
| Tasks | 10s | 2 | Show error |
| Infra | 15s | 1 | "Status unknown" (yellow) |
| News | 10s | 1 | "News unavailable" |
## Logging
- Run logs: `~/.claude/logs/morning-report.log`
- Systemd logs: `journalctl --user -u morning-report`
## Implementation Order
1. Config + skeleton structure
2. Weather, Stocks, Infra collectors (easy wins)
3. Google Tasks collector (new OAuth scope)
4. News collector
5. Orchestrator + renderer
6. Systemd timer + `/morning` command
## Future Considerations
- **fc-041:** Terminal output version (motd-style)
- **fc-042:** Enhanced infrastructure detail levels
## Sample Output
```markdown
# Morning Report - Thu Jan 2, 2025
## Weather
Seattle: 45°F, Partly Cloudy | High 52° Low 38° | Rain likely 3PM
## Email (3 unread, 1 urgent)
[!] From: boss@work.com - "Q4 numbers needed"
* From: github.com - "PR #123 merged"
* From: newsletter@tech.com - "Weekly digest"
## Today
* 9:00 AM - Standup (30m)
* 2:00 PM - 1:1 with Sarah (1h)
Tomorrow: 3 events, first at 10:00 AM
## Stocks
CRWV $79.32 +10.8% NVDA $188.85 +1.3% MSFT $430.50 -0.2%
## Tasks (4 pending)
* Finish quarterly report (due today)
* Review PR #456
* Book travel for conference
* Call dentist
## Infrastructure
K8s Cluster: [OK] | Workstation: [OK]
## Tech News
* "OpenAI announces GPT-5" (Hacker News, 342 pts)
* "Rust 2.0 released" (Lobsters, 89 votes)
* "Kubernetes 1.32 features" (Hacker News, 156 pts)
---
Generated: 2025-01-02 08:00:00 PT
```
@@ -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 |
+388
View File
@@ -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,442 @@
# Claude Ops Dashboard Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Extend the existing Go web dashboard to monitor Claude Code agent activity, context usage, skills/commands usage signals, and cost-related token stats from your local `~/.claude/` directory.
**Architecture:** Keep the current lightweight Go HTTP server + static HTML/JS frontend. Add a new “Claude Ops” section with API endpoints that parse local Claude Code state files (primarily `~/.claude/stats-cache.json`, `~/.claude/history.jsonl`, `~/.claude/state/component-registry.json`, and directory listings under `~/.claude/agents`, `~/.claude/skills`, `~/.claude/commands`). Frontend gains new navigation tabs + tables/charts using vanilla JS.
**Tech Stack:** Go 1.21+, chi router, vanilla HTML/CSS/JS (no React), file-based data sources under `~/.claude/`.
---
## Scope (YAGNI)
In this first iteration, focus on read-only analytics:
- Daily activity + token usage (from `~/.claude/stats-cache.json`)
- Recent sessions list (from `~/.claude/history.jsonl` without parsing full message bodies)
- Installed agents/skills/commands inventory (from `~/.claude/agents/`, `~/.claude/skills/`, `~/.claude/commands/`)
- “Debug” view: show which data files are missing/unreadable and last-modified timestamps
Explicitly out of scope:
- Real-time websocket streaming
- Multi-user auth
- Editing/triggering slash commands
- Deep semantic parsing of every history event (well add iteratively)
---
## Task 1: Add Claude directory config to server
**Files:**
- Modify: `~/.claude/dashboard/cmd/server/main.go`
- Modify: `~/.claude/dashboard/README.md`
**Step 1: Write the failing test**
Create a minimal unit test that ensures server config defaults to `~/.claude` when not specified.
- Create: `~/.claude/dashboard/cmd/server/config_test.go`
```go
package main
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultClaudeDir(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("UserHomeDir: %v", err)
}
want := filepath.Join(home, ".claude")
got := defaultClaudeDir()
if got != want {
t.Fatalf("defaultClaudeDir() = %q, want %q", got, want)
}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./...`
Expected: FAIL with `undefined: defaultClaudeDir`
**Step 3: Write minimal implementation**
- Modify: `~/.claude/dashboard/cmd/server/main.go`
Add helper:
```go
func defaultClaudeDir() string {
home, err := os.UserHomeDir()
if err != nil {
return "/home/will/.claude" // fallback; best-effort
}
return filepath.Join(home, ".claude")
}
```
Add CLI flag:
- `--claude` (default `~/.claude`)
**Step 4: Run test to verify it passes**
Run: `go test ./...`
Expected: PASS
**Step 5: Commit**
```bash
git add cmd/server/main.go cmd/server/config_test.go README.md
git commit -m "feat: add default claude dir config"
```
---
## Task 2: Add Claude models for API responses
**Files:**
- Create: `~/.claude/dashboard/internal/claude/models.go`
**Step 1: Write the failing test**
- Create: `~/.claude/dashboard/internal/claude/models_test.go`
```go
package claude
import "testing"
func TestModelTypesCompile(t *testing.T) {
_ = StatsCache{}
_ = DailyActivity{}
_ = ModelUsage{}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./...`
Expected: FAIL with `undefined: StatsCache`
**Step 3: Write minimal implementation**
- Create: `~/.claude/dashboard/internal/claude/models.go`
Implement structs matching the subset we use:
```go
package claude
type DailyActivity struct {
Date string `json:"date"`
MessageCount int `json:"messageCount"`
SessionCount int `json:"sessionCount"`
ToolCallCount int `json:"toolCallCount"`
}
type DailyModelTokens struct {
Date string `json:"date"`
TokensByModel map[string]int `json:"tokensByModel"`
}
type ModelUsage struct {
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
CacheReadInputTokens int `json:"cacheReadInputTokens"`
CacheCreationInputTokens int `json:"cacheCreationInputTokens"`
WebSearchRequests int `json:"webSearchRequests"`
CostUSD float64 `json:"costUSD"`
ContextWindow int `json:"contextWindow"`
}
type StatsCache struct {
Version int `json:"version"`
LastComputedDate string `json:"lastComputedDate"`
DailyActivity []DailyActivity `json:"dailyActivity"`
DailyModelTokens []DailyModelTokens `json:"dailyModelTokens"`
ModelUsage map[string]ModelUsage `json:"modelUsage"`
TotalSessions int `json:"totalSessions"`
TotalMessages int `json:"totalMessages"`
}
```
**Step 4: Run test to verify it passes**
Run: `go test ./...`
Expected: PASS
**Step 5: Commit**
```bash
git add internal/claude/models.go internal/claude/models_test.go
git commit -m "feat: add claude stats response models"
```
---
## Task 3: Implement Claude file loader
**Files:**
- Create: `~/.claude/dashboard/internal/claude/loader.go`
- Test: `~/.claude/dashboard/internal/claude/loader_test.go`
**Step 1: Write the failing test**
```go
package claude
import (
"os"
"path/filepath"
"testing"
)
func TestLoadStatsCache(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "stats-cache.json")
err := os.WriteFile(p, []byte(`{"version":1,"lastComputedDate":"2025-12-31","totalSessions":1,"totalMessages":2}`), 0644)
if err != nil {
t.Fatalf("WriteFile: %v", err)
}
loader := NewLoader(dir)
stats, err := loader.LoadStatsCache()
if err != nil {
t.Fatalf("LoadStatsCache: %v", err)
}
if stats.TotalSessions != 1 {
t.Fatalf("TotalSessions=%d", stats.TotalSessions)
}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./...`
Expected: FAIL with `undefined: NewLoader`
**Step 3: Write minimal implementation**
Implement `Loader` with:
- `NewLoader(claudeDir string)`
- `LoadStatsCache() (*StatsCache, error)` reads `<claudeDir>/stats-cache.json`
- `ListDir(name string) ([]DirEntry, error)` for `agents/`, `skills/`, `commands/`
- `FileInfo(path string) (FileMeta, error)` for debug view
**Step 4: Run test to verify it passes**
Run: `go test ./...`
Expected: PASS
**Step 5: Commit**
```bash
git add internal/claude/loader.go internal/claude/loader_test.go
git commit -m "feat: load claude stats-cache.json"
```
---
## Task 4: Add new Claude Ops API routes
**Files:**
- Modify: `~/.claude/dashboard/cmd/server/main.go`
- Create: `~/.claude/dashboard/internal/api/claude_handlers.go`
- Modify: `~/.claude/dashboard/internal/api/handlers.go` (only if you want shared helpers)
**Step 1: Write the failing test**
- Create: `~/.claude/dashboard/internal/api/claude_handlers_test.go`
```go
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/will/k8s-agent-dashboard/internal/claude"
)
type fakeLoader struct{}
func (f fakeLoader) LoadStatsCache() (*claude.StatsCache, error) {
return &claude.StatsCache{TotalSessions: 3}, nil
}
func TestGetClaudeStats(t *testing.T) {
r := chi.NewRouter()
r.Get("/api/claude/stats", GetClaudeStats(fakeLoader{}))
req := httptest.NewRequest(http.MethodGet, "/api/claude/stats", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./...`
Expected: FAIL with `undefined: GetClaudeStats`
**Step 3: Write minimal implementation**
Create endpoints:
- `GET /api/claude/health` → returns `{status:"ok", claudeDir:"..."}` and file presence checks
- `GET /api/claude/stats` → returns parsed `StatsCache`
- `GET /api/claude/inventory` → lists agents/skills/commands entries
- `GET /api/claude/debug/files` → returns file metas for key files and last-modified
Wire in `cmd/server/main.go`:
- Build loader from `--claude` flag
- Register routes under `/api/claude`
**Step 4: Run test to verify it passes**
Run: `go test ./...`
Expected: PASS
**Step 5: Commit**
```bash
git add cmd/server/main.go internal/api/claude_handlers.go internal/api/claude_handlers_test.go
git commit -m "feat: add claude ops api endpoints"
```
---
## Task 5: Add new UI navigation tabs
**Files:**
- Modify: `~/.claude/dashboard/cmd/server/web/index.html`
- Modify: `~/.claude/dashboard/cmd/server/web/static/css/style.css`
**Step 1: Make a minimal UI change (no tests)**
Add nav buttons:
- Overview
- Usage
- Inventory
- Debug
Add new `<section>` elements mirroring existing “views” pattern.
**Step 2: Manual verification**
Run: `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
Expected: New tabs switch views (even if empty).
**Step 3: Commit**
```bash
git add cmd/server/web/index.html cmd/server/web/static/css/style.css
git commit -m "feat: add claude ops dashboard views"
```
---
## Task 6: Implement frontend data fetching + rendering
**Files:**
- Modify: `~/.claude/dashboard/cmd/server/web/static/js/app.js`
**Step 1: Add API calls**
Add functions:
- `loadClaudeStats()``GET /api/claude/stats`
- `loadClaudeInventory()``GET /api/claude/inventory`
- `loadClaudeDebugFiles()``GET /api/claude/debug/files`
Integrate into `loadAllData()`.
**Step 2: Add render functions**
- Overview: show totals + lastComputedDate
- Usage: simple table for `dailyActivity` (date, messages, sessions, tool calls)
- Inventory: 3 columns lists: agents, skills, commands
- Debug: table of key files with status/missing + mtime
**Step 3: Manual verification**
Run: `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
Expected: Data populates from your local `~/.claude/stats-cache.json`.
**Step 4: Commit**
```bash
git add cmd/server/web/static/js/app.js
git commit -m "feat: render claude usage and inventory data"
```
---
## Task 7: Add “cost optimization” signals (derived)
**Files:**
- Modify: `~/.claude/dashboard/internal/api/claude_handlers.go`
- Modify: `~/.claude/dashboard/internal/claude/models.go`
**Step 1: Write the failing test**
Add a test that expects derived fields:
- cache hit ratio estimate: `cacheReadInputTokens / (inputTokens + cacheReadInputTokens + cacheCreationInputTokens)` (best-effort)
- top model by output tokens
**Step 2: Run test to verify it fails**
Run: `go test ./...`
Expected: FAIL because fields arent present
**Step 3: Implement derived summary endpoint**
- `GET /api/claude/summary` returns:
- totals
- per-model tokens
- derived cost signals
**Step 4: Run tests**
Run: `go test ./...`
Expected: PASS
**Step 5: Commit**
```bash
git add internal/api/claude_handlers.go internal/claude/models.go internal/api/claude_handlers_test.go
git commit -m "feat: add claude summary and cache efficiency signals"
```
---
## Task 8: End-to-end check
**Files:**
- None
**Step 1: Run tests**
Run: `go test ./...`
Expected: PASS
**Step 2: Run server locally**
Run: `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
Expected: Browser shows Claude Ops tabs with live data.
---
## Notes / Follow-ups (later iterations)
- Parse `~/.claude/history.jsonl` into “sessions” and show recent slash commands usage (requires schema discovery)
- Add “top tools called” chart (requires richer history parsing)
- Add alert thresholds (e.g., token spikes day-over-day)
@@ -0,0 +1,660 @@
# Claude Real-Time Monitoring (SSE) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add real-time-ish monitoring of Claude Code agent activity to the existing Go dashboard, with a backlog (last 200 history events) plus SSE updates, shown in a new “Live” UI feed with prettified rows and expandable raw JSON.
**Architecture:** Add an in-memory `EventHub` (pub/sub + ring buffer) that receives events from a `HistoryTailer` (tails `~/.claude/history.jsonl`) and a debounced file watcher (for `stats-cache.json` and `state/component-registry.json`). Expose a REST backlog endpoint (`/api/claude/live/backlog`) returning normalized `Event` objects (newest→oldest) and an SSE stream endpoint (`/api/claude/stream`) that pushes new events to the browser. Frontend uses `EventSource` plus batching (render every 12s).
**Tech Stack:** Go 1.21+, `chi`, vanilla HTML/CSS/JS, optional `fsnotify`.
---
## Decisions (locked)
- Transport: SSE first (WebSockets later).
- Acceptable latency: 25 seconds.
- UI: prettified table with expandable raw JSON.
- Backlog: enabled, default `limit=200`.
- Backlog ordering: **newest → oldest**.
- Parsing: **generic-first**, best-effort extraction of a few fields; always preserve raw JSON.
- Backlog response format: **normalized `events`** (not just raw lines).
---
## Data Contract
### Event JSON
All events share:
```json
{
"id": 123,
"ts": "2026-01-01T12:00:00Z",
"type": "history.append",
"data": {
"summary": {
"sessionId": "...",
"project": "...",
"display": "/model"
},
"rawLine": "{...}",
"json": { "...": "..." },
"parseError": "..."
}
}
```
Event types:
- `history.append`
- `file.changed`
- `server.notice`
- `server.error`
---
## Task 1: Add `Event` types
**Files:**
- Create: `~/.claude/dashboard/internal/claude/events.go`
- Test: `~/.claude/dashboard/internal/claude/events_test.go`
**Step 1: Write the failing test**
```go
package claude
import "testing"
func TestEventTypesCompile(t *testing.T) {
_ = Event{}
_ = EventTypeHistoryAppend
_ = EventTypeFileChanged
_ = EventTypeServerNotice
_ = EventTypeServerError
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./...`
Expected: FAIL with `undefined: Event`
**Step 3: Write minimal implementation**
```go
package claude
import "time"
type EventType string
const (
EventTypeHistoryAppend EventType = "history.append"
EventTypeFileChanged EventType = "file.changed"
EventTypeServerNotice EventType = "server.notice"
EventTypeServerError EventType = "server.error"
)
type Event struct {
ID int64 `json:"id"`
TS time.Time `json:"ts"`
Type EventType `json:"type"`
Data any `json:"data"`
}
```
**Step 4: Run test to verify it passes**
Run: `go test ./...`
Expected: PASS
**Step 5: Commit**
```bash
git add internal/claude/events.go internal/claude/events_test.go
git commit -m "feat: add real-time event types"
```
---
## Task 2: Implement `EventHub` (pub/sub + ring buffer)
**Files:**
- Create: `~/.claude/dashboard/internal/claude/eventhub.go`
- Test: `~/.claude/dashboard/internal/claude/eventhub_test.go`
**Step 1: Write failing tests**
```go
package claude
import (
"testing"
"time"
)
func TestEventHub_PublishSubscribe(t *testing.T) {
hub := NewEventHub(10)
ch, cancel := hub.Subscribe()
defer cancel()
hub.Publish(Event{TS: time.Unix(1, 0), Type: EventTypeServerNotice, Data: map[string]any{"msg": "hi"}})
select {
case ev := <-ch:
if ev.Type != EventTypeServerNotice {
t.Fatalf("type=%s", ev.Type)
}
if ev.ID == 0 {
t.Fatalf("expected id to be assigned")
}
default:
t.Fatalf("expected event")
}
}
func TestEventHub_ReplaySince(t *testing.T) {
hub := NewEventHub(3)
hub.Publish(Event{TS: time.Unix(1, 0), Type: EventTypeServerNotice}) // id 1
hub.Publish(Event{TS: time.Unix(2, 0), Type: EventTypeServerNotice}) // id 2
hub.Publish(Event{TS: time.Unix(3, 0), Type: EventTypeServerNotice}) // id 3
got := hub.ReplaySince(1)
if len(got) != 2 {
t.Fatalf("len=%d", len(got))
}
if got[0].ID != 2 || got[1].ID != 3 {
t.Fatalf("ids=%d,%d", got[0].ID, got[1].ID)
}
}
```
**Step 2: Run tests to verify RED**
Run: `go test ./...`
Expected: FAIL with `undefined: NewEventHub`
**Step 3: Write minimal implementation**
Implement:
- `type EventHub struct { ... }`
- `NewEventHub(bufferSize int) *EventHub`
- `Publish(ev Event) Event`:
- assign `ID` if zero using an internal counter
- set `TS = time.Now()` if zero
- append to ring buffer
- broadcast to subscriber channels (non-blocking send)
- `Subscribe() (chan Event, func())` returns a buffered channel and a cancel func
- `ReplaySince(lastID int64) []Event`
**Step 4: Run tests to verify GREEN**
Run: `go test ./...`
Expected: PASS
**Step 5: Commit**
```bash
git add internal/claude/eventhub.go internal/claude/eventhub_test.go
git commit -m "feat: add event hub with replay buffer"
```
---
## Task 3: Tail last N lines helper (newest → oldest)
**Files:**
- Create: `~/.claude/dashboard/internal/claude/tail.go`
- Test: `~/.claude/dashboard/internal/claude/tail_test.go`
**Step 1: Write the failing test**
```go
package claude
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestTailLastNLines_NewestFirst(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "history.jsonl")
var b strings.Builder
for i := 1; i <= 5; i++ {
b.WriteString("line")
b.WriteString([]string{"1","2","3","4","5"}[i-1])
b.WriteString("\n")
}
if err := os.WriteFile(p, []byte(b.String()), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
lines, err := TailLastNLines(p, 2)
if err != nil {
t.Fatalf("TailLastNLines: %v", err)
}
if len(lines) != 2 {
t.Fatalf("len=%d", len(lines))
}
if lines[0] != "line5" || lines[1] != "line4" {
t.Fatalf("got=%v", lines)
}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./...`
Expected: FAIL with `undefined: TailLastNLines`
**Step 3: Minimal implementation**
Create `~/.claude/dashboard/internal/claude/tail.go`:
- `TailLastNLines(path string, n int) ([]string, error)`
- First implementation can be simple (read whole file + split) with a TODO noting potential optimization.
- Return text lines without trailing newline; newest→oldest ordering.
**Step 4: Run tests to verify it passes**
Run: `go test ./...`
Expected: PASS
**Step 5: Commit**
```bash
git add internal/claude/tail.go internal/claude/tail_test.go
git commit -m "feat: add tail last N lines helper"
```
---
## Task 4: Add backlog endpoint returning normalized events
**Files:**
- Create: `~/.claude/dashboard/internal/api/claude_live_handlers.go`
- Modify: `~/.claude/dashboard/internal/api/claude_handlers.go` (only to share helper if needed)
- Modify: `~/.claude/dashboard/cmd/server/main.go`
- Test: `~/.claude/dashboard/internal/api/claude_live_handlers_test.go`
**Step 1: Write failing test**
```go
package api
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"github.com/will/k8s-agent-dashboard/internal/claude"
)
type fakeClaudeDirLoader struct{ dir string }
func (f fakeClaudeDirLoader) ClaudeDir() string { return f.dir }
func (f fakeClaudeDirLoader) LoadStatsCache() (*claude.StatsCache, error) { return &claude.StatsCache{}, nil }
func (f fakeClaudeDirLoader) ListDir(name string) ([]claude.DirEntry, error) { return nil, nil }
func (f fakeClaudeDirLoader) FileMeta(relPath string) (claude.FileMeta, error) { return claude.FileMeta{}, nil }
func (f fakeClaudeDirLoader) PathExists(relPath string) bool { return true }
func TestClaudeLiveBacklog_DefaultLimit(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "history.jsonl")
if err := os.WriteFile(p, []byte("{\"display\":\"/model\"}\n"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
loader := fakeClaudeDirLoader{dir: dir}
r := chi.NewRouter()
r.Get("/api/claude/live/backlog", GetClaudeLiveBacklog(loader))
req := httptest.NewRequest(http.MethodGet, "/api/claude/live/backlog", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
// Assert response includes an "events" array with at least 1 event.
if !jsonContainsKey(t, w.Body.Bytes(), "events") {
t.Fatalf("expected events in response: %s", w.Body.String())
}
}
```
**Step 2: Run test to verify RED**
Run: `go test ./...`
Expected: FAIL with `undefined: GetClaudeLiveBacklog`
**Step 3: Minimal implementation**
Create `~/.claude/dashboard/internal/api/claude_live_handlers.go`:
- `GetClaudeLiveBacklog(loader ClaudeLoader) http.HandlerFunc`
- Query param: `limit` (default 200; clamp 1..1000)
- Use `claude.TailLastNLines(filepath.Join(loader.ClaudeDir(), "history.jsonl"), limit)`
- For each line, create a `claude.Event` with:
- `Type: claude.EventTypeHistoryAppend`
- `TS: time.Now()` (or parse timestamp if present in JSON)
- `Data` contains: `rawLine`, optionally `json`, optionally `parseError`, and `summary` (best effort)
- JSON parsing should be schema-agnostic: unmarshal into `map[string]any`.
- Summary extraction should look for keys: `sessionId`, `project`, `display` (strings).
Return payload:
```json
{ "limit": 200, "events": [ ... ] }
```
**Step 4: Run tests to verify GREEN**
Run: `go test ./...`
Expected: PASS
**Step 5: Wire route**
Modify `~/.claude/dashboard/cmd/server/main.go` to register:
- `GET /api/claude/live/backlog`
**Step 6: Run tests again**
Run: `go test ./...`
Expected: PASS
**Step 7: Commit**
```bash
git add cmd/server/main.go internal/api/claude_live_handlers.go internal/api/claude_live_handlers_test.go internal/claude/tail.go internal/claude/tail_test.go
git commit -m "feat: add claude live backlog endpoint"
```
---
## Task 5: Add SSE stream endpoint
**Files:**
- Create: `~/.claude/dashboard/internal/api/claude_stream_handlers.go`
- Modify: `~/.claude/dashboard/cmd/server/main.go`
- Test: `~/.claude/dashboard/internal/api/claude_stream_handlers_test.go`
**Step 1: Write failing test**
```go
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/will/k8s-agent-dashboard/internal/claude"
)
func TestClaudeStream_SendsEvent(t *testing.T) {
hub := claude.NewEventHub(10)
r := chi.NewRouter()
r.Get("/api/claude/stream", GetClaudeStream(hub))
req := httptest.NewRequest(http.MethodGet, "/api/claude/stream", nil)
w := httptest.NewRecorder()
// Publish after handler starts.
go func() {
time.Sleep(10 * time.Millisecond)
hub.Publish(claude.Event{Type: claude.EventTypeServerNotice, Data: map[string]any{"msg": "hi"}})
}()
r.ServeHTTP(w, req)
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
t.Fatalf("content-type=%q", ct)
}
if !strings.Contains(w.Body.String(), "event:") || !strings.Contains(w.Body.String(), "data:") {
t.Fatalf("body=%s", w.Body.String())
}
}
```
**Step 2: Run test to verify RED**
Run: `go test ./...`
Expected: FAIL with `undefined: GetClaudeStream`
**Step 3: Implement minimal SSE handler**
Create `~/.claude/dashboard/internal/api/claude_stream_handlers.go`:
- `GetClaudeStream(hub *claude.EventHub) http.HandlerFunc`
- Set headers:
- `Content-Type: text/event-stream`
- `Cache-Control: no-cache`
- Subscribe to hub; write events in SSE format:
```
event: <type>
id: <id>
data: <json>
```
- Flush after each event.
- Keep it minimal; add keepalive pings later.
**Step 4: Run tests to verify GREEN**
Run: `go test ./...`
Expected: PASS
**Step 5: Wire route**
Modify `~/.claude/dashboard/cmd/server/main.go` to register:
- `GET /api/claude/stream`
**Step 6: Run tests again**
Run: `go test ./...`
Expected: PASS
**Step 7: Commit**
```bash
git add cmd/server/main.go internal/api/claude_stream_handlers.go internal/api/claude_stream_handlers_test.go internal/claude/eventhub.go internal/claude/eventhub_test.go
git commit -m "feat: add claude sse stream endpoint"
```
---
## Task 6: Implement HistoryTailer to publish hub events
**Files:**
- Create: `~/.claude/dashboard/internal/claude/history_tailer.go`
- Test: `~/.claude/dashboard/internal/claude/history_tailer_test.go`
- Modify: `~/.claude/dashboard/cmd/server/main.go`
**Step 1: Write failing test**
```go
package claude
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestHistoryTailer_EmitsOnAppend(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "history.jsonl")
if err := os.WriteFile(p, []byte(""), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
hub := NewEventHub(10)
ch, cancel := hub.Subscribe()
defer cancel()
stop := make(chan struct{})
go TailHistoryFile(stop, hub, p)
// Append a line
if err := os.WriteFile(p, []byte("{\"display\":\"/status\"}\n"), 0o600); err != nil {
t.Fatalf("append: %v", err)
}
select {
case ev := <-ch:
if ev.Type != EventTypeHistoryAppend {
t.Fatalf("type=%s", ev.Type)
}
case <-time.After(200 * time.Millisecond):
t.Fatalf("timed out waiting for event")
}
close(stop)
}
```
**Step 2: Run tests to verify RED**
Run: `go test ./...`
Expected: FAIL with `undefined: TailHistoryFile`
**Step 3: Minimal implementation**
Create `~/.claude/dashboard/internal/claude/history_tailer.go` implementing:
- `TailHistoryFile(stop <-chan struct{}, hub *EventHub, path string)`
- Simple polling loop (since target latency is 25s):
- Every 500ms1s, stat file size
- If size grew, read new bytes from offset, split on `\n`, publish `history.append` events
- If size shrank, reset offset to 0 and publish `server.notice`
Also implement an internal helper to parse a history line into event `Data` with `summary` extraction (same logic as backlog).
**Step 4: Run tests to verify GREEN**
Run: `go test ./...`
Expected: PASS
**Step 5: Wire tailer in server**
Modify `~/.claude/dashboard/cmd/server/main.go`:
- Create hub at startup: `hub := claude.NewEventHub(1000)`
- Start goroutine tailing `filepath.Join(*claudeDir, "history.jsonl")`
**Step 6: Run tests again**
Run: `go test ./...`
Expected: PASS
**Step 7: Commit**
```bash
git add cmd/server/main.go internal/claude/history_tailer.go internal/claude/history_tailer_test.go
git commit -m "feat: stream history.jsonl appends via event hub"
```
---
## Task 7: Frontend Live view (EventSource + batching)
**Files:**
- Modify: `~/.claude/dashboard/cmd/server/web/index.html`
- Modify: `~/.claude/dashboard/cmd/server/web/static/js/app.js`
- Modify: `~/.claude/dashboard/cmd/server/web/static/css/style.css`
**Step 1: Add Live tab + markup**
- Add nav button: `data-view="live"`
- Add section:
- `id="live-view"`
- table `id="claude-live-table"` and a connection indicator `id="claude-live-conn"`
**Step 2: Add JS backlog fetch + EventSource**
Modify `~/.claude/dashboard/cmd/server/web/static/js/app.js`:
- On DOMContentLoaded, create `EventSource('/api/claude/stream')`
- Maintain:
- `let pendingLiveEvents = []`
- `let liveEvents = []` (cap at 500)
- Every 1000ms:
- move pending → live
- render table rows
- Fetch backlog once:
- `GET /api/claude/live/backlog?limit=200`
- prepend/append into `liveEvents` (newest→oldest returned; UI should render newest first at top)
**Step 3: CSS**
- Add a small connection badge style (green/yellow/red)
- Ensure table remains readable
**Step 4: Manual verification**
Run:
- `go test ./...`
- `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
Expected:
- Live tab loads backlog rows
- New history events appear on subsequent CLI activity
**Step 5: Commit**
```bash
git add cmd/server/web/index.html cmd/server/web/static/js/app.js cmd/server/web/static/css/style.css
git commit -m "feat: add live feed UI with SSE batching"
```
---
## Task 8: End-to-end verification
**Files:**
- None (unless a bug requires fixes)
**Step 1: Run full test suite**
Run: `go test ./...`
Expected: PASS (0 failures)
**Step 2: Manual smoke check**
Run:
- `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
Check:
- `curl -N http://localhost:8080/api/claude/stream` prints SSE lines
- `curl http://localhost:8080/api/claude/live/backlog?limit=5` returns `events` array
- Browser Live tab updates
---
## Execution handoff
Plan complete and saved to `~/.claude/docs/plans/2026-01-01-claude-realtime-monitoring-implementation.md`.
Two execution options:
1. Subagent-Driven (this session) — I dispatch fresh subagent per task, review between tasks, fast iteration
2. Parallel Session (separate) — Open new session with `superpowers:executing-plans`, batch execution with checkpoints
Which approach?
@@ -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
+34
View File
@@ -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
}
]
}
]
}
}
+73
View File
@@ -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()
+283
View File
@@ -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()
+173
View File
@@ -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()
+61
View File
@@ -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
+14 -2
View File
@@ -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
+406
View File
@@ -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()
+125
View File
@@ -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()
+127
View File
@@ -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()
+49
View File
@@ -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'")
+56
View File
@@ -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'")
+98
View File
@@ -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()
+74
View File
@@ -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
+798
View File
@@ -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
+150
View File
@@ -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
+903
View File
@@ -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"
```
+375
View File
@@ -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)
+173
View File
@@ -0,0 +1,173 @@
# ~/.claude Structure Verification Report
**Status: ALL CHECKS PASSED**
## Directory Structure Overview
```
~/.claude/
├── CLAUDE.md # Shared memory (exists)
├── README.md # Setup guide (exists)
├── settings.json # Claude settings (exists)
├── .gitignore # Git ignore (exists)
├── .claude-plugin/ # Plugin manifest
│ ├── plugin.json # Valid JSON
│ └── marketplace.json # Valid JSON
├── agents/ # 13 agent files + README
│ ├── README.md
│ ├── personal-assistant.md # Opus, proper frontmatter
│ ├── master-orchestrator.md # Opus
│ ├── linux-sysadmin.md # Sonnet
│ ├── k8s-orchestrator.md # Opus
│ ├── k8s-diagnostician.md # Sonnet
│ ├── argocd-operator.md # Sonnet
│ ├── prometheus-analyst.md # Sonnet
│ ├── git-operator.md # Sonnet
│ ├── programmer-orchestrator.md # Opus
│ ├── code-planner.md # Sonnet
│ ├── code-implementer.md # Sonnet
│ └── code-reviewer.md # Sonnet
├── skills/ # 6 skills + README
│ ├── README.md
│ ├── gmail/ # SKILL.md + scripts/ + references/
│ ├── gcal/ # SKILL.md + scripts/
│ ├── k8s-quick-status/ # SKILL.md + scripts/
│ ├── sysadmin-health/ # SKILL.md + scripts/
│ ├── usage/ # SKILL.md + scripts/
│ └── programmer-add-project/ # SKILL.md only
├── commands/ # 22 commands + README + subdirs
│ ├── README.md
│ ├── pa.md, help.md, status.md, config.md, ...
│ ├── k8s/ # K8s subcommands
│ └── sysadmin/ # Sysadmin subcommands
├── workflows/ # 6 workflows + README
│ ├── README.md
│ ├── deploy/, health/, incidents/, sysadmin/
│ └── validate-agent-format.yaml
├── hooks/ # Event handlers
│ ├── hooks.json # Valid JSON
│ ├── README.md
│ └── scripts/ # session-start.sh, pre-compact.sh
├── state/ # Shared state (all valid JSON)
│ ├── README.md
│ ├── system-instructions.json
│ ├── future-considerations.json
│ ├── model-policy.json
│ ├── autonomy-levels.json
│ ├── component-registry.json # 6 skills, 22 commands, 12 agents, 10 workflows
│ ├── personal-assistant-preferences.json
│ ├── kb.json
│ ├── personal-assistant/ # PA state
│ │ ├── general-instructions.json
│ │ ├── session-context.json
│ │ ├── kb.json
│ │ ├── history/ # index.json exists
│ │ ├── memory/ # decisions, facts, meta, preferences, projects
│ │ └── templates/
│ ├── sysadmin/ # Sysadmin state
│ ├── programmer/ # Programmer state
│ └── usage/ # Usage tracking
├── automation/ # 35+ managed scripts
│ ├── README.md
│ ├── validate-setup.sh, backup.sh, restore.sh, clean.sh
│ ├── memory-add.py, memory-list.py, search.py
│ ├── skill-info.py, agent-info.py, workflow-info.py
│ ├── completions.bash, completions.zsh
│ └── systemd/ # Service files
└── mcp/ # MCP integrations
├── README.md
├── gmail/ # Gmail venv + credentials
└── delegation/ # Delegation helpers
```
## Validation Results
| Category | Status | Details |
|----------|--------|---------|
| Directory structure | PASS | All 8 expected directories exist |
| Core files | PASS | CLAUDE.md, README.md, settings.json, .gitignore |
| Plugin structure | PASS | plugin.json valid |
| Hooks | PASS | hooks.json valid, scripts executable |
| Skills | PASS | 6 skills with SKILL.md, scripts executable |
| State files | PASS | All JSON files valid |
| PA state | PASS | All memory files present and valid |
| Gmail integration | PASS | venv + credentials present |
| Documentation | PASS | 7/7 READMEs present |
## Component Registry Cross-Reference
| Component Type | In Registry | On Disk | Match |
|----------------|-------------|---------|-------|
| Skills | 6 | 6 | YES |
| Agents | 12 | 13 | +1 (README) |
| Commands | 22 | 22+ | YES |
| Workflows | 10 | 6 dirs | YES (nested) |
## Notes
- All JSON files parse successfully
- All agent files have proper YAML frontmatter with name, description, model
- All skill scripts are executable
- Gmail venv and credentials are in place
- History/memory structure for PA agent mode is ready
## Issues Found
### GCal Integration - BROKEN
| Component | Status |
|-----------|--------|
| Calendar token | ✅ `~/.gmail-mcp/calendar_token.json` with `calendar.readonly` scope |
| Credentials | ✅ `~/.gmail-mcp/credentials.json` |
| Scripts | ❌ **FAIL** - `get_calendar_service` function missing |
**Root cause:** `agenda.py` and `next_event.py` import `get_calendar_service` from `gmail_mcp.utils.GCP.gmail_auth`, but this function doesn't exist. Only `get_gmail_service` is available.
### Fix: Add `get_calendar_service()` to gmail_auth.py
**File:** `~/.claude/mcp/gmail/venv/lib/python3.14/site-packages/gmail_mcp/utils/GCP/gmail_auth.py`
**Add after `get_gmail_service()` (line 63):**
```python
CALENDAR_SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]
def get_calendar_service():
"""
Handles Google Calendar API authentication and returns the service object.
Uses separate token file from Gmail.
"""
token_path = Path.home() / ".gmail-mcp" / "calendar_token.json"
credentials_path = os.getenv('GMAIL_CREDENTIALS_PATH',
str(Path.home() / ".gmail-mcp" / "credentials.json"))
creds = None
if token_path.exists():
creds = Credentials.from_authorized_user_file(str(token_path), CALENDAR_SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
if not os.path.exists(credentials_path):
raise FileNotFoundError(f"Credentials not found at {credentials_path}")
flow = InstalledAppFlow.from_client_secrets_file(credentials_path, CALENDAR_SCOPES)
creds = flow.run_local_server(port=0)
token_path.parent.mkdir(parents=True, exist_ok=True)
token_path.write_text(creds.to_json())
return build("calendar", "v3", credentials=creds)
```
## Recommendation
**Action:** Add `get_calendar_service()` function to gmail_auth.py to match gmail pattern.
+230
View File
@@ -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"
+134
View File
@@ -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"
}
}
}
+115
View File
@@ -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)
+110
View File
@@ -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
+171
View File
@@ -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"
```
+139 -99
View File
@@ -1,185 +1,225 @@
{
"version": 1,
"fetchedAt": "2026-01-01T10:06:08.447Z",
"fetchedAt": "2026-01-02T19:46:53.863Z",
"counts": [
{
"plugin": "context7@claude-plugins-official",
"unique_installs": 42693
"plugin": "frontend-design@claude-plugins-official",
"unique_installs": 55210
},
{
"plugin": "frontend-design@claude-plugins-official",
"unique_installs": 42607
"plugin": "context7@claude-plugins-official",
"unique_installs": 51260
},
{
"plugin": "github@claude-plugins-official",
"unique_installs": 24946
"unique_installs": 30480
},
{
"plugin": "serena@claude-plugins-official",
"unique_installs": 24069
"unique_installs": 27360
},
{
"plugin": "feature-dev@claude-plugins-official",
"unique_installs": 21182
"unique_installs": 27004
},
{
"plugin": "code-review@claude-plugins-official",
"unique_installs": 18350
"unique_installs": 24073
},
{
"plugin": "commit-commands@claude-plugins-official",
"unique_installs": 14203
},
{
"plugin": "atlassian@claude-plugins-official",
"unique_installs": 13865
"unique_installs": 17939
},
{
"plugin": "supabase@claude-plugins-official",
"unique_installs": 13573
"unique_installs": 15463
},
{
"plugin": "security-guidance@claude-plugins-official",
"unique_installs": 11942
},
{
"plugin": "agent-sdk-dev@claude-plugins-official",
"unique_installs": 10940
},
{
"plugin": "figma@claude-plugins-official",
"unique_installs": 10505
},
{
"plugin": "pr-review-toolkit@claude-plugins-official",
"unique_installs": 10481
},
{
"plugin": "playwright@claude-plugins-official",
"unique_installs": 8098
},
{
"plugin": "Notion@claude-plugins-official",
"unique_installs": 6541
},
{
"plugin": "explanatory-output-style@claude-plugins-official",
"unique_installs": 6504
"unique_installs": 15183
},
{
"plugin": "typescript-lsp@claude-plugins-official",
"unique_installs": 6463
"unique_installs": 14985
},
{
"plugin": "atlassian@claude-plugins-official",
"unique_installs": 14664
},
{
"plugin": "playwright@claude-plugins-official",
"unique_installs": 13094
},
{
"plugin": "agent-sdk-dev@claude-plugins-official",
"unique_installs": 12925
},
{
"plugin": "pr-review-toolkit@claude-plugins-official",
"unique_installs": 12613
},
{
"plugin": "figma@claude-plugins-official",
"unique_installs": 12405
},
{
"plugin": "ralph-wiggum@claude-plugins-official",
"unique_installs": 5526
},
{
"plugin": "linear@claude-plugins-official",
"unique_installs": 5384
},
{
"plugin": "plugin-dev@claude-plugins-official",
"unique_installs": 5202
},
{
"plugin": "laravel-boost@claude-plugins-official",
"unique_installs": 5100
},
{
"plugin": "hookify@claude-plugins-official",
"unique_installs": 4831
},
{
"plugin": "learning-output-style@claude-plugins-official",
"unique_installs": 4567
},
{
"plugin": "sentry@claude-plugins-official",
"unique_installs": 4012
},
{
"plugin": "greptile@claude-plugins-official",
"unique_installs": 3812
"unique_installs": 9988
},
{
"plugin": "pyright-lsp@claude-plugins-official",
"unique_installs": 3413
"unique_installs": 8672
},
{
"plugin": "gitlab@claude-plugins-official",
"unique_installs": 3280
"plugin": "explanatory-output-style@claude-plugins-official",
"unique_installs": 7994
},
{
"plugin": "slack@claude-plugins-official",
"unique_installs": 3153
"plugin": "Notion@claude-plugins-official",
"unique_installs": 7622
},
{
"plugin": "plugin-dev@claude-plugins-official",
"unique_installs": 6845
},
{
"plugin": "linear@claude-plugins-official",
"unique_installs": 6366
},
{
"plugin": "hookify@claude-plugins-official",
"unique_installs": 6067
},
{
"plugin": "laravel-boost@claude-plugins-official",
"unique_installs": 5606
},
{
"plugin": "greptile@claude-plugins-official",
"unique_installs": 5524
},
{
"plugin": "learning-output-style@claude-plugins-official",
"unique_installs": 5517
},
{
"plugin": "sentry@claude-plugins-official",
"unique_installs": 4870
},
{
"plugin": "vercel@claude-plugins-official",
"unique_installs": 2748
"unique_installs": 4061
},
{
"plugin": "gitlab@claude-plugins-official",
"unique_installs": 3980
},
{
"plugin": "slack@claude-plugins-official",
"unique_installs": 3825
},
{
"plugin": "gopls-lsp@claude-plugins-official",
"unique_installs": 1539
},
{
"plugin": "firebase@claude-plugins-official",
"unique_installs": 1379
"unique_installs": 3280
},
{
"plugin": "rust-analyzer-lsp@claude-plugins-official",
"unique_installs": 1264
"unique_installs": 2793
},
{
"plugin": "csharp-lsp@claude-plugins-official",
"unique_installs": 1138
"unique_installs": 2705
},
{
"plugin": "php-lsp@claude-plugins-official",
"unique_installs": 1031
},
{
"plugin": "stripe@claude-plugins-official",
"unique_installs": 999
},
{
"plugin": "swift-lsp@claude-plugins-official",
"unique_installs": 942
"unique_installs": 2405
},
{
"plugin": "jdtls-lsp@claude-plugins-official",
"unique_installs": 911
"unique_installs": 2396
},
{
"plugin": "stripe@claude-plugins-official",
"unique_installs": 2186
},
{
"plugin": "firebase@claude-plugins-official",
"unique_installs": 2147
},
{
"plugin": "clangd-lsp@claude-plugins-official",
"unique_installs": 880
"unique_installs": 2017
},
{
"plugin": "asana@claude-plugins-official",
"unique_installs": 712
"plugin": "swift-lsp@claude-plugins-official",
"unique_installs": 1993
},
{
"plugin": "lua-lsp@claude-plugins-official",
"unique_installs": 500
"unique_installs": 1324
},
{
"plugin": "asana@claude-plugins-official",
"unique_installs": 951
},
{
"plugin": "figma-mcp@claude-plugins-official",
"unique_installs": 90
"unique_installs": 93
},
{
"plugin": "example-plugin@claude-plugins-official",
"unique_installs": 29
},
{
"plugin": "gitlab-mr-review@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "terraform-ls@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "typescript-native-lsp@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "pm@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "lean-lsp@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "document-skills@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "bun-typescript@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "document-skills@claude-plugins-official",
"plugin": "context@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "claude-rules-generator@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "ccpm@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "feature-ears@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "ocpm@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "openspec@claude-plugins-official",
"unique_installs": 1
}
]
+17 -6
View File
@@ -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
}
],
@@ -65,6 +65,17 @@
"gitCommitSha": "74afe935da49efe782907e837a27ce618498099a",
"isLocal": false
}
],
"ralph-wiggum@claude-plugins-official": [
{
"scope": "user",
"installPath": "/home/will/.claude/plugins/cache/claude-plugins-official/ralph-wiggum/883f2ba69e50",
"version": "883f2ba69e50",
"installedAt": "2026-01-02T19:47:02.395Z",
"lastUpdated": "2026-01-06T20:00:15.709Z",
"gitCommitSha": "de89f3066c68d7a2f2d4190173fa46c26e2f30fd",
"isLocal": true
}
]
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"repo": "anthropics/claude-plugins-official"
},
"installLocation": "/home/will/.claude/plugins/marketplaces/claude-plugins-official",
"lastUpdated": "2026-01-01T10:11:34.804Z"
"lastUpdated": "2026-01-07T19:06:34.488Z"
},
"superpowers-marketplace": {
"source": {
+35
View File
@@ -0,0 +1,35 @@
# Morning Report - Sat Jan 03, 2026
## 🌤 Weather
Weather unavailable: <urlopen error _ssl.c:1063: The handshake operation timed out>
## 📧 Email
10 unread, 2 attention-worthy
- [!] 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
## 📅 Today
• 2:00 PM - Seattle Saturday (SAM + QED + Lecosho) (5h)
Tomorrow: 1 event, first at 2:00 PM
## 📈 Stocks
CRWV $79.32 +10.8% ▲ NVDA $188.85 +1.3% ▲ MSFT $472.94 -2.2% ▼
## ✅ Tasks
⚠️ Could not fetch tasks: ('invalid_scope: Bad Request', {'error': 'invalid_scope', 'error_description': 'Bad Request'})
## 🖥 Infrastructure
K8s: 🟢 | Workstation: 🟢
## 📰 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)
---
*Generated: 2026-01-03 08:00:20 PT*
+30
View File
@@ -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*
+27
View File
@@ -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*
+31
View File
@@ -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*
+42
View File
@@ -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*
+42
View File
@@ -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*
+4 -2
View File
@@ -9,8 +9,10 @@
"commit-commands@claude-plugins-official": true,
"superpowers@superpowers-marketplace": true,
"pyright-lsp@claude-plugins-official": true,
"superpowers-developing-for-claude-code@superpowers-marketplace": true
"superpowers-developing-for-claude-code@superpowers-marketplace": true,
"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"
}
+1
View File
@@ -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
+52
View File
@@ -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
+30
View File
@@ -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()
+54
View File
@@ -0,0 +1,54 @@
---
name: morning-report
description: Generate daily morning dashboard with email, calendar, stocks, weather, tasks, infrastructure status, and news
---
# Morning Report Skill
Aggregates useful information into a single Markdown dashboard.
## Usage
Generate report:
```bash
~/.claude/skills/morning-report/scripts/generate.py
```
Or use the `/morning` command.
## Output
- **Location:** `~/.claude/reports/morning.md`
- **Archive:** `~/.claude/reports/archive/YYYY-MM-DD.md`
## Sections
| Section | Source | LLM Tier |
|---------|--------|----------|
| Weather | wttr.in | Haiku |
| Email | Gmail API | Sonnet |
| Calendar | Google Calendar API | None |
| Stocks | Yahoo Finance | Haiku |
| Tasks | Google Tasks API | None |
| Infrastructure | k8s + sysadmin skills | Haiku |
| News | RSS feeds | Sonnet |
## Configuration
Edit `~/.claude/skills/morning-report/config.json` to customize:
- Stock watchlist
- Weather location
- RSS feeds
- Display limits
## Scheduling
Systemd timer runs at 8:00 AM Pacific daily.
```bash
# Check timer status
systemctl --user status morning-report.timer
# View logs
journalctl --user -u morning-report
```
+44
View File
@@ -0,0 +1,44 @@
{
"version": "1.0",
"schedule": {
"time": "08:00",
"timezone": "America/Los_Angeles"
},
"output": {
"path": "~/.claude/reports/morning.md",
"archive": true,
"archive_days": 30
},
"stocks": {
"watchlist": ["CRWV", "NVDA", "MSFT"],
"show_trend": true
},
"weather": {
"location": "Seattle,WA,USA",
"provider": "wttr.in"
},
"email": {
"max_display": 5,
"triage": true
},
"calendar": {
"show_tomorrow": true
},
"tasks": {
"enabled": true,
"max_display": 5,
"show_due_dates": true
},
"infra": {
"check_k8s": true,
"check_workstation": true,
"detail_level": "traffic_light"
},
"news": {
"feeds": [
{"name": "Hacker News", "url": "https://hnrss.org/frontpage", "limit": 3},
{"name": "Lobsters", "url": "https://lobste.rs/rss", "limit": 2}
],
"summarize": true
}
}
+1
View File
@@ -0,0 +1 @@
# Morning report collectors
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Calendar collector using existing gcal skill."""
import os
import sys
from datetime import datetime, timedelta
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")
)
try:
# Add gmail venv to path
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_calendar_service
service = get_calendar_service()
now = datetime.utcnow()
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
)
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()
)
return events_result.get("items", [])
except Exception as e:
return [{"error": str(e)}]
def format_events(today_events: list, tomorrow_events: list = None) -> str:
"""Format calendar events - no LLM needed, structured data."""
lines = []
# Today's events
if today_events and (len(today_events) == 0 or "error" not in today_events[0]):
if not today_events:
lines.append("No events today")
else:
for event in today_events:
start = event.get("start", {})
time_str = ""
if "dateTime" in start:
# Timed event
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"
summary = event.get("summary", "(No title)")
duration = ""
# 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")
)
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)"
)
else:
duration = f" ({mins}m)"
lines.append(f"{time_str} - {summary}{duration}")
elif today_events and "error" in today_events[0]:
error = today_events[0].get("error", "Unknown")
lines.append(f"⚠️ Could not fetch calendar: {error}")
else:
lines.append("No events today")
# Tomorrow preview
if tomorrow_events is not None:
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")
)
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}"
)
else:
lines.append("Tomorrow: No events")
return "\n".join(lines) if lines else "No calendar data"
def collect(config: dict) -> dict:
"""Main collector entry point."""
cal_config = config.get("calendar", {})
show_tomorrow = cal_config.get("show_tomorrow", True)
today_events = fetch_events("today")
tomorrow_events = fetch_events("tomorrow") if show_tomorrow else None
formatted = format_events(today_events, tomorrow_events)
has_error = today_events and len(today_events) == 1 and "error" in today_events[0]
return {
"section": "Today",
"icon": "📅",
"content": formatted,
"raw": {"today": today_events, "tomorrow": tomorrow_events},
"error": today_events[0].get("error") if has_error else None,
}
if __name__ == "__main__":
config = {"calendar": {"show_tomorrow": True}}
result = collect(config)
print(f"## {result['icon']} {result['section']}")
print(result["content"])
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Gmail collector using existing gmail skill."""
import os
import subprocess
import sys
from collections import defaultdict
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")
)
try:
# Add gmail venv to path
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()
)
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"],
}
)
return emails
except Exception as e:
return [{"error": str(e)}]
def triage_with_sonnet(emails: list) -> str:
"""Use Sonnet to triage and summarize emails."""
if not emails or (len(emails) == 1 and "error" in emails[0]):
error = emails[0].get("error", "Unknown error") if emails else "No data"
return f"⚠️ Could not fetch emails: {error}"
# Build email summary for Sonnet
email_text = []
for i, e in enumerate(emails[:10], 1):
sender = e.get("from", "Unknown").split("<")[0].strip().strip('"')
subject = e.get("subject", "(no subject)")[:80]
email_text.append(f"{i}. From: {sender}\n Subject: {subject}")
email_context = "\n\n".join(email_text)
prompt = f"""You are triaging emails for a morning report. Given these unread emails, provide a brief summary.
Format:
- First line: count and any urgent items (e.g., "5 unread, 1 urgent")
- Then list top emails with [!] for urgent, or plain bullet
- Keep each email to one line: sender - subject snippet (max 50 chars)
- Maximum 5 emails shown
Emails:
{email_context}
Output the formatted email section, nothing else."""
try:
result = subprocess.run(
[
"/home/will/.local/bin/claude",
"--print",
"--model",
"sonnet",
"-p",
prompt,
],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except Exception:
pass
# Fallback to basic format
lines = [f"{len(emails)} unread"]
for e in emails[:5]:
sender = e.get("from", "Unknown").split("<")[0].strip().strip('"')[:20]
subject = e.get("subject", "(no subject)")[:40]
lines.append(f"{sender} - {subject}")
return "\n".join(lines)
def collect(config: dict) -> dict:
"""Main collector entry point."""
email_config = config.get("email", {})
max_display = email_config.get("max_display", 5)
use_triage = email_config.get("triage", True)
emails = fetch_unread_emails(days=7, max_results=max_display + 10)
if use_triage and emails and "error" not in emails[0]:
formatted = triage_with_sonnet(emails)
else:
# Basic format or error
if emails and "error" not in emails[0]:
lines = [f"{len(emails)} unread"]
for e in emails[:max_display]:
sender = e.get("from", "Unknown").split("<")[0].strip().strip('"')[:20]
subject = e.get("subject", "(no subject)")[:40]
lines.append(f"{sender} - {subject}")
formatted = "\n".join(lines)
else:
error = emails[0].get("error", "Unknown") if emails else "No data"
formatted = f"⚠️ Could not fetch emails: {error}"
has_error = emails and len(emails) == 1 and "error" in emails[0]
return {
"section": "Email",
"icon": "📧",
"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,
}
if __name__ == "__main__":
config = {"email": {"max_display": 5, "triage": True}}
result = collect(config)
print(f"## {result['icon']} {result['section']}")
print(result["content"])
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Google Tasks collector."""
import json
import os
import sys
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.14/site-packages"
if str(venv_site) not in sys.path:
sys.path.insert(0, str(venv_site))
# Google Tasks API
try:
from google.oauth2.credentials import Credentials
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
SCOPES = ["https://www.googleapis.com/auth/tasks.readonly"]
TOKEN_PATH = Path.home() / ".gmail-mcp/tasks_token.json"
CREDS_PATH = Path.home() / ".gmail-mcp/credentials.json"
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 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)
creds = flow.run_local_server(port=0)
TOKEN_PATH.write_text(creds.to_json())
return creds
def fetch_tasks(max_results: int = 10) -> list:
"""Fetch tasks from Google Tasks API."""
if not GOOGLE_API_AVAILABLE:
return [{"error": "Google API libraries not installed"}]
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"
}
]
service = build("tasks", "v1", credentials=creds)
# Get default task list
tasklists = service.tasklists().list(maxResults=1).execute()
if not tasklists.get("items"):
return []
tasklist_id = tasklists["items"][0]["id"]
# Get tasks
results = (
service.tasks()
.list(
tasklist=tasklist_id,
maxResults=max_results,
showCompleted=False,
showHidden=False,
)
.execute()
)
tasks = results.get("items", [])
return tasks
except Exception as e:
return [{"error": str(e)}]
def format_tasks(tasks: list, max_display: int = 5) -> str:
"""Format tasks - no LLM needed, structured data."""
if not tasks:
return "No pending tasks"
if len(tasks) == 1 and "error" in tasks[0]:
return f"⚠️ Could not fetch tasks: {tasks[0]['error']}"
lines = []
# Count and header
total = len(tasks)
due_today = 0
today_str = datetime.now().strftime("%Y-%m-%d")
for task in tasks:
due = task.get("due", "")
if due and due.startswith(today_str):
due_today += 1
header = f"{total} pending"
if due_today > 0:
header += f", {due_today} due today"
lines.append(header)
# List tasks
for task in tasks[:max_display]:
title = task.get("title", "(No title)")
due = task.get("due", "")
due_str = ""
if due:
try:
due_date = datetime.fromisoformat(due.replace("Z", "+00:00"))
if due_date.date() == datetime.now().date():
due_str = " (due today)"
elif due_date.date() < datetime.now().date():
due_str = " (overdue!)"
else:
due_str = f" (due {due_date.strftime('%b %d')})"
except ValueError:
pass
lines.append(f"{title}{due_str}")
if total > max_display:
lines.append(f" ... and {total - max_display} more")
return "\n".join(lines)
def collect(config: dict) -> dict:
"""Main collector entry point."""
tasks_config = config.get("tasks", {})
max_display = tasks_config.get("max_display", 5)
tasks = fetch_tasks(max_display + 5)
formatted = format_tasks(tasks, max_display)
has_error = tasks and len(tasks) == 1 and "error" in tasks[0]
return {
"section": "Tasks",
"icon": "",
"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,
}
if __name__ == "__main__":
import sys
if "--auth" in sys.argv:
print("Starting Tasks API authentication...")
# 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:
print("❌ Authentication failed")
sys.exit(0)
config = {"tasks": {"max_display": 5}}
result = collect(config)
print(f"## {result['icon']} {result['section']}")
print(result["content"])

Some files were not shown because too many files have changed in this diff Show More