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