@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,669 @@
1
+ #!/usr/bin/env python3
2
+ """pr_wait_mergeable.py -- Resilient cascade automation helper (#1369).
3
+
4
+ Wraps the Wave-2 resilient wait-until-ready helper (`scripts/monitor_pr.py`,
5
+ #1368) and the Layer-3 protected-issue pre-merge link inspector
6
+ (`scripts/pr_check_protected_issues.py`, #701) into a single end-to-end
7
+ cascade automation surface so a swarm monitor can request
8
+ "wait until this PR is mergeable, then merge it" in one invocation without
9
+ hand-rolling the loop.
10
+
11
+ Background
12
+ ----------
13
+ The 2026-05-26 #1166 swarm cascade for #1363 + Wave 3 saw the monitor
14
+ babysitting individual PRs because there was no first-class
15
+ "wait-until-ready, then merge" primitive that survived the documented
16
+ Grok Build harness fragility (#1353 / #1366). The Wave-1+2 work made the
17
+ underlying primitives reliable:
18
+
19
+ * ``scripts/_safe_subprocess.py::run_text`` (#1366) -- UTF-8-safe subprocess
20
+ capture; closes the ``Thread-3 (_readerthread) UnicodeDecodeError``
21
+ blind spot on Windows + Grok Build.
22
+ * ``scripts/pr_merge_readiness.py`` (#1368) -- layered fallback chain
23
+ (primary -> fallback1 -> fallback2) with a ``via`` discriminator on
24
+ every JSON response; fallback2 is structurally never CLEAN.
25
+ * ``scripts/monitor_pr.py`` (#1368) -- adaptive 1m/3m/5m cadence loop
26
+ around ``pr_merge_readiness`` that tolerates layered fallbacks and
27
+ exits 0 only on a primary/fallback1 CLEAN verdict.
28
+
29
+ This helper composes those primitives. The flow is strictly:
30
+
31
+ 1. **Layer-3 protected-issue link inspection** -- if any ``--protected
32
+ <issue-numbers>`` were supplied, run ``scripts/pr_check_protected_issues.py``
33
+ BEFORE the wait loop. A persistent ``closingIssuesReferences`` link
34
+ to a protected issue is a structural pre-condition failure: it cannot
35
+ be resolved by waiting, so the helper exits 1 (escalation) without
36
+ ever invoking the wait loop or the merge call. This is the
37
+ "exit 1 BEFORE merge call" path the tests pin.
38
+
39
+ 2. **Wait until CLEAN** -- delegate to ``scripts/monitor_pr.py``. The
40
+ monitor's exit code maps onto this helper's three-state exit:
41
+
42
+ * monitor exit 0 (PR reached a primary/fallback1 CLEAN) -> proceed to merge
43
+ * monitor exit 1 (poll cap reached without CLEAN) -> helper exit 1
44
+ * monitor exit 2 (gh missing / invalid args) -> helper exit 2
45
+ * monitor exit 3 (PR merged or closed out from under) -> helper exit 0
46
+ when ``merged=True`` (the cascade goal was reached, just by a
47
+ sibling cascade); helper exit 1 when ``state="closed"`` and not
48
+ merged (operator rejected the PR mid-loop, escalate).
49
+
50
+ 3. **Squash-merge** -- run
51
+ ``gh pr merge <N> --squash --delete-branch --admin`` (per
52
+ ``skills/deft-directive-swarm/SKILL.md`` Phase 6 Step 1). The per-PR
53
+ atomic gate ``task pr:merge-ready && gh pr merge`` documented in
54
+ the swarm skill still applies at the merge-time freshness window:
55
+ the wait loop's last CLEAN verdict is at most one poll interval old,
56
+ and the merge call itself is the freshness boundary.
57
+
58
+ Three-state exit (mirrors the rest of the framework's verb scripts):
59
+
60
+ 0 -- PR is now merged (either by this helper or by a sibling cascade)
61
+ 1 -- timeout or escalation: the PR was not merged. Reasons surfaced
62
+ to stderr include cap-reached (no CLEAN within the cap window),
63
+ protected-issue-link-present (Layer-3 false-positive on
64
+ ``closingIssuesReferences``), PR closed without merge, or a
65
+ non-zero exit from ``gh pr merge`` itself.
66
+ 2 -- configuration error: ``gh`` missing on the monitor host,
67
+ invalid CLI args, malformed --protected tokens, or any failure
68
+ from a chained script that mapped to config-error semantics.
69
+
70
+ Subprocess capture routes through :func:`scripts._safe_subprocess.run_text`
71
+ per the ``AGENTS.md`` ``## Safe subprocess capture (#1366)`` rule. All
72
+ external subprocess invocations (`monitor_pr.py`, `pr_check_protected_issues.py`,
73
+ `gh pr merge`) are exposed as module-level functions so tests can
74
+ monkey-patch them without hitting the network.
75
+
76
+ Usage
77
+ -----
78
+
79
+ # Minimal -- wait for CLEAN, then merge.
80
+ uv run python scripts/pr_wait_mergeable.py 1370 --repo deftai/directive
81
+
82
+ # Layer-3 protected-issue gate ahead of the wait loop.
83
+ uv run python scripts/pr_wait_mergeable.py 1370 \\
84
+ --repo deftai/directive \\
85
+ --protected 1119,1140
86
+
87
+ # Tune the wait cap and emit a JSON envelope for a parent monitor.
88
+ uv run python scripts/pr_wait_mergeable.py 1370 \\
89
+ --repo deftai/directive \\
90
+ --cap-minutes 45 \\
91
+ --json
92
+
93
+ Exit codes
94
+ ----------
95
+ 0 -- PR is merged (or already merged on entry)
96
+ 1 -- timeout / escalation (PR not merged; reason surfaced to stderr)
97
+ 2 -- configuration error (gh missing, invalid args, malformed --protected)
98
+ """
99
+
100
+ from __future__ import annotations
101
+
102
+ import argparse
103
+ import json
104
+ import os
105
+ import subprocess
106
+ import sys
107
+ from dataclasses import dataclass, field
108
+ from pathlib import Path
109
+ from typing import Any
110
+
111
+ # Make sibling scripts importable both when run as __main__ and when imported
112
+ # by tests.
113
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
114
+
115
+ # UTF-8-safe subprocess capture (#1366) -- per AGENTS.md
116
+ # ``## Safe subprocess capture (#1366)``, any new script that captures
117
+ # gh / python subprocess output MUST route the call through this helper.
118
+ from _safe_subprocess import run_text # noqa: E402
119
+
120
+ # ---- Exit codes -------------------------------------------------------------
121
+
122
+ EXIT_MERGED = 0
123
+ EXIT_TIMEOUT_OR_ESCALATION = 1
124
+ EXIT_CONFIG_ERROR = 2
125
+
126
+ # ---- Companion script paths -------------------------------------------------
127
+
128
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
129
+ _MONITOR_SCRIPT = _SCRIPTS_DIR / "monitor_pr.py"
130
+ _PROTECTED_SCRIPT = _SCRIPTS_DIR / "pr_check_protected_issues.py"
131
+
132
+
133
+ # ---- Result envelope --------------------------------------------------------
134
+
135
+
136
+ @dataclass
137
+ class WaitMergeableResult:
138
+ """Structured outcome of one ``pr_wait_mergeable`` invocation.
139
+
140
+ The envelope mirrors the shape ``scripts/monitor_pr.py`` emits so a
141
+ parent monitor parsing both stdouts sees a familiar field layout.
142
+ """
143
+
144
+ pr_number: int
145
+ repo: str | None
146
+ outcome: str # "merged" | "cap-reached" | "pr-closed" |
147
+ # "protected-linked" | "merge-failed" | "config-error"
148
+ exit_code: int
149
+ monitor_result: dict = field(default_factory=dict)
150
+ protected_check: dict = field(default_factory=dict)
151
+ merge_stdout: str = ""
152
+ merge_stderr: str = ""
153
+ error: str | None = None
154
+
155
+ def to_dict(self) -> dict:
156
+ payload: dict[str, Any] = {
157
+ "pr_number": self.pr_number,
158
+ "repo": self.repo,
159
+ "outcome": self.outcome,
160
+ "exit_code": self.exit_code,
161
+ }
162
+ if self.monitor_result:
163
+ payload["monitor_result"] = self.monitor_result
164
+ if self.protected_check:
165
+ payload["protected_check"] = self.protected_check
166
+ if self.merge_stdout:
167
+ payload["merge_stdout"] = self.merge_stdout
168
+ if self.merge_stderr:
169
+ payload["merge_stderr"] = self.merge_stderr
170
+ if self.error is not None:
171
+ payload["error"] = self.error
172
+ return payload
173
+
174
+
175
+ # ---- Chained subprocess wrappers --------------------------------------------
176
+ #
177
+ # Each wrapper is a module-level function so tests can monkey-patch the
178
+ # external call without going near a real ``gh`` invocation. The wrappers
179
+ # return uniform ``(returncode, stdout, stderr)`` tuples and route every
180
+ # text capture through ``_safe_subprocess.run_text`` per the #1366
181
+ # AGENTS.md rule.
182
+
183
+
184
+ def run_protected_check(
185
+ pr_number: int,
186
+ repo: str | None,
187
+ protected: list[int],
188
+ *,
189
+ python_executable: str | None = None,
190
+ timeout: float = 60,
191
+ ) -> tuple[int, str, str]:
192
+ """Invoke ``scripts/pr_check_protected_issues.py`` and return its result.
193
+
194
+ Returns ``(returncode, stdout, stderr)``. Exit 0 means no protected
195
+ link; exit 1 means a protected link is present; exit 2 means an
196
+ external/config error from the inspection. The caller maps these
197
+ onto the helper's three-state exit.
198
+
199
+ ``protected`` is the explicit issue-number list. The helper joins it
200
+ with commas onto a single ``--protected`` flag (the underlying
201
+ script supports comma-separated as well as repeated-flag forms; we
202
+ use the comma form for shell-quoting simplicity).
203
+ """
204
+ python = python_executable or sys.executable
205
+ cmd: list[str] = [
206
+ python,
207
+ str(_PROTECTED_SCRIPT),
208
+ str(pr_number),
209
+ "--protected",
210
+ ",".join(str(n) for n in protected),
211
+ ]
212
+ if repo:
213
+ cmd.extend(["--repo", repo])
214
+ try:
215
+ result = run_text(cmd, timeout=timeout)
216
+ except FileNotFoundError as exc:
217
+ return -1, "", f"python executable not found: {exc}"
218
+ except subprocess.TimeoutExpired:
219
+ return -1, "", f"protected-issue check timed out after {timeout}s"
220
+ return result.returncode, result.stdout, result.stderr
221
+
222
+
223
+ def run_monitor(
224
+ pr_number: int,
225
+ repo: str,
226
+ cap_minutes: float,
227
+ *,
228
+ python_executable: str | None = None,
229
+ timeout: float | None = None,
230
+ ) -> tuple[int, str, str]:
231
+ """Invoke ``scripts/monitor_pr.py --json`` and return its result.
232
+
233
+ Returns ``(returncode, stdout, stderr)``. The monitor's three-state
234
+ exit (plus an additional PR-terminal exit 3) is preserved verbatim;
235
+ the caller maps onto the helper's three-state exit.
236
+
237
+ ``timeout`` defaults to ``cap_minutes * 60 + 60`` seconds (one
238
+ minute of slack past the monitor's cap so a TimeoutExpired only
239
+ fires when the monitor itself is hung, not when it is mid-cap).
240
+ """
241
+ python = python_executable or sys.executable
242
+ cmd: list[str] = [
243
+ python,
244
+ str(_MONITOR_SCRIPT),
245
+ str(pr_number),
246
+ "--repo",
247
+ repo,
248
+ "--cap-minutes",
249
+ str(cap_minutes),
250
+ "--json",
251
+ ]
252
+ if timeout is None:
253
+ timeout = cap_minutes * 60 + 60
254
+ try:
255
+ result = run_text(cmd, timeout=timeout)
256
+ except FileNotFoundError as exc:
257
+ return -1, "", f"python executable not found: {exc}"
258
+ except subprocess.TimeoutExpired:
259
+ return -1, "", f"monitor_pr timed out after {timeout}s"
260
+ return result.returncode, result.stdout, result.stderr
261
+
262
+
263
+ def run_gh_merge(
264
+ pr_number: int,
265
+ repo: str | None,
266
+ *,
267
+ timeout: float = 120,
268
+ ) -> tuple[int, str, str]:
269
+ """Invoke ``gh pr merge --squash --delete-branch --admin`` and return result.
270
+
271
+ The merge call is the freshness boundary of the cascade: the wait
272
+ loop's last CLEAN verdict is at most one monitor poll interval old,
273
+ and ``gh pr merge`` fails non-zero if a sibling rebase has landed in
274
+ the elapsed window (which is the per-merge atomic gate the swarm
275
+ skill mandates).
276
+ """
277
+ cmd: list[str] = [
278
+ "gh",
279
+ "pr",
280
+ "merge",
281
+ str(pr_number),
282
+ "--squash",
283
+ "--delete-branch",
284
+ "--admin",
285
+ ]
286
+ if repo:
287
+ cmd.extend(["--repo", repo])
288
+ try:
289
+ result = run_text(cmd, timeout=timeout)
290
+ except FileNotFoundError:
291
+ return -1, "", "gh CLI not found. Install GitHub CLI."
292
+ except subprocess.TimeoutExpired:
293
+ return -1, "", f"gh pr merge timed out after {timeout}s"
294
+ return result.returncode, result.stdout, result.stderr
295
+
296
+
297
+ # ---- Argument parsing -------------------------------------------------------
298
+
299
+
300
+ def _parse_protected(values: list[str]) -> list[int]:
301
+ """Flatten comma-separated and repeated ``--protected`` flags.
302
+
303
+ Mirrors :func:`scripts.pr_check_protected_issues._parse_protected`
304
+ semantics so the helper rejects the same malformed tokens (Unicode
305
+ superscripts, non-decimal junk) and gives the same user-facing
306
+ error rather than letting the underlying script surface its own.
307
+
308
+ Raises :class:`ValueError` on any non-decimal token so the caller
309
+ can map to ``EXIT_CONFIG_ERROR``.
310
+ """
311
+ out: set[int] = set()
312
+ for chunk in values:
313
+ for tok in chunk.split(","):
314
+ tok = tok.strip().lstrip("#")
315
+ if not tok:
316
+ continue
317
+ # ``isdecimal()`` (vs ``isdigit()``) ONLY matches base-10 0-9 so
318
+ # superscript '\u00b2' is rejected with the actionable error
319
+ # rather than crashing inside int().
320
+ if not tok.isdecimal():
321
+ raise ValueError(f"Invalid protected issue token: {tok!r}")
322
+ out.add(int(tok))
323
+ return sorted(out)
324
+
325
+
326
+ def _build_parser() -> argparse.ArgumentParser:
327
+ parser = argparse.ArgumentParser(
328
+ prog="pr_wait_mergeable",
329
+ description=(
330
+ "Resilient cascade automation helper (#1369). Polls "
331
+ "mergeability via scripts/monitor_pr.py (#1368), runs the "
332
+ "Layer-3 protected-issue link inspection (#701) ahead of the "
333
+ "wait loop, and merges with `gh pr merge --squash "
334
+ "--delete-branch --admin` only after the readiness call exits "
335
+ "CLEAN on the current HEAD. Three-state exit: 0 merged, 1 "
336
+ "timeout/escalation, 2 config error."
337
+ ),
338
+ )
339
+ parser.add_argument(
340
+ "pr_number",
341
+ type=int,
342
+ help="Pull request number to wait on and merge.",
343
+ )
344
+ parser.add_argument(
345
+ "--repo",
346
+ default=None,
347
+ metavar="OWNER/REPO",
348
+ help=(
349
+ "Repository in OWNER/REPO form. Defaults to $GH_REPO or the "
350
+ "current checkout's remote."
351
+ ),
352
+ )
353
+ parser.add_argument(
354
+ "--cap-minutes",
355
+ type=float,
356
+ default=60.0,
357
+ help=(
358
+ "Total wall-clock cap for the wait loop in minutes (default: "
359
+ "60). Forwarded to scripts/monitor_pr.py."
360
+ ),
361
+ )
362
+ parser.add_argument(
363
+ "--protected",
364
+ action="append",
365
+ default=[],
366
+ metavar="ISSUE_NUMBERS",
367
+ help=(
368
+ "Comma-separated list of protected (umbrella / staying-OPEN) "
369
+ "issue numbers; may be passed multiple times. Inspected via "
370
+ "scripts/pr_check_protected_issues.py (#701) BEFORE the wait "
371
+ "loop -- a persistent link causes immediate exit 1 with no "
372
+ "merge call."
373
+ ),
374
+ )
375
+ parser.add_argument(
376
+ "--json",
377
+ dest="emit_json",
378
+ action="store_true",
379
+ help=(
380
+ "Emit a structured JSON envelope on stdout summarising the "
381
+ "monitor result, protected-issue check, merge output, and "
382
+ "final outcome."
383
+ ),
384
+ )
385
+ return parser
386
+
387
+
388
+ # ---- Outcome / exit mapping -------------------------------------------------
389
+
390
+
391
+ def _classify_monitor_outcome(
392
+ monitor_returncode: int,
393
+ monitor_payload: dict,
394
+ ) -> tuple[str, int]:
395
+ """Map monitor_pr's exit code onto a helper outcome + exit code.
396
+
397
+ The mapping is intentionally narrow so a future addition to
398
+ monitor_pr's exit table surfaces here as a config error rather than
399
+ silently turning into a merged-claim.
400
+ """
401
+ if monitor_returncode == 0:
402
+ # CLEAN -- the caller proceeds to the merge call.
403
+ return ("clean", EXIT_MERGED)
404
+ if monitor_returncode == 1:
405
+ return ("cap-reached", EXIT_TIMEOUT_OR_ESCALATION)
406
+ if monitor_returncode == 2:
407
+ return ("config-error", EXIT_CONFIG_ERROR)
408
+ if monitor_returncode == 3:
409
+ # PR-TERMINAL: merged-out-from-under-us or closed-without-merge.
410
+ # Map merged=True -> EXIT_MERGED (cascade goal reached); else
411
+ # treat as escalation (operator rejected the PR mid-loop).
412
+ readiness = (
413
+ monitor_payload.get("readiness", {})
414
+ if isinstance(monitor_payload, dict)
415
+ else {}
416
+ )
417
+ partial = (
418
+ readiness.get("partial_data", {})
419
+ if isinstance(readiness, dict)
420
+ else {}
421
+ )
422
+ if partial.get("merged") is True:
423
+ return ("merged-by-sibling", EXIT_MERGED)
424
+ return ("pr-closed", EXIT_TIMEOUT_OR_ESCALATION)
425
+ # Unknown monitor exit -- treat as config error so it surfaces loudly.
426
+ return ("config-error", EXIT_CONFIG_ERROR)
427
+
428
+
429
+ def _parse_monitor_payload(stdout: str) -> dict:
430
+ """Parse the monitor's --json envelope. Returns ``{}`` on failure."""
431
+ if not stdout or not stdout.strip():
432
+ return {}
433
+ try:
434
+ payload = json.loads(stdout)
435
+ except json.JSONDecodeError:
436
+ return {}
437
+ if isinstance(payload, dict):
438
+ return payload
439
+ return {}
440
+
441
+
442
+ # ---- Main orchestration -----------------------------------------------------
443
+
444
+
445
+ def wait_mergeable_and_merge(
446
+ pr_number: int,
447
+ repo: str,
448
+ *,
449
+ cap_minutes: float,
450
+ protected: list[int],
451
+ protected_fn=None,
452
+ monitor_fn=None,
453
+ merge_fn=None,
454
+ ) -> WaitMergeableResult:
455
+ """Run the protected-check -> wait -> merge cascade.
456
+
457
+ Subprocess wrappers are injected as keyword arguments so tests can
458
+ drive the cascade without spawning real processes. ``None`` (the
459
+ default) resolves the wrapper via :func:`globals` lookup at call
460
+ time so a ``monkeypatch.setattr(pwm, "run_monitor", fake)`` on the
461
+ module attribute reaches the cascade -- binding the function in the
462
+ default value would freeze the reference at function-definition
463
+ time and silently bypass the patch. The function body is the single
464
+ source of truth for the helper's state machine and is exhaustively
465
+ exercised by ``tests/cli/test_pr_wait_mergeable.py``.
466
+ """
467
+ # Late-bind via the module dict so monkeypatch.setattr on the module
468
+ # attribute takes effect; explicit-kwarg overrides still win.
469
+ protected_fn = protected_fn or globals()["run_protected_check"]
470
+ monitor_fn = monitor_fn or globals()["run_monitor"]
471
+ merge_fn = merge_fn or globals()["run_gh_merge"]
472
+ # --- Step 1: Layer-3 protected-issue link inspection (#701) ----------
473
+ protected_check_payload: dict = {}
474
+ if protected:
475
+ prc_rc, prc_stdout, prc_stderr = protected_fn(pr_number, repo, protected)
476
+ protected_check_payload = {
477
+ "returncode": prc_rc,
478
+ "stdout": prc_stdout,
479
+ "stderr": prc_stderr,
480
+ "protected": list(protected),
481
+ }
482
+ if prc_rc == 1:
483
+ # Persistent link present -- escalation, do NOT run monitor or merge.
484
+ return WaitMergeableResult(
485
+ pr_number=pr_number,
486
+ repo=repo,
487
+ outcome="protected-linked",
488
+ exit_code=EXIT_TIMEOUT_OR_ESCALATION,
489
+ protected_check=protected_check_payload,
490
+ error=(
491
+ "PR has a persistent closingIssuesReferences link to a "
492
+ "protected issue (#701). Unlink via the PR's Development "
493
+ "sidebar before re-running."
494
+ ),
495
+ )
496
+ if prc_rc not in (0,):
497
+ # Any non-zero non-1 exit collapses to a config error -- the
498
+ # inspection cannot run, so the gate cannot affirm safety.
499
+ return WaitMergeableResult(
500
+ pr_number=pr_number,
501
+ repo=repo,
502
+ outcome="config-error",
503
+ exit_code=EXIT_CONFIG_ERROR,
504
+ protected_check=protected_check_payload,
505
+ error=(
506
+ f"protected-issue check exited {prc_rc} (config error). "
507
+ f"stderr: {prc_stderr.strip()}"
508
+ ),
509
+ )
510
+
511
+ # --- Step 2: Wait until CLEAN (#1368) --------------------------------
512
+ mon_rc, mon_stdout, mon_stderr = monitor_fn(pr_number, repo, cap_minutes)
513
+ monitor_payload = _parse_monitor_payload(mon_stdout)
514
+ outcome, monitor_exit = _classify_monitor_outcome(mon_rc, monitor_payload)
515
+
516
+ if outcome != "clean":
517
+ # cap-reached, pr-closed, config-error, merged-by-sibling.
518
+ # The merged-by-sibling outcome is a SUCCESS path (exit_code 0)
519
+ # even though it lives in the non-clean branch, so it MUST NOT
520
+ # carry an ``error`` string -- a downstream consumer parsing the
521
+ # JSON envelope sees ``exit_code: 0`` and would treat a non-None
522
+ # ``error`` field as a self-contradiction (Greptile P2 finding
523
+ # on PR #1377).
524
+ if monitor_exit == EXIT_MERGED:
525
+ error_payload: str | None = None
526
+ else:
527
+ error_payload = (
528
+ f"monitor exited {mon_rc} (outcome={outcome}). "
529
+ f"stderr tail: {mon_stderr.strip()[-200:]}"
530
+ if mon_stderr.strip()
531
+ else f"monitor exited {mon_rc} (outcome={outcome})"
532
+ )
533
+ return WaitMergeableResult(
534
+ pr_number=pr_number,
535
+ repo=repo,
536
+ outcome=outcome,
537
+ exit_code=monitor_exit,
538
+ monitor_result=monitor_payload,
539
+ protected_check=protected_check_payload,
540
+ error=error_payload,
541
+ )
542
+
543
+ # --- Step 3: Squash-merge --------------------------------------------
544
+ merge_rc, merge_stdout, merge_stderr = merge_fn(pr_number, repo)
545
+ if merge_rc == 0:
546
+ return WaitMergeableResult(
547
+ pr_number=pr_number,
548
+ repo=repo,
549
+ outcome="merged",
550
+ exit_code=EXIT_MERGED,
551
+ monitor_result=monitor_payload,
552
+ protected_check=protected_check_payload,
553
+ merge_stdout=merge_stdout,
554
+ merge_stderr=merge_stderr,
555
+ )
556
+
557
+ # gh pr merge failed. The ``run_gh_merge`` wrapper signals "gh
558
+ # binary missing" (FileNotFoundError) and "gh runtime/IO timeout"
559
+ # by returning ``returncode == -1`` -- these are CONFIGURATION
560
+ # errors (the cascade gate cannot run), NOT merge-time escalations,
561
+ # and MUST surface as EXIT_CONFIG_ERROR per the documented
562
+ # three-state contract so automated callers keying on exit 2 to
563
+ # skip retries do not loop indefinitely (Greptile P1 finding on
564
+ # PR #1377). Mirrors ``run_protected_check``'s rc=-1 path that
565
+ # already collapses to EXIT_CONFIG_ERROR a few lines above.
566
+ if merge_rc == -1:
567
+ return WaitMergeableResult(
568
+ pr_number=pr_number,
569
+ repo=repo,
570
+ outcome="config-error",
571
+ exit_code=EXIT_CONFIG_ERROR,
572
+ monitor_result=monitor_payload,
573
+ protected_check=protected_check_payload,
574
+ merge_stdout=merge_stdout,
575
+ merge_stderr=merge_stderr,
576
+ error=(
577
+ f"gh pr merge wrapper failed at OS layer (rc=-1). "
578
+ f"stderr: {merge_stderr.strip()[-200:]}"
579
+ if merge_stderr.strip()
580
+ else "gh pr merge wrapper failed at OS layer (rc=-1)."
581
+ ),
582
+ )
583
+
584
+ # Non-zero non-sentinel exit -- sibling rebase landed in the
585
+ # freshness window, branch-protection refusal, network blip mid-
586
+ # merge, etc. The cascade goal was not reached but a retry MAY
587
+ # succeed; surface as escalation (exit 1).
588
+ return WaitMergeableResult(
589
+ pr_number=pr_number,
590
+ repo=repo,
591
+ outcome="merge-failed",
592
+ exit_code=EXIT_TIMEOUT_OR_ESCALATION,
593
+ monitor_result=monitor_payload,
594
+ protected_check=protected_check_payload,
595
+ merge_stdout=merge_stdout,
596
+ merge_stderr=merge_stderr,
597
+ error=(
598
+ f"gh pr merge exited {merge_rc}. stderr: {merge_stderr.strip()[-200:]}"
599
+ if merge_stderr.strip()
600
+ else f"gh pr merge exited {merge_rc}"
601
+ ),
602
+ )
603
+
604
+
605
+ # ---- CLI --------------------------------------------------------------------
606
+
607
+
608
+ def main(argv: list[str] | None = None) -> int:
609
+ args = _build_parser().parse_args(argv)
610
+
611
+ # Resolve --repo: explicit flag wins, then $GH_REPO. We do NOT auto-
612
+ # detect from the current checkout here because cascade automation
613
+ # is normally invoked from a non-clone harness (the swarm monitor's
614
+ # working directory may not be a git checkout of the target repo).
615
+ repo = args.repo or os.environ.get("GH_REPO")
616
+ if not repo:
617
+ print(
618
+ "Error: --repo OWNER/REPO is required (or set $GH_REPO).",
619
+ file=sys.stderr,
620
+ )
621
+ return EXIT_CONFIG_ERROR
622
+
623
+ # Flatten --protected before the cascade so a malformed token is a
624
+ # pre-flight config error rather than a mid-cascade surprise.
625
+ try:
626
+ protected = _parse_protected(args.protected)
627
+ except ValueError as exc:
628
+ print(f"Error: {exc}", file=sys.stderr)
629
+ return EXIT_CONFIG_ERROR
630
+
631
+ result = wait_mergeable_and_merge(
632
+ pr_number=args.pr_number,
633
+ repo=repo,
634
+ cap_minutes=args.cap_minutes,
635
+ protected=protected,
636
+ )
637
+
638
+ summary_label = {
639
+ EXIT_MERGED: "MERGED",
640
+ EXIT_TIMEOUT_OR_ESCALATION: "TIMEOUT-OR-ESCALATION",
641
+ EXIT_CONFIG_ERROR: "CONFIG-ERROR",
642
+ }.get(result.exit_code, "UNKNOWN")
643
+
644
+ # Per-poll status mirror lands on stderr from monitor_pr already; the
645
+ # final verdict goes on stdout so a consumer parsing the cascade
646
+ # output sees the outcome regardless of --json mode.
647
+ print(
648
+ f"[pr_wait_mergeable] PR #{result.pr_number} repo={result.repo} "
649
+ f"result={summary_label} outcome={result.outcome}",
650
+ file=sys.stderr,
651
+ )
652
+
653
+ if args.emit_json:
654
+ print(json.dumps(result.to_dict(), indent=2))
655
+ else:
656
+ print(f"PR #{result.pr_number} wait-mergeable-and-merge result: {summary_label}")
657
+ print(f" outcome: {result.outcome}")
658
+ if result.error:
659
+ print(f" error: {result.error}")
660
+ if result.merge_stdout.strip():
661
+ print(" merge stdout:")
662
+ for line in result.merge_stdout.strip().splitlines():
663
+ print(f" {line}")
664
+
665
+ return result.exit_code
666
+
667
+
668
+ if __name__ == "__main__":
669
+ sys.exit(main())