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