@deftai/directive-content 0.55.2 → 0.56.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.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +1 -1
|
@@ -0,0 +1,2677 @@
|
|
|
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())
|