@deftai/directive-content 0.55.2 → 0.56.0

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 (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,635 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ roadmap_render.py -- Render ROADMAP.md from vbrief/pending/ contents.
4
+
5
+ Deterministic tool that generates ROADMAP.md grouped by phase (vBRIEF
6
+ item hierarchy) with dependency-based ordering (vBRIEF edges). Surfaces
7
+ GitHub issue numbers from vBRIEF references entries.
8
+
9
+ Usage:
10
+ uv run python scripts/roadmap_render.py [pending_dir] [out_file]
11
+
12
+ pending_dir -- path to vbrief/pending/ (default: <cwd>/vbrief/pending)
13
+ out_file -- output path (default: <cwd>/ROADMAP.md)
14
+
15
+ Exit codes:
16
+ 0 -- rendered successfully
17
+ 1 -- error during rendering
18
+
19
+ Part of #309 (RFC: vBRIEF-centric document model). Closes #311.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import re
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ # UTF-8 stdout guard (#540).
30
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
31
+ from _stdio_utf8 import reconfigure_stdio # noqa: E402
32
+
33
+ reconfigure_stdio()
34
+
35
+ # Canonical 4-line machine-generated banner per
36
+ # ``conventions/machine-generated-banner.md`` (#572). All four lines
37
+ # are populated (Purpose / Source of truth / Regenerate with) so
38
+ # downstream detectors can match on a stable token.
39
+ BANNER = (
40
+ "<!-- AUTO-GENERATED by task roadmap:render -- DO NOT EDIT MANUALLY -->\n"
41
+ "<!-- Purpose: rendered roadmap -->\n"
42
+ "<!-- Source of truth: vbrief/pending/ (scope vBRIEFs) -->\n"
43
+ "<!-- Regenerate with: task roadmap:render -->\n"
44
+ )
45
+
46
+
47
+ def _scope_metadata_rank(plan: dict) -> int | None:
48
+ """Return ``plan.metadata.rank`` as an int, or ``None`` when absent/invalid.
49
+
50
+ Deliberate mirror of ``scripts/triage_queue.scope_metadata_rank`` so
51
+ the roadmap render and the triage queue share one rank interpretation
52
+ (#1419 Slice 1 / #987) without this lightweight renderer importing the
53
+ triage-cache module's dependency surface. A real int or an integer-
54
+ valued string (including a leading-minus negative) is accepted; ``bool``
55
+ is rejected because it subclasses ``int``; any other non-integer string
56
+ (e.g. ``"--3"``) returns ``None`` rather than raising. Both copies are
57
+ test-covered so the semantics cannot silently drift.
58
+ """
59
+ if not isinstance(plan, dict):
60
+ return None
61
+ metadata = plan.get("metadata")
62
+ if not isinstance(metadata, dict):
63
+ return None
64
+ rank = metadata.get("rank")
65
+ if isinstance(rank, bool):
66
+ return None
67
+ if isinstance(rank, int):
68
+ return rank
69
+ if isinstance(rank, str):
70
+ try:
71
+ return int(rank.strip())
72
+ except ValueError:
73
+ return None
74
+ return None
75
+
76
+
77
+ def _scope_rank_sort_key(vbrief: dict) -> tuple[int, int]:
78
+ """Stable-sort key ordering scopes by ``plan.metadata.rank`` ascending.
79
+
80
+ Ranked scopes sort first by ascending rank value (bucket 0); un-ranked
81
+ scopes tail-sort together (bucket 1). Because the caller pre-sorts by
82
+ filename and Python's sort is stable, filename order is the natural
83
+ tiebreaker within each bucket (#1419 Slice 1 / #987).
84
+ """
85
+ plan = vbrief.get("plan", {})
86
+ rank = _scope_metadata_rank(plan)
87
+ if rank is None:
88
+ return (1, 0)
89
+ return (0, rank)
90
+
91
+
92
+ def _load_vbriefs(folder: Path) -> list[dict]:
93
+ """Load all .vbrief.json files from a folder, ordered by rank then name.
94
+
95
+ Files are first sorted by filename, then stably re-sorted by
96
+ ``plan.metadata.rank`` ascending (#1419 Slice 1 / #987) so ROADMAP.md
97
+ lists pending scopes in rank order. Un-ranked scopes tail-sort after
98
+ ranked ones, with filename as the within-bucket tiebreaker.
99
+ """
100
+ if not folder.is_dir():
101
+ return []
102
+ files = sorted(folder.glob("*.vbrief.json"))
103
+ vbriefs = []
104
+ for f in files:
105
+ try:
106
+ data = json.loads(f.read_text(encoding="utf-8"))
107
+ data["_source_file"] = f.name
108
+ vbriefs.append(data)
109
+ except (json.JSONDecodeError, OSError):
110
+ # Skip malformed files silently
111
+ continue
112
+ vbriefs.sort(key=_scope_rank_sort_key)
113
+ return vbriefs
114
+
115
+
116
+ def _extract_issue_refs(references: list[dict]) -> list[str]:
117
+ """Extract GitHub issue numbers from vBRIEF references entries.
118
+
119
+ Accepts both the canonical v0.6 shape ``{uri, type, title}`` (#613)
120
+ and the legacy ``{id}`` / ``{url}`` shapes so ROADMAP.md renders
121
+ correctly against mixed-shape worktrees during the migrator flip.
122
+ """
123
+ issues: list[str] = []
124
+ for ref in references:
125
+ if not isinstance(ref, dict):
126
+ continue
127
+ # Legacy shape: explicit ``#N`` in ``id``.
128
+ ref_id = ref.get("id", "")
129
+ if isinstance(ref_id, str) and ref_id.startswith("#"):
130
+ issues.append(ref_id)
131
+ continue
132
+ # Canonical v0.6 (``uri``) and legacy (``url``) both carry a
133
+ # ``/issues/{N}`` suffix when the reference points at a GitHub
134
+ # issue; check both so no issue number is silently dropped.
135
+ for key in ("uri", "url"):
136
+ url = ref.get(key, "")
137
+ if isinstance(url, str) and "/issues/" in url:
138
+ num = url.rstrip("/").rsplit("/", 1)[-1]
139
+ if num.isdigit():
140
+ issues.append(f"#{num}")
141
+ break
142
+ return issues
143
+
144
+
145
+ def _extract_phases(vbrief: dict) -> list[dict]:
146
+ """Extract phase-level items from a vBRIEF plan.
147
+
148
+ Each top-level plan.item is treated as a phase. SubItems within
149
+ are the individual work items within that phase.
150
+ """
151
+ plan = vbrief.get("plan", {})
152
+ if not isinstance(plan, dict):
153
+ return []
154
+ return plan.get("items", [])
155
+
156
+
157
+ def _read_edge_endpoints(edge: dict) -> tuple[str, str]:
158
+ """Read an edge's from/to endpoints, supporting both schema conventions.
159
+
160
+ Prefers schema-canonical ``from``/``to`` keys. Falls back to legacy
161
+ ``source``/``target`` keys when the canonical keys are absent. If both
162
+ forms are present on the same edge, ``from``/``to`` wins. See #458 for
163
+ rationale -- silent-empty dep maps occur when schema-compliant inputs
164
+ meet code that only reads one convention.
165
+ """
166
+ if not isinstance(edge, dict):
167
+ return "", ""
168
+ frm = edge.get("from") or edge.get("source", "") or ""
169
+ to = edge.get("to") or edge.get("target", "") or ""
170
+ return frm, to
171
+
172
+
173
+ def _build_edge_map(vbrief: dict) -> dict[str, list[str]]:
174
+ """Build a dependency map from plan.edges.
175
+
176
+ Returns dict mapping item id -> list of ids it depends on.
177
+ Edges are directional: ``{from, to}`` means ``to`` depends on ``from``,
178
+ or equivalently ``from`` must complete before ``to``. Legacy
179
+ ``{source, target}`` edges are read with the same semantics (see #458).
180
+ """
181
+ plan = vbrief.get("plan", {})
182
+ if not isinstance(plan, dict):
183
+ return {}
184
+ edges = plan.get("edges", [])
185
+ if not isinstance(edges, list):
186
+ return {}
187
+ dep_map: dict[str, list[str]] = {}
188
+ for edge in edges:
189
+ frm, to = _read_edge_endpoints(edge)
190
+ if frm and to:
191
+ dep_map.setdefault(to, []).append(frm)
192
+ return dep_map
193
+
194
+
195
+ def _topo_sort_items(items: list[dict], dep_map: dict[str, list[str]]) -> list[dict]:
196
+ """Sort items by dependency order using topological sort.
197
+
198
+ Items without dependencies come first. Falls back to original
199
+ order for items with equal dependency depth.
200
+ """
201
+ if not items:
202
+ return []
203
+
204
+ id_to_item = {item.get("id", f"_anon_{i}"): item for i, item in enumerate(items)}
205
+ item_ids = list(id_to_item.keys())
206
+
207
+ # Compute depth for each item (longest dependency chain)
208
+ depths: dict[str, int] = {}
209
+
210
+ def _depth(item_id: str, visited: set[str] | None = None) -> int:
211
+ if item_id in depths:
212
+ return depths[item_id]
213
+ if visited is None:
214
+ visited = set()
215
+ if item_id in visited:
216
+ # Cycle detected -- break it
217
+ return 0
218
+ visited.add(item_id)
219
+ deps = dep_map.get(item_id, [])
220
+ if not deps:
221
+ depths[item_id] = 0
222
+ return 0
223
+ in_scope_deps = [d for d in deps if d in id_to_item]
224
+ if not in_scope_deps:
225
+ depths[item_id] = 0
226
+ return 0
227
+ max_dep = max(_depth(d, visited) for d in in_scope_deps)
228
+ result = max_dep + 1
229
+ depths[item_id] = result
230
+ return result
231
+
232
+ for iid in item_ids:
233
+ _depth(iid)
234
+
235
+ # Stable sort: by depth first, then by original order
236
+ sorted_ids = sorted(item_ids, key=lambda x: (depths.get(x, 0), item_ids.index(x)))
237
+ return [id_to_item[iid] for iid in sorted_ids]
238
+
239
+
240
+ def _render_item(item: dict, dep_map: dict[str, list[str]], indent: int = 0) -> list[str]:
241
+ """Render a single item (and its subItems) as markdown lines."""
242
+ lines: list[str] = []
243
+ item_id = item.get("id", "")
244
+ title = item.get("title", "Untitled")
245
+ status = item.get("status", "")
246
+
247
+ # Build display line
248
+ prefix = " " * indent + "- "
249
+ parts = []
250
+ if item_id:
251
+ parts.append(f"**{item_id}**")
252
+ parts.append(title)
253
+ if status:
254
+ parts.append(f"`[{status}]`")
255
+
256
+ # Show dependencies
257
+ deps = dep_map.get(item_id, [])
258
+ if deps:
259
+ dep_str = ", ".join(sorted(deps))
260
+ parts.append(f"(depends on: {dep_str})")
261
+
262
+ lines.append(prefix + " -- ".join(parts))
263
+
264
+ # Render subItems recursively
265
+ sub_items = item.get("subItems", [])
266
+ if sub_items:
267
+ sorted_subs = _topo_sort_items(sub_items, dep_map)
268
+ for sub in sorted_subs:
269
+ lines.extend(_render_item(sub, dep_map, indent + 1))
270
+
271
+ return lines
272
+
273
+
274
+ def render_roadmap(
275
+ pending_dir: str,
276
+ out_path: str,
277
+ completed_dir: str | None = None,
278
+ ) -> tuple[bool, str]:
279
+ """Render ROADMAP.md from vBRIEF files in pending_dir and completed_dir.
280
+
281
+ Returns:
282
+ (True, message) on success.
283
+ (False, error_message) on failure.
284
+ """
285
+ try:
286
+ cd = Path(completed_dir) if completed_dir else None
287
+ content = generate_roadmap_content(Path(pending_dir), completed_dir=cd)
288
+ Path(out_path).write_text(content, encoding="utf-8")
289
+ return True, f"✓ Rendered ROADMAP.md to {out_path}"
290
+ except OSError as exc:
291
+ return False, f"✗ Failed to write {out_path}: {exc}"
292
+
293
+
294
+ # #616: migrator-internal provenance (Phase / Tier / PhaseDescription)
295
+ # now lives under ``plan.metadata['x-migrator']`` on scope vBRIEFs
296
+ # produced by ``task migrate:vbrief``. Scope vBRIEFs authored before the
297
+ # clamp still carry these keys under ``plan.narratives``, so the
298
+ # grouping helpers below consult BOTH locations: metadata wins when
299
+ # present (new shape), narratives are the fallback (legacy / hand-
300
+ # authored files).
301
+ _MIGRATOR_METADATA_KEY: str = "x-migrator"
302
+
303
+
304
+ def _migrator_metadata(plan: dict) -> dict:
305
+ """Return ``plan.metadata['x-migrator']`` as a dict (empty if absent)."""
306
+ metadata = plan.get("metadata", {}) if isinstance(plan, dict) else {}
307
+ if not isinstance(metadata, dict):
308
+ return {}
309
+ bucket = metadata.get(_MIGRATOR_METADATA_KEY, {})
310
+ if not isinstance(bucket, dict):
311
+ return {}
312
+ return bucket
313
+
314
+
315
+ def _migrator_field(plan: dict, key: str) -> str:
316
+ """Return ``plan.metadata['x-migrator'][key]`` falling back to ``plan.narratives[key]``.
317
+
318
+ Returns the empty string if neither location carries a non-empty
319
+ string value. Used for Phase / Tier / PhaseDescription lookups that
320
+ were relocated from narratives to metadata in #616 while keeping
321
+ backwards compatibility with legacy scope vBRIEFs.
322
+ """
323
+ bucket = _migrator_metadata(plan)
324
+ value = bucket.get(key, "")
325
+ if isinstance(value, str) and value:
326
+ return value
327
+ narratives = plan.get("narratives", {}) if isinstance(plan, dict) else {}
328
+ if isinstance(narratives, dict):
329
+ fallback = narratives.get(key, "")
330
+ if isinstance(fallback, str):
331
+ return fallback
332
+ return ""
333
+
334
+
335
+ # #641: ``task roadmap:render`` previously preserved insertion order
336
+ # of phase labels as they were first encountered while scanning
337
+ # ``vbrief/pending/``. That made the rendered section order depend on
338
+ # file-discovery / glob order rather than numeric phase, so ROADMAP.md
339
+ # could render Phase 6 before Phase 1. ``_PHASE_NUMBER_RE`` and
340
+ # ``_phase_sort_key`` give us a deterministic numeric-first ordering
341
+ # (Phase 1, Phase 2, ...) with non-numbered groups (e.g. "Ungrouped")
342
+ # sorted alphabetically AFTER all numbered phases.
343
+ _PHASE_NUMBER_RE: re.Pattern[str] = re.compile(r"^Phase\s+(\d+)\b")
344
+
345
+
346
+ def _phase_sort_key(phase_name: str) -> tuple[int, int, str]:
347
+ """Sort key for phase group names.
348
+
349
+ Numbered phases (``^Phase\\s+(\\d+)\\b``) sort FIRST in ascending
350
+ numeric order (tuple slot 0 == 0). Non-numbered phase labels (e.g.
351
+ ``Ungrouped``, ``Backlog``, ``Completed``) sort AFTER all numbered
352
+ phases (tuple slot 0 == 1), then alphabetically by label.
353
+
354
+ The numeric slot is unused for non-numbered phases (set to 0) so the
355
+ alphabetical tiebreaker comes from slot 2. Fixes #641.
356
+ """
357
+ match = _PHASE_NUMBER_RE.match(phase_name)
358
+ if match is not None:
359
+ return (0, int(match.group(1)), phase_name)
360
+ return (1, 0, phase_name)
361
+
362
+
363
+ def _sorted_phase_names(phase_names: list[str]) -> list[str]:
364
+ """Return phase names sorted by ``_phase_sort_key`` (#641).
365
+
366
+ Numbered phases (``Phase 1``, ``Phase 2``, ...) come first in
367
+ ascending numeric order; non-numbered groups come after in
368
+ alphabetical order. Duplicates are preserved (caller responsibility).
369
+ """
370
+ return sorted(phase_names, key=_phase_sort_key)
371
+
372
+
373
+ def _group_by_phase(
374
+ vbriefs: list[dict],
375
+ ) -> tuple[dict[str, list[dict]], dict[str, str]]:
376
+ """Group flat scope vBRIEFs by their Phase label.
377
+
378
+ Reads Phase / PhaseDescription from ``plan.metadata['x-migrator']``
379
+ first (canonical post-#616 location) and falls back to
380
+ ``plan.narratives`` for legacy / hand-authored vBRIEFs.
381
+
382
+ Phase groups are returned in numeric-first ascending order: any
383
+ ``^Phase\\s+(\\d+)\\b`` label sorts by the parsed integer, and
384
+ non-numbered groups (e.g. ``Ungrouped``) sort after all numbered
385
+ phases in alphabetical order. This replaces the previous
386
+ insertion-order behaviour that depended on file-discovery order
387
+ (#641).
388
+
389
+ Returns:
390
+ - phase_groups: dict of phase -> list of vBRIEFs, keys iterated
391
+ in numeric-phase-first order
392
+ - phase_descriptions: dict of phase -> PhaseDescription
393
+ """
394
+ # First pass: accumulate in insertion order so per-phase vBRIEF
395
+ # order is still filename-sorted (from ``_load_vbriefs``).
396
+ insertion_groups: dict[str, list[dict]] = {}
397
+ phase_descriptions: dict[str, str] = {}
398
+
399
+ for vb in vbriefs:
400
+ plan = vb.get("plan", {})
401
+ if not isinstance(plan, dict):
402
+ continue
403
+ phase = _migrator_field(plan, "Phase") or "Ungrouped"
404
+ insertion_groups.setdefault(phase, []).append(vb)
405
+ # Capture phase description from the first vBRIEF that has one
406
+ if phase not in phase_descriptions:
407
+ pd = _migrator_field(plan, "PhaseDescription")
408
+ if pd:
409
+ phase_descriptions[phase] = pd
410
+
411
+ # Second pass: rebuild the dict in sorted phase order so downstream
412
+ # iteration (e.g. ``generate_roadmap_content``) emits ``## Phase 1``
413
+ # before ``## Phase 2`` before ``## Ungrouped`` (#641).
414
+ phase_groups: dict[str, list[dict]] = {
415
+ name: insertion_groups[name]
416
+ for name in _sorted_phase_names(list(insertion_groups.keys()))
417
+ }
418
+
419
+ return phase_groups, phase_descriptions
420
+
421
+
422
+ def _group_by_tier(vbriefs: list[dict]) -> dict[str, list[dict]]:
423
+ """Group vBRIEFs by their Tier label within a phase (bilingual reader)."""
424
+ tier_groups: dict[str, list[dict]] = {}
425
+ for vb in vbriefs:
426
+ plan = vb.get("plan", {})
427
+ if not isinstance(plan, dict):
428
+ continue
429
+ tier = _migrator_field(plan, "Tier")
430
+ tier_groups.setdefault(tier, []).append(vb)
431
+ return tier_groups
432
+
433
+
434
+ def _render_scope_item(vbrief_data: dict) -> list[str]:
435
+ """Render a single scope vBRIEF as a markdown list item."""
436
+ plan = vbrief_data.get("plan", {})
437
+ if not isinstance(plan, dict):
438
+ return []
439
+ title = plan.get("title", "Untitled")
440
+ status = plan.get("status", "")
441
+ references = plan.get("references", [])
442
+ if not isinstance(references, list):
443
+ references = []
444
+ issue_refs = _extract_issue_refs(references)
445
+
446
+ parts = []
447
+ if issue_refs:
448
+ parts.append(f"**{issue_refs[0]}**")
449
+ parts.append(title)
450
+ if status and status != "pending":
451
+ parts.append(f"`[{status}]`")
452
+
453
+ return ["- " + " -- ".join(parts)]
454
+
455
+
456
+ def generate_roadmap_content(
457
+ pending_dir: Path,
458
+ completed_dir: Path | None = None,
459
+ ) -> str:
460
+ """Generate the ROADMAP.md content string from pending/ and completed/ vBRIEFs.
461
+
462
+ This is the pure function used by both render and drift check.
463
+ Groups scope vBRIEFs by Phase narrative key, renders phase headings
464
+ with descriptions, includes tier subgroupings, and appends a Completed
465
+ section from completed/ folder.
466
+ """
467
+ vbriefs = _load_vbriefs(pending_dir)
468
+
469
+ # Infer completed_dir from pending_dir if not provided
470
+ if completed_dir is None:
471
+ completed_dir = pending_dir.parent / "completed"
472
+ completed_vbriefs = _load_vbriefs(completed_dir)
473
+
474
+ lines: list[str] = [BANNER, "# Roadmap\n"]
475
+
476
+ if not vbriefs and not completed_vbriefs:
477
+ lines.append("No pending work items.\n")
478
+ return "\n".join(lines) + "\n"
479
+
480
+ # Check if any vBRIEFs use the flat scope model. Post-#616 the
481
+ # migrator writes Phase to ``plan.metadata['x-migrator']``; legacy
482
+ # vBRIEFs still carry it under ``plan.narratives``. Accept either so
483
+ # a mid-transition repo renders consistently.
484
+ has_phase_narratives = any(
485
+ _migrator_field(vb.get("plan", {}), "Phase")
486
+ for vb in vbriefs
487
+ if isinstance(vb.get("plan", {}), dict)
488
+ )
489
+
490
+ if has_phase_narratives:
491
+ # --- Flat scope vBRIEFs grouped by Phase narrative ---
492
+ phase_groups, phase_descs = _group_by_phase(vbriefs)
493
+
494
+ for phase_name, phase_vbriefs in phase_groups.items():
495
+ lines.append(f"## {phase_name}\n")
496
+ desc = phase_descs.get(phase_name, "")
497
+ if desc:
498
+ lines.append(f"{desc}\n")
499
+
500
+ # Group by tier within phase
501
+ tier_groups = _group_by_tier(phase_vbriefs)
502
+ has_tiers = any(t for t in tier_groups if t)
503
+
504
+ if has_tiers:
505
+ # Render named tiers first, then untiered items
506
+ untiered = tier_groups.pop("", [])
507
+ for tier_name, tier_vbs in tier_groups.items():
508
+ lines.append(f"### {tier_name}\n")
509
+ for vb in tier_vbs:
510
+ lines.extend(_render_scope_item(vb))
511
+ lines.append("")
512
+ if untiered:
513
+ for vb in untiered:
514
+ lines.extend(_render_scope_item(vb))
515
+ lines.append("")
516
+ else:
517
+ for vb in phase_vbriefs:
518
+ lines.extend(_render_scope_item(vb))
519
+ lines.append("")
520
+ else:
521
+ # --- Hierarchical vBRIEFs (original behavior) ---
522
+ for vbrief in vbriefs:
523
+ plan = vbrief.get("plan", {})
524
+ if not isinstance(plan, dict):
525
+ continue
526
+
527
+ plan_title = plan.get("title", "Untitled")
528
+ references = plan.get("references", [])
529
+ if not isinstance(references, list):
530
+ references = []
531
+ issue_refs = _extract_issue_refs(references)
532
+
533
+ title_parts = [f"## {plan_title}"]
534
+ if issue_refs:
535
+ title_parts.append(f"({', '.join(issue_refs)})")
536
+ lines.append(" ".join(title_parts) + "\n")
537
+
538
+ narratives = plan.get("narratives", {})
539
+ if isinstance(narratives, dict):
540
+ overview = narratives.get("Overview", "")
541
+ if overview:
542
+ lines.append(f"{overview}\n")
543
+
544
+ dep_map = _build_edge_map(vbrief)
545
+ phases = _extract_phases(vbrief)
546
+ sorted_phases = _topo_sort_items(phases, dep_map)
547
+
548
+ for phase in sorted_phases:
549
+ phase_id = phase.get("id", "")
550
+ phase_title = phase.get("title", "Untitled Phase")
551
+ phase_status = phase.get("status", "")
552
+
553
+ heading_parts = []
554
+ if phase_id:
555
+ heading_parts.append(f"### {phase_id}: {phase_title}")
556
+ else:
557
+ heading_parts.append(f"### {phase_title}")
558
+ if phase_status:
559
+ heading_parts[0] += f" `[{phase_status}]`"
560
+ lines.append(heading_parts[0] + "\n")
561
+
562
+ narrative = phase.get("narrative", {})
563
+ if isinstance(narrative, dict):
564
+ for key, val in narrative.items():
565
+ if key not in ("Traces", "Acceptance"):
566
+ lines.append(f"{val}\n")
567
+
568
+ sub_items = phase.get("subItems", [])
569
+ if sub_items:
570
+ sorted_subs = _topo_sort_items(sub_items, dep_map)
571
+ for item in sorted_subs:
572
+ lines.extend(_render_item(item, dep_map))
573
+ lines.append("")
574
+
575
+ lines.append("---\n")
576
+
577
+ # --- Completed section ---
578
+ if completed_vbriefs:
579
+ lines.append("## Completed\n")
580
+ for vb in completed_vbriefs:
581
+ lines.extend(_render_scope_item(vb))
582
+ lines.append("")
583
+
584
+ return "\n".join(lines) + "\n"
585
+
586
+
587
+ def check_drift(pending_dir: str, roadmap_path: str) -> tuple[bool, str]:
588
+ """Check if ROADMAP.md matches what roadmap:render would produce.
589
+
590
+ Returns:
591
+ (True, message) if ROADMAP.md is up to date.
592
+ (False, message) if ROADMAP.md has drifted.
593
+ """
594
+ expected = generate_roadmap_content(Path(pending_dir))
595
+ roadmap = Path(roadmap_path)
596
+
597
+ if not roadmap.exists():
598
+ # If no ROADMAP.md exists and we'd generate empty content, that's OK
599
+ pending_p = Path(pending_dir)
600
+ inferred_completed = pending_p.parent / "completed"
601
+ has_pending = pending_p.is_dir() and list(
602
+ pending_p.glob("*.vbrief.json")
603
+ )
604
+ has_completed = inferred_completed.is_dir() and list(
605
+ inferred_completed.glob("*.vbrief.json")
606
+ )
607
+ if not has_pending and not has_completed:
608
+ return True, "✓ No ROADMAP.md needed (no pending or completed vBRIEFs)"
609
+ return False, "✗ ROADMAP.md does not exist but vBRIEFs found"
610
+
611
+ actual = roadmap.read_text(encoding="utf-8")
612
+ if actual == expected:
613
+ return True, "✓ ROADMAP.md is up to date"
614
+ return False, "✗ ROADMAP.md has drifted from pending/ vBRIEFs -- run: task roadmap:render"
615
+
616
+
617
+ def main() -> int:
618
+ """CLI entry point."""
619
+ positional = [a for a in sys.argv[1:] if not a.startswith("--")]
620
+ pending_dir = positional[0] if len(positional) >= 1 else str(Path.cwd() / "vbrief" / "pending")
621
+ out_path = positional[1] if len(positional) >= 2 else str(Path.cwd() / "ROADMAP.md")
622
+
623
+ # Check for --check flag
624
+ if "--check" in sys.argv:
625
+ ok, msg = check_drift(pending_dir, out_path)
626
+ print(msg)
627
+ return 0 if ok else 1
628
+
629
+ ok, msg = render_roadmap(pending_dir, out_path)
630
+ print(msg)
631
+ return 0 if ok else 1
632
+
633
+
634
+ if __name__ == "__main__":
635
+ sys.exit(main())