@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.
- package/bin/cli.js +375 -78
- package/package.json +29 -29
- package/templates/.qoder/.runtime/ctx-cache-5660152f1d6dd819.md +23 -0
- package/templates/.qoder/.runtime/ctx-cache-afdce0dac06b25b0.md +23 -0
- package/templates/.qoder/.runtime/search-cache-eae7644e7b122f35.txt +1 -0
- package/templates/.qoder/learning/eval-history.jsonl +28 -0
- package/templates/data/index/wiki-index.json +8 -0
- package/templates/qoder/agents/insight-planning.md +1 -1
- package/templates/qoder/agents/insight-research.md +28 -15
- package/templates/qoder/commands/optional/wl-insight.md +159 -161
- package/templates/qoder/commands/optional/wl-report.md +4 -4
- package/templates/qoder/commands/optional/wl-status.md +2 -2
- package/templates/qoder/commands/wl-code.md +2 -2
- package/templates/qoder/commands/wl-design.md +2 -2
- package/templates/qoder/commands/wl-init.md +3 -3
- package/templates/qoder/commands/wl-prd.md +2 -2
- package/templates/qoder/commands/wl-req.md +43 -0
- package/templates/qoder/commands/wl-search.md +8 -8
- package/templates/qoder/commands/wl-task.md +17 -17
- package/templates/qoder/commands/wl-test.md +41 -15
- package/templates/qoder/config.yaml +17 -1
- package/templates/qoder/hooks/session-start.py +12 -22
- package/templates/qoder/nul +4 -0
- package/templates/qoder/rules/wl-pipeline.md +22 -48
- package/templates/qoder/scripts/README.md +139 -0
- package/templates/qoder/scripts/common/autotest_auth.py +109 -0
- package/templates/qoder/scripts/common/bootstrap.py +145 -0
- package/templates/qoder/scripts/common/check_publish.py +98 -0
- package/templates/qoder/scripts/common/cmd_registry.py +112 -0
- package/templates/qoder/scripts/common/config.py +187 -0
- package/templates/qoder/scripts/common/contract.py +317 -0
- package/templates/qoder/scripts/common/developer.py +2 -1
- package/templates/qoder/scripts/common/feishu.py +10 -9
- package/templates/qoder/scripts/common/guard.py +159 -0
- package/templates/qoder/scripts/common/identity.py +121 -2
- package/templates/qoder/scripts/common/kg_capabilities.py +182 -0
- package/templates/qoder/scripts/common/mcp_base.py +268 -0
- package/templates/qoder/scripts/common/paths.py +187 -1
- package/templates/qoder/scripts/common/result.py +223 -0
- package/templates/qoder/scripts/common/roles.py +60 -0
- package/templates/qoder/scripts/common/task_utils.py +21 -9
- package/templates/qoder/scripts/common/test_extract.py +115 -0
- package/templates/qoder/scripts/kg/__init__.py +11 -0
- package/templates/qoder/scripts/kg/build_entity_registry.py +196 -0
- package/templates/qoder/scripts/kg/build_relations.py +127 -0
- package/templates/qoder/scripts/{build_style_index.py → kg/build_style_index.py} +39 -10
- package/templates/qoder/scripts/kg/build_workflows.py +144 -0
- package/templates/qoder/scripts/{context_pack.py → kg/context_pack.py} +18 -11
- package/templates/qoder/scripts/{enrich_prompt.py → kg/enrich_prompt.py} +232 -226
- package/templates/qoder/scripts/{extract_api_params.py → kg/extract.py} +398 -246
- package/templates/qoder/scripts/{kg.py → kg/kg.py} +638 -708
- package/templates/qoder/scripts/{kg_build.py → kg/kg_build.py} +618 -612
- package/templates/qoder/scripts/{kg_build_db.py → kg/kg_build_db.py} +333 -327
- package/templates/qoder/scripts/{kg_duckdb.py → kg/kg_duckdb.py} +38 -37
- package/templates/qoder/scripts/{kg_incremental.py → kg/kg_incremental.py} +420 -393
- package/templates/qoder/scripts/{kg_link_db.py → kg/kg_link_db.py} +230 -224
- package/templates/qoder/scripts/{kg_semantic.py → kg/kg_semantic.py} +156 -150
- package/templates/qoder/scripts/kg/prefetch.py +359 -0
- package/templates/qoder/scripts/{search_index.py → kg/search_index.py} +70 -14
- package/templates/qoder/scripts/mcp/__init__.py +11 -0
- package/templates/qoder/scripts/{kg_mcp_server.py → mcp/kg_mcp_server.py} +77 -272
- package/templates/qoder/scripts/{lanhu_stdio_wrapper.py → mcp/lanhu_stdio_wrapper.py} +125 -119
- package/templates/qoder/scripts/{check_mcp.py → mcp/mcp_doctor.py} +515 -298
- package/templates/qoder/scripts/{mcp_launcher.py → mcp/mcp_launcher.py} +442 -414
- package/templates/qoder/scripts/{mysql_mcp_server.py → mcp/mysql_mcp_server.py} +347 -396
- package/templates/qoder/scripts/{zentao_mcp_server.py → mcp/zentao_mcp_server.py} +384 -424
- package/templates/qoder/scripts/report/__init__.py +11 -0
- package/templates/qoder/scripts/{add_session.py → report/add_session.py} +250 -244
- package/templates/qoder/scripts/{archive_prd.py → report/archive_prd.py} +383 -377
- package/templates/qoder/scripts/{eval_prd.py → report/eval_prd.py} +73 -11
- package/templates/qoder/scripts/{export.py → report/export.py} +63 -0
- package/templates/qoder/scripts/{fill_prototype.py → report/fill_prototype.py} +6 -0
- package/templates/qoder/scripts/{gen_design_doc.py → report/gen_design_doc.py} +400 -394
- package/templates/qoder/scripts/{learn.py → report/learn.py} +152 -146
- package/templates/qoder/scripts/{learn_aggregate.py → report/learn_aggregate.py} +207 -201
- package/templates/qoder/scripts/{report.py → report/report.py} +287 -281
- package/templates/qoder/scripts/report/req.py +222 -0
- package/templates/qoder/scripts/report/role.py +33 -0
- package/templates/qoder/scripts/{status.py → report/status.py} +634 -628
- package/templates/qoder/scripts/setup/__init__.py +11 -0
- package/templates/qoder/scripts/setup/carriers.py +662 -0
- package/templates/qoder/scripts/{init_doctor.py → setup/init_doctor.py} +63 -26
- package/templates/qoder/scripts/{install_qoderwork.py → setup/install_qoderwork.py} +36 -26
- package/templates/qoder/scripts/{platform_doctor.py → setup/platform_doctor.py} +265 -259
- package/templates/qoder/scripts/{repo_root.py → setup/repo_root.py} +112 -106
- package/templates/qoder/scripts/{setup.py → setup/setup.py} +147 -0
- package/templates/qoder/scripts/{setup_lanhu.py → setup/setup_lanhu.py} +973 -963
- package/templates/qoder/scripts/task/__init__.py +11 -0
- package/templates/qoder/scripts/{git_sync.py → task/git_sync.py} +52 -31
- package/templates/qoder/scripts/{syncgate.py → task/syncgate.py} +6 -0
- package/templates/qoder/scripts/task/task.py +221 -0
- package/templates/qoder/scripts/task/task_lifecycle.py +596 -0
- package/templates/qoder/scripts/task/task_query.py +161 -0
- package/templates/qoder/scripts/task/task_relations.py +424 -0
- package/templates/qoder/scripts/{team_sync.py → task/team_sync.py} +93 -20
- package/templates/qoder/scripts/test/__init__.py +11 -0
- package/templates/qoder/scripts/{autotest.py → test/autotest.py} +1174 -1751
- package/templates/qoder/scripts/{autotest_batch.py → test/autotest_batch.py} +242 -224
- package/templates/qoder/scripts/test/autotest_data.py +675 -0
- package/templates/qoder/scripts/{autotest_run.py → test/autotest_run.py} +309 -297
- package/templates/qoder/scripts/{benchmark.py → test/benchmark.py} +6 -0
- package/templates/qoder/scripts/{kg_auto_login.py → test/kg_auto_login.py} +202 -196
- package/templates/qoder/scripts/{kg_test_runner.py → test/kg_test_runner.py} +7 -1
- package/templates/qoder/scripts/{page_probe.py → test/page_probe.py} +465 -459
- package/templates/qoder/scripts/wlkj.py +116 -0
- package/templates/qoder/settings.json +1 -10
- package/templates/qoder/skills/design-import/SKILL.md +226 -226
- package/templates/qoder/skills/design-review/SKILL.md +82 -82
- package/templates/qoder/skills/prd-generator/SKILL.md +26 -16
- package/templates/qoder/skills/prd-review/SKILL.md +5 -5
- package/templates/qoder/skills/prototype-generator/SKILL.md +256 -256
- package/templates/qoder/skills/spec-coder/SKILL.md +4 -4
- package/templates/qoder/skills/spec-generator/SKILL.md +4 -4
- package/templates/qoder/skills/test-generator/SKILL.md +5 -5
- package/templates/qoder/skills/wl-code/SKILL.md +4 -4
- package/templates/qoder/skills/wl-commit/SKILL.md +4 -4
- package/templates/qoder/skills/wl-design/SKILL.md +3 -3
- package/templates/qoder/skills/wl-init/SKILL.md +8 -8
- package/templates/qoder/skills/wl-insight/SKILL.md +5 -5
- package/templates/qoder/skills/wl-prd-full/SKILL.md +6 -6
- package/templates/qoder/skills/wl-prd-quick/SKILL.md +6 -6
- package/templates/qoder/skills/wl-prd-review/SKILL.md +4 -4
- package/templates/qoder/skills/wl-report/SKILL.md +7 -7
- package/templates/qoder/skills/wl-search/SKILL.md +13 -13
- package/templates/qoder/skills/wl-spec/SKILL.md +5 -5
- package/templates/qoder/skills/wl-status/SKILL.md +5 -5
- package/templates/qoder/skills/wl-task/SKILL.md +6 -6
- package/templates/qoder/skills/wl-test/SKILL.md +102 -39
- package/templates/root/AGENTS.md +39 -40
- package/templates/qoder/hooks/inject-workflow-state.py +0 -169
- package/templates/qoder/scripts/__pycache__/check_mcp_launch.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/__pycache__/install_qoderwork.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/__pycache__/mcp_launcher.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/__pycache__/platform_doctor.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/check_carriers.py +0 -238
- package/templates/qoder/scripts/check_mcp_launch.py +0 -183
- package/templates/qoder/scripts/check_qoderwork_consistency.py +0 -166
- package/templates/qoder/scripts/collect_prds.py +0 -31
- package/templates/qoder/scripts/common/mentions.py +0 -134
- package/templates/qoder/scripts/common/utf8.py +0 -38
- package/templates/qoder/scripts/extract_routes.py +0 -54
- package/templates/qoder/scripts/extract_routes_tree.py +0 -78
- package/templates/qoder/scripts/handoff.py +0 -22
- package/templates/qoder/scripts/init_developer.py +0 -76
- package/templates/qoder/scripts/parse_prds.py +0 -33
- package/templates/qoder/scripts/role.py +0 -51
- package/templates/qoder/scripts/sync_carriers.py +0 -259
- package/templates/qoder/scripts/task.py +0 -1261
- package/templates/qoder/scripts/workspace_init.py +0 -102
- package/templates/qoder/skills/prompt-enrich/SKILL.md +0 -90
- package/templates/qoder/skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp +0 -0
- /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())
|