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:
2026-05-03 20:11:00 +02:00
co-authored by Claude Opus 4.6
parent 287f9ba3a6
commit e6a557e8e7
48 changed files with 1164 additions and 2158 deletions
+150
View File
@@ -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())
-743
View File
@@ -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
View File
@@ -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: