@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,479 +0,0 @@
1
- """_vbrief_fidelity.py -- Fidelity fixes for migrate:vbrief (#495).
2
-
3
- Implements the #506 D2/D3/D4 in-scope findings for migrate:vbrief:
4
-
5
- 495-1 Preserve per-task body, ``Depends on:``, and acceptance-criteria
6
- bullets into scope-vBRIEF narratives (Description / DependsOn /
7
- AcceptanceCriteria). Body source is SPEC.md per D2 #14 (body
8
- routing is reconciled by Agent B's reconciliation module; this
9
- module consumes the reconciled state once available and falls
10
- back to direct SPEC parsing when running against the baseline).
11
- 495-3 Pass FR-N / NFR-N trace IDs through verbatim -- never renumber.
12
- 495-4 Parse FR-N: / NFR-N: definitions from SPECIFICATION.md and emit
13
- the ``Requirements`` narrative on ``specification.vbrief.json``.
14
- 495-6 Emit ``plan.edges[]`` from per-task ``Depends on:`` lines (edge
15
- type = ``blocks``).
16
- 495-6b Fold ``Acceptance Criteria (Project-Level)`` into
17
- ``SuccessMetrics`` (handled by _vbrief_legacy's known-mappings).
18
- 495-9 Align narrative keys to the #506 D3 canonical set per file.
19
- Fix known PROJECT-DEFINITION bugs (lowercase-space ``tech stack``
20
- -> PascalCase ``TechStack``; emit DeftVersion, vBRIEFInfo.author,
21
- vBRIEFInfo.created; ``plan.title`` = project name).
22
- 495-15 Log every narrative routing decision with source file + line
23
- range + target key + target file so the migrator log is
24
- unambiguous.
25
-
26
- Canonical heading -> narrative-key resolution is shared with #505 via
27
- ``_vbrief_legacy`` (single source of truth for the known-mappings list).
28
-
29
- Issue: #495, #506 D2/D3/D4.
30
- """
31
-
32
- from __future__ import annotations
33
-
34
- import re
35
- from collections.abc import Iterable
36
-
37
- # Local imports -- this module lives alongside _vbrief_legacy in scripts/.
38
- try:
39
- from _vbrief_legacy import (
40
- CANONICAL_SPEC_KEYS,
41
- SPEC_KNOWN_MAPPINGS,
42
- lookup_canonical,
43
- parse_top_level_sections,
44
- partition_sections,
45
- )
46
- except ImportError: # pragma: no cover -- imported as package in tests
47
- from ._vbrief_legacy import ( # type: ignore[no-redef]
48
- CANONICAL_SPEC_KEYS,
49
- SPEC_KNOWN_MAPPINGS,
50
- lookup_canonical,
51
- parse_top_level_sections,
52
- partition_sections,
53
- )
54
-
55
-
56
- # ---------------------------------------------------------------------------
57
- # Per-task body parsing (495-1)
58
- # ---------------------------------------------------------------------------
59
-
60
- # Task headings in SPECIFICATION.md look like:
61
- #
62
- # ### tX.Y.Z -- Title [status]
63
- # ### `tX.Y.Z` Title
64
- # #### tX.Y.Z Title
65
- #
66
- # We keep the match loose so pre-v0.20 spec styles all parse.
67
- _TASK_HEADING_RE = re.compile(
68
- r"^(?P<hashes>#{3,4})\s+"
69
- r"(?:`)?(?P<task_id>t[0-9]+(?:\.[0-9]+)+)(?:`)?"
70
- r"(?:\s*[-:]+\s*|\s+)"
71
- r"(?P<title>[^\[\n]+?)"
72
- r"(?:\s*\[(?P<status>[a-zA-Z_-]+)\])?\s*$"
73
- )
74
-
75
- # Recognised "Depends on:" / "DependsOn:" prose lines under a task heading.
76
- _DEPENDS_ON_RE = re.compile(
77
- r"^\*{0,2}\s*Depends\s*on\s*\*{0,2}\s*:\s*(?P<deps>.+)$",
78
- re.IGNORECASE,
79
- )
80
-
81
- # Recognised "Traces:" / "**Traces**:" prose lines under a task heading.
82
- _TRACES_RE = re.compile(
83
- r"^\s*\*{0,2}\s*Traces\s*\*{0,2}\s*:\s*(?P<traces>.+)$",
84
- re.IGNORECASE,
85
- )
86
-
87
- # FR-N / NFR-N definitions. Matches lines like:
88
- #
89
- # FR-1: Description
90
- # - **FR-1**: Description
91
- # * NFR-2 -- Description
92
- #
93
- _REQ_DEF_RE = re.compile(
94
- r"^\s*(?:[-*]\s+)?"
95
- r"\*{0,2}\s*(?P<id>(?:FR|NFR)-\d+)\s*\*{0,2}"
96
- r"\s*[:\-]+\s*"
97
- r"(?P<desc>.+?)\s*$",
98
- re.IGNORECASE,
99
- )
100
-
101
- # Trace-ID extractor used by 495-3. Accepts "FR-1", "NFR-12", comma or
102
- # space separated lists; returns upper-cased IDs in source order.
103
- _TRACE_ID_RE = re.compile(r"(?:FR|NFR)-\d+", re.IGNORECASE)
104
-
105
- # Status mapping from pre-v0.20 SPEC.md tags to vBRIEF status values.
106
- _SPEC_STATUS_TO_VBRIEF: dict[str, str] = {
107
- "done": "completed",
108
- "completed": "completed",
109
- "complete": "completed",
110
- "pending": "pending",
111
- "running": "running",
112
- "in-progress": "running",
113
- "in_progress": "running",
114
- "blocked": "blocked",
115
- "cancelled": "cancelled",
116
- "canceled": "cancelled",
117
- "draft": "draft",
118
- "proposed": "proposed",
119
- "approved": "approved",
120
- }
121
-
122
-
123
- def map_spec_status(raw: str | None) -> str:
124
- """Map a SPECIFICATION.md status token to a vBRIEF status (D2 vocabulary).
125
-
126
- Unknown or empty tokens default to ``pending``. ``[done]`` (historic
127
- pre-v0.20 marker) -> ``completed`` (#499 handles the folder routing).
128
- """
129
- if not raw:
130
- return "pending"
131
- return _SPEC_STATUS_TO_VBRIEF.get(raw.strip().lower(), "pending")
132
-
133
-
134
- def parse_spec_tasks(content: str) -> list[dict]:
135
- """Parse ``tX.Y.Z`` task sections out of SPECIFICATION.md.
136
-
137
- Returns a list of dicts with keys:
138
- - ``task_id`` (str, e.g. ``"t1.1.1"``)
139
- - ``title`` (str)
140
- - ``status`` (str, vBRIEF vocabulary)
141
- - ``body`` (str, multi-paragraph description)
142
- - ``depends_on`` (list[str], task IDs this task depends on)
143
- - ``traces`` (list[str], FR/NFR IDs -- verbatim, uppercased)
144
- - ``acceptance`` (list[str], acceptance-criteria bullets)
145
- - ``start_line`` (int, 1-indexed)
146
- - ``end_line`` (int, 1-indexed)
147
- """
148
- if not content:
149
- return []
150
- lines = content.splitlines()
151
- tasks: list[dict] = []
152
- current: dict | None = None
153
- current_start = 0
154
- current_body_lines: list[str] = []
155
-
156
- def _flush(end_line: int) -> None:
157
- if current is None:
158
- return
159
- body_lines = list(current_body_lines)
160
- # Split body into: description paragraphs, Depends-on, Traces, and
161
- # acceptance-criteria bullet list. Everything non-classified
162
- # becomes part of the description.
163
- depends: list[str] = []
164
- traces: list[str] = []
165
- acceptance: list[str] = []
166
- description_lines: list[str] = []
167
- in_acceptance = False
168
- for raw in body_lines:
169
- stripped = raw.strip()
170
- dep_match = _DEPENDS_ON_RE.match(stripped)
171
- if dep_match:
172
- deps_raw = dep_match.group("deps").strip()
173
- if deps_raw.lower() not in ("none", "n/a", "-"):
174
- for tok in re.split(r"[,\s]+", deps_raw):
175
- tok = tok.strip("`*,;. ")
176
- if tok:
177
- depends.append(tok)
178
- in_acceptance = False
179
- continue
180
- trace_match = _TRACES_RE.match(stripped)
181
- if trace_match:
182
- for m in _TRACE_ID_RE.finditer(trace_match.group("traces")):
183
- traces.append(m.group(0).upper())
184
- in_acceptance = False
185
- continue
186
- if re.match(r"^\*{0,2}\s*Acceptance(?:\s+criteria)?\*{0,2}\s*:?\s*$",
187
- stripped, re.IGNORECASE):
188
- in_acceptance = True
189
- continue
190
- # Blank lines preserve acceptance-capture state -- a blank
191
- # line between ``Acceptance criteria:`` and its first bullet
192
- # MUST NOT reset ``in_acceptance`` (PR #525 Greptile P1).
193
- if not stripped:
194
- if not in_acceptance:
195
- description_lines.append(raw)
196
- continue
197
- if stripped.startswith(("-", "*")) and in_acceptance:
198
- acceptance.append(re.sub(r"^[-*]\s+", "", stripped))
199
- continue
200
- if (
201
- stripped.startswith(("-", "*"))
202
- and not in_acceptance
203
- and not description_lines
204
- ):
205
- # Loose bullet list at the START of the task body (before any
206
- # description prose) counts as acceptance criteria when it
207
- # looks like one (each bullet is a testable assertion).
208
- # Conservative: only capture when no description prose has
209
- # been accumulated yet -- bullets that appear after prose
210
- # are description-area bullets (design notes, prerequisites)
211
- # and must stay in description to preserve Agent B's
212
- # reconciliation "SPEC owns body" routing (Greptile #525 P1).
213
- acceptance.append(re.sub(r"^[-*]\s+", "", stripped))
214
- continue
215
- if stripped.startswith(("-", "*")) and not in_acceptance:
216
- # Bullet after description prose -> treat as description.
217
- description_lines.append(raw)
218
- continue
219
- if not stripped:
220
- # Blank line: preserve in_acceptance across it so patterns
221
- # like ``Acceptance criteria:\n\n- first bullet`` still
222
- # capture into the acceptance list (Greptile #525 P1).
223
- description_lines.append(raw)
224
- continue
225
- description_lines.append(raw)
226
- in_acceptance = False
227
-
228
- body = "\n".join(description_lines).strip()
229
- current["body"] = body
230
- current["depends_on"] = depends
231
- current["traces"] = traces
232
- current["acceptance"] = acceptance
233
- current["start_line"] = current_start
234
- current["end_line"] = end_line
235
- tasks.append(current)
236
-
237
- for idx, line in enumerate(lines, start=1):
238
- heading = _TASK_HEADING_RE.match(line)
239
- if heading:
240
- _flush(idx - 1)
241
- current = {
242
- "task_id": heading.group("task_id").strip(),
243
- "title": heading.group("title").strip(),
244
- "status": map_spec_status(heading.group("status")),
245
- }
246
- current_start = idx
247
- current_body_lines = []
248
- continue
249
- # New non-task ## heading closes the current task.
250
- if re.match(r"^##\s+", line) and current is not None:
251
- _flush(idx - 1)
252
- current = None
253
- current_body_lines = []
254
- continue
255
- if current is not None:
256
- current_body_lines.append(line)
257
-
258
- _flush(len(lines))
259
- return tasks
260
-
261
-
262
- # ---------------------------------------------------------------------------
263
- # FR / NFR definition parsing + Requirements narrative (495-4)
264
- # ---------------------------------------------------------------------------
265
-
266
-
267
- def parse_requirement_definitions(content: str) -> dict[str, str]:
268
- """Parse FR-N / NFR-N definitions from SPECIFICATION.md.
269
-
270
- Looks inside any ``## Requirements`` / ``## Functional Requirements``
271
- / ``## Non-Functional Requirements`` sections (resolved via
272
- _vbrief_legacy's known-mappings) and returns a dict of
273
- ``{"FR-1": "description", "NFR-2": "description"}`` preserving source
274
- order via dict insertion order.
275
-
276
- Only the FIRST definition wins for any given ID so that renumbered or
277
- re-quoted IDs in later sections do not silently overwrite the
278
- canonical definition.
279
- """
280
- if not content:
281
- return {}
282
- sections = parse_top_level_sections(content)
283
- requirements: dict[str, str] = {}
284
- for title, body, _start, _end in sections:
285
- canonical = lookup_canonical(title, SPEC_KNOWN_MAPPINGS)
286
- if canonical not in ("Requirements", "NonFunctionalRequirements"):
287
- continue
288
- for line in body.splitlines():
289
- match = _REQ_DEF_RE.match(line)
290
- if not match:
291
- continue
292
- req_id = match.group("id").upper()
293
- desc = match.group("desc").strip()
294
- # Trim any trailing markdown emphasis / period noise
295
- desc = re.sub(r"\s*\*+\s*$", "", desc).strip()
296
- if req_id and desc and req_id not in requirements:
297
- requirements[req_id] = desc
298
- return requirements
299
-
300
-
301
- def build_requirements_narrative(requirements: dict[str, str]) -> str:
302
- """Render FR/NFR definitions as a Requirements narrative string.
303
-
304
- Output is deterministic: FR-N first (numeric order), then NFR-N
305
- (numeric order). Each line is ``{ID}: {Description}``.
306
- """
307
- if not requirements:
308
- return ""
309
-
310
- def _sort_key(item: tuple[str, str]) -> tuple[int, int]:
311
- rid, _ = item
312
- kind = 0 if rid.startswith("FR-") else 1
313
- num = int(rid.split("-", 1)[1]) if "-" in rid else 0
314
- return (kind, num)
315
-
316
- sorted_items = sorted(requirements.items(), key=_sort_key)
317
- return "\n".join(f"{rid}: {desc}" for rid, desc in sorted_items)
318
-
319
-
320
- # ---------------------------------------------------------------------------
321
- # plan.edges[] extraction (495-6)
322
- # ---------------------------------------------------------------------------
323
-
324
-
325
- def build_edges_from_tasks(tasks: Iterable[dict]) -> list[dict]:
326
- """Build ``plan.edges[]`` from per-task ``depends_on`` lists.
327
-
328
- Each ``Depends on:`` item yields an edge ``{from, to, type}`` where
329
- ``from`` is the dependency ID and ``to`` is the current task ID, edge
330
- type is ``"blocks"``. Self-edges and duplicates are suppressed. The
331
- returned edges conform to the vBRIEF schema ID pattern
332
- ``^[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)*$``; edges whose source or target
333
- would violate the pattern are silently dropped.
334
- """
335
- edges: list[dict] = []
336
- seen: set[tuple[str, str]] = set()
337
- id_pattern = re.compile(r"^[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$")
338
- for task in tasks:
339
- tgt = str(task.get("task_id", "")).strip()
340
- if not tgt or not id_pattern.match(tgt):
341
- continue
342
- for dep in task.get("depends_on", []) or []:
343
- src = str(dep or "").strip().strip("`")
344
- if not src or src == tgt or not id_pattern.match(src):
345
- continue
346
- key = (src, tgt)
347
- if key in seen:
348
- continue
349
- seen.add(key)
350
- edges.append({"from": src, "to": tgt, "type": "blocks"})
351
- return edges
352
-
353
-
354
- # ---------------------------------------------------------------------------
355
- # Narrative-key alignment per #506 D3 (495-9)
356
- # ---------------------------------------------------------------------------
357
-
358
-
359
- def align_spec_narratives(narratives: dict[str, str]) -> dict[str, str]:
360
- """Reduce a narratives dict to the #506 D3 canonical spec shape.
361
-
362
- - PascalCase keys win. Old spellings (``tech stack``, ``problem``,
363
- etc.) are rewritten through the known-mappings list.
364
- - Legacy keys that DO have a canonical mapping are folded under the
365
- canonical key with body preservation (non-destructive merge).
366
- - Anything not canonical is left in place so the caller can surface
367
- it to ``LegacyArtifacts`` via _vbrief_legacy.
368
-
369
- Returns a new dict; does not mutate the input.
370
- """
371
- if not isinstance(narratives, dict):
372
- return {}
373
- result: dict[str, str] = {}
374
- for key, value in narratives.items():
375
- if not isinstance(value, str):
376
- continue
377
- canonical = lookup_canonical(key, SPEC_KNOWN_MAPPINGS)
378
- target = canonical or key
379
- if target in result:
380
- result[target] = result[target].rstrip() + "\n\n" + value.strip()
381
- else:
382
- result[target] = value.strip()
383
- return result
384
-
385
-
386
- # ---------------------------------------------------------------------------
387
- # SPEC.md -> spec.vbrief.json narrative routing + migration log (495-15)
388
- # ---------------------------------------------------------------------------
389
-
390
-
391
- def ingest_spec_narratives(
392
- spec_content: str,
393
- source_file: str = "SPECIFICATION.md",
394
- ) -> tuple[dict[str, str], list[dict], list[tuple[str, str, int, int]]]:
395
- """Split SPECIFICATION.md into canonical narratives + legacy sections.
396
-
397
- Returns ``(canonical_narratives, log_entries, legacy_sections)`` where
398
- ``log_entries`` is a list of dicts suitable for the disambiguated
399
- migration log (``{source, line_range, target_key, target_file}``).
400
- """
401
- sections = parse_top_level_sections(spec_content or "")
402
- canonical, legacy = partition_sections(sections, SPEC_KNOWN_MAPPINGS)
403
-
404
- # Build disambiguated log entries per 495-15.
405
- log_entries: list[dict] = []
406
- for title, _body, start, end in sections:
407
- canonical_key = lookup_canonical(title, SPEC_KNOWN_MAPPINGS)
408
- target_file = "specification.vbrief.json"
409
- target_key = (
410
- canonical_key if canonical_key is not None else "LegacyArtifacts"
411
- )
412
- log_entries.append({
413
- "source": source_file,
414
- "section_title": title,
415
- "line_range": f"{start}-{end}" if end > start else f"{start}",
416
- "target_key": target_key,
417
- "target_file": target_file,
418
- })
419
-
420
- return canonical, log_entries, legacy
421
-
422
-
423
- def format_migration_log_entry(entry: dict) -> str:
424
- """Format a routing-decision dict as a single migrator log line.
425
-
426
- Example output::
427
-
428
- ROUTE SPECIFICATION.md:12-34 -> Overview -> specification.vbrief.json
429
- """
430
- src = entry.get("source", "?")
431
- rng = entry.get("line_range", "?")
432
- key = entry.get("target_key", "?")
433
- dst = entry.get("target_file", "?")
434
- return f"ROUTE {src}:{rng} -> {key} -> {dst}"
435
-
436
-
437
- # ---------------------------------------------------------------------------
438
- # Per-task scope-vBRIEF narratives (495-1)
439
- # ---------------------------------------------------------------------------
440
-
441
-
442
- def task_scope_narratives(task: dict) -> dict[str, str]:
443
- """Build the per-task scope-vBRIEF narrative dict.
444
-
445
- Emits ``Description`` / ``DependsOn`` / ``AcceptanceCriteria`` /
446
- ``Traces`` narratives populated from :func:`parse_spec_tasks` output.
447
- Empty values are omitted so the scope vBRIEF stays clean on tasks
448
- that carried only a title.
449
- """
450
- narratives: dict[str, str] = {}
451
- body = (task.get("body") or "").strip()
452
- if body:
453
- narratives["Description"] = body
454
- depends = task.get("depends_on") or []
455
- if depends:
456
- narratives["DependsOn"] = ", ".join(depends)
457
- acceptance = task.get("acceptance") or []
458
- if acceptance:
459
- narratives["AcceptanceCriteria"] = "\n".join(
460
- f"- {item}" for item in acceptance
461
- )
462
- traces = task.get("traces") or []
463
- if traces:
464
- narratives["Traces"] = ", ".join(traces)
465
- return narratives
466
-
467
-
468
- __all__ = [
469
- "CANONICAL_SPEC_KEYS",
470
- "align_spec_narratives",
471
- "build_edges_from_tasks",
472
- "build_requirements_narrative",
473
- "format_migration_log_entry",
474
- "ingest_spec_narratives",
475
- "map_spec_status",
476
- "parse_requirement_definitions",
477
- "parse_spec_tasks",
478
- "task_scope_narratives",
479
- ]