feat: 融合 devflow-claude P0 批机制 (REQ-20260416-0017)

P0-1: SessionStart Hook — hooks/session-context.sh
  从分支名解析 REQ-ID,调 MCP API 查询需求详情注入 system-reminder

P0-2: PreToolUse Hook — hooks/pre-tool-confirm.sh
  拦截生产推送、force push、docker prod 容器操作、git reset --hard 等

P0-3: Release Draft 闸门设计文档 — docs/design/release-draft-gate.md
  完整架构 + 渐进式落地路径(拆 7 个子任务延后)
  附最小可用脚本 hooks/release-draft.sh 创建 Gitea draft release

P0-4: Memory 隔离规则 — 写入 req-prd / req-design / req-workflow
  禁止 auto-memory 污染模板产出物(章节结构、字段定义、文档结构)

P0-5: CLAUDE.md 架构检查 + 架构片段库
  dev-coding skill 执行前检查架构关键词
  新增 templates/claude-md-snippets/ 含 Go+Gin / React+AntD / Vue+Element /
  MCP+TS / generic 五套骨架

P0-6: /commit 分支保护自动化 — 新 skill dev-commit-plugin
  保护分支自动建功能分支 + Conventional Commits + REQ-XXX 自动关联

安装:
  bash hooks/install.sh

后续:
  P0-3 完整实现拆 7 个子任务(P0-3.1 ~ P0-3.7)
  建议先部署 hooks 跑 1-2 周观察,再推进 Release 机制落地
This commit is contained in:
2026-04-16 21:02:29 +09:30
parent bfe3815626
commit 23ea8fdca5
18 changed files with 1433 additions and 0 deletions

100
hooks/README.md Normal file
View File

@@ -0,0 +1,100 @@
# Claude Code Hooks
本目录包含 ai-proj-helper 体系的 Claude Code 钩子脚本。
**REQ-20260416-0017 P0 批 — 源自 devflow-claude 借鉴**
## 脚本清单
| 脚本 | 事件 | 作用 |
|------|------|------|
| `session-context.sh` | SessionStart | 从分支名解析 REQ-ID注入需求上下文到会话 |
| `pre-tool-confirm.sh` | PreToolUse (Bash) | 拦截生产发布、force push、docker 生产容器、reset --hard 等危险操作 |
## 安装(用户级,一次即可)
### 方式 1编辑 `~/.claude/settings.json`
```jsonc
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "/Users/donglinlai/coding/qiudl/ai-proj-helper/hooks/session-context.sh",
"timeout": 10
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/Users/donglinlai/coding/qiudl/ai-proj-helper/hooks/pre-tool-confirm.sh",
"timeout": 30
}
]
}
]
}
}
```
### 方式 2一键安装脚本
```bash
bash /Users/donglinlai/coding/qiudl/ai-proj-helper/hooks/install.sh
```
## 验证
### SessionStart hook
1. 切到一个带 REQ-ID 的分支:`git checkout feat/REQ-20260416-0017-xxx`
2. 打开新的 Claude Code 会话
3. 会话开头应看到 `# 需求上下文SessionStart Hook`
### PreToolUse hook
让 AI 尝试执行这些命令中的任一条,应弹出原生确认对话框:
- `git push origin main`
- `git push --force`
- `tea pr merge --base main`
- `docker rm ai_postgres_prod`
- `git reset --hard`
## 依赖
- `bash` (macOS / Linux 默认)
- `jq` (PreToolUse hook 需要,`brew install jq`)
- `python3` (SessionStart hook 解析 JSON)
- `curl` (SessionStart hook 调 MCP API)
## 自定义
### 修改 MCP API 地址
在项目根目录创建 `.ai-proj-env`
```bash
export AI_PROJ_API_BASE="https://api.ai-proj.example.com"
export AI_PROJ_MCP_KEY="your-mcp-api-key"
```
或设置全局环境变量。
### 拦截更多命令
编辑 `pre-tool-confirm.sh`,在 `REASON` 赋值的 elif 链中增加规则。
## 设计原则
1. **快速失败退出**:不处理的命令立即 `exit 0`,不影响性能
2. **非侵入性**:网络/依赖缺失时静默退出,不阻塞正常工作流
3. **可复现**hook 脚本跟随仓库分发,方便团队一致部署

