diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 65ee941..0a774a2 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "gemini-plugin", "displayName": "Gemini Plugin", - "version": "0.5.1", + "version": "0.6.0", "description": "Wraps gemini-mcp into a Claude Code plugin so Gemini acts as a second opinion: validator/challenger/researcher/summarizer/reviewer subagents, auto-trigger hooks, and 9 task-oriented skills.", "author": { "name": "azmym" }, "homepage": "https://github.com/azmym/gemini-plugin", diff --git a/CHANGELOG.md b/CHANGELOG.md index ff6e667..b76d7ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to gemini-plugin are documented here. The format follows [Ke ## [Unreleased] +## [0.6.0] - 2026-06-02 + +### Added + +- **Automatic design-review pass.** Whenever a design/plan artifact is written (a `*-design.md` spec, a `*-plan.md`, or a file under a `specs/`/`plans/` directory) via a new `PostToolUse(Write|Edit)` hook, or when native plan mode exits, the plugin asks Claude to dispatch gemini-validator (VALIDATE_DESIGN) and gemini-challenger (CHALLENGE_DESIGN) over it. The pass is advisory (findings surface but never block), deduped by file content hash so cosmetic re-edits do not re-fire, exempt from the manual one-consult-per-turn cap (it is part of the uncounted hook channel), and silenced by the existing `CLAUDE_PLUGIN_GEMINI_DISABLE_HOOKS` / `brainstorm.off` kill switch. The artifact globs are overridable via `CLAUDE_PLUGIN_GEMINI_DESIGN_GLOBS`. + +### Changed + +- **Verdict handling is now per-dispatch advisory-or-blocking.** `subagent-verdict-handler.sh` reads and consumes a per-agent "pending mode" marker written by the dispatching hook. A `fail`/`block` verdict blocks (exit 2) only when the marker is `blocking` (the default when no marker exists, which preserves the plan-validator and done-claim gates); the design-review pass writes `advisory` markers so its findings never halt the flow. +- **Native plan-mode exit now also runs an advisory challenger** alongside the existing blocking plan-validator (the validator gate is unchanged). + ## [0.5.1] - 2026-06-01 ### Fixed diff --git a/hooks/design-review.sh b/hooks/design-review.sh new file mode 100755 index 0000000..f21b7ca --- /dev/null +++ b/hooks/design-review.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# PostToolUse(Write|Edit) hook: when a design/plan artifact is written, ask +# Claude to dispatch BOTH gemini-validator (VALIDATE_DESIGN) and +# gemini-challenger (CHALLENGE_DESIGN) as an ADVISORY pass. Deduped by content +# hash so cosmetic re-writes do not re-fire. +# +# Pattern: exit 0 + JSON hookSpecificOutput.additionalContext. PostToolUse runs +# after the write has happened, so it never denies; it only injects context. +set -euo pipefail +trap 'echo "[gemini-plugin] design-review crashed at line ${LINENO} (last command: ${BASH_COMMAND})" >&2; exit 0' ERR + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/lib/common.sh" +source "${SCRIPT_DIR}/lib/prompt-builder.sh" + +check_plugin_enabled +check_gemini_available +ensure_data_dir + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +[ -z "$FILE_PATH" ] && exit 0 +is_design_artifact "$FILE_PATH" || exit 0 + +# Material-change dedup: skip if content hash matches the last reviewed hash. +SEEN_FILE=$(design_seen_file "$FILE_PATH") +NEW_HASH=$(file_content_hash "$FILE_PATH") +[ -z "$NEW_HASH" ] && exit 0 +if [ -f "$SEEN_FILE" ] && [ "$(cat "$SEEN_FILE")" = "$NEW_HASH" ]; then + exit 0 +fi + +HISTORY=$(get_plan_history "VALIDATE_DESIGN" 3) +write_pending_mode "gemini-validator" "advisory" +write_pending_mode "gemini-challenger" "advisory" +DIRECTIVE=$(build_design_review_directive "$FILE_PATH" "$HISTORY") + +jq -n --arg ctx "$DIRECTIVE" '{ + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: $ctx + } +}' + +mkdir -p "$(dirname "$SEEN_FILE")" +echo "$NEW_HASH" > "$SEEN_FILE" +exit 0 diff --git a/hooks/hooks.json b/hooks/hooks.json index 0ab2079..473e3d5 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -29,6 +29,14 @@ ] } ], + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/design-review.sh", "async": false } + ] + } + ], "PreCompact": [ { "hooks": [ diff --git a/hooks/lib/common.sh b/hooks/lib/common.sh index bb2b792..edefcbb 100644 --- a/hooks/lib/common.sh +++ b/hooks/lib/common.sh @@ -84,3 +84,61 @@ is_destructive_command() { local cmd="$1" echo "$cmd" | grep -qE '\brm\s+-[a-zA-Z]*[rRf]|\bgit\s+reset\s+--hard\b|\bgit\s+push\s+[^|;]*--force\b|\bDROP\s+(TABLE|DATABASE|SCHEMA)\b|\bTRUNCATE\s+TABLE\b|\bdd\s+if=|>\s*/dev/sd[a-z]' } + +# Default design-artifact globs (colon-separated). Overridable via +# CLAUDE_PLUGIN_GEMINI_DESIGN_GLOBS. Most globs start with */ so they match a +# path whether it is absolute or repo-relative; the bare *-plan.md entry matches +# a file ending in -plan.md at any depth (including the repo root). A path +# matches if it matches ANY glob. In [[ $x == $glob ]], * matches across slashes. +DEFAULT_DESIGN_GLOBS="*/superpowers/specs/*-design.md:*/superpowers/plans/*.md:*-plan.md:*/specs/*.md:*/plans/*.md:*/DESIGN.md:*/PLAN.md" + +is_design_artifact() { + local path="$1" + local globs="${CLAUDE_PLUGIN_GEMINI_DESIGN_GLOBS:-$DEFAULT_DESIGN_GLOBS}" + local IFS=':' + local g + for g in $globs; do + [ -z "$g" ] && continue + # shellcheck disable=SC2053 + if [[ "$path" == $g ]]; then + return 0 + fi + done + return 1 +} + +# Record the intended verdict-handling mode ("advisory"|"blocking") for an +# agent about to be dispatched. The verdict-handler consumes it on SubagentStop. +write_pending_mode() { + local agent="$1" + local mode="$2" + mkdir -p "$(data_dir)/pending" + echo "$mode" > "$(data_dir)/pending/${agent}.mode" +} + +# Print and delete the pending mode for an agent. Prints "blocking" if no +# marker exists, which preserves the original blocking plan/done-claim gates. +read_consume_pending_mode() { + local agent="$1" + local f + f="$(data_dir)/pending/${agent}.mode" + if [ -f "$f" ]; then + cat "$f" + rm -f "$f" + else + echo "blocking" + fi +} + +# SHA-256 of a file's contents (first field only). Empty if unreadable. +file_content_hash() { + shasum -a 256 "$1" 2>/dev/null | cut -d' ' -f1 +} + +# Path-keyed file storing the last-reviewed content hash for a design artifact. +design_seen_file() { + local path="$1" + local pathhash + pathhash=$(echo -n "$path" | shasum -a 256 | cut -c1-12) + echo "$(data_dir)/design-review-seen/${pathhash}.sha" +} diff --git a/hooks/lib/prompt-builder.sh b/hooks/lib/prompt-builder.sh index fe7e91d..d6d8157 100644 --- a/hooks/lib/prompt-builder.sh +++ b/hooks/lib/prompt-builder.sh @@ -18,6 +18,24 @@ IMPORTANT: Block until the subagent returns its structured JSON verdict. If verd EOF } +# Like build_directive, but for an ADVISORY consult: surface findings without +# blocking, instead of build_directive's "Block until ... address the gaps" +# footer. Use for hook-dispatched agents whose verdict must not halt the flow. +build_advisory_directive() { + local agent="$1" + local task="$2" + local context="$3" + + cat <&2 < "$F" + run bash -c "source hooks/lib/common.sh; file_content_hash '$F'" + [ "$status" -eq 0 ] + [ -n "$output" ] +} +@test "file_content_hash: differs when content changes" { + F="$BATS_TMPDIR/h2-$$.txt" + echo "v1" > "$F" + H1=$(bash -c "source hooks/lib/common.sh; file_content_hash '$F'") + echo "v2" > "$F" + H2=$(bash -c "source hooks/lib/common.sh; file_content_hash '$F'") + [ "$H1" != "$H2" ] +} +@test "design_seen_file: path-keyed, stable for same path" { + S1=$(bash -c 'source hooks/lib/common.sh; design_seen_file "docs/superpowers/specs/x-design.md"') + S2=$(bash -c 'source hooks/lib/common.sh; design_seen_file "docs/superpowers/specs/x-design.md"') + [ "$S1" = "$S2" ] + [[ "$S1" == *"/design-review-seen/"* ]] +} +@test "design_seen_file: distinct paths produce distinct seen files" { + S1=$(bash -c 'source hooks/lib/common.sh; design_seen_file "docs/superpowers/specs/a-design.md"') + S2=$(bash -c 'source hooks/lib/common.sh; design_seen_file "docs/superpowers/specs/b-design.md"') + [ "$S1" != "$S2" ] +} + +# --- directives --- +@test "build_design_review_directive: names both agents and both tasks" { + run bash -c 'source hooks/lib/common.sh; source hooks/lib/prompt-builder.sh; build_design_review_directive "docs/superpowers/specs/x-design.md" "[]"' + [ "$status" -eq 0 ] + [[ "$output" == *"gemini-validator"* ]] + [[ "$output" == *"VALIDATE_DESIGN"* ]] + [[ "$output" == *"gemini-challenger"* ]] + [[ "$output" == *"CHALLENGE_DESIGN"* ]] + [[ "$output" == *"docs/superpowers/specs/x-design.md"* ]] + [[ "$output" == *"ADVISORY"* ]] +} +@test "build_plan_challenge_directive: challenger + CHALLENGE_PLAN" { + run bash -c 'source hooks/lib/common.sh; source hooks/lib/prompt-builder.sh; build_plan_challenge_directive "Step 1: do X."' + [ "$status" -eq 0 ] + [[ "$output" == *"gemini-challenger"* ]] + [[ "$output" == *"CHALLENGE_PLAN"* ]] +} +@test "build_plan_challenge_directive: advisory, omits the blocking footer" { + run bash -c 'source hooks/lib/common.sh; source hooks/lib/prompt-builder.sh; build_plan_challenge_directive "Step 1: do X."' + [ "$status" -eq 0 ] + [[ "$output" != *"Block until"* ]] + [[ "$output" == *"ADVISORY"* ]] +} + +# --- design-review.sh hook --- +@test "design-review: non-design path exits 0 with no output" { + run bash -c 'echo "{\"tool_input\":{\"file_path\":\"src/foo.ts\"}}" | ./hooks/design-review.sh' + [ "$status" -eq 0 ] + [ -z "$output" ] +} +@test "design-review: empty file_path exits 0 with no output" { + run bash -c 'echo "{\"tool_input\":{\"file_path\":\"\"}}" | ./hooks/design-review.sh' + [ "$status" -eq 0 ] + [ -z "$output" ] +} +@test "design-review: first write of a design file dispatches both agents" { + SPEC="$BATS_TMPDIR/sd1-$$/docs/superpowers/specs/x-design.md" + mkdir -p "$(dirname "$SPEC")" + echo "# design v1" > "$SPEC" + run bash -c "echo '{\"tool_input\":{\"file_path\":\"${SPEC}\"}}' | ./hooks/design-review.sh" + [ "$status" -eq 0 ] + echo "$output" | jq -e '.hookSpecificOutput.hookEventName == "PostToolUse"' + echo "$output" | jq -e '.hookSpecificOutput.additionalContext | contains("gemini-validator")' + echo "$output" | jq -e '.hookSpecificOutput.additionalContext | contains("VALIDATE_DESIGN")' + echo "$output" | jq -e '.hookSpecificOutput.additionalContext | contains("gemini-challenger")' + echo "$output" | jq -e '.hookSpecificOutput.additionalContext | contains("CHALLENGE_DESIGN")' +} +@test "design-review: writes advisory pending markers for both agents" { + SPEC="$BATS_TMPDIR/sd2-$$/docs/superpowers/specs/y-design.md" + mkdir -p "$(dirname "$SPEC")" + echo "# design" > "$SPEC" + run bash -c "echo '{\"tool_input\":{\"file_path\":\"${SPEC}\"}}' | ./hooks/design-review.sh" + [ "$status" -eq 0 ] + [ "$(cat "$CLAUDE_PLUGIN_DATA/pending/gemini-validator.mode")" = "advisory" ] + [ "$(cat "$CLAUDE_PLUGIN_DATA/pending/gemini-challenger.mode")" = "advisory" ] +} +@test "design-review: unchanged content does not re-fire (hash dedup)" { + SPEC="$BATS_TMPDIR/sd3-$$/docs/superpowers/specs/z-design.md" + mkdir -p "$(dirname "$SPEC")" + echo "# stable design" > "$SPEC" + run bash -c "echo '{\"tool_input\":{\"file_path\":\"${SPEC}\"}}' | ./hooks/design-review.sh" + [ "$status" -eq 0 ] + [ -n "$output" ] + run bash -c "echo '{\"tool_input\":{\"file_path\":\"${SPEC}\"}}' | ./hooks/design-review.sh" + [ "$status" -eq 0 ] + [ -z "$output" ] +} +@test "design-review: materially changed content re-fires" { + SPEC="$BATS_TMPDIR/sd4-$$/docs/superpowers/specs/w-design.md" + mkdir -p "$(dirname "$SPEC")" + echo "# v1" > "$SPEC" + run bash -c "echo '{\"tool_input\":{\"file_path\":\"${SPEC}\"}}' | ./hooks/design-review.sh" + [ "$status" -eq 0 ] + [ -n "$output" ] + echo "# v2 substantially different content here" > "$SPEC" + run bash -c "echo '{\"tool_input\":{\"file_path\":\"${SPEC}\"}}' | ./hooks/design-review.sh" + [ -n "$output" ] +} +@test "design-review: exits 0 silently when disabled" { + export CLAUDE_PLUGIN_GEMINI_DISABLE_HOOKS=1 + SPEC="$BATS_TMPDIR/sd5-$$/docs/superpowers/specs/d-design.md" + mkdir -p "$(dirname "$SPEC")" + echo "# design" > "$SPEC" + run bash -c "echo '{\"tool_input\":{\"file_path\":\"${SPEC}\"}}' | ./hooks/design-review.sh" + [ "$status" -eq 0 ] + [ -z "$output" ] +} diff --git a/tests/hooks-triggers.bats b/tests/hooks-triggers.bats index 263c330..d813bb1 100644 --- a/tests/hooks-triggers.bats +++ b/tests/hooks-triggers.bats @@ -170,6 +170,19 @@ teardown() { [ "$status" -eq 0 ] [ -z "$output" ] } +@test "plan-complete: also dispatches advisory challenger alongside blocking validator" { + run bash -c 'echo "{\"tool_input\":{\"plan\":\"Step 1: do X.\"}}" | ./hooks/plan-complete.sh' + [ "$status" -eq 0 ] + echo "$output" | jq -e '.hookSpecificOutput.additionalContext | contains("VALIDATE_PLAN")' + echo "$output" | jq -e '.hookSpecificOutput.additionalContext | contains("gemini-challenger")' + echo "$output" | jq -e '.hookSpecificOutput.additionalContext | contains("CHALLENGE_PLAN")' +} +@test "plan-complete: marks challenger advisory and validator blocking" { + run bash -c 'echo "{\"tool_input\":{\"plan\":\"Step 1: do X.\"}}" | ./hooks/plan-complete.sh' + [ "$status" -eq 0 ] + [ "$(cat "$CLAUDE_PLUGIN_DATA/pending/gemini-challenger.mode")" = "advisory" ] + [ "$(cat "$CLAUDE_PLUGIN_DATA/pending/gemini-validator.mode")" = "blocking" ] +} # --- stop-done-claim --- # Uses exit 0 + JSON with decision=block + additionalContext. @@ -232,6 +245,30 @@ teardown() { run bash -c "echo '{\"agent_type\":\"gemini-reviewer\",\"transcript_path\":\"${TRANSCRIPT}\"}' | ./hooks/subagent-verdict-handler.sh" [ "$status" -eq 0 ] } +@test "verdict-handler: advisory marker demotes fail to non-blocking (exit 0)" { + TRANSCRIPT="$BATS_TMPDIR/transcript-adv-$$.jsonl" + echo '{"type":"assistant","message":{"content":[{"text":"{\"verdict\":\"fail\",\"gaps\":[\"a gap\"]}"}]}}' > "$TRANSCRIPT" + mkdir -p "$CLAUDE_PLUGIN_DATA/pending" + echo "advisory" > "$CLAUDE_PLUGIN_DATA/pending/gemini-validator.mode" + run bash -c "echo '{\"agent_type\":\"gemini-validator\",\"transcript_path\":\"${TRANSCRIPT}\"}' | ./hooks/subagent-verdict-handler.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"a gap"* ]] + [ ! -f "$CLAUDE_PLUGIN_DATA/pending/gemini-validator.mode" ] +} +@test "verdict-handler: blocking marker keeps fail blocking (exit 2)" { + TRANSCRIPT="$BATS_TMPDIR/transcript-blk-$$.jsonl" + echo '{"type":"assistant","message":{"content":[{"text":"{\"verdict\":\"fail\",\"gaps\":[\"a gap\"]}"}]}}' > "$TRANSCRIPT" + mkdir -p "$CLAUDE_PLUGIN_DATA/pending" + echo "blocking" > "$CLAUDE_PLUGIN_DATA/pending/gemini-validator.mode" + run bash -c "echo '{\"agent_type\":\"gemini-validator\",\"transcript_path\":\"${TRANSCRIPT}\"}' | ./hooks/subagent-verdict-handler.sh" + [ "$status" -eq 2 ] +} +@test "verdict-handler: no marker defaults to blocking (exit 2), no regression" { + TRANSCRIPT="$BATS_TMPDIR/transcript-def-$$.jsonl" + echo '{"type":"assistant","message":{"content":[{"text":"{\"verdict\":\"fail\",\"gaps\":[\"a gap\"]}"}]}}' > "$TRANSCRIPT" + run bash -c "echo '{\"agent_type\":\"gemini-validator\",\"transcript_path\":\"${TRANSCRIPT}\"}' | ./hooks/subagent-verdict-handler.sh" + [ "$status" -eq 2 ] +} # --- plugin disable --- diff --git a/tests/manifests.bats b/tests/manifests.bats index 8f7736e..e042065 100644 --- a/tests/manifests.bats +++ b/tests/manifests.bats @@ -23,3 +23,16 @@ @test "marketplace.json is not present (distributed via SynthForge)" { [ ! -f .claude-plugin/marketplace.json ] } +@test "hooks.json is valid JSON" { + jq empty hooks/hooks.json +} +@test "hooks.json declares PostToolUse(Write|Edit) -> design-review.sh" { + jq -e '.hooks.PostToolUse[0].matcher == "Write|Edit"' hooks/hooks.json + jq -e '.hooks.PostToolUse[0].hooks[0].command | endswith("design-review.sh")' hooks/hooks.json +} +@test "hooks.json SubagentStop matcher includes all five agents" { + M=$(jq -r '.hooks.SubagentStop[0].matcher' hooks/hooks.json) + for a in gemini-validator gemini-challenger gemini-researcher gemini-summarizer gemini-reviewer; do + [[ "$M" == *"$a"* ]] + done +}