@whyshu/argent-engine 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. package/MANIFEST.json +307 -0
  2. package/engine/VERSION +1 -0
  3. package/engine/agents/catalog.json +454 -0
  4. package/engine/configs/_aliases.proposed.json +651 -0
  5. package/engine/configs/_migration-plan.proposed.json +899 -0
  6. package/engine/configs/account.json +272 -0
  7. package/engine/configs/advanced.json +205 -0
  8. package/engine/configs/host.json +149 -0
  9. package/engine/configs/hr.json +27 -0
  10. package/engine/configs/notify.json +264 -0
  11. package/engine/core/cli/argent.mjs +277 -0
  12. package/engine/core/contracts/argent.contracts.json +1065 -0
  13. package/engine/core/contracts/verbs.schema.json +2539 -0
  14. package/engine/core/lib/argent-datadir.mjs +223 -0
  15. package/engine/core/lib/audit.mjs +187 -0
  16. package/engine/core/lib/claim.mjs +258 -0
  17. package/engine/core/lib/config-catalog.mjs +1172 -0
  18. package/engine/core/lib/doctor-customer.mjs +577 -0
  19. package/engine/core/lib/flow-list.mjs +138 -0
  20. package/engine/core/lib/flow-registry.mjs +48 -0
  21. package/engine/core/lib/init.mjs +247 -0
  22. package/engine/core/lib/ledger.mjs +124 -0
  23. package/engine/core/lib/license-activate.mjs +813 -0
  24. package/engine/core/lib/license-device.mjs +314 -0
  25. package/engine/core/lib/license-file.mjs +349 -0
  26. package/engine/core/lib/license-guard.mjs +123 -0
  27. package/engine/core/lib/license-ops.mjs +1645 -0
  28. package/engine/core/lib/license-verdict.mjs +133 -0
  29. package/engine/core/lib/license.mjs +577 -0
  30. package/engine/core/lib/notify/dedup.mjs +101 -0
  31. package/engine/core/lib/notify/feishu.mjs +38 -0
  32. package/engine/core/lib/notify/index.mjs +95 -0
  33. package/engine/core/lib/notify/verb.mjs +327 -0
  34. package/engine/core/lib/notify/webhook.mjs +135 -0
  35. package/engine/core/lib/notify/wecom.mjs +36 -0
  36. package/engine/core/lib/python.mjs +145 -0
  37. package/engine/core/lib/sop.mjs +116 -0
  38. package/engine/core/lib/versions.mjs +205 -0
  39. package/engine/core/mcp/client.mjs +67 -0
  40. package/engine/core/mcp/http.mjs +138 -0
  41. package/engine/core/mcp/server.mjs +702 -0
  42. package/engine/scripts/_engine_rules_probe.py +15 -0
  43. package/engine/scripts/analyze-full-day.py +568 -0
  44. package/engine/scripts/archive/feishu-bridge.js +246 -0
  45. package/engine/scripts/argent-agents.mjs +527 -0
  46. package/engine/scripts/argent-capabilities.mjs +776 -0
  47. package/engine/scripts/argent-catalog.mjs +186 -0
  48. package/engine/scripts/argent-datadir.py +62 -0
  49. package/engine/scripts/argent-doctor.mjs +624 -0
  50. package/engine/scripts/argent-env-write.mjs +302 -0
  51. package/engine/scripts/argent-schedule.mjs +620 -0
  52. package/engine/scripts/argent-setup-check.mjs +369 -0
  53. package/engine/scripts/argent-upgrade.mjs +1046 -0
  54. package/engine/scripts/base-bootstrap.sh +575 -0
  55. package/engine/scripts/billing-mode-daily.sh +12 -0
  56. package/engine/scripts/build-engine-package.mjs +123 -0
  57. package/engine/scripts/capability-packs.lock.json +484 -0
  58. package/engine/scripts/capability_pack.py +1242 -0
  59. package/engine/scripts/check-1.0-criteria.mjs +355 -0
  60. package/engine/scripts/check-agent-catalog.mjs +141 -0
  61. package/engine/scripts/check-billing-mode.sh +377 -0
  62. package/engine/scripts/check-child-sessions.mjs +83 -0
  63. package/engine/scripts/check-config-catalog.mjs +677 -0
  64. package/engine/scripts/check-docs-drift.mjs +522 -0
  65. package/engine/scripts/check-dsh-web-expose.sh +98 -0
  66. package/engine/scripts/check-judgment-drift.mjs +403 -0
  67. package/engine/scripts/check-license.mjs +199 -0
  68. package/engine/scripts/check-model-auth.py +443 -0
  69. package/engine/scripts/check-pack-closure.mjs +661 -0
  70. package/engine/scripts/check-portable-symbols.mjs +116 -0
  71. package/engine/scripts/check-preset-landing.mjs +124 -0
  72. package/engine/scripts/check-production-home.mjs +602 -0
  73. package/engine/scripts/check-role-mapping.mjs +461 -0
  74. package/engine/scripts/check-sample-redaction.mjs +320 -0
  75. package/engine/scripts/check-skill-classification.mjs +188 -0
  76. package/engine/scripts/check-sop-consistency.mjs +431 -0
  77. package/engine/scripts/check-test-env-safety.mjs +627 -0
  78. package/engine/scripts/check-version-consistency.mjs +452 -0
  79. package/engine/scripts/ci-run.sh +558 -0
  80. package/engine/scripts/config-explain.mjs +278 -0
  81. package/engine/scripts/configure-provider.rb +29 -0
  82. package/engine/scripts/customer-config-migrate.mjs +179 -0
  83. package/engine/scripts/customer-preflight.py +892 -0
  84. package/engine/scripts/customer-readiness.sh +134 -0
  85. package/engine/scripts/daily-report-inbox.py +242 -0
  86. package/engine/scripts/daily-report-reconcile.mjs +312 -0
  87. package/engine/scripts/daily-report.mjs +1310 -0
  88. package/engine/scripts/daily-report.sh +46 -0
  89. package/engine/scripts/data/lip-product-dict.json +306 -0
  90. package/engine/scripts/deliver-customer.mjs +932 -0
  91. package/engine/scripts/delivery-faces.txt +31 -0
  92. package/engine/scripts/desktop-trial.sh +146 -0
  93. package/engine/scripts/dev-window.sh +260 -0
  94. package/engine/scripts/drill-clean-machine.sh +315 -0
  95. package/engine/scripts/dsh-api-client.mjs +215 -0
  96. package/engine/scripts/dsh-backup-scope.sh +83 -0
  97. package/engine/scripts/dsh-offline-pack.mjs +77 -0
  98. package/engine/scripts/dsh-overlay-pin.sh +87 -0
  99. package/engine/scripts/dsh-plane.mjs +153 -0
  100. package/engine/scripts/dsh-portforward.py +223 -0
  101. package/engine/scripts/dsh-preflight.mjs +179 -0
  102. package/engine/scripts/dsh-release-watch-daily.sh +19 -0
  103. package/engine/scripts/dsh-release-watch.mjs +458 -0
  104. package/engine/scripts/dsh-remote-check.sh +99 -0
  105. package/engine/scripts/dsh-restart.sh +179 -0
  106. package/engine/scripts/dsh-restore.sh +111 -0
  107. package/engine/scripts/dsh-scope-rewire.mjs +803 -0
  108. package/engine/scripts/dsh-snapshot.mjs +116 -0
  109. package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
  110. package/engine/scripts/dsh-upgrade.mjs +612 -0
  111. package/engine/scripts/dsh-url.sh +86 -0
  112. package/engine/scripts/dsh-web-launch.mjs +155 -0
  113. package/engine/scripts/ensure-store-windows.py +509 -0
  114. package/engine/scripts/entitlements.py +458 -0
  115. package/engine/scripts/env_probe.py +411 -0
  116. package/engine/scripts/feishu-card.mjs +252 -0
  117. package/engine/scripts/fix-skill-frontmatter.py +209 -0
  118. package/engine/scripts/flow-auto-retry.py +454 -0
  119. package/engine/scripts/flow-requirements.json +720 -0
  120. package/engine/scripts/flow-scaffold.py +275 -0
  121. package/engine/scripts/flow-timers-plist.sh +86 -0
  122. package/engine/scripts/flow-timers.sh +182 -0
  123. package/engine/scripts/flow-trigger-wake.mjs +284 -0
  124. package/engine/scripts/flow-trigger.sh +571 -0
  125. package/engine/scripts/flow_billing.py +354 -0
  126. package/engine/scripts/flow_capability_stats.py +314 -0
  127. package/engine/scripts/flow_case.py +194 -0
  128. package/engine/scripts/flow_claim.py +497 -0
  129. package/engine/scripts/flow_config.py +1024 -0
  130. package/engine/scripts/flow_failure.py +588 -0
  131. package/engine/scripts/flow_learn.py +105 -0
  132. package/engine/scripts/flow_ledger.py +235 -0
  133. package/engine/scripts/flow_policy.py +238 -0
  134. package/engine/scripts/flow_preflight.py +339 -0
  135. package/engine/scripts/flow_registry.py +341 -0
  136. package/engine/scripts/flow_retry.py +304 -0
  137. package/engine/scripts/flow_return_to_ops.py +191 -0
  138. package/engine/scripts/flow_source.py +315 -0
  139. package/engine/scripts/flow_sources/__init__.py +53 -0
  140. package/engine/scripts/flow_sources/cases_json.py +106 -0
  141. package/engine/scripts/flow_sources/common.py +428 -0
  142. package/engine/scripts/flow_sources/feishu_base.py +273 -0
  143. package/engine/scripts/flow_stale.py +185 -0
  144. package/engine/scripts/flow_status_reconcile.py +344 -0
  145. package/engine/scripts/flow_triage.py +849 -0
  146. package/engine/scripts/flow_write_verify.py +496 -0
  147. package/engine/scripts/fulfillment.js +586 -0
  148. package/engine/scripts/gen-agent-bundles.mjs +276 -0
  149. package/engine/scripts/gen-preset-rows.mjs +259 -0
  150. package/engine/scripts/init-ml.sh +2275 -0
  151. package/engine/scripts/install-internal-role.sh +190 -0
  152. package/engine/scripts/install-license.mjs +641 -0
  153. package/engine/scripts/install-one-shot.mjs +665 -0
  154. package/engine/scripts/integration_contract.py +220 -0
  155. package/engine/scripts/integrations/wenyao/adapter.py +98 -0
  156. package/engine/scripts/integrations/xzh/adapter.py +75 -0
  157. package/engine/scripts/invoice-mapping.example.json +40 -0
  158. package/engine/scripts/invoice-table.sh +194 -0
  159. package/engine/scripts/invoice_carrier_notice.py +279 -0
  160. package/engine/scripts/invoice_fail_closeout.py +297 -0
  161. package/engine/scripts/invoice_lip_runner.py +1508 -0
  162. package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
  163. package/engine/scripts/invoice_ready_audit.py +191 -0
  164. package/engine/scripts/invoice_runner.py +675 -0
  165. package/engine/scripts/invoice_stageb.py +516 -0
  166. package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
  167. package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
  168. package/engine/scripts/lease.py +377 -0
  169. package/engine/scripts/lib/agent-install.mjs +649 -0
  170. package/engine/scripts/lib/argent-datadir.mjs +16 -0
  171. package/engine/scripts/lib/argent_datadir.py +262 -0
  172. package/engine/scripts/lib/argent_filelock.py +507 -0
  173. package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
  174. package/engine/scripts/lib/cdp_ports.py +197 -0
  175. package/engine/scripts/lib/customer-env.mjs +102 -0
  176. package/engine/scripts/lib/daily-report-core.mjs +517 -0
  177. package/engine/scripts/lib/dep-declaration.mjs +81 -0
  178. package/engine/scripts/lib/deps-migrate.mjs +310 -0
  179. package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
  180. package/engine/scripts/lib/dsh-scope.mjs +500 -0
  181. package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
  182. package/engine/scripts/lib/dsh-versions.mjs +96 -0
  183. package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
  184. package/engine/scripts/lib/is-direct-run.mjs +61 -0
  185. package/engine/scripts/lib/patch-compose.mjs +241 -0
  186. package/engine/scripts/lib/patch-target.mjs +35 -0
  187. package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
  188. package/engine/scripts/lib/plugin-packages.mjs +332 -0
  189. package/engine/scripts/lib/preset-compat.mjs +127 -0
  190. package/engine/scripts/lib/preset-install.mjs +575 -0
  191. package/engine/scripts/lib/preset-mechanism.mjs +56 -0
  192. package/engine/scripts/lib/preset-sync.mjs +59 -0
  193. package/engine/scripts/lib/profile-retire.mjs +97 -0
  194. package/engine/scripts/lib/python-runtime.mjs +190 -0
  195. package/engine/scripts/lib/registry.mjs +72 -0
  196. package/engine/scripts/lib/repo-root.sh +56 -0
  197. package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
  198. package/engine/scripts/lib/schedule-probe.mjs +835 -0
  199. package/engine/scripts/lib/scope-pointer.mjs +164 -0
  200. package/engine/scripts/lib/settings-panels.mjs +816 -0
  201. package/engine/scripts/lib/stdio_utf8.py +86 -0
  202. package/engine/scripts/lib/wake-auth.mjs +392 -0
  203. package/engine/scripts/lib/webdriver_ipc.py +264 -0
  204. package/engine/scripts/lib/zstd-frames.mjs +104 -0
  205. package/engine/scripts/license-e2e-drill.mjs +426 -0
  206. package/engine/scripts/license-gate.mjs +245 -0
  207. package/engine/scripts/lip_backlog.py +171 -0
  208. package/engine/scripts/lip_table.py +185 -0
  209. package/engine/scripts/local-usage.mjs +214 -0
  210. package/engine/scripts/migrate-providers.mjs +127 -0
  211. package/engine/scripts/migrate-skills.sh +65 -0
  212. package/engine/scripts/ml-oauth-setup.mjs +550 -0
  213. package/engine/scripts/ml-store-ids.json +26 -0
  214. package/engine/scripts/ml_artifact_attribution.py +333 -0
  215. package/engine/scripts/ml_capacity_probe.py +1158 -0
  216. package/engine/scripts/ml_hub_status.py +1556 -0
  217. package/engine/scripts/ml_schema_check.py +115 -0
  218. package/engine/scripts/ml_store_ids.py +139 -0
  219. package/engine/scripts/npm-publish-argent.mjs +499 -0
  220. package/engine/scripts/npm-publish-argent.sh +20 -0
  221. package/engine/scripts/orchestrator.py +13811 -0
  222. package/engine/scripts/pack-plugins.mjs +140 -0
  223. package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
  224. package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
  225. package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
  226. package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
  227. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
  228. package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
  229. package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
  230. package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
  231. package/engine/scripts/patches/verify-hardening.mjs +167 -0
  232. package/engine/scripts/patches/verify-session-migration.mjs +149 -0
  233. package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
  234. package/engine/scripts/plan-role-rename.mjs +125 -0
  235. package/engine/scripts/plugin-bootcheck.mjs +451 -0
  236. package/engine/scripts/plugin-composition.json +15 -0
  237. package/engine/scripts/poc-mcp-host.mjs +455 -0
  238. package/engine/scripts/poll-fulfillment.sh +293 -0
  239. package/engine/scripts/poll-invoice.sh +74 -0
  240. package/engine/scripts/profile-doctor.mjs +686 -0
  241. package/engine/scripts/provider-select.sh +70 -0
  242. package/engine/scripts/provider-switch.sh +146 -0
  243. package/engine/scripts/provider-sync-target.check.mjs +64 -0
  244. package/engine/scripts/python-runtime.sh +78 -0
  245. package/engine/scripts/query_runner.py +310 -0
  246. package/engine/scripts/release-argent.sh +574 -0
  247. package/engine/scripts/repair-unresolved-tools.mjs +178 -0
  248. package/engine/scripts/run-tests.sh +256 -0
  249. package/engine/scripts/self-sign-license.mjs +179 -0
  250. package/engine/scripts/session-health-scan.mjs +63 -0
  251. package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
  252. package/engine/scripts/setup.sh +22 -0
  253. package/engine/scripts/skill-install.mjs +629 -0
  254. package/engine/scripts/sku_translate.py +148 -0
  255. package/engine/scripts/sync-plugins.sh +90 -0
  256. package/engine/scripts/sync-presets.sh +27 -0
  257. package/engine/scripts/sync-scripts.sh +445 -0
  258. package/engine/scripts/telemetry.py +873 -0
  259. package/engine/scripts/ui-copy-count.mjs +220 -0
  260. package/engine/scripts/usage-anchor.sh +136 -0
  261. package/engine/scripts/usage_report.py +531 -0
  262. package/engine/scripts/validate-preset-schema.mjs +208 -0
  263. package/engine/scripts/vertical-init.mjs +323 -0
  264. package/engine/scripts/vertical-lint.mjs +482 -0
  265. package/engine/scripts/wenyao-browser.sh +73 -0
  266. package/engine/scripts/wenyao-fixture-export.py +116 -0
  267. package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
  268. package/engine/scripts/wenyao-login.sh +34 -0
  269. package/engine/scripts/wenyao-template-map.json +292 -0
  270. package/engine/scripts/wenyao_attach.py +131 -0
  271. package/engine/scripts/wenyao_download_template.py +144 -0
  272. package/engine/scripts/wenyao_fill_template.py +568 -0
  273. package/engine/scripts/wenyao_login.py +145 -0
  274. package/engine/scripts/wenyao_portal.py +633 -0
  275. package/engine/scripts/wenyao_recon.py +230 -0
  276. package/engine/scripts/wenyao_run.py +503 -0
  277. package/engine/scripts/wenyao_shadow_diff.py +160 -0
  278. package/engine/scripts/wenyao_skill_contract.py +156 -0
  279. package/engine/scripts/wenyao_template_fetch.py +175 -0
  280. package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
  281. package/engine/scripts/ziniao-auth-probe.sh +268 -0
  282. package/engine/scripts/ziniao-keepalive.sh +216 -0
  283. package/engine/scripts/ziniao-post-upgrade-check.sh +120 -0
  284. package/engine/scripts/ziniao-windows.sh +153 -0
  285. package/engine/version.mjs +1 -0
  286. package/engine/verticals/README.md +103 -0
  287. package/engine/verticals/_engine-words.baseline.txt +237 -0
  288. package/engine/verticals/_engine-words.json +19 -0
  289. package/engine/verticals/mercadolibre/README.md +43 -0
  290. package/engine/verticals/mercadolibre/config.json +709 -0
  291. package/engine/verticals/mercadolibre/flows.json +706 -0
  292. package/engine/verticals/mercadolibre/manifest.json +538 -0
  293. package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
  294. package/engine/verticals/mercadolibre/preflight.py +143 -0
  295. package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
  296. package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
  297. package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
  298. package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
  299. package/engine/verticals/mercadolibre/prompts/full.md +91 -0
  300. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
  301. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
  302. package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
  303. package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
  304. package/package.json +21 -4
  305. package/README.md +0 -3
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env python3
2
+ """修复/校验仓库技能的 frontmatter(2026-09-11)
3
+
4
+ **这不是格式洁癖——不合法就是技能根本不生效。** 三处硬性要求都来自
5
+ `dsh-skill-filesystem` → `parseSkillFile()`(0.1.5-rc.1 `lib/index.js:664-695`):
6
+
7
+ ① **首行 `---` + 必须有闭合 `---`**(`parseFrontmatter()`,`:772-798`)。
8
+ 找不到闭合 ⇒ `return undefined` ⇒ 整文件丢弃,只 warn
9
+ `skill file ... ignored: missing YAML frontmatter`。
10
+ ⚠️ 隐蔽形态:正文里的水平线 `---` 会被**当成闭合**,中间几十~上百行正文被当 YAML 解析 → 失败 → 同样丢弃。
11
+
12
+ ② **`name` 必须是严格小写 kebab-case**(`:685` 调 `isSkillName()`,
13
+ `@deepseek-ai/dsh-skill` 的 `SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/`)。
14
+ 不合法 ⇒ warn `invalid skill name "…"` ⇒ 丢弃。
15
+ ⚠️ **中文名(如 `name: 美客多账户健康诊断`)一律非法**;技能目录里显示、`skill` 工具要调用的
16
+ 就是这个名字 ⇒ 人读的标题应放 `description`,或保留在自定义 `title` 字段(加载器不读它)。
17
+ 本脚本把 `name` 归一为**目录名**(与现有可加载技能一致),原标题搬到 `title:`。
18
+ ③ 另要求 frontmatter **同时含 `name` 与 `description`**(`:679-684`)。
19
+
20
+ 用法:
21
+ python3 scripts/fix-skill-frontmatter.py --check # 只报告(门禁/CI 用),有问题 exit 1
22
+ python3 scripts/fix-skill-frontmatter.py --write # 就地修复(补闭合 + name 归一为目录名)
23
+ python3 scripts/fix-skill-frontmatter.py --list # 列出全部技能状态
24
+ """
25
+ import pathlib
26
+ import re
27
+ import sys
28
+
29
+ try:
30
+ import yaml
31
+ except ImportError: # pragma: no cover
32
+ yaml = None
33
+
34
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
35
+ sys.path.insert(0, str(ROOT / 'scripts' / 'lib'))
36
+ from stdio_utf8 import use_utf8_stdio # noqa: E402 出口编码钉 UTF-8(中文 Windows 的 GBK 管道下非 ASCII 会崩)
37
+ SKILLS = ROOT / 'skills'
38
+ REQUIRED = ('name', 'description')
39
+ # 与 @deepseek-ai/dsh-skill 的 SKILL_NAME 完全一致
40
+ NAME_RE = re.compile(r'^[a-z0-9]+(?:-[a-z0-9]+)*$')
41
+
42
+
43
+ def closing_line(lines):
44
+ for i, line in enumerate(lines[1:], start=2):
45
+ if line.rstrip('\r') == '---':
46
+ return i
47
+ return None
48
+
49
+
50
+ def first_heading(lines):
51
+ for i, line in enumerate(lines[1:], start=2):
52
+ if line.startswith('#'):
53
+ return i
54
+ return None
55
+
56
+
57
+ def split_frontmatter(text):
58
+ """返回 (yaml_text, close_line, body);闭合位置不可信时 yaml_text=None。"""
59
+ lines = text.split('\n')
60
+ if not lines or lines[0].rstrip('\r') != '---':
61
+ return None, None, text
62
+ close = closing_line(lines)
63
+ head = first_heading(lines)
64
+ if close is None or (head is not None and close > head):
65
+ return None, close, text
66
+ return '\n'.join(lines[1:close - 1]), close, '\n'.join(lines[close:])
67
+
68
+
69
+ def raw_state(text):
70
+ lines = text.split('\n')
71
+ close = closing_line(lines)
72
+ head = first_heading(lines)
73
+ if close is None or (head is not None and close > head):
74
+ return {'reason': 'missing-closing-delimiter', 'head': head, 'stray': close}
75
+ return {'reason': 'ok'}
76
+
77
+
78
+ def diagnose(text, expect_name=None):
79
+ lines = text.split('\n')
80
+ if not lines or lines[0].rstrip('\r') != '---':
81
+ return {'ok': False, 'reason': 'no-leading-delimiter'}
82
+ state = raw_state(text)
83
+ if state['reason'] != 'ok':
84
+ head = state['head']
85
+ return {'ok': False, 'reason': 'missing-closing-delimiter',
86
+ 'head': head, 'stray': state['stray'],
87
+ 'insert_after': (head - 2) if head else None}
88
+ fm, close, _ = split_frontmatter(text)
89
+ if yaml is not None:
90
+ try:
91
+ data = yaml.safe_load(fm)
92
+ except Exception as e:
93
+ return {'ok': False, 'reason': f'invalid-yaml: {e}'}
94
+ if not isinstance(data, dict):
95
+ return {'ok': False, 'reason': 'frontmatter-not-a-mapping'}
96
+ missing = [k for k in REQUIRED if not data.get(k)]
97
+ if missing:
98
+ return {'ok': False, 'reason': f'missing-required: {",".join(missing)}'}
99
+ name = str(data.get('name', ''))
100
+ if not NAME_RE.match(name):
101
+ return {'ok': False, 'reason': 'invalid-skill-name', 'name': name}
102
+ if expect_name is not None and name != expect_name:
103
+ return {'ok': False, 'reason': 'name-dir-mismatch', 'name': name, 'expect': expect_name}
104
+ return {'ok': True, 'close': close, 'name': name}
105
+ return {'ok': True, 'close': close, 'name': None}
106
+
107
+
108
+ def fix(text, dir_id):
109
+ """返回 (新文本, [修复说明])。"""
110
+ notes = []
111
+ # ① 先补闭合
112
+ state = raw_state(text)
113
+ if state['reason'] == 'missing-closing-delimiter':
114
+ lines = text.split('\n')
115
+ head = state['head']
116
+ j = head - 2
117
+ while j >= 1 and lines[j].strip() == '':
118
+ j -= 1
119
+ lines = lines[:j + 1] + ['---'] + lines[j + 1:]
120
+ text = '\n'.join(lines)
121
+ notes.append('补闭合 `---`(原先命中的是正文水平线)' if state['stray'] is not None else '补闭合 `---`')
122
+ # ② 再归一 name
123
+ fm, close, _ = split_frontmatter(text)
124
+ if fm is None:
125
+ return text, notes + ['⚠️ 仍无法定位 frontmatter,需人工处理']
126
+ lines = text.split('\n')
127
+ name_idx = old_name = title_idx = None
128
+ for i in range(1, close - 1):
129
+ m = re.match(r'^name:\s*(.*)$', lines[i])
130
+ if m:
131
+ name_idx, old_name = i, m.group(1).strip().strip('"').strip("'")
132
+ if re.match(r'^title:\s*', lines[i]):
133
+ title_idx = i
134
+ if name_idx is None:
135
+ anchor = 1
136
+ while anchor < close - 1 and not re.match(r'^id:\s*', lines[anchor]):
137
+ anchor += 1
138
+ if anchor >= close - 1:
139
+ anchor = 1
140
+ lines.insert(anchor + 1, f'name: {dir_id}')
141
+ notes.append(f'补 `name: {dir_id}`')
142
+ elif old_name != dir_id:
143
+ lines[name_idx] = f'name: {dir_id}'
144
+ notes.append(f'`name: {old_name}` → `name: {dir_id}`')
145
+ if title_idx is None and old_name:
146
+ lines.insert(name_idx + 1, f'title: {old_name}')
147
+ notes.append(f'原标题保留为 `title: {old_name}`')
148
+ return '\n'.join(lines), notes
149
+
150
+
151
+ def iter_skills():
152
+ if not SKILLS.is_dir():
153
+ return
154
+ for d in sorted(SKILLS.iterdir()):
155
+ f = d / 'SKILL.md'
156
+ if d.is_dir() and f.is_file():
157
+ yield d.name, f
158
+
159
+
160
+ def main():
161
+ use_utf8_stdio() # 唯一出口编码判据:见 scripts/lib/stdio_utf8.py 的事故说明
162
+ mode = '--list' if '--list' in sys.argv else ('--write' if '--write' in sys.argv else '--check')
163
+ bad, fixed, total = [], [], 0
164
+ for name, path in iter_skills():
165
+ total += 1
166
+ text = path.read_text()
167
+ d = diagnose(text, expect_name=name)
168
+ if d['ok']:
169
+ if mode == '--list':
170
+ print(f' ✅ {name}')
171
+ continue
172
+ bad.append((name, d))
173
+ if mode == '--write':
174
+ new, notes = fix(text, name)
175
+ path.write_text(new)
176
+ again = diagnose(new, expect_name=name)
177
+ if again['ok']:
178
+ fixed.append((name, notes))
179
+ else:
180
+ print(f' ❌ {name}: 修复后仍不合法({again["reason"]})')
181
+ elif mode == '--list':
182
+ print(f' ❌ {name}: {d["reason"]}' + (f'(name={d.get("name")!r})' if d.get('name') else ''))
183
+ if d.get('insert_after'):
184
+ print(f' 应插在第 {d["insert_after"]} 行后')
185
+
186
+ if mode == '--write':
187
+ for name, notes in fixed:
188
+ print(f' ✅ {name}: ' + ';'.join(notes))
189
+ print(f'修复 {len(fixed)}/{len(bad)} 个')
190
+ return 0 if len(fixed) == len(bad) else 1
191
+
192
+ if mode == '--list':
193
+ return 0
194
+
195
+ if bad:
196
+ print(f'❌ {len(bad)} 个技能 frontmatter 不合法(这些技能**不会被加载**):')
197
+ for name, d in bad:
198
+ extra = f'(name={d.get("name")!r})' if d.get('name') else ''
199
+ if d.get('insert_after'):
200
+ extra += f'(应插在第 {d["insert_after"]} 行后)'
201
+ print(f' - {name}: {d["reason"]}{extra}')
202
+ print('\n修法:python3 scripts/fix-skill-frontmatter.py --write')
203
+ return 1
204
+ print(f'✅ 全部 {total} 个技能 frontmatter 合法(闭合 + name 为 kebab-case 且等于目录名 + description)')
205
+ return 0
206
+
207
+
208
+ if __name__ == '__main__':
209
+ sys.exit(main())
@@ -0,0 +1,454 @@
1
+ #!/usr/bin/env python3
2
+ """flow-auto-retry —— 流程失败「可用性类」的**受守卫自动重试**(用户口径方案 a;2026-10-13)。
3
+
4
+ 背景(真实现场):可用性类失败(网络/限流/浏览器瞬时类)会落到台账 `failed` → `blocked`,
5
+ 此后触发器只报"需人工",**不再自动跑**(现场两条票是人工手跑回来的)。用户拍板方案 (a):
6
+ 这类失败在**上限 + 冷却**约束下自动重试,而不是等人工。
7
+
8
+ ⛔ 本脚本**不写表**:真正"重置后重建"的动作全部委托 `scripts/flow_retry.py`
9
+ (走它的**全部**守卫:分类闸门 / 预约闸门 / 次数上限 / 写前整行备份 + 写后读回 + 审计 + 台账 `retry`)。
10
+ 本脚本只做**候选筛选 + 每轮限流 + 冷却 + 可见性**。
11
+
12
+ 候选(缺任一条 ⇒ **跳过并说明原因**,绝不"差不多就重试"):
13
+ ① 台账末条**状态性**事件 ∈ {failed, blocked}(已重试过的票末条是 retry ⇒ 不再筛进来,天然防重入);
14
+ 末条若是 `phase`/`wake` 这类**信息性**事件,回退到最近一条状态性事件(与 `flow_triage` 同一口径);
15
+ ② 唯一分类器 `scripts/flow_failure.py#classify` 判 **retryable**(⛔ 无 override:
16
+ 业务类/门户状态类一律跳过、保持需人工);
17
+ ③ 预约闸门 `scripts/flow_retry.py#appointment_gate` 放行(无预约残留 / 无"预约取消失败"留痕);
18
+ ④ **尝试**次数 < 上限(台账 `retry` 事件数 + 被委托侧拒绝的次数 —— 后者也要算,
19
+ 否则一张"每轮都会被拒"的票会永远每轮白占名额/白刷卡片);
20
+ ⑤ 距末条 failed/blocked ≥ 冷却分钟数;
21
+ ⑥ 每轮最多处理 N 票(避免风暴)。
22
+ 四个量(上限/冷却/每轮上限/开关)全部来自 `scripts/flow_config.py#autoRetry`(唯一读取点),
23
+ 可用 `--max` / `--cooldown-min` / `--per-cycle` 覆盖(运维/夹具用)。
24
+
25
+ 可见性:审计 JSONL(`<台账同目录>/flow-auto-retry-audit.jsonl`,每**真做**的一次重试一行)
26
+ + 台账(由 `flow_retry` 写的那条 `retry`,其 `detail` 带 `by=flow-auto-retry`)
27
+ + **一条**内部群卡片(复用既有通知路径 `scripts/feishu-card.mjs`;按轮汇总,⛔ 一票一条 = 噪音)。
28
+
29
+ ⚠️ **为什么本脚本不自己另写一条 `retry` 台账事件**(口径说明,⛔ 勿"顺手改回"):
30
+ `scripts/flow_triage.py` 的"人工放行重跑"闸门(Rule R)把**末条 `retry` 事件的 `by`**
31
+ 当作判据之一(必须是 `flow_retry`)。若自动重试再追加一条 `by=flow-auto-retry` 的 retry 事件,
32
+ 它就成为**末条** ⇒ 该票下一轮**既进不了 `to_dispatch`**、又落进"分诊未归类"兜底(需人工)
33
+ = 自动重试白做。⇒ 一次重试**只**由 `flow_retry` 记**恰好一条**;本脚本的署名落在
34
+ 它的 `--reason`(进台账 `detail`)与自己的审计 JSONL / 卡片 / 日志里。
35
+
36
+ 用法:
37
+ python3 scripts/flow-auto-retry.py --flow full # **dry-run**(默认;只打印将重试谁与原因)
38
+ python3 scripts/flow-auto-retry.py --flow full --yes # 真做(走 flow_retry 全部守卫)
39
+ … --max 2 --cooldown-min 20 --per-cycle 2 --only full__<rec> --no-notify --json
40
+ 退出码:0 正常(含"无候选 / 全部跳过 / 已关闭");2 前置不可用(配置或判据实现取不到 ⇒ 绝不猜)。
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import datetime
46
+ import json
47
+ import pathlib
48
+ import shutil
49
+ import subprocess
50
+ import sys
51
+ import time
52
+
53
+ HERE = pathlib.Path(__file__).resolve().parent
54
+ sys.path.insert(0, str(HERE))
55
+ sys.path.insert(0, str(HERE / 'lib'))
56
+
57
+ import flow_config # noqa: E402 唯一配置读取点(四个量的默认值只在它里面)
58
+ from argent_datadir import resolve_data_dir # noqa: E402 数据目录唯一判据(勿在此复制链)
59
+
60
+ DATA_DIR = pathlib.Path(resolve_data_dir()['dir'])
61
+ # 信息性事件(只带说明、不改变票据工作状态)⇒ 判据要回退到最近一条**状态性**事件。
62
+ # 逐条口径与 `scripts/flow_triage.py#INFO_EVENTS` 一致(2026-10-09 P0:末条 `phase` 曾让票静默消失)。
63
+ INFO_EVENTS = ('phase', 'wake', 'policy_apply', 'policy_new')
64
+ FAIL_EVENTS = ('failed', 'blocked')
65
+
66
+
67
+ # ══════════════════════════════════════════════════════════════════════════
68
+ # 判据实现:**只**从既有模块取(⛔ 本脚本不自写第二套分类/闸门规则)
69
+ # ══════════════════════════════════════════════════════════════════════════
70
+ def load_judges() -> tuple:
71
+ """取(分类器, 规范原因, 预约闸门, 失败步骤)。任一取不到 ⇒ 抛错(调用方停住)。"""
72
+ try:
73
+ from flow_failure import classify, canonical_reason # noqa: PLC0415
74
+ except Exception as exc: # noqa: BLE001
75
+ raise RuntimeError(f'失败分类器不可用({exc})—— 判据只能有一份,⛔ 本脚本不自写第二套')
76
+ try:
77
+ from flow_retry import appointment_gate, last_failed_step # noqa: PLC0415
78
+ except Exception as exc: # noqa: BLE001
79
+ raise RuntimeError(f'预约闸门不可用({exc})—— 有违约金风险,⛔ 缺闸门时绝不自动重试')
80
+ return classify, canonical_reason, appointment_gate, last_failed_step
81
+
82
+
83
+ def load_auto_retry_cfg(flow_id: str):
84
+ """四个量的**唯一读取点** = `flow_config.load_config(...)['autoRetry']`;读不到 ⇒ None(响亮)。"""
85
+ try:
86
+ cfg = flow_config.load_config(flow_id, strict=False)
87
+ except Exception as exc: # noqa: BLE001
88
+ print(f'❌ 读不到流程配置({exc})⇒ 不猜默认值,本轮不做', file=sys.stderr)
89
+ return None
90
+ ar = cfg.get('autoRetry')
91
+ if not isinstance(ar, dict) or 'enabled' not in ar:
92
+ print('❌ 配置读取点里没有 autoRetry 段(flow_config.py 版本过旧或投递不完整)'
93
+ '⇒ 本轮不做(⛔ 不自带第二份默认值)', file=sys.stderr)
94
+ return None
95
+ return ar, str(cfg.get('flowId') or flow_id)
96
+
97
+
98
+ # ══════════════════════════════════════════════════════════════════════════
99
+ # 台账读取(只读)
100
+ # ══════════════════════════════════════════════════════════════════════════
101
+ def read_events(path: pathlib.Path) -> list:
102
+ """读 JSONL 台账(坏行跳过)。读失败 ⇒ 抛 OSError(由 main 收口成 exit 2,⛔ 不当作"无候选")。"""
103
+ out = []
104
+ if not path.exists():
105
+ return out
106
+ for ln in path.read_text(encoding='utf-8').splitlines():
107
+ ln = ln.strip()
108
+ if not ln:
109
+ continue
110
+ try:
111
+ e = json.loads(ln)
112
+ except Exception: # noqa: BLE001
113
+ continue
114
+ if isinstance(e, dict) and e.get('caseId'):
115
+ out.append(e)
116
+ return out
117
+
118
+
119
+ def case_prefixes(flow_id: str, canonical: str) -> tuple:
120
+ """该流程在台账里可能出现的**全部** caseId 前缀(规范 id 优先 + 旧短名)。
121
+
122
+ 台账里写的是"当时那条流程叫什么":落位前的短名与落位后的规范 id 并存 ⇒ 只认一个会让
123
+ 另一半 case **集体消失**。首选引擎的规范实现(`flow_failure.case_prefixes`,它同时覆盖别名),
124
+ 取不到时**保守**用调用方给的两个名字(老克隆兜底)。
125
+ """
126
+ names = [n for n in (canonical, flow_id) if n]
127
+ try:
128
+ from flow_failure import case_prefixes as _engine_prefixes # noqa: PLC0415
129
+ names.extend(_engine_prefixes(canonical or flow_id))
130
+ except Exception: # noqa: BLE001
131
+ pass
132
+ return tuple(sorted({f'{n}__' for n in names}, key=len, reverse=True))
133
+
134
+
135
+ def effective_last(evs: list) -> tuple:
136
+ """(有效末条事件, 回退掉的信息性事件名)。信息性事件不改变票据状态 ⇒ 判定回退到状态性事件。"""
137
+ last_state, tail = None, ''
138
+ for e in evs:
139
+ ev = str(e.get('event') or '')
140
+ if ev in INFO_EVENTS:
141
+ tail = ev
142
+ continue
143
+ last_state, tail = e, ''
144
+ return last_state, tail
145
+
146
+
147
+ def retry_count(events: list, case_id: str) -> int:
148
+ """该 case 的 `retry` 事件数(= **成功重建过几次**)。
149
+
150
+ ⚠️ 判据必须与 `flow_retry.retry_count()` **逐字同口径**(同一条 `event == 'retry'`):
151
+ 本脚本先筛、`flow_retry` 再守 —— 两处口径若不同,就会出现"我说该重试、它拒绝"的空转。
152
+ """
153
+ return sum(1 for e in events
154
+ if str(e.get('caseId') or '') == case_id and str(e.get('event') or '') == 'retry')
155
+
156
+
157
+ def refused_count(audit_path: pathlib.Path, case_id: str) -> int:
158
+ """该 case **被委托侧拒绝过几次**(读本脚本自己的审计:`ok:false` 的那些行)。
159
+
160
+ 为什么上限要把它算进去:委托侧拒绝时**不会**写台账 `retry` 事件 ⇒ 只看台账事件数的话,
161
+ 一张"每轮都会被拒"的票(表里状态已不是失败 / 表备注分类不是可用性类 / 表备注的预约留痕
162
+ 与台账不一致…)会**每轮**白占一个每轮名额、白刷一条卡片与一行审计,直到永远。
163
+ 把"拒绝"也算作一次尝试 ⇒ 最多 `max` 次之后就明确交人工(日志点名原因)。
164
+ """
165
+ if not audit_path.exists():
166
+ return 0
167
+ n = 0
168
+ try:
169
+ for ln in audit_path.read_text(encoding='utf-8').splitlines():
170
+ ln = ln.strip()
171
+ if not ln:
172
+ continue
173
+ try:
174
+ e = json.loads(ln)
175
+ except Exception: # noqa: BLE001
176
+ continue
177
+ if str(e.get('caseId') or '') == case_id and e.get('ok') is False:
178
+ n += 1
179
+ except OSError:
180
+ return 0
181
+ return n
182
+
183
+
184
+ def plain_text(last: dict, canonical_reason) -> str:
185
+ """候选的失败文本(给分类器与预约闸门):`reason`(规范原因)→ `error` → `detail`,全并起来。"""
186
+ parts = [str(last.get('reason') or '').strip()]
187
+ err = str(last.get('error') or '').strip()
188
+ if err:
189
+ try:
190
+ err = canonical_reason(err) # 剥身份前缀,与表备注同源同文
191
+ except Exception: # noqa: BLE001
192
+ pass
193
+ parts.append(err)
194
+ parts.append(str(last.get('detail') or '').strip())
195
+ return ' '.join([p for p in parts if p]).strip()
196
+
197
+
198
+ # ══════════════════════════════════════════════════════════════════════════
199
+ # 四道闸门(逐个独立函数:既便于读,也便于门禁做"**去掉它就必须判红**"的有牙自证)
200
+ # ══════════════════════════════════════════════════════════════════════════
201
+ def check_classification(ctx: dict):
202
+ """② 可用性类判据:**只**认唯一分类器的 `retryable`(⛔ 无 override)。"""
203
+ kind = str((ctx.get('kind') or {}).get('kind') or '')
204
+ if kind != 'retryable':
205
+ return f"分类={kind}({(ctx.get('kind') or {}).get('reason') or ''})⇒ 非可用性类(业务/门户状态类),保持需人工"
206
+ return None
207
+
208
+
209
+ def check_appointment(ctx: dict):
210
+ """③ 预约闸门:无残留才允许重建(有预约未取消 ⇒ 违约金)。"""
211
+ gate = ctx.get('gate') or {}
212
+ if not gate.get('ok'):
213
+ return f"预约闸门拒绝({gate.get('why') or '证据不足'})"
214
+ return None
215
+
216
+
217
+ def check_upper_bound(ctx: dict):
218
+ """④ 次数上限:**尝试**次数(台账 `retry` 事件 + 被委托侧拒绝的次数)< 上限。"""
219
+ n = int(ctx.get('retryCount') or 0) + int(ctx.get('refusedCount') or 0)
220
+ mx = int(ctx['cfg']['max'])
221
+ if n >= mx:
222
+ r = int(ctx.get('refusedCount') or 0)
223
+ return f'已达上限({n}/{mx}{f",其中 {r} 次被委托侧拒绝" if r else ""})⇒ 交人工'
224
+ return None
225
+
226
+
227
+ def check_cooldown(ctx: dict):
228
+ """⑤ 冷却:距末条 failed/blocked 必须 ≥ 冷却分钟数。"""
229
+ left = float(ctx.get('cooldownLeftSec') or 0)
230
+ if left > 0:
231
+ return f'冷却未到(还需约 {int((left + 59) // 60)} 分钟)⇒ 交人工'
232
+ return None
233
+
234
+
235
+ # ⛔ `CHECKS` 是**四道闸门的唯一清单**(门禁 `scripts/tests/flow-auto-retry.test.mjs` 靠替换它
236
+ # 做"去掉某道闸门 ⇒ 夹具必须判红"的有牙自证 —— 改这个元组等于改判据强度,必须同时改用例)。
237
+ CHECKS = (check_classification, check_appointment, check_upper_bound, check_cooldown)
238
+
239
+
240
+ # ══════════════════════════════════════════════════════════════════════════
241
+ # 动作:委托 flow_retry(**不自己写表**)+ 审计 + 通知
242
+ # ══════════════════════════════════════════════════════════════════════════
243
+ def do_retry(flow_id: str, ctx: dict, cfg: dict) -> dict:
244
+ """委托 `flow_retry.py --yes`(走它全部守卫),返回 `{exit, ok, cmd, out}`。"""
245
+ n = int(ctx.get('retryCount') or 0)
246
+ reason = (f"自动重试(by=flow-auto-retry)第 {n + 1}/{cfg['max']} 次:"
247
+ f"{(ctx.get('kind') or {}).get('reason') or ''}")
248
+ cmd = [sys.executable, str(HERE / 'flow_retry.py'), '--flow', flow_id, '--case', ctx['caseId'],
249
+ '--yes', '--max', str(cfg['max']), '--reason', reason]
250
+ p = subprocess.run(cmd, capture_output=True, text=True)
251
+ out = f"{p.stdout or ''}{p.stderr or ''}".strip()
252
+ return {'exit': int(p.returncode), 'ok': int(p.returncode) == 0, 'cmd': cmd, 'out': out}
253
+
254
+
255
+ def audit(audit_path: pathlib.Path, rec: dict) -> None:
256
+ """追加一行审计(0600 由目录/文件默认权限决定;⛔ 不写业务值以外的敏感内容)。"""
257
+ try:
258
+ audit_path.parent.mkdir(parents=True, exist_ok=True)
259
+ with audit_path.open('a', encoding='utf-8') as fh:
260
+ fh.write(json.dumps(rec, ensure_ascii=False) + '\n')
261
+ except Exception as exc: # noqa: BLE001
262
+ print(f' ⚠️ 审计未落盘({exc})', file=sys.stderr)
263
+
264
+
265
+ def notify_card(flow_id: str, rows: list, cfg: dict) -> bool:
266
+ """**一条**内部群卡片(按轮汇总)。返回是否真发出。⛔ 通知失败不影响已做的重试(只告警)。"""
267
+ if not rows:
268
+ return False
269
+ script = HERE / 'feishu-card.mjs'
270
+ if not script.exists():
271
+ print(' ⚠️ 通知未发:缺少 scripts/feishu-card.mjs(旧版本)——见日志里的重试记录')
272
+ return False
273
+ node = shutil.which('node') or 'node'
274
+ cases = '、'.join(str(r['caseId']) for r in rows)[:200]
275
+ errs = ';'.join(f"{r['caseId']}:{str(r.get('why') or '')[:60]}" for r in rows)[:300]
276
+ args = [node, str(script), '--template', 'failure-alert', '--to', 'internal',
277
+ '--flow', flow_id, '--level', 'retryable', '--case', cases,
278
+ '--attempt', str(max(int(r.get('attempt') or 1) for r in rows)), '--max', str(cfg['max']),
279
+ '--error', errs or '可用性类失败(分类 retryable)',
280
+ '--action', '已自动重置重建(走 flow_retry 全部守卫);下一轮认领会自动跑,无需人工介入',
281
+ '--send']
282
+ try:
283
+ p = subprocess.run(args, capture_output=True, text=True, timeout=90)
284
+ except Exception as exc: # noqa: BLE001
285
+ print(f' ⚠️ 通知未发({exc})——已做的重试不受影响')
286
+ return False
287
+ if p.returncode != 0:
288
+ print(f" ⚠️ 通知未发(exit={p.returncode}):{(p.stderr or p.stdout or '').strip()[:200]}")
289
+ return False
290
+ print(f" 🔔 已发一条内部群卡片(本轮自动重试 {len(rows)} 票)")
291
+ return True
292
+
293
+
294
+ # ══════════════════════════════════════════════════════════════════════════
295
+ # 主流程
296
+ # ══════════════════════════════════════════════════════════════════════════
297
+ def main(argv=None) -> int:
298
+ ap = argparse.ArgumentParser(description='失败自动重试(可用性类;受守卫、默认 dry-run)')
299
+ ap.add_argument('--flow', default='full', help='流程 id(缺省 full)')
300
+ ap.add_argument('--ledger', default=str(DATA_DIR / 'flow-ledger.jsonl'), help='台账路径(缺省=数据目录)')
301
+ ap.add_argument('--yes', '--apply', dest='yes', action='store_true', help='真做(缺省 dry-run)')
302
+ ap.add_argument('--max', type=int, help='每票上限(覆盖 flow_config autoRetry.max)')
303
+ ap.add_argument('--cooldown-min', type=int, help='冷却分钟(覆盖 autoRetry.cooldownMin)')
304
+ ap.add_argument('--per-cycle', type=int, help='每轮最多处理几票(覆盖 autoRetry.perCycle)')
305
+ ap.add_argument('--only', help='只处理这一个 case(精确匹配 caseId)')
306
+ ap.add_argument('--no-notify', action='store_true', help='不发通知(日志与审计照旧)')
307
+ ap.add_argument('--now', type=float, help='覆盖"当前时间"(epoch 秒;仅测试/排障)')
308
+ ap.add_argument('--json', action='store_true', help='机器可读输出(夹具/巡检用)')
309
+ a = ap.parse_args(argv)
310
+
311
+ try:
312
+ classify, canonical_reason, appointment_gate, last_failed_step = load_judges()
313
+ except RuntimeError as exc:
314
+ print(f'❌ {exc}', file=sys.stderr)
315
+ return 2
316
+
317
+ loaded = load_auto_retry_cfg(a.flow)
318
+ if loaded is None:
319
+ return 2
320
+ ar, canonical = loaded
321
+ cfg = {
322
+ 'enabled': bool(ar.get('enabled')),
323
+ 'max': int(a.max if a.max is not None else ar.get('max')),
324
+ 'cooldownMin': int(a.cooldown_min if a.cooldown_min is not None else ar.get('cooldownMin')),
325
+ 'perCycle': int(a.per_cycle if a.per_cycle is not None else ar.get('perCycle')),
326
+ }
327
+ if cfg['max'] < 1 or cfg['perCycle'] < 1 or cfg['cooldownMin'] < 0:
328
+ print(f'❌ 取值非法: {cfg}(max/perCycle ≥ 1、cooldownMin ≥ 0)', file=sys.stderr)
329
+ return 2
330
+
331
+ ledger = pathlib.Path(a.ledger)
332
+ audit_path = ledger.parent / 'flow-auto-retry-audit.jsonl'
333
+ now = float(a.now if a.now is not None else time.time())
334
+ dry = not a.yes
335
+
336
+ result = {'flow': a.flow, 'canonical': canonical, 'dryRun': bool(dry), 'enabled': cfg['enabled'],
337
+ 'limits': {k: cfg[k] for k in ('max', 'cooldownMin', 'perCycle')},
338
+ 'ledger': str(ledger), 'audit': str(audit_path),
339
+ 'candidates': [], 'retried': [], 'notified': False}
340
+
341
+ if not cfg['enabled']:
342
+ print(f" 流程 {a.flow}:自动重试**已关闭**(flow_config autoRetry.enabled=false)⇒ 本轮不做")
343
+ if a.json:
344
+ print(json.dumps(result, ensure_ascii=False))
345
+ return 0
346
+
347
+ print(f" 流程 {a.flow}(规范 id={canonical})|自动重试:开|每票上限 {cfg['max']}"
348
+ f"|冷却 {cfg['cooldownMin']} 分|每轮上限 {cfg['perCycle']}|台账 {ledger}")
349
+ if ledger.parent != DATA_DIR:
350
+ print(f" ⚠️ 台账父目录({ledger.parent}) ≠ 数据目录({DATA_DIR}):委托的 flow_retry 仍写数据目录那份")
351
+
352
+ try:
353
+ events = read_events(ledger)
354
+ except OSError as exc:
355
+ print(f'❌ 台账读不到({exc})⇒ 本轮不做(⛔ 不把"读不到"当作"无候选")', file=sys.stderr)
356
+ return 2
357
+
358
+ prefixes = case_prefixes(a.flow, canonical)
359
+ by_case: dict = {}
360
+ for e in events:
361
+ cid = str(e.get('caseId') or '')
362
+ if any(cid.startswith(p) for p in prefixes):
363
+ by_case.setdefault(cid, []).append(e)
364
+
365
+ # ── 候选筛选(末条**状态性**事件 ∈ {failed, blocked})──────────────────────
366
+ cands = []
367
+ for cid, evs in by_case.items():
368
+ evs.sort(key=lambda e: (e.get('ts') or 0))
369
+ last, tail = effective_last(evs)
370
+ if last is None or str(last.get('event') or '') not in FAIL_EVENTS:
371
+ continue
372
+ if a.only and cid != a.only:
373
+ continue
374
+ cands.append((cid, last, tail))
375
+ # 先到先处理(最久没被处置的排在前面):与"每轮上限"配合时不会让某张票永远排不上
376
+ cands.sort(key=lambda x: float(x[1].get('ts') or 0))
377
+ n_tail = len([c for c in cands if c[2]])
378
+ if n_tail:
379
+ print(f" (其中 {n_tail} 张的末条是信息性事件,已按上一状态性事件判定)")
380
+ print(f" 候选 {len(cands)} 张(末条 failed/blocked)")
381
+
382
+ retried_rows = []
383
+ for cid, last, _tail in cands:
384
+ if len(retried_rows) >= cfg['perCycle']:
385
+ break
386
+ ts = float(last.get('ts') or 0)
387
+ text = plain_text(last, canonical_reason)
388
+ kind = classify(text)
389
+ record = cid.split('__', 1)[1] if '__' in cid else cid
390
+ gate = appointment_gate(text, last_failed_step(record))
391
+ ctx = {
392
+ 'caseId': cid, 'cfg': cfg, 'kind': kind, 'gate': gate, 'text': text,
393
+ 'retryCount': retry_count(events, cid),
394
+ 'refusedCount': refused_count(audit_path, cid),
395
+ 'cooldownLeftSec': max(0.0, cfg['cooldownMin'] * 60 - (now - ts)) if ts else 0.0,
396
+ }
397
+ skip = None
398
+ for check in CHECKS:
399
+ skip = check(ctx)
400
+ if skip:
401
+ break
402
+ tried = ctx['retryCount'] + ctx['refusedCount']
403
+ row = {'caseId': cid, 'attempt': tried + 1, 'retryCount': ctx['retryCount'],
404
+ 'refusedCount': ctx['refusedCount'],
405
+ 'kind': str(kind.get('kind') or ''), 'ts': int(ts),
406
+ 'cooldownLeftSec': int(ctx['cooldownLeftSec']), 'gate': str(gate.get('why') or '')}
407
+ if skip:
408
+ row.update({'decision': 'skip', 'why': skip})
409
+ print(f" · {cid} ⏭ 跳过:{skip}")
410
+ result['candidates'].append(row)
411
+ continue
412
+ refused_note = f"(其中 {ctx['refusedCount']} 次被委托侧拒绝)" if ctx['refusedCount'] else ''
413
+ row.update({'decision': 'retry',
414
+ 'why': f"分类=retryable|预约闸门放行({gate.get('why')})"
415
+ f"|已尝试 {tried}/{cfg['max']} 次{refused_note}|冷却已过"})
416
+ result['candidates'].append(row)
417
+ if dry:
418
+ print(f" · {cid} ▶ [dry-run] 将自动重试(第 {row['attempt']}/{cfg['max']} 次):{row['why']}")
419
+ continue
420
+ print(f" · {cid} ▶ 自动重试(第 {row['attempt']}/{cfg['max']} 次):{row['why']}")
421
+ got = do_retry(a.flow, ctx, cfg)
422
+ for line in (got['out'] or '').splitlines():
423
+ print(f' {line}')
424
+ row['exit'] = got['exit']
425
+ row['ok'] = got['ok']
426
+ retried_rows.append(row)
427
+ audit(audit_path, {
428
+ 'at': datetime.datetime.now().isoformat(timespec='seconds'),
429
+ 'flow': a.flow, 'canonical': canonical, 'caseId': cid, 'action': 'auto-retry',
430
+ 'by': 'flow-auto-retry', 'attempt': row['attempt'], 'max': cfg['max'],
431
+ 'classification': row['kind'], 'appointmentGate': row['gate'],
432
+ 'reason': text[:200], 'delegated': 'flow_retry.py', 'exit': got['exit'], 'ok': got['ok'],
433
+ 'auditFile': str(audit_path), 'ledger': str(ledger),
434
+ })
435
+ if not got['ok']:
436
+ print(f" ⚠️ 本次未成功(exit={got['exit']})⇒ 该票保持原状,等人工/下一轮(不重试本次)")
437
+
438
+ result['retried'] = retried_rows
439
+ ok_n = len([r for r in retried_rows if r.get('ok')])
440
+ if dry:
441
+ will = len([r for r in result['candidates'] if r['decision'] == 'retry'])
442
+ print(f" [dry-run] 未写入:将自动重试 {will} 票(上限 {cfg['perCycle']});加 --yes 才落库"
443
+ if will else f" [dry-run] 无票可自动重试(跳过 {len(result['candidates'])} 张,原因见上)")
444
+ else:
445
+ print(f" 完成:自动重试 {len(retried_rows)} 票(成功 {ok_n})|审计 {audit_path}")
446
+ if retried_rows and not a.no_notify:
447
+ result['notified'] = bool(notify_card(a.flow, retried_rows, cfg))
448
+ if a.json:
449
+ print(json.dumps(result, ensure_ascii=False))
450
+ return 0
451
+
452
+
453
+ if __name__ == '__main__':
454
+ sys.exit(main())