claude-dev-env 8.6.1 → 8.7.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 (102) hide show
  1. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  2. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +1 -0
  3. package/_shared/advisor/scripts/model_tier_run_validator.py +3 -2
  4. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +35 -0
  5. package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
  6. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
  7. package/bin/install.test.mjs +25 -0
  8. package/docs/high-trust-agent-delivery.md +132 -0
  9. package/hooks/advisory/refactor_guard.py +2 -2
  10. package/hooks/advisory/test_refactor_guard.py +21 -0
  11. package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
  12. package/hooks/blocking/code_rules_shared.py +4 -7
  13. package/hooks/blocking/conftest.py +22 -0
  14. package/hooks/blocking/state_description_blocker.py +10 -8
  15. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
  16. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
  17. package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
  18. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
  19. package/hooks/blocking/test_code_rules_shared.py +18 -0
  20. package/hooks/blocking/test_conftest.py +35 -0
  21. package/hooks/blocking/test_state_description_blocker.py +16 -0
  22. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
  23. package/hooks/git-hooks/pull_request_handoff.py +2 -2
  24. package/hooks/git-hooks/test_config.py +10 -3
  25. package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
  26. package/hooks/git-hooks/test_verification_notice_state.py +50 -0
  27. package/hooks/git-hooks/verification_notice_state.py +3 -1
  28. package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
  29. package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
  30. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
  31. package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
  32. package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
  33. package/hooks/validators/exempt_paths.py +3 -18
  34. package/hooks/validators/test_exempt_paths.py +20 -0
  35. package/hooks/validators/test_python_antipattern_checks.py +14 -0
  36. package/package.json +1 -1
  37. package/rules/agent-merges-its-own-green-pull-request.md +54 -0
  38. package/rules/archiving-agent-config.md +10 -0
  39. package/rules/code-standards.md +1 -0
  40. package/rules/correction-lens.md +134 -0
  41. package/rules/flag-non-breaking-findings.md +11 -0
  42. package/rules/no-contrast-framing.md +68 -0
  43. package/rules/review-closure-is-a-check.md +44 -0
  44. package/scripts/Invoke-PesterSuites.ps1 +42 -0
  45. package/scripts/agent_merge_check.py +381 -0
  46. package/scripts/check.ps1 +12 -1
  47. package/scripts/ci/install-playtest.mjs +291 -0
  48. package/scripts/ci/install-playtest.test.mjs +84 -0
  49. package/scripts/ci/installer-lifecycle.mjs +11 -30
  50. package/scripts/ci/installer-lifecycle.test.mjs +14 -0
  51. package/scripts/ci/pstack-command-shim.test.mjs +33 -0
  52. package/scripts/ci/scratch-home-install.mjs +73 -0
  53. package/scripts/ci/scratch-home-install.test.mjs +49 -0
  54. package/scripts/codex_compat_materializer.py +33 -16
  55. package/scripts/contrast_framing.py +101 -0
  56. package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
  57. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
  58. package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
  59. package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
  60. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
  61. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
  62. package/scripts/durable_post_lint.py +30 -4
  63. package/scripts/followup_cli.py +6 -10
  64. package/scripts/merge_gate_checks.py +236 -0
  65. package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
  66. package/scripts/policy_lint/adapters.py +30 -0
  67. package/scripts/policy_lint/config/constants.py +2 -0
  68. package/scripts/policy_lint/registry.py +5 -0
  69. package/scripts/repository_checks/config/constants.py +12 -0
  70. package/scripts/repository_checks/config/policy_document.py +28 -2
  71. package/scripts/repository_checks/followups.py +54 -0
  72. package/scripts/repository_checks/match_exemptions.py +67 -0
  73. package/scripts/repository_checks/models.py +46 -2
  74. package/scripts/repository_checks/reporting.py +23 -7
  75. package/scripts/repository_checks/tracked_secrets.py +2 -2
  76. package/scripts/repository_policy.py +4 -2
  77. package/scripts/review_closure.py +134 -0
  78. package/scripts/review_closure_github.py +345 -0
  79. package/scripts/review_closure_model.py +360 -0
  80. package/scripts/test_agent_merge_check.py +317 -0
  81. package/scripts/test_durable_post_lint.py +46 -0
  82. package/scripts/test_followup_cli.py +40 -0
  83. package/scripts/test_merge_gate_checks.py +92 -0
  84. package/scripts/tests/test_code_review_constants.py +78 -0
  85. package/scripts/tests/test_codex_compat_materializer.py +48 -4
  86. package/scripts/tests/test_contrast_framing.py +117 -0
  87. package/scripts/tests/test_followups.py +88 -0
  88. package/scripts/tests/test_match_exemptions.py +291 -0
  89. package/scripts/tests/test_models.py +67 -2
  90. package/scripts/tests/test_package_inventory.py +6 -4
  91. package/scripts/tests/test_pester_suites_run.py +53 -0
  92. package/scripts/tests/test_policy_document.py +39 -1
  93. package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
  94. package/scripts/tests/test_reporting.py +18 -0
  95. package/scripts/tests/test_repository_policy.py +43 -1
  96. package/scripts/tests/test_review_closure.py +126 -0
  97. package/scripts/tests/test_review_closure_github.py +240 -0
  98. package/scripts/tests/test_review_closure_model.py +299 -0
  99. package/scripts/tests/test_tracked_secrets.py +42 -0
  100. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
  101. package/scripts/repository_checks/email_exemptions.py +0 -39
  102. package/scripts/tests/test_email_exemptions.py +0 -158
