Initial commit
This commit is contained in:
185
hooks/claude-md-reminder.sh
Executable file
185
hooks/claude-md-reminder.sh
Executable file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
# UserPromptSubmit hook to periodically re-inject instruction files
|
||||
# Combats context drift in long-running sessions by re-surfacing project instructions
|
||||
# Supports: CLAUDE.md, AGENTS.md, RULES.md
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
|
||||
|
||||
# Configuration
|
||||
THROTTLE_INTERVAL=3 # Re-inject every N prompts
|
||||
INSTRUCTION_FILES=("CLAUDE.md" "AGENTS.md" "RULES.md") # File types to discover
|
||||
|
||||
# Use session-specific state file (per-session, not persistent)
|
||||
# CLAUDE_SESSION_ID should be provided by Claude Code, fallback to PPID for session isolation
|
||||
SESSION_ID="${CLAUDE_SESSION_ID:-$PPID}"
|
||||
STATE_FILE="/tmp/claude-instruction-reminder-${SESSION_ID}.state"
|
||||
CACHE_FILE="/tmp/claude-instruction-reminder-${SESSION_ID}.cache"
|
||||
|
||||
# Initialize or read state
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
PROMPT_COUNT=$(cat "$STATE_FILE")
|
||||
else
|
||||
PROMPT_COUNT=0
|
||||
fi
|
||||
|
||||
# Increment prompt count
|
||||
PROMPT_COUNT=$((PROMPT_COUNT + 1))
|
||||
echo "$PROMPT_COUNT" > "$STATE_FILE"
|
||||
|
||||
# Check if we should inject (every THROTTLE_INTERVAL prompts)
|
||||
if [ $((PROMPT_COUNT % THROTTLE_INTERVAL)) -ne 0 ]; then
|
||||
# Not time to inject, return empty
|
||||
cat <<EOF
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Time to inject! Find all instruction files
|
||||
|
||||
# Array to store all instruction file paths
|
||||
declare -a instruction_files=()
|
||||
|
||||
# For each file type, discover global, project root, and subdirectories
|
||||
for file_name in "${INSTRUCTION_FILES[@]}"; do
|
||||
# 1. Global file (~/.claude/CLAUDE.md, ~/.claude/AGENTS.md, etc.)
|
||||
global_file="${HOME}/.claude/${file_name}"
|
||||
if [ -f "$global_file" ]; then
|
||||
instruction_files+=("$global_file")
|
||||
fi
|
||||
|
||||
# 2. Project root file
|
||||
if [ -f "${PROJECT_DIR}/${file_name}" ]; then
|
||||
instruction_files+=("${PROJECT_DIR}/${file_name}")
|
||||
fi
|
||||
|
||||
# 3. All subdirectory files
|
||||
# Use find to discover files in project tree (exclude hidden dirs and common ignores)
|
||||
while IFS= read -r -d '' file; do
|
||||
instruction_files+=("$file")
|
||||
done < <(find "$PROJECT_DIR" \
|
||||
-type f -not -type l \
|
||||
-name "$file_name" \
|
||||
-not -path "*/\.*" \
|
||||
-not -path "*/node_modules/*" \
|
||||
-not -path "*/vendor/*" \
|
||||
-not -path "*/.venv/*" \
|
||||
-not -path "*/dist/*" \
|
||||
-not -path "*/build/*" \
|
||||
-print0 2>/dev/null)
|
||||
done
|
||||
|
||||
# Remove duplicates (project root might be found twice)
|
||||
# Use sort -u with proper handling of paths containing spaces/newlines
|
||||
if [ "${#instruction_files[@]}" -gt 0 ]; then
|
||||
# Create a temporary file to store paths (one per line)
|
||||
tmp_file=$(mktemp)
|
||||
printf '%s\n' "${instruction_files[@]}" | sort -u > "$tmp_file"
|
||||
|
||||
# Read back into array
|
||||
instruction_files=()
|
||||
while IFS= read -r file; do
|
||||
[ -n "$file" ] && instruction_files+=("$file")
|
||||
done < "$tmp_file"
|
||||
|
||||
rm -f "$tmp_file"
|
||||
fi
|
||||
|
||||
# Build reminder context
|
||||
reminder="<instruction-files-reminder>\n"
|
||||
reminder="${reminder}Re-reading instruction files to combat context drift (prompt ${PROMPT_COUNT}):\n\n"
|
||||
|
||||
for file in "${instruction_files[@]}"; do
|
||||
# Get relative path for display
|
||||
file_name=$(basename "$file")
|
||||
|
||||
if [[ "$file" == "${HOME}/.claude/"* ]]; then
|
||||
display_path="~/.claude/${file_name} (global)"
|
||||
else
|
||||
# Create relative path (cross-platform compatible)
|
||||
display_path="${file#$PROJECT_DIR/}"
|
||||
# If the file IS the project dir (no relative path created), just show filename
|
||||
if [[ "$display_path" == "$file" ]]; then
|
||||
display_path="$file_name"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Choose emoji based on file type
|
||||
case "$file_name" in
|
||||
CLAUDE.md)
|
||||
emoji="📋"
|
||||
;;
|
||||
AGENTS.md)
|
||||
emoji="🤖"
|
||||
;;
|
||||
RULES.md)
|
||||
emoji="📜"
|
||||
;;
|
||||
*)
|
||||
emoji="📄"
|
||||
;;
|
||||
esac
|
||||
|
||||
reminder="${reminder}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
|
||||
reminder="${reminder}${emoji} ${display_path}\n"
|
||||
reminder="${reminder}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
|
||||
|
||||
# Read entire file content and escape for JSON
|
||||
# Proper JSON string escaping for all control characters (RFC 8259)
|
||||
# Uses gsub for reliable cross-platform escaping (works on BSD and GNU awk)
|
||||
escaped_content=$(awk '
|
||||
BEGIN { ORS="" }
|
||||
{
|
||||
# Order matters: backslash must be escaped first
|
||||
gsub(/\\/, "\\\\")
|
||||
gsub(/"/, "\\\"")
|
||||
gsub(/\t/, "\\t")
|
||||
gsub(/\r/, "\\r")
|
||||
gsub(/\f/, "\\f")
|
||||
# Note: \b (backspace) rarely appears in text files, skip to avoid regex issues
|
||||
# Add escaped newline between lines
|
||||
if (NR > 1) printf "\\n"
|
||||
printf "%s", $0
|
||||
}
|
||||
END { printf "\\n" }
|
||||
' "$file")
|
||||
|
||||
reminder="${reminder}${escaped_content}\n\n"
|
||||
done
|
||||
|
||||
reminder="${reminder}</instruction-files-reminder>\n"
|
||||
|
||||
# Add agent usage reminder (compact, ~200 tokens)
|
||||
agent_reminder="<agent-usage-reminder>\n"
|
||||
agent_reminder="${agent_reminder}CONTEXT CHECK: Before using Glob/Grep/Read chains, consider agents:\n\n"
|
||||
agent_reminder="${agent_reminder}| Task | Agent |\n"
|
||||
agent_reminder="${agent_reminder}|------|-------|\n"
|
||||
agent_reminder="${agent_reminder}| Explore codebase | Explore |\n"
|
||||
agent_reminder="${agent_reminder}| Multi-file search | Explore |\n"
|
||||
agent_reminder="${agent_reminder}| Complex research | general-purpose |\n"
|
||||
agent_reminder="${agent_reminder}| Code review | ring-default:code-reviewer + ring-default:business-logic-reviewer + ring-default:security-reviewer (PARALLEL) |\n"
|
||||
agent_reminder="${agent_reminder}| Implementation plan | ring-default:write-plan |\n"
|
||||
agent_reminder="${agent_reminder}| Deep architecture | ring-default:codebase-explorer |\n\n"
|
||||
agent_reminder="${agent_reminder}**3-File Rule:** If reading >3 files, use an agent instead. 15x more context-efficient.\n"
|
||||
agent_reminder="${agent_reminder}</agent-usage-reminder>\n"
|
||||
|
||||
reminder="${reminder}${agent_reminder}"
|
||||
|
||||
# Output hook response with injected context
|
||||
cat <<EOF
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": "${reminder}"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
83
hooks/detect-solution.sh
Executable file
83
hooks/detect-solution.sh
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# detect-solution.sh - Detect solution confirmation phrases and suggest codify skill
|
||||
# Called by UserPromptSubmit hook to auto-suggest documentation after fixes
|
||||
#
|
||||
# Hook Order: This hook runs AFTER claude-md-reminder.sh in UserPromptSubmit sequence
|
||||
# See hooks.json for the full hook chain configuration
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Read hook input from stdin (contains user's message)
|
||||
HOOK_INPUT=$(cat)
|
||||
|
||||
# Defense in depth: limit input size to prevent resource exhaustion
|
||||
MAX_INPUT_SIZE=100000
|
||||
if [ ${#HOOK_INPUT} -gt $MAX_INPUT_SIZE ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract user message from JSON input with proper error handling
|
||||
USER_MESSAGE=$(echo "$HOOK_INPUT" | jq -r '.userMessage // empty' 2>/dev/null)
|
||||
JQ_EXIT_CODE=$?
|
||||
if [[ $JQ_EXIT_CODE -ne 0 ]]; then
|
||||
echo '{"error": "jq parsing failed", "exitCode": '$JQ_EXIT_CODE'}' >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Exit silently if no message
|
||||
[[ -z "$USER_MESSAGE" ]] && exit 0
|
||||
|
||||
# Convert to lowercase for case-insensitive matching
|
||||
MSG_LOWER=$(echo "$USER_MESSAGE" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# Detection patterns for solution confirmation
|
||||
# These phrases typically indicate a problem was just solved
|
||||
# NOTE: Single-word patterns removed to reduce false positives (M4 fix)
|
||||
PATTERNS=(
|
||||
# Explicit confirmation phrases (high confidence)
|
||||
"that worked"
|
||||
"it's fixed"
|
||||
"its fixed"
|
||||
"fixed it"
|
||||
"working now"
|
||||
"problem solved"
|
||||
"solved it"
|
||||
"that did it"
|
||||
"all good now"
|
||||
"issue resolved"
|
||||
"finally working"
|
||||
"works now"
|
||||
"that was it"
|
||||
"got it working"
|
||||
"issue fixed"
|
||||
# Gratitude with confirmation (high confidence)
|
||||
"thank you, it works"
|
||||
"thanks, working"
|
||||
"thanks, that fixed"
|
||||
# Note: Removed single-word patterns ("perfect", "awesome", "excellent", "nice", "great")
|
||||
# and ambiguous short phrases ("looks good", "all good", "good to go")
|
||||
# to reduce false positives from general positive feedback
|
||||
)
|
||||
|
||||
# Check if any pattern matches
|
||||
MATCHED=false
|
||||
for pattern in "${PATTERNS[@]}"; do
|
||||
if [[ "$MSG_LOWER" == *"$pattern"* ]]; then
|
||||
MATCHED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# If matched, output suggestion via additionalContext
|
||||
if [[ "$MATCHED" == "true" ]]; then
|
||||
cat <<'EOF'
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": "<codify-suggestion>\nIt looks like you've solved a problem! Would you like to document this solution for future reference?\n\nRun: /ring-default:codify\nOr say: 'yes, document this' to capture the solution.\n\nThis builds a searchable knowledge base in docs/solutions/ that helps future debugging.\n</codify-suggestion>"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit 0
|
||||
308
hooks/generate-skills-ref.py
Executable file
308
hooks/generate-skills-ref.py
Executable file
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate skills quick reference from skill frontmatter.
|
||||
Scans skills/ directory and extracts metadata from SKILL.md files.
|
||||
|
||||
New schema fields:
|
||||
- name: Skill identifier
|
||||
- description: WHAT the skill does (method/technique)
|
||||
- trigger: WHEN to use (specific conditions) - primary decision field
|
||||
- skip_when: WHEN NOT to use (exclusions) - differentiation field
|
||||
- sequence.after: Skills that should come before
|
||||
- sequence.before: Skills that typically follow
|
||||
- related.similar: Skills that seem similar but differ
|
||||
- related.complementary: Skills that pair well
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
# Category patterns for grouping skills
|
||||
CATEGORIES = {
|
||||
'Pre-Dev Workflow': [r'^pre-dev-'],
|
||||
'Testing & Debugging': [r'^test-', r'-debugging$', r'^condition-', r'^defense-', r'^root-cause'],
|
||||
'Collaboration': [r'-review$', r'^dispatching-', r'^sharing-'],
|
||||
'Planning & Execution': [r'^brainstorming$', r'^writing-plans$', r'^executing-plans$', r'-worktrees$', r'^subagent-driven'],
|
||||
'Meta Skills': [r'^using-', r'^writing-skills$', r'^testing-skills', r'^testing-agents'],
|
||||
}
|
||||
|
||||
try:
|
||||
import yaml
|
||||
YAML_AVAILABLE = True
|
||||
except ImportError:
|
||||
YAML_AVAILABLE = False
|
||||
print("Warning: pyyaml not installed, using fallback parser", file=sys.stderr)
|
||||
|
||||
|
||||
class Skill:
|
||||
"""Represents a skill with its metadata."""
|
||||
|
||||
def __init__(self, name: str, description: str, directory: str,
|
||||
trigger: str = "", skip_when: str = "",
|
||||
sequence: Optional[Dict[str, List[str]]] = None,
|
||||
related: Optional[Dict[str, List[str]]] = None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.directory = directory
|
||||
self.trigger = trigger
|
||||
self.skip_when = skip_when
|
||||
self.sequence = sequence or {}
|
||||
self.related = related or {}
|
||||
self.category = self._categorize()
|
||||
|
||||
def _categorize(self) -> str:
|
||||
"""Determine skill category based on directory name."""
|
||||
for category, patterns in CATEGORIES.items():
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, self.directory):
|
||||
return category
|
||||
return 'Other'
|
||||
|
||||
def __repr__(self):
|
||||
return f"Skill(name={self.name}, category={self.category})"
|
||||
|
||||
|
||||
def first_line(text: str) -> str:
|
||||
"""Extract first meaningful line from multi-line text."""
|
||||
if not text:
|
||||
return ""
|
||||
# Remove leading/trailing whitespace, take first line
|
||||
lines = text.strip().split('\n')
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
# Skip list markers and empty lines
|
||||
if line and not line.startswith('-'):
|
||||
return line
|
||||
elif line.startswith('- '):
|
||||
return line[2:] # Return first list item without marker
|
||||
return lines[0].strip() if lines else ""
|
||||
|
||||
|
||||
def parse_frontmatter_yaml(content: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse YAML frontmatter using pyyaml library."""
|
||||
if not YAML_AVAILABLE:
|
||||
return None
|
||||
|
||||
# Extract frontmatter between --- delimiters
|
||||
match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
try:
|
||||
frontmatter = yaml.safe_load(match.group(1))
|
||||
return frontmatter if isinstance(frontmatter, dict) else None
|
||||
except yaml.YAMLError as e:
|
||||
print(f"Warning: YAML parse error: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def parse_frontmatter_fallback(content: str) -> Optional[Dict[str, Any]]:
|
||||
"""Fallback parser using regex when pyyaml unavailable.
|
||||
|
||||
Handles:
|
||||
- Simple scalar fields: name, description, trigger, skip_when, when_to_use
|
||||
- Multi-line block scalars (|) - extracts first meaningful line
|
||||
- Nested structures: sequence, related - parses sub-fields with arrays
|
||||
"""
|
||||
match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
frontmatter_text = match.group(1)
|
||||
result = {}
|
||||
|
||||
# Extract simple/block scalar fields
|
||||
# Known top-level field names (prevents false matches on "error:" etc in values)
|
||||
simple_fields = ['name', 'description', 'trigger', 'skip_when', 'when_to_use']
|
||||
all_fields = simple_fields + ['sequence', 'related']
|
||||
fields_pattern = '|'.join(all_fields)
|
||||
|
||||
for field in simple_fields:
|
||||
# Match field: value OR field: | followed by indented content
|
||||
# Capture until next known top-level field or end of frontmatter
|
||||
# Using explicit field list prevents matching "error:" inside values
|
||||
pattern = rf'^{field}:\s*\|?\s*\n?(.*?)(?=^(?:{fields_pattern}):|\Z)'
|
||||
field_match = re.search(pattern, frontmatter_text, re.MULTILINE | re.DOTALL)
|
||||
if field_match:
|
||||
raw_value = field_match.group(1).strip()
|
||||
if raw_value:
|
||||
# Extract lines, clean indentation
|
||||
lines = []
|
||||
for line in raw_value.split('\n'):
|
||||
cleaned = line.strip()
|
||||
# Remove list marker prefix for cleaner display
|
||||
if cleaned.startswith('- '):
|
||||
cleaned = cleaned[2:]
|
||||
if cleaned and not cleaned.startswith('#'):
|
||||
lines.append(cleaned)
|
||||
if lines:
|
||||
# For quick reference, use first meaningful line
|
||||
result[field] = lines[0]
|
||||
|
||||
# Handle nested structures: sequence and related
|
||||
for nested_field in ['sequence', 'related']:
|
||||
# Match the nested block (indented content under field:)
|
||||
pattern = rf'^{nested_field}:\s*\n((?:[ \t]+[^\n]*\n?)+)'
|
||||
nested_match = re.search(pattern, frontmatter_text, re.MULTILINE)
|
||||
if nested_match:
|
||||
nested_text = nested_match.group(1)
|
||||
result[nested_field] = {}
|
||||
|
||||
# Parse sub-fields: after, before, similar, complementary
|
||||
# Format: subfield: [item1, item2] or subfield: [item1]
|
||||
subfields = ['after', 'before', 'similar', 'complementary']
|
||||
for subfield in subfields:
|
||||
# Match: subfield: [contents]
|
||||
sub_pattern = rf'^\s*{subfield}:\s*\[([^\]]*)\]'
|
||||
sub_match = re.search(sub_pattern, nested_text, re.MULTILINE)
|
||||
if sub_match:
|
||||
items_str = sub_match.group(1)
|
||||
# Parse comma-separated items, strip whitespace
|
||||
items = [s.strip() for s in items_str.split(',') if s.strip()]
|
||||
if items:
|
||||
result[nested_field][subfield] = items
|
||||
|
||||
# Remove empty nested dicts
|
||||
if not result[nested_field]:
|
||||
del result[nested_field]
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def parse_skill_file(skill_path: Path) -> Optional[Skill]:
|
||||
"""Parse a SKILL.md file and extract metadata."""
|
||||
try:
|
||||
with open(skill_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Try YAML parser first, fall back to regex
|
||||
frontmatter = parse_frontmatter_yaml(content)
|
||||
if not frontmatter:
|
||||
frontmatter = parse_frontmatter_fallback(content)
|
||||
|
||||
if not frontmatter or 'name' not in frontmatter:
|
||||
print(f"Warning: Missing name in {skill_path}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Handle backward compatibility: use when_to_use as trigger if trigger not set
|
||||
trigger = frontmatter.get('trigger', '')
|
||||
if not trigger:
|
||||
trigger = frontmatter.get('when_to_use', '')
|
||||
if not trigger:
|
||||
# Fall back to description for old-style skills
|
||||
trigger = frontmatter.get('description', '')
|
||||
|
||||
# Get description - prefer dedicated description field
|
||||
description = frontmatter.get('description', '')
|
||||
|
||||
directory = skill_path.parent.name
|
||||
return Skill(
|
||||
name=frontmatter['name'],
|
||||
description=description,
|
||||
directory=directory,
|
||||
trigger=trigger,
|
||||
skip_when=frontmatter.get('skip_when', ''),
|
||||
sequence=frontmatter.get('sequence', {}),
|
||||
related=frontmatter.get('related', {})
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Error parsing {skill_path}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def scan_skills_directory(skills_dir: Path) -> List[Skill]:
|
||||
"""Scan skills directory and parse all SKILL.md files."""
|
||||
skills = []
|
||||
|
||||
if not skills_dir.exists():
|
||||
print(f"Error: Skills directory not found: {skills_dir}", file=sys.stderr)
|
||||
return skills
|
||||
|
||||
for skill_dir in sorted(skills_dir.iterdir()):
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
|
||||
skill_file = skill_dir / 'SKILL.md'
|
||||
if not skill_file.exists():
|
||||
print(f"Warning: No SKILL.md in {skill_dir.name}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
skill = parse_skill_file(skill_file)
|
||||
if skill:
|
||||
skills.append(skill)
|
||||
|
||||
return skills
|
||||
|
||||
|
||||
def generate_markdown(skills: List[Skill]) -> str:
|
||||
"""Generate markdown quick reference from skills list.
|
||||
|
||||
New format is decision-focused:
|
||||
- Shows trigger (WHEN to use) as primary decision criteria
|
||||
- Shows skip_when to differentiate from similar skills
|
||||
- Shows sequence for workflow ordering
|
||||
"""
|
||||
if not skills:
|
||||
return "# Ring Skills Quick Reference\n\n**No skills found.**\n"
|
||||
|
||||
# Group skills by category
|
||||
categorized: Dict[str, List[Skill]] = {}
|
||||
for skill in skills:
|
||||
category = skill.category
|
||||
if category not in categorized:
|
||||
categorized[category] = []
|
||||
categorized[category].append(skill)
|
||||
|
||||
# Sort categories (predefined order, then Other)
|
||||
category_order = list(CATEGORIES.keys()) + ['Other']
|
||||
sorted_categories = [cat for cat in category_order if cat in categorized]
|
||||
|
||||
# Build markdown
|
||||
lines = ['# Ring Skills Quick Reference\n']
|
||||
|
||||
for category in sorted_categories:
|
||||
category_skills = categorized[category]
|
||||
lines.append(f'## {category} ({len(category_skills)} skills)\n')
|
||||
|
||||
for skill in sorted(category_skills, key=lambda s: s.name):
|
||||
# Skill name and description
|
||||
lines.append(f'- **{skill.name}**: {first_line(skill.description)}')
|
||||
|
||||
lines.append('') # Blank line between categories
|
||||
|
||||
# Add usage section
|
||||
lines.append('## Usage\n')
|
||||
lines.append('To use a skill: Use the Skill tool with skill name')
|
||||
lines.append('Example: `ring-default:brainstorming`')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
# Determine plugin root (parent of hooks directory)
|
||||
script_dir = Path(__file__).parent.resolve()
|
||||
plugin_root = script_dir.parent
|
||||
skills_dir = plugin_root / 'skills'
|
||||
|
||||
# Scan and parse skills
|
||||
skills = scan_skills_directory(skills_dir)
|
||||
|
||||
if not skills:
|
||||
print("Error: No valid skills found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Generate and output markdown
|
||||
markdown = generate_markdown(skills)
|
||||
print(markdown)
|
||||
|
||||
# Report statistics to stderr
|
||||
print(f"Generated reference for {len(skills)} skills", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
207
hooks/generate-skills-ref.sh
Executable file
207
hooks/generate-skills-ref.sh
Executable file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fallback skill reference generator when Python is unavailable
|
||||
# Requires bash 3.2+ (uses [[ ]], ${BASH_SOURCE}, ${var:0:n})
|
||||
# Tools used: sed, awk, grep (standard on macOS/Linux/Git Bash)
|
||||
#
|
||||
# This script provides a degraded but functional skills quick reference
|
||||
# when Python or PyYAML are not available on the system.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
SKILLS_DIR="${PLUGIN_ROOT}/skills"
|
||||
|
||||
# Parse a single field from YAML frontmatter
|
||||
# Uses the proven sed pattern from ralph-wiggum/hooks/stop-hook.sh
|
||||
extract_field() {
|
||||
local frontmatter="$1"
|
||||
local field="$2"
|
||||
|
||||
# For simple fields: fieldname: value
|
||||
# For block scalars: fieldname: | followed by indented lines
|
||||
echo "$frontmatter" | awk -v field="$field" '
|
||||
BEGIN { found = 0; value = "" }
|
||||
|
||||
# Match the field we want
|
||||
$0 ~ "^" field ":" {
|
||||
found = 1
|
||||
# Check for inline value (not block scalar)
|
||||
sub("^" field ":[[:space:]]*\\|?[[:space:]]*", "")
|
||||
if (length($0) > 0 && $0 !~ /^\|[[:space:]]*$/) {
|
||||
value = $0
|
||||
exit
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
# If we found our field and this line is indented, capture it
|
||||
found && /^[[:space:]]+[^[:space:]]/ {
|
||||
gsub(/^[[:space:]]+/, "")
|
||||
gsub(/[[:space:]]+$/, "")
|
||||
# Skip list markers for cleaner output
|
||||
gsub(/^-[[:space:]]+/, "")
|
||||
if (length($0) > 0 && value == "") {
|
||||
value = $0
|
||||
exit
|
||||
}
|
||||
}
|
||||
|
||||
# If we hit another field definition, stop
|
||||
found && /^[a-z_]+:/ && $0 !~ "^" field ":" {
|
||||
exit
|
||||
}
|
||||
|
||||
END { print value }
|
||||
'
|
||||
}
|
||||
|
||||
# Parse YAML frontmatter from SKILL.md
|
||||
parse_skill() {
|
||||
local skill_file="$1"
|
||||
local skill_dir
|
||||
skill_dir=$(basename "$(dirname "$skill_file")")
|
||||
|
||||
# Skip shared-patterns directory
|
||||
if [[ "$skill_dir" == "shared-patterns" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
# Extract frontmatter between --- delimiters
|
||||
# Pattern proven portable in ralph-wiggum/hooks/stop-hook.sh
|
||||
local frontmatter
|
||||
frontmatter=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$skill_file" 2>/dev/null) || return
|
||||
|
||||
if [[ -z "$frontmatter" ]]; then
|
||||
echo "Warning: No frontmatter in $skill_file" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
# Extract fields
|
||||
local name description trigger
|
||||
name=$(extract_field "$frontmatter" "name")
|
||||
description=$(extract_field "$frontmatter" "description")
|
||||
trigger=$(extract_field "$frontmatter" "trigger")
|
||||
|
||||
# Fallback: use when_to_use if trigger not set (backward compat)
|
||||
if [[ -z "$trigger" ]]; then
|
||||
trigger=$(extract_field "$frontmatter" "when_to_use")
|
||||
fi
|
||||
|
||||
# Use directory name if name field missing
|
||||
if [[ -z "$name" ]]; then
|
||||
name="$skill_dir"
|
||||
fi
|
||||
|
||||
# Default description if missing
|
||||
if [[ -z "$description" ]]; then
|
||||
description="(no description)"
|
||||
fi
|
||||
|
||||
# Truncate long descriptions for quick reference
|
||||
if [[ ${#description} -gt 100 ]]; then
|
||||
description="${description:0:97}..."
|
||||
fi
|
||||
|
||||
# Output as TSV for reliable parsing (dir, name, description, trigger)
|
||||
printf '%s\t%s\t%s\t%s\n' "$skill_dir" "$name" "$description" "$trigger"
|
||||
}
|
||||
|
||||
# Categorize skill based on directory name
|
||||
categorize_skill() {
|
||||
local dir="$1"
|
||||
case "$dir" in
|
||||
pre-dev-*) echo "Pre-Dev Workflow" ;;
|
||||
test-*|*-debugging|condition-*|defense-*|root-cause*) echo "Testing & Debugging" ;;
|
||||
*-review|dispatching-*|sharing-*) echo "Collaboration" ;;
|
||||
brainstorming|writing-plans|executing-plans|*-worktrees|subagent-driven*) echo "Planning & Execution" ;;
|
||||
using-*|writing-skills|testing-skills*|testing-agents*) echo "Meta Skills" ;;
|
||||
*) echo "Other" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Generate markdown output
|
||||
generate_markdown() {
|
||||
echo "# Ring Skills Quick Reference"
|
||||
echo ""
|
||||
echo "> **Note:** Python unavailable. Using bash fallback parser."
|
||||
echo "> Install Python + PyYAML for full output with categories."
|
||||
echo ""
|
||||
|
||||
local skill_count=0
|
||||
local current_category=""
|
||||
|
||||
# Sort by category, then by name
|
||||
while IFS=$'\t' read -r dir name desc trigger; do
|
||||
local category
|
||||
category=$(categorize_skill "$dir")
|
||||
|
||||
# Print category header if changed
|
||||
if [[ "$category" != "$current_category" ]]; then
|
||||
if [[ -n "$current_category" ]]; then
|
||||
echo ""
|
||||
fi
|
||||
echo "## $category"
|
||||
echo ""
|
||||
current_category="$category"
|
||||
fi
|
||||
|
||||
# Combine description with trigger hint if available
|
||||
local display_desc="$desc"
|
||||
if [[ -n "$trigger" && "$trigger" != "$desc" ]]; then
|
||||
display_desc="$trigger"
|
||||
fi
|
||||
|
||||
echo "- **${name}**: ${display_desc}"
|
||||
skill_count=$((skill_count + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "## Usage"
|
||||
echo ""
|
||||
echo "To use a skill: Use the Skill tool with skill name"
|
||||
echo "Example: \`ring-default:brainstorming\`"
|
||||
|
||||
# Output stats to stderr (like Python version)
|
||||
echo "" >&2
|
||||
echo "Generated reference for ${skill_count} skills (bash fallback)" >&2
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
if [[ ! -d "$SKILLS_DIR" ]]; then
|
||||
echo "Error: Skills directory not found: $SKILLS_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Collect all skills with categories, then sort and generate markdown
|
||||
local tmpfile
|
||||
tmpfile=$(mktemp)
|
||||
chmod 600 "$tmpfile" # Restrict permissions for security
|
||||
trap "rm -f '$tmpfile'" EXIT INT TERM HUP
|
||||
|
||||
for skill_dir in "$SKILLS_DIR"/*/; do
|
||||
# Skip if not a directory
|
||||
[[ -d "$skill_dir" ]] || continue
|
||||
|
||||
local skill_file="${skill_dir}SKILL.md"
|
||||
if [[ -f "$skill_file" ]]; then
|
||||
local skill_line
|
||||
skill_line=$(parse_skill "$skill_file")
|
||||
if [[ -n "$skill_line" ]]; then
|
||||
# Add category as first field for sorting
|
||||
local dir name desc trigger cat
|
||||
IFS=$'\t' read -r dir name desc trigger <<< "$skill_line"
|
||||
cat=$(categorize_skill "$dir")
|
||||
printf '%s\t%s\t%s\t%s\t%s\n' "$cat" "$dir" "$name" "$desc" "$trigger" >> "$tmpfile"
|
||||
fi
|
||||
else
|
||||
echo "Warning: No SKILL.md in $(basename "$skill_dir")" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# Sort by category, then by name, remove category column, generate markdown
|
||||
sort -t$'\t' -k1,1 -k3,3 "$tmpfile" | cut -f2- | generate_markdown
|
||||
}
|
||||
|
||||
main "$@"
|
||||
42
hooks/hooks.json
Normal file
42
hooks/hooks.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "clear|compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/claude-md-reminder.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/detect-solution.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
171
hooks/session-start.sh
Executable file
171
hooks/session-start.sh
Executable file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
# Enhanced SessionStart hook for ring plugin
|
||||
# Provides comprehensive skill overview and status
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Determine plugin root directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
# Auto-update Ring marketplace and plugins
|
||||
marketplace_updated="false"
|
||||
if command -v claude &> /dev/null && command -v git &> /dev/null; then
|
||||
# Detect marketplace path (common locations)
|
||||
marketplace_path=""
|
||||
for path in ~/.claude/plugins/marketplaces/ring ~/.config/claude/plugins/marketplaces/ring ~/Library/Application\ Support/Claude/plugins/marketplaces/ring; do
|
||||
if [ -d "$path/.git" ]; then
|
||||
marketplace_path="$path"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$marketplace_path" ]; then
|
||||
# Get current commit hash before update
|
||||
before_hash=$(git -C "$marketplace_path" rev-parse HEAD 2>/dev/null || echo "none")
|
||||
|
||||
# Update marketplace
|
||||
claude plugin marketplace update ring &> /dev/null || true
|
||||
|
||||
# Get commit hash after update
|
||||
after_hash=$(git -C "$marketplace_path" rev-parse HEAD 2>/dev/null || echo "none")
|
||||
|
||||
# If hashes differ, marketplace was actually updated
|
||||
if [ "$before_hash" != "$after_hash" ] && [ "$after_hash" != "none" ]; then
|
||||
marketplace_updated="true"
|
||||
# Reinstall all plugins to get new versions
|
||||
claude plugin install ring-default &> /dev/null || true
|
||||
claude plugin install ring-dev-team &> /dev/null || true
|
||||
claude plugin install ring-finops-team &> /dev/null || true
|
||||
claude plugin install ring-pm-team &> /dev/null || true
|
||||
claude plugin install ralph-wiggum &> /dev/null || true
|
||||
fi
|
||||
else
|
||||
# Marketplace not found, just run updates silently
|
||||
claude plugin marketplace update ring &> /dev/null || true
|
||||
claude plugin install ring-default &> /dev/null || true
|
||||
claude plugin install ring-dev-team &> /dev/null || true
|
||||
claude plugin install ring-finops-team &> /dev/null || true
|
||||
claude plugin install ralph-wiggum &> /dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Auto-install PyYAML if Python is available but PyYAML is not
|
||||
if command -v python3 &> /dev/null; then
|
||||
if ! python3 -c "import yaml" &> /dev/null 2>&1; then
|
||||
# PyYAML not installed, try to install it
|
||||
# Try different pip commands (pip3 preferred, then pip)
|
||||
for pip_cmd in pip3 pip; do
|
||||
if command -v "$pip_cmd" &> /dev/null; then
|
||||
# Strategy: Try --user first, then --user --break-system-packages
|
||||
# (--break-system-packages only exists in pip 22.1+, needed for PEP 668)
|
||||
if "$pip_cmd" install --quiet --user 'PyYAML>=6.0,<7.0' &> /dev/null 2>&1; then
|
||||
echo "PyYAML installed successfully" >&2
|
||||
break
|
||||
elif "$pip_cmd" install --quiet --user --break-system-packages 'PyYAML>=6.0,<7.0' &> /dev/null 2>&1; then
|
||||
echo "PyYAML installed successfully (with --break-system-packages)" >&2
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# If all installation attempts fail, generate-skills-ref.py will use fallback parser
|
||||
# (No error message needed - the Python script already warns about missing PyYAML)
|
||||
fi
|
||||
fi
|
||||
|
||||
# Critical rules that MUST survive compact (injected directly, not via skill file)
|
||||
# These are the most-violated rules that need to be in immediate context
|
||||
CRITICAL_RULES='## ⛔ ORCHESTRATOR CRITICAL RULES (SURVIVE COMPACT)
|
||||
|
||||
**3-FILE RULE: HARD GATE**
|
||||
DO NOT read/edit >3 files directly. This is a PROHIBITION.
|
||||
- >3 files → STOP. Launch specialist agent. DO NOT proceed manually.
|
||||
- Already touched 3 files? → At gate. Dispatch agent NOW.
|
||||
|
||||
**AUTO-TRIGGER PHRASES → MANDATORY AGENT:**
|
||||
- "fix issues/remaining/findings" → Launch specialist agent
|
||||
- "apply fixes", "fix the X issues" → Launch specialist agent
|
||||
- "find where", "search for", "understand how" → Launch Explore agent
|
||||
|
||||
**If you think "this task is small" or "I can handle 5 files":**
|
||||
WRONG. Count > 3 = agent. No exceptions. Task size is irrelevant.
|
||||
|
||||
**Full rules:** Use Skill tool with "ring-default:using-ring" if needed.
|
||||
'
|
||||
|
||||
# Generate skills overview with cascading fallback
|
||||
# Priority: Python+PyYAML > Python regex > Bash fallback > Error message
|
||||
generate_skills_overview() {
|
||||
local python_cmd=""
|
||||
|
||||
# Try python3 first, then python
|
||||
for cmd in python3 python; do
|
||||
if command -v "$cmd" &> /dev/null; then
|
||||
python_cmd="$cmd"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$python_cmd" ]]; then
|
||||
# Python available - use Python script (handles PyYAML fallback internally)
|
||||
"$python_cmd" "${SCRIPT_DIR}/generate-skills-ref.py" 2>&1
|
||||
return $?
|
||||
fi
|
||||
|
||||
# Python not available - try bash fallback
|
||||
if [[ -x "${SCRIPT_DIR}/generate-skills-ref.sh" ]]; then
|
||||
echo "Note: Python unavailable, using bash fallback" >&2
|
||||
"${SCRIPT_DIR}/generate-skills-ref.sh" 2>&1
|
||||
return $?
|
||||
fi
|
||||
|
||||
# Ultimate fallback - minimal useful output
|
||||
echo "# Ring Skills Quick Reference"
|
||||
echo ""
|
||||
echo "**Note:** Neither Python nor bash fallback available."
|
||||
echo "Skills are still accessible via the Skill tool."
|
||||
echo ""
|
||||
echo "Run: \`Skill tool: ring-default:using-ring\` to see available workflows."
|
||||
echo ""
|
||||
echo "To fix: Install Python 3.x or ensure generate-skills-ref.sh is executable."
|
||||
}
|
||||
|
||||
skills_overview=$(generate_skills_overview || echo "Error generating skills quick reference")
|
||||
|
||||
# Check jq availability (required for JSON escaping)
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "Error: jq is required for JSON escaping but not found" >&2
|
||||
echo "Install with: brew install jq (macOS) or apt install jq (Linux)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Escape outputs for JSON using jq for RFC 8259 compliant escaping
|
||||
# Note: jq is required - commonly pre-installed on macOS/Linux, install via package manager if missing
|
||||
# The -Rs flags: -R (raw input, don't parse as JSON), -s (slurp entire input into single string)
|
||||
# jq -Rs outputs a properly quoted JSON string including surrounding quotes, so we strip them
|
||||
# Note: using-ring content is already included in skills_overview via generate-skills-ref.py
|
||||
overview_escaped=$(echo "$skills_overview" | jq -Rs . | sed 's/^"//;s/"$//' || echo "$skills_overview")
|
||||
critical_rules_escaped=$(echo "$CRITICAL_RULES" | jq -Rs . | sed 's/^"//;s/"$//' || echo "$CRITICAL_RULES")
|
||||
|
||||
# Build JSON output - include update notification if marketplace was updated
|
||||
if [ "$marketplace_updated" = "true" ]; then
|
||||
cat <<EOF
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": "<ring-marketplace-updated>\nThe Ring marketplace was just updated to a new version. New skills and agents have been installed but won't be available until the session is restarted. Inform the user they should restart their session (type 'clear' or restart Claude Code) to load the new capabilities.\n</ring-marketplace-updated>\n\n<ring-critical-rules>\n${critical_rules_escaped}\n</ring-critical-rules>\n\n<ring-skills-system>\n${overview_escaped}\n</ring-skills-system>"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
else
|
||||
cat <<EOF
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": "<ring-critical-rules>\n${critical_rules_escaped}\n</ring-critical-rules>\n\n<ring-skills-system>\n${overview_escaped}\n</ring-skills-system>"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user