Skip to content

Commit cf3bee0

Browse files
committed
add hook health snapshot and guard fixes
Signed-off-by: VibeGuard Agent <1835304752@qq.com>
1 parent ce589e4 commit cf3bee0

10 files changed

Lines changed: 406 additions & 10 deletions

File tree

.claude/commands/vibeguard/stats.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,32 @@ name: "VibeGuard: Stats"
33
description: "查看 hooks 触发统计 — 拦截/警告/放行次数和原因分析"
44
category: VibeGuard
55
tags: [vibeguard, stats, logging, observability]
6-
argument-hint: "[days|all]"
6+
argument-hint: "[days|all|health [hours]]"
77
---
88

99
**核心功能**
1010
- 分析 `~/.vibeguard/events.jsonl` 中的 hook 触发日志
1111
- 输出拦截/警告/放行统计、按 hook 分布、原因 Top 5、每日触发量
12+
- 支持 `health` 快照模式:风险率、Top 风险 hook、最近风险事件 Top 10
1213
- 帮助用户了解 VibeGuard 是否在工作、拦截了什么
1314

1415
**Steps**
1516

16-
1. 运行统计脚本
17+
1. 根据参数运行对应脚本
1718
```bash
18-
bash ~/Desktop/code/AI/tools/vibeguard/scripts/stats.sh $ARGUMENTS
19+
if [[ "${ARGUMENTS:-}" == health* ]]; then
20+
health_arg="${ARGUMENTS#health}"
21+
health_arg="${health_arg#"${health_arg%%[![:space:]]*}"}"
22+
bash ~/Desktop/code/AI/tools/vibeguard/scripts/hook-health.sh "${health_arg:-24}"
23+
else
24+
bash ~/Desktop/code/AI/tools/vibeguard/scripts/stats.sh $ARGUMENTS
25+
fi
1926
```
2027
参数说明:
2128
- 无参数:最近 7 天
2229
- 数字(如 30):最近 N 天
2330
- `all`:全部历史
31+
- `health`:最近 24 小时健康快照
32+
- `health 72`:最近 72 小时健康快照
2433

2534
2. 将统计结果展示给用户,如有异常(如拦截为 0 但使用了一段时间)提醒检查 hooks 配置是否正确

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ jobs:
121121
shell: bash
122122
run: bash tests/test_setup.sh
123123

124+
- name: Hook health regression tests
125+
if: runner.os != 'Windows'
126+
shell: bash
127+
run: bash tests/test_hook_health.sh
128+
124129
- name: Guard unit tests
125130
if: runner.os != 'Windows'
126131
continue-on-error: true

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- Codex CLI hooks support: 4 hooks deployed via `~/.codex/hooks.json` with output format adapter (`hooks/run-hook-codex.sh`)
1112
- Guard message v2 format: OBSERVATION/FIX/DO NOT structure (`guards/`)
1213
- Baseline scanning: only report issues on newly added lines (`guards/`)
1314
- Test infrastructure protection rule W-12 (`guards/`)

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ Supports `// vibeguard:ignore` inline comments to skip specific lines.
148148
```bash
149149
bash ~/vibeguard/scripts/quality-grader.sh # Quality grade (A/B/C/D)
150150
bash ~/vibeguard/scripts/stats.sh # Hook trigger stats (7 days)
151+
bash ~/vibeguard/scripts/hook-health.sh 24 # Hook health snapshot (risk rate + top hooks + recent risks)
151152
bash ~/vibeguard/scripts/metrics-exporter.sh # Prometheus metrics export
152153
bash ~/vibeguard/scripts/doc-freshness-check.sh # Rule-guard coverage check
153154
```

