@hupan56/wlkj 3.0.0 → 3.1.2

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 (237) hide show
  1. package/bin/cli.js +1435 -1373
  2. package/package.json +1 -1
  3. package/templates/qoder/commands/optional/wl-insight.md +6 -4
  4. package/templates/qoder/commands/optional/wl-report.md +20 -39
  5. package/templates/qoder/commands/optional/wl-status.md +4 -4
  6. package/templates/qoder/commands/wl-code.md +2 -2
  7. package/templates/qoder/commands/wl-design.md +5 -5
  8. package/templates/qoder/commands/wl-init.md +3 -3
  9. package/templates/qoder/commands/wl-prd.md +26 -13
  10. package/templates/qoder/commands/wl-req.md +3 -3
  11. package/templates/qoder/commands/wl-search.md +78 -47
  12. package/templates/qoder/commands/wl-task.md +343 -40
  13. package/templates/qoder/commands/wl-test.md +10 -10
  14. package/templates/qoder/config.yaml +35 -13
  15. package/templates/qoder/hooks/post-tool-use.py +181 -0
  16. package/templates/qoder/hooks/session-start.py +82 -1
  17. package/templates/qoder/hooks/stop-eval.py +207 -0
  18. package/templates/qoder/hooks/user-prompt-submit.py +139 -0
  19. package/templates/qoder/rules/wl-pipeline.md +70 -8
  20. package/templates/qoder/scripts/README.md +78 -115
  21. package/templates/qoder/scripts/capability/__init__.py +26 -0
  22. package/templates/qoder/scripts/capability/__main__.py +72 -0
  23. package/templates/qoder/scripts/capability/adapters/__init__.py +29 -0
  24. package/templates/qoder/scripts/capability/adapters/cli.py +316 -0
  25. package/templates/qoder/scripts/capability/adapters/mcp.py +334 -0
  26. package/templates/qoder/scripts/capability/adapters/qw.py +271 -0
  27. package/templates/qoder/scripts/capability/caps/__init__.py +27 -0
  28. package/templates/qoder/scripts/capability/caps/context.py +36 -0
  29. package/templates/qoder/scripts/capability/caps/cron.py +50 -0
  30. package/templates/qoder/scripts/capability/caps/identity.py +43 -0
  31. package/templates/qoder/scripts/capability/caps/memory.py +71 -0
  32. package/templates/qoder/scripts/capability/caps/notify.py +41 -0
  33. package/templates/qoder/scripts/capability/caps/present.py +48 -0
  34. package/templates/qoder/scripts/capability/caps/repo.py +31 -0
  35. package/templates/qoder/scripts/capability/caps/sandbox.py +43 -0
  36. package/templates/qoder/scripts/capability/chain.py +92 -0
  37. package/templates/qoder/scripts/capability/memory_chain.py +41 -0
  38. package/templates/qoder/scripts/capability/registry.py +246 -0
  39. package/templates/qoder/scripts/capability/registry_mcp.py +250 -0
  40. package/templates/qoder/scripts/capability/smoke_test.py +211 -0
  41. package/templates/qoder/scripts/capability/smoke_test_report.json +94 -0
  42. package/templates/qoder/scripts/{report → deployment/setup}/__init__.py +11 -11
  43. package/templates/qoder/scripts/{setup → deployment/setup}/carriers.py +33 -26
  44. package/templates/qoder/scripts/deployment/setup/carriers_verify.py +148 -0
  45. package/templates/qoder/scripts/{setup → deployment/setup}/init_doctor.py +40 -16
  46. package/templates/qoder/scripts/{setup → deployment/setup}/install_qoderwork.py +20 -12
  47. package/templates/qoder/scripts/{setup → deployment/setup}/platform_doctor.py +8 -2
  48. package/templates/qoder/scripts/{setup → deployment/setup}/repo_root.py +113 -112
  49. package/templates/qoder/scripts/{setup → deployment/setup}/setup.py +31 -11
  50. package/templates/qoder/scripts/{setup → deployment/setup}/setup_lanhu.py +13 -7
  51. package/templates/qoder/scripts/domain/__init__.py +0 -0
  52. package/templates/qoder/scripts/domain/design/__init__.py +0 -0
  53. package/templates/qoder/scripts/{report → domain/design}/fill_prototype.py +12 -6
  54. package/templates/qoder/scripts/{report → domain/design}/gen_design_doc.py +8 -2
  55. package/templates/qoder/scripts/{setup → domain/kg}/__init__.py +11 -11
  56. package/templates/qoder/scripts/domain/kg/build/__init__.py +0 -0
  57. package/templates/qoder/scripts/{kg → domain/kg/build}/build_entity_registry.py +7 -2
  58. package/templates/qoder/scripts/{kg → domain/kg/build}/build_relations.py +7 -2
  59. package/templates/qoder/scripts/{kg → domain/kg/build}/build_style_index.py +13 -7
  60. package/templates/qoder/scripts/{kg → domain/kg/build}/build_workflows.py +7 -2
  61. package/templates/qoder/scripts/{kg → domain/kg/build}/kg_build.py +76 -9
  62. package/templates/qoder/scripts/{kg → domain/kg/build}/kg_build_db.py +7 -2
  63. package/templates/qoder/scripts/{kg → domain/kg/build}/kg_incremental.py +11 -6
  64. package/templates/qoder/scripts/{report → domain/kg/build}/learn_aggregate.py +9 -4
  65. package/templates/qoder/scripts/domain/kg/extract/__init__.py +0 -0
  66. package/templates/qoder/scripts/{common → domain/kg/extract}/extract.py +430 -419
  67. package/templates/qoder/scripts/{common → domain/kg/extract}/test_extract.py +115 -115
  68. package/templates/qoder/scripts/{common → domain/kg/extract}/ts_extract.py +614 -536
  69. package/templates/qoder/scripts/domain/kg/graph/__init__.py +0 -0
  70. package/templates/qoder/scripts/{kg → domain/kg/graph}/kg_link_db.py +7 -2
  71. package/templates/qoder/scripts/domain/kg/graph/kg_semantic.py +683 -0
  72. package/templates/qoder/scripts/{kg → domain/kg}/kg.py +263 -30
  73. package/templates/qoder/scripts/{common → domain/kg}/kg_capabilities.py +182 -182
  74. package/templates/qoder/scripts/domain/kg/search/__init__.py +0 -0
  75. package/templates/qoder/scripts/{kg → domain/kg/search}/context_pack.py +30 -9
  76. package/templates/qoder/scripts/{kg → domain/kg/search}/enrich_prompt.py +10 -4
  77. package/templates/qoder/scripts/{kg → domain/kg/search}/prefetch.py +33 -8
  78. package/templates/qoder/scripts/{common → domain/kg/search}/search_engine.py +207 -205
  79. package/templates/qoder/scripts/{kg → domain/kg/search}/search_index.py +143 -77
  80. package/templates/qoder/scripts/domain/kg/server/__init__.py +0 -0
  81. package/templates/qoder/scripts/domain/kg/server/kgd.py +549 -0
  82. package/templates/qoder/scripts/domain/kg/server/perf_bench.py +197 -0
  83. package/templates/qoder/scripts/domain/kg/storage/__init__.py +0 -0
  84. package/templates/qoder/scripts/{kg → domain/kg/storage}/kg_duckdb.py +38 -14
  85. package/templates/qoder/scripts/domain/learning/__init__.py +0 -0
  86. package/templates/qoder/scripts/{report → domain/learning}/learn.py +8 -3
  87. package/templates/qoder/scripts/{mcp → domain/report}/__init__.py +11 -11
  88. package/templates/qoder/scripts/{report → domain/report}/add_session.py +9 -3
  89. package/templates/qoder/scripts/{report → domain/report}/export.py +10 -5
  90. package/templates/qoder/scripts/{report → domain/report}/report.py +9 -4
  91. package/templates/qoder/scripts/domain/report/report_snapshot.py +261 -0
  92. package/templates/qoder/scripts/{report → domain/report}/role.py +10 -4
  93. package/templates/qoder/scripts/{report → domain/report}/status.py +112 -9
  94. package/templates/qoder/scripts/domain/report/status_snapshot.py +191 -0
  95. package/templates/qoder/scripts/domain/requirement/__init__.py +0 -0
  96. package/templates/qoder/scripts/{report → domain/requirement}/archive_prd.py +8 -2
  97. package/templates/qoder/scripts/{report → domain/requirement}/req.py +228 -222
  98. package/templates/qoder/scripts/{kg → domain/task}/__init__.py +11 -11
  99. package/templates/qoder/scripts/{task → domain/task}/git_sync.py +40 -21
  100. package/templates/qoder/scripts/{task → domain/task}/syncgate.py +13 -8
  101. package/templates/qoder/scripts/{task → domain/task}/task.py +13 -5
  102. package/templates/qoder/scripts/{task → domain/task}/task_lifecycle.py +606 -596
  103. package/templates/qoder/scripts/{task → domain/task}/task_query.py +162 -161
  104. package/templates/qoder/scripts/{task → domain/task}/task_relations.py +425 -424
  105. package/templates/qoder/scripts/{task → domain/task}/team_sync.py +9 -4
  106. package/templates/qoder/scripts/domain/task/zentao_sync.py +370 -0
  107. package/templates/qoder/scripts/foundation/__init__.py +0 -0
  108. package/templates/qoder/scripts/{common → foundation}/bootstrap.py +10 -10
  109. package/templates/qoder/scripts/foundation/core/__init__.py +0 -0
  110. package/templates/qoder/scripts/{common → foundation/core}/cmd_registry.py +1 -1
  111. package/templates/qoder/scripts/{common → foundation/core}/config.py +1 -1
  112. package/templates/qoder/scripts/{common → foundation/core}/paths.py +706 -586
  113. package/templates/qoder/scripts/foundation/data/__init__.py +0 -0
  114. package/templates/qoder/scripts/{common → foundation/data}/contract.py +2 -2
  115. package/templates/qoder/scripts/{common → foundation/data}/events.py +2 -2
  116. package/templates/qoder/scripts/{common → foundation/data}/result.py +1 -1
  117. package/templates/qoder/scripts/{common → foundation/data}/task_utils.py +427 -404
  118. package/templates/qoder/scripts/foundation/identity/__init__.py +0 -0
  119. package/templates/qoder/scripts/{common → foundation/identity}/check_publish.py +103 -98
  120. package/templates/qoder/scripts/{common → foundation/identity}/developer.py +239 -239
  121. package/templates/qoder/scripts/{common → foundation/identity}/guard.py +2 -2
  122. package/templates/qoder/scripts/{common → foundation/identity}/identity.py +14 -10
  123. package/templates/qoder/scripts/{common → foundation/identity}/roles.py +1 -1
  124. package/templates/qoder/scripts/foundation/integrations/__init__.py +0 -0
  125. package/templates/qoder/scripts/{common → foundation/integrations}/active_task.py +30 -15
  126. package/templates/qoder/scripts/{common → foundation/integrations}/feishu.py +2 -2
  127. package/templates/qoder/scripts/{common → foundation/integrations}/terms.py +3 -3
  128. package/templates/qoder/scripts/foundation/integrations/zentao_client.py +220 -0
  129. package/templates/qoder/scripts/foundation/io/__init__.py +0 -0
  130. package/templates/qoder/scripts/{common → foundation/io}/filelock.py +1 -1
  131. package/templates/qoder/scripts/{common → foundation/io}/reqid.py +1 -1
  132. package/templates/qoder/scripts/foundation/protocol/__init__.py +0 -0
  133. package/templates/qoder/scripts/foundation/protocol/duckdb_conn.py +39 -0
  134. package/templates/qoder/scripts/{common → foundation/protocol}/mcp_base.py +1 -1
  135. package/templates/qoder/scripts/foundation/utils/__init__.py +0 -0
  136. package/templates/qoder/scripts/{common → foundation/utils}/eval_api.py +12 -1
  137. package/templates/qoder/scripts/{common → foundation/utils}/pip_install.py +23 -6
  138. package/templates/qoder/scripts/{common → foundation/utils}/platform_guard.py +1 -1
  139. package/templates/qoder/scripts/orchestration/__init__.py +0 -0
  140. package/templates/qoder/scripts/orchestration/wlkj.py +185 -0
  141. package/templates/qoder/scripts/protocol/__init__.py +0 -0
  142. package/templates/qoder/scripts/protocol/browser/README.md +207 -0
  143. package/templates/qoder/scripts/protocol/browser/SKILL.md +265 -0
  144. package/templates/qoder/scripts/protocol/browser/config.env +9 -0
  145. package/templates/qoder/scripts/protocol/browser/references/cdp-api.md +110 -0
  146. package/templates/qoder/scripts/protocol/browser/references/migration-2.5.3.md +72 -0
  147. package/templates/qoder/scripts/protocol/browser/references/site-patterns/.gitkeep +0 -0
  148. package/templates/qoder/scripts/protocol/browser/scripts/browser-discovery.mjs +138 -0
  149. package/templates/qoder/scripts/protocol/browser/scripts/cdp-proxy.mjs +672 -0
  150. package/templates/qoder/scripts/protocol/browser/scripts/check-deps.mjs +206 -0
  151. package/templates/qoder/scripts/protocol/browser/scripts/find-url.mjs +253 -0
  152. package/templates/qoder/scripts/protocol/browser/scripts/match-site.mjs +46 -0
  153. package/templates/qoder/scripts/protocol/browser/templates/config.env.template +9 -0
  154. package/templates/qoder/scripts/protocol/mcp/__init__.py +11 -0
  155. package/templates/qoder/scripts/protocol/mcp/kg_mcp_server.py +478 -0
  156. package/templates/qoder/scripts/{mcp → protocol/mcp}/lanhu_stdio_wrapper.py +11 -5
  157. package/templates/qoder/scripts/{mcp → protocol/mcp}/mcp_doctor.py +47 -14
  158. package/templates/qoder/scripts/protocol/mcp/mcp_launcher.py +600 -0
  159. package/templates/qoder/scripts/{mcp → protocol/mcp}/mysql_mcp_server.py +181 -67
  160. package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +1920 -0
  161. package/templates/qoder/scripts/protocol/transports/__init__.py +56 -0
  162. package/templates/qoder/scripts/protocol/transports/base.py +87 -0
  163. package/templates/qoder/scripts/protocol/transports/cli.py +132 -0
  164. package/templates/qoder/scripts/protocol/transports/http.py +141 -0
  165. package/templates/qoder/scripts/protocol/transports/stdio.py +293 -0
  166. package/templates/qoder/scripts/run_weekly_update.bat +27 -18
  167. package/templates/qoder/scripts/run_weekly_update.sh +22 -13
  168. package/templates/qoder/scripts/validation/__init__.py +0 -0
  169. package/templates/qoder/scripts/validation/eval/__init__.py +0 -0
  170. package/templates/qoder/scripts/validation/eval/perf-report-2026-06-24.md +139 -0
  171. package/templates/qoder/scripts/validation/eval/qwork_harness.py +617 -0
  172. package/templates/qoder/scripts/validation/eval/report-commands.md +220 -0
  173. package/templates/qoder/scripts/validation/eval/transcript_timing.py +386 -0
  174. package/templates/qoder/scripts/validation/metrics/__init__.py +0 -0
  175. package/templates/qoder/scripts/{report → validation/metrics}/eval_prd.py +17 -10
  176. package/templates/qoder/scripts/validation/metrics/usability_score.py +750 -0
  177. package/templates/qoder/scripts/validation/test/__init__.py +11 -0
  178. package/templates/qoder/scripts/validation/test/assertion_gen.py +551 -0
  179. package/templates/qoder/scripts/{test → validation/test}/autotest.py +64 -4
  180. package/templates/qoder/scripts/{common → validation/test}/autotest_auth.py +1 -1
  181. package/templates/qoder/scripts/{test → validation/test}/autotest_batch.py +16 -3
  182. package/templates/qoder/scripts/{test → validation/test}/autotest_data.py +17 -12
  183. package/templates/qoder/scripts/validation/test/autotest_opencli.py +646 -0
  184. package/templates/qoder/scripts/{test → validation/test}/autotest_run.py +17 -3
  185. package/templates/qoder/scripts/validation/test/autotest_webaccess.py +571 -0
  186. package/templates/qoder/scripts/{test → validation/test}/benchmark.py +12 -6
  187. package/templates/qoder/scripts/{test → validation/test}/kg_auto_login.py +8 -2
  188. package/templates/qoder/scripts/{test → validation/test}/kg_test_runner.py +7 -2
  189. package/templates/qoder/scripts/{test → validation/test}/page_probe.py +8 -3
  190. package/templates/qoder/scripts/validation/test/smoke_all_commands.py +170 -0
  191. package/templates/qoder/settings.json +30 -0
  192. package/templates/qoder/skills/design-import/SKILL.md +22 -13
  193. package/templates/qoder/skills/design-review/SKILL.md +19 -10
  194. package/templates/qoder/skills/prd-generator/SKILL.md +22 -18
  195. package/templates/qoder/skills/prd-review/SKILL.md +6 -4
  196. package/templates/qoder/skills/prototype-generator/SKILL.md +63 -14
  197. package/templates/qoder/skills/spec-coder/SKILL.md +22 -13
  198. package/templates/qoder/skills/spec-generator/SKILL.md +22 -10
  199. package/templates/qoder/skills/test-generator/SKILL.md +7 -5
  200. package/templates/qoder/skills/wl-code/SKILL.md +23 -11
  201. package/templates/qoder/skills/wl-commit/SKILL.md +9 -8
  202. package/templates/qoder/skills/wl-design/SKILL.md +5 -3
  203. package/templates/qoder/skills/wl-init/SKILL.md +6 -6
  204. package/templates/qoder/skills/wl-insight/SKILL.md +23 -10
  205. package/templates/qoder/skills/wl-prd-full/SKILL.md +57 -6
  206. package/templates/qoder/skills/wl-prd-quick/SKILL.md +3 -3
  207. package/templates/qoder/skills/wl-prd-review/SKILL.md +16 -4
  208. package/templates/qoder/skills/wl-report/SKILL.md +25 -23
  209. package/templates/qoder/skills/wl-search/SKILL.md +149 -78
  210. package/templates/qoder/skills/wl-spec/SKILL.md +7 -5
  211. package/templates/qoder/skills/wl-status/SKILL.md +48 -16
  212. package/templates/qoder/skills/wl-task/SKILL.md +92 -10
  213. package/templates/qoder/skills/wl-test/SKILL.md +75 -29
  214. package/templates/qoder/templates/prd-full-template.md +7 -0
  215. package/templates/root/AGENTS.md +275 -236
  216. package/templates/root/requirements.txt +3 -0
  217. package/templates/root//344/275/277/347/224/250/350/257/264/346/230/216.md +3 -3
  218. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +2 -2
  219. package/templates/.qoder/.runtime/ctx-cache-5660152f1d6dd819.md +0 -23
  220. package/templates/.qoder/.runtime/ctx-cache-afdce0dac06b25b0.md +0 -23
  221. package/templates/.qoder/.runtime/search-cache-eae7644e7b122f35.txt +0 -1
  222. package/templates/.qoder/learning/eval-history.jsonl +0 -28
  223. package/templates/data/index/wiki-index.json +0 -8
  224. package/templates/qoder/nul +0 -4
  225. package/templates/qoder/scripts/kg/extract.py +0 -398
  226. package/templates/qoder/scripts/kg/kg_semantic.py +0 -156
  227. package/templates/qoder/scripts/mcp/kg_mcp_server.py +0 -606
  228. package/templates/qoder/scripts/mcp/mcp_launcher.py +0 -442
  229. package/templates/qoder/scripts/mcp/zentao_mcp_server.py +0 -384
  230. package/templates/qoder/scripts/task/__init__.py +0 -11
  231. package/templates/qoder/scripts/test/__init__.py +0 -11
  232. package/templates/qoder/scripts/wlkj.py +0 -116
  233. /package/templates/qoder/scripts/{common → deployment}/__init__.py +0 -0
  234. /package/templates/qoder/scripts/{common → domain/kg/graph}/graph_traverse.py +0 -0
  235. /package/templates/qoder/scripts/{common → domain/kg/server}/repowiki.py +0 -0
  236. /package/templates/qoder/scripts/{common → foundation/io}/atomicio.py +0 -0
  237. /package/templates/qoder/scripts/{test → validation/test}/secure-ls.js +0 -0
