claude-dev-env 1.40.0 → 1.41.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 (39) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +53 -3
  3. package/_shared/pr-loop/scripts/post_audit_thread.py +2 -2
  4. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +68 -3
  5. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
  6. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  7. package/hooks/_gh_pr_author_swap_utils.py +1211 -0
  8. package/hooks/blocking/gh_body_arg_blocker.py +9 -6
  9. package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
  10. package/hooks/blocking/gh_pr_author_restore.py +100 -0
  11. package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
  12. package/hooks/blocking/pr_description_enforcer.py +1 -3
  13. package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
  14. package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
  15. package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
  16. package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
  17. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
  18. package/hooks/config/gh_pr_author_swap_constants.py +76 -0
  19. package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
  20. package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
  21. package/hooks/config/pr_description_enforcer_constants.py +5 -0
  22. package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
  23. package/hooks/hooks.json +40 -0
  24. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
  25. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
  26. package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
  27. package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
  28. package/hooks/test__gh_pr_author_swap_utils.py +333 -0
  29. package/package.json +1 -1
  30. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  31. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  32. package/skills/bugteam/reference/audit-contract.md +22 -0
  33. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  34. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
  35. package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
  36. package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
  37. package/skills/pr-converge/SKILL.md +8 -2
  38. package/skills/pr-converge/config/constants.py +2 -1
  39. package/skills/pr-converge/reference/state-schema.md +36 -8
