claude-dev-env 1.92.1 → 1.93.1

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 (215) hide show
  1. package/CLAUDE.md +6 -2
  2. package/_shared/CLAUDE.md +2 -1
  3. package/_shared/advisor/CLAUDE.md +21 -0
  4. package/_shared/advisor/advisor-protocol.md +142 -0
  5. package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
  8. package/_shared/advisor/scripts/pyproject.toml +3 -0
  9. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
  10. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
  11. package/_shared/advisor/scripts/tier_model_ids.py +164 -0
  12. package/_shared/pr-loop/CLAUDE.md +1 -0
  13. package/_shared/pr-loop/precatch-rubric.md +65 -0
  14. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  15. package/_shared/pr-loop/scripts/code_rules_gate.py +260 -14
  16. package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  18. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
  19. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
  20. package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
  21. package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
  22. package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
  23. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
  24. package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
  25. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +292 -2
  27. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
  28. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
  29. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
  30. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
  31. package/agents/CLAUDE.md +3 -2
  32. package/agents/clasp-deployment-orchestrator.md +2 -2
  33. package/agents/code-advisor.md +7 -5
  34. package/agents/code-verifier.md +6 -2
  35. package/agents/session-advisor.md +27 -0
  36. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  37. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
  38. package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
  39. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  40. package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
  41. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
  42. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  43. package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
  44. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
  45. package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
  46. package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
  47. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
  48. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
  49. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
  50. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
  51. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
  52. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
  53. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
  54. package/bin/CLAUDE.md +1 -0
  55. package/bin/expand_home_directory_tokens.mjs +75 -0
  56. package/bin/install.mjs +21 -7
  57. package/bin/install.test.mjs +220 -10
  58. package/hooks/CLAUDE.md +1 -1
  59. package/hooks/blocking/CLAUDE.md +4 -1
  60. package/hooks/blocking/_path_setup.py +13 -0
  61. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
  62. package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
  63. package/hooks/blocking/code_rules_enforcer.py +6 -0
  64. package/hooks/blocking/code_rules_imports_logging.py +34 -1
  65. package/hooks/blocking/code_rules_paths_syspath.py +1 -1
  66. package/hooks/blocking/code_rules_test_layout.py +272 -0
  67. package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
  68. package/hooks/blocking/pii_prevention_blocker.py +781 -0
  69. package/hooks/blocking/pii_scanner.py +365 -0
  70. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  71. package/hooks/blocking/stop_dispatcher.py +84 -0
  72. package/hooks/blocking/test__path_setup.py +39 -0
  73. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
  74. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
  75. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
  76. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
  77. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
  78. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
  79. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
  80. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
  81. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
  82. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
  83. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
  84. package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
  85. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
  86. package/hooks/blocking/test_destructive_command_blocker.py +29 -9
  87. package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
  88. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
  89. package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
  90. package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
  91. package/hooks/blocking/test_pii_scanner.py +190 -0
  92. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
  93. package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
  94. package/hooks/blocking/test_stop_dispatcher.py +187 -0
  95. package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
  96. package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
  97. package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
  98. package/hooks/diagnostic/migrations/README.md +25 -26
  99. package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
  100. package/hooks/hooks.json +10 -120
  101. package/hooks/hooks_constants/CLAUDE.md +12 -3
  102. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
  103. package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
  104. package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
  105. package/hooks/hooks_constants/local_identity.py +182 -0
  106. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
  107. package/hooks/hooks_constants/pii_prevention_constants.py +295 -0
  108. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  109. package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
  110. package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
  111. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
  112. package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
  113. package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
  114. package/hooks/hooks_constants/test_layout_constants.py +28 -0
  115. package/hooks/hooks_constants/test_local_identity.py +88 -0
  116. package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
  117. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
  118. package/hooks/validators/README.md +5 -6
  119. package/hooks/validators/__init__.py +2 -2
  120. package/hooks/validators/python_style_checks.py +298 -243
  121. package/hooks/validators/run_all_validators.py +6 -0
  122. package/hooks/validators/test_python_style_checks.py +278 -163
  123. package/package.json +2 -2
  124. package/rules/CLAUDE.md +2 -1
  125. package/rules/bdd.md +1 -1
  126. package/rules/nas-ssh-invocation.md +6 -4
  127. package/rules/no-justification-noise.md +61 -0
  128. package/scripts/test_setup_project_paths.py +1 -1
  129. package/skills/CLAUDE.md +6 -3
  130. package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
  131. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  132. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  133. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
  134. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
  135. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
  136. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
  137. package/skills/autoconverge/CLAUDE.md +3 -3
  138. package/skills/autoconverge/SKILL.md +113 -35
  139. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  140. package/skills/autoconverge/reference/convergence.md +44 -22
  141. package/skills/autoconverge/reference/gotchas.md +11 -0
  142. package/skills/autoconverge/reference/stop-conditions.md +23 -13
  143. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  144. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
  145. package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
  146. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
  147. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
  148. package/skills/autoconverge/workflow/converge.mjs +329 -68
  149. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
  150. package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
  151. package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
  152. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
  153. package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
  154. package/skills/bdd-protocol/SKILL.md +4 -5
  155. package/skills/bdd-protocol/references/anti-patterns.md +1 -1
  156. package/skills/bugteam/SKILL.md +8 -0
  157. package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
  158. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
  159. package/skills/bugteam/reference/team-setup.md +7 -5
  160. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  161. package/skills/copilot-finding-triage/SKILL.md +124 -0
  162. package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
  163. package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
  164. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
  165. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
  166. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
  167. package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
  168. package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
  169. package/skills/copilot-finding-triage/templates/notification.md +34 -0
  170. package/skills/copilot-review/SKILL.md +8 -6
  171. package/skills/findbugs/SKILL.md +4 -0
  172. package/skills/fixbugs/SKILL.md +8 -7
  173. package/skills/gotcha/CLAUDE.md +2 -2
  174. package/skills/gotcha/SKILL.md +4 -4
  175. package/skills/log-audit/SKILL.md +4 -6
  176. package/skills/monitor-open-prs/CLAUDE.md +1 -1
  177. package/skills/monitor-open-prs/SKILL.md +6 -1
  178. package/skills/orchestrator/SKILL.md +74 -78
  179. package/skills/orchestrator-refresh/SKILL.md +24 -12
  180. package/skills/post-audit-findings/SKILL.md +5 -9
  181. package/skills/pr-consistency-audit/SKILL.md +5 -1
  182. package/skills/pr-converge/CLAUDE.md +1 -1
  183. package/skills/pr-converge/SKILL.md +86 -47
  184. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  185. package/skills/pr-converge/reference/convergence-gates.md +20 -17
  186. package/skills/pr-converge/reference/examples.md +63 -47
  187. package/skills/pr-converge/reference/fix-protocol.md +15 -15
  188. package/skills/pr-converge/reference/ground-rules.md +11 -7
  189. package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
  190. package/skills/pr-converge/reference/per-tick.md +129 -107
  191. package/skills/pr-converge/reference/state-schema.md +15 -10
  192. package/skills/pr-converge/scripts/check_convergence.py +5 -4
  193. package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
  194. package/skills/pr-fix-protocol/SKILL.md +3 -8
  195. package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
  196. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
  197. package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
  198. package/skills/pr-loop-lifecycle/SKILL.md +5 -9
  199. package/skills/pr-scope-resolve/SKILL.md +3 -6
  200. package/skills/privacy-hygiene/SKILL.md +114 -0
  201. package/skills/qbug/SKILL.md +8 -8
  202. package/skills/rebase/SKILL.md +5 -1
  203. package/skills/refine/SKILL.md +4 -5
  204. package/skills/reviewer-gates/SKILL.md +7 -11
  205. package/skills/session-log/SKILL.md +4 -1
  206. package/skills/skill-builder/SKILL.md +3 -6
  207. package/skills/structure-prompt/SKILL.md +4 -5
  208. package/skills/team-advisor/SKILL.md +56 -0
  209. package/skills/test_markdown_link_integrity.py +10 -6
  210. package/skills/update/SKILL.md +5 -1
  211. package/skills/usage-pause/SKILL.md +14 -5
  212. package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
  213. package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
  214. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
  215. package/skills/verified-build/SKILL.md +4 -9