@@ -0,0 +1,617 @@
1
+ """
2
+ qwork_harness.py — QoderWork 命令自动化测试框架 (P2)
3
+
4
+ 走完整个流程: 注入命令 → 轮询 transcript → 遇到 AskUserQuestion 自动代答
5
+ → 等 turn.finished → 调 transcript_timing 出耗时报告
6
+
7
+ 设计要点:
8
+ - 真相源是 transcript jsonl (实时追加), 不靠猜 DOM
9
+ - AskUserQuestion 的 question+options 全在 tool.requested.data.args 里
10
+ - 代答策略: 关键词匹配 option.label/description, 选最"全"的那个
11
+ (如 "整体/context/全貌/平台/默认" 等), 也可 --auto-answer=1 指定
12
+ - 代答回传: 优先点击渲染的选项 DOM; 兜底注入 label 文本 + Enter
13
+
14
+ 用法:
15
+ python qwork_harness.py "/wl-search 考勤"
16
+ python qwork_harness.py "/wl-search 考勤" --auto-answer=0 # 选第1个
17
+ python qwork_harness.py "/wl-search 考勤" --no-answer # 只跑不等答
18
+ python qwork_harness.py "/wl-prd-full 用户管理" --auto-answer=1
19
+
20
+ 依赖: websocket-client (pip install websocket-client)
21
+ """
22
+ import json
23
+ import os
24
+ import re
25
+ import sys
26
+ import time
27
+ import glob
28
+ import urllib.request
29
+
30
+ try:
31
+ import websocket
32
+ except ImportError:
33
+ print("[FATAL] 缺 websocket-client, 请: pip install websocket-client")
34
+ sys.exit(2)
35
+
36
+ # 复用 P1 计时引擎
37
+ _HERE = os.path.dirname(os.path.abspath(__file__))
38
+ sys.path.insert(0, _HERE)
39
+ import transcript_timing as tt # noqa: E402
40
+
41
+ # ── CDP 连接 ────────────────────────────────────────────────────────
42
+ CDP_PORT = 6529
43
+
44
+
45
+ def _get_qwork_ws(port=CDP_PORT):
46
+ d = json.loads(urllib.request.urlopen(f"http://127.0.0.1:{port}/json", timeout=3).read())
47
+ for t in d:
48
+ if t.get("type") == "page" and "QoderWork" in t.get("title", ""):
49
+ return t["webSocketDebuggerUrl"]
50
+ raise RuntimeError("没找到 QoderWork 页面 (CDP 6529). QoderWork 开着吗?")
51
+
52
+
53
+ class CDP:
54
+ def __init__(self, ws_url):
55
+ self.ws = websocket.create_connection(ws_url, timeout=15)
56
+ self.mid = 0
57
+
58
+ def call(self, method, params=None, timeout=15):
59
+ self.mid += 1
60
+ self.ws.send(json.dumps({"id": self.mid, "method": method, "params": params or {}}))
61
+ end = time.time() + timeout
62
+ while time.time() < end:
63
+ r = json.loads(self.ws.recv())
64
+ if r.get("id") == self.mid:
65
+ return r
66
+ return {}
67
+
68
+ def eval(self, expr, timeout=15):
69
+ r = self.call("Runtime.evaluate", {"expression": expr, "returnByValue": True}, timeout)
70
+ body = r.get("result", {})
71
+ if "exceptionDetails" in body:
72
+ return {"ERR": str(body["exceptionDetails"])[:200]}
73
+ res = body.get("result", {})
74
+ return res.get("value", res)
75
+
76
+ def close(self):
77
+ try:
78
+ self.ws.close()
79
+ except Exception:
80
+ pass
81
+
82
+
83
+ # ── 输入框操作 ──────────────────────────────────────────────────────
84
+ def focus_input(cdp):
85
+ return cdp.eval(r"""(() => {
86
+ const e = document.querySelector('[contenteditable="true"]');
87
+ if(!e) return 'NO_INPUT';
88
+ e.focus();
89
+ const sel=window.getSelection(); const range=document.createRange();
90
+ range.selectNodeContents(e); range.collapse(false);
91
+ sel.removeAllRanges(); sel.addRange(range);
92
+ return 'ok';
93
+ })()""")
94
+
95
+
96
+ def inject_text(cdp, text):
97
+ """CDP Input.insertText — 模拟真实键盘输入, 框架必识别。"""
98
+ cdp.call("Input.insertText", {"text": text}, timeout=8)
99
+ time.sleep(0.5)
100
+
101
+
102
+ def submit_enter(cdp):
103
+ """CDP 派发 Enter 键提交。"""
104
+ for et in ("rawKeyDown", "keyDown", "keyUp"):
105
+ cdp.call("Input.dispatchKeyEvent", {
106
+ "type": et, "key": "Enter", "code": "Enter", "windowsVirtualKeyCode": 13,
107
+ }, timeout=5)
108
+ time.sleep(0.05)
109
+
110
+
111
+ def get_input_text(cdp):
112
+ return cdp.eval(r"""document.querySelector('[contenteditable="true"]')?.textContent || ''""")
113
+
114
+
115
+ # ── transcript 实时读取 ─────────────────────────────────────────────
116
+ def _find_session_jsonl():
117
+ """找当前活跃 session 最新的 jsonl (取 mtime 最新且较大的)。"""
118
+ base = os.path.expanduser(r"~/.qoderwork/logs/sessions")
119
+ if not os.path.isdir(base):
120
+ return None
121
+ allf = []
122
+ for p in glob.glob(os.path.join(base, "**", "*.jsonl"), recursive=True):
123
+ try:
124
+ allf.append((os.path.getmtime(p), os.path.getsize(p), p))
125
+ except OSError:
126
+ continue
127
+ if not allf:
128
+ return None
129
+ allf.sort(reverse=True)
130
+ # 取最新且 >5KB 的(活跃会话)
131
+ for mt, sz, p in allf:
132
+ if sz > 5000:
133
+ return p
134
+ return allf[0][2]
135
+
136
+
137
+ # ── 系统/后台 turn 识别 ─────────────────────────────────────────────
138
+ # QoderWork 每条用户命令跑完后, 会自动追加一条 [SYSTEM: automated background
139
+ # review] 后台审核 turn。它的 prompt 文本含这些标记。harness 必须跳过它们,
140
+ # 否则 baseline+1 计数会被后台 turn 干扰, 提前误判"命令完成"。
141
+ _SYSTEM_MARKERS = (
142
+ "automated background review",
143
+ "must be fulfilled using",
144
+ "[system:",
145
+ "this request must be fulfilled",
146
+ )
147
+
148
+
149
+ def _is_system_prompt(text):
150
+ """判断 prompt 文本是否为系统/后台任务(非用户命令)。"""
151
+ if not text:
152
+ return False
153
+ low = text.lower()
154
+ return any(m in low for m in _SYSTEM_MARKERS)
155
+
156
+
157
+ def _ui_is_busy(cdp):
158
+ """检测 QoderWork 是否正忙(有任务在跑或有队列)。
159
+ 双重判定: ① UI 状态文字 ② transcript 的 turn 配对(started>finished)。
160
+ 返回 (busy:bool, detail:str)。
161
+ """
162
+ # ① UI 状态文字
163
+ res = cdp.eval(r"""(() => {
164
+ const txts = [...document.querySelectorAll('div,span')]
165
+ .filter(e => e.offsetParent !== null && e.children.length === 0)
166
+ .map(e => (e.textContent || '').trim())
167
+ .filter(t => t && t.length < 30);
168
+ const joined = txts.join('|');
169
+ const busy = /处理中|执行中|running/i.test(joined);
170
+ const queue = (txts.find(t => /\d+\s*个队列任务/.test(t))) || '';
171
+ return {busy, queue};
172
+ })()""")
173
+ ui_busy = False
174
+ detail = ""
175
+ if isinstance(res, dict):
176
+ ui_busy = bool(res.get("busy") or res.get("queue"))
177
+ detail = res.get("queue", "") or ("处理中" if res.get("busy") else "")
178
+ # ② transcript turn 配对(更可靠的兜底)
179
+ jsonl = _find_session_jsonl()
180
+ if jsonl:
181
+ ev = read_events(jsonl)
182
+ ns = sum(1 for e in ev if e.get("type") == "turn.started")
183
+ nf = sum(1 for e in ev if e.get("type") == "turn.finished")
184
+ if ns > nf:
185
+ ui_busy = True
186
+ detail = detail or f"turn未配对(started={ns}>finished={nf})"
187
+ return ui_busy, detail
188
+
189
+
190
+ def read_events(path):
191
+ """读 jsonl 全部事件。"""
192
+ if not path or not os.path.exists(path):
193
+ return []
194
+ events = []
195
+ with open(path, "r", encoding="utf-8") as f:
196
+ for line in f:
197
+ line = line.strip()
198
+ if not line:
199
+ continue
200
+ try:
201
+ events.append(json.loads(line))
202
+ except json.JSONDecodeError:
203
+ continue
204
+ return events
205
+
206
+
207
+ def _ts_of(event):
208
+ return tt._parse_ts(event.get("ts"))
209
+
210
+
211
+ # ── AskUserQuestion 代答逻辑 ────────────────────────────────────────
212
+ # AskUserQuestion 在 transcript 里的真实生命周期 (经实测 seq 验证):
213
+ # tool.requested (tool_name=="AskUserQuestion", args.questions=完整问题)
214
+ # permission.requested(同上, UI 此时渲染选项卡) ← 在此代答(点 DOM)
215
+ # permission.resolved (updated_args.answers 已填) ← 表示用户/AI 已答
216
+ # tool.execution.finished
217
+ # 一个 AskUserQuestion 可含多个 question, 每个都要答; multiSelect 可多选。
218
+ ANSWER_TOOL = "AskUserQuestion"
219
+
220
+ # 关键词优先级: 出现这些词的 option 优先选 (信息最全/最安全)
221
+ PREFER_KEYWORDS = [
222
+ "整体", "context", "全貌", "全部", "概览", # 搜索类:选最全的
223
+ "web", "管理端", "pc", 1, # 平台: 默认 web
224
+ "默认", "推荐", "recommended",
225
+ "是", "确认", "继续", "ok", "yes",
226
+ ]
227
+ AVOID_KEYWORDS = ["取消", "否", "不", "cancel", "no", "停止", "abort"]
228
+
229
+
230
+ def pick_answer_option(question_obj, force_index=None):
231
+ """从 AskUserQuestion 的单个 question 里选一个 option。
232
+ question_obj = {question, options:[{label,description}]}
233
+ 返回 (index, label)。
234
+ """
235
+ opts = question_obj.get("options", [])
236
+ if not opts:
237
+ return None, None
238
+ if force_index is not None and 0 <= force_index < len(opts):
239
+ return force_index, opts[force_index].get("label", "")
240
+ # 关键词打分
241
+ best_i, best_score = 0, -1
242
+ for i, o in enumerate(opts):
243
+ text = (o.get("label", "") + " " + o.get("description", "")).lower()
244
+ score = 0
245
+ for kw in PREFER_KEYWORDS:
246
+ if str(kw).lower() in text:
247
+ score += 2
248
+ for kw in AVOID_KEYWORDS:
249
+ if str(kw).lower() in text:
250
+ score -= 3
251
+ # 默认偏向第一个(通常是推荐项)
252
+ if i == 0:
253
+ score += 1
254
+ if score > best_score:
255
+ best_score = score
256
+ best_i = i
257
+ return best_i, opts[best_i].get("label", "")
258
+
259
+
260
+ def find_unanswered_questions(events, baseline_turns, already_seqs):
261
+ """从 transcript 事件里找出"已渲染但未答"的 AskUserQuestion。
262
+
263
+ 判定规则(实测 seq 验证):
264
+ - 定位 permission.requested (tool_name==AskUserQuestion): UI 此时已渲染选项
265
+ - 同 turn 窗口内若已有 permission.resolved(updated_args.answers 非空) → 已答, 跳过
266
+ - 用 seq 去重; 已在 already_seqs 里的不再返回
267
+
268
+ Returns: [{seq, questions:[...]}] 按 seq 升序
269
+ """
270
+ # 1. 收集 AskUserQuestion 的 permission.requested (seq, questions)
271
+ asks = []
272
+ for e in events:
273
+ d = e.get("data", {}) or {}
274
+ if e.get("type") != "permission.requested":
275
+ continue
276
+ if d.get("tool_name") != ANSWER_TOOL:
277
+ continue
278
+ seq = e.get("seq")
279
+ if seq is None or seq in already_seqs:
280
+ continue
281
+ questions = (d.get("args", {}) or {}).get("questions", [])
282
+ if questions:
283
+ asks.append((seq, e.get("ts"), questions))
284
+
285
+ if not asks:
286
+ return []
287
+
288
+ # 2. 收集已 resolved 的 (用 updated_args.answers 非空作为"已答"信号)
289
+ resolved_seqs = set()
290
+ for e in events:
291
+ d = e.get("data", {}) or {}
292
+ if e.get("type") != "permission.resolved":
293
+ continue
294
+ if d.get("tool_name") != ANSWER_TOOL:
295
+ continue
296
+ upd = d.get("updated_args", {}) or {}
297
+ if upd.get("answers"): # 有答案 = 真正答了
298
+ resolved_seqs.add(e.get("seq"))
299
+
300
+ # permission.resolved 的 seq 比 permission.requested 大 1~2;
301
+ # 用 questions 文本指纹匹配更稳: 已 resolved 的 question 文本集合
302
+ answered_qtexts = set()
303
+ for e in events:
304
+ if e.get("type") != "permission.resolved":
305
+ continue
306
+ d = e.get("data", {}) or {}
307
+ if d.get("tool_name") != ANSWER_TOOL:
308
+ continue
309
+ upd = d.get("updated_args", {}) or {}
310
+ for k in (upd.get("answers") or {}).keys():
311
+ answered_qtexts.add(k.strip())
312
+
313
+ out = []
314
+ for seq, ts, questions in asks:
315
+ # 本 AskUserQuestion 的所有 question 是否都已被答(resolved)
316
+ qtexts = {(q.get("question") or "").strip() for q in questions}
317
+ if qtexts and qtexts <= answered_qtexts:
318
+ continue
319
+ out.append({"seq": seq, "ts": ts, "questions": questions})
320
+ return out
321
+
322
+
323
+ def answer_via_dom(cdp, question_obj, chosen_label):
324
+ """策略A: 点击 DOM 里渲染的选项卡 (按 label 文字匹配)。
325
+ QoderWork 选项卡结构: <BUTTON class="w-full flex..."> 内含序号+label+description。
326
+ 必须点 BUTTON 本身(点内层 span/div 不触发 onClick), 且优先精确匹配。
327
+ 返回 True 如果成功点到。"""
328
+ js = r"""((label) => {
329
+ const target = label.replace(/\s+/g,' ').trim().toLowerCase();
330
+ // 第1轮: 优先 <button>, 文本含 label 即可(文本带序号前缀如"2新系统...")
331
+ const buttons = [...document.querySelectorAll('button')].filter(b => b.offsetParent !== null);
332
+ for(const el of buttons){
333
+ const txt = (el.textContent||'').replace(/\s+/g,' ').trim();
334
+ if(txt.length > 200) continue; // 排除消息体大按钮
335
+ if(txt.toLowerCase().includes(target)){
336
+ el.scrollIntoView({block:'center'});
337
+ el.click();
338
+ return {clicked:true, via:'button', txt:txt.slice(0,50)};
339
+ }
340
+ }
341
+ // 第2轮: 退而求其次, 任何 pointer 元素 (div/span) 文本精确等于 label
342
+ const cands = [...document.querySelectorAll('div,span,[role="button"],[role="option"]')]
343
+ .filter(e => e.offsetParent !== null);
344
+ for(const el of cands){
345
+ const cs = getComputedStyle(el);
346
+ if(cs.cursor !== 'pointer') continue;
347
+ const txt = (el.textContent||'').replace(/\s+/g,' ').trim();
348
+ if(!txt || txt.length > 80) continue;
349
+ if(txt.toLowerCase() === target){
350
+ el.scrollIntoView({block:'center'});
351
+ el.click();
352
+ return {clicked:true, via:'pointer', txt:txt.slice(0,50)};
353
+ }
354
+ }
355
+ return {clicked:false};
356
+ })(""" + json.dumps(chosen_label) + r""")"""
357
+ res = cdp.eval(js)
358
+ return isinstance(res, dict) and res.get("clicked")
359
+
360
+
361
+ def answer_via_text(cdp, chosen_label, question_obj):
362
+ """策略B(兜底): 把 option.label 当回复文本注入 + Enter。"""
363
+ focus_input(cdp)
364
+ time.sleep(0.3)
365
+ # 清空已有内容
366
+ cdp.eval(r"""(() => {
367
+ const e=document.querySelector('[contenteditable="true"]');
368
+ if(e){ e.focus(); e.textContent=''; }
369
+ return true;
370
+ })()""")
371
+ time.sleep(0.2)
372
+ inject_text(cdp, chosen_label)
373
+ time.sleep(0.3)
374
+ submit_enter(cdp)
375
+ return True
376
+
377
+
378
+ # ── 主流程 ──────────────────────────────────────────────────────────
379
+ def run(command, force_answer=None, timeout=600, no_answer=False, verbose=True):
380
+ """跑一条命令到完成。
381
+ Args:
382
+ command: "/wl-search 考勤"
383
+ force_answer: int|None, 强制选项序号(0-based)
384
+ timeout: 最大等待秒数
385
+ no_answer: True 则不自动代答(遇到提问就停, 返回待答信息)
386
+ verbose: 打印过程
387
+ Returns dict: {status, report, turns}
388
+ """
389
+ def log(*a):
390
+ if verbose:
391
+ print(*a)
392
+
393
+ log(f"[harness] 连接 CDP...")
394
+ cdp = CDP(_get_qwork_ws())
395
+ log("[harness] 已连上 QoderWork")
396
+
397
+ # 命令清洗: Git bash(MSYS)会把 /wl-xxx 误转成 Windows 路径
398
+ # 如 "/wl-search 考勤" → "D:/.../Git/wl-search 考勤" (斜杠被吃, Git/wl-search)
399
+ # 检测: 命令不以 /wl- 开头, 但内部有 wl- 词 → 截到 wl- 前补斜杠。
400
+ stripped = command.lstrip()
401
+ if not stripped.startswith("/wl-") and "wl-" in stripped:
402
+ m = re.search(r"(^|[/\\])(wl-[a-z][a-z-]*)", stripped)
403
+ if m:
404
+ # 保留 wl-xxx 及之后的所有参数(参数在第一个空格后, 未被污染)
405
+ head = "/" + m.group(2)
406
+ # 找原始命令第一个空格后的参数部分(参数含中文, 不会被 MSYS 转)
407
+ sp = stripped.find(" ")
408
+ tail = stripped[sp:] if sp > 0 else ""
409
+ clean = head + tail
410
+ log(f"[harness] 命令清洗(MSYS修复): {command!r} → {clean!r}")
411
+ command = clean
412
+
413
+ # 0. 注入前等待 QoderWork 空闲(防队列堆积)
414
+ wait_start = time.time()
415
+ while time.time() - wait_start < 120:
416
+ busy, detail = _ui_is_busy(cdp)
417
+ if not busy:
418
+ break
419
+ if int(time.time() - wait_start) % 10 < 2:
420
+ log(f"[harness] 等待 QoderWork 空闲... ({detail})")
421
+ time.sleep(3)
422
+ else:
423
+ log("[harness] ⚠ QoderWork 120s 仍忙碌, 强行注入(可能进队列)")
424
+ log("[harness] QoderWork 空闲, 开始注入")
425
+
426
+ # 1. 注入命令
427
+ log(f"[harness] 注入命令: {command}")
428
+ focus_input(cdp)
429
+ time.sleep(0.3)
430
+ inject_text(cdp, command)
431
+ entered = get_input_text(cdp)
432
+ if command.strip().split()[0] not in str(entered):
433
+ log(f"[harness][ERR] 注入失败, 输入框={entered!r}")
434
+ cdp.close()
435
+ return {"status": "inject_failed", "input": entered}
436
+ log("[harness] 注入成功, 提交...")
437
+ submit_enter(cdp)
438
+
439
+ start_time = time.time()
440
+
441
+ # 注入后重新锁定 session: 注入可能创建新 jsonl, 必须跟那个文件走。
442
+ # 等 2s 让 QoderWork 落盘, 再取当前最新 session 作为本轮监控目标。
443
+ time.sleep(2)
444
+ monitor_jsonl = _find_session_jsonl()
445
+ baseline_events = read_events(monitor_jsonl) if monitor_jsonl else []
446
+ # 记录注入前的 prompt 总数, 用于识别"我这条命令的 prompt"
447
+ baseline_user_prompts = sum(
448
+ 1 for e in baseline_events
449
+ if e.get("type") == "input.prompt.submitted"
450
+ and not _is_system_prompt((e.get("data", {}) or {}).get("text_preview", ""))
451
+ )
452
+ log(f"[harness] 锁定 session: {os.path.basename(monitor_jsonl or '')} | 基线user-prompt={baseline_user_prompts}")
453
+
454
+ answered_questions = []
455
+ new_turn_finished = False
456
+ my_prompt_ts = None # 我这条命令的 prompt.submitted 时间戳
457
+ answered_req_seqs = set() # 已代答/已答的 tool.requested seq
458
+
459
+ # 2. 轮询 transcript
460
+ while time.time() - start_time < timeout:
461
+ time.sleep(2)
462
+ elapsed = int(time.time() - start_time)
463
+
464
+ # ── session 切换保护 ─────────────────────────────
465
+ # QoderWork 后台记忆任务可能开新 session jsonl, 导致锁定文件不再更新。
466
+ # 检测到更新的 session 文件就切过去跟踪。
467
+ cur_latest = _find_session_jsonl()
468
+ if cur_latest and cur_latest != monitor_jsonl:
469
+ try:
470
+ mt_locked = os.path.getmtime(monitor_jsonl) if monitor_jsonl else 0
471
+ mt_latest = os.path.getmtime(cur_latest)
472
+ if mt_latest > mt_locked + 5: # 新文件明显更新
473
+ log(f"[harness][{elapsed}s] 检测到 session 切换: "
474
+ f"{os.path.basename(monitor_jsonl or '')[:16]} → {os.path.basename(cur_latest)[:16]}")
475
+ monitor_jsonl = cur_latest
476
+ base_ev = read_events(monitor_jsonl)
477
+ baseline_user_prompts = sum(
478
+ 1 for e in base_ev
479
+ if e.get("type") == "input.prompt.submitted"
480
+ and not _is_system_prompt((e.get("data", {}) or {}).get("text_preview", "")))
481
+ my_prompt_ts = None # 重新锁定我的命令
482
+ except OSError:
483
+ pass
484
+
485
+ jsonl = monitor_jsonl
486
+ if not jsonl or not os.path.exists(jsonl):
487
+ jsonl = _find_session_jsonl()
488
+ if not jsonl:
489
+ continue
490
+ events = read_events(jsonl)
491
+
492
+ # ── 检测 AskUserQuestion (未答的) ─────────────────
493
+ if not no_answer:
494
+ pending = find_unanswered_questions(events, baseline_user_prompts, answered_req_seqs)
495
+ for info in pending:
496
+ seq = info["seq"]
497
+ if seq in answered_req_seqs:
498
+ continue
499
+ q0 = info["questions"][0]
500
+ qtxt = q0.get("question", "")
501
+ # 同问题文本不重复代答
502
+ if any(aq.get("question") == qtxt for aq in answered_questions):
503
+ answered_req_seqs.add(seq)
504
+ continue
505
+
506
+ idx, label = pick_answer_option(q0, force_answer)
507
+ log(f"[harness][{elapsed}s] AI提问(seq={seq}): {qtxt}")
508
+ log(f"[harness][{elapsed}s] 选项: {[o.get('label','') for o in q0.get('options',[])]}")
509
+ log(f"[harness][{elapsed}s] 代答: #{idx} {label!r}")
510
+
511
+ # 策略A: 点 DOM 选项
512
+ ok = answer_via_dom(cdp, q0, label)
513
+ if not ok:
514
+ log(f"[harness][{elapsed}s] DOM点击失败, 兜底注入文本...")
515
+ answer_via_text(cdp, label, q0)
516
+ answered_questions.append({"question": qtxt, "answer_index": idx,
517
+ "answer_label": label})
518
+ answered_req_seqs.add(seq)
519
+ time.sleep(2)
520
+
521
+ # ── 定位"我这条命令"的 prompt.submitted ──────────
522
+ if my_prompt_ts is None:
523
+ user_prompts = [(e.get("ts"), (e.get("data", {}) or {}).get("text_preview", ""))
524
+ for e in events
525
+ if e.get("type") == "input.prompt.submitted"
526
+ and not _is_system_prompt((e.get("data", {}) or {}).get("text_preview", ""))]
527
+ if len(user_prompts) > baseline_user_prompts:
528
+ # 取第 (baseline+1) 条用户 prompt = 我注入的
529
+ my_prompt_ts = user_prompts[baseline_user_prompts][0]
530
+ log(f"[harness][{elapsed}s] 锁定我的命令 prompt: {my_prompt_ts}")
531
+
532
+ # ── 检测 turn.finished (只认我的命令对应的那个) ──
533
+ if my_prompt_ts is not None:
534
+ # 找 my_prompt_ts 之后的 turn.finished (跳过系统后台 turn)
535
+ # 系统 turn 的特征: 它前面紧跟的 prompt 是系统类
536
+ for e in events:
537
+ if e.get("type") != "turn.finished":
538
+ continue
539
+ if my_prompt_ts and e.get("ts", "") < my_prompt_ts:
540
+ continue # 早于我的命令, 忽略
541
+ # 判断这个 turn.finished 是否归属"用户命令"(非系统后台):
542
+ # 取本 turn 窗口内最近的 prompt.submitted, 若是系统类则跳过
543
+ prompt_before = None
544
+ for pe in events:
545
+ if pe.get("type") != "input.prompt.submitted":
546
+ continue
547
+ if pe.get("ts", "") > e.get("ts", ""):
548
+ break
549
+ prompt_before = pe
550
+ if prompt_before and _is_system_prompt(
551
+ (prompt_before.get("data", {}) or {}).get("text_preview", "")):
552
+ continue # 这是后台 review turn, 跳过
553
+ # 归属我的命令 → 完成
554
+ new_turn_finished = True
555
+ dur = (e.get("data", {}) or {}).get("duration_ms")
556
+ log(f"[harness][{elapsed}s] ✓ 我的命令 turn.finished! (dur={dur}ms)")
557
+ break
558
+
559
+ if new_turn_finished:
560
+ break # 命令已完成, 退出轮询
561
+
562
+ # 状态提示 (每 ~6s 一次, 不刷屏)
563
+ cur_turns = sum(1 for e in events if e.get("type") == "turn.finished")
564
+ if elapsed % 6 < 2:
565
+ log(f"[harness][{elapsed}s] 运行中... (events={len(events)}, "
566
+ f"total_turns={cur_turns}, 已答提问={len(answered_questions)})")
567
+
568
+ cdp.close()
569
+
570
+ if not new_turn_finished:
571
+ log(f"[harness] ⚠ 超时 {timeout}s, 未等到 turn.finished")
572
+ return {"status": "timeout", "timeout_s": timeout,
573
+ "answered": answered_questions, "jsonl": monitor_jsonl}
574
+
575
+ # 3. 算耗时报告
576
+ jsonl = monitor_jsonl or _find_session_jsonl()
577
+ log(f"[harness] 出报告: {os.path.basename(jsonl)}")
578
+ report = tt.analyze(jsonl)
579
+ return {"status": "completed", "report": report, "answered": answered_questions,
580
+ "jsonl": jsonl, "elapsed_s": round(time.time() - start_time, 1)}
581
+
582
+
583
+ def main():
584
+ import argparse
585
+ ap = argparse.ArgumentParser()
586
+ ap.add_argument("command", help='要跑的命令, 如 "/wl-search 考勤"')
587
+ ap.add_argument("--auto-answer", type=int, default=None,
588
+ help="强制选项序号(0-based), 如 --auto-answer=0")
589
+ ap.add_argument("--no-answer", action="store_true", help="不自动代答")
590
+ ap.add_argument("--timeout", type=int, default=600)
591
+ ap.add_argument("--json", action="store_true")
592
+ args = ap.parse_args()
593
+
594
+ result = run(args.command, force_answer=args.auto_answer,
595
+ timeout=args.timeout, no_answer=args.no_answer)
596
+
597
+ if args.json:
598
+ print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
599
+ else:
600
+ print("\n" + "=" * 60)
601
+ if result.get("status") == "completed":
602
+ print("✅ 完成! 耗时报告:")
603
+ print("=" * 60)
604
+ print(tt._format_report(result["report"]))
605
+ print("\n自动代答:")
606
+ for a in result.get("answered", []):
607
+ print(f" Q: {a['question']}")
608
+ print(f" A: #{a['answer_index']} {a['answer_label']}")
609
+ print(f"\n实际墙钟: {result.get('elapsed_s')}s")
610
+ elif result.get("status") == "timeout":
611
+ print(f"⏰ 超时 {result.get('timeout_s')}s")
612
+ else:
613
+ print(f"❌ {result.get('status')}: {result}")
614
+
615
+
616
+ if __name__ == "__main__":
617
+ main()