Skip to content

Commit 226ec94

Browse files
committed
Fix commit-reminder blocking and test-runner tmp file mismatch
Commit reminder (session-context plugin): - Switch from decision:"block" to advisory systemMessage - Add tiered logic: meaningful changes suggest committing, small changes are silent - Only fire when session actually edited files (new PostToolUse collector) - Wrap output in <system-reminder> tags with explicit no-auto-commit instruction Test runner (auto-code-quality plugin): - Fix tmp file prefix from claude-edited-files to claude-cq-edited to match collector
1 parent b2261ed commit 226ec94

File tree

7 files changed

+191
-28
lines changed

7 files changed

+191
-28
lines changed

.devcontainer/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
## [Unreleased]
44

5+
### Fixed
6+
7+
#### Session Context Plugin
8+
- **Commit reminder** no longer blocks Claude from stopping — switched from `decision: "block"` to advisory `systemMessage` wrapped in `<system-reminder>` tags
9+
- **Commit reminder** now uses tiered logic: meaningful changes (3+ files, 2+ source files, or test files) get an advisory suggestion; small changes are silent
10+
- **Commit reminder** only fires when the session actually modified files (via new PostToolUse edit tracker), preventing false reminders during read-only sessions
11+
12+
#### Auto Code Quality Plugin
13+
- **Advisory test runner** now reads from the correct tmp file prefix (`claude-cq-edited` instead of `claude-edited-files`), fixing a mismatch that prevented it from ever finding edited files
14+
515
### Added
616

717
#### README

.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ This plugin bundles functionality that may overlap with other plugins. If you're
120120
- `auto-linter` — linting is included here
121121
- `code-directive` `collect-edited-files.py` hook — file collection is included here
122122

123-
The temp file prefixes are different (`claude-cq-*` vs `claude-edited-files-*` / `claude-lint-files-*`), so enabling both won't corrupt data — but files would be formatted and linted twice.
123+
All pipelines use the `claude-cq-*` temp file prefix, so enabling both won't corrupt data — but files would be formatted and linted twice.
124124

125125
## Plugin Structure
126126

