claude-dev-env 1.83.0 → 1.85.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 (186) hide show
  1. package/_shared/pr-loop/audit-contract.md +24 -12
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +8 -0
  6. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
  7. package/_shared/pr-loop/scripts/terminology_sweep.py +85 -39
  8. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
  9. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
  12. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
  13. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +94 -4
  14. package/agents/clean-coder.md +3 -0
  15. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  16. package/bin/install.mjs +16 -8
  17. package/bin/install.test.mjs +17 -6
  18. package/commands/CLAUDE.md +0 -1
  19. package/docs/CODE_RULES.md +1 -1
  20. package/hooks/blocking/CLAUDE.md +0 -4
  21. package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
  22. package/hooks/blocking/code_rules_constants_config.py +5 -0
  23. package/hooks/blocking/code_rules_docstrings.py +133 -19
  24. package/hooks/blocking/code_rules_enforcer.py +4 -0
  25. package/hooks/blocking/code_rules_imports_logging.py +10 -6
  26. package/hooks/blocking/code_rules_magic_values.py +5 -0
  27. package/hooks/blocking/code_rules_naming_collection.py +15 -8
  28. package/hooks/blocking/code_rules_paired_test.py +3 -2
  29. package/hooks/blocking/code_rules_shared.py +34 -0
  30. package/hooks/blocking/code_rules_string_magic.py +1 -1
  31. package/hooks/blocking/code_rules_unused_imports.py +2 -2
  32. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
  33. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
  34. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
  36. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
  37. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
  38. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
  39. package/hooks/blocking/test_code_rules_magic_values.py +54 -0
  40. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
  41. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
  42. package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
  43. package/hooks/hooks_constants/CLAUDE.md +0 -3
  44. package/hooks/hooks_constants/blocking_check_limits.py +4 -0
  45. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  46. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
  47. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
  48. package/hooks/validation/post_tool_use_dispatcher.py +1 -1
  49. package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
  50. package/hooks/workflow/CLAUDE.md +2 -8
  51. package/package.json +1 -1
  52. package/rules/CLAUDE.md +1 -1
  53. package/rules/docstring-prose-matches-implementation.md +1 -1
  54. package/rules/env-var-table-code-drift.md +1 -1
  55. package/rules/package-inventory-stale-entry.md +1 -1
  56. package/rules/paired-test-coverage.md +1 -1
  57. package/rules/plain-illustrative-docstrings.md +35 -1
  58. package/skills/CLAUDE.md +6 -1
  59. package/skills/_shared/pr-loop/CLAUDE.md +1 -0
  60. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  61. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  62. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
  63. package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
  64. package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
  65. package/skills/autoconverge/SKILL.md +82 -58
  66. package/skills/autoconverge/reference/closing-report.md +6 -6
  67. package/skills/autoconverge/reference/convergence.md +17 -15
  68. package/skills/autoconverge/reference/gotchas.md +6 -3
  69. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
  70. package/skills/autoconverge/workflow/converge.contract.test.mjs +87 -13
  71. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +1 -1
  72. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
  73. package/skills/autoconverge/workflow/converge.mjs +71 -74
  74. package/skills/autoconverge/workflow/render_report.py +7 -11
  75. package/skills/bugteam/CLAUDE.md +1 -1
  76. package/skills/bugteam/PROMPTS.md +7 -6
  77. package/skills/bugteam/SKILL.md +25 -72
  78. package/skills/bugteam/reference/CLAUDE.md +1 -3
  79. package/skills/bugteam/reference/README.md +1 -1
  80. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  81. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
  82. package/skills/bugteam/reference/team-setup.md +8 -7
  83. package/skills/bugteam/scripts/CLAUDE.md +0 -6
  84. package/skills/bugteam/scripts/README.md +0 -4
  85. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
  86. package/skills/code/SKILL.md +2 -0
  87. package/skills/copilot-review/CLAUDE.md +1 -1
  88. package/skills/copilot-review/SKILL.md +25 -23
  89. package/skills/findbugs/CLAUDE.md +2 -2
  90. package/skills/findbugs/SKILL.md +22 -83
  91. package/skills/fixbugs/SKILL.md +2 -4
  92. package/skills/log-audit/CLAUDE.md +20 -0
  93. package/skills/log-audit/SKILL.md +68 -0
  94. package/skills/log-audit/reference/CLAUDE.md +9 -0
  95. package/skills/log-audit/reference/charter.md +52 -0
  96. package/skills/log-audit/scripts/CLAUDE.md +27 -0
  97. package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
  98. package/skills/log-audit/scripts/collect_log_window.py +199 -0
  99. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
  100. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
  101. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
  102. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
  103. package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
  104. package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
  105. package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
  106. package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
  107. package/skills/monitor-open-prs/SKILL.md +2 -2
  108. package/skills/post-audit-findings/SKILL.md +84 -0
  109. package/skills/pr-converge/CLAUDE.md +2 -0
  110. package/skills/pr-converge/SKILL.md +99 -60
  111. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  112. package/skills/pr-converge/reference/convergence-gates.md +16 -19
  113. package/skills/pr-converge/reference/examples.md +5 -5
  114. package/skills/pr-converge/reference/fix-protocol.md +16 -43
  115. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
  116. package/skills/pr-converge/reference/per-tick.md +24 -45
  117. package/skills/pr-converge/reference/state-schema.md +27 -0
  118. package/skills/pr-converge/scripts/README.md +3 -5
  119. package/skills/pr-fix-protocol/SKILL.md +70 -0
  120. package/skills/pr-loop-lifecycle/SKILL.md +73 -0
  121. package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
  122. package/skills/pr-scope-resolve/SKILL.md +48 -0
  123. package/skills/qbug/CLAUDE.md +4 -4
  124. package/skills/qbug/SKILL.md +46 -144
  125. package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
  126. package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
  127. package/skills/reviewer-gates/SKILL.md +96 -0
  128. package/skills/session-log/CLAUDE.md +7 -7
  129. package/skills/session-log/SKILL.md +27 -44
  130. package/skills/test_markdown_link_integrity.py +103 -0
  131. package/commands/doc-gist.md +0 -16
  132. package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
  133. package/hooks/blocking/conftest.py +0 -30
  134. package/hooks/blocking/md_path_exemptions.py +0 -224
  135. package/hooks/blocking/md_to_html_blocker.py +0 -155
  136. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
  137. package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
  138. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
  139. package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
  140. package/hooks/hooks_constants/html_companion_constants.py +0 -20
  141. package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
  142. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
  143. package/hooks/workflow/doc_gist_auto_publish.py +0 -144
  144. package/hooks/workflow/md_to_html_companion.py +0 -358
  145. package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
  146. package/hooks/workflow/test_md_to_html_companion.py +0 -613
  147. package/skills/bugteam/reference/audit-contract.md +0 -163
  148. package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
  149. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
  150. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
  151. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
  152. package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
  153. package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
  154. package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
  155. package/skills/doc-gist/CLAUDE.md +0 -25
  156. package/skills/doc-gist/SKILL.md +0 -97
  157. package/skills/doc-gist/references/CLAUDE.md +0 -9
  158. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
  159. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
  160. package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
  161. package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
  162. package/skills/doc-gist/references/examples/05-design-system.html +0 -629
  163. package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
  164. package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
  165. package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
  166. package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
  167. package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
  168. package/skills/doc-gist/references/examples/11-status-report.html +0 -528
  169. package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
  170. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
  171. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
  172. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
  173. package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
  174. package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
  175. package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
  176. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
  177. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
  178. package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
  179. package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
  180. package/skills/doc-gist/references/examples/README.md +0 -5
  181. package/skills/doc-gist/scripts/CLAUDE.md +0 -27
  182. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
  183. package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
  184. package/skills/doc-gist/scripts/gist_upload.py +0 -177
  185. package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
  186. /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
