@deftai/directive-content 0.55.1 → 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.
Files changed (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,883 @@
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
+ ]