From 187584209f5c2f43ea64de5edcc72d56f62e59a9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 14 Feb 2026 10:29:28 +0100 Subject: [PATCH] feat(db): add sprint CLI for lifecycle management and agent context Unified sprint script at db/connectors/sprint with 5 subcommands: status, start, stop, start-work, prepare. Calls ticket CLI via subprocess (no SQL duplication), auto-detects sprint from DB state and team from git branch. Guards prevent activating unplanned sprints. Updated start-sprint, plan-sprint, and ticket skills to reference the new CLI. Added sprint CLI section to CLAUDE.md. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/plan-sprint/SKILL.md | 57 ++- .claude/skills/start-sprint/SKILL.md | 26 +- .claude/skills/ticket/SKILL.md | 3 + CLAUDE.md | 12 + db/connectors/sprint | 627 +++++++++++++++++++++++++++ 5 files changed, 681 insertions(+), 44 deletions(-) create mode 100755 db/connectors/sprint diff --git a/.claude/skills/plan-sprint/SKILL.md b/.claude/skills/plan-sprint/SKILL.md index 7d9cbe43e..1597a75f6 100644 --- a/.claude/skills/plan-sprint/SKILL.md +++ b/.claude/skills/plan-sprint/SKILL.md @@ -53,34 +53,24 @@ file so the team knows who to spawn. ## Workflow -### 1. Determine sprint number +### 1. Run sprint prepare + +Get carry-overs, backlog candidates, and decision gaps in one shot: ```bash -db/connectors/ticket sprint --active +db/connectors/sprint prepare ``` -Next sprint = active sprint ID + 1. If no active sprint, ask the user. +This auto-detects the next sprint number (max ID + 1), creates the sprint +record in `planning` status if needed, and outputs: +- Previous sprint status and carry-over candidates +- Backlog candidates grouped by team +- Decision coverage gaps +- Already-assigned tickets (if any) -### 2. Gather current sprint state +### 2. Deepen the scan -Review the active sprint for carry-overs: - -```bash -db/connectors/ticket list --sprint --status in_progress -db/connectors/ticket list --sprint --status ready -``` - -Any ticket not `done` is a potential carry-over. Note these for the briefing. - -### 3. Scan the backlog - -Pull candidate tickets by priority: - -```bash -db/connectors/ticket epics --status backlog -``` - -For critical epics, check their children: +For critical epics, check their children for granular candidates: ```bash db/connectors/ticket children @@ -88,7 +78,7 @@ db/connectors/ticket children Use `db/connectors/ticket show --brief [...]` to quickly scan multiple tickets. -### 4. Read existing code state +### 3. Read existing code state Scan what's already built to write accurate "what exists" notes: @@ -103,7 +93,7 @@ ls client/scripts/ client/scripts/*/ Read key files that sprint tickets will build on (bridge types, existing renderers, etc.) to reference specific integration points in the briefing. -### 5. Select tickets — propose to user +### 4. Select tickets — propose to user Based on the backlog scan, propose a sprint with: @@ -122,12 +112,12 @@ Selection heuristics: - Aim for 3-6 tickets per team, with parallel tracks where possible - Check `decisions/questions.md` for open Q-NNN items that block candidates -### 6. Read relevant decisions +### 5. Read relevant decisions For the selected tickets, identify which `decisions/*.md` files are relevant. Read them to provide accurate cross-references in the briefing. -### 7. Write briefing files +### 6. Write briefing files Create `docs/sprints/sprint-N/` and write one file per team. @@ -148,19 +138,24 @@ Key requirements per file: Only generate briefing files for teams that have tickets assigned in the sprint. Not every sprint will have work for every team. -### 8. Assign tickets to sprint in DB +### 7. Assign tickets to sprint in DB -After the user approves, assign all selected tickets to the new sprint: +After the user approves, assign all selected tickets. The sprint record +was already created by `sprint prepare` in step 1 (status: `planning`). +Update it with the theme and goal, then assign tickets: ```bash -# Create the sprint -db/connectors/sqlite-exec "INSERT INTO sprints (name, goal, status) VALUES ('Sprint N: Theme', 'goal', 'planned')" +# Update the sprint with theme and goal +db/connectors/sqlite-exec "UPDATE sprints SET name='Sprint N: Theme', goal='goal' WHERE id=N" # Assign tickets db/connectors/ticket sprint assign ``` -### 9. Present summary +The sprint stays in `planning` status until explicitly activated via +`db/connectors/sprint start`. This prevents starting an unplanned sprint. + +### 8. Present summary Output: - Sprint number, theme, and goal diff --git a/.claude/skills/start-sprint/SKILL.md b/.claude/skills/start-sprint/SKILL.md index d0df783a5..3999e040d 100644 --- a/.claude/skills/start-sprint/SKILL.md +++ b/.claude/skills/start-sprint/SKILL.md @@ -35,34 +35,34 @@ git merge origin/main --no-edit If the merge has conflicts, report them and stop — do not force-resolve. -### 3. Find the active sprint +### 3. Load sprint context + +Run the sprint CLI to get the full context dump in one shot: ```bash -db/connectors/ticket sprint --active +db/connectors/sprint start-work ``` -Extract the sprint ID and name from the JSON output. If no active sprint, -report that and stop. +This auto-detects the active sprint and current team from the branch. +It outputs: sprint metadata, briefing paths, decision refs, actionable +tickets, blocked tickets, and done tickets. + +If no active sprint is found, report that and stop. ### 4. Read the sprint briefing -Read `docs/sprints/sprint-N/.md` where N is the sprint ID -and team matches the branch name (e.g. `server.md`, `client.md`, `copy.md`). -If no matching briefing exists for the team, report that and suggest running +Read the briefing file(s) listed in the `start-work` output +(e.g. `docs/sprints/sprint-6/server.md` and `joint.md`). +If no matching briefing exists for the team, suggest running `/plan-sprint` to generate one. -Also check for a `joint.md` briefing — joint tasks involve both teams and -should be mentioned. - ### 5. Load ticket details -For each ticket listed in the briefing, run: +For tickets that need more detail than the `start-work` summary provides: ```bash db/connectors/ticket show ``` -Identify which tickets are actionable now (no open blockers) vs blocked. - ### 6. Read key decisions Read the decision files referenced in the sprint briefing so the agent has diff --git a/.claude/skills/ticket/SKILL.md b/.claude/skills/ticket/SKILL.md index e5ef809a3..bb796e8ae 100644 --- a/.claude/skills/ticket/SKILL.md +++ b/.claude/skills/ticket/SKILL.md @@ -53,6 +53,9 @@ db/connectors/ticket sprint [--active] db/connectors/ticket sprint assign ``` +For sprint-scoped operations (status overview, context dumps, lifecycle), +use the dedicated sprint CLI instead: `db/connectors/sprint --help` + ### Dependencies ```bash db/connectors/ticket deps diff --git a/CLAUDE.md b/CLAUDE.md index e19f3a4b5..8f24fe438 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,18 @@ db/connectors/ticket show 78 db/connectors/ticket sprint --active ``` +### Sprint CLI +**Use the sprint CLI for sprint-scoped operations.** It batches ticket queries and formats output for agent consumption: +```bash +db/connectors/sprint status # Current sprint progress +db/connectors/sprint status --team server # Team-scoped view +db/connectors/sprint start-work --team client # Full context dump for starting work +db/connectors/sprint prepare # Prepare next sprint (candidates + gaps) +db/connectors/sprint start # Activate a planned sprint +db/connectors/sprint stop # Complete an active sprint +``` +Team is auto-detected from the current git branch (if not `main`). Sprint is auto-detected from DB state. + Only fall back to raw SQL for queries the CLI doesn't support. **Never use the `sqlite3` CLI** — it crashes in Claude Code due to a known std::bad_alloc bug. Use the wrapper scripts instead: ```bash db/connectors/sqlite-query "SELECT * FROM tickets WHERE status='in_progress'" diff --git a/db/connectors/sprint b/db/connectors/sprint new file mode 100755 index 000000000..fb1b00997 --- /dev/null +++ b/db/connectors/sprint @@ -0,0 +1,627 @@ +#!/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 +TICKET_CLI = str(SCRIPT_DIR / "ticket") +DB_PATH = (SCRIPT_DIR / ".." / ".." / ".." / "settledreach.db").resolve() +PROJECT_ROOT = (SCRIPT_DIR / ".." / "..").resolve() + +REMINDER = """--- +Reminder: Keep ticket status up to date after finishing work. + db/connectors/ticket status in_progress (when starting) + db/connectors/ticket status done (when finished)""" + + +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.""" + 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()