@@ -7,19 +7,16 @@ through ``validate_content_for_edit_lane``, which hook files never reach at PreT
7
7
  tests drive the real ``main()`` stdin entry point and the pre-check CLI with a
8
8
  hook-infrastructure target.
9
9
 
10
- Each test builds a temporary tree whose tail mirrors a production hook directory
11
- (``packages/claude-dev-env/hooks/blocking``) so ``is_hook_infrastructure`` matches
12
- the target path the same way it would for the real directory.
10
+ Each test takes the ``hook_blocking_dir`` fixture from ``conftest.py``, which builds
11
+ a temporary tree matching the production hook directory so ``is_hook_infrastructure``
12
+ routes a target inside it the same way it would for the production directory.
13
13
  """
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
17
  import json
18
18
  import pathlib
19
- import shutil
20
19
  import sys
21
- import tempfile
22
- from collections.abc import Iterator
23
20
 
24
21
  import pytest
25
22
 
@@ -46,19 +43,6 @@ PASS_THROUGH_ALIAS_SOURCE = (
46
43
  " return find_bare_path_segments(content)\n"
47
44
  )
48
45
 
49
- _HOOK_INFRASTRUCTURE_TAIL = pathlib.Path("packages") / "claude-dev-env" / "hooks" / "blocking"
50
-
51
-
52
- @pytest.fixture
53
- def hook_blocking_dir() -> Iterator[pathlib.Path]:
54
- base_directory = pathlib.Path(tempfile.mkdtemp())
55
- blocking_directory = base_directory / _HOOK_INFRASTRUCTURE_TAIL
56
- blocking_directory.mkdir(parents=True)
57
- try:
58
- yield blocking_directory
59
- finally:
60
- shutil.rmtree(base_directory, ignore_errors=False)
61
-
62
46
 
63
47
  def test_write_of_pass_through_alias_into_hook_directory_denies(
64
48
  hook_blocking_dir: pathlib.Path,
@@ -291,3 +291,21 @@ def test_is_dedicated_constants_module_true_for_config_directory() -> None:
291
291
 
292
292
  def test_is_dedicated_constants_module_false_for_ordinary_module() -> None:
293
293
  assert _SHARED_MODULE._is_dedicated_constants_module("app/services/orders.py") is False
294
+
295
+
296
+ @pytest.mark.parametrize(
297
+ "normalized_marker_table_name",
298
+ [
299
+ "ALL_AGENT_HOME_TOOLING_PATTERNS",
300
+ "ALL_HOOK_INFRASTRUCTURE_PATTERNS",
301
+ "ALL_WORKFLOW_REGISTRY_PATTERNS",
302
+ "ALL_MIGRATION_PATH_PATTERNS",
303
+ ],
304
+ )
305
+ def test_normalized_path_marker_table_carries_no_backslash_entry(
306
+ normalized_marker_table_name: str,
307
+ ) -> None:
308
+ """Each table's consumer normalizes the path with .replace("\\\\", "/") but
309
+ not the marker, so a backslash entry can never match."""
310
+ marker_table = getattr(_SHARED_MODULE, normalized_marker_table_name)
311
+ assert all("\\" not in each_marker for each_marker in marker_table)
@@ -0,0 +1,35 @@
1
+ """Tests for the shared fixtures this directory's ``conftest.py`` provides."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+
7
+ from code_rules_shared import is_hook_infrastructure
8
+
9
+
10
+ def should_yield_a_directory_a_target_inside_it_routes_as_hook_infrastructure(
11
+ hook_blocking_dir: pathlib.Path,
12
+ ) -> None:
13
+ target_file = hook_blocking_dir / "new_blocker.py"
14
+ assert is_hook_infrastructure(str(target_file)) is True
15
+
16
+
17
+ def should_yield_a_directory_that_exists_on_disk(
18
+ hook_blocking_dir: pathlib.Path,
19
+ ) -> None:
20
+ assert hook_blocking_dir.is_dir()
21
+
22
+
23
+ def should_keep_the_hook_blocking_dir_explanation_only_on_the_fixture() -> None:
24
+ directory = pathlib.Path(__file__).parent
25
+ exempt_paths = {directory / "conftest.py", pathlib.Path(__file__)}
26
+ explanation_fragment = "tail mirrors a production hook directory"
27
+ offending_files = [
28
+ path.name
29
+ for path in sorted(directory.glob("*.py"))
30
+ if path not in exempt_paths and explanation_fragment in path.read_text(encoding="utf-8")
31
+ ]
32
+ assert offending_files == [], (
33
+ "the hook_blocking_dir fixture's explanation belongs on conftest.py alone, "
34
+ f"got it repeated in: {offending_files}"
35
+ )
@@ -34,6 +34,11 @@ from code_rules_enforcer_test_support import (
34
34
  repository_file_path,
35
35
  repository_root,
36
36
  )
