@deftai/directive-content 0.58.0 → 0.60.0

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