.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/advisory-test-runner.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def get_edited_files(session_id: str) -> list[str]:
2525
Relies on collect-edited-files.py writing paths to a temp file.
2626
Returns deduplicated list of paths that still exist on disk.
2727
"""
28-
tmp_path = f"/tmp/claude-edited-files-{session_id}"
28+
tmp_path = f"/tmp/claude-cq-edited-{session_id}"
2929
try:
3030
with open(tmp_path, "r") as f:
3131
raw = f.read()
@@ -310,7 +310,9 @@ def main():
310310
)
311311
except subprocess.TimeoutExpired:
312312
json.dump(
313-
{"systemMessage": f"[Tests] {framework} timed out after {TIMEOUT_SECONDS}s"},
313+
{
314+
"systemMessage": f"[Tests] {framework} timed out after {TIMEOUT_SECONDS}s"
315+
},
314316
sys.stdout,
315317
)
316318
sys.exit(0)

.devcontainer/plugins/devs-marketplace/plugins/session-context/README.md

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ Claude Code plugin that injects contextual information at session boundaries. Pr
44

55
## What It Does
66

7-
Three hooks that run automatically at session lifecycle boundaries:
7+
Four hooks that run automatically at session lifecycle boundaries:
88

99
| Phase | Script | What It Injects |
1010
|-------|--------|-----------------|
1111
| Session start | `git-state-injector.py` | Current branch, status, recent commits, uncommitted changes |
1212
| Session start | `todo-harvester.py` | Count and top 10 TODO/FIXME/HACK/XXX markers in the codebase |
13-
| Stop | `commit-reminder.py` | Advisory about staged/unstaged changes that should be committed |
13+
| PostToolUse (Edit/Write) | `collect-session-edits.py` | Tracks which files the session modified (tmp file) |
14+
| Stop | `commit-reminder.py` | Advisory about uncommitted changes (only if session edited files) |
1415

1516
All hooks are non-blocking and cap their output to prevent context bloat.
1617

@@ -31,12 +32,19 @@ Scans source files for tech debt markers and injects a summary:
3132
- Shows total count plus top 10 items
3233
- Output capped at 800 characters
3334

35+
### Edit Tracking
36+
37+
Lightweight PostToolUse hook on Edit/Write that records file paths to `/tmp/claude-session-edits-{session_id}`. Used by the commit reminder to determine if this session actually modified files.
38+
3439
### Commit Reminder
3540

36-
Fires when Claude stops responding and checks for uncommitted work:
37-
- Detects staged and unstaged changes
38-
- Injects an advisory so Claude can naturally ask if the user wants to commit
39-
- Uses a guard flag to prevent infinite loops (the reminder itself is a Stop event)
41+
Fires when Claude stops responding, using tiered logic based on change significance:
42+
- Checks the session edit tracker — skips entirely if session was read-only
43+
- **Meaningful changes** (3+ files, 2+ source files, or test files): suggests committing via advisory `systemMessage`
44+
- **Small changes** (1-2 non-source files): silent, no output
45+
- Output wrapped in `<system-reminder>` tags — advisory only, never blocks
46+
- Instructs Claude not to commit without explicit user approval
47+
- Uses a guard flag to prevent infinite loops
4048

4149
## How It Works
4250

@@ -58,21 +66,27 @@ Session starts
5866
| +-> Injects count + top 10 as additionalContext
5967
|
6068
| ... Claude works ...
69+
| |
70+
| +-> PostToolUse (Edit|Write) fires
71+
| |
72+
| +-> collect-session-edits.py
73+
| |
74+
| +-> Appends file path to /tmp/claude-session-edits-{session_id}
6175
|
6276
Claude stops responding
6377
|
6478
+-> Stop fires
6579
|
6680
+-> commit-reminder.py
6781
|
68-
+-> Checks git status for changes
69-
+-> Has changes? -> Inject commit advisory
70-
+-> No changes? -> Silent (no output)
82+
+-> Session edited files? (checks tmp file)
83+
+-> No edits this session? -> Silent (no output)
84+
+-> Has edits + uncommitted changes? -> Inject advisory systemMessage
7185
```
7286

7387
### Exit Code Behavior
7488

75-
All three scripts exit 0 (advisory only). They never block operations.
89+
All four scripts exit 0 (advisory only). They never block operations.
7690

7791
### Error Handling
7892

@@ -88,6 +102,7 @@ All three scripts exit 0 (advisory only). They never block operations.
88102
|------|---------|
89103
| Git state injection | 10s |
90104
| TODO harvesting | 8s |
105+
| Edit tracking | 3s |
91106
| Commit reminder | 8s |
92107

93108
## Installation
@@ -125,11 +140,12 @@ session-context/
125140
+-- .claude-plugin/
126141
| +-- plugin.json # Plugin metadata
127142
+-- hooks/
128-
| +-- hooks.json # Hook registrations (SessionStart + Stop)
143+
| +-- hooks.json # Hook registrations (SessionStart + PostToolUse + Stop)
129144
+-- scripts/
130-
| +-- git-state-injector.py # Git state context (SessionStart)
131-
| +-- todo-harvester.py # Tech debt markers (SessionStart)
132-
| +-- commit-reminder.py # Uncommitted changes advisory (Stop)
145+
| +-- git-state-injector.py # Git state context (SessionStart)
146+
| +-- todo-harvester.py # Tech debt markers (SessionStart)
147+
| +-- collect-session-edits.py # Edit tracking (PostToolUse)
148+
| +-- commit-reminder.py # Uncommitted changes advisory (Stop)
133149
+-- README.md # This file
134150
```
135151

.devcontainer/plugins/devs-marketplace/plugins/session-context/hooks/hooks.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
{
2-
"description": "Context injection at session boundaries: git state, TODO harvesting, commit reminders",
2+
"description": "Context injection at session boundaries: git state, TODO harvesting, edit tracking, commit reminders",
33
"hooks": {
4+
"PostToolUse": [
5+
{
6+
"matcher": "Edit|Write",
7+
"hooks": [
8+
{
9+
"type": "command",
10+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/collect-session-edits.py",
11+
"timeout": 3
12+
}
13+
]
14+
}
15+
],
416
"SessionStart": [
517
{
618
"matcher": "",
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Collect edited file paths for session-aware Stop hooks.
4+
5+
Lightweight PostToolUse hook that appends the edited file path
6+
to a session-scoped temp file. The commit-reminder Stop hook
7+
reads this file to determine if the session modified any files.
8+
9+
Non-blocking: always exits 0. Runs in <10ms.
10+
"""
11+
12+
import json
13+
import os
14+
import sys
15+
16+
17+
def main():
18+
try:
19+
input_data = json.load(sys.stdin)
20+
except (json.JSONDecodeError, ValueError):
21+
sys.exit(0)
22+
23+
session_id = input_data.get("session_id", "")
24+
tool_input = input_data.get("tool_input", {})
25+
file_path = tool_input.get("file_path", "")
26+
27+
if not file_path or not session_id:
28+
sys.exit(0)
29+
30+
if not os.path.isfile(file_path):
31+
sys.exit(0)
32+
33+
tmp_path = f"/tmp/claude-session-edits-{session_id}"
34+
try:
35+
with open(tmp_path, "a") as f:
36+
f.write(file_path + "\n")
37+
except OSError:
38+
pass # non-critical, don't block Claude
39+
40+
sys.exit(0)
41+
42+
43+
if __name__ == "__main__":
44+
main()

.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/commit-reminder.py

