@hupan56/wlkj 3.1.27 → 3.1.29

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 (37) hide show
  1. package/package.json +1 -1
  2. package/templates/qoder/commands/wl-task.md +11 -4
  3. package/templates/qoder/scripts/capability/smoke_test_report.json +12 -12
  4. package/templates/qoder/scripts/capability/smoke_test_report.json.new +94 -0
  5. package/templates/qoder/scripts/deployment/setup/init_doctor.py +20 -6
  6. package/templates/qoder/scripts/domain/report/report_snapshot.py +87 -6
  7. package/templates/qoder/scripts/domain/task/team_sync.py +34 -2
  8. package/templates/qoder/scripts/domain/task/zentao_sync.py +6 -5
  9. package/templates/qoder/scripts/foundation/integrations/zentao_client.py +38 -0
  10. package/templates/qoder/scripts/protocol/mcp/mcp_doctor.py +14 -2
  11. package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +211 -92
  12. package/templates/root/AGENTS.md +12 -12
  13. package/templates/qoder/contracts/CHANGELOG.md +0 -418
  14. package/templates/qoder/contracts/README.md +0 -184
  15. package/templates/qoder/contracts/code.md +0 -81
  16. package/templates/qoder/contracts/commit.md +0 -86
  17. package/templates/qoder/contracts/contract-header.md +0 -76
  18. package/templates/qoder/contracts/design.md +0 -106
  19. package/templates/qoder/contracts/fallback.md +0 -126
  20. package/templates/qoder/contracts/isolation.md +0 -119
  21. package/templates/qoder/contracts/prd.md +0 -118
  22. package/templates/qoder/contracts/schemas/design-spec.schema.json +0 -46
  23. package/templates/qoder/contracts/schemas/prd.schema.json +0 -36
  24. package/templates/qoder/contracts/schemas/test-cases.schema.json +0 -40
  25. package/templates/qoder/contracts/spec.md +0 -81
  26. package/templates/qoder/contracts/task.md +0 -125
  27. package/templates/qoder/contracts/test.md +0 -112
  28. package/templates/qoder/scripts/domain/task/__pycache__/syncgate.cpython-39.pyc +0 -0
  29. package/templates/qoder/scripts/foundation/__pycache__/__init__.cpython-39.pyc +0 -0
  30. package/templates/qoder/scripts/foundation/__pycache__/bootstrap.cpython-39.pyc +0 -0
  31. package/templates/qoder/scripts/foundation/core/__pycache__/__init__.cpython-39.pyc +0 -0
  32. package/templates/qoder/scripts/foundation/core/__pycache__/paths.cpython-39.pyc +0 -0
  33. package/templates/qoder/scripts/foundation/identity/__pycache__/__init__.cpython-39.pyc +0 -0
  34. package/templates/qoder/scripts/foundation/identity/__pycache__/identity.cpython-39.pyc +0 -0
  35. package/templates/qoder/scripts/foundation/io/__pycache__/__init__.cpython-39.pyc +0 -0
  36. package/templates/qoder/scripts/foundation/io/__pycache__/atomicio.cpython-39.pyc +0 -0
  37. package/templates/qoder/scripts/foundation/io/__pycache__/filelock.cpython-39.pyc +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hupan56/wlkj",
3
- "version": "3.1.27",
3
+ "version": "3.1.29",
4
4
  "description": "AI Product R&D Workflow - PRD/Prototype/Search/Task/Report",
