@hupan56/wlkj 2.7.12 → 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 +344 -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} +113 -4
- 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,596 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""task_lifecycle.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_create(args: argparse.Namespace) -> int:
|
|
30
|
+
"""创建新任务。
|
|
31
|
+
|
|
32
|
+
创建:
|
|
33
|
+
- .qoder/tasks/<slug>/ 目录
|
|
34
|
+
- task.json (元数据)
|
|
35
|
+
- prd.md (空模板)
|
|
36
|
+
- implement.jsonl (实现上下文, 空文件)
|
|
37
|
+
- check.jsonl (检查上下文, 空文件)
|
|
38
|
+
"""
|
|
39
|
+
repo_root = get_repo_root()
|
|
40
|
+
title = args.title
|
|
41
|
+
slug = args.slug or _generate_slug(title)
|
|
42
|
+
assignee = args.assignee or get_developer(repo_root)
|
|
43
|
+
priority = args.priority or "P2"
|
|
44
|
+
|
|
45
|
+
# 生成日期前缀: MM-DD
|
|
46
|
+
date_prefix = datetime.now().strftime("%m-%d")
|
|
47
|
+
task_dir_name = f"{date_prefix}-{slug}"
|
|
48
|
+
|
|
49
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
50
|
+
task_dir = tasks_dir / task_dir_name
|
|
51
|
+
|
|
52
|
+
# 撞名处理: 同日同名 (两个 PM 都建"登录优化") 自动加 creator 后缀, 不阻塞
|
|
53
|
+
if task_dir.exists():
|
|
54
|
+
creator_tag = (assignee or "x")[:8]
|
|
55
|
+
# 加 creator 短标记; 仍撞则加 -2/-3
|
|
56
|
+
candidate = f"{date_prefix}-{slug}-{creator_tag}"
|
|
57
|
+
n = 2
|
|
58
|
+
while (tasks_dir / candidate).exists():
|
|
59
|
+
candidate = f"{date_prefix}-{slug}-{creator_tag}-{n}"
|
|
60
|
+
n += 1
|
|
61
|
+
if n > 99: # 防御性上限
|
|
62
|
+
print(f"Error: Task name collision exhausted: {slug}", file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
task_dir_name = candidate
|
|
65
|
+
task_dir = tasks_dir / task_dir_name
|
|
66
|
+
print(f"Note: 同名任务已存在, 自动加后缀: {task_dir_name}")
|
|
67
|
+
|
|
68
|
+
# 创建目录
|
|
69
|
+
task_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
# 创建 task.json
|
|
72
|
+
now_iso = datetime.now().isoformat()
|
|
73
|
+
task_data = {
|
|
74
|
+
"title": title,
|
|
75
|
+
"slug": slug,
|
|
76
|
+
"status": "planning",
|
|
77
|
+
"priority": priority,
|
|
78
|
+
"assignee": assignee,
|
|
79
|
+
"creator": assignee,
|
|
80
|
+
"created_at": now_iso,
|
|
81
|
+
"updated_at": now_iso,
|
|
82
|
+
"branch": None,
|
|
83
|
+
"base_branch": None,
|
|
84
|
+
"scope": None,
|
|
85
|
+
"parent": None,
|
|
86
|
+
"children": [],
|
|
87
|
+
"tags": [],
|
|
88
|
+
# B1 能力增强字段 (向后兼容: 旧 task.json 不含这些, 读时取默认值)
|
|
89
|
+
"due_date": None, # 截止日期 YYYY-MM-DD (可选)
|
|
90
|
+
"start_date": None, # 开始日期 YYYY-MM-DD (可选)
|
|
91
|
+
"estimate_hours": None, # 预估工时 (可选)
|
|
92
|
+
"blocked_by": [], # 依赖的任务 (数组, 自动维护反向 blocks)
|
|
93
|
+
"blocks": [], # 被哪些任务依赖 (自动维护)
|
|
94
|
+
# B3 阶段时间戳 (周期时间分析用)
|
|
95
|
+
"stage_ts": {
|
|
96
|
+
"created": now_iso,
|
|
97
|
+
"started": None, # cmd_start 时填
|
|
98
|
+
"completed": None, # cmd_finish 时填
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
write_task_json(task_dir, task_data)
|
|
102
|
+
|
|
103
|
+
# 创建 prd.md 模板
|
|
104
|
+
prd_file = task_dir / "prd.md"
|
|
105
|
+
prd_content = f"""# PRD: {title}
|
|
106
|
+
|
|
107
|
+
> Status: planning
|
|
108
|
+
> Assignee: {assignee or 'unassigned'}
|
|
109
|
+
> Priority: {priority}
|
|
110
|
+
> Created: {datetime.now().strftime("%Y-%m-%d %H:%M")}
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Background
|
|
115
|
+
|
|
116
|
+
<!-- Describe the business context -->
|
|
117
|
+
|
|
118
|
+
## Requirements
|
|
119
|
+
|
|
120
|
+
<!-- List functional requirements -->
|
|
121
|
+
|
|
122
|
+
## Acceptance Criteria
|
|
123
|
+
|
|
124
|
+
<!-- Define testable acceptance criteria -->
|
|
125
|
+
|
|
126
|
+
## Notes
|
|
127
|
+
|
|
128
|
+
<!-- Additional notes -->
|
|
129
|
+
"""
|
|
130
|
+
prd_file.write_text(prd_content, encoding="utf-8")
|
|
131
|
+
|
|
132
|
+
# 创建空的 JSONL 上下文文件
|
|
133
|
+
(task_dir / "implement.jsonl").write_text("", encoding="utf-8")
|
|
134
|
+
(task_dir / "check.jsonl").write_text("", encoding="utf-8")
|
|
135
|
+
|
|
136
|
+
print(f"Task created: {task_dir_name}")
|
|
137
|
+
print(f" Title: {title}")
|
|
138
|
+
print(f" Assignee: {assignee or 'unassigned'}")
|
|
139
|
+
print(f" Priority: {priority}")
|
|
140
|
+
print(f" Path: workspace/tasks/{task_dir_name}")
|
|
141
|
+
|
|
142
|
+
# 运行 after_create 钩子
|
|
143
|
+
run_task_hooks("after_create", task_dir / FILE_TASK_JSON, repo_root)
|
|
144
|
+
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# =============================================================================
|
|
149
|
+
# 命令: start
|
|
150
|
+
# =============================================================================
|
|
151
|
+
|
|
152
|
+
def cmd_start(args: argparse.Namespace) -> int:
|
|
153
|
+
"""设置活跃任务, 状态改为 in_progress。"""
|
|
154
|
+
repo_root = get_repo_root()
|
|
155
|
+
task_input = args.dir
|
|
156
|
+
|
|
157
|
+
if not task_input:
|
|
158
|
+
print("Error: task directory or name required", file=sys.stderr)
|
|
159
|
+
return 1
|
|
160
|
+
|
|
161
|
+
full_path = resolve_task_dir(task_input, repo_root)
|
|
162
|
+
|
|
163
|
+
if not full_path.is_dir():
|
|
164
|
+
print(f"Error: Task not found: {task_input}", file=sys.stderr)
|
|
165
|
+
return 1
|
|
166
|
+
|
|
167
|
+
# 零信任 ACL: 只有 creator/assignee/admin 能 start
|
|
168
|
+
try:
|
|
169
|
+
assert_can_modify_task(full_path, "start", repo_root)
|
|
170
|
+
except PermissionError as e:
|
|
171
|
+
print(str(e), file=sys.stderr)
|
|
172
|
+
return 4 # authz_denied
|
|
173
|
+
|
|
174
|
+
# 转为相对路径
|
|
175
|
+
try:
|
|
176
|
+
task_dir = full_path.relative_to(repo_root).as_posix()
|
|
177
|
+
except ValueError:
|
|
178
|
+
task_dir = str(full_path)
|
|
179
|
+
|
|
180
|
+
# 设置活跃任务
|
|
181
|
+
active = set_active_task(task_dir, repo_root)
|
|
182
|
+
if active:
|
|
183
|
+
print(f"Current task set to: {task_dir}")
|
|
184
|
+
print(f"Source: {active.source}")
|
|
185
|
+
|
|
186
|
+
# 更新 task.json 状态 (锁内 load-modify-write, 防并发丢更新)
|
|
187
|
+
def _start_mutator(data):
|
|
188
|
+
if data.get("status") == "planning":
|
|
189
|
+
now_iso = datetime.now().isoformat()
|
|
190
|
+
data["status"] = "in_progress"
|
|
191
|
+
data["updated_at"] = now_iso
|
|
192
|
+
stage_ts = data.get("stage_ts") or {}
|
|
193
|
+
stage_ts["started"] = now_iso
|
|
194
|
+
data["stage_ts"] = stage_ts
|
|
195
|
+
print("Status: planning -> in_progress")
|
|
196
|
+
modify_task_json(full_path, _start_mutator)
|
|
197
|
+
|
|
198
|
+
# 运行 after_start 钩子
|
|
199
|
+
run_task_hooks("after_start", full_path / FILE_TASK_JSON, repo_root)
|
|
200
|
+
return 0
|
|
201
|
+
else:
|
|
202
|
+
print("Error: Failed to set current task", file=sys.stderr)
|
|
203
|
+
return 1
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# =============================================================================
|
|
207
|
+
# 命令: current
|
|
208
|
+
# =============================================================================
|
|
209
|
+
|
|
210
|
+
def cmd_current(args: argparse.Namespace) -> int:
|
|
211
|
+
"""显示当前活跃任务。"""
|
|
212
|
+
repo_root = get_repo_root()
|
|
213
|
+
active = resolve_active_task(repo_root)
|
|
214
|
+
|
|
215
|
+
if args.source:
|
|
216
|
+
print(f"Current task: {active.task_path or '(none)'}")
|
|
217
|
+
print(f"Source: {active.source}")
|
|
218
|
+
return 0 if active.task_path else 1
|
|
219
|
+
|
|
220
|
+
if active.task_path:
|
|
221
|
+
print(active.task_path)
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
print("No active task", file=sys.stderr)
|
|
225
|
+
return 1
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# =============================================================================
|
|
229
|
+
# 命令: finish
|
|
230
|
+
# =============================================================================
|
|
231
|
+
|
|
232
|
+
def cmd_finish(args: argparse.Namespace) -> int:
|
|
233
|
+
"""完成当前任务 (状态改为 completed, 清除活跃任务指针)。"""
|
|
234
|
+
repo_root = get_repo_root()
|
|
235
|
+
# 先只读解析活跃任务 (resolve_active_task 不删指针), 做 ACL 校验;
|
|
236
|
+
# 校验通过后才 clear_active_task。否则 ACL 失败时指针已丢、status 没改,
|
|
237
|
+
# 用户必须重新 start 才能恢复活跃任务 (误触/会话被他人接手时尤其痛)。
|
|
238
|
+
active = resolve_active_task(repo_root)
|
|
239
|
+
current = active.task_path
|
|
240
|
+
|
|
241
|
+
if not current:
|
|
242
|
+
print("No current task set")
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
task_dir = repo_root / current
|
|
246
|
+
|
|
247
|
+
# 零信任 ACL: 只有 creator/assignee/admin 能 finish (先校验, 再清指针)
|
|
248
|
+
try:
|
|
249
|
+
assert_can_modify_task(task_dir, "finish", repo_root)
|
|
250
|
+
except PermissionError as e:
|
|
251
|
+
print(str(e), file=sys.stderr)
|
|
252
|
+
# active 指针未动, status 未改 —— 用户可重新 start 或由正确的人 finish
|
|
253
|
+
return 4 # authz_denied
|
|
254
|
+
|
|
255
|
+
# ACL 通过, 现在才安全地清除活跃任务指针
|
|
256
|
+
cleared = clear_active_task(repo_root)
|
|
257
|
+
print(f"Cleared current task (was: {cleared.task_path})")
|
|
258
|
+
print(f"Source: {cleared.source}")
|
|
259
|
+
|
|
260
|
+
# 锁内 load-modify-write, 防并发丢更新; 副作用 (飞书) 在锁外做
|
|
261
|
+
_finish_ctx = {}
|
|
262
|
+
def _finish_mutator(data):
|
|
263
|
+
if data.get("status") != "completed":
|
|
264
|
+
now_iso = datetime.now().isoformat()
|
|
265
|
+
data["status"] = "completed"
|
|
266
|
+
data["updated_at"] = now_iso
|
|
267
|
+
stage_ts = data.get("stage_ts") or {}
|
|
268
|
+
stage_ts["completed"] = now_iso
|
|
269
|
+
data["stage_ts"] = stage_ts
|
|
270
|
+
print("Status: -> completed")
|
|
271
|
+
_finish_ctx["now_iso"] = now_iso
|
|
272
|
+
_finish_ctx["stage_ts"] = stage_ts
|
|
273
|
+
_finish_ctx["assignee"] = data.get("assignee", "?")
|
|
274
|
+
modify_task_json(task_dir, _finish_mutator)
|
|
275
|
+
|
|
276
|
+
# D2: 飞书通知 (任务完成) —— 在锁外, 用 mutator 捕获的值
|
|
277
|
+
if _finish_ctx:
|
|
278
|
+
try:
|
|
279
|
+
from common.feishu import notify_task_finished
|
|
280
|
+
hours = None
|
|
281
|
+
started = _finish_ctx["stage_ts"].get("started")
|
|
282
|
+
if started:
|
|
283
|
+
try:
|
|
284
|
+
from datetime import datetime as _dt
|
|
285
|
+
t_start = _dt.fromisoformat(started)
|
|
286
|
+
t_end = _dt.fromisoformat(_finish_ctx["now_iso"])
|
|
287
|
+
hours = (t_end - t_start).total_seconds() / 3600
|
|
288
|
+
except (ValueError, TypeError):
|
|
289
|
+
pass
|
|
290
|
+
notify_task_finished(task_dir.name, _finish_ctx["assignee"], hours)
|
|
291
|
+
except Exception:
|
|
292
|
+
pass # 飞书失败不阻塞任务完成
|
|
293
|
+
|
|
294
|
+
task_json_path = task_dir / FILE_TASK_JSON
|
|
295
|
+
if task_json_path.is_file():
|
|
296
|
+
run_task_hooks("after_finish", task_json_path, repo_root)
|
|
297
|
+
|
|
298
|
+
# 🧩 测试交接画像 (可选增强): finish 后在任务目录生成 test-handoff.md,
|
|
299
|
+
# 列出涉及的页面/按钮/接口, 供测试人员照着写用例。失败绝不阻塞任务完成。
|
|
300
|
+
try:
|
|
301
|
+
_generate_test_handoff(task_dir, repo_root)
|
|
302
|
+
except Exception as e:
|
|
303
|
+
print("[测试交接画像] 跳过 (不阻塞): " + str(e)[:80])
|
|
304
|
+
|
|
305
|
+
# 埋点: 任务完成反馈给 learning 引擎
|
|
306
|
+
if _finish_ctx:
|
|
307
|
+
try:
|
|
308
|
+
from learn import record_feedback
|
|
309
|
+
record_feedback('task_completed', {
|
|
310
|
+
'task': task_dir.name,
|
|
311
|
+
'assignee': _finish_ctx.get('assignee', '?'),
|
|
312
|
+
'hours': _finish_ctx.get('now_iso'),
|
|
313
|
+
})
|
|
314
|
+
except Exception:
|
|
315
|
+
pass # 埋点失败不阻塞任务完成
|
|
316
|
+
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _extract_keywords(task_dir: Path) -> list:
|
|
321
|
+
"""从任务目录名 + task.json 标题 + PRD 正文提取业务关键词。
|
|
322
|
+
返回去重的关键词列表 (用于 page_probe 查页面画像)。
|
|
323
|
+
"""
|
|
324
|
+
kws = []
|
|
325
|
+
# 1. 任务目录名: 06-11-智能排班-环卫行业 → 智能排班, 环卫行业
|
|
326
|
+
dirname = task_dir.name
|
|
327
|
+
# 去掉日期前缀 (MM-DD- 或 MM-DD-)
|
|
328
|
+
parts = dirname.split('-')
|
|
329
|
+
# 找到第一个非纯数字段开始
|
|
330
|
+
biz_parts = []
|
|
331
|
+
started = False
|
|
332
|
+
for p in parts:
|
|
333
|
+
if not started:
|
|
334
|
+
if p.isdigit() and len(p) <= 2:
|
|
335
|
+
continue # 跳过 MM, DD
|
|
336
|
+
started = True
|
|
337
|
+
if p.strip():
|
|
338
|
+
biz_parts.append(p.strip())
|
|
339
|
+
if biz_parts:
|
|
340
|
+
kws.append(''.join(biz_parts)) # 整体: 智能排班环卫行业
|
|
341
|
+
if len(biz_parts) > 1:
|
|
342
|
+
kws.extend(biz_parts) # 分开: 智能排班, 环卫行业
|
|
343
|
+
|
|
344
|
+
# 2. task.json 的 title 字段
|
|
345
|
+
title = ''
|
|
346
|
+
try:
|
|
347
|
+
tj = task_dir / FILE_TASK_JSON
|
|
348
|
+
if tj.is_file():
|
|
349
|
+
data = load_task_json(task_dir) or {}
|
|
350
|
+
title = data.get('title', '')
|
|
351
|
+
if title and title not in kws:
|
|
352
|
+
kws.append(title)
|
|
353
|
+
except Exception:
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
# 2.5 从标题/目录名里切出短业务词 (保险异常筛选 → 保险, 异常, 筛选)
|
|
357
|
+
# page_probe 用 LIKE 查 vue 路径, 短词命中率更高
|
|
358
|
+
import re
|
|
359
|
+
cn_chunks = re.findall(r'[\u4e00-\u9fa5]+', (dirname + title))
|
|
360
|
+
short_words = set()
|
|
361
|
+
for chunk in cn_chunks:
|
|
362
|
+
# 2-4 字的连续子串都是有意义的业务词候选
|
|
363
|
+
if 2 <= len(chunk) <= 4:
|
|
364
|
+
short_words.add(chunk)
|
|
365
|
+
elif len(chunk) > 4:
|
|
366
|
+
# 长词切前4字、后4字、中间4字 (覆盖"智能排班系统"→智能排班/排班系统)
|
|
367
|
+
short_words.add(chunk[:4])
|
|
368
|
+
short_words.add(chunk[-4:])
|
|
369
|
+
# 去太泛的词
|
|
370
|
+
generic = {'功能', '系统', '管理', '模块', '页面', '需求', '设计', '实现',
|
|
371
|
+
'新增', '修改', '查询', '删除', '操作', '记录', '列表', '详情'}
|
|
372
|
+
for w in short_words:
|
|
373
|
+
if w not in generic and w not in kws:
|
|
374
|
+
kws.append(w)
|
|
375
|
+
|
|
376
|
+
# 3. PRD 正文里的业务关键词 (从标题和需求段落粗提)
|
|
377
|
+
try:
|
|
378
|
+
prd = task_dir / 'prd.md'
|
|
379
|
+
if prd.is_file():
|
|
380
|
+
content = prd.read_text(encoding='utf-8', errors='ignore')
|
|
381
|
+
import re
|
|
382
|
+
# 提取中文词组 (2-6 字连续中文)
|
|
383
|
+
cn_words = re.findall(r'[\u4e00-\u9fa5]{2,6}', content)
|
|
384
|
+
# 去停用词 + 高频词
|
|
385
|
+
stop = {'功能', '需求', '系统', '页面', '数据', '支持', '可以',
|
|
386
|
+
'进行', '操作', '显示', '一个', '这个', '实现', '业务',
|
|
387
|
+
'测试', '使用', '如果', '需要', '必须', '以及', '界面'}
|
|
388
|
+
from collections import Counter
|
|
389
|
+
freq = Counter(w for w in cn_words if w not in stop)
|
|
390
|
+
for w, _ in freq.most_common(5):
|
|
391
|
+
if w not in kws and len(w) >= 2:
|
|
392
|
+
kws.append(w)
|
|
393
|
+
except Exception:
|
|
394
|
+
pass
|
|
395
|
+
|
|
396
|
+
# 去重保序, 过滤太短的
|
|
397
|
+
seen = set()
|
|
398
|
+
result = []
|
|
399
|
+
for k in kws:
|
|
400
|
+
if k and len(k) >= 2 and k not in seen:
|
|
401
|
+
seen.add(k)
|
|
402
|
+
result.append(k)
|
|
403
|
+
return result[:6] # 最多 6 个, 避免查太多
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _generate_test_handoff(task_dir: Path, repo_root) -> None:
|
|
407
|
+
"""finish 后生成 test-handoff.md (开发→测试交接画像)。
|
|
408
|
+
从任务关键词查页面按钮+接口画像, 写入任务目录。无图谱/无匹配则跳过。
|
|
409
|
+
"""
|
|
410
|
+
try:
|
|
411
|
+
import page_probe
|
|
412
|
+
except ImportError:
|
|
413
|
+
sys.path.insert(0, str(repo_root / '.qoder' / 'scripts'))
|
|
414
|
+
import page_probe
|
|
415
|
+
|
|
416
|
+
kws = _extract_keywords(task_dir)
|
|
417
|
+
if not kws:
|
|
418
|
+
print("[测试交接画像] 跳过: 任务无明确业务关键词")
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
# 用每个关键词查画像, 合并结果
|
|
422
|
+
all_pages = []
|
|
423
|
+
seen_vue = set()
|
|
424
|
+
used_kws = []
|
|
425
|
+
for kw in kws:
|
|
426
|
+
profile = page_probe.build_page_profile(keyword=kw, repo_root=str(repo_root))
|
|
427
|
+
if not profile.get('ok'):
|
|
428
|
+
continue
|
|
429
|
+
for pg in profile['pages']:
|
|
430
|
+
if pg['vue_file'] in seen_vue:
|
|
431
|
+
continue
|
|
432
|
+
seen_vue.add(pg['vue_file'])
|
|
433
|
+
all_pages.append(pg)
|
|
434
|
+
used_kws.append(kw)
|
|
435
|
+
if len(all_pages) >= 12: # 够多了, 停止查更多关键词
|
|
436
|
+
break
|
|
437
|
+
|
|
438
|
+
handoff_path = task_dir / 'test-handoff.md'
|
|
439
|
+
if not all_pages:
|
|
440
|
+
# 没匹配到页面: 写一个提示性 handoff (让测试知道开发想覆盖什么)
|
|
441
|
+
lines = [
|
|
442
|
+
'# 测试交接: %s' % task_dir.name,
|
|
443
|
+
'',
|
|
444
|
+
'> 任务完成于 %s' % datetime.now().strftime('%Y-%m-%d %H:%M'),
|
|
445
|
+
'> 业务关键词: %s' % ', '.join(kws),
|
|
446
|
+
'',
|
|
447
|
+
'## ⚠️ 未能自动生成页面画像',
|
|
448
|
+
'',
|
|
449
|
+
'知识图谱里没有匹配这些关键词的页面按钮记录。可能原因:',
|
|
450
|
+
'- 功能是全新的, 代码还没被知识图谱索引',
|
|
451
|
+
'- 关键词与代码里的命名不一致',
|
|
452
|
+
'',
|
|
453
|
+
'**测试建议:** 手动确认涉及的页面, 或用 `autotest.py page --kw <准确关键词>` 重试。',
|
|
454
|
+
]
|
|
455
|
+
handoff_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
|
|
456
|
+
print("[测试交接画像] %s (无匹配页面, 已写提示)" % handoff_path.name)
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
# 渲染完整画像
|
|
460
|
+
total_btns = sum(len(pg['buttons']) for pg in all_pages)
|
|
461
|
+
lines = [
|
|
462
|
+
'# 测试交接: %s' % task_dir.name,
|
|
463
|
+
'',
|
|
464
|
+
'> 任务完成于 %s' % datetime.now().strftime('%Y-%m-%d %H:%M'),
|
|
465
|
+
'> 业务关键词: %s' % ', '.join(used_kws),
|
|
466
|
+
'> 共 %d 个页面, %d 个交互点' % (len(all_pages), total_btns),
|
|
467
|
+
'',
|
|
468
|
+
'> 💡 测试人员照此写用例, 不用自己找页面按钮。每行"按钮→接口"就是一个用例的核心断言点。',
|
|
469
|
+
'> 未解析到接口的按钮 (deferred/unresolved) 需现场点击确认实际调用。',
|
|
470
|
+
'',
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
# 复用 page_probe 的渲染, 但按页面输出
|
|
474
|
+
fake_profile = {'ok': True, 'pages': all_pages, 'stats': page_probe._trace_stats(str(repo_root))}
|
|
475
|
+
lines.append(page_probe.render_profile_md(fake_profile))
|
|
476
|
+
|
|
477
|
+
lines.append('')
|
|
478
|
+
lines.append('---')
|
|
479
|
+
lines.append('')
|
|
480
|
+
lines.append('## 测试用例生成')
|
|
481
|
+
lines.append('拿到这份画像后, 直接说:')
|
|
482
|
+
lines.append('- "测一下 %s" → 走 /wl-test quick 自动生成用例' % (used_kws[0] if used_kws else '这个功能'))
|
|
483
|
+
lines.append('- 或 `/wl-test browser %s` → 按任务 PRD 生成回归用例' % task_dir.name)
|
|
484
|
+
lines.append('- 也可 `autotest.py page --kw <更精确的关键词>` 单独查某页画像')
|
|
485
|
+
|
|
486
|
+
handoff_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
|
|
487
|
+
print("[测试交接画像] 已生成 %s (%d 页面, %d 交互点)"
|
|
488
|
+
% (handoff_path.name, len(all_pages), total_btns))
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _cleanup_orphaned_refs(archived_task_name: str, repo_root) -> int:
|
|
492
|
+
"""归档任务后, 清理其它任务中对它的引用 (blocked_by/blocks/children/parent)。
|
|
493
|
+
|
|
494
|
+
不清理 → 僵尸任务永远 blocked, resolve_task_dir 找不到引用对象。
|
|
495
|
+
返回清理的引用数。
|
|
496
|
+
"""
|
|
497
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
498
|
+
if not tasks_dir.is_dir():
|
|
499
|
+
return 0
|
|
500
|
+
cleaned = 0
|
|
501
|
+
for task_subdir in tasks_dir.iterdir():
|
|
502
|
+
if not task_subdir.is_dir() or task_subdir.name == archived_task_name:
|
|
503
|
+
continue
|
|
504
|
+
task_json = task_subdir / FILE_TASK_JSON
|
|
505
|
+
if not task_json.is_file():
|
|
506
|
+
continue
|
|
507
|
+
changed = False
|
|
508
|
+
|
|
509
|
+
def _purge_refs(data):
|
|
510
|
+
nonlocal changed
|
|
511
|
+
for field in ("blocked_by", "blocks", "children"):
|
|
512
|
+
lst = data.get(field)
|
|
513
|
+
if isinstance(lst, list) and archived_task_name in lst:
|
|
514
|
+
data[field] = [x for x in lst if x != archived_task_name]
|
|
515
|
+
changed = True
|
|
516
|
+
# parent 是单值字段
|
|
517
|
+
if data.get("parent") == archived_task_name:
|
|
518
|
+
data["parent"] = None
|
|
519
|
+
changed = True
|
|
520
|
+
if changed:
|
|
521
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
522
|
+
|
|
523
|
+
modify_task_json(task_subdir, _purge_refs)
|
|
524
|
+
if changed:
|
|
525
|
+
cleaned += 1
|
|
526
|
+
return cleaned
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# =============================================================================
|
|
530
|
+
# 命令: archive
|
|
531
|
+
# =============================================================================
|
|
532
|
+
|
|
533
|
+
def cmd_archive(args: argparse.Namespace) -> int:
|
|
534
|
+
"""归档任务 (移动到 archive/ 目录)。事务化: 先 move 成功再改 status。"""
|
|
535
|
+
repo_root = get_repo_root()
|
|
536
|
+
task_input = args.dir
|
|
537
|
+
|
|
538
|
+
full_path = resolve_task_dir(task_input, repo_root)
|
|
539
|
+
if not full_path.is_dir():
|
|
540
|
+
print(f"Error: Task not found: {task_input}", file=sys.stderr)
|
|
541
|
+
return 1
|
|
542
|
+
|
|
543
|
+
# 零信任 ACL: 只有 creator/assignee/admin 能 archive
|
|
544
|
+
try:
|
|
545
|
+
assert_can_modify_task(full_path, "archive", repo_root)
|
|
546
|
+
except PermissionError as e:
|
|
547
|
+
print(str(e), file=sys.stderr)
|
|
548
|
+
return 4 # authz_denied
|
|
549
|
+
|
|
550
|
+
# 准备 archive 目标
|
|
551
|
+
year_month = datetime.now().strftime("%Y-%m")
|
|
552
|
+
archive_dir = repo_root / ".qoder" / "archive" / year_month
|
|
553
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
554
|
+
|
|
555
|
+
dest = archive_dir / full_path.name
|
|
556
|
+
if dest.exists():
|
|
557
|
+
print(f"Error: Archive destination exists: {dest}", file=sys.stderr)
|
|
558
|
+
return 1
|
|
559
|
+
|
|
560
|
+
# 事务化: 先 move (失败则原任务完好无损), 成功后再改 status
|
|
561
|
+
try:
|
|
562
|
+
shutil.move(str(full_path), str(dest))
|
|
563
|
+
except (OSError, IOError) as e:
|
|
564
|
+
print(f"Error: Failed to archive (move failed, 原任务未动): {e}", file=sys.stderr)
|
|
565
|
+
return 1
|
|
566
|
+
|
|
567
|
+
# move 成功后在目标位置改 status (失败不影响归档, 仅 status 不更新)
|
|
568
|
+
task_data = load_task_json(dest)
|
|
569
|
+
if task_data:
|
|
570
|
+
task_data["status"] = "completed"
|
|
571
|
+
task_data["archived_at"] = datetime.now().isoformat()
|
|
572
|
+
task_data["updated_at"] = task_data["archived_at"]
|
|
573
|
+
write_task_json(dest, task_data)
|
|
574
|
+
|
|
575
|
+
# 运行 after_archive 钩子
|
|
576
|
+
run_task_hooks("after_archive", dest / FILE_TASK_JSON, repo_root)
|
|
577
|
+
|
|
578
|
+
# 清理孤儿依赖: 其它任务可能引用了本任务 (blocked_by/blocks/children/parent),
|
|
579
|
+
# 归档后这些引用变成死链 → 僵尸任务永远 blocked。扫描并移除。
|
|
580
|
+
cleaned = _cleanup_orphaned_refs(full_path.name, repo_root)
|
|
581
|
+
|
|
582
|
+
# 清除可能指向此任务的会话
|
|
583
|
+
active = resolve_active_task(repo_root)
|
|
584
|
+
if active.task_path and task_input in active.task_path:
|
|
585
|
+
clear_active_task(repo_root)
|
|
586
|
+
|
|
587
|
+
print(f"Task archived: {full_path.name} -> archive/{year_month}/{full_path.name}")
|
|
588
|
+
if cleaned:
|
|
589
|
+
print(f" (已清理 {cleaned} 个其它任务中对它的依赖引用)")
|
|
590
|
+
return 0
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
# =============================================================================
|
|
594
|
+
# 命令: list
|
|
595
|
+
# =============================================================================
|
|
596
|
+
|