@@ -0,0 +1,65 @@
1
+ # Pre-catch lens checklist
2
+
3
+ The shared checklist the internal pre-catch lenses read on demand.
4
+
5
+ ## Lane 1 — Deterministic sweep
6
+
7
+ The mechanical, static, and test classes belong to a deterministic run. The static-sweep
8
+ step owns them: it runs the CODE_RULES gate (`code_rules_gate.py --base origin/main`),
9
+ `ruff`, `mypy`, and stem-matched `pytest` over the changed files. A reading lens trusts the
10
+ sweep to have cleared them and reviews sweep-clean code.
11
+
12
+ - Every CODE_RULES violation the gate reports is a finding.
13
+ - Every `ruff` and `mypy` diagnostic on a changed file is a finding.
14
+ - Every failing test in a changed production module's paired test is a finding.
15
+ - **Proof of absence:** the gate exits 0, `ruff` and `mypy` report no diagnostic
16
+ on the changed files, and the stem-matched tests pass.
17
+
18
+ ## Lane 2 — Doc-vs-code parity
19
+
20
+ Every doc claim in the diff matches the code it describes. Reuse the
21
+ `pr-consistency-audit` skill's canonical-source cross-reference method
22
+ (`~/.claude/skills/pr-consistency-audit/SKILL.md`, canonical source first) and the
23
+ drift rubric at
24
+ `~/.claude/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md`.
25
+
26
+ - Every line citation resolves to the line it names.
27
+ - Every referenced file or script path exists.
28
+ - Every symbol is attributed to the file that defines it.
29
+ - Every inventory table, env-var table, count claim, and ordering claim matches
30
+ the code.
31
+ - **Proof of absence:** each referenced path, citation, symbol, and table checked
32
+ against its source and found in step.
33
+
34
+ ## Lane 3 — Test-assertion completeness
35
+
36
+ Every changed or new production path carries a test that exercises its behavior.
37
+
38
+ - Every changed or new production path has a paired test that calls it and asserts
39
+ on its return value or side effect.
40
+ - A changed test pins the behavior it covers rather than hiding it behind a mock.
41
+ - **Proof of absence:** each changed production path paired with a behavior test
42
+ that fails when the path regresses.
43
+
44
+ ## Lane 4 — PR-description-vs-diff two-way parity
45
+
46
+ The PR body and the diff describe the same change, both directions.
47
+
48
+ - Every claim in the PR body maps to a hunk in the diff.
49
+ - Every hunk in the diff maps to a claim in the PR body.
50
+ - Flag any invented path, invented count, or out-of-scope change.
51
+ - **Proof of absence:** the two-way map complete — every claim has a hunk and
52
+ every hunk has a claim.
53
+
54
+ ## Lane 5 — Adversarial audit
55
+
56
+ The bug-audit lens runs the adversarial second pass the audit contract specifies
57
+ (`~/.claude/_shared/pr-loop/audit-contract.md`): assume the first A-P pass missed
58
+ at least three P1 bugs, then find them.
59
+
60
+ - Return new file:line findings for the bugs the first pass missed.
61
+ - Or return a per-category proof-of-absence entry naming each re-examined category
62
+ and why it holds.
63
+ - A bare "nothing new" is not an acceptable result for this pass.
64
+ - **Proof of absence:** a per-category adversarial-probe entry for each re-examined
65
+ A-P category with no new finding.
@@ -13,7 +13,7 @@ Python scripts invoked at runtime by the PR-loop skills. Each script is a standa
13
13
  | `revoke_project_claude_permissions.py` | Removes the allow-rules and entries that `grant_project_claude_permissions.py` wrote; safe to run when no prior grant exists |