@@ -8,7 +8,8 @@ Shared output schema and audit-loop contract used by `/bugteam`, `/qbug`, `/find
8
8
  - Adversarial second pass
9
9
  - Haiku secondary auditor
10
10
  - Post-fix self-audit
11
- - Persistence (loop-N-audit.json, loop-N-diagnostics.json)
11
+ - De-dup and merge
12
+ - Persistence (loop-<L>-audit.json, loop-<L>-diagnostics.json)
12
13
 
13
14
  ## Finding schema
14
15
 
@@ -18,7 +19,7 @@ Each finding an audit produces MUST be one of exactly two shapes.
18
19
 
19
20
  ```json
20
21
  {
21
- "id": "loop<N>-<K>",
22
+ "id": "loop<L>-<K>",
22
23
  "file": "path/relative/to/repo/root.py",
23
24
  "line": 123,
24
25
  "category": "A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P",
@@ -29,7 +30,7 @@ Each finding an audit produces MUST be one of exactly two shapes.
29
30
  }
30
31
  ```
31
32
 
32
- `id` is `loop<N>-<K>` where `N` is the loop counter (1-based) and `K` is the 1-based index within the loop. For `/findbugs` which runs once, use `find<K>`.
33
+ `id` is `loop<L>-<K>` where `L` is the loop counter (1-based) and `K` is the 1-based index within the loop. For `/findbugs` which runs once, use `find<K>`.
33
34
 
34
35
  ### Shape B — structured proof-of-absence
35
36
 
@@ -98,16 +99,16 @@ For single-subagent skills (`/qbug`, `/findbugs`) the LEAD spawns two `Agent()`
98
99
 
99
100
  Both audit the same diff. The secondary returns findings to the LEAD only — never posted to the PR.
100
101
 
101
- Merge rules:
102
+ Merge rules — applied whenever the LEAD combines findings from multiple sources (primary auditor, Haiku secondary auditor, adversarial pass):
102
103
 
103
- - **De-dup key**: `(file, line, category)`.
104
- - **Severity conflict**: max wins (P0 > P1 > P2).
105
- - **Unique-to-Haiku findings**: added to the primary set with Haiku's severity and source annotation.
104
+ - **De-dup key**: `(file, line, category)`. Two findings sharing the same `(file, line, category)` tuple are the same finding and collapse into one entry.
105
+ - **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.
106
+ - **Unique-to-secondary findings**: added to the merged set with the secondary's severity and source annotation.
106
107
  - **Unique-to-primary findings**: kept as-is.
107
- - **Zero Haiku findings**: primary set trusted; proceed.
108
- - **Malformed or non-parseable Haiku output**: lead trusts the primary set, logs the event in `loop-<N>-diagnostics.json` under `haiku_findings` as `[{"parse_error": "<message>"}]`.
108
+ - **Zero secondary findings**: the primary set is trusted and the audit moves on.
109
+ - **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>"}]`.
109
110
 
