claude-dev-env 1.41.0 → 1.43.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 (214) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +232 -8
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +293 -8
  4. package/_shared/pr-loop/scripts/fix_hookspath.py +96 -5
  5. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +124 -20
  6. package/_shared/pr-loop/scripts/post_audit_thread.py +4 -4
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +90 -0
  8. package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/claude_settings_keys_constants.py +2 -0
  9. package/_shared/pr-loop/scripts/preflight.py +13 -31
  10. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -16
  11. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +76 -33
  12. package/_shared/pr-loop/scripts/tests/conftest.py +1 -51
  13. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +385 -0
  14. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +4 -2
  15. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +37 -2
  16. package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +4 -2
  17. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +4 -2
  18. package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +6 -2
  19. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +2 -2
  20. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +1 -2
  21. package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +4 -2
  22. package/_shared/pr-loop/scripts/tests/test_preflight.py +17 -52
  23. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +6 -2
  24. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +5 -3
  25. package/agents/pr-description-writer.md +50 -140
  26. package/docs/PR_DESCRIPTION_GUIDE.md +101 -102
  27. package/hooks/_gh_pr_author_swap_utils.py +1 -1
  28. package/hooks/blocking/bot_mention_comment_blocker.py +4 -10
  29. package/hooks/blocking/code_rules_enforcer.py +217 -99
  30. package/hooks/blocking/code_rules_path_utils.py +8 -1
  31. package/hooks/blocking/destructive_command_blocker.py +1 -1
  32. package/hooks/blocking/es_exe_path_rewriter.py +7 -13
  33. package/hooks/blocking/gh_body_arg_blocker.py +6 -1
  34. package/hooks/blocking/gh_pr_author_enforcer.py +5 -5
  35. package/hooks/blocking/gh_pr_author_restore.py +5 -5
  36. package/hooks/blocking/hedging_language_blocker.py +4 -10
  37. package/hooks/blocking/md_path_exemptions.py +205 -0
  38. package/hooks/blocking/md_to_html_blocker.py +48 -20
  39. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -11
  40. package/hooks/blocking/pr_description_enforcer.py +626 -41
  41. package/hooks/blocking/question_to_user_enforcer.py +4 -10
  42. package/hooks/blocking/state_description_blocker.py +6 -12
  43. package/hooks/blocking/tdd_enforcer.py +1 -1
  44. package/hooks/blocking/test_bot_mention_comment_blocker.py +1 -1
  45. package/hooks/blocking/test_code_rules_enforcer.py +3 -3
  46. package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +1 -1
  47. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -2
  48. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +184 -0
  49. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +82 -0
  50. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +29 -29
  51. package/hooks/blocking/test_gh_body_arg_blocker.py +7 -8
  52. package/hooks/blocking/test_gh_pr_author_enforcer.py +1 -1
  53. package/hooks/blocking/test_gh_pr_author_restore.py +1 -1
  54. package/hooks/blocking/test_hedging_language_blocker.py +2 -2
  55. package/hooks/blocking/test_md_to_html_blocker.py +463 -8
  56. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +1 -1
  57. package/hooks/blocking/test_pr_description_enforcer.py +1210 -13
  58. package/hooks/blocking/test_question_to_user_enforcer.py +1 -1
  59. package/hooks/blocking/windows_rmtree_blocker.py +5 -11
  60. package/hooks/diagnostic/hook_log_extractor.py +1 -1
  61. package/hooks/diagnostic/hook_log_init.py +1 -1
  62. package/hooks/diagnostic/hook_log_stop_wrapper.py +1 -1
  63. package/hooks/diagnostic/test_hook_log_extractor.py +1 -1
  64. package/hooks/diagnostic/test_hook_log_init.py +2 -2
  65. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +1 -1
  66. package/hooks/git-hooks/gate_utils.py +1 -1
  67. package/hooks/git-hooks/pre_commit.py +1 -1
  68. package/hooks/git-hooks/pre_push.py +1 -1
  69. package/hooks/git-hooks/test_config.py +5 -5
  70. package/hooks/git-hooks/test_pre_push.py +6 -6
  71. package/hooks/{config → hooks_constants}/code_rules_enforcer_constants.py +37 -0
  72. package/hooks/hooks_constants/code_rules_path_utils_constants.py +28 -0
  73. package/hooks/hooks_constants/md_to_html_blocker_constants.py +82 -0
  74. package/hooks/{config → hooks_constants}/pr_converge_bugteam_enforcer_state.py +1 -1
  75. package/hooks/hooks_constants/pr_description_enforcer_constants.py +154 -0
  76. package/hooks/{config → hooks_constants}/pre_tool_use_stdin.py +1 -1
  77. package/hooks/{config → hooks_constants}/project_paths_reader.py +2 -2
  78. package/hooks/{config → hooks_constants}/test_banned_identifiers_constants.py +1 -1
  79. package/hooks/{config → hooks_constants}/test_dynamic_stderr_handler.py +1 -1
  80. package/hooks/{config → hooks_constants}/test_hardcoded_user_path_constants.py +1 -1
  81. package/hooks/{config → hooks_constants}/test_hook_log_extractor_constants.py +2 -2
  82. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +110 -0
  83. package/hooks/{config → hooks_constants}/test_messages.py +2 -6
  84. package/hooks/{config → hooks_constants}/test_path_rewriter_constants.py +1 -1
  85. package/hooks/hooks_constants/test_pr_description_enforcer_constants.py +292 -0
  86. package/hooks/{config → hooks_constants}/test_pre_tool_use_stdin.py +2 -2
  87. package/hooks/{config → hooks_constants}/test_project_paths_reader.py +3 -3
  88. package/hooks/{config → hooks_constants}/test_session_env_cleanup_constants.py +1 -1
  89. package/hooks/{config → hooks_constants}/test_setup_project_paths_constants.py +2 -2
  90. package/hooks/{config → hooks_constants}/test_unused_module_import_constants.py +1 -1
  91. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +5 -11
  92. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +1 -1
  93. package/hooks/session/gh_pr_author_session_cleanup.py +5 -6
  94. package/hooks/session/session_env_cleanup.py +4 -10
  95. package/hooks/session/test_gh_pr_author_session_cleanup.py +1 -1
  96. package/hooks/session/test_untracked_repo_detector.py +2 -2
  97. package/hooks/session/untracked_repo_detector.py +6 -12
  98. package/hooks/test__gh_pr_author_swap_utils.py +1 -1
  99. package/hooks/validators/run_all_validators.py +16 -5
  100. package/hooks/validators/test_output_formatter.py +46 -0
  101. package/hooks/workflow/doc_gist_auto_publish.py +1 -1
  102. package/hooks/workflow/md_to_html_companion.py +8 -15
  103. package/hooks/workflow/test_md_to_html_companion.py +184 -23
  104. package/package.json +1 -1
  105. package/rules/ask-user-question-required.md +1 -1
  106. package/rules/vault-context.md +1 -1
  107. package/scripts/{config → dev_env_scripts_constants}/timing.py +1 -1
  108. package/scripts/setup_project_paths.py +49 -11
  109. package/scripts/sweep_empty_dirs.py +10 -1
  110. package/scripts/test_setup_project_paths.py +2 -2
  111. package/scripts/test_sweep_empty_dirs.py +2 -6
  112. package/skills/_shared/pr-loop/scripts/_path_resolver.py +1 -1
  113. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  114. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  115. package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -1
  116. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -1
  117. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  118. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  119. package/skills/bugteam/PROMPTS.md +1 -1
  120. package/skills/bugteam/SKILL.md +1 -1
  121. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  122. package/skills/bugteam/scripts/{_claude_permissions_common.py → _bugteam_permissions_common.py} +110 -13
  123. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +1 -13
  124. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +1 -16
  125. package/skills/bugteam/scripts/bugteam_preflight.py +1 -13
  126. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +69 -0
  127. package/skills/bugteam/scripts/grant_project_claude_permissions.py +117 -12
  128. package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +1 -1
  129. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  130. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +71 -25
  131. package/skills/bugteam/scripts/{test__claude_permissions_common.py → test__bugteam_permissions_common.py} +4 -4
  132. package/skills/bugteam/scripts/test_agent_config_carveout.py +356 -0
  133. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -26
  134. package/skills/bugteam/scripts/{test_claude_permissions_common.py → test_bugteam_permissions_common.py} +3 -66
  135. package/skills/bugteam/scripts/test_bugteam_preflight.py +2 -27
  136. package/skills/bugteam/scripts/windows_safe_rmtree.py +1 -1
  137. package/skills/doc-gist/SKILL.md +1 -1
  138. package/skills/doc-gist/scripts/gist_upload.py +1 -1
  139. package/skills/implement/SKILL.md +66 -0
  140. package/skills/implement/scripts/append_note.py +133 -0
  141. package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
  142. package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +12 -0
  143. package/skills/implement/scripts/test_append_note.py +191 -0
  144. package/skills/pr-converge/pr_converge_skill_constants/__init__.py +0 -0
  145. package/skills/pr-converge/{config → pr_converge_skill_constants}/constants.py +6 -1
  146. package/skills/pr-converge/scripts/check_bugbot_ci.py +2 -2
  147. package/skills/pr-converge/scripts/check_convergence.py +175 -29
  148. package/skills/pr-converge/scripts/check_pending_reviews.py +2 -2
  149. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +2 -2
  150. package/skills/pr-converge/scripts/post_fix_reply.py +2 -2
  151. package/skills/pr-converge/scripts/pr_converge_scripts_constants/__init__.py +0 -0
  152. package/skills/pr-converge/scripts/{config → pr_converge_scripts_constants}/pr_converge_constants.py +1 -1
  153. package/skills/pr-converge/scripts/reflow_skill_md.py +90 -16
  154. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +1 -1
  155. package/skills/pr-converge/scripts/test_check_convergence.py +324 -0
  156. package/skills/pr-converge/scripts/test_reflow_skill_md.py +0 -31
  157. package/skills/refine/SKILL.md +257 -0
  158. package/skills/refine/templates/implementation-notes-template.html +56 -0
  159. package/skills/refine/templates/plan-template.md +60 -0
  160. package/skills/session-log/SKILL.md +98 -233
  161. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +0 -36
  162. package/hooks/config/pr_description_enforcer_constants.py +0 -19
  163. package/hooks/config/test_pr_description_enforcer_constants.py +0 -82
  164. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +0 -20
  165. package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +0 -55
  166. package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +0 -55
  167. package/skills/pr-converge/scripts/evict_cached_config_modules.py +0 -20
  168. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +0 -22
  169. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/__init__.py +0 -0
  170. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/code_rules_gate_constants.py +0 -0
  171. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/fix_hookspath_constants.py +0 -0
  172. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/post_audit_thread_constants.py +0 -0
  173. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/preflight_constants.py +0 -0
  174. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/reviews_disabled_constants.py +0 -0
  175. /package/hooks/git-hooks/{config.py → git_hooks_constants/__init__.py} +0 -0
  176. /package/hooks/{config → hooks_constants}/__init__.py +0 -0
  177. /package/hooks/{config → hooks_constants}/any_type_config.py +0 -0
  178. /package/hooks/{config → hooks_constants}/banned_identifiers_constants.py +0 -0
  179. /package/hooks/{config → hooks_constants}/blocking_check_limits.py +0 -0
  180. /package/hooks/{config → hooks_constants}/bot_mention_comment_blocker_constants.py +0 -0
  181. /package/hooks/{config → hooks_constants}/convergence_branch_constants.py +0 -0
  182. /package/hooks/{config → hooks_constants}/doc_gist_auto_publish_constants.py +0 -0
  183. /package/hooks/{config → hooks_constants}/dynamic_stderr_handler.py +0 -0
  184. /package/hooks/{config → hooks_constants}/gh_pr_author_swap_constants.py +0 -0
  185. /package/hooks/{config → hooks_constants}/hardcoded_user_path_constants.py +0 -0
  186. /package/hooks/{config → hooks_constants}/hook_log_extractor_constants.py +0 -0
  187. /package/hooks/{config → hooks_constants}/html_companion_constants.py +0 -0
  188. /package/hooks/{config → hooks_constants}/inline_tuple_string_magic_constants.py +0 -0
  189. /package/hooks/{config → hooks_constants}/messages.py +0 -0
  190. /package/hooks/{config → hooks_constants}/path_rewriter_constants.py +0 -0
  191. /package/hooks/{config → hooks_constants}/pr_converge_bugteam_enforcer_constants.py +0 -0
  192. /package/hooks/{config → hooks_constants}/session_env_cleanup_constants.py +0 -0
  193. /package/hooks/{config → hooks_constants}/setup_project_paths_constants.py +0 -0
  194. /package/hooks/{config → hooks_constants}/state_description_blocker_constants.py +0 -0
  195. /package/hooks/{config → hooks_constants}/stuttering_check_config.py +0 -0
  196. /package/hooks/{config → hooks_constants}/stuttering_import_binding_constants.py +0 -0
  197. /package/hooks/{config → hooks_constants}/sys_path_insert_constants.py +0 -0
  198. /package/hooks/{config → hooks_constants}/unused_module_import_constants.py +0 -0
  199. /package/hooks/{config → hooks_constants}/windows_rmtree_blocker_constants.py +0 -0
  200. /package/{skills/_shared/pr-loop/scripts/config → hooks/lifecycle}/__init__.py +0 -0
  201. /package/{skills/bugteam/scripts/config → hooks/session}/__init__.py +0 -0
  202. /package/scripts/{config → dev_env_scripts_constants}/__init__.py +0 -0
  203. /package/skills/{doc-gist/scripts/config → _shared/pr-loop/scripts/skills_pr_loop_constants}/__init__.py +0 -0
  204. /package/skills/_shared/pr-loop/scripts/{config → skills_pr_loop_constants}/path_resolver_constants.py +0 -0
  205. /package/skills/{pr-converge/config → bugteam/scripts/bugteam_scripts_constants}/__init__.py +0 -0
  206. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_code_rules_gate_constants.py +0 -0
  207. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_fix_hookspath_constants.py +0 -0
  208. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_preflight_constants.py +0 -0
  209. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/probe_code_rules_enforcer_check_constants.py +0 -0
  210. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/reflow_skill_md_constants.py +0 -0
  211. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/windows_safe_rmtree_constants.py +0 -0
  212. /package/skills/{pr-converge/scripts/config → doc-gist/scripts/doc_gist_scripts_constants}/__init__.py +0 -0
  213. /package/skills/doc-gist/scripts/{config → doc_gist_scripts_constants}/gist_upload_constants.py +0 -0
  214. /package/skills/pr-converge/scripts/{config → pr_converge_scripts_constants}/reflow_skill_md_constants.py +0 -0
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+ """Append an entry to implementation-notes.html under one of four sections.
3
+
4
+ Used by the `implement` skill. Creates the file with all four sections if it
5
+ does not exist; otherwise appends a new <li> under the requested section.
6
+
7
+ Usage:
8
+ python append_note.py --section decisions --about "Where to write the file" --note "Wrote next to spec rather than CWD because spec path was known."
9
+ python append_note.py --section questions --about "Auth model" --note "Spec didn't say whether sessions persist across restarts." --file ./notes.html
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import html
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from implement_scripts_constants.notes_constants import DEFAULT_NOTES_FILENAME, HEADING_BY_SLUG
20
+
21
+
22
+ def _build_skeleton() -> str:
23
+ section_blocks = "\n".join(
24
+ f' <section id="{each_slug}">\n <h2>{each_heading}</h2>\n <ul></ul>\n </section>'
25
+ for each_slug, each_heading in HEADING_BY_SLUG.items()
26
+ )
27
+ return (
28
+ "<!doctype html>\n"
29
+ '<html lang="en">\n'
30
+ "<head>\n"
31
+ ' <meta charset="utf-8">\n'
32
+ " <title>Implementation notes</title>\n"
33
+ "</head>\n"
34
+ "<body>\n"
35
+ " <h1>Implementation notes</h1>\n"
36
+ f"{section_blocks}\n"
37
+ "</body>\n"
38
+ "</html>\n"
39
+ )
40
+
41
+
42
+ def _ensure_file(target: Path) -> str:
43
+ if not target.exists():
44
+ target.parent.mkdir(parents=True, exist_ok=True)
45
+ skeleton = _build_skeleton()
46
+ target.write_text(skeleton, encoding="utf-8")
47
+ return skeleton
48
+ return target.read_text(encoding="utf-8")
49
+
50
+
51
+ def _render_entry(about: str, note: str) -> str:
52
+ return f"<li><strong>{html.escape(about)}:</strong> {html.escape(note)}</li>"
53
+
54
+
55
+ def _insert_entry(document: str, slug: str, entry: str) -> str:
56
+ open_marker = f'<section id="{slug}">'
57
+ section_close_marker = "</section>"
58
+ close_marker = "</ul>"
59
+ section_start = document.find(open_marker)
60
+ if section_start == -1:
61
+ raise RuntimeError(
62
+ f"section '{slug}' not found in file — the file may have been "
63
+ f"edited by hand. Restore the four <section id=...> blocks or "
64
+ f"delete the file so it can be regenerated."
65
+ )
66
+ section_end = document.find(section_close_marker, section_start)
67
+ if section_end == -1:
68
+ raise RuntimeError(
69
+ f"section '{slug}' is missing its closing </section> — the file "
70
+ f"may have been edited by hand."
71
+ )
72
+ close_at = document.find(close_marker, section_start, section_end)
73
+ if close_at == -1:
74
+ raise RuntimeError(
75
+ f"section '{slug}' is missing its closing </ul> — the file may "
76
+ f"have been edited by hand."
77
+ )
78
+ boundary = close_at
79
+ while boundary > 0 and document[boundary - 1] in (" ", "\n"):
80
+ boundary -= 1
81
+ new_line = f"\n {entry}"
82
+ return document[:boundary] + new_line + "\n " + document[close_at:]
83
+
84
+
85
+ def _parse_arguments() -> argparse.Namespace:
86
+ parser = argparse.ArgumentParser(
87
+ description=f"Append an entry to {DEFAULT_NOTES_FILENAME}.",
88
+ )
89
+ parser.add_argument(
90
+ "--section",
91
+ required=True,
92
+ choices=sorted(HEADING_BY_SLUG.keys()),
93
+ help="Which section to append under.",
94
+ )
95
+ parser.add_argument(
96
+ "--about",
97
+ required=True,
98
+ help="Short label naming the part of the spec this entry relates to.",
99
+ )
100
+ parser.add_argument(
101
+ "--note",
102
+ required=True,
103
+ help="The decision / deviation / tradeoff / question itself.",
104
+ )
105
+ parser.add_argument(
106
+ "--file",
107
+ default=DEFAULT_NOTES_FILENAME,
108
+ help=(
109
+ f"Path to the notes file. Defaults to ./{DEFAULT_NOTES_FILENAME} "
110
+ f"in the current working directory."
111
+ ),
112
+ )
113
+ return parser.parse_args()
114
+
115
+
116
+ def main() -> int:
117
+ """Parse CLI arguments and append one entry to the notes file.
118
+
119
+ Returns:
120
+ Process exit code (0 on success).
121
+ """
122
+ arguments = _parse_arguments()
123
+ target_path = Path(arguments.file).expanduser().resolve()
124
+ document = _ensure_file(target_path)
125
+ entry = _render_entry(arguments.about, arguments.note)
126
+ updated = _insert_entry(document, arguments.section, entry)
127
+ target_path.write_text(updated, encoding="utf-8")
128
+ print(f"appended to [{arguments.section}] in {target_path}")
129
+ return 0
130
+
131
+
132
+ if __name__ == "__main__":
133
+ sys.exit(main())
@@ -0,0 +1,12 @@
1
+ """Configuration for the implementation-notes append script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ HEADING_BY_SLUG: dict[str, str] = {
6
+ "decisions": "Design decisions",
7
+ "deviations": "Deviations",
8
+ "tradeoffs": "Tradeoffs",
9
+ "questions": "Open questions",
10
+ }
11
+
12
+ DEFAULT_NOTES_FILENAME = "implementation-notes.html"
@@ -0,0 +1,191 @@
1
+ """Tests for append_note.
2
+
3
+ Covers:
4
+ - _build_skeleton emits a four-section HTML document keyed by every slug
5
+ - _ensure_file creates a fresh file on first call and round-trips on subsequent calls
6
+ - _render_entry HTML-escapes the about label and the note body
7
+ - _insert_entry puts the first <li> on its own line and keeps a 6-space indent across entries
8
+ - _insert_entry raises a descriptive RuntimeError when the section block is missing
9
+ - _insert_entry raises a descriptive RuntimeError when the closing </ul> is missing
10
+ - main appends through the CLI surface against a real on-disk file
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib.util
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+ from types import ModuleType
20
+
21
+ import pytest
22
+
23
+ _SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
24
+
25
+
26
+ def _load_module() -> ModuleType:
27
+ if str(_SCRIPTS_DIRECTORY) not in sys.path:
28
+ sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
29
+ module_path = _SCRIPTS_DIRECTORY / "append_note.py"
30
+ spec = importlib.util.spec_from_file_location("append_note", module_path)
31
+ assert spec is not None
32
+ assert spec.loader is not None
33
+ module = importlib.util.module_from_spec(spec)
34
+ spec.loader.exec_module(module)
35
+ return module
36
+
37
+
38
+ append_note_module = _load_module()
39
+ HEADING_BY_SLUG = append_note_module.HEADING_BY_SLUG
40
+
41
+
42
+ def test_should_build_skeleton_with_every_section_slug() -> None:
43
+ skeleton = append_note_module._build_skeleton()
44
+
45
+ for each_slug, each_heading in HEADING_BY_SLUG.items():
46
+ assert f'<section id="{each_slug}">' in skeleton
47
+ assert f"<h2>{each_heading}</h2>" in skeleton
48
+ assert skeleton.count("<ul></ul>") == len(HEADING_BY_SLUG)
49
+
50
+
51
+ def test_should_create_file_with_skeleton_on_first_ensure(tmp_path: Path) -> None:
52
+ target = tmp_path / "subdir" / "implementation-notes.html"
53
+
54
+ document = append_note_module._ensure_file(target)
55
+
56
+ assert target.exists()
57
+ assert document == target.read_text(encoding="utf-8")
58
+ assert '<section id="decisions">' in document
59
+
60
+
61
+ def test_should_return_existing_content_on_subsequent_ensure(tmp_path: Path) -> None:
62
+ target = tmp_path / "notes.html"
63
+ custom_content = "<!doctype html><html><body>existing</body></html>\n"
64
+ target.write_text(custom_content, encoding="utf-8")
65
+
66
+ returned = append_note_module._ensure_file(target)
67
+
68
+ assert returned == custom_content
69
+
70
+
71
+ def test_should_escape_html_metacharacters_in_about_and_note() -> None:
72
+ entry = append_note_module._render_entry("a<b & c>d", "<script>x</script>")
73
+
74
+ assert "<script>" not in entry
75
+ assert "&lt;script&gt;" in entry
76
+ assert "a&lt;b &amp; c&gt;d" in entry
77
+
78
+
79
+ def test_should_put_first_entry_on_its_own_line_inside_empty_ul() -> None:
80
+ skeleton = append_note_module._build_skeleton()
81
+ entry = append_note_module._render_entry("First", "alpha")
82
+
83
+ after_first = append_note_module._insert_entry(skeleton, "decisions", entry)
84
+
85
+ assert "<ul> <li>" not in after_first
86
+ assert "<ul>\n <li>" in after_first
87
+
88
+
89
+ def test_should_keep_uniform_six_space_indent_across_multiple_entries() -> None:
90
+ skeleton = append_note_module._build_skeleton()
91
+ first_entry = append_note_module._render_entry("First", "alpha")
92
+ second_entry = append_note_module._render_entry("Second", "beta")
93
+
94
+ after_first = append_note_module._insert_entry(skeleton, "decisions", first_entry)
95
+ after_second = append_note_module._insert_entry(after_first, "decisions", second_entry)
96
+
97
+ decisions_section_start = after_second.index('<section id="decisions">')
98
+ decisions_section_end = after_second.index("</section>", decisions_section_start)
99
+ decisions_section = after_second[decisions_section_start:decisions_section_end]
100
+
101
+ assert " <li>" not in decisions_section
102
+ assert decisions_section.count("\n <li>") == 2
103
+
104
+
105
+ def test_should_raise_when_requested_section_is_absent() -> None:
106
+ document_without_section = "<html><body></body></html>\n"
107
+ entry = append_note_module._render_entry("x", "y")
108
+
109
+ with pytest.raises(RuntimeError, match="section 'decisions' not found"):
110
+ append_note_module._insert_entry(document_without_section, "decisions", entry)
111
+
112
+
113
+ def test_should_raise_when_closing_ul_is_missing() -> None:
114
+ truncated_section = '<section id="decisions">\n <h2>Design decisions</h2>\n <ul>\n </section>\n'
115
+ entry = append_note_module._render_entry("x", "y")
116
+
117
+ with pytest.raises(RuntimeError, match="missing its closing </ul>"):
118
+ append_note_module._insert_entry(truncated_section, "decisions", entry)
119
+
120
+
121
+ def test_should_not_borrow_closing_ul_from_a_later_section() -> None:
122
+ malformed_first_with_intact_second = (
123
+ '<section id="decisions">\n'
124
+ ' <h2>Design decisions</h2>\n'
125
+ ' <ul>\n'
126
+ ' </section>\n'
127
+ ' <section id="deviations">\n'
128
+ ' <h2>Deviations</h2>\n'
129
+ ' <ul></ul>\n'
130
+ ' </section>\n'
131
+ )
132
+ entry = append_note_module._render_entry("x", "y")
133
+
134
+ with pytest.raises(RuntimeError, match="missing its closing </ul>"):
135
+ append_note_module._insert_entry(malformed_first_with_intact_second, "decisions", entry)
136
+
137
+
138
+ def test_should_raise_when_closing_section_is_missing() -> None:
139
+ section_without_close = '<section id="decisions">\n <h2>Design decisions</h2>\n <ul></ul>\n'
140
+ entry = append_note_module._render_entry("x", "y")
141
+
142
+ with pytest.raises(RuntimeError, match="missing its closing </section>"):
143
+ append_note_module._insert_entry(section_without_close, "decisions", entry)
144
+
145
+
146
+ def test_should_append_through_cli_against_real_file(tmp_path: Path) -> None:
147
+ target = tmp_path / "notes.html"
148
+ script_path = _SCRIPTS_DIRECTORY / "append_note.py"
149
+
150
+ first_run = subprocess.run(
151
+ [
152
+ sys.executable,
153
+ str(script_path),
154
+ "--section",
155
+ "decisions",
156
+ "--about",
157
+ "First",
158
+ "--note",
159
+ "alpha",
160
+ "--file",
161
+ str(target),
162
+ ],
163
+ cwd=str(_SCRIPTS_DIRECTORY),
164
+ capture_output=True,
165
+ text=True,
166
+ check=False,
167
+ )
168
+ second_run = subprocess.run(
169
+ [
170
+ sys.executable,
171
+ str(script_path),
172
+ "--section",
173
+ "questions",
174
+ "--about",
175
+ "Q1",
176
+ "--note",
177
+ "<beta & gamma>",
178
+ "--file",
179
+ str(target),
180
+ ],
181
+ cwd=str(_SCRIPTS_DIRECTORY),
182
+ capture_output=True,
183
+ text=True,
184
+ check=False,
185
+ )
186
+
187
+ assert first_run.returncode == 0, first_run.stderr
188
+ assert second_run.returncode == 0, second_run.stderr
189
+ output = target.read_text(encoding="utf-8")
190
+ assert "<li><strong>First:</strong> alpha</li>" in output
191
+ assert "<li><strong>Q1:</strong> &lt;beta &amp; gamma&gt;</li>" in output
@@ -2,7 +2,7 @@
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
- ``packages/claude-dev-env/skills/pr-converge/scripts/config/pr_converge_constants.py``,
5
+ ``packages/claude-dev-env/skills/pr-converge/scripts/pr_converge_scripts_constants/pr_converge_constants.py``,
6
6
  which imports from here.
7
7
  """
8
8
 
@@ -33,6 +33,11 @@ ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS = ("success", "neutral")
33
33
  BUGBOT_RUN_TRIGGER_PHRASE = "bugbot run\n"
34
34
  BUGBOT_RUN_TRIGGER_WAIT_SECONDS = 8
35
35
 
36
+ BUGTEAM_NEW_HEADER_PREFIX = "**Bugteam audit completed**"
37
+ BUGTEAM_LEGACY_HEADER_PREFIX = "## /bugteam loop "
38
+ BUGTEAM_NEW_CLEAN_LABEL = "Clean — no findings"
39
+ BUGTEAM_LEGACY_CLEAN_TOKEN = "→ clean"
40
+
36
41
  GH_INLINE_COMMENTS_PATH_TEMPLATE = "repos/{owner}/{repo}/pulls/{number}/comments"
37
42
  GH_REVIEW_COMMENTS_PATH_TEMPLATE = (
38
43
  "repos/{owner}/{repo}/pulls/{number}/reviews/{review_id}/comments"
@@ -29,11 +29,11 @@ import subprocess
29
29
  import sys
30
30
  from pathlib import Path
31
31
 
32
- _pr_converge_dir = Path(__file__).resolve().parent.parent
32
+ _pr_converge_dir = Path(__file__).absolute().parent.parent
33
33
  if str(_pr_converge_dir) not in sys.path:
34
34
  sys.path.insert(0, str(_pr_converge_dir))
35
35
 
36
- from config.constants import (
36
+ from pr_converge_skill_constants.constants import (
37
37
  ALL_BUGBOT_CHECK_RUN_ACTIVE_STATUSES,
38
38
  ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS,
39
39
  BUGBOT_CHECK_RUN_COMPLETED_STATUS,
@@ -2,9 +2,10 @@
2
2
 
3
3
  Usage:
4
4
  python scripts/check_convergence.py --owner <O> --repo <R> --pr-number <N>
5
+ [--bugbot-down]
5
6
 
6
7
  Exit codes:
7
- 0 — all seven pre-conditions met
8
+ 0 — all pre-conditions met
8
9
  1 — one or more conditions not met (FAIL lines printed to stdout)
9
10
  2 — gh CLI error
10
11
  """
@@ -18,18 +19,20 @@ import subprocess
18
19
  import sys
19
20
  from pathlib import Path
20
21
 
21
- _pr_converge_dir = Path(__file__).resolve().parent.parent
22
+ _pr_converge_dir = Path(__file__).absolute().parent.parent
22
23
  if str(_pr_converge_dir) not in sys.path:
23
24
  sys.path.insert(0, str(_pr_converge_dir))
24
25
 
25
- from config.constants import (
26
- ALL_CLAUDE_DIRTY_REVIEW_STATES,
26
+ from pr_converge_skill_constants.constants import (
27
27
  ALL_COPILOT_DIRTY_REVIEW_STATES,
28
28
  ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS,
29
29
  BUGBOT_CHECK_RUN_NAME_SUBSTRING,
30
30
  BUGBOT_DIRTY_BODY_REGEX,
31
+ BUGTEAM_LEGACY_CLEAN_TOKEN,
32
+ BUGTEAM_LEGACY_HEADER_PREFIX,
33
+ BUGTEAM_NEW_CLEAN_LABEL,
34
+ BUGTEAM_NEW_HEADER_PREFIX,
31
35
  CHECK_RUNS_PER_PAGE,
32
- ALL_CLAUDE_CLEAN_REVIEW_STATES,
33
36
  CLAUDE_LOGIN_FILTER_SUBSTRING,
34
37
  ALL_COPILOT_CLEAN_REVIEW_STATES,
35
38
  COPILOT_LOGIN_FILTER_SUBSTRING,
@@ -46,6 +49,93 @@ from config.constants import (
46
49
  )
47
50
 
48
51
 
52
+ def _is_bugteam_review(review_body: str) -> bool:
53
+ """Return True when a review body opens with a bugteam audit header.
54
+
55
+ Args:
56
+ review_body: Full body text of a PR review.
57
+
58
+ Returns:
59
+ True when the body opens with either the new audit-template header
60
+ prefix or the legacy bugteam loop header prefix; False otherwise.
61
+ Used to identify bugteam audit reviews by body content rather than
62
+ by the posting user's GitHub login (the underlying ``gh`` token is
63
+ typically the PR-owner or reviewer identity, not ``claude[bot]``).
64
+ """
65
+ return (
66
+ review_body.startswith(BUGTEAM_NEW_HEADER_PREFIX)
67
+ or review_body.startswith(BUGTEAM_LEGACY_HEADER_PREFIX)
68
+ )
69
+
70
+
71
+ def _is_clean_bugteam_review(review_body: str) -> bool:
72
+ """Return True when a bugteam audit review body declares a clean pass.
73
+
74
+ Args:
75
+ review_body: Body text of a review that has already satisfied
76
+ :func:`_is_bugteam_review`.
77
+
78
+ Returns:
79
+ True when the new-shape body's first line carries the clean state
80
+ label, or the legacy-shape body ends with the legacy clean token.
81
+ False for any other shape, including dirty audit reviews and
82
+ bodies that do not match the bugteam header signature.
83
+ """
84
+ if review_body.startswith(BUGTEAM_NEW_HEADER_PREFIX):
85
+ first_line = review_body.splitlines()[0]
86
+ return BUGTEAM_NEW_CLEAN_LABEL in first_line
87
+ if review_body.startswith(BUGTEAM_LEGACY_HEADER_PREFIX):
88
+ return review_body.rstrip().endswith(BUGTEAM_LEGACY_CLEAN_TOKEN)
89
+ return False
90
+
91
+
92
+ def _check_bugteam_clean(
93
+ *, owner: str, repo: str, number: int, head_sha: str
94
+ ) -> tuple[bool, str]:
95
+ endpoint = GH_REVIEWS_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
96
+ returncode, stdout = _gh_api_paginated(f"{endpoint}?per_page={REVIEWS_PER_PAGE}")
97
+ if returncode != 0:
98
+ return False, f"gh api error: {stdout}"
99
+ try:
100
+ raw_output = json.loads(stdout)
101
+ except json.JSONDecodeError:
102
+ return False, "gh api response not valid JSON"
103
+ if not isinstance(raw_output, list):
104
+ return False, "unexpected gh api response shape (expected list)"
105
+ all_pages = [p for p in raw_output if isinstance(p, list)]
106
+ all_flat: list[dict[str, object]] = [
107
+ each_entry
108
+ for page in all_pages
109
+ for each_entry in page
110
+ if isinstance(each_entry, dict)
111
+ ]
112
+ all_flat.sort(
113
+ key=lambda each_review: str(each_review.get("submitted_at", "")),
114
+ reverse=True,
115
+ )
116
+ for each_review in all_flat:
117
+ body = each_review.get("body", "")
118
+ if not isinstance(body, str):
119
+ continue
120
+ if not _is_bugteam_review(body):
121
+ continue
122
+ commit_id = each_review.get("commit_id", "")
123
+ if not isinstance(commit_id, str) or not commit_id.startswith(head_sha):
124
+ continue
125
+ review_id = each_review.get("id", "?")
126
+ short_commit = commit_id[:7]
127
+ if _is_clean_bugteam_review(body):
128
+ return (
129
+ True,
130
+ f"review #{review_id}, clean bugteam audit, commit: {short_commit}",
131
+ )
132
+ return (
133
+ False,
134
+ f"review #{review_id}, dirty bugteam audit, commit: {short_commit}",
135
+ )
136
+ return False, f"no bugteam review found on {head_sha[:7]}"
137
+
138
+
49
139
  def _gh_api(endpoint_path: str) -> tuple[int, str]:
50
140
  completed_process = subprocess.run(
51
141
  ["gh", "api", endpoint_path],
@@ -387,38 +477,67 @@ def _check_no_pending_reviews(
387
477
  return True, "no pending reviewers"
388
478
 
389
479
 
390
- def check_all(*, owner: str, repo: str, number: int) -> int:
480
+ def check_all(*, owner: str, repo: str, number: int, bugbot_down: bool) -> int:
481
+ """Run every convergence gate and print one PASS/FAIL line per condition.
482
+
483
+ Args:
484
+ owner: GitHub repository owner login.
485
+ repo: GitHub repository name.
486
+ number: Pull request number to inspect.
487
+ bugbot_down: When True, bypass both the Cursor Bugbot check-run
488
+ presence gate and the bugbot review-body content gate. The
489
+ check-run gate appears in the condition list with a
490
+ ``bypassed (bugbot_down)`` note; the review-body gate is
491
+ omitted entirely. Callers pass True when the lead has
492
+ declared Cursor Bugbot unreachable on the current HEAD so the
493
+ broader convergence gate can still close on the remaining
494
+ signals.
495
+
496
+ Returns:
497
+ ``0`` when every gate reports PASS, ``1`` when at least one gate
498
+ reports FAIL. Per-gate ``gh api`` transport failures surface as
499
+ gate FAIL lines in the printed output and contribute to the ``1``
500
+ exit code.
501
+
502
+ Raises:
503
+ SystemExit: Propagated by the initial ``_get_pr_head_sha`` call
504
+ with ``EXIT_CODE_GH_ERROR`` when the PR-head-SHA fetch fails
505
+ before any gate runs. The function does not catch this
506
+ exception; the caller is responsible for converting it into
507
+ an exit code.
508
+ """
391
509
  head_sha = _get_pr_head_sha(owner=owner, repo=repo, number=number)
392
510
  print(f"HEAD: {head_sha[:7]}\n")
393
511
 
394
512
  conditions: list[tuple[str, tuple[bool, str]]] = []
395
513
 
396
- conditions.append(
397
- (
398
- "bugbot_clean_at == current_head",
399
- _check_bugbot(owner=owner, repo=repo, sha=head_sha),
514
+ if bugbot_down:
515
+ conditions.append(
516
+ (
517
+ "bugbot_clean_at == current_head",
518
+ (True, "bypassed (bugbot_down)"),
519
+ )
400
520
  )
401
- )
402
- if conditions[-1][1][0]:
521
+ else:
403
522
  conditions.append(
404
523
  (
405
- "bugbot review body clean",
406
- _check_bugbot_not_dirty(owner=owner, repo=repo, number=number, head_sha=head_sha),
524
+ "bugbot_clean_at == current_head",
525
+ _check_bugbot(owner=owner, repo=repo, sha=head_sha),
407
526
  )
408
527
  )
528
+ if conditions[-1][1][0]:
529
+ conditions.append(
530
+ (
531
+ "bugbot review body clean",
532
+ _check_bugbot_not_dirty(owner=owner, repo=repo, number=number, head_sha=head_sha),
533
+ )
534
+ )
409
535
 
410
536
  conditions.append(
411
537
  (
412
538
  "bugteam_clean_at == current_head",
413
- _check_bot_review(
414
- owner=owner,
415
- repo=repo,
416
- number=number,
417
- head_sha=head_sha,
418
- login_substring=CLAUDE_LOGIN_FILTER_SUBSTRING,
419
- clean_states=ALL_CLAUDE_CLEAN_REVIEW_STATES,
420
- dirty_states=ALL_CLAUDE_DIRTY_REVIEW_STATES,
421
- label="claude[bot]",
539
+ _check_bugteam_clean(
540
+ owner=owner, repo=repo, number=number, head_sha=head_sha
422
541
  ),
423
542
  )
424
543
  )
@@ -458,13 +577,11 @@ def check_all(*, owner: str, repo: str, number: int) -> int:
458
577
  )
459
578
 
460
579
  is_all_passed = True
461
- index = 1
462
- for label, (passed, detail) in conditions:
463
- status = "PASS" if passed else "FAIL"
464
- print(f"{index}. {label}: {status} — {detail}")
465
- if not passed:
580
+ for each_index, (each_label, (each_passed, each_detail)) in enumerate(conditions, start=1):
581
+ status = "PASS" if each_passed else "FAIL"
582
+ print(f"{each_index}. {each_label}: {status} {each_detail}")
583
+ if not each_passed:
466
584
  is_all_passed = False
467
- index += 1
468
585
 
469
586
  print()
470
587
  if is_all_passed:
@@ -475,21 +592,50 @@ def check_all(*, owner: str, repo: str, number: int) -> int:
475
592
 
476
593
 
477
594
  def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
595
+ """Parse command-line arguments for the convergence checker.
596
+
597
+ Args:
598
+ all_argv: Argument list excluding the program name, typically
599
+ ``sys.argv[1:]``.
600
+
601
+ Returns:
602
+ Namespace exposing ``owner``, ``repo``, ``pr_number``, and
603
+ ``bugbot_down`` attributes. ``bugbot_down`` defaults to False so
604
+ the unmodified hook contract (``--owner X --repo Y --pr-number N``)
605
+ still picks up the full gate set.
606
+ """
478
607
  parser = argparse.ArgumentParser(description=__doc__)
479
608
  parser.add_argument("--owner", required=True, help="GitHub repository owner")
480
609
  parser.add_argument("--repo", required=True, help="GitHub repository name")
481
610
  parser.add_argument(
482
611
  "--pr-number", required=True, type=int, help="Pull request number"
483
612
  )
613
+ parser.add_argument(
614
+ "--bugbot-down",
615
+ action="store_true",
616
+ help=(
617
+ "Bypass the bugbot check-run gate (gate 1) when the lead has "
618
+ "declared Cursor Bugbot unreachable on the current HEAD."
619
+ ),
620
+ )
484
621
  return parser.parse_args(all_argv)
485
622
 
486
623
 
487
624
  def main(all_arguments: list[str]) -> int:
625
+ """Run the script end-to-end against parsed CLI arguments.
626
+
627
+ Args:
628
+ all_arguments: Argument list excluding the program name.
629
+
630
+ Returns:
631
+ ``0`` on full convergence, ``1`` on one or more gate failures.
632
+ """
488
633
  arguments = parse_arguments(all_arguments)
489
634
  return check_all(
490
635
  owner=arguments.owner,
491
636
  repo=arguments.repo,
492
637
  number=getattr(arguments, "pr_number"),
638
+ bugbot_down=arguments.bugbot_down,
493
639
  )
494
640
 
495
641
 
@@ -17,11 +17,11 @@ import subprocess
17
17
  import sys
18
18
  from pathlib import Path
19
19
 
20
- _pr_converge_dir = Path(__file__).resolve().parent.parent
20
+ _pr_converge_dir = Path(__file__).absolute().parent.parent
21
21
  if str(_pr_converge_dir) not in sys.path:
22
22
  sys.path.insert(0, str(_pr_converge_dir))
23
23
 
24
- from config.constants import (
24
+ from pr_converge_skill_constants.constants import (
25
25
  EXIT_CODE_GH_ERROR,
26
26
  GH_REVIEWS_PATH_TEMPLATE,
27
27
  REVIEWS_PER_PAGE,