14
14
  | `code_rules_gate.py` | Pre-commit gate that runs `code_rules_enforcer` checks on staged Python files before a fix commit lands, and the terminology sweep over the staged diff |
15
15
  | `terminology_sweep.py` | Flags a prose term that near-misses an identifier introduced on added code lines of a unified diff (shared leading word, divergent tail) |
16
- | `reviews_disabled.py` | Shared helper for the `CLAUDE_REVIEWS_DISABLED` opt-out gate; parses the env-var token to find which reviewer types are suppressed |
16
+ | `reviews_disabled.py` | Shared helper for the reviewer opt-out and opt-in gates; parses `CLAUDE_REVIEWS_DISABLED` and `CLAUDE_REVIEWS_ENABLED` tokens to decide which reviewers run (bugbot is off by default and runs only when `CLAUDE_REVIEWS_ENABLED` lists it) |
17
17
  | `copilot_quota.py` | Copilot premium-request quota pre-check: resolves a configured GitHub account, reads its remaining `premium_interactions` quota via `gh api copilot_internal/user`, and exits 0 (run Copilot) or non-zero (skip: out of quota, API down, or no account configured) |
18
18
  | `reviewer_availability.py` | Unified reviewer-availability entry point for Copilot and Bugbot: reuses `copilot_quota.py` and `reviews_disabled.py` and exits 0 when the named `--reviewer` may be spawned, non-zero when it is opted out or (for Copilot) out of quota |
19
19
  | `fix_hookspath.py` | Repairs a malformed `core.hooksPath` global git config entry |
@@ -1,6 +1,7 @@
1
1
  import argparse
2
2
  import ast
3
3
  import importlib.util
4
+ import os
4
5
  import re
5
6
  import subprocess
6
7
  import sys
@@ -15,13 +16,19 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
15
16
  ALL_CODE_FILE_EXTENSIONS,
16
17
  ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND,
17
18
  ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX,
19
+ ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS,
18
20
  ALL_PYTEST_CONFIG_FILE_SECTIONS,
19
21
  ALL_PYTEST_MODULE_INVOCATION,
20
22
  ALL_TEST_FILENAME_GLOB_SUFFIXES,
21
23
  ALL_TEST_FILENAME_SUFFIXES,
24
+ ALL_VENV_DIRECTORY_NAMES,
25
+ ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS,
22
26
  BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX,
23
27
  BANNED_NOUN_SPAN_GROUP_INDEX,
24
28
  BANNED_NOUN_VIOLATION_PATTERN,
