claude-dev-env 1.42.0 → 1.44.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 (208) hide show
  1. package/_shared/pr-loop/scripts/_claude_permissions_common.py +1 -5
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +293 -8
  3. package/_shared/pr-loop/scripts/fix_hookspath.py +96 -5
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +3 -16
  5. package/_shared/pr-loop/scripts/post_audit_thread.py +4 -4
  6. package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/claude_permissions_constants.py +1 -1
  7. package/_shared/pr-loop/scripts/preflight.py +13 -31
  8. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -16
  9. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +3 -16
  10. package/_shared/pr-loop/scripts/tests/conftest.py +1 -51
  11. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +4 -4
  12. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +4 -2
  13. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +4 -2
  14. package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +4 -2
  15. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +4 -2
  16. package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +6 -2
  17. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +2 -2
  18. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +1 -2
  19. package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +4 -2
  20. package/_shared/pr-loop/scripts/tests/test_preflight.py +17 -52
  21. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +6 -2
  22. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +2 -2
  23. package/agents/pr-description-writer.md +50 -140
  24. package/docs/PR_DESCRIPTION_GUIDE.md +101 -102
  25. package/hooks/_gh_pr_author_swap_utils.py +1 -1
  26. package/hooks/blocking/bot_mention_comment_blocker.py +4 -10
  27. package/hooks/blocking/code_rules_enforcer.py +217 -99
  28. package/hooks/blocking/code_rules_path_utils.py +8 -1
  29. package/hooks/blocking/destructive_command_blocker.py +1 -1
  30. package/hooks/blocking/es_exe_path_rewriter.py +7 -13
  31. package/hooks/blocking/gh_body_arg_blocker.py +6 -1
  32. package/hooks/blocking/gh_pr_author_enforcer.py +5 -5
  33. package/hooks/blocking/gh_pr_author_restore.py +5 -5
  34. package/hooks/blocking/hedging_language_blocker.py +4 -10
  35. package/hooks/blocking/md_path_exemptions.py +205 -0
  36. package/hooks/blocking/md_to_html_blocker.py +48 -20
  37. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -11
  38. package/hooks/blocking/pr_description_enforcer.py +626 -41
  39. package/hooks/blocking/question_to_user_enforcer.py +4 -10
  40. package/hooks/blocking/state_description_blocker.py +6 -12
  41. package/hooks/blocking/tdd_enforcer.py +1 -1
  42. package/hooks/blocking/test_bot_mention_comment_blocker.py +1 -1
  43. package/hooks/blocking/test_code_rules_enforcer.py +3 -3
  44. package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +1 -1
  45. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -2
  46. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +184 -0
  47. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +82 -0
  48. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +29 -29
  49. package/hooks/blocking/test_gh_body_arg_blocker.py +7 -8
  50. package/hooks/blocking/test_gh_pr_author_enforcer.py +1 -1
  51. package/hooks/blocking/test_gh_pr_author_restore.py +1 -1
  52. package/hooks/blocking/test_hedging_language_blocker.py +2 -2
  53. package/hooks/blocking/test_md_to_html_blocker.py +463 -8
  54. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +1 -1
  55. package/hooks/blocking/test_pr_description_enforcer.py +1210 -13
  56. package/hooks/blocking/test_question_to_user_enforcer.py +1 -1
  57. package/hooks/blocking/windows_rmtree_blocker.py +5 -11
  58. package/hooks/diagnostic/hook_log_extractor.py +1 -1
  59. package/hooks/diagnostic/hook_log_init.py +1 -1
  60. package/hooks/diagnostic/hook_log_stop_wrapper.py +1 -1
  61. package/hooks/diagnostic/test_hook_log_extractor.py +1 -1
  62. package/hooks/diagnostic/test_hook_log_init.py +2 -2
  63. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +1 -1
  64. package/hooks/git-hooks/gate_utils.py +1 -1
  65. package/hooks/git-hooks/pre_commit.py +1 -1
  66. package/hooks/git-hooks/pre_push.py +1 -1
  67. package/hooks/git-hooks/test_config.py +5 -5
  68. package/hooks/git-hooks/test_pre_push.py +6 -6
  69. package/hooks/{config → hooks_constants}/code_rules_enforcer_constants.py +37 -0
  70. package/hooks/hooks_constants/code_rules_path_utils_constants.py +28 -0
  71. package/hooks/hooks_constants/md_to_html_blocker_constants.py +82 -0
  72. package/hooks/{config → hooks_constants}/pr_converge_bugteam_enforcer_state.py +1 -1
  73. package/hooks/hooks_constants/pr_description_enforcer_constants.py +154 -0
  74. package/hooks/{config → hooks_constants}/pre_tool_use_stdin.py +1 -1
  75. package/hooks/{config → hooks_constants}/project_paths_reader.py +2 -2
  76. package/hooks/{config → hooks_constants}/test_banned_identifiers_constants.py +1 -1
  77. package/hooks/{config → hooks_constants}/test_dynamic_stderr_handler.py +1 -1
  78. package/hooks/{config → hooks_constants}/test_hardcoded_user_path_constants.py +1 -1
  79. package/hooks/{config → hooks_constants}/test_hook_log_extractor_constants.py +2 -2
  80. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +110 -0
  81. package/hooks/{config → hooks_constants}/test_messages.py +2 -6
  82. package/hooks/{config → hooks_constants}/test_path_rewriter_constants.py +1 -1
  83. package/hooks/hooks_constants/test_pr_description_enforcer_constants.py +292 -0
  84. package/hooks/{config → hooks_constants}/test_pre_tool_use_stdin.py +2 -2
  85. package/hooks/{config → hooks_constants}/test_project_paths_reader.py +3 -3
  86. package/hooks/{config → hooks_constants}/test_session_env_cleanup_constants.py +1 -1
  87. package/hooks/{config → hooks_constants}/test_setup_project_paths_constants.py +2 -2
  88. package/hooks/{config → hooks_constants}/test_unused_module_import_constants.py +1 -1
  89. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +5 -11
  90. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +1 -1
  91. package/hooks/session/gh_pr_author_session_cleanup.py +5 -6
  92. package/hooks/session/session_env_cleanup.py +4 -10
  93. package/hooks/session/test_gh_pr_author_session_cleanup.py +1 -1
  94. package/hooks/session/test_untracked_repo_detector.py +2 -2
  95. package/hooks/session/untracked_repo_detector.py +6 -12
  96. package/hooks/test__gh_pr_author_swap_utils.py +1 -1
  97. package/hooks/validators/run_all_validators.py +16 -5
  98. package/hooks/validators/test_output_formatter.py +46 -0
  99. package/hooks/workflow/doc_gist_auto_publish.py +1 -1
  100. package/hooks/workflow/md_to_html_companion.py +8 -15
  101. package/hooks/workflow/test_md_to_html_companion.py +184 -23
  102. package/package.json +1 -1
  103. package/rules/ask-user-question-required.md +1 -1
  104. package/rules/vault-context.md +1 -1
  105. package/scripts/{config → dev_env_scripts_constants}/timing.py +1 -1
  106. package/scripts/setup_project_paths.py +49 -11
  107. package/scripts/sweep_empty_dirs.py +10 -1
  108. package/scripts/test_setup_project_paths.py +2 -2
  109. package/scripts/test_sweep_empty_dirs.py +2 -6
  110. package/skills/_shared/pr-loop/scripts/_path_resolver.py +1 -1
  111. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  112. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  113. package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -1
  114. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -1
  115. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  116. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  117. package/skills/bugteam/PROMPTS.md +1 -1
  118. package/skills/bugteam/SKILL.md +1 -1
  119. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  120. package/skills/bugteam/scripts/{_claude_permissions_common.py → _bugteam_permissions_common.py} +1 -13
  121. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +1 -13
  122. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +1 -16
  123. package/skills/bugteam/scripts/bugteam_preflight.py +1 -13
  124. package/skills/bugteam/scripts/grant_project_claude_permissions.py +2 -8
  125. package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +1 -1
  126. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  127. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +2 -8
  128. package/skills/bugteam/scripts/{test__claude_permissions_common.py → test__bugteam_permissions_common.py} +4 -4
  129. package/skills/bugteam/scripts/test_agent_config_carveout.py +2 -2
  130. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -26
  131. package/skills/bugteam/scripts/{test_claude_permissions_common.py → test_bugteam_permissions_common.py} +3 -66
  132. package/skills/bugteam/scripts/test_bugteam_preflight.py +2 -27
  133. package/skills/bugteam/scripts/windows_safe_rmtree.py +1 -1
  134. package/skills/doc-gist/SKILL.md +1 -1
  135. package/skills/doc-gist/scripts/gist_upload.py +1 -1
  136. package/skills/implement/SKILL.md +2 -2
  137. package/skills/implement/scripts/append_note.py +1 -1
  138. package/skills/pr-converge/pr_converge_skill_constants/__init__.py +0 -0
  139. package/skills/pr-converge/{config → pr_converge_skill_constants}/constants.py +1 -1
  140. package/skills/pr-converge/scripts/check_bugbot_ci.py +1 -1
  141. package/skills/pr-converge/scripts/check_convergence.py +11 -4
  142. package/skills/pr-converge/scripts/check_pending_reviews.py +1 -1
  143. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +1 -1
  144. package/skills/pr-converge/scripts/post_fix_reply.py +1 -1
  145. package/skills/pr-converge/scripts/pr_converge_scripts_constants/__init__.py +0 -0
  146. package/skills/pr-converge/scripts/{config → pr_converge_scripts_constants}/pr_converge_constants.py +1 -1
  147. package/skills/pr-converge/scripts/reflow_skill_md.py +90 -16
  148. package/skills/pr-converge/scripts/test_check_convergence.py +18 -0
  149. package/skills/pr-converge/scripts/test_reflow_skill_md.py +0 -31
  150. package/skills/pre-compact/SKILL.md +114 -0
  151. package/skills/session-log/SKILL.md +98 -233
  152. package/hooks/config/pr_description_enforcer_constants.py +0 -19
  153. package/hooks/config/test_pr_description_enforcer_constants.py +0 -82
  154. package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +0 -55
  155. package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +0 -55
  156. package/skills/pr-converge/scripts/conftest.py +0 -60
  157. package/skills/pr-converge/scripts/evict_cached_config_modules.py +0 -20
  158. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +0 -22
  159. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/__init__.py +0 -0
  160. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/claude_settings_keys_constants.py +0 -0
  161. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/code_rules_gate_constants.py +0 -0
  162. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/fix_hookspath_constants.py +0 -0
  163. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/post_audit_thread_constants.py +0 -0
  164. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/preflight_constants.py +0 -0
  165. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/reviews_disabled_constants.py +0 -0
  166. /package/hooks/git-hooks/{config.py → git_hooks_constants/__init__.py} +0 -0
  167. /package/hooks/{config → hooks_constants}/__init__.py +0 -0
  168. /package/hooks/{config → hooks_constants}/any_type_config.py +0 -0
  169. /package/hooks/{config → hooks_constants}/banned_identifiers_constants.py +0 -0
  170. /package/hooks/{config → hooks_constants}/blocking_check_limits.py +0 -0
  171. /package/hooks/{config → hooks_constants}/bot_mention_comment_blocker_constants.py +0 -0
  172. /package/hooks/{config → hooks_constants}/convergence_branch_constants.py +0 -0
  173. /package/hooks/{config → hooks_constants}/doc_gist_auto_publish_constants.py +0 -0
  174. /package/hooks/{config → hooks_constants}/dynamic_stderr_handler.py +0 -0
  175. /package/hooks/{config → hooks_constants}/gh_pr_author_swap_constants.py +0 -0
  176. /package/hooks/{config → hooks_constants}/hardcoded_user_path_constants.py +0 -0
  177. /package/hooks/{config → hooks_constants}/hook_log_extractor_constants.py +0 -0
  178. /package/hooks/{config → hooks_constants}/html_companion_constants.py +0 -0
  179. /package/hooks/{config → hooks_constants}/inline_tuple_string_magic_constants.py +0 -0
  180. /package/hooks/{config → hooks_constants}/messages.py +0 -0
  181. /package/hooks/{config → hooks_constants}/path_rewriter_constants.py +0 -0
  182. /package/hooks/{config → hooks_constants}/pr_converge_bugteam_enforcer_constants.py +0 -0
  183. /package/hooks/{config → hooks_constants}/session_env_cleanup_constants.py +0 -0
  184. /package/hooks/{config → hooks_constants}/setup_project_paths_constants.py +0 -0
  185. /package/hooks/{config → hooks_constants}/state_description_blocker_constants.py +0 -0
  186. /package/hooks/{config → hooks_constants}/stuttering_check_config.py +0 -0
  187. /package/hooks/{config → hooks_constants}/stuttering_import_binding_constants.py +0 -0
  188. /package/hooks/{config → hooks_constants}/sys_path_insert_constants.py +0 -0
  189. /package/hooks/{config → hooks_constants}/unused_module_import_constants.py +0 -0
  190. /package/hooks/{config → hooks_constants}/windows_rmtree_blocker_constants.py +0 -0
  191. /package/{skills/_shared/pr-loop/scripts/config → hooks/lifecycle}/__init__.py +0 -0
  192. /package/{skills/bugteam/scripts/config → hooks/session}/__init__.py +0 -0
  193. /package/scripts/{config → dev_env_scripts_constants}/__init__.py +0 -0
  194. /package/skills/{doc-gist/scripts/config → _shared/pr-loop/scripts/skills_pr_loop_constants}/__init__.py +0 -0
  195. /package/skills/_shared/pr-loop/scripts/{config → skills_pr_loop_constants}/path_resolver_constants.py +0 -0
  196. /package/skills/{implement/scripts/config → bugteam/scripts/bugteam_scripts_constants}/__init__.py +0 -0
  197. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_code_rules_gate_constants.py +0 -0
  198. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_fix_hookspath_constants.py +0 -0
  199. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_preflight_constants.py +0 -0
  200. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/claude_permissions_common_constants.py +0 -0
  201. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/probe_code_rules_enforcer_check_constants.py +0 -0
  202. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/reflow_skill_md_constants.py +0 -0
  203. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/windows_safe_rmtree_constants.py +0 -0
  204. /package/skills/{pr-converge/config → doc-gist/scripts/doc_gist_scripts_constants}/__init__.py +0 -0
  205. /package/skills/doc-gist/scripts/{config → doc_gist_scripts_constants}/gist_upload_constants.py +0 -0
  206. /package/skills/{pr-converge/scripts/config → implement/scripts/implement_scripts_constants}/__init__.py +0 -0
  207. /package/skills/implement/scripts/{config → implement_scripts_constants}/notes_constants.py +0 -0
  208. /package/skills/pr-converge/scripts/{config → pr_converge_scripts_constants}/reflow_skill_md_constants.py +0 -0