data/2026-04-01.json

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
{
2+
"date": "2026-04-01",
3+
"mode": "fast",
4+
"score": 87.7,
5+
"grade": "B",
6+
"layer1": {
7+
"tp": 19,
8+
"fp": 1,
9+
"fn": 3,
10+
"tn": 35,
11+
"precision": 95.0,
12+
"recall": 86.4,
13+
"f1": 90.5,
14+
"layer1_score": 87.7,
15+
"by_hook": {
16+
"analysis-paralysis-guard": {
17+
"tp": 1,
18+
"fp": 0,
19+
"fn": 0,
20+
"tn": 1
21+
},
22+
"git-pre-push": {
23+
"tp": 2,
24+
"fp": 0,
25+
"fn": 0,
26+
"tn": 2
27+
},
28+
"post-build-check": {
29+
"tp": 1,
30+
"fp": 0,
31+
"fn": 0,
32+
"tn": 5
33+
},
34+
"post-edit-guard": {
35+
"tp": 4,
36+
"fp": 1,
37+
"fn": 0,
38+
"tn": 6
39+
},
40+
"post-write-guard": {
41+
"tp": 3,
42+
"fp": 0,
43+
"fn": 0,
44+
"tn": 5
45+
},
46+
"pre-bash-guard": {
47+
"tp": 5,
48+
"fp": 0,
49+
"fn": 0,
50+
"tn": 9
51+
},
52+
"pre-commit-guard": {
53+
"tp": 1,
54+
"fp": 0,
55+
"fn": 0,
56+
"tn": 1
57+
},
58+
"pre-edit-guard": {
59+
"tp": 2,
60+
"fp": 0,
61+
"fn": 0,
62+
"tn": 1
63+
},
64+
"pre-write-guard": {
65+
"tp": 0,
66+
"fp": 0,
67+
"fn": 3,
68+
"tn": 5
69+
}
70+
},
71+
"total_cases": 58
72+
},
73+
"layer2": {
74+
"layer2_score": 0,
75+
"sws": 0,
76+
"fpr": 0,
77+
"detection_rate": 0,
78+
"total_samples": 0
79+
}
80+
}