110
- For multi-subagent skills (`/bugteam`) the parallel-auditors pattern in [`audit-and-teammates.md`](audit-and-teammates.md) already provides cross-model coverage via the three variant teammates.
111
+ For `/bugteam`, the single audit agent provides per-category coverage by walking all A–P rubrics in one invocation.
111
112
 
112
113
  ## Post-fix self-audit
113
114
 
@@ -128,11 +129,22 @@ Sequence:
128
129
 
129
130
  `converged` exit condition: `primary_audit_clean AND post_fix_audit_clean` for the committing loop.
130
131
 
132
+ ## De-dup and merge
133
+
134
+ Findings from primary, adversarial, Haiku secondary, and post-fix passes are merged into a single deduped finding list before persistence.
135
+
136
+ - **De-dup key:** `(file, line, category)`. Two findings sharing the same `(file, line, category)` tuple collapse into a single deduped entry.
137
+ - **Severity conflict resolution:** `max wins`. When merged findings disagree on severity, the deduped entry carries the highest severity (`P0 > P1 > P2`).
138
+ - **Excerpt and failure_mode:** the deduped entry inherits these fields from the highest-severity contributing finding. Ties keep the first observed contributor.
139
+ - **`evidence_files`:** the deduped entry carries the union of every contributor's `evidence_files`, deduplicated and sorted.
140
+
141
+ 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).
142
+
131
143
  ## Persistence
132
144
 
133
145
  Every audit loop writes two JSON files under the skill's scoped temp directory (resolved via `tempfile.gettempdir()`):
134
146
 
135
- ### `loop-<N>-audit.json`
147
+ ### `loop-<L>-audit.json`
136
148
 
137
149
  ```json
138
150
  {
@@ -142,7 +154,7 @@ Every audit loop writes two JSON files under the skill's scoped temp directory (
142
154
  }
143
155
  ```
144
156
 
145
- ### `loop-<N>-diagnostics.json`
157
+ ### `loop-<L>-diagnostics.json`
146
158
 
