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.
- package/CHANGELOG.md +9 -0
- package/CONTRIBUTING.md +34 -0
- package/LICENSE +21 -0
- package/README.md +300 -0
- package/SECURITY.md +21 -0
- package/bin/super-engineer.js +19 -0
- package/docs/se/345/221/275/344/273/244/345/215/217/350/256/256.md +335 -0
- package/docs//344/270/255/346/226/207/344/275/277/347/224/250/346/211/213/345/206/214.md +707 -0
- 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
- 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
- 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
- package/package.json +55 -0
- package/scripts/se-cli.py +301 -0
- package/scripts/se-setup.py +331 -0
- package/scripts/se-smoke-test.py +86 -0
- package/super-engineer-workflow/SKILL.md +439 -0
- package/super-engineer-workflow/adapters/go.yml +8 -0
- package/super-engineer-workflow/adapters/java-gradle.yml +8 -0
- package/super-engineer-workflow/adapters/java-maven.yml +8 -0
- package/super-engineer-workflow/adapters/node-react.yml +8 -0
- package/super-engineer-workflow/adapters/node-vue.yml +8 -0
- package/super-engineer-workflow/adapters/python.yml +8 -0
- package/super-engineer-workflow/agents/openai.yaml +4 -0
- package/super-engineer-workflow/assets/config-schema.json +100 -0
- package/super-engineer-workflow/assets/config.example.yml +12 -0
- package/super-engineer-workflow/assets/plan-schema.json +362 -0
- package/super-engineer-workflow/assets/status-schema.json +83 -0
- package/super-engineer-workflow/assets/workspace.example.yml +25 -0
- package/super-engineer-workflow/config.example.yml +12 -0
- package/super-engineer-workflow/references/contracts.md +39 -0
- package/super-engineer-workflow/references/execution-modes.md +38 -0
- package/super-engineer-workflow/references/java.md +21 -0
- package/super-engineer-workflow/references/planning.md +45 -0
- package/super-engineer-workflow/references/platform-openclaw.md +10 -0
- package/super-engineer-workflow/references/project-docs.md +7 -0
- package/super-engineer-workflow/references/review-checklist.md +26 -0
- package/super-engineer-workflow/references/se-commands.md +582 -0
- package/super-engineer-workflow/references/verify-checklist.md +45 -0
- package/super-engineer-workflow/references/workflow.md +208 -0
- package/super-engineer-workflow/scripts/archive-openspec.py +110 -0
- package/super-engineer-workflow/scripts/bootstrap-openspec.py +42 -0
- package/super-engineer-workflow/scripts/common.py +3285 -0
- package/super-engineer-workflow/scripts/generate-discovery.py +185 -0
- package/super-engineer-workflow/scripts/generate-review-report.py +296 -0
- package/super-engineer-workflow/scripts/generate-self-check.py +185 -0
- package/super-engineer-workflow/scripts/generate-smart-plan.py +429 -0
- package/super-engineer-workflow/scripts/init-workspace.py +68 -0
- package/super-engineer-workflow/scripts/prepare-archive-openspec.py +186 -0
- package/super-engineer-workflow/scripts/propose-openspec.py +170 -0
- package/super-engineer-workflow/scripts/run-verify-and-report.py +399 -0
- package/super-engineer-workflow/scripts/run-workflow.py +506 -0
- package/super-engineer-workflow/scripts/update-status.py +43 -0
- 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
|