@deftai/directive-content 0.59.0 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.githooks/pre-commit +10 -128
- package/.githooks/pre-push +8 -108
- package/Taskfile.yml +48 -58
- package/UPGRADING.md +19 -3
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/skills/skills-pack-0.1.json +1 -1
- package/packs/strategies/strategies-pack-0.1.json +19 -19
- package/scm/github.md +37 -6
- package/skills/deft-directive-setup/SKILL.md +24 -15
- package/strategies/speckit.md +14 -14
- package/strategies/v0-20-contract.md +12 -1
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +26 -0
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -1
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2552
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- package/scripts/verify_vbrief_conformance.py +0 -478
|
@@ -1,883 +0,0 @@
|
|
|
1
|
-
"""Reconciliation of SPEC and ROADMAP sources during migrate:vbrief (Agent B, #496).
|
|
2
|
-
|
|
3
|
-
Implements the role-based reconciliation strategy mandated by master tracking
|
|
4
|
-
issue #506 (Decisions D3, D4) and by issue #496's Acceptance Criteria:
|
|
5
|
-
|
|
6
|
-
* Identity (body / acceptance / traces) is SPEC-owned. IDs pass through
|
|
7
|
-
unchanged -- this module never renumbers tasks.
|
|
8
|
-
* Status is ROADMAP-owned when ROADMAP carries an explicit completion signal
|
|
9
|
-
(``[done]`` in an active list or entry in a ``## Completed`` section).
|
|
10
|
-
Otherwise SPEC ``[done]`` (or ``plan.items[*].status == "completed"``)
|
|
11
|
-
wins as tiebreaker. The module never defaults to ``pending`` for tasks
|
|
12
|
-
that have any completion signal from either source.
|
|
13
|
-
* Grouping preserves both: ``narrative.Phase`` = ROADMAP milestone;
|
|
14
|
-
``narrative.SpecPhase`` = SPEC phase heading. The ROADMAP one-liner is
|
|
15
|
-
preserved in ``narrative.RoadmapSummary`` only when it differs from the
|
|
16
|
-
SPEC title.
|
|
17
|
-
* Orphan ROADMAP items (no matching SPEC task) route to ``vbrief/proposed/``
|
|
18
|
-
with ``narrative.SourceConflict = "missing-from-spec"``. When SPEC has no
|
|
19
|
-
items at all, orphan detection is disabled and ROADMAP items fall through
|
|
20
|
-
to ``pending/`` -- this preserves the degenerate case where a project has
|
|
21
|
-
a ROADMAP but no structured SPEC.
|
|
22
|
-
* Each narrative key gets a sibling ``*_source`` field ("SPECIFICATION.md" /
|
|
23
|
-
"ROADMAP.md" / "migration-overrides.yaml") so post-migration drift is
|
|
24
|
-
auditable without re-running the migrator.
|
|
25
|
-
|
|
26
|
-
Overrides (``vbrief/migration-overrides.yaml``) are applied BEFORE defaults
|
|
27
|
-
so operators can pin known resolutions. Every override that triggered is
|
|
28
|
-
logged to the RECONCILIATION.md report. A tiny purpose-built parser covers
|
|
29
|
-
the documented schema shape -- PyYAML is not a hard dependency for the
|
|
30
|
-
framework and we do not want to force consumers to install it for an
|
|
31
|
-
optional feature.
|
|
32
|
-
"""
|
|
33
|
-
|
|
34
|
-
from __future__ import annotations
|
|
35
|
-
|
|
36
|
-
import re
|
|
37
|
-
from dataclasses import dataclass, field
|
|
38
|
-
from datetime import UTC, datetime
|
|
39
|
-
from pathlib import Path
|
|
40
|
-
from typing import Any
|
|
41
|
-
|
|
42
|
-
# ---------------------------------------------------------------------------
|
|
43
|
-
# Status signal detection
|
|
44
|
-
# ---------------------------------------------------------------------------
|
|
45
|
-
|
|
46
|
-
_DONE_MARKERS: tuple[str, ...] = ("[done]", "[x]", "[X]", "\u2713", "\u2705")
|
|
47
|
-
_WIP_MARKERS: tuple[str, ...] = (
|
|
48
|
-
"[wip]", "[in progress]", "[in-progress]", "[running]", "[active]",
|
|
49
|
-
)
|
|
50
|
-
_BLOCKED_MARKERS: tuple[str, ...] = ("[blocked]",)
|
|
51
|
-
_CANCELLED_MARKERS: tuple[str, ...] = ("[cancelled]", "[canceled]")
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _detect_status_marker(text: str) -> str | None:
|
|
55
|
-
"""Return a schema-native status inferred from a markdown marker in ``text``.
|
|
56
|
-
|
|
57
|
-
Scans for ``[done]`` / ``[wip]`` / ``[blocked]`` / ``[cancelled]`` style
|
|
58
|
-
markers that operators commonly sprinkle on SPECIFICATION.md task lines.
|
|
59
|
-
Returns ``None`` if no recognised marker is present.
|
|
60
|
-
"""
|
|
61
|
-
if not text:
|
|
62
|
-
return None
|
|
63
|
-
lower = text.lower()
|
|
64
|
-
if any(m.lower() in lower for m in _CANCELLED_MARKERS):
|
|
65
|
-
return "cancelled"
|
|
66
|
-
if any(m.lower() in lower for m in _BLOCKED_MARKERS):
|
|
67
|
-
return "blocked"
|
|
68
|
-
if any(m in text or m.lower() in lower for m in _DONE_MARKERS):
|
|
69
|
-
return "completed"
|
|
70
|
-
if any(m.lower() in lower for m in _WIP_MARKERS):
|
|
71
|
-
return "running"
|
|
72
|
-
return None
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
# ---------------------------------------------------------------------------
|
|
76
|
-
# Overrides loader (vbrief/migration-overrides.yaml)
|
|
77
|
-
# ---------------------------------------------------------------------------
|
|
78
|
-
|
|
79
|
-
OVERRIDES_FILENAME = "migration-overrides.yaml"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _strip_quotes(value: str) -> str:
|
|
83
|
-
value = value.strip()
|
|
84
|
-
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
85
|
-
return value[1:-1]
|
|
86
|
-
return value
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _coerce_scalar(value: str) -> Any:
|
|
90
|
-
v = _strip_quotes(value)
|
|
91
|
-
lower = v.lower()
|
|
92
|
-
if lower in ("true", "yes", "on"):
|
|
93
|
-
return True
|
|
94
|
-
if lower in ("false", "no", "off"):
|
|
95
|
-
return False
|
|
96
|
-
if lower in ("null", "none", "~", ""):
|
|
97
|
-
return None
|
|
98
|
-
return v
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def parse_overrides_yaml(text: str) -> dict[str, dict[str, Any]]:
|
|
102
|
-
"""Parse the documented migration-overrides.yaml schema shape.
|
|
103
|
-
|
|
104
|
-
Recognised shape (mirrors #496 Proposed design component 4)::
|
|
105
|
-
|
|
106
|
-
overrides:
|
|
107
|
-
t2.4.1:
|
|
108
|
-
status: completed
|
|
109
|
-
body_source: spec
|
|
110
|
-
t3.1.2:
|
|
111
|
-
status: pending
|
|
112
|
-
body_source: roadmap
|
|
113
|
-
roadmap-9:
|
|
114
|
-
drop: true
|
|
115
|
-
|
|
116
|
-
Parser intentionally accepts a conservative subset: top-level
|
|
117
|
-
``overrides:`` mapping, one level of task-id keys, leaf scalar values.
|
|
118
|
-
Lines starting with ``#`` are comments. Returns an empty mapping when
|
|
119
|
-
no ``overrides:`` key is present.
|
|
120
|
-
"""
|
|
121
|
-
result: dict[str, dict[str, Any]] = {}
|
|
122
|
-
current_task: str | None = None
|
|
123
|
-
current_task_indent: int = 0
|
|
124
|
-
in_overrides = False
|
|
125
|
-
|
|
126
|
-
for raw_line in text.splitlines():
|
|
127
|
-
# Preserve indentation; strip only trailing whitespace and full-line
|
|
128
|
-
# comments. In-line ``#`` comments are left alone because the override
|
|
129
|
-
# values can legitimately contain ``#`` (e.g. issue references).
|
|
130
|
-
line = raw_line.rstrip()
|
|
131
|
-
stripped = line.lstrip()
|
|
132
|
-
if not stripped or stripped.startswith("#"):
|
|
133
|
-
continue
|
|
134
|
-
|
|
135
|
-
indent = len(line) - len(stripped)
|
|
136
|
-
|
|
137
|
-
if indent == 0:
|
|
138
|
-
# Top-level key -- only ``overrides:`` is meaningful.
|
|
139
|
-
key = stripped.split(":", 1)[0].strip()
|
|
140
|
-
in_overrides = key == "overrides"
|
|
141
|
-
current_task = None
|
|
142
|
-
current_task_indent = 0
|
|
143
|
-
continue
|
|
144
|
-
|
|
145
|
-
if not in_overrides:
|
|
146
|
-
continue
|
|
147
|
-
|
|
148
|
-
# Task-id row: a colon-terminated key with no other colons in the key
|
|
149
|
-
# name (task IDs match ^[a-zA-Z0-9_.-]+$ per #506). Indent must be >= 2
|
|
150
|
-
# but we do NOT pin the exact indent width so 2-space AND 4-space YAML
|
|
151
|
-
# (common .editorconfig settings) both work (Greptile #524 P1).
|
|
152
|
-
if stripped.endswith(":") and ":" not in stripped[:-1] and indent >= 2:
|
|
153
|
-
current_task = stripped[:-1].strip()
|
|
154
|
-
current_task_indent = indent
|
|
155
|
-
result.setdefault(current_task, {})
|
|
156
|
-
continue
|
|
157
|
-
|
|
158
|
-
# Field row: must be nested under a task-id row (strictly deeper indent),
|
|
159
|
-
# e.g. `` status: completed``. The stricter indent comparison catches
|
|
160
|
-
# malformed YAML where a field appears at the same level as the task id.
|
|
161
|
-
if (
|
|
162
|
-
current_task is not None
|
|
163
|
-
and ":" in stripped
|
|
164
|
-
and indent > current_task_indent
|
|
165
|
-
):
|
|
166
|
-
key, _, value = stripped.partition(":")
|
|
167
|
-
result[current_task][key.strip()] = _coerce_scalar(value)
|
|
168
|
-
|
|
169
|
-
return result
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def load_overrides(vbrief_dir: Path) -> dict[str, dict[str, Any]]:
|
|
173
|
-
"""Load ``vbrief/migration-overrides.yaml`` if present. Returns {} if absent."""
|
|
174
|
-
path = vbrief_dir / OVERRIDES_FILENAME
|
|
175
|
-
if not path.is_file():
|
|
176
|
-
return {}
|
|
177
|
-
try:
|
|
178
|
-
text = path.read_text(encoding="utf-8")
|
|
179
|
-
except OSError:
|
|
180
|
-
return {}
|
|
181
|
-
return parse_overrides_yaml(text)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
# ---------------------------------------------------------------------------
|
|
185
|
-
# SPEC task index
|
|
186
|
-
# ---------------------------------------------------------------------------
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def _normalize_task_id(task_id: str) -> str:
|
|
190
|
-
"""Canonicalise a task id for cross-source matching.
|
|
191
|
-
|
|
192
|
-
Strips a leading ``t`` / ``T`` before a digit or dot (so SPEC's ``t1.1.1``
|
|
193
|
-
matches ROADMAP's ``1.1.1``), trims whitespace, and returns the rest
|
|
194
|
-
verbatim. Empty / falsy input returns ``""``.
|
|
195
|
-
"""
|
|
196
|
-
if not task_id:
|
|
197
|
-
return ""
|
|
198
|
-
s = task_id.strip()
|
|
199
|
-
if len(s) >= 2 and s[0] in ("t", "T") and (s[1].isdigit() or s[1] == "."):
|
|
200
|
-
return s[1:].lstrip("-.").strip()
|
|
201
|
-
return s
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
# Bilingual reference-type gate: accepts both the canonical v0.6
|
|
205
|
-
# ``x-vbrief/github-issue`` type (#613) and the legacy ``github-issue``
|
|
206
|
-
# shape so SPEC items authored before the canonical flip continue to
|
|
207
|
-
# surface their GitHub-issue cross-links during reconciliation.
|
|
208
|
-
_GITHUB_ISSUE_REF_TYPES: frozenset[str] = frozenset(
|
|
209
|
-
{"github-issue", "x-vbrief/github-issue"}
|
|
210
|
-
)
|
|
211
|
-
# Match a canonical v0.6 ``https://github.com/{owner}/{repo}/issues/{N}``
|
|
212
|
-
# URI so ``_collect_issue_numbers`` can recover the bare issue number from
|
|
213
|
-
# either the legacy ``id: "#N"`` field or the canonical ``uri``.
|
|
214
|
-
_GITHUB_ISSUE_URI_RE = re.compile(
|
|
215
|
-
r"https://github\.com/[^/]+/[^/]+/issues/(?P<number>\d+)"
|
|
216
|
-
)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def _collect_issue_numbers(item: dict) -> list[str]:
|
|
220
|
-
"""Extract GitHub issue numbers referenced by a SPEC item.
|
|
221
|
-
|
|
222
|
-
Accepts both the canonical v0.6 reference shape ``{uri, type: x-
|
|
223
|
-
vbrief/github-issue, title}`` and the legacy ``{type: github-issue,
|
|
224
|
-
id}`` shape so mixed-shape SPEC files reconcile correctly during the
|
|
225
|
-
migrator transition (#613).
|
|
226
|
-
"""
|
|
227
|
-
numbers: list[str] = []
|
|
228
|
-
refs = item.get("references") or []
|
|
229
|
-
if isinstance(refs, list):
|
|
230
|
-
for ref in refs:
|
|
231
|
-
if not isinstance(ref, dict):
|
|
232
|
-
continue
|
|
233
|
-
if ref.get("type") not in _GITHUB_ISSUE_REF_TYPES:
|
|
234
|
-
continue
|
|
235
|
-
# Canonical shape: recover the trailing /issues/{N} segment
|
|
236
|
-
# from ``uri``.
|
|
237
|
-
uri = ref.get("uri")
|
|
238
|
-
if isinstance(uri, str) and uri:
|
|
239
|
-
match = _GITHUB_ISSUE_URI_RE.search(uri)
|
|
240
|
-
if match:
|
|
241
|
-
numbers.append(match.group("number"))
|
|
242
|
-
continue
|
|
243
|
-
# Legacy shape: ``id`` carries ``#N`` verbatim.
|
|
244
|
-
rid = str(ref.get("id", "")).lstrip("#")
|
|
245
|
-
if rid:
|
|
246
|
-
numbers.append(rid)
|
|
247
|
-
return numbers
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
@dataclass
|
|
251
|
-
class SpecTaskEntry:
|
|
252
|
-
"""A flattened SPEC task with enough context for reconciliation."""
|
|
253
|
-
|
|
254
|
-
item: dict = field(default_factory=dict)
|
|
255
|
-
spec_phase: str = ""
|
|
256
|
-
source_line: str = ""
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
def build_spec_task_index(spec_vbrief: dict | None) -> dict[str, SpecTaskEntry]:
|
|
260
|
-
"""Flatten ``spec_vbrief.plan.items`` (+ subItems) into an index.
|
|
261
|
-
|
|
262
|
-
Keys include both the raw ``item.id`` and the normalised form (so
|
|
263
|
-
``t1.1.1`` <-> ``1.1.1``) plus any referenced GitHub issue numbers
|
|
264
|
-
(both ``#123`` and ``123`` forms). Values carry the closest parent
|
|
265
|
-
phase label for later narrative.SpecPhase emission.
|
|
266
|
-
"""
|
|
267
|
-
index: dict[str, SpecTaskEntry] = {}
|
|
268
|
-
if not isinstance(spec_vbrief, dict):
|
|
269
|
-
return index
|
|
270
|
-
plan = spec_vbrief.get("plan", {})
|
|
271
|
-
if not isinstance(plan, dict):
|
|
272
|
-
return index
|
|
273
|
-
|
|
274
|
-
def _walk(items: object, parent_phase: str) -> None:
|
|
275
|
-
if not isinstance(items, list):
|
|
276
|
-
return
|
|
277
|
-
for item in items:
|
|
278
|
-
if not isinstance(item, dict):
|
|
279
|
-
continue
|
|
280
|
-
title = str(item.get("title", "") or "")
|
|
281
|
-
# A SPEC item that represents a phase contributes its own title
|
|
282
|
-
# as the phase label for its descendants.
|
|
283
|
-
if re.match(r"^(Phase\s+\d|IP[-\s]\d|Milestone\s+\d)", title,
|
|
284
|
-
flags=re.IGNORECASE):
|
|
285
|
-
child_phase = title
|
|
286
|
-
else:
|
|
287
|
-
child_phase = parent_phase
|
|
288
|
-
|
|
289
|
-
item_id = str(item.get("id", "") or "")
|
|
290
|
-
entry = SpecTaskEntry(item=item, spec_phase=parent_phase)
|
|
291
|
-
if item_id:
|
|
292
|
-
index.setdefault(item_id, entry)
|
|
293
|
-
normalised = _normalize_task_id(item_id)
|
|
294
|
-
if normalised and normalised != item_id:
|
|
295
|
-
index.setdefault(normalised, entry)
|
|
296
|
-
|
|
297
|
-
for num in _collect_issue_numbers(item):
|
|
298
|
-
index.setdefault(num, entry)
|
|
299
|
-
index.setdefault(f"#{num}", entry)
|
|
300
|
-
|
|
301
|
-
_walk(item.get("subItems", []), child_phase)
|
|
302
|
-
|
|
303
|
-
_walk(plan.get("items", []), parent_phase="")
|
|
304
|
-
return index
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
# ---------------------------------------------------------------------------
|
|
308
|
-
# SPEC body / acceptance / traces extraction
|
|
309
|
-
# ---------------------------------------------------------------------------
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
def _pick_narrative(item: dict, *keys: str) -> str:
|
|
313
|
-
"""Return the first non-empty narrative value for any of ``keys``."""
|
|
314
|
-
narrative = item.get("narrative") or {}
|
|
315
|
-
if not isinstance(narrative, dict):
|
|
316
|
-
return ""
|
|
317
|
-
for key in keys:
|
|
318
|
-
value = narrative.get(key)
|
|
319
|
-
if isinstance(value, str) and value.strip():
|
|
320
|
-
return value.strip()
|
|
321
|
-
return ""
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
def _spec_body(item: dict, default: str) -> str:
|
|
325
|
-
"""Return the SPEC-derived Description for a spec item, or ``default``."""
|
|
326
|
-
body = _pick_narrative(item, "Description", "Summary", "Body", "Overview")
|
|
327
|
-
if body:
|
|
328
|
-
return body
|
|
329
|
-
# Fallback to the item title so callers always get a non-empty body.
|
|
330
|
-
return str(item.get("title", "") or "").strip() or default
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
# ---------------------------------------------------------------------------
|
|
334
|
-
# Reconciliation report
|
|
335
|
-
# ---------------------------------------------------------------------------
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
@dataclass
|
|
339
|
-
class ConflictEntry:
|
|
340
|
-
task_id: str
|
|
341
|
-
title: str
|
|
342
|
-
dimensions: list[dict[str, str]] = field(default_factory=list)
|
|
343
|
-
overrides_applied: list[str] = field(default_factory=list)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
@dataclass
|
|
347
|
-
class ReconciliationReport:
|
|
348
|
-
conflicts: list[ConflictEntry] = field(default_factory=list)
|
|
349
|
-
orphans: list[dict[str, str]] = field(default_factory=list)
|
|
350
|
-
overrides_triggered: list[dict[str, str]] = field(default_factory=list)
|
|
351
|
-
overrides_unused: list[str] = field(default_factory=list)
|
|
352
|
-
|
|
353
|
-
def has_disagreement(self) -> bool:
|
|
354
|
-
return bool(
|
|
355
|
-
self.conflicts
|
|
356
|
-
or self.orphans
|
|
357
|
-
or self.overrides_triggered
|
|
358
|
-
)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
# ---------------------------------------------------------------------------
|
|
362
|
-
# Core reconciliation
|
|
363
|
-
# ---------------------------------------------------------------------------
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
def _status_from_spec(entry: SpecTaskEntry) -> str | None:
|
|
367
|
-
"""Return a schema-native status derived from SPEC data, or None."""
|
|
368
|
-
item = entry.item
|
|
369
|
-
status = item.get("status")
|
|
370
|
-
if isinstance(status, str) and status in {
|
|
371
|
-
"draft", "proposed", "approved", "pending",
|
|
372
|
-
"running", "completed", "blocked", "cancelled",
|
|
373
|
-
}:
|
|
374
|
-
return status
|
|
375
|
-
# Inline [done] marker on the title is also a signal.
|
|
376
|
-
return _detect_status_marker(str(item.get("title", "") or ""))
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
def _roadmap_status(roadmap_item: dict, completed: bool) -> str | None:
|
|
380
|
-
"""Return a status signal carried by the ROADMAP row, or None.
|
|
381
|
-
|
|
382
|
-
``completed`` is ``True`` when the row comes from ROADMAP's Completed
|
|
383
|
-
section. Otherwise status is derived from inline markers on the title.
|
|
384
|
-
"""
|
|
385
|
-
if completed:
|
|
386
|
-
return "completed"
|
|
387
|
-
title = str(roadmap_item.get("title", "") or "")
|
|
388
|
-
return _detect_status_marker(title)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
def _choose_status(
|
|
392
|
-
task_id: str,
|
|
393
|
-
title: str,
|
|
394
|
-
spec_entry: SpecTaskEntry | None,
|
|
395
|
-
roadmap_status: str | None,
|
|
396
|
-
override_status: str | None,
|
|
397
|
-
) -> tuple[str, str, str | None]:
|
|
398
|
-
"""Return ``(status, status_source, conflict_note)`` per D3 policy.
|
|
399
|
-
|
|
400
|
-
* Override wins when present.
|
|
401
|
-
* ROADMAP wins when it carries an explicit completion signal.
|
|
402
|
-
* SPEC ``[done]`` / SPEC ``status: completed`` is tiebreaker otherwise.
|
|
403
|
-
* Default is ``pending`` when nothing else applies.
|
|
404
|
-
"""
|
|
405
|
-
if override_status:
|
|
406
|
-
return override_status, "migration-overrides.yaml", None
|
|
407
|
-
|
|
408
|
-
spec_status = _status_from_spec(spec_entry) if spec_entry else None
|
|
409
|
-
|
|
410
|
-
# ROADMAP wins for explicit signals.
|
|
411
|
-
if roadmap_status:
|
|
412
|
-
if spec_status and spec_status != roadmap_status:
|
|
413
|
-
conflict = (
|
|
414
|
-
f"SPEC status = {spec_status!r}; "
|
|
415
|
-
f"ROADMAP status = {roadmap_status!r}; "
|
|
416
|
-
f"ROADMAP wins (D3 role policy)."
|
|
417
|
-
)
|
|
418
|
-
return roadmap_status, "ROADMAP.md", conflict
|
|
419
|
-
return roadmap_status, "ROADMAP.md", None
|
|
420
|
-
|
|
421
|
-
# SPEC tiebreaker.
|
|
422
|
-
if spec_status:
|
|
423
|
-
return spec_status, "SPECIFICATION.md (tiebreaker)", None
|
|
424
|
-
|
|
425
|
-
return "pending", "default", None
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
def _title_conflict(
|
|
429
|
-
spec_entry: SpecTaskEntry | None, roadmap_title: str,
|
|
430
|
-
) -> tuple[str, str, str | None, str]:
|
|
431
|
-
"""Return ``(title, title_source, conflict_note, roadmap_summary)``.
|
|
432
|
-
|
|
433
|
-
SPEC title wins over ROADMAP one-liner per D3. When SPEC is absent, the
|
|
434
|
-
ROADMAP title becomes the scope title and no RoadmapSummary is emitted.
|
|
435
|
-
When titles differ, the ROADMAP one-liner is preserved in
|
|
436
|
-
``RoadmapSummary``.
|
|
437
|
-
"""
|
|
438
|
-
roadmap_title = (roadmap_title or "").strip()
|
|
439
|
-
if not spec_entry:
|
|
440
|
-
return roadmap_title, "ROADMAP.md", None, ""
|
|
441
|
-
|
|
442
|
-
spec_title = str(spec_entry.item.get("title", "") or "").strip()
|
|
443
|
-
if not spec_title:
|
|
444
|
-
return roadmap_title, "ROADMAP.md", None, ""
|
|
445
|
-
|
|
446
|
-
if spec_title == roadmap_title:
|
|
447
|
-
return spec_title, "SPECIFICATION.md", None, ""
|
|
448
|
-
|
|
449
|
-
# Drift: both titles present but differ.
|
|
450
|
-
conflict = (
|
|
451
|
-
f"SPEC title = {spec_title!r}; ROADMAP title = {roadmap_title!r}; "
|
|
452
|
-
f"SPEC wins; ROADMAP preserved in narrative.RoadmapSummary."
|
|
453
|
-
)
|
|
454
|
-
return spec_title, "SPECIFICATION.md", conflict, roadmap_title
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
def _description(
|
|
458
|
-
spec_entry: SpecTaskEntry | None, roadmap_title: str, body_source_override: str | None,
|
|
459
|
-
) -> tuple[str, str]:
|
|
460
|
-
"""Pick description and source per body_source override or default D3 policy."""
|
|
461
|
-
if body_source_override == "roadmap":
|
|
462
|
-
return (roadmap_title or "").strip(), "ROADMAP.md (override)"
|
|
463
|
-
if body_source_override == "spec":
|
|
464
|
-
if spec_entry:
|
|
465
|
-
return _spec_body(spec_entry.item, roadmap_title), "SPECIFICATION.md (override)"
|
|
466
|
-
return (roadmap_title or "").strip(), "ROADMAP.md (override fallback: no SPEC match)"
|
|
467
|
-
|
|
468
|
-
if spec_entry:
|
|
469
|
-
body = _spec_body(spec_entry.item, roadmap_title)
|
|
470
|
-
return body, "SPECIFICATION.md"
|
|
471
|
-
return (roadmap_title or "").strip(), "ROADMAP.md"
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
def _override_status(override: dict[str, Any] | None) -> str | None:
|
|
475
|
-
if not override:
|
|
476
|
-
return None
|
|
477
|
-
status = override.get("status")
|
|
478
|
-
if isinstance(status, str) and status:
|
|
479
|
-
return status
|
|
480
|
-
return None
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
def _override_body_source(override: dict[str, Any] | None) -> str | None:
|
|
484
|
-
if not override:
|
|
485
|
-
return None
|
|
486
|
-
body_source = override.get("body_source")
|
|
487
|
-
if isinstance(body_source, str) and body_source in ("spec", "roadmap"):
|
|
488
|
-
return body_source
|
|
489
|
-
return None
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
def _override_drop(override: dict[str, Any] | None) -> bool:
|
|
493
|
-
if not override:
|
|
494
|
-
return False
|
|
495
|
-
return bool(override.get("drop"))
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
def _task_id_for_item(item: dict, is_completed: bool) -> str:
|
|
499
|
-
"""Return the canonical key the overrides file uses for this ROADMAP row."""
|
|
500
|
-
number = item.get("number", "")
|
|
501
|
-
if number:
|
|
502
|
-
return f"#{number}"
|
|
503
|
-
task_id = item.get("task_id", "")
|
|
504
|
-
if task_id:
|
|
505
|
-
return task_id
|
|
506
|
-
synthetic = item.get("synthetic_id", "")
|
|
507
|
-
if synthetic:
|
|
508
|
-
return synthetic
|
|
509
|
-
# Last resort -- deterministic fallback based on title (completed vs active
|
|
510
|
-
# so an ambiguous collision can't silently merge across states).
|
|
511
|
-
suffix = "completed" if is_completed else "active"
|
|
512
|
-
return f"{suffix}:{item.get('title', 'untitled')}"
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
def _lookup_override(
|
|
516
|
-
item: dict, canonical_key: str, overrides: dict[str, dict[str, Any]],
|
|
517
|
-
) -> tuple[dict[str, Any] | None, str | None]:
|
|
518
|
-
"""Return ``(override, matched_key)`` for any key shape the overrides file uses.
|
|
519
|
-
|
|
520
|
-
Operators tend to write ``t1.1.1`` in migration-overrides.yaml even when the
|
|
521
|
-
ROADMAP row renders as ``1.1.1`` (bare). We try each plausible form so a
|
|
522
|
-
single override line can drive either form of row.
|
|
523
|
-
"""
|
|
524
|
-
if not overrides:
|
|
525
|
-
return None, None
|
|
526
|
-
candidates: list[str] = [canonical_key]
|
|
527
|
-
task_id = str(item.get("task_id", "") or "")
|
|
528
|
-
if task_id:
|
|
529
|
-
normalised = _normalize_task_id(task_id)
|
|
530
|
-
candidates.extend([task_id, normalised, f"t{task_id}", f"t{normalised}"])
|
|
531
|
-
number = str(item.get("number", "") or "")
|
|
532
|
-
if number:
|
|
533
|
-
candidates.extend([number, f"#{number}"])
|
|
534
|
-
synthetic = str(item.get("synthetic_id", "") or "")
|
|
535
|
-
if synthetic:
|
|
536
|
-
candidates.append(synthetic)
|
|
537
|
-
for key in candidates:
|
|
538
|
-
if key and key in overrides:
|
|
539
|
-
return overrides[key], key
|
|
540
|
-
return None, None
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
def _match_spec_entry(
|
|
544
|
-
item: dict, spec_index: dict[str, SpecTaskEntry],
|
|
545
|
-
) -> SpecTaskEntry | None:
|
|
546
|
-
"""Best-effort SPEC lookup for a ROADMAP row."""
|
|
547
|
-
if not spec_index:
|
|
548
|
-
return None
|
|
549
|
-
number = str(item.get("number", "") or "")
|
|
550
|
-
if number:
|
|
551
|
-
for key in (number, f"#{number}"):
|
|
552
|
-
entry = spec_index.get(key)
|
|
553
|
-
if entry:
|
|
554
|
-
return entry
|
|
555
|
-
task_id = str(item.get("task_id", "") or "")
|
|
556
|
-
if task_id:
|
|
557
|
-
for key in (task_id, _normalize_task_id(task_id), f"t{task_id}"):
|
|
558
|
-
entry = spec_index.get(key)
|
|
559
|
-
if entry:
|
|
560
|
-
return entry
|
|
561
|
-
return None
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
def reconcile_scope_items(
|
|
565
|
-
*,
|
|
566
|
-
roadmap_active: list[dict],
|
|
567
|
-
roadmap_completed: list[dict],
|
|
568
|
-
spec_vbrief: dict | None,
|
|
569
|
-
phase_descriptions: dict[str, str] | None = None,
|
|
570
|
-
overrides: dict[str, dict[str, Any]] | None = None,
|
|
571
|
-
) -> tuple[list[dict], ReconciliationReport]:
|
|
572
|
-
"""Reconcile ROADMAP and SPEC into a list of routed scope items.
|
|
573
|
-
|
|
574
|
-
Returns ``(reconciled_items, report)`` where each reconciled item has the
|
|
575
|
-
shape consumed by ``_vbrief_routing.build_scope_vbrief_from_reconciled``.
|
|
576
|
-
The caller is responsible for writing the scope vBRIEFs to disk and
|
|
577
|
-
dispatching the report (``write_reconciliation_report``).
|
|
578
|
-
"""
|
|
579
|
-
overrides = overrides or {}
|
|
580
|
-
phase_descriptions = phase_descriptions or {}
|
|
581
|
-
spec_index = build_spec_task_index(spec_vbrief)
|
|
582
|
-
spec_has_items = bool(spec_index)
|
|
583
|
-
|
|
584
|
-
reconciled: list[dict] = []
|
|
585
|
-
report = ReconciliationReport()
|
|
586
|
-
used_override_keys: set[str] = set()
|
|
587
|
-
|
|
588
|
-
def _handle(item: dict, *, is_completed: bool) -> None:
|
|
589
|
-
task_key = _task_id_for_item(item, is_completed=is_completed)
|
|
590
|
-
override, matched_key = _lookup_override(item, task_key, overrides)
|
|
591
|
-
if override is not None and matched_key is not None:
|
|
592
|
-
used_override_keys.add(matched_key)
|
|
593
|
-
|
|
594
|
-
if _override_drop(override):
|
|
595
|
-
report.overrides_triggered.append({
|
|
596
|
-
"task_id": task_key,
|
|
597
|
-
"title": str(item.get("title", "") or ""),
|
|
598
|
-
"action": "dropped from migration",
|
|
599
|
-
})
|
|
600
|
-
return
|
|
601
|
-
|
|
602
|
-
spec_entry = _match_spec_entry(item, spec_index)
|
|
603
|
-
title, title_source, title_conflict, roadmap_summary = _title_conflict(
|
|
604
|
-
spec_entry, str(item.get("title", "") or ""),
|
|
605
|
-
)
|
|
606
|
-
|
|
607
|
-
description, description_source = _description(
|
|
608
|
-
spec_entry,
|
|
609
|
-
roadmap_title=str(item.get("title", "") or ""),
|
|
610
|
-
body_source_override=_override_body_source(override),
|
|
611
|
-
)
|
|
612
|
-
|
|
613
|
-
roadmap_status = _roadmap_status(item, completed=is_completed)
|
|
614
|
-
status, status_source, status_conflict = _choose_status(
|
|
615
|
-
task_id=task_key,
|
|
616
|
-
title=title,
|
|
617
|
-
spec_entry=spec_entry,
|
|
618
|
-
roadmap_status=roadmap_status,
|
|
619
|
-
override_status=_override_status(override),
|
|
620
|
-
)
|
|
621
|
-
|
|
622
|
-
# Orphan: ROADMAP item with no SPEC match, but SPEC had items.
|
|
623
|
-
# #496 acceptance: "route to vbrief/proposed/ with
|
|
624
|
-
# narrative.SourceConflict = 'missing-from-spec' so it surfaces for
|
|
625
|
-
# triage rather than silently joining the backlog."
|
|
626
|
-
#
|
|
627
|
-
# #593 (rc.4): when the orphan came from the ROADMAP's ``##
|
|
628
|
-
# Completed`` section, the completion signal is authoritative --
|
|
629
|
-
# ROADMAP explicitly tombstoned the issue as shipped. Preserve
|
|
630
|
-
# that signal by routing to completed/ with status=completed
|
|
631
|
-
# rather than burying it in proposed/ where downstream renderers
|
|
632
|
-
# (task roadmap:render / task project:render) would misreport 165
|
|
633
|
-
# shipped items as open backlog. Active-phase orphans retain the
|
|
634
|
-
# original proposed/ routing for triage. The orphan is still
|
|
635
|
-
# recorded in report.orphans so --strict flags the SPEC drift.
|
|
636
|
-
source_conflict = ""
|
|
637
|
-
folder: str
|
|
638
|
-
if spec_has_items and spec_entry is None:
|
|
639
|
-
source_conflict = "missing-from-spec"
|
|
640
|
-
if is_completed:
|
|
641
|
-
folder = "completed"
|
|
642
|
-
status = "completed"
|
|
643
|
-
status_source = (
|
|
644
|
-
"orphan: ROADMAP Completed section (#593)"
|
|
645
|
-
)
|
|
646
|
-
else:
|
|
647
|
-
folder = "proposed"
|
|
648
|
-
status = "proposed"
|
|
649
|
-
status_source = "orphan: proposed default"
|
|
650
|
-
report.orphans.append({
|
|
651
|
-
"task_id": task_key,
|
|
652
|
-
"title": title or str(item.get("title", "") or ""),
|
|
653
|
-
})
|
|
654
|
-
else:
|
|
655
|
-
# Lifecycle routing happens outside this module, but we need the
|
|
656
|
-
# folder here to ensure the status we emit is permitted in it.
|
|
657
|
-
folder = _folder_from_status(status)
|
|
658
|
-
|
|
659
|
-
phase = item.get("phase", "") or ""
|
|
660
|
-
tier = item.get("tier", "") or ""
|
|
661
|
-
phase_desc = phase_descriptions.get(phase, "") if phase else ""
|
|
662
|
-
spec_phase = spec_entry.spec_phase if spec_entry else ""
|
|
663
|
-
|
|
664
|
-
# ``source_section`` is the human-readable label for which part of
|
|
665
|
-
# ROADMAP.md fed this item (#593). ``is_completed`` is True for rows
|
|
666
|
-
# parsed from ``## Completed``; every other row (phase sections,
|
|
667
|
-
# tiered sub-phases, and items accumulated when SPEC has no
|
|
668
|
-
# ROADMAP counterpart at all) comes from the active phase
|
|
669
|
-
# portion of the document.
|
|
670
|
-
source_section = (
|
|
671
|
-
"ROADMAP Completed section" if is_completed
|
|
672
|
-
else "ROADMAP active phase"
|
|
673
|
-
)
|
|
674
|
-
reconciled.append({
|
|
675
|
-
"task_id": task_key,
|
|
676
|
-
"number": str(item.get("number", "") or ""),
|
|
677
|
-
"title": title,
|
|
678
|
-
"title_source": title_source if title_conflict else "",
|
|
679
|
-
"description": description,
|
|
680
|
-
"description_source": description_source,
|
|
681
|
-
"status": status,
|
|
682
|
-
"status_source": status_source,
|
|
683
|
-
"folder": folder,
|
|
684
|
-
"phase": phase,
|
|
685
|
-
"phase_description": phase_desc,
|
|
686
|
-
"tier": tier,
|
|
687
|
-
"spec_phase": spec_phase if spec_phase != phase else "",
|
|
688
|
-
"roadmap_summary": roadmap_summary,
|
|
689
|
-
"source_conflict": source_conflict,
|
|
690
|
-
"source_section": source_section,
|
|
691
|
-
"is_completed": is_completed,
|
|
692
|
-
"override_applied": override is not None,
|
|
693
|
-
"synthetic_id": item.get("synthetic_id", ""),
|
|
694
|
-
"original_task_id": item.get("task_id", ""),
|
|
695
|
-
})
|
|
696
|
-
|
|
697
|
-
# Record conflicts and override triggers.
|
|
698
|
-
dims: list[dict[str, str]] = []
|
|
699
|
-
if title_conflict:
|
|
700
|
-
dims.append({
|
|
701
|
-
"dimension": "TITLE drift",
|
|
702
|
-
"spec": str(spec_entry.item.get("title", "") if spec_entry else ""),
|
|
703
|
-
"roadmap": str(item.get("title", "") or ""),
|
|
704
|
-
"resolution": title_conflict,
|
|
705
|
-
})
|
|
706
|
-
if status_conflict:
|
|
707
|
-
dims.append({
|
|
708
|
-
"dimension": "STATUS conflict",
|
|
709
|
-
"spec": _status_from_spec(spec_entry) or "(none)" if spec_entry else "(no match)",
|
|
710
|
-
"roadmap": roadmap_status or "(none)",
|
|
711
|
-
"resolution": status_conflict,
|
|
712
|
-
})
|
|
713
|
-
|
|
714
|
-
triggered_fields: list[str] = []
|
|
715
|
-
if override is not None:
|
|
716
|
-
for key in ("status", "body_source"):
|
|
717
|
-
if key in override:
|
|
718
|
-
triggered_fields.append(key)
|
|
719
|
-
# drop:false is a no-op that explicitly records "do NOT drop this
|
|
720
|
-
# task" and must not trip --strict. Only drop:true is a triggered
|
|
721
|
-
# action (Greptile #524 P1).
|
|
722
|
-
if override.get("drop"):
|
|
723
|
-
triggered_fields.append("drop")
|
|
724
|
-
# Only record overrides that actually triggered a field change.
|
|
725
|
-
# A no-op override (e.g. drop:false with no other keys) still gets
|
|
726
|
-
# counted as used (so unused-override surfacing is accurate) but
|
|
727
|
-
# must not make has_disagreement() return True.
|
|
728
|
-
if triggered_fields:
|
|
729
|
-
report.overrides_triggered.append({
|
|
730
|
-
"task_id": task_key,
|
|
731
|
-
"title": title,
|
|
732
|
-
"fields": ", ".join(triggered_fields),
|
|
733
|
-
})
|
|
734
|
-
|
|
735
|
-
if dims or triggered_fields:
|
|
736
|
-
report.conflicts.append(ConflictEntry(
|
|
737
|
-
task_id=task_key,
|
|
738
|
-
title=title or str(item.get("title", "") or ""),
|
|
739
|
-
dimensions=dims,
|
|
740
|
-
overrides_applied=triggered_fields,
|
|
741
|
-
))
|
|
742
|
-
|
|
743
|
-
for item in roadmap_active:
|
|
744
|
-
_handle(item, is_completed=False)
|
|
745
|
-
for item in roadmap_completed:
|
|
746
|
-
_handle(item, is_completed=True)
|
|
747
|
-
|
|
748
|
-
# Overrides that never triggered -- surface so operators notice stale pins.
|
|
749
|
-
for key in overrides:
|
|
750
|
-
if key not in used_override_keys:
|
|
751
|
-
report.overrides_unused.append(key)
|
|
752
|
-
|
|
753
|
-
return reconciled, report
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
# NOTE: MUST mirror scripts/_vbrief_routing.STATUS_TO_FOLDER (#506 lifecycle↔status
|
|
757
|
-
# table). Kept inline to avoid an import cycle between reconciliation and
|
|
758
|
-
# routing. A cross-module equality test in tests/cli/test_vbrief_routing.py
|
|
759
|
-
# asserts both dicts stay in sync; update both sides together when the
|
|
760
|
-
# schema grows a new status (Greptile #524 P2).
|
|
761
|
-
def _folder_from_status(status: str) -> str:
|
|
762
|
-
"""Local copy of ``_vbrief_routing.folder_for_status`` to avoid an import cycle."""
|
|
763
|
-
mapping = {
|
|
764
|
-
"draft": "proposed", "proposed": "proposed",
|
|
765
|
-
"approved": "pending", "pending": "pending",
|
|
766
|
-
"running": "active", "blocked": "active",
|
|
767
|
-
"completed": "completed",
|
|
768
|
-
"cancelled": "cancelled",
|
|
769
|
-
}
|
|
770
|
-
return mapping.get(status, "pending")
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
# ---------------------------------------------------------------------------
|
|
774
|
-
# RECONCILIATION.md emitter
|
|
775
|
-
# ---------------------------------------------------------------------------
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
def _format_conflict_entry(entry: ConflictEntry) -> str:
|
|
779
|
-
lines = [f"## {entry.task_id} -- {entry.title}", ""]
|
|
780
|
-
for dim in entry.dimensions:
|
|
781
|
-
lines.append(f"- {dim['dimension']}")
|
|
782
|
-
if dim.get("spec"):
|
|
783
|
-
lines.append(f" - SPEC: {dim['spec']}")
|
|
784
|
-
if dim.get("roadmap"):
|
|
785
|
-
lines.append(f" - ROADMAP: {dim['roadmap']}")
|
|
786
|
-
lines.append(f" - Resolution: {dim['resolution']}")
|
|
787
|
-
if entry.overrides_applied:
|
|
788
|
-
lines.append(
|
|
789
|
-
f"- Overrides applied: {', '.join(entry.overrides_applied)} "
|
|
790
|
-
"(migration-overrides.yaml)"
|
|
791
|
-
)
|
|
792
|
-
lines.append("")
|
|
793
|
-
return "\n".join(lines)
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
def format_reconciliation_markdown(report: ReconciliationReport) -> str:
|
|
797
|
-
"""Render the report as the markdown emitted to RECONCILIATION.md."""
|
|
798
|
-
timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
799
|
-
parts: list[str] = [
|
|
800
|
-
"# Migration reconciliation report",
|
|
801
|
-
"",
|
|
802
|
-
f"Generated: {timestamp}",
|
|
803
|
-
"",
|
|
804
|
-
"Per #496 this file is emitted whenever SPECIFICATION.md and ROADMAP.md "
|
|
805
|
-
"disagreed on any dimension during `task migrate:vbrief`, or when any "
|
|
806
|
-
"override from `vbrief/migration-overrides.yaml` triggered.",
|
|
807
|
-
"",
|
|
808
|
-
]
|
|
809
|
-
|
|
810
|
-
if report.conflicts:
|
|
811
|
-
parts.append("## Per-task conflicts")
|
|
812
|
-
parts.append("")
|
|
813
|
-
for entry in report.conflicts:
|
|
814
|
-
parts.append(_format_conflict_entry(entry))
|
|
815
|
-
else:
|
|
816
|
-
parts.append("## Per-task conflicts")
|
|
817
|
-
parts.append("")
|
|
818
|
-
parts.append("(none)")
|
|
819
|
-
parts.append("")
|
|
820
|
-
|
|
821
|
-
parts.append("## Orphans in ROADMAP (no matching SPEC task)")
|
|
822
|
-
parts.append("")
|
|
823
|
-
if report.orphans:
|
|
824
|
-
for orph in report.orphans:
|
|
825
|
-
parts.append(
|
|
826
|
-
f"- `{orph['task_id']}` -- {orph['title']}\n"
|
|
827
|
-
f" - Resolution: emitted to vbrief/proposed/ with "
|
|
828
|
-
f"narrative.SourceConflict = \"missing-from-spec\"."
|
|
829
|
-
)
|
|
830
|
-
else:
|
|
831
|
-
parts.append("(none)")
|
|
832
|
-
parts.append("")
|
|
833
|
-
|
|
834
|
-
parts.append("## Overrides applied (vbrief/migration-overrides.yaml)")
|
|
835
|
-
parts.append("")
|
|
836
|
-
if report.overrides_triggered:
|
|
837
|
-
for ov in report.overrides_triggered:
|
|
838
|
-
fields = ov.get("fields", "") or ov.get("action", "")
|
|
839
|
-
parts.append(
|
|
840
|
-
f"- `{ov['task_id']}` -- {ov.get('title', '')}: {fields}"
|
|
841
|
-
)
|
|
842
|
-
else:
|
|
843
|
-
parts.append("(none)")
|
|
844
|
-
parts.append("")
|
|
845
|
-
|
|
846
|
-
if report.overrides_unused:
|
|
847
|
-
parts.append("## Overrides defined but not triggered")
|
|
848
|
-
parts.append("")
|
|
849
|
-
for key in report.overrides_unused:
|
|
850
|
-
parts.append(f"- `{key}`")
|
|
851
|
-
parts.append("")
|
|
852
|
-
|
|
853
|
-
return "\n".join(parts).rstrip() + "\n"
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
def write_reconciliation_report(
|
|
857
|
-
report: ReconciliationReport, vbrief_dir: Path,
|
|
858
|
-
) -> Path | None:
|
|
859
|
-
"""Write ``vbrief/migration/RECONCILIATION.md`` when the report has content.
|
|
860
|
-
|
|
861
|
-
Returns the path written, or ``None`` when no disagreement was recorded.
|
|
862
|
-
"""
|
|
863
|
-
if not report.has_disagreement():
|
|
864
|
-
return None
|
|
865
|
-
target_dir = vbrief_dir / "migration"
|
|
866
|
-
target_dir.mkdir(parents=True, exist_ok=True)
|
|
867
|
-
target = target_dir / "RECONCILIATION.md"
|
|
868
|
-
target.write_text(format_reconciliation_markdown(report), encoding="utf-8")
|
|
869
|
-
return target
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
__all__ = [
|
|
873
|
-
"OVERRIDES_FILENAME",
|
|
874
|
-
"ConflictEntry",
|
|
875
|
-
"ReconciliationReport",
|
|
876
|
-
"SpecTaskEntry",
|
|
877
|
-
"build_spec_task_index",
|
|
878
|
-
"format_reconciliation_markdown",
|
|
879
|
-
"load_overrides",
|
|
880
|
-
"parse_overrides_yaml",
|
|
881
|
-
"reconcile_scope_items",
|
|
882
|
-
"write_reconciliation_report",
|
|
883
|
-
]
|