claude-dev-env 1.83.0 → 1.84.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 (165) 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/reviews_disabled.py +2 -0
  5. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
  6. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
  7. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
  8. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
  9. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
  10. package/agents/clean-coder.md +3 -0
  11. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  12. package/bin/install.mjs +16 -8
  13. package/bin/install.test.mjs +17 -6
  14. package/commands/CLAUDE.md +0 -1
  15. package/docs/CODE_RULES.md +1 -1
  16. package/hooks/blocking/CLAUDE.md +0 -4
  17. package/hooks/blocking/code_rules_constants_config.py +5 -0
  18. package/hooks/blocking/code_rules_docstrings.py +116 -6
  19. package/hooks/blocking/code_rules_enforcer.py +4 -0
  20. package/hooks/blocking/code_rules_magic_values.py +5 -0
  21. package/hooks/blocking/code_rules_naming_collection.py +11 -6
  22. package/hooks/blocking/code_rules_shared.py +34 -0
  23. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
  24. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
  25. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
  26. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
  27. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
  28. package/hooks/blocking/test_code_rules_magic_values.py +54 -0
  29. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
  30. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
  31. package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
  32. package/hooks/hooks_constants/CLAUDE.md +0 -3
  33. package/hooks/hooks_constants/blocking_check_limits.py +3 -0
  34. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  35. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
  36. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
  37. package/hooks/validation/post_tool_use_dispatcher.py +1 -1
  38. package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
  39. package/hooks/workflow/CLAUDE.md +2 -8
  40. package/package.json +1 -1
  41. package/rules/CLAUDE.md +1 -1
  42. package/rules/plain-illustrative-docstrings.md +35 -1
  43. package/skills/CLAUDE.md +6 -1
  44. package/skills/autoconverge/SKILL.md +38 -57
  45. package/skills/autoconverge/reference/closing-report.md +6 -6
  46. package/skills/autoconverge/reference/convergence.md +17 -15
  47. package/skills/autoconverge/reference/gotchas.md +6 -3
  48. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
  49. package/skills/autoconverge/workflow/converge.contract.test.mjs +87 -13
  50. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +1 -1
  51. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
  52. package/skills/autoconverge/workflow/converge.mjs +70 -73
  53. package/skills/autoconverge/workflow/render_report.py +7 -11
  54. package/skills/bugteam/CLAUDE.md +1 -1
  55. package/skills/bugteam/PROMPTS.md +7 -6
  56. package/skills/bugteam/SKILL.md +25 -72
  57. package/skills/bugteam/reference/CLAUDE.md +1 -3
  58. package/skills/bugteam/reference/README.md +1 -1
  59. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  60. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
  61. package/skills/bugteam/reference/team-setup.md +8 -7
  62. package/skills/bugteam/scripts/CLAUDE.md +0 -6
  63. package/skills/bugteam/scripts/README.md +0 -4
  64. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
  65. package/skills/code/SKILL.md +2 -0
  66. package/skills/copilot-review/CLAUDE.md +1 -1
  67. package/skills/copilot-review/SKILL.md +25 -23
  68. package/skills/findbugs/CLAUDE.md +2 -2
  69. package/skills/findbugs/SKILL.md +22 -83
  70. package/skills/fixbugs/SKILL.md +2 -4
  71. package/skills/log-audit/CLAUDE.md +20 -0
  72. package/skills/log-audit/SKILL.md +68 -0
  73. package/skills/log-audit/reference/CLAUDE.md +9 -0
  74. package/skills/log-audit/reference/charter.md +52 -0
  75. package/skills/log-audit/scripts/CLAUDE.md +27 -0
  76. package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
  77. package/skills/log-audit/scripts/collect_log_window.py +199 -0
  78. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
  79. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
  80. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
  81. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
  82. package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
  83. package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
  84. package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
  85. package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
  86. package/skills/monitor-open-prs/SKILL.md +2 -2
  87. package/skills/post-audit-findings/SKILL.md +84 -0
  88. package/skills/pr-converge/CLAUDE.md +2 -0
  89. package/skills/pr-converge/SKILL.md +72 -59
  90. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  91. package/skills/pr-converge/reference/convergence-gates.md +16 -19
  92. package/skills/pr-converge/reference/examples.md +5 -5
  93. package/skills/pr-converge/reference/fix-protocol.md +16 -43
  94. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
  95. package/skills/pr-converge/reference/per-tick.md +24 -45
  96. package/skills/pr-converge/reference/state-schema.md +15 -0
  97. package/skills/pr-converge/scripts/README.md +3 -5
  98. package/skills/pr-fix-protocol/SKILL.md +70 -0
  99. package/skills/pr-loop-lifecycle/SKILL.md +73 -0
  100. package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
  101. package/skills/pr-scope-resolve/SKILL.md +48 -0
  102. package/skills/qbug/CLAUDE.md +4 -4
  103. package/skills/qbug/SKILL.md +46 -144
  104. package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
  105. package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
  106. package/skills/reviewer-gates/SKILL.md +96 -0
  107. package/skills/session-log/CLAUDE.md +7 -7
  108. package/skills/session-log/SKILL.md +27 -44
  109. package/skills/test_markdown_link_integrity.py +103 -0
  110. package/commands/doc-gist.md +0 -16
  111. package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
  112. package/hooks/blocking/conftest.py +0 -30
  113. package/hooks/blocking/md_path_exemptions.py +0 -224
  114. package/hooks/blocking/md_to_html_blocker.py +0 -155
  115. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
  116. package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
  117. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
  118. package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
  119. package/hooks/hooks_constants/html_companion_constants.py +0 -20
  120. package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
  121. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
  122. package/hooks/workflow/doc_gist_auto_publish.py +0 -144
  123. package/hooks/workflow/md_to_html_companion.py +0 -358
  124. package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
  125. package/hooks/workflow/test_md_to_html_companion.py +0 -613
  126. package/skills/bugteam/reference/audit-contract.md +0 -163
  127. package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
  128. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
  129. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
  130. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
  131. package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
  132. package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
  133. package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
  134. package/skills/doc-gist/CLAUDE.md +0 -25
  135. package/skills/doc-gist/SKILL.md +0 -97
  136. package/skills/doc-gist/references/CLAUDE.md +0 -9
  137. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
  138. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
  139. package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
  140. package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
  141. package/skills/doc-gist/references/examples/05-design-system.html +0 -629
  142. package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
  143. package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
  144. package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
  145. package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
  146. package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
  147. package/skills/doc-gist/references/examples/11-status-report.html +0 -528
  148. package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
  149. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
  150. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
  151. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
  152. package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
  153. package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
  154. package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
  155. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
  156. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
  157. package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
  158. package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
  159. package/skills/doc-gist/references/examples/README.md +0 -5
  160. package/skills/doc-gist/scripts/CLAUDE.md +0 -27
  161. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
  162. package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
  163. package/skills/doc-gist/scripts/gist_upload.py +0 -177
  164. package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
  165. /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,
