codebee 0.1.95 → 0.1.97

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 (144) hide show
  1. package/CHANGELOG.md +733 -720
  2. package/LICENSE +21 -21
  3. package/README.md +516 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +118 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +246 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +2055 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +648 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mcp_client.py +200 -29
  46. package/app/core/mocks.py +64 -64
  47. package/app/core/notify.py +201 -201
  48. package/app/core/novel_quality.py +181 -189
  49. package/app/core/openapi.py +177 -154
  50. package/app/core/operations.py +228 -228
  51. package/app/core/paihang.py +145 -145
  52. package/app/core/pipeline.py +319 -59
  53. package/app/core/planner.py +17 -11
  54. package/app/core/policy.py +84 -0
  55. package/app/core/portguard.py +111 -111
  56. package/app/core/presence.py +77 -77
  57. package/app/core/preview.py +293 -293
  58. package/app/core/project_memory.py +139 -0
  59. package/app/core/prompt_guard.py +78 -78
  60. package/app/core/publish/__init__.py +7 -7
  61. package/app/core/publish/auto.py +106 -22
  62. package/app/core/publish/browser.py +694 -655
  63. package/app/core/publish/chaprows.py +138 -0
  64. package/app/core/publish/fanqie.py +212 -165
  65. package/app/core/publish/flow.py +832 -640
  66. package/app/core/publish/flows-fanqie-calibrated.json +361 -336
  67. package/app/core/publish/ledger.py +51 -2
  68. package/app/core/publish/manager.py +1150 -1118
  69. package/app/core/publish/qimao.py +176 -176
  70. package/app/core/publish/volumes.py +108 -0
  71. package/app/core/publish/ws.py +139 -139
  72. package/app/core/quality_gate.py +194 -194
  73. package/app/core/redact.py +38 -38
  74. package/app/core/repeat_guard.py +129 -129
  75. package/app/core/retrieval.py +137 -137
  76. package/app/core/router.py +398 -398
  77. package/app/core/runner.py +2239 -2238
  78. package/app/core/selfupdate.py +384 -384
  79. package/app/core/session_log.py +161 -161
  80. package/app/core/sessions.py +312 -312
  81. package/app/core/settings.py +33 -5
  82. package/app/core/settings_schema.py +282 -271
  83. package/app/core/share_page.py +114 -114
  84. package/app/core/skill_scan.py +139 -139
  85. package/app/core/skillpacks/fanqie-novel.md +8 -6
  86. package/app/core/skillpacks/market/character-bible.md +66 -66
  87. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  88. package/app/core/skillpacks/market/git-workflow.md +57 -57
  89. package/app/core/skillpacks/market/release-notes.md +72 -72
  90. package/app/core/skillpacks/market/weekly-report.md +71 -71
  91. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  92. package/app/core/skillpacks/qimao-signing.md +14 -11
  93. package/app/core/skills.py +32 -21
  94. package/app/core/step_runner.py +132 -132
  95. package/app/core/store.py +24 -0
  96. package/app/core/story_tracking.py +275 -275
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tool_outputs.py +107 -0
  102. package/app/core/tracing.py +207 -144
  103. package/app/core/upstream.py +41 -41
  104. package/app/core/usage.py +1038 -1038
  105. package/app/core/volumes.py +389 -389
  106. package/app/core/webhooks.py +89 -0
  107. package/app/core/wxdigest.py +865 -858
  108. package/app/core/zentao.py +2692 -2692
  109. package/app/main.py +179 -10
  110. package/app/mcp_server.py +244 -244
  111. package/app/pick_dialog.py +110 -110
  112. package/app/ui/app.js +563 -21
  113. package/app/ui/artdiff.js +74 -74
  114. package/app/ui/assets/community-qr.png +0 -0
  115. package/app/ui/assets/donate-alipay.jpg +0 -0
  116. package/app/ui/assets/donate-qr.jpg +0 -0
  117. package/app/ui/assets/workbench-reference.png +0 -0
  118. package/app/ui/board.css +451 -666
  119. package/app/ui/board.html +165 -162
  120. package/app/ui/board.js +509 -305
  121. package/app/ui/hive3d.js +242 -0
  122. package/app/ui/i18n.js +125 -9
  123. package/app/ui/icons/bee.svg +79 -79
  124. package/app/ui/index.html +60 -3
  125. package/app/ui/manifest.json +16 -16
  126. package/app/ui/qrcode.js +2297 -2297
  127. package/app/ui/style.css +189 -9
  128. package/bin/tutti.js +147 -147
  129. package/package.json +49 -48
  130. package/tools/wx_reader.py +261 -261
  131. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  132. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  133. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  134. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  135. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  136. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  137. package/app/.playwright-cli/run-detail-after.png +0 -0
  138. package/app/output/_createbook_body.json +0 -1
  139. package/app/output/_probe_fq_click.py +0 -50
  140. package/app/output/_probe_fq_form.py +0 -42
  141. package/app/output/_probe_fq_modal.py +0 -55
  142. package/app/output/_probe_fq_points.py +0 -71
  143. package/app/output/_probe_fq_submit.py +0 -34
  144. package/app/output/_probe_fq_watch.py +0 -62