@@ -293,14 +293,25 @@ def run_test_safety_checks(files: List[Path]) -> ValidatorResult:
293
293
 
294
294
 
295
295
  def get_project_root() -> Optional[Path]:
296
- """Get project root by finding git root."""
297
- result = subprocess.run(
298
- ["git", "rev-parse", "--show-toplevel"],
296
+ """Get project root by finding git root.
297
+
298
+ Uses ``git -C <hooks_dir>`` to pin git's working tree to the hooks
299
+ directory without setting the subprocess cwd. On Windows, ``CreateProcess``
300
+ rejects some UNC working directories, so setting ``cwd=hooks_dir`` would
301
+ fail when ``hooks_dir`` resolves to a UNC path. The ``-C`` flag tells git
302
+ to operate as if started in that directory while the subprocess itself
303
+ inherits a normal cwd from the caller. Anchoring git to ``hooks_dir`` is
304
+ required so the lookup resolves to this repo even when the caller's cwd
305
+ points at an unrelated git checkout (e.g., the user's home), avoiding
306
+ validators that ``rglob`` over tens of thousands of unrelated files.
307
+ """
308
+ completed_git_lookup = subprocess.run(
309
+ ["git", "-C", str(hooks_dir), "rev-parse", "--show-toplevel"],
299
310
  capture_output=True,
300
311
  text=True,
301
312
  )
302
- if result.returncode == 0:
303
- return Path(result.stdout.strip())
313
+ if completed_git_lookup.returncode == 0:
314
+ return Path(completed_git_lookup.stdout.strip())
304
315
  return None
305
316
 
306
317
 
@@ -14,6 +14,7 @@ from .output_formatter import (
14
14
  ViolationDict,
15
15
  ValidatorResultDict,
16
16
  )
17
+ from . import run_all_validators
17
18
  from .run_all_validators import run_validators_entrypoint_subprocess
18
19
 
19
20
 
@@ -100,6 +101,51 @@ class TestJsonFlag:
100
101
  assert "results" in parsed
101
102
  assert isinstance(parsed["results"], list)
102
103
 
104
+ def test_get_project_root_anchored_under_unrelated_cwd(
105
+ self, tmp_path, monkeypatch
106
+ ) -> None:
107
+ """``get_project_root`` anchors git to the hooks tree, not the caller cwd.
108
+
109
+ Regression for the defect where ``get_project_root`` ran
110
+ ``git rev-parse --show-toplevel`` without anchoring to the hooks
111
+ directory; under a subprocess fallback cwd outside this repo, git
112
+ returned an unrelated checkout and the File Structure validator
113
+ rglob'd tens of thousands of unrelated files. This test calls the
114
+ helper in-process from a non-git ``tmp_path`` to directly exercise
115
+ the anchoring behavior provided by ``git -C <hooks_dir>``.
116
+ """
117
+ monkeypatch.chdir(tmp_path)
118
+ resolved_project_root = run_all_validators.get_project_root()
119
+
120
+ assert resolved_project_root is not None
121
+ assert resolved_project_root != tmp_path
122
+ hooks_directory_resolved = run_all_validators.hooks_dir.resolve()
123
+ assert hooks_directory_resolved.is_relative_to(resolved_project_root.resolve())
124
+
125
+ def test_file_structure_validator_output_is_bounded(self) -> None:
126
+ """File Structure validator output stays under 10 kB end-to-end.
127
+
128
+ Smoke check that the validators entrypoint subprocess returns
129
+ bounded File Structure output (<10 kB). The unrelated-cwd
130
+ anchoring behavior itself is exercised in-process by
131
+ ``test_get_project_root_anchored_under_unrelated_cwd``; this
132
+ subprocess test verifies the integrated entrypoint stays within
133
+ a bounded output budget. ``run_validators_entrypoint_subprocess``
134
+ sets its own ``cwd`` via
135
+ ``_hooks_subprocess_working_directory_and_environment``, so the
136
+ subprocess cwd is fixed regardless of the test runner's cwd.
137
+ """
138
+ completed_validation_run = run_validators_entrypoint_subprocess(["--json"])
139
+
140
+ parsed = json.loads(completed_validation_run.stdout.strip())
141
+ file_structure_results = [
142
+ each_validator_result
143
+ for each_validator_result in parsed["results"]
144
+ if each_validator_result["name"] == "File Structure"
145
+ ]
146
+ assert len(file_structure_results) == 1
147
+ assert len(file_structure_results[0]["output"]) < 10000
148
+
103
149
 
104
150
  class TestGroupViolationsByFile:
105
151
  def test_groups_violations_by_file_path(self) -> None:
@@ -32,7 +32,7 @@ _hooks_directory = str(Path(__file__).resolve().parent.parent)
32
32
  if _hooks_directory not in sys.path:
33
33
  sys.path.insert(0, _hooks_directory)
34
34
 
35
- from config.doc_gist_auto_publish_constants import ( # noqa: E402
35
+ from hooks_constants.doc_gist_auto_publish_constants import ( # noqa: E402
36
36
  ALL_TARGET_TOOL_NAMES,
37
37
  HOOK_SUBPROCESS_TIMEOUT_SECONDS,
38
38
  HTML_FILE_EXTENSION,
@@ -18,11 +18,15 @@ from urllib.parse import urlparse
18
18
 
19
19
  logging.basicConfig(stream=sys.stderr, level=logging.WARNING, format="%(message)s")
20
20
 
21
- _hook_dir = str(Path(__file__).absolute().parent.parent)
21
+ _hook_dir = str(Path(__file__).resolve().parent.parent)
22
22
  if _hook_dir not in sys.path:
23
23
  sys.path.insert(0, _hook_dir)
24
24
 
25
- from config.html_companion_constants import ( # noqa: E402
25
+ _blocking_dir = str(Path(__file__).resolve().parent.parent / "blocking")
26
+ if _blocking_dir not in sys.path:
27
+ sys.path.insert(0, _blocking_dir)
28
+
29
+ from hooks_constants.html_companion_constants import ( # noqa: E402
26
30
  BLOCKED_URL_SCHEMES,
27
31
  CSS_ACCENT_COLOR,
28
32
  CSS_BG_COLOR,
@@ -41,18 +45,7 @@ from config.html_companion_constants import ( # noqa: E402
41
45
  CSS_TABLE_WIDTH,
42
46
  CSS_TH_WEIGHT,
43
47
  )
44
-
45
-
46
- def _is_exempt_path(file_path: str) -> bool:
47
- normalized = file_path.replace("\\", "/")
48
- if "/.claude/" in normalized or normalized.startswith(".claude/"):
49
- return True
50
- if normalized.startswith("./"):
51
- normalized = normalized[2:]
52
- stripped = normalized.lstrip("/")
53
- if "/" not in stripped:
54
- return stripped.lower() in ("readme.md", "changelog.md")
55
- return False
48
+ from md_path_exemptions import is_exempt_path # noqa: E402
56
49
 
57
50
 
58
51
  def _md_to_html(markdown_text: str) -> str:
@@ -330,7 +323,7 @@ def main() -> None:
330
323
  if not file_path or not file_path.lower().endswith(".md"):
331
324
  sys.exit(0)
332
325
 
333
- if _is_exempt_path(file_path):
326
+ if is_exempt_path(file_path):
334
327
  sys.exit(0)
335
328
 
336
329
  if not os.path.exists(file_path):
@@ -3,18 +3,73 @@
3
3
  This test suite validates that the md-to-html companion hook correctly
4
4
  generates HTML from markdown input, handles edge cases, and produces
5
5
  valid HTML output.
6
+
7
+ Sandbox parent is created lazily by a session-scoped fixture rather than at
8
+ module import time, so test collection has no side effect on the filesystem.
9
+ The sandbox is rooted in a per-session unique directory created via
10
+ `tempfile.mkdtemp` so the OS-temp exemption (which the companion shares with
11
+ the blocker) does not silently skip the hook during tests.
6
12
  """
7
13
 
14
+ import functools
8
15
  import json
9
16
  import os
17
+ import shutil
18
+ import stat
10
19
  import subprocess
11
20
  import sys
12
21
  import tempfile
22
+ from pathlib import Path
23
+
24
+ import pytest
13
25
 
14
26
 
15
27
  HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "md_to_html_companion.py")
16
28
 
17
29
 
30
+ def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):
31
+ try:
32
+ os.chmod(target_path, stat.S_IWRITE)
33
+ removal_function(target_path)
34
+ except OSError:
35
+ pass
36
+
37
+
38
+ def _force_rmtree(target_path: str) -> None:
39
+ handler_kw = (
40
+ {"onexc": _strip_read_only_and_retry}
41
+ if sys.version_info >= (3, 12)
42
+ else {"onerror": _strip_read_only_and_retry}
43
+ )
44
+ try:
45
+ shutil.rmtree(target_path, **handler_kw)
46
+ except OSError:
47
+ pass
48
+
49
+
50
+ @functools.lru_cache(maxsize=1)
51
+ def _get_sandbox_parent_directory() -> str:
52
+ return tempfile.mkdtemp(prefix="pytest_md_companion_", dir=str(Path.home()))
53
+
54
+
55
+ @pytest.fixture(scope="session", autouse=True)
56
+ def _cleanup_sandbox_parent_directory():
57
+ yield
58
+ if _get_sandbox_parent_directory.cache_info().currsize:
59
+ _force_rmtree(_get_sandbox_parent_directory())
60
+ _get_sandbox_parent_directory.cache_clear()
61
+
62
+
63
+ def _make_sandbox() -> tempfile.TemporaryDirectory:
64
+ """Return a TemporaryDirectory rooted outside the OS temp directory.
65
+
66
+ The companion exempts the OS temp directory (mirroring the blocker), so
67
+ the default `tempfile.TemporaryDirectory()` would prevent the test hook
68
+ invocation generating any HTML sidecar at all.
69
+ """
70
+ return tempfile.TemporaryDirectory(dir=_get_sandbox_parent_directory())
71
+
72
+
18
73
  class _RunHook:
19
74
  def __call__(self, tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
20
75
  payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
@@ -31,7 +86,7 @@ _run_hook = _RunHook()
31
86
 
32
87
 
33
88
  def test_generates_html_companion():
34
- with tempfile.TemporaryDirectory() as tmp:
89
+ with _make_sandbox() as tmp:
35
90
  md_path = os.path.join(tmp, "guide.md")
36
91
  html_path = os.path.join(tmp, "guide.html")
37
92
 
@@ -47,7 +102,7 @@ def test_generates_html_companion():
47
102
 
48
103
 
49
104
  def test_html_contains_heading():
50
- with tempfile.TemporaryDirectory() as tmp:
105
+ with _make_sandbox() as tmp:
51
106
  md_path = os.path.join(tmp, "guide.md")
52
107
  with open(md_path, "w", encoding="utf-8") as f:
53
108
  f.write("# Hello World")
@@ -61,7 +116,7 @@ def test_html_contains_heading():
61
116
 
62
117
 
63
118
  def test_html_wraps_in_template():
64
- with tempfile.TemporaryDirectory() as tmp:
119
+ with _make_sandbox() as tmp:
65
120
  md_path = os.path.join(tmp, "guide.md")
66
121
  with open(md_path, "w", encoding="utf-8") as f:
67
122
  f.write("plain text")
@@ -75,7 +130,7 @@ def test_html_wraps_in_template():
75
130
 
76
131
 
77
132
  def test_skips_non_md_files():
78
- with tempfile.TemporaryDirectory() as tmp:
133
+ with _make_sandbox() as tmp:
79
134
  py_path = os.path.join(tmp, "main.py")
80
135
  html_path = os.path.join(tmp, "main.html")
81
136
 
@@ -89,7 +144,7 @@ def test_skips_non_md_files():
89
144
 
90
145
 
91
146
  def test_skips_claude_dir():
92
- with tempfile.TemporaryDirectory() as tmp:
147
+ with _make_sandbox() as tmp:
93
148
  claude_dir = os.path.join(tmp, ".claude")
94
149
  md_path = os.path.join(claude_dir, "CLAUDE.md")
95
150
  html_path = os.path.join(claude_dir, "CLAUDE.html")
@@ -124,7 +179,7 @@ def test_nonexistent_md_passes():
124
179
 
125
180
 
126
181
  def test_converts_code_fence():
127
- with tempfile.TemporaryDirectory() as tmp:
182
+ with _make_sandbox() as tmp:
128
183
  md_path = os.path.join(tmp, "guide.md")
129
184
  with open(md_path, "w", encoding="utf-8") as f:
130
185
  f.write("```python\nprint('hi')\n```")
@@ -141,7 +196,7 @@ def test_converts_code_fence():
141
196
 
142
197
 
143
198
  def test_converts_bold():
144
- with tempfile.TemporaryDirectory() as tmp:
199
+ with _make_sandbox() as tmp:
145
200
  md_path = os.path.join(tmp, "guide.md")
146
201
  with open(md_path, "w", encoding="utf-8") as f:
147
202
  f.write("This is **bold** text.")
@@ -154,7 +209,7 @@ def test_converts_bold():
154
209
 
155
210
 
156
211
  def test_escapes_html_special_chars():
157
- with tempfile.TemporaryDirectory() as tmp:
212
+ with _make_sandbox() as tmp:
158
213
  md_path = os.path.join(tmp, "guide.md")
159
214
  with open(md_path, "w", encoding="utf-8") as f:
160
215
  f.write("Use <div> for layout & choose \"text\" for quotes.")
@@ -175,7 +230,7 @@ def test_escapes_html_special_chars():
175
230
 
176
231
 
177
232
  def test_escapes_code_block_content():
178
- with tempfile.TemporaryDirectory() as tmp:
233
+ with _make_sandbox() as tmp:
179
234
  md_path = os.path.join(tmp, "guide.md")
180
235
  with open(md_path, "w", encoding="utf-8") as f:
181
236
  f.write("```\nif x < 5 and y > 3:\n print('hello')\n```")
@@ -195,7 +250,7 @@ def test_escapes_code_block_content():
195
250
 
196
251
 
197
252
  def test_lists_are_wrapped_in_ul():
198
- with tempfile.TemporaryDirectory() as tmp:
253
+ with _make_sandbox() as tmp:
199
254
  md_path = os.path.join(tmp, "guide.md")
200
255
  with open(md_path, "w", encoding="utf-8") as f:
201
256
  f.write("- item one\n- item two\n- item three")
@@ -217,7 +272,7 @@ def test_lists_are_wrapped_in_ul():
217
272
 
218
273
 
219
274
  def test_ordered_lists_are_wrapped_in_ol():
220
- with tempfile.TemporaryDirectory() as tmp:
275
+ with _make_sandbox() as tmp:
221
276
  md_path = os.path.join(tmp, "guide.md")
222
277
  with open(md_path, "w", encoding="utf-8") as f:
223
278
  f.write("1. first\n2. second")
@@ -234,7 +289,7 @@ def test_ordered_lists_are_wrapped_in_ol():
234
289
 
235
290
 
236
291
  def test_handles_curly_braces_in_body():
237
- with tempfile.TemporaryDirectory() as tmp:
292
+ with _make_sandbox() as tmp:
238
293
  md_path = os.path.join(tmp, "guide.md")
239
294
  with open(md_path, "w", encoding="utf-8") as f:
240
295
  f.write("# JS Example\n\nUse `{ foo: 1 }` in code.")
@@ -255,7 +310,7 @@ def test_handles_curly_braces_in_body():
255
310
 
256
311
 
257
312
  def test_escapes_title_in_html_output():
258
- with tempfile.TemporaryDirectory() as tmp:
313
+ with _make_sandbox() as tmp:
259
314
  md_path = os.path.join(tmp, "guide.md")
260
315
  with open(md_path, "w", encoding="utf-8") as f:
261
316
  f.write("# Hackers <3 Markdown & <scripts>")
@@ -275,7 +330,8 @@ def test_escapes_title_in_html_output():
275
330
 
276
331
 
277
332
  def test_skips_root_readme():
278
- with tempfile.TemporaryDirectory() as tmp:
333
+ with _make_sandbox() as tmp:
334
+ Path(tmp, ".git").touch()
279
335
  original_cwd = os.getcwd()
280
336
  try:
281
337
  os.chdir(tmp)
@@ -293,7 +349,8 @@ def test_skips_root_readme():
293
349
 
294
350
 
295
351
  def test_skips_root_changelog():
296
- with tempfile.TemporaryDirectory() as tmp:
352
+ with _make_sandbox() as tmp:
353
+ Path(tmp, ".git").touch()
297
354
  original_cwd = os.getcwd()
298
355
  try:
299
356
  os.chdir(tmp)
@@ -311,7 +368,7 @@ def test_skips_root_changelog():
311
368
 
312
369
 
313
370
  def test_language_class_valid():
314
- with tempfile.TemporaryDirectory() as tmp:
371
+ with _make_sandbox() as tmp:
315
372
  md_path = os.path.join(tmp, "guide.md")
316
373
  with open(md_path, "w", encoding="utf-8") as f:
317
374
  f.write("```python\nx = 1\n```")
@@ -324,7 +381,7 @@ def test_language_class_valid():
324
381
 
325
382
 
326
383
  def test_language_class_skips_invalid():
327
- with tempfile.TemporaryDirectory() as tmp:
384
+ with _make_sandbox() as tmp:
328
385
  md_path = os.path.join(tmp, "guide.md")
329
386
  with open(md_path, "w", encoding="utf-8") as f:
330
387
  f.write("```my lang\nx = 1\n```")
@@ -338,7 +395,7 @@ def test_language_class_skips_invalid():
338
395
 
339
396
 
340
397
  def test_language_class_allows_valid_chars():
341
- with tempfile.TemporaryDirectory() as tmp:
398
+ with _make_sandbox() as tmp:
342
399
  md_path = os.path.join(tmp, "guide.md")
343
400
  with open(md_path, "w", encoding="utf-8") as f:
344
401
  f.write("```c++\nint x = 1;\n```")
@@ -351,7 +408,7 @@ def test_language_class_allows_valid_chars():
351
408
 
352
409
 
353
410
  def test_link_text_asterisks_remain_literal():
354
- with tempfile.TemporaryDirectory() as tmp:
411
+ with _make_sandbox() as tmp:
355
412
  md_path = os.path.join(tmp, "guide.md")
356
413
  with open(md_path, "w", encoding="utf-8") as f:
357
414
  f.write("See [text *not italic*](url).")
@@ -368,7 +425,7 @@ def test_link_text_asterisks_remain_literal():
368
425
 
369
426
 
370
427
  def test_handles_parentheses_in_links():
371
- with tempfile.TemporaryDirectory() as tmp:
428
+ with _make_sandbox() as tmp:
372
429
  md_path = os.path.join(tmp, "guide.md")
373
430
  with open(md_path, "w", encoding="utf-8") as f:
374
431
  f.write(
@@ -394,7 +451,7 @@ def test_handles_parentheses_in_links():
394
451
 
395
452
 
396
453
  def test_does_not_skip_nested_readme():
397
- with tempfile.TemporaryDirectory() as tmp:
454
+ with _make_sandbox() as tmp:
398
455
  nested_dir = os.path.join(tmp, "docs")
399
456
  os.makedirs(nested_dir)
400
457
  md_path = os.path.join(nested_dir, "README.md")
@@ -412,7 +469,7 @@ def test_does_not_skip_nested_readme():
412
469
 
413
470
 
414
471
  def test_inline_code_preserves_asterisks():
415
- with tempfile.TemporaryDirectory() as tmp:
472
+ with _make_sandbox() as tmp:
416
473
  md_path = os.path.join(tmp, "guide.md")
417
474
  with open(md_path, "w", encoding="utf-8") as f:
418
475
  f.write("Type `**bold**` in a docstring.")
@@ -432,7 +489,7 @@ def test_inline_code_preserves_asterisks():
432
489
 
433
490
 
434
491
  def test_blocks_javascript_url_scheme():
435
- with tempfile.TemporaryDirectory() as tmp:
492
+ with _make_sandbox() as tmp:
436
493
  md_path = os.path.join(tmp, "guide.md")
437
494
  with open(md_path, "w", encoding="utf-8") as f:
438
495
  f.write("[click me](javascript:alert(1))")
@@ -450,3 +507,107 @@ def test_blocks_javascript_url_scheme():
450
507
  assert "javascript:" not in html
451
508
  assert "click me" in html
452
509
  assert "<a" not in html
510
+
511
+
512
+ def test_companion_skips_home_session_log_directory(tmp_path, monkeypatch):
513
+ synthetic_home_directory = tmp_path / "synthetic_home"
514
+ synthetic_home_directory.mkdir()
515
+ monkeypatch.setenv("HOME", str(synthetic_home_directory))
516
+ monkeypatch.setenv("USERPROFILE", str(synthetic_home_directory))
517
+ session_log_directory = synthetic_home_directory / "SessionLog" / "decisions"
518
+ session_log_directory.mkdir(parents=True)
519
+ md_path = str(session_log_directory / "companion_exempt_test.md")
520
+ html_path = str(session_log_directory / "companion_exempt_test.html")
521
+ with open(md_path, "w", encoding="utf-8") as f:
522
+ f.write("# Note")
523
+ result = _run_hook(
524
+ "Write",
525
+ {"file_path": md_path, "content": "# Note"},
526
+ )
527
+ assert result.returncode == 0
528
+ assert not os.path.exists(html_path)
529
+
530
+
531
+ def test_companion_skips_skill_md_anywhere():
532
+ with _make_sandbox() as tmp:
533
+ nested_directory = os.path.join(tmp, "packages", "dev-env", "skills", "foo")
534
+ os.makedirs(nested_directory, exist_ok=True)
535
+ md_path = os.path.join(nested_directory, "SKILL.md")
536
+ html_path = os.path.join(nested_directory, "SKILL.html")
537
+ with open(md_path, "w", encoding="utf-8") as f:
538
+ f.write("# Skill")
539
+ result = _run_hook(
540
+ "Write",
541
+ {"file_path": md_path, "content": "# Skill"},
542
+ )
543
+ assert result.returncode == 0
544
+ assert not os.path.exists(html_path)
545
+
546
+
547
+ def test_companion_skips_agents_directory_anywhere():
548
+ with _make_sandbox() as tmp:
549
+ agents_directory = os.path.join(tmp, "packages", "dev-env", "agents")
550
+ os.makedirs(agents_directory, exist_ok=True)
551
+ md_path = os.path.join(agents_directory, "pr-description-writer.md")
552
+ html_path = os.path.join(
553
+ agents_directory, "pr-description-writer.html"
554
+ )
555
+ with open(md_path, "w", encoding="utf-8") as f:
556
+ f.write("# Agent")
557
+ result = _run_hook(
558
+ "Write",
559
+ {"file_path": md_path, "content": "# Agent"},
560
+ )
561
+ assert result.returncode == 0
562
+ assert not os.path.exists(html_path)
563
+
564
+
565
+ def test_companion_skips_claude_plugin_directory():
566
+ with _make_sandbox() as tmp:
567
+ plugin_directory = os.path.join(tmp, ".claude-plugin")
568
+ os.makedirs(plugin_directory, exist_ok=True)
569
+ md_path = os.path.join(plugin_directory, "manifest.md")
570
+ html_path = os.path.join(plugin_directory, "manifest.html")
571
+ with open(md_path, "w", encoding="utf-8") as f:
572
+ f.write("# Manifest")
573
+ result = _run_hook(
574
+ "Write",
575
+ {"file_path": md_path, "content": "# Manifest"},
576
+ )
577
+ assert result.returncode == 0
578
+ assert not os.path.exists(html_path)
579
+
580
+
581
+ def test_companion_still_fires_for_ordinary_docs_md_file():
582
+ with _make_sandbox() as tmp:
583
+ docs_directory = os.path.join(tmp, "docs")
584
+ os.makedirs(docs_directory, exist_ok=True)
585
+ md_path = os.path.join(docs_directory, "regular.md")
586
+ html_path = os.path.join(docs_directory, "regular.html")
587
+ with open(md_path, "w", encoding="utf-8") as f:
588
+ f.write("# Regular")
589
+ result = _run_hook(
590
+ "Write",
591
+ {"file_path": md_path, "content": "# Regular"},
592
+ )
593
+ assert result.returncode == 0
594
+ assert os.path.exists(html_path)
595
+
596
+
597
+ def test_companion_skips_system_temp_directory():
598
+ temp_directory = tempfile.gettempdir()
599
+ md_path = os.path.join(temp_directory, "companion_temp_exempt_test.md")
600
+ html_path = os.path.join(temp_directory, "companion_temp_exempt_test.html")
601
+ try:
602
+ with open(md_path, "w", encoding="utf-8") as f:
603
+ f.write("# Scratch")
604
+ result = _run_hook(
605
+ "Write",
606
+ {"file_path": md_path, "content": "# Scratch"},
607
+ )
608
+ assert result.returncode == 0
609
+ assert not os.path.exists(html_path)
610
+ finally:
611
+ for each_path in (md_path, html_path):
612
+ if os.path.exists(each_path):
613
+ os.remove(each_path)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.42.0",
3
+ "version": "1.44.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,5 +40,5 @@ When a question is genuinely for the user, call the tool with:
40
40
 
41
41
  - Hook: `packages/claude-dev-env/hooks/blocking/question_to_user_enforcer.py`, registered on the `Stop` matcher in `packages/claude-dev-env/hooks/hooks.json`.
42
42
  - Loop prevention: the hook honors Claude Code's `stop_hook_active` flag and does not re-block on retry.
43
- - User-facing notice: `USER_FACING_ASKUSERQUESTION_NOTICE` in `packages/claude-dev-env/hooks/config/messages.py`.
43
+ - User-facing notice: `USER_FACING_ASKUSERQUESTION_NOTICE` in `packages/claude-dev-env/hooks/hooks_constants/messages.py`.
44
44
  - Related rule: `packages/claude-dev-env/rules/verify-before-asking.md` gates whether the question belongs to the user in the first place.
@@ -10,7 +10,7 @@ An Obsidian vault stores session reports, decisions, and research documents acro
10
10
 
11
11
  ## Vault Structure
12
12
 
13
- - `sessions/` -- session reports with frontmatter: `type: session-report`, `project`, `session`, `date`, `status`, `blocked`, `tags`
13
+ - `sessions/` -- session reports with frontmatter: `type: session-report`, `project`, `session`, `date`, `status`, `blocked`, `vault_context_retrieved`, `tags`
14
14
  - `decisions/` -- decision notes with frontmatter: `type: decision|procedural|fact|gotcha`, `project`, `date`, `status: Active|Superseded`, `tags`
15
15
  - `Research/` -- deep research documents
16
16
 
@@ -1,7 +1,7 @@
1
1
  """Timing constants for sweep_empty_dirs.
2
2
 
3
3
  Per the project's configuration conventions, timeouts, delays, and retries
4
- live in config/timing.py.
4
+ live in dev_env_scripts_constants/timing.py.
5
5
  """
6
6
 
7
7
  DEFAULT_AGE_SECONDS: int = 120
@@ -18,17 +18,12 @@ import subprocess
18
18
  import sys
19
19
  from pathlib import Path
20
20
 
21
- _hooks_dir_string = str(Path(__file__).resolve().parent.parent / "hooks")
22
- while _hooks_dir_string in sys.path:
23
- sys.path.remove(_hooks_dir_string)
24
- sys.path.insert(0, _hooks_dir_string)
21
+ _hooks_dir = str(Path(__file__).resolve().parent.parent / "hooks")
22
+ if _hooks_dir not in sys.path:
23
+ sys.path.insert(0, _hooks_dir)
25
24
 
26
- for _cached_module_name in list(sys.modules):
27
- if _cached_module_name == "config" or _cached_module_name.startswith("config."):
28
- del sys.modules[_cached_module_name]
29
-
30
- from config.project_paths_reader import registry_file_path
31
- from config.setup_project_paths_constants import (
25
+ from hooks_constants.project_paths_reader import registry_file_path # noqa: E402
26
+ from hooks_constants.setup_project_paths_constants import ( # noqa: E402
32
27
  ABORTED_NOTHING_WRITTEN_MESSAGE,
33
28
  CONFIRMATION_PROMPT_TEXT,
34
29
  ES_EXE_BINARY_NAME,
@@ -85,6 +80,13 @@ def filter_to_git_roots(all_es_exe_paths: list[str]) -> list[str]:
85
80
 
86
81
  Rejects siblings like ``.gitignore``, ``.github``, ``.gitattributes`` that
87
82
  share the ``.git`` prefix but are not the canonical git metadata directory.
83
+
84
+ Args:
85
+ all_es_exe_paths: Raw folder paths emitted by es.exe.
86
+
87
+ Returns:
88
+ Parent directory paths of every entry whose final segment is exactly
89
+ ``.git``.
88
90
  """
89
91
  all_repo_roots: list[str] = []
90
92
  for each_es_path in all_es_exe_paths:
@@ -102,6 +104,12 @@ def apply_exclusion_filter(all_candidate_paths: list[str]) -> list[str]:
102
104
  Whole-segment matching preserves legitimate names that merely contain an
103
105
  excluded substring (for example ``template`` is retained even though
104
106
  ``temp`` is excluded).
107
+
108
+ Args:
109
+ all_candidate_paths: Repo-root paths discovered by es.exe.
110
+
111
+ Returns:
112
+ Subset of *all_candidate_paths* with no segment in the exclusion set.
105
113
  """
106
114
  all_retained_paths: list[str] = []
107
115
  for each_candidate_path in all_candidate_paths:
@@ -134,6 +142,13 @@ def merge_registries(
134
142
  Pre-existing entries not in the new set are preserved. On name collisions
135
143
  the newly discovered entry wins. The ``_meta.last_scan`` timestamp is
136
144
  refreshed to the current UTC time.
145
+
146
+ Args:
147
+ existing_registry: Existing on-disk registry contents.
148
+ new_path_by_name: Newly discovered name-to-path entries.
149
+
150
+ Returns:
151
+ Merged registry with refreshed ``_meta.last_scan`` timestamp.
137
152
  """
138
153
  merged_registry: dict = {
139
154
  each_key: each_value
@@ -191,6 +206,11 @@ def write_registry_atomically(registry_to_write: dict, target_file: Path) -> Non
191
206
  Caller is responsible for reading the existing registry, verifying the
192
207
  schema version, and merging before calling this function. This function
193
208
  performs no file reads and no schema checks.
209
+
210
+ Args:
211
+ registry_to_write: Registry content to serialize.
212
+ target_file: Final destination path; the temp sibling is created
213
+ adjacent and renamed over the destination.
194
214
  """
195
215
  target_file.parent.mkdir(parents=True, exist_ok=True)
196
216
  temp_suffix = ".tmp"
@@ -228,7 +248,11 @@ def _run_es_exe_folders_query() -> list[str]:
228
248
 
229
249
 
230
250
  def discover_repo_roots_via_everything() -> list[str]:
231
- """Run es.exe, filter to genuine git roots, deduplicate, and sort."""
251
+ """Run es.exe, filter to genuine git roots, deduplicate, and sort.
252
+
253
+ Returns:
254
+ Deduplicated, sorted list of repo-root paths discovered by es.exe.
255
+ """
232
256
  all_raw_paths = _run_es_exe_folders_query()
233
257
  all_git_roots = filter_to_git_roots(all_raw_paths)
234
258
  all_included = apply_exclusion_filter(all_git_roots)
@@ -291,6 +315,11 @@ def prompt_and_write(
291
315
 
292
316
  Reads and validates the existing registry BEFORE prompting so the user
293
317
  learns of any schema or read error early. Declining writes nothing.
318
+
319
+ Args:
320
+ path_by_name: Proposed name-to-path mapping to present to the user.
321
+ save_path: Registry destination path used for the schema check
322
+ and atomic write target.
294
323
  """
295
324
  existing_registry = _load_and_validate_registry(save_path)
296
325
  _display_proposed_mapping(path_by_name, save_path)
@@ -319,6 +348,15 @@ def _build_path_by_name_from_roots(all_repo_roots: list[str]) -> dict[str, str]:
319
348
 
320
349
 
321
350
  def main() -> int:
351
+ """Run the discovery, prompt, and atomic-write flow for the registry.
352
+
353
+ Returns:
354
+ ``0`` on success or when no candidate repositories were found.
355
+
356
+ Raises:
357
+ SystemExit: When es.exe scan fails or the existing registry is
358
+ malformed.
359
+ """
322
360
  if not _everything_binary_is_available():
323
361
  print(
324
362
  f"ERROR: {ES_EXE_BINARY_NAME} not found on PATH. Install Everything "