cc-safe-setup 29.7.0 → 29.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  > 🚀 **Launching on [Product Hunt](https://www.producthunt.com/products/cc-safe-setup) — April 21!** Follow us and upvote to support open source safety for AI coding agents.
8
8
 
9
- **One command to make Claude Code safe for autonomous operation.** 700 example hooks · 9,200+ tests · 30K+ total installs · [日本語](docs/README.ja.md)
9
+ **One command to make Claude Code safe for autonomous operation.** 701 example hooks · 9,200+ tests · 30K+ total installs · [日本語](docs/README.ja.md)
10
10
 
11
11
  ```bash
12
12
  npx cc-safe-setup
@@ -16,7 +16,7 @@ Installs 8 safety hooks in ~10 seconds. Blocks `rm -rf /`, prevents pushes to ma
16
16
 
17
17
  > **What's a hook?** A checkpoint that runs before Claude executes a command. Like airport security — it inspects what's about to happen and blocks anything dangerous before it reaches the gate.
18
18
 
19
- [**Getting Started**](https://yurukusa.github.io/cc-safe-setup/getting-started.html) · [**Hook Selector**](https://yurukusa.github.io/cc-safe-setup/hook-selector.html) · [**Token Checkup**](https://yurukusa.github.io/cc-safe-setup/token-checkup.html) · [**Cache Health**](https://yurukusa.github.io/cc-safe-setup/cache-health.html) · [**Version Check**](https://yurukusa.github.io/cc-safe-setup/version-check.html) · [**CLAUDE.md Analyzer**](https://yurukusa.github.io/cc-safe-setup/claudemd-analyzer.html) · [**All Tools**](https://yurukusa.github.io/cc-safe-setup/hub.html) · [**Recipes**](https://yurukusa.github.io/cc-safe-setup/recipes.html) · [Validate your settings.json](https://yurukusa.github.io/cc-safe-setup/validator.html) · [**Check your score**](https://yurukusa.github.io/cc-health-check/) (`npx cc-health-check`) · [**Safety Audit**](https://yurukusa.github.io/cc-safe-setup/safety-audit.html)
19
+ [**Getting Started**](https://yurukusa.github.io/cc-safe-setup/getting-started.html) · [**Incident Tracker**](https://yurukusa.github.io/cc-safe-setup/incidents.html) · [**Hook Selector**](https://yurukusa.github.io/cc-safe-setup/hook-selector.html) · [**Token Checkup**](https://yurukusa.github.io/cc-safe-setup/token-checkup.html) · [**Cache Health**](https://yurukusa.github.io/cc-safe-setup/cache-health.html) · [**Version Check**](https://yurukusa.github.io/cc-safe-setup/version-check.html) · [**CLAUDE.md Analyzer**](https://yurukusa.github.io/cc-safe-setup/claudemd-analyzer.html) · [**All Tools**](https://yurukusa.github.io/cc-safe-setup/hub.html) · [**Recipes**](https://yurukusa.github.io/cc-safe-setup/recipes.html) · [Validate your settings.json](https://yurukusa.github.io/cc-safe-setup/validator.html) · [**Check your score**](https://yurukusa.github.io/cc-health-check/) (`npx cc-health-check`) · [**Safety Audit**](https://yurukusa.github.io/cc-safe-setup/safety-audit.html)
20
20
 
21
21
  ```
22
22
  cc-safe-setup
@@ -190,7 +190,7 @@ Guards against issues that corrupt sessions or waste tokens silently.
190
190
  | `--scan [--apply]` | Tech stack detection |
191
191
  | `--export / --import` | Team config sharing |
192
192
  | `--verify` | Test each hook |
193
- | `--install-example <name>` | Install from 700 examples |
193
+ | `--install-example <name>` | Install from 701 examples |
194
194
  | `--examples [filter]` | Browse examples by keyword |
195
195
  | `--full` | All-in-one setup |
196
196
  | `--status` | Check installed hooks |
@@ -495,7 +495,7 @@ See [Issue #1](https://github.com/yurukusa/cc-safe-setup/issues/1) for details.
495
495
 
496
496
  ## Learn More
497
497
 
498
- - **[Opus 4.7 Survival Guide](https://yurukusa.github.io/cc-safe-setup/opus-47-survival-guide.html)** — 50 known issues (67+ GitHub Issues + CVEs) with fixes: data loss, recursive spawn DoS, billing mismatch, subagent OOM, cache_read anomaly, allowedTools bypass, 1.7x token inflation, classifier failure, thinking summary bugs, 30-min stalls, and more. [`npx cc-safe-setup --opus47`](#-opus-47-crisis-april-2026)
498
+ - **[Opus 4.7 Survival Guide](https://yurukusa.github.io/cc-safe-setup/opus-47-survival-guide.html)** — 61 known issues (76+ GitHub Issues + CVEs) with fixes: data loss, recursive spawn DoS, billing mismatch, subagent OOM, cache_read anomaly, allowedTools bypass, 1.7x token inflation, classifier failure, thinking summary bugs, 30-min stalls, enterprise hooks bypass, and more. [`npx cc-safe-setup --opus47`](#-opus-47-crisis-april-2026)
499
499
  - **[Token Book (¥2,500)](https://zenn.dev/yurukusa/books/token-savings-guide)** — Cut token consumption in half. CLAUDE.md optimization, hook-based guards, context management, workflow design. 44,000 words with copy-paste templates. Intro + Ch.1 free. [Details](https://yurukusa.github.io/cc-safe-setup/token-book.html)
500
500
  - **[Safety Guide (¥800)](https://zenn.dev/yurukusa/books/6076c23b1cb18b)** — Token consumption diagnosis, file loss prevention, autonomous operation safety. From 800+ hours of real incidents. [Chapter 3 free](https://zenn.dev/yurukusa/books/6076c23b1cb18b/viewer/3-code-quality)
501
501
  - **[800 Hours Operation Record (¥800)](https://zenn.dev/yurukusa/books/3c3c3baee85f0a19)** — Non-engineer running Claude Code autonomously for 800 hours. Failures, recovery, revenue reality. [Chapter 2 free](https://zenn.dev/yurukusa/books/3c3c3baee85f0a19/viewer/2-first-failures)
@@ -0,0 +1,72 @@
1
+ #!/bin/bash
2
+ # ================================================================
3
+ # compact-circuit-breaker.sh — Prevent auto-compact death spirals
4
+ # ================================================================
5
+ # PURPOSE:
6
+ # Auto-compact can enter an infinite loop when FileHistory
7
+ # recovery is degraded — each compaction fails to restore
8
+ # continuity, triggering the next one immediately. Users have
9
+ # lost entire overnight token budgets to 15+ consecutive
10
+ # compactions with zero forward progress (#51088). One incident
11
+ # recorded 211 compactions in a single session (#24179).
12
+ #
13
+ # This hook acts as a circuit breaker: it allows normal
14
+ # compaction but blocks rapid-fire compaction that indicates
15
+ # a death spiral. After MAX_PER_HOUR compactions, further
16
+ # attempts are blocked until the window resets.
17
+ #
18
+ # TRIGGER: PreCompact
19
+ # MATCHER: (none — PreCompact has no matcher)
20
+ #
21
+ # DECISION: exit 0 = allow, exit 2 = block
22
+ #
23
+ # CONFIG:
24
+ # MAX_PER_HOUR — Maximum compactions allowed per hour (default: 3)
25
+ # MIN_INTERVAL — Minimum seconds between compactions (default: 120)
26
+ #
27
+ # See: https://github.com/anthropics/claude-code/issues/51088
28
+ # https://github.com/anthropics/claude-code/issues/24179
29
+ # ================================================================
30
+
31
+ MAX_PER_HOUR="${CC_COMPACT_MAX_PER_HOUR:-3}"
32
+ MIN_INTERVAL="${CC_COMPACT_MIN_INTERVAL:-120}"
33
+ STATE_DIR="/tmp/.cc-compact-circuit-breaker"
34
+ STATE_FILE="$STATE_DIR/compaction-log"
35
+
36
+ mkdir -p "$STATE_DIR"
37
+ touch "$STATE_FILE"
38
+
39
+ NOW=$(date +%s)
40
+ ONE_HOUR_AGO=$((NOW - 3600))
41
+
42
+ # Clean old entries (older than 1 hour)
43
+ if [ -f "$STATE_FILE" ]; then
44
+ awk -v cutoff="$ONE_HOUR_AGO" '$1 >= cutoff' "$STATE_FILE" > "$STATE_FILE.tmp"
45
+ mv "$STATE_FILE.tmp" "$STATE_FILE"
46
+ fi
47
+
48
+ # Count compactions in the last hour
49
+ RECENT_COUNT=$(wc -l < "$STATE_FILE" | tr -d ' ')
50
+
51
+ # Check minimum interval since last compaction
52
+ LAST_TIME=0
53
+ if [ -s "$STATE_FILE" ]; then
54
+ LAST_TIME=$(tail -1 "$STATE_FILE")
55
+ fi
56
+ ELAPSED=$((NOW - LAST_TIME))
57
+
58
+ # Circuit breaker: block if too many compactions
59
+ if [ "$RECENT_COUNT" -ge "$MAX_PER_HOUR" ]; then
60
+ echo "CIRCUIT BREAKER: $RECENT_COUNT compactions in the last hour (max: $MAX_PER_HOUR). Possible death spiral detected. Start a fresh session instead of compacting." >&2
61
+ exit 2
62
+ fi
63
+
64
+ # Cooldown: block if too soon after last compaction
65
+ if [ "$ELAPSED" -lt "$MIN_INTERVAL" ] && [ "$LAST_TIME" -gt 0 ]; then
66
+ echo "COOLDOWN: Last compaction was ${ELAPSED}s ago (min interval: ${MIN_INTERVAL}s). Wait before compacting again." >&2
67
+ exit 2
68
+ fi
69
+
70
+ # Allow compaction and log it
71
+ echo "$NOW" >> "$STATE_FILE"
72
+ exit 0
@@ -0,0 +1,77 @@
1
+ #!/bin/bash
2
+ # exploration-budget-guard.sh — Stop excessive exploration from draining tokens
3
+ #
4
+ # Solves: #51054 — Claude wastes 20% of weekly allowance exploring files
5
+ # on a simple task instead of acting. Read/Glob/Grep loops with
6
+ # no Edit/Write progress.
7
+ #
8
+ # Tracks read-only tool calls (Read, Glob, Grep) per session. After 25
9
+ # consecutive reads without a write, warns. After 40, blocks.
10
+ # Resets when an Edit/Write/Bash(write) occurs.
11
+ #
12
+ # TRIGGER: PreToolUse MATCHER: "Read|Glob|Grep|Edit|Write"
13
+
14
+ INPUT=$(cat)
15
+ TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
16
+
17
+ STATE_DIR="/tmp/.cc-exploration-budget"
18
+ mkdir -p "$STATE_DIR"
19
+
20
+ # Session-scoped state file (keyed by parent PID)
21
+ STATE_FILE="$STATE_DIR/session-$$"
22
+ # Fall back to a shared file if $$ changes between hook calls
23
+ STATE_FILE="$STATE_DIR/exploration-count"
24
+
25
+ NOW=$(date +%s)
26
+
27
+ case "$TOOL" in
28
+ Edit|Write)
29
+ # Write operation = progress. Reset exploration counter.
30
+ echo "0 $NOW" > "$STATE_FILE"
31
+ exit 0
32
+ ;;
33
+ Read|Glob|Grep)
34
+ # Read operation = exploration. Increment counter.
35
+ ;;
36
+ *)
37
+ exit 0
38
+ ;;
39
+ esac
40
+
41
+ # Read current count
42
+ COUNT=0
43
+ LAST_TIME=0
44
+ if [ -f "$STATE_FILE" ]; then
45
+ read -r COUNT LAST_TIME < "$STATE_FILE" 2>/dev/null || true
46
+ # Reset if more than 10 minutes since last call (new task)
47
+ ELAPSED=$(( NOW - LAST_TIME ))
48
+ if [ "$ELAPSED" -gt 600 ]; then
49
+ COUNT=0
50
+ fi
51
+ fi
52
+
53
+ COUNT=$(( COUNT + 1 ))
54
+ echo "$COUNT $NOW" > "$STATE_FILE"
55
+
56
+ WARN_THRESHOLD=25
57
+ BLOCK_THRESHOLD=40
58
+
59
+ if [ "$COUNT" -ge "$BLOCK_THRESHOLD" ]; then
60
+ echo "BLOCKED: $COUNT consecutive read operations without writing anything." >&2
61
+ echo " You're stuck in an exploration loop — this wastes tokens." >&2
62
+ echo " Take action NOW:" >&2
63
+ echo " 1. Write your solution based on what you've already read" >&2
64
+ echo " 2. If unsure, make a small change and test it" >&2
65
+ echo " 3. Ask the user for clarification instead of reading more files" >&2
66
+ echo "" >&2
67
+ echo " Exploration budget: $COUNT/$BLOCK_THRESHOLD (EXCEEDED)" >&2
68
+ exit 2
69
+ fi
70
+
71
+ if [ "$COUNT" -ge "$WARN_THRESHOLD" ]; then
72
+ echo "WARNING: $COUNT consecutive reads without any write." >&2
73
+ echo " You may be over-exploring. Consider acting on what you know." >&2
74
+ echo " Budget: $COUNT/$BLOCK_THRESHOLD reads before block." >&2
75
+ fi
76
+
77
+ exit 0
@@ -0,0 +1,61 @@
1
+ #!/bin/bash
2
+ # thinking-stall-detector.sh — Detect when Claude's thinking phase stalls
3
+ #
4
+ # Solves: #51092 — Sonnet 4.6 thinking ran for 25 minutes, consuming
5
+ # 16M+ tokens. User lost entire token allowance to a single
6
+ # reasoning phase that never produced output.
7
+ #
8
+ # HOW IT WORKS:
9
+ # Tracks time between consecutive tool calls. If the gap exceeds
10
+ # a threshold (default 5 minutes), it means Claude was "thinking"
11
+ # without taking any action — likely a reasoning stall.
12
+ #
13
+ # On detection, logs a warning with the stall duration and suggests
14
+ # the user interrupt with Ctrl+C.
15
+ #
16
+ # WHY THIS MATTERS:
17
+ # During thinking, tokens are consumed but no hooks fire. This hook
18
+ # fires on the NEXT tool call after the stall, so it can't prevent
19
+ # the stall itself — but it alerts the user that one occurred, so
20
+ # they can watch for it happening again and interrupt early.
21
+ #
22
+ # TRIGGER: PreToolUse MATCHER: ""
23
+ # Also works as: Notification (fires on status changes)
24
+ #
25
+ # CONFIGURATION:
26
+ # CC_STALL_WARN_SECS=300 warn after 5-minute gap (default)
27
+ # CC_STALL_LOG=/tmp/cc-thinking-stalls.log
28
+
29
+ INPUT=$(cat)
30
+ TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
31
+
32
+ STATE_FILE="/tmp/cc-thinking-stall-last-call"
33
+ LOG_FILE="${CC_STALL_LOG:-/tmp/cc-thinking-stalls.log}"
34
+ WARN_SECS="${CC_STALL_WARN_SECS:-300}"
35
+
36
+ NOW=$(date +%s)
37
+
38
+ # Read last tool call timestamp
39
+ LAST=$(cat "$STATE_FILE" 2>/dev/null || echo "$NOW")
40
+
41
+ # Update timestamp
42
+ echo "$NOW" > "$STATE_FILE"
43
+
44
+ # Calculate gap
45
+ GAP=$((NOW - LAST))
46
+
47
+ if [ "$GAP" -ge "$WARN_SECS" ]; then
48
+ MINUTES=$((GAP / 60))
49
+ REMAINDER=$((GAP % 60))
50
+
51
+ # Log the stall
52
+ echo "$(date -Iseconds) STALL ${MINUTES}m${REMAINDER}s before tool=$TOOL" >> "$LOG_FILE"
53
+
54
+ # Warn the user
55
+ echo "⚠️ Thinking stall detected: ${MINUTES}m${REMAINDER}s with no tool activity." >&2
56
+ echo "This may indicate a reasoning loop consuming tokens silently." >&2
57
+ echo "If this happens again, press Ctrl+C to interrupt." >&2
58
+ echo "See #51092: 25-minute thinking stall consumed 16M tokens." >&2
59
+ fi
60
+
61
+ exit 0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cc-safe-setup",
3
- "version": "29.7.0",
4
- "description": "One command to make Claude Code safe. 700 example hooks + 8 built-in. 56 CLI commands. Token consumption diagnosis. Works with Auto Mode.",
3
+ "version": "29.8.0",
4
+ "description": "One command to make Claude Code safe. 701 example hooks + 8 built-in. 56 CLI commands. Token consumption diagnosis. Works with Auto Mode.",
5
5
  "main": "index.mjs",
6
6
  "bin": {
7
7
  "cc-safe-setup": "index.mjs"
@@ -0,0 +1,134 @@
1
+ #!/bin/bash
2
+ # Tests for compact-circuit-breaker.sh
3
+ set -uo pipefail
4
+
5
+ HOOK="$(dirname "$0")/../examples/compact-circuit-breaker.sh"
6
+ STATE_DIR="/tmp/.cc-compact-circuit-breaker"
7
+ STATE_FILE="$STATE_DIR/compaction-log"
8
+ PASS=0; FAIL=0; TOTAL=0
9
+
10
+ run_test() {
11
+ local desc="$1"; shift
12
+ TOTAL=$((TOTAL + 1))
13
+ if "$@" 2>/dev/null; then
14
+ PASS=$((PASS + 1))
15
+ echo "✅ $desc"
16
+ else
17
+ local code=$?
18
+ if [ "$code" -eq 2 ]; then
19
+ # exit 2 = block, might be expected
20
+ FAIL=$((FAIL + 1))
21
+ echo "❌ $desc (exit $code)"
22
+ else
23
+ FAIL=$((FAIL + 1))
24
+ echo "❌ $desc (exit $code)"
25
+ fi
26
+ fi
27
+ }
28
+
29
+ run_test_blocked() {
30
+ local desc="$1"; shift
31
+ TOTAL=$((TOTAL + 1))
32
+ local code=0
33
+ "$@" 2>/dev/null || code=$?
34
+ if [ "$code" -eq 2 ]; then
35
+ PASS=$((PASS + 1))
36
+ echo "✅ $desc (correctly blocked)"
37
+ else
38
+ FAIL=$((FAIL + 1))
39
+ echo "❌ $desc (expected block, got exit $code)"
40
+ fi
41
+ }
42
+
43
+ cleanup() {
44
+ rm -rf "$STATE_DIR"
45
+ mkdir -p "$STATE_DIR"
46
+ }
47
+
48
+ # Test 1: First compaction should be allowed
49
+ cleanup
50
+ run_test "First compaction allowed" bash "$HOOK"
51
+
52
+ # Test 2: Second compaction within MIN_INTERVAL should be blocked (cooldown)
53
+ run_test_blocked "Cooldown blocks rapid compaction" bash "$HOOK"
54
+
55
+ # Test 3: After cooldown, compaction should be allowed
56
+ cleanup
57
+ echo "$(($(date +%s) - 200))" > "$STATE_FILE"
58
+ run_test "Compaction allowed after cooldown" bash "$HOOK"
59
+
60
+ # Test 4: Circuit breaker triggers after MAX_PER_HOUR
61
+ cleanup
62
+ NOW=$(date +%s)
63
+ for i in $(seq 1 3); do
64
+ echo "$((NOW - 300 + i * 10))" >> "$STATE_FILE"
65
+ done
66
+ run_test_blocked "Circuit breaker blocks after 3 compactions" bash "$HOOK"
67
+
68
+ # Test 5: Old entries are cleaned up
69
+ cleanup
70
+ ONE_HOUR_AGO=$(($(date +%s) - 3700))
71
+ for i in $(seq 1 5); do
72
+ echo "$((ONE_HOUR_AGO - i * 10))" >> "$STATE_FILE"
73
+ done
74
+ run_test "Old entries cleaned, compaction allowed" bash "$HOOK"
75
+
76
+ # Test 6: Custom MAX_PER_HOUR
77
+ cleanup
78
+ NOW=$(date +%s)
79
+ echo "$((NOW - 200))" > "$STATE_FILE"
80
+ run_test_blocked "Custom MAX_PER_HOUR=1 blocks second" env CC_COMPACT_MAX_PER_HOUR=1 bash "$HOOK"
81
+
82
+ # Test 7: Custom MIN_INTERVAL
83
+ cleanup
84
+ echo "$(date +%s)" > "$STATE_FILE"
85
+ run_test_blocked "Default MIN_INTERVAL blocks immediate retry" bash "$HOOK"
86
+
87
+ # Test 8: State directory created if missing
88
+ rm -rf "$STATE_DIR"
89
+ run_test "Creates state directory" bash "$HOOK"
90
+ [ -d "$STATE_DIR" ] && echo " ↳ State directory exists ✅" || echo " ↳ State directory missing ❌"
91
+
92
+ # Test 9: Empty state file handled
93
+ cleanup
94
+ mkdir -p "$STATE_DIR"
95
+ touch "$STATE_FILE"
96
+ run_test "Empty state file handled" bash "$HOOK"
97
+
98
+ # Test 10: Mixed old and new entries
99
+ cleanup
100
+ NOW=$(date +%s)
101
+ echo "$((NOW - 7200))" >> "$STATE_FILE" # 2 hours ago (old)
102
+ echo "$((NOW - 7100))" >> "$STATE_FILE" # old
103
+ echo "$((NOW - 200))" >> "$STATE_FILE" # recent (1)
104
+ run_test "Mixed entries: old cleaned, recent counted" bash "$HOOK"
105
+
106
+ # Test 11: Exactly at MAX_PER_HOUR boundary
107
+ cleanup
108
+ NOW=$(date +%s)
109
+ echo "$((NOW - 1800))" >> "$STATE_FILE"
110
+ echo "$((NOW - 900))" >> "$STATE_FILE"
111
+ echo "$((NOW - 200))" >> "$STATE_FILE"
112
+ run_test_blocked "Exactly at MAX=3 boundary blocked" bash "$HOOK"
113
+
114
+ # Test 12: Error message content
115
+ cleanup
116
+ NOW=$(date +%s)
117
+ for i in $(seq 1 3); do
118
+ echo "$((NOW - 300 + i * 10))" >> "$STATE_FILE"
119
+ done
120
+ OUTPUT=$(bash "$HOOK" 2>&1 || true)
121
+ TOTAL=$((TOTAL + 1))
122
+ if echo "$OUTPUT" | grep -q "CIRCUIT BREAKER"; then
123
+ PASS=$((PASS + 1))
124
+ echo "✅ Error message contains CIRCUIT BREAKER"
125
+ else
126
+ FAIL=$((FAIL + 1))
127
+ echo "❌ Error message missing CIRCUIT BREAKER: $OUTPUT"
128
+ fi
129
+
130
+ cleanup
131
+
132
+ echo ""
133
+ echo "Results: $PASS/$TOTAL passed, $FAIL failed"
134
+ [ "$FAIL" -eq 0 ] && exit 0 || exit 1
@@ -0,0 +1,164 @@
1
+ #!/bin/bash
2
+ # Tests for exploration-budget-guard.sh
3
+ set -euo pipefail
4
+
5
+ HOOK="$(dirname "$0")/../examples/exploration-budget-guard.sh"
6
+ PASS=0
7
+ FAIL=0
8
+ STATE_FILE="/tmp/.cc-exploration-budget/exploration-count"
9
+
10
+ setup() {
11
+ rm -f "$STATE_FILE"
12
+ mkdir -p /tmp/.cc-exploration-budget
13
+ }
14
+
15
+ run_hook() {
16
+ echo "$1" | bash "$HOOK" 2>&1 || true
17
+ }
18
+
19
+ assert_pass() {
20
+ local desc="$1"
21
+ local input="$2"
22
+ output=$(echo "$input" | bash "$HOOK" 2>&1) && rc=0 || rc=$?
23
+ if [ "$rc" -eq 0 ] && ! echo "$output" | grep -q "WARNING\|BLOCKED"; then
24
+ PASS=$((PASS + 1))
25
+ echo " PASS: $desc"
26
+ else
27
+ FAIL=$((FAIL + 1))
28
+ echo " FAIL: $desc (expected clean pass, rc=$rc)"
29
+ echo " output: $output"
30
+ fi
31
+ }
32
+
33
+ assert_warn() {
34
+ local desc="$1"
35
+ local input="$2"
36
+ output=$(echo "$input" | bash "$HOOK" 2>&1) && rc=0 || rc=$?
37
+ if [ $rc -eq 0 ] && echo "$output" | grep -q "WARNING"; then
38
+ PASS=$((PASS + 1))
39
+ echo " PASS: $desc"
40
+ else
41
+ FAIL=$((FAIL + 1))
42
+ echo " FAIL: $desc (expected warning, rc=$rc)"
43
+ echo " output: $output"
44
+ fi
45
+ }
46
+
47
+ assert_block() {
48
+ local desc="$1"
49
+ local input="$2"
50
+ output=$(echo "$input" | bash "$HOOK" 2>&1) && rc=0 || rc=$?
51
+ if [ $rc -eq 2 ] && echo "$output" | grep -q "BLOCKED"; then
52
+ PASS=$((PASS + 1))
53
+ echo " PASS: $desc"
54
+ else
55
+ FAIL=$((FAIL + 1))
56
+ echo " FAIL: $desc (expected block, rc=$rc)"
57
+ echo " output: $output"
58
+ fi
59
+ }
60
+
61
+ READ_INPUT='{"tool_name":"Read","tool_input":{"file_path":"/tmp/test.txt"}}'
62
+ GLOB_INPUT='{"tool_name":"Glob","tool_input":{"pattern":"*.ts"}}'
63
+ GREP_INPUT='{"tool_name":"Grep","tool_input":{"pattern":"foo"}}'
64
+ EDIT_INPUT='{"tool_name":"Edit","tool_input":{"file_path":"/tmp/test.txt","old_string":"a","new_string":"b"}}'
65
+ WRITE_INPUT='{"tool_name":"Write","tool_input":{"file_path":"/tmp/test.txt","content":"hello"}}'
66
+ OTHER_INPUT='{"tool_name":"Bash","tool_input":{"command":"ls"}}'
67
+
68
+ echo "=== exploration-budget-guard tests ==="
69
+
70
+ # Test 1: Single read passes
71
+ setup
72
+ assert_pass "single Read passes" "$READ_INPUT"
73
+
74
+ # Test 2: Non-tracked tool passes
75
+ setup
76
+ assert_pass "Bash is not tracked" "$OTHER_INPUT"
77
+
78
+ # Test 3: Edit resets counter
79
+ setup
80
+ for i in $(seq 1 20); do
81
+ echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
82
+ done
83
+ echo "$EDIT_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
84
+ assert_pass "Read after Edit reset passes" "$READ_INPUT"
85
+
86
+ # Test 4: Write resets counter
87
+ setup
88
+ for i in $(seq 1 20); do
89
+ echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
90
+ done
91
+ echo "$WRITE_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
92
+ assert_pass "Read after Write reset passes" "$READ_INPUT"
93
+
94
+ # Test 5: Warning at threshold
95
+ setup
96
+ for i in $(seq 1 24); do
97
+ echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
98
+ done
99
+ assert_warn "warns at 25 reads" "$READ_INPUT"
100
+
101
+ # Test 6: Different read tools count together
102
+ setup
103
+ for i in $(seq 1 8); do
104
+ echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
105
+ echo "$GLOB_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
106
+ echo "$GREP_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
107
+ done
108
+ assert_warn "mixed read tools warn at 25" "$READ_INPUT"
109
+
110
+ # Test 7: Block at 40
111
+ setup
112
+ for i in $(seq 1 39); do
113
+ echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
114
+ done
115
+ assert_block "blocks at 40 reads" "$READ_INPUT"
116
+
117
+ # Test 8: Block shows count
118
+ setup
119
+ for i in $(seq 1 41); do
120
+ echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
121
+ done
122
+ output=$(echo "$READ_INPUT" | bash "$HOOK" 2>&1 || true)
123
+ if echo "$output" | grep -q "EXCEEDED"; then
124
+ PASS=$((PASS + 1))
125
+ echo " PASS: block message shows EXCEEDED"
126
+ else
127
+ FAIL=$((FAIL + 1))
128
+ echo " FAIL: block message should show EXCEEDED"
129
+ fi
130
+
131
+ # Test 9: Timeout reset (simulate 11 min gap)
132
+ setup
133
+ for i in $(seq 1 30); do
134
+ echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
135
+ done
136
+ # Fake old timestamp
137
+ echo "30 $(($(date +%s) - 700))" > "$STATE_FILE"
138
+ assert_pass "resets after 10min gap" "$READ_INPUT"
139
+
140
+ # Test 10: Glob passes normally
141
+ setup
142
+ assert_pass "single Glob passes" "$GLOB_INPUT"
143
+
144
+ # Test 11: Grep passes normally
145
+ setup
146
+ assert_pass "single Grep passes" "$GREP_INPUT"
147
+
148
+ # Test 12: Counter persists across different read tools
149
+ setup
150
+ for i in $(seq 1 10); do
151
+ echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
152
+ done
153
+ for i in $(seq 1 10); do
154
+ echo "$GLOB_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
155
+ done
156
+ for i in $(seq 1 5); do
157
+ echo "$GREP_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
158
+ done
159
+ # Count should be 25 now
160
+ assert_warn "warns at 25 mixed reads" "$GREP_INPUT"
161
+
162
+ echo ""
163
+ echo "Results: $PASS passed, $FAIL failed out of $((PASS + FAIL)) tests"
164
+ [ "$FAIL" -eq 0 ] && exit 0 || exit 1
@@ -0,0 +1,151 @@
1
+ #!/bin/bash
2
+ # Tests for thinking-stall-detector.sh
3
+ set -euo pipefail
4
+
5
+ HOOK="$(dirname "$0")/../examples/thinking-stall-detector.sh"
6
+ PASS=0
7
+ FAIL=0
8
+ STATE_FILE="/tmp/cc-thinking-stall-last-call"
9
+ LOG_FILE="/tmp/cc-thinking-stalls.log"
10
+
11
+ setup() {
12
+ rm -f "$STATE_FILE" "$LOG_FILE"
13
+ }
14
+
15
+ run_hook() {
16
+ echo "$1" | CC_STALL_WARN_SECS="${2:-300}" bash "$HOOK" 2>&1 || true
17
+ }
18
+
19
+ # --- Test 1: First call (no previous state) should not warn ---
20
+ setup
21
+ output=$(echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=300 bash "$HOOK" 2>&1) || true
22
+ if echo "$output" | grep -q "stall"; then
23
+ echo " FAIL: first call should not warn"
24
+ FAIL=$((FAIL + 1))
25
+ else
26
+ echo " PASS: first call does not warn"
27
+ PASS=$((PASS + 1))
28
+ fi
29
+
30
+ # --- Test 2: Quick successive calls should not warn ---
31
+ setup
32
+ echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=300 bash "$HOOK" 2>/dev/null || true
33
+ sleep 1
34
+ output=$(echo '{"tool_name":"Write"}' | CC_STALL_WARN_SECS=300 bash "$HOOK" 2>&1) || true
35
+ if echo "$output" | grep -q "stall"; then
36
+ echo " FAIL: quick succession should not warn"
37
+ FAIL=$((FAIL + 1))
38
+ else
39
+ echo " PASS: quick succession does not warn"
40
+ PASS=$((PASS + 1))
41
+ fi
42
+
43
+ # --- Test 3: Stall detected with low threshold ---
44
+ setup
45
+ echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null || true
46
+ sleep 2
47
+ output=$(echo '{"tool_name":"Bash"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>&1) || true
48
+ if echo "$output" | grep -qi "stall"; then
49
+ echo " PASS: stall detected after threshold"
50
+ PASS=$((PASS + 1))
51
+ else
52
+ echo " FAIL: should have detected stall after 2s with 1s threshold"
53
+ echo " output: $output"
54
+ FAIL=$((FAIL + 1))
55
+ fi
56
+
57
+ # --- Test 4: Stall is logged to file ---
58
+ setup
59
+ echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null || true
60
+ sleep 2
61
+ echo '{"tool_name":"Edit"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null || true
62
+ if [ -f "$LOG_FILE" ] && grep -q "STALL" "$LOG_FILE"; then
63
+ echo " PASS: stall logged to file"
64
+ PASS=$((PASS + 1))
65
+ else
66
+ echo " FAIL: stall not logged to $LOG_FILE"
67
+ FAIL=$((FAIL + 1))
68
+ fi
69
+
70
+ # --- Test 5: Log contains tool name ---
71
+ if [ -f "$LOG_FILE" ] && grep -q "tool=Edit" "$LOG_FILE"; then
72
+ echo " PASS: log contains tool name"
73
+ PASS=$((PASS + 1))
74
+ else
75
+ echo " FAIL: log should contain tool=Edit"
76
+ FAIL=$((FAIL + 1))
77
+ fi
78
+
79
+ # --- Test 6: Warning mentions issue number ---
80
+ setup
81
+ echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null || true
82
+ sleep 2
83
+ output=$(echo '{"tool_name":"Glob"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>&1) || true
84
+ if echo "$output" | grep -q "51092"; then
85
+ echo " PASS: warning references #51092"
86
+ PASS=$((PASS + 1))
87
+ else
88
+ echo " FAIL: warning should reference #51092"
89
+ FAIL=$((FAIL + 1))
90
+ fi
91
+
92
+ # --- Test 7: After stall, next quick call should not warn ---
93
+ setup
94
+ echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null || true
95
+ sleep 2
96
+ echo '{"tool_name":"Edit"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null || true
97
+ output=$(echo '{"tool_name":"Write"}' | CC_STALL_WARN_SECS=300 bash "$HOOK" 2>&1) || true
98
+ if echo "$output" | grep -q "stall"; then
99
+ echo " FAIL: should not warn on quick follow-up after stall"
100
+ FAIL=$((FAIL + 1))
101
+ else
102
+ echo " PASS: no false positive after stall recovery"
103
+ PASS=$((PASS + 1))
104
+ fi
105
+
106
+ # --- Test 8: Hook always exits 0 (never blocks) ---
107
+ setup
108
+ echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null || true
109
+ sleep 2
110
+ echo '{"tool_name":"Bash"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null
111
+ rc=$?
112
+ if [ "$rc" -eq 0 ]; then
113
+ echo " PASS: hook exits 0 (warn-only, never blocks)"
114
+ PASS=$((PASS + 1))
115
+ else
116
+ echo " FAIL: hook should always exit 0, got $rc"
117
+ FAIL=$((FAIL + 1))
118
+ fi
119
+
120
+ # --- Test 9: Empty tool name handled gracefully ---
121
+ setup
122
+ output=$(echo '{}' | CC_STALL_WARN_SECS=300 bash "$HOOK" 2>&1) || true
123
+ rc=$?
124
+ if [ "$rc" -eq 0 ]; then
125
+ echo " PASS: empty tool name handled"
126
+ PASS=$((PASS + 1))
127
+ else
128
+ echo " FAIL: should handle empty tool name gracefully"
129
+ FAIL=$((FAIL + 1))
130
+ fi
131
+
132
+ # --- Test 10: Custom log path ---
133
+ setup
134
+ CUSTOM_LOG="/tmp/cc-stall-custom-test.log"
135
+ rm -f "$CUSTOM_LOG"
136
+ echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=1 CC_STALL_LOG="$CUSTOM_LOG" bash "$HOOK" 2>/dev/null || true
137
+ sleep 2
138
+ echo '{"tool_name":"Write"}' | CC_STALL_WARN_SECS=1 CC_STALL_LOG="$CUSTOM_LOG" bash "$HOOK" 2>/dev/null || true
139
+ if [ -f "$CUSTOM_LOG" ] && grep -q "STALL" "$CUSTOM_LOG"; then
140
+ echo " PASS: custom log path works"
141
+ PASS=$((PASS + 1))
142
+ else
143
+ echo " FAIL: custom log path not working"
144
+ FAIL=$((FAIL + 1))
145
+ fi
146
+ rm -f "$CUSTOM_LOG"
147
+
148
+ # --- Summary ---
149
+ echo ""
150
+ echo "Results: $PASS passed, $FAIL failed out of $((PASS + FAIL))"
151
+ [ "$FAIL" -eq 0 ] && exit 0 || exit 1