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,215 @@
1
+ #!/usr/bin/env python3
2
+ """Select the converge pacer for a skill and host tool surface.
3
+
4
+ Pacer values::
5
+
6
+ workflow — Claude Workflow tool (autoconverge native)
7
+ schedule_wakeup — Claude ScheduleWakeup ticks (pr-converge native)
8
+ portable — continuous in-session driver (third-party hosts)
9
+
10
+ Rules::
11
+
12
+ autoconverge + Workflow present -> workflow
13
+ autoconverge + Workflow absent -> portable
14
+ pr-converge + ScheduleWakeup present -> schedule_wakeup
15
+ pr-converge + ScheduleWakeup absent -> portable
16
+
17
+ Import ``select_converge_pacer`` or run as a CLI::
18
+
19
+ python select_converge_pacer.py --skill <pr-converge|autoconverge> \\
20
+ --has-workflow <0|1> --has-schedule-wakeup <0|1>
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import sys
28
+ from dataclasses import asdict, dataclass
29
+ from pathlib import Path
30
+
31
+ _self_dir = Path(__file__).resolve().parent
32
+ if str(_self_dir) not in sys.path:
33
+ sys.path.insert(0, str(_self_dir))
34
+
35
+ from skills_pr_loop_constants.pacer_constants import ( # noqa: E402
36
+ ALL_ENTRY_SKILLS,
37
+ ALL_FALSY_FLAG_TOKENS,
38
+ ALL_TRUTHY_FLAG_TOKENS,
39
+ CLI_HAS_SCHEDULE_WAKEUP_FLAG,
40
+ CLI_HAS_WORKFLOW_FLAG,
41
+ CLI_SKILL_FLAG,
42
+ ENTRY_SKILL_AUTOCONVERGE,
43
+ ENTRY_SKILL_JOIN_SEPARATOR,
44
+ EXIT_SUCCESS,
45
+ EXIT_USAGE_ERROR,
46
+ INVALID_BOOL_FLAG_ERROR,
47
+ PACER_PORTABLE,
48
+ PACER_SCHEDULE_WAKEUP,
49
+ PACER_WORKFLOW,
50
+ RESULT_KEY_ENTRY_SKILL,
51
+ RESULT_KEY_HAS_SCHEDULE_WAKEUP,
52
+ RESULT_KEY_HAS_WORKFLOW,
53
+ RESULT_KEY_PACER,
54
+ UNKNOWN_ENTRY_SKILL_ERROR,
55
+ )
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ConvergePacerSelection:
60
+ """Result of pacer selection for one skill invocation."""
61
+
62
+ pacer: str
63
+ entry_skill: str
64
+ has_workflow: bool
65
+ has_schedule_wakeup: bool
66
+
67
+
68
+ def parse_bool_flag(flag_token: str) -> bool:
69
+ """Parse a CLI boolean token into a bool.
70
+
71
+ Args:
72
+ flag_token: Token from ``--has-workflow`` or ``--has-schedule-wakeup``.
73
+
74
+ Returns:
75
+ True for truthy tokens, False for falsy tokens.
76
+
77
+ Raises:
78
+ ValueError: When the token is not a known boolean form.
79
+ """
80
+ normalized_token = flag_token.strip().lower()
81
+ if normalized_token in ALL_TRUTHY_FLAG_TOKENS:
82
+ return True
83
+ if normalized_token in ALL_FALSY_FLAG_TOKENS:
84
+ return False
85
+ raise ValueError(INVALID_BOOL_FLAG_ERROR.format(got=flag_token))
86
+
87
+
88
+ def select_converge_pacer(
89
+ *,
90
+ entry_skill: str,
91
+ has_workflow: bool,
92
+ has_schedule_wakeup: bool,
93
+ ) -> ConvergePacerSelection:
94
+ """Return the pacer the named entry skill must use on this host.
95
+
96
+ Args:
97
+ entry_skill: ``pr-converge`` or ``autoconverge``.
98
+ has_workflow: True when the host tool list includes ``Workflow``.
99
+ has_schedule_wakeup: True when the host tool list includes
100
+ ``ScheduleWakeup``.
101
+
102
+ Returns:
103
+ A ``ConvergePacerSelection`` with the chosen ``pacer`` name.
104
+
105
+ Raises:
106
+ ValueError: When ``entry_skill`` is not a known converge entry.
107
+ """
108
+ normalized_skill = entry_skill.strip().lower()
109
+ if normalized_skill not in ALL_ENTRY_SKILLS:
110
+ raise ValueError(
111
+ UNKNOWN_ENTRY_SKILL_ERROR.format(
112
+ allowed=ENTRY_SKILL_JOIN_SEPARATOR.join(ALL_ENTRY_SKILLS),
113
+ got=entry_skill,
114
+ )
115
+ )
116
+
117
+ if normalized_skill == ENTRY_SKILL_AUTOCONVERGE:
118
+ selected_pacer = PACER_WORKFLOW if has_workflow else PACER_PORTABLE
119
+ else:
120
+ selected_pacer = (
121
+ PACER_SCHEDULE_WAKEUP if has_schedule_wakeup else PACER_PORTABLE
122
+ )
123
+
124
+ return ConvergePacerSelection(
125
+ pacer=selected_pacer,
126
+ entry_skill=normalized_skill,
127
+ has_workflow=has_workflow,
128
+ has_schedule_wakeup=has_schedule_wakeup,
129
+ )
130
+
131
+
132
+ def selection_as_json_dict(
133
+ selection: ConvergePacerSelection,
134
+ ) -> dict[str, str | bool]:
135
+ """Serialize a selection for JSON stdout.
136
+
137
+ Args:
138
+ selection: Pacer selection to serialize.
139
+
140
+ Returns:
141
+ Dict with stable result keys for CLI and tests.
142
+ """
143
+ selection_by_field = asdict(selection)
144
+ return {
145
+ RESULT_KEY_PACER: selection_by_field["pacer"],
146
+ RESULT_KEY_ENTRY_SKILL: selection_by_field["entry_skill"],
147
+ RESULT_KEY_HAS_WORKFLOW: selection_by_field["has_workflow"],
148
+ RESULT_KEY_HAS_SCHEDULE_WAKEUP: selection_by_field[
149
+ "has_schedule_wakeup"
150
+ ],
151
+ }
152
+
153
+
154
+ def build_argument_parser() -> argparse.ArgumentParser:
155
+ """Build the CLI parser for pacer selection.
156
+
157
+ Returns:
158
+ Configured argument parser.
159
+ """
160
+ parser = argparse.ArgumentParser(
161
+ description=(
162
+ "Select workflow, schedule_wakeup, or portable pacer for a "
163
+ "converge entry skill."
164
+ )
165
+ )
166
+ parser.add_argument(
167
+ CLI_SKILL_FLAG,
168
+ required=True,
169
+ choices=list(ALL_ENTRY_SKILLS),
170
+ help="Entry skill invoking converge.",
171
+ )
172
+ parser.add_argument(
173
+ CLI_HAS_WORKFLOW_FLAG,
174
+ required=True,
175
+ help="1/true when the host tool list includes Workflow.",
176
+ )
177
+ parser.add_argument(
178
+ CLI_HAS_SCHEDULE_WAKEUP_FLAG,
179
+ required=True,
180
+ help="1/true when the host tool list includes ScheduleWakeup.",
181
+ )
182
+ return parser
183
+
184
+
185
+ def main(all_argv: list[str]) -> int:
186
+ """CLI entry: print one JSON selection line on stdout.
187
+
188
+ Args:
189
+ all_argv: Argument vector (without the program name).
190
+
191
+ Returns:
192
+ Process exit code (0 success, 2 usage/validation error).
193
+ """
194
+ parser = build_argument_parser()
195
+ parsed_arguments = parser.parse_args(all_argv)
196
+ try:
197
+ has_workflow = parse_bool_flag(parsed_arguments.has_workflow)
198
+ has_schedule_wakeup = parse_bool_flag(
199
+ parsed_arguments.has_schedule_wakeup
200
+ )
201
+ selection = select_converge_pacer(
202
+ entry_skill=parsed_arguments.skill,
203
+ has_workflow=has_workflow,
204
+ has_schedule_wakeup=has_schedule_wakeup,
205
+ )
206
+ except ValueError as validation_error:
207
+ print(str(validation_error), file=sys.stderr)
208
+ return EXIT_USAGE_ERROR
209
+
210
+ print(json.dumps(selection_as_json_dict(selection), sort_keys=True))
211
+ return EXIT_SUCCESS
212
+
213
+
214
+ if __name__ == "__main__":
215
+ raise SystemExit(main(sys.argv[1:]))
@@ -10,6 +10,9 @@ Python package of named constants for the `pr-loop` shared scripts. All constant
10
10
  | `path_resolver_constants.py` | Path template strings and format constants: workspace directory naming, worktree directory name, diff patch filename pattern, outcome XML filename patterns, fix status values, audit constraint texts, audit category entries, fix execution steps, and fix constraint texts. |
11
11
  | `preflight_constants.py` | Constants used by `preflight_worktree.py` for exit codes and output marker strings. |
12
12
  | `handoff_constants.py` | Filename, path-segment, and template constants for the durable handoff writer. |
13
+ | `pacer_constants.py` | Entry-skill names, pacer tokens, CLI flags, and result keys for `select_converge_pacer.py`. |
14
+ | `portable_driver_constants.py` | Phase names, next-action tokens, wait delays, blockers, CLI flags, and result keys for `portable_converge_driver.py`. |
15
+ | `converge_task_list_constants.py` | Task ids, kinds, and CLI flags for `build_converge_task_list.py`. |
13
16
 
14
17
  ## Usage
15
18
 
@@ -0,0 +1,56 @@
1
+ """Constants for the scripted converge task-list builder.
2
+
3
+ ``build_converge_task_list.py`` is the only place that decides which review
4
+ gates appear on a run. Skill prose must not invent a different list.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ TASK_ID_CODE_REVIEW: str = "code_review"
10
+ TASK_ID_BUGTEAM: str = "bugteam"
11
+ TASK_ID_BUGBOT: str = "bugbot"
12
+ TASK_ID_COPILOT: str = "copilot"
13
+ TASK_ID_CODEX: str = "codex"
14
+ TASK_ID_ALL_CLEAN_SAME_HEAD: str = "all_runnable_reviews_clean_same_head"
15
+
16
+ TASK_KIND_REVIEW: str = "review"
17
+ TASK_KIND_FINAL: str = "final"
18
+
19
+ TASK_TITLE_CODE_REVIEW: str = "CODE_REVIEW clean (invoke_code_review / high)"
20
+ TASK_TITLE_BUGTEAM: str = "BUGTEAM clean (full origin/main...HEAD audit)"
21
+ TASK_TITLE_BUGBOT: str = "BUGBOT clean on same HEAD (or down/skip)"
22
+ TASK_TITLE_COPILOT: str = "COPILOT clean on same HEAD (or down/skip)"
23
+ TASK_TITLE_CODEX: str = "CODEX clean on same HEAD (when required)"
24
+ TASK_TITLE_ALL_CLEAN_SAME_HEAD: str = (
25
+ "All runnable code reviews CLEAN on the same HEAD"
26
+ )
27
+
28
+ RESULT_KEY_TASKS: str = "tasks"
29
+ RESULT_KEY_FINAL_TASK_ID: str = "final_task_id"
30
+ RESULT_KEY_RUNNABLE_REVIEW_IDS: str = "runnable_review_ids"
31
+ RESULT_KEY_SKIPPED_REVIEW_IDS: str = "skipped_review_ids"
32
+ RESULT_KEY_DONE_WHEN: str = "done_when"
33
+
34
+ TASK_FIELD_ID: str = "id"
35
+ TASK_FIELD_TITLE: str = "title"
36
+ TASK_FIELD_KIND: str = "kind"
37
+ TASK_FIELD_IS_RUNNABLE: str = "is_runnable"
38
+ TASK_FIELD_SKIP_REASON: str = "skip_reason"
39
+
40
+ SKIP_REASON_BUGBOT_DOWN: str = "bugbot_down"
41
+ SKIP_REASON_COPILOT_DOWN: str = "copilot_down"
42
+ SKIP_REASON_CODEX_DOWN: str = "codex_down"
43
+ SKIP_REASON_CODEX_NOT_REQUIRED: str = "codex_not_required"
44
+
45
+ CLI_BUGBOT_DOWN_FLAG: str = "--bugbot-down"
46
+ CLI_COPILOT_DOWN_FLAG: str = "--copilot-down"
47
+ CLI_CODEX_DOWN_FLAG: str = "--codex-down"
48
+ CLI_CODEX_REQUIRED_FLAG: str = "--codex-required"
49
+
50
+ DONE_WHEN_TEXT: str = (
51
+ "Every task with is_runnable true and kind review is clean at one shared "
52
+ "HEAD; final task all_runnable_reviews_clean_same_head is completed."
53
+ )
54
+
55
+ EXIT_SUCCESS: int = 0
56
+ EXIT_USAGE_ERROR: int = 2
@@ -0,0 +1,47 @@
1
+ """Named constants for converge pacer selection.
2
+
3
+ Consumed by ``select_converge_pacer.py``. Pacer names are the only values the
4
+ skill hubs and the portable driver accept.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ ENTRY_SKILL_PR_CONVERGE: str = "pr-converge"
10
+ ENTRY_SKILL_AUTOCONVERGE: str = "autoconverge"
11
+ ALL_ENTRY_SKILLS: tuple[str, ...] = (
12
+ ENTRY_SKILL_PR_CONVERGE,
13
+ ENTRY_SKILL_AUTOCONVERGE,
14
+ )
15
+
16
+ PACER_WORKFLOW: str = "workflow"
17
+ PACER_SCHEDULE_WAKEUP: str = "schedule_wakeup"
18
+ PACER_PORTABLE: str = "portable"
19
+
20
+ CLI_SKILL_FLAG: str = "--skill"
21
+ CLI_HAS_WORKFLOW_FLAG: str = "--has-workflow"
22
+ CLI_HAS_SCHEDULE_WAKEUP_FLAG: str = "--has-schedule-wakeup"
23
+
24
+ RESULT_KEY_PACER: str = "pacer"
25
+ RESULT_KEY_ENTRY_SKILL: str = "entry_skill"
26
+ RESULT_KEY_HAS_WORKFLOW: str = "has_workflow"
27
+ RESULT_KEY_HAS_SCHEDULE_WAKEUP: str = "has_schedule_wakeup"
28
+
29
+ ALL_TRUTHY_FLAG_TOKENS: frozenset[str] = frozenset(
30
+ {"1", "true", "yes", "on", "y", "t"}
31
+ )
32
+ ALL_FALSY_FLAG_TOKENS: frozenset[str] = frozenset(
33
+ {"0", "false", "no", "off", "n", "f"}
34
+ )
35
+
36
+ ENTRY_SKILL_JOIN_SEPARATOR: str = ", "
37
+
38
+ UNKNOWN_ENTRY_SKILL_ERROR: str = (
39
+ "entry skill must be one of {allowed}; got {got!r}"
40
+ )
41
+ INVALID_BOOL_FLAG_ERROR: str = (
42
+ "flag value must be a boolean token (0/1, true/false, yes/no, on/off); "
43
+ "got {got!r}"
44
+ )
45
+
46
+ EXIT_SUCCESS: int = 0
47
+ EXIT_USAGE_ERROR: int = 2
@@ -0,0 +1,181 @@
1
+ """Constants for the scripted portable converge driver.
2
+
3
+ Deterministic control for third-party pacers lives in
4
+ ``portable_converge_driver.py``. These names are the only phase, next-action,
5
+ and delay tokens that driver emits.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ PHASE_CODE_REVIEW: str = "CODE_REVIEW"
11
+ PHASE_BUGTEAM: str = "BUGTEAM"
12
+ PHASE_BUGBOT: str = "BUGBOT"
13
+ PHASE_COPILOT_WAIT: str = "COPILOT_WAIT"
14
+ PHASE_READY: str = "READY"
15
+ PHASE_BLOCKED: str = "BLOCKED"
16
+ ALL_PHASES: tuple[str, ...] = (
17
+ PHASE_CODE_REVIEW,
18
+ PHASE_BUGTEAM,
19
+ PHASE_BUGBOT,
20
+ PHASE_COPILOT_WAIT,
21
+ PHASE_READY,
22
+ PHASE_BLOCKED,
23
+ )
24
+
25
+ NEXT_RUN_CODE_REVIEW: str = "run_code_review"
26
+ NEXT_APPLY_FIXES: str = "apply_fixes_and_push"
27
+ NEXT_RUN_BUGTEAM: str = "run_bugteam"
28
+ NEXT_RUN_BUGBOT_GATE: str = "run_bugbot_gate"
29
+ NEXT_RUN_CODEX: str = "run_codex_review"
30
+ NEXT_REQUEST_COPILOT: str = "request_copilot_review"
31
+ NEXT_POLL_WAIT: str = "poll_wait"
32
+ NEXT_CHECK_READY: str = "check_ready"
33
+ NEXT_MARK_READY: str = "mark_ready"
34
+ NEXT_TEARDOWN: str = "teardown"
35
+ NEXT_STOP_BLOCKED: str = "stop_blocked"
36
+ ALL_NEXT_ACTIONS: tuple[str, ...] = (
37
+ NEXT_RUN_CODE_REVIEW,
38
+ NEXT_APPLY_FIXES,
39
+ NEXT_RUN_BUGTEAM,
40
+ NEXT_RUN_BUGBOT_GATE,
41
+ NEXT_RUN_CODEX,
42
+ NEXT_REQUEST_COPILOT,
43
+ NEXT_POLL_WAIT,
44
+ NEXT_CHECK_READY,
45
+ NEXT_MARK_READY,
46
+ NEXT_TEARDOWN,
47
+ NEXT_STOP_BLOCKED,
48
+ )
49
+
50
+ DEFAULT_WAIT_SECONDS: int = 360
51
+ BUGBOT_INLINE_LAG_WAIT_SECONDS: int = 90
52
+ INLINE_LAG_STREAK_CAP: int = 3
53
+ COPILOT_WAIT_HARD_CAP: int = 3
54
+ BUGBOT_ABSENT_WAIT_HARD_CAP: int = 3
55
+
56
+ STATE_FILENAME: str = "pr-converge-state.json"
57
+ STATE_JSON_INDENT: int = 2
58
+ STATE_STAGING_SUFFIX: str = ".tmp"
59
+ ALL_GIT_REV_PARSE_HEAD_ARGV: tuple[str, ...] = ("git", "rev-parse", "HEAD")
60
+ PREFLIGHT_WORKTREE_SCRIPT_NAME: str = "preflight_worktree.py"
61
+ CLI_DESCRIPTION: str = "portable_converge_driver phase machine"
62
+ SERVED_COMMAND_IN_SESSION: str = "in_session"
63
+
64
+ RESULT_KEY_STATUS: str = "status"
65
+ RESULT_KEY_PHASE: str = "phase"
66
+ RESULT_KEY_NEXT: str = "next"
67
+ RESULT_KEY_STATE_FILE: str = "state_file"
68
+ RESULT_KEY_CURRENT_HEAD: str = "current_head"
69
+ RESULT_KEY_TICK_COUNT: str = "tick_count"
70
+ RESULT_KEY_WAIT_SECONDS: str = "wait_seconds"
71
+ RESULT_KEY_BLOCKER: str = "blocker"
72
+ RESULT_KEY_COMMANDS: str = "commands"
73
+ RESULT_KEY_PACER: str = "pacer"
74
+ RESULT_KEY_ENTRY_SKILL: str = "entry_skill"
75
+ RESULT_KEY_OWNER: str = "owner"
76
+ RESULT_KEY_REPO: str = "repo"
77
+ RESULT_KEY_PR_NUMBER: str = "pr_number"
78
+ RESULT_KEY_MESSAGE: str = "message"
79
+ RESULT_KEY_COPILOT_DOWN: str = "copilot_down"
80
+ RESULT_KEY_BUGBOT_DOWN: str = "bugbot_down"
81
+ RESULT_KEY_CODEX_DOWN: str = "codex_down"
82
+
83
+ STATUS_OK: str = "ok"
84
+ STATUS_ERROR: str = "error"
85
+
86
+ BLOCKER_COPILOT_WAIT_CAP: str = "copilot_wait_cap"
87
+ BLOCKER_BUGBOT_WAIT_CAP: str = "bugbot_wait_cap"
88
+ BLOCKER_INLINE_LAG_CAP: str = "inline_lag_cap"
89
+ BLOCKER_REVIEW_FAILED: str = "code_review_failed"
90
+ BLOCKER_PREFLIGHT: str = "preflight_failed"
91
+ BLOCKER_NOT_PORTABLE: str = "pacer_not_portable"
92
+
93
+ CLI_STATE_DIR_FLAG: str = "--state-dir"
94
+ CLI_STATE_FILE_FLAG: str = "--state-file"
95
+ CLI_OWNER_FLAG: str = "--owner"
96
+ CLI_REPO_FLAG: str = "--repo"
97
+ CLI_PR_NUMBER_FLAG: str = "--pr-number"
98
+ CLI_CWD_FLAG: str = "--cwd"
99
+ CLI_SESSION_MODEL_FLAG: str = "--session-model"
100
+ CLI_RETURNCODE_FLAG: str = "--returncode"
101
+ CLI_DIRTY_TREE_FLAG: str = "--dirty-tree"
102
+ CLI_SERVED_COMMAND_FLAG: str = "--served-command"
103
+ CLI_PUSHED_FLAG: str = "--pushed"
104
+ CLI_CONVERGED_FLAG: str = "--converged"
105
+ CLI_CLASSIFICATION_FLAG: str = "--classification"
106
+ CLI_REVIEW_SURFACED_FLAG: str = "--review-surfaced"
107
+ CLI_INLINE_LAG_FLAG: str = "--inline-lag"
108
+ CLI_BUGBOT_DOWN_FLAG: str = "--bugbot-down"
109
+ CLI_COPILOT_DOWN_FLAG: str = "--copilot-down"
110
+ CLI_CODEX_DOWN_FLAG: str = "--codex-down"
111
+ CLI_CODEX_REQUIRED_FLAG: str = "--codex-required"
112
+ CLI_CODEX_CLEAN_AT_FLAG: str = "--codex-clean-at"
113
+ CLI_CURRENT_HEAD_FLAG: str = "--current-head"
114
+ CLI_CHECK_CONVERGENCE_EXIT_FLAG: str = "--check-exit"
115
+
116
+ CLASSIFICATION_CLEAN: str = "clean"
117
+ CLASSIFICATION_DIRTY: str = "dirty"
118
+ CLASSIFICATION_ABSENT: str = "absent"
119
+ CLASSIFICATION_DOWN: str = "down"
120
+ ALL_CLASSIFICATIONS: tuple[str, ...] = (
121
+ CLASSIFICATION_CLEAN,
122
+ CLASSIFICATION_DIRTY,
123
+ CLASSIFICATION_ABSENT,
124
+ CLASSIFICATION_DOWN,
125
+ )
126
+ ALL_COPILOT_CLASSIFICATIONS: tuple[str, ...] = (
127
+ CLASSIFICATION_CLEAN,
128
+ CLASSIFICATION_DIRTY,
129
+ CLASSIFICATION_ABSENT,
130
+ CLASSIFICATION_DOWN,
131
+ )
132
+ ALL_CODEX_CLASSIFICATIONS: tuple[str, ...] = (
133
+ CLASSIFICATION_CLEAN,
134
+ CLASSIFICATION_DIRTY,
135
+ CLASSIFICATION_DOWN,
136
+ )
137
+
138
+ STATE_KEY_PENDING_NEXT: str = "pending_next"
139
+ STATE_KEY_PENDING_WAIT_SECONDS: str = "pending_wait_seconds"
140
+ STATE_KEY_CODEX_DOWN: str = "codex_down"
141
+ STATE_KEY_CODEX_REQUIRED: str = "codex_required"
142
+ STATE_KEY_CODEX_CLEAN_AT: str = "codex_clean_at"
143
+ STATE_KEY_SESSION_MODEL: str = "session_model"
144
+ STATE_KEY_CWD: str = "cwd"
145
+
146
+ MARK_READY_GH_BINARY: str = "gh"
147
+ MARK_READY_PR_TOKEN: str = "pr"
148
+ MARK_READY_SUBCOMMAND: str = "ready"
149
+ MARK_READY_REPO_FLAG: str = "--repo"
150
+
151
+ CHECK_CONVERGENCE_OWNER_FLAG: str = "--owner"
152
+ CHECK_CONVERGENCE_REPO_FLAG: str = "--repo"
153
+ CHECK_CONVERGENCE_PR_NUMBER_FLAG: str = "--pr-number"
154
+ CHECK_CONVERGENCE_COPILOT_DOWN_FLAG: str = "--copilot-down"
155
+ CHECK_CONVERGENCE_BUGBOT_DOWN_FLAG: str = "--bugbot-down"
156
+ CHECK_CONVERGENCE_CODEX_DOWN_FLAG: str = "--codex-down"
157
+ CHECK_CONVERGENCE_CODEX_CLEAN_AT_FLAG: str = "--codex-clean-at"
158
+
159
+ COMMAND_OPEN_RUN: str = "open-run"
160
+ COMMAND_AFTER_CODE_REVIEW: str = "after-code-review"
161
+ COMMAND_AFTER_BUGTEAM: str = "after-bugteam"
162
+ COMMAND_AFTER_BUGBOT: str = "after-bugbot"
163
+ COMMAND_AFTER_COPILOT_WAIT: str = "after-copilot-wait"
164
+ COMMAND_AFTER_CODEX: str = "after-codex"
165
+ COMMAND_AFTER_READY_CHECK: str = "after-ready-check"
166
+ COMMAND_SHOW_STATE: str = "show-state"
167
+
168
+ DEFAULT_SESSION_MODEL: str = "third-party"
169
+
170
+ INVOKE_CODE_REVIEW_RELATIVE_PATH: str = ".claude/scripts/invoke_code_review.py"
171
+ CHECK_CONVERGENCE_RELATIVE_PATH: str = (
172
+ ".claude/skills/pr-converge/scripts/check_convergence.py"
173
+ )
174
+
175
+ CODEX_REVIEW_SKILL_DIRNAME: str = "codex-review"
176
+ CODEX_REVIEW_SCRIPTS_DIRNAME: str = "scripts"
177
+ PORTABLE_SCRIPTS_TO_SKILLS_PARENT_HOPS: int = 3
178
+
179
+ EXIT_SUCCESS: int = 0
180
+ EXIT_USAGE_ERROR: int = 2
181
+ EXIT_CONTRACT_ERROR: int = 1
@@ -0,0 +1,140 @@
1
+ """Tests for build_converge_task_list — runnable gates and final CLEAN task."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
11
+ if str(_SCRIPTS_DIR) not in sys.path:
12
+ sys.path.insert(0, str(_SCRIPTS_DIR))
13
+
14
+ import build_converge_task_list as task_list_module # noqa: E402
15
+ from skills_pr_loop_constants.converge_task_list_constants import ( # noqa: E402
16
+ EXIT_SUCCESS,
17
+ RESULT_KEY_DONE_WHEN,
18
+ RESULT_KEY_FINAL_TASK_ID,
19
+ RESULT_KEY_RUNNABLE_REVIEW_IDS,
20
+ RESULT_KEY_SKIPPED_REVIEW_IDS,
21
+ RESULT_KEY_TASKS,
22
+ SKIP_REASON_BUGBOT_DOWN,
23
+ SKIP_REASON_CODEX_NOT_REQUIRED,
24
+ SKIP_REASON_COPILOT_DOWN,
25
+ TASK_FIELD_ID,
26
+ TASK_FIELD_IS_RUNNABLE,
27
+ TASK_FIELD_KIND,
28
+ TASK_ID_ALL_CLEAN_SAME_HEAD,
29
+ TASK_ID_BUGBOT,
30
+ TASK_ID_BUGTEAM,
31
+ TASK_ID_CODE_REVIEW,
32
+ TASK_ID_CODEX,
33
+ TASK_ID_COPILOT,
34
+ TASK_KIND_FINAL,
35
+ TASK_KIND_REVIEW,
36
+ )
37
+
38
+ SCRIPT_PATH = _SCRIPTS_DIR / "build_converge_task_list.py"
39
+
40
+
41
+ def test_default_flags_code_review_and_bugteam_runnable_final_last() -> None:
42
+ payload = task_list_module.build_converge_task_list(
43
+ is_bugbot_down=False,
44
+ is_copilot_down=False,
45
+ is_codex_down=False,
46
+ is_codex_required=False,
47
+ )
48
+ all_tasks = payload[RESULT_KEY_TASKS]
49
+ assert isinstance(all_tasks, list)
50
+ assert all_tasks[0][TASK_FIELD_ID] == TASK_ID_CODE_REVIEW
51
+ assert all_tasks[1][TASK_FIELD_ID] == TASK_ID_BUGTEAM
52
+ assert all_tasks[-1][TASK_FIELD_ID] == TASK_ID_ALL_CLEAN_SAME_HEAD
53
+ assert all_tasks[-1][TASK_FIELD_KIND] == TASK_KIND_FINAL
54
+ assert payload[RESULT_KEY_FINAL_TASK_ID] == TASK_ID_ALL_CLEAN_SAME_HEAD
55
+ assert TASK_ID_CODE_REVIEW in payload[RESULT_KEY_RUNNABLE_REVIEW_IDS]
56
+ assert TASK_ID_BUGTEAM in payload[RESULT_KEY_RUNNABLE_REVIEW_IDS]
57
+ assert TASK_ID_BUGBOT in payload[RESULT_KEY_RUNNABLE_REVIEW_IDS]
58
+ assert TASK_ID_COPILOT in payload[RESULT_KEY_RUNNABLE_REVIEW_IDS]
59
+ assert TASK_ID_CODEX in payload[RESULT_KEY_SKIPPED_REVIEW_IDS]
60
+
61
+
62
+ def test_down_flags_skip_external_reviews() -> None:
63
+ payload = task_list_module.build_converge_task_list(
64
+ is_bugbot_down=True,
65
+ is_copilot_down=True,
66
+ is_codex_down=True,
67
+ is_codex_required=True,
68
+ )
69
+ all_tasks = payload[RESULT_KEY_TASKS]
70
+ assert isinstance(all_tasks, list)
71
+ all_tasks_by_id = {
72
+ each_task[TASK_FIELD_ID]: each_task for each_task in all_tasks
73
+ }
74
+ assert all_tasks_by_id[TASK_ID_BUGBOT][TASK_FIELD_IS_RUNNABLE] is False
75
+ assert all_tasks_by_id[TASK_ID_COPILOT][TASK_FIELD_IS_RUNNABLE] is False
76
+ assert all_tasks_by_id[TASK_ID_CODEX][TASK_FIELD_IS_RUNNABLE] is False
77
+ assert payload[RESULT_KEY_RUNNABLE_REVIEW_IDS] == [
78
+ TASK_ID_CODE_REVIEW,
79
+ TASK_ID_BUGTEAM,
80
+ ]
81
+ assert SKIP_REASON_BUGBOT_DOWN in str(all_tasks_by_id[TASK_ID_BUGBOT])
82
+ assert SKIP_REASON_COPILOT_DOWN in str(all_tasks_by_id[TASK_ID_COPILOT])
83
+
84
+
85
+ def test_codex_required_is_runnable() -> None:
86
+ payload = task_list_module.build_converge_task_list(
87
+ is_bugbot_down=True,
88
+ is_copilot_down=True,
89
+ is_codex_down=False,
90
+ is_codex_required=True,
91
+ )
92
+ assert TASK_ID_CODEX in payload[RESULT_KEY_RUNNABLE_REVIEW_IDS]
93
+ assert SKIP_REASON_CODEX_NOT_REQUIRED not in str(payload)
94
+
95
+
96
+ def test_final_task_always_last_and_runnable() -> None:
97
+ payload = task_list_module.build_converge_task_list(
98
+ is_bugbot_down=True,
99
+ is_copilot_down=False,
100
+ is_codex_down=False,
101
+ is_codex_required=True,
102
+ )
103
+ all_tasks = payload[RESULT_KEY_TASKS]
104
+ assert isinstance(all_tasks, list)
105
+ final_task = all_tasks[-1]
106
+ assert final_task[TASK_FIELD_ID] == TASK_ID_ALL_CLEAN_SAME_HEAD
107
+ assert final_task[TASK_FIELD_IS_RUNNABLE] is True
108
+ assert final_task[TASK_FIELD_KIND] == TASK_KIND_FINAL
109
+ assert payload[RESULT_KEY_DONE_WHEN]
110
+
111
+
112
+ def test_cli_prints_json() -> None:
113
+ completed = subprocess.run(
114
+ [
115
+ sys.executable,
116
+ str(SCRIPT_PATH),
117
+ "--bugbot-down",
118
+ "1",
119
+ "--copilot-down",
120
+ "0",
121
+ "--codex-required",
122
+ "0",
123
+ ],
124
+ check=False,
125
+ capture_output=True,
126
+ text=True,
127
+ )
128
+ assert completed.returncode == EXIT_SUCCESS, completed.stderr
129
+ payload = json.loads(completed.stdout)
130
+ assert payload[RESULT_KEY_FINAL_TASK_ID] == TASK_ID_ALL_CLEAN_SAME_HEAD
131
+ assert TASK_ID_BUGBOT in payload[RESULT_KEY_SKIPPED_REVIEW_IDS]
132
+ assert any(
133
+ each_task[TASK_FIELD_KIND] == TASK_KIND_REVIEW
134
+ for each_task in payload[RESULT_KEY_TASKS]
135
+ )
136
+
137
+
138
+ def test_main_returns_usage_on_bad_bool() -> None:
139
+ exit_code = task_list_module.main(["--bugbot-down", "maybe"])
140
+ assert exit_code == task_list_module.EXIT_USAGE_ERROR