@hupan56/wlkj 2.5.0 → 2.7.0
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 +289 -12
- package/package.json +1 -1
- package/templates/qoder/agents/insight-planning.md +67 -0
- package/templates/qoder/agents/insight-research.md +61 -0
- package/templates/qoder/agents/prd-quick.md +1 -0
- package/templates/qoder/agents/prd-reference.md +10 -2
- package/templates/qoder/commands/optional/wl-insight.md +275 -0
- package/templates/qoder/commands/{wl-report.md → optional/wl-report.md} +13 -5
- package/templates/qoder/commands/{wl-spec.md → optional/wl-spec.md} +1 -1
- package/templates/qoder/commands/{wl-status.md → optional/wl-status.md} +28 -2
- package/templates/qoder/commands/wl-code.md +10 -2
- package/templates/qoder/commands/wl-commit.md +1 -1
- package/templates/qoder/commands/wl-design-draw.md +78 -0
- package/templates/qoder/commands/wl-design-scan.md +108 -0
- package/templates/qoder/commands/wl-design-spec.md +154 -0
- package/templates/qoder/commands/wl-design.md +32 -0
- package/templates/qoder/commands/wl-init.md +24 -3
- package/templates/qoder/commands/wl-prd-full.md +226 -0
- package/templates/qoder/commands/wl-prd-quick.md +134 -0
- package/templates/qoder/commands/wl-prd-review.md +104 -0
- package/templates/qoder/commands/wl-prd.md +17 -288
- package/templates/qoder/commands/wl-search.md +66 -30
- package/templates/qoder/commands/wl-task.md +290 -59
- package/templates/qoder/commands/wl-test.md +92 -24
- package/templates/qoder/config.yaml +59 -15
- package/templates/qoder/hooks/inject-workflow-state.py +35 -9
- package/templates/qoder/hooks/session-start.py +144 -62
- package/templates/qoder/rules/wl-pipeline.md +216 -105
- package/templates/qoder/scripts/__pycache__/search_index.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/archive_prd.py +377 -0
- package/templates/qoder/scripts/autotest.py +1715 -0
- package/templates/qoder/scripts/autotest_batch.py +224 -0
- package/templates/qoder/scripts/autotest_run.py +297 -0
- package/templates/qoder/scripts/benchmark.py +210 -209
- package/templates/qoder/scripts/build_style_index.py +444 -4
- package/templates/qoder/scripts/check_carriers.py +238 -0
- package/templates/qoder/scripts/check_mcp.py +298 -0
- package/templates/qoder/scripts/check_qoderwork_consistency.py +166 -0
- package/templates/qoder/scripts/common/developer.py +26 -19
- package/templates/qoder/scripts/common/events.py +46 -0
- package/templates/qoder/scripts/common/extract.py +419 -0
- package/templates/qoder/scripts/common/graph_traverse.py +533 -0
- package/templates/qoder/scripts/common/identity.py +6 -1
- package/templates/qoder/scripts/common/paths.py +89 -0
- package/templates/qoder/scripts/common/pip_install.py +144 -0
- package/templates/qoder/scripts/common/platform_guard.py +61 -0
- package/templates/qoder/scripts/common/search_engine.py +205 -205
- package/templates/qoder/scripts/common/terms.py +57 -0
- package/templates/qoder/scripts/common/ts_extract.py +536 -0
- package/templates/qoder/scripts/context_pack.py +73 -13
- package/templates/qoder/scripts/enrich_prompt.py +226 -0
- package/templates/qoder/scripts/eval_prd.py +318 -225
- package/templates/qoder/scripts/export.py +487 -487
- package/templates/qoder/scripts/extract_api_params.py +246 -0
- package/templates/qoder/scripts/extract_routes.py +54 -0
- package/templates/qoder/scripts/extract_routes_tree.py +78 -0
- package/templates/qoder/scripts/fill_prototype.py +707 -0
- package/templates/qoder/scripts/gen_design_doc.py +394 -0
- package/templates/qoder/scripts/git_sync.py +27 -15
- package/templates/qoder/scripts/init_doctor.py +306 -41
- package/templates/qoder/scripts/install_qoderwork.py +366 -9
- package/templates/qoder/scripts/kg.py +708 -0
- package/templates/qoder/scripts/kg_auto_login.py +196 -0
- package/templates/qoder/scripts/kg_build.py +612 -0
- package/templates/qoder/scripts/kg_build_db.py +327 -0
- package/templates/qoder/scripts/kg_duckdb.py +549 -0
- package/templates/qoder/scripts/kg_incremental.py +393 -0
- package/templates/qoder/scripts/kg_link_db.py +224 -0
- package/templates/qoder/scripts/kg_mcp_server.py +801 -0
- package/templates/qoder/scripts/kg_semantic.py +150 -0
- package/templates/qoder/scripts/kg_test_runner.py +241 -0
- package/templates/qoder/scripts/lanhu_stdio_wrapper.py +119 -0
- package/templates/qoder/scripts/learn.py +118 -39
- package/templates/qoder/scripts/learn_aggregate.py +201 -0
- package/templates/qoder/scripts/mcp_launcher.py +359 -0
- package/templates/qoder/scripts/mysql_mcp_server.py +396 -0
- package/templates/qoder/scripts/repo_root.py +106 -0
- package/templates/qoder/scripts/role.py +12 -0
- package/templates/qoder/scripts/run_weekly_update.bat +5 -0
- package/templates/qoder/scripts/run_weekly_update.sh +5 -0
- package/templates/qoder/scripts/search_index.py +307 -60
- package/templates/qoder/scripts/secure-ls.js +5640 -0
- package/templates/qoder/scripts/setup.py +706 -453
- package/templates/qoder/scripts/setup_lanhu.py +963 -0
- package/templates/qoder/scripts/status.py +250 -11
- package/templates/qoder/scripts/sync_carriers.py +259 -0
- package/templates/qoder/scripts/syncgate.py +5 -4
- package/templates/qoder/scripts/task.py +75 -0
- package/templates/qoder/scripts/team_sync.py +60 -4
- package/templates/qoder/scripts/workspace_init.py +1 -1
- package/templates/qoder/skills/design-import/SKILL.md +226 -0
- package/templates/qoder/skills/design-import/figma-workflow.md +81 -0
- package/templates/qoder/skills/design-review/SKILL.md +82 -25
- package/templates/qoder/skills/prd-generator/SKILL.md +185 -58
- package/templates/qoder/skills/prd-review/SKILL.md +18 -1
- package/templates/qoder/skills/prompt-enrich/SKILL.md +90 -0
- package/templates/qoder/skills/prototype-generator/SKILL.md +256 -141
- package/templates/qoder/skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp +0 -0
- package/templates/qoder/skills/spec-coder/SKILL.md +18 -1
- package/templates/qoder/skills/spec-generator/SKILL.md +18 -1
- package/templates/qoder/skills/test-generator/SKILL.md +15 -2
- package/templates/qoder/skills/wl-code/SKILL.md +55 -36
- package/templates/qoder/skills/wl-commit/SKILL.md +89 -76
- package/templates/qoder/skills/wl-design/SKILL.md +55 -0
- package/templates/qoder/skills/wl-init/SKILL.md +76 -67
- package/templates/qoder/skills/wl-insight/SKILL.md +201 -81
- package/templates/qoder/skills/wl-prd-full/SKILL.md +69 -0
- package/templates/qoder/skills/wl-prd-quick/SKILL.md +49 -0
- package/templates/qoder/skills/wl-prd-review/SKILL.md +34 -0
- package/templates/qoder/skills/wl-report/SKILL.md +131 -107
- package/templates/qoder/skills/wl-search/SKILL.md +141 -75
- package/templates/qoder/skills/wl-spec/SKILL.md +49 -39
- package/templates/qoder/skills/wl-status/SKILL.md +83 -61
- package/templates/qoder/skills/wl-task/SKILL.md +132 -58
- package/templates/qoder/skills/wl-test/SKILL.md +406 -40
- package/templates/qoder/templates/prd-full-template.md +2 -0
- package/templates/qoder/templates/prd-quick-template.md +1 -0
- package/templates/qoder/templates/prototype-app.html +13 -8
- package/templates/qoder/templates/prototype-web.html +376 -93
- package/templates/root/AGENTS.md +89 -34
- package/templates/root/requirements.txt +21 -0
- package/templates/root//344/275/277/347/224/250/350/257/264/346/230/216.md +259 -259
- package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -186
- package/templates/qoder/agents/prd-planning.md +0 -56
- package/templates/qoder/agents/prd-research.md +0 -33
- package/templates/qoder/commands/wl-insight.md +0 -51
- package/templates/qoder/skills/wl-prd/SKILL.md +0 -89
|
@@ -28,7 +28,7 @@ except (AttributeError, TypeError, OSError):
|
|
|
28
28
|
|
|
29
29
|
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
30
30
|
sys.path.insert(0, THIS_DIR)
|
|
31
|
-
from common.paths import get_repo_root, get_developer, get_tasks_dir
|
|
31
|
+
from common.paths import get_repo_root, get_developer, get_tasks_dir, MEMBERS_DIR
|
|
32
32
|
from common.task_utils import load_task_json
|
|
33
33
|
from common.atomicio import safe_read_json
|
|
34
34
|
|
|
@@ -198,6 +198,32 @@ def compute_blocked_graph(tasks):
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
|
|
201
|
+
# ============================================================
|
|
202
|
+
# 学习数据采集 (扫全部 dev 的 journal, 修旧版路径 bug)
|
|
203
|
+
# ============================================================
|
|
204
|
+
|
|
205
|
+
def _count_all_feedback():
|
|
206
|
+
"""统计全团队 feedback 事件总数 (扫 workspace/members/*/journal/feedback.jsonl)。
|
|
207
|
+
|
|
208
|
+
旧版读 .qoder/learning/feedback.jsonl (已废弃路径, 文件不存在) → 恒 0 → 学习度恒 2.0。
|
|
209
|
+
learn.py 早已迁移到 workspace/members/{dev}/journal/, 这里跟上。
|
|
210
|
+
"""
|
|
211
|
+
if not MEMBERS_DIR.is_dir():
|
|
212
|
+
return 0
|
|
213
|
+
total = 0
|
|
214
|
+
for dev_dir in MEMBERS_DIR.iterdir():
|
|
215
|
+
if not dev_dir.is_dir():
|
|
216
|
+
continue
|
|
217
|
+
fb = dev_dir / 'journal' / 'feedback.jsonl'
|
|
218
|
+
if fb.is_file():
|
|
219
|
+
try:
|
|
220
|
+
with open(fb, encoding='utf-8') as f:
|
|
221
|
+
total += sum(1 for line in f if line.strip())
|
|
222
|
+
except Exception:
|
|
223
|
+
continue
|
|
224
|
+
return total
|
|
225
|
+
|
|
226
|
+
|
|
201
227
|
# ============================================================
|
|
202
228
|
# 健康分 (综合)
|
|
203
229
|
# ============================================================
|
|
@@ -270,16 +296,11 @@ def compute_health(tasks):
|
|
|
270
296
|
scores['pipeline'] = 2.0
|
|
271
297
|
|
|
272
298
|
# 6. 学习 (10%)
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
scores['learning'] = min(5.0, 2.0 + fb_count * 0.1)
|
|
279
|
-
except Exception:
|
|
280
|
-
scores['learning'] = 2.0
|
|
281
|
-
else:
|
|
282
|
-
scores['learning'] = 2.0
|
|
299
|
+
# 修 bug: 旧代码读 .qoder/learning/feedback.jsonl (旧路径, 已废弃),
|
|
300
|
+
# 但 learn.py 实际写 workspace/members/{dev}/journal/feedback.jsonl。
|
|
301
|
+
# 迁移了写路径没迁移读路径 → 学习度恒为 2.0。改为扫全部 dev 的 journal。
|
|
302
|
+
fb_count = _count_all_feedback()
|
|
303
|
+
scores['learning'] = min(5.0, 2.0 + fb_count * 0.1) if fb_count else 2.0
|
|
283
304
|
|
|
284
305
|
# 加权
|
|
285
306
|
weights = {
|
|
@@ -295,6 +316,191 @@ def compute_health(tasks):
|
|
|
295
316
|
}
|
|
296
317
|
|
|
297
318
|
|
|
319
|
+
# ============================================================
|
|
320
|
+
# 流水线自检 (测工具本身, 不测产出) —— 可观测性闭环
|
|
321
|
+
# ============================================================
|
|
322
|
+
|
|
323
|
+
def _scan_skill_instrumentation():
|
|
324
|
+
"""扫 .qoder/skills/*/SKILL.md, 统计有多少 skill 调了 learn.py record (已埋点)。
|
|
325
|
+
返回 (已埋点skill列表, 全部skill列表)。
|
|
326
|
+
"""
|
|
327
|
+
skills_dir = BASE / '.qoder' / 'skills'
|
|
328
|
+
if not skills_dir.is_dir():
|
|
329
|
+
return [], []
|
|
330
|
+
all_skills = []
|
|
331
|
+
instrumented = []
|
|
332
|
+
for d in sorted(skills_dir.iterdir()):
|
|
333
|
+
if not d.is_dir():
|
|
334
|
+
continue
|
|
335
|
+
skill_md = d / 'SKILL.md'
|
|
336
|
+
if not skill_md.is_file():
|
|
337
|
+
continue
|
|
338
|
+
all_skills.append(d.name)
|
|
339
|
+
try:
|
|
340
|
+
text = skill_md.read_text(encoding='utf-8', errors='ignore')
|
|
341
|
+
except Exception:
|
|
342
|
+
continue
|
|
343
|
+
if 'learn.py' in text and 'record' in text:
|
|
344
|
+
instrumented.append(d.name)
|
|
345
|
+
return instrumented, all_skills
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _analyze_eva_rounds():
|
|
349
|
+
"""从 eval-history.jsonl 分析 EVA 修正轮数。
|
|
350
|
+
返回 {samples, rework_count, avg_rounds}。
|
|
351
|
+
rework = 连续 passed=false 后出现 passed=true 的序列。
|
|
352
|
+
"""
|
|
353
|
+
eval_path = BASE / '.qoder' / 'learning' / 'eval-history.jsonl'
|
|
354
|
+
if not eval_path.is_file():
|
|
355
|
+
return {'samples': 0, 'rework_count': 0, 'avg_rounds': 0}
|
|
356
|
+
records = []
|
|
357
|
+
try:
|
|
358
|
+
with open(eval_path, encoding='utf-8') as f:
|
|
359
|
+
for line in f:
|
|
360
|
+
line = line.strip()
|
|
361
|
+
if line:
|
|
362
|
+
try:
|
|
363
|
+
records.append(json.loads(line))
|
|
364
|
+
except Exception:
|
|
365
|
+
continue
|
|
366
|
+
except Exception:
|
|
367
|
+
return {'samples': 0, 'rework_count': 0, 'avg_rounds': 0}
|
|
368
|
+
|
|
369
|
+
recent = records[-20:]
|
|
370
|
+
rework = 0
|
|
371
|
+
total_fail_before_pass = 0
|
|
372
|
+
fails_in_run = 0
|
|
373
|
+
for r in recent:
|
|
374
|
+
if r.get('passed'):
|
|
375
|
+
if fails_in_run > 0:
|
|
376
|
+
rework += 1
|
|
377
|
+
total_fail_before_pass += fails_in_run
|
|
378
|
+
fails_in_run = 0
|
|
379
|
+
else:
|
|
380
|
+
fails_in_run += 1
|
|
381
|
+
avg_rounds = (total_fail_before_pass / rework + 1) if rework else 0
|
|
382
|
+
return {
|
|
383
|
+
'samples': len(recent),
|
|
384
|
+
'rework_count': rework,
|
|
385
|
+
'avg_rounds': round(avg_rounds, 1),
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _count_rule_violations():
|
|
390
|
+
"""统计规则违规事件 (没问平台就调脚本)。
|
|
391
|
+
扫全部 dev 的 journal 里 event=rule_violation 的条数。
|
|
392
|
+
"""
|
|
393
|
+
if not MEMBERS_DIR.is_dir():
|
|
394
|
+
return 0
|
|
395
|
+
count = 0
|
|
396
|
+
for dev_dir in MEMBERS_DIR.iterdir():
|
|
397
|
+
if not dev_dir.is_dir():
|
|
398
|
+
continue
|
|
399
|
+
fb = dev_dir / 'journal' / 'feedback.jsonl'
|
|
400
|
+
if not fb.is_file():
|
|
401
|
+
continue
|
|
402
|
+
try:
|
|
403
|
+
with open(fb, encoding='utf-8') as f:
|
|
404
|
+
for line in f:
|
|
405
|
+
line = line.strip()
|
|
406
|
+
if not line:
|
|
407
|
+
continue
|
|
408
|
+
try:
|
|
409
|
+
if json.loads(line).get('event') == 'rule_violation':
|
|
410
|
+
count += 1
|
|
411
|
+
except Exception:
|
|
412
|
+
continue
|
|
413
|
+
except Exception:
|
|
414
|
+
continue
|
|
415
|
+
return count
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def compute_pipeline_self():
|
|
419
|
+
"""流水线自检 —— 4 个指标全从已有数据派生, 不增加 PM 操作。
|
|
420
|
+
|
|
421
|
+
返回:
|
|
422
|
+
instrumentation: {done, total, missing} 埋点覆盖率
|
|
423
|
+
eva: {samples, rework_count, avg_rounds} EVA 修正轮数
|
|
424
|
+
violations: int 规则违规次数
|
|
425
|
+
verdict: str 一句话结论
|
|
426
|
+
"""
|
|
427
|
+
done, total = _scan_skill_instrumentation()
|
|
428
|
+
missing = [s for s in total if s not in done]
|
|
429
|
+
eva = _analyze_eva_rounds()
|
|
430
|
+
violations = _count_rule_violations()
|
|
431
|
+
|
|
432
|
+
parts = []
|
|
433
|
+
cov_pct = (len(done) / len(total) * 100) if total else 0
|
|
434
|
+
if cov_pct < 50:
|
|
435
|
+
parts.append('埋点覆盖偏低')
|
|
436
|
+
if eva['rework_count'] > 0 and eva['avg_rounds'] > 2:
|
|
437
|
+
parts.append('EVA 修正轮数偏高')
|
|
438
|
+
if violations > 0:
|
|
439
|
+
parts.append('存在规则违规')
|
|
440
|
+
verdict = ';'.join(parts) if parts else '正常'
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
'instrumentation': {'done': len(done), 'total': len(total),
|
|
444
|
+
'missing': missing, 'pct': round(cov_pct)},
|
|
445
|
+
'eva': eva,
|
|
446
|
+
'violations': violations,
|
|
447
|
+
'verdict': verdict,
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ============================================================
|
|
452
|
+
# 角色引导 (该角色该用哪些命令 —— 软引导, 帮新人快速上手)
|
|
453
|
+
# ============================================================
|
|
454
|
+
|
|
455
|
+
def compute_role_guide():
|
|
456
|
+
"""读当前角色 + config.yaml 的 commands 映射, 返回该角色的建议命令。
|
|
457
|
+
软引导: 不拦截任何命令, 只是把"你该用什么"摆出来。
|
|
458
|
+
"""
|
|
459
|
+
# 当前角色 (role.py 的 get_role 逻辑)
|
|
460
|
+
role = None
|
|
461
|
+
try:
|
|
462
|
+
sys.path.insert(0, str(BASE / '.qoder' / 'scripts'))
|
|
463
|
+
from role import get_role
|
|
464
|
+
role = get_role()
|
|
465
|
+
except Exception:
|
|
466
|
+
pass
|
|
467
|
+
if not role:
|
|
468
|
+
role = 'pm' # 默认
|
|
469
|
+
|
|
470
|
+
# 读 config.yaml 的 commands 映射
|
|
471
|
+
commands = []
|
|
472
|
+
role_name = role
|
|
473
|
+
try:
|
|
474
|
+
import yaml
|
|
475
|
+
cfg_path = BASE / '.qoder' / 'config.yaml'
|
|
476
|
+
if cfg_path.is_file():
|
|
477
|
+
with open(cfg_path, encoding='utf-8') as f:
|
|
478
|
+
cfg = yaml.safe_load(f) or {}
|
|
479
|
+
roles = cfg.get('roles', {})
|
|
480
|
+
rinfo = roles.get(role, {})
|
|
481
|
+
commands = rinfo.get('commands', [])
|
|
482
|
+
role_name = rinfo.get('name', role)
|
|
483
|
+
except Exception:
|
|
484
|
+
pass
|
|
485
|
+
|
|
486
|
+
# 校验建议命令是否真实存在 (command 文件在不在)
|
|
487
|
+
available = []
|
|
488
|
+
missing = []
|
|
489
|
+
cmds_dir = BASE / '.qoder' / 'commands'
|
|
490
|
+
for cmd in commands:
|
|
491
|
+
exists = (cmds_dir / (cmd + '.md')).is_file() or \
|
|
492
|
+
(cmds_dir / 'optional' / (cmd + '.md')).is_file()
|
|
493
|
+
(available if exists else missing).append(cmd)
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
'role': role,
|
|
497
|
+
'role_name': role_name,
|
|
498
|
+
'suggested': commands,
|
|
499
|
+
'available': available,
|
|
500
|
+
'missing': missing,
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
|
|
298
504
|
# ============================================================
|
|
299
505
|
# 渲染
|
|
300
506
|
# ============================================================
|
|
@@ -310,6 +516,17 @@ def render_full():
|
|
|
310
516
|
my_tasks = [n for n, d in tasks.items() if dev in (d.get('assignee'), d.get('creator'))]
|
|
311
517
|
print(f'我的任务: {len(my_tasks)} 个 (开发者: {dev})')
|
|
312
518
|
|
|
519
|
+
# 角色引导 (该角色该用哪些命令)
|
|
520
|
+
rg = compute_role_guide()
|
|
521
|
+
print(f'当前角色: {rg["role_name"]} ({rg["role"]})')
|
|
522
|
+
if rg['suggested']:
|
|
523
|
+
# 标注哪些命令当前可用
|
|
524
|
+
avail = rg['available']
|
|
525
|
+
print(f' 建议命令 ({len(avail)}/{len(rg["suggested"])} 可用): '
|
|
526
|
+
+ ' '.join('/' + c for c in avail))
|
|
527
|
+
if rg['missing']:
|
|
528
|
+
print(f' ⚠ 建议但未安装: {", ".join(rg["missing"])}')
|
|
529
|
+
|
|
313
530
|
# 健康分
|
|
314
531
|
print('\n--- 健康度 ---')
|
|
315
532
|
health = compute_health(tasks)
|
|
@@ -359,6 +576,28 @@ def render_full():
|
|
|
359
576
|
else:
|
|
360
577
|
print('暂无已完成任务的时间戳数据 (stage_ts 在 B3 后才有)')
|
|
361
578
|
|
|
579
|
+
# 流水线自检 (测工具本身)
|
|
580
|
+
print('\n--- 流水线自检 (测工具本身) ---')
|
|
581
|
+
ps = compute_pipeline_self()
|
|
582
|
+
inst = ps['instrumentation']
|
|
583
|
+
cov_mark = '✓' if inst['pct'] >= 50 else '⚠'
|
|
584
|
+
print(f" 埋点覆盖: {inst['done']}/{inst['total']} skill ({inst['pct']}%) {cov_mark}")
|
|
585
|
+
if inst['missing']:
|
|
586
|
+
# 只列前 5 个, 避免刷屏
|
|
587
|
+
shown = inst['missing'][:5]
|
|
588
|
+
more = '' if len(inst['missing']) <= 5 else f" 等{len(inst['missing'])}个"
|
|
589
|
+
print(f" 缺埋点: {', '.join(shown)}{more}")
|
|
590
|
+
eva = ps['eva']
|
|
591
|
+
if eva['samples'] > 0:
|
|
592
|
+
eva_mark = '✓' if eva['avg_rounds'] <= 2 else '⚠'
|
|
593
|
+
print(f" EVA 修正: 近{eva['samples']}次 {eva['rework_count']}次打回, "
|
|
594
|
+
f"平均{eva['avg_rounds']}轮过 {eva_mark}")
|
|
595
|
+
else:
|
|
596
|
+
print(' EVA 修正: 暂无评估历史')
|
|
597
|
+
v_mark = '✓' if ps['violations'] == 0 else '⚠'
|
|
598
|
+
print(f" 规则违规: {ps['violations']} 次 {v_mark}")
|
|
599
|
+
print(f" 自检结论: {ps['verdict']}")
|
|
600
|
+
|
|
362
601
|
|
|
363
602
|
def main():
|
|
364
603
|
parser = argparse.ArgumentParser(description='项目状态计算')
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
sync_carriers.py - 四载体 description 自动同步 (skill -> command)
|
|
5
|
+
|
|
6
|
+
为什么需要它:
|
|
7
|
+
check_carriers.py 能发现漂移, 但修复一直靠手工。git status 里
|
|
8
|
+
sync_templates.py 长期 M 状态就是手工同步的证据。
|
|
9
|
+
本脚本把权威源 (skill 的 SKILL.md frontmatter) 的 description 自动同步到
|
|
10
|
+
command 文件, 让"手工对齐"变成"一条命令"。
|
|
11
|
+
|
|
12
|
+
权威源 = skill (skills/wl-X/SKILL.md):
|
|
13
|
+
- skill 更新最频繁 (QoderWork 直接读, 迭代快)
|
|
14
|
+
- skill 的 description 最全 (含中文 + 英文 + 触发词)
|
|
15
|
+
- command 的 description 往往是旧版/简化版
|
|
16
|
+
|
|
17
|
+
同步规则:
|
|
18
|
+
- 只同步 description 字段 (其他字段 name/argument-hint/auto-approve/allowed-tools 保留)
|
|
19
|
+
- skill description 可能很长 (含触发词), 但 command 的 description 该简洁
|
|
20
|
+
→ 截取到第一个"。用户说"之前的部分 (核心功能描述), 去掉冗长的触发词列举
|
|
21
|
+
- 幂等: 已一致则跳过
|
|
22
|
+
- 支持 --dry-run 预览
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
python .qoder/scripts/sync_carriers.py # 同步所有漂移的
|
|
26
|
+
python .qoder/scripts/sync_carriers.py --dry-run # 只预览不改
|
|
27
|
+
python .qoder/scripts/sync_carriers.py wl-search # 只同步指定命令
|
|
28
|
+
Exit: 0 = 全部同步完成 (或已一致), 1 = 出错
|
|
29
|
+
"""
|
|
30
|
+
import argparse
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
if sys.platform == 'win32':
|
|
36
|
+
try:
|
|
37
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
38
|
+
except (AttributeError, IOError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
42
|
+
from common.paths import get_repo_root
|
|
43
|
+
|
|
44
|
+
BASE = get_repo_root()
|
|
45
|
+
COMMANDS_DIR = BASE / '.qoder' / 'commands'
|
|
46
|
+
SKILLS_DIR = BASE / '.qoder' / 'skills'
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _read_frontmatter(path):
|
|
50
|
+
"""读 .md 的 frontmatter, 返回 (fm_dict, body, raw_fm_text)。
|
|
51
|
+
raw_fm_text 用于精确替换 (保留原始格式)。
|
|
52
|
+
"""
|
|
53
|
+
text = path.read_text(encoding='utf-8', errors='ignore')
|
|
54
|
+
if not text.startswith('---'):
|
|
55
|
+
return {}, text, ''
|
|
56
|
+
parts = text.split('---', 2)
|
|
57
|
+
if len(parts) < 3:
|
|
58
|
+
return {}, text, ''
|
|
59
|
+
fm_raw = parts[1]
|
|
60
|
+
body = parts[2]
|
|
61
|
+
return _parse_fm(fm_raw), body, fm_raw
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_fm(fm_text):
|
|
65
|
+
"""解析 YAML frontmatter (简化版, 不引外部库)。返回 dict。
|
|
66
|
+
处理带引号的值和裸值。
|
|
67
|
+
"""
|
|
68
|
+
fm = {}
|
|
69
|
+
for line in fm_text.splitlines():
|
|
70
|
+
line = line.rstrip()
|
|
71
|
+
if not line.strip() or line.strip().startswith('#'):
|
|
72
|
+
continue
|
|
73
|
+
if ':' not in line:
|
|
74
|
+
continue
|
|
75
|
+
key, _, val = line.partition(':')
|
|
76
|
+
key = key.strip()
|
|
77
|
+
val = val.strip()
|
|
78
|
+
# 去引号
|
|
79
|
+
if (val.startswith('"') and val.endswith('"')) or \
|
|
80
|
+
(val.startswith("'") and val.endswith("'")):
|
|
81
|
+
val = val[1:-1]
|
|
82
|
+
fm[key] = val
|
|
83
|
+
return fm
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _condense_description(desc):
|
|
87
|
+
"""skill 的 description 往往很长 (含触发词列举), command 的该简洁。
|
|
88
|
+
截取策略: 保留到"用户说"或"触发"或第一个句号之前的核心描述。
|
|
89
|
+
保留中英双语部分 (英文描述通常是核心)。
|
|
90
|
+
"""
|
|
91
|
+
if not desc:
|
|
92
|
+
return desc
|
|
93
|
+
# 找截断点: "用户说" / "触发" / "时触发"
|
|
94
|
+
for marker in ['。用户说', ';用户说', ' 用户说', '。触发:', ' 触发:', ';触发', '。时触发', ' 时触发']:
|
|
95
|
+
idx = desc.find(marker)
|
|
96
|
+
if idx > 0:
|
|
97
|
+
return desc[:idx + 1] # 含句号
|
|
98
|
+
# 没找到截断点 → 原样返回 (可能本就简洁)
|
|
99
|
+
return desc
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _find_command_file(name):
|
|
103
|
+
"""在 commands/ 和 commands/optional/ 下找命令文件。返回 Path 或 None。"""
|
|
104
|
+
for cand in [COMMANDS_DIR / (name + '.md'),
|
|
105
|
+
COMMANDS_DIR / 'optional' / (name + '.md')]:
|
|
106
|
+
if cand.is_file():
|
|
107
|
+
return cand
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _find_skill_file(name):
|
|
112
|
+
"""skills/wl-X/SKILL.md。"""
|
|
113
|
+
p = SKILLS_DIR / name / 'SKILL.md'
|
|
114
|
+
return p if p.is_file() else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def sync_one(name, dry_run=False):
|
|
118
|
+
"""同步一个命令。返回 ('synced'/'skipped'/'error', 详情)。"""
|
|
119
|
+
skill_path = _find_skill_file(name)
|
|
120
|
+
cmd_path = _find_command_file(name)
|
|
121
|
+
if not skill_path:
|
|
122
|
+
return 'error', 'skill 不存在: %s' % skill_path
|
|
123
|
+
if not cmd_path:
|
|
124
|
+
return 'error', 'command 不存在: %s' % cmd_path
|
|
125
|
+
|
|
126
|
+
skill_fm, _, _ = _read_frontmatter(skill_path)
|
|
127
|
+
skill_desc = skill_fm.get('description', '')
|
|
128
|
+
if not skill_desc:
|
|
129
|
+
return 'skipped', 'skill 无 description 字段'
|
|
130
|
+
|
|
131
|
+
cmd_fm, cmd_body, cmd_fm_raw = _read_frontmatter(cmd_path)
|
|
132
|
+
cmd_desc = cmd_fm.get('description', '')
|
|
133
|
+
|
|
134
|
+
# 精简 skill desc (command 不该塞一堆触发词)
|
|
135
|
+
condensed = _condense_description(skill_desc)
|
|
136
|
+
|
|
137
|
+
if cmd_desc == condensed:
|
|
138
|
+
return 'skipped', '已一致'
|
|
139
|
+
|
|
140
|
+
# dry-run 只预览
|
|
141
|
+
if dry_run:
|
|
142
|
+
return 'would-sync', ' 旧: %s\n 新: %s' % (cmd_desc[:70], condensed[:70])
|
|
143
|
+
|
|
144
|
+
# 精确替换 frontmatter 里的 description 行
|
|
145
|
+
new_fm = _replace_desc_in_fm(cmd_fm_raw, condensed)
|
|
146
|
+
if new_fm is None:
|
|
147
|
+
return 'error', '无法解析 command frontmatter 的 description 行'
|
|
148
|
+
|
|
149
|
+
new_text = '---' + new_fm + '---' + cmd_body
|
|
150
|
+
cmd_path.write_text(new_text, encoding='utf-8')
|
|
151
|
+
return 'synced', condensed[:70]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _replace_desc_in_fm(fm_raw, new_desc):
|
|
155
|
+
"""在 frontmatter 原文里替换 description 行。保留其他行原样。
|
|
156
|
+
返回新的 fm_raw 或 None (无法替换)。
|
|
157
|
+
"""
|
|
158
|
+
# 匹配 description: 开头的行 (可能带引号)
|
|
159
|
+
# 用 json.dumps 保证引号转义正确 (description 含特殊字符)
|
|
160
|
+
escaped = _yaml_escape(new_desc)
|
|
161
|
+
pat = re.compile(r'^(\s*description:\s*)(.*)$', re.MULTILINE)
|
|
162
|
+
new_fm, n = pat.subn(lambda m: m.group(1) + escaped, fm_raw, count=1)
|
|
163
|
+
if n == 0:
|
|
164
|
+
# 没有 description 行 → 插一行 (在 name: 之后)
|
|
165
|
+
if re.search(r'^\s*name:', fm_raw, re.MULTILINE):
|
|
166
|
+
new_fm = re.sub(
|
|
167
|
+
r'^(\s*name:\s*.*)$',
|
|
168
|
+
r'\1\ndescription: ' + escaped,
|
|
169
|
+
fm_raw, count=1, flags=re.MULTILINE)
|
|
170
|
+
else:
|
|
171
|
+
return None
|
|
172
|
+
return new_fm
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _yaml_escape(s):
|
|
176
|
+
"""YAML 字符串转义: 含特殊字符(: # 等)用双引号包裹, 内部双引号转义。
|
|
177
|
+
简化: 一律用双引号 + JSON 转义 (最安全)。
|
|
178
|
+
"""
|
|
179
|
+
import json
|
|
180
|
+
return json.dumps(s, ensure_ascii=False)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def find_drifted_commands():
|
|
184
|
+
"""找出所有 command↔skill 配对且 description 漂移的命令名。
|
|
185
|
+
(复用 check_carriers 的相似度判断)
|
|
186
|
+
"""
|
|
187
|
+
from difflib import SequenceMatcher
|
|
188
|
+
drifted = []
|
|
189
|
+
if not SKILLS_DIR.is_dir():
|
|
190
|
+
return drifted
|
|
191
|
+
for d in sorted(SKILLS_DIR.iterdir()):
|
|
192
|
+
if not d.is_dir() or not (d / 'SKILL.md').is_file():
|
|
193
|
+
continue
|
|
194
|
+
name = d.name
|
|
195
|
+
cmd = _find_command_file(name)
|
|
196
|
+
if not cmd:
|
|
197
|
+
continue
|
|
198
|
+
skill_fm, _, _ = _read_frontmatter(d / 'SKILL.md')
|
|
199
|
+
cmd_fm, _, _ = _read_frontmatter(cmd)
|
|
200
|
+
sd, cd = skill_fm.get('description', ''), cmd_fm.get('description', '')
|
|
201
|
+
if not sd or not cd:
|
|
202
|
+
continue
|
|
203
|
+
sim = SequenceMatcher(None, sd.lower(), cd.lower()).ratio()
|
|
204
|
+
if sim < 0.85:
|
|
205
|
+
drifted.append(name)
|
|
206
|
+
return drifted
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main():
|
|
210
|
+
ap = argparse.ArgumentParser(description='四载体 description 自动同步 (skill -> command)')
|
|
211
|
+
ap.add_argument('command', nargs='?', default=None,
|
|
212
|
+
help='只同步指定命令 (如 wl-search); 不传则同步所有漂移的')
|
|
213
|
+
ap.add_argument('--dry-run', action='store_true', help='只预览不写文件')
|
|
214
|
+
args = ap.parse_args()
|
|
215
|
+
|
|
216
|
+
print('=' * 56)
|
|
217
|
+
print('四载体 description 同步 (skill -> command)')
|
|
218
|
+
print('=' * 56)
|
|
219
|
+
|
|
220
|
+
if args.command:
|
|
221
|
+
names = [args.command]
|
|
222
|
+
else:
|
|
223
|
+
names = find_drifted_commands()
|
|
224
|
+
if not names:
|
|
225
|
+
print('✅ 无漂移, 所有 command/skill description 已一致。')
|
|
226
|
+
return 0
|
|
227
|
+
print('发现 %d 个漂移命令: %s' % (len(names), ', '.join(names)))
|
|
228
|
+
print()
|
|
229
|
+
|
|
230
|
+
synced, skipped, errors = [], [], []
|
|
231
|
+
for name in names:
|
|
232
|
+
status, detail = sync_one(name, dry_run=args.dry_run)
|
|
233
|
+
if status == 'synced':
|
|
234
|
+
synced.append(name)
|
|
235
|
+
print(' [✓] %s' % name)
|
|
236
|
+
print(' %s' % detail)
|
|
237
|
+
elif status == 'would-sync':
|
|
238
|
+
synced.append(name)
|
|
239
|
+
print(' [~] %s (dry-run)' % name)
|
|
240
|
+
print('%s' % detail)
|
|
241
|
+
elif status == 'skipped':
|
|
242
|
+
skipped.append(name)
|
|
243
|
+
else:
|
|
244
|
+
errors.append((name, detail))
|
|
245
|
+
print(' [✗] %s: %s' % (name, detail))
|
|
246
|
+
|
|
247
|
+
print()
|
|
248
|
+
verb = '将同步' if args.dry_run else '已同步'
|
|
249
|
+
print('%s: %d 同步 / %d 跳过 / %d 错误'
|
|
250
|
+
% (verb, len(synced), len(skipped), len(errors)))
|
|
251
|
+
if not args.dry_run and synced:
|
|
252
|
+
print('提示: 重跑 check_carriers.py 验证一致性。')
|
|
253
|
+
if errors:
|
|
254
|
+
return 1
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == '__main__':
|
|
259
|
+
sys.exit(main())
|
|
@@ -223,7 +223,7 @@ _PLATFORM_KEYWORDS = [
|
|
|
223
223
|
def check_platform_gate(prd_paths: List[str], repo_root: str) -> None:
|
|
224
224
|
"""检查 PRD 文件是否标注了目标平台。未标注抛 GateFailure。
|
|
225
225
|
|
|
226
|
-
这是 /wl-prd "先问平台" 的程序级兜底:
|
|
226
|
+
这是 /wl-prd-full "先问平台" 的程序级兜底:
|
|
227
227
|
如果 AI 没问平台就生成了 PRD, push 时这里会拦住。
|
|
228
228
|
"""
|
|
229
229
|
missing = []
|
|
@@ -242,7 +242,7 @@ def check_platform_gate(prd_paths: List[str], repo_root: str) -> None:
|
|
|
242
242
|
|
|
243
243
|
if missing:
|
|
244
244
|
raise GateFailure(
|
|
245
|
-
"[gate] 拒绝: %d 个 PRD 未标注目标平台 (违反 /wl-prd 先问平台规则):\n"
|
|
245
|
+
"[gate] 拒绝: %d 个 PRD 未标注目标平台 (违反 /wl-prd-full 先问平台规则):\n"
|
|
246
246
|
" %s\n"
|
|
247
247
|
"修复: 在 PRD 头部加一行:\n"
|
|
248
248
|
" - **平台**: Web管理端 (fywl-ui) # 或 APP移动端 / 两端 / 后端" % (
|
|
@@ -280,8 +280,9 @@ def check_eval_gate(
|
|
|
280
280
|
try:
|
|
281
281
|
with open(full, 'r', encoding='utf-8', errors='replace') as f:
|
|
282
282
|
head = f.read(2048)
|
|
283
|
-
|
|
284
|
-
|
|
283
|
+
head_lower = head.lower()
|
|
284
|
+
if 'mode: quick' in head_lower or 'mini-prd' in head_lower or '零星需求' in head:
|
|
285
|
+
continue # Quick 模式, 跳过 EVA (mode 标记优先, 兼容旧 Mini-PRD 字样)
|
|
285
286
|
except OSError:
|
|
286
287
|
pass
|
|
287
288
|
# 找同名原型 (可选)
|