@hupan56/wlkj 3.3.0 → 3.3.2
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/package.json +29 -29
- package/templates/.qoder/.runtime/hook-errors.log +4 -0
- package/templates/qoder/commands/optional/wl-status.md +2 -0
- package/templates/qoder/commands/wl-code.md +45 -10
- package/templates/qoder/commands/wl-init.md +129 -129
- package/templates/qoder/commands/wl-prd.md +27 -12
- package/templates/qoder/commands/wl-search.md +32 -0
- package/templates/qoder/commands/wl-task.md +8 -4
- package/templates/qoder/commands/wl-test.md +4 -0
- package/templates/qoder/hooks/post-tool-use.py +31 -1
- package/templates/qoder/hooks/pre-tool-use.py +136 -0
- package/templates/qoder/hooks/session-start.py +365 -365
- package/templates/qoder/scripts/capability/__pycache__/present_html.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/__pycache__/registry.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/__init__.py +4 -4
- package/templates/qoder/scripts/capability/caps/__pycache__/__init__.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/__pycache__/notify.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/__pycache__/sandbox.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/notify.py +64 -0
- package/templates/qoder/scripts/capability/caps/sandbox.py +38 -0
- package/templates/qoder/scripts/capability/present_html.py +68 -0
- package/templates/qoder/scripts/capability/registry.py +6 -2
- package/templates/qoder/scripts/capability/registry_mcp.py +312 -314
- package/templates/qoder/scripts/domain/integration/__pycache__/return_to_platform.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/domain/integration/return_to_platform.py +394 -392
- package/templates/qoder/scripts/domain/integration/spec_upload.py +208 -209
- package/templates/qoder/scripts/domain/kg/switch_project.py +158 -159
- package/templates/qoder/scripts/engine/poller.py +219 -219
- package/templates/qoder/scripts/validation/metrics/__pycache__/present_board.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/validation/metrics/present_board.py +180 -0
- package/templates/qoder/settings.json +10 -0
- package/templates/qoder/scripts/domain/kg/extract/extract.py.bak +0 -430
|
@@ -1,365 +1,365 @@
|
|
|
1
|
-
# session-start.py - Inject project context on session start
|
|
2
|
-
import os, json, sys
|
|
3
|
-
|
|
4
|
-
if sys.platform == 'win32':
|
|
5
|
-
try:
|
|
6
|
-
sys.stdout.reconfigure(encoding='utf-8')
|
|
7
|
-
except Exception:
|
|
8
|
-
pass
|
|
9
|
-
|
|
10
|
-
NL = chr(10)
|
|
11
|
-
BASE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
12
|
-
|
|
13
|
-
HOOK_ERRORS_LOG = os.path.join(BASE, '.qoder', '.runtime', 'hook-errors.log')
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def _append_hook_error(log_path, line):
|
|
17
|
-
"""写一行错误到 hook-errors.log, 超 100KB 截断保留最后 50KB (防无限膨胀)。best-effort。"""
|
|
18
|
-
from datetime import datetime
|
|
19
|
-
try:
|
|
20
|
-
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
|
21
|
-
if os.path.isfile(log_path):
|
|
22
|
-
try:
|
|
23
|
-
if os.path.getsize(log_path) > 100 * 1024:
|
|
24
|
-
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
25
|
-
tail = f.read()[-50 * 1024:]
|
|
26
|
-
with open(log_path, 'w', encoding='utf-8') as f:
|
|
27
|
-
f.write(tail)
|
|
28
|
-
except OSError:
|
|
29
|
-
pass
|
|
30
|
-
with open(log_path, 'a', encoding='utf-8') as f:
|
|
31
|
-
f.write('[{}] session-start: {}\n'.format(
|
|
32
|
-
datetime.now().strftime('%Y-%m-%d %H:%M:%S'), str(line)[:200]))
|
|
33
|
-
except Exception:
|
|
34
|
-
pass
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _recent_hook_errors(hours=24, limit=3):
|
|
38
|
-
"""读最近 N 小时的 hook 错误, 返回最多 limit 条 (供会话注入告警)。
|
|
39
|
-
|
|
40
|
-
错误之前只落盘到 .runtime/hook-errors.log, PM 永远看不到 → hook 坏了无感知。
|
|
41
|
-
改后每次开会话把最近错误摘要注入上下文。
|
|
42
|
-
"""
|
|
43
|
-
try:
|
|
44
|
-
from datetime import datetime, timedelta
|
|
45
|
-
if not os.path.isfile(HOOK_ERRORS_LOG):
|
|
46
|
-
return []
|
|
47
|
-
cutoff = datetime.now() - timedelta(hours=hours)
|
|
48
|
-
recent = []
|
|
49
|
-
with open(HOOK_ERRORS_LOG, encoding='utf-8', errors='replace') as f:
|
|
50
|
-
for line in f:
|
|
51
|
-
line = line.strip()
|
|
52
|
-
if not line or '[' not in line or ']' not in line:
|
|
53
|
-
continue
|
|
54
|
-
try:
|
|
55
|
-
ts = line.split(']')[0].lstrip('[')
|
|
56
|
-
dt = datetime.strptime(ts, '%Y-%m-%d %H:%M:%S')
|
|
57
|
-
if dt >= cutoff:
|
|
58
|
-
recent.append(line)
|
|
59
|
-
except (ValueError, IndexError):
|
|
60
|
-
continue
|
|
61
|
-
return recent[-limit:] if recent else []
|
|
62
|
-
except Exception:
|
|
63
|
-
return []
|
|
64
|
-
|
|
65
|
-
# v3.0 M4: 删 CRITICAL_RULE (会话开始主动推销工作流铁律 → 非侵入式改造)
|
|
66
|
-
# 平台询问逻辑保留在 wl-pipeline.md 规则1 (命令启动后才生效, 不在会话开头强推)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def load_json_safe(path):
|
|
70
|
-
try:
|
|
71
|
-
with open(path, encoding='utf-8') as f:
|
|
72
|
-
return json.load(f)
|
|
73
|
-
except Exception:
|
|
74
|
-
return None
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def get_developer():
|
|
78
|
-
df = os.path.join(BASE, '.qoder', '.developer')
|
|
79
|
-
if not os.path.isfile(df):
|
|
80
|
-
return None
|
|
81
|
-
try:
|
|
82
|
-
with open(df, encoding='utf-8') as f:
|
|
83
|
-
content = f.read()
|
|
84
|
-
except Exception:
|
|
85
|
-
return None
|
|
86
|
-
info = {}
|
|
87
|
-
for line in content.strip().splitlines():
|
|
88
|
-
for sep in ('=', ':'):
|
|
89
|
-
if sep in line:
|
|
90
|
-
k, v = line.split(sep, 1)
|
|
91
|
-
info[k.strip()] = v.strip()
|
|
92
|
-
break
|
|
93
|
-
return info or None
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def get_active_tasks():
|
|
97
|
-
td = os.path.join(BASE, 'workspace', 'tasks')
|
|
98
|
-
if not os.path.isdir(td):
|
|
99
|
-
return []
|
|
100
|
-
tasks = []
|
|
101
|
-
for d in sorted(os.listdir(td)):
|
|
102
|
-
data = load_json_safe(os.path.join(td, d, 'task.json'))
|
|
103
|
-
if data is None:
|
|
104
|
-
continue
|
|
105
|
-
tasks.append({
|
|
106
|
-
'name': d,
|
|
107
|
-
'title': data.get('title', d),
|
|
108
|
-
'status': data.get('status', 'unknown')
|
|
109
|
-
})
|
|
110
|
-
return tasks
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def get_team_size():
|
|
114
|
-
md = os.path.join(BASE, 'workspace', 'members')
|
|
115
|
-
if not os.path.isdir(md):
|
|
116
|
-
return 0
|
|
117
|
-
return len([d for d in os.listdir(md) if os.path.isdir(os.path.join(md, d))])
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def get_index_info():
|
|
121
|
-
lines = []
|
|
122
|
-
# Hook-3: 检查 hook 错误日志, 超过 3 条新错误时告警
|
|
123
|
-
try:
|
|
124
|
-
log_path = os.path.join(BASE, '.qoder', '.runtime', 'hook-errors.log')
|
|
125
|
-
if os.path.isfile(log_path):
|
|
126
|
-
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
127
|
-
errors = [l for l in f.readlines() if l.strip()]
|
|
128
|
-
# 只看最近 24h 的错误
|
|
129
|
-
recent = errors[-10:] # 取最后 10 行检查
|
|
130
|
-
if len(recent) >= 3:
|
|
131
|
-
lines.append('[WARN] hook 最近频繁失败 (%d 条错误), 详情见 .qoder/.runtime/hook-errors.log' % len(recent))
|
|
132
|
-
lines.append(' 跑 python .qoder/scripts/setup/init_doctor.py --fix 排查')
|
|
133
|
-
except OSError:
|
|
134
|
-
pass
|
|
135
|
-
|
|
136
|
-
lines.append('## Knowledge Graph')
|
|
137
|
-
lines.append('MCP工具 (Qoder/QoderWork自动调用): search_code / rag_search / get_impact / project_brief / graphrag_ask 等43个')
|
|
138
|
-
lines.append(' python .qoder/scripts/orchestration/wlkj.py search <keyword> [--platform web|app]')
|
|
139
|
-
lines.append(' python .qoder/scripts/orchestration/wlkj.py context <keyword> # 一次打包全部上下文')
|
|
140
|
-
|
|
141
|
-
# DuckDB 知识图谱状态检查
|
|
142
|
-
# Read role from .developer file
|
|
143
|
-
role = 'pm'
|
|
144
|
-
try:
|
|
145
|
-
dev_file = os.path.join(BASE, '.qoder', '.developer')
|
|
146
|
-
if os.path.isfile(dev_file):
|
|
147
|
-
for _line in open(dev_file, encoding='utf-8'):
|
|
148
|
-
if _line.strip().startswith('role='):
|
|
149
|
-
role = _line.split('=', 1)[1].strip()
|
|
150
|
-
except Exception:
|
|
151
|
-
pass
|
|
152
|
-
# ★ 知识层健康检查:全迁 PG 后不再检查本地 kg.duckdb(已删除),改探活 MCP 知识层。
|
|
153
|
-
# 复用
|
|
154
|
-
# best-effort:任何失败都不阻塞会话启动(与其它 hook 铁律一致)。
|
|
155
|
-
kg_info = None
|
|
156
|
-
try:
|
|
157
|
-
import json as _json
|
|
158
|
-
import urllib.request as _url
|
|
159
|
-
cfg_path = os.path.join(BASE, '
|
|
160
|
-
if os.path.isfile(cfg_path):
|
|
161
|
-
_cfg = _json.load(open(cfg_path, encoding='utf-8'))
|
|
162
|
-
_srv = _cfg.get('mcpServers', {}).get('wlinkj-knowledge', {})
|
|
163
|
-
_url_s = _srv.get('url', '')
|
|
164
|
-
_tok = _srv.get('token', '')
|
|
165
|
-
if _url_s and _tok and _url_s != 'WILL_BE_FILLED_BY_WL_INIT':
|
|
166
|
-
_rpc = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
167
|
-
"params": {"name": "get_knowledge_health", "arguments": {}}}
|
|
168
|
-
_req = _url.Request(_url_s, data=_json.dumps(_rpc).encode(),
|
|
169
|
-
headers={"Content-Type": "application/json",
|
|
170
|
-
"X-MCP-Token": _tok}, method="POST")
|
|
171
|
-
_resp = _url.urlopen(_req, timeout=4).read()
|
|
172
|
-
kg_info = _json.loads(_json.loads(_resp)["result"]["content"][0]["text"])
|
|
173
|
-
except Exception:
|
|
174
|
-
kg_info = None
|
|
175
|
-
|
|
176
|
-
if kg_info and isinstance(kg_info, dict) and "error" not in kg_info:
|
|
177
|
-
lines.append(' ✓ 知识层在线 (PG@5432): 健康分 %s / 覆盖 %s / 新鲜 %s / 完整 %s' % (
|
|
178
|
-
kg_info.get("score", "?"), kg_info.get("coverage", "?"),
|
|
179
|
-
kg_info.get("freshness", "?"), kg_info.get("completeness", "?")))
|
|
180
|
-
issues = kg_info.get("issues", []) or []
|
|
181
|
-
if issues and role in ('admin', 'dev'):
|
|
182
|
-
lines.append(' [提示] 健康问题: ' + '; '.join(str(i)[:60] for i in issues[:2]))
|
|
183
|
-
else:
|
|
184
|
-
lines.append(' [WARN] 知识层未响应(MCP 后端未启动?)')
|
|
185
|
-
lines.append(' → 启动: cd wlinkj-workspace && start_pg.bat(后端 :10010)')
|
|
186
|
-
|
|
187
|
-
return NL.join(lines)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def get_style_info():
|
|
191
|
-
"""Read style metadata for quick reference"""
|
|
192
|
-
meta = load_json_safe(os.path.join(BASE, 'data', 'index', 'ui-style-meta.json'))
|
|
193
|
-
if not meta:
|
|
194
|
-
return None
|
|
195
|
-
|
|
196
|
-
lines = []
|
|
197
|
-
lines.append('## UI Style Index')
|
|
198
|
-
lines.append('Style search: python .qoder/scripts/orchestration/wlkj.py style --style <table|form|modal>')
|
|
199
|
-
lines.append('Field search: python .qoder/scripts/orchestration/wlkj.py search --field <name>')
|
|
200
|
-
|
|
201
|
-
for proj, d in meta.get('projects', {}).items():
|
|
202
|
-
vue = d.get('vue_files', 0)
|
|
203
|
-
if vue > 0:
|
|
204
|
-
pt = d.get('page_types', {})
|
|
205
|
-
types_str = ', '.join([k + ':' + str(v) for k, v in pt.items() if v > 0])
|
|
206
|
-
lines.append(' ' + proj + ': ' + str(vue) + ' Vue (' + types_str + ')')
|
|
207
|
-
|
|
208
|
-
lines.append('Top components: ' + ', '.join(list(meta.get('common_components', {}).keys())[:8]))
|
|
209
|
-
lines.append('Field map: ' + str(meta.get('field_count', 0)) + ' entries')
|
|
210
|
-
|
|
211
|
-
if os.path.isdir(os.path.join(BASE, 'data', 'style')):
|
|
212
|
-
lines.append('Style PDFs: data/style/ (design specs, code takes priority)')
|
|
213
|
-
|
|
214
|
-
return NL.join(lines)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def get_usability_summary():
|
|
218
|
-
"""读工作流可用性指标, 把"不达标"项注入会话上下文。
|
|
219
|
-
|
|
220
|
-
吸收宿主 SessionStart hook 能力: 不用等用户跑 /wl-status, 每次开会话主动
|
|
221
|
-
暴露"工作流用没用起来"的红灯项。只展示红/黄 (绿的不打扰), 无数据则不显示。
|
|
222
|
-
(与 usability_score.py 同源逻辑, 但 hook 内联精简版, 避免 import 验证脚本拖慢启动)
|
|
223
|
-
"""
|
|
224
|
-
lines = []
|
|
225
|
-
try:
|
|
226
|
-
# U2 一次过审率 (从 eval-history 取首条)
|
|
227
|
-
eval_path = os.path.join(BASE, 'data', 'learning', 'eval-history.jsonl')
|
|
228
|
-
records = []
|
|
229
|
-
if os.path.isfile(eval_path):
|
|
230
|
-
with open(eval_path, encoding='utf-8', errors='replace') as f:
|
|
231
|
-
for ln in f:
|
|
232
|
-
ln = ln.strip()
|
|
233
|
-
if ln:
|
|
234
|
-
try:
|
|
235
|
-
records.append(json.loads(ln))
|
|
236
|
-
except Exception:
|
|
237
|
-
continue
|
|
238
|
-
# 取最近 7 天
|
|
239
|
-
from datetime import datetime, timedelta
|
|
240
|
-
cutoff = datetime.now() - timedelta(days=7)
|
|
241
|
-
recent = []
|
|
242
|
-
for r in records:
|
|
243
|
-
t = r.get('time', '')
|
|
244
|
-
try:
|
|
245
|
-
if datetime.fromisoformat(str(t).split('.')[0]) >= cutoff:
|
|
246
|
-
recent.append(r)
|
|
247
|
-
except Exception:
|
|
248
|
-
continue
|
|
249
|
-
|
|
250
|
-
issues = [] # 只收红/黄
|
|
251
|
-
if recent:
|
|
252
|
-
passed = sum(1 for r in recent if r.get('passed'))
|
|
253
|
-
rate = 100.0 * passed / len(recent)
|
|
254
|
-
if rate < 60: # 黄线 30, 红线 60
|
|
255
|
-
tag = '🔴' if rate < 30 else '🟡'
|
|
256
|
-
issues.append('%s 一次过审率 %.0f%% (<60%% 目标) — PRD 质量需提升' % (tag, rate))
|
|
257
|
-
|
|
258
|
-
# U4 规则违反率 (从 feedback)
|
|
259
|
-
fb_dir = os.path.join(BASE, 'workspace', 'members')
|
|
260
|
-
violations = 0
|
|
261
|
-
prds_gen = 0
|
|
262
|
-
if os.path.isdir(fb_dir):
|
|
263
|
-
for dev in os.listdir(fb_dir):
|
|
264
|
-
fb = os.path.join(fb_dir, dev, 'journal', 'feedback.jsonl')
|
|
265
|
-
if not os.path.isfile(fb):
|
|
266
|
-
continue
|
|
267
|
-
try:
|
|
268
|
-
with open(fb, encoding='utf-8', errors='replace') as f:
|
|
269
|
-
for ln in f:
|
|
270
|
-
if '"rule_violation"' in ln:
|
|
271
|
-
violations += 1
|
|
272
|
-
elif '"prd_accepted"' in ln:
|
|
273
|
-
prds_gen += 1
|
|
274
|
-
except Exception:
|
|
275
|
-
continue
|
|
276
|
-
if prds_gen and violations:
|
|
277
|
-
vrate = 100.0 * violations / prds_gen
|
|
278
|
-
if vrate > 10:
|
|
279
|
-
tag = '🔴' if vrate > 25 else '🟡'
|
|
280
|
-
issues.append('%s 规则违反率 %.0f%% (>10%% 目标) — 关键规则没拦住' % (tag, vrate))
|
|
281
|
-
|
|
282
|
-
if issues:
|
|
283
|
-
lines.append('## Workflow Usability (近7天)')
|
|
284
|
-
lines.append('工作流"用不用得起来"红灯 (详情: wlkj.py usability):')
|
|
285
|
-
for it in issues:
|
|
286
|
-
lines.append(' ' + it)
|
|
287
|
-
except Exception:
|
|
288
|
-
pass # hook 失败绝不阻塞会话
|
|
289
|
-
return NL.join(lines) if lines else ''
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
def main():
|
|
293
|
-
parts = []
|
|
294
|
-
parts.append('<qoder-context>')
|
|
295
|
-
# Hook-3: 上次会话以来的 hook 错误告警 (不只落盘, 要让用户/AI 看到)
|
|
296
|
-
# hook 坏了会导致上下文注入失败 (任务状态/平台规则缺失), 必须可见
|
|
297
|
-
recent_errors = _recent_hook_errors(hours=24, limit=3)
|
|
298
|
-
if recent_errors:
|
|
299
|
-
parts.append('⚠️ [HOOK-WARN] 过去 24h 有 hook 错误 (上下文注入可能失效):')
|
|
300
|
-
for err in recent_errors:
|
|
301
|
-
parts.append(' ' + err)
|
|
302
|
-
parts.append(' 详情见 .qoder/.runtime/hook-errors.log; 检查 Python 环境或跑 init_doctor --fix')
|
|
303
|
-
dev = get_developer()
|
|
304
|
-
if dev:
|
|
305
|
-
name = dev.get('name', 'unknown')
|
|
306
|
-
# role 真源在 member.json (.developer 常无 role= 行 → 显示 unknown)。
|
|
307
|
-
# 回退查 member.json, 再回退 'pm' (绝不显示 unknown, 误导 AI)。
|
|
308
|
-
role = dev.get('role')
|
|
309
|
-
if not role or role == 'unknown':
|
|
310
|
-
try:
|
|
311
|
-
mj = os.path.join(BASE, 'workspace', 'members', name, 'member.json')
|
|
312
|
-
if os.path.isfile(mj):
|
|
313
|
-
m = json.load(open(mj, encoding='utf-8'))
|
|
314
|
-
role = m.get('role') or 'pm'
|
|
315
|
-
except Exception:
|
|
316
|
-
pass
|
|
317
|
-
role = role or 'pm'
|
|
318
|
-
parts.append('Developer: ' + name + ' (' + role + ')')
|
|
319
|
-
parts.append('Workspace: workspace/members/' + name + '/')
|
|
320
|
-
else:
|
|
321
|
-
parts.append('Developer: NOT SET. Run /wl-init to register.')
|
|
322
|
-
team = get_team_size()
|
|
323
|
-
if team > 0:
|
|
324
|
-
parts.append('Team members: ' + str(team))
|
|
325
|
-
tasks = get_active_tasks()
|
|
326
|
-
if tasks:
|
|
327
|
-
parts.append('Active tasks:')
|
|
328
|
-
for t in tasks:
|
|
329
|
-
parts.append(' [' + t['status'] + '] ' + t['name'] + ' - ' + t['title'])
|
|
330
|
-
|
|
331
|
-
usability = get_usability_summary()
|
|
332
|
-
if usability:
|
|
333
|
-
parts.append('')
|
|
334
|
-
parts.append(usability)
|
|
335
|
-
|
|
336
|
-
parts.append('')
|
|
337
|
-
|
|
338
|
-
idx_info = get_index_info()
|
|
339
|
-
if idx_info:
|
|
340
|
-
parts.append('')
|
|
341
|
-
parts.append(idx_info)
|
|
342
|
-
|
|
343
|
-
style_info = get_style_info()
|
|
344
|
-
if style_info:
|
|
345
|
-
parts.append('')
|
|
346
|
-
parts.append(style_info)
|
|
347
|
-
|
|
348
|
-
parts.append('</qoder-context>')
|
|
349
|
-
# Hook-1: 健康标记 — AI 能感知 hook 是否真的跑了
|
|
350
|
-
# QoderWork/Quest 里看不到 [hook-ok] 就知道 hook 没生效, 要自取上下文
|
|
351
|
-
parts.append('<!-- [hook-ok] session-start hook ran successfully -->')
|
|
352
|
-
print(NL.join(parts))
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if __name__ == '__main__':
|
|
356
|
-
try:
|
|
357
|
-
main()
|
|
358
|
-
except Exception as e:
|
|
359
|
-
# A broken hook must never kill the session - emit minimal context
|
|
360
|
-
# 同时落盘到日志 (带轮转), 方便下次会话告警
|
|
361
|
-
_append_hook_error(HOOK_ERRORS_LOG, str(e)[:200])
|
|
362
|
-
print('<qoder-context>')
|
|
363
|
-
print('session-start hook error: ' + str(e)[:200])
|
|
364
|
-
print(' (详情见 .qoder/.runtime/hook-errors.log)')
|
|
365
|
-
print('</qoder-context>')
|
|
1
|
+
# session-start.py - Inject project context on session start
|
|
2
|
+
import os, json, sys
|
|
3
|
+
|
|
4
|
+
if sys.platform == 'win32':
|
|
5
|
+
try:
|
|
6
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
7
|
+
except Exception:
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
NL = chr(10)
|
|
11
|
+
BASE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
12
|
+
|
|
13
|
+
HOOK_ERRORS_LOG = os.path.join(BASE, '.qoder', '.runtime', 'hook-errors.log')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _append_hook_error(log_path, line):
|
|
17
|
+
"""写一行错误到 hook-errors.log, 超 100KB 截断保留最后 50KB (防无限膨胀)。best-effort。"""
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
try:
|
|
20
|
+
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
|
21
|
+
if os.path.isfile(log_path):
|
|
22
|
+
try:
|
|
23
|
+
if os.path.getsize(log_path) > 100 * 1024:
|
|
24
|
+
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
25
|
+
tail = f.read()[-50 * 1024:]
|
|
26
|
+
with open(log_path, 'w', encoding='utf-8') as f:
|
|
27
|
+
f.write(tail)
|
|
28
|
+
except OSError:
|
|
29
|
+
pass
|
|
30
|
+
with open(log_path, 'a', encoding='utf-8') as f:
|
|
31
|
+
f.write('[{}] session-start: {}\n'.format(
|
|
32
|
+
datetime.now().strftime('%Y-%m-%d %H:%M:%S'), str(line)[:200]))
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _recent_hook_errors(hours=24, limit=3):
|
|
38
|
+
"""读最近 N 小时的 hook 错误, 返回最多 limit 条 (供会话注入告警)。
|
|
39
|
+
|
|
40
|
+
错误之前只落盘到 .runtime/hook-errors.log, PM 永远看不到 → hook 坏了无感知。
|
|
41
|
+
改后每次开会话把最近错误摘要注入上下文。
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
from datetime import datetime, timedelta
|
|
45
|
+
if not os.path.isfile(HOOK_ERRORS_LOG):
|
|
46
|
+
return []
|
|
47
|
+
cutoff = datetime.now() - timedelta(hours=hours)
|
|
48
|
+
recent = []
|
|
49
|
+
with open(HOOK_ERRORS_LOG, encoding='utf-8', errors='replace') as f:
|
|
50
|
+
for line in f:
|
|
51
|
+
line = line.strip()
|
|
52
|
+
if not line or '[' not in line or ']' not in line:
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
ts = line.split(']')[0].lstrip('[')
|
|
56
|
+
dt = datetime.strptime(ts, '%Y-%m-%d %H:%M:%S')
|
|
57
|
+
if dt >= cutoff:
|
|
58
|
+
recent.append(line)
|
|
59
|
+
except (ValueError, IndexError):
|
|
60
|
+
continue
|
|
61
|
+
return recent[-limit:] if recent else []
|
|
62
|
+
except Exception:
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
# v3.0 M4: 删 CRITICAL_RULE (会话开始主动推销工作流铁律 → 非侵入式改造)
|
|
66
|
+
# 平台询问逻辑保留在 wl-pipeline.md 规则1 (命令启动后才生效, 不在会话开头强推)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_json_safe(path):
|
|
70
|
+
try:
|
|
71
|
+
with open(path, encoding='utf-8') as f:
|
|
72
|
+
return json.load(f)
|
|
73
|
+
except Exception:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_developer():
|
|
78
|
+
df = os.path.join(BASE, '.qoder', '.developer')
|
|
79
|
+
if not os.path.isfile(df):
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
with open(df, encoding='utf-8') as f:
|
|
83
|
+
content = f.read()
|
|
84
|
+
except Exception:
|
|
85
|
+
return None
|
|
86
|
+
info = {}
|
|
87
|
+
for line in content.strip().splitlines():
|
|
88
|
+
for sep in ('=', ':'):
|
|
89
|
+
if sep in line:
|
|
90
|
+
k, v = line.split(sep, 1)
|
|
91
|
+
info[k.strip()] = v.strip()
|
|
92
|
+
break
|
|
93
|
+
return info or None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_active_tasks():
|
|
97
|
+
td = os.path.join(BASE, 'workspace', 'tasks')
|
|
98
|
+
if not os.path.isdir(td):
|
|
99
|
+
return []
|
|
100
|
+
tasks = []
|
|
101
|
+
for d in sorted(os.listdir(td)):
|
|
102
|
+
data = load_json_safe(os.path.join(td, d, 'task.json'))
|
|
103
|
+
if data is None:
|
|
104
|
+
continue
|
|
105
|
+
tasks.append({
|
|
106
|
+
'name': d,
|
|
107
|
+
'title': data.get('title', d),
|
|
108
|
+
'status': data.get('status', 'unknown')
|
|
109
|
+
})
|
|
110
|
+
return tasks
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_team_size():
|
|
114
|
+
md = os.path.join(BASE, 'workspace', 'members')
|
|
115
|
+
if not os.path.isdir(md):
|
|
116
|
+
return 0
|
|
117
|
+
return len([d for d in os.listdir(md) if os.path.isdir(os.path.join(md, d))])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_index_info():
|
|
121
|
+
lines = []
|
|
122
|
+
# Hook-3: 检查 hook 错误日志, 超过 3 条新错误时告警
|
|
123
|
+
try:
|
|
124
|
+
log_path = os.path.join(BASE, '.qoder', '.runtime', 'hook-errors.log')
|
|
125
|
+
if os.path.isfile(log_path):
|
|
126
|
+
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
127
|
+
errors = [l for l in f.readlines() if l.strip()]
|
|
128
|
+
# 只看最近 24h 的错误
|
|
129
|
+
recent = errors[-10:] # 取最后 10 行检查
|
|
130
|
+
if len(recent) >= 3:
|
|
131
|
+
lines.append('[WARN] hook 最近频繁失败 (%d 条错误), 详情见 .qoder/.runtime/hook-errors.log' % len(recent))
|
|
132
|
+
lines.append(' 跑 python .qoder/scripts/setup/init_doctor.py --fix 排查')
|
|
133
|
+
except OSError:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
lines.append('## Knowledge Graph')
|
|
137
|
+
lines.append('MCP工具 (Qoder/QoderWork自动调用): search_code / rag_search / get_impact / project_brief / graphrag_ask 等43个')
|
|
138
|
+
lines.append(' python .qoder/scripts/orchestration/wlkj.py search <keyword> [--platform web|app]')
|
|
139
|
+
lines.append(' python .qoder/scripts/orchestration/wlkj.py context <keyword> # 一次打包全部上下文')
|
|
140
|
+
|
|
141
|
+
# DuckDB 知识图谱状态检查
|
|
142
|
+
# Read role from .developer file
|
|
143
|
+
role = 'pm'
|
|
144
|
+
try:
|
|
145
|
+
dev_file = os.path.join(BASE, '.qoder', '.developer')
|
|
146
|
+
if os.path.isfile(dev_file):
|
|
147
|
+
for _line in open(dev_file, encoding='utf-8'):
|
|
148
|
+
if _line.strip().startswith('role='):
|
|
149
|
+
role = _line.split('=', 1)[1].strip()
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
# ★ 知识层健康检查:全迁 PG 后不再检查本地 kg.duckdb(已删除),改探活 MCP 知识层。
|
|
153
|
+
# 复用 .qoder/mcp_config.json 的 token+url,调 get_knowledge_health 拿真实健康分。
|
|
154
|
+
# best-effort:任何失败都不阻塞会话启动(与其它 hook 铁律一致)。
|
|
155
|
+
kg_info = None
|
|
156
|
+
try:
|
|
157
|
+
import json as _json
|
|
158
|
+
import urllib.request as _url
|
|
159
|
+
cfg_path = os.path.join(BASE, '.qoder', 'mcp_config.json')
|
|
160
|
+
if os.path.isfile(cfg_path):
|
|
161
|
+
_cfg = _json.load(open(cfg_path, encoding='utf-8'))
|
|
162
|
+
_srv = _cfg.get('mcpServers', {}).get('wlinkj-knowledge', {})
|
|
163
|
+
_url_s = _srv.get('url', '')
|
|
164
|
+
_tok = _srv.get('token', '')
|
|
165
|
+
if _url_s and _tok and _url_s != 'WILL_BE_FILLED_BY_WL_INIT':
|
|
166
|
+
_rpc = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
167
|
+
"params": {"name": "get_knowledge_health", "arguments": {}}}
|
|
168
|
+
_req = _url.Request(_url_s, data=_json.dumps(_rpc).encode(),
|
|
169
|
+
headers={"Content-Type": "application/json",
|
|
170
|
+
"X-MCP-Token": _tok}, method="POST")
|
|
171
|
+
_resp = _url.urlopen(_req, timeout=4).read()
|
|
172
|
+
kg_info = _json.loads(_json.loads(_resp)["result"]["content"][0]["text"])
|
|
173
|
+
except Exception:
|
|
174
|
+
kg_info = None
|
|
175
|
+
|
|
176
|
+
if kg_info and isinstance(kg_info, dict) and "error" not in kg_info:
|
|
177
|
+
lines.append(' ✓ 知识层在线 (PG@5432): 健康分 %s / 覆盖 %s / 新鲜 %s / 完整 %s' % (
|
|
178
|
+
kg_info.get("score", "?"), kg_info.get("coverage", "?"),
|
|
179
|
+
kg_info.get("freshness", "?"), kg_info.get("completeness", "?")))
|
|
180
|
+
issues = kg_info.get("issues", []) or []
|
|
181
|
+
if issues and role in ('admin', 'dev'):
|
|
182
|
+
lines.append(' [提示] 健康问题: ' + '; '.join(str(i)[:60] for i in issues[:2]))
|
|
183
|
+
else:
|
|
184
|
+
lines.append(' [WARN] 知识层未响应(MCP 后端未启动?)')
|
|
185
|
+
lines.append(' → 启动: cd wlinkj-workspace && start_pg.bat(后端 :10010)')
|
|
186
|
+
|
|
187
|
+
return NL.join(lines)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_style_info():
|
|
191
|
+
"""Read style metadata for quick reference"""
|
|
192
|
+
meta = load_json_safe(os.path.join(BASE, 'data', 'index', 'ui-style-meta.json'))
|
|
193
|
+
if not meta:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
lines = []
|
|
197
|
+
lines.append('## UI Style Index')
|
|
198
|
+
lines.append('Style search: python .qoder/scripts/orchestration/wlkj.py style --style <table|form|modal>')
|
|
199
|
+
lines.append('Field search: python .qoder/scripts/orchestration/wlkj.py search --field <name>')
|
|
200
|
+
|
|
201
|
+
for proj, d in meta.get('projects', {}).items():
|
|
202
|
+
vue = d.get('vue_files', 0)
|
|
203
|
+
if vue > 0:
|
|
204
|
+
pt = d.get('page_types', {})
|
|
205
|
+
types_str = ', '.join([k + ':' + str(v) for k, v in pt.items() if v > 0])
|
|
206
|
+
lines.append(' ' + proj + ': ' + str(vue) + ' Vue (' + types_str + ')')
|
|
207
|
+
|
|
208
|
+
lines.append('Top components: ' + ', '.join(list(meta.get('common_components', {}).keys())[:8]))
|
|
209
|
+
lines.append('Field map: ' + str(meta.get('field_count', 0)) + ' entries')
|
|
210
|
+
|
|
211
|
+
if os.path.isdir(os.path.join(BASE, 'data', 'style')):
|
|
212
|
+
lines.append('Style PDFs: data/style/ (design specs, code takes priority)')
|
|
213
|
+
|
|
214
|
+
return NL.join(lines)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_usability_summary():
|
|
218
|
+
"""读工作流可用性指标, 把"不达标"项注入会话上下文。
|
|
219
|
+
|
|
220
|
+
吸收宿主 SessionStart hook 能力: 不用等用户跑 /wl-status, 每次开会话主动
|
|
221
|
+
暴露"工作流用没用起来"的红灯项。只展示红/黄 (绿的不打扰), 无数据则不显示。
|
|
222
|
+
(与 usability_score.py 同源逻辑, 但 hook 内联精简版, 避免 import 验证脚本拖慢启动)
|
|
223
|
+
"""
|
|
224
|
+
lines = []
|
|
225
|
+
try:
|
|
226
|
+
# U2 一次过审率 (从 eval-history 取首条)
|
|
227
|
+
eval_path = os.path.join(BASE, 'data', 'learning', 'eval-history.jsonl')
|
|
228
|
+
records = []
|
|
229
|
+
if os.path.isfile(eval_path):
|
|
230
|
+
with open(eval_path, encoding='utf-8', errors='replace') as f:
|
|
231
|
+
for ln in f:
|
|
232
|
+
ln = ln.strip()
|
|
233
|
+
if ln:
|
|
234
|
+
try:
|
|
235
|
+
records.append(json.loads(ln))
|
|
236
|
+
except Exception:
|
|
237
|
+
continue
|
|
238
|
+
# 取最近 7 天
|
|
239
|
+
from datetime import datetime, timedelta
|
|
240
|
+
cutoff = datetime.now() - timedelta(days=7)
|
|
241
|
+
recent = []
|
|
242
|
+
for r in records:
|
|
243
|
+
t = r.get('time', '')
|
|
244
|
+
try:
|
|
245
|
+
if datetime.fromisoformat(str(t).split('.')[0]) >= cutoff:
|
|
246
|
+
recent.append(r)
|
|
247
|
+
except Exception:
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
issues = [] # 只收红/黄
|
|
251
|
+
if recent:
|
|
252
|
+
passed = sum(1 for r in recent if r.get('passed'))
|
|
253
|
+
rate = 100.0 * passed / len(recent)
|
|
254
|
+
if rate < 60: # 黄线 30, 红线 60
|
|
255
|
+
tag = '🔴' if rate < 30 else '🟡'
|
|
256
|
+
issues.append('%s 一次过审率 %.0f%% (<60%% 目标) — PRD 质量需提升' % (tag, rate))
|
|
257
|
+
|
|
258
|
+
# U4 规则违反率 (从 feedback)
|
|
259
|
+
fb_dir = os.path.join(BASE, 'workspace', 'members')
|
|
260
|
+
violations = 0
|
|
261
|
+
prds_gen = 0
|
|
262
|
+
if os.path.isdir(fb_dir):
|
|
263
|
+
for dev in os.listdir(fb_dir):
|
|
264
|
+
fb = os.path.join(fb_dir, dev, 'journal', 'feedback.jsonl')
|
|
265
|
+
if not os.path.isfile(fb):
|
|
266
|
+
continue
|
|
267
|
+
try:
|
|
268
|
+
with open(fb, encoding='utf-8', errors='replace') as f:
|
|
269
|
+
for ln in f:
|
|
270
|
+
if '"rule_violation"' in ln:
|
|
271
|
+
violations += 1
|
|
272
|
+
elif '"prd_accepted"' in ln:
|
|
273
|
+
prds_gen += 1
|
|
274
|
+
except Exception:
|
|
275
|
+
continue
|
|
276
|
+
if prds_gen and violations:
|
|
277
|
+
vrate = 100.0 * violations / prds_gen
|
|
278
|
+
if vrate > 10:
|
|
279
|
+
tag = '🔴' if vrate > 25 else '🟡'
|
|
280
|
+
issues.append('%s 规则违反率 %.0f%% (>10%% 目标) — 关键规则没拦住' % (tag, vrate))
|
|
281
|
+
|
|
282
|
+
if issues:
|
|
283
|
+
lines.append('## Workflow Usability (近7天)')
|
|
284
|
+
lines.append('工作流"用不用得起来"红灯 (详情: wlkj.py usability):')
|
|
285
|
+
for it in issues:
|
|
286
|
+
lines.append(' ' + it)
|
|
287
|
+
except Exception:
|
|
288
|
+
pass # hook 失败绝不阻塞会话
|
|
289
|
+
return NL.join(lines) if lines else ''
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def main():
|
|
293
|
+
parts = []
|
|
294
|
+
parts.append('<qoder-context>')
|
|
295
|
+
# Hook-3: 上次会话以来的 hook 错误告警 (不只落盘, 要让用户/AI 看到)
|
|
296
|
+
# hook 坏了会导致上下文注入失败 (任务状态/平台规则缺失), 必须可见
|
|
297
|
+
recent_errors = _recent_hook_errors(hours=24, limit=3)
|
|
298
|
+
if recent_errors:
|
|
299
|
+
parts.append('⚠️ [HOOK-WARN] 过去 24h 有 hook 错误 (上下文注入可能失效):')
|
|
300
|
+
for err in recent_errors:
|
|
301
|
+
parts.append(' ' + err)
|
|
302
|
+
parts.append(' 详情见 .qoder/.runtime/hook-errors.log; 检查 Python 环境或跑 init_doctor --fix')
|
|
303
|
+
dev = get_developer()
|
|
304
|
+
if dev:
|
|
305
|
+
name = dev.get('name', 'unknown')
|
|
306
|
+
# role 真源在 member.json (.developer 常无 role= 行 → 显示 unknown)。
|
|
307
|
+
# 回退查 member.json, 再回退 'pm' (绝不显示 unknown, 误导 AI)。
|
|
308
|
+
role = dev.get('role')
|
|
309
|
+
if not role or role == 'unknown':
|
|
310
|
+
try:
|
|
311
|
+
mj = os.path.join(BASE, 'workspace', 'members', name, 'member.json')
|
|
312
|
+
if os.path.isfile(mj):
|
|
313
|
+
m = json.load(open(mj, encoding='utf-8'))
|
|
314
|
+
role = m.get('role') or 'pm'
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
role = role or 'pm'
|
|
318
|
+
parts.append('Developer: ' + name + ' (' + role + ')')
|
|
319
|
+
parts.append('Workspace: workspace/members/' + name + '/')
|
|
320
|
+
else:
|
|
321
|
+
parts.append('Developer: NOT SET. Run /wl-init to register.')
|
|
322
|
+
team = get_team_size()
|
|
323
|
+
if team > 0:
|
|
324
|
+
parts.append('Team members: ' + str(team))
|
|
325
|
+
tasks = get_active_tasks()
|
|
326
|
+
if tasks:
|
|
327
|
+
parts.append('Active tasks:')
|
|
328
|
+
for t in tasks:
|
|
329
|
+
parts.append(' [' + t['status'] + '] ' + t['name'] + ' - ' + t['title'])
|
|
330
|
+
|
|
331
|
+
usability = get_usability_summary()
|
|
332
|
+
if usability:
|
|
333
|
+
parts.append('')
|
|
334
|
+
parts.append(usability)
|
|
335
|
+
|
|
336
|
+
parts.append('')
|
|
337
|
+
|
|
338
|
+
idx_info = get_index_info()
|
|
339
|
+
if idx_info:
|
|
340
|
+
parts.append('')
|
|
341
|
+
parts.append(idx_info)
|
|
342
|
+
|
|
343
|
+
style_info = get_style_info()
|
|
344
|
+
if style_info:
|
|
345
|
+
parts.append('')
|
|
346
|
+
parts.append(style_info)
|
|
347
|
+
|
|
348
|
+
parts.append('</qoder-context>')
|
|
349
|
+
# Hook-1: 健康标记 — AI 能感知 hook 是否真的跑了
|
|
350
|
+
# QoderWork/Quest 里看不到 [hook-ok] 就知道 hook 没生效, 要自取上下文
|
|
351
|
+
parts.append('<!-- [hook-ok] session-start hook ran successfully -->')
|
|
352
|
+
print(NL.join(parts))
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
if __name__ == '__main__':
|
|
356
|
+
try:
|
|
357
|
+
main()
|
|
358
|
+
except Exception as e:
|
|
359
|
+
# A broken hook must never kill the session - emit minimal context
|
|
360
|
+
# 同时落盘到日志 (带轮转), 方便下次会话告警
|
|
361
|
+
_append_hook_error(HOOK_ERRORS_LOG, str(e)[:200])
|
|
362
|
+
print('<qoder-context>')
|
|
363
|
+
print('session-start hook error: ' + str(e)[:200])
|
|
364
|
+
print(' (详情见 .qoder/.runtime/hook-errors.log)')
|
|
365
|
+
print('</qoder-context>')
|