@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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""task_query.py - task 查询层 (v3.0 f6 从 task.py 拆出)"""
|
|
4
|
+
# v3.0 路径自举
|
|
5
|
+
import os as _o, sys as _s, argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
_cp = _o.path.join(_o.path.dirname(_o.path.dirname(_o.path.abspath(__file__))), 'common')
|
|
9
|
+
if _cp not in _s.path: _s.path.insert(0, _cp)
|
|
10
|
+
from bootstrap import setup; setup()
|
|
11
|
+
|
|
12
|
+
import os, sys, json, shutil
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from common.paths import (
|
|
16
|
+
DIR_TASKS, DIR_ARCHIVE, FILE_TASK_JSON,
|
|
17
|
+
get_repo_root, get_developer, get_tasks_dir,
|
|
18
|
+
)
|
|
19
|
+
from common.active_task import (
|
|
20
|
+
clear_active_task, resolve_active_task, resolve_context_key, set_active_task,
|
|
21
|
+
)
|
|
22
|
+
from common.task_utils import (
|
|
23
|
+
resolve_task_dir, run_task_hooks, load_task_json, write_task_json,
|
|
24
|
+
modify_task_json, assert_can_modify_task,
|
|
25
|
+
)
|
|
26
|
+
from common.atomicio import atomic_write_json, safe_read_json, atomic_write_text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
30
|
+
"""列出活跃任务。支持 --mine / --status / --ready / --blocked 过滤。"""
|
|
31
|
+
repo_root = get_repo_root()
|
|
32
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
33
|
+
developer = get_developer(repo_root)
|
|
34
|
+
active = resolve_active_task(repo_root)
|
|
35
|
+
|
|
36
|
+
filter_mine = args.mine
|
|
37
|
+
filter_status = args.status
|
|
38
|
+
filter_ready = getattr(args, "ready", False)
|
|
39
|
+
filter_blocked = getattr(args, "blocked", False)
|
|
40
|
+
|
|
41
|
+
if not tasks_dir.is_dir():
|
|
42
|
+
print("No tasks directory found")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
# 预加载所有任务 (用于 --ready/--blocked 判断依赖是否完成)
|
|
46
|
+
all_tasks = {}
|
|
47
|
+
for d in sorted(tasks_dir.iterdir()):
|
|
48
|
+
if d.is_dir():
|
|
49
|
+
data = load_task_json(d)
|
|
50
|
+
if data:
|
|
51
|
+
all_tasks[d.name] = data
|
|
52
|
+
|
|
53
|
+
def is_dep_completed(dep_name: str) -> bool:
|
|
54
|
+
dep = all_tasks.get(dep_name)
|
|
55
|
+
return bool(dep and dep.get("status") == "completed")
|
|
56
|
+
|
|
57
|
+
tasks = []
|
|
58
|
+
for name, task_data in all_tasks.items():
|
|
59
|
+
d = tasks_dir / name
|
|
60
|
+
|
|
61
|
+
# 过滤: --mine
|
|
62
|
+
if filter_mine and developer not in (
|
|
63
|
+
task_data.get("assignee"), task_data.get("creator")
|
|
64
|
+
):
|
|
65
|
+
continue
|
|
66
|
+
if filter_status and task_data.get("status") != filter_status:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
# --ready: 未完成 且 所有 blocked_by 已完成
|
|
70
|
+
if filter_ready:
|
|
71
|
+
if task_data.get("status") == "completed":
|
|
72
|
+
continue
|
|
73
|
+
blocked_by = task_data.get("blocked_by") or []
|
|
74
|
+
if any(not is_dep_completed(dep) for dep in blocked_by):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
# --blocked: 有未完成的 blocked_by
|
|
78
|
+
if filter_blocked:
|
|
79
|
+
blocked_by = task_data.get("blocked_by") or []
|
|
80
|
+
open_blocks = [dep for dep in blocked_by if not is_dep_completed(dep)]
|
|
81
|
+
if not open_blocks:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
tasks.append((name, task_data))
|
|
85
|
+
|
|
86
|
+
if not tasks:
|
|
87
|
+
print("No tasks found" + (
|
|
88
|
+
" (no tasks ready to start)" if filter_ready else
|
|
89
|
+
" (no blocked tasks)" if filter_blocked else ""
|
|
90
|
+
))
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
print(f"Tasks ({len(tasks)}):")
|
|
94
|
+
for name, data in tasks:
|
|
95
|
+
is_current = active.task_path and name in active.task_path
|
|
96
|
+
marker = " *" if is_current else " "
|
|
97
|
+
assignee = data.get("assignee", "?")
|
|
98
|
+
status = data.get("status", "?")
|
|
99
|
+
priority = data.get("priority", "?")
|
|
100
|
+
title = data.get("title", name)
|
|
101
|
+
extras = []
|
|
102
|
+
due = data.get("due_date")
|
|
103
|
+
if due:
|
|
104
|
+
extras.append(f"due:{due}")
|
|
105
|
+
blocked_by = data.get("blocked_by") or []
|
|
106
|
+
open_blocks = [dep for dep in blocked_by if not is_dep_completed(dep)]
|
|
107
|
+
if open_blocks:
|
|
108
|
+
extras.append(f"blocked:{','.join(open_blocks)}")
|
|
109
|
+
extra_str = f" [{', '.join(extras)}]" if extras else ""
|
|
110
|
+
print(f"{marker} [{priority}] [{status}] {name} ({assignee}) - {title}{extra_str}")
|
|
111
|
+
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# =============================================================================
|
|
116
|
+
# 命令: show
|
|
117
|
+
# =============================================================================
|
|
118
|
+
|
|
119
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
120
|
+
"""显示任务详情。"""
|
|
121
|
+
repo_root = get_repo_root()
|
|
122
|
+
full_path = resolve_task_dir(args.dir, repo_root)
|
|
123
|
+
|
|
124
|
+
if not full_path.is_dir():
|
|
125
|
+
print(f"Error: Task not found: {args.dir}", file=sys.stderr)
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
task_data = load_task_json(full_path)
|
|
129
|
+
if not task_data:
|
|
130
|
+
print(f"Error: Missing task.json in {full_path.name}", file=sys.stderr)
|
|
131
|
+
return 1
|
|
132
|
+
|
|
133
|
+
print(f"Task: {full_path.name}")
|
|
134
|
+
for key in ("title", "status", "priority", "rice", "assignee", "creator",
|
|
135
|
+
"created_at", "updated_at", "parent", "tags",
|
|
136
|
+
"due_date", "start_date", "estimate_hours"):
|
|
137
|
+
val = task_data.get(key)
|
|
138
|
+
if val not in (None, [], ""):
|
|
139
|
+
print(f" {key}: {val}")
|
|
140
|
+
children = task_data.get("children") or []
|
|
141
|
+
if children:
|
|
142
|
+
print(f" children: {', '.join(children)}")
|
|
143
|
+
blocked_by = task_data.get("blocked_by") or []
|
|
144
|
+
if blocked_by:
|
|
145
|
+
print(f" blocked_by: {', '.join(blocked_by)}")
|
|
146
|
+
blocks = task_data.get("blocks") or []
|
|
147
|
+
if blocks:
|
|
148
|
+
print(f" blocks: {', '.join(blocks)}")
|
|
149
|
+
stage_ts = task_data.get("stage_ts") or {}
|
|
150
|
+
if any(stage_ts.values()):
|
|
151
|
+
print(f" stage_ts: {stage_ts}")
|
|
152
|
+
prd_file = full_path / "prd.md"
|
|
153
|
+
if prd_file.is_file():
|
|
154
|
+
print(f" prd: workspace/tasks/{full_path.name}/prd.md")
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# =============================================================================
|
|
159
|
+
# 命令: add-subtask / remove-subtask
|
|
160
|
+
# =============================================================================
|
|
161
|
+
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""task_relations.py - task 关系/排期层 (v3.0 f6 从 task.py 拆出)"""
|
|
4
|
+
# v3.0 路径自举
|
|
5
|
+
import os as _o, sys as _s, argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
_cp = _o.path.join(_o.path.dirname(_o.path.dirname(_o.path.abspath(__file__))), 'common')
|
|
9
|
+
if _cp not in _s.path: _s.path.insert(0, _cp)
|
|
10
|
+
from bootstrap import setup; setup()
|
|
11
|
+
|
|
12
|
+
import os, sys, json, shutil
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from common.paths import (
|
|
16
|
+
DIR_TASKS, DIR_ARCHIVE, FILE_TASK_JSON,
|
|
17
|
+
get_repo_root, get_developer, get_tasks_dir,
|
|
18
|
+
)
|
|
19
|
+
from common.active_task import (
|
|
20
|
+
clear_active_task, resolve_active_task, resolve_context_key, set_active_task,
|
|
21
|
+
)
|
|
22
|
+
from common.task_utils import (
|
|
23
|
+
resolve_task_dir, run_task_hooks, load_task_json, write_task_json,
|
|
24
|
+
modify_task_json, assert_can_modify_task,
|
|
25
|
+
)
|
|
26
|
+
from common.atomicio import atomic_write_json, safe_read_json, atomic_write_text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cmd_add_subtask(args: argparse.Namespace) -> int:
|
|
30
|
+
"""添加子任务关联。"""
|
|
31
|
+
repo_root = get_repo_root()
|
|
32
|
+
parent_dir = resolve_task_dir(args.parent, repo_root)
|
|
33
|
+
child_dir = resolve_task_dir(args.child, repo_root)
|
|
34
|
+
|
|
35
|
+
if not parent_dir.is_dir():
|
|
36
|
+
print(f"Error: Parent task not found: {args.parent}", file=sys.stderr)
|
|
37
|
+
return 1
|
|
38
|
+
if not child_dir.is_dir():
|
|
39
|
+
print(f"Error: Child task not found: {args.child}", file=sys.stderr)
|
|
40
|
+
return 1
|
|
41
|
+
|
|
42
|
+
# 零信任 ACL: 只有 parent 的 creator/assignee/admin 能加子任务
|
|
43
|
+
try:
|
|
44
|
+
assert_can_modify_task(parent_dir, "add-subtask", repo_root)
|
|
45
|
+
except PermissionError as e:
|
|
46
|
+
print(str(e), file=sys.stderr)
|
|
47
|
+
return 4
|
|
48
|
+
|
|
49
|
+
parent_data = load_task_json(parent_dir)
|
|
50
|
+
child_data = load_task_json(child_dir)
|
|
51
|
+
|
|
52
|
+
if not parent_data or not child_data:
|
|
53
|
+
print("Error: Missing task.json", file=sys.stderr)
|
|
54
|
+
return 1
|
|
55
|
+
|
|
56
|
+
child_name = child_dir.name
|
|
57
|
+
children = parent_data.get("children", [])
|
|
58
|
+
if child_name not in children:
|
|
59
|
+
children.append(child_name)
|
|
60
|
+
parent_data["children"] = children
|
|
61
|
+
parent_data["updated_at"] = datetime.now().isoformat()
|
|
62
|
+
write_task_json(parent_dir, parent_data)
|
|
63
|
+
|
|
64
|
+
# 子任务记录父任务
|
|
65
|
+
child_data["parent"] = parent_dir.name
|
|
66
|
+
child_data["updated_at"] = datetime.now().isoformat()
|
|
67
|
+
write_task_json(child_dir, child_data)
|
|
68
|
+
|
|
69
|
+
print(f"Subtask added: {parent_dir.name} -> {child_dir.name}")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_remove_subtask(args: argparse.Namespace) -> int:
|
|
74
|
+
"""移除子任务关联。"""
|
|
75
|
+
repo_root = get_repo_root()
|
|
76
|
+
parent_dir = resolve_task_dir(args.parent, repo_root)
|
|
77
|
+
child_dir = resolve_task_dir(args.child, repo_root)
|
|
78
|
+
|
|
79
|
+
# 零信任 ACL
|
|
80
|
+
if parent_dir.is_dir():
|
|
81
|
+
try:
|
|
82
|
+
assert_can_modify_task(parent_dir, "remove-subtask", repo_root)
|
|
83
|
+
except PermissionError as e:
|
|
84
|
+
print(str(e), file=sys.stderr)
|
|
85
|
+
return 4
|
|
86
|
+
|
|
87
|
+
parent_data = load_task_json(parent_dir)
|
|
88
|
+
child_data = load_task_json(child_dir)
|
|
89
|
+
|
|
90
|
+
if parent_data:
|
|
91
|
+
children = parent_data.get("children", [])
|
|
92
|
+
if child_dir.name in children:
|
|
93
|
+
children.remove(child_dir.name)
|
|
94
|
+
parent_data["children"] = children
|
|
95
|
+
parent_data["updated_at"] = datetime.now().isoformat()
|
|
96
|
+
write_task_json(parent_dir, parent_data)
|
|
97
|
+
|
|
98
|
+
if child_data:
|
|
99
|
+
child_data["parent"] = None
|
|
100
|
+
child_data["updated_at"] = datetime.now().isoformat()
|
|
101
|
+
write_task_json(child_dir, child_data)
|
|
102
|
+
|
|
103
|
+
print(f"Subtask removed: {parent_dir.name} -x- {child_dir.name}")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# =============================================================================
|
|
108
|
+
# 工具函数
|
|
109
|
+
# =============================================================================
|
|
110
|
+
|
|
111
|
+
def _generate_slug(title: str) -> str:
|
|
112
|
+
"""从标题生成 URL-safe slug。"""
|
|
113
|
+
# 简单处理: 小写, 空格转连字符, 只保留字母数字和连字符
|
|
114
|
+
slug = title.lower().strip()
|
|
115
|
+
slug = "".join(c if c.isalnum() or c in " -_" else "" for c in slug)
|
|
116
|
+
slug = slug.replace(" ", "-").replace("_", "-")
|
|
117
|
+
slug = "-".join(part for part in slug.split("-") if part)
|
|
118
|
+
return slug[:50] if slug else "untitled"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# =============================================================================
|
|
122
|
+
# 命令: set-due / block / unblock / gantt (B2 能力增强)
|
|
123
|
+
# =============================================================================
|
|
124
|
+
|
|
125
|
+
def cmd_set_due(args: argparse.Namespace) -> int:
|
|
126
|
+
"""设置任务截止日期。用法: task.py set-due <task> <YYYY-MM-DD>"""
|
|
127
|
+
repo_root = get_repo_root()
|
|
128
|
+
full_path = resolve_task_dir(args.task, repo_root)
|
|
129
|
+
if not full_path.is_dir():
|
|
130
|
+
print(f"Error: Task not found: {args.task}", file=sys.stderr)
|
|
131
|
+
return 1
|
|
132
|
+
# ACL
|
|
133
|
+
try:
|
|
134
|
+
assert_can_modify_task(full_path, "set-due", repo_root)
|
|
135
|
+
except PermissionError as e:
|
|
136
|
+
print(str(e), file=sys.stderr)
|
|
137
|
+
return 4
|
|
138
|
+
def _set_due_mutator(data):
|
|
139
|
+
data["due_date"] = args.due_date
|
|
140
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
141
|
+
if not modify_task_json(full_path, _set_due_mutator):
|
|
142
|
+
print("Error: Missing task.json", file=sys.stderr)
|
|
143
|
+
return 1
|
|
144
|
+
print(f"Set due_date: {args.due_date} ({full_path.name})")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cmd_reassign(args: argparse.Namespace) -> int:
|
|
149
|
+
"""改派任务负责人。用法: task.py reassign <task> <new_assignee>
|
|
150
|
+
|
|
151
|
+
场景: PM 建任务后改派给开发(多角色协作必需)。
|
|
152
|
+
ACL: 只有 creator/admin 能改派(防恶意抢占/甩锅)。
|
|
153
|
+
保留改派历史(assignee_history), 便于追溯。
|
|
154
|
+
"""
|
|
155
|
+
repo_root = get_repo_root()
|
|
156
|
+
full_path = resolve_task_dir(args.task, repo_root)
|
|
157
|
+
if not full_path.is_dir():
|
|
158
|
+
print(f"Error: Task not found: {args.task}", file=sys.stderr)
|
|
159
|
+
return 1
|
|
160
|
+
# ACL: 只有 creator/admin 能改派 (assignee 自己不能转手甩锅)
|
|
161
|
+
try:
|
|
162
|
+
assert_can_modify_task(full_path, "reassign", repo_root)
|
|
163
|
+
except PermissionError as e:
|
|
164
|
+
print(str(e), file=sys.stderr)
|
|
165
|
+
return 4
|
|
166
|
+
|
|
167
|
+
new_assignee = (args.new_assignee or "").strip()
|
|
168
|
+
if not new_assignee:
|
|
169
|
+
print("Error: new_assignee 不能为空", file=sys.stderr)
|
|
170
|
+
return 1
|
|
171
|
+
|
|
172
|
+
changed = {"from": None, "to": new_assignee}
|
|
173
|
+
|
|
174
|
+
def _reassign_mutator(data):
|
|
175
|
+
old = data.get("assignee")
|
|
176
|
+
changed["from"] = old
|
|
177
|
+
data["assignee"] = new_assignee
|
|
178
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
179
|
+
# 记录改派历史 (追溯用)
|
|
180
|
+
hist = data.get("assignee_history") or []
|
|
181
|
+
hist.append({
|
|
182
|
+
"from": old,
|
|
183
|
+
"to": new_assignee,
|
|
184
|
+
"by": get_developer(repo_root),
|
|
185
|
+
"at": datetime.now().isoformat(),
|
|
186
|
+
})
|
|
187
|
+
data["assignee_history"] = hist
|
|
188
|
+
|
|
189
|
+
if not modify_task_json(full_path, _reassign_mutator):
|
|
190
|
+
print("Error: Missing task.json", file=sys.stderr)
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
if changed["from"] == new_assignee:
|
|
194
|
+
print(f"Note: assignee 已是 {new_assignee}, 无变化 ({full_path.name})")
|
|
195
|
+
else:
|
|
196
|
+
print(f"Reassigned: {changed['from']} -> {new_assignee} ({full_path.name})")
|
|
197
|
+
print(f" 改派人: {get_developer(repo_root)} (已记录到 assignee_history)")
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_block(args: argparse.Namespace) -> int:
|
|
202
|
+
"""标记任务被另一任务阻塞。自动维护反向 blocks 关系。
|
|
203
|
+
用法: task.py block <task> <blocked-by-task>
|
|
204
|
+
语义: <task> 被 <blocked-by-task> 阻塞 (要等它完成)。
|
|
205
|
+
"""
|
|
206
|
+
repo_root = get_repo_root()
|
|
207
|
+
task_dir = resolve_task_dir(args.task, repo_root)
|
|
208
|
+
blocker_dir = resolve_task_dir(args.blocker, repo_root)
|
|
209
|
+
if not task_dir.is_dir():
|
|
210
|
+
print(f"Error: Task not found: {args.task}", file=sys.stderr)
|
|
211
|
+
return 1
|
|
212
|
+
if not blocker_dir.is_dir():
|
|
213
|
+
print(f"Error: Blocker task not found: {args.blocker}", file=sys.stderr)
|
|
214
|
+
return 1
|
|
215
|
+
# ACL: 只有 task 的 owner 能加依赖
|
|
216
|
+
try:
|
|
217
|
+
assert_can_modify_task(task_dir, "block", repo_root)
|
|
218
|
+
except PermissionError as e:
|
|
219
|
+
print(str(e), file=sys.stderr)
|
|
220
|
+
return 4
|
|
221
|
+
|
|
222
|
+
# 检测循环依赖
|
|
223
|
+
if _would_create_cycle(task_dir.name, blocker_dir.name, repo_root):
|
|
224
|
+
print(f"Error: 循环依赖检测到 ({blocker_dir.name} 已直接/间接依赖 {task_dir.name})",
|
|
225
|
+
file=sys.stderr)
|
|
226
|
+
return 1
|
|
227
|
+
|
|
228
|
+
# 检查是否已存在依赖关系 (先读, 幂等)
|
|
229
|
+
task_data = load_task_json(task_dir)
|
|
230
|
+
if not task_data:
|
|
231
|
+
print("Error: Missing task.json", file=sys.stderr)
|
|
232
|
+
return 1
|
|
233
|
+
if blocker_dir.name in (task_data.get("blocked_by") or []):
|
|
234
|
+
print(f"Already blocked by {blocker_dir.name}")
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
# 锁内更新 task.blocked_by
|
|
238
|
+
def _block_mutator(data):
|
|
239
|
+
blocked_by = data.get("blocked_by") or []
|
|
240
|
+
if blocker_dir.name not in blocked_by:
|
|
241
|
+
blocked_by.append(blocker_dir.name)
|
|
242
|
+
data["blocked_by"] = blocked_by
|
|
243
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
244
|
+
modify_task_json(task_dir, _block_mutator)
|
|
245
|
+
|
|
246
|
+
# 锁内反向更新 blocker.blocks
|
|
247
|
+
def _block_reverse_mutator(data):
|
|
248
|
+
blocks = data.get("blocks") or []
|
|
249
|
+
if task_dir.name not in blocks:
|
|
250
|
+
blocks.append(task_dir.name)
|
|
251
|
+
data["blocks"] = blocks
|
|
252
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
253
|
+
modify_task_json(blocker_dir, _block_reverse_mutator)
|
|
254
|
+
|
|
255
|
+
print(f"{task_dir.name} now blocked by {blocker_dir.name}")
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_unblock(args: argparse.Namespace) -> int:
|
|
260
|
+
"""移除阻塞关系。用法: task.py unblock <task> <blocked-by-task>"""
|
|
261
|
+
repo_root = get_repo_root()
|
|
262
|
+
task_dir = resolve_task_dir(args.task, repo_root)
|
|
263
|
+
blocker_dir = resolve_task_dir(args.blocker, repo_root)
|
|
264
|
+
if task_dir.is_dir():
|
|
265
|
+
try:
|
|
266
|
+
assert_can_modify_task(task_dir, "unblock", repo_root)
|
|
267
|
+
except PermissionError as e:
|
|
268
|
+
print(str(e), file=sys.stderr)
|
|
269
|
+
return 4
|
|
270
|
+
|
|
271
|
+
removed = False
|
|
272
|
+
# 锁内从 task.blocked_by 删
|
|
273
|
+
if task_dir.is_dir():
|
|
274
|
+
def _unblock_mutator(data):
|
|
275
|
+
nonlocal removed
|
|
276
|
+
if blocker_dir.name in (data.get("blocked_by") or []):
|
|
277
|
+
data["blocked_by"] = [x for x in (data.get("blocked_by") or []) if x != blocker_dir.name]
|
|
278
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
279
|
+
removed = True
|
|
280
|
+
modify_task_json(task_dir, _unblock_mutator)
|
|
281
|
+
|
|
282
|
+
# 锁内从 blocker.blocks 删
|
|
283
|
+
if blocker_dir.is_dir():
|
|
284
|
+
def _unblock_reverse_mutator(data):
|
|
285
|
+
nonlocal removed
|
|
286
|
+
if task_dir.name in (data.get("blocks") or []):
|
|
287
|
+
data["blocks"] = [x for x in (data.get("blocks") or []) if x != task_dir.name]
|
|
288
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
289
|
+
removed = True
|
|
290
|
+
modify_task_json(blocker_dir, _unblock_reverse_mutator)
|
|
291
|
+
|
|
292
|
+
if removed:
|
|
293
|
+
print(f"Removed block: {task_dir.name} no longer blocked by {blocker_dir.name}")
|
|
294
|
+
else:
|
|
295
|
+
print(f"No such block relationship found")
|
|
296
|
+
return 0
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _would_create_cycle(task_name: str, blocker_name: str, repo_root) -> bool:
|
|
300
|
+
"""检测添加 task←blocker 后是否会形成循环依赖。
|
|
301
|
+
即: blocker 是否(直接/间接)已经依赖 task?
|
|
302
|
+
"""
|
|
303
|
+
if task_name == blocker_name:
|
|
304
|
+
return True
|
|
305
|
+
# BFS: 从 blocker 出发, 沿 blocked_by 走, 看能否回到 task
|
|
306
|
+
visited = set()
|
|
307
|
+
queue = [blocker_name]
|
|
308
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
309
|
+
while queue:
|
|
310
|
+
current = queue.pop(0)
|
|
311
|
+
if current in visited:
|
|
312
|
+
continue
|
|
313
|
+
visited.add(current)
|
|
314
|
+
if current == task_name:
|
|
315
|
+
return True
|
|
316
|
+
cur_dir = tasks_dir / current
|
|
317
|
+
data = load_task_json(cur_dir) if cur_dir.is_dir() else None
|
|
318
|
+
if data:
|
|
319
|
+
for dep in (data.get("blocked_by") or []):
|
|
320
|
+
if dep not in visited:
|
|
321
|
+
queue.append(dep)
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _is_task_ready(task_data: dict) -> bool:
|
|
326
|
+
"""任务是否"可立即开始": 未完成 且 无未完成的 blocked_by。"""
|
|
327
|
+
if task_data.get("status") == "completed":
|
|
328
|
+
return False
|
|
329
|
+
blocked_by = task_data.get("blocked_by") or []
|
|
330
|
+
# blocked_by 为空 = 可开始 (这里简化: 不深入查依赖是否完成, 由调用方保证)
|
|
331
|
+
return len(blocked_by) == 0
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def cmd_gantt(args: argparse.Namespace) -> int:
|
|
335
|
+
"""渲染 markdown 甘特图 (按 start_date/due_date 排期)。
|
|
336
|
+
用法: task.py gantt [--weeks 4]
|
|
337
|
+
"""
|
|
338
|
+
repo_root = get_repo_root()
|
|
339
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
340
|
+
if not tasks_dir.is_dir():
|
|
341
|
+
print("No tasks directory")
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
from datetime import date, timedelta
|
|
345
|
+
today = date.today()
|
|
346
|
+
weeks = args.weeks if hasattr(args, "weeks") else 4
|
|
347
|
+
end = today + timedelta(weeks=weeks)
|
|
348
|
+
|
|
349
|
+
# 收集有日期的任务
|
|
350
|
+
items = []
|
|
351
|
+
for d in sorted(tasks_dir.iterdir()):
|
|
352
|
+
if not d.is_dir():
|
|
353
|
+
continue
|
|
354
|
+
data = load_task_json(d)
|
|
355
|
+
if not data:
|
|
356
|
+
continue
|
|
357
|
+
due = data.get("due_date")
|
|
358
|
+
start = data.get("start_date")
|
|
359
|
+
if not due and not start:
|
|
360
|
+
continue
|
|
361
|
+
items.append({
|
|
362
|
+
"name": d.name,
|
|
363
|
+
"title": data.get("title", d.name),
|
|
364
|
+
"start": start,
|
|
365
|
+
"due": due,
|
|
366
|
+
"status": data.get("status", "?"),
|
|
367
|
+
"assignee": data.get("assignee", "?"),
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
if not items:
|
|
371
|
+
print("No tasks with dates. 用 'task.py set-due <task> <YYYY-MM-DD>' 添加截止日期。")
|
|
372
|
+
return 0
|
|
373
|
+
|
|
374
|
+
# 按开始/截止日期排序
|
|
375
|
+
items.sort(key=lambda x: (x.get("start") or x.get("due") or "9999"))
|
|
376
|
+
|
|
377
|
+
print(f"# 任务排期 ({today} ~ {end})\n")
|
|
378
|
+
print("| 任务 | 负责人 | 开始 | 截止 | 状态 |")
|
|
379
|
+
print("|------|--------|------|------|------|")
|
|
380
|
+
for it in items:
|
|
381
|
+
start = it.get("start") or "-"
|
|
382
|
+
due = it.get("due") or "-"
|
|
383
|
+
# 标记逾期
|
|
384
|
+
if it.get("due") and it["status"] != "completed":
|
|
385
|
+
try:
|
|
386
|
+
due_date = date.fromisoformat(it["due"])
|
|
387
|
+
if due_date < today:
|
|
388
|
+
due = f"⚠️ {due} (逾期)"
|
|
389
|
+
except ValueError:
|
|
390
|
+
pass
|
|
391
|
+
print(f"| {it['name']} | {it['assignee']} | {start} | {due} | {it['status']} |")
|
|
392
|
+
|
|
393
|
+
# 简单时间轴 (ASCII)
|
|
394
|
+
print(f"\n## 时间轴 ({weeks} 周)\n")
|
|
395
|
+
print("```")
|
|
396
|
+
for it in items:
|
|
397
|
+
start_s = it.get("start") or it.get("due")
|
|
398
|
+
if not start_s:
|
|
399
|
+
continue
|
|
400
|
+
try:
|
|
401
|
+
start_d = date.fromisoformat(start_s) if it.get("start") else today
|
|
402
|
+
except ValueError:
|
|
403
|
+
start_d = today
|
|
404
|
+
due_s = it.get("due")
|
|
405
|
+
try:
|
|
406
|
+
due_d = date.fromisoformat(due_s) if due_s else start_d + timedelta(days=7)
|
|
407
|
+
except ValueError:
|
|
408
|
+
due_d = start_d + timedelta(days=7)
|
|
409
|
+
# 计算相对今天的偏移
|
|
410
|
+
start_off = max(0, (start_d - today).days)
|
|
411
|
+
due_off = min(weeks * 7, (due_d - today).days)
|
|
412
|
+
if due_off < 0:
|
|
413
|
+
bar = "[逾期]" + "=" * 5
|
|
414
|
+
else:
|
|
415
|
+
bar = " " * (start_off // 2) + "[" + "=" * max(1, (due_off - start_off) // 2) + "]"
|
|
416
|
+
print(f"{it['name'][:20]:20} {bar}")
|
|
417
|
+
print("```")
|
|
418
|
+
return 0
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# =============================================================================
|
|
422
|
+
# Main
|
|
423
|
+
# =============================================================================
|
|
424
|
+
|