claude-dev-env 1.85.0 → 1.86.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/scripts/code_rules_gate.py +18 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +142 -7
- package/_shared/pr-loop/scripts/terminology_sweep.py +288 -92
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +89 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +180 -91
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +7 -4
- package/hooks/blocking/session_edit_stage_gate.py +654 -0
- package/hooks/blocking/test_session_edit_stage_gate.py +537 -0
- package/hooks/hooks.json +30 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +92 -0
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +18 -0
- package/hooks/lifecycle/CLAUDE.md +1 -1
- package/hooks/observability/CLAUDE.md +2 -0
- package/hooks/observability/session_file_edit_tracker.py +224 -0
- package/hooks/observability/test_session_file_edit_tracker.py +174 -0
- package/hooks/session/CLAUDE.md +6 -2
- package/hooks/session/session_edit_tracker_cleanup.py +120 -0
- package/hooks/session/task_list_loop_starter.py +36 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +157 -0
- package/hooks/session/test_task_list_loop_starter.py +53 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/re-stage-before-commit.md +31 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionStart hook — start a task-list maintenance loop for the session.
|
|
3
|
+
|
|
4
|
+
At session start this hook emits an ``additionalContext`` directive asking Claude
|
|
5
|
+
to keep the session's task list current on a 10-minute cadence, starting the
|
|
6
|
+
``/loop`` skill when one is not already running. The hook writes nothing and runs
|
|
7
|
+
no tools itself. Claude reads the directive and invokes the ``/loop`` skill.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
17
|
+
if _hooks_dir not in sys.path:
|
|
18
|
+
sys.path.insert(0, _hooks_dir)
|
|
19
|
+
|
|
20
|
+
from hooks_constants.task_list_loop_starter_constants import ( # noqa: E402
|
|
21
|
+
TASK_LIST_LOOP_DIRECTIVE,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_session_directive() -> str:
|
|
26
|
+
"""Return the task-list loop directive emitted at session start."""
|
|
27
|
+
return TASK_LIST_LOOP_DIRECTIVE
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> None:
|
|
31
|
+
"""Emit the task-list loop directive as SessionStart additionalContext."""
|
|
32
|
+
print(json.dumps({"additionalContext": build_session_directive()}))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
main()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Behavior tests for the session_edit_tracker_cleanup hook.
|
|
2
|
+
|
|
3
|
+
The cleanup deletes only the running session's own session-edit file, and only
|
|
4
|
+
on a fresh startup (SessionStart with a ``startup`` source) or a session end. A
|
|
5
|
+
SessionStart that continues the same conversation — a compact or a resume —
|
|
6
|
+
keeps the in-flight tracker. A peer session's file is always left alone,
|
|
7
|
+
regardless of its age. These tests redirect ``tempfile.gettempdir`` to a
|
|
8
|
+
temporary directory, seed it with the current session's file and peer-session
|
|
9
|
+
files, run the hook's ``main`` through a lifecycle payload, and confirm which
|
|
10
|
+
files survive.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import importlib.util
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import pathlib
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
from unittest import mock
|
|
25
|
+
|
|
26
|
+
import pytest
|
|
27
|
+
|
|
28
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
29
|
+
_HOOKS_TREE = _HOOK_DIR.parent
|
|
30
|
+
for each_path in (str(_HOOK_DIR), str(_HOOKS_TREE)):
|
|
31
|
+
if each_path not in sys.path:
|
|
32
|
+
sys.path.insert(0, each_path)
|
|
33
|
+
|
|
34
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
35
|
+
"session_edit_tracker_cleanup",
|
|
36
|
+
_HOOK_DIR / "session_edit_tracker_cleanup.py",
|
|
37
|
+
)
|
|
38
|
+
assert hook_spec is not None
|
|
39
|
+
assert hook_spec.loader is not None
|
|
40
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
41
|
+
hook_spec.loader.exec_module(hook_module)
|
|
42
|
+
|
|
43
|
+
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
44
|
+
ALL_EDITED_FILE_PATHS_KEY,
|
|
45
|
+
SESSION_EDIT_FILE_PREFIX,
|
|
46
|
+
SESSION_EDIT_FILE_SUFFIX,
|
|
47
|
+
STATE_FILE_DEFAULT_SESSION_ID,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
AGED_TRACKER_FILE_SECONDS = 1860
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run_main_with_stdin(input_text: str) -> None:
|
|
54
|
+
with (
|
|
55
|
+
mock.patch("sys.stdin", io.StringIO(input_text)),
|
|
56
|
+
mock.patch("sys.stdout", new_callable=io.StringIO),
|
|
57
|
+
contextlib.suppress(SystemExit),
|
|
58
|
+
):
|
|
59
|
+
hook_module.main()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture()
|
|
63
|
+
def redirected_temp_directory(
|
|
64
|
+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
|
|
65
|
+
) -> pathlib.Path:
|
|
66
|
+
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
|
|
67
|
+
return tmp_path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _seed_edit_file(temp_directory: pathlib.Path, session_id: str) -> pathlib.Path:
|
|
71
|
+
edit_file = temp_directory / f"{SESSION_EDIT_FILE_PREFIX}{session_id}{SESSION_EDIT_FILE_SUFFIX}"
|
|
72
|
+
edit_file.write_text(json.dumps({ALL_EDITED_FILE_PATHS_KEY: []}), encoding="utf-8")
|
|
73
|
+
return edit_file
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _age_file(target_file: pathlib.Path, age_seconds: float) -> None:
|
|
77
|
+
old_time = time.time() - age_seconds
|
|
78
|
+
os.utime(target_file, (old_time, old_time))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _session_start_payload(session_id: str) -> str:
|
|
82
|
+
return json.dumps({"session_id": session_id, "source": "startup"})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _session_continuation_payload(session_id: str, source: str) -> str:
|
|
86
|
+
return json.dumps({"session_id": session_id, "source": source})
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _session_end_payload(session_id: str) -> str:
|
|
90
|
+
return json.dumps({"session_id": session_id, "hook_event_name": "SessionEnd"})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_main_keeps_other_session_stale_tracker(
|
|
94
|
+
redirected_temp_directory: pathlib.Path,
|
|
95
|
+
) -> None:
|
|
96
|
+
other_session_file = _seed_edit_file(redirected_temp_directory, "idle-live-session")
|
|
97
|
+
_age_file(other_session_file, AGED_TRACKER_FILE_SECONDS)
|
|
98
|
+
_run_main_with_stdin(_session_start_payload("currentsession"))
|
|
99
|
+
assert other_session_file.exists()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_should_keep_fresh_edit_file(redirected_temp_directory: pathlib.Path) -> None:
|
|
103
|
+
fresh_file = _seed_edit_file(redirected_temp_directory, "other-live-session")
|
|
104
|
+
_run_main_with_stdin(_session_start_payload("currentsession"))
|
|
105
|
+
assert fresh_file.exists()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_should_remove_current_session_edit_file(
|
|
109
|
+
redirected_temp_directory: pathlib.Path,
|
|
110
|
+
) -> None:
|
|
111
|
+
current_file = _seed_edit_file(redirected_temp_directory, "currentsession")
|
|
112
|
+
_run_main_with_stdin(_session_start_payload("currentsession"))
|
|
113
|
+
assert not current_file.exists()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_main_removes_current_session_stale_tracker(
|
|
117
|
+
redirected_temp_directory: pathlib.Path,
|
|
118
|
+
) -> None:
|
|
119
|
+
current_file = _seed_edit_file(redirected_temp_directory, "currentsession")
|
|
120
|
+
_age_file(current_file, AGED_TRACKER_FILE_SECONDS)
|
|
121
|
+
_run_main_with_stdin(_session_start_payload("currentsession"))
|
|
122
|
+
assert not current_file.exists()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.parametrize("continuation_source", ["compact", "resume"])
|
|
126
|
+
def test_should_keep_current_session_tracker_on_continuation(
|
|
127
|
+
redirected_temp_directory: pathlib.Path, continuation_source: str
|
|
128
|
+
) -> None:
|
|
129
|
+
current_file = _seed_edit_file(redirected_temp_directory, "currentsession")
|
|
130
|
+
_run_main_with_stdin(
|
|
131
|
+
_session_continuation_payload("currentsession", continuation_source)
|
|
132
|
+
)
|
|
133
|
+
assert current_file.exists()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_should_remove_current_session_tracker_on_session_end(
|
|
137
|
+
redirected_temp_directory: pathlib.Path,
|
|
138
|
+
) -> None:
|
|
139
|
+
current_file = _seed_edit_file(redirected_temp_directory, "currentsession")
|
|
140
|
+
_run_main_with_stdin(_session_end_payload("currentsession"))
|
|
141
|
+
assert not current_file.exists()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_should_exit_zero_on_malformed_stdin(
|
|
145
|
+
redirected_temp_directory: pathlib.Path,
|
|
146
|
+
) -> None:
|
|
147
|
+
fresh_file = _seed_edit_file(redirected_temp_directory, "other-live-session")
|
|
148
|
+
_run_main_with_stdin("not valid json {{{")
|
|
149
|
+
assert fresh_file.exists()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_malformed_stdin_keeps_default_session_tracker(
|
|
153
|
+
redirected_temp_directory: pathlib.Path,
|
|
154
|
+
) -> None:
|
|
155
|
+
default_file = _seed_edit_file(redirected_temp_directory, STATE_FILE_DEFAULT_SESSION_ID)
|
|
156
|
+
_run_main_with_stdin("not valid json {{{")
|
|
157
|
+
assert default_file.exists()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Tests for task_list_loop_starter — SessionStart hook that starts a task-list loop."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
10
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
11
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
12
|
+
if each_sys_path_entry not in sys.path:
|
|
13
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
14
|
+
|
|
15
|
+
import task_list_loop_starter as starter
|
|
16
|
+
|
|
17
|
+
from hooks_constants.task_list_loop_starter_constants import (
|
|
18
|
+
TASK_LIST_LOOP_DIRECTIVE,
|
|
19
|
+
TASK_LIST_MAINTENANCE_INSTRUCTION,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run_main() -> str:
|
|
24
|
+
"""Return stdout produced by running the hook's main()."""
|
|
25
|
+
captured_stdout = StringIO()
|
|
26
|
+
with patch("sys.stdout", captured_stdout):
|
|
27
|
+
starter.main()
|
|
28
|
+
return captured_stdout.getvalue()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestSessionDirective:
|
|
32
|
+
def test_main_emits_additional_context(self) -> None:
|
|
33
|
+
emitted = json.loads(_run_main())
|
|
34
|
+
assert "additionalContext" in emitted
|
|
35
|
+
|
|
36
|
+
def test_directive_carries_the_one_line_instruction(self) -> None:
|
|
37
|
+
emitted = json.loads(_run_main())
|
|
38
|
+
assert TASK_LIST_MAINTENANCE_INSTRUCTION in emitted["additionalContext"]
|
|
39
|
+
|
|
40
|
+
def test_directive_names_the_ten_minute_cadence(self) -> None:
|
|
41
|
+
emitted = json.loads(_run_main())
|
|
42
|
+
assert "10-minute" in emitted["additionalContext"]
|
|
43
|
+
|
|
44
|
+
def test_directive_names_the_loop_command(self) -> None:
|
|
45
|
+
emitted = json.loads(_run_main())
|
|
46
|
+
assert "/loop 10m" in emitted["additionalContext"]
|
|
47
|
+
|
|
48
|
+
def test_directive_is_idempotent_about_an_existing_loop(self) -> None:
|
|
49
|
+
emitted = json.loads(_run_main())
|
|
50
|
+
assert "not already running" in emitted["additionalContext"]
|
|
51
|
+
|
|
52
|
+
def test_build_session_directive_returns_the_shared_constant(self) -> None:
|
|
53
|
+
assert starter.build_session_directive() == TASK_LIST_LOOP_DIRECTIVE
|
package/package.json
CHANGED
package/rules/CLAUDE.md
CHANGED
|
@@ -36,6 +36,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
|
|
|
36
36
|
| `plain-illustrative-docstrings.md` | Public docstring narrative reads plainly and shows behavior with a diagram block (a `::` example or a doctest), painting a concrete scene a general developer follows on first read; a run-on backstop hook, a prose-wall backstop hook, and Category O9 audit enforce it |
|
|
37
37
|
| `plain-language.md` | Everyday words, short active sentences, lead with the answer |
|
|
38
38
|
| `prompt-workflow-context-controls.md` | Keep prompt-workflow instruction layers small and stable; load heavy skills on demand |
|
|
39
|
+
| `re-stage-before-commit.md` | Stage the files edited this session before `git commit`; the session edit stage gate denies a commit that leaves a tracked session edit unstaged, with `-a`, a pathspec, a preceding `git add`, and `# partial-commit` as escapes |
|
|
39
40
|
| `research-mode.md` | Three anti-hallucination constraints: say "I don't know", verify with citations, quote for factual grounding |
|
|
40
41
|
| `right-sized-engineering.md` | Simple over clever; functions over classes; concrete over abstract |
|
|
41
42
|
| `self-contained-docs.md` | Every document is fully self-contained; no references to the conversation that produced it |
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Re-Stage Session Edits Before Commit
|
|
2
|
+
|
|
3
|
+
**When this applies:** Any `git commit` run through the Bash tool in a git repository.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
Stage the files you edited this session right before you commit them. A file this session changed but left unstaged is dropped by a plain `git commit` — the commit records the staged snapshot and the edit stays behind in the working tree.
|
|
8
|
+
|
|
9
|
+
The `session_edit_stage_gate` hook holds you to this. It reads the per-session tracker that records every file the session edited, checks which of those files are tracked yet still unstaged at commit time, and denies the commit when any are left out. The denial names each file and gives the exact fix: `git add <paths>`, `git commit -a`, or a `# partial-commit` marker.
|
|
10
|
+
|
|
11
|
+
## What the gate allows
|
|
12
|
+
|
|
13
|
+
The gate steps aside for a commit that skips staged files on purpose:
|
|
14
|
+
|
|
15
|
+
- **`-a` / `--all`** — the commit already takes every tracked change, so nothing is dropped.
|
|
16
|
+
- **A pathspec** — `git commit -- <paths>` or `git commit <paths>` commits only the named paths on purpose.
|
|
17
|
+
- **A preceding `git add` / `git stage`** — `git add <paths> && git commit …` stages the files in its own segment before the commit runs, so they are staged by the time the commit records the index.
|
|
18
|
+
- **`# partial-commit`** — add this marker to the command to commit the staged set on purpose and leave the rest.
|
|
19
|
+
|
|
20
|
+
A `--amend` does not step the gate aside: an amend records the staged snapshot too, so an unstaged session edit is dropped the same way a plain commit drops it.
|
|
21
|
+
|
|
22
|
+
A missing tracker file or any git failure allows the commit, so the gate never blocks on a tooling problem.
|
|
23
|
+
|
|
24
|
+
## Companion hooks
|
|
25
|
+
|
|
26
|
+
- `session_file_edit_tracker` (PostToolUse) records the resolved absolute path of each Write, Edit, and MultiEdit into the per-session tracker file.
|
|
27
|
+
- `session_edit_tracker_cleanup` (SessionStart, SessionEnd) deletes the running session's own tracker file: at a fresh SessionStart so a new session begins with an empty record, and at SessionEnd so a clean exit leaves nothing behind. A tracker is read only by the session that wrote it, so a file a crashed session leaves behind is inert and no peer session touches it.
|
|
28
|
+
|
|
29
|
+
## Why
|
|
30
|
+
|
|
31
|
+
A stale git index is a quiet failure: the commit succeeds, the branch looks right, and one file you meant to include never lands. Catching it at commit time keeps the staged set and the session's edits in step.
|