claude-dev-env 1.88.1 → 1.90.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/CLAUDE.md +13 -34
- package/_shared/pr-loop/scripts/code_rules_gate.py +106 -14
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +26 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +160 -0
- package/agents/code-verifier.md +2 -1
- package/bin/install.mjs +0 -1
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +210 -0
- package/hooks/blocking/volatile_path_in_post_blocker.py +351 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +48 -0
- package/hooks/lifecycle/CLAUDE.md +3 -1
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +146 -0
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +178 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/durable-post-artifacts.md +35 -0
- package/scripts/CLAUDE.md +1 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +43 -0
- package/scripts/gh_artifact_upload.py +256 -0
- package/scripts/tests/test_gh_artifact_upload.py +205 -0
- package/skills/CLAUDE.md +0 -2
- package/skills/advisor/SKILL.md +0 -185
- package/skills/advisor-refresh/SKILL.md +0 -25
|
@@ -7,12 +7,14 @@ Hooks that run at session or config-change boundaries rather than on individual
|
|
|
7
7
|
| File | Event | What it does |
|
|
8
8
|
|---|---|---|
|
|
9
9
|
| `config_change_guard.py` | PostToolUse (Write/Edit on `settings.json`) | Counts hooks in the edited `settings.json` and logs any change to `~/.claude/cache/config-change-audit.log`; alerts when the hook count drops below the last known value |
|
|
10
|
+
| `enter_worktree_origin_prefetch.py` | PreToolUse (EnterWorktree) | Fetches origin's default branch before a worktree creation call, keeping the `refs/remotes/origin/<default-branch>` ref that `fresh` mode reads current; never blocks on fetch failure |
|
|
10
11
|
| `pr_converge_bugteam_skill_tracker.py` | PreToolUse (Skill) | Tracks which bugteam skill runs have completed within a pr-converge loop, so the enforcer can verify parallel execution |
|
|
11
12
|
| `session_end_cleanup.py` | SessionEnd | Purges stale cache entries from `~/.claude/cache/` (entries older than the configured threshold) and old backup files |
|
|
12
13
|
| `test_config_change_guard.py` | — | Tests for `config_change_guard.py` |
|
|
14
|
+
| `test_enter_worktree_origin_prefetch.py` | — | Tests for `enter_worktree_origin_prefetch.py` |
|
|
13
15
|
| `test_pr_converge_bugteam_skill_tracker.py` | — | Tests for `pr_converge_bugteam_skill_tracker.py` |
|
|
14
16
|
|
|
15
17
|
## Conventions
|
|
16
18
|
|
|
17
|
-
- Constants for these hooks (stale-age threshold, cache directory, known-hook-count file) live in `hooks_constants/session_env_cleanup_constants.py` and `hooks_constants/
|
|
19
|
+
- Constants for these hooks (stale-age threshold, cache directory, known-hook-count file, EnterWorktree prefetch tuning) live in `hooks_constants/session_env_cleanup_constants.py`, `hooks_constants/pr_converge_bugteam_enforcer_constants.py`, and `hooks_constants/enter_worktree_prefetch_constants.py`.
|
|
18
20
|
- Tests run with `python -m pytest lifecycle/test_<name>.py`.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: fetch origin's default branch before EnterWorktree creates one.
|
|
3
|
+
|
|
4
|
+
``EnterWorktree`` in its default ``fresh`` mode bases a new worktree on the
|
|
5
|
+
locally cached ``refs/remotes/origin/<default-branch>`` ref and only runs
|
|
6
|
+
``git fetch`` when that ref is missing entirely -- so a worktree created
|
|
7
|
+
without a recent fetch silently starts behind the remote. This hook fetches
|
|
8
|
+
that ref immediately before the tool resolves its base, so the ref it reads
|
|
9
|
+
is current.
|
|
10
|
+
|
|
11
|
+
::
|
|
12
|
+
|
|
13
|
+
EnterWorktree({}) # tool_input has no "path"
|
|
14
|
+
|
|
|
15
|
+
v
|
|
16
|
+
this hook: git fetch origin <default-branch> # best-effort, never blocks
|
|
17
|
+
|
|
|
18
|
+
v
|
|
19
|
+
EnterWorktree resolves refs/remotes/origin/<default-branch> # now fresh
|
|
20
|
+
|
|
21
|
+
The hook is a no-op when ``tool_input`` carries a ``path`` (switching into an
|
|
22
|
+
already-existing worktree needs no fresh base) and always exits 0 -- a failed
|
|
23
|
+
or slow fetch never blocks worktree creation.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
34
|
+
if _hooks_dir not in sys.path:
|
|
35
|
+
sys.path.insert(0, _hooks_dir)
|
|
36
|
+
|
|
37
|
+
from hooks_constants.enter_worktree_prefetch_constants import ( # noqa: E402
|
|
38
|
+
ENTER_WORKTREE_PATH_INPUT_KEY,
|
|
39
|
+
ENTER_WORKTREE_TOOL_NAME,
|
|
40
|
+
GIT_FETCH_TIMEOUT_SECONDS,
|
|
41
|
+
GIT_SYMBOLIC_REF_TIMEOUT_SECONDS,
|
|
42
|
+
ORIGIN_HEAD_SYMBOLIC_REF,
|
|
43
|
+
ORIGIN_REMOTE_NAME,
|
|
44
|
+
ORIGIN_REMOTE_REF_PREFIX,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_enter_worktree_creation(payload_by_field: dict[str, object]) -> bool:
|
|
49
|
+
"""Return True when this hook invocation is an EnterWorktree creation call.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
payload_by_field: The full PreToolUse hook payload (already JSON-parsed),
|
|
53
|
+
keyed by top-level field name.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
True when ``tool_name == "EnterWorktree"`` and ``tool_input`` carries
|
|
57
|
+
no ``path`` key. A ``path`` value means the call switches into an
|
|
58
|
+
already-existing worktree rather than creating one, so it is False
|
|
59
|
+
for that case and for every other tool.
|
|
60
|
+
"""
|
|
61
|
+
if payload_by_field.get("tool_name", "") != ENTER_WORKTREE_TOOL_NAME:
|
|
62
|
+
return False
|
|
63
|
+
tool_input = payload_by_field.get("tool_input", {})
|
|
64
|
+
if not isinstance(tool_input, dict):
|
|
65
|
+
return True
|
|
66
|
+
return not tool_input.get(ENTER_WORKTREE_PATH_INPUT_KEY)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_origin_default_branch(repo_directory: str) -> str | None:
|
|
70
|
+
"""Return the branch name origin's HEAD points at, or None if unresolved.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
repo_directory: Working directory to run ``git`` in.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The branch name (for example ``"main"``) parsed from
|
|
77
|
+
``refs/remotes/origin/HEAD``, or None when the symbolic ref is
|
|
78
|
+
unset, git is unavailable, or the command times out.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
completed_process = subprocess.run(
|
|
82
|
+
["git", "symbolic-ref", ORIGIN_HEAD_SYMBOLIC_REF],
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
timeout=GIT_SYMBOLIC_REF_TIMEOUT_SECONDS,
|
|
86
|
+
cwd=repo_directory,
|
|
87
|
+
check=False,
|
|
88
|
+
)
|
|
89
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
90
|
+
return None
|
|
91
|
+
if completed_process.returncode != 0:
|
|
92
|
+
return None
|
|
93
|
+
resolved_ref = completed_process.stdout.strip()
|
|
94
|
+
if not resolved_ref.startswith(ORIGIN_REMOTE_REF_PREFIX):
|
|
95
|
+
return None
|
|
96
|
+
return resolved_ref[len(ORIGIN_REMOTE_REF_PREFIX):]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def fetch_origin_branch(repo_directory: str, branch_name: str) -> None:
|
|
100
|
+
"""Best-effort ``git fetch origin <branch_name>``; never raises.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
repo_directory: Working directory to run ``git`` in.
|
|
104
|
+
branch_name: Branch to fetch from the ``origin`` remote.
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
subprocess.run(
|
|
108
|
+
["git", "fetch", ORIGIN_REMOTE_NAME, branch_name],
|
|
109
|
+
capture_output=True,
|
|
110
|
+
text=True,
|
|
111
|
+
timeout=GIT_FETCH_TIMEOUT_SECONDS,
|
|
112
|
+
cwd=repo_directory,
|
|
113
|
+
check=False,
|
|
114
|
+
)
|
|
115
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main() -> None:
|
|
120
|
+
"""Entry point for the PreToolUse:EnterWorktree hook.
|
|
121
|
+
|
|
122
|
+
Reads the PreToolUse payload from stdin, and when it is an EnterWorktree
|
|
123
|
+
creation call, fetches origin's default branch in the session's ``cwd``
|
|
124
|
+
before returning. Always exits 0: a fetch failure self-heals on the next
|
|
125
|
+
fetch rather than blocking worktree creation.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
hook_payload = json.load(sys.stdin)
|
|
129
|
+
except json.JSONDecodeError:
|
|
130
|
+
sys.exit(0)
|
|
131
|
+
if not isinstance(hook_payload, dict):
|
|
132
|
+
sys.exit(0)
|
|
133
|
+
if not is_enter_worktree_creation(hook_payload):
|
|
134
|
+
sys.exit(0)
|
|
135
|
+
repo_directory = hook_payload.get("cwd")
|
|
136
|
+
if not isinstance(repo_directory, str) or not repo_directory:
|
|
137
|
+
sys.exit(0)
|
|
138
|
+
default_branch = resolve_origin_default_branch(repo_directory)
|
|
139
|
+
if default_branch is None:
|
|
140
|
+
sys.exit(0)
|
|
141
|
+
fetch_origin_branch(repo_directory, default_branch)
|
|
142
|
+
sys.exit(0)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
main()
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Unit tests for the enter_worktree_origin_prefetch PreToolUse hook.
|
|
2
|
+
|
|
3
|
+
Uses real git repositories (a "remote" repo cloned into a "local" one) so the
|
|
4
|
+
fetch behavior is exercised against actual git plumbing, not mocked calls.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import pathlib
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
19
|
+
_HOOKS_TREE = _HOOK_DIR.parent
|
|
20
|
+
for each_path in (str(_HOOK_DIR), str(_HOOKS_TREE)):
|
|
21
|
+
if each_path not in sys.path:
|
|
22
|
+
sys.path.insert(0, each_path)
|
|
23
|
+
|
|
24
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
25
|
+
"enter_worktree_origin_prefetch",
|
|
26
|
+
_HOOK_DIR / "enter_worktree_origin_prefetch.py",
|
|
27
|
+
)
|
|
28
|
+
assert hook_spec is not None
|
|
29
|
+
assert hook_spec.loader is not None
|
|
30
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
31
|
+
hook_spec.loader.exec_module(hook_module)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _run_git(repo_directory: pathlib.Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
35
|
+
return subprocess.run(
|
|
36
|
+
["git", *args],
|
|
37
|
+
cwd=repo_directory,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
check=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _init_repo_with_commit(repo_directory: pathlib.Path) -> None:
|
|
45
|
+
repo_directory.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
_run_git(repo_directory, "init", "--quiet", "--initial-branch=main")
|
|
47
|
+
_run_git(repo_directory, "config", "user.email", "test@example.com")
|
|
48
|
+
_run_git(repo_directory, "config", "user.name", "Test")
|
|
49
|
+
_run_git(repo_directory, "commit", "--allow-empty", "--quiet", "-m", "init")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _clone_as_local(remote_directory: pathlib.Path, local_directory: pathlib.Path) -> None:
|
|
53
|
+
subprocess.run(
|
|
54
|
+
["git", "clone", "--quiet", str(remote_directory), str(local_directory)],
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True,
|
|
57
|
+
check=True,
|
|
58
|
+
)
|
|
59
|
+
_run_git(local_directory, "config", "user.email", "test@example.com")
|
|
60
|
+
_run_git(local_directory, "config", "user.name", "Test")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_is_enter_worktree_creation_true_when_no_path_given() -> None:
|
|
64
|
+
payload = {"tool_name": "EnterWorktree", "tool_input": {}}
|
|
65
|
+
assert hook_module.is_enter_worktree_creation(payload) is True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_is_enter_worktree_creation_true_with_name_only() -> None:
|
|
69
|
+
payload = {"tool_name": "EnterWorktree", "tool_input": {"name": "my-branch"}}
|
|
70
|
+
assert hook_module.is_enter_worktree_creation(payload) is True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_is_enter_worktree_creation_false_when_path_given() -> None:
|
|
74
|
+
payload = {"tool_name": "EnterWorktree", "tool_input": {"path": "/some/worktree"}}
|
|
75
|
+
assert hook_module.is_enter_worktree_creation(payload) is False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_is_enter_worktree_creation_false_for_other_tool() -> None:
|
|
79
|
+
payload = {"tool_name": "Bash", "tool_input": {}}
|
|
80
|
+
assert hook_module.is_enter_worktree_creation(payload) is False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_resolve_origin_default_branch_reads_cloned_head(tmp_path: pathlib.Path) -> None:
|
|
84
|
+
remote_directory = tmp_path / "remote"
|
|
85
|
+
local_directory = tmp_path / "local"
|
|
86
|
+
_init_repo_with_commit(remote_directory)
|
|
87
|
+
_clone_as_local(remote_directory, local_directory)
|
|
88
|
+
|
|
89
|
+
resolved_branch = hook_module.resolve_origin_default_branch(str(local_directory))
|
|
90
|
+
|
|
91
|
+
assert resolved_branch == "main"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_resolve_origin_default_branch_returns_none_without_origin(
|
|
95
|
+
tmp_path: pathlib.Path,
|
|
96
|
+
) -> None:
|
|
97
|
+
solo_directory = tmp_path / "solo"
|
|
98
|
+
_init_repo_with_commit(solo_directory)
|
|
99
|
+
|
|
100
|
+
resolved_branch = hook_module.resolve_origin_default_branch(str(solo_directory))
|
|
101
|
+
|
|
102
|
+
assert resolved_branch is None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_fetch_origin_branch_updates_stale_local_ref(tmp_path: pathlib.Path) -> None:
|
|
106
|
+
remote_directory = tmp_path / "remote"
|
|
107
|
+
local_directory = tmp_path / "local"
|
|
108
|
+
_init_repo_with_commit(remote_directory)
|
|
109
|
+
_clone_as_local(remote_directory, local_directory)
|
|
110
|
+
|
|
111
|
+
_run_git(remote_directory, "commit", "--allow-empty", "--quiet", "-m", "second")
|
|
112
|
+
remote_head_sha = _run_git(remote_directory, "rev-parse", "HEAD").stdout.strip()
|
|
113
|
+
local_cached_sha_before = _run_git(
|
|
114
|
+
local_directory, "rev-parse", "refs/remotes/origin/main"
|
|
115
|
+
).stdout.strip()
|
|
116
|
+
assert local_cached_sha_before != remote_head_sha
|
|
117
|
+
|
|
118
|
+
hook_module.fetch_origin_branch(str(local_directory), "main")
|
|
119
|
+
|
|
120
|
+
local_cached_sha_after = _run_git(
|
|
121
|
+
local_directory, "rev-parse", "refs/remotes/origin/main"
|
|
122
|
+
).stdout.strip()
|
|
123
|
+
assert local_cached_sha_after == remote_head_sha
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_fetch_origin_branch_never_raises_when_remote_missing(tmp_path: pathlib.Path) -> None:
|
|
127
|
+
solo_directory = tmp_path / "solo"
|
|
128
|
+
_init_repo_with_commit(solo_directory)
|
|
129
|
+
|
|
130
|
+
hook_module.fetch_origin_branch(str(solo_directory), "main")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_main_fetches_stale_ref_and_exits_zero(
|
|
134
|
+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
|
|
135
|
+
) -> None:
|
|
136
|
+
remote_directory = tmp_path / "remote"
|
|
137
|
+
local_directory = tmp_path / "local"
|
|
138
|
+
_init_repo_with_commit(remote_directory)
|
|
139
|
+
_clone_as_local(remote_directory, local_directory)
|
|
140
|
+
_run_git(remote_directory, "commit", "--allow-empty", "--quiet", "-m", "second")
|
|
141
|
+
remote_head_sha = _run_git(remote_directory, "rev-parse", "HEAD").stdout.strip()
|
|
142
|
+
|
|
143
|
+
payload = {
|
|
144
|
+
"tool_name": "EnterWorktree",
|
|
145
|
+
"tool_input": {},
|
|
146
|
+
"cwd": str(local_directory),
|
|
147
|
+
}
|
|
148
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
149
|
+
|
|
150
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
151
|
+
hook_module.main()
|
|
152
|
+
|
|
153
|
+
assert exit_info.value.code == 0
|
|
154
|
+
local_cached_sha_after = _run_git(
|
|
155
|
+
local_directory, "rev-parse", "refs/remotes/origin/main"
|
|
156
|
+
).stdout.strip()
|
|
157
|
+
assert local_cached_sha_after == remote_head_sha
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_main_exits_zero_for_non_enter_worktree_tool(
|
|
161
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
162
|
+
) -> None:
|
|
163
|
+
payload = {"tool_name": "Bash", "tool_input": {"command": "ls"}, "cwd": "/tmp"}
|
|
164
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
165
|
+
|
|
166
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
167
|
+
hook_module.main()
|
|
168
|
+
|
|
169
|
+
assert exit_info.value.code == 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_main_exits_zero_on_malformed_json(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
173
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO("not json"))
|
|
174
|
+
|
|
175
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
176
|
+
hook_module.main()
|
|
177
|
+
|
|
178
|
+
assert exit_info.value.code == 0
|
package/package.json
CHANGED
package/rules/CLAUDE.md
CHANGED
|
@@ -17,6 +17,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
|
|
|
17
17
|
| `conservative-action.md` | Research and recommend when intent is ambiguous; act only on explicit request |
|
|
18
18
|
| `context7.md` | Use Context7 MCP to fetch current library docs; always prefer live docs over built-in knowledge |
|
|
19
19
|
| `docstring-prose-matches-implementation.md` | Prose enumerations in docstrings cover every behavior the body applies |
|
|
20
|
+
| `durable-post-artifacts.md` | GitHub post bodies never reference volatile scratch paths; text embeds inline and binary artifacts upload to the `artifacts` release with the permanent URL linked |
|
|
20
21
|
| `explore-thoroughly.md` | Read relevant files and map existing patterns before proposing a change |
|
|
21
22
|
| `file-global-constants.md` | File-global constants need at least two same-file references; otherwise move value to `config/` |
|
|
22
23
|
| `gh-body-file.md` | Use `--body-file` with a temp file for all `gh` commands carrying markdown body content |
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Durable Post Artifacts
|
|
2
|
+
|
|
3
|
+
**When this applies:** Any GitHub post that lives on the server — an issue, a pull request, a comment, or a review — created through `gh` (`gh pr create/comment/edit/review`, `gh issue create/comment/edit`) or a GitHub MCP post tool.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
A post lives forever. Job scratch directories, worktrees, and system temp folders do not — they are cleaned soon after the run that made them. So a post must never point at a path in that scratch. The moment the directory is cleaned, the reference breaks and the reader is left with a dead path.
|
|
8
|
+
|
|
9
|
+
Handle the two kinds of content differently:
|
|
10
|
+
|
|
11
|
+
- **Text data** (logs, tables, diffs, stack traces): paste the actual text inline in the post body. Do not link a scratch file that holds it.
|
|
12
|
+
- **Binary artifacts** (images, screenshots, archives): upload the file to the repository's durable `artifacts` release and link the permanent URL. Use the helper:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
python3 ~/.claude/scripts/gh_artifact_upload.py <file-path> <owner/repo>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
It ensures the repository has a prerelease tagged `artifacts`, uploads the file under a `YYYYMMDD_HHMMSS_<name>` asset name, and prints the permanent download URL. Put that URL in the post.
|
|
19
|
+
|
|
20
|
+
## Volatile paths that must not appear in a post body
|
|
21
|
+
|
|
22
|
+
- A job scratch directory (`.claude-editor/jobs/`)
|
|
23
|
+
- A worktree (`.claude/worktrees/`)
|
|
24
|
+
- A system temp location (`AppData\Local\Temp`, `%TEMP%`, `$env:TEMP`, `/tmp/`)
|
|
25
|
+
- The job scratch environment variable (`$CLAUDE_JOB_DIR`)
|
|
26
|
+
|
|
27
|
+
Both slash directions count.
|
|
28
|
+
|
|
29
|
+
## Enforcement
|
|
30
|
+
|
|
31
|
+
The `volatile_path_in_post_blocker` PreToolUse hook reads the body of each `gh` post command and each GitHub MCP post call, scans it for these markers, and blocks the post when it finds one. For a `--body-file`, the hook reads the file and scans its contents, so writing the body to a temp file and passing it with `--body-file` stays allowed — what the hook rejects is a volatile path inside the text that gets posted.
|
|
32
|
+
|
|
33
|
+
## Why
|
|
34
|
+
|
|
35
|
+
A comment that cites an artifact under a job's tmp directory reads fine the moment it is posted and breaks a few minutes later, once the job is cleaned. Embedding text inline and linking binary artifacts to a durable release keeps every post readable for as long as it exists.
|
package/scripts/CLAUDE.md
CHANGED
|
@@ -6,6 +6,7 @@ Utility scripts installed into `~/.claude/scripts/` by `bin/install.mjs`. Each s
|
|
|
6
6
|
|
|
7
7
|
| File | Purpose |
|
|
8
8
|
|---|---|
|
|
9
|
+
| `gh_artifact_upload.py` | Uploads a file to a repo's durable `artifacts` prerelease under a timestamped asset name and prints the permanent download URL a GitHub post can link |
|
|
9
10
|
| `setup_project_paths.py` | One-time bootstrap: discovers git repos via `es.exe` (Everything) and writes `~/.claude/project-paths.json`; never hardcodes scan roots |
|
|
10
11
|
| `sweep_empty_dirs.py` | Deletes empty directories older than a configurable age under a given root; runs once (`--once`) or in continuous-watch mode |
|
|
11
12
|
| `sync_to_cursor.py` | Entry point for syncing Claude rules to Cursor `.mdc` files; delegates to the `sync_to_cursor/` package |
|
|
@@ -7,6 +7,7 @@ Named constants for scripts in `scripts/`. Follows the project convention that t
|
|
|
7
7
|
| File | Constants for |
|
|
8
8
|
|---|---|
|
|
9
9
|
| `timing.py` | `sweep_empty_dirs.py` — `DEFAULT_AGE_SECONDS` (smallest age before an empty directory is eligible for removal) and `DEFAULT_POLL_INTERVAL` (seconds between sweep passes in continuous-watch mode) |
|
|
10
|
+
| `gh_artifact_upload_constants.py` | `gh_artifact_upload.py` — the `artifacts` release tag, title, and notes body, the GitHub CLI binary name, the asset-name timestamp format and template, the asset download URL template, the notes-file suffix, and the text encoding |
|
|
10
11
|
| `__init__.py` | Empty package marker |
|
|
11
12
|
|
|
12
13
|
## Convention
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Constants for the gh_artifact_upload script.
|
|
2
|
+
|
|
3
|
+
Per the project's configuration conventions, script-level scalar constants
|
|
4
|
+
live in dev_env_scripts_constants alongside timing.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
ARTIFACTS_RELEASE_TAG: str = "artifacts"
|
|
8
|
+
"""Release tag that holds durable post artifacts."""
|
|
9
|
+
|
|
10
|
+
ARTIFACTS_RELEASE_TITLE: str = "Durable post artifacts"
|
|
11
|
+
"""Human-readable title for the artifacts release."""
|
|
12
|
+
|
|
13
|
+
ARTIFACTS_RELEASE_NOTES: str = (
|
|
14
|
+
"Permanent storage for binary artifacts linked from GitHub issue and pull "
|
|
15
|
+
"request posts. Job scratch directories are ephemeral and get cleaned soon "
|
|
16
|
+
"after a run; assets uploaded to this prerelease persist so a durable post "
|
|
17
|
+
"can link a stable URL."
|
|
18
|
+
)
|
|
19
|
+
"""Release body written when the artifacts release is first created."""
|
|
20
|
+
|
|
21
|
+
GH_BINARY_NAME: str = "gh"
|
|
22
|
+
"""The GitHub CLI executable name."""
|
|
23
|
+
|
|
24
|
+
ASSET_NAME_TIMESTAMP_FORMAT: str = "%Y%m%d_%H%M%S"
|
|
25
|
+
"""strftime format for the timestamp prefix on an uploaded asset name."""
|
|
26
|
+
|
|
27
|
+
ASSET_NAME_TEMPLATE: str = "{timestamp}_{basename}"
|
|
28
|
+
"""Template joining the timestamp prefix and the source file basename."""
|
|
29
|
+
|
|
30
|
+
RELEASE_ASSETS_JSON_KEY: str = "assets"
|
|
31
|
+
"""``gh release view --json`` field holding the release's asset list."""
|
|
32
|
+
|
|
33
|
+
ASSET_URL_JSON_KEY: str = "url"
|
|
34
|
+
"""Asset field carrying the browser download URL GitHub serves."""
|
|
35
|
+
|
|
36
|
+
ASSET_CREATED_AT_JSON_KEY: str = "createdAt"
|
|
37
|
+
"""Asset field carrying the ISO 8601 creation timestamp."""
|
|
38
|
+
|
|
39
|
+
NOTES_FILE_SUFFIX: str = ".md"
|
|
40
|
+
"""Suffix for the temporary release-notes file."""
|
|
41
|
+
|
|
42
|
+
UTF8_ENCODING: str = "utf-8"
|
|
43
|
+
"""Text encoding for subprocess output and temp files."""
|