@@ -1,201 +1,201 @@
1
- # -*- coding: utf-8 -*-
2
- """运行结果推送(借鉴 agency-orchestrator 的 --notify):任务跑完把结果摘要
3
- 推到钉钉/飞书/企业微信群机器人,配合定时自动化就是「AI 团队每天定点交活」。
4
-
5
- 两类通道:
6
- - 群机器人 webhook 一个地址全包——按域名自动适配三种机器人格式;也接受
7
- 任意 https 地址(按钉钉 text 形状发,自建 n8n 等自选)。
8
- - 个人推送通道(Bark / ntfy / Server酱 / Telegram Bot):推到私人手机,
9
- 无人值守场景「半夜失败/待裁决」的刚需;群机器人是给群里看的,这是给你看的。
10
- 推送在后台线程、失败只记日志——通知永远不影响任务本身。
11
- """
12
- from __future__ import annotations
13
-
14
- import logging
15
-
16
- log = logging.getLogger(__name__)
17
-
18
-
19
- def _payload_for(webhook, text):
20
- """按 webhook 域名适配群机器人消息体。返回 None = 无法识别的地址。"""
21
- from urllib.parse import urlparse
22
- host = (urlparse(webhook).hostname or "").lower()
23
- if "dingtalk" in host:
24
- return {"msgtype": "text", "text": {"content": text}}
25
- if "feishu" in host or "larksuite" in host:
26
- return {"msg_type": "text", "content": {"text": text}}
27
- if "weixin" in host or "wechat" in host or "work.weixin" in host:
28
- return {"msgtype": "text", "text": {"content": text}}
29
- if host:
30
- return {"msgtype": "text", "text": {"content": text}} # 未知域名按钉钉形状
31
- return None
32
-
33
-
34
- def _webhook():
35
- try:
36
- from . import settings
37
- return str(settings.load().get("notify_webhook") or "").strip()
38
- except Exception:
39
- return ""
40
-
41
-
42
- def _send(url, json_body=None, form=None):
43
- """curl 子进程发送(Python 不经手响应体);网络失败只记日志。返回 bool。
44
-
45
- json_body=JSON 体;form=表单体(值需 UTF-8 百分号编码,urlencode 负责)。
46
- 中文一律走请求体(Server酱表单 / ntfy JSON publish),不走 HTTP 头——
47
- 头里的非 ASCII 会被网关拒收(历史坑)。
48
- """
49
- from . import runner
50
- import json as _json
51
- argv = ["curl", "-sS", "--max-time", "20"]
52
- if json_body is not None:
53
- argv += ["-H", "Content-Type: application/json",
54
- "-d", _json.dumps(json_body, ensure_ascii=False)]
55
- elif form is not None:
56
- from urllib.parse import urlencode
57
- argv += ["-H", "Content-Type: application/x-www-form-urlencoded",
58
- "-d", urlencode(form)]
59
- argv.append(url)
60
- r = runner.run_process(argv=argv, timeout=30)
61
- return bool(r["ok"])
62
-
63
-
64
- def _post(hook, text):
65
- return _send(hook, json_body=_payload_for(hook, text))
66
-
67
-
68
- def _personal_channels():
69
- """读设置里的个人推送配置,返回 [(通道名, 发送函数(title, body)), ...]。
70
-
71
- 只收录配置完整的通道;键名与 settings.DEFAULTS 对齐。
72
- """
73
- from . import settings
74
- s = settings.load()
75
- chs = []
76
-
77
- bark_key = str(s.get("notify_bark_key") or "").strip()
78
- if bark_key:
79
- server = (str(s.get("notify_bark_server") or "").strip().rstrip("/")
80
- or "https://api.day.app")
81
- chs.append(("bark", lambda t, b: _send(
82
- server + "/push",
83
- json_body={"device_key": bark_key, "title": t, "body": b})))
84
-
85
- ntfy = str(s.get("notify_ntfy_topic") or "").strip()
86
- if ntfy:
87
- from urllib.parse import urlparse
88
- u = urlparse(ntfy if "://" in ntfy else "https://ntfy.sh/" + ntfy.lstrip("/"))
89
- topic = u.path.strip("/")
90
- if topic:
91
- server = "%s://%s" % (u.scheme, u.netloc)
92
- chs.append(("ntfy", lambda t, b: _send(
93
- server + "/",
94
- json_body={"topic": topic, "title": t, "message": b})))
95
-
96
- sc_key = str(s.get("notify_serverchan_key") or "").strip()
97
- if sc_key:
98
- chs.append(("serverchan", lambda t, b: _send(
99
- "https://sctapi.ftqq.com/%s.send" % sc_key,
100
- form={"title": t, "desp": b})))
101
-
102
- tg_token = str(s.get("notify_telegram_token") or "").strip()
103
- tg_chat = str(s.get("notify_telegram_chat_id") or "").strip()
104
- if tg_token and tg_chat:
105
- chs.append(("telegram", lambda t, b: _send(
106
- "https://api.telegram.org/bot%s/sendMessage" % tg_token,
107
- json_body={"chat_id": tg_chat, "text": (t + "\n" + b).strip()})))
108
- return chs
109
-
110
-
111
- def _split_title(text):
112
- """首行作标题(≤40 字),其余作正文;单行文本标题正文同文。"""
113
- lines = [ln for ln in str(text).splitlines() if ln.strip()]
114
- title = (lines[0].strip() if lines else "CodeBee")[:40]
115
- body = "\n".join(ln.strip() for ln in lines[1:]).strip() or title
116
- return title, body
117
-
118
-
119
- def push_text_ex(text):
120
- """推一条文本到全部已配置通道,返回 {通道名: 是否成功}。
121
-
122
- 未配置任何通道返回 {};单通道失败不影响其他通道。
123
- """
124
- results = {}
125
- hook = _webhook()
126
- if hook:
127
- if not hook.startswith("https://"):
128
- log.warning("notify webhook 必须是 https")
129
- results["webhook"] = False
130
- else:
131
- try:
132
- results["webhook"] = _post(hook, text)
133
- except Exception as e:
134
- log.warning("notify push failed: %s", e)
135
- results["webhook"] = False
136
- try:
137
- title, body = _split_title(text)
138
- except Exception:
139
- title, body = "CodeBee", str(text)
140
- for name, fn in _personal_channels():
141
- try:
142
- results[name] = bool(fn(title, body))
143
- except Exception as e:
144
- log.warning("notify %s push failed: %s", name, e)
145
- results[name] = False
146
- return results
147
-
148
-
149
- def push_text(text):
150
- """推一条文本;任一通道成功即 True;全未配置/全失败返回 False,不抛错。"""
151
- try:
152
- return any(push_text_ex(text).values())
153
- except Exception as e:
154
- log.warning("notify push failed: %s", e)
155
- return False
156
-
157
-
158
- def push_run_async(run_id):
159
- """任务收尾后异步推送结果摘要(jobs 层调用;绝不阻塞/影响任务)。"""
160
- threading.Thread(target=push_run, daemon=True,
161
- name="notify-%s" % run_id, args=(run_id,)).start()
162
-
163
-
164
- def push_run(run_id):
165
- """组装 run 结果摘要并推送(群 webhook 或任一个人通道配置了才推)。
166
-
167
- 摘要末尾附分享页路径(notify_base_url 设置非空时拼完整链接,否则只给
168
- run_id 供在本机 CodeBee 界面查找)。"""
169
- from . import store
170
- run = store.get_run(run_id) or {}
171
- if not run:
172
- return False
173
- status = str(run.get("status") or "")
174
- mark = {"done": "✅", "failed": "❌", "cancelled": "⚪"}.get(status, "🔔")
175
- lines = ["%s CodeBee 任务%s" % (mark, {"done": "完成", "failed": "失败",
176
- "cancelled": "已取消"}.get(status, status))]
177
- lines.append("任务:%s" % (run.get("title") or run_id))
178
- if run.get("error"):
179
- lines.append("错误:%s" % str(run["error"])[:200])
180
- v = run.get("verdict") or {}
181
- if v.get("overall") is not None:
182
- lines.append("综合评分 %.1f(%s)" % (
183
- float(v["overall"]), "达标" if v.get("publishable") else "未达标"))
184
- try:
185
- from .settings import load as _sload
186
- base = str(_sload().get("notify_base_url") or "").strip().rstrip("/")
187
- if base:
188
- lines.append("📄 详情:%s/api/runs/%s/share" % (base, run_id))
189
- except Exception:
190
- pass
191
- # 分支待裁决:任务跑完有改动等人拍板时点名提醒(手机端看到即可去详情处理)
192
- try:
193
- task = store.get_task(run.get("task_id")) if run.get("task_id") else None
194
- if task and task.get("git_state") == "isolated":
195
- lines.append("⚠ 任务分支待裁决(合并/丢弃)")
196
- except Exception:
197
- pass
198
- return push_text("\n".join(lines))
199
-
200
-
201
- import threading # noqa: E402 (push_run_async 依赖;置底避免顶部循环导入)
1
+ # -*- coding: utf-8 -*-
2
+ """运行结果推送(借鉴 agency-orchestrator 的 --notify):任务跑完把结果摘要
3
+ 推到钉钉/飞书/企业微信群机器人,配合定时自动化就是「AI 团队每天定点交活」。
4
+
5
+ 两类通道:
6
+ - 群机器人 webhook 一个地址全包——按域名自动适配三种机器人格式;也接受
7
+ 任意 https 地址(按钉钉 text 形状发,自建 n8n 等自选)。
8
+ - 个人推送通道(Bark / ntfy / Server酱 / Telegram Bot):推到私人手机,
9
+ 无人值守场景「半夜失败/待裁决」的刚需;群机器人是给群里看的,这是给你看的。
10
+ 推送在后台线程、失败只记日志——通知永远不影响任务本身。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+
19
+ def _payload_for(webhook, text):
20
+ """按 webhook 域名适配群机器人消息体。返回 None = 无法识别的地址。"""
21
+ from urllib.parse import urlparse
22
+ host = (urlparse(webhook).hostname or "").lower()
23
+ if "dingtalk" in host:
24
+ return {"msgtype": "text", "text": {"content": text}}
25
+ if "feishu" in host or "larksuite" in host:
26
+ return {"msg_type": "text", "content": {"text": text}}
27
+ if "weixin" in host or "wechat" in host or "work.weixin" in host:
28
+ return {"msgtype": "text", "text": {"content": text}}
29
+ if host:
30
+ return {"msgtype": "text", "text": {"content": text}} # 未知域名按钉钉形状
31
+ return None
32
+
33
+
34
+ def _webhook():
35
+ try:
36
+ from . import settings
37
+ return str(settings.load().get("notify_webhook") or "").strip()
38
+ except Exception:
39
+ return ""
40
+
41
+
42
+ def _send(url, json_body=None, form=None):
43
+ """curl 子进程发送(Python 不经手响应体);网络失败只记日志。返回 bool。
44
+
45
+ json_body=JSON 体;form=表单体(值需 UTF-8 百分号编码,urlencode 负责)。
46
+ 中文一律走请求体(Server酱表单 / ntfy JSON publish),不走 HTTP 头——
47
+ 头里的非 ASCII 会被网关拒收(历史坑)。
48
+ """
49
+ from . import runner
50
+ import json as _json
51
+ argv = ["curl", "-sS", "--max-time", "20"]
52
+ if json_body is not None:
53
+ argv += ["-H", "Content-Type: application/json",
54
+ "-d", _json.dumps(json_body, ensure_ascii=False)]
55
+ elif form is not None:
56
+ from urllib.parse import urlencode
57
+ argv += ["-H", "Content-Type: application/x-www-form-urlencoded",
58
+ "-d", urlencode(form)]
59
+ argv.append(url)
60
+ r = runner.run_process(argv=argv, timeout=30)
61
+ return bool(r["ok"])
62
+
63
+
64
+ def _post(hook, text):
65
+ return _send(hook, json_body=_payload_for(hook, text))
66
+
67
+
68
+ def _personal_channels():
69
+ """读设置里的个人推送配置,返回 [(通道名, 发送函数(title, body)), ...]。
70
+
71
+ 只收录配置完整的通道;键名与 settings.DEFAULTS 对齐。
72
+ """
73
+ from . import settings
74
+ s = settings.load()
75
+ chs = []
76
+
77
+ bark_key = str(s.get("notify_bark_key") or "").strip()
78
+ if bark_key:
79
+ server = (str(s.get("notify_bark_server") or "").strip().rstrip("/")
80
+ or "https://api.day.app")
81
+ chs.append(("bark", lambda t, b: _send(
82
+ server + "/push",
83
+ json_body={"device_key": bark_key, "title": t, "body": b})))
84
+
85
+ ntfy = str(s.get("notify_ntfy_topic") or "").strip()
86
+ if ntfy:
87
+ from urllib.parse import urlparse
88
+ u = urlparse(ntfy if "://" in ntfy else "https://ntfy.sh/" + ntfy.lstrip("/"))
89
+ topic = u.path.strip("/")
90
+ if topic:
91
+ server = "%s://%s" % (u.scheme, u.netloc)
92
+ chs.append(("ntfy", lambda t, b: _send(
93
+ server + "/",
94
+ json_body={"topic": topic, "title": t, "message": b})))
95
+
96
+ sc_key = str(s.get("notify_serverchan_key") or "").strip()
97
+ if sc_key:
98
+ chs.append(("serverchan", lambda t, b: _send(
99
+ "https://sctapi.ftqq.com/%s.send" % sc_key,
100
+ form={"title": t, "desp": b})))
101
+
102
+ tg_token = str(s.get("notify_telegram_token") or "").strip()
103
+ tg_chat = str(s.get("notify_telegram_chat_id") or "").strip()
104
+ if tg_token and tg_chat:
105
+ chs.append(("telegram", lambda t, b: _send(
106
+ "https://api.telegram.org/bot%s/sendMessage" % tg_token,
107
+ json_body={"chat_id": tg_chat, "text": (t + "\n" + b).strip()})))
108
+ return chs
109
+
110
+
111
+ def _split_title(text):
112
+ """首行作标题(≤40 字),其余作正文;单行文本标题正文同文。"""
113
+ lines = [ln for ln in str(text).splitlines() if ln.strip()]
114
+ title = (lines[0].strip() if lines else "CodeBee")[:40]
115
+ body = "\n".join(ln.strip() for ln in lines[1:]).strip() or title
116
+ return title, body
117
+
118
+
119
+ def push_text_ex(text):
120
+ """推一条文本到全部已配置通道,返回 {通道名: 是否成功}。
121
+
122
+ 未配置任何通道返回 {};单通道失败不影响其他通道。
123
+ """
124
+ results = {}
125
+ hook = _webhook()
126
+ if hook:
127
+ if not hook.startswith("https://"):
128
+ log.warning("notify webhook 必须是 https")
129
+ results["webhook"] = False
130
+ else:
131
+ try:
132
+ results["webhook"] = _post(hook, text)
133
+ except Exception as e:
134
+ log.warning("notify push failed: %s", e)
135
+ results["webhook"] = False
136
+ try:
137
+ title, body = _split_title(text)
138
+ except Exception:
139
+ title, body = "CodeBee", str(text)
140
+ for name, fn in _personal_channels():
141
+ try:
142
+ results[name] = bool(fn(title, body))
143
+ except Exception as e:
144
+ log.warning("notify %s push failed: %s", name, e)
145
+ results[name] = False
146
+ return results
147
+
148
+
149
+ def push_text(text):
150
+ """推一条文本;任一通道成功即 True;全未配置/全失败返回 False,不抛错。"""
151
+ try:
152
+ return any(push_text_ex(text).values())
153
+ except Exception as e:
154
+ log.warning("notify push failed: %s", e)
155
+ return False
156
+
157
+
158
+ def push_run_async(run_id):
159
+ """任务收尾后异步推送结果摘要(jobs 层调用;绝不阻塞/影响任务)。"""
160
+ threading.Thread(target=push_run, daemon=True,
161
+ name="notify-%s" % run_id, args=(run_id,)).start()
162
+
163
+
164
+ def push_run(run_id):
165
+ """组装 run 结果摘要并推送(群 webhook 或任一个人通道配置了才推)。
166
+
167
+ 摘要末尾附分享页路径(notify_base_url 设置非空时拼完整链接,否则只给
168
+ run_id 供在本机 CodeBee 界面查找)。"""
169
+ from . import store
170
+ run = store.get_run(run_id) or {}
171
+ if not run:
172
+ return False
173
+ status = str(run.get("status") or "")
174
+ mark = {"done": "✅", "failed": "❌", "cancelled": "⚪"}.get(status, "🔔")
175
+ lines = ["%s CodeBee 任务%s" % (mark, {"done": "完成", "failed": "失败",
176
+ "cancelled": "已取消"}.get(status, status))]
177
+ lines.append("任务:%s" % (run.get("title") or run_id))
178
+ if run.get("error"):
179
+ lines.append("错误:%s" % str(run["error"])[:200])
180
+ v = run.get("verdict") or {}
181
+ if v.get("overall") is not None:
182
+ lines.append("综合评分 %.1f(%s)" % (
183
+ float(v["overall"]), "达标" if v.get("publishable") else "未达标"))
184
+ try:
185
+ from .settings import load as _sload
186
+ base = str(_sload().get("notify_base_url") or "").strip().rstrip("/")
187
+ if base:
188
+ lines.append("📄 详情:%s/api/runs/%s/share" % (base, run_id))
189
+ except Exception:
190
+ pass
191
+ # 分支待裁决:任务跑完有改动等人拍板时点名提醒(手机端看到即可去详情处理)
192
+ try:
193
+ task = store.get_task(run.get("task_id")) if run.get("task_id") else None
194
+ if task and task.get("git_state") == "isolated":
195
+ lines.append("⚠ 任务分支待裁决(合并/丢弃)")
196
+ except Exception:
197
+ pass
198
+ return push_text("\n".join(lines))
199
+
200
+
201
+ import threading # noqa: E402 (push_run_async 依赖;置底避免顶部循环导入)