feat(meta): replace sprint workflow with kanban + milestones (D-221)
Sprint-based workflow (38 sprints) replaced by kanban + milestones. Milestones are many-to-many with tickets and can block each other. New: /whats-next skill (dependency-driven batch selection with Si refinement review), /pr-process skill (renamed from pr-push, adds review comment pickup), clerk agent + pre-push hook for D-record consistency checks. Deleted: sprint CLI, sprint-start/sprint-plan/sprint-status skills, team-scoped file restrictions. Si rewritten as refinement manager. All 19 agent briefings updated from stale PROJECT_STATE.md reference to live ticket milestone queries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Clerk pre-push review — spawns the clerk agent to check D-record consistency,
|
||||
ticket drift, and decision contradictions against the current diff.
|
||||
|
||||
Outputs exactly one word to stdout: APPROVED or REJECTED.
|
||||
Writes verbose findings to .cache/pre-push-review.md.
|
||||
|
||||
Exit codes:
|
||||
0 = APPROVED
|
||||
1 = REJECTED (hard contradiction found)
|
||||
2 = TIMEOUT (clerk didn't respond in time — treated as REJECTED)
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(subprocess.check_output(
|
||||
["git", "rev-parse", "--show-toplevel"], text=True
|
||||
).strip())
|
||||
|
||||
CACHE_DIR = REPO_ROOT / ".cache"
|
||||
FINDINGS_FILE = CACHE_DIR / "pre-push-review.md"
|
||||
TIMEOUT_SECONDS = 120
|
||||
|
||||
CLERK_PROMPT = """You are the CLERK, the institutional guardrail for The Settled Reach.
|
||||
|
||||
You are reviewing a pre-push diff for D-record consistency, ticket drift, and decision contradictions.
|
||||
|
||||
## The diff
|
||||
|
||||
{diff}
|
||||
|
||||
## Your task
|
||||
|
||||
1. Read `decisions/README.md` for the domain index.
|
||||
2. For each changed file in the diff, check:
|
||||
- Does the change contradict any active D-record? (grep decisions/*.md for relevant keywords)
|
||||
- If code references a D/Q/R-ID, does that ID exist and is it active?
|
||||
- If commits reference ticket #NNN, does the implementation match the ticket description?
|
||||
3. Surface any open Q-records relevant to changed files.
|
||||
|
||||
## Output format
|
||||
|
||||
First, write your detailed findings. Then on the VERY LAST LINE of your response,
|
||||
output exactly one word — either APPROVED or REJECTED.
|
||||
|
||||
REJECTED only for hard contradictions with active D-records. Everything else
|
||||
(drift, open Q-records, suggestions) is a finding but not a block.
|
||||
"""
|
||||
|
||||
|
||||
def get_diff():
|
||||
"""Get the diff that would be pushed."""
|
||||
branch = subprocess.check_output(
|
||||
["git", "branch", "--show-current"], text=True
|
||||
).strip()
|
||||
|
||||
for ref in [f"origin/{branch}", "origin/main"]:
|
||||
try:
|
||||
subprocess.check_output(
|
||||
["git", "rev-parse", "--verify", ref],
|
||||
stderr=subprocess.DEVNULL, text=True
|
||||
)
|
||||
return subprocess.check_output(
|
||||
["git", "diff", f"{ref}...HEAD"], text=True
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
|
||||
return subprocess.check_output(["git", "diff", "HEAD~1"], text=True)
|
||||
|
||||
|
||||
def run_clerk(diff_text):
|
||||
"""Spawn clerk via claude --print, return (verdict, findings)."""
|
||||
max_diff = 50000
|
||||
if len(diff_text) > max_diff:
|
||||
diff_text = diff_text[:max_diff] + f"\n\n... (truncated, {len(diff_text)} total chars)"
|
||||
|
||||
prompt = CLERK_PROMPT.format(diff=diff_text)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
f.write(prompt)
|
||||
prompt_file = f.name
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"claude", "-p",
|
||||
"--model", "sonnet",
|
||||
"--max-turns", "3",
|
||||
],
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=TIMEOUT_SECONDS,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
output = result.stdout.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
return "TIMEOUT", "Clerk agent timed out after {} seconds.".format(TIMEOUT_SECONDS)
|
||||
finally:
|
||||
os.unlink(prompt_file)
|
||||
|
||||
if not output:
|
||||
return "REJECTED", "Clerk agent produced no output."
|
||||
|
||||
lines = output.strip().split("\n")
|
||||
last_line = lines[-1].strip().upper()
|
||||
|
||||
if last_line == "APPROVED":
|
||||
verdict = "APPROVED"
|
||||
elif last_line == "REJECTED":
|
||||
verdict = "REJECTED"
|
||||
else:
|
||||
verdict = "REJECTED"
|
||||
output += "\n\n(No clear verdict on last line — defaulting to REJECTED)"
|
||||
|
||||
return verdict, output
|
||||
|
||||
|
||||
def main():
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
diff_text = get_diff()
|
||||
if not diff_text.strip():
|
||||
print("APPROVED")
|
||||
FINDINGS_FILE.write_text("# Clerk Review\n\nNo diff to review.\n\nVerdict: APPROVED\n")
|
||||
return 0
|
||||
|
||||
print(" clerk: reviewing diff...", file=sys.stderr)
|
||||
verdict, findings = run_clerk(diff_text)
|
||||
|
||||
FINDINGS_FILE.write_text(f"# Clerk Pre-Push Review\n\n{findings}\n")
|
||||
print(f" clerk: verdict — {verdict} (details: {FINDINGS_FILE})", file=sys.stderr)
|
||||
print(verdict)
|
||||
|
||||
if verdict == "APPROVED":
|
||||
return 0
|
||||
elif verdict == "TIMEOUT":
|
||||
return 2
|
||||
else:
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,743 +0,0 @@
|
||||
#!/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 sweep [--sprint N] Health check: grouped tickets, issues, team summary (JSON)
|
||||
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 subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from common import WORKTREE_ROOT, get_connection, load_config # noqa: E402
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
TICKET_CLI = str(SCRIPT_DIR / "ticket")
|
||||
PROJECT_ROOT = WORKTREE_ROOT
|
||||
|
||||
REMINDER = """---
|
||||
Reminder: Keep ticket status up to date after finishing work.
|
||||
tooling/db/ticket status <id> in_progress (when starting)
|
||||
tooling/db/ticket status <id> 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 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(load_config())
|
||||
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")]
|
||||
|
||||
# Auto-close: mark picked-up tickets (in_progress, review) as done.
|
||||
# Work merged to main before sprint close means the ticket is done —
|
||||
# agents just forget to update status. Backlog/ready tickets were
|
||||
# never started, so they stay as carry-over candidates.
|
||||
picked_up_statuses = ("in_progress", "review")
|
||||
picked_up = [t for t in incomplete if t["status"] in picked_up_statuses]
|
||||
auto_closed = []
|
||||
if picked_up:
|
||||
conn = get_connection(load_config())
|
||||
for t in picked_up:
|
||||
conn.execute("UPDATE tickets SET status='done' WHERE id=?", (t["id"],))
|
||||
auto_closed.append(t)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
# Move auto-closed into done count, remove from incomplete
|
||||
done = done + auto_closed
|
||||
incomplete = [t for t in incomplete if t["status"] not in picked_up_statuses]
|
||||
|
||||
# Unassign all done tickets in this sprint (agents don't clean up after themselves)
|
||||
conn = get_connection(load_config())
|
||||
conn.execute(
|
||||
"UPDATE tickets SET assigned_to=NULL WHERE sprint_id=? AND status='done' AND assigned_to IS NOT NULL",
|
||||
(sprint["id"],)
|
||||
)
|
||||
unassigned = conn.total_changes
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Complete the sprint
|
||||
conn = get_connection(load_config())
|
||||
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)}")
|
||||
if auto_closed:
|
||||
print(f"Auto-closed: {len(auto_closed)} tickets marked done on sprint close:")
|
||||
for t in auto_closed:
|
||||
print(f" #{t['id']}: {t['title']} ({t['status']} → done)")
|
||||
print()
|
||||
|
||||
if incomplete:
|
||||
print("Carry-over candidates (never started):")
|
||||
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_sweep(args):
|
||||
"""Health check: grouped tickets, bookkeeping issues, team summary (JSON)."""
|
||||
flags, _ = parse_flags(args, ["sprint"])
|
||||
sprint = detect_sprint(flags, prefer_status="active")
|
||||
|
||||
tickets = get_tickets_for_sprint(sprint["id"])
|
||||
|
||||
# Build dependency map for blocked detection
|
||||
blocked_by_map = {} # ticket_id -> [blocker_id, ...]
|
||||
for t in tickets:
|
||||
if t["status"] == "done":
|
||||
continue
|
||||
deps = get_ticket_deps(t["id"])
|
||||
open_blockers = [b["id"] for b in deps.get("blocked_by", []) if b["status"] != "done"]
|
||||
if open_blockers:
|
||||
blocked_by_map[t["id"]] = open_blockers
|
||||
|
||||
# Group by status
|
||||
by_status = {"done": [], "review": [], "in_progress": [], "blocked": [], "backlog": []}
|
||||
for t in tickets:
|
||||
entry = {
|
||||
"id": t["id"],
|
||||
"title": t["title"],
|
||||
"team": t.get("team") or "unassigned",
|
||||
"assigned_to": t.get("assigned_to"),
|
||||
}
|
||||
if t["status"] == "done":
|
||||
by_status["done"].append(entry)
|
||||
elif t["id"] in blocked_by_map:
|
||||
entry["blocked_by"] = blocked_by_map[t["id"]]
|
||||
by_status["blocked"].append(entry)
|
||||
elif t["status"] == "review":
|
||||
by_status["review"].append(entry)
|
||||
elif t["status"] == "in_progress":
|
||||
by_status["in_progress"].append(entry)
|
||||
else:
|
||||
by_status["backlog"].append(entry)
|
||||
|
||||
# Per-team summary
|
||||
by_team = {}
|
||||
for status, items in by_status.items():
|
||||
for item in items:
|
||||
team = item["team"]
|
||||
if team not in by_team:
|
||||
by_team[team] = {"backlog": 0, "in_progress": 0, "review": 0, "blocked": 0, "done": 0, "total": 0}
|
||||
by_team[team][status] = by_team[team].get(status, 0) + 1
|
||||
by_team[team]["total"] += 1
|
||||
|
||||
# Bookkeeping issues
|
||||
issues = []
|
||||
for t in tickets:
|
||||
if t["status"] in ("in_progress", "review") and not t.get("assigned_to"):
|
||||
issues.append({
|
||||
"type": "unassigned_in_progress",
|
||||
"detail": f"#{t['id']} unassigned {t['status']}",
|
||||
"fix": f"tooling/db/ticket assign {t['id']} <agent>",
|
||||
})
|
||||
if t["status"] == "backlog" and sprint["status"] == "active" and t["id"] not in blocked_by_map:
|
||||
issues.append({
|
||||
"type": "stale_backlog",
|
||||
"detail": f"#{t['id']} stale backlog",
|
||||
"fix": f"tooling/db/ticket status {t['id']} in_progress",
|
||||
})
|
||||
if t["status"] == "done" and t.get("assigned_to"):
|
||||
issues.append({
|
||||
"type": "assigned_but_done",
|
||||
"detail": f"#{t['id']} done, still assigned",
|
||||
"fix": f"tooling/db/ticket unassign {t['id']}",
|
||||
})
|
||||
|
||||
# Progress
|
||||
total = len(tickets)
|
||||
done = len(by_status["done"])
|
||||
pct = int(done / total * 100) if total > 0 else 0
|
||||
|
||||
result = {
|
||||
"sprint": {
|
||||
"id": sprint["id"],
|
||||
"name": sprint.get("name", f"Sprint {sprint['id']}"),
|
||||
"goal": sprint.get("goal", ""),
|
||||
},
|
||||
"progress": {"total": total, "done": done, "pct": pct},
|
||||
"by_status": by_status,
|
||||
"by_team": by_team,
|
||||
"issues": issues,
|
||||
}
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
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(load_config())
|
||||
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(load_config())
|
||||
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 sweep [--sprint N] Health check: grouped tickets, issues, team summary (JSON)
|
||||
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/sweep 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,
|
||||
"sweep": cmd_sweep,
|
||||
"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()
|
||||
+214
-48
@@ -3,21 +3,29 @@
|
||||
Ticket CLI — ergonomic interface to the project ticketing database.
|
||||
|
||||
Usage:
|
||||
ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T]
|
||||
ticket list [--status S] [--priority P] [--epic N] [--milestone N] [--assigned A] [--team T]
|
||||
ticket show <id>
|
||||
ticket done <id> [<id> ...]
|
||||
ticket status <id> <new_status>
|
||||
ticket assign <id> <agent>
|
||||
ticket unassign <id>
|
||||
ticket team <id> <teams>
|
||||
ticket sprint [--active]
|
||||
ticket sprint assign <id> <sprint_id>
|
||||
ticket deps <id>
|
||||
ticket dep add <blocker_id> <blocked_id>
|
||||
ticket dep rm <blocker_id> <blocked_id>
|
||||
ticket search <keyword>
|
||||
ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]
|
||||
ticket epics [--status S]
|
||||
ticket children <id>
|
||||
ticket count [--status S]
|
||||
ticket wip
|
||||
ticket milestone list [--status S]
|
||||
ticket milestone create <name> [--description TEXT] [--phase N]
|
||||
ticket milestone link <ticket_id> <milestone_id>
|
||||
ticket milestone unlink <ticket_id> <milestone_id>
|
||||
ticket milestone complete <milestone_id>
|
||||
ticket milestone show <milestone_id>
|
||||
ticket milestone dep <blocker_id> <blocked_id>
|
||||
|
||||
All output is JSON on stdout.
|
||||
"""
|
||||
@@ -30,6 +38,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from common import get_connection, load_config # noqa: E402
|
||||
|
||||
WIP_LIMIT = 3
|
||||
|
||||
|
||||
def query(conn, sql, params=()):
|
||||
cursor = conn.execute(sql, params)
|
||||
@@ -72,9 +82,10 @@ def parse_flags(args, known_flags):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_list(conn, args):
|
||||
flags, _ = parse_flags(args, ["status", "priority", "epic", "sprint", "assigned", "team"])
|
||||
flags, _ = parse_flags(args, ["status", "priority", "epic", "milestone", "assigned", "team"])
|
||||
conditions = []
|
||||
params = []
|
||||
join = ""
|
||||
if "status" in flags:
|
||||
conditions.append("t.status = ?")
|
||||
params.append(flags["status"])
|
||||
@@ -84,20 +95,20 @@ def cmd_list(conn, args):
|
||||
if "epic" in flags:
|
||||
conditions.append("t.parent_id = ?")
|
||||
params.append(int(flags["epic"]))
|
||||
if "sprint" in flags:
|
||||
conditions.append("t.sprint_id = ?")
|
||||
params.append(int(flags["sprint"]))
|
||||
if "milestone" in flags:
|
||||
join = "JOIN ticket_milestones tm ON tm.ticket_id = t.id"
|
||||
conditions.append("tm.milestone_id = ?")
|
||||
params.append(int(flags["milestone"]))
|
||||
if "assigned" in flags:
|
||||
conditions.append("t.assigned_to = ?")
|
||||
params.append(flags["assigned"])
|
||||
if "team" in flags:
|
||||
# Match exact team name within comma-separated list
|
||||
conditions.append("(',' || t.team || ',' LIKE '%,' || ? || ',%')")
|
||||
params.append(flags["team"])
|
||||
where = " AND ".join(conditions) if conditions else "1=1"
|
||||
sql = f"""SELECT t.id, t.type, t.title, t.status, t.priority, t.assigned_to,
|
||||
t.team, t.parent_id, t.sprint_id
|
||||
FROM tickets t WHERE {where}
|
||||
sql = f"""SELECT DISTINCT t.id, t.type, t.title, t.status, t.priority, t.assigned_to,
|
||||
t.team, t.parent_id
|
||||
FROM tickets t {join} WHERE {where}
|
||||
ORDER BY
|
||||
CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1
|
||||
WHEN 'medium' THEN 2 ELSE 3 END, t.id"""
|
||||
@@ -115,19 +126,20 @@ def cmd_show(conn, ids, brief=False):
|
||||
tickets.append({"id": ticket_id, "error": f"Ticket #{ticket_id} not found"})
|
||||
continue
|
||||
ticket = rows[0]
|
||||
# Get children
|
||||
children = query(conn, "SELECT id, title, status, priority FROM tickets WHERE parent_id = ? ORDER BY id", (ticket_id,))
|
||||
# Get dependencies (what blocks this)
|
||||
blockers = query(conn, """SELECT t.id, t.title, t.status FROM ticket_deps d
|
||||
JOIN tickets t ON d.blocker_id = t.id
|
||||
WHERE d.blocked_id = ?""", (ticket_id,))
|
||||
# Get dependents (what this blocks)
|
||||
blocks = query(conn, """SELECT t.id, t.title, t.status FROM ticket_deps d
|
||||
JOIN tickets t ON d.blocked_id = t.id
|
||||
WHERE d.blocker_id = ?""", (ticket_id,))
|
||||
milestones = query(conn, """SELECT m.id, m.name, m.status FROM ticket_milestones tm
|
||||
JOIN milestones m ON tm.milestone_id = m.id
|
||||
WHERE tm.ticket_id = ?""", (ticket_id,))
|
||||
ticket["children"] = children
|
||||
ticket["blocked_by"] = blockers
|
||||
ticket["blocks"] = blocks
|
||||
ticket["milestones"] = milestones
|
||||
tickets.append(ticket)
|
||||
if brief:
|
||||
_print_brief(tickets)
|
||||
@@ -142,9 +154,7 @@ def _print_brief(tickets):
|
||||
if "error" in t:
|
||||
print(f"#{t['id']}: NOT FOUND")
|
||||
continue
|
||||
# Header line
|
||||
print(f"#{t['id']}: {t['title']}")
|
||||
# Metadata line
|
||||
parts = [f"{t['type']}", f"P:{t['priority']}", f"S:{t['status']}"]
|
||||
if t.get("assigned_to"):
|
||||
parts.append(f"@{t['assigned_to']}")
|
||||
@@ -152,19 +162,17 @@ def _print_brief(tickets):
|
||||
parts.append(f"Team:{t['team']}")
|
||||
if t.get("parent_id"):
|
||||
parts.append(f"Epic:#{t['parent_id']} ({t.get('parent_title', '?')})")
|
||||
if t.get("sprint_id"):
|
||||
parts.append(f"Sprint:{t['sprint_id']}")
|
||||
if t.get("milestones"):
|
||||
ms = ", ".join(f"M{m['id']}" for m in t["milestones"])
|
||||
parts.append(f"Milestones:{ms}")
|
||||
if t.get("decision_ref"):
|
||||
parts.append(f"Ref:{t['decision_ref']}")
|
||||
print(f" {' | '.join(parts)}")
|
||||
# Description
|
||||
desc = t.get("description") or ""
|
||||
if desc:
|
||||
# Truncate long descriptions
|
||||
if len(desc) > 200:
|
||||
desc = desc[:197] + "..."
|
||||
print(f" {desc}")
|
||||
# Dependencies
|
||||
if t.get("blocked_by"):
|
||||
blockers = ", ".join(f"#{b['id']} ({b['status']})" for b in t["blocked_by"])
|
||||
print(f" Blocked by: {blockers}")
|
||||
@@ -187,6 +195,11 @@ def cmd_status(conn, ticket_id, new_status):
|
||||
if new_status not in valid:
|
||||
out({"ok": False, "error": f"Invalid status '{new_status}'. Valid: {', '.join(valid)}"})
|
||||
return
|
||||
if new_status == 'in_progress':
|
||||
wip = query(conn, "SELECT COUNT(*) as count FROM tickets WHERE status = 'in_progress'")
|
||||
count = wip[0]["count"]
|
||||
if count >= WIP_LIMIT:
|
||||
print(f"WARNING: WIP limit ({WIP_LIMIT}) reached — {count} tickets already in_progress", file=sys.stderr)
|
||||
updated = execute(conn, "UPDATE tickets SET status=?, updated_at=datetime('now') WHERE id=?", (new_status, int(ticket_id)))
|
||||
out({"ok": True, "updated": updated, "id": int(ticket_id), "status": new_status})
|
||||
|
||||
@@ -201,25 +214,6 @@ def cmd_unassign(conn, ticket_id):
|
||||
out({"ok": True, "updated": updated, "id": int(ticket_id), "assigned_to": None})
|
||||
|
||||
|
||||
def cmd_sprint(conn, args):
|
||||
flags, positional = parse_flags(args, ["active"])
|
||||
if positional and positional[0] == "assign" and len(positional) >= 3:
|
||||
ticket_id, sprint_id = int(positional[1]), int(positional[2])
|
||||
updated = execute(conn, "UPDATE tickets SET sprint_id=?, updated_at=datetime('now') WHERE id=?", (sprint_id, ticket_id))
|
||||
out({"ok": True, "updated": updated, "id": ticket_id, "sprint_id": sprint_id})
|
||||
return
|
||||
conditions = []
|
||||
params = []
|
||||
if "active" in flags:
|
||||
conditions.append("s.status = 'active'")
|
||||
where = " AND ".join(conditions) if conditions else "1=1"
|
||||
sprints = query(conn, f"""SELECT s.*, COUNT(t.id) as ticket_count,
|
||||
SUM(CASE WHEN t.status='done' THEN 1 ELSE 0 END) as done_count
|
||||
FROM sprints s LEFT JOIN tickets t ON t.sprint_id = s.id
|
||||
WHERE {where} GROUP BY s.id ORDER BY s.id DESC""", tuple(params))
|
||||
out({"ok": True, "count": len(sprints), "sprints": sprints})
|
||||
|
||||
|
||||
def cmd_deps(conn, ticket_id):
|
||||
blockers = query(conn, """SELECT t.id, t.title, t.status, t.priority FROM ticket_deps d
|
||||
JOIN tickets t ON d.blocker_id = t.id
|
||||
@@ -230,6 +224,24 @@ def cmd_deps(conn, ticket_id):
|
||||
out({"ok": True, "id": int(ticket_id), "blocked_by": blockers, "blocks": blocks})
|
||||
|
||||
|
||||
def cmd_dep(conn, args):
|
||||
if len(args) < 3:
|
||||
out({"ok": False, "error": "Usage: ticket dep add|rm <blocker_id> <blocked_id>"})
|
||||
return
|
||||
action, blocker_id, blocked_id = args[0], int(args[1]), int(args[2])
|
||||
if action == "add":
|
||||
try:
|
||||
execute(conn, "INSERT INTO ticket_deps (blocker_id, blocked_id) VALUES (?, ?)", (blocker_id, blocked_id))
|
||||
out({"ok": True, "action": "added", "blocker_id": blocker_id, "blocked_id": blocked_id})
|
||||
except Exception as e:
|
||||
out({"ok": False, "error": str(e)})
|
||||
elif action == "rm":
|
||||
updated = execute(conn, "DELETE FROM ticket_deps WHERE blocker_id = ? AND blocked_id = ?", (blocker_id, blocked_id))
|
||||
out({"ok": True, "action": "removed", "updated": updated, "blocker_id": blocker_id, "blocked_id": blocked_id})
|
||||
else:
|
||||
out({"ok": False, "error": f"Unknown dep action: {action}. Use 'add' or 'rm'."})
|
||||
|
||||
|
||||
def cmd_search(conn, keyword):
|
||||
rows = query(conn, """SELECT id, type, title, status, priority, assigned_to, team
|
||||
FROM tickets WHERE title LIKE ? OR description LIKE ?
|
||||
@@ -295,6 +307,148 @@ def cmd_count(conn, args):
|
||||
out({"ok": True, "rows": rows})
|
||||
|
||||
|
||||
def cmd_wip(conn):
|
||||
rows = query(conn, """SELECT id, title, assigned_to, team FROM tickets
|
||||
WHERE status = 'in_progress' ORDER BY id""")
|
||||
count = len(rows)
|
||||
out({"ok": True, "in_progress": count, "limit": WIP_LIMIT,
|
||||
"over_limit": count > WIP_LIMIT, "tickets": rows})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Milestone commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_milestone(conn, args):
|
||||
if not args:
|
||||
out({"ok": False, "error": "Usage: ticket milestone list|create|link|unlink|complete|show|dep ..."})
|
||||
return
|
||||
|
||||
sub = args[0]
|
||||
rest = args[1:]
|
||||
|
||||
if sub == "list":
|
||||
cmd_milestone_list(conn, rest)
|
||||
elif sub == "create":
|
||||
cmd_milestone_create(conn, rest)
|
||||
elif sub == "link":
|
||||
cmd_milestone_link(conn, rest)
|
||||
elif sub == "unlink":
|
||||
cmd_milestone_unlink(conn, rest)
|
||||
elif sub == "complete":
|
||||
cmd_milestone_complete(conn, rest)
|
||||
elif sub == "show":
|
||||
cmd_milestone_show(conn, rest)
|
||||
elif sub == "dep":
|
||||
cmd_milestone_dep(conn, rest)
|
||||
else:
|
||||
out({"ok": False, "error": f"Unknown milestone subcommand: {sub}"})
|
||||
|
||||
|
||||
def cmd_milestone_list(conn, args):
|
||||
flags, _ = parse_flags(args, ["status"])
|
||||
conditions = []
|
||||
params = []
|
||||
if "status" in flags:
|
||||
conditions.append("m.status = ?")
|
||||
params.append(flags["status"])
|
||||
where = " AND ".join(conditions) if conditions else "1=1"
|
||||
rows = query(conn, f"""SELECT m.*,
|
||||
COUNT(DISTINCT tm.ticket_id) as ticket_count,
|
||||
SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) as done_count
|
||||
FROM milestones m
|
||||
LEFT JOIN ticket_milestones tm ON tm.milestone_id = m.id
|
||||
LEFT JOIN tickets t ON t.id = tm.ticket_id
|
||||
WHERE {where}
|
||||
GROUP BY m.id ORDER BY m.id""", tuple(params))
|
||||
out({"ok": True, "count": len(rows), "milestones": rows})
|
||||
|
||||
|
||||
def cmd_milestone_create(conn, args):
|
||||
flags, positional = parse_flags(args, ["description", "phase"])
|
||||
if not positional:
|
||||
out({"ok": False, "error": "Usage: ticket milestone create <name> [--description TEXT] [--phase N]"})
|
||||
return
|
||||
name = " ".join(positional)
|
||||
description = flags.get("description")
|
||||
phase = int(flags["phase"]) if "phase" in flags else None
|
||||
conn.execute(
|
||||
"INSERT INTO milestones (name, description, cascade_phase) VALUES (?, ?, ?)",
|
||||
(name, description, phase))
|
||||
conn.commit()
|
||||
last_id = query(conn, "SELECT last_insert_rowid() as id")[0]["id"]
|
||||
out({"ok": True, "id": last_id, "name": name})
|
||||
|
||||
|
||||
def cmd_milestone_link(conn, args):
|
||||
if len(args) < 2:
|
||||
out({"ok": False, "error": "Usage: ticket milestone link <ticket_id> <milestone_id>"})
|
||||
return
|
||||
ticket_id, milestone_id = int(args[0]), int(args[1])
|
||||
try:
|
||||
execute(conn, "INSERT INTO ticket_milestones (ticket_id, milestone_id) VALUES (?, ?)", (ticket_id, milestone_id))
|
||||
out({"ok": True, "action": "linked", "ticket_id": ticket_id, "milestone_id": milestone_id})
|
||||
except Exception as e:
|
||||
out({"ok": False, "error": str(e)})
|
||||
|
||||
|
||||
def cmd_milestone_unlink(conn, args):
|
||||
if len(args) < 2:
|
||||
out({"ok": False, "error": "Usage: ticket milestone unlink <ticket_id> <milestone_id>"})
|
||||
return
|
||||
ticket_id, milestone_id = int(args[0]), int(args[1])
|
||||
updated = execute(conn, "DELETE FROM ticket_milestones WHERE ticket_id = ? AND milestone_id = ?", (ticket_id, milestone_id))
|
||||
out({"ok": True, "action": "unlinked", "updated": updated, "ticket_id": ticket_id, "milestone_id": milestone_id})
|
||||
|
||||
|
||||
def cmd_milestone_complete(conn, args):
|
||||
if not args:
|
||||
out({"ok": False, "error": "Usage: ticket milestone complete <milestone_id>"})
|
||||
return
|
||||
milestone_id = int(args[0])
|
||||
updated = execute(conn, "UPDATE milestones SET status='completed', completed_at=datetime('now') WHERE id=?", (milestone_id,))
|
||||
out({"ok": True, "updated": updated, "id": milestone_id, "status": "completed"})
|
||||
|
||||
|
||||
def cmd_milestone_show(conn, args):
|
||||
if not args:
|
||||
out({"ok": False, "error": "Usage: ticket milestone show <milestone_id>"})
|
||||
return
|
||||
milestone_id = int(args[0])
|
||||
ms = query(conn, "SELECT * FROM milestones WHERE id = ?", (milestone_id,))
|
||||
if not ms:
|
||||
out({"ok": False, "error": f"Milestone #{milestone_id} not found"})
|
||||
return
|
||||
milestone = ms[0]
|
||||
tickets = query(conn, """SELECT t.id, t.type, t.title, t.status, t.priority, t.assigned_to
|
||||
FROM ticket_milestones tm JOIN tickets t ON t.id = tm.ticket_id
|
||||
WHERE tm.milestone_id = ?
|
||||
ORDER BY CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1
|
||||
WHEN 'medium' THEN 2 ELSE 3 END, t.id""", (milestone_id,))
|
||||
blockers = query(conn, """SELECT m.id, m.name, m.status FROM milestone_deps d
|
||||
JOIN milestones m ON d.blocker_id = m.id
|
||||
WHERE d.blocked_id = ?""", (milestone_id,))
|
||||
blocks = query(conn, """SELECT m.id, m.name, m.status FROM milestone_deps d
|
||||
JOIN milestones m ON d.blocked_id = m.id
|
||||
WHERE d.blocker_id = ?""", (milestone_id,))
|
||||
milestone["tickets"] = tickets
|
||||
milestone["blocked_by"] = blockers
|
||||
milestone["blocks"] = blocks
|
||||
out({"ok": True, "milestone": milestone})
|
||||
|
||||
|
||||
def cmd_milestone_dep(conn, args):
|
||||
if len(args) < 2:
|
||||
out({"ok": False, "error": "Usage: ticket milestone dep <blocker_id> <blocked_id>"})
|
||||
return
|
||||
blocker_id, blocked_id = int(args[0]), int(args[1])
|
||||
try:
|
||||
execute(conn, "INSERT INTO milestone_deps (blocker_id, blocked_id) VALUES (?, ?)", (blocker_id, blocked_id))
|
||||
out({"ok": True, "action": "added", "blocker_id": blocker_id, "blocked_id": blocked_id})
|
||||
except Exception as e:
|
||||
out({"ok": False, "error": str(e)})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -302,21 +456,29 @@ def cmd_count(conn, args):
|
||||
HELP = """ticket — project ticket CLI
|
||||
|
||||
Usage:
|
||||
ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T]
|
||||
ticket list [--status S] [--priority P] [--epic N] [--milestone N] [--assigned A] [--team T]
|
||||
ticket show [--brief] <id> [<id>...] Full ticket detail (--brief for summary)
|
||||
ticket done <id> [<id> ...] Mark tickets as done
|
||||
ticket status <id> <new_status> Change ticket status
|
||||
ticket assign <id> <agent> Assign ticket to agent/branch
|
||||
ticket assign <id> <agent> Assign ticket to agent
|
||||
ticket unassign <id> Remove assignment
|
||||
ticket team <id> <teams> Set team(s) (comma-separated, e.g. server,client)
|
||||
ticket sprint [--active] List sprints
|
||||
ticket sprint assign <id> <sprint> Assign ticket to sprint
|
||||
ticket team <id> <teams> Set team(s) (comma-separated)
|
||||
ticket deps <id> Show ticket dependencies
|
||||
ticket dep add <blocker> <blocked> Add ticket dependency
|
||||
ticket dep rm <blocker> <blocked> Remove ticket dependency
|
||||
ticket search <keyword> Search tickets by title/description
|
||||
ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]
|
||||
ticket epics [--status S] List epics with child counts
|
||||
ticket children <id> List children of a ticket
|
||||
ticket count [--status S] Count tickets by status"""
|
||||
ticket count [--status S] Count tickets by status
|
||||
ticket wip Show in-progress count vs WIP limit
|
||||
ticket milestone list [--status S] List milestones
|
||||
ticket milestone create <name> [--description TEXT] [--phase N] Create milestone
|
||||
ticket milestone link <ticket> <milestone> Link ticket to milestone
|
||||
ticket milestone unlink <ticket> <milestone> Unlink ticket from milestone
|
||||
ticket milestone complete <milestone> Mark milestone completed
|
||||
ticket milestone show <milestone> Show milestone with tickets
|
||||
ticket milestone dep <blocker> <blocked> Add milestone dependency"""
|
||||
|
||||
|
||||
def main():
|
||||
@@ -369,13 +531,13 @@ def main():
|
||||
out({"ok": False, "error": "Usage: ticket team <id> <teams>"})
|
||||
else:
|
||||
cmd_team(conn, args[0], args[1])
|
||||
elif cmd == "sprint":
|
||||
cmd_sprint(conn, args)
|
||||
elif cmd == "deps":
|
||||
if not args:
|
||||
out({"ok": False, "error": "Usage: ticket deps <id>"})
|
||||
else:
|
||||
cmd_deps(conn, args[0])
|
||||
elif cmd == "dep":
|
||||
cmd_dep(conn, args)
|
||||
elif cmd == "search":
|
||||
if not args:
|
||||
out({"ok": False, "error": "Usage: ticket search <keyword>"})
|
||||
@@ -392,6 +554,10 @@ def main():
|
||||
cmd_children(conn, args[0])
|
||||
elif cmd == "count":
|
||||
cmd_count(conn, args)
|
||||
elif cmd == "wip":
|
||||
cmd_wip(conn)
|
||||
elif cmd == "milestone":
|
||||
cmd_milestone(conn, args)
|
||||
else:
|
||||
out({"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."})
|
||||
finally:
|
||||
|
||||
Reference in New Issue
Block a user