@@ -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
 
@@ -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:
@@ -160,6 +160,24 @@ def test_path_contains_glob_metacharacters_local_tuple_uses_all_collection_prefi
160
160
  )
161
161
 
162
162
 
163
+ def test_is_valid_project_root_detects_git_marker(tmp_path: Path) -> None:
164
+ git_project_root = tmp_path / "git_project"
165
+ (git_project_root / ".git").mkdir(parents=True)
166
+ assert common.is_valid_project_root(git_project_root) is True
167
+
168
+
169
+ def test_is_valid_project_root_detects_claude_marker(tmp_path: Path) -> None:
170
+ claude_project_root = tmp_path / "claude_project"
171
+ (claude_project_root / ".claude").mkdir(parents=True)
172
+ assert common.is_valid_project_root(claude_project_root) is True
173
+
174
+
175
+ def test_is_valid_project_root_rejects_unmarked_directory(tmp_path: Path) -> None:
176
+ unmarked_directory = tmp_path / "no_marker"
177
+ unmarked_directory.mkdir()
178
+ assert common.is_valid_project_root(unmarked_directory) is False
179
+
180
+
163
181
  def test_is_valid_project_root_uses_extracted_directory_marker_constants() -> None:
164
182
  """is_valid_project_root must reference extracted constants, not inline string literals."""
