@deftai/directive-content 0.59.0 → 0.61.0

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