super-engineer-workflow 0.1.5 → 0.1.7
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 +17 -0
- package/README.md +6 -10
- package/docs/se/345/221/275/344/273/244/345/215/217/350/256/256.md +42 -297
- package/docs//350/267/250/345/271/263/345/217/260/346/224/257/346/214/201/347/237/251/351/230/265.md +34 -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 +3 -2
- package/package.json +1 -1
- package/scripts/se-cli.py +146 -2
- package/scripts/se-e2e-test.py +168 -0
- package/scripts/se-setup.py +13 -2
- package/super-engineer-workflow/SKILL.md +20 -5
- package/super-engineer-workflow/references/commands/apply.md +28 -0
- package/super-engineer-workflow/references/commands/archive.md +23 -0
- package/super-engineer-workflow/references/commands/bridge.md +25 -0
- package/super-engineer-workflow/references/commands/common.md +32 -0
- package/super-engineer-workflow/references/commands/plan.md +25 -0
- package/super-engineer-workflow/references/commands/propose.md +25 -0
- package/super-engineer-workflow/references/commands/review.md +22 -0
- package/super-engineer-workflow/references/commands/status.md +22 -0
- package/super-engineer-workflow/references/commands/verify.md +23 -0
- package/super-engineer-workflow/scripts/bootstrap-openspec.py +3 -1
- package/super-engineer-workflow/scripts/common.py +143 -7
- package/super-engineer-workflow/scripts/generate-discovery.py +44 -0
- package/super-engineer-workflow/scripts/generate-smart-plan.py +12 -2
- package/super-engineer-workflow/scripts/prepare-archive-openspec.py +4 -0
- package/super-engineer-workflow/scripts/run-workflow.py +108 -33
- package/super-engineer-workflow/scripts/writeback-openspec.py +5 -1
- package/super-engineer-workflow/references/se-commands.md +0 -586
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# `/se:propose <change-name>`
|
|
2
|
+
|
|
3
|
+
用途:生成或修正当前 OpenSpec change 的规格文档,不改代码,不生成 todo。
|
|
4
|
+
|
|
5
|
+
## 前置
|
|
6
|
+
|
|
7
|
+
- `workflow_source=openspec`。
|
|
8
|
+
- 用户必须显式提供 `<change-name>`。
|
|
9
|
+
- 必须读取 `demand_file`,并读取真实存在的 `reference_files` 摘要作为上下文。
|
|
10
|
+
|
|
11
|
+
## 执行
|
|
12
|
+
|
|
13
|
+
1. 先执行公共 `route-check`。
|
|
14
|
+
2. 再执行:
|
|
15
|
+
`python3 scripts/run-workflow.py route-se --command-text "/se:propose <change-name>"`
|
|
16
|
+
3. 根据 `propose-input.json` 更新或生成 `proposal.md`、`design.md`、`tasks.md`、`specs/`。
|
|
17
|
+
4. 停在 `proposed` 阶段。
|
|
18
|
+
|
|
19
|
+
## 禁止
|
|
20
|
+
|
|
21
|
+
- 禁止生成或覆盖 `todo.md`。
|
|
22
|
+
- 禁止执行 `/se:bridge`、`/se:plan`、`/se:apply`。
|
|
23
|
+
- 禁止提示“确认后执行 /se:apply”。
|
|
24
|
+
|
|
25
|
+
最终回复必须表达:代码未修改,下一步只能执行 `/se:bridge`。
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# `/se:review`
|
|
2
|
+
|
|
3
|
+
用途:基于标准 session 和真实代码差异生成 review 结果。
|
|
4
|
+
|
|
5
|
+
## 前置
|
|
6
|
+
|
|
7
|
+
- 已完成实现和 self-check。
|
|
8
|
+
- 存在标准 `plan.json` / `plan-summary.json`。
|
|
9
|
+
|
|
10
|
+
## 执行
|
|
11
|
+
|
|
12
|
+
1. 先执行公共 `route-check`。
|
|
13
|
+
2. 再执行:
|
|
14
|
+
`python3 scripts/run-workflow.py route-se --command-text "/se:review"`
|
|
15
|
+
3. 优先读取 `plan-summary.json`,输出 compact review。
|
|
16
|
+
|
|
17
|
+
## 禁止
|
|
18
|
+
|
|
19
|
+
- 禁止在未实现完成时 review。
|
|
20
|
+
- 禁止跳过 blocking finding 进入 verify。
|
|
21
|
+
|
|
22
|
+
review 通过时下一步 `/se:verify`;存在 blocking finding 时下一步 `/se:apply` 修复。
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# `/se:status`
|
|
2
|
+
|
|
3
|
+
用途:查看当前工作流状态,不修改代码和产物。
|
|
4
|
+
|
|
5
|
+
## 执行
|
|
6
|
+
|
|
7
|
+
1. 先执行公共 `route-check`。
|
|
8
|
+
2. 再执行:
|
|
9
|
+
`python3 scripts/run-workflow.py route-se --command-text "/se:status"`
|
|
10
|
+
|
|
11
|
+
## 回复
|
|
12
|
+
|
|
13
|
+
只汇报:
|
|
14
|
+
|
|
15
|
+
- 当前 workflow_source / mode
|
|
16
|
+
- 当前 phase
|
|
17
|
+
- 当前 session
|
|
18
|
+
- 关键产物路径
|
|
19
|
+
- allowed_next
|
|
20
|
+
- blockers
|
|
21
|
+
|
|
22
|
+
禁止顺带执行下一阶段命令。
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# `/se:verify`
|
|
2
|
+
|
|
3
|
+
用途:运行验证命令、生成 verify 报告,并由脚本发送通知。
|
|
4
|
+
|
|
5
|
+
## 前置
|
|
6
|
+
|
|
7
|
+
- 已完成 review。
|
|
8
|
+
- 存在标准 session。
|
|
9
|
+
|
|
10
|
+
## 执行
|
|
11
|
+
|
|
12
|
+
1. 先执行公共 `route-check`。
|
|
13
|
+
2. 再执行:
|
|
14
|
+
`python3 scripts/run-workflow.py route-se --command-text "/se:verify"`
|
|
15
|
+
3. 验证日志由脚本截断摘要。
|
|
16
|
+
4. 通知只能由 verify 脚本发送,成功证据是 `notification.json`。
|
|
17
|
+
|
|
18
|
+
## 禁止
|
|
19
|
+
|
|
20
|
+
- 禁止 AI 直接调用任何 webhook。
|
|
21
|
+
- verify 失败时禁止提示 archive-check。
|
|
22
|
+
|
|
23
|
+
OpenSpec 模式 verify 通过后,下一步 `/se:archive-check`。
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import argparse
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
-
from common import ensure_workflow_inputs, load_workspace_config, todo_path, update_se_state, workflow_source, workspace_root
|
|
7
|
+
from common import ensure_workflow_inputs, load_workspace_config, openspec_tasks_hash, todo_path, update_se_state, workflow_source, workspace_root
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def main() -> None:
|
|
@@ -30,12 +30,14 @@ def main() -> None:
|
|
|
30
30
|
artifacts={
|
|
31
31
|
"todo": str(todo_path(config)),
|
|
32
32
|
"bridge_source": str(result.get("bridge_source", "")),
|
|
33
|
+
"tasks_sha256": openspec_tasks_hash(config),
|
|
33
34
|
},
|
|
34
35
|
)
|
|
35
36
|
print(f"workflow_source={result.get('workflow_source', '')}")
|
|
36
37
|
print(f"todo={todo_path(config)}")
|
|
37
38
|
print(f"bridge_generated={'true' if result.get('bridge_generated') else 'false'}")
|
|
38
39
|
print(f"bridge_source={result.get('bridge_source', '')}")
|
|
40
|
+
print(f"tasks_sha256={openspec_tasks_hash(config)}")
|
|
39
41
|
|
|
40
42
|
|
|
41
43
|
if __name__ == "__main__":
|
|
@@ -23,6 +23,61 @@ def now_iso() -> str:
|
|
|
23
23
|
return datetime.now(timezone.utc).isoformat()
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def workflow_lock_path(config: dict[str, Any]) -> Path:
|
|
27
|
+
return artifacts_dir(config) / "workflow.lock"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def process_is_running(pid: int) -> bool:
|
|
31
|
+
if pid <= 0:
|
|
32
|
+
return False
|
|
33
|
+
try:
|
|
34
|
+
os.kill(pid, 0)
|
|
35
|
+
except OSError:
|
|
36
|
+
return False
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def acquire_workflow_lock(config: dict[str, Any], command: str, stale_seconds: int = 1800) -> Path:
|
|
41
|
+
ensure_runtime_dirs(config)
|
|
42
|
+
path = workflow_lock_path(config)
|
|
43
|
+
payload = {
|
|
44
|
+
"pid": os.getpid(),
|
|
45
|
+
"command": command,
|
|
46
|
+
"created_at": now_iso(),
|
|
47
|
+
}
|
|
48
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
49
|
+
try:
|
|
50
|
+
fd = os.open(str(path), flags)
|
|
51
|
+
except FileExistsError:
|
|
52
|
+
existing = read_json(path, {})
|
|
53
|
+
pid = int(existing.get("pid", 0) or 0) if isinstance(existing, dict) else 0
|
|
54
|
+
created_at = parse_iso_datetime(str(existing.get("created_at", ""))) if isinstance(existing, dict) else None
|
|
55
|
+
age = (datetime.now(timezone.utc) - created_at).total_seconds() if created_at else stale_seconds + 1
|
|
56
|
+
if age > stale_seconds or not process_is_running(pid):
|
|
57
|
+
try:
|
|
58
|
+
path.unlink()
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
pass
|
|
61
|
+
return acquire_workflow_lock(config, command, stale_seconds=stale_seconds)
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
f"检测到工作流正在执行:pid={pid}, command={existing.get('command', '') if isinstance(existing, dict) else ''}。"
|
|
64
|
+
"请等待当前命令结束后重试。"
|
|
65
|
+
)
|
|
66
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
67
|
+
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
68
|
+
handle.write("\n")
|
|
69
|
+
return path
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def release_workflow_lock(path: Path | None) -> None:
|
|
73
|
+
if not path:
|
|
74
|
+
return
|
|
75
|
+
try:
|
|
76
|
+
path.unlink()
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
26
81
|
def parse_iso_datetime(value: str) -> datetime | None:
|
|
27
82
|
normalized = str(value).strip()
|
|
28
83
|
if not normalized:
|
|
@@ -652,17 +707,13 @@ def create_session(config: dict[str, Any]) -> dict[str, Any]:
|
|
|
652
707
|
},
|
|
653
708
|
)
|
|
654
709
|
Path(session_meta["data_dir"]).mkdir(parents=True, exist_ok=True)
|
|
655
|
-
Path(session_meta["report_dir"]).mkdir(parents=True, exist_ok=True)
|
|
656
710
|
write_managed_json(config, current_session_file(config), session_meta)
|
|
657
711
|
return session_meta
|
|
658
712
|
|
|
659
713
|
|
|
660
714
|
def current_session_meta(config: dict[str, Any]) -> dict[str, Any]:
|
|
661
715
|
session_meta = read_json(current_session_file(config), {})
|
|
662
|
-
|
|
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
|
|
716
|
+
return _normalize_session_meta(config, session_meta)
|
|
666
717
|
|
|
667
718
|
|
|
668
719
|
def current_session_status(config: dict[str, Any], session_meta: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
@@ -678,16 +729,26 @@ def active_session_for_plan(config: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
678
729
|
session_meta = current_session_meta(config)
|
|
679
730
|
except FileNotFoundError:
|
|
680
731
|
return None
|
|
681
|
-
|
|
682
|
-
|
|
732
|
+
data_dir = Path(str(session_meta["data_dir"]))
|
|
733
|
+
has_plan = data_artifact_path(config, "plan.json", session_meta).exists()
|
|
683
734
|
status = current_session_status(config, session_meta)
|
|
684
735
|
status_phase = str(status.get("phase", "") or "").strip()
|
|
685
736
|
if status_phase in ("done", "archived", "blocked"):
|
|
686
737
|
return None
|
|
738
|
+
if not has_plan and data_dir.exists():
|
|
739
|
+
return {
|
|
740
|
+
"session": session_meta,
|
|
741
|
+
"status": status,
|
|
742
|
+
"phase": status_phase or "draft",
|
|
743
|
+
"incomplete": True,
|
|
744
|
+
}
|
|
745
|
+
if not has_plan:
|
|
746
|
+
return None
|
|
687
747
|
return {
|
|
688
748
|
"session": session_meta,
|
|
689
749
|
"status": status,
|
|
690
750
|
"phase": status_phase or "plan",
|
|
751
|
+
"incomplete": False,
|
|
691
752
|
}
|
|
692
753
|
|
|
693
754
|
|
|
@@ -697,6 +758,8 @@ def ensure_plan_can_run(config: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
697
758
|
return None
|
|
698
759
|
phase = str(active.get("phase", "")).strip()
|
|
699
760
|
session = active.get("session", {})
|
|
761
|
+
if active.get("incomplete"):
|
|
762
|
+
return active
|
|
700
763
|
if phase in ("plan", "wait_confirm_plan"):
|
|
701
764
|
return active
|
|
702
765
|
raise RuntimeError(
|
|
@@ -834,6 +897,71 @@ def update_se_state(
|
|
|
834
897
|
return state
|
|
835
898
|
|
|
836
899
|
|
|
900
|
+
def openspec_tasks_hash(config: dict[str, Any]) -> str:
|
|
901
|
+
if workflow_source(config) != "openspec":
|
|
902
|
+
return ""
|
|
903
|
+
return file_sha256(openspec_tasks_path(config))
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def openspec_artifact_hashes(config: dict[str, Any]) -> dict[str, Any]:
|
|
907
|
+
if workflow_source(config) != "openspec":
|
|
908
|
+
return {}
|
|
909
|
+
result: dict[str, Any] = {}
|
|
910
|
+
openspec = config.get("openspec", {})
|
|
911
|
+
for key in ("proposal_file", "design_file", "tasks_file"):
|
|
912
|
+
path_text = str(openspec.get(key, "")).strip()
|
|
913
|
+
if not path_text:
|
|
914
|
+
continue
|
|
915
|
+
path = Path(path_text)
|
|
916
|
+
if path.exists() and path.is_file():
|
|
917
|
+
result[key.replace("_file", "")] = {
|
|
918
|
+
"path": str(path.resolve()),
|
|
919
|
+
"sha256": file_sha256(path),
|
|
920
|
+
}
|
|
921
|
+
specs: list[dict[str, str]] = []
|
|
922
|
+
specs_dir_text = str(openspec.get("specs_dir", "")).strip()
|
|
923
|
+
if specs_dir_text:
|
|
924
|
+
specs_dir = Path(specs_dir_text)
|
|
925
|
+
if specs_dir.exists() and specs_dir.is_dir():
|
|
926
|
+
for path in sorted(specs_dir.rglob("*.md")):
|
|
927
|
+
specs.append(
|
|
928
|
+
{
|
|
929
|
+
"path": str(path.resolve()),
|
|
930
|
+
"relative_path": str(path.relative_to(specs_dir)),
|
|
931
|
+
"sha256": file_sha256(path),
|
|
932
|
+
}
|
|
933
|
+
)
|
|
934
|
+
result["specs"] = specs
|
|
935
|
+
return result
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def openspec_hash_drift(config: dict[str, Any], baseline: dict[str, Any]) -> list[str]:
|
|
939
|
+
current = openspec_artifact_hashes(config)
|
|
940
|
+
drifts: list[str] = []
|
|
941
|
+
for key in ("proposal", "design", "tasks"):
|
|
942
|
+
old_item = baseline.get(key, {}) if isinstance(baseline, dict) else {}
|
|
943
|
+
new_item = current.get(key, {}) if isinstance(current, dict) else {}
|
|
944
|
+
old_hash = str(old_item.get("sha256", "")).strip() if isinstance(old_item, dict) else ""
|
|
945
|
+
new_hash = str(new_item.get("sha256", "")).strip() if isinstance(new_item, dict) else ""
|
|
946
|
+
if old_hash and new_hash and old_hash != new_hash:
|
|
947
|
+
drifts.append(f"{key}.md 自执行回写后已变化")
|
|
948
|
+
old_specs = {
|
|
949
|
+
str(item.get("relative_path", "")): str(item.get("sha256", ""))
|
|
950
|
+
for item in baseline.get("specs", [])
|
|
951
|
+
if isinstance(item, dict)
|
|
952
|
+
} if isinstance(baseline, dict) else {}
|
|
953
|
+
new_specs = {
|
|
954
|
+
str(item.get("relative_path", "")): str(item.get("sha256", ""))
|
|
955
|
+
for item in current.get("specs", [])
|
|
956
|
+
if isinstance(item, dict)
|
|
957
|
+
} if isinstance(current, dict) else {}
|
|
958
|
+
for rel, old_hash in old_specs.items():
|
|
959
|
+
new_hash = new_specs.get(rel, "")
|
|
960
|
+
if old_hash and new_hash and old_hash != new_hash:
|
|
961
|
+
drifts.append(f"specs/{rel} 自执行回写后已变化")
|
|
962
|
+
return drifts
|
|
963
|
+
|
|
964
|
+
|
|
837
965
|
def _se_state_artifact_exists(path_text: str) -> bool:
|
|
838
966
|
return bool(path_text and Path(path_text).exists())
|
|
839
967
|
|
|
@@ -1117,6 +1245,10 @@ def validate_se_state(config: dict[str, Any], run_command: str) -> dict[str, Any
|
|
|
1117
1245
|
errors.append("/se:propose 后不能直接进入交付,必须先执行 /se:bridge。")
|
|
1118
1246
|
if not _se_state_artifact_exists(str(artifacts.get("todo", ""))):
|
|
1119
1247
|
errors.append("缺少桥接 todo.md,请先执行 /se:bridge。")
|
|
1248
|
+
bridged_hash = str(artifacts.get("tasks_sha256", "")).strip()
|
|
1249
|
+
current_hash = openspec_tasks_hash(config)
|
|
1250
|
+
if bridged_hash and current_hash and bridged_hash != current_hash:
|
|
1251
|
+
errors.append("OpenSpec tasks.md 已变化,请重新执行 /se:bridge 生成待审核 todo.md。")
|
|
1120
1252
|
elif se_command == "/se:apply":
|
|
1121
1253
|
if phase not in ("bridged", "planned", "implementing", "reviewed", "blocked") and se_command not in allowed_next:
|
|
1122
1254
|
errors.append("当前状态不允许进入交付,请先完成 /se:bridge 并人工审核 todo.md。")
|
|
@@ -1124,6 +1256,10 @@ def validate_se_state(config: dict[str, Any], run_command: str) -> dict[str, Any
|
|
|
1124
1256
|
errors.append("/se:propose 后不能直接进入交付,必须先执行 /se:bridge。")
|
|
1125
1257
|
if not _se_state_artifact_exists(str(artifacts.get("todo", ""))):
|
|
1126
1258
|
errors.append("缺少桥接 todo.md,请先执行 /se:bridge。")
|
|
1259
|
+
bridged_hash = str(artifacts.get("tasks_sha256", "")).strip()
|
|
1260
|
+
current_hash = openspec_tasks_hash(config)
|
|
1261
|
+
if bridged_hash and current_hash and bridged_hash != current_hash:
|
|
1262
|
+
errors.append("OpenSpec tasks.md 已变化,请重新执行 /se:bridge 并审核新的 todo.md。")
|
|
1127
1263
|
elif se_command == "/se:review":
|
|
1128
1264
|
if phase not in ("self_checked", "reviewed", "blocked"):
|
|
1129
1265
|
errors.append("当前状态不允许 review,请先完成实现和自查。")
|
|
@@ -107,6 +107,49 @@ def build_discovery_markdown(discovery: dict) -> str:
|
|
|
107
107
|
return "\n".join(lines)
|
|
108
108
|
|
|
109
109
|
|
|
110
|
+
def build_discovery_summary(discovery: dict) -> dict[str, object]:
|
|
111
|
+
codebase_summaries: list[dict[str, object]] = []
|
|
112
|
+
evidence: list[dict[str, object]] = []
|
|
113
|
+
total_matches = 0
|
|
114
|
+
for codebase in discovery.get("codebases", []):
|
|
115
|
+
matches = list(codebase.get("matches", []))
|
|
116
|
+
total_matches += len(matches)
|
|
117
|
+
codebase_summaries.append(
|
|
118
|
+
{
|
|
119
|
+
"name": codebase.get("name", ""),
|
|
120
|
+
"path": codebase.get("path", ""),
|
|
121
|
+
"detected_project": codebase.get("detected_project", {}),
|
|
122
|
+
"match_count": len(matches),
|
|
123
|
+
"top_files": list(dict.fromkeys(str(item.get("file", "")) for item in matches if item.get("file")))[:12],
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
for item in matches[:6]:
|
|
127
|
+
evidence.append(
|
|
128
|
+
{
|
|
129
|
+
"codebase": codebase.get("name", ""),
|
|
130
|
+
"keyword": item.get("keyword", ""),
|
|
131
|
+
"file": item.get("file", ""),
|
|
132
|
+
"line": item.get("line", 0),
|
|
133
|
+
"snippet": item.get("snippet", ""),
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
if len(evidence) >= 20:
|
|
137
|
+
break
|
|
138
|
+
return {
|
|
139
|
+
"session_id": discovery.get("session_id", ""),
|
|
140
|
+
"source": "run-workflow.py discovery-summary",
|
|
141
|
+
"schema_version": 1,
|
|
142
|
+
"task_count": len(discovery.get("tasks", [])),
|
|
143
|
+
"keywords": discovery.get("keywords", [])[:30],
|
|
144
|
+
"service_resolution": discovery.get("service_resolution", {}),
|
|
145
|
+
"codebases": codebase_summaries,
|
|
146
|
+
"total_match_count": total_matches,
|
|
147
|
+
"evidence": evidence,
|
|
148
|
+
"planning_hints": discovery.get("planning_hints", [])[:10],
|
|
149
|
+
"created_at": discovery.get("created_at", ""),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
110
153
|
def main() -> None:
|
|
111
154
|
parser = argparse.ArgumentParser(description="根据 todo 关键词定位代码证据,生成 discovery 产物。")
|
|
112
155
|
parser.add_argument("--workspace", help="工作空间路径,默认读取当前目录")
|
|
@@ -165,6 +208,7 @@ def main() -> None:
|
|
|
165
208
|
"created_at": now_iso(),
|
|
166
209
|
}
|
|
167
210
|
write_managed_json(config, data_artifact_path(config, "discovery.json", session_meta), discovery)
|
|
211
|
+
write_managed_json(config, data_artifact_path(config, "discovery-summary.json", session_meta), build_discovery_summary(discovery))
|
|
168
212
|
write_managed_text(config, report_artifact_path(config, "discovery.md", session_meta), build_discovery_markdown(discovery))
|
|
169
213
|
|
|
170
214
|
status = ensure_status(config, session_meta, read_json(data_artifact_path(config, "status.json", session_meta), {}))
|
|
@@ -122,6 +122,8 @@ def build_acceptance_criteria(task_breakdown: list[dict[str, object]], detected:
|
|
|
122
122
|
|
|
123
123
|
|
|
124
124
|
def discovery_evidence(discovery: dict, limit: int = 16) -> list[dict[str, object]]:
|
|
125
|
+
if discovery.get("source") == "run-workflow.py discovery-summary":
|
|
126
|
+
return list(discovery.get("evidence", []))[:limit]
|
|
125
127
|
evidence: list[dict[str, object]] = []
|
|
126
128
|
for codebase in discovery.get("codebases", []):
|
|
127
129
|
for match in codebase.get("matches", [])[:limit]:
|
|
@@ -140,7 +142,10 @@ def discovery_evidence(discovery: dict, limit: int = 16) -> list[dict[str, objec
|
|
|
140
142
|
|
|
141
143
|
|
|
142
144
|
def confidence_from_discovery(discovery: dict, impacted_files: list[str]) -> str:
|
|
143
|
-
|
|
145
|
+
if discovery.get("source") == "run-workflow.py discovery-summary":
|
|
146
|
+
match_count = int(discovery.get("total_match_count", 0) or 0)
|
|
147
|
+
else:
|
|
148
|
+
match_count = sum(len(codebase.get("matches", [])) for codebase in discovery.get("codebases", []))
|
|
144
149
|
if match_count >= 5 and impacted_files:
|
|
145
150
|
return "high"
|
|
146
151
|
if match_count > 0 or impacted_files:
|
|
@@ -317,6 +322,11 @@ def collect_target_plan_data(config: dict, codebases: list[Path]) -> tuple[list[
|
|
|
317
322
|
|
|
318
323
|
def merge_discovery_files(discovery: dict, fallback_files: list[str]) -> list[str]:
|
|
319
324
|
files: list[str] = []
|
|
325
|
+
if discovery.get("source") == "run-workflow.py discovery-summary":
|
|
326
|
+
for codebase in discovery.get("codebases", []):
|
|
327
|
+
files.extend(str(item) for item in codebase.get("top_files", []) if item)
|
|
328
|
+
files.extend(fallback_files)
|
|
329
|
+
return unique(files)[:24]
|
|
320
330
|
for codebase in discovery.get("codebases", []):
|
|
321
331
|
for match in codebase.get("matches", []):
|
|
322
332
|
file_path = str(match.get("file", "")).strip()
|
|
@@ -369,7 +379,7 @@ def main() -> None:
|
|
|
369
379
|
summary = summarize_todo(todo_text)
|
|
370
380
|
docs = existing_reference_files(config)
|
|
371
381
|
target_codebases, impacted_files, impacted_modules = collect_target_plan_data(config, codebases)
|
|
372
|
-
discovery = read_json(data_artifact_path(config, "discovery.json", session_meta), {})
|
|
382
|
+
discovery = read_json(data_artifact_path(config, "discovery-summary.json", session_meta), {}) or read_json(data_artifact_path(config, "discovery.json", session_meta), {})
|
|
373
383
|
bridge_context = read_json(openspec_bridge_context_path(config), {})
|
|
374
384
|
impacted_files = merge_discovery_files(discovery, impacted_files)
|
|
375
385
|
impacted_modules = infer_java_modules(impacted_files) or impacted_modules
|
|
@@ -12,6 +12,7 @@ from common import (
|
|
|
12
12
|
is_standard_workflow_notification,
|
|
13
13
|
load_workspace_config,
|
|
14
14
|
openspec_change_dir,
|
|
15
|
+
openspec_hash_drift,
|
|
15
16
|
openspec_writeback_dir,
|
|
16
17
|
collect_openspec_cli_context,
|
|
17
18
|
read_json,
|
|
@@ -133,6 +134,8 @@ def main() -> None:
|
|
|
133
134
|
acceptance_result = summary.get("acceptance_result", [])
|
|
134
135
|
if any(item.get("status") != "passed" for item in acceptance_result):
|
|
135
136
|
blockers.append("存在未通过的验收项")
|
|
137
|
+
hash_drifts = openspec_hash_drift(config, summary.get("openspec_hashes", {}))
|
|
138
|
+
blockers.extend(hash_drifts)
|
|
136
139
|
blockers.extend(notification_blockers(config, summary))
|
|
137
140
|
spec_conflicts = detect_spec_conflicts(summary)
|
|
138
141
|
if spec_conflicts:
|
|
@@ -159,6 +162,7 @@ def main() -> None:
|
|
|
159
162
|
"verify_result": summary.get("verify", {}).get("result", ""),
|
|
160
163
|
"spec_impacts": summary.get("spec_impacts", []),
|
|
161
164
|
"spec_conflicts": spec_conflicts,
|
|
165
|
+
"openspec_hash_drifts": hash_drifts,
|
|
162
166
|
"merge_mode": merge_mode,
|
|
163
167
|
"acceptance_result": acceptance_result,
|
|
164
168
|
"residual_risks": summary.get("residual_risks", []),
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import argparse
|
|
5
|
+
import json
|
|
5
6
|
import subprocess
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
@@ -11,6 +12,7 @@ from common import (
|
|
|
11
12
|
current_session_is_stale,
|
|
12
13
|
current_session_meta,
|
|
13
14
|
data_artifact_path,
|
|
15
|
+
acquire_workflow_lock,
|
|
14
16
|
ensure_plan_can_run,
|
|
15
17
|
ensure_status,
|
|
16
18
|
load_workspace_config,
|
|
@@ -20,6 +22,7 @@ from common import (
|
|
|
20
22
|
planned_codebase,
|
|
21
23
|
read_json,
|
|
22
24
|
read_se_state,
|
|
25
|
+
release_workflow_lock,
|
|
23
26
|
require_se_state,
|
|
24
27
|
report_artifact_path,
|
|
25
28
|
recover_se_state_from_artifacts,
|
|
@@ -165,7 +168,7 @@ def command_validate_state(workspace: Path | None, command: str | None) -> None:
|
|
|
165
168
|
raise SystemExit(1)
|
|
166
169
|
|
|
167
170
|
|
|
168
|
-
def command_route_se(workspace: Path | None, command_text: str | None, timeout_seconds: int, force: bool = False) -> None:
|
|
171
|
+
def command_route_se(workspace: Path | None, command_text: str | None, timeout_seconds: int, force: bool = False, output_json: bool = False) -> None:
|
|
169
172
|
if not command_text:
|
|
170
173
|
raise SystemExit("缺少 /se:* 命令文本。")
|
|
171
174
|
parsed = parse_se_command(command_text)
|
|
@@ -178,27 +181,88 @@ def command_route_se(workspace: Path | None, command_text: str | None, timeout_s
|
|
|
178
181
|
print(f"argument={argument}")
|
|
179
182
|
if se_command == "/se:init":
|
|
180
183
|
command_init(workspace)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
184
|
+
print_route_reply_constraint(se_command)
|
|
185
|
+
return
|
|
186
|
+
config = load_workspace_config(workspace)
|
|
187
|
+
lock_path = None
|
|
188
|
+
if se_command != "/se:status":
|
|
189
|
+
try:
|
|
190
|
+
lock_path = acquire_workflow_lock(config, se_command)
|
|
191
|
+
print(f"workflow_lock={lock_path}")
|
|
192
|
+
except RuntimeError as error:
|
|
193
|
+
if output_json:
|
|
194
|
+
print(json.dumps({"se_command": se_command, "run_command": run_command, "result": "blocked", "error": str(error)}, ensure_ascii=False, indent=2))
|
|
195
|
+
raise SystemExit(str(error))
|
|
196
|
+
try:
|
|
197
|
+
if se_command == "/se:propose":
|
|
198
|
+
command_propose_openspec(workspace, argument or None)
|
|
199
|
+
elif se_command == "/se:bridge":
|
|
200
|
+
command_bootstrap_openspec(workspace, explicit_se_bridge=True)
|
|
201
|
+
elif se_command == "/se:plan":
|
|
202
|
+
command_plan(workspace)
|
|
203
|
+
elif se_command == "/se:apply":
|
|
204
|
+
command_apply(workspace, timeout_seconds)
|
|
205
|
+
elif se_command == "/se:review":
|
|
206
|
+
command_review(workspace)
|
|
207
|
+
elif se_command == "/se:verify":
|
|
208
|
+
command_verify(workspace, timeout_seconds, force)
|
|
209
|
+
elif se_command == "/se:archive-check":
|
|
210
|
+
command_prepare_archive_openspec(workspace)
|
|
211
|
+
elif se_command == "/se:archive":
|
|
212
|
+
command_archive_openspec(workspace)
|
|
213
|
+
elif se_command == "/se:status":
|
|
214
|
+
command_status(workspace)
|
|
215
|
+
else:
|
|
216
|
+
raise SystemExit(f"不支持的 /se:* 命令:{se_command}")
|
|
217
|
+
finally:
|
|
218
|
+
release_workflow_lock(lock_path)
|
|
201
219
|
print_route_reply_constraint(se_command)
|
|
220
|
+
if output_json:
|
|
221
|
+
state = validate_se_state(load_workspace_config(workspace), run_command)
|
|
222
|
+
payload = {
|
|
223
|
+
"se_command": se_command,
|
|
224
|
+
"run_command": run_command,
|
|
225
|
+
"argument": argument,
|
|
226
|
+
"result": "ok",
|
|
227
|
+
"phase": state.get("phase", ""),
|
|
228
|
+
"allowed_next": state.get("allowed_next", []),
|
|
229
|
+
}
|
|
230
|
+
try:
|
|
231
|
+
payload["session_id"] = current_session_meta(load_workspace_config(workspace)).get("session_id", "")
|
|
232
|
+
except FileNotFoundError:
|
|
233
|
+
payload["session_id"] = ""
|
|
234
|
+
print("route_result_json_begin")
|
|
235
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
236
|
+
print("route_result_json_end")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def command_route_check(workspace: Path | None, command_text: str | None) -> None:
|
|
240
|
+
if not command_text:
|
|
241
|
+
raise SystemExit("缺少 /se:* 命令文本。")
|
|
242
|
+
config = load_workspace_config(workspace)
|
|
243
|
+
parsed = parse_se_command(command_text)
|
|
244
|
+
se_command = parsed["se_command"]
|
|
245
|
+
run_command = parsed["run_command"]
|
|
246
|
+
result = validate_se_state(config, run_command)
|
|
247
|
+
payload = {
|
|
248
|
+
"se_command": se_command,
|
|
249
|
+
"run_command": run_command,
|
|
250
|
+
"argument": str(parsed.get("argument", "")).strip(),
|
|
251
|
+
"workflow_source": workflow_source(config),
|
|
252
|
+
"allowed": bool(result.get("valid")),
|
|
253
|
+
"phase": result.get("phase", ""),
|
|
254
|
+
"allowed_next": result.get("allowed_next", []),
|
|
255
|
+
"errors": result.get("errors", []),
|
|
256
|
+
"state_path": result.get("state_path", ""),
|
|
257
|
+
}
|
|
258
|
+
try:
|
|
259
|
+
session = current_session_meta(config)
|
|
260
|
+
payload["session_id"] = session.get("session_id", "")
|
|
261
|
+
except FileNotFoundError:
|
|
262
|
+
payload["session_id"] = ""
|
|
263
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
264
|
+
if not payload["allowed"]:
|
|
265
|
+
raise SystemExit(1)
|
|
202
266
|
|
|
203
267
|
|
|
204
268
|
def print_route_reply_constraint(se_command: str) -> None:
|
|
@@ -294,16 +358,24 @@ def command_plan(workspace: Path | None) -> None:
|
|
|
294
358
|
raise SystemExit(str(error))
|
|
295
359
|
if active:
|
|
296
360
|
session = active.get("session", {})
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
361
|
+
session_meta = session
|
|
362
|
+
if active.get("incomplete"):
|
|
363
|
+
print("session_action=reused_incomplete")
|
|
364
|
+
print(f"session_id={session_meta.get('session_id', '')}")
|
|
365
|
+
print(f"phase={active.get('phase', '')}")
|
|
366
|
+
print("next_action=复用未完成计划会话,继续生成 discovery/plan。")
|
|
367
|
+
else:
|
|
368
|
+
print("session_action=reused")
|
|
369
|
+
print(f"session_id={session_meta.get('session_id', '')}")
|
|
370
|
+
print(f"phase={active.get('phase', '')}")
|
|
371
|
+
print("next_action=当前计划已存在,继续执行 /se:apply。")
|
|
372
|
+
return
|
|
373
|
+
else:
|
|
374
|
+
command_init(workspace)
|
|
375
|
+
config = load_workspace_config(workspace)
|
|
376
|
+
session_meta = create_session(config)
|
|
377
|
+
print("session_action=created")
|
|
378
|
+
print(f"session_id={session_meta.get('session_id', '')}")
|
|
307
379
|
command_discover(workspace)
|
|
308
380
|
args = ["--workspace", str(workspace)] if workspace else []
|
|
309
381
|
run_python("generate-smart-plan.py", args)
|
|
@@ -465,19 +537,22 @@ def command_apply(workspace: Path | None, timeout_seconds: int) -> None:
|
|
|
465
537
|
|
|
466
538
|
def main() -> None:
|
|
467
539
|
parser = argparse.ArgumentParser(description="super-engineer 统一工作流入口。")
|
|
468
|
-
parser.add_argument("command", choices=["route-se", "init", "propose-openspec", "bootstrap-openspec", "writeback-openspec", "prepare-archive-openspec", "archive-openspec", "discover", "plan", "apply", "start-implement", "finish-implement", "self-check", "review", "verify", "status", "next", "validate-state", "assert-standard-session"])
|
|
540
|
+
parser.add_argument("command", choices=["route-se", "route-check", "init", "propose-openspec", "bootstrap-openspec", "writeback-openspec", "prepare-archive-openspec", "archive-openspec", "discover", "plan", "apply", "start-implement", "finish-implement", "self-check", "review", "verify", "status", "next", "validate-state", "assert-standard-session"])
|
|
469
541
|
parser.add_argument("change_name", nargs="?", help="配合 propose-openspec 或 validate-state 使用。")
|
|
470
542
|
parser.add_argument("--command-text", help="配合 route-se 使用,传入完整 /se:* 命令文本。")
|
|
471
543
|
parser.add_argument("--workspace", help="工作空间路径,默认读取当前目录")
|
|
472
544
|
parser.add_argument("--timeout-seconds", type=int, default=300)
|
|
473
545
|
parser.add_argument("--force", action="store_true", help="配合 verify 使用,强制重跑验证并覆盖结果。")
|
|
546
|
+
parser.add_argument("--json", action="store_true", help="输出机器可读摘要。")
|
|
474
547
|
parser.add_argument("--explicit-se-bridge", action="store_true", help="确认本次 bootstrap-openspec 来自用户显式 /se:bridge 命令。")
|
|
475
548
|
args = parser.parse_args()
|
|
476
549
|
|
|
477
550
|
workspace = Path(args.workspace).expanduser() if args.workspace else None
|
|
478
551
|
|
|
479
552
|
if args.command == "route-se":
|
|
480
|
-
command_route_se(workspace, args.command_text or args.change_name, args.timeout_seconds, args.force)
|
|
553
|
+
command_route_se(workspace, args.command_text or args.change_name, args.timeout_seconds, args.force, args.json)
|
|
554
|
+
elif args.command == "route-check":
|
|
555
|
+
command_route_check(workspace, args.command_text or args.change_name)
|
|
481
556
|
elif args.command == "init":
|
|
482
557
|
command_init(workspace)
|
|
483
558
|
elif args.command == "propose-openspec":
|