@deftai/directive-content 0.59.0 → 0.61.0

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