claude-dev-env 1.95.0 → 2.0.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 (240) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +20 -20
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
  6. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
  7. package/_shared/advisor/scripts/tier_model_ids.py +18 -18
  8. package/_shared/pr-loop/CLAUDE.md +1 -0
  9. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  10. package/_shared/pr-loop/scripts/README.md +1 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
  13. package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
  14. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
  15. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
  16. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
  23. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
  24. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
  26. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
  27. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
  28. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
  29. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
  33. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  35. package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
  36. package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
  37. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
  38. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
  39. package/_shared/pr-loop/worker-spawn.md +186 -0
  40. package/agents/code-verifier.md +1 -1
  41. package/bin/ever-shipped-skills.mjs +3 -0
  42. package/bin/expand_home_directory_tokens.mjs +1 -1
  43. package/bin/install.mjs +5 -2
  44. package/hooks/advisory/refactor_guard.py +3 -4
  45. package/hooks/blocking/CLAUDE.md +7 -1
  46. package/hooks/blocking/block_main_commit.py +2 -2
  47. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  48. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  49. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  50. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  61. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  62. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  63. package/hooks/blocking/code_rules_optional_params.py +2 -2
  64. package/hooks/blocking/code_rules_shared.py +1 -1
  65. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  66. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  67. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  68. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  69. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  70. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  71. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  72. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  73. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  74. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  75. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  76. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  77. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  78. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  79. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  80. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  85. package/hooks/blocking/pii_payload_scan.py +138 -42
  86. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  87. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  88. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  89. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  90. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  91. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  92. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  93. package/hooks/blocking/pr_description_command_parser.py +8 -4
  94. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  95. package/hooks/blocking/tdd_enforcer.py +97 -608
  96. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  97. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  98. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  99. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  100. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  101. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  102. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  103. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  104. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  105. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  106. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  107. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  108. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  109. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  110. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  111. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  112. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  113. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  114. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  115. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  116. package/hooks/blocking/tests/conftest.py +10 -0
  117. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  118. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  119. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  120. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  121. package/hooks/git-hooks/post_commit.py +3 -4
  122. package/hooks/hooks_constants/CLAUDE.md +2 -2
  123. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  124. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  125. package/hooks/hooks_constants/local_identity.py +59 -8
  126. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  127. package/hooks/hooks_constants/test_local_identity.py +105 -3
  128. package/hooks/pyproject.toml +13 -36
  129. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  130. package/hooks/validation/mypy_validator.py +2 -2
  131. package/hooks/validators/health_check.py +1 -0
  132. package/hooks/validators/mypy_integration.py +2 -0
  133. package/hooks/validators/ruff_integration.py +3 -0
  134. package/hooks/workflow/auto_formatter.py +5 -4
  135. package/package.json +1 -1
  136. package/scripts/CLAUDE.md +4 -0
  137. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  138. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  139. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  140. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  141. package/scripts/grok_headless_runner.py +294 -0
  142. package/scripts/grok_worker_preflight.py +410 -0
  143. package/scripts/invoke_code_review.py +463 -0
  144. package/scripts/resolve_worker_spawn.py +619 -0
  145. package/scripts/spawn_grok_batch.py +672 -0
  146. package/scripts/test_grok_headless_runner.py +626 -0
  147. package/scripts/test_grok_worker_preflight.py +1054 -0
  148. package/scripts/test_invoke_code_review.py +672 -0
  149. package/scripts/test_resolve_worker_spawn.py +1014 -0
  150. package/scripts/test_spawn_grok_batch.py +1017 -0
  151. package/skills/CLAUDE.md +5 -3
  152. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  153. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  154. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  155. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  156. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  157. package/skills/autoconverge/CLAUDE.md +3 -3
  158. package/skills/autoconverge/SKILL.md +9 -3
  159. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  160. package/skills/autoconverge/reference/convergence.md +33 -11
  161. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  162. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  163. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  164. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  165. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  166. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  167. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  168. package/skills/autoconverge/workflow/converge.mjs +200 -16
  169. package/skills/bugteam/CLAUDE.md +2 -2
  170. package/skills/bugteam/CONSTRAINTS.md +3 -2
  171. package/skills/bugteam/PROMPTS.md +7 -6
  172. package/skills/bugteam/SKILL.md +18 -13
  173. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  174. package/skills/bugteam/reference/design-rationale.md +1 -1
  175. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  176. package/skills/bugteam/reference/team-setup.md +8 -2
  177. package/skills/codex-review/CLAUDE.md +46 -0
  178. package/skills/codex-review/SKILL.md +181 -0
  179. package/skills/codex-review/reference/CLAUDE.md +15 -0
  180. package/skills/codex-review/reference/cli-contract.md +253 -0
  181. package/skills/codex-review/reference/loop-integration.md +118 -0
  182. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  183. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  184. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  185. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  186. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  187. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  188. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  189. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  190. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  191. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  192. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  193. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  194. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  195. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  196. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  197. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  198. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  199. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  200. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  201. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  202. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  203. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  204. package/skills/codex-review/test_skill_scaffold.py +192 -0
  205. package/skills/grok-spawn/CLAUDE.md +28 -0
  206. package/skills/grok-spawn/SKILL.md +226 -0
  207. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  208. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  209. package/skills/grokify/SKILL.md +9 -1
  210. package/skills/grokify/capability-claims.test.mjs +28 -0
  211. package/skills/grokify/evals/README.md +72 -0
  212. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  213. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  214. package/skills/orchestrator/SKILL.md +15 -11
  215. package/skills/orchestrator-refresh/SKILL.md +5 -5
  216. package/skills/pr-converge/SKILL.md +34 -13
  217. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  218. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  219. package/skills/pr-converge/reference/ground-rules.md +1 -1
  220. package/skills/pr-converge/reference/per-tick.md +130 -42
  221. package/skills/pr-converge/reference/state-schema.md +10 -0
  222. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  223. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  224. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  225. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  226. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  227. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  228. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  229. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  230. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  231. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  232. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  233. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  234. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  235. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  236. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  237. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  238. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  239. package/skills/reviewer-gates/SKILL.md +7 -5
  240. package/skills/team-advisor/SKILL.md +7 -7
