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
@@ -0,0 +1,103 @@
1
+ """Link-integrity check for the skills tree and the shared pr-loop docs.
2
+
3
+ Walks every markdown file under ``skills/`` and ``_shared/pr-loop/`` and
4
+ resolves each relative markdown link target against the linking file's
5
+ directory. A link whose target file or directory does not exist on disk is
6
+ reported with its source file and line number, and the test fails.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+
15
+ SKILLS_ROOT = Path(__file__).parent
16
+ SHARED_PR_LOOP_ROOT = SKILLS_ROOT.parent / "_shared" / "pr-loop"
17
+
18
+ MARKDOWN_LINK_PATTERN = re.compile(r"\]\(([^)\s]+)\)")
19
+ FENCE_MARKER = "```"
20
+
21
+ SKIPPED_TARGET_PREFIXES = (
22
+ "http://",
23
+ "https://",
24
+ "mailto:",
25
+ "#",
26
+ "/",
27
+ "$",
28
+ "~",
29
+ "<",
30
+ )
31
+
32
+
33
+ def _iter_markdown_files() -> list[Path]:
34
+ """List every markdown file the integrity check covers.
35
+
36
+ Returns:
37
+ All ``.md`` files under the skills tree and the shared pr-loop tree.
38
+ """
39
+ all_markdown_files = sorted(SKILLS_ROOT.rglob("*.md"))
40
+ all_markdown_files.extend(sorted(SHARED_PR_LOOP_ROOT.rglob("*.md")))
41
+ return all_markdown_files
42
+
43
+
44
+ def _is_checkable_target(link_target: str) -> bool:
45
+ """Decide whether a markdown link target names a local relative path.
46
+
47
+ Args:
48
+ link_target: The raw text between ``](`` and ``)`` in a markdown link.
49
+
50
+ Returns:
51
+ True when the target is a relative filesystem path this check can
52
+ resolve; False for URLs, anchors, absolute paths, environment-variable
53
+ paths, home-relative paths, and angle-bracket placeholders.
54
+ """
55
+ if link_target.startswith(SKIPPED_TARGET_PREFIXES):
56
+ return False
57
+ if "\\" in link_target and link_target.count(":") > 0:
58
+ return False
59
+ return True
60
+
61
+
62
+ def _collect_broken_links(markdown_path: Path) -> list[str]:
63
+ """Resolve every relative link in one markdown file.
64
+
65
+ Fenced code blocks are skipped, and a ``#fragment`` suffix is stripped
66
+ before resolution.
67
+
68
+ Args:
69
+ markdown_path: The markdown file whose links are resolved.
70
+
71
+ Returns:
72
+ One ``file:line -> target`` description per unresolvable link.
73
+ """
74
+ all_broken_links: list[str] = []
75
+ is_inside_fence = False
76
+ for line_number, each_line in enumerate(
77
+ markdown_path.read_text(encoding="utf-8").splitlines(), start=1
78
+ ):
79
+ if each_line.lstrip().startswith(FENCE_MARKER):
80
+ is_inside_fence = not is_inside_fence
81
+ continue
82
+ if is_inside_fence:
83
+ continue
84
+ for each_match in MARKDOWN_LINK_PATTERN.finditer(each_line):
85
+ link_target = each_match.group(1).split("#", 1)[0]
86
+ if not link_target or not _is_checkable_target(link_target):
87
+ continue
88
+ resolved_target = (markdown_path.parent / link_target).resolve()
89
+ if not resolved_target.exists():
90
+ all_broken_links.append(
91
+ f"{markdown_path}:{line_number} -> {each_match.group(1)}"
92
+ )
93
+ return all_broken_links
94
+
95
+
96
+ def test_every_relative_markdown_link_resolves() -> None:
97
+ all_broken_links: list[str] = []
98
+ for each_markdown_path in _iter_markdown_files():
99
+ all_broken_links.extend(_collect_broken_links(each_markdown_path))
100
+ broken_link_report = "\n".join(all_broken_links)
101
+ assert not all_broken_links, (
102
+ f"Unresolvable relative markdown links:\n{broken_link_report}"
103
+ )
@@ -1,16 +0,0 @@
1
- ---
2
- description: Upload an HTML file as a secret gist and open the htmlpreview URL. Manual escape hatch when the auto-publish hook's marker route doesn't apply.
3
- allowed-tools: Bash
4
- ---
5
-
6
- Argument: `$ARGUMENTS` is the path to an existing `.html` file.
7
-
8
- Run the doc-gist skill's transport script directly:
9
-
10
- ```
11
- python3 "${CLAUDE_PLUGIN_ROOT}/skills/doc-gist/scripts/gist_upload.py" --input "$ARGUMENTS"
12
- ```
13
-
14
- Quote both URLs (`Gist:` and `Preview:`) from stderr back to the user as clickable markdown links. Surface any `gh gist create failed` error with the suggested `gh auth login` next-step.
15
-
16
- When the user asks for a writeup or report rather than handing you a file path, design fresh HTML per [`skills/doc-gist/SKILL.md`](../skills/doc-gist/SKILL.md), include the `<!-- @publish-as-gist -->` marker, and write the file — the auto-publish hook will run on Write. Use this command only when the marker route doesn't fit (existing file you want to publish, HTML from a separate tool).
@@ -1,65 +0,0 @@
1
- """Shared subprocess-invocation helpers for the md_to_html_blocker test suites.
2
-
3
- Subprocess CWD is rooted in a per-session sandbox created lazily so that
4
- relative-path test cases canonicalize outside any `.claude-plugin/` ancestor,
5
- outside the OS temp directory, and outside the exempt home-relative
6
- subdirectories. The sandbox is a real repo root (it carries a `.git` marker) so
7
- relative `README.md` / `CHANGELOG.md` writes exercise the repo-root exemption
8
- path. This keeps the suites independent of where pytest itself is run.
9
- """
10
-
11
- import functools
12
- import json
13
- import os
14
- import shutil
15
- import stat
16
- import subprocess
17
- import sys
18
- import tempfile
19
- from pathlib import Path
20
-
21
- HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "md_to_html_blocker.py")
22
-
23
-
24
- def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):
25
- try:
26
- os.chmod(target_path, stat.S_IWRITE)
27
- removal_function(target_path)
28
- except OSError:
29
- pass
30
-
31
-
32
- def _force_rmtree(target_path: str) -> None:
33
- handler_kw = (
34
- {"onexc": _strip_read_only_and_retry}
35
- if sys.version_info >= (3, 12)
36
- else {"onerror": _strip_read_only_and_retry}
37
- )
38
- try:
39
- shutil.rmtree(target_path, **handler_kw)
40
- except OSError:
41
- pass
42
-
43
-
44
- @functools.lru_cache(maxsize=1)
45
- def _get_sandbox_parent_directory() -> str:
46
- sandbox_parent = tempfile.mkdtemp(prefix="pytest_md_blocker_", dir=str(Path.home()))
47
- git_marker_path = os.path.join(sandbox_parent, ".git")
48
- Path(git_marker_path).touch()
49
- return sandbox_parent
50
-
51
-
52
- class _RunHook:
53
- def __call__(self, tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
54
- payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
55
- return subprocess.run(
56
- [sys.executable, HOOK_SCRIPT_PATH],
57
- input=payload,
58
- capture_output=True,
59
- text=True,
60
- check=False,
61
- cwd=_get_sandbox_parent_directory(),
62
- )
63
-
64
-
65
- _run_hook = _RunHook()
@@ -1,30 +0,0 @@
1
- """Session-scoped cleanup fixture for the md_to_html_blocker test suites.
2
-
3
- The md_to_html_blocker suites share one lazily-created sandbox parent
4
- directory under the home directory. This fixture tears that sandbox down once
5
- the session ends so the suites leave no residue regardless of which split file
6
- pytest collects first.
7
- """
8
-
9
- import sys
10
- from pathlib import Path
11
-
12
- import pytest
13
-
14
- _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
15
-
16
- if str(_BLOCKING_DIRECTORY) not in sys.path:
17
- sys.path.insert(0, str(_BLOCKING_DIRECTORY))
18
-
19
- from _md_to_html_blocker_test_support import ( # noqa: E402
20
- _force_rmtree,
21
- _get_sandbox_parent_directory,
22
- )
23
-
24
-
25
- @pytest.fixture(scope="session", autouse=True)
26
- def _cleanup_sandbox_parent_directory():
27
- yield
28
- if _get_sandbox_parent_directory.cache_info().currsize:
29
- _force_rmtree(_get_sandbox_parent_directory())
30
- _get_sandbox_parent_directory.cache_clear()
@@ -1,224 +0,0 @@
1
- """Shared exemption rules for the .md blocker and its post-write companion.
2
-
3
- Both `md_to_html_blocker.py` (PreToolUse) and `md_to_html_companion.py`
4
- (PostToolUse) must agree on which file paths bypass the .md → .html policy.
5
- This module is the single source of truth for that decision.
6
- """
7
-
8
- import os
9
- import sys
10
- from pathlib import Path, PureWindowsPath
11
-
12
-
13
- _hooks_directory = str(Path(__file__).resolve().parent.parent)
14
- if _hooks_directory not in sys.path:
15
- sys.path.insert(0, _hooks_directory)
16
-
17
- from hooks_constants.md_to_html_blocker_constants import ( # noqa: E402
18
- ALL_CLAUDE_CODE_SOURCE_TOP_DIRECTORIES,
19
- ALL_EXEMPT_ANYWHERE_FILENAMES_LOWER,
20
- ALL_EXEMPT_HOME_DIRECTORY_PATH_PREFIXES,
21
- ALL_EXEMPT_PLUGIN_DIRECTORY_SEGMENTS,
22
- ALL_EXEMPT_ROOT_FILENAMES_LOWER,
23
- CLAUDE_DEV_ENV_REPO_NAME_SEGMENT,
24
- CLAUDE_DIRECTORY_NAME,
25
- CLAUDE_PROFILE_DIRECTORY_NAME_PREFIX,
26
- MINIMUM_SEGMENT_COUNT_TO_MATCH_INDICATOR,
27
- PACKAGES_TOP_LEVEL_SEGMENT,
28
- PLUGIN_ROOT_MARKER_DIRECTORY_NAME,
29
- REPO_ROOT_MARKER_NAME,
30
- RESOLVED_HOME_DIRECTORY_LOWER,
31
- RESOLVED_TEMP_DIRECTORY_PATH_PREFIX,
32
- )
33
-
34
-
35
- def is_exempt_path(file_path: str) -> bool:
36
- """Return True when the .md file path is exempt from the blocker policy.
37
-
38
- Exemption sources, in order of evaluation:
39
- - Any directory segment named `.claude` or prefixed `.claude-`
40
- (case-insensitive): project infrastructure, profile directories like
41
- `.claude-mel/`, and `.claude-plugin/`
42
- - Basename in `ALL_EXEMPT_ANYWHERE_FILENAMES` (e.g. SKILL.md)
43
- - Anchored under `packages/claude-dev-env/<one of
44
- ALL_CLAUDE_CODE_SOURCE_TOP_DIRECTORIES>/...` (docs, rules,
45
- system-prompts source files in this repo)
46
- - Path segment in `ALL_EXEMPT_PLUGIN_DIRECTORY_SEGMENTS` (agents/skills/commands)
47
- - Canonical path under a home-relative exempt directory
48
- (`ALL_EXEMPT_HOME_RELATIVE_DIRECTORIES`)
49
- - Canonical path under the OS temp directory
50
- - Ancestor directory contains `.claude-plugin/` (plugin-root marker walk)
51
- - Basename in `ALL_EXEMPT_ROOT_FILENAMES` and directory is a repo root
52
-
53
- Args:
54
- file_path: Raw file path from the hook payload. May contain tilde,
55
- backslashes, or be relative.
56
-
57
- Returns:
58
- True when the path is exempt, False when the policy applies.
59
- """
60
- expanded_path = os.path.expanduser(file_path)
61
- normalized = os.path.normpath(expanded_path).replace("\\", "/")
62
- lower_normalized = normalized.lower()
63
- if _has_claude_infrastructure_segment(lower_normalized):
64
- return True
65
- basename_lower = os.path.basename(normalized).lower()
66
- if basename_lower in ALL_EXEMPT_ANYWHERE_FILENAMES_LOWER:
67
- return True
68
- if _is_under_claude_dev_env_source_subdirectory(expanded_path, lower_normalized):
69
- return True
70
- if _has_plugin_directory_segment(lower_normalized):
71
- return True
72
- canonical_normalized_path = os.path.realpath(expanded_path).replace("\\", "/")
73
- canonical_lower_path = canonical_normalized_path.lower()
74
- if _is_under_exempt_home_directory(canonical_lower_path):
75
- return True
76
- if canonical_lower_path.startswith(RESOLVED_TEMP_DIRECTORY_PATH_PREFIX):
77
- return True
78
- if _is_under_plugin_root_marker(canonical_normalized_path):
79
- return True
80
- if basename_lower in ALL_EXEMPT_ROOT_FILENAMES_LOWER:
81
- absolute_directory = _resolve_absolute_directory(normalized)
82
- if _is_repo_root_directory(absolute_directory):
83
- return True
84
- return False
85
-
86
-
87
- def _resolve_absolute_directory(normalized_path: str) -> str:
88
- directory = os.path.dirname(normalized_path)
89
- if not directory or directory == ".":
90
- return os.getcwd()
91
- if os.path.isabs(directory):
92
- return directory
93
- return os.path.abspath(directory)
94
-
95
-
96
- def _has_claude_infrastructure_segment(lower_normalized_path: str) -> bool:
97
- """A directory named `.claude` or prefixed `.claude-` (profile and
98
- plugin directories) holds Claude infrastructure; any path inside one
99
- is exempt.
100
-
101
- Only directory segments are matched. The final segment is always the
102
- file's basename (``normpath`` strips any trailing slash), so a file
103
- merely named with the ``.claude-`` prefix in an ordinary directory
104
- stays subject to the policy.
105
-
106
- Args:
107
- lower_normalized_path: Lowercased path with separators normalized
108
- to forward slashes.
109
-
110
- Returns:
111
- True when any directory segment names a Claude infrastructure
112
- directory.
113
- """
114
- all_directory_segments = lower_normalized_path.split("/")[:-1]
115
- for each_segment in all_directory_segments:
116
- if each_segment == CLAUDE_DIRECTORY_NAME:
117
- return True
118
- if each_segment.startswith(CLAUDE_PROFILE_DIRECTORY_NAME_PREFIX):
119
- return True
120
- return False
121
-
122
-
123
- def _has_plugin_directory_segment(lower_normalized_path: str) -> bool:
124
- for each_directory_segment in ALL_EXEMPT_PLUGIN_DIRECTORY_SEGMENTS:
125
- segment_marker = f"/{each_directory_segment}/"
126
- if segment_marker in lower_normalized_path:
127
- return True
128
- if lower_normalized_path.startswith(f"{each_directory_segment}/"):
129
- return True
130
- return False
131
-
132
-
133
- def _is_absolute_path_cross_platform(file_path: str) -> bool:
134
- """Detect absolute paths in both POSIX and Windows drive-letter forms.
135
-
136
- ``os.path.isabs`` is platform-dependent: on Linux/macOS it classifies a
137
- Windows drive-letter path like ``Y:\\repo\\foo`` as relative. The anchored
138
- source-subdirectory exemption must scan every starting segment for
139
- absolute paths regardless of host OS, so a path's absoluteness must be
140
- decided cross-platform.
141
-
142
- Args:
143
- file_path: Tilde-expanded file path.
144
-
145
- Returns:
146
- True when the path is absolute under POSIX rules or carries a Windows
147
- drive-letter root (``[A-Za-z]:[\\\\/]...``).
148
- """
149
- if os.path.isabs(file_path):
150
- return True
151
- return PureWindowsPath(file_path).is_absolute()
152
-
153
-
154
- def _is_under_claude_dev_env_source_subdirectory(
155
- expanded_file_path: str, lower_normalized_path: str
156
- ) -> bool:
157
- """Anchored exemption for ``packages/claude-dev-env/<source-dir>/...``.
158
-
159
- The match requires segment-anchored matching at the start of the path
160
- (relative) or at the root of an absolute path. A nested path like
161
- ``notes/packages/claude-dev-env/docs/foo.md`` is NOT exempt — only the
162
- full three-segment anchor matches.
163
-
164
- Args:
165
- expanded_file_path: Tilde-expanded file path; ``os.path.isabs`` on
166
- this form classifies the path as absolute or relative on the
167
- current platform.
168
- lower_normalized_path: Same path lowercased and with separators
169
- normalized to forward slashes.
170
-
171
- Returns:
172
- True when the path is anchored under
173
- ``packages/claude-dev-env/<one of
174
- ALL_CLAUDE_CODE_SOURCE_TOP_DIRECTORIES>/``.
175
- """
176
- all_segments = [
177
- each_segment
178
- for each_segment in lower_normalized_path.split("/")
179
- if each_segment
180
- ]
181
- if not all_segments:
182
- return False
183
- if _is_absolute_path_cross_platform(expanded_file_path):
184
- starting_segment_index_options = list(range(len(all_segments)))
185
- else:
186
- starting_segment_index_options = [0]
187
- for each_starting_index in starting_segment_index_options:
188
- if (
189
- len(all_segments) >= each_starting_index + MINIMUM_SEGMENT_COUNT_TO_MATCH_INDICATOR
190
- and all_segments[each_starting_index] == PACKAGES_TOP_LEVEL_SEGMENT
191
- and all_segments[each_starting_index + 1] == CLAUDE_DEV_ENV_REPO_NAME_SEGMENT
192
- and all_segments[each_starting_index + 2] in ALL_CLAUDE_CODE_SOURCE_TOP_DIRECTORIES
193
- ):
194
- return True
195
- return False
196
-
197
-
198
- def _is_under_plugin_root_marker(normalized_path: str) -> bool:
199
- directory = os.path.dirname(normalized_path)
200
- visited_directories: set[str] = set()
201
- while directory and directory not in visited_directories:
202
- visited_directories.add(directory)
203
- marker_path = os.path.join(directory, PLUGIN_ROOT_MARKER_DIRECTORY_NAME)
204
- if os.path.isdir(marker_path):
205
- return True
206
- parent_directory = os.path.dirname(directory)
207
- if parent_directory == directory:
208
- break
209
- directory = parent_directory
210
- return False
211
-
212
-
213
- def _is_under_exempt_home_directory(lower_normalized_path: str) -> bool:
214
- if not RESOLVED_HOME_DIRECTORY_LOWER:
215
- return False
216
- for each_exempt_path_prefix in ALL_EXEMPT_HOME_DIRECTORY_PATH_PREFIXES:
217
- if lower_normalized_path.startswith(each_exempt_path_prefix):
218
- return True
219
- return False
220
-
221
-
222
- def _is_repo_root_directory(directory_path: str) -> bool:
223
- git_marker_path = os.path.join(directory_path, REPO_ROOT_MARKER_NAME)
224
- return os.path.exists(git_marker_path)
@@ -1,155 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PreToolUse hook: blocks Write/Edit targeting .md files, redirecting to .html.
3
-
4
- HTML preserves spatial structure (diffs, timelines, comparisons, diagrams)
5
- that markdown flattens. See https://thariqs.github.io/html-effectiveness/
6
- """
7
-
8
- import json
9
- import sys
10
- from pathlib import Path
11
- from typing import TextIO
12
-
13
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
14
- if _hooks_dir not in sys.path:
15
- sys.path.insert(0, _hooks_dir)
16
-
17
- _blocking_directory = str(Path(__file__).resolve().parent)
18
- if _blocking_directory not in sys.path:
19
- sys.path.insert(0, _blocking_directory)
20
-
21
- from md_path_exemptions import is_exempt_path # noqa: E402
22
-
23
- from hooks_constants.md_to_html_blocker_constants import ( # noqa: E402
24
- ALL_CLAUDE_CODE_SOURCE_TOP_DIRECTORIES,
25
- ALL_EXEMPT_ANYWHERE_FILENAMES,
26
- ALL_EXEMPT_HOME_RELATIVE_DIRECTORIES,
27
- ALL_EXEMPT_PLUGIN_DIRECTORY_SEGMENTS,
28
- CLAUDE_DEV_ENV_REPO_NAME_SEGMENT,
29
- CLAUDE_DIRECTORY_NAME,
30
- CLAUDE_PROFILE_DIRECTORY_NAME_PREFIX,
31
- PACKAGES_TOP_LEVEL_SEGMENT,
32
- PLUGIN_ROOT_MARKER_DIRECTORY_NAME,
33
- )
34
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
35
- from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
36
- read_hook_input_dictionary_from_stdin,
37
- )
38
-
39
- _markdown_extension = ".md"
40
- _html_effectiveness_url = "https://thariqs.github.io/html-effectiveness/"
41
- _exempt_anywhere_filenames_summary = ", ".join(ALL_EXEMPT_ANYWHERE_FILENAMES)
42
- _exempt_plugin_segments_summary = ", ".join(
43
- f"{each_segment}/" for each_segment in ALL_EXEMPT_PLUGIN_DIRECTORY_SEGMENTS
44
- )
45
- _exempt_home_directories_summary = ", ".join(
46
- f"~/{each_directory}/" for each_directory in ALL_EXEMPT_HOME_RELATIVE_DIRECTORIES
47
- )
48
- _claude_dev_env_source_directories_summary = (
49
- f"{PACKAGES_TOP_LEVEL_SEGMENT}/{CLAUDE_DEV_ENV_REPO_NAME_SEGMENT}/"
50
- f"{{{','.join(sorted(ALL_CLAUDE_CODE_SOURCE_TOP_DIRECTORIES))}}}/"
51
- )
52
-
53
-
54
- def _block_reason(file_path: str) -> str:
55
- return (
56
- f"BLOCKED: Write/Edit to .md file '{file_path}' is not permitted. "
57
- "Use .html files instead for documentation. "
58
- f"See {_html_effectiveness_url} for why HTML "
59
- "is more effective than Markdown for structured information."
60
- )
61
-
62
-
63
- def _block_context() -> str:
64
- return (
65
- "Generate a self-contained .html file instead of .md. "
66
- "Design freely — HTML can express spatial structure, interactivity, "
67
- "and visual hierarchy that markdown cannot.\n\n"
68
- "Reference for HTML effectiveness patterns:\n"
69
- f"{_html_effectiveness_url}\n"
70
- "Exceptions (.md still allowed):\n"
71
- f"- Files inside {CLAUDE_DIRECTORY_NAME}/ or {CLAUDE_PROFILE_DIRECTORY_NAME_PREFIX}*/ directories\n"
72
- f"- {_exempt_anywhere_filenames_summary} anywhere\n"
73
- f"- Files under {_exempt_plugin_segments_summary} directories\n"
74
- f"- Files under {_claude_dev_env_source_directories_summary} source directories\n"
75
- f"- Files under any directory whose ancestor contains {PLUGIN_ROOT_MARKER_DIRECTORY_NAME}/\n"
76
- "- README.md, CHANGELOG.md, CLAUDE.md, and AGENTS.md at any repo root\n"
77
- f"- Files under {_exempt_home_directories_summary}\n"
78
- "- Files under the OS temp directory"
79
- )
80
-
81
-
82
- def _block_system_message() -> str:
83
- return (
84
- ".md files are blocked in this project — generate a self-contained .html "
85
- f"file instead. See {_html_effectiveness_url} for "
86
- f"design patterns and examples. Exemptions: {CLAUDE_DIRECTORY_NAME}/ and "
87
- f"{CLAUDE_PROFILE_DIRECTORY_NAME_PREFIX}*/ infrastructure, "
88
- f"{_exempt_anywhere_filenames_summary} anywhere, {_exempt_plugin_segments_summary} trees, "
89
- f"{_claude_dev_env_source_directories_summary} source trees, "
90
- f"files under a {PLUGIN_ROOT_MARKER_DIRECTORY_NAME}/ root, "
91
- f"README.md/CHANGELOG.md/CLAUDE.md/AGENTS.md at any repo root, {_exempt_home_directories_summary}, "
92
- "and the OS temp directory."
93
- )
94
-
95
-
96
- def main() -> None:
97
- """Read hook input JSON from stdin, deny .md writes or pass through silently.
98
-
99
- Returns:
100
- None (exits process).
101
- """
102
- input_data = read_hook_input_dictionary_from_stdin()
103
- if input_data is None:
104
- sys.exit(0)
105
-
106
- tool_name = input_data.get("tool_name", "")
107
- if not isinstance(tool_name, str):
108
- sys.exit(0)
109
-
110
- tool_input = input_data.get("tool_input", {})
111
- if not isinstance(tool_input, dict):
112
- sys.exit(0)
113
-
114
- if tool_name not in ("Write", "Edit"):
115
- sys.exit(0)
116
-
117
- file_path = tool_input.get("file_path", "")
118
- if not file_path:
119
- sys.exit(0)
120
-
121
- if not file_path.lower().endswith(_markdown_extension):
122
- sys.exit(0)
123
-
124
- if is_exempt_path(file_path):
125
- sys.exit(0)
126
-
127
- deny_reason = _block_reason(file_path)
128
- block_payload = {
129
- "hookSpecificOutput": {
130
- "hookEventName": "PreToolUse",
131
- "permissionDecision": "deny",
132
- "permissionDecisionReason": deny_reason,
133
- "additionalContext": _block_context(),
134
- },
135
- "systemMessage": _block_system_message(),
136
- "suppressOutput": True,
137
- }
138
- log_hook_block(
139
- calling_hook_name="md_to_html_blocker.py",
140
- hook_event="PreToolUse",
141
- block_reason=deny_reason,
142
- tool_name=tool_name,
143
- offending_input_preview=file_path,
144
- )
145
- _emit_hook_result(block_payload, sys.stdout)
146
- sys.exit(0)
147
-
148
-
149
- def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
150
- output_stream.write(json.dumps(all_hook_data) + "\n")
151
- output_stream.flush()
152
-
153
-
154
- if __name__ == "__main__":
155
- main()