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:
+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