@deftai/directive-content 0.55.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env python3
2
+ """swarm_verify_review_clean.py -- Cohort-level CLEAN verification gate (#1364).
3
+
4
+ Verifies that EVERY PR in a swarm cohort satisfies the
5
+ ``skills/deft-directive-review-cycle/SKILL.md`` Phase 2 Step 6 exit condition
6
+ AND the ``skills/deft-directive-swarm/SKILL.md`` Phase 5 Exit Condition on the
7
+ **current HEAD** before the monitor is allowed to discuss the Phase 5 -> 6
8
+ merge cascade.
9
+
10
+ Background (#1364)
11
+ ------------------
12
+ The swarm skill's Phase 5 Exit Condition correctly documents the strong
13
+ per-PR CLEAN bar (confidence > 3, no P0/P1, no errored sentinel, CI clean,
14
+ HEAD-SHA freshness), and the per-PR programmatic gate
15
+ (``scripts/pr_merge_readiness.py`` / ``task pr:merge-ready``) closes the
16
+ per-merge SUCCESS-with-findings blind spot. But there is no mandatory
17
+ deterministic gate the monitor must pass at the COHORT level after the
18
+ Phase 6 pollers terminate but before the merge discussion begins. The
19
+ result: during the #1166 strategy-consistency swarm, multiple pollers
20
+ exited with ``clean_gate_holdout=confidence`` (i.e. confidence == 3) and
21
+ the monitor still surfaced the Phase 5 -> 6 merge gate because the
22
+ trigger keyed on "all pollers have reported back" rather than "every PR
23
+ in the cohort is objectively CLEAN".
24
+
25
+ This script is that structural gap-closer. It re-uses the Greptile
26
+ rolling-summary parser from ``scripts/pr_merge_readiness.py`` so the two
27
+ surfaces stay in lockstep -- a future fix to the parser (e.g. a new
28
+ Greptile rendering surface, a new severity badge) lands in both surfaces
29
+ at once. Do NOT duplicate the parsing logic here.
30
+
31
+ What it checks (per PR)
32
+ -----------------------
33
+ For every PR in the cohort, all of the following MUST hold on the current
34
+ PR HEAD:
35
+
36
+ 1. ``Last reviewed commit:`` SHA in the Greptile rolling-summary comment
37
+ body matches the live PR HEAD ref OID.
38
+ 2. The body is NOT the errored sentinel (#526) ``Greptile encountered an
39
+ error while reviewing this PR``.
40
+ 3. ``Confidence Score: X / 5`` is greater than 3 (i.e. 4 or 5).
41
+ 4. P0 and P1 finding counts are both zero. P2 findings are non-blocking
42
+ style suggestions per the review-cycle skill and do NOT gate the
43
+ cohort.
44
+
45
+ CI lane verification is intentionally out of scope: lane names vary per
46
+ repository, the Greptile body verdict already encodes review readiness,
47
+ and the per-merge ``task pr:merge-ready`` gate stays the freshness-window-
48
+ atomic merge-time check that pins HEAD-SHA equality. This cohort gate
49
+ fires once after the pollers terminate; the per-merge gate fires inside
50
+ the shell-`&&` chain that follows.
51
+
52
+ Usage
53
+ -----
54
+ # Explicit PR list
55
+ task swarm:verify-review-clean -- 1370 1371 1372 --repo deftai/directive
56
+
57
+ # Or cohort discovered from active vBRIEFs (resolves each vBRIEF's
58
+ # x-vbrief/github-pr reference, if any)
59
+ task swarm:verify-review-clean -- --cohort vbrief/active/*.vbrief.json --repo deftai/directive
60
+
61
+ # JSON output for programmatic consumers (a parent monitor agent)
62
+ task swarm:verify-review-clean -- 1370 1371 --repo deftai/directive --json
63
+
64
+ Exit codes
65
+ ----------
66
+ 0 -- every PR in the cohort is CLEAN on current HEAD (merge discussion may proceed)
67
+ 1 -- one or more PRs is unclean; per-PR diagnostics printed
68
+ 2 -- external / config error (gh missing, empty cohort, malformed vBRIEF,
69
+ no x-vbrief/github-pr references resolved, ...)
70
+
71
+ Pure stdlib + ``gh`` CLI; no third-party deps. The parser is imported
72
+ from ``scripts/pr_merge_readiness.py`` (so both surfaces share one source
73
+ of truth).
74
+ """
75
+
76
+ from __future__ import annotations
77
+
78
+ import argparse
79
+ import glob
80
+ import json
81
+ import re
82
+ import sys
83
+ from dataclasses import asdict, dataclass, field
84
+ from pathlib import Path
85
+
86
+ # Make sibling scripts importable both when run as __main__ and when imported
87
+ # by tests.
88
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
89
+
90
+ try:
91
+ from _stdio_utf8 import reconfigure_stdio # noqa: E402
92
+ reconfigure_stdio()
93
+ except ImportError:
94
+ # _stdio_utf8 is optional; some test contexts load this module directly.
95
+ pass
96
+
97
+ # Re-use the proven Greptile body parser + per-PR gate from
98
+ # pr_merge_readiness.py. Duplicating the parsing logic here would let the
99
+ # two surfaces drift -- a fix in one would not land in the other. The
100
+ # parser is module-private to pr_merge_readiness but exported by name and
101
+ # is the right load-bearing reuse point. See #1364.
102
+ import pr_merge_readiness as _mr # noqa: E402
103
+
104
+ EXIT_OK = 0
105
+ EXIT_UNCLEAN = 1
106
+ EXIT_EXTERNAL_ERROR = 2
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Cohort discovery
110
+ # ---------------------------------------------------------------------------
111
+
112
+ # Regex for an x-vbrief/github-pr URI of the form
113
+ # `https://github.com/<owner>/<repo>/pull/<N>`. The cohort discovery path
114
+ # resolves the PR number from any reference that matches.
115
+ _PR_URI_RE = re.compile(
116
+ r"https?://github\.com/[^/]+/[^/]+/pull/(?P<pr>\d+)",
117
+ )
118
+
119
+
120
+ @dataclass
121
+ class CohortResolutionError:
122
+ """Structured failure from cohort discovery."""
123
+ vbrief_path: str
124
+ reason: str
125
+
126
+
127
+ def resolve_cohort_from_vbriefs(
128
+ vbrief_globs: list[str],
129
+ ) -> tuple[list[int], list[CohortResolutionError]]:
130
+ """Resolve a list of PR numbers from one or more glob patterns over vBRIEF
131
+ paths.
132
+
133
+ For each matched ``*.vbrief.json`` file, read ``plan.references[]`` and
134
+ extract every URI matching ``https://github.com/.../pull/<N>``. Returns
135
+ a flat de-duplicated list of PR numbers preserving first-seen order
136
+ AND a per-vBRIEF list of resolution failures so the caller can surface
137
+ them with EXIT_EXTERNAL_ERROR.
138
+
139
+ Acceptable failure modes (each surfaced as a structured
140
+ ``CohortResolutionError`` but NOT raised) so a partial cohort can
141
+ still be diagnosed:
142
+ - vBRIEF JSON is malformed
143
+ - vBRIEF carries no PR references at all
144
+ - vBRIEF references a PR URL on a different host (we record but skip)
145
+ """
146
+ seen_prs: list[int] = []
147
+ seen_set: set[int] = set()
148
+ failures: list[CohortResolutionError] = []
149
+ paths: list[Path] = []
150
+ for pattern in vbrief_globs:
151
+ # Each glob can match zero or more files. We treat a glob that
152
+ # matches nothing as a soft failure (e.g. a typo): the caller
153
+ # gets a structured error so they can fix the glob.
154
+ matched = sorted(Path(p) for p in glob.glob(pattern))
155
+ if not matched:
156
+ failures.append(
157
+ CohortResolutionError(
158
+ vbrief_path=pattern,
159
+ reason=f"glob matched no files: {pattern!r}",
160
+ )
161
+ )
162
+ continue
163
+ paths.extend(matched)
164
+ for path in paths:
165
+ try:
166
+ payload = json.loads(path.read_text(encoding="utf-8"))
167
+ except (OSError, json.JSONDecodeError) as exc:
168
+ failures.append(
169
+ CohortResolutionError(vbrief_path=str(path), reason=f"unreadable: {exc}")
170
+ )
171
+ continue
172
+ references = payload.get("plan", {}).get("references", []) or []
173
+ pr_numbers_in_file: list[int] = []
174
+ for ref in references:
175
+ uri = (ref or {}).get("uri", "") if isinstance(ref, dict) else ""
176
+ if not uri:
177
+ continue
178
+ m = _PR_URI_RE.search(uri)
179
+ if not m:
180
+ continue
181
+ pr_numbers_in_file.append(int(m.group("pr")))
182
+ if not pr_numbers_in_file:
183
+ failures.append(
184
+ CohortResolutionError(
185
+ vbrief_path=str(path),
186
+ reason="no x-vbrief/github-pr-style references found",
187
+ )
188
+ )
189
+ continue
190
+ for pr_num in pr_numbers_in_file:
191
+ if pr_num in seen_set:
192
+ continue
193
+ seen_set.add(pr_num)
194
+ seen_prs.append(pr_num)
195
+ return seen_prs, failures
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Per-PR + cohort evaluation
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ @dataclass
204
+ class CohortPRResult:
205
+ """Per-PR slice of the cohort verdict."""
206
+ pr_number: int
207
+ head_sha: str | None
208
+ verdict: dict # asdict(GreptileVerdict)
209
+ failures: list[str] = field(default_factory=list)
210
+
211
+ @property
212
+ def clean(self) -> bool:
213
+ return not self.failures
214
+
215
+
216
+ @dataclass
217
+ class CohortResult:
218
+ """Aggregate cohort verdict."""
219
+ repo: str | None
220
+ pr_results: list[CohortPRResult] = field(default_factory=list)
221
+ resolution_errors: list[CohortResolutionError] = field(default_factory=list)
222
+
223
+ @property
224
+ def all_clean(self) -> bool:
225
+ return (
226
+ bool(self.pr_results)
227
+ and not self.resolution_errors
228
+ and all(r.clean for r in self.pr_results)
229
+ )
230
+
231
+ def to_dict(self) -> dict:
232
+ return {
233
+ "repo": self.repo,
234
+ "all_clean": self.all_clean,
235
+ "pr_count": len(self.pr_results),
236
+ "pr_results": [
237
+ {
238
+ "pr_number": r.pr_number,
239
+ "head_sha": r.head_sha,
240
+ "clean": r.clean,
241
+ "verdict": r.verdict,
242
+ "failures": list(r.failures),
243
+ }
244
+ for r in self.pr_results
245
+ ],
246
+ "resolution_errors": [asdict(e) for e in self.resolution_errors],
247
+ }
248
+
249
+
250
+ def evaluate_pr(
251
+ pr_number: int,
252
+ repo: str | None,
253
+ ) -> CohortPRResult | None:
254
+ """Evaluate one PR. Returns None on external error (caller maps to EXIT 2).
255
+
256
+ Resolves every fetch / parse / gate call through the ``_mr`` module
257
+ binding so monkey-patching ``_mr.fetch_pr_head_sha`` /
258
+ ``_mr.fetch_greptile_comment_body`` (the canonical seam for tests of
259
+ this script AND of pr_merge_readiness itself) propagates here at call
260
+ time. A previous draft captured the fetchers as default keyword
261
+ arguments, which froze the binding at function-definition time and
262
+ silently bypassed monkeypatch; resolving via the module attribute is
263
+ the right late-binding shape.
264
+ """
265
+ head_sha = _mr.fetch_pr_head_sha(pr_number, repo)
266
+ if head_sha is None:
267
+ return None
268
+ body = _mr.fetch_greptile_comment_body(pr_number, repo)
269
+ if body is None:
270
+ return None
271
+ verdict = _mr.parse_greptile_body(body)
272
+ failures = _mr.evaluate_gates(pr_number, head_sha, verdict)
273
+ return CohortPRResult(
274
+ pr_number=pr_number,
275
+ head_sha=head_sha,
276
+ verdict=asdict(verdict),
277
+ failures=failures,
278
+ )
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # CLI
283
+ # ---------------------------------------------------------------------------
284
+
285
+
286
+ def _build_parser() -> argparse.ArgumentParser:
287
+ parser = argparse.ArgumentParser(
288
+ prog="swarm_verify_review_clean",
289
+ description=(
290
+ "Cohort-level CLEAN verification gate (#1364). Exits 0 only when "
291
+ "EVERY PR in the cohort has SHA match, confidence > 3, zero P0/P1, "
292
+ "not errored on the current HEAD. Re-uses the Greptile body parser "
293
+ "from scripts/pr_merge_readiness.py so the per-PR merge gate and "
294
+ "the cohort gate stay in lockstep."
295
+ ),
296
+ )
297
+ parser.add_argument(
298
+ "pr_numbers",
299
+ nargs="*",
300
+ type=int,
301
+ help="Explicit PR numbers to verify.",
302
+ )
303
+ parser.add_argument(
304
+ "--cohort",
305
+ dest="cohort_globs",
306
+ action="append",
307
+ default=[],
308
+ metavar="GLOB",
309
+ help=(
310
+ "Glob pattern over vBRIEF JSON files. Each matched vBRIEF's "
311
+ "plan.references[].uri is scanned for github.com/.../pull/<N> "
312
+ "URIs; matching PRs join the cohort. May be passed multiple "
313
+ "times."
314
+ ),
315
+ )
316
+ parser.add_argument(
317
+ "--repo",
318
+ default=None,
319
+ metavar="OWNER/REPO",
320
+ help="Repository in OWNER/REPO form. Defaults to the current checkout's remote.",
321
+ )
322
+ parser.add_argument(
323
+ "--json",
324
+ dest="emit_json",
325
+ action="store_true",
326
+ help="Emit the cohort result as a single JSON object on stdout.",
327
+ )
328
+ return parser
329
+
330
+
331
+ def main(argv: list[str] | None = None) -> int:
332
+ args = _build_parser().parse_args(argv)
333
+
334
+ # Step 1: build the cohort (union of explicit PR numbers + --cohort globs).
335
+ pr_numbers: list[int] = list(dict.fromkeys(args.pr_numbers)) # de-dupe, preserve order
336
+ resolution_errors: list[CohortResolutionError] = []
337
+ if args.cohort_globs:
338
+ discovered, errs = resolve_cohort_from_vbriefs(args.cohort_globs)
339
+ for pr_num in discovered:
340
+ if pr_num not in pr_numbers:
341
+ pr_numbers.append(pr_num)
342
+ resolution_errors.extend(errs)
343
+
344
+ # Empty cohort is a config error -- the gate cannot affirm CLEAN over
345
+ # zero PRs (it would silently exit 0 and let the merge discussion
346
+ # proceed). Surface as EXIT_EXTERNAL_ERROR.
347
+ if not pr_numbers:
348
+ msg = (
349
+ "Error: empty cohort. Pass one or more PR numbers as positional "
350
+ "arguments and/or --cohort <glob> to discover PRs from vBRIEF "
351
+ "references."
352
+ )
353
+ if args.emit_json:
354
+ result = CohortResult(
355
+ repo=args.repo, pr_results=[], resolution_errors=resolution_errors
356
+ )
357
+ print(json.dumps(result.to_dict(), indent=2))
358
+ else:
359
+ print(msg, file=sys.stderr)
360
+ if resolution_errors:
361
+ for err in resolution_errors:
362
+ print(f" [{err.vbrief_path}] {err.reason}", file=sys.stderr)
363
+ return EXIT_EXTERNAL_ERROR
364
+
365
+ # If the --cohort globs surfaced resolution errors AND no PRs at all
366
+ # were resolved from them (but explicit PR numbers are present), keep
367
+ # going -- the explicit args satisfy the intent. If both surfaces
368
+ # contribute nothing, the empty-cohort branch above already handled
369
+ # it. We keep the resolution_errors in the result regardless so a
370
+ # JSON consumer / human reader can see partial failures.
371
+
372
+ # Step 2: per-PR evaluation.
373
+ pr_results: list[CohortPRResult] = []
374
+ for pr_num in pr_numbers:
375
+ per_pr = evaluate_pr(pr_num, args.repo)
376
+ if per_pr is None:
377
+ # External error already printed by the fetchers; abort the
378
+ # cohort with EXIT_EXTERNAL_ERROR so the operator sees the
379
+ # failed PR rather than a misleading "MERGE-BLOCKED" verdict
380
+ # on stale state.
381
+ return EXIT_EXTERNAL_ERROR
382
+ pr_results.append(per_pr)
383
+
384
+ cohort = CohortResult(
385
+ repo=args.repo,
386
+ pr_results=pr_results,
387
+ resolution_errors=resolution_errors,
388
+ )
389
+
390
+ if args.emit_json:
391
+ print(json.dumps(cohort.to_dict(), indent=2))
392
+ else:
393
+ _render_text(cohort)
394
+
395
+ return EXIT_OK if cohort.all_clean else EXIT_UNCLEAN
396
+
397
+
398
+ def _render_text(cohort: CohortResult) -> None:
399
+ """Pretty-print the cohort verdict for human consumers."""
400
+ n = len(cohort.pr_results)
401
+ print(f"Swarm cohort CLEAN verification ({n} PR{'s' if n != 1 else ''})")
402
+ if cohort.repo:
403
+ print(f" Repo: {cohort.repo}")
404
+ if cohort.resolution_errors:
405
+ print(" Resolution errors:")
406
+ for err in cohort.resolution_errors:
407
+ print(f" [{err.vbrief_path}] {err.reason}")
408
+ for r in cohort.pr_results:
409
+ status = "CLEAN" if r.clean else "UNCLEAN"
410
+ v = r.verdict
411
+ print()
412
+ print(f" PR #{r.pr_number} -- {status}")
413
+ print(f" HEAD SHA: {r.head_sha or '<unknown>'}")
414
+ print(f" Greptile reviewed: {v.get('last_reviewed_sha') or '<not parsed>'}")
415
+ conf = v.get("confidence")
416
+ conf_str = str(conf) if conf is not None else "<not parsed>"
417
+ print(f" Confidence: {conf_str}/5")
418
+ print(
419
+ f" Findings: P0={v.get('p0_count', 0)} "
420
+ f"P1={v.get('p1_count', 0)} P2={v.get('p2_count', 0)}"
421
+ )
422
+ print(f" Errored sentinel: {v.get('errored', False)}")
423
+ for i, fail in enumerate(r.failures, 1):
424
+ print(f" [{i}] {fail}")
425
+ print()
426
+ if cohort.all_clean:
427
+ print("Result: COHORT CLEAN -- Phase 5 -> 6 merge discussion may proceed")
428
+ else:
429
+ n_unclean = sum(1 for r in cohort.pr_results if not r.clean)
430
+ print(
431
+ f"Result: COHORT BLOCKED -- {n_unclean}/{n} PR(s) unclean. "
432
+ "Do NOT raise the Phase 5 -> 6 gate; re-dispatch pollers or "
433
+ "address findings, then re-run task swarm:verify-review-clean."
434
+ )
435
+
436
+
437
+ if __name__ == "__main__":
438
+ sys.exit(main())