refactor: remove project-local persistence layer

Removes all features that saved session data to .ring/ folders in user projects. This includes artifact indexing (SQLite FTS5), context usage tracking, learning extraction, continuity ledgers, outcome inference, and automated session tracking.

Deleted infrastructure: 7 hooks, 4 skills, 2 commands, artifact-index lib (SQLite), compound_learnings lib, outcome-inference lib, shared utilities (ledger-utils, context-check, get-context-usage), tests, and documentation.

Updated: hooks.json (removed PostToolUse/PreCompact/Stop hooks), session-start.sh (removed ledger loading), hook-utils.sh (removed .ring directory functions), test files (removed context/ledger tests).
X-Lerian-Ref: 0x1
This commit is contained in:
Fred Amaral 2026-01-15 00:07:53 -03:00
parent 387204faba
commit 5ae0fe0104
No known key found for this signature in database
GPG key ID: ADFE56C96F4AC56A
66 changed files with 10 additions and 38301 deletions

View file

@ -23,9 +23,6 @@ output_schema:
- name: "Global Prerequisites"
pattern: "^\\*\\*Global Prerequisites:\\*\\*"
required: true
- name: "Historical Precedent"
pattern: "^## Historical Precedent"
required: true
- name: "Task"
pattern: "^### Task \\d+:"
required: true
@ -86,94 +83,6 @@ You are a specialized agent that writes detailed implementation plans. Your plan
- Implementation agents apply language-specific standards (golang.md, typescript.md, etc.)
- This separation of concerns prevents planning from being language-coupled
## Historical Precedent Integration
**MANDATORY:** Before creating any plan, query the artifact index for historical context.
### Query Process
**Step 1: Extract and sanitize topic keywords from the planning request**
From the user's request, extract 3-5 keywords that describe the feature/task.
**Keyword Sanitization (MANDATORY):**
- Keep only: alphanumeric characters, hyphens, spaces
- Remove: punctuation, shell metacharacters (`;`, `$`, `` ` ``, `|`, `&`, `\`)
- Example: "Implement OAuth2 authentication with JWT tokens!" → keywords: "authentication oauth jwt tokens"
**Step 2: Query for precedent**
Run:
```bash
# Keywords MUST be sanitized (alphanumeric, hyphens, spaces only)
python3 default/lib/artifact-index/artifact_query.py --mode planning "sanitized keywords" --json
```
**Step 3: Interpret results**
| Result | Action |
|--------|--------|
| `is_empty_index: true` | Proceed without historical context (normal for new projects) |
| `successful_handoffs` not empty | Reference these patterns in plan, note what worked |
| `failed_handoffs` not empty | WARN in plan about these failure patterns, design to avoid them |
| `relevant_plans` not empty | Review for approach ideas, avoid duplicating past mistakes |
**Step 4: Add Historical Precedent section to plan**
After the plan header and before the first task, include:
```markdown
## Historical Precedent
**Query:** "[keywords used]"
**Index Status:** [Populated / Empty (new project)]
### Successful Patterns to Reference
- **[session/task-N]**: [Brief summary of what worked]
- **[session/task-M]**: [Brief summary of what worked]
### Failure Patterns to AVOID
- **[session/task-X]**: [What failed and why - DESIGN TO AVOID THIS]
- **[session/task-Y]**: [What failed and why - DESIGN TO AVOID THIS]
### Related Past Plans
- `[plan-file.md]`: [Brief relevance note]
---
```
### Empty Index Handling
If the index is empty (new project):
```markdown
## Historical Precedent
**Query:** "[keywords used]"
**Index Status:** Empty (new project)
No historical data available. This is normal for new projects.
Proceeding with standard planning approach.
---
```
### Performance Requirement
The precedent query MUST complete in <200ms. If it takes longer:
- Reduce keyword count
- Use more specific terms
- Report performance issue in plan
### Anti-Rationalization
| Rationalization | Why It's WRONG | Required Action |
|-----------------|----------------|-----------------|
| "Skip precedent query, feature is unique" | Every feature has relevant history. Query anyway. | **ALWAYS query before planning** |
| "Index is empty, don't bother" | Empty index is valid result - document it | **Always include Historical Precedent section** |
| "Query was slow, skip it" | Slow query indicates issue - report it | **Report performance, still include results** |
| "No relevant results" | Document that fact for future reference | **Include section even if empty** |
## Blocker Criteria - STOP and Report
**You MUST distinguish between decisions you can make and situations requiring escalation.**
@ -414,24 +323,10 @@ git status # Expected: clean working tree
pytest --version # Expected: 7.0+
```
## Historical Precedent
**Query:** "[keywords from request]"
**Index Status:** [Populated / Empty]
### Successful Patterns to Reference
[From artifact query results]
### Failure Patterns to AVOID
[From artifact query results - CRITICAL: Design to avoid these]
### Related Past Plans
[From artifact query results]
---
```
Adapt the prerequisites, verification commands, and historical precedent to the actual request.
Adapt the prerequisites and verification commands to the actual request.
## Task Structure Template
@ -574,8 +469,6 @@ Add this step after every 3-5 tasks (or after significant features):
Before saving the plan, verify:
- [ ] **Historical precedent queried** (artifact-query --mode planning)
- [ ] Historical Precedent section included in plan
- [ ] Header with goal, architecture, tech stack, prerequisites
- [ ] Verification commands with expected output
- [ ] Tasks broken into bite-sized steps (2-5 min each)
@ -586,7 +479,6 @@ Before saving the plan, verify:
- [ ] Code review checkpoints after batches
- [ ] Severity-based issue handling documented
- [ ] Passes Zero-Context Test
- [ ] **Plan avoids known failure patterns** (if any found in precedent)
## After Saving the Plan

View file

@ -1,132 +0,0 @@
---
name: ring:compound-learnings
description: Analyze session learnings and propose new rules/skills
argument-hint: "[--approve <id>] [--reject <id>] [--list]"
---
Analyze accumulated session learnings to detect recurring patterns and propose new rules, skills, or hooks. Requires user approval before creating any permanent artifacts.
## Usage
```
/compound-learnings [options]
```
## Options
| Option | Description |
|--------|-------------|
| (none) | Analyze learnings and generate new proposals |
| `--list` | List pending proposals without analyzing |
| `--approve <id>` | Approve a specific proposal (e.g., `--approve proposal-1`) |
| `--reject <id>` | Reject a specific proposal with optional reason |
| `--history` | Show history of approved/rejected proposals |
## Examples
### Analyze Learnings (Default)
```
/compound-learnings
```
Analyzes all session learnings, detects patterns appearing 3+ times, and presents proposals.
### List Pending Proposals
```
/compound-learnings --list
```
Shows proposals waiting for approval without re-analyzing.
### Approve a Proposal
```
/compound-learnings --approve proposal-1
```
Creates the rule/skill from the approved proposal.
### Reject a Proposal
```
/compound-learnings --reject proposal-2 "Too project-specific"
```
Marks proposal as rejected with reason.
### View History
```
/compound-learnings --history
```
Shows all past approvals and rejections.
## Process
The command follows this workflow:
### 1. Gather (Automatic)
- Reads `.ring/cache/learnings/*.md` files
- Counts total sessions available
### 2. Analyze (Automatic)
- Extracts patterns from What Worked, What Failed, Key Decisions
- Consolidates similar patterns using fuzzy matching
- Detects patterns appearing in 3+ sessions
### 3. Categorize (Automatic)
- Classifies patterns as rule, skill, or hook candidates
- Generates preview content for each
### 4. Propose (Interactive)
- Presents each qualifying pattern
- Shows draft content
- Waits for your decision
### 5. Create (On Approval)
- Creates rule/skill/hook in appropriate location
- Updates proposal history
- Archives processed learnings
## Output Example
```markdown
## Compound Learnings Analysis
**Sessions Analyzed:** 12
**Patterns Detected:** 8
**Qualifying (3+ sessions):** 3
---
### Proposal 1: Always use explicit file paths
**Signal:** 5 sessions (abc, def, ghi, jkl, mno)
**Category:** Rule
**Sources:** patterns, what_worked
**Preview:**
# Always use explicit file paths
## Pattern
Never use relative paths like "./file" - always use absolute paths.
...
**Action Required:** Type `approve`, `reject`, or `modify`
---
```
## Related Commands/Skills
| Command/Skill | Relationship |
|---------------|--------------|
| `compound-learnings` skill | Underlying skill with full process details |
| `ring:handoff-tracking` | Provides source data (what_worked, what_failed) |
| `artifact-query` | Search past handoffs for context |
## Troubleshooting
### "No learnings found"
No `.ring/cache/learnings/*.md` files exist. Complete some sessions with outcome tracking enabled.
### "Insufficient data for patterns"
Fewer than 3 learning files. Continue working - patterns emerge after multiple sessions.
### "Pattern already exists"
Check `.ring/generated/rules/` and `.ring/generated/skills/` in your project, or `default/rules/` and `default/skills/` in the plugin - a similar rule or skill may already exist.
### "Proposal not found"
The proposal ID doesn't exist in pending.json. Run `/compound-learnings --list` to see current proposals.

View file

@ -1,161 +0,0 @@
---
name: ring:query-artifacts
description: Search the Artifact Index for relevant historical context
argument-hint: "<search-terms> [--type TYPE] [--outcome OUTCOME]"
---
Search the Artifact Index for relevant handoffs, plans, and continuity ledgers using FTS5 full-text search with BM25 ranking.
## Usage
```
/query-artifacts <search-terms> [options]
```
## Arguments
| Argument | Required | Description |
|----------|----------|-------------|
| `search-terms` | Yes | Keywords to search for (e.g., "authentication OAuth", "error handling") |
| `--mode` | No | Query mode: `search` (default) or `planning` (structured precedent) |
| `--type` | No | Filter by type: `handoffs`, `plans`, `continuity`, `all` (default: all) |
| `--outcome` | No | Filter handoffs: `SUCCEEDED`, `PARTIAL_PLUS`, `PARTIAL_MINUS`, `FAILED` |
| `--limit` | No | Maximum results per category (1-100, default: 5) |
## Examples
### Search for Authentication Work
```
/query-artifacts authentication OAuth JWT
```
Returns handoffs, plans, and continuity ledgers related to authentication.
### Find Successful Implementations
```
/query-artifacts API design --outcome SUCCEEDED
```
Returns only handoffs that were marked as successful.
### Search Plans Only
```
/query-artifacts context management --type plans
```
Returns only matching plan documents.
### Limit Results
```
/query-artifacts testing --limit 3
```
Returns at most 3 results per category.
### Planning Mode (Structured Precedent)
```
/query-artifacts api rate limiting --mode planning
```
Returns structured precedent for creating implementation plans:
- **Successful Implementations** - What worked (reference these)
- **Failed Implementations** - What failed (AVOID these patterns)
- **Relevant Past Plans** - Similar approaches
This mode is used automatically by `/ring:write-plan` to inform new plans with historical context.
## Output
Results are displayed in markdown format:
```markdown
## Relevant Handoffs
### [OK] session-name/task-01
**Summary:** Implemented OAuth2 authentication...
**What worked:** Token refresh mechanism...
**What failed:** Initial PKCE implementation...
**File:** `/path/to/handoff.md`
## Relevant Plans
### OAuth2 Integration Plan
**Overview:** Implement OAuth2 with PKCE flow...
**File:** `/path/to/plan.md`
## Related Sessions
### Session: auth-implementation
**Goal:** Add authentication to the API...
**Key learnings:** Use refresh tokens...
**File:** `/path/to/ledger.md`
```
## Index Management
### Check Index Statistics
```
/query-artifacts --stats
```
Shows counts of indexed artifacts.
### Initialize/Rebuild Index
If the index is empty or out of date:
```bash
python3 default/lib/artifact-index/artifact_index.py --all
```
## Related Commands/Skills
| Command/Skill | Relationship |
|---------------|--------------|
| `/ring:write-plan` | Query before planning to inform decisions |
| `/ring:create-handoff` | Creates handoffs that get indexed |
| `artifact-query` | The underlying skill |
| `ring:writing-plans` | Uses query results for RAG-enhanced planning |
## Troubleshooting
### "Database not found"
The artifact index hasn't been initialized. Run:
```bash
python3 default/lib/artifact-index/artifact_index.py --all
```
### "No results found"
1. Check that artifacts exist: `ls docs/handoffs/ docs/plans/`
2. Re-index: `python3 default/lib/artifact-index/artifact_index.py --all`
3. Try broader search terms
### Slow queries
Queries should complete in < 100ms. If slow:
1. Check database size: `ls -la .ring/cache/artifact-index/`
2. Rebuild indexes: `python3 default/lib/artifact-index/artifact_index.py --all`
---
## MANDATORY: Load Full Skill
**This command MUST load the skill for complete workflow execution.**
```
Use Skill tool: ring:artifact-query
```
The skill contains the complete workflow with:
- Query formulation guidance
- Mode selection (search, planning)
- Result interpretation
- Learnings application
- Index initialization

View file

@ -1,163 +0,0 @@
# Context Usage Warnings
Ring monitors your estimated context usage and provides proactive warnings before you hit Claude's context limits.
## How It Works
A UserPromptSubmit hook estimates context usage based on:
- Turn count in current session
- Conservative token-per-turn estimates
Warnings are injected into the conversation at these thresholds:
| Threshold | Level | What Happens |
|-----------|-------|--------------|
| 50% | Info | Gentle reminder to consider summarizing |
| 70% | Warning | Recommend creating continuity ledger |
| 85% | Critical | Urgent - must clear context soon |
## Warning Behavior
- **50% and 70% warnings** are shown once per session
- **85% critical warnings** are always shown (safety feature)
- Warnings reset when you run `/clear` or start a new session
## Resetting Warnings
If you've dismissed a warning but want to see it again:
```bash
# Method 1: Clear context (recommended)
# Run /clear in Claude Code
# Method 2: Force reset via environment
export RING_RESET_CONTEXT_WARNING=1
# Then submit your next prompt
```
## Estimation Formula
The estimation uses conservative defaults:
| Constant | Value | Description |
|----------|-------|-------------|
| TOKENS_PER_TURN | 2,500 | Average tokens per conversation turn |
| BASE_SYSTEM_OVERHEAD | 45,000 | System prompt, tools, etc. |
| DEFAULT_CONTEXT_SIZE | 200,000 | Claude's typical context window |
**Formula:** `percentage = (45000 + turns * 2500) * 100 / 200000`
| Turn Count | Estimated % | Tier |
|------------|-------------|------|
| 1 | 23% | none |
| 22 | 50% | info |
| 38 | 70% | warning |
| 50 | 85% | critical |
## Customization
### Adjusting Thresholds
Edit `shared/lib/context-check.sh` and modify the `get_warning_tier` function:
```bash
get_warning_tier() {
local pct="${1:-0}"
if [[ "$pct" -ge 85 ]]; then # Adjust critical threshold
echo "critical"
elif [[ "$pct" -ge 70 ]]; then # Adjust warning threshold
echo "warning"
elif [[ "$pct" -ge 50 ]]; then # Adjust info threshold
echo "info"
else
echo "none"
fi
}
```
### Adjusting Estimation
Modify the constants in `shared/lib/context-check.sh`:
```bash
readonly TOKENS_PER_TURN=2500 # Average tokens per turn
readonly BASE_SYSTEM_OVERHEAD=45000 # System prompt overhead
readonly DEFAULT_CONTEXT_SIZE=200000 # Total context window
```
### Disabling Warnings
To disable context warnings entirely, remove the hook from `default/hooks/hooks.json`:
```json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/claude-md-reminder.sh"
}
// Remove context-usage-check.sh entry
]
}
]
}
}
```
## State Files
Context state is stored in:
- `.ring/state/context-usage-{session_id}.json` (if `.ring/` exists)
- `/tmp/context-usage-{session_id}.json` (fallback)
State files are automatically cleaned up on session start.
## Performance
The hook is designed to execute in <50ms to avoid slowing down prompts. Performance is achieved by:
- Simple turn-count based estimation (no API calls)
- Atomic file operations
- Minimal JSON processing
## Troubleshooting
### Warnings not appearing
1. Check hook is registered:
```bash
cat default/hooks/hooks.json | grep context-usage
```
2. Check hook is executable:
```bash
ls -la default/hooks/context-usage-check.sh
```
3. Run hook manually:
```bash
echo '{"prompt": "test"}' | default/hooks/context-usage-check.sh
```
### Warnings appearing too early/late
Adjust the `TOKENS_PER_TURN` constant in `shared/lib/context-check.sh`. Higher values = warnings appear earlier.
### State file issues
Clear state manually:
```bash
rm -f .ring/state/context-usage-*.json
rm -f /tmp/context-usage-*.json
```
## Security
The hook includes security measures:
- **Session ID validation**: Only alphanumeric, hyphens, and underscores allowed
- **JSON escaping**: Prevents injection attacks in state files
- **Atomic writes**: Uses temp file + mv pattern to prevent corruption
- **Symlink resolution**: PROJECT_DIR resolved to canonical path

View file

@ -1,240 +0,0 @@
# RAG-Enhanced Plan Judging
Ring's plan creation workflow automatically queries historical context before generating new plans. This enables plans that learn from past successes and avoid known failure patterns.
## How It Works
```
1. User requests: "Create plan for authentication"
2. Query artifact index for "authentication" precedent
3. Results inform plan:
- Successful patterns → Reference these
- Failed patterns → Design to AVOID
- Past plans → Review for approach ideas
4. Write plan with Historical Precedent section
5. Validate plan against failure patterns
6. Execute plan with confidence
```
## Components
### 1. Planning Query Mode
Query the artifact index for structured planning context:
```bash
python3 default/lib/artifact-index/artifact_query.py --mode planning "keywords" --json
```
**Returns:**
- `successful_handoffs` - Implementations that worked
- `failed_handoffs` - Implementations that failed (AVOID these)
- `relevant_plans` - Similar past plans
- `is_empty_index` - True if no historical data (normal for new projects)
- `query_time_ms` - Performance metric (target <200ms)
### 2. Historical Precedent Section
Every plan created by the ring:write-plan agent includes:
```markdown
## Historical Precedent
**Query:** "authentication oauth jwt"
**Index Status:** Populated
### Successful Patterns to Reference
- **[auth-impl/task-3]**: JWT refresh mechanism worked well
- **[api-v2/task-7]**: Token validation approach succeeded
### Failure Patterns to AVOID
- **[auth-v1/task-5]**: localStorage token storage caused XSS
- **[session-fix/task-2]**: Missing CSRF protection
### Related Past Plans
- `2024-01-15-oauth-integration.md`: Similar OAuth2 approach
---
```
### 3. Plan Validation
After creating a plan, validate it against known failures:
```bash
python3 default/lib/validate-plan-precedent.py docs/plans/YYYY-MM-DD-feature.md
```
**Interpretation:**
- `PASS` - No significant overlap with failure patterns
- `WARNING` - >30% keyword overlap with past failures (review required)
**Exit codes:**
- `0` - Plan passes validation
- `1` - Warning: overlap detected (plan may repeat failures)
- `2` - Error (invalid file, etc.)
## Usage
### Automatic (Recommended)
When using `/ring:write-plan` or the `ring:writing-plans` skill, RAG enhancement happens automatically:
1. Keywords are extracted from your planning request
2. Artifact index is queried for precedent
3. Historical Precedent section is added to plan
4. Plan is validated before execution
### Manual Query
To query precedent manually:
```bash
# Get structured planning context
python3 default/lib/artifact-index/artifact_query.py --mode planning "api rate limiting" --json
# Validate an existing plan
python3 default/lib/validate-plan-precedent.py docs/plans/my-plan.md
```
## Empty Index Handling
For new projects or empty indexes:
```json
{
"is_empty_index": true,
"message": "No artifact index found. This is normal for new projects."
}
```
Plans will include:
```markdown
## Historical Precedent
**Query:** "feature keywords"
**Index Status:** Empty (new project)
No historical data available. This is normal for new projects.
Proceeding with standard planning approach.
```
## Performance
| Component | Target | Measured |
|-----------|--------|----------|
| Planning query | <200ms | ~50ms |
| Plan validation | <500ms | ~100ms |
| Keyword extraction | <100ms | ~20ms |
## Overlap Detection Algorithm
The validation uses **bidirectional keyword overlap**:
1. Extract keywords from plan (Goal, Architecture, Tech Stack, Task names)
2. Extract keywords from failed handoffs (task_summary, what_failed, key_decisions)
3. Calculate overlap in both directions:
- `plan_coverage` = intersection / plan_keywords
- `failure_coverage` = intersection / handoff_keywords
4. Return maximum (catches both large and small plans)
**Threshold:** 30% overlap triggers warning
This bidirectional approach prevents large plans from escaping detection via keyword dilution.
## Keyword Extraction
Keywords are extracted from:
**Plans:**
- `**Goal:**` section
- `**Architecture:**` section
- `**Tech Stack:**` section
- `### Task N:` names
- `**Query:**` in Historical Precedent
**Handoffs:**
- `task_summary` field
- `what_failed` field
- `key_decisions` field
**Filtering:**
- 3+ character words only
- Stopwords removed (common English + technical generics)
- Case-insensitive matching
## Configuration
### Validation Threshold
Default: 30% overlap
```bash
# More sensitive (catches more potential issues)
python3 validate-plan-precedent.py plan.md --threshold 20
# Less sensitive (fewer warnings)
python3 validate-plan-precedent.py plan.md --threshold 50
```
### Keyword Sanitization
For security, keywords extracted from planning requests are sanitized:
- Only alphanumeric characters, hyphens, spaces allowed
- Shell metacharacters removed (`;`, `$`, `` ` ``, `|`, `&`)
## Troubleshooting
### "No historical data available"
Normal for:
- New projects
- Projects without `.ring/` initialized
- Projects where `artifact_index.py --all` hasn't been run
Solution: Run indexing once handoffs/plans exist:
```bash
python3 default/lib/artifact-index/artifact_index.py --all
```
### Query taking >200ms
Possible causes:
- Very large artifact index
- Complex query with many keywords
Solutions:
- Use more specific keywords
- Limit query scope with `--limit`
### Validation showing false positives
If validation warns about unrelated failures:
- Increase threshold: `--threshold 50`
- Review the overlapping keywords in output
- Refine plan wording to be more specific
## Tests
Run integration tests:
```bash
bash default/lib/tests/test-rag-planning.sh
```
Tests cover:
- Planning mode query functionality
- Performance (<200ms target)
- Keyword extraction quality
- Edge cases (missing files, invalid thresholds)
- Tech Stack extraction

View file

@ -1,92 +0,0 @@
#!/usr/bin/env bash
# PostToolUse hook: Index handoffs/plans immediately on Write
# Matcher: Write tool
# Purpose: Immediate indexing for searchability
set -euo pipefail
# Read input from stdin
INPUT=$(cat)
# Determine project root early for path validation
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
# Extract file path from Write tool output
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ -z "$FILE_PATH" ]]; then
# No file path in input, skip
echo '{"result": "continue"}'
exit 0
fi
# Path boundary validation: ensure FILE_PATH is within PROJECT_ROOT
# This prevents path traversal attacks (e.g., ../../etc/passwd)
validate_path_boundary() {
local file_path="$1"
local project_root="$2"
# Resolve to absolute paths (handles ../ and symlinks)
local resolved_file resolved_root
# For file path, resolve the directory portion (file may not exist yet)
local file_dir
file_dir=$(dirname "$file_path")
# If directory doesn't exist, walk up to find existing parent
while [[ ! -d "$file_dir" ]] && [[ "$file_dir" != "/" ]]; do
file_dir=$(dirname "$file_dir")
done
if [[ -d "$file_dir" ]]; then
resolved_file="$(cd "$file_dir" && pwd)/$(basename "$file_path")"
else
# Cannot resolve, reject
return 1
fi
resolved_root="$(cd "$project_root" && pwd)"
# Check if resolved file path starts with resolved project root
if [[ "$resolved_file" == "$resolved_root"/* ]]; then
return 0
else
return 1
fi
}
# Validate path is within project boundary
if ! validate_path_boundary "$FILE_PATH" "$PROJECT_ROOT"; then
# Path outside project root, skip silently (security: don't reveal validation)
echo '{"result": "continue"}'
exit 0
fi
# Check if file is a handoff, plan, or continuity ledger
FILE_BASENAME="$(basename "$FILE_PATH")"
if [[ "$FILE_PATH" == *"/handoffs/"* ]] || \
[[ "$FILE_PATH" == *"/plans/"* ]] || \
[[ "$FILE_PATH" == *"/ledgers/"* ]] || \
[[ "$FILE_BASENAME" == CONTINUITY* ]]; then
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Path to artifact indexer (try plugin lib first, then project .ring)
INDEXER="${PLUGIN_ROOT}/lib/artifact-index/artifact_index.py"
if [[ ! -f "$INDEXER" ]]; then
INDEXER="${PROJECT_ROOT}/.ring/lib/artifact-index/artifact_index.py"
fi
if [[ -f "$INDEXER" ]]; then
# Index the single file (fast path)
python3 "$INDEXER" --file "$FILE_PATH" --project "$PROJECT_ROOT" 2>/dev/null || true
fi
fi
# Continue processing
cat <<'HOOKEOF'
{
"result": "continue"
}
HOOKEOF

View file

@ -1,269 +0,0 @@
#!/usr/bin/env bash
# shellcheck disable=SC2034 # Unused variables OK for exported config
# Context Usage Check - UserPromptSubmit hook
# Estimates context usage and injects tiered warnings
# Performance target: <50ms execution time
set -euo pipefail
# Determine plugin root directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
MONOREPO_ROOT="$(cd "${PLUGIN_ROOT}/.." && pwd)"
# Session identification
# CLAUDE_SESSION_ID is provided by Claude Code, fallback to PPID for session isolation
SESSION_ID="${CLAUDE_SESSION_ID:-$PPID}"
# REQUIRED: Validate session ID - alphanumeric, hyphens, underscores only
if [[ ! "$SESSION_ID" =~ ^[a-zA-Z0-9_-]+$ ]]; then
# Fall back to PPID if invalid
SESSION_ID="$PPID"
fi
# Allow users to reset warnings via environment variable
# Set RING_RESET_CONTEXT_WARNING=1 to re-enable warnings
if [[ "${RING_RESET_CONTEXT_WARNING:-}" == "1" ]]; then
# Will be cleaned up below after STATE_FILE is set
RESET_REQUESTED=true
else
RESET_REQUESTED=false
fi
# State file location - use project's .ring/state/ if available, else /tmp
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
# Resolve symlinks to canonical path (prevents path confusion, allows /tmp -> /private/tmp)
if [[ -L "$PROJECT_DIR" ]] && [[ -d "$PROJECT_DIR" ]]; then
PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd -P)"
fi
if [[ -d "${PROJECT_DIR}/.ring/state" ]]; then
STATE_DIR="${PROJECT_DIR}/.ring/state"
else
# Fallback to /tmp for projects without .ring/ initialized
STATE_DIR="/tmp"
fi
STATE_FILE="${STATE_DIR}/context-usage-${SESSION_ID}.json"
TEMP_STATE="${STATE_FILE}.tmp.$$"
# Cleanup trap for temp file on exit (prevents orphaned files)
trap 'rm -f "$TEMP_STATE" 2>/dev/null' EXIT
# Handle reset request now that STATE_FILE is defined
if [[ "$RESET_REQUESTED" == "true" ]]; then
rm -f "$STATE_FILE" 2>/dev/null || true
fi
# Source shared libraries
SHARED_LIB="${MONOREPO_ROOT}/shared/lib"
# NEW: Try to get REAL context usage from session file
get_real_context_usage() {
if [[ -f "${SHARED_LIB}/get-context-usage.sh" ]]; then
# shellcheck source=/dev/null
source "${SHARED_LIB}/get-context-usage.sh"
local pct
pct=$(get_context_usage 2>/dev/null)
if [[ "$pct" != "unknown" ]] && [[ "$pct" =~ ^[0-9]+$ ]]; then
echo "$pct"
return 0
fi
fi
echo ""
return 1
}
if [[ -f "${SHARED_LIB}/context-check.sh" ]]; then
# shellcheck source=/dev/null
source "${SHARED_LIB}/context-check.sh"
else
# Fallback: inline minimal estimation (used when real usage unavailable)
estimate_context_pct() {
local turn_count="${1:-0}"
# Match shared lib: (45000 + turns*2500) * 100 / 200000 = 22.5 + turns*1.25
# Simplified: 23 + (turns * 5 / 4) for integer math (rounds up base for accuracy)
local pct=$(( 23 + (turn_count * 5 / 4) ))
[[ "$pct" -gt 100 ]] && pct=100
echo "$pct"
}
get_warning_tier() {
local pct="${1:-0}"
if [[ "$pct" -ge 85 ]]; then echo "critical"
elif [[ "$pct" -ge 70 ]]; then echo "warning"
elif [[ "$pct" -ge 50 ]]; then echo "info"
else echo "none"
fi
}
format_context_warning() {
local tier="$1"
local pct="$2"
case "$tier" in
critical)
cat <<EOF
<MANDATORY-USER-MESSAGE>
[!!!] CONTEXT CRITICAL: ${pct}% usage (from session data).
**MANDATORY ACTIONS:**
1. STOP current task immediately
2. Run /create-handoff to save progress NOW
3. Create continuity-ledger with current state
4. Run /clear to reset context
5. Resume from handoff in new session
**Verify with /context if needed.**
</MANDATORY-USER-MESSAGE>
EOF
;;
warning)
cat <<EOF
<MANDATORY-USER-MESSAGE>
[!!] Context Warning: ${pct}% usage (from session data).
**RECOMMENDED ACTIONS:**
- Create a continuity-ledger to preserve session state
- Run: /create-handoff or manually create ledger
**Recommended:** Complete current task, then /clear before starting new work.
</MANDATORY-USER-MESSAGE>
EOF
;;
info) echo "[i] Context at ${pct}%." ;;
*) echo "" ;;
esac
}
fi
if [[ -f "${SHARED_LIB}/json-escape.sh" ]]; then
# shellcheck source=/dev/null
source "${SHARED_LIB}/json-escape.sh"
else
# Fallback: minimal JSON escaping
json_escape() {
local input="$1"
if command -v jq &>/dev/null; then
printf '%s' "$input" | jq -Rs . | sed 's/^"//;s/"$//'
else
printf '%s' "$input" | sed \
-e 's/\\/\\\\/g' \
-e 's/"/\\"/g' \
-e 's/\t/\\t/g' \
-e 's/\r/\\r/g' \
-e ':a;N;$!ba;s/\n/\\n/g'
fi
}
fi
# Helper function to write state file (consolidates duplicate code)
# Args: $1 = acknowledged_tier value
write_state_file() {
local ack_tier="$1"
cat > "$TEMP_STATE" <<EOF
{
"session_id": "${SESSION_ID_ESCAPED}",
"turn_count": ${turn_count},
"estimated_pct": ${estimated_pct},
"current_tier": "${current_tier}",
"acknowledged_tier": "${ack_tier}",
"updated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
mv "$TEMP_STATE" "$STATE_FILE"
}
# Read current state or initialize
if [[ -f "$STATE_FILE" ]]; then
if command -v jq &>/dev/null; then
turn_count=$(jq -r '.turn_count // 0' "$STATE_FILE" 2>/dev/null || echo 0)
acknowledged_tier=$(jq -r '.acknowledged_tier // "none"' "$STATE_FILE" 2>/dev/null || echo "none")
else
# Fallback: grep-based parsing
turn_count=$(grep -o '"turn_count":[0-9]*' "$STATE_FILE" 2>/dev/null | grep -o '[0-9]*' || echo 0)
acknowledged_tier=$(grep -o '"acknowledged_tier":"[^"]*"' "$STATE_FILE" 2>/dev/null | sed 's/.*://;s/"//g' || echo "none")
fi
else
turn_count=0
acknowledged_tier="none"
fi
# Increment turn count
turn_count=$((turn_count + 1))
# Get context percentage - try REAL usage first, fall back to estimate
real_pct=$(get_real_context_usage 2>/dev/null || echo "")
if [[ -n "$real_pct" ]] && [[ "$real_pct" =~ ^[0-9]+$ ]]; then
# Using REAL context usage from session file
estimated_pct="$real_pct"
usage_source="real"
else
# Fallback to turn-count estimate
estimated_pct=$(estimate_context_pct "$turn_count")
usage_source="estimate"
fi
# Determine warning tier
current_tier=$(get_warning_tier "$estimated_pct")
# Write updated state using helper function
# Uses atomic write pattern: write to temp, then mv (TEMP_STATE defined at top with trap)
mkdir -p "$(dirname "$STATE_FILE")"
# JSON escape session ID to prevent injection
SESSION_ID_ESCAPED=$(json_escape "$SESSION_ID")
write_state_file "$acknowledged_tier"
# Determine if we should show a warning
# Don't repeat same-tier warnings within session (user has seen it)
# But DO show if tier has escalated
show_warning=false
if [[ "$current_tier" != "none" ]]; then
case "$current_tier" in
critical)
# Always show critical warnings
show_warning=true
;;
warning)
# Show if not already acknowledged at warning or higher
if [[ "$acknowledged_tier" != "warning" && "$acknowledged_tier" != "critical" ]]; then
show_warning=true
fi
;;
info)
# Show if not already acknowledged at any tier
if [[ "$acknowledged_tier" == "none" ]]; then
show_warning=true
fi
;;
esac
fi
# Update acknowledged tier if showing a warning
if [[ "$show_warning" == "true" ]]; then
# Update state with current tier as acknowledged
write_state_file "$current_tier"
fi
# Generate output
if [[ "$show_warning" == "true" ]]; then
warning_content=$(format_context_warning "$current_tier" "$estimated_pct")
warning_escaped=$(json_escape "$warning_content")
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "${warning_escaped}"
}
}
EOF
else
# No warning needed, return minimal output
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit"
}
}
EOF
fi
exit 0

View file

@ -16,10 +16,6 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-outcome.sh"
}
]
}
@ -30,58 +26,6 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/claude-md-reminder.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/context-usage-check.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/artifact-index-write.sh"
}
]
},
{
"matcher": "TodoWrite",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/task-completion-check.sh"
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/ledger-save.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/ledger-save.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/outcome-inference.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/learning-extract.sh"
}
]
}

View file

@ -1,341 +0,0 @@
#!/usr/bin/env python3
"""Learning extraction hook for SessionEnd.
This hook runs on SessionEnd (Stop event) to extract learnings from
the current session and save them to .ring/cache/learnings/.
Input (stdin JSON):
{
"type": "stop",
"session_id": "optional session identifier",
"reason": "user_stop|error|complete"
}
Output (stdout JSON):
{
"result": "continue",
"message": "Optional system reminder about learnings"
}
"""
import json
import sys
import os
from pathlib import Path
from datetime import datetime, timedelta
def read_stdin() -> dict:
"""Read JSON input from stdin."""
try:
return json.loads(sys.stdin.read())
except json.JSONDecodeError:
return {}
def get_project_root() -> Path:
"""Get the project root directory."""
# Try CLAUDE_PROJECT_DIR first
if os.environ.get("CLAUDE_PROJECT_DIR"):
return Path(os.environ["CLAUDE_PROJECT_DIR"])
# Fall back to current directory
return Path.cwd()
def sanitize_session_id(session_id: str) -> str:
"""Sanitize session ID for safe use in filenames.
SECURITY: Prevents path traversal attacks via malicious session_id.
Only allows alphanumeric characters, hyphens, and underscores.
"""
import re
# Remove any path separators or special characters
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '', session_id)
# Ensure non-empty and reasonable length
if not sanitized:
return "unknown"
return sanitized[:64] # Limit length
def get_session_id(input_data: dict) -> str:
"""Get session identifier from input or generate one."""
if input_data.get("session_id"):
# SECURITY: Sanitize user-provided session_id
return sanitize_session_id(input_data["session_id"])
# Generate based on timestamp with microseconds to prevent collision
return datetime.now().strftime("%H%M%S-%f")[:13]
def extract_learnings_from_handoffs(project_root: Path) -> dict:
"""Extract learnings from recent handoffs in the artifact index.
This queries the artifact index for handoffs from this session
and extracts their what_worked, what_failed, and key_decisions.
"""
learnings = {
"what_worked": [],
"what_failed": [],
"key_decisions": [],
"patterns": [],
}
# Try to query artifact index if available
db_path = project_root / ".ring" / "cache" / "artifact-index" / "context.db"
if not db_path.exists():
return learnings
try:
import sqlite3
# Use context manager for proper connection cleanup
with sqlite3.connect(str(db_path), timeout=5.0) as conn:
conn.row_factory = sqlite3.Row
# Get recent handoffs (from last 24 hours instead of calendar date)
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
cursor = conn.execute(
"""
SELECT outcome, what_worked, what_failed, key_decisions
FROM handoffs
WHERE created_at >= ?
ORDER BY created_at DESC
LIMIT 10
""",
(yesterday,)
)
seen_worked = set()
seen_failed = set()
seen_decisions = set()
for row in cursor.fetchall():
outcome = row["outcome"]
if outcome in ("SUCCEEDED", "PARTIAL_PLUS"):
if row["what_worked"] and row["what_worked"] not in seen_worked:
learnings["what_worked"].append(row["what_worked"])
seen_worked.add(row["what_worked"])
elif outcome in ("FAILED", "PARTIAL_MINUS"):
if row["what_failed"] and row["what_failed"] not in seen_failed:
learnings["what_failed"].append(row["what_failed"])
seen_failed.add(row["what_failed"])
if row["key_decisions"] and row["key_decisions"] not in seen_decisions:
learnings["key_decisions"].append(row["key_decisions"])
seen_decisions.add(row["key_decisions"])
except Exception as e:
# Log error for debugging but continue gracefully
import sys
print(f"Warning: Failed to extract learnings from handoffs: {e}", file=sys.stderr)
return learnings
def extract_learnings_from_outcomes(project_root: Path, session_id: str = None) -> dict:
"""Extract learnings from session outcomes file (if exists).
This reads from .ring/state/session-outcome-{session}.json if it exists.
Note: This file is optional - primary learnings come from handoffs database.
The file is created by outcome-inference.sh during session end.
Args:
project_root: Project root directory
session_id: Optional session identifier for session-isolated file lookup
"""
learnings = {
"what_worked": [],
"what_failed": [],
"key_decisions": [],
"patterns": [],
}
# Check for outcome file (optional - may not exist)
state_dir = project_root / ".ring" / "state"
# Try session-isolated file first (from outcome-inference.sh)
if session_id:
session_id_safe = sanitize_session_id(session_id)
outcome_file = state_dir / f"session-outcome-{session_id_safe}.json"
else:
# Fallback: find most recent session-outcome-*.json
outcome_files = list(state_dir.glob("session-outcome-*.json"))
if outcome_files:
outcome_file = max(outcome_files, key=lambda f: f.stat().st_mtime)
else:
outcome_file = state_dir / "session-outcome.json" # Legacy fallback
if not outcome_file.exists():
# File doesn't exist - this is normal, learnings come from handoffs
return learnings
try:
with open(outcome_file, encoding='utf-8') as f:
outcome = json.load(f)
status = outcome.get("status", "unknown")
if status in ["SUCCEEDED", "PARTIAL_PLUS"]:
if outcome.get("summary"):
learnings["what_worked"].append(outcome["summary"])
elif status in ["FAILED", "PARTIAL_MINUS"]:
if outcome.get("summary"):
learnings["what_failed"].append(outcome["summary"])
if outcome.get("key_decisions"):
if isinstance(outcome["key_decisions"], list):
learnings["key_decisions"].extend(outcome["key_decisions"])
else:
learnings["key_decisions"].append(outcome["key_decisions"])
except (json.JSONDecodeError, KeyError, IOError) as e:
import sys
import traceback
print(f"Warning: Failed to read session outcome file: {e}", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
return learnings
def save_learnings(project_root: Path, session_id: str, learnings: dict) -> Path:
"""Save learnings to cache file.
Args:
project_root: Project root directory
session_id: Session identifier (must be pre-sanitized)
learnings: Extracted learnings dictionary
Returns:
Path to saved learnings file
Raises:
ValueError: If resolved path escapes learnings directory
"""
# Create learnings directory
learnings_dir = project_root / ".ring" / "cache" / "learnings"
learnings_dir.mkdir(parents=True, exist_ok=True)
# Generate filename
date_str = datetime.now().strftime("%Y-%m-%d")
filename = f"{date_str}-{session_id}.md"
file_path = learnings_dir / filename
# SECURITY: Verify the resolved path is within learnings_dir
resolved_path = file_path.resolve()
resolved_learnings = learnings_dir.resolve()
if not str(resolved_path).startswith(str(resolved_learnings)):
raise ValueError(
f"Security: session_id would escape learnings directory: {session_id}"
)
# Generate content
content = f"""# Learnings from Session {session_id}
**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
## What Worked
"""
for item in learnings.get("what_worked", []):
content += f"- {item}\n"
if not learnings.get("what_worked"):
content += "- (No successful patterns recorded)\n"
content += """
## What Failed
"""
for item in learnings.get("what_failed", []):
content += f"- {item}\n"
if not learnings.get("what_failed"):
content += "- (No failures recorded)\n"
content += """
## Key Decisions
"""
for item in learnings.get("key_decisions", []):
content += f"- {item}\n"
if not learnings.get("key_decisions"):
content += "- (No key decisions recorded)\n"
content += """
## Patterns
"""
for item in learnings.get("patterns", []):
content += f"- {item}\n"
if not learnings.get("patterns"):
content += "- (No patterns identified)\n"
# Write file
file_path.write_text(content, encoding='utf-8')
return file_path
def main():
"""Main hook entry point.
Extraction Strategy:
1. Extract learnings from handoffs database (primary source)
2. Extract learnings from session outcomes file (secondary source)
3. Perform cross-source deduplication to prevent duplicates when the same
learning appears in both sources (normalized comparison preserves order)
"""
input_data = read_stdin()
# Only process on actual session end (not errors)
if input_data.get("type") != "stop":
print(json.dumps({"result": "continue"}))
return
project_root = get_project_root()
session_id = get_session_id(input_data)
# Combine learnings from different sources
learnings = {
"what_worked": [],
"what_failed": [],
"key_decisions": [],
"patterns": [],
}
# Extract from handoffs (if artifact index available)
handoff_learnings = extract_learnings_from_handoffs(project_root)
for key in learnings:
learnings[key].extend(handoff_learnings.get(key, []))
# Extract from outcomes (if outcome tracking available)
outcome_learnings = extract_learnings_from_outcomes(project_root, session_id)
for key in learnings:
learnings[key].extend(outcome_learnings.get(key, []))
# Cross-source deduplication: Remove duplicates while preserving order
# This prevents the same learning from being counted multiple times
# when it appears in both handoffs and session outcomes
for key in learnings:
seen = set()
deduplicated = []
for item in learnings[key]:
# Normalize for comparison (lowercase, strip whitespace)
normalized = item.lower().strip() if isinstance(item, str) else str(item)
if normalized not in seen:
seen.add(normalized)
deduplicated.append(item) # Keep original formatting
learnings[key] = deduplicated
# Check if we have any learnings to save
has_learnings = any(learnings.get(key) for key in learnings)
if has_learnings:
file_path = save_learnings(project_root, session_id, learnings)
message = f"Session learnings saved to {file_path.relative_to(project_root)}"
else:
message = "No learnings extracted from this session (no handoffs or outcomes recorded)"
# Output hook response
output = {
"result": "continue",
"message": message,
}
print(json.dumps(output))
if __name__ == "__main__":
main()

View file

@ -1,29 +0,0 @@
#!/usr/bin/env bash
# Learning extraction hook wrapper
# Calls Python script for SessionEnd learning extraction
set -euo pipefail
# Get the directory where this script lives
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Find Python interpreter
PYTHON_CMD=""
for cmd in python3 python; do
if command -v "$cmd" &> /dev/null; then
PYTHON_CMD="$cmd"
break
fi
done
if [[ -z "$PYTHON_CMD" ]]; then
# Python not available - output minimal JSON response
echo '{"result": "continue", "error": "Python not available for learning extraction"}'
exit 0
fi
# Construct input JSON for Stop event (framework doesn't provide stdin)
INPUT_JSON='{"type": "stop"}'
# Pass constructed input to Python script
echo "$INPUT_JSON" | "$PYTHON_CMD" "${SCRIPT_DIR}/learning-extract.py"

View file

@ -1,125 +0,0 @@
#!/usr/bin/env bash
# Ledger save hook - auto-saves active ledger before compaction or stop
# Triggered by: PreCompact, Stop events
set -euo pipefail
# Determine paths
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
LEDGER_DIR="${PROJECT_ROOT}/.ring/ledgers"
# Source shared utilities
SHARED_LIB="${SCRIPT_DIR}/../../shared/lib"
# Source JSON escaping utility
if [[ -f "${SHARED_LIB}/json-escape.sh" ]]; then
# shellcheck source=/dev/null
source "${SHARED_LIB}/json-escape.sh"
else
# Fallback: define json_escape locally
json_escape() {
local input="$1"
if command -v jq &>/dev/null; then
printf '%s' "$input" | jq -Rs . | sed 's/^"//;s/"$//'
else
printf '%s' "$input" | sed \
-e 's/\\/\\\\/g' \
-e 's/"/\\"/g' \
-e 's/\t/\\t/g' \
-e 's/\r/\\r/g' \
-e ':a;N;$!ba;s/\n/\\n/g'
fi
}
fi
# Source ledger utilities
if [[ -f "${SHARED_LIB}/ledger-utils.sh" ]]; then
# shellcheck source=/dev/null
source "${SHARED_LIB}/ledger-utils.sh"
else
# Fallback: define find_active_ledger locally if shared lib not found
find_active_ledger() {
local ledger_dir="$1"
[[ ! -d "$ledger_dir" ]] && echo "" && return 0
local newest="" newest_time=0
while IFS= read -r -d '' file; do
[[ -L "$file" ]] && continue
local mtime
if [[ "$(uname)" == "Darwin" ]]; then
mtime=$(stat -f %m "$file" 2>/dev/null || echo 0)
else
mtime=$(stat -c %Y "$file" 2>/dev/null || echo 0)
fi
(( mtime > newest_time )) && newest_time=$mtime && newest="$file"
done < <(find "$ledger_dir" -maxdepth 1 -name "CONTINUITY-*.md" -type f -print0 2>/dev/null)
echo "$newest"
}
fi
# Update ledger timestamp - SAFE implementation
# Fixes: Silent failure when Updated: line missing
update_ledger_timestamp() {
local ledger_file="$1"
local timestamp
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
if grep -q "^Updated:" "$ledger_file" 2>/dev/null; then
# Update existing timestamp
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "s/^Updated:.*$/Updated: ${timestamp}/" "$ledger_file"
else
sed -i "s/^Updated:.*$/Updated: ${timestamp}/" "$ledger_file"
fi
else
# Append if missing
echo "Updated: ${timestamp}" >> "$ledger_file"
fi
# Always update filesystem mtime as fallback
touch "$ledger_file"
}
# Main logic
main() {
local active_ledger
active_ledger=$(find_active_ledger "$LEDGER_DIR")
if [[ -z "$active_ledger" ]]; then
# No active ledger - nothing to save
cat <<'EOF'
{
"result": "continue"
}
EOF
exit 0
fi
# Update timestamp
update_ledger_timestamp "$active_ledger"
local ledger_name
ledger_name=$(basename "$active_ledger" .md)
# Extract current phase for status message
local current_phase=""
current_phase=$(grep -E '\[->' "$active_ledger" 2>/dev/null | head -1 | sed 's/^[[:space:]]*//' || echo "")
local message="Ledger saved: ${ledger_name}.md"
if [[ -n "$current_phase" ]]; then
message="${message}\nCurrent phase: ${current_phase}"
fi
message="${message}\n\nAfter clear/compact, state will auto-restore on resume."
local message_escaped
message_escaped=$(json_escape "$message")
cat <<EOF
{
"result": "continue",
"message": "${message_escaped}"
}
EOF
}
main "$@"

View file

@ -1,127 +0,0 @@
#!/usr/bin/env bash
# Outcome Inference Hook Wrapper
# Calls Python module to infer session outcome from state
# Triggered by: Stop event (runs before learning-extract.sh)
set -euo pipefail
# Get the directory where this script lives
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="${SCRIPT_DIR}/../lib/outcome-inference"
# Source shared JSON escaping library
SHARED_LIB="${SCRIPT_DIR}/../../shared/lib"
if [[ -f "${SHARED_LIB}/json-escape.sh" ]]; then
# shellcheck source=/dev/null
source "${SHARED_LIB}/json-escape.sh"
else
# Fallback: minimal JSON escaping
json_escape() {
local input="$1"
if command -v jq &>/dev/null; then
printf '%s' "$input" | jq -Rs . | sed 's/^"//;s/"$//'
else
printf '%s' "$input" | sed \
-e 's/\\/\\\\/g' \
-e 's/"/\\"/g' \
-e 's/\t/\\t/g' \
-e 's/\r/\\r/g' \
-e ':a;N;$!ba;s/\n/\\n/g'
fi
}
fi
# Read input from stdin
INPUT=$(cat)
# Find Python interpreter
PYTHON_CMD=""
for cmd in python3 python; do
if command -v "$cmd" &> /dev/null; then
PYTHON_CMD="$cmd"
break
fi
done
if [[ -z "$PYTHON_CMD" ]]; then
# Python not available - skip inference
echo '{"result": "continue", "message": "Outcome inference skipped: Python not available"}'
exit 0
fi
# Check if module exists
if [[ ! -f "${LIB_DIR}/outcome_inference.py" ]]; then
echo '{"result": "continue", "message": "Outcome inference skipped: Module not found"}'
exit 0
fi
# Session identification - use CLAUDE_SESSION_ID if available, fallback to PPID
SESSION_ID="${CLAUDE_SESSION_ID:-$PPID}"
# Sanitize session ID: keep only alphanumeric, hyphens, underscores
SESSION_ID_SAFE=$(echo "$SESSION_ID" | tr -cd 'a-zA-Z0-9_-')
[[ -z "$SESSION_ID_SAFE" ]] && SESSION_ID_SAFE="unknown"
# Read persisted todos from task-completion-check.sh
# This solves the data flow break where Stop hook doesn't have direct access to todos
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
STATE_DIR="${PROJECT_ROOT}/.ring/state"
TODOS_FILE="${STATE_DIR}/todos-state-${SESSION_ID_SAFE}.json"
INPUT_FOR_PYTHON='{"todos": []}'
if [[ -f "$TODOS_FILE" ]]; then
TODOS_DATA=$(cat "$TODOS_FILE" 2>/dev/null || echo '[]')
if command -v jq &>/dev/null; then
# Wrap in expected format for Python: {"todos": [...]}
INPUT_FOR_PYTHON=$(echo "$TODOS_DATA" | jq '{todos: .}' 2>/dev/null || echo '{"todos": []}')
else
# Fallback: simple string wrapping (assumes valid JSON array)
INPUT_FOR_PYTHON="{\"todos\": ${TODOS_DATA}}"
fi
fi
# Run inference with persisted todos piped
RESULT=$(echo "$INPUT_FOR_PYTHON" | "$PYTHON_CMD" "${LIB_DIR}/outcome_inference.py" 2>/dev/null || echo '{}')
# Extract outcome and reason using jq if available
if command -v jq &>/dev/null; then
OUTCOME=$(echo "$RESULT" | jq -r '.outcome // "UNKNOWN"')
REASON=$(echo "$RESULT" | jq -r '.reason // "Unable to determine"')
CONFIDENCE=$(echo "$RESULT" | jq -r '.confidence // "low"')
else
# Fallback: grep-based extraction
OUTCOME=$(echo "$RESULT" | grep -o '"outcome": *"[^"]*"' | cut -d'"' -f4 || echo "UNKNOWN")
REASON=$(echo "$RESULT" | grep -o '"reason": *"[^"]*"' | cut -d'"' -f4 || echo "Unable to determine")
CONFIDENCE=$(echo "$RESULT" | grep -o '"confidence": *"[^"]*"' | cut -d'"' -f4 || echo "low")
fi
# Save outcome to state file for learning-extract.py to pick up
# Note: PROJECT_ROOT, STATE_DIR, and SESSION_ID_SAFE already defined above
mkdir -p "$STATE_DIR"
# Escape variables for safe JSON output
OUTCOME_ESCAPED=$(json_escape "$OUTCOME")
REASON_ESCAPED=$(json_escape "$REASON")
CONFIDENCE_ESCAPED=$(json_escape "$CONFIDENCE")
# Write outcome state (session-isolated filename)
cat > "${STATE_DIR}/session-outcome-${SESSION_ID_SAFE}.json" << EOF
{
"status": "${OUTCOME_ESCAPED}",
"summary": "${REASON_ESCAPED}",
"confidence": "${CONFIDENCE_ESCAPED}",
"inferred_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"key_decisions": []
}
EOF
# Build message and escape for JSON
MESSAGE="Session outcome inferred: ${OUTCOME} (${CONFIDENCE} confidence) - ${REASON}"
MESSAGE_ESCAPED=$(json_escape "$MESSAGE")
# Return hook response
cat << EOF
{
"result": "continue",
"message": "${MESSAGE_ESCAPED}"
}
EOF

View file

@ -1,155 +0,0 @@
#!/usr/bin/env bash
# SessionStart hook: Prompt for previous session outcome grade
# Purpose: Capture user feedback on session success/failure AFTER /clear or /compact
# Event: SessionStart (matcher: clear|compact) - AI is active and can prompt user
#
# Why SessionStart instead of PreCompact?
# - PreCompact additionalContext is NOT visible to AI (architectural limitation)
# - SessionStart additionalContext IS visible to AI
# - State files (todos, ledger, handoffs) persist on disk after clear/compact
set -euo pipefail
# Read input from stdin (not used currently, but available)
INPUT=$(cat)
# Determine project root
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
# Session identification
SESSION_ID="${CLAUDE_SESSION_ID:-$PPID}"
SESSION_ID_SAFE=$(echo "$SESSION_ID" | tr -cd 'a-zA-Z0-9_-')
[[ -z "$SESSION_ID_SAFE" ]] && SESSION_ID_SAFE="unknown"
# Load shared JSON escape utility
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
SHARED_LIB="${SCRIPT_DIR}/../../shared/lib"
if [[ -f "${SHARED_LIB}/json-escape.sh" ]]; then
# shellcheck source=../../shared/lib/json-escape.sh
source "${SHARED_LIB}/json-escape.sh"
else
# Fallback if shared lib not found
json_escape() {
local input="$1"
if command -v jq &>/dev/null; then
printf '%s' "$input" | jq -Rs . | sed 's/^"//;s/"$//'
else
printf '%s' "$input" | sed \
-e 's/\\/\\\\/g' \
-e 's/"/\\"/g' \
-e 's/\t/\\t/g' \
-e 's/\r/\\r/g' \
-e ':a;N;$!ba;s/\n/\\n/g'
fi
}
fi
# Check if there's session work to grade
# Priority: 1) todos state, 2) active ledger, 3) recent handoff
HAS_SESSION_WORK=false
SESSION_CONTEXT=""
# Check for todos state (indicates TodoWrite was used this session)
TODOS_FILE="${PROJECT_ROOT}/.ring/state/todos-state-${SESSION_ID_SAFE}.json"
if [[ -f "$TODOS_FILE" ]]; then
HAS_SESSION_WORK=true
if command -v jq &>/dev/null; then
TOTAL=$(jq 'length' "$TODOS_FILE" 2>/dev/null || echo 0)
COMPLETED=$(jq '[.[] | select(.status == "completed")] | length' "$TODOS_FILE" 2>/dev/null || echo 0)
SESSION_CONTEXT="Task progress: ${COMPLETED}/${TOTAL} completed"
fi
fi
# Check for active ledger
LEDGER_DIR="${PROJECT_ROOT}/.ring/ledgers"
if [[ -d "$LEDGER_DIR" ]]; then
ACTIVE_LEDGER=$(find "$LEDGER_DIR" -maxdepth 1 -name "CONTINUITY-*.md" -type f -mmin -120 2>/dev/null | head -1)
if [[ -n "$ACTIVE_LEDGER" ]]; then
HAS_SESSION_WORK=true
LEDGER_NAME=$(basename "$ACTIVE_LEDGER" .md)
if [[ -n "$SESSION_CONTEXT" ]]; then
SESSION_CONTEXT="${SESSION_CONTEXT}, Ledger: ${LEDGER_NAME}"
else
SESSION_CONTEXT="Ledger: ${LEDGER_NAME}"
fi
fi
fi
# Check for recent handoffs (modified in last 2 hours)
# Store the path for later use with artifact_mark.py
HANDOFF_FILE_PATH=""
HANDOFFS_DIRS=("${PROJECT_ROOT}/docs/handoffs" "${PROJECT_ROOT}/.ring/handoffs")
for HANDOFFS_DIR in "${HANDOFFS_DIRS[@]}"; do
if [[ -d "$HANDOFFS_DIR" ]]; then
RECENT_HANDOFF=$(find "$HANDOFFS_DIR" -name "*.md" -mmin -120 -type f 2>/dev/null | head -1)
if [[ -n "$RECENT_HANDOFF" ]]; then
HAS_SESSION_WORK=true
HANDOFF_FILE_PATH="$RECENT_HANDOFF"
HANDOFF_NAME=$(basename "$RECENT_HANDOFF")
if [[ -n "$SESSION_CONTEXT" ]]; then
SESSION_CONTEXT="${SESSION_CONTEXT}, Handoff: ${HANDOFF_NAME}"
else
SESSION_CONTEXT="Handoff: ${HANDOFF_NAME}"
fi
break
fi
fi
done
# If no session work detected, skip prompting
if [[ "$HAS_SESSION_WORK" != "true" ]]; then
cat <<'HOOKEOF'
{
"hookSpecificOutput": {
"hookEventName": "SessionStart"
}
}
HOOKEOF
exit 0
fi
# Build artifact_mark instruction only if we have a handoff file to mark
ARTIFACT_MARK_INSTRUCTION=""
if [[ -n "$HANDOFF_FILE_PATH" ]]; then
# Make path relative to project root for cleaner output
RELATIVE_HANDOFF="${HANDOFF_FILE_PATH#"${PROJECT_ROOT}/"}"
# Use absolute path to artifact_mark.py (SCRIPT_DIR points to hooks/, lib/ is sibling)
ARTIFACT_MARK_SCRIPT="${SCRIPT_DIR}/../lib/artifact-index/artifact_mark.py"
ARTIFACT_MARK_INSTRUCTION="
After user responds, save the grade using:
\`python3 ${ARTIFACT_MARK_SCRIPT} --file ${RELATIVE_HANDOFF} --outcome <GRADE>\`"
fi
# Build prompt message for the AI to ask user
MESSAGE="<MANDATORY-USER-MESSAGE>
PREVIOUS SESSION OUTCOME GRADE REQUESTED
You just cleared/compacted the previous session. Please ask the user to rate the PREVIOUS session's outcome.
**Previous Session Context:** ${SESSION_CONTEXT}
**MANDATORY ACTION:** Use AskUserQuestion to ask:
\"How would you rate the previous session's outcome?\"
Options:
- SUCCEEDED: All goals achieved, high quality output
- PARTIAL_PLUS: Most goals achieved (≥80%), minor issues
- PARTIAL_MINUS: Some progress (50-79%), significant gaps
- FAILED: Goals not achieved (<50%), major issues
${ARTIFACT_MARK_INSTRUCTION}
Then continue with the new session.
</MANDATORY-USER-MESSAGE>"
MESSAGE_ESCAPED=$(json_escape "$MESSAGE")
# Return hook response with additionalContext (AI will process this)
cat <<HOOKEOF
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "${MESSAGE_ESCAPED}"
}
}
HOOKEOF

View file

@ -17,40 +17,6 @@ fi
# Determine plugin root directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
MONOREPO_ROOT="$(cd "${PLUGIN_ROOT}/.." && pwd)"
# Reset context usage state on session start
# This ensures fresh estimates after /clear or compact
reset_context_state() {
local session_id="${CLAUDE_SESSION_ID:-$PPID}"
local project_dir="${CLAUDE_PROJECT_DIR:-.}"
# REQUIRED: Validate session ID - alphanumeric, hyphens, underscores only
if [[ ! "$session_id" =~ ^[a-zA-Z0-9_-]+$ ]]; then
# Invalid session ID, skip cleanup (don't risk path traversal)
return 0
fi
# Clear state files for this session
rm -f "${project_dir}/.ring/state/context-usage-${session_id}.json" 2>/dev/null || true
rm -f "/tmp/context-usage-${session_id}.json" 2>/dev/null || true
}
# Always reset context state on SessionStart
# (whether startup, resume, clear, or compact)
reset_context_state
# Cleanup old state files (older than 7 days)
cleanup_old_state_files() {
local project_dir="${CLAUDE_PROJECT_DIR:-.}"
local state_dir="${project_dir}/.ring/state"
[[ ! -d "$state_dir" ]] && return 0
# Remove state files older than 7 days
find "$state_dir" -name "*.json" -type f -mtime +7 -delete 2>/dev/null || true
}
# Cleanup old state files early in session
cleanup_old_state_files
# Auto-install PyYAML if Python is available but PyYAML is not
# Set RING_AUTO_INSTALL_DEPS=false to disable automatic dependency installation
@ -127,84 +93,6 @@ State assumption → Explain why → Note what would change it
**Full pattern:** See shared-patterns/doubt-triggered-questions.md
'
# Source shared ledger utilities
SHARED_LIB_LEDGER="${MONOREPO_ROOT}/shared/lib"
if [[ -f "${SHARED_LIB_LEDGER}/ledger-utils.sh" ]]; then
# shellcheck source=/dev/null
source "${SHARED_LIB_LEDGER}/ledger-utils.sh"
else
# Fallback: define find_active_ledger locally if shared lib not found
find_active_ledger() {
local ledger_dir="$1"
[[ ! -d "$ledger_dir" ]] && echo "" && return 0
local newest="" newest_time=0
while IFS= read -r -d '' file; do
[[ -L "$file" ]] && continue
local mtime
if [[ "$(uname)" == "Darwin" ]]; then
mtime=$(stat -f %m "$file" 2>/dev/null || echo 0)
else
mtime=$(stat -c %Y "$file" 2>/dev/null || echo 0)
fi
(( mtime > newest_time )) && newest_time=$mtime && newest="$file"
done < <(find "$ledger_dir" -maxdepth 1 -name "CONTINUITY-*.md" -type f -print0 2>/dev/null)
echo "$newest"
}
fi
# Detect and load active continuity ledger
detect_active_ledger() {
local project_root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
local ledger_dir="${project_root}/.ring/ledgers"
local active_ledger=""
local ledger_content=""
# Check if ledger directory exists
if [[ ! -d "$ledger_dir" ]]; then
echo ""
return 0
fi
# Find most recently modified ledger (safe implementation)
active_ledger=$(find_active_ledger "$ledger_dir")
if [[ -z "$active_ledger" ]]; then
echo ""
return 0
fi
# Read ledger content
ledger_content=$(cat "$active_ledger" 2>/dev/null || echo "")
if [[ -z "$ledger_content" ]]; then
echo ""
return 0
fi
# Extract current phase (line with [->] marker)
local current_phase=""
current_phase=$(grep -E '\[->' "$active_ledger" 2>/dev/null | head -1 | sed 's/^[[:space:]]*//' || echo "")
# Format output
local ledger_name
ledger_name=$(basename "$active_ledger" .md)
cat <<LEDGER_EOF
## Active Continuity Ledger: ${ledger_name}
**Current Phase:** ${current_phase:-"No active phase marked"}
<continuity-ledger-content>
${ledger_content}
</continuity-ledger-content>
**Instructions:**
1. Review the State section - find [->] for current work
2. Check Open Questions for UNCONFIRMED items
3. Continue from where you left off
LEDGER_EOF
}
# Generate skills overview with cascading fallback
# Priority: Python+PyYAML > Python regex > Bash fallback > Error message
generate_skills_overview() {
@ -244,9 +132,6 @@ generate_skills_overview() {
skills_overview=$(generate_skills_overview || echo "Error generating skills quick reference")
# Detect active ledger (if any)
ledger_context=$(detect_active_ledger)
# Source shared JSON escaping utility
SHARED_LIB="${PLUGIN_ROOT}/../shared/lib"
if [[ -f "${SHARED_LIB}/json-escape.sh" ]]; then
@ -275,16 +160,10 @@ fi
overview_escaped=$(json_escape "$skills_overview")
critical_rules_escaped=$(json_escape "$CRITICAL_RULES")
doubt_questions_escaped=$(json_escape "$DOUBT_QUESTIONS")
ledger_context_escaped=$(json_escape "$ledger_context")
# Build additionalContext with optional ledger section
# Build additionalContext
additional_context="<ring-critical-rules>\n${critical_rules_escaped}\n</ring-critical-rules>\n\n<ring-doubt-questions>\n${doubt_questions_escaped}\n</ring-doubt-questions>\n\n<ring-skills-system>\n${overview_escaped}\n</ring-skills-system>"
# Append ledger context if present
if [[ -n "$ledger_context" ]]; then
additional_context="${additional_context}\n\n<ring-continuity-ledger>\n${ledger_context_escaped}\n</ring-continuity-ledger>"
fi
# Build JSON output
cat <<EOF
{

View file

@ -1,151 +0,0 @@
#!/usr/bin/env bash
# Task Completion Check - PostToolUse hook for TodoWrite
# Detects when all todos are marked complete and triggers handoff creation
# Performance target: <50ms execution time
set -euo pipefail
# Determine plugin root directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
MONOREPO_ROOT="$(cd "${PLUGIN_ROOT}/.." && pwd)"
# Read input from stdin
INPUT=$(cat)
# Source shared libraries
SHARED_LIB="${MONOREPO_ROOT}/shared/lib"
if [[ -f "${SHARED_LIB}/json-escape.sh" ]]; then
# shellcheck source=/dev/null
source "${SHARED_LIB}/json-escape.sh"
else
# Fallback: minimal JSON escaping
json_escape() {
local input="$1"
if command -v jq &>/dev/null; then
printf '%s' "$input" | jq -Rs . | sed 's/^"//;s/"$//'
else
printf '%s' "$input" | sed \
-e 's/\\/\\\\/g' \
-e 's/"/\\"/g' \
-e 's/\t/\\t/g' \
-e 's/\r/\\r/g' \
-e ':a;N;$!ba;s/\n/\\n/g'
fi
}
fi
# Parse TodoWrite tool output from input
# Expected input structure:
# {
# "tool_name": "TodoWrite",
# "tool_input": { "todos": [...] },
# "tool_output": { ... }
# }
# Extract todos array using jq if available, otherwise skip
if ! command -v jq &>/dev/null; then
# No jq - cannot parse todos, return minimal response
cat <<'EOF'
{
"result": "continue"
}
EOF
exit 0
fi
# Parse the todos from tool_input
TODOS=$(echo "$INPUT" | jq -r '.tool_input.todos // []' 2>/dev/null)
# Persist todos state for outcome-inference.sh to read later
# This solves the data flow break where Stop hook doesn't have access to todos
if [[ -n "$TODOS" ]] && [[ "$TODOS" != "null" ]] && [[ "$TODOS" != "[]" ]]; then
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
STATE_DIR="${PROJECT_ROOT}/.ring/state"
mkdir -p "$STATE_DIR"
# Session ID isolation - use CLAUDE_SESSION_ID if available, fallback to PPID
SESSION_ID="${CLAUDE_SESSION_ID:-$PPID}"
# Sanitize session ID: keep only alphanumeric, hyphens, underscores
SESSION_ID_SAFE=$(echo "$SESSION_ID" | tr -cd 'a-zA-Z0-9_-')
[[ -z "$SESSION_ID_SAFE" ]] && SESSION_ID_SAFE="unknown"
# Atomic write: temp file + mv
TODOS_STATE_FILE="${STATE_DIR}/todos-state-${SESSION_ID_SAFE}.json"
TEMP_TODOS_FILE="${TODOS_STATE_FILE}.tmp.$$"
echo "$TODOS" > "$TEMP_TODOS_FILE"
mv "$TEMP_TODOS_FILE" "$TODOS_STATE_FILE"
fi
if [[ -z "$TODOS" ]] || [[ "$TODOS" == "null" ]] || [[ "$TODOS" == "[]" ]]; then
# No todos or empty - nothing to check
cat <<'EOF'
{
"result": "continue"
}
EOF
exit 0
fi
# Count total and completed todos
TOTAL_COUNT=$(echo "$TODOS" | jq 'length')
COMPLETED_COUNT=$(echo "$TODOS" | jq '[.[] | select(.status == "completed")] | length')
IN_PROGRESS_COUNT=$(echo "$TODOS" | jq '[.[] | select(.status == "in_progress")] | length')
PENDING_COUNT=$(echo "$TODOS" | jq '[.[] | select(.status == "pending")] | length')
# Check if all todos are complete
if [[ "$TOTAL_COUNT" -gt 0 ]] && [[ "$COMPLETED_COUNT" -eq "$TOTAL_COUNT" ]]; then
# All todos complete - trigger handoff creation
TASK_LIST=$(echo "$TODOS" | jq -r '.[] | "- " + .content')
MESSAGE="<MANDATORY-USER-MESSAGE>
TASK COMPLETION DETECTED - HANDOFF RECOMMENDED
All ${TOTAL_COUNT} todos are marked complete.
**FIRST:** Run /context to check actual context usage.
**IF context is high (>70%):** Create handoff NOW:
1. Run /create-handoff to preserve this session's learnings
2. Document what worked and what failed in the handoff
**IF context is low (<70%):** Handoff is optional - you can continue with new tasks.
Completed tasks:
${TASK_LIST}
</MANDATORY-USER-MESSAGE>"
MESSAGE_ESCAPED=$(json_escape "$MESSAGE")
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "${MESSAGE_ESCAPED}"
}
}
EOF
else
# Not all complete - return status summary
if [[ "$COMPLETED_COUNT" -gt 0 ]]; then
MESSAGE="Task progress: ${COMPLETED_COUNT}/${TOTAL_COUNT} complete, ${IN_PROGRESS_COUNT} in progress, ${PENDING_COUNT} pending."
MESSAGE_ESCAPED=$(json_escape "$MESSAGE")
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "${MESSAGE_ESCAPED}"
}
}
EOF
else
# No completed tasks - minimal response
cat <<'EOF'
{
"result": "continue"
}
EOF
fi
fi
exit 0

View file

@ -1,252 +0,0 @@
#!/usr/bin/env bash
# Integration tests for context-usage-check.sh
# Run: bash default/hooks/tests/test-context-usage.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
HOOK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
HOOK="${HOOK_DIR}/context-usage-check.sh"
# Resolve /tmp to canonical path (e.g., /private/tmp on macOS)
TMP_DIR="/tmp"
if [[ -L "$TMP_DIR" ]] && [[ -d "$TMP_DIR" ]]; then
TMP_DIR="$(cd "$TMP_DIR" && pwd -P)"
fi
# Test counter
TESTS_RUN=0
TESTS_PASSED=0
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
# Test helper
run_test() {
local name="$1"
local expected="$2"
local input="$3"
TESTS_RUN=$((TESTS_RUN + 1))
# Clean state for test
rm -f "${TMP_DIR}/context-usage-test-"*.json
# Run hook with test session ID
export CLAUDE_SESSION_ID="test-$$"
export CLAUDE_PROJECT_DIR="/tmp"
local output
output=$(echo "$input" | "$HOOK" 2>/dev/null || true)
if echo "$output" | grep -q "$expected"; then
echo -e "${GREEN}PASS${NC}: $name"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
echo -e "${RED}FAIL${NC}: $name"
echo " Expected to contain: $expected"
echo " Got: $output"
fi
# Clean up
rm -f "${TMP_DIR}/context-usage-test-"*.json
}
echo "Running context-usage-check.sh tests..."
echo "========================================"
# Test 1: First prompt returns valid JSON
run_test "First prompt returns valid JSON" \
"hookSpecificOutput" \
'{"prompt": "test", "session_id": "test"}'
# Test 2: Hook is fast (<50ms)
echo -n "Testing performance... "
TESTS_RUN=$((TESTS_RUN + 1))
rm -f "${TMP_DIR}/context-usage-perf-"*.json
export CLAUDE_SESSION_ID="perf-$$"
export CLAUDE_PROJECT_DIR="/tmp"
start=$(date +%s%N 2>/dev/null || python3 -c "import time; print(int(time.time() * 1e9))")
echo '{"prompt": "test"}' | "$HOOK" > /dev/null 2>&1
end=$(date +%s%N 2>/dev/null || python3 -c "import time; print(int(time.time() * 1e9))")
duration=$(( (end - start) / 1000000 )) # Convert to ms
if [[ $duration -lt 50 ]]; then
echo -e "${GREEN}PASS${NC}: Hook executed in ${duration}ms (<50ms)"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
echo -e "${RED}FAIL${NC}: Hook took ${duration}ms (>50ms)"
fi
rm -f "${TMP_DIR}/context-usage-perf-"*.json
# Test 3: State file is created
echo -n "Testing state file creation... "
TESTS_RUN=$((TESTS_RUN + 1))
rm -f "${TMP_DIR}/context-usage-state-"*.json
rm -rf "${TMP_DIR}/.ring" 2>/dev/null || true
export CLAUDE_SESSION_ID="state-$$"
export CLAUDE_PROJECT_DIR="/tmp"
echo '{"prompt": "test"}' | "$HOOK" > /dev/null 2>&1
# Hook uses TMP_DIR directly when .ring/state doesn't exist
if [[ -f "${TMP_DIR}/context-usage-state-$$.json" ]]; then
echo -e "${GREEN}PASS${NC}: State file created"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
echo -e "${RED}FAIL${NC}: State file not found at ${TMP_DIR}/context-usage-state-$$.json"
fi
rm -f "${TMP_DIR}/context-usage-state-"*.json
# Test 4: Turn count increments
echo -n "Testing turn count increment... "
TESTS_RUN=$((TESTS_RUN + 1))
rm -f "${TMP_DIR}/context-usage-count-"*.json
rm -rf "${TMP_DIR}/.ring" 2>/dev/null || true
export CLAUDE_SESSION_ID="count-$$"
export CLAUDE_PROJECT_DIR="/tmp"
# Run hook 3 times
for _ in {1..3}; do
echo '{"prompt": "test"}' | "$HOOK" > /dev/null 2>&1
done
# Check turn count (hook uses TMP_DIR when .ring/state doesn't exist)
STATE_FILE="${TMP_DIR}/context-usage-count-$$.json"
if [[ -f "$STATE_FILE" ]]; then
if command -v jq &>/dev/null; then
count=$(jq -r '.turn_count' "$STATE_FILE")
else
count=$(grep -o '"turn_count":[0-9]*' "$STATE_FILE" | grep -o '[0-9]*')
fi
if [[ "$count" == "3" ]]; then
echo -e "${GREEN}PASS${NC}: Turn count is 3 after 3 prompts"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
echo -e "${RED}FAIL${NC}: Turn count is $count, expected 3"
fi
else
echo -e "${RED}FAIL${NC}: State file not found"
fi
rm -f "${TMP_DIR}/context-usage-count-"*.json
# Test 5: Warning deduplication (info tier)
echo -n "Testing warning deduplication... "
TESTS_RUN=$((TESTS_RUN + 1))
rm -f "${TMP_DIR}/context-usage-dedup-"*.json
rm -rf "${TMP_DIR}/.ring" 2>/dev/null || true
export CLAUDE_SESSION_ID="dedup-$$"
export CLAUDE_PROJECT_DIR="/tmp"
# Create state file at 51% (just over info threshold)
# Note: without .ring/state, hook uses TMP_DIR directly
cat > "${TMP_DIR}/context-usage-dedup-$$.json" <<EOF
{
"session_id": "dedup-$$",
"turn_count": 22,
"estimated_pct": 51,
"current_tier": "info",
"acknowledged_tier": "info",
"updated_at": "2025-01-01T00:00:00Z"
}
EOF
# Run hook - should NOT show warning since info already acknowledged
output=$(echo '{"prompt": "test"}' | "$HOOK" 2>/dev/null || true)
if echo "$output" | grep -q "additionalContext"; then
echo -e "${RED}FAIL${NC}: Warning shown when tier already acknowledged"
else
echo -e "${GREEN}PASS${NC}: Warning not repeated for same tier"
TESTS_PASSED=$((TESTS_PASSED + 1))
fi
rm -f "${TMP_DIR}/context-usage-dedup-"*.json
# Test 6: Critical always shows
echo -n "Testing critical always shows... "
TESTS_RUN=$((TESTS_RUN + 1))
rm -f "${TMP_DIR}/context-usage-crit-"*.json
rm -rf "${TMP_DIR}/.ring" 2>/dev/null || true
export CLAUDE_SESSION_ID="crit-$$"
export CLAUDE_PROJECT_DIR="/tmp"
# Create state at high turn count (68 turns = ~85%) with critical already acknowledged
# The hook recalculates tier from turn_count, so we need enough turns to trigger critical
# Formula: pct = (45000 + turns*2500) * 100 / 200000
# For 85%: 170000 = 45000 + turns*2500 => turns = 50
# For 86%: 172000 = 45000 + turns*2500 => turns = 50.8 => 51
cat > "${TMP_DIR}/context-usage-crit-$$.json" <<EOF
{
"session_id": "crit-$$",
"turn_count": 50,
"estimated_pct": 85,
"current_tier": "critical",
"acknowledged_tier": "critical",
"updated_at": "2025-01-01T00:00:00Z"
}
EOF
# Run hook - SHOULD show warning even though critical acknowledged
# After this run, turn_count becomes 51, pct stays at 86% (critical)
output=$(echo '{"prompt": "test"}' | "$HOOK" 2>/dev/null || true)
if echo "$output" | grep -q "additionalContext"; then
echo -e "${GREEN}PASS${NC}: Critical warning shown even when acknowledged"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
echo -e "${RED}FAIL${NC}: Critical warning not shown"
fi
rm -f "${TMP_DIR}/context-usage-crit-"*.json
# Test 7: Reset via environment variable
echo -n "Testing reset via env var... "
TESTS_RUN=$((TESTS_RUN + 1))
rm -f "${TMP_DIR}/context-usage-reset-"*.json
rm -rf "${TMP_DIR}/.ring" 2>/dev/null || true
export CLAUDE_SESSION_ID="reset-$$"
export CLAUDE_PROJECT_DIR="/tmp"
# Create state file (without .ring/state, hook uses TMP_DIR directly)
cat > "${TMP_DIR}/context-usage-reset-$$.json" <<EOF
{
"session_id": "reset-$$",
"turn_count": 10,
"estimated_pct": 30,
"current_tier": "none",
"acknowledged_tier": "info",
"updated_at": "2025-01-01T00:00:00Z"
}
EOF
# Run with reset env var - this should delete the state file and start fresh
export RING_RESET_CONTEXT_WARNING=1
echo '{"prompt": "test"}' | "$HOOK" > /dev/null 2>&1
unset RING_RESET_CONTEXT_WARNING
# Check state was reset (turn count should be 1 now)
STATE_FILE="${TMP_DIR}/context-usage-reset-$$.json"
if [[ -f "$STATE_FILE" ]]; then
if command -v jq &>/dev/null; then
count=$(jq -r '.turn_count' "$STATE_FILE")
else
count=$(grep -o '"turn_count":[0-9]*' "$STATE_FILE" | grep -o '[0-9]*')
fi
if [[ "$count" == "1" ]]; then
echo -e "${GREEN}PASS${NC}: State reset, turn count is 1"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
echo -e "${RED}FAIL${NC}: Turn count is $count, expected 1 after reset"
fi
else
echo -e "${RED}FAIL${NC}: State file not found"
fi
rm -f "${TMP_DIR}/context-usage-reset-"*.json
# Summary
echo "========================================"
echo "Tests: $TESTS_PASSED/$TESTS_RUN passed"
if [[ $TESTS_PASSED -eq $TESTS_RUN ]]; then
echo -e "${GREEN}All tests passed!${NC}"
exit 0
else
echo -e "${RED}Some tests failed${NC}"
exit 1
fi

View file

@ -1,13 +0,0 @@
"""Artifact Index - SQLite-based indexing and search for Ring artifacts.
This package provides:
- artifact_index.py: Index markdown files into SQLite with FTS5
- artifact_query.py: Search artifacts with BM25 ranking
- artifact_mark.py: Mark handoff outcomes
- utils.py: Shared utilities (get_project_root, get_db_path, validate_limit)
"""
from utils import get_project_root, get_db_path, validate_limit
__version__ = "1.0.0"
__all__ = ["get_project_root", "get_db_path", "validate_limit"]

View file

@ -1,566 +0,0 @@
#!/usr/bin/env python3
"""
USAGE: artifact_index.py [--handoffs] [--plans] [--continuity] [--all] [--file PATH] [--db PATH] [--project PATH]
Index handoffs, plans, and continuity ledgers into the Artifact Index database.
Examples:
# Index all handoffs in current project
python3 artifact_index.py --handoffs
# Index everything
python3 artifact_index.py --all
# Index a single handoff file (fast, for hooks)
python3 artifact_index.py --file docs/handoffs/session/task-01.md
# Use custom database path
python3 artifact_index.py --all --db /path/to/context.db
# Index from specific project root
python3 artifact_index.py --all --project /path/to/project
"""
import argparse
import hashlib
import json
import os
import re
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Any
from utils import get_project_root, get_db_path
def get_schema_path() -> Path:
"""Get path to schema file relative to this script."""
return Path(__file__).parent / "artifact_schema.sql"
def init_db(db_path: Path) -> sqlite3.Connection:
"""Initialize database with schema."""
conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA cache_size=10000")
schema_path = get_schema_path()
if schema_path.exists():
conn.executescript(schema_path.read_text(encoding='utf-8'))
else:
print(f"Warning: Schema file not found at {schema_path}", file=sys.stderr)
return conn
def parse_frontmatter(content: str) -> Dict[str, str]:
"""Extract YAML frontmatter from markdown content."""
frontmatter: Dict[str, str] = {}
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
for line in parts[1].strip().split("\n"):
if ":" in line:
key, value = line.split(":", 1)
frontmatter[key.strip()] = value.strip().strip('"').strip("'")
return frontmatter
def parse_sections(content: str) -> Dict[str, str]:
"""Extract h2 and h3 sections from markdown content."""
sections: Dict[str, str] = {}
current_section: Optional[str] = None
current_content: List[str] = []
# Skip frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
content = parts[2]
for line in content.split("\n"):
# h2 sections
if line.startswith("## "):
if current_section:
sections[current_section] = "\n".join(current_content).strip()
current_section = line[3:].strip().lower().replace(" ", "_").replace("-", "_")
current_content = []
# h3 sections (nested)
elif line.startswith("### "):
if current_section:
sections[current_section] = "\n".join(current_content).strip()
current_section = line[4:].strip().lower().replace(" ", "_").replace("-", "_")
current_content = []
elif current_section:
current_content.append(line)
if current_section:
sections[current_section] = "\n".join(current_content).strip()
return sections
def extract_files_from_content(content: str) -> List[str]:
"""Extract file paths from markdown content."""
files: List[str] = []
for line in content.split("\n"):
# Match common patterns like "- `path/to/file.py`" or "- `path/to/file.py:123`"
matches = re.findall(r"`([^`]+\.[a-z]+)(:[^`]*)?`", line)
files.extend([m[0] for m in matches])
# Match **File**: format
matches = re.findall(r"\*\*File\*\*:\s*`?([^\s`]+)`?", line)
files.extend(matches)
return list(set(files)) # Deduplicate
def parse_handoff(file_path: Path) -> Dict[str, Any]:
"""Parse a handoff markdown file into structured data."""
content = file_path.read_text(encoding='utf-8')
frontmatter = parse_frontmatter(content)
sections = parse_sections(content)
# Generate ID from file path
file_id = hashlib.md5(str(file_path.resolve()).encode()).hexdigest()[:12]
# Extract session name from path (docs/handoffs/<session>/task-XX.md)
parts = file_path.parts
session_name = ""
if "handoffs" in parts:
idx = parts.index("handoffs")
if idx + 1 < len(parts) and parts[idx + 1] != file_path.name:
session_name = parts[idx + 1]
# Extract task number
task_match = re.search(r"task-(\d+)", file_path.stem)
task_number = int(task_match.group(1)) if task_match else None
# Map status values to canonical outcome values
status = frontmatter.get("status", frontmatter.get("outcome", "UNKNOWN")).upper()
outcome_map = {
"SUCCESS": "SUCCEEDED",
"SUCCEEDED": "SUCCEEDED",
"PARTIAL": "PARTIAL_PLUS",
"PARTIAL_PLUS": "PARTIAL_PLUS",
"PARTIAL_MINUS": "PARTIAL_MINUS",
"FAILED": "FAILED",
"FAILURE": "FAILED",
"UNKNOWN": "UNKNOWN",
}
outcome = outcome_map.get(status, "UNKNOWN")
# Extract content from various section names
task_summary = (
sections.get("what_was_done") or
sections.get("summary") or
sections.get("task_summary") or
""
)[:500]
what_worked = (
sections.get("what_worked") or
sections.get("successes") or
""
)
what_failed = (
sections.get("what_failed") or
sections.get("failures") or
sections.get("issues") or
""
)
key_decisions = (
sections.get("key_decisions") or
sections.get("decisions") or
""
)
files_modified_section = (
sections.get("files_modified") or
sections.get("files_changed") or
sections.get("files") or
""
)
return {
"id": file_id,
"session_name": session_name or frontmatter.get("session", "default"),
"task_number": task_number,
"file_path": str(file_path.resolve()),
"task_summary": task_summary,
"what_worked": what_worked,
"what_failed": what_failed,
"key_decisions": key_decisions,
"files_modified": json.dumps(extract_files_from_content(files_modified_section)),
"outcome": outcome,
"created_at": frontmatter.get("date", frontmatter.get("created", datetime.now().isoformat())),
}
def parse_plan(file_path: Path) -> Dict[str, Any]:
"""Parse a plan markdown file into structured data."""
content = file_path.read_text(encoding='utf-8')
frontmatter = parse_frontmatter(content)
sections = parse_sections(content)
# Generate ID from file path
file_id = hashlib.md5(str(file_path.resolve()).encode()).hexdigest()[:12]
# Extract title from first H1
title_match = re.search(r"^# (.+)$", content, re.MULTILINE)
title = title_match.group(1).strip() if title_match else file_path.stem
# Extract session from path or frontmatter
session_name = frontmatter.get("session", "")
# Extract phases
phases: List[Dict[str, str]] = []
for key, value in sections.items():
if key.startswith("phase") or key.startswith("task"):
phases.append({"name": key, "content": value[:500]})
return {
"id": file_id,
"session_name": session_name,
"title": title,
"file_path": str(file_path.resolve()),
"overview": (sections.get("overview") or sections.get("goal") or "")[:1000],
"approach": (sections.get("implementation_approach") or sections.get("approach") or sections.get("architecture") or "")[:1000],
"phases": json.dumps(phases),
"constraints": sections.get("constraints") or sections.get("what_we're_not_doing") or "",
"created_at": frontmatter.get("date", frontmatter.get("created", datetime.now().isoformat())),
}
def parse_continuity(file_path: Path) -> Dict[str, Any]:
"""Parse a continuity ledger into structured data."""
content = file_path.read_text(encoding='utf-8')
frontmatter = parse_frontmatter(content)
sections = parse_sections(content)
# Generate ID from file path
file_id = hashlib.md5(str(file_path.resolve()).encode()).hexdigest()[:12]
# Extract session name from filename (CONTINUITY-<session>.md or CONTINUITY_CLAUDE-<session>.md)
session_match = re.search(r"CONTINUITY[-_](?:CLAUDE-)?(.+)\.md", file_path.name, re.IGNORECASE)
session_name = session_match.group(1) if session_match else file_path.stem
# Parse state section
state = sections.get("state", "")
state_done: List[str] = []
state_now = ""
state_next = ""
for line in state.split("\n"):
line_lower = line.lower()
if "[x]" in line_lower:
state_done.append(line.strip())
elif "[->]" in line or "now:" in line_lower or "current:" in line_lower:
state_now = line.strip()
elif "[ ]" in line or "next:" in line_lower:
state_next = line.strip()
# Validate snapshot_reason against allowed values
valid_reasons = {'phase_complete', 'session_end', 'milestone', 'manual', 'clear', 'compact'}
raw_reason = frontmatter.get("reason", "manual").lower()
snapshot_reason = raw_reason if raw_reason in valid_reasons else "manual"
return {
"id": file_id,
"session_name": session_name,
"file_path": str(file_path.resolve()),
"goal": (sections.get("goal") or "")[:500],
"state_done": json.dumps(state_done),
"state_now": state_now,
"state_next": state_next,
"key_learnings": sections.get("key_learnings") or sections.get("key_learnings_(this_session)") or "",
"key_decisions": sections.get("key_decisions") or "",
"snapshot_reason": snapshot_reason,
}
def index_handoffs(conn: sqlite3.Connection, project_root: Path) -> int:
"""Index all handoffs into the database."""
# Check multiple possible handoff locations
handoff_paths = [
project_root / "docs" / "handoffs",
project_root / ".ring" / "handoffs",
]
count = 0
for base_path in handoff_paths:
if not base_path.exists():
continue
for handoff_file in base_path.rglob("*.md"):
try:
data = parse_handoff(handoff_file)
conn.execute("""
INSERT OR REPLACE INTO handoffs
(id, session_name, task_number, file_path, task_summary, what_worked,
what_failed, key_decisions, files_modified, outcome, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
data["id"], data["session_name"], data["task_number"], data["file_path"],
data["task_summary"], data["what_worked"], data["what_failed"],
data["key_decisions"], data["files_modified"], data["outcome"],
data["created_at"]
))
count += 1
except Exception as e:
print(f"Error indexing {handoff_file}: {e}", file=sys.stderr)
conn.commit()
if count > 0:
print(f"Indexed {count} handoffs")
return count
def index_plans(conn: sqlite3.Connection, project_root: Path) -> int:
"""Index all plans into the database."""
plans_path = project_root / "docs" / "plans"
if not plans_path.exists():
return 0
count = 0
for plan_file in plans_path.glob("*.md"):
try:
data = parse_plan(plan_file)
conn.execute("""
INSERT OR REPLACE INTO plans
(id, session_name, title, file_path, overview, approach, phases, constraints, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
data["id"], data["session_name"], data["title"], data["file_path"],
data["overview"], data["approach"], data["phases"], data["constraints"],
data["created_at"]
))
count += 1
except Exception as e:
print(f"Error indexing {plan_file}: {e}", file=sys.stderr)
conn.commit()
if count > 0:
print(f"Indexed {count} plans")
return count
def index_continuity(conn: sqlite3.Connection, project_root: Path) -> int:
"""Index all continuity ledgers into the database."""
# Check multiple locations
ledger_paths = [
project_root / ".ring" / "ledgers",
project_root, # Root level CONTINUITY-*.md
]
count = 0
for base_path in ledger_paths:
if not base_path.exists():
continue
pattern = "CONTINUITY*.md" if base_path == project_root else "*.md"
for ledger_file in base_path.glob(pattern):
if not ledger_file.name.upper().startswith("CONTINUITY"):
continue
try:
data = parse_continuity(ledger_file)
conn.execute("""
INSERT OR REPLACE INTO continuity
(id, session_name, file_path, goal, state_done, state_now, state_next,
key_learnings, key_decisions, snapshot_reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
data["id"], data["session_name"], data["file_path"], data["goal"],
data["state_done"], data["state_now"], data["state_next"],
data["key_learnings"], data["key_decisions"], data["snapshot_reason"]
))
count += 1
except Exception as e:
print(f"Error indexing {ledger_file}: {e}", file=sys.stderr)
conn.commit()
if count > 0:
print(f"Indexed {count} continuity ledgers")
return count
def index_single_file(conn: sqlite3.Connection, file_path: Path) -> bool:
"""Index a single file based on its location/type.
Returns True if indexed successfully, False otherwise.
"""
file_path = Path(file_path).resolve()
if not file_path.exists():
print(f"File not found: {file_path}", file=sys.stderr)
return False
if file_path.suffix != ".md":
print(f"Not a markdown file: {file_path}", file=sys.stderr)
return False
path_str = str(file_path).lower()
# Determine type by path
if "handoff" in path_str:
try:
data = parse_handoff(file_path)
conn.execute("""
INSERT OR REPLACE INTO handoffs
(id, session_name, task_number, file_path, task_summary, what_worked,
what_failed, key_decisions, files_modified, outcome, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
data["id"], data["session_name"], data["task_number"], data["file_path"],
data["task_summary"], data["what_worked"], data["what_failed"],
data["key_decisions"], data["files_modified"], data["outcome"],
data["created_at"]
))
conn.commit()
print(f"Indexed handoff: {file_path.name}")
return True
except Exception as e:
print(f"Error indexing handoff {file_path}: {e}", file=sys.stderr)
return False
elif "plan" in path_str:
try:
data = parse_plan(file_path)
conn.execute("""
INSERT OR REPLACE INTO plans
(id, session_name, title, file_path, overview, approach, phases, constraints, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
data["id"], data["session_name"], data["title"], data["file_path"],
data["overview"], data["approach"], data["phases"], data["constraints"],
data["created_at"]
))
conn.commit()
print(f"Indexed plan: {file_path.name}")
return True
except Exception as e:
print(f"Error indexing plan {file_path}: {e}", file=sys.stderr)
return False
elif "continuity" in path_str.lower() or file_path.name.upper().startswith("CONTINUITY"):
try:
data = parse_continuity(file_path)
conn.execute("""
INSERT OR REPLACE INTO continuity
(id, session_name, file_path, goal, state_done, state_now, state_next,
key_learnings, key_decisions, snapshot_reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
data["id"], data["session_name"], data["file_path"], data["goal"],
data["state_done"], data["state_now"], data["state_next"],
data["key_learnings"], data["key_decisions"], data["snapshot_reason"]
))
conn.commit()
print(f"Indexed continuity: {file_path.name}")
return True
except Exception as e:
print(f"Error indexing continuity {file_path}: {e}", file=sys.stderr)
return False
else:
print(f"Unknown file type (not in handoffs/, plans/, or continuity path): {file_path}", file=sys.stderr)
return False
def rebuild_fts_indexes(conn: sqlite3.Connection) -> None:
"""Rebuild FTS5 indexes and optimize for query performance."""
print("Rebuilding FTS5 indexes...")
try:
conn.execute("INSERT INTO handoffs_fts(handoffs_fts) VALUES('rebuild')")
conn.execute("INSERT INTO plans_fts(plans_fts) VALUES('rebuild')")
conn.execute("INSERT INTO continuity_fts(continuity_fts) VALUES('rebuild')")
conn.execute("INSERT INTO queries_fts(queries_fts) VALUES('rebuild')")
conn.execute("INSERT INTO learnings_fts(learnings_fts) VALUES('rebuild')")
# Configure BM25 column weights
conn.execute("INSERT OR REPLACE INTO handoffs_fts(handoffs_fts, rank) VALUES('rank', 'bm25(10.0, 5.0, 3.0, 3.0, 1.0)')")
conn.execute("INSERT OR REPLACE INTO plans_fts(plans_fts, rank) VALUES('rank', 'bm25(10.0, 5.0, 3.0, 3.0, 1.0)')")
print("Optimizing indexes...")
conn.execute("INSERT INTO handoffs_fts(handoffs_fts) VALUES('optimize')")
conn.execute("INSERT INTO plans_fts(plans_fts) VALUES('optimize')")
conn.execute("INSERT INTO continuity_fts(continuity_fts) VALUES('optimize')")
conn.execute("INSERT INTO queries_fts(queries_fts) VALUES('optimize')")
conn.execute("INSERT INTO learnings_fts(learnings_fts) VALUES('optimize')")
conn.commit()
except sqlite3.OperationalError as e:
# FTS tables may not exist yet if no data
print(f"Note: FTS optimization skipped ({e})", file=sys.stderr)
def main() -> int:
parser = argparse.ArgumentParser(
description="Index Ring artifacts into SQLite for semantic search",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument("--handoffs", action="store_true", help="Index handoffs")
parser.add_argument("--plans", action="store_true", help="Index plans")
parser.add_argument("--continuity", action="store_true", help="Index continuity ledgers")
parser.add_argument("--all", action="store_true", help="Index everything")
parser.add_argument("--file", type=str, metavar="PATH", help="Index a single file (fast, for hooks)")
parser.add_argument("--db", type=str, metavar="PATH", help="Custom database path")
parser.add_argument("--project", type=str, metavar="PATH", help="Project root path (default: auto-detect)")
args = parser.parse_args()
project_root = get_project_root(args.project)
# Handle single file indexing (fast path for hooks)
if args.file:
file_path = Path(args.file)
if not file_path.is_absolute():
file_path = project_root / file_path
if not file_path.exists():
print(f"File not found: {file_path}", file=sys.stderr)
return 1
db_path = get_db_path(args.db, project_root)
conn = init_db(db_path)
success = index_single_file(conn, file_path)
conn.close()
return 0 if success else 1
if not any([args.handoffs, args.plans, args.continuity, args.all]):
parser.print_help()
return 0
db_path = get_db_path(args.db, project_root)
conn = init_db(db_path)
print(f"Using database: {db_path}")
print(f"Project root: {project_root}")
total = 0
if args.all or args.handoffs:
total += index_handoffs(conn, project_root)
if args.all or args.plans:
total += index_plans(conn, project_root)
if args.all or args.continuity:
total += index_continuity(conn, project_root)
if total > 0:
rebuild_fts_indexes(conn)
conn.close()
print(f"Done! Indexed {total} total artifacts.")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,185 +0,0 @@
#!/usr/bin/env python3
"""
USAGE: artifact_mark.py --handoff ID --outcome OUTCOME [--notes NOTES] [--db PATH]
Mark a handoff with user outcome in the Artifact Index database.
This updates the handoff's outcome and sets confidence to HIGH to indicate
user verification. Used for improving future session recommendations.
Examples:
# Mark a handoff as succeeded
python3 artifact_mark.py --handoff abc123 --outcome SUCCEEDED
# Mark with additional notes
python3 artifact_mark.py --handoff abc123 --outcome PARTIAL_PLUS --notes "Almost done, one test failing"
# List recent handoffs to find IDs
python3 artifact_mark.py --list
# Mark by file path instead of ID
python3 artifact_mark.py --file docs/handoffs/session/task-01.md --outcome SUCCEEDED
"""
import argparse
import hashlib
import sqlite3
import sys
from pathlib import Path
from typing import Optional
from utils import get_project_root, get_db_path, validate_limit
def get_handoff_id_from_file(file_path: Path) -> str:
"""Generate handoff ID from file path (same as indexer)."""
return hashlib.md5(str(file_path.resolve()).encode()).hexdigest()[:12]
def list_recent_handoffs(conn: sqlite3.Connection, limit: int = 10) -> None:
"""List recent handoffs."""
cursor = conn.execute("""
SELECT id, session_name, task_number, task_summary, outcome, created_at
FROM handoffs
ORDER BY created_at DESC
LIMIT ?
""", (limit,))
print("## Recent Handoffs")
print()
for row in cursor.fetchall():
handoff_id, session, task, summary, outcome, created = row
task_str = f"task-{task}" if task else "unknown"
summary_short = (summary or "")[:50]
outcome_str = outcome or "UNKNOWN"
print(f"- **{handoff_id}**: {session}/{task_str}")
print(f" Outcome: {outcome_str} | {summary_short}...")
print()
def main() -> int:
parser = argparse.ArgumentParser(
description="Mark handoff outcome in Artifact Index",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument(
"--handoff", "-H",
type=str,
metavar="ID",
help="Handoff ID to mark"
)
parser.add_argument(
"--file", "-f",
type=str,
metavar="PATH",
help="Handoff file path (alternative to --handoff)"
)
parser.add_argument(
"--outcome", "-o",
choices=["SUCCEEDED", "PARTIAL_PLUS", "PARTIAL_MINUS", "FAILED"],
help="Outcome of the handoff"
)
parser.add_argument(
"--notes", "-n",
default="",
help="Optional notes about the outcome"
)
parser.add_argument(
"--db",
type=str,
metavar="PATH",
help="Custom database path"
)
parser.add_argument(
"--list", "-l",
action="store_true",
help="List recent handoffs"
)
parser.add_argument(
"--limit",
type=validate_limit,
default=10,
help="Limit for --list (1-100, default: 10)"
)
args = parser.parse_args()
# Check mutual exclusivity of --handoff and --file
if args.handoff and args.file:
print("Error: Cannot use both --handoff and --file. Choose one.", file=sys.stderr)
return 1
db_path = get_db_path(args.db)
if not db_path.exists():
print(f"Error: Database not found: {db_path}", file=sys.stderr)
print("Run: python3 artifact_index.py --all", file=sys.stderr)
return 1
conn = sqlite3.connect(str(db_path), timeout=30.0)
# List mode
if args.list:
list_recent_handoffs(conn, args.limit)
conn.close()
return 0
# Get handoff ID
handoff_id = args.handoff
if args.file:
file_path = Path(args.file)
if not file_path.is_absolute():
file_path = get_project_root() / file_path
handoff_id = get_handoff_id_from_file(file_path)
if not handoff_id or not args.outcome:
parser.print_help()
conn.close()
return 0
# First, check if handoff exists
cursor = conn.execute(
"SELECT id, session_name, task_number, task_summary FROM handoffs WHERE id = ?",
(handoff_id,)
)
handoff = cursor.fetchone()
if not handoff:
print(f"Error: Handoff not found: {handoff_id}", file=sys.stderr)
print("\nRecent handoffs:", file=sys.stderr)
list_recent_handoffs(conn, 5)
conn.close()
return 1
# Update the handoff
cursor = conn.execute(
"UPDATE handoffs SET outcome = ?, outcome_notes = ?, confidence = 'HIGH' WHERE id = ?",
(args.outcome, args.notes, handoff_id)
)
if cursor.rowcount == 0:
print(f"Error: Failed to update handoff: {handoff_id}", file=sys.stderr)
conn.close()
return 1
conn.commit()
# Show confirmation
handoff_id_db, session, task_num, summary = handoff
print(f"[OK] Marked handoff {handoff_id} as {args.outcome}")
print(f" Session: {session}")
if task_num:
print(f" Task: task-{task_num}")
if summary:
print(f" Summary: {summary[:80]}...")
if args.notes:
print(f" Notes: {args.notes}")
print(f" Confidence: HIGH (user-verified)")
conn.close()
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,588 +0,0 @@
#!/usr/bin/env python3
"""
USAGE: artifact_query.py <query> [--type TYPE] [--outcome OUTCOME] [--limit N] [--db PATH] [--json]
Search the Artifact Index for relevant precedent using FTS5 full-text search.
Examples:
# Search for authentication-related work
python3 artifact_query.py "authentication OAuth JWT"
# Search only successful handoffs
python3 artifact_query.py "implement agent" --outcome SUCCEEDED
# Search plans only
python3 artifact_query.py "API design" --type plans
# Output as JSON for programmatic use
python3 artifact_query.py "context management" --json
# Limit results
python3 artifact_query.py "testing" --limit 3
"""
import argparse
import hashlib
import json
import sqlite3
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from utils import get_project_root, get_db_path, validate_limit
def escape_fts5_query(query: str) -> str:
"""Escape FTS5 query to prevent syntax errors.
Splits query into words and joins with OR for flexible matching.
Each word is quoted to handle special characters.
"""
words = query.split()
# Quote each word, escaping internal quotes
quoted_words = [f'"{w.replace(chr(34), chr(34)+chr(34))}"' for w in words if w]
# Join with OR for flexible matching
return " OR ".join(quoted_words) if quoted_words else '""'
def search_handoffs(
conn: sqlite3.Connection,
query: str,
outcome: Optional[str] = None,
limit: int = 5
) -> List[Dict[str, Any]]:
"""Search handoffs using FTS5 with BM25 ranking."""
sql = """
SELECT h.id, h.session_name, h.task_number, h.task_summary,
h.what_worked, h.what_failed, h.key_decisions,
h.outcome, h.file_path, h.created_at,
handoffs_fts.rank as score
FROM handoffs_fts
JOIN handoffs h ON handoffs_fts.rowid = h.rowid
WHERE handoffs_fts MATCH ?
"""
params: List[Any] = [escape_fts5_query(query)]
if outcome:
sql += " AND h.outcome = ?"
params.append(outcome)
sql += " ORDER BY rank LIMIT ?"
params.append(limit)
try:
cursor = conn.execute(sql, params)
columns = [desc[0] for desc in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
except sqlite3.OperationalError:
# FTS table may be empty
return []
def query_for_planning(conn: sqlite3.Connection, topic: str, limit: int = 5) -> Dict[str, Any]:
"""Query artifact index for planning context.
Returns structured data optimized for plan generation:
- Successful implementations (what worked)
- Failed implementations (what to avoid)
- Relevant past plans
Performance target: <200ms total query time.
"""
start_time = time.time()
results: Dict[str, Any] = {
"topic": topic,
"successful_handoffs": [],
"failed_handoffs": [],
"relevant_plans": [],
"query_time_ms": 0,
"is_empty_index": False
}
# Check if index has any data
try:
cursor = conn.execute("SELECT COUNT(*) FROM handoffs")
total_handoffs = cursor.fetchone()[0]
except sqlite3.OperationalError:
# Treat as empty index if schema is corrupted or table doesn't exist
results["is_empty_index"] = True
results["query_time_ms"] = int((time.time() - start_time) * 1000)
return results
if total_handoffs == 0:
results["is_empty_index"] = True
results["query_time_ms"] = int((time.time() - start_time) * 1000)
return results
# Query success and failure categories separately to ensure representation
# This prevents successful handoffs from crowding out failures in results
# Get successful handoffs (SUCCEEDED and PARTIAL_PLUS)
succeeded = search_handoffs(conn, topic, outcome="SUCCEEDED", limit=limit)
partial_plus = search_handoffs(conn, topic, outcome="PARTIAL_PLUS", limit=limit)
results["successful_handoffs"] = (succeeded + partial_plus)[:limit]
# Get failed handoffs (FAILED and PARTIAL_MINUS)
failed = search_handoffs(conn, topic, outcome="FAILED", limit=limit)
partial_minus = search_handoffs(conn, topic, outcome="PARTIAL_MINUS", limit=limit)
results["failed_handoffs"] = (failed + partial_minus)[:limit]
# Query relevant plans (capped at 3 for context size, limit controls handoffs only)
results["relevant_plans"] = search_plans(conn, topic, limit=min(limit, 3))
results["query_time_ms"] = int((time.time() - start_time) * 1000)
return results
def format_planning_results(results: Dict[str, Any]) -> str:
"""Format planning query results for agent consumption."""
output: List[str] = []
output.append("## Historical Precedent")
output.append("")
if results.get("is_empty_index"):
output.append("**Note:** No historical data available (new project or empty index).")
output.append("Proceed with standard planning approach.")
output.append("")
return "\n".join(output)
# Successful implementations
successful = results.get("successful_handoffs", [])
if successful:
output.append("### Successful Implementations (Reference These)")
output.append("")
for h in successful:
session = h.get('session_name', 'unknown')
task = h.get('task_number', '?')
outcome = h.get('outcome', 'UNKNOWN')
output.append(f"**[{session}/task-{task}]** ({outcome})")
summary = h.get('task_summary', '')
if summary:
output.append(f"- Summary: {summary[:200]}")
what_worked = h.get('what_worked', '')
if what_worked:
output.append(f"- What worked: {what_worked[:300]}")
output.append(f"- File: `{h.get('file_path', 'unknown')}`")
output.append("")
else:
output.append("### Successful Implementations")
output.append("No relevant successful implementations found.")
output.append("")
# Failed implementations
failed = results.get("failed_handoffs", [])
if failed:
output.append("### Failed Implementations (AVOID These Patterns)")
output.append("")
for h in failed:
session = h.get('session_name', 'unknown')
task = h.get('task_number', '?')
outcome = h.get('outcome', 'UNKNOWN')
output.append(f"**[{session}/task-{task}]** ({outcome})")
summary = h.get('task_summary', '')
if summary:
output.append(f"- Summary: {summary[:200]}")
what_failed = h.get('what_failed', '')
if what_failed:
output.append(f"- What failed: {what_failed[:300]}")
output.append(f"- File: `{h.get('file_path', 'unknown')}`")
output.append("")
else:
output.append("### Failed Implementations")
output.append("No relevant failures found (good sign!).")
output.append("")
# Relevant plans
plans = results.get("relevant_plans", [])
if plans:
output.append("### Relevant Past Plans")
output.append("")
for p in plans:
title = p.get('title', 'Untitled')
output.append(f"**{title}**")
overview = p.get('overview', '')
if overview:
output.append(f"- Overview: {overview[:200]}")
output.append(f"- File: `{p.get('file_path', 'unknown')}`")
output.append("")
query_time = results.get("query_time_ms", 0)
output.append("---")
output.append(f"*Query completed in {query_time}ms*")
return "\n".join(output)
def search_plans(
conn: sqlite3.Connection,
query: str,
limit: int = 3
) -> List[Dict[str, Any]]:
"""Search plans using FTS5 with BM25 ranking."""
sql = """
SELECT p.id, p.title, p.overview, p.approach, p.file_path, p.created_at,
plans_fts.rank as score
FROM plans_fts
JOIN plans p ON plans_fts.rowid = p.rowid
WHERE plans_fts MATCH ?
ORDER BY rank
LIMIT ?
"""
try:
cursor = conn.execute(sql, [escape_fts5_query(query), limit])
columns = [desc[0] for desc in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
except sqlite3.OperationalError:
return []
def search_continuity(
conn: sqlite3.Connection,
query: str,
limit: int = 3
) -> List[Dict[str, Any]]:
"""Search continuity ledgers using FTS5 with BM25 ranking."""
sql = """
SELECT c.id, c.session_name, c.goal, c.key_learnings, c.key_decisions,
c.state_now, c.created_at, c.file_path,
continuity_fts.rank as score
FROM continuity_fts
JOIN continuity c ON continuity_fts.rowid = c.rowid
WHERE continuity_fts MATCH ?
ORDER BY rank
LIMIT ?
"""
try:
cursor = conn.execute(sql, [escape_fts5_query(query), limit])
columns = [desc[0] for desc in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
except sqlite3.OperationalError:
return []
def search_past_queries(
conn: sqlite3.Connection,
query: str,
limit: int = 2
) -> List[Dict[str, Any]]:
"""Check if similar questions have been asked before (compound learning)."""
sql = """
SELECT q.id, q.question, q.answer, q.was_helpful, q.created_at,
queries_fts.rank as score
FROM queries_fts
JOIN queries q ON queries_fts.rowid = q.rowid
WHERE queries_fts MATCH ?
ORDER BY rank
LIMIT ?
"""
try:
cursor = conn.execute(sql, [escape_fts5_query(query), limit])
columns = [desc[0] for desc in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
except sqlite3.OperationalError:
return []
def get_handoff_by_id(conn: sqlite3.Connection, handoff_id: str) -> Optional[Dict[str, Any]]:
"""Get a handoff by its ID."""
sql = """
SELECT id, session_name, task_number, task_summary,
what_worked, what_failed, key_decisions,
outcome, file_path, created_at
FROM handoffs
WHERE id = ?
LIMIT 1
"""
cursor = conn.execute(sql, [handoff_id])
columns = [desc[0] for desc in cursor.description]
row = cursor.fetchone()
if row:
return dict(zip(columns, row))
return None
def save_query(conn: sqlite3.Connection, question: str, answer: str, matches: Dict[str, List]) -> None:
"""Save query for compound learning."""
query_id = hashlib.md5(f"{question}{datetime.now().isoformat()}".encode()).hexdigest()[:12]
conn.execute("""
INSERT INTO queries (id, question, answer, handoffs_matched, plans_matched, continuity_matched)
VALUES (?, ?, ?, ?, ?, ?)
""", (
query_id,
question,
answer,
json.dumps([h["id"] for h in matches.get("handoffs", [])]),
json.dumps([p["id"] for p in matches.get("plans", [])]),
json.dumps([c["id"] for c in matches.get("continuity", [])]),
))
conn.commit()
def format_results(results: Dict[str, List], verbose: bool = False) -> str:
"""Format search results for human-readable display."""
output: List[str] = []
# Past queries (compound learning)
if results.get("past_queries"):
output.append("## Previously Asked")
for q in results["past_queries"]:
question = q.get('question', '')[:100]
answer = q.get('answer', '')[:200]
output.append(f"- **Q:** {question}...")
output.append(f" **A:** {answer}...")
output.append("")
# Handoffs
if results.get("handoffs"):
output.append("## Relevant Handoffs")
for h in results["handoffs"]:
status_icon = {
"SUCCEEDED": "[OK]",
"PARTIAL_PLUS": "[~+]",
"PARTIAL_MINUS": "[~-]",
"FAILED": "[X]",
"UNKNOWN": "[?]"
}.get(h.get("outcome", "UNKNOWN"), "[?]")
session = h.get('session_name', 'unknown')
task = h.get('task_number', '?')
output.append(f"### {status_icon} {session}/task-{task}")
summary = h.get('task_summary', '')[:200]
if summary:
output.append(f"**Summary:** {summary}")
what_worked = h.get("what_worked", "")
if what_worked:
output.append(f"**What worked:** {what_worked[:200]}")
what_failed = h.get("what_failed", "")
if what_failed:
output.append(f"**What failed:** {what_failed[:200]}")
output.append(f"**File:** `{h.get('file_path', '')}`")
output.append("")
# Plans
if results.get("plans"):
output.append("## Relevant Plans")
for p in results["plans"]:
title = p.get('title', 'Untitled')
output.append(f"### {title}")
overview = p.get('overview', '')[:200]
if overview:
output.append(f"**Overview:** {overview}")
output.append(f"**File:** `{p.get('file_path', '')}`")
output.append("")
# Continuity
if results.get("continuity"):
output.append("## Related Sessions")
for c in results["continuity"]:
session = c.get('session_name', 'unknown')
output.append(f"### Session: {session}")
goal = c.get('goal', '')[:200]
if goal:
output.append(f"**Goal:** {goal}")
key_learnings = c.get("key_learnings", "")
if key_learnings:
output.append(f"**Key learnings:** {key_learnings[:200]}")
output.append(f"**File:** `{c.get('file_path', '')}`")
output.append("")
if not any(results.values()):
output.append("No relevant precedent found.")
return "\n".join(output)
def main() -> int:
parser = argparse.ArgumentParser(
description="Search the Artifact Index for relevant precedent",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument("query", nargs="*", help="Search query")
parser.add_argument(
"--mode", "-m",
choices=["search", "planning"],
default="search",
help="Query mode: 'search' for general queries, 'planning' for structured planning context"
)
parser.add_argument(
"--type", "-t",
choices=["handoffs", "plans", "continuity", "all"],
default="all",
help="Type of artifacts to search (default: all)"
)
parser.add_argument(
"--outcome", "-o",
choices=["SUCCEEDED", "PARTIAL_PLUS", "PARTIAL_MINUS", "FAILED"],
help="Filter handoffs by outcome"
)
parser.add_argument(
"--limit", "-l",
type=validate_limit,
default=5,
help="Maximum results per category (1-100, default: 5)"
)
parser.add_argument(
"--db",
type=str,
metavar="PATH",
help="Custom database path"
)
parser.add_argument(
"--no-save",
action="store_true",
help="Disable automatic query saving (saving enabled by default)"
)
parser.add_argument(
"--json", "-j",
action="store_true",
help="Output as JSON"
)
parser.add_argument(
"--id",
type=str,
metavar="ID",
help="Get specific handoff by ID"
)
parser.add_argument(
"--stats",
action="store_true",
help="Show database statistics"
)
args = parser.parse_args()
db_path = get_db_path(args.db)
# Graceful handling for missing database in planning mode
if not db_path.exists():
if args.mode == "planning":
# Planning mode: return empty index result (normal for new projects)
query = " ".join(args.query) if args.query else ""
results = {
"topic": query,
"successful_handoffs": [],
"failed_handoffs": [],
"relevant_plans": [],
"query_time_ms": 0,
"is_empty_index": True,
"message": "No artifact index found. This is normal for new projects."
}
if args.json:
print(json.dumps(results, indent=2, default=str))
else:
print(format_planning_results(results))
return 0
else:
print(f"Database not found: {db_path}", file=sys.stderr)
print("Run: python3 artifact_index.py --all", file=sys.stderr)
return 1
conn = sqlite3.connect(str(db_path), timeout=30.0)
# Stats mode
if args.stats:
stats = {
"handoffs": conn.execute("SELECT COUNT(*) FROM handoffs").fetchone()[0],
"plans": conn.execute("SELECT COUNT(*) FROM plans").fetchone()[0],
"continuity": conn.execute("SELECT COUNT(*) FROM continuity").fetchone()[0],
"queries": conn.execute("SELECT COUNT(*) FROM queries").fetchone()[0],
}
if args.json:
print(json.dumps(stats, indent=2))
else:
print("## Artifact Index Statistics")
print(f"- Handoffs: {stats['handoffs']}")
print(f"- Plans: {stats['plans']}")
print(f"- Continuity ledgers: {stats['continuity']}")
print(f"- Saved queries: {stats['queries']}")
conn.close()
return 0
# ID lookup mode
if args.id:
handoff = get_handoff_by_id(conn, args.id)
if args.json:
print(json.dumps(handoff, indent=2, default=str))
elif handoff:
print(f"## Handoff: {handoff.get('session_name')}/task-{handoff.get('task_number')}")
print(f"**Outcome:** {handoff.get('outcome', 'UNKNOWN')}")
print(f"**Summary:** {handoff.get('task_summary', '')}")
print(f"**File:** {handoff.get('file_path')}")
else:
print(f"Handoff not found: {args.id}", file=sys.stderr)
conn.close()
return 0 if handoff else 1
# Planning mode - structured output for plan generation
if args.mode == "planning":
if not args.query:
print("Error: Query required for planning mode", file=sys.stderr)
print("Usage: python3 artifact_query.py --mode planning 'topic keywords'", file=sys.stderr)
conn.close()
return 1
query = " ".join(args.query)
results = query_for_planning(conn, query, args.limit)
conn.close()
if args.json:
print(json.dumps(results, indent=2, default=str))
else:
print(format_planning_results(results))
return 0
# Regular search mode
if not args.query:
parser.print_help()
conn.close()
return 0
query = " ".join(args.query)
results: Dict[str, List] = {}
# Always check past queries first
results["past_queries"] = search_past_queries(conn, query)
if args.type in ["handoffs", "all"]:
results["handoffs"] = search_handoffs(conn, query, args.outcome, args.limit)
if args.type in ["plans", "all"]:
results["plans"] = search_plans(conn, query, args.limit)
if args.type in ["continuity", "all"]:
results["continuity"] = search_continuity(conn, query, args.limit)
if args.json:
formatted = json.dumps(results, indent=2, default=str)
print(formatted)
else:
formatted = format_results(results)
print(formatted)
# Auto-save queries for compound learning (unless --no-save)
if not args.no_save:
save_query(conn, query, formatted, results)
if not args.json:
print("\n[Query saved for compound learning]")
conn.close()
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,342 +0,0 @@
-- Ring Artifact Index Schema
-- Database location: $PROJECT_ROOT/.ring/cache/artifact-index/context.db
--
-- This schema supports indexing and querying Ring session artifacts:
-- - Handoffs (completed tasks with post-mortems)
-- - Plans (design documents)
-- - Continuity ledgers (session state snapshots)
-- - Queries (compound learning from Q&A)
--
-- FTS5 is used for full-text search with porter stemming.
-- Triggers keep FTS5 indexes in sync with main tables.
-- Adapted from Continuous-Claude-v2 for Ring plugin architecture.
-- Enable WAL mode for better concurrent read performance
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
-- ============================================================================
-- MAIN TABLES
-- ============================================================================
-- Handoffs (completed tasks with post-mortems)
CREATE TABLE IF NOT EXISTS handoffs (
id TEXT PRIMARY KEY,
session_name TEXT NOT NULL,
task_number INTEGER,
file_path TEXT NOT NULL,
-- Core content
task_summary TEXT,
what_worked TEXT,
what_failed TEXT,
key_decisions TEXT,
files_modified TEXT, -- JSON array
-- Outcome (from user annotation)
outcome TEXT CHECK(outcome IN ('SUCCEEDED', 'PARTIAL_PLUS', 'PARTIAL_MINUS', 'FAILED', 'UNKNOWN')),
outcome_notes TEXT,
confidence TEXT CHECK(confidence IN ('HIGH', 'INFERRED')) DEFAULT 'INFERRED',
-- Trace correlation (for debugging and analysis)
session_id TEXT, -- Claude session ID
trace_id TEXT, -- Optional external trace ID
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Plans (design documents)
CREATE TABLE IF NOT EXISTS plans (
id TEXT PRIMARY KEY,
session_name TEXT,
title TEXT NOT NULL,
file_path TEXT NOT NULL,
-- Content
overview TEXT,
approach TEXT,
phases TEXT, -- JSON array
constraints TEXT,
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Continuity snapshots (session state at key moments)
CREATE TABLE IF NOT EXISTS continuity (
id TEXT PRIMARY KEY,
session_name TEXT NOT NULL,
file_path TEXT,
-- State
goal TEXT,
state_done TEXT, -- JSON array
state_now TEXT,
state_next TEXT,
key_learnings TEXT,
key_decisions TEXT,
-- Context
snapshot_reason TEXT CHECK(snapshot_reason IN ('phase_complete', 'session_end', 'milestone', 'manual', 'clear', 'compact')),
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Queries (compound learning from Q&A)
CREATE TABLE IF NOT EXISTS queries (
id TEXT PRIMARY KEY,
question TEXT NOT NULL,
answer TEXT NOT NULL,
-- Matches
handoffs_matched TEXT, -- JSON array of IDs
plans_matched TEXT,
continuity_matched TEXT,
-- Feedback
was_helpful BOOLEAN,
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Learnings (extracted patterns from successful/failed sessions)
CREATE TABLE IF NOT EXISTS learnings (
id TEXT PRIMARY KEY,
pattern_type TEXT CHECK(pattern_type IN ('success', 'failure', 'efficiency', 'antipattern')),
-- Content
description TEXT NOT NULL,
context TEXT, -- When this applies
recommendation TEXT, -- What to do
-- Source tracking
source_handoffs TEXT, -- JSON array of handoff IDs
occurrence_count INTEGER DEFAULT 1,
-- Status
promoted_to TEXT, -- 'rule', 'skill', 'hook', or NULL
promoted_at TIMESTAMP,
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================================
-- FTS5 INDEXES (Full-Text Search)
-- ============================================================================
-- FTS5 indexes for full-text search
-- Using porter tokenizer for stemming + prefix index for code identifiers
CREATE VIRTUAL TABLE IF NOT EXISTS handoffs_fts USING fts5(
task_summary, what_worked, what_failed, key_decisions, files_modified,
content='handoffs', content_rowid='rowid',
tokenize='porter ascii',
prefix='2 3'
);
CREATE VIRTUAL TABLE IF NOT EXISTS plans_fts USING fts5(
title, overview, approach, phases, constraints,
content='plans', content_rowid='rowid',
tokenize='porter ascii',
prefix='2 3'
);
CREATE VIRTUAL TABLE IF NOT EXISTS continuity_fts USING fts5(
goal, key_learnings, key_decisions, state_now,
content='continuity', content_rowid='rowid',
tokenize='porter ascii'
);
CREATE VIRTUAL TABLE IF NOT EXISTS queries_fts USING fts5(
question, answer,
content='queries', content_rowid='rowid',
tokenize='porter ascii'
);
CREATE VIRTUAL TABLE IF NOT EXISTS learnings_fts USING fts5(
description, context, recommendation,
content='learnings', content_rowid='rowid',
tokenize='porter ascii'
);
-- ============================================================================
-- SYNC TRIGGERS (Keep FTS5 in sync with main tables)
-- ============================================================================
-- HANDOFFS triggers
CREATE TRIGGER IF NOT EXISTS handoffs_ai AFTER INSERT ON handoffs BEGIN
INSERT INTO handoffs_fts(rowid, task_summary, what_worked, what_failed, key_decisions, files_modified)
VALUES (NEW.rowid, NEW.task_summary, NEW.what_worked, NEW.what_failed, NEW.key_decisions, NEW.files_modified);
END;
CREATE TRIGGER IF NOT EXISTS handoffs_ad AFTER DELETE ON handoffs BEGIN
INSERT INTO handoffs_fts(handoffs_fts, rowid, task_summary, what_worked, what_failed, key_decisions, files_modified)
VALUES('delete', OLD.rowid, OLD.task_summary, OLD.what_worked, OLD.what_failed, OLD.key_decisions, OLD.files_modified);
END;
CREATE TRIGGER IF NOT EXISTS handoffs_au AFTER UPDATE ON handoffs BEGIN
INSERT INTO handoffs_fts(handoffs_fts, rowid, task_summary, what_worked, what_failed, key_decisions, files_modified)
VALUES('delete', OLD.rowid, OLD.task_summary, OLD.what_worked, OLD.what_failed, OLD.key_decisions, OLD.files_modified);
INSERT INTO handoffs_fts(rowid, task_summary, what_worked, what_failed, key_decisions, files_modified)
VALUES (NEW.rowid, NEW.task_summary, NEW.what_worked, NEW.what_failed, NEW.key_decisions, NEW.files_modified);
END;
-- PLANS triggers
CREATE TRIGGER IF NOT EXISTS plans_ai AFTER INSERT ON plans BEGIN
INSERT INTO plans_fts(rowid, title, overview, approach, phases, constraints)
VALUES (NEW.rowid, NEW.title, NEW.overview, NEW.approach, NEW.phases, NEW.constraints);
END;
CREATE TRIGGER IF NOT EXISTS plans_ad AFTER DELETE ON plans BEGIN
INSERT INTO plans_fts(plans_fts, rowid, title, overview, approach, phases, constraints)
VALUES('delete', OLD.rowid, OLD.title, OLD.overview, OLD.approach, OLD.phases, OLD.constraints);
END;
CREATE TRIGGER IF NOT EXISTS plans_au AFTER UPDATE ON plans BEGIN
INSERT INTO plans_fts(plans_fts, rowid, title, overview, approach, phases, constraints)
VALUES('delete', OLD.rowid, OLD.title, OLD.overview, OLD.approach, OLD.phases, OLD.constraints);
INSERT INTO plans_fts(rowid, title, overview, approach, phases, constraints)
VALUES (NEW.rowid, NEW.title, NEW.overview, NEW.approach, NEW.phases, NEW.constraints);
END;
-- CONTINUITY triggers
CREATE TRIGGER IF NOT EXISTS continuity_ai AFTER INSERT ON continuity BEGIN
INSERT INTO continuity_fts(rowid, goal, key_learnings, key_decisions, state_now)
VALUES (NEW.rowid, NEW.goal, NEW.key_learnings, NEW.key_decisions, NEW.state_now);
END;
CREATE TRIGGER IF NOT EXISTS continuity_ad AFTER DELETE ON continuity BEGIN
INSERT INTO continuity_fts(continuity_fts, rowid, goal, key_learnings, key_decisions, state_now)
VALUES('delete', OLD.rowid, OLD.goal, OLD.key_learnings, OLD.key_decisions, OLD.state_now);
END;
CREATE TRIGGER IF NOT EXISTS continuity_au AFTER UPDATE ON continuity BEGIN
INSERT INTO continuity_fts(continuity_fts, rowid, goal, key_learnings, key_decisions, state_now)
VALUES('delete', OLD.rowid, OLD.goal, OLD.key_learnings, OLD.key_decisions, OLD.state_now);
INSERT INTO continuity_fts(rowid, goal, key_learnings, key_decisions, state_now)
VALUES (NEW.rowid, NEW.goal, NEW.key_learnings, NEW.key_decisions, NEW.state_now);
END;
-- QUERIES triggers
CREATE TRIGGER IF NOT EXISTS queries_ai AFTER INSERT ON queries BEGIN
INSERT INTO queries_fts(rowid, question, answer)
VALUES (NEW.rowid, NEW.question, NEW.answer);
END;
CREATE TRIGGER IF NOT EXISTS queries_ad AFTER DELETE ON queries BEGIN
INSERT INTO queries_fts(queries_fts, rowid, question, answer)
VALUES('delete', OLD.rowid, OLD.question, OLD.answer);
END;
CREATE TRIGGER IF NOT EXISTS queries_au AFTER UPDATE ON queries BEGIN
INSERT INTO queries_fts(queries_fts, rowid, question, answer)
VALUES('delete', OLD.rowid, OLD.question, OLD.answer);
INSERT INTO queries_fts(rowid, question, answer)
VALUES (NEW.rowid, NEW.question, NEW.answer);
END;
-- LEARNINGS triggers
CREATE TRIGGER IF NOT EXISTS learnings_ai AFTER INSERT ON learnings BEGIN
INSERT INTO learnings_fts(rowid, description, context, recommendation)
VALUES (NEW.rowid, NEW.description, NEW.context, NEW.recommendation);
END;
CREATE TRIGGER IF NOT EXISTS learnings_ad AFTER DELETE ON learnings BEGIN
INSERT INTO learnings_fts(learnings_fts, rowid, description, context, recommendation)
VALUES('delete', OLD.rowid, OLD.description, OLD.context, OLD.recommendation);
END;
CREATE TRIGGER IF NOT EXISTS learnings_au AFTER UPDATE ON learnings BEGIN
INSERT INTO learnings_fts(learnings_fts, rowid, description, context, recommendation)
VALUES('delete', OLD.rowid, OLD.description, OLD.context, OLD.recommendation);
INSERT INTO learnings_fts(rowid, description, context, recommendation)
VALUES (NEW.rowid, NEW.description, NEW.context, NEW.recommendation);
END;
-- Auto-update updated_at timestamp on learnings modification
CREATE TRIGGER IF NOT EXISTS learnings_update_timestamp
AFTER UPDATE ON learnings
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE learnings SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
-- ============================================================================
-- INDEXES (Performance optimization)
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_handoffs_session ON handoffs(session_name);
CREATE INDEX IF NOT EXISTS idx_handoffs_outcome ON handoffs(outcome);
CREATE INDEX IF NOT EXISTS idx_handoffs_created ON handoffs(created_at);
CREATE INDEX IF NOT EXISTS idx_plans_session ON plans(session_name);
CREATE INDEX IF NOT EXISTS idx_plans_created ON plans(created_at);
CREATE INDEX IF NOT EXISTS idx_continuity_session ON continuity(session_name);
CREATE INDEX IF NOT EXISTS idx_continuity_reason ON continuity(snapshot_reason);
CREATE INDEX IF NOT EXISTS idx_queries_created ON queries(created_at);
CREATE INDEX IF NOT EXISTS idx_learnings_type ON learnings(pattern_type);
CREATE INDEX IF NOT EXISTS idx_learnings_promoted ON learnings(promoted_to);
-- ============================================================================
-- VIEWS (Convenience queries)
-- ============================================================================
-- Recent handoffs with outcomes
CREATE VIEW IF NOT EXISTS recent_handoffs AS
SELECT
id,
session_name,
task_summary,
outcome,
created_at
FROM handoffs
ORDER BY created_at DESC
LIMIT 100;
-- Success patterns (for compound learning)
CREATE VIEW IF NOT EXISTS success_patterns AS
SELECT
what_worked,
GROUP_CONCAT(DISTINCT task_summary, ' | ') as task_summaries,
GROUP_CONCAT(DISTINCT key_decisions, ' | ') as key_decisions_list,
COUNT(*) as occurrence_count
FROM handoffs
WHERE outcome IN ('SUCCEEDED', 'PARTIAL_PLUS')
AND what_worked IS NOT NULL
AND what_worked != ''
GROUP BY what_worked
HAVING COUNT(*) >= 2
ORDER BY occurrence_count DESC;
-- Failure patterns (for compound learning)
CREATE VIEW IF NOT EXISTS failure_patterns AS
SELECT
what_failed,
GROUP_CONCAT(DISTINCT task_summary, ' | ') as task_summaries,
GROUP_CONCAT(DISTINCT outcome_notes, ' | ') as outcome_notes_list,
COUNT(*) as occurrence_count
FROM handoffs
WHERE outcome IN ('FAILED', 'PARTIAL_MINUS')
AND what_failed IS NOT NULL
AND what_failed != ''
GROUP BY what_failed
HAVING COUNT(*) >= 2
ORDER BY occurrence_count DESC;
-- Unpromoted learnings ready for review
CREATE VIEW IF NOT EXISTS pending_learnings AS
SELECT *
FROM learnings
WHERE promoted_to IS NULL
AND occurrence_count >= 3
ORDER BY occurrence_count DESC, created_at DESC;

View file

@ -1 +0,0 @@
# Tests for artifact-index modules

View file

@ -1,892 +0,0 @@
#!/usr/bin/env python3
"""
Comprehensive test suite for artifact-index Python modules.
Tests cover:
- artifact_index.py: parse_frontmatter, parse_sections, parse_handoff, get_project_root
- artifact_query.py: escape_fts5_query, validate_limit, get_project_root
- artifact_mark.py: validate_limit, get_handoff_id_from_file
"""
import argparse
import os
import tempfile
from pathlib import Path
from unittest.mock import patch
import sys
import pytest
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from artifact_index import (
parse_frontmatter,
parse_sections,
parse_handoff,
extract_files_from_content,
get_project_root as index_get_project_root,
)
from artifact_query import (
escape_fts5_query,
validate_limit as query_validate_limit,
get_project_root as query_get_project_root,
)
from artifact_mark import (
validate_limit as mark_validate_limit,
get_project_root as mark_get_project_root,
get_handoff_id_from_file,
)
# =============================================================================
# Tests for parse_frontmatter()
# =============================================================================
class TestParseFrontmatter:
"""Tests for YAML frontmatter extraction from markdown content."""
def test_valid_frontmatter_simple(self):
"""Test extraction of simple key-value pairs from frontmatter."""
content = """---
title: My Document
author: John Doe
status: SUCCESS
---
# Content here
"""
result = parse_frontmatter(content)
assert result["title"] == "My Document"
assert result["author"] == "John Doe"
assert result["status"] == "SUCCESS"
def test_valid_frontmatter_with_quotes(self):
"""Test extraction handles quoted values correctly."""
content = """---
title: "Quoted Title"
description: 'Single quotes'
---
Content
"""
result = parse_frontmatter(content)
assert result["title"] == "Quoted Title"
assert result["description"] == "Single quotes"
def test_missing_frontmatter_no_markers(self):
"""Test returns empty dict when no frontmatter markers exist."""
content = """# Just a markdown document
No frontmatter here.
"""
result = parse_frontmatter(content)
assert result == {}
def test_missing_frontmatter_single_marker(self):
"""Test returns empty dict with only one --- marker."""
content = """---
title: incomplete
# Rest of document
"""
result = parse_frontmatter(content)
assert result == {}
def test_empty_content(self):
"""Test returns empty dict for empty content."""
result = parse_frontmatter("")
assert result == {}
def test_frontmatter_only_dashes(self):
"""Test handles frontmatter with only dashes (empty frontmatter)."""
content = """---
---
# Content
"""
result = parse_frontmatter(content)
assert result == {}
def test_nested_values_are_flattened(self):
"""Test that complex YAML is handled (values with colons)."""
content = """---
url: https://example.com
time: 12:30:00
---
Content
"""
result = parse_frontmatter(content)
# URL should capture everything after first colon
assert result["url"] == "https://example.com"
assert result["time"] == "12:30:00"
def test_frontmatter_with_empty_values(self):
"""Test handles keys with empty values."""
content = """---
title:
author: John
---
Content
"""
result = parse_frontmatter(content)
assert result["title"] == ""
assert result["author"] == "John"
def test_frontmatter_with_spaces_in_values(self):
"""Test preserves spaces in values correctly."""
content = """---
title: My Great Title With Spaces
description: A longer description here
---
Content
"""
result = parse_frontmatter(content)
assert result["title"] == "My Great Title With Spaces"
assert result["description"] == "A longer description here"
def test_frontmatter_trims_whitespace(self):
"""Test that keys and values are trimmed."""
content = """---
title : Padded Value
---
Content
"""
result = parse_frontmatter(content)
assert result["title"] == "Padded Value"
# =============================================================================
# Tests for parse_sections()
# =============================================================================
class TestParseSections:
"""Tests for markdown section extraction by header."""
def test_extract_h2_sections(self):
"""Test extraction of h2 (##) sections."""
content = """## What Was Done
This is the summary of work.
## What Worked
Everything went great.
## What Failed
Nothing failed.
"""
result = parse_sections(content)
assert "what_was_done" in result
assert result["what_was_done"] == "This is the summary of work."
assert "what_worked" in result
assert result["what_worked"] == "Everything went great."
assert "what_failed" in result
assert result["what_failed"] == "Nothing failed."
def test_extract_h3_sections(self):
"""Test extraction of h3 (###) sections."""
content = """### Task Summary
Summary content here.
### Key Decisions
Decision content.
"""
result = parse_sections(content)
assert "task_summary" in result
assert result["task_summary"] == "Summary content here."
assert "key_decisions" in result
assert result["key_decisions"] == "Decision content."
def test_mixed_h2_and_h3_sections(self):
"""Test extraction handles both h2 and h3 sections."""
content = """## Main Section
Main content.
### Sub Section
Sub content.
## Another Main
More content.
"""
result = parse_sections(content)
assert "main_section" in result
assert "sub_section" in result
assert "another_main" in result
def test_empty_sections(self):
"""Test handles sections with no content."""
content = """## Empty Section
## Next Section
Has content.
"""
result = parse_sections(content)
assert result["empty_section"] == ""
assert result["next_section"] == "Has content."
def test_no_sections(self):
"""Test returns empty dict when no sections exist."""
content = """Just plain text.
No headers here.
"""
result = parse_sections(content)
assert result == {}
def test_skips_frontmatter(self):
"""Test frontmatter is skipped before parsing sections."""
content = """---
title: Document
---
## Actual Section
This is content.
"""
result = parse_sections(content)
# Should not have 'title' as a section
assert "title" not in result
assert "actual_section" in result
assert result["actual_section"] == "This is content."
def test_section_name_normalization(self):
"""Test section names are normalized (lowercase, underscores)."""
content = """## What Was Done
Content here.
## Key-Decisions
More content.
"""
result = parse_sections(content)
assert "what_was_done" in result
assert "key_decisions" in result # hyphen becomes underscore
def test_multiline_section_content(self):
"""Test sections preserve multiline content."""
content = """## Description
Line 1
Line 2
Line 3
## Next
"""
result = parse_sections(content)
assert "Line 1" in result["description"]
assert "Line 2" in result["description"]
assert "Line 3" in result["description"]
def test_section_content_is_trimmed(self):
"""Test section content has leading/trailing whitespace trimmed."""
content = """## Section
Content with extra blank lines above.
"""
result = parse_sections(content)
assert result["section"] == "Content with extra blank lines above."
# =============================================================================
# Tests for parse_handoff()
# =============================================================================
class TestParseHandoff:
"""Tests for handoff markdown file parsing."""
def test_valid_handoff_parsing(self):
"""Test parsing a complete handoff file with all fields."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create directory structure: docs/handoffs/test-session/task-01.md
handoff_dir = Path(tmpdir) / "docs" / "handoffs" / "test-session"
handoff_dir.mkdir(parents=True)
handoff_file = handoff_dir / "task-01.md"
content = """---
status: SUCCESS
date: 2024-01-15
---
## What Was Done
Implemented the authentication system.
## What Worked
- JWT token generation
- Session management
## What Failed
Nothing major failed.
## Key Decisions
Used RS256 for signing.
## Files Modified
- `src/auth/jwt.py`
- `src/auth/session.py`
"""
handoff_file.write_text(content)
result = parse_handoff(handoff_file)
assert result["session_name"] == "test-session"
assert result["task_number"] == 1
assert result["outcome"] == "SUCCEEDED" # SUCCESS maps to SUCCEEDED
assert "Implemented the authentication system" in result["task_summary"]
assert "JWT token generation" in result["what_worked"]
assert "Nothing major failed" in result["what_failed"]
assert "RS256" in result["key_decisions"]
assert result["file_path"] == str(handoff_file.resolve())
assert result["id"] # Should have generated an ID
def test_session_name_extraction_from_path(self):
"""Test session name is extracted from directory structure."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create: handoffs/my-feature-branch/task-05.md
handoff_dir = Path(tmpdir) / "handoffs" / "my-feature-branch"
handoff_dir.mkdir(parents=True)
handoff_file = handoff_dir / "task-05.md"
handoff_file.write_text("---\nstatus: SUCCESS\n---\n## What Was Done\nTest")
result = parse_handoff(handoff_file)
assert result["session_name"] == "my-feature-branch"
assert result["task_number"] == 5
def test_task_number_extraction_regex(self):
"""Test task number extraction via regex patterns."""
with tempfile.TemporaryDirectory() as tmpdir:
handoff_dir = Path(tmpdir) / "handoffs" / "session"
handoff_dir.mkdir(parents=True)
# Test various task number patterns
test_cases = [
("task-01.md", 1),
("task-12.md", 12),
("task-099.md", 99),
]
for filename, expected_task_num in test_cases:
handoff_file = handoff_dir / filename
handoff_file.write_text("---\nstatus: SUCCESS\n---\n## What Was Done\nTest")
result = parse_handoff(handoff_file)
assert result["task_number"] == expected_task_num, f"Failed for {filename}"
def test_outcome_mapping_success(self):
"""Test outcome mapping: SUCCESS -> SUCCEEDED."""
with tempfile.TemporaryDirectory() as tmpdir:
handoff_file = Path(tmpdir) / "task-01.md"
handoff_file.write_text("---\nstatus: SUCCESS\n---\n## What Was Done\nTest")
result = parse_handoff(handoff_file)
assert result["outcome"] == "SUCCEEDED"
def test_outcome_mapping_succeeded(self):
"""Test outcome mapping: SUCCEEDED stays SUCCEEDED."""
with tempfile.TemporaryDirectory() as tmpdir:
handoff_file = Path(tmpdir) / "task-01.md"
handoff_file.write_text("---\nstatus: SUCCEEDED\n---\n## What Was Done\nTest")
result = parse_handoff(handoff_file)
assert result["outcome"] == "SUCCEEDED"
def test_outcome_mapping_partial(self):
"""Test outcome mapping: PARTIAL -> PARTIAL_PLUS."""
with tempfile.TemporaryDirectory() as tmpdir:
handoff_file = Path(tmpdir) / "task-01.md"
handoff_file.write_text("---\nstatus: PARTIAL\n---\n## What Was Done\nTest")
result = parse_handoff(handoff_file)
assert result["outcome"] == "PARTIAL_PLUS"
def test_outcome_mapping_failed(self):
"""Test outcome mapping: FAILED stays FAILED."""
with tempfile.TemporaryDirectory() as tmpdir:
handoff_file = Path(tmpdir) / "task-01.md"
handoff_file.write_text("---\nstatus: FAILED\n---\n## What Was Done\nTest")
result = parse_handoff(handoff_file)
assert result["outcome"] == "FAILED"
def test_outcome_mapping_failure(self):
"""Test outcome mapping: FAILURE -> FAILED."""
with tempfile.TemporaryDirectory() as tmpdir:
handoff_file = Path(tmpdir) / "task-01.md"
handoff_file.write_text("---\nstatus: FAILURE\n---\n## What Was Done\nTest")
result = parse_handoff(handoff_file)
assert result["outcome"] == "FAILED"
def test_outcome_mapping_unknown(self):
"""Test outcome mapping: unknown values -> UNKNOWN."""
with tempfile.TemporaryDirectory() as tmpdir:
handoff_file = Path(tmpdir) / "task-01.md"
handoff_file.write_text("---\nstatus: FOOBAR\n---\n## What Was Done\nTest")
result = parse_handoff(handoff_file)
assert result["outcome"] == "UNKNOWN"
def test_missing_frontmatter_uses_defaults(self):
"""Test graceful handling when frontmatter fields are missing."""
with tempfile.TemporaryDirectory() as tmpdir:
handoff_file = Path(tmpdir) / "task-01.md"
handoff_file.write_text("## What Was Done\n\nSome work was done.")
result = parse_handoff(handoff_file)
assert result["outcome"] == "UNKNOWN"
assert result["task_summary"] == "Some work was done."
def test_uses_outcome_field_as_fallback(self):
"""Test uses 'outcome' frontmatter field when 'status' is missing."""
with tempfile.TemporaryDirectory() as tmpdir:
handoff_file = Path(tmpdir) / "task-01.md"
handoff_file.write_text("---\noutcome: SUCCESS\n---\n## What Was Done\nTest")
result = parse_handoff(handoff_file)
assert result["outcome"] == "SUCCEEDED"
def test_alternative_section_names(self):
"""Test alternative section names are recognized."""
with tempfile.TemporaryDirectory() as tmpdir:
handoff_file = Path(tmpdir) / "task-01.md"
content = """---
status: SUCCESS
---
## Summary
Using 'Summary' instead of 'What Was Done'.
## Successes
Instead of 'What Worked'.
## Failures
Instead of 'What Failed'.
## Decisions
Instead of 'Key Decisions'.
"""
handoff_file.write_text(content)
result = parse_handoff(handoff_file)
assert "Using 'Summary'" in result["task_summary"]
assert "Instead of 'What Worked'" in result["what_worked"]
assert "Instead of 'What Failed'" in result["what_failed"]
assert "Instead of 'Key Decisions'" in result["key_decisions"]
# =============================================================================
# Tests for extract_files_from_content()
# =============================================================================
class TestExtractFilesFromContent:
"""Tests for file path extraction from markdown content."""
def test_extract_backtick_file_paths(self):
"""Test extraction of file paths in backticks."""
content = """
- `src/auth/jwt.py`
- `src/models/user.py`
- `tests/test_auth.py`
"""
result = extract_files_from_content(content)
assert "src/auth/jwt.py" in result
assert "src/models/user.py" in result
assert "tests/test_auth.py" in result
def test_extract_file_paths_with_line_numbers(self):
"""Test extraction handles file paths with line numbers."""
content = """
- `src/auth/jwt.py:45`
- `src/models/user.py:123-456`
"""
result = extract_files_from_content(content)
assert "src/auth/jwt.py" in result
assert "src/models/user.py" in result
def test_extract_file_format_style(self):
"""Test extraction of **File**: format."""
content = """
**File**: `src/main.py`
**File**: src/utils.py
"""
result = extract_files_from_content(content)
assert "src/main.py" in result
assert "src/utils.py" in result
def test_deduplicates_results(self):
"""Test duplicate file paths are removed."""
content = """
- `src/auth.py`
- `src/auth.py`
- `src/auth.py:10`
"""
result = extract_files_from_content(content)
assert result.count("src/auth.py") == 1
def test_empty_content(self):
"""Test returns empty list for empty content."""
result = extract_files_from_content("")
assert result == []
# =============================================================================
# Tests for escape_fts5_query()
# =============================================================================
class TestEscapeFts5Query:
"""Tests for FTS5 query escaping."""
def test_basic_word_quoting(self):
"""Test basic words are quoted for FTS5."""
result = escape_fts5_query("hello world")
assert '"hello"' in result
assert '"world"' in result
assert " OR " in result
def test_single_word(self):
"""Test single word is quoted."""
result = escape_fts5_query("authentication")
assert result == '"authentication"'
def test_special_characters_in_query(self):
"""Test special characters are handled."""
result = escape_fts5_query("user@example.com")
assert '"user@example.com"' in result
def test_empty_query(self):
"""Test empty query returns empty quoted string."""
result = escape_fts5_query("")
assert result == '""'
def test_query_with_embedded_quotes(self):
"""Test embedded quotes are escaped (doubled)."""
result = escape_fts5_query('say "hello"')
# Double quotes in FTS5 are escaped by doubling them
assert '""' in result or 'hello' in result
def test_multiple_spaces_between_words(self):
"""Test multiple spaces are handled correctly."""
result = escape_fts5_query("word1 word2")
# Should handle multiple spaces without creating empty words
assert '"word1"' in result
assert '"word2"' in result
def test_or_joining(self):
"""Test words are joined with OR for flexible matching."""
result = escape_fts5_query("auth jwt oauth")
assert result == '"auth" OR "jwt" OR "oauth"'
# =============================================================================
# Tests for validate_limit()
# =============================================================================
class TestValidateLimit:
"""Tests for limit validation in both query and mark modules."""
def test_valid_limit_minimum(self):
"""Test minimum valid limit (1)."""
result = query_validate_limit("1")
assert result == 1
result = mark_validate_limit("1")
assert result == 1
def test_valid_limit_maximum(self):
"""Test maximum valid limit (100)."""
result = query_validate_limit("100")
assert result == 100
result = mark_validate_limit("100")
assert result == 100
def test_valid_limit_middle(self):
"""Test middle range limit."""
result = query_validate_limit("50")
assert result == 50
def test_invalid_limit_below_minimum(self):
"""Test limit below minimum raises error."""
with pytest.raises(argparse.ArgumentTypeError) as exc_info:
query_validate_limit("0")
assert "between 1 and 100" in str(exc_info.value)
def test_invalid_limit_above_maximum(self):
"""Test limit above maximum raises error."""
with pytest.raises(argparse.ArgumentTypeError) as exc_info:
query_validate_limit("101")
assert "between 1 and 100" in str(exc_info.value)
def test_invalid_limit_negative(self):
"""Test negative limit raises error."""
with pytest.raises(argparse.ArgumentTypeError) as exc_info:
query_validate_limit("-5")
assert "between 1 and 100" in str(exc_info.value)
def test_non_numeric_input(self):
"""Test non-numeric input raises error."""
with pytest.raises(argparse.ArgumentTypeError) as exc_info:
query_validate_limit("abc")
assert "Invalid integer value" in str(exc_info.value)
def test_float_input(self):
"""Test float input raises error."""
with pytest.raises(argparse.ArgumentTypeError) as exc_info:
query_validate_limit("5.5")
assert "Invalid integer value" in str(exc_info.value)
def test_empty_string(self):
"""Test empty string raises error."""
with pytest.raises(argparse.ArgumentTypeError) as exc_info:
query_validate_limit("")
assert "Invalid integer value" in str(exc_info.value)
# =============================================================================
# Tests for get_project_root()
# =============================================================================
class TestGetProjectRoot:
"""Tests for project root detection."""
def test_finds_ring_directory(self):
"""Test finding project root via .ring directory."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create .ring directory
ring_dir = Path(tmpdir) / ".ring"
ring_dir.mkdir()
# Create a subdirectory to test from
sub_dir = Path(tmpdir) / "src" / "lib"
sub_dir.mkdir(parents=True)
# Change to subdirectory and test
original_cwd = os.getcwd()
try:
os.chdir(sub_dir)
result = query_get_project_root()
assert result == Path(tmpdir).resolve()
finally:
os.chdir(original_cwd)
def test_finds_git_directory(self):
"""Test finding project root via .git directory."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create .git directory
git_dir = Path(tmpdir) / ".git"
git_dir.mkdir()
# Create a subdirectory to test from
sub_dir = Path(tmpdir) / "deep" / "nested" / "path"
sub_dir.mkdir(parents=True)
original_cwd = os.getcwd()
try:
os.chdir(sub_dir)
result = query_get_project_root()
assert result == Path(tmpdir).resolve()
finally:
os.chdir(original_cwd)
def test_prefers_ring_over_git(self):
"""Test .ring is found before .git (both exist)."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create both .ring and .git
ring_dir = Path(tmpdir) / ".ring"
ring_dir.mkdir()
git_dir = Path(tmpdir) / ".git"
git_dir.mkdir()
original_cwd = os.getcwd()
try:
os.chdir(tmpdir)
result = query_get_project_root()
assert result == Path(tmpdir).resolve()
finally:
os.chdir(original_cwd)
def test_returns_cwd_when_no_marker_found(self):
"""Test returns current directory when no .ring or .git found."""
with tempfile.TemporaryDirectory() as tmpdir:
# Don't create any markers
original_cwd = os.getcwd()
try:
os.chdir(tmpdir)
result = query_get_project_root()
assert result == Path(tmpdir).resolve()
finally:
os.chdir(original_cwd)
def test_index_get_project_root_with_custom_path(self):
"""Test artifact_index.get_project_root with custom path argument."""
with tempfile.TemporaryDirectory() as tmpdir:
custom_path = Path(tmpdir) / "custom" / "path"
custom_path.mkdir(parents=True)
result = index_get_project_root(str(custom_path))
assert result == custom_path.resolve()
def test_index_get_project_root_without_custom_path(self):
"""Test artifact_index.get_project_root without custom path."""
with tempfile.TemporaryDirectory() as tmpdir:
ring_dir = Path(tmpdir) / ".ring"
ring_dir.mkdir()
original_cwd = os.getcwd()
try:
os.chdir(tmpdir)
result = index_get_project_root(None)
assert result == Path(tmpdir).resolve()
finally:
os.chdir(original_cwd)
# =============================================================================
# Tests for get_handoff_id_from_file()
# =============================================================================
class TestGetHandoffIdFromFile:
"""Tests for handoff ID generation from file path."""
def test_generates_consistent_id(self):
"""Test ID generation is deterministic for same path."""
with tempfile.TemporaryDirectory() as tmpdir:
file_path = Path(tmpdir) / "task-01.md"
file_path.touch()
id1 = get_handoff_id_from_file(file_path)
id2 = get_handoff_id_from_file(file_path)
assert id1 == id2
def test_different_paths_different_ids(self):
"""Test different paths generate different IDs."""
with tempfile.TemporaryDirectory() as tmpdir:
file1 = Path(tmpdir) / "task-01.md"
file2 = Path(tmpdir) / "task-02.md"
file1.touch()
file2.touch()
id1 = get_handoff_id_from_file(file1)
id2 = get_handoff_id_from_file(file2)
assert id1 != id2
def test_id_is_12_characters(self):
"""Test ID is exactly 12 characters (truncated MD5)."""
with tempfile.TemporaryDirectory() as tmpdir:
file_path = Path(tmpdir) / "task-01.md"
file_path.touch()
result = get_handoff_id_from_file(file_path)
assert len(result) == 12
def test_id_is_hexadecimal(self):
"""Test ID contains only hexadecimal characters."""
with tempfile.TemporaryDirectory() as tmpdir:
file_path = Path(tmpdir) / "task-01.md"
file_path.touch()
result = get_handoff_id_from_file(file_path)
# All characters should be valid hex
assert all(c in "0123456789abcdef" for c in result)
# =============================================================================
# Integration-like tests
# =============================================================================
class TestIntegration:
"""Integration-like tests combining multiple functions."""
def test_full_handoff_workflow(self):
"""Test complete handoff parsing workflow."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create proper directory structure
handoff_dir = Path(tmpdir) / "docs" / "handoffs" / "feature-auth"
handoff_dir.mkdir(parents=True)
handoff_file = handoff_dir / "task-03.md"
content = """---
status: SUCCESS
date: 2024-01-20
session: feature-auth
---
# Task 03: Implement OAuth Flow
## What Was Done
Implemented OAuth 2.0 authentication flow with Google and GitHub providers.
## What Worked
- Google OAuth integration
- GitHub OAuth integration
- Token refresh mechanism
## What Failed
Minor issues with rate limiting, resolved.
## Key Decisions
- Used passport.js for OAuth abstraction
- Stored tokens in Redis for session management
## Files Modified
- `src/auth/oauth.ts`
- `src/auth/providers/google.ts`
- `src/auth/providers/github.ts`
- `src/middleware/auth.ts`
"""
handoff_file.write_text(content)
# Parse the handoff
result = parse_handoff(handoff_file)
# Verify all fields
assert result["session_name"] == "feature-auth"
assert result["task_number"] == 3
assert result["outcome"] == "SUCCEEDED"
assert "OAuth 2.0" in result["task_summary"]
assert "Google OAuth" in result["what_worked"]
assert "rate limiting" in result["what_failed"]
assert "passport.js" in result["key_decisions"]
# Verify files were extracted
import json
files = json.loads(result["files_modified"])
assert "src/auth/oauth.ts" in files
assert len(files) >= 3
def test_handoff_with_minimal_content(self):
"""Test handoff parsing with minimal content."""
with tempfile.TemporaryDirectory() as tmpdir:
handoff_file = Path(tmpdir) / "task-01.md"
handoff_file.write_text("# Minimal handoff")
result = parse_handoff(handoff_file)
# Should not raise, should have defaults
assert result["outcome"] == "UNKNOWN"
assert result["task_summary"] == ""
assert result["id"] # Should still have an ID
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View file

@ -1,63 +0,0 @@
"""Shared utilities for artifact-index modules."""
import argparse
from pathlib import Path
from typing import Optional
def get_project_root(custom_path: Optional[str] = None) -> Path:
"""Get project root by finding .ring or .git directory.
Args:
custom_path: Optional explicit path to use as root
Returns:
Path to project root, or current directory if not found
"""
if custom_path:
return Path(custom_path).resolve()
current = Path.cwd()
for parent in [current] + list(current.parents):
if (parent / ".ring").exists() or (parent / ".git").exists():
return parent
return current
def get_db_path(custom_path: Optional[str] = None, project_root: Optional[Path] = None) -> Path:
"""Get database path, creating parent directory if needed.
Args:
custom_path: Optional explicit database path
project_root: Optional project root (computed if not provided)
Returns:
Path to SQLite database file
"""
if custom_path:
path = Path(custom_path)
else:
root = project_root or get_project_root()
path = root / ".ring" / "cache" / "artifact-index" / "context.db"
path.parent.mkdir(parents=True, exist_ok=True)
return path
def validate_limit(value: str) -> int:
"""Validate limit argument is integer between 1 and 100.
Args:
value: String value from argparse
Returns:
Validated integer
Raises:
argparse.ArgumentTypeError: If value invalid
"""
try:
ivalue = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"Invalid integer value: {value}")
if ivalue < 1 or ivalue > 100:
raise argparse.ArgumentTypeError(f"Limit must be between 1 and 100, got: {ivalue}")
return ivalue

View file

@ -1,72 +0,0 @@
"""Compound Learnings Library - Transform session learnings into permanent capabilities.
This package provides:
- Learning file parsing (learning_parser)
- Pattern detection across sessions (pattern_detector)
- Rule/skill generation from patterns (rule_generator)
"""
from .learning_parser import (
parse_learning_file,
extract_patterns,
extract_what_worked,
extract_what_failed,
extract_key_decisions,
load_all_learnings,
)
from .pattern_detector import (
normalize_pattern,
find_similar_patterns,
aggregate_patterns,
detect_recurring_patterns,
categorize_pattern,
generate_pattern_summary,
similarity_score,
DEFAULT_SIMILARITY_THRESHOLD,
)
from .rule_generator import (
generate_rule_content,
generate_skill_content,
create_rule_file,
create_skill_file,
generate_proposal,
save_proposal,
load_pending_proposals,
approve_proposal,
reject_proposal,
slugify,
RuleGenerationError,
)
__all__ = [
# learning_parser
"parse_learning_file",
"extract_patterns",
"extract_what_worked",
"extract_what_failed",
"extract_key_decisions",
"load_all_learnings",
# pattern_detector
"normalize_pattern",
"find_similar_patterns",
"aggregate_patterns",
"detect_recurring_patterns",
"categorize_pattern",
"generate_pattern_summary",
"similarity_score",
"DEFAULT_SIMILARITY_THRESHOLD",
# rule_generator
"generate_rule_content",
"generate_skill_content",
"create_rule_file",
"create_skill_file",
"generate_proposal",
"save_proposal",
"load_pending_proposals",
"approve_proposal",
"reject_proposal",
"slugify",
"RuleGenerationError",
]

View file

@ -1,152 +0,0 @@
"""Parse learning markdown files into structured data.
This module extracts structured information from session learning files
stored in .ring/cache/learnings/.
"""
import re
from pathlib import Path
from typing import Dict, List, Optional
def _extract_session_id(filename: str) -> str:
"""Extract session ID from filename, expecting YYYY-MM-DD-<session>.md format.
Args:
filename: File stem without extension (e.g., "2025-12-27-abc123")
Returns:
Session ID portion, or empty string if no valid session ID found
"""
parts = filename.split("-")
# Validate date prefix: must have at least 4 parts (YYYY-MM-DD-session)
if len(parts) >= 4:
year, month, day = parts[0], parts[1], parts[2]
# Verify first 3 parts look like a date
if len(year) == 4 and len(month) == 2 and len(day) == 2:
if year.isdigit() and month.isdigit() and day.isdigit():
# Join remaining parts as session ID
return "-".join(parts[3:])
# No hyphen or no valid date prefix - return filename as-is
if "-" not in filename:
return filename
# Has hyphens but not valid date format - return empty
return ""
def parse_learning_file(file_path: Path) -> Dict:
"""Parse a learning markdown file into structured data.
Args:
file_path: Path to the learning markdown file
Returns:
Dictionary with session_id, date, what_worked, what_failed,
key_decisions, and patterns
"""
try:
content = file_path.read_text(encoding='utf-8')
except (UnicodeDecodeError, IOError, PermissionError):
return {
"session_id": "",
"date": "",
"file_path": str(file_path),
"what_worked": [],
"what_failed": [],
"key_decisions": [],
"patterns": [],
"error": "Failed to read file",
}
# Extract session ID from filename: YYYY-MM-DD-<session-id>.md
filename = file_path.stem # e.g., "2025-12-27-abc123"
session_id = _extract_session_id(filename)
# Extract date from content
date_match = re.search(r'\*\*Date:\*\*\s*(.+)', content)
date = date_match.group(1).strip() if date_match else ""
return {
"session_id": session_id,
"date": date,
"file_path": str(file_path),
"what_worked": extract_what_worked(content),
"what_failed": extract_what_failed(content),
"key_decisions": extract_key_decisions(content),
"patterns": extract_patterns(content),
}
def _extract_section_items(content: str, section_name: str) -> List[str]:
"""Extract bullet items from a markdown section.
Args:
content: Full markdown content
section_name: Name of section (e.g., "What Worked")
Returns:
List of bullet point items (without the leading "- ")
"""
# Match section header and capture until next ## or end
pattern = rf'##\s*{re.escape(section_name)}\s*\n(.*?)(?=\n##|\Z)'
match = re.search(pattern, content, re.DOTALL | re.IGNORECASE)
if not match:
return []
section_content = match.group(1)
# Extract bullet points (lines starting with "- ")
items = []
for line in section_content.split('\n'):
line = line.strip()
if line.startswith('- '):
items.append(line[2:].strip())
return items
def extract_what_worked(content: str) -> List[str]:
"""Extract items from 'What Worked' section."""
return _extract_section_items(content, "What Worked")
def extract_what_failed(content: str) -> List[str]:
"""Extract items from 'What Failed' section."""
return _extract_section_items(content, "What Failed")
def extract_key_decisions(content: str) -> List[str]:
"""Extract items from 'Key Decisions' section."""
return _extract_section_items(content, "Key Decisions")
def extract_patterns(content: str) -> List[str]:
"""Extract items from 'Patterns' section."""
return _extract_section_items(content, "Patterns")
def load_all_learnings(learnings_dir: Path) -> List[Dict]:
"""Load all learning files from a directory.
Args:
learnings_dir: Path to .ring/cache/learnings/ directory
Returns:
List of parsed learning dictionaries, sorted by date (newest first)
"""
if not learnings_dir.exists():
return []
learnings = []
for file_path in learnings_dir.glob("*.md"):
try:
learning = parse_learning_file(file_path)
if not learning.get("error"):
learnings.append(learning)
except Exception as e:
# Log but continue processing other files
print(f"Warning: Failed to parse {file_path}: {e}")
# Sort by date (newest first)
learnings.sort(key=lambda x: x.get("date", ""), reverse=True)
return learnings

View file

@ -1,271 +0,0 @@
"""Detect recurring patterns across session learnings.
This module analyzes learning files to find patterns that appear frequently
enough to warrant becoming permanent rules, skills, or hooks.
"""
import re
from typing import Dict, List, Set
from difflib import SequenceMatcher
# Default similarity threshold for pattern matching
DEFAULT_SIMILARITY_THRESHOLD = 0.6
def normalize_pattern(text: str) -> str:
"""Normalize pattern text for comparison.
Args:
text: Raw pattern text
Returns:
Lowercase, whitespace-normalized text
"""
# Lowercase and collapse whitespace
return " ".join(text.lower().split())
def similarity_score(text1: str, text2: str) -> float:
"""Calculate similarity between two strings.
Uses SequenceMatcher for fuzzy matching.
Args:
text1: First string
text2: Second string
Returns:
Similarity score between 0.0 and 1.0
"""
return SequenceMatcher(
None, normalize_pattern(text1), normalize_pattern(text2)
).ratio()
def find_similar_patterns(
pattern: str, patterns: List[str], threshold: float = DEFAULT_SIMILARITY_THRESHOLD
) -> List[str]:
"""Find patterns similar to the given pattern.
Args:
pattern: Pattern to match against
patterns: List of patterns to search
threshold: Minimum similarity score (0.0 to 1.0)
Returns:
List of similar patterns
"""
similar = []
normalized_pattern = normalize_pattern(pattern)
for p in patterns:
if similarity_score(normalized_pattern, p) >= threshold:
similar.append(p)
return similar
def aggregate_patterns(
patterns: List[Dict], threshold: float = DEFAULT_SIMILARITY_THRESHOLD
) -> List[Dict]:
"""Aggregate similar patterns into groups.
Args:
patterns: List of {"text": str, "session": str} dictionaries
threshold: Minimum similarity score for grouping (0.0 to 1.0)
Returns:
List of pattern groups with canonical text and items
"""
if not patterns:
return []
groups: List[Dict] = []
used: Set[int] = set()
for i, p in enumerate(patterns):
if i in used:
continue
# Start a new group with this pattern
group = {
"canonical": p["text"],
"items": [p],
"sessions": {p["session"]},
}
used.add(i)
# Find similar patterns
for j, other in enumerate(patterns):
if j in used:
continue
if similarity_score(p["text"], other["text"]) >= threshold:
group["items"].append(other)
group["sessions"].add(other["session"])
used.add(j)
groups.append(group)
# Sort by number of items (most common first)
groups.sort(key=lambda g: len(g["items"]), reverse=True)
return groups
def detect_recurring_patterns(
learnings: List[Dict], min_occurrences: int = 3
) -> List[Dict]:
"""Detect patterns that appear in multiple sessions.
Args:
learnings: List of parsed learning dictionaries
min_occurrences: Minimum number of sessions for a pattern
Returns:
List of recurring patterns with metadata
"""
# Collect all patterns with their sources
all_patterns: List[Dict] = []
for learning in learnings:
session_id = learning.get("session_id", "unknown")
# Collect from explicit patterns section
for pattern in learning.get("patterns", []):
all_patterns.append({
"text": pattern,
"session": session_id,
"source": "patterns"
})
# Collect from what_worked (success patterns)
for item in learning.get("what_worked", []):
all_patterns.append({
"text": item,
"session": session_id,
"source": "what_worked"
})
# Collect from what_failed (inverted to anti-patterns)
for item in learning.get("what_failed", []):
all_patterns.append({
"text": f"Avoid: {item}",
"session": session_id,
"source": "what_failed"
})
# Aggregate similar patterns
groups = aggregate_patterns(all_patterns)
# Filter to those appearing in min_occurrences or more sessions
recurring = []
for group in groups:
if len(group["sessions"]) >= min_occurrences:
recurring.append({
"canonical": group["canonical"],
"occurrences": len(group["items"]),
"sessions": list(group["sessions"]),
"category": categorize_pattern(group["canonical"]),
"sources": [item["source"] for item in group["items"]],
})
return recurring
def categorize_pattern(pattern: str) -> str:
"""Categorize a pattern as rule, skill, or hook candidate.
Decision tree:
- Contains sequence/process keywords -> skill
- Contains event trigger keywords -> hook
- Otherwise -> rule (behavioral heuristic)
Args:
pattern: Pattern text
Returns:
Category: "rule", "skill", or "hook"
"""
pattern_lower = pattern.lower()
# Skill indicators: sequences, processes, step-by-step
skill_keywords = [
"step by step",
"process",
"workflow",
"procedure",
"first",
"then",
"finally",
"sequence",
"follow these",
]
for keyword in skill_keywords:
if keyword in pattern_lower:
return "skill"
# Hook indicators: event triggers, automatic actions
# Use regex word boundaries for precise matching
hook_patterns = [
r"\bon session\b",
r"\bon commit\b",
r"\bon save\b",
r"\bautomatically\b",
r"\btrigger\b",
r"\bevent\b",
r"\bat the end\b",
r"\bat the start\b",
r"\bafter save\b",
r"\bafter commit\b",
r"\bbefore save\b",
r"\bbefore commit\b",
r"\bwhen session\b",
r"\bwhen file\b",
]
for hook_pattern in hook_patterns:
if re.search(hook_pattern, pattern_lower):
return "hook"
# Default: behavioral rule
return "rule"
def generate_pattern_summary(recurring_patterns: List[Dict]) -> str:
"""Generate a human-readable summary of recurring patterns.
Args:
recurring_patterns: Output from detect_recurring_patterns
Returns:
Markdown-formatted summary
"""
if not recurring_patterns:
return "No recurring patterns found (patterns need 3+ occurrences)."
lines = ["# Recurring Patterns Analysis", ""]
# Group by category
by_category: Dict[str, List[Dict]] = {"rule": [], "skill": [], "hook": []}
for pattern in recurring_patterns:
category = pattern.get("category", "rule")
by_category[category].append(pattern)
for category, patterns in by_category.items():
if not patterns:
continue
lines.append(f"## {category.title()} Candidates ({len(patterns)})")
lines.append("")
for p in patterns:
canonical = p["canonical"]
truncated = canonical[:60] + "..." if len(canonical) > 60 else canonical
lines.append(f"### {truncated}")
lines.append(f"- **Occurrences:** {p['occurrences']} sessions")
sessions_str = ", ".join(p["sessions"][:5])
if len(p["sessions"]) > 5:
sessions_str += "..."
lines.append(f"- **Sessions:** {sessions_str}")
lines.append(f"- **Sources:** {', '.join(set(p['sources']))}")
lines.append("")
return "\n".join(lines)

View file

@ -1,386 +0,0 @@
"""Generate rules and skills from detected patterns.
This module creates the actual rule/skill files that will be added
to the Ring plugin based on user-approved patterns.
"""
import re
import json
from pathlib import Path
from typing import Dict, List, Optional
from datetime import datetime
def slugify(text: str, default: str = "unnamed-pattern") -> str:
"""Convert text to a URL-friendly slug.
Args:
text: Input text
default: Fallback value if text produces empty slug
Returns:
Lowercase, hyphen-separated slug
"""
if not text or not text.strip():
return default
# Lowercase and replace non-alphanumeric with hyphens
slug = re.sub(r'[^a-z0-9]+', '-', text.lower())
# Remove leading/trailing hyphens
slug = slug.strip('-')
return slug if slug else default
def generate_rule_content(pattern: Dict) -> str:
"""Generate markdown content for a rule.
Args:
pattern: Pattern dictionary with canonical, occurrences, sessions
Returns:
Markdown content for the rule file
"""
canonical = pattern["canonical"]
occurrences = pattern.get("occurrences", 0)
sessions = pattern.get("sessions", [])
sources = pattern.get("sources", [])
# Generate DO/DON'T based on pattern text
if canonical.lower().startswith("avoid"):
dont_text = canonical.replace("Avoid: ", "").replace("avoid ", "")
do_text = f"Do the opposite of: {dont_text}"
else:
do_text = canonical
dont_text = f"Skip or ignore: {canonical}"
content = f"""# {canonical}
**Context:** This rule emerged from {occurrences} sessions where this pattern appeared.
## Pattern
{canonical}
## DO
- {do_text}
## DON'T
- {dont_text}
## Source Sessions
This rule was automatically generated from learnings in these sessions:
"""
for session in sessions[:10]: # Show first 10
content += f"- `{session}`\n"
if len(sessions) > 10:
content += f"- ... and {len(sessions) - 10} more sessions\n"
content += f"""
## Evidence
- **Occurrences:** {occurrences} times across {len(sessions)} sessions
- **Sources:** {', '.join(set(sources))}
- **Generated:** {datetime.now().strftime('%Y-%m-%d')}
"""
return content
def generate_skill_content(pattern: Dict) -> str:
"""Generate markdown content for a skill.
Args:
pattern: Pattern dictionary with canonical, occurrences, sessions
Returns:
Markdown content for SKILL.md file
"""
canonical = pattern["canonical"]
occurrences = pattern.get("occurrences", 0)
sessions = pattern.get("sessions", [])
slug = slugify(canonical)
# Create description for frontmatter
description = f"Use when {canonical.lower()} - emerged from {occurrences} sessions"
if len(description) > 200:
description = description[:197] + "..."
content = f"""---
name: {slug}
description: |
{description}
---
# {canonical}
## Overview
This skill emerged from patterns observed across {occurrences} sessions.
{canonical}
## When to Use
- When you encounter situations similar to those in the source sessions
- When the pattern would apply to your current task
## When NOT to Use
- When the context is significantly different from the source sessions
- When project-specific conventions override this pattern
## Process
1. [Step 1 - TO BE FILLED: Review and customize this skill]
2. [Step 2 - TO BE FILLED: Add specific steps for your use case]
3. [Step 3 - TO BE FILLED: Include verification steps]
## Source Sessions
This skill was automatically generated from learnings in these sessions:
"""
for session in sessions[:10]:
content += f"- `{session}`\n"
content += f"""
## Notes
- **Generated:** {datetime.now().strftime('%Y-%m-%d')}
- **IMPORTANT:** This skill needs human review and customization before use
- Edit the Process section to add specific, actionable steps
"""
return content
class RuleGenerationError(Exception):
"""Error raised when rule/skill generation fails."""
pass
def create_rule_file(pattern: Dict, rules_dir: Path) -> Path:
"""Create a rule file on disk.
Args:
pattern: Pattern dictionary
rules_dir: Path to rules directory
Returns:
Path to created rule file
Raises:
RuleGenerationError: If file cannot be written
"""
slug = slugify(pattern["canonical"])
file_path = rules_dir / f"{slug}.md"
content = generate_rule_content(pattern)
try:
file_path.write_text(content, encoding='utf-8')
except (IOError, PermissionError, OSError) as e:
raise RuleGenerationError(f"Failed to write rule file {file_path}: {e}") from e
return file_path
def create_skill_file(pattern: Dict, skills_dir: Path) -> Path:
"""Create a skill directory and SKILL.md file.
Args:
pattern: Pattern dictionary
skills_dir: Path to skills directory
Returns:
Path to created skill directory
Raises:
RuleGenerationError: If directory/file cannot be created
"""
slug = slugify(pattern["canonical"])
skill_dir = skills_dir / slug
try:
skill_dir.mkdir(exist_ok=True)
skill_file = skill_dir / "SKILL.md"
content = generate_skill_content(pattern)
skill_file.write_text(content, encoding='utf-8')
except (IOError, PermissionError, OSError) as e:
raise RuleGenerationError(f"Failed to create skill {skill_dir}: {e}") from e
return skill_dir
def generate_proposal(patterns: List[Dict]) -> Dict:
"""Generate a proposal document for user review.
Args:
patterns: List of recurring patterns from pattern_detector
Returns:
Proposal dictionary with proposals list and metadata
"""
proposals = []
for i, pattern in enumerate(patterns):
category = pattern.get("category", "rule")
proposal = {
"id": f"proposal-{i+1}",
"canonical": pattern["canonical"],
"category": category,
"occurrences": pattern.get("occurrences", 0),
"sessions": pattern.get("sessions", []),
"sources": pattern.get("sources", []),
"status": "pending",
"preview": (
generate_rule_content(pattern)
if category == "rule"
else generate_skill_content(pattern)
),
}
proposals.append(proposal)
return {
"generated": datetime.now().isoformat(),
"total_proposals": len(proposals),
"proposals": proposals,
}
def save_proposal(proposal: Dict, proposals_dir: Path) -> Path:
"""Save proposal to pending.json file.
Args:
proposal: Proposal dictionary from generate_proposal
proposals_dir: Path to .ring/cache/proposals/
Returns:
Path to saved proposals file
"""
proposals_dir.mkdir(parents=True, exist_ok=True)
pending_file = proposals_dir / "pending.json"
with open(pending_file, 'w', encoding='utf-8') as f:
json.dump(proposal, f, indent=2)
return pending_file
def load_pending_proposals(proposals_dir: Path) -> Optional[Dict]:
"""Load pending proposals from file.
Args:
proposals_dir: Path to proposals directory
Returns:
Proposal dictionary or None if not found/corrupted
"""
pending_file = proposals_dir / "pending.json"
if not pending_file.exists():
return None
try:
with open(pending_file, encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return None
def _update_proposal_status(
proposal_id: str,
proposals_dir: Path,
status: str,
extra_fields: Optional[Dict] = None
) -> Optional[Dict]:
"""Common logic for updating proposal status.
Args:
proposal_id: ID of proposal to update
proposals_dir: Path to proposals directory
status: New status ("approved" or "rejected")
extra_fields: Additional fields to add to proposal
Returns:
Updated proposal dictionary, or None if not found
"""
pending_file = proposals_dir / "pending.json"
history_file = proposals_dir / "history.json"
if not pending_file.exists():
return None
try:
with open(pending_file, encoding='utf-8') as f:
data = json.load(f)
except (json.JSONDecodeError, IOError):
return None
# Find and update proposal
updated = None
for proposal in data.get("proposals", []):
if proposal["id"] == proposal_id:
proposal["status"] = status
proposal[f"{status}_at"] = datetime.now().isoformat()
if extra_fields:
proposal.update(extra_fields)
updated = proposal
break
if updated:
# Save updated pending file
with open(pending_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
# Append to history
history = []
if history_file.exists():
try:
with open(history_file, encoding='utf-8') as f:
loaded = json.load(f)
if isinstance(loaded, list):
history = loaded
except (json.JSONDecodeError, IOError):
pass # Start with empty history if corrupt
history.append(updated)
with open(history_file, 'w', encoding='utf-8') as f:
json.dump(history, f, indent=2)
return updated
def approve_proposal(proposal_id: str, proposals_dir: Path) -> Optional[Dict]:
"""Mark a proposal as approved.
Args:
proposal_id: ID of proposal to approve
proposals_dir: Path to proposals directory
Returns:
Approved proposal dictionary, or None if not found
"""
return _update_proposal_status(proposal_id, proposals_dir, "approved")
def reject_proposal(
proposal_id: str, proposals_dir: Path, reason: str = ""
) -> Optional[Dict]:
"""Mark a proposal as rejected.
Args:
proposal_id: ID of proposal to reject
proposals_dir: Path to proposals directory
reason: Optional rejection reason
Returns:
Rejected proposal dictionary, or None if not found
"""
return _update_proposal_status(
proposal_id, proposals_dir, "rejected",
{"rejection_reason": reason}
)

View file

@ -1,157 +0,0 @@
"""Tests for learning_parser.py"""
import unittest
from pathlib import Path
import tempfile
import shutil
# Will fail until learning_parser.py exists
from learning_parser import (
parse_learning_file,
extract_patterns,
extract_what_worked,
extract_what_failed,
extract_key_decisions,
)
class TestLearningParser(unittest.TestCase):
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.sample_learning = """# Learnings from Session abc123
**Date:** 2025-12-27 10:00:00
## What Worked
- Using TDD approach for the implementation
- Breaking down tasks into small commits
- Running tests after each change
## What Failed
- Trying to implement everything at once
- Skipping the planning phase
## Key Decisions
- Chose SQLite over PostgreSQL for simplicity
- Used Python instead of TypeScript for portability
## Patterns
- Always run tests before committing
- Use explicit file paths in plans
"""
self.learning_path = Path(self.test_dir) / "2025-12-27-abc123.md"
self.learning_path.write_text(self.sample_learning)
def tearDown(self):
shutil.rmtree(self.test_dir, ignore_errors=True)
def test_parse_learning_file(self):
"""Test parsing a complete learning file"""
result = parse_learning_file(self.learning_path)
self.assertEqual(result["session_id"], "abc123")
self.assertEqual(result["date"], "2025-12-27 10:00:00")
self.assertIn("Using TDD approach", result["what_worked"][0])
def test_extract_what_worked(self):
"""Test extracting What Worked section"""
items = extract_what_worked(self.sample_learning)
self.assertEqual(len(items), 3)
self.assertIn("TDD", items[0])
def test_extract_what_failed(self):
"""Test extracting What Failed section"""
items = extract_what_failed(self.sample_learning)
self.assertEqual(len(items), 2)
self.assertIn("everything at once", items[0])
def test_extract_key_decisions(self):
"""Test extracting Key Decisions section"""
items = extract_key_decisions(self.sample_learning)
self.assertEqual(len(items), 2)
self.assertIn("SQLite", items[0])
def test_extract_patterns(self):
"""Test extracting Patterns section"""
items = extract_patterns(self.sample_learning)
self.assertEqual(len(items), 2)
self.assertIn("tests before committing", items[0])
def test_parse_missing_sections(self):
"""Test parsing file with missing sections"""
sparse_content = """# Learnings from Session sparse
**Date:** 2025-12-27 12:00:00
## What Worked
- One thing worked
"""
sparse_path = Path(self.test_dir) / "2025-12-27-sparse.md"
sparse_path.write_text(sparse_content)
result = parse_learning_file(sparse_path)
self.assertEqual(result["session_id"], "sparse")
self.assertEqual(len(result["what_worked"]), 1)
self.assertEqual(len(result["what_failed"]), 0)
self.assertEqual(len(result["patterns"]), 0)
def test_extract_session_id_with_date_prefix(self):
"""Test session ID extraction from filename with date"""
from learning_parser import _extract_session_id
self.assertEqual(_extract_session_id("2025-12-27-abc123"), "abc123")
self.assertEqual(_extract_session_id("2025-12-27-multi-part-id"), "multi-part-id")
def test_extract_session_id_date_only(self):
"""Test session ID extraction from date-only filename"""
from learning_parser import _extract_session_id
# Date-only filenames should return empty string
self.assertEqual(_extract_session_id("2025-12-27"), "")
def test_extract_session_id_no_hyphens(self):
"""Test session ID extraction from filename without hyphens"""
from learning_parser import _extract_session_id
self.assertEqual(_extract_session_id("session"), "session")
def test_load_all_learnings(self):
"""Test loading all learning files from directory"""
# Create second learning file with earlier date
second_learning = Path(self.test_dir) / "2025-12-26-def456.md"
second_learning.write_text("""# Learnings from Session def456
**Date:** 2025-12-26 08:00:00
## What Worked
- Something else worked
""")
from learning_parser import load_all_learnings
learnings = load_all_learnings(Path(self.test_dir))
self.assertEqual(len(learnings), 2)
# Should be sorted newest first (by date string)
self.assertEqual(learnings[0]["session_id"], "abc123")
def test_load_all_learnings_empty_dir(self):
"""Test loading from empty directory"""
empty_dir = Path(self.test_dir) / "empty"
empty_dir.mkdir()
from learning_parser import load_all_learnings
learnings = load_all_learnings(empty_dir)
self.assertEqual(learnings, [])
def test_load_all_learnings_nonexistent_dir(self):
"""Test loading from non-existent directory"""
from learning_parser import load_all_learnings
learnings = load_all_learnings(Path("/nonexistent/path"))
self.assertEqual(learnings, [])
if __name__ == "__main__":
unittest.main()

View file

@ -1,160 +0,0 @@
"""Tests for pattern_detector.py"""
import unittest
from pattern_detector import (
normalize_pattern,
find_similar_patterns,
aggregate_patterns,
detect_recurring_patterns,
categorize_pattern,
similarity_score,
generate_pattern_summary,
)
class TestPatternDetector(unittest.TestCase):
def test_normalize_pattern(self):
"""Test pattern normalization"""
# Should lowercase and remove extra whitespace
self.assertEqual(
normalize_pattern(" Use TDD Approach "),
"use tdd approach"
)
def test_find_similar_patterns(self):
"""Test finding similar patterns using fuzzy matching"""
patterns = [
"always run tests before committing",
"run tests before commit",
"test before committing changes",
"use explicit file paths",
]
similar = find_similar_patterns("run tests before commit", patterns)
# Should find the similar patterns
self.assertGreaterEqual(len(similar), 2)
def test_aggregate_patterns(self):
"""Test aggregating similar patterns into groups"""
patterns = [
{"text": "run tests before commit", "session": "abc"},
{"text": "always run tests before committing", "session": "def"},
{"text": "test before committing changes", "session": "ghi"},
{"text": "use explicit file paths", "session": "jkl"},
]
groups = aggregate_patterns(patterns)
# Should group similar patterns together
self.assertGreaterEqual(len(groups), 1)
# At least one group should have 3 items
has_large_group = any(len(g["items"]) >= 3 for g in groups)
self.assertTrue(has_large_group)
def test_detect_recurring_patterns(self):
"""Test detecting patterns that appear 3+ times"""
learnings = [
{
"session_id": "abc",
"patterns": ["run tests before commit", "use explicit paths"],
"what_worked": ["TDD approach"],
"what_failed": [],
},
{
"session_id": "def",
"patterns": ["always run tests before committing"],
"what_worked": ["TDD workflow"],
"what_failed": [],
},
{
"session_id": "ghi",
"patterns": ["test before committing changes"],
"what_worked": ["test-driven development"],
"what_failed": [],
},
]
recurring = detect_recurring_patterns(learnings, min_occurrences=3)
# Should detect "run tests before commit" pattern (appears in 3 sessions)
self.assertGreaterEqual(len(recurring), 1)
def test_categorize_pattern_rule(self):
"""Test categorizing patterns as rules (heuristics)"""
self.assertEqual(
categorize_pattern("always run tests before committing"),
"rule"
)
self.assertEqual(
categorize_pattern("use explicit file paths"),
"rule"
)
def test_categorize_pattern_skill(self):
"""Test categorizing patterns as skills (sequences/processes)"""
self.assertEqual(
categorize_pattern("step by step debugging process"),
"skill"
)
self.assertEqual(
categorize_pattern("follow these steps to deploy"),
"skill"
)
def test_categorize_pattern_hook(self):
"""Test categorizing patterns as hooks (event triggers)"""
self.assertEqual(
categorize_pattern("on session end, save the state"),
"hook"
)
self.assertEqual(
categorize_pattern("automatically run lint after save"),
"hook"
)
self.assertEqual(
categorize_pattern("trigger validation on commit"),
"hook"
)
def test_no_recurring_with_insufficient_data(self):
"""Test that insufficient data returns no patterns"""
learnings = [
{"session_id": "abc", "patterns": ["unique pattern"], "what_worked": [], "what_failed": []},
{"session_id": "def", "patterns": ["another unique"], "what_worked": [], "what_failed": []},
]
recurring = detect_recurring_patterns(learnings, min_occurrences=3)
self.assertEqual(len(recurring), 0)
def test_similarity_score_identical(self):
"""Test similarity score for identical strings"""
self.assertEqual(similarity_score("test", "test"), 1.0)
def test_similarity_score_similar(self):
"""Test similarity score for similar strings"""
score = similarity_score("run tests before commit", "run tests before committing")
self.assertGreater(score, 0.8)
def test_similarity_score_different(self):
"""Test similarity score for different strings"""
score = similarity_score("completely different", "no match at all")
self.assertLess(score, 0.5)
def test_generate_pattern_summary_empty(self):
"""Test summary generation with no patterns"""
summary = generate_pattern_summary([])
self.assertIn("No recurring patterns", summary)
def test_generate_pattern_summary_with_patterns(self):
"""Test summary generation with patterns"""
patterns = [
{
"canonical": "Test pattern for rules",
"occurrences": 3,
"sessions": ["a", "b", "c"],
"category": "rule",
"sources": ["patterns"]
}
]
summary = generate_pattern_summary(patterns)
self.assertIn("# Recurring Patterns Analysis", summary)
self.assertIn("Rule Candidates", summary)
self.assertIn("Test pattern", summary)
if __name__ == "__main__":
unittest.main()

View file

@ -1,209 +0,0 @@
"""Tests for rule_generator.py"""
import unittest
from pathlib import Path
import tempfile
import shutil
from rule_generator import (
generate_rule_content,
generate_skill_content,
create_rule_file,
create_skill_file,
generate_proposal,
slugify,
save_proposal,
load_pending_proposals,
approve_proposal,
reject_proposal,
)
class TestRuleGenerator(unittest.TestCase):
def setUp(self):
self.test_dir = Path(tempfile.mkdtemp())
self.rules_dir = self.test_dir / "rules"
self.skills_dir = self.test_dir / "skills"
self.rules_dir.mkdir()
self.skills_dir.mkdir()
def tearDown(self):
shutil.rmtree(self.test_dir, ignore_errors=True)
def test_slugify(self):
"""Test converting text to slug"""
self.assertEqual(slugify("Always Run Tests"), "always-run-tests")
self.assertEqual(slugify("Use TDD!!!"), "use-tdd")
self.assertEqual(slugify("Step-by-step Process"), "step-by-step-process")
def test_generate_rule_content(self):
"""Test generating rule markdown content"""
pattern = {
"canonical": "Always run tests before committing",
"occurrences": 5,
"sessions": ["abc", "def", "ghi", "jkl", "mno"],
"sources": ["patterns", "what_worked"],
}
content = generate_rule_content(pattern)
self.assertIn("# Always run tests before committing", content)
self.assertIn("5 sessions", content)
self.assertIn("## Pattern", content)
self.assertIn("## DO", content)
self.assertIn("## DON'T", content)
def test_generate_skill_content(self):
"""Test generating skill markdown content"""
pattern = {
"canonical": "Step by step debugging process",
"occurrences": 4,
"sessions": ["abc", "def", "ghi", "jkl"],
"sources": ["patterns"],
}
content = generate_skill_content(pattern)
self.assertIn("---", content) # YAML frontmatter
self.assertIn("name:", content)
self.assertIn("description:", content)
self.assertIn("## Overview", content)
self.assertIn("## When to Use", content)
def test_create_rule_file(self):
"""Test creating a rule file on disk"""
pattern = {
"canonical": "Use explicit file paths",
"occurrences": 3,
"sessions": ["abc", "def", "ghi"],
"sources": ["patterns"],
}
file_path = create_rule_file(pattern, self.rules_dir)
self.assertTrue(file_path.exists())
self.assertIn("use-explicit-file-paths", file_path.name)
content = file_path.read_text()
self.assertIn("Use explicit file paths", content)
def test_create_skill_file(self):
"""Test creating a skill directory and SKILL.md"""
pattern = {
"canonical": "Systematic debugging workflow",
"occurrences": 4,
"sessions": ["abc", "def", "ghi", "jkl"],
"sources": ["patterns"],
}
skill_dir = create_skill_file(pattern, self.skills_dir)
self.assertTrue(skill_dir.exists())
skill_file = skill_dir / "SKILL.md"
self.assertTrue(skill_file.exists())
def test_generate_proposal(self):
"""Test generating a proposal document"""
patterns = [
{
"canonical": "Always run tests",
"occurrences": 3,
"sessions": ["abc", "def", "ghi"],
"category": "rule",
"sources": ["patterns"],
},
{
"canonical": "Step by step debug",
"occurrences": 4,
"sessions": ["abc", "def", "ghi", "jkl"],
"category": "skill",
"sources": ["what_worked"],
},
]
proposal = generate_proposal(patterns)
self.assertIn("proposals", proposal)
self.assertEqual(len(proposal["proposals"]), 2)
self.assertEqual(proposal["proposals"][0]["category"], "rule")
def test_generate_rule_for_avoid_pattern(self):
"""Test generating rule for anti-patterns (Avoid: ...)"""
pattern = {
"canonical": "Avoid: implementing everything at once",
"occurrences": 3,
"sessions": ["abc", "def", "ghi"],
"sources": ["what_failed"],
}
content = generate_rule_content(pattern)
self.assertIn("## DON'T", content)
# Should have inverted DO/DON'T for avoid patterns
self.assertIn("implementing everything at once", content)
def test_slugify_empty_input(self):
"""Test slugify with empty or special-only input"""
self.assertEqual(slugify(""), "unnamed-pattern")
self.assertEqual(slugify("!@#$%"), "unnamed-pattern")
self.assertEqual(slugify(" "), "unnamed-pattern")
def test_save_and_load_proposal(self):
"""Test saving and loading proposal"""
proposals_dir = self.test_dir / "proposals"
proposal = {
"generated": "2025-12-27T10:00:00",
"total_proposals": 1,
"proposals": [{"id": "proposal-1", "canonical": "Test", "status": "pending"}]
}
path = save_proposal(proposal, proposals_dir)
self.assertTrue(path.exists())
loaded = load_pending_proposals(proposals_dir)
self.assertIsNotNone(loaded)
self.assertEqual(loaded["total_proposals"], 1)
def test_load_pending_proposals_nonexistent(self):
"""Test loading from nonexistent directory"""
proposals_dir = self.test_dir / "nonexistent"
loaded = load_pending_proposals(proposals_dir)
self.assertIsNone(loaded)
def test_approve_proposal(self):
"""Test approving a proposal"""
proposals_dir = self.test_dir / "proposals"
proposal = {
"generated": "2025-12-27T10:00:00",
"total_proposals": 1,
"proposals": [{"id": "proposal-1", "canonical": "Test", "status": "pending"}]
}
save_proposal(proposal, proposals_dir)
result = approve_proposal("proposal-1", proposals_dir)
self.assertIsNotNone(result)
self.assertEqual(result["status"], "approved")
self.assertIn("approved_at", result)
def test_reject_proposal(self):
"""Test rejecting a proposal with reason"""
proposals_dir = self.test_dir / "proposals"
proposal = {
"generated": "2025-12-27T10:00:00",
"total_proposals": 1,
"proposals": [{"id": "proposal-1", "canonical": "Test", "status": "pending"}]
}
save_proposal(proposal, proposals_dir)
result = reject_proposal("proposal-1", proposals_dir, "Not useful")
self.assertIsNotNone(result)
self.assertEqual(result["status"], "rejected")
self.assertEqual(result["rejection_reason"], "Not useful")
def test_approve_nonexistent_proposal(self):
"""Test approving a proposal that doesn't exist"""
proposals_dir = self.test_dir / "proposals"
proposal = {
"generated": "2025-12-27T10:00:00",
"total_proposals": 1,
"proposals": [{"id": "proposal-1", "canonical": "Test", "status": "pending"}]
}
save_proposal(proposal, proposals_dir)
result = approve_proposal("proposal-999", proposals_dir)
self.assertIsNone(result)
if __name__ == "__main__":
unittest.main()

View file

@ -1,151 +0,0 @@
#!/usr/bin/env python3
"""Outcome inference module for automatic handoff outcome detection.
This module analyzes session state to infer task outcomes:
- SUCCEEDED: All todos complete, no errors
- PARTIAL_PLUS: Most todos complete, minor issues
- PARTIAL_MINUS: Some progress, significant blockers
- FAILED: No meaningful progress or abandoned
Usage:
from outcome_inference import infer_outcome
outcome = infer_outcome(project_root)
"""
import json
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# Outcome definitions
OUTCOME_SUCCEEDED = "SUCCEEDED"
OUTCOME_PARTIAL_PLUS = "PARTIAL_PLUS"
OUTCOME_PARTIAL_MINUS = "PARTIAL_MINUS"
OUTCOME_FAILED = "FAILED"
OUTCOME_UNKNOWN = "UNKNOWN"
def get_project_root() -> Path:
"""Get the project root directory."""
if os.environ.get("CLAUDE_PROJECT_DIR"):
return Path(os.environ["CLAUDE_PROJECT_DIR"])
return Path.cwd()
def sanitize_session_id(session_id: str) -> str:
"""Sanitize session ID for safe use in filenames."""
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '', session_id)
if not sanitized:
return "unknown"
return sanitized[:64]
def analyze_todos(todos: List[Dict]) -> Tuple[int, int, int, int]:
"""Analyze todo list and return counts.
Returns:
Tuple of (total, completed, in_progress, pending)
"""
if not todos:
return (0, 0, 0, 0)
total = len(todos)
completed = sum(1 for t in todos if t.get("status") == "completed")
in_progress = sum(1 for t in todos if t.get("status") == "in_progress")
pending = sum(1 for t in todos if t.get("status") == "pending")
return (total, completed, in_progress, pending)
def infer_outcome_from_todos(
total: int,
completed: int,
in_progress: int,
pending: int
) -> Tuple[str, str]:
"""Infer outcome from todo counts.
Returns:
Tuple of (outcome, reason)
"""
if total == 0:
return (OUTCOME_FAILED, "No todos tracked - unable to measure completion")
completion_ratio = completed / total
if completed == total:
return (OUTCOME_SUCCEEDED, f"All {total} tasks completed")
if completion_ratio >= 0.8:
remaining = total - completed
return (OUTCOME_PARTIAL_PLUS, f"{completed}/{total} tasks done, {remaining} minor items remain")
if completion_ratio >= 0.5:
return (OUTCOME_PARTIAL_MINUS, f"{completed}/{total} tasks done, significant work remains")
# <50% completion is FAILED - insufficient progress
# Note: in_progress tasks without completion don't count toward progress
return (OUTCOME_FAILED, f"Insufficient progress: only {completed}/{total} tasks completed ({int(completion_ratio * 100)}%)")
def infer_outcome(
todos: Optional[List[Dict]] = None,
) -> Dict:
"""Infer session outcome from available state.
Args:
todos: Optional pre-parsed todos list
Returns:
Dict with 'outcome', 'reason', and 'confidence' keys
"""
result = {
"outcome": OUTCOME_UNKNOWN,
"reason": "Unable to determine outcome",
"confidence": "low",
"sources": [],
}
if todos is not None:
total, completed, in_progress, pending = analyze_todos(todos)
# Always infer outcome from todos - even if empty (total=0 -> FAILED)
result["outcome"], result["reason"] = infer_outcome_from_todos(
total, completed, in_progress, pending
)
result["sources"].append("todos")
# Confidence based on completion clarity
if total == 0:
# Empty todos = high confidence it's a failure
result["confidence"] = "high"
elif completed == total or completed == 0:
result["confidence"] = "high"
elif completed / total >= 0.8 or completed / total <= 0.2:
result["confidence"] = "medium"
else:
result["confidence"] = "low"
return result
def main():
"""CLI entry point for testing."""
import sys
# Read optional todos from stdin
todos = None
if not sys.stdin.isatty():
try:
input_data = json.loads(sys.stdin.read())
todos = input_data.get("todos")
except json.JSONDecodeError:
pass
result = infer_outcome(todos=todos)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()

View file

@ -1,236 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# STATEFUL Context Check Utilities
# =============================================================================
# PURPOSE: State management for context tracking with file persistence
#
# USE THIS WHEN:
# - You need to track turn counts across hook invocations
# - You need to increment/reset context state
# - You're in a hook that modifies session state
#
# DO NOT USE FOR:
# - Simple percentage calculations (use shared/lib/context-check.sh)
# - Stateless warning tier lookups
#
# KEY FUNCTIONS:
# - increment_turn_count() - Atomically increment with flock
# - reset_turn_count() - Reset to 0 with flock
# - update_context_usage() - Persist usage state to file
# - estimate_context_usage() - Calculate usage from turn count
# - get_context_warning() - Get warning message if threshold crossed
#
# DEPENDENCIES:
# - hook-utils.sh (for get_ring_state_dir)
# - flock command (for file locking)
#
# STATE FILES:
# - .ring/state/current-session.json - Turn count and timestamps
# - .ring/state/context-usage-*.json - Per-session usage estimates
# =============================================================================
# shellcheck disable=SC2034 # Unused variables OK for exported config
set -euo pipefail
# Determine script location and source dependencies
CONTEXT_CHECK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source hook-utils if not already sourced
if ! declare -f get_ring_state_dir &>/dev/null; then
# shellcheck source=hook-utils.sh
source "${CONTEXT_CHECK_DIR}/hook-utils.sh"
fi
# Approximate context window size (tokens)
# Claude's actual limit is ~200K, we use conservative estimate
readonly CONTEXT_WINDOW_TOKENS=200000
# Warning thresholds
readonly THRESHOLD_INFO=50
readonly THRESHOLD_WARNING=70
readonly THRESHOLD_CRITICAL=85
# Estimate context usage percentage
# Returns: integer 0-100 representing estimated usage percentage
estimate_context_usage() {
local state_dir
state_dir=$(get_ring_state_dir)
local usage_file="${state_dir}/context-usage.json"
# Check if context-usage.json exists and has recent data
if [[ -f "$usage_file" ]]; then
local estimated
estimated=$(get_json_field "$(cat "$usage_file")" "estimated_percentage" 2>/dev/null || echo "")
if [[ -n "$estimated" ]] && [[ "$estimated" =~ ^[0-9]+$ ]]; then
printf '%s' "$estimated"
return 0
fi
fi
# Fallback: estimate from conversation turn count in state file
local session_file="${state_dir}/current-session.json"
if [[ -f "$session_file" ]]; then
local turns
turns=$(get_json_field "$(cat "$session_file")" "turn_count" 2>/dev/null || echo "0")
# Rough estimate: ~2000 tokens per turn average
# 200K context / 2000 tokens = 100 turns for full context
# So: percentage = turns (since 100 turns = 100%)
if [[ "$turns" =~ ^[0-9]+$ ]]; then
local percentage=$turns # 1 turn = 1%, 100 turns = 100%
if [[ $percentage -gt 100 ]]; then
percentage=100
fi
printf '%s' "$percentage"
return 0
fi
fi
# No data available, return 0 (unknown)
printf '0'
}
# Update context usage estimate
# Args: $1 - estimated percentage (0-100)
update_context_usage() {
local percentage="${1:-0}"
# Validate numeric input (security: prevent injection)
if ! [[ "$percentage" =~ ^[0-9]+$ ]]; then
percentage=0
fi
local state_dir
state_dir=$(get_ring_state_dir)
local usage_file="${state_dir}/context-usage.json"
local temp_file="${usage_file}.tmp.$$"
# Get current timestamp
local timestamp
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Atomic write: write to temp file then rename
cat > "$temp_file" <<EOF
{
"estimated_percentage": ${percentage},
"updated_at": "${timestamp}",
"threshold_info": ${THRESHOLD_INFO},
"threshold_warning": ${THRESHOLD_WARNING},
"threshold_critical": ${THRESHOLD_CRITICAL}
}
EOF
mv "$temp_file" "$usage_file"
}
# Increment turn count in session state (with atomic write and file locking)
increment_turn_count() {
local state_dir
state_dir=$(get_ring_state_dir)
local session_file="${state_dir}/current-session.json"
local lock_file="${session_file}.lock"
# Use flock for atomic read-modify-write
(
flock -x 200 || return 1
local temp_file="${session_file}.tmp.$$"
local turn_count=0
if [[ -f "$session_file" ]]; then
turn_count=$(get_json_field "$(cat "$session_file")" "turn_count" 2>/dev/null || echo "0")
if ! [[ "$turn_count" =~ ^[0-9]+$ ]]; then
turn_count=0
fi
fi
turn_count=$((turn_count + 1))
local timestamp
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Atomic write: write to temp file then rename
cat > "$temp_file" <<EOF
{
"turn_count": ${turn_count},
"updated_at": "${timestamp}"
}
EOF
mv "$temp_file" "$session_file"
printf '%s' "$turn_count"
) 200>"$lock_file"
}
# Reset turn count (call on session start, with atomic write and file locking)
# Uses same locking pattern as increment_turn_count to prevent races
reset_turn_count() {
local state_dir
state_dir=$(get_ring_state_dir)
local session_file="${state_dir}/current-session.json"
local lock_file="${session_file}.lock"
# Use flock for atomic write (same pattern as increment_turn_count)
(
# Acquire exclusive lock with timeout to prevent deadlock
flock -w 5 -x 200 || {
echo "Warning: Could not acquire lock for reset_turn_count" >&2
return 1
}
local temp_file="${session_file}.tmp.$$"
local timestamp
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Atomic write: write to temp file then rename
cat > "$temp_file" <<EOF
{
"turn_count": 0,
"started_at": "${timestamp}",
"updated_at": "${timestamp}"
}
EOF
mv "$temp_file" "$session_file"
) 200>"$lock_file"
}
# Get context warning message for a given percentage
# Args: $1 - percentage (0-100)
# Returns: warning message or empty string
get_context_warning() {
local percentage="${1:-0}"
if [[ $percentage -ge $THRESHOLD_CRITICAL ]]; then
printf 'CRITICAL: Context at %d%%. MUST run /clear with ledger save NOW or risk losing work.' "$percentage"
elif [[ $percentage -ge $THRESHOLD_WARNING ]]; then
printf 'WARNING: Context at %d%%. Recommend running /clear with ledger save soon.' "$percentage"
elif [[ $percentage -ge $THRESHOLD_INFO ]]; then
printf 'INFO: Context at %d%%. Consider summarizing or preparing ledger.' "$percentage"
else
# No warning needed
printf ''
fi
}
# Get warning severity level
# Args: $1 - percentage (0-100)
# Returns: "critical", "warning", "info", or "ok"
get_warning_level() {
local percentage="${1:-0}"
if [[ $percentage -ge $THRESHOLD_CRITICAL ]]; then
printf 'critical'
elif [[ $percentage -ge $THRESHOLD_WARNING ]]; then
printf 'warning'
elif [[ $percentage -ge $THRESHOLD_INFO ]]; then
printf 'info'
else
printf 'ok'
fi
}
# Export functions for subshells
export -f estimate_context_usage 2>/dev/null || true
export -f update_context_usage 2>/dev/null || true
export -f increment_turn_count 2>/dev/null || true
export -f reset_turn_count 2>/dev/null || true
export -f get_context_warning 2>/dev/null || true
export -f get_warning_level 2>/dev/null || true

View file

@ -13,7 +13,6 @@
# - output_hook_context: Output hook response with additionalContext
# - get_json_field: Extract field from JSON using jq or grep fallback
# - get_project_root: Get project root directory
# - get_ring_state_dir: Get .ring/state directory path
set -euo pipefail
@ -101,54 +100,6 @@ get_project_root() {
printf '%s' "$project_dir"
}
# Get .ring state directory path (creates if needed with secure permissions)
# Returns: path to .ring/state directory
get_ring_state_dir() {
local project_root
project_root=$(get_project_root)
local state_dir="${project_root}/.ring/state"
# Create if it doesn't exist (owner-only permissions for security)
if [[ ! -d "$state_dir" ]]; then
mkdir -p "$state_dir"
chmod 700 "$state_dir"
fi
printf '%s' "$state_dir"
}
# Get .ring cache directory path (creates if needed with secure permissions)
# Returns: path to .ring/cache directory
get_ring_cache_dir() {
local project_root
project_root=$(get_project_root)
local cache_dir="${project_root}/.ring/cache"
# Create if it doesn't exist (owner-only permissions for security)
if [[ ! -d "$cache_dir" ]]; then
mkdir -p "$cache_dir"
chmod 700 "$cache_dir"
fi
printf '%s' "$cache_dir"
}
# Get .ring ledgers directory path (creates if needed with secure permissions)
# Returns: path to .ring/ledgers directory
get_ring_ledgers_dir() {
local project_root
project_root=$(get_project_root)
local ledgers_dir="${project_root}/.ring/ledgers"
# Create if it doesn't exist (owner-only permissions for security)
if [[ ! -d "$ledgers_dir" ]]; then
mkdir -p "$ledgers_dir"
chmod 700 "$ledgers_dir"
fi
printf '%s' "$ledgers_dir"
}
# Output a basic hook result
# Args: $1 - result ("continue" or "block"), $2 - message (optional)
output_hook_result() {
@ -214,9 +165,6 @@ EOF
export -f read_hook_stdin 2>/dev/null || true
export -f get_json_field 2>/dev/null || true
export -f get_project_root 2>/dev/null || true
export -f get_ring_state_dir 2>/dev/null || true
export -f get_ring_cache_dir 2>/dev/null || true
export -f get_ring_ledgers_dir 2>/dev/null || true
export -f output_hook_result 2>/dev/null || true
export -f output_hook_context 2>/dev/null || true
export -f output_hook_empty 2>/dev/null || true

View file

@ -8,7 +8,6 @@
# Tests:
# - json-escape.sh: json_escape(), json_string()
# - hook-utils.sh: get_json_field(), output_hook_result(), etc.
# - context-check.sh: estimate_context_usage(), increment_turn_count(), etc.
set -euo pipefail
@ -133,38 +132,6 @@ assert_exit_code() {
fi
}
assert_file_exists() {
local filepath="$1"
local test_name="$2"
TESTS_RUN=$((TESTS_RUN + 1))
if [[ -f "$filepath" ]]; then
echo -e "${GREEN}${NC} $test_name"
TESTS_PASSED=$((TESTS_PASSED + 1))
return 0
else
echo -e "${RED}${NC} $test_name"
echo " File does not exist: $filepath"
TESTS_FAILED=$((TESTS_FAILED + 1))
return 1
fi
}
assert_dir_exists() {
local dirpath="$1"
local test_name="$2"
TESTS_RUN=$((TESTS_RUN + 1))
if [[ -d "$dirpath" ]]; then
echo -e "${GREEN}${NC} $test_name"
TESTS_PASSED=$((TESTS_PASSED + 1))
return 0
else
echo -e "${RED}${NC} $test_name"
echo " Directory does not exist: $dirpath"
TESTS_FAILED=$((TESTS_FAILED + 1))
return 1
fi
}
# =============================================================================
# Source the utilities (after helpers are defined)
# =============================================================================
@ -172,7 +139,6 @@ assert_dir_exists() {
# Source in order of dependencies
source "${SCRIPT_DIR}/../json-escape.sh"
source "${SCRIPT_DIR}/../hook-utils.sh"
source "${SCRIPT_DIR}/../context-check.sh"
# =============================================================================
# json_escape() Tests
@ -383,34 +349,6 @@ test_get_project_root_fallback() {
assert_equals "$pwd_result" "$result" "get_project_root: falls back to pwd when no env var"
}
# =============================================================================
# get_ring_state_dir() Tests
# =============================================================================
test_get_ring_state_dir_creates() {
echo -e "\n${YELLOW}=== get_ring_state_dir() Tests ===${NC}"
setup_test_env
local result
result=$(get_ring_state_dir)
assert_equals "${TEST_TMP_DIR}/.ring/state" "$result" "get_ring_state_dir: returns correct path"
assert_dir_exists "$result" "get_ring_state_dir: creates directory"
teardown_test_env
}
test_get_ring_state_dir_permissions() {
setup_test_env
local result
result=$(get_ring_state_dir)
local perms
perms=$(stat -f "%Lp" "$result" 2>/dev/null || stat -c "%a" "$result" 2>/dev/null)
assert_equals "700" "$perms" "get_ring_state_dir: has secure permissions (700)"
teardown_test_env
}
# =============================================================================
# output_hook_result() Tests
# =============================================================================
@ -467,300 +405,6 @@ test_output_hook_empty_with_event() {
assert_contains '"hookEventName": "PromptSubmit"' "$result" "output_hook_empty: includes event name"
}
# =============================================================================
# estimate_context_usage() Tests
# =============================================================================
test_estimate_context_usage_no_data() {
echo -e "\n${YELLOW}=== estimate_context_usage() Tests ===${NC}"
setup_test_env
local result
result=$(estimate_context_usage)
assert_equals "0" "$result" "estimate_context_usage: returns 0 when no data"
teardown_test_env
}
test_estimate_context_usage_from_turns() {
setup_test_env
# Create session file with turn count
local state_dir
state_dir=$(get_ring_state_dir)
cat > "${state_dir}/current-session.json" <<EOF
{
"turn_count": 23,
"updated_at": "2024-01-01T00:00:00Z"
}
EOF
local result
result=$(estimate_context_usage)
assert_equals "23" "$result" "estimate_context_usage: turn 23 → ~23%"
teardown_test_env
}
test_estimate_context_usage_from_context_file() {
setup_test_env
local state_dir
state_dir=$(get_ring_state_dir)
cat > "${state_dir}/context-usage.json" <<EOF
{
"estimated_percentage": 50,
"updated_at": "2024-01-01T00:00:00Z"
}
EOF
local result
result=$(estimate_context_usage)
assert_equals "50" "$result" "estimate_context_usage: reads from context-usage.json"
teardown_test_env
}
test_estimate_context_usage_capped_at_100() {
setup_test_env
local state_dir
state_dir=$(get_ring_state_dir)
cat > "${state_dir}/current-session.json" <<EOF
{
"turn_count": 150,
"updated_at": "2024-01-01T00:00:00Z"
}
EOF
local result
result=$(estimate_context_usage)
assert_equals "100" "$result" "estimate_context_usage: capped at 100%"
teardown_test_env
}
# =============================================================================
# increment_turn_count() Tests
# =============================================================================
test_increment_turn_count_from_zero() {
echo -e "\n${YELLOW}=== increment_turn_count() Tests ===${NC}"
setup_test_env
local result
result=$(increment_turn_count)
assert_equals "1" "$result" "increment_turn_count: 0 → 1"
teardown_test_env
}
test_increment_turn_count_existing() {
setup_test_env
local state_dir
state_dir=$(get_ring_state_dir)
cat > "${state_dir}/current-session.json" <<EOF
{
"turn_count": 5,
"updated_at": "2024-01-01T00:00:00Z"
}
EOF
local result
result=$(increment_turn_count)
assert_equals "6" "$result" "increment_turn_count: 5 → 6"
# Verify file was updated
local stored
stored=$(get_json_field "$(cat "${state_dir}/current-session.json")" "turn_count")
assert_equals "6" "$stored" "increment_turn_count: file updated to 6"
teardown_test_env
}
test_increment_turn_count_concurrent() {
echo -e "\n${YELLOW}=== increment_turn_count() Concurrency Test ===${NC}"
setup_test_env
# Reset to 0
reset_turn_count
# Run 10 concurrent increments
for i in {1..10}; do
increment_turn_count &
done
wait
local state_dir
state_dir=$(get_ring_state_dir)
local final_count
final_count=$(get_json_field "$(cat "${state_dir}/current-session.json")" "turn_count")
# With proper locking, should be exactly 10
assert_equals "10" "$final_count" "increment_turn_count: concurrent increments (10 parallel) → 10"
teardown_test_env
}
# =============================================================================
# reset_turn_count() Tests
# =============================================================================
test_reset_turn_count() {
echo -e "\n${YELLOW}=== reset_turn_count() Tests ===${NC}"
setup_test_env
# First increment a few times
increment_turn_count >/dev/null
increment_turn_count >/dev/null
increment_turn_count >/dev/null
# Now reset
reset_turn_count
local state_dir
state_dir=$(get_ring_state_dir)
local count
count=$(get_json_field "$(cat "${state_dir}/current-session.json")" "turn_count")
assert_equals "0" "$count" "reset_turn_count: resets to 0"
teardown_test_env
}
test_reset_turn_count_creates_file() {
setup_test_env
local state_dir
state_dir=$(get_ring_state_dir)
local session_file="${state_dir}/current-session.json"
# Ensure file doesn't exist
rm -f "$session_file"
# Reset should create it
reset_turn_count
assert_file_exists "$session_file" "reset_turn_count: creates file if missing"
teardown_test_env
}
test_reset_turn_count_includes_timestamps() {
setup_test_env
reset_turn_count
local state_dir
state_dir=$(get_ring_state_dir)
local content
content=$(cat "${state_dir}/current-session.json")
assert_contains "started_at" "$content" "reset_turn_count: includes started_at"
assert_contains "updated_at" "$content" "reset_turn_count: includes updated_at"
teardown_test_env
}
# =============================================================================
# update_context_usage() Tests
# =============================================================================
test_update_context_usage() {
echo -e "\n${YELLOW}=== update_context_usage() Tests ===${NC}"
setup_test_env
update_context_usage 75
local state_dir
state_dir=$(get_ring_state_dir)
local content
content=$(cat "${state_dir}/context-usage.json")
assert_contains '"estimated_percentage": 75' "$content" "update_context_usage: stores percentage"
assert_contains "threshold_info" "$content" "update_context_usage: includes thresholds"
teardown_test_env
}
test_update_context_usage_invalid_input() {
setup_test_env
# Non-numeric input should be treated as 0
update_context_usage "invalid"
local state_dir
state_dir=$(get_ring_state_dir)
local content
content=$(cat "${state_dir}/context-usage.json")
assert_contains '"estimated_percentage": 0' "$content" "update_context_usage: invalid input → 0"
teardown_test_env
}
# =============================================================================
# get_context_warning() Tests
# =============================================================================
test_get_context_warning_ok() {
echo -e "\n${YELLOW}=== get_context_warning() Tests ===${NC}"
local result
result=$(get_context_warning 30)
assert_empty "$result" "get_context_warning: 30% → no warning"
}
test_get_context_warning_info() {
local result
result=$(get_context_warning 50)
assert_contains "INFO" "$result" "get_context_warning: 50% → INFO"
assert_contains "50%" "$result" "get_context_warning: includes percentage"
}
test_get_context_warning_warning() {
local result
result=$(get_context_warning 70)
assert_contains "WARNING" "$result" "get_context_warning: 70% → WARNING"
}
test_get_context_warning_critical() {
local result
result=$(get_context_warning 85)
assert_contains "CRITICAL" "$result" "get_context_warning: 85% → CRITICAL"
assert_contains "/clear" "$result" "get_context_warning: critical includes /clear suggestion"
}
# =============================================================================
# get_warning_level() Tests
# =============================================================================
test_get_warning_level_ok() {
echo -e "\n${YELLOW}=== get_warning_level() Tests ===${NC}"
local result
result=$(get_warning_level 30)
assert_equals "ok" "$result" "get_warning_level: 30% → ok"
}
test_get_warning_level_info() {
local result
result=$(get_warning_level 55)
assert_equals "info" "$result" "get_warning_level: 55% → info"
}
test_get_warning_level_warning() {
local result
result=$(get_warning_level 75)
assert_equals "warning" "$result" "get_warning_level: 75% → warning"
}
test_get_warning_level_critical() {
local result
result=$(get_warning_level 90)
assert_equals "critical" "$result" "get_warning_level: 90% → critical"
}
# =============================================================================
# Run All Tests
# =============================================================================
@ -802,10 +446,6 @@ main() {
test_get_project_root
test_get_project_root_fallback
# get_ring_state_dir tests
test_get_ring_state_dir_creates
test_get_ring_state_dir_permissions
# output_hook_result tests
test_output_hook_result_continue
test_output_hook_result_block_with_message
@ -818,38 +458,6 @@ main() {
test_output_hook_empty
test_output_hook_empty_with_event
# estimate_context_usage tests
test_estimate_context_usage_no_data
test_estimate_context_usage_from_turns
test_estimate_context_usage_from_context_file
test_estimate_context_usage_capped_at_100
# increment_turn_count tests
test_increment_turn_count_from_zero
test_increment_turn_count_existing
test_increment_turn_count_concurrent
# reset_turn_count tests
test_reset_turn_count
test_reset_turn_count_creates_file
test_reset_turn_count_includes_timestamps
# update_context_usage tests
test_update_context_usage
test_update_context_usage_invalid_input
# get_context_warning tests
test_get_context_warning_ok
test_get_context_warning_info
test_get_context_warning_warning
test_get_context_warning_critical
# get_warning_level tests
test_get_warning_level_ok
test_get_warning_level_info
test_get_warning_level_warning
test_get_warning_level_critical
# Summary
echo ""
echo -e "${YELLOW}========================================${NC}"

View file

@ -1,218 +0,0 @@
#!/usr/bin/env bash
# Integration tests for RAG-Enhanced Plan Judging
# Tests the full workflow: query → plan → validate
# Run: bash default/lib/tests/test-rag-planning.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
LIB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
ARTIFACT_INDEX_DIR="${LIB_DIR}/artifact-index"
# Test counter
TESTS_RUN=0
TESTS_PASSED=0
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
# Test helper
run_test() {
local name="$1"
local expected_exit="$2"
shift 2
local cmd=("$@")
TESTS_RUN=$((TESTS_RUN + 1))
local output
local exit_code=0
output=$("${cmd[@]}" 2>&1) || exit_code=$?
if [[ "$exit_code" == "$expected_exit" ]]; then
echo -e "${GREEN}PASS${NC}: $name"
TESTS_PASSED=$((TESTS_PASSED + 1))
return 0
else
echo -e "${RED}FAIL${NC}: $name"
echo " Expected exit code: $expected_exit, got: $exit_code"
echo " Output: ${output:0:200}"
return 1
fi
}
# Test helper for output matching
run_test_output() {
local name="$1"
local expected_pattern="$2"
shift 2
local cmd=("$@")
TESTS_RUN=$((TESTS_RUN + 1))
local output
output=$("${cmd[@]}" 2>&1) || true
if echo "$output" | grep -q "$expected_pattern"; then
echo -e "${GREEN}PASS${NC}: $name"
TESTS_PASSED=$((TESTS_PASSED + 1))
return 0
else
echo -e "${RED}FAIL${NC}: $name"
echo " Expected pattern: $expected_pattern"
echo " Output: ${output:0:300}"
return 1
fi
}
echo "Running RAG-Enhanced Plan Judging Integration Tests..."
echo "======================================================="
echo ""
# ============================================
# Test 1: artifact_query.py --mode planning
# ============================================
echo "## Testing artifact_query.py planning mode"
run_test "Planning mode returns valid JSON" 0 \
python3 "${ARTIFACT_INDEX_DIR}/artifact_query.py" --mode planning "test query" --json
run_test_output "Planning mode has expected fields" "is_empty_index" \
python3 "${ARTIFACT_INDEX_DIR}/artifact_query.py" --mode planning "test query" --json
run_test_output "Planning mode includes query_time_ms" "query_time_ms" \
python3 "${ARTIFACT_INDEX_DIR}/artifact_query.py" --mode planning "authentication oauth" --json
run_test "Planning mode with limit works" 0 \
python3 "${ARTIFACT_INDEX_DIR}/artifact_query.py" --mode planning "test" --limit 3 --json
echo ""
# ============================================
# Test 2: Planning mode performance
# ============================================
echo "## Testing planning mode performance"
echo -n "Performance test (<200ms)... "
TESTS_RUN=$((TESTS_RUN + 1))
start=$(python3 -c "import time; print(int(time.time() * 1000))")
python3 "${ARTIFACT_INDEX_DIR}/artifact_query.py" --mode planning "authentication oauth jwt" --json > /dev/null 2>&1
end=$(python3 -c "import time; print(int(time.time() * 1000))")
duration=$((end - start))
if [[ $duration -lt 200 ]]; then
echo -e "${GREEN}PASS${NC}: Completed in ${duration}ms (<200ms)"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
echo -e "${YELLOW}WARN${NC}: Completed in ${duration}ms (>200ms target)"
TESTS_PASSED=$((TESTS_PASSED + 1)) # Still pass, just warn
fi
echo ""
# ============================================
# Test 3: validate-plan-precedent.py
# ============================================
echo "## Testing validate-plan-precedent.py"
# Create a temporary test plan
TEST_PLAN=$(mktemp /tmp/test-plan-XXXXXX.md)
cat > "$TEST_PLAN" <<'EOF'
# Test Plan
**Goal:** Implement authentication system
**Architecture:** Microservices with API gateway
**Tech Stack:** Python, SQLite, JWT
### Task 1: Create auth module
Test content here.
EOF
run_test "Validation script runs successfully" 0 \
python3 "${LIB_DIR}/validate-plan-precedent.py" "$TEST_PLAN"
run_test_output "Validation extracts keywords" "Keywords extracted" \
python3 "${LIB_DIR}/validate-plan-precedent.py" "$TEST_PLAN"
run_test_output "Validation JSON output has passed field" '"passed":' \
python3 "${LIB_DIR}/validate-plan-precedent.py" "$TEST_PLAN" --json
run_test "Validation with custom threshold works" 0 \
python3 "${LIB_DIR}/validate-plan-precedent.py" "$TEST_PLAN" --threshold 50
# Clean up test plan
rm -f "$TEST_PLAN"
echo ""
# ============================================
# Test 4: Edge cases
# ============================================
echo "## Testing edge cases"
# Test non-existent file
run_test "Missing file returns error exit code" 2 \
python3 "${LIB_DIR}/validate-plan-precedent.py" "/nonexistent/file.md"
# Test empty query (should work, return empty results)
run_test_output "Empty index handled gracefully" "is_empty_index" \
python3 "${ARTIFACT_INDEX_DIR}/artifact_query.py" --mode planning "xyznonexistentquery123" --json
# Test threshold validation
run_test "Invalid threshold (negative) rejected" 2 \
python3 "${LIB_DIR}/validate-plan-precedent.py" "$TEST_PLAN" --threshold -1 2>/dev/null || echo "expected"
run_test "Invalid threshold (>100) rejected" 2 \
python3 "${LIB_DIR}/validate-plan-precedent.py" "$TEST_PLAN" --threshold 101 2>/dev/null || echo "expected"
echo ""
# ============================================
# Test 5: Keyword extraction
# ============================================
echo "## Testing keyword extraction quality"
# Create plan with Tech Stack
TECH_PLAN=$(mktemp /tmp/tech-plan-XXXXXX.md)
cat > "$TECH_PLAN" <<'EOF'
# Tech Plan
**Goal:** Build API
**Architecture:** REST
**Tech Stack:** Redis, PostgreSQL, Go
### Task 1: Setup database
EOF
run_test_output "Tech Stack keywords extracted (redis)" "redis" \
python3 "${LIB_DIR}/validate-plan-precedent.py" "$TECH_PLAN" --json
run_test_output "Tech Stack keywords extracted (postgresql)" "postgresql" \
python3 "${LIB_DIR}/validate-plan-precedent.py" "$TECH_PLAN" --json
rm -f "$TECH_PLAN"
echo ""
# ============================================
# Summary
# ============================================
echo "======================================================="
echo "Tests: $TESTS_PASSED/$TESTS_RUN passed"
if [[ $TESTS_PASSED -eq $TESTS_RUN ]]; then
echo -e "${GREEN}All tests passed!${NC}"
exit 0
else
echo -e "${RED}Some tests failed${NC}"
exit 1
fi

View file

@ -1,348 +0,0 @@
#!/usr/bin/env python3
"""
Plan Precedent Validation
Validates a plan file against known failure patterns from the artifact index.
Warns if the plan's approach overlaps significantly with past failures.
Usage:
python3 validate-plan-precedent.py <plan-file> [--threshold 30] [--json]
Exit codes:
0 - Plan passes validation (or index unavailable)
1 - Plan has significant overlap with failure patterns (warning)
2 - Error (invalid arguments, file not found, etc.)
"""
import argparse
import json
import re
import sqlite3
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
def get_project_root() -> Path:
"""Get project root by looking for .ring or .git."""
current = Path.cwd()
for parent in [current] + list(current.parents):
if (parent / ".ring").exists() or (parent / ".git").exists():
return parent
return current
def get_db_path() -> Path:
"""Get artifact index database path."""
return get_project_root() / ".ring" / "cache" / "artifact-index" / "context.db"
def extract_keywords_from_plan(plan_path: Path) -> Set[str]:
"""Extract significant keywords from plan file.
Focuses on:
- Goal section
- Architecture section
- Tech Stack section
- Task names
- Technical terms
"""
try:
content = plan_path.read_text(encoding='utf-8')
except (UnicodeDecodeError, IOError, PermissionError):
# Return empty set on read error - caller will handle
return set()
keywords: Set[str] = set()
# Extract from Goal section
goal_match = re.search(r'\*\*Goal:\*\*\s*(.+?)(?:\n\n|\*\*)', content, re.DOTALL)
if goal_match:
keywords.update(extract_words(goal_match.group(1)))
# Extract from Architecture section
arch_match = re.search(r'\*\*Architecture:\*\*\s*(.+?)(?:\n\n|\*\*)', content, re.DOTALL)
if arch_match:
keywords.update(extract_words(arch_match.group(1)))
# Extract from Tech Stack section (key failure indicator)
tech_match = re.search(r'\*\*Tech Stack:\*\*\s*(.+?)(?:\n|$)', content)
if tech_match:
keywords.update(extract_words(tech_match.group(1)))
# Extract from Task names
task_names = re.findall(r'### Task \d+:\s*(.+)', content)
for name in task_names:
keywords.update(extract_words(name))
# Extract from Historical Precedent query if present
query_match = re.search(r'\*\*Query:\*\*\s*"([^"]+)"', content)
if query_match:
keywords.update(extract_words(query_match.group(1)))
return keywords
def extract_words(text: str) -> Set[str]:
"""Extract significant words from text (lowercase, >3 chars, alphanumeric)."""
words = re.findall(r'\b[a-zA-Z][a-zA-Z0-9]{2,}\b', text.lower())
# Filter out common words
stopwords = {
'the', 'and', 'for', 'this', 'that', 'with', 'from', 'will', 'have',
'are', 'was', 'been', 'being', 'has', 'had', 'does', 'did', 'doing',
'would', 'could', 'should', 'must', 'can', 'may', 'might', 'shall',
'into', 'onto', 'upon', 'about', 'above', 'below', 'between',
'through', 'during', 'before', 'after', 'under', 'over',
'then', 'than', 'when', 'where', 'what', 'which', 'who', 'how', 'why',
'each', 'every', 'all', 'any', 'some', 'most', 'many', 'few',
'file', 'files', 'code', 'step', 'task', 'test', 'tests', 'run', 'add',
'create', 'update', 'implement', 'use', 'using', 'used'
}
return {w for w in words if w not in stopwords}
def get_failed_handoffs(conn: sqlite3.Connection) -> List[Dict[str, Any]]:
"""Get all failed handoffs from the index."""
sql = """
SELECT id, session_name, task_number, task_summary, what_failed, outcome
FROM handoffs
WHERE outcome IN ('FAILED', 'PARTIAL_MINUS')
"""
try:
cursor = conn.execute(sql)
columns = [desc[0] for desc in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
except sqlite3.OperationalError:
return []
def extract_keywords_from_handoff(handoff: Dict[str, Any]) -> Set[str]:
"""Extract keywords from a handoff record.
Extracts from task_summary, what_failed, and key_decisions fields
to capture full failure context.
"""
keywords: Set[str] = set()
task_summary = handoff.get('task_summary', '') or ''
what_failed = handoff.get('what_failed', '') or ''
key_decisions = handoff.get('key_decisions', '') or ''
keywords.update(extract_words(task_summary))
keywords.update(extract_words(what_failed))
keywords.update(extract_words(key_decisions))
return keywords
def calculate_overlap(plan_keywords: Set[str], handoff_keywords: Set[str]) -> float:
"""Calculate keyword overlap using bidirectional maximum.
Checks overlap in both directions to avoid dilution bias:
- What % of plan keywords match failure? (catches small plans)
- What % of failure keywords appear in plan? (catches large plans)
Returns: Maximum of both percentages (0-100).
"""
if not plan_keywords or not handoff_keywords:
return 0.0
intersection = plan_keywords & handoff_keywords
# Bidirectional: Check both directions, return higher
# This prevents large plans from escaping detection via keyword dilution
plan_coverage = (len(intersection) / len(plan_keywords)) * 100
failure_coverage = (len(intersection) / len(handoff_keywords)) * 100
return max(plan_coverage, failure_coverage)
def validate_plan(
plan_path: Path,
threshold: int = 30
) -> Dict[str, Any]:
"""Validate plan against failure patterns.
Returns validation result with:
- passed: True if no significant overlap found
- warnings: List of overlapping handoffs
- plan_keywords: Keywords extracted from plan
- threshold: Overlap threshold used
"""
result: Dict[str, Any] = {
"passed": True,
"warnings": [],
"plan_file": str(plan_path),
"plan_keywords": [],
"threshold": threshold,
"index_available": True
}
# Check plan file exists
if not plan_path.exists():
result["passed"] = False
result["error"] = f"Plan file not found: {plan_path}"
return result
# Extract keywords from plan
plan_keywords = extract_keywords_from_plan(plan_path)
result["plan_keywords"] = sorted(plan_keywords)
if not plan_keywords:
result["warning"] = "No significant keywords extracted from plan"
return result
# Check database exists
db_path = get_db_path()
if not db_path.exists():
result["index_available"] = False
result["message"] = "Artifact index not available - skipping validation"
return result
# Query failed handoffs (use context manager to ensure connection is closed)
try:
with sqlite3.connect(str(db_path), timeout=30.0) as conn:
failed_handoffs = get_failed_handoffs(conn)
except sqlite3.Error as e:
result["index_available"] = False
result["error"] = "Database error: unable to query artifact index"
return result
if not failed_handoffs:
result["message"] = "No failed handoffs in index - nothing to validate against"
return result
# Check overlap with each failed handoff
for handoff in failed_handoffs:
handoff_keywords = extract_keywords_from_handoff(handoff)
overlap_pct = calculate_overlap(plan_keywords, handoff_keywords)
if overlap_pct >= threshold:
result["passed"] = False
overlapping_keywords = sorted(plan_keywords & handoff_keywords)
result["warnings"].append({
"handoff_id": handoff["id"],
"session": handoff.get("session_name", "unknown"),
"task": handoff.get("task_number", "?"),
"outcome": handoff.get("outcome", "UNKNOWN"),
"overlap_percent": round(overlap_pct, 1),
"overlapping_keywords": overlapping_keywords,
"what_failed": (handoff.get("what_failed", "") or "")[:200]
})
return result
def format_result(result: Dict[str, Any]) -> str:
"""Format validation result for human-readable output."""
output: List[str] = []
output.append("## Plan Precedent Validation")
output.append("")
output.append(f"**Plan:** `{result['plan_file']}`")
output.append(f"**Threshold:** {result['threshold']}% overlap")
output.append("")
if result.get("error"):
output.append(f"**ERROR:** {result['error']}")
return "\n".join(output)
if not result.get("index_available"):
output.append(f"**Note:** {result.get('message', 'Index not available')}")
output.append("")
output.append("**Result:** PASS (no validation data)")
return "\n".join(output)
if result.get("message"):
output.append(f"**Note:** {result['message']}")
output.append("")
keywords = result.get("plan_keywords", [])
if keywords:
output.append(f"**Keywords extracted:** {len(keywords)}")
output.append(f"```\n{', '.join(keywords[:20])}{'...' if len(keywords) > 20 else ''}\n```")
output.append("")
if result["passed"]:
output.append("### Result: PASS")
output.append("")
output.append("No significant overlap with known failure patterns.")
else:
output.append("### Result: WARNING - Overlap Detected")
output.append("")
output.append(f"Found {len(result['warnings'])} failure pattern(s) with >={result['threshold']}% keyword overlap:")
output.append("")
for warning in result["warnings"]:
session = warning.get("session", "unknown")
task = warning.get("task", "?")
overlap = warning.get("overlap_percent", 0)
outcome = warning.get("outcome", "UNKNOWN")
output.append(f"#### [{session}/task-{task}] ({outcome}) - {overlap}% overlap")
output.append("")
overlapping = warning.get("overlapping_keywords", [])
if overlapping:
output.append(f"**Overlapping keywords:** `{', '.join(overlapping)}`")
what_failed = warning.get("what_failed", "")
if what_failed:
output.append(f"**What failed:** {what_failed}")
output.append("")
output.append("---")
output.append("**Action Required:** Review the failure patterns above and ensure your plan addresses these issues.")
return "\n".join(output)
def main() -> int:
parser = argparse.ArgumentParser(
description="Validate plan against known failure patterns",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument(
"plan_file",
type=str,
help="Path to plan file to validate"
)
parser.add_argument(
"--threshold",
type=int,
default=30,
help="Overlap percentage threshold for warnings (default: 30)"
)
parser.add_argument(
"--json",
action="store_true",
help="Output as JSON"
)
args = parser.parse_args()
# Validate threshold
if args.threshold < 0 or args.threshold > 100:
print("Error: Threshold must be between 0 and 100", file=sys.stderr)
return 2
plan_path = Path(args.plan_file)
result = validate_plan(plan_path, args.threshold)
if args.json:
print(json.dumps(result, indent=2))
else:
print(format_result(result))
if result.get("error"):
return 2
elif not result["passed"]:
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,170 +0,0 @@
---
name: ring:artifact-query
description: |
Search the Artifact Index for relevant historical context using semantic
full-text search. Returns handoffs, plans, and continuity ledgers ranked
by relevance using BM25.
trigger: |
- Need to find similar past work before starting a task
- Want to learn from previous successes or failures
- Looking for historical context on a topic
- Planning a feature and want precedent
skip_when: |
- Working on completely novel functionality
- Simple task that doesn't benefit from historical context
- Artifact index not initialized (run artifact_index.py --all first)
sequence:
before: [ring:writing-plans, ring:executing-plans]
related:
similar: [ring:exploring-codebase]
---
# Artifact Query
## Overview
Search Ring's Artifact Index for relevant historical context. Uses SQLite FTS5 full-text search with BM25 ranking to find:
- **Handoffs** - Completed task records with what worked/failed
- **Plans** - Implementation design documents
- **Continuity** - Session state snapshots with learnings
**Query response time:** < 100ms for typical searches
**Announce at start:** "I'm searching the artifact index for relevant precedent."
## When to Use
| Scenario | Use This Skill |
|----------|---------------|
| Starting a new feature | Yes - find similar implementations |
| Debugging a recurring issue | Yes - find past resolutions |
| Writing a plan | Yes - learn from past approaches |
| Simple one-liner fix | No - overhead not worth it |
| First time using Ring | No - index is empty |
## The Process
### Step 1: Formulate Query
Choose relevant keywords that describe what you're looking for:
- Feature names: "authentication", "OAuth", "API"
- Problem types: "error handling", "performance", "testing"
- Components: "database", "frontend", "hook"
### Step 2: Run Query
```bash
python3 default/lib/artifact-index/artifact_query.py "<keywords>" [options]
```
**Options:**
- `--mode search|planning` - Query mode (planning for structured precedent)
- `--type handoffs|plans|continuity|all` - Filter by artifact type
- `--outcome SUCCEEDED|FAILED|...` - Filter handoffs by outcome
- `--limit N` - Maximum results (1-100, default: 5)
- `--json` - Output as JSON for programmatic use
- `--stats` - Show index statistics
- `--no-save` - Disable automatic query saving (saving enabled by default)
### Planning Mode (Recommended for ring:write-plan)
For structured precedent when creating implementation plans:
```bash
python3 default/lib/artifact-index/artifact_query.py --mode planning "feature topic" --json
```
Returns:
- **successful_handoffs**: Past implementations that worked (reference these)
- **failed_handoffs**: Past implementations that failed (avoid these patterns)
- **relevant_plans**: Similar past plans for reference
- **query_time_ms**: Performance metric (target <200ms)
- **is_empty_index**: True if no historical data available
Empty index returns:
```json
{
"is_empty_index": true,
"message": "No artifact index found. This is normal for new projects."
}
```
This is NOT an error - proceed with standard planning.
### Step 3: Interpret Results
Results are ranked by relevance (BM25 score). For each result:
1. **Check outcome** - Learn from successes, avoid failures
2. **Read what_worked** - Reuse successful approaches
3. **Read what_failed** - Don't repeat mistakes
4. **Note file paths** - Can read full artifact if needed
### Step 4: Apply Learnings
Use historical context to inform current work:
- Reference successful patterns in your implementation
- Avoid approaches that failed previously
- Cite the precedent in your plan or handoff
## Examples
### Find Authentication Implementations
```bash
python3 default/lib/artifact-index/artifact_query.py "authentication OAuth JWT" --type handoffs
```
### Find Successful API Designs
```bash
python3 default/lib/artifact-index/artifact_query.py "API design REST" --outcome SUCCEEDED
```
### Get Index Statistics
```bash
python3 default/lib/artifact-index/artifact_query.py --stats
```
### Search Plans Only
```bash
python3 default/lib/artifact-index/artifact_query.py "context management" --type plans --json
```
## Integration with Planning
When creating plans (ring:writing-plans skill), query the artifact index first:
1. Search for similar past implementations
2. Note which approaches succeeded vs failed
3. Include historical context in your plan
4. Reference specific handoffs that inform decisions
This enables RAG-enhanced planning where new plans learn from past experience.
## Initialization
If the index is empty, initialize it:
```bash
python3 default/lib/artifact-index/artifact_index.py --all
```
This indexes:
- `docs/handoffs/**/*.md` - Handoff documents
- `docs/plans/*.md` - Plan documents
- `.ring/ledgers/*.md` and `CONTINUITY*.md` - Continuity ledgers
## Remember
- Query before starting significant work
- Learn from both successes AND failures
- Cite historical precedent in your work
- Keep the index updated (hooks do this automatically)
- Response time target: < 100ms

View file

@ -1,265 +0,0 @@
---
name: ring:compound-learnings
description: |
Use when asked to "analyze learnings", "find patterns", "what should become rules",
"compound my learnings", or after accumulating 5+ session learnings. Analyzes
session learnings to detect recurring patterns (3+ occurrences), categorizes them
as rule/skill/hook candidates, and generates proposals for user approval.
trigger: |
- "What patterns should become permanent?"
- "Analyze my learnings"
- "Compound learnings"
- "Turn learnings into rules"
- After completing a feature with multiple sessions
skip_when: |
- Fewer than 3 learning files exist
- Just completed first session (no patterns to detect yet)
- Looking for specific past learning (use artifact search instead)
related:
complementary: [ring:handoff-tracking, artifact-query]
---
# Compound Learnings
Transform ephemeral session learnings into permanent, compounding capabilities.
## Overview
This skill analyzes accumulated learnings from past sessions, detects recurring patterns (appearing in 3+ sessions), and generates proposals for new rules, skills, or hooks. User approval is REQUIRED before creating any permanent artifacts.
**Core Principle:** The system improves itself over time by learning from successes and failures.
## When to Use
- "What should I learn from recent sessions?"
- "Improve my setup based on recent work"
- "Turn learnings into skills/rules"
- "What patterns should become permanent?"
- "Compound my learnings"
- After completing a multi-session feature
## When NOT to Use
- Fewer than 3 learning files exist (insufficient data)
- Looking for a specific past learning (use artifact search)
- First session on a new project (no history yet)
## Process
### Step 1: Gather Learnings
```bash
# List learnings (most recent first)
ls -t $PROJECT_ROOT/.ring/cache/learnings/*.md 2>/dev/null | head -20
# Count total learnings
ls $PROJECT_ROOT/.ring/cache/learnings/*.md 2>/dev/null | wc -l
```
**If fewer than 3 files:** STOP. Insufficient data for pattern detection.
Read the most recent 5-10 files for analysis.
### Step 2: Extract and Consolidate Patterns
For each learnings file, extract entries from:
| Section Header | What to Extract |
|----------------|-----------------|
| `## What Worked` | Success patterns |
| `## What Failed` | Anti-patterns (invert to rules) |
| `## Key Decisions` | Design principles |
| `## Patterns` | Direct pattern candidates |
**Consolidate similar patterns before counting:**
| Raw Patterns | Consolidated To |
|--------------|-----------------|
| "Check artifacts before editing", "Verify outputs first", "Look before editing" | "Observe outputs before editing code" |
| "TDD approach", "Test-driven development", "Write tests first" | "Use TDD workflow" |
Use the most general formulation.
### Step 3: Detect Recurring Patterns
**Signal thresholds:**
| Occurrences | Action |
|-------------|--------|
| 1 | Skip (insufficient signal) |
| 2 | Consider - present to user for information only |
| 3+ | Strong signal - recommend creating permanent artifact |
| 4+ | Definitely create |
**Only patterns appearing in 3+ DIFFERENT sessions qualify for proposals.**
### Step 4: Categorize Patterns
For each qualifying pattern, determine artifact type:
```
Is it a sequence of commands/steps?
→ YES → SKILL (executable process)
→ NO ↓
Should it run automatically on an event?
→ YES → HOOK (SessionEnd, PostToolUse, etc.)
→ NO ↓
Is it "when X, do Y" or "never do X"?
→ YES → RULE (behavioral heuristic)
→ NO → Skip (not worth capturing)
```
**Examples:**
| Pattern | Type | Why |
|---------|------|-----|
| "Run tests before committing" | Hook (PreCommit) | Automatic gate |
| "Step-by-step debugging process" | Skill | Manual sequence |
| "Always use explicit file paths" | Rule | Behavioral heuristic |
### Step 5: Generate Proposals
Present each proposal in this format:
```markdown
---
## Pattern: [Generalized Name]
**Signal:** [N] sessions ([list session IDs])
**Category:** [rule / skill / hook]
**Rationale:** [Why this artifact type, why worth creating]
**Draft Content:**
[Preview of what would be created]
**Target Location:** `.ring/generated/rules/[name].md` or `.ring/generated/skills/[name]/SKILL.md`
**Your Action:** [APPROVE] / [REJECT] / [MODIFY]
---
```
### Step 6: WAIT FOR USER APPROVAL
**CRITICAL: DO NOT create any files without explicit user approval.**
User must explicitly say:
- "Approve" or "Create it" → Proceed to create
- "Reject" or "Skip" → Mark as rejected, do not create
- "Modify" → User provides changes, then re-propose
**NEVER assume approval. NEVER create without explicit consent.**
### Step 7: Create Approved Artifacts
#### Before Creating Any Artifact:
Ensure the target directories exist:
```bash
mkdir -p $PROJECT_ROOT/.ring/generated/rules
mkdir -p $PROJECT_ROOT/.ring/generated/skills
mkdir -p $PROJECT_ROOT/.ring/generated/hooks
```
#### For Rules:
Create file at `.ring/generated/rules/<slug>.md` with:
- Rule name and context
- Pattern description
- DO / DON'T sections
- Source sessions
#### For Skills:
Create directory `.ring/generated/skills/<slug>/` with `SKILL.md` containing:
- YAML frontmatter (name, description)
- Overview (what it does)
- When to Use (triggers)
- When NOT to Use
- Step-by-step process
- Source sessions
**Note:** Generated skills are project-local. To make them discoverable, add to your project's `CLAUDE.md`:
```markdown
## Project-Specific Skills
See `.ring/generated/skills/` for learnings-based skills.
```
#### For Hooks:
Create in `.ring/generated/hooks/<name>.sh` following Ring's hook patterns.
Register in project's `.claude/hooks.json` (not plugin's hooks.json).
### Step 8: Archive Processed Learnings
After creating artifacts, move processed learnings:
```bash
# Create archive directory
mkdir -p $PROJECT_ROOT/.ring/cache/learnings/archived
# Move processed files (preserving them for reference)
mv $PROJECT_ROOT/.ring/cache/learnings/2025-*.md $PROJECT_ROOT/.ring/cache/learnings/archived/
```
### Step 9: Summary Report
```markdown
## Compounding Complete
**Learnings Analyzed:** [N] sessions
**Patterns Found:** [M]
**Artifacts Created:** [K]
### Created:
- Rule: `explicit-paths.md` - Always use explicit file paths
- Skill: `ring:systematic-debugging` - Step-by-step debugging workflow
### Skipped (insufficient signal):
- "Pattern X" (2 occurrences - below threshold)
### Rejected by User:
- "Pattern Y" - User chose not to create
**Your setup is now permanently improved.**
```
## Quality Checks
Before creating any artifact:
1. **Is it general enough?** Would it apply in other projects?
2. **Is it specific enough?** Does it give concrete guidance?
3. **Does it already exist?** Check `.ring/generated/rules/` and `.ring/generated/skills/` first
4. **Is it the right type?** Sequences → skills, heuristics → rules
## Common Mistakes
| Mistake | Why It's Wrong | Correct Approach |
|---------|----------------|------------------|
| Creating without approval | Violates user consent | ALWAYS wait for explicit approval |
| Low-signal patterns | 1-2 occurrences is noise | Require 3+ session occurrences |
| Too specific | Won't apply elsewhere | Generalize to broader principle |
| Too vague | No actionable guidance | Include concrete DO/DON'T |
| Duplicate artifacts | Wastes space, confuses | Check existing rules/skills first |
## Files Reference
**Project-local data (per-project):**
- Learnings input: `.ring/cache/learnings/*.md`
- Proposals: `.ring/cache/proposals/pending.json`
- History: `.ring/cache/proposals/history.json`
- Generated rules: `.ring/generated/rules/<name>.md`
- Generated skills: `.ring/generated/skills/<name>/SKILL.md`
- Generated hooks: `.ring/generated/hooks/<name>.sh`
**Plugin code (shared, read-only):**
- Library: `default/lib/compound_learnings/`
- Skill: `default/skills/compound-learnings/SKILL.md`
- Command: `default/commands/compound-learnings.md`

View file

@ -1,247 +0,0 @@
---
name: ring:continuity-ledger
description: |
Create or update continuity ledger for state preservation across /clear operations.
Ledgers maintain session state externally, surviving context resets with full fidelity.
trigger: |
- Before running /clear
- Context usage approaching 70%+
- Multi-phase implementations (3+ phases)
- Complex refactors spanning multiple sessions
- Any session expected to hit 85%+ context
skip_when: |
- Quick tasks (< 30 min estimated)
- Simple single-file bug fixes
- Already using handoffs for cross-session transfer
- No multi-phase work in progress
---
# Continuity Ledger
Maintain a ledger file that survives `/clear` for long-running sessions. Unlike handoffs (cross-session), ledgers preserve state within a session.
**Why clear instead of compact?** Each compaction is lossy compression - after several compactions, you're working with degraded context. Clearing + loading the ledger gives you fresh context with full signal.
## When to Use
- Before running `/clear`
- Context usage approaching 70%+
- Multi-day implementations
- Complex refactors you pick up/put down
- Any session expected to hit 85%+ context
## When NOT to Use
- Quick tasks (< 30 min)
- Simple bug fixes
- Single-file changes
- Already using handoffs for cross-session transfer
## Ledger Location
Ledgers are stored in: `$PROJECT_ROOT/.ring/ledgers/`
Format: `CONTINUITY-<session-name>.md`
**Use kebab-case for session name** (e.g., `auth-refactor`, `api-migration`)
## Process
### 1. Determine Ledger File
Check if a ledger already exists:
```bash
ls "$PROJECT_ROOT/.ring/ledgers/CONTINUITY-"*.md 2>/dev/null
```
- **If exists**: Update the existing ledger
- **If not**: Create new file with the template below
### 2. Create/Update Ledger
**REQUIRED SECTIONS (all must be present):**
```markdown
# Session: <name>
Updated: <ISO timestamp>
## Goal
<Success criteria - what does "done" look like?>
## Constraints
<Tech requirements, patterns to follow, things to avoid>
## Key Decisions
<Choices made with brief rationale>
- Decision 1: Chose X over Y because...
- Decision 2: ...
## State
- Done:
- [x] Phase 1: <completed phase>
- [x] Phase 2: <completed phase>
- Now: [->] Phase 3: <current focus - ONE thing only>
- Next:
- [ ] Phase 4: <queued item>
- [ ] Phase 5: <queued item>
## Open Questions
- UNCONFIRMED: <things needing verification after clear>
- UNCONFIRMED: <assumptions that should be validated>
## Working Set
<Active files, branch, test commands>
- Branch: `feature/xyz`
- Key files: `src/auth/`, `tests/auth/`
- Test cmd: `npm test -- --grep auth`
- Build cmd: `npm run build`
```
### 3. Checkbox States
| Symbol | Meaning |
|--------|---------|
| `[x]` | Completed |
| `[->]` | In progress (current) |
| `[ ]` | Pending |
**Why checkboxes in files:** TodoWrite survives compaction, but the *understanding* around those todos degrades each time context is compressed. File-based checkboxes are never compressed - full fidelity preserved.
### 4. Real-Time Update Rule (MANDATORY)
**⛔ HARD GATE: Update ledger IMMEDIATELY after completing ANY phase.**
You (the AI) are the one doing the work. You know exactly when:
- A phase is complete
- A decision is made
- An open question is resolved
**There is NO excuse to wait for the user to ask.** This is the same discipline as `TodoWrite` - update in real-time.
| After This Event | MUST Do This | Before |
|------------------|--------------|--------|
| Complete a phase | Mark `[x]`, move `[->]` to next | Proceeding to next phase |
| All phases done | Add `Status: COMPLETED` | Telling user "done" |
| Make key decision | Add to Key Decisions section | Moving on |
| Resolve open question | Change UNCONFIRMED → CONFIRMED | Proceeding |
**Anti-Rationalization:**
| Rationalization | Why It's WRONG | Required Action |
|-----------------|----------------|-----------------|
| "I'll update after I finish" | You'll forget. State drifts. User asks why ledger is stale. | **Update NOW** |
| "It's just one phase" | One phase becomes three. Ledger shows Phase 2 when you're on Phase 5. | **Update NOW** |
| "User will ask me to update" | User shouldn't have to. You're the AI doing the work. You know. | **Update NOW** |
| "I'm in the flow, don't want to stop" | 10 seconds to update vs. explaining why ledger is wrong later. | **Update NOW** |
### 5. Update Guidelines
**When to update the ledger:**
- **IMMEDIATELY after completing a phase** (MANDATORY - see above)
- Session start: Read and refresh
- After major decisions
- Before `/clear`
- At natural breakpoints
- When context usage >70%
**What to update:**
- Move completed items from "Now" to "Done" (change `[->]` to `[x]`)
- Update "Now" with current focus
- Add new decisions as they're made
- Mark items as UNCONFIRMED if uncertain
- Add `Status: COMPLETED` when all phases done
### 6. After Clear Recovery
When resuming after `/clear`:
1. **Ledger loads automatically** (SessionStart hook)
2. **Find `[->]` marker** to see current phase
3. **Review UNCONFIRMED items**
4. **Ask 1-3 targeted questions** to validate assumptions
5. **Update ledger** with clarifications
6. **Continue work** with fresh context
## Template Response
After creating/updating the ledger, respond:
```
Continuity ledger updated: .ring/ledgers/CONTINUITY-<name>.md
Current state:
- Done: <summary of completed phases>
- Now: <current focus>
- Next: <upcoming phases>
Ready for /clear - ledger will reload on resume.
```
## UNCONFIRMED Prefix
Mark uncertain items explicitly:
```markdown
## Open Questions
- UNCONFIRMED: Does the auth middleware need updating?
- UNCONFIRMED: Are we using v2 or v3 of the API?
```
After `/clear`, these prompt you to verify before proceeding.
## Comparison with Other Tools
| Tool | Scope | Fidelity |
|------|-------|----------|
| CLAUDE.md | Project | Always fresh, stable patterns |
| TodoWrite | Turn | Survives compaction, but understanding degrades |
| CONTINUITY-*.md | Session | External file - never compressed, full fidelity |
| Handoffs | Cross-session | External file - detailed context for new session |
## Example
```markdown
# Session: auth-refactor
Updated: 2025-01-15T14:30:00Z
## Goal
Replace JWT auth with session-based auth. Done when all tests pass and no JWT imports remain.
## Constraints
- Must maintain backward compat for 2 weeks (migration period)
- Use existing Redis for session storage
- No new dependencies
## Key Decisions
- Session tokens: UUID v4 (simpler than signed tokens for our use case)
- Storage: Redis with 24h TTL (matches current JWT expiry)
- Migration: Dual-auth period, feature flag controlled
## State
- Done:
- [x] Phase 1: Session model
- [x] Phase 2: Redis integration
- [x] Phase 3: Login endpoint
- Now: [->] Phase 4: Logout endpoint and session invalidation
- Next:
- [ ] Phase 5: Middleware swap
- [ ] Phase 6: Remove JWT
- [ ] Phase 7: Update tests
## Open Questions
- UNCONFIRMED: Does rate limiter need session awareness?
## Working Set
- Branch: `feature/session-auth`
- Key files: `src/auth/session.ts`, `src/middleware/auth.ts`
- Test cmd: `npm test -- --grep session`
```
## Additional Notes
- **Keep it concise** - Brevity matters for context
- **One "Now" item** - Forces focus, prevents sprawl
- **UNCONFIRMED prefix** - Signals what to verify after clear
- **Update frequently** - Stale ledgers lose value quickly
- **Clear > compact** - Fresh context beats degraded context

View file

@ -1,207 +0,0 @@
---
name: ring:handoff-tracking
description: |
Create detailed handoff documents for session transitions. Captures task status,
learnings, decisions, and next steps in a structured format that gets indexed
for future retrieval.
trigger: |
- Session ending or transitioning
- User runs /ring:create-handoff command
- Context pressure requiring /clear
- Completing a major milestone
skip_when: |
- Quick Q&A session with no implementation
- No meaningful work to document
- Session was exploratory with no decisions
related:
before: [ring:executing-plans, ring:subagent-driven-development]
after: [artifact-query]
---
# Handoff Tracking
## Overview
Create structured handoff documents that preserve session context for future sessions. Handoffs capture what was done, what worked, what failed, key decisions, and next steps.
**Core principle:** Handoffs are indexed immediately on creation, making them searchable before the session ends.
**Announce at start:** "I'm creating a handoff document to preserve this session's context."
## When to Create Handoffs
| Situation | Action |
|-----------|--------|
| Session ending | ALWAYS create handoff |
| Running /clear | Create handoff BEFORE clear |
| Major milestone complete | Create handoff to checkpoint progress |
| Context at 70%+ | Create handoff, then /clear |
| Blocked and need help | Create handoff with blockers documented |
## Handoff File Location
**Path:** `docs/handoffs/{session-name}/YYYY-MM-DD_HH-MM-SS_{description}.md`
Where:
- `{session-name}` - From active work context (e.g., `context-management`, `auth-feature`)
- `YYYY-MM-DD_HH-MM-SS` - Current timestamp in 24-hour format
- `{description}` - Brief kebab-case description of work done
**Example:** `docs/handoffs/context-management/2025-12-27_14-30-00_handoff-tracking-skill.md`
If no clear session context, use `general/` as the folder name.
## Handoff Document Template
Use this exact structure for all handoff documents:
~~~markdown
---
date: {ISO timestamp with timezone}
session_name: {session-name}
git_commit: {current commit hash}
branch: {current branch}
repository: {repository name}
topic: "{Feature/Task} Implementation"
tags: [implementation, {relevant-tags}]
status: {complete|in_progress|blocked}
outcome: UNKNOWN
root_span_id: {trace ID if available, empty otherwise}
turn_span_id: {turn span ID if available, empty otherwise}
---
# Handoff: {concise description}
## Task Summary
{Description of task(s) worked on and their status: completed, in_progress, blocked.
If following a plan, reference the plan document and current phase.}
## Critical References
{2-3 most important file paths that must be read to continue this work.
Leave blank if none.}
- `path/to/critical/file.md`
## Recent Changes
{Files modified in this session with line references}
- `src/path/to/file.py:45-67` - Added validation logic
- `tests/path/to/test.py:10-30` - New test cases
## Learnings
### What Worked
{Specific approaches that succeeded - these get indexed for future sessions}
- Approach: {description} - worked because {reason}
- Pattern: {pattern name} was effective for {use case}
### What Failed
{Attempted approaches that didn't work - helps future sessions avoid same mistakes}
- Tried: {approach} -> Failed because: {reason}
- Error: {error type} when {action} -> Fixed by: {solution}
### Key Decisions
{Important choices made and WHY - future sessions reference these}
- Decision: {choice made}
- Alternatives: {other options considered}
- Reason: {why this choice}
## Files Modified
{Exhaustive list of files created or modified}
- `path/to/new/file.py` - NEW: Description
- `path/to/existing/file.py:100-150` - MODIFIED: Description
## Action Items & Next Steps
{Prioritized list for the next session}
1. {Most important next action}
2. {Second priority}
3. {Additional items}
## Other Notes
{Anything else relevant: codebase locations, useful commands, gotchas}
~~~
## The Process
### Step 1: Gather Session Metadata
```bash
# Get current git state
git rev-parse HEAD # Commit hash
git branch --show-current # Branch name
git remote get-url origin # Repository
# Get timestamp
date -u +"%Y-%m-%dT%H:%M:%SZ"
```
### Step 2: Determine Session Name
Check for active work context:
1. Recent plan files in `docs/plans/` - extract feature name
2. Recent branch name - use as session context
3. If unclear, use `general`
### Step 3: Write Handoff Document
1. Create handoff directory if needed: `mkdir -p docs/handoffs/{session-name}/`
2. Write handoff file with template structure
3. Fill in all sections with session details
4. Be thorough in learnings - these feed compound learning
### Step 4: Verify Indexing
After writing the handoff, verify it was indexed:
```bash
# Check artifact index updated (if database exists)
sqlite3 .ring/cache/artifact-index/context.db \
"SELECT id, session_name FROM handoffs ORDER BY indexed_at DESC LIMIT 1"
```
The PostToolUse hook automatically indexes handoffs on Write.
## Integration with Ring
### Execution Reports
When working within dev-team cycles, the handoff's "Recent Changes" and "Files Modified" sections should mirror the execution report format:
| Metric | Include |
|--------|---------|
| Duration | Time spent on session |
| Tasks Completed | X/Y from plan |
| Files Created | N |
| Files Modified | N |
| Tests Added | N |
### Session Traces
If session tracing is enabled (Braintrust, etc.), include:
- `root_span_id` - Main trace ID
- `turn_span_id` - Current turn span
These enable correlation between handoffs and detailed session logs.
## Outcome Tracking
Outcomes are marked AFTER the handoff is created, either:
1. User responds to Stop hook prompt
2. User runs outcome marking command later
**Valid outcomes:**
| Outcome | Meaning |
|---------|---------|
| SUCCEEDED | Task completed successfully |
| PARTIAL_PLUS | Mostly done, minor issues remain |
| PARTIAL_MINUS | Some progress, major issues remain |
| FAILED | Task abandoned or blocked |
Handoffs start with `outcome: UNKNOWN` and get updated when marked.
## Remember
- **Be thorough in Learnings** - These feed the compound learning system
- **Include file:line references** - Makes resumption faster
- **Document WHY not just WHAT** - Decisions without rationale are useless
- **Index happens automatically** - PostToolUse hook handles it
- **Outcome is separate** - Don't try to guess outcome, leave as UNKNOWN

View file

@ -282,52 +282,6 @@ Your human partner's specific instructions describe WHAT to do, not HOW.
**Why:** Specific instructions mean clear requirements, which is when workflows matter MOST. Skipping process on "simple" tasks is how simple tasks become complex problems.
## Context Management & Self-Improvement
Ring includes skills for managing context and enabling self-improvement:
| Skill | Use When | Trigger |
|-------|----------|---------|
| `continuity-ledger` | Save session state for future resumption | At 70%+ context OR task completion |
| `ring:create-handoff` | Full handoff document with all context | At 85%+ context OR session end |
| `artifact-query` | Search past handoffs, plans, or outcomes by keywords | Need historical context |
| `ring:handoff-tracking` | Mark task completion with what_worked/what_failed/key_decisions | Task complete |
| `compound-learnings` | Analyze learnings from multiple sessions, detect patterns | After 3+ sessions |
**Compound Learnings workflow:** Session ends → Hook extracts learnings → After 3+ sessions, patterns emerge → User approves → Permanent rules/skills created.
### MANDATORY Context Preservation (NON-NEGOTIABLE)
**Context warnings are BEHAVIORAL TRIGGERS, not informational messages.**
| Context Level | Warning Type | MANDATORY Action |
|---------------|--------------|------------------|
| 50-69% (info) | `<context-warning>` | Acknowledge, plan for handoff |
| 70-84% (warning) | `<MANDATORY-USER-MESSAGE>` | **CREATE ledger NOW** - No exceptions |
| 85%+ (critical) | `<MANDATORY-USER-MESSAGE>` | **STOP + handoff + /clear** - Immediate |
**When you receive a MANDATORY-USER-MESSAGE about context:**
1. Display the message verbatim at start of response (per MANDATORY-USER-MESSAGE contract)
2. Execute the required action BEFORE continuing other work
3. Do NOT rationalize delaying action
**Anti-Rationalization for Context Management:**
| Rationalization | Why It's WRONG | Required Action |
|-----------------|----------------|-----------------|
| "I'll create handoff after this task" | Context may truncate mid-task, losing everything | **Create handoff NOW** |
| "70% is just a warning, not urgent" | 70% = MANDATORY action, not suggestion | **Create ledger immediately** |
| "The message is informational" | MANDATORY-USER-MESSAGE = behavioral trigger | **Execute required action** |
| "User didn't ask for handoff" | System requires it for context safety | **Create handoff anyway** |
| "I'm almost done, can finish first" | "Almost done" at 85% = high truncation risk | **STOP and handoff NOW** |
| "Small task, won't use much more context" | Every response adds ~2500 tokens | **Follow threshold rules** |
**Verification Checklist:**
- [ ] At 70%+: Did I create a continuity-ledger?
- [ ] At 85%+: Did I STOP, create handoff, and recommend /clear?
- [ ] Did I display MANDATORY-USER-MESSAGE verbatim?
- [ ] Did I execute required action BEFORE other work?
## Summary
**Starting any task:**

View file

@ -34,40 +34,16 @@ This skill dispatches a specialized agent to write comprehensive implementation
## The Process
**Step 1: Query Historical Precedent (MANDATORY)**
Before planning, query the artifact index for historical context:
```bash
# Keywords MUST be alphanumeric with spaces only (sanitize before use)
python3 default/lib/artifact-index/artifact_query.py --mode planning "keywords" --json
```
**Keyword Extraction (MANDATORY):**
1. Extract topic keywords from the planning request
2. Sanitize keywords: Keep only alphanumeric characters, hyphens, and spaces
3. Example: "Implement OAuth2 authentication!" → keywords: "oauth2 authentication"
**Safety:** Never include shell metacharacters (`;`, `$`, `` ` ``, `|`, `&`) in keywords.
Results inform the plan:
- `successful_handoffs` → Reference patterns that worked
- `failed_handoffs` → WARN in plan, design to avoid
- `relevant_plans` → Review for approach ideas
If `is_empty_index: true` → Proceed without precedent (normal for new projects).
**Step 2: Dispatch Write-Plan Agent**
**Step 1: Dispatch Write-Plan Agent**
Dispatch via `Task(subagent_type: "ring:write-plan", model: "opus")` with:
- Instructions to create bite-sized tasks (2-5 min each)
- Include exact file paths, complete code, verification steps
- **Include Historical Precedent section** in plan with query results
- Save to `docs/plans/YYYY-MM-DD-<feature-name>.md`
**Step 3: Validate Plan Against Failure Patterns**
**Step 2: Validate Plan**
After the plan is saved, validate it against known failures:
After the plan is saved, validate it:
```bash
python3 default/lib/validate-plan-precedent.py docs/plans/YYYY-MM-DD-<feature>.md
@ -75,14 +51,12 @@ python3 default/lib/validate-plan-precedent.py docs/plans/YYYY-MM-DD-<feature>.m
**Interpretation:**
- `PASS` → Plan is safe to execute
- `WARNING` → Plan has >30% keyword overlap with past failures
- `WARNING` → Plan has issues to address
- Review the warnings in the output
- Update plan to address the failure patterns
- Update plan to address the issues
- Re-run validation until PASS
**Note:** If artifact index is unavailable, validation passes automatically (nothing to check against).
**Step 4: Ask User About Execution**
**Step 3: Ask User About Execution**
Ask via `AskUserQuestion`: "Execute now?" Options:
1. Execute now → `ring:subagent-driven-development`
@ -95,11 +69,11 @@ Ask via `AskUserQuestion`: "Execute now?" Options:
## What the Agent Does
**Query precedent** → Explore codebase → identify files → break into bite-sized tasks (2-5 min) → write complete code → include exact commands → add review checkpoints → **include Historical Precedent section** verify Zero-Context Test → save to `docs/plans/YYYY-MM-DD-<feature>.md` → report back
Explore codebase → identify files → break into bite-sized tasks (2-5 min) → write complete code → include exact commands → add review checkpoints → verify Zero-Context Test → save to `docs/plans/YYYY-MM-DD-<feature>.md` → report back
## Requirements for Plans
Every plan: **Historical Precedent section** | Header (goal, architecture, tech stack) | Verification commands with expected output | Exact file paths (never "somewhere in src") | Complete code (never "add validation here") | Bite-sized steps with verification | Failure recovery | Review checkpoints | Zero-Context Test | **Recommended agents per task** | **Avoids known failure patterns**
Every plan: Header (goal, architecture, tech stack) | Verification commands with expected output | Exact file paths (never "somewhere in src") | Complete code (never "add validation here") | Bite-sized steps with verification | Failure recovery | Review checkpoints | Zero-Context Test | **Recommended agents per task**
## Agent Selection

View file

@ -1,48 +0,0 @@
# Session: context-management-system
Updated: 2025-12-27T14:30:00Z
## Goal
Implement context management system for Ring plugin. Done when:
1. Continuity ledger skill exists and is functional
2. SessionStart hook loads ledgers automatically
3. PreCompact/Stop hooks save ledgers automatically
4. All tests pass
## Constraints
- Must integrate with existing Ring hook infrastructure
- Must use bash for hooks (matching existing patterns)
- Must not break existing session-start.sh functionality
- Ledgers stored in .ring/ledgers/ (not default/ plugin directory)
## Key Decisions
- Ledger location: .ring/ledgers/ (project-level, not plugin-level)
- Rationale: Ledgers are project-specific state, not plugin artifacts
- Checkpoint marker: [->] for current phase
- Rationale: Visually distinct from [x] and [ ], grep-friendly
- Hook trigger: PreCompact AND Stop
- Rationale: Catch both automatic compaction and manual stop
## State
- Done:
- [x] Phase 1: Create continuity-ledger skill directory
- [x] Phase 2: Write SKILL.md with full documentation
- [x] Phase 3: Add ledger detection to session-start.sh
- [x] Phase 4: Create ledger-save.sh hook
- [x] Phase 5: Register hooks in hooks.json
- Now: [->] Phase 6: Create templates and documentation
- Next:
- [ ] Phase 7: Integration testing
- [ ] Phase 8: Clean up test artifacts
## Open Questions
- UNCONFIRMED: Does Claude Code support PreCompact hook? (need to verify)
- UNCONFIRMED: Should ledger-save.sh also index to artifact database?
## Working Set
- Branch: `main`
- Key files:
- `default/skills/continuity-ledger/SKILL.md`
- `default/hooks/session-start.sh`
- `default/hooks/ledger-save.sh`
- Test cmd: `echo '{}' | default/hooks/ledger-save.sh | jq .`
- Build cmd: N/A (bash scripts)

View file

@ -1,58 +0,0 @@
# Session: <session-name>
Updated: <YYYY-MM-DDTHH:MM:SSZ>
## Goal
<What does "done" look like? Include measurable success criteria.>
## Constraints
<Technical requirements, patterns to follow, things to avoid>
- Constraint 1: ...
- Constraint 2: ...
## Key Decisions
<Choices made with brief rationale>
- Decision 1: Chose X over Y because...
- Decision 2: ...
## State
- Done:
- [x] Phase 1: <completed phase description>
- Now: [->] Phase 2: <current focus - ONE thing only>
- Next:
- [ ] Phase 3: <queued item>
- [ ] Phase 4: <queued item>
## Open Questions
- UNCONFIRMED: <things needing verification after clear>
- UNCONFIRMED: <assumptions that should be validated>
## Working Set
- Branch: `<branch-name>`
- Key files: `<primary file or directory>`
- Test cmd: `<command to run tests>`
- Build cmd: `<command to build>`
---
## Ledger Usage Notes
**Checkbox States:**
- `[x]` = Completed
- `[->]` = In progress (current)
- `[ ]` = Pending
**When to Update:**
- After completing a phase (change `[->]` to `[x]`, move `[->]` to next)
- Before running `/clear`
- When context usage > 70%
**After Clear:**
1. Ledger loads automatically
2. Find `[->]` for current phase
3. Verify UNCONFIRMED items
4. Continue work
**To create a new ledger:**
```bash
cp .ring/templates/CONTINUITY-template.md .ring/ledgers/CONTINUITY-<session-name>.md
```

View file

@ -1,333 +0,0 @@
---
date: 2026-01-14T21:24:35Z
session_name: codereview-integration
git_commit: 186a5a2ddd914c32d2afe385c7b40aa4d10823de
branch: main
repository: ring
topic: "Codereview Pipeline: Unknown Language Handling + Security Hardening"
tags: [security, codereview, pipeline, checksums, TOCTOU, graceful-degradation]
status: complete
outcome: SUCCESS
root_span_id:
turn_span_id:
---
# Handoff: Codereview Pipeline Security Hardening Complete
## Task Summary
**Objective:** Fix pre-analysis pipeline to handle unknown language gracefully + implement hybrid security model with checksum verification and build-from-source fallback.
**Status:** Complete
**What was accomplished:**
1. Fixed pipeline to skip ast/callgraph/dataflow phases gracefully when language is "unknown" (markdown-only commits)
2. Implemented hybrid security model: checksum generation + verification + build-from-source fallback
3. Addressed all critical/high/medium/low security issues found in code review
4. Fixed TOCTOU race condition with atomic verify-and-execute pattern
5. Added macOS compatibility (shasum fallback)
6. Made unverified mode fail-closed by default
**Architecture implemented:**
```
Binary Found? ──Yes──> Verify Checksum ──Pass──> Execute
│ │
No Fail
│ │
└────> Build from Source <────┘
┌─────┴─────┐
Success Fail
│ │
Execute Degraded Mode
```
## Critical References
Must read to continue work:
- `scripts/codereview/cmd/run-all/main.go:65-76` - shouldSkipForUnknownLanguage helper
- `scripts/codereview/cmd/run-all/main.go:213` - Skip field added to Phase struct
- `scripts/codereview/cmd/run-all/main.go:581-589` - Skip execution logic
- `scripts/codereview/build-release.sh:347-407` - Checksum generation with macOS support
- `.github/workflows/build-codereview.yml:112-145` - CI checksum verification
- `default/skills/requesting-code-review/SKILL.md:288-359` - secure_execute_binary function
## Recent Changes
**Files created:**
- `default/lib/codereview/bin/CHECKSUMS.sha256` - NEW: Root checksums for all platforms
- `default/lib/codereview/bin/darwin_amd64/CHECKSUMS.sha256` - NEW: Platform checksums
- `default/lib/codereview/bin/darwin_arm64/CHECKSUMS.sha256` - NEW: Platform checksums
- `default/lib/codereview/bin/linux_amd64/CHECKSUMS.sha256` - NEW: Platform checksums
- `default/lib/codereview/bin/linux_arm64/CHECKSUMS.sha256` - NEW: Platform checksums
**Files modified:**
- `scripts/codereview/cmd/run-all/main.go:65-76` - ADDED: shouldSkipForUnknownLanguage helper
- `scripts/codereview/cmd/run-all/main.go:213` - ADDED: Skip field to Phase struct
- `scripts/codereview/cmd/run-all/main.go:234` - ADDED: SkipReason field to PhaseResult
- `scripts/codereview/cmd/run-all/main.go:192-197` - ADDED: unknown-ast.json to detectASTOutputFile
- `scripts/codereview/cmd/run-all/main.go:342,365,387` - ADDED: Skip conditions to phases
- `scripts/codereview/cmd/run-all/main.go:581-589` - ADDED: Skip execution in executePhase
- `scripts/codereview/build-release.sh:347-407` - ADDED: Checksum generation with macOS support
- `.github/workflows/build-codereview.yml:71-145` - MODIFIED: Added checksum validation
- `default/skills/requesting-code-review/SKILL.md:288-400` - REPLACED: verify_binary with secure_execute_binary
- `default/lib/codereview/bin/*` - REBUILT: 28 binaries with all fixes
## Learnings
### What Worked
**1. Graceful degradation for unknown language**
- Approach: Check scope.Language in Skip function, skip ast/callgraph/dataflow for "unknown"
- Why it worked: Allows pipeline to pass on markdown-only commits without failing
- Pattern: Phase-level skip conditions with clear reasons
- Before: FAIL with "exit status 1", 1.85s
- After: SUCCESS with "3 skipped", 156ms
**2. Atomic verify-and-execute pattern (TOCTOU fix)**
- Approach: Copy binary to secure temp, verify copy, execute copy immediately
- Why it worked: Prevents attacker from swapping binary between verification and execution
- Pattern: mktemp + trap for cleanup
```bash
secure_copy=$(mktemp)
trap "rm -f '$secure_copy'" EXIT
cp "$binary" "$secure_copy" && chmod 700 "$secure_copy"
# Verify $secure_copy
"$secure_copy" "${args[@]}"
```
**3. Exact match with awk instead of grep**
- Approach: `awk -v name="$binary_name" '$2 == name {print $1}'`
- Why it worked: Prevents partial string matches (run-all vs run-all-malicious)
- Before: `grep "$binary_name"` matched any substring
- After: Only exact field match
**4. macOS compatibility with command detection**
- Approach: Check for sha256sum first, fallback to shasum -a 256
- Why it worked: macOS doesn't have sha256sum by default
- Pattern:
```bash
if command -v sha256sum &> /dev/null; then
CHECKSUM_CMD="sha256sum"
elif command -v shasum &> /dev/null; then
CHECKSUM_CMD="shasum -a 256"
fi
```
**5. Fail-closed unverified mode**
- Approach: Default to requiring checksums, only bypass with explicit RING_ALLOW_UNVERIFIED=true
- Why it worked: Security by default, opt-in for development
- Before: Missing checksum file → warning + continue
- After: Missing checksum file → error + fail (unless explicitly bypassed)
**6. CI checksum integrity verification**
- Approach: Added `sha256sum --check CHECKSUMS.sha256` step in CI
- Why it worked: Validates checksums are correct, not just that files exist
- Before: CI only counted files (28 binaries + 5 checksums)
- After: CI verifies every checksum matches its binary
### What Failed
**1. Initial checksum generation included self-reference**
- Tried: `sha256sum * > CHECKSUMS.sha256` without deleting existing file
- Failed because: Existing CHECKSUMS.sha256 was included in the glob
- Fixed by: `rm -f CHECKSUMS.sha256` before regenerating
**2. Silent error suppression hid checksum generation failures**
- Tried: `sha256sum * > CHECKSUMS.sha256 2>/dev/null`
- Failed because: Errors were hidden, builds succeeded with incomplete checksums
- Fixed by: Removed `2>/dev/null`, added explicit error checking with `if !`
**3. grep partial match vulnerability**
- Tried: `grep "$binary_name" "$checksum_file"`
- Failed because: Matched partial strings (run-all matched run-all-debug)
- Fixed by: Switched to awk exact match
### Key Decisions
**Decision 1: Skip phases instead of failing for unknown language**
- Alternatives:
- Fail pipeline (original behavior)
- Try to force language detection
- Skip entire pre-analysis
- Reason: Allows documentation PRs to pass; context files still generated (empty but valid)
**Decision 2: Hybrid security model (checksums + build-from-source)**
- Alternatives:
- Checksums only
- Build on every invocation
- GitHub Releases for binaries
- Reason: Balance of security, convenience, and offline capability
**Decision 3: Atomic verify-and-execute (copy-based approach)**
- Alternatives:
- File locking with flock
- Verify original then execute (TOCTOU vulnerable)
- Always build from source
- Reason: Works on all platforms (Linux + macOS), prevents TOCTOU without complex locking
**Decision 4: Fail-closed for missing checksums**
- Alternatives:
- Warn and continue (original)
- Always require checksums
- Different behavior for plugin vs dev repo
- Reason: Security by default with explicit opt-out for development scenarios
**Decision 5: Fix all issues immediately (critical through low)**
- Alternatives:
- Fix only critical
- Fix critical + high, defer medium/low
- Merge as-is, track as tech debt
- Reason: User requested comprehensive fix; low issues were low-effort with high security value
## Files Modified
### Created
- `docs/handoffs/codereview-integration/2026-01-14_21-24-35_security-hardening-complete.md` - NEW: This handoff (you are here)
- `default/lib/codereview/bin/CHECKSUMS.sha256` - NEW: Root checksums (28 entries)
- `default/lib/codereview/bin/darwin_amd64/CHECKSUMS.sha256` - NEW: 7 entries
- `default/lib/codereview/bin/darwin_arm64/CHECKSUMS.sha256` - NEW: 7 entries
- `default/lib/codereview/bin/linux_amd64/CHECKSUMS.sha256` - NEW: 7 entries
- `default/lib/codereview/bin/linux_arm64/CHECKSUMS.sha256` - NEW: 7 entries
### Modified
- `scripts/codereview/cmd/run-all/main.go` - MODIFIED: Skip conditions for unknown language (51 insertions, 13 deletions)
- `scripts/codereview/build-release.sh` - MODIFIED: Checksum generation with macOS support (35 insertions)
- `.github/workflows/build-codereview.yml` - MODIFIED: Checksum validation (60 insertions)
- `default/skills/requesting-code-review/SKILL.md` - MODIFIED: secure_execute_binary (141 insertions)
- `default/lib/codereview/bin/darwin_amd64/*` - REBUILT: 7 binaries
- `default/lib/codereview/bin/darwin_arm64/*` - REBUILT: 7 binaries
- `default/lib/codereview/bin/linux_amd64/*` - REBUILT: 7 binaries
- `default/lib/codereview/bin/linux_arm64/*` - REBUILT: 7 binaries
## Action Items & Next Steps
1. **Test end-to-end security flow**
- Test with valid checksums (should pass)
- Test with tampered binary (should fail and fallback to build)
- Test with missing checksums + RING_ALLOW_UNVERIFIED=true (should warn and run)
- Test with missing checksums without flag (should fail)
- Test on macOS to verify shasum fallback works
2. **Commit security hardening changes**
- Commit 1: run-all.go unknown language handling
- Commit 2: build-release.sh checksum generation
- Commit 3: CI workflow checksum validation
- Commit 4: SKILL.md secure_execute_binary
- Commit 5: Rebuilt binaries with checksums
3. **Update documentation**
- Add section to README.md about checksum verification
- Document RING_ALLOW_UNVERIFIED flag in MANUAL.md
- Add security model explanation to default/lib/codereview/README.md
4. **Monitor CI workflow execution**
- Watch first run of updated build-codereview.yml
- Verify checksum validation step passes
- Verify binaries are correctly verified
5. **Consider future enhancements**
- Add GPG/Sigstore signatures for authenticity (checksums only verify integrity)
- Add SLSA provenance attestation
- Consider separate checksum storage (e.g., GitHub Releases)
- Add binary size sanity checks before verification
## Other Notes
### Security Model Summary
**Before (Original):**
- No checksums
- No verification
- Direct execution of pre-built binaries
- **Risk:** Supply chain attack via compromised binaries
**After (Commit 352e702 - First Attempt):**
- Checksums generated
- No verification (planned but not implemented)
- **Risk:** Still vulnerable to supply chain attack
**After (This Session - Complete):**
- Checksums generated with macOS support
- Verification with atomic execute (prevents TOCTOU)
- Build-from-source fallback
- Fail-closed by default
- **Residual Risk:** Checksums verify integrity not authenticity (future: add signatures)
### Code Review Findings (Addressed)
| Severity | Issue | Status |
|----------|-------|--------|
| CRITICAL | TOCTOU race condition | ✅ Fixed with secure_execute_binary |
| CRITICAL | Partial string match bypass | ✅ Fixed with awk exact match |
| HIGH | Self-inclusion in checksum | ✅ Fixed with rm -f before generate |
| HIGH | Silent error suppression | ✅ Fixed with explicit error checking |
| HIGH | CI doesn't verify checksums | ✅ Fixed with sha256sum --check |
| HIGH | Unverified mode too permissive | ✅ Fixed with fail-closed default |
| HIGH | macOS incompatibility | ✅ Fixed with shasum fallback |
| MEDIUM | Variable expansion inconsistency | ✅ Fixed with ${VAR:-} pattern |
| LOW | All low issues | ✅ Fixed |
### Binary Sizes (per platform, with checksums)
- darwin_amd64: 23.5M (7 binaries + 1 checksum file)
- darwin_arm64: 22.6M (7 binaries + 1 checksum file)
- linux_amd64: 23.5M (7 binaries + 1 checksum file)
- linux_arm64: 23.0M (7 binaries + 1 checksum file)
- **Total: 92.6M** (28 binaries + 5 checksum files)
### Pipeline Phases (Behavior After Fixes)
For **markdown-only commits** (language: unknown):
1. Phase 0: scope-detector → PASS (detects 40 markdown files, language: unknown)
2. Phase 1: static-analysis → PASS (0 linters for unknown language)
3. Phase 2: ast → **SKIP** (No supported code files detected)
4. Phase 3: callgraph → **SKIP** (No supported code files detected)
5. Phase 4: dataflow → **SKIP** (No supported code files detected)
6. Phase 5: context → PASS (generates empty context files)
For **Go/TypeScript commits** (language: go/typescript):
1. All phases run normally
2. Full static analysis, AST, call graph, data flow
3. Rich context files generated
### Environment Variables Used
- `${CLAUDE_PLUGIN_ROOT}` - Path to installed plugin (e.g., `~/.claude/plugins/cache/ring/ring-default/0.35.0`)
- `${RING_ALLOW_UNVERIFIED}` - NEW: Set to `true` to bypass checksum verification (not recommended)
### Useful Commands
```bash
# Rebuild binaries with checksums (locally)
cd scripts/codereview && ./build-release.sh --clean
# Test pipeline on markdown commit (should skip gracefully)
./default/lib/codereview/bin/darwin_arm64/run-all \
--base=352e702^ --head=352e702 --output=.ring/codereview --verbose
# Verify checksums manually
cd default/lib/codereview/bin/darwin_arm64
sha256sum --check CHECKSUMS.sha256 # Linux
shasum -a 256 --check CHECKSUMS.sha256 # macOS
# Test with tampered binary (should fail and fallback)
# 1. Modify a binary: echo "malicious" >> run-all
# 2. Run pipeline → should detect mismatch → build from source
# Test unverified mode (development)
RING_ALLOW_UNVERIFIED=true <run pipeline with missing checksums>
# Check CI workflow status
gh run list --workflow=build-codereview.yml
```
### Next Session Resume
To resume work on this integration:
1. Read this handoff document
2. Test the security model end-to-end
3. Commit all changes if tests pass
4. Update documentation (README.md, MANUAL.md)
5. Monitor first CI run after push
6. Consider GPG/Sigstore signatures for future enhancement

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,893 +0,0 @@
# Ring Continuity - Dependency Map (Gate 6)
> **Version:** 1.0
> **Date:** 2026-01-12
> **Status:** Draft
> **Based on:** Data Model v1.0 (docs/pre-dev/ring-continuity/data-model.md)
---
## Executive Summary
Ring Continuity uses **Python stdlib** as much as possible to minimize external dependencies. The only required external package is **PyYAML 6.0+** for YAML serialization. All other functionality (SQLite FTS5, JSON Schema validation, file operations) uses Python 3.9+ standard library.
**Total External Dependencies:** 1 (PyYAML)
**Total Optional Dependencies:** 2 (jsonschema for validation, ruamel.yaml for round-trip)
---
## Technology Stack
### Core Runtime
| Technology | Version | Pinned | Rationale | Alternatives Evaluated |
|------------|---------|--------|-----------|----------------------|
| **Python** | 3.9+ | No (minimum) | Stdlib sqlite3 with FTS5, match statements, type hints | Python 3.8 (no match), Python 3.10+ (reduces compatibility) |
| **Bash** | 4.0+ | No (minimum) | Hook scripts, associative arrays | Shell (no arrays), Zsh (not default on Linux) |
| **SQLite** | 3.35+ | No (via Python) | FTS5 support, included in Python stdlib | PostgreSQL (too heavy), Custom file format (reinventing wheel) |
**Version Pinning Strategy:**
- **Minimum versions specified** (>=3.9, >=4.0, >=3.35)
- **No upper bounds** (allow newer versions)
- **Rationale:** Maximum compatibility, users benefit from platform updates
---
### Python Packages
| Package | Version | Required | Purpose | Alternatives Evaluated |
|---------|---------|----------|---------|----------------------|
| **PyYAML** | >=6.0,<7.0 | Yes | YAML parsing and serialization | ruamel.yaml (heavier, more features), pyyaml-include (not needed) |
| **jsonschema** | >=4.0,<5.0 | No | Validate skill-rules.json and handoff schemas | Custom validation (reinventing wheel), JSON Schema (no Python impl) |
| **ruamel.yaml** | >=0.17 | No | Round-trip YAML with comment preservation | PyYAML (loses comments), YAML 1.2 parsers (overkill) |
**Installation:**
```bash
# Required only
pip install 'PyYAML>=6.0,<7.0'
# With optional validation
pip install 'PyYAML>=6.0,<7.0' 'jsonschema>=4.0,<5.0'
# With round-trip support
pip install 'PyYAML>=6.0,<7.0' 'ruamel.yaml>=0.17'
```
**Rationale for PyYAML 6.0+:**
1. **Security:** Safe loading by default (no `yaml.load()` footgun)
2. **Stability:** Mature, 15+ years of development
3. **Size:** Minimal (~100KB), no transitive dependencies
4. **Speed:** Fast C implementation available (LibYAML)
5. **Compatibility:** Works on all platforms
**Alternatives Rejected:**
| Alternative | Why Rejected |
|-------------|--------------|
| **ruamel.yaml as primary** | Heavier (500KB+), comment preservation not needed for most use cases |
| **Custom YAML parser** | Reinventing wheel, security risks |
| **TOML instead of YAML** | Less human-readable for nested data, no stdlib support |
| **JSON instead of YAML** | Lacks comments, less readable for humans |
---
### Bash Dependencies
| Dependency | Version | Required | Purpose | Alternatives |
|------------|---------|----------|---------|--------------|
| **jq** | 1.6+ | No | JSON escaping in hooks | Python fallback, sed fallback |
| **GNU date** | Any | No (macOS has BSD date) | ISO 8601 timestamps | Python datetime fallback |
| **GNU grep** | Any | No | Pattern matching in hooks | Built-in grep OK |
**Fallback Strategy:**
```bash
# JSON escaping
if command -v jq &>/dev/null; then
escaped=$(echo "$content" | jq -Rs .)
else
# Fallback to sed
escaped=$(echo "$content" | sed 's/\\/\\\\/g; s/"/\\"/g')
fi
```
**Rationale:**
- **Prefer stdlib/builtin** where possible
- **Graceful degradation** if optional tools missing
- **No hard dependencies** on non-standard tools
---
## Database Technology
### Selected: SQLite 3.35+
**Version:** 3.35.0 or higher (for FTS5 support)
**Why SQLite:**
| Criterion | Rating | Notes |
|-----------|--------|-------|
| **Zero configuration** | ✓✓✓ | No server setup, just a file |
| **Included in Python** | ✓✓✓ | No separate installation |
| **FTS5 support** | ✓✓✓ | Built-in full-text search |
| **Performance** | ✓✓ | Fast for <100K rows (sufficient) |
| **Portability** | ✓✓✓ | Works everywhere Python works |
| **Size** | ✓✓✓ | Minimal footprint (<1MB for 10K learnings) |
**Alternatives Evaluated:**
| Alternative | Pros | Cons | Verdict |
|-------------|------|------|---------|
| **PostgreSQL** | Better FTS, pgvector for embeddings | Requires server, heavy setup | ✗ Rejected (too heavy) |
| **File-based (JSON/YAML)** | Simple, no DB | No indexing, slow search | ✗ Rejected (performance) |
| **In-memory only** | Fast | No persistence | ✗ Rejected (defeats purpose) |
| **Redis** | Fast, good for caching | Requires server | ✗ Rejected (external service) |
| **TinyDB** | Pure Python | No FTS, slow | ✗ Rejected (no search) |
**Version Pinning:**
```
SQLite: >=3.35.0 (no upper bound)
```
**Feature Requirements:**
- FTS5 virtual tables
- Triggers for automatic index sync
- JSON1 extension for metadata queries
- Window functions for ranking
**Verification Command:**
```bash
python3 -c "import sqlite3; print(sqlite3.sqlite_version)"
# Must output: 3.35.0 or higher
```
---
## YAML Processing
### Selected: PyYAML 6.0+
**Version:** >=6.0,<7.0
**Why PyYAML:**
| Criterion | Rating | Notes |
|-----------|--------|-------|
| **Maturity** | ✓✓✓ | 15+ years, stable API |
| **Security** | ✓✓✓ | Safe loading by default in 6.0+ |
| **Performance** | ✓✓✓ | C extension (LibYAML) |
| **Size** | ✓✓✓ | Minimal, no transitive deps |
| **Compatibility** | ✓✓✓ | Python 3.9+ support |
| **Features** | ✓✓ | Full YAML 1.1 support |
**Alternatives Evaluated:**
| Alternative | Pros | Cons | Verdict |
|-------------|------|------|---------|
| **ruamel.yaml** | YAML 1.2, round-trip, comments | Heavier, more complex API | ✗ Use for optional round-trip only |
| **strictyaml** | Type-safe, no tags | Limited features, smaller ecosystem | ✗ Rejected (too restrictive) |
| **pyyaml-include** | Include directives | Niche feature, adds complexity | ✗ Rejected (not needed) |
**Version Pinning:**
```
PyYAML>=6.0,<7.0
```
**Usage:**
```python
import yaml
# Loading (always safe_load)
with open('handoff.yaml') as f:
data = yaml.safe_load(f)
# Dumping
with open('handoff.yaml', 'w') as f:
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True)
```
**Security Considerations:**
- **Never use `yaml.load()`** (arbitrary code execution)
- **Always use `yaml.safe_load()`** (safe types only)
- **Validate schemas** after loading
---
## Schema Validation
### Selected: jsonschema 4.0+
**Version:** >=4.0,<5.0
**Why jsonschema:**
| Criterion | Rating | Notes |
|-----------|--------|-------|
| **Standard support** | ✓✓✓ | Draft 2020-12 support |
| **Validation features** | ✓✓✓ | Full JSON Schema spec |
| **Error messages** | ✓✓ | Detailed validation errors |
| **Performance** | ✓ | Adequate for <1000 rules |
| **Optional** | ✓✓✓ | Ring works without it |
**Alternatives Evaluated:**
| Alternative | Pros | Cons | Verdict |
|-------------|------|------|---------|
| **Custom validation** | No dependency | Reinventing wheel, bugs | ✗ Rejected (maintenance burden) |
| **Pydantic** | Type hints, great DX | Heavy (15+ deps), overkill | ✗ Rejected (too heavy) |
| **python-jsonschema-objects** | Class generation | Unmaintained | ✗ Rejected (stale) |
**Version Pinning:**
```
jsonschema>=4.0,<5.0
```
**Usage:**
```python
from jsonschema import validate, ValidationError
schema = {
"type": "object",
"required": ["session", "task"],
"properties": {
"session": {"type": "object"},
"task": {"type": "object"}
}
}
try:
validate(instance=data, schema=schema)
except ValidationError as e:
print(f"Validation failed: {e.message}")
```
**Graceful Degradation:**
- If jsonschema not installed, skip validation
- Log warning about missing validation
- Rely on YAML parser for basic type checking
---
## Hook System Technologies
### Selected: Bash 4.0+ with Python Helpers
**Bash Version:** >=4.0
**Why Bash for Hooks:**
| Criterion | Rating | Notes |
|-----------|--------|-------|
| **Startup speed** | ✓✓✓ | <10ms for simple hooks |
| **Simplicity** | ✓✓ | Good for file ops, JSON output |
| **Compatibility** | ✓✓✓ | Available on all Unix systems |
| **Integration** | ✓✓✓ | Easy to call Python when needed |
**Bash Features Required:**
- Associative arrays (Bash 4.0+)
- Process substitution
- Command substitution
- Here-documents for JSON
**Python Helper Usage:**
```bash
# For complex operations, delegate to Python
result=$(python3 "${PLUGIN_ROOT}/lib/memory_search.py" "$query")
```
**Alternatives Evaluated:**
| Alternative | Pros | Cons | Verdict |
|-------------|------|------|---------|
| **Pure Python hooks** | Better error handling | Slower startup (~50ms) | ✗ Use Python only when needed |
| **TypeScript hooks** | Type safety, better tooling | Compilation step, Node.js dep | ✗ Rejected (complexity) |
| **Shell (sh)** | Maximum compatibility | No arrays, limited features | ✗ Rejected (too limited) |
---
## File Formats
### Configuration: YAML
**Selected:** YAML 1.1 (PyYAML default)
**Rationale:**
- Human-editable
- Comments supported
- Hierarchical structure
- Python stdlib-compatible (via PyYAML)
**Example:**
```yaml
# ~/.ring/config.yaml
memory:
enabled: true
max_memories: 10000
decay:
enabled: true
half_life_days: 180
skill_activation:
enabled: true
enforcement_default: suggest
handoffs:
format: yaml # yaml or markdown
auto_index: true
```
---
### Skill Rules: JSON
**Selected:** JSON (not YAML) for skill-rules.json
**Rationale:**
- Schema validation with jsonschema
- Faster parsing than YAML
- No ambiguity (YAML has edge cases)
- Standard for rule engines
**Example:**
```json
{
"version": "1.0",
"skills": {
"ring:test-driven-development": {
"type": "guardrail",
"enforcement": "block",
"priority": "critical",
"promptTriggers": {
"keywords": ["tdd", "test driven"],
"intentPatterns": ["write.*test.*first", "red.*green.*refactor"]
}
}
}
}
```
---
### Handoffs: YAML with Frontmatter
**Selected:** YAML with --- delimiters
**Rationale:**
- Machine-parseable frontmatter
- Human-readable body
- Compatible with existing Markdown frontmatter pattern
- Token-efficient
**Example:**
```yaml
---
version: "1.0"
schema: ring-handoff-v1
session:
id: context-management
started_at: 2026-01-12T10:00:00Z
task:
description: Implement memory system
status: completed
---
# Session Notes
Additional context in Markdown format...
```
---
## Platform Dependencies
### Operating System
| Platform | Support Level | Notes |
|----------|---------------|-------|
| **macOS** | ✓✓✓ Primary | Darwin kernel, BSD userland |
| **Linux** | ✓✓✓ Primary | GNU userland |
| **Windows (WSL)** | ✓✓ Compatible | Via WSL2 with Linux tools |
| **Windows (native)** | ✗ Not supported | Bash hooks incompatible |
**Platform Detection:**
```bash
case "$(uname -s)" in
Darwin*) PLATFORM="macos" ;;
Linux*) PLATFORM="linux" ;;
*) PLATFORM="unknown" ;;
esac
```
---
### File System Requirements
| Requirement | Purpose | Fallback |
|-------------|---------|----------|
| **Home directory writable** | ~/.ring/ creation | Use .ring/ in project |
| **Symlink support** | Avoid duplication | Copy files if symlinks unsupported |
| **UTF-8 filenames** | Unicode session names | ASCII-safe fallback |
| **POSIX paths** | Cross-platform compatibility | - |
---
## Development Dependencies
### Testing
| Package | Version | Purpose |
|---------|---------|---------|
| **pytest** | >=7.0,<8.0 | Unit and integration tests |
| **pytest-asyncio** | >=0.21 | Async test support (if needed) |
| **coverage** | >=7.0 | Code coverage tracking |
**Why pytest:**
- Industry standard
- Fixture system for test data
- Parametrized tests
- Plugin ecosystem
**Installation:**
```bash
pip install 'pytest>=7.0,<8.0' 'coverage>=7.0'
```
---
### Linting & Formatting
| Tool | Version | Purpose | Config |
|------|---------|---------|--------|
| **shellcheck** | latest | Bash linting | .shellcheckrc |
| **black** | >=24.0 | Python formatting | pyproject.toml |
| **ruff** | >=0.1 | Python linting | pyproject.toml |
**Why These Tools:**
- **shellcheck:** Catches Bash bugs before execution
- **black:** Consistent formatting, zero config
- **ruff:** Fast (Rust-based), replaces flake8/pylint/isort
**Installation:**
```bash
# Homebrew (macOS)
brew install shellcheck
# Python tools
pip install 'black>=24.0' 'ruff>=0.1'
```
---
## Optional Enhancements
### Optional: Round-Trip YAML (ruamel.yaml)
**Use Case:** Preserve comments when updating config files programmatically.
**When Needed:**
- Auto-updating skill-rules.json while keeping user comments
- Config migration with comment preservation
**Installation:**
```bash
pip install 'ruamel.yaml>=0.17'
```
**Usage:**
```python
from ruamel.yaml import YAML
yaml = YAML()
yaml.preserve_quotes = True
yaml.default_flow_style = False
with open('config.yaml') as f:
data = yaml.load(f)
# Modify data
data['memory']['enabled'] = True
with open('config.yaml', 'w') as f:
yaml.dump(data, f)
# Comments preserved!
```
---
### Optional: Schema Validation (jsonschema)
**Use Case:** Validate skill-rules.json and handoff YAML against schemas.
**When Needed:**
- Development: Catch schema errors early
- Production: Validate user-edited configs
- Testing: Ensure test data is valid
**Installation:**
```bash
pip install 'jsonschema>=4.0,<5.0'
```
**Usage:**
```python
from jsonschema import validate, Draft202012Validator
validator = Draft202012Validator(schema)
errors = list(validator.iter_errors(data))
if errors:
for error in errors:
print(f"{error.json_path}: {error.message}")
```
**Graceful Degradation:**
- If not installed, skip schema validation
- Basic type checking via YAML parser
- Runtime errors caught during execution
---
## Version Compatibility Matrix
### Python Version Support
| Python Version | Support | Notes |
|----------------|---------|-------|
| **3.9** | ✓✓✓ Minimum | Match statements, type hints |
| **3.10** | ✓✓✓ Recommended | Pattern matching improvements |
| **3.11** | ✓✓✓ Supported | Performance improvements |
| **3.12** | ✓✓✓ Supported | Latest stable |
| **3.8** | ✗ Not supported | No match statements |
| **3.13+** | ✓ Untested | Should work |
---
### SQLite Version Support
| SQLite Version | FTS5 | JSON1 | Window Functions | Verdict |
|----------------|------|-------|------------------|---------|
| **3.35+** | ✓ | ✓ | ✓ | ✓✓✓ Recommended |
| **3.32-3.34** | ✓ | ✓ | ✓ | ✓✓ Compatible (missing some FTS5 features) |
| **3.24-3.31** | ✓ | ✓ | ✗ | ✓ Partial (no window functions) |
| **<3.24** | | - | - | Not supported |
**Version Detection:**
```python
import sqlite3
version = tuple(map(int, sqlite3.sqlite_version.split('.')))
if version < (3, 35, 0):
raise RuntimeError(f"SQLite 3.35+ required, found {sqlite3.sqlite_version}")
```
---
### PyYAML Version Support
| PyYAML Version | Python 3.9 | Python 3.12 | safe_load default | Verdict |
|----------------|------------|-------------|-------------------|---------|
| **6.0+** | ✓ | ✓ | ✓ | ✓✓✓ Recommended |
| **5.4** | ✓ | ✓ | ✗ | ✓ Compatible (manual safe_load) |
| **5.3** | ✓ | ✗ | ✗ | ✗ Not compatible with Python 3.12 |
| **<5.3** | | | | Not supported |
---
## Installation Strategy
### Installation Methods
| Method | Target User | Command |
|--------|-------------|---------|
| **Install script** | End users | `curl ... \| bash` |
| **Manual pip** | Developers | `pip install -r requirements.txt` |
| **System package** | Enterprise | `apt install ring-plugin` (future) |
---
### Install Script Flow
```bash
#!/usr/bin/env bash
# install-ring-continuity.sh
set -euo pipefail
echo "🔄 Installing Ring Continuity..."
# 1. Check Python version
python_version=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))')
if [[ "${python_version}" < "3.9" ]]; then
echo "❌ Python 3.9+ required, found ${python_version}"
exit 1
fi
# 2. Check SQLite version
sqlite_version=$(python3 -c 'import sqlite3; print(sqlite3.sqlite_version)')
if [[ "${sqlite_version}" < "3.35.0" ]]; then
echo "⚠️ SQLite 3.35+ recommended, found ${sqlite_version}"
echo " FTS5 features may be limited"
fi
# 3. Install PyYAML
echo "📦 Installing PyYAML..."
pip3 install --user 'PyYAML>=6.0,<7.0'
# 4. Optional: Install jsonschema
read -p "Install jsonschema for validation? (y/N) " -n 1 -r
if [[ $REPLY =~ ^[Yy]$ ]]; then
pip3 install --user 'jsonschema>=4.0,<5.0'
fi
# 5. Create ~/.ring/ structure
echo "📁 Creating ~/.ring/ directory..."
mkdir -p ~/.ring/{handoffs,logs,cache,state}
# 6. Initialize databases
echo "💾 Initializing memory database..."
python3 - <<'EOF'
import sqlite3
from pathlib import Path
db_path = Path.home() / '.ring' / 'memory.db'
conn = sqlite3.connect(str(db_path))
# Execute schema creation...
conn.close()
EOF
# 7. Create default config
if [[ ! -f ~/.ring/config.yaml ]]; then
cat > ~/.ring/config.yaml <<'EOF'
memory:
enabled: true
max_memories: 10000
skill_activation:
enabled: true
enforcement_default: suggest
handoffs:
format: yaml
auto_index: true
EOF
fi
# 8. Create default skill rules
if [[ ! -f ~/.ring/skill-rules.json ]]; then
echo '{"version": "1.0", "skills": {}}' > ~/.ring/skill-rules.json
fi
echo "✅ Ring Continuity installed successfully!"
echo " Location: ~/.ring/"
echo " Config: ~/.ring/config.yaml"
echo " Memory: ~/.ring/memory.db"
```
---
### Requirements File
**File:** `requirements.txt`
```txt
# Ring Continuity Requirements
# Python 3.9+ required
# Core dependencies (required)
PyYAML>=6.0,<7.0
# Optional dependencies
jsonschema>=4.0,<5.0 # For schema validation
ruamel.yaml>=0.17 # For round-trip YAML with comments
# Development dependencies
pytest>=7.0,<8.0
coverage>=7.0,<8.0
black>=24.0
ruff>=0.1
```
---
## Dependency Justification
### Why Minimize Dependencies?
| Reason | Impact |
|--------|--------|
| **Easier installation** | Users don't need to manage many packages |
| **Fewer conflicts** | Less chance of version conflicts |
| **Faster startup** | Fewer imports = faster hook execution |
| **Better reliability** | Fewer points of failure |
| **Simpler maintenance** | Less to update and test |
### Why PyYAML is Worth It
| Benefit | Value |
|---------|-------|
| **YAML ubiquity** | Industry standard for config |
| **Human-editable** | Users can edit handoffs manually |
| **Structured data** | Better than JSON for readability |
| **Comment support** | Document configs inline |
| **5x token savings** | From ~2000 (Markdown) to ~400 (YAML) |
**Token savings calculation:**
```
Markdown handoff: ~2000 tokens (measured from examples)
YAML handoff: ~400 tokens (estimated from schema)
Savings: 1600 tokens per handoff
Cost: 1 dependency (PyYAML)
ROI: 1600 tokens / 1 dependency = Excellent
```
---
## Build & Distribution
### Build Process
**No build step required** for end users:
- Bash scripts are interpreted
- Python scripts are interpreted
- YAML/JSON configs are data files
**Development build:**
```bash
# Lint Bash hooks
shellcheck default/hooks/*.sh
# Format Python scripts
black default/lib/
# Lint Python scripts
ruff check default/lib/
# Run tests
pytest tests/
```
---
### Distribution Format
**Method:** Git repository + install script
**Structure:**
```
ring/
├── default/
│ ├── hooks/
│ │ ├── session-start.sh
│ │ ├── user-prompt-submit.sh
│ │ └── ...
│ ├── lib/
│ │ ├── memory_repository.py
│ │ ├── handoff_serializer.py
│ │ ├── skill_activator.py
│ │ └── ...
│ └── skills/
│ └── ring:handoff-tracking/
│ └── SKILL.md (updated)
├── install-ring-continuity.sh
└── requirements.txt
```
**No packaging needed:**
- Scripts run in place
- No compilation
- No bundling
---
## Dependency Security
### Security Considerations
| Package | Supply Chain Risk | Mitigation |
|---------|-------------------|------------|
| **PyYAML** | Low (mature, widely used) | Pin major version, use safe_load only |
| **jsonschema** | Low (optional, validation only) | Pin major version, optional install |
| **sqlite3** | None (stdlib) | Use parameterized queries |
### Security Best Practices
1. **Pin major versions** to prevent breaking changes
2. **Use safe APIs** (yaml.safe_load, not yaml.load)
3. **Parameterize queries** (prevent SQL injection)
4. **Validate inputs** before processing
5. **Regular updates** for security patches
---
## Runtime Environment
### Environment Variables
| Variable | Required | Default | Purpose |
|----------|----------|---------|---------|
| `CLAUDE_PLUGIN_ROOT` | Yes | - | Plugin directory |
| `CLAUDE_PROJECT_DIR` | Yes | - | Current project |
| `CLAUDE_SESSION_ID` | Yes | - | Session identifier |
| `RING_HOME` | No | `~/.ring` | Override home directory |
| `RING_LOG_LEVEL` | No | `INFO` | Logging verbosity |
**Detection Script:**
```bash
# Detect Ring home directory
if [[ -n "${RING_HOME:-}" ]]; then
RING_HOME="${RING_HOME}"
elif [[ -d "${HOME}/.ring" ]]; then
RING_HOME="${HOME}/.ring"
else
echo "Creating ~/.ring/ directory..."
mkdir -p "${HOME}/.ring"
RING_HOME="${HOME}/.ring"
fi
```
---
### System Requirements
| Resource | Minimum | Recommended | Purpose |
|----------|---------|-------------|---------|
| **Disk space** | 10 MB | 100 MB | Memory DB, handoffs, logs |
| **Memory (RAM)** | 50 MB | 200 MB | Python processes, SQLite cache |
| **Python heap** | Default | Default | No large allocations |
---
## Gate 6 Validation Checklist
- [x] All technologies selected with versions pinned
- [x] Rationale provided for each selection (Why this? Why not alternatives?)
- [x] Alternatives evaluated with pros/cons tables
- [x] Version compatibility matrix documented
- [x] Installation strategy defined (install script + requirements.txt)
- [x] Platform support specified (macOS, Linux)
- [x] Build process documented (none for users, lint/test for devs)
- [x] Security considerations addressed
- [x] Tech stack is complete and actionable
---
## Appendix: Dependency Catalog
### Required Dependencies
| Dependency | Version | Type | Installation |
|------------|---------|------|--------------|
| Python | >=3.9 | Runtime | System package manager |
| Bash | >=4.0 | Runtime | Included with OS |
| PyYAML | >=6.0,<7.0 | Library | pip install |
| SQLite | >=3.35 | Runtime | Included with Python |
### Optional Dependencies
| Dependency | Version | Type | Purpose |
|------------|---------|------|---------|
| jsonschema | >=4.0,<5.0 | Library | Schema validation |
| ruamel.yaml | >=0.17 | Library | Round-trip YAML |
| jq | >=1.6 | CLI Tool | JSON processing in hooks |
### Development Dependencies
| Dependency | Version | Type | Purpose |
|------------|---------|------|---------|
| pytest | >=7.0,<8.0 | Library | Testing |
| coverage | >=7.0,<8.0 | Library | Coverage tracking |
| black | >=24.0 | Tool | Code formatting |
| ruff | >=0.1 | Tool | Linting |
| shellcheck | latest | Tool | Bash linting |
---
## Technology Decision Summary
| Decision | Rationale | Risk |
|----------|-----------|------|
| **SQLite over PostgreSQL** | Zero config, included in Python | Limited for very large datasets (>100K memories) |
| **PyYAML over ruamel.yaml** | Lighter, simpler API | No round-trip support (acceptable trade-off) |
| **JSON for rules, YAML for config** | JSON has better schema validation | Slightly less readable |
| **Bash for hooks** | Fast startup, good for simple ops | Complex operations delegate to Python |
| **FTS5 over embeddings** | Sufficient for keyword search, no deps | Weaker semantic search (acceptable for MVP) |
| **Python 3.9 minimum** | Match statements, modern typing | Excludes older systems (acceptable) |

View file

@ -1,423 +0,0 @@
# Ring Continuity - Feature Map (Gate 2)
> **Version:** 1.0
> **Date:** 2026-01-12
> **Status:** Draft
> **Based on:** PRD v1.0 (docs/pre-dev/ring-continuity/prd.md)
---
## Executive Summary
Ring Continuity consists of **5 interconnected feature domains** that work together to create an intelligent, learning assistant. The feature map reveals a **layered dependency structure**: Foundation layer (~/.ring/), Storage layer (Memory + Handoffs), Enhancement layer (Confidence + Activation), and Integration layer (Hooks + Skills).
---
## Feature Domain Map
```
┌─────────────────────────────────────────────────────────────────┐
│ INTEGRATION LAYER │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ Hook Integration │ │ Skill Enhancement │ │
│ │ • SessionStart │ │ • Agent outputs │ │
│ │ • UserPromptSubmit │ │ • Skill definitions │ │
│ │ • PostToolUse │ │ • Command wrappers │ │
│ └──────────────────────────┘ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ ENHANCEMENT LAYER │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ Confidence Markers │ │ Skill Activation │ │
│ │ • Verification status │ │ • Rules engine │ │
│ │ • Evidence chains │ │ • Pattern matching │ │
│ │ • Trust calibration │ │ • Auto-suggestion │ │
│ └──────────────────────────┘ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ STORAGE LAYER │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ Persistent Memory │ │ YAML Handoffs │ │
│ │ • SQLite + FTS5 │ │ • Compact format │ │
│ │ • Learning types │ │ • Schema validation │ │
│ │ • Decay mechanism │ │ • Backward compat │ │
│ └──────────────────────────┘ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ FOUNDATION LAYER │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Global Ring Directory (~/.ring/) │ │
│ │ • Directory structure │ │
│ │ • Configuration management │ │
│ │ • Migration utilities │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Feature Domain Breakdown
### Domain 1: Foundation - Global Ring Directory
**Purpose:** Establish ~/.ring/ as the global home for Ring data and configuration.
**Features:**
| Feature | Description | Priority |
|---------|-------------|----------|
| F1.1 - Directory Creation | Create ~/.ring/ on first use | P0 |
| F1.2 - Config Management | Load/merge global and project configs | P0 |
| F1.3 - Migration Utilities | Move existing data to ~/.ring/ | P1 |
| F1.4 - Cleanup Utilities | Remove old/stale data | P2 |
**Scope:**
- IN: Global directory structure, config precedence, migration
- OUT: Cloud sync, multi-user support
**Dependencies:**
- None (foundation layer)
---
### Domain 2: Storage - Persistent Memory
**Purpose:** Store and retrieve learnings across sessions using SQLite FTS5.
**Features:**
| Feature | Description | Priority |
|---------|-------------|----------|
| F2.1 - Memory Database | SQLite with FTS5 schema | P0 |
| F2.2 - Memory Storage | Store learnings with type classification | P0 |
| F2.3 - Memory Retrieval | FTS5 search with BM25 ranking | P0 |
| F2.4 - Memory Awareness | Hook to inject relevant memories | P1 |
| F2.5 - Memory Decay | Reduce relevance over time | P2 |
| F2.6 - Memory Expiration | Auto-delete expired memories | P2 |
**Scope:**
- IN: 7 learning types, FTS5 search, decay mechanism
- OUT: Embeddings, semantic search, real-time updates
**Dependencies:**
- F1.1 (Directory Creation) - needs ~/.ring/memory.db location
---
### Domain 3: Storage - YAML Handoffs
**Purpose:** Replace Markdown handoffs with token-efficient YAML format.
**Features:**
| Feature | Description | Priority |
|---------|-------------|----------|
| F3.1 - YAML Schema | Define handoff YAML structure | P0 |
| F3.2 - YAML Writer | Create handoffs in YAML format | P0 |
| F3.3 - YAML Reader | Parse YAML handoffs | P0 |
| F3.4 - Schema Validation | Validate against JSON Schema | P0 |
| F3.5 - Backward Compatibility | Read legacy Markdown handoffs | P1 |
| F3.6 - Migration Tool | Convert Markdown to YAML | P1 |
| F3.7 - Auto-Indexing | Index YAML handoffs in FTS5 | P1 |
**Scope:**
- IN: YAML format, validation, migration, FTS5 indexing
- OUT: Markdown generation, HTML export
**Dependencies:**
- F1.1 (Directory Creation) - needs ~/.ring/handoffs/ location
- Existing ring:handoff-tracking skill - extends, not replaces
---
### Domain 4: Enhancement - Confidence Markers
**Purpose:** Add verification status to agent outputs to reduce false claims.
**Features:**
| Feature | Description | Priority |
|---------|-------------|----------|
| F4.1 - Marker Vocabulary | Define ✓/?/✗ symbols and meanings | P0 |
| F4.2 - Agent Output Schema | Add confidence section to schemas | P0 |
| F4.3 - Explorer Integration | Add markers to ring:codebase-explorer | P0 |
| F4.4 - Evidence Chains | Track how conclusions were reached | P1 |
| F4.5 - Trust Calibration | Display confidence visually | P2 |
**Scope:**
- IN: Structured markers, evidence chains, schema updates
- OUT: Natural language epistemic markers, confidence scores
**Dependencies:**
- Existing agent output schemas (docs/AGENT_DESIGN.md)
---
### Domain 5: Enhancement - Skill Activation
**Purpose:** Auto-suggest relevant skills based on user prompt patterns.
**Features:**
| Feature | Description | Priority |
|---------|-------------|----------|
| F5.1 - Rules Schema | Define skill-rules.json structure | P0 |
| F5.2 - Keyword Matching | Match prompt keywords to skills | P0 |
| F5.3 - Intent Matching | Regex pattern matching | P1 |
| F5.4 - Activation Hook | UserPromptSubmit hook integration | P0 |
| F5.5 - Enforcement Levels | Block/suggest/warn behaviors | P0 |
| F5.6 - Negative Patterns | Exclude false positives | P1 |
| F5.7 - Project Overrides | Project rules override global | P1 |
**Scope:**
- IN: JSON Schema rules, three-tier enforcement, pattern matching
- OUT: Machine learning, semantic embeddings, user training
**Dependencies:**
- F1.1 (Directory Creation) - needs ~/.ring/skill-rules.json location
- Existing skill frontmatter (trigger/skip_when fields)
---
## Feature Relationships
### Dependency Graph
```
F1.1 (Directory Creation)
├─> F2.1 (Memory Database)
│ └─> F2.2 (Memory Storage)
│ └─> F2.3 (Memory Retrieval)
│ └─> F2.4 (Memory Awareness Hook)
│ └─> F2.5 (Memory Decay)
│ └─> F2.6 (Memory Expiration)
├─> F3.1 (YAML Schema)
│ ├─> F3.2 (YAML Writer)
│ └─> F3.3 (YAML Reader)
│ ├─> F3.4 (Schema Validation)
│ ├─> F3.5 (Backward Compatibility)
│ └─> F3.6 (Migration Tool)
│ └─> F3.7 (Auto-Indexing)
└─> F5.1 (Rules Schema)
├─> F5.2 (Keyword Matching)
├─> F5.3 (Intent Matching)
└─> F5.4 (Activation Hook)
├─> F5.5 (Enforcement Levels)
├─> F5.6 (Negative Patterns)
└─> F5.7 (Project Overrides)
F4.1 (Marker Vocabulary)
└─> F4.2 (Agent Output Schema)
└─> F4.3 (Explorer Integration)
├─> F4.4 (Evidence Chains)
└─> F4.5 (Trust Calibration)
```
### Critical Path
The minimum viable feature set for Ring Continuity:
1. **F1.1** - Directory Creation (foundation)
2. **F3.1** - YAML Schema (handoff format)
3. **F3.2** - YAML Writer (handoff creation)
4. **F4.1** - Marker Vocabulary (confidence markers)
5. **F4.2** - Agent Output Schema (apply markers)
6. **F5.1** - Rules Schema (skill activation)
7. **F5.2** - Keyword Matching (basic activation)
**Estimated:** 2-3 days for MVP
### Optional Extensions
Can be deferred without breaking core functionality:
- F2.4 (Memory Awareness Hook)
- F2.5 (Memory Decay)
- F3.5 (Backward Compatibility)
- F5.3 (Intent Matching - regex)
- F5.6 (Negative Patterns)
---
## Domain Boundaries
### Physical Boundaries
| Domain | Files/Directories | Responsibility |
|--------|-------------------|----------------|
| **Foundation** | `~/.ring/`, `~/.ring/config.yaml` | Directory structure, config |
| **Memory** | `~/.ring/memory.db` | Persistent learning storage |
| **Handoffs** | `~/.ring/handoffs/`, `docs/handoffs/` | Session continuity |
| **Activation** | `~/.ring/skill-rules.json` | Skill routing |
| **Confidence** | Agent schemas in `docs/AGENT_DESIGN.md` | Output verification |
### Logical Boundaries
| Domain | Concerns | NOT Responsible For |
|--------|----------|---------------------|
| **Foundation** | Directory creation, config loading | Data storage, processing |
| **Memory** | Storage, retrieval, decay | Hook integration, display |
| **Handoffs** | YAML serialization, validation | Handoff creation logic (in skill) |
| **Activation** | Pattern matching, suggestion | Skill execution |
| **Confidence** | Schema definition, markers | Agent implementation |
---
## Integration Points
### Hook Integration Points
| Hook Event | Features Integrated | Purpose |
|------------|---------------------|---------|
| **SessionStart** | F1.2 (Config), F2.4 (Memory Awareness) | Load config, inject memories |
| **UserPromptSubmit** | F5.4 (Activation Hook) | Suggest relevant skills |
| **PostToolUse (Write)** | F3.7 (Auto-Indexing) | Index new handoffs |
| **PreCompact** | (Future) Auto-handoff creation | Save state before compact |
| **Stop** | (Future) Learning extraction | Extract session learnings |
### Skill Integration Points
| Skill | Features Integrated | Changes Required |
|-------|---------------------|------------------|
| **ring:handoff-tracking** | F3.2 (YAML Writer), F3.4 (Validation) | Output YAML instead of Markdown |
| **continuity-ledger** | F3.2 (YAML Writer) | Optional YAML format |
| **artifact-query** | F2.3 (Memory Retrieval) | Query both artifacts and memories |
| **ring:codebase-explorer** | F4.3 (Explorer Integration) | Add confidence markers to output |
### Agent Integration Points
| Agent | Features Integrated | Changes Required |
|-------|---------------------|------------------|
| **All exploration agents** | F4.2 (Agent Output Schema) | Add "Verification" section |
| **ring:codebase-explorer** | F4.3, F4.4 (Markers + Evidence) | Include evidence chain |
---
## Feature Interactions
### Positive Interactions (Synergies)
| Feature A | Feature B | Synergy |
|-----------|-----------|---------|
| F2.3 (Memory Retrieval) | F5.4 (Activation Hook) | Memory informs which skills to suggest |
| F3.7 (Auto-Indexing) | F2.3 (Memory Retrieval) | Handoffs become searchable memories |
| F4.3 (Explorer Integration) | F2.2 (Memory Storage) | Verified findings stored as high-confidence memories |
| F5.5 (Enforcement Levels) | F4.1 (Marker Vocabulary) | Block-level skills can require verification |
### Negative Interactions (Conflicts)
| Feature A | Feature B | Conflict | Resolution |
|-----------|-----------|----------|------------|
| F3.5 (Backward Compat) | F3.2 (YAML Writer) | Dual format support | Indexer supports both |
| F2.5 (Memory Decay) | F2.2 (Memory Storage) | When to apply decay? | Apply on retrieval, not storage |
| F5.7 (Project Overrides) | F1.2 (Config Management) | Precedence rules | Document: project > global > default |
---
## Scope Visualization
### Feature Distribution by Phase
```
Phase 1 (Foundation):
├─ F1.1 ■■■■■■■■■■ Directory Creation
├─ F1.2 ■■■■■■■□□□ Config Management (partial)
└─ F1.3 ■■■■■□□□□□ Migration Utilities (basic)
Phase 2 (YAML Handoffs):
├─ F3.1 ■■■■■■■■■■ YAML Schema
├─ F3.2 ■■■■■■■■■■ YAML Writer
├─ F3.3 ■■■■■■■■■■ YAML Reader
├─ F3.4 ■■■■■■■■■■ Schema Validation
└─ F3.5 ■■■■■■■□□□ Backward Compatibility (partial)
Phase 3 (Confidence Markers):
├─ F4.1 ■■■■■■■■■■ Marker Vocabulary
├─ F4.2 ■■■■■■■■■■ Agent Output Schema
└─ F4.3 ■■■■■■■■■■ Explorer Integration
Phase 4 (Skill Activation):
├─ F5.1 ■■■■■■■■■■ Rules Schema
├─ F5.2 ■■■■■■■■■■ Keyword Matching
├─ F5.4 ■■■■■■■■■■ Activation Hook
├─ F5.5 ■■■■■■■■■■ Enforcement Levels
└─ F5.3 ■■■■■□□□□□ Intent Matching (defer)
Phase 5 (Persistent Memory):
├─ F2.1 ■■■■■■■■■■ Memory Database
├─ F2.2 ■■■■■■■■■■ Memory Storage
├─ F2.3 ■■■■■■■■■■ Memory Retrieval
├─ F2.4 ■■■■■■■□□□ Memory Awareness Hook
└─ F2.5 ■■■■■□□□□□ Memory Decay (defer)
```
### Risk by Feature
| Feature | Complexity | Risk Level | Mitigation |
|---------|------------|------------|------------|
| F1.1 | Low | Low | Straightforward directory creation |
| F2.1 | Medium | Medium | Use existing FTS5 patterns |
| F3.2 | Low | Low | YAML serialization is stdlib |
| F4.2 | Low | Low | Schema addition only |
| F5.4 | High | High | Complex hook timing, false positives |
---
## Gate 2 Validation Checklist
- [x] All features from PRD mapped to domains
- [x] Feature relationships documented (dependency graph)
- [x] Domain boundaries defined (physical and logical)
- [x] Feature interactions identified (synergies and conflicts)
- [x] Integration points specified (hooks, skills, agents)
- [x] Scope visualization provided (phase distribution)
- [x] Critical path identified for MVP
- [x] Risk assessment per feature
---
## Appendix: Feature Catalog
### Complete Feature List
| ID | Feature Name | Domain | Priority | Dependencies |
|----|--------------|--------|----------|--------------|
| F1.1 | Directory Creation | Foundation | P0 | None |
| F1.2 | Config Management | Foundation | P0 | F1.1 |
| F1.3 | Migration Utilities | Foundation | P1 | F1.1 |
| F1.4 | Cleanup Utilities | Foundation | P2 | F1.1 |
| F2.1 | Memory Database | Memory | P0 | F1.1 |
| F2.2 | Memory Storage | Memory | P0 | F2.1 |
| F2.3 | Memory Retrieval | Memory | P0 | F2.2 |
| F2.4 | Memory Awareness | Memory | P1 | F2.3 |
| F2.5 | Memory Decay | Memory | P2 | F2.3 |
| F2.6 | Memory Expiration | Memory | P2 | F2.5 |
| F3.1 | YAML Schema | Handoffs | P0 | F1.1 |
| F3.2 | YAML Writer | Handoffs | P0 | F3.1 |
| F3.3 | YAML Reader | Handoffs | P0 | F3.1 |
| F3.4 | Schema Validation | Handoffs | P0 | F3.3 |
| F3.5 | Backward Compatibility | Handoffs | P1 | F3.3 |
| F3.6 | Migration Tool | Handoffs | P1 | F3.5 |
| F3.7 | Auto-Indexing | Handoffs | P1 | F3.6 |
| F4.1 | Marker Vocabulary | Confidence | P0 | None |
| F4.2 | Agent Output Schema | Confidence | P0 | F4.1 |
| F4.3 | Explorer Integration | Confidence | P0 | F4.2 |
| F4.4 | Evidence Chains | Confidence | P1 | F4.3 |
| F4.5 | Trust Calibration | Confidence | P2 | F4.4 |
| F5.1 | Rules Schema | Activation | P0 | F1.1 |
| F5.2 | Keyword Matching | Activation | P0 | F5.1 |
| F5.3 | Intent Matching | Activation | P1 | F5.2 |
| F5.4 | Activation Hook | Activation | P0 | F5.2 |
| F5.5 | Enforcement Levels | Activation | P0 | F5.4 |
| F5.6 | Negative Patterns | Activation | P1 | F5.5 |
| F5.7 | Project Overrides | Activation | P1 | F1.2 |
**Total:** 27 features across 5 domains

View file

@ -1,308 +0,0 @@
# Ring Continuity - Product Requirements Document (Gate 1)
> **Version:** 1.0
> **Date:** 2026-01-12
> **Status:** Draft
> **Stakeholder:** Ring Plugin Users & Maintainers
---
## Problem Statement
### The Problem
AI coding assistants like Claude Code suffer from **session amnesia** - they cannot remember learnings, preferences, or context across sessions. This leads to:
1. **Repeated explanations:** Users re-explain the same patterns, preferences, and project context every session
2. **Repeated mistakes:** AI makes the same errors it learned to avoid in previous sessions
3. **Wasted tokens:** Handoff documents consume ~2000 tokens in Markdown format when ~400 would suffice
4. **Manual skill invocation:** Users must remember and manually invoke relevant skills instead of automatic suggestion
5. **Unverified claims:** AI makes assertions without clear indication of verification status, leading to hallucination-based errors
### Who Is Affected
| User Type | Pain Point | Frequency |
|-----------|------------|-----------|
| **Daily Ring users** | Re-explaining preferences every session | Daily |
| **Long-running project teams** | Lost context between work sessions | Weekly |
| **Multi-project developers** | No cross-project learning transfer | Ongoing |
| **New Ring adopters** | Discovering which skills to use | Every task |
### Business Value
| Metric | Current State | Target State | Value |
|--------|---------------|--------------|-------|
| **Session startup time** | 5-10 min context rebuild | <1 min auto-load | 80% reduction |
| **Handoff token cost** | ~2000 tokens | ~400 tokens | 5x efficiency |
| **Skill discovery** | Manual lookup | Auto-suggest | Better adoption |
| **False claim rate** | ~80% (unverified grep) | <20% (with markers) | 4x improvement |
---
## Vision
**Ring Continuity** transforms Ring from a stateless skill library into an **intelligent, learning assistant** that:
- **Remembers** user preferences, project patterns, and successful approaches
- **Learns** from failures and avoids repeating mistakes
- **Suggests** relevant skills based on user intent
- **Verifies** claims with explicit confidence markers
- **Persists** state efficiently across sessions and projects
All while maintaining Ring's core principle: **lightweight, accessible, no heavy infrastructure**.
---
## User Stories
### Epic 1: Global Ring Directory (~/.ring/)
**US-1.1: As a Ring user, I want a global ~/.ring/ directory so that my preferences and learnings persist across all projects.**
- Acceptance Criteria:
- [ ] `~/.ring/` directory is created on first Ring use
- [ ] Directory contains `config.yaml`, `memory.db`, `skill-rules.json`
- [ ] Works alongside project-local `.ring/` directory
- [ ] Migration path from existing `.ring/` artifacts
**US-1.2: As a Ring user, I want my global settings to be separate from project-specific settings so that I can have different behaviors per project.**
- Acceptance Criteria:
- [ ] Global `~/.ring/config.yaml` for user preferences
- [ ] Project `.ring/config.yaml` overrides global settings
- [ ] Clear precedence: project > global > defaults
### Epic 2: YAML Handoff Format
**US-2.1: As a Ring user, I want handoffs in YAML format so that they consume fewer tokens and can be machine-parsed.**
- Acceptance Criteria:
- [ ] New `/ring:create-handoff` produces YAML format
- [ ] YAML handoffs are ~400 tokens (vs ~2000 markdown)
- [ ] Schema validation ensures required fields present
- [ ] `/ring:resume-handoff` reads both YAML and legacy Markdown
**US-2.2: As a Ring user, I want handoffs to be auto-indexed so that I can search past sessions.**
- Acceptance Criteria:
- [ ] PostToolUse hook indexes YAML handoffs
- [ ] FTS5 search via `/query-artifacts`
- [ ] BM25 ranking for relevance
### Epic 3: Confidence Markers
**US-3.1: As a Ring user, I want agent outputs to include confidence markers so that I know which claims are verified vs assumed.**
- Acceptance Criteria:
- [ ] Agents output `✓ VERIFIED` for claims backed by file reads
- [ ] Agents output `? INFERRED` for claims based on grep/search
- [ ] Agents output `✗ UNCERTAIN` for unverified assumptions
- [ ] Confidence markers appear in Findings/Claims sections
**US-3.2: As a Ring user, I want the ring:codebase-explorer to distinguish verified facts from inferences so that I can trust its findings.**
- Acceptance Criteria:
- [ ] Explorer agent marks each finding with confidence level
- [ ] Evidence chain shows how conclusion was reached
- [ ] Human review flag for low-confidence claims
### Epic 4: Skill Activation Rules
**US-4.1: As a Ring user, I want skills to be automatically suggested based on my prompt so that I don't have to remember all skill names.**
- Acceptance Criteria:
- [ ] `~/.ring/skill-rules.json` defines activation patterns
- [ ] UserPromptSubmit hook matches prompt against rules
- [ ] Matching skills appear as suggestions in response
- [ ] Three enforcement levels: block, suggest, warn
**US-4.2: As a Ring user, I want to customize which skills are suggested for which patterns so that I can tune the system to my workflow.**
- Acceptance Criteria:
- [ ] User can edit `skill-rules.json` directly
- [ ] Project-level rules override global rules
- [ ] Schema validation prevents invalid rules
### Epic 5: Persistent Memory System
**US-5.1: As a Ring user, I want the AI to remember what worked and what failed so that it doesn't repeat mistakes.**
- Acceptance Criteria:
- [ ] Learnings stored in `~/.ring/memory.db` (SQLite + FTS5)
- [ ] 7 learning types: WORKING_SOLUTION, FAILED_APPROACH, etc.
- [ ] Memory recalled at session start via hook
- [ ] Relevant memories injected into context
**US-5.2: As a Ring user, I want to store user preferences that persist across sessions so that the AI adapts to my style.**
- Acceptance Criteria:
- [ ] USER_PREFERENCE learning type for style/preferences
- [ ] Preferences searchable and retrievable
- [ ] Explicit `/remember` command to store preferences
- [ ] Automatic preference extraction from sessions
**US-5.3: As a Ring user, I want memories to have expiration and relevance decay so that the system doesn't get cluttered with stale information.**
- Acceptance Criteria:
- [ ] Memories have `created_at` and `accessed_at` timestamps
- [ ] Unused memories decay in relevance over time
- [ ] Optional `expires_at` for temporary learnings
- [ ] Maintenance task to prune old, low-relevance memories
---
## Acceptance Criteria Summary
### Must Have (P0)
| ID | Requirement | Validation |
|----|-------------|------------|
| P0-1 | `~/.ring/` directory created on first use | Directory exists with config.yaml |
| P0-2 | YAML handoff format produces <500 tokens | Token count measured |
| P0-3 | Confidence markers in explorer agent output | Markers visible in output |
| P0-4 | skill-rules.json with keyword matching | Skills suggested on matching prompts |
| P0-5 | SQLite FTS5 memory storage | Memories searchable via FTS5 |
### Should Have (P1)
| ID | Requirement | Validation |
|----|-------------|------------|
| P1-1 | Memory awareness hook at session start | Relevant memories injected |
| P1-2 | Intent pattern matching (regex) in skill rules | Patterns match fuzzy intents |
| P1-3 | Legacy Markdown handoff migration | Old handoffs still readable |
| P1-4 | Project-level config overrides | Project settings take precedence |
### Nice to Have (P2)
| ID | Requirement | Validation |
|----|-------------|------------|
| P2-1 | Memory relevance decay over time | Old unused memories deprioritized |
| P2-2 | `/remember` command for explicit storage | Command stores user preference |
| P2-3 | Automatic preference extraction | Preferences detected from behavior |
| P2-4 | Cross-project memory queries | Memories from other projects searchable |
---
## Success Metrics
### Quantitative Metrics
| Metric | Baseline | Target | Measurement |
|--------|----------|--------|-------------|
| **Handoff token count** | ~2000 | <500 | Token counter on handoff |
| **Session startup context** | Manual rebuild | Auto-injected | User feedback |
| **Skill discovery rate** | Manual lookup | 80% auto-suggested | Usage analytics |
| **False claim rate** | ~80% unverified | <20% | Sample audit |
### Qualitative Metrics
| Metric | Measurement Method |
|--------|-------------------|
| **User satisfaction** | Feedback in GitHub issues |
| **Adoption rate** | ~/.ring/ directory creation stats |
| **Workflow improvement** | Before/after user testimonials |
---
## Out of Scope
### Explicitly NOT Included
| Item | Reason |
|------|--------|
| **PostgreSQL/pgvector** | Conflicts with "lightweight, no dependencies" principle |
| **Cloud sync** | Privacy concerns, complexity |
| **Multi-user collaboration** | Ring is single-user tool |
| **Real-time memory updates** | Batch updates at session boundaries sufficient |
| **Embedding-based semantic search** | FTS5 with BM25 sufficient for MVP |
| **GUI configuration** | CLI/file-based config is sufficient |
### Future Considerations (Post-MVP)
| Item | Potential Value |
|------|-----------------|
| **Optional embedding support** | Better semantic search for power users |
| **Memory export/import** | Backup and portability |
| **Memory visualization** | Understanding what Ring remembers |
| **Collaborative memory** | Team-shared learnings |
---
## Constraints
### Technical Constraints
| Constraint | Impact |
|------------|--------|
| **No new runtime dependencies** | Must use Python stdlib (sqlite3, json, yaml via PyYAML) |
| **Bash hooks** | Performance-sensitive hooks stay in bash |
| **Backward compatibility** | Existing handoffs must remain readable |
| **Cross-platform** | macOS and Linux support required |
### Business Constraints
| Constraint | Impact |
|------------|--------|
| **Open source** | All code MIT licensed |
| **Self-contained** | No external services required |
| **Privacy-first** | All data stays local |
---
## Dependencies
### Internal Dependencies
| Dependency | Status | Impact |
|------------|--------|--------|
| Existing handoff tracking skill | Stable | Must extend, not replace |
| Existing FTS5 infrastructure | Stable | Reuse patterns |
| Hook system | Stable | Add new hooks |
| Agent output schema | Stable | Add confidence section |
### External Dependencies
| Dependency | Version | Purpose |
|------------|---------|---------|
| Python | 3.9+ | Memory scripts |
| SQLite | 3.35+ | FTS5 support |
| PyYAML | >=6.0 | YAML parsing |
| Bash | 4.0+ | Hook scripts |
---
## Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| **FTS5 insufficient for semantic search** | Medium | Medium | Design for optional embedding layer |
| **Memory bloat over time** | Medium | Low | Decay mechanism, expiration |
| **Skill activation false positives** | High | Low | Negative patterns, confidence thresholds |
| **Breaking existing handoffs** | Low | High | Dual-format support, migration tool |
| **Performance impact from hooks** | Medium | Medium | Timeout limits, async processing |
---
## Gate 1 Validation Checklist
- [x] Problem is clearly defined (session amnesia, repeated context, wasted tokens)
- [x] User value is measurable (80% startup time reduction, 5x token efficiency)
- [x] Acceptance criteria are testable (specific validation per requirement)
- [x] Scope is explicitly bounded (out of scope section)
- [x] Success metrics defined (quantitative and qualitative)
- [x] Risks identified with mitigations
- [x] Dependencies documented
---
## Appendix: Glossary
| Term | Definition |
|------|------------|
| **Handoff** | Document capturing session state for resume |
| **Ledger** | In-session state that survives /clear |
| **Memory** | Persistent learning stored in SQLite |
| **Skill activation** | Automatic suggestion of relevant skills |
| **Confidence marker** | Indicator of verification status (✓/?/✗) |
| **FTS5** | SQLite Full-Text Search version 5 |
| **BM25** | Best Match 25 ranking algorithm |

View file

@ -1,505 +0,0 @@
# Ring Continuity - Research Phase (Gate 0)
> **Generated:** 2026-01-12
> **Research Mode:** Modification (extending existing Ring functionality)
> **Agents Used:** ring:repo-research-analyst, ring:best-practices-researcher, ring:framework-docs-researcher
---
## Executive Summary
Ring already has significant infrastructure for continuity (handoff tracking, continuity ledger, SQLite FTS5 indexing). The implementation will extend these patterns to add:
1. **~/.ring/** global directory (currently only project-local `.ring/`)
2. **Persistent memory** with SQLite FTS5 (lightweight, no dependencies)
3. **YAML handoffs** (~400 tokens vs ~2000 markdown)
4. **skill-rules.json** for activation routing
5. **Confidence markers** in agent outputs
---
## Codebase Research Findings
### Existing Handoff System
| Aspect | Current State | File Reference |
|--------|---------------|----------------|
| **Skill Location** | `default/skills/handoff-tracking/SKILL.md` | Lines 1-208 |
| **Format** | Markdown with YAML frontmatter | Lines 57-123 |
| **Token Cost** | ~1200-2000 tokens per handoff | Measured from examples |
| **Storage Path** | `docs/handoffs/{session-name}/*.md` | Lines 46-55 |
| **Indexing** | Auto-indexed via PostToolUse hook | `default/hooks/artifact-index-write.sh` |
**Current Handoff Template (frontmatter):**
```yaml
---
date: {ISO timestamp}
session_name: {session-name}
git_commit: {hash}
branch: {branch}
repository: {repo}
topic: "{description}"
tags: [implementation, ...]
status: {complete|in_progress|blocked}
outcome: UNKNOWN
---
```
### Skill Discovery & Activation
| Aspect | Current State | File Reference |
|--------|---------------|----------------|
| **Discovery Script** | `default/hooks/generate-skills-ref.py` | Lines 1-308 |
| **Frontmatter Fields** | name, description, trigger, skip_when, sequence, related | Lines 1-15 |
| **Session Start** | `default/hooks/session-start.sh` | Lines 1-299 |
| **No skill-rules.json** | Manual skill invocation only | Opportunity identified |
**Existing Skill Frontmatter Schema:**
```yaml
---
name: ring:skill-name
description: |
What the skill does
trigger: |
- When to use condition 1
skip_when: |
- Skip condition 1
sequence:
before: [skill1]
after: [skill2]
related:
similar: [skill-a]
---
```
### Agent Output Schemas
**Location:** `docs/AGENT_DESIGN.md:1-275`
| Schema Type | Purpose | Key Sections |
|-------------|---------|--------------|
| Implementation | Code changes | Summary, Implementation, Files Changed, Testing |
| Analysis | Research findings | Analysis, Findings, Recommendations |
| Reviewer | Code review | VERDICT, Issues Found, What Was Done Well |
| Exploration | Codebase analysis | Key Findings, Architecture Insights |
| Planning | Implementation plans | Goal, Architecture, Tasks |
**Existing Confidence Field:**
```sql
-- artifact_schema.sql:37-40
confidence TEXT CHECK(confidence IN ('HIGH', 'INFERRED')) DEFAULT 'INFERRED'
```
### Hook System Architecture
**Location:** `default/hooks/hooks.json:1-90`
| Event | Hooks | Purpose |
|-------|-------|---------|
| SessionStart | session-start.sh | Load ledgers, generate skills overview |
| UserPromptSubmit | claude-md-reminder.sh, context-usage-check.sh | Context injection |
| PostToolUse | artifact-index-write.sh, task-completion-check.sh | Indexing, validation |
| PreCompact | ledger-save.sh | State preservation |
| Stop | ledger-save.sh, outcome-inference.sh, learning-extract.sh | Session end |
**Hook Output Schema:**
```json
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "escaped content string"
}
}
```
### Directory Conventions
| Directory | Purpose | Scope |
|-----------|---------|-------|
| `.ring/ledgers/` | Continuity ledger files | Project-local |
| `.ring/cache/artifact-index/` | SQLite database (context.db) | Project-local |
| `.ring/state/` | Session state files | Project-local |
| `docs/handoffs/` | Handoff documents | Project-local |
| **~/.ring/** | **NOT YET IMPLEMENTED** | Global (new) |
### SQLite FTS5 Implementation
**Schema Location:** `default/lib/artifact-index/artifact_schema.sql:1-343`
| Table | Purpose | FTS5 Index |
|-------|---------|------------|
| handoffs | Task records | handoffs_fts |
| plans | Design documents | plans_fts |
| continuity | Session snapshots | continuity_fts |
| queries | Compound learning Q&A | queries_fts |
| learnings | Extracted patterns | learnings_fts |
---
## Best Practices Research Findings
### Memory System Design
**Memory Type Taxonomy (Industry Standard):**
| Memory Type | Purpose | Storage Strategy |
|-------------|---------|------------------|
| **Episodic** | Interaction events | Timestamped logs |
| **Semantic** | Extracted knowledge | Key-value pairs |
| **Procedural** | Behavioral patterns | Policy rules |
**Key Recommendations:**
- Never rely on LLM implicit weights for facts
- Implement utility-based deletion (10% performance gain)
- Define explicit retention policies (what, how long, when to delete)
- Use semantic deduplication (0.85 similarity threshold)
**Learning Types for Ring:**
```python
LEARNING_TYPES = [
"FAILED_APPROACH", # Things that didn't work
"WORKING_SOLUTION", # Successful approaches
"USER_PREFERENCE", # User style/preferences
"CODEBASE_PATTERN", # Discovered code patterns
"ARCHITECTURAL_DECISION", # Design choices made
"ERROR_FIX", # Error->solution pairs
"OPEN_THREAD", # Unfinished work/TODOs
]
```
### Session Continuity Patterns
**YAML vs Markdown:**
- YAML: Machine-parseable, ~400 tokens, schema-validatable
- Markdown: Human-readable, ~2000 tokens, flexible
- **Hybrid recommended:** YAML frontmatter + Markdown body
**Essential Handoff Fields:**
| Field | Purpose | Required |
|-------|---------|----------|
| session_id | Unique identifier | Yes |
| timestamp | Creation time | Yes |
| task_summary | 1-2 sentence summary | Yes |
| current_state | planning/implementing/testing/blocked | Yes |
| key_decisions | Architectural choices | Yes |
| blockers | What prevented completion | If applicable |
| next_steps | What to do on resume | Yes |
**Anthropic's Context Engineering:**
1. Treat context as finite "attention budget"
2. Just-in-time loading (identifiers, not content)
3. Compaction (summarize, preserve decisions)
4. Progressive disclosure
### Skill Activation Patterns
**Three-Tier Enforcement:**
| Level | Behavior | Use Case |
|-------|----------|----------|
| **Block** | Must use skill | Critical workflows (TDD) |
| **Suggest** | Offer, allow skip | Helpful optimizations |
| **Warn** | Log availability | Analytics/learning |
**Layered Matching Approach:**
1. Rules/Regex (fast, deterministic)
2. Lemmatization (word forms)
3. Fuzzy matching (typos)
4. Semantic fallback (novel expressions)
**False Positive Reduction:**
- Negative patterns (exclude non-matches)
- Context-aware matching (verb+noun, not just nouns)
- Confidence thresholds (>0.85 auto, 0.6-0.85 suggest)
### Confidence Markers
**Critical Finding:** Natural language epistemic markers are unreliable in out-of-distribution scenarios.
**Recommended Approach:** Structured verification status
```yaml
verification:
status: verified|unverified|partially_verified|needs_review
evidence:
- type: test_passed|code_review|manual_check|assumption
description: string
confidence_factors:
code_exists: boolean
tests_pass: boolean
reviewed: boolean
```
**Confidence Markers for Ring:**
| Marker | Symbol | Meaning |
|--------|--------|---------|
| VERIFIED | ✓ | Read the file, traced the code |
| INFERRED | ? | Based on grep/search |
| UNCERTAIN | ✗ | Haven't checked |
### SQLite FTS5 Best Practices
**When FTS5 is Sufficient:**
- Keyword search
- Exact phrase matching
- Known vocabulary
- Typo tolerance (with trigram)
**When Embeddings Needed:**
- Semantic similarity
- Cross-language search
- Synonym expansion
**Recommended Tokenizer:**
```sql
CREATE VIRTUAL TABLE memories USING fts5(
content,
category,
tags,
tokenize = 'porter unicode61 remove_diacritics 1',
prefix = '2 3'
);
```
**BM25 Column Weighting:**
```sql
SELECT *, bm25(memories, 10.0, 5.0, 1.0) as relevance
-- title:10x, tags:5x, content:1x
FROM memories WHERE memories MATCH 'query'
ORDER BY relevance;
```
---
## Framework Documentation Findings
### Claude Code Hook System
**Input Format (stdin):**
```json
{
"session_id": "string",
"transcript_path": "string",
"cwd": "string",
"permission_mode": "string",
"prompt": "string"
}
```
**Output Format (stdout):**
```json
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Content injected into context"
}
}
```
**Blocking Response (PreToolUse):**
```json
{
"result": "block",
"reason": "Reason for blocking"
}
```
**Environment Variables:**
- `CLAUDE_PLUGIN_ROOT` - Plugin directory
- `CLAUDE_PROJECT_DIR` - Project directory
- `CLAUDE_SESSION_ID` - Session identifier
### SQLite FTS5 Python Implementation
```python
import sqlite3
def create_memory_database(db_path: str):
conn = sqlite3.connect(db_path)
# Main table
conn.execute("""
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
memory_type TEXT NOT NULL,
content TEXT NOT NULL,
context TEXT,
tags TEXT,
confidence TEXT DEFAULT 'medium'
)
""")
# FTS5 virtual table
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
content, context, tags,
content_rowid='id',
tokenize='porter unicode61'
)
""")
# Auto-sync triggers
conn.execute("""
CREATE TRIGGER IF NOT EXISTS memories_ai
AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, content, context, tags)
VALUES (new.id, new.content, new.context, new.tags);
END
""")
return conn
```
### YAML Schema Design
**PyYAML Safe Loading:**
```python
import yaml
def load_handoff(file_path: str) -> dict:
with open(file_path, 'r') as f:
content = f.read()
if content.startswith('---'):
parts = content.split('---', 2)
if len(parts) >= 3:
frontmatter = yaml.safe_load(parts[1])
body = parts[2].strip()
return {'frontmatter': frontmatter, 'body': body}
return yaml.safe_load(content)
```
### JSON Schema for Skill Rules
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["version", "skills"],
"properties": {
"version": {"type": "string"},
"skills": {
"type": "object",
"additionalProperties": {
"type": "object",
"required": ["type", "enforcement", "priority"],
"properties": {
"type": {"enum": ["guardrail", "domain", "workflow"]},
"enforcement": {"enum": ["block", "suggest", "warn"]},
"priority": {"enum": ["critical", "high", "medium", "low"]},
"promptTriggers": {
"type": "object",
"properties": {
"keywords": {"type": "array", "items": {"type": "string"}},
"intentPatterns": {"type": "array", "items": {"type": "string"}}
}
}
}
}
}
}
}
```
### Version Constraints
| Dependency | Version | Notes |
|------------|---------|-------|
| Python | 3.9+ | match statements, type hints |
| PyYAML | >=6.0 | safe_load by default |
| SQLite | 3.35+ | FTS5 in stdlib |
| jsonschema | >=4.0 | Draft 2020-12 support |
| Bash | 4.0+ | associative arrays |
---
## Potential Conflicts & Risks
### Database Location Conflict
- **Current:** `.ring/cache/artifact-index/context.db` (project-local)
- **New:** Need both project-local AND `~/.ring/` global databases
- **Resolution:** Dual database support with merge queries
### Handoff Format Change
- **Current:** Markdown with YAML frontmatter
- **New:** Pure YAML
- **Resolution:** Migration script + dual-format indexer support
### Hook Timing
- **Current:** Stop hook runs outcome-inference.sh, learning-extract.sh sequentially
- **Risk:** Adding memory writes may cause timeout
- **Resolution:** Background write or async processing
### Session Isolation
- **Current:** State files use `${SESSION_ID}` in filenames
- **Risk:** Global memory needs cross-session identifier strategy
- **Resolution:** User-scoped vs project-scoped memory distinction
---
## Recommended Architecture
### ~/.ring/ Directory Structure
```
~/.ring/
├── config.yaml # User preferences
├── memory.db # Global SQLite + FTS5
├── skill-rules.json # Activation rules
├── skill-rules-schema.json # JSON Schema for validation
├── state/ # Session state
│ └── context-usage-{session}.json
├── ledgers/ # Global continuity ledgers
│ └── CONTINUITY-{name}.md
├── handoffs/ # Global handoffs
│ └── {session}/
│ └── {timestamp}.yaml
└── logs/ # Debug logs
└── {date}.log
```
### Phased Implementation
| Phase | Components | Effort | Impact |
|-------|------------|--------|--------|
| **1** | ~/.ring/ structure + migration | Low | Foundation |
| **2** | YAML handoff format | Medium | High (5x token efficiency) |
| **3** | Confidence markers in agents | Low | High (reduces false claims) |
| **4** | skill-rules.json + activation hook | Medium | Medium (better routing) |
| **5** | Global memory system | High | High (cross-session learning) |
---
## Gate 0 Validation Checklist
- [x] Research mode determined: **Modification** (extending existing functionality)
- [x] All 3 agents dispatched and returned
- [x] File:line references included (see Codebase Research Findings)
- [x] External URLs included (see Best Practices Research Findings)
- [x] Tech stack versions documented (Python 3.9+, SQLite 3.35+, etc.)
- [x] Existing patterns identified for reuse
- [x] Potential conflicts documented with resolutions
---
## Sources
### Codebase References
- `default/skills/handoff-tracking/SKILL.md:1-208`
- `default/hooks/generate-skills-ref.py:1-308`
- `default/hooks/session-start.sh:1-299`
- `default/hooks/hooks.json:1-90`
- `default/lib/artifact-index/artifact_schema.sql:1-343`
- `docs/AGENT_DESIGN.md:1-275`
### External Sources
- [SQLite FTS5 Documentation](https://sqlite.org/fts5.html)
- [Anthropic - Effective Context Engineering](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents)
- [Mem0 Research Paper](https://arxiv.org/abs/2504.19413)
- [IBM - What Is AI Agent Memory](https://www.ibm.com/think/topics/ai-agent-memory)
- [Skywork - AI Agent Orchestration Best Practices](https://skywork.ai/blog/ai-agent-orchestration-best-practices-handoffs/)
- [ACL 2025 - Epistemic Markers](https://aclanthology.org/2025.acl-short.18/)

File diff suppressed because it is too large Load diff

View file

@ -1,931 +0,0 @@
# Ring Continuity - Task Breakdown (Gate 7)
> **Version:** 1.0
> **Date:** 2026-01-12
> **Status:** Draft
> **Based on:** Dependency Map v1.0 (docs/pre-dev/ring-continuity/dependency-map.md)
---
## Executive Summary
Ring Continuity implementation is broken into **5 phases** with **15 tasks** total. Each task delivers **independently deployable value** and includes **comprehensive testing**. Phases build on each other but each phase can ship to users separately.
**Total Estimated Effort:** 8-12 days (1.5-2.5 weeks)
**Phased Rollout:**
- Phase 1-2: Core foundation (3-4 days)
- Phase 3-4: Enhancements (3-4 days)
- Phase 5: Advanced features (2-4 days)
---
## Phase Overview
| Phase | Tasks | Value Delivered | Effort | Risk |
|-------|-------|-----------------|--------|------|
| **Phase 1: Foundation** | 3 | ~/.ring/ directory, config precedence | 2-3 days | Low |
| **Phase 2: YAML Handoffs** | 4 | 5x token efficiency, machine-parseable | 2-3 days | Low |
| **Phase 3: Confidence Markers** | 2 | Reduced false claims (80% → <20%) | 1-2 days | Low |
| **Phase 4: Skill Activation** | 3 | Auto-suggestion, better discovery | 2-3 days | Medium |
| **Phase 5: Persistent Memory** | 3 | Cross-session learning | 2-4 days | Medium |
---
## Phase 1: Foundation
**Goal:** Establish ~/.ring/ as Ring's global home with config precedence.
**User Value:** Single source of truth for Ring configuration and data across all projects.
**Success Criteria:** Users can customize Ring globally, projects can override.
---
### Task 1.1: Create ~/.ring/ Directory Structure
**Description:** Initialize ~/.ring/ with standard subdirectories on first Ring use.
**Value:** Foundation for all persistent Ring data.
**Acceptance Criteria:**
- [ ] ~/.ring/ created with subdirectories: config/, handoffs/, logs/, cache/, state/
- [ ] Directory creation is idempotent (safe to run multiple times)
- [ ] Falls back to .ring/ in project if home directory not writable
- [ ] Logs creation to ~/.ring/logs/installation.log
**Files to Create:**
- `default/lib/ring_home.py` - Directory manager
- `default/lib/ring_home_test.py` - Unit tests
- `install-ring-home.sh` - Standalone install script
**Testing Strategy:**
```bash
# Unit tests
pytest default/lib/ring_home_test.py
# Integration test
bash install-ring-home.sh
test -d ~/.ring/handoffs || exit 1
test -f ~/.ring/config.yaml || exit 1
# Fallback test (simulate permission error)
chmod 000 ~/.ring && bash install-ring-home.sh
# Should create .ring/ in current project
```
**Dependencies:** None
**Estimated Effort:** 0.5 days
**Risk:** Low (straightforward directory creation)
---
### Task 1.2: Implement Config Precedence System
**Description:** Load and merge configuration from project → global → defaults.
**Value:** Users can set global preferences and override per project.
**Acceptance Criteria:**
- [ ] Load config from ~/.ring/config.yaml (global)
- [ ] Load config from .ring/config.yaml (project, if exists)
- [ ] Merge with precedence: project > global > hardcoded defaults
- [ ] Return config value with source attribution (which file it came from)
- [ ] Handle missing config files gracefully (use defaults)
**Files to Create:**
- `default/lib/config_loader.py` - Config precedence logic
- `default/lib/config_loader_test.py` - Unit tests
- `~/.ring/config.yaml` - Default global config (created by install script)
**Testing Strategy:**
```python
# Test precedence
def test_config_precedence():
# Setup: global config has value A, project has value B
global_config = {'memory': {'enabled': True}}
project_config = {'memory': {'enabled': False}}
merged = merge_configs(global_config, project_config)
# Project should win
assert merged['memory']['enabled'] == False
assert merged['_source']['memory.enabled'] == 'project'
# Test graceful degradation
def test_missing_config_files():
# Neither global nor project exists
merged = load_config()
# Should use hardcoded defaults
assert merged['memory']['enabled'] == True
assert merged['_source']['memory.enabled'] == 'default'
```
**Dependencies:** Task 1.1 (directory structure)
**Estimated Effort:** 1 day
**Risk:** Low (well-understood pattern)
---
### Task 1.3: Create Migration Utilities
**Description:** Migrate existing .ring/ data to ~/.ring/ with safety checks.
**Value:** Users can adopt new structure without losing existing data.
**Acceptance Criteria:**
- [ ] Detect existing .ring/ directories in projects
- [ ] Migrate handoffs to ~/.ring/handoffs/{project-name}/
- [ ] Preserve original files (read-only copy, not move)
- [ ] Log migration results to ~/.ring/logs/migration.log
- [ ] Provide rollback script in case of issues
**Files to Create:**
- `default/lib/migrate_to_ring_home.py` - Migration script
- `default/lib/migrate_to_ring_home_test.py` - Tests
- `scripts/migrate-ring-data.sh` - User-facing script
**Testing Strategy:**
```bash
# Setup test project with old .ring/
mkdir -p test-project/.ring/handoffs/session1/
echo "---" > test-project/.ring/handoffs/session1/handoff.md
# Run migration
python3 default/lib/migrate_to_ring_home.py test-project
# Verify
test -f ~/.ring/handoffs/test-project/session1/handoff.md || exit 1
test -f test-project/.ring/handoffs/session1/handoff.md || exit 1 # Original preserved
```
**Dependencies:** Task 1.1 (directory structure), Task 1.2 (config loading)
**Estimated Effort:** 0.5-1 day
**Risk:** Low (copy operations, no deletion)
---
## Phase 2: YAML Handoffs
**Goal:** Replace Markdown handoffs with token-efficient YAML format.
**User Value:** 5x token savings (2000 → 400 tokens), machine-parseable for tools.
**Success Criteria:** New handoffs use YAML, legacy Markdown still readable.
---
### Task 2.1: Define YAML Handoff Schema
**Description:** Create JSON Schema for handoff validation and documentation.
**Value:** Prevents invalid handoffs, enables tooling.
**Acceptance Criteria:**
- [ ] JSON Schema file defines all handoff fields
- [ ] Schema includes required vs optional fields
- [ ] Schema includes validation constraints (max lengths, enums)
- [ ] Schema documented with examples
- [ ] Schema version field for future evolution
**Files to Create:**
- `default/schemas/handoff-schema-v1.json` - JSON Schema definition
- `default/schemas/handoff-example.yaml` - Example handoff
- `default/schemas/README.md` - Schema documentation
**Testing Strategy:**
```python
# Validate example against schema
def test_schema_example():
import json, yaml
from jsonschema import validate
with open('default/schemas/handoff-schema-v1.json') as f:
schema = json.load(f)
with open('default/schemas/handoff-example.yaml') as f:
example = yaml.safe_load(f)
# Should validate without errors
validate(instance=example, schema=schema)
```
**Dependencies:** None
**Estimated Effort:** 0.5 days
**Risk:** Low (schema definition)
---
### Task 2.2: Implement YAML Serialization
**Description:** Create Python module to serialize/deserialize handoffs in YAML.
**Value:** Core capability for YAML handoff creation.
**Acceptance Criteria:**
- [ ] Serialize HandoffData to YAML string
- [ ] Deserialize YAML string to HandoffData
- [ ] Support frontmatter + body format (--- delimited)
- [ ] Validate against schema before write (if jsonschema available)
- [ ] Token count estimation from serialized output
- [ ] Handles Unicode correctly
**Files to Create:**
- `default/lib/handoff_serializer.py` - Serialization logic
- `default/lib/handoff_serializer_test.py` - Unit tests
**Testing Strategy:**
```python
def test_serialize_deserialize():
original = HandoffData(
session={'id': 'test-session'},
task={'description': 'Test task', 'status': 'completed'},
# ... full handoff data
)
# Serialize
yaml_str = serialize(original, format='yaml')
# Deserialize
restored = deserialize(yaml_str, format='yaml')
# Should be equivalent
assert original == restored
def test_token_count():
handoff = create_sample_handoff()
yaml_str = serialize(handoff, format='yaml')
token_count = estimate_tokens(yaml_str)
# Should be under 500 tokens
assert token_count < 500
```
**Dependencies:** Task 2.1 (schema), PyYAML
**Estimated Effort:** 1 day
**Risk:** Low (straightforward serialization)
---
### Task 2.3: Update ring:handoff-tracking Skill for YAML
**Description:** Modify ring:handoff-tracking skill to output YAML instead of Markdown.
**Value:** Users immediately benefit from token savings.
**Acceptance Criteria:**
- [ ] Skill generates YAML format by default
- [ ] User can choose format via parameter: `--format=yaml|markdown`
- [ ] YAML output validated against schema
- [ ] Token count logged to session
- [ ] Backward compatible with existing skill invocation
**Files to Modify:**
- `default/skills/handoff-tracking/SKILL.md` - Update template and instructions
- `default/skills/handoff-tracking/handoff-template.yaml` - New YAML template
**Testing Strategy:**
```bash
# Test YAML handoff creation
echo "Create handoff for test session" | claude-code
# Verify YAML format
file_path=$(ls -t docs/handoffs/*/2026-*.yaml | head -1)
python3 -c "import yaml; yaml.safe_load(open('$file_path'))" # Should not error
# Verify token count
tokens=$(wc -w < "$file_path" | awk '{print $1 * 1.3}') # Rough estimate
test "$tokens" -lt 500 || exit 1
```
**Dependencies:** Task 2.2 (serialization)
**Estimated Effort:** 1 day
**Risk:** Low (skill update only)
---
### Task 2.4: Implement Dual-Format Indexing
**Description:** Update artifact indexer to handle both YAML and Markdown handoffs.
**Value:** All handoffs searchable regardless of format.
**Acceptance Criteria:**
- [ ] Detect handoff format from file extension (.yaml, .yml, .md)
- [ ] Extract frontmatter from both formats
- [ ] Parse YAML body sections (decisions, learnings, etc.)
- [ ] Index with same FTS5 schema regardless of source format
- [ ] Migration flag to convert Markdown to YAML during indexing
**Files to Modify:**
- `default/lib/artifact-index/artifact_index.py` - Add YAML parsing
- `default/lib/artifact-index/artifact_index_test.py` - Add YAML tests
**Testing Strategy:**
```python
def test_index_yaml_handoff():
# Create YAML handoff
yaml_path = create_test_handoff_yaml()
# Index it
index_handoff(yaml_path)
# Search should find it
results = search_handoffs("test task")
assert len(results) > 0
assert yaml_path in [r['file_path'] for r in results]
def test_index_markdown_handoff():
# Existing Markdown handoff
md_path = create_test_handoff_markdown()
# Should still index
index_handoff(md_path)
# Search should find it
results = search_handoffs("test task")
assert md_path in [r['file_path'] for r in results]
```
**Dependencies:** Task 2.2 (serialization), existing artifact-index
**Estimated Effort:** 0.5-1 day
**Risk:** Low (extends existing indexer)
---
## Phase 3: Confidence Markers
**Goal:** Add verification status to agent outputs to reduce false claims.
**User Value:** Users know which findings are verified vs inferred (80% → <20% false claim rate).
**Success Criteria:** Agents output ✓/?/✗ markers with evidence chains.
---
### Task 3.1: Update Agent Output Schemas
**Description:** Add "Verification" section to all agent output schemas in AGENT_DESIGN.md.
**Value:** Standardized confidence reporting across all agents.
**Acceptance Criteria:**
- [ ] Add "Verification" section to 5 schema archetypes
- [ ] Define confidence marker format (✓ VERIFIED, ? INFERRED, ✗ UNCERTAIN)
- [ ] Include evidence chain requirement
- [ ] Update schema validation examples
- [ ] Document when verification is required vs optional
**Files to Modify:**
- `docs/AGENT_DESIGN.md` - Add Verification section to schemas
- `docs/AGENT_DESIGN.md` - Update examples with confidence markers
**Testing Strategy:**
```bash
# Verify schema completeness
grep -c "## Verification" docs/AGENT_DESIGN.md
# Should return 5 (one per schema archetype)
# Verify examples include markers
grep -c "✓ VERIFIED" docs/AGENT_DESIGN.md
grep -c "? INFERRED" docs/AGENT_DESIGN.md
grep -c "✗ UNCERTAIN" docs/AGENT_DESIGN.md
```
**Dependencies:** None (documentation only)
**Estimated Effort:** 0.5 days
**Risk:** Low (documentation update)
---
### Task 3.2: Integrate Confidence Markers in ring:codebase-explorer
**Description:** Update ring:codebase-explorer agent to include confidence markers in findings.
**Value:** Most-used exploration agent immediately benefits from verification.
**Acceptance Criteria:**
- [ ] Agent output includes ## Verification section
- [ ] Each finding marked with ✓/?/✗
- [ ] Evidence chain documented (Glob → Read → Trace → ✓)
- [ ] Low-confidence findings flagged for review
- [ ] Agent prompt updated to enforce marker usage
**Files to Modify:**
- `default/agents/codebase-explorer.md` - Add Verification requirements
- `default/agents/codebase-explorer.md` - Add anti-rationalization table for skipping verification
**Testing Strategy:**
```bash
# Run explorer on test codebase
Task(subagent_type="ring:codebase-explorer", prompt="Find auth implementation")
# Verify output contains markers
output=$(cat .claude/cache/agents/codebase-explorer/latest-output.md)
echo "$output" | grep "## Verification" || exit 1
echo "$output" | grep -E "✓|✗|\?" || exit 1
```
**Dependencies:** Task 3.1 (schema definition)
**Estimated Effort:** 1 day
**Risk:** Low (single agent update)
---
## Phase 4: Skill Activation
**Goal:** Auto-suggest relevant skills based on user prompt patterns.
**User Value:** Users discover skills without memorizing names.
**Success Criteria:** 80% of applicable skills suggested automatically.
---
### Task 4.1: Create skill-rules.json with Core Skills
**Description:** Generate initial skill-rules.json from existing skill frontmatter.
**Value:** Immediate skill suggestions for most common workflows.
**Acceptance Criteria:**
- [ ] Parse all SKILL.md files for trigger/skip_when fields
- [ ] Generate skill-rules.json with keyword mappings
- [ ] Include 20+ core skills (TDD, code review, systematic debugging, etc.)
- [ ] Validate generated JSON against schema
- [ ] Install to ~/.ring/skill-rules.json
**Files to Create:**
- `scripts/generate-skill-rules.py` - Generator script
- `default/schemas/skill-rules-schema.json` - JSON Schema for rules
- `~/.ring/skill-rules.json` - Generated rules file
**Testing Strategy:**
```python
def test_generate_rules():
rules = generate_skill_rules_from_skills()
# Should have entries for key skills
assert 'ring:test-driven-development' in rules['skills']
assert 'ring:requesting-code-review' in rules['skills']
# Should have valid structure
validate(rules, skill_rules_schema)
def test_keyword_extraction():
skill_md = """
---
trigger: |
- When writing tests
- For test-driven development
---
"""
keywords = extract_keywords(skill_md)
assert 'test' in keywords or 'tdd' in keywords
```
**Dependencies:** Existing skills with trigger frontmatter
**Estimated Effort:** 1 day
**Risk:** Medium (mapping logic may need tuning)
---
### Task 4.2: Implement Skill Matching Engine
**Description:** Create Python module to match prompts against skill rules.
**Value:** Core logic for skill auto-suggestion.
**Acceptance Criteria:**
- [ ] Load skill-rules.json with validation
- [ ] Match prompt text against keywords (case-insensitive)
- [ ] Match prompt against regex patterns (intent patterns)
- [ ] Apply negative patterns to filter false positives
- [ ] Rank matches by priority (critical > high > medium > low)
- [ ] Return top 5 matches with confidence scores
**Files to Create:**
- `default/lib/skill_matcher.py` - Matching engine
- `default/lib/skill_matcher_test.py` - Unit tests
**Testing Strategy:**
```python
def test_keyword_matching():
rules = load_test_rules()
prompt = "I want to implement TDD for this feature"
matches = match_skills(prompt, rules)
assert 'ring:test-driven-development' in [m.skill for m in matches]
assert matches[0].confidence > 0.8
def test_negative_patterns():
rules = {
'ring:test-driven-development': {
'keywords': ['test'],
'negative_patterns': [r'test.*data', r'test.*environment']
}
}
# Should NOT match
prompt1 = "Load test data from database"
matches = match_skills(prompt1, rules)
assert 'ring:test-driven-development' not in [m.skill for m in matches]
# Should match
prompt2 = "Write test for authentication"
matches = match_skills(prompt2, rules)
assert 'ring:test-driven-development' in [m.skill for m in matches]
```
**Dependencies:** Task 4.1 (skill-rules.json)
**Estimated Effort:** 1-2 days
**Risk:** Medium (false positive tuning needed)
---
### Task 4.3: Create UserPromptSubmit Hook for Activation
**Description:** Hook that suggests skills on user prompt submission.
**Value:** Users see skill suggestions in real-time.
**Acceptance Criteria:**
- [ ] Hook runs on UserPromptSubmit event
- [ ] Calls skill matcher with user prompt
- [ ] Returns suggestions in additionalContext
- [ ] Respects enforcement levels (block/suggest/warn)
- [ ] Completes within 1 second timeout
- [ ] Gracefully handles matcher failures
**Files to Create:**
- `default/hooks/skill-activation.sh` - Bash hook wrapper
- `default/hooks/skill-activation.py` - Python matcher caller
- Update `default/hooks/hooks.json` - Register new hook
**Testing Strategy:**
```bash
# Mock hook input
cat <<EOF | default/hooks/skill-activation.sh
{
"event": "UserPromptSubmit",
"session_id": "test",
"prompt": "I want to use TDD for this feature"
}
EOF
# Verify output suggests TDD skill
output=$(cat)
echo "$output" | jq '.hookSpecificOutput.additionalContext' | grep "ring:test-driven-development"
```
**Dependencies:** Task 4.2 (matching engine)
**Estimated Effort:** 1 day
**Risk:** Medium (hook timing sensitive)
---
## Phase 5: Persistent Memory
**Goal:** Store and recall learnings across sessions.
**User Value:** AI remembers what worked/failed, user preferences, patterns.
**Success Criteria:** Learnings retrieved and injected at session start.
---
### Task 5.1: Create Memory Database Schema
**Description:** Initialize SQLite database with FTS5 for memory storage.
**Value:** Foundation for persistent learning.
**Acceptance Criteria:**
- [ ] Create memories table with all fields (id, content, type, confidence, etc.)
- [ ] Create memories_fts virtual table for full-text search
- [ ] Create triggers for automatic FTS sync
- [ ] Create indexes for common queries (type, session_id, created_at)
- [ ] Schema supports soft deletion (deleted_at field)
- [ ] Schema supports expiration (expires_at field)
**Files to Create:**
- `default/lib/memory-schema.sql` - Database schema
- `default/lib/memory_repository.py` - Repository implementation
- `default/lib/memory_repository_test.py` - Unit tests
**Testing Strategy:**
```python
def test_memory_storage():
repo = MemoryRepository('~/.ring/memory.db')
learning_id = repo.store_learning(
content="Use async/await for better readability",
type="WORKING_SOLUTION",
confidence="HIGH"
)
assert learning_id is not None
def test_fts_search():
repo = MemoryRepository('~/.ring/memory.db')
# Store learning
repo.store_learning("TypeScript async patterns work well", type="WORKING_SOLUTION")
# Search should find it
results = repo.search_memories("typescript async")
assert len(results) > 0
assert "async" in results[0].content.lower()
```
**Dependencies:** Task 1.1 (directory structure)
**Estimated Effort:** 1-2 days
**Risk:** Low (similar to existing artifact-index)
---
### Task 5.2: Implement Memory Search & Retrieval
**Description:** Create search interface with BM25 ranking and filters.
**Value:** Users can find relevant learnings quickly.
**Acceptance Criteria:**
- [ ] Search by text query using FTS5
- [ ] Filter by learning type, tags, confidence
- [ ] Rank by BM25 relevance score
- [ ] Apply recency weighting (recent learnings rank higher)
- [ ] Update access_count and accessed_at on retrieval
- [ ] Return top 10 results with highlights
**Files to Modify:**
- `default/lib/memory_repository.py` - Add search methods
**Testing Strategy:**
```python
def test_search_with_filters():
repo = MemoryRepository('~/.ring/memory.db')
results = repo.search_memories(
query="async await",
filters={'types': ['WORKING_SOLUTION'], 'confidence': 'HIGH'},
limit=5
)
assert len(results) <= 5
assert all(r.type == 'WORKING_SOLUTION' for r in results)
assert all(r.confidence == 'HIGH' for r in results)
def test_relevance_ranking():
repo = MemoryRepository('~/.ring/memory.db')
results = repo.search_memories("error handling")
# Results should be sorted by relevance
for i in range(len(results) - 1):
assert results[i].relevance_score >= results[i+1].relevance_score
```
**Dependencies:** Task 5.1 (database schema)
**Estimated Effort:** 1 day
**Risk:** Low (FTS5 handles ranking)
---
### Task 5.3: Create SessionStart Hook for Memory Injection
**Description:** Hook that searches memories on session start and injects relevant ones.
**Value:** Users automatically get context from past sessions.
**Acceptance Criteria:**
- [ ] Hook runs on SessionStart event
- [ ] Searches memories using project path as context
- [ ] Injects top 5 relevant memories into session
- [ ] Formats memories for readability
- [ ] Completes within 2 seconds timeout
- [ ] Handles missing memory.db gracefully
**Files to Create:**
- `default/hooks/memory-awareness.sh` - Hook wrapper
- `default/hooks/memory-awareness.py` - Python memory searcher
- Update `default/hooks/hooks.json` - Register hook
**Testing Strategy:**
```bash
# Store test learning
python3 -c "
from memory_repository import MemoryRepository
repo = MemoryRepository('~/.ring/memory.db')
repo.store_learning('Test learning about async', type='WORKING_SOLUTION')
"
# Mock SessionStart hook
cat <<EOF | default/hooks/memory-awareness.sh
{
"event": "SessionStart",
"session_id": "test",
"project_path": "$PWD"
}
EOF
# Verify memories injected
output=$(cat)
echo "$output" | jq '.hookSpecificOutput.additionalContext' | grep "async"
```
**Dependencies:** Task 5.2 (search), Task 1.2 (config for enable/disable)
**Estimated Effort:** 1-2 days
**Risk:** Medium (timing sensitive, must complete fast)
---
## Task Dependencies Graph
```
Phase 1: Foundation
T1.1 (Directory) ──┬──> T1.2 (Config)
└──> T1.3 (Migration)
Phase 2: YAML Handoffs
T2.1 (Schema) ──> T2.2 (Serializer) ──> T2.3 (Skill Update)
└──> T2.4 (Indexing)
Phase 3: Confidence Markers
T3.1 (Schema Update) ──> T3.2 (Explorer Integration)
Phase 4: Skill Activation
T4.1 (Rules Gen) ──> T4.2 (Matcher) ──> T4.3 (Hook)
Phase 5: Persistent Memory
T5.1 (DB Schema) ──> T5.2 (Search) ──> T5.3 (Hook)
Cross-Phase Dependencies:
T1.1 ──> T2.2, T4.1, T5.1 (All need ~/.ring/)
T1.2 ──> T4.3, T5.3 (Hooks check config)
T2.2 ──> T2.4 (Indexing needs serializer)
```
---
## Testing Strategy Summary
### Test Coverage Targets
| Component | Unit Test Coverage | Integration Test Coverage |
|-----------|-------------------|---------------------------|
| ring_home.py | 90% | 100% |
| config_loader.py | 95% | 100% |
| handoff_serializer.py | 95% | 100% |
| skill_matcher.py | 90% | 90% |
| memory_repository.py | 90% | 95% |
| Hooks | 70% (bash) | 100% |
### Test Data
**Location:** `tests/fixtures/`
**Test Artifacts:**
- Sample handoffs (YAML and Markdown)
- Sample skill-rules.json
- Sample memory.db with test learnings
- Sample config.yaml files (global and project)
### Continuous Integration
**On commit:**
```bash
# Lint
shellcheck default/hooks/*.sh
ruff check default/lib/
black --check default/lib/
# Test
pytest tests/ --cov=default/lib/ --cov-report=term-missing
# Coverage gate
coverage report --fail-under=85
```
---
## Deployment Strategy
### Rollout Plan
| Phase | Users | Deployment |
|-------|-------|------------|
| **Phase 1-2** | Early adopters | Alpha release, manual install |
| **Phase 3-4** | Ring users | Beta release, install script |
| **Phase 5** | All users | Stable release, auto-update |
### Feature Flags
**Location:** `~/.ring/config.yaml`
```yaml
features:
yaml_handoffs: true # Enable YAML handoff format
skill_activation: true # Enable auto-suggestion
memory_system: true # Enable persistent memory
confidence_markers: true # Enable markers in agent outputs
```
**Allows:**
- Gradual feature rollout
- Users opt-out if issues
- A/B testing different configurations
---
## Risk Mitigation
### High-Risk Tasks
| Task | Risk | Mitigation |
|------|------|------------|
| T4.2 (Skill Matcher) | False positives | Extensive test suite with edge cases, negative patterns |
| T4.3 (Activation Hook) | Timeout issues | Async matching, 1s timeout, fallback to no suggestions |
| T5.3 (Memory Hook) | Performance impact | Cache searches, limit to 5 memories, 2s timeout |
### Rollback Plan
**Per Phase:**
1. **Phase 1:** Delete ~/.ring/, revert to .ring/
2. **Phase 2:** Set `handoffs.format: markdown` in config
3. **Phase 3:** Agents output without markers (backward compatible)
4. **Phase 4:** Set `skill_activation.enabled: false` in config
5. **Phase 5:** Set `memory_system.enabled: false` in config
**All features are opt-out via config.**
---
## Gate 7 Validation Checklist
- [x] Every task delivers user value independently
- [x] No task larger than 2 weeks (max is 2 days)
- [x] Dependencies are clear (dependency graph)
- [x] Testing approach defined per task
- [x] 5 phases map to 5 feature domains
- [x] 15 total tasks with clear acceptance criteria
- [x] Risk mitigation for high-risk tasks
- [x] Rollout plan with feature flags
---
## Appendix: Task Summary
| Phase | Task | Value | Effort | Risk |
|-------|------|-------|--------|------|
| **1** | T1.1 Directory Creation | Foundation | 0.5d | Low |
| **1** | T1.2 Config Precedence | Global preferences | 1d | Low |
| **1** | T1.3 Migration Utilities | Preserve existing data | 0.5-1d | Low |
| **2** | T2.1 YAML Schema | Handoff validation | 0.5d | Low |
| **2** | T2.2 YAML Serialization | Core capability | 1d | Low |
| **2** | T2.3 Update Skill | 5x token savings | 1d | Low |
| **2** | T2.4 Dual-Format Indexing | Universal search | 0.5-1d | Low |
| **3** | T3.1 Update Schemas | Standardized confidence | 0.5d | Low |
| **3** | T3.2 Explorer Integration | Reduced false claims | 1d | Low |
| **4** | T4.1 Generate Rules | Initial rules | 1d | Medium |
| **4** | T4.2 Matching Engine | Core activation logic | 1-2d | Medium |
| **4** | T4.3 Activation Hook | Real-time suggestions | 1d | Medium |
| **5** | T5.1 Memory Database | Persistent storage | 1-2d | Low |
| **5** | T5.2 Memory Search | Learning retrieval | 1d | Low |
| **5** | T5.3 Memory Hook | Auto-injection | 1-2d | Medium |
**Total:** 12-18 days (2.5-3.5 weeks)

File diff suppressed because it is too large Load diff

View file

@ -1,193 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# STATELESS Context Check Utilities
# =============================================================================
# PURPOSE: Pure functions for context estimation without file I/O
#
# USE THIS WHEN:
# - You need quick context percentage calculations
# - You need warning tier lookups without state
# - You're building UI/display components
#
# DO NOT USE FOR:
# - Tracking turn counts (use default/lib/shell/context-check.sh)
# - Persisting state across invocations
#
# KEY FUNCTIONS:
# - estimate_context_pct() - Calculate % from turn count (pure function)
# - get_warning_tier() - Get tier name from percentage (pure function)
# - format_context_warning() - Format user-facing warning message
#
# FORMULA:
# estimated_tokens = 45000 + (turn_count * 2500)
# percentage = estimated_tokens * 100 / context_size
#
# TIERS (4 levels):
# - none: 0-49% (safe, no warning)
# - info: 50-69% (informational notice)
# - warning: 70-84% (recommend handoff/ledger)
# - critical: 85%+ (mandatory action required)
# =============================================================================
# shellcheck disable=SC2034 # Unused variables OK for exported config
# Constants for estimation
# These are conservative estimates based on typical usage patterns
readonly TOKENS_PER_TURN=2500 # Average tokens per conversation turn
readonly BASE_SYSTEM_OVERHEAD=45000 # System prompt, tools, etc.
readonly DEFAULT_CONTEXT_SIZE=200000 # Claude's typical context window
# Estimate context usage percentage based on turn count
# Args: $1 = turn_count (required)
# Returns: Estimated percentage (0-100)
estimate_context_pct() {
local turn_count="${1:-0}"
local context_size="${2:-$DEFAULT_CONTEXT_SIZE}"
# Estimate: system overhead + (turns * tokens_per_turn)
local estimated_tokens=$((BASE_SYSTEM_OVERHEAD + (turn_count * TOKENS_PER_TURN)))
# Calculate percentage (integer math)
local pct=$((estimated_tokens * 100 / context_size))
# Cap at 100%
if [[ "$pct" -gt 100 ]]; then
pct=100
fi
echo "$pct"
}
# Get warning tier based on percentage
# Args: $1 = percentage (required)
# Returns: "none", "info", "warning", or "critical"
get_warning_tier() {
local pct="${1:-0}"
if [[ "$pct" -ge 85 ]]; then
echo "critical"
elif [[ "$pct" -ge 70 ]]; then
echo "warning"
elif [[ "$pct" -ge 50 ]]; then
echo "info"
else
echo "none"
fi
}
# Get warning message for a tier
# Args: $1 = tier (required), $2 = percentage (required)
# Returns: Warning message string or empty
get_warning_message() {
local tier="$1"
local pct="$2"
case "$tier" in
critical)
echo "CONTEXT CRITICAL: ${pct}% - MUST create handoff/ledger and /clear soon to avoid losing work!"
;;
warning)
echo "Context Warning: ${pct}% - Recommend creating a continuity ledger or handoff document soon."
;;
info)
echo "Context at ${pct}%. Consider summarizing progress when you reach a stopping point."
;;
*)
echo ""
;;
esac
}
# Wrap message in MANDATORY-USER-MESSAGE tags for critical actions
# Args: $1 = message content (required)
# Returns: Message wrapped in mandatory tags
wrap_mandatory_message() {
local message="$1"
cat <<EOF
<MANDATORY-USER-MESSAGE>
$message
</MANDATORY-USER-MESSAGE>
EOF
}
# Format a context warning block for injection
# Args: $1 = tier (required), $2 = percentage (required)
# Returns: Formatted warning block or empty
# BEHAVIOR:
# - critical (85%+): MANDATORY-USER-MESSAGE requiring STOP + ledger + handoff + /clear
# - warning (70-84%): MANDATORY-USER-MESSAGE requiring ledger creation
# - info (50-69%): Simple context-warning XML tag (no mandatory)
format_context_warning() {
local tier="$1"
local pct="$2"
local message
message=$(get_warning_message "$tier" "$pct")
if [[ -z "$message" ]]; then
echo ""
return
fi
local icon
case "$tier" in
critical)
icon="[!!!]"
;;
warning)
icon="[!!]"
;;
info)
icon="[i]"
;;
*)
icon=""
;;
esac
# Format based on severity - MANDATORY tags at warning+ tiers
if [[ "$tier" == "critical" ]]; then
# Critical: MANDATORY message requiring immediate action
local critical_content
critical_content=$(cat <<INNER
$icon CONTEXT CRITICAL: ${pct}% usage (from session data).
**MANDATORY ACTIONS:**
1. STOP current task immediately
2. Run /create-handoff to save progress NOW
3. Create continuity-ledger with current state
4. Run /clear to reset context
5. Resume from handoff in new session
**Verify with /context if needed.**
INNER
)
wrap_mandatory_message "$critical_content"
elif [[ "$tier" == "warning" ]]; then
# Warning: MANDATORY message requiring ledger creation
local warning_content
warning_content=$(cat <<INNER
$icon Context Warning: ${pct}% usage (from session data).
**RECOMMENDED ACTIONS:**
- Create a continuity-ledger to preserve session state
- Run: /create-handoff or manually create ledger
**Recommended:** Complete current task, then /clear before starting new work.
INNER
)
wrap_mandatory_message "$warning_content"
else
# Info: Simple warning, no mandatory tags
cat <<EOF
<context-warning severity="info">
$icon Context at ${pct}%.
</context-warning>
EOF
fi
}
# Export for subshells
export -f estimate_context_pct 2>/dev/null || true
export -f get_warning_tier 2>/dev/null || true
export -f get_warning_message 2>/dev/null || true
export -f wrap_mandatory_message 2>/dev/null || true
export -f format_context_warning 2>/dev/null || true

View file

@ -1,114 +0,0 @@
#!/usr/bin/env bash
# Get actual context usage from Claude Code session file
# Returns: percentage (0-100) or "unknown" if cannot determine
#
# Usage: source get-context-usage.sh && get_context_usage
# Or: bash get-context-usage.sh (prints percentage)
set -euo pipefail
# Find the current session file
find_session_file() {
local project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}"
local claude_projects="$HOME/.claude/projects"
# Convert project path to Claude's format (replace / with -)
local project_key
project_key=$(echo "$project_dir" | sed 's|/|-|g')
local project_sessions="$claude_projects/$project_key"
if [[ ! -d "$project_sessions" ]]; then
echo ""
return
fi
# Find most recently modified .jsonl file
local session_file
session_file=$(ls -t "$project_sessions"/*.jsonl 2>/dev/null | head -1)
echo "$session_file"
}
# Get context usage percentage from session file
get_context_usage() {
local session_file
session_file=$(find_session_file)
if [[ -z "$session_file" ]] || [[ ! -f "$session_file" ]]; then
echo "unknown"
return
fi
# Extract usage from last message with usage data
local usage_json
usage_json=$(grep '"usage"' "$session_file" 2>/dev/null | tail -1)
if [[ -z "$usage_json" ]]; then
echo "unknown"
return
fi
# Calculate total tokens and percentage
# Context window is 200k for Claude
local context_size=200000
if command -v jq &>/dev/null; then
# Use jq for accurate parsing
local pct
pct=$(echo "$usage_json" | jq -r '
.message.usage // . |
((.cache_read_input_tokens // 0) + (.cache_creation_input_tokens // 0) + (.input_tokens // 0)) as $total |
($total * 100 / 200000) | floor
' 2>/dev/null)
if [[ "$pct" =~ ^[0-9]+$ ]]; then
echo "$pct"
return
fi
fi
# Fallback: grep-based extraction
local cache_read cache_create input_tok
cache_read=$(echo "$usage_json" | grep -o '"cache_read_input_tokens":[0-9]*' | grep -o '[0-9]*' | head -1)
cache_create=$(echo "$usage_json" | grep -o '"cache_creation_input_tokens":[0-9]*' | grep -o '[0-9]*' | head -1)
input_tok=$(echo "$usage_json" | grep -o '"input_tokens":[0-9]*' | grep -o '[0-9]*' | head -1)
cache_read=${cache_read:-0}
cache_create=${cache_create:-0}
input_tok=${input_tok:-0}
local total=$((cache_read + cache_create + input_tok))
local pct=$((total * 100 / context_size))
echo "$pct"
}
# Get warning tier based on actual usage
get_context_tier() {
local pct
pct=$(get_context_usage)
if [[ "$pct" == "unknown" ]]; then
echo "unknown"
return
fi
if [[ "$pct" -ge 85 ]]; then
echo "critical"
elif [[ "$pct" -ge 70 ]]; then
echo "warning"
elif [[ "$pct" -ge 50 ]]; then
echo "info"
else
echo "safe"
fi
}
# If run directly (not sourced), output usage
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
pct=$(get_context_usage)
tier=$(get_context_tier)
echo "Context Usage: ${pct}%"
echo "Tier: ${tier}"
fi

View file

@ -1,54 +0,0 @@
#!/usr/bin/env bash
# shellcheck disable=SC2034 # Unused variables OK for exported config
# Shared ledger utilities for Ring hooks
# Usage: source this file, then call find_active_ledger "$ledger_dir"
# Find active ledger (most recently modified) - SAFE implementation
# Finds the newest CONTINUITY-*.md file in the specified directory
#
# Arguments:
# $1 - ledger_dir: Path to the directory containing CONTINUITY-*.md files
#
# Returns:
# Prints the path to the most recently modified ledger file, or empty string if none found
#
# Security:
# - Rejects symlinks explicitly (prevents symlink attacks)
# - Uses null-terminated paths (prevents injection via malicious filenames)
# - Portable across macOS/Linux (handles stat differences)
find_active_ledger() {
local ledger_dir="$1"
# Return empty if directory doesn't exist
[[ ! -d "$ledger_dir" ]] && echo "" && return 0
local newest=""
local newest_time=0
# Safe iteration with null-terminated paths
while IFS= read -r -d '' file; do
# Security: Reject symlinks explicitly
if [[ -L "$file" ]]; then
echo "Warning: Skipping symlink: $file" >&2
continue
fi
# Get modification time (portable across macOS/Linux)
local mtime
if [[ "$(uname)" == "Darwin" ]]; then
mtime=$(stat -f %m "$file" 2>/dev/null || echo 0)
else
mtime=$(stat -c %Y "$file" 2>/dev/null || echo 0)
fi
if (( mtime > newest_time )); then
newest_time=$mtime
newest="$file"
fi
done < <(find "$ledger_dir" -maxdepth 1 -name "CONTINUITY-*.md" -type f -print0 2>/dev/null)
echo "$newest"
}
# Export for subshells
export -f find_active_ledger 2>/dev/null || true