add-coder 0.2.8 → 0.2.10

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.
Files changed (57) hide show
  1. package/README.md +22 -17
  2. package/dist/index.js +10 -0
  3. package/package.json +4 -11
  4. package/templates/adapters/claude/hooks/doc-format-guard.sh +17 -0
  5. package/templates/adapters/claude/hooks/lib/notify.sh +34 -0
  6. package/templates/adapters/claude/hooks/pre-tool-use.sh +26 -7
  7. package/templates/adapters/claude/hooks/prompt-submit.sh +16 -0
  8. package/templates/adapters/codex/hooks/doc-format-guard.sh +17 -0
  9. package/templates/adapters/codex/hooks/lib/notify.sh +34 -0
  10. package/templates/adapters/codex/hooks/pre-tool-use.sh +48 -18
  11. package/templates/adapters/codex/hooks/prompt-submit.sh +16 -0
  12. package/templates/adapters/qoder/hooks/doc-format-guard.sh +17 -0
  13. package/templates/adapters/qoder/hooks/lib/notify.sh +34 -0
  14. package/templates/adapters/qoder/hooks/pre-tool-use.sh +28 -9
  15. package/templates/adapters/qoder/hooks/prompt-submit.sh +18 -1
  16. package/templates/adapters/trae/hooks/doc-format-guard.sh +17 -0
  17. package/templates/adapters/trae/hooks/lib/notify.sh +34 -0
  18. package/templates/adapters/trae/hooks/pre-tool-use.sh +48 -18
  19. package/templates/adapters/trae/hooks/prompt-submit.sh +16 -0
  20. package/templates/adapters/vscode/hooks/doc-format-guard.sh +16 -0
  21. package/templates/adapters/vscode/hooks/lib/notify.sh +34 -0
  22. package/templates/adapters/vscode/hooks/pre-tool-use.sh +26 -7
  23. package/templates/adapters/vscode/hooks/prompt-submit.sh +16 -0
  24. package/templates/core/hooks/doc-format-guard.sh +17 -0
  25. package/templates/core/hooks/lib/notify.sh +34 -0
  26. package/templates/core/hooks/pre-tool-use.sh +48 -18
  27. package/templates/core/hooks/prompt-submit.sh +16 -0
  28. package/templates/core/reviews/.gitkeep +0 -0
  29. package/templates/core/scripts/mcp-server/elicitation/confirm.ts +30 -0
  30. package/templates/core/scripts/mcp-server/elicitation/index.ts +1 -0
  31. package/templates/core/scripts/mcp-server/index.ts +15 -0
  32. package/templates/core/scripts/mcp-server/notifications/hitl.ts +49 -0
  33. package/templates/core/scripts/mcp-server/notifications/hook.ts +268 -0
  34. package/templates/core/scripts/mcp-server/notifications/index.ts +8 -0
  35. package/templates/core/scripts/mcp-server/resources/add-coder-version.ts +25 -0
  36. package/templates/core/scripts/mcp-server/resources/add-state.ts +44 -0
  37. package/templates/core/scripts/mcp-server/resources/hook-events-report.ts +104 -0
  38. package/templates/core/scripts/mcp-server/resources/index.ts +12 -0
  39. package/templates/core/scripts/mcp-server/resources/round-task.ts +22 -0
  40. package/templates/core/scripts/mcp-server/sampling/index.ts +1 -0
  41. package/templates/core/scripts/mcp-server/sampling/review.ts +47 -0
  42. package/templates/core/scripts/mcp-server/shared/env.ts +26 -0
  43. package/templates/core/scripts/mcp-server/shared/fs.ts +40 -0
  44. package/templates/core/scripts/mcp-server/shared/prisma.ts +35 -0
  45. package/templates/core/scripts/mcp-server/shared/response.ts +9 -0
  46. package/templates/core/scripts/mcp-server/tasks/index.ts +2 -0
  47. package/templates/core/scripts/mcp-server/tasks/runner.ts +20 -0
  48. package/templates/core/scripts/mcp-server/tasks/store.ts +20 -0
  49. package/templates/core/scripts/mcp-server/tools/audit.ts +73 -0
  50. package/templates/core/scripts/mcp-server/tools/context.ts +337 -0
  51. package/templates/core/scripts/mcp-server/tools/docs.ts +42 -0
  52. package/templates/core/scripts/mcp-server/tools/gateway.ts +135 -0
  53. package/templates/core/scripts/mcp-server/tools/hook-event-report.ts +92 -0
  54. package/templates/core/scripts/mcp-server/tools/index.ts +17 -0
  55. package/templates/core/scripts/mcp-server/tools/quality.ts +93 -0
  56. package/templates/core/scripts/mcp-server/types.ts +7 -0
  57. package/templates/core/scripts/mcp-server.ts +8 -3455
@@ -8,6 +8,7 @@ export CURRENT_MAGIC=$(basename "$(dirname "$HOOK_DIR")")
8
8
 
9
9
  COMMON_LIB="$HOOK_DIR/lib/common.sh"
10
10
  [ -f "$COMMON_LIB" ] && source "$COMMON_LIB"
