Repository navigation
Expand file tree
/
Copy pathgen-roadmap.py
More file actions
executable file
·1362 lines (1182 loc) · 56.9 KB
/
Copy pathgen-roadmap.py
File metadata and controls
executable file
·1362 lines (1182 loc) · 56.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Generate ROADMAP.md from roadmap.yaml (+ live specs/<NNN>/tasks.md badges).
This is the renderer for the git-native roadmap prototype. It reads the
hand-maintained DAG in roadmap.yaml, recomputes per-spec progress by counting
checkboxes in each specs/<NNN>/tasks.md, and writes a single ROADMAP.md
containing:
1. A generated-file banner + schema/regenerate instructions.
2. "Roadmap at a glance": a small Mermaid `graph LR` of the CROSS-EPIC DAG
(only epics that have an edge; the dependency-free ones are listed as text
beneath it) plus a per-epic `<details>` block holding that epic's own task
graph and task table. One 70-node graph was illegible on GitHub; splitting
it by zoom level is what makes the roadmap browsable.
3. A status table (epic, status, priority, progress, spec/PR links).
4. A compact "Shipped" table of epics swept into roadmap.archive.yaml.
5. An aggregate per-spec progress table (recomputed from tasks.md).
Design choice: we write the aggregate spec table into ROADMAP.md rather than
overwriting the hand-maintained specs/README.md, so the existing curated index
(with its prose, runbooks and design-doc links) is never clobbered. ROADMAP.md
is fully generated and safe to overwrite on every run.
Archive: roadmap.yaml is the WORKING SET (todo/in_progress/in_review/blocked/
parked). Cold `done` epics are swept into roadmap.archive.yaml by `--archive`
so the active file does not grow without bound. Provenance (PR refs, notes) is
preserved verbatim: the sweep moves the raw YAML text block, it does not
re-serialise it. A `depends_on:` edge pointing at an archived id is satisfied
by definition (archived == done), so the generator resolves ids against BOTH
files and only ERRORs on ids that exist in neither.
Usage:
python3 scripts/gen-roadmap.py [--check | --check-github [--strict]]
python3 scripts/gen-roadmap.py --archive [--min-age-days N] [--dry-run]
--check Exit non-zero if ROADMAP.md is out of date (does not write).
Useful as a CI canary.
--check-github Cross-check roadmap.yaml against ground truth (does not write):
live GitHub PR state (via `gh`), spec: links resolving to
real specs/ dirs, depends_on ids resolving to a known epic or
task, and status sanity. Reports ERROR/WARN and exits 1 on any
ERROR, 0 otherwise; 2 if `gh` is unavailable.
--strict With --check-github, promote warnings to errors for the exit
code (report is unchanged).
--archive Sweep cold `done` epics out of roadmap.yaml into
roadmap.archive.yaml, then regenerate ROADMAP.md. An epic is
cold when it is `done`, every child task is `done`, every PR
it references is MERGED, and the NEWEST of those merges is at
least --min-age-days old (default 14 — "shipped and two weeks
cold"). Epics carrying `keep: true` are never swept; epics
with no PR refs cannot be dated and are skipped. Needs an
authenticated `gh` unless --force-undated.
--min-age-days Cool-off window for --archive (default 14).
--dry-run With --archive, print what would move and change nothing.
--force-undated With --archive, sweep without `gh`. PR merge state is NOT
verified; shipped_on is stamped "unknown". --min-age-days 0
waives the cool-off, never the merge check.
Pure stdlib + PyYAML (already used by scripts/check-settings-parity.py).
Idempotent: running twice with no source change produces identical output.
"""
from __future__ import annotations
import argparse
import datetime as _dt
import html
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
try:
import yaml
except ImportError: # pragma: no cover
sys.stderr.write("error: PyYAML required (pip install pyyaml)\n")
sys.exit(2)
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ROADMAP_YAML = os.path.join(REPO_ROOT, "roadmap.yaml")
ROADMAP_ARCHIVE_YAML = os.path.join(REPO_ROOT, "roadmap.archive.yaml")
ROADMAP_MD = os.path.join(REPO_ROOT, "ROADMAP.md")
SPECS_DIR = os.path.join(REPO_ROOT, "specs")
# Default cool-off before a done epic is swept into the archive.
DEFAULT_MIN_AGE_DAYS = 14
ARCHIVE_HEADER = """\
# roadmap.archive.yaml — cold storage for SHIPPED epics.
#
# Swept out of roadmap.yaml by `python3 scripts/gen-roadmap.py --archive` once an
# epic is `done`, all its PRs are merged, and the newest merge has cooled off
# (default 14 days). roadmap.yaml stays the working set; this file keeps the
# provenance: PR refs, gotcha notes, and the DAG ids other epics depend on.
#
# Schema is identical to roadmap.yaml, plus two generator-stamped fields:
# shipped_on: date of the NEWEST merged PR in the epic subtree
# archived_on: date the sweep moved it here
#
# A `depends_on:` edge pointing at an id in this file is satisfied by definition.
# Nothing here is re-serialised: blocks are moved as raw text, so comments and
# formatting survive. Append-only in practice; edit by hand only to fix a typo.
"""
# GitHub repo `gh` queries target for --check-github.
REPO_SLUG = "smart-mcp-proxy/mcpproxy-go"
# A PR ref inside a pr: field, either "#786" or ".../pull/786".
PR_NUM_RE = re.compile(r"(?:#|/pull/)(\d+)")
# A checkbox line: "- [ ] ...", "- [x] ...", "- [X] ..." (matches specs/README.md).
CHECKBOX_RE = re.compile(r"^- \[([ xX])\]")
STATUS_ORDER = ["in_progress", "in_review", "blocked", "todo", "done"]
STATUS_LABEL = {
"todo": "Todo",
"in_progress": "In progress",
"in_review": "In review",
"blocked": "Blocked",
"done": "Done",
}
# Mermaid classDef colours keyed by status.
STATUS_CLASSDEF = {
"done": "fill:#1f7a1f,stroke:#0d3d0d,color:#ffffff",
"in_progress": "fill:#1f6feb,stroke:#0b3d91,color:#ffffff",
"in_review": "fill:#9a6700,stroke:#5c3d00,color:#ffffff",
"blocked": "fill:#a40e26,stroke:#5c0712,color:#ffffff",
"todo": "fill:#6e7781,stroke:#3d4248,color:#ffffff",
"parked": "fill:#30363d,stroke:#161b22,color:#9da7b3,stroke-dasharray:4 3",
}
# ── spec checkbox accounting ────────────────────────────────────────────────
def count_checkboxes(tasks_md: str) -> tuple[int, int]:
"""Return (checked, total) from a tasks.md path. (0, 0) if absent."""
if not os.path.isfile(tasks_md):
return (0, 0)
checked = total = 0
with open(tasks_md, encoding="utf-8") as fh:
for line in fh:
m = CHECKBOX_RE.match(line)
if not m:
continue
total += 1
if m.group(1) in ("x", "X"):
checked += 1
return (checked, total)
def spec_badge(checked: int, total: int) -> tuple[str, str]:
"""Map counts to (status_word, progress_str) using the specs/README legend."""
if total == 0:
return ("—", "—")
pct = round(100 * checked / total)
if pct >= 95:
word = "shipped"
elif pct >= 1:
word = "in-flight"
else:
word = "drafted"
return (word, f"{checked}/{total} ({pct}%)")
def spec_progress(spec_path: str | None) -> tuple[str, str]:
"""Resolve a roadmap spec: link to a (status, progress) badge pair."""
if not spec_path:
return ("", "")
tasks_md = os.path.join(REPO_ROOT, spec_path, "tasks.md")
return spec_badge(*count_checkboxes(tasks_md))
# ── node id sanitising for Mermaid ──────────────────────────────────────────
def node_id(raw: str) -> str:
"""Mermaid node ids must be alnum/underscore."""
return re.sub(r"[^0-9A-Za-z]", "_", raw)
def status_of(item: dict) -> str:
"""Effective status; parked todos render as a distinct 'parked' class."""
st = item.get("status", "todo")
if st == "todo" and item.get("parked"):
return "parked"
return st
# ── rendering ───────────────────────────────────────────────────────────────
def fmt_pr(pr) -> str:
if not pr:
return ""
if isinstance(pr, list):
return " ".join(str(p) for p in pr)
return str(pr)
def all_ids(epics: list[dict]) -> set[str]:
"""Every epic id and task id declared in a roadmap document."""
ids: set[str] = set()
for epic in epics:
ids.add(epic["id"])
for t in epic.get("tasks") or []:
ids.add(t["id"])
return ids
def id_list(epics: list[dict]) -> list[str]:
"""Every epic and task id, in file order, WITH duplicates preserved."""
out: list[str] = []
for epic in epics:
out.append(epic["id"])
for t in epic.get("tasks") or []:
out.append(t["id"])
return out
def duplicate_ids(epics: list[dict]) -> list[str]:
"""Ids declared more than once. The schema says ids are unique across epics
AND tasks; YAML happily accepts duplicates because these are list items,
not mapping keys, so nothing else enforces it.
A duplicate epic id is a data-loss hazard for --archive: the sweep keys raw
text blocks by id, so one block silently overwrites the other while both
line ranges are cut from the source.
"""
seen: set[str] = set()
dupes: list[str] = []
for i in id_list(epics):
if i in seen and i not in dupes:
dupes.append(i)
seen.add(i)
return dupes
def node_id_collisions(epics: list[dict]) -> list[tuple[str, str]]:
"""Distinct ids that sanitise to the same Mermaid node id (`a-b` and `a_b`
both become `a_b`), which would merge two nodes and their edges."""
by_node: dict[str, list[str]] = {}
for i in sorted(all_ids(epics)):
by_node.setdefault(node_id(i), []).append(i)
return [(v[0], v[1]) for v in by_node.values() if len(v) > 1]
def find_cycles(epics: list[dict]) -> list[list[str]]:
"""Every dependency cycle, self-loops included. depends_on is supposed to
describe a DAG; existence checks alone let `a -> b -> a` through and the
rendered graph then claims a prerequisite chain that can never start."""
adj: dict[str, list[str]] = {}
for item in id_list(epics):
adj.setdefault(item, [])
for epic in epics:
for dep in epic.get("depends_on") or []:
adj.setdefault(dep, []).append(epic["id"])
for t in epic.get("tasks") or []:
for dep in t.get("depends_on") or []:
adj.setdefault(dep, []).append(t["id"])
cycles: list[list[str]] = []
WHITE, GREY, BLACK = 0, 1, 2
colour = {n: WHITE for n in adj}
def walk(node: str, stack: list[str]) -> None:
colour[node] = GREY
stack.append(node)
for nxt in adj.get(node, []):
if colour.get(nxt, WHITE) == GREY: # back edge → cycle
cyc = stack[stack.index(nxt):] + [nxt]
if cyc not in cycles:
cycles.append(cyc)
elif colour.get(nxt, WHITE) == WHITE:
walk(nxt, stack)
stack.pop()
colour[node] = BLACK
for n in list(adj):
if colour.get(n, WHITE) == WHITE:
walk(n, [])
return cycles
# ── DAG rendering ────────────────────────────────────────────────────────────
# The roadmap has two zoom levels — ~24 epics and ~70 tasks — and cramming both
# into one Mermaid `graph TD` renders as an illegible postage stamp on GitHub.
# We split them: a small EPIC-LEVEL overview graph (legible at default zoom),
# then per-epic <details> blocks (GitHub renders Mermaid + tables inside them)
# each carrying that one epic's task DAG and links. A reader expands only what
# they care about, so the page stays short.
# Status glyphs for the collapsed <summary> lines and per-task table cells,
# mirroring the STATUS_CLASSDEF fills (green/blue/amber/red/grey/dark).
STATUS_EMOJI = {
"done": "🟢",
"in_progress": "🔵",
"in_review": "🟡",
"blocked": "🔴",
"todo": "⚪",
"parked": "⚫",
}
def truncate(text: str, limit: int) -> str:
"""Collapse whitespace and clip to `limit` chars with an ellipsis."""
text = " ".join(text.split())
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
def node_box(label: str) -> str:
"""A default-rectangle Mermaid node body: `["label"]`. Quotes and brackets
are neutralised — either terminates the node body early and corrupts the
graph — so parens/slashes/em dashes survive."""
safe = label.replace('"', "'").replace("[", "(").replace("]", ")")
return f'["{safe}"]'
def md_cell(text: str) -> str:
"""Escape a value bound for a Markdown table cell: a raw `|` opens a new
column and a newline ends the row."""
return " ".join(str(text).split()).replace("|", "\\|")
def html_text(text: str) -> str:
"""Escape a value interpolated into raw HTML (a <summary>): a title holding
`</summary>` would otherwise break the collapsible block open."""
return html.escape(str(text), quote=False)
def epic_owner_map(epics: list[dict]) -> dict[str, str]:
"""Map every node id (epic id or task id) to the id of its owning epic."""
owner: dict[str, str] = {}
for epic in epics:
owner[epic["id"]] = epic["id"]
for t in epic.get("tasks") or []:
owner[t["id"]] = epic["id"]
return owner
def iter_edges(epics: list[dict]):
"""Yield every (prerequisite_id, dependent_id) edge in the whole DAG."""
for epic in epics:
for dep in epic.get("depends_on") or []:
yield (dep, epic["id"])
for t in epic.get("tasks") or []:
for dep in t.get("depends_on") or []:
yield (dep, t["id"])
def _emit_classes(lines: list[str], classed: dict[str, list[str]]) -> None:
"""Append Mermaid classDef + class-assignment lines for used statuses only."""
for status, ids in classed.items():
if ids:
lines.append(f" classDef {status} {STATUS_CLASSDEF[status]};")
for status, ids in classed.items():
if ids:
lines.append(f" class {','.join(ids)} {status};")
def overview_edges(epics: list[dict]) -> list[tuple[str, str]]:
"""Distinct epic→epic edges, collapsing every cross-epic task edge onto the
epics that own its endpoints. Edges whose endpoint resolves to an archived
or absent id are dropped — an edge into the archive is satisfied by
definition, and drawing it would conjure a phantom node."""
owner = epic_owner_map(epics)
seen: list[tuple[str, str]] = []
for dep, dependent in iter_edges(epics):
src, dst = owner.get(dep), owner.get(dependent)
if not src or not dst or src == dst or (src, dst) in seen:
continue
seen.append((src, dst))
return seen
def render_overview_mermaid(epics: list[dict]) -> tuple[str, list[dict]]:
"""Epic-level overview: one node per *connected* epic, epic→epic edges only.
Returns (mermaid_block, standalone_epics). Epics with no cross-epic edge are
NOT drawn: with 24 epics and only ~6 edges, including them turned the graph
into a 2000px column of disconnected boxes — a list pretending to be a graph.
They are listed as text beside it instead, which is what a reader wants and
what keeps the actual DAG legible at default zoom.
"""
edges = overview_edges(epics)
connected = {e for pair in edges for e in pair}
drawn = [e for e in epics if e["id"] in connected]
lines = ["```mermaid", "graph LR"]
classed: dict[str, list[str]] = {k: [] for k in STATUS_CLASSDEF}
for epic in drawn:
eid = node_id(epic["id"])
classed[status_of(epic)].append(eid)
lines.append(f" {eid}{node_box(truncate(epic['title'], 40))}")
lines.append("")
for src, dst in edges:
lines.append(f" {node_id(src)} --> {node_id(dst)}")
lines.append("")
_emit_classes(lines, classed)
lines.append("```")
return "\n".join(lines), [e for e in epics if e["id"] not in connected]
def render_standalone_epics(epics: list[dict]) -> str:
"""One line per dependency-free epic, grouped by status — the epics the
overview graph omits because they have no edges to show."""
if not epics:
return ""
order = {s: i for i, s in enumerate(STATUS_ORDER)}
rows = []
for epic in sorted(epics, key=lambda e: (order.get(e.get("status", "todo"), 99),
e.get("priority", "P9"))):
st = status_of(epic)
prio = epic.get("priority")
meta = " · ".join(x for x in (STATUS_LABEL.get(epic.get("status", "todo")), prio,
"parked" if epic.get("parked") else None) if x)
rows.append(f"- {STATUS_EMOJI[st]} **{md_cell(epic['title'])}** — {meta}")
return "\n".join(rows)
def render_epic_mini_mermaid(epic: dict) -> str:
"""Per-epic task DAG: the epic's tasks + their INTERNAL edges only. Empty
string when the epic has no tasks (nothing to draw)."""
tasks = epic.get("tasks") or []
if not tasks:
return ""
task_ids = {t["id"] for t in tasks}
lines = ["```mermaid", "graph LR"]
classed: dict[str, list[str]] = {k: [] for k in STATUS_CLASSDEF}
for t in tasks:
tid = node_id(t["id"])
classed[status_of(t)].append(tid)
label = truncate(t["title"], 46)
if t.get("mcp"):
label = f"{label}<br/>{t['mcp']}"
lines.append(f" {tid}{node_box(label)}")
lines.append("")
for t in tasks:
for dep in t.get("depends_on") or []:
if dep in task_ids: # internal edges only; cross-epic edges live in the overview
lines.append(f" {node_id(dep)} --> {node_id(t['id'])}")
lines.append("")
_emit_classes(lines, classed)
lines.append("```")
return "\n".join(lines)
def render_epic_task_table(epic: dict) -> str:
"""Compact per-epic task table (status + tracker/PR links). Empty string
when the epic has no tasks."""
tasks = epic.get("tasks") or []
if not tasks:
return ""
rows = ["| Task | Status | Refs |", "| --- | --- | --- |"]
for t in tasks:
st = t.get("status", "todo")
cell = f"{STATUS_EMOJI.get(status_of(t), '')} {STATUS_LABEL.get(st, st)}"
refs = []
if t.get("mcp"):
refs.append(f"`{t['mcp']}`")
pr = fmt_pr(t.get("pr"))
if pr:
refs.append(pr)
rows.append(f"| {md_cell(t['title'])} | {cell} | {' '.join(refs) or '—'} |")
return "\n".join(rows)
def render_epic_details(epics: list[dict]) -> str:
"""One collapsible <details> per epic (active status first), each holding
the epic note, spec/PR links, its task DAG and a task table."""
order = {s: i for i, s in enumerate(STATUS_ORDER)}
def sort_key(e):
return (order.get(e.get("status", "todo"), 99),
1 if e.get("parked") else 0,
e.get("priority", "P9"))
out: list[str] = []
for epic in sorted(epics, key=sort_key):
st = epic.get("status", "todo")
label = STATUS_LABEL.get(st, st)
if epic.get("parked"):
label += " · parked"
head = [label]
if epic.get("priority"):
head.append(epic["priority"])
if epic.get("mcp"):
head.append(epic["mcp"])
summary = (f"{STATUS_EMOJI.get(status_of(epic), '')} {html_text(epic['title'])}"
f" — {html_text(' · '.join(head))}")
out.append("<details>")
out.append(f"<summary>{summary}</summary>")
out.append("") # blank line: GitHub needs it to render markdown inside <details>
if epic.get("note"):
out.append(f"> {md_cell(epic['note'])}")
out.append("")
links = []
if epic.get("spec"):
spec = epic["spec"]
links.append(f"Spec: [{os.path.basename(spec)}](./{spec}/)")
pr = fmt_pr(epic.get("pr"))
if pr:
links.append(f"PR: {pr}")
if links:
out.append(" · ".join(links))
out.append("")
mini = render_epic_mini_mermaid(epic)
if mini:
out.append(mini)
out.append("")
table = render_epic_task_table(epic)
if table:
out.append(table)
out.append("")
out.append("</details>")
out.append("")
return "\n".join(out).rstrip("\n")
def render_status_table(epics: list[dict]) -> str:
rows = ["| Epic | Status | Priority | Progress | Spec | PR |",
"| --- | --- | --- | --- | --- | --- |"]
order = {s: i for i, s in enumerate(STATUS_ORDER)}
def sort_key(e):
return (order.get(e.get("status", "todo"), 99),
1 if e.get("parked") else 0,
e.get("priority", "P9"))
for epic in sorted(epics, key=sort_key):
st = STATUS_LABEL.get(epic.get("status", "todo"), epic.get("status", ""))
if epic.get("parked"):
st += " (parked)"
_, progress = spec_progress(epic.get("spec"))
spec = epic.get("spec")
spec_cell = f"[{os.path.basename(spec)}](./{spec}/)" if spec else ""
pr = fmt_pr(epic.get("pr"))
mcp = epic.get("mcp")
epic_cell = md_cell(epic["title"]) + (f" `{mcp}`" if mcp else "")
rows.append(
f"| {epic_cell} | {st} | {epic.get('priority', '')} | "
f"{progress or '—'} | {spec_cell} | {pr} |"
)
return "\n".join(rows)
def load_archive() -> dict:
"""Read roadmap.archive.yaml; an absent file is an empty archive."""
if not os.path.isfile(ROADMAP_ARCHIVE_YAML):
return {"version": 1, "epics": []}
with open(ROADMAP_ARCHIVE_YAML, encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
data.setdefault("epics", [])
if data["epics"] is None:
data["epics"] = []
return data
def render_archive_table(archive: dict) -> str:
"""Compact provenance table for swept epics: what shipped, when, in which PRs."""
epics = archive.get("epics") or []
if not epics:
return "_Nothing archived yet._"
rows = ["| Epic | Shipped | Archived | PRs |", "| --- | --- | --- | --- |"]
for epic in sorted(epics, key=lambda e: str(e.get("shipped_on", "")), reverse=True):
prs = " ".join(
sorted({f"#{n}" for n in parse_pr_refs(epic.get("pr"))}
| {f"#{n}" for t in epic.get("tasks") or []
for n in parse_pr_refs(t.get("pr"))},
key=lambda s: int(s[1:]))
)
mcp = epic.get("mcp")
title = md_cell(epic["title"]) + (f" `{mcp}`" if mcp else "")
rows.append(
f"| {title} | {epic.get('shipped_on', '—')} | "
f"{epic.get('archived_on', '—')} | {prs or '—'} |"
)
return "\n".join(rows)
def render_spec_table() -> str:
"""Recompute the aggregate per-spec progress table from tasks.md files."""
rows = ["| # | Status | Progress |", "| --- | --- | --- |"]
for name in sorted(os.listdir(SPECS_DIR)):
spec_dir = os.path.join(SPECS_DIR, name)
if not os.path.isdir(spec_dir):
continue
if not re.match(r"^\d", name): # only numbered spec dirs
continue
word, progress = spec_badge(*count_checkboxes(os.path.join(spec_dir, "tasks.md")))
badge = f"`{word}`" if word != "—" else "—"
rows.append(f"| [{name}](./specs/{name}/) | {badge} | {progress} |")
return "\n".join(rows)
def render(data: dict, archive: dict | None = None) -> str:
epics = data.get("epics", [])
archive = archive if archive is not None else {"epics": []}
out = []
out.append("<!-- GENERATED FILE — do not edit by hand. -->")
out.append("<!-- Source: roadmap.yaml · Generator: scripts/gen-roadmap.py -->")
out.append("<!-- Regenerate: python3 scripts/gen-roadmap.py (or scripts/gen-roadmap) -->")
out.append("")
out.append("# MCPProxy Roadmap")
out.append("")
out.append("> **Generated — do not edit by hand.** This file is rendered from "
"[`roadmap.yaml`](./roadmap.yaml) by [`scripts/gen-roadmap.py`](./scripts/gen-roadmap.py). "
"Edit `roadmap.yaml` and re-run the generator.")
out.append("")
out.append("The roadmap models cross-spec **epics → tasks** with a dependency DAG, "
"execution `status`, `priority`, and links — the things a "
"per-spec `tasks.md` checkbox list cannot express. Per-spec checkbox "
"progress is recomputed live from each `specs/<NNN>/tasks.md`.")
out.append("")
out.append("[`roadmap.yaml`](./roadmap.yaml) holds the **working set** (todo · in-progress · "
"in-review · blocked · parked). Cold shipped epics are swept into "
"[`roadmap.archive.yaml`](./roadmap.archive.yaml) and surface in the "
"[Shipped](#shipped-archived) table below, so the working file stays small "
"while provenance survives. A `depends_on:` edge into the archive is "
"satisfied by definition.")
out.append("")
out.append("## How to regenerate")
out.append("")
out.append("```bash")
out.append("python3 scripts/gen-roadmap.py # writes ROADMAP.md")
out.append("scripts/gen-roadmap # convenience wrapper (same thing)")
out.append("python3 scripts/gen-roadmap.py --check # CI canary: fail if ROADMAP.md is stale")
out.append("python3 scripts/gen-roadmap.py --check-github # cross-check statuses vs live GitHub PR state,")
out.append(" # spec links, depends_on ids, and status sanity")
out.append(" # (add --strict to fail on warnings; needs gh)")
out.append("python3 scripts/gen-roadmap.py --archive --dry-run # preview the cold-done sweep")
out.append("python3 scripts/gen-roadmap.py --archive # sweep into roadmap.archive.yaml + regenerate")
out.append("```")
out.append("")
out.append("## roadmap.yaml schema (short form)")
out.append("")
out.append("- **epics[]** — each has `id` (stable slug, DAG node), `title`, "
"`status` (todo·in_progress·in_review·blocked·done), "
"`priority` (P0–P3), `depends_on: [ids]` (DAG edges, prerequisite→dependent), "
"optional `parked: true`, and links `spec:` / `pr:` / `mcp:` (external MCP-xxxx).")
out.append("- **epics[].tasks[]** — child tasks with the same fields; their "
"`depends_on` may reference sibling tasks or other epics.")
out.append("- See the header comment in `roadmap.yaml` for the full field reference.")
out.append("")
out.append("## Roadmap at a glance")
out.append("")
out.append("The cross-epic dependency graph — **one node per epic**, edges point "
"prerequisite → dependent. Task-level detail lives in the collapsible "
"sections below, and dependency-free epics are listed under the graph "
"rather than drawn as disconnected boxes, so this stays legible at "
"default zoom. Node colour = status: "
"🟢 done · 🔵 in-progress · 🟡 in-review · 🔴 blocked · ⚪ todo · ⚫ parked.")
out.append("")
overview, standalone = render_overview_mermaid(epics)
out.append(overview)
if standalone:
out.append("")
out.append(f"**Independent epics** ({len(standalone)}) — no cross-epic "
f"prerequisites; each stands alone:")
out.append("")
out.append(render_standalone_epics(standalone))
out.append("")
out.append("## Epic details")
out.append("")
out.append("Each epic's child tasks, their internal dependency graph, and "
"tracker/PR links — **collapsed by default**, expand the ones you "
"care about. Full metadata (priority, spec progress) is in "
"the [Epics](#epics) table below.")
out.append("")
out.append(render_epic_details(epics))
out.append("")
out.append("## Epics")
out.append("")
out.append(render_status_table(epics))
out.append("")
out.append("## Shipped (archived)")
out.append("")
out.append("Swept out of the working set by `--archive` once done, merged and cooled off. "
"Full entries — notes, child tasks, PR refs — live in "
"[`roadmap.archive.yaml`](./roadmap.archive.yaml).")
out.append("")
out.append(render_archive_table(archive))
out.append("")
out.append("## Per-spec progress (recomputed from `specs/<NNN>/tasks.md`)")
out.append("")
out.append("Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · "
"`—` no `tasks.md`. This aggregate is regenerated here rather than "
"overwriting the hand-maintained [`specs/README.md`](./specs/README.md), "
"which keeps its curated prose, runbooks and design-doc links.")
out.append("")
out.append(render_spec_table())
out.append("")
return "\n".join(out)
# ── GitHub / ground-truth cross-check (--check-github) ──────────────────────
class Finding:
"""One report line: an ERROR or WARN against a roadmap item."""
__slots__ = ("level", "ref", "reason")
def __init__(self, level: str, ref: str, reason: str):
self.level = level # "ERROR" | "WARN"
self.ref = ref
self.reason = reason
def iter_items(data: dict):
"""Yield metadata for every epic and task, in file order.
Each dict: item (raw), id, kind ('epic'|'task'), epic_id (owning epic),
has_children (bool). A task's owning epic id lets us attribute a task's
spec: link back to its epic for double-count detection.
"""
for epic in data.get("epics", []):
children = epic.get("tasks") or []
yield {"item": epic, "id": epic["id"], "kind": "epic",
"epic_id": epic["id"], "has_children": bool(children)}
for t in children:
yield {"item": t, "id": t["id"], "kind": "task",
"epic_id": epic["id"], "has_children": False}
def ref_label(meta: dict) -> str:
if meta["kind"] == "epic":
return f"{meta['id']} (epic)"
return f"{meta['id']} (task · epic {meta['epic_id']})"
def parse_pr_refs(pr) -> list[int]:
"""Extract PR numbers from a pr: field ("#786", full URL, or a list)."""
if not pr:
return []
refs = pr if isinstance(pr, list) else [pr]
nums: list[int] = []
for r in refs:
for m in PR_NUM_RE.finditer(str(r)):
n = int(m.group(1))
if n not in nums:
nums.append(n)
return nums
def gh_available() -> tuple[bool, str]:
"""(ok, reason). ok=False means skip the live PR cross-check (exit 2)."""
if not shutil.which("gh"):
return False, "`gh` CLI not found on PATH"
try:
r = subprocess.run(["gh", "auth", "status"],
capture_output=True, text=True)
except OSError as e: # pragma: no cover
return False, f"could not execute `gh`: {e}"
if r.returncode != 0:
return False, "`gh` is not authenticated (`gh auth status` failed)"
return True, ""
def gh_pr_state(number: int, repo: str, cache: dict) -> dict:
"""Return {'state','mergedAt'} for a PR, or {'error': msg}. Cached per number."""
if number in cache:
return cache[number]
r = subprocess.run(
["gh", "pr", "view", str(number), "--repo", repo,
"--json", "state,mergedAt"],
capture_output=True, text=True)
if r.returncode != 0:
cache[number] = {"error": (r.stderr.strip().splitlines() or ["not found"])[-1]}
else:
try:
cache[number] = json.loads(r.stdout)
except json.JSONDecodeError:
cache[number] = {"error": "unparseable `gh` JSON output"}
return cache[number]
def check_pr_status(items: list[dict], repo: str, cache: dict) -> list[Finding]:
"""Cross-check every pr: link against live GitHub state.
MERGED but not done → ERROR; CLOSED (unmerged) but in_progress/in_review →
ERROR; OPEN but done → ERROR; OPEN but todo → WARN; unresolvable ref → ERROR.
"""
out: list[Finding] = []
for meta in items:
status = meta["item"].get("status", "todo")
for num in parse_pr_refs(meta["item"].get("pr")):
st = gh_pr_state(num, repo, cache)
if "error" in st:
out.append(Finding("ERROR", ref_label(meta),
f"PR #{num} could not be resolved on GitHub "
f"({st['error']}) — dangling pr: link."))
continue
state = st.get("state") # OPEN | CLOSED | MERGED
if state == "MERGED":
if status != "done":
out.append(Finding("ERROR", ref_label(meta),
f"PR #{num} is MERGED but status is '{status}' "
f"(expected 'done')."))
elif state == "CLOSED":
if status in ("in_progress", "in_review"):
out.append(Finding("ERROR", ref_label(meta),
f"PR #{num} is CLOSED (unmerged) but status is "
f"'{status}'."))
elif state == "OPEN":
if status == "done":
out.append(Finding("ERROR", ref_label(meta),
f"PR #{num} is OPEN but status is 'done'."))
elif status == "todo":
out.append(Finding("WARN", ref_label(meta),
f"PR #{num} is OPEN (work started) but status is "
f"still 'todo'."))
return out
def check_spec_links(items: list[dict]) -> list[Finding]:
"""Every spec: must resolve to a real specs/<NNN> dir (ERROR if not).
A spec shared by two different epics double-counts its badge (WARN)."""
out: list[Finding] = []
spec_to_epics: dict[str, set] = {}
for meta in items:
spec = meta["item"].get("spec")
if not spec:
continue
if not os.path.isdir(os.path.join(REPO_ROOT, spec)):
out.append(Finding("ERROR", ref_label(meta),
f"spec: '{spec}' does not resolve to a directory under specs/."))
# Attribute to the owning epic so an epic sharing a spec with its OWN
# child task is not flagged — only genuinely distinct epics are.
spec_to_epics.setdefault(spec, set()).add(meta["epic_id"])
for spec, epics in sorted(spec_to_epics.items()):
if len(epics) > 1:
out.append(Finding("WARN", f"spec {spec}",
f"shared by {len(epics)} distinct epics "
f"({', '.join(sorted(epics))}) — the Epics-table progress "
f"badge double-counts this spec."))
return out
def check_id_integrity(data: dict, archive: dict) -> list[Finding]:
"""Ids must be unique (across both files), and must not collide once
sanitised for Mermaid. Both are silent corruptors otherwise."""
out: list[Finding] = []
both = (data.get("epics") or []) + (archive.get("epics") or [])
for dup in duplicate_ids(both):
out.append(Finding("ERROR", f"id {dup}",
"declared more than once across roadmap.yaml/roadmap.archive.yaml. "
"Ids must be unique across epics AND tasks; a duplicate epic id "
"makes --archive silently drop one of the two blocks."))
for a, b in node_id_collisions(both):
out.append(Finding("ERROR", f"id {a}",
f"sanitises to the same Mermaid node id as '{b}' — the two would "
f"render as one node and inherit each other's edges."))
return out
def check_cycles(data: dict) -> list[Finding]:
"""depends_on must describe a DAG."""
out: list[Finding] = []
for cyc in find_cycles(data.get("epics") or []):
kind = "self-loop" if len(cyc) == 2 else "cycle"
out.append(Finding("ERROR", f"depends_on {kind}",
" → ".join(cyc) + " — depends_on must be acyclic."))
return out
def check_dangling_deps(items: list[dict], archive: dict) -> list[Finding]:
"""Every depends_on target must name a known id.
Known == an epic/task in roadmap.yaml, or an epic/task in the archive (an
edge into the archive is a satisfied prerequisite, not a dangling one).
Anything else is a typo or a renamed id that silently broke the DAG.
"""
out: list[Finding] = []
known = {m["id"] for m in items} | all_ids(archive.get("epics") or [])
for meta in items:
for dep in meta["item"].get("depends_on") or []:
if dep not in known:
out.append(Finding("ERROR", ref_label(meta),
f"depends_on: '{dep}' matches no epic or task id in "
f"roadmap.yaml or roadmap.archive.yaml."))
return out
def check_status_sanity(items: list[dict]) -> list[Finding]:
"""Reviews/in-flight work should have PR evidence; done epics should have
all children done.
in_review with no pr: → WARN for any item (an in-review claim with no PR
anywhere is exactly the drift this audit found). in_progress with no pr: →
WARN only for leaf items, since an umbrella epic legitimately delegates its
PRs to child tasks.
"""
out: list[Finding] = []
for meta in items:
item = meta["item"]
status = item.get("status", "todo")
has_pr = bool(parse_pr_refs(item.get("pr")))
if not has_pr:
if status == "in_review":
out.append(Finding("WARN", ref_label(meta),
"status 'in_review' but no pr: link — an in-review item "
"should link its PR as evidence."))
elif status == "in_progress" and not meta["has_children"]:
out.append(Finding("WARN", ref_label(meta),
"status 'in_progress' but no pr: link and no child tasks "
"— nothing links the in-flight work."))
if meta["kind"] == "epic" and status == "done":
for t in item.get("tasks") or []:
if t.get("status") != "done":
out.append(Finding("WARN", ref_label(meta),
f"epic is 'done' but child task '{t['id']}' is "
f"'{t.get('status', 'todo')}'."))
return out
def print_report(findings: list[Finding], strict: bool) -> int:
errors = [f for f in findings if f.level == "ERROR"]
warnings = [f for f in findings if f.level == "WARN"]
print(f"roadmap.yaml ground-truth cross-check (repo {REPO_SLUG})\n")
def emit(group: list[Finding], head: str):
print(f"{head} ({len(group)}):")
if not group:
print(" none")
for f in group:
print(f" [{f.level:<5}] {f.ref}")
print(f" {f.reason}")
print()
emit(errors, "ERRORS")
emit(warnings, "WARNINGS")
print(f"Summary: {len(errors)} error(s), {len(warnings)} warning(s).")
if strict and warnings:
print("(--strict: warnings count as errors for the exit code.)")
if errors or (strict and warnings):
return 1
return 0
def check_github(data: dict, strict: bool) -> int:
items = list(iter_items(data))
findings: list[Finding] = []
ok, reason = gh_available()
if ok:
cache: dict = {}
findings += check_pr_status(items, REPO_SLUG, cache)
# spec + status + DAG checks are offline and always run.
archive = load_archive()
findings += check_spec_links(items)
findings += check_id_integrity(data, archive)
findings += check_cycles(data)
findings += check_dangling_deps(items, archive)
findings += check_status_sanity(items)
if not ok:
print_report(findings, strict)
sys.stderr.write(
f"\nerror: PR cross-check skipped — {reason}. "
"Install and authenticate `gh` (`gh auth login`) to validate PR "
"state; offline spec/status checks above still ran.\n")
return 2
return print_report(findings, strict)
# ── archive sweep (--archive) ───────────────────────────────────────────────
# An epic block starts at indent 2 (" - id: slug"); child tasks sit at indent 6
# and so never match. A trailing comment or quotes on the id are tolerated.
# Section banners (" # ── DONE ──") separate groups.
#
# This is a REGEX over YAML, which cannot know about block scalars: a `note: |`
# whose body happens to contain a line like " - id: x" would look like an epic
# start. `assert_blocks_match_yaml` is the guard — the ids this regex finds must
# equal the ids PyYAML parses, or we refuse to touch the file.
EPIC_START_RE = re.compile(r"""^ - id:\s*["']?([^"'\s#]+)["']?\s*(?:\#.*)?$""")
# Only the generated banner shape (" # ── TITLE ──…"), never any indent-2
# comment that merely starts with a box-drawing glyph.
SECTION_HDR_RE = re.compile(r"^ # ──\s")
def atomic_write(path: str, text: str) -> None:
"""Write via a temp file in the same directory + rename, so a crash never
leaves a half-written roadmap or archive behind."""
d = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(dir=d, prefix=".gen-roadmap.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
os.replace(tmp, path)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
def epic_pr_numbers(epic: dict) -> list[int]:
"""Every PR referenced by an epic or any of its child tasks."""
nums = list(parse_pr_refs(epic.get("pr")))
for t in epic.get("tasks") or []:
for n in parse_pr_refs(t.get("pr")):