#!/usr/bin/env python3 """ Sprint CLI — orchestrates sprint lifecycle and context for agents. Calls the ticket CLI for data queries (no SQL duplication). Direct DB access only for sprint lifecycle mutations. Usage: sprint status [--sprint N] [--team T] Sprint progress and ticket overview sprint start [--sprint N] Activate a planned sprint sprint stop [--sprint N] Complete an active sprint sprint start-work [--sprint N] [--team T] Full context dump for starting work sprint prepare [--sprint N] [--team T] Prepare next sprint (candidates + gaps) """ import json import os import sqlite3 import subprocess import sys from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent CONFIG_PATH = SCRIPT_DIR / "config.json" TICKET_CLI = str(SCRIPT_DIR / "ticket") PROJECT_ROOT = (SCRIPT_DIR / ".." / "..").resolve() REMINDER = """--- Reminder: Keep ticket status up to date after finishing work. tooling/db/ticket status in_progress (when starting) tooling/db/ticket status done (when finished)""" def load_config(): with open(CONFIG_PATH, "r") as f: return json.load(f) def resolve_db_path(): """Resolve database path from environment or config.""" env_path = os.environ.get("PROJECT_DB") if env_path: return Path(env_path).resolve() cfg = load_config() db_name = cfg.get("db_name", "project.db") db_location = cfg.get("db_location", "parent") if db_location == "parent": return (SCRIPT_DIR / ".." / ".." / ".." / db_name).resolve() elif db_location == "local": return (SCRIPT_DIR / ".." / ".." / db_name).resolve() else: return Path(db_location).resolve() / db_name def run_ticket(*args): """Call the ticket CLI and return parsed JSON.""" result = subprocess.run( [sys.executable, TICKET_CLI] + list(args), capture_output=True, text=True ) if result.returncode != 0: return {"ok": False, "error": result.stderr.strip()} try: return json.loads(result.stdout) except json.JSONDecodeError: return {"ok": False, "error": f"Bad ticket output: {result.stdout[:200]}"} def get_connection(): """Direct DB connection for lifecycle mutations only.""" db_path = resolve_db_path() conn = sqlite3.connect(str(db_path)) conn.execute("PRAGMA journal_mode=WAL;") conn.execute("PRAGMA foreign_keys=ON;") conn.row_factory = sqlite3.Row return conn def parse_flags(args, known_flags): """Parse --flag value pairs from args, return (flags_dict, positional_args).""" flags = {} positional = [] i = 0 while i < len(args): if args[i].startswith("--") and args[i][2:] in known_flags: key = args[i][2:] if i + 1 < len(args): flags[key] = args[i + 1] i += 2 else: positional.append(args[i]) i += 1 else: positional.append(args[i]) i += 1 return flags, positional def detect_team(flags): """Detect team from flags or git branch.""" if "team" in flags: return flags["team"] try: result = subprocess.run( ["git", "branch", "--show-current"], capture_output=True, text=True, cwd=str(PROJECT_ROOT) ) branch = result.stdout.strip() if branch and branch != "main": return branch except Exception: pass return None def get_all_sprints(): """Get all sprints via ticket CLI.""" data = run_ticket("sprint") if not data.get("ok"): return [] return data.get("sprints", []) def detect_sprint(flags, prefer_status=None): """Detect sprint from flags or by status preference. prefer_status: which status to prefer when auto-detecting. 'active' for status/start-work/stop 'planning' for start None for prepare (targets next sprint) """ if "sprint" in flags: sprint_id = int(flags["sprint"]) sprints = get_all_sprints() for s in sprints: if s["id"] == sprint_id: return s print(f"Error: Sprint {sprint_id} not found.") sys.exit(1) sprints = get_all_sprints() if not sprints: print("Error: No sprints found in database.") sys.exit(1) if prefer_status: matching = [s for s in sprints if s["status"] == prefer_status] if len(matching) == 1: return matching[0] if len(matching) > 1: ids = ", ".join(str(s["id"]) for s in matching) print(f"Error: Multiple {prefer_status} sprints: {ids}. Use --sprint N to specify.") sys.exit(1) # Fall through: no match for preferred status if prefer_status == "active": # No active sprint print("Error: No active sprint. Use --sprint N to specify.") sys.exit(1) if prefer_status == "planning": print("Error: No sprint in planning status. Use sprint prepare first.") sys.exit(1) return None def detect_sprint_for_prepare(flags): """For prepare: target the next sprint after the most recent one.""" if "sprint" in flags: sprint_id = int(flags["sprint"]) sprints = get_all_sprints() for s in sprints: if s["id"] == sprint_id: return s # Sprint doesn't exist yet — return a stub return {"id": sprint_id, "status": "new", "name": None} sprints = get_all_sprints() # If there's a planning sprint, use it planning = [s for s in sprints if s["status"] == "planning"] if len(planning) == 1: return planning[0] if len(planning) > 1: ids = ", ".join(str(s["id"]) for s in planning) print(f"Error: Multiple planning sprints: {ids}. Use --sprint N to specify.") sys.exit(1) # Otherwise target max_id + 1 if sprints: next_id = max(s["id"] for s in sprints) + 1 return {"id": next_id, "status": "new", "name": None} return {"id": 1, "status": "new", "name": None} def get_tickets_for_sprint(sprint_id, team=None): """Get tickets for a sprint, optionally filtered by team.""" args = ["list", "--sprint", str(sprint_id)] if team: args += ["--team", team] data = run_ticket(*args) if not data.get("ok"): return [] return data.get("rows", []) def get_ticket_deps(ticket_id): """Get dependencies for a ticket.""" data = run_ticket("deps", str(ticket_id)) if not data.get("ok"): return {"blocked_by": [], "blocks": []} return data def get_ticket_detail(ticket_id): """Get full ticket detail.""" data = run_ticket("show", str(ticket_id)) if not data.get("ok"): return None return data.get("ticket") def briefing_path(sprint_id, team): """Find the briefing file for a sprint/team if it exists.""" p = PROJECT_ROOT / "docs" / "sprints" / f"sprint-{sprint_id}" / f"{team}.md" if p.exists(): return str(p.relative_to(PROJECT_ROOT)) return None def format_ticket_table(tickets): """Format tickets as an aligned table.""" if not tickets: print(" (none)") return # Header print(f" {'#':<6} {'Title':<50} {'Status':<12} {'Assigned':<10} {'Priority'}") print(f" {'---':<6} {'---':<50} {'---':<12} {'---':<10} {'---'}") for t in tickets: title = t.get("title", "") if len(title) > 48: title = title[:45] + "..." assigned = t.get("assigned_to") or "" print(f" {t['id']:<6} {title:<50} {t['status']:<12} {assigned:<10} {t['priority']}") # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- def cmd_status(args): flags, _ = parse_flags(args, ["sprint", "team"]) sprint = detect_sprint(flags, prefer_status="active") team = detect_team(flags) tickets = get_tickets_for_sprint(sprint["id"], team) # Header name = sprint.get("name", f"Sprint {sprint['id']}") print(f"=== {name} ({sprint['status']}) ===") if sprint.get("goal"): print(f"Goal: {sprint['goal']}") parts = [] if sprint.get("start_date"): parts.append(f"Started: {sprint['start_date']}") if sprint.get("end_date"): parts.append(f"Ended: {sprint['end_date']}") if team: parts.append(f"Team: {team}") if parts: print(" | ".join(parts)) print() # Progress total = len(tickets) done = sum(1 for t in tickets if t["status"] == "done") pct = int(done / total * 100) if total > 0 else 0 print(f"Progress: {done}/{total} done ({pct}%)") # Status breakdown statuses = {} for t in tickets: statuses[t["status"]] = statuses.get(t["status"], 0) + 1 status_parts = [] for s in ["backlog", "ready", "in_progress", "review", "done", "cancelled"]: if s in statuses: status_parts.append(f"{s}: {statuses[s]}") if status_parts: print(f" {' | '.join(status_parts)}") print() # Ticket table print("Tickets:") format_ticket_table(tickets) print() # Blocked tickets blocked_lines = [] for t in tickets: if t["status"] == "done": continue deps = get_ticket_deps(t["id"]) for b in deps.get("blocked_by", []): if b["status"] != "done": blocked_lines.append(f" #{t['id']} blocked by #{b['id']} ({b['status']})") if blocked_lines: print("Blocked:") for line in blocked_lines: print(line) print() # Briefing if team: bp = briefing_path(sprint["id"], team) if bp: print(f"Briefing: {bp}") else: # Show all available briefings briefings = [] for t_name in ["server", "client", "copy", "audio", "visual", "ci", "joint"]: bp = briefing_path(sprint["id"], t_name) if bp: briefings.append(bp) if briefings: print("Briefings:") for bp in briefings: print(f" {bp}") print() print(REMINDER) def cmd_start(args): flags, _ = parse_flags(args, ["sprint"]) sprint = detect_sprint(flags, prefer_status="planning") if sprint["status"] != "planning": print(f"Error: Sprint {sprint['id']} is '{sprint['status']}', expected 'planning'.") sys.exit(1) # Check ticket count tickets = get_tickets_for_sprint(sprint["id"]) if not tickets: print(f"Error: Sprint {sprint['id']} has no tickets. Run sprint prepare first.") sys.exit(1) # Activate conn = get_connection() conn.execute( "UPDATE sprints SET status='active', start_date=date('now') WHERE id=?", (sprint["id"],) ) conn.commit() conn.close() # Summary teams = {} for t in tickets: team = t.get("team") or "unassigned" teams[team] = teams.get(team, 0) + 1 name = sprint.get("name", f"Sprint {sprint['id']}") print(f"Started: {name}") print(f"Tickets: {len(tickets)}") for team, count in sorted(teams.items()): print(f" {team}: {count}") print() print(REMINDER) def cmd_stop(args): flags, _ = parse_flags(args, ["sprint"]) sprint = detect_sprint(flags, prefer_status="active") if sprint["status"] != "active": print(f"Error: Sprint {sprint['id']} is '{sprint['status']}', expected 'active'.") sys.exit(1) tickets = get_tickets_for_sprint(sprint["id"]) done = [t for t in tickets if t["status"] == "done"] cancelled = [t for t in tickets if t["status"] == "cancelled"] incomplete = [t for t in tickets if t["status"] not in ("done", "cancelled")] # Complete conn = get_connection() conn.execute( "UPDATE sprints SET status='completed', end_date=date('now') WHERE id=?", (sprint["id"],) ) conn.commit() conn.close() name = sprint.get("name", f"Sprint {sprint['id']}") print(f"Completed: {name}") print(f"Done: {len(done)}/{len(tickets)}") if cancelled: print(f"Cancelled: {len(cancelled)}") print() if incomplete: print("Carry-over candidates (incomplete):") format_ticket_table(incomplete) print() print(REMINDER) def cmd_start_work(args): flags, _ = parse_flags(args, ["sprint", "team"]) sprint = detect_sprint(flags, prefer_status="active") team = detect_team(flags) if sprint["status"] != "active": print(f"Error: Sprint {sprint['id']} is '{sprint['status']}', expected 'active'.") sys.exit(1) tickets = get_tickets_for_sprint(sprint["id"], team) # Header name = sprint.get("name", f"Sprint {sprint['id']}") team_label = f" \u2014 {team.title()}" if team else "" print(f"=== {name}{team_label} ===") if sprint.get("goal"): print(f"Goal: {sprint['goal']}") parts = [f"Status: {sprint['status']}"] if sprint.get("start_date"): parts.append(f"Started: {sprint['start_date']}") print(" | ".join(parts)) print() # Briefing if team: bp = briefing_path(sprint["id"], team) if bp: print(f"Briefing: {bp}") # Also check joint briefing jbp = briefing_path(sprint["id"], "joint") if jbp: print(f"Joint briefing: {jbp}") # Collect decision refs decision_refs = set() for t in tickets: detail = get_ticket_detail(t["id"]) if detail and detail.get("decision_ref"): decision_refs.add(detail["decision_ref"]) if decision_refs: print(f"Decisions: {', '.join(sorted(decision_refs))}") print() # Build dependency map blocked_by_map = {} # ticket_id -> [blocker tickets] blocks_map = {} # ticket_id -> [blocked ticket ids] for t in tickets: deps = get_ticket_deps(t["id"]) open_blockers = [b for b in deps.get("blocked_by", []) if b["status"] != "done"] if open_blockers: blocked_by_map[t["id"]] = open_blockers blocking = deps.get("blocks", []) if blocking: blocks_map[t["id"]] = blocking # Categorize done_tickets = [t for t in tickets if t["status"] == "done"] blocked_tickets = [t for t in tickets if t["status"] != "done" and t["id"] in blocked_by_map] actionable_tickets = [t for t in tickets if t["status"] != "done" and t["id"] not in blocked_by_map] # Actionable if actionable_tickets: print("Actionable (not blocked, not done):") for t in actionable_tickets: print(f" #{t['id']}: {t['title']}") # Metadata line meta = [t.get("type", ""), f"P:{t['priority']}", f"S:{t['status']}"] if t.get("assigned_to"): meta.append(f"@{t['assigned_to']}") if t.get("team"): meta.append(f"Team:{t['team']}") detail = get_ticket_detail(t["id"]) if detail and detail.get("decision_ref"): meta.append(f"Ref:{detail['decision_ref']}") print(f" {' | '.join(meta)}") if t["id"] in blocks_map: block_ids = ", ".join(f"#{b['id']}" for b in blocks_map[t["id"]]) print(f" Blocks: {block_ids}") print() # Blocked if blocked_tickets: print("Blocked:") for t in blocked_tickets: blockers = blocked_by_map[t["id"]] blocker_str = ", ".join(f"#{b['id']} ({b['status']})" for b in blockers) print(f" #{t['id']}: {t['title']} \u2190 blocked by {blocker_str}") print() # Done if done_tickets: print("Done:") for t in done_tickets: print(f" #{t['id']}: {t['title']} \u2713") print() print(REMINDER) def cmd_prepare(args): flags, _ = parse_flags(args, ["sprint", "team"]) sprint = detect_sprint_for_prepare(flags) team = detect_team(flags) # Create sprint record if it doesn't exist if sprint.get("status") == "new": conn = get_connection() conn.execute( "INSERT INTO sprints (id, name, status) VALUES (?, ?, 'planning')", (sprint["id"], f"Sprint {sprint['id']}") ) conn.commit() conn.close() print(f"Created Sprint {sprint['id']} (planning)") sprint["status"] = "planning" sprint["name"] = f"Sprint {sprint['id']}" elif sprint["status"] not in ("planning", "new"): print(f"Warning: Sprint {sprint['id']} is '{sprint['status']}', not 'planning'.") print(f"=== Preparing Sprint {sprint['id']} ===") print() # Previous sprint info all_sprints = get_all_sprints() prev_sprints = [s for s in all_sprints if s["id"] < sprint["id"]] if prev_sprints: prev = max(prev_sprints, key=lambda s: s["id"]) prev_tickets = get_tickets_for_sprint(prev["id"]) prev_done = sum(1 for t in prev_tickets if t["status"] == "done") prev_name = prev.get("name", f"Sprint {prev['id']}") print(f"Previous: {prev_name} ({prev['status']}, {prev_done}/{len(prev_tickets)} done)") print() # Carry-over candidates incomplete = [t for t in prev_tickets if t["status"] not in ("done", "cancelled")] if team: incomplete = [t for t in incomplete if team in (t.get("team") or "")] if incomplete: print("Carry-over candidates (incomplete from previous sprint):") format_ticket_table(incomplete) print() # Backlog candidates backlog_args = ["list", "--status", "backlog"] if team: backlog_args += ["--team", team] backlog_data = run_ticket(*backlog_args) backlog = backlog_data.get("rows", []) if backlog_data.get("ok") else [] # Filter out tickets already assigned to a sprint backlog = [t for t in backlog if not t.get("sprint_id")] if backlog: if team: print(f"Backlog candidates ({team}):") format_ticket_table(backlog) else: # Group by team by_team = {} for t in backlog: t_team = t.get("team") or "unassigned" by_team.setdefault(t_team, []).append(t) print("Backlog candidates (unassigned to any sprint):") for t_name in sorted(by_team.keys()): print(f"\n {t_name.title()}:") format_ticket_table(by_team[t_name]) print() # Decision coverage gaps conn = get_connection() cursor = conn.execute(""" SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL) ORDER BY id """) orphans = cursor.fetchall() conn.close() if orphans: print("Decision coverage gaps (active decisions without tickets):") for row in orphans: print(f" {row[0]}: {row[1]}") print() # Already assigned to this sprint assigned = get_tickets_for_sprint(sprint["id"], team) if assigned: print(f"Already assigned to Sprint {sprint['id']}:") format_ticket_table(assigned) print() print(REMINDER) # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- HELP = """sprint \u2014 sprint lifecycle and context for agents Usage: sprint status [--sprint N] [--team T] Sprint progress and ticket overview sprint start [--sprint N] Activate a planned sprint sprint stop [--sprint N] Complete an active sprint sprint start-work [--sprint N] [--team T] Full context dump for starting work sprint prepare [--sprint N] [--team T] Prepare next sprint (candidates + gaps) Sprint auto-detection: status/start-work prefer the active sprint start prefer the planning sprint stop prefer the active sprint prepare target next sprint (max id + 1) Team auto-detection: If --team is omitted, uses the current git branch name (unless on main). On main with no --team, shows all teams.""" def main(): if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"): print(HELP) sys.exit(0) cmd = sys.argv[1] args = sys.argv[2:] commands = { "status": cmd_status, "start": cmd_start, "stop": cmd_stop, "start-work": cmd_start_work, "prepare": cmd_prepare, } if cmd not in commands: print(f"Error: Unknown command '{cmd}'. Use --help for usage.") sys.exit(1) commands[cmd](args) if __name__ == "__main__": main()