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
1 change: 1 addition & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
"Edit(./.deepwork/**)",
"Write(./.deepwork/**)",
"Bash(deepwork:*)",
"Bash(make:*)",
"Bash(gh pr edit:*)",
"Bash(gh api:*)",
"Bash(learning_agents/scripts/*:*)",
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Post-commit review reminder hook now short-circuits when all applicable (non-catch-all) review rules for the committed files are already marked as passed, emitting "No re-review needed" instead of nagging

### Fixed

### Removed
Expand Down
1 change: 1 addition & 0 deletions doc/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ deepwork/ # DeepWork tool repository
│ ├── hooks/ # Hook system and cross-platform wrappers
│ │ ├── wrapper.py # Cross-platform input/output normalization
│ │ ├── deepschema_write.py # DeepSchema write-time validation hook
│ │ ├── post_commit_reminder.py # Post-commit review reminder hook
│ │ ├── claude_hook.sh # Shell wrapper for Claude Code
│ │ └── gemini_hook.sh # Shell wrapper for Gemini CLI
│ ├── deepschema/ # DeepSchema subsystem
Expand Down
2 changes: 1 addition & 1 deletion doc/mcp_interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ Array<{

### 8. `mark_review_as_passed`

Mark a review as passed so it won't be re-run while reviewed files remain unchanged. The `review_id` is provided in the instruction file's "After Review" section.
Mark a review as passed so it won't be re-run while reviewed files remain unchanged. Call this when a review has no findings, when all findings have been fixed, or when remaining findings have been explicitly dismissed by the user. The `review_id` is provided in the instruction file's "After Review" section.

This tool operates outside the workflow lifecycle — it can be called independently at any time.

Expand Down
15 changes: 4 additions & 11 deletions plugins/claude/hooks/post_commit_reminder.sh
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
#!/usr/bin/env bash
# Post-commit reminder hook
# Triggers after Bash tool uses that contain "git commit" to remind
# the agent to run the review skill.

# Post-commit reminder hook — delegates to deepwork Python hook.
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if echo "$COMMAND" | grep -q 'git commit'; then
cat << 'EOF'
{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"You **MUST** use AskUserQuestion tool to offer to the user to run the `review` skill to review the changes you just committed if you have not run a review recently."}}
EOF
fi
export DEEPWORK_HOOK_PLATFORM="claude"
echo "${INPUT}" | deepwork hook post_commit_reminder
exit $?
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ The Claude Code plugin is the primary distribution mechanism for DeepWork on the
### PLUG-REQ-001.7: Post-Commit Review Reminder

1. The plugin MUST register a `PostToolUse` hook on the `Bash` tool via `plugins/claude/hooks/hooks.json`.
2. When the agent runs a `git commit` command, the hook MUST prompt the agent to offer the user a review of the committed changes.
2. When the agent runs a `git commit` command and at least one review rule that matches the committed files has not been marked as passed, the hook MUST prompt the agent to offer the user a review of the committed changes.
3. Catch-all review rules (include patterns that are pure `*`/`**` globs) MUST be excluded from this determination.
4. When every applicable review rule for the committed files is already marked as passed (or no non-catch-all rule matches), the hook MUST return a context notice stating that no re-review is needed instead of prompting.

### PLUG-REQ-001.8: Skill Directory Conventions