11
+ [ -f "$HOOK_DIR/lib/notify.sh" ] && source "$HOOK_DIR/lib/notify.sh" 2>/dev/null || true
11
12
 
12
13
  export PROJECT_DIR="$PWD"
13
14
 
@@ -56,6 +57,7 @@ if [ -z "$state" ]; then
56
57
  Step 8: 收敛判断
57
58
  如果 add-paradigm SKILL 尚未激活,请先调用它。
58
59
  EOF
60
+ write_hook_event "prompt-submit" "deny" "$prompt" "无活跃 ADD Plan 下检测到开发任务" "no-active-plan" "none" 2>/dev/null || true
59
61
  exit 2
60
62
  fi
61
63
 
@@ -69,6 +71,20 @@ cat <<EOF
69
71
  handoff: ${handoff}
70
72
  EOF
71
73
 
74
+ # ─── Hook 治理日报(注入 AI 上下文)───
75
+ HOOK_JSONL="${MAGIC_DIR:-.qoder}/reports/hook-events.jsonl"
76
+ if [ -f "$HOOK_JSONL" ]; then
77
+ TODAY="$(date +%Y-%m-%d)"
78
+ TOTAL=$(grep -c "\"ts\":\"${TODAY}" "$HOOK_JSONL" 2>/dev/null || echo 0)
79
+ NO_PLAN=$(grep "\"ts\":\"${TODAY}" "$HOOK_JSONL" 2>/dev/null | grep -c '"planKeyword":"no-active-plan"' || echo 0)
80
+ if [ "$TOTAL" -gt 0 ] 2>/dev/null; then
81
+ echo "[Hook 治理] 今日拦截: ${TOTAL} 次 | 无 Plan 违规: ${NO_PLAN} 次"
82
+ if [ "$NO_PLAN" -ge 10 ] 2>/dev/null; then
83
+ echo "[Hook ⚠️] 无 Plan 违规已达 ${NO_PLAN} 次(≥10),建议创建 Plan"
84
+ fi
85
+ fi
86
+ fi
87
+
72
88
  # 模板全文注入(tpl-injected 去重)
73
89
  TPL_SCRIPT="$HOOK_DIR/lib/preload-templates.sh"
74
90
  if [ -f "$TPL_SCRIPT" ]; then
@@ -8,6 +8,18 @@ input=$(cat)
8
8
  HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
9
9
  PARENT_DIR="$(dirname "$HOOK_DIR")"
10
10
  MAGIC_DIR="$(basename "$PARENT_DIR")"
11
+ PROJECT_DIR="$PWD"
12
+
13
+ # ── Hook 通知: 拦截事件写入 jsonl(旁路,失败不阻断 exit 2)──
14
+ source "${HOOK_DIR}/lib/notify.sh" 2>/dev/null || true
15
+ ACTIVE_PLAN=$(detect_active_add 2>/dev/null || true)
16
+ if [ -n "$ACTIVE_PLAN" ]; then
17
+ PLAN_KEYWORD="${ACTIVE_PLAN%%::*}"
18
+ PLAN_STATUS="active"
19
+ else
20
+ PLAN_KEYWORD="no-active-plan"
21
+ PLAN_STATUS="none"
22
+ fi
11
23
 
12
24
  # DEBUG: dump stdin for investigation
13
25
  mkdir -p "$MAGIC_DIR/debug-dump"
@@ -44,6 +56,7 @@ CONTENT=$(echo "$input" | jq -r '
44
56
  if [ -z "$CONTENT" ]; then
45
57
  echo "⛔ 拒绝:Write 工具未传 file_content,无法校验手写文档格式" >&2
46
58
  echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"Write 工具未传 file_content,无法校验手写文档。请用 SearchReplace 改写已有文件,或用 Write 工具重试。"}}'
59
+ write_hook_event "doc-format-guard" "deny" "Write" "Write 工具未传 file_content" "$PLAN_KEYWORD" "$PLAN_STATUS" 2>/dev/null || true
47
60
  exit 2
48
61
  fi
49
62
 
@@ -93,6 +106,7 @@ if [ -z "$TEMPLATE_NAME" ]; then
93
106
  else
94
107
  echo "⛔ handoff 文件内容无法识别模板类型(缺 '## 全局元信息' 或 '## 1. 交接前状态'),拒绝写入" >&2
95
108
  echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"handoff 内容不符合 single/multi 模板规范"}}'
109
+ write_hook_event "doc-format-guard" "deny" "$file_path" "handoff 模板类型无法识别" "$PLAN_KEYWORD" "$PLAN_STATUS" 2>/dev/null || true
96
110
  exit 2
97
111
  fi
98
112
  ;;
@@ -113,6 +127,7 @@ if [ -z "$TEMPLATE_NAME" ]; then
113
127
  fi
114
128
  echo "⛔ 拒绝:无法识别文档类型 (file_path: $file_path),缺少模板匹配规则" >&2
115
129
  echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"无法识别 ADD 文档类型,请联系管理员更新 doc-format-guard.sh"}}'
130
+ write_hook_event "doc-format-guard" "deny" "$file_path" "无法识别文档类型" "$PLAN_KEYWORD" "$PLAN_STATUS" 2>/dev/null || true
116
131
  exit 2
