Compare commits
1 Commits
nexusburn/
...
burn/1354-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eabc32ece8 |
@@ -1,129 +0,0 @@
|
||||
# NexusBurn Backlog Management — Execution Complete
|
||||
|
||||
## Summary
|
||||
Successfully implemented the NexusBurn Backlog Manager for issue #1127: Perplexity Evening Pass — 14 PR Reviews.
|
||||
|
||||
## What Was Built
|
||||
|
||||
### 1. Core Implementation
|
||||
- **Backlog Manager** (`bin/backlog_manager.py`)
|
||||
- Automated triage parser for issue bodies
|
||||
- PR closure automation for zombies, duplicates, and rubber-stamped PRs
|
||||
- Comprehensive reporting with metrics and recommendations
|
||||
- Dry-run support for safe testing
|
||||
|
||||
### 2. Configuration System
|
||||
- **Config File** (`config/backlog_config.yaml`)
|
||||
- Repository-specific settings
|
||||
- Customizable closure templates
|
||||
- Process improvement definitions
|
||||
- Integration points with Gitea, Hermes, and cron
|
||||
|
||||
### 3. Test Suite
|
||||
- **Unit Tests** (`tests/test_backlog_manager.py`)
|
||||
- 6 passing tests covering all core functionality
|
||||
- Mocking for API isolation
|
||||
- Integration tests for real scenarios
|
||||
|
||||
### 4. Documentation
|
||||
- **Usage Guide** (`docs/backlog-manager.md`)
|
||||
- Complete usage examples
|
||||
- Configuration reference
|
||||
- Output file descriptions
|
||||
- Future enhancement roadmap
|
||||
|
||||
## Key Features
|
||||
|
||||
### Automated PR Closure
|
||||
Identifies and closes:
|
||||
1. **Zombie PRs** - PRs with no actual changes (0 additions, 0 deletions)
|
||||
2. **Duplicate PRs** - PRs that are exact duplicates of other PRs
|
||||
3. **Rubber-Stamped PRs** - PRs with approval reviews but no actual changes
|
||||
|
||||
### Process Improvement Tracking
|
||||
Addresses all 5 process issues from issue #1127:
|
||||
1. ✅ Rubber-stamping detection and closure
|
||||
2. ✅ Duplicate PR identification and closure
|
||||
3. ✅ Zombie PR detection and closure
|
||||
4. ✅ Missing reviewer tracking and alerting
|
||||
5. ✅ Duplicate milestone consolidation planning
|
||||
|
||||
### Reporting and Metrics
|
||||
- Markdown reports with summary statistics
|
||||
- JSON logs for programmatic processing
|
||||
- Time-stamped action tracking
|
||||
- Organization health metrics
|
||||
|
||||
## Execution Results
|
||||
|
||||
### Branch Created
|
||||
`nexusburn/backlog-management-1127`
|
||||
|
||||
### Commit
|
||||
```
|
||||
feat: implement NexusBurn Backlog Manager for issue #1127
|
||||
|
||||
- Add automated triage parser for Perplexity Evening Pass data
|
||||
- Implement PR closure automation for zombies, duplicates, and rubber-stamped PRs
|
||||
- Add comprehensive reporting with metrics and recommendations
|
||||
- Include configuration system for repository-specific rules
|
||||
- Add test suite with 6 passing tests
|
||||
- Address all 5 process issues from triage
|
||||
```
|
||||
|
||||
### PR Created
|
||||
**PR #1375**: feat: implement NexusBurn Backlog Manager for issue #1127
|
||||
URL: https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/pulls/1375
|
||||
|
||||
### Issue Updates
|
||||
- Added implementation summary comment to issue #1127
|
||||
- Added follow-up status check comment
|
||||
- Linked PR #1375 to issue #1127
|
||||
|
||||
## Status Check Findings
|
||||
|
||||
**All 14 triaged PRs are already closed:**
|
||||
- 4 PRs recommended for closure: ✅ All closed
|
||||
- 10 other triaged PRs: ✅ All closed
|
||||
|
||||
The triage recommendations from the Perplexity Evening Pass have already been implemented.
|
||||
|
||||
## Value of Implementation
|
||||
|
||||
While the immediate triage issues are resolved, the NexusBurn Backlog Manager provides:
|
||||
|
||||
1. **Automated future triage** - Can process similar triage issues automatically
|
||||
2. **Ongoing backlog health** - Monitors for new zombie/duplicate PRs
|
||||
3. **Process improvement tracking** - Identifies systemic issues like rubber-stamping
|
||||
4. **Reporting infrastructure** - Generates actionable reports for any triage pass
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Review and merge PR #1375**
|
||||
2. **Run backlog manager in dry-run mode** to validate against current state
|
||||
3. **Schedule regular runs** via cron for ongoing backlog maintenance
|
||||
4. **Implement reviewer assignment automation** as next enhancement
|
||||
|
||||
## Files Added/Modified
|
||||
|
||||
```
|
||||
bin/backlog_manager.py # Main implementation
|
||||
config/backlog_config.yaml # Configuration
|
||||
tests/test_backlog_manager.py # Test suite
|
||||
docs/backlog-manager.md # Documentation
|
||||
IMPLEMENTATION_SUMMARY.md # Implementation details
|
||||
```
|
||||
|
||||
## Testing Results
|
||||
All 6 tests pass:
|
||||
- ✅ Token loading
|
||||
- ✅ Triage parsing
|
||||
- ✅ Report generation
|
||||
- ✅ API integration (mocked)
|
||||
- ✅ Dry run functionality
|
||||
- ✅ Close PR workflow
|
||||
|
||||
## Author
|
||||
Timmy (NexusBurn Backlog Management Lane)
|
||||
Date: 2026-04-13
|
||||
Time: 18:23 UTC
|
||||
@@ -1,134 +0,0 @@
|
||||
# NexusBurn Backlog Management Implementation
|
||||
## Issue #1127: Perplexity Evening Pass — 14 PR Reviews
|
||||
|
||||
### Overview
|
||||
This implementation provides automated backlog management for the Timmy Foundation organization, specifically addressing the triage findings from issue #1127.
|
||||
|
||||
### What Was Built
|
||||
|
||||
#### 1. Core Backlog Manager (`bin/backlog_manager.py`)
|
||||
- **Triage Parser**: Extracts structured data from issue bodies containing PR reviews, process issues, and recommendations
|
||||
- **PR Management**: Identifies and closes zombie PRs, duplicate PRs, and rubber-stamped PRs
|
||||
- **Report Generation**: Creates comprehensive markdown reports with metrics and actionable recommendations
|
||||
- **Dry Run Support**: Safe testing mode that shows what would be closed without actually closing PRs
|
||||
|
||||
#### 2. Configuration System (`config/backlog_config.yaml`)
|
||||
- Repository-specific settings for auto-closure rules
|
||||
- Customizable closure comment templates
|
||||
- Process improvement definitions
|
||||
- Integration points with Gitea, Hermes, and cron
|
||||
- Alert thresholds for monitoring
|
||||
|
||||
#### 3. Test Suite (`tests/test_backlog_manager.py`)
|
||||
- Unit tests for all core functionality
|
||||
- Integration tests for dry-run and real scenarios
|
||||
- Mocking for API calls to ensure test isolation
|
||||
|
||||
#### 4. Documentation (`docs/backlog-manager.md`)
|
||||
- Complete usage guide with examples
|
||||
- Configuration reference
|
||||
- Output file descriptions
|
||||
- Future enhancement roadmap
|
||||
|
||||
### Key Features Implemented
|
||||
|
||||
#### Automated PR Closure
|
||||
Based on issue #1127 triage, the system identifies and can close:
|
||||
|
||||
1. **Zombie PRs**: PRs with no actual changes (0 additions, 0 deletions)
|
||||
- Example: timmy-home #572
|
||||
- Example: timmy-config #359 (with 3 rubber-stamp approvals)
|
||||
|
||||
2. **Duplicate PRs**: PRs that are exact duplicates of other PRs
|
||||
- Example: timmy-config #363 (duplicate of #362)
|
||||
- Example: timmy-config #377 (duplicate of timmy-home #580)
|
||||
|
||||
3. **Rubber-Stamped PRs**: PRs with approval reviews but no actual changes
|
||||
- Addresses the process issue identified in triage
|
||||
|
||||
#### Process Improvement Tracking
|
||||
The system identifies and tracks:
|
||||
- Missing reviewer assignments
|
||||
- Duplicate milestones across repositories
|
||||
- SOUL.md canonical location decisions
|
||||
- Empty diff rejection requirements
|
||||
|
||||
#### Reporting and Metrics
|
||||
- Markdown reports with summary statistics
|
||||
- JSON logs for programmatic processing
|
||||
- Time-stamped action tracking
|
||||
- Organization health metrics
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```bash
|
||||
# Generate report only
|
||||
python bin/backlog_manager.py --report-only
|
||||
|
||||
# Dry run (show what would be closed)
|
||||
python bin/backlog_manager.py --close-prs --dry-run
|
||||
|
||||
# Actually close PRs
|
||||
python bin/backlog_manager.py --close-prs
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
#### With Gitea
|
||||
- Uses Gitea API for PR management
|
||||
- Adds explanatory comments before closing
|
||||
- Respects branch protection rules
|
||||
|
||||
#### With Hermes
|
||||
- Logs all actions to Hermes logging system
|
||||
- Can be triggered from Hermes cron jobs
|
||||
- Integrates with burn mode workflows
|
||||
|
||||
#### With Cron
|
||||
- Can be scheduled for regular runs (e.g., daily at 6 PM)
|
||||
- Supports dry-run mode for safe automation
|
||||
|
||||
### Testing Results
|
||||
All 6 tests pass:
|
||||
- Token loading
|
||||
- Triage parsing
|
||||
- Report generation
|
||||
- API integration (mocked)
|
||||
- Dry run functionality
|
||||
- Close PR workflow
|
||||
|
||||
### Files Added/Modified
|
||||
|
||||
```
|
||||
bin/backlog_manager.py # Main implementation
|
||||
config/backlog_config.yaml # Configuration
|
||||
tests/test_backlog_manager.py # Test suite
|
||||
docs/backlog-manager.md # Documentation
|
||||
```
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. **Immediate**: Close the 4 dead PRs identified in triage
|
||||
2. **Short-term**: Implement reviewer assignment automation
|
||||
3. **Medium-term**: Build milestone deduplication tool
|
||||
4. **Long-term**: Integrate with broader burn mode workflow
|
||||
|
||||
### Impact
|
||||
|
||||
This implementation directly addresses the 5 process issues identified in issue #1127:
|
||||
|
||||
1. **Rubber-stamping**: Automated detection and closure
|
||||
2. **Duplicate PRs**: Automated detection and closure
|
||||
3. **Zombie PRs**: Automated detection and closure
|
||||
4. **Missing reviewers**: Tracking and alerting system
|
||||
5. **Duplicate milestones**: Identification and consolidation planning
|
||||
|
||||
### Branch Information
|
||||
- Branch: `nexusburn/backlog-management-1127`
|
||||
- Base: `main`
|
||||
- Issue: #1127
|
||||
- PR: [To be created]
|
||||
|
||||
### Author
|
||||
Timmy (NexusBurn Backlog Management Lane)
|
||||
Date: 2026-04-13
|
||||
@@ -1,331 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NexusBurn Backlog Manager
|
||||
Processes triage data and automates backlog management actions.
|
||||
Issue #1127: Perplexity Evening Pass — 14 PR Reviews
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
# Configuration
|
||||
GITEA_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
||||
TOKEN_PATH = os.path.expanduser("~/.config/gitea/token")
|
||||
LOG_DIR = os.path.expanduser("~/.hermes/backlog-logs")
|
||||
|
||||
|
||||
class BacklogManager:
|
||||
def __init__(self):
|
||||
self.token = self._load_token()
|
||||
self.org = "Timmy_Foundation"
|
||||
|
||||
def _load_token(self) -> str:
|
||||
"""Load Gitea API token."""
|
||||
try:
|
||||
with open(TOKEN_PATH, "r") as f:
|
||||
return f.read().strip()
|
||||
except FileNotFoundError:
|
||||
print(f"ERROR: Token not found at {TOKEN_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
def _api_request(self, endpoint: str, method: str = "GET", data: Optional[Dict] = None) -> Any:
|
||||
"""Make authenticated Gitea API request."""
|
||||
url = f"{GITEA_BASE}{endpoint}"
|
||||
headers = {
|
||||
"Authorization": f"token {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
req = urllib.request.Request(url, headers=headers, method=method)
|
||||
if data:
|
||||
req.data = json.dumps(data).encode()
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
if resp.status == 204: # No content
|
||||
return {"status": "success", "code": resp.status}
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
error_body = e.read().decode() if e.fp else "No error body"
|
||||
print(f"API Error {e.code}: {error_body}")
|
||||
return {"error": e.code, "message": error_body}
|
||||
|
||||
def parse_triage_issue(self, issue_body: str) -> Dict[str, Any]:
|
||||
"""Parse the Perplexity triage issue body into structured data."""
|
||||
result = {
|
||||
"pr_reviews": [],
|
||||
"process_issues": [],
|
||||
"assigned_issues": [],
|
||||
"org_health": {},
|
||||
"recommendations": []
|
||||
}
|
||||
|
||||
lines = issue_body.split("\n")
|
||||
current_section = None
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Detect sections
|
||||
if line.startswith("### PR Reviews"):
|
||||
current_section = "pr_reviews"
|
||||
continue
|
||||
elif line.startswith("### Process Issues"):
|
||||
current_section = "process_issues"
|
||||
continue
|
||||
elif line.startswith("### Issues Assigned"):
|
||||
current_section = "assigned_issues"
|
||||
continue
|
||||
elif line.startswith("### Org Health"):
|
||||
current_section = "org_health"
|
||||
continue
|
||||
elif line.startswith("### Recommendations"):
|
||||
current_section = "recommendations"
|
||||
continue
|
||||
|
||||
# Parse PR reviews
|
||||
if current_section == "pr_reviews" and line.startswith("| #"):
|
||||
parts = [p.strip() for p in line.split("|") if p.strip()]
|
||||
if len(parts) >= 4:
|
||||
pr_info = {
|
||||
"pr": parts[0],
|
||||
"repo": parts[1],
|
||||
"author": parts[2],
|
||||
"verdict": parts[3],
|
||||
"notes": parts[4] if len(parts) > 4 else ""
|
||||
}
|
||||
result["pr_reviews"].append(pr_info)
|
||||
|
||||
# Parse process issues (lines starting with "1. **" or "1. ")
|
||||
elif current_section == "process_issues":
|
||||
# Check for numbered list items
|
||||
if line.startswith("1.") or line.startswith("2.") or line.startswith("3.") or line.startswith("4.") or line.startswith("5."):
|
||||
# Extract content after the number and period
|
||||
content = line[2:].strip()
|
||||
result["process_issues"].append(content)
|
||||
|
||||
# Parse recommendations (lines starting with "1. **" or "1. ")
|
||||
elif current_section == "recommendations":
|
||||
# Check for numbered list items
|
||||
if line.startswith("1.") or line.startswith("2.") or line.startswith("3.") or line.startswith("4."):
|
||||
# Extract content after the number and period
|
||||
content = line[2:].strip()
|
||||
result["recommendations"].append(content)
|
||||
|
||||
return result
|
||||
|
||||
def get_open_prs(self, repo: str) -> List[Dict]:
|
||||
"""Get open PRs for a repository."""
|
||||
endpoint = f"/repos/{self.org}/{repo}/pulls?state=open"
|
||||
prs = self._api_request(endpoint)
|
||||
return prs if isinstance(prs, list) else []
|
||||
|
||||
def close_pr(self, repo: str, pr_number: int, reason: str) -> bool:
|
||||
"""Close a pull request with a comment explaining why."""
|
||||
# First, add a comment
|
||||
comment_data = {
|
||||
"body": f"**Closed by NexusBurn Backlog Manager**\n\nReason: {reason}\n\nSee issue #1127 for triage context."
|
||||
}
|
||||
|
||||
comment_endpoint = f"/repos/{self.org}/{repo}/issues/{pr_number}/comments"
|
||||
comment_result = self._api_request(comment_endpoint, "POST", comment_data)
|
||||
|
||||
if "error" in comment_result:
|
||||
print(f"Failed to add comment to PR #{pr_number}: {comment_result}")
|
||||
return False
|
||||
|
||||
# Close the PR by updating state
|
||||
close_data = {"state": "closed"}
|
||||
close_endpoint = f"/repos/{self.org}/{repo}/pulls/{pr_number}"
|
||||
close_result = self._api_request(close_endpoint, "PATCH", close_data)
|
||||
|
||||
if "error" in close_result:
|
||||
print(f"Failed to close PR #{pr_number}: {close_result}")
|
||||
return False
|
||||
|
||||
print(f"Closed PR #{pr_number} in {repo}: {reason}")
|
||||
return True
|
||||
|
||||
def generate_report(self, triage_data: Dict[str, Any]) -> str:
|
||||
"""Generate a markdown report of triage analysis."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
report = f"""# NexusBurn Backlog Report
|
||||
Generated: {now}
|
||||
Source: Issue #1127 — Perplexity Evening Pass
|
||||
|
||||
## Summary
|
||||
- **Total PRs reviewed:** {len(triage_data['pr_reviews'])}
|
||||
- **Process issues identified:** {len(triage_data['process_issues'])}
|
||||
- **Recommendations:** {len(triage_data['recommendations'])}
|
||||
|
||||
## PR Review Results
|
||||
| Verdict | Count |
|
||||
|---------|-------|
|
||||
| Approved | {sum(1 for r in triage_data['pr_reviews'] if '✅' in r['verdict'])} |
|
||||
| Close | {sum(1 for r in triage_data['pr_reviews'] if '❌' in r['verdict'])} |
|
||||
| Comment | {sum(1 for r in triage_data['pr_reviews'] if '💬' in r['verdict'])} |
|
||||
| Needs Review | {sum(1 for r in triage_data['pr_reviews'] if r['verdict'] == '—')} |
|
||||
|
||||
## PRs to Close
|
||||
"""
|
||||
close_prs = [r for r in triage_data['pr_reviews'] if '❌' in r['verdict']]
|
||||
for pr in close_prs:
|
||||
report += f"- **{pr['pr']}** ({pr['repo']}): {pr['notes']}\n"
|
||||
|
||||
report += f"""
|
||||
## Process Issues
|
||||
"""
|
||||
for i, issue in enumerate(triage_data['process_issues'], 1):
|
||||
report += f"{i}. {issue}\n"
|
||||
|
||||
report += f"""
|
||||
## Recommendations
|
||||
"""
|
||||
for i, rec in enumerate(triage_data['recommendations'], 1):
|
||||
report += f"{i}. {rec}\n"
|
||||
|
||||
report += f"""
|
||||
## Action Items
|
||||
1. Close {len(close_prs)} dead PRs identified in triage
|
||||
2. Review duplicate milestone consolidation
|
||||
3. Implement reviewer assignment policy
|
||||
4. Establish SOUL.md canonical location
|
||||
"""
|
||||
|
||||
return report
|
||||
|
||||
def process_close_prs(self, triage_data: Dict[str, Any], dry_run: bool = True) -> List[Dict]:
|
||||
"""Process PRs that should be closed based on triage."""
|
||||
actions = []
|
||||
|
||||
# Parse close-worthy PRs from triage
|
||||
close_prs = [r for r in triage_data['pr_reviews'] if '❌' in r['verdict']]
|
||||
|
||||
for pr_info in close_prs:
|
||||
# Extract PR number and repo
|
||||
pr_str = pr_info['pr'].replace('#', '')
|
||||
repo = pr_info['repo']
|
||||
|
||||
try:
|
||||
pr_number = int(pr_str)
|
||||
except ValueError:
|
||||
print(f"Warning: Could not parse PR number from '{pr_str}'")
|
||||
continue
|
||||
|
||||
# Check if PR is still open
|
||||
open_prs = self.get_open_prs(repo)
|
||||
pr_exists = any(p['number'] == pr_number for p in open_prs)
|
||||
|
||||
action = {
|
||||
"repo": repo,
|
||||
"pr_number": pr_number,
|
||||
"reason": pr_info['notes'],
|
||||
"exists": pr_exists,
|
||||
"closed": False
|
||||
}
|
||||
|
||||
if pr_exists:
|
||||
if not dry_run:
|
||||
success = self.close_pr(repo, pr_number, pr_info['notes'])
|
||||
action["closed"] = success
|
||||
else:
|
||||
print(f"DRY RUN: Would close PR #{pr_number} in {repo}")
|
||||
|
||||
actions.append(action)
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for backlog manager."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="NexusBurn Backlog Manager")
|
||||
parser.add_argument("--triage-file", help="Path to triage issue body file")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Don't actually close PRs")
|
||||
parser.add_argument("--report-only", action="store_true", help="Generate report only")
|
||||
parser.add_argument("--close-prs", action="store_true", help="Process PR closures")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
manager = BacklogManager()
|
||||
|
||||
# For this implementation, we'll hardcode the triage data from issue #1127
|
||||
# In production, this would parse from the actual issue or a downloaded file
|
||||
triage_data = {
|
||||
"pr_reviews": [
|
||||
{"pr": "#1113", "repo": "the-nexus", "author": "claude", "verdict": "✅ Approved", "notes": "Clean audit response doc, +9"},
|
||||
{"pr": "#580", "repo": "timmy-home", "author": "Timmy", "verdict": "✅ Approved", "notes": "SOUL.md identity lock — urgent fix for Claude bleed-through"},
|
||||
{"pr": "#572", "repo": "timmy-home", "author": "Timmy", "verdict": "❌ Close", "notes": "**Zombie** — 0 additions, 0 deletions, 0 changed files"},
|
||||
{"pr": "#377", "repo": "timmy-config", "author": "Timmy", "verdict": "❌ Close", "notes": "**Duplicate** of timmy-home #580 (exact same SOUL.md diff)"},
|
||||
{"pr": "#375", "repo": "timmy-config", "author": "perplexity", "verdict": "—", "notes": "My own PR (MEMORY_ARCHITECTURE.md), needs external reviewer"},
|
||||
{"pr": "#374", "repo": "timmy-config", "author": "Timmy", "verdict": "✅ Approved", "notes": "MemPalace integration — skill port, enforcer, scratchpad, wakeup + tests"},
|
||||
{"pr": "#366", "repo": "timmy-config", "author": "Timmy", "verdict": "💬 Comment", "notes": "Art assets (24 images + 2 videos) — question: should media live in timmy-config?"},
|
||||
{"pr": "#365", "repo": "timmy-config", "author": "Rockachopa", "verdict": "✅ Approved", "notes": "FLEET-010/011/012 — cross-agent delegation, model pipeline, lifecycle"},
|
||||
{"pr": "#364", "repo": "timmy-config", "author": "gemini", "verdict": "✅ Approved", "notes": "Bezalel config, +10, clean"},
|
||||
{"pr": "#363", "repo": "timmy-config", "author": "Timmy", "verdict": "❌ Close", "notes": "**Exact duplicate** of #362 (same 2 files, same diff)"},
|
||||
{"pr": "#362", "repo": "timmy-config", "author": "Timmy", "verdict": "✅ Approved", "notes": "Orchestrator v1 — backlog reader, scorer, dispatcher"},
|
||||
{"pr": "#359", "repo": "timmy-config", "author": "Rockachopa", "verdict": "❌ Close", "notes": "**Zombie** — 0 changes, 3 rubber-stamp approvals from Timmy on empty diff"},
|
||||
{"pr": "#225", "repo": "hermes-agent", "author": "Rockachopa", "verdict": "✅ Approved", "notes": "kimi-for-coding → kimi-k2.5 rename, net zero, last hermes-agent review"},
|
||||
{"pr": "#27", "repo": "the-beacon", "author": "Rockachopa", "verdict": "✅ Approved", "notes": "Game content merge, wizard buildings + harmony system"}
|
||||
],
|
||||
"process_issues": [
|
||||
"**Rubber-stamping:** timmy-config #359 has 3 APPROVED reviews from Timmy on a PR with zero changes. The review process must reject empty diffs.",
|
||||
"**Duplicate PRs:** #362/#363 are identical diffs. #580/#377 are the same SOUL.md patch in two repos. Agents are filing the same work twice.",
|
||||
"**Zombie PRs:** #572 and #359 have no actual changes. Either the branch was already merged or commits were never pushed.",
|
||||
"**No reviewers assigned:** 0 of 14 PRs had a reviewer assigned before this pass.",
|
||||
"**Duplicate milestones:** Found duplicates in timmy-config (3 pairs), hermes-agent (1 triple), and the-nexus (1 pair). Creates confusion for milestone tracking."
|
||||
],
|
||||
"recommendations": [
|
||||
"**Close the 4 dead PRs** (#572, #377, #363, #359) immediately to clean the board.",
|
||||
"**Decide SOUL.md canonical home** — timmy-home or timmy-config, not both.",
|
||||
"**Clean duplicate milestones** — 7 duplicate milestones across 3 repos need consolidation.",
|
||||
"**Require reviewer assignment** on PR creation — no PR should sit with 0 reviewers."
|
||||
]
|
||||
}
|
||||
|
||||
# Ensure log directory exists
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
# Generate report
|
||||
report = manager.generate_report(triage_data)
|
||||
|
||||
if args.report_only or not args.close_prs:
|
||||
print(report)
|
||||
|
||||
# Save report to file
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
report_path = os.path.join(LOG_DIR, f"backlog_report_{timestamp}.md")
|
||||
with open(report_path, "w") as f:
|
||||
f.write(report)
|
||||
print(f"\nReport saved to: {report_path}")
|
||||
return
|
||||
|
||||
# Process PR closures
|
||||
if args.close_prs:
|
||||
dry_run = args.dry_run
|
||||
actions = manager.process_close_prs(triage_data, dry_run=dry_run)
|
||||
|
||||
print(f"\nProcessed {len(actions)} PRs:")
|
||||
for action in actions:
|
||||
status = "CLOSED" if action["closed"] else ("DRY RUN" if dry_run else "FAILED")
|
||||
exists = "EXISTS" if action["exists"] else "NOT FOUND"
|
||||
print(f" {action['repo']} #{action['pr_number']}: {status} ({exists})")
|
||||
|
||||
# Save actions log
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
actions_path = os.path.join(LOG_DIR, f"backlog_actions_{timestamp}.json")
|
||||
with open(actions_path, "w") as f:
|
||||
json.dump(actions, f, indent=2)
|
||||
print(f"\nActions log saved to: {actions_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,131 +0,0 @@
|
||||
# NexusBurn Backlog Manager Configuration
|
||||
# Issue #1127: Perplexity Evening Pass — 14 PR Reviews
|
||||
|
||||
backlog:
|
||||
# Repository settings
|
||||
organization: "Timmy_Foundation"
|
||||
|
||||
# Repositories to manage
|
||||
repositories:
|
||||
- name: "the-nexus"
|
||||
priority: "high"
|
||||
auto_close_zombies: true
|
||||
auto_close_duplicates: true
|
||||
|
||||
- name: "timmy-config"
|
||||
priority: "high"
|
||||
auto_close_zombies: true
|
||||
auto_close_duplicates: true
|
||||
|
||||
- name: "timmy-home"
|
||||
priority: "high"
|
||||
auto_close_zombies: true
|
||||
auto_close_duplicates: true
|
||||
|
||||
- name: "hermes-agent"
|
||||
priority: "medium"
|
||||
auto_close_zombies: false # Sidecar policy - winding down
|
||||
auto_close_duplicates: true
|
||||
|
||||
- name: "the-beacon"
|
||||
priority: "low"
|
||||
auto_close_zombies: true
|
||||
auto_close_duplicates: true
|
||||
|
||||
# PR closure rules
|
||||
closure_rules:
|
||||
zombie:
|
||||
description: "PRs with no actual changes (0 additions, 0 deletions)"
|
||||
action: "close"
|
||||
comment_template: |
|
||||
**Closed by NexusBurn Backlog Manager**
|
||||
|
||||
This PR has no actual changes (0 additions, 0 deletions, 0 files changed).
|
||||
This is a "zombie" PR that was either already merged or never had commits pushed.
|
||||
|
||||
See issue #1127 for triage context.
|
||||
|
||||
duplicate:
|
||||
description: "PRs that are exact duplicates of other PRs"
|
||||
action: "close"
|
||||
comment_template: |
|
||||
**Closed by NexusBurn Backlog Manager**
|
||||
|
||||
This PR is an exact duplicate of another PR (same files, same diff).
|
||||
Duplicate PRs create confusion and waste reviewer time.
|
||||
|
||||
See issue #1127 for triage context.
|
||||
|
||||
rubber_stamp:
|
||||
description: "PRs with approval reviews but no actual changes"
|
||||
action: "close"
|
||||
comment_template: |
|
||||
**Closed by NexusBurn Backlog Manager**
|
||||
|
||||
This PR has approval reviews but contains no actual changes.
|
||||
This indicates a rubber-stamping problem in the review process.
|
||||
|
||||
See issue #1127 for triage context.
|
||||
|
||||
# Reporting settings
|
||||
reporting:
|
||||
output_dir: "~/.hermes/backlog-logs"
|
||||
formats:
|
||||
- "markdown"
|
||||
- "json"
|
||||
include_metrics: true
|
||||
include_recommendations: true
|
||||
|
||||
# Process improvements
|
||||
process_improvements:
|
||||
- name: "require_reviewers"
|
||||
description: "All PRs must have at least one reviewer assigned"
|
||||
action: "notify"
|
||||
severity: "warning"
|
||||
|
||||
- name: "reject_empty_diffs"
|
||||
description: "PRs with no changes should be automatically rejected"
|
||||
action: "block"
|
||||
severity: "error"
|
||||
|
||||
- name: "canonical_soul_location"
|
||||
description: "SOUL.md should exist in only one canonical location"
|
||||
action: "notify"
|
||||
severity: "warning"
|
||||
|
||||
# Milestone management
|
||||
milestones:
|
||||
deduplicate: true
|
||||
consolidation_strategy: "keep_newest"
|
||||
repositories:
|
||||
- "timmy-config"
|
||||
- "hermes-agent"
|
||||
- "the-nexus"
|
||||
|
||||
# Automation settings
|
||||
automation:
|
||||
dry_run_default: true
|
||||
require_confirmation: true
|
||||
log_all_actions: true
|
||||
backup_before_close: true
|
||||
backup_dir: "~/.hermes/backlog-backups"
|
||||
|
||||
# Integration points
|
||||
integrations:
|
||||
gitea:
|
||||
enabled: true
|
||||
token_path: "~/.config/gitea/token"
|
||||
|
||||
hermes:
|
||||
enabled: true
|
||||
log_to_hermes: true
|
||||
|
||||
cron:
|
||||
enabled: false # Enable for scheduled runs
|
||||
schedule: "0 18 * * *" # 6 PM daily
|
||||
|
||||
# Alert thresholds
|
||||
alerts:
|
||||
zombie_pr_threshold: 3 # Alert if more than 3 zombie PRs found
|
||||
duplicate_pr_threshold: 2 # Alert if more than 2 duplicate PRs found
|
||||
missing_reviewers_threshold: 5 # Alert if more than 5 PRs missing reviewers
|
||||
@@ -1,177 +0,0 @@
|
||||
# NexusBurn Backlog Manager
|
||||
|
||||
Automated backlog management tool for the Timmy Foundation organization. Processes triage data from issues like #1127 and automates cleanup actions.
|
||||
|
||||
## Overview
|
||||
|
||||
The NexusBurn Backlog Manager is designed to:
|
||||
|
||||
1. **Parse triage data** from issues containing PR reviews and recommendations
|
||||
2. **Identify and close** zombie PRs, duplicate PRs, and rubber-stamped PRs
|
||||
3. **Generate reports** on organization health and process issues
|
||||
4. **Automate cleanup** actions to keep repositories clean and manageable
|
||||
|
||||
## Features
|
||||
|
||||
### Triage Data Processing
|
||||
- Parses structured triage issues (like #1127: Perplexity Evening Pass)
|
||||
- Extracts PR reviews, process issues, and recommendations
|
||||
- Categorizes PRs by verdict (Approved, Close, Comment, Needs Review)
|
||||
|
||||
### Automated Actions
|
||||
- **Close zombie PRs**: PRs with no actual changes (0 additions, 0 deletions)
|
||||
- **Close duplicate PRs**: PRs that are exact duplicates of other PRs
|
||||
- **Address rubber-stamping**: PRs with approval reviews but no actual changes
|
||||
- **Generate cleanup reports** with metrics and recommendations
|
||||
|
||||
### Reporting
|
||||
- Markdown reports with summary statistics
|
||||
- JSON logs for programmatic processing
|
||||
- Metrics on organization health and process issues
|
||||
- Actionable recommendations for process improvements
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Generate report only (no actions)
|
||||
python bin/backlog_manager.py --report-only
|
||||
|
||||
# Dry run (show what would be closed)
|
||||
python bin/backlog_manager.py --close-prs --dry-run
|
||||
|
||||
# Actually close PRs (with confirmation)
|
||||
python bin/backlog_manager.py --close-prs
|
||||
|
||||
# Parse custom triage file
|
||||
python bin/backlog_manager.py --triage-file path/to/triage.md --report-only
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
|
||||
```
|
||||
--triage-file PATH Path to custom triage issue body file
|
||||
--dry-run Don't actually close PRs, just show what would happen
|
||||
--report-only Generate report only, don't process closures
|
||||
--close-prs Process PR closures based on triage verdicts
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The manager uses `config/backlog_config.yaml` for configuration:
|
||||
|
||||
### Key Settings
|
||||
|
||||
```yaml
|
||||
backlog:
|
||||
# Repository settings
|
||||
organization: "Timmy_Foundation"
|
||||
|
||||
# Repositories to manage
|
||||
repositories:
|
||||
- name: "the-nexus"
|
||||
priority: "high"
|
||||
auto_close_zombies: true
|
||||
auto_close_duplicates: true
|
||||
|
||||
# PR closure rules
|
||||
closure_rules:
|
||||
zombie:
|
||||
action: "close"
|
||||
comment_template: "Closed by NexusBurn..."
|
||||
|
||||
# Automation settings
|
||||
automation:
|
||||
dry_run_default: true
|
||||
require_confirmation: true
|
||||
log_all_actions: true
|
||||
```
|
||||
|
||||
## Output Files
|
||||
|
||||
### Reports
|
||||
- **Markdown reports**: `~/.hermes/backlog-logs/backlog_report_YYYYMMDD_HHMMSS.md`
|
||||
- **Action logs**: `~/.hermes/backlog-logs/backlog_actions_YYYYMMDD_HHMMSS.json`
|
||||
|
||||
### Example Report Structure
|
||||
|
||||
```markdown
|
||||
# NexusBurn Backlog Report
|
||||
Generated: 2026-04-13T18:19:00Z
|
||||
Source: Issue #1127 — Perplexity Evening Pass
|
||||
|
||||
## Summary
|
||||
- Total PRs reviewed: 14
|
||||
- Process issues identified: 5
|
||||
- Recommendations: 4
|
||||
|
||||
## PR Review Results
|
||||
| Verdict | Count |
|
||||
|---------|-------|
|
||||
| Approved | 8 |
|
||||
| Close | 4 |
|
||||
| Comment | 1 |
|
||||
| Needs Review | 1 |
|
||||
|
||||
## PRs to Close
|
||||
- **#572** (timmy-home): Zombie — 0 additions, 0 deletions
|
||||
- **#377** (timmy-config): Duplicate of timmy-home #580
|
||||
- **#363** (timmy-config): Exact duplicate of #362
|
||||
- **#359** (timmy-config): Zombie — 0 changes, rubber-stamped
|
||||
```
|
||||
|
||||
## Process Improvements
|
||||
|
||||
Based on issue #1127 analysis, the manager identifies:
|
||||
|
||||
1. **Rubber-stamping**: PRs with approval reviews but no actual changes
|
||||
2. **Duplicate PRs**: Same work filed multiple times across repos
|
||||
3. **Zombie PRs**: PRs with no changes (already merged or never pushed)
|
||||
4. **Missing reviewers**: PRs sitting with 0 assigned reviewers
|
||||
5. **Duplicate milestones**: Confusing milestone tracking across repos
|
||||
|
||||
## Integration
|
||||
|
||||
### With Hermes
|
||||
- Logs all actions to Hermes logging system
|
||||
- Can be triggered from Hermes cron jobs
|
||||
- Integrates with burn mode workflows
|
||||
|
||||
### With Gitea
|
||||
- Uses Gitea API for PR management
|
||||
- Respects branch protection rules
|
||||
- Adds explanatory comments before closing
|
||||
|
||||
### With Cron
|
||||
- Can be scheduled for regular runs (e.g., daily at 6 PM)
|
||||
- Supports dry-run mode for safe automation
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_backlog_manager.py -v
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
bin/backlog_manager.py # Main entry point
|
||||
config/backlog_config.yaml # Configuration
|
||||
tests/test_backlog_manager.py # Unit tests
|
||||
docs/backlog-manager.md # Detailed documentation
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Milestone consolidation**: Automatically deduplicate milestones
|
||||
2. **Reviewer assignment**: Auto-assign reviewers based on CODEOWNERS
|
||||
3. **Duplicate detection**: Advanced diff comparison for finding duplicates
|
||||
4. **Process metrics**: Track improvements over time
|
||||
5. **Slack/Telegram integration**: Notifications for critical issues
|
||||
|
||||
## License
|
||||
|
||||
Part of the Timmy Foundation project. See LICENSE for details.
|
||||
95
playground/README.md
Normal file
95
playground/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Sovereign Sound Playground
|
||||
|
||||
An interactive audio-visual experience that lets you paint with sound and create music visually.
|
||||
|
||||
## Live Version
|
||||
|
||||
**LIVE:** https://playground.alexanderwhitestone.com/playground.html
|
||||
|
||||
## Features
|
||||
|
||||
### Core Functionality
|
||||
- **Visual Piano Keyboard**: 26 keys mapped to keyboard (QWERTY layout)
|
||||
- **6 Visual Modes**:
|
||||
- FREE: Freeform painting with sound
|
||||
- GRAVITY: Notes gravitate toward cursor
|
||||
- RAIN: Musical rain falls from above
|
||||
- CONSTELLATION: Notes connect in constellation patterns
|
||||
- BPM: Grid pulses to the beat
|
||||
- MIRROR: Mirror notes across vertical axis
|
||||
- **5 Color Palettes**:
|
||||
- AURORA: Warm rainbow colors
|
||||
- OCEAN: Cool blues and teals
|
||||
- EMBER: Warm reds and oranges
|
||||
- FOREST: Natural greens
|
||||
- NEON: Vibrant neon colors
|
||||
|
||||
### Audio Features
|
||||
- **Ambient Beat**: Automatic chord progressions with kick, snare, and hi-hat
|
||||
- **Chord Detection**: Real-time chord recognition (major, minor, 7th, etc.)
|
||||
- **Mouse Playback**: Hover over painted notes to hear them again
|
||||
- **Touch Support**: Works on mobile devices
|
||||
|
||||
### Tools
|
||||
- **Recording**: Press R to record your session
|
||||
- **Export**: Press S to save your creation as PNG
|
||||
- **Clear**: Press Backspace to clear the canvas
|
||||
- **Mode Switch**: Press Tab to cycle through modes
|
||||
- **Palette Switch**: Press 1-5 to switch color palettes
|
||||
|
||||
## Controls
|
||||
|
||||
### Keyboard
|
||||
- **A-Z**: Play notes and paint
|
||||
- **Space**: Toggle ambient beat
|
||||
- **Backspace**: Clear canvas
|
||||
- **Tab**: Switch mode
|
||||
- **R**: Toggle recording
|
||||
- **S**: Save as PNG
|
||||
- **1-5**: Switch color palette
|
||||
|
||||
### Mouse
|
||||
- **Click**: Play random note and paint
|
||||
- **Drag**: Continuous painting
|
||||
- **Hover over notes**: Replay sounds
|
||||
|
||||
### Touch
|
||||
- **Touch and drag**: Paint with sound
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Zero dependencies
|
||||
- Pure HTML5 Canvas + Web Audio API
|
||||
- No external libraries
|
||||
- Self-contained single HTML file
|
||||
|
||||
## Integration
|
||||
|
||||
The playground is integrated into The Nexus as a portal:
|
||||
- **Portal ID**: `playground`
|
||||
- **Portal Type**: `creative-tool`
|
||||
- **Status**: Online
|
||||
- **Access**: Visitor mode (no operator privileges needed)
|
||||
|
||||
## Iteration Plan
|
||||
|
||||
Future enhancements:
|
||||
- [ ] More modes (Spiral, Gravity Well, Strobe)
|
||||
- [ ] MIDI keyboard support
|
||||
- [ ] Share session as URL
|
||||
- [ ] Mobile optimization
|
||||
- [ ] Multiplayer via WebSocket
|
||||
- [ ] Integration with Nexus spatial audio system
|
||||
- [ ] Memory system for saved compositions
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
playground/
|
||||
├── playground.html # Main playground application
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
Created as part of the Timmy Foundation's Sovereign Sound initiative.
|
||||
692
playground/playground.html
Normal file
692
playground/playground.html
Normal file
@@ -0,0 +1,692 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Sovereign Sound — Playground</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
body {
|
||||
background: #050510;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: #fff;
|
||||
cursor: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
canvas { display: block; position: fixed; top: 0; left: 0; }
|
||||
.piano {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
height: 80px; display: flex;
|
||||
background: rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
z-index: 10;
|
||||
}
|
||||
.key {
|
||||
flex: 1; border-right: 1px solid rgba(255,255,255,0.05);
|
||||
display: flex; align-items: flex-end; justify-content: center;
|
||||
padding-bottom: 8px; font-size: 9px; opacity: 0.3;
|
||||
transition: all 0.1s; position: relative;
|
||||
}
|
||||
.key.black {
|
||||
background: rgba(0,0,0,0.5);
|
||||
height: 50px; margin: 0 -8px; width: 60%; z-index: 1;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.key.active {
|
||||
background: rgba(255,255,255,0.15);
|
||||
opacity: 0.8;
|
||||
transform: scaleY(0.98);
|
||||
transform-origin: bottom;
|
||||
}
|
||||
.hud {
|
||||
position: fixed; top: 16px; left: 16px;
|
||||
font-size: 9px; letter-spacing: 3px;
|
||||
text-transform: uppercase; opacity: 0.2;
|
||||
line-height: 2.2; z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mode-switch {
|
||||
position: fixed; top: 16px; right: 16px;
|
||||
display: flex; gap: 4px; z-index: 10;
|
||||
}
|
||||
.mode-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.15);
|
||||
cursor: pointer; transition: all 0.3s;
|
||||
pointer-events: all;
|
||||
}
|
||||
.mode-dot.active { background: rgba(255,255,255,0.6); transform: scale(1.4); }
|
||||
.toast {
|
||||
position: fixed; top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 10px; letter-spacing: 6px;
|
||||
text-transform: uppercase; opacity: 0;
|
||||
transition: opacity 0.4s; pointer-events: none; z-index: 20;
|
||||
}
|
||||
.toast.show { opacity: 0.4; }
|
||||
.rec-dot {
|
||||
position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #ff0040; opacity: 0;
|
||||
transition: opacity 0.3s; z-index: 10;
|
||||
}
|
||||
.rec-dot.on { opacity: 1; animation: pulse 1s infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<canvas id="c"></canvas>
|
||||
|
||||
<div class="hud" id="hud">
|
||||
<div id="h-mode">FREE</div>
|
||||
<div id="h-pal">AURORA</div>
|
||||
<div id="h-notes">0 notes</div>
|
||||
<div id="h-chord">—</div>
|
||||
</div>
|
||||
|
||||
<div class="mode-switch" id="modes"></div>
|
||||
<div class="rec-dot" id="rec"></div>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<div class="piano" id="piano"></div>
|
||||
|
||||
<script>
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SOVEREIGN SOUND — PLAYGROUND v3
|
||||
// The ultimate interactive audio-visual experience.
|
||||
// Zero dependencies. Pure craft.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const canvas = document.getElementById('c');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let W, H;
|
||||
|
||||
function resize() {
|
||||
W = canvas.width = innerWidth;
|
||||
H = canvas.height = innerHeight;
|
||||
ctx.fillStyle = '#050510';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
}
|
||||
addEventListener('resize', resize); resize();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AUDIO ENGINE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
let ac = null, master = null, analyser = null;
|
||||
|
||||
function initAudio() {
|
||||
if (ac) return;
|
||||
ac = new AudioContext();
|
||||
master = ac.createGain(); master.gain.value = 0.4;
|
||||
|
||||
const wet = ac.createGain(); wet.gain.value = 0.2;
|
||||
[0.037, 0.059, 0.083, 0.127].forEach(t => {
|
||||
const d = ac.createDelay(1); d.delayTime.value = t;
|
||||
const fb = ac.createGain(); fb.gain.value = 0.22;
|
||||
master.connect(d); d.connect(fb); fb.connect(d); d.connect(wet);
|
||||
});
|
||||
wet.connect(ac.destination);
|
||||
|
||||
analyser = ac.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = 0.8;
|
||||
master.connect(analyser);
|
||||
master.connect(ac.destination);
|
||||
}
|
||||
|
||||
function freq(name) {
|
||||
const n = { C:0,'C#':1,D:2,'D#':3,E:4,F:5,'F#':6,G:7,'G#':8,A:9,'A#':10,B:11 };
|
||||
const nm = name.replace(/\d/,'');
|
||||
const oct = parseInt(name.match(/\d/)?.[0] || 4);
|
||||
return 440 * Math.pow(2, (n[nm] + (oct-4)*12 - 9) / 12);
|
||||
}
|
||||
|
||||
function tone(f, type='sine', dur=0.5, vol=0.1) {
|
||||
initAudio();
|
||||
const t = ac.currentTime;
|
||||
const o = ac.createOscillator();
|
||||
const g = ac.createGain();
|
||||
o.type = type; o.frequency.value = f;
|
||||
g.gain.setValueAtTime(0, t);
|
||||
g.gain.linearRampToValueAtTime(vol, t + 0.01);
|
||||
g.gain.exponentialRampToValueAtTime(vol*0.3, t+dur*0.4);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, t+dur);
|
||||
o.connect(g); g.connect(master);
|
||||
o.start(t); o.stop(t+dur);
|
||||
}
|
||||
|
||||
function kick() { initAudio(); const t=ac.currentTime; const o=ac.createOscillator(), g=ac.createGain(); o.type='sine'; o.frequency.setValueAtTime(80,t); o.frequency.exponentialRampToValueAtTime(30,t+0.12); g.gain.setValueAtTime(0.4,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.15); o.connect(g); g.connect(master); o.start(t); o.stop(t+0.15); }
|
||||
function snare() { initAudio(); const t=ac.currentTime; const len=ac.sampleRate*0.06; const buf=ac.createBuffer(1,len,ac.sampleRate); const d=buf.getChannelData(0); for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*0.25; const s=ac.createBufferSource(); s.buffer=buf; const g=ac.createGain(); g.gain.setValueAtTime(0.2,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.08); s.connect(g); g.connect(master); s.start(t); }
|
||||
function hat() { initAudio(); const t=ac.currentTime; const len=ac.sampleRate*0.025; const buf=ac.createBuffer(1,len,ac.sampleRate); const d=buf.getChannelData(0); for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*0.12; const s=ac.createBufferSource(); s.buffer=buf; const g=ac.createGain(); g.gain.setValueAtTime(0.1,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.025); s.connect(g); g.connect(master); s.start(t); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SCALES & PALETTES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const SCALES = {
|
||||
AURORA: { colors:['#ff6b6b','#ff9f43','#feca57','#48dbfb','#54a0ff','#5f27cd','#ff9ff3','#00d2d3'], notes:['C5','D5','E5','F5','G5','A5','B5','C6','D6','E6','C4','D4','E4','F4','G4','A4','B4','C5','D5','E5','F5','C2','D2','E2','F2','G2'], bg:[6,6,16], glow:'#ff9ff3' },
|
||||
OCEAN: { colors:['#0077b6','#00b4d8','#90e0ef','#48cae4','#023e8a','#ade8f4'], notes:['D5','E5','F#5','G5','A5','B5','C#6','D6','E6','D4','E4','F#4','G4','A4','B4','C#5','D5','E5','D3','E3','F#3','D2','E2','F#2','G2','A2'], bg:[4,12,22], glow:'#48cae4' },
|
||||
EMBER: { colors:['#ff4500','#ff6347','#ff7f50','#dc143c','#cd5c5c','#f08080'], notes:['C5','Eb5','F5','G5','Ab5','Bb5','C6','D5','Eb5','C4','Eb4','F4','G4','Ab4','Bb4','C5','D5','Eb5','C3','Eb3','F3','C2','Eb2','F2','G2','Ab2'], bg:[14,5,5], glow:'#ff6347' },
|
||||
FOREST: { colors:['#2d6a4f','#40916c','#52b788','#74c69d','#95d5b2','#b7e4c7'], notes:['E5','F#5','G5','A5','B5','C6','D6','E6','F#6','E4','F#4','G4','A4','B4','C5','D5','E5','F#5','E3','F#3','G3','E2','F#2','G2','A2','B2'], bg:[4,12,6], glow:'#52b788' },
|
||||
NEON: { colors:['#ff00ff','#00ffff','#ffff00','#ff0080','#00ff80','#8000ff'], notes:['C5','D5','E5','G5','A5','C6','D6','E6','G6','C4','D4','E4','G4','A4','C5','D5','E5','G5','C3','D3','E3','C2','D2','E2','G2','A2'], bg:[8,2,16], glow:'#00ffff' },
|
||||
};
|
||||
|
||||
let palName = 'AURORA';
|
||||
let pal = SCALES[palName];
|
||||
const PAL_NAMES = Object.keys(SCALES);
|
||||
let palIdx = 0;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MODES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const MODES = ['FREE','GRAVITY','RAIN','CONSTELLATION','BPM','MIRROR'];
|
||||
let modeIdx = 0, mode = MODES[0];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// STATE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
let notes = []; // permanent painted notes
|
||||
let particles = []; // transient particles
|
||||
let ripples = []; // ripple effects
|
||||
let raindrops = [];
|
||||
let mouseX = W/2, mouseY = H/2;
|
||||
let mouseDown = false;
|
||||
let time = 0;
|
||||
let ambientOn = false;
|
||||
let ambientStep = 0;
|
||||
let ambientTimer = null;
|
||||
let screenShake = 0;
|
||||
let lastPaintTime = 0;
|
||||
let recentNotes = [];
|
||||
let recording = false;
|
||||
let recordedNotes = [];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PIANO KEYBOARD — visual at bottom
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const KEYS = 'qwertyuiopasdfghjklzxcvbnm';
|
||||
const IS_BLACK = [false,true,false,true,false,false,true,false,true,false,true,false,
|
||||
false,true,false,true,false,false,true,false,true,false,true,false,false,false];
|
||||
|
||||
function buildPiano() {
|
||||
const piano = document.getElementById('piano');
|
||||
piano.innerHTML = '';
|
||||
KEYS.split('').forEach((k, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'key' + (IS_BLACK[i] ? ' black' : '');
|
||||
div.dataset.key = k;
|
||||
div.textContent = k.toUpperCase();
|
||||
div.addEventListener('mousedown', () => triggerKey(k));
|
||||
div.addEventListener('touchstart', (e) => { e.preventDefault(); triggerKey(k); });
|
||||
piano.appendChild(div);
|
||||
});
|
||||
}
|
||||
buildPiano();
|
||||
|
||||
// Mode/palette dots
|
||||
const modesDiv = document.getElementById('modes');
|
||||
MODES.forEach((m, i) => {
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'mode-dot' + (i===0?' active':'');
|
||||
dot.onclick = () => { modeIdx=i; mode=MODES[i]; updateDots(); toast(m); };
|
||||
modesDiv.appendChild(dot);
|
||||
});
|
||||
PAL_NAMES.forEach((p, i) => {
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'mode-dot';
|
||||
dot.style.background = SCALES[p].glow;
|
||||
dot.style.opacity = '0.2';
|
||||
if (i===0) { dot.classList.add('active'); dot.style.opacity='0.6'; }
|
||||
dot.onclick = () => { palIdx=i; palName=p; pal=SCALES[p]; updateDots(); toast(p); };
|
||||
modesDiv.appendChild(dot);
|
||||
});
|
||||
|
||||
function updateDots() {
|
||||
modesDiv.querySelectorAll('.mode-dot').forEach((d, i) => {
|
||||
if (i < MODES.length) {
|
||||
d.classList.toggle('active', i===modeIdx);
|
||||
} else {
|
||||
const pi = i - MODES.length;
|
||||
d.classList.toggle('active', pi===palIdx);
|
||||
d.style.opacity = pi===palIdx ? '0.6' : '0.2';
|
||||
}
|
||||
});
|
||||
document.getElementById('h-mode').textContent = mode;
|
||||
document.getElementById('h-pal').textContent = palName;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PAINT & PLAY
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function paint(x, y, color, noteFreq, noteType, size=25) {
|
||||
// Permanent splash
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.06;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath(); ctx.arc(x, y, size*2, 0, Math.PI*2); ctx.fill();
|
||||
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.beginPath();
|
||||
const pts = 6+Math.floor(Math.random()*6);
|
||||
for (let i=0; i<=pts; i++) {
|
||||
const a = (i/pts)*Math.PI*2;
|
||||
const r = size*(0.5+Math.random()*0.5);
|
||||
i===0 ? ctx.moveTo(x+Math.cos(a)*r, y+Math.sin(a)*r) : ctx.lineTo(x+Math.cos(a)*r, y+Math.sin(a)*r);
|
||||
}
|
||||
ctx.closePath(); ctx.fill();
|
||||
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.beginPath(); ctx.arc(x, y, size*0.12, 0, Math.PI*2); ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
notes.push({ x, y, radius: size, color, freq: noteFreq, type: noteType });
|
||||
if (notes.length > 4000) notes.splice(0, 500);
|
||||
|
||||
// Particles
|
||||
for (let i=0; i<12; i++) {
|
||||
const a = Math.random()*Math.PI*2;
|
||||
const s = 1+Math.random()*4;
|
||||
particles.push({ x, y, vx:Math.cos(a)*s, vy:Math.sin(a)*s, size:1+Math.random()*3, life:1, color });
|
||||
}
|
||||
if (particles.length > 400) particles.splice(0, 100);
|
||||
|
||||
ripples.push({ x, y, color, size: size*0.3, maxSize: size*3, life:1 });
|
||||
if (ripples.length > 25) ripples.shift();
|
||||
|
||||
if (noteType === 'sawtooth' && noteFreq < 200) screenShake = 6;
|
||||
}
|
||||
|
||||
function triggerKey(key) {
|
||||
const i = KEYS.indexOf(key);
|
||||
if (i < 0) return;
|
||||
|
||||
const noteName = pal.notes[i % pal.notes.length];
|
||||
const noteFreq = freq(noteName);
|
||||
const isBass = i >= 21;
|
||||
const noteType = isBass ? 'sawtooth' : (i%3===0 ? 'triangle' : 'sine');
|
||||
|
||||
tone(noteFreq, noteType, isBass ? 0.3 : 0.6, isBass ? 0.18 : 0.12);
|
||||
|
||||
const x = mouseX + (Math.random()-0.5)*50;
|
||||
const y = mouseY + (Math.random()-0.5)*50;
|
||||
paint(x, y, pal.colors[i % pal.colors.length], noteFreq, noteType, isBass ? 35+Math.random()*15 : 20+Math.random()*15);
|
||||
|
||||
// Piano visual
|
||||
const pianoKey = document.querySelector(`.key[data-key="${key}"]`);
|
||||
if (pianoKey) {
|
||||
pianoKey.classList.add('active');
|
||||
pianoKey.style.background = pal.colors[i % pal.colors.length] + '30';
|
||||
setTimeout(() => { pianoKey.classList.remove('active'); pianoKey.style.background = ''; }, 200);
|
||||
}
|
||||
|
||||
// Track for chord detection
|
||||
recentNotes.push({ freq: noteFreq, time: Date.now() });
|
||||
if (recentNotes.length > 10) recentNotes.shift();
|
||||
detectChord();
|
||||
|
||||
// Recording
|
||||
if (recording) recordedNotes.push({ key, time: Date.now(), x, y });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CHORD DETECTION
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function detectChord() {
|
||||
const now = Date.now();
|
||||
const recent = recentNotes.filter(n => now-n.time < 1500);
|
||||
if (recent.length < 2) { document.getElementById('h-chord').textContent = '—'; return; }
|
||||
|
||||
const freqs = recent.map(n => n.freq).sort((a,b) => a-b);
|
||||
const ratios = [];
|
||||
for (let i=1; i<freqs.length; i++) ratios.push(Math.round(1200*Math.log2(freqs[i]/freqs[0])));
|
||||
|
||||
const patterns = { 'major':[0,400,700],'minor':[0,300,700],'7':[0,400,700,1000],'maj7':[0,400,700,1100],'min7':[0,300,700,1000],'power':[0,700],'sus4':[0,500,700],'sus2':[0,200,700],'dim':[0,300,600],'aug':[0,400,800] };
|
||||
|
||||
let best = '—', bestScore = 0;
|
||||
for (const [name, pat] of Object.entries(patterns)) {
|
||||
let score = 0;
|
||||
for (const p of pat) if (ratios.some(r => Math.abs(r-p) < 60)) score++;
|
||||
score /= pat.length;
|
||||
if (score > bestScore && score > 0.5) { bestScore = score; best = name; }
|
||||
}
|
||||
document.getElementById('h-chord').textContent = best;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MOUSE PLAYBACK — play notes by hovering
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
let lastPlayed = null, lastPlayT = 0;
|
||||
function checkPlay(x, y) {
|
||||
const now = Date.now();
|
||||
if (now-lastPlayT < 50) return;
|
||||
let closest = null, closestD = Infinity;
|
||||
for (const n of notes) {
|
||||
const d = Math.hypot(x-n.x, y-n.y);
|
||||
if (d < n.radius*1.4 && d < closestD) { closest = n; closestD = d; }
|
||||
}
|
||||
if (closest && closest !== lastPlayed) {
|
||||
const vol = 0.05 + (1-closestD/closest.radius)*0.1;
|
||||
tone(closest.freq, closest.type, 0.2, vol);
|
||||
ripples.push({ x:closest.x, y:closest.y, color:closest.color, size:closest.radius*0.2, maxSize:closest.radius*1.5, life:1 });
|
||||
for (let i=0; i<3; i++) {
|
||||
const a = Math.random()*Math.PI*2;
|
||||
particles.push({ x:closest.x, y:closest.y, vx:Math.cos(a)*1.5, vy:Math.sin(a)*1.5, size:1.5, life:1, color:closest.color });
|
||||
}
|
||||
lastPlayed = closest;
|
||||
lastPlayT = now;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AMBIENT BEAT
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function ambientTick() {
|
||||
if (!ambientOn) return;
|
||||
const bpm = [72,60,80,66,128,90][palIdx];
|
||||
const stepDur = 60000/bpm/4;
|
||||
const beat = ambientStep % 16;
|
||||
|
||||
if (beat%4===0) { kick(); screenShake=2; }
|
||||
if (beat===4||beat===12) snare();
|
||||
if (beat%2===1) hat();
|
||||
|
||||
if (beat===0) {
|
||||
const chords = [
|
||||
[freq('C4'),freq('E4'),freq('G4')],
|
||||
[freq('A3'),freq('C4'),freq('E4')],
|
||||
[freq('F3'),freq('A3'),freq('C4')],
|
||||
[freq('G3'),freq('B3'),freq('D4')]
|
||||
];
|
||||
chords[Math.floor(ambientStep/16)%4].forEach(f => tone(f,'triangle',0.7,0.05));
|
||||
}
|
||||
|
||||
if (beat%2===0) {
|
||||
const i = Math.floor(Math.random()*KEYS.length);
|
||||
const k = KEYS[i];
|
||||
const noteName = pal.notes[i % pal.notes.length];
|
||||
paint(W/2+(Math.random()-0.5)*400, H/2+(Math.random()-0.5)*300,
|
||||
pal.colors[i%pal.colors.length], freq(noteName), i>=21?'sawtooth':'sine', 10+Math.random()*8);
|
||||
}
|
||||
|
||||
ambientStep++;
|
||||
ambientTimer = setTimeout(ambientTick, stepDur);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// INPUT
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function toast(msg) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg; el.classList.add('show');
|
||||
setTimeout(() => el.classList.remove('show'), 1200);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
const k = e.key.toLowerCase();
|
||||
|
||||
if (k===' ') { e.preventDefault(); ambientOn=!ambientOn; ambientOn?(ambientStep=0,ambientTick(),toast('AMBIENT ON')):(clearTimeout(ambientTimer),toast('AMBIENT OFF')); return; }
|
||||
if (k==='backspace') { e.preventDefault(); ctx.fillStyle='#050510'; ctx.fillRect(0,0,W,H); notes=[]; ripples=[]; particles=[]; raindrops=[]; toast('CLEARED'); return; }
|
||||
if (k==='tab') { e.preventDefault(); modeIdx=(modeIdx+1)%MODES.length; mode=MODES[modeIdx]; updateDots(); toast(mode); return; }
|
||||
if (k==='r') { recording=!recording; document.getElementById('rec').classList.toggle('on',recording); toast(recording?'REC ON':'REC OFF'); if(!recording&&recordedNotes.length) replayRecording(); return; }
|
||||
if (k==='s') { e.preventDefault(); saveCanvas(); return; }
|
||||
if (k>='1' && k<='5') { palIdx=parseInt(k)-1; palName=PAL_NAMES[palIdx]; pal=SCALES[palName]; updateDots(); toast(palName); return; }
|
||||
|
||||
triggerKey(k);
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', e => {
|
||||
mouseX = e.clientX; mouseY = e.clientY;
|
||||
checkPlay(mouseX, mouseY);
|
||||
if (mouseDown && Date.now()-lastPaintTime > 40) {
|
||||
const i = Math.floor(Math.random()*KEYS.length);
|
||||
triggerKey(KEYS[i]);
|
||||
lastPaintTime = Date.now();
|
||||
}
|
||||
if (Math.random()>0.65) {
|
||||
particles.push({ x:mouseX, y:mouseY, vx:(Math.random()-0.5)*0.5, vy:(Math.random()-0.5)*0.5, size:1+Math.random()*1.5, life:1, color:'rgba(255,255,255,0.3)' });
|
||||
if (particles.length>400) particles.splice(0,80);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousedown', e => { mouseDown=true; triggerKey(KEYS[Math.floor(Math.random()*KEYS.length)]); });
|
||||
canvas.addEventListener('mouseup', () => mouseDown=false);
|
||||
|
||||
// Touch
|
||||
canvas.addEventListener('touchmove', e => {
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
mouseX = t.clientX; mouseY = t.clientY;
|
||||
checkPlay(mouseX, mouseY);
|
||||
if (Date.now()-lastPaintTime > 60) {
|
||||
triggerKey(KEYS[Math.floor(Math.random()*KEYS.length)]);
|
||||
lastPaintTime = Date.now();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MODE EFFECTS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function applyGravity() {
|
||||
for (const n of notes) {
|
||||
const dx = mouseX-n.x, dy = mouseY-n.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d>10 && d<300) { n.x += dx*0.2/d; n.y += dy*0.2/d; }
|
||||
}
|
||||
}
|
||||
|
||||
function spawnRain() {
|
||||
if (Math.random()>0.2) return;
|
||||
const i = Math.floor(Math.random()*KEYS.length);
|
||||
raindrops.push({ x:Math.random()*W, y:-20, vy:1.5+Math.random()*3, color:pal.colors[i%pal.colors.length], freq:freq(pal.notes[i%pal.notes.length]), type:i>=21?'sawtooth':'sine', size:8+Math.random()*12, played:false });
|
||||
if (raindrops.length>40) raindrops.shift();
|
||||
}
|
||||
|
||||
function updateRain() {
|
||||
for (let i=raindrops.length-1; i>=0; i--) {
|
||||
const r = raindrops[i]; r.y += r.vy;
|
||||
if (!r.played) for (const n of notes) {
|
||||
if (Math.hypot(r.x-n.x, r.y-n.y) < n.radius) {
|
||||
tone(r.freq, r.type, 0.3, 0.06);
|
||||
ripples.push({ x:r.x, y:r.y, color:r.color, size:5, maxSize:25, life:1 });
|
||||
r.played = true; break;
|
||||
}
|
||||
}
|
||||
if (r.y > H) {
|
||||
if (!r.played) { paint(r.x, H-20, r.color, r.freq, r.type, r.size); tone(r.freq, r.type, 0.3, 0.05); }
|
||||
raindrops.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawConstellation() {
|
||||
ctx.save();
|
||||
for (let i=0; i<notes.length; i++) {
|
||||
for (let j=i+1; j<notes.length; j++) {
|
||||
const d = Math.hypot(notes[i].x-notes[j].x, notes[i].y-notes[j].y);
|
||||
if (d < 180) {
|
||||
ctx.globalAlpha = (1-d/180)*0.12;
|
||||
ctx.strokeStyle = notes[i].color;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(notes[i].x, notes[i].y);
|
||||
ctx.lineTo(notes[j].x, notes[j].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawBPMGrid() {
|
||||
const bpm = 120;
|
||||
const beat = (time % (60/bpm)) / (60/bpm);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = pal.colors[0];
|
||||
ctx.lineWidth = 0.5 + beat;
|
||||
ctx.globalAlpha = 0.02 + beat*0.03;
|
||||
for (let x=0; x<W; x+=80) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
|
||||
for (let y=0; y<H; y+=80) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawMirror() {
|
||||
// Mirror notes across vertical axis
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.08;
|
||||
for (const n of notes) {
|
||||
ctx.fillStyle = n.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(W-n.x, n.y, n.radius*0.6, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// RECORDING & EXPORT
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function replayRecording() {
|
||||
if (!recordedNotes.length) return;
|
||||
toast(`REPLAY ${recordedNotes.length} notes`);
|
||||
const start = recordedNotes[0].time;
|
||||
recordedNotes.forEach(n => {
|
||||
setTimeout(() => triggerKey(n.key), n.time - start);
|
||||
});
|
||||
recordedNotes = [];
|
||||
}
|
||||
|
||||
function saveCanvas() {
|
||||
const link = document.createElement('a');
|
||||
link.download = `sovereign-${Date.now()}.png`;
|
||||
link.href = canvas.toDataURL();
|
||||
link.click();
|
||||
toast('SAVED');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// RENDER LOOP
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function render() {
|
||||
time += 0.016;
|
||||
|
||||
if (screenShake > 0) { ctx.save(); ctx.translate((Math.random()-0.5)*screenShake,(Math.random()-0.5)*screenShake); screenShake*=0.85; if(screenShake<0.5)screenShake=0; }
|
||||
|
||||
// Mode effects
|
||||
if (mode==='GRAVITY') applyGravity();
|
||||
if (mode==='RAIN') { spawnRain(); updateRain(); }
|
||||
if (mode==='CONSTELLATION') drawConstellation();
|
||||
if (mode==='BPM') drawBPMGrid();
|
||||
if (mode==='MIRROR') drawMirror();
|
||||
|
||||
// Ripples
|
||||
for (let i=ripples.length-1; i>=0; i--) {
|
||||
const r = ripples[i];
|
||||
r.size += (r.maxSize-r.size)*0.07;
|
||||
r.life -= 0.02;
|
||||
if (r.life<=0) { ripples.splice(i,1); continue; }
|
||||
ctx.globalAlpha = r.life*0.3;
|
||||
ctx.strokeStyle = r.color;
|
||||
ctx.lineWidth = 1.5*r.life;
|
||||
ctx.beginPath(); ctx.arc(r.x,r.y,r.size,0,Math.PI*2); ctx.stroke();
|
||||
}
|
||||
|
||||
// Rain
|
||||
for (const r of raindrops) {
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.fillStyle = r.color;
|
||||
ctx.beginPath(); ctx.arc(r.x,r.y,r.size*0.2,0,Math.PI*2); ctx.fill();
|
||||
}
|
||||
|
||||
// Particles
|
||||
for (let i=particles.length-1; i>=0; i--) {
|
||||
const p = particles[i];
|
||||
p.x+=p.vx; p.y+=p.vy; p.vx*=0.96; p.vy*=0.96; p.life-=0.014;
|
||||
if (p.life<=0) { particles.splice(i,1); continue; }
|
||||
ctx.globalAlpha = p.life*0.5;
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.beginPath(); ctx.arc(p.x,p.y,p.size*p.life,0,Math.PI*2); ctx.fill();
|
||||
}
|
||||
|
||||
// Audio-reactive
|
||||
if (analyser) {
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
analyser.getByteFrequencyData(data);
|
||||
let energy = 0;
|
||||
for (let i=0; i<data.length; i++) energy += data[i];
|
||||
energy /= data.length*255;
|
||||
|
||||
if (energy > 0.08) {
|
||||
const grad = ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,200+energy*200);
|
||||
grad.addColorStop(0, pal.glow+'08');
|
||||
grad.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.globalAlpha = 0.3+energy*0.3;
|
||||
ctx.fillRect(0,0,W,H);
|
||||
}
|
||||
|
||||
// Edge frequency bars
|
||||
ctx.globalAlpha = 0.03;
|
||||
for (let i=0; i<data.length; i++) {
|
||||
const v = data[i]/255;
|
||||
if (v<0.08) continue;
|
||||
ctx.fillStyle = pal.colors[i%pal.colors.length];
|
||||
ctx.fillRect((i/data.length)*W, H-v*40-80, 2, v*40); // above piano
|
||||
}
|
||||
}
|
||||
|
||||
if (screenShake > 0) ctx.restore();
|
||||
|
||||
// Cursor
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(mouseX-8,mouseY); ctx.lineTo(mouseX-3,mouseY);
|
||||
ctx.moveTo(mouseX+3,mouseY); ctx.lineTo(mouseX+8,mouseY);
|
||||
ctx.moveTo(mouseX,mouseY-8); ctx.lineTo(mouseX,mouseY-3);
|
||||
ctx.moveTo(mouseX,mouseY+3); ctx.lineTo(mouseX,mouseY+8);
|
||||
ctx.stroke();
|
||||
|
||||
// Color ring when hovering note
|
||||
for (const n of notes) {
|
||||
if (Math.hypot(mouseX-n.x, mouseY-n.y) < n.radius*1.4) {
|
||||
ctx.strokeStyle = n.color;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.beginPath(); ctx.arc(mouseX, mouseY, 12, 0, Math.PI*2); ctx.stroke();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.beginPath(); ctx.arc(mouseX,mouseY,1.5,0,Math.PI*2); ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// HUD
|
||||
document.getElementById('h-notes').textContent = `${notes.length} notes`;
|
||||
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
119
portals.json
119
portals.json
@@ -6,24 +6,6 @@
|
||||
"status": "online",
|
||||
"color": "#ff6600",
|
||||
"role": "pilot",
|
||||
"position": { "x": 15, "y": 0, "z": -10 },
|
||||
"rotation": { "y": -0.5 },
|
||||
"portal_type": "game-world",
|
||||
"world_category": "rpg",
|
||||
"environment": "local",
|
||||
"access_mode": "operator",
|
||||
"readiness_state": "prototype",
|
||||
"readiness_steps": {
|
||||
"prototype": { "label": "Prototype", "done": true },
|
||||
"runtime_ready": { "label": "Runtime Ready", "done": false },
|
||||
"launched": { "label": "Launched", "done": false },
|
||||
"harness_bridged": { "label": "Harness Bridged", "done": false }
|
||||
},
|
||||
"blocked_reason": null,
|
||||
"telemetry_source": "hermes-harness:morrowind",
|
||||
"owner": "Timmy",
|
||||
"app_id": 22320,
|
||||
"window_title": "OpenMW",
|
||||
"position": {
|
||||
"x": 15,
|
||||
"y": 0,
|
||||
@@ -32,12 +14,38 @@
|
||||
"rotation": {
|
||||
"y": -0.5
|
||||
},
|
||||
"portal_type": "game-world",
|
||||
"world_category": "rpg",
|
||||
"environment": "local",
|
||||
"access_mode": "operator",
|
||||
"readiness_state": "prototype",
|
||||
"readiness_steps": {
|
||||
"prototype": {
|
||||
"label": "Prototype",
|
||||
"done": true
|
||||
},
|
||||
"runtime_ready": {
|
||||
"label": "Runtime Ready",
|
||||
"done": false
|
||||
},
|
||||
"launched": {
|
||||
"label": "Launched",
|
||||
"done": false
|
||||
},
|
||||
"harness_bridged": {
|
||||
"label": "Harness Bridged",
|
||||
"done": false
|
||||
}
|
||||
},
|
||||
"blocked_reason": null,
|
||||
"telemetry_source": "hermes-harness:morrowind",
|
||||
"owner": "Timmy",
|
||||
"app_id": 22320,
|
||||
"window_title": "OpenMW",
|
||||
"destination": {
|
||||
"url": null,
|
||||
"type": "harness",
|
||||
"action_label": "Enter Vvardenfell",
|
||||
"params": { "world": "vvardenfell" }
|
||||
}
|
||||
"params": {
|
||||
"world": "vvardenfell"
|
||||
}
|
||||
@@ -54,8 +62,6 @@
|
||||
"status": "downloaded",
|
||||
"color": "#ffd700",
|
||||
"role": "pilot",
|
||||
"position": { "x": -15, "y": 0, "z": -10 },
|
||||
"rotation": { "y": 0.5 },
|
||||
"position": {
|
||||
"x": -15,
|
||||
"y": 0,
|
||||
@@ -110,8 +116,6 @@
|
||||
"status": "online",
|
||||
"color": "#4af0c0",
|
||||
"role": "timmy",
|
||||
"position": { "x": 0, "y": 0, "z": -20 },
|
||||
"rotation": { "y": 0 },
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
@@ -140,8 +144,6 @@
|
||||
"status": "online",
|
||||
"color": "#0066ff",
|
||||
"role": "timmy",
|
||||
"position": { "x": 25, "y": 0, "z": 0 },
|
||||
"rotation": { "y": -1.57 },
|
||||
"position": {
|
||||
"x": 25,
|
||||
"y": 0,
|
||||
@@ -169,8 +171,6 @@
|
||||
"status": "online",
|
||||
"color": "#ffd700",
|
||||
"role": "timmy",
|
||||
"position": { "x": -25, "y": 0, "z": 0 },
|
||||
"rotation": { "y": 1.57 },
|
||||
"position": {
|
||||
"x": -25,
|
||||
"y": 0,
|
||||
@@ -196,8 +196,6 @@
|
||||
"status": "online",
|
||||
"color": "#4af0c0",
|
||||
"role": "reflex",
|
||||
"position": { "x": 15, "y": 0, "z": 10 },
|
||||
"rotation": { "y": -2.5 },
|
||||
"position": {
|
||||
"x": 15,
|
||||
"y": 0,
|
||||
@@ -226,8 +224,6 @@
|
||||
"status": "standby",
|
||||
"color": "#ff4466",
|
||||
"role": "reflex",
|
||||
"position": { "x": -15, "y": 0, "z": 10 },
|
||||
"rotation": { "y": 2.5 },
|
||||
"position": {
|
||||
"x": -15,
|
||||
"y": 0,
|
||||
@@ -245,5 +241,64 @@
|
||||
},
|
||||
"agents_present": [],
|
||||
"interaction_ready": false
|
||||
},
|
||||
{
|
||||
"id": "playground",
|
||||
"name": "Sound Playground",
|
||||
"description": "Interactive audio-visual experience. Paint with sound, create music visually.",
|
||||
"status": "online",
|
||||
"color": "#ff00ff",
|
||||
"role": "creative",
|
||||
"position": {
|
||||
"x": 10,
|
||||
"y": 0,
|
||||
"z": 15
|
||||
},
|
||||
"rotation": {
|
||||
"y": -0.7
|
||||
},
|
||||
"portal_type": "creative-tool",
|
||||
"world_category": "audio-visual",
|
||||
"environment": "production",
|
||||
"access_mode": "visitor",
|
||||
"readiness_state": "online",
|
||||
"readiness_steps": {
|
||||
"prototype": {
|
||||
"label": "Prototype",
|
||||
"done": true
|
||||
},
|
||||
"runtime_ready": {
|
||||
"label": "Runtime Ready",
|
||||
"done": true
|
||||
},
|
||||
"launched": {
|
||||
"label": "Launched",
|
||||
"done": true
|
||||
},
|
||||
"harness_bridged": {
|
||||
"label": "Harness Bridged",
|
||||
"done": true
|
||||
}
|
||||
},
|
||||
"blocked_reason": null,
|
||||
"telemetry_source": "playground",
|
||||
"owner": "Timmy",
|
||||
"destination": {
|
||||
"url": "./playground/playground.html",
|
||||
"type": "local",
|
||||
"action_label": "Enter Playground",
|
||||
"params": {}
|
||||
},
|
||||
"agents_present": [],
|
||||
"interaction_ready": true,
|
||||
"features": [
|
||||
"Visual piano keyboard",
|
||||
"6 modes (Free, Gravity, Rain, Constellation, BPM, Mirror)",
|
||||
"5 color palettes (Aurora, Ocean, Ember, Forest, Neon)",
|
||||
"Ambient beat with chord progressions",
|
||||
"Mouse playback and chord detection",
|
||||
"Touch support",
|
||||
"Export as PNG"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,176 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for NexusBurn Backlog Manager
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from bin.backlog_manager import BacklogManager
|
||||
|
||||
|
||||
class TestBacklogManager(unittest.TestCase):
|
||||
"""Test cases for BacklogManager."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.token_path = os.path.join(self.temp_dir, "token")
|
||||
|
||||
# Create test token
|
||||
with open(self.token_path, "w") as f:
|
||||
f.write("test_token_123")
|
||||
|
||||
# Patch the TOKEN_PATH constant
|
||||
self.patcher = patch('bin.backlog_manager.TOKEN_PATH', self.token_path)
|
||||
self.patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up after tests."""
|
||||
self.patcher.stop()
|
||||
import shutil
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_load_token(self):
|
||||
"""Test token loading."""
|
||||
manager = BacklogManager()
|
||||
self.assertEqual(manager.token, "test_token_123")
|
||||
|
||||
def test_parse_triage_issue(self):
|
||||
"""Test parsing of triage issue body."""
|
||||
manager = BacklogManager()
|
||||
|
||||
# Sample triage body
|
||||
triage_body = """
|
||||
## Perplexity Triage Pass — 2026-04-07 Evening
|
||||
|
||||
### PR Reviews (14 total)
|
||||
|
||||
| PR | Repo | Author | Verdict | Notes |
|
||||
|----|------|--------|---------|-------|
|
||||
| #1113 | the-nexus | claude | ✅ Approved | Clean audit response doc, +9 |
|
||||
| #572 | timmy-home | Timmy | ❌ Close | **Zombie** — 0 additions |
|
||||
|
||||
### Process Issues Found
|
||||
|
||||
1. **Rubber-stamping:** timmy-config #359 has 3 APPROVED reviews
|
||||
2. **Duplicate PRs:** #362/#363 are identical diffs
|
||||
|
||||
### Recommendations
|
||||
|
||||
1. **Close the 4 dead PRs** (#572, #377, #363, #359)
|
||||
2. **Decide SOUL.md canonical home**
|
||||
"""
|
||||
|
||||
result = manager.parse_triage_issue(triage_body)
|
||||
|
||||
# Check PR reviews
|
||||
self.assertEqual(len(result["pr_reviews"]), 2)
|
||||
self.assertEqual(result["pr_reviews"][0]["pr"], "#1113")
|
||||
self.assertIn("Approved", result["pr_reviews"][0]["verdict"])
|
||||
|
||||
# Check process issues
|
||||
self.assertEqual(len(result["process_issues"]), 2)
|
||||
self.assertIn("Rubber-stamping", result["process_issues"][0])
|
||||
|
||||
# Check recommendations
|
||||
self.assertEqual(len(result["recommendations"]), 2)
|
||||
self.assertIn("Close the 4 dead PRs", result["recommendations"][0])
|
||||
|
||||
def test_generate_report(self):
|
||||
"""Test report generation."""
|
||||
manager = BacklogManager()
|
||||
|
||||
triage_data = {
|
||||
"pr_reviews": [
|
||||
{"pr": "#1113", "repo": "the-nexus", "author": "claude", "verdict": "✅ Approved", "notes": "Clean"},
|
||||
{"pr": "#572", "repo": "timmy-home", "author": "Timmy", "verdict": "❌ Close", "notes": "Zombie"}
|
||||
],
|
||||
"process_issues": ["Test issue 1", "Test issue 2"],
|
||||
"recommendations": ["Rec 1", "Rec 2"]
|
||||
}
|
||||
|
||||
report = manager.generate_report(triage_data)
|
||||
|
||||
# Check report contains expected sections
|
||||
self.assertIn("# NexusBurn Backlog Report", report)
|
||||
self.assertIn("Total PRs reviewed:** 2", report) # Updated to match actual format
|
||||
self.assertIn("PRs to Close", report)
|
||||
self.assertIn("#572", report)
|
||||
self.assertIn("Process Issues", report)
|
||||
self.assertIn("Recommendations", report)
|
||||
|
||||
@patch('bin.backlog_manager.urllib.request.urlopen')
|
||||
def test_get_open_prs(self, mock_urlopen):
|
||||
"""Test fetching open PRs."""
|
||||
# Mock response
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps([
|
||||
{"number": 1113, "title": "Test PR", "user": {"login": "claude"}}
|
||||
]).encode()
|
||||
mock_response.__enter__ = MagicMock(return_value=mock_response)
|
||||
mock_response.__exit__ = MagicMock()
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
manager = BacklogManager()
|
||||
prs = manager.get_open_prs("the-nexus")
|
||||
|
||||
self.assertEqual(len(prs), 1)
|
||||
self.assertEqual(prs[0]["number"], 1113)
|
||||
|
||||
@patch('bin.backlog_manager.urllib.request.urlopen')
|
||||
def test_close_pr(self, mock_urlopen):
|
||||
"""Test closing a PR."""
|
||||
# Mock successful responses
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps({"id": 123}).encode()
|
||||
mock_response.status = 201
|
||||
mock_response.__enter__ = MagicMock(return_value=mock_response)
|
||||
mock_response.__exit__ = MagicMock()
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
manager = BacklogManager()
|
||||
result = manager.close_pr("the-nexus", 1113, "Test reason")
|
||||
|
||||
self.assertTrue(result)
|
||||
# Verify both API calls were made (comment + close)
|
||||
self.assertEqual(mock_urlopen.call_count, 2)
|
||||
|
||||
|
||||
class TestBacklogManagerIntegration(unittest.TestCase):
|
||||
"""Integration tests for BacklogManager."""
|
||||
|
||||
def test_process_close_prs_dry_run(self):
|
||||
"""Test dry run mode."""
|
||||
manager = BacklogManager()
|
||||
|
||||
triage_data = {
|
||||
"pr_reviews": [
|
||||
{"pr": "#572", "repo": "timmy-home", "author": "Timmy", "verdict": "❌ Close", "notes": "Zombie"},
|
||||
{"pr": "#377", "repo": "timmy-config", "author": "Timmy", "verdict": "❌ Close", "notes": "Duplicate"}
|
||||
]
|
||||
}
|
||||
|
||||
# Mock get_open_prs to return empty list
|
||||
with patch.object(manager, 'get_open_prs', return_value=[]):
|
||||
actions = manager.process_close_prs(triage_data, dry_run=True)
|
||||
|
||||
self.assertEqual(len(actions), 2)
|
||||
self.assertFalse(actions[0]["closed"]) # Should not close in dry run
|
||||
self.assertFalse(actions[0]["exists"]) # No open PRs found
|
||||
|
||||
|
||||
def run_tests():
|
||||
"""Run all tests."""
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_tests()
|
||||
Reference in New Issue
Block a user