super-engineer-workflow 0.1.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/CONTRIBUTING.md +34 -0
  3. package/LICENSE +21 -0
  4. package/README.md +300 -0
  5. package/SECURITY.md +21 -0
  6. package/bin/super-engineer.js +19 -0
  7. package/docs/se/345/221/275/344/273/244/345/215/217/350/256/256.md +335 -0
  8. package/docs//344/270/255/346/226/207/344/275/277/347/224/250/346/211/213/345/206/214.md +707 -0
  9. package/docs//345/205/254/345/274/200/345/217/221/345/270/203/346/243/200/346/237/245/346/270/205/345/215/225.md +43 -0
  10. package/docs//345/277/253/351/200/237/345/210/235/345/247/213/345/214/226/345/267/245/344/275/234/345/214/272.md +419 -0
  11. package/docs//351/241/271/347/233/256/346/236/266/346/236/204/344/270/216/350/256/276/350/256/241/350/257/264/346/230/216.md +657 -0
  12. package/package.json +55 -0
  13. package/scripts/se-cli.py +301 -0
  14. package/scripts/se-setup.py +331 -0
  15. package/scripts/se-smoke-test.py +86 -0
  16. package/super-engineer-workflow/SKILL.md +439 -0
  17. package/super-engineer-workflow/adapters/go.yml +8 -0
  18. package/super-engineer-workflow/adapters/java-gradle.yml +8 -0
  19. package/super-engineer-workflow/adapters/java-maven.yml +8 -0
  20. package/super-engineer-workflow/adapters/node-react.yml +8 -0
  21. package/super-engineer-workflow/adapters/node-vue.yml +8 -0
  22. package/super-engineer-workflow/adapters/python.yml +8 -0
  23. package/super-engineer-workflow/agents/openai.yaml +4 -0
  24. package/super-engineer-workflow/assets/config-schema.json +100 -0
  25. package/super-engineer-workflow/assets/config.example.yml +12 -0
  26. package/super-engineer-workflow/assets/plan-schema.json +362 -0
  27. package/super-engineer-workflow/assets/status-schema.json +83 -0
  28. package/super-engineer-workflow/assets/workspace.example.yml +25 -0
  29. package/super-engineer-workflow/config.example.yml +12 -0
  30. package/super-engineer-workflow/references/contracts.md +39 -0
  31. package/super-engineer-workflow/references/execution-modes.md +38 -0
  32. package/super-engineer-workflow/references/java.md +21 -0
  33. package/super-engineer-workflow/references/planning.md +45 -0
  34. package/super-engineer-workflow/references/platform-openclaw.md +10 -0
  35. package/super-engineer-workflow/references/project-docs.md +7 -0
  36. package/super-engineer-workflow/references/review-checklist.md +26 -0
  37. package/super-engineer-workflow/references/se-commands.md +582 -0
  38. package/super-engineer-workflow/references/verify-checklist.md +45 -0
  39. package/super-engineer-workflow/references/workflow.md +208 -0
  40. package/super-engineer-workflow/scripts/archive-openspec.py +110 -0
  41. package/super-engineer-workflow/scripts/bootstrap-openspec.py +42 -0
  42. package/super-engineer-workflow/scripts/common.py +3285 -0
  43. package/super-engineer-workflow/scripts/generate-discovery.py +185 -0
  44. package/super-engineer-workflow/scripts/generate-review-report.py +296 -0
  45. package/super-engineer-workflow/scripts/generate-self-check.py +185 -0
  46. package/super-engineer-workflow/scripts/generate-smart-plan.py +429 -0
  47. package/super-engineer-workflow/scripts/init-workspace.py +68 -0
  48. package/super-engineer-workflow/scripts/prepare-archive-openspec.py +186 -0
  49. package/super-engineer-workflow/scripts/propose-openspec.py +170 -0
  50. package/super-engineer-workflow/scripts/run-verify-and-report.py +399 -0
  51. package/super-engineer-workflow/scripts/run-workflow.py +506 -0
  52. package/super-engineer-workflow/scripts/update-status.py +43 -0
  53. package/super-engineer-workflow/scripts/writeback-openspec.py +311 -0