5
5
  "bin": {
6
6
  "wlkj": "bin/cli.js"
@@ -43,14 +43,21 @@ PY=$(python --version >/dev/null 2>&1 && echo python || echo python3)
43
43
  - **建任务**:`create_task` 的 `assignedTo` **默认指派给当前开发者**(从 member.json 的 zentao_account),显式传参才派别人。
44
44
  - 隔离红线见 `contracts/isolation.md`「禅道同步」。
45
45
 
46
- ### 第 0 步:探测禅道可达性
47
- 调用禅道健康检查(QoderWork: `qw_mcp_call("check_zentao_status", {})` / CLI: `cap.mcp.call("check_zentao_status")`)——内网 + 凭据一道探测:
46
+ ### 第 0 步:按操作类型决定要不要探测禅道
47
+
48
+ 禅道工具自带内网/凭据门(不通返回友好提示)。**读类操作直接调,省掉探测往返**:
49
+
50
+ | 操作类型 | 探测? | 走法 |
51
+ |---------|:----:|------|
52
+ | **读**(看我的活/列任务/看详情/Bug状态) | ❌ 不探测 | 直接 `my_workbench`/`list_my_tasks`/`get_*`,门会兜底 |
53
+ | **写**(create/start/finish/assign/resolve/改/删) | ✅ 探测 | 先 `check_zentao_status`,或写前 `get_*` 确认归属时顺手探,避免写一半断网 |
54
+ | 用户明说"离线/先存本地" | — | 即使可达也走本地模式 |
55
+
48
56
  - **✅ 可达** → 走**禅道模式**(下方各动作的「禅道走法」)
49
57
  - **❌ 不可达 / 未配置** → 走**本地模式**(下方各动作的原有 `task.py` 走法,离线兜底,不报错不阻塞)
50
- - **用户明确说"离线"/"先存本地"** → 即使禅道可达也走本地模式
51
58
 
52
59
  > 这套判定让禅道有就同步、没内网就静默回退 —— 符合 AGENTS.md「本地数据是唯一事实源、连接器是可选增强、断网不阻塞」原则。
53
- > 探测本身要 1 次调用。**高频场景(看我的活)可省略探测**:直接 my_workbench,它内部自带内网/凭据门,不通会返回友好提示,不浪费一次往返。
60
+ > 高频的"看我的活"现在是 **1 次调用直达**(旧版 probe + workbench 两次),思考链更短。
54
61
 
55
62
  ### 列表勾选式交互(禅道模式的统一范式)
56
63
 
@@ -1,10 +1,10 @@
1
1
  {
2
- "timestamp": "2026-06-26 21:25:55",
2
+ "timestamp": "2026-06-29 11:20:28",
3
3
  "servers": {
4
4
  "qoder-knowledge-graph": {
5
5
  "available": true,
6
6
  "tools_indexed": 17,
7
- "latency_ms": 437.0,
7
+ "latency_ms": 484.0,
8
8
  "sample_tools": [
9
9
  "search_code",
10
10
  "search_api",
@@ -12,13 +12,13 @@
12
12
  "get_impact",
13
13
  "context_360"
14
14
  ],
15
- "spawn_pid": 38616,
15
+ "spawn_pid": 66880,
16
16
  "status": "PASS"
17
17
  },
18
18
  "qoder-zentao": {
19
19
  "available": true,
20
- "tools_indexed": 66,
21
- "latency_ms": 3657.0,
20
+ "tools_indexed": 68,
21
+ "latency_ms": 766.0,
22
22
  "sample_tools": [
23
23
  "check_zentao_status",
24
24
  "get_zentao_url",
@@ -26,13 +26,13 @@
26
26
  "get_product",
27
27
  "create_product"
28
28
  ],
29
- "spawn_pid": 11224,
29
+ "spawn_pid": 48620,
30
30
  "status": "PASS"
31
31
  },
32
32
  "qoder-mysql": {
33
33
  "available": true,
34
34
  "tools_indexed": 6,
35
- "latency_ms": 3484.0,
35
+ "latency_ms": 609.0,
36
36
  "sample_tools": [
37
37
  "list_envs",
38
38
  "query_schema",
@@ -40,13 +40,13 @@
40
40
  "query_distinct",
41
41
  "list_databases"
42
42
  ],
43
- "spawn_pid": 37868,
43
+ "spawn_pid": 67572,
44
44
  "status": "PASS"
45
45
  },
46
46
  "lanhu": {
47
47
  "available": true,
48
48
  "tools_indexed": 13,
49
- "latency_ms": 3156.0,
49
+ "latency_ms": 3313.0,
50
50
  "sample_tools": [
51
51
  "lanhu_resolve_invite_link",
52
52
  "lanhu_list_product_documents",
@@ -54,13 +54,13 @@
54
54
  "lanhu_get_ai_analyze_page_result",
55
55
  "lanhu_get_designs"
56
56
  ],
57
- "spawn_pid": 36836,
57
+ "spawn_pid": 32460,
58
58
  "status": "PASS"
59
59
  }
60
60
  },
61
61
  "call_tool": {
62
62
  "status": "PASS",
63
- "latency_ms": 3953.0,
63
+ "latency_ms": 3547.0,
64
64
  "text_preview": "Chinese: 保险 -> English: ins insurance vehlife/insurance\n\n[Carmg-H5] 12 files\n Carmg-H5/src/.../main/List.vue -> data/code/Carmg-H5/src/modules/icsAbnormalCenter/module/main/List.vue\n Carmg-H5/src/",
65
65
  "is_error": false
66
66
  },
@@ -89,6 +89,6 @@
89
89
  "all_pass": true,
90
90
  "active_servers": 4,
91
91
  "total_servers": 4,
92
- "total_tools": 102
92
+ "total_tools": 104
93
93
  }
94
94
  }
