@hupan56/wlkj 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +213 -0
- package/package.json +11 -0
- package/templates/cli.js +198 -0
- package/templates/qoder/commands/wl-code.md +43 -0
- package/templates/qoder/commands/wl-commit.md +30 -0
- package/templates/qoder/commands/wl-init.md +80 -0
- package/templates/qoder/commands/wl-insight.md +51 -0
- package/templates/qoder/commands/wl-prd.md +199 -0
- package/templates/qoder/commands/wl-report.md +166 -0
- package/templates/qoder/commands/wl-search.md +52 -0
- package/templates/qoder/commands/wl-spec.md +18 -0
- package/templates/qoder/commands/wl-status.md +51 -0
- package/templates/qoder/commands/wl-task.md +71 -0
- package/templates/qoder/commands/wl-test.md +42 -0
- package/templates/qoder/config.toml +5 -0
- package/templates/qoder/config.yaml +141 -0
- package/templates/qoder/hooks/inject-workflow-state.py +117 -0
- package/templates/qoder/hooks/session-start.py +204 -0
- package/templates/qoder/rules/wl-pipeline.md +105 -0
- package/templates/qoder/scripts/add_session.py +245 -0
- package/templates/qoder/scripts/benchmark.py +209 -0
- package/templates/qoder/scripts/build_style_index.py +268 -0
- package/templates/qoder/scripts/code_index.py +41 -0
- package/templates/qoder/scripts/collect_prds.py +31 -0
- package/templates/qoder/scripts/common/__init__.py +0 -0
- package/templates/qoder/scripts/common/active_task.py +230 -0
- package/templates/qoder/scripts/common/atomicio.py +172 -0
- package/templates/qoder/scripts/common/developer.py +161 -0
- package/templates/qoder/scripts/common/eval_api.py +144 -0
- package/templates/qoder/scripts/common/feishu.py +278 -0
- package/templates/qoder/scripts/common/filelock.py +211 -0
- package/templates/qoder/scripts/common/identity.py +285 -0
- package/templates/qoder/scripts/common/mentions.py +134 -0
- package/templates/qoder/scripts/common/paths.py +311 -0
- package/templates/qoder/scripts/common/reqid.py +218 -0
- package/templates/qoder/scripts/common/search_engine.py +205 -0
- package/templates/qoder/scripts/common/task_utils.py +342 -0
- package/templates/qoder/scripts/common/terms.py +234 -0
- package/templates/qoder/scripts/common/utf8.py +38 -0
- package/templates/qoder/scripts/context_pack.py +196 -0
- package/templates/qoder/scripts/eval_prd.py +225 -0
- package/templates/qoder/scripts/export.py +487 -0
- package/templates/qoder/scripts/git_sync.py +1087 -0
- package/templates/qoder/scripts/handoff.py +22 -0
- package/templates/qoder/scripts/init_developer.py +76 -0
- package/templates/qoder/scripts/init_doctor.py +527 -0
- package/templates/qoder/scripts/install_qoderwork.py +339 -0
- package/templates/qoder/scripts/learn.py +67 -0
- package/templates/qoder/scripts/notify.py +5 -0
- package/templates/qoder/scripts/parse_prds.py +33 -0
- package/templates/qoder/scripts/report.py +281 -0
- package/templates/qoder/scripts/role.py +39 -0
- package/templates/qoder/scripts/run_weekly_update.bat +17 -0
- package/templates/qoder/scripts/run_weekly_update.sh +20 -0
- package/templates/qoder/scripts/search_index.py +352 -0
- package/templates/qoder/scripts/setup.py +453 -0
- package/templates/qoder/scripts/setup_weekly_cron.bat +22 -0
- package/templates/qoder/scripts/setup_weekly_cron.sh +19 -0
- package/templates/qoder/scripts/status.py +389 -0
- package/templates/qoder/scripts/syncgate.py +330 -0
- package/templates/qoder/scripts/task.py +954 -0
- package/templates/qoder/scripts/team.py +29 -0
- package/templates/qoder/scripts/team_sync.py +419 -0
- package/templates/qoder/scripts/workspace_init.py +102 -0
- package/templates/qoder/settings.json +53 -0
- package/templates/qoder/skills/design-review/SKILL.md +25 -0
- package/templates/qoder/skills/prd-generator/SKILL.md +180 -0
- package/templates/qoder/skills/prd-review/SKILL.md +36 -0
- package/templates/qoder/skills/prototype-generator/SKILL.md +141 -0
- package/templates/qoder/skills/spec-coder/SKILL.md +69 -0
- package/templates/qoder/skills/spec-generator/SKILL.md +67 -0
- package/templates/qoder/skills/test-generator/SKILL.md +72 -0
- package/templates/qoder/skills/wl-commit/SKILL.md +76 -0
- package/templates/qoder/skills/wl-init/SKILL.md +67 -0
- package/templates/qoder/skills/wl-insight/SKILL.md +81 -0
- package/templates/qoder/skills/wl-report/SKILL.md +87 -0
- package/templates/qoder/skills/wl-search/SKILL.md +75 -0
- package/templates/qoder/skills/wl-status/SKILL.md +61 -0
- package/templates/qoder/skills/wl-task/SKILL.md +58 -0
- package/templates/qoder/templates/prd-full-template.md +103 -0
- package/templates/qoder/templates/prd-quick-template.md +69 -0
- package/templates/qoder/templates/prototype-app.html +344 -0
- package/templates/qoder/templates/prototype-web.html +310 -0
- package/templates/root/AGENTS.md +182 -0
- package/templates/root/README-pipeline.md +56 -0
- package/templates/root/ROLES.md +85 -0
- package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
search_engine.py - 搜索倒排索引 + mtime 缓存 (性能优化 A2)
|
|
4
|
+
|
|
5
|
+
把 search_keywords 的 O(n) 全扫描 3267 keys 降到 O(命中数)。
|
|
6
|
+
|
|
7
|
+
设计:
|
|
8
|
+
- exact_index: {word_lower: [keyword_keys]} 精确匹配, O(1) 查找
|
|
9
|
+
- prefix/suffix 索引用 trigram (3-gram) 加速子串匹配
|
|
10
|
+
- 缓存序列化到 data/index/.inverted-cache.json, 含源 mtime
|
|
11
|
+
- 只在 keyword-index.json 变化时重建
|
|
12
|
+
|
|
13
|
+
核心 API:
|
|
14
|
+
get_inverted_index(repo_root) -> dict # 带缓存的倒排索引
|
|
15
|
+
search_keywords_fast(words, inverted, ki) -> {kw: files} # 快速搜索
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
22
|
+
|
|
23
|
+
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
24
|
+
if _THIS_DIR not in sys.path:
|
|
25
|
+
sys.path.insert(0, _THIS_DIR)
|
|
26
|
+
|
|
27
|
+
from atomicio import safe_read_json, atomic_write_json
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"get_inverted_index",
|
|
31
|
+
"search_keywords_fast",
|
|
32
|
+
"build_inverted_index",
|
|
33
|
+
"MIN_FUZZY_LEN",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
MIN_FUZZY_LEN = 4 # 与 search_index.py 保持一致
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _trigrams(s: str) -> Set[str]:
|
|
40
|
+
"""生成字符串的所有 3-gram (小写)。短于 3 字符则返回 {字符串本身}。"""
|
|
41
|
+
s = s.lower()
|
|
42
|
+
if len(s) < 3:
|
|
43
|
+
return {s}
|
|
44
|
+
return {s[i:i+3] for i in range(len(s) - 2)}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_inverted_index(keyword_index: Dict[str, list]) -> dict:
|
|
48
|
+
"""从 keyword_index 构建倒排索引。
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
{
|
|
52
|
+
"exact": {word_lower: [keyword_keys]}, # 精确匹配
|
|
53
|
+
"trigram": {trigram: set(keyword_keys)}, # 3-gram → 含它的 keys
|
|
54
|
+
"keywords": [所有 keyword_keys 排序] # 用于回退全扫描
|
|
55
|
+
}
|
|
56
|
+
"""
|
|
57
|
+
exact = {}
|
|
58
|
+
trigram = {}
|
|
59
|
+
all_keys = sorted(keyword_index.keys())
|
|
60
|
+
|
|
61
|
+
for kw in all_keys:
|
|
62
|
+
kl = kw.lower()
|
|
63
|
+
# 精确索引
|
|
64
|
+
exact.setdefault(kl, []).append(kw)
|
|
65
|
+
# trigram 索引 (用于子串匹配的候选集缩小)
|
|
66
|
+
for tg in _trigrams(kl):
|
|
67
|
+
trigram.setdefault(tg, set()).add(kw)
|
|
68
|
+
|
|
69
|
+
# set 转 list 便于 JSON 序列化
|
|
70
|
+
trigram_serializable = {tg: sorted(keys) for tg, keys in trigram.items()}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"exact": exact,
|
|
74
|
+
"trigram": trigram_serializable,
|
|
75
|
+
"keywords": all_keys,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_inverted_index(repo_root: str) -> Optional[dict]:
|
|
80
|
+
"""获取倒排索引 (带 mtime 缓存)。
|
|
81
|
+
|
|
82
|
+
缓存逻辑:
|
|
83
|
+
- 读 .inverted-cache.json 的 source_mtime
|
|
84
|
+
- 与 keyword-index.json 当前 mtime 比对
|
|
85
|
+
- 不一致或缓存缺失则重建 + 持久化
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
倒排索引 dict, 或 None (keyword-index 不存在时)。
|
|
89
|
+
"""
|
|
90
|
+
index_dir = os.path.join(repo_root, "data", "index")
|
|
91
|
+
ki_path = os.path.join(index_dir, "keyword-index.json")
|
|
92
|
+
cache_path = os.path.join(index_dir, ".inverted-cache.json")
|
|
93
|
+
|
|
94
|
+
if not os.path.isfile(ki_path):
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
ki_mtime = os.path.getmtime(ki_path)
|
|
99
|
+
except OSError:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
# 尝试读缓存
|
|
103
|
+
cached = safe_read_json(cache_path, default=None)
|
|
104
|
+
if cached and cached.get("source_mtime") == ki_mtime:
|
|
105
|
+
# 缓存命中 (trigram 的 set 反序列化为 list, 搜索时兼容)
|
|
106
|
+
return cached
|
|
107
|
+
|
|
108
|
+
# 缓存失效或缺失, 重建
|
|
109
|
+
ki = safe_read_json(ki_path, default=None)
|
|
110
|
+
if not ki:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
inverted = build_inverted_index(ki)
|
|
114
|
+
inverted["source_mtime"] = ki_mtime
|
|
115
|
+
inverted["built_at"] = os.path.getmtime(ki_path)
|
|
116
|
+
|
|
117
|
+
# 持久化 (best-effort, 失败不阻塞搜索)
|
|
118
|
+
try:
|
|
119
|
+
atomic_write_json(cache_path, inverted)
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
return inverted
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _candidate_keys(word: str, inverted: dict) -> List[str]:
|
|
127
|
+
"""用倒排索引找到候选 keyword keys (缩小扫描集)。
|
|
128
|
+
|
|
129
|
+
策略:
|
|
130
|
+
- 用 trigram 交集找候选 (子串匹配的双方至少共享一个 trigram)
|
|
131
|
+
- 精确命中必然包含在 trigram 候选里 (相同字符串共享所有 trigram)
|
|
132
|
+
- 候选为空则回退全部 keys (兜底, 罕见)
|
|
133
|
+
|
|
134
|
+
关键: 不能在精确命中后短路 —— query "insurance" 还要匹配
|
|
135
|
+
"insurancepolicy" 等 (query 是 kw 的子串), 所以必须返回 trigram 候选集。
|
|
136
|
+
"""
|
|
137
|
+
wl = word.lower()
|
|
138
|
+
tgs = _trigrams(wl)
|
|
139
|
+
trigram_index = inverted.get("trigram", {})
|
|
140
|
+
candidate_sets = []
|
|
141
|
+
for tg in tgs:
|
|
142
|
+
keys = trigram_index.get(tg)
|
|
143
|
+
if keys:
|
|
144
|
+
candidate_sets.append(set(keys))
|
|
145
|
+
|
|
146
|
+
if not candidate_sets:
|
|
147
|
+
# 词太短无 trigram (如 2 字符), 回退精确 + 全扫描兜底
|
|
148
|
+
exact = inverted.get("exact", {}).get(wl)
|
|
149
|
+
if exact:
|
|
150
|
+
return list(exact)
|
|
151
|
+
return inverted.get("keywords", [])
|
|
152
|
+
|
|
153
|
+
# 取并集: query 含 kw 或 kw 含 query, 至少一个 trigram 重叠
|
|
154
|
+
candidates = set()
|
|
155
|
+
for cs in candidate_sets:
|
|
156
|
+
candidates |= cs
|
|
157
|
+
|
|
158
|
+
return sorted(candidates)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def search_keywords_fast(
|
|
162
|
+
words: List[str],
|
|
163
|
+
inverted: Optional[dict],
|
|
164
|
+
keyword_index: Dict[str, list],
|
|
165
|
+
) -> Dict[str, list]:
|
|
166
|
+
"""快速关键词搜索 (用倒排索引缩小候选集)。
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
words: 查询词列表 (已小写化、已 CN 扩展)。
|
|
170
|
+
inverted: 倒排索引 (None 则回退到全扫描)。
|
|
171
|
+
keyword_index: 原始 keyword-index (用于取 files)。
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
{keyword_key: files} 匹配结果。
|
|
175
|
+
"""
|
|
176
|
+
matches = {}
|
|
177
|
+
|
|
178
|
+
if inverted is None:
|
|
179
|
+
# 回退: 全扫描 (旧逻辑)
|
|
180
|
+
for word in words:
|
|
181
|
+
for kw, files in keyword_index.items():
|
|
182
|
+
if _match(word, kw):
|
|
183
|
+
matches[kw] = files
|
|
184
|
+
return matches
|
|
185
|
+
|
|
186
|
+
for word in words:
|
|
187
|
+
candidates = _candidate_keys(word, inverted)
|
|
188
|
+
for kw in candidates:
|
|
189
|
+
files = keyword_index.get(kw)
|
|
190
|
+
if files and _match(word, kw):
|
|
191
|
+
matches[kw] = files
|
|
192
|
+
|
|
193
|
+
return matches
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _match(query_word: str, kw: str) -> bool:
|
|
197
|
+
"""与 search_index._match_keyword 一致的匹配逻辑。"""
|
|
198
|
+
w, k = query_word.lower(), kw.lower()
|
|
199
|
+
if w == k:
|
|
200
|
+
return True
|
|
201
|
+
if len(k) >= MIN_FUZZY_LEN and k in w:
|
|
202
|
+
return True
|
|
203
|
+
if len(w) >= MIN_FUZZY_LEN and w in k:
|
|
204
|
+
return True
|
|
205
|
+
return False
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
QODER Pipeline - 任务工具函数
|
|
5
|
+
|
|
6
|
+
提供:
|
|
7
|
+
- resolve_task_dir: 解析任务目录 (支持名称/相对路径/绝对路径)
|
|
8
|
+
- run_task_hooks: 运行任务生命周期钩子
|
|
9
|
+
- load_task_json: 加载 task.json
|
|
10
|
+
- write_task_json: 写入 task.json
|
|
11
|
+
|
|
12
|
+
参考: Trellis 的 common/task_utils.py 设计
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .paths import DIR_TASKS, FILE_TASK_JSON, get_repo_root, get_tasks_dir
|
|
24
|
+
|
|
25
|
+
# 原子写 (解决半写损坏)
|
|
26
|
+
try:
|
|
27
|
+
from .atomicio import atomic_write_json
|
|
28
|
+
except ImportError:
|
|
29
|
+
def atomic_write_json(path, data, indent=2, ensure_ascii=False, sort_keys=True):
|
|
30
|
+
import json as _j
|
|
31
|
+
Path(path).write_text(_j.dumps(data, indent=indent, ensure_ascii=ensure_ascii,
|
|
32
|
+
sort_keys=sort_keys) + "\n", encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# =============================================================================
|
|
36
|
+
# 任务目录解析
|
|
37
|
+
# =============================================================================
|
|
38
|
+
|
|
39
|
+
def resolve_task_dir(task_input: str, repo_root: Path | None = None) -> Path:
|
|
40
|
+
"""解析任务目录路径。
|
|
41
|
+
|
|
42
|
+
支持三种输入:
|
|
43
|
+
1. 纯名称/全名: "my-task" / "06-10-my-task" -> workspace/tasks/<name>
|
|
44
|
+
2. 相对路径: "workspace/tasks/06-10-my-task" -> 完整路径
|
|
45
|
+
3. 绝对路径: 直接使用
|
|
46
|
+
|
|
47
|
+
安全变更 (零信任): 不再做模糊子串匹配 (旧版 "a" 可能匹配到他人的
|
|
48
|
+
"06-10-alpha" 导致误操作)。现在要求精确名或唯一前缀。
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
task_input: 任务标识 (名称或路径)。
|
|
52
|
+
repo_root: 项目根目录, 默认自动检测。
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
任务目录的绝对路径。
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
FileNotFoundError: 找不到任务。
|
|
59
|
+
ValueError: 名称歧义 (多个候选)。
|
|
60
|
+
"""
|
|
61
|
+
if repo_root is None:
|
|
62
|
+
repo_root = get_repo_root()
|
|
63
|
+
|
|
64
|
+
input_path = Path(task_input)
|
|
65
|
+
|
|
66
|
+
# 已经是绝对路径且存在
|
|
67
|
+
if input_path.is_absolute() and input_path.is_dir():
|
|
68
|
+
return input_path.resolve()
|
|
69
|
+
|
|
70
|
+
# 相对路径
|
|
71
|
+
full_from_root = (repo_root / task_input).resolve()
|
|
72
|
+
if full_from_root.is_dir():
|
|
73
|
+
return full_from_root
|
|
74
|
+
|
|
75
|
+
# 纯名称: 在 tasks 目录下精确匹配
|
|
76
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
77
|
+
direct_match = tasks_dir / task_input
|
|
78
|
+
if direct_match.is_dir():
|
|
79
|
+
return direct_match.resolve()
|
|
80
|
+
|
|
81
|
+
# 精确匹配失败 —— 查唯一前缀匹配 (不允许歧义)
|
|
82
|
+
if tasks_dir.is_dir():
|
|
83
|
+
candidates = [
|
|
84
|
+
d for d in tasks_dir.iterdir()
|
|
85
|
+
if d.is_dir() and (
|
|
86
|
+
d.name == task_input or # 全名精确
|
|
87
|
+
d.name.endswith("-" + task_input) or # "06-10-my-task".endswith("-my-task")
|
|
88
|
+
d.name.startswith(task_input + "-") # "06-10".startswith("06-10-...")
|
|
89
|
+
)
|
|
90
|
+
]
|
|
91
|
+
if len(candidates) == 1:
|
|
92
|
+
return candidates[0].resolve()
|
|
93
|
+
if len(candidates) > 1:
|
|
94
|
+
names = ", ".join(d.name for d in candidates[:5])
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"任务名 '%s' 歧义, 匹配到 %d 个: %s。请用完整任务名。"
|
|
97
|
+
% (task_input, len(candidates), names)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# 不存在 —— 返回原始路径 (调用方决定怎么报错)
|
|
101
|
+
return tasks_dir / task_input
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# =============================================================================
|
|
105
|
+
# 任务 JSON 读写
|
|
106
|
+
# =============================================================================
|
|
107
|
+
|
|
108
|
+
def load_task_json(task_dir: Path) -> dict | None:
|
|
109
|
+
"""加载 task.json 文件。
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
task_dir: 任务目录路径。
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
解析后的 dict, 文件不存在或解析失败返回 None。
|
|
116
|
+
"""
|
|
117
|
+
task_json = task_dir / FILE_TASK_JSON
|
|
118
|
+
if not task_json.is_file():
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
return json.loads(task_json.read_text(encoding="utf-8"))
|
|
123
|
+
except (json.JSONDecodeError, OSError, IOError):
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def write_task_json(task_dir: Path, data: dict) -> bool:
|
|
128
|
+
"""原子写入 task.json 文件 (temp + os.replace, 写失败原文件不变)。
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
task_dir: 任务目录路径。
|
|
132
|
+
data: 要写入的数据。
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
成功返回 True。
|
|
136
|
+
"""
|
|
137
|
+
task_json = task_dir / FILE_TASK_JSON
|
|
138
|
+
try:
|
|
139
|
+
# 原子写: sort_keys=False 保留插入顺序 (task.json 字段顺序有意义)
|
|
140
|
+
atomic_write_json(
|
|
141
|
+
str(task_json), data,
|
|
142
|
+
indent=2, ensure_ascii=False, sort_keys=False,
|
|
143
|
+
)
|
|
144
|
+
return True
|
|
145
|
+
except (OSError, IOError) as e:
|
|
146
|
+
print(f"Warning: Failed to write task.json: {e}", file=sys.stderr)
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# =============================================================================
|
|
151
|
+
# ACL: 任务操作权限校验 (零信任)
|
|
152
|
+
# =============================================================================
|
|
153
|
+
|
|
154
|
+
def assert_can_modify_task(
|
|
155
|
+
task_dir: Path,
|
|
156
|
+
action: str,
|
|
157
|
+
repo_root: Path | None = None,
|
|
158
|
+
) -> str:
|
|
159
|
+
"""校验当前开发者是否有权修改该任务。
|
|
160
|
+
|
|
161
|
+
零信任 ACL: 只有 creator / assignee / admin 角色能修改。
|
|
162
|
+
其他人 start/finish/archive 别人的任务会被拒绝。
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
task_dir: 任务目录。
|
|
166
|
+
action: 动作名 (start/finish/archive/add-subtask/remove-subtask)。
|
|
167
|
+
repo_root: 项目根。
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
当前开发者名 (校验通过)。
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
PermissionError: 无权操作。
|
|
174
|
+
"""
|
|
175
|
+
if repo_root is None:
|
|
176
|
+
repo_root = get_repo_root()
|
|
177
|
+
repo_root = Path(repo_root) # 统一为 Path
|
|
178
|
+
|
|
179
|
+
# 读当前开发者
|
|
180
|
+
try:
|
|
181
|
+
from .paths import get_developer
|
|
182
|
+
except ImportError:
|
|
183
|
+
from paths import get_developer # type: ignore
|
|
184
|
+
dev = get_developer(repo_root)
|
|
185
|
+
if not dev:
|
|
186
|
+
raise PermissionError(
|
|
187
|
+
"[authz] 拒绝 %s: 未设置开发者身份。先 /wl-init。" % action
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
task = load_task_json(task_dir)
|
|
191
|
+
if not task:
|
|
192
|
+
# 任务不存在 —— 让上层处理, 这里放行 (创建场景)
|
|
193
|
+
return dev
|
|
194
|
+
|
|
195
|
+
creator = (task.get("creator") or "").strip()
|
|
196
|
+
assignee = (task.get("assignee") or "").strip()
|
|
197
|
+
authorized = {creator, assignee}
|
|
198
|
+
authorized.discard("")
|
|
199
|
+
|
|
200
|
+
# admin 角色可操作任意任务
|
|
201
|
+
try:
|
|
202
|
+
from .identity import get_member
|
|
203
|
+
m = get_member(dev, repo_root)
|
|
204
|
+
if m and m.get("role") == "admin":
|
|
205
|
+
return dev
|
|
206
|
+
except Exception:
|
|
207
|
+
pass # identity 模块不可用则退化为只看 creator/assignee
|
|
208
|
+
|
|
209
|
+
if dev not in authorized:
|
|
210
|
+
raise PermissionError(
|
|
211
|
+
"[authz] 拒绝 %s 任务 '%s': 当前开发者 '%s' 不是 creator(%s)/assignee(%s)。"
|
|
212
|
+
"只有任务负责人或 admin 可操作。"
|
|
213
|
+
% (action, task_dir.name, dev, creator or "?", assignee or "?")
|
|
214
|
+
)
|
|
215
|
+
return dev
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# =============================================================================
|
|
219
|
+
# 生命周期钩子
|
|
220
|
+
# =============================================================================
|
|
221
|
+
|
|
222
|
+
def run_task_hooks(
|
|
223
|
+
hook_name: str,
|
|
224
|
+
task_json_path: Path,
|
|
225
|
+
repo_root: Path | None = None,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""运行任务生命周期钩子。
|
|
228
|
+
|
|
229
|
+
从 config.yaml 读取钩子配置并执行。
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
hook_name: 钩子名称 (after_create/after_start/after_finish/after_archive)。
|
|
233
|
+
task_json_path: task.json 的路径。
|
|
234
|
+
repo_root: 项目根目录, 默认自动检测。
|
|
235
|
+
"""
|
|
236
|
+
if repo_root is None:
|
|
237
|
+
repo_root = get_repo_root()
|
|
238
|
+
|
|
239
|
+
config_path = repo_root / ".qoder" / "config.yaml"
|
|
240
|
+
if not config_path.is_file():
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
# 简单的 YAML 解析 (不依赖 PyYAML)
|
|
244
|
+
try:
|
|
245
|
+
import yaml
|
|
246
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
247
|
+
config = yaml.safe_load(f)
|
|
248
|
+
except ImportError:
|
|
249
|
+
# 没有 PyYAML, 用简单解析
|
|
250
|
+
config = _simple_yaml_parse(config_path)
|
|
251
|
+
|
|
252
|
+
if not config:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
hooks = config.get("hooks", {})
|
|
256
|
+
commands = hooks.get(hook_name, [])
|
|
257
|
+
if not commands:
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
env = os.environ.copy()
|
|
261
|
+
env["TASK_JSON_PATH"] = str(task_json_path)
|
|
262
|
+
|
|
263
|
+
for cmd in commands:
|
|
264
|
+
try:
|
|
265
|
+
print(f" Running hook [{hook_name}]: {cmd}")
|
|
266
|
+
result = subprocess.run(
|
|
267
|
+
cmd,
|
|
268
|
+
shell=True,
|
|
269
|
+
cwd=str(repo_root),
|
|
270
|
+
env=env,
|
|
271
|
+
capture_output=True,
|
|
272
|
+
text=True,
|
|
273
|
+
timeout=30,
|
|
274
|
+
)
|
|
275
|
+
if result.returncode != 0:
|
|
276
|
+
print(f" Warning: Hook returned non-zero: {result.stderr}", file=sys.stderr)
|
|
277
|
+
except subprocess.TimeoutExpired:
|
|
278
|
+
print(f" Warning: Hook timed out: {cmd}", file=sys.stderr)
|
|
279
|
+
except Exception as e:
|
|
280
|
+
print(f" Warning: Hook error: {e}", file=sys.stderr)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _simple_yaml_parse(path: Path) -> dict:
|
|
284
|
+
"""简单的 YAML 解析器 (不依赖外部库)。
|
|
285
|
+
|
|
286
|
+
只支持两级嵌套和列表。
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
path: YAML 文件路径。
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
解析后的 dict。
|
|
293
|
+
"""
|
|
294
|
+
result = {}
|
|
295
|
+
current_key = None
|
|
296
|
+
current_list = None
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
300
|
+
except (OSError, IOError):
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
for line in lines:
|
|
304
|
+
stripped = line.strip()
|
|
305
|
+
if not stripped or stripped.startswith("#"):
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
indent = len(line) - len(line.lstrip())
|
|
309
|
+
|
|
310
|
+
if indent == 0 and ":" in stripped and not stripped.startswith("-"):
|
|
311
|
+
# 顶层 key
|
|
312
|
+
if current_key and current_list is not None:
|
|
313
|
+
result[current_key] = current_list
|
|
314
|
+
key, _, value = stripped.partition(":")
|
|
315
|
+
current_key = key.strip()
|
|
316
|
+
current_list = None
|
|
317
|
+
if value.strip():
|
|
318
|
+
result[current_key] = value.strip()
|
|
319
|
+
current_key = None
|
|
320
|
+
|
|
321
|
+
elif indent > 0 and stripped.startswith("- "):
|
|
322
|
+
# 列表项
|
|
323
|
+
if current_list is None:
|
|
324
|
+
current_list = []
|
|
325
|
+
item = stripped[2:].strip().strip("'\"")
|
|
326
|
+
current_list.append(item)
|
|
327
|
+
|
|
328
|
+
elif indent > 0 and ":" in stripped and current_key:
|
|
329
|
+
# 子键值对
|
|
330
|
+
if current_list is not None:
|
|
331
|
+
result[current_key] = current_list
|
|
332
|
+
current_list = None
|
|
333
|
+
key, _, value = stripped.partition(":")
|
|
334
|
+
if isinstance(result.get(current_key), dict):
|
|
335
|
+
result[current_key][key.strip()] = value.strip().strip("'\"")
|
|
336
|
+
else:
|
|
337
|
+
result[current_key] = {key.strip(): value.strip().strip("'\"")}
|
|
338
|
+
|
|
339
|
+
if current_key and current_list is not None:
|
|
340
|
+
result[current_key] = current_list
|
|
341
|
+
|
|
342
|
+
return result
|