165
183
  source_text = inspect.getsource(common.is_valid_project_root)
@@ -9,6 +9,7 @@ Covers:
9
9
 
10
10
  import importlib.util
11
11
  import inspect
12
+ import os
12
13
  import subprocess
13
14
  import sys
14
15
  import unittest.mock
@@ -93,6 +94,13 @@ def stage_file(repository_root: Path, relative_path: str) -> None:
93
94
  run_git_in_repository(repository_root, "add", "--", relative_path)
94
95
 
95
96
 
97
+ @pytest.fixture(autouse=True)
98
+ def isolated_git_environment(monkeypatch: pytest.MonkeyPatch) -> None:
99
+ for each_variable_name in list(os.environ):
100
+ if each_variable_name.startswith("GIT_"):
101
+ monkeypatch.delenv(each_variable_name, raising=False)
102
+
103
+
96
104
  @pytest.fixture()
97
105
  def temporary_git_repository(tmp_path: Path) -> Path:
98
106
  repository_root = tmp_path / "repository_under_test"
@@ -320,6 +328,35 @@ def test_main_staged_mode_exits_zero_when_nothing_staged(
320
328
  assert exit_code == 0
321
329
 
322
330
 
331
+ def test_main_staged_mode_passes_when_only_terminology_findings_exist(
332
+ temporary_git_repository: Path,
333
+ monkeypatch: pytest.MonkeyPatch,
334
+ ) -> None:
335
+ write_file(temporary_git_repository / "quota.py", "first_count = 1\n")
336
+ commit_all_files(temporary_git_repository, "initial")
337
+ write_file(
338
+ temporary_git_repository / "quota.py",
339
+ "first_count = 1\npremium_interactions = 1\n",
340
+ )
341
+ write_file(
342
+ temporary_git_repository / "README.md",
343
+ "The premium-request budget gates the run.\n",
344
+ )
345
+ stage_file(temporary_git_repository, "quota.py")
346
+ stage_file(temporary_git_repository, "README.md")
347
+
348
+ monkeypatch.chdir(temporary_git_repository)
349
+ terminology_findings = gate_module.staged_terminology_findings(
350
+ temporary_git_repository
351
+ )
352
+ exit_code = gate_module.main(["--staged"])
353
+
354
+ assert any(
355
+ "premium-request" in each_finding for each_finding in terminology_findings
356
+ )
357
+ assert exit_code == 0
358
+
359
+
323
360
  def test_added_lines_for_staged_file_returns_empty_for_modified_file_with_no_additions(
324
361
  temporary_git_repository: Path,
325
362
  ) -> None:
@@ -120,3 +120,17 @@ def test_cli_main_supports_bugteam_reviewer(
120
120
  ) -> None:
121
121
  monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugteam")
122
122
  assert reviews_disabled.main(["--reviewer", "bugteam"]) == 0
123
+
124
+
125
+ def test_cli_main_supports_copilot_reviewer(
126
+ monkeypatch: pytest.MonkeyPatch,
127
+ ) -> None:
128
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "copilot")
129
+ assert reviews_disabled.main(["--reviewer", "copilot"]) == 0
130
+
131
+
132
+ def test_cli_main_returns_one_for_copilot_when_only_bugbot_disabled(
133
+ monkeypatch: pytest.MonkeyPatch,
134
+ ) -> None:
135
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugbot")
136
+ assert reviews_disabled.main(["--reviewer", "copilot"]) == 1
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: clean-coder
3
3
  description: "Use PROACTIVELY for ALL code generation — feature development, bug fixes, refactoring, hook creation, automation scripts, and any task that produces code. Internalizes CODE_RULES.md and the 8-dimension readability standard so thoroughly that /check finds zero issues. The definitive code-writing agent."
