Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .devcontainer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## [Unreleased]

### Fixed

#### Session Context Plugin
- **Commit reminder** no longer blocks Claude from stopping — switched from `decision: "block"` to advisory `systemMessage` wrapped in `<system-reminder>` tags
- **Commit reminder** now uses tiered logic: meaningful changes (3+ files, 2+ source files, or test files) get an advisory suggestion; small changes are silent
- **Commit reminder** only fires when the session actually modified files (via new PostToolUse edit tracker), preventing false reminders during read-only sessions

#### Auto Code Quality Plugin
- **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

### Added

#### README
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ This plugin bundles functionality that may overlap with other plugins. If you're
- `auto-linter` — linting is included here
- `code-directive` `collect-edited-files.py` hook — file collection is included here

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.
All pipelines use the `claude-cq-*` temp file prefix, so enabling both won't corrupt data — but files would be formatted and linted twice.

## Plugin Structure

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def get_edited_files(session_id: str) -> list[str]:
Relies on collect-edited-files.py writing paths to a temp file.
Returns deduplicated list of paths that still exist on disk.
"""
tmp_path = f"/tmp/claude-edited-files-{session_id}"
tmp_path = f"/tmp/claude-cq-edited-{session_id}"
try:
with open(tmp_path, "r") as f:
raw = f.read()
Expand Down Expand Up @@ -310,7 +310,9 @@ def main():
)
except subprocess.TimeoutExpired:
json.dump(
{"systemMessage": f"[Tests] {framework} timed out after {TIMEOUT_SECONDS}s"},
{
"systemMessage": f"[Tests] {framework} timed out after {TIMEOUT_SECONDS}s"
},
sys.stdout,
)
sys.exit(0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ Claude Code plugin that injects contextual information at session boundaries. Pr

## What It Does

Three hooks that run automatically at session lifecycle boundaries:
Four hooks that run automatically at session lifecycle boundaries:

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

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

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

### Edit Tracking

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.

### Commit Reminder

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

## How It Works

Expand All @@ -58,21 +66,27 @@ Session starts
| +-> Injects count + top 10 as additionalContext
|
| ... Claude works ...
| |
| +-> PostToolUse (Edit|Write) fires
| |
| +-> collect-session-edits.py
| |
| +-> Appends file path to /tmp/claude-session-edits-{session_id}
|
Claude stops responding
|
+-> Stop fires
|
+-> commit-reminder.py
|
+-> Checks git status for changes
+-> Has changes? -> Inject commit advisory
+-> No changes? -> Silent (no output)
+-> Session edited files? (checks tmp file)
+-> No edits this session? -> Silent (no output)
+-> Has edits + uncommitted changes? -> Inject advisory systemMessage
```

### Exit Code Behavior

All three scripts exit 0 (advisory only). They never block operations.
All four scripts exit 0 (advisory only). They never block operations.

### Error Handling

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

## Installation
Expand Down Expand Up @@ -125,11 +140,12 @@ session-context/
+-- .claude-plugin/
| +-- plugin.json # Plugin metadata
+-- hooks/
| +-- hooks.json # Hook registrations (SessionStart + Stop)
| +-- hooks.json # Hook registrations (SessionStart + PostToolUse + Stop)
+-- scripts/
| +-- git-state-injector.py # Git state context (SessionStart)
| +-- todo-harvester.py # Tech debt markers (SessionStart)
| +-- commit-reminder.py # Uncommitted changes advisory (Stop)
| +-- git-state-injector.py # Git state context (SessionStart)
| +-- todo-harvester.py # Tech debt markers (SessionStart)
| +-- collect-session-edits.py # Edit tracking (PostToolUse)
| +-- commit-reminder.py # Uncommitted changes advisory (Stop)
+-- README.md # This file
```

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
{
"description": "Context injection at session boundaries: git state, TODO harvesting, commit reminders",
"description": "Context injection at session boundaries: git state, TODO harvesting, edit tracking, commit reminders",
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/collect-session-edits.py",
"timeout": 3
}
]
}
],
"SessionStart": [
{
"matcher": "",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""
Collect edited file paths for session-aware Stop hooks.

Lightweight PostToolUse hook that appends the edited file path
to a session-scoped temp file. The commit-reminder Stop hook
reads this file to determine if the session modified any files.

Non-blocking: always exits 0. Runs in <10ms.
"""

