@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.
Files changed (127) hide show
  1. package/bin/cli.js +289 -12
  2. package/package.json +1 -1
  3. package/templates/qoder/agents/insight-planning.md +67 -0
  4. package/templates/qoder/agents/insight-research.md +61 -0
  5. package/templates/qoder/agents/prd-quick.md +1 -0
  6. package/templates/qoder/agents/prd-reference.md +10 -2
  7. package/templates/qoder/commands/optional/wl-insight.md +275 -0
  8. package/templates/qoder/commands/{wl-report.md → optional/wl-report.md} +13 -5
  9. package/templates/qoder/commands/{wl-spec.md → optional/wl-spec.md} +1 -1
  10. package/templates/qoder/commands/{wl-status.md → optional/wl-status.md} +28 -2
  11. package/templates/qoder/commands/wl-code.md +10 -2
  12. package/templates/qoder/commands/wl-commit.md +1 -1
  13. package/templates/qoder/commands/wl-design-draw.md +78 -0
  14. package/templates/qoder/commands/wl-design-scan.md +108 -0
  15. package/templates/qoder/commands/wl-design-spec.md +154 -0
  16. package/templates/qoder/commands/wl-design.md +32 -0
  17. package/templates/qoder/commands/wl-init.md +24 -3
  18. package/templates/qoder/commands/wl-prd-full.md +226 -0
  19. package/templates/qoder/commands/wl-prd-quick.md +134 -0
  20. package/templates/qoder/commands/wl-prd-review.md +104 -0
  21. package/templates/qoder/commands/wl-prd.md +17 -288
  22. package/templates/qoder/commands/wl-search.md +66 -30
  23. package/templates/qoder/commands/wl-task.md +290 -59
  24. package/templates/qoder/commands/wl-test.md +92 -24
  25. package/templates/qoder/config.yaml +59 -15
  26. package/templates/qoder/hooks/inject-workflow-state.py +35 -9
  27. package/templates/qoder/hooks/session-start.py +144 -62
  28. package/templates/qoder/rules/wl-pipeline.md +216 -105
  29. package/templates/qoder/scripts/__pycache__/search_index.cpython-39.pyc +0 -0
  30. package/templates/qoder/scripts/archive_prd.py +377 -0
  31. package/templates/qoder/scripts/autotest.py +1715 -0
  32. package/templates/qoder/scripts/autotest_batch.py +224 -0
  33. package/templates/qoder/scripts/autotest_run.py +297 -0
  34. package/templates/qoder/scripts/benchmark.py +210 -209
  35. package/templates/qoder/scripts/build_style_index.py +444 -4
  36. package/templates/qoder/scripts/check_carriers.py +238 -0
  37. package/templates/qoder/scripts/check_mcp.py +298 -0
  38. package/templates/qoder/scripts/check_qoderwork_consistency.py +166 -0
  39. package/templates/qoder/scripts/common/developer.py +26 -19
  40. package/templates/qoder/scripts/common/events.py +46 -0
  41. package/templates/qoder/scripts/common/extract.py +419 -0
  42. package/templates/qoder/scripts/common/graph_traverse.py +533 -0
  43. package/templates/qoder/scripts/common/identity.py +6 -1
  44. package/templates/qoder/scripts/common/paths.py +89 -0
  45. package/templates/qoder/scripts/common/pip_install.py +144 -0
  46. package/templates/qoder/scripts/common/platform_guard.py +61 -0
  47. package/templates/qoder/scripts/common/search_engine.py +205 -205
  48. package/templates/qoder/scripts/common/terms.py +57 -0
  49. package/templates/qoder/scripts/common/ts_extract.py +536 -0
  50. package/templates/qoder/scripts/context_pack.py +73 -13
  51. package/templates/qoder/scripts/enrich_prompt.py +226 -0
  52. package/templates/qoder/scripts/eval_prd.py +318 -225
  53. package/templates/qoder/scripts/export.py +487 -487
  54. package/templates/qoder/scripts/extract_api_params.py +246 -0
  55. package/templates/qoder/scripts/extract_routes.py +54 -0
  56. package/templates/qoder/scripts/extract_routes_tree.py +78 -0
  57. package/templates/qoder/scripts/fill_prototype.py +707 -0
  58. package/templates/qoder/scripts/gen_design_doc.py +394 -0
  59. package/templates/qoder/scripts/git_sync.py +27 -15
  60. package/templates/qoder/scripts/init_doctor.py +306 -41
  61. package/templates/qoder/scripts/install_qoderwork.py +366 -9
  62. package/templates/qoder/scripts/kg.py +708 -0
  63. package/templates/qoder/scripts/kg_auto_login.py +196 -0
  64. package/templates/qoder/scripts/kg_build.py +612 -0
  65. package/templates/qoder/scripts/kg_build_db.py +327 -0
  66. package/templates/qoder/scripts/kg_duckdb.py +549 -0
  67. package/templates/qoder/scripts/kg_incremental.py +393 -0
  68. package/templates/qoder/scripts/kg_link_db.py +224 -0
  69. package/templates/qoder/scripts/kg_mcp_server.py +801 -0
  70. package/templates/qoder/scripts/kg_semantic.py +150 -0
  71. package/templates/qoder/scripts/kg_test_runner.py +241 -0
  72. package/templates/qoder/scripts/lanhu_stdio_wrapper.py +119 -0
  73. package/templates/qoder/scripts/learn.py +118 -39
  74. package/templates/qoder/scripts/learn_aggregate.py +201 -0
  75. package/templates/qoder/scripts/mcp_launcher.py +359 -0
  76. package/templates/qoder/scripts/mysql_mcp_server.py +396 -0
  77. package/templates/qoder/scripts/repo_root.py +106 -0
  78. package/templates/qoder/scripts/role.py +12 -0
  79. package/templates/qoder/scripts/run_weekly_update.bat +5 -0
  80. package/templates/qoder/scripts/run_weekly_update.sh +5 -0
  81. package/templates/qoder/scripts/search_index.py +307 -60
  82. package/templates/qoder/scripts/secure-ls.js +5640 -0
  83. package/templates/qoder/scripts/setup.py +706 -453
  84. package/templates/qoder/scripts/setup_lanhu.py +963 -0
  85. package/templates/qoder/scripts/status.py +250 -11
  86. package/templates/qoder/scripts/sync_carriers.py +259 -0
  87. package/templates/qoder/scripts/syncgate.py +5 -4
  88. package/templates/qoder/scripts/task.py +75 -0
  89. package/templates/qoder/scripts/team_sync.py +60 -4
  90. package/templates/qoder/scripts/workspace_init.py +1 -1
  91. package/templates/qoder/skills/design-import/SKILL.md +226 -0
  92. package/templates/qoder/skills/design-import/figma-workflow.md +81 -0
  93. package/templates/qoder/skills/design-review/SKILL.md +82 -25
  94. package/templates/qoder/skills/prd-generator/SKILL.md +185 -58
  95. package/templates/qoder/skills/prd-review/SKILL.md +18 -1
  96. package/templates/qoder/skills/prompt-enrich/SKILL.md +90 -0
  97. package/templates/qoder/skills/prototype-generator/SKILL.md +256 -141
  98. package/templates/qoder/skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp +0 -0
  99. package/templates/qoder/skills/spec-coder/SKILL.md +18 -1
  100. package/templates/qoder/skills/spec-generator/SKILL.md +18 -1
  101. package/templates/qoder/skills/test-generator/SKILL.md +15 -2
  102. package/templates/qoder/skills/wl-code/SKILL.md +55 -36
  103. package/templates/qoder/skills/wl-commit/SKILL.md +89 -76
  104. package/templates/qoder/skills/wl-design/SKILL.md +55 -0
  105. package/templates/qoder/skills/wl-init/SKILL.md +76 -67
  106. package/templates/qoder/skills/wl-insight/SKILL.md +201 -81
  107. package/templates/qoder/skills/wl-prd-full/SKILL.md +69 -0
  108. package/templates/qoder/skills/wl-prd-quick/SKILL.md +49 -0
  109. package/templates/qoder/skills/wl-prd-review/SKILL.md +34 -0
  110. package/templates/qoder/skills/wl-report/SKILL.md +131 -107
  111. package/templates/qoder/skills/wl-search/SKILL.md +141 -75
  112. package/templates/qoder/skills/wl-spec/SKILL.md +49 -39
  113. package/templates/qoder/skills/wl-status/SKILL.md +83 -61
  114. package/templates/qoder/skills/wl-task/SKILL.md +132 -58
  115. package/templates/qoder/skills/wl-test/SKILL.md +406 -40
  116. package/templates/qoder/templates/prd-full-template.md +2 -0
  117. package/templates/qoder/templates/prd-quick-template.md +1 -0
  118. package/templates/qoder/templates/prototype-app.html +13 -8
  119. package/templates/qoder/templates/prototype-web.html +376 -93
  120. package/templates/root/AGENTS.md +89 -34
  121. package/templates/root/requirements.txt +21 -0
  122. package/templates/root//344/275/277/347/224/250/350/257/264/346/230/216.md +259 -259
  123. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -186
  124. package/templates/qoder/agents/prd-planning.md +0 -56
  125. package/templates/qoder/agents/prd-research.md +0 -33
  126. package/templates/qoder/commands/wl-insight.md +0 -51
  127. 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
- feedback_path = BASE / '.qoder' / 'learning' / 'feedback.jsonl'
274
- if feedback_path.is_file():
275
- try:
276
- with open(feedback_path, encoding='utf-8') as f:
277
- fb_count = sum(1 for _ in f)
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
- if 'mode: quick' in head.lower():
284
- continue # Quick 模式, 跳过 EVA
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
  # 找同名原型 (可选)