Browse Source

chore: merge main - pigeon replaces agentmail

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
0xDarkMatter 2 months ago
parent
commit
a9745a0a5b
81 changed files with 4217 additions and 141 deletions
  1. 3 2
      .claude-plugin/plugin.json
  2. 3 3
      AGENTS.md
  3. 15 6
      README.md
  4. 8 8
      commands/sync.md
  5. 92 0
      docs/AGENT-SKILLS-COMPLIANCE-BRIEF.md
  6. 67 6
      docs/SKILL-SUBAGENT-REFERENCE.md
  7. 87 48
      hooks/check-mail.sh
  8. 49 0
      scripts/install.ps1
  9. 45 0
      scripts/install.sh
  10. 4 1
      skills/api-design-ops/SKILL.md
  11. 4 1
      skills/astro-ops/SKILL.md
  12. 4 3
      skills/atomise/SKILL.md
  13. 4 1
      skills/auth-ops/SKILL.md
  14. 4 1
      skills/ci-cd-ops/SKILL.md
  15. 4 2
      skills/claude-code-debug/SKILL.md
  16. 4 2
      skills/claude-code-headless/SKILL.md
  17. 4 2
      skills/claude-code-hooks/SKILL.md
  18. 4 2
      skills/cli-ops/SKILL.md
  19. 3 0
      skills/code-stats/SKILL.md
  20. 4 1
      skills/color-ops/SKILL.md
  21. 3 0
      skills/container-orchestration/SKILL.md
  22. 3 0
      skills/data-processing/SKILL.md
  23. 4 1
      skills/debug-ops/SKILL.md
  24. 3 0
      skills/doc-scanner/SKILL.md
  25. 4 1
      skills/docker-ops/SKILL.md
  26. 5 3
      skills/explain/SKILL.md
  27. 3 0
      skills/file-search/SKILL.md
  28. 3 0
      skills/find-replace/SKILL.md
  29. 1843 0
      skills/genart-ops/SKILL.md
  30. 4 1
      skills/git-ops/SKILL.md
  31. 4 1
      skills/go-ops/SKILL.md
  32. 4 1
      skills/introspect/SKILL.md
  33. 3 0
      skills/iterate/SKILL.md
  34. 4 1
      skills/javascript-ops/SKILL.md
  35. 4 1
      skills/laravel-ops/SKILL.md
  36. 4 1
      skills/log-ops/SKILL.md
  37. 3 0
      skills/markitdown/SKILL.md
  38. 4 1
      skills/mcp-ops/SKILL.md
  39. 4 1
      skills/migrate-ops/SKILL.md
  40. 4 1
      skills/monitoring-ops/SKILL.md
  41. 4 1
      skills/nginx-ops/SKILL.md
  42. 4 1
      skills/perf-ops/SKILL.md
  43. 249 0
      skills/pigeon/SKILL.md
  44. 0 0
      skills/pigeon/assets/.gitkeep
  45. 0 0
      skills/pigeon/references/.gitkeep
  46. 157 0
      skills/pigeon/scripts/identicon.sh
  47. 725 0
      skills/pigeon/scripts/mail-db.sh
  48. 630 0
      skills/pigeon/scripts/test-mail.sh
  49. 4 1
      skills/postgres-ops/SKILL.md
  50. 3 0
      skills/project-planner/SKILL.md
  51. 5 2
      skills/python-async-ops/SKILL.md
  52. 4 2
      skills/python-cli-ops/SKILL.md
  53. 5 2
      skills/python-database-ops/SKILL.md
  54. 3 2
      skills/python-env/SKILL.md
  55. 5 2
      skills/python-fastapi-ops/SKILL.md
  56. 5 2
      skills/python-observability-ops/SKILL.md
  57. 4 2
      skills/python-pytest-ops/SKILL.md
  58. 4 2
      skills/python-typing-ops/SKILL.md
  59. 4 1
      skills/react-ops/SKILL.md
  60. 4 1
      skills/refactor-ops/SKILL.md
  61. 3 0
      skills/rest-ops/SKILL.md
  62. 3 0
      skills/review/SKILL.md
  63. 4 1
      skills/rust-ops/SKILL.md
  64. 4 1
      skills/scaffold/SKILL.md
  65. 3 2
      skills/screenshot/SKILL.md
  66. 4 1
      skills/security-ops/SKILL.md
  67. 4 3
      skills/setperms/SKILL.md
  68. 2 0
      skills/skill-creator/SKILL.md
  69. 5 3
      skills/spawn/SKILL.md
  70. 4 1
      skills/sql-ops/SKILL.md
  71. 3 0
      skills/sqlite-ops/SKILL.md
  72. 3 0
      skills/structural-search/SKILL.md
  73. 4 1
      skills/tailwind-ops/SKILL.md
  74. 3 0
      skills/task-runner/SKILL.md
  75. 3 0
      skills/techdebt/SKILL.md
  76. 3 0
      skills/testgen/SKILL.md
  77. 3 0
      skills/testing-ops/SKILL.md
  78. 4 2
      skills/tool-discovery/SKILL.md
  79. 4 1
      skills/typescript-ops/SKILL.md
  80. 3 0
      skills/unfold-admin/SKILL.md
  81. 4 1
      skills/vue-ops/SKILL.md

+ 3 - 2
.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "claude-mods",
   "version": "2.3.0",
-  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 65 skills, 3 commands, 5 rules, 3 hooks, 5 output styles, modern CLI tools",
+  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 67 skills, 3 commands, 5 rules, 4 hooks, 5 output styles, modern CLI tools",
   "author": "0xDarkMatter",
   "repository": "https://github.com/0xDarkMatter/claude-mods",
   "license": "MIT",
