@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,667 @@
1
+ #!/usr/bin/env python3
2
+ """resolve_changelog_unreleased.py -- union-merge CHANGELOG [Unreleased] conflicts (#911).
3
+
4
+ Pure stdlib, cross-platform. Invoked from:
5
+
6
+ - ``task changelog:resolve-unreleased`` (Taskfile target wraps this script)
7
+ - ``uv run python scripts/resolve_changelog_unreleased.py [--changelog-path PATH]``
8
+ - Manually as a swarm cascade Phase 6 Step 1 helper, replacing the older
9
+ HEAD-take-and-discard pattern that silently dropped the rebasing branch's
10
+ CHANGELOG entry on every cascade rebase.
11
+
12
+ Recurrence record (the bug this script closes):
13
+
14
+ The 2026-05-04 v0.25.1 swarm cascade (4 PRs: #909 -> #907 -> #908 -> #906)
15
+ honoured the swarm-skill Phase 6 Step 1 rules ("use ``edit_files`` not shell
16
+ regex; verify structural integrity post-resolve") and the structural integrity
17
+ check passed. But the resolution PATTERN used (taking only the HEAD side of
18
+ each ``[Unreleased]``-section conflict) silently dropped the rebasing branch's
19
+ new CHANGELOG entry on every rebase after the first. Net effect: PR #908
20
+ squash-merged WITHOUT its CHANGELOG entry for #900; PR #906 squash-merged
21
+ WITHOUT its CHANGELOG entry for #901.
22
+
23
+ The correct resolution is a **union merge**: keep ALL HEAD entries (they are
24
+ the prior PRs' contributions that already landed on master) AND prepend each
25
+ branch entry that is not already in HEAD by ``(#NNN)`` issue-number heuristic.
26
+
27
+ Algorithm (per the #911 vBRIEF Overview):
28
+
29
+ 1. Read CHANGELOG.md (UTF-8, atomic).
30
+ 2. Locate the ``## [Unreleased]`` section and the next top-level ``## [...]``
31
+ section header (or EOF).
32
+ 3. Within those bounds, locate each conflict block delimited by
33
+ ``<<<<<<< HEAD`` / ``=======`` / ``>>>>>>> <sha>``.
34
+ 4. For each conflict block:
35
+ - Determine the ambient ``### <subsection>`` (the most recent ``### header``
36
+ between the start of ``[Unreleased]`` and the conflict marker).
37
+ - Parse HEAD side and branch side as ``### <subsection>`` -> entries
38
+ mappings; entries that appear before any ``###`` header are attached to
39
+ the ambient subsection.
40
+ - Union-merge: keep ALL HEAD entries; for each branch entry, if its
41
+ ``(#NNN)`` issue-number set does not overlap any HEAD entry in the same
42
+ subsection, PREPEND it under that subsection. Subsections that exist
43
+ only in the branch side are appended.
44
+ 5. Atomic write back via ``tempfile.NamedTemporaryFile`` + ``os.replace``;
45
+ verify no ``<<<<<<<`` / ``=======`` / ``>>>>>>>`` markers remain
46
+ post-resolve. If markers remain (e.g. conflict outside [Unreleased]),
47
+ exit 1.
48
+
49
+ Three-state exit (mirrors ``scripts/preflight_branch.py`` / ``scripts/verify_encoding.py``):
50
+
51
+ - ``0`` -- resolved (or no-op when no conflict markers were present).
52
+ - ``1`` -- unresolvable: corrupted / mismatched / nested markers, or markers
53
+ remained after the resolve pass.
54
+ - ``2`` -- config error: ``--changelog-path`` does not exist, file unreadable,
55
+ or unrecognised CLI shape.
56
+
57
+ Out of scope (documented, NOT worked around):
58
+
59
+ - Conflicts INSIDE released sections (``## [0.X.Y]``) are NOT resolved here;
60
+ the script reports them as exit 1 unresolvable so the operator falls back
61
+ to ``edit_files`` (the manual fallback path documented in
62
+ ``skills/deft-directive-swarm/SKILL.md`` Phase 6 Step 1).
63
+ - Multi-line entries with non-bullet continuation lines are preserved when
64
+ the continuation is indented (leading whitespace) but a bare blank line
65
+ ends the entry block. This matches the dominant CHANGELOG.md style.
66
+ """
67
+
68
+ from __future__ import annotations
69
+
70
+ import argparse
71
+ import contextlib
72
+ import os
73
+ import re
74
+ import sys
75
+ import tempfile
76
+ from pathlib import Path
77
+
78
+ #: Top-level section header (``## [Unreleased]`` or ``## [0.26.0] - ...``).
79
+ SECTION_HEADER_RE = re.compile(r"^##\s+\[([^\]]+)\]")
80
+
81
+ #: Subsection header (``### Added`` / ``### Fixed`` / ...).
82
+ SUBSECTION_HEADER_RE = re.compile(r"^###\s+(.+?)\s*$")
83
+
84
+ #: Bullet entry start (`- entry text` -- leading whitespace tolerated for
85
+ #: indented sublists).
86
+ ENTRY_BULLET_RE = re.compile(r"^\s*-\s")
87
+
88
+ #: Issue-number reference (``(#911)`` / ``(#1234)``). The dedup heuristic
89
+ #: extracts the SET of all issue numbers in an entry; two entries are
90
+ #: considered duplicates iff their sets share at least one number.
91
+ ISSUE_NUM_RE = re.compile(r"\(#(\d+)\)")
92
+
93
+ #: Entry-start with an opening bold marker (``- **`` / ``* **``). A deft
94
+ #: CHANGELOG entry canonically opens ``- **<conventional-commit subject>** --``;
95
+ #: a *truncated* header keeps the opening ``**`` but loses the closing ``**``
96
+ #: (and the trailing ``(#NNN)``), which is the orphan-stub shape #1003 fixes.
97
+ ENTRY_BOLD_OPEN_RE = re.compile(r"^\s*[-*]\s+\*\*")
98
+
99
+ #: Number of normalized leading characters used by the content-prefix dedup
100
+ #: fallback for entries that carry no ``(#NNN)`` reference (#1003).
101
+ CONTENT_PREFIX_LEN = 60
102
+
103
+ #: Conflict markers (the three-state union of git's standard merge markers).
104
+ CONFLICT_HEAD_PREFIX = "<<<<<<< "
105
+ CONFLICT_SEP = "======="
106
+ CONFLICT_TAIL_PREFIX = ">>>>>>> "
107
+
108
+ #: Sentinel for "no ambient subsection" (entries directly under ``[Unreleased]``
109
+ #: with no ``###`` header above them). Empty string is used internally; on
110
+ #: render we emit entries-only without re-emitting any header.
111
+ AMBIENT_NONE = ""
112
+
113
+
114
+ def _self_reconfigure_utf8() -> None:
115
+ """Force UTF-8 stdout/stderr at script entry per #814.
116
+
117
+ Mirrors the block at the top of :mod:`scripts.preflight_branch` and
118
+ :mod:`scripts.verify_encoding`. Windows Python defaults stdout to cp1252
119
+ (or cp437) when invoked from a hook or Taskfile target, neither of which
120
+ has glyphs for the diagnostic messages this script may emit (``->``,
121
+ ``check``, etc.). Without this reconfigure, the script can crash with
122
+ ``UnicodeEncodeError`` AFTER the resolve has already succeeded, leaving
123
+ the operator unsure whether the file was rewritten. ``errors='replace'``
124
+ is a belt-and-suspenders fallback for the rare environment that still
125
+ cannot render UTF-8.
126
+ """
127
+ if hasattr(sys.stdout, "reconfigure"):
128
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
129
+ if hasattr(sys.stderr, "reconfigure"):
130
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
131
+
132
+
133
+ def find_unreleased_bounds(lines: list[str]) -> tuple[int, int] | tuple[None, None]:
134
+ """Return ``(start, end)`` line indices of the ``[Unreleased]`` section.
135
+
136
+ ``start`` is the index of the ``## [Unreleased]`` header line; ``end`` is
137
+ the index of the NEXT ``## [...]`` header (or ``len(lines)`` when
138
+ ``[Unreleased]`` is the last section). When no ``[Unreleased]`` header
139
+ exists, returns ``(None, None)``.
140
+ """
141
+ start: int | None = None
142
+ for i, line in enumerate(lines):
143
+ m = SECTION_HEADER_RE.match(line)
144
+ if m and m.group(1).strip().lower() == "unreleased":
145
+ start = i
146
+ break
147
+ if start is None:
148
+ return None, None
149
+ end = len(lines)
150
+ for j in range(start + 1, len(lines)):
151
+ if SECTION_HEADER_RE.match(lines[j]):
152
+ end = j
153
+ break
154
+ return start, end
155
+
156
+
157
+ def find_conflict_blocks(
158
+ lines: list[str], start: int, end: int
159
+ ) -> list[tuple[int, int, int]] | None:
160
+ """Find conflict blocks within ``lines[start:end]``.
161
+
162
+ Returns a list of ``(head_idx, sep_idx, tail_idx)`` triples (inclusive line
163
+ indices). Returns ``None`` on any structural error -- nested marker, missing
164
+ separator, missing tail, or sep/tail before head -- so the caller can
165
+ surface exit 1 with a clear unresolvable diagnostic.
166
+ """
167
+ blocks: list[tuple[int, int, int]] = []
168
+ i = start
169
+ while i < end:
170
+ line = lines[i]
171
+ if line.startswith(CONFLICT_HEAD_PREFIX):
172
+ head_idx = i
173
+ sep_idx: int | None = None
174
+ tail_idx: int | None = None
175
+ j = i + 1
176
+ while j < end:
177
+ inner = lines[j]
178
+ if inner.startswith(CONFLICT_HEAD_PREFIX):
179
+ # Nested conflict head before a tail closes the prior --
180
+ # not supported here. Bail to manual fallback.
181
+ return None
182
+ if inner == CONFLICT_SEP and sep_idx is None:
183
+ sep_idx = j
184
+ elif inner.startswith(CONFLICT_TAIL_PREFIX) and sep_idx is not None:
185
+ tail_idx = j
186
+ break
187
+ j += 1
188
+ if sep_idx is None or tail_idx is None:
189
+ return None
190
+ blocks.append((head_idx, sep_idx, tail_idx))
191
+ i = tail_idx + 1
192
+ elif line == CONFLICT_SEP or line.startswith(CONFLICT_TAIL_PREFIX):
193
+ # Stray separator/tail without a preceding head -- malformed.
194
+ return None
195
+ else:
196
+ i += 1
197
+ return blocks
198
+
199
+
200
+ def find_ambient_subsection(
201
+ lines: list[str], conflict_start: int, unreleased_start: int
202
+ ) -> str:
203
+ """Walk back from ``conflict_start - 1`` to find the most recent ``### header``.
204
+
205
+ Stops at the ``[Unreleased]`` header to avoid matching subsections inside
206
+ a previously-rendered released section. Returns the subsection name when
207
+ found, else :data:`AMBIENT_NONE` (``""``).
208
+ """
209
+ for i in range(conflict_start - 1, unreleased_start, -1):
210
+ m = SUBSECTION_HEADER_RE.match(lines[i])
211
+ if m:
212
+ return m.group(1).strip()
213
+ return AMBIENT_NONE
214
+
215
+
216
+ def parse_side(
217
+ side_lines: list[str], ambient_subsection: str
218
+ ) -> list[tuple[str, list[str]]]:
219
+ """Parse one side of a conflict into ``[(subsection_name, entries)]``.
220
+
221
+ Each entry is the joined text (with embedded ``\\n``) of one bullet block,
222
+ including any indented continuation lines. Lines that are neither bullets
223
+ nor ``###`` headers are dropped between entry blocks (they are blank
224
+ separators that the renderer regenerates).
225
+
226
+ The ambient subsection collects entries that appear before the first
227
+ ``###`` header in the side. Subsequent ``###`` headers introduce new
228
+ subsections in the order they appear.
229
+ """
230
+ sections: list[tuple[str, list[str]]] = []
231
+ current_name = ambient_subsection
232
+ current_entries: list[str] = []
233
+ current_entry_lines: list[str] = []
234
+
235
+ def flush_entry() -> None:
236
+ nonlocal current_entry_lines
237
+ if current_entry_lines:
238
+ current_entries.append("\n".join(current_entry_lines))
239
+ current_entry_lines = []
240
+
241
+ def flush_section() -> None:
242
+ nonlocal current_entries
243
+ flush_entry()
244
+ # Drop empty ambient sections so the renderer does not emit empty
245
+ # subsection blocks for sides that contained zero entries above the
246
+ # first ``###`` header.
247
+ if current_entries or current_name != AMBIENT_NONE:
248
+ sections.append((current_name, current_entries))
249
+ current_entries = []
250
+
251
+ for raw_line in side_lines:
252
+ line = raw_line.rstrip("\n")
253
+ sub_m = SUBSECTION_HEADER_RE.match(line)
254
+ if sub_m:
255
+ flush_section()
256
+ current_name = sub_m.group(1).strip()
257
+ continue
258
+ if ENTRY_BULLET_RE.match(line):
259
+ flush_entry()
260
+ current_entry_lines = [line]
261
+ continue
262
+ if current_entry_lines:
263
+ stripped = line.strip()
264
+ if stripped == "":
265
+ # Blank line ends the current entry block.
266
+ flush_entry()
267
+ elif line.startswith((" ", "\t")):
268
+ # Indented continuation of the current entry.
269
+ current_entry_lines.append(line)
270
+ else:
271
+ # Non-bullet, non-indented, non-blank line: ends the entry,
272
+ # otherwise discarded as inter-entry prose.
273
+ flush_entry()
274
+ # Lines outside any entry are blank separators; the renderer
275
+ # regenerates them, so we drop them on parse.
276
+
277
+ flush_section()
278
+ return sections
279
+
280
+
281
+ def issue_numbers(entry_text: str) -> set[str]:
282
+ """Return the SET of ``#NNN`` issue numbers referenced in an entry."""
283
+ return set(ISSUE_NUM_RE.findall(entry_text))
284
+
285
+
286
+ def is_orphan_header(entry_text: str) -> bool:
287
+ """Return ``True`` when ``entry_text`` is a truncated orphan header (#1003).
288
+
289
+ A deft CHANGELOG entry canonically opens
290
+ ``- **<subject>** -- <body> (#NNN)``. A cascade rebase can splice a
291
+ *truncated* header that keeps the opening ``**`` but loses the closing
292
+ ``**`` AND the ``(#NNN)`` reference, e.g.::
293
+
294
+ - **feat(scripts): `gh_rest.py` REST-fallback helpers
295
+
296
+ Such a stub has no ``(#NNN)`` dedup key, so the union-merge helper used to
297
+ preserve a fresh copy on every rebase -- two duplicate stubs shipped in
298
+ v0.26.2. An orphan header is detected as an entry whose first line opens a
299
+ bold span (``- **`` / ``* **``) but does NOT close it on that line
300
+ (fewer than two ``**`` markers), AND carries no ``(#NNN)`` reference
301
+ anywhere in the entry.
302
+ """
303
+ first_line = entry_text.split("\n", 1)[0]
304
+ if not ENTRY_BOLD_OPEN_RE.match(first_line):
305
+ return False
306
+ # A well-formed header closes its bold span on the same line (>= 2 ``**``).
307
+ if first_line.count("**") >= 2:
308
+ return False
309
+ # A trailing issue reference is a valid dedup key -- not an orphan.
310
+ return not issue_numbers(entry_text)
311
+
312
+
313
+ def content_prefix(entry_text: str) -> str:
314
+ """Return a normalized leading-content key for prefix-based dedup (#1003).
315
+
316
+ Entries that carry no ``(#NNN)`` reference have no issue-number dedup key.
317
+ To stop issue-numberless duplicates from accumulating across cascade
318
+ rebases, the helper falls back to a normalized content prefix: the first
319
+ line with its bullet marker, bold markers, and any ``(#NNN)`` references
320
+ stripped, whitespace collapsed, lowercased, and truncated to
321
+ :data:`CONTENT_PREFIX_LEN` chars. Dropping the ``(#NNN)`` token lets a
322
+ cross-parity duplicate collapse -- a HEAD entry that carries the issue
323
+ reference and an otherwise-identical branch entry that does not still
324
+ share a prefix.
325
+ """
326
+ first_line = entry_text.split("\n", 1)[0]
327
+ stripped = re.sub(r"^\s*[-*]\s+", "", first_line, count=1)
328
+ stripped = stripped.replace("**", "")
329
+ stripped = ISSUE_NUM_RE.sub("", stripped)
330
+ stripped = " ".join(stripped.split())
331
+ return stripped[:CONTENT_PREFIX_LEN].lower()
332
+
333
+
334
+ def union_merge(
335
+ head_sections: list[tuple[str, list[str]]],
336
+ branch_sections: list[tuple[str, list[str]]],
337
+ *,
338
+ warnings: list[str] | None = None,
339
+ ) -> list[tuple[str, list[str]]]:
340
+ """Union-merge branch entries into HEAD's section structure.
341
+
342
+ Per the #911 contract:
343
+ - All HEAD entries are kept verbatim, in HEAD's order.
344
+ - For each branch entry, if its ``(#NNN)`` set does not overlap any
345
+ HEAD entry in the SAME subsection, the branch entry is PREPENDED
346
+ under that subsection.
347
+ - Subsections that exist only in the branch side are appended after
348
+ all HEAD subsections in the order they first appear in the branch.
349
+
350
+ Two #1003 safeguards stop truncated / issue-numberless stubs from
351
+ accumulating across cascade rebases:
352
+
353
+ - **Orphan-header drop.** A truncated orphan header (see
354
+ :func:`is_orphan_header`) has no dedup key, so it used to be prepended
355
+ fresh on every rebase. Such stubs are now DROPPED from BOTH sides and
356
+ never dedup against valid entries; each drop is recorded in
357
+ ``warnings`` (when supplied) so the caller can surface a stderr WARN.
358
+ - **Content-prefix fallback.** A branch entry with NO ``(#NNN)``
359
+ reference is deduplicated against HEAD by a normalized content prefix
360
+ (see :func:`content_prefix`); when no HEAD entry shares its prefix it is
361
+ still prepended, so a genuinely new issue-numberless entry survives.
362
+ """
363
+
364
+ def _warn(side: str, name: str, entry_text: str) -> None:
365
+ if warnings is None:
366
+ return
367
+ first_line = entry_text.split("\n", 1)[0]
368
+ subsection = name or "(ambient)"
369
+ warnings.append(
370
+ f"dropped truncated orphan header from {side} side under "
371
+ f"'{subsection}': {first_line!r}"
372
+ )
373
+
374
+ head_dict: dict[str, list[str]] = {}
375
+ head_order: list[str] = []
376
+ for name, entries in head_sections:
377
+ kept: list[str] = []
378
+ for e in entries:
379
+ if is_orphan_header(e):
380
+ _warn("HEAD", name, e)
381
+ continue
382
+ kept.append(e)
383
+ if name in head_dict:
384
+ head_dict[name].extend(kept)
385
+ else:
386
+ head_dict[name] = list(kept)
387
+ head_order.append(name)
388
+
389
+ for name, entries in branch_sections:
390
+ if name not in head_dict:
391
+ head_dict[name] = []
392
+ head_order.append(name)
393
+ existing_nums: set[str] = set()
394
+ existing_prefixes: set[str] = set()
395
+ for e in head_dict[name]:
396
+ existing_nums |= issue_numbers(e)
397
+ existing_prefixes.add(content_prefix(e))
398
+ new_entries: list[str] = []
399
+ for e in entries:
400
+ if is_orphan_header(e):
401
+ _warn("branch", name, e)
402
+ continue
403
+ nums = issue_numbers(e)
404
+ if nums and nums & existing_nums:
405
+ continue
406
+ if not nums and content_prefix(e) in existing_prefixes:
407
+ # Content-prefix fallback: an issue-numberless entry whose
408
+ # normalized prefix already exists in HEAD is a duplicate.
409
+ continue
410
+ new_entries.append(e)
411
+ existing_nums |= nums
412
+ existing_prefixes.add(content_prefix(e))
413
+ # Prepend in branch-side order so the leftmost branch entry ends up
414
+ # at the top of the resolved section.
415
+ head_dict[name] = new_entries + head_dict[name]
416
+
417
+ return [(name, head_dict[name]) for name in head_order]
418
+
419
+
420
+ def render_resolved(
421
+ merged: list[tuple[str, list[str]]], ambient_subsection: str
422
+ ) -> list[str]:
423
+ """Render merged sections as a list of lines (no trailing newline).
424
+
425
+ The ambient subsection is rendered without a header (its ``###`` line
426
+ is already in the file ABOVE the conflict block). Other subsections are
427
+ rendered with their ``###`` header followed by a blank line, matching
428
+ the existing CHANGELOG.md house style.
429
+ """
430
+ out: list[str] = []
431
+ for name, entries in merged:
432
+ if name != ambient_subsection:
433
+ if out and out[-1] != "":
434
+ out.append("")
435
+ out.append(f"### {name}")
436
+ out.append("")
437
+ for entry in entries:
438
+ for entry_line in entry.split("\n"):
439
+ out.append(entry_line)
440
+ # Trailing blank between non-ambient subsections is added on the
441
+ # next iteration's leading-blank insertion above. The final
442
+ # subsection gets no trailing blank here; the surrounding file
443
+ # context provides the spacing.
444
+ return out
445
+
446
+
447
+ def resolve_changelog(content: str) -> tuple[str | None, str]:
448
+ """Pure function: take CHANGELOG content, return (new_content, message).
449
+
450
+ Returns ``(new_content, "resolved" message)`` on a successful merge,
451
+ ``(content, "no-op" message)`` when no conflicts were found, or
452
+ ``(None, error message)`` when the content is unresolvable. Separated
453
+ from :func:`main` so tests can drive every branch without temp files.
454
+ """
455
+ # Preserve trailing-newline behaviour: split keeps everything line-by-line
456
+ # and we re-join with ``\n`` then add a trailing newline iff the original
457
+ # had one. This mirrors the round-trip behaviour that ``edit_files``
458
+ # produces and matches tools/git's expectation.
459
+ had_trailing_newline = content.endswith("\n")
460
+ lines = content.split("\n")
461
+ # Drop the synthetic empty final element introduced by split() when the
462
+ # input ends with a newline. We re-introduce it on render.
463
+ if had_trailing_newline and lines and lines[-1] == "":
464
+ lines = lines[:-1]
465
+
466
+ unreleased_start, unreleased_end = find_unreleased_bounds(lines)
467
+ if unreleased_start is None:
468
+ # No [Unreleased] section -- check if any conflict markers exist
469
+ # anywhere; if so, fail unresolvable. Otherwise no-op.
470
+ if any(
471
+ line.startswith((CONFLICT_HEAD_PREFIX, CONFLICT_TAIL_PREFIX))
472
+ or line == CONFLICT_SEP
473
+ for line in lines
474
+ ):
475
+ return None, (
476
+ "unresolvable: conflict markers present but no [Unreleased] "
477
+ "section found"
478
+ )
479
+ return content, "no-op: no [Unreleased] section, no conflict markers"
480
+
481
+ blocks = find_conflict_blocks(lines, unreleased_start, unreleased_end)
482
+ if blocks is None:
483
+ return None, (
484
+ "unresolvable: malformed conflict markers (nested / missing "
485
+ "separator / orphan tail) inside [Unreleased]"
486
+ )
487
+
488
+ # Detect conflicts OUTSIDE [Unreleased]: scan the rest of the file.
489
+ has_outside_marker = any(
490
+ (
491
+ line.startswith((CONFLICT_HEAD_PREFIX, CONFLICT_TAIL_PREFIX))
492
+ or line == CONFLICT_SEP
493
+ )
494
+ for i, line in enumerate(lines)
495
+ if i < unreleased_start or i >= unreleased_end
496
+ )
497
+
498
+ if not blocks:
499
+ if has_outside_marker:
500
+ return None, (
501
+ "unresolvable: conflict markers present outside [Unreleased] "
502
+ "section -- resolve manually with edit_files"
503
+ )
504
+ return content, "no-op: no conflict markers in [Unreleased]"
505
+
506
+ # Resolve each conflict block, walking back-to-front so earlier indices
507
+ # remain valid as we splice replacement lines in.
508
+ new_lines = list(lines)
509
+ warnings: list[str] = []
510
+ for head_idx, sep_idx, tail_idx in reversed(blocks):
511
+ # Sides are sliced exclusive of the markers themselves.
512
+ head_side = new_lines[head_idx + 1 : sep_idx]
513
+ branch_side = new_lines[sep_idx + 1 : tail_idx]
514
+ ambient = find_ambient_subsection(new_lines, head_idx, unreleased_start)
515
+ head_parsed = parse_side(head_side, ambient)
516
+ branch_parsed = parse_side(branch_side, ambient)
517
+ merged = union_merge(head_parsed, branch_parsed, warnings=warnings)
518
+ rendered = render_resolved(merged, ambient)
519
+ new_lines[head_idx : tail_idx + 1] = rendered
520
+
521
+ # Verify no markers remain anywhere in the file.
522
+ for line in new_lines:
523
+ if (
524
+ line.startswith((CONFLICT_HEAD_PREFIX, CONFLICT_TAIL_PREFIX))
525
+ or line == CONFLICT_SEP
526
+ ):
527
+ if has_outside_marker:
528
+ return None, (
529
+ "unresolvable: conflict markers remain outside "
530
+ "[Unreleased] -- resolve manually with edit_files"
531
+ )
532
+ return None, (
533
+ "unresolvable: conflict markers remain after resolve "
534
+ "(internal error -- please file an issue)"
535
+ )
536
+
537
+ # Surface any dropped orphan stubs on stderr so the operator can recover
538
+ # the canonical entry manually if the drop was unexpected (#1003 AC-1).
539
+ for warning in warnings:
540
+ print(f"WARN resolve_changelog: {warning}", file=sys.stderr)
541
+
542
+ new_content = "\n".join(new_lines)
543
+ if had_trailing_newline:
544
+ new_content += "\n"
545
+ return new_content, f"resolved: union-merged {len(blocks)} conflict block(s)"
546
+
547
+
548
+ def atomic_write(path: Path, content: str) -> None:
549
+ """Write ``content`` to ``path`` atomically via tempfile + ``os.replace``.
550
+
551
+ The temp file is created in the SAME directory as the target so the
552
+ rename is on the same filesystem (``os.replace`` is atomic only within a
553
+ single filesystem on POSIX; on Windows it requires same-volume too).
554
+ UTF-8 encoding mandated per #798 root-cause rule -- no PowerShell-side
555
+ string round-trip ever touches the bytes.
556
+ """
557
+ parent = path.parent
558
+ parent.mkdir(parents=True, exist_ok=True)
559
+ fd, tmp_name = tempfile.mkstemp(
560
+ prefix=f".{path.name}.", suffix=".tmp", dir=str(parent)
561
+ )
562
+ tmp_path = Path(tmp_name)
563
+ try:
564
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
565
+ fh.write(content)
566
+ os.replace(str(tmp_path), str(path))
567
+ except Exception:
568
+ # Best-effort cleanup of the temp file on any failure path.
569
+ with contextlib.suppress(OSError):
570
+ tmp_path.unlink()
571
+ raise
572
+
573
+
574
+ def evaluate(
575
+ changelog_path: Path, *, dry_run: bool = False
576
+ ) -> tuple[int, str]:
577
+ """Pure function returning ``(exit_code, human_message)``.
578
+
579
+ Separated from :func:`main` so tests can drive every state without
580
+ ``capsys`` plumbing or ``argparse`` round-tripping.
581
+ """
582
+ if not changelog_path.exists():
583
+ return 2, (
584
+ f"config error: CHANGELOG path does not exist: {changelog_path}\n"
585
+ " Recovery: pass --changelog-path pointing at an existing file."
586
+ )
587
+ if not changelog_path.is_file():
588
+ return 2, (
589
+ f"config error: CHANGELOG path is not a regular file: {changelog_path}"
590
+ )
591
+ try:
592
+ content = changelog_path.read_text(encoding="utf-8")
593
+ except OSError as exc:
594
+ return 2, f"config error: cannot read {changelog_path}: {exc}"
595
+
596
+ new_content, message = resolve_changelog(content)
597
+ if new_content is None:
598
+ # Greptile P2 (PR #999): inner messages from resolve_changelog already
599
+ # carry the canonical ``unresolvable: ...`` prefix; do not re-prefix
600
+ # here or operators see ``unresolvable: unresolvable: ...`` on stderr.
601
+ return 1, f"{message}\n Path: {changelog_path}"
602
+
603
+ if new_content == content:
604
+ return 0, f"OK {changelog_path}: {message}"
605
+
606
+ if dry_run:
607
+ return 0, f"OK (dry-run) {changelog_path}: {message}"
608
+
609
+ try:
610
+ atomic_write(changelog_path, new_content)
611
+ except OSError as exc:
612
+ return 2, f"config error: cannot write {changelog_path}: {exc}"
613
+
614
+ return 0, f"OK {changelog_path}: {message}"
615
+
616
+
617
+ def _build_parser() -> argparse.ArgumentParser:
618
+ parser = argparse.ArgumentParser(
619
+ prog="resolve_changelog_unreleased.py",
620
+ description=(
621
+ "Union-merge CHANGELOG.md [Unreleased] conflicts (#911). "
622
+ "Replaces the HEAD-take-and-discard pattern that silently "
623
+ "dropped the rebasing branch's CHANGELOG entry on swarm "
624
+ "cascade rebase. Three-state exit: 0 resolved (or no-op), "
625
+ "1 unresolvable, 2 config error."
626
+ ),
627
+ )
628
+ parser.add_argument(
629
+ "--changelog-path",
630
+ default="CHANGELOG.md",
631
+ help=(
632
+ "Path to CHANGELOG.md (default: ./CHANGELOG.md relative to "
633
+ "the working directory)."
634
+ ),
635
+ )
636
+ parser.add_argument(
637
+ "--dry-run",
638
+ action="store_true",
639
+ help=(
640
+ "Compute the resolution and report what would change without "
641
+ "writing the file. Useful for review-cycle preview."
642
+ ),
643
+ )
644
+ parser.add_argument(
645
+ "--quiet",
646
+ action="store_true",
647
+ help="Suppress the OK message (errors still print).",
648
+ )
649
+ return parser
650
+
651
+
652
+ def main(argv: list[str] | None = None) -> int:
653
+ _self_reconfigure_utf8()
654
+ parser = _build_parser()
655
+ args = parser.parse_args(argv)
656
+ changelog_path = Path(args.changelog_path).resolve()
657
+ code, msg = evaluate(changelog_path, dry_run=args.dry_run)
658
+ if code == 0:
659
+ if not args.quiet:
660
+ print(msg)
661
+ else:
662
+ print(msg, file=sys.stderr)
663
+ return code
664
+
665
+
666
+ if __name__ == "__main__":
667
+ sys.exit(main())