@@ -0,0 +1,94 @@
1
+ {
2
+ "timestamp": "2026-06-26 21:25:55",
3
+ "servers": {
4
+ "qoder-knowledge-graph": {
5
+ "available": true,
6
+ "tools_indexed": 17,
7
+ "latency_ms": 437.0,
8
+ "sample_tools": [
9
+ "search_code",
10
+ "search_api",
11
+ "search_prd",
12
+ "get_impact",
13
+ "context_360"
14
+ ],
15
+ "spawn_pid": 38616,
16
+ "status": "PASS"
17
+ },
18
+ "qoder-zentao": {
19
+ "available": true,
20
+ "tools_indexed": 66,
21
+ "latency_ms": 3657.0,
22
+ "sample_tools": [
23
+ "check_zentao_status",
24
+ "get_zentao_url",
25
+ "list_products",
26
+ "get_product",
27
+ "create_product"
28
+ ],
29
+ "spawn_pid": 11224,
30
+ "status": "PASS"
31
+ },
32
+ "qoder-mysql": {
33
+ "available": true,
34
+ "tools_indexed": 6,
35
+ "latency_ms": 3484.0,
36
+ "sample_tools": [
37
+ "list_envs",
38
+ "query_schema",
39
+ "query_data",
40
+ "query_distinct",
41
+ "list_databases"
42
+ ],
43
+ "spawn_pid": 37868,
44
+ "status": "PASS"
45
+ },
46
+ "lanhu": {
47
+ "available": true,
48
+ "tools_indexed": 13,
49
+ "latency_ms": 3156.0,
50
+ "sample_tools": [
51
+ "lanhu_resolve_invite_link",
52
+ "lanhu_list_product_documents",
53
+ "lanhu_get_pages",
54
+ "lanhu_get_ai_analyze_page_result",
55
+ "lanhu_get_designs"
56
+ ],
57
+ "spawn_pid": 36836,
58
+ "status": "PASS"
59
+ }
60
+ },
61
+ "call_tool": {
62
+ "status": "PASS",
63
+ "latency_ms": 3953.0,
64
+ "text_preview": "Chinese: 保险 -> English: ins insurance vehlife/insurance\n\n[Carmg-H5] 12 files\n Carmg-H5/src/.../main/List.vue -> data/code/Carmg-H5/src/modules/icsAbnormalCenter/module/main/List.vue\n Carmg-H5/src/",
65
+ "is_error": false
66
+ },
67
+ "memory": {
68
+ "available": true,
69
+ "providers": [
70
+ {
71
+ "provider": "McpMemoryProvider",
72
+ "available": true,
73
+ "hit": true
74
+ },
75
+ {
76
+ "provider": "FileMemoryCap",
77
+ "available": true,
78
+ "hit": false
79
+ },
80
+ {
81
+ "provider": "NoOpMemoryCap",
82
+ "available": false,
83
+ "hit": false
84
+ }
85
+ ],
86
+ "status": "PASS"
87
+ },
88
+ "summary": {
89
+ "all_pass": true,
90
+ "active_servers": 4,
91
+ "total_servers": 4,
92
+ "total_tools": 102
93
+ }
94
+ }
@@ -485,19 +485,33 @@ def check_index(fix):
485
485
  print('\n--- 5. 知识图谱 (data/index/) ---')
486
486
  age = index_age_days()
487
487
  if age is None:
