beacon-framework 1.2.2__tar.gz → 1.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/PKG-INFO +1 -1
  2. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/pyproject.toml +5 -1
  3. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/__init__.py +1 -1
  4. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/cli.py +22 -7
  5. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/doctor.py +41 -0
  6. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/manifest.py +6 -0
  7. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/beacon/roadmap.md +3 -1
  8. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/roadmap.py +53 -19
  9. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/.gitignore +0 -0
  10. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/BEACON.md +0 -0
  11. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/LICENSE +0 -0
  12. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/README.md +0 -0
  13. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/pragmatic-principles.md +0 -0
  14. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/__main__.py +0 -0
  15. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/bullet.py +0 -0
  16. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/docs.py +0 -0
  17. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/epic.py +0 -0
  18. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/installer.py +0 -0
  19. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/integrations/__init__.py +0 -0
  20. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/integrations/claude_code.py +0 -0
  21. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/integrations/release.py +0 -0
  22. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/language.py +0 -0
  23. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/beacon.md +0 -0
  24. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/help/commands.md +0 -0
  25. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/help/doctor.md +0 -0
  26. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/help/git-flow.md +0 -0
  27. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/help/phases.md +0 -0
  28. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/help/quickstart.md +0 -0
  29. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/CLAUDE.md.fragment +0 -0
  30. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/agents/beacon-reviewer.md +0 -0
  31. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/beacon/constitution.md +0 -0
  32. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/beacon/continue.md +0 -0
  33. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/beacon/implement.md +0 -0
  34. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/beacon/plan.md +0 -0
  35. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/beacon/prd.md +0 -0
  36. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/beacon/review.md +0 -0
  37. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/beacon/seed.md +0 -0
  38. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/beacon/specify.md +0 -0
  39. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/beacon/status.md +0 -0
  40. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/beacon/tasks.md +0 -0
  41. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/design/diagram.md +0 -0
  42. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/design/evaluate.md +0 -0
  43. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/design/wardley.md +0 -0
  44. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/git/feature.md +0 -0
  45. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/git/pr.md +0 -0
  46. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/git/release.gitflow.md +0 -0
  47. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/git/release.trunk.md +0 -0
  48. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/commands/init.md +0 -0
  49. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/settings.json +0 -0
  50. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/skills/beacon-build/SKILL.md +0 -0
  51. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/skills/beacon-design/SKILL.md +0 -0
  52. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/skills/beacon-seed/SKILL.md +0 -0
  53. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/claude_code/skills/beacon-ship/SKILL.md +0 -0
  54. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/release/PUBLISHING.gitflow.md +0 -0
  55. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/release/PUBLISHING.trunk.md +0 -0
  56. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/release/psr_config.gitflow.toml +0 -0
  57. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/release/psr_config.trunk.toml +0 -0
  58. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/release/workflows/release.gitflow.yml +0 -0
  59. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/integrations/release/workflows/release.trunk.yml +0 -0
  60. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/languages/node.toml +0 -0
  61. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/languages/python.toml +0 -0
  62. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/.beacon/doctor.toml.example +0 -0
  63. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/ADRs/ADR-000-template.md +0 -0
  64. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/ADRs/README.md +0 -0
  65. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/Background/00-problem-statement.md +0 -0
  66. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/Background/01-final-architecture-document.md +0 -0
  67. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/Roadmap/README.md +0 -0
  68. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/Roadmap/archive/.gitkeep +0 -0
  69. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/Roadmap/epics/.gitkeep +0 -0
  70. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/Roadmap/epics/EPIC-TEMPLATE.md +0 -0
  71. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/Roadmap/epics/archive/.gitkeep +0 -0
  72. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/Work/README.md +0 -0
  73. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/Work/analysis/.gitkeep +0 -0
  74. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/Work/branches/.gitkeep +0 -0
  75. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/Work/planning/.gitkeep +0 -0
  76. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/project-management/Work/sessions/.gitkeep +0 -0
  77. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/roadmap_template.html +0 -0
  78. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/user-facing-docs/README.md +0 -0
  79. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/user-facing-docs/explanation/index.md +0 -0
  80. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/user-facing-docs/how-to/index.md +0 -0
  81. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/user-facing-docs/reference/index.md +0 -0
  82. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/resources/user-facing-docs/tutorials/getting-started.md +0 -0
  83. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/seed.py +0 -0
  84. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/upgrader.py +0 -0
  85. {beacon_framework-1.2.2 → beacon_framework-1.3.1}/src/beacon/validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: beacon-framework