81
hooks/install.sh Executable file
View File

@@ -0,0 +1,81 @@
#!/bin/bash
# install.sh
# 一键把 ai-proj-helper hooks 注册到 ~/.claude/settings.json
#
# 用法: bash hooks/install.sh
set -e
HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SETTINGS_FILE="$HOME/.claude/settings.json"
if ! command -v python3 >/dev/null 2>&1; then
echo "❌ 需要 python3用于合并 JSON"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "⚠️ 未安装 jqPreToolUse hook 将无法正常工作"
echo " 请执行: brew install jq"
fi
mkdir -p "$HOME/.claude"
python3 << EOF
import json
import os
settings_file = "$SETTINGS_FILE"
hooks_dir = "$HOOKS_DIR"
# 读已有配置
if os.path.exists(settings_file):
with open(settings_file) as f:
data = json.load(f)
else:
data = {}
# 合并 hooks
hooks = data.setdefault("hooks", {})
# SessionStart
session_start = hooks.setdefault("SessionStart", [])
session_cmd = f"{hooks_dir}/session-context.sh"
already_registered = any(
any(h.get("command") == session_cmd for h in entry.get("hooks", []))
for entry in session_start
)
if not already_registered:
session_start.append({
"hooks": [{"type": "command", "command": session_cmd, "timeout": 10}]
})
print("✅ 注册 SessionStart hook")
else:
print("⏭️ SessionStart hook 已存在")
# PreToolUse (Bash)
pre_tool = hooks.setdefault("PreToolUse", [])
pre_cmd = f"{hooks_dir}/pre-tool-confirm.sh"
already_registered = any(
entry.get("matcher") == "Bash" and any(h.get("command") == pre_cmd for h in entry.get("hooks", []))
for entry in pre_tool
)
if not already_registered:
pre_tool.append({
"matcher": "Bash",
"hooks": [{"type": "command", "command": pre_cmd, "timeout": 30}]
})
print("✅ 注册 PreToolUse (Bash) hook")
else:
print("⏭️ PreToolUse hook 已存在")
# 写回
with open(settings_file, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f"\n📝 已更新: {settings_file}")
print("\n下一步")
print(" 1. 重启 Claude Code 会话")
print(" 2. 切到含 REQ-ID 的分支测试 SessionStart hook")
print(" 3. 让 AI 尝试 'git push origin main' 测试 PreToolUse hook")
EOF

94
hooks/pre-tool-confirm.sh Executable file
View File