Lines changed: 89 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,31 @@
22
"""
33
Commit reminder — Stop hook that advises about uncommitted changes.
44
5-
On Stop, checks for uncommitted changes (staged + unstaged) and injects
6-
an advisory reminder as additionalContext. Claude sees it and can
7-
naturally ask the user if they want to commit.
5+
On Stop, checks whether this session edited any files (via the tmp file
6+
written by collect-session-edits.py) and whether uncommitted changes exist.
7+
Uses tiered logic: meaningful changes (3+ files, 2+ source files, or test
8+
files touched) get an advisory suggestion; small changes are silent.
89
9-
Reads hook input from stdin (JSON). Returns JSON on stdout.
10-
Blocks with decision/reason so Claude addresses uncommitted changes
11-
before finishing. The stop_hook_active guard prevents infinite loops.
10+
Output is a systemMessage wrapped in <system-reminder> tags — advisory only,
11+
never blocks. The stop_hook_active guard prevents loops.
1212
"""
1313

1414
import json
15+
import os
1516
import subprocess
1617
import sys
1718

1819
GIT_CMD_TIMEOUT = 5
1920

21+
# Extensions considered source code (not config/docs)
22+
SOURCE_EXTS = frozenset((
23+
".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs",
24+
".java", ".kt", ".rb", ".svelte", ".vue", ".c", ".cpp", ".h",
25+
))
26+
27+
# Patterns that indicate test files
28+
TEST_PATTERNS = ("test_", "_test.", ".test.", ".spec.", "/tests/", "/test/")
29+
2030

2131
def _run_git(args: list[str]) -> str | None:
2232
"""Run a git command and return stdout, or None on any failure."""
@@ -34,6 +44,58 @@ def _run_git(args: list[str]) -> str | None:
3444
return None
3545

3646

47+
def _read_session_edits(session_id: str) -> list[str]:
48+
"""Read the list of files edited this session."""
49+
tmp_path = f"/tmp/claude-session-edits-{session_id}"
50+
try:
51+
with open(tmp_path, "r") as f:
52+
raw = f.read()
53+
except OSError:
54+
return []
55+
56+
seen: set[str] = set()
57+
result: list[str] = []
58+
for line in raw.strip().splitlines():
59+
path = line.strip()
60+
if path and path not in seen:
61+
seen.add(path)
62+
result.append(path)
63+
return result
64+
65+
66+
def _is_source_file(path: str) -> bool:
67+
"""Check if a file path looks like source code."""
68+
_, ext = os.path.splitext(path)
69+
return ext.lower() in SOURCE_EXTS
70+
71+
72+
def _is_test_file(path: str) -> bool:
73+
"""Check if a file path looks like a test file."""
74+
lower = path.lower()
75+
return any(pattern in lower for pattern in TEST_PATTERNS)
76+
77+
78+
def _is_meaningful(edited_files: list[str]) -> bool:
79+
"""Determine if the session's edits are meaningful enough to suggest committing.
80+
81+
Meaningful when any of:
82+
- 3+ total files edited
83+
- 2+ source code files edited
84+
- Any test files edited (suggests feature work)
85+
"""
86+
if len(edited_files) >= 3:
87+
return True
88+
89+
source_count = sum(1 for f in edited_files if _is_source_file(f))
90+
if source_count >= 2:
91+
return True
92+
93+
if any(_is_test_file(f) for f in edited_files):
94+
return True
95+
96+
return False
97+
98+
3799
def main():
38100
try:
39101
input_data = json.load(sys.stdin)
@@ -44,7 +106,20 @@ def main():
44106
if input_data.get("stop_hook_active"):
45107
sys.exit(0)
46108

47-
# Check if there are any changes at all
109+
# Only fire if this session actually edited files
110+
session_id = input_data.get("session_id", "")
111+
if not session_id:
112+
sys.exit(0)
113+
114+
edited_files = _read_session_edits(session_id)
115+
if not edited_files:
116+
sys.exit(0)
117+
118+
# Small changes — stay silent
119+
if not _is_meaningful(edited_files):
120+
sys.exit(0)
121+
122+
# Check if there are any uncommitted changes
48123
porcelain = _run_git(["status", "--porcelain"])
49124
if porcelain is None:
50125
# Not a git repo or git not available
@@ -79,11 +154,15 @@ def main():
79154
summary = ", ".join(parts) if parts else f"{total} changed"
80155

81156
message = (
82-
f"[Uncommitted Changes] {total} files with changes ({summary}).\n"
83-
"Consider asking the user if they'd like to commit before finishing."
157+
"<system-reminder>\n"
158+
f"[Session Summary] Modified {total} files ({summary}). "
159+
"This looks like a complete unit of work.\n"
160+
"Consider asking the user if they would like to commit.\n"
161+
"Do NOT commit without explicit user approval.\n"
162+
"</system-reminder>"
84163
)
85164

86-
json.dump({"decision": "block", "reason": message}, sys.stdout)
165+
json.dump({"systemMessage": message}, sys.stdout)
87166
sys.exit(0)
88167

89168

0 commit comments

Comments
 (0)