488
- # 完全缺失 -> 需要全量 (唯一允许"重头再来"的情形)
488
+ # 完全缺失 先尝试 team_sync pull 拉管理员建好的 kg.duckdb
489
+ if fix:
490
+ print(' 索引缺失, 先从团队仓库拉取 (team_sync pull)...')
491
+ if run_script('team_sync.py', 'pull', timeout=120):
492
+ # pull 成功, 检查 kg.duckdb 是否拉到了
493
+ age2 = index_age_days()
494
+ if age2 is not None:
495
+ say(OK, '已从团队仓库拉取知识图谱 ({:.1f} 天前构建)'.format(age2))
496
+ actions.append('从团队拉取知识图谱')
497
+ return
498
+ # pull 了但 kg.duckdb 还是没到(管理员还没建过?)
499
+ print(' team_sync pull 完成, 但 kg.duckdb 未在仓库中')
500
+ else:
501
+ say(WARN, 'team_sync pull 失败 (离线? 冲突?)')
502
+
503
+ # pull 失败或没拉到 → 降级: 有源码就本地全量构建
489
504
  if _has_source_code():
490
- # 有源码 → 可以建索引
491
505
  if fix:
492
- print(' 索引缺失, 全量构建 (一次性, 之后都是增量) ...')
506
+ print(' 降级: 本地全量构建 (一次性, 之后都是增量) ...')
493
507
  if run_script('git_sync.py', '--index-only'):
494
- actions.append('全量构建知识图谱')
508
+ actions.append('全量构建知识图谱 (本地降级)')
495
509
  else:
496
510
  problem('索引构建失败, 查看上方报错')
497
511
  else:
498
- problem('知识图谱不存在', '加 --fix 全量构建 (仅首次)')
512
+ problem('知识图谱不存在', '加 --fix: 先拉团队, 拉不到再本地构建')
499
513
  else:
500
- # 无源码 → 建不了索引, 醒目告警 (不能只说"先解决第4项", 太含糊)
514
+ # 无源码 → 建不了索引
501
515
  problem('索引缺失且无源码 (data/code/ 为空)',
502
516
  '无法构建知识图谱。看下方"降级状态"说明, 配源码后重跑 init')
503
517
  return
@@ -11,6 +11,7 @@ report_snapshot.py — /wl-report 的一次性数据聚合器 (性能优化)
11
11
  3. journal/feedback — 今天的学习/反馈事件
12
12
  4. git log --author — 我的提交 (扫 data/code/ 各项目)
13
13
  5. drafts/ + specs/prd — 我写的 PRD/草稿
14
+ 6. zentao 我的任务 — 禅道真实工时(consumed), 周报"完成任务/工时"用此(可选, 不通降级)
14
15
 
15
16
  输出: JSON, 缺失的字段标 null 并注明原因。AI 拿到后直接写报告, 不用再探查。
16
17
  所有数据严格按 .developer 里的开发者名过滤 (report 的隔离铁律)。
@@ -204,6 +205,83 @@ def _journal_today(repo, author, days):
204
205
  return events[:100] if events else []
205
206
 
206
207
 
208
+ def _f(v):
209
+ """安全转 float (工时聚合用), 失败返回 0。"""
210
+ try:
211
+ return float(v or 0)
212
+ except (TypeError, ValueError):
213
+ return 0.0
214
+
215
+
216
+ def _zentao_my_tasks(repo, days):
217
+ """从禅道拉'我的'任务(含真实 consumed 工时), 供周报用。
218
+
219
+ 周报的"完成任务/工时"应基于禅道真实数据, 而非本地 task.json 缓存
220
+ (本地缓存常滞后/缺工时)。禅道不通/未配置/未识别身份 → 返回 None,
221
+ 静默降级不阻塞周报 (符合"连接器可选增强、断网不阻塞")。
222
+ """
223
+ scripts = os.path.join(repo, ".qoder", "scripts")
224
+ if scripts not in sys.path:
225
+ sys.path.insert(0, scripts)
226
+ try:
227
+ from foundation.integrations.zentao_client import ZentaoClient
228
+ from foundation.core.paths import get_developer
229
+ from foundation.identity.identity import get_member
230
+ except Exception:
231
+ return None
232
+ client = ZentaoClient.from_config()
233
+ if client is None:
234
+ return None
235
+ try:
236
+ ok, _ = client.check_intranet()
237
+ if not ok:
238
+ return None
239
+ dev = get_developer()
240
+ my_acc = ""
241
+ if dev:
242
+ my_acc = (get_member(dev) or {}).get("zentao_account", "")
243
+ if not my_acc:
244
+ return None
245
+ r = client.api("GET", "/executions", {"limit": 200})
246
+ if r.status_code not in (200, 201):
247
+ return None
248
+ execs = r.json().get("executions") if isinstance(r.json(), dict) else None
249
+ if not isinstance(execs, list):
250
+ return None
251
+ mine = []
252
+ for e in execs:
253
+ eid = e.get("id")
254
+ try:
255
+ r2 = client.api("GET", "/executions/%s/tasks" % eid, {"limit": 200})
256
+ except Exception:
257
+ continue
258
+ if r2.status_code not in (200, 201):
259
+ continue
260
+ tasks = r2.json().get("tasks") if isinstance(r2.json(), dict) else None
261
+ if not isinstance(tasks, list):
262
+ continue
263
+ for t in tasks:
264
+ if isinstance(t, dict) and ZentaoClient.assignee_account(t) == my_acc:
265
+ mine.append({
266
+ "id": t.get("id"), "name": str(t.get("name", ""))[:40],
267
+ "status": t.get("status"), "consumed": t.get("consumed"),
268
+ "estimate": t.get("estimate"), "left": t.get("left"),
269
+ "deadline": t.get("deadline"), "execution": e.get("name", ""),
270
+ })
271
+ except Exception:
272
+ return None
273
+ done = [x for x in mine if x.get("status") in ("done", "closed")]
274
+ return {
275
+ "my_account": my_acc,
276
+ "total": len(mine),
277
+ "done_count": len(done),
278
+ "consumed_hours": round(sum(_f(x.get("consumed")) for x in mine), 1),
279
+ "estimate_hours": round(sum(_f(x.get("estimate")) for x in mine), 1),
280
+ "active_items": [x for x in mine if x.get("status") not in ("done", "closed")][:30],
281
+ "note": "禅道真实工时(非本地缓存); 周报'完成任务/工时'用 consumed_hours",
282
+ }
283
+
284
+
207
285
  def snapshot(repo=None, days=None):
208
286
  """收集 wl-report 需要的全部数据, 返回 dict。"""
209
287
  repo = repo or _repo_root()
@@ -226,12 +304,13 @@ def snapshot(repo=None, days=None):
226
304
  # 2-5 四个独立数据源并行采集 (task list 最慢~440ms, 并行后总耗时≈最慢的那个)
227
305
  # task list / git log / journal / prds 互不依赖 → 用线程池同时跑, 砍掉串行等待。
228
306
  from concurrent.futures import ThreadPoolExecutor
229
- with ThreadPoolExecutor(max_workers=4) as pool:
307
+ with ThreadPoolExecutor(max_workers=5) as pool:
230
308
  f_tasks = pool.submit(_run_script, ".qoder/scripts/domain/task/task.py", repo,
231
309
  ["list", "--mine"])
232
310
  f_journal = pool.submit(_journal_today, repo, developer, days)
233
311
  f_commits = pool.submit(_git_log_for_author, repo, developer, days)
234
312
  f_prds = pool.submit(_list_prds_by_author, repo, developer, days)
313
+ f_zentao = pool.submit(_zentao_my_tasks, repo, days)
235
314
 
236
315
  task_out, task_ok = f_tasks.result()
237
316
  data["my_tasks"] = task_out if task_ok else None
@@ -239,6 +318,7 @@ def snapshot(repo=None, days=None):
239
318
  data["journal"] = f_journal.result()
240
319
  data["commits"] = f_commits.result()
241
320
  data["my_prds"] = f_prds.result()
321
+ data["zentao_tasks"] = f_zentao.result() # 禅道真实工时(周报用); None=未连通/未配置
242
322
 
243
323
  # 6. 报告日期范围 (供 AI 套模板)
244
324
  today = datetime.now().strftime("%Y-%m-%d")
@@ -249,11 +329,12 @@ def snapshot(repo=None, days=None):
249
329
 
250
330
 
251
331
  def main():
252
- repo = None
253
- args = [a for a in sys.argv[1:] if not a.startswith("--")]
254
- if args:
255
- repo = args[0]
256
- data = snapshot(repo)
332
+ import argparse
333
+ p = argparse.ArgumentParser(description='wl-report 数据快照 (一次性聚合全部数据源)')
334
+ p.add_argument('repo', nargs='?', help='仓库根路径 (默认从当前目录向上找 .qoder)')
335
+ p.add_argument('--days', type=int, default=1, help='回看天数 (默认1=今天/日报; 7=周报)')
336
+ a = p.parse_args()
337
+ data = snapshot(a.repo, a.days)
257
338
  print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
258
339
 
259
340
 
@@ -267,13 +267,45 @@ def _do_pull_locked(quiet, branch):
267
267
  """锁内的实际 pull 逻辑。"""
268
268
  r = git('pull', '--rebase', '--autostash', 'origin', branch, timeout=90)
269
269
  if r.returncode != 0:
270
+ err = (r.stderr or '') + (r.stdout or '')
270
271
  if r.returncode == 124:
271
272
  print('Pull 超时 (网络/认证问题): ' + (r.stderr or '').strip()[:150])
272
273
  print('检查 git 凭证或团队仓库连通性。继续离线工作, 下次同步自动补推。')
273
274
  return 1
274
- if 'CONFLICT' in (r.stdout + r.stderr) or rebase_in_progress():
275
+ if 'CONFLICT' in err or rebase_in_progress():
275
276
  return report_conflict(r.stderr or r.stdout)
276
- print('Pull failed (network/remote?): ' + (r.stderr or r.stdout).strip()[:200])
277
+ # 常见问题: untracked working tree files would be overwritten
278
+ # 那台机器上有 init 生成的未跟踪文件 (.gitignore, .qoder/.engine-*),
279
+ # git pull --rebase 拒绝覆盖。解决: git checkout 先强制覆盖未跟踪文件。
280
+ if 'untracked working tree files would be overwritten' in err:
281
+ print('检测到未跟踪文件冲突, 尝试强制覆盖...')
282
+ # 提取冲突文件名
283
+ conflict_files = []
284
+ for line in err.splitlines():
285
+ line = line.strip()
286
+ if line and not line.startswith('error:') and not line.startswith('Please'):
287
+ conflict_files.append(line)
288
+ # git checkout -- <files> 强制覆盖
289
+ if conflict_files:
290
+ git('checkout', '--', *conflict_files[:50], timeout=30)
291
+ # 重试 pull
292
+ r2 = git('pull', '--rebase', '--autostash', 'origin', branch, timeout=90)
293
+ if r2.returncode == 0:
294
+ _auto_resolve_kg_conflict()
295
+ touch_pull_marker()
296
+ if not quiet:
297
+ print('Pulled latest from team (强制覆盖未跟踪文件后成功)。')
298
+ return 0
299
+ print('强制覆盖后仍失败: ' + (r2.stderr or '').strip()[:200])
300
+ else:
301
+ # 直接 git checkout -f 强制
302
+ git('checkout', '-f', 'origin/' + branch, timeout=60)
303
+ r2 = git('reset', '--hard', 'origin/' + branch, timeout=60)
304
+ if r2.returncode == 0:
305
+ if not quiet:
306
+ print('Pulled latest from team (强制重置后成功)。')
307
+ return 0
308
+ print('Pull failed (network/remote?): ' + err.strip()[:200])
277
309
  print('继续离线工作, 产出不会丢失, 下次同步会自动补推。')
278
310
  return 1
279
311
 
@@ -137,7 +137,7 @@ def cmd_pull(args, creds, my_account):
137
137
  print('[DRY-RUN] 仅预览, 不写本地。加 --apply 才真写。\n')
138
138
 
139
139
  r = creds.api('GET', '/executions/%s/tasks' % eid, {'limit': 100})
140
- if r.status_code != 200:
140
+ if r.status_code not in (200, 201):
141
141
  print('查询失败: %s %s' % (r.status_code, r.text[:120]))
142
142
  return 3
143
143
  data = r.json()
@@ -146,9 +146,10 @@ def cmd_pull(args, creds, my_account):
146
146
  print('响应格式异常 (无 tasks 列表)。')
147
147
  return 3
148
148
 
149
- # ── 归属硬过滤: 只取 assignedTo == 我 ──
149
+ # ── 归属硬过滤: 只取 assignedTo == 我 (用 ZentaoClient.assignee_account,
150
+ # 兼容禅道 assignedTo 返回 dict 的格式; 旧版 str(dict)==account 永远不匹配 → 拉到 0 个) ──
150
151
  mine = [t for t in tasks if isinstance(t, dict)
151
- and str(t.get('assignedTo', '')) == my_account]
152
+ and ZentaoClient.assignee_account(t) == my_account]
152
153
  skipped = len(tasks) - len(mine)
153
154
  print('执行 #%s 共 %d 个任务, 归属我的 %d 个 (跳过队友 %d 个, 不拉)。\n'
154
155
  % (eid, len(tasks), len(mine), skipped))
@@ -225,8 +226,8 @@ def cmd_push(args, creds, my_account, developer):
225
226
  if zid:
226
227
  # 已有 zentao_id → 更新前校验归属一致性
227
228
  chk = creds.api('GET', '/tasks/%s' % zid)
228
- if chk.status_code == 200:
229
- remote_assign = str(chk.json().get('assignedTo', ''))
229
+ if chk.status_code in (200, 201):
230
+ remote_assign = ZentaoClient.assignee_account(chk.json())
230
231
  if remote_assign != my_account:
231
232
  print(' 🚫 拒绝更新 #%s「%s」: 禅道 assignedTo=%s ≠ 你(%s)'
232
233
  ' (防误改队友任务)' % (zid, title, remote_assign, my_account))
@@ -241,3 +241,41 @@ class ZentaoClient:
241
241
  return r.json().get('error', r.text[:120])
242
242
  except Exception:
243
243
  return r.text[:120]
244
+
245
+ # ------------------------------------------------------------------
246
+ # 归属解析 (纯数据提取, sync + mcp 共用 —— 消除 str(dict)==account bug 根因)
247
+ # ------------------------------------------------------------------
248
+
249
+ @staticmethod
250
+ def _account_field(item, field: str) -> str:
251
+ """从 item 提取账号字段 (兼容禅道两种返回格式)。
252
+
253
+ 禅道 API 的 assignedTo/openedBy 可能是:
254
+ - dict: {'id':14,'account':'zhengkang','realname':'郑康'} (列表接口常见)
255
+ - str: 'zhengkang' (详情接口常见)
256
+ - None/其它: 返回空串
257
+
258
+ 历史 bug: 旧代码用 ``str(dict)==account`` 比较, dict 被转成
259
+ ``"{'account':'zhengkang'}"`` 永远 != 'zhengkang' → 归属过滤全失效
260
+ (sync pull 永远拉 0 个; push 永远误判归属不一致而拒绝更新)。
261
+ 本方法统一取 account 子字段, 是归属判定的唯一真相源。
262
+ """
263
+ if not isinstance(item, dict):
264
+ return ''
265
+ v = item.get(field)
266
+ if isinstance(v, dict):
267
+ return str(v.get('account', ''))
268
+ if isinstance(v, str):
269
+ return v
270
+ return ''
271
+
272
+ @staticmethod
273
+ def assignee_account(item) -> str:
274
+ """指派人账号 (assignedTo)。task/bug/story 的归属主字段。"""
275
+ return ZentaoClient._account_field(item, 'assignedTo')
276
+
277
+ @staticmethod
278
+ def openedby_account(item) -> str:
279
+ """创建人账号 (openedBy)。bug/story 归属的放宽字段
280
+ (团队习惯提了 bug 不指派, 留在创建人头上)。"""
281
+ return ZentaoClient._account_field(item, 'openedBy')
@@ -381,10 +381,22 @@ def find_python_with(mod):
381
381
  return None
382
382
 
383
383
 
384
+ def _find_launcher():
385
+ """定位 mcp_launcher.py 真实位置。
386
+
387
+ launcher 由 QoderWork 安装到用户级 ~/.qoderwork/ (不在仓库内),
388
+ 兼容少数仓库内就近部署的情况。返回 (路径, 是否存在)。
389
+ """
390
+ for cand in (QODERWORK_DIR / 'mcp_launcher.py', SCRIPTS_DIR / 'mcp_launcher.py'):
391
+ if cand.is_file():
392
+ return cand, True
393
+ return QODERWORK_DIR / 'mcp_launcher.py', False
394
+
395
+
384
396
  def test_mcp(name):
385
397
  """测试一个 MCP 能否正常响应 initialize."""
386
- launcher = SCRIPTS_DIR / 'mcp_launcher.py'
387
- if not launcher.is_file():
398
+ launcher, exists = _find_launcher()
399
+ if not exists:
388
400
  return {'name': name, 'status': 'ERROR',
389
401
  'detail': f'launcher 不存在: {launcher}'}
390
402