@@ -0,0 +1,94 @@
#!/bin/bash
# pre-tool-confirm.sh
# PreToolUse Hook: 拦截危险操作,强制原生确认对话框
#
# 输入格式stdin JSON
# { "tool_name": "Bash", "tool_input": { "command": "..." } }
#
# 输出格式stdout JSON
# { "hookSpecificOutput": { "hookEventName": "PreToolUse",
# "permissionDecision": "ask",
# "permissionDecisionReason": "..." } }
#
# 安装方式:在 ~/.claude/settings.json 配置
# hooks.PreToolUse:
# - matcher: "Bash"
# hooks:
# - type: command
# command: "<path>/hooks/pre-tool-confirm.sh"
# timeout: 30
#
# 参考devflow-claude confirm-before-commit.sh + ai-proj memory 规则
# REQ-20260416-0017 P0-2
set -e
INPUT=$(cat)
if ! command -v jq >/dev/null 2>&1; then
# 没有 jq 就不处理
exit 0
fi
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [ -z "$COMMAND" ]; then
exit 0
fi
REASON=""
# ============ 1. 生产分支推送 ============
if echo "$COMMAND" | grep -qE '\bgit\s+push\b.*\b(origin\s+)?(main|master)\b'; then
REASON="⚠️ 即将推送到 main/master 生产分支。确认已过 PR 评审?"
# ============ 2. 强制推送 ============
elif echo "$COMMAND" | grep -qE '\bgit\s+push\b.*(--force|--force-with-lease|-f\b)'; then
REASON="⛔ 危险force push 会覆盖远程历史,可能丢失他人提交。确认继续?"
# ============ 3. tea pr merge --base main ============
elif echo "$COMMAND" | grep -qE '\btea\s+pr\s+merge\b.*--base\s+main\b'; then
REASON="⚠️ 即将合并 PR 到 main 分支,合并后将触发生产部署。确认 PR 已测试?"
# ============ 4. docker rm/stop 生产容器 ============
elif echo "$COMMAND" | grep -qE '\bdocker\s+(rm|stop|kill)\b.*\b(ai_postgres_prod|ai_backend_prod|ai_frontend_prod|ai_redis_prod)\b'; then
REASON="⛔ 危险:即将停止/删除生产容器2026-04-05 曾因此宕机 15 分钟。确认是灾难恢复?"
# ============ 5. docker rm/stop 其他 prod 相关 ============
elif echo "$COMMAND" | grep -qE '\bdocker\s+(rm|stop|kill)\b.*_prod\b'; then
REASON="⚠️ 即将停止/删除含 _prod 的容器。确认是生产环境?"
# ============ 6. git reset --hard / clean -fd ============
elif echo "$COMMAND" | grep -qE '\bgit\s+reset\s+--hard\b'; then
REASON="⚠️ git reset --hard 会丢弃所有未提交改动。确认继续?"
elif echo "$COMMAND" | grep -qE '\bgit\s+clean\s+.*-f'; then
REASON="⚠️ git clean -f 会删除未跟踪文件。确认继续?"
# ============ 7. rm -rf / 系统路径 ============
elif echo "$COMMAND" | grep -qE '\brm\s+.*-[rf]+.*\s+(/|/\*|~|\$HOME)'; then
REASON="⛔ 危险rm -rf 指向系统根或家目录。确认继续?"
# ============ 8. ssh 生产服务器 + 破坏性命令 ============
elif echo "$COMMAND" | grep -qE 'ssh\s+\S*prod\S*.*\b(rm|drop|truncate|delete)\b'; then
REASON="⛔ 危险:在生产服务器执行破坏性命令。确认继续?"
# ============ 9. psql/mysql 生产数据库 + DROP/TRUNCATE/DELETE ============
elif echo "$COMMAND" | grep -qiE '(psql|mysql).*prod.*\b(DROP|TRUNCATE|DELETE FROM)\b'; then
REASON="⛔ 危险:生产数据库 DROP/TRUNCATE/DELETE。确认已备份"
# ============ 10. MCP advance_delivery_stage force=true ============
# 这种会走 MCP 而不是 Bash本 hook 不好拦,留给另一个 matcher 处理
fi
if [ -n "$REASON" ]; then
jq -n --arg reason "$REASON" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: $reason
}
}'
else
exit 0
fi

161
hooks/release-draft.sh Executable file
View File