29
+ CODE_RULES_GATE_PYTHON_ENV_VAR,
30
+ CODE_RULES_GATE_PYTHONPATH_ENV_VAR,
31
+ COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH,
25
32
  DUPLICATE_BODY_DEFINITION_LINE_GROUP_INDEX,
26
33
  DUPLICATE_BODY_SPAN_GROUP_INDEX,
27
34
  DUPLICATE_BODY_VIOLATION_PATTERN,
@@ -35,8 +42,12 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
35
42
  ISOLATION_DEFINITION_LINE_GROUP_INDEX,
36
43
  ISOLATION_SPAN_GROUP_INDEX,
37
44
  ISOLATION_VIOLATION_PATTERN,
45
+ MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS,
38
46
  MAX_VIOLATIONS_PER_CHECK,
47
+ MINIMUM_STAGED_PYTEST_PYTHON_MAJOR,
48
+ MINIMUM_STAGED_PYTEST_PYTHON_MINOR,
39
49
  PYTHON_FILE_EXTENSION,
50
+ PYTHONPATH_ENV_VAR,
40
51
  STAGED_PYTEST_TIMEOUT_SECONDS,
41
52
  STAGED_TEST_FAILURE_HEADER,
42
53
  STAGED_TEST_GROUP_FAILURE_MESSAGE,
@@ -1620,33 +1631,230 @@ def _group_staged_tests_by_root(
1620
1631
  return tests_by_root
1621
1632
 
1622
1633
 
1623
- def _run_pytest_for_group(group_root: Path, all_group_test_paths: list[Path]) -> int:
1624
- """Run pytest over one group's test files with the working directory at its root.
1634
+ def _venv_python_relative_path() -> tuple[str, ...]:
1635
+ """Return the venv-relative python executable path segments for this platform.
1636
+
1637
+ Returns:
1638
+ The Windows ``Scripts/python.exe`` segments on Windows, the POSIX
1639
+ ``bin/python`` segments on every other platform.
1640
+ """
1641
+ if os.name == "nt":
1642
+ return ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS
1643
+ return ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS
1644
+
1645
+
1646
+ def _venv_python_executable(repository_root: Path) -> Path | None:
1647
+ """Return the first existing venv interpreter under *repository_root*.
1648
+
1649
+ Checks each directory name in ``ALL_VENV_DIRECTORY_NAMES`` in order and
1650
+ returns the platform-specific interpreter path the first time it exists
1651
+ on disk.
1625
1652
 
1626
1653
  Args:
1627
- group_root: The owning test root used as the pytest working directory.
1628
- all_group_test_paths: The staged test files that share *group_root*.
1654
+ repository_root: The repository root to search for a venv directory.
1655
+
1656
+ Returns:
1657
+ The resolved interpreter path, or None when no candidate exists.
1658
+ """
1659
+ relative_python_path = _venv_python_relative_path()
1660
+ for each_venv_directory_name in ALL_VENV_DIRECTORY_NAMES:
1661
+ candidate_path = repository_root.joinpath(
1662
+ each_venv_directory_name, *relative_python_path
1663
+ )
1664
+ if candidate_path.is_file():
1665
+ return candidate_path
1666
+ return None
1667
+
1668
+
1669
+ def _resolve_gate_python_executable(repository_root: Path) -> str:
1670
+ """Return the interpreter the staged-test subprocess runs under.
1671
+
1672
+ Resolves in order: the ``CODE_RULES_GATE_PYTHON`` environment variable
1673
+ when set, then the first existing project venv interpreter under
1674
+ *repository_root*, then the interpreter running the gate itself.
1675
+
1676
+ Args:
1677
+ repository_root: The repository root used to locate a project venv.
1678
+
1679
+ Returns:
1680
+ The absolute path (or command name) of the interpreter to invoke.
1681
+ """
1682
+ configured_python = os.environ.get(CODE_RULES_GATE_PYTHON_ENV_VAR)
1683
+ if configured_python:
1684
+ return configured_python
1685
+ venv_python = _venv_python_executable(repository_root)
1686
+ if venv_python is not None:
1687
+ return str(venv_python)
1688
+ return sys.executable
1689
+
1690
+
1691
+ def _staged_pytest_environment() -> dict[str, str]:
1692
+ """Return the subprocess environment for the staged-test pytest run.
1693
+
1694
+ Starts from ``repository_environment()`` (the GIT_-scrubbed process
1695
+ environment) and, when ``CODE_RULES_GATE_PYTHONPATH`` is set, prepends
1696
+ its value to ``PYTHONPATH`` so a resolved venv interpreter that lacks the
1697
+ project on its default path can still import it.
1698
+
1699
+ Returns:
1700
+ The environment mapping to pass to the staged-test subprocess.
1701
+ """
1702
+ environment = repository_environment()
1703
+ configured_pythonpath = os.environ.get(CODE_RULES_GATE_PYTHONPATH_ENV_VAR)
1704
+ if not configured_pythonpath:
1705
+ return environment
1706
+ existing_pythonpath = environment.get(PYTHONPATH_ENV_VAR, "")
1707
+ environment[PYTHONPATH_ENV_VAR] = (
1708
+ configured_pythonpath
1709
+ if not existing_pythonpath
1710
+ else os.pathsep.join([configured_pythonpath, existing_pythonpath])
1711
+ )
1712
+ return environment
1713
+
1714
+
1715
+ def _relative_pytest_argument(test_path: Path, group_root: Path) -> str:
1716
+ """Express *test_path* as a pytest argument relative to *group_root*.
1717
+
1718
+ *group_root* is already the pytest working directory, so the relative form
1719
+ names the same file in far fewer characters.
1720
+
1721
+ Example::
1722
+
1723
+ group_root = C:/repo/package
1724
+ ok: C:/repo/package/tests/test_a.py -> "tests/test_a.py"
1725
+ flag: C:/elsewhere/test_b.py -> no relative form; passed absolute
1726
+
1727
+ Args:
1728
+ test_path: The staged test file to express as a pytest argument.
1729
+ group_root: The pytest working directory for the group.
1730
+
1731
+ Returns:
1732
+ The path relative to *group_root* when *test_path* sits under it,
1733
+ otherwise the absolute path unchanged.
1734
+ """
1735
+ try:
1736
+ return str(test_path.relative_to(group_root))
1737
+ except ValueError:
1738
+ return str(test_path)
1739
+
1740
+
1741
+ def _batched_pytest_arguments(
1742
+ all_pytest_arguments: list[str], character_budget: int
1743
+ ) -> list[list[str]]:
1744
+ """Split pytest path arguments into command-line-length-safe batches.
1745
+
1746
+ Windows rejects a command line past 32767 characters with WinError 206, so a
1747
+ large staged set passed in one invocation crashes the gate and denies the
1748
+ commit. Each batch takes arguments until the next one would push its joined
1749
+ length past *character_budget*.
1750
+
1751
+ Example::
1752
+
1753
+ character_budget = 10
1754
+ arguments = ["aaaa", "bbbb", "cccc"]
1755
+ ok: [["aaaa", "bbbb"], ["cccc"]] - every batch within budget
1756
+ flag: [["aaaa", "bbbb", "cccc"]] - 14 characters, over budget
1757
+
1758
+ Args:
1759
+ all_pytest_arguments: The path arguments to distribute, in order.
1760
+ character_budget: The joined length each batch must stay within. An
1761
+ argument wider than the budget on its own still lands in a batch
1762
+ by itself, so no argument is ever dropped.
1629
1763
 
1630
1764
  Returns:
1631
- 0 when the group's tests pass or collect nothing; pytest's non-zero
1632
- exit code otherwise.
1765
+ One argument list per pytest invocation, preserving input order.
1766
+ """
1767
+ all_batches: list[list[str]] = []
1768
+ current_batch: list[str] = []
1769
+ current_length = 0
1770
+ for each_argument in all_pytest_arguments:
1771
+ argument_length = len(each_argument) + COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH
1772
+ if current_batch and current_length + argument_length > character_budget:
1773
+ all_batches.append(current_batch)
1774
+ current_batch = []
1775
+ current_length = 0
1776
+ current_batch.append(each_argument)
1777
+ current_length += argument_length
1778
+ if current_batch:
1779
+ all_batches.append(current_batch)
1780
+ return all_batches
1781
+
1782
+
1783
+ def _pytest_batch_exit_code(all_batch_command: list[str], group_root: Path) -> int:
1784
+ """Run one pytest invocation and normalize its exit code.
1785
+
1786
+ Args:
1787
+ all_batch_command: The full argv for this pytest invocation.
1788
+ group_root: The owning test root used as the working directory.
1789
+
1790
+ Returns:
1791
+ 0 when the batch passes or collects no tests; pytest's non-zero exit
1792
+ code otherwise.
1633
1793
  """
1634
1794
  pytest_process = subprocess.run(
1635
- [
1636
- sys.executable,
1637
- *ALL_PYTEST_MODULE_INVOCATION,
1638
- *[str(each_path) for each_path in all_group_test_paths],
1639
- ],
1795
+ all_batch_command,
1640
1796
  cwd=str(group_root),
1641
1797
  timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
1642
1798
  check=False,
1643
- env=repository_environment(),
1799
+ env=_staged_pytest_environment(),
1644
1800
  )
1645
1801
  if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
1646
1802
  return 0
1647
1803
  return pytest_process.returncode
1648
1804
 
1649
1805
 
1806
+ def _run_pytest_for_group(
1807
+ group_root: Path, all_group_test_paths: list[Path], repository_root: Path
1808
+ ) -> int:
1809
+ """Run pytest over one group's test files with the working directory at its root.
1810
+
1811
+ Paths are passed relative to *group_root* (the subprocess working directory)
1812
+ and split into batches whose joined command line stays within
1813
+ ``MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS``, so a large staged set never
1814
+ trips the Windows 32767-character limit. A staged set that fits the budget runs
1815
+ as one invocation, exactly as an unbatched run did.
1816
+
1817
+ Example::
1818
+
1819
+ ok: 3 staged tests -> 1 pytest invocation, argv well under the budget
1820
+ flag: 800 staged tests -> 1 pytest invocation, argv over the Windows limit
1821
+ (WinError 206); batching splits it across several
1822
+
1823
+ Args:
1824
+ group_root: The owning test root used as the pytest working directory.
1825
+ all_group_test_paths: The staged test files that share *group_root*.
1826
+ repository_root: The repository root used to resolve a project venv
1827
+ interpreter when ``CODE_RULES_GATE_PYTHON`` is not set.
1828
+
1829
+ Returns:
1830
+ 0 when every batch passes or collects nothing; the first failing batch's
1831
+ non-zero pytest exit code otherwise.
1832
+ """
1833
+ all_fixed_command = [
1834
+ _resolve_gate_python_executable(repository_root),
1835
+ *ALL_PYTEST_MODULE_INVOCATION,
1836
+ ]
1837
+ fixed_command_length = sum(
1838
+ len(each_part) + COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH
1839
+ for each_part in all_fixed_command
1840
+ )
1841
+ all_batches = _batched_pytest_arguments(
1842
+ [
1843
+ _relative_pytest_argument(each_path, group_root)
1844
+ for each_path in all_group_test_paths
1845
+ ],
1846
+ MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS - fixed_command_length,
1847
+ )
1848
+ first_failing_exit_code = 0
1849
+ for each_batch in all_batches:
1850
+ batch_exit_code = _pytest_batch_exit_code(
1851
+ [*all_fixed_command, *each_batch], group_root
1852
+ )
1853
+ if batch_exit_code != 0 and first_failing_exit_code == 0:
1854
+ first_failing_exit_code = batch_exit_code
1855
+ return first_failing_exit_code
1856
+
1857
+
1650
1858
  def run_staged_test_files(repository_root: Path) -> int:
1651
1859
  """Run pytest over the staged test files and return the gate exit code.
1652
1860
 
@@ -1670,7 +1878,7 @@ def run_staged_test_files(repository_root: Path) -> int:
1670
1878
  first_failing_exit_code = 0
1671
1879
  for each_group_root in sorted(tests_by_root):
1672
1880
  group_exit_code = _run_pytest_for_group(
1673
- each_group_root, tests_by_root[each_group_root]
1881
+ each_group_root, tests_by_root[each_group_root], repository_root
1674
1882
  )
1675
1883
  if group_exit_code != 0:
1676
1884
  print(
@@ -1684,6 +1892,42 @@ def run_staged_test_files(repository_root: Path) -> int:
1684
1892
  return first_failing_exit_code
1685
1893
 
1686
1894
 
1895
+ def _staged_pytest_exit_code_for_current_python(repository_root: Path) -> int:
1896
+ """Run the staged test files, or skip them on a Python below the minimum.
1897
+
1898
+ ::
1899
+
1900
+ running (3, 11), minimum (3, 12) -> log a notice and return 0 (skipped)
1901
+ running (3, 12), minimum (3, 12) -> run pytest over the staged tests
1902
+
1903
+ The staged tests call newer-Python APIs, so an older interpreter fails them
1904
+ for the environment rather than for a code defect. On such a runtime this
1905
+ step is skipped so the gap does not block the commit, while the terminology
1906
+ and code-rules checks still run.
1907
+
1908
+ Args:
1909
+ repository_root: The repository root whose staged test files run.
1910
+
1911
+ Returns:
1912
+ 0 when the running Python is below the staged-test minimum, otherwise the
1913
+ exit code from running the staged test files.
1914
+ """
1915
+ running_version = (sys.version_info.major, sys.version_info.minor)
1916
+ minimum_version = (
1917
+ MINIMUM_STAGED_PYTEST_PYTHON_MAJOR,
1918
+ MINIMUM_STAGED_PYTEST_PYTHON_MINOR,
1919
+ )
1920
+ if running_version < minimum_version:
1921
+ print(
1922
+ f"code_rules_gate: Python {running_version} is below the staged-test "
1923
+ f"minimum {minimum_version}; skipping the staged "
1924
+ "pytest step (environment gap, not a code failure).",
1925
+ file=sys.stderr,
1926
+ )
1927
+ return 0
1928
+ return run_staged_test_files(repository_root)
1929
+
1930
+
1687
1931
  def _report_terminology_findings(all_findings: list[str]) -> None:
1688
1932
  """Print the terminology-sweep findings, when any, to standard error.
1689
1933
 
@@ -1782,7 +2026,9 @@ def main(all_arguments: list[str]) -> int:
1782
2026
  if arguments.staged:
1783
2027
  all_terminology_findings = staged_terminology_findings(repository_root)
1784
2028
  _report_terminology_findings(all_terminology_findings)
1785
- staged_test_exit_code = run_staged_test_files(repository_root)
2029
+ staged_test_exit_code = _staged_pytest_exit_code_for_current_python(
2030
+ repository_root
2031
+ )
1786
2032
  staged_file_paths = paths_from_git_staged(repository_root)
1787
2033
  staged_file_paths = filter_paths_under_prefixes(
1788
2034
  staged_file_paths,
@@ -223,7 +223,7 @@ def parse_command_line_arguments(all_arguments: list[str]) -> argparse.Namespace
223
223
  parser.add_argument(
224
224
  CLI_FLAG_REPO,
225
225
  required=True,
226
- help="Repository name (e.g., claude-code-config).",
226
+ help="Repository name (e.g., claude-dev-env).",
227
227
  )
228
228
  parser.add_argument(
229
229
  CLI_FLAG_PR_NUMBER,
@@ -14,7 +14,7 @@ Named constants for every script in `_shared/pr-loop/scripts/`. Each module owns
14
14
  | `post_audit_thread_constants.py` | `post_audit_thread.py` — HTTP status codes, retry counts, GitHub API paths |
15
15
  | `preflight_constants.py` | `preflight.py` — env-var names, git subcommands, pytest exit codes, test discovery patterns |
16
16
  | `preflight_self_heal_constants.py` | `preflight_self_heal.py` — git config keys and local-scope detection strings |
17
- | `reviews_disabled_constants.py` | `reviews_disabled.py` — `CLAUDE_REVIEWS_DISABLED` token taxonomy |
17
+ | `reviews_disabled_constants.py` | `reviews_disabled.py` — `CLAUDE_REVIEWS_DISABLED` and `CLAUDE_REVIEWS_ENABLED` env-var names and token taxonomy |
18
18
  | `copilot_quota_constants.py` | `copilot_quota.py` — `COPILOT_QUOTA_ACCOUNT` env-var name, `copilot_internal/user` API path, the `premium_interactions` gating field names, the four skip/run exit codes, and the default `.env` path |
19
19
  | `terminology_sweep_constants.py` | `terminology_sweep.py` — identifier and prose-token regexes, diff-parsing prefixes, code-file extensions, test-file detection patterns, and the finding-message template |
20
20
  | `reviewer_availability_constants.py` | `reviewer_availability.py` — the available/down exit codes for the unified reviewer-availability pre-check |
@@ -88,8 +88,24 @@ ALL_PYTEST_MODULE_INVOCATION: tuple[str, ...] = (
88
88
  "-q",
89
89
  )
90
90
 
91
+ CODE_RULES_GATE_PYTHON_ENV_VAR: str = "CODE_RULES_GATE_PYTHON"
92
+
93
+ CODE_RULES_GATE_PYTHONPATH_ENV_VAR: str = "CODE_RULES_GATE_PYTHONPATH"
94
+
95
+ PYTHONPATH_ENV_VAR: str = "PYTHONPATH"
96
+
97
+ ALL_VENV_DIRECTORY_NAMES: tuple[str, ...] = (".venv", "venv")
98
+
99
+ ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS: tuple[str, ...] = ("Scripts", "python.exe")
100
+
101
+ ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS: tuple[str, ...] = ("bin", "python")
102
+
91
103
  STAGED_PYTEST_TIMEOUT_SECONDS: int = 600
92
104
 
105
+ MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS: int = 24000
106
+
107
+ COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH: int = 1
108
+
93
109
  STAGED_TEST_FAILURE_HEADER: str = (
94
110
  "code_rules_gate: staged test file(s) failed under pytest; commit blocked."
95
111
  )
@@ -119,3 +135,7 @@ STAGED_TEST_GROUP_FAILURE_MESSAGE: str = (
119
135
  "code_rules_gate: staged test group rooted at {group_root} "
120
136
  "failed under pytest; commit blocked."
121
137
  )
138
+
139
+ MINIMUM_STAGED_PYTEST_PYTHON_MAJOR: int = 3
140
+
141
+ MINIMUM_STAGED_PYTEST_PYTHON_MINOR: int = 12
@@ -1,8 +1,9 @@
1
- """Configuration constants for the CLAUDE_REVIEWS_DISABLED opt-out gate."""
1
+ """Configuration constants for the reviewer opt-out and opt-in gates."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME: str = "CLAUDE_REVIEWS_DISABLED"
6
+ CLAUDE_REVIEWS_ENABLED_ENV_VAR_NAME: str = "CLAUDE_REVIEWS_ENABLED"
6
7
  CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR: str = ","
7
8
  CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN: str = "bugteam"
8
9
  CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN: str = "bugbot"
@@ -11,9 +11,11 @@ Copilot counts as down in two cases: it is opted out via
11
11
  than quota available (out of quota, the quota API or account down, or no
12
12
  account configured).
13
13
 
14
- Bugbot carries no quota or availability API. It counts as down only when it
15
- is opted out via ``CLAUDE_REVIEWS_DISABLED``. A genuine runtime outage shows
16
- up later as a poll timeout, not here.
14
+ Bugbot carries no quota or availability API. It is off by default and counts
15
+ as down whenever it is disabled for the run: the default unless
16
+ ``CLAUDE_REVIEWS_ENABLED`` lists ``bugbot``, and always when
17
+ ``CLAUDE_REVIEWS_DISABLED`` lists it. A genuine runtime outage shows up later
18
+ as a poll timeout, not here.
17
19
 
18
20
  The exit code tells the caller what to do. Exit 0 means the reviewer is
19
21
  available and may be spawned. The documented down code (3) means skip it. Any
@@ -42,8 +44,13 @@ from pr_loop_shared_constants.reviews_disabled_constants import (
42
44
  CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN,
43
45
  CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN,
44
46
  CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME,
47
+ CLAUDE_REVIEWS_ENABLED_ENV_VAR_NAME,
48
+ )
49
+ from reviews_disabled import (
50
+ is_bugbot_disabled_via_env,
51
+ is_bugbot_opted_out_via_env,
52
+ is_copilot_disabled_via_env,
45
53
  )
46
- from reviews_disabled import is_bugbot_disabled_via_env, is_copilot_disabled_via_env
47
54
 
48
55
 
49
56
  @dataclass(frozen=True)
@@ -84,22 +91,45 @@ def _evaluate_copilot_availability(env_file_path: Path) -> ReviewerAvailability:
84
91
  return ReviewerAvailability(EXIT_CODE_REVIEWER_AVAILABLE, quota_decision.message)
85
92
 
86
93
 
94
+ def _bugbot_down_message() -> str:
95
+ """Name the cause that keeps Cursor Bugbot off for this run.
96
+
97
+ An opt-out and the off-by-default state read differently to the operator:
98
+ one names ``CLAUDE_REVIEWS_DISABLED``, the other points at the
99
+ ``CLAUDE_REVIEWS_ENABLED`` opt-in the run is missing.
100
+
101
+ Returns:
102
+ The opt-out message when ``CLAUDE_REVIEWS_DISABLED`` lists ``bugbot``,
103
+ and the off-by-default opt-in message otherwise.
104
+ """
105
+ if is_bugbot_opted_out_via_env():
106
+ return (
107
+ f"reviewer-availability: bugbot is disabled via "
108
+ f"{CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME} — skipping."
109
+ )
110
+ return (
111
+ f"reviewer-availability: bugbot is off by default — set "
112
+ f"{CLAUDE_REVIEWS_ENABLED_ENV_VAR_NAME}={CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN} "
113
+ f"to opt in — skipping."
114
+ )
115
+
116
+
87
117
  def _evaluate_bugbot_availability() -> ReviewerAvailability:
88
118
  """Decide whether Cursor Bugbot is available to spawn.
89
119
 
90
120
  Bugbot carries no quota or availability API, so this checks only the
91
- deterministic ``CLAUDE_REVIEWS_DISABLED`` opt-out.
121
+ deterministic enable/disable env gate. Bugbot is off by default: it is
122
+ available only when ``CLAUDE_REVIEWS_ENABLED`` lists ``bugbot`` and
123
+ ``CLAUDE_REVIEWS_DISABLED`` does not.
92
124
 
93
125
  Returns:
94
- A ReviewerAvailability that is down when Bugbot is opted out via
95
- ``CLAUDE_REVIEWS_DISABLED``, and available otherwise.
126
+ A ReviewerAvailability that is down whenever Bugbot is disabled for
127
+ the run the default unless ``CLAUDE_REVIEWS_ENABLED`` lists
128
+ ``bugbot``, and always when ``CLAUDE_REVIEWS_DISABLED`` lists it — and
129
+ available otherwise.
96
130
  """
97
131
  if is_bugbot_disabled_via_env():
98
- return ReviewerAvailability(
99
- EXIT_CODE_REVIEWER_DOWN,
100
- f"reviewer-availability: bugbot is disabled via "
101
- f"{CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME} — skipping.",
102
- )
132
+ return ReviewerAvailability(EXIT_CODE_REVIEWER_DOWN, _bugbot_down_message())
103
133
  return ReviewerAvailability(
104
134
  EXIT_CODE_REVIEWER_AVAILABLE,
105
135
  "reviewer-availability: bugbot is available.",