@hupan56/wlkj 2.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 (87) hide show
  1. package/bin/cli.js +213 -0
  2. package/package.json +11 -0
  3. package/templates/cli.js +198 -0
  4. package/templates/qoder/commands/wl-code.md +43 -0
  5. package/templates/qoder/commands/wl-commit.md +30 -0
  6. package/templates/qoder/commands/wl-init.md +80 -0
  7. package/templates/qoder/commands/wl-insight.md +51 -0
  8. package/templates/qoder/commands/wl-prd.md +199 -0
  9. package/templates/qoder/commands/wl-report.md +166 -0
  10. package/templates/qoder/commands/wl-search.md +52 -0
  11. package/templates/qoder/commands/wl-spec.md +18 -0
  12. package/templates/qoder/commands/wl-status.md +51 -0
  13. package/templates/qoder/commands/wl-task.md +71 -0
  14. package/templates/qoder/commands/wl-test.md +42 -0
  15. package/templates/qoder/config.toml +5 -0
  16. package/templates/qoder/config.yaml +141 -0
  17. package/templates/qoder/hooks/inject-workflow-state.py +117 -0
  18. package/templates/qoder/hooks/session-start.py +204 -0
  19. package/templates/qoder/rules/wl-pipeline.md +105 -0
  20. package/templates/qoder/scripts/add_session.py +245 -0
  21. package/templates/qoder/scripts/benchmark.py +209 -0
  22. package/templates/qoder/scripts/build_style_index.py +268 -0
  23. package/templates/qoder/scripts/code_index.py +41 -0
  24. package/templates/qoder/scripts/collect_prds.py +31 -0
  25. package/templates/qoder/scripts/common/__init__.py +0 -0
  26. package/templates/qoder/scripts/common/active_task.py +230 -0
  27. package/templates/qoder/scripts/common/atomicio.py +172 -0
  28. package/templates/qoder/scripts/common/developer.py +161 -0
  29. package/templates/qoder/scripts/common/eval_api.py +144 -0
  30. package/templates/qoder/scripts/common/feishu.py +278 -0
  31. package/templates/qoder/scripts/common/filelock.py +211 -0
  32. package/templates/qoder/scripts/common/identity.py +285 -0
  33. package/templates/qoder/scripts/common/mentions.py +134 -0
  34. package/templates/qoder/scripts/common/paths.py +311 -0
  35. package/templates/qoder/scripts/common/reqid.py +218 -0
  36. package/templates/qoder/scripts/common/search_engine.py +205 -0
  37. package/templates/qoder/scripts/common/task_utils.py +342 -0
  38. package/templates/qoder/scripts/common/terms.py +234 -0
  39. package/templates/qoder/scripts/common/utf8.py +38 -0
  40. package/templates/qoder/scripts/context_pack.py +196 -0
  41. package/templates/qoder/scripts/eval_prd.py +225 -0
  42. package/templates/qoder/scripts/export.py +487 -0
  43. package/templates/qoder/scripts/git_sync.py +1087 -0
  44. package/templates/qoder/scripts/handoff.py +22 -0
  45. package/templates/qoder/scripts/init_developer.py +76 -0
  46. package/templates/qoder/scripts/init_doctor.py +527 -0
  47. package/templates/qoder/scripts/install_qoderwork.py +339 -0
  48. package/templates/qoder/scripts/learn.py +67 -0
  49. package/templates/qoder/scripts/notify.py +5 -0
  50. package/templates/qoder/scripts/parse_prds.py +33 -0
  51. package/templates/qoder/scripts/report.py +281 -0
  52. package/templates/qoder/scripts/role.py +39 -0
  53. package/templates/qoder/scripts/run_weekly_update.bat +17 -0
  54. package/templates/qoder/scripts/run_weekly_update.sh +20 -0
  55. package/templates/qoder/scripts/search_index.py +352 -0
  56. package/templates/qoder/scripts/setup.py +453 -0
  57. package/templates/qoder/scripts/setup_weekly_cron.bat +22 -0
  58. package/templates/qoder/scripts/setup_weekly_cron.sh +19 -0
  59. package/templates/qoder/scripts/status.py +389 -0
  60. package/templates/qoder/scripts/syncgate.py +330 -0
  61. package/templates/qoder/scripts/task.py +954 -0
  62. package/templates/qoder/scripts/team.py +29 -0
  63. package/templates/qoder/scripts/team_sync.py +419 -0
  64. package/templates/qoder/scripts/workspace_init.py +102 -0
  65. package/templates/qoder/settings.json +53 -0
  66. package/templates/qoder/skills/design-review/SKILL.md +25 -0
  67. package/templates/qoder/skills/prd-generator/SKILL.md +180 -0
  68. package/templates/qoder/skills/prd-review/SKILL.md +36 -0
  69. package/templates/qoder/skills/prototype-generator/SKILL.md +141 -0
  70. package/templates/qoder/skills/spec-coder/SKILL.md +69 -0
  71. package/templates/qoder/skills/spec-generator/SKILL.md +67 -0
  72. package/templates/qoder/skills/test-generator/SKILL.md +72 -0
  73. package/templates/qoder/skills/wl-commit/SKILL.md +76 -0
  74. package/templates/qoder/skills/wl-init/SKILL.md +67 -0
  75. package/templates/qoder/skills/wl-insight/SKILL.md +81 -0
  76. package/templates/qoder/skills/wl-report/SKILL.md +87 -0
  77. package/templates/qoder/skills/wl-search/SKILL.md +75 -0
  78. package/templates/qoder/skills/wl-status/SKILL.md +61 -0
  79. package/templates/qoder/skills/wl-task/SKILL.md +58 -0
  80. package/templates/qoder/templates/prd-full-template.md +103 -0
  81. package/templates/qoder/templates/prd-quick-template.md +69 -0
  82. package/templates/qoder/templates/prototype-app.html +344 -0
  83. package/templates/qoder/templates/prototype-web.html +310 -0
  84. package/templates/root/AGENTS.md +182 -0
  85. package/templates/root/README-pipeline.md +56 -0
  86. package/templates/root/ROLES.md +85 -0
  87. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -0
@@ -0,0 +1,954 @@
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
+ - add-subtask / remove-subtask: 父子任务关联
14
+
15
+ 参考: Trellis 的 task.py 设计
16
+
17
+ Usage:
18
+ python task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3]
19
+ python task.py start <name>
20
+ python task.py current [--source]
21
+ python task.py finish
22
+ python task.py archive <name>
23
+ python task.py list [--mine] [--status <s>]
24
+ python task.py add-subtask <parent> <child>
25
+ python task.py remove-subtask <parent> <child>
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import json
32
+ import os
33
+ import shutil
34
+ import sys
35
+ from datetime import datetime
36
+ from pathlib import Path
37
+
38
+ # 将 scripts 目录加入路径
39
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
40
+
41
+ from common.paths import (
42
+ DIR_TASKS,
43
+ DIR_ARCHIVE,
44
+ FILE_TASK_JSON,
45
+ get_repo_root,
46
+ get_developer,
47
+ get_tasks_dir,
48
+ )
49
+ from common.active_task import (
50
+ clear_active_task,
51
+ resolve_active_task,
52
+ resolve_context_key,
53
+ set_active_task,
54
+ )
55
+ from common.task_utils import (
56
+ resolve_task_dir,
57
+ run_task_hooks,
58
+ load_task_json,
59
+ write_task_json,
60
+ assert_can_modify_task,
61
+ )
62
+
63
+
64
+ # =============================================================================
65
+ # 命令: create
66
+ # =============================================================================
67
+
68
+ def cmd_create(args: argparse.Namespace) -> int:
69
+ """创建新任务。
70
+
71
+ 创建:
72
+ - .qoder/tasks/<slug>/ 目录
73
+ - task.json (元数据)
74
+ - prd.md (空模板)
75
+ - implement.jsonl (实现上下文, 空文件)
76
+ - check.jsonl (检查上下文, 空文件)
77
+ """
78
+ repo_root = get_repo_root()
79
+ title = args.title
80
+ slug = args.slug or _generate_slug(title)
81
+ assignee = args.assignee or get_developer(repo_root)
82
+ priority = args.priority or "P2"
83
+
84
+ # 生成日期前缀: MM-DD
85
+ date_prefix = datetime.now().strftime("%m-%d")
86
+ task_dir_name = f"{date_prefix}-{slug}"
87
+
88
+ tasks_dir = get_tasks_dir(repo_root)
89
+ task_dir = tasks_dir / task_dir_name
90
+
91
+ # 撞名处理: 同日同名 (两个 PM 都建"登录优化") 自动加 creator 后缀, 不阻塞
92
+ if task_dir.exists():
93
+ creator_tag = (assignee or "x")[:8]
94
+ # 加 creator 短标记; 仍撞则加 -2/-3
95
+ candidate = f"{date_prefix}-{slug}-{creator_tag}"
96
+ n = 2
97
+ while (tasks_dir / candidate).exists():
98
+ candidate = f"{date_prefix}-{slug}-{creator_tag}-{n}"
99
+ n += 1
100
+ if n > 99: # 防御性上限
101
+ print(f"Error: Task name collision exhausted: {slug}", file=sys.stderr)
102
+ return 1
103
+ task_dir_name = candidate
104
+ task_dir = tasks_dir / task_dir_name
105
+ print(f"Note: 同名任务已存在, 自动加后缀: {task_dir_name}")
106
+
107
+ # 创建目录
108
+ task_dir.mkdir(parents=True, exist_ok=True)
109
+
110
+ # 创建 task.json
111
+ now_iso = datetime.now().isoformat()
112
+ task_data = {
113
+ "title": title,
114
+ "slug": slug,
115
+ "status": "planning",
116
+ "priority": priority,
117
+ "assignee": assignee,
118
+ "creator": assignee,
119
+ "created_at": now_iso,
120
+ "updated_at": now_iso,
121
+ "branch": None,
122
+ "base_branch": None,
123
+ "scope": None,
124
+ "parent": None,
125
+ "children": [],
126
+ "tags": [],
127
+ # B1 能力增强字段 (向后兼容: 旧 task.json 不含这些, 读时取默认值)
128
+ "due_date": None, # 截止日期 YYYY-MM-DD (可选)
129
+ "start_date": None, # 开始日期 YYYY-MM-DD (可选)
130
+ "estimate_hours": None, # 预估工时 (可选)
131
+ "blocked_by": [], # 依赖的任务 (数组, 自动维护反向 blocks)
132
+ "blocks": [], # 被哪些任务依赖 (自动维护)
133
+ # B3 阶段时间戳 (周期时间分析用)
134
+ "stage_ts": {
135
+ "created": now_iso,
136
+ "started": None, # cmd_start 时填
137
+ "completed": None, # cmd_finish 时填
138
+ },
139
+ }
140
+ write_task_json(task_dir, task_data)
141
+
142
+ # 创建 prd.md 模板
143
+ prd_file = task_dir / "prd.md"
144
+ prd_content = f"""# PRD: {title}
145
+
146
+ > Status: planning
147
+ > Assignee: {assignee or 'unassigned'}
148
+ > Priority: {priority}
149
+ > Created: {datetime.now().strftime("%Y-%m-%d %H:%M")}
150
+
151
+ ---
152
+
153
+ ## Background
154
+
155
+ <!-- Describe the business context -->
156
+
157
+ ## Requirements
158
+
159
+ <!-- List functional requirements -->
160
+
161
+ ## Acceptance Criteria
162
+
163
+ <!-- Define testable acceptance criteria -->
164
+
165
+ ## Notes
166
+
167
+ <!-- Additional notes -->
168
+ """
169
+ prd_file.write_text(prd_content, encoding="utf-8")
170
+
171
+ # 创建空的 JSONL 上下文文件
172
+ (task_dir / "implement.jsonl").write_text("", encoding="utf-8")
173
+ (task_dir / "check.jsonl").write_text("", encoding="utf-8")
174
+
175
+ print(f"Task created: {task_dir_name}")
176
+ print(f" Title: {title}")
177
+ print(f" Assignee: {assignee or 'unassigned'}")
178
+ print(f" Priority: {priority}")
179
+ print(f" Path: workspace/tasks/{task_dir_name}")
180
+
181
+ # 运行 after_create 钩子
182
+ run_task_hooks("after_create", task_dir / FILE_TASK_JSON, repo_root)
183
+
184
+ return 0
185
+
186
+
187
+ # =============================================================================
188
+ # 命令: start
189
+ # =============================================================================
190
+
191
+ def cmd_start(args: argparse.Namespace) -> int:
192
+ """设置活跃任务, 状态改为 in_progress。"""
193
+ repo_root = get_repo_root()
194
+ task_input = args.dir
195
+
196
+ if not task_input:
197
+ print("Error: task directory or name required", file=sys.stderr)
198
+ return 1
199
+
200
+ full_path = resolve_task_dir(task_input, repo_root)
201
+
202
+ if not full_path.is_dir():
203
+ print(f"Error: Task not found: {task_input}", file=sys.stderr)
204
+ return 1
205
+
206
+ # 零信任 ACL: 只有 creator/assignee/admin 能 start
207
+ try:
208
+ assert_can_modify_task(full_path, "start", repo_root)
209
+ except PermissionError as e:
210
+ print(str(e), file=sys.stderr)
211
+ return 4 # authz_denied
212
+
213
+ # 转为相对路径
214
+ try:
215
+ task_dir = full_path.relative_to(repo_root).as_posix()
216
+ except ValueError:
217
+ task_dir = str(full_path)
218
+
219
+ # 设置活跃任务
220
+ active = set_active_task(task_dir, repo_root)
221
+ if active:
222
+ print(f"Current task set to: {task_dir}")
223
+ print(f"Source: {active.source}")
224
+
225
+ # 更新 task.json 状态
226
+ task_data = load_task_json(full_path)
227
+ if task_data and task_data.get("status") == "planning":
228
+ now_iso = datetime.now().isoformat()
229
+ task_data["status"] = "in_progress"
230
+ task_data["updated_at"] = now_iso
231
+ # B3: 记录阶段时间戳
232
+ stage_ts = task_data.get("stage_ts") or {}
233
+ stage_ts["started"] = now_iso
234
+ task_data["stage_ts"] = stage_ts
235
+ write_task_json(full_path, task_data)
236
+ print("Status: planning -> in_progress")
237
+
238
+ # 运行 after_start 钩子
239
+ run_task_hooks("after_start", full_path / FILE_TASK_JSON, repo_root)
240
+ return 0
241
+ else:
242
+ print("Error: Failed to set current task", file=sys.stderr)
243
+ return 1
244
+
245
+
246
+ # =============================================================================
247
+ # 命令: current
248
+ # =============================================================================
249
+
250
+ def cmd_current(args: argparse.Namespace) -> int:
251
+ """显示当前活跃任务。"""
252
+ repo_root = get_repo_root()
253
+ active = resolve_active_task(repo_root)
254
+
255
+ if args.source:
256
+ print(f"Current task: {active.task_path or '(none)'}")
257
+ print(f"Source: {active.source}")
258
+ return 0 if active.task_path else 1
259
+
260
+ if active.task_path:
261
+ print(active.task_path)
262
+ return 0
263
+
264
+ print("No active task", file=sys.stderr)
265
+ return 1
266
+
267
+
268
+ # =============================================================================
269
+ # 命令: finish
270
+ # =============================================================================
271
+
272
+ def cmd_finish(args: argparse.Namespace) -> int:
273
+ """完成当前任务 (状态改为 completed, 清除活跃任务指针)。"""
274
+ repo_root = get_repo_root()
275
+ active = clear_active_task(repo_root)
276
+ current = active.task_path
277
+
278
+ if not current:
279
+ print("No current task set")
280
+ return 0
281
+
282
+ print(f"Cleared current task (was: {current})")
283
+ print(f"Source: {active.source}")
284
+
285
+ # 更新 task.json 状态为 completed
286
+ task_dir = repo_root / current
287
+
288
+ # 零信任 ACL: 只有 creator/assignee/admin 能 finish
289
+ try:
290
+ assert_can_modify_task(task_dir, "finish", repo_root)
291
+ except PermissionError as e:
292
+ print(str(e), file=sys.stderr)
293
+ # 已清了 active 指针, 但不改 status —— 让用户重新 start 再由正确的人 finish
294
+ return 4 # authz_denied
295
+
296
+ task_data = load_task_json(task_dir)
297
+ if task_data and task_data.get("status") != "completed":
298
+ now_iso = datetime.now().isoformat()
299
+ task_data["status"] = "completed"
300
+ task_data["updated_at"] = now_iso
301
+ # B3: 记录完成时间戳
302
+ stage_ts = task_data.get("stage_ts") or {}
303
+ stage_ts["completed"] = now_iso
304
+ task_data["stage_ts"] = stage_ts
305
+ write_task_json(task_dir, task_data)
306
+ print("Status: -> completed")
307
+
308
+ # D2: 飞书通知 (任务完成)
309
+ try:
310
+ from common.feishu import notify_task_finished
311
+ # 算用时
312
+ hours = None
313
+ started = stage_ts.get("started")
314
+ if started:
315
+ try:
316
+ from datetime import datetime as _dt
317
+ t_start = _dt.fromisoformat(started)
318
+ t_end = _dt.fromisoformat(now_iso)
319
+ hours = (t_end - t_start).total_seconds() / 3600
320
+ except (ValueError, TypeError):
321
+ pass
322
+ notify_task_finished(task_dir.name, task_data.get("assignee", "?"), hours)
323
+ except Exception:
324
+ pass # 飞书失败不阻塞任务完成
325
+
326
+ task_json_path = task_dir / FILE_TASK_JSON
327
+ if task_json_path.is_file():
328
+ run_task_hooks("after_finish", task_json_path, repo_root)
329
+
330
+ return 0
331
+
332
+
333
+ # =============================================================================
334
+ # 命令: archive
335
+ # =============================================================================
336
+
337
+ def cmd_archive(args: argparse.Namespace) -> int:
338
+ """归档任务 (移动到 archive/ 目录)。事务化: 先 move 成功再改 status。"""
339
+ repo_root = get_repo_root()
340
+ task_input = args.dir
341
+
342
+ full_path = resolve_task_dir(task_input, repo_root)
343
+ if not full_path.is_dir():
344
+ print(f"Error: Task not found: {task_input}", file=sys.stderr)
345
+ return 1
346
+
347
+ # 零信任 ACL: 只有 creator/assignee/admin 能 archive
348
+ try:
349
+ assert_can_modify_task(full_path, "archive", repo_root)
350
+ except PermissionError as e:
351
+ print(str(e), file=sys.stderr)
352
+ return 4 # authz_denied
353
+
354
+ # 准备 archive 目标
355
+ year_month = datetime.now().strftime("%Y-%m")
356
+ archive_dir = repo_root / ".qoder" / "archive" / year_month
357
+ archive_dir.mkdir(parents=True, exist_ok=True)
358
+
359
+ dest = archive_dir / full_path.name
360
+ if dest.exists():
361
+ print(f"Error: Archive destination exists: {dest}", file=sys.stderr)
362
+ return 1
363
+
364
+ # 事务化: 先 move (失败则原任务完好无损), 成功后再改 status
365
+ try:
366
+ shutil.move(str(full_path), str(dest))
367
+ except (OSError, IOError) as e:
368
+ print(f"Error: Failed to archive (move failed, 原任务未动): {e}", file=sys.stderr)
369
+ return 1
370
+
371
+ # move 成功后在目标位置改 status (失败不影响归档, 仅 status 不更新)
372
+ task_data = load_task_json(dest)
373
+ if task_data:
374
+ task_data["status"] = "completed"
375
+ task_data["archived_at"] = datetime.now().isoformat()
376
+ task_data["updated_at"] = task_data["archived_at"]
377
+ write_task_json(dest, task_data)
378
+
379
+ # 运行 after_archive 钩子
380
+ run_task_hooks("after_archive", dest / FILE_TASK_JSON, repo_root)
381
+
382
+ # 清除可能指向此任务的会话
383
+ active = resolve_active_task(repo_root)
384
+ if active.task_path and task_input in active.task_path:
385
+ clear_active_task(repo_root)
386
+
387
+ print(f"Task archived: {full_path.name} -> archive/{year_month}/{full_path.name}")
388
+ return 0
389
+
390
+
391
+ # =============================================================================
392
+ # 命令: list
393
+ # =============================================================================
394
+
395
+ def cmd_list(args: argparse.Namespace) -> int:
396
+ """列出活跃任务。支持 --mine / --status / --ready / --blocked 过滤。"""
397
+ repo_root = get_repo_root()
398
+ tasks_dir = get_tasks_dir(repo_root)
399
+ developer = get_developer(repo_root)
400
+ active = resolve_active_task(repo_root)
401
+
402
+ filter_mine = args.mine
403
+ filter_status = args.status
404
+ filter_ready = getattr(args, "ready", False)
405
+ filter_blocked = getattr(args, "blocked", False)
406
+
407
+ if not tasks_dir.is_dir():
408
+ print("No tasks directory found")
409
+ return 0
410
+
411
+ # 预加载所有任务 (用于 --ready/--blocked 判断依赖是否完成)
412
+ all_tasks = {}
413
+ for d in sorted(tasks_dir.iterdir()):
414
+ if d.is_dir():
415
+ data = load_task_json(d)
416
+ if data:
417
+ all_tasks[d.name] = data
418
+
419
+ def is_dep_completed(dep_name: str) -> bool:
420
+ dep = all_tasks.get(dep_name)
421
+ return bool(dep and dep.get("status") == "completed")
422
+
423
+ tasks = []
424
+ for name, task_data in all_tasks.items():
425
+ d = tasks_dir / name
426
+
427
+ # 过滤: --mine
428
+ if filter_mine and developer not in (
429
+ task_data.get("assignee"), task_data.get("creator")
430
+ ):
431
+ continue
432
+ if filter_status and task_data.get("status") != filter_status:
433
+ continue
434
+
435
+ # --ready: 未完成 且 所有 blocked_by 已完成
436
+ if filter_ready:
437
+ if task_data.get("status") == "completed":
438
+ continue
439
+ blocked_by = task_data.get("blocked_by") or []
440
+ if any(not is_dep_completed(dep) for dep in blocked_by):
441
+ continue
442
+
443
+ # --blocked: 有未完成的 blocked_by
444
+ if filter_blocked:
445
+ blocked_by = task_data.get("blocked_by") or []
446
+ open_blocks = [dep for dep in blocked_by if not is_dep_completed(dep)]
447
+ if not open_blocks:
448
+ continue
449
+
450
+ tasks.append((name, task_data))
451
+
452
+ if not tasks:
453
+ print("No tasks found" + (
454
+ " (no tasks ready to start)" if filter_ready else
455
+ " (no blocked tasks)" if filter_blocked else ""
456
+ ))
457
+ return 0
458
+
459
+ print(f"Tasks ({len(tasks)}):")
460
+ for name, data in tasks:
461
+ is_current = active.task_path and name in active.task_path
462
+ marker = " *" if is_current else " "
463
+ assignee = data.get("assignee", "?")
464
+ status = data.get("status", "?")
465
+ priority = data.get("priority", "?")
466
+ title = data.get("title", name)
467
+ extras = []
468
+ due = data.get("due_date")
469
+ if due:
470
+ extras.append(f"due:{due}")
471
+ blocked_by = data.get("blocked_by") or []
472
+ open_blocks = [dep for dep in blocked_by if not is_dep_completed(dep)]
473
+ if open_blocks:
474
+ extras.append(f"blocked:{','.join(open_blocks)}")
475
+ extra_str = f" [{', '.join(extras)}]" if extras else ""
476
+ print(f"{marker} [{priority}] [{status}] {name} ({assignee}) - {title}{extra_str}")
477
+
478
+ return 0
479
+
480
+
481
+ # =============================================================================
482
+ # 命令: show
483
+ # =============================================================================
484
+
485
+ def cmd_show(args: argparse.Namespace) -> int:
486
+ """显示任务详情。"""
487
+ repo_root = get_repo_root()
488
+ full_path = resolve_task_dir(args.dir, repo_root)
489
+
490
+ if not full_path.is_dir():
491
+ print(f"Error: Task not found: {args.dir}", file=sys.stderr)
492
+ return 1
493
+
494
+ task_data = load_task_json(full_path)
495
+ if not task_data:
496
+ print(f"Error: Missing task.json in {full_path.name}", file=sys.stderr)
497
+ return 1
498
+
499
+ print(f"Task: {full_path.name}")
500
+ for key in ("title", "status", "priority", "rice", "assignee", "creator",
501
+ "created_at", "updated_at", "parent", "tags",
502
+ "due_date", "start_date", "estimate_hours"):
503
+ val = task_data.get(key)
504
+ if val not in (None, [], ""):
505
+ print(f" {key}: {val}")
506
+ children = task_data.get("children") or []
507
+ if children:
508
+ print(f" children: {', '.join(children)}")
509
+ blocked_by = task_data.get("blocked_by") or []
510
+ if blocked_by:
511
+ print(f" blocked_by: {', '.join(blocked_by)}")
512
+ blocks = task_data.get("blocks") or []
513
+ if blocks:
514
+ print(f" blocks: {', '.join(blocks)}")
515
+ stage_ts = task_data.get("stage_ts") or {}
516
+ if any(stage_ts.values()):
517
+ print(f" stage_ts: {stage_ts}")
518
+ prd_file = full_path / "prd.md"
519
+ if prd_file.is_file():
520
+ print(f" prd: workspace/tasks/{full_path.name}/prd.md")
521
+ return 0
522
+
523
+
524
+ # =============================================================================
525
+ # 命令: add-subtask / remove-subtask
526
+ # =============================================================================
527
+
528
+ def cmd_add_subtask(args: argparse.Namespace) -> int:
529
+ """添加子任务关联。"""
530
+ repo_root = get_repo_root()
531
+ parent_dir = resolve_task_dir(args.parent, repo_root)
532
+ child_dir = resolve_task_dir(args.child, repo_root)
533
+
534
+ if not parent_dir.is_dir():
535
+ print(f"Error: Parent task not found: {args.parent}", file=sys.stderr)
536
+ return 1
537
+ if not child_dir.is_dir():
538
+ print(f"Error: Child task not found: {args.child}", file=sys.stderr)
539
+ return 1
540
+
541
+ # 零信任 ACL: 只有 parent 的 creator/assignee/admin 能加子任务
542
+ try:
543
+ assert_can_modify_task(parent_dir, "add-subtask", repo_root)
544
+ except PermissionError as e:
545
+ print(str(e), file=sys.stderr)
546
+ return 4
547
+
548
+ parent_data = load_task_json(parent_dir)
549
+ child_data = load_task_json(child_dir)
550
+
551
+ if not parent_data or not child_data:
552
+ print("Error: Missing task.json", file=sys.stderr)
553
+ return 1
554
+
555
+ child_name = child_dir.name
556
+ children = parent_data.get("children", [])
557
+ if child_name not in children:
558
+ children.append(child_name)
559
+ parent_data["children"] = children
560
+ parent_data["updated_at"] = datetime.now().isoformat()
561
+ write_task_json(parent_dir, parent_data)
562
+
563
+ # 子任务记录父任务
564
+ child_data["parent"] = parent_dir.name
565
+ child_data["updated_at"] = datetime.now().isoformat()
566
+ write_task_json(child_dir, child_data)
567
+
568
+ print(f"Subtask added: {parent_dir.name} -> {child_dir.name}")
569
+ return 0
570
+
571
+
572
+ def cmd_remove_subtask(args: argparse.Namespace) -> int:
573
+ """移除子任务关联。"""
574
+ repo_root = get_repo_root()
575
+ parent_dir = resolve_task_dir(args.parent, repo_root)
576
+ child_dir = resolve_task_dir(args.child, repo_root)
577
+
578
+ # 零信任 ACL
579
+ if parent_dir.is_dir():
580
+ try:
581
+ assert_can_modify_task(parent_dir, "remove-subtask", repo_root)
582
+ except PermissionError as e:
583
+ print(str(e), file=sys.stderr)
584
+ return 4
585
+
586
+ parent_data = load_task_json(parent_dir)
587
+ child_data = load_task_json(child_dir)
588
+
589
+ if parent_data:
590
+ children = parent_data.get("children", [])
591
+ if child_dir.name in children:
592
+ children.remove(child_dir.name)
593
+ parent_data["children"] = children
594
+ parent_data["updated_at"] = datetime.now().isoformat()
595
+ write_task_json(parent_dir, parent_data)
596
+
597
+ if child_data:
598
+ child_data["parent"] = None
599
+ child_data["updated_at"] = datetime.now().isoformat()
600
+ write_task_json(child_dir, child_data)
601
+
602
+ print(f"Subtask removed: {parent_dir.name} -x- {child_dir.name}")
603
+ return 0
604
+
605
+
606
+ # =============================================================================
607
+ # 工具函数
608
+ # =============================================================================
609
+
610
+ def _generate_slug(title: str) -> str:
611
+ """从标题生成 URL-safe slug。"""
612
+ # 简单处理: 小写, 空格转连字符, 只保留字母数字和连字符
613
+ slug = title.lower().strip()
614
+ slug = "".join(c if c.isalnum() or c in " -_" else "" for c in slug)
615
+ slug = slug.replace(" ", "-").replace("_", "-")
616
+ slug = "-".join(part for part in slug.split("-") if part)
617
+ return slug[:50] if slug else "untitled"
618
+
619
+
620
+ # =============================================================================
621
+ # 命令: set-due / block / unblock / gantt (B2 能力增强)
622
+ # =============================================================================
623
+
624
+ def cmd_set_due(args: argparse.Namespace) -> int:
625
+ """设置任务截止日期。用法: task.py set-due <task> <YYYY-MM-DD>"""
626
+ repo_root = get_repo_root()
627
+ full_path = resolve_task_dir(args.task, repo_root)
628
+ if not full_path.is_dir():
629
+ print(f"Error: Task not found: {args.task}", file=sys.stderr)
630
+ return 1
631
+ # ACL
632
+ try:
633
+ assert_can_modify_task(full_path, "set-due", repo_root)
634
+ except PermissionError as e:
635
+ print(str(e), file=sys.stderr)
636
+ return 4
637
+ task_data = load_task_json(full_path)
638
+ if not task_data:
639
+ print("Error: Missing task.json", file=sys.stderr)
640
+ return 1
641
+ task_data["due_date"] = args.due_date
642
+ task_data["updated_at"] = datetime.now().isoformat()
643
+ write_task_json(full_path, task_data)
644
+ print(f"Set due_date: {args.due_date} ({full_path.name})")
645
+ return 0
646
+
647
+
648
+ def cmd_block(args: argparse.Namespace) -> int:
649
+ """标记任务被另一任务阻塞。自动维护反向 blocks 关系。
650
+ 用法: task.py block <task> <blocked-by-task>
651
+ 语义: <task> 被 <blocked-by-task> 阻塞 (要等它完成)。
652
+ """
653
+ repo_root = get_repo_root()
654
+ task_dir = resolve_task_dir(args.task, repo_root)
655
+ blocker_dir = resolve_task_dir(args.blocker, repo_root)
656
+ if not task_dir.is_dir():
657
+ print(f"Error: Task not found: {args.task}", file=sys.stderr)
658
+ return 1
659
+ if not blocker_dir.is_dir():
660
+ print(f"Error: Blocker task not found: {args.blocker}", file=sys.stderr)
661
+ return 1
662
+ # ACL: 只有 task 的 owner 能加依赖
663
+ try:
664
+ assert_can_modify_task(task_dir, "block", repo_root)
665
+ except PermissionError as e:
666
+ print(str(e), file=sys.stderr)
667
+ return 4
668
+
669
+ # 检测循环依赖
670
+ if _would_create_cycle(task_dir.name, blocker_dir.name, repo_root):
671
+ print(f"Error: 循环依赖检测到 ({blocker_dir.name} 已直接/间接依赖 {task_dir.name})",
672
+ file=sys.stderr)
673
+ return 1
674
+
675
+ # 更新 task.blocked_by
676
+ task_data = load_task_json(task_dir)
677
+ if not task_data:
678
+ print("Error: Missing task.json", file=sys.stderr)
679
+ return 1
680
+ blocked_by = task_data.get("blocked_by") or []
681
+ if blocker_dir.name in blocked_by:
682
+ print(f"Already blocked by {blocker_dir.name}")
683
+ return 0
684
+ blocked_by.append(blocker_dir.name)
685
+ task_data["blocked_by"] = blocked_by
686
+ task_data["updated_at"] = datetime.now().isoformat()
687
+ write_task_json(task_dir, task_data)
688
+
689
+ # 反向更新 blocker.blocks
690
+ blocker_data = load_task_json(blocker_dir)
691
+ if blocker_data:
692
+ blocks = blocker_data.get("blocks") or []
693
+ if task_dir.name not in blocks:
694
+ blocks.append(task_dir.name)
695
+ blocker_data["blocks"] = blocks
696
+ blocker_data["updated_at"] = datetime.now().isoformat()
697
+ write_task_json(blocker_dir, blocker_data)
698
+
699
+ print(f"{task_dir.name} now blocked by {blocker_dir.name}")
700
+ return 0
701
+
702
+
703
+ def cmd_unblock(args: argparse.Namespace) -> int:
704
+ """移除阻塞关系。用法: task.py unblock <task> <blocked-by-task>"""
705
+ repo_root = get_repo_root()
706
+ task_dir = resolve_task_dir(args.task, repo_root)
707
+ blocker_dir = resolve_task_dir(args.blocker, repo_root)
708
+ if task_dir.is_dir():
709
+ try:
710
+ assert_can_modify_task(task_dir, "unblock", repo_root)
711
+ except PermissionError as e:
712
+ print(str(e), file=sys.stderr)
713
+ return 4
714
+
715
+ removed = False
716
+ # 从 task.blocked_by 删
717
+ task_data = load_task_json(task_dir) if task_dir.is_dir() else None
718
+ if task_data and blocker_dir.name in (task_data.get("blocked_by") or []):
719
+ task_data["blocked_by"] = [x for x in task_data["blocked_by"] if x != blocker_dir.name]
720
+ task_data["updated_at"] = datetime.now().isoformat()
721
+ write_task_json(task_dir, task_data)
722
+ removed = True
723
+
724
+ # 从 blocker.blocks 删
725
+ blocker_data = load_task_json(blocker_dir) if blocker_dir.is_dir() else None
726
+ if blocker_data and task_dir.name in (blocker_data.get("blocks") or []):
727
+ blocker_data["blocks"] = [x for x in blocker_data["blocks"] if x != task_dir.name]
728
+ blocker_data["updated_at"] = datetime.now().isoformat()
729
+ write_task_json(blocker_dir, blocker_data)
730
+ removed = True
731
+
732
+ if removed:
733
+ print(f"Removed block: {task_dir.name} no longer blocked by {blocker_dir.name}")
734
+ else:
735
+ print(f"No such block relationship found")
736
+ return 0
737
+
738
+
739
+ def _would_create_cycle(task_name: str, blocker_name: str, repo_root) -> bool:
740
+ """检测添加 task←blocker 后是否会形成循环依赖。
741
+ 即: blocker 是否(直接/间接)已经依赖 task?
742
+ """
743
+ if task_name == blocker_name:
744
+ return True
745
+ # BFS: 从 blocker 出发, 沿 blocked_by 走, 看能否回到 task
746
+ visited = set()
747
+ queue = [blocker_name]
748
+ tasks_dir = get_tasks_dir(repo_root)
749
+ while queue:
750
+ current = queue.pop(0)
751
+ if current in visited:
752
+ continue
753
+ visited.add(current)
754
+ if current == task_name:
755
+ return True
756
+ cur_dir = tasks_dir / current
757
+ data = load_task_json(cur_dir) if cur_dir.is_dir() else None
758
+ if data:
759
+ for dep in (data.get("blocked_by") or []):
760
+ if dep not in visited:
761
+ queue.append(dep)
762
+ return False
763
+
764
+
765
+ def _is_task_ready(task_data: dict) -> bool:
766
+ """任务是否"可立即开始": 未完成 且 无未完成的 blocked_by。"""
767
+ if task_data.get("status") == "completed":
768
+ return False
769
+ blocked_by = task_data.get("blocked_by") or []
770
+ # blocked_by 为空 = 可开始 (这里简化: 不深入查依赖是否完成, 由调用方保证)
771
+ return len(blocked_by) == 0
772
+
773
+
774
+ def cmd_gantt(args: argparse.Namespace) -> int:
775
+ """渲染 markdown 甘特图 (按 start_date/due_date 排期)。
776
+ 用法: task.py gantt [--weeks 4]
777
+ """
778
+ repo_root = get_repo_root()
779
+ tasks_dir = get_tasks_dir(repo_root)
780
+ if not tasks_dir.is_dir():
781
+ print("No tasks directory")
782
+ return 0
783
+
784
+ from datetime import date, timedelta
785
+ today = date.today()
786
+ weeks = args.weeks if hasattr(args, "weeks") else 4
787
+ end = today + timedelta(weeks=weeks)
788
+
789
+ # 收集有日期的任务
790
+ items = []
791
+ for d in sorted(tasks_dir.iterdir()):
792
+ if not d.is_dir():
793
+ continue
794
+ data = load_task_json(d)
795
+ if not data:
796
+ continue
797
+ due = data.get("due_date")
798
+ start = data.get("start_date")
799
+ if not due and not start:
800
+ continue
801
+ items.append({
802
+ "name": d.name,
803
+ "title": data.get("title", d.name),
804
+ "start": start,
805
+ "due": due,
806
+ "status": data.get("status", "?"),
807
+ "assignee": data.get("assignee", "?"),
808
+ })
809
+
810
+ if not items:
811
+ print("No tasks with dates. 用 'task.py set-due <task> <YYYY-MM-DD>' 添加截止日期。")
812
+ return 0
813
+
814
+ # 按开始/截止日期排序
815
+ items.sort(key=lambda x: (x.get("start") or x.get("due") or "9999"))
816
+
817
+ print(f"# 任务排期 ({today} ~ {end})\n")
818
+ print("| 任务 | 负责人 | 开始 | 截止 | 状态 |")
819
+ print("|------|--------|------|------|------|")
820
+ for it in items:
821
+ start = it.get("start") or "-"
822
+ due = it.get("due") or "-"
823
+ # 标记逾期
824
+ if it.get("due") and it["status"] != "completed":
825
+ try:
826
+ due_date = date.fromisoformat(it["due"])
827
+ if due_date < today:
828
+ due = f"⚠️ {due} (逾期)"
829
+ except ValueError:
830
+ pass
831
+ print(f"| {it['name']} | {it['assignee']} | {start} | {due} | {it['status']} |")
832
+
833
+ # 简单时间轴 (ASCII)
834
+ print(f"\n## 时间轴 ({weeks} 周)\n")
835
+ print("```")
836
+ for it in items:
837
+ start_s = it.get("start") or it.get("due")
838
+ if not start_s:
839
+ continue
840
+ try:
841
+ start_d = date.fromisoformat(start_s) if it.get("start") else today
842
+ except ValueError:
843
+ start_d = today
844
+ due_s = it.get("due")
845
+ try:
846
+ due_d = date.fromisoformat(due_s) if due_s else start_d + timedelta(days=7)
847
+ except ValueError:
848
+ due_d = start_d + timedelta(days=7)
849
+ # 计算相对今天的偏移
850
+ start_off = max(0, (start_d - today).days)
851
+ due_off = min(weeks * 7, (due_d - today).days)
852
+ if due_off < 0:
853
+ bar = "[逾期]" + "=" * 5
854
+ else:
855
+ bar = " " * (start_off // 2) + "[" + "=" * max(1, (due_off - start_off) // 2) + "]"
856
+ print(f"{it['name'][:20]:20} {bar}")
857
+ print("```")
858
+ return 0
859
+
860
+
861
+ # =============================================================================
862
+ # Main
863
+ # =============================================================================
864
+
865
+ def main() -> int:
866
+ """CLI 入口。"""
867
+ parser = argparse.ArgumentParser(description="QODER Task Management")
868
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
869
+
870
+ # create
871
+ p_create = subparsers.add_parser("create", help="Create a new task")
872
+ p_create.add_argument("title", help="Task title")
873
+ p_create.add_argument("--slug", help="URL-safe slug (auto-generated if omitted)")
874
+ p_create.add_argument("--assignee", help="Assignee (defaults to current developer)")
875
+ p_create.add_argument("--priority", choices=["P0", "P1", "P2", "P3"], default="P2")
876
+ p_create.set_defaults(func=cmd_create)
877
+
878
+ # start
879
+ p_start = subparsers.add_parser("start", help="Start a task")
880
+ p_start.add_argument("dir", help="Task directory or name")
881
+ p_start.set_defaults(func=cmd_start)
882
+
883
+ # current
884
+ p_current = subparsers.add_parser("current", help="Show current task")
885
+ p_current.add_argument("--source", action="store_true", help="Show source info")
886
+ p_current.set_defaults(func=cmd_current)
887
+
888
+ # finish
889
+ p_finish = subparsers.add_parser("finish", help="Finish current task")
890
+ p_finish.set_defaults(func=cmd_finish)
891
+
892
+ # archive
893
+ p_archive = subparsers.add_parser("archive", help="Archive a task")
894
+ p_archive.add_argument("dir", help="Task directory or name")
895
+ p_archive.set_defaults(func=cmd_archive)
896
+
897
+ # list
898
+ p_list = subparsers.add_parser("list", help="List tasks")
899
+ p_list.add_argument("--mine", action="store_true", help="Only my tasks (assignee or creator)")
900
+ p_list.add_argument("--status", help="Filter by status")
901
+ p_list.add_argument("--ready", action="store_true", help="只显示可立即开始的 (无未完成依赖)")
902
+ p_list.add_argument("--blocked", action="store_true", help="只显示被阻塞的任务")
903
+ p_list.set_defaults(func=cmd_list)
904
+
905
+ # show
906
+ p_show = subparsers.add_parser("show", help="Show task details")
907
+ p_show.add_argument("dir", help="Task directory or name")
908
+ p_show.set_defaults(func=cmd_show)
909
+
910
+ # add-subtask
911
+ p_addsub = subparsers.add_parser("add-subtask", help="Add subtask")
912
+ p_addsub.add_argument("parent", help="Parent task")
913
+ p_addsub.add_argument("child", help="Child task")
914
+ p_addsub.set_defaults(func=cmd_add_subtask)
915
+
916
+ # remove-subtask
917
+ p_remsub = subparsers.add_parser("remove-subtask", help="Remove subtask")
918
+ p_remsub.add_argument("parent", help="Parent task")
919
+ p_remsub.add_argument("child", help="Child task")
920
+ p_remsub.set_defaults(func=cmd_remove_subtask)
921
+
922
+ # set-due (B2)
923
+ p_due = subparsers.add_parser("set-due", help="Set due date (YYYY-MM-DD)")
924
+ p_due.add_argument("task", help="Task name")
925
+ p_due.add_argument("due_date", help="Due date YYYY-MM-DD")
926
+ p_due.set_defaults(func=cmd_set_due)
927
+
928
+ # block (B2)
929
+ p_block = subparsers.add_parser("block", help="Mark task blocked by another")
930
+ p_block.add_argument("task", help="Task that is blocked")
931
+ p_block.add_argument("blocker", help="Task that blocks it (must finish first)")
932
+ p_block.set_defaults(func=cmd_block)
933
+
934
+ # unblock (B2)
935
+ p_unblock = subparsers.add_parser("unblock", help="Remove block relationship")
936
+ p_unblock.add_argument("task", help="Task")
937
+ p_unblock.add_argument("blocker", help="Blocker task")
938
+ p_unblock.set_defaults(func=cmd_unblock)
939
+
940
+ # gantt (B2)
941
+ p_gantt = subparsers.add_parser("gantt", help="Render markdown Gantt chart")
942
+ p_gantt.add_argument("--weeks", type=int, default=4, help="Number of weeks to show")
943
+ p_gantt.set_defaults(func=cmd_gantt)
944
+
945
+ args = parser.parse_args()
946
+ if not hasattr(args, "func"):
947
+ parser.print_help()
948
+ return 1
949
+
950
+ return args.func(args)
951
+
952
+
953
+ if __name__ == "__main__":
954
+ sys.exit(main())