claude-dev-env 2.4.0 → 2.5.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 (195) hide show
  1. package/CLAUDE.md +53 -49
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  9. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  12. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  14. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  16. package/_shared/pr-loop/worker-spawn.md +1 -1
  17. package/agents/CLAUDE.md +2 -1
  18. package/agents/caveman.md +0 -1
  19. package/agents/clasp-deployment-orchestrator.md +0 -1
  20. package/agents/clean-coder.md +0 -1
  21. package/agents/code-advisor.md +0 -1
  22. package/agents/code-quality-agent.md +1 -2
  23. package/agents/code-verifier.md +0 -1
  24. package/agents/deep-research.md +0 -1
  25. package/agents/docs-agent.md +0 -1
  26. package/agents/git-commit-crafter.md +0 -1
  27. package/agents/issue-tracker.md +42 -0
  28. package/agents/plan-packet-validator.md +0 -1
  29. package/agents/pr-description-writer.md +0 -1
  30. package/agents/test_agent_frontmatter.py +67 -18
  31. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
  32. package/bin/CLAUDE.md +68 -5
  33. package/bin/ever-shipped-skills.mjs +1 -0
  34. package/bin/install-constants.mjs +88 -0
  35. package/bin/install.mjs +1138 -114
  36. package/bin/install.prune.test.mjs +869 -19
  37. package/bin/install.test.mjs +906 -2
  38. package/commands/implement.md +1 -1
  39. package/commands/right-size.md +1 -1
  40. package/docs/CLAUDE.md +1 -0
  41. package/docs/host-pool-health-monitor.md +102 -0
  42. package/docs/references/CLAUDE.md +4 -2
  43. package/docs/references/advisor-tool.md +13 -0
  44. package/docs/references/code-review-enforcement.md +10 -0
  45. package/docs/references/team-advisor-skill.md +14 -0
  46. package/hooks/blocking/CLAUDE.md +1 -0
  47. package/hooks/blocking/code_review_pr_create_gate.py +7 -3
  48. package/hooks/blocking/code_review_push_gate.py +9 -4
  49. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
  50. package/hooks/blocking/config/__init__.py +5 -5
  51. package/hooks/blocking/config/code_review_enforcement_constants.py +4 -1
  52. package/hooks/blocking/config/test_code_review_enforcement_constants.py +5 -0
  53. package/hooks/blocking/config/verified_commit_constants.py +160 -159
  54. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  55. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  56. package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
  57. package/hooks/blocking/test_code_review_push_gate.py +16 -0
  58. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
  59. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  60. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
  61. package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
  62. package/hooks/blocking/test_verification_verdict_store.py +54 -0
  63. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  64. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  65. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  66. package/hooks/blocking/verification_verdict_store.py +1039 -1036
  67. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  68. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  69. package/hooks/git-hooks/test_pre_push.py +25 -0
  70. package/hooks/hooks.json +10 -0
  71. package/hooks/hooks_constants/CLAUDE.md +2 -1
  72. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  73. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  74. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  75. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  76. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  77. package/hooks/pyproject.toml +1 -0
  78. package/hooks/validators/CLAUDE.md +1 -0
  79. package/hooks/validators/config/__init__.py +0 -0
  80. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  81. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  82. package/hooks/validators/conftest.py +4 -0
  83. package/hooks/validators/ruff_integration.py +49 -5
  84. package/hooks/validators/run_all_validators.py +206 -9
  85. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  86. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  87. package/hooks/validators/test_ruff_integration.py +92 -1
  88. package/hooks/validators/test_run_all_validators.py +115 -68
  89. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  90. package/package.json +10 -2
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +45 -44
  93. package/rules/state-what-is.md +25 -0
  94. package/rules/verified-commit-gate-skip.md +1 -1
  95. package/scripts/CLAUDE.md +1 -0
  96. package/scripts/Capture-PoolHealth.ps1 +410 -0
  97. package/scripts/_code_review_test_support.py +404 -0
  98. package/scripts/claude_chain_runner.py +141 -1
  99. package/scripts/conftest.py +16 -1
  100. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  101. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
  102. package/scripts/resolve_worker_spawn.py +626 -626
  103. package/scripts/spawn_grok_batch.py +672 -672
  104. package/scripts/test_claude_chain_runner.py +131 -0
  105. package/scripts/test_invoke_code_review_chain.py +70 -0
  106. package/scripts/test_invoke_code_review_cli.py +192 -0
  107. package/scripts/test_invoke_code_review_contract.py +256 -0
  108. package/scripts/test_invoke_code_review_git.py +123 -0
  109. package/scripts/test_invoke_code_review_mode.py +99 -0
  110. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  111. package/skills/CLAUDE.md +2 -0
  112. package/skills/auditing-claude-config/SKILL.md +114 -114
  113. package/skills/autoconverge/SKILL.md +427 -427
  114. package/skills/autoconverge/reference/convergence.md +24 -3
  115. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  116. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
  117. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
  118. package/skills/autoconverge/workflow/converge.mjs +167 -0
  119. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  120. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  121. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  122. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  123. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  124. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  125. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  126. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  127. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  128. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  129. package/skills/closeout/SKILL.md +33 -50
  130. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  131. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  132. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  133. package/skills/condensing-instructions/SKILL.md +81 -0
  134. package/skills/copilot-review/SKILL.md +119 -119
  135. package/skills/e-code-review/SKILL.md +52 -0
  136. package/skills/e-code-review/reference/fix.md +54 -0
  137. package/skills/e-code-review/reference/loop.md +43 -0
  138. package/skills/e-code-review/reference/low.md +57 -0
  139. package/skills/e-code-review/reference/medium.md +153 -0
  140. package/skills/e-code-review/reference/xhigh.md +182 -0
  141. package/skills/e-simplify/SKILL.md +97 -0
  142. package/skills/issue-tracker/SKILL.md +92 -0
  143. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  144. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  145. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  146. package/skills/orchestrator/SKILL.md +162 -21
  147. package/skills/orchestrator/scripts/status_gate.py +625 -0
  148. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  149. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  150. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  151. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  152. package/skills/orchestrator-refresh/SKILL.md +110 -35
  153. package/skills/plan-to-pr/SKILL.md +155 -0
  154. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  155. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  156. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  157. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  158. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  159. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  160. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  161. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  162. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  163. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  164. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  165. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  166. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  167. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  168. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  169. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  170. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  171. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  172. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  173. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  174. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  175. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  176. package/skills/pr-converge/SKILL.md +472 -469
  177. package/skills/pr-converge/reference/examples.md +3 -3
  178. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  179. package/skills/pr-converge/reference/ground-rules.md +7 -4
  180. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  181. package/skills/pr-converge/reference/per-tick.md +5 -5
  182. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  183. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  184. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  185. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  186. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  187. package/skills/pr-fix-protocol/SKILL.md +1 -1
  188. package/skills/privacy-hygiene/SKILL.md +68 -68
  189. package/skills/prototype/workflows/promotion.md +1 -1
  190. package/skills/release-notes-html/SKILL.md +164 -0
  191. package/skills/task-build/CLAUDE.md +8 -7
  192. package/skills/task-build/SKILL.md +16 -8
  193. package/skills/task-build/reference/tool-routing.md +19 -0
  194. package/scripts/test_invoke_code_review.py +0 -966
  195. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -0,0 +1,404 @@