147
159
  ```json
148
160
  {
@@ -299,9 +299,11 @@ def write_atomically_with_mode(
299
299
 
300
300
  Raises:
301
301
  OSError: When os.open or os.fdopen fails. The raw file descriptor
302
- is closed before re-raising so the descriptor does not leak.
302
+ is closed and the on-disk temporary file is unlinked before
303
+ re-raising so neither the descriptor nor the file leaks.
303
304
  MemoryError: When os.fdopen runs out of buffer memory; the file
304
- descriptor is closed before re-raising.
305
+ descriptor is closed and the temporary file is unlinked
306
+ before re-raising.
305
307
  """
306
308
  file_descriptor = os.open(
307
309
  str(temporary_path),
@@ -312,6 +314,10 @@ def write_atomically_with_mode(
312
314
  writer = os.fdopen(file_descriptor, "w", encoding=TEXT_FILE_ENCODING)
313
315
  except (OSError, MemoryError):
314
316
  os.close(file_descriptor)
317
+ try:
318
+ os.unlink(str(temporary_path))
319
+ except OSError:
320
+ pass
315
321
  raise
316
322
  with writer:
317
323
  writer.write(serialized_content)
@@ -321,7 +327,8 @@ def save_settings(settings_path: Path, all_settings: dict[str, object]) -> None:
321
327
  """Write settings to a JSON file atomically with permission preservation.
322
328
 
323
329
  Creates a temporary sibling file, writes content, then atomically
324
- replaces the target. Cleans up the temporary file in a finally block.
330
+ replaces the target. Cleans up the temporary file in a finally block,
331
+ printing a warning to stderr when that cleanup unlink fails.
325
332
 
326
333
  Args:
327
334
  settings_path: Path to the target settings JSON file.
@@ -352,8 +359,12 @@ def save_settings(settings_path: Path, all_settings: dict[str, object]) -> None:
352
359
  if temporary_path.exists():
353
360
  try:
354
361
  temporary_path.unlink()
355
- except OSError:
356
- pass
362
+ except OSError as unlink_error:
363
+ print(
364
+ f"Warning: could not remove temp file {temporary_path}: "
365
+ f"{type(unlink_error).__name__}: {unlink_error}",
366
+ file=sys.stderr,
367
+ )
357
368
 
358
369
 
359
370
  def append_if_missing(all_target_list: list[object], new_value: str) -> bool:
@@ -1673,7 +1673,6 @@ def main(all_arguments: list[str]) -> int:
1673
1673
  if arguments.staged:
1674
1674
  all_terminology_findings = staged_terminology_findings(repository_root)
1675
1675
  _report_terminology_findings(all_terminology_findings)
1676
- terminology_exit_code = 1 if all_terminology_findings else 0
1677
1676
  staged_test_exit_code = run_staged_test_files(repository_root)
1678
1677
  staged_file_paths = paths_from_git_staged(repository_root)
1679
1678
  staged_file_paths = filter_paths_under_prefixes(
@@ -1682,7 +1681,7 @@ def main(all_arguments: list[str]) -> int:
1682
1681
  arguments.only_under,
1683
1682
  )
1684
1683
  if not staged_file_paths:
1685
- return terminology_exit_code or staged_test_exit_code
1684
+ return staged_test_exit_code
1686
1685
  staged_added_lines = added_lines_by_file_staged(
1687
1686
  repository_root, staged_file_paths
1688
1687
  )
@@ -1693,7 +1692,7 @@ def main(all_arguments: list[str]) -> int:
1693
1692
  all_added_lines_by_path=staged_added_lines,
1694
1693
  read_staged_content_flag=True,
1695
1694
  )
1696
- return gate_exit_code or terminology_exit_code or staged_test_exit_code
1695
+ return gate_exit_code or staged_test_exit_code
1697
1696
  file_paths = paths_from_git_diff(repository_root, arguments.base)
1698
1697
  file_paths = filter_paths_under_prefixes(
1699
1698
  file_paths,
@@ -16,7 +16,7 @@ Named constants for every script in `_shared/pr-loop/scripts/`. Each module owns
16
16
  | `preflight_self_heal_constants.py` | `preflight_self_heal.py` — git config keys and local-scope detection strings |
17
17
  | `reviews_disabled_constants.py` | `reviews_disabled.py` — `CLAUDE_REVIEWS_DISABLED` token taxonomy |
18
18
  | `copilot_quota_constants.py` | `copilot_quota.py` — `COPILOT_QUOTA_ACCOUNT` env-var name, `copilot_internal/user` API path, the `premium_interactions` gating field names, the four skip/run exit codes, and the default `.env` path |
19
- | `terminology_sweep_constants.py` | `terminology_sweep.py` — identifier and prose-token regexes, diff-parsing prefixes, code-file extensions, and the finding-message template |
19
+ | `terminology_sweep_constants.py` | `terminology_sweep.py` — identifier and prose-token regexes, diff-parsing prefixes, code-file extensions, test-file detection patterns, and the finding-message template |
20
20
  | `reviewer_availability_constants.py` | `reviewer_availability.py` — the available/down exit codes for the unified reviewer-availability pre-check |
21
21
  | `__init__.py` | Empty package marker |
22
22
 
@@ -8,6 +8,8 @@ ALL_SWEEP_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
8
8
 
9
9
  MARKDOWN_FILE_EXTENSION: str = ".md"
10
10
 
11
+ PYTHON_FILE_EXTENSION: str = ".py"
12
+
11
13
  SNAKE_CASE_IDENTIFIER_PATTERN: re.Pattern[str] = re.compile(
12
14
  r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b"
13
15
  )
@@ -28,6 +30,12 @@ STRING_LITERAL_CONTENT_PATTERN: re.Pattern[str] = re.compile(
28
30
  r"\"([^\"]*)\"|'([^']*)'|`([^`]*)`"
29
31
  )
30
32
 
33
+ TEST_DIRECTORY_PATH_SEGMENT: str = "/tests/"
34
+
35
+ TEST_FILE_NAME_PREFIX: str = "test_"
36
+
37
+ ALL_TEST_FILE_NAME_INFIX_MARKERS: tuple[str, ...] = ("_test.", ".test.", ".spec.")
38
+
31
39
  DIFF_FILE_HEADER_PREFIX: str = "+++ "
32
40
 
33
41
  DIFF_HUNK_HEADER_PATTERN: re.Pattern[str] = re.compile(
@@ -103,6 +103,7 @@ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
103
103
  choices=[
104
104
  CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN,
105
105
  CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN,
106
+ CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN,
106
107
  ],
107
108
  help="Reviewer token to test against CLAUDE_REVIEWS_DISABLED",
108
109
  )
@@ -122,6 +123,7 @@ def main(all_arguments: list[str]) -> int:
122
123
  disabled_checker_by_reviewer = {
123
124
  CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN: is_bugbot_disabled_via_env,
124
125
  CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN: is_bugteam_disabled_via_env,
126
+ CLAUDE_REVIEWS_DISABLED_COPILOT_TOKEN: is_copilot_disabled_via_env,
125
127
  }
126
128
  return 0 if disabled_checker_by_reviewer[arguments.reviewer]() else 1
127
129
 
@@ -1,40 +1,27 @@
1
- """Flag prose terms that near-miss an identifier a diff introduces.
2
-
3
- Picture a change that adds an API field named ``premium_interactions``. On the
4
- same branch, the docs call it the ``premium-request`` budget. The code and the
5
- prose now name one thing two ways. A reader who searches for one term never
6
- finds the other.
7
-
8
- This sweep reads a unified diff and collects every multi-word identifier added
9
- on code lines. Then it scans each added prose line — a Markdown line, or a
10
- comment, docstring, or string inside a code file. It flags a hyphenated term
11
- that shares an identifier's leading word but diverges in the tail. Each
12
- near-miss prints as one ``file:line`` finding, and the run exits non-zero when
13
- any finding remains, so a commit gate can block on it.
14
-
15
- Only hyphenated terms are candidates. A hyphen marks a deliberate compound
16
- the author bound the words into one term so a divergent tail there is a real
17
- naming drift. A bare spaced word run is ordinary prose: windowing every
18
- sentence that starts with an identifier's first word ("each attempt", "file
19
- exists") floods a vocabulary-dense change with false findings and gives the
20
- gate nothing actionable.
21
-
22
- A shared leading token alone is too weak a signal: ordinary English compounds
23
- such as ``read-only`` and ``data-driven`` collide with unrelated identifiers
24
- (``read_config``, ``data_source``) and would block a commit falsely. To keep the
25
- grounding case (``premium-request`` against ``premium_interactions``) firing
26
- while sparing those compounds, a candidate whose final token is a common English
27
- compound tail word (``only``, ``driven``, ``safe``, and the rest listed in
28
- ``ALL_COMMON_ENGLISH_COMPOUND_TAIL_WORDS``) is treated as ordinary prose, not a
29
- near-miss.
30
-
31
- Ordinary singular/plural prose is spared the same way. A candidate that differs
32
- from an identifier only by a singular/plural form of one or more tokens
33
- (``test files`` against ``test_file``, ``retry policies`` against
34
- ``retry_policy``) is treated as the same term, not a near-miss.
1
+ """Flag a prose term that near-misses a code identifier a diff introduces.
2
+
3
+ A change adds the field ``premium_interactions`` while the same branch's docs
4
+ call it the ``premium-request`` budget: two names for one thing, and a reader
5
+ who searches one never finds the other.
6
+
7
+ ::
8
+
9
+ code adds: premium_interactions (the identifier)
10
+ prose adds: "the premium-request budget" (a hyphenated term)
11
+ flag: premium-request vs premium_interactions -- shared first token, tail diverges
12
+ ok: premium-interactions -- exact hyphen form, no drift
13
+ ok: read-only, test files -- English compound or plural, not drift
14
+
15
+ Only hyphenated prose is a candidate, since a hyphen marks a deliberate compound
16
+ while a spaced word run is ordinary prose. Markdown scans in full; a JavaScript
17
+ or TypeScript line contributes its comments and string literals; a Python file
18
+ contributes identifiers only; a test code file is skipped whole. Each near-miss
19
+ prints one ``file:line`` finding and the run exits non-zero, so a commit gate
20
+ blocks on it.
35
21
  """
36
22
 
37
23
  import argparse
24
+ import os
38
25
  import subprocess
39
26
  import sys
40
27
  from pathlib import Path
@@ -48,6 +35,7 @@ from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
48
35
  ALL_DIFF_FILE_PATH_STRIP_PREFIXES,
49
36
  ALL_GIT_DIFF_CACHED_UNIFIED_ZERO_COMMAND,
50
37
  ALL_SWEEP_CODE_FILE_EXTENSIONS,
38
+ ALL_TEST_FILE_NAME_INFIX_MARKERS,
51
39
  CAMEL_CASE_IDENTIFIER_PATTERN,
52
40
  CAMEL_CASE_WORD_PATTERN,
53
41
  DIFF_ADDED_LINE_PREFIX,
@@ -64,10 +52,13 @@ from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
64
52
  MARKDOWN_FILE_EXTENSION,
65
53
  MINIMUM_IDENTIFIER_TOKEN_COUNT,
66
54
  PYTHON_COMMENT_MARKER,
55
+ PYTHON_FILE_EXTENSION,
67
56
  SNAKE_CASE_IDENTIFIER_PATTERN,
68
57
  STRING_LITERAL_CONTENT_PATTERN,
69
58
  TERMINOLOGY_FINDING_TEMPLATE,
70
59
  TERMINOLOGY_SWEEP_DESCRIPTION,
60
+ TEST_DIRECTORY_PATH_SEGMENT,
61
+ TEST_FILE_NAME_PREFIX,
71
62
  )
72
63
 
73
64
  IdentifierTuple = tuple[str, ...]
@@ -124,6 +115,35 @@ def _file_extension(file_path: str) -> str:
124
115
  return Path(file_path).suffix.lower()
125
116
 
126
117
 
118
+ def _is_test_file(file_path: str) -> bool:
119
+ """Return True when the diff path is a test code file the sweep should skip.
120
+
121
+ A test code file's prose is scaffolding: its docstrings and string fixtures
122
+ name the identifiers under test on purpose, so scanning it only adds noise. A
123
+ Markdown or other non-code file is prose worth scanning whatever its name, so
124
+ a ``test_plan.md`` design doc is scanned rather than skipped.
125
+
126
+ Args:
127
+ file_path: The repository-relative diff path.
128
+
129
+ Returns:
130
+ True for a code file under a ``tests`` directory, a ``test_`` prefixed
131
+ code file, or a code file whose name carries a
132
+ ``_test.``/``.test.``/``.spec.`` marker. False for any non-code file.
133
+ """
134
+ if _file_extension(file_path) not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
135
+ return False
136
+ normalized_path = file_path.replace("\\", "/").lower()
137
+ if TEST_DIRECTORY_PATH_SEGMENT in f"/{normalized_path}":
138
+ return True
139
+ basename = normalized_path.rsplit("/", 1)[-1]
140
+ if basename.startswith(TEST_FILE_NAME_PREFIX):
141
+ return True
142
+ return any(
143
+ each_marker in basename for each_marker in ALL_TEST_FILE_NAME_INFIX_MARKERS
144
+ )
145
+
146
+
127
147
  def _identifier_token_tuple(identifier: str) -> IdentifierTuple:
128
148
  """Return the lowercase word tokens of a snake_case or camelCase identifier."""
129
149
  if "_" in identifier:
@@ -175,8 +195,11 @@ def _collect_introduced_identifiers(
175
195
  def _prose_fragments(file_path: str, line_text: str) -> list[str]:
176
196
  """Return the prose fragments of an added line worth scanning for terms.
177
197
 
178
- A Markdown line is prose in full. A code line contributes its comment tail,
179
- its JSDoc continuation text, and the contents of its string literals.
198
+ A Markdown line is prose in full. A JavaScript or TypeScript line contributes
199
+ its comment tail, its JSDoc continuation text, and the contents of its string
200
+ literals. A Python line contributes no prose: to avoid flagging a module's own
201
+ vocabulary as drift, the sweep ignores Python comments, docstrings, and string
202
+ literals and scans Python files for identifiers only.
180
203
 
181
204
  Args:
182
205
  file_path: The path the added line belongs to.
@@ -185,9 +208,12 @@ def _prose_fragments(file_path: str, line_text: str) -> list[str]:
185
208
  Returns:
186
209
  The prose fragments to scan for near-miss terms.
187
210
  """
188
- if _file_extension(file_path) == MARKDOWN_FILE_EXTENSION:
211
+ extension = _file_extension(file_path)
212
+ if extension == MARKDOWN_FILE_EXTENSION:
189
213
  return [line_text]
190
- if _file_extension(file_path) not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
214
+ if extension not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
215
+ return []
216
+ if extension == PYTHON_FILE_EXTENSION:
191
217
  return []
192
218
  all_fragments: list[str] = []
193
219
  stripped_line = line_text.strip()
@@ -352,7 +378,11 @@ def sweep_diff(diff_text: str) -> list[str]:
352
378
  Returns:
353
379
  One finding string per near-miss term on an added prose line.
354
380
  """
355
- all_added_lines = _parse_added_lines(diff_text)
381
+ all_added_lines = [
382
+ each_added_line
383
+ for each_added_line in _parse_added_lines(diff_text)
384
+ if not _is_test_file(each_added_line[0])
385
+ ]
356
386
  all_identifier_tuples, identifiers_by_first_token = _collect_introduced_identifiers(
357
387
  all_added_lines
358
388
  )
@@ -372,6 +402,21 @@ def sweep_diff(diff_text: str) -> list[str]:
372
402
  return all_findings
373
403
 
374
404
 
405
+ def _git_environment() -> dict[str, str]:
406
+ """Return the process environment with every GIT_ variable removed.
407
+
408
+ A git commit hook runs this sweep with GIT_INDEX_FILE pointed at the commit
409
+ index. Stripping every GIT_ variable makes ``git diff --cached`` read the
410
+ repository's own index, so the sweep sees the staged diff of the repository
411
+ it was handed rather than an ambient override.
412
+ """
413
+ return {
414
+ each_key: each_setting
415
+ for each_key, each_setting in os.environ.items()
416
+ if not each_key.startswith("GIT_")
417
+ }
418
+
419
+
375
420
  def staged_terminology_findings(repository_root: Path) -> list[str]:
376
421
  """Return terminology near-miss findings for a repository's staged diff.
377
422
 
@@ -391,6 +436,7 @@ def staged_terminology_findings(repository_root: Path) -> list[str]:
391
436
  errors="replace",
392
437
  timeout=GIT_DIFF_SUBPROCESS_TIMEOUT_SECONDS,
393
438
  check=False,
439
+ env=_git_environment(),
394
440
  )
395
441
  if diff_process.returncode != 0:
396
442
  return []
@@ -133,3 +133,215 @@ def test_path_contains_glob_metacharacters_accepts_windows_paths_with_parens() -
133
133
  assert common.path_contains_glob_metacharacters("C:/Users/Jon (Admin)/project") is False
134
134
  assert common.path_contains_glob_metacharacters("C:/Projects/a,b/file.py") is False
135
135
 
136
+
137
+ def test_get_mode_to_preserve_returns_existing_file_mode(tmp_path: Path) -> None:
138
+ """When the file exists, the actual filesystem mode must be returned (not the default)."""
139
+ target_path = tmp_path / "settings.json"
140
+ target_path.write_text("{}", encoding="utf-8")
141
+ actual_filesystem_mode = target_path.stat().st_mode & 0o777
142
+ returned_mode = common.get_mode_to_preserve(target_path)
143
+ assert returned_mode == actual_filesystem_mode
144
+
145
+
146
+ def test_get_mode_to_preserve_returns_secure_default_when_file_missing(
147
+ tmp_path: Path,
148
+ ) -> None:
149
+ """A missing settings file must fall back to the secure 0o600 default mode."""
150
+ missing_settings_path = tmp_path / "no_such_file.json"
151
+ returned_mode = common.get_mode_to_preserve(missing_settings_path)
152
+ assert returned_mode == 0o600
153
+
154
+
155
+ def test_write_atomically_with_mode_raises_oserror_when_open_fails(
156
+ tmp_path: Path,
157
+ ) -> None:
158
+ """OSError from os.open must propagate to the caller."""
159
+ target_path = tmp_path / "subdirectory" / "missing" / "settings.json.tmp"
160
+ with pytest.raises(OSError):
161
+ common.write_atomically_with_mode(target_path, "payload", file_mode=0o600)
162
+
163
+
164
+ def test_write_atomically_with_mode_unlinks_temp_when_fdopen_raises(
165
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
166
+ ) -> None:
167
+ """A failure inside os.fdopen must remove the on-disk temp file.
168
+
169
+ The file exists the moment os.open returns; closing only the raw
170
+ descriptor would leave an empty temp sibling on disk after the
171
+ exception propagates to the caller.
172
+ """
173
+
174
+ def failing_fdopen(*_args: object, **_kwargs: object) -> object:
175
+ raise MemoryError("fdopen failure")
176
+
177
+ monkeypatch.setattr(common.os, "fdopen", failing_fdopen)
178
+ target_path = tmp_path / "settings.json.tmp"
179
+ with pytest.raises(MemoryError):
180
+ common.write_atomically_with_mode(target_path, "payload", file_mode=0o600)
181
+ assert not target_path.exists(), (
182
+ "the temp file created by os.open must be unlinked before re-raising"
183
+ )
184
+
185
+
186
+ def test_save_settings_warns_on_stderr_when_temp_unlink_fails(
187
+ tmp_path: Path,
188
+ monkeypatch: pytest.MonkeyPatch,
189
+ capsys: pytest.CaptureFixture[str],
190
+ ) -> None:
191
+ """A swallowed unlink in the finally block must surface to stderr.
192
+
193
+ Forces a write success followed by os.replace failure so the temp file
194
+ survives into the finally branch, then makes Path.unlink raise.
195
+ """
196
+ settings_path = tmp_path / "settings.json"
197
+ settings_path.write_text('{"existing": true}\n', encoding="utf-8")
198
+
199
+ def failing_replace(*_args: object, **_kwargs: object) -> None:
200
+ raise OSError("replace blocked by AV")
201
+
202
+ def failing_unlink(_self: Path, *_args: object, **_kwargs: object) -> None:
203
+ raise PermissionError("temp file held by AV")
204
+
205
+ monkeypatch.setattr(common.os, "replace", failing_replace)
206
+ monkeypatch.setattr(Path, "unlink", failing_unlink)
207
+ with pytest.raises(SystemExit):
208
+ common.save_settings(settings_path, {"new_key": "value"})
209
+ captured = capsys.readouterr()
210
+ assert ".tmp" in captured.err
211
+ assert "PermissionError" in captured.err or "held by AV" in captured.err
212
+
213
+
214
+ def test_save_settings_finally_skips_unlink_when_no_temp_was_created(
215
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
216
+ ) -> None:
217
+ """When this invocation never created the temp file, finally must not unlink it."""
218
+ settings_path = tmp_path / "settings.json"
219
+ settings_path.write_text('{"existing": true}\n', encoding="utf-8")
220
+
221
+ unlink_call_paths: list[Path] = []
222
+ original_unlink = Path.unlink
223
+
224
+ def recording_unlink(unlink_target: Path, *args: object, **kwargs: object) -> None:
225
+ unlink_call_paths.append(unlink_target)
226
+ original_unlink(unlink_target, *args, **kwargs)
227
+
228
+ def write_raises(*_args: object, **_kwargs: object) -> None:
229
+ raise FileExistsError("another writer's temp")
230
+
231
+ monkeypatch.setattr(common, "write_atomically_with_mode", write_raises)
232
+ monkeypatch.setattr(Path, "unlink", recording_unlink)
233
+ with pytest.raises(SystemExit):
234
+ common.save_settings(settings_path, {"new_key": "value"})
235
+ assert not any(
236
+ ".tmp." in each_path.name for each_path in unlink_call_paths
237
+ ), "finally must not unlink a temp file this invocation never created"
238
+
239
+
240
+ def test_exit_with_error_prints_prefixed_message_and_exits_nonzero(
241
+ capsys: pytest.CaptureFixture[str],
242
+ ) -> None:
243
+ with pytest.raises(SystemExit) as raised_exit:
244
+ common.exit_with_error("boom")
245
+ assert raised_exit.value.code == 1
246
+ assert "Error: boom" in capsys.readouterr().err
247
+
248
+
249
+ def test_build_permission_rules_builds_one_rule_per_tool() -> None:
250
+ built_rules = common.build_permission_rules("/proj", ("Edit", "Read"))
251
+ assert built_rules == ["Edit(/proj/.claude/**)", "Read(/proj/.claude/**)"]
252
+
253
+
254
+ def test_build_agent_config_deny_rule_embeds_tool_path_and_pattern() -> None:
255
+ built_rule = common.build_agent_config_deny_rule("Write", "/proj", "hooks/**")
256
+ assert built_rule == "Write(/proj/.claude/hooks/**)"
257
+
258
+
259
+ def test_remove_matching_entries_from_list_filters_in_place_and_counts() -> None:
260
+ all_entries: list[object] = ["keep", "drop", "drop", 7]
261
+ removed_count = common.remove_matching_entries_from_list(
262
+ all_entries, lambda each_entry: each_entry == "drop"
263
+ )
264
+ assert removed_count == 2
265
+ assert all_entries == ["keep", 7]
266
+
267
+
268
+ def test_load_settings_returns_empty_dict_when_file_missing(tmp_path: Path) -> None:
269
+ assert common.load_settings(tmp_path / "absent.json") == {}
270
+
271
+
272
+ def test_load_settings_parses_existing_json_object(tmp_path: Path) -> None:
273
+ settings_path = tmp_path / "settings.json"
274
+ settings_path.write_text('{"permissions": {"allow": []}}', encoding="utf-8")
275
+ assert common.load_settings(settings_path) == {"permissions": {"allow": []}}
276
+
277
+
278
+ def test_load_settings_exits_when_file_holds_invalid_json(
279
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
280
+ ) -> None:
281
+ settings_path = tmp_path / "settings.json"
282
+ settings_path.write_text("not json", encoding="utf-8")
283
+ with pytest.raises(SystemExit):
284
+ common.load_settings(settings_path)
285
+ assert "not valid JSON" in capsys.readouterr().err
286
+
287
+
288
+ def test_serialize_settings_to_json_text_sorts_keys() -> None:
289
+ serialized_text = common.serialize_settings_to_json_text({"b": 1, "a": 2})
290
+ assert serialized_text.index('"a"') < serialized_text.index('"b"')
291
+ assert json.loads(serialized_text) == {"a": 2, "b": 1}
292
+
293
+
294
+ def test_append_if_missing_appends_only_new_values() -> None:
295
+ all_entries: list[object] = ["existing"]
296
+ assert common.append_if_missing(all_entries, "fresh") is True
297
+ assert common.append_if_missing(all_entries, "fresh") is False
298
+ assert all_entries == ["existing", "fresh"]
299
+
300
+
301
+ def test_ensure_dict_section_creates_missing_and_returns_existing() -> None:
302
+ all_settings: dict[str, object] = {}
303
+ created_section = common.ensure_dict_section(all_settings, "permissions")
304
+ assert created_section == {}
305
+ assert all_settings["permissions"] is created_section
306
+ assert common.ensure_dict_section(all_settings, "permissions") is created_section
307
+
308
+
309
+ def test_ensure_dict_section_exits_on_non_dict_value(
310
+ capsys: pytest.CaptureFixture[str],
311
+ ) -> None:
312
+ all_settings: dict[str, object] = {"permissions": "oops"}
313
+ with pytest.raises(SystemExit):
314
+ common.ensure_dict_section(all_settings, "permissions")
315
+ assert "not a JSON object" in capsys.readouterr().err
316
+
317
+
318
+ def test_ensure_list_entry_creates_missing_and_returns_existing() -> None:
319
+ permissions_section: dict[str, object] = {}
320
+ created_entry = common.ensure_list_entry(permissions_section, "allow")
321
+ assert created_entry == []
322
+ assert permissions_section["allow"] is created_entry
323
+ assert common.ensure_list_entry(permissions_section, "allow") is created_entry
324
+
325
+
326
+ def test_ensure_list_entry_exits_on_non_list_value(
327
+ capsys: pytest.CaptureFixture[str],
328
+ ) -> None:
329
+ permissions_section: dict[str, object] = {"allow": {}}
330
+ with pytest.raises(SystemExit):
331
+ common.ensure_list_entry(permissions_section, "allow")
332
+ assert "not a JSON array" in capsys.readouterr().err
333
+
334
+
335
+ def test_prune_empty_list_then_empty_section_removes_empty_structures() -> None:
336
+ all_settings: dict[str, object] = {"permissions": {"allow": []}}
337
+ common.prune_empty_list_then_empty_section(all_settings, "permissions", "allow")
338
+ assert all_settings == {}
339
+
340
+
341
+ def test_prune_empty_list_then_empty_section_keeps_populated_structures() -> None:
342
+ all_settings: dict[str, object] = {
343
+ "permissions": {"allow": ["Edit(/proj/.claude/**)"], "deny": []}
344
+ }
345
+ common.prune_empty_list_then_empty_section(all_settings, "permissions", "deny")
346
+ assert all_settings == {"permissions": {"allow": ["Edit(/proj/.claude/**)"]}}
347
+
@@ -355,6 +355,24 @@ def test_is_trust_entry_rejects_cross_project_path_suffix_collision() -> None:
355
355
  )
356
356
 
357
357
 
358
+ def test_is_valid_project_root_shared_by_grant_and_revoke_modules(
359
+ tmp_path: Path,
360
+ ) -> None:
361
+ """Grant and revoke import the same helper, so both agree on the marker contract."""
362
+ grant_module = _load_grant_module()
363
+ revoke_module = _load_revoke_module()
364
+ git_marker_project_root = tmp_path / "git_project"
365
+ (git_marker_project_root / ".git").mkdir(parents=True)
366
+ claude_marker_project_root = tmp_path / "claude_project"
367
+ (claude_marker_project_root / ".claude").mkdir(parents=True)
368
+ bare_directory = tmp_path / "no_marker"
369
+ bare_directory.mkdir()
370
+ for each_module in (grant_module, revoke_module):
371
+ assert each_module.is_valid_project_root(git_marker_project_root) is True
372
+ assert each_module.is_valid_project_root(claude_marker_project_root) is True
373
+ assert each_module.is_valid_project_root(bare_directory) is False
374
+
375
+
358
376
  def test_second_grant_is_idempotent_when_no_other_settings_changed(
359
377
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
360
378
  ) -> None: