@hupan56/wlkj 2.7.12 → 3.0.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 (152) hide show
  1. package/bin/cli.js +344 -78
  2. package/package.json +29 -29
  3. package/templates/.qoder/.runtime/ctx-cache-5660152f1d6dd819.md +23 -0
  4. package/templates/.qoder/.runtime/ctx-cache-afdce0dac06b25b0.md +23 -0
  5. package/templates/.qoder/.runtime/search-cache-eae7644e7b122f35.txt +1 -0
  6. package/templates/.qoder/learning/eval-history.jsonl +28 -0
  7. package/templates/data/index/wiki-index.json +8 -0
  8. package/templates/qoder/agents/insight-planning.md +1 -1
  9. package/templates/qoder/agents/insight-research.md +28 -15
  10. package/templates/qoder/commands/optional/wl-insight.md +159 -161
  11. package/templates/qoder/commands/optional/wl-report.md +4 -4
  12. package/templates/qoder/commands/optional/wl-status.md +2 -2
  13. package/templates/qoder/commands/wl-code.md +2 -2
  14. package/templates/qoder/commands/wl-design.md +2 -2
  15. package/templates/qoder/commands/wl-init.md +3 -3
  16. package/templates/qoder/commands/wl-prd.md +2 -2
  17. package/templates/qoder/commands/wl-req.md +43 -0
  18. package/templates/qoder/commands/wl-search.md +8 -8
  19. package/templates/qoder/commands/wl-task.md +17 -17
  20. package/templates/qoder/commands/wl-test.md +41 -15
  21. package/templates/qoder/config.yaml +17 -1
  22. package/templates/qoder/hooks/session-start.py +12 -22
  23. package/templates/qoder/nul +4 -0
  24. package/templates/qoder/rules/wl-pipeline.md +22 -48
  25. package/templates/qoder/scripts/README.md +139 -0
  26. package/templates/qoder/scripts/common/autotest_auth.py +109 -0
  27. package/templates/qoder/scripts/common/bootstrap.py +145 -0
  28. package/templates/qoder/scripts/common/check_publish.py +98 -0
  29. package/templates/qoder/scripts/common/cmd_registry.py +112 -0
  30. package/templates/qoder/scripts/common/config.py +187 -0
  31. package/templates/qoder/scripts/common/contract.py +317 -0
  32. package/templates/qoder/scripts/common/developer.py +2 -1
  33. package/templates/qoder/scripts/common/feishu.py +10 -9
  34. package/templates/qoder/scripts/common/guard.py +159 -0
  35. package/templates/qoder/scripts/common/identity.py +121 -2
  36. package/templates/qoder/scripts/common/kg_capabilities.py +182 -0
  37. package/templates/qoder/scripts/common/mcp_base.py +268 -0
  38. package/templates/qoder/scripts/common/paths.py +187 -1
  39. package/templates/qoder/scripts/common/result.py +223 -0
  40. package/templates/qoder/scripts/common/roles.py +60 -0
  41. package/templates/qoder/scripts/common/task_utils.py +21 -9
  42. package/templates/qoder/scripts/common/test_extract.py +115 -0
  43. package/templates/qoder/scripts/kg/__init__.py +11 -0
  44. package/templates/qoder/scripts/kg/build_entity_registry.py +196 -0
  45. package/templates/qoder/scripts/kg/build_relations.py +127 -0
  46. package/templates/qoder/scripts/{build_style_index.py → kg/build_style_index.py} +39 -10
  47. package/templates/qoder/scripts/kg/build_workflows.py +144 -0
  48. package/templates/qoder/scripts/{context_pack.py → kg/context_pack.py} +18 -11
  49. package/templates/qoder/scripts/{enrich_prompt.py → kg/enrich_prompt.py} +232 -226
  50. package/templates/qoder/scripts/{extract_api_params.py → kg/extract.py} +398 -246
  51. package/templates/qoder/scripts/{kg.py → kg/kg.py} +638 -708
  52. package/templates/qoder/scripts/{kg_build.py → kg/kg_build.py} +618 -612
  53. package/templates/qoder/scripts/{kg_build_db.py → kg/kg_build_db.py} +333 -327
  54. package/templates/qoder/scripts/{kg_duckdb.py → kg/kg_duckdb.py} +38 -37
  55. package/templates/qoder/scripts/{kg_incremental.py → kg/kg_incremental.py} +420 -393
  56. package/templates/qoder/scripts/{kg_link_db.py → kg/kg_link_db.py} +230 -224
  57. package/templates/qoder/scripts/{kg_semantic.py → kg/kg_semantic.py} +156 -150
  58. package/templates/qoder/scripts/kg/prefetch.py +359 -0
  59. package/templates/qoder/scripts/{search_index.py → kg/search_index.py} +70 -14
  60. package/templates/qoder/scripts/mcp/__init__.py +11 -0
  61. package/templates/qoder/scripts/{kg_mcp_server.py → mcp/kg_mcp_server.py} +77 -272
  62. package/templates/qoder/scripts/{lanhu_stdio_wrapper.py → mcp/lanhu_stdio_wrapper.py} +125 -119
  63. package/templates/qoder/scripts/{check_mcp.py → mcp/mcp_doctor.py} +515 -298
  64. package/templates/qoder/scripts/{mcp_launcher.py → mcp/mcp_launcher.py} +442 -414
  65. package/templates/qoder/scripts/{mysql_mcp_server.py → mcp/mysql_mcp_server.py} +347 -396
  66. package/templates/qoder/scripts/{zentao_mcp_server.py → mcp/zentao_mcp_server.py} +384 -424
  67. package/templates/qoder/scripts/report/__init__.py +11 -0
  68. package/templates/qoder/scripts/{add_session.py → report/add_session.py} +250 -244
  69. package/templates/qoder/scripts/{archive_prd.py → report/archive_prd.py} +383 -377
  70. package/templates/qoder/scripts/{eval_prd.py → report/eval_prd.py} +73 -11
  71. package/templates/qoder/scripts/{export.py → report/export.py} +63 -0
  72. package/templates/qoder/scripts/{fill_prototype.py → report/fill_prototype.py} +6 -0
  73. package/templates/qoder/scripts/{gen_design_doc.py → report/gen_design_doc.py} +400 -394
  74. package/templates/qoder/scripts/{learn.py → report/learn.py} +152 -146
  75. package/templates/qoder/scripts/{learn_aggregate.py → report/learn_aggregate.py} +207 -201
  76. package/templates/qoder/scripts/{report.py → report/report.py} +287 -281
  77. package/templates/qoder/scripts/report/req.py +222 -0
  78. package/templates/qoder/scripts/report/role.py +33 -0
  79. package/templates/qoder/scripts/{status.py → report/status.py} +634 -628
  80. package/templates/qoder/scripts/setup/__init__.py +11 -0
  81. package/templates/qoder/scripts/setup/carriers.py +662 -0
  82. package/templates/qoder/scripts/{init_doctor.py → setup/init_doctor.py} +63 -26
  83. package/templates/qoder/scripts/{install_qoderwork.py → setup/install_qoderwork.py} +36 -26
  84. package/templates/qoder/scripts/{platform_doctor.py → setup/platform_doctor.py} +265 -259
  85. package/templates/qoder/scripts/{repo_root.py → setup/repo_root.py} +112 -106
  86. package/templates/qoder/scripts/{setup.py → setup/setup.py} +113 -4
  87. package/templates/qoder/scripts/{setup_lanhu.py → setup/setup_lanhu.py} +973 -963
  88. package/templates/qoder/scripts/task/__init__.py +11 -0
  89. package/templates/qoder/scripts/{git_sync.py → task/git_sync.py} +52 -31
  90. package/templates/qoder/scripts/{syncgate.py → task/syncgate.py} +6 -0
  91. package/templates/qoder/scripts/task/task.py +221 -0
  92. package/templates/qoder/scripts/task/task_lifecycle.py +596 -0
  93. package/templates/qoder/scripts/task/task_query.py +161 -0
  94. package/templates/qoder/scripts/task/task_relations.py +424 -0
  95. package/templates/qoder/scripts/{team_sync.py → task/team_sync.py} +93 -20
  96. package/templates/qoder/scripts/test/__init__.py +11 -0
  97. package/templates/qoder/scripts/{autotest.py → test/autotest.py} +1174 -1751
  98. package/templates/qoder/scripts/{autotest_batch.py → test/autotest_batch.py} +242 -224
  99. package/templates/qoder/scripts/test/autotest_data.py +675 -0
  100. package/templates/qoder/scripts/{autotest_run.py → test/autotest_run.py} +309 -297
  101. package/templates/qoder/scripts/{benchmark.py → test/benchmark.py} +6 -0
  102. package/templates/qoder/scripts/{kg_auto_login.py → test/kg_auto_login.py} +202 -196
  103. package/templates/qoder/scripts/{kg_test_runner.py → test/kg_test_runner.py} +7 -1
  104. package/templates/qoder/scripts/{page_probe.py → test/page_probe.py} +465 -459
  105. package/templates/qoder/scripts/wlkj.py +116 -0
  106. package/templates/qoder/settings.json +1 -10
  107. package/templates/qoder/skills/design-import/SKILL.md +226 -226
  108. package/templates/qoder/skills/design-review/SKILL.md +82 -82
  109. package/templates/qoder/skills/prd-generator/SKILL.md +26 -16
  110. package/templates/qoder/skills/prd-review/SKILL.md +5 -5
  111. package/templates/qoder/skills/prototype-generator/SKILL.md +256 -256
  112. package/templates/qoder/skills/spec-coder/SKILL.md +4 -4
  113. package/templates/qoder/skills/spec-generator/SKILL.md +4 -4
  114. package/templates/qoder/skills/test-generator/SKILL.md +5 -5
  115. package/templates/qoder/skills/wl-code/SKILL.md +4 -4
  116. package/templates/qoder/skills/wl-commit/SKILL.md +4 -4
  117. package/templates/qoder/skills/wl-design/SKILL.md +3 -3
  118. package/templates/qoder/skills/wl-init/SKILL.md +8 -8
  119. package/templates/qoder/skills/wl-insight/SKILL.md +5 -5
  120. package/templates/qoder/skills/wl-prd-full/SKILL.md +6 -6
  121. package/templates/qoder/skills/wl-prd-quick/SKILL.md +6 -6
  122. package/templates/qoder/skills/wl-prd-review/SKILL.md +4 -4
  123. package/templates/qoder/skills/wl-report/SKILL.md +7 -7
  124. package/templates/qoder/skills/wl-search/SKILL.md +13 -13
  125. package/templates/qoder/skills/wl-spec/SKILL.md +5 -5
  126. package/templates/qoder/skills/wl-status/SKILL.md +5 -5
  127. package/templates/qoder/skills/wl-task/SKILL.md +6 -6
  128. package/templates/qoder/skills/wl-test/SKILL.md +102 -39
  129. package/templates/root/AGENTS.md +39 -40
  130. package/templates/qoder/hooks/inject-workflow-state.py +0 -169
  131. package/templates/qoder/scripts/__pycache__/check_mcp_launch.cpython-39.pyc +0 -0
  132. package/templates/qoder/scripts/__pycache__/install_qoderwork.cpython-39.pyc +0 -0
  133. package/templates/qoder/scripts/__pycache__/mcp_launcher.cpython-39.pyc +0 -0
  134. package/templates/qoder/scripts/__pycache__/platform_doctor.cpython-39.pyc +0 -0
  135. package/templates/qoder/scripts/check_carriers.py +0 -238
  136. package/templates/qoder/scripts/check_mcp_launch.py +0 -183
  137. package/templates/qoder/scripts/check_qoderwork_consistency.py +0 -166
  138. package/templates/qoder/scripts/collect_prds.py +0 -31
  139. package/templates/qoder/scripts/common/mentions.py +0 -134
  140. package/templates/qoder/scripts/common/utf8.py +0 -38
  141. package/templates/qoder/scripts/extract_routes.py +0 -54
  142. package/templates/qoder/scripts/extract_routes_tree.py +0 -78
  143. package/templates/qoder/scripts/handoff.py +0 -22
  144. package/templates/qoder/scripts/init_developer.py +0 -76
  145. package/templates/qoder/scripts/parse_prds.py +0 -33
  146. package/templates/qoder/scripts/role.py +0 -51
  147. package/templates/qoder/scripts/sync_carriers.py +0 -259
  148. package/templates/qoder/scripts/task.py +0 -1261
  149. package/templates/qoder/scripts/workspace_init.py +0 -102
  150. package/templates/qoder/skills/prompt-enrich/SKILL.md +0 -90
  151. package/templates/qoder/skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp +0 -0
  152. /package/templates/qoder/scripts/{secure-ls.js → test/secure-ls.js} +0 -0
@@ -1,628 +1,634 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- status.py - 项目状态计算引擎 (支撑 /wl-status 命令)
5
-
6
- /wl-status markdown 指令由 AI 执行, 但数据计算 (健康分/周期时间/阻塞图/
7
- 截止日期) 由本脚本提供, 保证数据准确。
8
-
9
- Usage:
10
- python status.py # 全景: current + roadmap + health
11
- python status.py health # 只算健康分
12
- python status.py cycle # 只算周期时间 (从 stage_ts)
13
- python status.py deadlines [--days 7] # 未来 N 天的截止任务
14
- python status.py blocked # 被阻塞的任务图
15
- """
16
-
17
- import argparse
18
- import json
19
- import os
20
- import sys
21
- from datetime import datetime, date, timedelta
22
- from pathlib import Path
23
-
24
- try:
25
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
26
- except (AttributeError, TypeError, OSError):
27
- pass
28
-
29
- THIS_DIR = os.path.dirname(os.path.abspath(__file__))
30
- sys.path.insert(0, THIS_DIR)
31
- from common.paths import get_repo_root, get_developer, get_tasks_dir, MEMBERS_DIR
32
- from common.task_utils import load_task_json
33
- from common.atomicio import safe_read_json
34
-
35
- BASE = get_repo_root()
36
- INDEX_DIR = BASE / 'data' / 'index'
37
-
38
-
39
- def load_all_tasks():
40
- """加载所有活跃任务 (workspace/tasks/)。"""
41
- tasks_dir = get_tasks_dir(BASE)
42
- if not tasks_dir.is_dir():
43
- return {}
44
- out = {}
45
- for d in tasks_dir.iterdir():
46
- if d.is_dir():
47
- data = load_task_json(d)
48
- if data:
49
- out[d.name] = data
50
- return out
51
-
52
-
53
- # ============================================================
54
- # 周期时间分析 (B4: cycle time)
55
- # ============================================================
56
-
57
- def compute_cycle_times(tasks):
58
- """从 stage_ts 算各阶段耗时。
59
-
60
- Returns:
61
- {
62
- "avg_total_hours": float, # created -> completed 平均
63
- "avg_dev_hours": float, # started -> completed 平均 (纯开发)
64
- "samples": int, # 有完整时间戳的任务数
65
- "by_task": [{name, total_h, dev_h, ...}]
66
- }
67
- """
68
- samples = []
69
- for name, data in tasks.items():
70
- if data.get("status") != "completed":
71
- continue
72
- ts = data.get("stage_ts") or {}
73
- created = ts.get("created")
74
- started = ts.get("started")
75
- completed = ts.get("completed")
76
- if not created or not completed:
77
- continue
78
- try:
79
- t_created = datetime.fromisoformat(created)
80
- t_completed = datetime.fromisoformat(completed)
81
- total_h = (t_completed - t_created).total_seconds() / 3600
82
- dev_h = None
83
- if started:
84
- t_started = datetime.fromisoformat(started)
85
- dev_h = (t_completed - t_started).total_seconds() / 3600
86
- samples.append({
87
- "name": name,
88
- "title": data.get("title", name),
89
- "total_hours": round(total_h, 1),
90
- "dev_hours": round(dev_h, 1) if dev_h is not None else None,
91
- })
92
- except (ValueError, TypeError):
93
- continue
94
-
95
- if not samples:
96
- return {"avg_total_hours": 0, "avg_dev_hours": 0, "samples": 0, "by_task": []}
97
-
98
- avg_total = sum(s["total_hours"] for s in samples) / len(samples)
99
- dev_samples = [s for s in samples if s["dev_hours"] is not None]
100
- avg_dev = sum(s["dev_hours"] for s in dev_samples) / len(dev_samples) if dev_samples else 0
101
-
102
- return {
103
- "avg_total_hours": round(avg_total, 1),
104
- "avg_dev_hours": round(avg_dev, 1),
105
- "samples": len(samples),
106
- "by_task": sorted(samples, key=lambda x: -x["total_hours"])[:10],
107
- }
108
-
109
-
110
- # ============================================================
111
- # 截止日期提醒 (B4: deadlines)
112
- # ============================================================
113
-
114
- def compute_deadlines(tasks, days=7):
115
- """未来 N 天内到期的任务 + 已逾期任务。
116
-
117
- Returns:
118
- {
119
- "overdue": [...], # 已逾期未完成
120
- "due_soon": [...], # 未来 days 天内到期
121
- "no_date_count": int, # 无截止日期的活跃任务数
122
- }
123
- """
124
- today = date.today()
125
- horizon = today + timedelta(days=days)
126
- overdue = []
127
- due_soon = []
128
- no_date = 0
129
-
130
- for name, data in tasks.items():
131
- if data.get("status") == "completed":
132
- continue
133
- due = data.get("due_date")
134
- if not due:
135
- no_date += 1
136
- continue
137
- try:
138
- due_d = date.fromisoformat(due)
139
- except ValueError:
140
- continue
141
- item = {
142
- "name": name,
143
- "title": data.get("title", name),
144
- "due_date": due,
145
- "assignee": data.get("assignee", "?"),
146
- "priority": data.get("priority", "?"),
147
- "days_left": (due_d - today).days,
148
- }
149
- if due_d < today:
150
- overdue.append(item)
151
- elif due_d <= horizon:
152
- due_soon.append(item)
153
-
154
- overdue.sort(key=lambda x: x["days_left"])
155
- due_soon.sort(key=lambda x: x["days_left"])
156
- return {
157
- "overdue": overdue,
158
- "due_soon": due_soon,
159
- "no_date_count": no_date,
160
- }
161
-
162
-
163
- # ============================================================
164
- # 阻塞图 (B4: blocked)
165
- # ============================================================
166
-
167
- def compute_blocked_graph(tasks):
168
- """分析任务间的阻塞关系。
169
-
170
- Returns:
171
- {
172
- "blocked_tasks": [{name, blocked_by: [...open...], title}],
173
- "blocking_count": {blocker_name: count}, # 谁阻塞了最多任务
174
- }
175
- """
176
- blocked = []
177
- blocking_count = {}
178
- for name, data in tasks.items():
179
- if data.get("status") == "completed":
180
- continue
181
- blocked_by = data.get("blocked_by") or []
182
- open_blocks = []
183
- for dep in blocked_by:
184
- dep_data = tasks.get(dep)
185
- if not dep_data or dep_data.get("status") != "completed":
186
- open_blocks.append(dep)
187
- blocking_count[dep] = blocking_count.get(dep, 0) + 1
188
- if open_blocks:
189
- blocked.append({
190
- "name": name,
191
- "title": data.get("title", name),
192
- "blocked_by": open_blocks,
193
- "assignee": data.get("assignee", "?"),
194
- })
195
- return {
196
- "blocked_tasks": blocked,
197
- "blocking_count": dict(sorted(blocking_count.items(), key=lambda x: -x[1])[:5]),
198
- }
199
-
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
-
227
- # ============================================================
228
- # 健康分 (综合)
229
- # ============================================================
230
-
231
- def compute_health(tasks):
232
- """加权健康分 (满分 5)。
233
-
234
- 维度: EVA 合格率 / 索引新鲜度 / 按时交付 / 团队同步 / 流水线 / 学习
235
- """
236
- scores = {}
237
-
238
- # 1. EVA 合格率 (25%)
239
- # eval-history 是 jsonl (每行一个 JSON), 不能用 safe_read_json
240
- eval_path = BASE / '.qoder' / 'learning' / 'eval-history.jsonl'
241
- if eval_path.is_file():
242
- records = []
243
- try:
244
- with open(eval_path, encoding='utf-8') as f:
245
- for line in f:
246
- line = line.strip()
247
- if line:
248
- records.append(json.loads(line))
249
- recent = records[-10:]
250
- if recent:
251
- passed = sum(1 for r in recent if r.get('passed'))
252
- scores['eva'] = (passed / len(recent)) * 5
253
- else:
254
- scores['eva'] = 3.0 # 无数据, 中性
255
- except Exception:
256
- scores['eva'] = 3.0
257
- else:
258
- scores['eva'] = 3.0
259
-
260
- # 2. 索引新鲜度 (20%)
261
- meta = safe_read_json(INDEX_DIR / '.index-meta.json', default={}) or {}
262
- last_sync = meta.get('last_sync', '')
263
- try:
264
- ts = str(last_sync).replace('T', ' ').split('.')[0].strip()
265
- sync_dt = datetime.strptime(ts, '%Y-%m-%d %H:%M')
266
- age_days = (datetime.now() - sync_dt).days
267
- if age_days <= 7:
268
- scores['index'] = 5.0
269
- elif age_days <= 14:
270
- scores['index'] = 3.0
271
- else:
272
- scores['index'] = 1.0
273
- except Exception:
274
- scores['index'] = 2.0
275
-
276
- # 3. 按时交付 (20%) - 从 deadlines 算逾期率
277
- deadlines = compute_deadlines(tasks, days=0)
278
- total_with_due = len(deadlines['overdue']) + len(deadlines['due_soon']) + 1
279
- overdue_rate = len(deadlines['overdue']) / total_with_due if total_with_due else 0
280
- scores['on_time'] = max(0, 5 - overdue_rate * 10)
281
-
282
- # 4. 团队同步 (15%) - 简化: ahead 提交 = 有未同步
283
- scores['sync'] = 4.0 # 默认良好 (详细检查由 team_sync status 做)
284
-
285
- # 5. 流水线流转 (10%) - 任务在各阶段分布
286
- statuses = {}
287
- for data in tasks.values():
288
- s = data.get('status', '?')
289
- statuses[s] = statuses.get(s, 0) + 1
290
- # 有 in_progress 且不全卡 planning = 健康
291
- if statuses.get('in_progress', 0) > 0:
292
- scores['pipeline'] = 4.0
293
- elif statuses.get('planning', 0) > 0:
294
- scores['pipeline'] = 3.0
295
- else:
296
- scores['pipeline'] = 2.0
297
-
298
- # 6. 学习 (10%)
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
304
-
305
- # 加权
306
- weights = {
307
- 'eva': 0.25, 'index': 0.20, 'on_time': 0.20,
308
- 'sync': 0.15, 'pipeline': 0.10, 'learning': 0.10,
309
- }
310
- total = sum(scores[k] * weights[k] for k in weights)
311
- return {
312
- 'total': round(total, 2),
313
- 'scores': scores,
314
- 'weights': weights,
315
- 'verdict': '健康' if total >= 4 else ('有风险' if total >= 3 else '需关注'),
316
- }
317
-
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
-
504
- # ============================================================
505
- # 渲染
506
- # ============================================================
507
-
508
- def render_full():
509
- tasks = load_all_tasks()
510
- print('=' * 50)
511
- print('项目状态总览')
512
- print('=' * 50)
513
- print(f'\n活跃任务: {len(tasks)} 个')
514
- dev = get_developer(BASE)
515
- if dev:
516
- my_tasks = [n for n, d in tasks.items() if dev in (d.get('assignee'), d.get('creator'))]
517
- print(f'我的任务: {len(my_tasks)} 个 (开发者: {dev})')
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
-
530
- # 健康分
531
- print('\n--- 健康度 ---')
532
- health = compute_health(tasks)
533
- print(f"总分: {health['total']}/5 ({health['verdict']})")
534
- for k, v in health['scores'].items():
535
- w = health['weights'][k]
536
- print(f" {k:12} {v:.1f} × {w:.0%}")
537
-
538
- # 阻塞
539
- print('\n--- 阻塞图 ---')
540
- bg = compute_blocked_graph(tasks)
541
- if bg['blocked_tasks']:
542
- print(f"被阻塞的任务: {len(bg['blocked_tasks'])} ")
543
- for bt in bg['blocked_tasks'][:10]:
544
- print(f" {bt['name']} ({bt['assignee']}) <- {', '.join(bt['blocked_by'])}")
545
- if bg['blocking_count']:
546
- print(f"阻塞最多的: {bg['blocking_count']}")
547
- else:
548
- print('无被阻塞的任务')
549
-
550
- # 截止日期
551
- print('\n--- 截止日期 (未来 7 天) ---')
552
- dl = compute_deadlines(tasks, days=7)
553
- if dl['overdue']:
554
- print(f"⚠️ 已逾期: {len(dl['overdue'])} 个")
555
- for item in dl['overdue'][:5]:
556
- print(f" {item['name']} ({item['days_left']}天前) [{item['priority']}] {item['title']}")
557
- if dl['due_soon']:
558
- print(f"即将到期: {len(dl['due_soon'])} 个")
559
- for item in dl['due_soon'][:5]:
560
- print(f" {item['name']} (还剩{item['days_left']}天) [{item['priority']}] {item['title']}")
561
- if not dl['overdue'] and not dl['due_soon']:
562
- print('未来 7 天无截止任务')
563
- print(f"无截止日期的活跃任务: {dl['no_date_count']} 个")
564
-
565
- # 周期时间
566
- print('\n--- 周期时间 ---')
567
- ct = compute_cycle_times(tasks)
568
- if ct['samples'] > 0:
569
- print(f"已完成样本: {ct['samples']} 个")
570
- print(f"平均总周期: {ct['avg_total_hours']} 小时 ({ct['avg_total_hours']/24:.1f} 天)")
571
- print(f"平均开发时长: {ct['avg_dev_hours']} 小时")
572
- if ct['by_task']:
573
- print("最慢的 5 个:")
574
- for s in ct['by_task'][:5]:
575
- print(f" {s['name']}: {s['total_hours']}h (开发 {s['dev_hours']}h)")
576
- else:
577
- print('暂无已完成任务的时间戳数据 (stage_ts 在 B3 后才有)')
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
-
601
-
602
- def main():
603
- parser = argparse.ArgumentParser(description='项目状态计算')
604
- parser.add_argument('scope', nargs='?', default='all',
605
- choices=['all', 'health', 'cycle', 'deadlines', 'blocked'])
606
- parser.add_argument('--days', type=int, default=7, help='截止日期 horizon 天数')
607
- args = parser.parse_args()
608
-
609
- tasks = load_all_tasks()
610
-
611
- if args.scope == 'health':
612
- h = compute_health(tasks)
613
- print(json.dumps(h, indent=2, ensure_ascii=False))
614
- elif args.scope == 'cycle':
615
- c = compute_cycle_times(tasks)
616
- print(json.dumps(c, indent=2, ensure_ascii=False))
617
- elif args.scope == 'deadlines':
618
- d = compute_deadlines(tasks, args.days)
619
- print(json.dumps(d, indent=2, ensure_ascii=False))
620
- elif args.scope == 'blocked':
621
- b = compute_blocked_graph(tasks)
622
- print(json.dumps(b, indent=2, ensure_ascii=False))
623
- else:
624
- render_full()
625
-
626
-
627
- if __name__ == '__main__':
628
- main()
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
4
+ import os as _o, sys as _s
5
+ _cp = _o.path.join(_o.path.dirname(_o.path.dirname(_o.path.abspath(__file__))), 'common')
6
+ if _cp not in _s.path: _s.path.insert(0, _cp)
7
+ from bootstrap import setup; setup()
8
+
9
+ """
10
+ status.py - 项目状态计算引擎 (支撑 /wl-status 命令)
11
+
12
+ /wl-status markdown 指令由 AI 执行, 但数据计算 (健康分/周期时间/阻塞图/
13
+ 截止日期) 由本脚本提供, 保证数据准确。
14
+
15
+ Usage:
16
+ python status.py # 全景: current + roadmap + health
17
+ python status.py health # 只算健康分
18
+ python status.py cycle # 只算周期时间 (从 stage_ts)
19
+ python status.py deadlines [--days 7] # 未来 N 天的截止任务
20
+ python status.py blocked # 被阻塞的任务图
21
+ """
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import sys
27
+ from datetime import datetime, date, timedelta
28
+ from pathlib import Path
29
+
30
+ try:
31
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
32
+ except (AttributeError, TypeError, OSError):
33
+ pass
34
+
35
+ THIS_DIR = os.path.dirname(os.path.abspath(__file__))
36
+ sys.path.insert(0, THIS_DIR)
37
+ from common.paths import get_repo_root, get_developer, get_tasks_dir, MEMBERS_DIR
38
+ from common.task_utils import load_task_json
39
+ from common.atomicio import safe_read_json
40
+
41
+ BASE = get_repo_root()
42
+ INDEX_DIR = BASE / 'data' / 'index'
43
+
44
+
45
+ def load_all_tasks():
46
+ """加载所有活跃任务 (workspace/tasks/)。"""
47
+ tasks_dir = get_tasks_dir(BASE)
48
+ if not tasks_dir.is_dir():
49
+ return {}
50
+ out = {}
51
+ for d in tasks_dir.iterdir():
52
+ if d.is_dir():
53
+ data = load_task_json(d)
54
+ if data:
55
+ out[d.name] = data
56
+ return out
57
+
58
+
59
+ # ============================================================
60
+ # 周期时间分析 (B4: cycle time)
61
+ # ============================================================
62
+
63
+ def compute_cycle_times(tasks):
64
+ """从 stage_ts 算各阶段耗时。
65
+
66
+ Returns:
67
+ {
68
+ "avg_total_hours": float, # created -> completed 平均
69
+ "avg_dev_hours": float, # started -> completed 平均 (纯开发)
70
+ "samples": int, # 有完整时间戳的任务数
71
+ "by_task": [{name, total_h, dev_h, ...}]
72
+ }
73
+ """
74
+ samples = []
75
+ for name, data in tasks.items():
76
+ if data.get("status") != "completed":
77
+ continue
78
+ ts = data.get("stage_ts") or {}
79
+ created = ts.get("created")
80
+ started = ts.get("started")
81
+ completed = ts.get("completed")
82
+ if not created or not completed:
83
+ continue
84
+ try:
85
+ t_created = datetime.fromisoformat(created)
86
+ t_completed = datetime.fromisoformat(completed)
87
+ total_h = (t_completed - t_created).total_seconds() / 3600
88
+ dev_h = None
89
+ if started:
90
+ t_started = datetime.fromisoformat(started)
91
+ dev_h = (t_completed - t_started).total_seconds() / 3600
92
+ samples.append({
93
+ "name": name,
94
+ "title": data.get("title", name),
95
+ "total_hours": round(total_h, 1),
96
+ "dev_hours": round(dev_h, 1) if dev_h is not None else None,
97
+ })
98
+ except (ValueError, TypeError):
99
+ continue
100
+
101
+ if not samples:
102
+ return {"avg_total_hours": 0, "avg_dev_hours": 0, "samples": 0, "by_task": []}
103
+
104
+ avg_total = sum(s["total_hours"] for s in samples) / len(samples)
105
+ dev_samples = [s for s in samples if s["dev_hours"] is not None]
106
+ avg_dev = sum(s["dev_hours"] for s in dev_samples) / len(dev_samples) if dev_samples else 0
107
+
108
+ return {
109
+ "avg_total_hours": round(avg_total, 1),
110
+ "avg_dev_hours": round(avg_dev, 1),
111
+ "samples": len(samples),
112
+ "by_task": sorted(samples, key=lambda x: -x["total_hours"])[:10],
113
+ }
114
+
115
+
116
+ # ============================================================
117
+ # 截止日期提醒 (B4: deadlines)
118
+ # ============================================================
119
+
120
+ def compute_deadlines(tasks, days=7):
121
+ """未来 N 天内到期的任务 + 已逾期任务。
122
+
123
+ Returns:
124
+ {
125
+ "overdue": [...], # 已逾期未完成
126
+ "due_soon": [...], # 未来 days 天内到期
127
+ "no_date_count": int, # 无截止日期的活跃任务数
128
+ }
129
+ """
130
+ today = date.today()
131
+ horizon = today + timedelta(days=days)
132
+ overdue = []
133
+ due_soon = []
134
+ no_date = 0
135
+
136
+ for name, data in tasks.items():
137
+ if data.get("status") == "completed":
138
+ continue
139
+ due = data.get("due_date")
140
+ if not due:
141
+ no_date += 1
142
+ continue
143
+ try:
144
+ due_d = date.fromisoformat(due)
145
+ except ValueError:
146
+ continue
147
+ item = {
148
+ "name": name,
149
+ "title": data.get("title", name),
150
+ "due_date": due,
151
+ "assignee": data.get("assignee", "?"),
152
+ "priority": data.get("priority", "?"),
153
+ "days_left": (due_d - today).days,
154
+ }
155
+ if due_d < today:
156
+ overdue.append(item)
157
+ elif due_d <= horizon:
158
+ due_soon.append(item)
159
+
160
+ overdue.sort(key=lambda x: x["days_left"])
161
+ due_soon.sort(key=lambda x: x["days_left"])
162
+ return {
163
+ "overdue": overdue,
164
+ "due_soon": due_soon,
165
+ "no_date_count": no_date,
166
+ }
167
+
168
+
169
+ # ============================================================
170
+ # 阻塞图 (B4: blocked)
171
+ # ============================================================
172
+
173
+ def compute_blocked_graph(tasks):
174
+ """分析任务间的阻塞关系。
175
+
176
+ Returns:
177
+ {
178
+ "blocked_tasks": [{name, blocked_by: [...open...], title}],
179
+ "blocking_count": {blocker_name: count}, # 谁阻塞了最多任务
180
+ }
181
+ """
182
+ blocked = []
183
+ blocking_count = {}
184
+ for name, data in tasks.items():
185
+ if data.get("status") == "completed":
186
+ continue
187
+ blocked_by = data.get("blocked_by") or []
188
+ open_blocks = []
189
+ for dep in blocked_by:
190
+ dep_data = tasks.get(dep)
191
+ if not dep_data or dep_data.get("status") != "completed":
192
+ open_blocks.append(dep)
193
+ blocking_count[dep] = blocking_count.get(dep, 0) + 1
194
+ if open_blocks:
195
+ blocked.append({
196
+ "name": name,
197
+ "title": data.get("title", name),
198
+ "blocked_by": open_blocks,
199
+ "assignee": data.get("assignee", "?"),
200
+ })
201
+ return {
202
+ "blocked_tasks": blocked,
203
+ "blocking_count": dict(sorted(blocking_count.items(), key=lambda x: -x[1])[:5]),
204
+ }
205
+
206
+
207
+ # ============================================================
208
+ # 学习数据采集 (扫全部 dev journal, 修旧版路径 bug)
209
+ # ============================================================
210
+
211
+ def _count_all_feedback():
212
+ """统计全团队 feedback 事件总数 (扫 workspace/members/*/journal/feedback.jsonl)。
213
+
214
+ 旧版读 data/learning/feedback.jsonl (已废弃路径, 文件不存在) → 恒 0 → 学习度恒 2.0。
215
+ learn.py 早已迁移到 workspace/members/{dev}/journal/, 这里跟上。
216
+ """
217
+ if not MEMBERS_DIR.is_dir():
218
+ return 0
219
+ total = 0
220
+ for dev_dir in MEMBERS_DIR.iterdir():
221
+ if not dev_dir.is_dir():
222
+ continue
223
+ fb = dev_dir / 'journal' / 'feedback.jsonl'
224
+ if fb.is_file():
225
+ try:
226
+ with open(fb, encoding='utf-8') as f:
227
+ total += sum(1 for line in f if line.strip())
228
+ except Exception:
229
+ continue
230
+ return total
231
+
232
+
233
+ # ============================================================
234
+ # 健康分 (综合)
235
+ # ============================================================
236
+
237
+ def compute_health(tasks):
238
+ """加权健康分 (满分 5)
239
+
240
+ 维度: EVA 合格率 / 索引新鲜度 / 按时交付 / 团队同步 / 流水线 / 学习
241
+ """
242
+ scores = {}
243
+
244
+ # 1. EVA 合格率 (25%)
245
+ # eval-history jsonl (每行一个 JSON), 不能用 safe_read_json
246
+ eval_path = BASE / '.qoder' / 'learning' / 'eval-history.jsonl'
247
+ if eval_path.is_file():
248
+ records = []
249
+ try:
250
+ with open(eval_path, encoding='utf-8') as f:
251
+ for line in f:
252
+ line = line.strip()
253
+ if line:
254
+ records.append(json.loads(line))
255
+ recent = records[-10:]
256
+ if recent:
257
+ passed = sum(1 for r in recent if r.get('passed'))
258
+ scores['eva'] = (passed / len(recent)) * 5
259
+ else:
260
+ scores['eva'] = 3.0 # 无数据, 中性
261
+ except Exception:
262
+ scores['eva'] = 3.0
263
+ else:
264
+ scores['eva'] = 3.0
265
+
266
+ # 2. 索引新鲜度 (20%)
267
+ meta = safe_read_json(INDEX_DIR / '.index-meta.json', default={}) or {}
268
+ last_sync = meta.get('last_sync', '')
269
+ try:
270
+ ts = str(last_sync).replace('T', ' ').split('.')[0].strip()
271
+ sync_dt = datetime.strptime(ts, '%Y-%m-%d %H:%M')
272
+ age_days = (datetime.now() - sync_dt).days
273
+ if age_days <= 7:
274
+ scores['index'] = 5.0
275
+ elif age_days <= 14:
276
+ scores['index'] = 3.0
277
+ else:
278
+ scores['index'] = 1.0
279
+ except Exception:
280
+ scores['index'] = 2.0
281
+
282
+ # 3. 按时交付 (20%) - deadlines 算逾期率
283
+ deadlines = compute_deadlines(tasks, days=0)
284
+ total_with_due = len(deadlines['overdue']) + len(deadlines['due_soon']) + 1
285
+ overdue_rate = len(deadlines['overdue']) / total_with_due if total_with_due else 0
286
+ scores['on_time'] = max(0, 5 - overdue_rate * 10)
287
+
288
+ # 4. 团队同步 (15%) - 简化: 有 ahead 提交 = 有未同步
289
+ scores['sync'] = 4.0 # 默认良好 (详细检查由 team_sync status 做)
290
+
291
+ # 5. 流水线流转 (10%) - 任务在各阶段分布
292
+ statuses = {}
293
+ for data in tasks.values():
294
+ s = data.get('status', '?')
295
+ statuses[s] = statuses.get(s, 0) + 1
296
+ # 有 in_progress 且不全卡 planning = 健康
297
+ if statuses.get('in_progress', 0) > 0:
298
+ scores['pipeline'] = 4.0
299
+ elif statuses.get('planning', 0) > 0:
300
+ scores['pipeline'] = 3.0
301
+ else:
302
+ scores['pipeline'] = 2.0
303
+
304
+ # 6. 学习 (10%)
305
+ # 修 bug: 旧代码读 data/learning/feedback.jsonl (旧路径, 已废弃),
306
+ # learn.py 实际写 workspace/members/{dev}/journal/feedback.jsonl。
307
+ # 迁移了写路径没迁移读路径 学习度恒为 2.0。改为扫全部 dev 的 journal。
308
+ fb_count = _count_all_feedback()
309
+ scores['learning'] = min(5.0, 2.0 + fb_count * 0.1) if fb_count else 2.0
310
+
311
+ # 加权
312
+ weights = {
313
+ 'eva': 0.25, 'index': 0.20, 'on_time': 0.20,
314
+ 'sync': 0.15, 'pipeline': 0.10, 'learning': 0.10,
315
+ }
316
+ total = sum(scores[k] * weights[k] for k in weights)
317
+ return {
318
+ 'total': round(total, 2),
319
+ 'scores': scores,
320
+ 'weights': weights,
321
+ 'verdict': '健康' if total >= 4 else ('有风险' if total >= 3 else '需关注'),
322
+ }
323
+
324
+
325
+ # ============================================================
326
+ # 流水线自检 (测工具本身, 不测产出) —— 可观测性闭环
327
+ # ============================================================
328
+
329
+ def _scan_skill_instrumentation():
330
+ """扫 .qoder/skills/*/SKILL.md, 统计有多少 skill 调了 learn.py record (已埋点)。
331
+ 返回 (已埋点skill列表, 全部skill列表)。
332
+ """
333
+ skills_dir = BASE / '.qoder' / 'skills'
334
+ if not skills_dir.is_dir():
335
+ return [], []
336
+ all_skills = []
337
+ instrumented = []
338
+ for d in sorted(skills_dir.iterdir()):
339
+ if not d.is_dir():
340
+ continue
341
+ skill_md = d / 'SKILL.md'
342
+ if not skill_md.is_file():
343
+ continue
344
+ all_skills.append(d.name)
345
+ try:
346
+ text = skill_md.read_text(encoding='utf-8', errors='ignore')
347
+ except Exception:
348
+ continue
349
+ if 'learn.py' in text and 'record' in text:
350
+ instrumented.append(d.name)
351
+ return instrumented, all_skills
352
+
353
+
354
+ def _analyze_eva_rounds():
355
+ """从 eval-history.jsonl 分析 EVA 修正轮数。
356
+ 返回 {samples, rework_count, avg_rounds}。
357
+ rework = 连续 passed=false 后出现 passed=true 的序列。
358
+ """
359
+ eval_path = BASE / '.qoder' / 'learning' / 'eval-history.jsonl'
360
+ if not eval_path.is_file():
361
+ return {'samples': 0, 'rework_count': 0, 'avg_rounds': 0}
362
+ records = []
363
+ try:
364
+ with open(eval_path, encoding='utf-8') as f:
365
+ for line in f:
366
+ line = line.strip()
367
+ if line:
368
+ try:
369
+ records.append(json.loads(line))
370
+ except Exception:
371
+ continue
372
+ except Exception:
373
+ return {'samples': 0, 'rework_count': 0, 'avg_rounds': 0}
374
+
375
+ recent = records[-20:]
376
+ rework = 0
377
+ total_fail_before_pass = 0
378
+ fails_in_run = 0
379
+ for r in recent:
380
+ if r.get('passed'):
381
+ if fails_in_run > 0:
382
+ rework += 1
383
+ total_fail_before_pass += fails_in_run
384
+ fails_in_run = 0
385
+ else:
386
+ fails_in_run += 1
387
+ avg_rounds = (total_fail_before_pass / rework + 1) if rework else 0
388
+ return {
389
+ 'samples': len(recent),
390
+ 'rework_count': rework,
391
+ 'avg_rounds': round(avg_rounds, 1),
392
+ }
393
+
394
+
395
+ def _count_rule_violations():
396
+ """统计规则违规事件 (没问平台就调脚本)
397
+ 扫全部 dev 的 journal 里 event=rule_violation 的条数。
398
+ """
399
+ if not MEMBERS_DIR.is_dir():
400
+ return 0
401
+ count = 0
402
+ for dev_dir in MEMBERS_DIR.iterdir():
403
+ if not dev_dir.is_dir():
404
+ continue
405
+ fb = dev_dir / 'journal' / 'feedback.jsonl'
406
+ if not fb.is_file():
407
+ continue
408
+ try:
409
+ with open(fb, encoding='utf-8') as f:
410
+ for line in f:
411
+ line = line.strip()
412
+ if not line:
413
+ continue
414
+ try:
415
+ if json.loads(line).get('event') == 'rule_violation':
416
+ count += 1
417
+ except Exception:
418
+ continue
419
+ except Exception:
420
+ continue
421
+ return count
422
+
423
+
424
+ def compute_pipeline_self():
425
+ """流水线自检 —— 4 个指标全从已有数据派生, 不增加 PM 操作。
426
+
427
+ 返回:
428
+ instrumentation: {done, total, missing} 埋点覆盖率
429
+ eva: {samples, rework_count, avg_rounds} EVA 修正轮数
430
+ violations: int 规则违规次数
431
+ verdict: str 一句话结论
432
+ """
433
+ done, total = _scan_skill_instrumentation()
434
+ missing = [s for s in total if s not in done]
435
+ eva = _analyze_eva_rounds()
436
+ violations = _count_rule_violations()
437
+
438
+ parts = []
439
+ cov_pct = (len(done) / len(total) * 100) if total else 0
440
+ if cov_pct < 50:
441
+ parts.append('埋点覆盖偏低')
442
+ if eva['rework_count'] > 0 and eva['avg_rounds'] > 2:
443
+ parts.append('EVA 修正轮数偏高')
444
+ if violations > 0:
445
+ parts.append('存在规则违规')
446
+ verdict = ''.join(parts) if parts else '正常'
447
+
448
+ return {
449
+ 'instrumentation': {'done': len(done), 'total': len(total),
450
+ 'missing': missing, 'pct': round(cov_pct)},
451
+ 'eva': eva,
452
+ 'violations': violations,
453
+ 'verdict': verdict,
454
+ }
455
+
456
+
457
+ # ============================================================
458
+ # 角色引导 (该角色该用哪些命令 —— 软引导, 帮新人快速上手)
459
+ # ============================================================
460
+
461
+ def compute_role_guide():
462
+ """读当前角色 + config.yaml commands 映射, 返回该角色的建议命令。
463
+ 软引导: 不拦截任何命令, 只是把"你该用什么"摆出来。
464
+ """
465
+ # 当前角色 (v3.0: 角色能力已并入 common/identity.py)
466
+ role = None
467
+ try:
468
+ sys.path.insert(0, str(BASE / '.qoder' / 'scripts'))
469
+ from common.identity import get_role
470
+ role = get_role()
471
+ except Exception:
472
+ pass
473
+ if not role:
474
+ role = 'pm' # 默认
475
+
476
+ # 读 config.yaml 的 commands 映射
477
+ commands = []
478
+ role_name = role
479
+ try:
480
+ import yaml
481
+ cfg_path = BASE / '.qoder' / 'config.yaml'
482
+ if cfg_path.is_file():
483
+ with open(cfg_path, encoding='utf-8') as f:
484
+ cfg = yaml.safe_load(f) or {}
485
+ roles = cfg.get('roles', {})
486
+ rinfo = roles.get(role, {})
487
+ commands = rinfo.get('commands', [])
488
+ role_name = rinfo.get('name', role)
489
+ except Exception:
490
+ pass
491
+
492
+ # 校验建议命令是否真实存在 (command 文件在不在)
493
+ available = []
494
+ missing = []
495
+ cmds_dir = BASE / '.qoder' / 'commands'
496
+ for cmd in commands:
497
+ exists = (cmds_dir / (cmd + '.md')).is_file() or \
498
+ (cmds_dir / 'optional' / (cmd + '.md')).is_file()
499
+ (available if exists else missing).append(cmd)
500
+
501
+ return {
502
+ 'role': role,
503
+ 'role_name': role_name,
504
+ 'suggested': commands,
505
+ 'available': available,
506
+ 'missing': missing,
507
+ }
508
+
509
+
510
+ # ============================================================
511
+ # 渲染
512
+ # ============================================================
513
+
514
+ def render_full():
515
+ tasks = load_all_tasks()
516
+ print('=' * 50)
517
+ print('项目状态总览')
518
+ print('=' * 50)
519
+ print(f'\n活跃任务: {len(tasks)} 个')
520
+ dev = get_developer(BASE)
521
+ if dev:
522
+ my_tasks = [n for n, d in tasks.items() if dev in (d.get('assignee'), d.get('creator'))]
523
+ print(f'我的任务: {len(my_tasks)} 个 (开发者: {dev})')
524
+
525
+ # 角色引导 (该角色该用哪些命令)
526
+ rg = compute_role_guide()
527
+ print(f'当前角色: {rg["role_name"]} ({rg["role"]})')
528
+ if rg['suggested']:
529
+ # 标注哪些命令当前可用
530
+ avail = rg['available']
531
+ print(f' 建议命令 ({len(avail)}/{len(rg["suggested"])} 可用): '
532
+ + ' '.join('/' + c for c in avail))
533
+ if rg['missing']:
534
+ print(f' ⚠ 建议但未安装: {", ".join(rg["missing"])}')
535
+
536
+ # 健康分
537
+ print('\n--- 健康度 ---')
538
+ health = compute_health(tasks)
539
+ print(f"总分: {health['total']}/5 ({health['verdict']})")
540
+ for k, v in health['scores'].items():
541
+ w = health['weights'][k]
542
+ print(f" {k:12} {v:.1f} × {w:.0%}")
543
+
544
+ # 阻塞
545
+ print('\n--- 阻塞图 ---')
546
+ bg = compute_blocked_graph(tasks)
547
+ if bg['blocked_tasks']:
548
+ print(f"被阻塞的任务: {len(bg['blocked_tasks'])} 个")
549
+ for bt in bg['blocked_tasks'][:10]:
550
+ print(f" {bt['name']} ({bt['assignee']}) <- {', '.join(bt['blocked_by'])}")
551
+ if bg['blocking_count']:
552
+ print(f"阻塞最多的: {bg['blocking_count']}")
553
+ else:
554
+ print('无被阻塞的任务')
555
+
556
+ # 截止日期
557
+ print('\n--- 截止日期 (未来 7 天) ---')
558
+ dl = compute_deadlines(tasks, days=7)
559
+ if dl['overdue']:
560
+ print(f"⚠️ 已逾期: {len(dl['overdue'])} ")
561
+ for item in dl['overdue'][:5]:
562
+ print(f" {item['name']} ({item['days_left']}天前) [{item['priority']}] {item['title']}")
563
+ if dl['due_soon']:
564
+ print(f"即将到期: {len(dl['due_soon'])} 个")
565
+ for item in dl['due_soon'][:5]:
566
+ print(f" {item['name']} (还剩{item['days_left']}天) [{item['priority']}] {item['title']}")
567
+ if not dl['overdue'] and not dl['due_soon']:
568
+ print('未来 7 天无截止任务')
569
+ print(f"无截止日期的活跃任务: {dl['no_date_count']} 个")
570
+
571
+ # 周期时间
572
+ print('\n--- 周期时间 ---')
573
+ ct = compute_cycle_times(tasks)
574
+ if ct['samples'] > 0:
575
+ print(f"已完成样本: {ct['samples']} ")
576
+ print(f"平均总周期: {ct['avg_total_hours']} 小时 ({ct['avg_total_hours']/24:.1f} 天)")
577
+ print(f"平均开发时长: {ct['avg_dev_hours']} 小时")
578
+ if ct['by_task']:
579
+ print("最慢的 5 个:")
580
+ for s in ct['by_task'][:5]:
581
+ print(f" {s['name']}: {s['total_hours']}h (开发 {s['dev_hours']}h)")
582
+ else:
583
+ print('暂无已完成任务的时间戳数据 (stage_ts B3 后才有)')
584
+
585
+ # 流水线自检 (测工具本身)
586
+ print('\n--- 流水线自检 (测工具本身) ---')
587
+ ps = compute_pipeline_self()
588
+ inst = ps['instrumentation']
589
+ cov_mark = '✓' if inst['pct'] >= 50 else '⚠'
590
+ print(f" 埋点覆盖: {inst['done']}/{inst['total']} skill ({inst['pct']}%) {cov_mark}")
591
+ if inst['missing']:
592
+ # 只列前 5 个, 避免刷屏
593
+ shown = inst['missing'][:5]
594
+ more = '' if len(inst['missing']) <= 5 else f"{len(inst['missing'])}"
595
+ print(f" 缺埋点: {', '.join(shown)}{more}")
596
+ eva = ps['eva']
597
+ if eva['samples'] > 0:
598
+ eva_mark = '✓' if eva['avg_rounds'] <= 2 else '⚠'
599
+ print(f" EVA 修正: 近{eva['samples']}次 {eva['rework_count']}次打回, "
600
+ f"平均{eva['avg_rounds']}轮过 {eva_mark}")
601
+ else:
602
+ print(' EVA 修正: 暂无评估历史')
603
+ v_mark = '' if ps['violations'] == 0 else '⚠'
604
+ print(f" 规则违规: {ps['violations']} 次 {v_mark}")
605
+ print(f" 自检结论: {ps['verdict']}")
606
+
607
+
608
+ def main():
609
+ parser = argparse.ArgumentParser(description='项目状态计算')
610
+ parser.add_argument('scope', nargs='?', default='all',
611
+ choices=['all', 'health', 'cycle', 'deadlines', 'blocked'])
612
+ parser.add_argument('--days', type=int, default=7, help='截止日期 horizon 天数')
613
+ args = parser.parse_args()
614
+
615
+ tasks = load_all_tasks()
616
+
617
+ if args.scope == 'health':
618
+ h = compute_health(tasks)
619
+ print(json.dumps(h, indent=2, ensure_ascii=False))
620
+ elif args.scope == 'cycle':
621
+ c = compute_cycle_times(tasks)
622
+ print(json.dumps(c, indent=2, ensure_ascii=False))
623
+ elif args.scope == 'deadlines':
624
+ d = compute_deadlines(tasks, args.days)
625
+ print(json.dumps(d, indent=2, ensure_ascii=False))
626
+ elif args.scope == 'blocked':
627
+ b = compute_blocked_graph(tasks)
628
+ print(json.dumps(b, indent=2, ensure_ascii=False))
629
+ else:
630
+ render_full()
631
+
632
+
633
+ if __name__ == '__main__':
634
+ main()