claude-dev-env 2.3.0 → 2.4.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 (136) hide show
  1. package/CLAUDE.md +3 -2
  2. package/agents/CLAUDE.md +1 -0
  3. package/agents/code-verifier.md +3 -3
  4. package/agents/skill-writer-agent.md +84 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  6. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  7. package/docs/CLAUDE.md +1 -0
  8. package/docs/references/CLAUDE.md +1 -0
  9. package/docs/references/code-review-enforcement.md +97 -0
  10. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  11. package/hooks/blocking/CLAUDE.md +8 -1
  12. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  13. package/hooks/blocking/code_review_gate_deny.py +74 -0
  14. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  15. package/hooks/blocking/code_review_push_gate.py +140 -0
  16. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  17. package/hooks/blocking/code_review_stamp_store.py +233 -0
  18. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  19. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  20. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  21. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  22. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  23. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  24. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  25. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  26. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  27. package/hooks/blocking/config/verified_commit_constants.py +13 -9
  28. package/hooks/blocking/conftest.py +2 -0
  29. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  30. package/hooks/blocking/destructive_command_blocker.py +19 -6
  31. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  32. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  33. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  34. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  35. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  36. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  37. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  38. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  39. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  40. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  41. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  42. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  43. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  44. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  45. package/hooks/blocking/test_verification_verdict_store.py +920 -903
  46. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  47. package/hooks/blocking/verification_verdict_store.py +27 -5
  48. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  49. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  50. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  51. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  52. package/hooks/git-hooks/pre_push.py +89 -2
  53. package/hooks/git-hooks/test_pre_push.py +103 -0
  54. package/hooks/hooks.json +16 -1
  55. package/hooks/hooks_constants/CLAUDE.md +1 -0
  56. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  57. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  58. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  59. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  60. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  61. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  62. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  63. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  64. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  65. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  66. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  67. package/hooks/validators/CLAUDE.md +1 -0
  68. package/hooks/validators/mypy_integration.py +63 -50
  69. package/hooks/validators/pyproject_config_discovery.py +101 -0
  70. package/hooks/validators/ruff_integration.py +211 -23
  71. package/hooks/validators/run_all_validators.py +21 -14
  72. package/hooks/validators/test_mypy_integration.py +32 -0
  73. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  74. package/hooks/validators/test_ruff_integration.py +68 -1
  75. package/hooks/validators/test_run_all_validators.py +25 -0
  76. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  77. package/package.json +1 -1
  78. package/rules/docstring-prose-matches-implementation.md +20 -43
  79. package/rules/durable-post-artifacts.md +7 -0
  80. package/scripts/codec_forwarding_test_support.py +83 -0
  81. package/scripts/conftest.py +8 -0
  82. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  83. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +44 -1
  84. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  85. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  86. package/scripts/invoke_code_review.py +550 -38
  87. package/scripts/resolve_worker_spawn.py +8 -1
  88. package/scripts/test_invoke_code_review.py +298 -4
  89. package/scripts/test_invoke_code_review_codec.py +77 -0
  90. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  91. package/skills/autoconverge/SKILL.md +9 -3
  92. package/skills/autoconverge/reference/convergence.md +2 -1
  93. package/skills/autoconverge/reference/multi-pr.md +6 -1
  94. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  95. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  96. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  98. package/skills/autoconverge/workflow/converge.mjs +24 -8
  99. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  100. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  101. package/skills/fresh-branch/CLAUDE.md +2 -0
  102. package/skills/fresh-branch/SKILL.md +2 -0
  103. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  104. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  105. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  106. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  107. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  108. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  109. package/skills/prototype/SKILL.md +86 -0
  110. package/skills/prototype/reference/honest-limitations.md +23 -0
  111. package/skills/prototype/reference/promotion-tasks.md +23 -0
  112. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  113. package/skills/prototype/scripts/conftest.py +15 -0
  114. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  115. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  116. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  117. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  118. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  119. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  120. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  121. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  122. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  123. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  124. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  125. package/skills/prototype/workflows/promotion.md +27 -0
  126. package/skills/prototype/workflows/sandbox.md +35 -0
  127. package/skills/skill-builder/CLAUDE.md +3 -3
  128. package/skills/skill-builder/SKILL.md +5 -5
  129. package/skills/skill-builder/references/CLAUDE.md +1 -1
  130. package/skills/skill-builder/references/delegation-map.md +3 -3
  131. package/skills/skill-builder/references/description-field.md +1 -1
  132. package/skills/skill-builder/references/skill-modularity.md +2 -3
  133. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  134. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  135. package/skills/skill-builder/workflows/new-skill.md +2 -2
  136. package/skills/team-advisor/SKILL.md +2 -2
@@ -50,6 +50,9 @@ from claude_chain_runner import ( # noqa: E402
50
50
  ChainInvocationOutcome,
51
51
  run_claude,
52
52
  )
53
+ from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
54
+ collect_forwarded_text_codec,
55
+ )
53
56
  from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