@@ -0,0 +1,333 @@
1
+ """Canonical-location tests for the gh-pr-author swap utils module.
2
+
3
+ The TDD enforcer matches a production filename ``X.py`` to ``test_X.py``;
4
+ ``_gh_pr_author_swap_utils.py`` carries a leading underscore that the
5
+ enforcer treats as part of the name. This file's tests are the canonical
6
+ match. The broader behavioural suite lives alongside in
7
+ ``blocking/test_gh_pr_author_swap_utils.py``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.util
13
+ import os
14
+ import pathlib
15
+ import sys
16
+ import tempfile
17
+
18
+ import pytest
19
+
20
+ from config.gh_pr_author_swap_constants import STATE_FILE_PERMISSION_MODE
21
+
22
+ _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent
23
+ if str(_HOOKS_ROOT) not in sys.path:
24
+ sys.path.insert(0, str(_HOOKS_ROOT))
25
+
26
+ utils_module_spec = importlib.util.spec_from_file_location(
27
+ "_gh_pr_author_swap_utils",
28
+ _HOOKS_ROOT / "_gh_pr_author_swap_utils.py",
29
+ )
30
+ assert utils_module_spec is not None
31
+ assert utils_module_spec.loader is not None
32
+ utils_module = importlib.util.module_from_spec(utils_module_spec)
33
+ utils_module_spec.loader.exec_module(utils_module)
34
+
35
+
36
+ def test_state_file_path_rejects_path_traversal_session_id() -> None:
37
+ """A session_id containing path-traversal characters must not escape tempdir.
38
+
39
+ Regression guard: an unsanitised ``session_id`` containing ``../`` or
40
+ ``/`` would interpolate into the filename and let the resulting path
41
+ land outside ``tempfile.gettempdir()``. The sanitiser strips every
42
+ character outside ``[A-Za-z0-9_-]`` and falls back to the default
43
+ session id when the result is empty.
44
+ """
45
+ sanitised_path = utils_module._state_file_path("../../tmp/evil")
46
+ temporary_directory_path = pathlib.Path(tempfile.gettempdir()).resolve()
47
+ assert sanitised_path.parent.resolve() == temporary_directory_path
48
+
49
+
50
+ def test_state_file_path_rejects_backslash_in_session_id() -> None:
51
+ """Backslashes are also unsafe path separators on Windows."""
52
+ sanitised_path = utils_module._state_file_path("evil\\..\\..\\system32")
53
+ temporary_directory_path = pathlib.Path(tempfile.gettempdir()).resolve()
54
+ assert sanitised_path.parent.resolve() == temporary_directory_path
55
+
56
+
57
+ def test_state_file_path_rejects_nul_byte_in_session_id() -> None:
58
+ """A NUL byte inside the session id must not reach the filename."""
59
+ sanitised_path = utils_module._state_file_path("abc\x00../def")
60
+ temporary_directory_path = pathlib.Path(tempfile.gettempdir()).resolve()
61
+ assert sanitised_path.parent.resolve() == temporary_directory_path
62
+ assert "\x00" not in sanitised_path.name
63
+
64
+
65
+ def test_state_file_path_preserves_safe_session_id() -> None:
66
+ """A well-formed session id passes through unchanged."""
67
+ safe_session_id = "session-001_abc"
68
+ produced_path = utils_module._state_file_path(safe_session_id)
69
+ assert safe_session_id in produced_path.name
70
+
71
+
72
+ def test_backtick_substitution_blanks_inner_quoted_literals() -> None:
73
+ """A ``gh pr create`` literal inside a single-quoted argument of a backtick body must not trigger.
74
+
75
+ Mirrors ``$(printf '...')`` behaviour: when the backtick body's
76
+ quoted literal contains the token, the matcher must blank the
77
+ quoted region before searching.
78
+ """
79
+ stripped_command = utils_module._strip_quoted_regions("foo `printf ';gh pr create'`")
80
+ assert not utils_module._command_invokes_gh_pr_create_in_stripped(stripped_command)
81
+
82
+
83
+ def test_backtick_substitution_matches_unquoted_gh_pr_create_inside_body() -> None:
84
+ """A bare ``gh pr create`` inside a backtick body still matches.
85
+
86
+ Symmetric to ``$(gh pr create)`` — the substitution body is real
87
+ code, so the matcher must see it.
88
+ """
89
+ stripped_command = utils_module._strip_quoted_regions("echo `gh pr create --title T`")
90
+ assert utils_module._command_invokes_gh_pr_create_in_stripped(stripped_command)
91
+
92
+
93
+ def test_state_file_is_attacker_planted_returns_true_for_world_readable_mode(
94
+ tmp_path: pathlib.Path,
95
+ ) -> None:
96
+ """A state file with mode 0o644 is flagged as attacker-planted on POSIX.
97
+
98
+ The enforcer always atomically creates state files at 0o600. A file
99
+ at the predictable swap-state path with any other mode bits cannot
100
+ have come from the enforcer running as this user.
101
+ """
102
+ if not hasattr(os, "getuid"):
103
+ return
104
+ state_file = tmp_path / "gh_pr_author_swap_session-attacker.json"
105
+ state_file.write_text("{}", encoding="utf-8")
106
+ os.chmod(state_file, 0o644)
107
+
108
+ assert utils_module._state_file_is_attacker_planted(state_file) is True
109
+
110
+
111
+ def test_state_file_is_attacker_planted_returns_false_for_well_formed_file(
112
+ tmp_path: pathlib.Path,
113
+ ) -> None:
114
+ """A state file written exactly the way the enforcer writes is not flagged."""
115
+ state_file = tmp_path / "gh_pr_author_swap_session-good.json"
116
+ state_file.write_text("{}", encoding="utf-8")
117
+ if hasattr(os, "getuid"):
118
+ os.chmod(state_file, STATE_FILE_PERMISSION_MODE)
119
+
120
+ assert utils_module._state_file_is_attacker_planted(state_file) is False
121
+
122
+
123
+ def test_state_file_is_attacker_planted_returns_false_for_missing_file(
124
+ tmp_path: pathlib.Path,
125
+ ) -> None:
126
+ """A missing state file is treated as not-planted so callers can no-op cleanly."""
127
+ missing_state_file = tmp_path / "gh_pr_author_swap_session-missing.json"
128
+
129
+ assert utils_module._state_file_is_attacker_planted(missing_state_file) is False
130
+
131
+
132
+ def test_state_file_is_attacker_planted_returns_true_for_non_regular_file(
133
+ tmp_path: pathlib.Path,
134
+ ) -> None:
135
+ """A FIFO at the predictable swap-state path is flagged as attacker-planted.
136
+
137
+ The enforcer only writes regular files; any non-regular file type
138
+ (symlink, FIFO, device) at the predictable path indicates another
139
+ party pre-created it to redirect the restore or cleanup hook.
140
+ """
141
+ if not hasattr(os, "mkfifo"):
142
+ pytest.skip("mkfifo not available on this platform")
143
+ if not hasattr(os, "getuid"):
144
+ pytest.skip("POSIX ownership semantics not available on this platform")
145
+ fifo_state_file = tmp_path / "gh_pr_author_swap_session-fifo.json"
146
+ os.mkfifo(fifo_state_file, STATE_FILE_PERMISSION_MODE)
147
+
148
+ assert utils_module._state_file_is_attacker_planted(fifo_state_file) is True
149
+
150
+
151
+ def test_state_file_is_attacker_planted_returns_true_when_lstat_raises_os_error(
152
+ tmp_path: pathlib.Path,
153
+ monkeypatch: pytest.MonkeyPatch,
154
+ ) -> None:
155
+ """An ``OSError`` from ``lstat`` fails closed — the path is treated as planted.
156
+
157
+ A path that exists but is unreadable (permission denied, broken
158
+ filesystem mount, etc.) cannot be proven safe, so the helper
159
+ returns True to keep the restore or cleanup hook from trusting it.
160
+ """
161
+ if not hasattr(os, "getuid"):
162
+ pytest.skip("POSIX ownership semantics not available on this platform")
163
+ unreadable_state_file = tmp_path / "gh_pr_author_swap_session-unreadable.json"
164
+ unreadable_state_file.write_text("{}", encoding="utf-8")
165
+
166
+ def raise_permission_error(self: pathlib.Path) -> os.stat_result:
167
+ raise PermissionError("simulated lstat failure")
168
+
169
+ monkeypatch.setattr(pathlib.Path, "lstat", raise_permission_error)
170
+
171
+ assert utils_module._state_file_is_attacker_planted(unreadable_state_file) is True
172
+
173
+
174
+ def test_strip_bash_comments_preserves_substitution_body_when_hash_is_inside_dollar_paren() -> None:
175
+ """Regression: ``$(date +%H # 24h) && gh pr create`` must not let the regex eat past the substitution closer.
176
+
177
+ Before this fix ``_strip_bash_comments`` ran a flat regex sweep that
178
+ blanked from ``#`` to end-of-line regardless of substitution depth.
179
+ A ``#`` preceded by whitespace inside a ``$(...)`` body consumed
180
+ the closing ``)`` AND every byte after it on the same line,
181
+ erasing a real ``gh pr create`` invocation from the enforcer's
182
+ view and silently bypassing the swap.
183
+ """
184
+ raw_command = "$(date +%H # 24h) && gh pr create --title T"
185
+ preprocessed_command = utils_module._preprocess_command_for_matching(raw_command)
186
+ assert "gh pr create" in preprocessed_command
187
+ assert utils_module._command_invokes_gh_pr_create_in_stripped(preprocessed_command)
188
+
189
+
190
+ def test_strip_bash_comments_preserves_substitution_body_when_hash_is_inside_backtick() -> None:
191
+ """Regression: a ``#`` inside a backtick body must not erase a trailing ``gh pr create``.
192
+
193
+ Backtick substitution is symmetric with ``$(...)``; the
194
+ substitution-aware comment walker must treat both shapes the same.
195
+ """
196
+ raw_command = "foo `cmd # comment` bar && gh pr create"
197
+ preprocessed_command = utils_module._preprocess_command_for_matching(raw_command)
198
+ assert "gh pr create" in preprocessed_command
199
+ assert utils_module._command_invokes_gh_pr_create_in_stripped(preprocessed_command)
200
+
201
+
202
+ def test_strip_bash_comments_strips_top_level_trailing_comment() -> None:
203
+ """Existing behaviour: a top-level trailing ``#`` comment after ``gh pr create`` is blanked.
204
+
205
+ The comment-stripping pass must still erase a real top-level
206
+ comment so the enforcer treats only the command portion as code.
207
+ """
208
+ raw_command = "gh pr create # this is a comment"
209
+ preprocessed_command = utils_module._preprocess_command_for_matching(raw_command)
210
+ assert "this is a comment" not in preprocessed_command
211
+
212
+
213
+ def test_strip_bash_comments_strips_prior_line_comment_only() -> None:
214
+ """Existing behaviour: a comment on line 1 is blanked but a ``gh pr create`` on line 2 still matches.
215
+
216
+ The newline is preserved so the matcher can still tell the two
217
+ lines apart, and the second-line command stays intact.
218
+ """
219
+ raw_command = "echo a # b\ngh pr create"
220
+ preprocessed_command = utils_module._preprocess_command_for_matching(raw_command)
221
+ assert "gh pr create" in preprocessed_command
222
+ assert "# b" not in preprocessed_command
223
+ assert utils_module._command_invokes_gh_pr_create_in_stripped(preprocessed_command)
224
+
225
+
226
+ def test_lstat_indicates_attacker_planted_returns_false_for_well_formed_lstat(
227
+ tmp_path: pathlib.Path,
228
+ ) -> None:
229
+ """A 0o600 regular file owned by the current user is not flagged.
230
+
231
+ Mirrors ``test_state_file_is_attacker_planted_returns_false_for_well_formed_file``
232
+ but feeds the helper a pre-computed ``lstat`` result so the helper
233
+ does not perform its own syscall.
234
+ """
235
+ state_file = tmp_path / "gh_pr_author_swap_session-well_formed.json"
236
+ state_file.write_text("{}", encoding="utf-8")
237
+ if hasattr(os, "getuid"):
238
+ os.chmod(state_file, STATE_FILE_PERMISSION_MODE)
239
+
240
+ file_lstat_result = state_file.lstat()
241
+
242
+ assert utils_module._lstat_indicates_attacker_planted(file_lstat_result) is False
243
+
244
+
245
+ def test_lstat_indicates_attacker_planted_returns_true_for_world_readable_mode(
246
+ tmp_path: pathlib.Path,
247
+ ) -> None:
248
+ """A 0o644 regular file is flagged as attacker-planted on POSIX.
249
+
250
+ The enforcer always creates state files at 0o600, so a divergent
251
+ mode is treated as a plant.
252
+ """
253
+ if not hasattr(os, "getuid"):
254
+ pytest.skip("POSIX ownership semantics not available on this platform")
255
+ state_file = tmp_path / "gh_pr_author_swap_session-mode_wrong.json"
256
+ state_file.write_text("{}", encoding="utf-8")
257
+ os.chmod(state_file, 0o644)
258
+
259
+ file_lstat_result = state_file.lstat()
260
+
261
+ assert utils_module._lstat_indicates_attacker_planted(file_lstat_result) is True
262
+
263
+
264
+ def test_lstat_indicates_attacker_planted_returns_true_for_foreign_uid(
265
+ tmp_path: pathlib.Path,
266
+ ) -> None:
267
+ """A regular 0o600 file owned by a different uid is flagged on POSIX.
268
+
269
+ The helper sees only the ``stat_result`` it is given, so the test
270
+ builds a synthetic ``os.stat_result`` whose ``st_uid`` does not match
271
+ ``os.getuid()`` and feeds it directly to the helper.
272
+ """
273
+ if not hasattr(os, "getuid"):
274
+ pytest.skip("POSIX ownership semantics not available on this platform")
275
+ state_file = tmp_path / "gh_pr_author_swap_session-foreign_uid.json"
276
+ state_file.write_text("{}", encoding="utf-8")
277
+ os.chmod(state_file, STATE_FILE_PERMISSION_MODE)
278
+ real_lstat_result = state_file.lstat()
279
+ foreign_user_id = os.getuid() + 1
280
+ synthetic_stat_fields = (
281
+ real_lstat_result.st_mode,
282
+ real_lstat_result.st_ino,
283
+ real_lstat_result.st_dev,
284
+ real_lstat_result.st_nlink,
285
+ foreign_user_id,
286
+ real_lstat_result.st_gid,
287
+ real_lstat_result.st_size,
288
+ real_lstat_result.st_atime,
289
+ real_lstat_result.st_mtime,
290
+ real_lstat_result.st_ctime,
291
+ )
292
+ synthetic_stat_result = os.stat_result(synthetic_stat_fields)
293
+
294
+ assert utils_module._lstat_indicates_attacker_planted(synthetic_stat_result) is True
295
+
296
+
297
+ def test_lstat_indicates_attacker_planted_returns_true_for_non_regular_file(
298
+ tmp_path: pathlib.Path,
299
+ ) -> None:
300
+ """A FIFO at the predictable swap-state path is flagged.
301
+
302
+ Mirrors ``test_state_file_is_attacker_planted_returns_true_for_non_regular_file``
303
+ but feeds the helper the FIFO's own ``lstat`` result rather than the
304
+ path.
305
+ """
306
+ if not hasattr(os, "mkfifo"):
307
+ pytest.skip("mkfifo not available on this platform")
308
+ if not hasattr(os, "getuid"):
309
+ pytest.skip("POSIX ownership semantics not available on this platform")
310
+ fifo_state_file = tmp_path / "gh_pr_author_swap_session-fifo.json"
311
+ os.mkfifo(fifo_state_file, STATE_FILE_PERMISSION_MODE)
312
+
313
+ file_lstat_result = fifo_state_file.lstat()
314
+
315
+ assert utils_module._lstat_indicates_attacker_planted(file_lstat_result) is True
316
+
317
+
318
+ def test_lstat_indicates_attacker_planted_returns_false_on_windows(
319
+ tmp_path: pathlib.Path,
320
+ ) -> None:
321
+ """On Windows (no ``os.getuid``) the helper short-circuits to False.
322
+
323
+ Windows tempdir is already per-user, so the cross-user attack
324
+ surface this check guards against on POSIX does not exist there.
325
+ """
326
+ if hasattr(os, "getuid"):
327
+ pytest.skip("POSIX has os.getuid; this case asserts Windows-only behaviour")
328
+ state_file = tmp_path / "gh_pr_author_swap_session-windows.json"
329
+ state_file.write_text("{}", encoding="utf-8")
330
+
331
+ file_lstat_result = state_file.lstat()
332
+
333
+ assert utils_module._lstat_indicates_attacker_planted(file_lstat_result) is False
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.40.0",
3
+ "version": "1.41.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -70,8 +70,8 @@ def _populate_findings(parent: Element, findings_data: list[dict[str, object]])
70
70
 
71
71
  Scalar finding fields become XML attributes on `<finding>`; the
72
72
  body fields named in `ALL_FINDING_BODY_ELEMENT_KEYS` (defined in
73
- `config/path_resolver_constants.py` and currently
74
- `("title", "excerpt", "description")`) become child elements.
73
+ `packages/claude-dev-env/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py`
74
+ and currently `("title", "excerpt", "description")`) become child elements.
75
75
  Nested dicts or lists in scalar slots are flattened to string form
76
76
  so attribute serialization stays well-defined.
77
77
 
@@ -1,8 +1,8 @@
1
1
  """Validate status enum values and write <bugteam_fix> XML at the canonical path.
