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
52 changes: 51 additions & 1 deletion hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand Down Expand Up @@ -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
}
]
}
]
}
}
89 changes: 89 additions & 0 deletions hooks/post-validate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/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
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",
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
ABS_PATH=$(realpath -m "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
case "$ABS_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
64 changes: 64 additions & 0 deletions hooks/stop-gate.sh
Original file line number Diff line number Diff line change
@@ -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=$(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

# 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
83 changes: 83 additions & 0 deletions hooks/subagent-stop.sh
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions src/lib/similarity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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
}
// Short strings can't produce bigrams; fall back to exact match (handled above)
if len(a) < 2 || len(b) < 2 {
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
}
Loading
Loading