claude-dev-env 1.36.1 → 1.36.2

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 (36) hide show
  1. package/_shared/pr-loop/audit-contract.md +159 -0
  2. package/_shared/pr-loop/code-rules-gate.md +64 -0
  3. package/_shared/pr-loop/fix-protocol.md +37 -0
  4. package/_shared/pr-loop/gh-payloads.md +85 -0
  5. package/_shared/pr-loop/scripts/README.md +20 -0
  6. package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
  8. package/_shared/pr-loop/scripts/config/__init__.py +0 -0
  9. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
  10. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
  11. package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
  12. package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
  13. package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
  14. package/_shared/pr-loop/scripts/config/preflight_constants.py +47 -0
  15. package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
  16. package/_shared/pr-loop/scripts/gh_util.py +193 -0
  17. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
  18. package/_shared/pr-loop/scripts/preflight.py +227 -0
  19. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
  20. package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
  21. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
  22. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
  23. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
  24. package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
  25. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
  27. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
  28. package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
  29. package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
  30. package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
  31. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
  32. package/_shared/pr-loop/scripts/tests/test_preflight.py +333 -0
  33. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +82 -0
  34. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
  35. package/_shared/pr-loop/state-schema.md +81 -0
  36. package/package.json +2 -1
@@ -0,0 +1,333 @@
1
+ """Tests for preflight git hooks path verification.
2
+
3
+ Covers:
4
+ - core.hooksPath unset: exits non-zero with correction message
5
+ - core.hooksPath pointing to the correct claude hooks dir: exits zero
6
+ - core.hooksPath pointing elsewhere (husky override): exits non-zero
7
+ - core.hooksPath with trailing slash: must still pass after normalization
8
+ """
9
+
10
+ import importlib.util
11
+ import inspect
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+ from types import ModuleType
16
+ from unittest.mock import MagicMock, patch
17
+
18
+ import pytest
19
+
20
+
21
+ def _load_preflight_module() -> ModuleType:
22
+ module_path = Path(__file__).parent.parent / "preflight.py"
23
+ spec = importlib.util.spec_from_file_location("preflight", module_path)
24
+ assert spec is not None
25
+ assert spec.loader is not None
26
+ module = importlib.util.module_from_spec(spec)
27
+ spec.loader.exec_module(module)
28
+ return module
29
+
30
+
31
+ preflight = _load_preflight_module()
32
+
33
+ from config.preflight_constants import PYTEST_NO_TESTS_COLLECTED_EXIT_CODE # noqa: E402
34
+
35
+
36
+ def _make_completed_process(
37
+ stdout: str, returncode: int
38
+ ) -> subprocess.CompletedProcess:
39
+ process = MagicMock(spec=subprocess.CompletedProcess)
40
+ process.stdout = stdout
41
+ process.returncode = returncode
42
+ return process
43
+
44
+
45
+ def test_should_exit_nonzero_when_core_hooks_path_unset(
46
+ capsys: pytest.CaptureFixture[str],
47
+ ) -> None:
48
+ with patch("subprocess.run") as mock_run:
49
+ mock_run.return_value = _make_completed_process("", returncode=1)
50
+ exit_code = preflight.verify_git_hooks_path()
51
+ assert exit_code != 0
52
+ captured = capsys.readouterr()
53
+ assert "core.hooksPath" in captured.err
54
+ assert "npx claude-dev-env" in captured.err or "git config" in captured.err
55
+
56
+
57
+ def test_should_exit_zero_when_core_hooks_path_points_to_claude_hooks(
58
+ tmp_path: Path,
59
+ ) -> None:
60
+ claude_hooks_path = tmp_path / ".claude" / "hooks" / "git-hooks"
61
+ claude_hooks_path.mkdir(parents=True)
62
+ with patch("subprocess.run") as mock_run:
63
+ mock_run.return_value = _make_completed_process(
64
+ str(claude_hooks_path) + "\n", returncode=0
65
+ )
66
+ exit_code = preflight.verify_git_hooks_path()
67
+ assert exit_code == 0
68
+
69
+
70
+ def test_should_exit_nonzero_when_core_hooks_path_points_elsewhere(
71
+ capsys: pytest.CaptureFixture[str],
72
+ ) -> None:
73
+ with patch("subprocess.run") as mock_run:
74
+ mock_run.return_value = _make_completed_process(
75
+ "/some/other/path/.husky\n", returncode=0
76
+ )
77
+ exit_code = preflight.verify_git_hooks_path()
78
+ assert exit_code != 0
79
+ captured = capsys.readouterr()
80
+ assert "core.hooksPath" in captured.err
81
+
82
+
83
+ def test_should_include_correction_commands_in_error_message(
84
+ capsys: pytest.CaptureFixture[str],
85
+ ) -> None:
86
+ with patch("subprocess.run") as mock_run:
87
+ mock_run.return_value = _make_completed_process("", returncode=1)
88
+ preflight.verify_git_hooks_path()
89
+ captured = capsys.readouterr()
90
+ assert (
91
+ "npx claude-dev-env" in captured.err
92
+ or "git config --global core.hooksPath" in captured.err
93
+ )
94
+
95
+
96
+ def test_main_should_exit_nonzero_when_hooks_path_unset() -> None:
97
+ with patch("subprocess.run") as mock_run:
98
+ mock_run.return_value = _make_completed_process("", returncode=1)
99
+ exit_code = preflight.main(["--no-pytest"])
100
+ assert exit_code != 0
101
+
102
+
103
+ def test_main_should_continue_when_hooks_path_valid(tmp_path: Path) -> None:
104
+ claude_hooks_path = tmp_path / ".claude" / "hooks" / "git-hooks"
105
+ claude_hooks_path.mkdir(parents=True)
106
+ with patch("subprocess.run") as mock_run:
107
+ mock_run.return_value = _make_completed_process(
108
+ str(claude_hooks_path) + "\n", returncode=0
109
+ )
110
+ exit_code = preflight.main(["--no-pytest"])
111
+ assert exit_code == 0
112
+
113
+
114
+ def test_should_accept_hooks_path_with_trailing_slash() -> None:
115
+ with patch("subprocess.run") as mock_run:
116
+ mock_run.return_value = _make_completed_process(
117
+ "/home/user/.claude/hooks/git-hooks/\n", returncode=0
118
+ )
119
+ exit_code = preflight.verify_git_hooks_path()
120
+ assert exit_code == 0, (
121
+ "hooksPath with trailing slash must pass verification after normalization"
122
+ )
123
+
124
+
125
+ def test_should_exit_zero_when_hooks_path_set_at_repo_scope(tmp_path: Path) -> None:
126
+ claude_hooks_path = tmp_path / ".claude" / "hooks" / "git-hooks"
127
+ claude_hooks_path.mkdir(parents=True)
128
+ repo_root = tmp_path / "my-repo"
129
+ repo_root.mkdir()
130
+ with patch("subprocess.run") as mock_run:
131
+ mock_run.return_value = _make_completed_process(
132
+ str(claude_hooks_path) + "\n", returncode=0
133
+ )
134
+ exit_code = preflight.verify_git_hooks_path(repo_root)
135
+ assert exit_code == 0, (
136
+ "verify_git_hooks_path must accept a valid path returned by effective "
137
+ "config query (not restricted to --global scope)"
138
+ )
139
+ called_command = mock_run.call_args[0][0]
140
+ assert "--global" not in called_command, (
141
+ "verify_git_hooks_path must query effective config, not --global only"
142
+ )
143
+ assert "-C" in called_command, (
144
+ "verify_git_hooks_path must use git -C <repo_root> for repo-effective config"
145
+ )
146
+ dash_c_index = called_command.index("-C")
147
+ assert called_command[dash_c_index + 1] == str(repo_root), (
148
+ "git -C must receive the resolved repository root path"
149
+ )
150
+
151
+
152
+ def test_should_accept_hooks_path_with_backslash_and_trailing_slash() -> None:
153
+ with patch("subprocess.run") as mock_run:
154
+ mock_run.return_value = _make_completed_process(
155
+ "C:\\Users\\user\\.claude\\hooks\\git-hooks\\\n", returncode=0
156
+ )
157
+ exit_code = preflight.verify_git_hooks_path()
158
+ assert exit_code == 0, (
159
+ "Windows hooksPath with trailing backslash must pass after normalization"
160
+ )
161
+
162
+
163
+ def test_should_exit_nonzero_when_git_executable_not_found(
164
+ capsys: pytest.CaptureFixture[str],
165
+ ) -> None:
166
+ """Preflight must not crash with a traceback when git is missing from PATH."""
167
+ with patch("subprocess.run", side_effect=FileNotFoundError()):
168
+ exit_code = preflight.verify_git_hooks_path()
169
+ assert exit_code != 0, (
170
+ "FileNotFoundError from subprocess.run must produce a non-zero exit, "
171
+ "not a propagated traceback"
172
+ )
173
+ captured = capsys.readouterr()
174
+ assert "git" in captured.err.lower(), (
175
+ "Error message must mention git so the user knows what is missing"
176
+ )
177
+ assert (
178
+ "npx claude-dev-env" in captured.err
179
+ or "git config --global core.hooksPath" in captured.err
180
+ ), "Error message must include the enforcement-absent remediation hints"
181
+
182
+
183
+ def test_should_exit_nonzero_when_subprocess_run_raises_os_error(
184
+ capsys: pytest.CaptureFixture[str],
185
+ ) -> None:
186
+ """Preflight must surface a clean error for other OS-level git launch failures."""
187
+ with patch("subprocess.run", side_effect=OSError("permission denied")):
188
+ exit_code = preflight.verify_git_hooks_path()
189
+ assert exit_code != 0, (
190
+ "OSError from subprocess.run must produce a non-zero exit, "
191
+ "not a propagated traceback"
192
+ )
193
+ captured = capsys.readouterr()
194
+ assert "preflight" in captured.err, (
195
+ "Error message must be prefixed with the preflight tool name for context"
196
+ )
197
+ assert "permission denied" in captured.err, (
198
+ "Error message must include the underlying OSError detail for diagnosis"
199
+ )
200
+
201
+
202
+ def test_preflight_uses_shared_hooks_path_suffix_constant() -> None:
203
+ """Preflight's expected suffix must come from config.fix_hookspath_constants
204
+ so the canonical hooks directory is defined in exactly one place."""
205
+ scripts_directory = str(Path(__file__).parent.parent.resolve())
206
+ if scripts_directory not in sys.path:
207
+ sys.path.insert(0, scripts_directory)
208
+ constants_module_path = (
209
+ Path(__file__).parent.parent / "config" / "fix_hookspath_constants.py"
210
+ )
211
+ constants_specification = importlib.util.spec_from_file_location(
212
+ "config.fix_hookspath_constants",
213
+ constants_module_path,
214
+ )
215
+ assert constants_specification is not None
216
+ assert constants_specification.loader is not None
217
+ constants_module = importlib.util.module_from_spec(constants_specification)
218
+ constants_specification.loader.exec_module(constants_module)
219
+ expected_suffix = constants_module.HOOKS_PATH_VERIFICATION_SUFFIX
220
+
221
+ with patch("subprocess.run") as mock_run:
222
+ mock_run.return_value = _make_completed_process(
223
+ f"/some/where/{expected_suffix}\n", returncode=0
224
+ )
225
+ exit_code = preflight.verify_git_hooks_path()
226
+ assert exit_code == 0
227
+
228
+
229
+ def test_preflight_skip_uses_shared_env_var_constant(
230
+ capsys: pytest.CaptureFixture[str],
231
+ monkeypatch: pytest.MonkeyPatch,
232
+ ) -> None:
233
+ """The preflight skip env-var name must come from config/preflight_constants.py."""
234
+ scripts_directory = str(Path(__file__).parent.parent.resolve())
235
+ if scripts_directory not in sys.path:
236
+ sys.path.insert(0, scripts_directory)
237
+ constants_module_path = (
238
+ Path(__file__).parent.parent / "config" / "preflight_constants.py"
239
+ )
240
+ constants_specification = importlib.util.spec_from_file_location(
241
+ "config.preflight_constants",
242
+ constants_module_path,
243
+ )
244
+ assert constants_specification is not None
245
+ assert constants_specification.loader is not None
246
+ constants_module = importlib.util.module_from_spec(constants_specification)
247
+ constants_specification.loader.exec_module(constants_module)
248
+ skip_env_var_name = constants_module.BUGTEAM_PREFLIGHT_SKIP_ENV_VAR_NAME
249
+
250
+ monkeypatch.setenv(skip_env_var_name, "1")
251
+ exit_code = preflight.main(["--no-pytest"])
252
+ assert exit_code == 0
253
+ captured = capsys.readouterr()
254
+ assert skip_env_var_name in captured.err
255
+
256
+
257
+ def test_loop_variables_use_each_prefix_in_preflight_module() -> None:
258
+ find_root_source = inspect.getsource(preflight.find_repository_root)
259
+ assert "for each_candidate in" in find_root_source
260
+
261
+ discover_tests_source = inspect.getsource(preflight.has_discoverable_tests)
262
+ assert "for each_path in" in discover_tests_source
263
+ assert "for each_part in" in discover_tests_source
264
+
265
+
266
+ def test_preflight_uses_extracted_directory_marker_constants() -> None:
267
+ """preflight.py must reference extracted constants instead of inline string literals.
268
+
269
+ The CODE_RULES magic-values rule treats inline ``.git`` and ``.venv``
270
+ string literals in production function bodies as violations. Confirm
271
+ preflight.py imports them (or a frozenset that contains ``.venv``) from
272
+ config.preflight_constants instead.
273
+ """
274
+ preflight_source = inspect.getsource(preflight)
275
+ assert "GIT_DIRECTORY_NAME" in preflight_source
276
+ assert "ALL_TESTS_DIRECTORY_IGNORE_PARTS" in preflight_source
277
+ find_root_source = inspect.getsource(preflight.find_repository_root)
278
+ assert "'.git'" not in find_root_source
279
+ assert '".git"' not in find_root_source
280
+ discover_tests_source = inspect.getsource(preflight.has_discoverable_tests)
281
+ assert "'.venv'" not in discover_tests_source
282
+ assert '".venv"' not in discover_tests_source
283
+
284
+
285
+ def test_preflight_does_not_import_unused_venv_directory_name_constant() -> None:
286
+ """The ``VENV_DIRECTORY_NAME`` constant is not consumed by preflight.py
287
+ (``.venv`` reaches the function body via ``ALL_TESTS_DIRECTORY_IGNORE_PARTS``).
288
+ Importing the standalone name is dead code per the unused-imports rule."""
289
+ preflight_source = inspect.getsource(preflight)
290
+ assert "VENV_DIRECTORY_NAME" not in preflight_source, (
291
+ "Dead import must be removed; preflight.py reaches `.venv` via "
292
+ "ALL_TESTS_DIRECTORY_IGNORE_PARTS instead"
293
+ )
294
+
295
+
296
+ def test_preflight_stderr_uses_bugteam_preflight_prefix(
297
+ capsys: pytest.CaptureFixture[str],
298
+ ) -> None:
299
+ """Preflight's stderr prefix must remain ``bugteam_preflight:`` so the bugteam
300
+ SKILL.md auto-remediation pattern (`bugteam_preflight: core.hooksPath is`)
301
+ keeps matching when Phase 2 wires bugteam to import this shared script."""
302
+ with patch("subprocess.run") as mock_run:
303
+ mock_run.return_value = _make_completed_process(
304
+ "/some/other/path/.husky\n", returncode=0
305
+ )
306
+ preflight.verify_git_hooks_path()
307
+ captured = capsys.readouterr()
308
+ assert "bugteam_preflight: core.hooksPath is" in captured.err, (
309
+ "Stderr prefix must preserve the bugteam SKILL.md auto-remediation contract"
310
+ )
311
+
312
+
313
+ def test_preflight_does_not_import_unused_repository_root_marker_constant() -> None:
314
+ """The ``ALL_REPOSITORY_ROOT_MARKER_FILENAMES`` constant is not consumed by
315
+ preflight.py. Importing it is dead code per the unused-imports rule."""
316
+ preflight_source = inspect.getsource(preflight)
317
+ assert "ALL_REPOSITORY_ROOT_MARKER_FILENAMES" not in preflight_source, (
318
+ "Dead import must be removed; preflight.py uses individual marker "
319
+ "filename constants directly"
320
+ )
321
+
322
+
323
+ def test_pytest_no_tests_collected_helper_returns_named_constant() -> None:
324
+ """The pytest "no tests collected" exit code must be sourced from the
325
+ named constant in config/preflight_constants.py rather than the bare
326
+ literal 5 inside the function body (CODE_RULES magic-values rule)."""
327
+ assert preflight._pytest_exit_code_no_tests_collected() == (
328
+ PYTEST_NO_TESTS_COLLECTED_EXIT_CODE
329
+ )
330
+ helper_source = inspect.getsource(preflight._pytest_exit_code_no_tests_collected)
331
+ assert "PYTEST_NO_TESTS_COLLECTED_EXIT_CODE" in helper_source, (
332
+ "Helper body must return the named constant, not the bare literal 5"
333
+ )
@@ -0,0 +1,82 @@
1
+ """Tests for preflight_constants.py extracted constant set."""
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _load_constants_module() -> ModuleType:
9
+ module_path = Path(__file__).parent.parent / "config" / "preflight_constants.py"
10
+ specification = importlib.util.spec_from_file_location(
11
+ "config.preflight_constants", module_path
12
+ )
13
+ assert specification is not None
14
+ assert specification.loader is not None
15
+ module = importlib.util.module_from_spec(specification)
16
+ specification.loader.exec_module(module)
17
+ return module
18
+
19
+
20
+ constants_module = _load_constants_module()
21
+
22
+
23
+ def test_bugteam_preflight_skip_env_var_name() -> None:
24
+ assert (
25
+ constants_module.BUGTEAM_PREFLIGHT_SKIP_ENV_VAR_NAME == "BUGTEAM_PREFLIGHT_SKIP"
26
+ )
27
+
28
+
29
+ def test_bugteam_preflight_skip_enabled_value() -> None:
30
+ assert constants_module.BUGTEAM_PREFLIGHT_SKIP_ENABLED_VALUE == "1"
31
+
32
+
33
+ def test_git_directory_name() -> None:
34
+ assert constants_module.GIT_DIRECTORY_NAME == ".git"
35
+
36
+
37
+ def test_claude_directory_name() -> None:
38
+ assert constants_module.CLAUDE_DIRECTORY_NAME == ".claude"
39
+
40
+
41
+ def test_venv_directory_name() -> None:
42
+ assert constants_module.VENV_DIRECTORY_NAME == ".venv"
43
+
44
+
45
+ def test_pytest_ini_filename() -> None:
46
+ assert constants_module.PYTEST_INI_FILENAME == "pytest.ini"
47
+
48
+
49
+ def test_pyproject_toml_filename() -> None:
50
+ assert constants_module.PYPROJECT_TOML_FILENAME == "pyproject.toml"
51
+
52
+
53
+ def test_pytest_toml_table_prefix() -> None:
54
+ assert constants_module.PYTEST_TOML_TABLE_PREFIX == "[tool.pytest"
55
+
56
+
57
+ def test_pre_commit_config_yaml_filename() -> None:
58
+ assert constants_module.PRE_COMMIT_CONFIG_YAML_FILENAME == ".pre-commit-config.yaml"
59
+
60
+
61
+ def test_all_test_file_patterns_for_discovery() -> None:
62
+ assert constants_module.ALL_TEST_FILE_PATTERNS_FOR_DISCOVERY == (
63
+ "test_*.py",
64
+ "*_test.py",
65
+ )
66
+
67
+
68
+ def test_all_tests_directory_ignore_parts_includes_venv_marker() -> None:
69
+ assert constants_module.VENV_DIRECTORY_NAME in (
70
+ constants_module.ALL_TESTS_DIRECTORY_IGNORE_PARTS
71
+ )
72
+
73
+
74
+ def test_all_repository_root_marker_filenames() -> None:
75
+ assert constants_module.ALL_REPOSITORY_ROOT_MARKER_FILENAMES == (
76
+ constants_module.GIT_DIRECTORY_NAME,
77
+ constants_module.PYTEST_INI_FILENAME,
78
+ )
79
+
80
+
81
+ def test_pytest_no_tests_collected_exit_code() -> None:
82
+ assert constants_module.PYTEST_NO_TESTS_COLLECTED_EXIT_CODE == 5
@@ -0,0 +1,49 @@
1
+ """Smoke tests for revoke_project_claude_permissions wiring.
2
+
3
+ Confirms the module imports cleanly with the constants now sourced from
4
+ config/claude_permissions_constants.py and config/claude_settings_keys_constants.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.util
10
+ import sys
11
+ from pathlib import Path
12
+ from types import ModuleType
13
+
14
+
15
+ def _load_revoke_module() -> ModuleType:
16
+ scripts_directory = Path(__file__).parent.parent
17
+ parent_directory = str(scripts_directory.resolve())
18
+ if parent_directory not in sys.path:
19
+ sys.path.insert(0, parent_directory)
20
+ sys.modules.pop("config", None)
21
+ module_path = scripts_directory / "revoke_project_claude_permissions.py"
22
+ specification = importlib.util.spec_from_file_location(
23
+ "revoke_project_claude_permissions", module_path
24
+ )
25
+ assert specification is not None
26
+ assert specification.loader is not None
27
+ module = importlib.util.module_from_spec(specification)
28
+ specification.loader.exec_module(module)
29
+ return module
30
+
31
+
32
+ def test_module_imports_constants_from_config_modules() -> None:
33
+ revoke_module = _load_revoke_module()
34
+ assert revoke_module.ALL_PERMISSION_ALLOW_TOOLS == ("Edit", "Write", "Read")
35
+ assert "{project_path}" in revoke_module.AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE
36
+ assert revoke_module.CLAUDE_SETTINGS_PERMISSIONS_KEY == "permissions"
37
+
38
+
39
+ def test_revoke_module_guards_sys_path_insert_against_duplicates() -> None:
40
+ """revoke_project_claude_permissions.py must guard its sys.path.insert with a
41
+ membership check so re-imports under test harnesses do not push duplicate
42
+ entries (consistent with sibling modules in the same directory)."""
43
+ module_source = (
44
+ Path(__file__).parent.parent / "revoke_project_claude_permissions.py"
45
+ ).read_text(encoding="utf-8")
46
+ assert "if str(Path(__file__).resolve().parent) not in sys.path:" in module_source, (
47
+ "revoke_project_claude_permissions.py must guard sys.path.insert against "
48
+ "duplicate entries on reload (consistent with sibling modules)"
49
+ )
@@ -0,0 +1,81 @@
1
+ # State schema
2
+
3
+ State each PR-loop workflow tracks across iterations. Workflows differ on persistence (in-memory vs files) and which fields they use; shapes overlap.
4
+
5
+ ## Common fields
6
+
7
+ | Field | Type | Purpose |
8
+ |---|---|---|
9
+ | `loop_count` | int | Iterations completed; bumps on each AUDIT or tick |
10
+ | `last_action` | enum | `fresh` \| `audited` \| `fixed` — drives next-step dispatch |
11
+ | `last_findings` | object | `{p0, p1, p2, total}` count of findings from most recent AUDIT |
12
+ | `audit_log` | list[str] | Per-iteration one-line summaries for the final report |
13
+ | `starting_sha` | str | `git rev-parse HEAD` at workflow start |
14
+ | `loop_comment_index` | dict | `{finding_id: {finding_comment_id, finding_comment_url, used_fallback, fix_status, ...}}` |
15
+
16
+ ## Workflow-specific extensions
17
+
18
+ ### bugteam
19
+
20
+ Adds:
21
+ - `team_name` — `bugteam-pr-<N>-<YYYYMMDDHHMMSS>` or `bugteam-<YYYYMMDDHHMMSS>` for multi-PR
22
+ - `team_temp_dir` — absolute path resolved from `tempfile.gettempdir()`
23
+ - `pre_fix_sha` — `git rev-parse HEAD` immediately before each FIX
24
+ - `gate_round_count` — consecutive pre-audit gate failures (cap: 5 → exit `error`)
25
+
26
+ State lives inline in the lead session (orchestrator). Cleared on TeamDelete.
27
+
28
+ ### qbug
29
+
30
+ Adds nothing beyond common. Single subagent loops internally and returns a final summary; orchestrator discards intermediate state. Subagent's loop counter and findings return in the exit payload (`{exit_reason, loop_count, final_commit_sha, audit_log, unresolved}`).
31
+
32
+ ### pr-converge
33
+
34
+ Adds the same **traffic** fields whether they live in **`state.json`** or in the **conversation state line**; only the **store** differs.
35
+
36
+ | Field | Type | Purpose |
37
+ |---|---|---|
38
+ | `phase` | enum | `BUGBOT` \| `BUGTEAM` — which reviewer the current tick drives |
39
+ | `current_head` | str | PR `headRefOid` / `git rev-parse` for the PR under work (each tick; from `view_pr_context.py` when no file store) |
40
+ | `bugbot_clean_at` | str \| null | HEAD SHA at which Cursor Bugbot last reported clean, or `null` (reset on every push) |
41
+ | `copilot_clean_at` | str \| null | HEAD SHA at which the GitHub Copilot reviewer (`copilot-pull-request-reviewer[bot]`) last reported clean (review `state == "APPROVED"`), or `null`. Reset on every push. Convergence gates require this equals `current_head` after bugbot+bugteam are clean (see `skills/pr-converge/SKILL.md` § Convergence gates). |
42
+ | `merge_state_status` | str \| null | Last-observed `mergeStateStatus` from `gh pr view --json mergeable,mergeStateStatus,headRefOid` (e.g., `CLEAN`, `DIRTY`, `BLOCKED`, `BEHIND`, `UNKNOWN`), or `null` before the first check. Reset on every push. `DIRTY` triggers the rebase invocation; non-`CLEAN` non-`DIRTY` is a hard blocker per pr-converge `Stop conditions`. |
43
+ | `inline_lag_streak` | int | Consecutive ticks where bugbot's review body claims findings but inline-comments API returns zero rows for `current_head` |
44
+ | `tick_count` | int | Observability only — **no ceiling**; loop ends on convergence or **Stop conditions** in `pr-converge` |
45
+
46
+ **Dual persistence** (normative: `skills/pr-converge/SKILL.md` § State across ticks, § Multi-PR orchestration model):
47
+
48
+ | Mode | When it applies | Source of truth | `tick_count` bump |
49
+ |---|---|---|---|
50
+ | **`state.json`** | File exists at `<TMPDIR>/pr-converge-<session_id>/state.json` (multi-PR orchestration or other file-backed session) | JSON: top-level `session_id`; per-PR objects under `prs[<number>]` with `owner`, `repo`, `branch`, `phase`, `current_head`, `bugbot_clean_at`, `inline_lag_streak`, `tick_count`, `last_action`, `status`, `last_updated`. Optional sibling `converged.log` (append-only; multi-PR only). Writes use lock + atomic replace per skill **Concurrency** | **Orchestrator only** at tick start (locked merge for every non-terminal PR); **never** bump `tick_count` in Step 1 when this file is in use |
51
+ | **Conversation state line** | **No** `state.json` (typical single-PR `/pr-converge` in Cursor) | Persist **`phase`**, **`bugbot_clean_at`**, **`inline_lag_streak`**, **`tick_count`** as **plain text** in each assistant turn; next tick reads them from the **most recent assistant message**. **`current_head` is not serialized in that line** — re-resolve each tick via `view_pr_context.py` (same contract as `skills/pr-converge/SKILL.md` § State across ticks). | **Step 1** increments `tick_count` in that line **only** when no `state.json` — must not double-count with any file-backed path |
52
+
53
+ **`status` (file-backed `prs[...]` only):** `fresh` \| `in_progress` \| `awaiting_bugbot` \| `awaiting_bugteam` \| `converged` \| `blocked`
54
+
55
+ ### monitor-many
56
+
57
+ Adds per-PR JSON state file at `~/.claude/skills/monitor-many/state/<owner>-<repo>-<pr_number>.json`:
58
+
59
+ | Field | Type | Description |
60
+ |---|---|---|
61
+ | `repo_name` | str | Full `owner/repo` |
62
+ | `pr_number` | int | PR number |
63
+ | `status` | enum | `open` \| `blocked_escalation` \| `fixing` \| `ready_candidate` \| `closed` |
64
+ | `copilot_review` | enum | `none` \| `requested` \| `pending` \| `commented` \| `approved` |
65
+ | `bugbot_review` | enum | Same vocabulary as `copilot_review` |
66
+ | `last_seen_comment_id` | int \| null | Highest processed review-comment id (incremental polling watermark) |
67
+ | `review_comments` | list[object] | Optional cache; `{id, author, path, line}` per entry |
68
+ | `escalation_queue` | list[object] | Pending human-judgment items: `{comment_id, summary, created_at}` |
69
+
70
+ ## Reset semantics
71
+
72
+ - bugteam: cleared on each new `/bugteam` invocation
73
+ - qbug: cleared on each new `/qbug` invocation
74
+ - pr-converge: `bugbot_clean_at`, `copilot_clean_at`, and `merge_state_status` all reset to `null` on every push (a new commit invalidates every reviewer's prior clean and the prior mergeability snapshot by definition); `phase` cycles each tick. With `state.json`, orchestrator reads that file at tick start; without it, rely on the prior conversation state line — **never** mix both increment rules for `tick_count` on the same run
75
+ - monitor-many: persists across orchestrator runs; only `last_seen_comment_id` advances monotonically
76
+
77
+ ## Convergence checks
78
+
79
+ - bugteam, qbug: `last_action == "audited"` AND `last_findings.total == 0` → `converged`
80
+ - pr-converge: `bugbot_clean_at == current_head` AND most-recent bugteam exit is `converged` AND no push during the bugteam tick AND no outstanding Copilot findings on `current_head` AND `merge_state_status == "CLEAN"` (per `skills/pr-converge/SKILL.md` § Convergence gates) → back-to-back clean → `gh pr ready` (read `current_head` / `bugbot_clean_at` / `copilot_clean_at` / `merge_state_status` from `state.json` when file-backed, else from the conversation state line and Step 1 `view_pr_context.py` output)
81
+ - monitor-many: no unresolved comments requiring code changes AND required checks green AND review policy satisfied → `gh pr ready`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.36.1",
3
+ "version": "1.36.2",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,6 +19,7 @@
19
19
  "hooks/",
20
20
  "system-prompts/",
21
21
  "scripts/",
22
+ "_shared/",
22
23
  "CLAUDE.md"
23
24
  ],
24
25
  "keywords": [