@deftai/directive-content 0.55.2 → 0.56.1

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 (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,589 @@
1
+ """_vbrief_legacy.py -- LegacyArtifacts mechanism for migrate:vbrief (#505).
2
+
3
+ Shared known-mappings list + normalization for both canonical extraction
4
+ (#495, consumed by ``_vbrief_fidelity``) and non-canonical capture (#505,
5
+ consumed by ``migrate_vbrief``). Hard-coded for v0.20 per #506 D5;
6
+ config-driven extensibility is a v0.21+ feature request.
7
+
8
+ Exports
9
+ -------
10
+ SPEC_KNOWN_MAPPINGS, PROJECT_KNOWN_MAPPINGS
11
+ Normalized-heading -> canonical-narrative-key dicts covering the locked
12
+ v0.20 aliases per #506 D5.
13
+ normalize_title(title)
14
+ Four-rule normalization: case-insensitive + whitespace-collapsed +
15
+ punctuation-stripped + word-separator-tolerant.
16
+ lookup_canonical(title, mapping)
17
+ Return the canonical key for a heading, or None if unknown (legacy).
18
+ parse_top_level_sections(content)
19
+ Split markdown content at top-level ``## `` boundaries; returns a list
20
+ of ``(title, body, start_line, end_line)`` tuples. Substructure (H3
21
+ etc.) is preserved verbatim inside each body.
22
+ partition_sections(sections, mapping)
23
+ Split parsed sections into canonical (matches known-mappings) and
24
+ legacy (no match) buckets.
25
+ emit_legacy_artifacts(legacy_sections, source_file, project_root, *, slugify_fn,
26
+ warning_prefix=None, event_emitter=None)
27
+ Build the LegacyArtifacts narrative string for one vBRIEF file, write
28
+ any >6 KB sidecars under ``vbrief/legacy/``, and return
29
+ ``(narrative_str, sidecar_paths, stats)``. When ``event_emitter`` is
30
+ supplied, also emits one ``legacy:detected`` framework event per
31
+ captured section via the callback (#635 events behavioral wiring).
32
+ emit_legacy_report(project_root, captures)
33
+ Write ``vbrief/migration/LEGACY-REPORT.md`` per #505 Section 6.
34
+ detect_prd_legacy(prd_content, canonical_specification_keys, *, source_name)
35
+ PRD.md section-name diff (OQ3-b): sections whose normalized title does
36
+ NOT match a canonical spec narrative key are captured with the
37
+ hand-edit warning prefix.
38
+
39
+ Issue: #505, #506 D5. Shared with #495 via ``_vbrief_fidelity``.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import contextlib
45
+ import re
46
+ from collections.abc import Callable
47
+ from datetime import UTC, datetime
48
+ from pathlib import Path
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Constants
52
+ # ---------------------------------------------------------------------------
53
+
54
+ # 6 KB inline threshold per #506 D5. Sections whose preserved text exceeds
55
+ # this limit overflow to ``vbrief/legacy/{stem}-{slug}.md``.
56
+ INLINE_THRESHOLD_BYTES: int = 6 * 1024
57
+
58
+ # Hand-edit warning prefix for PRD.md captured content (#505 Section 5).
59
+ PRD_HAND_EDIT_WARNING: str = (
60
+ "> WARNING: PRD.md was edited manually in this project. PRD.md is "
61
+ "framework-defined\n"
62
+ "> as a rendered export from specification.vbrief.json. Manual edits "
63
+ "here are\n"
64
+ "> against framework guidance; review whether this content should be "
65
+ "migrated\n"
66
+ "> into a specification.vbrief.json narrative."
67
+ )
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Normalization (four rules per #506 D5)
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ def normalize_title(title: str) -> str:
76
+ """Apply the four normalization rules from #506 D5 (+ CamelCase split).
77
+
78
+ 1. Case-insensitive (lowercase)
79
+ 2. Punctuation stripped (keep alphanumerics, spaces, hyphens, underscores)
80
+ 3. Word-separator tolerant (``-`` / ``_`` / CamelCase / space equivalent)
81
+ 4. Whitespace collapsed (runs of spaces -> single space; trim)
82
+
83
+ CamelCase splitting is treated as a word-separator equivalence so
84
+ ``ProblemStatement`` and ``Problem Statement`` both normalize to
85
+ ``problem statement`` (see #495/#506 D5 comment thread: word-separator
86
+ tolerance covers the prd_render.py no-space output).
87
+ """
88
+ raw = title or ""
89
+ # Split CamelCase word boundaries BEFORE lowercasing so we can use
90
+ # ``[A-Z]`` detection: ``ProblemStatement`` -> ``Problem Statement``.
91
+ split = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", raw)
92
+ low = split.lower().strip()
93
+ low = re.sub(r"[^a-z0-9\s_\-]", " ", low)
94
+ low = re.sub(r"[-_]+", " ", low)
95
+ return re.sub(r"\s+", " ", low).strip()
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Known mappings (hard-coded v0.20 per #506 D5)
100
+ # ---------------------------------------------------------------------------
101
+
102
+ # Canonical narrative keys per #506 D3.
103
+ CANONICAL_SPEC_KEYS: tuple[str, ...] = (
104
+ "Overview",
105
+ "Architecture",
106
+ "ProblemStatement",
107
+ "Goals",
108
+ "UserStories",
109
+ "Requirements",
110
+ "NonFunctionalRequirements",
111
+ "SuccessMetrics",
112
+ "TestingStrategy",
113
+ "Deployment",
114
+ )
115
+
116
+ CANONICAL_PROJECT_KEYS: tuple[str, ...] = (
117
+ "TechStack",
118
+ "Strategy",
119
+ "Quality",
120
+ "ProjectRules",
121
+ "Branching",
122
+ "DeftVersion",
123
+ )
124
+
125
+ # specification.vbrief.json aliases. Keys are normalized per
126
+ # normalize_title(); values are canonical PascalCase narrative keys.
127
+ SPEC_KNOWN_MAPPINGS: dict[str, str] = {
128
+ "overview": "Overview",
129
+ "summary": "Overview",
130
+ "architecture": "Architecture",
131
+ "system design": "Architecture",
132
+ "technical architecture": "Architecture",
133
+ "problem statement": "ProblemStatement",
134
+ "problem": "ProblemStatement",
135
+ "background": "ProblemStatement",
136
+ "goals": "Goals",
137
+ "objectives": "Goals",
138
+ "user stories": "UserStories",
139
+ "use cases": "UserStories",
140
+ "requirements": "Requirements",
141
+ "functional requirements": "Requirements",
142
+ "non functional requirements": "NonFunctionalRequirements",
143
+ "nfrs": "NonFunctionalRequirements",
144
+ "success metrics": "SuccessMetrics",
145
+ "acceptance criteria": "SuccessMetrics",
146
+ "acceptance criteria project level": "SuccessMetrics",
147
+ "testing strategy": "TestingStrategy",
148
+ "test plan": "TestingStrategy",
149
+ "testing": "TestingStrategy",
150
+ "deployment": "Deployment",
151
+ "deployment plan": "Deployment",
152
+ }
153
+
154
+ # PROJECT-DEFINITION.vbrief.json aliases.
155
+ PROJECT_KNOWN_MAPPINGS: dict[str, str] = {
156
+ "tech stack": "TechStack",
157
+ "technology stack": "TechStack",
158
+ "stack": "TechStack",
159
+ "project configuration": "TechStack",
160
+ "strategy": "Strategy",
161
+ "quality": "Quality",
162
+ "standards": "Quality",
163
+ "quality standards": "Quality",
164
+ "project specific rules": "ProjectRules",
165
+ "project rules": "ProjectRules",
166
+ "custom rules": "ProjectRules",
167
+ "branching": "Branching",
168
+ "branching strategy": "Branching",
169
+ "git workflow": "Branching",
170
+ }
171
+
172
+
173
+ def lookup_canonical(title: str, mapping: dict[str, str]) -> str | None:
174
+ """Return the canonical key for ``title`` or None if not a known alias."""
175
+ return mapping.get(normalize_title(title))
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Section parsing (top-level ## only per #506 D5)
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ def parse_top_level_sections(
184
+ content: str,
185
+ ) -> list[tuple[str, str, int, int]]:
186
+ """Split markdown at top-level ``## `` boundaries.
187
+
188
+ Returns a list of ``(title, body, start_line, end_line)`` tuples where
189
+ lines are 1-indexed. Substructure (``###`` and below) is preserved
190
+ verbatim inside each body -- the migrator MUST NOT attempt to re-parse
191
+ it (per #506 D5 / #505 Section 2).
192
+
193
+ Fenced code blocks are respected so that ``## ``-prefixed lines inside
194
+ a fence are not misread as section boundaries.
195
+ """
196
+ if not content:
197
+ return []
198
+
199
+ lines = content.splitlines()
200
+ sections: list[tuple[str, str, int, int]] = []
201
+ in_fence = False
202
+ current_title: str | None = None
203
+ current_start = 0
204
+ current_body: list[str] = []
205
+
206
+ def _flush(end_line: int) -> None:
207
+ if current_title is None:
208
+ return
209
+ body = "\n".join(current_body).rstrip()
210
+ sections.append((current_title, body, current_start, end_line))
211
+
212
+ for idx, line in enumerate(lines, start=1):
213
+ stripped = line.lstrip()
214
+ # Track fences so we don't misinterpret ## inside code blocks.
215
+ if stripped.startswith("```"):
216
+ in_fence = not in_fence
217
+ if current_title is not None:
218
+ current_body.append(line)
219
+ continue
220
+
221
+ if not in_fence:
222
+ match = re.match(r"^##\s+(.+?)\s*$", line)
223
+ if match:
224
+ # Close previous section.
225
+ _flush(idx - 1)
226
+ current_title = match.group(1).strip()
227
+ current_start = idx
228
+ current_body = []
229
+ continue
230
+
231
+ if current_title is not None:
232
+ current_body.append(line)
233
+
234
+ # Flush trailing section.
235
+ _flush(len(lines))
236
+ return sections
237
+
238
+
239
+ def partition_sections(
240
+ sections: list[tuple[str, str, int, int]],
241
+ mapping: dict[str, str],
242
+ ) -> tuple[dict[str, str], list[tuple[str, str, int, int]]]:
243
+ """Split parsed sections into canonical vs legacy buckets.
244
+
245
+ Returns ``(canonical, legacy)`` where ``canonical`` is a dict of
246
+ ``canonical_key -> body`` and ``legacy`` is the list of unmatched
247
+ ``(title, body, start, end)`` tuples in source order.
248
+
249
+ When multiple aliases collapse onto the same canonical key, bodies are
250
+ joined with a blank line so no content is lost.
251
+ """
252
+ canonical: dict[str, str] = {}
253
+ legacy: list[tuple[str, str, int, int]] = []
254
+ for title, body, start, end in sections:
255
+ key = lookup_canonical(title, mapping)
256
+ if key is None:
257
+ legacy.append((title, body, start, end))
258
+ continue
259
+ if not body.strip():
260
+ # Skip empty canonical sections (see existing
261
+ # _parse_prd_narratives behaviour).
262
+ continue
263
+ if key in canonical:
264
+ canonical[key] = canonical[key].rstrip() + "\n\n" + body.strip()
265
+ else:
266
+ canonical[key] = body.strip()
267
+ return canonical, legacy
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # LegacyArtifacts narrative construction + sidecar overflow
272
+ # ---------------------------------------------------------------------------
273
+
274
+
275
+ def _format_line_range(start: int, end: int) -> str:
276
+ """Format a line-range for provenance headers."""
277
+ if end <= start:
278
+ return f"{start}"
279
+ return f"{start}-{end}"
280
+
281
+
282
+ def emit_legacy_artifacts(
283
+ legacy_sections: list[tuple[str, str, int, int]],
284
+ source_file: str,
285
+ project_root: Path,
286
+ *,
287
+ slugify_fn: Callable[[str], str],
288
+ warning_prefix: str | None = None,
289
+ event_emitter: Callable[[str, dict], None] | None = None,
290
+ flagged: bool = False,
291
+ ) -> tuple[str, list[Path], list[dict]]:
292
+ """Build the LegacyArtifacts narrative for one vBRIEF file.
293
+
294
+ ``source_file`` is the display name used in provenance headers (e.g.
295
+ ``SPECIFICATION.md``). The basename-without-extension (lowercased) is
296
+ used as the sidecar ``{stem}`` (#506 D5 / #505 Section 4).
297
+
298
+ ``slugify_fn`` converts section titles to lowercase-kebab-case
299
+ filenames; Agent D's slug-safe ID generator (#498) is preferred once
300
+ available, otherwise the repo's historic ``slugify`` works.
301
+
302
+ ``warning_prefix`` optionally injects a warning block under each
303
+ section header -- used for PRD.md hand-edit captures (#505 Section 5).
304
+
305
+ ``event_emitter`` is an optional ``(event_name, payload)`` callback
306
+ invoked once per captured section with ``event_name='legacy:detected'``
307
+ and the per-section stat dict as payload (#635 behavioral events
308
+ wiring). Defaulting to ``None`` keeps the existing API surface
309
+ bit-for-bit identical when callers do not opt in -- existing tests
310
+ and consumers continue to behave exactly as before.
311
+
312
+ ``flagged`` (default ``False``) marks every captured section's stat
313
+ dict with ``"flagged": True`` BEFORE the event is emitted so the
314
+ ``legacy:detected`` event payload accurately reflects the PRD.md
315
+ hand-edit provenance contract documented in ``events/registry.json``
316
+ under ``category: "behavioral"`` (Greptile #706 P1, post-#706
317
+ unification per #709 / #710). Callers that pass ``warning_prefix``
318
+ for PRD.md hand-edit captures SHOULD also pass ``flagged=True`` so
319
+ the structural emission matches the warning prefix in the
320
+ narrative.
321
+
322
+ Returns ``(narrative_str, sidecar_paths, stats)`` where ``stats`` is a
323
+ list of per-section dicts with keys: ``title``, ``source``, ``range``,
324
+ ``size_bytes``, ``inline`` (bool), ``sidecar`` (str | None),
325
+ ``flagged`` (bool, when ``flagged=True`` was passed),
326
+ ``canonical_suggestion`` (str | None).
327
+ """
328
+ if not legacy_sections:
329
+ return "", [], []
330
+
331
+ stem = Path(source_file).stem.lower()
332
+ legacy_dir = project_root / "vbrief" / "legacy"
333
+ narrative_parts: list[str] = []
334
+ sidecar_paths: list[Path] = []
335
+ stats: list[dict] = []
336
+
337
+ for title, body, start, end in legacy_sections:
338
+ header = (
339
+ f"### {title} (from {source_file}:"
340
+ f"{_format_line_range(start, end)})"
341
+ )
342
+ body_stripped = body.strip()
343
+ size = len(body_stripped.encode("utf-8"))
344
+ if size > INLINE_THRESHOLD_BYTES:
345
+ slug = slugify_fn(title) or slugify_fn(f"section-{start}")
346
+ sidecar_name = f"{stem}-{slug}.md"
347
+ sidecar = legacy_dir / sidecar_name
348
+ legacy_dir.mkdir(parents=True, exist_ok=True)
349
+ sidecar_content = (
350
+ f"# {title}\n\n"
351
+ f"> Captured from {source_file}:"
352
+ f"{_format_line_range(start, end)} during "
353
+ f"`task migrate:vbrief` (#505)\n\n"
354
+ f"{body_stripped}\n"
355
+ )
356
+ sidecar.write_text(sidecar_content, encoding="utf-8")
357
+ sidecar_paths.append(sidecar)
358
+ pointer = (
359
+ f"[Content exceeds inline threshold — "
360
+ f"see vbrief/legacy/{sidecar_name}]"
361
+ )
362
+ section_block = f"{header}\n{pointer}"
363
+ stats.append({
364
+ "title": title,
365
+ "source": source_file,
366
+ "range": _format_line_range(start, end),
367
+ "size_bytes": size,
368
+ "inline": False,
369
+ "sidecar": f"vbrief/legacy/{sidecar_name}",
370
+ })
371
+ else:
372
+ if warning_prefix:
373
+ section_block = (
374
+ f"{header}\n{warning_prefix}\n\n{body_stripped}"
375
+ )
376
+ else:
377
+ section_block = f"{header}\n\n{body_stripped}"
378
+ stats.append({
379
+ "title": title,
380
+ "source": source_file,
381
+ "range": _format_line_range(start, end),
382
+ "size_bytes": size,
383
+ "inline": True,
384
+ "sidecar": None,
385
+ })
386
+ # Apply the ``flagged`` annotation BEFORE emitting the event so
387
+ # the ``legacy:detected`` payload contract documented in
388
+ # ``events/registry.json`` (``category: "behavioral"``) is
389
+ # honoured for PRD.md hand-edit captures (Greptile #706 P1,
390
+ # post-#706 unification per #709 / #710). Previously the
391
+ # migrator patched this field on the returned stats AFTER the
392
+ # function had already emitted, leaving every PRD.md event
393
+ # missing the ``flagged`` field.
394
+ if flagged:
395
+ stats[-1]["flagged"] = True
396
+ narrative_parts.append(section_block)
397
+ if event_emitter is not None:
398
+ # Emit a structural ``legacy:detected`` framework event per
399
+ # captured section (#635 behavioral events wiring; the event
400
+ # contract lives in ``events/registry.json`` under
401
+ # ``category: "behavioral"`` post-#706 unification).
402
+ # Failures in the emitter MUST NOT break the migrator --
403
+ # legacy capture is the primary contract here, the event
404
+ # stream is an additive observability layer.
405
+ with contextlib.suppress(Exception):
406
+ event_emitter("legacy:detected", dict(stats[-1]))
407
+
408
+ narrative = "\n\n".join(narrative_parts).rstrip() + "\n"
409
+ return narrative, sidecar_paths, stats
410
+
411
+
412
+ # ---------------------------------------------------------------------------
413
+ # LEGACY-REPORT.md emission (#505 Section 6)
414
+ # ---------------------------------------------------------------------------
415
+
416
+
417
+ def _render_size(size_bytes: int) -> str:
418
+ if size_bytes < 1024:
419
+ return f"{size_bytes} B"
420
+ return f"{size_bytes / 1024:.1f} KB"
421
+
422
+
423
+ def emit_legacy_report(
424
+ project_root: Path,
425
+ captures: dict[str, list[dict]],
426
+ *,
427
+ migrator_version: str,
428
+ sources: list[str],
429
+ timestamp: str | None = None,
430
+ ) -> Path | None:
431
+ """Write ``vbrief/migration/LEGACY-REPORT.md``.
432
+
433
+ ``captures`` keys are report section labels (e.g.
434
+ ``"specification.vbrief.json -> LegacyArtifacts"``) mapping to the
435
+ per-section stat dicts produced by :func:`emit_legacy_artifacts`.
436
+
437
+ ``timestamp`` is an ISO-8601 ``YYYY-MM-DDTHH:MM:SSZ`` string; when
438
+ ``None`` (default) the current UTC wall clock is used. Tests inject
439
+ a frozen value so the golden fixture can diff byte-for-byte without
440
+ a clock-freezing library (Greptile #525 P1).
441
+
442
+ Returns the path to the written file, or ``None`` if there is
443
+ nothing to report (all buckets empty).
444
+ """
445
+ if not any(captures.values()):
446
+ return None
447
+
448
+ report_dir = project_root / "vbrief" / "migration"
449
+ report_dir.mkdir(parents=True, exist_ok=True)
450
+ report_path = report_dir / "LEGACY-REPORT.md"
451
+
452
+ now = timestamp or datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
453
+ lines: list[str] = [
454
+ "# Legacy content captured during migration",
455
+ "",
456
+ f"Generated: {now}",
457
+ f"Migrator version: {migrator_version}",
458
+ f"Sources: {', '.join(sources)}",
459
+ "",
460
+ ]
461
+
462
+ for label, items in captures.items():
463
+ lines.append(f"## {label}")
464
+ lines.append("")
465
+ if not items:
466
+ lines.append("(none)")
467
+ lines.append("")
468
+ continue
469
+ for item in items:
470
+ rng = item.get("range", "?")
471
+ src = item.get("source", "?")
472
+ title = item.get("title", "Untitled")
473
+ inline = item.get("inline", True)
474
+ size = _render_size(int(item.get("size_bytes", 0)))
475
+ sidecar = item.get("sidecar")
476
+ flagged = bool(item.get("flagged"))
477
+
478
+ lines.append(f"### {title} ({src}:{rng})")
479
+ disposition = "inline" if inline else f"sidecar: {sidecar}"
480
+ lines.append(f"- Size: {size} ({disposition})")
481
+ reason = item.get("reason") or (
482
+ "No canonical narrative match; captured verbatim to preserve "
483
+ "intent."
484
+ )
485
+ lines.append(f"- Reason: {reason}")
486
+ lines.append(
487
+ "- Suggested disposition: review during "
488
+ "`deft-directive-sync` Phase 6c Legacy Artifact Review."
489
+ )
490
+ lines.append("- Action options:")
491
+ lines.append(" - Keep as LegacyArtifacts (no action)")
492
+ lines.append(" - Fold into an existing canonical narrative")
493
+ lines.append(" - Drop (confirm nothing important is lost)")
494
+ if flagged:
495
+ lines.append(
496
+ "- Flag: PRD.md was hand-edited -- content does not "
497
+ "match any canonical specification narrative name."
498
+ )
499
+ lines.append("")
500
+
501
+ report_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
502
+ return report_path
503
+
504
+
505
+ # ---------------------------------------------------------------------------
506
+ # PRD.md section-name diff (OQ3-b per #505 Section 5)
507
+ # ---------------------------------------------------------------------------
508
+
509
+
510
+ def detect_prd_legacy(
511
+ prd_content: str,
512
+ canonical_keys_present: set[str],
513
+ *,
514
+ source_name: str = "PRD.md",
515
+ ) -> list[tuple[str, str, int, int]]:
516
+ """Return PRD.md sections that are not render output of canonical keys.
517
+
518
+ Per #506 D5 / #505 Section 5 (OQ3-b): section-title diff only. A
519
+ section whose normalized title maps to a canonical spec narrative key
520
+ that IS present in the post-migration spec vBRIEF is treated as
521
+ expected render output and NOT captured. Everything else is treated
522
+ as hand-edited content and returned for legacy capture.
523
+
524
+ ``canonical_keys_present`` is the set of canonical narrative keys that
525
+ actually exist on the spec vBRIEF after migration (e.g.
526
+ ``{"Overview", "Goals"}``) -- the caller computes this.
527
+ """
528
+ sections = parse_top_level_sections(prd_content or "")
529
+ legacy: list[tuple[str, str, int, int]] = []
530
+ for title, body, start, end in sections:
531
+ canonical = lookup_canonical(title, SPEC_KNOWN_MAPPINGS)
532
+ if canonical and canonical in canonical_keys_present:
533
+ continue
534
+ legacy.append((title, body, start, end))
535
+ return legacy
536
+
537
+
538
+ # ---------------------------------------------------------------------------
539
+ # Stdout summary (#505 Section 8)
540
+ # ---------------------------------------------------------------------------
541
+
542
+
543
+ def summarize_captures(captures: dict[str, list[dict]]) -> list[str]:
544
+ """Return stdout-summary lines for the end-of-run migrator output."""
545
+ if not any(captures.values()):
546
+ return []
547
+ lines = ["", "LEGACY CONTENT CAPTURED:"]
548
+ total_sidecars = 0
549
+ for label, items in captures.items():
550
+ inline_size = sum(
551
+ int(i.get("size_bytes", 0)) for i in items if i.get("inline")
552
+ )
553
+ total_sidecars += sum(1 for i in items if not i.get("inline"))
554
+ flagged = " (flagged: hand-edited)" if any(
555
+ i.get("flagged") for i in items
556
+ ) else ""
557
+ lines.append(
558
+ f" {label}: {len(items)} section(s) "
559
+ f"({_render_size(inline_size)} inline){flagged}"
560
+ )
561
+ lines.append(f" Sidecar files: {total_sidecars}")
562
+ lines.append("")
563
+ lines.append(
564
+ " Full list and suggested dispositions: "
565
+ "vbrief/migration/LEGACY-REPORT.md"
566
+ )
567
+ lines.append(
568
+ " Review with: `task sync` (or any session-start sync) -- "
569
+ "agent will walk you through each item."
570
+ )
571
+ return lines
572
+
573
+
574
+ __all__ = [
575
+ "CANONICAL_PROJECT_KEYS",
576
+ "CANONICAL_SPEC_KEYS",
577
+ "INLINE_THRESHOLD_BYTES",
578
+ "PRD_HAND_EDIT_WARNING",
579
+ "PROJECT_KNOWN_MAPPINGS",
580
+ "SPEC_KNOWN_MAPPINGS",
581
+ "detect_prd_legacy",
582
+ "emit_legacy_artifacts",
583
+ "emit_legacy_report",
584
+ "lookup_canonical",
585
+ "normalize_title",
586
+ "parse_top_level_sections",
587
+ "partition_sections",
588
+ "summarize_captures",
589
+ ]