From 7867cef0817cfeaa916e323b35734e70afd28c53 Mon Sep 17 00:00:00 2001 From: AnExiledDev Date: Thu, 26 Feb 2026 17:05:18 +0000 Subject: [PATCH 1/2] 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 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 --- .devcontainer/CHANGELOG.md | 10 ++ .../plugins/auto-code-quality/README.md | 2 +- .../scripts/advisory-test-runner.py | 6 +- .../plugins/session-context/README.md | 44 ++++++--- .../plugins/session-context/hooks/hooks.json | 14 ++- .../scripts/collect-session-edits.py | 44 +++++++++ .../scripts/commit-reminder.py | 99 +++++++++++++++++-- 7 files changed, 191 insertions(+), 28 deletions(-) create mode 100644 .devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/collect-session-edits.py diff --git a/.devcontainer/CHANGELOG.md b/.devcontainer/CHANGELOG.md index f45e1e9..7702d01 100644 --- a/.devcontainer/CHANGELOG.md +++ b/.devcontainer/CHANGELOG.md @@ -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 `` 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 diff --git a/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/README.md b/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/README.md index 4a607e7..80b2d79 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/README.md +++ b/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/README.md @@ -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 diff --git a/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/advisory-test-runner.py b/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/advisory-test-runner.py index f9d9ca1..6c48c4f 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/advisory-test-runner.py +++ b/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/advisory-test-runner.py @@ -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() @@ -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) diff --git a/.devcontainer/plugins/devs-marketplace/plugins/session-context/README.md b/.devcontainer/plugins/devs-marketplace/plugins/session-context/README.md index e12a872..0ff0360 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/session-context/README.md +++ b/.devcontainer/plugins/devs-marketplace/plugins/session-context/README.md @@ -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. @@ -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 `` 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 @@ -58,6 +66,12 @@ 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 | @@ -65,14 +79,14 @@ Claude stops responding | +-> 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 @@ -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 @@ -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 ``` diff --git a/.devcontainer/plugins/devs-marketplace/plugins/session-context/hooks/hooks.json b/.devcontainer/plugins/devs-marketplace/plugins/session-context/hooks/hooks.json index b868078..c9c43bc 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/session-context/hooks/hooks.json +++ b/.devcontainer/plugins/devs-marketplace/plugins/session-context/hooks/hooks.json @@ -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": "", diff --git a/.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/collect-session-edits.py b/.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/collect-session-edits.py new file mode 100644 index 0000000..3b8fce7 --- /dev/null +++ b/.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/collect-session-edits.py @@ -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() diff --git a/.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/commit-reminder.py b/.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/commit-reminder.py index b317614..37d6154 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/commit-reminder.py +++ b/.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/commit-reminder.py @@ -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 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.""" @@ -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) @@ -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 @@ -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." + "\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" + "" ) - json.dump({"decision": "block", "reason": message}, sys.stdout) + json.dump({"systemMessage": message}, sys.stdout) sys.exit(0) From 607f19baa2a07146297b0d11afc151f882c4b4f2 Mon Sep 17 00:00:00 2001 From: AnExiledDev Date: Thu, 26 Feb 2026 21:26:05 +0000 Subject: [PATCH 2/2] Set executable permission on collect-session-edits.py Matches sibling scripts in the same directory. While hooks invoke via python3 (not direct execution), consistency prevents confusion. --- .../plugins/session-context/scripts/collect-session-edits.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 .devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/collect-session-edits.py diff --git a/.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/collect-session-edits.py b/.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/collect-session-edits.py old mode 100644 new mode 100755