Files
settled-reach/whatsinagame/static/db/connectors/sprint
T
jpmschweitzerandClaude Opus 4.6 7158ae9cf5 feat(meta): add whatsinagame multi-agent team starter kit
Claude-native starter kit that bootstraps multi-agent team
infrastructure for any project. Clone once, install as a global
skill, run /kit-install in any project directory.

Includes:
- 3-tier profile system (minimal/standard/full: 3-12 agents)
- 16 agent archetype templates with personality spectrum
- 18 skill templates using domain-action naming convention
- Stakeholder persona panel for workshops and PR reviews
- SQLite ticketing DB with CLI tools (config-based DB paths)
- Decision tracking, sprint lifecycle, workshop orchestration
- Multi-git-host support (GitHub, Gitea, GitLab)
- /kit-update skill for syncing with source repo evolution
- Naming theme support for agent identity/flavor
- Smoke tests for all three profile tiers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:22:30 +01:00

650 lines
20 KiB
Python
Executable File

#!/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.
db/connectors/ticket status <id> in_progress (when starting)
db/connectors/ticket status <id> 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()