import json
import os
import sys


def main():
try:
input_data = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
sys.exit(0)

session_id = input_data.get("session_id", "")
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path", "")

if not file_path or not session_id:
sys.exit(0)

if not os.path.isfile(file_path):
sys.exit(0)

tmp_path = f"/tmp/claude-session-edits-{session_id}"
try:
with open(tmp_path, "a") as f:
f.write(file_path + "\n")
except OSError:
pass # non-critical, don't block Claude

sys.exit(0)


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,31 @@
"""
Commit reminder — Stop hook that advises about uncommitted changes.

On Stop, checks for uncommitted changes (staged + unstaged) and injects
an advisory reminder as additionalContext. Claude sees it and can
naturally ask the user if they want to commit.
On Stop, checks whether this session edited any files (via the tmp file
written by collect-session-edits.py) and whether uncommitted changes exist.
Uses tiered logic: meaningful changes (3+ files, 2+ source files, or test
files touched) get an advisory suggestion; small changes are silent.

Reads hook input from stdin (JSON). Returns JSON on stdout.
Blocks with decision/reason so Claude addresses uncommitted changes
before finishing. The stop_hook_active guard prevents infinite loops.
Output is a systemMessage wrapped in <system-reminder> tags — advisory only,
never blocks. The stop_hook_active guard prevents loops.
"""

import json
import os
import subprocess
import sys

GIT_CMD_TIMEOUT = 5

# Extensions considered source code (not config/docs)
SOURCE_EXTS = frozenset((
".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs",
".java", ".kt", ".rb", ".svelte", ".vue", ".c", ".cpp", ".h",
))

# Patterns that indicate test files
TEST_PATTERNS = ("test_", "_test.", ".test.", ".spec.", "/tests/", "/test/")


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


def _read_session_edits(session_id: str) -> list[str]:
"""Read the list of files edited this session."""
tmp_path = f"/tmp/claude-session-edits-{session_id}"
try:
with open(tmp_path, "r") as f:
raw = f.read()
except OSError:
return []

seen: set[str] = set()
result: list[str] = []
for line in raw.strip().splitlines():
path = line.strip()
if path and path not in seen:
seen.add(path)
result.append(path)
return result


def _is_source_file(path: str) -> bool:
"""Check if a file path looks like source code."""
_, ext = os.path.splitext(path)
return ext.lower() in SOURCE_EXTS


def _is_test_file(path: str) -> bool:
"""Check if a file path looks like a test file."""
lower = path.lower()
return any(pattern in lower for pattern in TEST_PATTERNS)


def _is_meaningful(edited_files: list[str]) -> bool:
"""Determine if the session's edits are meaningful enough to suggest committing.

Meaningful when any of:
- 3+ total files edited
- 2+ source code files edited
- Any test files edited (suggests feature work)
"""
if len(edited_files) >= 3:
return True

source_count = sum(1 for f in edited_files if _is_source_file(f))
if source_count >= 2:
return True

if any(_is_test_file(f) for f in edited_files):
return True

return False


def main():
try:
input_data = json.load(sys.stdin)
Expand All @@ -44,7 +106,20 @@ def main():
if input_data.get("stop_hook_active"):
sys.exit(0)

# Check if there are any changes at all
# Only fire if this session actually edited files
session_id = input_data.get("session_id", "")
if not session_id:
sys.exit(0)

edited_files = _read_session_edits(session_id)
if not edited_files:
sys.exit(0)

# Small changes — stay silent
if not _is_meaningful(edited_files):
sys.exit(0)

# Check if there are any uncommitted changes
porcelain = _run_git(["status", "--porcelain"])
if porcelain is None:
# Not a git repo or git not available
Expand Down Expand Up @@ -79,11 +154,15 @@ def main():
summary = ", ".join(parts) if parts else f"{total} changed"

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

json.dump({"decision": "block", "reason": message}, sys.stdout)
json.dump({"systemMessage": message}, sys.stdout)
sys.exit(0)


Expand Down
Loading