37
+ from hooks_constants.code_rules_enforcer_constants import (
38
+ JAVASCRIPT_BLOCK_COMMENT_CLOSER,
39
+ JAVASCRIPT_BLOCK_COMMENT_OPENER,
40
+ )
41
+ import state_description_blocker as _state_description_blocker_module
37
42
 
38
43
  HOOK_SCRIPT_PATH = os.path.join(
39
44
  os.path.dirname(__file__), "state_description_blocker.py"
@@ -995,3 +1000,14 @@ def test_find_violations_with_lines_reports_markdown_line_past_a_code_fence() ->
995
1000
  all_violations = find_violations_with_lines(markdown_source, "docs/api.md")
996
1001
 
997
1002
  assert all_violations == [("previously", 7), ("was previously", 7)]
1003
+
1004
+
1005
+ def test_block_comment_markers_are_the_javascript_constants() -> None:
1006
+ assert (
1007
+ _state_description_blocker_module.JAVASCRIPT_BLOCK_COMMENT_OPENER
1008
+ is JAVASCRIPT_BLOCK_COMMENT_OPENER
1009
+ )
1010
+ assert (
1011
+ _state_description_blocker_module.JAVASCRIPT_BLOCK_COMMENT_CLOSER
1012
+ is JAVASCRIPT_BLOCK_COMMENT_CLOSER
1013
+ )
@@ -47,20 +47,7 @@ LOCAL_SHA_FIELD_INDEX: int = 1
47
47
  NO_PARSEABLE_STDIN_LINES_MESSAGE: str = (
48
48
  "claude-dev-env pre-push: no parseable stdin lines; aborting"
49
49
  )
50
- NO_PARSEABLE_STDIN_LINES_SENTINEL: str = "__no_parseable_stdin_lines__"
51
- UNRESOLVABLE_MERGE_BASE_SENTINEL: str = "__unresolvable_merge_base__"
52
- UNRESOLVABLE_MERGE_BASE_MESSAGE: str = (
53
- "claude-dev-env pre-push: CODE_RULES validation is pending because the pushed "
54
- "object and default branch have no shared merge base. Restore shared history "
55
- "or provide a reachable default branch, then run the push again."
56
- )
57
50
  LOCAL_BRANCH_REFERENCE_PREFIX: str = "refs/heads/"
58
- ORIGIN_HEAD_SYMBOLIC_REFERENCE: str = "refs/remotes/origin/HEAD"
59
- ORIGIN_REMOTE_TRACKING_REFERENCE_PREFIX: str = "refs/remotes/origin/"
60
- ALL_DEFAULT_BRANCH_FALLBACK_REFERENCES: tuple[str, ...] = (
61
- "refs/remotes/origin/main",
62
- "refs/remotes/origin/master",
63
- )
64
51
  REMOTE_BRANCH_REFERENCE_TEMPLATE: str = "refs/remotes/{remote}/{branch}"
65
52
  ALL_GIT_SYMBOLIC_REFERENCE_COMMAND_PREFIX: tuple[str, ...] = (
66
53
  "git",
@@ -99,7 +86,7 @@ GIT_SYMBOLIC_REFERENCE_SUBCOMMAND: str = "symbolic-ref"
99
86
  GIT_COMMAND_TIMEOUT_SECONDS: int = 30
100
87
  GH_EXECUTABLE_NAME: str = "gh"
101
88
  GH_PR_VIEW_TIMEOUT_SECONDS: int = 5
102
- GH_PR_VIEW_ARGUMENTS: tuple[str, ...] = ("pr", "view", "--json", "url", "--jq", ".url")
89
+ ALL_GH_PR_VIEW_ARGUMENTS: tuple[str, ...] = ("pr", "view", "--json", "url", "--jq", ".url")
103
90
  GIT_FOR_EACH_REF_SUBCOMMAND: str = "for-each-ref"
104
91
  GIT_REFERENCE_SHORT_NAME_FORMAT_ARGUMENT: str = "--format=%(refname:short)"
105
92
  COMMIT_OBJECT_NAME_SUFFIX: str = "^{commit}"
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  from git_hooks_constants import (
7
7
  GH_EXECUTABLE_NAME,
8
- GH_PR_VIEW_ARGUMENTS,
8
+ ALL_GH_PR_VIEW_ARGUMENTS,
9
9
  GH_PR_VIEW_TIMEOUT_SECONDS,
10
10
  )
11
11
 
@@ -15,7 +15,7 @@ from subprocess_window_access import hidden_window_creation_flags
15
15
  def get_pull_request_url(repo_dir: Path) -> str | None:
16
16
  try:
17
17
  result = subprocess.run(
18
- [GH_EXECUTABLE_NAME, *GH_PR_VIEW_ARGUMENTS],
18
+ [GH_EXECUTABLE_NAME, *ALL_GH_PR_VIEW_ARGUMENTS],
19
19
  cwd=repo_dir,
20
20
  check=False,
21
21
  capture_output=True,
@@ -11,6 +11,13 @@ def test_no_parseable_stdin_lines_message_exists_and_describes_problem() -> None
11
11
  assert "no parseable stdin lines" in git_hooks_constants.NO_PARSEABLE_STDIN_LINES_MESSAGE
12
12
 
13
13
 
14
- def test_no_parseable_stdin_lines_sentinel_is_distinct_sentinel_value() -> None:
15
- assert git_hooks_constants.NO_PARSEABLE_STDIN_LINES_SENTINEL is not None
16
- assert git_hooks_constants.NO_PARSEABLE_STDIN_LINES_SENTINEL != git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE
14
+ def test_pre_push_cut_leftovers_are_removed() -> None:
15
+ for removed_name in (
16
+ "ALL_DEFAULT_BRANCH_FALLBACK_REFERENCES",
17
+ "ORIGIN_HEAD_SYMBOLIC_REFERENCE",
18
+ "ORIGIN_REMOTE_TRACKING_REFERENCE_PREFIX",
19
+ "UNRESOLVABLE_MERGE_BASE_SENTINEL",
20
+ "UNRESOLVABLE_MERGE_BASE_MESSAGE",
21
+ "NO_PARSEABLE_STDIN_LINES_SENTINEL",
22
+ ):
23
+ assert not hasattr(git_hooks_constants, removed_name)
@@ -0,0 +1,49 @@
1
+ """Tests for the pull-request URL query the commit handoff runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ import pull_request_handoff
11
+ from git_hooks_constants import (
12
+ ALL_GH_PR_VIEW_ARGUMENTS,
13
+ GH_EXECUTABLE_NAME,
14
+ GH_PR_VIEW_TIMEOUT_SECONDS,
15
+ )
16
+
17
+
18
+ def test_should_query_the_url_through_the_named_executable_and_arguments(
19
+ monkeypatch: pytest.MonkeyPatch,
20
+ ) -> None:
21
+ all_calls: list[tuple[list[str], object]] = []
22
+
23
+ def record_call(
24
+ command: list[str], **keyword_arguments: object
25
+ ) -> subprocess.CompletedProcess[str]:
26
+ all_calls.append((command, keyword_arguments["timeout"]))
27
+ return subprocess.CompletedProcess(command, 0, "https://example/pull/1\n", "")
28
+
29
+ monkeypatch.setattr(pull_request_handoff.subprocess, "run", record_call)
30
+
31
+ url = pull_request_handoff.get_pull_request_url(Path("/repo"))
32
+
33
+ assert url == "https://example/pull/1"
34
+ assert all_calls == [
35
+ ([GH_EXECUTABLE_NAME, *ALL_GH_PR_VIEW_ARGUMENTS], GH_PR_VIEW_TIMEOUT_SECONDS)
36
+ ]
37
+
38
+
39
+ def test_should_report_no_url_when_the_query_exits_non_zero(
40
+ monkeypatch: pytest.MonkeyPatch,
41
+ ) -> None:
42
+ def failing_call(
43
+ command: list[str], **keyword_arguments: object
44
+ ) -> subprocess.CompletedProcess[str]:
45
+ return subprocess.CompletedProcess(command, 1, "", "no pull request")
46
+
47
+ monkeypatch.setattr(pull_request_handoff.subprocess, "run", failing_call)
48
+
49
+ assert pull_request_handoff.get_pull_request_url(Path("/repo")) is None
@@ -0,0 +1,50 @@
1
+ """Tests for the base-reference construction in ``_base_matches_current``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ import verification_notice
10
+ import verification_notice_state
11
+ from git_hooks_constants import COMMIT_OBJECT_NAME_SUFFIX
12
+ from git_hooks_constants.verification_notice_constants import (
13
+ ALL_GIT_BASE_QUERY_PREFIX,
14
+ BASE_REFERENCE,
15
+ )
16
+
17
+
18
+ def test_base_matches_current_queries_base_reference_plus_commit_suffix() -> None:
19
+ captured_queries: list[tuple[str, ...]] = []
20
+
21
+ def _fake_git_query(
22
+ repository_root: Path, arguments: tuple[str, ...], capture_stderr: bool
23
+ ) -> str | None:
24
+ captured_queries.append(arguments)
25
+ return "deadbeef"
26
+
27
+ verification_notice_state._base_matches_current(Path("/repo"), "deadbeef", _fake_git_query)
28
+
29
+ assert captured_queries == [
30
+ (*ALL_GIT_BASE_QUERY_PREFIX, BASE_REFERENCE + COMMIT_OBJECT_NAME_SUFFIX)
31
+ ]
32
+
33
+
34
+ def test_base_matches_current_derives_its_query_from_the_base_reference_constant(
35
+ monkeypatch: pytest.MonkeyPatch,
36
+ ) -> None:
37
+ monkeypatch.setattr(verification_notice_state, "BASE_REFERENCE", "custom/base")
38
+ captured_queries: list[tuple[str, ...]] = []
39
+
40
+ def _fake_git_query(
41
+ repository_root: Path, arguments: tuple[str, ...], capture_stderr: bool
42
+ ) -> str | None:
43
+ captured_queries.append(arguments)
44
+ return None
45
+
46
+ verification_notice_state._base_matches_current(Path("/repo"), "deadbeef", _fake_git_query)
47
+
48
+ assert captured_queries == [
49
+ (*ALL_GIT_BASE_QUERY_PREFIX, "custom/base" + COMMIT_OBJECT_NAME_SUFFIX)
50
+ ]
@@ -6,8 +6,10 @@ from collections.abc import Callable, Mapping
6
6
  from pathlib import Path
7
7
  from typing import Protocol
8
8
 
9
+ from git_hooks_constants import COMMIT_OBJECT_NAME_SUFFIX
9
10
  from git_hooks_constants.verification_notice_constants import (
10
11
  ALL_GIT_BASE_QUERY_PREFIX,
12
+ BASE_REFERENCE,
11
13
  GIT_COMMAND_SUCCESS_EXIT_CODE,
12
14
  HEX_DIGITS,
13
15
  NOTICE_FAILED_LINE,
@@ -180,7 +182,7 @@ def _base_matches_current(
180
182
  ) -> bool:
181
183
  current_base_revision = run_git_query(
182
184
  repository_root,
183
- (*ALL_GIT_BASE_QUERY_PREFIX, "origin/main^{commit}"),
185
+ (*ALL_GIT_BASE_QUERY_PREFIX, BASE_REFERENCE + COMMIT_OBJECT_NAME_SUFFIX),
184
186
  False,
185
187
  )
186
188
  return current_base_revision == base_revision
@@ -38,10 +38,13 @@ CLAUDE_JOB_DIR_SCRATCH_SUBDIRECTORY: str = "tmp"
38
38
  EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME: str = "CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT"
39
39
  ALL_EPHEMERAL_EXEMPT_DISABLE_TRUTHY_VALUES: frozenset[str] = frozenset({"1", "true", "yes", "on"})
40
40
  LEADING_DRIVE_LETTER_PATTERN: re.Pattern[str] = re.compile(r"^[a-z]:")
41
- ALL_AGENT_HOME_TOOLING_PATTERNS = {"/.grok/", "\\.grok\\", "\\.grok/"}
42
- ALL_HOOK_INFRASTRUCTURE_PATTERNS = {"/.claude/hooks/", "\\.claude\\hooks\\", "\\.claude/hooks/", "/packages/claude-dev-env/hooks/", "\\packages\\claude-dev-env\\hooks\\"} | ALL_AGENT_HOME_TOOLING_PATTERNS
43
- ALL_WORKFLOW_REGISTRY_PATTERNS = {"/workflow/", "\\workflow\\", "_tab.py", "/states.py", "\\states.py", "/modules.py", "\\modules.py"}
44
- ALL_MIGRATION_PATH_PATTERNS = {"/migrations/", "\\migrations\\"}
41
+ ALL_AGENT_HOME_TOOLING_PATTERNS = {"/.grok/"}
42
+ ALL_HOOK_INFRASTRUCTURE_PATTERNS = {
43
+ "/.claude/hooks/",
44
+ "/packages/claude-dev-env/hooks/",
45
+ } | ALL_AGENT_HOME_TOOLING_PATTERNS
46
+ ALL_WORKFLOW_REGISTRY_PATTERNS = {"/workflow/", "_tab.py", "/states.py", "/modules.py"}
47
+ ALL_MIGRATION_PATH_PATTERNS = {"/migrations/"}
45
48
 
46
49
  CONSTANTS_MODULE_SUFFIX: str = "_constants.py"
47
50
  CONFIG_DIRECTORY_SEGMENT: str = "config"
@@ -192,7 +195,12 @@ ALL_COLLECTION_TYPE_NAMES: frozenset[str] = frozenset({
192
195
  })
193
196
  ALL_SUBSCRIPT_ONLY_COLLECTION_TYPE_NAMES: frozenset[str] = frozenset({"dict"})
194
197
  COLLECTION_BY_NAME_PATTERN: re.Pattern[str] = re.compile(r"^[a-z][a-z0-9]*_by_[a-z][a-z0-9_]*$")
195
- ALL_CLI_FILE_PATH_MARKERS: tuple[str, ...] = ("/scripts/", "\\scripts\\", "_cli.py", "/cli.py", "\\cli.py")
198
+ ALL_CLI_FILE_PATH_MARKERS: tuple[str, ...] = (
199
+ "/scripts/",
200
+ "/diagnostics/",
201
+ "_cli.py",
202
+ "/cli.py",
203
+ )
196
204
 
197
205
  LOGGING_FSTRING_PATTERN = re.compile(
198
206
  r'\b(?:log_(?:debug|info|warning|error|critical|exception)'
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ GIT_EXECUTABLE_TOKEN: str = "git"
6
+ GIT_COMMAND_TIMEOUT_SECONDS: int = 5
5
7
  MAXIMUM_REFACTOR_LINE_DELTA: int = 3
6
8
  REFACTOR_LINE_DELTA_DIVISOR: int = 2
7
9
  CHANGED_SURFACE_MATCH_RATIO: float = 0.5
@@ -1,9 +1,9 @@
1
1
  """Constants for the session-edit stage gate hook trio.
2
2
 
3
3
  Shared by the PostToolUse tracker
4
- (``hooks/observability/session_file_edit_tracker.py``), the PreToolUse gate
5
- (``hooks/blocking/session_edit_stage_gate.py``), and the SessionStart cleanup
6
- (``hooks/session/session_edit_tracker_cleanup.py``): the per-session tracker
4
+ (``hooks/observability/session_file_edit_tracker.py``) and the SessionStart
5
+ cleanup (``hooks/session/session_edit_tracker_cleanup.py``): the per-session
6
+ tracker
7
7
  filename shape and JSON payload key, the session-id sanitize pattern, the
8
8
  SessionStart source key and the fresh-startup source value the cleanup keys its
9
9
  deletion on, the edit-tool name set, the per-session lock filename suffix and
@@ -62,7 +62,6 @@ ALL_TRACKED_UNSTAGED_FILES_COMMAND: tuple[str, ...] = (
62
62
 
63
63
  PARTIAL_COMMIT_BYPASS_MARKER: str = "# partial-commit"
64
64
  COMMIT_SUBCOMMAND_TOKEN: str = "commit"
65
- GIT_EXECUTABLE_TOKEN: str = "git"
66
65
  ENV_ASSIGNMENT_PREFIX_PATTERN: re.Pattern[str] = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
67
66
  ALL_STAGING_SUBCOMMAND_TOKENS: frozenset[str] = frozenset({"add", "stage"})
68
67
  ALL_STAGE_ALL_ADD_FLAG_TOKENS: frozenset[str] = frozenset({"-A", "--all", "-u", "--update"})
@@ -40,9 +40,6 @@ ALL_COMMENT_TRANSITION_PATTERNS: list[Pattern[str]] = [
40
40
  compile(r"\bas of\b", IGNORECASE),
41
41
  ]
42
42
 
43
- BLOCK_COMMENT_OPEN_MARKER: str = "/*"
44
- BLOCK_COMMENT_CLOSE_MARKER: str = "*/"
45
-
46
43
  INLINE_CODE_PATTERN: Pattern[str] = compile(r"``[^`]+``|`[^`]+`")
47
44
  DOUBLE_QUOTED_SPAN_PATTERN: Pattern[str] = compile(r'"[^"\n]*"')
48
45
  TRIPLE_QUOTED_BLOCK_PATTERN: Pattern[str] = compile(
@@ -10,13 +10,15 @@ if str(_HOOKS_ROOT) not in sys.path:
10
10
  sys.path.insert(0, str(_HOOKS_ROOT))
11
11
 
12
12
  from blocking.state_description_blocker import _extract_comment_lines
13
+ from hooks_constants.code_rules_enforcer_constants import (
14
+ JAVASCRIPT_BLOCK_COMMENT_CLOSER,
15
+ JAVASCRIPT_BLOCK_COMMENT_OPENER,
16
+ )
13
17
  from hooks_constants.state_description_blocker_constants import (
14
18
  ALL_BLOCK_COMMENT_EXTENSIONS,
15
19
  ALL_COMMENT_TRANSITION_PATTERNS,
16
20
  ALL_HASH_ONLY_EXTENSIONS,
17
21
  ALL_MARKDOWN_EXTENSIONS,
18
- BLOCK_COMMENT_CLOSE_MARKER,
19
- BLOCK_COMMENT_OPEN_MARKER,
20
22
  DOUBLE_QUOTE_BODY_GROUP,
21
23
  SINGLE_QUOTE_BODY_GROUP,
22
24
  TRIPLE_QUOTED_BLOCK_PATTERN,
@@ -47,19 +49,19 @@ def test_transition_patterns_do_not_match_unrelated_prose() -> None:
47
49
 
48
50
 
49
51
  def test_block_comment_markers_close_a_single_line_block_comment() -> None:
50
- text = f"{BLOCK_COMMENT_OPEN_MARKER} note {BLOCK_COMMENT_CLOSE_MARKER}\ncode();"
52
+ text = f"{JAVASCRIPT_BLOCK_COMMENT_OPENER} note {JAVASCRIPT_BLOCK_COMMENT_CLOSER}\ncode();"
51
53
  all_comment_lines = _extract_comment_lines(text, ".js")
52
54
  assert all_comment_lines == [
53
- (1, f"{BLOCK_COMMENT_OPEN_MARKER} note {BLOCK_COMMENT_CLOSE_MARKER}")
55
+ (1, f"{JAVASCRIPT_BLOCK_COMMENT_OPENER} note {JAVASCRIPT_BLOCK_COMMENT_CLOSER}")
54
56
  ]
55
57
 
56
58
 
57
59
  def test_block_comment_markers_carry_an_unclosed_comment_to_the_next_line() -> None:
58
- text = f"{BLOCK_COMMENT_OPEN_MARKER} still open\nclosed here {BLOCK_COMMENT_CLOSE_MARKER}"
60
+ text = f"{JAVASCRIPT_BLOCK_COMMENT_OPENER} still open\nclosed here {JAVASCRIPT_BLOCK_COMMENT_CLOSER}"
59
61
  all_comment_lines = _extract_comment_lines(text, ".js")
60
62
  assert all_comment_lines == [
61
- (1, f"{BLOCK_COMMENT_OPEN_MARKER} still open"),
62
- (2, f"closed here {BLOCK_COMMENT_CLOSE_MARKER}"),
63
+ (1, f"{JAVASCRIPT_BLOCK_COMMENT_OPENER} still open"),
64
+ (2, f"closed here {JAVASCRIPT_BLOCK_COMMENT_CLOSER}"),
63
65
  ]
64
66
 
65
67
 
@@ -48,27 +48,21 @@ TEST_PATH_PATTERNS: frozenset[str] = frozenset(
48
48
  HOOK_INFRASTRUCTURE_PATTERNS: frozenset[str] = frozenset(
49
49
  {
50
50
  "/.claude/hooks/",
51
- "\\.claude\\hooks\\",
52
- "\\.claude/hooks/",
53
51
  }
54
52
  )
55
53
 
56
54
  WORKFLOW_REGISTRY_PATTERNS: frozenset[str] = frozenset(
57
55
  {
58
56
  "/workflow/",
59
- "\\workflow\\",
60
57
  "_tab.py",
61
58
  "/states.py",
62
- "\\states.py",
63
59
  "/modules.py",
64
- "\\modules.py",
65
60
  }
66
61
  )
67
62
 
68
63
  MIGRATION_PATH_PATTERNS: frozenset[str] = frozenset(
69
64
  {
70
65
  "/migrations/",
71
- "\\migrations\\",
72
66
  }
73
67
  )
74
68
 
@@ -80,23 +74,14 @@ def is_test_file(file_path: str) -> bool:
80
74
 
81
75
  def is_hook_infrastructure(file_path: str) -> bool:
82
76
  path_normalized = file_path.lower().replace("\\", "/")
83
- return any(
84
- pattern.replace("\\", "/") in path_normalized
85
- for pattern in HOOK_INFRASTRUCTURE_PATTERNS
86
- )
77
+ return any(pattern in path_normalized for pattern in HOOK_INFRASTRUCTURE_PATTERNS)
87
78
 
88
79
 
89
80
  def is_workflow_registry_file(file_path: str) -> bool:
90
81
  path_normalized = file_path.lower().replace("\\", "/")
91
- return any(
92
- pattern.replace("\\", "/") in path_normalized
93
- for pattern in WORKFLOW_REGISTRY_PATTERNS
94
- )
82
+ return any(pattern in path_normalized for pattern in WORKFLOW_REGISTRY_PATTERNS)
95
83
 
96
84
 
97
85
  def is_migration_file(file_path: str) -> bool:
98
86
  path_normalized = file_path.lower().replace("\\", "/")
99
- return any(
100
- pattern.replace("\\", "/") in path_normalized
101
- for pattern in MIGRATION_PATH_PATTERNS
102
- )
87
+ return any(pattern in path_normalized for pattern in MIGRATION_PATH_PATTERNS)
@@ -14,6 +14,9 @@ _BLOCKING_DIR = str(Path(__file__).resolve().parent.parent / "blocking")
14
14
  if _BLOCKING_DIR not in sys.path:
15
15
  sys.path.insert(0, _BLOCKING_DIR)
16
16
 
17
+ import pytest # noqa: E402
18
+
19
+ from validators import exempt_paths # noqa: E402
17
20
  from validators.exempt_paths import is_config_file # noqa: E402
18
21
  from code_rules_path_utils import is_config_file as path_utils_is_config_file # noqa: E402
19
22
 
@@ -63,3 +66,20 @@ def test_is_config_file_is_identical_function_object_from_path_utils() -> None:
63
66
  assert is_config_file is path_utils_is_config_file, (
64
67
  "exempt_paths.is_config_file must be imported from code_rules_path_utils, not re-defined"
65
68
  )
69
+
70
+
71
+ @pytest.mark.parametrize(
72
+ "normalized_pattern_set_name",
73
+ [
74
+ "HOOK_INFRASTRUCTURE_PATTERNS",
75
+ "WORKFLOW_REGISTRY_PATTERNS",
76
+ "MIGRATION_PATH_PATTERNS",
77
+ ],
78
+ )
79
+ def test_normalized_pattern_set_carries_no_backslash_entry(
80
+ normalized_pattern_set_name: str,
81
+ ) -> None:
82
+ """Each set's consumer normalizes the path with .replace("\\\\", "/") but
83
+ not the pattern, so a backslash entry can never match."""
84
+ pattern_set = getattr(exempt_paths, normalized_pattern_set_name)
85
+ assert all("\\" not in each_pattern for each_pattern in pattern_set)
@@ -150,6 +150,18 @@ class TestPrintInCliEntryPoint:
150
150
  tree = ast.parse(CLI_ENTRY_POINT_WITH_PRINTS)
151
151
  assert check_print_in_production(tree, "packages/foo/scripts/run_job.py") == []
152
152
 
153
+ def test_diagnostics_path_allows_print(self) -> None:
154
+ tree = ast.parse(CLI_ENTRY_POINT_WITH_PRINTS)
155
+ assert check_print_in_production(tree, "theme_exports/tools/diagnostics/probe.py") == []
156
+
157
+ def test_windows_diagnostics_path_allows_print(self) -> None:
158
+ tree = ast.parse(CLI_ENTRY_POINT_WITH_PRINTS)
159
+ assert check_print_in_production(tree, "theme_exports\\tools\\diagnostics\\probe.py") == []
160
+
161
+ def test_near_miss_diagnostics_name_still_flagged(self) -> None:
162
+ tree = ast.parse(NEAR_MISS_WITH_PRINT)
163
+ assert len(check_print_in_production(tree, "run_diagnostics.py")) == 1
164
+
153
165
  def test_near_miss_name_still_flagged(self) -> None:
154
166
  tree = ast.parse(NEAR_MISS_WITH_PRINT)
155
167
  assert len(check_print_in_production(tree, "publicli.py")) == 1
@@ -180,6 +192,8 @@ class TestCrossSurfaceConsistency:
180
192
  "stp_preview/renderer.py",
181
193
  "publicli.py",
182
194
  "packages/foo/scripts/run_job.py",
195
+ "theme_exports/tools/diagnostics/probe.py",
196
+ "run_diagnostics.py",
183
197
  ):
184
198
  production_allows = check_print_in_production(tree, path) == []
185
199
  library_allows = check_library_print(source, path) == []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.6.1",
3
+ "version": "8.7.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,54 @@
1
+ # The Agent Merges Its Own Green Pull Request
2
+
3
+ **When this applies:** Any pull request an agent opened or was asked to drive, once its checks report.
4
+
5
+ ## Rule
6
+
7
+ The agent that drives a pull request merges it. A pull request that is green, carries no open review thread, and sits at a merge state of `clean` is merged in the same run that brought it there. Waiting for the owner to type "merge" parks finished work on the person the work was done for.
8
+
9
+ Three things stay with the owner, and nothing else does:
10
+
11
+ - A pull request the owner asked to hold.
12
+ - A change the owner said they want to read first.
13
+ - A repository whose branch rule requires an approving review the agent cannot give.
14
+
15
+ Where the branch rule requires zero approvals and one status check, that check is the gate, and the agent merges on its verdict.
16
+
17
+ Validation is the precondition, and green means every check reported on the exact head commit. A branch rule that requires one status check names the floor a merge needs; a pull request whose other checks are red or still running is held until they report.
18
+
19
+ ## The precondition is mechanical
20
+
21
+ One command prints the verdict:
22
+
23
+ ```
24
+ python packages/claude-dev-env/scripts/agent_merge_check.py <owner>/<name> <number>
25
+ ```
26
+
27
+ It prints `MERGE` and exits 0 when the pull request is ready. It prints `HOLD` with the reason and exits 1 for a draft, for a head behind or conflicting with the base, for a required check that is not passing, for a check still running or red, and for an open review thread. It exits 2 when the state could not be read.
28
+
29
+ Each hold reason names its own repair, and each repair belongs to the agent:
30
+
31
+ | Hold | What the agent does |
32
+ |---|---|
33
+ | Head behind the base | Merge the base branch in and push |
34
+ | Head conflicts with the base | Merge the base branch in, resolve, push |
35
+ | A required check is red | Read the failing check, fix it, push |
36
+ | A check is red or still running | Fix it, or wait for it, then read the verdict again |
37
+ | A review thread is open | Answer it, push the fix, resolve the thread |
38
+ | The pull request is a draft | Mark it ready once the checks pass |
39
+
40
+ ## When a gate elsewhere holds the merge command
41
+
42
+ A session working inside another repository can sit behind that repository's own pre-merge gate, which reads the checkout the session works in and refuses a merge command whatever repository the pull request belongs to. That session hands the merge to the session that owns this repository's pull requests, by message, naming the pull request. The receiving session reads the verdict above and merges. The hand-off carries the work; it never lands on the owner.
43
+
44
+ ## After the merge
45
+
46
+ Delete nothing by hand. The repository deletes the head branch on merge.
47
+
48
+ ## Sibling rules
49
+
50
+ | Rule | Role |
51
+ |---|---|
52
+ | [`git-workflow.md`](git-workflow.md) | Draft first, and confirm each required context fired after the push |
53
+ | [`ci-owns-the-gate.md`](ci-owns-the-gate.md) | The gate runs once, and it runs on CI |
54
+ | [`correction-lens.md`](correction-lens.md) | A correction becomes a control at the highest layer that can hold it |
@@ -43,6 +43,16 @@ Record a row in the archive manifest for each file: why it went, the `git mv`
43
43
  that restores it, and every other edit a restore has to undo. A restore that has
44
44
  to be reconstructed from the diff is not recoverable in any useful sense.
45
45
 
46
+ ## Exempt files
47
+
48
+ The "Never archived" section of `rules-archived/ARCHIVE-MANIFEST.md` names the
49
+ files this procedure leaves alone. Read that section before a prune moves
50
+ anything. A pass that reaches an exempt file stops there and reports which file
51
+ it was, so the prune's other moves still land.
52
+
53
+ `rules/correction-lens.md` is on that list today. Editing an exempt file to
54
+ sharpen it is ordinary work.
55
+
46
56
  ## Make the archive stick
47
57
 
48
58
  Two things do not follow the file on their own.
@@ -27,6 +27,7 @@ BDD is the outer process and TDD is the inner loop: [`bdd.md`](bdd.md) discovers
27
27
 
28
28
  | Concern | Rule file |
29
29
  |---|---|
30
+ | Handling a correction from the user | [`correction-lens.md`](correction-lens.md) |
30
31
  | Task tracking / worker completion | [`workers-done-before-complete.md`](workers-done-before-complete.md) |
31
32
  | Multi-step task list | skill `task-build` (see agents catalog) |
32
33