From c80c3968c334e910db87fa4d7de2682b39036805 Mon Sep 17 00:00:00 2001 From: rysweet_microsoft Date: Sat, 7 Mar 2026 15:44:55 -0800 Subject: [PATCH 1/9] feat: integrate /top5 priority aggregation into pm-architect Merge top5 priority aggregation directly into pm-architect rather than as a standalone skill. Adds Pattern 5 (Quick Priority View) and Pattern 6 (Daily Standup) to the orchestrator. Files added/modified: - scripts/generate_top5.py: Aggregates priorities across backlog-curator, workstream-coordinator, roadmap-strategist, and work-delegator into a strict Top 5 ranked list (weights: 35/25/25/15) - scripts/tests/test_generate_top5.py: 31 unit tests covering extraction, aggregation, ranking, tiebreaking, and edge cases - scripts/tests/conftest.py: Added pm_dir, sample_backlog_items, and populated_pm fixtures for top5 testing - SKILL.md: Added /top5 trigger, Pattern 5 and 6, updated scripts list Closes milestone 1 of #2932 Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/pm-architect/SKILL.md | 21 +- .../pm-architect/scripts/generate_top5.py | 322 +++++++++++++++++ .../pm-architect/scripts/tests/conftest.py | 127 +++++++ .../scripts/tests/test_generate_top5.py | 325 ++++++++++++++++++ 4 files changed, 794 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/pm-architect/scripts/generate_top5.py create mode 100644 .claude/skills/pm-architect/scripts/tests/test_generate_top5.py diff --git a/.claude/skills/pm-architect/SKILL.md b/.claude/skills/pm-architect/SKILL.md index 240293635..392fc1b41 100644 --- a/.claude/skills/pm-architect/SKILL.md +++ b/.claude/skills/pm-architect/SKILL.md @@ -1,6 +1,8 @@ --- name: pm-architect description: Expert project manager orchestrating backlog-curator, work-delegator, workstream-coordinator, and roadmap-strategist sub-skills. Coordinates complex software projects through delegation and strategic oversight. Activates when managing projects, coordinating work, or tracking overall progress. +explicit_triggers: + - /top5 --- # PM Architect Skill (Orchestrator) @@ -18,6 +20,8 @@ Activate when the user: - Wants to organize multiple projects or features - Needs help with project planning or execution - Says "I'm losing track" or "What should I work on?" +- Asks "What are the top priorities?" or invokes `/top5` +- Wants a quick daily standup or status overview ## Sub-Skills @@ -69,6 +73,16 @@ Sequential: work-delegator creates package, then workstream-coordinator tracks i Create .pm/ structure, invoke roadmap-strategist for roadmap generation. +### Pattern 5: Top 5 Priorities (`/top5`) + +Run `scripts/generate_top5.py` to aggregate priorities across all four sub-skills into a strict ranked list. Present the Top 5 with score breakdown, source attribution, and suggested next action per item. + +Weights: backlog 35%, workstream urgency 25%, roadmap alignment 25%, delegation readiness 15%. + +### Pattern 6: Daily Standup + +Run `scripts/generate_daily_status.py` to produce a cross-project status report. Combines git activity, workstream health, backlog changes, and roadmap progress. + ## Philosophy Alignment - **Ruthless Simplicity**: Thin orchestrator (< 200 lines), complexity in sub-skills @@ -77,7 +91,12 @@ Create .pm/ structure, invoke roadmap-strategist for roadmap generation. ## Scripts -Orchestrator owns `scripts/manage_state.py` for basic operations. +Orchestrator owns these scripts: +- `scripts/manage_state.py` — Basic .pm/ state operations (init, add, update, list) +- `scripts/generate_top5.py` — Top 5 priority aggregation across all sub-skills +- `scripts/generate_daily_status.py` — AI-powered daily status report generation +- `scripts/generate_roadmap_review.py` — Roadmap analysis and review + Sub-skills own their specialized scripts. ## Success Criteria diff --git a/.claude/skills/pm-architect/scripts/generate_top5.py b/.claude/skills/pm-architect/scripts/generate_top5.py new file mode 100644 index 000000000..5a540cd90 --- /dev/null +++ b/.claude/skills/pm-architect/scripts/generate_top5.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Aggregate priorities from PM sub-skills into a strict Top 5 ranked list. + +Queries backlog-curator, workstream-coordinator, roadmap-strategist, and +work-delegator state to produce a unified priority ranking. + +Usage: + python generate_top5.py [--project-root PATH] + +Returns JSON with top 5 priorities. +""" + +import argparse +import json +import sys +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import yaml + + +# Aggregation weights +WEIGHT_BACKLOG = 0.35 +WEIGHT_WORKSTREAM = 0.25 +WEIGHT_ROADMAP = 0.25 +WEIGHT_DELEGATION = 0.15 + +TOP_N = 5 + + +def load_yaml(path: Path) -> dict[str, Any]: + """Load YAML file safely.""" + if not path.exists(): + return {} + with open(path) as f: + return yaml.safe_load(f) or {} + + +def load_backlog_candidates(pm_dir: Path) -> list[dict]: + """Extract priority candidates from backlog. + + Scores READY items using the same multi-criteria approach as backlog-curator. + """ + backlog_data = load_yaml(pm_dir / "backlog" / "items.yaml") + items = backlog_data.get("items", []) + ready_items = [item for item in items if item.get("status") == "READY"] + + candidates = [] + priority_map = {"HIGH": 1.0, "MEDIUM": 0.6, "LOW": 0.3} + + for item in ready_items: + priority = item.get("priority", "MEDIUM") + priority_score = priority_map.get(priority, 0.5) + + # Blocking score: count items that depend on this one + item_id = item["id"] + blocking_count = 0 + for other in items: + if other["id"] == item_id: + continue + deps = other.get("dependencies", []) + if item_id in deps: + blocking_count += 1 + + total_items = max(len(items), 1) + blocking_score = min(blocking_count / max(total_items * 0.3, 1), 1.0) + + # Ease score based on estimated hours + hours = item.get("estimated_hours", 4) + if hours < 2: + ease_score = 1.0 + elif hours <= 6: + ease_score = 0.6 + else: + ease_score = 0.3 + + raw_score = (priority_score * 0.40 + blocking_score * 0.30 + ease_score * 0.20 + priority_score * 0.10) * 100 + + # Rationale + reasons = [] + if priority == "HIGH": + reasons.append("HIGH priority") + if blocking_count > 0: + reasons.append(f"unblocks {blocking_count} item(s)") + if hours < 2: + reasons.append("quick win") + if not reasons: + reasons.append("good next step") + + candidates.append({ + "title": item.get("title", item_id), + "source": "backlog", + "raw_score": round(raw_score, 1), + "rationale": ", ".join(reasons), + "item_id": item_id, + "priority": priority, + }) + + return candidates + + +def load_workstream_candidates(pm_dir: Path) -> list[dict]: + """Extract urgent items from workstream state. + + Stalled or blocked workstreams become high-priority candidates. + """ + workstreams_dir = pm_dir / "workstreams" + if not workstreams_dir.exists(): + return [] + + candidates = [] + now = datetime.now(UTC) + + for ws_file in workstreams_dir.glob("ws-*.yaml"): + ws = load_yaml(ws_file) + if not ws or ws.get("status") != "RUNNING": + continue + + last_activity = ws.get("last_activity") + if not last_activity: + continue + + try: + last_dt = datetime.fromisoformat(last_activity.replace("Z", "+00:00")) + hours_idle = (now - last_dt).total_seconds() / 3600 + except (ValueError, TypeError): + hours_idle = 0.0 + + # Stalled workstreams get urgency score proportional to idle time + if hours_idle > 1: + urgency = min(hours_idle / 4.0, 1.0) # Max at 4 hours + raw_score = urgency * 100 + + candidates.append({ + "title": f"Investigate stalled: {ws.get('title', ws.get('id', 'unknown'))}", + "source": "workstream", + "raw_score": round(raw_score, 1), + "rationale": f"no activity for {hours_idle:.1f} hours", + "item_id": ws.get("id", ""), + "priority": "HIGH" if hours_idle > 2 else "MEDIUM", + }) + + return candidates + + +def extract_roadmap_goals(pm_dir: Path) -> list[str]: + """Extract strategic goals from roadmap markdown.""" + roadmap_path = pm_dir / "roadmap.md" + if not roadmap_path.exists(): + return [] + + text = roadmap_path.read_text() + goals = [] + + # Extract goals from markdown headers and bullet points + for line in text.splitlines(): + line = line.strip() + # Match "## Goal: ...", "- Goal: ...", "* ..." + if line.startswith("## ") or line.startswith("### "): + goals.append(line.lstrip("#").strip()) + elif line.startswith("- ") or line.startswith("* "): + goals.append(line.lstrip("-* ").strip()) + + return goals + + +def score_roadmap_alignment(candidate: dict, goals: list[str]) -> float: + """Score how well a candidate aligns with roadmap goals. Returns 0.0-1.0.""" + if not goals: + return 0.5 # Neutral when no goals defined + + title_lower = candidate["title"].lower() + max_alignment = 0.0 + + for goal in goals: + goal_words = set(goal.lower().split()) + # Remove common stop words + goal_words -= {"the", "a", "an", "and", "or", "to", "for", "in", "of", "is", "with"} + if not goal_words: + continue + + matching = sum(1 for word in goal_words if word in title_lower) + alignment = matching / len(goal_words) if goal_words else 0.0 + max_alignment = max(max_alignment, alignment) + + return min(max_alignment, 1.0) + + +def load_delegation_candidates(pm_dir: Path) -> list[dict]: + """Extract items from delegation state that need action.""" + delegations_dir = pm_dir / "delegations" + if not delegations_dir.exists(): + return [] + + candidates = [] + for deleg_file in delegations_dir.glob("*.yaml"): + deleg = load_yaml(deleg_file) + if not deleg: + continue + + status = deleg.get("status", "") + if status in ("PENDING", "READY"): + raw_score = 70.0 if status == "READY" else 50.0 + candidates.append({ + "title": f"Delegate: {deleg.get('title', deleg_file.stem)}", + "source": "delegation", + "raw_score": raw_score, + "rationale": f"delegation {status.lower()}, ready for assignment", + "item_id": deleg.get("id", deleg_file.stem), + "priority": "MEDIUM", + }) + + return candidates + + +def aggregate_and_rank( + backlog: list[dict], + workstream: list[dict], + delegation: list[dict], + goals: list[str], + top_n: int = TOP_N, +) -> list[dict]: + """Aggregate candidates from all sources and rank by weighted score. + + Each candidate's final score is computed as: + final = (source_weight * raw_score) + (roadmap_weight * alignment * 100) + + where source_weight depends on which sub-skill produced the candidate. + """ + scored = [] + + source_weights = { + "backlog": WEIGHT_BACKLOG, + "workstream": WEIGHT_WORKSTREAM, + "delegation": WEIGHT_DELEGATION, + } + + all_candidates = backlog + workstream + delegation + + for candidate in all_candidates: + source = candidate["source"] + source_weight = source_weights.get(source, 0.25) + raw = candidate["raw_score"] + + alignment = score_roadmap_alignment(candidate, goals) + final_score = (source_weight * raw) + (WEIGHT_ROADMAP * alignment * 100) + + scored.append({ + "title": candidate["title"], + "source": candidate["source"], + "score": round(final_score, 1), + "rationale": candidate["rationale"], + "item_id": candidate.get("item_id", ""), + "priority": candidate.get("priority", "MEDIUM"), + "alignment": round(alignment, 2), + }) + + # Sort by score descending, then by priority (HIGH first) for tiebreaking + priority_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2} + scored.sort(key=lambda x: (-x["score"], priority_order.get(x["priority"], 1))) + + # Take top N and assign ranks + top = scored[:top_n] + for i, item in enumerate(top): + item["rank"] = i + 1 + + return top + + +def generate_top5(project_root: Path) -> dict: + """Generate the Top 5 priority list from all PM sub-skill state.""" + pm_dir = project_root / ".pm" + + if not pm_dir.exists(): + return { + "top5": [], + "message": "No .pm/ directory found. Run pm-architect to initialize.", + "sources": {"backlog": 0, "workstream": 0, "roadmap_goals": 0, "delegation": 0}, + } + + # Gather candidates from each source + backlog = load_backlog_candidates(pm_dir) + workstream = load_workstream_candidates(pm_dir) + goals = extract_roadmap_goals(pm_dir) + delegation = load_delegation_candidates(pm_dir) + + # Aggregate and rank + top5 = aggregate_and_rank(backlog, workstream, delegation, goals) + + return { + "top5": top5, + "sources": { + "backlog": len(backlog), + "workstream": len(workstream), + "roadmap_goals": len(goals), + "delegation": len(delegation), + }, + "total_candidates": len(backlog) + len(workstream) + len(delegation), + } + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description="Generate Top 5 priorities from PM state") + parser.add_argument( + "--project-root", type=Path, default=Path.cwd(), help="Project root directory" + ) + + args = parser.parse_args() + + try: + result = generate_top5(args.project_root) + print(json.dumps(result, indent=2)) + return 0 + except Exception as e: + print(json.dumps({"error": str(e)}), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/pm-architect/scripts/tests/conftest.py b/.claude/skills/pm-architect/scripts/tests/conftest.py index 40af58e5a..448aa9983 100644 --- a/.claude/skills/pm-architect/scripts/tests/conftest.py +++ b/.claude/skills/pm-architect/scripts/tests/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock import pytest +import yaml @pytest.fixture @@ -131,3 +132,129 @@ def sample_daily_status_output() -> str: 1. Prioritize design review for API refactoring 2. Address technical debt in authentication system """ + + +# --- Top 5 Priority Aggregation Fixtures --- + + +@pytest.fixture +def pm_dir(tmp_path: Path) -> Path: + """Create .pm/ directory structure with sample data.""" + pm = tmp_path / ".pm" + (pm / "backlog").mkdir(parents=True) + (pm / "workstreams").mkdir(parents=True) + (pm / "delegations").mkdir(parents=True) + return pm + + +@pytest.fixture +def sample_backlog_items() -> dict: + """Sample backlog items YAML data.""" + return { + "items": [ + { + "id": "BL-001", + "title": "Fix authentication bug", + "description": "Auth tokens expire prematurely", + "priority": "HIGH", + "estimated_hours": 2, + "status": "READY", + "tags": ["auth", "bug"], + "dependencies": [], + }, + { + "id": "BL-002", + "title": "Implement config parser", + "description": "Parse YAML and JSON config files", + "priority": "MEDIUM", + "estimated_hours": 4, + "status": "READY", + "tags": ["config", "core"], + "dependencies": [], + }, + { + "id": "BL-003", + "title": "Add logging framework", + "description": "Structured logging with JSON output", + "priority": "LOW", + "estimated_hours": 8, + "status": "READY", + "tags": ["infrastructure"], + "dependencies": ["BL-002"], + }, + { + "id": "BL-004", + "title": "Write API documentation", + "description": "Document all REST endpoints", + "priority": "MEDIUM", + "estimated_hours": 3, + "status": "READY", + "tags": ["docs"], + "dependencies": [], + }, + { + "id": "BL-005", + "title": "Database migration tool", + "description": "Automated schema migrations", + "priority": "HIGH", + "estimated_hours": 6, + "status": "READY", + "tags": ["database", "core"], + "dependencies": ["BL-002"], + }, + { + "id": "BL-006", + "title": "Refactor test suite", + "description": "Improve test performance and coverage", + "priority": "MEDIUM", + "estimated_hours": 1, + "status": "IN_PROGRESS", + "tags": ["test"], + "dependencies": [], + }, + ] + } + + +@pytest.fixture +def populated_pm(pm_dir, sample_backlog_items): + """Create fully populated .pm/ directory.""" + with open(pm_dir / "backlog" / "items.yaml", "w") as f: + yaml.dump(sample_backlog_items, f) + + ws_data = { + "id": "ws-1", + "backlog_id": "BL-006", + "title": "Test Suite Refactor", + "agent": "builder", + "status": "RUNNING", + "last_activity": "2020-01-01T00:00:00Z", + } + with open(pm_dir / "workstreams" / "ws-1.yaml", "w") as f: + yaml.dump(ws_data, f) + + deleg_data = { + "id": "DEL-001", + "title": "Implement caching layer", + "status": "READY", + "backlog_id": "BL-002", + } + with open(pm_dir / "delegations" / "del-001.yaml", "w") as f: + yaml.dump(deleg_data, f) + + roadmap = """# Project Roadmap + +## Q1 Goals + +### Core Infrastructure +- Implement config parser +- Database migration tool +- Logging framework + +### Quality +- Test coverage above 80% +- API documentation complete +""" + (pm_dir / "roadmap.md").write_text(roadmap) + + return pm_dir diff --git a/.claude/skills/pm-architect/scripts/tests/test_generate_top5.py b/.claude/skills/pm-architect/scripts/tests/test_generate_top5.py new file mode 100644 index 000000000..0a67902de --- /dev/null +++ b/.claude/skills/pm-architect/scripts/tests/test_generate_top5.py @@ -0,0 +1,325 @@ +"""Tests for generate_top5.py - aggregation and ranking logic.""" + +import sys +from pathlib import Path + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from generate_top5 import ( + aggregate_and_rank, + extract_roadmap_goals, + generate_top5, + load_backlog_candidates, + load_delegation_candidates, + load_workstream_candidates, + score_roadmap_alignment, +) + + +class TestLoadBacklogCandidates: + """Tests for backlog candidate extraction.""" + + def test_no_pm_dir(self, project_root): + """Returns empty when .pm doesn't exist.""" + result = load_backlog_candidates(project_root / ".pm") + assert result == [] + + def test_no_ready_items(self, pm_dir): + """Returns empty when no READY items.""" + items = {"items": [{"id": "BL-001", "title": "Done", "status": "DONE", "priority": "HIGH"}]} + with open(pm_dir / "backlog" / "items.yaml", "w") as f: + yaml.dump(items, f) + + result = load_backlog_candidates(pm_dir) + assert result == [] + + def test_extracts_ready_items(self, populated_pm): + """Extracts all READY items with scores.""" + result = load_backlog_candidates(populated_pm) + # BL-006 is IN_PROGRESS, so 5 READY items + assert len(result) == 5 + assert all(c["source"] == "backlog" for c in result) + assert all("raw_score" in c for c in result) + + def test_high_priority_scores_higher(self, populated_pm): + """HIGH priority items score higher than LOW.""" + result = load_backlog_candidates(populated_pm) + high = next(c for c in result if c["item_id"] == "BL-001") + low = next(c for c in result if c["item_id"] == "BL-003") + assert high["raw_score"] > low["raw_score"] + + def test_blocking_items_get_boost(self, pm_dir): + """Items that unblock others get higher scores.""" + items = { + "items": [ + {"id": "BL-A", "title": "Blocker", "status": "READY", "priority": "MEDIUM", "dependencies": []}, + {"id": "BL-B", "title": "Blocked1", "status": "READY", "priority": "MEDIUM", "dependencies": ["BL-A"]}, + {"id": "BL-C", "title": "Blocked2", "status": "READY", "priority": "MEDIUM", "dependencies": ["BL-A"]}, + ] + } + with open(pm_dir / "backlog" / "items.yaml", "w") as f: + yaml.dump(items, f) + + result = load_backlog_candidates(pm_dir) + blocker = next(c for c in result if c["item_id"] == "BL-A") + blocked = next(c for c in result if c["item_id"] == "BL-B") + assert blocker["raw_score"] > blocked["raw_score"] + assert "unblocks 2" in blocker["rationale"] + + def test_quick_win_rationale(self, pm_dir): + """Items with < 2 hours get quick win rationale.""" + items = { + "items": [ + {"id": "BL-X", "title": "Quick task", "status": "READY", "priority": "MEDIUM", "estimated_hours": 1}, + ] + } + with open(pm_dir / "backlog" / "items.yaml", "w") as f: + yaml.dump(items, f) + + result = load_backlog_candidates(pm_dir) + assert len(result) == 1 + assert "quick win" in result[0]["rationale"] + + +class TestLoadWorkstreamCandidates: + """Tests for workstream candidate extraction.""" + + def test_no_workstreams_dir(self, project_root): + """Returns empty when no workstreams directory.""" + result = load_workstream_candidates(project_root / ".pm") + assert result == [] + + def test_stalled_workstream_detected(self, populated_pm): + """Stalled workstreams become candidates.""" + result = load_workstream_candidates(populated_pm) + assert len(result) == 1 + assert result[0]["source"] == "workstream" + assert "stalled" in result[0]["title"].lower() + + def test_active_workstream_ignored(self, pm_dir): + """Recently active workstreams are not flagged.""" + from datetime import UTC, datetime + + ws = { + "id": "ws-2", + "title": "Active Work", + "status": "RUNNING", + "last_activity": datetime.now(UTC).isoformat(), + } + with open(pm_dir / "workstreams" / "ws-2.yaml", "w") as f: + yaml.dump(ws, f) + + result = load_workstream_candidates(pm_dir) + assert len(result) == 0 + + +class TestExtractRoadmapGoals: + """Tests for roadmap goal extraction.""" + + def test_no_roadmap(self, project_root): + """Returns empty when no roadmap exists.""" + result = extract_roadmap_goals(project_root / ".pm") + assert result == [] + + def test_extracts_goals(self, populated_pm): + """Extracts goals from roadmap markdown.""" + goals = extract_roadmap_goals(populated_pm) + assert len(goals) > 0 + assert any("config" in g.lower() for g in goals) + + +class TestScoreRoadmapAlignment: + """Tests for roadmap alignment scoring.""" + + def test_no_goals_returns_neutral(self): + """Returns 0.5 when no goals defined.""" + candidate = {"title": "Something", "source": "backlog"} + assert score_roadmap_alignment(candidate, []) == 0.5 + + def test_matching_title_scores_high(self): + """Title matching goal words scores high.""" + candidate = {"title": "Implement config parser", "source": "backlog"} + goals = ["config parser implementation"] + score = score_roadmap_alignment(candidate, goals) + assert score > 0.0 + + def test_unrelated_title_scores_zero(self): + """Unrelated title scores zero.""" + candidate = {"title": "Fix authentication bug", "source": "backlog"} + goals = ["database migration tool"] + score = score_roadmap_alignment(candidate, goals) + assert score == 0.0 + + +class TestLoadDelegationCandidates: + """Tests for delegation candidate extraction.""" + + def test_no_delegations_dir(self, project_root): + """Returns empty when no delegations directory.""" + result = load_delegation_candidates(project_root / ".pm") + assert result == [] + + def test_ready_delegation_extracted(self, populated_pm): + """READY delegations become candidates.""" + result = load_delegation_candidates(populated_pm) + assert len(result) == 1 + assert result[0]["source"] == "delegation" + assert result[0]["raw_score"] == 70.0 + + def test_pending_delegation_lower_score(self, pm_dir): + """PENDING delegations score lower than READY.""" + deleg = {"id": "DEL-P", "title": "Pending task", "status": "PENDING"} + with open(pm_dir / "delegations" / "del-p.yaml", "w") as f: + yaml.dump(deleg, f) + + result = load_delegation_candidates(pm_dir) + assert len(result) == 1 + assert result[0]["raw_score"] == 50.0 + + +class TestAggregateAndRank: + """Tests for the core aggregation and ranking logic.""" + + def test_empty_input(self): + """Returns empty list when no candidates.""" + result = aggregate_and_rank([], [], [], []) + assert result == [] + + def test_returns_max_5(self): + """Never returns more than 5 items.""" + candidates = [ + {"title": f"Item {i}", "source": "backlog", "raw_score": float(100 - i), + "rationale": "test", "item_id": f"BL-{i}", "priority": "MEDIUM"} + for i in range(10) + ] + result = aggregate_and_rank(candidates, [], [], []) + assert len(result) == 5 + + def test_custom_top_n(self): + """Respects custom top_n parameter.""" + candidates = [ + {"title": f"Item {i}", "source": "backlog", "raw_score": float(100 - i), + "rationale": "test", "item_id": f"BL-{i}", "priority": "MEDIUM"} + for i in range(10) + ] + result = aggregate_and_rank(candidates, [], [], [], top_n=3) + assert len(result) == 3 + + def test_ranked_in_order(self): + """Items are ranked by descending score.""" + candidates = [ + {"title": "Low", "source": "backlog", "raw_score": 30.0, + "rationale": "test", "item_id": "BL-1", "priority": "LOW"}, + {"title": "High", "source": "backlog", "raw_score": 90.0, + "rationale": "test", "item_id": "BL-2", "priority": "HIGH"}, + {"title": "Mid", "source": "backlog", "raw_score": 60.0, + "rationale": "test", "item_id": "BL-3", "priority": "MEDIUM"}, + ] + result = aggregate_and_rank(candidates, [], [], []) + assert result[0]["title"] == "High" + assert result[1]["title"] == "Mid" + assert result[2]["title"] == "Low" + + def test_ranks_assigned(self): + """Each item gets a sequential rank.""" + candidates = [ + {"title": f"Item {i}", "source": "backlog", "raw_score": float(100 - i), + "rationale": "test", "item_id": f"BL-{i}", "priority": "MEDIUM"} + for i in range(3) + ] + result = aggregate_and_rank(candidates, [], [], []) + assert [r["rank"] for r in result] == [1, 2, 3] + + def test_mixed_sources(self): + """Items from different sources are correctly weighted.""" + backlog = [ + {"title": "Backlog item", "source": "backlog", "raw_score": 80.0, + "rationale": "test", "item_id": "BL-1", "priority": "HIGH"}, + ] + workstream = [ + {"title": "Stalled ws", "source": "workstream", "raw_score": 80.0, + "rationale": "test", "item_id": "ws-1", "priority": "HIGH"}, + ] + delegation = [ + {"title": "Ready deleg", "source": "delegation", "raw_score": 80.0, + "rationale": "test", "item_id": "DEL-1", "priority": "MEDIUM"}, + ] + result = aggregate_and_rank(backlog, workstream, delegation, []) + # With same raw_score=80, backlog (0.35) > workstream (0.25) > delegation (0.15) + assert result[0]["source"] == "backlog" + assert result[1]["source"] == "workstream" + assert result[2]["source"] == "delegation" + + def test_roadmap_alignment_boosts_score(self): + """Items matching roadmap goals get higher scores.""" + candidates = [ + {"title": "Implement config parser", "source": "backlog", "raw_score": 50.0, + "rationale": "test", "item_id": "BL-1", "priority": "MEDIUM"}, + {"title": "Fix random thing", "source": "backlog", "raw_score": 50.0, + "rationale": "test", "item_id": "BL-2", "priority": "MEDIUM"}, + ] + goals = ["config parser implementation"] + result = aggregate_and_rank(candidates, [], [], goals) + config_item = next(r for r in result if "config" in r["title"].lower()) + other_item = next(r for r in result if "random" in r["title"].lower()) + assert config_item["score"] > other_item["score"] + + def test_tiebreak_by_priority(self): + """Equal scores are tiebroken by priority (HIGH first).""" + backlog = [ + {"title": "Low priority", "source": "backlog", "raw_score": 50.0, + "rationale": "test", "item_id": "BL-1", "priority": "LOW"}, + {"title": "High priority", "source": "backlog", "raw_score": 50.0, + "rationale": "test", "item_id": "BL-2", "priority": "HIGH"}, + ] + result = aggregate_and_rank(backlog, [], [], []) + assert result[0]["priority"] == "HIGH" + assert result[1]["priority"] == "LOW" + + +class TestGenerateTop5: + """Tests for the main generate_top5 function.""" + + def test_no_pm_dir(self, project_root): + """Returns message when .pm/ doesn't exist.""" + result = generate_top5(project_root) + assert result["top5"] == [] + assert "No .pm/ directory" in result["message"] + + def test_empty_pm_dir(self, pm_dir): + """Returns empty top5 when .pm/ exists but is empty.""" + result = generate_top5(pm_dir.parent) + assert result["top5"] == [] + assert result["total_candidates"] == 0 + + def test_full_aggregation(self, populated_pm): + """Full integration test with populated PM state.""" + result = generate_top5(populated_pm.parent) + assert len(result["top5"]) <= 5 + assert len(result["top5"]) > 0 + assert result["sources"]["backlog"] > 0 + assert result["sources"]["workstream"] > 0 + assert result["sources"]["delegation"] > 0 + assert result["sources"]["roadmap_goals"] > 0 + assert result["total_candidates"] > 0 + + def test_items_have_required_fields(self, populated_pm): + """Each top5 item has all required fields.""" + result = generate_top5(populated_pm.parent) + required_fields = {"rank", "title", "source", "score", "rationale", "priority"} + for item in result["top5"]: + assert required_fields.issubset(item.keys()), f"Missing fields: {required_fields - item.keys()}" + + def test_ranks_sequential(self, populated_pm): + """Ranks are sequential starting from 1.""" + result = generate_top5(populated_pm.parent) + ranks = [item["rank"] for item in result["top5"]] + assert ranks == list(range(1, len(ranks) + 1)) + + def test_scores_descending(self, populated_pm): + """Scores are in descending order.""" + result = generate_top5(populated_pm.parent) + scores = [item["score"] for item in result["top5"]] + assert scores == sorted(scores, reverse=True) From a502412e4efed01211768a988252f1974e203767 Mon Sep 17 00:00:00 2001 From: rysweet_microsoft Date: Sat, 7 Mar 2026 17:39:03 -0800 Subject: [PATCH 2/9] feat: GitHub-native data sourcing for /top5 Rewrites generate_top5.py to query live GitHub data instead of reading static .pm/ YAML files. Sources issues and PRs across multiple GitHub accounts via gh CLI search API. - Reads .pm/sources.yaml for account/repo configuration - Fetches open issues and PRs via gh api search/issues - Scores by: label priority, staleness, comment activity, draft status - Falls back to .pm/backlog/ for local overrides when GitHub unavailable - Restores original gh account after multi-account queries - Weights: issues 40%, PRs 30%, roadmap alignment 20%, local 10% Tested live: 116 candidates from rysweet + rysweet_microsoft accounts 29 unit tests passing (mocked gh calls + aggregation logic) Implements Milestone 0 of #2932 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pm-architect/scripts/generate_top5.py | 471 ++++++++++++------ .../scripts/tests/test_generate_top5.py | 450 +++++++++-------- .pm/sources.yaml | 18 + 3 files changed, 579 insertions(+), 360 deletions(-) create mode 100644 .pm/sources.yaml diff --git a/.claude/skills/pm-architect/scripts/generate_top5.py b/.claude/skills/pm-architect/scripts/generate_top5.py index 5a540cd90..9f1c84c58 100644 --- a/.claude/skills/pm-architect/scripts/generate_top5.py +++ b/.claude/skills/pm-architect/scripts/generate_top5.py @@ -1,17 +1,20 @@ #!/usr/bin/env python3 -"""Aggregate priorities from PM sub-skills into a strict Top 5 ranked list. +"""Aggregate priorities across GitHub accounts into a strict Top 5 ranked list. -Queries backlog-curator, workstream-coordinator, roadmap-strategist, and -work-delegator state to produce a unified priority ranking. +Queries GitHub issues and PRs across configured accounts/repos, scores them +by priority labels, staleness, blocking status, and roadmap alignment. + +Falls back to .pm/ YAML state if GitHub is unavailable or for enrichment. Usage: - python generate_top5.py [--project-root PATH] + python generate_top5.py [--project-root PATH] [--sources PATH] Returns JSON with top 5 priorities. """ import argparse import json +import subprocess import sys from datetime import UTC, datetime from pathlib import Path @@ -21,13 +24,28 @@ # Aggregation weights -WEIGHT_BACKLOG = 0.35 -WEIGHT_WORKSTREAM = 0.25 -WEIGHT_ROADMAP = 0.25 -WEIGHT_DELEGATION = 0.15 +WEIGHT_ISSUES = 0.40 +WEIGHT_PRS = 0.30 +WEIGHT_ROADMAP = 0.20 +WEIGHT_LOCAL = 0.10 # .pm/ overrides TOP_N = 5 +# Label-to-priority mapping +PRIORITY_LABELS = { + "critical": 1.0, + "priority:critical": 1.0, + "high": 0.9, + "priority:high": 0.9, + "bug": 0.8, + "medium": 0.6, + "priority:medium": 0.6, + "enhancement": 0.5, + "feature": 0.5, + "low": 0.3, + "priority:low": 0.3, +} + def load_yaml(path: Path) -> dict[str, Any]: """Load YAML file safely.""" @@ -37,109 +55,269 @@ def load_yaml(path: Path) -> dict[str, Any]: return yaml.safe_load(f) or {} -def load_backlog_candidates(pm_dir: Path) -> list[dict]: - """Extract priority candidates from backlog. +def load_sources(sources_path: Path) -> list[dict]: + """Load GitHub source configuration.""" + data = load_yaml(sources_path) + return data.get("github", []) + + +def run_gh(args: list[str], account: str | None = None) -> str | None: + """Run a gh CLI command, optionally switching account first. - Scores READY items using the same multi-criteria approach as backlog-curator. + Returns stdout on success, None on failure. """ - backlog_data = load_yaml(pm_dir / "backlog" / "items.yaml") - items = backlog_data.get("items", []) - ready_items = [item for item in items if item.get("status") == "READY"] + env = None + if account: + # gh respects GH_TOKEN but switching is cleaner + switch = subprocess.run( + ["gh", "auth", "switch", "--user", account], + capture_output=True, text=True, timeout=10, + ) + if switch.returncode != 0: + return None + try: + result = subprocess.run( + ["gh"] + args, + capture_output=True, text=True, timeout=30, + env=env, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError): + return None + + +def get_current_gh_account() -> str | None: + """Get the currently active gh account.""" + result = subprocess.run( + ["gh", "auth", "status"], + capture_output=True, text=True, timeout=10, + ) + for line in result.stderr.splitlines() + result.stdout.splitlines(): + if "Active account: true" in line: + # The account name is on the previous line + pass + if "Logged in to" in line and "Active account" not in line: + # Parse: "Logged in to github.com account USERNAME" + parts = line.strip().split("account ") + if len(parts) >= 2: + account = parts[1].split(" ")[0].strip() + # Check if next line says active + idx = (result.stderr + result.stdout).find(line) + remaining = (result.stderr + result.stdout)[idx + len(line):] + if "Active account: true" in remaining.split("\n")[0:2]: + return account + return None + + +def fetch_github_issues(account: str, repos: list[str]) -> list[dict]: + """Fetch open issues for an account's repos from GitHub.""" candidates = [] - priority_map = {"HIGH": 1.0, "MEDIUM": 0.6, "LOW": 0.3} - for item in ready_items: - priority = item.get("priority", "MEDIUM") - priority_score = priority_map.get(priority, 0.5) + # Use search API to get all issues at once + repo_qualifiers = " ".join(f"repo:{r}" if "/" in r else f"repo:{account}/{r}" for r in repos) + query = f"is:open is:issue {repo_qualifiers}" + + jq_filter = ( + '.items[] | {' + 'repo: (.repository_url | split("/") | .[-2:] | join("/")),' + 'title: .title,' + 'labels: [.labels[].name],' + 'created: .created_at,' + 'updated: .updated_at,' + 'number: .number,' + 'comments: .comments' + '}' + ) - # Blocking score: count items that depend on this one - item_id = item["id"] - blocking_count = 0 - for other in items: - if other["id"] == item_id: - continue - deps = other.get("dependencies", []) - if item_id in deps: - blocking_count += 1 + output = run_gh( + ["api", "search/issues", "--method", "GET", + "-f", f"q={query}", "-f", "per_page=50", + "--jq", jq_filter], + account=account, + ) - total_items = max(len(items), 1) - blocking_score = min(blocking_count / max(total_items * 0.3, 1), 1.0) + if not output: + return [] - # Ease score based on estimated hours - hours = item.get("estimated_hours", 4) - if hours < 2: - ease_score = 1.0 - elif hours <= 6: - ease_score = 0.6 - else: - ease_score = 0.3 + now = datetime.now(UTC) + + for line in output.strip().splitlines(): + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + + # Score by labels + labels = [l.lower() for l in item.get("labels", [])] + priority_score = 0.5 # default + for label in labels: + if label in PRIORITY_LABELS: + priority_score = max(priority_score, PRIORITY_LABELS[label]) + + # Staleness boost: older updated = needs attention + try: + updated = datetime.fromisoformat(item["updated"].replace("Z", "+00:00")) + days_stale = (now - updated).total_seconds() / 86400 + except (ValueError, KeyError): + days_stale = 0 + + staleness_score = min(days_stale / 14.0, 1.0) # Max at 2 weeks - raw_score = (priority_score * 0.40 + blocking_score * 0.30 + ease_score * 0.20 + priority_score * 0.10) * 100 + # Comment activity: more comments = more discussion = potentially blocked + comments = item.get("comments", 0) + activity_score = min(comments / 10.0, 1.0) + + raw_score = (priority_score * 0.50 + staleness_score * 0.30 + activity_score * 0.20) * 100 # Rationale reasons = [] - if priority == "HIGH": - reasons.append("HIGH priority") - if blocking_count > 0: - reasons.append(f"unblocks {blocking_count} item(s)") - if hours < 2: - reasons.append("quick win") + if priority_score >= 0.8: + reasons.append(f"labeled {', '.join(l for l in labels if l in PRIORITY_LABELS)}") + if days_stale > 7: + reasons.append(f"stale {days_stale:.0f}d") + if comments > 3: + reasons.append(f"{comments} comments") if not reasons: - reasons.append("good next step") + reasons.append("open issue") + repo = item.get("repo", "") candidates.append({ - "title": item.get("title", item_id), - "source": "backlog", + "title": f"[{repo}#{item['number']}] {item['title']}", + "source": "github_issue", "raw_score": round(raw_score, 1), "rationale": ", ".join(reasons), - "item_id": item_id, - "priority": priority, + "item_id": f"{repo}#{item['number']}", + "priority": "HIGH" if priority_score >= 0.8 else "MEDIUM" if priority_score >= 0.5 else "LOW", + "repo": repo, + "url": f"https://github.com/{repo}/issues/{item['number']}", }) return candidates -def load_workstream_candidates(pm_dir: Path) -> list[dict]: - """Extract urgent items from workstream state. +def fetch_github_prs(account: str, repos: list[str]) -> list[dict]: + """Fetch open PRs for an account's repos from GitHub.""" + candidates = [] + + repo_qualifiers = " ".join(f"repo:{r}" if "/" in r else f"repo:{account}/{r}" for r in repos) + query = f"is:open is:pr {repo_qualifiers}" + + jq_filter = ( + '.items[] | {' + 'repo: (.repository_url | split("/") | .[-2:] | join("/")),' + 'title: .title,' + 'labels: [.labels[].name],' + 'created: .created_at,' + 'updated: .updated_at,' + 'number: .number,' + 'draft: .draft,' + 'comments: .comments' + '}' + ) - Stalled or blocked workstreams become high-priority candidates. - """ - workstreams_dir = pm_dir / "workstreams" - if not workstreams_dir.exists(): + output = run_gh( + ["api", "search/issues", "--method", "GET", + "-f", f"q={query}", "-f", "per_page=50", + "--jq", jq_filter], + account=account, + ) + + if not output: return [] - candidates = [] now = datetime.now(UTC) - for ws_file in workstreams_dir.glob("ws-*.yaml"): - ws = load_yaml(ws_file) - if not ws or ws.get("status") != "RUNNING": + for line in output.strip().splitlines(): + try: + item = json.loads(line) + except json.JSONDecodeError: continue - last_activity = ws.get("last_activity") - if not last_activity: - continue + is_draft = item.get("draft", False) + + # PRs waiting for review are higher priority than drafts + base_score = 0.4 if is_draft else 0.7 + # Labels boost + labels = [l.lower() for l in item.get("labels", [])] + for label in labels: + if label in PRIORITY_LABELS: + base_score = max(base_score, PRIORITY_LABELS[label]) + + # Staleness: PRs waiting for review get more urgent over time try: - last_dt = datetime.fromisoformat(last_activity.replace("Z", "+00:00")) - hours_idle = (now - last_dt).total_seconds() / 3600 - except (ValueError, TypeError): - hours_idle = 0.0 - - # Stalled workstreams get urgency score proportional to idle time - if hours_idle > 1: - urgency = min(hours_idle / 4.0, 1.0) # Max at 4 hours - raw_score = urgency * 100 - - candidates.append({ - "title": f"Investigate stalled: {ws.get('title', ws.get('id', 'unknown'))}", - "source": "workstream", - "raw_score": round(raw_score, 1), - "rationale": f"no activity for {hours_idle:.1f} hours", - "item_id": ws.get("id", ""), - "priority": "HIGH" if hours_idle > 2 else "MEDIUM", - }) + updated = datetime.fromisoformat(item["updated"].replace("Z", "+00:00")) + days_stale = (now - updated).total_seconds() / 86400 + except (ValueError, KeyError): + days_stale = 0 + + staleness_score = min(days_stale / 7.0, 1.0) # PRs stale faster (1 week max) + + raw_score = (base_score * 0.60 + staleness_score * 0.40) * 100 + + reasons = [] + if is_draft: + reasons.append("draft PR") + else: + reasons.append("awaiting review") + if days_stale > 3: + reasons.append(f"stale {days_stale:.0f}d") + if labels: + relevant = [l for l in labels if l in PRIORITY_LABELS] + if relevant: + reasons.append(f"labeled {', '.join(relevant)}") + + repo = item.get("repo", "") + candidates.append({ + "title": f"[{repo}#{item['number']}] {item['title']}", + "source": "github_pr", + "raw_score": round(raw_score, 1), + "rationale": ", ".join(reasons), + "item_id": f"{repo}#{item['number']}", + "priority": "HIGH" if base_score >= 0.8 else "MEDIUM", + "repo": repo, + "url": f"https://github.com/{repo}/pull/{item['number']}", + }) + + return candidates + + +def load_local_overrides(pm_dir: Path) -> list[dict]: + """Load manually-added items from .pm/backlog for local enrichment.""" + backlog_data = load_yaml(pm_dir / "backlog" / "items.yaml") + items = backlog_data.get("items", []) + ready_items = [item for item in items if item.get("status") == "READY"] + + candidates = [] + priority_map = {"HIGH": 1.0, "MEDIUM": 0.6, "LOW": 0.3} + + for item in ready_items: + priority = item.get("priority", "MEDIUM") + priority_score = priority_map.get(priority, 0.5) + hours = item.get("estimated_hours", 4) + ease_score = 1.0 if hours < 2 else 0.6 if hours <= 6 else 0.3 + + raw_score = (priority_score * 0.60 + ease_score * 0.40) * 100 + + reasons = [] + if priority == "HIGH": + reasons.append("HIGH priority") + if hours < 2: + reasons.append("quick win") + if not reasons: + reasons.append("local backlog item") + + candidates.append({ + "title": item.get("title", item["id"]), + "source": "local", + "raw_score": round(raw_score, 1), + "rationale": ", ".join(reasons), + "item_id": item["id"], + "priority": priority, + }) return candidates @@ -153,10 +331,8 @@ def extract_roadmap_goals(pm_dir: Path) -> list[str]: text = roadmap_path.read_text() goals = [] - # Extract goals from markdown headers and bullet points for line in text.splitlines(): line = line.strip() - # Match "## Goal: ...", "- Goal: ...", "* ..." if line.startswith("## ") or line.startswith("### "): goals.append(line.lstrip("#").strip()) elif line.startswith("- ") or line.startswith("* "): @@ -168,14 +344,13 @@ def extract_roadmap_goals(pm_dir: Path) -> list[str]: def score_roadmap_alignment(candidate: dict, goals: list[str]) -> float: """Score how well a candidate aligns with roadmap goals. Returns 0.0-1.0.""" if not goals: - return 0.5 # Neutral when no goals defined + return 0.5 title_lower = candidate["title"].lower() max_alignment = 0.0 for goal in goals: goal_words = set(goal.lower().split()) - # Remove common stop words goal_words -= {"the", "a", "an", "and", "or", "to", "for", "in", "of", "is", "with"} if not goal_words: continue @@ -187,56 +362,23 @@ def score_roadmap_alignment(candidate: dict, goals: list[str]) -> float: return min(max_alignment, 1.0) -def load_delegation_candidates(pm_dir: Path) -> list[dict]: - """Extract items from delegation state that need action.""" - delegations_dir = pm_dir / "delegations" - if not delegations_dir.exists(): - return [] - - candidates = [] - for deleg_file in delegations_dir.glob("*.yaml"): - deleg = load_yaml(deleg_file) - if not deleg: - continue - - status = deleg.get("status", "") - if status in ("PENDING", "READY"): - raw_score = 70.0 if status == "READY" else 50.0 - candidates.append({ - "title": f"Delegate: {deleg.get('title', deleg_file.stem)}", - "source": "delegation", - "raw_score": raw_score, - "rationale": f"delegation {status.lower()}, ready for assignment", - "item_id": deleg.get("id", deleg_file.stem), - "priority": "MEDIUM", - }) - - return candidates - - def aggregate_and_rank( - backlog: list[dict], - workstream: list[dict], - delegation: list[dict], + issues: list[dict], + prs: list[dict], + local: list[dict], goals: list[str], top_n: int = TOP_N, ) -> list[dict]: - """Aggregate candidates from all sources and rank by weighted score. - - Each candidate's final score is computed as: - final = (source_weight * raw_score) + (roadmap_weight * alignment * 100) - - where source_weight depends on which sub-skill produced the candidate. - """ + """Aggregate candidates from all sources and rank by weighted score.""" scored = [] source_weights = { - "backlog": WEIGHT_BACKLOG, - "workstream": WEIGHT_WORKSTREAM, - "delegation": WEIGHT_DELEGATION, + "github_issue": WEIGHT_ISSUES, + "github_pr": WEIGHT_PRS, + "local": WEIGHT_LOCAL, } - all_candidates = backlog + workstream + delegation + all_candidates = issues + prs + local for candidate in all_candidates: source = candidate["source"] @@ -246,7 +388,7 @@ def aggregate_and_rank( alignment = score_roadmap_alignment(candidate, goals) final_score = (source_weight * raw) + (WEIGHT_ROADMAP * alignment * 100) - scored.append({ + entry = { "title": candidate["title"], "source": candidate["source"], "score": round(final_score, 1), @@ -254,13 +396,17 @@ def aggregate_and_rank( "item_id": candidate.get("item_id", ""), "priority": candidate.get("priority", "MEDIUM"), "alignment": round(alignment, 2), - }) + } + if "url" in candidate: + entry["url"] = candidate["url"] + if "repo" in candidate: + entry["repo"] = candidate["repo"] + + scored.append(entry) - # Sort by score descending, then by priority (HIGH first) for tiebreaking priority_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2} scored.sort(key=lambda x: (-x["score"], priority_order.get(x["priority"], 1))) - # Take top N and assign ranks top = scored[:top_n] for i, item in enumerate(top): item["rank"] = i + 1 @@ -268,49 +414,76 @@ def aggregate_and_rank( return top -def generate_top5(project_root: Path) -> dict: - """Generate the Top 5 priority list from all PM sub-skill state.""" +def generate_top5(project_root: Path, sources_path: Path | None = None) -> dict: + """Generate the Top 5 priority list from GitHub + local state.""" pm_dir = project_root / ".pm" - if not pm_dir.exists(): - return { - "top5": [], - "message": "No .pm/ directory found. Run pm-architect to initialize.", - "sources": {"backlog": 0, "workstream": 0, "roadmap_goals": 0, "delegation": 0}, - } + if sources_path is None: + sources_path = pm_dir / "sources.yaml" + + # Load GitHub sources config + sources = load_sources(sources_path) - # Gather candidates from each source - backlog = load_backlog_candidates(pm_dir) - workstream = load_workstream_candidates(pm_dir) - goals = extract_roadmap_goals(pm_dir) - delegation = load_delegation_candidates(pm_dir) + # Remember original account to restore after + original_account = get_current_gh_account() + + # Fetch from GitHub + all_issues = [] + all_prs = [] + accounts_queried = [] + + for source in sources: + account = source.get("account", "") + repos = source.get("repos", []) + if not account or not repos: + continue + + accounts_queried.append(account) + all_issues.extend(fetch_github_issues(account, repos)) + all_prs.extend(fetch_github_prs(account, repos)) + + # Restore original account + if original_account and accounts_queried: + run_gh(["auth", "switch", "--user", original_account]) + + # Load local overrides + local = [] + if pm_dir.exists(): + local = load_local_overrides(pm_dir) + + # Load roadmap goals + goals = extract_roadmap_goals(pm_dir) if pm_dir.exists() else [] # Aggregate and rank - top5 = aggregate_and_rank(backlog, workstream, delegation, goals) + top5 = aggregate_and_rank(all_issues, all_prs, local, goals) return { "top5": top5, "sources": { - "backlog": len(backlog), - "workstream": len(workstream), + "github_issues": len(all_issues), + "github_prs": len(all_prs), + "local_items": len(local), "roadmap_goals": len(goals), - "delegation": len(delegation), + "accounts": accounts_queried, }, - "total_candidates": len(backlog) + len(workstream) + len(delegation), + "total_candidates": len(all_issues) + len(all_prs) + len(local), } def main(): """Main entry point.""" - parser = argparse.ArgumentParser(description="Generate Top 5 priorities from PM state") + parser = argparse.ArgumentParser(description="Generate Top 5 priorities from GitHub + local state") parser.add_argument( "--project-root", type=Path, default=Path.cwd(), help="Project root directory" ) + parser.add_argument( + "--sources", type=Path, default=None, help="Path to sources.yaml (default: .pm/sources.yaml)" + ) args = parser.parse_args() try: - result = generate_top5(args.project_root) + result = generate_top5(args.project_root, args.sources) print(json.dumps(result, indent=2)) return 0 except Exception as e: diff --git a/.claude/skills/pm-architect/scripts/tests/test_generate_top5.py b/.claude/skills/pm-architect/scripts/tests/test_generate_top5.py index 0a67902de..cf67cb7c3 100644 --- a/.claude/skills/pm-architect/scripts/tests/test_generate_top5.py +++ b/.claude/skills/pm-architect/scripts/tests/test_generate_top5.py @@ -1,118 +1,174 @@ -"""Tests for generate_top5.py - aggregation and ranking logic.""" +"""Tests for generate_top5.py - GitHub-native priority aggregation.""" +import json import sys from pathlib import Path +from unittest.mock import patch import pytest import yaml sys.path.insert(0, str(Path(__file__).parent.parent)) from generate_top5 import ( + PRIORITY_LABELS, aggregate_and_rank, extract_roadmap_goals, + fetch_github_issues, + fetch_github_prs, generate_top5, - load_backlog_candidates, - load_delegation_candidates, - load_workstream_candidates, + load_local_overrides, + load_sources, score_roadmap_alignment, ) -class TestLoadBacklogCandidates: - """Tests for backlog candidate extraction.""" +class TestLoadSources: + """Tests for sources.yaml loading.""" - def test_no_pm_dir(self, project_root): - """Returns empty when .pm doesn't exist.""" - result = load_backlog_candidates(project_root / ".pm") - assert result == [] - - def test_no_ready_items(self, pm_dir): - """Returns empty when no READY items.""" - items = {"items": [{"id": "BL-001", "title": "Done", "status": "DONE", "priority": "HIGH"}]} - with open(pm_dir / "backlog" / "items.yaml", "w") as f: - yaml.dump(items, f) - - result = load_backlog_candidates(pm_dir) + def test_no_sources_file(self, project_root): + """Returns empty list when sources.yaml doesn't exist.""" + result = load_sources(project_root / "sources.yaml") assert result == [] - def test_extracts_ready_items(self, populated_pm): - """Extracts all READY items with scores.""" - result = load_backlog_candidates(populated_pm) - # BL-006 is IN_PROGRESS, so 5 READY items - assert len(result) == 5 - assert all(c["source"] == "backlog" for c in result) - assert all("raw_score" in c for c in result) - - def test_high_priority_scores_higher(self, populated_pm): - """HIGH priority items score higher than LOW.""" - result = load_backlog_candidates(populated_pm) - high = next(c for c in result if c["item_id"] == "BL-001") - low = next(c for c in result if c["item_id"] == "BL-003") - assert high["raw_score"] > low["raw_score"] - - def test_blocking_items_get_boost(self, pm_dir): - """Items that unblock others get higher scores.""" - items = { - "items": [ - {"id": "BL-A", "title": "Blocker", "status": "READY", "priority": "MEDIUM", "dependencies": []}, - {"id": "BL-B", "title": "Blocked1", "status": "READY", "priority": "MEDIUM", "dependencies": ["BL-A"]}, - {"id": "BL-C", "title": "Blocked2", "status": "READY", "priority": "MEDIUM", "dependencies": ["BL-A"]}, + def test_loads_github_sources(self, tmp_path): + """Parses sources.yaml correctly.""" + sources = { + "github": [ + {"account": "rysweet", "repos": ["amplihack", "azlin"]}, + {"account": "rysweet_microsoft", "repos": ["cloud-ecosystem-security/SedanDelivery"]}, ] } - with open(pm_dir / "backlog" / "items.yaml", "w") as f: - yaml.dump(items, f) + path = tmp_path / "sources.yaml" + with open(path, "w") as f: + yaml.dump(sources, f) + + result = load_sources(path) + assert len(result) == 2 + assert result[0]["account"] == "rysweet" + assert result[1]["repos"] == ["cloud-ecosystem-security/SedanDelivery"] + + +class TestFetchGithubIssues: + """Tests for GitHub issue fetching (mocked).""" + + def test_returns_empty_on_gh_failure(self): + """Returns empty list when gh CLI fails.""" + with patch("generate_top5.run_gh", return_value=None): + result = fetch_github_issues("rysweet", ["amplihack"]) + assert result == [] + + def test_parses_issue_data(self): + """Correctly parses gh API JSON output.""" + mock_output = json.dumps({ + "repo": "rysweet/amplihack", + "title": "Fix auth bug", + "labels": ["bug", "high"], + "created": "2026-03-01T00:00:00Z", + "updated": "2026-03-07T00:00:00Z", + "number": 123, + "comments": 5, + }) + with patch("generate_top5.run_gh", return_value=mock_output): + result = fetch_github_issues("rysweet", ["amplihack"]) + assert len(result) == 1 + assert result[0]["source"] == "github_issue" + assert result[0]["priority"] == "HIGH" + assert "bug" in result[0]["rationale"] + assert result[0]["url"] == "https://github.com/rysweet/amplihack/issues/123" + + def test_priority_from_labels(self): + """Labels correctly map to priority scores.""" + mock_output = json.dumps({ + "repo": "r/a", "title": "Critical issue", + "labels": ["critical"], "created": "2026-03-07T00:00:00Z", + "updated": "2026-03-07T00:00:00Z", "number": 1, "comments": 0, + }) + with patch("generate_top5.run_gh", return_value=mock_output): + result = fetch_github_issues("r", ["a"]) + assert result[0]["priority"] == "HIGH" + assert result[0]["raw_score"] >= 50.0 + + def test_staleness_boosts_score(self): + """Older issues score higher due to staleness.""" + fresh = json.dumps({ + "repo": "r/a", "title": "Fresh", "labels": [], + "created": "2026-03-07T00:00:00Z", "updated": "2026-03-07T00:00:00Z", + "number": 1, "comments": 0, + }) + stale = json.dumps({ + "repo": "r/a", "title": "Stale", "labels": [], + "created": "2026-01-01T00:00:00Z", "updated": "2026-01-01T00:00:00Z", + "number": 2, "comments": 0, + }) + with patch("generate_top5.run_gh", return_value=f"{fresh}\n{stale}"): + result = fetch_github_issues("r", ["a"]) + stale_item = next(c for c in result if "Stale" in c["title"]) + fresh_item = next(c for c in result if "Fresh" in c["title"]) + assert stale_item["raw_score"] > fresh_item["raw_score"] + + +class TestFetchGithubPrs: + """Tests for GitHub PR fetching (mocked).""" + + def test_returns_empty_on_failure(self): + """Returns empty list when gh CLI fails.""" + with patch("generate_top5.run_gh", return_value=None): + result = fetch_github_prs("rysweet", ["amplihack"]) + assert result == [] + + def test_draft_pr_scores_lower(self): + """Draft PRs score lower than non-drafts.""" + draft = json.dumps({ + "repo": "r/a", "title": "Draft PR", "labels": [], + "created": "2026-03-07T00:00:00Z", "updated": "2026-03-07T00:00:00Z", + "number": 1, "draft": True, "comments": 0, + }) + ready = json.dumps({ + "repo": "r/a", "title": "Ready PR", "labels": [], + "created": "2026-03-07T00:00:00Z", "updated": "2026-03-07T00:00:00Z", + "number": 2, "draft": False, "comments": 0, + }) + with patch("generate_top5.run_gh", return_value=f"{draft}\n{ready}"): + result = fetch_github_prs("r", ["a"]) + draft_item = next(c for c in result if "Draft" in c["title"]) + ready_item = next(c for c in result if "Ready" in c["title"]) + assert ready_item["raw_score"] > draft_item["raw_score"] + + def test_pr_has_url(self): + """PRs include correct GitHub URL.""" + mock = json.dumps({ + "repo": "rysweet/amplihack", "title": "Fix stuff", "labels": [], + "created": "2026-03-07T00:00:00Z", "updated": "2026-03-07T00:00:00Z", + "number": 42, "draft": False, "comments": 0, + }) + with patch("generate_top5.run_gh", return_value=mock): + result = fetch_github_prs("rysweet", ["amplihack"]) + assert result[0]["url"] == "https://github.com/rysweet/amplihack/pull/42" + + +class TestLoadLocalOverrides: + """Tests for local .pm/ backlog loading.""" - result = load_backlog_candidates(pm_dir) - blocker = next(c for c in result if c["item_id"] == "BL-A") - blocked = next(c for c in result if c["item_id"] == "BL-B") - assert blocker["raw_score"] > blocked["raw_score"] - assert "unblocks 2" in blocker["rationale"] + def test_no_pm_dir(self, project_root): + """Returns empty when .pm doesn't exist.""" + result = load_local_overrides(project_root / ".pm") + assert result == [] - def test_quick_win_rationale(self, pm_dir): - """Items with < 2 hours get quick win rationale.""" + def test_loads_ready_items(self, pm_dir): + """Loads READY items from backlog.""" items = { "items": [ - {"id": "BL-X", "title": "Quick task", "status": "READY", "priority": "MEDIUM", "estimated_hours": 1}, + {"id": "BL-001", "title": "Task A", "status": "READY", "priority": "HIGH", "estimated_hours": 1}, + {"id": "BL-002", "title": "Task B", "status": "DONE", "priority": "HIGH"}, ] } with open(pm_dir / "backlog" / "items.yaml", "w") as f: yaml.dump(items, f) - result = load_backlog_candidates(pm_dir) + result = load_local_overrides(pm_dir) assert len(result) == 1 - assert "quick win" in result[0]["rationale"] - - -class TestLoadWorkstreamCandidates: - """Tests for workstream candidate extraction.""" - - def test_no_workstreams_dir(self, project_root): - """Returns empty when no workstreams directory.""" - result = load_workstream_candidates(project_root / ".pm") - assert result == [] - - def test_stalled_workstream_detected(self, populated_pm): - """Stalled workstreams become candidates.""" - result = load_workstream_candidates(populated_pm) - assert len(result) == 1 - assert result[0]["source"] == "workstream" - assert "stalled" in result[0]["title"].lower() - - def test_active_workstream_ignored(self, pm_dir): - """Recently active workstreams are not flagged.""" - from datetime import UTC, datetime - - ws = { - "id": "ws-2", - "title": "Active Work", - "status": "RUNNING", - "last_activity": datetime.now(UTC).isoformat(), - } - with open(pm_dir / "workstreams" / "ws-2.yaml", "w") as f: - yaml.dump(ws, f) - - result = load_workstream_candidates(pm_dir) - assert len(result) == 0 + assert result[0]["source"] == "local" + assert result[0]["item_id"] == "BL-001" class TestExtractRoadmapGoals: @@ -135,50 +191,24 @@ class TestScoreRoadmapAlignment: def test_no_goals_returns_neutral(self): """Returns 0.5 when no goals defined.""" - candidate = {"title": "Something", "source": "backlog"} + candidate = {"title": "Something", "source": "github_issue"} assert score_roadmap_alignment(candidate, []) == 0.5 def test_matching_title_scores_high(self): """Title matching goal words scores high.""" - candidate = {"title": "Implement config parser", "source": "backlog"} + candidate = {"title": "Implement config parser", "source": "github_issue"} goals = ["config parser implementation"] score = score_roadmap_alignment(candidate, goals) assert score > 0.0 def test_unrelated_title_scores_zero(self): """Unrelated title scores zero.""" - candidate = {"title": "Fix authentication bug", "source": "backlog"} + candidate = {"title": "Fix authentication bug", "source": "github_issue"} goals = ["database migration tool"] score = score_roadmap_alignment(candidate, goals) assert score == 0.0 -class TestLoadDelegationCandidates: - """Tests for delegation candidate extraction.""" - - def test_no_delegations_dir(self, project_root): - """Returns empty when no delegations directory.""" - result = load_delegation_candidates(project_root / ".pm") - assert result == [] - - def test_ready_delegation_extracted(self, populated_pm): - """READY delegations become candidates.""" - result = load_delegation_candidates(populated_pm) - assert len(result) == 1 - assert result[0]["source"] == "delegation" - assert result[0]["raw_score"] == 70.0 - - def test_pending_delegation_lower_score(self, pm_dir): - """PENDING delegations score lower than READY.""" - deleg = {"id": "DEL-P", "title": "Pending task", "status": "PENDING"} - with open(pm_dir / "delegations" / "del-p.yaml", "w") as f: - yaml.dump(deleg, f) - - result = load_delegation_candidates(pm_dir) - assert len(result) == 1 - assert result[0]["raw_score"] == 50.0 - - class TestAggregateAndRank: """Tests for the core aggregation and ranking logic.""" @@ -190,136 +220,134 @@ def test_empty_input(self): def test_returns_max_5(self): """Never returns more than 5 items.""" candidates = [ - {"title": f"Item {i}", "source": "backlog", "raw_score": float(100 - i), - "rationale": "test", "item_id": f"BL-{i}", "priority": "MEDIUM"} + {"title": f"Item {i}", "source": "github_issue", "raw_score": float(100 - i), + "rationale": "test", "item_id": f"#{i}", "priority": "MEDIUM"} for i in range(10) ] result = aggregate_and_rank(candidates, [], [], []) assert len(result) == 5 - def test_custom_top_n(self): - """Respects custom top_n parameter.""" - candidates = [ - {"title": f"Item {i}", "source": "backlog", "raw_score": float(100 - i), - "rationale": "test", "item_id": f"BL-{i}", "priority": "MEDIUM"} - for i in range(10) - ] - result = aggregate_and_rank(candidates, [], [], [], top_n=3) - assert len(result) == 3 - def test_ranked_in_order(self): """Items are ranked by descending score.""" - candidates = [ - {"title": "Low", "source": "backlog", "raw_score": 30.0, - "rationale": "test", "item_id": "BL-1", "priority": "LOW"}, - {"title": "High", "source": "backlog", "raw_score": 90.0, - "rationale": "test", "item_id": "BL-2", "priority": "HIGH"}, - {"title": "Mid", "source": "backlog", "raw_score": 60.0, - "rationale": "test", "item_id": "BL-3", "priority": "MEDIUM"}, + issues = [ + {"title": "Low", "source": "github_issue", "raw_score": 30.0, + "rationale": "test", "item_id": "#1", "priority": "LOW"}, + {"title": "High", "source": "github_issue", "raw_score": 90.0, + "rationale": "test", "item_id": "#2", "priority": "HIGH"}, ] - result = aggregate_and_rank(candidates, [], [], []) + result = aggregate_and_rank(issues, [], [], []) assert result[0]["title"] == "High" - assert result[1]["title"] == "Mid" - assert result[2]["title"] == "Low" - - def test_ranks_assigned(self): - """Each item gets a sequential rank.""" - candidates = [ - {"title": f"Item {i}", "source": "backlog", "raw_score": float(100 - i), - "rationale": "test", "item_id": f"BL-{i}", "priority": "MEDIUM"} - for i in range(3) - ] - result = aggregate_and_rank(candidates, [], [], []) - assert [r["rank"] for r in result] == [1, 2, 3] + assert result[1]["title"] == "Low" def test_mixed_sources(self): """Items from different sources are correctly weighted.""" - backlog = [ - {"title": "Backlog item", "source": "backlog", "raw_score": 80.0, - "rationale": "test", "item_id": "BL-1", "priority": "HIGH"}, + issues = [ + {"title": "Issue", "source": "github_issue", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "HIGH"}, ] - workstream = [ - {"title": "Stalled ws", "source": "workstream", "raw_score": 80.0, - "rationale": "test", "item_id": "ws-1", "priority": "HIGH"}, + prs = [ + {"title": "PR", "source": "github_pr", "raw_score": 80.0, + "rationale": "test", "item_id": "#2", "priority": "HIGH", + "url": "https://github.com/r/a/pull/2", "repo": "r/a"}, ] - delegation = [ - {"title": "Ready deleg", "source": "delegation", "raw_score": 80.0, - "rationale": "test", "item_id": "DEL-1", "priority": "MEDIUM"}, + local = [ + {"title": "Local", "source": "local", "raw_score": 80.0, + "rationale": "test", "item_id": "BL-1", "priority": "MEDIUM"}, ] - result = aggregate_and_rank(backlog, workstream, delegation, []) - # With same raw_score=80, backlog (0.35) > workstream (0.25) > delegation (0.15) - assert result[0]["source"] == "backlog" - assert result[1]["source"] == "workstream" - assert result[2]["source"] == "delegation" + result = aggregate_and_rank(issues, prs, local, []) + # Issues (0.40) > PRs (0.30) > Local (0.10) with same raw score + assert result[0]["source"] == "github_issue" + assert result[1]["source"] == "github_pr" + assert result[2]["source"] == "local" def test_roadmap_alignment_boosts_score(self): """Items matching roadmap goals get higher scores.""" - candidates = [ - {"title": "Implement config parser", "source": "backlog", "raw_score": 50.0, - "rationale": "test", "item_id": "BL-1", "priority": "MEDIUM"}, - {"title": "Fix random thing", "source": "backlog", "raw_score": 50.0, - "rationale": "test", "item_id": "BL-2", "priority": "MEDIUM"}, + issues = [ + {"title": "Implement config parser", "source": "github_issue", "raw_score": 50.0, + "rationale": "test", "item_id": "#1", "priority": "MEDIUM"}, + {"title": "Fix random thing", "source": "github_issue", "raw_score": 50.0, + "rationale": "test", "item_id": "#2", "priority": "MEDIUM"}, ] goals = ["config parser implementation"] - result = aggregate_and_rank(candidates, [], [], goals) + result = aggregate_and_rank(issues, [], [], goals) config_item = next(r for r in result if "config" in r["title"].lower()) other_item = next(r for r in result if "random" in r["title"].lower()) assert config_item["score"] > other_item["score"] def test_tiebreak_by_priority(self): """Equal scores are tiebroken by priority (HIGH first).""" - backlog = [ - {"title": "Low priority", "source": "backlog", "raw_score": 50.0, - "rationale": "test", "item_id": "BL-1", "priority": "LOW"}, - {"title": "High priority", "source": "backlog", "raw_score": 50.0, - "rationale": "test", "item_id": "BL-2", "priority": "HIGH"}, + issues = [ + {"title": "Low priority", "source": "github_issue", "raw_score": 50.0, + "rationale": "test", "item_id": "#1", "priority": "LOW"}, + {"title": "High priority", "source": "github_issue", "raw_score": 50.0, + "rationale": "test", "item_id": "#2", "priority": "HIGH"}, ] - result = aggregate_and_rank(backlog, [], [], []) + result = aggregate_and_rank(issues, [], [], []) assert result[0]["priority"] == "HIGH" assert result[1]["priority"] == "LOW" + def test_preserves_url_and_repo(self): + """URL and repo fields are preserved in output.""" + prs = [ + {"title": "PR", "source": "github_pr", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "MEDIUM", + "url": "https://github.com/r/a/pull/1", "repo": "r/a"}, + ] + result = aggregate_and_rank([], prs, [], []) + assert result[0]["url"] == "https://github.com/r/a/pull/1" + assert result[0]["repo"] == "r/a" + class TestGenerateTop5: """Tests for the main generate_top5 function.""" - def test_no_pm_dir(self, project_root): - """Returns message when .pm/ doesn't exist.""" - result = generate_top5(project_root) - assert result["top5"] == [] - assert "No .pm/ directory" in result["message"] - - def test_empty_pm_dir(self, pm_dir): - """Returns empty top5 when .pm/ exists but is empty.""" - result = generate_top5(pm_dir.parent) - assert result["top5"] == [] - assert result["total_candidates"] == 0 - - def test_full_aggregation(self, populated_pm): - """Full integration test with populated PM state.""" - result = generate_top5(populated_pm.parent) - assert len(result["top5"]) <= 5 - assert len(result["top5"]) > 0 - assert result["sources"]["backlog"] > 0 - assert result["sources"]["workstream"] > 0 - assert result["sources"]["delegation"] > 0 - assert result["sources"]["roadmap_goals"] > 0 - assert result["total_candidates"] > 0 - - def test_items_have_required_fields(self, populated_pm): + def test_no_sources_no_pm(self, project_root): + """Returns empty results when no sources and no .pm/.""" + with patch("generate_top5.get_current_gh_account", return_value="rysweet"): + result = generate_top5(project_root) + assert result["top5"] == [] + assert result["total_candidates"] == 0 + + def test_github_failure_falls_back_to_local(self, populated_pm): + """Still returns local items when GitHub is unavailable.""" + # Write a minimal sources.yaml + sources = {"github": [{"account": "test", "repos": ["test/repo"]}]} + sources_path = populated_pm / "sources.yaml" + with open(sources_path, "w") as f: + yaml.dump(sources, f) + + with patch("generate_top5.run_gh", return_value=None), \ + patch("generate_top5.get_current_gh_account", return_value="test"): + result = generate_top5(populated_pm.parent, sources_path) + # Should still have local items from populated_pm + assert result["sources"]["local_items"] > 0 + assert result["sources"]["github_issues"] == 0 + + def test_items_have_required_fields(self): """Each top5 item has all required fields.""" - result = generate_top5(populated_pm.parent) - required_fields = {"rank", "title", "source", "score", "rationale", "priority"} - for item in result["top5"]: - assert required_fields.issubset(item.keys()), f"Missing fields: {required_fields - item.keys()}" - - def test_ranks_sequential(self, populated_pm): - """Ranks are sequential starting from 1.""" - result = generate_top5(populated_pm.parent) - ranks = [item["rank"] for item in result["top5"]] - assert ranks == list(range(1, len(ranks) + 1)) - - def test_scores_descending(self, populated_pm): - """Scores are in descending order.""" - result = generate_top5(populated_pm.parent) - scores = [item["score"] for item in result["top5"]] - assert scores == sorted(scores, reverse=True) + issues = [ + {"title": "Test", "source": "github_issue", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "HIGH", + "url": "https://github.com/r/a/issues/1", "repo": "r/a"}, + ] + with patch("generate_top5.load_sources", return_value=[]), \ + patch("generate_top5.get_current_gh_account", return_value="test"): + # Directly test aggregation output format + result = aggregate_and_rank(issues, [], [], []) + required = {"rank", "title", "source", "score", "rationale", "priority"} + for item in result: + assert required.issubset(item.keys()) + + +class TestPriorityLabels: + """Tests for label-to-priority mapping.""" + + def test_critical_is_highest(self): + assert PRIORITY_LABELS["critical"] == 1.0 + assert PRIORITY_LABELS["priority:critical"] == 1.0 + + def test_bug_is_high(self): + assert PRIORITY_LABELS["bug"] == 0.8 + + def test_enhancement_is_medium(self): + assert PRIORITY_LABELS["enhancement"] == 0.5 diff --git a/.pm/sources.yaml b/.pm/sources.yaml new file mode 100644 index 000000000..41f4733a8 --- /dev/null +++ b/.pm/sources.yaml @@ -0,0 +1,18 @@ +github: + - account: rysweet + repos: + - amplihack + - azlin + - agent-haymaker + - haymaker-azure-workloads + - haymaker-m365-workloads + - haymaker-workload-starter + - amplihack-agent-eval + - amplihack-memory-lib + - azure-tenant-grapher + - agent-kgpacks + - gadugi-agentic-test + - account: rysweet_microsoft + repos: + - cloud-ecosystem-security/SedanDelivery + - cloud-ecosystem-security/cybergym From 758a7fb3403f1d4127e437473ca05cfe412fe45b Mon Sep 17 00:00:00 2001 From: rysweet_microsoft Date: Sat, 7 Mar 2026 19:02:42 -0800 Subject: [PATCH 3/9] fix: address review findings in /top5 implementation - Remove dead `env` variable and unused comment in run_gh() - Simplify get_current_gh_account() from fragile stderr parsing to `gh api user --jq` - Fix lstrip("-* ") bug in extract_roadmap_goals() using removeprefix() - Rename shadow variable `l` to `lbl` in list comprehensions for readability - Fix SKILL.md weight documentation to match actual code (40/30/20/10) Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/pm-architect/SKILL.md | 4 +- .../pm-architect/scripts/generate_top5.py | 44 +++++------- .pm/backlog/items.yaml | 72 +++++++++++++++++++ .pm/config.yaml | 9 +++ .pm/delegations/del-001.yaml | 11 +++ .pm/delegations/del-002.yaml | 11 +++ .pm/roadmap.md | 18 +++++ .pm/workstreams/ws-001.yaml | 14 ++++ .pm/workstreams/ws-002.yaml | 13 ++++ 9 files changed, 167 insertions(+), 29 deletions(-) create mode 100644 .pm/backlog/items.yaml create mode 100644 .pm/config.yaml create mode 100644 .pm/delegations/del-001.yaml create mode 100644 .pm/delegations/del-002.yaml create mode 100644 .pm/roadmap.md create mode 100644 .pm/workstreams/ws-001.yaml create mode 100644 .pm/workstreams/ws-002.yaml diff --git a/.claude/skills/pm-architect/SKILL.md b/.claude/skills/pm-architect/SKILL.md index 392fc1b41..6b5480872 100644 --- a/.claude/skills/pm-architect/SKILL.md +++ b/.claude/skills/pm-architect/SKILL.md @@ -75,9 +75,9 @@ Create .pm/ structure, invoke roadmap-strategist for roadmap generation. ### Pattern 5: Top 5 Priorities (`/top5`) -Run `scripts/generate_top5.py` to aggregate priorities across all four sub-skills into a strict ranked list. Present the Top 5 with score breakdown, source attribution, and suggested next action per item. +Run `scripts/generate_top5.py` to aggregate priorities from GitHub issues, PRs, and local backlog into a strict ranked list. Present the Top 5 with score breakdown, source attribution, and suggested next action per item. -Weights: backlog 35%, workstream urgency 25%, roadmap alignment 25%, delegation readiness 15%. +Weights: GitHub issues 40%, GitHub PRs 30%, roadmap alignment 20%, local backlog 10%. ### Pattern 6: Daily Standup diff --git a/.claude/skills/pm-architect/scripts/generate_top5.py b/.claude/skills/pm-architect/scripts/generate_top5.py index 9f1c84c58..da031675c 100644 --- a/.claude/skills/pm-architect/scripts/generate_top5.py +++ b/.claude/skills/pm-architect/scripts/generate_top5.py @@ -66,9 +66,7 @@ def run_gh(args: list[str], account: str | None = None) -> str | None: Returns stdout on success, None on failure. """ - env = None if account: - # gh respects GH_TOKEN but switching is cleaner switch = subprocess.run( ["gh", "auth", "switch", "--user", account], capture_output=True, text=True, timeout=10, @@ -80,7 +78,6 @@ def run_gh(args: list[str], account: str | None = None) -> str | None: result = subprocess.run( ["gh"] + args, capture_output=True, text=True, timeout=30, - env=env, ) if result.returncode != 0: return None @@ -91,24 +88,15 @@ def run_gh(args: list[str], account: str | None = None) -> str | None: def get_current_gh_account() -> str | None: """Get the currently active gh account.""" - result = subprocess.run( - ["gh", "auth", "status"], - capture_output=True, text=True, timeout=10, - ) - for line in result.stderr.splitlines() + result.stdout.splitlines(): - if "Active account: true" in line: - # The account name is on the previous line - pass - if "Logged in to" in line and "Active account" not in line: - # Parse: "Logged in to github.com account USERNAME" - parts = line.strip().split("account ") - if len(parts) >= 2: - account = parts[1].split(" ")[0].strip() - # Check if next line says active - idx = (result.stderr + result.stdout).find(line) - remaining = (result.stderr + result.stdout)[idx + len(line):] - if "Active account: true" in remaining.split("\n")[0:2]: - return account + try: + result = subprocess.run( + ["gh", "api", "user", "--jq", ".login"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError): + pass return None @@ -151,7 +139,7 @@ def fetch_github_issues(account: str, repos: list[str]) -> list[dict]: continue # Score by labels - labels = [l.lower() for l in item.get("labels", [])] + labels = [lbl.lower() for lbl in item.get("labels", [])] priority_score = 0.5 # default for label in labels: if label in PRIORITY_LABELS: @@ -175,7 +163,7 @@ def fetch_github_issues(account: str, repos: list[str]) -> list[dict]: # Rationale reasons = [] if priority_score >= 0.8: - reasons.append(f"labeled {', '.join(l for l in labels if l in PRIORITY_LABELS)}") + reasons.append(f"labeled {', '.join(lbl for lbl in labels if lbl in PRIORITY_LABELS)}") if days_stale > 7: reasons.append(f"stale {days_stale:.0f}d") if comments > 3: @@ -242,7 +230,7 @@ def fetch_github_prs(account: str, repos: list[str]) -> list[dict]: base_score = 0.4 if is_draft else 0.7 # Labels boost - labels = [l.lower() for l in item.get("labels", [])] + labels = [lbl.lower() for lbl in item.get("labels", [])] for label in labels: if label in PRIORITY_LABELS: base_score = max(base_score, PRIORITY_LABELS[label]) @@ -266,7 +254,7 @@ def fetch_github_prs(account: str, repos: list[str]) -> list[dict]: if days_stale > 3: reasons.append(f"stale {days_stale:.0f}d") if labels: - relevant = [l for l in labels if l in PRIORITY_LABELS] + relevant = [lbl for lbl in labels if lbl in PRIORITY_LABELS] if relevant: reasons.append(f"labeled {', '.join(relevant)}") @@ -335,8 +323,10 @@ def extract_roadmap_goals(pm_dir: Path) -> list[str]: line = line.strip() if line.startswith("## ") or line.startswith("### "): goals.append(line.lstrip("#").strip()) - elif line.startswith("- ") or line.startswith("* "): - goals.append(line.lstrip("-* ").strip()) + elif line.startswith("- "): + goals.append(line.removeprefix("- ").strip()) + elif line.startswith("* "): + goals.append(line.removeprefix("* ").strip()) return goals diff --git a/.pm/backlog/items.yaml b/.pm/backlog/items.yaml new file mode 100644 index 000000000..490616c5a --- /dev/null +++ b/.pm/backlog/items.yaml @@ -0,0 +1,72 @@ +items: + - id: BL-001 + title: "Implement /standup skill wrapper in pm-architect" + description: "Wrap existing generate_daily_status.py as Pattern 6 in SKILL.md" + priority: HIGH + estimated_hours: 2 + status: READY + tags: [pm-architect, standup] + dependencies: [] + + - id: BL-002 + title: "WorkIQ bridge for pm-architect" + description: "Connect WorkIQ MCP to pm-architect workflows for M365 data enrichment" + priority: HIGH + estimated_hours: 8 + status: READY + tags: [pm-architect, workiq, m365] + dependencies: [] + + - id: BL-003 + title: "Cross-project .pm/ state aggregation" + description: "Aggregate backlog and workstreams across multiple repos" + priority: MEDIUM + estimated_hours: 6 + status: READY + tags: [pm-architect, cross-project] + dependencies: [BL-001] + + - id: BL-004 + title: "Fix recipe runner tmux detection on WSL" + description: "tmux sessions not detected correctly under WSL2" + priority: HIGH + estimated_hours: 1 + status: READY + tags: [recipe-runner, bug, wsl] + dependencies: [] + + - id: BL-005 + title: "Add delegation history tracking" + description: "Track delegation outcomes for velocity estimation" + priority: LOW + estimated_hours: 4 + status: READY + tags: [pm-architect, delegation] + dependencies: [BL-002] + + - id: BL-006 + title: "Implement Haymaker event bus integration tests" + description: "E2E tests for the event bus orchestrator" + priority: MEDIUM + estimated_hours: 3 + status: IN_PROGRESS + tags: [haymaker, testing] + dependencies: [] + + - id: BL-007 + title: "Azure tenant grapher Neo4j query optimization" + description: "Slow Cypher queries on large tenant graphs" + priority: MEDIUM + estimated_hours: 5 + status: READY + tags: [azure-tenant-grapher, performance] + dependencies: [] + + - id: BL-008 + title: "Upgrade amplihack-memory-lib to RyuGraph v2" + description: "Migrate from Kuzu to RyuGraph embedded database" + priority: LOW + estimated_hours: 12 + status: READY + tags: [memory, migration] + dependencies: [] diff --git a/.pm/config.yaml b/.pm/config.yaml new file mode 100644 index 000000000..3a6635b81 --- /dev/null +++ b/.pm/config.yaml @@ -0,0 +1,9 @@ +project_name: amplihack +project_type: cli-tool +primary_goals: + - Ship /top5 and /standup as pm-architect capabilities + - Integrate WorkIQ M365 data into priority scoring + - Support cross-project priority aggregation +quality_bar: balanced +initialized_at: "2026-03-07T15:00:00Z" +version: "0.1.0" diff --git a/.pm/delegations/del-001.yaml b/.pm/delegations/del-001.yaml new file mode 100644 index 000000000..7ef2e3fef --- /dev/null +++ b/.pm/delegations/del-001.yaml @@ -0,0 +1,11 @@ +id: DEL-001 +title: "Implement /standup wrapper" +backlog_id: BL-001 +status: READY +agent: builder +created_at: "2026-03-07T14:00:00Z" +context: + files: + - .claude/skills/pm-architect/scripts/generate_daily_status.py + - .claude/skills/pm-architect/SKILL.md + instructions: "Add Pattern 6 to SKILL.md and wire generate_daily_status.py as /standup trigger" diff --git a/.pm/delegations/del-002.yaml b/.pm/delegations/del-002.yaml new file mode 100644 index 000000000..644c0591d --- /dev/null +++ b/.pm/delegations/del-002.yaml @@ -0,0 +1,11 @@ +id: DEL-002 +title: "WorkIQ bridge design" +backlog_id: BL-002 +status: PENDING +agent: architect +created_at: "2026-03-07T14:30:00Z" +context: + files: + - .claude/skills/work-iq/SKILL.md + - .claude/skills/pm-architect/SKILL.md + instructions: "Design the bridge between WorkIQ MCP and pm-architect state" diff --git a/.pm/roadmap.md b/.pm/roadmap.md new file mode 100644 index 000000000..114705c7a --- /dev/null +++ b/.pm/roadmap.md @@ -0,0 +1,18 @@ +# amplihack Roadmap + +## Q1 2026 Goals + +### PM System Enhancement +- Ship /top5 and /standup as pm-architect capabilities +- WorkIQ M365 integration for calendar-aware prioritization +- Cross-project coordination support + +### Platform Stability +- Fix WSL tmux detection issues +- Recipe runner reliability improvements +- Session tree depth management + +### Ecosystem Growth +- Haymaker event bus integration +- Azure tenant grapher performance +- Memory system migration to RyuGraph diff --git a/.pm/workstreams/ws-001.yaml b/.pm/workstreams/ws-001.yaml new file mode 100644 index 000000000..df38918de --- /dev/null +++ b/.pm/workstreams/ws-001.yaml @@ -0,0 +1,14 @@ +id: ws-001 +backlog_id: BL-006 +title: "Haymaker Event Bus Integration Tests" +status: RUNNING +agent: builder +started_at: "2026-03-07T10:00:00Z" +completed_at: null +process_id: "tmux-haymaker-tests" +elapsed_minutes: 180 +progress_notes: + - "Started test scaffolding" + - "Event bus mock created" +last_activity: "2026-03-07T11:00:00Z" +dependencies: [] diff --git a/.pm/workstreams/ws-002.yaml b/.pm/workstreams/ws-002.yaml new file mode 100644 index 000000000..851ced836 --- /dev/null +++ b/.pm/workstreams/ws-002.yaml @@ -0,0 +1,13 @@ +id: ws-002 +backlog_id: BL-004 +title: "Fix WSL tmux detection" +status: RUNNING +agent: fix-agent +started_at: "2026-03-07T14:00:00Z" +completed_at: null +process_id: "local" +elapsed_minutes: 30 +progress_notes: + - "Investigating tmux session listing under WSL2" +last_activity: "2026-03-07T15:20:00Z" +dependencies: [] From 34351892d32e9d32456c1a924e8ef9973089fef5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 8 Mar 2026 03:03:12 +0000 Subject: [PATCH 4/9] [skip ci] chore: Auto-bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2d2cecdb8..2f85e6867 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ backend-path = ["."] [project] name = "amplihack" -version = "0.5.118" +version = "0.5.119" description = "Amplifier bundle for agentic coding with comprehensive skills, recipes, and workflows" requires-python = ">=3.11" dependencies = [ From c4b607f475d6cb7746f1548ab31c5029c02a50f3 Mon Sep 17 00:00:00 2001 From: rysweet_microsoft Date: Sat, 7 Mar 2026 19:09:49 -0800 Subject: [PATCH 5/9] test: add outside-in agentic test scenarios for /top5 trigger Five gadugi-agentic-test YAML scenarios covering: - Smoke test: valid JSON output with expected keys - GitHub source aggregation: end-to-end with real sources.yaml - Error handling: malformed YAML, missing dirs, empty sources - Local overrides: .pm/backlog + roadmap alignment scoring - Ranking enforcement: top-5 limit, rank fields 1-5, score ordering Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agentic/test-top5-error-handling.yaml | 88 +++++++++++++++ .../agentic/test-top5-local-overrides.yaml | 97 ++++++++++++++++ .../tests/agentic/test-top5-ranking.yaml | 104 ++++++++++++++++++ .../tests/agentic/test-top5-smoke.yaml | 52 +++++++++ .../tests/agentic/test-top5-with-sources.yaml | 83 ++++++++++++++ 5 files changed, 424 insertions(+) create mode 100644 .claude/skills/pm-architect/scripts/tests/agentic/test-top5-error-handling.yaml create mode 100644 .claude/skills/pm-architect/scripts/tests/agentic/test-top5-local-overrides.yaml create mode 100644 .claude/skills/pm-architect/scripts/tests/agentic/test-top5-ranking.yaml create mode 100644 .claude/skills/pm-architect/scripts/tests/agentic/test-top5-smoke.yaml create mode 100644 .claude/skills/pm-architect/scripts/tests/agentic/test-top5-with-sources.yaml diff --git a/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-error-handling.yaml b/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-error-handling.yaml new file mode 100644 index 000000000..1e281b6e1 --- /dev/null +++ b/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-error-handling.yaml @@ -0,0 +1,88 @@ +# Outside-in test for /top5 error handling +# Validates generate_top5.py handles failure modes gracefully: +# invalid sources, missing gh CLI, malformed YAML. + +scenario: + name: "Top 5 Priorities - Error Handling" + description: | + Verifies that generate_top5.py degrades gracefully when GitHub is + unreachable, sources.yaml is malformed, or the project root is invalid. + The script should always return valid JSON, never crash. + type: cli + level: 2 + tags: [cli, error-handling, top5, pm-architect, resilience] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + + steps: + # Test 1: Non-existent project root (no .pm/ dir) + - action: launch + target: "python" + args: + - ".claude/skills/pm-architect/scripts/generate_top5.py" + - "--project-root" + - "/tmp/top5-test-nonexistent-path-12345" + description: "Run with non-existent project root" + timeout: 15s + + - action: verify_output + contains: '"top5"' + description: "Still returns valid JSON with top5 key" + + - action: verify_output + contains: '"total_candidates": 0' + description: "Reports zero candidates when no sources available" + + - action: verify_exit_code + expected: 0 + description: "Exits cleanly even with missing project root" + + # Test 2: Malformed sources.yaml + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm" && + echo "not: [valid: yaml: {{{{" > "$TMPDIR/.pm/sources.yaml" && + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run with malformed sources.yaml" + timeout: 15s + + # Script should handle YAML parse errors (may return error JSON or empty results) + - action: verify_exit_code + expected_one_of: [0, 1] + description: "Exits with 0 or 1, never crashes with traceback" + + # Test 3: Empty sources.yaml (valid but no accounts) + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm" && + echo "github: []" > "$TMPDIR/.pm/sources.yaml" && + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run with empty sources list" + timeout: 15s + + - action: verify_output + contains: '"top5"' + description: "Returns valid JSON with empty top5" + + - action: verify_output + contains: '"total_candidates": 0' + description: "Reports zero candidates" + + - action: verify_exit_code + expected: 0 + description: "Exits cleanly with empty sources" + + cleanup: + - action: stop_application + force: true + description: "Ensure all processes are terminated" diff --git a/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-local-overrides.yaml b/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-local-overrides.yaml new file mode 100644 index 000000000..3d763c94b --- /dev/null +++ b/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-local-overrides.yaml @@ -0,0 +1,97 @@ +# Outside-in test for /top5 local backlog integration +# Validates generate_top5.py incorporates .pm/backlog items and roadmap goals +# into the priority ranking alongside (or instead of) GitHub data. + +scenario: + name: "Top 5 Priorities - Local Overrides and Roadmap Alignment" + description: | + Verifies that generate_top5.py reads .pm/backlog/items.yaml for local + priorities and .pm/roadmap.md for strategic alignment scoring. + Tests the full aggregation pipeline without requiring GitHub access. + type: cli + level: 2 + tags: [cli, integration, top5, pm-architect, local, roadmap] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + + steps: + # Setup local .pm/ state with backlog items and roadmap + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm/backlog" && + cat > "$TMPDIR/.pm/backlog/items.yaml" << 'BACKLOG' + items: + - id: "local-001" + title: "Fix authentication timeout bug" + status: "READY" + priority: "HIGH" + estimated_hours: 1 + - id: "local-002" + title: "Add dashboard metrics" + status: "READY" + priority: "MEDIUM" + estimated_hours: 4 + - id: "local-003" + title: "Refactor logging module" + status: "IN_PROGRESS" + priority: "LOW" + estimated_hours: 8 + BACKLOG + cat > "$TMPDIR/.pm/roadmap.md" << 'ROADMAP' + ## Q1 Goals + ### Improve authentication reliability + - Fix timeout and retry logic + ### Add observability dashboard + - Metrics and monitoring + ROADMAP + cat > "$TMPDIR/.pm/sources.yaml" << 'SOURCES' + github: [] + SOURCES + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run with local backlog items and roadmap goals, no GitHub sources" + timeout: 15s + + # Verify local items appear in output + - action: verify_output + contains: "Fix authentication timeout bug" + timeout: 5s + description: "HIGH priority READY item appears in results" + + - action: verify_output + contains: "Add dashboard metrics" + description: "MEDIUM priority READY item appears in results" + + # IN_PROGRESS items should NOT appear (only READY items are loaded) + - action: verify_output + not_contains: "Refactor logging module" + description: "IN_PROGRESS item is excluded (only READY items loaded)" + + # Verify source attribution + - action: verify_output + contains: '"source": "local"' + description: "Items attributed to local source" + + # Verify roadmap goals were loaded + - action: verify_output + contains: '"roadmap_goals"' + description: "Roadmap goals count present in sources" + + # Verify alignment scoring (auth bug should align with roadmap goal) + - action: verify_output + matches: '"alignment":\\s*[0-9]' + description: "Items have alignment scores" + + - action: verify_exit_code + expected: 0 + description: "Script exits cleanly" + + cleanup: + - action: stop_application + force: true + description: "Ensure process is terminated" diff --git a/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-ranking.yaml b/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-ranking.yaml new file mode 100644 index 000000000..4fac284f8 --- /dev/null +++ b/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-ranking.yaml @@ -0,0 +1,104 @@ +# Outside-in test for /top5 ranking correctness +# Validates that output is strictly ranked by score descending, +# limited to 5 items, and each item has a rank field 1-5. + +scenario: + name: "Top 5 Priorities - Ranking and Limit Enforcement" + description: | + Verifies that generate_top5.py returns exactly TOP_N (5) items, + ranked by descending score, with rank fields 1 through 5. + Uses local backlog with >5 items to verify the limit. + type: cli + level: 2 + tags: [cli, integration, top5, pm-architect, ranking] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + + steps: + # Setup: 7 local items to verify only top 5 are returned + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm/backlog" && + cat > "$TMPDIR/.pm/backlog/items.yaml" << 'BACKLOG' + items: + - id: "item-1" + title: "Critical security fix" + status: "READY" + priority: "HIGH" + estimated_hours: 1 + - id: "item-2" + title: "API rate limiting" + status: "READY" + priority: "HIGH" + estimated_hours: 2 + - id: "item-3" + title: "Database migration" + status: "READY" + priority: "MEDIUM" + estimated_hours: 4 + - id: "item-4" + title: "Update documentation" + status: "READY" + priority: "MEDIUM" + estimated_hours: 6 + - id: "item-5" + title: "Add unit tests" + status: "READY" + priority: "MEDIUM" + estimated_hours: 3 + - id: "item-6" + title: "Refactor config loader" + status: "READY" + priority: "LOW" + estimated_hours: 8 + - id: "item-7" + title: "Add logging headers" + status: "READY" + priority: "LOW" + estimated_hours: 10 + BACKLOG + cat > "$TMPDIR/.pm/sources.yaml" << 'SOURCES' + github: [] + SOURCES + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run with 7 local items to verify top-5 limit" + timeout: 15s + + # Verify rank fields 1-5 exist + - action: verify_output + contains: '"rank": 1' + timeout: 5s + description: "First ranked item present" + + - action: verify_output + contains: '"rank": 5' + description: "Fifth ranked item present" + + # Verify rank 6 and 7 are NOT in output (limit enforced) + - action: verify_output + not_contains: '"rank": 6' + description: "No sixth rank (limit to 5)" + + - action: verify_output + not_contains: '"rank": 7' + description: "No seventh rank (limit to 5)" + + # Verify total_candidates reflects all 7 items considered + - action: verify_output + contains: '"total_candidates": 7' + description: "Total candidates count includes all 7 items" + + - action: verify_exit_code + expected: 0 + description: "Script exits cleanly" + + cleanup: + - action: stop_application + force: true + description: "Ensure process is terminated" diff --git a/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-smoke.yaml b/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-smoke.yaml new file mode 100644 index 000000000..d3d8ee24c --- /dev/null +++ b/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-smoke.yaml @@ -0,0 +1,52 @@ +# Outside-in smoke test for /top5 priority aggregation +# Validates the generate_top5.py CLI produces valid JSON output +# with the expected structure from a user's perspective. + +scenario: + name: "Top 5 Priorities - Smoke Test" + description: | + Verifies that generate_top5.py runs successfully, produces valid JSON, + and contains the expected top-level keys (top5, sources, total_candidates). + Uses an empty project root so no GitHub calls are made. + type: cli + level: 1 + tags: [cli, smoke, top5, pm-architect] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + + steps: + # Run with empty project root (no .pm/ dir, no sources.yaml) + - action: launch + target: "python" + args: + - "scripts/generate_top5.py" + - "--project-root" + - "/tmp/top5-test-empty" + working_directory: ".claude/skills/pm-architect" + description: "Run generate_top5.py with empty project root" + timeout: 15s + + # Verify valid JSON output with expected keys + - action: verify_output + contains: '"top5"' + timeout: 5s + description: "Output contains top5 key" + + - action: verify_output + contains: '"sources"' + description: "Output contains sources key" + + - action: verify_output + contains: '"total_candidates"' + description: "Output contains total_candidates key" + + - action: verify_exit_code + expected: 0 + description: "Script exits cleanly with code 0" + + cleanup: + - action: stop_application + force: true + description: "Ensure process is terminated" diff --git a/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-with-sources.yaml b/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-with-sources.yaml new file mode 100644 index 000000000..a76e52d4a --- /dev/null +++ b/.claude/skills/pm-architect/scripts/tests/agentic/test-top5-with-sources.yaml @@ -0,0 +1,83 @@ +# Outside-in test for /top5 with configured sources +# Validates generate_top5.py queries GitHub when sources.yaml is provided, +# produces ranked output with score breakdown and source attribution. + +scenario: + name: "Top 5 Priorities - GitHub Source Aggregation" + description: | + Verifies that generate_top5.py correctly reads a sources.yaml config, + queries GitHub for issues and PRs, aggregates scores, and returns + a ranked list with proper source attribution and metadata. + Requires gh CLI authenticated with at least one account. + type: cli + level: 2 + tags: [cli, integration, top5, pm-architect, github] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + - "gh CLI is authenticated" + - "Network access to GitHub API" + + environment: + variables: + GH_PAGER: "" + + steps: + # Setup: create a minimal sources.yaml pointing to a known public repo + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm" && + cat > "$TMPDIR/.pm/sources.yaml" << 'SOURCES' + github: + - account: rysweet + repos: + - amplihack + SOURCES + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run generate_top5.py with sources pointing to amplihack repo" + timeout: 45s + + # Verify JSON structure + - action: verify_output + contains: '"top5"' + timeout: 5s + description: "Output has top5 array" + + - action: verify_output + contains: '"github_issues"' + description: "Sources breakdown includes github_issues count" + + - action: verify_output + contains: '"github_prs"' + description: "Sources breakdown includes github_prs count" + + - action: verify_output + contains: '"accounts"' + description: "Sources breakdown includes accounts queried" + + # Verify ranked items have required fields + - action: verify_output + matches: '"score":\\s*[0-9]' + description: "Items have numeric scores" + + - action: verify_output + matches: '"source":\\s*"github_(issue|pr)"' + description: "Items have source attribution" + + - action: verify_output + matches: '"rationale":' + description: "Items include rationale text" + + - action: verify_exit_code + expected: 0 + description: "Script exits cleanly" + + cleanup: + - action: stop_application + force: true + description: "Ensure process is terminated" From 0fa0293b8601e8bc7ded3d209747bc986d8d926d Mon Sep 17 00:00:00 2001 From: rysweet_microsoft Date: Sat, 7 Mar 2026 22:05:40 -0800 Subject: [PATCH 6/9] feat: enriched /top5 output with score breakdown, actions, near-misses, and repo summary The previous output was a flat ranked list with no context for decision-making. Now includes: - Score breakdown per item (label_priority, staleness, activity components) - Suggested action per item ("Merge, close, or rebase", "Fix immediately", etc.) - Near-misses (items #6-#10 that just missed the cut) - Per-repo summary (issue/PR counts, high-priority counts) - Per-account summary (total work across repos) - Full metadata preserved (labels, dates, days_stale, draft status, comments) 40 tests passing (up from 29), covering new features: - suggest_action logic for all source types - near_misses return from aggregate_and_rank - build_repo_summary grouping and counting Refs #2932 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pm-architect/scripts/generate_top5.py | 121 ++++++++- .../scripts/tests/test_generate_top5.py | 235 +++++++++++------- 2 files changed, 261 insertions(+), 95 deletions(-) diff --git a/.claude/skills/pm-architect/scripts/generate_top5.py b/.claude/skills/pm-architect/scripts/generate_top5.py index da031675c..829d3b0b1 100644 --- a/.claude/skills/pm-architect/scripts/generate_top5.py +++ b/.claude/skills/pm-architect/scripts/generate_top5.py @@ -173,14 +173,25 @@ def fetch_github_issues(account: str, repos: list[str]) -> list[dict]: repo = item.get("repo", "") candidates.append({ - "title": f"[{repo}#{item['number']}] {item['title']}", + "title": item["title"], "source": "github_issue", "raw_score": round(raw_score, 1), + "score_breakdown": { + "label_priority": round(priority_score, 2), + "staleness": round(staleness_score, 2), + "activity": round(activity_score, 2), + }, "rationale": ", ".join(reasons), "item_id": f"{repo}#{item['number']}", "priority": "HIGH" if priority_score >= 0.8 else "MEDIUM" if priority_score >= 0.5 else "LOW", "repo": repo, + "account": account, "url": f"https://github.com/{repo}/issues/{item['number']}", + "labels": item.get("labels", []), + "created": item.get("created", ""), + "updated": item.get("updated", ""), + "days_stale": round(days_stale, 1), + "comments": comments, }) return candidates @@ -260,14 +271,24 @@ def fetch_github_prs(account: str, repos: list[str]) -> list[dict]: repo = item.get("repo", "") candidates.append({ - "title": f"[{repo}#{item['number']}] {item['title']}", + "title": item["title"], "source": "github_pr", "raw_score": round(raw_score, 1), + "score_breakdown": { + "base_priority": round(base_score, 2), + "staleness": round(staleness_score, 2), + }, "rationale": ", ".join(reasons), "item_id": f"{repo}#{item['number']}", "priority": "HIGH" if base_score >= 0.8 else "MEDIUM", "repo": repo, + "account": account, "url": f"https://github.com/{repo}/pull/{item['number']}", + "labels": item.get("labels", []), + "created": item.get("created", ""), + "updated": item.get("updated", ""), + "days_stale": round(days_stale, 1), + "is_draft": is_draft, }) return candidates @@ -352,14 +373,44 @@ def score_roadmap_alignment(candidate: dict, goals: list[str]) -> float: return min(max_alignment, 1.0) +def suggest_action(candidate: dict) -> str: + """Suggest a concrete next action for a candidate.""" + source = candidate["source"] + days_stale = candidate.get("days_stale", 0) + labels = candidate.get("labels", []) + + if source == "github_pr": + if candidate.get("is_draft"): + return "Finish draft or close if abandoned" + if days_stale > 14: + return "Merge, close, or rebase — stale >2 weeks" + if days_stale > 7: + return "Review and merge or request changes" + return "Review PR" + elif source == "github_issue": + if any(lbl in ("critical", "priority:critical") for lbl in labels): + return "Fix immediately — critical severity" + if any(lbl in ("bug",) for lbl in labels): + return "Investigate and fix bug" + if days_stale > 30: + return "Triage: still relevant? Close or reprioritize" + return "Work on issue or delegate" + elif source == "local": + return "Pick up from local backlog" + return "Review" + + def aggregate_and_rank( issues: list[dict], prs: list[dict], local: list[dict], goals: list[str], top_n: int = TOP_N, -) -> list[dict]: - """Aggregate candidates from all sources and rank by weighted score.""" +) -> tuple[list[dict], list[dict]]: + """Aggregate candidates from all sources and rank by weighted score. + + Returns (top_n items, next 5 near-misses). + """ scored = [] source_weights = { @@ -382,15 +433,19 @@ def aggregate_and_rank( "title": candidate["title"], "source": candidate["source"], "score": round(final_score, 1), + "raw_score": candidate["raw_score"], + "source_weight": source_weight, "rationale": candidate["rationale"], "item_id": candidate.get("item_id", ""), "priority": candidate.get("priority", "MEDIUM"), "alignment": round(alignment, 2), + "action": suggest_action(candidate), } - if "url" in candidate: - entry["url"] = candidate["url"] - if "repo" in candidate: - entry["repo"] = candidate["repo"] + # Preserve all metadata from the candidate + for key in ("url", "repo", "account", "labels", "created", "updated", + "days_stale", "comments", "is_draft", "score_breakdown"): + if key in candidate: + entry[key] = candidate[key] scored.append(entry) @@ -401,7 +456,47 @@ def aggregate_and_rank( for i, item in enumerate(top): item["rank"] = i + 1 - return top + near_misses = scored[top_n:top_n + 5] + for i, item in enumerate(near_misses): + item["rank"] = top_n + i + 1 + + return top, near_misses + + +def build_repo_summary(all_candidates: list[dict]) -> dict: + """Build a per-repo, per-account summary of open work.""" + repos: dict[str, dict] = {} + accounts: dict[str, dict] = {} + + for c in all_candidates: + repo = c.get("repo", "local") + account = c.get("account", "local") + + if repo not in repos: + repos[repo] = {"issues": 0, "prs": 0, "high_priority": 0} + if account not in accounts: + accounts[account] = {"issues": 0, "prs": 0, "repos": set()} + + if c["source"] == "github_issue": + repos[repo]["issues"] += 1 + accounts[account]["issues"] += 1 + elif c["source"] == "github_pr": + repos[repo]["prs"] += 1 + accounts[account]["prs"] += 1 + + if c.get("priority") == "HIGH": + repos[repo]["high_priority"] += 1 + + accounts[account]["repos"].add(repo) + + # Convert sets to lists for JSON serialization + for a in accounts.values(): + a["repos"] = sorted(a["repos"]) + + # Sort repos by total open items descending + sorted_repos = dict(sorted(repos.items(), key=lambda x: -(x[1]["issues"] + x[1]["prs"]))) + + return {"by_repo": sorted_repos, "by_account": accounts} def generate_top5(project_root: Path, sources_path: Path | None = None) -> dict: @@ -445,10 +540,14 @@ def generate_top5(project_root: Path, sources_path: Path | None = None) -> dict: goals = extract_roadmap_goals(pm_dir) if pm_dir.exists() else [] # Aggregate and rank - top5 = aggregate_and_rank(all_issues, all_prs, local, goals) + all_candidates = all_issues + all_prs + local + top5, near_misses = aggregate_and_rank(all_issues, all_prs, local, goals) + summary = build_repo_summary(all_candidates) return { "top5": top5, + "near_misses": near_misses, + "summary": summary, "sources": { "github_issues": len(all_issues), "github_prs": len(all_prs), @@ -456,7 +555,7 @@ def generate_top5(project_root: Path, sources_path: Path | None = None) -> dict: "roadmap_goals": len(goals), "accounts": accounts_queried, }, - "total_candidates": len(all_issues) + len(all_prs) + len(local), + "total_candidates": len(all_candidates), } diff --git a/.claude/skills/pm-architect/scripts/tests/test_generate_top5.py b/.claude/skills/pm-architect/scripts/tests/test_generate_top5.py index cf67cb7c3..8928e6a21 100644 --- a/.claude/skills/pm-architect/scripts/tests/test_generate_top5.py +++ b/.claude/skills/pm-architect/scripts/tests/test_generate_top5.py @@ -12,6 +12,7 @@ from generate_top5 import ( PRIORITY_LABELS, aggregate_and_rank, + build_repo_summary, extract_roadmap_goals, fetch_github_issues, fetch_github_prs, @@ -19,6 +20,7 @@ load_local_overrides, load_sources, score_roadmap_alignment, + suggest_action, ) @@ -58,7 +60,7 @@ def test_returns_empty_on_gh_failure(self): assert result == [] def test_parses_issue_data(self): - """Correctly parses gh API JSON output.""" + """Correctly parses gh API JSON output with full metadata.""" mock_output = json.dumps({ "repo": "rysweet/amplihack", "title": "Fix auth bug", @@ -71,10 +73,15 @@ def test_parses_issue_data(self): with patch("generate_top5.run_gh", return_value=mock_output): result = fetch_github_issues("rysweet", ["amplihack"]) assert len(result) == 1 - assert result[0]["source"] == "github_issue" - assert result[0]["priority"] == "HIGH" - assert "bug" in result[0]["rationale"] - assert result[0]["url"] == "https://github.com/rysweet/amplihack/issues/123" + item = result[0] + assert item["source"] == "github_issue" + assert item["priority"] == "HIGH" + assert item["url"] == "https://github.com/rysweet/amplihack/issues/123" + assert item["account"] == "rysweet" + assert item["labels"] == ["bug", "high"] + assert item["comments"] == 5 + assert "score_breakdown" in item + assert "label_priority" in item["score_breakdown"] def test_priority_from_labels(self): """Labels correctly map to priority scores.""" @@ -86,7 +93,7 @@ def test_priority_from_labels(self): with patch("generate_top5.run_gh", return_value=mock_output): result = fetch_github_issues("r", ["a"]) assert result[0]["priority"] == "HIGH" - assert result[0]["raw_score"] >= 50.0 + assert result[0]["score_breakdown"]["label_priority"] == 1.0 def test_staleness_boosts_score(self): """Older issues score higher due to staleness.""" @@ -105,6 +112,7 @@ def test_staleness_boosts_score(self): stale_item = next(c for c in result if "Stale" in c["title"]) fresh_item = next(c for c in result if "Fresh" in c["title"]) assert stale_item["raw_score"] > fresh_item["raw_score"] + assert stale_item["days_stale"] > fresh_item["days_stale"] class TestFetchGithubPrs: @@ -133,17 +141,21 @@ def test_draft_pr_scores_lower(self): draft_item = next(c for c in result if "Draft" in c["title"]) ready_item = next(c for c in result if "Ready" in c["title"]) assert ready_item["raw_score"] > draft_item["raw_score"] + assert draft_item["is_draft"] is True + assert ready_item["is_draft"] is False - def test_pr_has_url(self): - """PRs include correct GitHub URL.""" + def test_pr_has_url_and_metadata(self): + """PRs include correct GitHub URL and metadata.""" mock = json.dumps({ - "repo": "rysweet/amplihack", "title": "Fix stuff", "labels": [], + "repo": "rysweet/amplihack", "title": "Fix stuff", "labels": ["bug"], "created": "2026-03-07T00:00:00Z", "updated": "2026-03-07T00:00:00Z", "number": 42, "draft": False, "comments": 0, }) with patch("generate_top5.run_gh", return_value=mock): result = fetch_github_prs("rysweet", ["amplihack"]) assert result[0]["url"] == "https://github.com/rysweet/amplihack/pull/42" + assert result[0]["account"] == "rysweet" + assert result[0]["labels"] == ["bug"] class TestLoadLocalOverrides: @@ -190,127 +202,183 @@ class TestScoreRoadmapAlignment: """Tests for roadmap alignment scoring.""" def test_no_goals_returns_neutral(self): - """Returns 0.5 when no goals defined.""" - candidate = {"title": "Something", "source": "github_issue"} - assert score_roadmap_alignment(candidate, []) == 0.5 + assert score_roadmap_alignment({"title": "X", "source": "github_issue"}, []) == 0.5 def test_matching_title_scores_high(self): - """Title matching goal words scores high.""" - candidate = {"title": "Implement config parser", "source": "github_issue"} - goals = ["config parser implementation"] - score = score_roadmap_alignment(candidate, goals) + score = score_roadmap_alignment( + {"title": "Implement config parser", "source": "github_issue"}, + ["config parser implementation"], + ) assert score > 0.0 def test_unrelated_title_scores_zero(self): - """Unrelated title scores zero.""" - candidate = {"title": "Fix authentication bug", "source": "github_issue"} - goals = ["database migration tool"] - score = score_roadmap_alignment(candidate, goals) + score = score_roadmap_alignment( + {"title": "Fix authentication bug", "source": "github_issue"}, + ["database migration tool"], + ) assert score == 0.0 +class TestSuggestAction: + """Tests for action suggestion logic.""" + + def test_critical_issue(self): + action = suggest_action({"source": "github_issue", "labels": ["critical"]}) + assert "immediately" in action.lower() + + def test_bug_issue(self): + action = suggest_action({"source": "github_issue", "labels": ["bug"], "days_stale": 1}) + assert "bug" in action.lower() + + def test_stale_pr(self): + action = suggest_action({"source": "github_pr", "is_draft": False, "days_stale": 20}) + assert "stale" in action.lower() or "merge" in action.lower() + + def test_draft_pr(self): + action = suggest_action({"source": "github_pr", "is_draft": True, "days_stale": 1}) + assert "draft" in action.lower() + + def test_local_item(self): + action = suggest_action({"source": "local"}) + assert "backlog" in action.lower() + + class TestAggregateAndRank: """Tests for the core aggregation and ranking logic.""" def test_empty_input(self): - """Returns empty list when no candidates.""" - result = aggregate_and_rank([], [], [], []) - assert result == [] + top, near = aggregate_and_rank([], [], [], []) + assert top == [] + assert near == [] def test_returns_max_5(self): - """Never returns more than 5 items.""" candidates = [ {"title": f"Item {i}", "source": "github_issue", "raw_score": float(100 - i), "rationale": "test", "item_id": f"#{i}", "priority": "MEDIUM"} for i in range(10) ] - result = aggregate_and_rank(candidates, [], [], []) - assert len(result) == 5 + top, near = aggregate_and_rank(candidates, [], [], []) + assert len(top) == 5 + assert len(near) == 5 def test_ranked_in_order(self): - """Items are ranked by descending score.""" issues = [ {"title": "Low", "source": "github_issue", "raw_score": 30.0, "rationale": "test", "item_id": "#1", "priority": "LOW"}, {"title": "High", "source": "github_issue", "raw_score": 90.0, "rationale": "test", "item_id": "#2", "priority": "HIGH"}, ] - result = aggregate_and_rank(issues, [], [], []) - assert result[0]["title"] == "High" - assert result[1]["title"] == "Low" + top, _ = aggregate_and_rank(issues, [], [], []) + assert top[0]["title"] == "High" + assert top[1]["title"] == "Low" def test_mixed_sources(self): - """Items from different sources are correctly weighted.""" - issues = [ - {"title": "Issue", "source": "github_issue", "raw_score": 80.0, - "rationale": "test", "item_id": "#1", "priority": "HIGH"}, - ] - prs = [ - {"title": "PR", "source": "github_pr", "raw_score": 80.0, - "rationale": "test", "item_id": "#2", "priority": "HIGH", - "url": "https://github.com/r/a/pull/2", "repo": "r/a"}, - ] - local = [ - {"title": "Local", "source": "local", "raw_score": 80.0, - "rationale": "test", "item_id": "BL-1", "priority": "MEDIUM"}, - ] - result = aggregate_and_rank(issues, prs, local, []) - # Issues (0.40) > PRs (0.30) > Local (0.10) with same raw score - assert result[0]["source"] == "github_issue" - assert result[1]["source"] == "github_pr" - assert result[2]["source"] == "local" + issues = [{"title": "Issue", "source": "github_issue", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "HIGH"}] + prs = [{"title": "PR", "source": "github_pr", "raw_score": 80.0, + "rationale": "test", "item_id": "#2", "priority": "HIGH", + "url": "https://github.com/r/a/pull/2", "repo": "r/a"}] + local = [{"title": "Local", "source": "local", "raw_score": 80.0, + "rationale": "test", "item_id": "BL-1", "priority": "MEDIUM"}] + top, _ = aggregate_and_rank(issues, prs, local, []) + assert top[0]["source"] == "github_issue" + assert top[1]["source"] == "github_pr" + assert top[2]["source"] == "local" def test_roadmap_alignment_boosts_score(self): - """Items matching roadmap goals get higher scores.""" issues = [ {"title": "Implement config parser", "source": "github_issue", "raw_score": 50.0, "rationale": "test", "item_id": "#1", "priority": "MEDIUM"}, {"title": "Fix random thing", "source": "github_issue", "raw_score": 50.0, "rationale": "test", "item_id": "#2", "priority": "MEDIUM"}, ] - goals = ["config parser implementation"] - result = aggregate_and_rank(issues, [], [], goals) - config_item = next(r for r in result if "config" in r["title"].lower()) - other_item = next(r for r in result if "random" in r["title"].lower()) + top, _ = aggregate_and_rank(issues, [], [], ["config parser implementation"]) + config_item = next(r for r in top if "config" in r["title"].lower()) + other_item = next(r for r in top if "random" in r["title"].lower()) assert config_item["score"] > other_item["score"] def test_tiebreak_by_priority(self): - """Equal scores are tiebroken by priority (HIGH first).""" issues = [ {"title": "Low priority", "source": "github_issue", "raw_score": 50.0, "rationale": "test", "item_id": "#1", "priority": "LOW"}, {"title": "High priority", "source": "github_issue", "raw_score": 50.0, "rationale": "test", "item_id": "#2", "priority": "HIGH"}, ] - result = aggregate_and_rank(issues, [], [], []) - assert result[0]["priority"] == "HIGH" - assert result[1]["priority"] == "LOW" + top, _ = aggregate_and_rank(issues, [], [], []) + assert top[0]["priority"] == "HIGH" + assert top[1]["priority"] == "LOW" def test_preserves_url_and_repo(self): - """URL and repo fields are preserved in output.""" - prs = [ - {"title": "PR", "source": "github_pr", "raw_score": 80.0, - "rationale": "test", "item_id": "#1", "priority": "MEDIUM", - "url": "https://github.com/r/a/pull/1", "repo": "r/a"}, + prs = [{"title": "PR", "source": "github_pr", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "MEDIUM", + "url": "https://github.com/r/a/pull/1", "repo": "r/a"}] + top, _ = aggregate_and_rank([], prs, [], []) + assert top[0]["url"] == "https://github.com/r/a/pull/1" + assert top[0]["repo"] == "r/a" + + def test_items_have_action(self): + issues = [{"title": "Bug", "source": "github_issue", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "HIGH", + "labels": ["bug"], "days_stale": 5}] + top, _ = aggregate_and_rank(issues, [], [], []) + assert "action" in top[0] + assert len(top[0]["action"]) > 0 + + def test_near_misses_returned(self): + candidates = [ + {"title": f"Item {i}", "source": "github_issue", "raw_score": float(100 - i), + "rationale": "test", "item_id": f"#{i}", "priority": "MEDIUM"} + for i in range(8) + ] + top, near = aggregate_and_rank(candidates, [], [], []) + assert len(top) == 5 + assert len(near) == 3 + assert near[0]["rank"] == 6 + + +class TestBuildRepoSummary: + """Tests for per-repo summary generation.""" + + def test_empty_candidates(self): + result = build_repo_summary([]) + assert result["by_repo"] == {} + assert result["by_account"] == {} + + def test_counts_by_repo(self): + candidates = [ + {"source": "github_issue", "repo": "r/a", "account": "x", "priority": "HIGH"}, + {"source": "github_issue", "repo": "r/a", "account": "x", "priority": "MEDIUM"}, + {"source": "github_pr", "repo": "r/a", "account": "x", "priority": "MEDIUM"}, + {"source": "github_issue", "repo": "r/b", "account": "x", "priority": "HIGH"}, ] - result = aggregate_and_rank([], prs, [], []) - assert result[0]["url"] == "https://github.com/r/a/pull/1" - assert result[0]["repo"] == "r/a" + result = build_repo_summary(candidates) + assert result["by_repo"]["r/a"]["issues"] == 2 + assert result["by_repo"]["r/a"]["prs"] == 1 + assert result["by_repo"]["r/a"]["high_priority"] == 1 + assert result["by_repo"]["r/b"]["issues"] == 1 + + def test_counts_by_account(self): + candidates = [ + {"source": "github_issue", "repo": "r/a", "account": "alice", "priority": "HIGH"}, + {"source": "github_pr", "repo": "r/b", "account": "bob", "priority": "MEDIUM"}, + ] + result = build_repo_summary(candidates) + assert result["by_account"]["alice"]["issues"] == 1 + assert result["by_account"]["bob"]["prs"] == 1 + assert "r/a" in result["by_account"]["alice"]["repos"] class TestGenerateTop5: """Tests for the main generate_top5 function.""" def test_no_sources_no_pm(self, project_root): - """Returns empty results when no sources and no .pm/.""" with patch("generate_top5.get_current_gh_account", return_value="rysweet"): result = generate_top5(project_root) assert result["top5"] == [] + assert result["near_misses"] == [] assert result["total_candidates"] == 0 def test_github_failure_falls_back_to_local(self, populated_pm): - """Still returns local items when GitHub is unavailable.""" - # Write a minimal sources.yaml sources = {"github": [{"account": "test", "repos": ["test/repo"]}]} sources_path = populated_pm / "sources.yaml" with open(sources_path, "w") as f: @@ -319,24 +387,24 @@ def test_github_failure_falls_back_to_local(self, populated_pm): with patch("generate_top5.run_gh", return_value=None), \ patch("generate_top5.get_current_gh_account", return_value="test"): result = generate_top5(populated_pm.parent, sources_path) - # Should still have local items from populated_pm assert result["sources"]["local_items"] > 0 assert result["sources"]["github_issues"] == 0 + def test_output_has_summary(self): + with patch("generate_top5.get_current_gh_account", return_value="test"), \ + patch("generate_top5.load_sources", return_value=[]): + result = generate_top5(Path("/nonexistent")) + assert "summary" in result + assert "near_misses" in result + def test_items_have_required_fields(self): - """Each top5 item has all required fields.""" - issues = [ - {"title": "Test", "source": "github_issue", "raw_score": 80.0, - "rationale": "test", "item_id": "#1", "priority": "HIGH", - "url": "https://github.com/r/a/issues/1", "repo": "r/a"}, - ] - with patch("generate_top5.load_sources", return_value=[]), \ - patch("generate_top5.get_current_gh_account", return_value="test"): - # Directly test aggregation output format - result = aggregate_and_rank(issues, [], [], []) - required = {"rank", "title", "source", "score", "rationale", "priority"} - for item in result: - assert required.issubset(item.keys()) + issues = [{"title": "Test", "source": "github_issue", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "HIGH", + "url": "https://github.com/r/a/issues/1", "repo": "r/a"}] + top, _ = aggregate_and_rank(issues, [], [], []) + required = {"rank", "title", "source", "score", "rationale", "priority", "action", "alignment"} + for item in top: + assert required.issubset(item.keys()), f"Missing: {required - item.keys()}" class TestPriorityLabels: @@ -344,7 +412,6 @@ class TestPriorityLabels: def test_critical_is_highest(self): assert PRIORITY_LABELS["critical"] == 1.0 - assert PRIORITY_LABELS["priority:critical"] == 1.0 def test_bug_is_high(self): assert PRIORITY_LABELS["bug"] == 0.8 From 07ead8c4012f425fa42990bcf21aeb8470162f09 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 8 Mar 2026 19:41:04 +0000 Subject: [PATCH 7/9] fix: sync skill mirrors and remove ephemeral .pm/ state files - Sync all new pm-architect files to amplifier-bundle/skills/ and docs/claude/skills/ (fixes "Check skill/agent drift" CI failure) - Remove .pm/ ephemeral state files (backlog, workstreams, delegations, config) that Repo Guardian correctly flagged as point-in-time state - Add .pm/ to .gitignore to prevent future accidental commits Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + .pm/backlog/items.yaml | 72 --- .pm/config.yaml | 9 - .pm/delegations/del-001.yaml | 11 - .pm/delegations/del-002.yaml | 11 - .pm/roadmap.md | 18 - .pm/sources.yaml | 18 - .pm/workstreams/ws-001.yaml | 14 - .pm/workstreams/ws-002.yaml | 13 - amplifier-bundle/skills/pm-architect/SKILL.md | 104 ++++ .../pm-architect/scripts/generate_top5.py | 584 ++++++++++++++++++ .../agentic/test-top5-error-handling.yaml | 88 +++ .../agentic/test-top5-local-overrides.yaml | 97 +++ .../tests/agentic/test-top5-ranking.yaml | 104 ++++ .../tests/agentic/test-top5-smoke.yaml | 52 ++ .../tests/agentic/test-top5-with-sources.yaml | 83 +++ .../pm-architect/scripts/tests/conftest.py | 127 ++++ .../scripts/tests/test_generate_top5.py | 420 +++++++++++++ docs/claude/skills/pm-architect/SKILL.md | 104 ++++ .../pm-architect/scripts/generate_top5.py | 584 ++++++++++++++++++ .../agentic/test-top5-error-handling.yaml | 88 +++ .../agentic/test-top5-local-overrides.yaml | 97 +++ .../tests/agentic/test-top5-ranking.yaml | 104 ++++ .../tests/agentic/test-top5-smoke.yaml | 52 ++ .../tests/agentic/test-top5-with-sources.yaml | 83 +++ .../pm-architect/scripts/tests/conftest.py | 127 ++++ .../scripts/tests/test_generate_top5.py | 420 +++++++++++++ 27 files changed, 3319 insertions(+), 166 deletions(-) delete mode 100644 .pm/backlog/items.yaml delete mode 100644 .pm/config.yaml delete mode 100644 .pm/delegations/del-001.yaml delete mode 100644 .pm/delegations/del-002.yaml delete mode 100644 .pm/roadmap.md delete mode 100644 .pm/sources.yaml delete mode 100644 .pm/workstreams/ws-001.yaml delete mode 100644 .pm/workstreams/ws-002.yaml create mode 100644 amplifier-bundle/skills/pm-architect/SKILL.md create mode 100644 amplifier-bundle/skills/pm-architect/scripts/generate_top5.py create mode 100644 amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-error-handling.yaml create mode 100644 amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-local-overrides.yaml create mode 100644 amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-ranking.yaml create mode 100644 amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-smoke.yaml create mode 100644 amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-with-sources.yaml create mode 100644 amplifier-bundle/skills/pm-architect/scripts/tests/test_generate_top5.py create mode 100644 docs/claude/skills/pm-architect/SKILL.md create mode 100644 docs/claude/skills/pm-architect/scripts/generate_top5.py create mode 100644 docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-error-handling.yaml create mode 100644 docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-local-overrides.yaml create mode 100644 docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-ranking.yaml create mode 100644 docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-smoke.yaml create mode 100644 docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-with-sources.yaml create mode 100644 docs/claude/skills/pm-architect/scripts/tests/test_generate_top5.py diff --git a/.gitignore b/.gitignore index ed46056ee..c7914c6f2 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ htmlcov/ **/.metadata/versions.json # Project specific +.pm/ .test_state.json .requirements_extraction_state.json test_requirements*.md diff --git a/.pm/backlog/items.yaml b/.pm/backlog/items.yaml deleted file mode 100644 index 490616c5a..000000000 --- a/.pm/backlog/items.yaml +++ /dev/null @@ -1,72 +0,0 @@ -items: - - id: BL-001 - title: "Implement /standup skill wrapper in pm-architect" - description: "Wrap existing generate_daily_status.py as Pattern 6 in SKILL.md" - priority: HIGH - estimated_hours: 2 - status: READY - tags: [pm-architect, standup] - dependencies: [] - - - id: BL-002 - title: "WorkIQ bridge for pm-architect" - description: "Connect WorkIQ MCP to pm-architect workflows for M365 data enrichment" - priority: HIGH - estimated_hours: 8 - status: READY - tags: [pm-architect, workiq, m365] - dependencies: [] - - - id: BL-003 - title: "Cross-project .pm/ state aggregation" - description: "Aggregate backlog and workstreams across multiple repos" - priority: MEDIUM - estimated_hours: 6 - status: READY - tags: [pm-architect, cross-project] - dependencies: [BL-001] - - - id: BL-004 - title: "Fix recipe runner tmux detection on WSL" - description: "tmux sessions not detected correctly under WSL2" - priority: HIGH - estimated_hours: 1 - status: READY - tags: [recipe-runner, bug, wsl] - dependencies: [] - - - id: BL-005 - title: "Add delegation history tracking" - description: "Track delegation outcomes for velocity estimation" - priority: LOW - estimated_hours: 4 - status: READY - tags: [pm-architect, delegation] - dependencies: [BL-002] - - - id: BL-006 - title: "Implement Haymaker event bus integration tests" - description: "E2E tests for the event bus orchestrator" - priority: MEDIUM - estimated_hours: 3 - status: IN_PROGRESS - tags: [haymaker, testing] - dependencies: [] - - - id: BL-007 - title: "Azure tenant grapher Neo4j query optimization" - description: "Slow Cypher queries on large tenant graphs" - priority: MEDIUM - estimated_hours: 5 - status: READY - tags: [azure-tenant-grapher, performance] - dependencies: [] - - - id: BL-008 - title: "Upgrade amplihack-memory-lib to RyuGraph v2" - description: "Migrate from Kuzu to RyuGraph embedded database" - priority: LOW - estimated_hours: 12 - status: READY - tags: [memory, migration] - dependencies: [] diff --git a/.pm/config.yaml b/.pm/config.yaml deleted file mode 100644 index 3a6635b81..000000000 --- a/.pm/config.yaml +++ /dev/null @@ -1,9 +0,0 @@ -project_name: amplihack -project_type: cli-tool -primary_goals: - - Ship /top5 and /standup as pm-architect capabilities - - Integrate WorkIQ M365 data into priority scoring - - Support cross-project priority aggregation -quality_bar: balanced -initialized_at: "2026-03-07T15:00:00Z" -version: "0.1.0" diff --git a/.pm/delegations/del-001.yaml b/.pm/delegations/del-001.yaml deleted file mode 100644 index 7ef2e3fef..000000000 --- a/.pm/delegations/del-001.yaml +++ /dev/null @@ -1,11 +0,0 @@ -id: DEL-001 -title: "Implement /standup wrapper" -backlog_id: BL-001 -status: READY -agent: builder -created_at: "2026-03-07T14:00:00Z" -context: - files: - - .claude/skills/pm-architect/scripts/generate_daily_status.py - - .claude/skills/pm-architect/SKILL.md - instructions: "Add Pattern 6 to SKILL.md and wire generate_daily_status.py as /standup trigger" diff --git a/.pm/delegations/del-002.yaml b/.pm/delegations/del-002.yaml deleted file mode 100644 index 644c0591d..000000000 --- a/.pm/delegations/del-002.yaml +++ /dev/null @@ -1,11 +0,0 @@ -id: DEL-002 -title: "WorkIQ bridge design" -backlog_id: BL-002 -status: PENDING -agent: architect -created_at: "2026-03-07T14:30:00Z" -context: - files: - - .claude/skills/work-iq/SKILL.md - - .claude/skills/pm-architect/SKILL.md - instructions: "Design the bridge between WorkIQ MCP and pm-architect state" diff --git a/.pm/roadmap.md b/.pm/roadmap.md deleted file mode 100644 index 114705c7a..000000000 --- a/.pm/roadmap.md +++ /dev/null @@ -1,18 +0,0 @@ -# amplihack Roadmap - -## Q1 2026 Goals - -### PM System Enhancement -- Ship /top5 and /standup as pm-architect capabilities -- WorkIQ M365 integration for calendar-aware prioritization -- Cross-project coordination support - -### Platform Stability -- Fix WSL tmux detection issues -- Recipe runner reliability improvements -- Session tree depth management - -### Ecosystem Growth -- Haymaker event bus integration -- Azure tenant grapher performance -- Memory system migration to RyuGraph diff --git a/.pm/sources.yaml b/.pm/sources.yaml deleted file mode 100644 index 41f4733a8..000000000 --- a/.pm/sources.yaml +++ /dev/null @@ -1,18 +0,0 @@ -github: - - account: rysweet - repos: - - amplihack - - azlin - - agent-haymaker - - haymaker-azure-workloads - - haymaker-m365-workloads - - haymaker-workload-starter - - amplihack-agent-eval - - amplihack-memory-lib - - azure-tenant-grapher - - agent-kgpacks - - gadugi-agentic-test - - account: rysweet_microsoft - repos: - - cloud-ecosystem-security/SedanDelivery - - cloud-ecosystem-security/cybergym diff --git a/.pm/workstreams/ws-001.yaml b/.pm/workstreams/ws-001.yaml deleted file mode 100644 index df38918de..000000000 --- a/.pm/workstreams/ws-001.yaml +++ /dev/null @@ -1,14 +0,0 @@ -id: ws-001 -backlog_id: BL-006 -title: "Haymaker Event Bus Integration Tests" -status: RUNNING -agent: builder -started_at: "2026-03-07T10:00:00Z" -completed_at: null -process_id: "tmux-haymaker-tests" -elapsed_minutes: 180 -progress_notes: - - "Started test scaffolding" - - "Event bus mock created" -last_activity: "2026-03-07T11:00:00Z" -dependencies: [] diff --git a/.pm/workstreams/ws-002.yaml b/.pm/workstreams/ws-002.yaml deleted file mode 100644 index 851ced836..000000000 --- a/.pm/workstreams/ws-002.yaml +++ /dev/null @@ -1,13 +0,0 @@ -id: ws-002 -backlog_id: BL-004 -title: "Fix WSL tmux detection" -status: RUNNING -agent: fix-agent -started_at: "2026-03-07T14:00:00Z" -completed_at: null -process_id: "local" -elapsed_minutes: 30 -progress_notes: - - "Investigating tmux session listing under WSL2" -last_activity: "2026-03-07T15:20:00Z" -dependencies: [] diff --git a/amplifier-bundle/skills/pm-architect/SKILL.md b/amplifier-bundle/skills/pm-architect/SKILL.md new file mode 100644 index 000000000..6b5480872 --- /dev/null +++ b/amplifier-bundle/skills/pm-architect/SKILL.md @@ -0,0 +1,104 @@ +--- +name: pm-architect +description: Expert project manager orchestrating backlog-curator, work-delegator, workstream-coordinator, and roadmap-strategist sub-skills. Coordinates complex software projects through delegation and strategic oversight. Activates when managing projects, coordinating work, or tracking overall progress. +explicit_triggers: + - /top5 +--- + +# PM Architect Skill (Orchestrator) + +## Role + +You are the project manager orchestrating four specialized sub-skills to coordinate software development projects. You delegate to specialists and synthesize their insights for comprehensive project management. + +## When to Activate + +Activate when the user: + +- Mentions managing projects or coordinating work +- Asks about project status or progress +- Wants to organize multiple projects or features +- Needs help with project planning or execution +- Says "I'm losing track" or "What should I work on?" +- Asks "What are the top priorities?" or invokes `/top5` +- Wants a quick daily standup or status overview + +## Sub-Skills + +### 1. backlog-curator + +**Focus**: Backlog prioritization and recommendations +**Use when**: Analyzing what to work on next, adding items, checking priorities + +### 2. work-delegator + +**Focus**: Delegation package creation for agents +**Use when**: Assigning work to coding agents, creating context packages + +### 3. workstream-coordinator + +**Focus**: Multi-workstream tracking and coordination +**Use when**: Checking status, detecting stalls/conflicts, managing concurrent work + +### 4. roadmap-strategist + +**Focus**: Strategic planning and goal alignment +**Use when**: Discussing goals, milestones, strategic direction, roadmap updates + +## Core Workflow + +When user requests project management help: + +1. **Understand intent**: Determine which sub-skill(s) to invoke +2. **Invoke specialist(s)**: Call appropriate sub-skill(s) in parallel when possible +3. **Synthesize results**: Combine insights from sub-skills +4. **Present cohesively**: Deliver unified response to user +5. **Recommend actions**: Suggest next steps + +## Orchestration Patterns + +### Pattern 1: What Should I Work On? + +Invoke backlog-curator + roadmap-strategist in parallel, synthesize recommendations with strategic alignment. + +### Pattern 2: Check Overall Status + +Invoke workstream-coordinator + roadmap-strategist in parallel, present unified project health dashboard. + +### Pattern 3: Start New Work + +Sequential: work-delegator creates package, then workstream-coordinator tracks it. + +### Pattern 4: Initialize PM + +Create .pm/ structure, invoke roadmap-strategist for roadmap generation. + +### Pattern 5: Top 5 Priorities (`/top5`) + +Run `scripts/generate_top5.py` to aggregate priorities from GitHub issues, PRs, and local backlog into a strict ranked list. Present the Top 5 with score breakdown, source attribution, and suggested next action per item. + +Weights: GitHub issues 40%, GitHub PRs 30%, roadmap alignment 20%, local backlog 10%. + +### Pattern 6: Daily Standup + +Run `scripts/generate_daily_status.py` to produce a cross-project status report. Combines git activity, workstream health, backlog changes, and roadmap progress. + +## Philosophy Alignment + +- **Ruthless Simplicity**: Thin orchestrator (< 200 lines), complexity in sub-skills +- **Single Responsibility**: Coordinate, don't implement +- **Zero-BS**: All sub-skills complete and functional + +## Scripts + +Orchestrator owns these scripts: +- `scripts/manage_state.py` — Basic .pm/ state operations (init, add, update, list) +- `scripts/generate_top5.py` — Top 5 priority aggregation across all sub-skills +- `scripts/generate_daily_status.py` — AI-powered daily status report generation +- `scripts/generate_roadmap_review.py` — Roadmap analysis and review + +Sub-skills own their specialized scripts. + +## Success Criteria + +Users can manage projects, prioritize work, delegate to agents, track progress, and align with goals effectively. diff --git a/amplifier-bundle/skills/pm-architect/scripts/generate_top5.py b/amplifier-bundle/skills/pm-architect/scripts/generate_top5.py new file mode 100644 index 000000000..829d3b0b1 --- /dev/null +++ b/amplifier-bundle/skills/pm-architect/scripts/generate_top5.py @@ -0,0 +1,584 @@ +#!/usr/bin/env python3 +"""Aggregate priorities across GitHub accounts into a strict Top 5 ranked list. + +Queries GitHub issues and PRs across configured accounts/repos, scores them +by priority labels, staleness, blocking status, and roadmap alignment. + +Falls back to .pm/ YAML state if GitHub is unavailable or for enrichment. + +Usage: + python generate_top5.py [--project-root PATH] [--sources PATH] + +Returns JSON with top 5 priorities. +""" + +import argparse +import json +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import yaml + + +# Aggregation weights +WEIGHT_ISSUES = 0.40 +WEIGHT_PRS = 0.30 +WEIGHT_ROADMAP = 0.20 +WEIGHT_LOCAL = 0.10 # .pm/ overrides + +TOP_N = 5 + +# Label-to-priority mapping +PRIORITY_LABELS = { + "critical": 1.0, + "priority:critical": 1.0, + "high": 0.9, + "priority:high": 0.9, + "bug": 0.8, + "medium": 0.6, + "priority:medium": 0.6, + "enhancement": 0.5, + "feature": 0.5, + "low": 0.3, + "priority:low": 0.3, +} + + +def load_yaml(path: Path) -> dict[str, Any]: + """Load YAML file safely.""" + if not path.exists(): + return {} + with open(path) as f: + return yaml.safe_load(f) or {} + + +def load_sources(sources_path: Path) -> list[dict]: + """Load GitHub source configuration.""" + data = load_yaml(sources_path) + return data.get("github", []) + + +def run_gh(args: list[str], account: str | None = None) -> str | None: + """Run a gh CLI command, optionally switching account first. + + Returns stdout on success, None on failure. + """ + if account: + switch = subprocess.run( + ["gh", "auth", "switch", "--user", account], + capture_output=True, text=True, timeout=10, + ) + if switch.returncode != 0: + return None + + try: + result = subprocess.run( + ["gh"] + args, + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError): + return None + + +def get_current_gh_account() -> str | None: + """Get the currently active gh account.""" + try: + result = subprocess.run( + ["gh", "api", "user", "--jq", ".login"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + return None + + +def fetch_github_issues(account: str, repos: list[str]) -> list[dict]: + """Fetch open issues for an account's repos from GitHub.""" + candidates = [] + + # Use search API to get all issues at once + repo_qualifiers = " ".join(f"repo:{r}" if "/" in r else f"repo:{account}/{r}" for r in repos) + query = f"is:open is:issue {repo_qualifiers}" + + jq_filter = ( + '.items[] | {' + 'repo: (.repository_url | split("/") | .[-2:] | join("/")),' + 'title: .title,' + 'labels: [.labels[].name],' + 'created: .created_at,' + 'updated: .updated_at,' + 'number: .number,' + 'comments: .comments' + '}' + ) + + output = run_gh( + ["api", "search/issues", "--method", "GET", + "-f", f"q={query}", "-f", "per_page=50", + "--jq", jq_filter], + account=account, + ) + + if not output: + return [] + + now = datetime.now(UTC) + + for line in output.strip().splitlines(): + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + + # Score by labels + labels = [lbl.lower() for lbl in item.get("labels", [])] + priority_score = 0.5 # default + for label in labels: + if label in PRIORITY_LABELS: + priority_score = max(priority_score, PRIORITY_LABELS[label]) + + # Staleness boost: older updated = needs attention + try: + updated = datetime.fromisoformat(item["updated"].replace("Z", "+00:00")) + days_stale = (now - updated).total_seconds() / 86400 + except (ValueError, KeyError): + days_stale = 0 + + staleness_score = min(days_stale / 14.0, 1.0) # Max at 2 weeks + + # Comment activity: more comments = more discussion = potentially blocked + comments = item.get("comments", 0) + activity_score = min(comments / 10.0, 1.0) + + raw_score = (priority_score * 0.50 + staleness_score * 0.30 + activity_score * 0.20) * 100 + + # Rationale + reasons = [] + if priority_score >= 0.8: + reasons.append(f"labeled {', '.join(lbl for lbl in labels if lbl in PRIORITY_LABELS)}") + if days_stale > 7: + reasons.append(f"stale {days_stale:.0f}d") + if comments > 3: + reasons.append(f"{comments} comments") + if not reasons: + reasons.append("open issue") + + repo = item.get("repo", "") + candidates.append({ + "title": item["title"], + "source": "github_issue", + "raw_score": round(raw_score, 1), + "score_breakdown": { + "label_priority": round(priority_score, 2), + "staleness": round(staleness_score, 2), + "activity": round(activity_score, 2), + }, + "rationale": ", ".join(reasons), + "item_id": f"{repo}#{item['number']}", + "priority": "HIGH" if priority_score >= 0.8 else "MEDIUM" if priority_score >= 0.5 else "LOW", + "repo": repo, + "account": account, + "url": f"https://github.com/{repo}/issues/{item['number']}", + "labels": item.get("labels", []), + "created": item.get("created", ""), + "updated": item.get("updated", ""), + "days_stale": round(days_stale, 1), + "comments": comments, + }) + + return candidates + + +def fetch_github_prs(account: str, repos: list[str]) -> list[dict]: + """Fetch open PRs for an account's repos from GitHub.""" + candidates = [] + + repo_qualifiers = " ".join(f"repo:{r}" if "/" in r else f"repo:{account}/{r}" for r in repos) + query = f"is:open is:pr {repo_qualifiers}" + + jq_filter = ( + '.items[] | {' + 'repo: (.repository_url | split("/") | .[-2:] | join("/")),' + 'title: .title,' + 'labels: [.labels[].name],' + 'created: .created_at,' + 'updated: .updated_at,' + 'number: .number,' + 'draft: .draft,' + 'comments: .comments' + '}' + ) + + output = run_gh( + ["api", "search/issues", "--method", "GET", + "-f", f"q={query}", "-f", "per_page=50", + "--jq", jq_filter], + account=account, + ) + + if not output: + return [] + + now = datetime.now(UTC) + + for line in output.strip().splitlines(): + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + + is_draft = item.get("draft", False) + + # PRs waiting for review are higher priority than drafts + base_score = 0.4 if is_draft else 0.7 + + # Labels boost + labels = [lbl.lower() for lbl in item.get("labels", [])] + for label in labels: + if label in PRIORITY_LABELS: + base_score = max(base_score, PRIORITY_LABELS[label]) + + # Staleness: PRs waiting for review get more urgent over time + try: + updated = datetime.fromisoformat(item["updated"].replace("Z", "+00:00")) + days_stale = (now - updated).total_seconds() / 86400 + except (ValueError, KeyError): + days_stale = 0 + + staleness_score = min(days_stale / 7.0, 1.0) # PRs stale faster (1 week max) + + raw_score = (base_score * 0.60 + staleness_score * 0.40) * 100 + + reasons = [] + if is_draft: + reasons.append("draft PR") + else: + reasons.append("awaiting review") + if days_stale > 3: + reasons.append(f"stale {days_stale:.0f}d") + if labels: + relevant = [lbl for lbl in labels if lbl in PRIORITY_LABELS] + if relevant: + reasons.append(f"labeled {', '.join(relevant)}") + + repo = item.get("repo", "") + candidates.append({ + "title": item["title"], + "source": "github_pr", + "raw_score": round(raw_score, 1), + "score_breakdown": { + "base_priority": round(base_score, 2), + "staleness": round(staleness_score, 2), + }, + "rationale": ", ".join(reasons), + "item_id": f"{repo}#{item['number']}", + "priority": "HIGH" if base_score >= 0.8 else "MEDIUM", + "repo": repo, + "account": account, + "url": f"https://github.com/{repo}/pull/{item['number']}", + "labels": item.get("labels", []), + "created": item.get("created", ""), + "updated": item.get("updated", ""), + "days_stale": round(days_stale, 1), + "is_draft": is_draft, + }) + + return candidates + + +def load_local_overrides(pm_dir: Path) -> list[dict]: + """Load manually-added items from .pm/backlog for local enrichment.""" + backlog_data = load_yaml(pm_dir / "backlog" / "items.yaml") + items = backlog_data.get("items", []) + ready_items = [item for item in items if item.get("status") == "READY"] + + candidates = [] + priority_map = {"HIGH": 1.0, "MEDIUM": 0.6, "LOW": 0.3} + + for item in ready_items: + priority = item.get("priority", "MEDIUM") + priority_score = priority_map.get(priority, 0.5) + hours = item.get("estimated_hours", 4) + ease_score = 1.0 if hours < 2 else 0.6 if hours <= 6 else 0.3 + + raw_score = (priority_score * 0.60 + ease_score * 0.40) * 100 + + reasons = [] + if priority == "HIGH": + reasons.append("HIGH priority") + if hours < 2: + reasons.append("quick win") + if not reasons: + reasons.append("local backlog item") + + candidates.append({ + "title": item.get("title", item["id"]), + "source": "local", + "raw_score": round(raw_score, 1), + "rationale": ", ".join(reasons), + "item_id": item["id"], + "priority": priority, + }) + + return candidates + + +def extract_roadmap_goals(pm_dir: Path) -> list[str]: + """Extract strategic goals from roadmap markdown.""" + roadmap_path = pm_dir / "roadmap.md" + if not roadmap_path.exists(): + return [] + + text = roadmap_path.read_text() + goals = [] + + for line in text.splitlines(): + line = line.strip() + if line.startswith("## ") or line.startswith("### "): + goals.append(line.lstrip("#").strip()) + elif line.startswith("- "): + goals.append(line.removeprefix("- ").strip()) + elif line.startswith("* "): + goals.append(line.removeprefix("* ").strip()) + + return goals + + +def score_roadmap_alignment(candidate: dict, goals: list[str]) -> float: + """Score how well a candidate aligns with roadmap goals. Returns 0.0-1.0.""" + if not goals: + return 0.5 + + title_lower = candidate["title"].lower() + max_alignment = 0.0 + + for goal in goals: + goal_words = set(goal.lower().split()) + goal_words -= {"the", "a", "an", "and", "or", "to", "for", "in", "of", "is", "with"} + if not goal_words: + continue + + matching = sum(1 for word in goal_words if word in title_lower) + alignment = matching / len(goal_words) if goal_words else 0.0 + max_alignment = max(max_alignment, alignment) + + return min(max_alignment, 1.0) + + +def suggest_action(candidate: dict) -> str: + """Suggest a concrete next action for a candidate.""" + source = candidate["source"] + days_stale = candidate.get("days_stale", 0) + labels = candidate.get("labels", []) + + if source == "github_pr": + if candidate.get("is_draft"): + return "Finish draft or close if abandoned" + if days_stale > 14: + return "Merge, close, or rebase — stale >2 weeks" + if days_stale > 7: + return "Review and merge or request changes" + return "Review PR" + elif source == "github_issue": + if any(lbl in ("critical", "priority:critical") for lbl in labels): + return "Fix immediately — critical severity" + if any(lbl in ("bug",) for lbl in labels): + return "Investigate and fix bug" + if days_stale > 30: + return "Triage: still relevant? Close or reprioritize" + return "Work on issue or delegate" + elif source == "local": + return "Pick up from local backlog" + return "Review" + + +def aggregate_and_rank( + issues: list[dict], + prs: list[dict], + local: list[dict], + goals: list[str], + top_n: int = TOP_N, +) -> tuple[list[dict], list[dict]]: + """Aggregate candidates from all sources and rank by weighted score. + + Returns (top_n items, next 5 near-misses). + """ + scored = [] + + source_weights = { + "github_issue": WEIGHT_ISSUES, + "github_pr": WEIGHT_PRS, + "local": WEIGHT_LOCAL, + } + + all_candidates = issues + prs + local + + for candidate in all_candidates: + source = candidate["source"] + source_weight = source_weights.get(source, 0.25) + raw = candidate["raw_score"] + + alignment = score_roadmap_alignment(candidate, goals) + final_score = (source_weight * raw) + (WEIGHT_ROADMAP * alignment * 100) + + entry = { + "title": candidate["title"], + "source": candidate["source"], + "score": round(final_score, 1), + "raw_score": candidate["raw_score"], + "source_weight": source_weight, + "rationale": candidate["rationale"], + "item_id": candidate.get("item_id", ""), + "priority": candidate.get("priority", "MEDIUM"), + "alignment": round(alignment, 2), + "action": suggest_action(candidate), + } + # Preserve all metadata from the candidate + for key in ("url", "repo", "account", "labels", "created", "updated", + "days_stale", "comments", "is_draft", "score_breakdown"): + if key in candidate: + entry[key] = candidate[key] + + scored.append(entry) + + priority_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2} + scored.sort(key=lambda x: (-x["score"], priority_order.get(x["priority"], 1))) + + top = scored[:top_n] + for i, item in enumerate(top): + item["rank"] = i + 1 + + near_misses = scored[top_n:top_n + 5] + for i, item in enumerate(near_misses): + item["rank"] = top_n + i + 1 + + return top, near_misses + + +def build_repo_summary(all_candidates: list[dict]) -> dict: + """Build a per-repo, per-account summary of open work.""" + repos: dict[str, dict] = {} + accounts: dict[str, dict] = {} + + for c in all_candidates: + repo = c.get("repo", "local") + account = c.get("account", "local") + + if repo not in repos: + repos[repo] = {"issues": 0, "prs": 0, "high_priority": 0} + if account not in accounts: + accounts[account] = {"issues": 0, "prs": 0, "repos": set()} + + if c["source"] == "github_issue": + repos[repo]["issues"] += 1 + accounts[account]["issues"] += 1 + elif c["source"] == "github_pr": + repos[repo]["prs"] += 1 + accounts[account]["prs"] += 1 + + if c.get("priority") == "HIGH": + repos[repo]["high_priority"] += 1 + + accounts[account]["repos"].add(repo) + + # Convert sets to lists for JSON serialization + for a in accounts.values(): + a["repos"] = sorted(a["repos"]) + + # Sort repos by total open items descending + sorted_repos = dict(sorted(repos.items(), key=lambda x: -(x[1]["issues"] + x[1]["prs"]))) + + return {"by_repo": sorted_repos, "by_account": accounts} + + +def generate_top5(project_root: Path, sources_path: Path | None = None) -> dict: + """Generate the Top 5 priority list from GitHub + local state.""" + pm_dir = project_root / ".pm" + + if sources_path is None: + sources_path = pm_dir / "sources.yaml" + + # Load GitHub sources config + sources = load_sources(sources_path) + + # Remember original account to restore after + original_account = get_current_gh_account() + + # Fetch from GitHub + all_issues = [] + all_prs = [] + accounts_queried = [] + + for source in sources: + account = source.get("account", "") + repos = source.get("repos", []) + if not account or not repos: + continue + + accounts_queried.append(account) + all_issues.extend(fetch_github_issues(account, repos)) + all_prs.extend(fetch_github_prs(account, repos)) + + # Restore original account + if original_account and accounts_queried: + run_gh(["auth", "switch", "--user", original_account]) + + # Load local overrides + local = [] + if pm_dir.exists(): + local = load_local_overrides(pm_dir) + + # Load roadmap goals + goals = extract_roadmap_goals(pm_dir) if pm_dir.exists() else [] + + # Aggregate and rank + all_candidates = all_issues + all_prs + local + top5, near_misses = aggregate_and_rank(all_issues, all_prs, local, goals) + summary = build_repo_summary(all_candidates) + + return { + "top5": top5, + "near_misses": near_misses, + "summary": summary, + "sources": { + "github_issues": len(all_issues), + "github_prs": len(all_prs), + "local_items": len(local), + "roadmap_goals": len(goals), + "accounts": accounts_queried, + }, + "total_candidates": len(all_candidates), + } + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description="Generate Top 5 priorities from GitHub + local state") + parser.add_argument( + "--project-root", type=Path, default=Path.cwd(), help="Project root directory" + ) + parser.add_argument( + "--sources", type=Path, default=None, help="Path to sources.yaml (default: .pm/sources.yaml)" + ) + + args = parser.parse_args() + + try: + result = generate_top5(args.project_root, args.sources) + print(json.dumps(result, indent=2)) + return 0 + except Exception as e: + print(json.dumps({"error": str(e)}), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-error-handling.yaml b/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-error-handling.yaml new file mode 100644 index 000000000..1e281b6e1 --- /dev/null +++ b/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-error-handling.yaml @@ -0,0 +1,88 @@ +# Outside-in test for /top5 error handling +# Validates generate_top5.py handles failure modes gracefully: +# invalid sources, missing gh CLI, malformed YAML. + +scenario: + name: "Top 5 Priorities - Error Handling" + description: | + Verifies that generate_top5.py degrades gracefully when GitHub is + unreachable, sources.yaml is malformed, or the project root is invalid. + The script should always return valid JSON, never crash. + type: cli + level: 2 + tags: [cli, error-handling, top5, pm-architect, resilience] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + + steps: + # Test 1: Non-existent project root (no .pm/ dir) + - action: launch + target: "python" + args: + - ".claude/skills/pm-architect/scripts/generate_top5.py" + - "--project-root" + - "/tmp/top5-test-nonexistent-path-12345" + description: "Run with non-existent project root" + timeout: 15s + + - action: verify_output + contains: '"top5"' + description: "Still returns valid JSON with top5 key" + + - action: verify_output + contains: '"total_candidates": 0' + description: "Reports zero candidates when no sources available" + + - action: verify_exit_code + expected: 0 + description: "Exits cleanly even with missing project root" + + # Test 2: Malformed sources.yaml + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm" && + echo "not: [valid: yaml: {{{{" > "$TMPDIR/.pm/sources.yaml" && + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run with malformed sources.yaml" + timeout: 15s + + # Script should handle YAML parse errors (may return error JSON or empty results) + - action: verify_exit_code + expected_one_of: [0, 1] + description: "Exits with 0 or 1, never crashes with traceback" + + # Test 3: Empty sources.yaml (valid but no accounts) + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm" && + echo "github: []" > "$TMPDIR/.pm/sources.yaml" && + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run with empty sources list" + timeout: 15s + + - action: verify_output + contains: '"top5"' + description: "Returns valid JSON with empty top5" + + - action: verify_output + contains: '"total_candidates": 0' + description: "Reports zero candidates" + + - action: verify_exit_code + expected: 0 + description: "Exits cleanly with empty sources" + + cleanup: + - action: stop_application + force: true + description: "Ensure all processes are terminated" diff --git a/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-local-overrides.yaml b/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-local-overrides.yaml new file mode 100644 index 000000000..3d763c94b --- /dev/null +++ b/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-local-overrides.yaml @@ -0,0 +1,97 @@ +# Outside-in test for /top5 local backlog integration +# Validates generate_top5.py incorporates .pm/backlog items and roadmap goals +# into the priority ranking alongside (or instead of) GitHub data. + +scenario: + name: "Top 5 Priorities - Local Overrides and Roadmap Alignment" + description: | + Verifies that generate_top5.py reads .pm/backlog/items.yaml for local + priorities and .pm/roadmap.md for strategic alignment scoring. + Tests the full aggregation pipeline without requiring GitHub access. + type: cli + level: 2 + tags: [cli, integration, top5, pm-architect, local, roadmap] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + + steps: + # Setup local .pm/ state with backlog items and roadmap + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm/backlog" && + cat > "$TMPDIR/.pm/backlog/items.yaml" << 'BACKLOG' + items: + - id: "local-001" + title: "Fix authentication timeout bug" + status: "READY" + priority: "HIGH" + estimated_hours: 1 + - id: "local-002" + title: "Add dashboard metrics" + status: "READY" + priority: "MEDIUM" + estimated_hours: 4 + - id: "local-003" + title: "Refactor logging module" + status: "IN_PROGRESS" + priority: "LOW" + estimated_hours: 8 + BACKLOG + cat > "$TMPDIR/.pm/roadmap.md" << 'ROADMAP' + ## Q1 Goals + ### Improve authentication reliability + - Fix timeout and retry logic + ### Add observability dashboard + - Metrics and monitoring + ROADMAP + cat > "$TMPDIR/.pm/sources.yaml" << 'SOURCES' + github: [] + SOURCES + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run with local backlog items and roadmap goals, no GitHub sources" + timeout: 15s + + # Verify local items appear in output + - action: verify_output + contains: "Fix authentication timeout bug" + timeout: 5s + description: "HIGH priority READY item appears in results" + + - action: verify_output + contains: "Add dashboard metrics" + description: "MEDIUM priority READY item appears in results" + + # IN_PROGRESS items should NOT appear (only READY items are loaded) + - action: verify_output + not_contains: "Refactor logging module" + description: "IN_PROGRESS item is excluded (only READY items loaded)" + + # Verify source attribution + - action: verify_output + contains: '"source": "local"' + description: "Items attributed to local source" + + # Verify roadmap goals were loaded + - action: verify_output + contains: '"roadmap_goals"' + description: "Roadmap goals count present in sources" + + # Verify alignment scoring (auth bug should align with roadmap goal) + - action: verify_output + matches: '"alignment":\\s*[0-9]' + description: "Items have alignment scores" + + - action: verify_exit_code + expected: 0 + description: "Script exits cleanly" + + cleanup: + - action: stop_application + force: true + description: "Ensure process is terminated" diff --git a/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-ranking.yaml b/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-ranking.yaml new file mode 100644 index 000000000..4fac284f8 --- /dev/null +++ b/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-ranking.yaml @@ -0,0 +1,104 @@ +# Outside-in test for /top5 ranking correctness +# Validates that output is strictly ranked by score descending, +# limited to 5 items, and each item has a rank field 1-5. + +scenario: + name: "Top 5 Priorities - Ranking and Limit Enforcement" + description: | + Verifies that generate_top5.py returns exactly TOP_N (5) items, + ranked by descending score, with rank fields 1 through 5. + Uses local backlog with >5 items to verify the limit. + type: cli + level: 2 + tags: [cli, integration, top5, pm-architect, ranking] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + + steps: + # Setup: 7 local items to verify only top 5 are returned + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm/backlog" && + cat > "$TMPDIR/.pm/backlog/items.yaml" << 'BACKLOG' + items: + - id: "item-1" + title: "Critical security fix" + status: "READY" + priority: "HIGH" + estimated_hours: 1 + - id: "item-2" + title: "API rate limiting" + status: "READY" + priority: "HIGH" + estimated_hours: 2 + - id: "item-3" + title: "Database migration" + status: "READY" + priority: "MEDIUM" + estimated_hours: 4 + - id: "item-4" + title: "Update documentation" + status: "READY" + priority: "MEDIUM" + estimated_hours: 6 + - id: "item-5" + title: "Add unit tests" + status: "READY" + priority: "MEDIUM" + estimated_hours: 3 + - id: "item-6" + title: "Refactor config loader" + status: "READY" + priority: "LOW" + estimated_hours: 8 + - id: "item-7" + title: "Add logging headers" + status: "READY" + priority: "LOW" + estimated_hours: 10 + BACKLOG + cat > "$TMPDIR/.pm/sources.yaml" << 'SOURCES' + github: [] + SOURCES + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run with 7 local items to verify top-5 limit" + timeout: 15s + + # Verify rank fields 1-5 exist + - action: verify_output + contains: '"rank": 1' + timeout: 5s + description: "First ranked item present" + + - action: verify_output + contains: '"rank": 5' + description: "Fifth ranked item present" + + # Verify rank 6 and 7 are NOT in output (limit enforced) + - action: verify_output + not_contains: '"rank": 6' + description: "No sixth rank (limit to 5)" + + - action: verify_output + not_contains: '"rank": 7' + description: "No seventh rank (limit to 5)" + + # Verify total_candidates reflects all 7 items considered + - action: verify_output + contains: '"total_candidates": 7' + description: "Total candidates count includes all 7 items" + + - action: verify_exit_code + expected: 0 + description: "Script exits cleanly" + + cleanup: + - action: stop_application + force: true + description: "Ensure process is terminated" diff --git a/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-smoke.yaml b/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-smoke.yaml new file mode 100644 index 000000000..d3d8ee24c --- /dev/null +++ b/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-smoke.yaml @@ -0,0 +1,52 @@ +# Outside-in smoke test for /top5 priority aggregation +# Validates the generate_top5.py CLI produces valid JSON output +# with the expected structure from a user's perspective. + +scenario: + name: "Top 5 Priorities - Smoke Test" + description: | + Verifies that generate_top5.py runs successfully, produces valid JSON, + and contains the expected top-level keys (top5, sources, total_candidates). + Uses an empty project root so no GitHub calls are made. + type: cli + level: 1 + tags: [cli, smoke, top5, pm-architect] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + + steps: + # Run with empty project root (no .pm/ dir, no sources.yaml) + - action: launch + target: "python" + args: + - "scripts/generate_top5.py" + - "--project-root" + - "/tmp/top5-test-empty" + working_directory: ".claude/skills/pm-architect" + description: "Run generate_top5.py with empty project root" + timeout: 15s + + # Verify valid JSON output with expected keys + - action: verify_output + contains: '"top5"' + timeout: 5s + description: "Output contains top5 key" + + - action: verify_output + contains: '"sources"' + description: "Output contains sources key" + + - action: verify_output + contains: '"total_candidates"' + description: "Output contains total_candidates key" + + - action: verify_exit_code + expected: 0 + description: "Script exits cleanly with code 0" + + cleanup: + - action: stop_application + force: true + description: "Ensure process is terminated" diff --git a/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-with-sources.yaml b/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-with-sources.yaml new file mode 100644 index 000000000..a76e52d4a --- /dev/null +++ b/amplifier-bundle/skills/pm-architect/scripts/tests/agentic/test-top5-with-sources.yaml @@ -0,0 +1,83 @@ +# Outside-in test for /top5 with configured sources +# Validates generate_top5.py queries GitHub when sources.yaml is provided, +# produces ranked output with score breakdown and source attribution. + +scenario: + name: "Top 5 Priorities - GitHub Source Aggregation" + description: | + Verifies that generate_top5.py correctly reads a sources.yaml config, + queries GitHub for issues and PRs, aggregates scores, and returns + a ranked list with proper source attribution and metadata. + Requires gh CLI authenticated with at least one account. + type: cli + level: 2 + tags: [cli, integration, top5, pm-architect, github] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + - "gh CLI is authenticated" + - "Network access to GitHub API" + + environment: + variables: + GH_PAGER: "" + + steps: + # Setup: create a minimal sources.yaml pointing to a known public repo + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm" && + cat > "$TMPDIR/.pm/sources.yaml" << 'SOURCES' + github: + - account: rysweet + repos: + - amplihack + SOURCES + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run generate_top5.py with sources pointing to amplihack repo" + timeout: 45s + + # Verify JSON structure + - action: verify_output + contains: '"top5"' + timeout: 5s + description: "Output has top5 array" + + - action: verify_output + contains: '"github_issues"' + description: "Sources breakdown includes github_issues count" + + - action: verify_output + contains: '"github_prs"' + description: "Sources breakdown includes github_prs count" + + - action: verify_output + contains: '"accounts"' + description: "Sources breakdown includes accounts queried" + + # Verify ranked items have required fields + - action: verify_output + matches: '"score":\\s*[0-9]' + description: "Items have numeric scores" + + - action: verify_output + matches: '"source":\\s*"github_(issue|pr)"' + description: "Items have source attribution" + + - action: verify_output + matches: '"rationale":' + description: "Items include rationale text" + + - action: verify_exit_code + expected: 0 + description: "Script exits cleanly" + + cleanup: + - action: stop_application + force: true + description: "Ensure process is terminated" diff --git a/amplifier-bundle/skills/pm-architect/scripts/tests/conftest.py b/amplifier-bundle/skills/pm-architect/scripts/tests/conftest.py index 40af58e5a..448aa9983 100644 --- a/amplifier-bundle/skills/pm-architect/scripts/tests/conftest.py +++ b/amplifier-bundle/skills/pm-architect/scripts/tests/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock import pytest +import yaml @pytest.fixture @@ -131,3 +132,129 @@ def sample_daily_status_output() -> str: 1. Prioritize design review for API refactoring 2. Address technical debt in authentication system """ + + +# --- Top 5 Priority Aggregation Fixtures --- + + +@pytest.fixture +def pm_dir(tmp_path: Path) -> Path: + """Create .pm/ directory structure with sample data.""" + pm = tmp_path / ".pm" + (pm / "backlog").mkdir(parents=True) + (pm / "workstreams").mkdir(parents=True) + (pm / "delegations").mkdir(parents=True) + return pm + + +@pytest.fixture +def sample_backlog_items() -> dict: + """Sample backlog items YAML data.""" + return { + "items": [ + { + "id": "BL-001", + "title": "Fix authentication bug", + "description": "Auth tokens expire prematurely", + "priority": "HIGH", + "estimated_hours": 2, + "status": "READY", + "tags": ["auth", "bug"], + "dependencies": [], + }, + { + "id": "BL-002", + "title": "Implement config parser", + "description": "Parse YAML and JSON config files", + "priority": "MEDIUM", + "estimated_hours": 4, + "status": "READY", + "tags": ["config", "core"], + "dependencies": [], + }, + { + "id": "BL-003", + "title": "Add logging framework", + "description": "Structured logging with JSON output", + "priority": "LOW", + "estimated_hours": 8, + "status": "READY", + "tags": ["infrastructure"], + "dependencies": ["BL-002"], + }, + { + "id": "BL-004", + "title": "Write API documentation", + "description": "Document all REST endpoints", + "priority": "MEDIUM", + "estimated_hours": 3, + "status": "READY", + "tags": ["docs"], + "dependencies": [], + }, + { + "id": "BL-005", + "title": "Database migration tool", + "description": "Automated schema migrations", + "priority": "HIGH", + "estimated_hours": 6, + "status": "READY", + "tags": ["database", "core"], + "dependencies": ["BL-002"], + }, + { + "id": "BL-006", + "title": "Refactor test suite", + "description": "Improve test performance and coverage", + "priority": "MEDIUM", + "estimated_hours": 1, + "status": "IN_PROGRESS", + "tags": ["test"], + "dependencies": [], + }, + ] + } + + +@pytest.fixture +def populated_pm(pm_dir, sample_backlog_items): + """Create fully populated .pm/ directory.""" + with open(pm_dir / "backlog" / "items.yaml", "w") as f: + yaml.dump(sample_backlog_items, f) + + ws_data = { + "id": "ws-1", + "backlog_id": "BL-006", + "title": "Test Suite Refactor", + "agent": "builder", + "status": "RUNNING", + "last_activity": "2020-01-01T00:00:00Z", + } + with open(pm_dir / "workstreams" / "ws-1.yaml", "w") as f: + yaml.dump(ws_data, f) + + deleg_data = { + "id": "DEL-001", + "title": "Implement caching layer", + "status": "READY", + "backlog_id": "BL-002", + } + with open(pm_dir / "delegations" / "del-001.yaml", "w") as f: + yaml.dump(deleg_data, f) + + roadmap = """# Project Roadmap + +## Q1 Goals + +### Core Infrastructure +- Implement config parser +- Database migration tool +- Logging framework + +### Quality +- Test coverage above 80% +- API documentation complete +""" + (pm_dir / "roadmap.md").write_text(roadmap) + + return pm_dir diff --git a/amplifier-bundle/skills/pm-architect/scripts/tests/test_generate_top5.py b/amplifier-bundle/skills/pm-architect/scripts/tests/test_generate_top5.py new file mode 100644 index 000000000..8928e6a21 --- /dev/null +++ b/amplifier-bundle/skills/pm-architect/scripts/tests/test_generate_top5.py @@ -0,0 +1,420 @@ +"""Tests for generate_top5.py - GitHub-native priority aggregation.""" + +import json +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from generate_top5 import ( + PRIORITY_LABELS, + aggregate_and_rank, + build_repo_summary, + extract_roadmap_goals, + fetch_github_issues, + fetch_github_prs, + generate_top5, + load_local_overrides, + load_sources, + score_roadmap_alignment, + suggest_action, +) + + +class TestLoadSources: + """Tests for sources.yaml loading.""" + + def test_no_sources_file(self, project_root): + """Returns empty list when sources.yaml doesn't exist.""" + result = load_sources(project_root / "sources.yaml") + assert result == [] + + def test_loads_github_sources(self, tmp_path): + """Parses sources.yaml correctly.""" + sources = { + "github": [ + {"account": "rysweet", "repos": ["amplihack", "azlin"]}, + {"account": "rysweet_microsoft", "repos": ["cloud-ecosystem-security/SedanDelivery"]}, + ] + } + path = tmp_path / "sources.yaml" + with open(path, "w") as f: + yaml.dump(sources, f) + + result = load_sources(path) + assert len(result) == 2 + assert result[0]["account"] == "rysweet" + assert result[1]["repos"] == ["cloud-ecosystem-security/SedanDelivery"] + + +class TestFetchGithubIssues: + """Tests for GitHub issue fetching (mocked).""" + + def test_returns_empty_on_gh_failure(self): + """Returns empty list when gh CLI fails.""" + with patch("generate_top5.run_gh", return_value=None): + result = fetch_github_issues("rysweet", ["amplihack"]) + assert result == [] + + def test_parses_issue_data(self): + """Correctly parses gh API JSON output with full metadata.""" + mock_output = json.dumps({ + "repo": "rysweet/amplihack", + "title": "Fix auth bug", + "labels": ["bug", "high"], + "created": "2026-03-01T00:00:00Z", + "updated": "2026-03-07T00:00:00Z", + "number": 123, + "comments": 5, + }) + with patch("generate_top5.run_gh", return_value=mock_output): + result = fetch_github_issues("rysweet", ["amplihack"]) + assert len(result) == 1 + item = result[0] + assert item["source"] == "github_issue" + assert item["priority"] == "HIGH" + assert item["url"] == "https://github.com/rysweet/amplihack/issues/123" + assert item["account"] == "rysweet" + assert item["labels"] == ["bug", "high"] + assert item["comments"] == 5 + assert "score_breakdown" in item + assert "label_priority" in item["score_breakdown"] + + def test_priority_from_labels(self): + """Labels correctly map to priority scores.""" + mock_output = json.dumps({ + "repo": "r/a", "title": "Critical issue", + "labels": ["critical"], "created": "2026-03-07T00:00:00Z", + "updated": "2026-03-07T00:00:00Z", "number": 1, "comments": 0, + }) + with patch("generate_top5.run_gh", return_value=mock_output): + result = fetch_github_issues("r", ["a"]) + assert result[0]["priority"] == "HIGH" + assert result[0]["score_breakdown"]["label_priority"] == 1.0 + + def test_staleness_boosts_score(self): + """Older issues score higher due to staleness.""" + fresh = json.dumps({ + "repo": "r/a", "title": "Fresh", "labels": [], + "created": "2026-03-07T00:00:00Z", "updated": "2026-03-07T00:00:00Z", + "number": 1, "comments": 0, + }) + stale = json.dumps({ + "repo": "r/a", "title": "Stale", "labels": [], + "created": "2026-01-01T00:00:00Z", "updated": "2026-01-01T00:00:00Z", + "number": 2, "comments": 0, + }) + with patch("generate_top5.run_gh", return_value=f"{fresh}\n{stale}"): + result = fetch_github_issues("r", ["a"]) + stale_item = next(c for c in result if "Stale" in c["title"]) + fresh_item = next(c for c in result if "Fresh" in c["title"]) + assert stale_item["raw_score"] > fresh_item["raw_score"] + assert stale_item["days_stale"] > fresh_item["days_stale"] + + +class TestFetchGithubPrs: + """Tests for GitHub PR fetching (mocked).""" + + def test_returns_empty_on_failure(self): + """Returns empty list when gh CLI fails.""" + with patch("generate_top5.run_gh", return_value=None): + result = fetch_github_prs("rysweet", ["amplihack"]) + assert result == [] + + def test_draft_pr_scores_lower(self): + """Draft PRs score lower than non-drafts.""" + draft = json.dumps({ + "repo": "r/a", "title": "Draft PR", "labels": [], + "created": "2026-03-07T00:00:00Z", "updated": "2026-03-07T00:00:00Z", + "number": 1, "draft": True, "comments": 0, + }) + ready = json.dumps({ + "repo": "r/a", "title": "Ready PR", "labels": [], + "created": "2026-03-07T00:00:00Z", "updated": "2026-03-07T00:00:00Z", + "number": 2, "draft": False, "comments": 0, + }) + with patch("generate_top5.run_gh", return_value=f"{draft}\n{ready}"): + result = fetch_github_prs("r", ["a"]) + draft_item = next(c for c in result if "Draft" in c["title"]) + ready_item = next(c for c in result if "Ready" in c["title"]) + assert ready_item["raw_score"] > draft_item["raw_score"] + assert draft_item["is_draft"] is True + assert ready_item["is_draft"] is False + + def test_pr_has_url_and_metadata(self): + """PRs include correct GitHub URL and metadata.""" + mock = json.dumps({ + "repo": "rysweet/amplihack", "title": "Fix stuff", "labels": ["bug"], + "created": "2026-03-07T00:00:00Z", "updated": "2026-03-07T00:00:00Z", + "number": 42, "draft": False, "comments": 0, + }) + with patch("generate_top5.run_gh", return_value=mock): + result = fetch_github_prs("rysweet", ["amplihack"]) + assert result[0]["url"] == "https://github.com/rysweet/amplihack/pull/42" + assert result[0]["account"] == "rysweet" + assert result[0]["labels"] == ["bug"] + + +class TestLoadLocalOverrides: + """Tests for local .pm/ backlog loading.""" + + def test_no_pm_dir(self, project_root): + """Returns empty when .pm doesn't exist.""" + result = load_local_overrides(project_root / ".pm") + assert result == [] + + def test_loads_ready_items(self, pm_dir): + """Loads READY items from backlog.""" + items = { + "items": [ + {"id": "BL-001", "title": "Task A", "status": "READY", "priority": "HIGH", "estimated_hours": 1}, + {"id": "BL-002", "title": "Task B", "status": "DONE", "priority": "HIGH"}, + ] + } + with open(pm_dir / "backlog" / "items.yaml", "w") as f: + yaml.dump(items, f) + + result = load_local_overrides(pm_dir) + assert len(result) == 1 + assert result[0]["source"] == "local" + assert result[0]["item_id"] == "BL-001" + + +class TestExtractRoadmapGoals: + """Tests for roadmap goal extraction.""" + + def test_no_roadmap(self, project_root): + """Returns empty when no roadmap exists.""" + result = extract_roadmap_goals(project_root / ".pm") + assert result == [] + + def test_extracts_goals(self, populated_pm): + """Extracts goals from roadmap markdown.""" + goals = extract_roadmap_goals(populated_pm) + assert len(goals) > 0 + assert any("config" in g.lower() for g in goals) + + +class TestScoreRoadmapAlignment: + """Tests for roadmap alignment scoring.""" + + def test_no_goals_returns_neutral(self): + assert score_roadmap_alignment({"title": "X", "source": "github_issue"}, []) == 0.5 + + def test_matching_title_scores_high(self): + score = score_roadmap_alignment( + {"title": "Implement config parser", "source": "github_issue"}, + ["config parser implementation"], + ) + assert score > 0.0 + + def test_unrelated_title_scores_zero(self): + score = score_roadmap_alignment( + {"title": "Fix authentication bug", "source": "github_issue"}, + ["database migration tool"], + ) + assert score == 0.0 + + +class TestSuggestAction: + """Tests for action suggestion logic.""" + + def test_critical_issue(self): + action = suggest_action({"source": "github_issue", "labels": ["critical"]}) + assert "immediately" in action.lower() + + def test_bug_issue(self): + action = suggest_action({"source": "github_issue", "labels": ["bug"], "days_stale": 1}) + assert "bug" in action.lower() + + def test_stale_pr(self): + action = suggest_action({"source": "github_pr", "is_draft": False, "days_stale": 20}) + assert "stale" in action.lower() or "merge" in action.lower() + + def test_draft_pr(self): + action = suggest_action({"source": "github_pr", "is_draft": True, "days_stale": 1}) + assert "draft" in action.lower() + + def test_local_item(self): + action = suggest_action({"source": "local"}) + assert "backlog" in action.lower() + + +class TestAggregateAndRank: + """Tests for the core aggregation and ranking logic.""" + + def test_empty_input(self): + top, near = aggregate_and_rank([], [], [], []) + assert top == [] + assert near == [] + + def test_returns_max_5(self): + candidates = [ + {"title": f"Item {i}", "source": "github_issue", "raw_score": float(100 - i), + "rationale": "test", "item_id": f"#{i}", "priority": "MEDIUM"} + for i in range(10) + ] + top, near = aggregate_and_rank(candidates, [], [], []) + assert len(top) == 5 + assert len(near) == 5 + + def test_ranked_in_order(self): + issues = [ + {"title": "Low", "source": "github_issue", "raw_score": 30.0, + "rationale": "test", "item_id": "#1", "priority": "LOW"}, + {"title": "High", "source": "github_issue", "raw_score": 90.0, + "rationale": "test", "item_id": "#2", "priority": "HIGH"}, + ] + top, _ = aggregate_and_rank(issues, [], [], []) + assert top[0]["title"] == "High" + assert top[1]["title"] == "Low" + + def test_mixed_sources(self): + issues = [{"title": "Issue", "source": "github_issue", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "HIGH"}] + prs = [{"title": "PR", "source": "github_pr", "raw_score": 80.0, + "rationale": "test", "item_id": "#2", "priority": "HIGH", + "url": "https://github.com/r/a/pull/2", "repo": "r/a"}] + local = [{"title": "Local", "source": "local", "raw_score": 80.0, + "rationale": "test", "item_id": "BL-1", "priority": "MEDIUM"}] + top, _ = aggregate_and_rank(issues, prs, local, []) + assert top[0]["source"] == "github_issue" + assert top[1]["source"] == "github_pr" + assert top[2]["source"] == "local" + + def test_roadmap_alignment_boosts_score(self): + issues = [ + {"title": "Implement config parser", "source": "github_issue", "raw_score": 50.0, + "rationale": "test", "item_id": "#1", "priority": "MEDIUM"}, + {"title": "Fix random thing", "source": "github_issue", "raw_score": 50.0, + "rationale": "test", "item_id": "#2", "priority": "MEDIUM"}, + ] + top, _ = aggregate_and_rank(issues, [], [], ["config parser implementation"]) + config_item = next(r for r in top if "config" in r["title"].lower()) + other_item = next(r for r in top if "random" in r["title"].lower()) + assert config_item["score"] > other_item["score"] + + def test_tiebreak_by_priority(self): + issues = [ + {"title": "Low priority", "source": "github_issue", "raw_score": 50.0, + "rationale": "test", "item_id": "#1", "priority": "LOW"}, + {"title": "High priority", "source": "github_issue", "raw_score": 50.0, + "rationale": "test", "item_id": "#2", "priority": "HIGH"}, + ] + top, _ = aggregate_and_rank(issues, [], [], []) + assert top[0]["priority"] == "HIGH" + assert top[1]["priority"] == "LOW" + + def test_preserves_url_and_repo(self): + prs = [{"title": "PR", "source": "github_pr", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "MEDIUM", + "url": "https://github.com/r/a/pull/1", "repo": "r/a"}] + top, _ = aggregate_and_rank([], prs, [], []) + assert top[0]["url"] == "https://github.com/r/a/pull/1" + assert top[0]["repo"] == "r/a" + + def test_items_have_action(self): + issues = [{"title": "Bug", "source": "github_issue", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "HIGH", + "labels": ["bug"], "days_stale": 5}] + top, _ = aggregate_and_rank(issues, [], [], []) + assert "action" in top[0] + assert len(top[0]["action"]) > 0 + + def test_near_misses_returned(self): + candidates = [ + {"title": f"Item {i}", "source": "github_issue", "raw_score": float(100 - i), + "rationale": "test", "item_id": f"#{i}", "priority": "MEDIUM"} + for i in range(8) + ] + top, near = aggregate_and_rank(candidates, [], [], []) + assert len(top) == 5 + assert len(near) == 3 + assert near[0]["rank"] == 6 + + +class TestBuildRepoSummary: + """Tests for per-repo summary generation.""" + + def test_empty_candidates(self): + result = build_repo_summary([]) + assert result["by_repo"] == {} + assert result["by_account"] == {} + + def test_counts_by_repo(self): + candidates = [ + {"source": "github_issue", "repo": "r/a", "account": "x", "priority": "HIGH"}, + {"source": "github_issue", "repo": "r/a", "account": "x", "priority": "MEDIUM"}, + {"source": "github_pr", "repo": "r/a", "account": "x", "priority": "MEDIUM"}, + {"source": "github_issue", "repo": "r/b", "account": "x", "priority": "HIGH"}, + ] + result = build_repo_summary(candidates) + assert result["by_repo"]["r/a"]["issues"] == 2 + assert result["by_repo"]["r/a"]["prs"] == 1 + assert result["by_repo"]["r/a"]["high_priority"] == 1 + assert result["by_repo"]["r/b"]["issues"] == 1 + + def test_counts_by_account(self): + candidates = [ + {"source": "github_issue", "repo": "r/a", "account": "alice", "priority": "HIGH"}, + {"source": "github_pr", "repo": "r/b", "account": "bob", "priority": "MEDIUM"}, + ] + result = build_repo_summary(candidates) + assert result["by_account"]["alice"]["issues"] == 1 + assert result["by_account"]["bob"]["prs"] == 1 + assert "r/a" in result["by_account"]["alice"]["repos"] + + +class TestGenerateTop5: + """Tests for the main generate_top5 function.""" + + def test_no_sources_no_pm(self, project_root): + with patch("generate_top5.get_current_gh_account", return_value="rysweet"): + result = generate_top5(project_root) + assert result["top5"] == [] + assert result["near_misses"] == [] + assert result["total_candidates"] == 0 + + def test_github_failure_falls_back_to_local(self, populated_pm): + sources = {"github": [{"account": "test", "repos": ["test/repo"]}]} + sources_path = populated_pm / "sources.yaml" + with open(sources_path, "w") as f: + yaml.dump(sources, f) + + with patch("generate_top5.run_gh", return_value=None), \ + patch("generate_top5.get_current_gh_account", return_value="test"): + result = generate_top5(populated_pm.parent, sources_path) + assert result["sources"]["local_items"] > 0 + assert result["sources"]["github_issues"] == 0 + + def test_output_has_summary(self): + with patch("generate_top5.get_current_gh_account", return_value="test"), \ + patch("generate_top5.load_sources", return_value=[]): + result = generate_top5(Path("/nonexistent")) + assert "summary" in result + assert "near_misses" in result + + def test_items_have_required_fields(self): + issues = [{"title": "Test", "source": "github_issue", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "HIGH", + "url": "https://github.com/r/a/issues/1", "repo": "r/a"}] + top, _ = aggregate_and_rank(issues, [], [], []) + required = {"rank", "title", "source", "score", "rationale", "priority", "action", "alignment"} + for item in top: + assert required.issubset(item.keys()), f"Missing: {required - item.keys()}" + + +class TestPriorityLabels: + """Tests for label-to-priority mapping.""" + + def test_critical_is_highest(self): + assert PRIORITY_LABELS["critical"] == 1.0 + + def test_bug_is_high(self): + assert PRIORITY_LABELS["bug"] == 0.8 + + def test_enhancement_is_medium(self): + assert PRIORITY_LABELS["enhancement"] == 0.5 diff --git a/docs/claude/skills/pm-architect/SKILL.md b/docs/claude/skills/pm-architect/SKILL.md new file mode 100644 index 000000000..6b5480872 --- /dev/null +++ b/docs/claude/skills/pm-architect/SKILL.md @@ -0,0 +1,104 @@ +--- +name: pm-architect +description: Expert project manager orchestrating backlog-curator, work-delegator, workstream-coordinator, and roadmap-strategist sub-skills. Coordinates complex software projects through delegation and strategic oversight. Activates when managing projects, coordinating work, or tracking overall progress. +explicit_triggers: + - /top5 +--- + +# PM Architect Skill (Orchestrator) + +## Role + +You are the project manager orchestrating four specialized sub-skills to coordinate software development projects. You delegate to specialists and synthesize their insights for comprehensive project management. + +## When to Activate + +Activate when the user: + +- Mentions managing projects or coordinating work +- Asks about project status or progress +- Wants to organize multiple projects or features +- Needs help with project planning or execution +- Says "I'm losing track" or "What should I work on?" +- Asks "What are the top priorities?" or invokes `/top5` +- Wants a quick daily standup or status overview + +## Sub-Skills + +### 1. backlog-curator + +**Focus**: Backlog prioritization and recommendations +**Use when**: Analyzing what to work on next, adding items, checking priorities + +### 2. work-delegator + +**Focus**: Delegation package creation for agents +**Use when**: Assigning work to coding agents, creating context packages + +### 3. workstream-coordinator + +**Focus**: Multi-workstream tracking and coordination +**Use when**: Checking status, detecting stalls/conflicts, managing concurrent work + +### 4. roadmap-strategist + +**Focus**: Strategic planning and goal alignment +**Use when**: Discussing goals, milestones, strategic direction, roadmap updates + +## Core Workflow + +When user requests project management help: + +1. **Understand intent**: Determine which sub-skill(s) to invoke +2. **Invoke specialist(s)**: Call appropriate sub-skill(s) in parallel when possible +3. **Synthesize results**: Combine insights from sub-skills +4. **Present cohesively**: Deliver unified response to user +5. **Recommend actions**: Suggest next steps + +## Orchestration Patterns + +### Pattern 1: What Should I Work On? + +Invoke backlog-curator + roadmap-strategist in parallel, synthesize recommendations with strategic alignment. + +### Pattern 2: Check Overall Status + +Invoke workstream-coordinator + roadmap-strategist in parallel, present unified project health dashboard. + +### Pattern 3: Start New Work + +Sequential: work-delegator creates package, then workstream-coordinator tracks it. + +### Pattern 4: Initialize PM + +Create .pm/ structure, invoke roadmap-strategist for roadmap generation. + +### Pattern 5: Top 5 Priorities (`/top5`) + +Run `scripts/generate_top5.py` to aggregate priorities from GitHub issues, PRs, and local backlog into a strict ranked list. Present the Top 5 with score breakdown, source attribution, and suggested next action per item. + +Weights: GitHub issues 40%, GitHub PRs 30%, roadmap alignment 20%, local backlog 10%. + +### Pattern 6: Daily Standup + +Run `scripts/generate_daily_status.py` to produce a cross-project status report. Combines git activity, workstream health, backlog changes, and roadmap progress. + +## Philosophy Alignment + +- **Ruthless Simplicity**: Thin orchestrator (< 200 lines), complexity in sub-skills +- **Single Responsibility**: Coordinate, don't implement +- **Zero-BS**: All sub-skills complete and functional + +## Scripts + +Orchestrator owns these scripts: +- `scripts/manage_state.py` — Basic .pm/ state operations (init, add, update, list) +- `scripts/generate_top5.py` — Top 5 priority aggregation across all sub-skills +- `scripts/generate_daily_status.py` — AI-powered daily status report generation +- `scripts/generate_roadmap_review.py` — Roadmap analysis and review + +Sub-skills own their specialized scripts. + +## Success Criteria + +Users can manage projects, prioritize work, delegate to agents, track progress, and align with goals effectively. diff --git a/docs/claude/skills/pm-architect/scripts/generate_top5.py b/docs/claude/skills/pm-architect/scripts/generate_top5.py new file mode 100644 index 000000000..829d3b0b1 --- /dev/null +++ b/docs/claude/skills/pm-architect/scripts/generate_top5.py @@ -0,0 +1,584 @@ +#!/usr/bin/env python3 +"""Aggregate priorities across GitHub accounts into a strict Top 5 ranked list. + +Queries GitHub issues and PRs across configured accounts/repos, scores them +by priority labels, staleness, blocking status, and roadmap alignment. + +Falls back to .pm/ YAML state if GitHub is unavailable or for enrichment. + +Usage: + python generate_top5.py [--project-root PATH] [--sources PATH] + +Returns JSON with top 5 priorities. +""" + +import argparse +import json +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import yaml + + +# Aggregation weights +WEIGHT_ISSUES = 0.40 +WEIGHT_PRS = 0.30 +WEIGHT_ROADMAP = 0.20 +WEIGHT_LOCAL = 0.10 # .pm/ overrides + +TOP_N = 5 + +# Label-to-priority mapping +PRIORITY_LABELS = { + "critical": 1.0, + "priority:critical": 1.0, + "high": 0.9, + "priority:high": 0.9, + "bug": 0.8, + "medium": 0.6, + "priority:medium": 0.6, + "enhancement": 0.5, + "feature": 0.5, + "low": 0.3, + "priority:low": 0.3, +} + + +def load_yaml(path: Path) -> dict[str, Any]: + """Load YAML file safely.""" + if not path.exists(): + return {} + with open(path) as f: + return yaml.safe_load(f) or {} + + +def load_sources(sources_path: Path) -> list[dict]: + """Load GitHub source configuration.""" + data = load_yaml(sources_path) + return data.get("github", []) + + +def run_gh(args: list[str], account: str | None = None) -> str | None: + """Run a gh CLI command, optionally switching account first. + + Returns stdout on success, None on failure. + """ + if account: + switch = subprocess.run( + ["gh", "auth", "switch", "--user", account], + capture_output=True, text=True, timeout=10, + ) + if switch.returncode != 0: + return None + + try: + result = subprocess.run( + ["gh"] + args, + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError): + return None + + +def get_current_gh_account() -> str | None: + """Get the currently active gh account.""" + try: + result = subprocess.run( + ["gh", "api", "user", "--jq", ".login"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + return None + + +def fetch_github_issues(account: str, repos: list[str]) -> list[dict]: + """Fetch open issues for an account's repos from GitHub.""" + candidates = [] + + # Use search API to get all issues at once + repo_qualifiers = " ".join(f"repo:{r}" if "/" in r else f"repo:{account}/{r}" for r in repos) + query = f"is:open is:issue {repo_qualifiers}" + + jq_filter = ( + '.items[] | {' + 'repo: (.repository_url | split("/") | .[-2:] | join("/")),' + 'title: .title,' + 'labels: [.labels[].name],' + 'created: .created_at,' + 'updated: .updated_at,' + 'number: .number,' + 'comments: .comments' + '}' + ) + + output = run_gh( + ["api", "search/issues", "--method", "GET", + "-f", f"q={query}", "-f", "per_page=50", + "--jq", jq_filter], + account=account, + ) + + if not output: + return [] + + now = datetime.now(UTC) + + for line in output.strip().splitlines(): + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + + # Score by labels + labels = [lbl.lower() for lbl in item.get("labels", [])] + priority_score = 0.5 # default + for label in labels: + if label in PRIORITY_LABELS: + priority_score = max(priority_score, PRIORITY_LABELS[label]) + + # Staleness boost: older updated = needs attention + try: + updated = datetime.fromisoformat(item["updated"].replace("Z", "+00:00")) + days_stale = (now - updated).total_seconds() / 86400 + except (ValueError, KeyError): + days_stale = 0 + + staleness_score = min(days_stale / 14.0, 1.0) # Max at 2 weeks + + # Comment activity: more comments = more discussion = potentially blocked + comments = item.get("comments", 0) + activity_score = min(comments / 10.0, 1.0) + + raw_score = (priority_score * 0.50 + staleness_score * 0.30 + activity_score * 0.20) * 100 + + # Rationale + reasons = [] + if priority_score >= 0.8: + reasons.append(f"labeled {', '.join(lbl for lbl in labels if lbl in PRIORITY_LABELS)}") + if days_stale > 7: + reasons.append(f"stale {days_stale:.0f}d") + if comments > 3: + reasons.append(f"{comments} comments") + if not reasons: + reasons.append("open issue") + + repo = item.get("repo", "") + candidates.append({ + "title": item["title"], + "source": "github_issue", + "raw_score": round(raw_score, 1), + "score_breakdown": { + "label_priority": round(priority_score, 2), + "staleness": round(staleness_score, 2), + "activity": round(activity_score, 2), + }, + "rationale": ", ".join(reasons), + "item_id": f"{repo}#{item['number']}", + "priority": "HIGH" if priority_score >= 0.8 else "MEDIUM" if priority_score >= 0.5 else "LOW", + "repo": repo, + "account": account, + "url": f"https://github.com/{repo}/issues/{item['number']}", + "labels": item.get("labels", []), + "created": item.get("created", ""), + "updated": item.get("updated", ""), + "days_stale": round(days_stale, 1), + "comments": comments, + }) + + return candidates + + +def fetch_github_prs(account: str, repos: list[str]) -> list[dict]: + """Fetch open PRs for an account's repos from GitHub.""" + candidates = [] + + repo_qualifiers = " ".join(f"repo:{r}" if "/" in r else f"repo:{account}/{r}" for r in repos) + query = f"is:open is:pr {repo_qualifiers}" + + jq_filter = ( + '.items[] | {' + 'repo: (.repository_url | split("/") | .[-2:] | join("/")),' + 'title: .title,' + 'labels: [.labels[].name],' + 'created: .created_at,' + 'updated: .updated_at,' + 'number: .number,' + 'draft: .draft,' + 'comments: .comments' + '}' + ) + + output = run_gh( + ["api", "search/issues", "--method", "GET", + "-f", f"q={query}", "-f", "per_page=50", + "--jq", jq_filter], + account=account, + ) + + if not output: + return [] + + now = datetime.now(UTC) + + for line in output.strip().splitlines(): + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + + is_draft = item.get("draft", False) + + # PRs waiting for review are higher priority than drafts + base_score = 0.4 if is_draft else 0.7 + + # Labels boost + labels = [lbl.lower() for lbl in item.get("labels", [])] + for label in labels: + if label in PRIORITY_LABELS: + base_score = max(base_score, PRIORITY_LABELS[label]) + + # Staleness: PRs waiting for review get more urgent over time + try: + updated = datetime.fromisoformat(item["updated"].replace("Z", "+00:00")) + days_stale = (now - updated).total_seconds() / 86400 + except (ValueError, KeyError): + days_stale = 0 + + staleness_score = min(days_stale / 7.0, 1.0) # PRs stale faster (1 week max) + + raw_score = (base_score * 0.60 + staleness_score * 0.40) * 100 + + reasons = [] + if is_draft: + reasons.append("draft PR") + else: + reasons.append("awaiting review") + if days_stale > 3: + reasons.append(f"stale {days_stale:.0f}d") + if labels: + relevant = [lbl for lbl in labels if lbl in PRIORITY_LABELS] + if relevant: + reasons.append(f"labeled {', '.join(relevant)}") + + repo = item.get("repo", "") + candidates.append({ + "title": item["title"], + "source": "github_pr", + "raw_score": round(raw_score, 1), + "score_breakdown": { + "base_priority": round(base_score, 2), + "staleness": round(staleness_score, 2), + }, + "rationale": ", ".join(reasons), + "item_id": f"{repo}#{item['number']}", + "priority": "HIGH" if base_score >= 0.8 else "MEDIUM", + "repo": repo, + "account": account, + "url": f"https://github.com/{repo}/pull/{item['number']}", + "labels": item.get("labels", []), + "created": item.get("created", ""), + "updated": item.get("updated", ""), + "days_stale": round(days_stale, 1), + "is_draft": is_draft, + }) + + return candidates + + +def load_local_overrides(pm_dir: Path) -> list[dict]: + """Load manually-added items from .pm/backlog for local enrichment.""" + backlog_data = load_yaml(pm_dir / "backlog" / "items.yaml") + items = backlog_data.get("items", []) + ready_items = [item for item in items if item.get("status") == "READY"] + + candidates = [] + priority_map = {"HIGH": 1.0, "MEDIUM": 0.6, "LOW": 0.3} + + for item in ready_items: + priority = item.get("priority", "MEDIUM") + priority_score = priority_map.get(priority, 0.5) + hours = item.get("estimated_hours", 4) + ease_score = 1.0 if hours < 2 else 0.6 if hours <= 6 else 0.3 + + raw_score = (priority_score * 0.60 + ease_score * 0.40) * 100 + + reasons = [] + if priority == "HIGH": + reasons.append("HIGH priority") + if hours < 2: + reasons.append("quick win") + if not reasons: + reasons.append("local backlog item") + + candidates.append({ + "title": item.get("title", item["id"]), + "source": "local", + "raw_score": round(raw_score, 1), + "rationale": ", ".join(reasons), + "item_id": item["id"], + "priority": priority, + }) + + return candidates + + +def extract_roadmap_goals(pm_dir: Path) -> list[str]: + """Extract strategic goals from roadmap markdown.""" + roadmap_path = pm_dir / "roadmap.md" + if not roadmap_path.exists(): + return [] + + text = roadmap_path.read_text() + goals = [] + + for line in text.splitlines(): + line = line.strip() + if line.startswith("## ") or line.startswith("### "): + goals.append(line.lstrip("#").strip()) + elif line.startswith("- "): + goals.append(line.removeprefix("- ").strip()) + elif line.startswith("* "): + goals.append(line.removeprefix("* ").strip()) + + return goals + + +def score_roadmap_alignment(candidate: dict, goals: list[str]) -> float: + """Score how well a candidate aligns with roadmap goals. Returns 0.0-1.0.""" + if not goals: + return 0.5 + + title_lower = candidate["title"].lower() + max_alignment = 0.0 + + for goal in goals: + goal_words = set(goal.lower().split()) + goal_words -= {"the", "a", "an", "and", "or", "to", "for", "in", "of", "is", "with"} + if not goal_words: + continue + + matching = sum(1 for word in goal_words if word in title_lower) + alignment = matching / len(goal_words) if goal_words else 0.0 + max_alignment = max(max_alignment, alignment) + + return min(max_alignment, 1.0) + + +def suggest_action(candidate: dict) -> str: + """Suggest a concrete next action for a candidate.""" + source = candidate["source"] + days_stale = candidate.get("days_stale", 0) + labels = candidate.get("labels", []) + + if source == "github_pr": + if candidate.get("is_draft"): + return "Finish draft or close if abandoned" + if days_stale > 14: + return "Merge, close, or rebase — stale >2 weeks" + if days_stale > 7: + return "Review and merge or request changes" + return "Review PR" + elif source == "github_issue": + if any(lbl in ("critical", "priority:critical") for lbl in labels): + return "Fix immediately — critical severity" + if any(lbl in ("bug",) for lbl in labels): + return "Investigate and fix bug" + if days_stale > 30: + return "Triage: still relevant? Close or reprioritize" + return "Work on issue or delegate" + elif source == "local": + return "Pick up from local backlog" + return "Review" + + +def aggregate_and_rank( + issues: list[dict], + prs: list[dict], + local: list[dict], + goals: list[str], + top_n: int = TOP_N, +) -> tuple[list[dict], list[dict]]: + """Aggregate candidates from all sources and rank by weighted score. + + Returns (top_n items, next 5 near-misses). + """ + scored = [] + + source_weights = { + "github_issue": WEIGHT_ISSUES, + "github_pr": WEIGHT_PRS, + "local": WEIGHT_LOCAL, + } + + all_candidates = issues + prs + local + + for candidate in all_candidates: + source = candidate["source"] + source_weight = source_weights.get(source, 0.25) + raw = candidate["raw_score"] + + alignment = score_roadmap_alignment(candidate, goals) + final_score = (source_weight * raw) + (WEIGHT_ROADMAP * alignment * 100) + + entry = { + "title": candidate["title"], + "source": candidate["source"], + "score": round(final_score, 1), + "raw_score": candidate["raw_score"], + "source_weight": source_weight, + "rationale": candidate["rationale"], + "item_id": candidate.get("item_id", ""), + "priority": candidate.get("priority", "MEDIUM"), + "alignment": round(alignment, 2), + "action": suggest_action(candidate), + } + # Preserve all metadata from the candidate + for key in ("url", "repo", "account", "labels", "created", "updated", + "days_stale", "comments", "is_draft", "score_breakdown"): + if key in candidate: + entry[key] = candidate[key] + + scored.append(entry) + + priority_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2} + scored.sort(key=lambda x: (-x["score"], priority_order.get(x["priority"], 1))) + + top = scored[:top_n] + for i, item in enumerate(top): + item["rank"] = i + 1 + + near_misses = scored[top_n:top_n + 5] + for i, item in enumerate(near_misses): + item["rank"] = top_n + i + 1 + + return top, near_misses + + +def build_repo_summary(all_candidates: list[dict]) -> dict: + """Build a per-repo, per-account summary of open work.""" + repos: dict[str, dict] = {} + accounts: dict[str, dict] = {} + + for c in all_candidates: + repo = c.get("repo", "local") + account = c.get("account", "local") + + if repo not in repos: + repos[repo] = {"issues": 0, "prs": 0, "high_priority": 0} + if account not in accounts: + accounts[account] = {"issues": 0, "prs": 0, "repos": set()} + + if c["source"] == "github_issue": + repos[repo]["issues"] += 1 + accounts[account]["issues"] += 1 + elif c["source"] == "github_pr": + repos[repo]["prs"] += 1 + accounts[account]["prs"] += 1 + + if c.get("priority") == "HIGH": + repos[repo]["high_priority"] += 1 + + accounts[account]["repos"].add(repo) + + # Convert sets to lists for JSON serialization + for a in accounts.values(): + a["repos"] = sorted(a["repos"]) + + # Sort repos by total open items descending + sorted_repos = dict(sorted(repos.items(), key=lambda x: -(x[1]["issues"] + x[1]["prs"]))) + + return {"by_repo": sorted_repos, "by_account": accounts} + + +def generate_top5(project_root: Path, sources_path: Path | None = None) -> dict: + """Generate the Top 5 priority list from GitHub + local state.""" + pm_dir = project_root / ".pm" + + if sources_path is None: + sources_path = pm_dir / "sources.yaml" + + # Load GitHub sources config + sources = load_sources(sources_path) + + # Remember original account to restore after + original_account = get_current_gh_account() + + # Fetch from GitHub + all_issues = [] + all_prs = [] + accounts_queried = [] + + for source in sources: + account = source.get("account", "") + repos = source.get("repos", []) + if not account or not repos: + continue + + accounts_queried.append(account) + all_issues.extend(fetch_github_issues(account, repos)) + all_prs.extend(fetch_github_prs(account, repos)) + + # Restore original account + if original_account and accounts_queried: + run_gh(["auth", "switch", "--user", original_account]) + + # Load local overrides + local = [] + if pm_dir.exists(): + local = load_local_overrides(pm_dir) + + # Load roadmap goals + goals = extract_roadmap_goals(pm_dir) if pm_dir.exists() else [] + + # Aggregate and rank + all_candidates = all_issues + all_prs + local + top5, near_misses = aggregate_and_rank(all_issues, all_prs, local, goals) + summary = build_repo_summary(all_candidates) + + return { + "top5": top5, + "near_misses": near_misses, + "summary": summary, + "sources": { + "github_issues": len(all_issues), + "github_prs": len(all_prs), + "local_items": len(local), + "roadmap_goals": len(goals), + "accounts": accounts_queried, + }, + "total_candidates": len(all_candidates), + } + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description="Generate Top 5 priorities from GitHub + local state") + parser.add_argument( + "--project-root", type=Path, default=Path.cwd(), help="Project root directory" + ) + parser.add_argument( + "--sources", type=Path, default=None, help="Path to sources.yaml (default: .pm/sources.yaml)" + ) + + args = parser.parse_args() + + try: + result = generate_top5(args.project_root, args.sources) + print(json.dumps(result, indent=2)) + return 0 + except Exception as e: + print(json.dumps({"error": str(e)}), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-error-handling.yaml b/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-error-handling.yaml new file mode 100644 index 000000000..1e281b6e1 --- /dev/null +++ b/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-error-handling.yaml @@ -0,0 +1,88 @@ +# Outside-in test for /top5 error handling +# Validates generate_top5.py handles failure modes gracefully: +# invalid sources, missing gh CLI, malformed YAML. + +scenario: + name: "Top 5 Priorities - Error Handling" + description: | + Verifies that generate_top5.py degrades gracefully when GitHub is + unreachable, sources.yaml is malformed, or the project root is invalid. + The script should always return valid JSON, never crash. + type: cli + level: 2 + tags: [cli, error-handling, top5, pm-architect, resilience] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + + steps: + # Test 1: Non-existent project root (no .pm/ dir) + - action: launch + target: "python" + args: + - ".claude/skills/pm-architect/scripts/generate_top5.py" + - "--project-root" + - "/tmp/top5-test-nonexistent-path-12345" + description: "Run with non-existent project root" + timeout: 15s + + - action: verify_output + contains: '"top5"' + description: "Still returns valid JSON with top5 key" + + - action: verify_output + contains: '"total_candidates": 0' + description: "Reports zero candidates when no sources available" + + - action: verify_exit_code + expected: 0 + description: "Exits cleanly even with missing project root" + + # Test 2: Malformed sources.yaml + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm" && + echo "not: [valid: yaml: {{{{" > "$TMPDIR/.pm/sources.yaml" && + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run with malformed sources.yaml" + timeout: 15s + + # Script should handle YAML parse errors (may return error JSON or empty results) + - action: verify_exit_code + expected_one_of: [0, 1] + description: "Exits with 0 or 1, never crashes with traceback" + + # Test 3: Empty sources.yaml (valid but no accounts) + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm" && + echo "github: []" > "$TMPDIR/.pm/sources.yaml" && + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run with empty sources list" + timeout: 15s + + - action: verify_output + contains: '"top5"' + description: "Returns valid JSON with empty top5" + + - action: verify_output + contains: '"total_candidates": 0' + description: "Reports zero candidates" + + - action: verify_exit_code + expected: 0 + description: "Exits cleanly with empty sources" + + cleanup: + - action: stop_application + force: true + description: "Ensure all processes are terminated" diff --git a/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-local-overrides.yaml b/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-local-overrides.yaml new file mode 100644 index 000000000..3d763c94b --- /dev/null +++ b/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-local-overrides.yaml @@ -0,0 +1,97 @@ +# Outside-in test for /top5 local backlog integration +# Validates generate_top5.py incorporates .pm/backlog items and roadmap goals +# into the priority ranking alongside (or instead of) GitHub data. + +scenario: + name: "Top 5 Priorities - Local Overrides and Roadmap Alignment" + description: | + Verifies that generate_top5.py reads .pm/backlog/items.yaml for local + priorities and .pm/roadmap.md for strategic alignment scoring. + Tests the full aggregation pipeline without requiring GitHub access. + type: cli + level: 2 + tags: [cli, integration, top5, pm-architect, local, roadmap] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + + steps: + # Setup local .pm/ state with backlog items and roadmap + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm/backlog" && + cat > "$TMPDIR/.pm/backlog/items.yaml" << 'BACKLOG' + items: + - id: "local-001" + title: "Fix authentication timeout bug" + status: "READY" + priority: "HIGH" + estimated_hours: 1 + - id: "local-002" + title: "Add dashboard metrics" + status: "READY" + priority: "MEDIUM" + estimated_hours: 4 + - id: "local-003" + title: "Refactor logging module" + status: "IN_PROGRESS" + priority: "LOW" + estimated_hours: 8 + BACKLOG + cat > "$TMPDIR/.pm/roadmap.md" << 'ROADMAP' + ## Q1 Goals + ### Improve authentication reliability + - Fix timeout and retry logic + ### Add observability dashboard + - Metrics and monitoring + ROADMAP + cat > "$TMPDIR/.pm/sources.yaml" << 'SOURCES' + github: [] + SOURCES + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run with local backlog items and roadmap goals, no GitHub sources" + timeout: 15s + + # Verify local items appear in output + - action: verify_output + contains: "Fix authentication timeout bug" + timeout: 5s + description: "HIGH priority READY item appears in results" + + - action: verify_output + contains: "Add dashboard metrics" + description: "MEDIUM priority READY item appears in results" + + # IN_PROGRESS items should NOT appear (only READY items are loaded) + - action: verify_output + not_contains: "Refactor logging module" + description: "IN_PROGRESS item is excluded (only READY items loaded)" + + # Verify source attribution + - action: verify_output + contains: '"source": "local"' + description: "Items attributed to local source" + + # Verify roadmap goals were loaded + - action: verify_output + contains: '"roadmap_goals"' + description: "Roadmap goals count present in sources" + + # Verify alignment scoring (auth bug should align with roadmap goal) + - action: verify_output + matches: '"alignment":\\s*[0-9]' + description: "Items have alignment scores" + + - action: verify_exit_code + expected: 0 + description: "Script exits cleanly" + + cleanup: + - action: stop_application + force: true + description: "Ensure process is terminated" diff --git a/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-ranking.yaml b/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-ranking.yaml new file mode 100644 index 000000000..4fac284f8 --- /dev/null +++ b/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-ranking.yaml @@ -0,0 +1,104 @@ +# Outside-in test for /top5 ranking correctness +# Validates that output is strictly ranked by score descending, +# limited to 5 items, and each item has a rank field 1-5. + +scenario: + name: "Top 5 Priorities - Ranking and Limit Enforcement" + description: | + Verifies that generate_top5.py returns exactly TOP_N (5) items, + ranked by descending score, with rank fields 1 through 5. + Uses local backlog with >5 items to verify the limit. + type: cli + level: 2 + tags: [cli, integration, top5, pm-architect, ranking] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + + steps: + # Setup: 7 local items to verify only top 5 are returned + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm/backlog" && + cat > "$TMPDIR/.pm/backlog/items.yaml" << 'BACKLOG' + items: + - id: "item-1" + title: "Critical security fix" + status: "READY" + priority: "HIGH" + estimated_hours: 1 + - id: "item-2" + title: "API rate limiting" + status: "READY" + priority: "HIGH" + estimated_hours: 2 + - id: "item-3" + title: "Database migration" + status: "READY" + priority: "MEDIUM" + estimated_hours: 4 + - id: "item-4" + title: "Update documentation" + status: "READY" + priority: "MEDIUM" + estimated_hours: 6 + - id: "item-5" + title: "Add unit tests" + status: "READY" + priority: "MEDIUM" + estimated_hours: 3 + - id: "item-6" + title: "Refactor config loader" + status: "READY" + priority: "LOW" + estimated_hours: 8 + - id: "item-7" + title: "Add logging headers" + status: "READY" + priority: "LOW" + estimated_hours: 10 + BACKLOG + cat > "$TMPDIR/.pm/sources.yaml" << 'SOURCES' + github: [] + SOURCES + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run with 7 local items to verify top-5 limit" + timeout: 15s + + # Verify rank fields 1-5 exist + - action: verify_output + contains: '"rank": 1' + timeout: 5s + description: "First ranked item present" + + - action: verify_output + contains: '"rank": 5' + description: "Fifth ranked item present" + + # Verify rank 6 and 7 are NOT in output (limit enforced) + - action: verify_output + not_contains: '"rank": 6' + description: "No sixth rank (limit to 5)" + + - action: verify_output + not_contains: '"rank": 7' + description: "No seventh rank (limit to 5)" + + # Verify total_candidates reflects all 7 items considered + - action: verify_output + contains: '"total_candidates": 7' + description: "Total candidates count includes all 7 items" + + - action: verify_exit_code + expected: 0 + description: "Script exits cleanly" + + cleanup: + - action: stop_application + force: true + description: "Ensure process is terminated" diff --git a/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-smoke.yaml b/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-smoke.yaml new file mode 100644 index 000000000..d3d8ee24c --- /dev/null +++ b/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-smoke.yaml @@ -0,0 +1,52 @@ +# Outside-in smoke test for /top5 priority aggregation +# Validates the generate_top5.py CLI produces valid JSON output +# with the expected structure from a user's perspective. + +scenario: + name: "Top 5 Priorities - Smoke Test" + description: | + Verifies that generate_top5.py runs successfully, produces valid JSON, + and contains the expected top-level keys (top5, sources, total_candidates). + Uses an empty project root so no GitHub calls are made. + type: cli + level: 1 + tags: [cli, smoke, top5, pm-architect] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + + steps: + # Run with empty project root (no .pm/ dir, no sources.yaml) + - action: launch + target: "python" + args: + - "scripts/generate_top5.py" + - "--project-root" + - "/tmp/top5-test-empty" + working_directory: ".claude/skills/pm-architect" + description: "Run generate_top5.py with empty project root" + timeout: 15s + + # Verify valid JSON output with expected keys + - action: verify_output + contains: '"top5"' + timeout: 5s + description: "Output contains top5 key" + + - action: verify_output + contains: '"sources"' + description: "Output contains sources key" + + - action: verify_output + contains: '"total_candidates"' + description: "Output contains total_candidates key" + + - action: verify_exit_code + expected: 0 + description: "Script exits cleanly with code 0" + + cleanup: + - action: stop_application + force: true + description: "Ensure process is terminated" diff --git a/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-with-sources.yaml b/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-with-sources.yaml new file mode 100644 index 000000000..a76e52d4a --- /dev/null +++ b/docs/claude/skills/pm-architect/scripts/tests/agentic/test-top5-with-sources.yaml @@ -0,0 +1,83 @@ +# Outside-in test for /top5 with configured sources +# Validates generate_top5.py queries GitHub when sources.yaml is provided, +# produces ranked output with score breakdown and source attribution. + +scenario: + name: "Top 5 Priorities - GitHub Source Aggregation" + description: | + Verifies that generate_top5.py correctly reads a sources.yaml config, + queries GitHub for issues and PRs, aggregates scores, and returns + a ranked list with proper source attribution and metadata. + Requires gh CLI authenticated with at least one account. + type: cli + level: 2 + tags: [cli, integration, top5, pm-architect, github] + + prerequisites: + - "Python 3.11+ is available" + - "PyYAML is installed" + - "gh CLI is authenticated" + - "Network access to GitHub API" + + environment: + variables: + GH_PAGER: "" + + steps: + # Setup: create a minimal sources.yaml pointing to a known public repo + - action: launch + target: "bash" + args: + - "-c" + - | + TMPDIR=$(mktemp -d) && + mkdir -p "$TMPDIR/.pm" && + cat > "$TMPDIR/.pm/sources.yaml" << 'SOURCES' + github: + - account: rysweet + repos: + - amplihack + SOURCES + python .claude/skills/pm-architect/scripts/generate_top5.py --project-root "$TMPDIR" --sources "$TMPDIR/.pm/sources.yaml" + description: "Run generate_top5.py with sources pointing to amplihack repo" + timeout: 45s + + # Verify JSON structure + - action: verify_output + contains: '"top5"' + timeout: 5s + description: "Output has top5 array" + + - action: verify_output + contains: '"github_issues"' + description: "Sources breakdown includes github_issues count" + + - action: verify_output + contains: '"github_prs"' + description: "Sources breakdown includes github_prs count" + + - action: verify_output + contains: '"accounts"' + description: "Sources breakdown includes accounts queried" + + # Verify ranked items have required fields + - action: verify_output + matches: '"score":\\s*[0-9]' + description: "Items have numeric scores" + + - action: verify_output + matches: '"source":\\s*"github_(issue|pr)"' + description: "Items have source attribution" + + - action: verify_output + matches: '"rationale":' + description: "Items include rationale text" + + - action: verify_exit_code + expected: 0 + description: "Script exits cleanly" + + cleanup: + - action: stop_application + force: true + description: "Ensure process is terminated" diff --git a/docs/claude/skills/pm-architect/scripts/tests/conftest.py b/docs/claude/skills/pm-architect/scripts/tests/conftest.py index 40af58e5a..448aa9983 100644 --- a/docs/claude/skills/pm-architect/scripts/tests/conftest.py +++ b/docs/claude/skills/pm-architect/scripts/tests/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock import pytest +import yaml @pytest.fixture @@ -131,3 +132,129 @@ def sample_daily_status_output() -> str: 1. Prioritize design review for API refactoring 2. Address technical debt in authentication system """ + + +# --- Top 5 Priority Aggregation Fixtures --- + + +@pytest.fixture +def pm_dir(tmp_path: Path) -> Path: + """Create .pm/ directory structure with sample data.""" + pm = tmp_path / ".pm" + (pm / "backlog").mkdir(parents=True) + (pm / "workstreams").mkdir(parents=True) + (pm / "delegations").mkdir(parents=True) + return pm + + +@pytest.fixture +def sample_backlog_items() -> dict: + """Sample backlog items YAML data.""" + return { + "items": [ + { + "id": "BL-001", + "title": "Fix authentication bug", + "description": "Auth tokens expire prematurely", + "priority": "HIGH", + "estimated_hours": 2, + "status": "READY", + "tags": ["auth", "bug"], + "dependencies": [], + }, + { + "id": "BL-002", + "title": "Implement config parser", + "description": "Parse YAML and JSON config files", + "priority": "MEDIUM", + "estimated_hours": 4, + "status": "READY", + "tags": ["config", "core"], + "dependencies": [], + }, + { + "id": "BL-003", + "title": "Add logging framework", + "description": "Structured logging with JSON output", + "priority": "LOW", + "estimated_hours": 8, + "status": "READY", + "tags": ["infrastructure"], + "dependencies": ["BL-002"], + }, + { + "id": "BL-004", + "title": "Write API documentation", + "description": "Document all REST endpoints", + "priority": "MEDIUM", + "estimated_hours": 3, + "status": "READY", + "tags": ["docs"], + "dependencies": [], + }, + { + "id": "BL-005", + "title": "Database migration tool", + "description": "Automated schema migrations", + "priority": "HIGH", + "estimated_hours": 6, + "status": "READY", + "tags": ["database", "core"], + "dependencies": ["BL-002"], + }, + { + "id": "BL-006", + "title": "Refactor test suite", + "description": "Improve test performance and coverage", + "priority": "MEDIUM", + "estimated_hours": 1, + "status": "IN_PROGRESS", + "tags": ["test"], + "dependencies": [], + }, + ] + } + + +@pytest.fixture +def populated_pm(pm_dir, sample_backlog_items): + """Create fully populated .pm/ directory.""" + with open(pm_dir / "backlog" / "items.yaml", "w") as f: + yaml.dump(sample_backlog_items, f) + + ws_data = { + "id": "ws-1", + "backlog_id": "BL-006", + "title": "Test Suite Refactor", + "agent": "builder", + "status": "RUNNING", + "last_activity": "2020-01-01T00:00:00Z", + } + with open(pm_dir / "workstreams" / "ws-1.yaml", "w") as f: + yaml.dump(ws_data, f) + + deleg_data = { + "id": "DEL-001", + "title": "Implement caching layer", + "status": "READY", + "backlog_id": "BL-002", + } + with open(pm_dir / "delegations" / "del-001.yaml", "w") as f: + yaml.dump(deleg_data, f) + + roadmap = """# Project Roadmap + +## Q1 Goals + +### Core Infrastructure +- Implement config parser +- Database migration tool +- Logging framework + +### Quality +- Test coverage above 80% +- API documentation complete +""" + (pm_dir / "roadmap.md").write_text(roadmap) + + return pm_dir diff --git a/docs/claude/skills/pm-architect/scripts/tests/test_generate_top5.py b/docs/claude/skills/pm-architect/scripts/tests/test_generate_top5.py new file mode 100644 index 000000000..8928e6a21 --- /dev/null +++ b/docs/claude/skills/pm-architect/scripts/tests/test_generate_top5.py @@ -0,0 +1,420 @@ +"""Tests for generate_top5.py - GitHub-native priority aggregation.""" + +import json +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from generate_top5 import ( + PRIORITY_LABELS, + aggregate_and_rank, + build_repo_summary, + extract_roadmap_goals, + fetch_github_issues, + fetch_github_prs, + generate_top5, + load_local_overrides, + load_sources, + score_roadmap_alignment, + suggest_action, +) + + +class TestLoadSources: + """Tests for sources.yaml loading.""" + + def test_no_sources_file(self, project_root): + """Returns empty list when sources.yaml doesn't exist.""" + result = load_sources(project_root / "sources.yaml") + assert result == [] + + def test_loads_github_sources(self, tmp_path): + """Parses sources.yaml correctly.""" + sources = { + "github": [ + {"account": "rysweet", "repos": ["amplihack", "azlin"]}, + {"account": "rysweet_microsoft", "repos": ["cloud-ecosystem-security/SedanDelivery"]}, + ] + } + path = tmp_path / "sources.yaml" + with open(path, "w") as f: + yaml.dump(sources, f) + + result = load_sources(path) + assert len(result) == 2 + assert result[0]["account"] == "rysweet" + assert result[1]["repos"] == ["cloud-ecosystem-security/SedanDelivery"] + + +class TestFetchGithubIssues: + """Tests for GitHub issue fetching (mocked).""" + + def test_returns_empty_on_gh_failure(self): + """Returns empty list when gh CLI fails.""" + with patch("generate_top5.run_gh", return_value=None): + result = fetch_github_issues("rysweet", ["amplihack"]) + assert result == [] + + def test_parses_issue_data(self): + """Correctly parses gh API JSON output with full metadata.""" + mock_output = json.dumps({ + "repo": "rysweet/amplihack", + "title": "Fix auth bug", + "labels": ["bug", "high"], + "created": "2026-03-01T00:00:00Z", + "updated": "2026-03-07T00:00:00Z", + "number": 123, + "comments": 5, + }) + with patch("generate_top5.run_gh", return_value=mock_output): + result = fetch_github_issues("rysweet", ["amplihack"]) + assert len(result) == 1 + item = result[0] + assert item["source"] == "github_issue" + assert item["priority"] == "HIGH" + assert item["url"] == "https://github.com/rysweet/amplihack/issues/123" + assert item["account"] == "rysweet" + assert item["labels"] == ["bug", "high"] + assert item["comments"] == 5 + assert "score_breakdown" in item + assert "label_priority" in item["score_breakdown"] + + def test_priority_from_labels(self): + """Labels correctly map to priority scores.""" + mock_output = json.dumps({ + "repo": "r/a", "title": "Critical issue", + "labels": ["critical"], "created": "2026-03-07T00:00:00Z", + "updated": "2026-03-07T00:00:00Z", "number": 1, "comments": 0, + }) + with patch("generate_top5.run_gh", return_value=mock_output): + result = fetch_github_issues("r", ["a"]) + assert result[0]["priority"] == "HIGH" + assert result[0]["score_breakdown"]["label_priority"] == 1.0 + + def test_staleness_boosts_score(self): + """Older issues score higher due to staleness.""" + fresh = json.dumps({ + "repo": "r/a", "title": "Fresh", "labels": [], + "created": "2026-03-07T00:00:00Z", "updated": "2026-03-07T00:00:00Z", + "number": 1, "comments": 0, + }) + stale = json.dumps({ + "repo": "r/a", "title": "Stale", "labels": [], + "created": "2026-01-01T00:00:00Z", "updated": "2026-01-01T00:00:00Z", + "number": 2, "comments": 0, + }) + with patch("generate_top5.run_gh", return_value=f"{fresh}\n{stale}"): + result = fetch_github_issues("r", ["a"]) + stale_item = next(c for c in result if "Stale" in c["title"]) + fresh_item = next(c for c in result if "Fresh" in c["title"]) + assert stale_item["raw_score"] > fresh_item["raw_score"] + assert stale_item["days_stale"] > fresh_item["days_stale"] + + +class TestFetchGithubPrs: + """Tests for GitHub PR fetching (mocked).""" + + def test_returns_empty_on_failure(self): + """Returns empty list when gh CLI fails.""" + with patch("generate_top5.run_gh", return_value=None): + result = fetch_github_prs("rysweet", ["amplihack"]) + assert result == [] + + def test_draft_pr_scores_lower(self): + """Draft PRs score lower than non-drafts.""" + draft = json.dumps({ + "repo": "r/a", "title": "Draft PR", "labels": [], + "created": "2026-03-07T00:00:00Z", "updated": "2026-03-07T00:00:00Z", + "number": 1, "draft": True, "comments": 0, + }) + ready = json.dumps({ + "repo": "r/a", "title": "Ready PR", "labels": [], + "created": "2026-03-07T00:00:00Z", "updated": "2026-03-07T00:00:00Z", + "number": 2, "draft": False, "comments": 0, + }) + with patch("generate_top5.run_gh", return_value=f"{draft}\n{ready}"): + result = fetch_github_prs("r", ["a"]) + draft_item = next(c for c in result if "Draft" in c["title"]) + ready_item = next(c for c in result if "Ready" in c["title"]) + assert ready_item["raw_score"] > draft_item["raw_score"] + assert draft_item["is_draft"] is True + assert ready_item["is_draft"] is False + + def test_pr_has_url_and_metadata(self): + """PRs include correct GitHub URL and metadata.""" + mock = json.dumps({ + "repo": "rysweet/amplihack", "title": "Fix stuff", "labels": ["bug"], + "created": "2026-03-07T00:00:00Z", "updated": "2026-03-07T00:00:00Z", + "number": 42, "draft": False, "comments": 0, + }) + with patch("generate_top5.run_gh", return_value=mock): + result = fetch_github_prs("rysweet", ["amplihack"]) + assert result[0]["url"] == "https://github.com/rysweet/amplihack/pull/42" + assert result[0]["account"] == "rysweet" + assert result[0]["labels"] == ["bug"] + + +class TestLoadLocalOverrides: + """Tests for local .pm/ backlog loading.""" + + def test_no_pm_dir(self, project_root): + """Returns empty when .pm doesn't exist.""" + result = load_local_overrides(project_root / ".pm") + assert result == [] + + def test_loads_ready_items(self, pm_dir): + """Loads READY items from backlog.""" + items = { + "items": [ + {"id": "BL-001", "title": "Task A", "status": "READY", "priority": "HIGH", "estimated_hours": 1}, + {"id": "BL-002", "title": "Task B", "status": "DONE", "priority": "HIGH"}, + ] + } + with open(pm_dir / "backlog" / "items.yaml", "w") as f: + yaml.dump(items, f) + + result = load_local_overrides(pm_dir) + assert len(result) == 1 + assert result[0]["source"] == "local" + assert result[0]["item_id"] == "BL-001" + + +class TestExtractRoadmapGoals: + """Tests for roadmap goal extraction.""" + + def test_no_roadmap(self, project_root): + """Returns empty when no roadmap exists.""" + result = extract_roadmap_goals(project_root / ".pm") + assert result == [] + + def test_extracts_goals(self, populated_pm): + """Extracts goals from roadmap markdown.""" + goals = extract_roadmap_goals(populated_pm) + assert len(goals) > 0 + assert any("config" in g.lower() for g in goals) + + +class TestScoreRoadmapAlignment: + """Tests for roadmap alignment scoring.""" + + def test_no_goals_returns_neutral(self): + assert score_roadmap_alignment({"title": "X", "source": "github_issue"}, []) == 0.5 + + def test_matching_title_scores_high(self): + score = score_roadmap_alignment( + {"title": "Implement config parser", "source": "github_issue"}, + ["config parser implementation"], + ) + assert score > 0.0 + + def test_unrelated_title_scores_zero(self): + score = score_roadmap_alignment( + {"title": "Fix authentication bug", "source": "github_issue"}, + ["database migration tool"], + ) + assert score == 0.0 + + +class TestSuggestAction: + """Tests for action suggestion logic.""" + + def test_critical_issue(self): + action = suggest_action({"source": "github_issue", "labels": ["critical"]}) + assert "immediately" in action.lower() + + def test_bug_issue(self): + action = suggest_action({"source": "github_issue", "labels": ["bug"], "days_stale": 1}) + assert "bug" in action.lower() + + def test_stale_pr(self): + action = suggest_action({"source": "github_pr", "is_draft": False, "days_stale": 20}) + assert "stale" in action.lower() or "merge" in action.lower() + + def test_draft_pr(self): + action = suggest_action({"source": "github_pr", "is_draft": True, "days_stale": 1}) + assert "draft" in action.lower() + + def test_local_item(self): + action = suggest_action({"source": "local"}) + assert "backlog" in action.lower() + + +class TestAggregateAndRank: + """Tests for the core aggregation and ranking logic.""" + + def test_empty_input(self): + top, near = aggregate_and_rank([], [], [], []) + assert top == [] + assert near == [] + + def test_returns_max_5(self): + candidates = [ + {"title": f"Item {i}", "source": "github_issue", "raw_score": float(100 - i), + "rationale": "test", "item_id": f"#{i}", "priority": "MEDIUM"} + for i in range(10) + ] + top, near = aggregate_and_rank(candidates, [], [], []) + assert len(top) == 5 + assert len(near) == 5 + + def test_ranked_in_order(self): + issues = [ + {"title": "Low", "source": "github_issue", "raw_score": 30.0, + "rationale": "test", "item_id": "#1", "priority": "LOW"}, + {"title": "High", "source": "github_issue", "raw_score": 90.0, + "rationale": "test", "item_id": "#2", "priority": "HIGH"}, + ] + top, _ = aggregate_and_rank(issues, [], [], []) + assert top[0]["title"] == "High" + assert top[1]["title"] == "Low" + + def test_mixed_sources(self): + issues = [{"title": "Issue", "source": "github_issue", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "HIGH"}] + prs = [{"title": "PR", "source": "github_pr", "raw_score": 80.0, + "rationale": "test", "item_id": "#2", "priority": "HIGH", + "url": "https://github.com/r/a/pull/2", "repo": "r/a"}] + local = [{"title": "Local", "source": "local", "raw_score": 80.0, + "rationale": "test", "item_id": "BL-1", "priority": "MEDIUM"}] + top, _ = aggregate_and_rank(issues, prs, local, []) + assert top[0]["source"] == "github_issue" + assert top[1]["source"] == "github_pr" + assert top[2]["source"] == "local" + + def test_roadmap_alignment_boosts_score(self): + issues = [ + {"title": "Implement config parser", "source": "github_issue", "raw_score": 50.0, + "rationale": "test", "item_id": "#1", "priority": "MEDIUM"}, + {"title": "Fix random thing", "source": "github_issue", "raw_score": 50.0, + "rationale": "test", "item_id": "#2", "priority": "MEDIUM"}, + ] + top, _ = aggregate_and_rank(issues, [], [], ["config parser implementation"]) + config_item = next(r for r in top if "config" in r["title"].lower()) + other_item = next(r for r in top if "random" in r["title"].lower()) + assert config_item["score"] > other_item["score"] + + def test_tiebreak_by_priority(self): + issues = [ + {"title": "Low priority", "source": "github_issue", "raw_score": 50.0, + "rationale": "test", "item_id": "#1", "priority": "LOW"}, + {"title": "High priority", "source": "github_issue", "raw_score": 50.0, + "rationale": "test", "item_id": "#2", "priority": "HIGH"}, + ] + top, _ = aggregate_and_rank(issues, [], [], []) + assert top[0]["priority"] == "HIGH" + assert top[1]["priority"] == "LOW" + + def test_preserves_url_and_repo(self): + prs = [{"title": "PR", "source": "github_pr", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "MEDIUM", + "url": "https://github.com/r/a/pull/1", "repo": "r/a"}] + top, _ = aggregate_and_rank([], prs, [], []) + assert top[0]["url"] == "https://github.com/r/a/pull/1" + assert top[0]["repo"] == "r/a" + + def test_items_have_action(self): + issues = [{"title": "Bug", "source": "github_issue", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "HIGH", + "labels": ["bug"], "days_stale": 5}] + top, _ = aggregate_and_rank(issues, [], [], []) + assert "action" in top[0] + assert len(top[0]["action"]) > 0 + + def test_near_misses_returned(self): + candidates = [ + {"title": f"Item {i}", "source": "github_issue", "raw_score": float(100 - i), + "rationale": "test", "item_id": f"#{i}", "priority": "MEDIUM"} + for i in range(8) + ] + top, near = aggregate_and_rank(candidates, [], [], []) + assert len(top) == 5 + assert len(near) == 3 + assert near[0]["rank"] == 6 + + +class TestBuildRepoSummary: + """Tests for per-repo summary generation.""" + + def test_empty_candidates(self): + result = build_repo_summary([]) + assert result["by_repo"] == {} + assert result["by_account"] == {} + + def test_counts_by_repo(self): + candidates = [ + {"source": "github_issue", "repo": "r/a", "account": "x", "priority": "HIGH"}, + {"source": "github_issue", "repo": "r/a", "account": "x", "priority": "MEDIUM"}, + {"source": "github_pr", "repo": "r/a", "account": "x", "priority": "MEDIUM"}, + {"source": "github_issue", "repo": "r/b", "account": "x", "priority": "HIGH"}, + ] + result = build_repo_summary(candidates) + assert result["by_repo"]["r/a"]["issues"] == 2 + assert result["by_repo"]["r/a"]["prs"] == 1 + assert result["by_repo"]["r/a"]["high_priority"] == 1 + assert result["by_repo"]["r/b"]["issues"] == 1 + + def test_counts_by_account(self): + candidates = [ + {"source": "github_issue", "repo": "r/a", "account": "alice", "priority": "HIGH"}, + {"source": "github_pr", "repo": "r/b", "account": "bob", "priority": "MEDIUM"}, + ] + result = build_repo_summary(candidates) + assert result["by_account"]["alice"]["issues"] == 1 + assert result["by_account"]["bob"]["prs"] == 1 + assert "r/a" in result["by_account"]["alice"]["repos"] + + +class TestGenerateTop5: + """Tests for the main generate_top5 function.""" + + def test_no_sources_no_pm(self, project_root): + with patch("generate_top5.get_current_gh_account", return_value="rysweet"): + result = generate_top5(project_root) + assert result["top5"] == [] + assert result["near_misses"] == [] + assert result["total_candidates"] == 0 + + def test_github_failure_falls_back_to_local(self, populated_pm): + sources = {"github": [{"account": "test", "repos": ["test/repo"]}]} + sources_path = populated_pm / "sources.yaml" + with open(sources_path, "w") as f: + yaml.dump(sources, f) + + with patch("generate_top5.run_gh", return_value=None), \ + patch("generate_top5.get_current_gh_account", return_value="test"): + result = generate_top5(populated_pm.parent, sources_path) + assert result["sources"]["local_items"] > 0 + assert result["sources"]["github_issues"] == 0 + + def test_output_has_summary(self): + with patch("generate_top5.get_current_gh_account", return_value="test"), \ + patch("generate_top5.load_sources", return_value=[]): + result = generate_top5(Path("/nonexistent")) + assert "summary" in result + assert "near_misses" in result + + def test_items_have_required_fields(self): + issues = [{"title": "Test", "source": "github_issue", "raw_score": 80.0, + "rationale": "test", "item_id": "#1", "priority": "HIGH", + "url": "https://github.com/r/a/issues/1", "repo": "r/a"}] + top, _ = aggregate_and_rank(issues, [], [], []) + required = {"rank", "title", "source", "score", "rationale", "priority", "action", "alignment"} + for item in top: + assert required.issubset(item.keys()), f"Missing: {required - item.keys()}" + + +class TestPriorityLabels: + """Tests for label-to-priority mapping.""" + + def test_critical_is_highest(self): + assert PRIORITY_LABELS["critical"] == 1.0 + + def test_bug_is_high(self): + assert PRIORITY_LABELS["bug"] == 0.8 + + def test_enhancement_is_medium(self): + assert PRIORITY_LABELS["enhancement"] == 0.5 From 873055d0e136fc28e0cf273a30a637f657e494e2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 8 Mar 2026 19:41:33 +0000 Subject: [PATCH 8/9] [skip ci] chore: Auto-bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2f85e6867..62668afe7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ backend-path = ["."] [project] name = "amplihack" -version = "0.5.119" +version = "0.5.120" description = "Amplifier bundle for agentic coding with comprehensive skills, recipes, and workflows" requires-python = ">=3.11" dependencies = [ From ace758746248390e7ecb72f1c7443b79f3544241 Mon Sep 17 00:00:00 2001 From: Ryan Sweet Date: Sat, 7 Mar 2026 22:07:09 -0800 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20Oxidizer=20recipe=20=E2=80=94=20aut?= =?UTF-8?q?omated=20Python-to-Rust=20migration=20workflow=20(#2950)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Oxidizer recipe — automated Python-to-Rust migration workflow Adds the oxidizer-workflow recipe, skill definition, and documentation. - amplifier-bundle/recipes/oxidizer-workflow.yaml: 65-step recipe with iterative convergence loops, quality audits, and zero-tolerance parity - .claude/skills/oxidizer-workflow/SKILL.md: Skill definition with activation keywords and usage examples - docs/OXIDIZER.md: Full documentation covering all phases, context variables, and the zero-tolerance policy - mkdocs.yml: Navigation entries for the workflow and skill Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * [skip ci] chore: Auto-bump patch version --------- Co-authored-by: Ubuntu Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .claude/skills/oxidizer-workflow/SKILL.md | 115 ++ .../recipes/oxidizer-workflow.yaml | 1131 +++++++++++++++++ docs/OXIDIZER.md | 162 +++ mkdocs.yml | 2 + pyproject.toml | 1 + 5 files changed, 1411 insertions(+) create mode 100644 .claude/skills/oxidizer-workflow/SKILL.md create mode 100644 amplifier-bundle/recipes/oxidizer-workflow.yaml create mode 100644 docs/OXIDIZER.md diff --git a/.claude/skills/oxidizer-workflow/SKILL.md b/.claude/skills/oxidizer-workflow/SKILL.md new file mode 100644 index 000000000..2ef5c1f6f --- /dev/null +++ b/.claude/skills/oxidizer-workflow/SKILL.md @@ -0,0 +1,115 @@ +--- +name: oxidizer-workflow +description: | + Automated Python-to-Rust migration via iterative convergence loops. + Treats the Python codebase as the living specification and produces a + fully-tested Rust equivalent with zero-tolerance parity validation. + Use when migrating Python modules, libraries, or CLIs to Rust. + Activates for: migration, oxidize, python to rust, port to rust, rewrite in rust. +--- + +# Oxidizer Workflow Skill + +## Purpose + +Orchestrates the `oxidizer-workflow` recipe to migrate Python codebases to Rust. +The workflow is recursive and goal-seeking — it loops until 100% feature parity +is achieved, with quality audit and silent degradation checks on every iteration. + +## When to Use + +- Migrating a Python module, package, or CLI to Rust +- Porting a Python library to a standalone Rust crate +- Creating a Rust binary that replaces a Python tool + +## Core Principles + +1. **Tests first** — Python test coverage must be complete before any porting begins +2. **Zero tolerance** — 100% parity required; partial results are not accepted +3. **Quality gates** — Every iteration runs clippy, fmt, and a full test suite +4. **No silent degradation** — Every feature, edge case, and error path must be preserved +5. **Iterative convergence** — Module-by-module, loop until converged + +## Required Inputs + +| Input | Example | Description | +|-------|---------|-------------| +| `python_package_path` | `src/amplihack/recipes` | Path to the Python package to migrate | +| `rust_target_path` | `rust/recipe-runner` | Where to create the Rust project | +| `rust_repo_name` | `amplihack-recipe-runner` | GitHub repo name for the Rust project | +| `rust_repo_org` | `rysweet` | GitHub org or user for the repo | + +## Execution + +### Via Recipe Runner + +```bash +recipe-runner-rs amplifier-bundle/recipes/oxidizer-workflow.yaml \ + --set python_package_path=src/mypackage \ + --set rust_target_path=rust/mypackage \ + --set rust_repo_name=my-rust-package \ + --set rust_repo_org=myorg +``` + +### Via Python API + +```python +from amplihack.recipes import run_recipe_by_name +from amplihack.recipes.adapters.cli_subprocess import CLISubprocessAdapter + +result = run_recipe_by_name( + "oxidizer-workflow", + adapter=CLISubprocessAdapter(), + user_context={ + "python_package_path": "src/mypackage", + "rust_target_path": "rust/mypackage", + "rust_repo_name": "my-rust-package", + "rust_repo_org": "myorg", + }, +) +``` + +## Workflow Phases + +``` +Phase 1: Analysis + └─ AST analysis, dependency mapping, type inference, public API extraction + +Phase 1B: Test Completeness Gate + └─ Measure coverage → write missing tests → re-verify → BLOCK if < 100% + +Phase 2: Scaffolding + └─ cargo init, add dependencies, create module structure + +Phase 3: Test Extraction + └─ Port Python tests to Rust test modules → quality audit tests + +Phase 4-6: Iterative Convergence Loop (× N until 100% parity) + ├─ Select next module (priority order from Phase 1) + ├─ Implement module in Rust + ├─ Compare: feature matrix diff against Python + ├─ Quality gate: cargo clippy + fmt + test + ├─ Silent degradation audit: check for lossy conversions + ├─ Fix any degradation found + └─ Convergence check: if < 100% parity → loop again + +Final: Summary report with parity matrix +``` + +## Convergence Rules + +- Each iteration processes one module at a time (core-out strategy) +- Up to 5 unrolled loops in the recipe, plus `max_depth: 8` for sub-recipes +- The recipe terminates when `convergence_status == "CONVERGED"` or + `iteration_number > max_iterations` (default 30) +- If max iterations reached without convergence, the final summary reports + which modules are still incomplete + +## What Success Looks Like + +- Rust project builds cleanly (`cargo build`) +- All tests pass (`cargo test`) +- Zero clippy warnings (`cargo clippy -- -D warnings`) +- Formatted (`cargo fmt --check`) +- Feature parity matrix shows 100% coverage +- No silent degradation detected diff --git a/amplifier-bundle/recipes/oxidizer-workflow.yaml b/amplifier-bundle/recipes/oxidizer-workflow.yaml new file mode 100644 index 000000000..9e8b3ed3d --- /dev/null +++ b/amplifier-bundle/recipes/oxidizer-workflow.yaml @@ -0,0 +1,1131 @@ +name: "oxidizer-workflow" +description: | + Automated Python-to-Rust migration workflow. Treats the Python codebase as + the living specification and iteratively converges toward FULL Rust parity + through a recursive, quality-seeking, goal-evaluating loop. + + ZERO TOLERANCE: Convergence requires 100% parity. No partial results accepted. + Every iteration passes through quality-audit-cycle AND silent-degradation-audit. + Test coverage must be complete BEFORE any code porting begins. + + Phases: Analysis → Test Completeness Gate → Scaffolding → Test Extraction → + [Core-Out Implementation → Comparison → Quality Audit → Silent Degradation + Audit → Convergence Check] × N iterations until 100% parity. +version: "2.0.0" +author: "Amplihack Team" +tags: ["oxidizer", "migration", "python-to-rust", "goal-seeking", "zero-tolerance"] + +recursion: + max_depth: 8 + max_total_steps: 120 + +context: + # Required inputs + task_description: "" + repo_path: "." + python_package_path: "" # e.g. "src/amplihack/recipes" + rust_target_path: "" # e.g. "rust/recipe-runner-rs" + rust_repo_name: "" # e.g. "amplihack-recipe-runner-rs" + rust_repo_org: "" # e.g. "rysweet" (GitHub org/user) + + # Phase 1 outputs + analysis_json: "" + migration_priority: "" + + # Phase 1B: Test completeness gate + test_completeness_result: "" + test_coverage_percentage: "0" + test_coverage_sufficient: "false" + + # Phase 2 outputs + scaffold_result: "" + + # Phase 3 outputs + test_extraction_result: "" + scorecard: "" + + # Phase 4-6 iteration state + current_module: "" + next_module_json: "" + implementation_result: "" + comparison_result: "" + parity_percentage: "0" + iteration_number: "1" + max_iterations: "30" + quality_audit_result: "" + silent_degradation_result: "" + convergence_status: "NOT_CONVERGED" + convergence_json: "" + + # Strict parity enforcement + parity_target: "100" + allow_partial_convergence: "false" + +steps: + # ======================================================================== + # PHASE 1: ANALYSIS + # Map Python modules, dependencies, CLI commands, test coverage + # ======================================================================== + + - id: "phase-1-analyze" + type: "agent" + agent: "amplihack:core:analyzer" + prompt: | + # Oxidizer Phase 1: Comprehensive Analysis + + Analyze the Python codebase for migration to Rust. This analysis must be + EXHAUSTIVE — every public function, every code path, every edge case. + + **Python Package Path:** {{python_package_path}} + **Repository:** {{repo_path}} + + Produce a comprehensive analysis covering: + + 1. **Dependency Graph**: Map ALL Python modules, their imports, and + inter-module dependencies. Identify leaf modules (no internal deps) + vs hub modules (many dependents). Include private helpers. + + 2. **Feature Catalog**: List ALL public AND private APIs, CLI commands, + subcommands, flags, and their behaviors. Include: + - Every function signature with parameter types and return types + - Every class with all methods + - Every constant and enum + - Error types and exception hierarchies + + 3. **Test Coverage**: Measure existing test coverage per-module. + Identify ALL untested code paths, branches, and edge cases. + This is critical — gaps here become gaps in the Rust port. + + 4. **External Integrations**: Catalog ALL external service integrations + (APIs, databases, cloud SDKs, subprocess calls, file I/O, env vars). + + 5. **Migration Priority Order**: Leaf modules first, then inward. + + 6. **Rust Ecosystem Mapping**: For EVERY Python dependency, identify + the Rust equivalent. Flag any with no good equivalent — these need + custom implementations and MUST be explicitly tracked. + + 7. **Complexity Metrics**: LOC, cyclomatic complexity, number of + functions per module. This drives effort estimation. + + **Output as JSON**: + ```json + { + "dependency_graph": {"module_name": ["dep1", "dep2"]}, + "feature_catalog": [{"module": "...", "functions": [...], "classes": [...], "constants": [...]}], + "test_coverage": {"overall": "X%", "per_module": {...}, "untested_paths": [...]}, + "external_integrations": [...], + "migration_priority": ["module1", "module2"], + "rust_mapping": {"python_dep": "rust_crate"}, + "complexity": {"total_loc": 0, "per_module": {...}}, + "total_features": 0 + } + ``` + output: "analysis_json" + parse_json: true + + - id: "extract-analysis" + type: "bash" + command: | + echo '{{analysis_json}}' | python3 -c " + import sys, json + data = json.load(sys.stdin) + print(json.dumps(data.get('migration_priority', [])))" 2>/dev/null || echo '[]' + output: "migration_priority" + + # ======================================================================== + # PHASE 1B: TEST COMPLETENESS GATE (MANDATORY) + # Ensure Python test coverage is complete BEFORE porting begins. + # If coverage is insufficient, ADD tests to the Python codebase first. + # ======================================================================== + + - id: "phase-1b-test-completeness-check" + type: "agent" + agent: "amplihack:core:reviewer" + prompt: | + # Oxidizer Phase 1B: Test Completeness Gate + + **CRITICAL**: No porting begins until the Python codebase has comprehensive + test coverage. The Python tests ARE the specification for the Rust port. + Insufficient tests = insufficient specification = guaranteed parity gaps. + + **Python Package Path:** {{python_package_path}} + **Repository:** {{repo_path}} + **Analysis:** {{analysis_json}} + + Evaluate test completeness: + + 1. Run the existing Python test suite. Report pass/fail counts. + 2. Measure line coverage and branch coverage. + 3. For every public function in the feature catalog, verify there is at + least one test exercising it. + 4. For every error path / exception handler, verify there is a test. + 5. For every CLI command/subcommand, verify there is an integration test. + 6. Identify ALL untested code paths. + + If coverage is below 90%, produce a detailed list of EXACTLY which tests + must be written. Group by module and priority. + + **Output as JSON**: + ```json + { + "test_coverage_percentage": N, + "tests_run": N, + "tests_passed": N, + "tests_failed": N, + "coverage_sufficient": true/false, + "untested_functions": ["mod.func1", "mod.func2"], + "untested_error_paths": ["mod.func1:line42"], + "untested_cli_commands": ["cmd1", "cmd2"], + "tests_to_write": [ + {"module": "...", "function": "...", "test_description": "...", "priority": "high"} + ] + } + ``` + output: "test_completeness_result" + parse_json: true + + - id: "extract-test-coverage" + type: "bash" + command: | + echo '{{test_completeness_result}}' | python3 -c " + import sys, json + data = json.load(sys.stdin) + pct = data.get('test_coverage_percentage', 0) + print(pct)" 2>/dev/null || echo '0' + output: "test_coverage_percentage" + + - id: "check-test-sufficiency" + type: "bash" + command: | + echo '{{test_completeness_result}}' | python3 -c " + import sys, json + data = json.load(sys.stdin) + sufficient = data.get('coverage_sufficient', False) + print('true' if sufficient else 'false')" 2>/dev/null || echo 'false' + output: "test_coverage_sufficient" + + # If tests are insufficient, write the missing tests FIRST + - id: "phase-1b-write-missing-tests" + type: "recipe" + recipe: "default-workflow" + condition: "test_coverage_sufficient == 'false'" + sub_context: + task_description: | + MANDATORY: Write missing Python tests before the Oxidizer migration can proceed. + + The following test gaps were identified in the Python codebase at + {{python_package_path}}. ALL of these must be filled: + + {{test_completeness_result}} + + Requirements: + 1. Write tests for EVERY function listed in untested_functions + 2. Write tests for EVERY error path listed in untested_error_paths + 3. Write integration tests for EVERY CLI command in untested_cli_commands + 4. Use pytest fixtures and parametrize for thorough coverage + 5. Include edge cases: empty inputs, None values, invalid types, boundary values + 6. Run the full test suite and verify ALL tests pass + 7. Achieve at least 90% line coverage + + Do NOT proceed to any other task until test coverage is sufficient. + repo_path: "{{repo_path}}" + output: "test_writing_result" + + # Re-verify coverage after writing tests + - id: "phase-1b-reverify-coverage" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "test_coverage_sufficient == 'false'" + prompt: | + # Re-verify Test Coverage After Writing Missing Tests + + The missing tests have been written. Re-run the full Python test suite + at {{python_package_path}} and verify coverage is now sufficient (>=90%). + + If still insufficient, list remaining gaps. + + **Output as JSON**: + ```json + { + "test_coverage_percentage": N, + "coverage_sufficient": true/false, + "remaining_gaps": [] + } + ``` + output: "test_reverify_result" + parse_json: true + + # ======================================================================== + # PHASE 2: RUST SCAFFOLDING + # Create Cargo workspace mirroring Python structure + # ======================================================================== + + - id: "phase-2-scaffold" + type: "recipe" + recipe: "default-workflow" + sub_context: + task_description: | + Create a Rust Cargo workspace for the project '{{rust_repo_name}}'. + + Based on the analysis of the Python package at {{python_package_path}}: + {{analysis_json}} + + Tasks: + 1. If repo '{{rust_repo_name}}' doesn't exist yet, create it: + gh repo create {{rust_repo_org}}/{{rust_repo_name}} --public --clone + 2. Create a Cargo workspace with modules mirroring the Python structure + 3. Set up GitHub Actions CI that builds and tests the Rust code + 4. Add dependencies to Cargo.toml based on the Rust ecosystem mapping + 5. Create a README.md documenting the migration status + 6. Create a SCORECARD.json with all features from the feature catalog, + all initially set to passes_rust: false + + The Cargo workspace should be at: {{rust_target_path}} + repo_path: "{{repo_path}}" + output: "scaffold_result" + + # ======================================================================== + # PHASE 3: TEST EXTRACTION + # Convert Python tests to Rust BEFORE any implementation + # ======================================================================== + + - id: "phase-3-extract-tests" + type: "recipe" + recipe: "default-workflow" + sub_context: + task_description: | + Extract and convert ALL tests from the Python codebase to Rust for the + '{{rust_repo_name}}' migration. Tests must exist BEFORE implementation. + + Python package: {{python_package_path}} + Rust workspace: {{rust_target_path}} + Analysis: {{analysis_json}} + + MANDATORY — Every single test must be ported: + 1. Convert ALL Python unit tests to Rust #[test] equivalents + 2. Convert ALL integration tests to Rust integration tests + 3. Create shared agentic test scenarios (YAML) that run against both + Python and Rust versions with identical inputs/outputs + 4. Create the Feature Scorecard (scorecard.json) with EVERY feature: + ```json + { + "features": [ + { + "name": "feature_name", + "module": "module_name", + "has_test": true, + "passes_python": true, + "passes_rust": false, + "exec_time_python_ms": null, + "exec_time_rust_ms": null, + "notes": "" + } + ], + "total_features": N, + "features_with_tests": N, + "parity_percentage": 0, + "last_updated": "ISO-8601" + } + ``` + 5. Verify ALL extracted tests pass against the Python version (baseline) + 6. The Rust tests should initially FAIL (compile but fail) — that's expected + + The scorecard MUST list every feature from the analysis. No feature left behind. + repo_path: "{{repo_path}}" + output: "test_extraction_result" + + # Quality audit the test extraction itself + - id: "phase-3-test-quality-audit" + type: "recipe" + recipe: "quality-audit-cycle" + sub_context: + target_path: "{{rust_target_path}}" + min_cycles: "2" + max_cycles: "4" + categories: "test_gaps,reliability,structural" + + # ======================================================================== + # PHASE 4: CORE-OUT IMPLEMENTATION (Iteration 1) + # Port modules from inside-out, using default-workflow for each module + # ======================================================================== + + - id: "phase-4-select-next-module" + type: "agent" + agent: "amplihack:core:architect" + condition: "convergence_status != 'CONVERGED'" + prompt: | + # Oxidizer Phase 4: Select Next Module to Port (Iteration {{iteration_number}}) + + **Migration Priority Order:** {{migration_priority}} + **Current Scorecard:** {{scorecard}} + **Iteration:** {{iteration_number}} of {{max_iterations}} + **Previous Results:** {{implementation_result}} + **Previous Comparison:** {{comparison_result}} + + Select the next module to port. Rules: + 1. NEVER select a module whose dependencies haven't been ported yet + 2. Select the module with the LARGEST parity gap + 3. If this is a re-visit (module was partially ported), focus on the gaps + + **Output as JSON**: + ```json + { + "module_name": "the_module_to_port", + "reason": "why this module is next", + "dependencies_ported": true, + "estimated_complexity": "low|medium|high", + "specific_gaps_to_close": ["gap1", "gap2"] + } + ``` + output: "next_module_json" + parse_json: true + + - id: "extract-module-name" + type: "bash" + condition: "convergence_status != 'CONVERGED'" + command: | + echo '{{next_module_json}}' | python3 -c " + import sys, json + data = json.load(sys.stdin) + print(data.get('module_name', ''))" 2>/dev/null || echo '' + output: "current_module" + + - id: "phase-4-implement-module" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status != 'CONVERGED'" + sub_context: + task_description: | + Port the Python module '{{current_module}}' to Rust as part of the + {{rust_repo_name}} migration. Iteration {{iteration_number}}. + + **Python Source:** {{python_package_path}}/{{current_module}} + **Rust Target:** {{rust_target_path}} + **Analysis:** {{analysis_json}} + **Specific Gaps to Close:** {{next_module_json}} + + STRICT REQUIREMENTS: + 1. Read the Python source THOROUGHLY — every function, every branch + 2. Write IDIOMATIC Rust — don't transliterate Python line-by-line + 3. Implement ALL public AND private functions + 4. Write #[test] for EVERY function — not just happy paths but error cases too + 5. ALL previously extracted tests for this module MUST pass + 6. Run `cargo test` and verify zero failures + 7. Run `cargo clippy` and fix all warnings + 8. Do NOT delete or modify the Python source + 9. If the previous iteration had gaps, close ALL of them + + Python-to-Rust patterns: + - dataclasses → #[derive(Debug, Clone, Serialize, Deserialize)] + - dict → HashMap or typed structs + - Optional → Option + - exceptions → Result with thiserror + - list comprehension → .iter().map().collect() + - context managers → Drop trait or RAII + repo_path: "{{repo_path}}" + output: "implementation_result" + + # ======================================================================== + # PHASE 5: COMPARISON + QUALITY GATES + # Automated parity checking + quality audit + silent degradation audit + # ======================================================================== + + - id: "phase-5-compare" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status != 'CONVERGED'" + prompt: | + # Oxidizer Phase 5: Strict Parity Comparison (Iteration {{iteration_number}}) + + Compare Python and Rust implementations of '{{current_module}}'. + ZERO TOLERANCE — every feature must match. + + **Python Package:** {{python_package_path}} + **Rust Workspace:** {{rust_target_path}} + **Module:** {{current_module}} + + Run these comparisons: + 1. **Feature Completeness**: For EVERY function in the Python module, + verify the Rust equivalent exists and has the same signature/behavior. + List ALL missing functions — even internal helpers. + 2. **Test Results**: Run `cargo test` on the Rust code. Run `pytest` on + the Python code. Report exact pass/fail for each test. + 3. **Output Comparison**: For key functions, run identical inputs through + both versions and diff the outputs character-by-character. + 4. **Error Behavior**: Verify that the same invalid inputs produce + equivalent errors in both versions. + 5. **Performance**: Measure execution time for both versions. + 6. **Edge Cases**: Test boundary conditions, empty inputs, None/null. + + Update the scorecard. Set parity_percentage to the ACTUAL percentage + of features that fully pass in both versions. + + **Output as JSON**: + ```json + { + "module": "{{current_module}}", + "feature_completeness": {"total": N, "ported": M, "missing": [...]}, + "tests": {"python_pass": N, "python_fail": 0, "rust_pass": M, "rust_fail": K, "rust_failing_tests": [...]}, + "output_matches": true/false, + "error_behavior_matches": true/false, + "performance": {"python_ms": N, "rust_ms": M}, + "parity_percentage": N, + "remaining_gaps": ["specific gap 1", "specific gap 2"] + } + ``` + output: "comparison_result" + parse_json: true + + - id: "extract-parity" + type: "bash" + condition: "convergence_status != 'CONVERGED'" + command: | + echo '{{comparison_result}}' | python3 -c " + import sys, json + data = json.load(sys.stdin) + print(data.get('parity_percentage', 0))" 2>/dev/null || echo '0' + output: "parity_percentage" + + # Quality audit — rigorous, minimum 3 cycles + - id: "phase-5-quality-gate" + type: "recipe" + recipe: "quality-audit-cycle" + condition: "convergence_status != 'CONVERGED'" + sub_context: + target_path: "{{rust_target_path}}" + min_cycles: "3" + max_cycles: "6" + severity_threshold: "medium" + categories: "security,reliability,dead_code,silent_fallbacks,error_swallowing,structural,hardcoded_limits,test_gaps" + + # Silent degradation audit — catch things that "work" but produce wrong results + - id: "phase-5-silent-degradation-audit" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status != 'CONVERGED'" + prompt: | + # Oxidizer: Silent Degradation Audit (Iteration {{iteration_number}}) + + Audit the Rust implementation at {{rust_target_path}} for SILENT DEGRADATION. + These are bugs where the code runs without errors but produces subtly wrong + results — the most dangerous class of migration bugs. + + **Module Under Review:** {{current_module}} + **Python Reference:** {{python_package_path}}/{{current_module}} + + Check these 6 categories: + + 1. **Dependency Failures**: What happens when external dependencies + (subprocess calls, file I/O, network) fail? Does the Rust version + handle failures identically to Python? Look for: + - unwrap() on Results that Python would catch with try/except + - Missing timeout handling that Python has + - Different default values on failure + + 2. **Config/Input Errors**: What happens with malformed input? Does + the Rust version reject the same inputs Python rejects? + - Missing validation that Python performs + - Different parsing behavior for edge cases + - Silent type coercion differences + + 3. **Background/Async Work**: If any async or background operations + exist, do failures propagate identically? + + 4. **Test Effectiveness**: Do the Rust tests actually verify behavior + or just check that code runs without panicking? Look for: + - assert!(result.is_ok()) without checking the actual value + - Tests that always pass regardless of implementation + - Missing negative test cases + + 5. **Operator Visibility**: Are errors and warnings visible? Look for: + - Python logging calls that have no Rust log::* equivalent + - Error messages that differ between versions + - Silent error swallowing (catch-all error handlers) + + 6. **Functional Stubs**: Code that compiles and runs but doesn't + actually implement the intended behavior. Look for: + - Functions that return Ok(()) or default values without real logic + - todo!() or unimplemented!() behind conditional paths + - Incomplete match arms that silently ignore variants + + **Output as JSON**: + ```json + { + "findings_count": N, + "critical_findings": [...], + "high_findings": [...], + "medium_findings": [...], + "all_clear": true/false + } + ``` + output: "silent_degradation_result" + parse_json: true + + # If silent degradation found critical/high issues, fix them + - id: "phase-5-fix-degradation" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status != 'CONVERGED'" + sub_context: + task_description: | + FIX all silent degradation findings in the Rust code at {{rust_target_path}}. + + Findings from the silent degradation audit: + {{silent_degradation_result}} + + For EACH finding: + 1. Understand the Python behavior that the Rust code should match + 2. Fix the Rust code to match Python behavior exactly + 3. Write a regression test proving the fix + 4. Run `cargo test` and verify zero failures + + Do NOT skip any finding. Every silent degradation is a parity gap. + repo_path: "{{repo_path}}" + output: "degradation_fix_result" + + # ======================================================================== + # PHASE 6: CONVERGENCE CHECK (STRICT — 100% or loop) + # ======================================================================== + + - id: "phase-6-convergence-check" + type: "agent" + agent: "amplihack:core:reviewer" + prompt: | + # Oxidizer Phase 6: Strict Convergence Evaluation (Iteration {{iteration_number}}) + + **ZERO TOLERANCE POLICY**: Convergence means 100% parity. Nothing less. + + **Current Parity:** {{parity_percentage}}% + **Target Parity:** {{parity_target}}% + **Iteration:** {{iteration_number}} of {{max_iterations}} + **Scorecard:** {{scorecard}} + **Latest Comparison:** {{comparison_result}} + **Silent Degradation Findings:** {{silent_degradation_result}} + **Allow Partial:** {{allow_partial_convergence}} + + Evaluation rules: + 1. Parity is EXACTLY 100% AND zero silent degradation findings → CONVERGED + 2. Any remaining gaps, failing tests, or degradation findings → NOT_CONVERGED + 3. max_iterations reached BUT parity < 100% → NOT_CONVERGED (keep going, + raise max_iterations if needed — we do NOT accept partial results) + + For NOT_CONVERGED, identify the SPECIFIC gaps remaining and what must be + done in the next iteration to close them. + + **Output as JSON**: + ```json + { + "convergence_status": "CONVERGED" | "NOT_CONVERGED", + "parity_percentage": N, + "remaining_modules": [...], + "remaining_gaps": ["specific gap description"], + "silent_degradation_clear": true/false, + "quality_audit_clear": true/false, + "recommendation": "specific action for next iteration", + "iteration_number": N + } + ``` + output: "convergence_json" + parse_json: true + + - id: "extract-convergence" + type: "bash" + command: | + echo '{{convergence_json}}' | python3 -c " + import sys, json + data = json.load(sys.stdin) + print(data.get('convergence_status', 'NOT_CONVERGED'))" 2>/dev/null || echo 'NOT_CONVERGED' + output: "convergence_status" + + - id: "increment-iteration" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo $(({{iteration_number}} + 1)) + output: "iteration_number" + + # ======================================================================== + # RECURSIVE LOOP: Iterations 2-5 (repeat the full cycle) + # Each iteration: select → implement → compare → quality audit → + # silent degradation → fix → convergence check + # ======================================================================== + + - id: "loop-2-select" + type: "agent" + agent: "amplihack:core:architect" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Oxidizer Iteration {{iteration_number}}: Module Selection + + **Priority:** {{migration_priority}} + **Scorecard:** {{scorecard}} + **Previous Gaps:** {{convergence_json}} + + Select the module with the largest remaining parity gap. + If revisiting a module, list the SPECIFIC gaps to close. + + **Output JSON**: {"module_name": "...", "reason": "...", "specific_gaps_to_close": [...]} + output: "next_module_json" + parse_json: true + + - id: "loop-2-extract-module" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{next_module_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('module_name',''))" 2>/dev/null || echo '' + output: "current_module" + + - id: "loop-2-implement" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Oxidizer iteration {{iteration_number}}: Port/fix '{{current_module}}'. + Python: {{python_package_path}}/{{current_module}} + Rust: {{rust_target_path}} + Gaps to close: {{convergence_json}} + All tests must pass. Run cargo test && cargo clippy. + repo_path: "{{repo_path}}" + output: "implementation_result" + + - id: "loop-2-compare" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Parity Comparison — Iteration {{iteration_number}}, module '{{current_module}}' + Python: {{python_package_path}}, Rust: {{rust_target_path}} + Check feature completeness, test results, output comparison, error behavior. + **Output JSON**: {"module":"...","parity_percentage":N,"remaining_gaps":[...], + "tests":{"rust_pass":N,"rust_fail":K},"feature_completeness":{"total":N,"ported":M,"missing":[...]}} + output: "comparison_result" + parse_json: true + + - id: "loop-2-extract-parity" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{comparison_result}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('parity_percentage',0))" 2>/dev/null || echo '0' + output: "parity_percentage" + + - id: "loop-2-quality-gate" + type: "recipe" + recipe: "quality-audit-cycle" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + target_path: "{{rust_target_path}}" + min_cycles: "3" + max_cycles: "5" + categories: "security,reliability,dead_code,silent_fallbacks,error_swallowing,test_gaps" + + - id: "loop-2-silent-degradation" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Silent Degradation Audit — Iteration {{iteration_number}} + Rust: {{rust_target_path}}, Module: {{current_module}}, Python ref: {{python_package_path}} + Check: dependency failures, config errors, test effectiveness, operator visibility, functional stubs. + **Output JSON**: {"findings_count":N,"critical_findings":[...],"high_findings":[...],"all_clear":true/false} + output: "silent_degradation_result" + parse_json: true + + - id: "loop-2-fix-degradation" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Fix silent degradation findings in {{rust_target_path}}: + {{silent_degradation_result}} + Fix each finding, add regression tests, run cargo test. + repo_path: "{{repo_path}}" + output: "degradation_fix_result" + + - id: "loop-2-convergence" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Convergence Check — Iteration {{iteration_number}} + Parity: {{parity_percentage}}%, Target: {{parity_target}}% + Comparison: {{comparison_result}}, Degradation: {{silent_degradation_result}} + RULE: 100% parity + zero degradation findings = CONVERGED. Otherwise NOT_CONVERGED. + **Output JSON**: {"convergence_status":"CONVERGED"|"NOT_CONVERGED","parity_percentage":N, + "remaining_gaps":[...],"silent_degradation_clear":true/false} + output: "convergence_json" + parse_json: true + + - id: "loop-2-extract-convergence" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{convergence_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('convergence_status','NOT_CONVERGED'))" 2>/dev/null || echo 'NOT_CONVERGED' + output: "convergence_status" + + - id: "loop-2-increment" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: "echo $(({{iteration_number}} + 1))" + output: "iteration_number" + + # --- Iteration 3 --- + - id: "loop-3-select" + type: "agent" + agent: "amplihack:core:architect" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Oxidizer Iteration {{iteration_number}}: Module Selection + Priority: {{migration_priority}}, Scorecard: {{scorecard}}, Gaps: {{convergence_json}} + Select next module. Output JSON: {"module_name":"...","reason":"...","specific_gaps_to_close":[...]} + output: "next_module_json" + parse_json: true + + - id: "loop-3-extract-module" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{next_module_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('module_name',''))" 2>/dev/null || echo '' + output: "current_module" + + - id: "loop-3-implement" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Oxidizer iteration {{iteration_number}}: Port/fix '{{current_module}}'. + Python: {{python_package_path}}/{{current_module}}, Rust: {{rust_target_path}} + Gaps: {{convergence_json}}. All tests must pass. cargo test && cargo clippy. + repo_path: "{{repo_path}}" + output: "implementation_result" + + - id: "loop-3-compare" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Parity Comparison — Iteration {{iteration_number}}, module '{{current_module}}' + Python: {{python_package_path}}, Rust: {{rust_target_path}} + Output JSON: {"module":"...","parity_percentage":N,"remaining_gaps":[...], + "tests":{"rust_pass":N,"rust_fail":K},"feature_completeness":{"total":N,"ported":M,"missing":[...]}} + output: "comparison_result" + parse_json: true + + - id: "loop-3-parity" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{comparison_result}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('parity_percentage',0))" 2>/dev/null || echo '0' + output: "parity_percentage" + + - id: "loop-3-quality" + type: "recipe" + recipe: "quality-audit-cycle" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + target_path: "{{rust_target_path}}" + min_cycles: "3" + max_cycles: "5" + categories: "security,reliability,silent_fallbacks,error_swallowing,test_gaps" + + - id: "loop-3-degradation" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Silent Degradation Audit — Iteration {{iteration_number}} + Rust: {{rust_target_path}}, Module: {{current_module}}, Python: {{python_package_path}} + Output JSON: {"findings_count":N,"critical_findings":[...],"all_clear":true/false} + output: "silent_degradation_result" + parse_json: true + + - id: "loop-3-fix" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Fix degradation in {{rust_target_path}}: {{silent_degradation_result}} + Add regression tests. cargo test. + repo_path: "{{repo_path}}" + output: "degradation_fix_result" + + - id: "loop-3-convergence" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Convergence — Iteration {{iteration_number}} + Parity: {{parity_percentage}}%, Target: 100%, Degradation: {{silent_degradation_result}} + RULE: 100% + zero degradation = CONVERGED. Otherwise NOT_CONVERGED. + Output JSON: {"convergence_status":"...","parity_percentage":N,"remaining_gaps":[...]} + output: "convergence_json" + parse_json: true + + - id: "loop-3-extract" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{convergence_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('convergence_status','NOT_CONVERGED'))" 2>/dev/null || echo 'NOT_CONVERGED' + output: "convergence_status" + + - id: "loop-3-increment" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: "echo $(({{iteration_number}} + 1))" + output: "iteration_number" + + # --- Iteration 4 --- + - id: "loop-4-select" + type: "agent" + agent: "amplihack:core:architect" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Oxidizer Iteration {{iteration_number}}: Module Selection + Priority: {{migration_priority}}, Gaps: {{convergence_json}} + Output JSON: {"module_name":"...","reason":"...","specific_gaps_to_close":[...]} + output: "next_module_json" + parse_json: true + + - id: "loop-4-extract-module" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{next_module_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('module_name',''))" 2>/dev/null || echo '' + output: "current_module" + + - id: "loop-4-implement" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Oxidizer iteration {{iteration_number}}: Port/fix '{{current_module}}'. + Python: {{python_package_path}}/{{current_module}}, Rust: {{rust_target_path}} + Gaps: {{convergence_json}}. cargo test && cargo clippy. + repo_path: "{{repo_path}}" + output: "implementation_result" + + - id: "loop-4-compare" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Parity — Iteration {{iteration_number}}, '{{current_module}}' + Python: {{python_package_path}}, Rust: {{rust_target_path}} + Output JSON: {"module":"...","parity_percentage":N,"remaining_gaps":[...], + "tests":{"rust_pass":N,"rust_fail":K}} + output: "comparison_result" + parse_json: true + + - id: "loop-4-parity" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{comparison_result}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('parity_percentage',0))" 2>/dev/null || echo '0' + output: "parity_percentage" + + - id: "loop-4-quality" + type: "recipe" + recipe: "quality-audit-cycle" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + target_path: "{{rust_target_path}}" + min_cycles: "3" + max_cycles: "5" + categories: "security,reliability,silent_fallbacks,error_swallowing,test_gaps" + + - id: "loop-4-degradation" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Silent Degradation — Iteration {{iteration_number}} + Rust: {{rust_target_path}}, Module: {{current_module}}, Python: {{python_package_path}} + Output JSON: {"findings_count":N,"critical_findings":[...],"all_clear":true/false} + output: "silent_degradation_result" + parse_json: true + + - id: "loop-4-fix" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Fix degradation in {{rust_target_path}}: {{silent_degradation_result}} + Regression tests. cargo test. + repo_path: "{{repo_path}}" + output: "degradation_fix_result" + + - id: "loop-4-convergence" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Convergence — Iteration {{iteration_number}} + Parity: {{parity_percentage}}%, Degradation: {{silent_degradation_result}} + 100% + zero degradation = CONVERGED. Otherwise NOT_CONVERGED. + Output JSON: {"convergence_status":"...","parity_percentage":N,"remaining_gaps":[...]} + output: "convergence_json" + parse_json: true + + - id: "loop-4-extract" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{convergence_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('convergence_status','NOT_CONVERGED'))" 2>/dev/null || echo 'NOT_CONVERGED' + output: "convergence_status" + + - id: "loop-4-increment" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: "echo $(({{iteration_number}} + 1))" + output: "iteration_number" + + # --- Iteration 5 --- + - id: "loop-5-select" + type: "agent" + agent: "amplihack:core:architect" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Oxidizer Iteration {{iteration_number}}: Module Selection + Priority: {{migration_priority}}, Gaps: {{convergence_json}} + Output JSON: {"module_name":"...","reason":"...","specific_gaps_to_close":[...]} + output: "next_module_json" + parse_json: true + + - id: "loop-5-extract-module" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{next_module_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('module_name',''))" 2>/dev/null || echo '' + output: "current_module" + + - id: "loop-5-implement" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Oxidizer iteration {{iteration_number}}: FINAL PUSH on '{{current_module}}'. + Python: {{python_package_path}}/{{current_module}}, Rust: {{rust_target_path}} + Gaps: {{convergence_json}}. Close EVERY remaining gap. cargo test && cargo clippy. + repo_path: "{{repo_path}}" + output: "implementation_result" + + - id: "loop-5-compare" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Final Parity — Iteration {{iteration_number}}, '{{current_module}}' + Python: {{python_package_path}}, Rust: {{rust_target_path}} + Output JSON: {"module":"...","parity_percentage":N,"remaining_gaps":[...], + "tests":{"rust_pass":N,"rust_fail":K}} + output: "comparison_result" + parse_json: true + + - id: "loop-5-parity" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{comparison_result}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('parity_percentage',0))" 2>/dev/null || echo '0' + output: "parity_percentage" + + - id: "loop-5-quality" + type: "recipe" + recipe: "quality-audit-cycle" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + target_path: "{{rust_target_path}}" + min_cycles: "3" + max_cycles: "6" + categories: "security,reliability,silent_fallbacks,error_swallowing,test_gaps" + + - id: "loop-5-degradation" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Silent Degradation — Iteration {{iteration_number}} + Rust: {{rust_target_path}}, Module: {{current_module}}, Python: {{python_package_path}} + Output JSON: {"findings_count":N,"critical_findings":[...],"all_clear":true/false} + output: "silent_degradation_result" + parse_json: true + + - id: "loop-5-fix" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Fix degradation in {{rust_target_path}}: {{silent_degradation_result}} + Regression tests. cargo test. + repo_path: "{{repo_path}}" + output: "degradation_fix_result" + + - id: "loop-5-convergence" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Final Convergence — Iteration {{iteration_number}} + Parity: {{parity_percentage}}%, Degradation: {{silent_degradation_result}} + 100% + zero degradation = CONVERGED. Otherwise NOT_CONVERGED. + If NOT_CONVERGED after 5 iterations, detail EXACTLY what remains. + Output JSON: {"convergence_status":"...","parity_percentage":N,"remaining_gaps":[...]} + output: "convergence_json" + parse_json: true + + - id: "loop-5-extract" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{convergence_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('convergence_status','NOT_CONVERGED'))" 2>/dev/null || echo 'NOT_CONVERGED' + output: "convergence_status" + + # ======================================================================== + # FINAL SUMMARY + # ======================================================================== + + - id: "final-summary" + type: "agent" + agent: "amplihack:core:reviewer" + prompt: | + # Oxidizer Migration Report + + **Final Parity:** {{parity_percentage}}% + **Target Parity:** {{parity_target}}% + **Total Iterations:** {{iteration_number}} + **Convergence Status:** {{convergence_status}} + **Final Scorecard:** {{scorecard}} + **Silent Degradation Clear:** Check {{silent_degradation_result}} + + Produce a comprehensive final report: + 1. Summary of ALL modules ported + 2. Final scorecard with every feature and its status + 3. Performance comparison (Python vs Rust) per module + 4. Quality audit history across iterations + 5. Silent degradation audit results + 6. If parity < 100%: detailed list of EVERY remaining gap with + specific instructions for closing each one + 7. If parity == 100%: recommendation for deprecating Python version, + including a migration checklist for downstream consumers + + Format as a markdown report for a GitHub issue comment. + output: "final_report" diff --git a/docs/OXIDIZER.md b/docs/OXIDIZER.md new file mode 100644 index 000000000..a6d093876 --- /dev/null +++ b/docs/OXIDIZER.md @@ -0,0 +1,162 @@ +# Oxidizer Workflow + +The Oxidizer workflow automates Python-to-Rust migration through iterative +convergence loops. It treats the Python codebase as the living specification +and produces a fully-tested Rust equivalent with zero-tolerance parity +validation. + +## Overview + +Oxidizer is a recipe-driven workflow that: + +- Analyzes the Python codebase (AST, dependencies, types, public API) +- Ensures complete test coverage before any porting begins +- Scaffolds a Rust project with the correct structure +- Ports tests first, then implementation module-by-module +- Runs quality and degradation audits on every iteration +- Loops until 100% feature parity is achieved + +## Quick Start + +```bash +recipe-runner-rs amplifier-bundle/recipes/oxidizer-workflow.yaml \ + --set python_package_path=src/mypackage \ + --set rust_target_path=rust/mypackage \ + --set rust_repo_name=my-rust-crate \ + --set rust_repo_org=myorg +``` + +## Required Context Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `python_package_path` | Path to the Python package to migrate | `src/amplihack/recipes` | +| `rust_target_path` | Where to create the Rust project | `rust/recipe-runner` | +| `rust_repo_name` | GitHub repository name for the output | `amplihack-recipe-runner` | +| `rust_repo_org` | GitHub org or user | `rysweet` | + +## Workflow Phases + +### Phase 1: Analysis + +Performs comprehensive analysis of the Python codebase: + +- AST analysis of every module +- Dependency graph mapping +- Type inference for function signatures +- Public API surface extraction +- Migration priority ordering (leaf modules first) + +### Phase 1B: Test Completeness Gate + +**This gate blocks all further progress until test coverage is sufficient.** + +1. Measures current Python test coverage +2. Identifies untested code paths +3. Writes missing tests +4. Re-verifies coverage +5. If coverage is still insufficient → **workflow stops** + +### Phase 2: Scaffolding + +Creates the Rust project structure: + +- `cargo init` with appropriate dependencies +- Module structure mirroring the Python package +- CI configuration (clippy, fmt, test) +- README and documentation scaffolding + +### Phase 3: Test Extraction + +Ports Python tests to Rust before any implementation: + +- Converts pytest fixtures to Rust test helpers +- Maps Python assertions to Rust equivalents +- Runs a quality audit on extracted tests +- Tests are expected to fail at this point (no implementation yet) + +### Phase 4–6: Iterative Convergence + +Each iteration processes one module: + +``` +┌─────────────────────────────────────────────┐ +│ Select next module (priority order) │ +│ ↓ │ +│ Implement module in Rust │ +│ ↓ │ +│ Compare: feature matrix diff vs Python │ +│ ↓ │ +│ Quality gate: clippy + fmt + test │ +│ ↓ │ +│ Silent degradation audit │ +│ ↓ │ +│ Fix any degradation found │ +│ ↓ │ +│ Convergence check │ +│ ↓ │ +│ < 100% parity? → loop again │ +│ = 100% parity? → done │ +└─────────────────────────────────────────────┘ +``` + +The recipe unrolls 5 explicit loop iterations. The `max_depth: 8` recursion +setting allows sub-recipes to recurse further if needed. The `max_iterations` +context variable (default: 30) provides an upper bound. + +## Zero-Tolerance Policy + +The oxidizer enforces strict standards: + +- **No partial convergence** — `allow_partial_convergence` is `false` +- **Parity target is 100%** — anything less loops again +- **Silent degradation audit** — catches lossy type conversions, missing error + variants, dropped edge cases, and behavioral differences +- **Quality gate** — `cargo clippy -- -D warnings`, `cargo fmt --check`, full + test suite must pass + +## Using via Python API + +```python +from amplihack.recipes import run_recipe_by_name +from amplihack.recipes.adapters.cli_subprocess import CLISubprocessAdapter + +result = run_recipe_by_name( + "oxidizer-workflow", + adapter=CLISubprocessAdapter(), + user_context={ + "python_package_path": "src/mypackage", + "rust_target_path": "rust/mypackage", + "rust_repo_name": "my-rust-crate", + "rust_repo_org": "myorg", + }, +) + +if result.success: + print("Migration complete — 100% parity achieved") +else: + for sr in result.step_results: + if sr.error: + print(f" {sr.step_id}: {sr.error}") +``` + +## Recipe Location + +The oxidizer recipe lives at: + +``` +amplifier-bundle/recipes/oxidizer-workflow.yaml +``` + +## Customization + +Override any context variable with `--set`: + +```bash +recipe-runner-rs amplifier-bundle/recipes/oxidizer-workflow.yaml \ + --set max_iterations=50 \ + --set parity_target=100 +``` + +The recipe is designed to be used as-is for most migrations. For specialized +needs, copy the recipe and modify the agent prompts in each phase. diff --git a/mkdocs.yml b/mkdocs.yml index e175fd7f7..ca02289f5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -93,6 +93,7 @@ nav: - Pre-Commit Diagnostic: claude/agents/amplihack/specialized/pre-commit-diagnostic.md - CI Diagnostic: claude/agents/amplihack/specialized/ci-diagnostic-workflow.md - Fix Workflow: claude/agents/amplihack/specialized/fix-agent.md + - Oxidizer: OXIDIZER.md - Agents: - Overview: claude/agents/README.md - Core Agents: @@ -138,6 +139,7 @@ nav: - PPTX: claude/skills/pptx/SKILL.md - XLSX: claude/skills/xlsx/SKILL.md - PDF: claude/skills/pdf/SKILL.md + - Oxidizer: claude/skills/oxidizer-workflow/SKILL.md - Tools: - Scenarios Overview: claude/scenarios/README.md - Analyze Codebase: claude/scenarios/analyze-codebase/README.md diff --git a/pyproject.toml b/pyproject.toml index 62668afe7..472da0707 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ backend-path = ["."] [project] name = "amplihack" + version = "0.5.120" description = "Amplifier bundle for agentic coding with comprehensive skills, recipes, and workflows" requires-python = ">=3.11"