claude-dev-env 2.1.0 → 2.2.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 (27) hide show
  1. package/package.json +1 -1
  2. package/skills/_shared/pr-loop/CLAUDE.md +18 -13
  3. package/skills/_shared/pr-loop/portable-driver.md +150 -0
  4. package/skills/_shared/pr-loop/scripts/CLAUDE.md +3 -0
  5. package/skills/_shared/pr-loop/scripts/build_converge_task_list.py +310 -0
  6. package/skills/_shared/pr-loop/scripts/portable_converge_driver.py +1637 -0
  7. package/skills/_shared/pr-loop/scripts/select_converge_pacer.py +215 -0
  8. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +3 -0
  9. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/converge_task_list_constants.py +56 -0
  10. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/pacer_constants.py +47 -0
  11. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/portable_driver_constants.py +181 -0
  12. package/skills/_shared/pr-loop/scripts/test_build_converge_task_list.py +140 -0
  13. package/skills/_shared/pr-loop/scripts/test_portable_converge_driver.py +1119 -0
  14. package/skills/_shared/pr-loop/scripts/test_select_converge_pacer.py +207 -0
  15. package/skills/autoconverge/CLAUDE.md +5 -3
  16. package/skills/autoconverge/SKILL.md +185 -74
  17. package/skills/autoconverge/reference/convergence.md +5 -5
  18. package/skills/autoconverge/reference/multi-pr.md +33 -2
  19. package/skills/autoconverge/reference/stop-conditions.md +9 -6
  20. package/skills/autoconverge/test_portable_pacer_gate.py +54 -0
  21. package/skills/copilot-finding-triage/SKILL.md +16 -4
  22. package/skills/pr-converge/CLAUDE.md +4 -3
  23. package/skills/pr-converge/SKILL.md +236 -44
  24. package/skills/pr-converge/reference/multi-pr-orchestration.md +5 -1
  25. package/skills/pr-converge/reference/per-tick.md +51 -24
  26. package/skills/pr-converge/test_portable_pacer_gate.py +67 -0
  27. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -3
