super-engineer-workflow 0.1.6 → 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.
Files changed (27) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +6 -10
  3. package/docs/se/345/221/275/344/273/244/345/215/217/350/256/256.md +42 -297
  4. 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
  5. 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
  6. package/package.json +1 -1
  7. package/scripts/se-cli.py +146 -2
  8. package/scripts/se-e2e-test.py +89 -0
  9. package/scripts/se-setup.py +13 -2
  10. package/super-engineer-workflow/SKILL.md +20 -5
  11. package/super-engineer-workflow/references/commands/apply.md +28 -0
  12. package/super-engineer-workflow/references/commands/archive.md +23 -0
  13. package/super-engineer-workflow/references/commands/bridge.md +25 -0
  14. package/super-engineer-workflow/references/commands/common.md +32 -0
  15. package/super-engineer-workflow/references/commands/plan.md +25 -0
  16. package/super-engineer-workflow/references/commands/propose.md +25 -0
  17. package/super-engineer-workflow/references/commands/review.md +22 -0
  18. package/super-engineer-workflow/references/commands/status.md +22 -0
  19. package/super-engineer-workflow/references/commands/verify.md +23 -0
  20. package/super-engineer-workflow/scripts/bootstrap-openspec.py +3 -1
  21. package/super-engineer-workflow/scripts/common.py +128 -0
  22. package/super-engineer-workflow/scripts/generate-discovery.py +44 -0
  23. package/super-engineer-workflow/scripts/generate-smart-plan.py +12 -2
  24. package/super-engineer-workflow/scripts/prepare-archive-openspec.py +4 -0
  25. package/super-engineer-workflow/scripts/run-workflow.py +90 -23
  26. package/super-engineer-workflow/scripts/writeback-openspec.py +5 -1
  27. package/super-engineer-workflow/references/se-commands.md +0 -586
@@ -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:
@@ -842,6 +897,71 @@ def update_se_state(
842
897
  return state
843
898
 
844
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
+
845
965
  def _se_state_artifact_exists(path_text: str) -> bool:
846
966
  return bool(path_text and Path(path_text).exists())
847
967
 
@@ -1125,6 +1245,10 @@ def validate_se_state(config: dict[str, Any], run_command: str) -> dict[str, Any
1125
1245
  errors.append("/se:propose 后不能直接进入交付,必须先执行 /se:bridge。")
1126
1246
  if not _se_state_artifact_exists(str(artifacts.get("todo", ""))):
1127
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。")
1128
1252
  elif se_command == "/se:apply":
1129
1253
  if phase not in ("bridged", "planned", "implementing", "reviewed", "blocked") and se_command not in allowed_next:
1130
1254
  errors.append("当前状态不允许进入交付,请先完成 /se:bridge 并人工审核 todo.md。")
@@ -1132,6 +1256,10 @@ def validate_se_state(config: dict[str, Any], run_command: str) -> dict[str, Any
1132
1256
  errors.append("/se:propose 后不能直接进入交付,必须先执行 /se:bridge。")
1133
1257
  if not _se_state_artifact_exists(str(artifacts.get("todo", ""))):
1134
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。")
1135
1263
  elif se_command == "/se:review":
1136
1264
  if phase not in ("self_checked", "reviewed", "blocked"):
1137
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
- match_count = sum(len(codebase.get("matches", [])) for codebase in discovery.get("codebases", []))
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
- elif se_command == "/se:propose":
182
- command_propose_openspec(workspace, argument or None)
183
- elif se_command == "/se:bridge":
184
- command_bootstrap_openspec(workspace, explicit_se_bridge=True)
185
- elif se_command == "/se:plan":
186
- command_plan(workspace)
187
- elif se_command == "/se:apply":
188
- command_apply(workspace, timeout_seconds)
189
- elif se_command == "/se:review":
190
- command_review(workspace)
191
- elif se_command == "/se:verify":
192
- command_verify(workspace, timeout_seconds, force)
193
- elif se_command == "/se:archive-check":
194
- command_prepare_archive_openspec(workspace)
195
- elif se_command == "/se:archive":
196
- command_archive_openspec(workspace)
197
- elif se_command == "/se:status":
198
- command_status(workspace)
199
- else:
200
- raise SystemExit(f"不支持的 /se:* 命令:{se_command}")
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:
@@ -473,19 +537,22 @@ def command_apply(workspace: Path | None, timeout_seconds: int) -> None:
473
537
 
474
538
  def main() -> None:
475
539
  parser = argparse.ArgumentParser(description="super-engineer 统一工作流入口。")
476
- 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"])
477
541
  parser.add_argument("change_name", nargs="?", help="配合 propose-openspec 或 validate-state 使用。")
478
542
  parser.add_argument("--command-text", help="配合 route-se 使用,传入完整 /se:* 命令文本。")
479
543
  parser.add_argument("--workspace", help="工作空间路径,默认读取当前目录")
480
544
  parser.add_argument("--timeout-seconds", type=int, default=300)
481
545
  parser.add_argument("--force", action="store_true", help="配合 verify 使用,强制重跑验证并覆盖结果。")
546
+ parser.add_argument("--json", action="store_true", help="输出机器可读摘要。")
482
547
  parser.add_argument("--explicit-se-bridge", action="store_true", help="确认本次 bootstrap-openspec 来自用户显式 /se:bridge 命令。")
483
548
  args = parser.parse_args()
484
549
 
485
550
  workspace = Path(args.workspace).expanduser() if args.workspace else None
486
551
 
487
552
  if args.command == "route-se":
488
- 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)
489
556
  elif args.command == "init":
490
557
  command_init(workspace)
491
558
  elif args.command == "propose-openspec":
@@ -10,6 +10,7 @@ from common import (
10
10
  format_duration,
11
11
  load_workspace_config,
12
12
  now_iso,
13
+ openspec_artifact_hashes,
13
14
  openspec_bridge_context_path,
14
15
  openspec_change_dir,
15
16
  openspec_writeback_dir,
@@ -265,6 +266,7 @@ def main() -> None:
265
266
  if str(status.get("phase", "")).strip() != "done":
266
267
  archive_blockers.append(f"当前状态不是 done:{status.get('phase', '')}")
267
268
 
269
+ mapping = task_mapping(plan, verify, todo_text)
268
270
  payload = {
269
271
  "change_name": str(config.get("openspec", {}).get("change_name", "")),
270
272
  "change_dir": str(openspec_change_dir(config)),
@@ -295,7 +297,8 @@ def main() -> None:
295
297
  "sections": verify.get("sections", []),
296
298
  },
297
299
  "acceptance_result": summarize_acceptance(plan, verify),
298
- "task_mapping": task_mapping(plan, verify, todo_text),
300
+ "task_mapping": mapping,
301
+ "openspec_hashes": openspec_artifact_hashes(config),
299
302
  "spec_impacts": infer_spec_impacts(plan, plan_bridge_context),
300
303
  "residual_risks": residual_risks(plan, review, verify),
301
304
  "manual_decisions": [],
@@ -310,6 +313,7 @@ def main() -> None:
310
313
 
311
314
  output_dir = openspec_writeback_dir(config)
312
315
  write_managed_json(config, output_dir / "execution-summary.json", payload)
316
+ write_managed_json(config, output_dir / "task-mapping.json", {"source": "run-workflow.py writeback-openspec", "items": mapping})
313
317
  write_managed_text(config, output_dir / "execution-summary.md", build_markdown(payload))
314
318
  print(f"writeback_dir={output_dir}")
315
319
  print(f"change_dir={openspec_change_dir(config)}")