claude-dev-env 1.59.0 → 1.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CLAUDE.md +4 -0
  2. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  3. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  4. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  6. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  7. package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
  8. package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
  9. package/docs/CODE_RULES.md +2 -2
  10. package/hooks/blocking/code_rules_annotations_length.py +189 -10
  11. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  12. package/hooks/blocking/code_rules_duplicate_body.py +152 -0
  13. package/hooks/blocking/code_rules_enforcer.py +38 -15
  14. package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
  15. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  16. package/hooks/blocking/config/__init__.py +5 -0
  17. package/hooks/blocking/config/verified_commit_constants.py +118 -0
  18. package/hooks/blocking/destructive_command_blocker.py +483 -61
  19. package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
  20. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  21. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  22. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  23. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
  24. package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
  25. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  26. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  27. package/hooks/blocking/test_destructive_command_blocker.py +213 -0
  28. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  29. package/hooks/blocking/test_verification_verdict_store.py +490 -0
  30. package/hooks/blocking/test_verified_commit_gate.py +495 -0
  31. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  32. package/hooks/blocking/test_verifier_verdict_minter.py +193 -0
  33. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  34. package/hooks/blocking/verification_verdict_store.py +686 -0
  35. package/hooks/blocking/verified_commit_gate.py +535 -0
  36. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  37. package/hooks/blocking/verifier_verdict_minter.py +221 -0
  38. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  39. package/hooks/hooks.json +43 -1
  40. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  41. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  42. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  43. package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
  44. package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
  45. package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
  46. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  47. package/hooks/validation/mypy_validator.py +59 -7
  48. package/hooks/validation/test_mypy_validator.py +94 -0
  49. package/package.json +1 -1
  50. package/rules/file-global-constants.md +7 -1
  51. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  52. package/rules/orphan-css-class.md +23 -0
  53. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  54. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  55. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  56. package/skills/autoconverge/SKILL.md +54 -17
  57. package/skills/autoconverge/reference/closing-report.md +59 -17
  58. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  59. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +192 -76
  60. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +395 -206
  62. package/skills/autoconverge/workflow/converge.mjs +520 -57
  63. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  64. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  65. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  66. package/skills/autoconverge/workflow/render_report.py +488 -397
  67. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  68. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  69. package/skills/autoconverge/workflow/test_render_report.py +518 -259
  70. package/skills/pr-converge/reference/per-tick.md +28 -8
  71. package/skills/rebase/SKILL.md +2 -4
  72. package/system-prompts/software-engineer.xml +2 -6
  73. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  74. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  75. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  76. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  77. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  78. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  79. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  80. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  81. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -20,7 +20,13 @@ Test-file detection uses the following anchored patterns against the full relati
20
20
 
21
21
  ## `config/` files are exempt
22
22
 
23
- Constants placed in `config/` satisfy the constants-location rule; the use-count requirement applies only to production code outside `config/`.
23
+ Constants placed in `config/` satisfy the constants-location rule; the use-count rule applies only to production code outside `config/`.
24
+
25
+ ## Dead constant in a dedicated constants module (cross-module)
26
+
27
+ The use-count rule above governs a file-global constant in production code outside `config/` by counting same-file references. A dedicated constants module — a file whose name ends in `_constants.py`, or any module under a `config/` directory — exports its constants to importer modules elsewhere, so a same-file count proves nothing. A separate hook, `check_dead_module_constants` (dispatched from `code_rules_enforcer`), governs these modules: it flags an `UPPER_SNAKE` constant defined in the written module whose name appears in no `.py` module anywhere under the enclosing package tree — not imported, not read, not listed in an `__all__` literal, not named in a string annotation. That is the dead exported constant CODE_RULES §9.8 targets, caught at Write/Edit time.
28
+
29
+ The scan resolves the enclosing package tree from the written file: for a constants module inside a package subdirectory, the tree is the package's parent (so an importer one directory up is in scope); for a `config/` module, the tree is the parent of the `config` directory. A module that declares its own `__all__` is skipped — the author's explicit export surface is taken as the liveness contract. A reference from a test module under the tree keeps a constant live. Test modules and migration modules are themselves exempt from the check.
24
30
 
25
31
  ## Examples
26
32
 
