@hupan56/wlkj 2.7.11 → 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 +375 -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} +147 -0
  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,1261 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- QODER Pipeline - 任务生命周期管理
5
-
6
- 完整的任务 CRUD 和生命周期管理:
7
- - create: 创建任务 (含 task.json, prd.md 模板, implement.jsonl, check.jsonl)
8
- - start: 开始任务 (设置活跃任务, 状态 planning -> in_progress)
9
- - current: 查看当前活跃任务
10
- - finish: 完成任务 (清除活跃任务)
11
- - archive: 归档任务 (移动到 archive/ 目录)
12
- - list: 列出活跃/归档任务
13
- - show: 查看任务详情
14
- - reassign: 改派任务负责人 (多角色协作, PM 建任务后转给开发)
15
- - add-subtask / remove-subtask: 父子任务关联
16
- - set-due / block / unblock / gantt: 排期与依赖
17
-
18
- 参考: Trellis 的 task.py 设计
19
-
20
- Usage:
21
- python task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3]
22
- python task.py start <name>
23
- python task.py current [--source]
24
- python task.py finish
25
- python task.py archive <name>
26
- python task.py reassign <name> <new_assignee>
27
- python task.py list [--mine] [--status <s>]
28
- python task.py add-subtask <parent> <child>
29
- python task.py remove-subtask <parent> <child>
30
- """
31
-
32
- from __future__ import annotations
33
-
34
- import argparse
35
- import json
36
- import os
37
- import shutil
38
- import sys
39
- from datetime import datetime
40
- from pathlib import Path
41
-
42
- # 将 scripts 目录加入路径
43
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
44
-
45
- from common.paths import (
46
- DIR_TASKS,
47
- DIR_ARCHIVE,
48
- FILE_TASK_JSON,
49
- get_repo_root,
50
- get_developer,
51
- get_tasks_dir,
52
- )
53
- from common.active_task import (
54
- clear_active_task,
55
- resolve_active_task,
56
- resolve_context_key,
57
- set_active_task,
58
- )
59
- from common.task_utils import (
60
- resolve_task_dir,
61
- run_task_hooks,
62
- load_task_json,
63
- write_task_json,
64
- modify_task_json,
65
- assert_can_modify_task,
66
- )
67
-
68
-
69
- # =============================================================================
70
- # 命令: create
71
- # =============================================================================
72
-
73
- def cmd_create(args: argparse.Namespace) -> int:
74
- """创建新任务。
75
-
76
- 创建:
77
- - .qoder/tasks/<slug>/ 目录
78
- - task.json (元数据)
79
- - prd.md (空模板)
80
- - implement.jsonl (实现上下文, 空文件)
81
- - check.jsonl (检查上下文, 空文件)
82
- """
83
- repo_root = get_repo_root()
84
- title = args.title
85
- slug = args.slug or _generate_slug(title)
86
- assignee = args.assignee or get_developer(repo_root)
87
- priority = args.priority or "P2"
88
-
89
- # 生成日期前缀: MM-DD
90
- date_prefix = datetime.now().strftime("%m-%d")
91
- task_dir_name = f"{date_prefix}-{slug}"
92
-
93
- tasks_dir = get_tasks_dir(repo_root)
94
- task_dir = tasks_dir / task_dir_name
95
-
96
- # 撞名处理: 同日同名 (两个 PM 都建"登录优化") 自动加 creator 后缀, 不阻塞
97
- if task_dir.exists():
98
- creator_tag = (assignee or "x")[:8]
99
- # 加 creator 短标记; 仍撞则加 -2/-3
100
- candidate = f"{date_prefix}-{slug}-{creator_tag}"
101
- n = 2
102
- while (tasks_dir / candidate).exists():
103
- candidate = f"{date_prefix}-{slug}-{creator_tag}-{n}"
104
- n += 1
105
- if n > 99: # 防御性上限
106
- print(f"Error: Task name collision exhausted: {slug}", file=sys.stderr)
107
- return 1
108
- task_dir_name = candidate
109
- task_dir = tasks_dir / task_dir_name
110
- print(f"Note: 同名任务已存在, 自动加后缀: {task_dir_name}")
111
-
112
- # 创建目录
113
- task_dir.mkdir(parents=True, exist_ok=True)
114
-
115
- # 创建 task.json
116
- now_iso = datetime.now().isoformat()
117
- task_data = {
118
- "title": title,
119
- "slug": slug,
120
- "status": "planning",
121
- "priority": priority,
122
- "assignee": assignee,
123
- "creator": assignee,
124
- "created_at": now_iso,
125
- "updated_at": now_iso,
126
- "branch": None,
127
- "base_branch": None,
128
- "scope": None,
129
- "parent": None,
130
- "children": [],
131
- "tags": [],
132
- # B1 能力增强字段 (向后兼容: 旧 task.json 不含这些, 读时取默认值)
133
- "due_date": None, # 截止日期 YYYY-MM-DD (可选)
134
- "start_date": None, # 开始日期 YYYY-MM-DD (可选)
135
- "estimate_hours": None, # 预估工时 (可选)
136
- "blocked_by": [], # 依赖的任务 (数组, 自动维护反向 blocks)
137
- "blocks": [], # 被哪些任务依赖 (自动维护)
138
- # B3 阶段时间戳 (周期时间分析用)
139
- "stage_ts": {
140
- "created": now_iso,
141
- "started": None, # cmd_start 时填
142
- "completed": None, # cmd_finish 时填
143
- },
144
- }
145
- write_task_json(task_dir, task_data)
146
-
147
- # 创建 prd.md 模板
148
- prd_file = task_dir / "prd.md"
149
- prd_content = f"""# PRD: {title}
150
-
151
- > Status: planning
152
- > Assignee: {assignee or 'unassigned'}
153
- > Priority: {priority}
154
- > Created: {datetime.now().strftime("%Y-%m-%d %H:%M")}
155
-
156
- ---
157
-
158
- ## Background
159
-
160
- <!-- Describe the business context -->
161
-
162
- ## Requirements
163
-
164
- <!-- List functional requirements -->
165
-
166
- ## Acceptance Criteria
167
-
168
- <!-- Define testable acceptance criteria -->
169
-
170
- ## Notes
171
-
172
- <!-- Additional notes -->
173
- """
174
- prd_file.write_text(prd_content, encoding="utf-8")
175
-
176
- # 创建空的 JSONL 上下文文件
177
- (task_dir / "implement.jsonl").write_text("", encoding="utf-8")
178
- (task_dir / "check.jsonl").write_text("", encoding="utf-8")
179
-
180
- print(f"Task created: {task_dir_name}")
181
- print(f" Title: {title}")
182
- print(f" Assignee: {assignee or 'unassigned'}")
183
- print(f" Priority: {priority}")
184
- print(f" Path: workspace/tasks/{task_dir_name}")
185
-
186
- # 运行 after_create 钩子
187
- run_task_hooks("after_create", task_dir / FILE_TASK_JSON, repo_root)
188
-
189
- return 0
190
-
191
-
192
- # =============================================================================
193
- # 命令: start
194
- # =============================================================================
195
-
196
- def cmd_start(args: argparse.Namespace) -> int:
197
- """设置活跃任务, 状态改为 in_progress。"""
198
- repo_root = get_repo_root()
199
- task_input = args.dir
200
-
201
- if not task_input:
202
- print("Error: task directory or name required", file=sys.stderr)
203
- return 1
204
-
205
- full_path = resolve_task_dir(task_input, repo_root)
206
-
207
- if not full_path.is_dir():
208
- print(f"Error: Task not found: {task_input}", file=sys.stderr)
209
- return 1
210
-
211
- # 零信任 ACL: 只有 creator/assignee/admin 能 start
212
- try:
213
- assert_can_modify_task(full_path, "start", repo_root)
214
- except PermissionError as e:
215
- print(str(e), file=sys.stderr)
216
- return 4 # authz_denied
217
-
218
- # 转为相对路径
219
- try:
220
- task_dir = full_path.relative_to(repo_root).as_posix()
221
- except ValueError:
222
- task_dir = str(full_path)
223
-
224
- # 设置活跃任务
225
- active = set_active_task(task_dir, repo_root)
226
- if active:
227
- print(f"Current task set to: {task_dir}")
228
- print(f"Source: {active.source}")
229
-
230
- # 更新 task.json 状态 (锁内 load-modify-write, 防并发丢更新)
231
- def _start_mutator(data):
232
- if data.get("status") == "planning":
233
- now_iso = datetime.now().isoformat()
234
- data["status"] = "in_progress"
235
- data["updated_at"] = now_iso
236
- stage_ts = data.get("stage_ts") or {}
237
- stage_ts["started"] = now_iso
238
- data["stage_ts"] = stage_ts
239
- print("Status: planning -> in_progress")
240
- modify_task_json(full_path, _start_mutator)
241
-
242
- # 运行 after_start 钩子
243
- run_task_hooks("after_start", full_path / FILE_TASK_JSON, repo_root)
244
- return 0
245
- else:
246
- print("Error: Failed to set current task", file=sys.stderr)
247
- return 1
248
-
249
-
250
- # =============================================================================
251
- # 命令: current
252
- # =============================================================================
253
-
254
- def cmd_current(args: argparse.Namespace) -> int:
255
- """显示当前活跃任务。"""
256
- repo_root = get_repo_root()
257
- active = resolve_active_task(repo_root)
258
-
259
- if args.source:
260
- print(f"Current task: {active.task_path or '(none)'}")
261
- print(f"Source: {active.source}")
262
- return 0 if active.task_path else 1
263
-
264
- if active.task_path:
265
- print(active.task_path)
266
- return 0
267
-
268
- print("No active task", file=sys.stderr)
269
- return 1
270
-
271
-
272
- # =============================================================================
273
- # 命令: finish
274
- # =============================================================================
275
-
276
- def cmd_finish(args: argparse.Namespace) -> int:
277
- """完成当前任务 (状态改为 completed, 清除活跃任务指针)。"""
278
- repo_root = get_repo_root()
279
- active = clear_active_task(repo_root)
280
- current = active.task_path
281
-
282
- if not current:
283
- print("No current task set")
284
- return 0
285
-
286
- print(f"Cleared current task (was: {current})")
287
- print(f"Source: {active.source}")
288
-
289
- # 更新 task.json 状态为 completed
290
- task_dir = repo_root / current
291
-
292
- # 零信任 ACL: 只有 creator/assignee/admin 能 finish
293
- try:
294
- assert_can_modify_task(task_dir, "finish", repo_root)
295
- except PermissionError as e:
296
- print(str(e), file=sys.stderr)
297
- # 已清了 active 指针, 但不改 status —— 让用户重新 start 再由正确的人 finish
298
- return 4 # authz_denied
299
-
300
- # 锁内 load-modify-write, 防并发丢更新; 副作用 (飞书) 在锁外做
301
- _finish_ctx = {}
302
- def _finish_mutator(data):
303
- if data.get("status") != "completed":
304
- now_iso = datetime.now().isoformat()
305
- data["status"] = "completed"
306
- data["updated_at"] = now_iso
307
- stage_ts = data.get("stage_ts") or {}
308
- stage_ts["completed"] = now_iso
309
- data["stage_ts"] = stage_ts
310
- print("Status: -> completed")
311
- _finish_ctx["now_iso"] = now_iso
312
- _finish_ctx["stage_ts"] = stage_ts
313
- _finish_ctx["assignee"] = data.get("assignee", "?")
314
- modify_task_json(task_dir, _finish_mutator)
315
-
316
- # D2: 飞书通知 (任务完成) —— 在锁外, 用 mutator 捕获的值
317
- if _finish_ctx:
318
- try:
319
- from common.feishu import notify_task_finished
320
- hours = None
321
- started = _finish_ctx["stage_ts"].get("started")
322
- if started:
323
- try:
324
- from datetime import datetime as _dt
325
- t_start = _dt.fromisoformat(started)
326
- t_end = _dt.fromisoformat(_finish_ctx["now_iso"])
327
- hours = (t_end - t_start).total_seconds() / 3600
328
- except (ValueError, TypeError):
329
- pass
330
- notify_task_finished(task_dir.name, _finish_ctx["assignee"], hours)
331
- except Exception:
332
- pass # 飞书失败不阻塞任务完成
333
-
334
- task_json_path = task_dir / FILE_TASK_JSON
335
- if task_json_path.is_file():
336
- run_task_hooks("after_finish", task_json_path, repo_root)
337
-
338
- # 🧩 测试交接画像 (可选增强): finish 后在任务目录生成 test-handoff.md,
339
- # 列出涉及的页面/按钮/接口, 供测试人员照着写用例。失败绝不阻塞任务完成。
340
- try:
341
- _generate_test_handoff(task_dir, repo_root)
342
- except Exception as e:
343
- print("[测试交接画像] 跳过 (不阻塞): " + str(e)[:80])
344
-
345
- # 埋点: 任务完成反馈给 learning 引擎
346
- if _finish_ctx:
347
- try:
348
- from learn import record_feedback
349
- record_feedback('task_completed', {
350
- 'task': task_dir.name,
351
- 'assignee': _finish_ctx.get('assignee', '?'),
352
- 'hours': _finish_ctx.get('now_iso'),
353
- })
354
- except Exception:
355
- pass # 埋点失败不阻塞任务完成
356
-
357
- return 0
358
-
359
-
360
- def _extract_keywords(task_dir: Path) -> list:
361
- """从任务目录名 + task.json 标题 + PRD 正文提取业务关键词。
362
- 返回去重的关键词列表 (用于 page_probe 查页面画像)。
363
- """
364
- kws = []
365
- # 1. 任务目录名: 06-11-智能排班-环卫行业 → 智能排班, 环卫行业
366
- dirname = task_dir.name
367
- # 去掉日期前缀 (MM-DD- 或 MM-DD-)
368
- parts = dirname.split('-')
369
- # 找到第一个非纯数字段开始
370
- biz_parts = []
371
- started = False
372
- for p in parts:
373
- if not started:
374
- if p.isdigit() and len(p) <= 2:
375
- continue # 跳过 MM, DD
376
- started = True
377
- if p.strip():
378
- biz_parts.append(p.strip())
379
- if biz_parts:
380
- kws.append(''.join(biz_parts)) # 整体: 智能排班环卫行业
381
- if len(biz_parts) > 1:
382
- kws.extend(biz_parts) # 分开: 智能排班, 环卫行业
383
-
384
- # 2. task.json 的 title 字段
385
- title = ''
386
- try:
387
- tj = task_dir / FILE_TASK_JSON
388
- if tj.is_file():
389
- data = load_task_json(task_dir) or {}
390
- title = data.get('title', '')
391
- if title and title not in kws:
392
- kws.append(title)
393
- except Exception:
394
- pass
395
-
396
- # 2.5 从标题/目录名里切出短业务词 (保险异常筛选 → 保险, 异常, 筛选)
397
- # page_probe 用 LIKE 查 vue 路径, 短词命中率更高
398
- import re
399
- cn_chunks = re.findall(r'[\u4e00-\u9fa5]+', (dirname + title))
400
- short_words = set()
401
- for chunk in cn_chunks:
402
- # 2-4 字的连续子串都是有意义的业务词候选
403
- if 2 <= len(chunk) <= 4:
404
- short_words.add(chunk)
405
- elif len(chunk) > 4:
406
- # 长词切前4字、后4字、中间4字 (覆盖"智能排班系统"→智能排班/排班系统)
407
- short_words.add(chunk[:4])
408
- short_words.add(chunk[-4:])
409
- # 去太泛的词
410
- generic = {'功能', '系统', '管理', '模块', '页面', '需求', '设计', '实现',
411
- '新增', '修改', '查询', '删除', '操作', '记录', '列表', '详情'}
412
- for w in short_words:
413
- if w not in generic and w not in kws:
414
- kws.append(w)
415
-
416
- # 3. PRD 正文里的业务关键词 (从标题和需求段落粗提)
417
- try:
418
- prd = task_dir / 'prd.md'
419
- if prd.is_file():
420
- content = prd.read_text(encoding='utf-8', errors='ignore')
421
- import re
422
- # 提取中文词组 (2-6 字连续中文)
423
- cn_words = re.findall(r'[\u4e00-\u9fa5]{2,6}', content)
424
- # 去停用词 + 高频词
425
- stop = {'功能', '需求', '系统', '页面', '数据', '支持', '可以',
426
- '进行', '操作', '显示', '一个', '这个', '实现', '业务',
427
- '测试', '使用', '如果', '需要', '必须', '以及', '界面'}
428
- from collections import Counter
429
- freq = Counter(w for w in cn_words if w not in stop)
430
- for w, _ in freq.most_common(5):
431
- if w not in kws and len(w) >= 2:
432
- kws.append(w)
433
- except Exception:
434
- pass
435
-
436
- # 去重保序, 过滤太短的
437
- seen = set()
438
- result = []
439
- for k in kws:
440
- if k and len(k) >= 2 and k not in seen:
441
- seen.add(k)
442
- result.append(k)
443
- return result[:6] # 最多 6 个, 避免查太多
444
-
445
-
446
- def _generate_test_handoff(task_dir: Path, repo_root) -> None:
447
- """finish 后生成 test-handoff.md (开发→测试交接画像)。
448
- 从任务关键词查页面按钮+接口画像, 写入任务目录。无图谱/无匹配则跳过。
449
- """
450
- try:
451
- import page_probe
452
- except ImportError:
453
- sys.path.insert(0, str(repo_root / '.qoder' / 'scripts'))
454
- import page_probe
455
-
456
- kws = _extract_keywords(task_dir)
457
- if not kws:
458
- print("[测试交接画像] 跳过: 任务无明确业务关键词")
459
- return
460
-
461
- # 用每个关键词查画像, 合并结果
462
- all_pages = []
463
- seen_vue = set()
464
- used_kws = []
465
- for kw in kws:
466
- profile = page_probe.build_page_profile(keyword=kw, repo_root=str(repo_root))
467
- if not profile.get('ok'):
468
- continue
469
- for pg in profile['pages']:
470
- if pg['vue_file'] in seen_vue:
471
- continue
472
- seen_vue.add(pg['vue_file'])
473
- all_pages.append(pg)
474
- used_kws.append(kw)
475
- if len(all_pages) >= 12: # 够多了, 停止查更多关键词
476
- break
477
-
478
- handoff_path = task_dir / 'test-handoff.md'
479
- if not all_pages:
480
- # 没匹配到页面: 写一个提示性 handoff (让测试知道开发想覆盖什么)
481
- lines = [
482
- '# 测试交接: %s' % task_dir.name,
483
- '',
484
- '> 任务完成于 %s' % datetime.now().strftime('%Y-%m-%d %H:%M'),
485
- '> 业务关键词: %s' % ', '.join(kws),
486
- '',
487
- '## ⚠️ 未能自动生成页面画像',
488
- '',
489
- '知识图谱里没有匹配这些关键词的页面按钮记录。可能原因:',
490
- '- 功能是全新的, 代码还没被知识图谱索引',
491
- '- 关键词与代码里的命名不一致',
492
- '',
493
- '**测试建议:** 手动确认涉及的页面, 或用 `autotest.py page --kw <准确关键词>` 重试。',
494
- ]
495
- handoff_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
496
- print("[测试交接画像] %s (无匹配页面, 已写提示)" % handoff_path.name)
497
- return
498
-
499
- # 渲染完整画像
500
- total_btns = sum(len(pg['buttons']) for pg in all_pages)
501
- lines = [
502
- '# 测试交接: %s' % task_dir.name,
503
- '',
504
- '> 任务完成于 %s' % datetime.now().strftime('%Y-%m-%d %H:%M'),
505
- '> 业务关键词: %s' % ', '.join(used_kws),
506
- '> 共 %d 个页面, %d 个交互点' % (len(all_pages), total_btns),
507
- '',
508
- '> 💡 测试人员照此写用例, 不用自己找页面按钮。每行"按钮→接口"就是一个用例的核心断言点。',
509
- '> 未解析到接口的按钮 (deferred/unresolved) 需现场点击确认实际调用。',
510
- '',
511
- ]
512
-
513
- # 复用 page_probe 的渲染, 但按页面输出
514
- fake_profile = {'ok': True, 'pages': all_pages, 'stats': page_probe._trace_stats(str(repo_root))}
515
- lines.append(page_probe.render_profile_md(fake_profile))
516
-
517
- lines.append('')
518
- lines.append('---')
519
- lines.append('')
520
- lines.append('## 测试用例生成')
521
- lines.append('拿到这份画像后, 直接说:')
522
- lines.append('- "测一下 %s" → 走 /wl-test quick 自动生成用例' % (used_kws[0] if used_kws else '这个功能'))
523
- lines.append('- 或 `/wl-test browser %s` → 按任务 PRD 生成回归用例' % task_dir.name)
524
- lines.append('- 也可 `autotest.py page --kw <更精确的关键词>` 单独查某页画像')
525
-
526
- handoff_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
527
- print("[测试交接画像] 已生成 %s (%d 页面, %d 交互点)"
528
- % (handoff_path.name, len(all_pages), total_btns))
529
-
530
-
531
- def _cleanup_orphaned_refs(archived_task_name: str, repo_root) -> int:
532
- """归档任务后, 清理其它任务中对它的引用 (blocked_by/blocks/children/parent)。
533
-
534
- 不清理 → 僵尸任务永远 blocked, resolve_task_dir 找不到引用对象。
535
- 返回清理的引用数。
536
- """
537
- tasks_dir = get_tasks_dir(repo_root)
538
- if not tasks_dir.is_dir():
539
- return 0
540
- cleaned = 0
541
- for task_subdir in tasks_dir.iterdir():
542
- if not task_subdir.is_dir() or task_subdir.name == archived_task_name:
543
- continue
544
- task_json = task_subdir / FILE_TASK_JSON
545
- if not task_json.is_file():
546
- continue
547
- changed = False
548
-
549
- def _purge_refs(data):
550
- nonlocal changed
551
- for field in ("blocked_by", "blocks", "children"):
552
- lst = data.get(field)
553
- if isinstance(lst, list) and archived_task_name in lst:
554
- data[field] = [x for x in lst if x != archived_task_name]
555
- changed = True
556
- # parent 是单值字段
557
- if data.get("parent") == archived_task_name:
558
- data["parent"] = None
559
- changed = True
560
- if changed:
561
- data["updated_at"] = datetime.now().isoformat()
562
-
563
- modify_task_json(task_subdir, _purge_refs)
564
- if changed:
565
- cleaned += 1
566
- return cleaned
567
-
568
-
569
- # =============================================================================
570
- # 命令: archive
571
- # =============================================================================
572
-
573
- def cmd_archive(args: argparse.Namespace) -> int:
574
- """归档任务 (移动到 archive/ 目录)。事务化: 先 move 成功再改 status。"""
575
- repo_root = get_repo_root()
576
- task_input = args.dir
577
-
578
- full_path = resolve_task_dir(task_input, repo_root)
579
- if not full_path.is_dir():
580
- print(f"Error: Task not found: {task_input}", file=sys.stderr)
581
- return 1
582
-
583
- # 零信任 ACL: 只有 creator/assignee/admin 能 archive
584
- try:
585
- assert_can_modify_task(full_path, "archive", repo_root)
586
- except PermissionError as e:
587
- print(str(e), file=sys.stderr)
588
- return 4 # authz_denied
589
-
590
- # 准备 archive 目标
591
- year_month = datetime.now().strftime("%Y-%m")
592
- archive_dir = repo_root / ".qoder" / "archive" / year_month
593
- archive_dir.mkdir(parents=True, exist_ok=True)
594
-
595
- dest = archive_dir / full_path.name
596
- if dest.exists():
597
- print(f"Error: Archive destination exists: {dest}", file=sys.stderr)
598
- return 1
599
-
600
- # 事务化: 先 move (失败则原任务完好无损), 成功后再改 status
601
- try:
602
- shutil.move(str(full_path), str(dest))
603
- except (OSError, IOError) as e:
604
- print(f"Error: Failed to archive (move failed, 原任务未动): {e}", file=sys.stderr)
605
- return 1
606
-
607
- # move 成功后在目标位置改 status (失败不影响归档, 仅 status 不更新)
608
- task_data = load_task_json(dest)
609
- if task_data:
610
- task_data["status"] = "completed"
611
- task_data["archived_at"] = datetime.now().isoformat()
612
- task_data["updated_at"] = task_data["archived_at"]
613
- write_task_json(dest, task_data)
614
-
615
- # 运行 after_archive 钩子
616
- run_task_hooks("after_archive", dest / FILE_TASK_JSON, repo_root)
617
-
618
- # 清理孤儿依赖: 其它任务可能引用了本任务 (blocked_by/blocks/children/parent),
619
- # 归档后这些引用变成死链 → 僵尸任务永远 blocked。扫描并移除。
620
- cleaned = _cleanup_orphaned_refs(full_path.name, repo_root)
621
-
622
- # 清除可能指向此任务的会话
623
- active = resolve_active_task(repo_root)
624
- if active.task_path and task_input in active.task_path:
625
- clear_active_task(repo_root)
626
-
627
- print(f"Task archived: {full_path.name} -> archive/{year_month}/{full_path.name}")
628
- if cleaned:
629
- print(f" (已清理 {cleaned} 个其它任务中对它的依赖引用)")
630
- return 0
631
-
632
-
633
- # =============================================================================
634
- # 命令: list
635
- # =============================================================================
636
-
637
- def cmd_list(args: argparse.Namespace) -> int:
638
- """列出活跃任务。支持 --mine / --status / --ready / --blocked 过滤。"""
639
- repo_root = get_repo_root()
640
- tasks_dir = get_tasks_dir(repo_root)
641
- developer = get_developer(repo_root)
642
- active = resolve_active_task(repo_root)
643
-
644
- filter_mine = args.mine
645
- filter_status = args.status
646
- filter_ready = getattr(args, "ready", False)
647
- filter_blocked = getattr(args, "blocked", False)
648
-
649
- if not tasks_dir.is_dir():
650
- print("No tasks directory found")
651
- return 0
652
-
653
- # 预加载所有任务 (用于 --ready/--blocked 判断依赖是否完成)
654
- all_tasks = {}
655
- for d in sorted(tasks_dir.iterdir()):
656
- if d.is_dir():
657
- data = load_task_json(d)
658
- if data:
659
- all_tasks[d.name] = data
660
-
661
- def is_dep_completed(dep_name: str) -> bool:
662
- dep = all_tasks.get(dep_name)
663
- return bool(dep and dep.get("status") == "completed")
664
-
665
- tasks = []
666
- for name, task_data in all_tasks.items():
667
- d = tasks_dir / name
668
-
669
- # 过滤: --mine
670
- if filter_mine and developer not in (
671
- task_data.get("assignee"), task_data.get("creator")
672
- ):
673
- continue
674
- if filter_status and task_data.get("status") != filter_status:
675
- continue
676
-
677
- # --ready: 未完成 且 所有 blocked_by 已完成
678
- if filter_ready:
679
- if task_data.get("status") == "completed":
680
- continue
681
- blocked_by = task_data.get("blocked_by") or []
682
- if any(not is_dep_completed(dep) for dep in blocked_by):
683
- continue
684
-
685
- # --blocked: 有未完成的 blocked_by
686
- if filter_blocked:
687
- blocked_by = task_data.get("blocked_by") or []
688
- open_blocks = [dep for dep in blocked_by if not is_dep_completed(dep)]
689
- if not open_blocks:
690
- continue
691
-
692
- tasks.append((name, task_data))
693
-
694
- if not tasks:
695
- print("No tasks found" + (
696
- " (no tasks ready to start)" if filter_ready else
697
- " (no blocked tasks)" if filter_blocked else ""
698
- ))
699
- return 0
700
-
701
- print(f"Tasks ({len(tasks)}):")
702
- for name, data in tasks:
703
- is_current = active.task_path and name in active.task_path
704
- marker = " *" if is_current else " "
705
- assignee = data.get("assignee", "?")
706
- status = data.get("status", "?")
707
- priority = data.get("priority", "?")
708
- title = data.get("title", name)
709
- extras = []
710
- due = data.get("due_date")
711
- if due:
712
- extras.append(f"due:{due}")
713
- blocked_by = data.get("blocked_by") or []
714
- open_blocks = [dep for dep in blocked_by if not is_dep_completed(dep)]
715
- if open_blocks:
716
- extras.append(f"blocked:{','.join(open_blocks)}")
717
- extra_str = f" [{', '.join(extras)}]" if extras else ""
718
- print(f"{marker} [{priority}] [{status}] {name} ({assignee}) - {title}{extra_str}")
719
-
720
- return 0
721
-
722
-
723
- # =============================================================================
724
- # 命令: show
725
- # =============================================================================
726
-
727
- def cmd_show(args: argparse.Namespace) -> int:
728
- """显示任务详情。"""
729
- repo_root = get_repo_root()
730
- full_path = resolve_task_dir(args.dir, repo_root)
731
-
732
- if not full_path.is_dir():
733
- print(f"Error: Task not found: {args.dir}", file=sys.stderr)
734
- return 1
735
-
736
- task_data = load_task_json(full_path)
737
- if not task_data:
738
- print(f"Error: Missing task.json in {full_path.name}", file=sys.stderr)
739
- return 1
740
-
741
- print(f"Task: {full_path.name}")
742
- for key in ("title", "status", "priority", "rice", "assignee", "creator",
743
- "created_at", "updated_at", "parent", "tags",
744
- "due_date", "start_date", "estimate_hours"):
745
- val = task_data.get(key)
746
- if val not in (None, [], ""):
747
- print(f" {key}: {val}")
748
- children = task_data.get("children") or []
749
- if children:
750
- print(f" children: {', '.join(children)}")
751
- blocked_by = task_data.get("blocked_by") or []
752
- if blocked_by:
753
- print(f" blocked_by: {', '.join(blocked_by)}")
754
- blocks = task_data.get("blocks") or []
755
- if blocks:
756
- print(f" blocks: {', '.join(blocks)}")
757
- stage_ts = task_data.get("stage_ts") or {}
758
- if any(stage_ts.values()):
759
- print(f" stage_ts: {stage_ts}")
760
- prd_file = full_path / "prd.md"
761
- if prd_file.is_file():
762
- print(f" prd: workspace/tasks/{full_path.name}/prd.md")
763
- return 0
764
-
765
-
766
- # =============================================================================
767
- # 命令: add-subtask / remove-subtask
768
- # =============================================================================
769
-
770
- def cmd_add_subtask(args: argparse.Namespace) -> int:
771
- """添加子任务关联。"""
772
- repo_root = get_repo_root()
773
- parent_dir = resolve_task_dir(args.parent, repo_root)
774
- child_dir = resolve_task_dir(args.child, repo_root)
775
-
776
- if not parent_dir.is_dir():
777
- print(f"Error: Parent task not found: {args.parent}", file=sys.stderr)
778
- return 1
779
- if not child_dir.is_dir():
780
- print(f"Error: Child task not found: {args.child}", file=sys.stderr)
781
- return 1
782
-
783
- # 零信任 ACL: 只有 parent 的 creator/assignee/admin 能加子任务
784
- try:
785
- assert_can_modify_task(parent_dir, "add-subtask", repo_root)
786
- except PermissionError as e:
787
- print(str(e), file=sys.stderr)
788
- return 4
789
-
790
- parent_data = load_task_json(parent_dir)
791
- child_data = load_task_json(child_dir)
792
-
793
- if not parent_data or not child_data:
794
- print("Error: Missing task.json", file=sys.stderr)
795
- return 1
796
-
797
- child_name = child_dir.name
798
- children = parent_data.get("children", [])
799
- if child_name not in children:
800
- children.append(child_name)
801
- parent_data["children"] = children
802
- parent_data["updated_at"] = datetime.now().isoformat()
803
- write_task_json(parent_dir, parent_data)
804
-
805
- # 子任务记录父任务
806
- child_data["parent"] = parent_dir.name
807
- child_data["updated_at"] = datetime.now().isoformat()
808
- write_task_json(child_dir, child_data)
809
-
810
- print(f"Subtask added: {parent_dir.name} -> {child_dir.name}")
811
- return 0
812
-
813
-
814
- def cmd_remove_subtask(args: argparse.Namespace) -> int:
815
- """移除子任务关联。"""
816
- repo_root = get_repo_root()
817
- parent_dir = resolve_task_dir(args.parent, repo_root)
818
- child_dir = resolve_task_dir(args.child, repo_root)
819
-
820
- # 零信任 ACL
821
- if parent_dir.is_dir():
822
- try:
823
- assert_can_modify_task(parent_dir, "remove-subtask", repo_root)
824
- except PermissionError as e:
825
- print(str(e), file=sys.stderr)
826
- return 4
827
-
828
- parent_data = load_task_json(parent_dir)
829
- child_data = load_task_json(child_dir)
830
-
831
- if parent_data:
832
- children = parent_data.get("children", [])
833
- if child_dir.name in children:
834
- children.remove(child_dir.name)
835
- parent_data["children"] = children
836
- parent_data["updated_at"] = datetime.now().isoformat()
837
- write_task_json(parent_dir, parent_data)
838
-
839
- if child_data:
840
- child_data["parent"] = None
841
- child_data["updated_at"] = datetime.now().isoformat()
842
- write_task_json(child_dir, child_data)
843
-
844
- print(f"Subtask removed: {parent_dir.name} -x- {child_dir.name}")
845
- return 0
846
-
847
-
848
- # =============================================================================
849
- # 工具函数
850
- # =============================================================================
851
-
852
- def _generate_slug(title: str) -> str:
853
- """从标题生成 URL-safe slug。"""
854
- # 简单处理: 小写, 空格转连字符, 只保留字母数字和连字符
855
- slug = title.lower().strip()
856
- slug = "".join(c if c.isalnum() or c in " -_" else "" for c in slug)
857
- slug = slug.replace(" ", "-").replace("_", "-")
858
- slug = "-".join(part for part in slug.split("-") if part)
859
- return slug[:50] if slug else "untitled"
860
-
861
-
862
- # =============================================================================
863
- # 命令: set-due / block / unblock / gantt (B2 能力增强)
864
- # =============================================================================
865
-
866
- def cmd_set_due(args: argparse.Namespace) -> int:
867
- """设置任务截止日期。用法: task.py set-due <task> <YYYY-MM-DD>"""
868
- repo_root = get_repo_root()
869
- full_path = resolve_task_dir(args.task, repo_root)
870
- if not full_path.is_dir():
871
- print(f"Error: Task not found: {args.task}", file=sys.stderr)
872
- return 1
873
- # ACL
874
- try:
875
- assert_can_modify_task(full_path, "set-due", repo_root)
876
- except PermissionError as e:
877
- print(str(e), file=sys.stderr)
878
- return 4
879
- def _set_due_mutator(data):
880
- data["due_date"] = args.due_date
881
- data["updated_at"] = datetime.now().isoformat()
882
- if not modify_task_json(full_path, _set_due_mutator):
883
- print("Error: Missing task.json", file=sys.stderr)
884
- return 1
885
- print(f"Set due_date: {args.due_date} ({full_path.name})")
886
- return 0
887
-
888
-
889
- def cmd_reassign(args: argparse.Namespace) -> int:
890
- """改派任务负责人。用法: task.py reassign <task> <new_assignee>
891
-
892
- 场景: PM 建任务后改派给开发(多角色协作必需)。
893
- ACL: 只有 creator/admin 能改派(防恶意抢占/甩锅)。
894
- 保留改派历史(assignee_history), 便于追溯。
895
- """
896
- repo_root = get_repo_root()
897
- full_path = resolve_task_dir(args.task, repo_root)
898
- if not full_path.is_dir():
899
- print(f"Error: Task not found: {args.task}", file=sys.stderr)
900
- return 1
901
- # ACL: 只有 creator/admin 能改派 (assignee 自己不能转手甩锅)
902
- try:
903
- assert_can_modify_task(full_path, "reassign", repo_root)
904
- except PermissionError as e:
905
- print(str(e), file=sys.stderr)
906
- return 4
907
-
908
- new_assignee = (args.new_assignee or "").strip()
909
- if not new_assignee:
910
- print("Error: new_assignee 不能为空", file=sys.stderr)
911
- return 1
912
-
913
- changed = {"from": None, "to": new_assignee}
914
-
915
- def _reassign_mutator(data):
916
- old = data.get("assignee")
917
- changed["from"] = old
918
- data["assignee"] = new_assignee
919
- data["updated_at"] = datetime.now().isoformat()
920
- # 记录改派历史 (追溯用)
921
- hist = data.get("assignee_history") or []
922
- hist.append({
923
- "from": old,
924
- "to": new_assignee,
925
- "by": get_developer(repo_root),
926
- "at": datetime.now().isoformat(),
927
- })
928
- data["assignee_history"] = hist
929
-
930
- if not modify_task_json(full_path, _reassign_mutator):
931
- print("Error: Missing task.json", file=sys.stderr)
932
- return 1
933
-
934
- if changed["from"] == new_assignee:
935
- print(f"Note: assignee 已是 {new_assignee}, 无变化 ({full_path.name})")
936
- else:
937
- print(f"Reassigned: {changed['from']} -> {new_assignee} ({full_path.name})")
938
- print(f" 改派人: {get_developer(repo_root)} (已记录到 assignee_history)")
939
- return 0
940
-
941
-
942
- def cmd_block(args: argparse.Namespace) -> int:
943
- """标记任务被另一任务阻塞。自动维护反向 blocks 关系。
944
- 用法: task.py block <task> <blocked-by-task>
945
- 语义: <task> 被 <blocked-by-task> 阻塞 (要等它完成)。
946
- """
947
- repo_root = get_repo_root()
948
- task_dir = resolve_task_dir(args.task, repo_root)
949
- blocker_dir = resolve_task_dir(args.blocker, repo_root)
950
- if not task_dir.is_dir():
951
- print(f"Error: Task not found: {args.task}", file=sys.stderr)
952
- return 1
953
- if not blocker_dir.is_dir():
954
- print(f"Error: Blocker task not found: {args.blocker}", file=sys.stderr)
955
- return 1
956
- # ACL: 只有 task 的 owner 能加依赖
957
- try:
958
- assert_can_modify_task(task_dir, "block", repo_root)
959
- except PermissionError as e:
960
- print(str(e), file=sys.stderr)
961
- return 4
962
-
963
- # 检测循环依赖
964
- if _would_create_cycle(task_dir.name, blocker_dir.name, repo_root):
965
- print(f"Error: 循环依赖检测到 ({blocker_dir.name} 已直接/间接依赖 {task_dir.name})",
966
- file=sys.stderr)
967
- return 1
968
-
969
- # 检查是否已存在依赖关系 (先读, 幂等)
970
- task_data = load_task_json(task_dir)
971
- if not task_data:
972
- print("Error: Missing task.json", file=sys.stderr)
973
- return 1
974
- if blocker_dir.name in (task_data.get("blocked_by") or []):
975
- print(f"Already blocked by {blocker_dir.name}")
976
- return 0
977
-
978
- # 锁内更新 task.blocked_by
979
- def _block_mutator(data):
980
- blocked_by = data.get("blocked_by") or []
981
- if blocker_dir.name not in blocked_by:
982
- blocked_by.append(blocker_dir.name)
983
- data["blocked_by"] = blocked_by
984
- data["updated_at"] = datetime.now().isoformat()
985
- modify_task_json(task_dir, _block_mutator)
986
-
987
- # 锁内反向更新 blocker.blocks
988
- def _block_reverse_mutator(data):
989
- blocks = data.get("blocks") or []
990
- if task_dir.name not in blocks:
991
- blocks.append(task_dir.name)
992
- data["blocks"] = blocks
993
- data["updated_at"] = datetime.now().isoformat()
994
- modify_task_json(blocker_dir, _block_reverse_mutator)
995
-
996
- print(f"{task_dir.name} now blocked by {blocker_dir.name}")
997
- return 0
998
-
999
-
1000
- def cmd_unblock(args: argparse.Namespace) -> int:
1001
- """移除阻塞关系。用法: task.py unblock <task> <blocked-by-task>"""
1002
- repo_root = get_repo_root()
1003
- task_dir = resolve_task_dir(args.task, repo_root)
1004
- blocker_dir = resolve_task_dir(args.blocker, repo_root)
1005
- if task_dir.is_dir():
1006
- try:
1007
- assert_can_modify_task(task_dir, "unblock", repo_root)
1008
- except PermissionError as e:
1009
- print(str(e), file=sys.stderr)
1010
- return 4
1011
-
1012
- removed = False
1013
- # 锁内从 task.blocked_by 删
1014
- if task_dir.is_dir():
1015
- def _unblock_mutator(data):
1016
- nonlocal removed
1017
- if blocker_dir.name in (data.get("blocked_by") or []):
1018
- data["blocked_by"] = [x for x in (data.get("blocked_by") or []) if x != blocker_dir.name]
1019
- data["updated_at"] = datetime.now().isoformat()
1020
- removed = True
1021
- modify_task_json(task_dir, _unblock_mutator)
1022
-
1023
- # 锁内从 blocker.blocks 删
1024
- if blocker_dir.is_dir():
1025
- def _unblock_reverse_mutator(data):
1026
- nonlocal removed
1027
- if task_dir.name in (data.get("blocks") or []):
1028
- data["blocks"] = [x for x in (data.get("blocks") or []) if x != task_dir.name]
1029
- data["updated_at"] = datetime.now().isoformat()
1030
- removed = True
1031
- modify_task_json(blocker_dir, _unblock_reverse_mutator)
1032
-
1033
- if removed:
1034
- print(f"Removed block: {task_dir.name} no longer blocked by {blocker_dir.name}")
1035
- else:
1036
- print(f"No such block relationship found")
1037
- return 0
1038
-
1039
-
1040
- def _would_create_cycle(task_name: str, blocker_name: str, repo_root) -> bool:
1041
- """检测添加 task←blocker 后是否会形成循环依赖。
1042
- 即: blocker 是否(直接/间接)已经依赖 task?
1043
- """
1044
- if task_name == blocker_name:
1045
- return True
1046
- # BFS: 从 blocker 出发, 沿 blocked_by 走, 看能否回到 task
1047
- visited = set()
1048
- queue = [blocker_name]
1049
- tasks_dir = get_tasks_dir(repo_root)
1050
- while queue:
1051
- current = queue.pop(0)
1052
- if current in visited:
1053
- continue
1054
- visited.add(current)
1055
- if current == task_name:
1056
- return True
1057
- cur_dir = tasks_dir / current
1058
- data = load_task_json(cur_dir) if cur_dir.is_dir() else None
1059
- if data:
1060
- for dep in (data.get("blocked_by") or []):
1061
- if dep not in visited:
1062
- queue.append(dep)
1063
- return False
1064
-
1065
-
1066
- def _is_task_ready(task_data: dict) -> bool:
1067
- """任务是否"可立即开始": 未完成 且 无未完成的 blocked_by。"""
1068
- if task_data.get("status") == "completed":
1069
- return False
1070
- blocked_by = task_data.get("blocked_by") or []
1071
- # blocked_by 为空 = 可开始 (这里简化: 不深入查依赖是否完成, 由调用方保证)
1072
- return len(blocked_by) == 0
1073
-
1074
-
1075
- def cmd_gantt(args: argparse.Namespace) -> int:
1076
- """渲染 markdown 甘特图 (按 start_date/due_date 排期)。
1077
- 用法: task.py gantt [--weeks 4]
1078
- """
1079
- repo_root = get_repo_root()
1080
- tasks_dir = get_tasks_dir(repo_root)
1081
- if not tasks_dir.is_dir():
1082
- print("No tasks directory")
1083
- return 0
1084
-
1085
- from datetime import date, timedelta
1086
- today = date.today()
1087
- weeks = args.weeks if hasattr(args, "weeks") else 4
1088
- end = today + timedelta(weeks=weeks)
1089
-
1090
- # 收集有日期的任务
1091
- items = []
1092
- for d in sorted(tasks_dir.iterdir()):
1093
- if not d.is_dir():
1094
- continue
1095
- data = load_task_json(d)
1096
- if not data:
1097
- continue
1098
- due = data.get("due_date")
1099
- start = data.get("start_date")
1100
- if not due and not start:
1101
- continue
1102
- items.append({
1103
- "name": d.name,
1104
- "title": data.get("title", d.name),
1105
- "start": start,
1106
- "due": due,
1107
- "status": data.get("status", "?"),
1108
- "assignee": data.get("assignee", "?"),
1109
- })
1110
-
1111
- if not items:
1112
- print("No tasks with dates. 用 'task.py set-due <task> <YYYY-MM-DD>' 添加截止日期。")
1113
- return 0
1114
-
1115
- # 按开始/截止日期排序
1116
- items.sort(key=lambda x: (x.get("start") or x.get("due") or "9999"))
1117
-
1118
- print(f"# 任务排期 ({today} ~ {end})\n")
1119
- print("| 任务 | 负责人 | 开始 | 截止 | 状态 |")
1120
- print("|------|--------|------|------|------|")
1121
- for it in items:
1122
- start = it.get("start") or "-"
1123
- due = it.get("due") or "-"
1124
- # 标记逾期
1125
- if it.get("due") and it["status"] != "completed":
1126
- try:
1127
- due_date = date.fromisoformat(it["due"])
1128
- if due_date < today:
1129
- due = f"⚠️ {due} (逾期)"
1130
- except ValueError:
1131
- pass
1132
- print(f"| {it['name']} | {it['assignee']} | {start} | {due} | {it['status']} |")
1133
-
1134
- # 简单时间轴 (ASCII)
1135
- print(f"\n## 时间轴 ({weeks} 周)\n")
1136
- print("```")
1137
- for it in items:
1138
- start_s = it.get("start") or it.get("due")
1139
- if not start_s:
1140
- continue
1141
- try:
1142
- start_d = date.fromisoformat(start_s) if it.get("start") else today
1143
- except ValueError:
1144
- start_d = today
1145
- due_s = it.get("due")
1146
- try:
1147
- due_d = date.fromisoformat(due_s) if due_s else start_d + timedelta(days=7)
1148
- except ValueError:
1149
- due_d = start_d + timedelta(days=7)
1150
- # 计算相对今天的偏移
1151
- start_off = max(0, (start_d - today).days)
1152
- due_off = min(weeks * 7, (due_d - today).days)
1153
- if due_off < 0:
1154
- bar = "[逾期]" + "=" * 5
1155
- else:
1156
- bar = " " * (start_off // 2) + "[" + "=" * max(1, (due_off - start_off) // 2) + "]"
1157
- print(f"{it['name'][:20]:20} {bar}")
1158
- print("```")
1159
- return 0
1160
-
1161
-
1162
- # =============================================================================
1163
- # Main
1164
- # =============================================================================
1165
-
1166
- def main() -> int:
1167
- """CLI 入口。"""
1168
- parser = argparse.ArgumentParser(description="QODER Task Management")
1169
- subparsers = parser.add_subparsers(dest="command", help="Available commands")
1170
-
1171
- # create
1172
- p_create = subparsers.add_parser("create", help="Create a new task")
1173
- p_create.add_argument("title", help="Task title")
1174
- p_create.add_argument("--slug", help="URL-safe slug (auto-generated if omitted)")
1175
- p_create.add_argument("--assignee", help="Assignee (defaults to current developer)")
1176
- p_create.add_argument("--priority", choices=["P0", "P1", "P2", "P3"], default="P2")
1177
- p_create.set_defaults(func=cmd_create)
1178
-
1179
- # start
1180
- p_start = subparsers.add_parser("start", help="Start a task")
1181
- p_start.add_argument("dir", help="Task directory or name")
1182
- p_start.set_defaults(func=cmd_start)
1183
-
1184
- # current
1185
- p_current = subparsers.add_parser("current", help="Show current task")
1186
- p_current.add_argument("--source", action="store_true", help="Show source info")
1187
- p_current.set_defaults(func=cmd_current)
1188
-
1189
- # finish
1190
- p_finish = subparsers.add_parser("finish", help="Finish current task")
1191
- p_finish.set_defaults(func=cmd_finish)
1192
-
1193
- # archive
1194
- p_archive = subparsers.add_parser("archive", help="Archive a task")
1195
- p_archive.add_argument("dir", help="Task directory or name")
1196
- p_archive.set_defaults(func=cmd_archive)
1197
-
1198
- # list
1199
- p_list = subparsers.add_parser("list", help="List tasks")
1200
- p_list.add_argument("--mine", action="store_true", help="Only my tasks (assignee or creator)")
1201
- p_list.add_argument("--status", help="Filter by status")
1202
- p_list.add_argument("--ready", action="store_true", help="只显示可立即开始的 (无未完成依赖)")
1203
- p_list.add_argument("--blocked", action="store_true", help="只显示被阻塞的任务")
1204
- p_list.set_defaults(func=cmd_list)
1205
-
1206
- # show
1207
- p_show = subparsers.add_parser("show", help="Show task details")
1208
- p_show.add_argument("dir", help="Task directory or name")
1209
- p_show.set_defaults(func=cmd_show)
1210
-
1211
- # add-subtask
1212
- p_addsub = subparsers.add_parser("add-subtask", help="Add subtask")
1213
- p_addsub.add_argument("parent", help="Parent task")
1214
- p_addsub.add_argument("child", help="Child task")
1215
- p_addsub.set_defaults(func=cmd_add_subtask)
1216
-
1217
- # remove-subtask
1218
- p_remsub = subparsers.add_parser("remove-subtask", help="Remove subtask")
1219
- p_remsub.add_argument("parent", help="Parent task")
1220
- p_remsub.add_argument("child", help="Child task")
1221
- p_remsub.set_defaults(func=cmd_remove_subtask)
1222
-
1223
- # set-due (B2)
1224
- p_due = subparsers.add_parser("set-due", help="Set due date (YYYY-MM-DD)")
1225
- p_due.add_argument("task", help="Task name")
1226
- p_due.add_argument("due_date", help="Due date YYYY-MM-DD")
1227
- p_due.set_defaults(func=cmd_set_due)
1228
-
1229
- # reassign (改派任务负责人, 多角色协作必需)
1230
- p_reassign = subparsers.add_parser("reassign", help="Reassign task to another developer")
1231
- p_reassign.add_argument("task", help="Task name")
1232
- p_reassign.add_argument("new_assignee", help="New assignee (developer name)")
1233
- p_reassign.set_defaults(func=cmd_reassign)
1234
-
1235
- # block (B2)
1236
- p_block = subparsers.add_parser("block", help="Mark task blocked by another")
1237
- p_block.add_argument("task", help="Task that is blocked")
1238
- p_block.add_argument("blocker", help="Task that blocks it (must finish first)")
1239
- p_block.set_defaults(func=cmd_block)
1240
-
1241
- # unblock (B2)
1242
- p_unblock = subparsers.add_parser("unblock", help="Remove block relationship")
1243
- p_unblock.add_argument("task", help="Task")
1244
- p_unblock.add_argument("blocker", help="Blocker task")
1245
- p_unblock.set_defaults(func=cmd_unblock)
1246
-
1247
- # gantt (B2)
1248
- p_gantt = subparsers.add_parser("gantt", help="Render markdown Gantt chart")
1249
- p_gantt.add_argument("--weeks", type=int, default=4, help="Number of weeks to show")
1250
- p_gantt.set_defaults(func=cmd_gantt)
1251
-
1252
- args = parser.parse_args()
1253
- if not hasattr(args, "func"):
1254
- parser.print_help()
1255
- return 1
1256
-
1257
- return args.func(args)
1258
-
1259
-
1260
- if __name__ == "__main__":
1261
- sys.exit(main())