@hupan56/wlkj 3.3.12 → 3.3.14
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/bin/cli.js +1 -23
- package/package.json +29 -29
- package/templates/qoder/agents/design-agent.md +20 -0
- package/templates/qoder/agents/spec-generator.md +21 -0
- package/templates/qoder/commands/optional/wl-spec.md +2 -12
- package/templates/qoder/commands/optional/wl-status.md +0 -8
- package/templates/qoder/commands/wl-code.md +0 -8
- package/templates/qoder/commands/wl-commit.md +1 -11
- package/templates/qoder/commands/wl-init.md +129 -129
- package/templates/qoder/commands/wl-prd.md +0 -24
- package/templates/qoder/commands/wl-search.md +11 -26
- package/templates/qoder/commands/wl-task.md +1 -11
- package/templates/qoder/commands/wl-test.md +0 -8
- package/templates/qoder/config.yaml +8 -3
- package/templates/qoder/hooks/session-start.py +365 -384
- package/templates/qoder/hooks/stop-eval.py +254 -383
- package/templates/qoder/scripts/capability/adapters/mcp.py +314 -322
- package/templates/qoder/scripts/capability/registry.py +248 -245
- package/templates/qoder/scripts/capability/registry_mcp.py +4 -10
- package/templates/qoder/scripts/capability/smoke_test_report.json.new +94 -0
- package/templates/qoder/scripts/deployment/setup/init_doctor.py +2 -6
- package/templates/qoder/scripts/deployment/setup/install_qoderwork.py +14 -0
- package/templates/qoder/scripts/deployment/setup/setup.py +2 -16
- package/templates/qoder/scripts/domain/integration/return_to_platform.py +2 -4
- package/templates/qoder/scripts/domain/integration/spec_upload.py +4 -4
- package/templates/qoder/scripts/domain/kg/build/build_entity_registry.py +12 -12
- package/templates/qoder/scripts/domain/kg/build/build_relations.py +12 -12
- package/templates/qoder/scripts/domain/kg/extract/asset/__init__.py +10 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/asset_tree.py +57 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/discussion_importer.py +62 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/prd_importer.py +146 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/prototype_importer.py +64 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/returns_importer.py +52 -0
- package/templates/qoder/scripts/domain/kg/extract/build_goal3.py +104 -0
- package/templates/qoder/scripts/domain/kg/extract/build_goal4.py +55 -0
- package/templates/qoder/scripts/domain/kg/extract/build_goal5.py +95 -0
- package/templates/qoder/scripts/domain/kg/extract/db/__init__.py +8 -0
- package/templates/qoder/scripts/domain/kg/extract/db/data_profile.py +22 -0
- package/templates/qoder/scripts/domain/kg/extract/db/fk_extractor.py +55 -0
- package/templates/qoder/scripts/domain/kg/extract/db/schema_extractor.py +90 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/__init__.py +9 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/community_summarizer.py +206 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/embed_builder.py +132 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/naming_matcher.py +80 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/promote.py +59 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/recompute.py +93 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/weak_link.py +421 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/__init__.py +9 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/all.py +79 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/mapper_parser.py +99 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/relation_builder.py +69 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/sql_extractor.py +78 -0
- package/templates/qoder/scripts/domain/kg/extract/prd/__init__.py +8 -0
- package/templates/qoder/scripts/domain/kg/extract/prd/prd_chunk_embed.py +105 -0
- package/templates/qoder/scripts/domain/kg/extract/prd/prd_llm_extract.py +153 -0
- package/templates/qoder/scripts/domain/kg/extract/prd/req_anchor.py +120 -0
- package/templates/qoder/scripts/domain/kg/graph/kg_link_db.py +235 -0
- package/templates/qoder/scripts/domain/kg/graph/kg_semantic.py +5 -41
- package/templates/qoder/scripts/domain/kg/kg.py +0 -14
- package/templates/qoder/scripts/domain/kg/search/enrich_prompt.py +238 -0
- package/templates/qoder/scripts/domain/kg/server/perf_bench.py +197 -0
- package/templates/qoder/scripts/domain/kg/switch_project.py +2 -2
- package/templates/qoder/scripts/domain/kg/sync_repowiki.py +109 -0
- package/templates/qoder/scripts/domain/task/wlkj_panel.py +1348 -1503
- package/templates/qoder/scripts/engine/poller.py +219 -0
- package/templates/qoder/scripts/foundation/bootstrap.py +145 -145
- package/templates/qoder/scripts/orchestration/wlkj.py +189 -306
- package/templates/qoder/settings.json +0 -8
- package/templates/qoder/skills/wl-spec/SKILL.md +1 -1
- package/templates/qoder/commands/wl-data.md +0 -46
- package/templates/qoder/commands/wl-fix.md +0 -47
- package/templates/qoder/commands/wl-knowledge.md +0 -41
- package/templates/qoder/commands/wl-review.md +0 -49
- package/templates/qoder/contracts/insight.md +0 -55
- package/templates/qoder/hooks/pre-tool-use-commit.py +0 -124
- package/templates/qoder/scripts/foundation/io/context_cache.py +0 -94
- package/templates/qoder/scripts/tool_guide.md +0 -70
- package/templates/qoder/scripts/validation/eval/alignment_matrix.py +0 -176
- package/templates/qoder/scripts/validation/eval/bf2_content_fidelity.py +0 -110
- package/templates/qoder/scripts/validation/eval/bf2_llmjudge.py +0 -104
- package/templates/qoder/scripts/validation/eval/bf_score.py +0 -218
- package/templates/qoder/scripts/validation/eval/code_flywheel.py +0 -150
- package/templates/qoder/scripts/validation/eval/dispatcher_ab.py +0 -156
- package/templates/qoder/scripts/validation/eval/dispatcher_ab_2026-07-21.json +0 -23
- package/templates/qoder/scripts/validation/eval/feature_fidelity_flywheel.py +0 -143
- package/templates/qoder/scripts/validation/eval/gradient_matrix.py +0 -261
- package/templates/qoder/scripts/validation/eval/gradient_matrix_baseline_2026-07-21.json +0 -33
- package/templates/qoder/scripts/validation/eval/metrics_dashboard.py +0 -105
- package/templates/qoder/scripts/validation/eval/multi_turn_flywheel.py +0 -118
- package/templates/qoder/scripts/validation/eval/prd_fidelity_flywheel.py +0 -128
- package/templates/qoder/scripts/validation/eval/prd_flywheel.py +0 -148
- package/templates/qoder/scripts/validation/eval/prototype_fidelity_flywheel.py +0 -166
- package/templates/qoder/scripts/validation/eval/recall_flywheel.py +0 -148
- package/templates/qoder/scripts/validation/eval/robustness_flywheel.py +0 -139
- package/templates/qoder/scripts/validation/eval/speed_accuracy_flywheel.py +0 -188
- package/templates/qoder/scripts/validation/eval/task_flywheel.py +0 -124
- package/templates/qoder/scripts/validation/eval/token_flywheel.py +0 -88
- package/templates/qoder/scripts/validation/metrics/eval_code_ac.py +0 -177
- package/templates/qoder/scripts/validation/metrics/lint_cases.py +0 -170
- package/templates/qoder/scripts/validation/test/test_context_cache.py +0 -78
- package/templates/qoder/scripts/validation/test/test_lint_cases.py +0 -60
- package/templates/qoder/scripts/validation/test/test_pre_tool_use_commit.py +0 -70
- package/templates/workspace/specs/REQ-TEST-CTX/.context-cache.json +0 -1
|
@@ -1,383 +1,254 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
"""stop-eval.py - Stop hook: Agent 说"完成"时强制 eval 门禁 (治"说做完了没真评测")
|
|
4
|
-
|
|
5
|
-
吸收宿主确定性 hook 能力: 把 EVA 门禁 (PRD 发布前 eval≥80%) 从"靠 AI 自觉跑"
|
|
6
|
-
升级为"Agent 准备结束回复时宿主拦住"。AI 说"PRD 做完了"但没跑 eval → 被拦回继续。
|
|
7
|
-
|
|
8
|
-
阻断契约: exit 0 放行; exit 2 阻断(stderr 反馈, 强制 Agent 续修)。
|
|
9
|
-
|
|
10
|
-
防递归 (关键!): Stop hook 被 exit 2 阻断后, Agent 续修完再次 Stop 会再次触发本 hook →
|
|
11
|
-
死循环。必须检查 stdin 的 stop_hook_active: 为 true 时直接 exit 0 放行 (Agent 已在本轮
|
|
12
|
-
被拦过一次, 不再二次拦)。
|
|
13
|
-
|
|
14
|
-
判断逻辑 (保守, 只拦明确场景):
|
|
15
|
-
- 本会话最近产出过 PRD 文件 (扫描 workspace/members/*/drafts + tasks/*/prd.md,
|
|
16
|
-
找近 10 分钟内 mtime 的 prd*.md)
|
|
17
|
-
- 且该 PRD 没有对应的 eval 记录 (eval-history.jsonl 里查不到该 prd 名)
|
|
18
|
-
→ exit 2 强制 eval
|
|
19
|
-
其它情况一律 exit 0 (绝不误拦普通对话/非 PRD 工作)。
|
|
20
|
-
|
|
21
|
-
stdin 字段名官方未公开, 兼容 stop_hook_active / hook_active 等多种写法。
|
|
22
|
-
"""
|
|
23
|
-
import json
|
|
24
|
-
import os
|
|
25
|
-
import sys
|
|
26
|
-
import time
|
|
27
|
-
from datetime import datetime
|
|
28
|
-
|
|
29
|
-
if sys.platform == 'win32':
|
|
30
|
-
try:
|
|
31
|
-
sys.stdout.reconfigure(encoding='utf-8')
|
|
32
|
-
sys.stderr.reconfigure(encoding='utf-8')
|
|
33
|
-
except Exception:
|
|
34
|
-
pass
|
|
35
|
-
|
|
36
|
-
BASE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
37
|
-
HOOK_ERRORS_LOG = os.path.join(BASE, '.qoder', '.runtime', 'hook-errors.log')
|
|
38
|
-
EVAL_HISTORY = os.path.join(BASE, 'data', 'learning', 'eval-history.jsonl')
|
|
39
|
-
RECENT_WINDOW = 600 # 10 分钟内产出的 PRD 视为本轮产物
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _is_reentry(stdin_data):
|
|
43
|
-
"""是否二次进入 (防递归)。兼容多字段名。"""
|
|
44
|
-
if not isinstance(stdin_data, dict):
|
|
45
|
-
return False
|
|
46
|
-
for k in ('stop_hook_active', 'hook_active', 'is_reentry'):
|
|
47
|
-
v = stdin_data.get(k)
|
|
48
|
-
if v in (True, 'true', 'True', 1, '1'):
|
|
49
|
-
return True
|
|
50
|
-
return False
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _read_stdin():
|
|
54
|
-
try:
|
|
55
|
-
if sys.stdin.isatty():
|
|
56
|
-
return {}
|
|
57
|
-
raw = sys.stdin.read().strip()
|
|
58
|
-
if not raw:
|
|
59
|
-
return {}
|
|
60
|
-
return json.loads(raw)
|
|
61
|
-
except Exception:
|
|
62
|
-
return {}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _is_prd_filename(fn):
|
|
66
|
-
"""判断文件名是否是 PRD 产物。
|
|
67
|
-
|
|
68
|
-
真实 PRD 有两类命名 (与 eval-history.jsonl 记录的 'prd' 字段对齐):
|
|
69
|
-
1. workspace/tasks/<task>/prd.md (任务级 PRD, 文件名含 prd)
|
|
70
|
-
2. workspace/members/<dev>/drafts/REQ-2026-XXX-<feature>.md
|
|
71
|
-
(AI 产出的 PRD 草稿, 命名 REQ-<id>-<feature>.md, 不含 'prd' 字样)
|
|
72
|
-
只匹配 'prd' 会漏掉第 2 类 (绝大多数真实产出) → 门禁形同虚设。
|
|
73
|
-
这里两类都认: 文件名含 'prd' 或 以 'REQ-' 开头 (eval-history 的真实记录格式)。
|
|
74
|
-
"""
|
|
75
|
-
low = fn.lower()
|
|
76
|
-
if not low.endswith('.md'):
|
|
77
|
-
return False
|
|
78
|
-
return 'prd' in low or low.startswith('req-')
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _recent_prd_files():
|
|
82
|
-
"""找近 RECENT_WINDOW 秒内产出的 PRD 文件 (绝对路径)。扫描 drafts + tasks。"""
|
|
83
|
-
now = time.time()
|
|
84
|
-
candidates = []
|
|
85
|
-
search_roots = [
|
|
86
|
-
os.path.join(BASE, 'workspace', 'members'),
|
|
87
|
-
os.path.join(BASE, 'workspace', 'tasks'),
|
|
88
|
-
os.path.join(BASE, 'data', 'docs', 'prd'),
|
|
89
|
-
]
|
|
90
|
-
for root in search_roots:
|
|
91
|
-
if not os.path.isdir(root):
|
|
92
|
-
continue
|
|
93
|
-
for dirpath, dirs, files in os.walk(root):
|
|
94
|
-
# 跳过无关大目录
|
|
95
|
-
dirs[:] = [d for d in dirs if d not in ('node_modules', '.git', '__pycache__', '.secrets')]
|
|
96
|
-
for fn in files:
|
|
97
|
-
if not _is_prd_filename(fn):
|
|
98
|
-
continue
|
|
99
|
-
fp = os.path.join(dirpath, fn)
|
|
100
|
-
try:
|
|
101
|
-
if now - os.path.getmtime(fp) <= RECENT_WINDOW:
|
|
102
|
-
candidates.append(fp)
|
|
103
|
-
except OSError:
|
|
104
|
-
continue
|
|
105
|
-
return candidates
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
# 快速模式 6 章必需标题 (与 prd-quick-template.md 对齐)
|
|
109
|
-
QUICK_REQUIRED_SECTIONS = [
|
|
110
|
-
'功能入口', '需求背景', '需求说明', '影响范围', '验收标准', '不在本次范围',
|
|
111
|
-
]
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _check_quick_prd_complete(prd_path):
|
|
115
|
-
"""检测 quick 模式 PRD 是否缺章。返回缺失章节列表 (空=完整)。
|
|
116
|
-
|
|
117
|
-
判定 quick 模式: @contract 头含 mode: quick。
|
|
118
|
-
必需 6 章: 功能入口/需求背景/需求说明/影响范围/验收标准/不在本次范围。
|
|
119
|
-
治"残废品" (真实案例: 海外考勤 PRD 只写"功能入口"一段, 6 章缺 5 章)。
|
|
120
|
-
"""
|
|
121
|
-
try:
|
|
122
|
-
text = open(prd_path, encoding='utf-8', errors='replace').read()
|
|
123
|
-
except Exception:
|
|
124
|
-
return [] # 读不到不拦 (误拦比放过更糟)
|
|
125
|
-
# 只检 quick 模式 (完整档 13 章另算, 不在此 hook 范围)
|
|
126
|
-
if 'mode: quick' not in text[:500]:
|
|
127
|
-
return []
|
|
128
|
-
missing = []
|
|
129
|
-
for sec in QUICK_REQUIRED_SECTIONS:
|
|
130
|
-
# 章节标题: ## 功能入口 / ## 需求背景 等 (允许前后空格)
|
|
131
|
-
import re
|
|
132
|
-
if not re.search(r'^#{1,3}\s*' + sec, text, re.MULTILINE):
|
|
133
|
-
missing.append(sec)
|
|
134
|
-
return missing
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _evald_prds():
|
|
138
|
-
"""读 eval-history.jsonl, 返回【已通过门禁】的 prd 文件名集合。
|
|
139
|
-
|
|
140
|
-
门禁定义 (与消息承诺一致): 该 PRD 最近一次 eval 记录 passed=True 或 score_pct>=80。
|
|
141
|
-
只"跑过 eval 但没过线"的不算通过 → 仍需拦回去重测。
|
|
142
|
-
eval-history.jsonl 是 append-only, 同一 PRD 可能有多条 → 取最后一条为准。
|
|
143
|
-
"""
|
|
144
|
-
last = _last_record_by_prd()
|
|
145
|
-
done = set()
|
|
146
|
-
for key, r in last.items():
|
|
147
|
-
passed = r.get('passed')
|
|
148
|
-
score = r.get('score_pct')
|
|
149
|
-
is_ok = (passed is True) or (isinstance(score, (int, float)) and score >= 80)
|
|
150
|
-
if is_ok:
|
|
151
|
-
done.add(key)
|
|
152
|
-
return done
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def _last_record_by_prd():
|
|
156
|
-
"""读 eval-history.jsonl, 返回 {prd_basename: 最后一条记录}。
|
|
157
|
-
|
|
158
|
-
append-only 文件, 后出现的记录覆盖前面的 → 取每个 PRD 的最新 eval。
|
|
159
|
-
被门禁诊断复用 (区分"没跑过 eval" vs "跑了但没过线")。
|
|
160
|
-
"""
|
|
161
|
-
last_by_prd = {}
|
|
162
|
-
if not os.path.isfile(EVAL_HISTORY):
|
|
163
|
-
return last_by_prd
|
|
164
|
-
try:
|
|
165
|
-
with open(EVAL_HISTORY, encoding='utf-8', errors='replace') as f:
|
|
166
|
-
for line in f:
|
|
167
|
-
line = line.strip()
|
|
168
|
-
if not line:
|
|
169
|
-
continue
|
|
170
|
-
try:
|
|
171
|
-
r = json.loads(line)
|
|
172
|
-
p = r.get('prd')
|
|
173
|
-
if p:
|
|
174
|
-
last_by_prd[os.path.basename(str(p))] = r
|
|
175
|
-
except Exception:
|
|
176
|
-
continue
|
|
177
|
-
except Exception:
|
|
178
|
-
pass
|
|
179
|
-
return last_by_prd
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
continue
|
|
256
|
-
out_lines = [l for l in (r.stdout or '').split('\n') if l.strip()]
|
|
257
|
-
try:
|
|
258
|
-
data = json.loads(out_lines[-1]) if out_lines else {}
|
|
259
|
-
except Exception:
|
|
260
|
-
data = {}
|
|
261
|
-
blocks = [v for v in (data.get('violations') or []) if v.get('severity') == 'BLOCK']
|
|
262
|
-
if blocks:
|
|
263
|
-
detail = '\n'.join(' - case#%s %s' % (v.get('idx'), str(v.get('detail', ''))[:60]) for v in blocks[:5])
|
|
264
|
-
sys.stderr.write(
|
|
265
|
-
'【wl-test cases 门禁】%s 检测到旧格式用例(action+CSS selector,新 Playwright 管线不认):\n%s\n'
|
|
266
|
-
'修复: 改 intent+anchor 格式,anchor={role,name}(来自 recall/snapshot),凭据用 {{ask:key}}。\n'
|
|
267
|
-
% (os.path.basename(fp), detail))
|
|
268
|
-
sys.exit(2)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def _enforce_commit_msg_gate():
|
|
272
|
-
"""P0-3 w3: wl-commit msg 门禁。近 30min commit msg 须含 [#REQ-xxx] 或 [#禅道号]。
|
|
273
|
-
|
|
274
|
-
治"commit 不关联需求"(F1 闭环数据断)。memory commit-convention-zentao-id:
|
|
275
|
-
S3 [#REQ-<uuid8>] / S5.1 push 后 [#<禅道号>]。无标记 → exit 2 提示 amend。
|
|
276
|
-
"""
|
|
277
|
-
import re as _re
|
|
278
|
-
import subprocess
|
|
279
|
-
try:
|
|
280
|
-
r = subprocess.run(['git', 'log', '-1', '--format=%B|||%ct'], cwd=BASE,
|
|
281
|
-
capture_output=True, text=True, timeout=10)
|
|
282
|
-
out = (r.stdout or '').strip()
|
|
283
|
-
except Exception:
|
|
284
|
-
return # 崩溃不阻塞
|
|
285
|
-
if '|||' not in out:
|
|
286
|
-
return
|
|
287
|
-
msg, _, ts_s = out.rpartition('|||')
|
|
288
|
-
try:
|
|
289
|
-
ts = int(ts_s)
|
|
290
|
-
except ValueError:
|
|
291
|
-
ts = 0
|
|
292
|
-
if ts and time.time() - ts > 1800:
|
|
293
|
-
return # 非本轮 commit
|
|
294
|
-
if _re.search(r'\[#REQ-[\w-]+\]|\[#\d+\]', msg):
|
|
295
|
-
return # 含需求标记, 放行
|
|
296
|
-
sys.stderr.write(
|
|
297
|
-
'【wl-commit msg 门禁】最近 commit msg 缺需求关联标记:\n %s\n'
|
|
298
|
-
'修复: git commit --amend 补 [#REQ-<uuid>] (S3) 或 [#<禅道号>] (S5.1 push 后)。\n'
|
|
299
|
-
% msg.replace('\n', ' ')[:120])
|
|
300
|
-
sys.exit(2)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def main():
|
|
304
|
-
try:
|
|
305
|
-
data = _read_stdin()
|
|
306
|
-
# 防递归: 二次进入直接放行
|
|
307
|
-
if _is_reentry(data):
|
|
308
|
-
sys.exit(0)
|
|
309
|
-
|
|
310
|
-
# ★ P0-3 wl-code spec AC 门禁(代码漏实现 AC 检测, 治自检全打勾)
|
|
311
|
-
_enforce_code_ac_gate()
|
|
312
|
-
# P0-3 w2 wl-test cases 格式门禁(旧 action+CSS selector → 拦)
|
|
313
|
-
_enforce_cases_lint_gate()
|
|
314
|
-
# w3 wl-commit msg 门禁: 应在 pre-tool-use(git commit 命令前验 msg)而非 stop-eval——
|
|
315
|
-
# stop-eval 的 git log -1 不分"本轮 wl-commit"vs 其他 commit, 会在平台开发等非 wl-commit
|
|
316
|
-
# 场景误拦。函数 _enforce_commit_msg_gate 已定义待 pre-tool-use 细化接入。
|
|
317
|
-
|
|
318
|
-
# 找近 10 分钟产出的 PRD, 检查是否已【通过】门禁
|
|
319
|
-
recent = _recent_prd_files()
|
|
320
|
-
if not recent:
|
|
321
|
-
sys.exit(0) # 本轮没产 PRD, 放行
|
|
322
|
-
|
|
323
|
-
# ── 先查 quick 模式 PRD 缺章 (残废品门禁, 比 eval 更优先) ──
|
|
324
|
-
incomplete = []
|
|
325
|
-
for p in recent:
|
|
326
|
-
miss = _check_quick_prd_complete(p)
|
|
327
|
-
if miss:
|
|
328
|
-
incomplete.append((p, miss))
|
|
329
|
-
if incomplete:
|
|
330
|
-
lines = []
|
|
331
|
-
for p, miss in incomplete[:3]:
|
|
332
|
-
lines.append(' %s 缺: %s' % (os.path.basename(p), '、'.join(miss)))
|
|
333
|
-
sys.stderr.write(
|
|
334
|
-
'【PRD 残废品门禁】检测到快速模式 PRD 缺章 (6 章缺一不可):\n'
|
|
335
|
-
+ '\n'.join(lines) + '\n'
|
|
336
|
-
'修复: 补齐缺失章节的标题和内容 (不涉及的写一句话说明, 标题必须保留),\n'
|
|
337
|
-
' 章节顺序: 功能入口/需求背景/需求说明/影响范围/验收标准/不在本次范围。\n'
|
|
338
|
-
' 模板: .qoder/templates/prd-quick-template.md\n')
|
|
339
|
-
sys.exit(2)
|
|
340
|
-
evald = _evald_prds()
|
|
341
|
-
last_records = _last_record_by_prd()
|
|
342
|
-
# 区分两种未过门禁: 从没跑过 eval / 跑了但没过线
|
|
343
|
-
not_run = [p for p in recent if os.path.basename(p) not in last_records]
|
|
344
|
-
failed = [p for p in recent
|
|
345
|
-
if os.path.basename(p) in last_records
|
|
346
|
-
and os.path.basename(p) not in evald]
|
|
347
|
-
if not not_run and not failed:
|
|
348
|
-
sys.exit(0) # 全部通过门禁, 放行
|
|
349
|
-
|
|
350
|
-
# 有未过门禁的近期 PRD → 阻断, 给出可操作指引
|
|
351
|
-
parts = []
|
|
352
|
-
if not_run:
|
|
353
|
-
parts.append('未跑评测: ' + ', '.join(os.path.basename(p) for p in not_run[:3]))
|
|
354
|
-
if failed:
|
|
355
|
-
details = []
|
|
356
|
-
for p in failed[:3]:
|
|
357
|
-
rec = last_records.get(os.path.basename(p), {})
|
|
358
|
-
s = rec.get('score_pct')
|
|
359
|
-
details.append('%s (最近 %s)' % (os.path.basename(p),
|
|
360
|
-
('%s%%' % s) if s is not None else '未过'))
|
|
361
|
-
parts.append('评测未过线: ' + ', '.join(details))
|
|
362
|
-
sys.stderr.write(
|
|
363
|
-
'【EVA 门禁】检测到近期产出的 PRD 未通过质量门禁 (要求 eval ≥80%%):\n'
|
|
364
|
-
' %s\n'
|
|
365
|
-
'修复: python .qoder/scripts/orchestration/wlkj.py eval <PRD> [原型]\n'
|
|
366
|
-
' 针对评测报告的扣分项改 PRD, 重跑 eval 到 ≥80%% 再结束。\n'
|
|
367
|
-
% '; '.join(parts))
|
|
368
|
-
sys.exit(2)
|
|
369
|
-
except SystemExit:
|
|
370
|
-
raise
|
|
371
|
-
except Exception as e:
|
|
372
|
-
try:
|
|
373
|
-
os.makedirs(os.path.dirname(HOOK_ERRORS_LOG), exist_ok=True)
|
|
374
|
-
with open(HOOK_ERRORS_LOG, 'a', encoding='utf-8') as f:
|
|
375
|
-
f.write('[{}] stop-eval: {}\n'.format(
|
|
376
|
-
datetime.now().strftime('%Y-%m-%d %H:%M:%S'), str(e)[:200]))
|
|
377
|
-
except Exception:
|
|
378
|
-
pass
|
|
379
|
-
sys.exit(0) # 铁律: hook 崩溃绝不阻塞
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if __name__ == '__main__':
|
|
383
|
-
main()
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""stop-eval.py - Stop hook: Agent 说"完成"时强制 eval 门禁 (治"说做完了没真评测")
|
|
4
|
+
|
|
5
|
+
吸收宿主确定性 hook 能力: 把 EVA 门禁 (PRD 发布前 eval≥80%) 从"靠 AI 自觉跑"
|
|
6
|
+
升级为"Agent 准备结束回复时宿主拦住"。AI 说"PRD 做完了"但没跑 eval → 被拦回继续。
|
|
7
|
+
|
|
8
|
+
阻断契约: exit 0 放行; exit 2 阻断(stderr 反馈, 强制 Agent 续修)。
|
|
9
|
+
|
|
10
|
+
防递归 (关键!): Stop hook 被 exit 2 阻断后, Agent 续修完再次 Stop 会再次触发本 hook →
|
|
11
|
+
死循环。必须检查 stdin 的 stop_hook_active: 为 true 时直接 exit 0 放行 (Agent 已在本轮
|
|
12
|
+
被拦过一次, 不再二次拦)。
|
|
13
|
+
|
|
14
|
+
判断逻辑 (保守, 只拦明确场景):
|
|
15
|
+
- 本会话最近产出过 PRD 文件 (扫描 workspace/members/*/drafts + tasks/*/prd.md,
|
|
16
|
+
找近 10 分钟内 mtime 的 prd*.md)
|
|
17
|
+
- 且该 PRD 没有对应的 eval 记录 (eval-history.jsonl 里查不到该 prd 名)
|
|
18
|
+
→ exit 2 强制 eval
|
|
19
|
+
其它情况一律 exit 0 (绝不误拦普通对话/非 PRD 工作)。
|
|
20
|
+
|
|
21
|
+
stdin 字段名官方未公开, 兼容 stop_hook_active / hook_active 等多种写法。
|
|
22
|
+
"""
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
|
|
29
|
+
if sys.platform == 'win32':
|
|
30
|
+
try:
|
|
31
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
32
|
+
sys.stderr.reconfigure(encoding='utf-8')
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
BASE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
37
|
+
HOOK_ERRORS_LOG = os.path.join(BASE, '.qoder', '.runtime', 'hook-errors.log')
|
|
38
|
+
EVAL_HISTORY = os.path.join(BASE, 'data', 'learning', 'eval-history.jsonl')
|
|
39
|
+
RECENT_WINDOW = 600 # 10 分钟内产出的 PRD 视为本轮产物
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_reentry(stdin_data):
|
|
43
|
+
"""是否二次进入 (防递归)。兼容多字段名。"""
|
|
44
|
+
if not isinstance(stdin_data, dict):
|
|
45
|
+
return False
|
|
46
|
+
for k in ('stop_hook_active', 'hook_active', 'is_reentry'):
|
|
47
|
+
v = stdin_data.get(k)
|
|
48
|
+
if v in (True, 'true', 'True', 1, '1'):
|
|
49
|
+
return True
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read_stdin():
|
|
54
|
+
try:
|
|
55
|
+
if sys.stdin.isatty():
|
|
56
|
+
return {}
|
|
57
|
+
raw = sys.stdin.read().strip()
|
|
58
|
+
if not raw:
|
|
59
|
+
return {}
|
|
60
|
+
return json.loads(raw)
|
|
61
|
+
except Exception:
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_prd_filename(fn):
|
|
66
|
+
"""判断文件名是否是 PRD 产物。
|
|
67
|
+
|
|
68
|
+
真实 PRD 有两类命名 (与 eval-history.jsonl 记录的 'prd' 字段对齐):
|
|
69
|
+
1. workspace/tasks/<task>/prd.md (任务级 PRD, 文件名含 prd)
|
|
70
|
+
2. workspace/members/<dev>/drafts/REQ-2026-XXX-<feature>.md
|
|
71
|
+
(AI 产出的 PRD 草稿, 命名 REQ-<id>-<feature>.md, 不含 'prd' 字样)
|
|
72
|
+
只匹配 'prd' 会漏掉第 2 类 (绝大多数真实产出) → 门禁形同虚设。
|
|
73
|
+
这里两类都认: 文件名含 'prd' 或 以 'REQ-' 开头 (eval-history 的真实记录格式)。
|
|
74
|
+
"""
|
|
75
|
+
low = fn.lower()
|
|
76
|
+
if not low.endswith('.md'):
|
|
77
|
+
return False
|
|
78
|
+
return 'prd' in low or low.startswith('req-')
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _recent_prd_files():
|
|
82
|
+
"""找近 RECENT_WINDOW 秒内产出的 PRD 文件 (绝对路径)。扫描 drafts + tasks。"""
|
|
83
|
+
now = time.time()
|
|
84
|
+
candidates = []
|
|
85
|
+
search_roots = [
|
|
86
|
+
os.path.join(BASE, 'workspace', 'members'),
|
|
87
|
+
os.path.join(BASE, 'workspace', 'tasks'),
|
|
88
|
+
os.path.join(BASE, 'data', 'docs', 'prd'),
|
|
89
|
+
]
|
|
90
|
+
for root in search_roots:
|
|
91
|
+
if not os.path.isdir(root):
|
|
92
|
+
continue
|
|
93
|
+
for dirpath, dirs, files in os.walk(root):
|
|
94
|
+
# 跳过无关大目录
|
|
95
|
+
dirs[:] = [d for d in dirs if d not in ('node_modules', '.git', '__pycache__', '.secrets')]
|
|
96
|
+
for fn in files:
|
|
97
|
+
if not _is_prd_filename(fn):
|
|
98
|
+
continue
|
|
99
|
+
fp = os.path.join(dirpath, fn)
|
|
100
|
+
try:
|
|
101
|
+
if now - os.path.getmtime(fp) <= RECENT_WINDOW:
|
|
102
|
+
candidates.append(fp)
|
|
103
|
+
except OSError:
|
|
104
|
+
continue
|
|
105
|
+
return candidates
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# 快速模式 6 章必需标题 (与 prd-quick-template.md 对齐)
|
|
109
|
+
QUICK_REQUIRED_SECTIONS = [
|
|
110
|
+
'功能入口', '需求背景', '需求说明', '影响范围', '验收标准', '不在本次范围',
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _check_quick_prd_complete(prd_path):
|
|
115
|
+
"""检测 quick 模式 PRD 是否缺章。返回缺失章节列表 (空=完整)。
|
|
116
|
+
|
|
117
|
+
判定 quick 模式: @contract 头含 mode: quick。
|
|
118
|
+
必需 6 章: 功能入口/需求背景/需求说明/影响范围/验收标准/不在本次范围。
|
|
119
|
+
治"残废品" (真实案例: 海外考勤 PRD 只写"功能入口"一段, 6 章缺 5 章)。
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
text = open(prd_path, encoding='utf-8', errors='replace').read()
|
|
123
|
+
except Exception:
|
|
124
|
+
return [] # 读不到不拦 (误拦比放过更糟)
|
|
125
|
+
# 只检 quick 模式 (完整档 13 章另算, 不在此 hook 范围)
|
|
126
|
+
if 'mode: quick' not in text[:500]:
|
|
127
|
+
return []
|
|
128
|
+
missing = []
|
|
129
|
+
for sec in QUICK_REQUIRED_SECTIONS:
|
|
130
|
+
# 章节标题: ## 功能入口 / ## 需求背景 等 (允许前后空格)
|
|
131
|
+
import re
|
|
132
|
+
if not re.search(r'^#{1,3}\s*' + sec, text, re.MULTILINE):
|
|
133
|
+
missing.append(sec)
|
|
134
|
+
return missing
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _evald_prds():
|
|
138
|
+
"""读 eval-history.jsonl, 返回【已通过门禁】的 prd 文件名集合。
|
|
139
|
+
|
|
140
|
+
门禁定义 (与消息承诺一致): 该 PRD 最近一次 eval 记录 passed=True 或 score_pct>=80。
|
|
141
|
+
只"跑过 eval 但没过线"的不算通过 → 仍需拦回去重测。
|
|
142
|
+
eval-history.jsonl 是 append-only, 同一 PRD 可能有多条 → 取最后一条为准。
|
|
143
|
+
"""
|
|
144
|
+
last = _last_record_by_prd()
|
|
145
|
+
done = set()
|
|
146
|
+
for key, r in last.items():
|
|
147
|
+
passed = r.get('passed')
|
|
148
|
+
score = r.get('score_pct')
|
|
149
|
+
is_ok = (passed is True) or (isinstance(score, (int, float)) and score >= 80)
|
|
150
|
+
if is_ok:
|
|
151
|
+
done.add(key)
|
|
152
|
+
return done
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _last_record_by_prd():
|
|
156
|
+
"""读 eval-history.jsonl, 返回 {prd_basename: 最后一条记录}。
|
|
157
|
+
|
|
158
|
+
append-only 文件, 后出现的记录覆盖前面的 → 取每个 PRD 的最新 eval。
|
|
159
|
+
被门禁诊断复用 (区分"没跑过 eval" vs "跑了但没过线")。
|
|
160
|
+
"""
|
|
161
|
+
last_by_prd = {}
|
|
162
|
+
if not os.path.isfile(EVAL_HISTORY):
|
|
163
|
+
return last_by_prd
|
|
164
|
+
try:
|
|
165
|
+
with open(EVAL_HISTORY, encoding='utf-8', errors='replace') as f:
|
|
166
|
+
for line in f:
|
|
167
|
+
line = line.strip()
|
|
168
|
+
if not line:
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
r = json.loads(line)
|
|
172
|
+
p = r.get('prd')
|
|
173
|
+
if p:
|
|
174
|
+
last_by_prd[os.path.basename(str(p))] = r
|
|
175
|
+
except Exception:
|
|
176
|
+
continue
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
return last_by_prd
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main():
|
|
183
|
+
try:
|
|
184
|
+
data = _read_stdin()
|
|
185
|
+
# 防递归: 二次进入直接放行
|
|
186
|
+
if _is_reentry(data):
|
|
187
|
+
sys.exit(0)
|
|
188
|
+
|
|
189
|
+
# 找近 10 分钟产出的 PRD, 检查是否已【通过】门禁
|
|
190
|
+
recent = _recent_prd_files()
|
|
191
|
+
if not recent:
|
|
192
|
+
sys.exit(0) # 本轮没产 PRD, 放行
|
|
193
|
+
|
|
194
|
+
# ── 先查 quick 模式 PRD 缺章 (残废品门禁, 比 eval 更优先) ──
|
|
195
|
+
incomplete = []
|
|
196
|
+
for p in recent:
|
|
197
|
+
miss = _check_quick_prd_complete(p)
|
|
198
|
+
if miss:
|
|
199
|
+
incomplete.append((p, miss))
|
|
200
|
+
if incomplete:
|
|
201
|
+
lines = []
|
|
202
|
+
for p, miss in incomplete[:3]:
|
|
203
|
+
lines.append(' %s 缺: %s' % (os.path.basename(p), '、'.join(miss)))
|
|
204
|
+
sys.stderr.write(
|
|
205
|
+
'【PRD 残废品门禁】检测到快速模式 PRD 缺章 (6 章缺一不可):\n'
|
|
206
|
+
+ '\n'.join(lines) + '\n'
|
|
207
|
+
'修复: 补齐缺失章节的标题和内容 (不涉及的写一句话说明, 标题必须保留),\n'
|
|
208
|
+
' 章节顺序: 功能入口/需求背景/需求说明/影响范围/验收标准/不在本次范围。\n'
|
|
209
|
+
' 模板: .qoder/templates/prd-quick-template.md\n')
|
|
210
|
+
sys.exit(2)
|
|
211
|
+
evald = _evald_prds()
|
|
212
|
+
last_records = _last_record_by_prd()
|
|
213
|
+
# 区分两种未过门禁: 从没跑过 eval / 跑了但没过线
|
|
214
|
+
not_run = [p for p in recent if os.path.basename(p) not in last_records]
|
|
215
|
+
failed = [p for p in recent
|
|
216
|
+
if os.path.basename(p) in last_records
|
|
217
|
+
and os.path.basename(p) not in evald]
|
|
218
|
+
if not not_run and not failed:
|
|
219
|
+
sys.exit(0) # 全部通过门禁, 放行
|
|
220
|
+
|
|
221
|
+
# 有未过门禁的近期 PRD → 阻断, 给出可操作指引
|
|
222
|
+
parts = []
|
|
223
|
+
if not_run:
|
|
224
|
+
parts.append('未跑评测: ' + ', '.join(os.path.basename(p) for p in not_run[:3]))
|
|
225
|
+
if failed:
|
|
226
|
+
details = []
|
|
227
|
+
for p in failed[:3]:
|
|
228
|
+
rec = last_records.get(os.path.basename(p), {})
|
|
229
|
+
s = rec.get('score_pct')
|
|
230
|
+
details.append('%s (最近 %s)' % (os.path.basename(p),
|
|
231
|
+
('%s%%' % s) if s is not None else '未过'))
|
|
232
|
+
parts.append('评测未过线: ' + ', '.join(details))
|
|
233
|
+
sys.stderr.write(
|
|
234
|
+
'【EVA 门禁】检测到近期产出的 PRD 未通过质量门禁 (要求 eval ≥80%%):\n'
|
|
235
|
+
' %s\n'
|
|
236
|
+
'修复: python .qoder/scripts/orchestration/wlkj.py eval <PRD> [原型]\n'
|
|
237
|
+
' 针对评测报告的扣分项改 PRD, 重跑 eval 到 ≥80%% 再结束。\n'
|
|
238
|
+
% '; '.join(parts))
|
|
239
|
+
sys.exit(2)
|
|
240
|
+
except SystemExit:
|
|
241
|
+
raise
|
|
242
|
+
except Exception as e:
|
|
243
|
+
try:
|
|
244
|
+
os.makedirs(os.path.dirname(HOOK_ERRORS_LOG), exist_ok=True)
|
|
245
|
+
with open(HOOK_ERRORS_LOG, 'a', encoding='utf-8') as f:
|
|
246
|
+
f.write('[{}] stop-eval: {}\n'.format(
|
|
247
|
+
datetime.now().strftime('%Y-%m-%d %H:%M:%S'), str(e)[:200]))
|
|
248
|
+
except Exception:
|
|
249
|
+
pass
|
|
250
|
+
sys.exit(0) # 铁律: hook 崩溃绝不阻塞
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == '__main__':
|
|
254
|
+
main()
|