1
+ """Shared fixtures and seams for the ``/code-review`` invoker test suite.
2
+
3
+ Every ``test_invoke_code_review_*`` module imports its fixture values, its
4
+ chain-outcome builders, its runner shortcuts, and its seam installer from here
5
+ so the behavioral groups stay small and none of the shared support is copied
6
+ between them.
7
+
8
+ This module inserts the scripts directory on ``sys.path`` before its flat
9
+ import list, so the invoker and its constants package resolve by bare name
10
+ whether or not pytest's conftest has already registered the directory.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import subprocess
16
+ import sys
17
+ from collections.abc import Callable, Sequence
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ import pytest
22
+
23
+ _SCRIPTS_DIRECTORY = str(Path(__file__).resolve().parent)
24
+ if _SCRIPTS_DIRECTORY not in sys.path:
25
+ sys.path.insert(0, _SCRIPTS_DIRECTORY)
26
+
27
+ import claude_chain_runner as chain_runner # noqa: E402
28
+ import invoke_code_review as invoker # noqa: E402
29
+ from claude_chain_runner import ChainAttempt, ChainInvocationOutcome # noqa: E402
30
+ from dev_env_scripts_constants.code_review_constants import ( # noqa: E402
31
+ CLI_SESSION_MODEL_FLAG,
32
+ CODE_REVIEW_MODEL_ALIAS,
33
+ GIT_BINARY,
34
+ RECORD_STAMP_FLAG,
35
+ )
36
+ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
37
+ CLI_TIMEOUT_FLAG,
38
+ CWD_FLAG,
39
+ )
40
+ from dev_env_scripts_constants.timing import ( # noqa: E402
41
+ DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
42
+ )
43
+
44
+ HOST_PROFILE_CLAUDE = "Claude"
45
+ HOST_PROFILE_THIRD_PARTY = "ThirdParty"
46
+
47
+ FIXTURE_SERVED_COMMAND = "claude"
48
+ FIXTURE_CHAIN_RETURNCODE = 0
49
+ FIXTURE_FAILED_RETURNCODE = 1
50
+ FIXTURE_GIT_STATUS_FAILURE_RETURNCODE = 128
51
+ FIXTURE_CHAIN_STDOUT = '{"result":"review done"}'
52
+ FIXTURE_SESSION_OPUS = "opus"
53
+ FIXTURE_SESSION_OPUS_UPPER = "Opus"
54
+ FIXTURE_SESSION_SONNET = "sonnet"
55
+ FIXTURE_SESSION_HAIKU = "haiku"
56
+ FIXTURE_CHAIN_CONFIG_ERROR_MESSAGE = "chain config missing"
57
+ FIXTURE_HOST_PROFILE_ERROR_MESSAGE = "unknown host profile"
58
+ DIRTY_FILE_NAME = "review_fix.txt"
59
+ DIRTY_FILE_CONTENTS = "applied fix\n"
60
+ GIT_INIT_TIMEOUT_SECONDS = 30
61
+ REPOSITORY_USER_EMAIL = "reviewer@example.com"
62
+ REPOSITORY_USER_NAME = "Reviewer"
63
+ PINNED_INITIAL_BRANCH = "main"
64
+
65
+
66
+ def _run_git(*git_arguments: str, working_directory: Path | None = None) -> None:
67
+ all_git_tokens = [GIT_BINARY]
68
+ if working_directory is not None:
69
+ all_git_tokens += ["-C", str(working_directory)]
70
+ all_git_tokens += list(git_arguments)
71
+ subprocess.run(
72
+ all_git_tokens,
73
+ check=True,
74
+ capture_output=True,
75
+ text=True,
76
+ timeout=GIT_INIT_TIMEOUT_SECONDS,
77
+ )
78
+
79
+
80
+ def _initialize_repository(repository_directory: Path) -> None:
81
+ repository_directory.mkdir(parents=True, exist_ok=True)
82
+ _run_git(
83
+ "init",
84
+ "--initial-branch",
85
+ PINNED_INITIAL_BRANCH,
86
+ working_directory=repository_directory,
87
+ )
88
+ _run_git(
89
+ "config",
90
+ "user.email",
91
+ REPOSITORY_USER_EMAIL,
92
+ working_directory=repository_directory,
93
+ )
94
+ _run_git(
95
+ "config",
96
+ "user.name",
97
+ REPOSITORY_USER_NAME,
98
+ working_directory=repository_directory,
99
+ )
100
+
101
+
102
+ def write_dirty_tree(working_directory: Path) -> None:
103
+ dirty_file = working_directory / DIRTY_FILE_NAME
104
+ dirty_file.write_text(DIRTY_FILE_CONTENTS, encoding="utf-8")
105
+
106
+
107
+ def run_record_stamp_cli(working_directory: Path, *, effort: str) -> int:
108
+ return invoker.main(
109
+ [
110
+ CWD_FLAG,
111
+ str(working_directory),
112
+ CLI_SESSION_MODEL_FLAG,
113
+ CODE_REVIEW_MODEL_ALIAS,
114
+ RECORD_STAMP_FLAG,
115
+ effort,
116
+ ]
117
+ )
118
+
119
+
120
+ def claude_served(
121
+ *,
122
+ returncode: int = FIXTURE_CHAIN_RETURNCODE,
123
+ stdout: str = FIXTURE_CHAIN_STDOUT,
124
+ ) -> ChainInvocationOutcome:
125
+ return ChainInvocationOutcome(
126
+ served_command=FIXTURE_SERVED_COMMAND,
127
+ returncode=returncode,
128
+ stdout=stdout,
129
+ stderr="",
130
+ attempts=(ChainAttempt(command=FIXTURE_SERVED_COMMAND, status="served"),),
131
+ )
132
+
133
+
134
+ def claude_failed() -> ChainInvocationOutcome:
135
+ return ChainInvocationOutcome(
136
+ served_command=None,
137
+ returncode=FIXTURE_FAILED_RETURNCODE,
138
+ stdout="",
139
+ stderr="chain exhausted",
140
+ attempts=(
141
+ ChainAttempt(command=FIXTURE_SERVED_COMMAND, status="usage_limited"),
142
+ ),
143
+ )
144
+
145
+
146
+ def init_git_repository(repository_directory: Path) -> Path:
147
+ _initialize_repository(repository_directory)
148
+ (repository_directory / "README.md").write_text("baseline\n", encoding="utf-8")
149
+ _run_git("add", "README.md", working_directory=repository_directory)
150
+ _run_git("commit", "-m", "baseline", working_directory=repository_directory)
151
+ return repository_directory
152
+
153
+
154
+ def run_review(
155
+ working_directory: Path, *, session_model: str
156
+ ) -> invoker.CodeReviewOutcome:
157
+ return invoker.invoke_code_review(
158
+ working_directory=working_directory,
159
+ session_model=session_model,
160
+ timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
161
+ )
162
+
163
+
164
+ def run_review_cli(working_directory: Path, *, session_model: str) -> int:
165
+ return invoker.main(
166
+ [
167
+ CWD_FLAG,
168
+ str(working_directory),
169
+ CLI_SESSION_MODEL_FLAG,
170
+ session_model,
171
+ CLI_TIMEOUT_FLAG,
172
+ str(DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS),
173
+ ]
174
+ )
175
+
176
+
177
+ @dataclass
178
+ class SeamCallLog:
179
+ claude_calls: int = 0
180
+ claude_arguments: list[str] | None = None
181
+ host_profile_calls: int = 0
182
+ is_stdin_empty: bool = False
183
+ claude_working_directory: Path | None = None
184
+ all_observed_working_directories: list[Path] = field(default_factory=list)
185
+
186
+
187
+ @dataclass
188
+ class _SeamConfiguration:
189
+ host_profile: str
190
+ claude_outcome: ChainInvocationOutcome | BaseException | None
191
+ should_dirty_tree_on_chain: bool
192
+ working_directory: Path | None
193
+
194
+
195
+ def _record_dirty_tree(configuration: _SeamConfiguration) -> None:
196
+ if not configuration.should_dirty_tree_on_chain:
197
+ return
198
+ if configuration.working_directory is None:
199
+ return
200
+ write_dirty_tree(configuration.working_directory)
201
+
202
+
203
+ def _detect_empty_stdin(maybe_stdin: object) -> bool | None:
204
+ if maybe_stdin is subprocess.DEVNULL:
205
+ return True
206
+ if maybe_stdin is None:
207
+ return None
208
+ maybe_read = getattr(maybe_stdin, "read", None)
209
+ if not callable(maybe_read):
210
+ return None
211
+ stdin_contents = maybe_read()
212
+ maybe_seek = getattr(maybe_stdin, "seek", None)
213
+ if callable(maybe_seek):
214
+ maybe_seek(0)
215
+ return stdin_contents == ""
216
+
217
+
218
+ def _build_host_profile_seam(
219
+ call_log: SeamCallLog, configuration: _SeamConfiguration
220
+ ) -> Callable[..., str]:
221
+ def fake_host_profile(setting_by_name: object | None = None) -> str:
222
+ del setting_by_name
223
+ call_log.host_profile_calls += 1
224
+ return configuration.host_profile
225
+
226
+ return fake_host_profile
227
+
228
+
229
+ def _build_claude_seam(
230
+ call_log: SeamCallLog, configuration: _SeamConfiguration
231
+ ) -> Callable[..., ChainInvocationOutcome]:
232
+ def fake_claude(
233
+ all_claude_arguments: list[str], *, timeout_seconds: int
234
+ ) -> ChainInvocationOutcome:
235
+ call_log.claude_calls += 1
236
+ call_log.claude_arguments = list(all_claude_arguments)
237
+ chain_runner.chain_subprocess_runner(
238
+ ["claude", *all_claude_arguments],
239
+ capture_output=True,
240
+ text=True,
241
+ timeout=timeout_seconds,
242
+ check=False,
243
+ )
244
+ _record_dirty_tree(configuration)
245
+ if isinstance(configuration.claude_outcome, BaseException):
246
+ raise configuration.claude_outcome
247
+ assert isinstance(configuration.claude_outcome, ChainInvocationOutcome)
248
+ return configuration.claude_outcome
249
+
250
+ return fake_claude
251
+
252
+
253
+ def _build_subprocess_seam(
254
+ call_log: SeamCallLog,
255
+ ) -> Callable[..., subprocess.CompletedProcess[str]]:
256
+ def tracking_subprocess_runner(
257
+ all_invocation_tokens: Sequence[str],
258
+ *all_positionals: object,
259
+ **all_keywords: object,
260
+ ) -> subprocess.CompletedProcess[str]:
261
+ del all_positionals
262
+ maybe_empty_stdin = _detect_empty_stdin(all_keywords.get("stdin"))
263
+ if maybe_empty_stdin is not None:
264
+ call_log.is_stdin_empty = maybe_empty_stdin
265
+ maybe_cwd = all_keywords.get("cwd")
266
+ if maybe_cwd is not None:
267
+ resolved_directory = Path(str(maybe_cwd))
268
+ call_log.claude_working_directory = resolved_directory
269
+ call_log.all_observed_working_directories.append(resolved_directory)
270
+ return subprocess.CompletedProcess(
271
+ args=list(all_invocation_tokens),
272
+ returncode=0,
273
+ stdout="{}",
274
+ stderr="",
275
+ )
276
+
277
+ return tracking_subprocess_runner
278
+
279
+
280
+ def _apply_seams(
281
+ monkeypatch: pytest.MonkeyPatch,
282
+ call_log: SeamCallLog,
283
+ configuration: _SeamConfiguration,
284
+ ) -> None:
285
+ monkeypatch.setattr(
286
+ invoker,
287
+ "review_host_profile_detector",
288
+ _build_host_profile_seam(call_log, configuration),
289
+ )
290
+ monkeypatch.setattr(
291
+ invoker,
292
+ "review_claude_runner",
293
+ _build_claude_seam(call_log, configuration),
294
+ )
295
+ monkeypatch.setattr(
296
+ chain_runner,
297
+ "chain_subprocess_runner",
298
+ _build_subprocess_seam(call_log),
299
+ )
300
+
301
+
302
+ def install_seams(
303
+ monkeypatch: pytest.MonkeyPatch,
304
+ *,
305
+ host_profile: str = HOST_PROFILE_CLAUDE,
306
+ claude_outcome: ChainInvocationOutcome | BaseException | None = None,
307
+ should_dirty_tree_on_chain: bool = False,
308
+ working_directory: Path | None = None,
309
+ ) -> SeamCallLog:
310
+ call_log = SeamCallLog()
311
+ configuration = _SeamConfiguration(
312
+ host_profile=host_profile,
313
+ claude_outcome=claude_outcome,
314
+ should_dirty_tree_on_chain=should_dirty_tree_on_chain,
315
+ working_directory=working_directory,
316
+ )
317
+ _apply_seams(monkeypatch, call_log, configuration)
318
+ return call_log
319
+
320
+
321
+ EFFORT_LOW = "low"
322
+ REJECTED_ULTRA_EFFORT = "ultra"
323
+ SINGLE_PASS_CAP = 1
324
+ MISSING_STORE_FILE_NAME = "code_review_stamp_store_absent.py"
325
+ SURFACE_SOURCE = "def add(left: int, right: int) -> int:\n return left + right\n"
326
+ SURFACE_CHANGE_SOURCE = (
327
+ "def add(left: int, right: int) -> int:\n return left - right\n"
328
+ )
329
+
330
+
331
+ def _make_repo_with_change_surface(tmp_path: Path) -> Path:
332
+ origin_directory = tmp_path / "origin.git"
333
+ work_directory = tmp_path / "work"
334
+ _run_git(
335
+ "init",
336
+ "--bare",
337
+ "--initial-branch",
338
+ PINNED_INITIAL_BRANCH,
339
+ str(origin_directory),
340
+ )
341
+ _initialize_repository(work_directory)
342
+ (work_directory / "app.py").write_text(SURFACE_SOURCE, encoding="utf-8")
343
+ _run_git("add", "-A", working_directory=work_directory)
344
+ _run_git("commit", "-m", "base", working_directory=work_directory)
345
+ _run_git(
346
+ "remote",
347
+ "add",
348
+ "origin",
349
+ str(origin_directory),
350
+ working_directory=work_directory,
351
+ )
352
+ _run_git(
353
+ "push", "-u", "origin", PINNED_INITIAL_BRANCH, working_directory=work_directory
354
+ )
355
+ (work_directory / "app.py").write_text(SURFACE_CHANGE_SOURCE, encoding="utf-8")
356
+ return work_directory
357
+
358
+
359
+ def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: Path) -> None:
360
+ home_text = str(fake_home)
361
+ monkeypatch.setenv("HOME", home_text)
362
+ monkeypatch.setenv("USERPROFILE", home_text)
363
+ monkeypatch.delenv("HOMEDRIVE", raising=False)
364
+ monkeypatch.delenv("HOMEPATH", raising=False)
365
+
366
+
367
+ def prepared_surface_repo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
368
+ fake_home = tmp_path / "home"
369
+ fake_home.mkdir()
370
+ _isolate_home(monkeypatch, fake_home)
371
+ return _make_repo_with_change_surface(tmp_path)
372
+
373
+
374
+ def _chain_clean_outcome() -> invoker.CodeReviewOutcome:
375
+ return invoker.CodeReviewOutcome(
376
+ mode=invoker.MODE_CHAIN,
377
+ served_command=FIXTURE_SERVED_COMMAND,
378
+ returncode=FIXTURE_CHAIN_RETURNCODE,
379
+ is_dirty_tree=True,
380
+ )
381
+
382
+
383
+ def stable_clean_review(**_review_keywords: object) -> invoker.CodeReviewOutcome:
384
+ return _chain_clean_outcome()
385
+
386
+
387
+ def surface_changing_review(
388
+ *, working_directory: Path, **_review_keywords: object
389
+ ) -> invoker.CodeReviewOutcome:
390
+ write_dirty_tree(working_directory)
391
+ return _chain_clean_outcome()
392
+
393
+
394
+ class DriftingReview:
395
+ def __init__(self) -> None:
396
+ self.pass_count = 0
397
+
398
+ def __call__(
399
+ self, *, working_directory: Path, **_review_keywords: object
400
+ ) -> invoker.CodeReviewOutcome:
401
+ self.pass_count += 1
402
+ drift_path = working_directory / f"fix_{self.pass_count}.txt"
403
+ drift_path.write_text(str(self.pass_count), encoding="utf-8")
404
+ return _chain_clean_outcome()
@@ -40,6 +40,7 @@ import io
40
40
  import json
41
41
  import subprocess
42
42
  import sys
43
+ import tempfile
43
44
  from collections.abc import Callable, Sequence
44
45
  from dataclasses import dataclass
45
46
  from pathlib import Path
@@ -58,6 +59,7 @@ from dev_env_scripts_constants.claude_chain_constants import (
58
59
  ATTEMPT_STATUS_USAGE_LIMITED,
59
60
  ATTEMPT_SUMMARY_ENTRY_TEMPLATE,
60
61
  ATTEMPT_SUMMARY_JOIN_SEPARATOR,
62
+ CARRIAGE_RETURN,
61
63
  CHAIN_CONFIG_ERROR_EXIT_CODE,
62
64
  CHAIN_EXHAUSTED_EXIT_CODE,
63
65
  CHAIN_EXHAUSTED_MESSAGE_TEMPLATE,
@@ -82,13 +84,151 @@ from dev_env_scripts_constants.claude_chain_constants import (
82
84
  CONFIG_MISSING_MESSAGE_TEMPLATE,
83
85
  CONFIG_NOT_OBJECT_REASON,
84
86
  CONFIG_UNREADABLE_MESSAGE_TEMPLATE,
87
+ CRLF_NEWLINE,
85
88
  DEFAULT_TIMEOUT_SECONDS,
86
89
  EXAMPLE_CONFIG_FILENAME,
90
+ LINE_FEED,
87
91
  NO_COMPLETED_PROCESS_RETURN_CODE,
88
92
  UTF8_ENCODING,
89
93
  )
90
94
 
91
95
 
96
+ def _decode_captured_stream(raw_bytes: bytes, encoding: str, errors: str) -> str:
97
+ """Decode captured *raw_bytes* with ``text=True`` universal-newline semantics.
98
+
99
+ Spool capture writes binary temp files, so a bare ``.decode`` leaves CRLF and
100
+ bare CR intact. ``subprocess.run(..., text=True)`` normalized those to LF;
101
+ this helper restores that contract for Windows children that emit ``\\r\\n``.
102
+ """
103
+ decoded_text = raw_bytes.decode(encoding, errors)
104
+ return decoded_text.replace(CRLF_NEWLINE, LINE_FEED).replace(
105
+ CARRIAGE_RETURN, LINE_FEED
106
+ )
107
+
108
+
109
+ class _SpooledByteStream(Protocol):
110
+ """Binary spool with seek/read — TemporaryFile wrappers and BufferedIO."""
111
+
112
+ def seek(self, target: int, whence: int = 0, /) -> int: ...
113
+
114
+ def read(self, size: int | None = -1, /) -> bytes: ...
115
+
116
+
117
+ def _decoded_spooled_streams(
118
+ stdout_file: _SpooledByteStream,
119
+ stderr_file: _SpooledByteStream,
120
+ encoding: str,
121
+ errors: str,
122
+ ) -> tuple[str, str]:
123
+ """Seek both spool files to the start and decode their full contents."""
124
+ stdout_file.seek(0)
125
+ stderr_file.seek(0)
126
+ return (
127
+ _decode_captured_stream(stdout_file.read(), encoding, errors),
128
+ _decode_captured_stream(stderr_file.read(), encoding, errors),
129
+ )
130
+
131
+
132
+ def _attach_partial_timeout_streams(
133
+ timeout_error: subprocess.TimeoutExpired,
134
+ stdout_file: _SpooledByteStream,
135
+ stderr_file: _SpooledByteStream,
136
+ encoding: str,
137
+ errors: str,
138
+ ) -> None:
139
+ """Decode partial spool contents onto *timeout_error* before re-raise."""
140
+ captured_stdout, captured_stderr = _decoded_spooled_streams(
141
+ stdout_file, stderr_file, encoding, errors
142
+ )
143
+ timeout_error.stdout = captured_stdout
144
+ timeout_error.stderr = captured_stderr
145
+
146
+
147
+ # Capturing a large-output child through OS pipes (``capture_output=True``)
148
+ # deadlocks on Windows: the child buffers its whole response and flushes it at
149
+ # once, the pipe buffer fills before the parent drains it, and both sides block.
150
+ # Redirecting each stream to a temporary file removes the pipe, so the child
151
+ # writes freely; the files are then read back and decoded the way a pipe capture
152
+ # would. ``capture_output``, ``text``, and ``env`` are ignored in favor of file
153
+ # redirection and the parent environment; ``timeout``, ``check``, ``cwd``,
154
+ # ``stdin``, ``input``, ``encoding``, and ``errors`` are honored. On timeout,
155
+ # partial stdout/stderr are decoded from the temp files and attached to the
156
+ # raised ``TimeoutExpired``. When ``check=True`` and the child exits non-zero,
157
+ # ``CalledProcessError.stdout`` / ``.stderr`` stay unset (temp files are not
158
+ # attached to that raised error).
159
+ def _run_captured_subprocess(
160
+ all_invocation_tokens: list[str],
161
+ **all_subprocess_options: object,
162
+ ) -> subprocess.CompletedProcess[str]:
163
+ """Run *all_invocation_tokens*, spooling stdout and stderr to temp files."""
164
+ encoding = str(all_subprocess_options.get("encoding") or UTF8_ENCODING)
165
+ errors = str(all_subprocess_options.get("errors") or CODEC_ERROR_STRATEGY)
166
+ input_bytes = _captured_stdin_bytes(all_subprocess_options, encoding, errors)
167
+ working_directory = all_subprocess_options.get("cwd")
168
+ timeout_seconds = all_subprocess_options.get("timeout")
169
+ with (
170
+ tempfile.TemporaryFile() as stdout_file,
171
+ tempfile.TemporaryFile() as stderr_file,
172
+ ):
173
+ try:
174
+ completion = subprocess.run(
175
+ all_invocation_tokens,
176
+ stdout=stdout_file,
177
+ stderr=stderr_file,
178
+ cwd=working_directory if isinstance(working_directory, str) else None,
179
+ check=bool(all_subprocess_options.get("check", False)),
180
+ timeout=(
181
+ float(timeout_seconds)
182
+ if isinstance(timeout_seconds, (int, float))
183
+ else None
184
+ ),
185
+ input=input_bytes,
186
+ )
187
+ except subprocess.TimeoutExpired as timeout_error:
188
+ _attach_partial_timeout_streams(
189
+ timeout_error, stdout_file, stderr_file, encoding, errors
190
+ )
191
+ raise
192
+ captured_stdout, captured_stderr = _decoded_spooled_streams(
193
+ stdout_file, stderr_file, encoding, errors
194
+ )
195
+ return subprocess.CompletedProcess(
196
+ all_invocation_tokens,
197
+ completion.returncode,
198
+ captured_stdout,
199
+ captured_stderr,
200
+ )
201
+
202
+
203
+ def _captured_stdin_bytes(
204
+ all_subprocess_options: dict[str, object], encoding: str, errors: str
205
+ ) -> bytes | None:
206
+ """Return the bytes to feed the child's stdin for a spooled run.
207
+
208
+ ::
209
+
210
+ stdin=<open prompt file> -> the file's bytes
211
+ stdin=subprocess.DEVNULL -> b"" (an immediate EOF)
212
+ input="charter text" -> the encoded text
213
+
214
+ A wrapper hands the runner a ``stdin`` stream or a ``DEVNULL`` sentinel; the
215
+ spooled run reads from an ``input`` pipe rather than the caller's handle, so
216
+ the stream is read into bytes here to deliver the same stdin a direct pipe
217
+ would. When no ``stdin`` is given, the ``input`` text is encoded instead.
218
+ """
219
+ stdin_source = all_subprocess_options.get("stdin")
220
+ if isinstance(stdin_source, io.TextIOBase):
221
+ return stdin_source.read().encode(encoding, errors)
222
+ if isinstance(stdin_source, (io.RawIOBase, io.BufferedIOBase)):
223
+ return stdin_source.read() or b""
224
+ if isinstance(stdin_source, int):
225
+ return b""
226
+ input_text = all_subprocess_options.get("input")
227
+ if input_text is None:
228
+ return None
229
+ return str(input_text).encode(encoding, errors)
230
+
231
+
92
232
  class ChainConfigurationError(Exception):
93
233
  """Raised when the chain configuration is missing, unreadable, or malformed."""
94
234
 
@@ -138,7 +278,7 @@ class WeeklyUsageAccountReport(Protocol):
138
278
  command: str
139
279
 
140
280
 
141
- chain_subprocess_runner = subprocess.run
281
+ chain_subprocess_runner = _run_captured_subprocess
142
282
 
143
283
 
144
284
  def _load_chain_usage_module() -> ModuleType:
@@ -1,4 +1,19 @@
1
- """Put the scripts directory on sys.path so tests import each script by name."""
1
+ """Put the scripts directory on ``sys.path`` for every test in this folder.
2
+
3
+ The code-review invoker and its constants package live beside these tests
4
+ rather than on the default pytest path, so each test module can import them
5
+ by bare name once this directory is registered.
6
+
7
+ ::
8
+
9
+ import invoke_code_review as invoker # resolves here
10
+ from dev_env_scripts_constants... # resolves here
11
+
12
+ pytest loads this file before collecting sibling test modules, so the path
13
+ is in place by the time any test module runs its top-level imports.
14
+ """
15
+
16
+ from __future__ import annotations
2
17
 
3
18
  import sys
4
19
  from pathlib import Path
@@ -11,7 +11,7 @@ Named constants for scripts in `scripts/`. Follows the project convention that t
11
11
  | `claude_chain_constants.py` | `claude_chain_runner.py` - the chain config filename and home subdirectory, the usage-limit signature text, the per-binary status labels, the default timeout, CLI flag and separator tokens, config JSON keys (including optional `credentials_path`), invalid-shape reason text, config-error and exhausted-chain message templates, and CLI exit codes; `invoke_code_review.py` and `resolve_worker_spawn.py` - the text-codec keyword names and the `collect_forwarded_text_codec` helper that forwards them to the subprocess runner for text-mode capture |
12
12
  | `claude_chain_usage_constants.py` | `claude_chain_usage.py` - full weekly percent scale, usage-pause skill path segments, CLI config-path flag, JSON report keys, and probe error message templates |
13
13
  | `grok_worker_constants.py` | `grok_worker_preflight.py`, `grok_headless_runner.py`, and `spawn_grok_batch.py` - the `grok` binary name and CLI flags, model and subcommand tokens, leader-socket and scratch-file name parts, auth and usage-limit signature lists, outcome classifications, fallthrough reasons, tool-profile names and prompt headers, timeouts and turn caps (including launch-failure return code and post-kill grace), ping-cache keys and TTL, batch-spec and summary JSON keys, the prompt-part and report-stream join separators, and the CLI launch-error stderr prefix |
14
- | `code_review_constants.py` | `invoke_code_review.py` - the `/code-review xhigh --fix` prompt, opus model alias, permission-mode flag and value, result mode and JSON keys, session-model CLI flag, git dirty-check tokens, and in-session return markers |
14
+ | `code_review_constants.py` | `invoke_code_review.py` - the `/code-review ultra --fix` prompt, opus model alias, permission-mode flag and value, result mode and JSON keys, session-model CLI flag, git dirty-check tokens, and in-session return markers |
15
15
  | `__init__.py` | Empty package marker |
16
16
 
17
17
  ## Convention
@@ -15,6 +15,15 @@ UTF8_ENCODING: str = "utf-8"
15
15
  CODEC_ERROR_STRATEGY: str = "replace"
16
16
  """Codec error handler that maps any unencodable or undecodable value to a marker."""
17
17
 
18
+ CRLF_NEWLINE: str = "\r\n"
19
+ """Windows-style newline sequence normalized away when decoding spool captures."""
20
+
21
+ CARRIAGE_RETURN: str = "\r"
22
+ """Bare carriage return normalized to LF when decoding spool captures."""
23
+
24
+ LINE_FEED: str = "\n"
25
+ """Unix-style newline retained after universal-newline normalization."""
26
+
18
27
  SUBPROCESS_ENCODING_KEYWORD: str = "encoding"
19
28
  """Keyword name for text encoding when forwarding chain subprocess runner kwargs."""
20
29