mirror of
https://github.com/LerianStudio/ring
synced 2026-04-21 13:37:27 +00:00
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:
parent
387204faba
commit
5ae0fe0104
66 changed files with 10 additions and 38301 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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;
|
||||
|
|
@ -1 +0,0 @@
|
|||
# Tests for artifact-index modules
|
||||
|
|
@ -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"])
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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}
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
|
@ -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) |
|
||||
|
|
@ -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
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue