@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.
- package/bin/cli.js +213 -0
- package/package.json +11 -0
- package/templates/cli.js +198 -0
- package/templates/qoder/commands/wl-code.md +43 -0
- package/templates/qoder/commands/wl-commit.md +30 -0
- package/templates/qoder/commands/wl-init.md +80 -0
- package/templates/qoder/commands/wl-insight.md +51 -0
- package/templates/qoder/commands/wl-prd.md +199 -0
- package/templates/qoder/commands/wl-report.md +166 -0
- package/templates/qoder/commands/wl-search.md +52 -0
- package/templates/qoder/commands/wl-spec.md +18 -0
- package/templates/qoder/commands/wl-status.md +51 -0
- package/templates/qoder/commands/wl-task.md +71 -0
- package/templates/qoder/commands/wl-test.md +42 -0
- package/templates/qoder/config.toml +5 -0
- package/templates/qoder/config.yaml +141 -0
- package/templates/qoder/hooks/inject-workflow-state.py +117 -0
- package/templates/qoder/hooks/session-start.py +204 -0
- package/templates/qoder/rules/wl-pipeline.md +105 -0
- package/templates/qoder/scripts/add_session.py +245 -0
- package/templates/qoder/scripts/benchmark.py +209 -0
- package/templates/qoder/scripts/build_style_index.py +268 -0
- package/templates/qoder/scripts/code_index.py +41 -0
- package/templates/qoder/scripts/collect_prds.py +31 -0
- package/templates/qoder/scripts/common/__init__.py +0 -0
- package/templates/qoder/scripts/common/active_task.py +230 -0
- package/templates/qoder/scripts/common/atomicio.py +172 -0
- package/templates/qoder/scripts/common/developer.py +161 -0
- package/templates/qoder/scripts/common/eval_api.py +144 -0
- package/templates/qoder/scripts/common/feishu.py +278 -0
- package/templates/qoder/scripts/common/filelock.py +211 -0
- package/templates/qoder/scripts/common/identity.py +285 -0
- package/templates/qoder/scripts/common/mentions.py +134 -0
- package/templates/qoder/scripts/common/paths.py +311 -0
- package/templates/qoder/scripts/common/reqid.py +218 -0
- package/templates/qoder/scripts/common/search_engine.py +205 -0
- package/templates/qoder/scripts/common/task_utils.py +342 -0
- package/templates/qoder/scripts/common/terms.py +234 -0
- package/templates/qoder/scripts/common/utf8.py +38 -0
- package/templates/qoder/scripts/context_pack.py +196 -0
- package/templates/qoder/scripts/eval_prd.py +225 -0
- package/templates/qoder/scripts/export.py +487 -0
- package/templates/qoder/scripts/git_sync.py +1087 -0
- package/templates/qoder/scripts/handoff.py +22 -0
- package/templates/qoder/scripts/init_developer.py +76 -0
- package/templates/qoder/scripts/init_doctor.py +527 -0
- package/templates/qoder/scripts/install_qoderwork.py +339 -0
- package/templates/qoder/scripts/learn.py +67 -0
- package/templates/qoder/scripts/notify.py +5 -0
- package/templates/qoder/scripts/parse_prds.py +33 -0
- package/templates/qoder/scripts/report.py +281 -0
- package/templates/qoder/scripts/role.py +39 -0
- package/templates/qoder/scripts/run_weekly_update.bat +17 -0
- package/templates/qoder/scripts/run_weekly_update.sh +20 -0
- package/templates/qoder/scripts/search_index.py +352 -0
- package/templates/qoder/scripts/setup.py +453 -0
- package/templates/qoder/scripts/setup_weekly_cron.bat +22 -0
- package/templates/qoder/scripts/setup_weekly_cron.sh +19 -0
- package/templates/qoder/scripts/status.py +389 -0
- package/templates/qoder/scripts/syncgate.py +330 -0
- package/templates/qoder/scripts/task.py +954 -0
- package/templates/qoder/scripts/team.py +29 -0
- package/templates/qoder/scripts/team_sync.py +419 -0
- package/templates/qoder/scripts/workspace_init.py +102 -0
- package/templates/qoder/settings.json +53 -0
- package/templates/qoder/skills/design-review/SKILL.md +25 -0
- package/templates/qoder/skills/prd-generator/SKILL.md +180 -0
- package/templates/qoder/skills/prd-review/SKILL.md +36 -0
- package/templates/qoder/skills/prototype-generator/SKILL.md +141 -0
- package/templates/qoder/skills/spec-coder/SKILL.md +69 -0
- package/templates/qoder/skills/spec-generator/SKILL.md +67 -0
- package/templates/qoder/skills/test-generator/SKILL.md +72 -0
- package/templates/qoder/skills/wl-commit/SKILL.md +76 -0
- package/templates/qoder/skills/wl-init/SKILL.md +67 -0
- package/templates/qoder/skills/wl-insight/SKILL.md +81 -0
- package/templates/qoder/skills/wl-report/SKILL.md +87 -0
- package/templates/qoder/skills/wl-search/SKILL.md +75 -0
- package/templates/qoder/skills/wl-status/SKILL.md +61 -0
- package/templates/qoder/skills/wl-task/SKILL.md +58 -0
- package/templates/qoder/templates/prd-full-template.md +103 -0
- package/templates/qoder/templates/prd-quick-template.md +69 -0
- package/templates/qoder/templates/prototype-app.html +344 -0
- package/templates/qoder/templates/prototype-web.html +310 -0
- package/templates/root/AGENTS.md +182 -0
- package/templates/root/README-pipeline.md +56 -0
- package/templates/root/ROLES.md +85 -0
- 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())
|