@deftai/directive-content 0.55.1 → 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 (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,178 @@
1
+ """slug_normalize.py -- canonical slug normalization for scope vBRIEF filenames (#532).
2
+
3
+ Single source of truth for the rules laid out in issue #532. Used by
4
+ ``scripts/migrate_vbrief.py`` when generating scope vBRIEF filenames so the
5
+ migrator and any future skill / helper that creates scope vBRIEFs all agree
6
+ on how ``YYYY-MM-DD-<slug>.vbrief.json`` slugs are derived.
7
+
8
+ Rules (per #532 Suggested normalization rules):
9
+
10
+ 1. Normalize Unicode to NFKD; strip combining marks; drop non-ASCII.
11
+ 2. Lowercase the entire result.
12
+ 3. Strip common Markdown checkbox markers (``[x]``, ``[ ]``) before the
13
+ punctuation pass so they do not leak into the slug as a literal ``x``.
14
+ 4. Replace any run of ``[^a-z0-9]+`` with a single hyphen.
15
+ 5. Strip leading and trailing hyphens.
16
+ 6. Truncate at word boundaries at or before ``max_len`` (default 60). If the
17
+ next character after the cut is inside a word, backtrack to the most
18
+ recent hyphen provided that hyphen is past ``max_len // 2``.
19
+ 7. Empty-slug fallback: return ``"untitled"`` when normalization produces
20
+ an empty string.
21
+ 8. Reserved names: if the slug equals a Windows reserved name (``con``,
22
+ ``prn``, ``aux``, ``nul``, ``com1``-``com9``, ``lpt1``-``lpt9``), append
23
+ ``-scope``.
24
+
25
+ Collision handling: :func:`disambiguate_slug` appends ``-2``, ``-3``, ... to
26
+ the normalized slug until the candidate is not in the supplied ``existing``
27
+ set. Callers typically pass a set pre-populated with stems from existing
28
+ lifecycle-folder files.
29
+
30
+ This module intentionally has no dependency on the rest of the migrator so
31
+ future skills (refinement, setup) can import it without dragging the full
32
+ migration surface.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ import unicodedata
39
+
40
+ __all__ = [
41
+ "WINDOWS_RESERVED",
42
+ "DEFAULT_MAX_LEN",
43
+ "normalize_slug",
44
+ "disambiguate_slug",
45
+ ]
46
+
47
+ # Windows-reserved filename stems (case-insensitive). Matching is performed on
48
+ # the fully normalized slug so ``CON`` -> ``con`` is rejected just like ``con``.
49
+ WINDOWS_RESERVED: frozenset[str] = frozenset(
50
+ {"con", "prn", "aux", "nul"}
51
+ | {f"com{i}" for i in range(1, 10)}
52
+ | {f"lpt{i}" for i in range(1, 10)}
53
+ )
54
+
55
+ # Default body length per #532. Shorter than the historic ~80 char ceiling so
56
+ # the final ``YYYY-MM-DD-<slug>.vbrief.json`` filename stays well within
57
+ # Windows path limits for deeply nested worktrees.
58
+ DEFAULT_MAX_LEN: int = 60
59
+
60
+ # Match a leading checkbox marker at a word boundary: ``[x]``, ``[X]``, ``[ ]``.
61
+ # We deliberately only strip leading markers (or those preceded by whitespace)
62
+ # rather than anywhere in the string, so a legitimate ``[x]`` inside a sentence
63
+ # like ``add [x]-axis scaling`` is not mangled.
64
+ _CHECKBOX_RE = re.compile(r"(?:(?<=^)|(?<=\s))\[[ xX]\]")
65
+
66
+
67
+ def normalize_slug(text: str, max_len: int = DEFAULT_MAX_LEN) -> str:
68
+ """Return a filesystem-safe, deterministic slug for ``text``.
69
+
70
+ See module docstring for the full rule list.
71
+
72
+ Parameters
73
+ ----------
74
+ text:
75
+ Free-form input -- typically a GitHub issue title, ROADMAP line, or
76
+ spec task body. ``None`` and empty strings return ``"untitled"``.
77
+ max_len:
78
+ Hard ceiling on the returned body length. Default 60. Values less
79
+ than 1 fall back to ``DEFAULT_MAX_LEN`` so callers cannot accidentally
80
+ truncate the slug away entirely.
81
+
82
+ Returns
83
+ -------
84
+ str
85
+ A slug matching ``^[a-z0-9]+(-[a-z0-9]+)*$`` with length <= ``max_len``.
86
+ """
87
+ if not text:
88
+ return "untitled"
89
+ if max_len < 1:
90
+ max_len = DEFAULT_MAX_LEN
91
+
92
+ # 1. Unicode NFKD, drop combining marks, drop non-ASCII.
93
+ decomposed = unicodedata.normalize("NFKD", text)
94
+ ascii_only = "".join(
95
+ ch for ch in decomposed if not unicodedata.combining(ch)
96
+ )
97
+ ascii_only = ascii_only.encode("ascii", "ignore").decode("ascii")
98
+
99
+ # 2. Lowercase.
100
+ lowered = ascii_only.lower()
101
+
102
+ # 3. Strip checkbox markers before the punctuation pass so ``[x]`` does
103
+ # not leak into the slug as a literal ``x``.
104
+ stripped = _CHECKBOX_RE.sub(" ", lowered)
105
+
106
+ # 4. Collapse non-alphanumeric runs to a single hyphen.
107
+ hyphenated = re.sub(r"[^a-z0-9]+", "-", stripped)
108
+
109
+ # 5. Strip leading/trailing hyphens.
110
+ trimmed = hyphenated.strip("-")
111
+
112
+ # 6. Truncate at word boundaries at or before max_len.
113
+ if len(trimmed) > max_len:
114
+ truncated = trimmed[:max_len]
115
+ # If we cut mid-word, backtrack to the most recent hyphen provided
116
+ # that hyphen is past max_len // 2 -- otherwise the slug collapses
117
+ # too aggressively for short limits.
118
+ if trimmed[max_len] not in "-":
119
+ last_hyphen = truncated.rfind("-")
120
+ if last_hyphen > max_len // 2:
121
+ truncated = truncated[:last_hyphen]
122
+ trimmed = truncated.rstrip("-")
123
+
124
+ # 7. Empty-after-normalization fallback.
125
+ if not trimmed:
126
+ return "untitled"
127
+
128
+ # 8. Windows reserved names.
129
+ if trimmed in WINDOWS_RESERVED:
130
+ return f"{trimmed}-scope"
131
+
132
+ return trimmed
133
+
134
+
135
+ def disambiguate_slug(
136
+ slug: str,
137
+ existing: set[str] | frozenset[str],
138
+ *,
139
+ max_len: int = DEFAULT_MAX_LEN,
140
+ ) -> str:
141
+ """Return a collision-free variant of ``slug`` relative to ``existing``.
142
+
143
+ Appends ``-2``, ``-3``, ... to ``slug`` until the candidate is not in
144
+ ``existing``. The suffix always respects ``max_len`` by truncating the
145
+ body portion when necessary so the final slug remains within the ceiling.
146
+
147
+ The function does NOT mutate ``existing``; callers record the returned
148
+ value themselves once it is adopted.
149
+ """
150
+ if slug not in existing:
151
+ return slug
152
+
153
+ base = slug
154
+ n = 2
155
+ while True:
156
+ suffix = f"-{n}"
157
+ candidate = base + suffix
158
+ if len(candidate) > max_len:
159
+ # Trim the base to make room for the suffix; rstrip hyphens so we
160
+ # do not produce e.g. ``foo--2``.
161
+ body_budget = max_len - len(suffix)
162
+ if body_budget < 1:
163
+ # Pathological short max_len -- just return the base + suffix;
164
+ # caller's filesystem handling will still reject if absurd.
165
+ body_budget = 1
166
+ trimmed = base[:body_budget].rstrip("-") or base[:body_budget]
167
+ candidate = trimmed + suffix
168
+ if candidate not in existing:
169
+ return candidate
170
+ n += 1
171
+ # Guard against runaway loops on pathological inputs (e.g. ``existing``
172
+ # that contains every integer suffix). 10_000 is well above any
173
+ # reasonable real-world collision depth.
174
+ if n > 10_000:
175
+ raise RuntimeError(
176
+ f"disambiguate_slug: unable to resolve collision for {slug!r} "
177
+ f"after {n} attempts"
178
+ )
@@ -0,0 +1,477 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ spec_render.py — Render a vbrief specification JSON file to SPECIFICATION.md.
4
+
5
+ Usage:
6
+ uv run python scripts/spec_render.py <spec_file> [out_file] [--include-scopes=on|off]
7
+
8
+ spec_file — path to vbrief/specification.vbrief.json
9
+ out_file — output path (default: <spec_file's grandparent>/SPECIFICATION.md)
10
+ --include-scopes=on|off
11
+ — include (default) or exclude the Implementation Plan section
12
+ aggregated from vbrief/{pending,active,completed} scope vBRIEFs (#435)
13
+
14
+ Exit codes:
15
+ 0 — rendered successfully
16
+ 1 — validation failed or status not in ('approved', 'running', 'completed')
17
+ 2 — usage error (no argument provided)
18
+
19
+ Implementation: IMPLEMENTATION.md Phase 5.2; lifecycle aggregator per #435.
20
+ """
21
+
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ # Allow co-located import of spec_validate when run as a script
27
+ sys.path.insert(0, str(Path(__file__).parent))
28
+ from _stdio_utf8 import reconfigure_stdio # noqa: E402
29
+ from spec_validate import validate_spec # noqa: E402
30
+
31
+ # UTF-8 stdout guard (#540).
32
+ reconfigure_stdio()
33
+
34
+ # Declared narrative ordering for SPECIFICATION.md. Covers both the
35
+ # interview/light key set (Overview, ProblemStatement, Goals, UserStories,
36
+ # Requirements, SuccessMetrics, Architecture) and the speckit Phase 2/3 key
37
+ # set (EdgeCases, TechDecisions, ImplementationPhases, PreImplementationGates).
38
+ # Narratives present on the spec render in this declared order; any other
39
+ # narrative keys render after these, sorted alphabetically. See #434.
40
+ SPECIFICATION_NARRATIVE_KEY_ORDER = [
41
+ "Overview",
42
+ "ProblemStatement",
43
+ "Goals",
44
+ "UserStories",
45
+ "Requirements",
46
+ "SuccessMetrics",
47
+ "EdgeCases",
48
+ "Architecture",
49
+ "TechDecisions",
50
+ "ImplementationPhases",
51
+ "PreImplementationGates",
52
+ ]
53
+
54
+ # Lifecycle folders walked by the scope aggregator, in render order (#435).
55
+ LIFECYCLE_BUCKETS: tuple[tuple[str, str], ...] = (
56
+ ("pending", "Pending"),
57
+ ("active", "Active"),
58
+ ("completed", "Completed"),
59
+ )
60
+
61
+ # Canonical 4-line machine-generated banner per
62
+ # ``conventions/machine-generated-banner.md`` (#572). spec:render
63
+ # previously emitted no banner, so downstream heuristics could not
64
+ # distinguish a rendered specification from a hand-authored one -- a
65
+ # correctness gap. Now every rendered SPECIFICATION.md opens with the
66
+ # same four lines as prd:render / roadmap:render.
67
+ _BANNER = (
68
+ "<!-- AUTO-GENERATED by task spec:render -- DO NOT EDIT MANUALLY -->\n"
69
+ "<!-- Purpose: rendered specification -->\n"
70
+ "<!-- Source of truth: vbrief/specification.vbrief.json -->\n"
71
+ "<!-- Regenerate with: task spec:render -->\n"
72
+ )
73
+
74
+ # Narrative key resolution order used when rendering a scope's summary line.
75
+ _SCOPE_SUMMARY_NARRATIVES = (
76
+ "Overview",
77
+ "Summary",
78
+ "Description",
79
+ "ProblemStatement",
80
+ "Problem",
81
+ "Outcome",
82
+ )
83
+
84
+
85
+ def _read_edge_endpoints(edge: dict) -> tuple[str, str]:
86
+ """Bilingual edge reader: prefer ``{from, to}``, fall back to ``{source, target}``.
87
+
88
+ Mirrors the reader in ``roadmap_render._read_edge_endpoints`` per #458 --
89
+ when both conventions are present on a single edge, canonical ``from``/``to``
90
+ wins. Replicated here rather than imported to keep the lifecycle aggregator
91
+ decoupled from roadmap rendering.
92
+ """
93
+ if not isinstance(edge, dict):
94
+ return "", ""
95
+ frm = edge.get("from") or edge.get("source", "") or ""
96
+ to = edge.get("to") or edge.get("target", "") or ""
97
+ return frm, to
98
+
99
+
100
+ def _load_scope_vbriefs(folder: Path) -> list[tuple[str, dict]]:
101
+ """Load ``*.vbrief.json`` files from a lifecycle folder.
102
+
103
+ Returns a list of ``(filename_stem, vbrief_data)`` tuples, sorted by
104
+ filename. Missing / non-directory / malformed files are skipped silently
105
+ so the aggregator never prevents a successful render.
106
+ """
107
+ if not folder.is_dir():
108
+ return []
109
+ out: list[tuple[str, dict]] = []
110
+ for path in sorted(folder.glob("*.vbrief.json")):
111
+ try:
112
+ data = json.loads(path.read_text(encoding="utf-8"))
113
+ except (json.JSONDecodeError, OSError):
114
+ continue
115
+ stem = path.name
116
+ if stem.endswith(".vbrief.json"):
117
+ stem = stem[: -len(".vbrief.json")]
118
+ out.append((stem, data))
119
+ return out
120
+
121
+
122
+ def _scope_id(stem: str, vbrief: dict) -> str:
123
+ """Resolve a stable identifier for a scope used in cross-scope edge lookup."""
124
+ plan = vbrief.get("plan", {})
125
+ if isinstance(plan, dict):
126
+ plan_id = plan.get("id", "")
127
+ if isinstance(plan_id, str) and plan_id:
128
+ return plan_id
129
+ return stem
130
+
131
+
132
+ def _cross_scope_dep_map(scopes: list[tuple[str, dict]]) -> dict[str, list[str]]:
133
+ """Build a cross-scope dep map using the bilingual edge reader (#458).
134
+
135
+ Only edges whose endpoints match scope IDs present in ``scopes`` contribute
136
+ to ordering; out-of-scope endpoints are ignored silently so a scope cannot
137
+ silently pin the whole render to an unresolved id.
138
+ """
139
+ scope_ids = {_scope_id(stem, vb) for stem, vb in scopes}
140
+ dep_map: dict[str, list[str]] = {}
141
+ for _stem, vbrief in scopes:
142
+ plan = vbrief.get("plan", {})
143
+ if not isinstance(plan, dict):
144
+ continue
145
+ edges = plan.get("edges", [])
146
+ if not isinstance(edges, list):
147
+ continue
148
+ for edge in edges:
149
+ frm, to = _read_edge_endpoints(edge)
150
+ if frm in scope_ids and to in scope_ids and frm and to:
151
+ dep_map.setdefault(to, []).append(frm)
152
+ return dep_map
153
+
154
+
155
+ def _topo_sort_scopes(
156
+ scopes: list[tuple[str, dict]],
157
+ dep_map: dict[str, list[str]],
158
+ ) -> list[tuple[str, dict]]:
159
+ """Order scopes by longest dependency chain depth; fall back to filename order."""
160
+ if not scopes:
161
+ return []
162
+ id_by_index = [_scope_id(stem, vb) for stem, vb in scopes]
163
+ id_to_index = {sid: i for i, sid in enumerate(id_by_index)}
164
+ depths: dict[str, int] = {}
165
+
166
+ def _depth(sid: str, visited: set[str] | None = None) -> int:
167
+ if sid in depths:
168
+ return depths[sid]
169
+ if visited is None:
170
+ visited = set()
171
+ if sid in visited:
172
+ return 0
173
+ visited.add(sid)
174
+ deps = [d for d in dep_map.get(sid, []) if d in id_to_index]
175
+ if not deps:
176
+ depths[sid] = 0
177
+ return 0
178
+ result = max(_depth(d, visited) for d in deps) + 1
179
+ depths[sid] = result
180
+ return result
181
+
182
+ for sid in id_by_index:
183
+ _depth(sid)
184
+
185
+ ordered_indices = sorted(
186
+ range(len(scopes)),
187
+ key=lambda i: (depths.get(id_by_index[i], 0), i),
188
+ )
189
+ return [scopes[i] for i in ordered_indices]
190
+
191
+
192
+ def _scope_summary_narrative(plan: dict) -> str:
193
+ """Pick the most informative narrative string for a scope summary line."""
194
+ if not isinstance(plan, dict):
195
+ return ""
196
+ narratives = plan.get("narratives", {})
197
+ if not isinstance(narratives, dict):
198
+ return ""
199
+ for key in _SCOPE_SUMMARY_NARRATIVES:
200
+ val = narratives.get(key)
201
+ if isinstance(val, str) and val.strip():
202
+ return val.strip()
203
+ # Fallback: first non-empty string narrative in insertion order.
204
+ for val in narratives.values():
205
+ if isinstance(val, str) and val.strip():
206
+ return val.strip()
207
+ return ""
208
+
209
+
210
+ def _split_acceptance(value: object) -> list[str]:
211
+ """Normalize acceptance text/list values into visible markdown bullets."""
212
+ if isinstance(value, list):
213
+ return [str(item).strip() for item in value if str(item).strip()]
214
+ if not isinstance(value, str):
215
+ return []
216
+ parts: list[str] = []
217
+ for line in value.splitlines():
218
+ cleaned = line.strip()
219
+ if not cleaned:
220
+ continue
221
+ if cleaned.startswith(("- ", "* ")):
222
+ cleaned = cleaned[2:].strip()
223
+ if cleaned:
224
+ parts.append(cleaned)
225
+ return parts
226
+
227
+
228
+ def _item_acceptance(item: dict) -> list[str]:
229
+ narrative = item.get("narrative")
230
+ if not isinstance(narrative, dict):
231
+ return []
232
+ return _split_acceptance(narrative.get("Acceptance"))
233
+
234
+
235
+ def _render_scope_block(stem: str, vbrief: dict) -> list[str]:
236
+ """Render a single scope vBRIEF as a markdown block inside Implementation Plan."""
237
+ plan = vbrief.get("plan", {})
238
+ if not isinstance(plan, dict):
239
+ return []
240
+ title = plan.get("title", stem)
241
+ status = plan.get("status", "")
242
+ heading_parts = [f"### {stem}: {title}"]
243
+ if status:
244
+ heading_parts[0] += f" `[{status}]`"
245
+ lines: list[str] = [heading_parts[0] + "\n"]
246
+
247
+ summary = _scope_summary_narrative(plan)
248
+ if summary:
249
+ lines.append(f"{summary}\n")
250
+
251
+ narratives = plan.get("narratives", {})
252
+ if isinstance(narratives, dict):
253
+ scope_acceptance = _split_acceptance(narratives.get("Acceptance"))
254
+ if scope_acceptance:
255
+ lines.append("**Scope Acceptance**:\n")
256
+ for criterion in scope_acceptance:
257
+ lines.append(f"- {criterion}")
258
+ lines.append("")
259
+
260
+ # Acceptance items -- each plan.items entry rendered as a bullet with status.
261
+ items = plan.get("items", [])
262
+ if isinstance(items, list) and items:
263
+ lines.append("**Acceptance**:\n")
264
+ for item in items:
265
+ if not isinstance(item, dict):
266
+ continue
267
+ item_title = item.get("title", "Untitled")
268
+ item_status = item.get("status", "")
269
+ bullet = f"- {item_title}"
270
+ if item_status:
271
+ bullet += f" `[{item_status}]`"
272
+ lines.append(bullet)
273
+ for criterion in _item_acceptance(item):
274
+ if criterion != item_title:
275
+ lines.append(f" - Acceptance: {criterion}")
276
+ lines.append("")
277
+ return lines
278
+
279
+
280
+ def _aggregate_scope_section(vbrief_dir: Path) -> list[str]:
281
+ """Build the Implementation Plan section from vbrief/{pending,active,completed}.
282
+
283
+ Returns an empty list if no scope vBRIEFs exist in any lifecycle folder.
284
+ """
285
+ buckets: list[tuple[str, str, list[tuple[str, dict]]]] = []
286
+ for folder_name, heading in LIFECYCLE_BUCKETS:
287
+ scopes = _load_scope_vbriefs(vbrief_dir / folder_name)
288
+ if folder_name == "completed":
289
+ # Status pin: only completed scopes rendered under Completed.
290
+ scopes = [
291
+ (stem, vb)
292
+ for stem, vb in scopes
293
+ if isinstance(vb.get("plan"), dict)
294
+ and vb["plan"].get("status") == "completed"
295
+ ]
296
+ if scopes:
297
+ buckets.append((folder_name, heading, scopes))
298
+
299
+ if not buckets:
300
+ return []
301
+
302
+ lines: list[str] = ["## Implementation Plan\n"]
303
+ for _folder, heading, scopes in buckets:
304
+ dep_map = _cross_scope_dep_map(scopes)
305
+ ordered = _topo_sort_scopes(scopes, dep_map)
306
+ lines.append(f"### {heading}\n")
307
+ for stem, vbrief in ordered:
308
+ lines.extend(_render_scope_block(stem, vbrief))
309
+ return lines
310
+
311
+
312
+ def render_spec(
313
+ spec_path: str,
314
+ out_path: str,
315
+ include_scopes: bool = True,
316
+ ) -> tuple[bool, str]:
317
+ """
318
+ Render the approved spec at *spec_path* to markdown at *out_path*.
319
+
320
+ Returns:
321
+ (True, success_message) on success.
322
+ (False, error_message) on failure.
323
+ """
324
+ # Validate first
325
+ ok, msg = validate_spec(spec_path)
326
+ if not ok:
327
+ return False, msg
328
+
329
+ with open(spec_path, encoding="utf-8") as fh:
330
+ spec = json.load(fh)
331
+
332
+ # Support vBRIEF v0.5 envelope structure
333
+ plan = spec.get("plan", {})
334
+ status = plan.get("status", "") if isinstance(plan, dict) else spec.get("status", "")
335
+
336
+ renderable_statuses = ("approved", "running", "completed")
337
+ if status not in renderable_statuses:
338
+ return (
339
+ False,
340
+ f"⚠ specification.vbrief.json status is '{status}' "
341
+ f"(expected one of {renderable_statuses})\n"
342
+ " Have the user review and set status to one of the"
343
+ " renderable statuses before rendering.",
344
+ )
345
+
346
+ # Canonical 4-line banner prepended to every rendered SPECIFICATION.md
347
+ # so operators (and downstream detectors) can recognise the file as
348
+ # machine-managed and re-run `task spec:render` to regenerate it
349
+ # (#572).
350
+ lines: list[str] = [_BANNER]
351
+
352
+ if isinstance(plan, dict):
353
+ title = plan.get("title", "Specification")
354
+ else:
355
+ title = plan or spec.get("title", "Specification")
356
+ lines.append(f"# {title}\n")
357
+
358
+ # Render narratives in declared order, then remaining keys alphabetically.
359
+ # Mirrors prd_render.py behavior -- speckit-shaped specs (ProblemStatement,
360
+ # Goals, Requirements, etc.) must not be silently dropped (#434).
361
+ if isinstance(plan, dict):
362
+ narratives = plan.get("narratives", {})
363
+ if not isinstance(narratives, dict):
364
+ narratives = {}
365
+ else:
366
+ # Legacy flat-format specs may carry overview/description at top level.
367
+ legacy_overview = spec.get("overview") or spec.get("description") or ""
368
+ narratives = {"Overview": legacy_overview} if legacy_overview else {}
369
+
370
+ rendered_keys: set[str] = set()
371
+ for key in SPECIFICATION_NARRATIVE_KEY_ORDER:
372
+ if key in narratives and narratives[key]:
373
+ lines.append(f"## {key}\n")
374
+ lines.append(f"{narratives[key]}\n")
375
+ rendered_keys.add(key)
376
+
377
+ for key in sorted(narratives.keys()):
378
+ if key in rendered_keys or not narratives.get(key):
379
+ continue
380
+ lines.append(f"## {key}\n")
381
+ lines.append(f"{narratives[key]}\n")
382
+
383
+ # Extract items from plan.items (v0.5) or spec.tasks (legacy). Items render
384
+ # after narratives so hybrid/legacy specs (items + narratives) still produce
385
+ # complete output.
386
+ items = plan.get("items", []) if isinstance(plan, dict) else spec.get("tasks", [])
387
+ for item in items:
388
+ item_id = item.get("id", "")
389
+ title_text = item.get("title", "")
390
+ item_status = item.get("status", "")
391
+ lines.append(f"## {item_id}: {title_text} `[{item_status}]`\n")
392
+
393
+ # Dependencies from metadata (v0.5) or inline (legacy)
394
+ deps = None
395
+ if metadata := item.get("metadata"):
396
+ deps = metadata.get("dependencies")
397
+ if not deps:
398
+ deps = item.get("dependencies")
399
+ if deps:
400
+ dep_list = ", ".join(deps)
401
+ lines.append(f"**Depends on**: {dep_list}\n")
402
+
403
+ # Narrative is an object in v0.5, string/list in legacy
404
+ narrative = item.get("narrative")
405
+ if isinstance(narrative, dict):
406
+ for key, val in narrative.items():
407
+ if key == "Traces":
408
+ lines.append(f"**Traces**: {val}\n")
409
+ elif key == "Acceptance":
410
+ for acceptance_line in _split_acceptance(val):
411
+ lines.append(f"- {acceptance_line}")
412
+ lines.append("")
413
+ else:
414
+ lines.append(f"{val}\n")
415
+ elif isinstance(narrative, list):
416
+ for entry in narrative:
417
+ lines.append(f"- {entry}")
418
+ lines.append("")
419
+ elif narrative:
420
+ lines.append(f"{narrative}\n")
421
+
422
+ # Aggregator: append Implementation Plan from lifecycle folders (#435).
423
+ if include_scopes:
424
+ vbrief_dir = Path(spec_path).resolve().parent
425
+ scope_lines = _aggregate_scope_section(vbrief_dir)
426
+ if scope_lines:
427
+ lines.extend(scope_lines)
428
+
429
+ Path(out_path).write_text("\n".join(lines), encoding="utf-8")
430
+ return True, f"✓ Rendered to {out_path}"
431
+
432
+
433
+ def _parse_include_scopes_flag(argv: list[str]) -> tuple[bool, list[str]]:
434
+ """Extract ``--include-scopes[=on|off]`` from argv.
435
+
436
+ Returns ``(include_scopes, remaining_argv)``. Defaults to True (#435).
437
+ Accepts ``--include-scopes``, ``--include-scopes=on``, ``--include-scopes=off``,
438
+ ``--include-scopes=true``, ``--include-scopes=false`` (case-insensitive on value).
439
+ """
440
+ include = True
441
+ remaining: list[str] = []
442
+ for arg in argv:
443
+ if arg == "--include-scopes":
444
+ include = True
445
+ continue
446
+ if arg.startswith("--include-scopes="):
447
+ value = arg.split("=", 1)[1].lower()
448
+ include = value in ("on", "true", "1", "yes")
449
+ continue
450
+ remaining.append(arg)
451
+ return include, remaining
452
+
453
+
454
+ def main() -> int:
455
+ include_scopes, positional = _parse_include_scopes_flag(sys.argv[1:])
456
+ if not positional:
457
+ print(
458
+ "Usage: spec_render.py <spec_file> [out_file] [--include-scopes=on|off]",
459
+ file=sys.stderr,
460
+ )
461
+ return 2
462
+
463
+ spec_path = positional[0]
464
+ if len(positional) >= 2:
465
+ out_path = positional[1]
466
+ else:
467
+ # Default: place SPECIFICATION.md at the grandparent of the spec file
468
+ # e.g. vbrief/specification.vbrief.json → SPECIFICATION.md
469
+ out_path = str(Path(spec_path).resolve().parent.parent / "SPECIFICATION.md")
470
+
471
+ ok, message = render_spec(spec_path, out_path, include_scopes=include_scopes)
472
+ print(message)
473
+ return 0 if ok else 1
474
+
475
+
476
+ if __name__ == "__main__":
477
+ sys.exit(main())