@@ -0,0 +1,672 @@
1
+ """Behavioral tests for the host-aware ``/code-review`` invoker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
15
+ if str(_SCRIPTS_DIR) not in sys.path:
16
+ sys.path.insert(0, str(_SCRIPTS_DIR))
17
+
18
+ import claude_chain_runner as chain_runner # noqa: E402
19
+ import invoke_code_review as invoker # noqa: E402
20
+ from claude_chain_runner import ( # noqa: E402
21
+ ChainAttempt,
22
+ ChainConfigurationError,
23
+ ChainInvocationOutcome,
24
+ )
25
+ from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
26
+ CHAIN_CONFIG_ERROR_EXIT_CODE,
27
+ )
28
+ from dev_env_scripts_constants.code_review_constants import ( # noqa: E402
29
+ CLI_SESSION_MODEL_FLAG,
30
+ CODE_REVIEW_MODEL_ALIAS,
31
+ CODE_REVIEW_PROMPT,
32
+ GIT_BINARY,
33
+ GIT_PORCELAIN_FLAG,
34
+ GIT_STATUS_SUBCOMMAND,
35
+ HOST_PROFILE_ERROR_RETURNCODE,
36
+ IN_SESSION_RETURNCODE,
37
+ MODE_CHAIN,
38
+ MODE_IN_SESSION,
39
+ PERMISSION_MODE_BYPASS,
40
+ PERMISSION_MODE_FLAG,
41
+ RESULT_KEY_DIRTY_TREE,
42
+ RESULT_KEY_MODE,
43
+ RESULT_KEY_RETURNCODE,
44
+ RESULT_KEY_SERVED_COMMAND,
45
+ )
46
+ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
47
+ CLI_TIMEOUT_FLAG,
48
+ CWD_FLAG,
49
+ MODEL_FLAG,
50
+ OUTPUT_FORMAT_FLAG,
51
+ OUTPUT_FORMAT_JSON,
52
+ SINGLE_TURN_FLAG,
53
+ )
54
+ from dev_env_scripts_constants.timing import ( # noqa: E402
55
+ DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
56
+ )
57
+
58
+ HOST_PROFILE_CLAUDE = "Claude"
59
+ HOST_PROFILE_THIRD_PARTY = "ThirdParty"
60
+
61
+ FIXTURE_SERVED_COMMAND = "claude"
62
+ FIXTURE_CHAIN_RETURNCODE = 0
63
+ FIXTURE_FAILED_RETURNCODE = 1
64
+ FIXTURE_GIT_STATUS_FAILURE_RETURNCODE = 128
65
+ FIXTURE_CHAIN_STDOUT = '{"result":"review done"}'
66
+ FIXTURE_SESSION_OPUS = "opus"
67
+ FIXTURE_SESSION_OPUS_UPPER = "Opus"
68
+ FIXTURE_SESSION_SONNET = "sonnet"
69
+ FIXTURE_SESSION_HAIKU = "haiku"
70
+ FIXTURE_CHAIN_CONFIG_ERROR_MESSAGE = "chain config missing"
71
+ FIXTURE_HOST_PROFILE_ERROR_MESSAGE = "unknown host profile"
72
+ DIRTY_FILE_NAME = "review_fix.txt"
73
+ DIRTY_FILE_CONTENTS = "applied fix\n"
74
+ GIT_INIT_TIMEOUT_SECONDS = 30
75
+
76
+
77
+ def _claude_served(
78
+ *,
79
+ returncode: int = FIXTURE_CHAIN_RETURNCODE,
80
+ stdout: str = FIXTURE_CHAIN_STDOUT,
81
+ ) -> ChainInvocationOutcome:
82
+ return ChainInvocationOutcome(
83
+ served_command=FIXTURE_SERVED_COMMAND,
84
+ returncode=returncode,
85
+ stdout=stdout,
86
+ stderr="",
87
+ attempts=(
88
+ ChainAttempt(command=FIXTURE_SERVED_COMMAND, status="served"),
89
+ ),
90
+ )
91
+
92
+
93
+ def _claude_failed() -> ChainInvocationOutcome:
94
+ return ChainInvocationOutcome(
95
+ served_command=None,
96
+ returncode=FIXTURE_FAILED_RETURNCODE,
97
+ stdout="",
98
+ stderr="chain exhausted",
99
+ attempts=(
100
+ ChainAttempt(command=FIXTURE_SERVED_COMMAND, status="usage_limited"),
101
+ ),
102
+ )
103
+
104
+
105
+ def _init_git_repository(repository_directory: Path) -> Path:
106
+ repository_directory.mkdir(parents=True, exist_ok=True)
107
+ subprocess.run(
108
+ [GIT_BINARY, "init"],
109
+ cwd=str(repository_directory),
110
+ check=True,
111
+ capture_output=True,
112
+ text=True,
113
+ timeout=GIT_INIT_TIMEOUT_SECONDS,
114
+ )
115
+ subprocess.run(
116
+ [GIT_BINARY, "config", "user.email", "reviewer@example.com"],
117
+ cwd=str(repository_directory),
118
+ check=True,
119
+ capture_output=True,
120
+ text=True,
121
+ timeout=GIT_INIT_TIMEOUT_SECONDS,
122
+ )
123
+ subprocess.run(
124
+ [GIT_BINARY, "config", "user.name", "Reviewer"],
125
+ cwd=str(repository_directory),
126
+ check=True,
127
+ capture_output=True,
128
+ text=True,
129
+ timeout=GIT_INIT_TIMEOUT_SECONDS,
130
+ )
131
+ tracked_file = repository_directory / "README.md"
132
+ tracked_file.write_text("baseline\n", encoding="utf-8")
133
+ subprocess.run(
134
+ [GIT_BINARY, "add", "README.md"],
135
+ cwd=str(repository_directory),
136
+ check=True,
137
+ capture_output=True,
138
+ text=True,
139
+ timeout=GIT_INIT_TIMEOUT_SECONDS,
140
+ )
141
+ subprocess.run(
142
+ [GIT_BINARY, "commit", "-m", "baseline"],
143
+ cwd=str(repository_directory),
144
+ check=True,
145
+ capture_output=True,
146
+ text=True,
147
+ timeout=GIT_INIT_TIMEOUT_SECONDS,
148
+ )
149
+ return repository_directory
150
+
151
+
152
+ @dataclass
153
+ class SeamCallLog:
154
+ claude_calls: int = 0
155
+ claude_arguments: list[str] | None = None
156
+ host_profile_calls: int = 0
157
+ is_stdin_empty: bool = False
158
+ claude_working_directory: Path | None = None
159
+ all_observed_working_directories: list[Path] = field(default_factory=list)
160
+ all_git_status_commands: list[list[str]] = field(default_factory=list)
161
+
162
+
163
+ def _install_seams(
164
+ monkeypatch: pytest.MonkeyPatch,
165
+ *,
166
+ host_profile: str = HOST_PROFILE_CLAUDE,
167
+ claude_outcome: ChainInvocationOutcome | BaseException | None = None,
168
+ should_dirty_tree_on_chain: bool = False,
169
+ working_directory: Path | None = None,
170
+ ) -> SeamCallLog:
171
+ call_log = SeamCallLog()
172
+
173
+ def fake_host_profile(
174
+ setting_by_name: object | None = None,
175
+ ) -> str:
176
+ del setting_by_name
177
+ call_log.host_profile_calls += 1
178
+ return host_profile
179
+
180
+ def fake_claude(
181
+ all_claude_arguments: list[str], *, timeout_seconds: int
182
+ ) -> ChainInvocationOutcome:
183
+ call_log.claude_calls += 1
184
+ call_log.claude_arguments = list(all_claude_arguments)
185
+ chain_runner.chain_subprocess_runner(
186
+ ["claude", *all_claude_arguments],
187
+ capture_output=True,
188
+ text=True,
189
+ timeout=timeout_seconds,
190
+ check=False,
191
+ )
192
+ if should_dirty_tree_on_chain and working_directory is not None:
193
+ dirty_file = working_directory / DIRTY_FILE_NAME
194
+ dirty_file.write_text(DIRTY_FILE_CONTENTS, encoding="utf-8")
195
+ if isinstance(claude_outcome, BaseException):
196
+ raise claude_outcome
197
+ assert isinstance(claude_outcome, ChainInvocationOutcome)
198
+ return claude_outcome
199
+
200
+ def _tracking_subprocess_runner(
201
+ all_invocation_tokens: Sequence[str],
202
+ *all_positionals: object,
203
+ **all_keywords: object,
204
+ ) -> subprocess.CompletedProcess[str]:
205
+ del all_positionals
206
+ maybe_stdin = all_keywords.get("stdin")
207
+ if maybe_stdin is subprocess.DEVNULL:
208
+ call_log.is_stdin_empty = True
209
+ elif maybe_stdin is not None:
210
+ maybe_read = getattr(maybe_stdin, "read", None)
211
+ if callable(maybe_read):
212
+ call_log.is_stdin_empty = maybe_read() == ""
213
+ maybe_seek = getattr(maybe_stdin, "seek", None)
214
+ if callable(maybe_seek):
215
+ maybe_seek(0)
216
+ maybe_cwd = all_keywords.get("cwd")
217
+ if maybe_cwd is not None:
218
+ resolved_directory = Path(str(maybe_cwd))
219
+ call_log.claude_working_directory = resolved_directory
220
+ call_log.all_observed_working_directories.append(resolved_directory)
221
+ return subprocess.CompletedProcess(
222
+ args=list(all_invocation_tokens),
223
+ returncode=0,
224
+ stdout="{}",
225
+ stderr="",
226
+ )
227
+
228
+ monkeypatch.setattr(invoker, "review_host_profile_detector", fake_host_profile)
229
+ monkeypatch.setattr(invoker, "review_claude_runner", fake_claude)
230
+ monkeypatch.setattr(
231
+ chain_runner, "chain_subprocess_runner", _tracking_subprocess_runner
232
+ )
233
+ return call_log
234
+
235
+
236
+ @pytest.mark.parametrize(
237
+ ("host_profile", "session_model", "expected_mode"),
238
+ [
239
+ (HOST_PROFILE_CLAUDE, FIXTURE_SESSION_OPUS, MODE_IN_SESSION),
240
+ (HOST_PROFILE_CLAUDE, FIXTURE_SESSION_OPUS_UPPER, MODE_IN_SESSION),
241
+ (HOST_PROFILE_CLAUDE, FIXTURE_SESSION_SONNET, MODE_CHAIN),
242
+ (HOST_PROFILE_CLAUDE, FIXTURE_SESSION_HAIKU, MODE_CHAIN),
243
+ (HOST_PROFILE_THIRD_PARTY, FIXTURE_SESSION_OPUS, MODE_CHAIN),
244
+ (HOST_PROFILE_THIRD_PARTY, FIXTURE_SESSION_SONNET, MODE_CHAIN),
245
+ ],
246
+ )
247
+ def test_mode_decision_host_and_model_matrix(
248
+ monkeypatch: pytest.MonkeyPatch,
249
+ tmp_path: Path,
250
+ host_profile: str,
251
+ session_model: str,
252
+ expected_mode: str,
253
+ ) -> None:
254
+ working_directory = _init_git_repository(tmp_path / "repo")
255
+ claude_outcome = _claude_served() if expected_mode == MODE_CHAIN else None
256
+ call_log = _install_seams(
257
+ monkeypatch,
258
+ host_profile=host_profile,
259
+ claude_outcome=claude_outcome,
260
+ working_directory=working_directory,
261
+ )
262
+
263
+ review_outcome = invoker.invoke_code_review(
264
+ working_directory=working_directory,
265
+ session_model=session_model,
266
+ timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
267
+ )
268
+
269
+ assert review_outcome.mode == expected_mode
270
+ assert call_log.host_profile_calls == 1
271
+ if expected_mode == MODE_IN_SESSION:
272
+ assert call_log.claude_calls == 0
273
+ assert review_outcome.served_command is None
274
+ assert review_outcome.returncode == IN_SESSION_RETURNCODE
275
+ assert review_outcome.is_dirty_tree is False
276
+ else:
277
+ assert call_log.claude_calls == 1
278
+
279
+
280
+ def test_chain_argv_assembly(
281
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
282
+ ) -> None:
283
+ working_directory = _init_git_repository(tmp_path / "repo")
284
+ call_log = _install_seams(
285
+ monkeypatch,
286
+ host_profile=HOST_PROFILE_THIRD_PARTY,
287
+ claude_outcome=_claude_served(),
288
+ working_directory=working_directory,
289
+ )
290
+
291
+ invoker.invoke_code_review(
292
+ working_directory=working_directory,
293
+ session_model=FIXTURE_SESSION_SONNET,
294
+ timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
295
+ )
296
+
297
+ assert call_log.claude_arguments == [
298
+ SINGLE_TURN_FLAG,
299
+ CODE_REVIEW_PROMPT,
300
+ MODEL_FLAG,
301
+ CODE_REVIEW_MODEL_ALIAS,
302
+ OUTPUT_FORMAT_FLAG,
303
+ OUTPUT_FORMAT_JSON,
304
+ PERMISSION_MODE_FLAG,
305
+ PERMISSION_MODE_BYPASS,
306
+ ]
307
+
308
+
309
+ def test_chain_redirects_empty_stdin_and_sets_cwd(
310
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
311
+ ) -> None:
312
+ working_directory = _init_git_repository(tmp_path / "repo")
313
+ call_log = _install_seams(
314
+ monkeypatch,
315
+ host_profile=HOST_PROFILE_THIRD_PARTY,
316
+ claude_outcome=_claude_served(),
317
+ working_directory=working_directory,
318
+ )
319
+
320
+ invoker.invoke_code_review(
321
+ working_directory=working_directory,
322
+ session_model=FIXTURE_SESSION_OPUS,
323
+ timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
324
+ )
325
+
326
+ assert call_log.is_stdin_empty is True
327
+ assert call_log.claude_working_directory == working_directory
328
+
329
+
330
+ def test_dirty_tree_true_after_chain_writes_file(
331
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
332
+ ) -> None:
333
+ working_directory = _init_git_repository(tmp_path / "repo")
334
+ _install_seams(
335
+ monkeypatch,
336
+ host_profile=HOST_PROFILE_THIRD_PARTY,
337
+ claude_outcome=_claude_served(),
338
+ should_dirty_tree_on_chain=True,
339
+ working_directory=working_directory,
340
+ )
341
+
342
+ review_outcome = invoker.invoke_code_review(
343
+ working_directory=working_directory,
344
+ session_model=FIXTURE_SESSION_OPUS,
345
+ timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
346
+ )
347
+
348
+ assert review_outcome.mode == MODE_CHAIN
349
+ assert review_outcome.is_dirty_tree is True
350
+ assert review_outcome.served_command == FIXTURE_SERVED_COMMAND
351
+ assert review_outcome.returncode == FIXTURE_CHAIN_RETURNCODE
352
+
353
+
354
+ def test_dirty_tree_false_when_chain_leaves_tree_clean(
355
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
356
+ ) -> None:
357
+ working_directory = _init_git_repository(tmp_path / "repo")
358
+ _install_seams(
359
+ monkeypatch,
360
+ host_profile=HOST_PROFILE_THIRD_PARTY,
361
+ claude_outcome=_claude_served(),
362
+ should_dirty_tree_on_chain=False,
363
+ working_directory=working_directory,
364
+ )
365
+
366
+ review_outcome = invoker.invoke_code_review(
367
+ working_directory=working_directory,
368
+ session_model=FIXTURE_SESSION_OPUS,
369
+ timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
370
+ )
371
+
372
+ assert review_outcome.is_dirty_tree is False
373
+
374
+
375
+ def test_cli_prints_result_json_only(
376
+ monkeypatch: pytest.MonkeyPatch,
377
+ tmp_path: Path,
378
+ capsys: pytest.CaptureFixture[str],
379
+ ) -> None:
380
+ working_directory = _init_git_repository(tmp_path / "repo")
381
+ _install_seams(
382
+ monkeypatch,
383
+ host_profile=HOST_PROFILE_CLAUDE,
384
+ claude_outcome=None,
385
+ working_directory=working_directory,
386
+ )
387
+
388
+ exit_code = invoker.main(
389
+ [
390
+ CWD_FLAG,
391
+ str(working_directory),
392
+ CLI_SESSION_MODEL_FLAG,
393
+ FIXTURE_SESSION_OPUS,
394
+ CLI_TIMEOUT_FLAG,
395
+ str(DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS),
396
+ ]
397
+ )
398
+
399
+ assert exit_code == IN_SESSION_RETURNCODE
400
+ captured = capsys.readouterr()
401
+ assert captured.err == ""
402
+ parsed_payload = json.loads(captured.out)
403
+ assert parsed_payload == {
404
+ RESULT_KEY_MODE: MODE_IN_SESSION,
405
+ RESULT_KEY_SERVED_COMMAND: None,
406
+ RESULT_KEY_RETURNCODE: IN_SESSION_RETURNCODE,
407
+ RESULT_KEY_DIRTY_TREE: False,
408
+ }
409
+
410
+
411
+ def test_build_code_review_arguments_matches_contract() -> None:
412
+ all_arguments = invoker.build_code_review_arguments()
413
+ assert all_arguments == [
414
+ SINGLE_TURN_FLAG,
415
+ CODE_REVIEW_PROMPT,
416
+ MODEL_FLAG,
417
+ CODE_REVIEW_MODEL_ALIAS,
418
+ OUTPUT_FORMAT_FLAG,
419
+ OUTPUT_FORMAT_JSON,
420
+ PERMISSION_MODE_FLAG,
421
+ PERMISSION_MODE_BYPASS,
422
+ ]
423
+
424
+
425
+ def test_is_working_tree_dirty_against_real_git_repo(tmp_path: Path) -> None:
426
+ working_directory = _init_git_repository(tmp_path / "repo")
427
+ assert invoker.is_working_tree_dirty(working_directory) is False
428
+ (working_directory / DIRTY_FILE_NAME).write_text(
429
+ DIRTY_FILE_CONTENTS, encoding="utf-8"
430
+ )
431
+ assert invoker.is_working_tree_dirty(working_directory) is True
432
+
433
+
434
+ def test_git_status_command_uses_porcelain(
435
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
436
+ ) -> None:
437
+ working_directory = _init_git_repository(tmp_path / "repo")
438
+ all_commands: list[list[str]] = []
439
+
440
+ def fake_git_status(
441
+ all_command_tokens: Sequence[str],
442
+ *all_positionals: object,
443
+ **all_keywords: object,
444
+ ) -> subprocess.CompletedProcess[str]:
445
+ del all_positionals, all_keywords
446
+ all_commands.append(list(all_command_tokens))
447
+ return subprocess.CompletedProcess(
448
+ args=list(all_command_tokens),
449
+ returncode=0,
450
+ stdout="",
451
+ stderr="",
452
+ )
453
+
454
+ monkeypatch.setattr(invoker, "review_git_status_runner", fake_git_status)
455
+ invoker.is_working_tree_dirty(working_directory)
456
+ assert all_commands == [
457
+ [GIT_BINARY, GIT_STATUS_SUBCOMMAND, GIT_PORCELAIN_FLAG]
458
+ ]
459
+
460
+
461
+ def test_chain_failure_preserves_returncode(
462
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
463
+ ) -> None:
464
+ working_directory = _init_git_repository(tmp_path / "repo")
465
+ _install_seams(
466
+ monkeypatch,
467
+ host_profile=HOST_PROFILE_THIRD_PARTY,
468
+ claude_outcome=_claude_failed(),
469
+ working_directory=working_directory,
470
+ )
471
+
472
+ review_outcome = invoker.invoke_code_review(
473
+ working_directory=working_directory,
474
+ session_model=FIXTURE_SESSION_OPUS,
475
+ timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
476
+ )
477
+
478
+ assert review_outcome.mode == MODE_CHAIN
479
+ assert review_outcome.served_command is None
480
+ assert review_outcome.returncode == FIXTURE_FAILED_RETURNCODE
481
+ assert review_outcome.is_dirty_tree is False
482
+ assert invoker.is_successful_code_review(review_outcome) is False
483
+ assert invoker.is_code_review_clean_stamp_allowed(review_outcome) is False
484
+
485
+
486
+ def test_is_working_tree_dirty_nonzero_returncode_is_not_clean(
487
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
488
+ ) -> None:
489
+ working_directory = _init_git_repository(tmp_path / "repo")
490
+
491
+ def fake_git_status(
492
+ all_command_tokens: Sequence[str],
493
+ *all_positionals: object,
494
+ **all_keywords: object,
495
+ ) -> subprocess.CompletedProcess[str]:
496
+ del all_positionals, all_keywords
497
+ return subprocess.CompletedProcess(
498
+ args=list(all_command_tokens),
499
+ returncode=FIXTURE_GIT_STATUS_FAILURE_RETURNCODE,
500
+ stdout="",
501
+ stderr="fatal: not a git repository",
502
+ )
503
+
504
+ monkeypatch.setattr(invoker, "review_git_status_runner", fake_git_status)
505
+ assert invoker.is_working_tree_dirty(working_directory) is True
506
+
507
+
508
+ def test_cli_emits_json_on_chain_configuration_error(
509
+ monkeypatch: pytest.MonkeyPatch,
510
+ tmp_path: Path,
511
+ capsys: pytest.CaptureFixture[str],
512
+ ) -> None:
513
+ working_directory = _init_git_repository(tmp_path / "repo")
514
+ _install_seams(
515
+ monkeypatch,
516
+ host_profile=HOST_PROFILE_THIRD_PARTY,
517
+ claude_outcome=ChainConfigurationError(FIXTURE_CHAIN_CONFIG_ERROR_MESSAGE),
518
+ working_directory=working_directory,
519
+ )
520
+
521
+ exit_code = invoker.main(
522
+ [
523
+ CWD_FLAG,
524
+ str(working_directory),
525
+ CLI_SESSION_MODEL_FLAG,
526
+ FIXTURE_SESSION_OPUS,
527
+ CLI_TIMEOUT_FLAG,
528
+ str(DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS),
529
+ ]
530
+ )
531
+
532
+ assert exit_code == CHAIN_CONFIG_ERROR_EXIT_CODE
533
+ captured = capsys.readouterr()
534
+ parsed_payload = json.loads(captured.out)
535
+ assert parsed_payload == {
536
+ RESULT_KEY_MODE: MODE_CHAIN,
537
+ RESULT_KEY_SERVED_COMMAND: None,
538
+ RESULT_KEY_RETURNCODE: CHAIN_CONFIG_ERROR_EXIT_CODE,
539
+ RESULT_KEY_DIRTY_TREE: False,
540
+ }
541
+ assert invoker.is_code_review_clean_stamp_allowed(
542
+ invoker.CodeReviewOutcome(
543
+ mode=MODE_CHAIN,
544
+ served_command=None,
545
+ returncode=CHAIN_CONFIG_ERROR_EXIT_CODE,
546
+ is_dirty_tree=False,
547
+ )
548
+ ) is False
549
+
550
+
551
+ def test_cli_emits_json_on_host_profile_value_error(
552
+ monkeypatch: pytest.MonkeyPatch,
553
+ tmp_path: Path,
554
+ capsys: pytest.CaptureFixture[str],
555
+ ) -> None:
556
+ working_directory = _init_git_repository(tmp_path / "repo")
557
+
558
+ def fake_host_profile_raises(
559
+ setting_by_name: object | None = None,
560
+ ) -> str:
561
+ del setting_by_name
562
+ raise ValueError(FIXTURE_HOST_PROFILE_ERROR_MESSAGE)
563
+
564
+ monkeypatch.setattr(
565
+ invoker, "review_host_profile_detector", fake_host_profile_raises
566
+ )
567
+
568
+ exit_code = invoker.main(
569
+ [
570
+ CWD_FLAG,
571
+ str(working_directory),
572
+ CLI_SESSION_MODEL_FLAG,
573
+ FIXTURE_SESSION_OPUS,
574
+ CLI_TIMEOUT_FLAG,
575
+ str(DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS),
576
+ ]
577
+ )
578
+
579
+ assert exit_code == HOST_PROFILE_ERROR_RETURNCODE
580
+ captured = capsys.readouterr()
581
+ parsed_payload = json.loads(captured.out)
582
+ assert parsed_payload == {
583
+ RESULT_KEY_MODE: MODE_CHAIN,
584
+ RESULT_KEY_SERVED_COMMAND: None,
585
+ RESULT_KEY_RETURNCODE: HOST_PROFILE_ERROR_RETURNCODE,
586
+ RESULT_KEY_DIRTY_TREE: False,
587
+ }
588
+
589
+
590
+ def test_clean_stamp_allowed_only_on_successful_clean_serve() -> None:
591
+ clean_success = invoker.CodeReviewOutcome(
592
+ mode=MODE_CHAIN,
593
+ served_command=FIXTURE_SERVED_COMMAND,
594
+ returncode=FIXTURE_CHAIN_RETURNCODE,
595
+ is_dirty_tree=False,
596
+ )
597
+ dirty_success = invoker.CodeReviewOutcome(
598
+ mode=MODE_CHAIN,
599
+ served_command=FIXTURE_SERVED_COMMAND,
600
+ returncode=FIXTURE_CHAIN_RETURNCODE,
601
+ is_dirty_tree=True,
602
+ )
603
+ failed_serve = invoker.CodeReviewOutcome(
604
+ mode=MODE_CHAIN,
605
+ served_command=None,
606
+ returncode=FIXTURE_FAILED_RETURNCODE,
607
+ is_dirty_tree=False,
608
+ )
609
+ in_session_ready = invoker.CodeReviewOutcome(
610
+ mode=MODE_IN_SESSION,
611
+ served_command=None,
612
+ returncode=IN_SESSION_RETURNCODE,
613
+ is_dirty_tree=False,
614
+ )
615
+
616
+ assert invoker.is_code_review_clean_stamp_allowed(clean_success) is True
617
+ assert invoker.is_code_review_clean_stamp_allowed(dirty_success) is False
618
+ assert invoker.is_code_review_clean_stamp_allowed(failed_serve) is False
619
+ assert invoker.is_code_review_clean_stamp_allowed(in_session_ready) is True
620
+ assert invoker.is_successful_code_review(failed_serve) is False
621
+ assert invoker.is_successful_code_review(clean_success) is True
622
+
623
+
624
+ @pytest.mark.parametrize(
625
+ ("session_model", "expected_is_opus"),
626
+ [
627
+ (FIXTURE_SESSION_OPUS, True),
628
+ (FIXTURE_SESSION_OPUS_UPPER, True),
629
+ (FIXTURE_SESSION_SONNET, False),
630
+ (" opus ", True),
631
+ ],
632
+ )
633
+ def test_is_opus_session_model(
634
+ session_model: str, expected_is_opus: bool
635
+ ) -> None:
636
+ assert invoker.is_opus_session_model(session_model) is expected_is_opus
637
+
638
+
639
+ @pytest.mark.parametrize(
640
+ ("host_profile", "session_model", "expected_mode"),
641
+ [
642
+ (HOST_PROFILE_CLAUDE, FIXTURE_SESSION_OPUS, MODE_IN_SESSION),
643
+ (HOST_PROFILE_CLAUDE, FIXTURE_SESSION_SONNET, MODE_CHAIN),
644
+ (HOST_PROFILE_THIRD_PARTY, FIXTURE_SESSION_OPUS, MODE_CHAIN),
645
+ ],
646
+ )
647
+ def test_decide_review_mode(
648
+ host_profile: str, session_model: str, expected_mode: str
649
+ ) -> None:
650
+ assert (
651
+ invoker.decide_review_mode(
652
+ host_profile=host_profile,
653
+ session_model=session_model,
654
+ )
655
+ == expected_mode
656
+ )
657
+
658
+
659
+ def test_encode_code_review_outcome_shape() -> None:
660
+ review_outcome = invoker.CodeReviewOutcome(
661
+ mode=MODE_CHAIN,
662
+ served_command=FIXTURE_SERVED_COMMAND,
663
+ returncode=FIXTURE_CHAIN_RETURNCODE,
664
+ is_dirty_tree=True,
665
+ )
666
+ encoded_payload = invoker.encode_code_review_outcome(review_outcome)
667
+ assert encoded_payload == {
668
+ RESULT_KEY_MODE: MODE_CHAIN,
669
+ RESULT_KEY_SERVED_COMMAND: FIXTURE_SERVED_COMMAND,
670
+ RESULT_KEY_RETURNCODE: FIXTURE_CHAIN_RETURNCODE,
671
+ RESULT_KEY_DIRTY_TREE: True,
672
+ }