@@ -0,0 +1,3285 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import time
13
+ import uuid
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib import error as urllib_error
18
+ from urllib.parse import urlparse
19
+ from urllib import request as urllib_request
20
+
21
+
22
+ def now_iso() -> str:
23
+ return datetime.now(timezone.utc).isoformat()
24
+
25
+
26
+ def parse_iso_datetime(value: str) -> datetime | None:
27
+ normalized = str(value).strip()
28
+ if not normalized:
29
+ return None
30
+ if normalized.endswith("Z"):
31
+ normalized = normalized[:-1] + "+00:00"
32
+ try:
33
+ parsed = datetime.fromisoformat(normalized)
34
+ except ValueError:
35
+ return None
36
+ if parsed.tzinfo is None:
37
+ return parsed.replace(tzinfo=timezone.utc)
38
+ return parsed
39
+
40
+
41
+ def format_duration(seconds: float) -> str:
42
+ safe_seconds = max(0.0, float(seconds))
43
+ if safe_seconds < 60:
44
+ return f"{safe_seconds:.2f} 秒"
45
+ total_seconds = int(round(safe_seconds))
46
+ hours, remainder = divmod(total_seconds, 3600)
47
+ minutes, secs = divmod(remainder, 60)
48
+ parts: list[str] = []
49
+ if hours:
50
+ parts.append(f"{hours} 小时")
51
+ if minutes:
52
+ parts.append(f"{minutes} 分")
53
+ if secs or not parts:
54
+ parts.append(f"{secs} 秒")
55
+ return "".join(parts)
56
+
57
+
58
+ def parse_scalar(value: str) -> Any:
59
+ stripped = value.strip()
60
+ if stripped in ("", "null", "~"):
61
+ return ""
62
+ if stripped == "[]":
63
+ return []
64
+ if stripped.startswith("[") and stripped.endswith("]"):
65
+ inner = stripped[1:-1].strip()
66
+ if not inner:
67
+ return []
68
+ parts: list[str] = []
69
+ current = ""
70
+ quote = ""
71
+ for char in inner:
72
+ if char in ("'", '"'):
73
+ if not quote:
74
+ quote = char
75
+ elif quote == char:
76
+ quote = ""
77
+ current += char
78
+ continue
79
+ if char == "," and not quote:
80
+ parts.append(current.strip())
81
+ current = ""
82
+ continue
83
+ current += char
84
+ if current.strip():
85
+ parts.append(current.strip())
86
+ return [parse_scalar(part) for part in parts]
87
+ if stripped == "true":
88
+ return True
89
+ if stripped == "false":
90
+ return False
91
+ if re.fullmatch(r"-?\d+", stripped):
92
+ return int(stripped)
93
+ if (stripped.startswith('"') and stripped.endswith('"')) or (
94
+ stripped.startswith("'") and stripped.endswith("'")
95
+ ):
96
+ return stripped[1:-1]
97
+ return stripped
98
+
99
+
100
+ def _tokenize_yaml(text: str) -> list[tuple[int, str]]:
101
+ tokens: list[tuple[int, str]] = []
102
+ for raw_line in text.splitlines():
103
+ if not raw_line.strip():
104
+ continue
105
+ if raw_line.lstrip().startswith("#"):
106
+ continue
107
+ indent = len(raw_line) - len(raw_line.lstrip(" "))
108
+ tokens.append((indent, raw_line.strip()))
109
+ return tokens
110
+
111
+
112
+ def _parse_yaml_block(tokens: list[tuple[int, str]], index: int, indent: int) -> tuple[int, Any]:
113
+ container: Any = None
114
+
115
+ while index < len(tokens):
116
+ current_indent, content = tokens[index]
117
+ if current_indent < indent:
118
+ break
119
+ if current_indent > indent:
120
+ raise ValueError(f"YAML 缩进不合法:{content}")
121
+
122
+ if content.startswith("- "):
123
+ if container is None:
124
+ container = []
125
+ if not isinstance(container, list):
126
+ raise ValueError("YAML 不能在同一层混用对象和数组。")
127
+ value_part = content[2:].strip()
128
+ if value_part:
129
+ container.append(parse_scalar(value_part))
130
+ index += 1
131
+ continue
132
+ index, nested = _parse_yaml_block(tokens, index + 1, indent + 2)
133
+ container.append(nested)
134
+ continue
135
+
136
+ if container is None:
137
+ container = {}
138
+ if not isinstance(container, dict):
139
+ raise ValueError("YAML 不能在同一层混用数组和对象。")
140
+
141
+ key, sep, value_part = content.partition(":")
142
+ if not sep:
143
+ raise ValueError(f"YAML 行缺少冒号:{content}")
144
+ key = key.strip()
145
+ value_part = value_part.strip()
146
+ if value_part:
147
+ container[key] = parse_scalar(value_part)
148
+ index += 1
149
+ continue
150
+ index, nested = _parse_yaml_block(tokens, index + 1, indent + 2)
151
+ container[key] = nested
152
+
153
+ if container is None:
154
+ return index, {}
155
+ return index, container
156
+
157
+
158
+ def parse_simple_yaml(text: str) -> dict[str, Any]:
159
+ tokens = _tokenize_yaml(text)
160
+ if not tokens:
161
+ return {}
162
+ _, parsed = _parse_yaml_block(tokens, 0, tokens[0][0])
163
+ if not isinstance(parsed, dict):
164
+ raise ValueError("工作空间配置必须是对象结构。")
165
+ return parsed
166
+
167
+
168
+ def _stringify_workspace_vars(raw_vars: Any) -> dict[str, str]:
169
+ if raw_vars in ("", None):
170
+ return {}
171
+ if not isinstance(raw_vars, dict):
172
+ raise ValueError("workspace.yml 中的 vars 必须是对象。")
173
+ variables: dict[str, str] = {}
174
+ for key, value in raw_vars.items():
175
+ key_text = str(key).strip()
176
+ if not key_text:
177
+ raise ValueError("workspace.yml 中的 vars 不能包含空 key。")
178
+ if isinstance(value, (dict, list)):
179
+ raise ValueError(f"workspace.yml 中的 vars.{key_text} 必须是标量值。")
180
+ variables[key_text] = str(value)
181
+ return variables
182
+
183
+
184
+ def _expand_workspace_value(value: Any, variables: dict[str, str]) -> Any:
185
+ if isinstance(value, str):
186
+ def replace(match: re.Match[str]) -> str:
187
+ name = match.group(1)
188
+ if name.startswith("vars."):
189
+ name = name[5:]
190
+ if name not in variables:
191
+ raise ValueError(f"workspace.yml 中引用了未定义变量:{match.group(1)}")
192
+ return variables[name]
193
+
194
+ return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_.-]*)\}", replace, value)
195
+ if isinstance(value, list):
196
+ return [_expand_workspace_value(item, variables) for item in value]
197
+ if isinstance(value, dict):
198
+ return {key: _expand_workspace_value(item, variables) for key, item in value.items()}
199
+ return value
200
+
201
+
202
+ def expand_workspace_variables(config: dict[str, Any], root: Path) -> dict[str, Any]:
203
+ user_vars = _stringify_workspace_vars(config.get("vars", {}))
204
+ variables = {
205
+ "workspace_root": str(root),
206
+ **user_vars,
207
+ }
208
+ expanded = _expand_workspace_value(config, variables)
209
+ if not isinstance(expanded, dict):
210
+ raise ValueError("工作空间配置必须是对象结构。")
211
+ expanded["vars"] = user_vars
212
+ return expanded
213
+
214
+
215
+ def resolve_workspace_path(root: Path, value: Any) -> Path:
216
+ path = Path(str(value)).expanduser()
217
+ if path.is_absolute():
218
+ return path.resolve()
219
+ return (root / path).resolve()
220
+
221
+
222
+ def is_url(value: Any) -> bool:
223
+ parsed = urlparse(str(value).strip())
224
+ return parsed.scheme in ("http", "https") and bool(parsed.netloc)
225
+
226
+
227
+ def is_lark_doc_url(value: Any) -> bool:
228
+ text = str(value).strip()
229
+ if not is_url(text):
230
+ return False
231
+ parsed = urlparse(text)
232
+ host = parsed.netloc.lower()
233
+ path = parsed.path.lower()
234
+ if not any(marker in host for marker in ("feishu.cn", "larksuite.com", "larksuite.cn")):
235
+ return False
236
+ return any(marker in path for marker in ("/doc", "/docx", "/wiki"))
237
+
238
+
239
+ def normalize_verify_commands(raw: Any) -> dict[str, str]:
240
+ if raw in ("", None):
241
+ return {}
242
+ if isinstance(raw, str):
243
+ command = raw.strip()
244
+ return {"default": command} if command else {}
245
+ if not isinstance(raw, dict):
246
+ raise ValueError("workspace.yml 中的 verify_commands 必须是字符串或对象。")
247
+ commands: dict[str, str] = {}
248
+ for key, value in raw.items():
249
+ key_text = str(key).strip()
250
+ value_text = str(value).strip()
251
+ if not key_text:
252
+ raise ValueError("workspace.yml 中的 verify_commands 不能包含空 key。")
253
+ if isinstance(value, (dict, list)):
254
+ raise ValueError(f"workspace.yml 中的 verify_commands.{key_text} 必须是字符串。")
255
+ if value_text:
256
+ commands[key_text] = value_text
257
+ return commands
258
+
259
+
260
+ def workspace_root(workspace: Path | None = None) -> Path:
261
+ return (workspace or Path.cwd()).expanduser().resolve()
262
+
263
+
264
+ def skill_root() -> Path:
265
+ return Path(__file__).resolve().parent.parent
266
+
267
+
268
+ def user_skill_config_dir() -> Path:
269
+ return Path.home() / ".super-engineer"
270
+
271
+
272
+ def workspace_config_path(workspace: Path | None = None) -> Path:
273
+ root = workspace_root(workspace)
274
+ workspace_yml = root / "workspace.yml"
275
+ if workspace_yml.exists():
276
+ return workspace_yml
277
+ legacy_config = root / "config.yml"
278
+ if legacy_config.exists():
279
+ return legacy_config
280
+ return workspace_yml
281
+
282
+
283
+ def skill_config_path() -> Path:
284
+ return user_skill_config_dir() / "skill-config.yml"
285
+
286
+
287
+ def skill_config_example_path() -> Path:
288
+ return skill_root() / "config.example.yml"
289
+
290
+
291
+ def default_skill_config() -> dict[str, Any]:
292
+ return {
293
+ "version": 1,
294
+ "notification": {
295
+ "pushplus": {
296
+ "token": "",
297
+ "ordinary": {
298
+ "enabled": False,
299
+ "channel": "wechat",
300
+ "template": "markdown",
301
+ },
302
+ },
303
+ "feishu": {
304
+ "enabled": False,
305
+ "webhook_url": "",
306
+ "secret": "",
307
+ },
308
+ },
309
+ }
310
+
311
+
312
+ def default_skill_config_text() -> str:
313
+ return """version: 1
314
+ notification:
315
+ pushplus:
316
+ token: ""
317
+ ordinary:
318
+ enabled: false
319
+ channel: wechat
320
+ template: markdown
321
+ feishu:
322
+ enabled: false
323
+ webhook_url: ""
324
+ secret: ""
325
+ """
326
+
327
+
328
+ def ensure_user_skill_config() -> tuple[Path, bool]:
329
+ config_path = skill_config_path()
330
+ if config_path.exists():
331
+ return config_path, False
332
+ config_path.parent.mkdir(parents=True, exist_ok=True)
333
+ config_path.write_text(default_skill_config_text(), encoding="utf-8")
334
+ return config_path, True
335
+
336
+
337
+ def load_skill_config() -> dict[str, Any]:
338
+ config_path, created = ensure_user_skill_config()
339
+ config = default_skill_config()
340
+ if config_path.exists():
341
+ loaded = parse_simple_yaml(config_path.read_text(encoding="utf-8"))
342
+ if loaded:
343
+ config.update(loaded)
344
+
345
+ if config.get("version") != 1:
346
+ raise ValueError("~/.super-engineer/skill-config.yml 中的 version 必须为 1。")
347
+
348
+ notification = config.get("notification", {})
349
+ if notification in ("", None):
350
+ notification = {}
351
+ if not isinstance(notification, dict):
352
+ raise ValueError("~/.super-engineer/skill-config.yml 中的 notification 必须是对象。")
353
+
354
+ pushplus = notification.get("pushplus", {})
355
+ if pushplus in ("", None):
356
+ pushplus = {}
357
+ if not isinstance(pushplus, dict):
358
+ raise ValueError("~/.super-engineer/skill-config.yml 中的 notification.pushplus 必须是对象。")
359
+
360
+ pushplus_token = str(pushplus.get("token", "")).strip()
361
+ ordinary_raw = pushplus.get("ordinary", {})
362
+ feishu_raw = notification.get("feishu", {})
363
+
364
+ if ordinary_raw in ("", None):
365
+ ordinary_raw = {}
366
+ if feishu_raw in ("", None):
367
+ feishu_raw = {}
368
+ if not isinstance(ordinary_raw, dict):
369
+ raise ValueError("~/.super-engineer/skill-config.yml 中的 notification.pushplus.ordinary 必须是对象。")
370
+ if not isinstance(feishu_raw, dict):
371
+ raise ValueError("~/.super-engineer/skill-config.yml 中的 notification.feishu 必须是对象。")
372
+
373
+ ordinary_channel = str(ordinary_raw.get("channel", "wechat")).strip() or "wechat"
374
+ ordinary_template = str(ordinary_raw.get("template", "markdown")).strip() or "markdown"
375
+ ordinary_enabled = bool(ordinary_raw.get("enabled", False))
376
+
377
+ feishu_enabled = bool(feishu_raw.get("enabled", False))
378
+ feishu_webhook_url = str(feishu_raw.get("webhook_url", "")).strip()
379
+ feishu_secret = str(feishu_raw.get("secret", "")).strip()
380
+
381
+ if not ordinary_channel:
382
+ raise ValueError("~/.super-engineer/skill-config.yml 中的 notification.pushplus.ordinary.channel 不能为空。")
383
+ if ordinary_enabled and not pushplus_token:
384
+ raise ValueError("启用 PushPlus 通知时,~/.super-engineer/skill-config.yml 中的 notification.pushplus.token 不能为空。")
385
+ if feishu_enabled and not feishu_webhook_url:
386
+ raise ValueError("启用飞书通知时,~/.super-engineer/skill-config.yml 中的 notification.feishu.webhook_url 不能为空。")
387
+ if feishu_webhook_url and not feishu_webhook_url.startswith("https://open.feishu.cn/open-apis/bot/v2/hook/"):
388
+ raise ValueError("notification.feishu.webhook_url 必须是飞书自定义机器人的 webhook 地址。")
389
+
390
+ config["notification"] = {
391
+ "pushplus": {
392
+ "token": pushplus_token,
393
+ "ordinary": {
394
+ "enabled": ordinary_enabled,
395
+ "channel": ordinary_channel,
396
+ "template": ordinary_template,
397
+ },
398
+ },
399
+ "feishu": {
400
+ "enabled": feishu_enabled,
401
+ "webhook_url": feishu_webhook_url,
402
+ "secret": feishu_secret,
403
+ },
404
+ }
405
+ config["__skill_root"] = str(skill_root())
406
+ config["__skill_config_example_path"] = str(skill_config_example_path())
407
+ config["__skill_config_path"] = str(config_path)
408
+ config["__skill_config_created"] = created
409
+ return config
410
+
411
+
412
+ def load_workspace_config(workspace: Path | None = None) -> dict[str, Any]:
413
+ root = workspace_root(workspace)
414
+ config_path = workspace_config_path(root)
415
+ if not config_path.exists():
416
+ raise FileNotFoundError(f"未找到工作空间配置文件:{config_path}")
417
+
418
+ config = expand_workspace_variables(parse_simple_yaml(config_path.read_text(encoding="utf-8")), root)
419
+ config.setdefault("version", 1)
420
+ config.setdefault("mode", "manual")
421
+ config.setdefault("workflow_source", "todo")
422
+ config.setdefault("reference_files", [])
423
+
424
+ if config.get("version") != 1:
425
+ raise ValueError("workspace.yml 中的 version 必须为 1。")
426
+ if config.get("mode") not in ("manual", "auto"):
427
+ raise ValueError("workspace.yml 中的 mode 只支持 manual 或 auto。")
428
+ if config.get("workflow_source") not in ("todo", "openspec"):
429
+ raise ValueError("workspace.yml 中的 workflow_source 只支持 todo 或 openspec。")
430
+
431
+ todo_file = resolve_workspace_path(root, config.get("todo_file", ""))
432
+ demand_file_raw = config.get("demand_file", "")
433
+ demand_source = str(demand_file_raw).strip() if demand_file_raw not in ("", None) else ""
434
+ demand_file = None
435
+ demand_source_type = ""
436
+ if demand_source:
437
+ if is_lark_doc_url(demand_source):
438
+ demand_source_type = "lark_doc"
439
+ elif is_url(demand_source):
440
+ raise ValueError("workspace.yml 中的 demand_file URL 目前只支持飞书/Lark 云文档链接。")
441
+ else:
442
+ demand_file = resolve_workspace_path(root, demand_source)
443
+ demand_source_type = "local"
444
+
445
+ code_path = resolve_workspace_path(root, config.get("code_path", ""))
446
+ if not code_path.exists():
447
+ raise ValueError(f"workspace.yml 中的 code_path 不存在:{code_path}")
448
+
449
+ output_dir = resolve_workspace_path(root, config.get("output_dir", ""))
450
+
451
+ reference_files = config.get("reference_files", [])
452
+ if reference_files == {}:
453
+ reference_files = []
454
+ if not isinstance(reference_files, list):
455
+ raise ValueError("workspace.yml 中的 reference_files 必须是数组。")
456
+ normalized_refs: list[str] = []
457
+ for item in reference_files:
458
+ path = resolve_workspace_path(root, item)
459
+ normalized_refs.append(str(path))
460
+
461
+ config["todo_file"] = str(todo_file.resolve())
462
+ config["demand_file"] = demand_source if demand_source_type == "lark_doc" else (str(demand_file.resolve()) if demand_file else "")
463
+ config["demand_source_type"] = demand_source_type
464
+ config["code_path"] = str(code_path.resolve())
465
+ config["output_dir"] = str(output_dir.resolve())
466
+ config["reference_files"] = normalized_refs
467
+ config["workflow_source"] = str(config.get("workflow_source", "todo")).strip() or "todo"
468
+ config["verify_commands"] = normalize_verify_commands(config.get("verify_commands", {}))
469
+
470
+ openspec_raw = config.get("openspec", {})
471
+ if openspec_raw in ("", None):
472
+ openspec_raw = {}
473
+ if not isinstance(openspec_raw, dict):
474
+ raise ValueError("workspace.yml 中的 openspec 必须是对象。")
475
+ openspec: dict[str, Any] = {}
476
+ if config["workflow_source"] == "openspec":
477
+ changes_dir_raw = openspec_raw.get("changes_dir", "")
478
+ change_dir_raw = openspec_raw.get("change_dir", "")
479
+ changes_dir = resolve_workspace_path(root, changes_dir_raw) if changes_dir_raw not in ("", None) else None
480
+ configured_change_dir = resolve_workspace_path(root, change_dir_raw) if change_dir_raw not in ("", None) else None
481
+
482
+ active_change = read_json(active_openspec_change_path(root), {})
483
+ bridge_context = read_json(root / ".super-engineer" / "openspec-bridge-context.json", {})
484
+ se_state = read_json(root / ".super-engineer" / "se-state.json", {})
485
+ active_change_name = str(active_change.get("change_name", "")).strip()
486
+ bridge_change_name = str(bridge_context.get("change_name", "")).strip()
487
+ state_change_name = str(se_state.get("current_change", "")).strip()
488
+ configured_change_name = str(openspec_raw.get("change_name", "")).strip()
489
+
490
+ if changes_dir is None:
491
+ if configured_change_dir is not None:
492
+ changes_dir = configured_change_dir if configured_change_dir.name == "changes" else configured_change_dir.parent
493
+ else:
494
+ changes_dir = (root / "openspec" / "changes").resolve()
495
+
496
+ def usable_change_name(candidate: str, require_existing_dir: bool) -> str:
497
+ normalized = str(candidate).strip()
498
+ if not normalized or not re.fullmatch(r"[a-z][a-z0-9-]*", normalized):
499
+ return ""
500
+ if require_existing_dir and not (changes_dir / normalized).exists():
501
+ return ""
502
+ return normalized
503
+
504
+ inferred_change_name = (
505
+ usable_change_name(active_change_name, True)
506
+ or usable_change_name(configured_change_name, False)
507
+ or usable_change_name(state_change_name, True)
508
+ or usable_change_name(bridge_change_name, True)
509
+ )
510
+
511
+ if inferred_change_name:
512
+ change_name = inferred_change_name
513
+ change_dir = changes_dir / change_name
514
+ elif configured_change_dir is not None and configured_change_dir.name != "changes":
515
+ change_dir = configured_change_dir
516
+ change_name = configured_change_name or change_dir.name
517
+ else:
518
+ change_name = ""
519
+ change_dir = changes_dir / change_name if change_name else changes_dir
520
+
521
+ tasks_file = resolve_workspace_path(root, openspec_raw.get("tasks_file", change_dir / "tasks.md"))
522
+ proposal_file = resolve_workspace_path(root, openspec_raw.get("proposal_file", change_dir / "proposal.md"))
523
+ design_file = resolve_workspace_path(root, openspec_raw.get("design_file", change_dir / "design.md"))
524
+ specs_dir = resolve_workspace_path(root, openspec_raw.get("specs_dir", change_dir / "specs"))
525
+ writeback_dir = resolve_workspace_path(root, openspec_raw.get("writeback_dir", change_dir / "super-engineer"))
526
+ openspec = {
527
+ "changes_dir": str(changes_dir.resolve()),
528
+ "change_dir": str(change_dir.resolve()),
529
+ "tasks_file": str(tasks_file.resolve()),
530
+ "proposal_file": str(proposal_file.resolve()),
531
+ "design_file": str(design_file.resolve()),
532
+ "specs_dir": str(specs_dir.resolve()),
533
+ "writeback_dir": str(writeback_dir.resolve()),
534
+ "change_name": change_name,
535
+ }
536
+ config["openspec"] = openspec
537
+
538
+ skill_config = load_skill_config()
539
+ config["notification"] = skill_config.get("notification", {})
540
+ config["__workspace_root"] = str(root)
541
+ config["__config_path"] = str(config_path)
542
+ config["__skill_root"] = str(skill_root())
543
+ config["__skill_config_path"] = str(skill_config_path())
544
+ config["__skill_config_created"] = bool(skill_config.get("__skill_config_created", False))
545
+ config["__skill_config_example_path"] = str(skill_config_example_path())
546
+ return config
547
+
548
+
549
+ def code_root(config: dict[str, Any]) -> Path:
550
+ return Path(str(config["code_path"])).resolve()
551
+
552
+
553
+ def looks_like_project_root(path: Path) -> bool:
554
+ return any(
555
+ (
556
+ (path / ".git").exists(),
557
+ (path / "pom.xml").exists(),
558
+ (path / "mvnw").exists(),
559
+ (path / "build.gradle").exists(),
560
+ (path / "build.gradle.kts").exists(),
561
+ (path / "gradlew").exists(),
562
+ (path / "package.json").exists(),
563
+ (path / "go.mod").exists(),
564
+ (path / "pyproject.toml").exists(),
565
+ (path / "requirements.txt").exists(),
566
+ (path / "Cargo.toml").exists(),
567
+ (path / "composer.json").exists(),
568
+ (path / "Gemfile").exists(),
569
+ (path / "CMakeLists.txt").exists(),
570
+ (path / "src" / "main").exists(),
571
+ )
572
+ )
573
+
574
+
575
+ def todo_path(config: dict[str, Any]) -> Path:
576
+ return Path(str(config["todo_file"])).resolve()
577
+
578
+
579
+ def workflow_source(config: dict[str, Any]) -> str:
580
+ return str(config.get("workflow_source", "todo")).strip() or "todo"
581
+
582
+
583
+ def artifacts_dir(config: dict[str, Any]) -> Path:
584
+ return Path(str(config["__workspace_root"])).resolve() / ".super-engineer"
585
+
586
+
587
+ def active_openspec_change_path(root: Path | str) -> Path:
588
+ return Path(str(root)).resolve() / ".super-engineer" / "current-openspec-change.json"
589
+
590
+
591
+ def se_state_path(config: dict[str, Any]) -> Path:
592
+ return artifacts_dir(config) / "se-state.json"
593
+
594
+
595
+ def todo_state_path(config: dict[str, Any]) -> Path:
596
+ return artifacts_dir(config) / "todo-state.json"
597
+
598
+
599
+ def workflow_state_path(config: dict[str, Any]) -> Path:
600
+ return todo_state_path(config) if workflow_source(config) == "todo" else se_state_path(config)
601
+
602
+
603
+ def sessions_dir(config: dict[str, Any]) -> Path:
604
+ return artifacts_dir(config) / "sessions"
605
+
606
+
607
+ def current_session_file(config: dict[str, Any]) -> Path:
608
+ return artifacts_dir(config) / "current-session.json"
609
+
610
+
611
+ def output_dir(config: dict[str, Any]) -> Path:
612
+ return Path(str(config["output_dir"])).resolve()
613
+
614
+
615
+ def session_data_dir(config: dict[str, Any], session_id: str) -> Path:
616
+ return sessions_dir(config) / session_id
617
+
618
+
619
+ def session_report_dir(config: dict[str, Any], session_id: str) -> Path:
620
+ return output_dir(config) / session_id
621
+
622
+
623
+ def _normalize_session_meta(config: dict[str, Any], session_meta: dict[str, Any]) -> dict[str, Any]:
624
+ session_id = str(session_meta.get("session_id", "")).strip()
625
+ if not session_id:
626
+ raise FileNotFoundError("尚未发现当前会话,请先执行 plan 创建新的工作流会话。")
627
+ started_at = (
628
+ str(session_meta.get("started_at", "")).strip()
629
+ or str(session_meta.get("created_at", "")).strip()
630
+ or now_iso()
631
+ )
632
+ return {
633
+ "session_id": session_id,
634
+ "created_at": str(session_meta.get("created_at", "")).strip() or now_iso(),
635
+ "started_at": started_at,
636
+ "workspace": str(workspace_root(Path(str(config["__workspace_root"])))),
637
+ "data_dir": str(session_data_dir(config, session_id)),
638
+ "report_dir": str(session_report_dir(config, session_id)),
639
+ }
640
+
641
+
642
+ def create_session(config: dict[str, Any]) -> dict[str, Any]:
643
+ ensure_runtime_dirs(config)
644
+ session_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}"
645
+ started_at = now_iso()
646
+ session_meta = _normalize_session_meta(
647
+ config,
648
+ {
649
+ "session_id": session_id,
650
+ "created_at": started_at,
651
+ "started_at": started_at,
652
+ },
653
+ )
654
+ Path(session_meta["data_dir"]).mkdir(parents=True, exist_ok=True)
655
+ Path(session_meta["report_dir"]).mkdir(parents=True, exist_ok=True)
656
+ write_managed_json(config, current_session_file(config), session_meta)
657
+ return session_meta
658
+
659
+
660
+ def current_session_meta(config: dict[str, Any]) -> dict[str, Any]:
661
+ session_meta = read_json(current_session_file(config), {})
662
+ normalized = _normalize_session_meta(config, session_meta)
663
+ Path(normalized["data_dir"]).mkdir(parents=True, exist_ok=True)
664
+ Path(normalized["report_dir"]).mkdir(parents=True, exist_ok=True)
665
+ return normalized
666
+
667
+
668
+ def data_artifact_path(config: dict[str, Any], name: str, session_meta: dict[str, Any] | None = None) -> Path:
669
+ meta = _normalize_session_meta(config, session_meta or current_session_meta(config))
670
+ return Path(meta["data_dir"]) / name
671
+
672
+
673
+ def report_artifact_path(config: dict[str, Any], name: str, session_meta: dict[str, Any] | None = None) -> Path:
674
+ meta = _normalize_session_meta(config, session_meta or current_session_meta(config))
675
+ return Path(meta["report_dir"]) / name
676
+
677
+
678
+ def workspace_relative_path(config: dict[str, Any], path: Path | str) -> str:
679
+ resolved = Path(str(path)).resolve()
680
+ root = Path(str(config.get("__workspace_root", ""))).resolve()
681
+ try:
682
+ return str(resolved.relative_to(root))
683
+ except ValueError:
684
+ return resolved.name
685
+
686
+
687
+ def artifact_path(config: dict[str, Any], name: str) -> Path:
688
+ return data_artifact_path(config, name)
689
+
690
+
691
+ SE_PHASE_ALLOWED_NEXT: dict[str, list[str]] = {
692
+ "draft": ["/se:propose"],
693
+ "proposed": ["/se:bridge"],
694
+ "bridged": ["/se:apply", "/se:plan"],
695
+ "planned": ["/se:apply"],
696
+ "implementing": [],
697
+ "self_checked": ["/se:review"],
698
+ "reviewed": ["/se:verify", "/se:apply"],
699
+ "verified": ["/se:archive-check"],
700
+ "archive_ready": ["/se:archive"],
701
+ "archived": [],
702
+ "blocked": ["/se:apply", "/se:verify"],
703
+ }
704
+
705
+ TODO_PHASE_ALLOWED_NEXT: dict[str, list[str]] = {
706
+ "draft": ["/se:init", "/se:plan", "/se:apply"],
707
+ "planned": ["/se:apply"],
708
+ "implementing": [],
709
+ "self_checked": ["/se:review"],
710
+ "reviewed": ["/se:verify", "/se:apply"],
711
+ "done": [],
712
+ "blocked": ["/se:apply", "/se:verify"],
713
+ }
714
+
715
+
716
+ RUN_COMMAND_TO_SE_COMMAND: dict[str, str] = {
717
+ "route-se": "",
718
+ "propose-openspec": "/se:propose",
719
+ "bootstrap-openspec": "/se:bridge",
720
+ "plan": "/se:plan",
721
+ "apply": "/se:apply",
722
+ "start-implement": "/se:apply",
723
+ "finish-implement": "/se:apply",
724
+ "review": "/se:review",
725
+ "verify": "/se:verify",
726
+ "prepare-archive-openspec": "/se:archive-check",
727
+ "archive-openspec": "/se:archive",
728
+ }
729
+
730
+
731
+ SE_COMMAND_TO_RUN_COMMAND: dict[str, str] = {
732
+ "/se:init": "init",
733
+ "/se:propose": "propose-openspec",
734
+ "/se:bridge": "bootstrap-openspec",
735
+ "/se:plan": "plan",
736
+ "/se:apply": "apply",
737
+ "/se:review": "review",
738
+ "/se:verify": "verify",
739
+ "/se:archive-check": "prepare-archive-openspec",
740
+ "/se:archive": "archive-openspec",
741
+ "/se:status": "status",
742
+ }
743
+
744
+
745
+ def read_se_state(config: dict[str, Any]) -> dict[str, Any]:
746
+ state = read_json(workflow_state_path(config), {})
747
+ if not isinstance(state, dict):
748
+ state = {}
749
+ phase = str(state.get("phase", "") or "").strip() or "draft"
750
+ allowed_next = state.get("allowed_next")
751
+ if not isinstance(allowed_next, list):
752
+ allowed_map = TODO_PHASE_ALLOWED_NEXT if workflow_source(config) == "todo" else SE_PHASE_ALLOWED_NEXT
753
+ allowed_next = allowed_map.get(phase, [])
754
+ state["phase"] = phase
755
+ state["allowed_next"] = [str(item) for item in allowed_next]
756
+ return state
757
+
758
+
759
+ def write_se_state(config: dict[str, Any], state: dict[str, Any]) -> Path:
760
+ phase = str(state.get("phase", "") or "draft").strip()
761
+ state["phase"] = phase
762
+ allowed_map = TODO_PHASE_ALLOWED_NEXT if workflow_source(config) == "todo" else SE_PHASE_ALLOWED_NEXT
763
+ state["allowed_next"] = list(allowed_map.get(phase, []))
764
+ state["updated_at"] = now_iso()
765
+ path = workflow_state_path(config)
766
+ write_managed_json(config, path, state)
767
+ return path
768
+
769
+
770
+ def update_se_state(
771
+ config: dict[str, Any],
772
+ phase: str,
773
+ last_command: str,
774
+ artifacts: dict[str, Any] | None = None,
775
+ blocked_reason: str = "",
776
+ ) -> dict[str, Any]:
777
+ state = read_se_state(config)
778
+ state.update(
779
+ {
780
+ "phase": phase,
781
+ "last_command": last_command,
782
+ "current_change": openspec_change_name(config) if workflow_source(config) == "openspec" else "",
783
+ "blocked_reason": blocked_reason,
784
+ }
785
+ )
786
+ if artifacts:
787
+ existing_artifacts = state.get("artifacts", {})
788
+ if not isinstance(existing_artifacts, dict):
789
+ existing_artifacts = {}
790
+ existing_artifacts.update(artifacts)
791
+ state["artifacts"] = existing_artifacts
792
+ write_se_state(config, state)
793
+ return state
794
+
795
+
796
+ def _se_state_artifact_exists(path_text: str) -> bool:
797
+ return bool(path_text and Path(path_text).exists())
798
+
799
+
800
+ def current_session_is_stale(config: dict[str, Any]) -> bool:
801
+ raw = read_json(current_session_file(config), {})
802
+ if not isinstance(raw, dict) or not raw.get("session_id"):
803
+ return False
804
+ session_id = str(raw.get("session_id", "")).strip()
805
+ expected_report_dir = session_report_dir(config, session_id).resolve()
806
+ raw_report_dir = str(raw.get("report_dir", "")).strip()
807
+ if raw_report_dir and Path(raw_report_dir).expanduser().resolve() != expected_report_dir:
808
+ return True
809
+ raw_workspace = str(raw.get("workspace", "")).strip()
810
+ if raw_workspace and Path(raw_workspace).expanduser().resolve() != Path(str(config["__workspace_root"])).resolve():
811
+ return True
812
+ return False
813
+
814
+
815
+ def _safe_current_session_meta(config: dict[str, Any]) -> dict[str, Any] | None:
816
+ try:
817
+ return current_session_meta(config)
818
+ except FileNotFoundError:
819
+ return None
820
+
821
+
822
+ def _status_phase_for_todo(config: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any] | None]:
823
+ if current_session_is_stale(config):
824
+ return "draft", {}, None
825
+ session_meta = _safe_current_session_meta(config)
826
+ if not session_meta:
827
+ return "draft", {}, None
828
+ status_path = data_artifact_path(config, "status.json", session_meta)
829
+ status = read_json(status_path, {})
830
+ if not isinstance(status, dict) or not status:
831
+ if data_artifact_path(config, "plan.json", session_meta).exists():
832
+ return "planned", {}, session_meta
833
+ return "draft", {}, session_meta
834
+ status_phase = str(status.get("phase", "") or "").strip()
835
+ phase_map = {
836
+ "context": "draft",
837
+ "plan": "planned",
838
+ "wait_confirm_plan": "planned",
839
+ "implement": "implementing",
840
+ "self_check": "self_checked",
841
+ "wait_confirm_implement": "self_checked",
842
+ "review": "reviewed",
843
+ "wait_confirm_review": "reviewed",
844
+ "done": "done",
845
+ "blocked": "blocked",
846
+ }
847
+ return phase_map.get(status_phase, status_phase or "draft"), status, session_meta
848
+
849
+
850
+ def _standard_source(payload: dict[str, Any], expected: str) -> bool:
851
+ return str(payload.get("source", "")).strip() == expected
852
+
853
+
854
+ def validate_standard_session(config: dict[str, Any], require_notification: bool = False) -> dict[str, Any]:
855
+ errors: list[str] = []
856
+ if current_session_is_stale(config):
857
+ errors.append("current-session.json 指向旧 output_dir,请重新执行 /se:plan 创建当前需求的标准会话。")
858
+ return {"valid": False, "errors": errors}
859
+ session_meta = _safe_current_session_meta(config)
860
+ if not session_meta:
861
+ errors.append("缺少当前 session,请先执行 /se:plan。")
862
+ return {"valid": False, "errors": errors}
863
+
864
+ status_path = data_artifact_path(config, "status.json", session_meta)
865
+ status = read_json(status_path, {})
866
+ if not isinstance(status, dict) or not status:
867
+ errors.append("缺少标准 status.json。")
868
+ status = {}
869
+
870
+ plan = read_json(data_artifact_path(config, "plan.json", session_meta), {})
871
+ if not isinstance(plan, dict) or not plan:
872
+ errors.append("缺少标准 plan.json。")
873
+ elif not _standard_source(plan, "run-workflow.py plan"):
874
+ errors.append("plan.json 不是标准脚本生成的产物。")
875
+
876
+ optional_sources = {
877
+ "self-check.json": "run-workflow.py self-check",
878
+ "review.json": "run-workflow.py review",
879
+ "verify.json": "run-workflow.py verify",
880
+ }
881
+ for artifact_name, source in optional_sources.items():
882
+ path = data_artifact_path(config, artifact_name, session_meta)
883
+ if not path.exists():
884
+ continue
885
+ payload = read_json(path, {})
886
+ if not isinstance(payload, dict) or not _standard_source(payload, source):
887
+ errors.append(f"{artifact_name} 不是标准脚本生成的产物。")
888
+
889
+ if require_notification or str(status.get("phase", "")).strip() == "done":
890
+ verify = read_json(data_artifact_path(config, "verify.json", session_meta), {})
891
+ notification = read_json(data_artifact_path(config, "notification.json", session_meta), {})
892
+ overall_result = str(verify.get("result", "")).strip() if isinstance(verify, dict) else ""
893
+ if overall_result != "通过":
894
+ errors.append("缺少通过状态的标准 verify.json。")
895
+ else:
896
+ pushplus_enabled = any(item.get("enabled") for item in pushplus_config(config).get("routes", []))
897
+ notification_enabled = bool(feishu_config(config).get("enabled") or pushplus_enabled)
898
+ if notification_enabled:
899
+ if not is_standard_workflow_notification(config, session_meta, ensure_status(config, session_meta, status), overall_result, notification):
900
+ errors.append("缺少标准 notification.json,或通知不是由 run-workflow.py verify 成功发送。")
901
+ elif not (
902
+ isinstance(notification, dict)
903
+ and str(notification.get("provider", "")).strip() == "notification"
904
+ and str(notification.get("source", "")).strip() == "run-workflow.py verify"
905
+ and str(notification.get("status", "")).strip() == "skipped"
906
+ ):
907
+ errors.append("缺少标准 notification.json,或未按未配置通知场景标记 skipped。")
908
+
909
+ return {
910
+ "valid": not errors,
911
+ "errors": errors,
912
+ "session_id": session_meta["session_id"] if session_meta else "",
913
+ }
914
+
915
+
916
+ def recover_se_state_from_artifacts(config: dict[str, Any]) -> dict[str, Any]:
917
+ if workflow_source(config) != "openspec":
918
+ return {"phase": "", "allowed_next": []}
919
+ state = read_se_state(config)
920
+ if se_state_path(config).exists() and str(state.get("phase", "")).strip() != "draft":
921
+ return state
922
+
923
+ artifacts = dict(state.get("artifacts", {}) if isinstance(state.get("artifacts"), dict) else {})
924
+ phase = "draft"
925
+ last_command = ""
926
+ try:
927
+ proposal = openspec_change_dir(config) / "proposal.md"
928
+ design = openspec_change_dir(config) / "design.md"
929
+ tasks = openspec_tasks_path(config)
930
+ if proposal.exists() or design.exists() or tasks.exists():
931
+ artifacts.update(
932
+ {
933
+ "proposal": str(proposal),
934
+ "design": str(design),
935
+ "tasks": str(tasks),
936
+ "change_dir": str(openspec_change_dir(config)),
937
+ }
938
+ )
939
+ if proposal.exists() and design.exists() and tasks.exists():
940
+ phase = "proposed"
941
+ last_command = "/se:propose"
942
+ except Exception:
943
+ pass
944
+
945
+ try:
946
+ todo_file = todo_path(config)
947
+ todo_text = read_text(todo_file)
948
+ if todo_file.exists() and not is_todo_template_placeholder(todo_text):
949
+ artifacts["todo"] = str(todo_file)
950
+ phase = "bridged"
951
+ last_command = "/se:bridge"
952
+ except Exception:
953
+ pass
954
+
955
+ try:
956
+ session_meta = current_session_meta(config)
957
+ plan_path = data_artifact_path(config, "plan.json", session_meta)
958
+ self_check_path = data_artifact_path(config, "self-check.json", session_meta)
959
+ review_path = data_artifact_path(config, "review.json", session_meta)
960
+ verify_path = data_artifact_path(config, "verify.json", session_meta)
961
+ notification_path = data_artifact_path(config, "notification.json", session_meta)
962
+ if plan_path.exists():
963
+ artifacts["plan_json"] = str(plan_path)
964
+ artifacts["plan_md"] = str(report_artifact_path(config, "plan.md", session_meta))
965
+ phase = "planned"
966
+ last_command = "/se:plan"
967
+ if self_check_path.exists():
968
+ artifacts["self_check_json"] = str(self_check_path)
969
+ artifacts["self_check_md"] = str(report_artifact_path(config, "self-check.md", session_meta))
970
+ phase = "self_checked"
971
+ last_command = "/se:apply"
972
+ if review_path.exists():
973
+ artifacts["review_json"] = str(review_path)
974
+ artifacts["review_md"] = str(report_artifact_path(config, "review.md", session_meta))
975
+ phase = "reviewed"
976
+ last_command = "/se:review"
977
+ if verify_path.exists():
978
+ artifacts["verify_json"] = str(verify_path)
979
+ artifacts["verify_md"] = str(report_artifact_path(config, "verify.md", session_meta))
980
+ artifacts["notification_json"] = str(notification_path)
981
+ verify = read_json(verify_path, {})
982
+ status = ensure_status(config, session_meta, read_json(data_artifact_path(config, "status.json", session_meta), {}))
983
+ notification = read_json(notification_path, {})
984
+ overall_result = str(verify.get("result") or verify.get("overall_result") or "").strip()
985
+ if overall_result == "通过" and is_standard_workflow_notification(config, session_meta, status, overall_result, notification):
986
+ phase = "verified"
987
+ last_command = "/se:verify"
988
+ else:
989
+ phase = "reviewed"
990
+ last_command = "/se:review"
991
+ except Exception:
992
+ pass
993
+
994
+ if phase != "draft":
995
+ state.update(
996
+ {
997
+ "phase": phase,
998
+ "last_command": last_command,
999
+ "current_change": openspec_change_name(config),
1000
+ "blocked_reason": "",
1001
+ "artifacts": artifacts,
1002
+ }
1003
+ )
1004
+ write_se_state(config, state)
1005
+ return read_se_state(config)
1006
+
1007
+
1008
+ def validate_se_state(config: dict[str, Any], run_command: str) -> dict[str, Any]:
1009
+ if workflow_source(config) != "openspec":
1010
+ se_command = RUN_COMMAND_TO_SE_COMMAND.get(run_command, "")
1011
+ phase, status, session_meta = _status_phase_for_todo(config)
1012
+ allowed_next = TODO_PHASE_ALLOWED_NEXT.get(phase, [])
1013
+ errors: list[str] = []
1014
+ if run_command in ("propose-openspec", "bootstrap-openspec", "prepare-archive-openspec", "archive-openspec"):
1015
+ errors.append("当前是 todo 模式,不能执行 OpenSpec 专属命令。")
1016
+ elif run_command in ("plan", "apply"):
1017
+ if not todo_path(config).exists():
1018
+ errors.append("缺少 todo_file,请先执行 /se:init 或补充 todo.md。")
1019
+ elif run_command == "start-implement":
1020
+ if phase not in ("planned", "implementing", "blocked"):
1021
+ errors.append("当前状态不允许进入实现,请先执行 /se:plan。")
1022
+ elif run_command == "finish-implement":
1023
+ if phase != "implementing":
1024
+ errors.append("当前状态不允许完成实现,必须先通过 /se:apply 进入 implementing。")
1025
+ elif run_command == "review":
1026
+ if phase not in ("self_checked", "reviewed", "blocked"):
1027
+ errors.append("当前状态不允许 review,请先完成实现和自查。")
1028
+ elif run_command == "verify":
1029
+ if phase not in ("reviewed", "done", "blocked"):
1030
+ errors.append("当前状态不允许 verify,请先完成 review。")
1031
+ if session_meta and run_command in ("start-implement", "finish-implement", "review", "verify"):
1032
+ standard = validate_standard_session(config, require_notification=False)
1033
+ for item in standard.get("errors", []):
1034
+ if "notification.json" not in str(item):
1035
+ errors.append(str(item))
1036
+ return {
1037
+ "valid": not errors,
1038
+ "phase": phase,
1039
+ "allowed_next": allowed_next,
1040
+ "errors": errors,
1041
+ "status_phase": status.get("phase", "") if isinstance(status, dict) else "",
1042
+ }
1043
+ se_command = RUN_COMMAND_TO_SE_COMMAND.get(run_command, "")
1044
+ if not se_command:
1045
+ return {"valid": True, "phase": "", "allowed_next": []}
1046
+ if se_command == "/se:propose":
1047
+ return {"valid": True, "phase": read_se_state(config).get("phase", "draft"), "allowed_next": ["/se:propose"]}
1048
+
1049
+ state = recover_se_state_from_artifacts(config)
1050
+ phase = str(state.get("phase", "") or "draft").strip()
1051
+ allowed_next = [str(item) for item in state.get("allowed_next", [])]
1052
+ artifacts = state.get("artifacts", {})
1053
+ if not isinstance(artifacts, dict):
1054
+ artifacts = {}
1055
+
1056
+ errors: list[str] = []
1057
+ if se_command == "/se:bridge":
1058
+ if not openspec_change_name(config):
1059
+ errors.append(
1060
+ "缺少当前 OpenSpec change。请先执行 /se:propose <change-name>;"
1061
+ "workspace.yml 支持相对路径、${demand_name} 和 openspec.changes_dir,不要改成绝对路径或手工配置 openspec.change_dir。"
1062
+ )
1063
+ if phase not in ("proposed", "bridged"):
1064
+ errors.append("当前状态不允许执行 /se:bridge,请先执行 /se:propose <change-name>,或在进入交付前停留在 bridged 阶段重新桥接。")
1065
+ for key in ("proposal", "design", "tasks"):
1066
+ if not _se_state_artifact_exists(str(artifacts.get(key, ""))):
1067
+ errors.append(f"缺少 OpenSpec 产物:{key}")
1068
+ elif se_command in ("/se:plan", "/se:apply"):
1069
+ if phase not in ("bridged", "planned", "implementing", "reviewed", "blocked") and se_command not in allowed_next:
1070
+ errors.append("当前状态不允许进入交付,请先完成 /se:bridge 并人工审核 todo.md。")
1071
+ if phase == "proposed":
1072
+ errors.append("/se:propose 后不能直接进入交付,必须先执行 /se:bridge。")
1073
+ if not _se_state_artifact_exists(str(artifacts.get("todo", ""))):
1074
+ errors.append("缺少桥接 todo.md,请先执行 /se:bridge。")
1075
+ elif se_command == "/se:review":
1076
+ if phase not in ("self_checked", "reviewed", "blocked"):
1077
+ errors.append("当前状态不允许 review,请先完成实现和自查。")
1078
+ elif se_command == "/se:verify":
1079
+ if phase not in ("reviewed", "verified", "blocked"):
1080
+ errors.append("当前状态不允许 verify,请先完成 review。")
1081
+ elif se_command == "/se:archive-check":
1082
+ if phase != "verified":
1083
+ errors.append("当前状态不允许 archive-check,请先完成 /se:verify。")
1084
+ elif se_command == "/se:archive":
1085
+ if phase != "archive_ready":
1086
+ errors.append("当前状态不允许 archive,请先完成 /se:archive-check 且结果为 safe_merge。")
1087
+
1088
+ return {
1089
+ "valid": not errors,
1090
+ "phase": phase,
1091
+ "allowed_next": allowed_next,
1092
+ "errors": errors,
1093
+ "state_path": str(se_state_path(config)),
1094
+ }
1095
+
1096
+
1097
+ def require_se_state(config: dict[str, Any], run_command: str) -> None:
1098
+ result = validate_se_state(config, run_command)
1099
+ if result.get("valid"):
1100
+ return
1101
+ errors = result.get("errors", [])
1102
+ message = "\n".join(str(item) for item in errors) if errors else "当前工作流状态不允许执行该命令。"
1103
+ raise SystemExit(message)
1104
+
1105
+
1106
+ def parse_se_command(text: str) -> dict[str, Any]:
1107
+ stripped = str(text).strip()
1108
+ match = re.match(r"^(/se:[a-z][a-z-]*)(?:\s+([A-Za-z0-9][A-Za-z0-9-]*))?(?:\s|$)", stripped)
1109
+ if not match:
1110
+ raise ValueError("未识别到 /se:* 命令。")
1111
+ se_command = match.group(1)
1112
+ argument = match.group(2) or ""
1113
+ if se_command not in SE_COMMAND_TO_RUN_COMMAND:
1114
+ raise ValueError(f"不支持的 /se:* 命令:{se_command}")
1115
+ return {
1116
+ "se_command": se_command,
1117
+ "run_command": SE_COMMAND_TO_RUN_COMMAND[se_command],
1118
+ "argument": argument,
1119
+ }
1120
+
1121
+
1122
+ def assert_managed_artifact(path: Path, config: dict[str, Any]) -> Path:
1123
+ resolved = path.resolve()
1124
+ data_root = artifacts_dir(config).resolve()
1125
+ output_root = output_dir(config).resolve()
1126
+ writeback = openspec_writeback_dir(config).resolve() if workflow_source(config) == "openspec" else None
1127
+ allowed_roots = [data_root, output_root]
1128
+ if writeback:
1129
+ allowed_roots.append(writeback)
1130
+ allowed_roots.append(openspec_archive_root(config).resolve())
1131
+ if not any(resolved == root or root in resolved.parents for root in allowed_roots):
1132
+ raise ValueError(f"拒绝写入非工作流托管产物:{resolved}")
1133
+ return resolved
1134
+
1135
+
1136
+ def ensure_runtime_dirs(config: dict[str, Any]) -> None:
1137
+ artifacts_dir(config).mkdir(parents=True, exist_ok=True)
1138
+ sessions_dir(config).mkdir(parents=True, exist_ok=True)
1139
+ output_dir(config).mkdir(parents=True, exist_ok=True)
1140
+
1141
+
1142
+ def read_text(path: Path) -> str:
1143
+ if not path.exists():
1144
+ return ""
1145
+ return path.read_text(encoding="utf-8")
1146
+
1147
+
1148
+ def file_sha256(path: Path) -> str:
1149
+ if not path.exists() or not path.is_file():
1150
+ return ""
1151
+ return hashlib.sha256(path.read_bytes()).hexdigest()
1152
+
1153
+
1154
+ def write_text(path: Path, content: str) -> None:
1155
+ path.parent.mkdir(parents=True, exist_ok=True)
1156
+ path.write_text(content, encoding="utf-8")
1157
+
1158
+
1159
+ def todo_template() -> str:
1160
+ return """# 限制条件
1161
+ - 修改的服务是 your-service-name
1162
+
1163
+ # 待办事项
1164
+
1165
+ ## 模块一:在这里写大需求模块名称
1166
+ - [ ] 在这里写主任务 1
1167
+ 1. 在这里写子要求 1
1168
+ 2. 在这里写子要求 2
1169
+
1170
+ - [ ] 在这里写主任务 2
1171
+ 1. 在这里写子要求 1
1172
+ 2. 在这里写子要求 2
1173
+
1174
+ ## 模块二:在这里写第二个大需求模块名称
1175
+ - [ ] 在这里写主任务 3
1176
+ - [ ] 在这里写主任务 4
1177
+
1178
+ ## 验收补充
1179
+ - [ ] 在这里写需要补充的测试、文档或验证要求
1180
+ """
1181
+
1182
+
1183
+ def openspec_change_dir(config: dict[str, Any]) -> Path:
1184
+ return Path(str(config.get("openspec", {}).get("change_dir", ""))).resolve()
1185
+
1186
+
1187
+ def openspec_change_name(config: dict[str, Any]) -> str:
1188
+ return str(config.get("openspec", {}).get("change_name", "")).strip()
1189
+
1190
+
1191
+ def validate_openspec_change_name(change_name: str) -> str:
1192
+ normalized = str(change_name).strip()
1193
+ if not normalized:
1194
+ raise ValueError("OpenSpec change 名称不能为空。请使用 /se:propose <change-name> 显式指定。")
1195
+ if "/" in normalized or "\\" in normalized:
1196
+ raise ValueError("OpenSpec change 名称不能包含路径分隔符。")
1197
+ if not re.fullmatch(r"[a-z][a-z0-9-]*", normalized):
1198
+ raise ValueError("OpenSpec change 名称必须匹配 [a-z][a-z0-9-]*,例如 demand-addition-rate。")
1199
+ return normalized
1200
+
1201
+
1202
+ def select_openspec_change(config: dict[str, Any], change_name: str) -> dict[str, Any]:
1203
+ selected_name = validate_openspec_change_name(change_name)
1204
+ openspec = dict(config.get("openspec", {}))
1205
+ changes_dir_text = str(openspec.get("changes_dir", "")).strip()
1206
+ if changes_dir_text:
1207
+ changes_dir = Path(changes_dir_text).resolve()
1208
+ else:
1209
+ current_dir = Path(str(openspec.get("change_dir", ""))).resolve()
1210
+ changes_dir = current_dir if current_dir.name == "changes" else current_dir.parent
1211
+ change_dir = changes_dir / selected_name
1212
+ openspec.update(
1213
+ {
1214
+ "changes_dir": str(changes_dir),
1215
+ "change_name": selected_name,
1216
+ "change_dir": str(change_dir),
1217
+ "tasks_file": str(change_dir / "tasks.md"),
1218
+ "proposal_file": str(change_dir / "proposal.md"),
1219
+ "design_file": str(change_dir / "design.md"),
1220
+ "specs_dir": str(change_dir / "specs"),
1221
+ "writeback_dir": str(change_dir / "super-engineer"),
1222
+ }
1223
+ )
1224
+ config["openspec"] = openspec
1225
+ return config
1226
+
1227
+
1228
+ def write_active_openspec_change(config: dict[str, Any], change_name: str) -> Path:
1229
+ selected_name = validate_openspec_change_name(change_name)
1230
+ active_path = active_openspec_change_path(config["__workspace_root"])
1231
+ active_path.parent.mkdir(parents=True, exist_ok=True)
1232
+ write_managed_json(
1233
+ config,
1234
+ active_path,
1235
+ {
1236
+ "change_name": selected_name,
1237
+ "change_dir": str(openspec_change_dir(config)),
1238
+ "updated_at": now_iso(),
1239
+ },
1240
+ )
1241
+ return active_path
1242
+
1243
+
1244
+ def openspec_root(config: dict[str, Any]) -> Path:
1245
+ changes_dir = str(config.get("openspec", {}).get("changes_dir", "")).strip()
1246
+ if changes_dir:
1247
+ resolved = Path(changes_dir).resolve()
1248
+ if resolved.name == "changes" and resolved.parent.name == "openspec":
1249
+ return resolved.parent.parent.resolve()
1250
+ return resolved.parent.resolve()
1251
+ change_dir = openspec_change_dir(config)
1252
+ if change_dir.parent.name == "changes" and change_dir.parent.parent.name == "openspec":
1253
+ return change_dir.parent.parent.parent.resolve()
1254
+ return change_dir.parent.parent.resolve()
1255
+
1256
+
1257
+ def demand_path(config: dict[str, Any]) -> Path | None:
1258
+ path_text = str(config.get("demand_file", "")).strip()
1259
+ if not path_text or str(config.get("demand_source_type", "")).strip() == "lark_doc" or is_url(path_text):
1260
+ return None
1261
+ return Path(path_text).resolve()
1262
+
1263
+
1264
+ def lark_cli_available() -> bool:
1265
+ return bool(shutil.which("lark-cli"))
1266
+
1267
+
1268
+ def lark_cli_install_message() -> str:
1269
+ return "\n".join(
1270
+ [
1271
+ "检测到 demand_file 是飞书/Lark 云文档,但本机未安装官方 lark-cli。",
1272
+ "请先安装并完成授权:",
1273
+ "1. npx @larksuite/cli@latest install",
1274
+ "2. lark-cli config init --new",
1275
+ "3. lark-cli auth login --recommend",
1276
+ "4. lark-cli auth status",
1277
+ "安装和授权完成后重新执行 /se:propose <change-name>。",
1278
+ ]
1279
+ )
1280
+
1281
+
1282
+ def _extract_lark_cli_text(stdout: str) -> str:
1283
+ text = stdout.strip()
1284
+ if not text:
1285
+ return ""
1286
+ try:
1287
+ parsed = json.loads(text)
1288
+ except json.JSONDecodeError:
1289
+ return text
1290
+
1291
+ def pick(value: Any) -> str:
1292
+ if isinstance(value, str):
1293
+ return value.strip()
1294
+ if isinstance(value, dict):
1295
+ for key in ("content", "markdown", "text", "body", "document", "doc"):
1296
+ picked = pick(value.get(key))
1297
+ if picked:
1298
+ return picked
1299
+ for item in value.values():
1300
+ picked = pick(item)
1301
+ if picked:
1302
+ return picked
1303
+ if isinstance(value, list):
1304
+ parts = [pick(item) for item in value]
1305
+ return "\n\n".join(item for item in parts if item)
1306
+ return ""
1307
+
1308
+ return pick(parsed) or text
1309
+
1310
+
1311
+ def read_lark_doc_demand(config: dict[str, Any], demand_url: str) -> dict[str, Any]:
1312
+ if not lark_cli_available():
1313
+ raise RuntimeError(lark_cli_install_message())
1314
+ commands = [
1315
+ ["lark-cli", "docs", "+fetch", "--api-version", "v2", "--doc", demand_url, "--doc-format", "markdown", "--detail", "full"],
1316
+ ["lark-cli", "docs", "+fetch", "--api-version", "v2", "--doc", demand_url, "--doc-format", "markdown"],
1317
+ ["lark-cli", "docs", "+fetch", "--doc", demand_url, "--doc-format", "markdown"],
1318
+ ["lark-cli", "docs", "+fetch", "--url", demand_url, "--doc-format", "markdown"],
1319
+ ]
1320
+ attempts: list[dict[str, Any]] = []
1321
+ for command in commands:
1322
+ try:
1323
+ result = subprocess.run(
1324
+ command,
1325
+ cwd=str(config.get("__workspace_root", os.getcwd())),
1326
+ text=True,
1327
+ capture_output=True,
1328
+ check=False,
1329
+ timeout=90,
1330
+ )
1331
+ except FileNotFoundError:
1332
+ raise RuntimeError(lark_cli_install_message())
1333
+ except subprocess.TimeoutExpired:
1334
+ attempts.append(
1335
+ {
1336
+ "command": command,
1337
+ "returncode": "timeout",
1338
+ "stdout": "",
1339
+ "stderr": "lark-cli docs +fetch timeout",
1340
+ }
1341
+ )
1342
+ continue
1343
+ attempts.append(
1344
+ {
1345
+ "command": command,
1346
+ "returncode": result.returncode,
1347
+ "stdout": result.stdout,
1348
+ "stderr": result.stderr,
1349
+ }
1350
+ )
1351
+ if result.returncode == 0:
1352
+ content = _extract_lark_cli_text(result.stdout)
1353
+ if content:
1354
+ return {
1355
+ "source_type": "lark_doc",
1356
+ "source": demand_url,
1357
+ "content": content,
1358
+ "command": command,
1359
+ "attempts": attempts,
1360
+ }
1361
+ last = attempts[-1] if attempts else {}
1362
+ raise RuntimeError(
1363
+ "读取飞书/Lark 云文档失败。请确认 lark-cli 已完成授权且当前账号有文档访问权限。\n"
1364
+ "建议执行:lark-cli auth status;如未登录,执行:lark-cli auth login --recommend。\n"
1365
+ f"最后一次错误:{str(last.get('stderr') or last.get('stdout') or '').strip()}"
1366
+ )
1367
+
1368
+
1369
+ def read_demand_source(config: dict[str, Any]) -> dict[str, Any]:
1370
+ source = str(config.get("demand_file", "")).strip()
1371
+ if not source:
1372
+ return {"source_type": "", "source": "", "content": "", "command": [], "attempts": []}
1373
+ if str(config.get("demand_source_type", "")).strip() == "lark_doc" or is_lark_doc_url(source):
1374
+ return read_lark_doc_demand(config, source)
1375
+ path = Path(source)
1376
+ return {
1377
+ "source_type": "local",
1378
+ "source": str(path.resolve()),
1379
+ "content": read_text(path),
1380
+ "command": [],
1381
+ "attempts": [],
1382
+ }
1383
+
1384
+
1385
+ def openspec_cli_available() -> bool:
1386
+ return bool(shutil.which("openspec"))
1387
+
1388
+
1389
+ def run_openspec_cli(config: dict[str, Any], args: list[str]) -> dict[str, Any]:
1390
+ if not openspec_cli_available():
1391
+ return {
1392
+ "available": False,
1393
+ "args": args,
1394
+ "returncode": None,
1395
+ "stdout": "",
1396
+ "stderr": "openspec CLI not found in PATH",
1397
+ "json": None,
1398
+ }
1399
+ result = subprocess.run(
1400
+ ["openspec", *args],
1401
+ cwd=str(openspec_root(config)),
1402
+ text=True,
1403
+ capture_output=True,
1404
+ check=False,
1405
+ )
1406
+ parsed_json: Any = None
1407
+ stdout = result.stdout.strip()
1408
+ if stdout:
1409
+ try:
1410
+ parsed_json = json.loads(stdout)
1411
+ except json.JSONDecodeError:
1412
+ parsed_json = None
1413
+ return {
1414
+ "available": True,
1415
+ "args": args,
1416
+ "returncode": result.returncode,
1417
+ "stdout": result.stdout,
1418
+ "stderr": result.stderr,
1419
+ "json": parsed_json,
1420
+ }
1421
+
1422
+
1423
+ def collect_openspec_cli_context(config: dict[str, Any], include_apply: bool = False, include_archive: bool = False) -> dict[str, Any]:
1424
+ change_name = openspec_change_name(config)
1425
+ context: dict[str, Any] = {
1426
+ "available": openspec_cli_available(),
1427
+ "change_name": change_name,
1428
+ "status": {},
1429
+ "apply_instructions": {},
1430
+ "archive_instructions": {},
1431
+ }
1432
+ if not change_name:
1433
+ context["error"] = "missing active OpenSpec change; run /se:propose <change-name> first"
1434
+ return context
1435
+ if not context["available"]:
1436
+ context["error"] = "openspec CLI not found in PATH"
1437
+ return context
1438
+ status = run_openspec_cli(config, ["status", "--change", change_name, "--json"])
1439
+ context["status"] = status
1440
+ if include_apply:
1441
+ context["apply_instructions"] = run_openspec_cli(config, ["instructions", "apply", "--change", change_name, "--json"])
1442
+ if include_archive:
1443
+ context["archive_instructions"] = run_openspec_cli(config, ["instructions", "archive", "--change", change_name, "--json"])
1444
+ return context
1445
+
1446
+
1447
+ def openspec_tasks_path(config: dict[str, Any]) -> Path:
1448
+ return Path(str(config.get("openspec", {}).get("tasks_file", ""))).resolve()
1449
+
1450
+
1451
+ def openspec_reference_files(config: dict[str, Any]) -> list[str]:
1452
+ if workflow_source(config) != "openspec":
1453
+ return []
1454
+ openspec = config.get("openspec", {})
1455
+ files: list[str] = []
1456
+ for key in ("proposal_file", "design_file"):
1457
+ path_text = str(openspec.get(key, "")).strip()
1458
+ if not path_text:
1459
+ continue
1460
+ path = Path(path_text)
1461
+ if path.exists() and path.is_file():
1462
+ files.append(str(path.resolve()))
1463
+ specs_dir_text = str(openspec.get("specs_dir", "")).strip()
1464
+ if specs_dir_text:
1465
+ specs_dir = Path(specs_dir_text)
1466
+ if specs_dir.exists() and specs_dir.is_dir():
1467
+ for path in sorted(specs_dir.rglob("*.md")):
1468
+ files.append(str(path.resolve()))
1469
+ return unique(files)
1470
+
1471
+
1472
+ def openspec_writeback_dir(config: dict[str, Any]) -> Path:
1473
+ return Path(str(config.get("openspec", {}).get("writeback_dir", ""))).resolve()
1474
+
1475
+
1476
+ def openspec_archive_root(config: dict[str, Any]) -> Path:
1477
+ return openspec_change_dir(config).parent / "archive"
1478
+
1479
+
1480
+ def openspec_bridge_context_path(config: dict[str, Any]) -> Path:
1481
+ return artifacts_dir(config) / "openspec-bridge-context.json"
1482
+
1483
+
1484
+ def transform_openspec_tasks_to_todo(tasks_text: str, change_name: str, change_dir: Path, service_names: list[str] | None = None) -> str:
1485
+ lines = [
1486
+ "# 限制条件",
1487
+ f"- 需求来源是 OpenSpec change:{change_name}",
1488
+ f"- OpenSpec 变更目录是 {change_dir}",
1489
+ "- 优先以 proposal.md、design.md 和 specs/ 下的 delta specs 作为业务边界",
1490
+ ]
1491
+ for service_name in unique(service_names or []):
1492
+ lines.append(f"- 修改的服务是 {service_name}")
1493
+ lines.extend(
1494
+ [
1495
+ "",
1496
+ "# 待办事项",
1497
+ "",
1498
+ ]
1499
+ )
1500
+ has_tasks = False
1501
+ for raw_line in tasks_text.splitlines():
1502
+ stripped = raw_line.rstrip()
1503
+ compact = stripped.strip()
1504
+ if not compact:
1505
+ if lines and lines[-1] != "":
1506
+ lines.append("")
1507
+ continue
1508
+ if compact.startswith("#"):
1509
+ heading_text = compact.lstrip("#").strip()
1510
+ if heading_text.lower() == "tasks":
1511
+ continue
1512
+ lines.append(f"## {heading_text}")
1513
+ has_tasks = True
1514
+ continue
1515
+ if compact.startswith("- [") or compact.startswith("* ["):
1516
+ lines.append(compact.replace("* [", "- [", 1))
1517
+ has_tasks = True
1518
+ continue
1519
+ if compact.startswith("- ") or compact.startswith("* "):
1520
+ lines.append("- [ ] " + normalize_todo_text_item(compact))
1521
+ has_tasks = True
1522
+ continue
1523
+ if re.match(r"^\d+\.\s+", compact):
1524
+ lines.append(compact)
1525
+ has_tasks = True
1526
+ continue
1527
+ lines.append("- [ ] " + normalize_todo_text_item(compact))
1528
+ has_tasks = True
1529
+ if not has_tasks:
1530
+ lines.extend(
1531
+ [
1532
+ "## 默认任务",
1533
+ "- [ ] OpenSpec tasks.md 中未识别到可执行任务,请先补充任务项",
1534
+ ]
1535
+ )
1536
+ if lines[-1] != "":
1537
+ lines.append("")
1538
+ return "\n".join(lines)
1539
+
1540
+
1541
+ def openspec_source_texts(config: dict[str, Any], tasks_text: str) -> list[str]:
1542
+ openspec = config.get("openspec", {})
1543
+ proposal_file = Path(str(openspec.get("proposal_file", "")))
1544
+ design_file = Path(str(openspec.get("design_file", "")))
1545
+ proposal_text = read_text(proposal_file) if proposal_file.exists() else ""
1546
+ design_text = read_text(design_file) if design_file.exists() else ""
1547
+ texts = [tasks_text, proposal_text, design_text]
1548
+ specs_dir = Path(str(openspec.get("specs_dir", ""))).expanduser()
1549
+ if specs_dir.exists() and specs_dir.is_dir():
1550
+ for path in sorted(specs_dir.rglob("*.md")):
1551
+ texts.append(read_text(path))
1552
+ return texts
1553
+
1554
+
1555
+ def infer_openspec_service_hints(config: dict[str, Any], tasks_text: str) -> list[str]:
1556
+ source_text = "\n".join(openspec_source_texts(config, tasks_text))
1557
+ hints = _extract_service_hints_from_lines(source_text.splitlines())
1558
+ try:
1559
+ root = code_root(config)
1560
+ candidates = [root] if looks_like_project_root(root) else find_candidate_codebases(root)
1561
+ lowered_source = source_text.lower()
1562
+ for candidate in candidates:
1563
+ candidate_name = candidate.name.strip()
1564
+ if candidate_name and candidate_name.lower() in lowered_source:
1565
+ hints.append(candidate_name)
1566
+ except Exception:
1567
+ pass
1568
+ return unique(hints)
1569
+
1570
+
1571
+ def build_openspec_bridge_context(config: dict[str, Any], tasks_text: str) -> dict[str, Any]:
1572
+ openspec = config.get("openspec", {})
1573
+ proposal_file = Path(str(openspec.get("proposal_file", "")))
1574
+ design_file = Path(str(openspec.get("design_file", "")))
1575
+ proposal_text = read_text(proposal_file) if proposal_file.exists() else ""
1576
+ design_text = read_text(design_file) if design_file.exists() else ""
1577
+ specs = openspec_reference_files(config)
1578
+ change_dir = openspec_change_dir(config)
1579
+ repo_openspec_root = change_dir.parent.parent
1580
+ delta_specs_dir = Path(str(openspec.get("specs_dir", ""))).expanduser()
1581
+ spec_merge_targets: list[dict[str, Any]] = []
1582
+ if delta_specs_dir.exists() and delta_specs_dir.is_dir():
1583
+ target_specs_root = repo_openspec_root / "specs"
1584
+ for source in sorted(delta_specs_dir.rglob("*.md")):
1585
+ relative = source.relative_to(delta_specs_dir)
1586
+ target = target_specs_root / relative
1587
+ spec_merge_targets.append(
1588
+ {
1589
+ "delta_source": str(source.resolve()),
1590
+ "target": str(target.resolve()),
1591
+ "relative_path": str(relative),
1592
+ "target_exists": target.exists(),
1593
+ "target_sha256": file_sha256(target),
1594
+ }
1595
+ )
1596
+
1597
+ def extract_headings(text: str) -> list[str]:
1598
+ headings: list[str] = []
1599
+ for line in text.splitlines():
1600
+ stripped = line.strip()
1601
+ if stripped.startswith("#"):
1602
+ headings.append(stripped.lstrip("#").strip())
1603
+ return headings[:12]
1604
+
1605
+ compatibility_notes: list[str] = []
1606
+ for source_text in (proposal_text, design_text, tasks_text):
1607
+ for line in source_text.splitlines():
1608
+ stripped = line.strip()
1609
+ lowered = stripped.lower()
1610
+ if any(keyword in lowered for keyword in ("兼容", "rollback", "回滚", "灰度", "mq", "schema", "contract")):
1611
+ compatibility_notes.append(stripped)
1612
+
1613
+ acceptance_criteria: list[str] = []
1614
+ for line in tasks_text.splitlines():
1615
+ stripped = normalize_todo_text_item(line)
1616
+ if stripped and ("test" in stripped.lower() or "验证" in stripped or "验收" in stripped):
1617
+ acceptance_criteria.append(stripped)
1618
+
1619
+ return {
1620
+ "workflow_source": "openspec",
1621
+ "change_name": openspec_change_name(config),
1622
+ "change_dir": str(openspec_change_dir(config)),
1623
+ "tasks_file": str(openspec_tasks_path(config)),
1624
+ "openspec_cli": collect_openspec_cli_context(config, include_apply=True),
1625
+ "proposal_file": str(proposal_file.resolve()) if proposal_file.exists() else "",
1626
+ "design_file": str(design_file.resolve()) if design_file.exists() else "",
1627
+ "spec_reference_files": specs,
1628
+ "proposal_headings": extract_headings(proposal_text),
1629
+ "design_headings": extract_headings(design_text),
1630
+ "business_constraints": [
1631
+ f"需求来源是 OpenSpec change:{openspec.get('change_name', '')}",
1632
+ "优先以 proposal.md、design.md 和 specs/ 下的 delta specs 作为业务边界",
1633
+ ],
1634
+ "service_hints": infer_openspec_service_hints(config, tasks_text),
1635
+ "acceptance_criteria": unique(acceptance_criteria),
1636
+ "compatibility_notes": unique(compatibility_notes),
1637
+ "spec_merge_targets": spec_merge_targets,
1638
+ "updated_at": now_iso(),
1639
+ }
1640
+
1641
+
1642
+ def ensure_workflow_inputs(config: dict[str, Any], *, allow_bridge_write: bool = False) -> dict[str, Any]:
1643
+ source = workflow_source(config)
1644
+ todo_file = todo_path(config)
1645
+ result = {
1646
+ "workflow_source": source,
1647
+ "todo_path": str(todo_file),
1648
+ "todo_created": False,
1649
+ "todo_needs_edit": False,
1650
+ "bridge_generated": False,
1651
+ "bridge_source": "",
1652
+ }
1653
+ if source == "todo":
1654
+ if not todo_file.exists():
1655
+ write_text(todo_file, todo_template())
1656
+ result["todo_created"] = True
1657
+ todo_text = read_text(todo_file)
1658
+ result["todo_needs_edit"] = is_todo_template_placeholder(todo_text)
1659
+ return result
1660
+
1661
+ change_name = openspec_change_name(config)
1662
+ if not change_name:
1663
+ raise ValueError(
1664
+ "缺少当前 OpenSpec change。请先执行 /se:propose <change-name> 记录 active change;"
1665
+ "不需要把 workspace.yml 改成绝对路径,也不需要显式配置 openspec.change_dir。"
1666
+ )
1667
+ tasks_file = openspec_tasks_path(config)
1668
+ if not tasks_file.exists():
1669
+ raise FileNotFoundError(
1670
+ f"OpenSpec tasks 文件不存在:{tasks_file}。请确认已执行 /se:propose {change_name} 并生成 tasks.md;"
1671
+ "不要手工生成桥接产物。"
1672
+ )
1673
+ tasks_text = read_text(tasks_file)
1674
+ existing = read_text(todo_file)
1675
+ if not allow_bridge_write:
1676
+ if not existing.strip():
1677
+ raise FileNotFoundError(
1678
+ f"桥接 todo 文件不存在:{todo_file}。OpenSpec 模式下只有显式执行 /se:bridge "
1679
+ "才允许从 tasks.md 生成 todo.md;/se:init、/se:propose、/se:plan、/se:apply 都不能自动生成桥接 todo。"
1680
+ )
1681
+ result["bridge_source"] = str(tasks_file)
1682
+ result["todo_needs_edit"] = is_todo_template_placeholder(existing)
1683
+ bridge_context = build_openspec_bridge_context(config, tasks_text)
1684
+ write_managed_json(config, openspec_bridge_context_path(config), bridge_context)
1685
+ return result
1686
+ service_names = infer_openspec_service_hints(config, tasks_text)
1687
+ bridged_todo = transform_openspec_tasks_to_todo(
1688
+ tasks_text,
1689
+ str(config.get("openspec", {}).get("change_name", tasks_file.parent.name)),
1690
+ openspec_change_dir(config),
1691
+ service_names,
1692
+ )
1693
+ if existing != bridged_todo:
1694
+ write_text(todo_file, bridged_todo)
1695
+ result["bridge_generated"] = True
1696
+ result["bridge_source"] = str(tasks_file)
1697
+ result["todo_needs_edit"] = is_todo_template_placeholder(bridged_todo)
1698
+ bridge_context = build_openspec_bridge_context(config, tasks_text)
1699
+ write_managed_json(config, openspec_bridge_context_path(config), bridge_context)
1700
+ return result
1701
+
1702
+
1703
+ def is_todo_template_placeholder(todo_text: str) -> bool:
1704
+ normalized = todo_text.strip()
1705
+ if not normalized:
1706
+ return True
1707
+ if normalized == todo_template().strip():
1708
+ return True
1709
+ markers = [
1710
+ "your-service-name",
1711
+ "在这里写大需求模块名称",
1712
+ "在这里写主任务 1",
1713
+ "在这里写需要补充的测试、文档或验证要求",
1714
+ ]
1715
+ return any(marker in normalized for marker in markers)
1716
+
1717
+
1718
+ def read_json(path: Path, fallback: Any) -> Any:
1719
+ if not path.exists():
1720
+ return fallback
1721
+ try:
1722
+ text = path.read_text(encoding="utf-8").strip()
1723
+ if not text:
1724
+ return fallback
1725
+ return json.loads(text)
1726
+ except json.JSONDecodeError:
1727
+ return fallback
1728
+
1729
+
1730
+ def write_json(path: Path, payload: Any) -> None:
1731
+ path.parent.mkdir(parents=True, exist_ok=True)
1732
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
1733
+ tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
1734
+ tmp_path.replace(path)
1735
+
1736
+
1737
+ def write_managed_json(config: dict[str, Any], path: Path, payload: Any) -> None:
1738
+ write_json(assert_managed_artifact(path, config), payload)
1739
+
1740
+
1741
+ def write_managed_text(config: dict[str, Any], path: Path, content: str) -> None:
1742
+ write_text(assert_managed_artifact(path, config), content)
1743
+
1744
+
1745
+ def unique(items: list[str]) -> list[str]:
1746
+ seen: set[str] = set()
1747
+ ordered: list[str] = []
1748
+ for item in items:
1749
+ if item and item not in seen:
1750
+ seen.add(item)
1751
+ ordered.append(item)
1752
+ return ordered
1753
+
1754
+
1755
+ def normalize_todo_text_item(text: str) -> str:
1756
+ normalized = text.strip()
1757
+ normalized = re.sub(r"^\d+\.\s*", "", normalized)
1758
+ normalized = re.sub(r"^[-*]\s*", "", normalized)
1759
+ normalized = re.sub(r"^\[(?: |x|X)\]\s*", "", normalized)
1760
+ return normalized.strip()
1761
+
1762
+
1763
+ def parse_todo_document(todo_text: str) -> dict[str, Any]:
1764
+ constraints: list[str] = []
1765
+ modules: list[dict[str, Any]] = []
1766
+ default_module_title = "未分组需求"
1767
+ current_section = "__root__"
1768
+ current_module: dict[str, Any] | None = None
1769
+ current_task: dict[str, Any] | None = None
1770
+
1771
+ def ensure_module(title: str | None = None) -> dict[str, Any]:
1772
+ nonlocal current_module, current_task
1773
+ if current_module is not None and title is None:
1774
+ return current_module
1775
+ module_title = (title or default_module_title).strip() or default_module_title
1776
+ if current_module and current_module["title"] == module_title:
1777
+ return current_module
1778
+ current_module = {
1779
+ "id": f"module-{len(modules) + 1}",
1780
+ "title": module_title,
1781
+ "tasks": [],
1782
+ }
1783
+ modules.append(current_module)
1784
+ current_task = None
1785
+ return current_module
1786
+
1787
+ for raw_line in todo_text.splitlines():
1788
+ stripped = raw_line.strip()
1789
+ if not stripped:
1790
+ continue
1791
+
1792
+ heading = re.match(r"^(#+)\s*(.+?)\s*$", stripped)
1793
+ if heading:
1794
+ level = len(heading.group(1))
1795
+ title = heading.group(2).strip()
1796
+ lowered = title.lower()
1797
+ if level == 1:
1798
+ current_task = None
1799
+ if is_constraint_section(lowered):
1800
+ current_section = "constraints"
1801
+ current_module = None
1802
+ elif is_task_section(lowered):
1803
+ current_section = "tasks"
1804
+ current_module = None
1805
+ else:
1806
+ current_section = lowered
1807
+ current_module = None
1808
+ continue
1809
+ if current_section == "tasks":
1810
+ ensure_module(title)
1811
+ continue
1812
+
1813
+ if current_section == "constraints":
1814
+ normalized = normalize_todo_text_item(stripped)
1815
+ if normalized:
1816
+ constraints.append(normalized)
1817
+ continue
1818
+
1819
+ in_task_area = current_section == "tasks" or current_section == "__root__"
1820
+ if not in_task_area:
1821
+ continue
1822
+
1823
+ checkbox = re.match(r"^[-*]\s+\[( |x|X)\]\s*(.+)$", stripped)
1824
+ if checkbox:
1825
+ module = ensure_module()
1826
+ completed = checkbox.group(1).lower() == "x"
1827
+ title = normalize_todo_text_item(checkbox.group(2))
1828
+ current_task = {
1829
+ "title": title,
1830
+ "details": [],
1831
+ "completed": completed,
1832
+ }
1833
+ module["tasks"].append(current_task)
1834
+ continue
1835
+
1836
+ bullet = re.match(r"^[-*]\s+(.+)$", stripped)
1837
+ if bullet:
1838
+ module = ensure_module()
1839
+ title = normalize_todo_text_item(bullet.group(1))
1840
+ current_task = {
1841
+ "title": title,
1842
+ "details": [],
1843
+ "completed": False,
1844
+ }
1845
+ module["tasks"].append(current_task)
1846
+ continue
1847
+
1848
+ detail = normalize_todo_text_item(stripped)
1849
+ if not detail:
1850
+ continue
1851
+ if current_task is None:
1852
+ module = ensure_module()
1853
+ current_task = {
1854
+ "title": detail,
1855
+ "details": [],
1856
+ "completed": False,
1857
+ }
1858
+ module["tasks"].append(current_task)
1859
+ continue
1860
+ current_task["details"].append(detail)
1861
+
1862
+ normalized_modules: list[dict[str, Any]] = []
1863
+ task_index = 1
1864
+ for module_index, module in enumerate(modules, start=1):
1865
+ normalized_tasks: list[dict[str, Any]] = []
1866
+ for task in module.get("tasks", []):
1867
+ title = str(task.get("title", "")).strip()
1868
+ if not title:
1869
+ continue
1870
+ details = [str(item).strip() for item in task.get("details", []) if str(item).strip()]
1871
+ normalized_tasks.append(
1872
+ {
1873
+ "id": f"task-{task_index}",
1874
+ "title": title,
1875
+ "details": details,
1876
+ "completed": bool(task.get("completed", False)),
1877
+ }
1878
+ )
1879
+ task_index += 1
1880
+ if normalized_tasks:
1881
+ normalized_modules.append(
1882
+ {
1883
+ "id": f"module-{module_index}",
1884
+ "title": str(module.get("title", default_module_title)).strip() or default_module_title,
1885
+ "tasks": normalized_tasks,
1886
+ }
1887
+ )
1888
+
1889
+ pending_modules: list[dict[str, Any]] = []
1890
+ completed_modules: list[dict[str, Any]] = []
1891
+ for module in normalized_modules:
1892
+ pending_tasks = [task for task in module["tasks"] if not task["completed"]]
1893
+ completed_tasks = [task for task in module["tasks"] if task["completed"]]
1894
+ if pending_tasks:
1895
+ pending_modules.append(
1896
+ {
1897
+ "id": module["id"],
1898
+ "title": module["title"],
1899
+ "tasks": pending_tasks,
1900
+ }
1901
+ )
1902
+ if completed_tasks:
1903
+ completed_modules.append(
1904
+ {
1905
+ "id": module["id"],
1906
+ "title": module["title"],
1907
+ "tasks": completed_tasks,
1908
+ }
1909
+ )
1910
+
1911
+ pending_tasks = [task for module in pending_modules for task in module["tasks"]]
1912
+ completed_tasks = [task for module in completed_modules for task in module["tasks"]]
1913
+ return {
1914
+ "constraints": unique(constraints),
1915
+ "modules": pending_modules,
1916
+ "completed_modules": completed_modules,
1917
+ "tasks": pending_tasks,
1918
+ "completed_tasks": completed_tasks,
1919
+ "stats": {
1920
+ "pending_task_count": len(pending_tasks),
1921
+ "completed_task_count": len(completed_tasks),
1922
+ "total_task_count": len(pending_tasks) + len(completed_tasks),
1923
+ },
1924
+ }
1925
+
1926
+
1927
+ def parse_task_blocks(todo_text: str) -> list[dict[str, Any]]:
1928
+ document = parse_todo_document(todo_text)
1929
+ return [
1930
+ {
1931
+ "id": task["id"],
1932
+ "title": task["title"],
1933
+ "details": task["details"],
1934
+ }
1935
+ for task in document["tasks"]
1936
+ ]
1937
+
1938
+
1939
+ def parse_task_modules(todo_text: str) -> list[dict[str, Any]]:
1940
+ document = parse_todo_document(todo_text)
1941
+ return [
1942
+ {
1943
+ "id": module["id"],
1944
+ "title": module["title"],
1945
+ "tasks": [
1946
+ {
1947
+ "id": task["id"],
1948
+ "title": task["title"],
1949
+ "details": task["details"],
1950
+ }
1951
+ for task in module["tasks"]
1952
+ ],
1953
+ }
1954
+ for module in document["modules"]
1955
+ ]
1956
+
1957
+
1958
+ def todo_progress(todo_text: str) -> dict[str, int]:
1959
+ document = parse_todo_document(todo_text)
1960
+ return {
1961
+ "pending_task_count": int(document["stats"]["pending_task_count"]),
1962
+ "completed_task_count": int(document["stats"]["completed_task_count"]),
1963
+ "total_task_count": int(document["stats"]["total_task_count"]),
1964
+ }
1965
+
1966
+
1967
+ def todo_items(todo_text: str) -> list[str]:
1968
+ tasks = parse_task_blocks(todo_text)
1969
+ items: list[str] = []
1970
+ for task in tasks:
1971
+ items.append(task["title"])
1972
+ items.extend(task.get("details", []))
1973
+ return unique(items)
1974
+
1975
+
1976
+ def parse_todo_sections(todo_text: str) -> dict[str, list[str]]:
1977
+ sections: dict[str, list[str]] = {"__root__": []}
1978
+ current = "__root__"
1979
+ for raw_line in todo_text.splitlines():
1980
+ stripped = raw_line.strip()
1981
+ if not stripped:
1982
+ continue
1983
+ heading = re.match(r"^#+\s*(.+?)\s*$", stripped)
1984
+ if heading:
1985
+ current = heading.group(1).strip().lower()
1986
+ sections.setdefault(current, [])
1987
+ continue
1988
+ sections.setdefault(current, []).append(stripped)
1989
+ return sections
1990
+
1991
+
1992
+ def is_task_section(title: str) -> bool:
1993
+ return any(keyword in title for keyword in ("待办", "todo", "tasks", "task"))
1994
+
1995
+
1996
+ def is_constraint_section(title: str) -> bool:
1997
+ return any(keyword in title for keyword in ("限制条件", "约束", "constraints", "constraint"))
1998
+
1999
+
2000
+ def constraint_items(todo_text: str) -> list[str]:
2001
+ return parse_todo_document(todo_text)["constraints"]
2002
+
2003
+
2004
+ def _extract_service_hints_from_lines(lines: list[str]) -> list[str]:
2005
+ hints: list[str] = []
2006
+ patterns = [
2007
+ r"(?:修改的服务|目标服务|服务名|服务)\s*(?:是|为|:|:)\s*([A-Za-z0-9._-]+)",
2008
+ r"(?:修改的服务|目标服务|服务名|服务)\s*(?:包括|包含|有|涉及)\s*([A-Za-z0-9._,\-、,\s]+)",
2009
+ r"(?:仓库|项目)\s*(?:是|为|:|:)\s*([A-Za-z0-9._-]+)",
2010
+ r"(?:仓库|项目)\s*(?:包括|包含|有|涉及)\s*([A-Za-z0-9._,\-、,\s]+)",
2011
+ ]
2012
+ for line in lines:
2013
+ stripped = line.strip()
2014
+ if not stripped:
2015
+ continue
2016
+ for pattern in patterns:
2017
+ match = re.search(pattern, stripped, flags=re.IGNORECASE)
2018
+ if not match:
2019
+ continue
2020
+ raw_value = match.group(1).strip()
2021
+ for part in re.split(r"[、,,\s]+", raw_value):
2022
+ normalized = part.strip()
2023
+ if normalized:
2024
+ hints.append(normalized)
2025
+ return unique(hints)
2026
+
2027
+
2028
+ def service_hints(todo_text: str) -> list[str]:
2029
+ candidate_lines = constraint_items(todo_text)
2030
+ hints = _extract_service_hints_from_lines(candidate_lines)
2031
+ if not hints:
2032
+ candidate_lines = [line.strip() for line in todo_text.splitlines() if line.strip()]
2033
+ hints = _extract_service_hints_from_lines(candidate_lines)
2034
+ return unique(hints)
2035
+
2036
+
2037
+ def summarize_todo(todo_text: str) -> str:
2038
+ tasks = parse_task_blocks(todo_text)
2039
+ if not tasks:
2040
+ return "请在 todo 文件中补充待办需求。"
2041
+ return ";".join(task["title"] for task in tasks[:3])
2042
+
2043
+
2044
+ def extract_todo_keywords(todo_text: str, limit: int = 24) -> list[str]:
2045
+ keywords: list[str] = []
2046
+ candidates = todo_items(todo_text) + constraint_items(todo_text)
2047
+ token_pattern = re.compile(r"[A-Za-z_][A-Za-z0-9_./:-]{2,}|[\u4e00-\u9fff]{2,}")
2048
+ stopwords = {
2049
+ "todo",
2050
+ "task",
2051
+ "tasks",
2052
+ "null",
2053
+ "true",
2054
+ "false",
2055
+ "修改",
2056
+ "增加",
2057
+ "新增",
2058
+ "需要",
2059
+ "接口",
2060
+ "字段",
2061
+ "测试",
2062
+ "服务",
2063
+ "模块",
2064
+ "待办",
2065
+ "限制条件",
2066
+ }
2067
+ for candidate in candidates:
2068
+ for token in token_pattern.findall(str(candidate)):
2069
+ normalized = token.strip("`'\",。、;::()()[]【】")
2070
+ if len(normalized) < 2:
2071
+ continue
2072
+ if normalized.lower() in stopwords or normalized in stopwords:
2073
+ continue
2074
+ keywords.append(normalized)
2075
+ return unique(keywords)[:limit]
2076
+
2077
+
2078
+ def relative_to(path: str | Path, root: Path) -> str:
2079
+ candidate = Path(str(path)).resolve()
2080
+ try:
2081
+ return str(candidate.relative_to(root.resolve()))
2082
+ except ValueError:
2083
+ return str(candidate)
2084
+
2085
+
2086
+ def existing_reference_files(config: dict[str, Any]) -> list[str]:
2087
+ files: list[str] = []
2088
+ for item in config.get("reference_files", []):
2089
+ path = Path(str(item))
2090
+ if path.exists():
2091
+ files.append(str(path.resolve()))
2092
+ files.extend(openspec_reference_files(config))
2093
+ return unique(files)
2094
+
2095
+
2096
+ def find_candidate_codebases(root: Path, max_depth: int = 3) -> list[Path]:
2097
+ candidates: list[Path] = []
2098
+ if looks_like_project_root(root):
2099
+ candidates.append(root.resolve())
2100
+
2101
+ def walk(path: Path, depth: int) -> None:
2102
+ if depth > max_depth:
2103
+ return
2104
+ try:
2105
+ children = sorted([child for child in path.iterdir() if child.is_dir()])
2106
+ except OSError:
2107
+ return
2108
+ for child in children:
2109
+ if child.name.startswith("."):
2110
+ continue
2111
+ if looks_like_project_root(child):
2112
+ candidates.append(child.resolve())
2113
+ continue
2114
+ walk(child, depth + 1)
2115
+
2116
+ walk(root, 1)
2117
+ ordered: list[Path] = []
2118
+ seen: set[str] = set()
2119
+ for item in candidates:
2120
+ key = str(item.resolve())
2121
+ if key not in seen:
2122
+ seen.add(key)
2123
+ ordered.append(item.resolve())
2124
+ return ordered
2125
+
2126
+
2127
+ def _match_score(candidate: Path, hint: str) -> int:
2128
+ candidate_name = candidate.name.lower()
2129
+ candidate_path = str(candidate).lower()
2130
+ normalized_hint = hint.strip().lower()
2131
+ if not normalized_hint:
2132
+ return 0
2133
+ if candidate_name == normalized_hint:
2134
+ return 100
2135
+ if candidate_name.replace("_", "-") == normalized_hint.replace("_", "-"):
2136
+ return 95
2137
+ if normalized_hint in candidate_name:
2138
+ return 85
2139
+ if f"/{normalized_hint}/" in candidate_path:
2140
+ return 75
2141
+ return 0
2142
+
2143
+
2144
+ def resolve_target_codebases(config: dict[str, Any], todo_text: str | None = None) -> tuple[list[Path], dict[str, Any]]:
2145
+ root = code_root(config)
2146
+ todo_text = todo_text if todo_text is not None else read_text(todo_path(config))
2147
+ hints = service_hints(todo_text)
2148
+
2149
+ if looks_like_project_root(root):
2150
+ return [root], {
2151
+ "configured_code_path": str(root),
2152
+ "resolved_code_paths": [str(root)],
2153
+ "service_hints": hints,
2154
+ "selection_reason": "code_path 本身就是可识别的项目根目录,按单仓模式处理。",
2155
+ "candidate_codebases": [str(root)],
2156
+ }
2157
+
2158
+ candidates = find_candidate_codebases(root)
2159
+ if not candidates:
2160
+ raise ValueError(f"code_path 下未找到可识别的项目目录:{root}")
2161
+ if not hints:
2162
+ lowered_todo = todo_text.lower()
2163
+ for candidate in candidates:
2164
+ candidate_name = candidate.name.strip()
2165
+ if candidate_name and candidate_name.lower() in lowered_todo:
2166
+ hints.append(candidate_name)
2167
+ hints = unique(hints)
2168
+
2169
+ matched_candidates: list[Path] = []
2170
+ matched_pairs: list[dict[str, str]] = []
2171
+ for candidate in candidates:
2172
+ local_best_hint = ""
2173
+ local_best_score = 0
2174
+ for hint in hints:
2175
+ score = _match_score(candidate, hint)
2176
+ if score > local_best_score:
2177
+ local_best_score = score
2178
+ local_best_hint = hint
2179
+ if local_best_score > 0:
2180
+ matched_candidates.append(candidate.resolve())
2181
+ matched_pairs.append(
2182
+ {
2183
+ "service_hint": local_best_hint,
2184
+ "resolved_code_path": str(candidate.resolve()),
2185
+ }
2186
+ )
2187
+
2188
+ if matched_candidates:
2189
+ return matched_candidates, {
2190
+ "configured_code_path": str(root),
2191
+ "resolved_code_paths": [str(item.resolve()) for item in matched_candidates],
2192
+ "service_hints": hints,
2193
+ "matched_services": matched_pairs,
2194
+ "selection_reason": "根据 todo 中识别到的服务标识,匹配到一个或多个目标项目目录。",
2195
+ "candidate_codebases": [str(item) for item in candidates],
2196
+ }
2197
+
2198
+ if len(candidates) == 1:
2199
+ return [candidates[0].resolve()], {
2200
+ "configured_code_path": str(root),
2201
+ "resolved_code_paths": [str(candidates[0].resolve())],
2202
+ "service_hints": hints,
2203
+ "selection_reason": "code_path 下只发现一个可识别的项目目录,已自动使用该目录。",
2204
+ "candidate_codebases": [str(item) for item in candidates],
2205
+ }
2206
+
2207
+ candidate_names = ", ".join(item.name for item in candidates[:10])
2208
+ raise ValueError(
2209
+ "code_path 下发现多个项目目录,但无法根据 todo 判断目标服务。"
2210
+ f" 请在 todo 中明确写出服务名,例如“修改的服务是 xxx”。候选目录:{candidate_names}"
2211
+ )
2212
+
2213
+
2214
+ def resolve_target_codebase(config: dict[str, Any], todo_text: str | None = None) -> tuple[Path, dict[str, Any]]:
2215
+ codebases, resolution = resolve_target_codebases(config, todo_text)
2216
+ return codebases[0], resolution
2217
+
2218
+
2219
+ def planned_codebases(config: dict[str, Any], session_meta: dict[str, Any] | None = None) -> list[Path]:
2220
+ meta = session_meta or current_session_meta(config)
2221
+ plan = read_json(data_artifact_path(config, "plan.json", meta), {})
2222
+ paths = plan.get("resolved_code_paths", [])
2223
+ if isinstance(paths, list) and paths:
2224
+ return [Path(str(item)).resolve() for item in paths if str(item).strip()]
2225
+ path = str(plan.get("resolved_code_path", "")).strip()
2226
+ if path:
2227
+ return [Path(path).resolve()]
2228
+ return [code_root(config)]
2229
+
2230
+
2231
+ def planned_codebase(config: dict[str, Any], session_meta: dict[str, Any] | None = None) -> Path:
2232
+ return planned_codebases(config, session_meta)[0]
2233
+
2234
+
2235
+ def _read_json_file(path: Path) -> dict[str, Any]:
2236
+ if not path.exists():
2237
+ return {}
2238
+ try:
2239
+ parsed = json.loads(path.read_text(encoding="utf-8"))
2240
+ except (OSError, json.JSONDecodeError):
2241
+ return {}
2242
+ return parsed if isinstance(parsed, dict) else {}
2243
+
2244
+
2245
+ def _read_file_lower(path: Path) -> str:
2246
+ if not path.exists():
2247
+ return ""
2248
+ try:
2249
+ return path.read_text(encoding="utf-8", errors="ignore").lower()
2250
+ except OSError:
2251
+ return ""
2252
+
2253
+
2254
+ def _npm_run_command(manager: str, script: str) -> str:
2255
+ if manager == "npm":
2256
+ return f"npm run {script}" if script not in ("test", "start") else f"npm {script}"
2257
+ if manager == "yarn":
2258
+ return f"yarn {script}"
2259
+ if manager == "bun":
2260
+ return f"bun run {script}"
2261
+ return f"{manager} {script}"
2262
+
2263
+
2264
+ def _node_package_manager(codebase: Path) -> str:
2265
+ if (codebase / "pnpm-lock.yaml").exists():
2266
+ return "pnpm"
2267
+ if (codebase / "yarn.lock").exists():
2268
+ return "yarn"
2269
+ if (codebase / "bun.lockb").exists() or (codebase / "bun.lock").exists():
2270
+ return "bun"
2271
+ return "npm"
2272
+
2273
+
2274
+ def _node_language_and_tool(codebase: Path, package_json: dict[str, Any], manager: str) -> tuple[str, str]:
2275
+ deps: dict[str, Any] = {}
2276
+ for key in ("dependencies", "devDependencies", "peerDependencies"):
2277
+ value = package_json.get(key, {})
2278
+ if isinstance(value, dict):
2279
+ deps.update(value)
2280
+ has_typescript = (
2281
+ "typescript" in deps
2282
+ or (codebase / "tsconfig.json").exists()
2283
+ or any(codebase.glob("src/**/*.ts"))
2284
+ or any(codebase.glob("src/**/*.tsx"))
2285
+ )
2286
+ framework = ""
2287
+ for name in ("vue", "react", "next", "nuxt", "svelte", "angular"):
2288
+ if name in deps:
2289
+ framework = name
2290
+ break
2291
+ language = "typescript" if has_typescript else "javascript"
2292
+ build_tool = f"{manager}/{framework}" if framework else manager
2293
+ return language, build_tool
2294
+
2295
+
2296
+ def _node_commands(codebase: Path, package_json: dict[str, Any], manager: str) -> tuple[str, str, str]:
2297
+ scripts = package_json.get("scripts", {})
2298
+ scripts = scripts if isinstance(scripts, dict) else {}
2299
+ script_names = {str(key): str(value) for key, value in scripts.items()}
2300
+
2301
+ def has_real_script(name: str) -> bool:
2302
+ value = script_names.get(name, "").lower()
2303
+ if not value:
2304
+ return False
2305
+ return "no test specified" not in value and "exit 1" not in value
2306
+
2307
+ test_command = ""
2308
+ for name in ("test", "test:unit", "unit", "vitest", "jest"):
2309
+ if has_real_script(name):
2310
+ test_command = _npm_run_command(manager, name)
2311
+ break
2312
+ build_command = _npm_run_command(manager, "build") if has_real_script("build") else ""
2313
+ lint_command = _npm_run_command(manager, "lint") if has_real_script("lint") else ""
2314
+ start_command = ""
2315
+ for name in ("dev", "start", "serve"):
2316
+ if has_real_script(name):
2317
+ start_command = _npm_run_command(manager, name)
2318
+ break
2319
+ if test_command and build_command:
2320
+ verify_command = f"{test_command} && {build_command}"
2321
+ else:
2322
+ verify_command = test_command or lint_command or build_command
2323
+ return test_command, start_command, verify_command
2324
+
2325
+
2326
+ def _python_commands(codebase: Path) -> tuple[str, str, str]:
2327
+ uses_uv = (codebase / "uv.lock").exists()
2328
+ uses_poetry = (codebase / "poetry.lock").exists() or "tool.poetry" in _read_file_lower(codebase / "pyproject.toml")
2329
+ has_pytest = (
2330
+ (codebase / "pytest.ini").exists()
2331
+ or (codebase / "conftest.py").exists()
2332
+ or "pytest" in _read_file_lower(codebase / "pyproject.toml")
2333
+ or "pytest" in _read_file_lower(codebase / "requirements.txt")
2334
+ or (codebase / "tests").exists()
2335
+ )
2336
+ prefix = "uv run " if uses_uv else "poetry run " if uses_poetry else ""
2337
+ if has_pytest:
2338
+ test_command = f"{prefix}python -m pytest"
2339
+ else:
2340
+ test_command = f"{prefix}python -m unittest discover"
2341
+ start_command = ""
2342
+ if (codebase / "manage.py").exists():
2343
+ start_command = f"{prefix}python manage.py runserver"
2344
+ elif (codebase / "app.py").exists():
2345
+ start_command = f"{prefix}python app.py"
2346
+ return test_command, start_command, test_command
2347
+
2348
+
2349
+ def _apply_verify_override(config: dict[str, Any] | None, codebase: Path, detected: dict[str, str]) -> dict[str, str]:
2350
+ if not config:
2351
+ return detected
2352
+ commands = config.get("verify_commands", {})
2353
+ if not isinstance(commands, dict) or not commands:
2354
+ return detected
2355
+ candidates = [
2356
+ str(codebase.resolve()),
2357
+ str(codebase),
2358
+ codebase.name,
2359
+ "default",
2360
+ ]
2361
+ for candidate in candidates:
2362
+ command = str(commands.get(candidate, "")).strip()
2363
+ if command:
2364
+ overridden = dict(detected)
2365
+ overridden["verify_command"] = command
2366
+ if not overridden.get("test_command"):
2367
+ overridden["test_command"] = command
2368
+ return overridden
2369
+ return detected
2370
+
2371
+
2372
+ def load_project_adapters() -> list[dict[str, Any]]:
2373
+ adapters_dir = skill_root() / "adapters"
2374
+ if not adapters_dir.exists():
2375
+ return []
2376
+ adapters: list[dict[str, Any]] = []
2377
+ for path in sorted(adapters_dir.glob("*.yml")):
2378
+ try:
2379
+ adapter = parse_simple_yaml(path.read_text(encoding="utf-8"))
2380
+ except Exception:
2381
+ continue
2382
+ if not isinstance(adapter, dict):
2383
+ continue
2384
+ adapter["__path"] = str(path)
2385
+ adapters.append(adapter)
2386
+ return adapters
2387
+
2388
+
2389
+ def adapter_matches(codebase: Path, adapter: dict[str, Any]) -> bool:
2390
+ detect_files = adapter.get("detect_files", [])
2391
+ if isinstance(detect_files, str):
2392
+ detect_files = [detect_files]
2393
+ if not isinstance(detect_files, list) or not detect_files:
2394
+ return False
2395
+ return any((codebase / str(item)).exists() for item in detect_files if str(item).strip())
2396
+
2397
+
2398
+ def adapter_detection(codebase: Path) -> dict[str, str]:
2399
+ for adapter in load_project_adapters():
2400
+ if not adapter_matches(codebase, adapter):
2401
+ continue
2402
+ return {
2403
+ "adapter_id": str(adapter.get("id", "")).strip(),
2404
+ "language": str(adapter.get("language", "")).strip(),
2405
+ "build_tool": str(adapter.get("build_tool", "")).strip(),
2406
+ "test_command": str(adapter.get("test_command", "")).strip(),
2407
+ "start_command": str(adapter.get("start_command", "")).strip(),
2408
+ "verify_command": str(adapter.get("verify_command", "")).strip(),
2409
+ "review_profile": str(adapter.get("review_profile", "")).strip(),
2410
+ }
2411
+ return {}
2412
+
2413
+
2414
+ def detect_project(codebase: Path, config: dict[str, Any] | None = None) -> dict[str, str]:
2415
+ adapter = adapter_detection(codebase)
2416
+ has_maven = (codebase / "pom.xml").exists() or (codebase / "mvnw").exists()
2417
+ has_gradle = (
2418
+ (codebase / "build.gradle").exists()
2419
+ or (codebase / "build.gradle.kts").exists()
2420
+ or (codebase / "gradlew").exists()
2421
+ )
2422
+ has_java = has_maven or has_gradle or (codebase / "src" / "main" / "java").exists()
2423
+ has_node = (codebase / "package.json").exists()
2424
+ has_go = (codebase / "go.mod").exists()
2425
+ has_python = any(
2426
+ (codebase / name).exists()
2427
+ for name in ("pyproject.toml", "requirements.txt", "setup.py", "setup.cfg", "Pipfile", "tox.ini")
2428
+ ) or (codebase / "tests").exists()
2429
+ has_rust = (codebase / "Cargo.toml").exists()
2430
+ has_dotnet = bool(list(codebase.glob("*.sln")) or list(codebase.glob("*.csproj")))
2431
+ has_php = (codebase / "composer.json").exists()
2432
+ has_ruby = (codebase / "Gemfile").exists() or (codebase / "Rakefile").exists()
2433
+ has_make = (codebase / "Makefile").exists() or (codebase / "makefile").exists()
2434
+ has_cmake = (codebase / "CMakeLists.txt").exists()
2435
+
2436
+ language = "java" if has_java else "unknown"
2437
+ build_tool = ""
2438
+ test_command = ""
2439
+ start_command = ""
2440
+ verify_command = ""
2441
+
2442
+ if has_maven:
2443
+ build_tool = "maven"
2444
+ test_command = "./mvnw test" if (codebase / "mvnw").exists() else "mvn test"
2445
+ start_command = "./mvnw spring-boot:run" if (codebase / "mvnw").exists() else "mvn spring-boot:run"
2446
+ verify_command = test_command
2447
+ elif has_gradle:
2448
+ build_tool = "gradle"
2449
+ test_command = "./gradlew test" if (codebase / "gradlew").exists() else "gradle test"
2450
+ start_command = "./gradlew bootRun" if (codebase / "gradlew").exists() else "gradle bootRun"
2451
+ verify_command = test_command
2452
+ elif has_node:
2453
+ package_json = _read_json_file(codebase / "package.json")
2454
+ manager = _node_package_manager(codebase)
2455
+ language, build_tool = _node_language_and_tool(codebase, package_json, manager)
2456
+ test_command, start_command, verify_command = _node_commands(codebase, package_json, manager)
2457
+ elif has_go:
2458
+ language = "go"
2459
+ build_tool = "go"
2460
+ test_command = "go test ./..."
2461
+ start_command = "go run ."
2462
+ verify_command = test_command
2463
+ elif has_python:
2464
+ language = "python"
2465
+ build_tool = "uv" if (codebase / "uv.lock").exists() else "poetry" if (codebase / "poetry.lock").exists() else "python"
2466
+ test_command, start_command, verify_command = _python_commands(codebase)
2467
+ elif has_rust:
2468
+ language = "rust"
2469
+ build_tool = "cargo"
2470
+ test_command = "cargo test"
2471
+ start_command = "cargo run"
2472
+ verify_command = test_command
2473
+ elif has_dotnet:
2474
+ language = "csharp"
2475
+ build_tool = "dotnet"
2476
+ test_command = "dotnet test"
2477
+ start_command = "dotnet run"
2478
+ verify_command = test_command
2479
+ elif has_php:
2480
+ language = "php"
2481
+ build_tool = "composer"
2482
+ composer_json = _read_json_file(codebase / "composer.json")
2483
+ scripts = composer_json.get("scripts", {}) if isinstance(composer_json, dict) else {}
2484
+ if isinstance(scripts, dict) and scripts.get("test"):
2485
+ test_command = "composer test"
2486
+ elif (codebase / "vendor" / "bin" / "phpunit").exists() or (codebase / "phpunit.xml").exists():
2487
+ test_command = "vendor/bin/phpunit"
2488
+ start_command = "php -S localhost:8000 -t public" if (codebase / "public").exists() else ""
2489
+ verify_command = test_command
2490
+ elif has_ruby:
2491
+ language = "ruby"
2492
+ build_tool = "bundler"
2493
+ if (codebase / "spec").exists():
2494
+ test_command = "bundle exec rspec"
2495
+ elif (codebase / "test").exists():
2496
+ test_command = "bundle exec rake test"
2497
+ start_command = "bundle exec rails server" if (codebase / "config" / "application.rb").exists() else ""
2498
+ verify_command = test_command
2499
+ elif has_make:
2500
+ language = "native"
2501
+ build_tool = "make"
2502
+ makefile = _read_file_lower(codebase / "Makefile") or _read_file_lower(codebase / "makefile")
2503
+ test_command = "make test" if re.search(r"^test\s*:", makefile, flags=re.MULTILINE) else ""
2504
+ verify_command = test_command or "make"
2505
+ elif has_cmake:
2506
+ language = "cpp"
2507
+ build_tool = "cmake"
2508
+ test_command = "ctest --test-dir build" if (codebase / "build").exists() else ""
2509
+ verify_command = test_command
2510
+
2511
+ detected = {
2512
+ "adapter_id": adapter.get("adapter_id", ""),
2513
+ "language": language,
2514
+ "build_tool": build_tool,
2515
+ "test_command": test_command,
2516
+ "start_command": start_command,
2517
+ "verify_command": verify_command,
2518
+ "review_profile": adapter.get("review_profile", ""),
2519
+ }
2520
+ for key in ("language", "build_tool", "test_command", "start_command", "verify_command"):
2521
+ if not detected.get(key) and adapter.get(key):
2522
+ detected[key] = adapter[key]
2523
+ return _apply_verify_override(config, codebase, detected)
2524
+
2525
+
2526
+ def summarize_detected_projects(projects: list[dict[str, str]]) -> dict[str, str]:
2527
+ if not projects:
2528
+ return {
2529
+ "adapter_id": "",
2530
+ "language": "",
2531
+ "build_tool": "",
2532
+ "test_command": "",
2533
+ "start_command": "",
2534
+ "verify_command": "",
2535
+ "review_profile": "",
2536
+ }
2537
+
2538
+ def summarize_field(name: str) -> str:
2539
+ values = unique([item.get(name, "") for item in projects if item.get(name, "")])
2540
+ if not values:
2541
+ return ""
2542
+ if len(values) == 1:
2543
+ return values[0]
2544
+ return "multiple"
2545
+
2546
+ return {
2547
+ "adapter_id": summarize_field("adapter_id"),
2548
+ "language": summarize_field("language"),
2549
+ "build_tool": summarize_field("build_tool"),
2550
+ "test_command": summarize_field("test_command"),
2551
+ "start_command": summarize_field("start_command"),
2552
+ "verify_command": summarize_field("verify_command"),
2553
+ "review_profile": summarize_field("review_profile"),
2554
+ }
2555
+
2556
+
2557
+ def default_status(mode: str) -> dict[str, Any]:
2558
+ return {
2559
+ "mode": mode,
2560
+ "session_id": "",
2561
+ "data_dir": "",
2562
+ "report_dir": "",
2563
+ "phase": "context",
2564
+ "current_task": "等待开始工作流。",
2565
+ "progress": 0,
2566
+ "awaiting_confirmation": False,
2567
+ "pending_confirmation_for": "",
2568
+ "next_action": "读取 todo 文件并生成计划。",
2569
+ "completed_tasks": [],
2570
+ "blocked_tasks": [],
2571
+ "started_at": "",
2572
+ "finished_at": "",
2573
+ "duration_seconds": 0,
2574
+ "notification_status": "pending",
2575
+ "notification_message": "",
2576
+ "updated_at": now_iso(),
2577
+ }
2578
+
2579
+
2580
+ def ensure_status(config: dict[str, Any], session_meta: dict[str, Any], status: dict[str, Any] | None = None) -> dict[str, Any]:
2581
+ merged = default_status(config["mode"])
2582
+ if status:
2583
+ merged.update(status)
2584
+ merged["mode"] = config["mode"]
2585
+ merged["session_id"] = session_meta["session_id"]
2586
+ merged["data_dir"] = session_meta["data_dir"]
2587
+ merged["report_dir"] = session_meta["report_dir"]
2588
+ merged["started_at"] = (
2589
+ str(merged.get("started_at", "")).strip()
2590
+ or str(session_meta.get("started_at", "")).strip()
2591
+ or str(session_meta.get("created_at", "")).strip()
2592
+ or now_iso()
2593
+ )
2594
+ merged["finished_at"] = str(merged.get("finished_at", "")).strip()
2595
+ try:
2596
+ merged["duration_seconds"] = float(merged.get("duration_seconds", 0) or 0)
2597
+ except (TypeError, ValueError):
2598
+ merged["duration_seconds"] = 0.0
2599
+ merged["notification_status"] = str(merged.get("notification_status", "pending") or "pending")
2600
+ merged["notification_message"] = str(merged.get("notification_message", "") or "")
2601
+ return merged
2602
+
2603
+
2604
+ def workflow_duration_seconds(
2605
+ session_meta: dict[str, Any],
2606
+ status: dict[str, Any] | None = None,
2607
+ finished_at: str | None = None,
2608
+ ) -> float:
2609
+ start_text = (
2610
+ str((status or {}).get("started_at", "")).strip()
2611
+ or str(session_meta.get("started_at", "")).strip()
2612
+ or str(session_meta.get("created_at", "")).strip()
2613
+ )
2614
+ end_text = str(finished_at or (status or {}).get("finished_at", "")).strip() or now_iso()
2615
+ started = parse_iso_datetime(start_text)
2616
+ ended = parse_iso_datetime(end_text)
2617
+ if started is None or ended is None:
2618
+ return 0.0
2619
+ return max(0.0, (ended - started).total_seconds())
2620
+
2621
+
2622
+ def pushplus_config(config: dict[str, Any]) -> dict[str, Any]:
2623
+ notification = config.get("notification", {})
2624
+ if not isinstance(notification, dict):
2625
+ return {
2626
+ "token": "",
2627
+ "routes": [],
2628
+ }
2629
+ pushplus = notification.get("pushplus", {})
2630
+ if not isinstance(pushplus, dict):
2631
+ return {
2632
+ "token": "",
2633
+ "routes": [],
2634
+ }
2635
+
2636
+ ordinary = pushplus.get("ordinary", {})
2637
+ if not isinstance(ordinary, dict):
2638
+ ordinary = {}
2639
+ return {
2640
+ "token": str(pushplus.get("token", "")).strip(),
2641
+ "routes": [
2642
+ {
2643
+ "name": "ordinary",
2644
+ "enabled": bool(ordinary.get("enabled", False)),
2645
+ "channel": str(ordinary.get("channel", "wechat")).strip() or "wechat",
2646
+ "template": str(ordinary.get("template", "markdown")).strip() or "markdown",
2647
+ },
2648
+ ],
2649
+ }
2650
+
2651
+
2652
+ def feishu_config(config: dict[str, Any]) -> dict[str, Any]:
2653
+ notification = config.get("notification", {})
2654
+ if not isinstance(notification, dict):
2655
+ return {
2656
+ "enabled": False,
2657
+ "webhook_url": "",
2658
+ "secret": "",
2659
+ }
2660
+ feishu = notification.get("feishu", {})
2661
+ if not isinstance(feishu, dict):
2662
+ return {
2663
+ "enabled": False,
2664
+ "webhook_url": "",
2665
+ "secret": "",
2666
+ }
2667
+ return {
2668
+ "enabled": bool(feishu.get("enabled", False)),
2669
+ "webhook_url": str(feishu.get("webhook_url", "")).strip(),
2670
+ "secret": str(feishu.get("secret", "")).strip(),
2671
+ }
2672
+
2673
+
2674
+ def pushplus_api_url() -> str:
2675
+ return str(os.environ.get("SUPER_ENGINEER_PUSHPLUS_URL", "https://www.pushplus.plus/send")).strip()
2676
+
2677
+
2678
+ def pushplus_request_url(token: str) -> str:
2679
+ base = pushplus_api_url().rstrip("/")
2680
+ return f"{base}/{token}" if token else base
2681
+
2682
+
2683
+ def _normalize_pushplus_response(status_code: int, body: str, route: dict[str, Any], sender: str) -> dict[str, Any]:
2684
+ try:
2685
+ response_payload = json.loads(body) if body.strip() else {}
2686
+ except json.JSONDecodeError:
2687
+ response_payload = {"raw": body}
2688
+ response_code = response_payload.get("code")
2689
+ success = status_code == 200 and response_code in (None, 0, 200, "0", "200")
2690
+ return {
2691
+ "route": route.get("name", ""),
2692
+ "channel": route.get("channel", ""),
2693
+ "template": route.get("template", ""),
2694
+ "sender": sender,
2695
+ "status": "sent" if success else "failed",
2696
+ "success": success,
2697
+ "message": str(response_payload.get("msg") or ("发送成功" if success else "发送失败")),
2698
+ "response": response_payload,
2699
+ }
2700
+
2701
+
2702
+ def send_pushplus_notification_python(payload: dict[str, Any], route: dict[str, Any]) -> dict[str, Any]:
2703
+ request = urllib_request.Request(
2704
+ pushplus_api_url(),
2705
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
2706
+ headers={"Content-Type": "application/json"},
2707
+ method="POST",
2708
+ )
2709
+ try:
2710
+ with urllib_request.urlopen(request, timeout=15) as response:
2711
+ body = response.read().decode("utf-8", errors="replace")
2712
+ status_code = getattr(response, "status", 200)
2713
+ except urllib_error.HTTPError as error:
2714
+ body = error.read().decode("utf-8", errors="replace")
2715
+ result = _normalize_pushplus_response(error.code, body, route, "python")
2716
+ result["message"] = str(result.get("message") or f"PushPlus HTTP {error.code}")
2717
+ return result
2718
+ except urllib_error.URLError as error:
2719
+ return {
2720
+ "route": route.get("name", ""),
2721
+ "channel": route.get("channel", ""),
2722
+ "template": route.get("template", ""),
2723
+ "sender": "python",
2724
+ "status": "failed",
2725
+ "success": False,
2726
+ "message": f"PushPlus 请求失败:{error.reason}",
2727
+ "response": {},
2728
+ }
2729
+ return _normalize_pushplus_response(status_code, body, route, "python")
2730
+
2731
+
2732
+ def send_pushplus_notification_curl(payload: dict[str, Any], route: dict[str, Any]) -> dict[str, Any]:
2733
+ curl_path = shutil.which("curl")
2734
+ if not curl_path:
2735
+ return {
2736
+ "route": route.get("name", ""),
2737
+ "channel": route.get("channel", ""),
2738
+ "template": route.get("template", ""),
2739
+ "sender": "curl",
2740
+ "status": "failed",
2741
+ "success": False,
2742
+ "message": "系统中未找到 curl,无法执行回退发送。",
2743
+ "response": {},
2744
+ }
2745
+ result = subprocess.run(
2746
+ [
2747
+ curl_path,
2748
+ "-sS",
2749
+ pushplus_api_url(),
2750
+ "-H",
2751
+ "Content-Type: application/json",
2752
+ "-d",
2753
+ json.dumps(payload, ensure_ascii=False),
2754
+ ],
2755
+ capture_output=True,
2756
+ text=True,
2757
+ check=False,
2758
+ timeout=20,
2759
+ )
2760
+ if result.returncode != 0:
2761
+ return {
2762
+ "route": route.get("name", ""),
2763
+ "channel": route.get("channel", ""),
2764
+ "template": route.get("template", ""),
2765
+ "sender": "curl",
2766
+ "status": "failed",
2767
+ "success": False,
2768
+ "message": f"curl 回退发送失败:{result.stderr.strip() or result.stdout.strip() or result.returncode}",
2769
+ "response": {},
2770
+ }
2771
+ return _normalize_pushplus_response(200, result.stdout, route, "curl")
2772
+
2773
+
2774
+ def send_pushplus_notification(pushplus: dict[str, Any], route: dict[str, Any], title: str, content: str) -> dict[str, Any]:
2775
+ payload: dict[str, Any] = {
2776
+ "token": str(pushplus.get("token", "")).strip(),
2777
+ "title": title,
2778
+ "content": content,
2779
+ "template": route.get("template", "markdown"),
2780
+ "channel": route.get("channel", "wechat"),
2781
+ }
2782
+ python_result = send_pushplus_notification_python(payload, route)
2783
+ if python_result.get("success"):
2784
+ return python_result
2785
+ curl_result = send_pushplus_notification_curl(payload, route)
2786
+ if curl_result.get("success"):
2787
+ curl_result["message"] = f"{curl_result.get('message', '')}(Python失败后已回退curl成功)"
2788
+ curl_result["python_error"] = python_result.get("message", "")
2789
+ return curl_result
2790
+ curl_result["python_error"] = python_result.get("message", "")
2791
+ curl_result["message"] = (
2792
+ f"Python发送失败:{python_result.get('message', '')};"
2793
+ f"curl回退也失败:{curl_result.get('message', '')}"
2794
+ )
2795
+ return curl_result
2796
+
2797
+
2798
+ def _normalize_feishu_response(status_code: int, body: str, sender: str) -> dict[str, Any]:
2799
+ try:
2800
+ response_payload = json.loads(body) if body.strip() else {}
2801
+ except json.JSONDecodeError:
2802
+ response_payload = {"raw": body}
2803
+ response_code = response_payload.get("code")
2804
+ success = status_code == 200 and response_code in (None, 0, "0")
2805
+ return {
2806
+ "route": "feishu",
2807
+ "channel": "webhook",
2808
+ "template": "interactive",
2809
+ "sender": sender,
2810
+ "status": "sent" if success else "failed",
2811
+ "success": success,
2812
+ "message": str(response_payload.get("msg") or ("success" if success else "发送失败")),
2813
+ "response": response_payload,
2814
+ }
2815
+
2816
+
2817
+ def feishu_sign(secret: str, timestamp: str) -> str:
2818
+ key = f"{timestamp}\n{secret}".encode("utf-8")
2819
+ digest = hmac.new(key, b"", digestmod=hashlib.sha256).digest()
2820
+ return base64.b64encode(digest).decode("utf-8")
2821
+
2822
+
2823
+ def _workflow_notification_title(session_id: str, overall_result: str) -> str:
2824
+ return "super-engineer-workflow任务通知"
2825
+
2826
+
2827
+ def _workflow_notification_status_text(status: dict[str, Any], overall_result: str) -> str:
2828
+ current_task = str(status.get("current_task", "") or "暂无").strip()
2829
+ next_action = str(status.get("next_action", "") or "工作流已完成,请前往工作区查看。").strip()
2830
+ if overall_result == "通过":
2831
+ current_task = current_task.replace("✅", "").replace("❌", "").rstrip("。")
2832
+ return f"{current_task} ✅。{next_action}"
2833
+ current_task = current_task.replace("✅", "").replace("❌", "").rstrip("。")
2834
+ return f"{current_task} ❌。{next_action}"
2835
+
2836
+
2837
+ def workflow_notification_fingerprint(
2838
+ session_meta: dict[str, Any],
2839
+ status: dict[str, Any],
2840
+ overall_result: str,
2841
+ ) -> str:
2842
+ finished_at = str(status.get("finished_at", "") or "").strip()
2843
+ phase = str(status.get("phase", "") or "").strip()
2844
+ current_task = str(status.get("current_task", "") or "").strip()
2845
+ return "|".join(
2846
+ [
2847
+ str(session_meta.get("session_id", "")).strip(),
2848
+ overall_result.strip(),
2849
+ finished_at,
2850
+ phase,
2851
+ current_task,
2852
+ ]
2853
+ )
2854
+
2855
+
2856
+ def notification_has_sent_route(notification: dict[str, Any], route: str, template: str) -> bool:
2857
+ results = notification.get("results", [])
2858
+ if not isinstance(results, list):
2859
+ return False
2860
+ for item in results:
2861
+ if not isinstance(item, dict):
2862
+ continue
2863
+ if str(item.get("route", "")).strip() != route:
2864
+ continue
2865
+ if str(item.get("template", "")).strip() != template:
2866
+ continue
2867
+ if str(item.get("status", "")).strip() == "sent" and item.get("success", True) is not False:
2868
+ return True
2869
+ return False
2870
+
2871
+
2872
+ def is_standard_workflow_notification(
2873
+ config: dict[str, Any],
2874
+ session_meta: dict[str, Any],
2875
+ status: dict[str, Any],
2876
+ overall_result: str,
2877
+ notification: dict[str, Any],
2878
+ ) -> bool:
2879
+ if not isinstance(notification, dict):
2880
+ return False
2881
+ if str(notification.get("provider", "")).strip() != "notification":
2882
+ return False
2883
+ if str(notification.get("source", "")).strip() != "run-workflow.py verify":
2884
+ return False
2885
+ if str(notification.get("status", "")).strip() not in ("sent", "partial"):
2886
+ return False
2887
+ expected_fingerprint = workflow_notification_fingerprint(session_meta, status, overall_result)
2888
+ if str(notification.get("fingerprint", "")).strip() != expected_fingerprint:
2889
+ return False
2890
+ if feishu_config(config).get("enabled"):
2891
+ return notification_has_sent_route(notification, "feishu", "interactive")
2892
+ return str(notification.get("status", "")).strip() == "sent"
2893
+
2894
+
2895
+ def build_workflow_notification(
2896
+ config: dict[str, Any],
2897
+ session_meta: dict[str, Any],
2898
+ plan: dict[str, Any],
2899
+ status: dict[str, Any],
2900
+ overall_result: str,
2901
+ template: str = "markdown",
2902
+ ) -> tuple[str, str]:
2903
+ duration_seconds = workflow_duration_seconds(session_meta, status, status.get("finished_at", ""))
2904
+ progress = plan.get("todo_progress", {})
2905
+ targets = [str(item.get("name", "")).strip() for item in plan.get("target_codebases", []) if str(item.get("name", "")).strip()]
2906
+ target_text = "、".join(targets) if targets else "未识别"
2907
+ title = _workflow_notification_title(session_meta["session_id"], overall_result)
2908
+ current_task = _workflow_notification_status_text(status, overall_result)
2909
+ phase_text = str(status.get("phase", "") or "unknown").strip()
2910
+ mode_text = str(config.get("mode", "manual")).strip()
2911
+ completed_count = progress.get("completed_task_count", 0)
2912
+ total_count = progress.get("total_task_count", 0)
2913
+ pending_count = progress.get("pending_task_count", 0)
2914
+ if overall_result == "通过" and phase_text == "done" and total_count:
2915
+ completed_count = total_count
2916
+ pending_count = 0
2917
+
2918
+ if template == "html":
2919
+ lines = [
2920
+ "<div style=\"font-size:14px;line-height:1.7;\">",
2921
+ "<h2 style=\"margin:0 0 8px 0;font-size:16px;\">任务摘要</h2>",
2922
+ "<ul style=\"margin:0 0 16px 18px;padding:0;\">",
2923
+ f"<li>会话:<code>{session_meta['session_id']}</code></li>",
2924
+ f"<li>仓库:{target_text}</li>",
2925
+ f"<li>模式:<code>{mode_text}</code></li>",
2926
+ f"<li>耗时:{format_duration(duration_seconds)}</li>",
2927
+ f"<li>进度:{completed_count}/{total_count} 已完成,剩余 {pending_count}</li>",
2928
+ "</ul>",
2929
+ "<h2 style=\"margin:0 0 8px 0;font-size:16px;\">任务结果</h2>",
2930
+ "<ul style=\"margin:0 0 0 18px;padding:0;\">",
2931
+ f"<li>当前阶段:<code>{phase_text}</code></li>",
2932
+ f"<li>当前说明:{current_task}</li>",
2933
+ f"<li>下一步:{next_action}</li>",
2934
+ "</ul>",
2935
+ "</div>",
2936
+ ]
2937
+ return title, "".join(lines)
2938
+
2939
+ if template == "txt":
2940
+ lines = [
2941
+ "【任务摘要】",
2942
+ f"会话:{session_meta['session_id']}",
2943
+ f"仓库:{target_text}",
2944
+ f"模式:{mode_text}|耗时:{format_duration(duration_seconds)}",
2945
+ f"进度:{completed_count}/{total_count} 已完成,剩余 {pending_count}",
2946
+ "",
2947
+ "【任务结果】",
2948
+ f"阶段:{phase_text}",
2949
+ f"说明:{current_task or '暂无'}",
2950
+ ]
2951
+ return title, "\n".join(lines)
2952
+
2953
+ lines = [
2954
+ "## 任务摘要",
2955
+ "",
2956
+ f"会话:`{session_meta['session_id']}` ",
2957
+ f"仓库:{target_text} ",
2958
+ f"模式:`{mode_text}`|耗时:{format_duration(duration_seconds)} ",
2959
+ f"进度:{completed_count}/{total_count} 已完成,剩余 {pending_count}",
2960
+ "",
2961
+ "## 任务结果",
2962
+ "",
2963
+ f"阶段:`{phase_text}` ",
2964
+ f"说明:{current_task or '暂无'}",
2965
+ ]
2966
+ return title, "\n".join(lines)
2967
+
2968
+
2969
+ def build_feishu_notification_payload(
2970
+ config: dict[str, Any],
2971
+ session_meta: dict[str, Any],
2972
+ plan: dict[str, Any],
2973
+ status: dict[str, Any],
2974
+ overall_result: str,
2975
+ title: str,
2976
+ ) -> dict[str, Any]:
2977
+ duration_seconds = workflow_duration_seconds(session_meta, status, status.get("finished_at", ""))
2978
+ progress = plan.get("todo_progress", {})
2979
+ targets = [str(item.get("name", "")).strip() for item in plan.get("target_codebases", []) if str(item.get("name", "")).strip()]
2980
+ target_text = "、".join(targets) if targets else "未识别"
2981
+ current_task = _workflow_notification_status_text(status, overall_result)
2982
+ phase_text = str(status.get("phase", "") or "unknown").strip()
2983
+ mode_text = str(config.get("mode", "manual")).strip()
2984
+ completed_count = progress.get("completed_task_count", 0)
2985
+ total_count = progress.get("total_task_count", 0)
2986
+ pending_count = progress.get("pending_task_count", 0)
2987
+ if overall_result == "通过" and phase_text == "done" and total_count:
2988
+ completed_count = total_count
2989
+ pending_count = 0
2990
+ status_emoji = "✅" if overall_result == "通过" else "❌"
2991
+ header_template = "green" if overall_result == "通过" else "red"
2992
+ reports = {
2993
+ "plan.md": workspace_relative_path(config, report_artifact_path(config, "plan.md", session_meta)),
2994
+ "review.md": workspace_relative_path(config, report_artifact_path(config, "review.md", session_meta)),
2995
+ "verify.md": workspace_relative_path(config, report_artifact_path(config, "verify.md", session_meta)),
2996
+ }
2997
+ return {
2998
+ "msg_type": "interactive",
2999
+ "card": {
3000
+ "schema": "2.0",
3001
+ "config": {
3002
+ "update_multi": True,
3003
+ "style": {
3004
+ "text_size": {
3005
+ "normal_v2": {
3006
+ "default": "normal",
3007
+ "pc": "normal",
3008
+ "mobile": "heading",
3009
+ }
3010
+ }
3011
+ },
3012
+ },
3013
+ "header": {
3014
+ "title": {
3015
+ "tag": "plain_text",
3016
+ "content": title,
3017
+ },
3018
+ "template": header_template,
3019
+ "padding": "12px 12px 12px 12px",
3020
+ },
3021
+ "body": {
3022
+ "direction": "vertical",
3023
+ "padding": "12px 12px 12px 12px",
3024
+ "elements": [
3025
+ {
3026
+ "tag": "markdown",
3027
+ "content": (
3028
+ "**任务摘要**\n"
3029
+ f"- 会话:`{session_meta['session_id']}`\n"
3030
+ f"- 仓库:{target_text}\n"
3031
+ f"- 模式:`{mode_text}`\n"
3032
+ f"- 耗时:{format_duration(duration_seconds)}\n"
3033
+ f"- 进度:{completed_count}/{total_count} 已完成,剩余 {pending_count}"
3034
+ ),
3035
+ "text_align": "left",
3036
+ "text_size": "normal_v2",
3037
+ },
3038
+ {
3039
+ "tag": "markdown",
3040
+ "content": (
3041
+ "**任务结果**\n"
3042
+ f"- 阶段:`{phase_text}`\n"
3043
+ f"- 说明:{current_task or '暂无'}\n"
3044
+ f"- 通知来源:`super-engineer verify`\n"
3045
+ f"- 报告:`plan.md` / `review.md` / `verify.md`"
3046
+ ),
3047
+ "text_align": "left",
3048
+ "text_size": "normal_v2",
3049
+ },
3050
+ {
3051
+ "tag": "markdown",
3052
+ "content": (
3053
+ "**报告路径**\n"
3054
+ f"- plan:`{reports['plan.md']}`\n"
3055
+ f"- review:`{reports['review.md']}`\n"
3056
+ f"- verify:`{reports['verify.md']}`"
3057
+ ),
3058
+ "text_align": "left",
3059
+ "text_size": "normal_v2",
3060
+ },
3061
+ ],
3062
+ },
3063
+ },
3064
+ }
3065
+
3066
+
3067
+ def send_feishu_notification_python(webhook_url: str, payload: dict[str, Any]) -> dict[str, Any]:
3068
+ request = urllib_request.Request(
3069
+ webhook_url,
3070
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
3071
+ headers={"Content-Type": "application/json"},
3072
+ method="POST",
3073
+ )
3074
+ try:
3075
+ with urllib_request.urlopen(request, timeout=15) as response:
3076
+ body = response.read().decode("utf-8", errors="replace")
3077
+ status_code = getattr(response, "status", 200)
3078
+ except urllib_error.HTTPError as error:
3079
+ body = error.read().decode("utf-8", errors="replace")
3080
+ result = _normalize_feishu_response(error.code, body, "python")
3081
+ result["message"] = str(result.get("message") or f"飞书 HTTP {error.code}")
3082
+ return result
3083
+ except urllib_error.URLError as error:
3084
+ return {
3085
+ "route": "feishu",
3086
+ "channel": "webhook",
3087
+ "template": "interactive",
3088
+ "sender": "python",
3089
+ "status": "failed",
3090
+ "success": False,
3091
+ "message": f"飞书 webhook 请求失败:{error.reason}",
3092
+ "response": {},
3093
+ }
3094
+ return _normalize_feishu_response(status_code, body, "python")
3095
+
3096
+
3097
+ def send_feishu_notification_curl(webhook_url: str, payload: dict[str, Any]) -> dict[str, Any]:
3098
+ curl_path = shutil.which("curl")
3099
+ if not curl_path:
3100
+ return {
3101
+ "route": "feishu",
3102
+ "channel": "webhook",
3103
+ "template": "interactive",
3104
+ "sender": "curl",
3105
+ "status": "failed",
3106
+ "success": False,
3107
+ "message": "系统中未找到 curl,无法执行飞书回退发送。",
3108
+ "response": {},
3109
+ }
3110
+ result = subprocess.run(
3111
+ [
3112
+ curl_path,
3113
+ "-sS",
3114
+ webhook_url,
3115
+ "-H",
3116
+ "Content-Type: application/json",
3117
+ "-d",
3118
+ json.dumps(payload, ensure_ascii=False),
3119
+ ],
3120
+ capture_output=True,
3121
+ text=True,
3122
+ check=False,
3123
+ timeout=20,
3124
+ )
3125
+ if result.returncode != 0:
3126
+ return {
3127
+ "route": "feishu",
3128
+ "channel": "webhook",
3129
+ "template": "interactive",
3130
+ "sender": "curl",
3131
+ "status": "failed",
3132
+ "success": False,
3133
+ "message": f"飞书 curl 回退发送失败:{result.stderr.strip() or result.stdout.strip() or result.returncode}",
3134
+ "response": {},
3135
+ }
3136
+ return _normalize_feishu_response(200, result.stdout, "curl")
3137
+
3138
+
3139
+ def send_feishu_notification(
3140
+ feishu: dict[str, Any],
3141
+ config: dict[str, Any],
3142
+ session_meta: dict[str, Any],
3143
+ plan: dict[str, Any],
3144
+ status: dict[str, Any],
3145
+ overall_result: str,
3146
+ ) -> dict[str, Any]:
3147
+ title = _workflow_notification_title(session_meta["session_id"], overall_result)
3148
+ payload = build_feishu_notification_payload(config, session_meta, plan, status, overall_result, title)
3149
+ secret = str(feishu.get("secret", "")).strip()
3150
+ if secret:
3151
+ timestamp = str(int(time.time()))
3152
+ payload["timestamp"] = timestamp
3153
+ payload["sign"] = feishu_sign(secret, timestamp)
3154
+ webhook_url = str(feishu.get("webhook_url", "")).strip()
3155
+ python_result = send_feishu_notification_python(webhook_url, payload)
3156
+ if python_result.get("success"):
3157
+ return python_result
3158
+ curl_result = send_feishu_notification_curl(webhook_url, payload)
3159
+ if curl_result.get("success"):
3160
+ curl_result["message"] = f"{curl_result.get('message', '')}(Python失败后已回退curl成功)"
3161
+ curl_result["python_error"] = python_result.get("message", "")
3162
+ return curl_result
3163
+ curl_result["python_error"] = python_result.get("message", "")
3164
+ curl_result["message"] = (
3165
+ f"Python发送失败:{python_result.get('message', '')};"
3166
+ f"curl回退也失败:{curl_result.get('message', '')}"
3167
+ )
3168
+ return curl_result
3169
+
3170
+
3171
+ def notify_workflow_result(
3172
+ config: dict[str, Any],
3173
+ session_meta: dict[str, Any],
3174
+ plan: dict[str, Any],
3175
+ status: dict[str, Any],
3176
+ overall_result: str,
3177
+ ) -> dict[str, Any]:
3178
+ notification_path = data_artifact_path(config, "notification.json", session_meta)
3179
+ existing_result = read_json(notification_path, {})
3180
+ current_fingerprint = workflow_notification_fingerprint(session_meta, status, overall_result)
3181
+ if (
3182
+ isinstance(existing_result, dict)
3183
+ and is_standard_workflow_notification(config, session_meta, status, overall_result, existing_result)
3184
+ ):
3185
+ deduped_result = dict(existing_result)
3186
+ deduped_result["deduplicated"] = True
3187
+ deduped_result["message"] = "通知已发送,已跳过重复发送。"
3188
+ return deduped_result
3189
+
3190
+ pushplus = pushplus_config(config)
3191
+ feishu = feishu_config(config)
3192
+ enabled_routes = [item for item in pushplus.get("routes", []) if item.get("enabled")]
3193
+ if feishu.get("enabled"):
3194
+ enabled_routes.append({"name": "feishu", "channel": "webhook", "template": "interactive"})
3195
+ result: dict[str, Any] = {
3196
+ "provider": "notification",
3197
+ "source": "run-workflow.py verify",
3198
+ "schema_version": 1,
3199
+ "fingerprint": current_fingerprint,
3200
+ "enabled": bool(enabled_routes),
3201
+ "status": "skipped",
3202
+ "message": "未配置通知。",
3203
+ "sent_at": now_iso(),
3204
+ "title": "",
3205
+ "routes": enabled_routes,
3206
+ "results": [],
3207
+ }
3208
+ if enabled_routes:
3209
+ route_results = []
3210
+ last_title = ""
3211
+ for route in enabled_routes:
3212
+ if route.get("name") == "feishu":
3213
+ last_title = _workflow_notification_title(session_meta["session_id"], overall_result)
3214
+ route_results.append(
3215
+ send_feishu_notification(feishu, config, session_meta, plan, status, overall_result)
3216
+ )
3217
+ continue
3218
+ title, content = build_workflow_notification(
3219
+ config,
3220
+ session_meta,
3221
+ plan,
3222
+ status,
3223
+ overall_result,
3224
+ template=str(route.get("template", "markdown") or "markdown"),
3225
+ )
3226
+ last_title = title
3227
+ route_results.append(send_pushplus_notification(pushplus, route, title, content))
3228
+ sent_count = sum(1 for item in route_results if item.get("status") == "sent")
3229
+ if sent_count == len(route_results):
3230
+ result["status"] = "sent"
3231
+ elif sent_count > 0:
3232
+ result["status"] = "partial"
3233
+ else:
3234
+ result["status"] = "failed"
3235
+ result["title"] = last_title
3236
+ result["message"] = ";".join(
3237
+ f"{item.get('route', 'unknown')}:{item.get('message', '')}" for item in route_results
3238
+ )
3239
+ result["results"] = route_results
3240
+ result["sent_at"] = now_iso()
3241
+ write_managed_json(config, notification_path, result)
3242
+ return result
3243
+
3244
+
3245
+ def phase_after(stage: str, mode: str) -> tuple[str, bool, str, str]:
3246
+ if stage == "plan":
3247
+ if mode == "manual":
3248
+ return ("wait_confirm_plan", True, "implement", "等待确认后开始按计划修改代码。")
3249
+ return ("plan", False, "", "继续进入实现阶段。")
3250
+ if stage == "implement":
3251
+ if mode == "manual":
3252
+ return ("wait_confirm_implement", True, "review", "等待确认后执行代码审查。")
3253
+ return ("implement", False, "", "继续进入代码审查阶段。")
3254
+ if stage == "review":
3255
+ if mode == "manual":
3256
+ return ("wait_confirm_review", True, "verify", "等待确认后执行验证。")
3257
+ return ("review", False, "", "继续进入验证阶段。")
3258
+ return ("done", False, "", "工作流已完成。")
3259
+
3260
+
3261
+ def scan_java_files(codebase: Path) -> list[str]:
3262
+ results: list[str] = []
3263
+ for pattern in ("src/main/java/**/*.java", "src/test/java/**/*.java"):
3264
+ for path in sorted(codebase.glob(pattern)):
3265
+ results.append(str(path.resolve()))
3266
+ return results
3267
+
3268
+
3269
+ def infer_java_modules(paths: list[str]) -> list[str]:
3270
+ modules: list[str] = []
3271
+ for absolute_path in paths:
3272
+ parts = Path(absolute_path).parts
3273
+ if "java" not in parts:
3274
+ continue
3275
+ java_index = parts.index("java")
3276
+ package_parts = list(parts[java_index + 1 : -1])
3277
+ if not package_parts:
3278
+ continue
3279
+ if len(package_parts) >= 2:
3280
+ module = f"{package_parts[-2]}-{package_parts[-1]}"
3281
+ else:
3282
+ module = package_parts[-1]
3283
+ if module not in modules:
3284
+ modules.append(module)
3285
+ return modules