@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,314 @@
1
+ #!/usr/bin/env python3
2
+ """按**能力**观测(迭代 3-⑬):把"按 flow 平铺"的台账聚合成「能力 → 成功率 / 耗时 / 卡点 / 集成健康度」。
3
+
4
+ ## 为什么要按能力聚合
5
+ 2026-09-20 的三次故障(step7 箱唛 64s、放行勾漏清、PO 映射错)都不是"某条 flow 坏了",而是
6
+ **某个能力/某个集成**的共性问题的两次显形。按 flow 平铺只能看到"今天哪条红了",
7
+ 按能力看才能看到"发票这个能力最近三次都卡在同一阶段"。
8
+
9
+ ## 口径(都从既有台账派生,不新增埋点)
10
+ - 台账事件:`dispatched / done / failed / blocked / failed_ack / phase / retry / wake`;
11
+ - **成功率** = `done / (done + failed + blocked)`(`failed_ack`=人工判定与流程无关 ⇒ 不计入分母);
12
+ - **耗时** = 同一 caseId 的 `dispatched → 终态` 的秒数差(取终态前最近一次 dispatched);
13
+ - **卡点** = `phase` 事件里**失败 case 最后停留的阶段**(各能力最多的一条);
14
+ - **集成健康度** = 该集成适配器声明的 `ERROR_CLASSES` 对失败文本的分类命中数 + 最近一次命中时间;
15
+ - **未处置** = 走 `flow_failure.classify`(与失败回路**同一套判据**,不另立一套)。
16
+
17
+ 用法:
18
+ python3 scripts/flow_capability_stats.py [--days 7] [--json] [--flow <id>] [--ledger <path>]
19
+ 退出码:0 正常;2 读取失败。
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import datetime as dt
25
+ import json
26
+ import os
27
+ import pathlib
28
+ import sys
29
+
30
+ try:
31
+ from flow_registry import consumer_doc as _registry_doc # P2a:base + 业务线合并(唯一真源)
32
+ except Exception: # noqa: BLE001 —— 半坏机器上退回单文件(升级链路要能跑)
33
+ def _registry_doc(path=None):
34
+ _p = pathlib.Path(path) if path else (HERE / "flow-requirements.json")
35
+ return json.loads(_p.read_text(encoding="utf-8"))
36
+
37
+
38
+ HERE = pathlib.Path(__file__).resolve().parent
39
+ sys.path.insert(0, str(HERE))
40
+ sys.path.insert(0, str(HERE / 'lib'))
41
+ from argent_datadir import resolve_data_dir # noqa: E402 数据目录唯一判据(勿在此复制链)
42
+ from stdio_utf8 import use_utf8_stdio # noqa: E402 出口编码钉 UTF-8(中文 Windows 的 GBK 管道下非 ASCII 会崩)
43
+
44
+ REGISTRY = HERE / "flow-requirements.json"
45
+ TERMINAL = ("done", "failed", "blocked", "failed_ack")
46
+ PHASE_GAP_MAX = 6 * 3600 # 阶段分段上限(超过视为挂起,不计入阶段耗时)
47
+ KIND_CN = {"needs_human": "等人补数据", "needs_agent": "需判断/集成问题",
48
+ "retryable": "可重试", "unknown": "未分类"}
49
+
50
+
51
+ def load() -> dict:
52
+ return _registry_doc()
53
+
54
+
55
+ def read_ledger(path: pathlib.Path) -> list:
56
+ out = []
57
+ if not path.exists():
58
+ return out
59
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
60
+ line = line.strip()
61
+ if not line:
62
+ continue
63
+ try:
64
+ rec = json.loads(line)
65
+ except Exception: # noqa: BLE001
66
+ continue
67
+ if isinstance(rec, dict):
68
+ out.append(rec)
69
+ return out
70
+
71
+
72
+ def median(xs: list) -> float:
73
+ xs = sorted(float(x) for x in xs)
74
+ if not xs:
75
+ return 0.0
76
+ mid = len(xs) // 2
77
+ return xs[mid] if len(xs) % 2 else (xs[mid - 1] + xs[mid]) / 2
78
+
79
+
80
+ def _ts(rec: dict) -> float:
81
+ try:
82
+ return float(rec.get("ts") or 0)
83
+ except Exception: # noqa: BLE001
84
+ return 0.0
85
+
86
+
87
+ def _flow_of(case_id: str) -> str:
88
+ return str(case_id).split("__", 1)[0] if "__" in str(case_id) else ""
89
+
90
+
91
+ def _classify(error: str, attempt=None) -> str:
92
+ try:
93
+ from flow_failure import classify # 与失败回路同一套判据
94
+ return str(classify(error, attempt).get("kind") or "")
95
+ except Exception: # noqa: BLE001
96
+ return ""
97
+
98
+
99
+ def _adapter_error_hits(integration: str, errors: list) -> dict:
100
+ """集成健康度:用适配器自己的 ERROR_CLASSES 给失败文本归类(不另立一份词表)。"""
101
+ if not integration:
102
+ return {"status": "", "hits": {}, "byKind": {}, "lastHitAt": ""}
103
+ try:
104
+ import integration_contract as C
105
+ mod = C.load(integration)
106
+ except Exception: # noqa: BLE001
107
+ return {"status": "", "hits": {}, "byKind": {}, "lastHitAt": ""}
108
+ import re
109
+ rules = []
110
+ for pair in (getattr(mod, "ERROR_CLASSES", None) or {}).items() if isinstance(getattr(mod, "ERROR_CLASSES", None), dict) else []:
111
+ rules.append((str(pair[0]), str(pair[1])))
112
+ if not rules:
113
+ for item in (getattr(mod, "ERROR_CLASSES", None) or []):
114
+ try:
115
+ pat, kind = item[0], item[1]
116
+ except Exception: # noqa: BLE001
117
+ continue
118
+ rules.append((str(pat), str(kind)))
119
+ hits, by_kind, last = {}, {}, 0.0
120
+ for err, ts in errors:
121
+ # ① 集成自身错误词表命中("这个集成在报错")
122
+ for pat, kind in rules:
123
+ try:
124
+ if re.search(pat, err, re.I):
125
+ hits[kind] = hits.get(kind, 0) + 1
126
+ last = max(last, ts)
127
+ break
128
+ except re.error:
129
+ continue
130
+ # ② 失败**性质**(与失败回路同一判据):区分"集成坏了(needs_agent)"与
131
+ # "运营没填数据(needs_human)"—— 否则面板上"失败 5 次"会被读成"文鳐门户不稳",
132
+ # 实际是等人补字段(2026-09-20 用户读面板时问到的正是这一处)。
133
+ k = _classify(err) or "unknown"
134
+ by_kind[k] = by_kind.get(k, 0) + 1
135
+ return {
136
+ "status": str(getattr(mod, "STATUS", "") or ""),
137
+ "hits": hits,
138
+ "byKind": by_kind,
139
+ "lastHitAt": dt.datetime.fromtimestamp(last).strftime("%Y-%m-%d %H:%M") if last else "",
140
+ }
141
+
142
+
143
+ def stats(days: float = 7, ledger: pathlib.Path | None = None, only_flow: str = "",
144
+ doc: dict | None = None) -> dict:
145
+ d = doc or load()
146
+ since = dt.datetime.now().timestamp() - days * 86400
147
+ # 台账目录走**唯一判据**(三级链;勿在此复制链 —— 此前硬编码 ⇒ 隔离实例读生产台账)
148
+ led = ledger or (pathlib.Path(resolve_data_dir()['dir']) / "flow-ledger.jsonl")
149
+ recs = [r for r in read_ledger(led) if _ts(r) >= since]
150
+
151
+ caps = {k: v for k, v in (d.get("capabilities") or {}).items() if not k.startswith("_")}
152
+ flows = d.get("flows") or {}
153
+ out = {}
154
+ for cid, c in caps.items():
155
+ out[cid] = {"minPlan": c.get("minPlan", ""), "flows": [], "runs": 0, "done": 0, "failed": 0,
156
+ "blocked": 0, "acked": 0, "successRate": None, "avgSec": None, "slowestPhase": "",
157
+ "stuckAt": "", "pending": [], "integrations": {}, "lastAt": ""}
158
+ for fid, spec in flows.items():
159
+ cid = str((spec or {}).get("capability") or "")
160
+ if cid not in out:
161
+ continue
162
+ # P2a:`--flow` 可能给短名(旧 id)而注册表是规范 id ⇒ 先解析别名
163
+ _alias0 = d.get("aliases") or {}
164
+ _only = _alias0.get(only_flow, only_flow) if only_flow else ""
165
+ if _only and fid != _only:
166
+ continue
167
+ out[cid]["flows"].append(fid)
168
+ # P2a:台账里的 caseId 前缀是**写台账时的流程名**(历史与新数据都可能是旧名),
169
+ # 而注册表里是规范 id ⇒ 归属比较必须过一遍别名,否则统计会集体归零。
170
+ _alias = d.get("aliases") or {}
171
+ _canon = lambda pfx: _alias.get(pfx, pfx) # noqa: E731
172
+ mine = [r for r in recs if _canon(_flow_of(str(r.get("caseId") or ""))) == fid]
173
+ by_case: dict = {}
174
+ for r in mine:
175
+ by_case.setdefault(str(r.get("caseId") or ""), []).append(r)
176
+ errs = []
177
+ for case_id, rs in by_case.items():
178
+ rs.sort(key=_ts)
179
+ disp = [x for x in rs if x.get("event") == "dispatched"]
180
+ term = [x for x in rs if x.get("event") in TERMINAL]
181
+ # runs = 窗口内有活动的 case 数(不是 dispatched 事件数):跨窗口的长任务
182
+ # 可能"终态在窗口内、派发在窗口外",只数 dispatched 会让成功率分母与分子对不上
183
+ # (2026-09-20 实测 full 出现 done=25 / runs=1 的假象)。
184
+ if disp or term:
185
+ out[cid]["runs"] += 1
186
+ if term:
187
+ ev = term[-1].get("event")
188
+ if ev == "done":
189
+ out[cid]["done"] += 1
190
+ elif ev == "failed":
191
+ out[cid]["failed"] += 1
192
+ elif ev == "blocked":
193
+ out[cid]["blocked"] += 1
194
+ elif ev == "failed_ack":
195
+ out[cid]["acked"] += 1
196
+ if disp:
197
+ secs = max(0.0, _ts(term[-1]) - _ts(disp[-1]))
198
+ if secs and secs < 86400:
199
+ out[cid].setdefault("_dur", []).append(secs)
200
+ if term[-1].get("event") in ("failed", "blocked"):
201
+ phases = [x for x in rs if x.get("event") == "phase"]
202
+ if phases:
203
+ out[cid]["stuckAt"] = str(phases[-1].get("phase") or "") or out[cid]["stuckAt"]
204
+ # 集成健康度要的是**所有失败事件**(含被 retry 后成功的):只统计终态失败会漏掉
205
+ # "这个集成老在超时"这种最该看见的信号(2026-09-20 实测:net::ERR 重试成功后健康度里查无此事)。
206
+ for x in rs:
207
+ if x.get("event") != "failed":
208
+ continue
209
+ errs.append((str(x.get("error") or x.get("detail") or "").strip(), _ts(x)))
210
+ # 阶段耗时:相邻 phase 事件的间隔归给**前一个**阶段(最后一段归给终态)
211
+ phases = [x for x in rs if x.get("event") == "phase"]
212
+ marks = [(str(x.get("phase") or ""), _ts(x)) for x in phases]
213
+ if term:
214
+ marks.append(("(收尾)", _ts(term[-1])))
215
+ for i in range(len(marks) - 1):
216
+ name, t0 = marks[i]
217
+ gap = marks[i + 1][1] - t0
218
+ # 只统计 <6h 的分段:隔夜挂起的 case(等运营补数据)会把"某阶段耗时"拉到几十小时,
219
+ # 那种数字没有诊断价值(≠ 该阶段真的慢)。窗口外的分段直接丢弃并在输出里标注。
220
+ if name and 0 < gap < PHASE_GAP_MAX:
221
+ out[cid].setdefault("_phase", {}).setdefault(name, []).append(gap)
222
+ out[cid]["lastAt"] = dt.datetime.fromtimestamp(max([_ts(r) for r in mine] or [0])).strftime("%Y-%m-%d %H:%M") if mine else ""
223
+ # 未处置:**直接复用失败回路的 pending()**(不是"我照它写一遍")——
224
+ # 判据含"blocked 之后没有 failed_ack 仍算未处置"这条 2026-09-18 的修正,
225
+ # 自己重写必然慢慢漂移。
226
+ try:
227
+ from flow_failure import pending as failure_pending
228
+ for it in failure_pending(led, flow=fid):
229
+ out[cid]["pending"].append({
230
+ "caseId": str(it.get("caseId") or ""), "kind": str(it.get("kind") or ""),
231
+ "error": str(it.get("error") or "")[:160],
232
+ "at": dt.datetime.fromtimestamp(float(it.get("ts") or 0)).strftime("%Y-%m-%d %H:%M"),
233
+ })
234
+ except Exception: # noqa: BLE001
235
+ pass
236
+ # 集成健康度(能力下可能挂多个集成,如发票 = 文鳐 + 鑫中航)
237
+ integ = str((spec or {}).get("integration") or "")
238
+ if integ:
239
+ slot = out[cid]["integrations"].setdefault(
240
+ integ, {"status": "", "hits": {}, "byKind": {}, "lastHitAt": "", "errors": 0, "lastError": ""})
241
+ h = _adapter_error_hits(integ, errs)
242
+ slot["status"] = h["status"] or slot["status"]
243
+ slot["errors"] += len(errs)
244
+ for k, n in (h.get("byKind") or {}).items():
245
+ slot["byKind"][k] = slot["byKind"].get(k, 0) + n
246
+ if errs:
247
+ newest = max(errs, key=lambda e: e[1])
248
+ if newest[1] >= 0 and (not slot["lastError"] or True):
249
+ slot["lastError"] = str(newest[0])[:160]
250
+ for k, v in (h["hits"] or {}).items():
251
+ slot["hits"][k] = slot["hits"].get(k, 0) + v
252
+ slot["lastHitAt"] = max(slot["lastHitAt"], h["lastHitAt"])
253
+
254
+ for cid, v in out.items():
255
+ dur = v.pop("_dur", [])
256
+ phase = v.pop("_phase", {})
257
+ v["avgSec"] = round(sum(dur) / len(dur)) if dur else None
258
+ # 中位数而不是均值:长挂起的 case(隔夜等运营补数据)会把均值拉到小时级,
259
+ # 均值看着像"每条发票要跑 3 小时",中位数才反映典型耗时。
260
+ v["medianSec"] = round(median(dur)) if dur else None
261
+ v["phases"] = {k: round(median(x)) for k, x in
262
+ sorted(phase.items(), key=lambda kv: -median(kv[1]))[:5]}
263
+ v["slowestPhase"] = (f"{next(iter(v['phases']))} ≈{v['phases'][next(iter(v['phases']))]}s"
264
+ if v["phases"] else "")
265
+ denom = v["done"] + v["failed"] + v["blocked"]
266
+ v["successRate"] = round(v["done"] / denom, 3) if denom else None
267
+ v["pendingCount"] = len(v["pending"])
268
+ v["pending"] = v["pending"][:5]
269
+ return {"windowDays": days, "ledger": str(led), "generatedAt": dt.datetime.now().isoformat(timespec="seconds"),
270
+ "capabilities": out}
271
+
272
+
273
+ def main(argv=None) -> int:
274
+ use_utf8_stdio() # 唯一出口编码判据:见 scripts/lib/stdio_utf8.py 的事故说明
275
+ ap = argparse.ArgumentParser(description="按能力观测(成功率/耗时/卡点/集成健康度)")
276
+ ap.add_argument("--days", type=float, default=7)
277
+ ap.add_argument("--ledger")
278
+ ap.add_argument("--flow")
279
+ ap.add_argument("--json", action="store_true")
280
+ a = ap.parse_args(argv)
281
+ try:
282
+ data = stats(a.days, pathlib.Path(a.ledger) if a.ledger else None, a.flow or "")
283
+ except Exception as exc: # noqa: BLE001
284
+ print(f"❌ 读取台账失败:{exc}", file=sys.stderr)
285
+ return 2
286
+ if a.json:
287
+ print(json.dumps(data, ensure_ascii=False, indent=2))
288
+ return 0
289
+ print(f" 窗口:最近 {data['windowDays']:g} 天 · 台账 {data['ledger']}")
290
+ for cid, v in data["capabilities"].items():
291
+ rate = "—" if v["successRate"] is None else f"{v['successRate'] * 100:.0f}%"
292
+ dur = "—" if v["medianSec"] is None else f"中位 {v['medianSec']}s"
293
+ print(f" {cid:18} 档位={v['minPlan']:11} 流程={len(v['flows'])} 跑={v['runs']} "
294
+ f"成功={v['done']}/失败={v['failed']}/等人={v['blocked']} 成功率={rate} 平均={dur}")
295
+ if v["stuckAt"]:
296
+ print(f" 卡点:{v['stuckAt']}")
297
+ if v.get("slowestPhase"):
298
+ print(" 阶段耗时中位(只算 <6h 的分段):"
299
+ + "、".join(f"{k}≈{s2}s" for k, s2 in v["phases"].items()))
300
+ for name, h in (v["integrations"] or {}).items():
301
+ hits = "、".join(f"{k}×{n}" for k, n in (h["hits"] or {}).items()) or "无命中"
302
+ kinds = "、".join(f"{KIND_CN.get(k, k)} {n}" for k, n in (h.get("byKind") or {}).items()) or "—"
303
+ print(f" 集成 {name}({h['status'] or '?'}):失败 {h['errors']} 次({kinds})· 词表命中 {hits}"
304
+ + (f" · 最近 {h['lastHitAt']}" if h["lastHitAt"] else ""))
305
+ if h.get("lastError"):
306
+ print(f" 最近错误:{h['lastError'][:120]}")
307
+ if v["pendingCount"]:
308
+ print(f" 未处置 {v['pendingCount']} 条:"
309
+ + ";".join(f"{p['caseId']}[{p['kind']}]" for p in v["pending"][:3]))
310
+ return 0
311
+
312
+
313
+ if __name__ == "__main__":
314
+ raise SystemExit(main())
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ """流程「案件(case)」归一化层 —— 把**任意来源**的字段收敛成统一中间表示。
3
+
4
+ 为什么需要:数据源现在只有飞书表,将来会有别的方式(用户在会话里给字段、CSV、其他系统…)。
5
+ 做法是**要求侧与供给侧分离**:要求侧固定(`scripts/flow-requirements.json`:每流程必需字段 + 谁补),
6
+ 供给侧做**可插拔适配器**;两者之间用 **case** 对接,下游(分诊/闸门/执行/回写)只消费 case。
7
+
8
+ case 契约(version 1):
9
+ {
10
+ "version": 1,
11
+ "caseId": "<flowId>__<ticketKey>", # 幂等键:同流程同票 = 同 case
12
+ "flowId": "wenyao-invoice",
13
+ "ticketKey": "76637122",
14
+ "fields": { "<字段名>": <值>, ... },
15
+ "provenance": { # ★硬要求★ 每个字段的来源与提供者
16
+ "<字段名>": {"sourceId": "...", "sourceKind": "feishu-base|conversation|cli-json|...",
17
+ "providedBy": "<谁>", "at": "<ISO8601>"}
18
+ },
19
+ "sources": ["<sourceId>", ...], # 本 case 累积过的来源
20
+ "conflicts": [ {"field": "...", "kept": {...}, "rejected": {...}} ],
21
+ "createdAt": "...", "updatedAt": "..."
22
+ }
23
+
24
+ 用法:
25
+ python3 scripts/flow_case.py --flow wenyao-invoice --ticket 76637122 \
26
+ --source-id lip-feishu --source-kind feishu-base --provided-by 运营 \
27
+ --fields-from-json /tmp/row.json [--out-dir ~/.dsh/argent/flow-cases] [--json]
28
+
29
+ 退出码:0 = 写入/合并成功且无冲突 ; 1 = 有冲突(**落卡等人确认**,case 仍写入); 2 = 参数/IO 错误
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import datetime as _dt
35
+ import json
36
+ import os
37
+ import pathlib
38
+ import sys
39
+
40
+ # 数据目录:**唯一判据**在 `scripts/lib/argent_datadir.py`(三级链 `ARGENT_DATA_DIR` →
41
+ # `$DSH_HOME/argent` → `~/.dsh/argent`,与 JS 内核 `core/lib/argent-datadir.mjs` 同语义)。
42
+ # 历史:本文件曾**连 ARGENT_DATA_DIR 都不看** ⇒ 隔离跑(MCP 影子沙箱、测试夹具)写的 case
43
+ # 全落进**真实** `~/.dsh/argent/flow-cases/`(2026-10-02 实测:`r-flow__T-NEW` 夹具件污染生产目录);
44
+ # 后改两级链(不看 DSH_HOME)。现在一律走唯一判据。
45
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent / 'lib'))
46
+ from argent_datadir import resolve_data_dir # noqa: E402
47
+ DEFAULT_OUT = pathlib.Path(resolve_data_dir()['dir']) / 'flow-cases'
48
+ CASE_VERSION = 1
49
+
50
+
51
+ def _now() -> str:
52
+ return _dt.datetime.now().astimezone().isoformat(timespec="seconds")
53
+
54
+
55
+ def safe_key(ticket_key: str) -> str:
56
+ """文件名安全化:票号可能含 `/`(一个单元格写两个货件号)等路径字符。
57
+
58
+ 逻辑键(caseId 内的 ticketKey)保持原样,仅**落盘文件名**替换危险字符,
59
+ 避免 `a/b` 被当成目录。
60
+ """
61
+ bad = '/\\:*?"<>|\n\r\t '
62
+ out = ''.join('-' if c in bad else c for c in str(ticket_key)).strip('-')
63
+ return out or 'unknown'
64
+
65
+
66
+ def case_id(flow_id: str, ticket_key: str) -> str:
67
+ return f"{flow_id}__{ticket_key}"
68
+
69
+
70
+ def build_case(flow_id: str, ticket_key: str, fields: dict, source_id: str,
71
+ source_kind: str, provided_by: str, at: str | None = None) -> dict:
72
+ """从**一个来源**构造 case(新 case)。"""
73
+ at = at or _now()
74
+ return {
75
+ "version": CASE_VERSION,
76
+ "caseId": case_id(flow_id, ticket_key),
77
+ "flowId": flow_id,
78
+ "ticketKey": str(ticket_key),
79
+ "fields": dict(fields),
80
+ "provenance": {k: {"sourceId": source_id, "sourceKind": source_kind,
81
+ "providedBy": provided_by, "at": at} for k in fields},
82
+ "sources": [source_id],
83
+ "conflicts": [],
84
+ "createdAt": at,
85
+ "updatedAt": at,
86
+ }
87
+
88
+
89
+ def merge_case(existing: dict, flow_id: str, ticket_key: str, fields: dict, source_id: str,
90
+ source_kind: str, provided_by: str, at: str | None = None) -> tuple[dict, list]:
91
+ """把一个来源的字段**合并**进已有 case。
92
+
93
+ 规则(对应红线要求):
94
+ 1. **新字段** → 直接并入,记 provenance;
95
+ 2. **同值** → 仅追加来源(幂等,不产生冲突);
96
+ 3. **不同值** → **不覆盖**,记入 `conflicts` 交人确认(**绝不静默选一个**)。
97
+ · 例外(2026-09-18 修):`_` 前缀的**合成字段** 与 **同一来源**的再次读数 ⇒ 覆盖
98
+ (否则系统字段会被第一次读到的旧值冻住 —— 详见循环内注释)。
99
+ ⚠️ 这条正是"同一票在飞书表与会话里同时出现"的处理方式:冲突落卡等人。
100
+ """
101
+ at = at or _now()
102
+ out = json.loads(json.dumps(existing)) # 深拷贝,避免调用方对象被改
103
+ conflicts = []
104
+ for k, v in fields.items():
105
+ cur = out["fields"].get(k)
106
+ prev = out["provenance"].get(k) or {}
107
+ # 2026-09-18 真实故障修复:**系统维护字段必须刷新**,不能被下面的"不覆盖"规则冻住。
108
+ # 现场:运营在飞书表勾了「就绪」,但 case 第一次读到的是空值 ⇒ 之后每次合并都判
109
+ # "不同值 ⇒ 不覆盖" ⇒ `_phase` 永远停在 await_release、分诊恒为 to_dispatch=0,
110
+ # 触发器连续报"无票可认领"(勾了就绪也不跑)。清掉 case 目录才恢复,说明就是这里。
111
+ # 两类必须覆盖:① `_` 前缀的合成字段(`_phase`/`_record_id`…,系统算出来的);
112
+ # ② **同一来源**再次读到的值(同一个源刷新自己,不是跨源冲突)。
113
+ if k in out["fields"] and (str(k).startswith("_") or prev.get("sourceId") == source_id):
114
+ if str(cur) != str(v):
115
+ out["fields"][k] = v
116
+ out["provenance"][k] = {"sourceId": source_id, "sourceKind": source_kind,
117
+ "providedBy": provided_by, "at": at}
118
+ continue
119
+ if k not in out["fields"]:
120
+ out["fields"][k] = v
121
+ out["provenance"][k] = {"sourceId": source_id, "sourceKind": source_kind,
122
+ "providedBy": provided_by, "at": at}
123
+ elif str(cur) == str(v):
124
+ out["provenance"].setdefault(k, {})
125
+ out["provenance"][k].setdefault("alsoSeenFrom", [])
126
+ if source_id not in out["provenance"][k]["alsoSeenFrom"]:
127
+ out["provenance"][k]["alsoSeenFrom"].append(source_id)
128
+ else:
129
+ conflicts.append({
130
+ "field": k,
131
+ "kept": {"value": cur, "provenance": out["provenance"].get(k)},
132
+ "rejected": {"value": v, "sourceId": source_id, "sourceKind": source_kind,
133
+ "providedBy": provided_by, "at": at},
134
+ })
135
+ if source_id not in out["sources"]:
136
+ out["sources"].append(source_id)
137
+ out["conflicts"] = (out.get("conflicts") or []) + conflicts
138
+ out["updatedAt"] = at
139
+ return out, conflicts
140
+
141
+
142
+ def main(argv=None) -> int:
143
+ ap = argparse.ArgumentParser(description="流程 case 归一化(多源 → 统一中间表示)")
144
+ ap.add_argument("--flow", required=True, help="流程 id,如 wenyao-invoice")
145
+ ap.add_argument("--ticket", required=True, help="票据键,如货件号 76637122")
146
+ ap.add_argument("--fields-from-json", required=True,
147
+ help="字段 JSON 文件路径(对象),或 - 表示从 stdin 读")
148
+ ap.add_argument("--source-id", required=True, help="来源标识,如 lip-feishu / chat-oc_xxx")
149
+ ap.add_argument("--source-kind", default="cli-json",
150
+ help="来源类型:feishu-base | conversation | cli-json | csv | ...")
151
+ ap.add_argument("--provided-by", default="unknown", help="提供者(运营/用户/角色名)")
152
+ ap.add_argument("--out-dir", default=str(DEFAULT_OUT))
153
+ ap.add_argument("--json", action="store_true", help="把 case 打到 stdout")
154
+ a = ap.parse_args(argv)
155
+
156
+ try:
157
+ raw = sys.stdin.read() if a.fields_from_json == "-" else pathlib.Path(a.fields_from_json).read_text(encoding="utf-8")
158
+ fields = json.loads(raw)
159
+ if not isinstance(fields, dict):
160
+ raise ValueError("字段必须是 JSON 对象")
161
+ except Exception as exc: # noqa: BLE001
162
+ print(f"读取字段失败: {exc}", file=sys.stderr)
163
+ return 2
164
+
165
+ out_dir = pathlib.Path(a.out_dir).expanduser()
166
+ path = out_dir / f"{safe_key(case_id(a.flow, a.ticket))}.json"
167
+ try:
168
+ out_dir.mkdir(parents=True, exist_ok=True)
169
+ if path.exists():
170
+ existing = json.loads(path.read_text(encoding="utf-8"))
171
+ case, conflicts = merge_case(existing, a.flow, a.ticket, fields,
172
+ a.source_id, a.source_kind, a.provided_by)
173
+ else:
174
+ case = build_case(a.flow, a.ticket, fields, a.source_id, a.source_kind, a.provided_by)
175
+ conflicts = []
176
+ path.write_text(json.dumps(case, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
177
+ except Exception as exc: # noqa: BLE001
178
+ print(f"写入失败: {exc}", file=sys.stderr)
179
+ return 2
180
+
181
+ if a.json:
182
+ print(json.dumps(case, ensure_ascii=False, indent=2))
183
+ else:
184
+ print(f"case {case['caseId']} 字段 {len(case['fields'])} 个|来源 {len(case['sources'])} 个"
185
+ f"|冲突 {len(conflicts)} 个 → {path}")
186
+ for c in conflicts:
187
+ print(f" ⚠ 冲突 [{c['field']}] 保留={c['kept']['value']!r}"
188
+ f"({c['kept']['provenance'].get('sourceId') if c['kept'].get('provenance') else '?'})"
189
+ f" 拒绝={c['rejected']['value']!r}({c['rejected']['sourceId']})→ 需人确认")
190
+ return 1 if conflicts else 0
191
+
192
+
193
+ if __name__ == "__main__":
194
+ raise SystemExit(main())