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>
650 lines
20 KiB
Python
Executable File
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()
|