@@ -49,6 +49,7 @@
     ],
     "skills": [
       "skills/api-design-ops",
+      "skills/pigeon",
       "skills/astro-ops",
       "skills/atomise",
       "skills/auth-ops",
@@ -113,7 +114,7 @@
       "skills/typescript-ops",
       "skills/unfold-admin",
       "skills/vue-ops",
-      "skills/agentmail"
+      "skills/genart-ops"
     ],
     "rules": [
       "rules/cli-tools.md",

+ 3 - 3
AGENTS.md

@@ -5,9 +5,9 @@
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 - **23 expert agents** for specialized domains (React, Python, Go, Rust, AWS, git, etc.)
 - **3 commands** for session management (/sync, /save) and experimental features (/canvas)
-- **65 skills** for CLI tools, patterns, workflows, and development tasks
+- **67 skills** for CLI tools, patterns, workflows, and development tasks
 - **5 output styles** for response personality (Vesper, Spartan, Mentor, Executive, Pair)
-- **3 hooks** for pre-commit linting, post-edit formatting, and dangerous command warnings
+- **4 hooks** for pre-commit linting, post-edit formatting, dangerous command warnings, and inter-session mail
 
 ## Installation
 
@@ -37,7 +37,7 @@ cd claude-mods && ./scripts/install.sh  # or .\scripts\install.ps1 on Windows
 | `tools/` | Modern CLI toolkit documentation |
 | `tests/` | Validation scripts + justfile |
 | `scripts/` | Install scripts |
-| `docs/` | PLAN.md, DASH.md, WORKFLOWS.md |
+| `docs/` | PLAN.md, DASH.md, WORKFLOWS.md, SKILL-SUBAGENT-REFERENCE.md, AGENT-SKILLS-COMPLIANCE-BRIEF.md |
 
 ## Session Init
 

+ 15 - 6
README.md

@@ -12,16 +12,22 @@
 
 > *A comprehensive extension toolkit that transforms Claude Code into a specialized development powerhouse.*
 
-**claude-mods** is a production-ready plugin that extends Claude Code with 23 expert agents, 65 specialized skills, 5 output styles, 3 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
+**claude-mods** is a production-ready plugin that extends Claude Code with 23 expert agents, 67 specialized skills, 5 output styles, 4 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
 
-Built on [Anthropic's Agent Skills standard](https://github.com/anthropics/skills), claude-mods fills critical gaps in Claude Code's capabilities: persistent session state that survives across machines, on-demand expert knowledge for specialized domains, token-efficient modern CLI tools (10-100x faster than traditional alternatives), and proven workflow patterns for TDD, code review, and feature development. The toolkit implements Anthropic's [recommended patterns for long-running agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents), ensuring your development context never vanishes when sessions end.
+Built on the [Agent Skills specification](https://agentskills.io/specification) (an open standard backed by Anthropic, Vercel, Google, Microsoft, and 40+ agent platforms), claude-mods fills critical gaps in Claude Code's capabilities: persistent session state that survives across machines, on-demand expert knowledge for specialized domains, token-efficient modern CLI tools (10-100x faster than traditional alternatives), and proven workflow patterns for TDD, code review, and feature development. The toolkit implements Anthropic's [recommended patterns for long-running agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents), ensuring your development context never vanishes when sessions end.
 
 From Python async patterns to Rust ownership models, from AWS Fargate deployments to Craft CMS development - claude-mods provides the specialized knowledge and tools that transform Claude from a general-purpose assistant into a domain expert who understands your stack, remembers your workflow, and ships production code.
 
-**23 agents. 65 skills. 5 styles. 3 hooks. One install.**
+**23 agents. 67 skills. 5 styles. 4 hooks. One install.**
 
 ## Recent Updates
 
+**v2.3.1** (April 2026)
+- 🎨 **`genart-ops` skill** - Comprehensive generative art skill (1,843 lines) covering three.js scene scaffolding, p5.js sketch structure, SVG generation, GLSL shaders (noise, SDF, ray marching, IQ palettes), procedural algorithms (flow fields, Poisson disk, L-systems, WFC, Voronoi), and OKLAB/OKLCH colour theory
+- 📐 **Agent Skills spec compliance** - All 67 skills migrated to the [Agent Skills specification](https://agentskills.io/specification). Non-standard frontmatter fields moved into `metadata:` block, `license: MIT` and `metadata.author: claude-mods` on every skill. Verified 67/67 pass.
+- 📚 **Docs updated** - `SKILL-SUBAGENT-REFERENCE.md` rewritten with spec as standard, `naming-conventions.md` updated with spec-compliant frontmatter examples, `AGENT-SKILLS-COMPLIANCE-BRIEF.md` added to docs/
+- 📬 **`pigeon` skill** - Inter-session pmail between Claude Code sessions across projects. SQLite-backed messaging at `~/.claude/pmail.db` with hook-based notification. Integrated into `/sync` for session-start mail check.
+
 **v2.3.0** (March 2026)
 - 🎯 **Orchestrator-dispatch pattern** - Three skills upgraded from static reference dumps to active orchestrators that classify intent, dispatch to agents, and manage safety tiers:
   - **`git-ops`** + **`git-agent`** - First skill+agent pair. Orchestrator routes T1 reads inline, dispatches T2 writes and T3 destructive ops to a dedicated Sonnet background agent with preflight confirmation. Replaces `git-workflow`.
@@ -99,14 +105,14 @@ claude-mods/
 ├── .claude-plugin/     # Plugin metadata
 ├── agents/             # Expert subagents (22)
 ├── commands/           # Slash commands (3)
-├── skills/             # Custom skills (65)
+├── skills/             # Custom skills (67)
 ├── output-styles/      # Response personalities
 ├── hooks/              # Hook examples & docs
 ├── rules/              # Claude Code rules
 ├── tools/              # Modern CLI toolkit installers
 ├── scripts/            # Plugin install scripts
 ├── tests/              # Test suites + justfile
-├── docs/               # Project docs (PLAN.md, DASH.md)
+├── docs/               # Project docs
 └── templates/          # Extension templates
 ```
 
@@ -161,7 +167,7 @@ Install modern CLI tools (fd, rg, bat, etc.) for better performance:
 
 ## Skill Architecture
 
-All skills follow [Anthropic's official pattern](https://github.com/anthropics/skills) with consistent structure:
+All skills comply with the [Agent Skills specification](https://agentskills.io/specification) and follow a consistent structure:
 
 ```
 skill-name/
@@ -206,6 +212,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [cli-ops](skills/cli-ops/) | Production CLI tool patterns - agentic workflows, stream separation, exit codes |
 | [tailwind-ops](skills/tailwind-ops/) | Tailwind CSS patterns, v4 migration, components, configuration |
 | [color-ops](skills/color-ops/) | Color spaces, WCAG/APCA contrast checker, palette + harmony generators, CSS color functions, design tokens, color converter |
+| [genart-ops](skills/genart-ops/) | Generative art - three.js scenes, p5.js sketches, SVG generation, GLSL shaders, procedural algorithms, colour theory |
 
 #### Data & API Skills
 | Skill | Description |
@@ -250,6 +257,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [python-env](skills/python-env/) | Fast Python environment management with uv |
 | [task-runner](skills/task-runner/) | Run project commands with just |
 | [screenshot](skills/screenshot/) | Find and display recent screenshots from common screenshot directories |
+| [pigeon](skills/pigeon/) | Inter-session pmail - SQLite-backed messaging between Claude Code sessions across projects |
 
 #### Development Skills
 | Skill | Description |
@@ -275,6 +283,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [pre-commit-lint.sh](hooks/pre-commit-lint.sh) | PreToolUse | Auto-lint staged files before commit (JS/TS, Python, Go, Rust, PHP) |
 | [post-edit-format.sh](hooks/post-edit-format.sh) | PostToolUse | Auto-format files after Write/Edit (Prettier, Ruff, gofmt, rustfmt) |
 | [dangerous-cmd-warn.sh](hooks/dangerous-cmd-warn.sh) | PreToolUse | Block destructive commands (force push, rm -rf, DROP TABLE) |
+| [check-mail.sh](hooks/check-mail.sh) | PreToolUse | Check for unread inter-session mail (10s cooldown, silent when empty) |
 
 ### Output Styles
 

+ 8 - 8
commands/sync.md

@@ -150,11 +150,11 @@ git log -1 --format="%h %s" 2>/dev/null
 
 ### Step 5: Check Mail
 
-Check for unread agentmail messages using the globally installed script:
+Check for unread pigeon messages using the globally installed script:
 
 ```bash
-bash "$HOME/.claude/agentmail/mail-db.sh" status 2>/dev/null
-bash "$HOME/.claude/agentmail/mail-db.sh" unread 2>/dev/null
+bash "$HOME/.claude/pigeon/mail-db.sh" status 2>/dev/null
+bash "$HOME/.claude/pigeon/mail-db.sh" unread 2>/dev/null
 ```
 
 - If the script doesn't exist or returns no unread, skip silently
@@ -238,15 +238,15 @@ what's already in context - no file read needed.
 
 ## Mail
 
-[If agentmail is installed and has unread messages:]
+[If pigeon is installed and has unread messages:]
 3 unread messages:
   From: some-api  |  Auth endpoints ready
   From: frontend  |  Need updated types
   From: infra     |  Deploy complete
 
-Run `agentmail read` to read.
+Run `pigeon read` to read.
 
-[If no unread messages or agentmail not installed: omit this section entirely]
+[If no unread messages or pigeon not installed: omit this section entirely]
 
 ## Quick Reference
 
@@ -258,7 +258,7 @@ Run `agentmail read` to read.
 
 ## Next Steps
 
-1. **Read mail**: N unread messages - `agentmail read` (when unread mail exists)
+1. **Read mail**: N unread messages - `pigeon read` (when unread mail exists)
 2. **Continue**: Fix callback URL handling
 3. **Check diff**: /sync --diff to see changes since save
 4. **Resume conversation**: `claude --resume abc123...` (when session_id present)
@@ -293,7 +293,7 @@ Project Synced: [project-name]
 
 ## Next Steps
 
-1. **Read mail**: N unread messages - `agentmail read` (when unread mail exists)
+1. **Read mail**: N unread messages - `pigeon read` (when unread mail exists)
 2. **Ready for new task** - No pending work detected
 3. **Create a plan** - Use native /plan for implementation planning
 4. **Save before leaving** - /save "notes" to persist state

+ 92 - 0
docs/AGENT-SKILLS-COMPLIANCE-BRIEF.md

@@ -0,0 +1,92 @@
+# Agent Skills Spec Compliance Brief
+
+> Bring all 66 skills in claude-mods into compliance with the Agent Skills specification at https://agentskills.io/specification
+
+## Background
+
+The Agent Skills format (originally by Anthropic, now an open standard backed by Vercel, Google, Microsoft, and 40+ agent platforms) has a formal spec. Our skills are ~80% compliant but have non-standard top-level frontmatter fields that need to move into the `metadata:` block.
+
+The spec allows exactly these top-level frontmatter fields:
+- `name` (required) - lowercase, hyphens, 1-64 chars, must match directory name
+- `description` (required) - 1-1024 chars
+- `license` (optional)
+- `compatibility` (optional) - 1-500 chars
+- `allowed-tools` (optional, experimental) - space-delimited
+- `metadata` (optional) - arbitrary key-value map
+
+Everything else is non-standard and should move into `metadata:`.
+
+## Changes Required Per Skill
+
+For each of the 66 SKILL.md files:
+
+1. **Move `related-skills`** into `metadata.related-skills` (comma-separated string)
+2. **Move `depends-on`** into `metadata.depends-on` (comma-separated string)
+3. **Move `version`** into `metadata.version` (if present)
+4. **Move `category`** into `metadata.category` (if present)
+5. **Move `requires`** into `metadata.requires-bins` / `metadata.requires-skills`
+6. **Move `cli-help`** into `metadata.cli-help`
+7. **Add `license: MIT`** if missing
+8. **Add `metadata.author: claude-mods`** if no metadata block exists
+9. **Ensure `compatibility`** stays top-level (it's spec-compliant already)
+10. **Ensure `allowed-tools`** stays top-level (spec-compliant)
+
+## Example Transform
+
+### Before
+```yaml
+---
+name: pigeon
+description: "Inter-session pmail..."
+allowed-tools: "Read Bash Grep"
+related-skills: [sqlite-ops]
+---
+```
+
+### After
+```yaml
+---
+name: pigeon
+description: "Inter-session pmail..."
+license: MIT
+allowed-tools: "Read Bash Grep"
+metadata:
+  author: claude-mods
+  related-skills: "sqlite-ops"
+---
+```
+
+## Directory Structure Convention
+
+The spec recommends these optional dirs alongside SKILL.md:
+- `scripts/` - executable code
+- `references/` - documentation loaded on demand
+- `assets/` - templates, resources
+
+Create `scripts/` and `assets/` with `.gitkeep` where missing. Most skills already have `references/`.
+
+## Do NOT Change
+
+- Markdown body content after frontmatter
+- File paths, directory names
+- Content in references/, scripts/, assets/
+
+## Validation
+
+After changes, frontmatter should pass: `npx skills-ref validate ./skills/<name>`
+
+Or manual check: only `name`, `description`, `license`, `compatibility`, `allowed-tools`, and `metadata` at the top level.
+
+## Execution
+
+```bash
+# From claude-mods root
+claude -p "Update all 66 SKILL.md files in /Users/mack/projects/claude-mods/skills/ to comply with Agent Skills spec. [paste this brief as context]" --dangerously-skip-permissions
+```
+
+## Reference
+
+- Spec: https://agentskills.io/specification
+- CLI: https://github.com/vercel-labs/skills (npx skills)
+- Directory: https://skills.sh
+- private-project core skills (already updated): /Users/mack/projects/private-project/00_forma/.claude/skills/

+ 67 - 6
docs/SKILL-SUBAGENT-REFERENCE.md

@@ -2,13 +2,74 @@
 
 Quick reference for Claude Code skill and subagent APIs. **Always check official docs first** - this may be outdated.
 
-## Skill Frontmatter Fields (January 2026)
+## Skill Frontmatter - Agent Skills Spec
+
+All skills MUST comply with the [Agent Skills specification](https://agentskills.io/specification). This is an open standard backed by Anthropic, Vercel, Google, Microsoft, and 40+ agent platforms.
+
+### Allowed Top-Level Fields
+
+Only these fields are permitted at the top level of SKILL.md frontmatter:
+
+```yaml
+---
+name: skill-name                    # Required: kebab-case, 1-64 chars, must match directory name
+description: "Triggers on: ..."     # Required: 1-1024 chars, include trigger keywords
+license: MIT                        # Required for claude-mods skills
+compatibility: "Python 3.10+..."    # Optional: 1-500 chars, runtime requirements
+allowed-tools: "Read Write Bash"    # Optional: space-delimited tool names
+metadata:                           # Optional: arbitrary key-value map
+  author: claude-mods               # Required for claude-mods skills
+  related-skills: "skill-a, skill-b"  # Comma-separated string (NOT array)
+  depends-on: "skill-c"             # Comma-separated string (NOT array)
+---
+```
+
+**Everything else goes in `metadata:`**. No other top-level keys are permitted.
+
+### Non-Standard Fields - Where They Go
+
+| Field | Location | Format |
+|-------|----------|--------|
+| `related-skills` | `metadata.related-skills` | Comma-separated string |
+| `depends-on` | `metadata.depends-on` | Comma-separated string |
+| `version` | `metadata.version` | String |
+| `category` | `metadata.category` | String |
+| `requires` | `metadata.requires` | String |
+| `cli-help` | `metadata.cli-help` | String |
+| `author` | `metadata.author` | String |
+
+### Rules for claude-mods Skills
+
+1. **`license: MIT`** on every skill (exception: skill-creator has custom license)
+2. **`metadata.author: claude-mods`** on every skill
+3. **No empty arrays** - if `depends-on` or `related-skills` would be empty, omit them entirely
+4. **No arrays in metadata** - use comma-separated strings instead
+5. **Directory structure**: every skill must have `scripts/`, `references/`, `assets/` (use `.gitkeep` if empty)
+
+### Validation
+
+```bash
+# Quick check: no non-standard top-level keys
+for f in skills/*/SKILL.md; do
+  awk '/^---$/{n++} n==1 && !/^(name|description|license|compatibility|allowed-tools|metadata|  |---):/' "$f"
+done
+
+# Full spec validation (if skills-ref CLI available)
+npx skills-ref validate ./skills/<name>
+```
+
+### Reference
+
+- Spec: https://agentskills.io/specification
+- CLI: https://github.com/vercel-labs/skills
+- Directory: https://skills.sh
+
+## Claude Code Skill Fields (Beyond Spec)
+
+These fields are specific to Claude Code's skill loader and are NOT part of the Agent Skills spec. Use only when needed:
 
 ```yaml
 ---
-name: skill-name                    # Required: kebab-case
-description: "Triggers on: ..."     # Required: include trigger keywords
-allowed-tools: "Read Write Bash"    # Restrict available tools
 disable-model-invocation: false     # true = manual /skill only
 user-invocable: true                # false = hide from slash completion
 context: main                       # main | fork (subagent isolation)
@@ -31,8 +92,8 @@ hooks:
 
 ## Decision Framework: Main Context vs Fork
 
-| Question | If Yes | If No |
-|----------|----------|---------|
+| Question | If Yes | If No |
+|----------|--------|-------|
 | Needs current session state (tasks, conversation)? | Main context | Consider fork |
 | Output verbose (>500 lines)? | Consider fork | Main context |
 | Needs user interaction during execution? | Main context | Consider fork |

+ 87 - 48
hooks/check-mail.sh

@@ -1,61 +1,100 @@
 #!/bin/bash
 # hooks/check-mail.sh
-# PreToolUse hook - checks for unread inter-session mail
-# Runs on every tool call. Silent when inbox is empty.
-# Matcher: * (all tools)
-#
-# Configuration in .claude/settings.json or .claude/settings.local.json:
-# {
-#   "hooks": {
-#     "PreToolUse": [{
-#       "matcher": "*",
-#       "hooks": ["bash hooks/check-mail.sh"]
-#     }]
-#   }
-# }
-
-MAIL_DB="$HOME/.claude/mail.db"
-COOLDOWN_SECONDS=10
+# PreToolUse hook - event-driven mail delivery with thread context.
+# Checks a signal file (stat, nanoseconds) before touching SQLite.
+# Silent when no signal. Delivers full thread context for each message.
+
+PMAIL_DB="$HOME/.claude/pmail.db"
+PMAIL_SCRIPT="$HOME/.claude/pigeon/mail-db.sh"
 
 # Skip if disabled for this project
-[ -f ".claude/agentmail.disable" ] && exit 0
+[ -f ".claude/pigeon.disable" ] && exit 0
 
-# Skip if no database exists yet
-[ -f "$MAIL_DB" ] || exit 0
+# Project identity: git root commit hash, fallback to path hash
+ROOT_COMMIT=$(git rev-list --max-parents=0 HEAD 2>/dev/null | head -1)
+if [ -n "$ROOT_COMMIT" ]; then
+  PROJECT_HASH="${ROOT_COMMIT:0:6}"
+else
+  CANONICAL=$(cd "$PWD" && pwd -P)
+  PROJECT_HASH=$(printf '%s' "$CANONICAL" | shasum -a 256 | cut -c1-6)
+fi
 
-PROJECT=$(basename "$PWD" | sed "s/'/''/g")
-COOLDOWN_FILE="/tmp/agentmail_${PROJECT}"
+SIGNAL="/tmp/pigeon_signal_${PROJECT_HASH}"
 
-# Cooldown: skip if checked recently (within COOLDOWN_SECONDS)
-if [ -f "$COOLDOWN_FILE" ]; then
-  last_check=$(stat -c %Y "$COOLDOWN_FILE" 2>/dev/null || stat -f %m "$COOLDOWN_FILE" 2>/dev/null || echo 0)
-  now=$(date +%s)
-  if [ $((now - last_check)) -lt $COOLDOWN_SECONDS ]; then
-    exit 0
-  fi
+# Fast path: no signal file = no mail. Stat check only, no SQLite.
+[ -f "$SIGNAL" ] || exit 0
+
+# Signal exists - check DB to confirm
+[ -f "$PMAIL_DB" ] || exit 0
+
+UNREAD=$(sqlite3 "$PMAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${PROJECT_HASH}' AND read=0;" 2>/dev/null)
+
+if [ "${UNREAD:-0}" -eq 0 ]; then
+  # Signal was stale, clean up
+  rm -f "$SIGNAL"
+  exit 0
 fi
-touch "$COOLDOWN_FILE"
 
-# Single fast query - count unread
-UNREAD=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${PROJECT}' AND read=0;" 2>/dev/null)
+# Resolve display name for a hash
+show_from() {
+  local hash="$1"
+  local name
+  name=$(sqlite3 "$PMAIL_DB" "SELECT name FROM projects WHERE hash='${hash}';" 2>/dev/null)
+  [ -n "$name" ] && echo "$name" || echo "$hash"
+}
+
+# Deliver each message with thread context
+echo ""
+echo "=== INCOMING PMAIL (${UNREAD} message(s)) ==="
+
+while read -r msg_id; do
+  [ -z "$msg_id" ] && continue
+  from_hash=$(sqlite3 "$PMAIL_DB" "SELECT from_project FROM messages WHERE id=${msg_id};" 2>/dev/null)
+  priority=$(sqlite3 "$PMAIL_DB" "SELECT priority FROM messages WHERE id=${msg_id};" 2>/dev/null)
+  subject=$(sqlite3 "$PMAIL_DB" "SELECT subject FROM messages WHERE id=${msg_id};" 2>/dev/null)
+  body=$(sqlite3 "$PMAIL_DB" "SELECT body FROM messages WHERE id=${msg_id};" 2>/dev/null)
+  timestamp=$(sqlite3 "$PMAIL_DB" "SELECT timestamp FROM messages WHERE id=${msg_id};" 2>/dev/null)
+  thread_id=$(sqlite3 "$PMAIL_DB" "SELECT thread_id FROM messages WHERE id=${msg_id};" 2>/dev/null)
+  from_name=$(show_from "$from_hash")
+  urgent=""
+  [ "$priority" = "urgent" ] && urgent=" [URGENT]"
+
+  attachments=$(sqlite3 "$PMAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${msg_id};" 2>/dev/null)
 
-# Silent exit if no mail
-[ "${UNREAD:-0}" -eq 0 ] && exit 0
+  echo ""
+  echo "--- #${msg_id} from ${from_name} (${from_hash})${urgent} @ ${timestamp} ---"
+  echo "Subject: ${subject}"
+  echo "${body}"
 
-# Check for urgent messages
-URGENT=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${PROJECT}' AND read=0 AND priority='urgent';" 2>/dev/null)
+  # Show attachments
+  if [ -n "$attachments" ]; then
+    echo ""
+    while IFS= read -r apath; do
+      [ -z "$apath" ] && continue
+      if [ -e "$apath" ]; then
+        echo "[Attached: ${apath} ($(wc -c < "$apath" | tr -d ' ') bytes)] <-- Use Read tool to view"
+      else
+        echo "[Attached: ${apath} (missing)]"
+      fi
+    done <<< "$attachments"
+  fi
+
+  # Show thread context if this is part of a conversation
+  if [ -n "$thread_id" ]; then
+    thread_root="$thread_id"
+    thread_count=$(sqlite3 "$PMAIL_DB" "SELECT COUNT(*) FROM messages WHERE id=${thread_root} OR thread_id=${thread_root};" 2>/dev/null)
+    if [ "${thread_count:-0}" -gt 1 ]; then
+      echo ""
+      echo "[Thread #${thread_root} - ${thread_count} messages. Run: pigeon thread ${thread_root}]"
+    fi
+  fi
+done < <(sqlite3 "$PMAIL_DB" \
+  "SELECT id FROM messages WHERE to_project='${PROJECT_HASH}' AND read=0 ORDER BY priority DESC, timestamp ASC;" 2>/dev/null)
 
-# Show notification with preview of first 3 messages
 echo ""
-if [ "${URGENT:-0}" -gt 0 ]; then
-  echo "=== URGENT MAIL: ${UNREAD} unread (${URGENT} urgent) ==="
-else
-  echo "=== MAIL: ${UNREAD} unread message(s) ==="
-fi
-sqlite3 -separator '  ' "$MAIL_DB" \
-  "SELECT '  ' || CASE WHEN priority='urgent' THEN '[!] ' ELSE '' END || 'From: ' || from_project || '  |  ' || subject FROM messages WHERE to_project='${PROJECT}' AND read=0 ORDER BY priority DESC, timestamp DESC LIMIT 3;" 2>/dev/null
-if [ "$UNREAD" -gt 3 ]; then
-  echo "  ... and $((UNREAD - 3)) more"
-fi
-echo "Use agentmail read to read messages."
-echo "==="
+echo "=== ACTION REQUIRED: Inform the user about these messages and ask if they want to reply. ==="
+echo "=== Then run: pigeon read (to mark as read) ==="
+echo "=== To reply: pigeon reply <id> \"message\" ==="
+
+# Clear signal (new sends will re-create it)
+rm -f "$SIGNAL"

+ 49 - 0
scripts/install.ps1

@@ -150,6 +150,55 @@ if (Test-Path $stylesDir) {
 }
 Write-Host ""
 
+# =============================================================================
+# PIGEON - Global install (scripts + hook config hint)
+# =============================================================================
+Write-Host "Installing pigeon (pmail)..." -ForegroundColor Cyan
+
+# Clean up old agentmail install if present
+$oldAgentmailDir = Join-Path $claudeDir "agentmail"
+if (Test-Path $oldAgentmailDir) {
+    Remove-Item -Path $oldAgentmailDir -Recurse -Force
+    Write-Host "  Removed old agentmail/ (renamed to pigeon/)" -ForegroundColor Red
+}
+
+$pigeonDir = Join-Path $claudeDir "pigeon"
+New-Item -ItemType Directory -Force -Path $pigeonDir | Out-Null
+
+$mailDbSrc = Join-Path $projectRoot "skills\pigeon\scripts\mail-db.sh"
+$checkMailSrc = Join-Path $projectRoot "hooks\check-mail.sh"
+
+if (Test-Path $mailDbSrc) {
+    Copy-Item $mailDbSrc -Destination "$pigeonDir\" -Force
+    Write-Host "  mail-db.sh" -ForegroundColor Green
+}
+if (Test-Path $checkMailSrc) {
+    Copy-Item $checkMailSrc -Destination "$pigeonDir\" -Force
+    Write-Host "  check-mail.sh" -ForegroundColor Green
+}
+
+$settingsPath = Join-Path $claudeDir "settings.json"
+if ((Test-Path $settingsPath) -and (Select-String -Path $settingsPath -Pattern "check-mail.sh" -Quiet)) {
+    Write-Host "  Hook already configured in settings.json" -ForegroundColor Green
+} else {
+    Write-Host ""
+    Write-Host '  To enable automatic pmail notifications, add this to ~/.claude/settings.json:' -ForegroundColor Yellow
+    Write-Host ""
+    Write-Host '  "hooks": {'
+    Write-Host '    "PreToolUse": [{'
+    Write-Host '      "matcher": "*",'
+    Write-Host '      "hooks": [{'
+    Write-Host '        "type": "command",'
+    Write-Host '        "command": "bash \"$HOME/.claude/pigeon/check-mail.sh\"",'
+    Write-Host '        "timeout": 5'
+    Write-Host '      }]'
+    Write-Host '    }]'
+    Write-Host '  }'
+    Write-Host ""
+    Write-Host "  Without this, pigeon works but you must check manually (pigeon read)." -ForegroundColor Yellow
+}
+Write-Host ""
+
 # =============================================================================
 # SUMMARY
 # =============================================================================

+ 45 - 0
scripts/install.sh

@@ -164,6 +164,51 @@ if [ -d "$PROJECT_ROOT/output-styles" ]; then
 fi
 echo ""
 
+# =============================================================================
+# PIGEON - Global install (scripts + hook config hint)
+# =============================================================================
+echo -e "${BLUE}Installing pigeon (pmail)...${NC}"
+
+# Clean up old agentmail install if present
+if [ -d "$CLAUDE_DIR/agentmail" ]; then
+    rm -rf "$CLAUDE_DIR/agentmail"
+    echo -e "  ${RED}Removed old agentmail/ (renamed to pigeon/)${NC}"
+fi
+
+mkdir -p "$CLAUDE_DIR/pigeon"
+if [ -f "$PROJECT_ROOT/skills/pigeon/scripts/mail-db.sh" ]; then
+    cp "$PROJECT_ROOT/skills/pigeon/scripts/mail-db.sh" "$CLAUDE_DIR/pigeon/"
+    chmod +x "$CLAUDE_DIR/pigeon/mail-db.sh"
+    echo -e "  ${GREEN}mail-db.sh${NC}"
+fi
+if [ -f "$PROJECT_ROOT/hooks/check-mail.sh" ]; then
+    cp "$PROJECT_ROOT/hooks/check-mail.sh" "$CLAUDE_DIR/pigeon/"
+    chmod +x "$CLAUDE_DIR/pigeon/check-mail.sh"
+    echo -e "  ${GREEN}check-mail.sh${NC}"
+fi
+
+# Check if hook is already configured
+if grep -q "check-mail.sh" "$CLAUDE_DIR/settings.json" 2>/dev/null; then
+    echo -e "  ${GREEN}Hook already configured in settings.json${NC}"
+else
+    echo ""
+    echo -e "  ${YELLOW}To enable automatic pmail notifications, add this to ~/.claude/settings.json:${NC}"
+    echo ""
+    echo '  "hooks": {'
+    echo '    "PreToolUse": [{'
+    echo '      "matcher": "*",'
+    echo '      "hooks": [{'
+    echo '        "type": "command",'
+    echo '        "command": "bash \"$HOME/.claude/pigeon/check-mail.sh\"",'
+    echo '        "timeout": 5'
+    echo '      }]'
+    echo '    }]'
+    echo '  }'
+    echo ""
+    echo -e "  ${YELLOW}Without this, pigeon works but you must check manually (pigeon read).${NC}"
+fi
+echo ""
+
 # =============================================================================
 # SUMMARY
 # =============================================================================

+ 4 - 1
skills/api-design-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: api-design-ops
 description: "API design patterns for REST, gRPC, and GraphQL. Use for: api design, REST, gRPC, GraphQL, protobuf, schema design, api versioning, pagination, rate limiting, error format, OpenAPI, API authentication, JWT, OAuth2, API gateway, webhook, idempotency."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [rest-ops, security-ops, go-ops, rust-ops, typescript-ops]
+metadata:
+  author: claude-mods
+  related-skills: rest-ops, security-ops, go-ops, rust-ops, typescript-ops
 ---
 
 # API Design Ops

+ 4 - 1
skills/astro-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: astro-ops
 description: "Astro framework patterns, islands architecture, content collections, rendering strategies, and deployment. Use for: astro, islands architecture, content collections, astro cloudflare, view transitions, partial hydration, astrojs, SSG, SSR, hybrid rendering, astro adapter."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [typescript-ops, tailwind-ops, javascript-ops]
+metadata:
+  author: claude-mods
+  related-skills: typescript-ops, tailwind-ops, javascript-ops
 ---
 
 # Astro Operations

+ 4 - 3
skills/atomise/SKILL.md

@@ -1,10 +1,11 @@
 ---
 name: atomise
 description: "Atom of Thoughts (AoT) reasoning - decompose complex problems into atomic units with confidence tracking and backtracking. For genuinely complex reasoning, not everyday questions. Triggers on: atomise, complex reasoning, decompose problem, structured thinking, verify hypothesis."
-allowed-tools: "Read"
+license: MIT
 compatibility: "Pure reasoning framework, no external dependencies."
-depends-on: []
-related-skills: []
+allowed-tools: "Read"
+metadata:
+  author: claude-mods
 ---
 
 # Atomise - Atom of Thoughts Reasoning

+ 4 - 1
skills/auth-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: auth-ops
 description: "Authentication and authorization patterns - JWT, OAuth2, sessions, RBAC, ABAC, passkeys, and MFA. Use for: authentication, authorization, jwt, oauth, oauth2, session, login, rbac, abac, passkey, mfa, totp, api key, token, auth, cookie, csrf, cors credentials, bearer token, refresh token, oidc."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [security-ops, api-design-ops, postgres-ops]
+metadata:
+  author: claude-mods
+  related-skills: security-ops, api-design-ops, postgres-ops
 ---
 
 # Auth Operations

+ 4 - 1
skills/ci-cd-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: ci-cd-ops
 description: "CI/CD pipeline patterns with GitHub Actions, release automation, and testing strategies. Use for: github actions, workflow, CI, CD, pipeline, deploy, release, semantic release, changesets, goreleaser, matrix, cache, secrets, environment, artifact, reusable workflow, composite action."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [git-ops, docker-ops, testing-ops]
+metadata:
+  author: claude-mods
+  related-skills: git-ops, docker-ops, testing-ops
 ---
 
 # CI/CD Operations

+ 4 - 2
skills/claude-code-debug/SKILL.md

@@ -1,10 +1,12 @@
 ---
 name: claude-code-debug
 description: "Troubleshoot Claude Code extensions and behavior. Triggers on: debug, troubleshoot, not working, skill not loading, hook not running, agent not found."
+license: MIT
 compatibility: "Claude Code CLI"
 allowed-tools: "Bash Read"
-depends-on: []
-related-skills: [claude-code-hooks, claude-code-headless, claude-code-templates]
+metadata:
+  author: claude-mods
+  related-skills: claude-code-hooks, claude-code-headless, claude-code-templates
 ---
 
 # Claude Code Debug

+ 4 - 2
skills/claude-code-headless/SKILL.md

@@ -1,10 +1,12 @@
 ---
 name: claude-code-headless
 description: "Run Claude Code programmatically without interactive UI. Triggers on: headless, CLI automation, --print, output-format, stream-json, CI/CD, scripting."
+license: MIT
 compatibility: "Claude Code CLI"
 allowed-tools: "Bash Read"
-depends-on: []
-related-skills: [claude-code-hooks, claude-code-debug]
+metadata:
+  author: claude-mods
+  related-skills: claude-code-hooks, claude-code-debug
 ---
 
 # Claude Code Headless Mode

+ 4 - 2
skills/claude-code-hooks/SKILL.md

@@ -1,10 +1,12 @@
 ---
 name: claude-code-hooks
 description: "Claude Code hook system for pre/post tool execution. Triggers on: hooks, PreToolUse, PostToolUse, hook script, tool validation, audit logging."
+license: MIT
 compatibility: "Claude Code CLI with settings.json support"
 allowed-tools: "Bash Read Write"
-depends-on: []
-related-skills: [claude-code-debug, claude-code-headless]
+metadata:
+  author: claude-mods
+  related-skills: claude-code-debug, claude-code-headless
 ---
 
 # Claude Code Hooks

+ 4 - 2
skills/cli-ops/SKILL.md

@@ -1,10 +1,12 @@
 ---
 name: cli-ops
 description: "Patterns for building production-quality CLI tools with predictable behavior, parseable output, and agentic workflows. Triggers: cli tool, command line tool, build cli, cli patterns, agentic cli, cli design, typer cli, click cli."
+license: MIT
 compatibility: "Python 3.11+, Typer, Click"
 allowed-tools: "Read, Write, Edit"
-depends-on: []
-related-skills: [python-cli-ops, python-async-ops]
+metadata:
+  author: claude-mods
+  related-skills: python-cli-ops, python-async-ops
 ---
 
 # CLI Patterns for Agentic Workflows

+ 3 - 0
skills/code-stats/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: code-stats
 description: "Analyze codebase with tokei (fast line counts by language) and difft (semantic AST-aware diffs). Get quick project overview without manual counting. Triggers on: how big is codebase, count lines of code, what languages, show semantic diff, compare files, code statistics."
+license: MIT
 compatibility: "Requires tokei and difft CLI tools. Install: brew install tokei difft (macOS) or cargo install tokei difftastic (cross-platform)."
 allowed-tools: "Bash"
+metadata:
+  author: claude-mods
 ---
 
 # Code Statistics

+ 4 - 1
skills/color-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: color-ops
 description: "Color for developers - color spaces, accessibility contrast, palette generation, CSS color functions, design tokens, dark mode, and CVD simulation. Use for: color, colour, palette, contrast, accessibility, WCAG, APCA, OKLCH, OKLAB, HSL, color picker, color-mix, dark mode colors, design tokens, color system, color scale, color ramp, gradient, CVD, color blind, gamut, P3, sRGB, color naming, color harmony, color temperature, semantic colors."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [tailwind-ops, react-ops, frontend-design]
+metadata:
+  author: claude-mods
+  related-skills: tailwind-ops, react-ops, frontend-design
 ---
 
 # Color Operations

+ 3 - 0
skills/container-orchestration/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: container-orchestration
 description: "Docker and Kubernetes patterns. Triggers on: Dockerfile, docker-compose, kubernetes, k8s, helm, pod, deployment, service, ingress, container, image."
+license: MIT
 compatibility: "Docker 20+, Kubernetes 1.25+, Helm 3+"
 allowed-tools: "Read Write Bash"
+metadata:
+  author: claude-mods
 ---
 
 # Container Orchestration

+ 3 - 0
skills/data-processing/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: data-processing
 description: "Process JSON with jq and YAML/TOML with yq. Filter, transform, query structured data efficiently. Triggers on: parse JSON, extract from YAML, query config, Docker Compose, K8s manifests, GitHub Actions workflows, package.json, filter data."
+license: MIT
 compatibility: "Requires jq and yq CLI tools. Install: brew install jq yq (macOS)."
 allowed-tools: "Bash Read"
+metadata:
+  author: claude-mods
 ---
 
 # Data Processing

+ 4 - 1
skills/debug-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: debug-ops
 description: "Systematic debugging methodology, language-specific debuggers, and common scenario playbooks. Use for: debug, debugging, bug, crash, hang, memory leak, race condition, deadlock, bisect, reproduce, root cause, breakpoint, profiling, performance issue, segfault, stack trace, core dump."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [testing-ops, security-ops, monitoring-ops, code-stats]
+metadata:
+  author: claude-mods
+  related-skills: testing-ops, security-ops, monitoring-ops, code-stats
 ---
 
 # Debug Operations

+ 3 - 0
skills/doc-scanner/SKILL.md

@@ -1,7 +1,10 @@
 ---
 name: doc-scanner
 description: "Scans for project documentation files (AGENTS.md, CLAUDE.md, GEMINI.md, COPILOT.md, CURSOR.md, WARP.md, and 15+ other formats) and synthesizes guidance. Auto-activates when user asks to review, understand, or explore a codebase, when starting work in a new project, when asking about conventions or agents, or when documentation context would help. Can consolidate multiple platform docs into unified AGENTS.md."
+license: MIT
 allowed-tools: "Glob Read Write Bash"
+metadata:
+  author: claude-mods
 ---
 
 # Documentation Scanner

+ 4 - 1
skills/docker-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: docker-ops
 description: "Docker containerization patterns, Dockerfile best practices, multi-stage builds, and Docker Compose. Use for: docker, Dockerfile, docker-compose, container, image, multi-stage build, docker build, docker run, .dockerignore, health check, distroless, scratch image, BuildKit, layer caching, container security."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [container-orchestration, go-ops, rust-ops, ci-cd-ops]
+metadata:
+  author: claude-mods
+  related-skills: container-orchestration, go-ops, rust-ops, ci-cd-ops
 ---
 
 # Docker Operations

+ 5 - 3
skills/explain/SKILL.md

@@ -1,10 +1,12 @@
 ---
 name: explain
 description: "Deep explanation of complex code, files, or concepts. Routes to expert agents, uses structural search, generates mermaid diagrams. Triggers on: explain, deep dive, how does X work, architecture, data flow."
-allowed-tools: "Read Glob Grep Bash Task"
+license: MIT
 compatibility: "Uses ast-grep, tokei, rg, fd if available. Falls back to standard tools."
-depends-on: []
-related-skills: ["structural-search", "code-stats"]
+allowed-tools: "Read Glob Grep Bash Task"
+metadata:
+  author: claude-mods
+  related-skills: structural-search, code-stats
 ---
 
 # Explain - Deep Code Explanation

+ 3 - 0
skills/file-search/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: file-search
 description: "Modern file and content search using fd, ripgrep (rg), and fzf. Triggers on: fd, ripgrep, rg, find files, search code, fzf, fuzzy find, search codebase."
+license: MIT
 compatibility: "Requires fd, ripgrep (rg), and optionally fzf. Install: brew install fd ripgrep fzf (macOS)."
 allowed-tools: "Bash"
+metadata:
+  author: claude-mods
 ---
 
 # File Search

+ 3 - 0
skills/find-replace/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: find-replace
 description: "Modern find-and-replace using sd (simpler than sed) and batch replacement patterns. Triggers on: sd, find replace, batch replace, sed replacement, string replacement, rename."
+license: MIT
 compatibility: "Requires sd CLI tool. Install: brew install sd (macOS) or cargo install sd (cross-platform)."
 allowed-tools: "Bash"
+metadata:
+  author: claude-mods
 ---
 
 # Find Replace

+ 1843 - 0
skills/genart-ops/SKILL.md

@@ -0,0 +1,1843 @@
+---
+name: genart-ops
+description: "Generative art programming - three.js scenes, p5.js sketches, SVG generation, GLSL shaders, procedural algorithms, and color for creative coding. Use for: generative art, creative coding, three.js, p5.js, SVG, GLSL, shader, noise, perlin, simplex, flow field, particle system, SDF, ray marching, procedural, L-system, voronoi, delaunay, cellular automata, wave function collapse, instanced mesh, post-processing, bloom, WebGL, canvas, fragment shader, vertex shader, FBM, domain warping."
+license: MIT
+allowed-tools: "Read Write Bash"
+metadata:
+  author: claude-mods
+  related-skills: color-ops, javascript-ops, typescript-ops
+---
+
+# Generative Art Operations
+
+Practical patterns for creative coding and generative art. Covers three.js, p5.js, SVG generation, GLSL shaders, procedural algorithms, and color theory for computational aesthetics.
+
+> Color-ops handles CSS color, accessibility, and design tokens. This skill focuses on generative/procedural color techniques (palette algorithms, shader color, gradient interpolation in perceptual space).
+
+---
+
+## 1. Three.js -- Scene Scaffolding (2026)
+
+### Minimal Scene
+
+```javascript
+import * as THREE from 'three';
+
+const scene = new THREE.Scene();
+const camera = new THREE.PerspectiveCamera(
+  75,                                    // fov
+  window.innerWidth / window.innerHeight, // aspect
+  0.1,                                   // near
+  1000                                   // far
+);
+camera.position.set(0, 2, 5);
+
+const renderer = new THREE.WebGLRenderer({ antialias: true });
+renderer.setPixelRatio(window.devicePixelRatio);
+renderer.setSize(window.innerWidth, window.innerHeight);
+renderer.toneMapping = THREE.ACESFilmicToneMapping;
+document.body.appendChild(renderer.domElement);
+
+// --- Responsive ---
+window.addEventListener('resize', () => {
+  camera.aspect = window.innerWidth / window.innerHeight;
+  camera.updateProjectionMatrix();
+  renderer.setSize(window.innerWidth, window.innerHeight);
+});
+```
+
+### Animation Loop (Timer-based, 2026 pattern)
+
+```javascript
+const timer = new THREE.Timer();
+timer.connect(document); // auto-pauses on tab switch
+
+renderer.setAnimationLoop(() => {
+  timer.update();
+  const delta = timer.getDelta();
+  const elapsed = timer.getElapsed();
+
+  // animate objects using delta/elapsed
+  mesh.rotation.y += delta;
+
+  renderer.render(scene, camera);
+});
+```
+
+### OrbitControls
+
+```javascript
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+
+const controls = new OrbitControls(camera, renderer.domElement);
+controls.enableDamping = true;
+controls.dampingFactor = 0.05;
+controls.maxPolarAngle = Math.PI * 0.5;
+controls.minDistance = 2;
+controls.maxDistance = 20;
+
+// Must call update in animation loop when damping enabled
+renderer.setAnimationLoop(() => {
+  controls.update();
+  renderer.render(scene, camera);
+});
+```
+
+### Lighting Rig (Three-point)
+
+```javascript
+// Key light
+const key = new THREE.DirectionalLight(0xffffff, 1.5);
+key.position.set(5, 5, 5);
+scene.add(key);
+
+// Fill light (softer, opposite side)
+const fill = new THREE.DirectionalLight(0x8888ff, 0.5);
+fill.position.set(-5, 3, -5);
+scene.add(fill);
+
+// Rim / back light
+const rim = new THREE.DirectionalLight(0xffffff, 0.8);
+rim.position.set(0, 5, -10);
+scene.add(rim);
+
+// Ambient baseline
+scene.add(new THREE.AmbientLight(0x404040, 0.5));
+```
+
+### Post-Processing Pipeline (Bloom)
+
+```javascript
+import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
+import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
+import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
+import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
+
+const composer = new EffectComposer(renderer);
+composer.addPass(new RenderPass(scene, camera));
+
+const bloomPass = new UnrealBloomPass(
+  new THREE.Vector2(window.innerWidth, window.innerHeight),
+  1.5,  // strength
+  0.4,  // radius
+  0.85  // threshold
+);
+composer.addPass(bloomPass);
+composer.addPass(new OutputPass()); // always last -- handles tone mapping
+
+// In animation loop: composer.render() instead of renderer.render()
+// On resize: composer.setSize(width, height)
+```
+
+### InstancedMesh (Particle Systems / Mass Geometry)
+
+```javascript
+const geometry = new THREE.SphereGeometry(0.05, 8, 8);
+const material = new THREE.MeshStandardMaterial({ color: 0xff6600 });
+const COUNT = 10000;
+
+const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
+scene.add(mesh);
+
+const dummy = new THREE.Object3D();
+const matrix = new THREE.Matrix4();
+
+for (let i = 0; i < COUNT; i++) {
+  dummy.position.set(
+    (Math.random() - 0.5) * 40,
+    (Math.random() - 0.5) * 40,
+    (Math.random() - 0.5) * 40
+  );
+  dummy.updateMatrix();
+  mesh.setMatrixAt(i, dummy.matrix);
+}
+mesh.instanceMatrix.needsUpdate = true;
+
+// Per-instance color
+const color = new THREE.Color();
+for (let i = 0; i < COUNT; i++) {
+  color.setHSL(Math.random(), 0.8, 0.6);
+  mesh.setColorAt(i, color);
+}
+mesh.instanceColor.needsUpdate = true;
+
+// Animate instances
+function animateInstances(elapsed) {
+  for (let i = 0; i < COUNT; i++) {
+    mesh.getMatrixAt(i, matrix);
+    matrix.decompose(dummy.position, dummy.quaternion, dummy.scale);
+    dummy.position.y += Math.sin(elapsed + i * 0.1) * 0.001;
+    dummy.updateMatrix();
+    mesh.setMatrixAt(i, dummy.matrix);
+  }
+  mesh.instanceMatrix.needsUpdate = true;
+}
+```
+
+### Custom ShaderMaterial
+
+```javascript
+const shaderMaterial = new THREE.ShaderMaterial({
+  uniforms: {
+    uTime: { value: 0 },
+    uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
+    uMouse: { value: new THREE.Vector2(0, 0) },
+    uColor: { value: new THREE.Color(0x3b82f6) },
+  },
+  vertexShader: /* glsl */ `
+    varying vec2 vUv;
+    varying vec3 vPosition;
+    uniform float uTime;
+
+    void main() {
+      vUv = uv;
+      vPosition = position;
+      vec3 pos = position;
+      pos.z += sin(pos.x * 3.0 + uTime) * 0.2;
+      gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
+    }
+  `,
+  fragmentShader: /* glsl */ `
+    uniform float uTime;
+    uniform vec2 uResolution;
+    uniform vec3 uColor;
+    varying vec2 vUv;
+
+    void main() {
+      vec3 col = uColor * (0.5 + 0.5 * sin(vUv.x * 10.0 + uTime));
+      gl_FragColor = vec4(col, 1.0);
+    }
+  `,
+  side: THREE.DoubleSide,
+});
+
+// Update in animation loop:
+shaderMaterial.uniforms.uTime.value = elapsed;
+```
+
+---
+
+## 2. p5.js -- Sketch Patterns (2026)
+
+### Global Mode (Quick Sketching)
+
+```javascript
+function setup() {
+  createCanvas(800, 800);
+  colorMode(HSB, 360, 100, 100, 100);
+  noStroke();
+}
+
+function draw() {
+  background(0, 0, 10);
+  for (let i = 0; i < 100; i++) {
+    let x = random(width);
+    let y = random(height);
+    fill(random(360), 80, 90, 50);
+    circle(x, y, random(5, 30));
+  }
+}
+```
+
+### Instance Mode (Multiple Sketches / Modules)
+
+```javascript
+const sketch = (p) => {
+  let particles = [];
+
+  p.setup = () => {
+    p.createCanvas(800, 800);
+    p.colorMode(p.HSB, 360, 100, 100, 100);
+    for (let i = 0; i < 200; i++) {
+      particles.push({
+        x: p.random(p.width),
+        y: p.random(p.height),
+        vx: p.random(-1, 1),
+        vy: p.random(-1, 1),
+        hue: p.random(360),
+      });
+    }
+  };
+
+  p.draw = () => {
+    p.background(0, 0, 5, 10); // trailing fade
+    for (let pt of particles) {
+      pt.x += pt.vx;
+      pt.y += pt.vy;
+      if (pt.x < 0 || pt.x > p.width) pt.vx *= -1;
+      if (pt.y < 0 || pt.y > p.height) pt.vy *= -1;
+      p.fill(pt.hue, 80, 90, 60);
+      p.noStroke();
+      p.circle(pt.x, pt.y, 6);
+    }
+  };
+};
+
+new p5(sketch, document.getElementById('canvas-container'));
+```
+
+### WebGL Mode
+
+```javascript
+function setup() {
+  createCanvas(800, 800, WEBGL);
+}
+
+function draw() {
+  background(0);
+  orbitControl();
+  ambientLight(60);
+  directionalLight(255, 255, 255, 0.5, -1, -0.5);
+
+  push();
+  rotateX(frameCount * 0.01);
+  rotateY(frameCount * 0.013);
+  normalMaterial();
+  torus(150, 50, 24, 16);
+  pop();
+}
+```
+
+### Custom Shaders in p5.js
+
+```javascript
+let myShader;
+
+const vertSrc = `
+  precision highp float;
+  uniform mat4 uModelViewMatrix;
+  uniform mat4 uProjectionMatrix;
+  attribute vec3 aPosition;
+  attribute vec2 aTexCoord;
+  varying vec2 vTexCoord;
+
+  void main() {
+    vTexCoord = aTexCoord;
+    vec4 positionVec4 = vec4(aPosition, 1.0);
+    gl_Position = uProjectionMatrix * uModelViewMatrix * positionVec4;
+  }
+`;
+
+const fragSrc = `
+  precision highp float;
+  uniform float uTime;
+  uniform vec2 uResolution;
+  varying vec2 vTexCoord;
+
+  void main() {
+    vec2 uv = vTexCoord;
+    vec3 col = 0.5 + 0.5 * cos(uTime + uv.xyx + vec3(0, 2, 4));
+    gl_FragColor = vec4(col, 1.0);
+  }
+`;
+
+function setup() {
+  createCanvas(800, 800, WEBGL);
+  myShader = createShader(vertSrc, fragSrc);
+}
+
+function draw() {
+  shader(myShader);
+  myShader.setUniform('uTime', millis() / 1000.0);
+  myShader.setUniform('uResolution', [width, height]);
+  rect(0, 0, width, height);
+}
+```
+
+### Pixel Manipulation
+
+```javascript
+function draw() {
+  loadPixels();
+  for (let x = 0; x < width; x++) {
+    for (let y = 0; y < height; y++) {
+      let idx = (x + y * width) * 4;
+      let n = noise(x * 0.01, y * 0.01, frameCount * 0.01);
+      pixels[idx]     = n * 255;     // R
+      pixels[idx + 1] = n * 128;     // G
+      pixels[idx + 2] = 255 - n*255; // B
+      pixels[idx + 3] = 255;         // A
+    }
+  }
+  updatePixels();
+}
+```
+
+### Recording / Export
+
+```javascript
+// Frame export (PNG sequence)
+function draw() {
+  // ... drawing code ...
+  if (frameCount <= 300) {
+    saveCanvas('frame-' + nf(frameCount, 4), 'png');
+  }
+}
+
+// SVG export (requires p5.js-svg library)
+function setup() {
+  createCanvas(800, 800, SVG);
+}
+function draw() {
+  // ... vector drawing ...
+  save('artwork.svg');
+  noLoop();
+}
+
+// With canvas-sketch (standalone, not p5)
+// npm install canvas-sketch canvas-sketch-cli -g
+const canvasSketch = require('canvas-sketch');
+
+const settings = {
+  dimensions: [2048, 2048],
+  animate: true,
+  fps: 30,
+  duration: 5,
+  suffix: '-artwork',
+};
+
+const sketch = () => {
+  return ({ context, width, height, time }) => {
+    const ctx = context;
+    ctx.fillStyle = '#000';
+    ctx.fillRect(0, 0, width, height);
+    // ... drawing with Canvas 2D API ...
+  };
+};
+
+canvasSketch(sketch, settings);
+// Export: Ctrl+Shift+S for PNG, or --stream flag for MP4
+```
+
+---
+
+## 3. SVG Generation
+
+### Programmatic SVG in JavaScript
+
+```javascript
+function createSVG(width, height) {
+  const NS = 'http://www.w3.org/2000/svg';
+  const svg = document.createElementNS(NS, 'svg');
+  svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
+  svg.setAttribute('xmlns', NS);
+  return svg;
+}
+
+function addPath(svg, d, attrs = {}) {
+  const NS = 'http://www.w3.org/2000/svg';
+  const path = document.createElementNS(NS, 'path');
+  path.setAttribute('d', d);
+  for (const [k, v] of Object.entries(attrs)) {
+    path.setAttribute(k, v);
+  }
+  svg.appendChild(path);
+  return path;
+}
+
+// Serialize to string
+function svgToString(svg) {
+  return new XMLSerializer().serializeToString(svg);
+}
+```
+
+### SVG Path Commands Reference
+
+| Command | Name | Syntax | Notes |
+|---------|------|--------|-------|
+| `M x y` | Move to | Absolute | Start new subpath |
+| `m dx dy` | Move to | Relative | |
+| `L x y` | Line to | Absolute | Straight line |
+| `l dx dy` | Line to | Relative | |
+| `H x` | Horizontal line | Absolute | |
+| `h dx` | Horizontal line | Relative | |
+| `V y` | Vertical line | Absolute | |
+| `v dy` | Vertical line | Relative | |
+| `C x1 y1 x2 y2 x y` | Cubic bezier | 2 control points + endpoint |
+| `c dx1 dy1 dx2 dy2 dx dy` | Cubic bezier | Relative |
+| `S x2 y2 x y` | Smooth cubic | Reflects previous control point |
+| `Q x1 y1 x y` | Quadratic bezier | 1 control point + endpoint |
+| `T x y` | Smooth quadratic | Reflects previous control point |
+| `A rx ry rot large-arc sweep x y` | Arc | Elliptical arc |
+| `Z` | Close path | Back to subpath start |
+
+### Generative SVG Patterns
+
+```javascript
+// Generative organic blob
+function blob(cx, cy, radius, points = 8, variance = 0.3) {
+  const pts = [];
+  for (let i = 0; i < points; i++) {
+    const angle = (i / points) * Math.PI * 2;
+    const r = radius * (1 + (Math.random() - 0.5) * variance);
+    pts.push([
+      cx + Math.cos(angle) * r,
+      cy + Math.sin(angle) * r,
+    ]);
+  }
+  return smoothClosedPath(pts);
+}
+
+// Convert points to smooth cubic bezier closed path
+function smoothClosedPath(points) {
+  const n = points.length;
+  let d = `M ${points[0][0]} ${points[0][1]}`;
+  for (let i = 0; i < n; i++) {
+    const curr = points[i];
+    const next = points[(i + 1) % n];
+    const prev = points[(i - 1 + n) % n];
+    const next2 = points[(i + 2) % n];
+
+    const cp1x = curr[0] + (next[0] - prev[0]) / 6;
+    const cp1y = curr[1] + (next[1] - prev[1]) / 6;
+    const cp2x = next[0] - (next2[0] - curr[0]) / 6;
+    const cp2y = next[1] - (next2[1] - curr[1]) / 6;
+
+    d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${next[0]} ${next[1]}`;
+  }
+  return d + ' Z';
+}
+
+// Generative line hatching
+function hatchRect(x, y, w, h, angle, spacing) {
+  const paths = [];
+  const cos = Math.cos(angle);
+  const sin = Math.sin(angle);
+  const diag = Math.sqrt(w * w + h * h);
+
+  for (let d = -diag; d < diag; d += spacing) {
+    const x1 = x + d * cos - diag * sin;
+    const y1 = y + d * sin + diag * cos;
+    const x2 = x + d * cos + diag * sin;
+    const y2 = y + d * sin - diag * cos;
+    // Clip to rect bounds and add to paths
+    paths.push(`M ${x1} ${y1} L ${x2} ${y2}`);
+  }
+  return paths.join(' ');
+}
+```
+
+### SVG Filters for Generative Effects
+
+```xml
+<!-- Organic texture -->
+<filter id="organic">
+  <feTurbulence type="fractalNoise" baseFrequency="0.02"
+    numOctaves="4" seed="42" result="noise"/>
+  <feDisplacementMap in="SourceGraphic" in2="noise"
+    scale="20" xChannelSelector="R" yChannelSelector="G"/>
+</filter>
+
+<!-- Glow effect -->
+<filter id="glow">
+  <feGaussianBlur stdDeviation="4" result="blur"/>
+  <feMerge>
+    <feMergeNode in="blur"/>
+    <feMergeNode in="SourceGraphic"/>
+  </feMerge>
+</filter>
+
+<!-- Paper texture -->
+<filter id="paper">
+  <feTurbulence type="fractalNoise" baseFrequency="0.04"
+    numOctaves="5" result="noise"/>
+  <feDiffuseLighting in="noise" lighting-color="white"
+    surfaceScale="2" result="lit">
+    <feDistantLight azimuth="45" elevation="60"/>
+  </feDiffuseLighting>
+  <feComposite in="SourceGraphic" in2="lit"
+    operator="multiply"/>
+</filter>
+
+<!-- Eroded / distressed edges -->
+<filter id="eroded">
+  <feTurbulence type="turbulence" baseFrequency="0.05"
+    numOctaves="2" result="noise"/>
+  <feDisplacementMap in="SourceGraphic" in2="noise"
+    scale="6" xChannelSelector="R" yChannelSelector="G"
+    result="displaced"/>
+  <feGaussianBlur in="displaced" stdDeviation="0.5"/>
+</filter>
+
+<!-- Usage -->
+<path d="..." filter="url(#organic)" fill="oklch(0.7 0.15 200)"/>
+```
+
+### SVG Animation
+
+```xml
+<!-- SMIL animation (native SVG) -->
+<circle cx="50" cy="50" r="20" fill="oklch(0.7 0.2 250)">
+  <animate attributeName="r" from="20" to="40"
+    dur="2s" repeatCount="indefinite"
+    values="20;40;20" keyTimes="0;0.5;1"/>
+  <animate attributeName="fill-opacity" from="1" to="0.3"
+    dur="2s" repeatCount="indefinite"/>
+</circle>
+
+<!-- Morph path -->
+<path fill="oklch(0.6 0.18 150)">
+  <animate attributeName="d" dur="4s" repeatCount="indefinite"
+    values="M10,80 Q52,10 95,80 T180,80;
+            M10,80 Q52,50 95,20 T180,80;
+            M10,80 Q52,10 95,80 T180,80"/>
+</path>
+
+<!-- CSS animation on SVG -->
+<style>
+  @keyframes dash {
+    to { stroke-dashoffset: 0; }
+  }
+  .draw-in {
+    stroke-dasharray: 1000;
+    stroke-dashoffset: 1000;
+    animation: dash 3s ease-in-out forwards;
+  }
+</style>
+<path class="draw-in" d="..." stroke="#000" fill="none"/>
+```
+
+### SVG Optimization (SVGO)
+
+```bash
+# Install
+npm install -g svgo
+
+# Optimize single file
+svgo input.svg -o output.svg
+
+# Batch optimize
+svgo -f ./input-dir -o ./output-dir
+
+# Preserve viewBox, remove dimensions (responsive)
+svgo input.svg -o output.svg --config='{ "plugins": [
+  { "name": "removeDimensions" },
+  { "name": "removeViewBox", "active": false }
+]}'
+```
+
+---
+
+## 4. GLSL Shaders
+
+### Shader Boilerplate (Standalone WebGL)
+
+```glsl
+// --- Vertex Shader ---
+attribute vec2 aPosition;
+varying vec2 vUv;
+
+void main() {
+  vUv = aPosition * 0.5 + 0.5;
+  gl_Position = vec4(aPosition, 0.0, 1.0);
+}
+
+// --- Fragment Shader ---
+precision highp float;
+uniform float uTime;
+uniform vec2 uResolution;
+uniform vec2 uMouse;
+varying vec2 vUv;
+
+void main() {
+  vec2 uv = gl_FragCoord.xy / uResolution;
+  // ... shader logic ...
+  gl_FragColor = vec4(col, 1.0);
+}
+```
+
+### Common Uniforms
+
+```glsl
+uniform float uTime;        // seconds elapsed
+uniform vec2 uResolution;   // canvas pixel dimensions
+uniform vec2 uMouse;        // mouse position (normalized or pixels)
+uniform float uFrame;       // frame counter
+uniform sampler2D uTexture;  // texture input
+```
+
+### Hash / Random Functions
+
+```glsl
+// 1D hash
+float hash(float n) {
+  return fract(sin(n) * 43758.5453123);
+}
+
+// 2D hash
+float hash(vec2 p) {
+  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
+}
+
+// 2D -> 2D hash
+vec2 hash2(vec2 p) {
+  p = vec2(dot(p, vec2(127.1, 311.7)),
+           dot(p, vec2(269.5, 183.3)));
+  return fract(sin(p) * 43758.5453123);
+}
+```
+
+### Value Noise
+
+```glsl
+float valueNoise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  vec2 u = f * f * (3.0 - 2.0 * f); // smoothstep
+
+  return mix(
+    mix(hash(i + vec2(0, 0)), hash(i + vec2(1, 0)), u.x),
+    mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x),
+    u.y
+  );
+}
+```
+
+### Simplex Noise (2D)
+
+```glsl
+// Credit: Stefan Gustavson, Ian McEwan (MIT)
+vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
+vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
+vec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0) * x); }
+
+float snoise(vec2 v) {
+  const vec4 C = vec4(
+    0.211324865405187,   // (3.0-sqrt(3.0))/6.0
+    0.366025403784439,   // 0.5*(sqrt(3.0)-1.0)
+   -0.577350269189626,   // -1.0 + 2.0 * C.x
+    0.024390243902439);  // 1.0 / 41.0
+
+  vec2 i  = floor(v + dot(v, C.yy));
+  vec2 x0 = v - i + dot(i, C.xx);
+
+  vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
+  vec4 x12 = x0.xyxy + C.xxzz;
+  x12.xy -= i1;
+
+  i = mod289(i);
+  vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0))
+                          + i.x + vec3(0.0, i1.x, 1.0));
+
+  vec3 m = max(0.5 - vec3(
+    dot(x0, x0),
+    dot(x12.xy, x12.xy),
+    dot(x12.zw, x12.zw)
+  ), 0.0);
+  m = m * m;
+  m = m * m;
+
+  vec3 x = 2.0 * fract(p * C.www) - 1.0;
+  vec3 h = abs(x) - 0.5;
+  vec3 ox = floor(x + 0.5);
+  vec3 a0 = x - ox;
+
+  m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
+
+  vec3 g;
+  g.x = a0.x * x0.x + h.x * x0.y;
+  g.yz = a0.yz * x12.xz + h.yz * x12.yw;
+
+  return 130.0 * dot(m, g);
+}
+```
+
+### FBM (Fractal Brownian Motion)
+
+```glsl
+float fbm(vec2 p, int octaves) {
+  float value = 0.0;
+  float amplitude = 0.5;
+  float frequency = 1.0;
+
+  for (int i = 0; i < 8; i++) { // max octaves = 8
+    if (i >= octaves) break;
+    value += amplitude * snoise(p * frequency);
+    frequency *= 2.0;   // lacunarity
+    amplitude *= 0.5;   // gain / persistence
+  }
+  return value;
+}
+```
+
+### Domain Warping
+
+```glsl
+// Single warp
+float warpedNoise(vec2 p) {
+  vec2 q = vec2(
+    fbm(p + vec2(0.0, 0.0), 4),
+    fbm(p + vec2(5.2, 1.3), 4)
+  );
+  return fbm(p + 4.0 * q, 4);
+}
+
+// Double warp (Inigo Quilez technique)
+float doubleWarp(vec2 p) {
+  vec2 q = vec2(
+    fbm(p + vec2(0.0, 0.0), 4),
+    fbm(p + vec2(5.2, 1.3), 4)
+  );
+  vec2 r = vec2(
+    fbm(p + 4.0 * q + vec2(1.7, 9.2), 4),
+    fbm(p + 4.0 * q + vec2(8.3, 2.8), 4)
+  );
+  return fbm(p + 4.0 * r, 4);
+}
+```
+
+### Worley / Cellular Noise
+
+```glsl
+float worley(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  float minDist = 1.0;
+
+  for (int y = -1; y <= 1; y++) {
+    for (int x = -1; x <= 1; x++) {
+      vec2 neighbor = vec2(float(x), float(y));
+      vec2 point = hash2(i + neighbor);
+      vec2 diff = neighbor + point - f;
+      float dist = length(diff);
+      minDist = min(minDist, dist);
+    }
+  }
+  return minDist;
+}
+
+// F2 - F1 for cell edges
+float worleyEdge(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  float f1 = 1.0, f2 = 1.0;
+
+  for (int y = -1; y <= 1; y++) {
+    for (int x = -1; x <= 1; x++) {
+      vec2 neighbor = vec2(float(x), float(y));
+      vec2 point = hash2(i + neighbor);
+      float dist = length(neighbor + point - f);
+      if (dist < f1) { f2 = f1; f1 = dist; }
+      else if (dist < f2) { f2 = dist; }
+    }
+  }
+  return f2 - f1;
+}
+```
+
+### 2D SDF Primitives
+
+```glsl
+float sdCircle(vec2 p, float r) {
+  return length(p) - r;
+}
+
+float sdBox(vec2 p, vec2 b) {
+  vec2 d = abs(p) - b;
+  return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
+}
+
+float sdSegment(vec2 p, vec2 a, vec2 b) {
+  vec2 pa = p - a, ba = b - a;
+  float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
+  return length(pa - ba * h);
+}
+
+float sdEquilateralTriangle(vec2 p, float r) {
+  const float k = sqrt(3.0);
+  p.x = abs(p.x) - r;
+  p.y = p.y + r / k;
+  if (p.x + k * p.y > 0.0) p = vec2(p.x - k*p.y, -k*p.x - p.y) / 2.0;
+  p.x -= clamp(p.x, -2.0*r, 0.0);
+  return -length(p) * sign(p.y);
+}
+```
+
+### 3D SDF Primitives
+
+```glsl
+float sdSphere(vec3 p, float r) { return length(p) - r; }
+
+float sdBox(vec3 p, vec3 b) {
+  vec3 q = abs(p) - b;
+  return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
+}
+
+float sdTorus(vec3 p, vec2 t) {
+  vec2 q = vec2(length(p.xz) - t.x, p.y);
+  return length(q) - t.y;
+}
+
+float sdCapsule(vec3 p, vec3 a, vec3 b, float r) {
+  vec3 pa = p - a, ba = b - a;
+  float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
+  return length(pa - ba * h) - r;
+}
+
+float sdRoundBox(vec3 p, vec3 b, float r) {
+  vec3 q = abs(p) - b + r;
+  return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - r;
+}
+
+float sdOctahedron(vec3 p, float s) {
+  p = abs(p);
+  float m = p.x + p.y + p.z - s;
+  vec3 q;
+       if (3.0*p.x < m) q = p.xyz;
+  else if (3.0*p.y < m) q = p.yzx;
+  else if (3.0*p.z < m) q = p.zxy;
+  else return m * 0.57735027;
+  float k = clamp(0.5*(q.z - q.y + s), 0.0, s);
+  return length(vec3(q.x, q.y - s + k, q.z - k));
+}
+```
+
+### SDF Operations
+
+```glsl
+// Boolean
+float opUnion(float a, float b) { return min(a, b); }
+float opSubtract(float a, float b) { return max(-a, b); }
+float opIntersect(float a, float b) { return max(a, b); }
+
+// Smooth boolean
+float opSmoothUnion(float a, float b, float k) {
+  k *= 4.0;
+  float h = max(k - abs(a - b), 0.0);
+  return min(a, b) - h*h*0.25/k;
+}
+
+float opSmoothSubtract(float a, float b, float k) {
+  return -opSmoothUnion(a, -b, k);
+}
+
+// Transform
+float opRound(float d, float r) { return d - r; }
+float opOnion(float d, float t) { return abs(d) - t; }
+
+// Repetition
+vec3 opRepeat(vec3 p, vec3 s) { return p - s * round(p / s); }
+vec3 opRepeatLimited(vec3 p, float s, vec3 lim) {
+  return p - s * clamp(round(p / s), -lim, lim);
+}
+
+// Twist
+vec3 opTwist(vec3 p, float k) {
+  float c = cos(k * p.y);
+  float s = sin(k * p.y);
+  mat2 m = mat2(c, -s, s, c);
+  return vec3(m * p.xz, p.y);
+}
+```
+
+### Ray Marching Template
+
+```glsl
+#define MAX_STEPS 100
+#define MAX_DIST 100.0
+#define SURF_DIST 0.001
+
+float map(vec3 p) {
+  float sphere = sdSphere(p - vec3(0, 1, 0), 1.0);
+  float plane = p.y;
+  return opSmoothUnion(sphere, plane, 0.5);
+}
+
+float rayMarch(vec3 ro, vec3 rd) {
+  float d = 0.0;
+  for (int i = 0; i < MAX_STEPS; i++) {
+    vec3 p = ro + rd * d;
+    float ds = map(p);
+    d += ds;
+    if (d > MAX_DIST || ds < SURF_DIST) break;
+  }
+  return d;
+}
+
+vec3 getNormal(vec3 p) {
+  vec2 e = vec2(0.001, 0.0);
+  return normalize(vec3(
+    map(p + e.xyy) - map(p - e.xyy),
+    map(p + e.yxy) - map(p - e.yxy),
+    map(p + e.yyx) - map(p - e.yyx)
+  ));
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+  vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
+
+  // Camera
+  vec3 ro = vec3(0, 2, -5);  // ray origin
+  vec3 rd = normalize(vec3(uv, 1.0));  // ray direction
+
+  float d = rayMarch(ro, rd);
+
+  vec3 col = vec3(0.0);
+  if (d < MAX_DIST) {
+    vec3 p = ro + rd * d;
+    vec3 n = getNormal(p);
+    vec3 lightDir = normalize(vec3(1, 2, -1));
+    float diff = max(dot(n, lightDir), 0.0);
+    col = vec3(1.0, 0.8, 0.6) * diff;
+  }
+
+  fragColor = vec4(col, 1.0);
+}
+```
+
+### Color Blending in Shaders
+
+```glsl
+// Palette function (Inigo Quilez)
+vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
+  return a + b * cos(6.28318 * (c * t + d));
+}
+
+// Common palettes:
+// Rainbow:  palette(t, vec3(0.5), vec3(0.5), vec3(1.0), vec3(0.0, 0.33, 0.67))
+// Sunset:   palette(t, vec3(0.5), vec3(0.5), vec3(1.0), vec3(0.0, 0.1, 0.2))
+// Ocean:    palette(t, vec3(0.5), vec3(0.5), vec3(1.0, 1.0, 0.5), vec3(0.8, 0.9, 0.3))
+// Fire:     palette(t, vec3(0.5,0.5,0.3), vec3(0.5,0.5,0.3), vec3(1.0), vec3(0.0,0.1,0.2))
+
+// OKLAB blending in GLSL (see color section below for conversion functions)
+vec3 blendOklab(vec3 rgb1, vec3 rgb2, float t) {
+  vec3 lab1 = linearSRGBToOklab(rgb1);
+  vec3 lab2 = linearSRGBToOklab(rgb2);
+  vec3 mixed = mix(lab1, lab2, t);
+  return oklabToLinearSRGB(mixed);
+}
+```
+
+---
+
+## 5. Procedural Generation Algorithms
+
+### Perlin / Simplex Noise (JavaScript)
+
+```javascript
+// Use a library: npm install simplex-noise
+import { createNoise2D, createNoise3D, createNoise4D } from 'simplex-noise';
+
+const noise2D = createNoise2D();  // returns -1..1
+const noise3D = createNoise3D();
+const noise4D = createNoise4D();
+
+// With seeded random
+import { createNoise2D } from 'simplex-noise';
+import alea from 'alea';
+
+const prng = alea('my-seed');
+const noise2D = createNoise2D(prng);
+```
+
+### FBM (JavaScript)
+
+```javascript
+function fbm(x, y, octaves = 6, lacunarity = 2.0, gain = 0.5) {
+  let value = 0;
+  let amplitude = 1.0;
+  let frequency = 1.0;
+  let maxValue = 0;
+
+  for (let i = 0; i < octaves; i++) {
+    value += amplitude * noise2D(x * frequency, y * frequency);
+    maxValue += amplitude;
+    frequency *= lacunarity;
+    amplitude *= gain;
+  }
+
+  return value / maxValue; // normalize to -1..1
+}
+```
+
+### Domain Warping (JavaScript)
+
+```javascript
+function domainWarp(x, y, scale = 0.005, warpStrength = 100) {
+  const qx = fbm(x * scale, y * scale, 4);
+  const qy = fbm(x * scale + 5.2, y * scale + 1.3, 4);
+
+  return fbm(
+    (x + warpStrength * qx) * scale,
+    (y + warpStrength * qy) * scale,
+    4
+  );
+}
+
+// Double warp for more organic patterns
+function doubleWarp(x, y, scale = 0.005) {
+  const q = [
+    fbm(x * scale, y * scale, 4),
+    fbm(x * scale + 5.2, y * scale + 1.3, 4),
+  ];
+  const r = [
+    fbm((x + 100 * q[0]) * scale + 1.7, (y + 100 * q[1]) * scale + 9.2, 4),
+    fbm((x + 100 * q[0]) * scale + 8.3, (y + 100 * q[1]) * scale + 2.8, 4),
+  ];
+  return fbm(
+    (x + 100 * r[0]) * scale,
+    (y + 100 * r[1]) * scale,
+    4
+  );
+}
+```
+
+### Ridged Noise
+
+```javascript
+function ridgedNoise(x, y, octaves = 6) {
+  let value = 0;
+  let amplitude = 1.0;
+  let frequency = 1.0;
+  let weight = 1.0;
+
+  for (let i = 0; i < octaves; i++) {
+    let signal = noise2D(x * frequency, y * frequency);
+    signal = 1.0 - Math.abs(signal); // create ridges
+    signal *= signal;                 // sharpen
+    signal *= weight;
+    weight = Math.min(1.0, Math.max(0.0, signal * 2.0));
+
+    value += signal * amplitude;
+    frequency *= 2.0;
+    amplitude *= 0.5;
+  }
+  return value;
+}
+```
+
+### Flow Fields
+
+```javascript
+class FlowField {
+  constructor(cols, rows, noiseScale = 0.1) {
+    this.cols = cols;
+    this.rows = rows;
+    this.field = new Float32Array(cols * rows);
+    this.noiseScale = noiseScale;
+  }
+
+  update(time = 0) {
+    for (let y = 0; y < this.rows; y++) {
+      for (let x = 0; x < this.cols; x++) {
+        const angle = noise2D(
+          x * this.noiseScale,
+          y * this.noiseScale + time * 0.2
+        ) * Math.PI * 2;
+        this.field[y * this.cols + x] = angle;
+      }
+    }
+  }
+
+  getAngle(x, y) {
+    const col = Math.floor(x) % this.cols;
+    const row = Math.floor(y) % this.rows;
+    return this.field[row * this.cols + col];
+  }
+}
+
+class Particle {
+  constructor(x, y) {
+    this.x = x;
+    this.y = y;
+    this.prevX = x;
+    this.prevY = y;
+    this.speed = 2;
+  }
+
+  follow(field) {
+    this.prevX = this.x;
+    this.prevY = this.y;
+    const angle = field.getAngle(this.x, this.y);
+    this.x += Math.cos(angle) * this.speed;
+    this.y += Math.sin(angle) * this.speed;
+  }
+
+  edges(w, h) {
+    if (this.x < 0 || this.x > w || this.y < 0 || this.y > h) {
+      this.x = Math.random() * w;
+      this.y = Math.random() * h;
+      this.prevX = this.x;
+      this.prevY = this.y;
+    }
+  }
+}
+
+// p5.js usage
+const field = new FlowField(80, 80, 0.05);
+const particles = Array.from({ length: 1000 },
+  () => new Particle(random(width), random(height))
+);
+
+function draw() {
+  field.update(frameCount * 0.01);
+  for (const p of particles) {
+    p.follow(field);
+    p.edges(width, height);
+    stroke(255, 20);
+    line(p.prevX, p.prevY, p.x, p.y);
+  }
+}
+```
+
+### Poisson Disk Sampling
+
+```javascript
+function poissonDisk(width, height, minDist, maxAttempts = 30) {
+  const cellSize = minDist / Math.SQRT2;
+  const gridW = Math.ceil(width / cellSize);
+  const gridH = Math.ceil(height / cellSize);
+  const grid = new Array(gridW * gridH).fill(null);
+  const points = [];
+  const active = [];
+
+  function gridIndex(x, y) {
+    return Math.floor(x / cellSize) + Math.floor(y / cellSize) * gridW;
+  }
+
+  // Seed point
+  const p0 = { x: width / 2, y: height / 2 };
+  points.push(p0);
+  active.push(p0);
+  grid[gridIndex(p0.x, p0.y)] = p0;
+
+  while (active.length > 0) {
+    const idx = Math.floor(Math.random() * active.length);
+    const point = active[idx];
+    let found = false;
+
+    for (let n = 0; n < maxAttempts; n++) {
+      const angle = Math.random() * Math.PI * 2;
+      const dist = minDist + Math.random() * minDist;
+      const candidate = {
+        x: point.x + Math.cos(angle) * dist,
+        y: point.y + Math.sin(angle) * dist,
+      };
+
+      if (candidate.x < 0 || candidate.x >= width ||
+          candidate.y < 0 || candidate.y >= height) continue;
+
+      const gi = gridIndex(candidate.x, candidate.y);
+      let ok = true;
+
+      // Check neighboring cells
+      const gx = Math.floor(candidate.x / cellSize);
+      const gy = Math.floor(candidate.y / cellSize);
+      for (let dy = -2; dy <= 2 && ok; dy++) {
+        for (let dx = -2; dx <= 2 && ok; dx++) {
+          const nx = gx + dx, ny = gy + dy;
+          if (nx < 0 || nx >= gridW || ny < 0 || ny >= gridH) continue;
+          const neighbor = grid[nx + ny * gridW];
+          if (neighbor) {
+            const d = Math.hypot(candidate.x - neighbor.x,
+                                 candidate.y - neighbor.y);
+            if (d < minDist) ok = false;
+          }
+        }
+      }
+
+      if (ok) {
+        points.push(candidate);
+        active.push(candidate);
+        grid[gi] = candidate;
+        found = true;
+        break;
+      }
+    }
+
+    if (!found) active.splice(idx, 1);
+  }
+
+  return points;
+}
+```
+
+### L-Systems
+
+```javascript
+class LSystem {
+  constructor(axiom, rules, angle = 25) {
+    this.axiom = axiom;
+    this.rules = rules; // { 'F': 'FF+[+F-F-F]-[-F+F+F]' }
+    this.angle = angle * (Math.PI / 180);
+    this.sentence = axiom;
+  }
+
+  generate(iterations) {
+    this.sentence = this.axiom;
+    for (let i = 0; i < iterations; i++) {
+      let next = '';
+      for (const ch of this.sentence) {
+        next += this.rules[ch] || ch;
+      }
+      this.sentence = next;
+    }
+    return this.sentence;
+  }
+
+  // Returns array of line segments [{x1,y1,x2,y2}]
+  interpret(startX, startY, stepLen) {
+    const lines = [];
+    const stack = [];
+    let x = startX, y = startY;
+    let angle = -Math.PI / 2; // start pointing up
+
+    for (const ch of this.sentence) {
+      switch (ch) {
+        case 'F': {
+          const nx = x + Math.cos(angle) * stepLen;
+          const ny = y + Math.sin(angle) * stepLen;
+          lines.push({ x1: x, y1: y, x2: nx, y2: ny });
+          x = nx; y = ny;
+          break;
+        }
+        case '+': angle += this.angle; break;
+        case '-': angle -= this.angle; break;
+        case '[': stack.push({ x, y, angle }); break;
+        case ']': {
+          const state = stack.pop();
+          x = state.x; y = state.y; angle = state.angle;
+          break;
+        }
+      }
+    }
+    return lines;
+  }
+}
+
+// Classic trees
+const tree = new LSystem('F', { 'F': 'FF+[+F-F-F]-[-F+F+F]' }, 22.5);
+tree.generate(4);
+
+// Koch curve
+const koch = new LSystem('F', { 'F': 'F+F-F-F+F' }, 90);
+
+// Sierpinski triangle
+const sierpinski = new LSystem('F-G-G', {
+  'F': 'F-G+F+G-F',
+  'G': 'GG'
+}, 120);
+
+// Dragon curve
+const dragon = new LSystem('FX', {
+  'X': 'X+YF+',
+  'Y': '-FX-Y'
+}, 90);
+```
+
+### Cellular Automata (Game of Life)
+
+```javascript
+class CellularAutomata {
+  constructor(width, height) {
+    this.w = width;
+    this.h = height;
+    this.grid = new Uint8Array(width * height);
+    this.next = new Uint8Array(width * height);
+  }
+
+  randomize(density = 0.3) {
+    for (let i = 0; i < this.grid.length; i++) {
+      this.grid[i] = Math.random() < density ? 1 : 0;
+    }
+  }
+
+  step() {
+    for (let y = 0; y < this.h; y++) {
+      for (let x = 0; x < this.w; x++) {
+        const neighbors = this.countNeighbors(x, y);
+        const idx = y * this.w + x;
+        const alive = this.grid[idx];
+
+        // Conway's Game of Life rules
+        if (alive && (neighbors < 2 || neighbors > 3)) {
+          this.next[idx] = 0;
+        } else if (!alive && neighbors === 3) {
+          this.next[idx] = 1;
+        } else {
+          this.next[idx] = this.grid[idx];
+        }
+      }
+    }
+    [this.grid, this.next] = [this.next, this.grid];
+  }
+
+  countNeighbors(x, y) {
+    let count = 0;
+    for (let dy = -1; dy <= 1; dy++) {
+      for (let dx = -1; dx <= 1; dx++) {
+        if (dx === 0 && dy === 0) continue;
+        const nx = (x + dx + this.w) % this.w;
+        const ny = (y + dy + this.h) % this.h;
+        count += this.grid[ny * this.w + nx];
+      }
+    }
+    return count;
+  }
+}
+```
+
+### Voronoi Diagram (Fortune's Algorithm Alternative -- Brute Force)
+
+```javascript
+// For production use: npm install d3-delaunay
+import { Delaunay } from 'd3-delaunay';
+
+// Generate Voronoi from random points
+const points = Array.from({ length: 50 }, () => [
+  Math.random() * width,
+  Math.random() * height,
+]);
+
+const delaunay = Delaunay.from(points);
+const voronoi = delaunay.voronoi([0, 0, width, height]);
+
+// Iterate cells
+for (let i = 0; i < points.length; i++) {
+  const cell = voronoi.cellPolygon(i);
+  if (!cell) continue;
+  // cell is array of [x,y] vertices (closed polygon)
+  // Draw with canvas, SVG, etc.
+}
+
+// Delaunay triangles
+for (let i = 0; i < delaunay.triangles.length; i += 3) {
+  const p0 = points[delaunay.triangles[i]];
+  const p1 = points[delaunay.triangles[i + 1]];
+  const p2 = points[delaunay.triangles[i + 2]];
+  // Draw triangle
+}
+
+// Lloyd relaxation (makes cells more even)
+function lloydRelax(points, bounds, iterations = 3) {
+  let pts = [...points];
+  for (let i = 0; i < iterations; i++) {
+    const d = Delaunay.from(pts);
+    const v = d.voronoi(bounds);
+    pts = pts.map((_, j) => {
+      const cell = v.cellPolygon(j);
+      if (!cell) return pts[j];
+      // Centroid of polygon
+      let cx = 0, cy = 0;
+      for (let k = 0; k < cell.length - 1; k++) {
+        cx += cell[k][0];
+        cy += cell[k][1];
+      }
+      return [cx / (cell.length - 1), cy / (cell.length - 1)];
+    });
+  }
+  return pts;
+}
+```
+
+### Wave Function Collapse (Simple Tiled)
+
+```javascript
+class WFC {
+  constructor(tiles, adjacency, width, height) {
+    this.tiles = tiles;        // array of tile IDs
+    this.adj = adjacency;      // { tileId: { up: [...], down: [...], left: [...], right: [...] } }
+    this.w = width;
+    this.h = height;
+    // Each cell starts with all tiles possible
+    this.grid = Array.from({ length: width * height },
+      () => new Set(tiles)
+    );
+  }
+
+  entropy(idx) {
+    return this.grid[idx].size;
+  }
+
+  // Find cell with lowest entropy > 1
+  findLowestEntropy() {
+    let minE = Infinity, minIdx = -1;
+    for (let i = 0; i < this.grid.length; i++) {
+      const e = this.grid[i].size;
+      if (e > 1 && e < minE) {
+        minE = e;
+        minIdx = i;
+      }
+    }
+    return minIdx;
+  }
+
+  collapse(idx) {
+    const options = [...this.grid[idx]];
+    const chosen = options[Math.floor(Math.random() * options.length)];
+    this.grid[idx] = new Set([chosen]);
+    return chosen;
+  }
+
+  propagate(idx) {
+    const stack = [idx];
+    while (stack.length > 0) {
+      const current = stack.pop();
+      const x = current % this.w;
+      const y = Math.floor(current / this.w);
+      const currentTiles = this.grid[current];
+
+      const neighbors = [
+        { dx: 0, dy: -1, dir: 'up', opp: 'down' },
+        { dx: 0, dy: 1, dir: 'down', opp: 'up' },
+        { dx: -1, dy: 0, dir: 'left', opp: 'right' },
+        { dx: 1, dy: 0, dir: 'right', opp: 'left' },
+      ];
+
+      for (const { dx, dy, dir } of neighbors) {
+        const nx = x + dx, ny = y + dy;
+        if (nx < 0 || nx >= this.w || ny < 0 || ny >= this.h) continue;
+        const ni = ny * this.w + nx;
+        const neighborPossible = this.grid[ni];
+        const prevSize = neighborPossible.size;
+
+        // Compute allowed tiles for neighbor
+        const allowed = new Set();
+        for (const t of currentTiles) {
+          for (const a of (this.adj[t]?.[dir] || [])) {
+            allowed.add(a);
+          }
+        }
+
+        // Intersect
+        for (const t of neighborPossible) {
+          if (!allowed.has(t)) neighborPossible.delete(t);
+        }
+
+        if (neighborPossible.size < prevSize) {
+          stack.push(ni);
+        }
+      }
+    }
+  }
+
+  solve() {
+    while (true) {
+      const idx = this.findLowestEntropy();
+      if (idx === -1) break; // all collapsed
+      this.collapse(idx);
+      this.propagate(idx);
+    }
+    return this.grid.map(s => [...s][0]);
+  }
+}
+```
+
+### Terrain with Noise Octaves
+
+```javascript
+function generateTerrain(width, height, options = {}) {
+  const {
+    octaves = 6,
+    lacunarity = 2.0,
+    gain = 0.5,
+    scale = 0.005,
+    exponent = 1.5,  // redistribution power
+    seed = 'terrain',
+  } = options;
+
+  const prng = alea(seed);
+  const noise = createNoise2D(prng);
+  const data = new Float32Array(width * height);
+
+  for (let y = 0; y < height; y++) {
+    for (let x = 0; x < width; x++) {
+      const nx = x * scale - 0.5;
+      const ny = y * scale - 0.5;
+
+      let e = 0, amplitude = 1, frequency = 1, maxAmp = 0;
+      for (let i = 0; i < octaves; i++) {
+        e += amplitude * noise(nx * frequency, ny * frequency);
+        maxAmp += amplitude;
+        frequency *= lacunarity;
+        amplitude *= gain;
+      }
+      e = (e / maxAmp + 1) * 0.5; // normalize to 0..1
+      e = Math.pow(e, exponent);   // redistribute
+
+      data[y * width + x] = e;
+    }
+  }
+  return data;
+}
+
+// Biome from elevation + moisture
+function biome(e, m) {
+  if (e < 0.1) return 'DEEP_WATER';
+  if (e < 0.15) return 'WATER';
+  if (e < 0.18) return 'BEACH';
+  if (e > 0.8) {
+    if (m < 0.2) return 'SCORCHED';
+    if (m < 0.5) return 'BARE';
+    return 'SNOW';
+  }
+  if (e > 0.6) {
+    if (m < 0.33) return 'SHRUBLAND';
+    return 'FOREST';
+  }
+  if (m < 0.16) return 'DESERT';
+  if (m < 0.5) return 'GRASSLAND';
+  return 'RAINFOREST';
+}
+```
+
+### Seamless Tiling (Cylindrical / Toroidal Noise)
+
+```javascript
+// Wrap noise seamlessly by mapping to higher dimensions
+function torusNoise(nx, ny, noise4D) {
+  const TAU = Math.PI * 2;
+  return noise4D(
+    Math.cos(TAU * nx) / TAU,
+    Math.sin(TAU * nx) / TAU,
+    Math.cos(TAU * ny) / TAU,
+    Math.sin(TAU * ny) / TAU
+  );
+}
+
+// Scale output by sqrt(2) to compensate for 4D range narrowing
+```
+
+---
+
+## 6. Color Theory for Generative Art
+
+> For CSS color, accessibility, design tokens, and gamut details, see `color-ops`.
+
+### OKLAB / OKLCH Conversion (JavaScript)
+
+```javascript
+function linearSRGBToOklab(r, g, b) {
+  const l = 0.4122214708*r + 0.5363325363*g + 0.0514459929*b;
+  const m = 0.2119034982*r + 0.6806995451*g + 0.1073969566*b;
+  const s = 0.0883024619*r + 0.2817188376*g + 0.6299787005*b;
+  const l_ = Math.cbrt(l), m_ = Math.cbrt(m), s_ = Math.cbrt(s);
+  return {
+    L: 0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_,
+    a: 1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_,
+    b: 0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_,
+  };
+}
+
+function oklabToLinearSRGB(L, a, b) {
+  const l_ = L + 0.3963377774*a + 0.2158037573*b;
+  const m_ = L - 0.1055613458*a - 0.0638541728*b;
+  const s_ = L - 0.0894841775*a - 1.2914855480*b;
+  return {
+    r: +4.0767416621*l_**3 - 3.3077115913*m_**3 + 0.2309699292*s_**3,
+    g: -1.2684380046*l_**3 + 2.6097574011*m_**3 - 0.3413193965*s_**3,
+    b: -0.0041960863*l_**3 - 0.7034186147*m_**3 + 1.7076147010*s_**3,
+  };
+}
+
+function oklabToOklch({ L, a, b }) {
+  return { L, C: Math.hypot(a, b), h: Math.atan2(b, a) * 180 / Math.PI };
+}
+
+function oklchToOklab({ L, C, h }) {
+  const rad = h * Math.PI / 180;
+  return { L, a: C * Math.cos(rad), b: C * Math.sin(rad) };
+}
+```
+
+### OKLAB / OKLCH Conversion (GLSL)
+
+```glsl
+vec3 linearSRGBToOklab(vec3 c) {
+  vec3 lms = vec3(
+    dot(c, vec3(0.4122214708, 0.5363325363, 0.0514459929)),
+    dot(c, vec3(0.2119034982, 0.6806995451, 0.1073969566)),
+    dot(c, vec3(0.0883024619, 0.2817188376, 0.6299787005))
+  );
+  lms = sign(lms) * pow(abs(lms), vec3(1.0/3.0));
+  return vec3(
+    dot(lms, vec3(0.2104542553, 0.7936177850, -0.0040720468)),
+    dot(lms, vec3(1.9779984951, -2.4285922050, 0.4505937099)),
+    dot(lms, vec3(0.0259040371, 0.7827717662, -0.8086757660))
+  );
+}
+
+vec3 oklabToLinearSRGB(vec3 lab) {
+  vec3 lms = vec3(
+    lab.x + 0.3963377774*lab.y + 0.2158037573*lab.z,
+    lab.x - 0.1055613458*lab.y - 0.0638541728*lab.z,
+    lab.x - 0.0894841775*lab.y - 1.2914855480*lab.z
+  );
+  return vec3(
+    dot(lms*lms*lms, vec3(4.0767416621, -3.3077115913, 0.2309699292)),
+    dot(lms*lms*lms, vec3(-1.2684380046, 2.6097574011, -0.3413193965)),
+    dot(lms*lms*lms, vec3(-0.0041960863, -0.7034186147, 1.7076147010))
+  );
+}
+```
+
+### Palette Generation Algorithms
+
+```javascript
+// Cosine palette (port of Inigo Quilez technique)
+function cosinePalette(t, a, b, c, d) {
+  return [
+    a[0] + b[0] * Math.cos(Math.PI * 2 * (c[0] * t + d[0])),
+    a[1] + b[1] * Math.cos(Math.PI * 2 * (c[1] * t + d[1])),
+    a[2] + b[2] * Math.cos(Math.PI * 2 * (c[2] * t + d[2])),
+  ];
+}
+
+// Presets (a, b, c, d)
+const PALETTES = {
+  rainbow:  [[0.5,0.5,0.5], [0.5,0.5,0.5], [1,1,1],       [0, 0.33, 0.67]],
+  sunset:   [[0.5,0.5,0.5], [0.5,0.5,0.5], [1,1,1],       [0, 0.1, 0.2]],
+  ocean:    [[0.5,0.5,0.5], [0.5,0.5,0.5], [1,1,0.5],     [0.8, 0.9, 0.3]],
+  fire:     [[0.5,0.5,0.3], [0.5,0.5,0.3], [1,1,1],       [0, 0.1, 0.2]],
+  electric: [[0.5,0.5,0.5], [0.5,0.5,0.5], [2,1,0],       [0.5, 0.2, 0.25]],
+  forest:   [[0.5,0.5,0.5], [0.5,0.5,0.5], [1,0.7,0.4],   [0, 0.15, 0.2]],
+};
+
+// Usage: get color at position t (0..1) along palette
+const [r, g, b] = cosinePalette(0.5, ...PALETTES.sunset);
+```
+
+### OKLCH Palette Generation
+
+```javascript
+// Perceptually uniform palette with fixed lightness
+function oklchPalette(count, L = 0.7, C = 0.15, hueOffset = 0) {
+  return Array.from({ length: count }, (_, i) => {
+    const h = (hueOffset + (i / count) * 360) % 360;
+    return { L, C, h };
+  });
+}
+
+// Analogous palette (clustered hues)
+function analogousPalette(baseHue, count = 5, spread = 30, L = 0.7, C = 0.15) {
+  return Array.from({ length: count }, (_, i) => {
+    const t = i / (count - 1) - 0.5; // -0.5 to 0.5
+    return { L, C, h: (baseHue + t * spread + 360) % 360 };
+  });
+}
+
+// Warm/cool palette
+function warmCoolPalette(count = 6) {
+  return Array.from({ length: count }, (_, i) => {
+    const t = i / (count - 1);
+    return {
+      L: 0.5 + t * 0.3,
+      C: 0.12 + Math.sin(t * Math.PI) * 0.06,
+      h: 20 + t * 220,  // warm orange -> cool blue
+    };
+  });
+}
+```
+
+### Gradient Interpolation in Perceptual Space
+
+```javascript
+// Interpolate in OKLAB (no hue discontinuity issues)
+function lerpOklab(lab1, lab2, t) {
+  return {
+    L: lab1.L + (lab2.L - lab1.L) * t,
+    a: lab1.a + (lab2.a - lab1.a) * t,
+    b: lab1.b + (lab2.b - lab1.b) * t,
+  };
+}
+
+// Interpolate in OKLCH with shortest hue path
+function lerpOklch(lch1, lch2, t) {
+  let dh = lch2.h - lch1.h;
+  if (dh > 180) dh -= 360;
+  if (dh < -180) dh += 360;
+
+  return {
+    L: lch1.L + (lch2.L - lch1.L) * t,
+    C: lch1.C + (lch2.C - lch1.C) * t,
+    h: (lch1.h + dh * t + 360) % 360,
+  };
+}
+
+// Multi-stop gradient
+function multiStopGradient(stops, t) {
+  // stops: [{pos: 0, color: {L,C,h}}, {pos: 0.5, ...}, {pos: 1, ...}]
+  if (t <= stops[0].pos) return stops[0].color;
+  if (t >= stops[stops.length - 1].pos) return stops[stops.length - 1].color;
+
+  for (let i = 0; i < stops.length - 1; i++) {
+    if (t >= stops[i].pos && t <= stops[i + 1].pos) {
+      const localT = (t - stops[i].pos) / (stops[i + 1].pos - stops[i].pos);
+      return lerpOklch(stops[i].color, stops[i + 1].color, localT);
+    }
+  }
+}
+```
+
+### Color Cycling
+
+```javascript
+// Smooth cycling through a palette
+function cyclePalette(palette, t, speed = 1.0) {
+  const idx = (t * speed) % palette.length;
+  const i = Math.floor(idx);
+  const frac = idx - i;
+  const c1 = palette[i % palette.length];
+  const c2 = palette[(i + 1) % palette.length];
+  return lerpOklch(c1, c2, frac);
+}
+
+// Phase-shifted cycling (each element gets different phase)
+function phasedColor(palette, t, elementIndex, phaseSpread = 0.1) {
+  return cyclePalette(palette, t + elementIndex * phaseSpread);
+}
+```
+
+### Harmony Rules in OKLCH
+
+```javascript
+function colorHarmonies(baseHue, L = 0.65, C = 0.15) {
+  const h = baseHue;
+  return {
+    complementary:   [{ L, C, h }, { L, C, h: (h + 180) % 360 }],
+    analogous:       [{ L, C, h: (h - 30 + 360) % 360 }, { L, C, h }, { L, C, h: (h + 30) % 360 }],
+    triadic:         [{ L, C, h }, { L, C, h: (h + 120) % 360 }, { L, C, h: (h + 240) % 360 }],
+    splitComplementary: [{ L, C, h }, { L, C, h: (h + 150) % 360 }, { L, C, h: (h + 210) % 360 }],
+    tetradic:        [{ L, C, h }, { L, C, h: (h + 90) % 360 }, { L, C, h: (h + 180) % 360 }, { L, C, h: (h + 270) % 360 }],
+  };
+}
+```
+
+---
+
+## Quick Reference: Noise Algorithm Comparison
+
+| Algorithm | Dimension | Character | Cost | Use Case |
+|-----------|-----------|-----------|------|----------|
+| Value noise | Any | Blocky, grid artifacts | Cheap | Quick prototypes |
+| Perlin (gradient) | Any | Smooth, directional | Medium | Classic terrain, clouds |
+| Simplex | Any | Smooth, isotropic | Medium | Default choice, fewer artifacts than Perlin |
+| Worley (cellular) | Any | Cell-like, organic | Expensive | Stone, water, cells |
+| FBM | Any | Fractal detail | N * base | Terrain, clouds, organic shapes |
+| Ridged FBM | Any | Sharp mountain ridges | N * base | Mountains, lightning |
+| Domain warping | 2D+ | Swirling, marble-like | 3-9x base | Marble, smoke, alien landscapes |
+
+## Quick Reference: Libraries
+
+| Task | Library | Install |
+|------|---------|---------|
+| Noise | `simplex-noise` | `npm install simplex-noise` |
+| Seeded random | `alea` | `npm install alea` |
+| Voronoi/Delaunay | `d3-delaunay` | `npm install d3-delaunay` |
+| 3D engine | `three` | `npm install three` |
+| 2D canvas | `p5` | `npm install p5` |
+| Canvas export | `canvas-sketch` | `npm install canvas-sketch` |
+| Video export | `ccapture.js` | `npm install ccapture.js` |
+| SVG optimize | `svgo` | `npm install -g svgo` |
+| Color | `culori` | `npm install culori` |
+| Shader library | LYGIA | `#include` from lygia.xyz |
+
+## See Also
+
+- `color-ops` - CSS color, accessibility, design tokens, palette scripts
+- `javascript-ops` - JS async patterns, modules, ES2024+ features
+- [Book of Shaders](https://thebookofshaders.com/) - GLSL fundamentals
+- [Shadertoy](https://www.shadertoy.com/) - Live shader playground
+- [Inigo Quilez articles](https://iquilezles.org/articles/) - SDF, noise, ray marching
+- [LYGIA](https://lygia.xyz/) - Cross-platform shader library
+- [Red Blob Games](https://www.redblobgames.com/) - Procedural generation algorithms

+ 4 - 1
skills/git-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: git-ops
 description: "Git operations orchestrator - commits, PRs, branch management, releases, changelog. Routes lightweight reads inline, dispatches heavy work to background Sonnet agent. Triggers on: commit, push, pull request, create PR, git status, git diff, rebase, stash, branch, merge, release, tag, changelog, semver, cherry-pick, bisect, worktree."
+license: MIT
 allowed-tools: "Read Bash Glob Grep Agent TaskCreate TaskUpdate"
-related-skills: [review, ci-cd-ops]
+metadata:
+  author: claude-mods
+  related-skills: review, ci-cd-ops
 ---
 
 # Git Ops

+ 4 - 1
skills/go-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: go-ops
 description: "Go development patterns, concurrency, error handling, testing, and project structure. Use for: golang, go, goroutine, channel, context, errgroup, go test, go mod, go build, interface, generics, table-driven tests, worker pool, sync.Mutex, sync.WaitGroup, pprof, go vet, golangci-lint, go workspace, functional options, middleware, http handler."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [docker-ops, ci-cd-ops, api-design-ops, testing-ops]
+metadata:
+  author: claude-mods
+  related-skills: docker-ops, ci-cd-ops, api-design-ops, testing-ops
 ---
 
 # Go Operations

+ 4 - 1
skills/introspect/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: introspect
 description: "Analyze Claude Code session logs and surface productivity improvements. Extracts thinking blocks, tool usage stats, error patterns, debug trajectories - then generates friendly, actionable recommendations. Triggers on: introspect, session logs, trajectory, analyze sessions, what went wrong, tool usage, thinking blocks, session history, my reasoning, past sessions, what did I do, how can I improve."
+license: MIT
 allowed-tools: "Bash Read Grep Glob"
-related-skills: [log-ops, data-processing]
+metadata:
+  author: claude-mods
+  related-skills: log-ops, data-processing
 ---
 
 # Introspect

+ 3 - 0
skills/iterate/SKILL.md

@@ -1,7 +1,10 @@
 ---
 name: iterate
 description: "Autonomous improvement loop - modify, measure, keep or discard, repeat. Inspired by Karpathy's autoresearch. Triggers on: iterate, improve autonomously, run overnight, keep improving, autoresearch, improvement loop, iterate until done, autonomous iteration."
+license: MIT
 allowed-tools: "Read Write Edit Glob Grep Bash Agent TaskCreate TaskUpdate TaskList"
+metadata:
+  author: claude-mods
 ---
 
 # Iterate - Autonomous Improvement Loop

+ 4 - 1
skills/javascript-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: javascript-ops
 description: "JavaScript and Node.js patterns, async programming, modules, runtime internals, and modern ES2024+ features. Use for: javascript, js, node, nodejs, esm, commonjs, promise, async await, event loop, v8, npm, es6, es2024, worker threads, streams, event emitter, prototype, closure."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [typescript-ops, react-ops, vue-ops, testing-ops]
+metadata:
+  author: claude-mods
+  related-skills: typescript-ops, react-ops, vue-ops, testing-ops
 ---
 
 # JavaScript Operations

+ 4 - 1
skills/laravel-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: laravel-ops
 description: "Laravel framework patterns, Eloquent ORM, authentication, queues, and testing. Use for: laravel, eloquent, artisan, blade, php, sanctum, livewire, inertia, pest, phpunit, forge, vapor, queue, middleware, migration, factory, seeder."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [sql-ops, postgres-ops, testing-ops, docker-ops]
+metadata:
+  author: claude-mods
+  related-skills: sql-ops, postgres-ops, testing-ops, docker-ops
 ---
 
 # Laravel Operations

+ 4 - 1
skills/log-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: log-ops
 description: "Log analysis and JSONL processing - structured extraction, cross-log correlation, timeline reconstruction, pattern search"
+license: MIT
 allowed-tools: "Read Edit Write Bash Glob Grep Agent"
-related-skills: [data-processing, debug-ops, monitoring-ops, file-search, introspect]
+metadata:
+  author: claude-mods
+  related-skills: data-processing, debug-ops, monitoring-ops, file-search, introspect
 ---
 
 # Log Operations

+ 3 - 0
skills/markitdown/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: markitdown
 description: "Convert local documents to Markdown using Microsoft's markitdown CLI. Best for: PDF, Word, Excel, PowerPoint, images (OCR), audio. Can fetch URLs but Jina is faster for web. Triggers on: convert to markdown, read PDF, parse document, extract text from, docx, xlsx, pptx, OCR image, local file."
+license: MIT
 compatibility: "Requires markitdown. Install: pip install markitdown"
 allowed-tools: "Bash"
+metadata:
+  author: claude-mods
 ---
 
 # markitdown - Document to Markdown

+ 4 - 1
skills/mcp-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: mcp-ops
 description: "Model Context Protocol server development, tool design, resource handling, and transport configuration. Use for: mcp, model context protocol, mcp server, mcp tool, mcp resource, fastmcp, mcp transport, stdio, sse, streamable http, mcp inspector, tool handler, mcp prompt."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [claude-code-hooks, claude-code-debug, typescript-ops, python-fastapi-ops]
+metadata:
+  author: claude-mods
+  related-skills: claude-code-hooks, claude-code-debug, typescript-ops, python-fastapi-ops
 ---
 
 # MCP Operations

+ 4 - 1
skills/migrate-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: migrate-ops
 description: "Framework and language migration patterns - version upgrades, breaking changes, dependency audit, safe rollback. Use for: migrate, migration, upgrade, version bump, breaking changes, deprecation, dependency audit, npm audit, pip-audit, codemod, jscodeshift, rector, rollback, semver, changelog, framework upgrade, language upgrade, React 19, Vue 3, Next.js App Router, Laravel 11, Angular, Python 3.12, Node 22, TypeScript 5, Go 1.22, Rust 2024, PHP 8.4."
+license: MIT
 allowed-tools: "Read Edit Write Bash Glob Grep Agent"
-related-skills: [testing-ops, debug-ops, git-ops, refactor-ops]
+metadata:
+  author: claude-mods
+  related-skills: testing-ops, debug-ops, git-ops, refactor-ops
 ---
 
 # Migrate Operations

+ 4 - 1
skills/monitoring-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: monitoring-ops
 description: "Observability patterns - metrics, logging, tracing, alerting, and infrastructure monitoring. Use for: monitoring, observability, prometheus, grafana, metrics, alerting, structured logging, distributed tracing, opentelemetry, SLO, SLI, dashboard, health check, loki, jaeger, datadog, pagerduty."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [python-observability-ops, docker-ops, ci-cd-ops, nginx-ops]
+metadata:
+  author: claude-mods
+  related-skills: python-observability-ops, docker-ops, ci-cd-ops, nginx-ops
 ---
 
 # Monitoring Operations

+ 4 - 1
skills/nginx-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: nginx-ops
 description: "Nginx configuration, reverse proxy, SSL/TLS, load balancing, and performance tuning. Use for: nginx, reverse proxy, load balancer, proxy_pass, ssl certificate, lets encrypt, web server, location block, upstream, server block, nginx config, certbot, hsts, gzip, rate limiting."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [docker-ops, security-ops, ci-cd-ops]
+metadata:
+  author: claude-mods
+  related-skills: docker-ops, security-ops, ci-cd-ops
 ---
 
 # Nginx Operations

+ 4 - 1
skills/perf-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: perf-ops
 description: "Performance profiling and optimization orchestrator - diagnoses symptoms, dispatches profiling to language experts, manages before/after comparisons. Triggers on: performance, profiling, flamegraph, pprof, py-spy, clinic.js, memray, heaptrack, bundle size, webpack analyzer, load testing, k6, artillery, vegeta, locust, benchmark, hyperfine, criterion, slow query, EXPLAIN ANALYZE, N+1, caching, optimization, latency, throughput, p99, memory leak, CPU spike, bottleneck."
+license: MIT
 allowed-tools: "Read Edit Write Bash Glob Grep Agent TaskCreate TaskUpdate"
-related-skills: [debug-ops, monitoring-ops, testing-ops, code-stats, postgres-ops]
+metadata:
+  author: claude-mods
+  related-skills: debug-ops, monitoring-ops, testing-ops, code-stats, postgres-ops
 ---
 
 # Performance Operations

+ 249 - 0
skills/pigeon/SKILL.md

@@ -0,0 +1,249 @@
+---
+name: pigeon
+description: "Inter-session pmail - send and receive messages between Claude Code sessions running in different project directories. Uses global SQLite database at ~/.claude/pmail.db. Triggers on: mail, pmail, send message, check mail, inbox, inter-session, message another session, pigeon."
+license: MIT
+allowed-tools: "Read Bash Grep"
+metadata:
+  author: claude-mods
+  related-skills: sqlite-ops
+---
+
+# Pigeon
+
+Inter-session messaging for Claude Code. Send and receive pmail between sessions running in different projects.
+
+## Quick Reference
+
+All commands go through `MAIL`, a shorthand for `bash "$HOME/.claude/pigeon/mail-db.sh"`.
+
+Set this at the top of execution:
+
+```bash
+MAIL="$HOME/.claude/pigeon/mail-db.sh"
+```
+
+Then use it for all commands below.
+
+## Command Router
+
+Parse the user's input after `pigeon` (or `/pigeon`) and run the matching command:
+
+| User says | Run |
+|-----------|-----|
+| `pigeon read` | `bash "$MAIL" read` |
+| `pigeon read 42` | `bash "$MAIL" read 42` |
+| `pigeon send <project> "<subject>" "<body>"` | `bash "$MAIL" send "<project>" "<subject>" "<body>"` |
+| `pigeon send --urgent <project> "<subject>" "<body>"` | `bash "$MAIL" send --urgent "<project>" "<subject>" "<body>"` |
+| `pigeon send --attach <path> <project> "<subject>" "<body>"` | `bash "$MAIL" send --attach "<path>" "<project>" "<subject>" "<body>"` |
+| `pigeon reply <id> "<body>"` | `bash "$MAIL" reply <id> "<body>"` |
+| `pigeon reply --attach <path> <id> "<body>"` | `bash "$MAIL" reply --attach "<path>" <id> "<body>"` |
+| `pigeon broadcast "<subject>" "<body>"` | `bash "$MAIL" broadcast "<subject>" "<body>"` |
+| `pigeon search <keyword>` | `bash "$MAIL" search "<keyword>"` |
+| `pigeon status` | `bash "$MAIL" status` |
+| `pigeon unread` | `bash "$MAIL" unread` |
+| `pigeon list` | `bash "$MAIL" list` |
+| `pigeon list 50` | `bash "$MAIL" list 50` |
+| `pigeon projects` | `bash "$MAIL" projects` |
+| `pigeon clear` | `bash "$MAIL" clear` |
+| `pigeon clear 7` | `bash "$MAIL" clear 7` |
+| `pigeon alias <old> <new>` | `bash "$MAIL" alias "<old>" "<new>"` |
+| `pigeon purge` | `bash "$MAIL" purge` |
+| `pigeon purge --all` | `bash "$MAIL" purge --all` |
+| `pigeon id` | `bash "$MAIL" id` |
+| `pigeon migrate` | `bash "$MAIL" migrate` |
+| `pigeon init` | `bash "$MAIL" init` |
+
+When the user just says "check mail", "read mail", "inbox", "any mail?", or "any pmail?" - run `bash "$MAIL" read`.
+
+When the user says "send mail to X", "send pmail to X", or "message X" - parse out the project name, subject, and body, then run `bash "$MAIL" send`.
+
+## Project Identity
+
+Each project gets a stable 6-character hash ID derived from its **git root commit** (the very first commit in the repo). This means:
+
+- IDs survive directory renames, moves, and clones
+- Case-insensitive filesystems (macOS) don't cause collisions
+- Every clone of the same repo shares the same identity
+
+For non-git directories, falls back to a hash of the canonical path (`pwd -P`).
+
+Use `pigeon id` to see your project's name and hash:
+
+```
+claude-mods 7663d6
+```
+
+When sending messages, you can address projects by **name**, **hash**, or **path** - they all resolve to the same hash ID.
+
+### Identicons
+
+Each project hash renders as a unique pixel-art identicon (11x11 symmetric grid using Unicode half-block characters). Run `identicon.sh` to see yours, or view all projects with `pigeon projects`.
+
+## Passive Notification (Hook)
+
+A global PreToolUse hook checks for pmail on every tool call (no cooldown). Silent when inbox is empty.
+
+```
+=== PMAIL: 3 unread message(s) ===
+  From: some-api  |  Auth endpoints ready
+  From: frontend  |  Need updated types
+  ... and 1 more
+Use pigeon read to read messages.
+```
+
+## Attachments
+
+Send file references with `--attach <path>` (repeatable). Paths are resolved to absolute and stored as references - files are not copied.
+
+```bash
+# Send with one attachment
+pigeon send --attach src/config.ts my-api "Config update" "Updated the auth config"
+
+# Send with multiple attachments
+pigeon send --attach src/schema.sql --attach docs/API.md my-api "Schema + docs" "See attached"
+
+# Reply with attachment
+pigeon reply --attach output/report.json 42 "Here's the analysis"
+```
+
+Recipients see attachment paths with file sizes and can read them directly with the Read tool. If a file has been moved or deleted since sending, it shows as `(missing)`.
+
+## When to Send
+
+- You've completed work another session depends on
+- An API contract or shared interface changed
+- A shared branch (main) is broken or fixed
+- You need input from a session working on a different project
+
+## Per-Project Disable
+
+```bash
+touch .claude/pigeon.disable    # Disable hook notifications
+rm .claude/pigeon.disable       # Re-enable
+```
+
+Only the hook is disabled - you can still send messages from the project.
+
+---
+
+## Installation
+
+Pigeon requires two things: **scripts** (the mail engine) and a **hook** (passive notifications). Both install globally - one setup, every project gets pmail.
+
+### Prerequisites
+
+- `sqlite3` - ships with macOS, most Linux distros, and Git Bash on Windows. No install needed.
+
+### Step 1: Copy Scripts
+
+```bash
+mkdir -p ~/.claude/pigeon
+cp skills/pigeon/scripts/mail-db.sh ~/.claude/pigeon/
+cp hooks/check-mail.sh ~/.claude/pigeon/
+chmod +x ~/.claude/pigeon/mail-db.sh ~/.claude/pigeon/check-mail.sh
+```
+
+This gives you the pmail commands. You can now send and read messages manually:
+
+```bash
+bash ~/.claude/pigeon/mail-db.sh init      # Create database
+bash ~/.claude/pigeon/mail-db.sh status    # Check it works
+```
+
+### Step 2: Enable the Hook
+
+Add a `hooks` block to `~/.claude/settings.json`. This makes Claude check for pmail automatically on every tool call:
+
+```json
+{
+  "hooks": {
+    "PreToolUse": [
+      {
+        "matcher": "*",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "bash \"$HOME/.claude/pigeon/check-mail.sh\"",
+            "timeout": 5
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+**Important:** If you already have a `hooks` section in your settings, merge the PreToolUse entry into the existing array - don't replace the whole block.
+
+Without this step, pigeon still works but you have to check manually (`pigeon read`). With the hook, unread pmail appears automatically.
+
+### What Gets Created
+
+```
+~/.claude/
+  settings.json            # Hook config (you edit this)
+  pmail.db                 # Message store (auto-created on first use)
+  pigeon/
+    mail-db.sh             # All pmail commands (send, read, reply, etc.)
+    check-mail.sh          # PreToolUse hook (silent when inbox empty)
+```
+
+### Verify
+
+```bash
+# Check your project identity
+bash ~/.claude/pigeon/mail-db.sh id
+
+# Send yourself a test message (use your project name from above)
+bash ~/.claude/pigeon/mail-db.sh send "my-project" "Test" "Hello from pigeon"
+
+# Check it arrived
+bash ~/.claude/pigeon/mail-db.sh read
+
+# Clean up
+bash ~/.claude/pigeon/mail-db.sh purge --all
+```
+
+### Uninstall
+
+```bash
+rm -rf ~/.claude/pigeon ~/.claude/pmail.db
+# Then remove the hooks.PreToolUse entry from ~/.claude/settings.json
+```
+
+## Database
+
+Single SQLite file at `~/.claude/pmail.db`. Auto-created on first `init` or `send`.
+
+```sql
+CREATE TABLE messages (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    from_project TEXT NOT NULL,   -- 6-char hash ID
+    to_project TEXT NOT NULL,     -- 6-char hash ID
+    subject TEXT DEFAULT '',
+    body TEXT NOT NULL,
+    timestamp TEXT DEFAULT (datetime('now')),
+    read INTEGER DEFAULT 0,
+    priority TEXT DEFAULT 'normal'
+);
+
+CREATE TABLE projects (
+    hash TEXT PRIMARY KEY,        -- 6-char ID (git root commit or path hash)
+    name TEXT NOT NULL,           -- Display name (basename of project dir)
+    path TEXT NOT NULL,           -- Canonical path
+    registered TEXT DEFAULT (datetime('now'))
+);
+```
+
+## Troubleshooting
+
+| Issue | Fix |
+|-------|-----|
+| `sqlite3: not found` | Ships with macOS, Linux, and Git Bash on Windows. Run `sqlite3 --version` to check. |
+| Hook not firing | Ensure `hooks` block is in `~/.claude/settings.json` (Step 2 above) |
+| Hook fires but no notification | Working as intended - hook is silent when inbox is empty |
+| Messages not arriving | Target must be a known name, hash, or path. Use `pigeon projects` to see registered projects |
+| Upgraded from basename IDs | Run `pigeon migrate` to convert old messages to hash-based IDs |
+| Changed display name | Use `pigeon alias old-name new-name` to update the project's display name |
+| Want to disable for one project | `touch .claude/pigeon.disable` in that project's root |
+| Check your project ID | Run `pigeon id` to see name and 6-char hash |

+ 0 - 0
skills/pigeon/assets/.gitkeep


+ 0 - 0
skills/pigeon/references/.gitkeep


+ 157 - 0
skills/pigeon/scripts/identicon.sh

@@ -0,0 +1,157 @@
+#!/usr/bin/env bash
+# Generate a symmetric pixel art identicon from a hash
+# Usage: bash identicon.sh <path_or_string> [--compact]
+#
+# 11x11 pixel grid (mirrored from 6 columns), rendered with Unicode
+# half-block characters for double vertical resolution. Each project
+# gets a unique colored portrait derived from sha256 of its canonical path.
+
+set -e
+
+INPUT="${1:-$PWD}"
+COMPACT=false
+[[ "${2:-}" == "--compact" || "${1:-}" == "--compact" ]] && COMPACT=true
+[[ "${1:-}" == "--compact" ]] && INPUT="$PWD"
+
+# Identity: git root commit hash > canonical path hash
+# This must match mail-db.sh project_hash() logic
+if [ -d "$INPUT" ]; then
+    CANONICAL=$(cd "$INPUT" && pwd -P)
+    ROOT_COMMIT=$(git -C "$INPUT" rev-list --max-parents=0 HEAD 2>/dev/null | head -1)
+    if [ -n "$ROOT_COMMIT" ]; then
+        # Use full root commit for visual entropy, short ID from first 6
+        HASH=$(printf '%s' "$ROOT_COMMIT" | shasum -a 256 | cut -c1-40)
+        SHORT="${ROOT_COMMIT:0:6}"
+    else
+        HASH=$(printf '%s' "$CANONICAL" | shasum -a 256 | cut -c1-40)
+        SHORT="${HASH:0:6}"
+    fi
+else
+    CANONICAL="$INPUT"
+    HASH=$(printf '%s' "$CANONICAL" | shasum -a 256 | cut -c1-40)
+    SHORT="${HASH:0:6}"
+fi
+NAME=$(basename "$CANONICAL")
+
+# --- Color palette ---
+# Two colors per identicon: foreground + accent, from different hash regions
+FG_IDX=$(( $(printf '%d' "0x${HASH:6:2}") % 7 ))
+BG_IDX=$(( $(printf '%d' "0x${HASH:8:2}") % 4 ))
+
+# Foreground: vivid ANSI colors
+FG_CODES=(31 32 33 34 35 36 91)
+FG="\033[${FG_CODES[$FG_IDX]}m"
+
+# Shade characters: full, dark, medium, light
+CHARS=("█" "▓" "▒" "░")
+
+RESET="\033[0m"
+DIM="\033[2m"
+
+# --- Build 11x12 pixel grid ---
+# 6 columns generated, mirrored to 11 (c0 c1 c2 c3 c4 c5 c4 c3 c2 c1 c0)
+# 12 rows, rendered as 6 lines using half-block characters
+# Each cell has 2 bits (4 shade levels): 6 cols * 12 rows = 72 cells = 144 bits
+# We have 160 bits from 40 hex chars
+
+declare -a GRID  # GRID[row*6+col] = shade level (0-3)
+
+bit_pos=0
+for row in $(seq 0 11); do
+    for col in $(seq 0 5); do
+        hex_pos=$((bit_pos / 4))
+        bit_offset=$((bit_pos % 4))
+        hex_char="${HASH:$hex_pos:1}"
+        nibble=$(printf '%d' "0x${hex_char}")
+
+        # Extract 2 bits for shade level
+        if [ $bit_offset -le 2 ]; then
+            shade=$(( (nibble >> bit_offset) & 3 ))
+        else
+            # Straddle nibble boundary
+            next_char="${HASH:$((hex_pos+1)):1}"
+            next_nibble=$(printf '%d' "0x${next_char}")
+            shade=$(( ((nibble >> bit_offset) | (next_nibble << (4 - bit_offset))) & 3 ))
+        fi
+
+        GRID[$((row * 6 + col))]=$shade
+        bit_pos=$((bit_pos + 2))
+    done
+done
+
+# --- Render with half-blocks ---
+# Each output line combines two pixel rows using ▀▄█ and space
+# Top pixel = upper half, Bottom pixel = lower half
+#
+# Both filled  = █ (full block)
+# Top only     = ▀ (upper half)
+# Bottom only  = ▄ (lower half)
+# Neither      = " " (space)
+
+get_mirrored_col() {
+    local col=$1
+    # Mirror pattern: 0 1 2 3 4 5 4 3 2 1 0
+    if [ $col -le 5 ]; then
+        echo $col
+    else
+        echo $((10 - col))
+    fi
+}
+
+render_cell() {
+    local top_shade=$1
+    local bot_shade=$2
+
+    # Threshold: shades 0-1 = filled, 2-3 = empty (gives ~50% fill)
+    local top_on=$(( top_shade <= 1 ? 1 : 0 ))
+    local bot_on=$(( bot_shade <= 1 ? 1 : 0 ))
+
+    if [ $top_on -eq 1 ] && [ $bot_on -eq 1 ]; then
+        # Both filled - use shade of top for character choice
+        printf '%s' "${CHARS[$top_shade]}"
+    elif [ $top_on -eq 1 ]; then
+        printf '▀'
+    elif [ $bot_on -eq 1 ]; then
+        printf '▄'
+    else
+        printf ' '
+    fi
+}
+
+# Width: 11 columns, each 1 char wide = 11 chars inside frame
+BORDER_TOP="${DIM}┌───────────┐${RESET}"
+BORDER_BOT="${DIM}└───────────┘${RESET}"
+
+if [ "$COMPACT" = true ]; then
+    # Compact: no frame, just the icon + hash
+    for line in $(seq 0 5); do
+        top_row=$((line * 2))
+        bot_row=$((line * 2 + 1))
+        printf '%b' "${FG}"
+        for col in $(seq 0 10); do
+            src_col=$(get_mirrored_col $col)
+            top_shade=${GRID[$((top_row * 6 + src_col))]}
+            bot_shade=${GRID[$((bot_row * 6 + src_col))]}
+            render_cell $top_shade $bot_shade
+        done
+        printf '%b\n' "${RESET}"
+    done
+    echo -e "${FG}${SHORT}${RESET}"
+else
+    # Framed display
+    echo -e "$BORDER_TOP"
+    for line in $(seq 0 5); do
+        top_row=$((line * 2))
+        bot_row=$((line * 2 + 1))
+        printf '%b' "${DIM}│${RESET}${FG}"
+        for col in $(seq 0 10); do
+            src_col=$(get_mirrored_col $col)
+            top_shade=${GRID[$((top_row * 6 + src_col))]}
+            bot_shade=${GRID[$((bot_row * 6 + src_col))]}
+            render_cell $top_shade $bot_shade
+        done
+        printf '%b\n' "${RESET}${DIM}│${RESET}"
+    done
+    echo -e "$BORDER_BOT"
+    echo -e " ${FG}${NAME}${RESET} ${DIM}${SHORT}${RESET}"
+fi

+ 725 - 0
skills/pigeon/scripts/mail-db.sh

@@ -0,0 +1,725 @@
+#!/bin/bash
+# mail-db.sh - SQLite pmail database operations
+# Global mail database at ~/.claude/pmail.db
+# Project identity: 6-char ID derived from git root commit (stable across
+# renames, moves, clones) with fallback to canonical path hash for non-git dirs.
+
+set -euo pipefail
+
+MAIL_DB="$HOME/.claude/pmail.db"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# ============================================================================
+# Identity - git-rooted project IDs
+# ============================================================================
+
+# Get canonical path (resolves symlinks + case on macOS)
+canonical_path() {
+  if [ -d "${1:-$PWD}" ]; then
+    (cd "${1:-$PWD}" && pwd -P)
+  else
+    printf '%s' "${1:-$PWD}"
+  fi
+}
+
+# Generate 6-char project ID
+# Priority: git root commit hash > canonical path hash
+project_hash() {
+  local dir="${1:-$PWD}"
+
+  # Try git root commit (first commit in repo history)
+  if [ -d "$dir" ]; then
+    local root_commit
+    root_commit=$(git -C "$dir" rev-list --max-parents=0 HEAD 2>/dev/null | head -1)
+    if [ -n "$root_commit" ]; then
+      echo "${root_commit:0:6}"
+      return 0
+    fi
+  fi
+
+  # Fallback: hash of canonical path
+  local path
+  path=$(canonical_path "$dir")
+  printf '%s' "$path" | shasum -a 256 | cut -c1-6
+}
+
+# Get display name (basename of canonical path)
+project_name() {
+  basename "$(canonical_path "${1:-$PWD}")"
+}
+
+# ============================================================================
+# Database
+# ============================================================================
+
+init_db() {
+  mkdir -p "$(dirname "$MAIL_DB")"
+  sqlite3 "$MAIL_DB" <<'SQL'
+CREATE TABLE IF NOT EXISTS messages (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    from_project TEXT NOT NULL,
+    to_project TEXT NOT NULL,
+    subject TEXT DEFAULT '',
+    body TEXT NOT NULL,
+    timestamp TEXT DEFAULT (datetime('now')),
+    read INTEGER DEFAULT 0,
+    priority TEXT DEFAULT 'normal'
+);
+CREATE INDEX IF NOT EXISTS idx_unread ON messages(to_project, read);
+CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
+
+CREATE TABLE IF NOT EXISTS projects (
+    hash TEXT PRIMARY KEY,
+    name TEXT NOT NULL,
+    path TEXT NOT NULL,
+    registered TEXT DEFAULT (datetime('now'))
+);
+SQL
+  # Migration: add priority column if missing
+  sqlite3 "$MAIL_DB" "SELECT priority FROM messages LIMIT 0;" 2>/dev/null || \
+    sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN priority TEXT DEFAULT 'normal';" 2>/dev/null
+  # Migration: create projects table if missing (for existing installs)
+  sqlite3 "$MAIL_DB" "SELECT hash FROM projects LIMIT 0;" 2>/dev/null || \
+    sqlite3 "$MAIL_DB" "CREATE TABLE IF NOT EXISTS projects (hash TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, registered TEXT DEFAULT (datetime('now')));" 2>/dev/null
+  # Migration: add thread_id column if missing
+  sqlite3 "$MAIL_DB" "SELECT thread_id FROM messages LIMIT 0;" 2>/dev/null || \
+    sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN thread_id INTEGER REFERENCES messages(id);" 2>/dev/null
+  # Migration: add attachments column if missing
+  sqlite3 "$MAIL_DB" "SELECT attachments FROM messages LIMIT 0;" 2>/dev/null || \
+    sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN attachments TEXT DEFAULT '';" 2>/dev/null
+}
+
+sql_escape() {
+  printf '%s' "$1" | sed "s/'/''/g"
+}
+
+# Resolve attachment path to absolute, validate existence
+resolve_attach() {
+  local p="$1"
+  if [ ! -e "$p" ]; then
+    echo "Error: attachment not found: $p" >&2
+    return 1
+  fi
+  (cd "$(dirname "$p")" && echo "$(pwd -P)/$(basename "$p")")
+}
+
+# Read body from argument or stdin (use - or omit for stdin)
+read_body() {
+  local arg="$1"
+  if [ "$arg" = "-" ] || [ -z "$arg" ]; then
+    cat
+  else
+    printf '%s' "$arg"
+  fi
+}
+
+# Register current project in the projects table (idempotent)
+register_project() {
+  local hash name path
+  hash=$(project_hash "${1:-$PWD}")
+  name=$(sql_escape "$(project_name "${1:-$PWD}")")
+  path=$(sql_escape "$(canonical_path "${1:-$PWD}")")
+  sqlite3 "$MAIL_DB" \
+    "INSERT OR REPLACE INTO projects (hash, name, path) VALUES ('${hash}', '${name}', '${path}');"
+}
+
+# Get project ID for current directory
+get_project_id() {
+  project_hash "${1:-$PWD}"
+}
+
+# Resolve a user-supplied name/hash to a project hash
+# Accepts: hash (6 chars), project name, or path
+resolve_target() {
+  local target="$1"
+  local safe_target
+  safe_target=$(sql_escape "$target")
+
+  # 1. Exact hash match
+  if [[ ${#target} -eq 6 ]] && [[ "$target" =~ ^[0-9a-f]+$ ]]; then
+    local found
+    found=$(sqlite3 "$MAIL_DB" "SELECT hash FROM projects WHERE hash='${safe_target}';")
+    if [ -n "$found" ]; then
+      echo "$found"
+      return 0
+    fi
+  fi
+
+  # 2. Name match (case-insensitive)
+  local by_name
+  by_name=$(sqlite3 "$MAIL_DB" "SELECT hash FROM projects WHERE LOWER(name)=LOWER('${safe_target}') ORDER BY registered DESC LIMIT 1;")
+  if [ -n "$by_name" ]; then
+    echo "$by_name"
+    return 0
+  fi
+
+  # 3. Path match - target might be a directory
+  if [ -d "$target" ]; then
+    local hash
+    hash=$(project_hash "$target")
+    echo "$hash"
+    return 0
+  fi
+
+  # 4. Generate hash from target as a string (for unknown projects)
+  # Register it so replies work
+  local hash
+  hash=$(printf '%s' "$target" | shasum -a 256 | cut -c1-6)
+  sqlite3 "$MAIL_DB" \
+    "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${hash}', '${safe_target}', '${safe_target}');"
+  echo "$hash"
+}
+
+# Look up display name for a hash
+display_name() {
+  local hash="$1"
+  local name
+  name=$(sqlite3 "$MAIL_DB" "SELECT name FROM projects WHERE hash='${hash}';")
+  if [ -n "$name" ]; then
+    echo "$name"
+  else
+    echo "$hash"
+  fi
+}
+
+# ============================================================================
+# Identicon display (inline, compact)
+# ============================================================================
+
+show_identicon() {
+  local target="${1:-$PWD}"
+  if [ -f "$SCRIPT_DIR/identicon.sh" ]; then
+    bash "$SCRIPT_DIR/identicon.sh" "$target"
+  fi
+}
+
+# ============================================================================
+# Mail operations
+# ============================================================================
+
+count_unread() {
+  init_db
+  register_project
+  local pid
+  pid=$(get_project_id)
+  sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;"
+}
+
+list_unread() {
+  init_db
+  register_project
+  local pid
+  pid=$(get_project_id)
+  local rows
+  rows=$(sqlite3 -separator '|' "$MAIL_DB" \
+    "SELECT id, from_project, subject, timestamp FROM messages WHERE to_project='${pid}' AND read=0 ORDER BY timestamp DESC;")
+  [ -z "$rows" ] && return 0
+  while IFS='|' read -r id from_hash subj ts; do
+    local from_name
+    from_name=$(display_name "$from_hash")
+    echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${ts}"
+  done <<< "$rows"
+}
+
+read_mail() {
+  init_db
+  register_project
+  local pid
+  pid=$(get_project_id)
+  # Use ASCII record separator (0x1E) to avoid splitting on pipes/newlines in body
+  local RS=$'\x1e'
+  local count
+  count=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;")
+  [ "${count:-0}" -eq 0 ] && return 0
+  # Query each message individually to preserve multi-line bodies
+  local ids
+  ids=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages WHERE to_project='${pid}' AND read=0 ORDER BY timestamp ASC;")
+  echo "id | from_project | subject | body | timestamp"
+  while read -r msg_id; do
+    [ -z "$msg_id" ] && continue
+    local from_hash subj body ts from_name attachments
+    from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${msg_id};")
+    subj=$(sqlite3 "$MAIL_DB" "SELECT subject FROM messages WHERE id=${msg_id};")
+    body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${msg_id};")
+    ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${msg_id};")
+    attachments=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${msg_id};")
+    from_name=$(display_name "$from_hash")
+    echo "${msg_id} | ${from_name} (${from_hash}) | ${subj} | ${body} | ${ts}"
+    if [ -n "$attachments" ]; then
+      while IFS= read -r apath; do
+        [ -z "$apath" ] && continue
+        local astat="missing"
+        [ -e "$apath" ] && astat="$(wc -c < "$apath" | tr -d ' ') bytes"
+        echo "  [Attached: ${apath} (${astat})]"
+      done <<< "$attachments"
+    fi
+  done <<< "$ids"
+  sqlite3 "$MAIL_DB" \
+    "UPDATE messages SET read=1 WHERE to_project='${pid}' AND read=0;"
+  # Clear signal file
+  rm -f "/tmp/pigeon_signal_${pid}"
+}
+
+read_one() {
+  local msg_id="$1"
+  if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
+    echo "Error: message ID must be numeric" >&2
+    return 1
+  fi
+  init_db
+  local exists
+  exists=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE id=${msg_id};")
+  [ "${exists:-0}" -eq 0 ] && return 0
+  local from_hash to_hash subj body ts from_name to_name attachments
+  from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${msg_id};")
+  to_hash=$(sqlite3 "$MAIL_DB" "SELECT to_project FROM messages WHERE id=${msg_id};")
+  subj=$(sqlite3 "$MAIL_DB" "SELECT subject FROM messages WHERE id=${msg_id};")
+  body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${msg_id};")
+  ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${msg_id};")
+  attachments=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${msg_id};")
+  from_name=$(display_name "$from_hash")
+  to_name=$(display_name "$to_hash")
+  echo "id | from_project | to_project | subject | body | timestamp"
+  echo "${msg_id} | ${from_name} (${from_hash}) | ${to_name} (${to_hash}) | ${subj} | ${body} | ${ts}"
+  if [ -n "$attachments" ]; then
+    while IFS= read -r apath; do
+      [ -z "$apath" ] && continue
+      local astat="missing"
+      [ -e "$apath" ] && astat="$(wc -c < "$apath" | tr -d ' ') bytes"
+      echo "  [Attached: ${apath} (${astat})]"
+    done <<< "$attachments"
+  fi
+  sqlite3 "$MAIL_DB" \
+    "UPDATE messages SET read=1 WHERE id=${msg_id};"
+}
+
+send() {
+  local priority="normal"
+  local -a attach_paths=()
+  # Parse flags before positional args
+  while [ $# -gt 0 ]; do
+    case "$1" in
+      --urgent) priority="urgent"; shift ;;
+      --attach) shift; local resolved; resolved=$(resolve_attach "$1") || return 1; attach_paths+=("$resolved"); shift ;;
+      *) break ;;
+    esac
+  done
+  local to_input="${1:?to_project required}"
+  local subject="${2:-no subject}"
+  local body
+  body=$(read_body "${3:-}")
+  if [ -z "$body" ]; then
+    echo "Error: message body cannot be empty" >&2
+    return 1
+  fi
+  init_db
+  register_project
+  local from_id to_id
+  from_id=$(get_project_id)
+  to_id=$(resolve_target "$to_input")
+  local safe_subject safe_body safe_attachments
+  safe_subject=$(sql_escape "$subject")
+  safe_body=$(sql_escape "$body")
+  # Join attachment paths with newlines
+  local attachments=""
+  if [ ${#attach_paths[@]} -gt 0 ]; then
+    attachments=$(IFS=$'\n'; echo "${attach_paths[*]}")
+  fi
+  safe_attachments=$(sql_escape "$attachments")
+  sqlite3 "$MAIL_DB" \
+    "INSERT INTO messages (from_project, to_project, subject, body, priority, attachments) VALUES ('${from_id}', '${to_id}', '${safe_subject}', '${safe_body}', '${priority}', '${safe_attachments}');"
+  # Signal the recipient
+  touch "/tmp/pigeon_signal_${to_id}"
+  local to_name
+  to_name=$(display_name "$to_id")
+  local attach_note=""
+  [ ${#attach_paths[@]} -gt 0 ] && attach_note=" [${#attach_paths[@]} attachment(s)]"
+  echo "Sent to ${to_name} (${to_id}): ${subject}${attach_note}$([ "$priority" = "urgent" ] && echo " [URGENT]" || true)"
+}
+
+sent() {
+  local limit="${1:-20}"
+  init_db
+  register_project
+  local pid
+  pid=$(get_project_id)
+  local rows
+  rows=$(sqlite3 -separator '|' "$MAIL_DB" \
+    "SELECT id, to_project, subject, timestamp FROM messages WHERE from_project='${pid}' ORDER BY timestamp DESC LIMIT ${limit};")
+  [ -z "$rows" ] && echo "No sent messages" && return 0
+  echo "id | to | subject | timestamp"
+  while IFS='|' read -r id to_hash subj ts; do
+    local to_name
+    to_name=$(display_name "$to_hash")
+    echo "${id} | ${to_name} (${to_hash}) | ${subj} | ${ts}"
+  done <<< "$rows"
+}
+
+search() {
+  local keyword="$1"
+  if [ -z "$keyword" ]; then
+    echo "Error: search keyword required" >&2
+    return 1
+  fi
+  init_db
+  register_project
+  local pid
+  pid=$(get_project_id)
+  local safe_keyword
+  safe_keyword=$(sql_escape "$keyword")
+  local rows
+  rows=$(sqlite3 -separator '|' "$MAIL_DB" \
+    "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END, timestamp FROM messages WHERE to_project='${pid}' AND (subject LIKE '%${safe_keyword}%' OR body LIKE '%${safe_keyword}%') ORDER BY timestamp DESC LIMIT 20;")
+  [ -z "$rows" ] && return 0
+  echo "id | from | subject | status | timestamp"
+  while IFS='|' read -r id from_hash subj status ts; do
+    local from_name
+    from_name=$(display_name "$from_hash")
+    echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${status} | ${ts}"
+  done <<< "$rows"
+}
+
+list_all() {
+  init_db
+  register_project
+  local pid
+  pid=$(get_project_id)
+  local limit="${1:-20}"
+  if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
+    limit=20
+  fi
+  local rows
+  rows=$(sqlite3 -separator '|' "$MAIL_DB" \
+    "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END, timestamp FROM messages WHERE to_project='${pid}' ORDER BY timestamp DESC LIMIT ${limit};")
+  [ -z "$rows" ] && return 0
+  echo "id | from | subject | status | timestamp"
+  while IFS='|' read -r id from_hash subj status ts; do
+    local from_name
+    from_name=$(display_name "$from_hash")
+    echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${status} | ${ts}"
+  done <<< "$rows"
+}
+
+clear_old() {
+  init_db
+  local days="${1:-7}"
+  if ! [[ "$days" =~ ^[0-9]+$ ]]; then
+    days=7
+  fi
+  local deleted
+  deleted=$(sqlite3 "$MAIL_DB" \
+    "DELETE FROM messages WHERE read=1 AND timestamp < datetime('now', '-${days} days'); SELECT changes();")
+  echo "Cleared ${deleted} read messages older than ${days} days"
+}
+
+reply() {
+  local -a attach_paths=()
+  # Parse flags before positional args
+  while [ $# -gt 0 ]; do
+    case "$1" in
+      --attach) shift; local resolved; resolved=$(resolve_attach "$1") || return 1; attach_paths+=("$resolved"); shift ;;
+      *) break ;;
+    esac
+  done
+  local msg_id="$1"
+  local body
+  body=$(read_body "${2:-}")
+  if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
+    echo "Error: message ID must be numeric" >&2
+    return 1
+  fi
+  if [ -z "$body" ]; then
+    echo "Error: reply body cannot be empty" >&2
+    return 1
+  fi
+  init_db
+  register_project
+  local orig
+  orig=$(sqlite3 -separator '|' "$MAIL_DB" "SELECT from_project, subject, thread_id FROM messages WHERE id=${msg_id};")
+  if [ -z "$orig" ]; then
+    echo "Error: message #${msg_id} not found" >&2
+    return 1
+  fi
+  local orig_from_hash orig_subject orig_thread
+  orig_from_hash=$(echo "$orig" | cut -d'|' -f1)
+  orig_subject=$(echo "$orig" | cut -d'|' -f2)
+  orig_thread=$(echo "$orig" | cut -d'|' -f3)
+  # Thread ID: inherit from parent, or use parent's ID as thread root
+  local thread_id="${orig_thread:-$msg_id}"
+  local from_id
+  from_id=$(get_project_id)
+  local safe_subject safe_body safe_attachments
+  safe_subject=$(sql_escape "Re: ${orig_subject}")
+  safe_body=$(sql_escape "$body")
+  local attachments=""
+  if [ ${#attach_paths[@]} -gt 0 ]; then
+    attachments=$(IFS=$'\n'; echo "${attach_paths[*]}")
+  fi
+  safe_attachments=$(sql_escape "$attachments")
+  sqlite3 "$MAIL_DB" \
+    "INSERT INTO messages (from_project, to_project, subject, body, thread_id, attachments) VALUES ('${from_id}', '${orig_from_hash}', '${safe_subject}', '${safe_body}', ${thread_id}, '${safe_attachments}');"
+  # Signal the recipient
+  touch "/tmp/pigeon_signal_${orig_from_hash}"
+  local orig_name
+  orig_name=$(display_name "$orig_from_hash")
+  local attach_note=""
+  [ ${#attach_paths[@]} -gt 0 ] && attach_note=" [${#attach_paths[@]} attachment(s)]"
+  echo "Replied to ${orig_name} (${orig_from_hash}): Re: ${orig_subject}${attach_note}"
+}
+
+thread() {
+  local msg_id="$1"
+  if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
+    echo "Error: message ID must be numeric" >&2
+    return 1
+  fi
+  init_db
+  # Find the thread root: either the message itself or its thread_id
+  local thread_root
+  thread_root=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(thread_id, id) FROM messages WHERE id=${msg_id};" 2>/dev/null)
+  [ -z "$thread_root" ] && echo "Message not found" && return 1
+  # Get all message IDs in this thread (root + replies)
+  local ids
+  ids=$(sqlite3 "$MAIL_DB" \
+    "SELECT id FROM messages WHERE id=${thread_root} OR thread_id=${thread_root} ORDER BY timestamp ASC;")
+  [ -z "$ids" ] && echo "No thread found" && return 0
+  local msg_count=0
+  echo "=== Thread #${thread_root} ==="
+  while read -r tid; do
+    [ -z "$tid" ] && continue
+    local from_hash body ts from_name attachments
+    from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${tid};")
+    body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${tid};")
+    ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${tid};")
+    attachments=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${tid};")
+    from_name=$(display_name "$from_hash")
+    echo ""
+    echo "--- #${tid} ${from_name} @ ${ts} ---"
+    echo "${body}"
+    if [ -n "$attachments" ]; then
+      while IFS= read -r apath; do
+        [ -z "$apath" ] && continue
+        local astat="missing"
+        [ -e "$apath" ] && astat="$(wc -c < "$apath" | tr -d ' ') bytes"
+        echo "  [Attached: ${apath} (${astat})]"
+      done <<< "$attachments"
+    fi
+    msg_count=$((msg_count + 1))
+  done <<< "$ids"
+  echo ""
+  echo "=== End of thread (${msg_count} messages) ==="
+}
+
+broadcast() {
+  local subject="$1"
+  local body="$2"
+  if [ -z "$body" ]; then
+    echo "Error: message body cannot be empty" >&2
+    return 1
+  fi
+  init_db
+  register_project
+  local from_id
+  from_id=$(get_project_id)
+  local targets
+  targets=$(sqlite3 "$MAIL_DB" \
+    "SELECT hash FROM projects WHERE hash != '${from_id}' ORDER BY name;")
+  local count=0
+  local safe_subject safe_body
+  safe_subject=$(sql_escape "$subject")
+  safe_body=$(sql_escape "$body")
+  while IFS= read -r target_hash; do
+    [ -z "$target_hash" ] && continue
+    sqlite3 "$MAIL_DB" \
+      "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${from_id}', '${target_hash}', '${safe_subject}', '${safe_body}');"
+    touch "/tmp/pigeon_signal_${target_hash}"
+    count=$((count + 1))
+  done <<< "$targets"
+  echo "Broadcast to ${count} project(s): ${subject}"
+}
+
+status() {
+  init_db
+  register_project
+  local pid
+  pid=$(get_project_id)
+  local unread total
+  unread=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;")
+  total=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}';")
+  echo "Inbox: ${unread} unread / ${total} total"
+  if [ "${unread:-0}" -gt 0 ]; then
+    local senders
+    senders=$(sqlite3 -separator '|' "$MAIL_DB" \
+      "SELECT from_project, COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0 GROUP BY from_project ORDER BY COUNT(*) DESC;")
+    while IFS='|' read -r from_hash cnt; do
+      local from_name
+      from_name=$(display_name "$from_hash")
+      echo "  ${from_name} (${from_hash}): ${cnt} message(s)"
+    done <<< "$senders"
+  fi
+}
+
+purge() {
+  init_db
+  if [ "${1:-}" = "--all" ]; then
+    local count
+    count=$(sqlite3 "$MAIL_DB" "DELETE FROM messages; SELECT changes();")
+    echo "Purged all ${count} message(s) from database"
+  else
+    register_project
+    local pid
+    pid=$(get_project_id)
+    local count
+    count=$(sqlite3 "$MAIL_DB" \
+      "DELETE FROM messages WHERE to_project='${pid}' OR from_project='${pid}'; SELECT changes();")
+    local name
+    name=$(project_name)
+    echo "Purged ${count} message(s) for ${name} (${pid})"
+  fi
+}
+
+alias_project() {
+  local old_name="$1"
+  local new_name="$2"
+  if [ -z "$old_name" ] || [ -z "$new_name" ]; then
+    echo "Error: both old and new project names required" >&2
+    return 1
+  fi
+  init_db
+  # Resolve old name to hash, then update the display name
+  local old_hash
+  old_hash=$(resolve_target "$old_name")
+  local safe_new
+  safe_new=$(sql_escape "$new_name")
+  local safe_old
+  safe_old=$(sql_escape "$old_name")
+  sqlite3 "$MAIL_DB" \
+    "UPDATE projects SET name='${safe_new}' WHERE hash='${old_hash}';"
+  # Also update path if it matches the old name (phantom projects)
+  sqlite3 "$MAIL_DB" \
+    "UPDATE projects SET path='${safe_new}' WHERE hash='${old_hash}' AND path='${safe_old}';"
+  echo "Renamed '${old_name}' -> '${new_name}' (hash: ${old_hash})"
+}
+
+list_projects() {
+  init_db
+  register_project
+  local rows
+  rows=$(sqlite3 -separator '|' "$MAIL_DB" \
+    "SELECT hash, name, path FROM projects ORDER BY name;")
+  [ -z "$rows" ] && echo "No known projects" && return 0
+  local my_id
+  my_id=$(get_project_id)
+  while IFS='|' read -r hash name path; do
+    local marker=""
+    [ "$hash" = "$my_id" ] && marker=" (you)"
+    echo ""
+    # Show identicon if available
+    if [ -f "$SCRIPT_DIR/identicon.sh" ]; then
+      bash "$SCRIPT_DIR/identicon.sh" "$path" --compact 2>/dev/null || true
+    fi
+    echo "${name} ${hash}${marker}"
+    echo "${path}"
+  done <<< "$rows"
+}
+
+# Migrate old basename-style messages to hash IDs
+migrate() {
+  init_db
+  register_project
+  echo "Migrating old messages to hash-based IDs..."
+  # Find all unique project names in messages that aren't 6-char hex hashes
+  local old_names
+  old_names=$(sqlite3 "$MAIL_DB" \
+    "SELECT DISTINCT from_project FROM messages WHERE LENGTH(from_project) != 6 OR from_project GLOB '*[^0-9a-f]*' UNION SELECT DISTINCT to_project FROM messages WHERE LENGTH(to_project) != 6 OR to_project GLOB '*[^0-9a-f]*';")
+  if [ -z "$old_names" ]; then
+    echo "No messages need migration."
+    return 0
+  fi
+  local count=0
+  while IFS= read -r old_name; do
+    [ -z "$old_name" ] && continue
+    # Try to find the project path - check common locations
+    local found_path=""
+    for base_dir in "$HOME/projects" "$HOME/Projects" "$HOME/code" "$HOME/Code" "$HOME/dev" "$HOME/repos"; do
+      if [ -d "${base_dir}/${old_name}" ]; then
+        found_path=$(cd "${base_dir}/${old_name}" && pwd -P)
+        break
+      fi
+    done
+
+    local new_hash
+    if [ -n "$found_path" ]; then
+      new_hash=$(printf '%s' "$found_path" | shasum -a 256 | cut -c1-6)
+      local safe_name safe_path
+      safe_name=$(sql_escape "$old_name")
+      safe_path=$(sql_escape "$found_path")
+      sqlite3 "$MAIL_DB" \
+        "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${new_hash}', '${safe_name}', '${safe_path}');"
+    else
+      # Can't find directory - hash the name itself
+      new_hash=$(printf '%s' "$old_name" | shasum -a 256 | cut -c1-6)
+      local safe_name
+      safe_name=$(sql_escape "$old_name")
+      sqlite3 "$MAIL_DB" \
+        "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${new_hash}', '${safe_name}', '${safe_name}');"
+    fi
+
+    local safe_old
+    safe_old=$(sql_escape "$old_name")
+    sqlite3 "$MAIL_DB" "UPDATE messages SET from_project='${new_hash}' WHERE from_project='${safe_old}';"
+    sqlite3 "$MAIL_DB" "UPDATE messages SET to_project='${new_hash}' WHERE to_project='${safe_old}';"
+    echo "  ${old_name} -> ${new_hash}$([ -n "$found_path" ] && echo " (${found_path})" || echo " (name only)")"
+    count=$((count + 1))
+  done <<< "$old_names"
+  echo "Migrated ${count} project name(s)."
+}
+
+# ============================================================================
+# Dispatch
+# ============================================================================
+
+case "${1:-help}" in
+  init)       init_db && echo "Mail database initialized at $MAIL_DB" ;;
+  count)      count_unread ;;
+  unread)     list_unread ;;
+  read)       if [ -n "${2:-}" ]; then read_one "$2"; else read_mail; fi ;;
+  send)       shift; send "$@" ;;
+  reply)      shift; reply "$@" ;;
+  sent)       sent "${2:-20}" ;;
+  thread)     thread "${2:?message_id required}" ;;
+  list)       list_all "${2:-20}" ;;
+  clear)      clear_old "${2:-7}" ;;
+  broadcast)  broadcast "${2:-no subject}" "${3:?body required}" ;;
+  search)     search "${2:?keyword required}" ;;
+  status)     status ;;
+  purge)      purge "${2:-}" ;;
+  alias)      alias_project "${2:?old name required}" "${3:?new name required}" ;;
+  projects)   list_projects ;;
+  migrate)    migrate ;;
+  id)         init_db; register_project; echo "$(project_name) $(get_project_id)" ;;
+  help)
+    echo "Usage: mail-db.sh <command> [args]"
+    echo ""
+    echo "Commands:"
+    echo "  init                    Initialize database"
+    echo "  id                      Show this project's name and hash"
+    echo "  count                   Count unread messages"
+    echo "  unread                  List unread messages (brief)"
+    echo "  read [id]               Read messages and mark as read"
+    echo "  send [--urgent] [--attach <path>]... <to> <subj> <body|->  Send with optional attachments"
+    echo "  reply [--attach <path>]... <id> <body|->  Reply with optional attachments"
+    echo "  sent [limit]            Show sent messages (outbox)"
+    echo "  thread <id>             View full conversation thread"
+    echo "  list [limit]            List recent messages (default 20)"
+    echo "  clear [days]            Clear read messages older than N days"
+    echo "  broadcast <subj> <body> Send to all known projects"
+    echo "  search <keyword>        Search messages by keyword"
+    echo "  status                  Inbox summary"
+    echo "  purge [--all]           Delete all messages for this project"
+    echo "  alias <old> <new>       Rename project display name"
+    echo "  projects                List known projects with identicons"
+    echo "  migrate                 Convert old basename messages to hash IDs"
+    ;;
+  *)          echo "Unknown command: $1. Run with 'help' for usage." >&2; exit 1 ;;
+esac

+ 630 - 0
skills/pigeon/scripts/test-mail.sh

@@ -0,0 +1,630 @@
+#!/bin/bash
+# test-mail.sh - Test harness for mail-ops
+# Outputs: number of passing test cases
+# Each test prints PASS/FAIL and we count PASSes at the end
+
+set -uo pipefail
+
+MAIL_DB="$HOME/.claude/pmail.db"
+MAIL_SCRIPT="$(dirname "$0")/mail-db.sh"
+HOOK_SCRIPT="$(dirname "$0")/../../hooks/check-mail.sh"
+# Resolve relative to repo root if needed
+if [ ! -f "$HOOK_SCRIPT" ]; then
+  HOOK_SCRIPT="$(cd "$(dirname "$0")/../../.." && pwd)/hooks/check-mail.sh"
+fi
+
+PASS=0
+FAIL=0
+TOTAL=0
+
+assert() {
+  local name="$1"
+  local expected="$2"
+  local actual="$3"
+  TOTAL=$((TOTAL + 1))
+  if [ "$expected" = "$actual" ]; then
+    echo "PASS: $name"
+    PASS=$((PASS + 1))
+  else
+    echo "FAIL: $name (expected='$expected', actual='$actual')"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
+assert_contains() {
+  local name="$1"
+  local needle="$2"
+  local haystack="$3"
+  TOTAL=$((TOTAL + 1))
+  if echo "$haystack" | grep -qF "$needle"; then
+    echo "PASS: $name"
+    PASS=$((PASS + 1))
+  else
+    echo "FAIL: $name (expected to contain '$needle')"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
+assert_not_empty() {
+  local name="$1"
+  local value="$2"
+  TOTAL=$((TOTAL + 1))
+  if [ -n "$value" ]; then
+    echo "PASS: $name"
+    PASS=$((PASS + 1))
+  else
+    echo "FAIL: $name (was empty)"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
+assert_empty() {
+  local name="$1"
+  local value="$2"
+  TOTAL=$((TOTAL + 1))
+  if [ -z "$value" ]; then
+    echo "PASS: $name"
+    PASS=$((PASS + 1))
+  else
+    echo "FAIL: $name (expected empty, got '$value')"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
+assert_exit_code() {
+  local name="$1"
+  local expected="$2"
+  local actual="$3"
+  TOTAL=$((TOTAL + 1))
+  if [ "$expected" = "$actual" ]; then
+    echo "PASS: $name"
+    PASS=$((PASS + 1))
+  else
+    echo "FAIL: $name (exit code expected=$expected, actual=$actual)"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
+# No-op: cooldown was removed, but tests still call this
+clear_cooldown() { :; }
+
+# --- Setup: clean slate ---
+rm -f "$MAIL_DB"
+
+echo "=== Basic Operations ==="
+
+# T1: Init creates database
+bash "$MAIL_SCRIPT" init >/dev/null 2>&1
+assert "init creates database" "true" "$([ -f "$MAIL_DB" ] && echo true || echo false)"
+
+# T2: Count on empty inbox
+result=$(bash "$MAIL_SCRIPT" count)
+assert "empty inbox count is 0" "0" "$result"
+
+# T3: Send a message
+result=$(bash "$MAIL_SCRIPT" send "test-project" "Hello" "World" 2>&1)
+assert_contains "send succeeds" "Sent to test-project" "$result"
+
+# T4: Count after send (we're in claude-mods, sent to test-project)
+result=$(bash "$MAIL_SCRIPT" count)
+assert "count still 0 for sender project" "0" "$result"
+
+# T5: Send to self
+result=$(bash "$MAIL_SCRIPT" send "claude-mods" "Self mail" "Testing self-send" 2>&1)
+assert_contains "self-send succeeds" "Sent to claude-mods" "$result"
+
+# T6: Count after self-send
+result=$(bash "$MAIL_SCRIPT" count)
+assert "count is 1 after self-send" "1" "$result"
+
+# T7: Unread shows message
+result=$(bash "$MAIL_SCRIPT" unread)
+assert_contains "unread shows subject" "Self mail" "$result"
+
+# T8: Read marks as read
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" count)
+assert "count is 0 after read" "0" "$result"
+
+# T9: List shows read messages
+result=$(bash "$MAIL_SCRIPT" list)
+assert_contains "list shows read status" "read" "$result"
+
+# T10: Projects lists known projects
+result=$(bash "$MAIL_SCRIPT" projects)
+assert_contains "projects lists claude-mods" "claude-mods" "$result"
+assert_contains "projects lists test-project" "test-project" "$result"
+
+echo ""
+echo "=== Edge Cases ==="
+
+# T11: Empty body - should fail gracefully
+result=$(bash "$MAIL_SCRIPT" send "target" "subject" "" 2>&1)
+exit_code=$?
+# Empty body should either fail or send empty - document the behavior
+TOTAL=$((TOTAL + 1))
+if [ $exit_code -ne 0 ] || echo "$result" | grep -qiE "error|required|empty"; then
+  echo "PASS: empty body rejected or warned"
+  PASS=$((PASS + 1))
+else
+  echo "FAIL: empty body accepted silently"
+  FAIL=$((FAIL + 1))
+fi
+
+# T12: Missing arguments to send
+result=$(bash "$MAIL_SCRIPT" send 2>&1)
+exit_code=$?
+assert_exit_code "send with no args fails" "1" "$exit_code"
+
+# T13: SQL injection in subject
+bash "$MAIL_SCRIPT" send "claude-mods" "'; DROP TABLE messages; --" "injection test" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" count)
+# If table still exists and count works, injection failed (good)
+TOTAL=$((TOTAL + 1))
+if [ -n "$result" ] && [ "$result" -ge 0 ] 2>/dev/null; then
+  echo "PASS: SQL injection in subject blocked"
+  PASS=$((PASS + 1))
+else
+  echo "FAIL: SQL injection may have succeeded"
+  FAIL=$((FAIL + 1))
+fi
+
+# T14: SQL injection in body
+bash "$MAIL_SCRIPT" send "claude-mods" "test" "'); DELETE FROM messages; --" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" count)
+TOTAL=$((TOTAL + 1))
+if [ -n "$result" ] && [ "$result" -ge 0 ] 2>/dev/null; then
+  echo "PASS: SQL injection in body blocked"
+  PASS=$((PASS + 1))
+else
+  echo "FAIL: SQL injection in body may have succeeded"
+  FAIL=$((FAIL + 1))
+fi
+
+# T15: SQL injection in project name
+bash "$MAIL_SCRIPT" send "'; DROP TABLE messages; --" "test" "injection via project" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" count)
+TOTAL=$((TOTAL + 1))
+if [ -n "$result" ] && [ "$result" -ge 0 ] 2>/dev/null; then
+  echo "PASS: SQL injection in project name blocked"
+  PASS=$((PASS + 1))
+else
+  echo "FAIL: SQL injection in project name may have succeeded"
+  FAIL=$((FAIL + 1))
+fi
+
+# T16: Special characters in body (newlines, quotes, backslashes)
+bash "$MAIL_SCRIPT" send "claude-mods" "special chars" 'Line1\nLine2 "quoted" and back\\slash' >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" read 2>&1)
+assert_contains "special chars preserved" "special chars" "$result"
+
+# T17: Very long message body (1000+ chars)
+long_body=$(python3 -c "print('x' * 2000)" 2>/dev/null || printf '%0.s.' $(seq 1 2000))
+bash "$MAIL_SCRIPT" send "claude-mods" "long msg" "$long_body" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" count)
+assert "long message accepted" "1" "$result"
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+# T18: Unicode in subject and body
+bash "$MAIL_SCRIPT" send "claude-mods" "Unicode test" "Hello from Tokyo" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" read 2>&1)
+assert_contains "unicode in body" "Tokyo" "$result"
+
+# T19: Read by specific ID
+bash "$MAIL_SCRIPT" send "claude-mods" "ID test" "Read me by ID" >/dev/null 2>&1
+msg_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages WHERE subject='ID test' AND read=0 LIMIT 1;")
+result=$(bash "$MAIL_SCRIPT" read "$msg_id" 2>&1)
+assert_contains "read by ID works" "Read me by ID" "$result"
+
+# T20: Read by invalid ID
+result=$(bash "$MAIL_SCRIPT" read 99999 2>&1)
+assert_empty "read invalid ID returns nothing" "$result"
+
+echo ""
+echo "=== Hook Tests ==="
+
+# T21: Hook silent on empty inbox
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1  # clear any unread
+clear_cooldown
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_empty "hook silent when no mail" "$result"
+
+# T22: Hook delivers message inline (does NOT auto-read)
+bash "$MAIL_SCRIPT" send "claude-mods" "Hook test" "Should trigger hook" >/dev/null 2>&1
+clear_cooldown
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_contains "hook shows INCOMING MAIL" "INCOMING MAIL" "$result"
+assert_contains "hook shows subject" "Hook test" "$result"
+assert_contains "hook shows body" "Should trigger hook" "$result"
+# Signal cleared after first delivery, so second call is silent
+result2=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_empty "hook silent after signal cleared" "$result2"
+# But messages are still unread (hook does NOT auto-read)
+unread_count=$(bash "$MAIL_SCRIPT" count 2>&1)
+assert_contains "messages persist unread after hook" "1" "$unread_count"
+# Manually mark read for cleanup
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+# T23: Hook with missing database
+clear_cooldown
+backup_db="${MAIL_DB}.testbak"
+mv "$MAIL_DB" "$backup_db"
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+exit_code=$?
+assert_exit_code "hook exits 0 with missing db" "0" "$exit_code"
+assert_empty "hook silent with missing db" "$result"
+mv "$backup_db" "$MAIL_DB"
+
+echo ""
+echo "=== Cleanup ==="
+
+# T24: Clear old messages
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1  # mark all as read
+result=$(bash "$MAIL_SCRIPT" clear 0 2>&1)
+assert_contains "clear reports deleted count" "Cleared" "$result"
+
+# T25: Count after clear
+result=$(bash "$MAIL_SCRIPT" count)
+assert "count 0 after clear" "0" "$result"
+
+# T26: Help command
+result=$(bash "$MAIL_SCRIPT" help 2>&1)
+assert_contains "help shows usage" "Usage" "$result"
+
+# T27: Unknown command
+result=$(bash "$MAIL_SCRIPT" nonexistent 2>&1)
+exit_code=$?
+assert_exit_code "unknown command fails" "1" "$exit_code"
+
+echo ""
+echo "=== Input Validation ==="
+
+# T28: Non-numeric message ID rejected
+result=$(bash "$MAIL_SCRIPT" read "abc" 2>&1)
+exit_code=$?
+assert_exit_code "non-numeric ID rejected" "1" "$exit_code"
+
+# T29: SQL injection via message ID
+bash "$MAIL_SCRIPT" send "claude-mods" "id-inject-test" "before injection" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" read "1 OR 1=1" 2>&1)
+exit_code=$?
+assert_exit_code "SQL injection via ID rejected" "1" "$exit_code"
+
+# T30: Non-numeric limit in list
+result=$(bash "$MAIL_SCRIPT" list "abc" 2>&1)
+exit_code=$?
+assert_exit_code "non-numeric limit handled" "0" "$exit_code"
+
+# T31: Non-numeric days in clear
+result=$(bash "$MAIL_SCRIPT" clear "abc" 2>&1)
+assert_contains "non-numeric days handled" "Cleared" "$result"
+
+# T32: Single quotes in subject preserved
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1  # clear unread
+bash "$MAIL_SCRIPT" send "claude-mods" "it's working" "body with 'quotes'" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" read 2>&1)
+assert_contains "single quotes in subject" "it's working" "$result"
+
+# T33: Double quotes in body preserved
+bash "$MAIL_SCRIPT" send "claude-mods" "quotes" 'She said "hello"' >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" read 2>&1)
+assert_contains "double quotes in body" "hello" "$result"
+
+# T34: Project name with spaces (edge case)
+bash "$MAIL_SCRIPT" send "my project" "spaces" "project name has spaces" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" projects)
+assert_contains "project with spaces stored" "my project" "$result"
+
+# T35: Multiple rapid sends
+for i in 1 2 3 4 5; do
+  bash "$MAIL_SCRIPT" send "claude-mods" "rapid-$i" "rapid fire test $i" >/dev/null 2>&1
+done
+result=$(bash "$MAIL_SCRIPT" count)
+assert "5 rapid sends all counted" "5" "$result"
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+# T36: Init is idempotent
+bash "$MAIL_SCRIPT" init >/dev/null 2>&1
+bash "$MAIL_SCRIPT" init >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" count)
+assert "init idempotent" "0" "$result"
+
+# T37: Empty subject defaults
+result=$(bash "$MAIL_SCRIPT" send "claude-mods" "" "empty subject body" 2>&1)
+assert_contains "empty subject accepted" "Sent to claude-mods" "$result"
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+echo ""
+echo "=== Reply ==="
+
+# T38: Reply to a message
+bash "$MAIL_SCRIPT" send "claude-mods" "Original msg" "Please reply" >/dev/null 2>&1
+msg_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages WHERE subject='Original msg' AND read=0 LIMIT 1;")
+bash "$MAIL_SCRIPT" read "$msg_id" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" reply "$msg_id" "Here is my reply" 2>&1)
+assert_contains "reply succeeds" "Replied to claude-mods" "$result"
+assert_contains "reply has Re: prefix" "Re: Original msg" "$result"
+
+# T39: Reply to nonexistent message
+result=$(bash "$MAIL_SCRIPT" reply 99999 "reply to nothing" 2>&1)
+exit_code=$?
+assert_exit_code "reply to nonexistent fails" "1" "$exit_code"
+
+# T40: Reply with empty body
+result=$(bash "$MAIL_SCRIPT" reply "$msg_id" "" 2>&1)
+exit_code=$?
+assert_exit_code "reply with empty body fails" "1" "$exit_code"
+
+# T41: Reply with non-numeric ID
+result=$(bash "$MAIL_SCRIPT" reply "abc" "body" 2>&1)
+exit_code=$?
+assert_exit_code "reply with non-numeric ID fails" "1" "$exit_code"
+
+# Clean up
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+echo ""
+echo "=== Priority & Search ==="
+
+# T38: Send urgent message
+result=$(bash "$MAIL_SCRIPT" send --urgent "claude-mods" "Server down" "Production is on fire" 2>&1)
+assert_contains "urgent send succeeds" "URGENT" "$result"
+
+# T39: Hook delivers urgent message with marker
+clear_cooldown
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_contains "hook shows URGENT" "URGENT" "$result"
+assert_contains "hook shows urgent body" "Production is on fire" "$result"
+
+# T40: Normal send still works after priority feature
+result=$(bash "$MAIL_SCRIPT" send "claude-mods" "Normal msg" "not urgent" 2>&1)
+TOTAL=$((TOTAL + 1))
+if echo "$result" | grep -qvF "URGENT"; then
+  echo "PASS: normal send has no URGENT tag"
+  PASS=$((PASS + 1))
+else
+  echo "FAIL: normal send incorrectly tagged URGENT"
+  FAIL=$((FAIL + 1))
+fi
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+# T41: Search by keyword in subject
+bash "$MAIL_SCRIPT" send "claude-mods" "API endpoint changed" "details here" >/dev/null 2>&1
+bash "$MAIL_SCRIPT" send "claude-mods" "unrelated" "nothing relevant" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" search "API" 2>&1)
+assert_contains "search finds by subject" "API endpoint" "$result"
+
+# T42: Search by keyword in body
+result=$(bash "$MAIL_SCRIPT" search "relevant" 2>&1)
+assert_contains "search finds by body" "unrelated" "$result"
+
+# T43: Search with no results
+result=$(bash "$MAIL_SCRIPT" search "xyznonexistent" 2>&1)
+assert_empty "search no results is empty" "$result"
+
+# T44: Search with no keyword fails
+result=$(bash "$MAIL_SCRIPT" search 2>&1)
+exit_code=$?
+assert_exit_code "search no keyword fails" "1" "$exit_code"
+
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+echo ""
+echo "=== Broadcast & Status ==="
+
+# Setup: ensure multiple projects exist
+bash "$MAIL_SCRIPT" send "project-a" "setup" "creating project-a" >/dev/null 2>&1
+bash "$MAIL_SCRIPT" send "project-b" "setup" "creating project-b" >/dev/null 2>&1
+
+# T42: Broadcast sends to all known projects except self
+result=$(bash "$MAIL_SCRIPT" broadcast "Announcement" "Main is frozen" 2>&1)
+assert_contains "broadcast reports count" "Broadcast to" "$result"
+
+# T43: Broadcast doesn't send to self
+self_count=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='claude-mods' AND subject='Announcement';")
+assert "broadcast skips self" "0" "$self_count"
+
+# T44: Broadcast with empty body fails
+result=$(bash "$MAIL_SCRIPT" broadcast "test" "" 2>&1)
+exit_code=$?
+assert_exit_code "broadcast empty body fails" "1" "$exit_code"
+
+# T45: Status shows inbox summary
+bash "$MAIL_SCRIPT" send "claude-mods" "Status test 1" "msg1" >/dev/null 2>&1
+bash "$MAIL_SCRIPT" send "claude-mods" "Status test 2" "msg2" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" status 2>&1)
+assert_contains "status shows unread count" "unread" "$result"
+assert_contains "status shows Inbox" "Inbox" "$result"
+
+# T46: Status on empty inbox
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" status 2>&1)
+assert_contains "status shows 0 unread" "0 unread" "$result"
+
+echo ""
+echo "=== Alias (Rename) ==="
+
+# Setup: send messages with old project name
+bash "$MAIL_SCRIPT" send "old-project" "before rename" "testing alias" >/dev/null 2>&1
+bash "$MAIL_SCRIPT" send "claude-mods" "from old" "message from old name" >/dev/null 2>&1
+
+# T47: Alias renames in all messages
+result=$(bash "$MAIL_SCRIPT" alias "old-project" "new-project" 2>&1)
+assert_contains "alias reports rename" "Renamed" "$result"
+assert_contains "alias shows old name" "old-project" "$result"
+assert_contains "alias shows new name" "new-project" "$result"
+
+# T48: Old project name no longer appears
+result=$(bash "$MAIL_SCRIPT" projects)
+TOTAL=$((TOTAL + 1))
+if echo "$result" | grep -qF "old-project"; then
+  echo "FAIL: old project name still present after alias"
+  FAIL=$((FAIL + 1))
+else
+  echo "PASS: old project name removed after alias"
+  PASS=$((PASS + 1))
+fi
+
+# T49: New project name appears
+assert_contains "new project name present" "new-project" "$result"
+
+# T50: Alias with missing args fails
+result=$(bash "$MAIL_SCRIPT" alias "only-one" 2>&1)
+exit_code=$?
+assert_exit_code "alias with missing arg fails" "1" "$exit_code"
+
+# Clean up
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+echo ""
+echo "=== Hook ==="
+
+# T52: Hook delivers without auto-read
+bash "$MAIL_SCRIPT" send "claude-mods" "hook test" "testing hook" >/dev/null 2>&1
+result1=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_contains "hook delivers message" "INCOMING MAIL" "$result1"
+
+# T53: Signal cleared after delivery, second call silent
+result2=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_empty "hook silent after signal cleared (2)" "$result2"
+# Messages still unread - verify then clean up
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+echo ""
+echo "=== Attachments ==="
+
+# Create temp files for attachment tests
+ATTACH_DIR=$(mktemp -d)
+echo "file one content" > "$ATTACH_DIR/file1.txt"
+echo "file two content" > "$ATTACH_DIR/file2.txt"
+mkdir -p "$ATTACH_DIR/sub dir"
+echo "spaced path" > "$ATTACH_DIR/sub dir/spaced.txt"
+
+# T: Send with single attachment
+result=$(bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/file1.txt" "claude-mods" "attach test" "one file" 2>&1)
+assert_contains "send with attachment succeeds" "1 attachment" "$result"
+
+# T: Attachment path stored as absolute
+last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
+stored=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};")
+assert_contains "attachment path is absolute" "$ATTACH_DIR/file1.txt" "$stored"
+
+# T: Read shows attachment with size
+result=$(bash "$MAIL_SCRIPT" read "$last_id" 2>&1)
+assert_contains "read shows Attached" "[Attached:" "$result"
+assert_contains "read shows file size" "bytes" "$result"
+
+# T: Send with multiple attachments
+result=$(bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/file1.txt" --attach "$ATTACH_DIR/file2.txt" "claude-mods" "multi attach" "two files" 2>&1)
+assert_contains "send with 2 attachments" "2 attachment" "$result"
+
+# T: Multiple attachment paths stored correctly
+last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
+attach_count=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};" | grep -c '.')
+assert "two attachment paths stored" "2" "$attach_count"
+
+# T: No trailing empty line in stored attachments
+trailing=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};" | tail -1)
+assert_not_empty "no trailing empty line" "$trailing"
+
+# T: Nonexistent file rejected
+result=$(bash "$MAIL_SCRIPT" send --attach "/tmp/nonexistent_$$.txt" "claude-mods" "fail" "body" 2>&1)
+exit_code=$?
+assert_contains "nonexistent attach rejected" "not found" "$result"
+assert_exit_code "nonexistent attach exits 1" "1" "$exit_code"
+
+# T: Send without attachment still works (no regression)
+result=$(bash "$MAIL_SCRIPT" send "claude-mods" "no attach" "plain message" 2>&1)
+assert_contains "send without attach works" "Sent to" "$result"
+last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
+stored=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${last_id};")
+assert "no-attach message has empty attachments" "" "$stored"
+
+# T: Reply with attachment via dispatch
+base_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
+result=$(bash "$MAIL_SCRIPT" reply --attach "$ATTACH_DIR/file1.txt" "$base_id" "reply with file" 2>&1)
+assert_contains "reply with attachment succeeds" "1 attachment" "$result"
+
+# T: Reply attachment stored correctly
+last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
+stored=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};")
+assert_contains "reply attachment path stored" "$ATTACH_DIR/file1.txt" "$stored"
+
+# T: Attachment with spaces in path
+result=$(bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/sub dir/spaced.txt" "claude-mods" "spaced path" "path has spaces" 2>&1)
+assert_contains "spaced path attachment succeeds" "1 attachment" "$result"
+last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
+stored=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};")
+assert_contains "spaced path preserved" "sub dir/spaced.txt" "$stored"
+
+# T: Hook shows attachments
+bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/file1.txt" "claude-mods" "hook attach" "check hook" >/dev/null 2>&1
+clear_cooldown
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_contains "hook shows attachment" "[Attached:" "$result"
+assert_contains "hook shows Read hint" "Use Read tool" "$result"
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+# T: Mixed flags - --urgent with --attach
+result=$(bash "$MAIL_SCRIPT" send --urgent --attach "$ATTACH_DIR/file1.txt" "claude-mods" "urgent+attach" "both flags" 2>&1)
+assert_contains "urgent+attach shows attachment" "1 attachment" "$result"
+assert_contains "urgent+attach shows URGENT" "URGENT" "$result"
+
+# T: Deleted file shows as missing
+VANISH="$ATTACH_DIR/vanish.txt"
+echo "temporary" > "$VANISH"
+bash "$MAIL_SCRIPT" send --attach "$VANISH" "claude-mods" "vanish test" "file will disappear" >/dev/null 2>&1
+rm -f "$VANISH"
+last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
+result=$(bash "$MAIL_SCRIPT" read "$last_id" 2>&1)
+assert_contains "deleted file shows missing" "missing" "$result"
+
+# Clean up temp dir
+rm -rf "$ATTACH_DIR"
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+echo ""
+echo "=== Purge ==="
+
+# T54: Purge removes messages for current project
+bash "$MAIL_SCRIPT" send "claude-mods" "purge test 1" "msg1" >/dev/null 2>&1
+bash "$MAIL_SCRIPT" send "claude-mods" "purge test 2" "msg2" >/dev/null 2>&1
+# Insert a message not involving claude-mods at all
+sqlite3 "$MAIL_DB" "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('alpha', 'beta', 'unrelated', 'should survive');"
+result=$(bash "$MAIL_SCRIPT" purge 2>&1)
+assert_contains "purge reports count" "Purged" "$result"
+
+# T55: Unrelated project messages survive purge
+other_count=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE from_project='alpha';")
+assert "unrelated messages survive purge" "1" "$other_count"
+
+# T56: Purge --all removes everything
+bash "$MAIL_SCRIPT" send "claude-mods" "test" "body" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" purge --all 2>&1)
+assert_contains "purge --all reports count" "Purged all" "$result"
+total=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages;")
+assert "purge --all empties db" "0" "$total"
+
+echo ""
+echo "=== Per-Project Disable ==="
+
+# T52: Hook respects .claude/pigeon.disable
+bash "$MAIL_SCRIPT" send "claude-mods" "disable test" "should not appear" >/dev/null 2>&1
+clear_cooldown
+mkdir -p .claude
+touch .claude/pigeon.disable
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_empty "hook silent when disabled" "$result"
+
+# T53: Hook delivers after re-enable
+rm -f .claude/pigeon.disable
+clear_cooldown
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_contains "hook works after re-enable" "INCOMING MAIL" "$result"
+
+echo ""
+echo "=== Results ==="
+echo "Passed: $PASS / $TOTAL"
+echo "Failed: $FAIL / $TOTAL"
+echo ""
+echo "$PASS"

+ 4 - 1
skills/postgres-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: postgres-ops
 description: "PostgreSQL operations, optimization, and administration. Use for: schema design, index selection, query tuning with EXPLAIN ANALYZE, postgresql.conf configuration, backup and restore (pg_dump, pg_basebackup, WAL, PITR), vacuum and autovacuum tuning, connection pooling (pgBouncer, pgPool), replication (streaming, logical), partitioning, monitoring (pg_stat_statements, pg_stat_activity), JSONB operations, full-text search (tsvector, tsquery), row-level security (RLS), extensions (PostGIS, pg_trgm, timescaledb), GiST/GIN/BRIN indexes, materialized views, foreign data wrappers, LISTEN/NOTIFY."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [sql-ops, sqlite-ops, python-database-ops]
+metadata:
+  author: claude-mods
+  related-skills: sql-ops, sqlite-ops, python-database-ops
 ---
 
 # PostgreSQL Operations

+ 3 - 0
skills/project-planner/SKILL.md

@@ -1,7 +1,10 @@
 ---
 name: project-planner
 description: "Detects stale project plans and suggests session commands. Triggers on: sync plan, update plan, check status, plan is stale, track progress, project planning."
+license: MIT
 allowed-tools: "Read Glob TaskList TaskCreate"
+metadata:
+  author: claude-mods
 ---
 
 # Project Planner Skill

+ 5 - 2
skills/python-async-ops/SKILL.md

@@ -1,10 +1,13 @@
 ---
 name: python-async-ops
 description: "Python asyncio patterns for concurrent programming. Triggers on: asyncio, async, await, coroutine, gather, semaphore, TaskGroup, event loop, aiohttp, concurrent."
+license: MIT
 compatibility: "Python 3.10+ recommended. Some patterns require 3.11+ (TaskGroup, timeout)."
 allowed-tools: "Read Write"
-depends-on: [python-typing-ops]
-related-skills: [python-fastapi-ops, python-observability-ops]
+metadata:
+  author: claude-mods
+  depends-on: python-typing-ops
+  related-skills: python-fastapi-ops, python-observability-ops
 ---
 
 # Python Async Patterns

+ 4 - 2
skills/python-cli-ops/SKILL.md

@@ -1,10 +1,12 @@
 ---
 name: python-cli-ops
 description: "CLI application patterns for Python. Triggers on: cli, command line, typer, click, argparse, terminal, rich, console, terminal ui."
+license: MIT
 compatibility: "Python 3.10+. Requires typer and rich for modern CLI development."
 allowed-tools: "Read Write Bash"
-depends-on: []
-related-skills: [python-typing-ops, python-observability-ops]
+metadata:
+  author: claude-mods
+  related-skills: python-typing-ops, python-observability-ops
 ---
 
 # Python CLI Patterns

+ 5 - 2
skills/python-database-ops/SKILL.md

@@ -1,10 +1,13 @@
 ---
 name: python-database-ops
 description: "SQLAlchemy and database patterns for Python. Triggers on: sqlalchemy, database, orm, migration, alembic, async database, connection pool, repository pattern, unit of work."
+license: MIT
 compatibility: "SQLAlchemy 2.0+, Python 3.10+. Async requires asyncpg (PostgreSQL) or aiosqlite."
 allowed-tools: "Read Write Bash"
-depends-on: [python-typing-ops, python-async-ops]
-related-skills: [python-fastapi-ops, postgres-ops]
+metadata:
+  author: claude-mods
+  depends-on: python-typing-ops, python-async-ops
+  related-skills: python-fastapi-ops, postgres-ops
 ---
 
 # Python Database Patterns

+ 3 - 2
skills/python-env/SKILL.md

@@ -1,10 +1,11 @@
 ---
 name: python-env
 description: "Fast Python environment management with uv (10-100x faster than pip). Triggers on: uv, venv, pip, pyproject, python environment, install package, dependencies."
+license: MIT
 compatibility: "Requires uv CLI tool. Install: curl -LsSf https://astral.sh/uv/install.sh | sh"
 allowed-tools: "Bash"
-depends-on: []
-related-skills: []
+metadata:
+  author: claude-mods
 ---
 
 # Python Environment

+ 5 - 2
skills/python-fastapi-ops/SKILL.md

@@ -1,10 +1,13 @@
 ---
 name: python-fastapi-ops
 description: "FastAPI web framework patterns. Triggers on: fastapi, api endpoint, dependency injection, pydantic model, openapi, swagger, starlette, async api, rest api, uvicorn."
+license: MIT
 compatibility: "FastAPI 0.100+, Pydantic v2, Python 3.10+. Requires uvicorn for production."
 allowed-tools: "Read Write Bash"
-depends-on: [python-typing-ops, python-async-ops]
-related-skills: [python-database-ops, python-observability-ops, python-pytest-ops]
+metadata:
+  author: claude-mods
+  depends-on: python-typing-ops, python-async-ops
+  related-skills: python-database-ops, python-observability-ops, python-pytest-ops
 ---
 
 # FastAPI Patterns

+ 5 - 2
skills/python-observability-ops/SKILL.md

@@ -1,10 +1,13 @@
 ---
 name: python-observability-ops
 description: "Observability patterns for Python applications. Triggers on: logging, metrics, tracing, opentelemetry, prometheus, observability, monitoring, structlog, correlation id."
+license: MIT
 compatibility: "Python 3.10+. Requires structlog, opentelemetry-api, prometheus-client."
 allowed-tools: "Read Write"
-depends-on: [python-async-ops]
-related-skills: [python-fastapi-ops, python-cli-ops]
+metadata:
+  author: claude-mods
+  depends-on: python-async-ops
+  related-skills: python-fastapi-ops, python-cli-ops
 ---
 
 # Python Observability Patterns

+ 4 - 2
skills/python-pytest-ops/SKILL.md

@@ -1,10 +1,12 @@
 ---
 name: python-pytest-ops
 description: "pytest testing patterns for Python. Triggers on: pytest, fixture, mark, parametrize, mock, conftest, test coverage, unit test, integration test, pytest.raises."
+license: MIT
 compatibility: "pytest 7.0+, Python 3.9+. Some features require pytest-asyncio, pytest-mock, pytest-cov."
 allowed-tools: "Read Write Bash"
-depends-on: []
-related-skills: [python-typing-ops, python-async-ops]
+metadata:
+  author: claude-mods
+  related-skills: python-typing-ops, python-async-ops
 ---
 
 # Python pytest Patterns

+ 4 - 2
skills/python-typing-ops/SKILL.md

@@ -1,10 +1,12 @@
 ---
 name: python-typing-ops
 description: "Python type hints and type safety patterns. Triggers on: type hints, typing, TypeVar, Generic, Protocol, mypy, pyright, type annotation, overload, TypedDict."
+license: MIT
 compatibility: "Python 3.10+ (uses union syntax X | Y). Some patterns require 3.11+ (Self, TypeVarTuple)."
 allowed-tools: "Read Write"
-depends-on: []
-related-skills: [python-pytest-ops]
+metadata:
+  author: claude-mods
+  related-skills: python-pytest-ops
 ---
 
 # Python Typing Patterns

+ 4 - 1
skills/react-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: react-ops
 description: "React development patterns, hooks, state management, Server Components, and performance optimization. Use for: react, hooks, useState, useEffect, jsx, tsx, next.js, nextjs, app router, server components, RSC, zustand, react query, component patterns, react testing library, error boundary, suspense, react 19."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [typescript-ops, testing-ops, tailwind-ops, javascript-ops]
+metadata:
+  author: claude-mods
+  related-skills: typescript-ops, testing-ops, tailwind-ops, javascript-ops
 ---
 
 # React Operations

+ 4 - 1
skills/refactor-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: refactor-ops
 description: "Safe refactoring patterns - extract, rename, restructure with test-driven methodology and dead code detection. Use for: refactor, refactoring, extract function, extract component, rename, move file, restructure, dead code, unused imports, code smell, duplicate code, long function, god object, feature envy, DRY, technical debt, cleanup, simplify, decompose, inline, pull up, push down, strangler fig, parallel change."
+license: MIT
 allowed-tools: "Read Edit Write Bash Glob Grep Agent"
-related-skills: [testing-ops, structural-search, debug-ops, code-stats, migrate-ops]
+metadata:
+  author: claude-mods
+  related-skills: testing-ops, structural-search, debug-ops, code-stats, migrate-ops
 ---
 
 # Refactor Operations

+ 3 - 0
skills/rest-ops/SKILL.md

@@ -1,7 +1,10 @@
 ---
 name: rest-ops
 description: "Quick reference for RESTful API design patterns, HTTP semantics, caching, and rate limiting. Triggers on: rest api, http methods, status codes, api design, endpoint design, api versioning, rate limiting, caching headers."
+license: MIT
 allowed-tools: "Read Write"
+metadata:
+  author: claude-mods
 ---
 
 # REST Patterns

+ 3 - 0
skills/review/SKILL.md

@@ -1,7 +1,10 @@
 ---
 name: review
 description: "Code review with semantic diffs, expert routing, and auto-TaskCreate. Triggers on: code review, review changes, check code, review PR, security audit."
+license: MIT
 allowed-tools: "Read Write Edit Bash Glob Grep Task TaskCreate TaskUpdate"
+metadata:
+  author: claude-mods
 ---
 
 # Review Skill - AI Code Review

+ 4 - 1
skills/rust-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: rust-ops
 description: "Rust development patterns, ownership, async, error handling, and ecosystem. Use for: rust, cargo, ownership, borrow checker, lifetime, tokio, serde, trait, Result, Option, async rust, crate, derive, impl, enum, pattern matching, Arc, Mutex, Send, Sync, thiserror, anyhow, clap, axum, sqlx, reqwest, rayon, tracing."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [docker-ops, ci-cd-ops, testing-ops]
+metadata:
+  author: claude-mods
+  related-skills: docker-ops, ci-cd-ops, testing-ops
 ---
 
 # Rust Operations

+ 4 - 1
skills/scaffold/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: scaffold
 description: "Project scaffolding - generate boilerplate for common project types with best-practice defaults. Use for: scaffold, boilerplate, template, new project, init, create project, starter, setup, project structure, directory structure, monorepo, microservice, API template, web app template, CLI tool template, library template."
+license: MIT
 allowed-tools: "Read Edit Write Bash Glob Grep Agent"
-related-skills: [docker-ops, ci-cd-ops, testing-ops, python-env, typescript-ops]
+metadata:
+  author: claude-mods
+  related-skills: docker-ops, ci-cd-ops, testing-ops, python-env, typescript-ops
 ---
 
 # Scaffold

+ 3 - 2
skills/screenshot/SKILL.md

@@ -1,10 +1,11 @@
 ---
 name: screenshot
 description: "Find and display recent screenshots. Triggers: screenshot, check screenshot, show screenshot, recent screenshot, last screenshot."
+license: MIT
 compatibility: "Windows, macOS, Linux"
 allowed-tools: "Bash, Glob, Read"
-depends-on: []
-related-skills: []
+metadata:
+  author: claude-mods
 ---
 
 # Screenshot Viewer

+ 4 - 1
skills/security-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: security-ops
 description: "Security audit orchestrator - parallel dependency scanning, SAST pattern detection, auth/config review. Dispatches 3 audit agents simultaneously, consolidates into OWASP-mapped severity report. Triggers on: security review, security audit, OWASP, XSS, SQL injection, CSRF, authentication, authorization, secrets management, input validation, secure coding, vulnerability scan, dependency audit."
+license: MIT
 allowed-tools: "Read Edit Write Bash Glob Grep Agent TaskCreate TaskUpdate"
-related-skills: [auth-ops, testing-ops, debug-ops, monitoring-ops]
+metadata:
+  author: claude-mods
+  related-skills: auth-ops, testing-ops, debug-ops, monitoring-ops
 ---
 
 # Security Operations

+ 4 - 3
skills/setperms/SKILL.md

@@ -1,10 +1,11 @@
 ---
 name: setperms
 description: "Set tool permissions for Claude Code. Configures allowed commands, rules, and preferences in .claude/ directory. Triggers on: setperms, init tools, configure permissions, setup project, set permissions, init claude."
-allowed-tools: "Read Write Bash"
+license: MIT
 compatibility: "Creates project-local .claude/ configuration."
-depends-on: []
-related-skills: []
+allowed-tools: "Read Write Bash"
+metadata:
+  author: claude-mods
 ---
 
 # /setperms

+ 2 - 0
skills/skill-creator/SKILL.md

@@ -2,6 +2,8 @@
 name: skill-creator
 description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.
 license: Complete terms in LICENSE.txt
+metadata:
+  author: claude-mods
 ---
 
 # Skill Creator

+ 5 - 3
skills/spawn/SKILL.md

@@ -1,10 +1,12 @@
 ---
 name: spawn
 description: "Generate PhD-level expert agent prompts for Claude Code. Creates comprehensive 500-1000 line agents with detailed patterns, code examples, and best practices. Triggers on: spawn agent, create agent, generate expert, new agent, agent genesis."
-allowed-tools: "Read Write Bash WebSearch WebFetch AskUserQuestion"
+license: MIT
 compatibility: "Requires internet access for WebSearch/WebFetch to research official docs."
-depends-on: []
-related-skills: ["claude-code-templates"]
+allowed-tools: "Read Write Bash WebSearch WebFetch AskUserQuestion"
+metadata:
+  author: claude-mods
+  related-skills: claude-code-templates
 ---
 
 # Spawn - Expert Agent Generator

+ 4 - 1
skills/sql-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: sql-ops
 description: "Quick reference for common SQL patterns, CTEs, window functions, and indexing strategies. Triggers on: sql patterns, cte example, window functions, sql join, index strategy, pagination sql."
+license: MIT
 allowed-tools: "Read Write"
-related-skills: [postgres-ops, sqlite-ops]
+metadata:
+  author: claude-mods
+  related-skills: postgres-ops, sqlite-ops
 ---
 
 # SQL Patterns

+ 3 - 0
skills/sqlite-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: sqlite-ops
 description: "Patterns for SQLite databases in Python projects - state management, caching, and async operations. Triggers on: sqlite, sqlite3, aiosqlite, local database, database schema, migration, wal mode."
+license: MIT
 compatibility: "Requires Python 3.8+ with sqlite3 (standard library) or aiosqlite for async."
 allowed-tools: "Read Write Bash"
+metadata:
+  author: claude-mods
 ---
 
 # SQLite Operations

+ 3 - 0
skills/structural-search/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: structural-search
 description: "Search code by AST structure using ast-grep. Find semantic patterns like function calls, imports, class definitions instead of text patterns. Triggers on: find all calls to X, search for pattern, refactor usages, find where function is used, structural search, ast-grep, sg."
+license: MIT
 compatibility: "Requires ast-grep (sg) CLI tool. Install: brew install ast-grep (macOS) or cargo install ast-grep (cross-platform)."
 allowed-tools: "Bash"
+metadata:
+  author: claude-mods
 ---
 
 # Structural Search

+ 4 - 1
skills/tailwind-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: tailwind-ops
 description: "Tailwind CSS utility patterns, responsive design, component patterns, v4 migration, and configuration. Use for: tailwind, tailwindcss, utility classes, responsive design, dark mode, tailwind v4, tailwind config, tw, container queries, @apply, prose, typography, animation."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [react-ops, vue-ops, astro-ops]
+metadata:
+  author: claude-mods
+  related-skills: react-ops, vue-ops, astro-ops
 ---
 
 # Tailwind Operations

+ 3 - 0
skills/task-runner/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: task-runner
 description: "Run project commands with just. Check for justfile in project root, list available tasks, execute common operations like test, build, lint. Triggers on: run tests, build project, list tasks, check available commands, run script, project commands."
+license: MIT
 compatibility: "Requires just CLI tool. Install: brew install just (macOS) or cargo install just (cross-platform)."
 allowed-tools: "Bash Glob"
+metadata:
+  author: claude-mods
 ---
 
 # Task Runner

+ 3 - 0
skills/techdebt/SKILL.md

@@ -1,6 +1,9 @@
 ---
 name: techdebt
 description: "Technical debt detection and remediation. Run at session end to find duplicated code, dead imports, security issues, and complexity hotspots. Triggers: 'find tech debt', 'scan for issues', 'check code quality', 'wrap up session', 'ready to commit', 'before merge', 'code review prep'. Always uses parallel subagents for fast analysis."
+license: MIT
+metadata:
+  author: claude-mods
 ---
 
 # Tech Debt Scanner

+ 3 - 0
skills/testgen/SKILL.md

@@ -1,7 +1,10 @@
 ---
 name: testgen
 description: "Generate tests with expert routing, framework detection, and auto-TaskCreate. Triggers on: generate tests, write tests, testgen, create test file, add test coverage."
+license: MIT
 allowed-tools: "Read Write Edit Bash Glob Grep Task TaskCreate"
+metadata:
+  author: claude-mods
 ---
 
 # TestGen Skill - AI Test Generation

+ 3 - 0
skills/testing-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: testing-ops
 description: "Cross-language testing strategies and patterns. Triggers on: test pyramid, unit test, integration test, e2e test, TDD, BDD, test coverage, mocking strategy, test doubles, test isolation."
+license: MIT
 compatibility: "Language-agnostic patterns. Framework-specific details in references."
 allowed-tools: "Read Write Bash"
+metadata:
+  author: claude-mods
 ---
 
 # Testing Patterns

+ 4 - 2
skills/tool-discovery/SKILL.md

@@ -1,9 +1,11 @@
 ---
 name: tool-discovery
 description: "Recommend the right agents and skills for any task. Covers both heavyweight agents (Task tool) and lightweight skills (Skill tool). Triggers on: which agent, which skill, what tool should I use, help me choose, recommend agent, find the right tool."
+license: MIT
 allowed-tools: "Read Glob"
-depends-on: []
-related-skills: [claude-code-templates, claude-code-debug]
+metadata:
+  author: claude-mods
+  related-skills: claude-code-templates, claude-code-debug
 ---
 
 # Tool Discovery

+ 4 - 1
skills/typescript-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: typescript-ops
 description: "TypeScript type system, generics, utility types, strict mode, and ecosystem patterns. Use for: typescript, ts, type, generic, utility type, Partial, Pick, Omit, Record, Exclude, Extract, ReturnType, Parameters, keyof, typeof, infer, mapped type, conditional type, template literal type, discriminated union, type guard, type assertion, type narrowing, tsconfig, strict mode, declaration file, zod, valibot."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [react-ops, testing-ops]
+metadata:
+  author: claude-mods
+  related-skills: react-ops, testing-ops
 ---
 
 # TypeScript Operations

+ 3 - 0
skills/unfold-admin/SKILL.md

@@ -1,6 +1,9 @@
 ---
 name: unfold-admin
 description: "Django Unfold admin theme - build, configure, and enhance modern Django admin interfaces with Unfold. Use when working with: (1) Django admin UI customisation or theming, (2) Unfold ModelAdmin, inlines, actions, filters, widgets, or decorators, (3) Admin dashboard components and KPI cards, (4) Sidebar navigation, tabs, or conditional fields, (5) Any mention of 'unfold', 'django-unfold', or 'unfold admin'. Covers the full Unfold feature set: site configuration, actions system, display decorators, filter types, widget overrides, inline variants, dashboard components, datasets, sections, theming, and third-party integrations."
+license: MIT
+metadata:
+  author: claude-mods
 ---
 
 # Django Unfold Admin

+ 4 - 1
skills/vue-ops/SKILL.md

@@ -1,8 +1,11 @@
 ---
 name: vue-ops
 description: "Vue 3 development patterns, Composition API, Pinia state management, Vue Router, and Nuxt 3. Use for: vue, vuejs, composition api, pinia, vue router, nuxt, nuxt3, script setup, composable, reactive, defineProps, defineEmits, defineModel, v-model, provide inject, vue3."
+license: MIT
 allowed-tools: "Read Write Bash"
-related-skills: [typescript-ops, testing-ops, tailwind-ops, javascript-ops]
+metadata:
+  author: claude-mods
+  related-skills: typescript-ops, testing-ops, tailwind-ops, javascript-ops
 ---
 
 # Vue Operations