@@ -0,0 +1,29 @@
1
+ ---
2
+ paths: "**/skills/*/scripts/**/*.py"
3
+ ---
4
+
5
+ # Cross-Skill Duplicate Helpers
6
+
7
+ **When this applies:** Any Write or Edit to a `.py` file under a skill's `scripts/` directory (`**/skills/<skill-name>/scripts/**/*.py`) that copies a top-level helper from another skill's `scripts/` directory.
8
+
9
+ ## The two duplication cases differ
10
+
11
+ CODE_RULES "Reuse before create" / DRY says one helper lives in one home and both call sites import it. That rule is blocking **within one skill** — two `.py` modules in the same skill's `scripts/` directory that carry the same top-level function body fail the `code_rules_duplicate_body` gate, and the fix is a shared module both import.
12
+
13
+ Across **two skill folders** the same copy is a different call. Each skill folder installs on its own, so a shared module would couple two skills the install model keeps separate: deleting or reinstalling one skill would break a helper the other depends on. A small launch helper copied into each skill (for example a Chrome-open helper that reads the registry and runs `chrome.exe`) is a defensible skill-isolation tradeoff, not a regression.
14
+
15
+ ## Decision
16
+
17
+ Before you copy a top-level helper from one skill's `scripts/` directory into another:
18
+
19
+ - **Same skill, two modules** — extract one shared module in that skill and import it from both. The `code_rules_duplicate_body` gate blocks the copy.
20
+ - **Two skill folders, a small self-contained helper** — copy it; the skill-isolation tradeoff stands. A non-blocking `[CODE_RULES advisory]` names the source skill at Write time so the copy is a deliberate choice on record, not an oversight.
21
+ - **Two skill folders, a large or behavior-bearing body** — when the copied body is large, holds business logic, or would drift in a way that changes behavior, raise the choice through `AskUserQuestion`: copy and accept drift, or stand up a shared dependency both skills declare (for example a published package both `requirements` files name, or a `_shared` module the install step writes into each skill). A shared dependency that survives independent install is the only shared-home path that does not break the install model.
22
+
23
+ ## What the advisory tells you
24
+
25
+ The `advise_cross_skill_duplicate_helper` check in `code_rules_duplicate_body` prints to stderr (never blocks) when a top-level function in the file being written has the same normalized body as a top-level function in another skill's `scripts/` directory. The message names the source skill and function so a reviewer can confirm the copy was intentional. It fires only across skill folders; within one skill the blocking gate already covers the copy.
26
+
27
+ ## Why this is a rule, not a wider gate
28
+
29
+ Extending the blocking duplicate-body gate to span skill folders would deny the exact skill-isolation copy that keeps skills independently installable — a false positive on a sanctioned pattern. The boundary between "same skill, block" and "two skills, signal" is a judgment the writer makes with the source skill named in front of them. The rule states the judgment; the `[CODE_RULES advisory]` surfaces the signal; neither blocks the defensible copy.
@@ -0,0 +1,23 @@
1
+ # Orphan CSS Class in Generated Markup
2
+
3
+ **When this applies:** Any Write or Edit to a production `.py` file that builds HTML by emitting `class="..."` attributes inside string literals and pairs them with a `<style>` block — in the same file or in a companion module beside it.
4
+
5
+ ## Rule
6
+
7
+ Every class name a markup string references has a matching `.<class>` selector in the `<style>` block. A class that appears in the markup but carries no selector anywhere is a dead attribute (or a missing rule): the markup names a style that the stylesheet never defines, so a reader who trusts the class to be styled is misled, and the attribute adds noise without effect.
8
+
9
+ When you add a `class="..."` attribute, add its `.<class>` selector to the `<style>` block in the same change. When you drop a selector, drop the class attribute it styled.
10
+
11
+ ## What the gate checks
12
+
13
+ The `check_orphan_css_classes` check in `code_rules_orphan_css_class.py` (wired into `code_rules_enforcer.py`) runs on every production Python write. It:
14
+
15
+ 1. Collects each class name referenced in a `class="..."` attribute across the file's string literals.
16
+ 2. Collects each class selector defined in a `<style>` block — both in the file under edit and in every Python module beside it (its own directory and immediate child directories), since a markup module commonly imports its style constant from a companion package directory.
17
+ 3. Flags each referenced class with no matching selector in that whole set.
18
+
19
+ The check stays quiet for a file that emits no `class="..."` markup, and for a file whose markup has no `<style>` source nearby (its stylesheet lives outside the scan, so the gate cannot judge it). Test files are exempt, since a fixture may carry intentional orphan markup.
20
+
21
+ ## Why this is a hook, not a lint pass
22
+
23
+ A class attribute with no matching selector reads as styled but renders unstyled. Native elements such as `<details>` stay functional without CSS, so the gap survives review as a cosmetic defect rather than a crash — exactly the class of issue that slips past a manual pass and lands as a deferred code-standard finding. Catching it at Write time keeps the markup and the stylesheet in step as each line is written.
@@ -0,0 +1,392 @@
1
+ """Preflight guard for the working directory and worktree before a PR-convergence run.
2
+
3
+ Usage:
4
+ python preflight_worktree.py --owner <O> --repo <R> --mode <strict|classify>
5
+
6
+ Modes:
7
+ strict — autoconverge: the working directory must be the PR's own repo so
8
+ EnterWorktree can create and enter the branch worktree. Any other
9
+ state aborts (exit 1).
10
+ classify — pr-converge: emit the environment classification so the caller can
11
+ route. same_repo and different_repo both succeed (exit 0); a
12
+ re-rooted session (no git work tree, or no readable origin) aborts
13
+ (exit 1).
14
+
15
+ Output (stdout):
16
+ A line 'PREFLIGHT_OUTCOME=<same_repo|different_repo|re_rooted>', a
17
+ human-readable summary, and, on abort, a recovery instruction.
18
+
19
+ Exit codes:
20
+ 0 — safe to continue for the given mode
21
+ 1 — abort (the caller must stop and recover)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import subprocess
28
+ import sys
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+
32
+ _self_dir = Path(__file__).resolve().parent
33
+ if str(_self_dir) not in sys.path:
34
+ sys.path.insert(0, str(_self_dir))
35
+
36
+ from skills_pr_loop_constants.preflight_constants import (
37
+ ABORT_DIFFERENT_REPO_STRICT_TEMPLATE,
38
+ ABORT_RE_ROOTED_TEMPLATE,
39
+ ABORT_WORKTREE_BROKEN_TEMPLATE,
40
+ ALL_GIT_IS_INSIDE_WORK_TREE_ARGS,
41
+ ALL_GIT_REMOTE_GET_URL_ARGS,
42
+ ALL_GIT_WORKTREE_LIST_ARGS,
43
+ ALL_PREFLIGHT_MODES,
44
+ CWD_IDENTITY_UNKNOWN,
45
+ EXIT_PREFLIGHT_ABORT,
46
+ EXIT_PREFLIGHT_OK,
47
+ GIT_DIRECTORY_FLAG,
48
+ GIT_EXECUTABLE,
49
+ GIT_INSIDE_WORK_TREE_TRUE,
50
+ GIT_SUBPROCESS_TIMEOUT_SECONDS,
51
+ MODE_ARG_FLAG,
52
+ MODE_STRICT,
53
+ OUTCOME_DIFFERENT_REPO,
54
+ OUTCOME_MARKER_TEMPLATE,
55
+ OUTCOME_RE_ROOTED,
56
+ OUTCOME_SAME_REPO,
57
+ OWNER_ARG_FLAG,
58
+ PREFLIGHT_CLI_DESCRIPTION,
59
+ REMOTE_URL_IDENTITY_PATTERN,
60
+ REPO_ARG_FLAG,
61
+ ROUTE_DIFFERENT_REPO_TEMPLATE,
62
+ SUMMARY_DIFFERENT_REPO_TEMPLATE,
63
+ SUMMARY_RE_ROOTED_TEMPLATE,
64
+ SUMMARY_SAME_REPO_TEMPLATE,
65
+ )
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class RepoIdentity:
70
+ """A GitHub repository identity parsed from a git remote URL.
71
+
72
+ Attributes:
73
+ owner: Repository owner (login or org), lower-cased for comparison.
74
+ repo: Repository name, lower-cased for comparison.
75
+ """
76
+
77
+ owner: str
78
+ repo: str
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class PreflightVerdict:
83
+ """Classification of the working directory against a target PR repo.
84
+
85
+ Attributes:
86
+ outcome: One of OUTCOME_SAME_REPO, OUTCOME_DIFFERENT_REPO, or
87
+ OUTCOME_RE_ROOTED.
88
+ cwd_identity: Parsed identity of the working directory's origin, or
89
+ None when there is no git work tree, no origin, or an unparseable
90
+ origin URL.
91
+ has_healthy_worktree_machinery: True when 'git worktree list' runs
92
+ cleanly in the working directory.
93
+ """
94
+
95
+ outcome: str
96
+ cwd_identity: RepoIdentity | None
97
+ has_healthy_worktree_machinery: bool
98
+
99
+
100
+ def parse_repo_identity(remote_url: str) -> RepoIdentity | None:
101
+ """Parse a GitHub owner/repo from a git remote URL.
102
+
103
+ Accepts the https, git@ (scp-like), and ssh:// forms and drops a trailing
104
+ '.git'. Comparison is case-insensitive, so the returned owner and repo are
105
+ lower-cased.
106
+
107
+ Args:
108
+ remote_url: The remote URL from 'git remote get-url origin'.
109
+
110
+ Returns:
111
+ A RepoIdentity, or None when the URL is not a GitHub remote.
112
+ """
113
+ match = REMOTE_URL_IDENTITY_PATTERN.search(remote_url.strip())
114
+ if match is None:
115
+ return None
116
+ return RepoIdentity(
117
+ owner=match.group("owner").lower(),
118
+ repo=match.group("repo").lower(),
119
+ )
120
+
121
+
122
+ def _run_git(
123
+ working_directory: Path, all_git_arguments: tuple[str, ...]
124
+ ) -> subprocess.CompletedProcess[str]:
125
+ """Run a git subcommand in a working directory with a bounded timeout.
126
+
127
+ Args:
128
+ working_directory: Directory to run git in (passed via 'git -C').
129
+ all_git_arguments: The git subcommand and its arguments.
130
+
131
+ Returns:
132
+ The completed process with captured text stdout and stderr.
133
+ """
134
+ return subprocess.run(
135
+ [
136
+ GIT_EXECUTABLE,
137
+ GIT_DIRECTORY_FLAG,
138
+ str(working_directory),
139
+ *all_git_arguments,
140
+ ],
141
+ capture_output=True,
142
+ text=True,
143
+ check=False,
144
+ timeout=GIT_SUBPROCESS_TIMEOUT_SECONDS,
145
+ )
146
+
147
+
148
+ def is_inside_work_tree(working_directory: Path) -> bool:
149
+ """Report whether the working directory is inside a git work tree.
150
+
151
+ Args:
152
+ working_directory: Directory to test.
153
+
154
+ Returns:
155
+ True when 'git rev-parse --is-inside-work-tree' prints 'true'.
156
+ """
157
+ completed = _run_git(working_directory, ALL_GIT_IS_INSIDE_WORK_TREE_ARGS)
158
+ return completed.returncode == 0 and (
159
+ completed.stdout.strip() == GIT_INSIDE_WORK_TREE_TRUE
160
+ )
161
+
162
+
163
+ def worktree_machinery_is_healthy(working_directory: Path) -> bool:
164
+ """Report whether git worktree machinery works in the working directory.
165
+
166
+ EnterWorktree relies on 'git worktree' to create and enter the branch
167
+ worktree; a failing worktree list means it cannot.
168
+
169
+ Args:
170
+ working_directory: Directory to test.
171
+
172
+ Returns:
173
+ True when 'git worktree list' exits cleanly.
174
+ """
175
+ completed = _run_git(working_directory, ALL_GIT_WORKTREE_LIST_ARGS)
176
+ return completed.returncode == 0
177
+
178
+
179
+ def classify_environment(
180
+ working_directory: Path, pr_identity: RepoIdentity
181
+ ) -> PreflightVerdict:
182
+ """Classify the working directory against the target PR repository.
183
+
184
+ Args:
185
+ working_directory: The session's current working directory.
186
+ pr_identity: The PR's owner/repo (lower-cased for comparison).
187
+
188
+ Returns:
189
+ A PreflightVerdict naming the outcome and the worktree-machinery health.
190
+ """
191
+ if not is_inside_work_tree(working_directory):
192
+ return PreflightVerdict(OUTCOME_RE_ROOTED, None, False)
193
+ has_healthy_machinery = worktree_machinery_is_healthy(working_directory)
194
+ remote = _run_git(working_directory, ALL_GIT_REMOTE_GET_URL_ARGS)
195
+ if remote.returncode != 0:
196
+ return PreflightVerdict(OUTCOME_RE_ROOTED, None, has_healthy_machinery)
197
+ cwd_identity = parse_repo_identity(remote.stdout)
198
+ if cwd_identity is None:
199
+ return PreflightVerdict(OUTCOME_DIFFERENT_REPO, None, has_healthy_machinery)
200
+ if cwd_identity == pr_identity:
201
+ return PreflightVerdict(OUTCOME_SAME_REPO, cwd_identity, has_healthy_machinery)
202
+ return PreflightVerdict(OUTCOME_DIFFERENT_REPO, cwd_identity, has_healthy_machinery)
203
+
204
+
205
+ def decide_exit_code(verdict: PreflightVerdict, mode: str) -> int:
206
+ """Decide the process exit code from a classification and the mode.
207
+
208
+ Args:
209
+ verdict: The classification of the working directory.
210
+ mode: MODE_STRICT (autoconverge) or MODE_CLASSIFY (pr-converge).
211
+
212
+ Returns:
213
+ EXIT_PREFLIGHT_OK when it is safe to continue, else EXIT_PREFLIGHT_ABORT.
214
+ """
215
+ if verdict.outcome == OUTCOME_RE_ROOTED:
216
+ return EXIT_PREFLIGHT_ABORT
217
+ if verdict.outcome == OUTCOME_SAME_REPO:
218
+ if not verdict.has_healthy_worktree_machinery:
219
+ return EXIT_PREFLIGHT_ABORT
220
+ return EXIT_PREFLIGHT_OK
221
+ if mode == MODE_STRICT:
222
+ return EXIT_PREFLIGHT_ABORT
223
+ return EXIT_PREFLIGHT_OK
224
+
225
+
226
+ def _cwd_identity_labels(verdict: PreflightVerdict) -> tuple[str, str]:
227
+ """Return display labels for the working directory's owner and repo.
228
+
229
+ Args:
230
+ verdict: The classification of the working directory.
231
+
232
+ Returns:
233
+ The lower-cased owner and repo, or the unknown placeholder for each
234
+ when the working directory identity could not be parsed.
235
+ """
236
+ if verdict.cwd_identity is None:
237
+ return CWD_IDENTITY_UNKNOWN, CWD_IDENTITY_UNKNOWN
238
+ return verdict.cwd_identity.owner, verdict.cwd_identity.repo
239
+
240
+
241
+ def _same_repo_lines(
242
+ verdict: PreflightVerdict, working_directory: Path, pr_identity: RepoIdentity
243
+ ) -> list[str]:
244
+ """Build the report lines for the same-repo outcome.
245
+
246
+ Args:
247
+ verdict: The classification of the working directory.
248
+ working_directory: The session's current working directory.
249
+ pr_identity: The PR's owner/repo.
250
+
251
+ Returns:
252
+ A summary line, plus an abort line when the worktree machinery is broken.
253
+ """
254
+ report_lines = [
255
+ SUMMARY_SAME_REPO_TEMPLATE.format(
256
+ owner=pr_identity.owner, repo=pr_identity.repo
257
+ )
258
+ ]
259
+ if not verdict.has_healthy_worktree_machinery:
260
+ report_lines.append(
261
+ ABORT_WORKTREE_BROKEN_TEMPLATE.format(cwd=working_directory)
262
+ )
263
+ return report_lines
264
+
265
+
266
+ def _different_repo_lines(
267
+ verdict: PreflightVerdict, mode: str, pr_identity: RepoIdentity
268
+ ) -> list[str]:
269
+ """Build the report lines for the different-repo outcome.
270
+
271
+ Args:
272
+ verdict: The classification of the working directory.
273
+ mode: MODE_STRICT (autoconverge) or MODE_CLASSIFY (pr-converge).
274
+ pr_identity: The PR's owner/repo.
275
+
276
+ Returns:
277
+ A summary line, plus an abort line under strict mode or a route line
278
+ under classify mode.
279
+ """
280
+ cwd_owner, cwd_repo = _cwd_identity_labels(verdict)
281
+ report_lines = [
282
+ SUMMARY_DIFFERENT_REPO_TEMPLATE.format(
283
+ cwd_owner=cwd_owner,
284
+ cwd_repo=cwd_repo,
285
+ owner=pr_identity.owner,
286
+ repo=pr_identity.repo,
287
+ )
288
+ ]
289
+ if mode == MODE_STRICT:
290
+ report_lines.append(
291
+ ABORT_DIFFERENT_REPO_STRICT_TEMPLATE.format(
292
+ cwd_owner=cwd_owner,
293
+ cwd_repo=cwd_repo,
294
+ owner=pr_identity.owner,
295
+ repo=pr_identity.repo,
296
+ )
297
+ )
298
+ else:
299
+ report_lines.append(
300
+ ROUTE_DIFFERENT_REPO_TEMPLATE.format(
301
+ owner=pr_identity.owner, repo=pr_identity.repo
302
+ )
303
+ )
304
+ return report_lines
305
+
306
+
307
+ def _re_rooted_lines(working_directory: Path, pr_identity: RepoIdentity) -> list[str]:
308
+ """Build the report lines for the re-rooted outcome.
309
+
310
+ Args:
311
+ working_directory: The session's current working directory.
312
+ pr_identity: The PR's owner/repo.
313
+
314
+ Returns:
315
+ A summary line and an abort line with the recovery instruction.
316
+ """
317
+ return [
318
+ SUMMARY_RE_ROOTED_TEMPLATE.format(cwd=working_directory),
319
+ ABORT_RE_ROOTED_TEMPLATE.format(owner=pr_identity.owner, repo=pr_identity.repo),
320
+ ]
321
+
322
+
323
+ def build_report_lines(
324
+ verdict: PreflightVerdict,
325
+ mode: str,
326
+ working_directory: Path,
327
+ pr_identity: RepoIdentity,
328
+ ) -> list[str]:
329
+ """Build the stdout report lines for a classification.
330
+
331
+ Args:
332
+ verdict: The classification of the working directory.
333
+ mode: MODE_STRICT (autoconverge) or MODE_CLASSIFY (pr-converge).
334
+ working_directory: The session's current working directory.
335
+ pr_identity: The PR's owner/repo.
336
+
337
+ Returns:
338
+ Lines to print: the machine-readable outcome marker first, then a
339
+ summary and a recovery or routing instruction when relevant.
340
+ """
341
+ report_lines = [OUTCOME_MARKER_TEMPLATE.format(outcome=verdict.outcome)]
342
+ if verdict.outcome == OUTCOME_SAME_REPO:
343
+ report_lines.extend(_same_repo_lines(verdict, working_directory, pr_identity))
344
+ elif verdict.outcome == OUTCOME_DIFFERENT_REPO:
345
+ report_lines.extend(_different_repo_lines(verdict, mode, pr_identity))
346
+ else:
347
+ report_lines.extend(_re_rooted_lines(working_directory, pr_identity))
348
+ return report_lines
349
+
350
+
351
+ def main(all_arguments: list[str]) -> int:
352
+ """Classify the working directory and report whether the run can continue.
353
+
354
+ Args:
355
+ all_arguments: The argument vector (sys.argv[1:] in normal use).
356
+
357
+ Returns:
358
+ The process exit code (EXIT_PREFLIGHT_OK or EXIT_PREFLIGHT_ABORT).
359
+ """
360
+ parser = argparse.ArgumentParser(description=PREFLIGHT_CLI_DESCRIPTION)
361
+ parser.add_argument(OWNER_ARG_FLAG, required=True)
362
+ parser.add_argument(REPO_ARG_FLAG, required=True)
363
+ parser.add_argument(MODE_ARG_FLAG, required=True, choices=ALL_PREFLIGHT_MODES)
364
+ parsed_arguments = parser.parse_args(all_arguments)
365
+ pr_identity = RepoIdentity(
366
+ owner=parsed_arguments.owner.lower(),
367
+ repo=parsed_arguments.repo.lower(),
368
+ )
369
+ working_directory = Path.cwd()
370
+ try:
371
+ verdict = classify_environment(working_directory, pr_identity)
372
+ except subprocess.TimeoutExpired:
373
+ print(
374
+ OUTCOME_MARKER_TEMPLATE.format(outcome=OUTCOME_RE_ROOTED),
375
+ file=sys.stdout,
376
+ )
377
+ print(
378
+ ABORT_RE_ROOTED_TEMPLATE.format(
379
+ owner=pr_identity.owner, repo=pr_identity.repo
380
+ ),
381
+ file=sys.stdout,
382
+ )
383
+ return EXIT_PREFLIGHT_ABORT
384
+ for each_report_line in build_report_lines(
385
+ verdict, parsed_arguments.mode, working_directory, pr_identity
386
+ ):
387
+ print(each_report_line, file=sys.stdout)
388
+ return decide_exit_code(verdict, parsed_arguments.mode)
389
+
390
+
391
+ if __name__ == "__main__":
392
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,70 @@
1
+ """Constants for the worktree/cwd preflight guard shared by pr-loop skills."""
2
+
3
+ import re
4
+
5
+ OUTCOME_SAME_REPO = "same_repo"
6
+ OUTCOME_DIFFERENT_REPO = "different_repo"
7
+ OUTCOME_RE_ROOTED = "re_rooted"
8
+
9
+ MODE_STRICT = "strict"
10
+ MODE_CLASSIFY = "classify"
11
+ ALL_PREFLIGHT_MODES = (MODE_STRICT, MODE_CLASSIFY)
12
+
13
+ EXIT_PREFLIGHT_OK = 0
14
+ EXIT_PREFLIGHT_ABORT = 1
15
+
16
+ GIT_EXECUTABLE = "git"
17
+ GIT_DIRECTORY_FLAG = "-C"
18
+ ALL_GIT_IS_INSIDE_WORK_TREE_ARGS = ("rev-parse", "--is-inside-work-tree")
19
+ ALL_GIT_REMOTE_GET_URL_ARGS = ("remote", "get-url", "origin")
20
+ ALL_GIT_WORKTREE_LIST_ARGS = ("worktree", "list")
21
+ GIT_INSIDE_WORK_TREE_TRUE = "true"
22
+ GIT_SUBPROCESS_TIMEOUT_SECONDS = 30
23
+
24
+ REMOTE_URL_IDENTITY_PATTERN = re.compile(
25
+ r"github\.com[:/](?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
26
+ )
27
+
28
+ CWD_IDENTITY_UNKNOWN = "unknown"
29
+
30
+ OWNER_ARG_FLAG = "--owner"
31
+ REPO_ARG_FLAG = "--repo"
32
+ MODE_ARG_FLAG = "--mode"
33
+
34
+ PREFLIGHT_CLI_DESCRIPTION = (
35
+ "Verify the working directory and worktree before a PR-convergence run."
36
+ )
37
+
38
+ OUTCOME_MARKER_TEMPLATE = "PREFLIGHT_OUTCOME={outcome}"
39
+
40
+ SUMMARY_SAME_REPO_TEMPLATE = (
41
+ "OK: the working directory is the PR repo {owner}/{repo}; EnterWorktree can "
42
+ "create and enter the branch worktree."
43
+ )
44
+ SUMMARY_DIFFERENT_REPO_TEMPLATE = (
45
+ "The working directory repo {cwd_owner}/{cwd_repo} differs from the PR repo "
46
+ "{owner}/{repo}."
47
+ )
48
+ SUMMARY_RE_ROOTED_TEMPLATE = (
49
+ "The working directory {cwd} is not a usable checkout of a GitHub repo "
50
+ "(no git work tree, or no readable origin remote)."
51
+ )
52
+ ABORT_DIFFERENT_REPO_STRICT_TEMPLATE = (
53
+ "ABORT: autoconverge runs inside the PR's own repo, but this session is "
54
+ "rooted in {cwd_owner}/{cwd_repo}. Start the session from a checkout of "
55
+ "{owner}/{repo} and re-run."
56
+ )
57
+ ABORT_RE_ROOTED_TEMPLATE = (
58
+ "ABORT: this session is not rooted in a git checkout (a resumed or "
59
+ "background session can re-root to the home directory). Start the session "
60
+ "from a checkout of {owner}/{repo} and re-run."
61
+ )
62
+ ABORT_WORKTREE_BROKEN_TEMPLATE = (
63
+ "ABORT: the git worktree machinery in {cwd} is broken (git worktree list "
64
+ "failed), so EnterWorktree cannot create the branch worktree. Run "
65
+ "'git worktree prune' in {cwd} and re-run."
66
+ )
67
+ ROUTE_DIFFERENT_REPO_TEMPLATE = (
68
+ "ROUTE: resolve the PR worktree for {owner}/{repo} and cd into it before "
69
+ "any local work (cross-repo PR)."
70
+ )