2
2
 
3
3
  Status enum (canonical source: `ALL_VALID_FIX_STATUSES` in
4
- `config/path_resolver_constants.py`): fixed | could_not_address |
5
- hook_blocked | unverified_fixed.
4
+ `packages/claude-dev-env/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py`):
5
+ fixed | could_not_address | hook_blocked | unverified_fixed.
6
6
 
7
7
  Each outcome's scalar fields become XML attributes on `<outcome>`; the
8
8
  body fields named in `ALL_FIX_OUTCOME_BODY_ELEMENT_KEYS` (currently
@@ -91,6 +91,17 @@ The audit must either produce new Shape A findings citing new file:line referenc
91
91
 
92
92
  For `/bugteam`, the single audit agent provides per-category coverage by walking all A–K rubrics in one invocation.
93
93
 
94
+ ## Merge rules
95
+
96
+ When the LEAD combines findings from multiple sources (primary auditor, Haiku secondary auditor, adversarial pass), it applies these rules:
97
+
98
+ - **De-dup key**: `(file, line, category)`. Two findings sharing the same `(file, line, category)` tuple are the same finding and collapse into one entry.
99
+ - **Severity conflict**: max wins (`P0 > P1 > P2`). When sources disagree on severity for the same de-dup key, the merged entry keeps the highest severity.
100
+ - **Unique-to-secondary findings**: added to the merged set with the secondary's severity and source annotation.
101
+ - **Unique-to-primary findings**: kept as-is.
102
+ - **Zero secondary findings**: primary set trusted; proceed.
103
+ - **Malformed or non-parseable secondary output**: lead trusts the primary set and logs the event in `loop-<L>-diagnostics.json` under `haiku_findings` as `[{"parse_error": "<message>"}]`.
104
+
94
105
  ## Post-fix self-audit
95
106
 
96
107
  Audit-and-fix skills (`/qbug`, `/bugteam`) MUST re-audit modified files between `py_compile` and `git add`. This catches fix-induced regressions in the same loop that introduced them rather than on loop N+1.
@@ -109,6 +120,17 @@ Sequence:
109
120
 
110
121
  `converged` exit condition: `primary_audit_clean AND post_fix_audit_clean` for the committing loop.
111
122
 
123
+ ## De-dup and merge
124
+
125
+ Findings from primary, adversarial, Haiku secondary, and post-fix passes are merged into a single deduped finding list before persistence.
126
+
127
+ - **De-dup key:** `(file, line, category)`. Two findings sharing the same `(file, line, category)` tuple collapse into a single deduped entry.
128
+ - **Severity conflict resolution:** `max wins`. When merged findings disagree on severity, the deduped entry carries the maximum severity (`P0 > P1 > P2`).
129
+ - **Excerpt and failure_mode:** the deduped entry inherits these fields from the highest-severity contributing finding. Ties keep the first observed contributor.
130
+ - **`evidence_files`:** the deduped entry carries the union of every contributor's `evidence_files`, deduplicated and sorted.
131
+
132
+ The merged list lands in `loop-<L>-diagnostics.json` under both `merged` (one entry per contributing finding) and `deduped` (one entry per unique `(file, line, category)` tuple).
133
+
112
134
  ## Persistence
113
135
 
114
136
  Every audit loop writes two JSON files under the skill's scoped temp directory (resolved via `tempfile.gettempdir()`):
@@ -22,7 +22,7 @@ python "${CLAUDE_SKILL_DIR}/../../_shared/pr-loop/scripts/post_audit_thread.py"
22
22
 
23
23
  Capture `<head_sha>` via `git rev-parse HEAD` in the subagent cwd immediately before this call so the review attaches to the commit the audit actually scoped.
24
24
 
25
- `--findings-json` points to a JSON file whose root is a list of objects shaped `{path, line, side, severity, description, fix_summary}`. Build it from the merged Shape A findings: finding `file` → `path`. Each finding's `failure_mode` carries the full audit-to-fix handoff text per [`agents/code-quality-agent.md`](../../../agents/code-quality-agent.md); split `failure_mode` at the literal `Fix:` heading so the failure narrative becomes `description` and the suffix beginning at `Fix:` (including the trailing `Validation:` clause) becomes `fix_summary`. When a finding's `failure_mode` omits the `Fix:` heading, write the full text to BOTH `description` and `fix_summary` so the script's body template (`INLINE_COMMENT_BODY_TEMPLATE` in [`scripts/config/post_audit_thread_constants.py`](../../../_shared/pr-loop/scripts/config/post_audit_thread_constants.py)) renders coherently. Set `side="RIGHT"` for every entry. On CLEAN the list is empty (`[]`); on DIRTY the list carries one entry per finding.
25
+ `--findings-json` points to a JSON file whose root is a list of objects shaped `{path, line, side, severity, description, fix_summary}`. Build it from the merged Shape A findings: finding `file` → `path`. Each finding's `failure_mode` carries the full audit-to-fix handoff text per [`agents/code-quality-agent.md`](../../../agents/code-quality-agent.md); split `failure_mode` at the literal `Fix:` heading so the failure narrative becomes `description` and the suffix beginning at `Fix:` (including the trailing `Validation:` clause) becomes `fix_summary`. When a finding's `failure_mode` omits the `Fix:` heading, write the full text to BOTH `description` and `fix_summary` so the script's body template (`INLINE_COMMENT_BODY_TEMPLATE` in [`packages/claude-dev-env/_shared/pr-loop/scripts/config/post_audit_thread_constants.py`](../../../_shared/pr-loop/scripts/config/post_audit_thread_constants.py)) renders coherently. Set `side="RIGHT"` for every entry. On CLEAN the list is empty (`[]`); on DIRTY the list carries one entry per finding.
26
26
 
27
27
  The script handles retries internally — 1s / 4s / 16s backoff across four attempts (one initial plus three retries). Exit codes:
28
28
 
@@ -5,14 +5,20 @@ import subprocess
5
5
  import sys
6
6
  from pathlib import Path
7
7
 
8
+ parent_directory = str(Path(__file__).resolve().parent)
9
+ try:
10
+ sys.path.remove(parent_directory)
11
+ except ValueError:
12
+ pass
13
+ if parent_directory not in sys.path:
14
+ sys.path.insert(0, parent_directory)
15
+
8
16
  for each_cached_module_name in [
9
17
  each_module_key
10
18
  for each_module_key in list(sys.modules)
11
19
  if each_module_key == "config" or each_module_key.startswith("config.")
12
20
  ]:
13
21
  sys.modules.pop(each_cached_module_name, None)
14
- if str(Path(__file__).resolve().parent) not in sys.path:
15
- sys.path.insert(0, str(Path(__file__).resolve().parent))
16
22
 
17
23
  from config.bugteam_fix_hookspath_constants import (
18
24
  ALL_CANONICAL_HOOKS_DIRECTORY_COMPONENTS,
@@ -19,6 +19,54 @@ if _script_directory not in sys.path:
19
19
  sys.path.insert(0, _script_directory)
20
20
 
21
21
  import _claude_permissions_common as common_module
22
+ import grant_project_claude_permissions as grant_module
23
+ import revoke_project_claude_permissions as revoke_module
24
+
25
+
26
+ def test_is_valid_project_root_not_defined_on_common_module() -> None:
27
+ """The common module must not expose ``is_valid_project_root``.
28
+
29
+ Both grant and revoke define their own local copy. A shared copy on
30
+ the common module would be a third parallel definition with no
31
+ production caller, so its absence is the asserted contract.
32
+ """
33
+ assert not hasattr(common_module, "is_valid_project_root")
34
+
35
+
36
+ def test_grant_is_valid_project_root_detects_git_marker(tmp_path: Path) -> None:
37
+ git_project_root = tmp_path / "git_project"
38
+ (git_project_root / ".git").mkdir(parents=True)
39
+ assert grant_module.is_valid_project_root(git_project_root) is True
40
+
41
+
42
+ def test_grant_is_valid_project_root_detects_claude_marker(tmp_path: Path) -> None:
43
+ claude_project_root = tmp_path / "claude_project"
44
+ (claude_project_root / ".claude").mkdir(parents=True)
45
+ assert grant_module.is_valid_project_root(claude_project_root) is True
46
+
47
+
48
+ def test_grant_is_valid_project_root_rejects_unmarked_directory(tmp_path: Path) -> None:
49
+ unmarked_directory = tmp_path / "no_marker"
50
+ unmarked_directory.mkdir()
51
+ assert grant_module.is_valid_project_root(unmarked_directory) is False
52
+
53
+
54
+ def test_revoke_is_valid_project_root_detects_git_marker(tmp_path: Path) -> None:
55
+ git_project_root = tmp_path / "git_project"
56
+ (git_project_root / ".git").mkdir(parents=True)
57
+ assert revoke_module.is_valid_project_root(git_project_root) is True
58
+
59
+
60
+ def test_revoke_is_valid_project_root_detects_claude_marker(tmp_path: Path) -> None:
61
+ claude_project_root = tmp_path / "claude_project"
62
+ (claude_project_root / ".claude").mkdir(parents=True)
63
+ assert revoke_module.is_valid_project_root(claude_project_root) is True
64
+
65
+
66
+ def test_revoke_is_valid_project_root_rejects_unmarked_directory(tmp_path: Path) -> None:
67
+ unmarked_directory = tmp_path / "no_marker"
68
+ unmarked_directory.mkdir()
69
+ assert revoke_module.is_valid_project_root(unmarked_directory) is False
22
70
 
23
71
 
24
72
  def test_write_atomically_with_mode_releases_fd_when_fdopen_raises(
@@ -118,18 +118,26 @@ def test_default_settings_file_mode_used_when_settings_file_missing(
118
118
  assert returned_mode == DEFAULT_SETTINGS_FILE_MODE
119
119
 
120
120
 
121
- def test_is_valid_project_root_helper_is_not_orphaned_in_common_module() -> None:
122
- """The orphan helper in the common module must be removed.
121
+ def test_is_valid_project_root_exported_from_consumer_modules(
122
+ tmp_path: Path,
123
+ ) -> None:
124
+ """is_valid_project_root behaviour matches across both consumers.
123
125
 
124
- Both grant and revoke keep their own local copies and consume them from
125
- module scope; the common-module copy was dead code with zero call sites.
126
+ Grant and revoke each define their own local copy of the helper, so
127
+ both copies must agree on the .git / .claude marker contract.
126
128
  """
127
- assert not hasattr(common_module, "is_valid_project_root"), (
128
- "is_valid_project_root must not live in _claude_permissions_common — "
129
- "neither grant nor revoke imports it from there"
130
- )
131
- assert callable(grant_module.is_valid_project_root)
132
- assert callable(revoke_module.is_valid_project_root)
129
+ git_marker_project_root = tmp_path / "git_project"
130
+ (git_marker_project_root / ".git").mkdir(parents=True)
131
+ claude_marker_project_root = tmp_path / "claude_project"
132
+ (claude_marker_project_root / ".claude").mkdir(parents=True)
133
+ bare_directory = tmp_path / "no_marker"
134
+ bare_directory.mkdir()
135
+ assert grant_module.is_valid_project_root(git_marker_project_root) is True
136
+ assert grant_module.is_valid_project_root(claude_marker_project_root) is True
137
+ assert grant_module.is_valid_project_root(bare_directory) is False
138
+ assert revoke_module.is_valid_project_root(git_marker_project_root) is True
139
+ assert revoke_module.is_valid_project_root(claude_marker_project_root) is True
140
+ assert revoke_module.is_valid_project_root(bare_directory) is False
133
141
 
134
142
 
135
143
  def _reload_with_stale_config_cache(module_name: str) -> ModuleType:
@@ -38,7 +38,8 @@ clean-at SHAs. On tick exit, write updated state before calling ScheduleWakeup
38
38
  so the next tick resumes with accurate state.
39
39
 
40
40
  Fields: `phase`, `tick_count`, `bugbot_clean_at`, `bugteam_clean_at`,
41
- `copilot_clean_at`, `current_head`, `bugbot_acknowledged_at`, `bugbot_down`.
41
+ `copilot_clean_at`, `current_head`, `bugbot_acknowledged_at`, `bugbot_down`,
42
+ `bugteam_skill_invoked_at_head`, `bugteam_skill_invoked_at_tick`.
42
43
 
43
44
  ## Gotchas
44
45
 
@@ -150,7 +151,12 @@ no longer applies.
150
151
 
151
152
  Pre-condition: `bugbot_clean_at == current_head` (or `bugbot_down == true`).
152
153
 
153
- Run `Skill({skill: "bugteam", args: "<PR URL>"})`.
154
+ Step 5 advances ONLY after `Skill({skill: "bugteam", args: "<PR URL>"})`
155
+ fires this tick. Substituting an `Agent({subagent_type: "clean-coder"})`
156
+ audit call for the formal Skill invocation is a protocol violation — the
157
+ `pr_converge_bugteam_enforcer` hook blocks it. `qbug` is NOT an accepted
158
+ substitute; `bugteam` is the only allowed skill at this step.
159
+
154
160
  After bugteam completes, re-resolve HEAD.
155
161
 
156
162
  - [ ] **bugteam pushed new commits** →
@@ -2,7 +2,8 @@
2
2
 
3
3
  All runtime and API constants live here. Script-specific constants
4
4
  (CLI args, markdown patterns, reflow settings) stay in
5
- ``scripts/config/pr_converge_constants.py``, which imports from here.
5
+ ``packages/claude-dev-env/skills/pr-converge/scripts/config/pr_converge_constants.py``,
6
+ which imports from here.
6
7
  """
7
8
 
8
9
  CURSOR_BOT_LOGIN = "cursor[bot]"
@@ -1,10 +1,13 @@
1
1
  # State across ticks
2
2
 
3
- **Dual persistence:** `<TMPDIR>/pr-converge-<session_id>/state.json`
4
- exists (multi-PR) that file is source of truth for `phase`, heads,
5
- counters, status, not conversation transcript. No `state.json` (typical
6
- single-PR `/pr-converge`) track in each assistant turn as
7
- plain text so next tick re-reads from context:
3
+ **Dual persistence:** Single-PR `/pr-converge` writes loop state to
4
+ `$CLAUDE_JOB_DIR/pr-converge-state.json`; that file is the source of truth
5
+ for `phase`, heads, counters, status. Multi-PR mode additionally maintains
6
+ `<TMPDIR>/pr-converge-<session_id>/state.json` for orchestrator coordination
7
+ across PRs. Both files share most of the fields below; the
8
+ `bugteam_skill_invoked_at_head` and `bugteam_skill_invoked_at_tick` fields
9
+ live ONLY in the single-PR `$CLAUDE_JOB_DIR/pr-converge-state.json` file
10
+ (see those field entries below for details).
8
11
 
9
12
  - `phase`: `BUGBOT`, `BUGTEAM`, or `COPILOT_WAIT`. Start `BUGBOT` on first tick.
10
13
  - `bugbot_clean_at`: HEAD SHA where bugbot last reported clean, or `null`.
@@ -34,7 +37,32 @@ plain text so next tick re-reads from context:
34
37
  (c) reads this field to decide between "schedule next wakeup" and
35
38
  "escalate to bugbot-down".
36
39
  - `tick_count`: integer, init `0`. Increment every tick.
40
+ - `bugteam_skill_invoked_at_head`: HEAD SHA (string) at which the formal
41
+ `Skill({skill: "bugteam"})` was last invoked, or `null`. Stamped by the
42
+ `pr_converge_bugteam_skill_tracker` hook on every formal bugteam Skill
43
+ invocation. **On-disk location:** the tracker writes this field to
44
+ `$CLAUDE_JOB_DIR/pr-converge-state.json` (single-PR mode); it is NOT
45
+ mirrored into the multi-PR `<TMPDIR>/pr-converge-<session_id>/state.json`
46
+ file. Operators inspecting these stamps must read the single-PR
47
+ `pr-converge-state.json` under `$CLAUDE_JOB_DIR`. Reset by overwrite on
48
+ the next bugteam Skill invocation; staleness is detected by the head/tick
49
+ equality check rather than by explicit reset. The
50
+ `pr_converge_bugteam_enforcer` hook reads this field together with
51
+ `current_head` to confirm the formal Skill registered at the current HEAD
52
+ before allowing follow-on clean-coder audit-shaped Agent spawns. `qbug`
53
+ invocations deliberately do NOT update this field.
54
+ - `bugteam_skill_invoked_at_tick`: integer tick number at which the formal
55
+ bugteam Skill was last invoked, or `null`. Companion to
56
+ `bugteam_skill_invoked_at_head` and persisted to the same
57
+ `$CLAUDE_JOB_DIR/pr-converge-state.json` file (single-PR mode only).
58
+ Reset by overwrite on the next bugteam Skill invocation; staleness is
59
+ detected by the head/tick equality check rather than by explicit reset.
60
+ The enforcer requires this value to equal the current `tick_count` so a
61
+ Skill invocation from a prior tick cannot wave through clean-coder
62
+ audit-shaped Agent spawns on a later tick at the same HEAD.
37
63
 
38
- Tick begins reading prior state line from most recent assistant message
39
- (no `state.json`) and ends by emitting updated state line; with
40
- `state.json`, follow `multi-pr-orchestration.md` §What orchestrator does per tick.
64
+ Single-PR tick begins by reading `$CLAUDE_JOB_DIR/pr-converge-state.json`
65
+ if it exists and ends by writing the updated state back to that same file
66
+ before scheduling the next wakeup. Multi-PR mode additionally coordinates
67
+ across PRs via `<TMPDIR>/pr-converge-<session_id>/state.json` per
68
+ `multi-pr-orchestration.md` §What orchestrator does per tick.