54
57
  AGENT_FLAG,
55
58
  ALL_AGENT_FILENAMES_BY_ROLE,
@@ -177,7 +180,9 @@ def _run_claude_with_headless_overrides(
177
180
  ) -> ChainInvocationOutcome:
178
181
  working_directory_path = str(working_directory)
179
182
  with _HEADLESS_CHAIN_RUNNER_LOCK:
180
- previous_runner = chain_runner.chain_subprocess_runner
183
+ previous_runner: TextCapturingSubprocessRunner = (
184
+ chain_runner.chain_subprocess_runner
185
+ )
181
186
 
182
187
  def _runner_with_headless_overrides(
183
188
  all_invocation_tokens: Sequence[str],
@@ -186,6 +191,7 @@ def _run_claude_with_headless_overrides(
186
191
  ) -> subprocess.CompletedProcess[str]:
187
192
  del all_positionals
188
193
  prompt_stdin.seek(0)
194
+ forwarded_text_codec = collect_forwarded_text_codec(all_keywords)
189
195
  completed_process: subprocess.CompletedProcess[str] = previous_runner(
190
196
  all_invocation_tokens,
191
197
  capture_output=True,
@@ -194,6 +200,7 @@ def _run_claude_with_headless_overrides(
194
200
  check=False,
195
201
  stdin=prompt_stdin,
196
202
  cwd=working_directory_path,
203
+ **forwarded_text_codec,
197
204
  )
198
205
  return completed_process
199
206
 
@@ -1,4 +1,4 @@
1
- """Behavioral tests for the host-aware ``/code-review`` invoker."""
1
+ """Behavioral tests for the host-aware ``/code-review`` invoker and stamps."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -28,7 +28,7 @@ from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
28
28
  from dev_env_scripts_constants.code_review_constants import ( # noqa: E402
29
29
  CLI_SESSION_MODEL_FLAG,
30
30
  CODE_REVIEW_MODEL_ALIAS,
31
- CODE_REVIEW_PROMPT,
31
+ DEFAULT_CODE_REVIEW_EFFORT,
32
32
  GIT_BINARY,
33
33
  GIT_PORCELAIN_FLAG,
34
34
  GIT_STATUS_SUBCOMMAND,
@@ -296,7 +296,7 @@ def test_chain_argv_assembly(
296
296
 
297
297
  assert call_log.claude_arguments == [
298
298
  SINGLE_TURN_FLAG,
299
- CODE_REVIEW_PROMPT,
299
+ invoker.build_code_review_prompt(DEFAULT_CODE_REVIEW_EFFORT),
300
300
  MODEL_FLAG,
301
301
  CODE_REVIEW_MODEL_ALIAS,
302
302
  OUTPUT_FORMAT_FLAG,
@@ -412,7 +412,7 @@ def test_build_code_review_arguments_matches_contract() -> None:
412
412
  all_arguments = invoker.build_code_review_arguments()
413
413
  assert all_arguments == [
414
414
  SINGLE_TURN_FLAG,
415
- CODE_REVIEW_PROMPT,
415
+ invoker.build_code_review_prompt(DEFAULT_CODE_REVIEW_EFFORT),
416
416
  MODEL_FLAG,
417
417
  CODE_REVIEW_MODEL_ALIAS,
418
418
  OUTPUT_FORMAT_FLAG,
@@ -670,3 +670,297 @@ def test_encode_code_review_outcome_shape() -> None:
670
670
  RESULT_KEY_RETURNCODE: FIXTURE_CHAIN_RETURNCODE,
671
671
  RESULT_KEY_DIRTY_TREE: True,
672
672
  }
673
+
674
+
675
+ EFFORT_LOW = "low"
676
+ REJECTED_ULTRA_EFFORT = "ultra"
677
+ RECORD_STAMP_MINT_CAP = 3
678
+ SINGLE_PASS_CAP = 1
679
+ INVALID_EFFORT_EXIT_CODE = 2
680
+ DID_NOT_CONVERGE_EXIT_CODE = 1
681
+ RECORD_STAMP_CLI_FLAG = "--record-stamp"
682
+ RESULT_STAMP_MINTED_KEY = "stamp_minted"
683
+ RESULT_PASS_COUNT_KEY = "pass_count"
684
+ RESULT_BOUND_HASH_KEY = "bound_hash"
685
+ MISSING_STORE_FILE_NAME = "code_review_stamp_store_absent.py"
686
+ SURFACE_SOURCE = "def add(left: int, right: int) -> int:\n return left + right\n"
687
+ SURFACE_CHANGE_SOURCE = "def add(left: int, right: int) -> int:\n return left - right\n"
688
+
689
+
690
+ def _run_git(repository_directory: Path, *git_arguments: str) -> None:
691
+ subprocess.run(
692
+ [GIT_BINARY, "-C", str(repository_directory), *git_arguments],
693
+ check=True,
694
+ capture_output=True,
695
+ text=True,
696
+ timeout=GIT_INIT_TIMEOUT_SECONDS,
697
+ )
698
+
699
+
700
+ def _make_repo_with_change_surface(tmp_path: Path) -> Path:
701
+ origin_directory = tmp_path / "origin.git"
702
+ work_directory = tmp_path / "work"
703
+ work_directory.mkdir()
704
+ subprocess.run(
705
+ [GIT_BINARY, "init", "--bare", "--initial-branch=main", str(origin_directory)],
706
+ check=True,
707
+ capture_output=True,
708
+ text=True,
709
+ timeout=GIT_INIT_TIMEOUT_SECONDS,
710
+ )
711
+ _run_git(work_directory, "init", "--initial-branch=main")
712
+ _run_git(work_directory, "config", "user.email", "tests@example.com")
713
+ _run_git(work_directory, "config", "user.name", "Reviewer")
714
+ (work_directory / "app.py").write_text(SURFACE_SOURCE, encoding="utf-8")
715
+ _run_git(work_directory, "add", "-A")
716
+ _run_git(work_directory, "commit", "-m", "base")
717
+ _run_git(work_directory, "remote", "add", "origin", str(origin_directory))
718
+ _run_git(work_directory, "push", "-u", "origin", "main")
719
+ (work_directory / "app.py").write_text(SURFACE_CHANGE_SOURCE, encoding="utf-8")
720
+ return work_directory
721
+
722
+
723
+ def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: Path) -> None:
724
+ home_text = str(fake_home)
725
+ monkeypatch.setenv("HOME", home_text)
726
+ monkeypatch.setenv("USERPROFILE", home_text)
727
+ monkeypatch.delenv("HOMEDRIVE", raising=False)
728
+ monkeypatch.delenv("HOMEPATH", raising=False)
729
+
730
+
731
+ def _prepared_surface_repo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
732
+ fake_home = tmp_path / "home"
733
+ fake_home.mkdir()
734
+ _isolate_home(monkeypatch, fake_home)
735
+ return _make_repo_with_change_surface(tmp_path)
736
+
737
+
738
+ def _chain_clean_outcome() -> invoker.CodeReviewOutcome:
739
+ return invoker.CodeReviewOutcome(
740
+ mode=MODE_CHAIN,
741
+ served_command=FIXTURE_SERVED_COMMAND,
742
+ returncode=FIXTURE_CHAIN_RETURNCODE,
743
+ is_dirty_tree=True,
744
+ )
745
+
746
+
747
+ def _stable_clean_review(**_review_keywords: object) -> invoker.CodeReviewOutcome:
748
+ return _chain_clean_outcome()
749
+
750
+
751
+ def _surface_changing_review(
752
+ *, working_directory: Path, **_review_keywords: object
753
+ ) -> invoker.CodeReviewOutcome:
754
+ applied_fix_path = working_directory / DIRTY_FILE_NAME
755
+ applied_fix_path.write_text(DIRTY_FILE_CONTENTS, encoding="utf-8")
756
+ return _chain_clean_outcome()
757
+
758
+
759
+ class _DriftingReview:
760
+ def __init__(self) -> None:
761
+ self.pass_count = 0
762
+
763
+ def __call__(
764
+ self, *, working_directory: Path, **_review_keywords: object
765
+ ) -> invoker.CodeReviewOutcome:
766
+ self.pass_count += 1
767
+ drift_path = working_directory / f"fix_{self.pass_count}.txt"
768
+ drift_path.write_text(str(self.pass_count), encoding="utf-8")
769
+ return _chain_clean_outcome()
770
+
771
+
772
+ @pytest.mark.parametrize("valid_effort", ["low", "medium", "high", "xhigh", "max"])
773
+ def test_validate_effort_token_accepts_known_tokens(valid_effort: str) -> None:
774
+ assert invoker.validate_effort_token(valid_effort) is None
775
+
776
+
777
+ def test_validate_effort_token_rejects_ultra_loudly() -> None:
778
+ error_message = invoker.validate_effort_token(REJECTED_ULTRA_EFFORT)
779
+ assert error_message is not None
780
+ assert REJECTED_ULTRA_EFFORT in error_message
781
+
782
+
783
+ def test_validate_effort_token_rejects_unknown_token() -> None:
784
+ error_message = invoker.validate_effort_token("bogus")
785
+ assert error_message is not None
786
+ assert "bogus" in error_message
787
+
788
+
789
+ def test_build_code_review_prompt_reads_as_slash_command() -> None:
790
+ assert invoker.build_code_review_prompt(EFFORT_LOW) == "/code-review low --fix"
791
+ assert invoker.build_code_review_prompt("xhigh") == "/code-review xhigh --fix"
792
+
793
+
794
+ def test_cli_rejects_ultra_effort_with_nonzero_exit(
795
+ monkeypatch: pytest.MonkeyPatch,
796
+ tmp_path: Path,
797
+ capsys: pytest.CaptureFixture[str],
798
+ ) -> None:
799
+ working_directory = _init_git_repository(tmp_path / "repo")
800
+ _install_seams(
801
+ monkeypatch,
802
+ host_profile=HOST_PROFILE_CLAUDE,
803
+ claude_outcome=None,
804
+ working_directory=working_directory,
805
+ )
806
+ exit_code = invoker.main(
807
+ [
808
+ CWD_FLAG,
809
+ str(working_directory),
810
+ CLI_SESSION_MODEL_FLAG,
811
+ FIXTURE_SESSION_OPUS,
812
+ REJECTED_ULTRA_EFFORT,
813
+ ]
814
+ )
815
+ assert exit_code == INVALID_EFFORT_EXIT_CODE
816
+ assert REJECTED_ULTRA_EFFORT in capsys.readouterr().err
817
+
818
+
819
+ def test_record_stamp_mints_on_surface_stable_clean_pass(
820
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
821
+ ) -> None:
822
+ working_directory = _prepared_surface_repo(monkeypatch, tmp_path)
823
+ monkeypatch.setattr(invoker, "invoke_code_review", _stable_clean_review)
824
+ mint_outcome = invoker.invoke_code_review_and_record_stamp(
825
+ working_directory=working_directory,
826
+ session_model=CODE_REVIEW_MODEL_ALIAS,
827
+ timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
828
+ effort=EFFORT_LOW,
829
+ )
830
+ assert mint_outcome.is_stamp_minted is True
831
+ assert mint_outcome.bound_hash is not None
832
+ store_module = invoker.load_code_review_stamp_store()
833
+ assert store_module.stamp_covers_surface(
834
+ str(working_directory), mint_outcome.bound_hash, EFFORT_LOW
835
+ )
836
+
837
+
838
+ def test_record_stamp_does_not_mint_when_review_changes_surface(
839
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
840
+ ) -> None:
841
+ working_directory = _prepared_surface_repo(monkeypatch, tmp_path)
842
+ monkeypatch.setattr(invoker, "invoke_code_review", _surface_changing_review)
843
+ mint_outcome = invoker.invoke_code_review_and_record_stamp(
844
+ working_directory=working_directory,
845
+ session_model=CODE_REVIEW_MODEL_ALIAS,
846
+ timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
847
+ effort=EFFORT_LOW,
848
+ maximum_passes=SINGLE_PASS_CAP,
849
+ )
850
+ assert mint_outcome.is_stamp_minted is False
851
+ assert mint_outcome.pass_count == SINGLE_PASS_CAP
852
+ assert mint_outcome.bound_hash is None
853
+
854
+
855
+ def test_record_stamp_hits_cap_without_minting(
856
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
857
+ ) -> None:
858
+ working_directory = _prepared_surface_repo(monkeypatch, tmp_path)
859
+ monkeypatch.setattr(invoker, "invoke_code_review", _DriftingReview())
860
+ mint_outcome = invoker.invoke_code_review_and_record_stamp(
861
+ working_directory=working_directory,
862
+ session_model=CODE_REVIEW_MODEL_ALIAS,
863
+ timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
864
+ effort=EFFORT_LOW,
865
+ maximum_passes=RECORD_STAMP_MINT_CAP,
866
+ )
867
+ assert mint_outcome.is_stamp_minted is False
868
+ assert mint_outcome.pass_count == RECORD_STAMP_MINT_CAP
869
+
870
+
871
+ def test_cli_record_stamp_returns_non_convergence_code_on_cap(
872
+ monkeypatch: pytest.MonkeyPatch,
873
+ tmp_path: Path,
874
+ capsys: pytest.CaptureFixture[str],
875
+ ) -> None:
876
+ working_directory = _prepared_surface_repo(monkeypatch, tmp_path)
877
+ monkeypatch.setattr(invoker, "invoke_code_review", _DriftingReview())
878
+ exit_code = invoker.main(
879
+ [
880
+ CWD_FLAG,
881
+ str(working_directory),
882
+ CLI_SESSION_MODEL_FLAG,
883
+ CODE_REVIEW_MODEL_ALIAS,
884
+ RECORD_STAMP_CLI_FLAG,
885
+ EFFORT_LOW,
886
+ ]
887
+ )
888
+ assert exit_code == DID_NOT_CONVERGE_EXIT_CODE
889
+ parsed_payload = json.loads(capsys.readouterr().out)
890
+ assert parsed_payload[RESULT_STAMP_MINTED_KEY] is False
891
+ assert parsed_payload[RESULT_PASS_COUNT_KEY] == RECORD_STAMP_MINT_CAP
892
+ assert parsed_payload[RESULT_BOUND_HASH_KEY] is None
893
+
894
+
895
+ def test_load_code_review_stamp_store_records_and_covers_surface(
896
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
897
+ ) -> None:
898
+ working_directory = _prepared_surface_repo(monkeypatch, tmp_path)
899
+ store_module = invoker.load_code_review_stamp_store()
900
+ surface_hash = store_module.live_surface_hash(str(working_directory))
901
+ assert surface_hash is not None
902
+ stamp_path = store_module.record_clean_stamp(
903
+ str(working_directory), surface_hash, EFFORT_LOW
904
+ )
905
+ assert stamp_path.exists()
906
+ assert store_module.stamp_covers_surface(
907
+ str(working_directory), surface_hash, EFFORT_LOW
908
+ )
909
+
910
+
911
+ def test_load_code_review_stamp_store_raises_when_file_absent(
912
+ monkeypatch: pytest.MonkeyPatch,
913
+ ) -> None:
914
+ monkeypatch.setattr(
915
+ invoker, "STAMP_STORE_MODULE_FILE_NAME", MISSING_STORE_FILE_NAME
916
+ )
917
+ with pytest.raises(ModuleNotFoundError):
918
+ invoker.load_code_review_stamp_store()
919
+
920
+
921
+ def test_cli_record_stamp_reports_missing_store_dependency(
922
+ monkeypatch: pytest.MonkeyPatch,
923
+ tmp_path: Path,
924
+ capsys: pytest.CaptureFixture[str],
925
+ ) -> None:
926
+ working_directory = _init_git_repository(tmp_path / "repo")
927
+
928
+ def raise_missing_store(*all_args: object, **all_keywords: object) -> object:
929
+ del all_args, all_keywords
930
+ raise ModuleNotFoundError("store missing", name="code_review_stamp_store")
931
+
932
+ monkeypatch.setattr(invoker, "load_code_review_stamp_store", raise_missing_store)
933
+ exit_code = invoker.main(
934
+ [
935
+ CWD_FLAG,
936
+ str(working_directory),
937
+ CLI_SESSION_MODEL_FLAG,
938
+ CODE_REVIEW_MODEL_ALIAS,
939
+ RECORD_STAMP_CLI_FLAG,
940
+ EFFORT_LOW,
941
+ ]
942
+ )
943
+ assert exit_code == INVALID_EFFORT_EXIT_CODE
944
+ captured = capsys.readouterr()
945
+ assert "stamp store" in captured.err
946
+ parsed_payload = json.loads(captured.out)
947
+ assert parsed_payload[RESULT_STAMP_MINTED_KEY] is False
948
+
949
+
950
+ def test_encode_stamp_mint_outcome_includes_mint_metadata() -> None:
951
+ review_outcome = invoker.CodeReviewOutcome(
952
+ mode=MODE_CHAIN,
953
+ served_command=FIXTURE_SERVED_COMMAND,
954
+ returncode=FIXTURE_CHAIN_RETURNCODE,
955
+ is_dirty_tree=False,
956
+ )
957
+ mint_outcome = invoker.StampMintOutcome(
958
+ review_outcome=review_outcome,
959
+ is_stamp_minted=True,
960
+ pass_count=SINGLE_PASS_CAP,
961
+ bound_hash="abc123",
962
+ )
963
+ encoded_payload = invoker.encode_stamp_mint_outcome(mint_outcome)
964
+ assert encoded_payload[RESULT_STAMP_MINTED_KEY] is True
965
+ assert encoded_payload[RESULT_PASS_COUNT_KEY] == SINGLE_PASS_CAP
966
+ assert encoded_payload[RESULT_BOUND_HASH_KEY] == "abc123"
@@ -0,0 +1,77 @@
1
+ """Behavioral tests for text codec keyword forwarding in the ``/code-review`` invoker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import invoke_code_review as invoker
8
+ import pytest
9
+ from codec_forwarding_test_support import (
10
+ FIXTURE_CHAIN_ENCODING,
11
+ FIXTURE_CHAIN_ERRORS,
12
+ FIXTURE_ENCODING_KEYWORD_NAME,
13
+ FIXTURE_ERRORS_KEYWORD_NAME,
14
+ install_codec_seams,
15
+ )
16
+ from dev_env_scripts_constants.timing import DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS
17
+
18
+ FIXTURE_CHAIN_STDOUT = '{"result":"review done"}'
19
+
20
+
21
+ def _install_invoker_codec_seams(
22
+ monkeypatch: pytest.MonkeyPatch,
23
+ *,
24
+ all_chain_codec_keywords: dict[str, str],
25
+ ) -> dict[str, object]:
26
+ return install_codec_seams(
27
+ monkeypatch,
28
+ all_chain_codec_keywords=all_chain_codec_keywords,
29
+ chain_stdout=FIXTURE_CHAIN_STDOUT,
30
+ runner_host=invoker,
31
+ runner_attribute_name="review_claude_runner",
32
+ )
33
+
34
+
35
+ def test_forwards_text_codec_keywords_to_subprocess_runner(
36
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
37
+ ) -> None:
38
+ all_observed_runner_keywords = _install_invoker_codec_seams(
39
+ monkeypatch,
40
+ all_chain_codec_keywords={
41
+ FIXTURE_ENCODING_KEYWORD_NAME: FIXTURE_CHAIN_ENCODING,
42
+ FIXTURE_ERRORS_KEYWORD_NAME: FIXTURE_CHAIN_ERRORS,
43
+ },
44
+ )
45
+
46
+ invoker._run_claude_with_empty_stdin(
47
+ [],
48
+ timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
49
+ working_directory=tmp_path,
50
+ )
51
+
52
+ assert (
53
+ all_observed_runner_keywords[FIXTURE_ENCODING_KEYWORD_NAME]
54
+ == FIXTURE_CHAIN_ENCODING
55
+ )
56
+ assert (
57
+ all_observed_runner_keywords[FIXTURE_ERRORS_KEYWORD_NAME]
58
+ == FIXTURE_CHAIN_ERRORS
59
+ )
60
+
61
+
62
+ def test_absent_text_codec_keywords_are_not_invented(
63
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
64
+ ) -> None:
65
+ all_observed_runner_keywords = _install_invoker_codec_seams(
66
+ monkeypatch,
67
+ all_chain_codec_keywords={},
68
+ )
69
+
70
+ invoker._run_claude_with_empty_stdin(
71
+ [],
72
+ timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
73
+ working_directory=tmp_path,
74
+ )
75
+
76
+ assert FIXTURE_ENCODING_KEYWORD_NAME not in all_observed_runner_keywords
77
+ assert FIXTURE_ERRORS_KEYWORD_NAME not in all_observed_runner_keywords
@@ -0,0 +1,101 @@
1
+ """Behavioral tests for text codec keyword forwarding in worker-spawn headless overrides."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ import resolve_worker_spawn as dispatcher
9
+ from codec_forwarding_test_support import (
10
+ FIXTURE_CHAIN_ENCODING,
11
+ FIXTURE_CHAIN_ERRORS,
12
+ FIXTURE_ENCODING_KEYWORD_NAME,
13
+ FIXTURE_ERRORS_KEYWORD_NAME,
14
+ install_codec_seams,
15
+ )
16
+ from dev_env_scripts_constants.grok_worker_constants import (
17
+ DEFAULT_WORKER_TIMEOUT_SECONDS,
18
+ UTF8_ENCODING,
19
+ )
20
+
21
+ FIXTURE_CHAIN_STDOUT = '{"tier":"claude","status":"done"}'
22
+ FIXTURE_PROMPT_TEXT = "do the work"
23
+
24
+
25
+ def _install_dispatcher_codec_seams(
26
+ monkeypatch: pytest.MonkeyPatch,
27
+ *,
28
+ all_chain_codec_keywords: dict[str, str],
29
+ ) -> dict[str, object]:
30
+ return install_codec_seams(
31
+ monkeypatch,
32
+ all_chain_codec_keywords=all_chain_codec_keywords,
33
+ chain_stdout=FIXTURE_CHAIN_STDOUT,
34
+ runner_host=dispatcher,
35
+ runner_attribute_name="spawn_claude_runner",
36
+ )
37
+
38
+
39
+ def _run_headless_with_prompt(
40
+ *,
41
+ prompt_file: Path,
42
+ working_directory: Path,
43
+ timeout_seconds: int = DEFAULT_WORKER_TIMEOUT_SECONDS,
44
+ ) -> None:
45
+ prompt_stdin = prompt_file.open(encoding=UTF8_ENCODING)
46
+ try:
47
+ dispatcher._run_claude_with_headless_overrides(
48
+ [],
49
+ timeout_seconds=timeout_seconds,
50
+ working_directory=working_directory,
51
+ prompt_stdin=prompt_stdin,
52
+ )
53
+ finally:
54
+ prompt_stdin.close()
55
+
56
+
57
+ def test_forwards_text_codec_keywords_to_subprocess_runner(
58
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
59
+ ) -> None:
60
+ prompt_file = tmp_path / "prompt.txt"
61
+ prompt_file.write_text(FIXTURE_PROMPT_TEXT, encoding=UTF8_ENCODING)
62
+ all_observed_runner_keywords = _install_dispatcher_codec_seams(
63
+ monkeypatch,
64
+ all_chain_codec_keywords={
65
+ FIXTURE_ENCODING_KEYWORD_NAME: FIXTURE_CHAIN_ENCODING,
66
+ FIXTURE_ERRORS_KEYWORD_NAME: FIXTURE_CHAIN_ERRORS,
67
+ },
68
+ )
69
+
70
+ _run_headless_with_prompt(
71
+ prompt_file=prompt_file,
72
+ working_directory=tmp_path,
73
+ )
74
+
75
+ assert (
76
+ all_observed_runner_keywords[FIXTURE_ENCODING_KEYWORD_NAME]
77
+ == FIXTURE_CHAIN_ENCODING
78
+ )
79
+ assert (
80
+ all_observed_runner_keywords[FIXTURE_ERRORS_KEYWORD_NAME]
81
+ == FIXTURE_CHAIN_ERRORS
82
+ )
83
+
84
+
85
+ def test_absent_text_codec_keywords_are_not_invented(
86
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
87
+ ) -> None:
88
+ prompt_file = tmp_path / "prompt.txt"
89
+ prompt_file.write_text(FIXTURE_PROMPT_TEXT, encoding=UTF8_ENCODING)
90
+ all_observed_runner_keywords = _install_dispatcher_codec_seams(
91
+ monkeypatch,
92
+ all_chain_codec_keywords={},
93
+ )
94
+
95
+ _run_headless_with_prompt(
96
+ prompt_file=prompt_file,
97
+ working_directory=tmp_path,
98
+ )
99
+
100
+ assert FIXTURE_ENCODING_KEYWORD_NAME not in all_observed_runner_keywords
101
+ assert FIXTURE_ERRORS_KEYWORD_NAME not in all_observed_runner_keywords
@@ -161,13 +161,18 @@ orchestrating session's own steps, and the script's agent-prompt text carries
161
161
  ```
162
162
  Workflow({
163
163
  scriptPath: "<this skill dir>/workflow/converge.mjs",
164
- args: { owner: "<O>", repo: "<R>", prNumber: <N>, bugbotDisabled: false, copilotDisabled: false }
164
+ args: { owner: "<O>", repo: "<R>", prNumber: <N>, bugbotDisabled: false, copilotDisabled: false, homeDirectory: "<HOME>" }
165
165
  })
166
166
  ```
167
167
 
168
168
  `scriptPath` is the absolute path to `workflow/converge.mjs` inside this skill's
169
169
  own directory (on this install,
170
- `<home>/.claude/skills/autoconverge/workflow/converge.mjs`). Set
170
+ `<home>/.claude/skills/autoconverge/workflow/converge.mjs`). `homeDirectory` is
171
+ the absolute path to the directory that holds `.claude` (resolve it once from
172
+ `$HOME`, or `$env:USERPROFILE` on Windows, with forward slashes). The workflow
173
+ runs in a sandbox with no access to environment variables, so the run reads the
174
+ home directory from this arg to build the path to the codex-review scripts the
175
+ Codex gate calls; leave it out and the Codex gate cannot find those scripts. Set
171
176
  `bugbotDisabled: true` only when the user has opted Cursor Bugbot out for the
172
177
  run; otherwise the workflow detects an opt-out or an unreachable Bugbot on its
173
178
  own. Set `copilotDisabled: true` when the step 5 quota pre-check exits non-zero,
@@ -189,7 +194,7 @@ Write it again when the result lands, so the handoff carries the final run id an
189
194
  names the teardown phase the fresh session picks up from.
190
195
 
191
196
  The workflow returns
192
- `{ converged, rounds, finalSha, blocker, standardsNote, copilotNote, cleanAuditNote, reuseNote, deferredPrs }`,
197
+ `{ converged, rounds, finalSha, blocker, standardsNote, copilotNote, codexNote, cleanAuditNote, reuseNote, deferredPrs }`,
193
198
  plus a `userReview` field on a `blocker: "user-review"` return. `cleanAuditNote` is
194
199
  non-null when the environment refused the CLEAN bugteam review post and the run
195
200
  recorded the bypass — see
@@ -359,6 +364,7 @@ ready again — then run the checkpoints.
359
364
  Blocker: <blocker> # only when blocked
360
365
  Standards: <standardsNote> # only when a round deferred code-standard findings
361
366
  Copilot: <copilotNote> # only when Copilot was down or out of quota
367
+ Codex: <codexNote> # only when the Codex gate was bypassed (codex_down or opt-out)
362
368
  Clean-audit: <cleanAuditNote> # only when the CLEAN bugteam post was bypassed
363
369
  Reuse: <reuseNote> # only when the reuse pass identified an improvement
364
370
  ```
@@ -135,7 +135,8 @@ any bot threads with a deferral note, and reports the deferral in
135
135
  the weekly usage probe via `is_codex_review_required` (shared threshold
136
136
  constant — no inline percent), and the wrapper's `codex_down` class.
137
137
  - Opt-out token or `codex_down` → set `codexDown`, no stamp, move to the
138
- convergence check with `--codex-down`.
138
+ convergence check with `--codex-down`. A `codex_down` classification also
139
+ records a `codexNote` so the skip stays visible in the final report.
139
140
  - Usage at/below threshold or null → skip with no stamp; the machine checklist
140
141
  applies the same rule.
141
142
  - Above threshold → run the codex-review wrapper against the PR base branch.
@@ -72,6 +72,7 @@ Workflow({
72
72
  scriptPath: "<this skill dir>/workflow/converge_multi.mjs",
73
73
  args: {
74
74
  convergeScriptPath: "<this skill dir>/workflow/converge.mjs",
75
+ homeDirectory: "<HOME>",
75
76
  prs: [
76
77
  { owner: "<O>", repo: "<R>", prNumber: <N1>, repoPath: "<abs worktree 1>", bugbotDisabled: false, copilotDisabled: false },
77
78
  { owner: "<O>", repo: "<R>", prNumber: <N2>, repoPath: "<abs worktree 2>", bugbotDisabled: false, copilotDisabled: false }
@@ -82,7 +83,11 @@ Workflow({
82
83
 
83
84
  `convergeScriptPath` is the absolute path to `workflow/converge.mjs` in this same
84
85
  skill directory; each `repoPath` is the absolute path of the worktree that PR is
85
- checked out in. The workflow runs in the background and notifies the session on
86
+ checked out in. `homeDirectory` is the absolute path to the directory that holds
87
+ `.claude` (resolve it once from `$HOME`, or `$env:USERPROFILE` on Windows, with
88
+ forward slashes); the fan-out forwards it to every child run, which needs it to
89
+ build the codex-review scripts path because the workflow sandbox has no access to
90
+ environment variables. The workflow runs in the background and notifies the session on
86
91
  completion; watch live progress with `/workflows`, where each PR's child run
87
92
  appears under its own group.
88
93
 
@@ -88,16 +88,22 @@ Each reviewer-down condition below skips its own convergence-check gate. The fla
88
88
  `--copilot-down` (the Copilot review gate and the pending-requested-reviews
89
89
  gate bypassed), then mark-ready. `copilotNote` records the bypass for the
90
90
  final report.
91
- - **Terminal Codex gate skipped or down** — the conditional-required Codex gate
92
- runs after Bugbot and Copilot. When `CLAUDE_REVIEWS_DISABLED` lists `codex`
93
- (`reviews_disabled.py --reviewer codex` exit 0), the gate sets `codexDown` and
94
- advances with no review. When the weekly usage probe reports `percent_left`
95
- null or at/below the shared threshold (`is_codex_review_required` false), the
96
- gate skips without a stamp; the convergence check applies the same rule. When
97
- the wrapper classifies `codex_down`, the gate sets `codexDown` and advances.
98
- A clean required run stamps `codexCleanAt` for `--codex-clean-at`. Non-code-standard
99
- findings re-enter CONVERGE through the existing fix path. Standards-only findings
100
- defer a follow-up, stamp `codexCleanAt`, and advance to FINALIZE (no fix push).
91
+ - **Terminal Codex gate skipped (opt-out, usage, or `codex_down`)** — the
92
+ conditional-required Codex gate runs after Bugbot and Copilot. When
93
+ `CLAUDE_REVIEWS_DISABLED` lists `codex` (`reviews_disabled.py --reviewer codex`
94
+ exit 0), the gate sets `codexDown` and advances with no review. When the weekly
95
+ usage probe reports `percent_left` null or at/below the shared threshold
96
+ (`is_codex_review_required` false), the gate skips without a stamp; the
97
+ convergence check applies the same rule. When the codex-review wrapper
98
+ classifies `codex_down` a Codex CLI error, or a review that ran past its
99
+ `CONFIG.codexReviewTimeoutSeconds` budget the gate skips the same way: it sets
100
+ `codexDown`, records a `codexNote` so the skip stays visible in the final
101
+ report, clears `codexCleanAt`, and advances to `FINALIZE` (mark-ready with the
102
+ gate bypassed) so a Codex outage does not block convergence; Codex runs again on
103
+ the next convergence. A clean required run stamps `codexCleanAt` for
104
+ `--codex-clean-at`. Non-code-standard findings re-enter `CONVERGE` through the
105
+ existing fix path. Standards-only findings defer a follow-up, stamp
106
+ `codexCleanAt`, and advance to `FINALIZE` (no fix push).
101
107
  - **A lens agent dies** — when one parallel reading lens returns null (a terminal
102
108
  agent failure), the round proceeds on the surviving lenses. A real defect it
103
109
  would have caught surfaces in a later round or at the convergence check. A dead
@@ -655,7 +655,7 @@ test('the workflow declares cleanAuditNote and assembles it into every result vi
655
655
  assert.match(convergeSource, /let cleanAuditNote = null/);
656
656
  assert.match(
657
657
  convergeSource,
658
- /const assembleResult = \(outcomeFields\) => \(\{\n\s*\.\.\.outcomeFields,\n\s*standardsNote,\n\s*copilotNote,\n\s*cleanAuditNote,\n\s*reuseNote,\n\s*deferredPrs,\n\s*\}\)/,
658
+ /const assembleResult = \(outcomeFields\) => \(\{\n\s*\.\.\.outcomeFields,\n\s*standardsNote,\n\s*copilotNote,\n\s*codexNote,\n\s*cleanAuditNote,\n\s*reuseNote,\n\s*deferredPrs,\n\s*\}\)/,
659
659
  );
660
660
  const assembleCalls = convergeSource.match(/return assembleResult\(/g) || [];
661
661
  assert.ok(