3
- Version: 1.2.2
3
+ Version: 1.3.1
4
4
  Summary: Light-touch, pragmatic, artifact-driven framework for AI-assisted software delivery
5
5
  Project-URL: Repository, https://github.com/darth-veitcher/beacon
6
6
  Author: darth-veitcher
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "beacon-framework"
7
- version = "1.2.2"
7
+ version = "1.3.1"
8
8
  description = "Light-touch, pragmatic, artifact-driven framework for AI-assisted software delivery"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -76,6 +76,8 @@ Repository = "https://github.com/darth-veitcher/beacon"
76
76
 
77
77
 
78
78
 
79
+
80
+
79
81
 
80
82
 
81
83
 
@@ -185,6 +187,8 @@ exclude_commit_patterns = ["^chore\\(release\\):"]
185
187
 
186
188
 
187
189
 
190
+
191
+
188
192
 
189
193
 
190
194
 
@@ -1,3 +1,3 @@
1
1
  """BEACON Framework: lifecycle and discipline for artifact-driven AI-assisted delivery."""
2
2
 
3
- __version__ = "1.2.2"
3
+ __version__ = "1.3.1"
@@ -1271,8 +1271,8 @@ def roadmap_export(
1271
1271
  stdout: bool = typer.Option(
1272
1272
  False, "--stdout", help="Print to stdout instead of writing a file."
1273
1273
  ),
1274
- include_done: bool = typer.Option(
1275
- False, "--include-done", help="Include Done epics in the Gantt chart."
1274
+ exclude_done: bool = typer.Option(
1275
+ False, "--exclude-done", help="Exclude Done/archived epics from the roadmap."
1276
1276
  ),
1277
1277
  ) -> None:
1278
1278
  """Generate ROADMAP.md — version-controlled Markdown + Mermaid source of truth.
@@ -1280,10 +1280,14 @@ def roadmap_export(
1280
1280
  Renders epics, spec progress, ADRs, and active bullets into a Mermaid-enhanced
1281
1281
  Markdown document. Safe to commit alongside beacon.md; regenerate on demand or
1282
1282
  wire into CI with ``beacon roadmap export``.
1283
+
1284
+ Done/archived epics are included by default (up to ``roadmap_done_limit`` in
1285
+ your manifest, defaulting to 3). Pass ``--exclude-done`` to suppress them.
1283
1286
  """
1284
1287
  from datetime import date
1285
1288
 
1286
1289
  from beacon import roadmap as _roadmap
1290
+ from beacon.manifest import Manifest
1287
1291
 
1288
1292
  root = _resolve_root(here, path)
1289
1293
  if not (root / "project-management").is_dir():
@@ -1293,9 +1297,11 @@ def roadmap_export(
1293
1297
  "[dim]beacon roadmap export /path/to/project[/dim]"
1294
1298
  )
1295
1299
  raise typer.Exit(1)
1300
+ _m = Manifest.load(root)
1301
+ done_limit = 0 if exclude_done else (_m.roadmap_done_limit if _m else 3)
1296
1302
  today = date.today().isoformat()
1297
1303
  data = _roadmap.collect(root)
1298
- content = _roadmap.render_markdown(data, today, include_done=include_done)
1304
+ content = _roadmap.render_markdown(data, today, done_limit=done_limit)
1299
1305
  if stdout:
1300
1306
  console.print(content)
1301
1307
  return
@@ -1303,13 +1309,13 @@ def roadmap_export(
1303
1309
  dest.write_text(content, encoding="utf-8")
1304
1310
  console.print(f"[green]✓[/green] Roadmap exported → [cyan]{dest.relative_to(root)}[/cyan]")
1305
1311
  if not data.epics:
1306
- if data.archived_epics and not include_done:
1312
+ if data.archived_epics and exclude_done:
1307
1313
  n = len(data.archived_epics)
1308
1314
  plural = "s" if n > 1 else ""
1309
1315
  console.print(
1310
1316
  f"[dim] ({n} archived epic{plural}"
1311
- " — run [/dim][dim cyan]beacon roadmap export --include-done"
1312
- "[/dim cyan][dim] to include completed work)[/dim]"
1317
+ " — remove [/dim][dim cyan]--exclude-done"
1318
+ "[/dim cyan][dim] to include recent completed work)[/dim]"
1313
1319
  )
1314
1320
  elif not data.archived_epics:
1315
1321
  console.print(
@@ -1325,6 +1331,9 @@ def roadmap_render(
1325
1331
  output: Path | None = typer.Option(
1326
1332
  None, "--output", "-o", help="Output path (default: ROADMAP.html in project root)."
1327
1333
  ),
1334
+ exclude_done: bool = typer.Option(
1335
+ False, "--exclude-done", help="Exclude Done/archived epics from the visualization."
1336
+ ),
1328
1337
  ) -> None:
1329
1338
  """Generate ROADMAP.html — Structural Breath interactive visualization (gitignored).
1330
1339
 
@@ -1332,10 +1341,14 @@ def roadmap_render(
1332
1341
  cards, active-bullet feed, and ADR index — all styled with the Structural Breath
1333
1342
  design system (dark, blueprint-grid, engineering-whitepaper aesthetic). Open
1334
1343
  directly in a browser, screenshot into a slide deck, or host as a static page.
1344
+
1345
+ Done/archived epics are included by default (up to ``roadmap_done_limit`` in
1346
+ your manifest, defaulting to 3). Pass ``--exclude-done`` to suppress them.
1335
1347
  """
1336
1348
  from datetime import date
1337
1349
 
1338
1350
  from beacon import roadmap as _roadmap
1351
+ from beacon.manifest import Manifest
1339
1352
 
1340
1353
  root = _resolve_root(here, path)
1341
1354
  if not (root / "project-management").is_dir():
@@ -1345,9 +1358,11 @@ def roadmap_render(
1345
1358
  "[dim]beacon roadmap render /path/to/project[/dim]"
1346
1359
  )
1347
1360
  raise typer.Exit(1)
1361
+ _m = Manifest.load(root)
1362
+ done_limit = 0 if exclude_done else (_m.roadmap_done_limit if _m else 3)
1348
1363
  today = date.today().isoformat()
1349
1364
  data = _roadmap.collect(root)
1350
- content = _roadmap.render_html(data, today)
1365
+ content = _roadmap.render_html(data, today, done_limit=done_limit)
1351
1366
  dest = output or (root / "ROADMAP.html")
1352
1367
  dest.write_text(content, encoding="utf-8")
1353
1368
  console.print(f"[green]✓[/green] Roadmap rendered → [cyan]{dest.relative_to(root)}[/cyan]")
@@ -901,6 +901,39 @@ def _check_epic_adr_coverage(root: Path, config: Config = DEFAULT_CONFIG) -> Che
901
901
  )
902
902
 
903
903
 
904
+ def _check_epic_adr_broken_paths(root: Path) -> Check:
905
+ """Every ADR entry in every epic's ## ADRs section must resolve to a real file.
906
+
907
+ Silently broken paths (typos, wrong relative paths, unrecognised markdown
908
+ link syntax) render as ``(file missing)`` in the DECISIONS tab without any
909
+ warning. This check surfaces them before a render is needed.
910
+ """
911
+ from beacon.roadmap import _parse_adr # local import to avoid circular
912
+
913
+ all_epics = _epic.list_active(root) + _epic.list_archived(root)
914
+ broken: list[str] = []
915
+ for e in all_epics:
916
+ epic_dir = e.path.parent if e.path else None
917
+ for adr_path in e.adrs:
918
+ summary = _parse_adr(root, adr_path, epic_dir)
919
+ if summary.title == "(file missing)":
920
+ broken.append(f"{e.slug}: {adr_path!r}")
921
+ if broken:
922
+ head = "; ".join(broken[:3]) + ("…" if len(broken) > 3 else "")
923
+ return Check(
924
+ "epic-adr-broken-paths",
925
+ Status.WARN,
926
+ f"{len(broken)} ADR entry/entries resolve to missing file(s): {head}. "
927
+ "Use bare paths (e.g. `project-management/ADRs/ADR-001-foo.md`) or "
928
+ "markdown links with a correct relative href.",
929
+ )
930
+ return Check(
931
+ "epic-adr-broken-paths",
932
+ Status.OK,
933
+ "All ADR entries in epics resolve to existing files.",
934
+ )
935
+
936
+
904
937
  ROADMAP_REVIEW_AGE = timedelta(days=90)
905
938
  """Roadmap is stale if `Last reviewed:` is older than this."""
906
939
 
@@ -1917,6 +1950,7 @@ CHECKS: tuple[tuple[str, object, bool], ...] = (
1917
1950
  ("epic-spec-lifecycle", _check_epic_spec_lifecycle, False),
1918
1951
  ("epic-rollup", _check_epic_rollup, False),
1919
1952
  ("epic-adr-coverage", _check_epic_adr_coverage, True),
1953
+ ("epic-adr-broken-paths", _check_epic_adr_broken_paths, False),
1920
1954
  ("roadmap-staleness", _check_roadmap_staleness, True),
1921
1955
  ("publishing-mode", _check_publishing_mode, False),
1922
1956
  ("release-config-alignment", _check_release_config_alignment, False),
@@ -2074,6 +2108,13 @@ CHECK_DESCRIPTIONS: dict[str, str] = {
2074
2108
  "probably went unrecorded — creating/editing an epic is a BEACON "
2075
2109
  "DESIGN-phase activity that should produce ADRs."
2076
2110
  ),
2111
+ "epic-adr-broken-paths": (
2112
+ "Every ADR entry in every epic's `## ADRs` section must resolve to a "
2113
+ "file that exists on disk. WARN means one or more entries are broken — "
2114
+ "typo, wrong relative path, or unsupported markdown link syntax. "
2115
+ "Broken entries render silently as `(file missing)` in `beacon roadmap "
2116
+ "render` without this check."
2117
+ ),
2077
2118
  "roadmap-staleness": (
2078
2119
  "`Roadmap/README.md` should carry a `Last reviewed: YYYY-MM-DD` "
2079
2120
  "header newer than `roadmap_review_age_days` (default 90). WARN means "
@@ -50,6 +50,11 @@ class Manifest:
50
50
  # `--no-speckit` / declining the offer sets this False; pre-PR installs
51
51
  # without the field default False (no nag for users who never opted in).
52
52
  speckit_requested: bool = False
53
+ # How many of the most-recently-completed (archived) epics to include in
54
+ # roadmap output by default. 0 = exclude Done epics entirely; any positive
55
+ # integer caps the count. Use ``--exclude-done`` on the CLI to override to
56
+ # 0 for a single run without touching this value.
57
+ roadmap_done_limit: int = 3
53
58
 
54
59
  @classmethod
55
60
  def fresh(cls) -> Manifest:
@@ -71,6 +76,7 @@ class Manifest:
71
76
  hooks_enabled=bool(data.get("hooks_enabled", True)),
72
77
  git_flow=str(data.get("git_flow", "trunk")),
73
78
  speckit_requested=bool(data.get("speckit_requested", False)),
79
+ roadmap_done_limit=int(data.get("roadmap_done_limit", 3)),
74
80
  )
75
81
 
76
82
  def save(self, project_root: Path) -> Path:
@@ -17,6 +17,8 @@ Run:
17
17
  beacon roadmap export
18
18
  ```
19
19
 
20
+ This includes the most-recently-completed epics by default (controlled by `roadmap_done_limit` in the manifest, defaulting to 3). Pass `--exclude-done` to suppress Done/archived epics.
21
+
20
22
  If the user passed `render` as an argument (i.e. `$ARGUMENTS` contains the word `render`), also run:
21
23
  ```bash
22
24
  beacon roadmap render
@@ -26,7 +28,7 @@ beacon roadmap render
26
28
 
27
29
  Read the just-written `ROADMAP.md` and report concisely:
28
30
 
29
- - **Epic counts** by status: Active / Planning / Paused / Done
31
+ - **Epic counts** by status: Active / Planning / Paused / Done (including any archived epics shown)
30
32
  - **Fidelity** for each epic — show the badge (`S+/S? A+/A? T:N%`) and flag any that are `S?` (no specs) or `A?` (no ADRs)
31
33
  - **In-flight bullets** — how many worktrees have active bullets and which epics they're attached to
32
34
  - **Roadmap coverage** — fraction of Active epics that have both specs and ADRs filled in
@@ -33,6 +33,9 @@ _ADR_TITLE_RE = re.compile(r"^#\s*ADR-\d+[:\s]+(.+?)\s*$", re.MULTILINE)
33
33
  # Valid ADR statuses
34
34
  _ADR_STATUSES = {"Proposed", "Accepted", "Deprecated", "Superseded"}
35
35
 
36
+ # Markdown link href extractor: [text](href)
37
+ _MD_LINK_RE = re.compile(r"\[.*?\]\(([^)]+)\)")
38
+
36
39
 
37
40
  # ---------------------------------------------------------------------------
38
41
  # Dataclasses
@@ -176,15 +179,23 @@ def _fidelity_badge(f: EpicFidelity) -> str:
176
179
  return f"{spec} {adr} {task}"
177
180
 
178
181
 
179
- def _parse_adr(root: Path, adr_path: str) -> AdrSummary:
182
+ def _parse_adr(root: Path, adr_path: str, epic_dir: Path | None = None) -> AdrSummary:
180
183
  """Extract token, title, and status from an ADR file. Never returns None."""
181
- # Normalise: drop leading "./" and trailing "/"
182
- clean = adr_path.strip().lstrip("./").rstrip("/")
183
- full = root / clean
184
+ # Extract href from markdown link syntax [text](href) if present
185
+ md_m = _MD_LINK_RE.search(adr_path)
186
+ raw = md_m.group(1) if md_m else adr_path
184
187
 
185
188
  token_m = _ADR_TOKEN_RE.search(adr_path)
186
189
  token = token_m.group(1).upper() if token_m else "ADR-???"
187
190
 
191
+ raw = raw.strip().rstrip("/")
192
+ if raw.startswith(("../", "./")):
193
+ # Relative path — resolve against the epic file's directory
194
+ base = epic_dir or (root / "project-management/Roadmap/epics")
195
+ full = (base / raw).resolve()
196
+ else:
197
+ full = root / raw.lstrip("./")
198
+
188
199
  if not full.exists():
189
200
  return AdrSummary(path=adr_path, token=token, title="(file missing)", status="Unknown")
190
201
 
@@ -208,6 +219,15 @@ def _parse_adr(root: Path, adr_path: str) -> AdrSummary:
208
219
  return AdrSummary(path=adr_path, token=token, title=title, status=status)
209
220
 
210
221
 
222
+ def _select_done_epics(archived: list[Epic], done_limit: int) -> list[Epic]:
223
+ """Return the N most-recently-started archived epics, sorted chronologically."""
224
+ if done_limit <= 0:
225
+ return []
226
+ by_recency = sorted(archived, key=lambda e: e.started or "0000-00-00", reverse=True)
227
+ selected = by_recency[:done_limit]
228
+ return sorted(selected, key=lambda e: e.started or "9999-99-99")
229
+
230
+
211
231
  def _mermaid_gantt_src(
212
232
  epics: list[Epic],
213
233
  rollups: dict[str, Rollup],
@@ -290,12 +310,13 @@ def _markdown_status_table(
290
310
  return "\n".join(rows)
291
311
 
292
312
 
293
- def _to_json_data(data: RoadmapData, generated_at: str, *, include_done: bool = False) -> dict:
313
+ def _to_json_data(data: RoadmapData, generated_at: str, *, done_limit: int = 3) -> dict:
294
314
  """Serialise RoadmapData to the shape embedded in ROADMAP.html."""
295
- all_epics = data.epics + (data.archived_epics if include_done else [])
315
+ done_epics = _select_done_epics(data.archived_epics, done_limit)
316
+ all_epics = data.epics + done_epics
296
317
  epics_list = []
297
318
  for e in all_epics:
298
- if not include_done and e.status == "Done":
319
+ if done_limit == 0 and e.status == "Done":
299
320
  continue
300
321
  roll = data.rollups.get(e.slug)
301
322
  f = data.fidelity.get(e.slug)
@@ -354,7 +375,7 @@ def _to_json_data(data: RoadmapData, generated_at: str, *, include_done: bool =
354
375
  # Build gantt_bars for D3
355
376
  gantt_bars = []
356
377
  for e in all_epics:
357
- if not include_done and e.status == "Done":
378
+ if done_limit == 0 and e.status == "Done":
358
379
  continue
359
380
  if not e.started:
360
381
  continue
@@ -422,12 +443,13 @@ def collect(root: Path) -> RoadmapData:
422
443
  adr_count=len(e.adrs),
423
444
  )
424
445
 
446
+ epic_dir = e.path.parent if e.path else None
425
447
  for adr_path in e.adrs:
426
448
  m = _ADR_TOKEN_RE.search(adr_path)
427
449
  if m:
428
450
  tok = m.group(1).upper()
429
451
  if tok not in seen_adrs:
430
- seen_adrs[tok] = _parse_adr(root, adr_path)
452
+ seen_adrs[tok] = _parse_adr(root, adr_path, epic_dir)
431
453
 
432
454
  adrs = sorted(seen_adrs.values(), key=lambda a: a.token)
433
455
 
@@ -452,7 +474,7 @@ def render_markdown(
452
474
  data: RoadmapData,
453
475
  generated_at: str,
454
476
  *,
455
- include_done: bool = False,
477
+ done_limit: int = 3,
456
478
  ) -> str:
457
479
  """Render ROADMAP.md content from collected data."""
458
480
  lines: list[str] = []
@@ -465,7 +487,8 @@ def render_markdown(
465
487
  lines.append(f"> {data.vision_excerpt}")
466
488
  lines.append("")
467
489
 
468
- visible_epics = data.epics + (data.archived_epics if include_done else [])
490
+ done_epics = _select_done_epics(data.archived_epics, done_limit)
491
+ visible_epics = data.epics + done_epics
469
492
 
470
493
  if not visible_epics:
471
494
  if data.archived_epics:
@@ -473,7 +496,8 @@ def render_markdown(
473
496
  plural = "s" if n > 1 else ""
474
497
  lines.append(
475
498
  f"_No active epics — {n} epic{plural} archived. "
476
- f"Run `beacon roadmap export --include-done` to include completed work._"
499
+ "Remove `--exclude-done` or raise `roadmap_done_limit` in your manifest"
500
+ " to include completed work._"
477
501
  )
478
502
  else:
479
503
  lines.append(
@@ -497,7 +521,7 @@ def render_markdown(
497
521
  lines.append("")
498
522
  lines.append("```mermaid")
499
523
  lines.append(
500
- _mermaid_gantt_src(visible_epics, data.rollups, generated_at, include_done=include_done)
524
+ _mermaid_gantt_src(visible_epics, data.rollups, generated_at, include_done=(done_limit > 0))
501
525
  )
502
526
  lines.append("```")
503
527
  lines.append("")
@@ -545,7 +569,7 @@ def render_markdown(
545
569
  return "\n".join(lines)
546
570
 
547
571
 
548
- def render_html(data: RoadmapData, generated_at: str) -> str:
572
+ def render_html(data: RoadmapData, generated_at: str, *, done_limit: int = 3) -> str:
549
573
  """Render ROADMAP.html by injecting JSON into the Structural Breath template."""
550
574
  from importlib import resources as _res
551
575
 
@@ -554,7 +578,7 @@ def render_html(data: RoadmapData, generated_at: str) -> str:
554
578
  )
555
579
  template = template_bytes.decode("utf-8")
556
580
 
557
- json_data = _to_json_data(data, generated_at)
581
+ json_data = _to_json_data(data, generated_at, done_limit=done_limit)
558
582
  # Use json.dumps with separators for compact embedding; escape </script> sequences
559
583
  json_str = json.dumps(json_data, ensure_ascii=False).replace("</script>", "<\\/script>")
560
584
 
@@ -568,26 +592,36 @@ def generate_markdown(
568
592
  root: Path,
569
593
  output: Path | None = None,
570
594
  *,
571
- include_done: bool = False,
595
+ done_limit: int | None = None,
572
596
  ) -> Path:
573
597
  """Collect data, render, and write ROADMAP.md. Returns the written path."""
574
598
  from datetime import date
575
599
 
600
+ from beacon.manifest import Manifest
601
+
602
+ if done_limit is None:
603
+ m = Manifest.load(root)
604
+ done_limit = m.roadmap_done_limit if m else 3
576
605
  today = date.today().isoformat()
577
606
  data = collect(root)
578
- content = render_markdown(data, today, include_done=include_done)
607
+ content = render_markdown(data, today, done_limit=done_limit)
579
608
  dest = output or (root / "ROADMAP.md")
580
609
  dest.write_text(content, encoding="utf-8")
581
610
  return dest
582
611
 
583
612
 
584
- def generate_html(root: Path, output: Path | None = None) -> Path:
613
+ def generate_html(root: Path, output: Path | None = None, *, done_limit: int | None = None) -> Path:
585
614
  """Collect data, render, and write ROADMAP.html. Returns the written path."""
586
615
  from datetime import date
587
616
 
617
+ from beacon.manifest import Manifest
618
+
619
+ if done_limit is None:
620
+ m = Manifest.load(root)
621
+ done_limit = m.roadmap_done_limit if m else 3
588
622
  today = date.today().isoformat()
589
623
  data = collect(root)
590
- content = render_html(data, today)
624
+ content = render_html(data, today, done_limit=done_limit)
591
625
  dest = output or (root / "ROADMAP.html")
592
626
  dest.write_text(content, encoding="utf-8")
593
627
  return dest