@deftai/directive-content 0.58.0 → 0.60.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 (187) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +57 -67
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/rules/rules-pack-0.1.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +22 -22
  10. package/scm/github.md +20 -2
  11. package/tasks/change.yml +16 -31
  12. package/tasks/ci.yml +8 -0
  13. package/tasks/commit.yml +12 -19
  14. package/tasks/core.yml +10 -0
  15. package/tasks/engine.yml +42 -0
  16. package/tasks/framework.yml +3 -0
  17. package/tasks/install.yml +20 -19
  18. package/tasks/migrate.yml +26 -15
  19. package/tasks/project.yml +16 -0
  20. package/tasks/relocate.yml +18 -48
  21. package/tasks/toolchain.yml +15 -5
  22. package/tasks/vbrief.yml +4 -3
  23. package/tasks/verify.yml +12 -14
  24. package/templates/agents-entry.md +1 -2
  25. package/scripts/_agents_md.py +0 -494
  26. package/scripts/_cache_fetch.py +0 -635
  27. package/scripts/_cache_quota.py +0 -529
  28. package/scripts/_cache_refresh.py +0 -163
  29. package/scripts/_cache_validate.py +0 -209
  30. package/scripts/_content_root.py +0 -42
  31. package/scripts/_doctor_state.py +0 -277
  32. package/scripts/_event_detect.py +0 -305
  33. package/scripts/_events.py +0 -514
  34. package/scripts/_lifecycle_hygiene.py +0 -568
  35. package/scripts/_pathspec.py +0 -91
  36. package/scripts/_policy_show_cli.py +0 -266
  37. package/scripts/_precutover.py +0 -92
  38. package/scripts/_project_context.py +0 -224
  39. package/scripts/_project_definition_io.py +0 -164
  40. package/scripts/_relocate_snapshot.py +0 -209
  41. package/scripts/_relocate_states.py +0 -343
  42. package/scripts/_resolve_preflight_path.py +0 -152
  43. package/scripts/_safe_subprocess.py +0 -167
  44. package/scripts/_session_start_hook.py +0 -205
  45. package/scripts/_sor_gate_diff.py +0 -365
  46. package/scripts/_stdio_utf8.py +0 -59
  47. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  48. package/scripts/_triage_classify_cli.py +0 -122
  49. package/scripts/_triage_queue_cli.py +0 -625
  50. package/scripts/_triage_scope_cli.py +0 -343
  51. package/scripts/_triage_scope_drift_cli.py +0 -121
  52. package/scripts/_triage_scope_ignores.py +0 -286
  53. package/scripts/_triage_scope_milestone.py +0 -432
  54. package/scripts/_triage_scope_mutations.py +0 -337
  55. package/scripts/_triage_scope_renderers.py +0 -207
  56. package/scripts/_triage_smoketest_stages.py +0 -674
  57. package/scripts/_triage_subscribe_cli.py +0 -140
  58. package/scripts/_triage_welcome_cli.py +0 -421
  59. package/scripts/_vbrief_build.py +0 -239
  60. package/scripts/_vbrief_fidelity.py +0 -479
  61. package/scripts/_vbrief_legacy.py +0 -589
  62. package/scripts/_vbrief_reconciliation.py +0 -883
  63. package/scripts/_vbrief_routing.py +0 -277
  64. package/scripts/_vbrief_safety.py +0 -778
  65. package/scripts/_vbrief_sources.py +0 -312
  66. package/scripts/_vbrief_speckit.py +0 -262
  67. package/scripts/_vbrief_story_quality.py +0 -353
  68. package/scripts/_vbrief_validation.py +0 -299
  69. package/scripts/build_dist.py +0 -412
  70. package/scripts/cache.py +0 -1078
  71. package/scripts/cache_scanner.py +0 -745
  72. package/scripts/candidates_log.py +0 -432
  73. package/scripts/capacity_backfill.py +0 -680
  74. package/scripts/capacity_show.py +0 -653
  75. package/scripts/ci_local.py +0 -689
  76. package/scripts/code_structure_validate.py +0 -765
  77. package/scripts/codebase_default_extractor.py +0 -495
  78. package/scripts/codebase_map.py +0 -304
  79. package/scripts/codebase_map_fresh.py +0 -104
  80. package/scripts/codebase_projection_registry.py +0 -94
  81. package/scripts/codebase_provider.py +0 -582
  82. package/scripts/doctor.py +0 -2551
  83. package/scripts/framework_commands.py +0 -505
  84. package/scripts/gh_rest.py +0 -882
  85. package/scripts/github_auth_modes.py +0 -437
  86. package/scripts/github_body.py +0 -292
  87. package/scripts/ip_risk.py +0 -531
  88. package/scripts/issue_emit.py +0 -670
  89. package/scripts/issue_ingest.py +0 -1064
  90. package/scripts/migrate_preflight.py +0 -418
  91. package/scripts/migrate_vbrief.py +0 -2677
  92. package/scripts/monitor_pr.py +0 -401
  93. package/scripts/pack_migrate_lessons.py +0 -336
  94. package/scripts/pack_migrate_patterns.py +0 -254
  95. package/scripts/pack_migrate_rules.py +0 -350
  96. package/scripts/pack_migrate_skills.py +0 -423
  97. package/scripts/pack_migrate_strategies.py +0 -311
  98. package/scripts/pack_migrate_swarm_spec.py +0 -250
  99. package/scripts/pack_render.py +0 -434
  100. package/scripts/packs_slice.py +0 -712
  101. package/scripts/platform_capabilities.py +0 -336
  102. package/scripts/policy.py +0 -2826
  103. package/scripts/policy_set.py +0 -324
  104. package/scripts/pr_check_closing_keywords.py +0 -524
  105. package/scripts/pr_check_protected_issues.py +0 -267
  106. package/scripts/pr_merge_readiness.py +0 -1004
  107. package/scripts/pr_wait_mergeable.py +0 -669
  108. package/scripts/prd_render.py +0 -159
  109. package/scripts/preflight_architecture_sor.py +0 -974
  110. package/scripts/preflight_branch.py +0 -289
  111. package/scripts/preflight_cache.py +0 -974
  112. package/scripts/preflight_gh.py +0 -721
  113. package/scripts/preflight_implementation.py +0 -272
  114. package/scripts/preflight_story_start.py +0 -838
  115. package/scripts/preflight_wip_cap.py +0 -149
  116. package/scripts/probe_session.py +0 -545
  117. package/scripts/project_render.py +0 -293
  118. package/scripts/quarantine_ext.py +0 -237
  119. package/scripts/reconcile_issues.py +0 -1442
  120. package/scripts/refresh-path.ps1 +0 -107
  121. package/scripts/release.py +0 -2030
  122. package/scripts/release_e2e.py +0 -1011
  123. package/scripts/release_publish.py +0 -486
  124. package/scripts/release_rollback.py +0 -980
  125. package/scripts/relocate.py +0 -1034
  126. package/scripts/resolve_changelog_unreleased.py +0 -667
  127. package/scripts/resolve_version.py +0 -490
  128. package/scripts/resume_conditions.py +0 -706
  129. package/scripts/ritual_sentinel.py +0 -609
  130. package/scripts/roadmap_render.py +0 -635
  131. package/scripts/rule_ownership_lint.py +0 -325
  132. package/scripts/scm.py +0 -591
  133. package/scripts/scope_audit_log.py +0 -387
  134. package/scripts/scope_decompose.py +0 -654
  135. package/scripts/scope_demote.py +0 -509
  136. package/scripts/scope_lifecycle.py +0 -1126
  137. package/scripts/scope_undo.py +0 -772
  138. package/scripts/session_start.py +0 -406
  139. package/scripts/setup_ghx.py +0 -339
  140. package/scripts/setup_windows.ps1 +0 -220
  141. package/scripts/slice_audit.py +0 -585
  142. package/scripts/slice_record.py +0 -530
  143. package/scripts/slice_record_existing.py +0 -692
  144. package/scripts/slug_normalize.py +0 -178
  145. package/scripts/spec_render.py +0 -477
  146. package/scripts/spec_validate.py +0 -238
  147. package/scripts/subagent_monitor.py +0 -658
  148. package/scripts/swarm_complete_cohort.py +0 -644
  149. package/scripts/swarm_launch.py +0 -1206
  150. package/scripts/swarm_readiness.py +0 -554
  151. package/scripts/swarm_verify_review_clean.py +0 -438
  152. package/scripts/swarm_worktrees.py +0 -497
  153. package/scripts/toolchain-check.py +0 -52
  154. package/scripts/triage_actions.py +0 -871
  155. package/scripts/triage_bootstrap.py +0 -1153
  156. package/scripts/triage_bulk.py +0 -630
  157. package/scripts/triage_classify.py +0 -932
  158. package/scripts/triage_help.py +0 -1685
  159. package/scripts/triage_queue.py +0 -1944
  160. package/scripts/triage_reconcile.py +0 -581
  161. package/scripts/triage_refresh.py +0 -643
  162. package/scripts/triage_scope.py +0 -999
  163. package/scripts/triage_scope_drift.py +0 -575
  164. package/scripts/triage_smoketest.py +0 -396
  165. package/scripts/triage_subscribe.py +0 -399
  166. package/scripts/triage_summary.py +0 -1011
  167. package/scripts/triage_welcome.py +0 -1178
  168. package/scripts/ts_check_lane.py +0 -86
  169. package/scripts/validate-links.py +0 -64
  170. package/scripts/validate_strategy_output.py +0 -212
  171. package/scripts/vbrief_activate.py +0 -228
  172. package/scripts/vbrief_migrate_conformance.py +0 -368
  173. package/scripts/vbrief_reconcile_graph.py +0 -306
  174. package/scripts/vbrief_reconcile_labels.py +0 -460
  175. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  176. package/scripts/vbrief_validate.py +0 -1144
  177. package/scripts/verify-stubs.py +0 -61
  178. package/scripts/verify_capacity.py +0 -160
  179. package/scripts/verify_encoding.py +0 -699
  180. package/scripts/verify_hooks_installed.py +0 -206
  181. package/scripts/verify_investigation.py +0 -360
  182. package/scripts/verify_judgment_gates.py +0 -827
  183. package/scripts/verify_no_task_runtime.py +0 -171
  184. package/scripts/verify_scm_boundary.py +0 -509
  185. package/scripts/verify_session_ritual.py +0 -389
  186. package/scripts/verify_tools.py +0 -426
  187. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,635 +0,0 @@
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())