@hupan56/wlkj 2.2.4 → 2.2.5

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.
@@ -1,393 +1,393 @@
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
- def modify_task_json(task_dir: Path, mutator, timeout: float = 15.0):
151
- """在文件锁内 load → mutator(data) → write, 保证并发 read-modify-write 原子。
152
-
153
- 解决两个成员同时改同一任务 (一个改 status, 一个改 due) 的丢更新问题。
154
- 锁是每任务粒度 (task_dir/.task.lock), 不同任务互不阻塞。
155
-
156
- Args:
157
- task_dir: 任务目录。
158
- mutator: 接收 data dict, 就地修改它 (返回值忽略)。
159
- timeout: 获取锁的等待秒数。
160
-
161
- Returns:
162
- 成功 (修改并写入) 返回 True; 加载失败返回 False。
163
- """
164
- try:
165
- from .filelock import FileLock, LockTimeoutError
166
- except ImportError:
167
- # filelock 不可用 → 降级为无锁 (保留旧行为, 不阻塞功能)
168
- data = load_task_json(task_dir)
169
- if data is None:
170
- return False
171
- mutator(data)
172
- return write_task_json(task_dir, data)
173
-
174
- lock_path = task_dir / ".task.lock"
175
- lock = FileLock(str(lock_path), timeout=timeout, stale_seconds=300)
176
- try:
177
- lock.acquire()
178
- except LockTimeoutError as e:
179
- print(f"Warning: task lock busy ({task_dir.name}): {e}", file=sys.stderr)
180
- # 降级: 不阻塞, 直接写 (好过完全失败)
181
- data = load_task_json(task_dir)
182
- if data is None:
183
- return False
184
- mutator(data)
185
- return write_task_json(task_dir, data)
186
- try:
187
- data = load_task_json(task_dir)
188
- if data is None:
189
- return False
190
- mutator(data)
191
- return write_task_json(task_dir, data)
192
- finally:
193
- lock.release()
194
-
195
-
196
- # =============================================================================
197
- # ACL: 任务操作权限校验 (零信任)
198
- # =============================================================================
199
-
200
- def assert_can_modify_task(
201
- task_dir: Path,
202
- action: str,
203
- repo_root: Path | None = None,
204
- ) -> str:
205
- """校验当前开发者是否有权修改该任务。
206
-
207
- 零信任 ACL: 只有 creator / assignee / admin 角色能修改。
208
- 其他人 start/finish/archive 别人的任务会被拒绝。
209
-
210
- Args:
211
- task_dir: 任务目录。
212
- action: 动作名 (start/finish/archive/add-subtask/remove-subtask)。
213
- repo_root: 项目根。
214
-
215
- Returns:
216
- 当前开发者名 (校验通过)。
217
-
218
- Raises:
219
- PermissionError: 无权操作。
220
- """
221
- if repo_root is None:
222
- repo_root = get_repo_root()
223
- repo_root = Path(repo_root) # 统一为 Path
224
-
225
- # 读当前开发者
226
- try:
227
- from .paths import get_developer
228
- except ImportError:
229
- from paths import get_developer # type: ignore
230
- dev = get_developer(repo_root)
231
- if not dev:
232
- raise PermissionError(
233
- "[authz] 拒绝 %s: 未设置开发者身份。先 /wl-init。" % action
234
- )
235
-
236
- task = load_task_json(task_dir)
237
- if not task:
238
- # 任务不存在 —— 让上层处理, 这里放行 (创建场景)
239
- return dev
240
-
241
- creator = (task.get("creator") or "").strip()
242
- assignee = (task.get("assignee") or "").strip()
243
- authorized = {creator, assignee}
244
- authorized.discard("")
245
-
246
- # admin 角色可操作任意任务
247
- try:
248
- from .identity import get_member
249
- m = get_member(dev, repo_root)
250
- if m and m.get("role") == "admin":
251
- return dev
252
- except Exception:
253
- pass # identity 模块不可用则退化为只看 creator/assignee
254
-
255
- if dev not in authorized:
256
- raise PermissionError(
257
- "[authz] 拒绝 %s 任务 '%s': 当前开发者 '%s' 不是 creator(%s)/assignee(%s)。"
258
- "只有任务负责人或 admin 可操作。"
259
- % (action, task_dir.name, dev, creator or "?", assignee or "?")
260
- )
261
- return dev
262
-
263
-
264
- # =============================================================================
265
- # 生命周期钩子
266
- # =============================================================================
267
-
268
- def run_task_hooks(
269
- hook_name: str,
270
- task_json_path: Path,
271
- repo_root: Path | None = None,
272
- ) -> None:
273
- """运行任务生命周期钩子。
274
-
275
- 从 config.yaml 读取钩子配置并执行。
276
-
277
- Args:
278
- hook_name: 钩子名称 (after_create/after_start/after_finish/after_archive)。
279
- task_json_path: task.json 的路径。
280
- repo_root: 项目根目录, 默认自动检测。
281
- """
282
- if repo_root is None:
283
- repo_root = get_repo_root()
284
-
285
- config_path = repo_root / ".qoder" / "config.yaml"
286
- if not config_path.is_file():
287
- return
288
-
289
- # 简单的 YAML 解析 (不依赖 PyYAML)
290
- try:
291
- import yaml
292
- with open(config_path, "r", encoding="utf-8") as f:
293
- config = yaml.safe_load(f)
294
- except ImportError:
295
- # 没有 PyYAML, 用简单解析
296
- config = _simple_yaml_parse(config_path)
297
- except Exception as e:
298
- # config.yaml 语法错误等: 不阻塞任务操作, 只是跳过 hooks
299
- # (用户可能手编 config.yaml 引入了语法错误, 不该让整个任务系统崩溃)
300
- print(f"Warning: .qoder/config.yaml 解析失败, 跳过 hooks ({type(e).__name__})", file=sys.stderr)
301
- return
302
-
303
- if not config:
304
- return
305
-
306
- hooks = config.get("hooks", {})
307
- commands = hooks.get(hook_name, [])
308
- if not commands:
309
- return
310
-
311
- env = os.environ.copy()
312
- env["TASK_JSON_PATH"] = str(task_json_path)
313
-
314
- for cmd in commands:
315
- try:
316
- print(f" Running hook [{hook_name}]: {cmd}")
317
- result = subprocess.run(
318
- cmd,
319
- shell=True,
320
- cwd=str(repo_root),
321
- env=env,
322
- capture_output=True,
323
- text=True,
324
- timeout=30,
325
- )
326
- if result.returncode != 0:
327
- print(f" Warning: Hook returned non-zero: {result.stderr}", file=sys.stderr)
328
- except subprocess.TimeoutExpired:
329
- print(f" Warning: Hook timed out: {cmd}", file=sys.stderr)
330
- except Exception as e:
331
- print(f" Warning: Hook error: {e}", file=sys.stderr)
332
-
333
-
334
- def _simple_yaml_parse(path: Path) -> dict:
335
- """简单的 YAML 解析器 (不依赖外部库)。
336
-
337
- 只支持两级嵌套和列表。
338
-
339
- Args:
340
- path: YAML 文件路径。
341
-
342
- Returns:
343
- 解析后的 dict。
344
- """
345
- result = {}
346
- current_key = None
347
- current_list = None
348
-
349
- try:
350
- lines = path.read_text(encoding="utf-8").splitlines()
351
- except (OSError, IOError):
352
- return result
353
-
354
- for line in lines:
355
- stripped = line.strip()
356
- if not stripped or stripped.startswith("#"):
357
- continue
358
-
359
- indent = len(line) - len(line.lstrip())
360
-
361
- if indent == 0 and ":" in stripped and not stripped.startswith("-"):
362
- # 顶层 key
363
- if current_key and current_list is not None:
364
- result[current_key] = current_list
365
- key, _, value = stripped.partition(":")
366
- current_key = key.strip()
367
- current_list = None
368
- if value.strip():
369
- result[current_key] = value.strip()
370
- current_key = None
371
-
372
- elif indent > 0 and stripped.startswith("- "):
373
- # 列表项
374
- if current_list is None:
375
- current_list = []
376
- item = stripped[2:].strip().strip("'\"")
377
- current_list.append(item)
378
-
379
- elif indent > 0 and ":" in stripped and current_key:
380
- # 子键值对
381
- if current_list is not None:
382
- result[current_key] = current_list
383
- current_list = None
384
- key, _, value = stripped.partition(":")
385
- if isinstance(result.get(current_key), dict):
386
- result[current_key][key.strip()] = value.strip().strip("'\"")
387
- else:
388
- result[current_key] = {key.strip(): value.strip().strip("'\"")}
389
-
390
- if current_key and current_list is not None:
391
- result[current_key] = current_list
392
-
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
+ def modify_task_json(task_dir: Path, mutator, timeout: float = 15.0):
151
+ """在文件锁内 load → mutator(data) → write, 保证并发 read-modify-write 原子。
152
+
153
+ 解决两个成员同时改同一任务 (一个改 status, 一个改 due) 的丢更新问题。
154
+ 锁是每任务粒度 (task_dir/.task.lock), 不同任务互不阻塞。
155
+
156
+ Args:
157
+ task_dir: 任务目录。
158
+ mutator: 接收 data dict, 就地修改它 (返回值忽略)。
159
+ timeout: 获取锁的等待秒数。
160
+
161
+ Returns:
162
+ 成功 (修改并写入) 返回 True; 加载失败返回 False。
163
+ """
164
+ try:
165
+ from .filelock import FileLock, LockTimeoutError
166
+ except ImportError:
167
+ # filelock 不可用 → 降级为无锁 (保留旧行为, 不阻塞功能)
168
+ data = load_task_json(task_dir)
169
+ if data is None:
170
+ return False
171
+ mutator(data)
172
+ return write_task_json(task_dir, data)
173
+
174
+ lock_path = task_dir / ".task.lock"
175
+ lock = FileLock(str(lock_path), timeout=timeout, stale_seconds=300)
176
+ try:
177
+ lock.acquire()
178
+ except LockTimeoutError as e:
179
+ print(f"Warning: task lock busy ({task_dir.name}): {e}", file=sys.stderr)
180
+ # 降级: 不阻塞, 直接写 (好过完全失败)
181
+ data = load_task_json(task_dir)
182
+ if data is None:
183
+ return False
184
+ mutator(data)
185
+ return write_task_json(task_dir, data)
186
+ try:
187
+ data = load_task_json(task_dir)
188
+ if data is None:
189
+ return False
190
+ mutator(data)
191
+ return write_task_json(task_dir, data)
192
+ finally:
193
+ lock.release()
194
+
195
+
196
+ # =============================================================================
197
+ # ACL: 任务操作权限校验 (零信任)
198
+ # =============================================================================
199
+
200
+ def assert_can_modify_task(
201
+ task_dir: Path,
202
+ action: str,
203
+ repo_root: Path | None = None,
204
+ ) -> str:
205
+ """校验当前开发者是否有权修改该任务。
206
+
207
+ 零信任 ACL: 只有 creator / assignee / admin 角色能修改。
208
+ 其他人 start/finish/archive 别人的任务会被拒绝。
209
+
210
+ Args:
211
+ task_dir: 任务目录。
212
+ action: 动作名 (start/finish/archive/add-subtask/remove-subtask)。
213
+ repo_root: 项目根。
214
+
215
+ Returns:
216
+ 当前开发者名 (校验通过)。
217
+
218
+ Raises:
219
+ PermissionError: 无权操作。
220
+ """
221
+ if repo_root is None:
222
+ repo_root = get_repo_root()
223
+ repo_root = Path(repo_root) # 统一为 Path
224
+
225
+ # 读当前开发者
226
+ try:
227
+ from .paths import get_developer
228
+ except ImportError:
229
+ from paths import get_developer # type: ignore
230
+ dev = get_developer(repo_root)
231
+ if not dev:
232
+ raise PermissionError(
233
+ "[authz] 拒绝 %s: 未设置开发者身份。先 /wl-init。" % action
234
+ )
235
+
236
+ task = load_task_json(task_dir)
237
+ if not task:
238
+ # 任务不存在 —— 让上层处理, 这里放行 (创建场景)
239
+ return dev
240
+
241
+ creator = (task.get("creator") or "").strip()
242
+ assignee = (task.get("assignee") or "").strip()
243
+ authorized = {creator, assignee}
244
+ authorized.discard("")
245
+
246
+ # admin 角色可操作任意任务
247
+ try:
248
+ from .identity import get_member
249
+ m = get_member(dev, repo_root)
250
+ if m and m.get("role") == "admin":
251
+ return dev
252
+ except Exception:
253
+ pass # identity 模块不可用则退化为只看 creator/assignee
254
+
255
+ if dev not in authorized:
256
+ raise PermissionError(
257
+ "[authz] 拒绝 %s 任务 '%s': 当前开发者 '%s' 不是 creator(%s)/assignee(%s)。"
258
+ "只有任务负责人或 admin 可操作。"
259
+ % (action, task_dir.name, dev, creator or "?", assignee or "?")
260
+ )
261
+ return dev
262
+
263
+
264
+ # =============================================================================
265
+ # 生命周期钩子
266
+ # =============================================================================
267
+
268
+ def run_task_hooks(
269
+ hook_name: str,
270
+ task_json_path: Path,
271
+ repo_root: Path | None = None,
272
+ ) -> None:
273
+ """运行任务生命周期钩子。
274
+
275
+ 从 config.yaml 读取钩子配置并执行。
276
+
277
+ Args:
278
+ hook_name: 钩子名称 (after_create/after_start/after_finish/after_archive)。
279
+ task_json_path: task.json 的路径。
280
+ repo_root: 项目根目录, 默认自动检测。
281
+ """
282
+ if repo_root is None:
283
+ repo_root = get_repo_root()
284
+
285
+ config_path = repo_root / ".qoder" / "config.yaml"
286
+ if not config_path.is_file():
287
+ return
288
+
289
+ # 简单的 YAML 解析 (不依赖 PyYAML)
290
+ try:
291
+ import yaml
292
+ with open(config_path, "r", encoding="utf-8") as f:
293
+ config = yaml.safe_load(f)
294
+ except ImportError:
295
+ # 没有 PyYAML, 用简单解析
296
+ config = _simple_yaml_parse(config_path)
297
+ except Exception as e:
298
+ # config.yaml 语法错误等: 不阻塞任务操作, 只是跳过 hooks
299
+ # (用户可能手编 config.yaml 引入了语法错误, 不该让整个任务系统崩溃)
300
+ print(f"Warning: .qoder/config.yaml 解析失败, 跳过 hooks ({type(e).__name__})", file=sys.stderr)
301
+ return
302
+
303
+ if not config:
304
+ return
305
+
306
+ hooks = config.get("hooks", {})
307
+ commands = hooks.get(hook_name, [])
308
+ if not commands:
309
+ return
310
+
311
+ env = os.environ.copy()
312
+ env["TASK_JSON_PATH"] = str(task_json_path)
313
+
314
+ for cmd in commands:
315
+ try:
316
+ print(f" Running hook [{hook_name}]: {cmd}")
317
+ result = subprocess.run(
318
+ cmd,
319
+ shell=True,
320
+ cwd=str(repo_root),
321
+ env=env,
322
+ capture_output=True,
323
+ text=True,
324
+ timeout=30,
325
+ )
326
+ if result.returncode != 0:
327
+ print(f" Warning: Hook returned non-zero: {result.stderr}", file=sys.stderr)
328
+ except subprocess.TimeoutExpired:
329
+ print(f" Warning: Hook timed out: {cmd}", file=sys.stderr)
330
+ except Exception as e:
331
+ print(f" Warning: Hook error: {e}", file=sys.stderr)
332
+
333
+
334
+ def _simple_yaml_parse(path: Path) -> dict:
335
+ """简单的 YAML 解析器 (不依赖外部库)。
336
+
337
+ 只支持两级嵌套和列表。
338
+
339
+ Args:
340
+ path: YAML 文件路径。
341
+
342
+ Returns:
343
+ 解析后的 dict。
344
+ """
345
+ result = {}
346
+ current_key = None
347
+ current_list = None
348
+
349
+ try:
350
+ lines = path.read_text(encoding="utf-8").splitlines()
351
+ except (OSError, IOError):
352
+ return result
353
+
354
+ for line in lines:
355
+ stripped = line.strip()
356
+ if not stripped or stripped.startswith("#"):
357
+ continue
358
+
359
+ indent = len(line) - len(line.lstrip())
360
+
361
+ if indent == 0 and ":" in stripped and not stripped.startswith("-"):
362
+ # 顶层 key
363
+ if current_key and current_list is not None:
364
+ result[current_key] = current_list
365
+ key, _, value = stripped.partition(":")
366
+ current_key = key.strip()
367
+ current_list = None
368
+ if value.strip():
369
+ result[current_key] = value.strip()
370
+ current_key = None
371
+
372
+ elif indent > 0 and stripped.startswith("- "):
373
+ # 列表项
374
+ if current_list is None:
375
+ current_list = []
376
+ item = stripped[2:].strip().strip("'\"")
377
+ current_list.append(item)
378
+
379
+ elif indent > 0 and ":" in stripped and current_key:
380
+ # 子键值对
381
+ if current_list is not None:
382
+ result[current_key] = current_list
383
+ current_list = None
384
+ key, _, value = stripped.partition(":")
385
+ if isinstance(result.get(current_key), dict):
386
+ result[current_key][key.strip()] = value.strip().strip("'\"")
387
+ else:
388
+ result[current_key] = {key.strip(): value.strip().strip("'\"")}
389
+
390
+ if current_key and current_list is not None:
391
+ result[current_key] = current_list
392
+
393
393
  return result