guards/rust/check_unwrap_in_prod.sh

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,54 @@ if [[ -n "${VIBEGUARD_STAGED_FILES:-}" ]] && [[ -f "${VIBEGUARD_STAGED_FILES}" ]
4040
# Save diff to a temp file so we can re-read it on Python failure.
4141
_diff_tmp=$(create_tmpfile)
4242
git diff --cached -U0 -- "${f}" 2>/dev/null > "${_diff_tmp}"
43-
if ! python3 -c "
43+
# Write Python script to temp file to avoid bash escaping issues with regex
44+
_diff_py=$(create_tmpfile)
45+
cat > "${_diff_py}" << 'DIFFPYEOF'
4446
import sys, re
47+
4548
fname = sys.argv[1]
46-
# \.(unwrap\(|expect\() matches .unwrap( and .expect( but NOT .unwrap_or*(
47-
# because after 'unwrap' the next char must be '(' not '_'.
48-
# Using safe_pat + 'not safe_pat.search()' would exclude lines that have both
49-
# .expect('msg') and .unwrap_or_default() chained, causing false negatives.
5049
danger_pat = re.compile(r'\.(unwrap\(|expect\()')
5150
comment_pat = re.compile(r'^\s*//')
5251
hunk_pat = re.compile(r'^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@')
52+
_ITEM_KW = re.compile(r'\b(mod|fn|impl|struct|enum|type|trait)\b')
53+
54+
# --- Build test_lines set from original file (reuse standalone logic) ---
55+
def _count_braces(s):
56+
s = re.sub(r'"(?:[^"\\]|\\.)*"', '', s)
57+
s = re.sub(r"'(?:[^'\\\\]|\\\\.)*'", '', s)
58+
s = re.sub(r'//.*$', '', s)
59+
return s.count('{') - s.count('}')
60+
61+
test_lines = set()
62+
try:
63+
with open(fname) as _src:
64+
_all = _src.readlines()
65+
_pending = False; _in_mod = False; _depth = 0
66+
for _i, _ln in enumerate(_all, 1):
67+
_s = _ln.strip()
68+
if _s.startswith('#[cfg(test)]'):
69+
test_lines.add(_i)
70+
if _ITEM_KW.search(_s[len('#[cfg(test)]'):]):
71+
_in_mod = True; _depth = _count_braces(_s)
72+
if _depth <= 0: _in_mod = False
73+
else:
74+
_pending = True
75+
continue
76+
if _pending:
77+
if _s.startswith('#['):
78+
test_lines.add(_i); continue
79+
_pending = False
80+
if _ITEM_KW.search(_s):
81+
_in_mod = True; _depth = _count_braces(_s); test_lines.add(_i)
82+
if _depth <= 0: _in_mod = False
83+
continue
84+
if _in_mod:
85+
test_lines.add(_i); _depth += _count_braces(_s)
86+
if _depth <= 0: _in_mod = False
87+
except Exception:
88+
pass
89+
90+
# --- Scan diff lines, skip test_lines ---
5391
current_line = 0
5492
for raw in sys.stdin:
5593
line = raw.rstrip('\n')
@@ -61,12 +99,15 @@ for raw in sys.stdin:
6199
continue
62100
if line.startswith('+'):
63101
current_line += 1
102+
if current_line in test_lines:
103+
continue
64104
content = line[1:]
65105
if danger_pat.search(content) and not comment_pat.match(content):
66106
print('[RS-03] ' + fname + ':' + str(current_line) + ' ' + line)
67107
elif not line.startswith('-'):
68108
current_line += 1
69-
" "${f}" < "${_diff_tmp}" 2>/dev/null; then
109+
DIFFPYEOF
110+
if ! python3 "${_diff_py}" "${f}" < "${_diff_tmp}" 2>/dev/null; then
70111
echo "[RS-03] WARN: python3 解析失败 ${f},使用 grep fallback" >&2
71112
<"${_diff_tmp}" grep '^+' \
72113
| grep -v '^+++' \

hooks/post-edit-guard.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ case "$FILE_PATH" in
106106
# CLI 项目允许 console,跳过(bin 字段 / src/cli.* / scripts 含 cli)
107107
_PKG_DIR=$(dirname "$FILE_PATH")
108108
_IS_CLI=false
109-
while [[ "$_PKG_DIR" != "/" ]]; do
109+
while [[ "$_PKG_DIR" != "/" && "$_PKG_DIR" != "." ]]; do
110110
if [[ -f "$_PKG_DIR/package.json" ]]; then
111111
grep -qE '"bin"' "$_PKG_DIR/package.json" 2>/dev/null && _IS_CLI=true
112112
grep -qE '"[^"]*":\s*"[^"]*cli[^"]*"' "$_PKG_DIR/package.json" 2>/dev/null && _IS_CLI=true

scripts/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ VibeGuard 工具脚本,提供统计、合规检查、指标收集等功能。
1717
| `gc/gc-scheduled.sh` | 定期 GC + 学习 + 反思:日志归档、worktree 清理、metrics 清理、跨会话学习信号检测、会话质量反思报告 |
1818
| `project-init.sh` | 项目级脚手架:检测语言/框架 → 列出激活守卫/规则 → 生成 CLAUDE.md 片段建议,并安装 pre-commit/pre-push hook |
1919
| `quality-grader.sh` | 质量等级评分:从 events.jsonl 计算 A/B/C/D 等级,推荐 GC 频率 |
20+
| `hook-health.sh` | Hook 健康快照:最近 N 小时风险率、Top 风险 hook、最近风险事件 Top 10 |
2021
| `verify/doc-freshness-check.sh` | 文档新鲜度:交叉比对 rules/ 和 guards/ 的规则 ID 覆盖度 |
2122
| `log-capability-change.sh` | 能力进化日志:从 git log 提取守卫/规则/Skill 变更时间线 |
2223
| `constraint-recommender.py` | 约束推荐器:基于项目语言/框架自动生成 preflight 约束初稿 |
@@ -35,4 +36,5 @@ VibeGuard 工具脚本,提供统计、合规检查、指标收集等功能。
3536
bash scripts/stats.sh # 最近 7 天统计
3637
bash scripts/stats.sh 30 # 最近 30 天
3738
bash scripts/stats.sh all # 全部历史
39+
bash scripts/hook-health.sh 24 # 最近 24 小时健康快照
3840
```

scripts/hook-health.sh

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env bash
2+
# VibeGuard Hook Health Snapshot
3+
# 读取 events.jsonl,输出最近 N 小时的健康快照。
4+
#
5+
# 用法:
6+
# bash scripts/hook-health.sh # 最近 24 小时
7+
# bash scripts/hook-health.sh 72 # 最近 72 小时
8+
9+
set -euo pipefail
10+
11+
HOURS="${1:-24}"
12+
LOG_FILE="${VIBEGUARD_LOG_DIR:-${HOME}/.vibeguard}/events.jsonl"
13+
14+
if ! [[ "${HOURS}" =~ ^[0-9]+$ ]] || [[ "${HOURS}" -le 0 ]]; then
15+
echo "参数必须是正整数小时数,例如: 24"
16+
exit 1
17+
fi
18+
19+
if [[ ! -f "${LOG_FILE}" ]]; then
20+
echo "没有日志数据。hooks 触发后会自动记录到 ${LOG_FILE}"
21+
exit 0
22+
fi
23+
24+
VG_HOURS="${HOURS}" VG_LOG_FILE="${LOG_FILE}" python3 - <<'PY'
25+
import json
26+
import os
27+
import sys
28+
from collections import Counter
29+
from datetime import datetime, timedelta, timezone
30+
31+
32+
def parse_ts(ts: str):
33+
if not ts:
34+
return None
35+
try:
36+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
37+
except ValueError:
38+
return None
39+
40+
41+
hours = int(os.environ["VG_HOURS"])
42+
log_file = os.environ["VG_LOG_FILE"]
43+
now = datetime.now(timezone.utc)
44+
cutoff = now - timedelta(hours=hours)
45+
46+
events = []
47+
with open(log_file, "r", encoding="utf-8") as f:
48+
for line in f:
49+
line = line.strip()
50+
if not line:
51+
continue
52+
try:
53+
event = json.loads(line)
54+
except json.JSONDecodeError:
55+
continue
56+
event_ts = parse_ts(event.get("ts", ""))
57+
if event_ts is None:
58+
continue
59+
if event_ts >= cutoff:
60+
event["_parsed_ts"] = event_ts
61+
events.append(event)
62+
63+
if not events:
64+
print(f"最近 {hours} 小时没有日志数据。")
65+
sys.exit(0)
66+
67+
events.sort(key=lambda e: e["_parsed_ts"])
68+
total = len(events)
69+
by_decision = Counter(e.get("decision", "unknown") for e in events)
70+
pass_count = by_decision.get("pass", 0)
71+
risk_count = total - pass_count
72+
risk_rate = (risk_count / total * 100) if total else 0.0
73+
74+
first_ts = events[0].get("ts", "?")
75+
last_ts = events[-1].get("ts", "?")
76+
77+
print(f"VibeGuard Hook Health (最近 {hours} 小时)")
78+
print("=" * 44)
79+
print(f"时间范围: {first_ts} ~ {last_ts}")
80+
print(f"总触发: {total}")
81+
print(f"通过(pass): {pass_count}")
82+
print(f"风险(非 pass): {risk_count}")
83+
print(f"风险率: {risk_rate:.1f}%")
84+
print(f" block: {by_decision.get('block', 0)}")
85+
print(f" gate: {by_decision.get('gate', 0)}")
86+
print(f" warn: {by_decision.get('warn', 0)}")
87+
print(f" escalate: {by_decision.get('escalate', 0)}")
88+
print(f" correction: {by_decision.get('correction', 0)}")
89+
90+
non_pass_events = [e for e in events if e.get("decision") != "pass"]
91+
if non_pass_events:
92+
top_non_pass_hooks = Counter(e.get("hook", "unknown") for e in non_pass_events)
93+
print("\n风险 Hook Top 5:")
94+
for hook, count in top_non_pass_hooks.most_common(5):
95+
print(f" {hook}: {count}")
96+
97+
print("\n最近风险事件 Top 10:")
98+
for i, event in enumerate(reversed(non_pass_events[-10:]), start=1):
99+
ts = event.get("ts", "?")
100+
session = event.get("session", "?")
101+
hook = event.get("hook", "unknown")
102+
decision = event.get("decision", "unknown")
103+
reason = (event.get("reason") or "").replace("\n", " ").strip()
104+
detail = (event.get("detail") or "").replace("\n", " ").strip()
105+
if len(reason) > 100:
106+
reason = reason[:97] + "..."
107+
if len(detail) > 100:
108+
detail = detail[:97] + "..."
109+
print(f" {i}. {ts} | {hook} | {decision} | session={session}")
110+
if reason:
111+
print(f" reason: {reason}")
112+
if detail:
113+
print(f" detail: {detail}")
114+
115+
print()
116+
PY

0 commit comments

Comments
 (0)