@deftai/directive-content 0.59.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/.githooks/pre-commit +10 -128
  2. package/.githooks/pre-push +8 -108
  3. package/Taskfile.yml +48 -58
  4. package/UPGRADING.md +19 -3
  5. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  6. package/docs/directive-lifecycle.md +73 -0
  7. package/docs/getting-started.md +5 -1
  8. package/package.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +1 -1
  10. package/packs/strategies/strategies-pack-0.1.json +19 -19
  11. package/scm/github.md +37 -6
  12. package/skills/deft-directive-setup/SKILL.md +24 -15
  13. package/strategies/speckit.md +14 -14
  14. package/strategies/v0-20-contract.md +12 -1
  15. package/tasks/change.yml +16 -31
  16. package/tasks/ci.yml +8 -0
  17. package/tasks/commit.yml +12 -19
  18. package/tasks/core.yml +10 -0
  19. package/tasks/engine.yml +42 -0
  20. package/tasks/framework.yml +3 -0
  21. package/tasks/install.yml +20 -19
  22. package/tasks/migrate.yml +26 -15
  23. package/tasks/project.yml +26 -0
  24. package/tasks/toolchain.yml +15 -5
  25. package/tasks/vbrief.yml +4 -3
  26. package/tasks/verify.yml +12 -14
  27. package/templates/agents-entry.md +1 -1
  28. package/scripts/_agents_md.py +0 -494
  29. package/scripts/_cache_fetch.py +0 -635
  30. package/scripts/_cache_quota.py +0 -529
  31. package/scripts/_cache_refresh.py +0 -163
  32. package/scripts/_cache_validate.py +0 -209
  33. package/scripts/_content_root.py +0 -42
  34. package/scripts/_doctor_state.py +0 -277
  35. package/scripts/_event_detect.py +0 -305
  36. package/scripts/_events.py +0 -514
  37. package/scripts/_lifecycle_hygiene.py +0 -568
  38. package/scripts/_pathspec.py +0 -91
  39. package/scripts/_policy_show_cli.py +0 -266
  40. package/scripts/_precutover.py +0 -92
  41. package/scripts/_project_context.py +0 -224
  42. package/scripts/_project_definition_io.py +0 -164
  43. package/scripts/_relocate_snapshot.py +0 -209
  44. package/scripts/_relocate_states.py +0 -343
  45. package/scripts/_resolve_preflight_path.py +0 -152
  46. package/scripts/_safe_subprocess.py +0 -167
  47. package/scripts/_session_start_hook.py +0 -205
  48. package/scripts/_sor_gate_diff.py +0 -365
  49. package/scripts/_stdio_utf8.py +0 -59
  50. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  51. package/scripts/_triage_classify_cli.py +0 -122
  52. package/scripts/_triage_queue_cli.py +0 -625
  53. package/scripts/_triage_scope_cli.py +0 -343
  54. package/scripts/_triage_scope_drift_cli.py +0 -121
  55. package/scripts/_triage_scope_ignores.py +0 -286
  56. package/scripts/_triage_scope_milestone.py +0 -432
  57. package/scripts/_triage_scope_mutations.py +0 -337
  58. package/scripts/_triage_scope_renderers.py +0 -207
  59. package/scripts/_triage_smoketest_stages.py +0 -674
  60. package/scripts/_triage_subscribe_cli.py +0 -140
  61. package/scripts/_triage_welcome_cli.py +0 -421
  62. package/scripts/_vbrief_build.py +0 -239
  63. package/scripts/_vbrief_fidelity.py +0 -479
  64. package/scripts/_vbrief_legacy.py +0 -589
  65. package/scripts/_vbrief_reconciliation.py +0 -883
  66. package/scripts/_vbrief_routing.py +0 -277
  67. package/scripts/_vbrief_safety.py +0 -778
  68. package/scripts/_vbrief_sources.py +0 -312
  69. package/scripts/_vbrief_speckit.py +0 -262
  70. package/scripts/_vbrief_story_quality.py +0 -353
  71. package/scripts/_vbrief_validation.py +0 -299
  72. package/scripts/build_dist.py +0 -412
  73. package/scripts/cache.py +0 -1078
  74. package/scripts/cache_scanner.py +0 -745
  75. package/scripts/candidates_log.py +0 -432
  76. package/scripts/capacity_backfill.py +0 -680
  77. package/scripts/capacity_show.py +0 -653
  78. package/scripts/ci_local.py +0 -689
  79. package/scripts/code_structure_validate.py +0 -765
  80. package/scripts/codebase_default_extractor.py +0 -495
  81. package/scripts/codebase_map.py +0 -304
  82. package/scripts/codebase_map_fresh.py +0 -104
  83. package/scripts/codebase_projection_registry.py +0 -94
  84. package/scripts/codebase_provider.py +0 -582
  85. package/scripts/doctor.py +0 -2552
  86. package/scripts/framework_commands.py +0 -505
  87. package/scripts/gh_rest.py +0 -882
  88. package/scripts/github_auth_modes.py +0 -437
  89. package/scripts/github_body.py +0 -292
  90. package/scripts/ip_risk.py +0 -531
  91. package/scripts/issue_emit.py +0 -670
  92. package/scripts/issue_ingest.py +0 -1064
  93. package/scripts/migrate_preflight.py +0 -418
  94. package/scripts/migrate_vbrief.py +0 -2677
  95. package/scripts/monitor_pr.py +0 -401
  96. package/scripts/pack_migrate_lessons.py +0 -336
  97. package/scripts/pack_migrate_patterns.py +0 -254
  98. package/scripts/pack_migrate_rules.py +0 -350
  99. package/scripts/pack_migrate_skills.py +0 -423
  100. package/scripts/pack_migrate_strategies.py +0 -311
  101. package/scripts/pack_migrate_swarm_spec.py +0 -250
  102. package/scripts/pack_render.py +0 -434
  103. package/scripts/packs_slice.py +0 -712
  104. package/scripts/platform_capabilities.py +0 -336
  105. package/scripts/policy.py +0 -2826
  106. package/scripts/policy_set.py +0 -324
  107. package/scripts/pr_check_closing_keywords.py +0 -524
  108. package/scripts/pr_check_protected_issues.py +0 -267
  109. package/scripts/pr_merge_readiness.py +0 -1004
  110. package/scripts/pr_wait_mergeable.py +0 -669
  111. package/scripts/prd_render.py +0 -159
  112. package/scripts/preflight_architecture_sor.py +0 -974
  113. package/scripts/preflight_branch.py +0 -289
  114. package/scripts/preflight_cache.py +0 -974
  115. package/scripts/preflight_gh.py +0 -721
  116. package/scripts/preflight_implementation.py +0 -272
  117. package/scripts/preflight_story_start.py +0 -838
  118. package/scripts/preflight_wip_cap.py +0 -149
  119. package/scripts/probe_session.py +0 -545
  120. package/scripts/project_render.py +0 -293
  121. package/scripts/quarantine_ext.py +0 -237
  122. package/scripts/reconcile_issues.py +0 -1442
  123. package/scripts/refresh-path.ps1 +0 -107
  124. package/scripts/release.py +0 -2030
  125. package/scripts/release_e2e.py +0 -1011
  126. package/scripts/release_publish.py +0 -486
  127. package/scripts/release_rollback.py +0 -980
  128. package/scripts/relocate.py +0 -1034
  129. package/scripts/resolve_changelog_unreleased.py +0 -667
  130. package/scripts/resolve_version.py +0 -490
  131. package/scripts/resume_conditions.py +0 -706
  132. package/scripts/ritual_sentinel.py +0 -609
  133. package/scripts/roadmap_render.py +0 -635
  134. package/scripts/rule_ownership_lint.py +0 -325
  135. package/scripts/scm.py +0 -591
  136. package/scripts/scope_audit_log.py +0 -387
  137. package/scripts/scope_decompose.py +0 -654
  138. package/scripts/scope_demote.py +0 -509
  139. package/scripts/scope_lifecycle.py +0 -1126
  140. package/scripts/scope_undo.py +0 -772
  141. package/scripts/session_start.py +0 -406
  142. package/scripts/setup_ghx.py +0 -339
  143. package/scripts/setup_windows.ps1 +0 -220
  144. package/scripts/slice_audit.py +0 -585
  145. package/scripts/slice_record.py +0 -530
  146. package/scripts/slice_record_existing.py +0 -692
  147. package/scripts/slug_normalize.py +0 -178
  148. package/scripts/spec_render.py +0 -477
  149. package/scripts/spec_validate.py +0 -238
  150. package/scripts/subagent_monitor.py +0 -658
  151. package/scripts/swarm_complete_cohort.py +0 -644
  152. package/scripts/swarm_launch.py +0 -1206
  153. package/scripts/swarm_readiness.py +0 -554
  154. package/scripts/swarm_verify_review_clean.py +0 -438
  155. package/scripts/swarm_worktrees.py +0 -497
  156. package/scripts/toolchain-check.py +0 -52
  157. package/scripts/triage_actions.py +0 -871
  158. package/scripts/triage_bootstrap.py +0 -1153
  159. package/scripts/triage_bulk.py +0 -630
  160. package/scripts/triage_classify.py +0 -932
  161. package/scripts/triage_help.py +0 -1685
  162. package/scripts/triage_queue.py +0 -1944
  163. package/scripts/triage_reconcile.py +0 -581
  164. package/scripts/triage_refresh.py +0 -643
  165. package/scripts/triage_scope.py +0 -999
  166. package/scripts/triage_scope_drift.py +0 -575
  167. package/scripts/triage_smoketest.py +0 -396
  168. package/scripts/triage_subscribe.py +0 -399
  169. package/scripts/triage_summary.py +0 -1011
  170. package/scripts/triage_welcome.py +0 -1178
  171. package/scripts/ts_check_lane.py +0 -86
  172. package/scripts/validate-links.py +0 -64
  173. package/scripts/validate_strategy_output.py +0 -212
  174. package/scripts/vbrief_activate.py +0 -228
  175. package/scripts/vbrief_migrate_conformance.py +0 -368
  176. package/scripts/vbrief_reconcile_graph.py +0 -306
  177. package/scripts/vbrief_reconcile_labels.py +0 -460
  178. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  179. package/scripts/vbrief_validate.py +0 -1144
  180. package/scripts/verify-stubs.py +0 -61
  181. package/scripts/verify_capacity.py +0 -160
  182. package/scripts/verify_encoding.py +0 -699
  183. package/scripts/verify_hooks_installed.py +0 -206
  184. package/scripts/verify_investigation.py +0 -360
  185. package/scripts/verify_judgment_gates.py +0 -827
  186. package/scripts/verify_no_task_runtime.py +0 -171
  187. package/scripts/verify_scm_boundary.py +0 -509
  188. package/scripts/verify_session_ritual.py +0 -389
  189. package/scripts/verify_tools.py +0 -426
  190. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,2677 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- migrate_vbrief.py -- Migrate a Deft project to the vBRIEF-centric document model.