117
132
  ;;
118
133
  esac
@@ -124,6 +139,7 @@ SCHEMA_FILE="$TEMPLATES_DIR/${TEMPLATE_NAME%.md}.schema.json"
124
139
  if [ ! -f "$SCHEMA_FILE" ]; then
125
140
  echo "⛔ 阻断:模板 ${TEMPLATE_NAME} 缺少对应的 .schema.json 校验规则" >&2
126
141
  echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"缺少 .schema.json 校验规则文件,禁止无规则放行"}}'
142
+ write_hook_event "doc-format-guard" "deny" "$file_path" "缺少 .schema.json 校验规则" "$PLAN_KEYWORD" "$PLAN_STATUS" 2>/dev/null || true
127
143
  exit 2
128
144
  fi
129
145
 
@@ -230,6 +246,7 @@ if [ -n "$ISSUES" ]; then
230
246
  echo "⛔ $TEMPLATE_NAME 校验不通过:
231
247
  $ISSUES" >&2
232
248
  echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"文档格式校验不通过,请对照模板修正\"}}"
249
+ write_hook_event "doc-format-guard" "deny" "$file_path" "文档格式校验不通过" "$PLAN_KEYWORD" "$PLAN_STATUS" 2>/dev/null || true
233
250
  exit 2
234
251
  fi
235
252
 
@@ -0,0 +1,34 @@
1
+ #!/bin/bash
2
+ # notify.sh — Hook 拦截事件写入 jsonl(零外部依赖,bash 原生)
3
+ # 用法: source 后在 exit 2 前调用 write_hook_event
4
+ #
5
+ # 写入格式(7 字段 jsonl):
6
+ # {"ts":"ISO8601","hook":"...","decision":"deny","cmd":"...","reason":"...","planKeyword":"...","planStatus":"..."}
7
+ #
8
+ # 磁盘管理:
9
+ # - 单文件 ≤256KB,超限轮转为 .old(覆盖,不累积多份)
10
+ # - 总量 ≤512KB(当前 + 一个 .old)
11
+ # - MCP Server 宕机不丢事件,重启后从文件恢复消费
12
+
13
+ write_hook_event() {
14
+ local hook="$1" decision="$2" cmd="$3" reason="$4" plan="${5:-unknown}" status="${6:-none}"
15
+ local dir="${MAGIC_DIR:-.qoder}/reports"
16
+ mkdir -p "$dir" 2>/dev/null || true
17
+ local file="$dir/hook-events.jsonl"
18
+
19
+ # 超过 256KB 自动轮转(262144 bytes)
20
+ if [ -f "$file" ]; then
21
+ local sz
22
+ sz=$(stat -c%s "$file" 2>/dev/null || echo 0)
23
+ [ "$sz" -gt 262144 ] && mv "$file" "${file}.old" 2>/dev/null || true
24
+ fi
25
+
26
+ printf '{"ts":"%s","hook":"%s","decision":"%s","cmd":"%s","reason":"%s","planKeyword":"%s","planStatus":"%s"}\n' \
27
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
28
+ "$hook" \
29
+ "$decision" \
30
+ "$cmd" \
31
+ "$reason" \
32
+ "$plan" \
33
+ "$status" >> "$file"
34
+ }
@@ -1,7 +1,7 @@
1
1
  #!/bin/bash
2
2
  # pre-tool-use.sh — PreToolUse §A§B(阻断模式,通用适配版)
3
3
  # §A: Bash 裸写保护 — 拦截所有绕过 IDE 追踪的文件写操作
4
- # §B: 源码 Plan 关联检查 Plan src/**/*.ts 编辑阻断
4
+ # §B: Write/Edit 写入前置守卫 — Plan/Spec/Review 需要活跃 ADD Plan + 敏感文件保护
5
5
  # 治理卡位 #4: 危险命令拦截 / 模板路径兜底 / 写入前置守卫 / 敏感文件保护
6
6
  set -euo pipefail
7
7
 
@@ -13,6 +13,17 @@ PARENT="$(dirname "$HOOK_DIR")"
13
13
  MAGIC_DIR="$(basename "$PARENT")"
14
14
  PROJECT_DIR="$PWD"
15
15
 
16
+ # ── Hook 通知: 拦截事件写入 jsonl(旁路,失败不阻断 exit 2)──
17
+ source "${HOOK_DIR}/lib/notify.sh" 2>/dev/null || true
18
+ ACTIVE_PLAN=$(detect_active_add 2>/dev/null || true)
19
+ if [ -n "$ACTIVE_PLAN" ]; then
20
+ PLAN_KEYWORD="${ACTIVE_PLAN%%::*}"
21
+ PLAN_STATUS="active"
22
+ else
23
+ PLAN_KEYWORD="no-active-plan"
24
+ PLAN_STATUS="none"
25
+ fi
26
+
16
27
  # ── §A 辅助函数: 阻断日志 ──