4
+ model: opus
4
5
  tools: Read, Write, Edit, Bash, Grep, Glob, Task, Skill
5
6
  color: green
6
7
  ---
@@ -436,6 +437,8 @@ This default is overridden by explicit user instruction such as "refactor this e
436
437
 
437
438
  Docstrings on functions, methods, classes, and modules are encouraged for public APIs. The self-documenting-names gate inspects inline `#` and block `#` comments only; docstrings are exempt from that gate.
438
439
 
440
+ Write a behavior-illustrating docstring in the diagram-first shape: one summary line, then a `::` literal block or a doctest that shows a concrete input and its marked outcome (with `ok:` / `flag:` contrast lines where a pass-and-fail pair helps), then a couple of short neutral prose lines, then the `Args:` / `Returns:` sections. Two hooks back this: `check_docstring_runon_sentence` flags a run-on prose wall, and `check_docstring_prose_wall_without_illustration` flags a narrative that runs long with no diagram. See `../rules/plain-illustrative-docstrings.md` for the shape and a worked example.
441
+
439
442
  ## Audit Awareness
440
443
 
441
444
  Code clean-coder writes will be audited later against the A–P bug categories from `code-quality-agent`. The hooks listed in this file enforce the Category J slice at write time, but A–I and K–P surface only in audit. For each category's full rubric, sub-bucket decomposition, and concrete checks, see `../audit-rubrics/category_rubrics/` (relative to this agent file). While generating code, anticipate the full A–P surface so the first write clears every audit category.
@@ -28,7 +28,7 @@ Decomposition is by the **kind of docstring claim** that needs to be cross-check
28
28
  | O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. When a docstring sentence excludes a named category of input from what the function flags (`X are not dispatch steps`, `Y is not a match`), confirm the axis the prose excludes on is the axis the body's branch condition actually keys on. A body that flags a call when it sits inside an `If.test` guard, paired with prose that excludes by the call's receiver shape (`method-on-local calls inside a branch are not dispatch steps`), is an O6 finding: a guarded method-on-local call is flagged even though the prose lists it as excluded — the exclusion is keyed to the wrong axis. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. The exception-guard shape of this drift — a docstring that promises a malformed payload `resolves to None` while a payload subscript (`payload["key"]`, `float(payload["key"])`) sits outside the try/except whose handler returns None, so a present-but-malformed payload raises rather than resolving to None — is gated deterministically at Write/Edit time by `check_docstring_unguarded_malformed_payload_claim`, so the audit lane focuses on the wider Raises/None-on-failure claims the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
29
29
  | O7 | Module-doc-vs-split-module after refactor | When a refactor moves a responsibility to a sibling module, the originating module's docstring and the receiving module's docstring both describe the home of that responsibility. A module docstring should describe only the responsibilities it owns. |
30
30
  | O8 | Companion-doc ordering/content vs producer | When a PR changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the PR diff never touched the `.md` file, because the behavior change orphaned the doc claim. See `../../rules/docstring-prose-matches-implementation.md`. |