@@ -0,0 +1,161 @@
#!/bin/bash
# release-draft.sh
# 最小可用版:从本地 git 仓库创建 Gitea draft release
#
# 用法:
# export GITEA_TOKEN=$(bw get password "Gitea - qiudl Token")
# bash release-draft.sh v1.2.0 [--from v1.1.9]
#
# 输出: 创建的 draft release URL待人工 publish
# REQ-20260416-0017 P0-3 最小可用脚本
set -e
VERSION="$1"
if [ -z "$VERSION" ]; then
echo "❌ 用法: $0 <version> [--from <previous_tag>]"
exit 1
fi
FROM_TAG=""
if [ "$2" = "--from" ]; then
FROM_TAG="$3"
fi
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "❌ 不在 git 仓库内"
exit 1
fi
REPO_SLUG=$(git remote get-url origin 2>/dev/null | \
sed -E 's|.*[:/]([^/]+/[^/]+)\.git$|\1|' | \
sed -E 's|.*[:/]([^/]+/[^/]+)$|\1|')
if [ -z "$REPO_SLUG" ]; then
echo "❌ 无法从 git remote 推断 OWNER/REPO"
exit 1
fi
if [ -z "$GITEA_TOKEN" ]; then
echo "❌ 需要 GITEA_TOKEN 环境变量"
echo " export GITEA_TOKEN=\$(bw get password 'Gitea - qiudl Token')"
exit 1
fi
GITEA_URL="${GITEA_URL:-https://gitea.pipexerp.com}"
# 推断 from
if [ -z "$FROM_TAG" ]; then
FROM_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
fi
TO_REF="HEAD"
# 生成 changelog 内容
echo "📋 生成 changelog..."
CHANGELOG=""
if [ -n "$FROM_TAG" ]; then
COMMITS=$(git log --pretty=format:'- %s (%h)' "${FROM_TAG}..${TO_REF}" 2>/dev/null || echo "")
else
COMMITS=$(git log --pretty=format:'- %s (%h)' "${TO_REF}" 2>/dev/null || echo "")
fi
if [ -z "$COMMITS" ]; then
echo "⚠️ 无 commit放弃"
exit 1
fi
# 按类型分组
FEATS=$(echo "$COMMITS" | grep -iE 'feat(\(|:)|新功能' || true)
FIXES=$(echo "$COMMITS" | grep -iE 'fix(\(|:)|修复' || true)
CHORES=$(echo "$COMMITS" | grep -iE 'chore(\(|:)' || true)
OTHERS=$(echo "$COMMITS" | grep -vE 'feat(\(|:)|fix(\(|:)|chore(\(|:)|新功能|修复' || true)
CHANGELOG="## 发布内容
**版本**: \`${VERSION}\`
**区间**: \`${FROM_TAG:-init}..${TO_REF}\`
"
if [ -n "$FEATS" ]; then
CHANGELOG="${CHANGELOG}### 新功能
${FEATS}
"
fi
if [ -n "$FIXES" ]; then
CHANGELOG="${CHANGELOG}### Bug 修复
${FIXES}
"
fi
if [ -n "$CHORES" ]; then
CHANGELOG="${CHANGELOG}### 杂项
${CHORES}
"
fi
if [ -n "$OTHERS" ]; then
CHANGELOG="${CHANGELOG}### 其他
${OTHERS}
"
fi
CHANGELOG="${CHANGELOG}
---
⚠️ **这是 draft release**,审查无误后点击 'Publish release' 按钮才会触发生产部署。
📋 审查要点:
- [ ] 所有改动已过 PR 评审
- [ ] SQL migration 已验证(如有)
- [ ] 回滚方案已确认(如有)
- [ ] 生产环境准备就绪"
# 创建 draft release
echo "🚀 创建 Gitea draft release..."
BODY_JSON=$(python3 -c "
import json
print(json.dumps({
'tag_name': '$VERSION',
'target_commitish': 'main',
'name': '$VERSION',
'body': '''$CHANGELOG''',
'draft': True,
'prerelease': False,
}))
")
RESP=$(curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$BODY_JSON" \
"${GITEA_URL}/api/v1/repos/${REPO_SLUG}/releases")
HTML_URL=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('html_url',''))" 2>/dev/null)
if [ -n "$HTML_URL" ]; then
echo "✅ Draft release 已创建"
echo "🔗 $HTML_URL"
echo ""
echo "⏭️ 下一步:"
echo " 1. 打开链接审查产物清单"
echo " 2. 确认无误后点 'Publish release' 按钮"
echo " 3. CI/CD 将自动触发生产部署"
else
echo "❌ 创建失败"
echo "$RESP" | head -20
exit 1
fi

130
hooks/session-context.sh Executable file
View File

@@ -0,0 +1,130 @@
#!/bin/bash
# session-context.sh
# SessionStart Hook: 会话启动时自动注入需求上下文
#
# 从当前 Git 分支名解析 REQ-ID调用 ai-proj MCP API 查询需求详情,
# 把标题 / 状态 / delivery_stage / reviewer / 进行中需求数注入 system-reminder。
#
# 安装方式:
# 在 ~/.claude/settings.json 的 hooks.SessionStart 配置:
# {
# "command": "/Users/donglinlai/coding/qiudl/ai-proj-helper/hooks/session-context.sh",
# "timeout": 10
# }
#
# 参考devflow-claude 同名脚本 + ai-proj MCP 适配
# REQ-20260416-0017 P0-1
set -e
# 仅在 git 仓库内执行
if ! git rev-parse --git-dir >/dev/null 2>&1; then
exit 0
fi
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$REPO_ROOT" ]; then
exit 0
fi
cd "$REPO_ROOT"
# ============ 1. 当前分支 → REQ ID ============
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")
REQ_ID=""
if [ -n "$BRANCH" ]; then
# 匹配 feat/REQ-20260416-0017-xxx / fix/REQ-20260416-0017 / feature/req-20260416-0017
REQ_ID=$(echo "$BRANCH" | grep -oiE 'REQ-[0-9]{8}-[0-9]{4}' | head -1 | tr '[:lower:]' '[:upper:]')
fi
# ============ 2. 无 REQ 时仅输出分支信息(静默退出条件) ============
if [ -z "$REQ_ID" ]; then
# 只要不在 main/develop 上就提示一下
case "$BRANCH" in
main|master|develop|"") exit 0 ;;
esac
echo "# 会话上下文"
echo ""
echo "- 当前分支: \`${BRANCH}\`(未检测到 REQ ID"
exit 0
fi
# ============ 3. 查询 MCP API ============
# MCP API 通过 localhost:8080 直连ai-proj 本地后端)或 ai-proj-prod
# 这里优先读项目根的 .ai-proj-env 决定环境
API_BASE="${AI_PROJ_API_BASE:-}"
API_TOKEN="${AI_PROJ_MCP_KEY:-}"
if [ -f "$REPO_ROOT/.ai-proj-env" ]; then
# shellcheck disable=SC1091
source "$REPO_ROOT/.ai-proj-env"
fi
if [ -z "$API_BASE" ]; then
# 默认走本地 dev
API_BASE="http://localhost:8080"
fi
# 查询需求
RESP=""
if command -v curl >/dev/null 2>&1; then
if [ -n "$API_TOKEN" ]; then
RESP=$(curl -s --max-time 3 -H "X-MCP-API-Key: $API_TOKEN" \
"${API_BASE}/api/v1/mcp/requirements/by-display-id/${REQ_ID}" 2>/dev/null || echo "")
else
RESP=$(curl -s --max-time 3 \
"${API_BASE}/api/v1/mcp/requirements/by-display-id/${REQ_ID}" 2>/dev/null || echo "")
fi
fi
# ============ 4. 解析并输出 ============
echo "# 需求上下文SessionStart Hook"
echo ""
echo "- 分支: \`${BRANCH}\`"
echo "- 需求: **${REQ_ID}**"
if [ -n "$RESP" ] && command -v python3 >/dev/null 2>&1; then
# 尝试用 python 解析
PARSED=$(python3 -c "
import sys, json
try:
d = json.loads('''$RESP''')
data = d.get('data', {})
if not data:
sys.exit(0)
title = data.get('title', '?')
status = data.get('status', '?')
stage = data.get('delivery_stage', '?')
priority = data.get('priority', '?')
project = data.get('project_name', '?')
print(f'title={title}')
print(f'status={status}')
print(f'stage={stage}')
print(f'priority={priority}')
print(f'project={project}')
except Exception:
pass
" 2>/dev/null)
if [ -n "$PARSED" ]; then
TITLE=$(echo "$PARSED" | grep '^title=' | sed 's/^title=//')
STATUS=$(echo "$PARSED" | grep '^status=' | sed 's/^status=//')
STAGE=$(echo "$PARSED" | grep '^stage=' | sed 's/^stage=//')
PRIORITY=$(echo "$PARSED" | grep '^priority=' | sed 's/^priority=//')
PROJECT=$(echo "$PARSED" | grep '^project=' | sed 's/^project=//')
[ -n "$TITLE" ] && echo "- 标题: ${TITLE}"
[ -n "$PROJECT" ] && echo "- 项目: ${PROJECT}"
[ -n "$STATUS" ] && echo "- 状态: ${STATUS}"
[ -n "$STAGE" ] && echo "- 交付阶段: ${STAGE}"
[ -n "$PRIORITY" ] && echo "- 优先级: ${PRIORITY}"
else
echo "- 📡 MCP API 响应为空或未授权API_BASE=${API_BASE}"
fi
else
echo "- ⚠️ 无法连接 MCP API${API_BASE}),仅显示分支信息"
fi
echo ""
echo "💡 相关命令:\`/req get ${REQ_ID}\` 查看详情 · \`/commit\` 智能提交"