From 35f489eb09aa1480352c0b8bfd44fa48dc1d1a98 Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Fri, 3 Apr 2026 12:39:41 -0400 Subject: [PATCH 1/3] Add multi-layer enforcement stack and similarity detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand hook coverage from PreToolUse-only to full 4-layer stack: - PostToolUse: validates output, detects secrets in writes, flags errors - SubagentStop: blocks agents from exiting without running tests - Stop: quality gate checking uncommitted changes, conflict markers, TODOs Add Groundhog Day detection to improve loop — stops when consecutive metric outputs are >90% similar, preventing wasted budget on identical failing approaches. --- hooks/hooks.json | 52 ++++++++++++++++++++++- hooks/post-validate.sh | 87 ++++++++++++++++++++++++++++++++++++++ hooks/stop-gate.sh | 64 ++++++++++++++++++++++++++++ hooks/subagent-stop.sh | 83 ++++++++++++++++++++++++++++++++++++ src/lib/similarity.go | 45 ++++++++++++++++++++ src/lib/similarity_test.go | 62 +++++++++++++++++++++++++++ src/loops/improve.go | 22 +++++++++- src/loops/loops_test.go | 71 +++++++++++++++++++++++++++++++ 8 files changed, 484 insertions(+), 2 deletions(-) create mode 100755 hooks/post-validate.sh create mode 100755 hooks/stop-gate.sh create mode 100755 hooks/subagent-stop.sh create mode 100644 src/lib/similarity.go create mode 100644 src/lib/similarity_test.go diff --git a/hooks/hooks.json b/hooks/hooks.json index fe1525c..9629069 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Safety hooks and RTK token optimization for Bash, Edit, and Write tools", + "description": "Multi-layer enforcement stack: PreToolUse safety + RTK optimization, PostToolUse validation, SubagentStop verification, Stop quality gate", "hooks": { "PreToolUse": [ { @@ -35,6 +35,56 @@ } ] } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/post-validate.sh", + "statusMessage": "Validating output...", + "timeout": 10 + } + ] + }, + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/post-validate.sh", + "statusMessage": "Validating write...", + "timeout": 10 + } + ] + } + ], + "SubagentStop": [ + { + "matcher": "Stop", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-stop.sh", + "statusMessage": "Verifying agent work...", + "timeout": 10 + } + ] + } + ], + "Stop": [ + { + "matcher": "Stop", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop-gate.sh", + "statusMessage": "Quality gate...", + "timeout": 10 + } + ] + } ] } } diff --git a/hooks/post-validate.sh b/hooks/post-validate.sh new file mode 100755 index 0000000..d00e05d --- /dev/null +++ b/hooks/post-validate.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# devkit PostToolUse hook — validates work after Bash/Edit/Write execution +# +# Checks for common post-execution issues: +# - Bash commands that silently failed (non-zero exit hidden in piped output) +# - Edit/Write operations that created files outside the repo +# - Accidental secret/credential content in written files +# +# Hook input (JSON on stdin): +# .tool_name = "Bash" | "Edit" | "Write" +# .tool_input = the original tool input +# .tool_output = the tool's output/result +# +# Exit codes: +# 0 → allow (with optional feedback message) +# 1 → hook error (allow by default) + +set -euo pipefail + +INPUT=$(cat) +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') +TOOL_OUTPUT=$(echo "$INPUT" | jq -r '.tool_output // empty') +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') +CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty') + +# --- Bash: check for suppressed errors --- +if [ "$TOOL_NAME" = "Bash" ]; then + # Flag common error patterns in output that the agent might miss + if echo "$TOOL_OUTPUT" | grep -qiE 'permission denied|no such file or directory|command not found|segmentation fault|killed|out of memory'; then + jq -n --arg msg "$(echo "$TOOL_OUTPUT" | grep -iE 'permission denied|no such file or directory|command not found|segmentation fault|killed|out of memory' | head -3)" '{ + hookSpecificOutput: { + hookEventName: "PostToolUse", + permissionDecision: "allow", + permissionDecisionReason: ("Warning: command output contains error signals — verify this was expected: " + $msg) + } + }' + exit 0 + fi +fi + +# --- Edit/Write: check for secrets in content --- +if [ "$TOOL_NAME" = "Edit" ] || [ "$TOOL_NAME" = "Write" ]; then + CHECK_CONTENT="$CONTENT" + if [ -z "$CHECK_CONTENT" ]; then + CHECK_CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty') + fi + + if [ -n "$CHECK_CONTENT" ]; then + # Check for patterns that look like hardcoded secrets + if echo "$CHECK_CONTENT" | grep -qE '(sk-[a-zA-Z0-9]{20,}|AKIA[A-Z0-9]{16}|ghp_[a-zA-Z0-9]{36}|-----BEGIN (RSA |EC )?PRIVATE KEY)'; then + jq -n '{ + hookSpecificOutput: { + hookEventName: "PostToolUse", + permissionDecision: "allow", + permissionDecisionReason: "WARNING: Written content appears to contain a hardcoded secret or API key. Use environment variables instead." + } + }' + exit 0 + fi + fi + + # Check for writes outside the git repo + if [ -n "$FILE_PATH" ]; then + REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) + if [ -n "$REPO_ROOT" ]; then + case "$FILE_PATH" in + "$REPO_ROOT"/*) + ;; # within repo, OK + /tmp/*|/private/tmp/*) + ;; # temp files, OK + *) + jq -n --arg file "$FILE_PATH" --arg repo "$REPO_ROOT" '{ + hookSpecificOutput: { + hookEventName: "PostToolUse", + permissionDecision: "allow", + permissionDecisionReason: ("Note: file written outside repository root (" + $repo + "): " + $file) + } + }' + exit 0 + ;; + esac + fi + fi +fi + +# All clear +exit 0 diff --git a/hooks/stop-gate.sh b/hooks/stop-gate.sh new file mode 100755 index 0000000..9de8b59 --- /dev/null +++ b/hooks/stop-gate.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# devkit Stop hook — final quality gate before session ends +# +# Checks for common issues that indicate incomplete work: +# - Uncommitted changes left in the working tree +# - Merge conflict markers in tracked files +# - TODO/FIXME markers introduced in the current diff +# +# Hook input (JSON on stdin): +# .tool_name = "Stop" +# .session_id = current session ID (if available) +# +# Exit codes: +# 0 + permissionDecision "allow" → session may end +# 0 + permissionDecision "ask" → prompt user before ending +# 1 → hook error (allow by default) + +set -euo pipefail + +WARNINGS="" + +# Check for uncommitted changes +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + DIRTY=$(git status --porcelain 2>/dev/null | head -5) + if [ -n "$DIRTY" ]; then + WARNINGS="${WARNINGS}Uncommitted changes detected. " + fi + + # Check for merge conflict markers in staged/modified files + CHANGED_FILES=$(git diff --name-only HEAD 2>/dev/null || true) + if [ -n "$CHANGED_FILES" ]; then + CONFLICTS=$(echo "$CHANGED_FILES" | xargs grep -l '<<<<<<< ' 2>/dev/null | head -3 || true) + if [ -n "$CONFLICTS" ]; then + WARNINGS="${WARNINGS}Merge conflict markers found in: ${CONFLICTS}. " + fi + + # Check for new TODO/FIXME in diff (not in the whole file, just new lines) + NEW_TODOS=$(git diff HEAD 2>/dev/null | grep '^+' | grep -iE '(TODO|FIXME|HACK|XXX):' | head -3 || true) + if [ -n "$NEW_TODOS" ]; then + WARNINGS="${WARNINGS}New TODO/FIXME markers in diff. " + fi + fi +fi + +# If warnings found, prompt the user +if [ -n "$WARNINGS" ]; then + jq -n --arg reason "$WARNINGS" '{ + hookSpecificOutput: { + hookEventName: "Stop", + permissionDecision: "ask", + permissionDecisionReason: ("Quality gate: " + $reason + "Continue anyway?") + } + }' + exit 0 +fi + +# All clear +jq -n '{ + hookSpecificOutput: { + hookEventName: "Stop", + permissionDecision: "allow" + } +}' +exit 0 diff --git a/hooks/subagent-stop.sh b/hooks/subagent-stop.sh new file mode 100755 index 0000000..4847a3a --- /dev/null +++ b/hooks/subagent-stop.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# devkit SubagentStop hook — prevents agents from exiting without running tests +# +# Checks the agent's transcript for evidence that a test/metric command was +# actually executed before allowing the agent to stop. This prevents the +# common failure mode where an agent claims "done" without verifying. +# +# Hook input (JSON on stdin): +# .tool_name = "Stop" +# .agent_name = the sub-agent's name +# .agent_output = the agent's final output text +# +# Exit codes: +# 0 + permissionDecision "allow" → agent may stop +# 0 + permissionDecision "block" → agent must continue (with reason) +# 1 → hook error (allow by default) + +set -euo pipefail + +INPUT=$(cat) +AGENT_OUTPUT=$(echo "$INPUT" | jq -r '.agent_output // empty') + +# If agent output is empty or very short, block — something went wrong +if [ ${#AGENT_OUTPUT} -lt 20 ]; then + jq -n '{ + hookSpecificOutput: { + hookEventName: "SubagentStop", + permissionDecision: "block", + permissionDecisionReason: "Agent output is suspiciously short. Please verify your work is complete and run any test/metric commands before stopping." + } + }' + exit 0 +fi + +# Check for evidence that tests/metrics were actually run +# Look for common test runner output patterns +TEST_EVIDENCE=false + +# Go test +if echo "$AGENT_OUTPUT" | grep -qE '(PASS|FAIL|ok\s+\S+\s+[0-9.]+s|--- PASS|--- FAIL|go test)'; then + TEST_EVIDENCE=true +fi + +# Node/Jest/Vitest +if echo "$AGENT_OUTPUT" | grep -qE '(Tests?:\s+[0-9]|test suites?|✓|✗|✘|PASS\s|FAIL\s|npm test|npx jest|npx vitest)'; then + TEST_EVIDENCE=true +fi + +# Python pytest +if echo "$AGENT_OUTPUT" | grep -qE '(passed|failed|error).*(pytest|test)|pytest\s|python.*-m.*test'; then + TEST_EVIDENCE=true +fi + +# Generic pass/fail signals +if echo "$AGENT_OUTPUT" | grep -qE '(ALL_PASSING|ALL_DONE|ALL_TESTS_PASSING|BUILD_SUCCESS|LINT_CLEAN|RESEARCH_COMPLETE)'; then + TEST_EVIDENCE=true +fi + +# Metric command output (exit code references) +if echo "$AGENT_OUTPUT" | grep -qE '(exit\s+(code\s+)?0|metric.*pass|tests?\s+pass)'; then + TEST_EVIDENCE=true +fi + +# If no test evidence found, block and ask the agent to verify +if [ "$TEST_EVIDENCE" = "false" ]; then + jq -n '{ + hookSpecificOutput: { + hookEventName: "SubagentStop", + permissionDecision: "block", + permissionDecisionReason: "No evidence of test/metric execution found in output. Run the test or metric command to verify your changes before stopping." + } + }' + exit 0 +fi + +# All clear — agent ran tests +jq -n '{ + hookSpecificOutput: { + hookEventName: "SubagentStop", + permissionDecision: "allow" + } +}' +exit 0 diff --git a/src/lib/similarity.go b/src/lib/similarity.go new file mode 100644 index 0000000..5d97b94 --- /dev/null +++ b/src/lib/similarity.go @@ -0,0 +1,45 @@ +package lib + +// Similarity computes the ratio of matching characters between two strings +// using a simple bigram overlap approach. Returns a value between 0.0 and 1.0. +// Used to detect "Groundhog Day" patterns where consecutive metric outputs +// are nearly identical, indicating the agent is stuck in a loop. +func Similarity(a, b string) float64 { + if a == b { + return 1.0 + } + if len(a) < 2 || len(b) < 2 { + if a == b { + return 1.0 + } + return 0.0 + } + + bigramsA := bigrams(a) + bigramsB := bigrams(b) + + var matches int + for bg, countA := range bigramsA { + if countB, ok := bigramsB[bg]; ok { + if countA < countB { + matches += countA + } else { + matches += countB + } + } + } + + total := len(a) - 1 + len(b) - 1 + if total == 0 { + return 0.0 + } + return 2.0 * float64(matches) / float64(total) +} + +func bigrams(s string) map[string]int { + m := make(map[string]int, len(s)-1) + for i := 0; i < len(s)-1; i++ { + m[s[i:i+2]]++ + } + return m +} diff --git a/src/lib/similarity_test.go b/src/lib/similarity_test.go new file mode 100644 index 0000000..01c56e8 --- /dev/null +++ b/src/lib/similarity_test.go @@ -0,0 +1,62 @@ +package lib + +import ( + "strings" + "testing" +) + +func TestSimilarity_Identical(t *testing.T) { + if got := Similarity("hello world", "hello world"); got != 1.0 { + t.Errorf("identical strings: got %f, want 1.0", got) + } +} + +func TestSimilarity_CompletelyDifferent(t *testing.T) { + got := Similarity("aaaaaa", "zzzzzz") + if got > 0.01 { + t.Errorf("completely different: got %f, want ~0.0", got) + } +} + +func TestSimilarity_Empty(t *testing.T) { + if got := Similarity("", ""); got != 1.0 { + t.Errorf("both empty: got %f, want 1.0", got) + } + if got := Similarity("hello", ""); got != 0.0 { + t.Errorf("one empty: got %f, want 0.0", got) + } +} + +func TestSimilarity_HighOverlap(t *testing.T) { + a := "FAIL: 3 errors found in parser.go" + b := "FAIL: 3 errors found in parser.go " + got := Similarity(a, b) + if got < 0.90 { + t.Errorf("high overlap: got %f, want >= 0.90", got) + } +} + +func TestSimilarity_ModerateOverlap(t *testing.T) { + a := "FAIL: 3 errors found in parser.go" + b := "FAIL: 5 errors found in handler.go" + got := Similarity(a, b) + if got < 0.4 || got > 0.9 { + t.Errorf("moderate overlap: got %f, want between 0.4 and 0.9", got) + } +} + +func TestSimilarity_LongIdenticalOutputs(t *testing.T) { + long := strings.Repeat("test output line\n", 100) + if got := Similarity(long, long); got != 1.0 { + t.Errorf("long identical: got %f, want 1.0", got) + } +} + +func TestSimilarity_Short(t *testing.T) { + if got := Similarity("a", "a"); got != 1.0 { + t.Errorf("single char identical: got %f, want 1.0", got) + } + if got := Similarity("a", "b"); got != 0.0 { + t.Errorf("single char different: got %f, want 0.0", got) + } +} diff --git a/src/loops/improve.go b/src/loops/improve.go index 574e44d..a9994d2 100644 --- a/src/loops/improve.go +++ b/src/loops/improve.go @@ -89,6 +89,9 @@ func runIterations(ctx context.Context, db *lib.DB, runner runners.Runner, git * cfg.MaxFailures = 3 } + const similarityThreshold = 0.90 + const maxSimilarOutputs = 2 + var spentUSD float64 if startIter > 1 { spent, _ := db.SessionTotalCost(session.ID) @@ -96,6 +99,8 @@ func runIterations(ctx context.Context, db *lib.DB, runner runners.Runner, git * } consecutiveFailures := 0 + consecutiveSimilar := 0 + lastMetricOutput := "" stopReason := "completed" for i := startIter; i <= cfg.MaxIterations; i++ { @@ -111,6 +116,10 @@ func runIterations(ctx context.Context, db *lib.DB, runner runners.Runner, git * stopReason = fmt.Sprintf("stuck — %d consecutive failures", consecutiveFailures) break } + if consecutiveSimilar >= maxSimilarOutputs { + stopReason = fmt.Sprintf("stuck — %d consecutive similar outputs (>%.0f%% match), agent is repeating itself", consecutiveSimilar, similarityThreshold*100) + break + } steps, _ := db.GetSteps(session.ID) if err := lib.WriteHandoff(cfg.RepoRoot, session, steps, baseline); err != nil { @@ -166,6 +175,8 @@ func runIterations(ctx context.Context, db *lib.DB, runner runners.Runner, git * step.Kept = true step.ChangeSummary = summary consecutiveFailures = 0 + consecutiveSimilar = 0 + lastMetricOutput = metricResult.Output fmt.Printf(" KEPT (exit 0) — $%.4f\n", result.CostUSD) } else { if revertErr := git.RevertAll(); revertErr != nil { @@ -175,7 +186,16 @@ func runIterations(ctx context.Context, db *lib.DB, runner runners.Runner, git * step.Kept = false step.ChangeSummary = fmt.Sprintf("metric exit %d", metricResult.ExitCode) consecutiveFailures++ - fmt.Printf(" REVERTED (exit %d) — $%.4f\n", metricResult.ExitCode, result.CostUSD) + + // Detect Groundhog Day: agent keeps producing near-identical failing output + if lastMetricOutput != "" && lib.Similarity(lastMetricOutput, metricResult.Output) >= similarityThreshold { + consecutiveSimilar++ + fmt.Printf(" REVERTED (exit %d, similar output %d/%d) — $%.4f\n", metricResult.ExitCode, consecutiveSimilar, maxSimilarOutputs, result.CostUSD) + } else { + consecutiveSimilar = 0 + fmt.Printf(" REVERTED (exit %d) — $%.4f\n", metricResult.ExitCode, result.CostUSD) + } + lastMetricOutput = metricResult.Output } db.UpdateStep(step) diff --git a/src/loops/loops_test.go b/src/loops/loops_test.go index 4df1dd2..fe9563d 100644 --- a/src/loops/loops_test.go +++ b/src/loops/loops_test.go @@ -1384,6 +1384,77 @@ func TestBuildImprovePrompt_EmptyFields(t *testing.T) { } } +// --------------------------------------------------------------------------- +// RunImproveLoop — similarity detection +// --------------------------------------------------------------------------- + +func TestRunImproveLoop_SimilarOutputStopsEarly(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + // All iterations will fail (metric "false"), and the agent produces + // near-identical output each time. The similarity detector should + // stop before hitting MaxFailures (set high to prove similarity wins). + runner := mockRunner("claude", []runners.RunResult{ + successResult("attempt A"), + successResult("attempt B"), + successResult("attempt C"), + successResult("attempt D"), + successResult("attempt E"), + }, nil) + + result, err := RunImproveLoop(context.Background(), db, runner, git, ImproveConfig{ + Target: "src/", + Metric: "echo 'FAIL: 3 errors found in parser.go' && exit 1", + Objective: "fix errors", + MaxIterations: 10, + MaxFailures: 10, // high — similarity should trigger first + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunImproveLoop: %v", err) + } + if !strings.Contains(result.StopReason, "similar outputs") { + t.Errorf("stop reason = %q, want 'similar outputs'", result.StopReason) + } + // Should stop after 3 iterations (2 similar = threshold) + if runner.CallCount() > 4 { + t.Errorf("runner calls = %d, expected <= 4 (similarity bail)", runner.CallCount()) + } +} + +func TestRunImproveLoop_DifferentOutputsNoSimilarityStop(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + // Each iteration fails with different output — should hit MaxFailures, not similarity + runner := mockRunner("claude", []runners.RunResult{ + successResult("change 1"), + successResult("change 2"), + successResult("change 3"), + }, nil) + + // Use a script that produces different output each time + counterFile := filepath.Join(dir, "counter.txt") + os.WriteFile(counterFile, []byte("0"), 0o644) + metric := fmt.Sprintf(`n=$(cat %s); echo "FAIL: error $n" && echo $((n+1)) > %s && exit 1`, counterFile, counterFile) + + result, err := RunImproveLoop(context.Background(), db, runner, git, ImproveConfig{ + Target: "src/", + Metric: metric, + Objective: "fix", + MaxIterations: 10, + MaxFailures: 3, + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunImproveLoop: %v", err) + } + if !strings.Contains(result.StopReason, "consecutive failures") { + t.Errorf("stop reason = %q, want 'consecutive failures'", result.StopReason) + } +} + // --------------------------------------------------------------------------- // git helper for review tests // --------------------------------------------------------------------------- From 08ca0ba339890287e64521ddf34da76c6d9b7974 Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Fri, 3 Apr 2026 12:41:58 -0400 Subject: [PATCH 2/3] Fix xargs filename injection in stop-gate hook Use git diff --name-only -z with xargs -0 to safely handle filenames containing spaces, quotes, or special characters. --- hooks/stop-gate.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/stop-gate.sh b/hooks/stop-gate.sh index 9de8b59..3efd164 100755 --- a/hooks/stop-gate.sh +++ b/hooks/stop-gate.sh @@ -29,7 +29,7 @@ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then # Check for merge conflict markers in staged/modified files CHANGED_FILES=$(git diff --name-only HEAD 2>/dev/null || true) if [ -n "$CHANGED_FILES" ]; then - CONFLICTS=$(echo "$CHANGED_FILES" | xargs grep -l '<<<<<<< ' 2>/dev/null | head -3 || true) + CONFLICTS=$(git diff --name-only -z HEAD 2>/dev/null | xargs -0 grep -l '<<<<<<< ' 2>/dev/null | head -3 || true) if [ -n "$CONFLICTS" ]; then WARNINGS="${WARNINGS}Merge conflict markers found in: ${CONFLICTS}. " fi From 8a9655eb60ff79f415ca58280641c58e70d91aec Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Fri, 3 Apr 2026 12:45:48 -0400 Subject: [PATCH 3/3] Fix issues from tri-agent review - stop-gate.sh: add -- to grep in xargs to prevent filename injection - post-validate.sh: single grep pass for error detection (was running twice) - post-validate.sh: canonicalize paths with realpath before repo-root check - similarity.go: remove redundant a==b check in short-string branch --- hooks/post-validate.sh | 8 +++++--- hooks/stop-gate.sh | 2 +- src/lib/similarity.go | 4 +--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/hooks/post-validate.sh b/hooks/post-validate.sh index d00e05d..74fb2bd 100755 --- a/hooks/post-validate.sh +++ b/hooks/post-validate.sh @@ -26,8 +26,9 @@ CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty') # --- Bash: check for suppressed errors --- if [ "$TOOL_NAME" = "Bash" ]; then # Flag common error patterns in output that the agent might miss - if echo "$TOOL_OUTPUT" | grep -qiE 'permission denied|no such file or directory|command not found|segmentation fault|killed|out of memory'; then - jq -n --arg msg "$(echo "$TOOL_OUTPUT" | grep -iE 'permission denied|no such file or directory|command not found|segmentation fault|killed|out of memory' | head -3)" '{ + ERROR_MATCHES=$(printf '%s\n' "$TOOL_OUTPUT" | grep -iE 'permission denied|no such file or directory|command not found|segmentation fault|killed|out of memory' | head -3 || true) + if [ -n "$ERROR_MATCHES" ]; then + jq -n --arg msg "$ERROR_MATCHES" '{ hookSpecificOutput: { hookEventName: "PostToolUse", permissionDecision: "allow", @@ -63,7 +64,8 @@ if [ "$TOOL_NAME" = "Edit" ] || [ "$TOOL_NAME" = "Write" ]; then if [ -n "$FILE_PATH" ]; then REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) if [ -n "$REPO_ROOT" ]; then - case "$FILE_PATH" in + ABS_PATH=$(realpath -m "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH") + case "$ABS_PATH" in "$REPO_ROOT"/*) ;; # within repo, OK /tmp/*|/private/tmp/*) diff --git a/hooks/stop-gate.sh b/hooks/stop-gate.sh index 3efd164..cdd8b62 100755 --- a/hooks/stop-gate.sh +++ b/hooks/stop-gate.sh @@ -29,7 +29,7 @@ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then # Check for merge conflict markers in staged/modified files CHANGED_FILES=$(git diff --name-only HEAD 2>/dev/null || true) if [ -n "$CHANGED_FILES" ]; then - CONFLICTS=$(git diff --name-only -z HEAD 2>/dev/null | xargs -0 grep -l '<<<<<<< ' 2>/dev/null | head -3 || true) + CONFLICTS=$(git diff --name-only -z HEAD 2>/dev/null | xargs -0 grep -l -- '<<<<<<< ' 2>/dev/null | head -3 || true) if [ -n "$CONFLICTS" ]; then WARNINGS="${WARNINGS}Merge conflict markers found in: ${CONFLICTS}. " fi diff --git a/src/lib/similarity.go b/src/lib/similarity.go index 5d97b94..4a10a89 100644 --- a/src/lib/similarity.go +++ b/src/lib/similarity.go @@ -8,10 +8,8 @@ func Similarity(a, b string) float64 { if a == b { return 1.0 } + // Short strings can't produce bigrams; fall back to exact match (handled above) if len(a) < 2 || len(b) < 2 { - if a == b { - return 1.0 - } return 0.0 }