4
-
5
- Converts existing SPECIFICATION.md + specification.vbrief.json + PROJECT.md +
6
- ROADMAP.md into the new lifecycle folder structure defined by RFC #309.
7
-
8
- Usage:
9
- uv run python scripts/migrate_vbrief.py [project_root]
10
-
11
- project_root -- path to the project root (default: current working directory)
12
-
13
- Exit codes:
14
- 0 -- migration completed successfully
15
- 1 -- migration failed (errors printed to stderr)
16
-
17
- Story: #312 (Phase 2 vBRIEF Architecture Cutover)
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import contextlib
23
- import json
24
- import re
25
- import subprocess
26
- import sys
27
- from datetime import UTC, datetime
28
- from pathlib import Path
29
- from typing import Any
30
- from urllib.parse import urlparse
31
-
32
- # Ensure the ``scripts/`` directory is on sys.path so sibling module
33
- # ``_vbrief_build`` is importable whether this file is run as __main__ or
34
- # imported from a test harness that appends the ``scripts/`` path.
35
- sys.path.insert(0, str(Path(__file__).resolve().parent))
36
-
37
-
38
- # #635: Detection-bound emit helper -- lazy-imported so an import-time
39
- # failure in ``scripts/_event_detect.py`` (e.g. syntax error in a future
40
- # change) cannot break the migrator's ability to load. The events surface
41
- # MUST NOT break the wrapped CLI; importing at module level would let an
42
- # import-time exception in the helper take down the migrator before the
43
- # call-site ``contextlib.suppress`` could intervene (Greptile P1 on PR
44
- # #707 -- mirrors the lazy pattern in ``run::_emit_event_safe``).
45
- # Filename is intentionally distinct from the sibling vBRIEF's
46
- # ``scripts/_events.py`` (behavioral events) to avoid file-level merge
47
- # conflicts; post-merge consolidation may unify them under one name.
48
- def _emit_event(name: str, payload: dict[str, Any]) -> None:
49
- """Lazy-import scripts/_event_detect.emit and forward the call."""
50
- from _event_detect import emit # noqa: I001 -- intentional lazy import
51
-
52
- emit(name, payload)
53
-
54
-
55
- from _vbrief_build import ( # noqa: E402 -- after sys.path mutate + lazy emit helper
56
- EMITTED_VBRIEF_VERSION, # canonical emitted version per #533
57
- create_scope_vbrief as _create_scope_vbrief_shared,
58
- reference_with_default_trust as _reference_with_default_trust,
59
- slugify as _slugify_shared,
60
- )
61
- from _vbrief_speckit import ( # noqa: E402
62
- create_speckit_scope_vbrief as _create_speckit_scope_vbrief_shared,
63
- dependencies_for_item as _dependencies_for_item_shared,
64
- edge_nodes as _edge_nodes_shared,
65
- migrate_speckit_plan as _migrate_speckit_plan_shared,
66
- speckit_ip_index as _speckit_ip_index_shared,
67
- speckit_ip_slug as _speckit_ip_slug_shared,
68
- )
69
- from _vbrief_validation import ( # noqa: E402
70
- finalize_migration,
71
- slug_fallback_id,
72
- slugify_id,
73
- )
74
-
75
- # Re-export slug-safe sanitiser under the migrator's underscore-prefixed
76
- # convention so test harnesses and other migrator-adjacent tooling can import
77
- # it from ``migrate_vbrief`` alongside the existing ``_slugify`` shim (#498).
78
- _slugify_id = slugify_id
79
- _slug_fallback_id = slug_fallback_id
80
-
81
- # --- safety (Agent C, #497) ---
82
- # Safety affordances for `task migrate:vbrief` live in `_vbrief_safety`:
83
- # .premigrate.* backups, --dry-run preview, dirty-tree guard, --rollback.
84
- # See `scripts/_vbrief_safety.py` and tracking issue #506 (D7) for the
85
- # authoritative decisions this code implements.
86
- # --- end safety ---
87
- # --- reconciliation (Agent B, #496) ---
88
- # Role-based SPEC/ROADMAP reconciliation per #506 D3 + overrides loader +
89
- # RECONCILIATION.md emitter live in ``_vbrief_reconciliation``.
90
- # --- end lifecycle-routing ---
91
- # --- fidelity (Agent A, #495) ---
92
- # Per-task body / FR-NFR definition parsing, Requirements narrative, plan.edges[]
93
- # extraction, and the disambiguated ROUTE migration log live in
94
- # ``_vbrief_fidelity``. Per #506 D2 #14 body routing is reconciled by Agent B;
95
- # this module FEEDS reconciliation by enriching spec_vbrief.plan.items with
96
- # the narratives parsed from raw SPECIFICATION.md content.
97
- # --- legacy-artifacts (Agent A, #505) ---
98
- # LegacyArtifacts narrative emission + 6KB sidecar overflow + LEGACY-REPORT.md
99
- # + stdout summary live in ``_vbrief_legacy``. The known-mappings list is
100
- # shared with #495's canonical extraction path so both agree on what is
101
- # canonical vs non-canonical (#506 D5).
102
- # --- behavioral events (#635 events behavioral wiring) ---
103
- # Structural ``legacy:detected`` event emission. Each captured legacy
104
- # section produces one framework event alongside the existing
105
- # ``vbrief/migration/LEGACY-REPORT.md`` write (existing report behaviour
106
- # preserved). Handlers are deferred to follow-up work per the vBRIEF.
107
- #
108
- # Imported under the distinct ``_emit_behavioral_event`` name so it
109
- # does NOT shadow the detection-bound ``_emit_event`` lazy-import wrapper
110
- # defined above. The two helpers consume the same unified
111
- # ``events/registry.json`` post-#706 unification but enforce different
112
- # category boundaries: ``_emit_event`` (detection-bound) accepts any
113
- # registered event name; ``_emit_behavioral_event`` (this alias) only
114
- # accepts events whose registry entry carries ``category: "behavioral"``.
115
- from _events import ( # noqa: E402
116
- DEFAULT_EVENT_LOG as _DEFAULT_EVENT_LOG,
117
- emit as _emit_behavioral_event,
118
- )
119
- from _vbrief_fidelity import ( # noqa: E402
120
- build_edges_from_tasks as _build_edges_from_tasks,
121
- build_requirements_narrative as _build_requirements_narrative,
122
- format_migration_log_entry as _format_migration_log_entry,
123
- ingest_spec_narratives as _ingest_spec_narratives,
124
- parse_requirement_definitions as _parse_requirement_definitions,
125
- parse_spec_tasks as _parse_spec_tasks,
126
- task_scope_narratives as _task_scope_narratives,
127
- )
128
-
129
- # --- end behavioral events ---
130
- from _vbrief_legacy import ( # noqa: E402
131
- CANONICAL_SPEC_KEYS as _CANONICAL_SPEC_KEYS,
132
- PRD_HAND_EDIT_WARNING as _PRD_HAND_EDIT_WARNING,
133
- PROJECT_KNOWN_MAPPINGS as _PROJECT_KNOWN_MAPPINGS,
134
- detect_prd_legacy as _detect_prd_legacy,
135
- emit_legacy_artifacts as _emit_legacy_artifacts,
136
- emit_legacy_report as _emit_legacy_report,
137
- parse_top_level_sections as _parse_top_level_sections,
138
- partition_sections as _partition_sections,
139
- summarize_captures as _summarize_captures,
140
- )
141
- from _vbrief_reconciliation import ( # noqa: E402
142
- load_overrides as _load_overrides,
143
- reconcile_scope_items as _reconcile_scope_items,
144
- write_reconciliation_report as _write_reconciliation_report,
145
- )
146
-
147
- # --- end reconciliation ---
148
- # --- lifecycle-routing (Agent B, #499) ---
149
- # Lifecycle folder <-> status mapping + scope vBRIEF builder per #506 shared
150
- # conventions. Schema vocabulary only -- ``active/`` uses ``running``, NEVER
151
- # ``in_progress`` (the critical #499 correction comment).
152
- from _vbrief_routing import ( # noqa: E402
153
- build_scope_vbrief_from_reconciled as _build_reconciled_scope_vbrief,
154
- )
155
- from _vbrief_safety import ( # noqa: E402
156
- FileModification,
157
- SafetyManifest,
158
- dirty_tree_refusal_message,
159
- is_tree_dirty,
160
- load_safety_manifest,
161
- now_utc_iso,
162
- plan_backups,
163
- rollback as safety_rollback, # noqa: E402
164
- sha256_of,
165
- write_backups,
166
- write_safety_manifest,
167
- )
168
- from slug_normalize import ( # noqa: E402
169
- DEFAULT_MAX_LEN as _SLUG_MAX_LEN,
170
- disambiguate_slug as _disambiguate_slug,
171
- normalize_slug as _normalize_slug,
172
- )
173
-
174
- MIGRATOR_VERSION = "0.20.0"
175
-
176
- # --- vbrief version (#533) ---
177
- # ``EMITTED_VBRIEF_VERSION`` is the canonical ``vBRIEFInfo.version`` string
178
- # emitted on every file the migrator writes. Imported above from
179
- # ``_vbrief_build`` so the migrator, ingestion helpers, and speckit all share
180
- # a single source of truth. Bumped from ``"0.5"`` to ``"0.6"`` as part of the
181
- # Agent 2 schema vendor transition (#533). During the transition the
182
- # validator accepts both values; the migrator only emits the newer string.
183
- # --- end vbrief version ---
184
-
185
- # --- gitignore (#530) ---
186
- # Canonical comment block + patterns appended to a consumer project's
187
- # ``.gitignore`` by the migrator on its first run so ``.premigrate.*`` backup
188
- # files do not leak into commits. Idempotent -- the migrator only appends
189
- # patterns that are not already matched by an existing .gitignore rule.
190
- _GITIGNORE_MARKER_LINE = (
191
- "# Migration backups (created by `task migrate:vbrief`) -- do NOT commit."
192
- )
193
- _GITIGNORE_COMMENT_BLOCK: tuple[str, ...] = (
194
- _GITIGNORE_MARKER_LINE,
195
- "# Post-commit, pre-migration state is recoverable via git history; see",
196
- "# deft/main.md \u00a7 Safety flags for the post-commit recovery path.",
197
- )
198
- _GITIGNORE_PATTERNS: tuple[str, ...] = (
199
- "*.premigrate.md",
200
- "*.premigrate.vbrief.json",
201
- )
202
- # --- end gitignore ---
203
-
204
- # --- traces strip (#529) ---
205
- # Regex matching a ``**Traces**: ...`` line inside a LegacyArtifacts task block.
206
- # ``items[].subItems[].narrative.Traces`` is the single source of truth; the
207
- # duplicated line inside ``LegacyArtifacts`` is stripped during migration to
208
- # prevent downstream drift between the two copies. Applied with ``.match()``
209
- # against each individual line in ``_strip_traces_from_narrative`` so the
210
- # ``re.MULTILINE`` flag is not needed (Greptile #561 P2).
211
- _TRACES_LINE_RE = re.compile(r"^\s*\*\*Traces\*\*\s*:.*$")
212
- # Regex matching a LegacyArtifacts task header: e.g. ``### t2.1.2: ...`` or
213
- # ``### t2.1.2 -- ...``. Used to attribute the stripped line to a task id for
214
- # the RECONCILIATION.md audit trail. Applied with ``.match()`` against each
215
- # individual line so ``re.MULTILINE`` is likewise unnecessary.
216
- _TASK_HEADER_RE = re.compile(
217
- r"^###\s+(?P<task_id>[A-Za-z]?\d+(?:\.\d+)+)\b",
218
- )
219
- # Marker used to guard RECONCILIATION.md against duplicate Traces-stripped
220
- # sections on migrator re-runs (Greptile #561 P2). Must match the section
221
- # header emitted by :func:`_write_traces_stripped_note` exactly.
222
- _TRACES_SECTION_HEADER = "## Traces lines stripped from LegacyArtifacts (#529)"
223
- # --- end traces strip ---
224
-
225
- # --- end fidelity + legacy-artifacts ---
226
-
227
- # Lifecycle folders per RFC #309 D13
228
- LIFECYCLE_FOLDERS = ("proposed", "pending", "active", "completed", "cancelled")
229
-
230
- # Migrator-managed subdirectories under ``vbrief/`` that are created lazily by
231
- # sidecar emission (``vbrief/legacy/``, #505) and reporting (``vbrief/migration/``)
232
- # paths. Tracked in the safety manifest's ``created_dirs`` when the migrator
233
- # creates them for the first time so ``--rollback`` can RMDIR them consistently
234
- # with the lifecycle folders (issues #527, #528).
235
- _MANAGED_SUBDIRS: tuple[str, ...] = ("legacy", "migration")
236
-
237
- # Deprecation redirect sentinel per Story S (#334). Retained for one
238
- # release cycle alongside the canonical banner (#572) so consumers
239
- # that migrated under rc.1 / rc.2 are not incorrectly re-flagged as
240
- # pre-cutover on rc.3 and later.
241
- DEPRECATION_SENTINEL = "<!-- deft:deprecated-redirect -->"
242
-
243
- # Canonical machine-generated banner markers per #572 /
244
- # ``conventions/machine-generated-banner.md``. The migrator and the
245
- # three render scripts all emit the ``AUTO-GENERATED by`` +
246
- # ``<!-- Purpose:`` pair as the first two banner lines, so the
247
- # user-customisation detector below only needs to look for either
248
- # token (plus the legacy deprecation sentinel for one release cycle).
249
- # ``_is_user_customized()`` treats any file carrying one of these
250
- # markers as machine-managed and therefore safe to replace.
251
- #
252
- # Greptile P2 on the review of this PR: the marker is the FULL
253
- # ``<!-- Purpose:`` HTML-comment prefix, not the bare ``Purpose:``
254
- # string, so a hand-authored spec containing ``Purpose: deliver a
255
- # self-service flow`` in ordinary prose is not misclassified as
256
- # machine-managed and silently overwritten.
257
- _SPEC_AUTO_MARKERS = (
258
- "AUTO-GENERATED by",
259
- "<!-- Purpose:",
260
- DEPRECATION_SENTINEL,
261
- # Legacy markers kept for one release cycle so a previously-
262
- # generated file that used the old banner shape is still
263
- # recognised as machine-managed.
264
- "Generated by",
265
- "deft-setup skill",
266
- "spec_render.py",
267
- )
268
- _PROJECT_AUTO_MARKERS = (
269
- "AUTO-GENERATED by",
270
- "<!-- Purpose:",
271
- DEPRECATION_SENTINEL,
272
- # Legacy markers -- see _SPEC_AUTO_MARKERS for rationale.
273
- "Generated by",
274
- "deft-setup skill",
275
- )
276
-
277
- # Date for migration-created vBRIEF filenames (D7: creation date)
278
- _TODAY = datetime.now(UTC).strftime("%Y-%m-%d")
279
-
280
- # ISO-8601 UTC timestamp stamped onto ``vBRIEFInfo.updated`` when the
281
- # migrator routes a scope to ``completed/`` (#593). Module-level so the
282
- # golden-file test can monkeypatch for deterministic byte-for-byte output
283
- # (mirrors ``_TODAY``).
284
- _MIGRATION_TIMESTAMP = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
285
-
286
- # Mapping of markdown heading text (lowercased) to canonical narrative key names.
287
- # Covers both CamelCase keys (from prd_render.py output) and space-separated
288
- # forms (from hand-written PRDs/specs). Keys must match prd_render.py.
289
- _HEADING_TO_NARRATIVE_KEY: dict[str, str] = {
290
- "overview": "Overview",
291
- "problemstatement": "ProblemStatement",
292
- "problem statement": "ProblemStatement",
293
- "goals": "Goals",
294
- "userstories": "UserStories",
295
- "user stories": "UserStories",
296
- "requirements": "Requirements",
297
- "successmetrics": "SuccessMetrics",
298
- "success metrics": "SuccessMetrics",
299
- "architecture": "Architecture",
300
- "nonfunctionalrequirements": "NonFunctionalRequirements",
301
- "non-functional requirements": "NonFunctionalRequirements",
302
- "non functional requirements": "NonFunctionalRequirements",
303
- "openquestions": "OpenQuestions",
304
- "open questions": "OpenQuestions",
305
- }
306
-
307
-
308
- def _is_user_customized(content: str, auto_markers: tuple[str, ...]) -> bool:
309
- """Check if file content has been customized beyond auto-generated content.
310
-
311
- Returns True if the content does NOT contain any of the known auto-generation
312
- markers, suggesting the user has substantially rewritten the file.
313
- """
314
- return not any(marker in content for marker in auto_markers)
315
-
316
-
317
- # Legacy underscore-prefixed alias -- extraction of the shared helper into
318
- # ``_vbrief_build`` (#454) preserves the public surface tests import today.
319
- _slugify = _slugify_shared
320
-
321
-
322
- def _parse_prd_narratives(content: str) -> dict[str, str]:
323
- """Parse structured ## sections from PRD/SPECIFICATION markdown into narrative keys.
324
-
325
- Recognizes known PRD headings (both CamelCase and space-separated forms)
326
- and maps them to canonical narrative key names matching prd_render.py
327
- NARRATIVE_KEY_ORDER.
328
-
329
- Returns a dict of narrative_key -> section_body for recognized sections.
330
- """
331
- narratives: dict[str, str] = {}
332
- parts = re.split(r"^##\s+", content, flags=re.MULTILINE)
333
-
334
- for part in parts[1:]: # skip preamble before first ##
335
- heading, _, body = part.partition("\n")
336
- heading = heading.strip()
337
- # Strip trailing auto-generated footer (--- followed by italicized note)
338
- body = re.sub(r"\n---\s*\n\*{1,2}[^*]+\*{1,2}\s*$", "", body)
339
- body = body.strip()
340
-
341
- if not body:
342
- continue
343
-
344
- key = _HEADING_TO_NARRATIVE_KEY.get(heading.lower())
345
- if key:
346
- narratives[key] = body
347
-
348
- return narratives
349
-
350
-
351
- def _parse_roadmap_items(roadmap_path: Path) -> tuple[list[dict], dict[str, str], list[dict]]:
352
- """Parse ROADMAP.md and extract items as structured data.
353
-
354
- Returns a tuple of:
355
- - active items: list of dicts with keys: number, title, phase, tier.
356
- - phase_descriptions: dict mapping phase heading -> description text.
357
- - completed items: list of dicts with keys: number, title (from Completed section).
358
- """
359
- if not roadmap_path.exists():
360
- return [], {}, []
361
-
362
- content = roadmap_path.read_text(encoding="utf-8")
363
- items: list[dict] = []
364
- completed_items: list[dict] = []
365
- phase_descriptions: dict[str, str] = {}
366
- current_phase = ""
367
- current_tier = ""
368
- in_completed = False
369
- # Accumulate description lines between heading and first list item
370
- desc_lines: list[str] = []
371
- capturing_desc = False
372
- _synthetic_counter = 0
373
-
374
- for line in content.splitlines():
375
- # Detect phase headings (## Level)
376
- phase_match = re.match(r"^##\s+(.+)", line)
377
- if phase_match:
378
- # Save previous phase description
379
- if current_phase and desc_lines:
380
- phase_descriptions[current_phase] = "\n".join(desc_lines).strip()
381
- desc_lines = []
382
-
383
- current_phase = phase_match.group(1).strip()
384
- current_tier = ""
385
- if "completed" in current_phase.lower():
386
- in_completed = True
387
- capturing_desc = False
388
- else:
389
- in_completed = False
390
- capturing_desc = True
391
- continue
392
-
393
- # Detect tier subheadings (### Level)
394
- tier_match = re.match(r"^###\s+(.+)", line)
395
- if tier_match:
396
- if current_phase and desc_lines and capturing_desc:
397
- phase_descriptions[current_phase] = "\n".join(desc_lines).strip()
398
- desc_lines = []
399
- capturing_desc = False
400
- current_tier = tier_match.group(1).strip()
401
- continue
402
-
403
- # Accumulate phase description text (non-empty, non-list lines)
404
- if capturing_desc and not in_completed:
405
- stripped = line.strip()
406
- if stripped and not stripped.startswith("-"):
407
- desc_lines.append(stripped)
408
- continue
409
- if stripped.startswith("-"):
410
- # First list item ends description capture
411
- if desc_lines:
412
- phase_descriptions[current_phase] = "\n".join(desc_lines).strip()
413
- desc_lines = []
414
- capturing_desc = False
415
- # Fall through to item parsing below
416
- else:
417
- # Empty line during desc capture
418
- if desc_lines:
419
- desc_lines.append("")
420
- continue
421
-
422
- if not current_phase:
423
- continue
424
-
425
- # --- Completed section items ---
426
- if in_completed:
427
- # Match: - ~~#NNN -- Title~~ or - ~~Title~~
428
- comp_match = re.match(r"^-\s+~~(?:#?(\d+)\s*--?\s*)?(.+?)~~", line)
429
- if comp_match:
430
- comp_number = comp_match.group(1) or ""
431
- comp_title = comp_match.group(2).strip()
432
- completed_items.append({
433
- "number": comp_number,
434
- "title": comp_title,
435
- "phase": current_phase,
436
- })
437
- continue
438
-
439
- # --- Active section items ---
440
- # Match GitHub issue format: - **#NNN** -- Title
441
- item_match = re.match(r"^-\s+\*\*#(\d+)\*\*\s+--\s+(.+)", line)
442
- if item_match:
443
- items.append({
444
- "number": item_match.group(1),
445
- "title": item_match.group(2).strip(),
446
- "phase": current_phase,
447
- "tier": current_tier,
448
- })
449
- continue
450
-
451
- # Match task-based format: - **`X.Y.Z`** Title or - `X.Y.Z` Title
452
- task_match = re.match(
453
- r"^-\s+(?:\*\*)?`([^`]+)`(?:\*\*)?\s+(.+)", line
454
- )
455
- if task_match:
456
- task_id = task_match.group(1).strip()
457
- title = task_match.group(2).strip()
458
- items.append({
459
- "number": "",
460
- "title": title,
461
- "phase": current_phase,
462
- "tier": current_tier,
463
- "task_id": task_id,
464
- })
465
- continue
466
-
467
- # Generic fallback: - Title (any list item under a ## heading)
468
- generic_match = re.match(r"^-\s+(.+)", line)
469
- if generic_match:
470
- title = generic_match.group(1).strip()
471
- # Skip items that look like sub-bullets or empty
472
- if not title:
473
- continue
474
- _synthetic_counter += 1
475
- items.append({
476
- "number": "",
477
- "title": title,
478
- "phase": current_phase,
479
- "tier": current_tier,
480
- "synthetic_id": f"roadmap-{_synthetic_counter}",
481
- })
482
- continue
483
-
484
- # Save final phase description
485
- if current_phase and desc_lines and not in_completed:
486
- phase_descriptions[current_phase] = "\n".join(desc_lines).strip()
487
-
488
- return items, phase_descriptions, completed_items
489
-
490
-
491
- # --- repo detection (#613) ---
492
- # Regex mirroring ``reconcile_issues.detect_repo`` + ``issue_ingest._resolve_
493
- # repo_url``: accept both ``git@github.com:owner/repo.git`` and
494
- # ``https://github.com/owner/repo.git`` origin URLs and tolerate a trailing
495
- # ``.git`` suffix. Exposed at module level so tests can monkeypatch
496
- # ``_GIT_REMOTE_RE`` if they need to stub edge-case remotes without fighting
497
- # subprocess.
498
- _GIT_REMOTE_RE = re.compile(
499
- r"github\.com[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?(?:\s|$)",
500
- )
501
-
502
-
503
- def _detect_repo_from_git_remote(project_root: Path | None) -> str:
504
- """Return ``https://github.com/owner/repo`` from ``git remote get-url origin``.
505
-
506
- Matches the detection approach used by ``scripts/issue_ingest.py`` /
507
- ``scripts/reconcile_issues.detect_repo`` -- shells out to ``git remote
508
- get-url origin`` inside ``project_root`` (not the migrator's own CWD,
509
- which would pick up deft's own remote on consumer projects, #538) and
510
- returns the matching ``https://github.com/{owner}/{repo}`` URL. Returns
511
- the empty string on any failure (git missing, remote missing, parse
512
- failure) so callers can fall back cleanly without surfacing subprocess
513
- errors to the migration log.
514
- """
515
- cwd = str(project_root) if project_root is not None else None
516
- try:
517
- result = subprocess.run(
518
- ["git", "remote", "get-url", "origin"],
519
- capture_output=True,
520
- text=True,
521
- timeout=10,
522
- cwd=cwd,
523
- )
524
- except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
525
- return ""
526
- if result.returncode != 0:
527
- return ""
528
- url = (result.stdout or "").strip()
529
- if not url:
530
- return ""
531
- match = _GIT_REMOTE_RE.search(url)
532
- if not match:
533
- return ""
534
- return f"https://github.com/{match.group(1)}/{match.group(2)}"
535
-
536
-
537
- def _resolve_repo_url(
538
- spec_vbrief: dict | None,
539
- project_root: Path | None = None,
540
- ) -> str:
541
- """Resolve ``https://github.com/{owner}/{repo}`` for scope vBRIEF references.
542
-
543
- Resolution order (highest precedence first):
544
-
545
- 1. ``spec_vbrief.vBRIEFInfo.repository`` (OWNER/REPO string).
546
- 2. Any ``github.com/{owner}/{repo}`` URI inside ``spec_vbrief.plan.
547
- references[]`` (matches canonical v0.6 and legacy shapes).
548
- 3. ``git remote get-url origin`` rooted at ``project_root`` when
549
- provided -- mirrors ``scripts/issue_ingest.py`` so consumer-project
550
- migrations resolve to the consumer's GitHub repo, not deft's own
551
- remote (#538, #613).
552
-
553
- Returns the empty string when none resolve. Callers that receive the
554
- empty string MUST NOT emit a ``references[]`` entry for GitHub issues
555
- (the canonical v0.6 shape requires ``uri`` -- see #613 and
556
- ``conventions/references.md``).
557
- """
558
- # Try spec_vbrief metadata first
559
- if spec_vbrief:
560
- repo = spec_vbrief.get("vBRIEFInfo", {}).get("repository", "")
561
- if repo:
562
- return f"https://github.com/{repo}"
563
- # Check references for a GitHub URL pattern
564
- refs = spec_vbrief.get("plan", {}).get("references", [])
565
- for ref in refs:
566
- uri = ref.get("uri", "")
567
- if urlparse(uri).netloc in ("github.com", "www.github.com"):
568
- # Extract owner/repo from URL
569
- parts = uri.split("github.com/")[-1].split("/")
570
- if len(parts) >= 2:
571
- return f"https://github.com/{parts[0]}/{parts[1]}"
572
- # #613: fall back to the project's git origin so consumer migrations
573
- # get canonical URIs even when spec_vbrief is absent or carries no
574
- # repository hint.
575
- if project_root is not None:
576
- return _detect_repo_from_git_remote(project_root)
577
- return ""
578
-
579
-
580
- def _extract_tech_stack(project_content: str) -> str:
581
- """Extract tech stack information from PROJECT.md content.
582
-
583
- Looks for common patterns:
584
- - **Tech Stack**: value
585
- - ## Tech Stack\n content
586
- - Tech Stack: value
587
- Returns extracted tech stack string, or empty string if not found.
588
- """
589
- # Pattern 1: **Tech Stack**: value (bold label on a single line)
590
- match = re.search(
591
- r"\*\*Tech\s+Stack\*\*\s*:\s*(.+)", project_content, re.IGNORECASE
592
- )
593
- if match:
594
- return match.group(1).strip()
595
-
596
- # Pattern 2: ## Tech Stack section (grab lines until next ## or EOF)
597
- section_match = re.search(
598
- r"##\s+Tech\s+Stack\s*\n(.*?)(?=\n##\s|\Z)",
599
- project_content,
600
- re.IGNORECASE | re.DOTALL,
601
- )
602
- if section_match:
603
- section = section_match.group(1).strip()
604
- if section:
605
- return section
606
-
607
- # Pattern 3: plain Tech Stack: value
608
- plain_match = re.search(
609
- r"Tech\s+Stack\s*:\s*(.+)", project_content, re.IGNORECASE
610
- )
611
- if plain_match:
612
- return plain_match.group(1).strip()
613
-
614
- return ""
615
-
616
-
617
- def _first_prose_paragraph(content: str) -> str:
618
- """Return the first non-empty prose paragraph from markdown content.
619
-
620
- Skips fenced code blocks, blank lines, markdown heading lines, and list
621
- items; returns the first plain paragraph it finds. Falls back to the
622
- first H1 (`# Title`) heading text if no prose paragraph exists. Returns
623
- the empty string if nothing usable is found.
624
- """
625
- if not content:
626
- return ""
627
- first_h1 = ""
628
- in_code_block = False
629
- paragraph_lines: list[str] = []
630
-
631
- def _flush() -> str:
632
- if paragraph_lines:
633
- return " ".join(paragraph_lines).strip()
634
- return ""
635
-
636
- for line in content.splitlines():
637
- stripped = line.strip()
638
- if stripped.startswith("```"):
639
- in_code_block = not in_code_block
640
- continue
641
- if in_code_block:
642
- continue
643
- # First H1 title (# Title). Ignore H2/H3 etc.
644
- if re.match(r"^#\s+", stripped) and not first_h1:
645
- first_h1 = re.sub(r"^#\s+", "", stripped).strip()
646
- continue
647
- # Skip other headings -- also flush any accumulated paragraph first
648
- if stripped.startswith("#"):
649
- para = _flush()
650
- if para:
651
- return para
652
- paragraph_lines.clear()
653
- continue
654
- # List items (unordered and ordered), blockquotes, and tables are not
655
- # prose paragraphs for Overview purposes. Ordered list detection uses
656
- # the standard markdown pattern "N.\s" at the line start.
657
- if stripped.startswith(("-", "*", ">", "|")) or re.match(r"^\d+\.\s", stripped):
658
- para = _flush()
659
- if para:
660
- return para
661
- paragraph_lines.clear()
662
- continue
663
- # Empty line ends paragraph
664
- if not stripped:
665
- para = _flush()
666
- if para:
667
- return para
668
- paragraph_lines.clear()
669
- continue
670
- paragraph_lines.append(stripped)
671
-
672
- # Final paragraph at EOF
673
- para = _flush()
674
- if para:
675
- return para
676
- # Fallback to H1 title text
677
- return first_h1
678
-
679
-
680
- def _derive_overview_narrative(
681
- spec_vbrief: dict | None,
682
- spec_md_content: str | None,
683
- project_content: str | None,
684
- scope_item_count: int,
685
- ) -> str:
686
- """Derive an Overview narrative for PROJECT-DEFINITION.vbrief.json (#417).
687
-
688
- D3 requires the `Overview` narrative key (after case-folding) to be
689
- present on `vbrief/PROJECT-DEFINITION.vbrief.json`. Resolution order:
690
-
691
- 1. `spec_vbrief.plan.narratives['Overview']` if present and non-empty.
692
- 2. First prose paragraph / H1 title of `SPECIFICATION.md` (pre-sentinel).
693
- 3. First prose paragraph / H1 title of `PROJECT.md` (pre-sentinel).
694
- 4. Synthesized placeholder naming the scope count, telling the user how
695
- to fill it in. Always non-empty so `vbrief:validate` passes D3.
696
- """
697
- # 1. spec_vbrief narratives (set by step 2b PRD/SPEC ingestion, or by the
698
- # caller if there was a pre-existing specification.vbrief.json).
699
- if spec_vbrief:
700
- narratives = spec_vbrief.get("plan", {}).get("narratives", {})
701
- if isinstance(narratives, dict):
702
- ov = narratives.get("Overview")
703
- if isinstance(ov, str) and ov.strip():
704
- return ov.strip()
705
-
706
- # 2. SPECIFICATION.md prose / title -- but only if not already a sentinel
707
- # stub (would happen on re-run after migration).
708
- if spec_md_content and DEPRECATION_SENTINEL not in spec_md_content:
709
- derived = _first_prose_paragraph(spec_md_content)
710
- if derived:
711
- return derived
712
-
713
- # 3. PROJECT.md prose / title -- same sentinel guard.
714
- if project_content and DEPRECATION_SENTINEL not in project_content:
715
- derived = _first_prose_paragraph(project_content)
716
- if derived:
717
- return derived
718
-
719
- # 4. Synthesized fallback. Always non-empty so the D3 validator passes.
720
- if scope_item_count > 0:
721
- return (
722
- f"Project overview was not auto-derived during migration. "
723
- f"{scope_item_count} scope item(s) were created in vbrief/pending/. "
724
- f"Update vbrief/PROJECT-DEFINITION.vbrief.json narratives['Overview'] "
725
- f"manually to describe your project."
726
- )
727
- return (
728
- "Project overview was not auto-derived during migration. "
729
- "Update vbrief/PROJECT-DEFINITION.vbrief.json narratives['Overview'] "
730
- "manually to describe your project."
731
- )
732
-
733
-
734
- def _build_project_definition(
735
- spec_vbrief: dict | None,
736
- project_content: str | None,
737
- scope_items: list[dict],
738
- repo_url: str = "",
739
- spec_md_content: str | None = None,
740
- ) -> dict:
741
- """Build PROJECT-DEFINITION.vbrief.json from existing sources.
742
-
743
- Per RFC #309 D3:
744
- - narratives holds project identity (overview, tech stack, architecture, risks, config)
745
- - items acts as a scope registry referencing individual vBRIEF files
746
-
747
- ``spec_md_content`` is the raw SPECIFICATION.md text (pre-sentinel) for
748
- Overview-narrative derivation on canonical v0.19 consumer projects that
749
- have no pre-existing ``specification.vbrief.json`` (#417).
750
-
751
- Per #498: every ``plan.items[*].id`` is routed through
752
- :func:`_vbrief_validation.slugify_id` so the scope-registry id conforms
753
- to the schema-locked ID regex ``^[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)*$``
754
- and matches the slug used for the scope vBRIEF filename.
755
- """
756
- narratives: dict[str, str] = {}
757
-
758
- # Extract from specification.vbrief.json
759
- if spec_vbrief:
760
- plan = spec_vbrief.get("plan", {})
761
- if isinstance(plan, dict):
762
- spec_narratives = plan.get("narratives", {})
763
- if isinstance(spec_narratives, dict):
764
- for key, value in spec_narratives.items():
765
- if isinstance(value, str):
766
- narratives[key] = value
767
-
768
- # Extract from PROJECT.md
769
- if project_content:
770
- narratives["ProjectConfig"] = project_content
771
- # Extract tech stack into its own narrative key (D3 requirement)
772
- tech_stack = _extract_tech_stack(project_content)
773
- if tech_stack:
774
- narratives["tech stack"] = tech_stack
775
-
776
- # Ensure Overview narrative is present AND non-empty so the generated
777
- # PROJECT-DEFINITION passes `scripts/vbrief_validate.py::
778
- # validate_project_definition` D3 out of the box (#417). Case-insensitive
779
- # check because D3 lowers() keys before comparing to
780
- # PROJECT_DEF_EXPECTED_NARRATIVES = {"overview", "tech stack"}. The
781
- # value-awareness matters because a pre-existing specification.vbrief.json
782
- # may carry an empty / whitespace-only `Overview` -- without this check,
783
- # that blank value would round-trip into PROJECT-DEFINITION unchanged
784
- # (D3 only asserts key presence, so we surface a useful narrative instead).
785
- overview_key = next(
786
- (k for k in narratives if k.lower() == "overview"), None
787
- )
788
- overview_value = narratives.get(overview_key, "") if overview_key else ""
789
- if not isinstance(overview_value, str) or not overview_value.strip():
790
- derived = _derive_overview_narrative(
791
- spec_vbrief, spec_md_content, project_content, len(scope_items)
792
- )
793
- if derived:
794
- # Keep the existing key spelling (e.g. "overview" vs "Overview")
795
- # if one is present so we do not create a second key that differs
796
- # only in case. Default to CamelCase "Overview" for new entries.
797
- narratives[overview_key or "Overview"] = derived
798
-
799
- # Per #498 D8 / validator D3: PROJECT_DEF_EXPECTED_NARRATIVES requires
800
- # `tech stack` (lowercase, space-separated) alongside `overview`. When
801
- # no PROJECT.md was present we never populated it above, which the
802
- # self-validation hook surfaces as a hard-block schema error. Synthesize
803
- # a placeholder -- the same pattern #417 established for Overview -- so
804
- # minimal fixtures round-trip cleanly and operators see a visible
805
- # "fill-me-in" hint rather than a silent regression.
806
- tech_stack_key = next((k for k in narratives if k.lower() == "tech stack"), None)
807
- tech_stack_value = narratives.get(tech_stack_key, "") if tech_stack_key else ""
808
- if not isinstance(tech_stack_value, str) or not tech_stack_value.strip():
809
- narratives[tech_stack_key or "tech stack"] = (
810
- "Tech stack was not auto-derived during migration. "
811
- "Update vbrief/PROJECT-DEFINITION.vbrief.json narratives['tech stack'] "
812
- "with your language, framework, and runtime versions."
813
- )
814
-
815
- items: list[dict] = []
816
- # Per #498: use slug-safe ids, disambiguating collisions within a single
817
- # registry build so every emitted id is unique and passes the schema's
818
- # ID regex out of the box.
819
- emitted_scope_ids: set[str] = set()
820
- for scope in scope_items:
821
- number = scope.get("number", "")
822
- id_source = slug_fallback_id(scope)
823
- scope_id = f"scope-{slugify_id(id_source, emitted_scope_ids)}"
824
- # #499-registry: registry status mirrors the scope's reconciled
825
- # status when the caller provides it (the migrator passes
826
- # reconciled items whose status already reflects the #506
827
- # lifecycle<->status mapping). Falls back to the phase-based
828
- # heuristic for unstructured callers (e.g. direct test callers
829
- # that pass raw ROADMAP items without reconciliation).
830
- scope_status = scope.get("status")
831
- if not isinstance(scope_status, str) or not scope_status:
832
- phase = str(scope.get("phase", "") or "")
833
- scope_status = (
834
- "completed" if "completed" in phase.lower() else "pending"
835
- )
836
- item_title = scope.get("title", "Untitled")
837
- item: dict = {
838
- "id": scope_id,
839
- "title": item_title,
840
- "status": scope_status,
841
- }
842
- # #613: emit canonical v0.6 references on PROJECT-DEFINITION.plan.
843
- # items[*].references so every scope registry row links back to
844
- # its origin GitHub issue in the same shape the scope vBRIEF file
845
- # carries. The VBriefReference schema requires ``uri`` and a
846
- # ``^x-vbrief/`` type -- without a resolvable ``repo_url`` we
847
- # cannot honestly construct ``uri`` so we drop the reference
848
- # rather than emit a malformed stub.
849
- if number and repo_url:
850
- ref_title = (
851
- f"Issue #{number}: {item_title}"
852
- if item_title and item_title != "Untitled"
853
- else f"Issue #{number}"
854
- )
855
- item["references"] = [
856
- _reference_with_default_trust(
857
- {
858
- "uri": f"{repo_url}/issues/{number}",
859
- "type": "x-vbrief/github-issue",
860
- "title": ref_title,
861
- }
862
- )
863
- ]
864
- items.append(item)
865
-
866
- return {
867
- "vBRIEFInfo": {
868
- "version": EMITTED_VBRIEF_VERSION,
869
- "description": "Project definition -- synthesized gestalt of the project.",
870
- },
871
- "plan": {
872
- "title": "PROJECT-DEFINITION",
873
- "status": "running",
874
- "narratives": narratives,
875
- "items": items,
876
- },
877
- }
878
-
879
-
880
- # Legacy underscore-prefixed alias -- the shared helper lives in
881
- # ``_vbrief_build`` (#454). Tests and callers continue to import
882
- # ``_create_scope_vbrief`` from this module.
883
- _create_scope_vbrief = _create_scope_vbrief_shared
884
-
885
-
886
- def _deprecation_redirect(
887
- original_name: str,
888
- pointer_target: str,
889
- scope_note: str,
890
- ) -> str:
891
- """Generate deprecation redirect content for a replaced file.
892
-
893
- Opens with the canonical 4-line banner documented in
894
- ``conventions/machine-generated-banner.md`` (#572) so downstream
895
- detectors (pre-cutover guards, user-customisation heuristics)
896
- have a stable token to match on. The legacy ``DEPRECATION_SENTINEL``
897
- comment is preserved on the fifth line for one release cycle so
898
- tools that still search for it continue to work.
899
- """
900
- return (
901
- "<!-- AUTO-GENERATED by task migrate:vbrief -- DO NOT EDIT MANUALLY -->\n"
902
- "<!-- Purpose: deprecation redirect -->\n"
903
- "<!-- Source of truth: n/a -->\n"
904
- "<!-- Regenerate with: task migrate:vbrief -->\n"
905
- f"{DEPRECATION_SENTINEL}\n"
906
- f"# {original_name} -- DEPRECATED\n"
907
- f"\n"
908
- f"This file has been replaced by the vBRIEF-centric document model.\n"
909
- f"\n"
910
- f"**See instead:**\n"
911
- f"- `{pointer_target}` -- project definition and scope registry\n"
912
- f"- `vbrief/pending/` -- individual scope vBRIEFs (backlog)\n"
913
- f"- `vbrief/active/` -- in-progress scope vBRIEFs\n"
914
- f"\n"
915
- f"{scope_note}\n"
916
- f"\n"
917
- f"Migrated on {_TODAY} by `task migrate:vbrief` (RFC #309, Story #312).\n"
918
- )
919
-
920
-
921
- # --- gitignore helper (#530 + #567) ---
922
- def _ensure_gitignore_patterns(
923
- project_root: Path, *, dry_run: bool
924
- ) -> tuple[str | None, FileModification | None]:
925
- """Append migration-backup gitignore patterns to ``.gitignore`` idempotently.
926
-
927
- Per issue #530 Option A: the migrator writes the two ``.premigrate.*``
928
- glob patterns under a comment block so the backups do not leak into
929
- commits on greenfield consumer projects. Idempotent -- checks whether
930
- each pattern is already present as a standalone rule before appending.
931
- If ``.gitignore`` is absent, it is created.
932
-
933
- Per issue #567: when a non-dry-run write actually lands, also return
934
- a ``FileModification`` record (pre_hash / post_hash / appended bytes /
935
- operation = ``append`` or ``create``) so rollback can symmetrically
936
- reverse the forward-pass edit.
937
-
938
- Returns ``(log_line, file_modification)``. ``log_line`` is ``None``
939
- when the append is a no-op (patterns already present); ``file_
940
- modification`` is ``None`` under ``dry_run`` or when no write landed.
941
- """
942
- gitignore = project_root / ".gitignore"
943
- existing: list[str]
944
- pre_existed = gitignore.is_file()
945
- if pre_existed:
946
- try:
947
- existing_text = gitignore.read_text(encoding="utf-8")
948
- except OSError:
949
- return None, None
950
- existing = existing_text.splitlines()
951
- else:
952
- existing_text = ""
953
- existing = []
954
-
955
- # A pattern is considered "present" if it appears verbatim on any
956
- # non-comment line. This matches git's own loose interpretation: a
957
- # project-level override that negates the pattern (``!*.premigrate.md``)
958
- # still counts as "gitignore is aware of it" for our purposes.
959
- existing_patterns = {
960
- line.strip()
961
- for line in existing
962
- if line.strip() and not line.strip().startswith("#")
963
- }
964
- missing = [p for p in _GITIGNORE_PATTERNS if p not in existing_patterns]
965
- if not missing:
966
- return None, None
967
-
968
- # Build the new block. When any patterns are missing we always include
969
- # the full comment block for the first append so operators see the
970
- # rationale. If the marker line is already present (partial prior
971
- # append), skip re-emitting the comment block and just append the
972
- # missing patterns under a short note.
973
- block_lines: list[str] = []
974
- if _GITIGNORE_MARKER_LINE in existing:
975
- block_lines.append(
976
- "# Additional migration backup patterns appended by "
977
- "`task migrate:vbrief`."
978
- )
979
- else:
980
- block_lines.extend(_GITIGNORE_COMMENT_BLOCK)
981
- block_lines.extend(missing)
982
- # Ensure the file ends with a newline before appending so we do not
983
- # merge our comment onto a previous pattern line.
984
- separator = ""
985
- if existing_text and not existing_text.endswith("\n"):
986
- separator = "\n"
987
- # ``appended_content`` captures the EXACT bytes we add to the file
988
- # (including the leading separator / blank-line spacer) so the #567
989
- # rollback path can strip them verbatim.
990
- appended_content = (
991
- separator
992
- + ("\n" if existing_text else "")
993
- + "\n".join(block_lines)
994
- + "\n"
995
- )
996
- if pre_existed:
997
- new_text = existing_text + appended_content
998
- operation = "append"
999
- else:
1000
- # Greenfield: the full file body IS the appended content and
1001
- # rollback deletes the file rather than stripping a suffix.
1002
- new_text = "\n".join(block_lines) + "\n"
1003
- appended_content = new_text
1004
- operation = "create"
1005
-
1006
- rel = ".gitignore"
1007
- verb = "CREATE" if not pre_existed else "UPDATE"
1008
- if dry_run:
1009
- return (
1010
- f"DRYRUN {verb} {rel} (append {len(missing)} migration-backup "
1011
- f"pattern(s): {', '.join(missing)})"
1012
- ), None
1013
- pre_hash = sha256_of(gitignore) if pre_existed else ""
1014
- gitignore.write_text(new_text, encoding="utf-8")
1015
- post_hash = sha256_of(gitignore)
1016
- modification = FileModification(
1017
- path=rel,
1018
- operation=operation,
1019
- pre_hash=pre_hash,
1020
- post_hash=post_hash,
1021
- appended_content=appended_content,
1022
- )
1023
- return (
1024
- f"{verb} {rel} (append {len(missing)} migration-backup "
1025
- f"pattern(s): {', '.join(missing)})"
1026
- ), modification
1027
- # --- end gitignore helper ---
1028
-
1029
-
1030
- # --- traces strip helpers (#529) ---
1031
- def _strip_traces_from_narrative(narrative: str) -> tuple[str, list[str]]:
1032
- """Strip ``**Traces**: ...`` lines from a LegacyArtifacts narrative.
1033
-
1034
- ``plan.items[].subItems[].narrative.Traces`` is the single source of
1035
- truth (see issue #529 for the 25/36 drift inventory). The duplicated
1036
- ``**Traces**: ...`` line inside each LegacyArtifacts task block is
1037
- stripped during migration so downstream tooling cannot pick a stale
1038
- second copy.
1039
-
1040
- Returns ``(cleaned_narrative, stripped_task_ids)``. The cleaned
1041
- narrative preserves every other line verbatim; stripped task ids are
1042
- attributed to the preceding ``### tX.Y.Z`` header when available, or
1043
- recorded as ``<unattributed>`` when a ``**Traces**:`` line appears
1044
- outside any recognised task block.
1045
- """
1046
- if not narrative or "**Traces**" not in narrative:
1047
- return narrative, []
1048
-
1049
- stripped_ids: list[str] = []
1050
- lines = narrative.splitlines()
1051
- current_task_id = ""
1052
- cleaned: list[str] = []
1053
- for line in lines:
1054
- header_match = _TASK_HEADER_RE.match(line)
1055
- if header_match:
1056
- current_task_id = header_match.group("task_id")
1057
- if _TRACES_LINE_RE.match(line):
1058
- attribution = current_task_id or "<unattributed>"
1059
- if attribution not in stripped_ids:
1060
- stripped_ids.append(attribution)
1061
- continue
1062
- cleaned.append(line)
1063
- # Preserve the trailing newline shape of the input (emit_legacy_artifacts
1064
- # emits narratives terminated with ``\n``).
1065
- trailing_newline = "\n" if narrative.endswith("\n") else ""
1066
- return "\n".join(cleaned) + trailing_newline, stripped_ids
1067
-
1068
-
1069
- def _write_traces_stripped_note(
1070
- project_root: Path,
1071
- stripped_audit: list[dict],
1072
- *,
1073
- dry_run: bool,
1074
- ) -> tuple[Path | None, str | None]:
1075
- """Append a Traces-stripped section to ``vbrief/migration/RECONCILIATION.md``.
1076
-
1077
- Creates the file if it doesn't exist. Returns ``(path, log_line)``.
1078
- ``log_line`` is ``None`` when ``stripped_audit`` is empty (nothing to
1079
- emit). Called after :func:`_vbrief_reconciliation.write_reconciliation_report`
1080
- so the Traces-stripped section follows any reconciliation conflicts
1081
- already recorded in the same file.
1082
- """
1083
- if not stripped_audit:
1084
- return None, None
1085
- report_dir = project_root / "vbrief" / "migration"
1086
- target = report_dir / "RECONCILIATION.md"
1087
- total = sum(len(entry.get("task_ids", [])) for entry in stripped_audit)
1088
-
1089
- section_lines: list[str] = [
1090
- "## Traces lines stripped from LegacyArtifacts (#529)",
1091
- "",
1092
- (
1093
- "Per issue #529 the migrator strips duplicated ``**Traces**: ...`` "
1094
- "lines from LegacyArtifacts task blocks so downstream tooling reads "
1095
- "a single source of truth from ``plan.items[].subItems[].narrative.Traces``."
1096
- ),
1097
- "",
1098
- ]
1099
- for entry in stripped_audit:
1100
- source = entry.get("source", "?")
1101
- task_ids = entry.get("task_ids", []) or ["<none>"]
1102
- section_lines.append(f"- `{source}`: {', '.join(task_ids)}")
1103
- section_lines.append("")
1104
-
1105
- section = "\n".join(section_lines)
1106
- rel = "vbrief/migration/RECONCILIATION.md"
1107
-
1108
- if dry_run:
1109
- return None, (
1110
- f"DRYRUN APPEND {rel} (Traces-stripped audit: {total} task(s))"
1111
- )
1112
-
1113
- report_dir.mkdir(parents=True, exist_ok=True)
1114
- if target.is_file():
1115
- existing = target.read_text(encoding="utf-8")
1116
- # Idempotency guard (Greptile #561 P2): re-running the migrator on
1117
- # a project whose PROJECT.md / PRD.md still carries **Traces**:
1118
- # lines would otherwise append a duplicate section on every pass.
1119
- # Skip when the canonical section header is already present.
1120
- if _TRACES_SECTION_HEADER in existing:
1121
- return target, (
1122
- f"SKIP {rel} (Traces-stripped section already recorded)"
1123
- )
1124
- if not existing.endswith("\n"):
1125
- existing += "\n"
1126
- separator = "" if existing.endswith("\n\n") else "\n"
1127
- target.write_text(existing + separator + section, encoding="utf-8")
1128
- verb = "APPEND"
1129
- else:
1130
- header = (
1131
- "# Migration reconciliation report\n"
1132
- "\n"
1133
- f"Generated: {now_utc_iso()}\n"
1134
- "\n"
1135
- "Per #496 / #529 this file records SPEC/ROADMAP reconciliation "
1136
- "decisions and LegacyArtifacts traces-line stripping performed "
1137
- "during `task migrate:vbrief`.\n"
1138
- "\n"
1139
- )
1140
- target.write_text(header + section, encoding="utf-8")
1141
- verb = "CREATE"
1142
- return target, f"{verb} {rel} (Traces-stripped audit: {total} task(s))"
1143
- # --- end traces strip helpers ---
1144
-
1145
-
1146
- def _track_managed_subdir(
1147
- project_root: Path,
1148
- subdir_name: str,
1149
- pre_existed: dict[str, bool],
1150
- created_dirs: list[str],
1151
- ) -> None:
1152
- """Add ``vbrief/{subdir_name}`` to ``created_dirs`` if we created it (#527/#528).
1153
-
1154
- Uses ``pre_existed`` (captured at migration start) so the decision is
1155
- derived from safety-manifest state, not from scanning the filesystem.
1156
- A repeat call is a no-op, preserving idempotency for callers that may
1157
- invoke this at multiple points in the migration flow.
1158
- """
1159
- rel = f"vbrief/{subdir_name}"
1160
- if pre_existed.get(subdir_name):
1161
- return
1162
- if rel in created_dirs:
1163
- return
1164
- folder = project_root / "vbrief" / subdir_name
1165
- if folder.is_dir():
1166
- created_dirs.append(rel)
1167
-
1168
-
1169
- # --- prettier remediation breadcrumb (#670) ---
1170
- # The migrator's generated artifacts (Markdown stubs + JSON vBRIEFs) are not
1171
- # guaranteed to be byte-identical to what ``prettier --write`` would produce
1172
- # (Python ``json.dumps`` always expands single-element arrays prettier would
1173
- # collapse; trivial Markdown spacing around HTML-comment blocks). Migration is
1174
- # a rare one-shot pre-v0.20 legacy path, so byte-matching prettier in the
1175
- # generator (#670 option 1) or auto-running it (option 2) is intentionally out
1176
- # of scope. Instead we emit a remediation breadcrumb so a consumer whose
1177
- # ``task check`` runs ``prettier --check`` (or ``task fmt:check``) turns a
1178
- # surprise baseline failure into a known one-command fix.
1179
- _PRETTIER_BREADCRUMB_MARKER = "Prettier remediation (#670)"
1180
-
1181
- # Canonical generated paths the breadcrumb enumerates. These are the migration
1182
- # outputs most likely to trip ``prettier --check``.
1183
- _PRETTIER_BREADCRUMB_PATHS: tuple[str, ...] = (
1184
- "SPECIFICATION.md",
1185
- "PROJECT.md",
1186
- "ROADMAP.md",
1187
- "vbrief/specification.vbrief.json",
1188
- "vbrief/PROJECT-DEFINITION.vbrief.json",
1189
- "vbrief/migration/LEGACY-REPORT.md",
1190
- )
1191
-
1192
-
1193
- def _prettier_breadcrumb_body() -> list[str]:
1194
- """Return the remediation note body as plain-text lines (no Markdown heading).
1195
-
1196
- Shared verbatim between the stdout action log and the
1197
- ``vbrief/migration/LEGACY-REPORT.md`` section (the latter under a ``##``
1198
- heading) so the two surfaces never drift.
1199
- """
1200
- lines = [
1201
- f"{_PRETTIER_BREADCRUMB_MARKER}: migration output is NOT guaranteed to "
1202
- "be prettier-clean. If your `task check` runs `prettier --check` (or "
1203
- "`task fmt:check`), these generated files may fail the gate on a fresh "
1204
- "post-migration checkout:",
1205
- ]
1206
- for path in _PRETTIER_BREADCRUMB_PATHS:
1207
- lines.append(f" - {path}")
1208
- lines.append(
1209
- "Fix before `task check`: run `prettier --write` on the files above, "
1210
- "or add them to `.prettierignore`."
1211
- )
1212
- return lines
1213
-
1214
-
1215
- def _append_prettier_breadcrumb(report_path: Path) -> bool:
1216
- """Append the prettier remediation section to an existing LEGACY-REPORT.md.
1217
-
1218
- Idempotent: returns ``False`` without writing when the breadcrumb marker
1219
- is already present (a migrator re-run), and ``True`` after appending
1220
- otherwise. The caller only invokes this when ``report_path`` exists --
1221
- i.e. ``scripts/_vbrief_legacy.emit_legacy_report`` captured legacy
1222
- sections (the typical pre-v0.20 migration) -- so the breadcrumb augments
1223
- the legacy report rather than creating a misleadingly-titled report with
1224
- no legacy content captured.
1225
- """
1226
- existing = report_path.read_text(encoding="utf-8")
1227
- if _PRETTIER_BREADCRUMB_MARKER in existing:
1228
- return False
1229
- section = "\n".join(
1230
- [f"## {_PRETTIER_BREADCRUMB_MARKER}", ""] + _prettier_breadcrumb_body()
1231
- )
1232
- report_path.write_text(
1233
- existing.rstrip("\n") + "\n\n" + section + "\n", encoding="utf-8"
1234
- )
1235
- return True
1236
- # --- end prettier remediation breadcrumb ---
1237
-
1238
-
1239
- def migrate(
1240
- project_root: Path,
1241
- *,
1242
- dry_run: bool = False,
1243
- force: bool = False,
1244
- strict: bool = False,
1245
- ) -> tuple[bool, list[str]]:
1246
- """Run the full migration on the given project root.
1247
-
1248
- ``dry_run`` -- when True, produce the full action log without writing any
1249
- file to disk (#497-2). All backup, manifest, and lifecycle-folder lines
1250
- are prefixed ``DRYRUN`` so the operator can distinguish a plan from a
1251
- real run.
1252
-
1253
- ``force`` -- when True, bypass the dirty-tree guard (#497-3). The guard
1254
- refuses to run on a dirty working tree by default to keep migration
1255
- output separable from in-progress edits.
1256
-
1257
- ``strict`` -- when True (``task migrate:vbrief -- --strict`` per #496),
1258
- exit non-zero if SPEC and ROADMAP disagreed on any dimension or any
1259
- override from ``vbrief/migration-overrides.yaml`` triggered. Scope
1260
- vBRIEFs and ``vbrief/migration/RECONCILIATION.md`` are still written so
1261
- the operator can inspect before re-running without ``--strict``.
1262
-
1263
- Returns:
1264
- (True, actions) on success -- actions is a list of human-readable lines.
1265
- (False, errors) on failure.
1266
- """
1267
- actions: list[str] = []
1268
- warnings: list[str] = []
1269
- vbrief_dir = project_root / "vbrief"
1270
- created_files: list[str] = []
1271
- created_dirs: list[str] = []
1272
-
1273
- # #527 / #528: snapshot which migrator-managed subdirs pre-existed so we
1274
- # can record any we create in the safety manifest's ``created_dirs``.
1275
- # Tracking is derived from this captured state -- NOT from post-hoc
1276
- # filesystem scans -- so rollback's RMDIR decision comes straight from
1277
- # the manifest and never clobbers a directory that was already present.
1278
- managed_subdir_pre_existed: dict[str, bool] = {
1279
- name: (vbrief_dir / name).is_dir() for name in _MANAGED_SUBDIRS
1280
- }
1281
-
1282
- # --- safety (Agent C, #497) ---
1283
- # Dirty-tree guard (#497-3): refuse on a non-clean git status unless the
1284
- # operator passes --force. Runs BEFORE any filesystem mutation so a
1285
- # dirty-tree refusal leaves the project in its exact pre-run state.
1286
- # --dry-run is explicitly exempt (Greptile #509 P1): dry-run is read-only,
1287
- # cannot corrupt state, and operators are encouraged to preview BEFORE
1288
- # committing any pending edits. Pairing --force with --dry-run to preview
1289
- # on an unfamiliar project would defeat the purpose of dry-run.
1290
- if not force and not dry_run and is_tree_dirty(project_root):
1291
- # #635: emit dirty-tree event before returning the refusal so any
1292
- # consumer (skill, task, CI runner) can react uniformly. Existing
1293
- # CLI output (the canonical refusal message) is preserved. The
1294
- # events surface MUST NOT break the migrator, so registry/IO
1295
- # failures are silently suppressed.
1296
- with contextlib.suppress(Exception):
1297
- _emit_event(
1298
- "dirty-tree:detected",
1299
- {"project_root": str(project_root.resolve())},
1300
- )
1301
- return False, [dirty_tree_refusal_message()]
1302
-
1303
- # Always-on backups (#497-1): copy every pre-cutover input to its
1304
- # .premigrate.* sibling BEFORE we touch anything else (the lifecycle
1305
- # folder creation below is technically the first filesystem write, but
1306
- # backups come first so we can surface an actionable error if a backup
1307
- # itself fails before any write lands).
1308
- backup_pairs = plan_backups(project_root)
1309
- backup_records, backup_actions = write_backups(
1310
- project_root, backup_pairs, dry_run=dry_run
1311
- )
1312
- actions.extend(backup_actions)
1313
- # --- end safety ---
1314
-
1315
- # --- gitignore (#530 + #567) ---
1316
- # Append the ``.premigrate.*`` glob patterns to the consumer project's
1317
- # ``.gitignore`` on first migration so backups never leak into commits.
1318
- # Idempotent on subsequent runs. The helper also returns a
1319
- # ``FileModification`` record (pre_hash / post_hash /
1320
- # appended_content) that we stash for the safety manifest so
1321
- # ``--rollback`` can reverse this edit symmetrically with
1322
- # ``post_migration_stub_hashes`` (#567).
1323
- gitignore_action, gitignore_modification = _ensure_gitignore_patterns(
1324
- project_root, dry_run=dry_run
1325
- )
1326
- if gitignore_action:
1327
- actions.append(gitignore_action)
1328
- file_modifications: list[FileModification] = []
1329
- if gitignore_modification is not None:
1330
- file_modifications.append(gitignore_modification)
1331
- # --- end gitignore ---
1332
-
1333
- # ---- Step 1: Create lifecycle folders ----
1334
- for folder_name in LIFECYCLE_FOLDERS:
1335
- folder = vbrief_dir / folder_name
1336
- rel = folder.relative_to(project_root).as_posix()
1337
- if folder.exists():
1338
- actions.append(f"SKIP lifecycle folder already exists: vbrief/{folder_name}/")
1339
- elif dry_run:
1340
- actions.append(f"DRYRUN CREATE lifecycle folder: vbrief/{folder_name}/")
1341
- else:
1342
- folder.mkdir(parents=True, exist_ok=True)
1343
- created_dirs.append(rel)
1344
- actions.append(f"CREATE lifecycle folder: vbrief/{folder_name}/")
1345
-
1346
- # ---- Step 2: Read existing sources ----
1347
- spec_vbrief_path = vbrief_dir / "specification.vbrief.json"
1348
- spec_vbrief: dict | None = None
1349
- if spec_vbrief_path.exists():
1350
- try:
1351
- spec_vbrief = json.loads(spec_vbrief_path.read_text(encoding="utf-8"))
1352
- actions.append("READ vbrief/specification.vbrief.json")
1353
- except json.JSONDecodeError as exc:
1354
- return False, [f"ERROR: invalid JSON in specification.vbrief.json: {exc}"]
1355
-
1356
- # #571: the migrator now guarantees that every ingested
1357
- # ``specification.vbrief.json`` is stamped with the current
1358
- # ``EMITTED_VBRIEF_VERSION`` before being written back to disk.
1359
- # Previously the ``_ingest_spec_narratives`` path only merged new
1360
- # keys under ``plan.narratives`` and left ``vBRIEFInfo.version``
1361
- # at its pre-migration value, so consumers that started at v0.5
1362
- # stayed at v0.5 after a "successful" migration and then hit a
1363
- # hard-fail on the next ``task spec:validate`` with a misleading
1364
- # "Migrate legacy v0.5 vBRIEFs via the migrator sweep" error --
1365
- # pointing at a sweep that did not exist for these files.
1366
- if isinstance(spec_vbrief, dict):
1367
- envelope = spec_vbrief.setdefault("vBRIEFInfo", {})
1368
- if isinstance(envelope, dict) and envelope.get(
1369
- "version"
1370
- ) != EMITTED_VBRIEF_VERSION:
1371
- prior_version = envelope.get("version")
1372
- envelope["version"] = EMITTED_VBRIEF_VERSION
1373
- # Greptile P1 on this PR: persist-or-log split mirrors
1374
- # the plan.vbrief.json branch below so ``--dry-run``
1375
- # surfaces the bump as ``DRYRUN BUMP ...`` rather than
1376
- # a bare ``BUMP ...`` that would mislead operators
1377
- # previewing a run into thinking the change landed.
1378
- if dry_run:
1379
- actions.append(
1380
- "DRYRUN BUMP specification.vbrief.json "
1381
- "vBRIEFInfo.version "
1382
- f"{prior_version!r} -> "
1383
- f"{EMITTED_VBRIEF_VERSION!r} (#571)"
1384
- )
1385
- else:
1386
- # Persist the bump immediately so even a no-
1387
- # narrative-ingest migration lands v0.6 on disk.
1388
- # Subsequent ingest writes may re-serialize the
1389
- # same (already-bumped) in-memory copy; that is
1390
- # harmless because the envelope has already been
1391
- # mutated.
1392
- spec_vbrief_path.write_text(
1393
- json.dumps(spec_vbrief, indent=2, ensure_ascii=False)
1394
- + "\n",
1395
- encoding="utf-8",
1396
- )
1397
- actions.append(
1398
- "BUMP specification.vbrief.json "
1399
- "vBRIEFInfo.version "
1400
- f"{prior_version!r} -> "
1401
- f"{EMITTED_VBRIEF_VERSION!r} (#571)"
1402
- )
1403
- else:
1404
- actions.append("SKIP vbrief/specification.vbrief.json not found")
1405
-
1406
- # #571: mirror the spec_vbrief version bump on any pre-existing
1407
- # ``vbrief/plan.vbrief.json``. ``migrate_speckit_plan()`` already
1408
- # force-bumps the envelope on its speckit-shaped conversion path
1409
- # (L2053-L2056 below), but a non-speckit session-scoped
1410
- # plan.vbrief.json never reaches that function during the normal
1411
- # ``task migrate:vbrief`` flow -- so it used to stay at v0.5
1412
- # indefinitely and later fail ``spec:validate``. Here we read it,
1413
- # bump the envelope in-place, and rewrite it with no other shape
1414
- # changes so the operator gets a clean v0.5 -> v0.6 flip without
1415
- # surprises.
1416
- plan_vbrief_path = vbrief_dir / "plan.vbrief.json"
1417
- if plan_vbrief_path.is_file():
1418
- try:
1419
- plan_vbrief_data = json.loads(
1420
- plan_vbrief_path.read_text(encoding="utf-8")
1421
- )
1422
- except json.JSONDecodeError as exc:
1423
- return False, [
1424
- f"ERROR: invalid JSON in plan.vbrief.json: {exc}"
1425
- ]
1426
- if isinstance(plan_vbrief_data, dict):
1427
- plan_envelope = plan_vbrief_data.setdefault("vBRIEFInfo", {})
1428
- if isinstance(plan_envelope, dict) and plan_envelope.get(
1429
- "version"
1430
- ) != EMITTED_VBRIEF_VERSION:
1431
- prior_plan_version = plan_envelope.get("version")
1432
- plan_envelope["version"] = EMITTED_VBRIEF_VERSION
1433
- if dry_run:
1434
- actions.append(
1435
- "DRYRUN BUMP plan.vbrief.json vBRIEFInfo.version "
1436
- f"{prior_plan_version!r} -> "
1437
- f"{EMITTED_VBRIEF_VERSION!r} (#571)"
1438
- )
1439
- else:
1440
- plan_vbrief_path.write_text(
1441
- json.dumps(
1442
- plan_vbrief_data, indent=2, ensure_ascii=False
1443
- )
1444
- + "\n",
1445
- encoding="utf-8",
1446
- )
1447
- actions.append(
1448
- "BUMP plan.vbrief.json vBRIEFInfo.version "
1449
- f"{prior_plan_version!r} -> "
1450
- f"{EMITTED_VBRIEF_VERSION!r} (#571)"
1451
- )
1452
-
1453
- spec_md_path = project_root / "SPECIFICATION.md"
1454
- spec_md_content: str | None = None
1455
- if spec_md_path.exists():
1456
- spec_md_content = spec_md_path.read_text(encoding="utf-8")
1457
- actions.append("READ SPECIFICATION.md")
1458
-
1459
- project_md_path = project_root / "PROJECT.md"
1460
- project_content: str | None = None
1461
- if project_md_path.exists():
1462
- project_content = project_md_path.read_text(encoding="utf-8")
1463
- actions.append("READ PROJECT.md")
1464
-
1465
- roadmap_path = project_root / "ROADMAP.md"
1466
- roadmap_items, phase_descriptions, completed_items = _parse_roadmap_items(roadmap_path)
1467
- total_items = len(roadmap_items) + len(completed_items)
1468
- if total_items:
1469
- actions.append(
1470
- f"READ ROADMAP.md ({len(roadmap_items)} active, "
1471
- f"{len(completed_items)} completed items parsed)"
1472
- )
1473
- else:
1474
- actions.append("SKIP ROADMAP.md not found or no items parsed")
1475
-
1476
- # Resolve repository URL for provenance references. The ``project_root``
1477
- # arg enables the ``git remote get-url origin`` fallback added in #613 so
1478
- # scope vBRIEFs built without a pre-existing ``specification.vbrief.json``
1479
- # repository hint still get canonical ``{uri, type, title}`` references.
1480
- repo_url = _resolve_repo_url(spec_vbrief, project_root=project_root)
1481
-
1482
- # ---- Step 2b: Ingest PRD/SPECIFICATION structured narratives (#397) ----
1483
- prd_path = project_root / "PRD.md"
1484
- ingested_narratives: dict[str, str] = {}
1485
-
1486
- if prd_path.exists():
1487
- prd_content = prd_path.read_text(encoding="utf-8")
1488
- actions.append("READ PRD.md")
1489
- ingested_narratives.update(_parse_prd_narratives(prd_content))
1490
-
1491
- if spec_md_content and DEPRECATION_SENTINEL not in spec_md_content:
1492
- spec_parsed = _parse_prd_narratives(spec_md_content)
1493
- # SPECIFICATION.md sections take priority over PRD.md for overlaps
1494
- ingested_narratives.update(spec_parsed)
1495
-
1496
- if ingested_narratives:
1497
- # Ensure spec_vbrief structure exists
1498
- if spec_vbrief is None:
1499
- spec_vbrief = {
1500
- "vBRIEFInfo": {
1501
- "version": EMITTED_VBRIEF_VERSION,
1502
- "description": "Specification",
1503
- },
1504
- "plan": {
1505
- "title": "Specification",
1506
- "status": "approved",
1507
- "narratives": {},
1508
- "items": [],
1509
- },
1510
- }
1511
-
1512
- existing = spec_vbrief.setdefault("plan", {}).setdefault("narratives", {})
1513
- ingested_keys: list[str] = []
1514
- for key, value in ingested_narratives.items():
1515
- if key not in existing:
1516
- existing[key] = value
1517
- ingested_keys.append(key)
1518
-
1519
- if ingested_keys:
1520
- rel = spec_vbrief_path.relative_to(project_root).as_posix()
1521
- created_new_spec_vbrief = not spec_vbrief_path.exists()
1522
- if dry_run:
1523
- actions.append(
1524
- f"DRYRUN INGEST narratives into specification.vbrief.json: "
1525
- f"{', '.join(sorted(ingested_keys))}"
1526
- )
1527
- else:
1528
- spec_vbrief_path.parent.mkdir(parents=True, exist_ok=True)
1529
- spec_vbrief_path.write_text(
1530
- json.dumps(spec_vbrief, indent=2, ensure_ascii=False) + "\n",
1531
- encoding="utf-8",
1532
- )
1533
- if created_new_spec_vbrief:
1534
- created_files.append(rel)
1535
- actions.append(
1536
- f"INGEST narratives into specification.vbrief.json: "
1537
- f"{', '.join(sorted(ingested_keys))}"
1538
- )
1539
-
1540
- # --- fidelity (Agent A, #495) ---
1541
- # Parse the raw SPECIFICATION.md for per-task bodies (Description /
1542
- # DependsOn / AcceptanceCriteria / Traces) + FR-N / NFR-N definitions
1543
- # + non-canonical ## sections. Enrich spec_vbrief.plan.items so Agent
1544
- # B's reconciliation picks up the bodies through its "spec owns body"
1545
- # path (#506 D2 #14). Emit the Requirements narrative (#495-4) and
1546
- # plan.edges[] (#495-6, #506 D4) on the spec vBRIEF. Collect legacy
1547
- # SPEC sections for #505 capture at the end of the run.
1548
- spec_tasks: list[dict] = []
1549
- requirement_defs: dict[str, str] = {}
1550
- spec_legacy_sections: list[tuple[str, str, int, int]] = []
1551
- fidelity_log: list[dict] = []
1552
- if spec_md_content and DEPRECATION_SENTINEL not in spec_md_content:
1553
- spec_tasks = _parse_spec_tasks(spec_md_content)
1554
- requirement_defs = _parse_requirement_definitions(spec_md_content)
1555
- _canon, fidelity_log, spec_legacy_sections = _ingest_spec_narratives(
1556
- spec_md_content, source_file="SPECIFICATION.md"
1557
- )
1558
- if spec_vbrief is None:
1559
- spec_vbrief = {
1560
- "vBRIEFInfo": {
1561
- "version": EMITTED_VBRIEF_VERSION,
1562
- "description": "Specification",
1563
- },
1564
- "plan": {
1565
- "title": "Specification",
1566
- "status": "approved",
1567
- "narratives": {},
1568
- "items": [],
1569
- },
1570
- }
1571
- spec_plan = spec_vbrief.setdefault("plan", {})
1572
- spec_narratives = spec_plan.setdefault("narratives", {})
1573
-
1574
- # Requirements narrative (#495-4): FR/NFR defs emitted as a single
1575
- # string. Preserve any pre-existing narrative.
1576
- req_narrative = _build_requirements_narrative(requirement_defs)
1577
- if req_narrative and not spec_narratives.get("Requirements"):
1578
- spec_narratives["Requirements"] = req_narrative
1579
- actions.append(
1580
- "FIDELITY specification.vbrief.json Requirements: "
1581
- f"{len(requirement_defs)} FR/NFR definition(s)"
1582
- )
1583
-
1584
- # plan.edges[] from per-task Depends-on (#495-6, D4).
1585
- edges = _build_edges_from_tasks(spec_tasks)
1586
- if edges:
1587
- existing_edges = spec_plan.get("edges", [])
1588
- if not isinstance(existing_edges, list):
1589
- existing_edges = []
1590
- seen_keys = {
1591
- (str(e.get("from", "")), str(e.get("to", "")),
1592
- str(e.get("type", "")))
1593
- for e in existing_edges if isinstance(e, dict)
1594
- }
1595
- new_count = 0
1596
- for edge in edges:
1597
- key = (edge["from"], edge["to"], edge["type"])
1598
- if key not in seen_keys:
1599
- existing_edges.append(edge)
1600
- seen_keys.add(key)
1601
- new_count += 1
1602
- if new_count:
1603
- spec_plan["edges"] = existing_edges
1604
- actions.append(
1605
- f"FIDELITY specification.vbrief.json plan.edges[]: "
1606
- f"{new_count} Depends-on edge(s) emitted (#506 D4)"
1607
- )
1608
-
1609
- # Enrich spec_vbrief.plan.items with per-task narratives so B's
1610
- # reconciliation picks up Description / DependsOn / AcceptanceCriteria
1611
- # / Traces from SPEC.md bodies (#495-1). Match by task_id; when no
1612
- # matching item exists, synthesize a new item so the body is not lost.
1613
- spec_items = spec_plan.setdefault("items", [])
1614
- if not isinstance(spec_items, list):
1615
- spec_items = []
1616
- spec_plan["items"] = spec_items
1617
-
1618
- def _find_spec_item(task_id: str) -> dict | None:
1619
- for item in spec_items:
1620
- if isinstance(item, dict) and str(item.get("id", "")) == task_id:
1621
- return item
1622
- return None
1623
-
1624
- enriched_count = 0
1625
- for task in spec_tasks:
1626
- task_id = task.get("task_id", "")
1627
- if not task_id:
1628
- continue
1629
- task_narr = _task_scope_narratives(task)
1630
- if not task_narr:
1631
- continue
1632
- item = _find_spec_item(task_id)
1633
- if item is None:
1634
- item = {
1635
- "id": task_id,
1636
- "title": task.get("title", task_id),
1637
- "status": task.get("status", "pending"),
1638
- "narrative": {},
1639
- }
1640
- spec_items.append(item)
1641
- narrative = item.setdefault("narrative", {})
1642
- if not isinstance(narrative, dict):
1643
- narrative = {}
1644
- item["narrative"] = narrative
1645
- for key, value in task_narr.items():
1646
- if not narrative.get(key):
1647
- narrative[key] = value
1648
- enriched_count += 1
1649
-
1650
- if enriched_count:
1651
- actions.append(
1652
- f"FIDELITY specification.vbrief.json items enriched: "
1653
- f"{enriched_count} per-task narrative field(s) (#495-1)"
1654
- )
1655
-
1656
- # Persist the enriched spec vBRIEF so Agent B's reconciliation
1657
- # reads the enriched state. Skipped under --dry-run.
1658
- if not dry_run and (req_narrative or edges or enriched_count):
1659
- rel_spec = spec_vbrief_path.relative_to(project_root).as_posix()
1660
- created_new = not spec_vbrief_path.exists()
1661
- spec_vbrief_path.parent.mkdir(parents=True, exist_ok=True)
1662
- spec_vbrief_path.write_text(
1663
- json.dumps(spec_vbrief, indent=2, ensure_ascii=False) + "\n",
1664
- encoding="utf-8",
1665
- )
1666
- if created_new and rel_spec not in created_files:
1667
- created_files.append(rel_spec)
1668
-
1669
- # Disambiguated migration log (#495-15): every section routing decision
1670
- # gets a ROUTE line recording source : line-range -> target-key -> target-file.
1671
- for entry in fidelity_log:
1672
- actions.append(_format_migration_log_entry(entry))
1673
- # --- end fidelity ---
1674
-
1675
- # --- reconciliation (Agent B, #496) ---
1676
- # Load overrides BEFORE defaults apply, then reconcile SPEC + ROADMAP
1677
- # into a single list of routed scope items. The report captures every
1678
- # resolved disagreement for downstream emission to
1679
- # vbrief/migration/RECONCILIATION.md and for --strict exit-code gating.
1680
- overrides = _load_overrides(vbrief_dir)
1681
- if overrides:
1682
- actions.append(
1683
- f"READ vbrief/migration-overrides.yaml ({len(overrides)} override(s))"
1684
- )
1685
- reconciled_items, reconciliation_report = _reconcile_scope_items(
1686
- roadmap_active=roadmap_items,
1687
- roadmap_completed=completed_items,
1688
- spec_vbrief=spec_vbrief,
1689
- phase_descriptions=phase_descriptions,
1690
- overrides=overrides,
1691
- )
1692
- # --- end reconciliation ---
1693
-
1694
- # ---- Step 3: Generate PROJECT-DEFINITION.vbrief.json ----
1695
- proj_def_path = vbrief_dir / "PROJECT-DEFINITION.vbrief.json"
1696
- if proj_def_path.exists():
1697
- actions.append("SKIP PROJECT-DEFINITION.vbrief.json already exists (idempotent)")
1698
- else:
1699
- # #499-registry: pass reconciled items so PROJECT-DEFINITION
1700
- # plan.items[*].status mirrors each scope's reconciled status. Falls
1701
- # back to raw roadmap_items + completed_items for the degenerate
1702
- # case where no ROADMAP existed (reconciled_items is empty and the
1703
- # registry was historically empty too).
1704
- if reconciled_items:
1705
- registry_items = [
1706
- {
1707
- "number": r.get("number", ""),
1708
- "title": r.get("title", "Untitled"),
1709
- "status": r.get("status", "pending"),
1710
- "phase": r.get("phase", ""),
1711
- "task_id": r.get("original_task_id", ""),
1712
- "synthetic_id": r.get("synthetic_id", ""),
1713
- }
1714
- for r in reconciled_items
1715
- ]
1716
- else:
1717
- registry_items = roadmap_items + completed_items
1718
- proj_def = _build_project_definition(
1719
- spec_vbrief,
1720
- project_content,
1721
- registry_items,
1722
- repo_url=repo_url,
1723
- spec_md_content=spec_md_content,
1724
- )
1725
- if dry_run:
1726
- actions.append("DRYRUN CREATE vbrief/PROJECT-DEFINITION.vbrief.json")
1727
- else:
1728
- proj_def_path.write_text(
1729
- json.dumps(proj_def, indent=2, ensure_ascii=False) + "\n",
1730
- encoding="utf-8",
1731
- )
1732
- created_files.append(
1733
- proj_def_path.relative_to(project_root).as_posix()
1734
- )
1735
- actions.append("CREATE vbrief/PROJECT-DEFINITION.vbrief.json")
1736
-
1737
- # --- lifecycle-routing (Agent B, #499) ---
1738
- # Write each reconciled scope vBRIEF to the lifecycle folder chosen by
1739
- # the reconciler (proposed / pending / active / completed / cancelled
1740
- # per #506). Replaces the old Steps 4 + 4b that dumped everything into
1741
- # pending/ or completed/. Orphan ROADMAP items route to proposed/ with
1742
- # narrative.SourceConflict = "missing-from-spec".
1743
- #
1744
- # #532: filename stem uses ``slug_normalize.normalize_slug`` (Unicode
1745
- # NFKD, checkbox markers stripped, word-boundary truncation at 60 chars,
1746
- # Windows-reserved fallback). The id prefix is still emitted via
1747
- # ``slugify_id`` (#498) because it is ALSO used as an in-JSON
1748
- # ``plan.items[*].id`` value that must match the schema ID regex.
1749
- emitted_stems: set[str] = set()
1750
- for reconciled in reconciled_items:
1751
- folder = reconciled.get("folder", "pending")
1752
- number = reconciled.get("number", "")
1753
- id_source = slug_fallback_id({
1754
- "number": number,
1755
- "task_id": reconciled.get("original_task_id", ""),
1756
- "synthetic_id": reconciled.get("synthetic_id", ""),
1757
- "title": reconciled.get("title", "untitled"),
1758
- })
1759
- id_part = slugify_id(id_source)
1760
- # Compose id + raw title then normalize as a single unit so the
1761
- # word-boundary truncation rule considers the full composed stem.
1762
- raw_title = reconciled.get("title", "untitled") or "untitled"
1763
- composed_raw = f"{id_part}-{raw_title}" if id_part else raw_title
1764
- normalized_stem = _normalize_slug(composed_raw, max_len=_SLUG_MAX_LEN)
1765
- stem = _disambiguate_slug(
1766
- normalized_stem, emitted_stems, max_len=_SLUG_MAX_LEN
1767
- )
1768
- emitted_stems.add(stem)
1769
- # Kept for the human-readable ``label`` fallback below.
1770
- title_slug = _normalize_slug(
1771
- reconciled.get("title", "untitled") or "untitled",
1772
- max_len=_SLUG_MAX_LEN,
1773
- )
1774
- filename = f"{_TODAY}-{stem}.vbrief.json"
1775
- target_folder = vbrief_dir / folder
1776
- if not target_folder.exists() and not dry_run:
1777
- target_folder.mkdir(parents=True, exist_ok=True)
1778
- target_path = target_folder / filename
1779
-
1780
- if target_path.exists():
1781
- actions.append(
1782
- f"SKIP {folder}/{filename} already exists (idempotent)"
1783
- )
1784
- continue
1785
-
1786
- # Check if any existing file references this issue number
1787
- if number:
1788
- existing = _find_existing_scope_vbrief(vbrief_dir, number)
1789
- if existing:
1790
- actions.append(
1791
- f"SKIP #{number} already has scope vBRIEF: "
1792
- f"{existing.relative_to(vbrief_dir)}"
1793
- )
1794
- continue
1795
-
1796
- scope_vbrief = _build_reconciled_scope_vbrief(
1797
- reconciled,
1798
- repo_url=repo_url,
1799
- migration_timestamp=_MIGRATION_TIMESTAMP,
1800
- )
1801
- label = (
1802
- f"#{number}" if number
1803
- else reconciled.get("task_id") or title_slug
1804
- )
1805
- # #593: annotate the CREATE log line with the source section so
1806
- # operators can audit routing decisions post-migration without
1807
- # re-running the migrator. ``source_section`` is populated by the
1808
- # reconciler for every ROADMAP-sourced row; SPEC-only items (no
1809
- # ROADMAP counterpart) fall back to the short label.
1810
- source_section = reconciled.get("source_section", "")
1811
- log_suffix = (
1812
- f"({label}, from {source_section})"
1813
- if source_section
1814
- else f"({label})"
1815
- )
1816
- if dry_run:
1817
- actions.append(f"DRYRUN CREATE {folder}/{filename} {log_suffix}")
1818
- else:
1819
- target_path.write_text(
1820
- json.dumps(scope_vbrief, indent=2, ensure_ascii=False) + "\n",
1821
- encoding="utf-8",
1822
- )
1823
- created_files.append(
1824
- target_path.relative_to(project_root).as_posix()
1825
- )
1826
- actions.append(f"CREATE {folder}/{filename} {log_suffix}")
1827
- # --- end lifecycle-routing ---
1828
-
1829
- # ---- Step 5: Deprecation redirects ----
1830
- # Hashes captured after write (or proposed-write in dry-run) so --rollback
1831
- # can detect whether the operator has edited the stub since migration.
1832
- stub_hashes: dict[str, str] = {}
1833
- if spec_md_path.exists():
1834
- if spec_md_content and DEPRECATION_SENTINEL in spec_md_content:
1835
- actions.append("SKIP SPECIFICATION.md already has deprecation redirect")
1836
- else:
1837
- # Check for user customization
1838
- if spec_md_content and _is_user_customized(spec_md_content, _SPEC_AUTO_MARKERS):
1839
- # In dry-run the fold target (PROJECT-DEFINITION) may not yet
1840
- # exist -- _fold_custom_content short-circuits gracefully and
1841
- # returns False, which would otherwise abort. Skip the abort
1842
- # path in dry-run and record the fold as proposed.
1843
- if dry_run:
1844
- warnings.append(
1845
- "WARNING: SPECIFICATION.md appears user-customized. "
1846
- "Original content would be preserved in "
1847
- "PROJECT-DEFINITION.vbrief.json narratives (dry-run)."
1848
- )
1849
- else:
1850
- preserved = _fold_custom_content(
1851
- proj_def_path, "SpecificationContent", spec_md_content or ""
1852
- )
1853
- if preserved:
1854
- warnings.append(
1855
- "WARNING: SPECIFICATION.md appears user-customized. "
1856
- "Original content preserved in "
1857
- "PROJECT-DEFINITION.vbrief.json narratives."
1858
- )
1859
- else:
1860
- return False, [
1861
- "ERROR: SPECIFICATION.md appears user-customized but content could not "
1862
- "be preserved in PROJECT-DEFINITION.vbrief.json. Fix the project "
1863
- "definition file structure and re-run to prevent data loss."
1864
- ]
1865
-
1866
- redirect = _deprecation_redirect(
1867
- "SPECIFICATION.md",
1868
- "vbrief/PROJECT-DEFINITION.vbrief.json",
1869
- "For scope details, see individual vBRIEF files in the lifecycle folders.",
1870
- )
1871
- if dry_run:
1872
- actions.append("DRYRUN REPLACE SPECIFICATION.md with deprecation redirect")
1873
- else:
1874
- spec_md_path.write_text(redirect, encoding="utf-8")
1875
- stub_hashes["SPECIFICATION.md"] = sha256_of(spec_md_path)
1876
- actions.append("REPLACE SPECIFICATION.md with deprecation redirect")
1877
-
1878
- if project_md_path.exists():
1879
- if project_content and DEPRECATION_SENTINEL in project_content:
1880
- actions.append("SKIP PROJECT.md already has deprecation redirect")
1881
- else:
1882
- # Check for user customization -- note: PROJECT.md content is already
1883
- # captured in narratives["ProjectConfig"] by _build_project_definition (step 3),
1884
- # so the fold here is a safety net only.
1885
- if project_content and _is_user_customized(project_content, _PROJECT_AUTO_MARKERS):
1886
- warnings.append(
1887
- "WARNING: PROJECT.md appears user-customized. "
1888
- "Original content preserved in PROJECT-DEFINITION.vbrief.json narratives."
1889
- )
1890
-
1891
- redirect = _deprecation_redirect(
1892
- "PROJECT.md",
1893
- "vbrief/PROJECT-DEFINITION.vbrief.json",
1894
- "For project configuration, see the narratives section.",
1895
- )
1896
- if dry_run:
1897
- actions.append("DRYRUN REPLACE PROJECT.md with deprecation redirect")
1898
- else:
1899
- project_md_path.write_text(redirect, encoding="utf-8")
1900
- stub_hashes["PROJECT.md"] = sha256_of(project_md_path)
1901
- actions.append("REPLACE PROJECT.md with deprecation redirect")
1902
-
1903
- # --- legacy-artifacts (Agent A, #505) ---
1904
- # Capture non-canonical ## sections from SPECIFICATION.md, PROJECT.md,
1905
- # and PRD.md into a ``LegacyArtifacts`` narrative on the matching
1906
- # vBRIEF file (per #506 D5 / #505 Section 1). Sections >6 KB overflow
1907
- # to ``vbrief/legacy/{stem}-{slug}.md`` sidecars (Section 4). PRD.md
1908
- # hand-edited sections get the RFC-defined warning prefix (Section 5).
1909
- # Emit ``vbrief/migration/LEGACY-REPORT.md`` when any capture occurs
1910
- # (Section 6) and append a stdout summary (Section 8).
1911
- #
1912
- # Skipped under --dry-run so operators can preview the plan without
1913
- # synthesising sidecar files. The .premigrate.* backups (Agent C)
1914
- # cover rollback; LegacyArtifacts is an additive preservation mechanism.
1915
- captures: dict[str, list[dict]] = {
1916
- "specification.vbrief.json -> LegacyArtifacts": [],
1917
- "PROJECT-DEFINITION.vbrief.json -> LegacyArtifacts": [],
1918
- "PRD.md content (flagged: hand-edited)": [],
1919
- }
1920
-
1921
- # Pin the event log to ``<project_root>/<DEFAULT_EVENT_LOG>`` so the
1922
- # migrator's emissions stay scoped to the project being migrated --
1923
- # without this, ``_resolve_log_path`` would fall back to the agent's
1924
- # CWD and a test running ``migrate(tmp_path)`` from the repo root
1925
- # would write events into the deft repo's own log directory. The
1926
- # default path lives under the already-gitignored ``.deft-cache/``
1927
- # (relocated from ``.deft/`` in #1465) so the log never leaks as an
1928
- # untracked file in the migrated consumer.
1929
- _legacy_event_log = project_root / _DEFAULT_EVENT_LOG
1930
-
1931
- def _legacy_event_emitter(event_name: str, payload: dict) -> None:
1932
- """Emit a ``legacy:detected`` framework event per captured section.
1933
-
1934
- Wraps the shared :func:`scripts._events.emit` helper (aliased here
1935
- as ``_emit_behavioral_event`` to avoid shadowing the
1936
- detection-bound ``_emit_event`` wrapper) so the migrator's
1937
- emission stays out of the inner loop in
1938
- ``_vbrief_legacy.emit_legacy_artifacts``. Failures are swallowed
1939
- in the caller (#635 behavioral events wiring; post-#706
1940
- unification per #709 / #710).
1941
- """
1942
- _emit_behavioral_event(event_name, payload, log_path=_legacy_event_log)
1943
- # #529: collect per-source Traces-stripping audit entries. Each entry
1944
- # records the source file name and the list of task ids whose
1945
- # ``**Traces**: ...`` line was stripped from the emitted LegacyArtifacts
1946
- # narrative. The audit is emitted to ``vbrief/migration/RECONCILIATION.md``
1947
- # after reconciliation writes its own conflicts.
1948
- traces_stripped_audit: list[dict] = []
1949
- if not dry_run:
1950
- # SPEC.md legacy sections were collected by the fidelity hook above.
1951
- if spec_legacy_sections:
1952
- narrative, sidecars, stats = _emit_legacy_artifacts(
1953
- spec_legacy_sections,
1954
- "SPECIFICATION.md",
1955
- project_root,
1956
- slugify_fn=_slugify_shared,
1957
- event_emitter=_legacy_event_emitter,
1958
- )
1959
- if narrative:
1960
- narrative, stripped_ids = _strip_traces_from_narrative(narrative)
1961
- if stripped_ids:
1962
- traces_stripped_audit.append({
1963
- "source": "SPECIFICATION.md",
1964
- "task_ids": stripped_ids,
1965
- })
1966
- if not spec_vbrief_path.exists():
1967
- # Nothing has written spec.vbrief.json yet (e.g. SPEC is
1968
- # 100% non-canonical) -- synthesize a minimal skeleton
1969
- # so LegacyArtifacts has a target file.
1970
- spec_vbrief_path.parent.mkdir(parents=True, exist_ok=True)
1971
- spec_vbrief_path.write_text(
1972
- json.dumps(
1973
- {
1974
- "vBRIEFInfo": {
1975
- "version": EMITTED_VBRIEF_VERSION,
1976
- "description": "Specification",
1977
- },
1978
- "plan": {
1979
- "title": "Specification",
1980
- "status": "approved",
1981
- "narratives": {},
1982
- "items": [],
1983
- },
1984
- },
1985
- indent=2,
1986
- ensure_ascii=False,
1987
- )
1988
- + "\n",
1989
- encoding="utf-8",
1990
- )
1991
- created_files.append(
1992
- spec_vbrief_path.relative_to(project_root).as_posix()
1993
- )
1994
- _attach_legacy_narrative(spec_vbrief_path, narrative)
1995
- for sidecar in sidecars:
1996
- try:
1997
- rel = sidecar.relative_to(project_root).as_posix()
1998
- except ValueError:
1999
- rel = str(sidecar)
2000
- if rel not in created_files:
2001
- created_files.append(rel)
2002
- captures[
2003
- "specification.vbrief.json -> LegacyArtifacts"
2004
- ].extend(stats)
2005
- actions.append(
2006
- "LEGACY specification.vbrief.json LegacyArtifacts: "
2007
- f"{len(stats)} section(s)"
2008
- )
2009
-
2010
- # PROJECT.md non-canonical sections -> PROJECT-DEFINITION.vbrief.json.
2011
- if project_content and DEPRECATION_SENTINEL not in project_content:
2012
- project_sections = _parse_top_level_sections(project_content)
2013
- _project_canonical, project_legacy = _partition_sections(
2014
- project_sections, _PROJECT_KNOWN_MAPPINGS
2015
- )
2016
- if project_legacy:
2017
- narrative, sidecars, stats = _emit_legacy_artifacts(
2018
- project_legacy,
2019
- "PROJECT.md",
2020
- project_root,
2021
- slugify_fn=_slugify_shared,
2022
- event_emitter=_legacy_event_emitter,
2023
- )
2024
- if narrative and proj_def_path.exists():
2025
- narrative, stripped_ids = _strip_traces_from_narrative(
2026
- narrative
2027
- )
2028
- if stripped_ids:
2029
- traces_stripped_audit.append({
2030
- "source": "PROJECT.md",
2031
- "task_ids": stripped_ids,
2032
- })
2033
- _attach_legacy_narrative(proj_def_path, narrative)
2034
- for sidecar in sidecars:
2035
- try:
2036
- rel = sidecar.relative_to(project_root).as_posix()
2037
- except ValueError:
2038
- rel = str(sidecar)
2039
- if rel not in created_files:
2040
- created_files.append(rel)
2041
- captures[
2042
- "PROJECT-DEFINITION.vbrief.json -> LegacyArtifacts"
2043
- ].extend(stats)
2044
- actions.append(
2045
- "LEGACY PROJECT-DEFINITION.vbrief.json LegacyArtifacts: "
2046
- f"{len(stats)} section(s)"
2047
- )
2048
-
2049
- # PRD.md section-name diff (OQ3-b, #505 Section 5). Hand-edited
2050
- # sections whose normalised title is NOT a canonical spec narrative
2051
- # key on the post-migration spec vBRIEF get captured with the
2052
- # warning prefix.
2053
- if prd_path.exists():
2054
- prd_content = prd_path.read_text(encoding="utf-8")
2055
- canonical_present = _canonical_spec_keys_in(spec_vbrief_path)
2056
- prd_legacy = _detect_prd_legacy(
2057
- prd_content, canonical_present, source_name="PRD.md"
2058
- )
2059
- if prd_legacy:
2060
- # Greptile #706 P1: pass ``flagged=True`` so the
2061
- # ``legacy:detected`` event payload carries
2062
- # ``flagged: true`` BEFORE emission, matching the
2063
- # ``events/registry.json`` (``category: "behavioral"``)
2064
- # contract for PRD.md hand-edit captures (post-#706
2065
- # unification per #709 / #710). The legacy stat-dict
2066
- # patch loop below is preserved as a defensive belt-
2067
- # and-suspenders for any downstream consumer that
2068
- # still inspects the returned stats list directly.
2069
- narrative, sidecars, stats = _emit_legacy_artifacts(
2070
- prd_legacy,
2071
- "PRD.md",
2072
- project_root,
2073
- slugify_fn=_slugify_shared,
2074
- warning_prefix=_PRD_HAND_EDIT_WARNING,
2075
- event_emitter=_legacy_event_emitter,
2076
- flagged=True,
2077
- )
2078
- for stat in stats:
2079
- stat["flagged"] = True
2080
- if narrative and spec_vbrief_path.exists():
2081
- narrative, stripped_ids = _strip_traces_from_narrative(
2082
- narrative
2083
- )
2084
- if stripped_ids:
2085
- traces_stripped_audit.append({
2086
- "source": "PRD.md",
2087
- "task_ids": stripped_ids,
2088
- })
2089
- _attach_legacy_narrative(spec_vbrief_path, narrative)
2090
- for sidecar in sidecars:
2091
- try:
2092
- rel = sidecar.relative_to(project_root).as_posix()
2093
- except ValueError:
2094
- rel = str(sidecar)
2095
- if rel not in created_files:
2096
- created_files.append(rel)
2097
- captures[
2098
- "PRD.md content (flagged: hand-edited)"
2099
- ].extend(stats)
2100
- actions.append(
2101
- "LEGACY PRD.md hand-edit captures: "
2102
- f"{len(stats)} section(s)"
2103
- )
2104
-
2105
- # Emit vbrief/migration/LEGACY-REPORT.md + stdout summary.
2106
- sources_read = [p for p in (
2107
- "SPECIFICATION.md" if spec_md_content else None,
2108
- "PROJECT.md" if project_content else None,
2109
- "ROADMAP.md" if total_items else None,
2110
- "PRD.md" if prd_path.exists() else None,
2111
- ) if p]
2112
- report_path = _emit_legacy_report(
2113
- project_root,
2114
- captures,
2115
- migrator_version=MIGRATOR_VERSION,
2116
- sources=sources_read,
2117
- )
2118
- if report_path is not None:
2119
- try:
2120
- rel_report = report_path.relative_to(project_root).as_posix()
2121
- except ValueError:
2122
- rel_report = str(report_path)
2123
- if rel_report not in created_files:
2124
- created_files.append(rel_report)
2125
- total_captured = sum(len(v) for v in captures.values())
2126
- actions.append(
2127
- f"CREATE {rel_report} ({total_captured} section(s) captured)"
2128
- )
2129
- for line in _summarize_captures(captures):
2130
- actions.append(line)
2131
- elif spec_legacy_sections or project_content:
2132
- actions.append("DRYRUN LEGACY capture (skipped under --dry-run)")
2133
- # --- end legacy-artifacts ---
2134
-
2135
- # --- reconciliation-report (Agent B, #496) ---
2136
- # Emit vbrief/migration/RECONCILIATION.md when SPEC and ROADMAP
2137
- # disagreed or any override triggered. Runs AFTER scope vBRIEFs are
2138
- # written but BEFORE Agent C's safety manifest so the report is
2139
- # recorded in created_files and removed on --rollback.
2140
- if not dry_run and reconciliation_report.has_disagreement():
2141
- report_path = _write_reconciliation_report(
2142
- reconciliation_report, vbrief_dir
2143
- )
2144
- if report_path is not None:
2145
- try:
2146
- rel = report_path.relative_to(project_root).as_posix()
2147
- except ValueError:
2148
- rel = str(report_path)
2149
- created_files.append(rel)
2150
- actions.append(f"CREATE {rel}")
2151
- elif dry_run and reconciliation_report.has_disagreement():
2152
- actions.append(
2153
- "DRYRUN CREATE vbrief/migration/RECONCILIATION.md"
2154
- )
2155
- # --- end reconciliation-report ---
2156
-
2157
- # #529: Append the Traces-stripped audit section to RECONCILIATION.md.
2158
- # Runs AFTER the reconciliation report so both live in the same file --
2159
- # the report writer overwrites, so appending last keeps both surfaces.
2160
- # In --dry-run the call short-circuits to a log line.
2161
- traces_report_path, traces_action = _write_traces_stripped_note(
2162
- project_root, traces_stripped_audit, dry_run=dry_run
2163
- )
2164
- if traces_action:
2165
- actions.append(traces_action)
2166
- if traces_report_path is not None:
2167
- try:
2168
- rel = traces_report_path.relative_to(project_root).as_posix()
2169
- except ValueError:
2170
- rel = str(traces_report_path)
2171
- if rel not in created_files:
2172
- created_files.append(rel)
2173
-
2174
- # --- prettier remediation breadcrumb (#670) ---
2175
- # Emit the prettier remediation note on stdout (via the action log) on
2176
- # every successful migration, and append it to vbrief/migration/
2177
- # LEGACY-REPORT.md when that report was generated (legacy sections were
2178
- # captured -- the typical pre-v0.20 migration; the report is already in
2179
- # created_files for --rollback). The note turns a surprise baseline
2180
- # ``task check`` prettier failure into a known one-command fix. Skipped
2181
- # under --dry-run (no files are written).
2182
- if not dry_run:
2183
- report_path = (
2184
- project_root / "vbrief" / "migration" / "LEGACY-REPORT.md"
2185
- )
2186
- if report_path.exists():
2187
- _append_prettier_breadcrumb(report_path)
2188
- for line in _prettier_breadcrumb_body():
2189
- actions.append(line)
2190
- # --- end prettier remediation breadcrumb ---
2191
-
2192
- # #527 / #528: record any migrator-managed subdirs we created (legacy,
2193
- # migration) in the safety manifest's created_dirs so --rollback RMDIRs
2194
- # them consistently with the lifecycle folders. Uses the pre-existed
2195
- # snapshot captured at the top of this function so the decision is
2196
- # driven by manifest state rather than filesystem scan.
2197
- #
2198
- # Pre-create vbrief/migration/ here because the safety manifest is about
2199
- # to be written into it below (via write_safety_manifest) -- by mkdir'ing
2200
- # now we surface its creation to the tracking helper in the same call
2201
- # site as all other managed-subdir tracking.
2202
- migration_dir = vbrief_dir / "migration"
2203
- if not dry_run and not migration_dir.is_dir():
2204
- migration_dir.mkdir(parents=True, exist_ok=True)
2205
- for subdir_name in _MANAGED_SUBDIRS:
2206
- _track_managed_subdir(
2207
- project_root,
2208
- subdir_name,
2209
- managed_subdir_pre_existed,
2210
- created_dirs,
2211
- )
2212
-
2213
- # --- safety (Agent C, #497) ---
2214
- # Persist a safety manifest for --rollback. The manifest lives under
2215
- # vbrief/migration/ (#506 shared path convention) and records:
2216
- # * every .premigrate.* backup we wrote (for restore);
2217
- # * every file/directory this run created (for removal on rollback);
2218
- # * post-migration stub hashes (so rollback can detect later edits).
2219
- #
2220
- # Re-run protection (Greptile #509 P1 cascade-3): when the migrator is
2221
- # re-invoked on an already-migrated project, plan_backups correctly
2222
- # returns zero pairs (sources are all stubs), so ``backup_records`` is
2223
- # empty. Writing a fresh manifest with ``backups=[]`` would overwrite
2224
- # the first run's record, leaving ``--rollback`` unable to restore any
2225
- # originals. Load any prior manifest and carry its backup records
2226
- # forward so subsequent rollback still works end-to-end. Stub hashes
2227
- # and created_files are merged the same way so rollback still knows
2228
- # which artefacts to remove.
2229
- prior = load_safety_manifest(project_root) if not dry_run else None
2230
- merged_backups = list(backup_records)
2231
- merged_stub_hashes = dict(stub_hashes)
2232
- merged_created_files = list(created_files)
2233
- merged_created_dirs = list(created_dirs)
2234
- merged_file_modifications = list(file_modifications)
2235
- if prior is not None:
2236
- # Re-run on already-migrated project: union the prior manifest's
2237
- # records with this run's so nothing recorded before is dropped.
2238
- # Current-run records take precedence for overlapping sources
2239
- # (fresh digest wins), and prior-run records for sources we did
2240
- # not touch this time (e.g. SPECIFICATION.md / PROJECT.md are
2241
- # stubs on the second pass and get skipped by plan_backups).
2242
- current_sources = {b.source for b in backup_records}
2243
- for prior_record in prior.backups:
2244
- if prior_record.source not in current_sources:
2245
- merged_backups.append(prior_record)
2246
- for rel, digest in prior.post_migration_stub_hashes.items():
2247
- merged_stub_hashes.setdefault(rel, digest)
2248
- for rel in prior.created_files:
2249
- if rel not in merged_created_files:
2250
- merged_created_files.append(rel)
2251
- for rel in prior.created_dirs:
2252
- if rel not in merged_created_dirs:
2253
- merged_created_dirs.append(rel)
2254
- # #567: carry prior file_modifications forward when the current
2255
- # run did not re-record the same path (e.g. a re-run on an
2256
- # already-migrated project whose .gitignore already has the
2257
- # patterns -- the helper returns ``None`` as a no-op). Without
2258
- # this, rollback would lose the original modification record
2259
- # and be unable to reverse the first run's append.
2260
- current_modification_paths = {m.path for m in file_modifications}
2261
- for prior_mod in prior.file_modifications:
2262
- if prior_mod.path not in current_modification_paths:
2263
- merged_file_modifications.append(prior_mod)
2264
- manifest = SafetyManifest(
2265
- version="1",
2266
- migration_timestamp=now_utc_iso(),
2267
- backups=merged_backups,
2268
- created_files=merged_created_files,
2269
- created_dirs=merged_created_dirs,
2270
- post_migration_stub_hashes=merged_stub_hashes,
2271
- file_modifications=merged_file_modifications,
2272
- )
2273
- manifest_action = write_safety_manifest(
2274
- project_root, manifest, dry_run=dry_run
2275
- )
2276
- actions.append(manifest_action)
2277
- # --- end safety ---
2278
-
2279
- # ---- Report ----
2280
- for w in warnings:
2281
- actions.append(w)
2282
-
2283
- # --- strict gate (Agent B, #496) ---
2284
- # ``task migrate:vbrief -- --strict`` must exit non-zero when any
2285
- # SPEC/ROADMAP disagreement was recorded so CI can gate cutover until
2286
- # the operator has reviewed RECONCILIATION.md. Runs BEFORE Agent D's
2287
- # validation gate because a reconciliation conflict is a workflow
2288
- # decision surface -- the scope vBRIEFs themselves are still
2289
- # schema-valid, so the operator would otherwise see a success exit
2290
- # from the validator. Agent C's .premigrate.* backups remain in place
2291
- # for ``task migrate:vbrief -- --rollback`` recovery either way.
2292
- if strict and reconciliation_report.has_disagreement() and not dry_run:
2293
- actions.append(
2294
- "STRICT: reconciliation conflicts detected; see "
2295
- "vbrief/migration/RECONCILIATION.md"
2296
- )
2297
- return False, actions
2298
- # --- end strict gate ---
2299
-
2300
- # --- validation (Agent D, #498) ---
2301
- # Hard-block on schema-invalid migration output per #506 D8. Runs AFTER
2302
- # Agent C's safety path (#497) so .premigrate.* backups and the safety
2303
- # manifest remain in place on failure for ``task migrate:vbrief --
2304
- # --rollback`` recovery. Skipped under --dry-run so operators can preview
2305
- # the plan without invoking the validator on a non-existent tree. Full
2306
- # implementation lives in scripts/_vbrief_validation.py::
2307
- # finalize_migration to keep migrate_vbrief.py under the 1000-line cap.
2308
- if dry_run:
2309
- return True, actions
2310
- return finalize_migration(project_root, vbrief_dir, actions)
2311
-
2312
-
2313
- def _edge_nodes(edge: dict) -> tuple[str, str]:
2314
- """Compatibility shim for the shared Speckit translator."""
2315
- return _edge_nodes_shared(edge)
2316
-
2317
-
2318
- def _dependencies_for_item(item_id: str, edges: list[dict]) -> list[str]:
2319
- """Compatibility shim for the shared Speckit translator."""
2320
- return _dependencies_for_item_shared(item_id, edges)
2321
-
2322
-
2323
- def _speckit_ip_slug(title: str, item_id: str) -> str:
2324
- """Compatibility shim for the shared Speckit translator."""
2325
- return _speckit_ip_slug_shared(title, item_id)
2326
-
2327
-
2328
- def _speckit_ip_index(item: dict, fallback_index: int) -> int:
2329
- """Compatibility shim for the shared Speckit translator."""
2330
- return _speckit_ip_index_shared(item, fallback_index)
2331
-
2332
-
2333
- def _create_speckit_scope_vbrief(
2334
- item: dict,
2335
- *,
2336
- ip_index: int,
2337
- dependencies: list[str],
2338
- spec_ref: str,
2339
- ) -> dict:
2340
- """Compatibility shim for the shared Speckit translator."""
2341
- return _create_speckit_scope_vbrief_shared(
2342
- item,
2343
- ip_index=ip_index,
2344
- dependencies=dependencies,
2345
- spec_ref=spec_ref,
2346
- )
2347
-
2348
-
2349
- def migrate_speckit_plan(
2350
- plan_path: Path,
2351
- *,
2352
- pending_dir: Path | None = None,
2353
- date: str | None = None,
2354
- spec_ref: str = "specification.vbrief.json",
2355
- ) -> tuple[bool, list[str]]:
2356
- """Compatibility shim for the shared Speckit translator."""
2357
- return _migrate_speckit_plan_shared(
2358
- plan_path,
2359
- pending_dir=pending_dir,
2360
- date=date,
2361
- spec_ref=spec_ref,
2362
- today=_TODAY,
2363
- )
2364
-
2365
-
2366
- # Pattern shared with ``reconcile_issues.ISSUE_URL_PATTERN``: matches the
2367
- # canonical v0.6 ``https://github.com/{owner}/{repo}/issues/{N}`` URI that
2368
- # the migrator now emits on scope vBRIEF references (#613). Kept at module
2369
- # scope so the regex compiles once per interpreter.
2370
- _CANONICAL_ISSUE_URI_RE = re.compile(
2371
- r"https://github\.com/[^/]+/[^/]+/issues/(?P<number>\d+)"
2372
- )
2373
-
2374
- # Filename-stem fallback pattern. When ``repo_url`` is unresolvable at
2375
- # migration time (no git remote, no ``spec_vbrief.repository`` hint), the
2376
- # migrator's scope vBRIEFs carry an empty ``plan.references`` -- the
2377
- # canonical shape requires ``uri`` which we can't synthesize without
2378
- # ``{owner}/{repo}``. Cross-day re-migrations must still deduplicate
2379
- # those files, so we pattern-match the leading issue number out of the
2380
- # filename stem (``YYYY-MM-DD-<N>-<slug>.vbrief.json`` per
2381
- # ``conventions/vbrief-filenames.md``). Addresses Greptile P1 finding:
2382
- # without this fallback, ``_find_existing_scope_vbrief`` returns None
2383
- # for every reference-less file and duplicate scope vBRIEFs accumulate
2384
- # on each re-run.
2385
- _FILENAME_ISSUE_RE = re.compile(
2386
- r"^\d{4}-\d{2}-\d{2}-(?P<number>\d+)-"
2387
- )
2388
-
2389
-
2390
- def _reference_matches_issue(ref: dict, issue_number: str) -> bool:
2391
- """Return True if ``ref`` points at GitHub issue ``#{issue_number}``.
2392
-
2393
- Accepts both the canonical v0.6 shape ``{uri, type: x-vbrief/github-
2394
- issue, title}`` and the legacy shape ``{type: github-issue, id}`` so
2395
- the migrator's duplicate-suppression path stays idempotent during the
2396
- transition (#613). ``issue_number`` is the bare digit string.
2397
- """
2398
- if not isinstance(ref, dict) or not issue_number:
2399
- return False
2400
- legacy_id = ref.get("id")
2401
- if isinstance(legacy_id, str) and legacy_id == f"#{issue_number}":
2402
- return True
2403
- uri = ref.get("uri")
2404
- if isinstance(uri, str) and uri:
2405
- match = _CANONICAL_ISSUE_URI_RE.search(uri)
2406
- if match and match.group("number") == issue_number:
2407
- return True
2408
- return False
2409
-
2410
-
2411
- def _find_existing_scope_vbrief(vbrief_dir: Path, issue_number: str) -> Path | None:
2412
- """Check if any existing vBRIEF in lifecycle folders matches the issue.
2413
-
2414
- Three-tier match (most-reliable first):
2415
-
2416
- 1. Canonical v0.6 reference shape -- ``plan.references[*].uri``
2417
- contains the canonical ``.../issues/{N}`` URI (#613 primary path).
2418
- 2. Legacy reference shape -- ``plan.references[*].id == "#{N}"`` (kept
2419
- for mixed-shape worktrees during the transition).
2420
- 3. Filename-stem fallback -- ``YYYY-MM-DD-{N}-`` prefix. Covers the
2421
- edge case where ``repo_url`` was unresolvable at migration time
2422
- (no git remote, no ``spec_vbrief.repository`` hint); those files
2423
- ship with empty ``plan.references`` because the canonical
2424
- ``VBriefReference`` schema requires ``uri`` which we cannot
2425
- synthesize. Without this fallback, cross-day re-migrations on
2426
- such projects silently produce duplicate scope vBRIEFs because
2427
- tier 1 and tier 2 both miss.
2428
-
2429
- Returns the first matching path found, or ``None``.
2430
- """
2431
- # Pass 1: reference-based match (canonical + legacy, same scan).
2432
- for folder_name in LIFECYCLE_FOLDERS:
2433
- folder = vbrief_dir / folder_name
2434
- if not folder.exists():
2435
- continue
2436
- for fpath in folder.glob("*.vbrief.json"):
2437
- try:
2438
- data = json.loads(fpath.read_text(encoding="utf-8"))
2439
- refs = data.get("plan", {}).get("references", [])
2440
- if not isinstance(refs, list):
2441
- continue
2442
- for ref in refs:
2443
- if _reference_matches_issue(ref, issue_number):
2444
- return fpath
2445
- except (json.JSONDecodeError, AttributeError):
2446
- continue
2447
-
2448
- # Pass 2: filename-stem fallback for reference-less files.
2449
- if not issue_number:
2450
- return None
2451
- for folder_name in LIFECYCLE_FOLDERS:
2452
- folder = vbrief_dir / folder_name
2453
- if not folder.exists():
2454
- continue
2455
- for fpath in folder.glob("*.vbrief.json"):
2456
- stem = fpath.name.removesuffix(".vbrief.json")
2457
- match = _FILENAME_ISSUE_RE.match(stem)
2458
- if match and match.group("number") == issue_number:
2459
- return fpath
2460
- return None
2461
-
2462
-
2463
- def _fold_custom_content(proj_def_path: Path, key: str, content: str) -> bool:
2464
- """Fold custom content into PROJECT-DEFINITION.vbrief.json narratives.
2465
-
2466
- Returns True if content was successfully preserved, False otherwise.
2467
-
2468
- Legacy fallback preserved for backward compatibility; the new
2469
- ``LegacyArtifacts`` mechanism (#505) captures non-canonical ## sections
2470
- with full provenance headers and is the preferred preservation surface
2471
- going forward.
2472
- """
2473
- if not proj_def_path.exists():
2474
- return False
2475
- try:
2476
- data = json.loads(proj_def_path.read_text(encoding="utf-8"))
2477
- data.setdefault("plan", {}).setdefault("narratives", {})[key] = content
2478
- proj_def_path.write_text(
2479
- json.dumps(data, indent=2, ensure_ascii=False) + "\n",
2480
- encoding="utf-8",
2481
- )
2482
- return True
2483
- except (json.JSONDecodeError, AttributeError):
2484
- return False
2485
-
2486
-
2487
- # --- legacy-artifacts (Agent A, #505) ---
2488
- def _attach_legacy_narrative(vbrief_path: Path, narrative: str) -> None:
2489
- """Append a ``LegacyArtifacts`` narrative onto an existing vBRIEF file.
2490
-
2491
- If the file already carries a ``LegacyArtifacts`` narrative, the new
2492
- content is concatenated (blank-line separator) so multiple capture
2493
- passes on one run do not silently overwrite one another.
2494
- """
2495
- if not vbrief_path.exists():
2496
- return
2497
- try:
2498
- data = json.loads(vbrief_path.read_text(encoding="utf-8"))
2499
- except (json.JSONDecodeError, OSError):
2500
- return
2501
- plan = data.setdefault("plan", {})
2502
- narratives = plan.setdefault("narratives", {})
2503
- existing = narratives.get("LegacyArtifacts", "")
2504
- if isinstance(existing, str) and existing.strip():
2505
- narratives["LegacyArtifacts"] = (
2506
- existing.rstrip() + "\n\n" + narrative.strip() + "\n"
2507
- )
2508
- else:
2509
- narratives["LegacyArtifacts"] = narrative
2510
- vbrief_path.write_text(
2511
- json.dumps(data, indent=2, ensure_ascii=False) + "\n",
2512
- encoding="utf-8",
2513
- )
2514
-
2515
-
2516
- def _canonical_spec_keys_in(spec_vbrief_path: Path) -> set[str]:
2517
- """Return the canonical spec narrative keys present on disk.
2518
-
2519
- Used by the PRD.md section-name diff (OQ3-b, #505 Section 5) to decide
2520
- whether a PRD ## section is expected render output (skip capture) or a
2521
- hand-edited section that should be captured with the warning prefix.
2522
- """
2523
- if not spec_vbrief_path.exists():
2524
- return set()
2525
- try:
2526
- data = json.loads(spec_vbrief_path.read_text(encoding="utf-8"))
2527
- except (json.JSONDecodeError, OSError):
2528
- return set()
2529
- narratives = data.get("plan", {}).get("narratives", {}) or {}
2530
- if not isinstance(narratives, dict):
2531
- return set()
2532
- return {
2533
- k for k, v in narratives.items()
2534
- if k in _CANONICAL_SPEC_KEYS and isinstance(v, str) and v.strip()
2535
- }
2536
- # --- end legacy-artifacts ---
2537
-
2538
-
2539
- def main() -> int:
2540
- """Entry point for the migration script."""
2541
- import argparse
2542
-
2543
- args = list(sys.argv[1:])
2544
-
2545
- # --speckit-plan <path> subcommand: convert a speckit-shaped plan.vbrief.json
2546
- # into per-IP scope vBRIEFs in ``<plan dir>/pending/`` (#436, #458).
2547
- # Handled ahead of the main argparse so we keep its positional-path calling
2548
- # convention stable for the test harness that already exercises it.
2549
- if args and args[0] == "--speckit-plan":
2550
- if len(args) < 2:
2551
- print(
2552
- "ERROR: --speckit-plan requires a path argument",
2553
- file=sys.stderr,
2554
- )
2555
- return 2
2556
- plan_path = Path(args[1]).resolve()
2557
- print(f"Migrating speckit plan at: {plan_path}")
2558
- print("=" * 60)
2559
- ok, messages = migrate_speckit_plan(plan_path)
2560
- for msg in messages:
2561
- print(f" {msg}")
2562
- print("=" * 60)
2563
- if ok:
2564
- print("speckit plan migration completed successfully.")
2565
- return 0
2566
- print("speckit plan migration FAILED.", file=sys.stderr)
2567
- return 1
2568
-
2569
- # --- safety (Agent C, #497) ---
2570
- # Primary CLI for `task migrate:vbrief` -- positional project_root +
2571
- # --dry-run / --force / --rollback flags per #506 D7.
2572
- parser = argparse.ArgumentParser(
2573
- prog="migrate_vbrief.py",
2574
- description=(
2575
- "Migrate a Deft project to the vBRIEF-centric document model. "
2576
- "Destructive by default; use --dry-run to preview or --rollback "
2577
- "to undo a previous migration."
2578
- ),
2579
- )
2580
- parser.add_argument(
2581
- "project_root",
2582
- nargs="?",
2583
- default=None,
2584
- help="Path to the project root (default: current working directory).",
2585
- )
2586
- parser.add_argument(
2587
- "--dry-run",
2588
- action="store_true",
2589
- help=(
2590
- "Print the migration plan without writing any files. Exits 0 on "
2591
- "success with every planned action prefixed DRYRUN."
2592
- ),
2593
- )
2594
- parser.add_argument(
2595
- "--force",
2596
- action="store_true",
2597
- help=(
2598
- "Bypass the dirty-tree guard (and the rollback confirmation / "
2599
- "edited-stub guard). Not recommended."
2600
- ),
2601
- )
2602
- parser.add_argument(
2603
- "--rollback",
2604
- action="store_true",
2605
- help=(
2606
- "Restore from .premigrate.* backups and remove the scope "
2607
- "vBRIEFs and migration artefacts a prior run created. Reads "
2608
- "vbrief/migration/safety-manifest.json written by the migrator."
2609
- ),
2610
- )
2611
- # --- strict flag (Agent B, #496) ---
2612
- parser.add_argument(
2613
- "--strict",
2614
- action="store_true",
2615
- help=(
2616
- "Fail the run non-zero if SPEC and ROADMAP disagreed on any "
2617
- "dimension or any override from vbrief/migration-overrides.yaml "
2618
- "triggered. Scope vBRIEFs and vbrief/migration/RECONCILIATION.md "
2619
- "are still written so the operator can inspect and re-run."
2620
- ),
2621
- )
2622
- # --- end strict flag ---
2623
- ns = parser.parse_args(args)
2624
-
2625
- project_root = (
2626
- Path(ns.project_root).resolve() if ns.project_root else Path.cwd()
2627
- )
2628
-
2629
- if not project_root.is_dir():
2630
- print(f"ERROR: {project_root} is not a directory", file=sys.stderr)
2631
- return 1
2632
-
2633
- if ns.rollback:
2634
- print(f"Rolling back migration at: {project_root}")
2635
- print("=" * 60)
2636
- ok, messages = safety_rollback(project_root, force=ns.force)
2637
- for msg in messages:
2638
- print(f" {msg}")
2639
- print("=" * 60)
2640
- if ok:
2641
- print("Rollback completed successfully.")
2642
- return 0
2643
- print("Rollback FAILED.", file=sys.stderr)
2644
- return 1
2645
-
2646
- if ns.dry_run:
2647
- print(f"Dry-run migration at: {project_root}")
2648
- else:
2649
- print(f"Migrating project at: {project_root}")
2650
- if ns.strict:
2651
- print("Strict mode enabled: reconciliation conflicts will fail the run.")
2652
- print("=" * 60)
2653
-
2654
- ok, messages = migrate(
2655
- project_root,
2656
- dry_run=ns.dry_run,
2657
- force=ns.force,
2658
- strict=ns.strict,
2659
- )
2660
-
2661
- for msg in messages:
2662
- print(f" {msg}")
2663
-
2664
- print("=" * 60)
2665
- if ok:
2666
- if ns.dry_run:
2667
- print("Dry-run completed successfully. No files were modified.")
2668
- else:
2669
- print("Migration completed successfully.")
2670
- return 0
2671
- # --- end safety ---
2672
- print("Migration FAILED.", file=sys.stderr)
2673
- return 1
2674
-
2675
-
2676
- if __name__ == "__main__":
2677
- sys.exit(main())