@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,589 +0,0 @@
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
- ]