31
- | O9 | Python docstring plainness for a general developer | A changed module / class / public-function docstring's narrative prose — the summary and description before the first `Args:` / `Returns:` / `Raises:` / `Yields:` section — reads plainly and paints a concrete scene a general developer follows on first read. Flag a narrative that stacks abstract machinery nouns into a wall (`the SIGINT install/restore/installability check, the atexit terminal-record registration, and the interrupted-run finalizer`), that defines a thing by what it is not (`the non-promoter-specific machinery`), or that runs one sentence long while joining clauses with an em-dash or a semicolon. The deterministic run-on mark is gated at Write/Edit time by `check_docstring_runon_sentence` in `code_rules_docstrings.py`, so this lane carries the illustrative-and-brief judgment the gate cannot: whether a stranger to the code pictures the moment, the input, and the outcome after one read. See `../../rules/plain-illustrative-docstrings.md`. |
31
+ | O9 | Python docstring plainness for a general developer | A changed module / class / public-function docstring's narrative prose — the summary and description before the first `Args:` / `Returns:` / `Raises:` / `Yields:` section — reads plainly and paints a concrete scene a general developer follows on first read. Flag a narrative that stacks abstract machinery nouns into a wall (`the SIGINT install/restore/installability check, the atexit terminal-record registration, and the interrupted-run finalizer`), that defines a thing by what it is not (`the non-promoter-specific machinery`), or that runs one sentence long while joining clauses with an em-dash or a semicolon. The diagram-first shape carries this best: a summary line, then a `::` example block or a doctest that shows a concrete input and its marked outcome, then a couple of short prose lines. The deterministic run-on mark is gated at Write/Edit time by `check_docstring_runon_sentence` in `code_rules_docstrings.py`, and a narrative that runs more than six prose lines with no such block is gated by `check_docstring_prose_wall_without_illustration` in the same module, so this lane carries the judgment the gates cannot: whether a stranger to the code pictures the moment, the input, and the outcome after one read, and whether the diagram truly illustrates — a real input, a marked outcome, an `ok:` / `flag:` contrast a reader learns from. See `../../rules/plain-illustrative-docstrings.md`. |
32
32
 
33
33
  ---
34
34
 
package/bin/install.mjs CHANGED
@@ -294,10 +294,13 @@ function backupClaudeHubBeforeOverwrite(destPath, incomingPath) {
294
294
  }
295
295
 
296
296
  /**
297
- * Hook script paths that were folded into the PreToolUse dispatcher in Stage 1.
298
- * These entries no longer appear in hooks.json but must still be recognized as
299
- * managed so a reinstall from an older settings shape prunes them and they do
300
- * not double-run alongside the dispatcher.
297
+ * PreToolUse hook script paths the installer manages even though hooks.json
298
+ * carries no standalone entry for them. Most were folded into the PreToolUse
299
+ * dispatcher in Stage 1; md_to_html_blocker is a retired hook with no script on
300
+ * disk. Each path stays in this set so a reinstall from an older settings shape
301
+ * prunes its standalone entry — a folded hook would otherwise double-run
302
+ * alongside the dispatcher, and the retired hook's entry would point at a
303
+ * missing script.
301
304
  */
302
305
  export const FOLDED_HOOK_RELATIVE_PATHS = new Set([
303
306
  'blocking/write_existing_file_blocker.py',
@@ -316,18 +319,23 @@ export const FOLDED_HOOK_RELATIVE_PATHS = new Set([
316
319
  'blocking/pytest_testpaths_orphan_blocker.py',
317
320
  'blocking/open_questions_in_plans_blocker.py',
318
321
  'blocking/plain_language_blocker.py',
322
+ 'blocking/md_to_html_blocker.py',
319
323
  ]);
320
324
 
321
325
  /**
322
- * Hook script paths that were folded into the PostToolUse dispatcher. These
323
- * after-write hooks no longer appear in hooks.json but must still be recognized
324
- * as managed so a reinstall from an older settings shape prunes them and they do
325
- * not double-run alongside the PostToolUse dispatcher.
326
+ * After-write hook script paths the installer manages even though hooks.json
327
+ * carries no standalone entry for them. mypy_validator and auto_formatter run
328
+ * hosted inside the PostToolUse dispatcher; doc_gist_auto_publish and
329
+ * md_to_html_companion are retired hooks with no script on disk. Each path stays
330
+ * in this set so a reinstall from an older settings shape prunes its standalone
331
+ * entry — a hosted hook would otherwise double-run alongside the dispatcher, and
332
+ * a retired hook's entry would point at a missing script.
326
333
  */
327
334
  export const POST_FOLDED_HOOK_RELATIVE_PATHS = new Set([
328
335
  'validation/mypy_validator.py',
329
336
  'workflow/auto_formatter.py',
330
337
  'workflow/doc_gist_auto_publish.py',
338
+ 'workflow/md_to_html_companion.py',
331
339
  ]);
332
340
 
333
341
  /**