@deftai/directive-content 0.59.0 → 0.61.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.
- package/.githooks/pre-commit +10 -128
- package/.githooks/pre-push +8 -108
- package/Taskfile.yml +48 -58
- package/UPGRADING.md +19 -3
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/skills/skills-pack-0.1.json +1 -1
- package/packs/strategies/strategies-pack-0.1.json +19 -19
- package/scm/github.md +37 -6
- package/skills/deft-directive-setup/SKILL.md +24 -15
- package/strategies/speckit.md +14 -14
- package/strategies/v0-20-contract.md +12 -1
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +26 -0
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -1
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2552
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- 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())
|