Modules: triage, health_scanner, feedback_loop, memory_hygiene,
roadmap, validate_output, enhanced_search, auto_handoff
+ composite_scorer, intent_classifier
CLI: 'cortex <module> <command>' unified entry point
Tests: 157/169 passing (12 assertion mismatches from rename)
Docker: python:3.11-slim based
338 lines
10 KiB
Python
338 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Roadmap Manager — track tasks, deadlines, dependencies, generate reports.
|
|
|
|
Usage:
|
|
python3 roadmap.py list [--status open] [--priority P0,P1] [--tag security]
|
|
python3 roadmap.py add "title" --priority P1 --deadline 2026-02-19 --tag infra
|
|
python3 roadmap.py update <id> --status done
|
|
python3 roadmap.py overdue
|
|
python3 roadmap.py upcoming [--days 7]
|
|
python3 roadmap.py report
|
|
python3 roadmap.py check-deps
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROADMAP_FILE = Path.home() / "clawd" / "memory" / "roadmap.json"
|
|
VALID_STATUSES = {"open", "in_progress", "blocked", "done"}
|
|
VALID_PRIORITIES = {"P0", "P1", "P2", "P3"}
|
|
|
|
|
|
def load_roadmap(path: Path | None = None) -> dict:
|
|
p = path or ROADMAP_FILE
|
|
if p.exists():
|
|
return json.loads(p.read_text())
|
|
return {"items": []}
|
|
|
|
|
|
def save_roadmap(data: dict, path: Path | None = None):
|
|
p = path or ROADMAP_FILE
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
|
|
|
|
def find_item(data: dict, item_id: str) -> dict | None:
|
|
for item in data["items"]:
|
|
if item["id"] == item_id or item["id"].startswith(item_id):
|
|
return item
|
|
return None
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.now().isoformat(timespec="seconds")
|
|
|
|
|
|
def parse_date(s: str | None) -> datetime | None:
|
|
if not s:
|
|
return None
|
|
for fmt in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"]:
|
|
try:
|
|
return datetime.strptime(s, fmt)
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
# --- Commands ---
|
|
|
|
def cmd_list(args, data: dict):
|
|
items = data["items"]
|
|
if args.status:
|
|
statuses = set(args.status.split(","))
|
|
items = [i for i in items if i["status"] in statuses]
|
|
if args.priority:
|
|
priorities = set(args.priority.split(","))
|
|
items = [i for i in items if i["priority"] in priorities]
|
|
if args.tag:
|
|
tags = set(args.tag.split(","))
|
|
items = [i for i in items if tags & set(i.get("tags", []))]
|
|
|
|
if not items:
|
|
print("No items found.")
|
|
return
|
|
|
|
# Sort by priority then deadline
|
|
prio_order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
|
|
items.sort(key=lambda x: (prio_order.get(x["priority"], 9),
|
|
x.get("deadline") or "9999"))
|
|
|
|
for i in items:
|
|
deadline = i.get("deadline", "")
|
|
dl_str = f" ⏰ {deadline}" if deadline else ""
|
|
status_icon = {"open": "⬚", "in_progress": "🔄", "blocked": "🚫", "done": "✅"
|
|
}.get(i["status"], "?")
|
|
tags = " ".join(f"#{t}" for t in i.get("tags", []))
|
|
print(f" {status_icon} [{i['priority']}] {i['title']}{dl_str} {tags}")
|
|
print(f" id: {i['id'][:8]}")
|
|
|
|
|
|
def cmd_add(args, data: dict):
|
|
item = {
|
|
"id": str(uuid.uuid4()),
|
|
"title": args.title,
|
|
"status": "open",
|
|
"priority": args.priority or "P2",
|
|
"deadline": args.deadline,
|
|
"depends_on": [],
|
|
"tags": args.tag.split(",") if args.tag else [],
|
|
"created": now_iso(),
|
|
"updated": now_iso(),
|
|
"notes": args.notes or "",
|
|
}
|
|
if item["priority"] not in VALID_PRIORITIES:
|
|
print(f"Invalid priority: {item['priority']}")
|
|
sys.exit(1)
|
|
data["items"].append(item)
|
|
save_roadmap(data)
|
|
print(f"Added: {item['title']} (id: {item['id'][:8]})")
|
|
|
|
|
|
def cmd_update(args, data: dict):
|
|
item = find_item(data, args.id)
|
|
if not item:
|
|
print(f"Item not found: {args.id}")
|
|
sys.exit(1)
|
|
if args.status:
|
|
if args.status not in VALID_STATUSES:
|
|
print(f"Invalid status: {args.status}")
|
|
sys.exit(1)
|
|
item["status"] = args.status
|
|
if args.priority:
|
|
item["priority"] = args.priority
|
|
if args.deadline:
|
|
item["deadline"] = args.deadline
|
|
if args.notes:
|
|
item["notes"] = args.notes
|
|
if args.tag:
|
|
item["tags"] = args.tag.split(",")
|
|
item["updated"] = now_iso()
|
|
save_roadmap(data)
|
|
print(f"Updated: {item['title']}")
|
|
|
|
|
|
def cmd_overdue(args, data: dict):
|
|
now = datetime.now()
|
|
overdue = []
|
|
for item in data["items"]:
|
|
if item["status"] == "done":
|
|
continue
|
|
dl = parse_date(item.get("deadline"))
|
|
if dl and dl < now:
|
|
overdue.append(item)
|
|
|
|
if not overdue:
|
|
print("No overdue items. ✅")
|
|
return
|
|
|
|
prio_order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
|
|
overdue.sort(key=lambda x: prio_order.get(x["priority"], 9))
|
|
|
|
has_critical = any(i["priority"] in ("P0", "P1") for i in overdue)
|
|
print(f"🚨 {len(overdue)} overdue item(s):")
|
|
for i in overdue:
|
|
print(f" [{i['priority']}] {i['title']} — due {i['deadline']}")
|
|
|
|
if has_critical:
|
|
sys.exit(1)
|
|
|
|
|
|
def cmd_upcoming(args, data: dict):
|
|
now = datetime.now()
|
|
days = args.days or 7
|
|
cutoff = now + timedelta(days=days)
|
|
upcoming = []
|
|
for item in data["items"]:
|
|
if item["status"] == "done":
|
|
continue
|
|
dl = parse_date(item.get("deadline"))
|
|
if dl and now <= dl <= cutoff:
|
|
upcoming.append(item)
|
|
|
|
if not upcoming:
|
|
print(f"No items due in the next {days} days.")
|
|
return
|
|
|
|
upcoming.sort(key=lambda x: x.get("deadline", ""))
|
|
print(f"📅 {len(upcoming)} item(s) due in the next {days} days:")
|
|
for i in upcoming:
|
|
print(f" [{i['priority']}] {i['title']} — due {i['deadline']}")
|
|
|
|
|
|
def cmd_report(args, data: dict):
|
|
now = datetime.now()
|
|
lines = [
|
|
f"# Roadmap Status Report",
|
|
f"*Generated: {now.strftime('%Y-%m-%d %H:%M')}*",
|
|
"",
|
|
]
|
|
|
|
# Summary
|
|
by_status = {}
|
|
for item in data["items"]:
|
|
by_status.setdefault(item["status"], []).append(item)
|
|
|
|
lines.append("## Summary")
|
|
for status in ["open", "in_progress", "blocked", "done"]:
|
|
count = len(by_status.get(status, []))
|
|
icon = {"open": "⬚", "in_progress": "🔄", "blocked": "🚫", "done": "✅"}[status]
|
|
lines.append(f"- {icon} {status}: {count}")
|
|
lines.append("")
|
|
|
|
# Overdue
|
|
overdue = [i for i in data["items"] if i["status"] != "done"
|
|
and parse_date(i.get("deadline")) and parse_date(i["deadline"]) < now]
|
|
if overdue:
|
|
lines.append("## 🚨 Overdue")
|
|
for i in overdue:
|
|
lines.append(f"- **[{i['priority']}]** {i['title']} — due {i['deadline']}")
|
|
lines.append("")
|
|
|
|
# Upcoming (7 days)
|
|
cutoff = now + timedelta(days=7)
|
|
upcoming = [i for i in data["items"] if i["status"] != "done"
|
|
and parse_date(i.get("deadline"))
|
|
and now <= parse_date(i["deadline"]) <= cutoff]
|
|
if upcoming:
|
|
lines.append("## 📅 Upcoming (7 days)")
|
|
for i in sorted(upcoming, key=lambda x: x["deadline"]):
|
|
lines.append(f"- **[{i['priority']}]** {i['title']} — due {i['deadline']}")
|
|
lines.append("")
|
|
|
|
# Stale items (in_progress > 7 days without update)
|
|
stale = []
|
|
for item in data["items"]:
|
|
if item["status"] == "in_progress":
|
|
updated = parse_date(item.get("updated"))
|
|
if updated and (now - updated).days > 7:
|
|
stale.append(item)
|
|
if stale:
|
|
lines.append("## ⏳ Stale (in_progress >7 days)")
|
|
for i in stale:
|
|
lines.append(f"- **[{i['priority']}]** {i['title']} — last updated {i['updated']}")
|
|
lines.append("")
|
|
|
|
# Active work
|
|
active = by_status.get("in_progress", [])
|
|
if active:
|
|
lines.append("## 🔄 In Progress")
|
|
for i in active:
|
|
dl = f" (due {i['deadline']})" if i.get("deadline") else ""
|
|
lines.append(f"- **[{i['priority']}]** {i['title']}{dl}")
|
|
lines.append("")
|
|
|
|
print("\n".join(lines))
|
|
|
|
|
|
def cmd_check_deps(args, data: dict):
|
|
id_status = {i["id"]: i for i in data["items"]}
|
|
issues = []
|
|
for item in data["items"]:
|
|
if item["status"] == "done":
|
|
continue
|
|
for dep_id in item.get("depends_on", []):
|
|
dep = id_status.get(dep_id)
|
|
if not dep:
|
|
issues.append((item, dep_id, "missing"))
|
|
elif dep["status"] != "done":
|
|
issues.append((item, dep_id, dep["status"]))
|
|
|
|
if not issues:
|
|
print("No dependency issues. ✅")
|
|
return
|
|
|
|
print(f"⚠️ {len(issues)} dependency issue(s):")
|
|
for item, dep_id, status in issues:
|
|
if status == "missing":
|
|
print(f" [{item['priority']}] {item['title']} → depends on unknown {dep_id[:8]}")
|
|
else:
|
|
dep = id_status[dep_id]
|
|
print(f" [{item['priority']}] {item['title']} → blocked by '{dep['title']}' ({status})")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Roadmap Manager")
|
|
sub = parser.add_subparsers(dest="command")
|
|
|
|
# list
|
|
p_list = sub.add_parser("list")
|
|
p_list.add_argument("--status", type=str)
|
|
p_list.add_argument("--priority", type=str)
|
|
p_list.add_argument("--tag", type=str)
|
|
|
|
# add
|
|
p_add = sub.add_parser("add")
|
|
p_add.add_argument("title", type=str)
|
|
p_add.add_argument("--priority", type=str, default="P2")
|
|
p_add.add_argument("--deadline", type=str)
|
|
p_add.add_argument("--tag", type=str)
|
|
p_add.add_argument("--notes", type=str)
|
|
|
|
# update
|
|
p_upd = sub.add_parser("update")
|
|
p_upd.add_argument("id", type=str)
|
|
p_upd.add_argument("--status", type=str)
|
|
p_upd.add_argument("--priority", type=str)
|
|
p_upd.add_argument("--deadline", type=str)
|
|
p_upd.add_argument("--tag", type=str)
|
|
p_upd.add_argument("--notes", type=str)
|
|
|
|
# overdue
|
|
sub.add_parser("overdue")
|
|
|
|
# upcoming
|
|
p_up = sub.add_parser("upcoming")
|
|
p_up.add_argument("--days", type=int, default=7)
|
|
|
|
# report
|
|
sub.add_parser("report")
|
|
|
|
# check-deps
|
|
sub.add_parser("check-deps")
|
|
|
|
args = parser.parse_args()
|
|
if not args.command:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
data = load_roadmap()
|
|
|
|
cmd_map = {
|
|
"list": cmd_list,
|
|
"add": cmd_add,
|
|
"update": cmd_update,
|
|
"overdue": cmd_overdue,
|
|
"upcoming": cmd_upcoming,
|
|
"report": cmd_report,
|
|
"check-deps": cmd_check_deps,
|
|
}
|
|
cmd_map[args.command](args, data)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|