17
28
  _log_block() {
18
29
  local rule="$1" cmd="$2"
@@ -32,7 +43,7 @@ command=$(echo "$input" | jq -r '.tool_input.command // empty')
32
43
  if [ -n "$command" ]; then
33
44
 
34
45
  # 检测 1: 脚本解释器 — 可写任意文件,无法解析脚本内容做细粒度拦截
35
- if echo "$command" | grep -qE '^\s*(python3?|node|ruby|perl|php)(\s|$)'; then
46
+ if echo "$command" | grep -qE '(^|;|\|\||&&|\|)\s*(python3?|node|ruby|perl|php)(\s|$)'; then
36
47
  _reason="禁止通过脚本解释器直接修改文件。请使用 Write 或 SearchReplace 工具操作文件。"
37
48
  cat >&2 <<'EOF'
38
49
  ⛔ [ADD PreToolUse §A] 阻断: 禁止通过脚本解释器直接修改文件。
@@ -47,6 +58,7 @@ if [ -n "$command" ]; then
47
58
  EOF
48
59
  echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"${_reason}\"}}"
49
60
  _log_block "脚本解释器" "$command"
61
+ write_hook_event "pre-tool-use" "deny" "$command" "禁止通过脚本解释器直接修改文件" "$PLAN_KEYWORD" "$PLAN_STATUS" 2>/dev/null || true
50
62
  exit 2
51
63
  fi
52
64
 
@@ -61,6 +73,7 @@ EOF
61
73
  EOF
62
74
  echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"${_reason}\"}}"
63
75
  _log_block "sed -i" "$command"
76
+ write_hook_event "pre-tool-use" "deny" "$command" "禁止通过 sed -i 原地编辑" "$PLAN_KEYWORD" "$PLAN_STATUS" 2>/dev/null || true
64
77
  exit 2
65
78
  fi
66
79
 
@@ -75,6 +88,7 @@ EOF
75
88
  EOF
76
89
  echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"${_reason}\"}}"
77
90
  _log_block "重定向" "$command"
91
+ write_hook_event "pre-tool-use" "deny" "$command" "禁止通过重定向写入文件" "$PLAN_KEYWORD" "$PLAN_STATUS" 2>/dev/null || true
78
92
  exit 2
79
93
  fi
80
94
 
@@ -88,11 +102,12 @@ EOF
88
102
  EOF
89
103
  echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"${_reason}\"}}"
90
104
  _log_block "tee/dd" "$command"
105
+ write_hook_event "pre-tool-use" "deny" "$command" "禁止通过 tee/dd 写入文件" "$PLAN_KEYWORD" "$PLAN_STATUS" 2>/dev/null || true
91
106
  exit 2
92
107
  fi
93
108
 
94
109
  # 检测 5: cp / mv / touch — 可创建或覆盖文件
95
- if echo "$command" | grep -qE '^\s*(cp|mv|touch)\b'; then
110
+ if echo "$command" | grep -qE '(^|;|\|\||&&|\|)\s*(cp|mv|touch)\b'; then
96
111
  _reason="禁止通过 cp/mv/touch 操作文件。请使用 Write 或 SearchReplace 工具。"
97
112
  cat >&2 <<'EOF'
98
113
  ⛔ [ADD PreToolUse §A] 阻断: 禁止通过 cp/mv/touch 操作文件。
@@ -101,6 +116,7 @@ EOF
101
116
  EOF
102
117
  echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"${_reason}\"}}"
103
118
  _log_block "cp/mv/touch" "$command"
119
+ write_hook_event "pre-tool-use" "deny" "$command" "禁止通过 cp/mv/touch 操作文件" "$PLAN_KEYWORD" "$PLAN_STATUS" 2>/dev/null || true
104
120
  exit 2
105
121
  fi
106
122
 
@@ -109,23 +125,37 @@ EOF
109
125
  exit 0
110
126
  fi
111
127
 
112
- # ═══════════════ §B: 源码 Plan 关联检查 ═══════════════
113
- file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
114
- [ -z "$file_path" ] && exit 0
115
-
116
- if ! echo "$file_path" | grep -qE '(src/|/src/).*\.(ts|tsx)$'; then
117
- exit 0
118
- fi
119
-
120
- MOD=$(basename "$file_path" | sed 's/\.[jt]sx\?$//')
128
+ # ═══════════════ §B: Write/Edit 文件写入前置守卫 ═══════════════
129
+ # Bash 工具(Write/Edit)的文件写入需要经过:
130
+ # 1. Plan/Spec/Review 文档写入 需要活跃 ADD Plan
131
+ # 2. 敏感文件保护 → 阻断写入
132
+ # 3. Dev Action 标记 用于 Stop 检查
133
+ # 注意: 不再对 src/**/*.ts 做 Plan 白名单放行,避免绕过 skill/rules 规定的 HITL。
134
+ tool_name=$(echo "$input" | jq -r '.tool_name // empty')
135
+ if [ "$tool_name" = "Write" ] || [ "$tool_name" = "Edit" ]; then
136
+ file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
137
+ [ -z "$file_path" ] && exit 0
138
+
139
+ if echo "$file_path" | grep -qE '\.(qoder|claude|add)/(plans|specs|reviews)/'; then
140
+ if type detect_active_add >/dev/null 2>&1; then
141
+ state=$(detect_active_add 2>/dev/null || true)
142
+ if [ -z "$state" ]; then
143
+ echo "⛔ 正在写入 Plan/Spec/Review 文档但无活跃 ADD Plan——请先执行 add-paradigm" >&2
144
+ echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Plan/Spec/Review 写入需要活跃 ADD Plan,请先执行 add-paradigm\"}}"
145
+ write_hook_event "pre-tool-use" "deny" "$tool_name $file_path" "Plan/Spec/Review 写入需活跃 ADD Plan" "$PLAN_KEYWORD" "$PLAN_STATUS" 2>/dev/null || true
146
+ exit 2
147
+ fi
148
+ fi
149
+ fi
121
150
 
122
- MATCHES=$(grep -rl "$MOD" "$PROJECT_DIR/$MAGIC_DIR/plans" "$PROJECT_DIR/$MAGIC_DIR/specs" "$PROJECT_DIR/$MAGIC_DIR/reports" 2>/dev/null | wc -l)
151
+ if echo "$file_path" | grep -qE '\.env$|\.env\.production$|\.env\.local$|credentials|secrets'; then
152
+ echo "⛔ 敏感文件受保护,禁止写入: $file_path" >&2
153
+ echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"敏感文件受保护\"}}"
154
+ exit 2
155
+ fi
123
156
 
124
- if [ "$MATCHES" -gt 0 ]; then
125
- echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"相关 ADD Plan 已存在\"}}"
157
+ mark_dev_action 2>/dev/null || true
126
158
  exit 0
127
159
  fi
128
160
 
129
- echo "⛔ 阻断: ${file_path} 无相关 ADD Plan。请先创建 Plan(plan-template / simple-plan-template)。" >&2
130
- echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"无相关 ADD Plan。请用 AskUserQuestion 询问用户选择 plan-template 或 simple-plan-template 创建 Plan。\"}}"
131
- exit 2
161
+ exit 0
@@ -10,6 +10,7 @@ prompt=$(echo "$input" | jq -r '.prompt // empty')
10
10
  HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
11
11
  source "$HOOK_DIR/lib/vocabulary.sh" 2>/dev/null || true
12
12
  source "$HOOK_DIR/lib/state-detect.sh" 2>/dev/null || true
13
+ source "$HOOK_DIR/lib/notify.sh" 2>/dev/null || true
13
14
 
14
15
  # ─── Layer 1: 精准 P0 触发词 ───
15
16
  matched=$(match_trigger "$prompt" 2>/dev/null || true)
@@ -63,6 +64,7 @@ if [ -z "$state" ]; then
63
64
  Step 8: 收敛判断
64
65
  如果 add-paradigm SKILL 尚未激活,请先调用它。
65
66
  EOF
67
+ write_hook_event "prompt-submit" "deny" "$prompt" "无活跃 ADD Plan 下检测到开发任务" "no-active-plan" "none" 2>/dev/null || true
66
68
  exit 2
67
69
  fi
68
70
 
@@ -72,4 +74,18 @@ step=$(echo "$state" | awk -F'::' '{print $2}')
72
74
  rounds=$(echo "$state" | awk -F'::' '{print $3}')
73
75
  handoff=$(echo "$state" | awk -F'::' '{print $4}')
74
76
  echo "[ADD 状态] Plan: ${plan}, 轮次: ${rounds}, Step: ${step}, handoff: ${handoff}"
77
+
78
+ # ─── Hook 治理日报(注入 AI 上下文)───
79
+ HOOK_JSONL="${MAGIC_DIR:-.qoder}/reports/hook-events.jsonl"
80
+ if [ -f "$HOOK_JSONL" ]; then
81
+ TODAY="$(date +%Y-%m-%d)"
82
+ TOTAL=$(grep -c "\"ts\":\"${TODAY}" "$HOOK_JSONL" 2>/dev/null || echo 0)
83
+ NO_PLAN=$(grep "\"ts\":\"${TODAY}" "$HOOK_JSONL" 2>/dev/null | grep -c '"planKeyword":"no-active-plan"' || echo 0)
84
+ if [ "$TOTAL" -gt 0 ] 2>/dev/null; then
85
+ echo "[Hook 治理] 今日拦截: ${TOTAL} 次 | 无 Plan 违规: ${NO_PLAN} 次"
86
+ if [ "$NO_PLAN" -ge 10 ] 2>/dev/null; then
87
+ echo "[Hook ⚠️] 无 Plan 违规已达 ${NO_PLAN} 次(≥10),建议创建 Plan 或检查 hooks 误报"
88
+ fi
89
+ fi
90
+ fi
75
91
  exit 0
File without changes
@@ -0,0 +1,30 @@
1
+ import { inputRequired, type InputRequiredResult, type InputRequest } from "@modelcontextprotocol/server"
2
+
3
+ function buildConfirmElicit(message: string, actions: readonly string[]): InputRequest {
4
+ return inputRequired.elicit({
5
+ message,
6
+ requestedSchema: {
7
+ type: "object" as const,
8
+ properties: {
9
+ action: {
10
+ type: "string" as const,
11
+ enum: [...actions],
12
+ description: "选择操作"
13
+ }
14
+ },
15
+ required: ["action"]
16
+ }
17
+ })
18
+ }
19
+
20
+ export function elicitHitlConfirm(message: string): InputRequiredResult {
21
+ return inputRequired({
22
+ inputRequests: { elicit: buildConfirmElicit(message, ["accept", "reject", "modify"]) }
23
+ })
24
+ }
25
+
26
+ export function elicitRiskPrompt(risk: string, suggestion: string): InputRequiredResult {
27
+ return inputRequired({
28
+ inputRequests: { elicit: buildConfirmElicit(`⚠️ 风险提示: ${risk}\n建议: ${suggestion}`, ["proceed", "abort"]) }
29
+ })
30
+ }
@@ -0,0 +1 @@
1
+ export { elicitHitlConfirm, elicitRiskPrompt } from "./confirm.js"
@@ -0,0 +1,15 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server"
2
+ import { registerAllTools } from "./tools/index.js"
3
+ import { registerAllResources } from "./resources/index.js"
4
+ import { registerAllNotifications } from "./notifications/index.js"
5
+ // v2: Sampling/Elicitation 通过 inputRequired 在 handler 内触发,不作为独立能力注册
6
+ // v2: Tasks 通过 protocol tasks/create 请求处理
7
+
8
+ export function registerAll(server: McpServer) {
9
+ registerAllTools(server)
10
+ registerAllResources(server)
11
+ registerAllNotifications(server)
12
+ // Sampling/Elicitation: 导出 builder 函数 (createReviewRequest, elicitHitlConfirm 等)
13
+ // 供 tools handler 内部使用: return createReviewRequest(planKeyword)
14
+ // Tasks: 导出工具函数 (enqueueTask, runTask 等),在 handler 内调用
15
+ }
@@ -0,0 +1,49 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server"
2
+ import { join } from "path"
3
+ import { existsSync } from "fs"
4
+ import { readFileSafe, readdirRecursive, PROJECT_ROOT, MAGIC_DIR } from "../shared/fs.js"
5
+ import { prisma } from "../shared/prisma.js"
6
+
7
+ export function registerHitlNotifications(server: McpServer) {
8
+ // ── HITL 表扫描 ──
9
+ const scanHitl = async () => {
10
+ const plansDir = join(PROJECT_ROOT, MAGIC_DIR, "plans")
11
+ if (!existsSync(plansDir)) return
12
+ const files = await readdirRecursive(plansDir)
13
+ const planFiles = files.filter(f => f.endsWith(".md") && f.includes("-plan-v"))
14
+ for (const pf of planFiles.slice(0, 3)) {
15
+ const content = await readFileSafe(join(plansDir, pf)) || ""
16
+ if (content.includes("## HITL 计划总览") && !content.includes("同意") && content.includes("同意/调整")) {
17
+ await server.sendLoggingMessage({
18
+ level: "notice" as const,
19
+ data: `HITL 表待确认: ${pf.replace(/-plan-v\d+\.md$/, "")}`
20
+ })
21
+ }
22
+ }
23
+ }
24
+
25
+ // ── Hook 阈值告警: no-active-plan ≥ 10 次/天 ──
26
+ let lastWarnedCount = 0
27
+ const checkThreshold = async () => {
28
+ try {
29
+ const ops = prisma.devOperation as Record<string, (...a: unknown[]) => unknown>
30
+ const since = new Date(Date.now() - 24 * 60 * 60 * 1000)
31
+ const count = await ops.count({
32
+ where: { action: "HOOK_INTERCEPT", planKeyword: "no-active-plan", createdAt: { gte: since } },
33
+ }) as number
34
+ if (count >= 10 && count !== lastWarnedCount) {
35
+ lastWarnedCount = count
36
+ await server.sendLoggingMessage({
37
+ level: "warning" as const,
38
+ data: `⚠️ Hook 阈值告警: 过去 24h 内无 Plan 违规 ${count} 次(≥10)。建议创建 Plan 或检查 hooks 误报。`
39
+ })
40
+ }
41
+ } catch { /* DB 不可用,静默 */ }
42
+ }
43
+
44
+ // 周期扫描: 30s HITL + 300s 阈值
45
+ setInterval(() => { void scanHitl() }, 30000)
46
+ setInterval(() => { void checkThreshold() }, 300000)
47
+ // 启动后立即执行一次阈值检查
48
+ void checkThreshold()
49
+ }
@@ -0,0 +1,268 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server"
2
+ import { join } from "path"
3
+ import { existsSync, watch, readFileSync, statSync } from "fs"
4
+ import { readFile } from "fs/promises"
5
+ import { PROJECT_ROOT, MAGIC_DIR } from "../shared/fs.js"
6
+ import { prisma } from "../shared/prisma.js"
7
+
8
+ // ── 类型定义 ──
9
+ interface HookEvent {
10
+ ts: string
11
+ hook: string
12
+ decision: string
13
+ cmd: string
14
+ reason: string
15
+ planKeyword: string
16
+ planStatus: string
17
+ }
18
+
19
+ interface QueueState {
20
+ events: HookEvent[]
21
+ /** 文件 inode(检测轮转) */
22
+ inode: number
23
+ /** 文件已读字节数 */
24
+ bytesRead: number
25
+ /** flush 定时器 */
26
+ timer: ReturnType<typeof setTimeout> | null
27
+ }
28
+
29
+ const REPORT_DIR = join(PROJECT_ROOT, MAGIC_DIR, "reports")
30
+ const JSONL_FILE = join(REPORT_DIR, "hook-events.jsonl")
31
+ const OVERFLOW_FILE = join(REPORT_DIR, "hook-events-overflow.jsonl")
32
+
33
+ // ── 内存缓冲队列 ──
34
+ const MAX_QUEUE = 50
35
+ let serverRef: McpServer | null = null
36
+
37
+ function createQueueState(): QueueState {
38
+ const st = statSync(JSONL_FILE, { throwIfNoEntry: false })
39
+ return {
40
+ events: [],
41
+ inode: st?.ino ?? 0,
42
+ bytesRead: st?.size ?? 0,
43
+ timer: null,
44
+ }
45
+ }
46
+
47
+ const q: QueueState = createQueueState()
48
+
49
+ // ── jsonl 解析 ──
50
+ function parseJsonlLine(line: string): HookEvent | null {
51
+ const trimmed = line.trim()
52
+ if (!trimmed) return null
53
+ try {
54
+ const obj = JSON.parse(trimmed) as HookEvent
55
+ if (!obj.ts || !obj.hook) return null
56
+ return obj
57
+ } catch {
58
+ return null
59
+ }
60
+ }
61
+
62
+ // ── 去重 key ──
63
+ function dedupKey(e: HookEvent): string {
64
+ return `${e.hook}::${e.ts}::${e.planKeyword}`
65
+ }
66
+
67
+ // ── 批量落库 ──
68
+ async function flushToDB(events: HookEvent[]): Promise<number> {
69
+ if (events.length === 0) return 0
70
+ const seen = new Set<string>()
71
+ const unique = events.filter(e => {
72
+ const k = dedupKey(e)
73
+ if (seen.has(k)) return false
74
+ seen.add(k)
75
+ return true
76
+ })
77
+ if (unique.length === 0) return 0
78
+
79
+ try {
80
+ // 获取或创建 ai-assistant 用户
81
+ const au = prisma.addUser as Record<string, (...a: unknown[]) => unknown>
82
+ let userId: string
83
+ try {
84
+ const existing = await au.findUnique({ where: { username: "ai-assistant" }, select: { id: true } }) as { id: string } | null
85
+ if (existing) {
86
+ userId = existing.id
87
+ } else {
88
+ const created = await au.create({
89
+ data: { id: "ai-assistant", username: "ai-assistant", email: "ai-assistant@internal" },
90
+ select: { id: true },
91
+ }) as { id: string }
92
+ userId = created.id
93
+ }
94
+ } catch {
95
+ userId = "ai-assistant"
96
+ }
97
+
98
+ const ops = prisma.devOperation as Record<string, (...a: unknown[]) => unknown>
99
+ const data = unique.map(e => ({
100
+ userId,
101
+ action: "HOOK_INTERCEPT",
102
+ targetType: e.hook,
103
+ targetId: e.cmd.substring(0, 500),
104
+ planKeyword: e.planKeyword,
105
+ reason: `[${e.decision}] ${e.reason}`,
106
+ afterState: JSON.stringify({ planStatus: e.planStatus }),
107
+ }))
108
+
109
+ await ops.createMany({ data })
110
+ return unique.length
111
+ } catch (err) {
112
+ console.error("[hook-notify] flushToDB 失败:", err instanceof Error ? err.message : String(err))
113
+ return 0
114
+ }
115
+ }
116
+
117
+ // ── 消费溢出文件 ──
118
+ async function drainOverflowFile(): Promise<HookEvent[]> {
119
+ const events: HookEvent[] = []
120
+ try {
121
+ if (!existsSync(OVERFLOW_FILE)) return events
122
+ const content = await readFile(OVERFLOW_FILE, "utf-8")
123
+ for (const line of content.split("\n")) {
124
+ const ev = parseJsonlLine(line)
125
+ if (ev) events.push(ev)
126
+ }
127
+ // 清空溢出文件
128
+ const { writeFileSync } = await import("fs")
129
+ writeFileSync(OVERFLOW_FILE, "", "utf-8")
130
+ } catch {
131
+ // 忽略,下次 flush 重试
132
+ }
133
+ return events
134
+ }
135
+
136
+ // ── 核心: flush ──
137
+ async function doFlush(): Promise<void> {
138
+ // 1) drain 内存队列
139
+ const memEvents = q.events.splice(0)
140
+ // 2) drain 溢出文件
141
+ const overflowEvents = await drainOverflowFile()
142
+ // 3) 合并
143
+ const allEvents = [...memEvents, ...overflowEvents]
144
+ if (allEvents.length === 0) return
145
+
146
+ const count = await flushToDB(allEvents)
147
+ if (count > 0 && serverRef) {
148
+ const noPlanCount = allEvents.filter(e => e.planKeyword === "no-active-plan").length
149
+ const msg = `[Hook] ${count} 条拦截事件已审计落库(计划: ${[...new Set(allEvents.map(e => e.planKeyword))].join(", ")})`
150
+ serverRef.sendLoggingMessage({ level: "warning" as const, data: msg }).catch(() => {})
151
+ }
152
+ }
153
+
154
+ // ── 入队 + 触发策略 ──
155
+ function enqueue(event: HookEvent): void {
156
+ if (q.events.length >= MAX_QUEUE) {
157
+ // 降级写入溢出文件
158
+ try {
159
+ const { appendFileSync } = require("fs") as typeof import("fs")
160
+ appendFileSync(OVERFLOW_FILE, JSON.stringify(event) + "\n", "utf-8")
161
+ } catch { /* 静默 */ }
162
+ return
163
+ }
164
+ q.events.push(event)
165
+
166
+ if (q.events.length >= MAX_QUEUE) {
167
+ // 满 50 条立即 flush
168
+ if (q.timer) { clearTimeout(q.timer); q.timer = null }
169
+ void doFlush()
170
+ } else if (!q.timer) {
171
+ // 调度 2s 后 flush
172
+ q.timer = setTimeout(() => {
173
+ q.timer = null
174
+ void doFlush()
175
+ }, 2000)
176
+ }
177
+ }
178
+
179
+ // ── fs.watch 回调 ──
180
+ function readNewLines(): void {
181
+ try {
182
+ const st = statSync(JSONL_FILE, { throwIfNoEntry: false })
183
+ if (!st) return
184
+
185
+ // 检测文件轮转(inode 变化或文件变小)
186
+ if (st.ino !== q.inode || st.size < q.bytesRead) {
187
+ q.inode = st.ino
188
+ q.bytesRead = 0
189
+ }
190
+
191
+ if (st.size <= q.bytesRead) return
192
+
193
+ // 只读增量部分
194
+ const fd = require("fs").openSync(JSONL_FILE, "r")
195
+ const buf = Buffer.alloc(st.size - q.bytesRead)
196
+ require("fs").readSync(fd, buf, 0, buf.length, q.bytesRead)
197
+ require("fs").closeSync(fd)
198
+
199
+ const text = buf.toString("utf-8")
200
+ for (const line of text.split("\n")) {
201
+ const ev = parseJsonlLine(line)
202
+ if (ev) enqueue(ev)
203
+ }
204
+ q.bytesRead = st.size
205
+ } catch {
206
+ // 文件可能被轮转删除,下次重试
207
+ }
208
+ }
209
+
210
+ // ── 启动时全量扫描已有文件 ──
211
+ async function initialScan(): Promise<void> {
212
+ const files = [JSONL_FILE, `${JSONL_FILE}.old`]
213
+ for (const f of files) {
214
+ if (!existsSync(f)) continue
215
+ try {
216
+ const content = await readFile(f, "utf-8")
217
+ for (const line of content.split("\n")) {
218
+ const ev = parseJsonlLine(line)
219
+ if (ev) enqueue(ev)
220
+ }
221
+ } catch { /* skip */ }
222
+ }
223
+ // 初始化消费位点
224
+ const st = statSync(JSONL_FILE, { throwIfNoEntry: false })
225
+ if (st) {
226
+ q.inode = st.ino
227
+ q.bytesRead = st.size
228
+ }
229
+ }
230
+
231
+ // ── 注册 ──
232
+ export function registerHookNotifications(server: McpServer) {
233
+ serverRef = server
234
+
235
+ // 启动通知
236
+ server.sendLoggingMessage({
237
+ level: "notice" as const,
238
+ data: "ADD Hooks 治理卡位已激活,拦截事件将自动审计落库"
239
+ }).catch(() => {})
240
+
241
+ // 启动时扫描 + 建立 fs.watch
242
+ void initialScan().then(() => {
243
+ // 确保 jsonl 文件存在(fs.watch 要求文件已存在)
244
+ try {
245
+ const { mkdirSync, writeFileSync } = require("fs") as typeof import("fs")
246
+ mkdirSync(REPORT_DIR, { recursive: true })
247
+ if (!existsSync(JSONL_FILE)) writeFileSync(JSONL_FILE, "", "utf-8")
248
+ } catch { /* ok */ }
249
+
250
+ // 立即消费启动前已有事件
251
+ void doFlush()
252
+
253
+ // fs.watch 监听目录变化(文件可能后来才创建)
254
+ try {
255
+ watch(REPORT_DIR, (_event, filename) => {
256
+ if (filename === "hook-events.jsonl" || filename === "hook-events.jsonl.old") {
257
+ readNewLines()
258
+ }
259
+ })
260
+ } catch { /* watch 可能不支持 */ }
261
+ })
262
+
263
+ // 进程退出时清空剩余队列
264
+ const cleanup = () => { void doFlush() }
265
+ process.on("exit", cleanup)
266
+ process.on("SIGTERM", cleanup)
267
+ process.on("SIGINT", cleanup)
268
+ }