Expand Down
1 change: 1 addition & 0 deletions src/deepwork/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ pytest tests/shell_script_tests/test_hook_wrappers.py -v
|------|---------|
| `wrapper.py` | Cross-platform input/output normalization |
| `deepschema_write.py` | DeepSchema write-time validation hook |
| `post_commit_reminder.py` | Post-commit hook that nudges the agent to run `/review` (skips if all reviews already passed) |
| `claude_hook.sh` | Shell wrapper for Claude Code |
| `gemini_hook.sh` | Shell wrapper for Gemini CLI |
| `.deepreview` | Review rule ensuring hooks use correct output routing (DW-REQ-006.6) |
82 changes: 82 additions & 0 deletions src/deepwork/hooks/post_commit_reminder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""PostToolUse hook: nudge the agent to run the review skill after a
git commit -- but only when at least one applicable review has not
already been marked as passed for the committed files."""

from __future__ import annotations

import os
import subprocess
import sys
from pathlib import Path

from deepwork.hooks.wrapper import (
HookInput,
HookOutput,
NormalizedEvent,
Platform,
output_hook_error,
run_hook,
)

REMINDER_CONTEXT = (
"You **MUST** use AskUserQuestion tool to offer to the user to run "
"the `review` skill to review the changes you just committed if you "
"have not run a review recently."
)

ALL_PASSED_CONTEXT = "No re-review needed - all reviews passed for committed files"


def post_commit_reminder_hook(hook_input: HookInput) -> HookOutput:
if hook_input.event != NormalizedEvent.AFTER_TOOL:
return HookOutput()
if hook_input.tool_name != "shell":
return HookOutput()
command = hook_input.tool_input.get("command", "") or ""
if "git commit" not in command:
return HookOutput()

cwd = hook_input.cwd or os.getcwd()
project_root = Path(cwd)

committed = _committed_files(project_root)
if committed is None:
# Git call failed; fall back to the safe old behavior.
return HookOutput(context=REMINDER_CONTEXT)

try:
from deepwork.review.mcp import all_reviews_passed_for_files

passed = all_reviews_passed_for_files(project_root, committed)
except Exception:
return HookOutput(context=REMINDER_CONTEXT)

return HookOutput(context=ALL_PASSED_CONTEXT if passed else REMINDER_CONTEXT)


def _committed_files(project_root: Path) -> list[str] | None:
"""Return files in HEAD's commit, or ``None`` if the git command fails."""
try:
result = subprocess.run(
["git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
cwd=project_root,
capture_output=True,
text=True,
check=True,
)
except (OSError, subprocess.CalledProcessError):
return None
return [line for line in result.stdout.splitlines() if line.strip()]


def main() -> int:
platform = Platform(os.environ.get("DEEPWORK_HOOK_PLATFORM", "claude"))
return run_hook(post_commit_reminder_hook, platform)


if __name__ == "__main__": # pragma: no cover
try:
sys.exit(main())
except Exception as e:
output_hook_error(e, context="post_commit_reminder hook")
sys.exit(0)
6 changes: 4 additions & 2 deletions src/deepwork/jobs/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,8 +490,10 @@ async def get_configured_reviews(
@mcp.tool(
description=(
"Mark a review as passed so it won't be re-run while reviewed files "
"remain unchanged. The review_id is provided in the instruction file's "
'"After Review" section.'
"remain unchanged. Call this when a review has no findings, when all "
"findings have been fixed, or when remaining findings have been "
"explicitly dismissed by the user. The review_id is provided in the "
'instruction file\'s "After Review" section.'
)
)
async def mark_review_as_passed(review_id: str, ctx: Context) -> str:
Expand Down
3 changes: 2 additions & 1 deletion src/deepwork/review/instructions.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,8 @@ def build_instruction_file(
if review_id:
parts.append("## After Review\n")
parts.append(
"If this review passes with no findings, call the `mark_review_as_passed` tool with:\n"
"If this review passes with no findings, or if all findings have been "
"addressed or explicitly dismissed, call the `mark_review_as_passed` tool with:\n"
)
parts.append(f'- `review_id`: `"{review_id}"`')
parts.append("")
Expand Down
38 changes: 36 additions & 2 deletions src/deepwork/review/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@
from pathlib import Path

from deepwork.deepschema.review_bridge import generate_review_rules as gen_schema_rules
from deepwork.review.config import ReviewRule
from deepwork.review.discovery import DiscoveryError, load_all_rules
from deepwork.review.formatter import format_for_claude
from deepwork.review.instructions import INSTRUCTIONS_DIR, write_instruction_files
from deepwork.review.instructions import (
INSTRUCTIONS_DIR,
compute_review_id,
write_instruction_files,
)
from deepwork.review.matcher import (
GitDiffError,
format_source_location,
Expand All @@ -34,7 +39,7 @@ def _is_catch_all_pattern(pattern: str) -> bool:
return pattern != "" and all(c in "*/" for c in pattern)


def _rule_is_catch_all(rule) -> bool: # type: ignore[no-untyped-def]
def _rule_is_catch_all(rule: ReviewRule) -> bool:
"""Return True if every include pattern on the rule is a catch-all."""
return bool(rule.include_patterns) and all(
_is_catch_all_pattern(p) for p in rule.include_patterns
Expand Down Expand Up @@ -216,3 +221,32 @@ def mark_passed(project_root: Path, review_id: str) -> str:
passed_file.write_bytes(b"")

return f"Review '{review_id}' marked as passed."


def all_reviews_passed_for_files(
project_root: Path,
files: list[str],
) -> bool:
"""Return True iff every non-catch-all review rule that matches ``files``
has a ``.passed`` marker for its computed review_id.

Vacuously True when no rules match (or ``files`` is empty).

Catch-all rules (include patterns that are pure ``*``/``**`` globs) are
excluded — matches the scoping used by ``get_configured_reviews`` and
``run_review`` when files are specified explicitly.
"""
rules, _ = load_all_rules(project_root)
schema_rules, _ = gen_schema_rules(project_root)
rules.extend(schema_rules)

rules = [r for r in rules if not _rule_is_catch_all(r)]

tasks = match_files_to_rules(files, rules, project_root)

instructions_dir = project_root / INSTRUCTIONS_DIR
for task in tasks:
review_id = compute_review_id(task, project_root)
if not (instructions_dir / f"{review_id}.passed").exists():
return False
return True
26 changes: 7 additions & 19 deletions tests/unit/plugins/test_claude_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,25 +323,13 @@ def test_registers_post_tool_use_on_bash(self) -> None:
def test_hook_script_detects_git_commit(self) -> None:
# THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.7.2).
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
"""PLUG-REQ-001.7.2: hook script detects git commit and prompts review."""
data = json.loads(self.hooks_json_path.read_text())
hooks = data["hooks"]["PostToolUse"]
bash_hook = next(h for h in hooks if h.get("matcher") == "Bash")

# Get the hook command path
hook_commands = bash_hook.get("hooks", [])
assert len(hook_commands) >= 1

# Read the actual hook script
command = hook_commands[0]["command"]
# The command uses ${CLAUDE_PLUGIN_ROOT} — resolve relative to plugin dir
script_name = command.split("/")[-1]
script_path = PLUGIN_DIR / "hooks" / script_name
assert script_path.exists(), f"Hook script not found: {script_path}"

content = script_path.read_text(encoding="utf-8")
assert "git commit" in content, "Hook script must detect git commit commands"
assert "review" in content.lower(), "Hook script must prompt for review"
"""PLUG-REQ-001.7.2: Python hook detects git commit and prompts review."""
from deepwork.hooks.post_commit_reminder import post_commit_reminder_hook

hook_py = Path(post_commit_reminder_hook.__code__.co_filename)
content = hook_py.read_text(encoding="utf-8")
assert "git commit" in content, "Python hook must detect git commit commands"
assert "review" in content.lower(), "Python hook must reference review"


# ---------------------------------------------------------------------------
Expand Down
Loading
Loading