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