@@ -0,0 +1,1637 @@
1
+ #!/usr/bin/env python3
2
+ """portable_converge_driver phase machine (deterministic only).
3
+
4
+ The agent never invents phase transitions, wait delays, clean stamps, or
5
+ ready decisions from prose. It runs this CLI, reads JSON, and either:
6
+
7
+ * executes the named ``commands`` (shell helpers already scripted), or
8
+ * performs the single judgment step named in ``next`` (fix implementation,
9
+ worker spawn body) and reports outcomes back through this CLI.
10
+
11
+ ::
12
+
13
+ portable_converge_driver.py open-run --skill autoconverge \\
14
+ --has-workflow 0 --has-schedule-wakeup 0 \\
15
+ --owner O --repo R --pr-number N --cwd DIR --state-dir DIR \\
16
+ [--session-model third-party] [--copilot-down 0|1] [--bugbot-down 0|1] \\
17
+ [--codex-down 0|1] [--codex-required 0|1]
18
+
19
+ portable_converge_driver.py after-code-review --state-file PATH \\
20
+ --returncode 0 --dirty-tree 0 --current-head SHA \\
21
+ --served-command ''|in_session|chain-command
22
+
23
+ portable_converge_driver.py after-bugteam --state-file PATH \\
24
+ --pushed 0|1 --converged 0|1 --current-head SHA
25
+
26
+ portable_converge_driver.py after-bugbot --state-file PATH \\
27
+ --classification clean|dirty|absent|down --current-head SHA \\
28
+ [--inline-lag 0|1]
29
+
30
+ portable_converge_driver.py after-codex --state-file PATH \\
31
+ --classification clean|dirty|down --current-head SHA
32
+
33
+ portable_converge_driver.py after-copilot-wait --state-file PATH \\
34
+ --review-surfaced 0|1 --classification clean|dirty|absent|down \\
35
+ --current-head SHA
36
+
37
+ portable_converge_driver.py after-ready-check --state-file PATH \\
38
+ --check-exit 0|1|2 --current-head SHA
39
+
40
+ portable_converge_driver.py show-state --state-file PATH
41
+
42
+ Successful code-review serve: returncode 0 only. ``served_command`` is
43
+ informational (empty / ``in_session`` / chain argv) and does not decide
44
+ success. Non-zero returncode is always a failed serve.
45
+
46
+ After Bugbot clean or down, the machine runs the Codex step (or the
47
+ codex-down / not-required waiver) before COPILOT_WAIT or check_ready.
48
+
49
+ When ``--codex-down`` is off, open-run and every codex decision point probe
50
+ weekly usage through ``codex_usage_probe`` so ``codex_required`` matches
51
+ ``check_convergence`` (CLI ``--codex-required 1`` still forces the step on).
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import argparse
57
+ import json
58
+ import os
59
+ import subprocess
60
+ import sys
61
+ from pathlib import Path
62
+ from typing import Mapping
63
+
64
+ _self_dir = Path(__file__).resolve().parent
65
+ if str(_self_dir) not in sys.path:
66
+ sys.path.insert(0, str(_self_dir))
67
+
68
+ from build_converge_task_list import build_converge_task_list # noqa: E402
69
+ from select_converge_pacer import ( # noqa: E402
70
+ parse_bool_flag,
71
+ select_converge_pacer,
72
+ )
73
+ from skills_pr_loop_constants.pacer_constants import ( # noqa: E402
74
+ CLI_HAS_SCHEDULE_WAKEUP_FLAG,
75
+ CLI_HAS_WORKFLOW_FLAG,
76
+ CLI_SKILL_FLAG,
77
+ PACER_PORTABLE,
78
+ )
79
+ from skills_pr_loop_constants.portable_driver_constants import ( # noqa: E402
80
+ ALL_CLASSIFICATIONS,
81
+ ALL_CODEX_CLASSIFICATIONS,
82
+ ALL_COPILOT_CLASSIFICATIONS,
83
+ ALL_GIT_REV_PARSE_HEAD_ARGV,
84
+ BLOCKER_BUGBOT_WAIT_CAP,
85
+ BLOCKER_COPILOT_WAIT_CAP,
86
+ BLOCKER_INLINE_LAG_CAP,
87
+ BLOCKER_NOT_PORTABLE,
88
+ BLOCKER_PREFLIGHT,
89
+ BLOCKER_REVIEW_FAILED,
90
+ BUGBOT_ABSENT_WAIT_HARD_CAP,
91
+ BUGBOT_INLINE_LAG_WAIT_SECONDS,
92
+ CHECK_CONVERGENCE_BUGBOT_DOWN_FLAG,
93
+ CHECK_CONVERGENCE_CODEX_CLEAN_AT_FLAG,
94
+ CHECK_CONVERGENCE_CODEX_DOWN_FLAG,
95
+ CHECK_CONVERGENCE_COPILOT_DOWN_FLAG,
96
+ CHECK_CONVERGENCE_OWNER_FLAG,
97
+ CHECK_CONVERGENCE_PR_NUMBER_FLAG,
98
+ CHECK_CONVERGENCE_RELATIVE_PATH,
99
+ CHECK_CONVERGENCE_REPO_FLAG,
100
+ CLASSIFICATION_ABSENT,
101
+ CLASSIFICATION_DIRTY,
102
+ CLASSIFICATION_DOWN,
103
+ CLI_BUGBOT_DOWN_FLAG,
104
+ CLI_CHECK_CONVERGENCE_EXIT_FLAG,
105
+ CLI_CLASSIFICATION_FLAG,
106
+ CLI_CODEX_DOWN_FLAG,
107
+ CLI_CODEX_REQUIRED_FLAG,
108
+ CLI_CONVERGED_FLAG,
109
+ CLI_COPILOT_DOWN_FLAG,
110
+ CLI_CURRENT_HEAD_FLAG,
111
+ CLI_CWD_FLAG,
112
+ CLI_DESCRIPTION,
113
+ CLI_DIRTY_TREE_FLAG,
114
+ CLI_INLINE_LAG_FLAG,
115
+ CLI_OWNER_FLAG,
116
+ CLI_PR_NUMBER_FLAG,
117
+ CLI_PUSHED_FLAG,
118
+ CLI_REPO_FLAG,
119
+ CLI_RETURNCODE_FLAG,
120
+ CLI_REVIEW_SURFACED_FLAG,
121
+ CLI_SERVED_COMMAND_FLAG,
122
+ CLI_SESSION_MODEL_FLAG,
123
+ CLI_STATE_DIR_FLAG,
124
+ CLI_STATE_FILE_FLAG,
125
+ CODEX_REVIEW_SCRIPTS_DIRNAME,
126
+ CODEX_REVIEW_SKILL_DIRNAME,
127
+ COMMAND_AFTER_BUGBOT,
128
+ COMMAND_AFTER_BUGTEAM,
129
+ COMMAND_AFTER_CODE_REVIEW,
130
+ COMMAND_AFTER_CODEX,
131
+ COMMAND_AFTER_COPILOT_WAIT,
132
+ COMMAND_AFTER_READY_CHECK,
133
+ COMMAND_OPEN_RUN,
134
+ COMMAND_SHOW_STATE,
135
+ COPILOT_WAIT_HARD_CAP,
136
+ DEFAULT_SESSION_MODEL,
137
+ DEFAULT_WAIT_SECONDS,
138
+ EXIT_CONTRACT_ERROR,
139
+ EXIT_SUCCESS,
140
+ EXIT_USAGE_ERROR,
141
+ INLINE_LAG_STREAK_CAP,
142
+ INVOKE_CODE_REVIEW_RELATIVE_PATH,
143
+ PORTABLE_SCRIPTS_TO_SKILLS_PARENT_HOPS,
144
+ NEXT_APPLY_FIXES,
145
+ NEXT_CHECK_READY,
146
+ NEXT_MARK_READY,
147
+ NEXT_POLL_WAIT,
148
+ NEXT_REQUEST_COPILOT,
149
+ NEXT_RUN_BUGBOT_GATE,
150
+ NEXT_RUN_BUGTEAM,
151
+ NEXT_RUN_CODE_REVIEW,
152
+ NEXT_RUN_CODEX,
153
+ NEXT_STOP_BLOCKED,
154
+ PHASE_BLOCKED,
155
+ PHASE_BUGBOT,
156
+ PHASE_BUGTEAM,
157
+ PHASE_CODE_REVIEW,
158
+ PHASE_COPILOT_WAIT,
159
+ PHASE_READY,
160
+ PREFLIGHT_WORKTREE_SCRIPT_NAME,
161
+ RESULT_KEY_BLOCKER,
162
+ RESULT_KEY_BUGBOT_DOWN,
163
+ RESULT_KEY_CODEX_DOWN,
164
+ RESULT_KEY_COMMANDS,
165
+ RESULT_KEY_COPILOT_DOWN,
166
+ RESULT_KEY_CURRENT_HEAD,
167
+ RESULT_KEY_ENTRY_SKILL,
168
+ RESULT_KEY_MESSAGE,
169
+ RESULT_KEY_NEXT,
170
+ RESULT_KEY_OWNER,
171
+ RESULT_KEY_PACER,
172
+ RESULT_KEY_PHASE,
173
+ RESULT_KEY_PR_NUMBER,
174
+ RESULT_KEY_REPO,
175
+ RESULT_KEY_STATE_FILE,
176
+ RESULT_KEY_STATUS,
177
+ RESULT_KEY_TICK_COUNT,
178
+ RESULT_KEY_WAIT_SECONDS,
179
+ STATE_FILENAME,
180
+ STATE_JSON_INDENT,
181
+ MARK_READY_GH_BINARY,
182
+ MARK_READY_PR_TOKEN,
183
+ MARK_READY_REPO_FLAG,
184
+ MARK_READY_SUBCOMMAND,
185
+ STATE_KEY_CODEX_CLEAN_AT,
186
+ STATE_KEY_CODEX_DOWN,
187
+ STATE_KEY_CODEX_REQUIRED,
188
+ STATE_KEY_CWD,
189
+ STATE_KEY_PENDING_NEXT,
190
+ STATE_KEY_PENDING_WAIT_SECONDS,
191
+ STATE_KEY_SESSION_MODEL,
192
+ STATE_STAGING_SUFFIX,
193
+ STATUS_ERROR,
194
+ STATUS_OK,
195
+ )
196
+ from skills_pr_loop_constants.preflight_constants import ( # noqa: E402
197
+ MODE_ARG_FLAG,
198
+ MODE_STRICT,
199
+ OWNER_ARG_FLAG,
200
+ REPO_ARG_FLAG,
201
+ )
202
+
203
+ _skills_directory_for_codex = Path(__file__).resolve().parent
204
+ _remaining_codex_path_hops = PORTABLE_SCRIPTS_TO_SKILLS_PARENT_HOPS
205
+ while _remaining_codex_path_hops > 0:
206
+ _skills_directory_for_codex = _skills_directory_for_codex.parent
207
+ _remaining_codex_path_hops -= 1
208
+ _codex_scripts_directory = (
209
+ _skills_directory_for_codex
210
+ / CODEX_REVIEW_SKILL_DIRNAME
211
+ / CODEX_REVIEW_SCRIPTS_DIRNAME
212
+ )
213
+ _codex_scripts_text = str(_codex_scripts_directory)
214
+ if _codex_scripts_text not in sys.path:
215
+ sys.path.insert(0, _codex_scripts_text)
216
+
217
+ from codex_review_scripts_constants.codex_usage_probe_constants import ( # noqa: E402
218
+ USAGE_REPORT_KEY_PERCENT_LEFT,
219
+ )
220
+ from codex_usage_probe import ( # noqa: E402
221
+ is_codex_review_required,
222
+ probe_weekly_usage_via_subprocess,
223
+ )
224
+
225
+ _EMPTY_COMMANDS: list[str] = []
226
+
227
+
228
+ def _home_path(*parts: str) -> Path:
229
+ return Path.home().joinpath(*parts)
230
+
231
+
232
+ def _emit(all_payload: Mapping[str, object]) -> None:
233
+ print(json.dumps(dict(all_payload), sort_keys=True))
234
+
235
+
236
+ def _error_payload(message: str, **extra: object) -> dict[str, object]:
237
+ all_payload: dict[str, object] = {
238
+ RESULT_KEY_STATUS: STATUS_ERROR,
239
+ RESULT_KEY_MESSAGE: message,
240
+ }
241
+ all_payload.update(extra)
242
+ return all_payload
243
+
244
+
245
+ def load_state(state_file: Path) -> dict[str, object]:
246
+ """Load loop state JSON from disk.
247
+
248
+ Args:
249
+ state_file: Path to ``pr-converge-state.json``.
250
+
251
+ Returns:
252
+ Parsed state mapping.
253
+
254
+ Raises:
255
+ ValueError: When the file is missing or not a JSON object.
256
+ """
257
+ if not state_file.is_file():
258
+ raise ValueError(f"state file missing: {state_file}")
259
+ loaded = json.loads(state_file.read_text(encoding="utf-8"))
260
+ if not isinstance(loaded, dict):
261
+ raise ValueError(f"state file is not a JSON object: {state_file}")
262
+ return loaded
263
+
264
+
265
+ def save_state(state_file: Path, all_state: Mapping[str, object]) -> None:
266
+ """Write loop state JSON atomically.
267
+
268
+ Args:
269
+ state_file: Destination path.
270
+ all_state: State mapping to persist.
271
+ """
272
+ state_file.parent.mkdir(parents=True, exist_ok=True)
273
+ staging_path = state_file.with_suffix(state_file.suffix + STATE_STAGING_SUFFIX)
274
+ staging_path.write_text(
275
+ json.dumps(dict(all_state), indent=STATE_JSON_INDENT, sort_keys=True)
276
+ + "\n",
277
+ encoding="utf-8",
278
+ )
279
+ os.replace(staging_path, state_file)
280
+
281
+
282
+ def build_initial_state(
283
+ *,
284
+ current_head: str,
285
+ entry_skill: str,
286
+ is_copilot_down: bool,
287
+ is_bugbot_down: bool,
288
+ owner: str,
289
+ repo: str,
290
+ pr_number: int,
291
+ is_codex_down: bool = False,
292
+ is_codex_required: bool = False,
293
+ session_model: str = DEFAULT_SESSION_MODEL,
294
+ cwd_path: str | None = None,
295
+ ) -> dict[str, object]:
296
+ """Build the initial portable loop state for a PR head.
297
+
298
+ Args:
299
+ current_head: Current PR head SHA.
300
+ entry_skill: ``pr-converge`` or ``autoconverge``.
301
+ is_copilot_down: Copilot gate skipped for the whole run.
302
+ is_bugbot_down: Bugbot gate skipped for the whole run.
303
+ owner: Repository owner.
304
+ repo: Repository name.
305
+ pr_number: Pull request number.
306
+ is_codex_down: Codex gate skipped (disabled or opted out).
307
+ is_codex_required: Codex is required by usage/policy for this run.
308
+ session_model: Alias passed to invoke_code_review on re-entry.
309
+ cwd_path: PR worktree path for invoke_code_review --cwd.
310
+
311
+ Returns:
312
+ New state mapping at ``CODE_REVIEW``.
313
+ """
314
+ return {
315
+ "phase": PHASE_CODE_REVIEW,
316
+ "tick_count": 0,
317
+ "bugbot_clean_at": None,
318
+ "code_review_clean_at": None,
319
+ "bugteam_clean_at": None,
320
+ "copilot_clean_at": None,
321
+ STATE_KEY_CODEX_CLEAN_AT: None,
322
+ "merge_state_status": None,
323
+ "current_head": current_head,
324
+ "copilot_wait_count": 0,
325
+ "bugbot_wait_count": 0,
326
+ "copilot_down": is_copilot_down,
327
+ "bugbot_down": is_bugbot_down,
328
+ STATE_KEY_CODEX_DOWN: is_codex_down,
329
+ STATE_KEY_CODEX_REQUIRED: is_codex_required,
330
+ "inline_lag_streak": 0,
331
+ "pacer": PACER_PORTABLE,
332
+ "entry_skill": entry_skill,
333
+ "owner": owner,
334
+ "repo": repo,
335
+ "pr_number": pr_number,
336
+ "blocker": None,
337
+ STATE_KEY_PENDING_NEXT: NEXT_RUN_CODE_REVIEW,
338
+ STATE_KEY_SESSION_MODEL: session_model,
339
+ STATE_KEY_CWD: cwd_path,
340
+ }
341
+
342
+
343
+ def _reset_push_invalidated_markers(all_state: dict[str, object]) -> None:
344
+ all_state["bugbot_clean_at"] = None
345
+ all_state["code_review_clean_at"] = None
346
+ all_state["bugteam_clean_at"] = None
347
+ all_state["copilot_clean_at"] = None
348
+ all_state[STATE_KEY_CODEX_CLEAN_AT] = None
349
+ all_state["merge_state_status"] = None
350
+ all_state["copilot_wait_count"] = 0
351
+ all_state["bugbot_wait_count"] = 0
352
+ all_state["inline_lag_streak"] = 0
353
+ all_state["bugbot_down"] = False
354
+ all_state[STATE_KEY_CODEX_DOWN] = False
355
+
356
+
357
+ def _sync_task_list_from_down_flags(all_state: dict[str, object]) -> None:
358
+ all_task_list = build_converge_task_list(
359
+ is_bugbot_down=bool(all_state.get("bugbot_down")),
360
+ is_copilot_down=bool(all_state.get("copilot_down")),
361
+ is_codex_down=bool(all_state.get(STATE_KEY_CODEX_DOWN)),
362
+ is_codex_required=bool(all_state.get(STATE_KEY_CODEX_REQUIRED)),
363
+ )
364
+ all_state["tasks"] = all_task_list["tasks"]
365
+ all_state["final_task_id"] = all_task_list["final_task_id"]
366
+ all_state["runnable_review_ids"] = all_task_list["runnable_review_ids"]
367
+ all_state["done_when"] = all_task_list["done_when"]
368
+
369
+
370
+ def probe_weekly_usage_requires_codex() -> bool:
371
+ """Return whether live weekly Codex usage requires a review stamp.
372
+
373
+ Matches ``check_convergence``: unknown percent and probe failures never
374
+ require a review. A known percent above the shared threshold does.
375
+
376
+ Returns:
377
+ True only when the weekly probe exits successfully with a percent
378
+ that ``is_codex_review_required`` accepts as required.
379
+ """
380
+ try:
381
+ usage_report = probe_weekly_usage_via_subprocess()
382
+ except (
383
+ FileNotFoundError,
384
+ OSError,
385
+ subprocess.TimeoutExpired,
386
+ subprocess.SubprocessError,
387
+ json.JSONDecodeError,
388
+ ValueError,
389
+ TypeError,
390
+ KeyError,
391
+ ):
392
+ return False
393
+ raw_percent = usage_report.get(USAGE_REPORT_KEY_PERCENT_LEFT)
394
+ if isinstance(raw_percent, bool):
395
+ return False
396
+ if isinstance(raw_percent, (int, float)):
397
+ return is_codex_review_required(float(raw_percent))
398
+ return False
399
+
400
+
401
+ def resolve_codex_required_flag(
402
+ *,
403
+ is_codex_down: bool,
404
+ is_codex_required: bool,
405
+ ) -> bool:
406
+ """Resolve whether Codex is a runnable review for this run.
407
+
408
+ Args:
409
+ is_codex_down: Codex gate skipped (disabled or opted out).
410
+ is_codex_required: CLI force-on (``--codex-required 1``).
411
+
412
+ Returns:
413
+ False when Codex is down; otherwise the CLI force-on flag or the
414
+ live weekly usage probe (same rule as ``check_convergence``).
415
+ """
416
+ if is_codex_down:
417
+ return False
418
+ if is_codex_required:
419
+ return True
420
+ return probe_weekly_usage_requires_codex()
421
+
422
+
423
+ def _ensure_codex_required_matches_usage(all_state: dict[str, object]) -> None:
424
+ """Promote ``codex_required`` when live usage requires a stamp.
425
+
426
+ Args:
427
+ all_state: Mutable loop state; may set ``codex_required`` and refresh
428
+ the task list when the probe requires Codex.
429
+ """
430
+ if all_state.get(STATE_KEY_CODEX_DOWN):
431
+ return
432
+ if all_state.get(STATE_KEY_CODEX_REQUIRED):
433
+ return
434
+ if not probe_weekly_usage_requires_codex():
435
+ return
436
+ all_state[STATE_KEY_CODEX_REQUIRED] = True
437
+ _sync_task_list_from_down_flags(all_state)
438
+
439
+
440
+ def _maybe_reset_on_head_change(
441
+ all_state: dict[str, object], current_head: str
442
+ ) -> None:
443
+ prior_head = all_state.get("current_head")
444
+ if (
445
+ isinstance(prior_head, str)
446
+ and prior_head
447
+ and prior_head != current_head
448
+ ):
449
+ _reset_push_invalidated_markers(all_state)
450
+ _sync_task_list_from_down_flags(all_state)
451
+ all_state["current_head"] = current_head
452
+
453
+
454
+ def _code_review_commands(
455
+ *,
456
+ cwd_path: Path,
457
+ session_model: str,
458
+ ) -> list[str]:
459
+ invoke_path = _home_path(*INVOKE_CODE_REVIEW_RELATIVE_PATH.split("/"))
460
+ return [
461
+ "python",
462
+ str(invoke_path),
463
+ "--cwd",
464
+ str(cwd_path),
465
+ "--session-model",
466
+ session_model,
467
+ ]
468
+
469
+
470
+ def _code_review_commands_from_state(
471
+ all_state: Mapping[str, object],
472
+ ) -> list[str]:
473
+ stored_cwd = all_state.get(STATE_KEY_CWD)
474
+ stored_session_model = all_state.get(STATE_KEY_SESSION_MODEL)
475
+ if isinstance(stored_cwd, str) and stored_cwd:
476
+ cwd_path = Path(stored_cwd)
477
+ else:
478
+ cwd_path = Path.cwd()
479
+ if isinstance(stored_session_model, str) and stored_session_model:
480
+ session_model = stored_session_model
481
+ else:
482
+ session_model = DEFAULT_SESSION_MODEL
483
+ return _code_review_commands(
484
+ cwd_path=cwd_path, session_model=session_model
485
+ )
486
+
487
+
488
+ def _mark_ready_commands(all_state: Mapping[str, object]) -> list[str]:
489
+ owner = str(all_state["owner"])
490
+ repo = str(all_state["repo"])
491
+ return [
492
+ MARK_READY_GH_BINARY,
493
+ MARK_READY_PR_TOKEN,
494
+ MARK_READY_SUBCOMMAND,
495
+ str(all_state["pr_number"]),
496
+ MARK_READY_REPO_FLAG,
497
+ f"{owner}/{repo}",
498
+ ]
499
+
500
+
501
+ def _check_convergence_commands(
502
+ *,
503
+ owner: str,
504
+ repo: str,
505
+ pr_number: int,
506
+ is_copilot_down: bool,
507
+ is_bugbot_down: bool,
508
+ is_codex_down: bool,
509
+ codex_clean_at: str | None,
510
+ ) -> list[str]:
511
+ check_path = _home_path(*CHECK_CONVERGENCE_RELATIVE_PATH.split("/"))
512
+ all_arguments = [
513
+ "python",
514
+ str(check_path),
515
+ CHECK_CONVERGENCE_OWNER_FLAG,
516
+ owner,
517
+ CHECK_CONVERGENCE_REPO_FLAG,
518
+ repo,
519
+ CHECK_CONVERGENCE_PR_NUMBER_FLAG,
520
+ str(pr_number),
521
+ ]
522
+ if is_copilot_down:
523
+ all_arguments.append(CHECK_CONVERGENCE_COPILOT_DOWN_FLAG)
524
+ if is_bugbot_down:
525
+ all_arguments.append(CHECK_CONVERGENCE_BUGBOT_DOWN_FLAG)
526
+ if is_codex_down:
527
+ all_arguments.append(CHECK_CONVERGENCE_CODEX_DOWN_FLAG)
528
+ if codex_clean_at:
529
+ all_arguments.extend(
530
+ [CHECK_CONVERGENCE_CODEX_CLEAN_AT_FLAG, codex_clean_at]
531
+ )
532
+ return all_arguments
533
+
534
+
535
+ def _state_pr_number(all_state: Mapping[str, object]) -> int:
536
+ return int(str(all_state["pr_number"]))
537
+
538
+
539
+ def _codex_clean_at_or_none(all_state: Mapping[str, object]) -> str | None:
540
+ maybe_stamp = all_state.get(STATE_KEY_CODEX_CLEAN_AT)
541
+ if isinstance(maybe_stamp, str) and maybe_stamp:
542
+ return maybe_stamp
543
+ return None
544
+
545
+
546
+ def _check_convergence_commands_from_state(
547
+ all_state: Mapping[str, object],
548
+ ) -> list[str]:
549
+ return _check_convergence_commands(
550
+ owner=str(all_state["owner"]),
551
+ repo=str(all_state["repo"]),
552
+ pr_number=_state_pr_number(all_state),
553
+ is_copilot_down=bool(all_state.get("copilot_down")),
554
+ is_bugbot_down=bool(all_state.get("bugbot_down")),
555
+ is_codex_down=bool(all_state.get(STATE_KEY_CODEX_DOWN)),
556
+ codex_clean_at=_codex_clean_at_or_none(all_state),
557
+ )
558
+
559
+
560
+ def _base_ok_payload(
561
+ all_state: Mapping[str, object], state_file: Path
562
+ ) -> dict[str, object]:
563
+ return {
564
+ RESULT_KEY_STATUS: STATUS_OK,
565
+ RESULT_KEY_PHASE: all_state["phase"],
566
+ RESULT_KEY_STATE_FILE: str(state_file),
567
+ RESULT_KEY_CURRENT_HEAD: all_state.get("current_head"),
568
+ RESULT_KEY_TICK_COUNT: all_state.get("tick_count"),
569
+ RESULT_KEY_PACER: all_state.get("pacer", PACER_PORTABLE),
570
+ RESULT_KEY_ENTRY_SKILL: all_state.get("entry_skill"),
571
+ RESULT_KEY_OWNER: all_state.get("owner"),
572
+ RESULT_KEY_REPO: all_state.get("repo"),
573
+ RESULT_KEY_PR_NUMBER: all_state.get("pr_number"),
574
+ RESULT_KEY_COPILOT_DOWN: bool(all_state.get("copilot_down")),
575
+ RESULT_KEY_BUGBOT_DOWN: bool(all_state.get("bugbot_down")),
576
+ RESULT_KEY_CODEX_DOWN: bool(all_state.get(STATE_KEY_CODEX_DOWN)),
577
+ }
578
+
579
+
580
+ def _store_pending_wait_seconds(
581
+ all_state: dict[str, object],
582
+ *,
583
+ wait_seconds: int | None,
584
+ ) -> None:
585
+ if wait_seconds is None:
586
+ all_state.pop(STATE_KEY_PENDING_WAIT_SECONDS, None)
587
+ return
588
+ all_state[STATE_KEY_PENDING_WAIT_SECONDS] = wait_seconds
589
+
590
+
591
+ def _resolve_poll_wait_seconds(all_state: Mapping[str, object]) -> int:
592
+ pending_wait_seconds = all_state.get(STATE_KEY_PENDING_WAIT_SECONDS)
593
+ if isinstance(pending_wait_seconds, int):
594
+ return pending_wait_seconds
595
+ return DEFAULT_WAIT_SECONDS
596
+
597
+
598
+ def _finish_ok(
599
+ all_state: dict[str, object],
600
+ state_file: Path,
601
+ *,
602
+ next_action: str,
603
+ all_commands: list[str],
604
+ wait_seconds: int | None,
605
+ blocker: str | None,
606
+ all_extra_fields: Mapping[str, object] | None,
607
+ ) -> tuple[dict[str, object], int]:
608
+ all_state[STATE_KEY_PENDING_NEXT] = next_action
609
+ _store_pending_wait_seconds(all_state, wait_seconds=wait_seconds)
610
+ resolved_commands = all_commands
611
+ if next_action == NEXT_RUN_CODE_REVIEW:
612
+ resolved_commands = _code_review_commands_from_state(all_state)
613
+ save_state(state_file, all_state)
614
+ all_payload = _base_ok_payload(all_state, state_file)
615
+ all_payload[RESULT_KEY_NEXT] = next_action
616
+ all_payload[RESULT_KEY_COMMANDS] = resolved_commands
617
+ if wait_seconds is not None:
618
+ all_payload[RESULT_KEY_WAIT_SECONDS] = wait_seconds
619
+ if blocker is not None:
620
+ all_payload[RESULT_KEY_BLOCKER] = blocker
621
+ if all_extra_fields is not None:
622
+ all_payload.update(dict(all_extra_fields))
623
+ return all_payload, EXIT_SUCCESS
624
+
625
+
626
+ def _run_preflight(
627
+ *,
628
+ cwd_path: Path,
629
+ owner: str,
630
+ repo: str,
631
+ ) -> tuple[dict[str, object] | None, int]:
632
+ preflight_script = _self_dir / PREFLIGHT_WORKTREE_SCRIPT_NAME
633
+ preflight_process = subprocess.run(
634
+ [
635
+ sys.executable,
636
+ str(preflight_script),
637
+ OWNER_ARG_FLAG,
638
+ owner,
639
+ REPO_ARG_FLAG,
640
+ repo,
641
+ MODE_ARG_FLAG,
642
+ MODE_STRICT,
643
+ ],
644
+ cwd=str(cwd_path),
645
+ check=False,
646
+ capture_output=True,
647
+ text=True,
648
+ )
649
+ if preflight_process.returncode == 0:
650
+ return None, EXIT_SUCCESS
651
+ return (
652
+ _error_payload(
653
+ preflight_process.stdout.strip()
654
+ or preflight_process.stderr.strip()
655
+ or "preflight failed",
656
+ blocker=BLOCKER_PREFLIGHT,
657
+ ),
658
+ EXIT_CONTRACT_ERROR,
659
+ )
660
+
661
+
662
+ def _read_git_head(
663
+ cwd_path: Path,
664
+ ) -> tuple[str | None, dict[str, object] | None, int]:
665
+ head_process = subprocess.run(
666
+ list(ALL_GIT_REV_PARSE_HEAD_ARGV),
667
+ cwd=str(cwd_path),
668
+ check=False,
669
+ capture_output=True,
670
+ text=True,
671
+ )
672
+ if head_process.returncode != 0:
673
+ return (
674
+ None,
675
+ _error_payload(
676
+ "git rev-parse HEAD failed", blocker=BLOCKER_PREFLIGHT
677
+ ),
678
+ EXIT_CONTRACT_ERROR,
679
+ )
680
+ return head_process.stdout.strip(), None, EXIT_SUCCESS
681
+
682
+
683
+ def _seed_open_run_task_list_and_finish(
684
+ *,
685
+ current_head: str,
686
+ entry_skill: str,
687
+ is_copilot_down: bool,
688
+ is_bugbot_down: bool,
689
+ is_codex_down: bool,
690
+ is_codex_required: bool,
691
+ owner: str,
692
+ repo: str,
693
+ pr_number: int,
694
+ state_file: Path,
695
+ cwd_path: Path,
696
+ session_model: str,
697
+ ) -> tuple[dict[str, object], int]:
698
+ all_task_list = build_converge_task_list(
699
+ is_bugbot_down=is_bugbot_down,
700
+ is_copilot_down=is_copilot_down,
701
+ is_codex_down=is_codex_down,
702
+ is_codex_required=is_codex_required,
703
+ )
704
+ all_state = build_initial_state(
705
+ current_head=current_head,
706
+ entry_skill=entry_skill,
707
+ is_copilot_down=is_copilot_down,
708
+ is_bugbot_down=is_bugbot_down,
709
+ is_codex_down=is_codex_down,
710
+ is_codex_required=is_codex_required,
711
+ owner=owner,
712
+ repo=repo,
713
+ pr_number=pr_number,
714
+ session_model=session_model,
715
+ cwd_path=str(cwd_path),
716
+ )
717
+ all_state["tasks"] = all_task_list["tasks"]
718
+ all_state["final_task_id"] = all_task_list["final_task_id"]
719
+ all_state["runnable_review_ids"] = all_task_list["runnable_review_ids"]
720
+ all_state["done_when"] = all_task_list["done_when"]
721
+ return _finish_ok(
722
+ all_state,
723
+ state_file,
724
+ next_action=NEXT_RUN_CODE_REVIEW,
725
+ all_commands=_code_review_commands(
726
+ cwd_path=cwd_path, session_model=session_model
727
+ ),
728
+ wait_seconds=None,
729
+ blocker=None,
730
+ all_extra_fields=all_task_list,
731
+ )
732
+
733
+
734
+ def run_open_run(
735
+ *,
736
+ entry_skill: str,
737
+ has_workflow: bool,
738
+ has_schedule_wakeup: bool,
739
+ owner: str,
740
+ repo: str,
741
+ pr_number: int,
742
+ cwd_path: Path,
743
+ state_dir: Path,
744
+ session_model: str,
745
+ is_copilot_down: bool,
746
+ is_bugbot_down: bool,
747
+ is_codex_down: bool = False,
748
+ is_codex_required: bool = False,
749
+ ) -> tuple[dict[str, object], int]:
750
+ """Select pacer, require portable, preflight worktree, seed state + tasks.
751
+
752
+ Args:
753
+ entry_skill: Converge entry skill name.
754
+ has_workflow: Host exposes Workflow.
755
+ has_schedule_wakeup: Host exposes ScheduleWakeup.
756
+ owner: Repository owner.
757
+ repo: Repository name.
758
+ pr_number: Pull request number.
759
+ cwd_path: PR worktree path.
760
+ state_dir: Directory for ``pr-converge-state.json``.
761
+ session_model: Alias passed to invoke_code_review.
762
+ is_copilot_down: Skip Copilot as a runnable review.
763
+ is_bugbot_down: Skip Bugbot as a runnable review.
764
+ is_codex_down: Skip Codex as a runnable review.
765
+ is_codex_required: Codex is required by usage/policy for this run.
766
+
767
+ Returns:
768
+ Payload and process exit code. Payload includes the scripted task list
769
+ from ``build_converge_task_list.py``; final task is all runnable reviews
770
+ CLEAN on the same HEAD.
771
+ """
772
+ selection = select_converge_pacer(
773
+ entry_skill=entry_skill,
774
+ has_workflow=has_workflow,
775
+ has_schedule_wakeup=has_schedule_wakeup,
776
+ )
777
+ if selection.pacer != PACER_PORTABLE:
778
+ return (
779
+ _error_payload(
780
+ "open-run is only for pacer=portable",
781
+ blocker=BLOCKER_NOT_PORTABLE,
782
+ pacer=selection.pacer,
783
+ ),
784
+ EXIT_CONTRACT_ERROR,
785
+ )
786
+ maybe_error, preflight_exit = _run_preflight(
787
+ cwd_path=cwd_path, owner=owner, repo=repo
788
+ )
789
+ if maybe_error is not None:
790
+ return maybe_error, preflight_exit
791
+ current_head, maybe_head_error, head_exit = _read_git_head(cwd_path)
792
+ if maybe_head_error is not None or current_head is None:
793
+ return maybe_head_error or _error_payload(
794
+ "git rev-parse HEAD failed", blocker=BLOCKER_PREFLIGHT
795
+ ), head_exit
796
+ state_dir.mkdir(parents=True, exist_ok=True)
797
+ resolved_codex_required = resolve_codex_required_flag(
798
+ is_codex_down=is_codex_down,
799
+ is_codex_required=is_codex_required,
800
+ )
801
+ return _seed_open_run_task_list_and_finish(
802
+ current_head=current_head,
803
+ entry_skill=selection.entry_skill,
804
+ is_copilot_down=is_copilot_down,
805
+ is_bugbot_down=is_bugbot_down,
806
+ is_codex_down=is_codex_down,
807
+ is_codex_required=resolved_codex_required,
808
+ owner=owner,
809
+ repo=repo,
810
+ pr_number=pr_number,
811
+ state_file=state_dir / STATE_FILENAME,
812
+ cwd_path=cwd_path,
813
+ session_model=session_model,
814
+ )
815
+
816
+
817
+ def _is_successful_serve(*, returncode: int) -> bool:
818
+ """Return True when code-review completed a successful serve.
819
+
820
+ Args:
821
+ returncode: Helper process exit code.
822
+
823
+ Returns:
824
+ Whether the serve counts as successful for phase advance.
825
+ """
826
+ return returncode == 0
827
+
828
+
829
+ def run_after_code_review(
830
+ *,
831
+ state_file: Path,
832
+ returncode: int,
833
+ is_dirty_tree: bool,
834
+ served_command: str,
835
+ current_head: str,
836
+ ) -> tuple[dict[str, object], int]:
837
+ """Advance state from a code-review helper result.
838
+
839
+ Args:
840
+ state_file: Loop state path.
841
+ returncode: Helper process exit code.
842
+ is_dirty_tree: Working tree dirty after review.
843
+ served_command: Informational only (empty, ``in_session``, or chain
844
+ argv). Success is decided solely by ``returncode == 0``.
845
+ current_head: SHA after the review step.
846
+
847
+ Returns:
848
+ Payload and process exit code.
849
+ """
850
+ all_state = load_state(state_file)
851
+ _maybe_reset_on_head_change(all_state, current_head)
852
+ all_state["tick_count"] = int(all_state.get("tick_count") or 0) + 1
853
+
854
+ del served_command
855
+ is_successful_serve = _is_successful_serve(returncode=returncode)
856
+ if not is_successful_serve:
857
+ all_state["phase"] = PHASE_CODE_REVIEW
858
+ all_state["blocker"] = BLOCKER_REVIEW_FAILED
859
+ return _finish_ok(
860
+ all_state,
861
+ state_file,
862
+ next_action=NEXT_RUN_CODE_REVIEW,
863
+ all_commands=_EMPTY_COMMANDS,
864
+ wait_seconds=None,
865
+ blocker=BLOCKER_REVIEW_FAILED,
866
+ all_extra_fields=None,
867
+ )
868
+
869
+ if is_dirty_tree:
870
+ all_state["phase"] = PHASE_CODE_REVIEW
871
+ all_state["code_review_clean_at"] = None
872
+ return _finish_ok(
873
+ all_state,
874
+ state_file,
875
+ next_action=NEXT_APPLY_FIXES,
876
+ all_commands=_EMPTY_COMMANDS,
877
+ wait_seconds=None,
878
+ blocker=None,
879
+ all_extra_fields=None,
880
+ )
881
+
882
+ all_state["code_review_clean_at"] = current_head
883
+ all_state["phase"] = PHASE_BUGTEAM
884
+ all_state["blocker"] = None
885
+ return _finish_ok(
886
+ all_state,
887
+ state_file,
888
+ next_action=NEXT_RUN_BUGTEAM,
889
+ all_commands=_EMPTY_COMMANDS,
890
+ wait_seconds=None,
891
+ blocker=None,
892
+ all_extra_fields=None,
893
+ )
894
+
895
+
896
+ def _needs_codex_step(all_state: Mapping[str, object]) -> bool:
897
+ if all_state.get(STATE_KEY_CODEX_DOWN):
898
+ return False
899
+ current_head = all_state.get("current_head")
900
+ if (
901
+ isinstance(current_head, str)
902
+ and all_state.get(STATE_KEY_CODEX_CLEAN_AT) == current_head
903
+ ):
904
+ return False
905
+ if all_state.get(STATE_KEY_CODEX_REQUIRED):
906
+ return True
907
+ return probe_weekly_usage_requires_codex()
908
+
909
+
910
+ def _advance_after_codex_resolved(
911
+ all_state: dict[str, object],
912
+ state_file: Path,
913
+ ) -> tuple[dict[str, object], int]:
914
+ if all_state.get("copilot_down"):
915
+ all_state["phase"] = PHASE_READY
916
+ return _finish_ok(
917
+ all_state,
918
+ state_file,
919
+ next_action=NEXT_CHECK_READY,
920
+ all_commands=_check_convergence_commands_from_state(all_state),
921
+ wait_seconds=None,
922
+ blocker=None,
923
+ all_extra_fields=None,
924
+ )
925
+ all_state["phase"] = PHASE_COPILOT_WAIT
926
+ return _finish_ok(
927
+ all_state,
928
+ state_file,
929
+ next_action=NEXT_REQUEST_COPILOT,
930
+ all_commands=_EMPTY_COMMANDS,
931
+ wait_seconds=None,
932
+ blocker=None,
933
+ all_extra_fields=None,
934
+ )
935
+
936
+
937
+ def _advance_after_bugbot_resolved(
938
+ all_state: dict[str, object],
939
+ state_file: Path,
940
+ ) -> tuple[dict[str, object], int]:
941
+ _ensure_codex_required_matches_usage(all_state)
942
+ if _needs_codex_step(all_state):
943
+ all_state["phase"] = PHASE_BUGBOT
944
+ return _finish_ok(
945
+ all_state,
946
+ state_file,
947
+ next_action=NEXT_RUN_CODEX,
948
+ all_commands=_EMPTY_COMMANDS,
949
+ wait_seconds=None,
950
+ blocker=None,
951
+ all_extra_fields=None,
952
+ )
953
+ return _advance_after_codex_resolved(all_state, state_file)
954
+
955
+
956
+ def _after_bugteam_converged(
957
+ all_state: dict[str, object],
958
+ state_file: Path,
959
+ current_head: str,
960
+ ) -> tuple[dict[str, object], int]:
961
+ all_state["bugteam_clean_at"] = current_head
962
+ if not all_state.get("bugbot_down"):
963
+ all_state["phase"] = PHASE_BUGBOT
964
+ return _finish_ok(
965
+ all_state,
966
+ state_file,
967
+ next_action=NEXT_RUN_BUGBOT_GATE,
968
+ all_commands=_EMPTY_COMMANDS,
969
+ wait_seconds=None,
970
+ blocker=None,
971
+ all_extra_fields=None,
972
+ )
973
+ return _advance_after_bugbot_resolved(all_state, state_file)
974
+
975
+
976
+ def run_after_bugteam(
977
+ *,
978
+ state_file: Path,
979
+ is_pushed: bool,
980
+ is_converged: bool,
981
+ current_head: str,
982
+ ) -> tuple[dict[str, object], int]:
983
+ """Advance state from a bugteam outcome.
984
+
985
+ Args:
986
+ state_file: Loop state path.
987
+ is_pushed: Bugteam pushed commits.
988
+ is_converged: Bugteam reported clean with no push.
989
+ current_head: SHA after bugteam.
990
+
991
+ Returns:
992
+ Payload and process exit code.
993
+ """
994
+ all_state = load_state(state_file)
995
+ _maybe_reset_on_head_change(all_state, current_head)
996
+ all_state["tick_count"] = int(all_state.get("tick_count") or 0) + 1
997
+
998
+ if is_pushed:
999
+ _reset_push_invalidated_markers(all_state)
1000
+ _sync_task_list_from_down_flags(all_state)
1001
+ all_state["phase"] = PHASE_CODE_REVIEW
1002
+ return _finish_ok(
1003
+ all_state,
1004
+ state_file,
1005
+ next_action=NEXT_RUN_CODE_REVIEW,
1006
+ all_commands=_EMPTY_COMMANDS,
1007
+ wait_seconds=None,
1008
+ blocker=None,
1009
+ all_extra_fields=None,
1010
+ )
1011
+
1012
+ if is_converged:
1013
+ return _after_bugteam_converged(all_state, state_file, current_head)
1014
+
1015
+ all_state["phase"] = PHASE_CODE_REVIEW
1016
+ return _finish_ok(
1017
+ all_state,
1018
+ state_file,
1019
+ next_action=NEXT_APPLY_FIXES,
1020
+ all_commands=_EMPTY_COMMANDS,
1021
+ wait_seconds=None,
1022
+ blocker=None,
1023
+ all_extra_fields=None,
1024
+ )
1025
+
1026
+
1027
+ def _after_bugbot_down(
1028
+ all_state: dict[str, object],
1029
+ state_file: Path,
1030
+ ) -> tuple[dict[str, object], int]:
1031
+ all_state["bugbot_down"] = True
1032
+ _sync_task_list_from_down_flags(all_state)
1033
+ return _advance_after_bugbot_resolved(all_state, state_file)
1034
+
1035
+
1036
+ def _after_bugbot_dirty(
1037
+ all_state: dict[str, object],
1038
+ state_file: Path,
1039
+ *,
1040
+ is_inline_lag: bool,
1041
+ ) -> tuple[dict[str, object], int]:
1042
+ if not is_inline_lag:
1043
+ all_state["inline_lag_streak"] = 0
1044
+ all_state["phase"] = PHASE_CODE_REVIEW
1045
+ return _finish_ok(
1046
+ all_state,
1047
+ state_file,
1048
+ next_action=NEXT_APPLY_FIXES,
1049
+ all_commands=_EMPTY_COMMANDS,
1050
+ wait_seconds=None,
1051
+ blocker=None,
1052
+ all_extra_fields=None,
1053
+ )
1054
+ streak = int(all_state.get("inline_lag_streak") or 0) + 1
1055
+ all_state["inline_lag_streak"] = streak
1056
+ if streak >= INLINE_LAG_STREAK_CAP:
1057
+ all_state["phase"] = PHASE_BLOCKED
1058
+ all_state["blocker"] = BLOCKER_INLINE_LAG_CAP
1059
+ return _finish_ok(
1060
+ all_state,
1061
+ state_file,
1062
+ next_action=NEXT_STOP_BLOCKED,
1063
+ all_commands=_EMPTY_COMMANDS,
1064
+ wait_seconds=None,
1065
+ blocker=BLOCKER_INLINE_LAG_CAP,
1066
+ all_extra_fields=None,
1067
+ )
1068
+ all_state["phase"] = PHASE_BUGBOT
1069
+ return _finish_ok(
1070
+ all_state,
1071
+ state_file,
1072
+ next_action=NEXT_POLL_WAIT,
1073
+ all_commands=_EMPTY_COMMANDS,
1074
+ wait_seconds=BUGBOT_INLINE_LAG_WAIT_SECONDS,
1075
+ blocker=None,
1076
+ all_extra_fields=None,
1077
+ )
1078
+
1079
+
1080
+ def _after_bugbot_clean(
1081
+ all_state: dict[str, object],
1082
+ state_file: Path,
1083
+ current_head: str,
1084
+ ) -> tuple[dict[str, object], int]:
1085
+ all_state["bugbot_clean_at"] = current_head
1086
+ all_state["inline_lag_streak"] = 0
1087
+ return _advance_after_bugbot_resolved(all_state, state_file)
1088
+
1089
+
1090
+ def run_after_codex(
1091
+ *,
1092
+ state_file: Path,
1093
+ classification: str,
1094
+ current_head: str,
1095
+ ) -> tuple[dict[str, object], int]:
1096
+ """Advance state from a Codex review step outcome.
1097
+
1098
+ Args:
1099
+ state_file: Loop state path.
1100
+ classification: clean, dirty, or down.
1101
+ current_head: Current PR head SHA.
1102
+
1103
+ Returns:
1104
+ Payload and process exit code.
1105
+ """
1106
+ if classification not in ALL_CODEX_CLASSIFICATIONS:
1107
+ return (
1108
+ _error_payload(f"unknown classification: {classification!r}"),
1109
+ EXIT_USAGE_ERROR,
1110
+ )
1111
+ all_state = load_state(state_file)
1112
+ _maybe_reset_on_head_change(all_state, current_head)
1113
+ all_state["tick_count"] = int(all_state.get("tick_count") or 0) + 1
1114
+
1115
+ if classification == CLASSIFICATION_DIRTY:
1116
+ _reset_push_invalidated_markers(all_state)
1117
+ _sync_task_list_from_down_flags(all_state)
1118
+ all_state["phase"] = PHASE_CODE_REVIEW
1119
+ return _finish_ok(
1120
+ all_state,
1121
+ state_file,
1122
+ next_action=NEXT_APPLY_FIXES,
1123
+ all_commands=_EMPTY_COMMANDS,
1124
+ wait_seconds=None,
1125
+ blocker=None,
1126
+ all_extra_fields=None,
1127
+ )
1128
+
1129
+ if classification == CLASSIFICATION_DOWN:
1130
+ all_state[STATE_KEY_CODEX_DOWN] = True
1131
+ _sync_task_list_from_down_flags(all_state)
1132
+ return _advance_after_codex_resolved(all_state, state_file)
1133
+
1134
+ all_state[STATE_KEY_CODEX_CLEAN_AT] = current_head
1135
+ return _advance_after_codex_resolved(all_state, state_file)
1136
+
1137
+
1138
+ def run_after_bugbot(
1139
+ *,
1140
+ state_file: Path,
1141
+ classification: str,
1142
+ current_head: str,
1143
+ is_inline_lag: bool,
1144
+ ) -> tuple[dict[str, object], int]:
1145
+ """Advance state from a Bugbot terminal-gate outcome.
1146
+
1147
+ Args:
1148
+ state_file: Loop state path.
1149
+ classification: clean, dirty, absent, or down.
1150
+ current_head: Current PR head SHA.
1151
+ is_inline_lag: Body dirty but zero inline findings.
1152
+
1153
+ Returns:
1154
+ Payload and process exit code.
1155
+ """
1156
+ if classification not in ALL_CLASSIFICATIONS:
1157
+ return (
1158
+ _error_payload(f"unknown classification: {classification!r}"),
1159
+ EXIT_USAGE_ERROR,
1160
+ )
1161
+ all_state = load_state(state_file)
1162
+ _maybe_reset_on_head_change(all_state, current_head)
1163
+ all_state["tick_count"] = int(all_state.get("tick_count") or 0) + 1
1164
+
1165
+ if classification == CLASSIFICATION_DOWN:
1166
+ return _after_bugbot_down(all_state, state_file)
1167
+ if classification == CLASSIFICATION_DIRTY:
1168
+ return _after_bugbot_dirty(
1169
+ all_state, state_file, is_inline_lag=is_inline_lag
1170
+ )
1171
+ if classification == CLASSIFICATION_ABSENT:
1172
+ wait_count = int(all_state.get("bugbot_wait_count") or 0) + 1
1173
+ all_state["bugbot_wait_count"] = wait_count
1174
+ if wait_count >= BUGBOT_ABSENT_WAIT_HARD_CAP:
1175
+ all_state["phase"] = PHASE_BLOCKED
1176
+ all_state["blocker"] = BLOCKER_BUGBOT_WAIT_CAP
1177
+ return _finish_ok(
1178
+ all_state,
1179
+ state_file,
1180
+ next_action=NEXT_STOP_BLOCKED,
1181
+ all_commands=_EMPTY_COMMANDS,
1182
+ wait_seconds=None,
1183
+ blocker=BLOCKER_BUGBOT_WAIT_CAP,
1184
+ all_extra_fields=None,
1185
+ )
1186
+ all_state["phase"] = PHASE_BUGBOT
1187
+ return _finish_ok(
1188
+ all_state,
1189
+ state_file,
1190
+ next_action=NEXT_POLL_WAIT,
1191
+ all_commands=_EMPTY_COMMANDS,
1192
+ wait_seconds=DEFAULT_WAIT_SECONDS,
1193
+ blocker=None,
1194
+ all_extra_fields=None,
1195
+ )
1196
+ return _after_bugbot_clean(all_state, state_file, current_head)
1197
+
1198
+
1199
+ def _after_copilot_not_surfaced(
1200
+ all_state: dict[str, object],
1201
+ state_file: Path,
1202
+ ) -> tuple[dict[str, object], int]:
1203
+ wait_count = int(all_state.get("copilot_wait_count") or 0) + 1
1204
+ all_state["copilot_wait_count"] = wait_count
1205
+ if wait_count >= COPILOT_WAIT_HARD_CAP:
1206
+ all_state["phase"] = PHASE_BLOCKED
1207
+ all_state["blocker"] = BLOCKER_COPILOT_WAIT_CAP
1208
+ return _finish_ok(
1209
+ all_state,
1210
+ state_file,
1211
+ next_action=NEXT_STOP_BLOCKED,
1212
+ all_commands=_EMPTY_COMMANDS,
1213
+ wait_seconds=None,
1214
+ blocker=BLOCKER_COPILOT_WAIT_CAP,
1215
+ all_extra_fields=None,
1216
+ )
1217
+ all_state["phase"] = PHASE_COPILOT_WAIT
1218
+ return _finish_ok(
1219
+ all_state,
1220
+ state_file,
1221
+ next_action=NEXT_POLL_WAIT,
1222
+ all_commands=_EMPTY_COMMANDS,
1223
+ wait_seconds=DEFAULT_WAIT_SECONDS,
1224
+ blocker=None,
1225
+ all_extra_fields=None,
1226
+ )
1227
+
1228
+
1229
+ def _after_copilot_clean(
1230
+ all_state: dict[str, object],
1231
+ state_file: Path,
1232
+ current_head: str,
1233
+ ) -> tuple[dict[str, object], int]:
1234
+ all_state["copilot_clean_at"] = current_head
1235
+ all_state["phase"] = PHASE_READY
1236
+ return _finish_ok(
1237
+ all_state,
1238
+ state_file,
1239
+ next_action=NEXT_CHECK_READY,
1240
+ all_commands=_check_convergence_commands_from_state(all_state),
1241
+ wait_seconds=None,
1242
+ blocker=None,
1243
+ all_extra_fields=None,
1244
+ )
1245
+
1246
+
1247
+ def _after_copilot_down(
1248
+ all_state: dict[str, object],
1249
+ state_file: Path,
1250
+ ) -> tuple[dict[str, object], int]:
1251
+ all_state["copilot_down"] = True
1252
+ _sync_task_list_from_down_flags(all_state)
1253
+ all_state["phase"] = PHASE_READY
1254
+ return _finish_ok(
1255
+ all_state,
1256
+ state_file,
1257
+ next_action=NEXT_CHECK_READY,
1258
+ all_commands=_check_convergence_commands_from_state(all_state),
1259
+ wait_seconds=None,
1260
+ blocker=None,
1261
+ all_extra_fields=None,
1262
+ )
1263
+
1264
+
1265
+ def _after_copilot_surfaced(
1266
+ all_state: dict[str, object],
1267
+ state_file: Path,
1268
+ *,
1269
+ classification: str,
1270
+ current_head: str,
1271
+ ) -> tuple[dict[str, object], int]:
1272
+ if classification not in ALL_COPILOT_CLASSIFICATIONS:
1273
+ return (
1274
+ _error_payload(f"unknown classification: {classification!r}"),
1275
+ EXIT_USAGE_ERROR,
1276
+ )
1277
+ if classification == CLASSIFICATION_DIRTY:
1278
+ all_state["copilot_wait_count"] = 0
1279
+ all_state["phase"] = PHASE_CODE_REVIEW
1280
+ return _finish_ok(
1281
+ all_state,
1282
+ state_file,
1283
+ next_action=NEXT_APPLY_FIXES,
1284
+ all_commands=_EMPTY_COMMANDS,
1285
+ wait_seconds=None,
1286
+ blocker=None,
1287
+ all_extra_fields=None,
1288
+ )
1289
+ if classification == CLASSIFICATION_ABSENT:
1290
+ return _after_copilot_not_surfaced(all_state, state_file)
1291
+ if classification == CLASSIFICATION_DOWN:
1292
+ return _after_copilot_down(all_state, state_file)
1293
+ return _after_copilot_clean(all_state, state_file, current_head)
1294
+
1295
+
1296
+ def run_after_copilot_wait(
1297
+ *,
1298
+ state_file: Path,
1299
+ is_review_surfaced: bool,
1300
+ classification: str,
1301
+ current_head: str,
1302
+ ) -> tuple[dict[str, object], int]:
1303
+ """Advance state from a Copilot wait poll.
1304
+
1305
+ Args:
1306
+ state_file: Loop state path.
1307
+ is_review_surfaced: A Copilot review exists at current_head.
1308
+ classification: clean, dirty, absent, or down when surfaced.
1309
+ current_head: Current PR head SHA.
1310
+
1311
+ Returns:
1312
+ Payload and process exit code.
1313
+ """
1314
+ all_state = load_state(state_file)
1315
+ _maybe_reset_on_head_change(all_state, current_head)
1316
+ all_state["tick_count"] = int(all_state.get("tick_count") or 0) + 1
1317
+ if not is_review_surfaced:
1318
+ return _after_copilot_not_surfaced(all_state, state_file)
1319
+ return _after_copilot_surfaced(
1320
+ all_state,
1321
+ state_file,
1322
+ classification=classification,
1323
+ current_head=current_head,
1324
+ )
1325
+
1326
+
1327
+ def run_after_ready_check(
1328
+ *,
1329
+ state_file: Path,
1330
+ check_exit: int,
1331
+ current_head: str,
1332
+ ) -> tuple[dict[str, object], int]:
1333
+ """Advance state from check_convergence exit code.
1334
+
1335
+ Args:
1336
+ state_file: Loop state path.
1337
+ check_exit: Process exit from check_convergence.py.
1338
+ current_head: Current PR head SHA.
1339
+
1340
+ Returns:
1341
+ Payload and process exit code.
1342
+ """
1343
+ all_state = load_state(state_file)
1344
+ _maybe_reset_on_head_change(all_state, current_head)
1345
+ all_state["tick_count"] = int(all_state.get("tick_count") or 0) + 1
1346
+ if check_exit == 0:
1347
+ all_state["phase"] = PHASE_READY
1348
+ all_state["blocker"] = None
1349
+ return _finish_ok(
1350
+ all_state,
1351
+ state_file,
1352
+ next_action=NEXT_MARK_READY,
1353
+ all_commands=_mark_ready_commands(all_state),
1354
+ wait_seconds=None,
1355
+ blocker=None,
1356
+ all_extra_fields=None,
1357
+ )
1358
+
1359
+ if check_exit == 1:
1360
+ _ensure_codex_required_matches_usage(all_state)
1361
+ if _needs_codex_step(all_state):
1362
+ all_state["phase"] = PHASE_BUGBOT
1363
+ return _finish_ok(
1364
+ all_state,
1365
+ state_file,
1366
+ next_action=NEXT_RUN_CODEX,
1367
+ all_commands=_EMPTY_COMMANDS,
1368
+ wait_seconds=None,
1369
+ blocker=None,
1370
+ all_extra_fields=None,
1371
+ )
1372
+ all_state["phase"] = PHASE_CODE_REVIEW
1373
+ return _finish_ok(
1374
+ all_state,
1375
+ state_file,
1376
+ next_action=NEXT_APPLY_FIXES,
1377
+ all_commands=_EMPTY_COMMANDS,
1378
+ wait_seconds=None,
1379
+ blocker=None,
1380
+ all_extra_fields=None,
1381
+ )
1382
+
1383
+ all_state["phase"] = PHASE_BLOCKED
1384
+ all_state["blocker"] = f"check_convergence_exit_{check_exit}"
1385
+ return _finish_ok(
1386
+ all_state,
1387
+ state_file,
1388
+ next_action=NEXT_STOP_BLOCKED,
1389
+ all_commands=_EMPTY_COMMANDS,
1390
+ wait_seconds=None,
1391
+ blocker=str(all_state["blocker"]),
1392
+ all_extra_fields=None,
1393
+ )
1394
+
1395
+
1396
+ def _register_open_run_arguments(
1397
+ open_run_parser: argparse.ArgumentParser,
1398
+ ) -> None:
1399
+ open_run_parser.add_argument(CLI_SKILL_FLAG, required=True)
1400
+ open_run_parser.add_argument(CLI_HAS_WORKFLOW_FLAG, required=True)
1401
+ open_run_parser.add_argument(CLI_HAS_SCHEDULE_WAKEUP_FLAG, required=True)
1402
+ open_run_parser.add_argument(CLI_OWNER_FLAG, required=True)
1403
+ open_run_parser.add_argument(CLI_REPO_FLAG, required=True)
1404
+ open_run_parser.add_argument(CLI_PR_NUMBER_FLAG, required=True, type=int)
1405
+ open_run_parser.add_argument(CLI_CWD_FLAG, required=True)
1406
+ open_run_parser.add_argument(CLI_STATE_DIR_FLAG, required=True)
1407
+ open_run_parser.add_argument(
1408
+ CLI_SESSION_MODEL_FLAG, default=DEFAULT_SESSION_MODEL
1409
+ )
1410
+ open_run_parser.add_argument(CLI_COPILOT_DOWN_FLAG, default="0")
1411
+ open_run_parser.add_argument(CLI_BUGBOT_DOWN_FLAG, default="0")
1412
+ open_run_parser.add_argument(CLI_CODEX_DOWN_FLAG, default="0")
1413
+ open_run_parser.add_argument(CLI_CODEX_REQUIRED_FLAG, default="0")
1414
+
1415
+
1416
+ def build_argument_parser() -> argparse.ArgumentParser:
1417
+ """Build the multi-command CLI parser.
1418
+
1419
+ Returns:
1420
+ Configured argument parser.
1421
+ """
1422
+ parser = argparse.ArgumentParser(description=CLI_DESCRIPTION)
1423
+ all_subparsers = parser.add_subparsers(dest="command", required=True)
1424
+
1425
+ open_run_parser = all_subparsers.add_parser(COMMAND_OPEN_RUN)
1426
+ _register_open_run_arguments(open_run_parser)
1427
+
1428
+ after_review_parser = all_subparsers.add_parser(COMMAND_AFTER_CODE_REVIEW)
1429
+ after_review_parser.add_argument(CLI_STATE_FILE_FLAG, required=True)
1430
+ after_review_parser.add_argument(
1431
+ CLI_RETURNCODE_FLAG, required=True, type=int
1432
+ )
1433
+ after_review_parser.add_argument(CLI_DIRTY_TREE_FLAG, required=True)
1434
+ after_review_parser.add_argument(CLI_SERVED_COMMAND_FLAG, default="")
1435
+ after_review_parser.add_argument(CLI_CURRENT_HEAD_FLAG, required=True)
1436
+
1437
+ after_bugteam_parser = all_subparsers.add_parser(COMMAND_AFTER_BUGTEAM)
1438
+ after_bugteam_parser.add_argument(CLI_STATE_FILE_FLAG, required=True)
1439
+ after_bugteam_parser.add_argument(CLI_PUSHED_FLAG, required=True)
1440
+ after_bugteam_parser.add_argument(CLI_CONVERGED_FLAG, required=True)
1441
+ after_bugteam_parser.add_argument(CLI_CURRENT_HEAD_FLAG, required=True)
1442
+
1443
+ after_bugbot_parser = all_subparsers.add_parser(COMMAND_AFTER_BUGBOT)
1444
+ after_bugbot_parser.add_argument(CLI_STATE_FILE_FLAG, required=True)
1445
+ after_bugbot_parser.add_argument(CLI_CLASSIFICATION_FLAG, required=True)
1446
+ after_bugbot_parser.add_argument(CLI_CURRENT_HEAD_FLAG, required=True)
1447
+ after_bugbot_parser.add_argument(CLI_INLINE_LAG_FLAG, default="0")
1448
+
1449
+ after_codex_parser = all_subparsers.add_parser(COMMAND_AFTER_CODEX)
1450
+ after_codex_parser.add_argument(CLI_STATE_FILE_FLAG, required=True)
1451
+ after_codex_parser.add_argument(CLI_CLASSIFICATION_FLAG, required=True)
1452
+ after_codex_parser.add_argument(CLI_CURRENT_HEAD_FLAG, required=True)
1453
+
1454
+ after_copilot_parser = all_subparsers.add_parser(COMMAND_AFTER_COPILOT_WAIT)
1455
+ after_copilot_parser.add_argument(CLI_STATE_FILE_FLAG, required=True)
1456
+ after_copilot_parser.add_argument(CLI_REVIEW_SURFACED_FLAG, required=True)
1457
+ after_copilot_parser.add_argument(
1458
+ CLI_CLASSIFICATION_FLAG, default="absent"
1459
+ )
1460
+ after_copilot_parser.add_argument(CLI_CURRENT_HEAD_FLAG, required=True)
1461
+
1462
+ after_ready_parser = all_subparsers.add_parser(COMMAND_AFTER_READY_CHECK)
1463
+ after_ready_parser.add_argument(CLI_STATE_FILE_FLAG, required=True)
1464
+ after_ready_parser.add_argument(
1465
+ CLI_CHECK_CONVERGENCE_EXIT_FLAG, required=True, type=int
1466
+ )
1467
+ after_ready_parser.add_argument(CLI_CURRENT_HEAD_FLAG, required=True)
1468
+
1469
+ show_parser = all_subparsers.add_parser(COMMAND_SHOW_STATE)
1470
+ show_parser.add_argument(CLI_STATE_FILE_FLAG, required=True)
1471
+
1472
+ return parser
1473
+
1474
+
1475
+ def _dispatch_open_run(
1476
+ parsed_arguments: argparse.Namespace,
1477
+ ) -> tuple[dict[str, object], int]:
1478
+ return run_open_run(
1479
+ entry_skill=parsed_arguments.skill,
1480
+ has_workflow=parse_bool_flag(parsed_arguments.has_workflow),
1481
+ has_schedule_wakeup=parse_bool_flag(
1482
+ parsed_arguments.has_schedule_wakeup
1483
+ ),
1484
+ owner=parsed_arguments.owner,
1485
+ repo=parsed_arguments.repo,
1486
+ pr_number=parsed_arguments.pr_number,
1487
+ cwd_path=Path(parsed_arguments.cwd),
1488
+ state_dir=Path(parsed_arguments.state_dir),
1489
+ session_model=parsed_arguments.session_model,
1490
+ is_copilot_down=parse_bool_flag(parsed_arguments.copilot_down),
1491
+ is_bugbot_down=parse_bool_flag(parsed_arguments.bugbot_down),
1492
+ is_codex_down=parse_bool_flag(parsed_arguments.codex_down),
1493
+ is_codex_required=parse_bool_flag(parsed_arguments.codex_required),
1494
+ )
1495
+
1496
+
1497
+ def _dispatch_after_code_review(
1498
+ parsed_arguments: argparse.Namespace,
1499
+ ) -> tuple[dict[str, object], int]:
1500
+ return run_after_code_review(
1501
+ state_file=Path(parsed_arguments.state_file),
1502
+ returncode=parsed_arguments.returncode,
1503
+ is_dirty_tree=parse_bool_flag(parsed_arguments.dirty_tree),
1504
+ served_command=parsed_arguments.served_command or "",
1505
+ current_head=parsed_arguments.current_head,
1506
+ )
1507
+
1508
+
1509
+ def _dispatch_after_bugteam(
1510
+ parsed_arguments: argparse.Namespace,
1511
+ ) -> tuple[dict[str, object], int]:
1512
+ return run_after_bugteam(
1513
+ state_file=Path(parsed_arguments.state_file),
1514
+ is_pushed=parse_bool_flag(parsed_arguments.pushed),
1515
+ is_converged=parse_bool_flag(parsed_arguments.converged),
1516
+ current_head=parsed_arguments.current_head,
1517
+ )
1518
+
1519
+
1520
+ def _dispatch_after_bugbot(
1521
+ parsed_arguments: argparse.Namespace,
1522
+ ) -> tuple[dict[str, object], int]:
1523
+ return run_after_bugbot(
1524
+ state_file=Path(parsed_arguments.state_file),
1525
+ classification=parsed_arguments.classification,
1526
+ current_head=parsed_arguments.current_head,
1527
+ is_inline_lag=parse_bool_flag(parsed_arguments.inline_lag),
1528
+ )
1529
+
1530
+
1531
+ def _dispatch_after_codex(
1532
+ parsed_arguments: argparse.Namespace,
1533
+ ) -> tuple[dict[str, object], int]:
1534
+ return run_after_codex(
1535
+ state_file=Path(parsed_arguments.state_file),
1536
+ classification=parsed_arguments.classification,
1537
+ current_head=parsed_arguments.current_head,
1538
+ )
1539
+
1540
+
1541
+ def _dispatch_after_copilot_wait(
1542
+ parsed_arguments: argparse.Namespace,
1543
+ ) -> tuple[dict[str, object], int]:
1544
+ return run_after_copilot_wait(
1545
+ state_file=Path(parsed_arguments.state_file),
1546
+ is_review_surfaced=parse_bool_flag(parsed_arguments.review_surfaced),
1547
+ classification=parsed_arguments.classification,
1548
+ current_head=parsed_arguments.current_head,
1549
+ )
1550
+
1551
+
1552
+ def _dispatch_after_ready_check(
1553
+ parsed_arguments: argparse.Namespace,
1554
+ ) -> tuple[dict[str, object], int]:
1555
+ return run_after_ready_check(
1556
+ state_file=Path(parsed_arguments.state_file),
1557
+ check_exit=parsed_arguments.check_exit,
1558
+ current_head=parsed_arguments.current_head,
1559
+ )
1560
+
1561
+
1562
+ def _resolve_show_state_next(all_state: Mapping[str, object]) -> str:
1563
+ pending_next = all_state.get(STATE_KEY_PENDING_NEXT)
1564
+ if isinstance(pending_next, str) and pending_next:
1565
+ return pending_next
1566
+ if all_state.get("phase") == PHASE_READY:
1567
+ return NEXT_MARK_READY
1568
+ if all_state.get("phase") == PHASE_BLOCKED:
1569
+ return NEXT_STOP_BLOCKED
1570
+ return str(all_state.get("phase"))
1571
+
1572
+
1573
+ def _dispatch_show_state(
1574
+ parsed_arguments: argparse.Namespace,
1575
+ ) -> tuple[dict[str, object], int]:
1576
+ state_file = Path(parsed_arguments.state_file)
1577
+ all_state = load_state(state_file)
1578
+ all_payload = _base_ok_payload(all_state, state_file)
1579
+ next_action = _resolve_show_state_next(all_state)
1580
+ all_payload[RESULT_KEY_NEXT] = next_action
1581
+ if next_action == NEXT_RUN_CODE_REVIEW:
1582
+ all_payload[RESULT_KEY_COMMANDS] = _code_review_commands_from_state(
1583
+ all_state
1584
+ )
1585
+ elif next_action == NEXT_CHECK_READY:
1586
+ all_payload[RESULT_KEY_COMMANDS] = (
1587
+ _check_convergence_commands_from_state(all_state)
1588
+ )
1589
+ elif next_action == NEXT_MARK_READY:
1590
+ all_payload[RESULT_KEY_COMMANDS] = _mark_ready_commands(all_state)
1591
+ elif next_action == NEXT_POLL_WAIT:
1592
+ all_payload[RESULT_KEY_WAIT_SECONDS] = _resolve_poll_wait_seconds(
1593
+ all_state
1594
+ )
1595
+ return all_payload, EXIT_SUCCESS
1596
+
1597
+
1598
+ def main(all_argv: list[str]) -> int:
1599
+ """CLI entry: print one JSON control payload on stdout.
1600
+
1601
+ Args:
1602
+ all_argv: Argument vector without program name.
1603
+
1604
+ Returns:
1605
+ Process exit code.
1606
+ """
1607
+ parser = build_argument_parser()
1608
+ parsed_arguments = parser.parse_args(all_argv)
1609
+ dispatch_by_command = {
1610
+ COMMAND_OPEN_RUN: _dispatch_open_run,
1611
+ COMMAND_AFTER_CODE_REVIEW: _dispatch_after_code_review,
1612
+ COMMAND_AFTER_BUGTEAM: _dispatch_after_bugteam,
1613
+ COMMAND_AFTER_BUGBOT: _dispatch_after_bugbot,
1614
+ COMMAND_AFTER_CODEX: _dispatch_after_codex,
1615
+ COMMAND_AFTER_COPILOT_WAIT: _dispatch_after_copilot_wait,
1616
+ COMMAND_AFTER_READY_CHECK: _dispatch_after_ready_check,
1617
+ COMMAND_SHOW_STATE: _dispatch_show_state,
1618
+ }
1619
+ try:
1620
+ maybe_dispatch = dispatch_by_command.get(parsed_arguments.command)
1621
+ if maybe_dispatch is None:
1622
+ _emit(
1623
+ _error_payload(
1624
+ f"unknown command: {parsed_arguments.command!r}"
1625
+ )
1626
+ )
1627
+ return EXIT_USAGE_ERROR
1628
+ all_payload, exit_code = maybe_dispatch(parsed_arguments)
1629
+ _emit(all_payload)
1630
+ return exit_code
1631
+ except ValueError as validation_error:
1632
+ _emit(_error_payload(str(validation_error)))
1633
+ return EXIT_USAGE_ERROR
1634
+
1635
+
1636
+ if __name__ == "__main__":
1637
+ raise SystemExit(main(sys.argv[1:]))