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.
- package/_shared/pr-loop/audit-contract.md +24 -12
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
- package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
- package/agents/clean-coder.md +3 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +16 -8
- package/bin/install.test.mjs +17 -6
- package/commands/CLAUDE.md +0 -1
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +0 -4
- package/hooks/blocking/code_rules_constants_config.py +5 -0
- package/hooks/blocking/code_rules_docstrings.py +116 -6
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_magic_values.py +5 -0
- package/hooks/blocking/code_rules_naming_collection.py +11 -6
- package/hooks/blocking/code_rules_shared.py +34 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
- package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
- package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
- package/hooks/blocking/test_code_rules_magic_values.py +54 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
- package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
- package/hooks/hooks_constants/CLAUDE.md +0 -3
- package/hooks/hooks_constants/blocking_check_limits.py +3 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
- package/hooks/validation/post_tool_use_dispatcher.py +1 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
- package/hooks/workflow/CLAUDE.md +2 -8
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -1
- package/rules/plain-illustrative-docstrings.md +35 -1
- package/skills/CLAUDE.md +6 -1
- package/skills/autoconverge/SKILL.md +38 -57
- package/skills/autoconverge/reference/closing-report.md +6 -6
- package/skills/autoconverge/reference/convergence.md +17 -15
- package/skills/autoconverge/reference/gotchas.md +6 -3
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
- package/skills/autoconverge/workflow/converge.contract.test.mjs +87 -13
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
- package/skills/autoconverge/workflow/converge.mjs +70 -73
- package/skills/autoconverge/workflow/render_report.py +7 -11
- package/skills/bugteam/CLAUDE.md +1 -1
- package/skills/bugteam/PROMPTS.md +7 -6
- package/skills/bugteam/SKILL.md +25 -72
- package/skills/bugteam/reference/CLAUDE.md +1 -3
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +1 -1
- package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
- package/skills/bugteam/reference/team-setup.md +8 -7
- package/skills/bugteam/scripts/CLAUDE.md +0 -6
- package/skills/bugteam/scripts/README.md +0 -4
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
- package/skills/code/SKILL.md +2 -0
- package/skills/copilot-review/CLAUDE.md +1 -1
- package/skills/copilot-review/SKILL.md +25 -23
- package/skills/findbugs/CLAUDE.md +2 -2
- package/skills/findbugs/SKILL.md +22 -83
- package/skills/fixbugs/SKILL.md +2 -4
- package/skills/log-audit/CLAUDE.md +20 -0
- package/skills/log-audit/SKILL.md +68 -0
- package/skills/log-audit/reference/CLAUDE.md +9 -0
- package/skills/log-audit/reference/charter.md +52 -0
- package/skills/log-audit/scripts/CLAUDE.md +27 -0
- package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
- package/skills/log-audit/scripts/collect_log_window.py +199 -0
- package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
- package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
- package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
- package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
- package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
- package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
- package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
- package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
- package/skills/monitor-open-prs/SKILL.md +2 -2
- package/skills/post-audit-findings/SKILL.md +84 -0
- package/skills/pr-converge/CLAUDE.md +2 -0
- package/skills/pr-converge/SKILL.md +72 -59
- package/skills/pr-converge/reference/CLAUDE.md +1 -1
- package/skills/pr-converge/reference/convergence-gates.md +16 -19
- package/skills/pr-converge/reference/examples.md +5 -5
- package/skills/pr-converge/reference/fix-protocol.md +16 -43
- package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
- package/skills/pr-converge/reference/per-tick.md +24 -45
- package/skills/pr-converge/reference/state-schema.md +15 -0
- package/skills/pr-converge/scripts/README.md +3 -5
- package/skills/pr-fix-protocol/SKILL.md +70 -0
- package/skills/pr-loop-lifecycle/SKILL.md +73 -0
- package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
- package/skills/pr-scope-resolve/SKILL.md +48 -0
- package/skills/qbug/CLAUDE.md +4 -4
- package/skills/qbug/SKILL.md +46 -144
- package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
- package/skills/reviewer-gates/SKILL.md +96 -0
- package/skills/session-log/CLAUDE.md +7 -7
- package/skills/session-log/SKILL.md +27 -44
- package/skills/test_markdown_link_integrity.py +103 -0
- package/commands/doc-gist.md +0 -16
- package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
- package/hooks/blocking/conftest.py +0 -30
- package/hooks/blocking/md_path_exemptions.py +0 -224
- package/hooks/blocking/md_to_html_blocker.py +0 -155
- package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
- package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
- package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
- package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
- package/hooks/hooks_constants/html_companion_constants.py +0 -20
- package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
- package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
- package/hooks/workflow/doc_gist_auto_publish.py +0 -144
- package/hooks/workflow/md_to_html_companion.py +0 -358
- package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
- package/hooks/workflow/test_md_to_html_companion.py +0 -613
- package/skills/bugteam/reference/audit-contract.md +0 -163
- package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
- package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
- package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
- package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
- package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
- package/skills/doc-gist/CLAUDE.md +0 -25
- package/skills/doc-gist/SKILL.md +0 -97
- package/skills/doc-gist/references/CLAUDE.md +0 -9
- package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
- package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
- package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
- package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
- package/skills/doc-gist/references/examples/05-design-system.html +0 -629
- package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
- package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
- package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
- package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
- package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
- package/skills/doc-gist/references/examples/11-status-report.html +0 -528
- package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
- package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
- package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
- package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
- package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
- package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
- package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
- package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
- package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
- package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
- package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
- package/skills/doc-gist/references/examples/README.md +0 -5
- package/skills/doc-gist/scripts/CLAUDE.md +0 -27
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
- package/skills/doc-gist/scripts/gist_upload.py +0 -177
- package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
- /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
|
+
)
|
package/commands/doc-gist.md
DELETED
|
@@ -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()
|