@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,341 @@
1
+ #!/usr/bin/env python3
2
+ """流程注册表加载器(**引擎级 base + 每条业务线一份**)—— 唯一真源。
3
+
4
+ Argent 0.2 平台化第一步(P1,2026-09-29)
5
+ ========================================
6
+ 此前注册表是**单个全局文件** `scripts/flow-requirements.json`:所有业务线的流程
7
+ 声明挤在一起,于是"两条业务线同机"必然撞名(flow id / 角色 / env / 暂停开关 /
8
+ 技能名),独立迭代是假解耦。现在拆成:
9
+
10
+ scripts/flow-requirements.json 引擎级 base(引擎契约、错误词表、lease、billing 规则)
11
+ verticals/<id>/flows.json 该业务线的流程/能力/计费单位/错误类
12
+ verticals/<id>/manifest.json 该业务线元数据(版本、依赖、端口、角色、技能)
13
+
14
+ 合并规则(冲突**一律 fail**,不静默覆盖):
15
+ · flow :局部 id `<local>` → 规范 id `<vertical>.<local>`;重复规范 id ⇒ 冲突
16
+ · capability:同上(局部 id 不带点)
17
+ · billing.units / entitlements.roleMinPlan / failureClasses:按键合并,同名 ⇒ 冲突
18
+ · aliases :短名 → 规范 id(向后兼容用;别名撞车 ⇒ 冲突)
19
+
20
+ 兼容与开关:
21
+ · `--registry <path>` 显式指定单文件 ⇒ **legacy 单文件模式**(历史行为,测试依赖)
22
+ · 环境变量 `ARGENT_REGISTRY_MODE=legacy` ⇒ 只读 base(不扫 verticals)
23
+ · 环境变量 `ARGENT_REGISTRY_ROOT` ⇒ 覆盖仓库根(测试用隔离目录)
24
+
25
+ 设计约束:**零第三方依赖**(与 `scripts/lib/dsh-scope.mjs` 同规矩——升级链路与
26
+ 半坏机器上也要能跑)。Node/shell 侧不要自己实现合并,调本文件的 `--json`。
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import json
32
+ import os
33
+ import pathlib
34
+ import re
35
+ import sys
36
+
37
+ HERE = pathlib.Path(__file__).resolve().parent
38
+ REPO_ROOT = HERE.parent
39
+ sys.path.insert(0, str(HERE / 'lib'))
40
+ try:
41
+ from stdio_utf8 import use_utf8_stdio # 出口编码唯一判据(stdout/stderr 钉 UTF-8)
42
+ except ImportError: # noqa: BLE001 —— 只拷了单个脚本的夹具/半同步的树:**不许把注册表加载器打崩**
43
+ def use_utf8_stdio() -> bool:
44
+ print('⚠️ 缺 scripts/lib/stdio_utf8.py ⇒ stdout 不做 UTF-8 重配(中文 Windows 的 GBK 管道下'
45
+ '非 ASCII 会崩);补齐该文件即可(它在 pack.engine 的清单里)', file=sys.stderr)
46
+ return False
47
+ BASE_NAME = 'flow-requirements.json'
48
+ VERTICALS_DIRNAME = 'verticals'
49
+
50
+ # 局部 id 允许的字符:小写字母/数字/连字符(与技能名同规矩);**不许带点**(点留给命名空间)
51
+ LOCAL_ID_RE = re.compile(r'^[a-z0-9]+(?:-[a-z0-9]+)*$')
52
+
53
+ REQUIRED_MANIFEST = ('id', 'version', 'title', 'requires', 'ports', 'capabilities')
54
+
55
+
56
+ class Conflict(Exception):
57
+ """合并冲突:调用方应 fail 而不是继续(静默覆盖是这类架构最贵的 bug)。"""
58
+
59
+
60
+ def repo_root() -> pathlib.Path:
61
+ return pathlib.Path(os.environ.get('ARGENT_REGISTRY_ROOT') or REPO_ROOT)
62
+
63
+
64
+ def base_path(root: pathlib.Path | None = None) -> pathlib.Path:
65
+ return (root or repo_root()) / 'scripts' / BASE_NAME
66
+
67
+
68
+ def verticals_dir(root: pathlib.Path | None = None) -> pathlib.Path:
69
+ return (root or repo_root()) / VERTICALS_DIRNAME
70
+
71
+
72
+ def mode() -> str:
73
+ m = (os.environ.get('ARGENT_REGISTRY_MODE') or 'merged').strip().lower()
74
+ return m if m in ('merged', 'legacy') else 'merged'
75
+
76
+
77
+ def _read_json(p: pathlib.Path) -> dict:
78
+ try:
79
+ return json.loads(p.read_text(encoding='utf-8'))
80
+ except FileNotFoundError:
81
+ raise Conflict(f'注册表文件不存在: {p}')
82
+ except Exception as exc: # noqa: BLE001 —— 报错口径统一:文件 + 原因
83
+ raise Conflict(f'读取注册表失败: {p}: {exc}')
84
+
85
+
86
+ def _prefix(vertical: str, local: str) -> str:
87
+ """局部 id → 规范 id;已带 `<vertical>.` 前缀的原样返回(幂等)。"""
88
+ return local if local.startswith(f'{vertical}.') else f'{vertical}.{local}'
89
+
90
+
91
+ def _check_local_id(vertical: str, kind: str, local: str) -> None:
92
+ if not LOCAL_ID_RE.match(local):
93
+ raise Conflict(
94
+ f'{vertical}: {kind} 局部 id 不合法 `{local}`(只允许小写字母/数字/连字符,且**不含点**——点用于命名空间)'
95
+ )
96
+
97
+
98
+ def discover_verticals(root: pathlib.Path | None = None) -> list[tuple[str, pathlib.Path]]:
99
+ """列出已安装的业务线:目录名即 vertical id,必须含 manifest.json 与 flows.json。"""
100
+ vd = verticals_dir(root)
101
+ out: list[tuple[str, pathlib.Path]] = []
102
+ if not vd.is_dir():
103
+ return out
104
+ for d in sorted(vd.iterdir()):
105
+ if not d.is_dir() or d.name.startswith(('.', '_')):
106
+ continue
107
+ if not (d / 'manifest.json').exists() and not (d / 'flows.json').exists():
108
+ continue # 占位目录(如 README.md 同级空目录)不算业务线
109
+ out.append((d.name, d))
110
+ return out
111
+
112
+
113
+ def load_vertical(vid: str, vdir: pathlib.Path) -> dict:
114
+ """读一条业务线的 manifest + flows,做**结构级**校验(策略级检查在 vertical-lint)。"""
115
+ manifest_p = vdir / 'manifest.json'
116
+ flows_p = vdir / 'flows.json'
117
+ manifest = _read_json(manifest_p) if manifest_p.exists() else {}
118
+ if not manifest:
119
+ raise Conflict(f'{vid}: 缺 manifest.json(业务线必须有元数据)')
120
+ if manifest.get('id') != vid:
121
+ raise Conflict(f'{vid}: manifest.id=`{manifest.get("id")}` 与目录名不一致')
122
+ missing = [k for k in REQUIRED_MANIFEST if not manifest.get(k)]
123
+ if missing:
124
+ raise Conflict(f'{vid}: manifest.json 缺字段 {missing}(必需 {list(REQUIRED_MANIFEST)})')
125
+
126
+ doc = _read_json(flows_p) if flows_p.exists() else {}
127
+ if not doc:
128
+ raise Conflict(f'{vid}: 缺 flows.json(业务线至少要有 flows 声明;空业务线用 `{{}}` 也应显式存在)')
129
+ declared = doc.get('vertical')
130
+ if declared != vid:
131
+ raise Conflict(f'{vid}: flows.json 的 `vertical` 字段=`{declared}` 与目录名不一致(防复制粘贴串线)')
132
+ return {'id': vid, 'dir': vdir, 'manifest': manifest, 'doc': doc}
133
+
134
+
135
+ def merge(root: pathlib.Path | None = None) -> dict:
136
+ """合并 base + 所有业务线,返回**完整注册表文档**(形状与旧单文件一致,可直接替换)。"""
137
+ root = root or repo_root()
138
+ if mode() == 'legacy':
139
+ doc = _read_json(base_path(root))
140
+ doc.setdefault('_meta', {})['registryMode'] = 'legacy'
141
+ doc['_meta']['sources'] = [str(base_path(root))]
142
+ return doc
143
+
144
+ doc = _read_json(base_path(root))
145
+ out = dict(doc)
146
+ out['flows'] = dict(doc.get('flows') or {})
147
+ out['capabilities'] = dict(doc.get('capabilities') or {})
148
+ out['billing'] = dict(doc.get('billing') or {})
149
+ out['billing']['units'] = dict((doc.get('billing') or {}).get('units') or {})
150
+ out['entitlements'] = dict(doc.get('entitlements') or {})
151
+ out['entitlements']['roleMinPlan'] = dict((doc.get('entitlements') or {}).get('roleMinPlan') or {})
152
+ out['failureClasses'] = dict(doc.get('failureClasses') or {})
153
+
154
+ origin: dict[tuple[str, str], str] = {} # (空间, 键) → 来源,用于冲突信息
155
+ for k in out['flows']:
156
+ origin[('flows', k)] = 'base'
157
+ for k in out['capabilities']:
158
+ if not k.startswith('_'):
159
+ origin[('capabilities', k)] = 'base'
160
+ for k in out['billing']['units']:
161
+ origin[('billing.units', k)] = 'base'
162
+ for k in out['entitlements']['roleMinPlan']:
163
+ origin[('entitlements.roleMinPlan', k)] = 'base'
164
+ for k in out['failureClasses']:
165
+ if not k.startswith('_'):
166
+ origin[('failureClasses', k)] = 'base'
167
+
168
+ aliases: dict[str, str] = dict(doc.get('aliases') or {})
169
+ verticals_meta: dict[str, dict] = {}
170
+ sources = [str(base_path(root))]
171
+
172
+ def put(space: str, key: str, value, src: str) -> None:
173
+ if key.startswith('_'):
174
+ return # `_why`/`_note` 之类的说明键不参与合并(base 的保留)
175
+ seen = origin.get((space, key))
176
+ if seen is not None and seen != src:
177
+ raise Conflict(f'{space} 键 `{key}` 在 {seen} 与 {src} 重复定义(冲突即 fail,不静默覆盖)')
178
+ origin[(space, key)] = src
179
+ if space == 'billing.units':
180
+ out['billing']['units'][key] = value
181
+ elif space == 'entitlements.roleMinPlan':
182
+ out['entitlements']['roleMinPlan'][key] = value
183
+ elif space == 'failureClasses':
184
+ out['failureClasses'][key] = value
185
+ else:
186
+ out[space][key] = value
187
+
188
+ for vid, vdir in discover_verticals(root):
189
+ v = load_vertical(vid, vdir)
190
+ sources.append(str(vdir))
191
+ man, vdoc = v['manifest'], v['doc']
192
+ verticals_meta[vid] = {
193
+ 'title': man.get('title'),
194
+ 'version': man.get('version'),
195
+ 'requires': man.get('requires'),
196
+ 'verifiedOn': man.get('verifiedOn'),
197
+ 'ports': man.get('ports'),
198
+ 'roles': man.get('roles') or [],
199
+ 'skills': man.get('skills') or [],
200
+ }
201
+
202
+ for local, spec in (vdoc.get('flows') or {}).items():
203
+ _check_local_id(vid, 'flow', local)
204
+ put('flows', _prefix(vid, local), spec, vid)
205
+
206
+ for local, spec in (vdoc.get('capabilities') or {}).items():
207
+ _check_local_id(vid, 'capability', local)
208
+ put('capabilities', _prefix(vid, local), spec, vid)
209
+
210
+ for name, spec in (vdoc.get('billing', {}).get('units') or {}).items():
211
+ put('billing.units', name, spec, vid)
212
+ for name, spec in (vdoc.get('entitlements', {}).get('roleMinPlan') or {}).items():
213
+ put('entitlements.roleMinPlan', name, spec, vid)
214
+ for name, spec in (vdoc.get('failureClasses') or {}).items():
215
+ put('failureClasses', name, spec, vid)
216
+
217
+ for short, target in (vdoc.get('aliases') or {}).items():
218
+ if short in aliases and aliases[short] != target:
219
+ raise Conflict(f'别名 `{short}` 冲突:{aliases[short]} vs {target}(来自 {vid})')
220
+ aliases[short] = target
221
+
222
+ declared_flows = set(man.get('flows') or [])
223
+ if declared_flows:
224
+ actual = {_prefix(vid, k) for k in (vdoc.get('flows') or {})}
225
+ if declared_flows != actual:
226
+ raise Conflict(
227
+ f'{vid}: manifest.flows 与 flows.json 不一致 —— 只在 manifest 里: {sorted(declared_flows - actual)};'
228
+ f'只在 flows.json 里: {sorted(actual - declared_flows)}'
229
+ )
230
+
231
+ out['aliases'] = aliases
232
+ out['verticals'] = verticals_meta
233
+ out['_meta'] = {
234
+ 'registryMode': 'merged',
235
+ 'sources': sources,
236
+ 'verticals': sorted(verticals_meta),
237
+ }
238
+ return out
239
+
240
+
241
+ def consumer_doc(path=None) -> dict:
242
+ """给既有消费者(flow_source / orchestrator / 计费 / 权限 / 打包…)的统一读入点。
243
+
244
+ · `path` 给定 ⇒ 读该文件的纯内容(保留"显式传注册表"的历史语义,测试夹具依赖)。
245
+ · 未给定 ⇒ 合并 base + 各业务线。**合并冲突照常抛**(冲突必须响亮失败,
246
+ 不能因为"消费者怕麻烦"就静默回退成单文件 —— 那会让两条业务线悄悄撞名)。
247
+ """
248
+ return _read_json(pathlib.Path(path)) if path else merge()
249
+
250
+
251
+ def resolve_flow_id(flow_id: str, doc: dict | None = None) -> str:
252
+ """把别名解析成规范 flow id(无别名时原样返回)。"""
253
+ doc = doc if doc is not None else merge()
254
+ return (doc.get('aliases') or {}).get(flow_id, flow_id)
255
+
256
+
257
+ def load_flows(registry_path=None) -> dict:
258
+ """返回 flows 字典(历史形状:`load_registry()` 只返回 flows,多处依赖)。
259
+
260
+ `registry_path` 显式给出 ⇒ legacy 单文件模式(历史行为)。
261
+ """
262
+ if registry_path:
263
+ doc = _read_json(pathlib.Path(registry_path))
264
+ else:
265
+ doc = merge()
266
+ flows = doc.get('flows')
267
+ if not isinstance(flows, dict):
268
+ raise Conflict(f'流程注册表格式错误(缺 flows 对象): {registry_path or base_path()}')
269
+ return flows
270
+
271
+
272
+ def load_capabilities(registry_path=None) -> dict:
273
+ """返回 capabilities 段(与 flows 同级;空则 {})。"""
274
+ try:
275
+ doc = _read_json(pathlib.Path(registry_path)) if registry_path else merge()
276
+ except Conflict:
277
+ return {}
278
+ caps = doc.get('capabilities')
279
+ return caps if isinstance(caps, dict) else {}
280
+
281
+
282
+ def load_doc(registry_path=None) -> dict:
283
+ return _read_json(pathlib.Path(registry_path)) if registry_path else merge()
284
+
285
+
286
+ def main(argv=None) -> int:
287
+ use_utf8_stdio() # 唯一出口编码判据:见 scripts/lib/stdio_utf8.py 的事故说明
288
+ ap = argparse.ArgumentParser(description='流程注册表加载器(base + verticals 合并,冲突即 fail)')
289
+ ap.add_argument('--json', action='store_true', help='输出合并后的完整注册表(供 Node/shell 消费)')
290
+ ap.add_argument('--check', action='store_true', help='只做合并与冲突检查(成功打印摘要)')
291
+ ap.add_argument('--list', action='store_true', help='列出规范 flow id(含所属业务线)')
292
+ ap.add_argument('--flow', help='按 id 或别名取单条 flow 的规范 id')
293
+ ap.add_argument('--registry', help='legacy:显式指定单文件注册表(跳过合并)')
294
+ ap.add_argument('--mode', choices=('merged', 'legacy'), help='覆盖 ARGENT_REGISTRY_MODE')
295
+ a = ap.parse_args(argv)
296
+
297
+ if a.mode:
298
+ os.environ['ARGENT_REGISTRY_MODE'] = a.mode
299
+ try:
300
+ doc = load_doc(a.registry)
301
+ except Conflict as exc:
302
+ print(f'✗ 注册表合并失败:{exc}', file=sys.stderr)
303
+ return 2
304
+
305
+ if a.flow:
306
+ canonical = resolve_flow_id(a.flow, doc)
307
+ if canonical not in (doc.get('flows') or {}):
308
+ print(f'✗ 未知流程 `{a.flow}`(规范 id `{canonical}` 不在注册表中)', file=sys.stderr)
309
+ return 2
310
+ print(canonical)
311
+ return 0
312
+
313
+ if a.list:
314
+ for fid in sorted(doc.get('flows') or {}):
315
+ print(f' {fid}')
316
+ return 0
317
+
318
+ if a.check:
319
+ meta = doc.get('_meta') or {}
320
+ vs = meta.get('verticals') or []
321
+ # ⚠️ 不要用"嵌套同引号 f-string"(PEP 701,Python 3.12+ 才允许):
322
+ # 本仓库的流程脚本跑在 venv(**3.11**)里,系统 python 是 3.14 ⇒ 只有系统解释器能过,
323
+ # 真跑起来 SyntaxError(2026-09-29 实测:分诊全报"注册表为空")。
324
+ cap_n = len([k for k in (doc.get('capabilities') or {}) if not k.startswith('_')])
325
+ vs_note = f'({", ".join(vs)})' if vs else ''
326
+ print(f'✅ 注册表合并通过:模式={meta.get("registryMode")}、'
327
+ f'flow {len(doc.get("flows") or {})} 条、能力 {cap_n} 个、'
328
+ f'业务线 {len(vs)} 条{vs_note}')
329
+ return 0
330
+
331
+ if a.json:
332
+ # 供 Node/shell 消费:稳定输出(键序固定,便于比对)
333
+ print(json.dumps(doc, ensure_ascii=False, sort_keys=True))
334
+ return 0
335
+
336
+ ap.print_help()
337
+ return 0
338
+
339
+
340
+ if __name__ == '__main__':
341
+ raise SystemExit(main())
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env python3
2
+ """流程重跑(**重置后重建**)—— 用户口径 A 的落地面(2026-09-17)。
3
+
4
+ 背景(真实票 77328544 暴露):lip 表「一行一货件」,一轮跑挂掉后该行会停在
5
+ `状态=失败` + 货件号仍在(美客多那边预约已 `Reserva cancelada`、标签/箱唛没产出)。
6
+ 旧架构对这类行的恢复语义是 **清货件号 + 清备注 + 置「待执行」+ 就绪=true**
7
+ (orchestrator.py `_maybe_auto_reset` 的 lip 分支,KB-109「方案 A 重建」)⇒ 下一轮从头
8
+ 重建一个新货件,**不需要运营插手**。本脚本把这套语义做成**带守卫的独立动作**,
9
+ 供失败回路(retryable 类)与人工/掌舵者显式调用。
10
+
11
+ 守卫(写生产表,与发票回写器同规格):
12
+ ① 字段白名单:只许改 `状态 / 就绪 / 备注 / 货件号`(来自流程注册表的 fields 映射);
13
+ ② 记录白名单:必须 `--case`(或 `--record`)显式指定,一次一条;
14
+ ③ 分类闸门:该行备注必须分类为 **retryable**(flow_failure 同一套分类器);
15
+ 业务/门户状态类(如 `Reserva cancelada`、缺字段)**拒绝重置**,交人裁定;
16
+ ④ 次数上限:台账里该 case 的 `retry` 事件数 ≥ 上限(默认 2,同 AUTO_RETRY_MAX)⇒ 拒绝;
17
+ ⑤ 写前整行备份 + 写后读回验收 + 审计 JSONL + 台账 `retry` 事件;
18
+ ⑥ 默认 `--dry-run`,真写要 `--yes`。
19
+
20
+ 用法:
21
+ python3 scripts/flow_retry.py --flow full --case full__recvvgPQgoZW9B # 预览
22
+ python3 scripts/flow_retry.py --flow full --case full__recvvgPQgoZW9B --yes # 真写
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import datetime
28
+ import json
29
+ import os
30
+ import pathlib
31
+ import subprocess
32
+ import sys
33
+
34
+ HERE = pathlib.Path(__file__).resolve().parent
35
+ sys.path.insert(0, str(HERE))
36
+
37
+ import flow_config # noqa: E402 唯一配置读取点
38
+ from flow_failure import classify # noqa: E402 同一套分类器(避免两套规则漂移)
39
+ from flow_source import load_allowed_stores # noqa: E402
40
+
41
+ sys.path.insert(0, str(HERE / 'lib'))
42
+ from argent_datadir import resolve_data_dir # noqa: E402 数据目录唯一判据(勿在此复制链)
43
+
44
+ DATA_DIR = pathlib.Path(resolve_data_dir()['dir'])
45
+ AUDIT = DATA_DIR / "flow-retry-audit.jsonl"
46
+ BACKUP_DIR = DATA_DIR / "flow-retry-backup"
47
+ LEDGER = DATA_DIR / "flow-ledger.jsonl"
48
+ DEFAULT_MAX = 2
49
+
50
+
51
+ def lark(profile: str, args: list[str], timeout: int = 60):
52
+ p = subprocess.run(["lark-cli", "--profile", profile, "base", *args, "--as", "bot", "--format", "json"],
53
+ capture_output=True, text=True, timeout=timeout)
54
+ if p.returncode != 0:
55
+ raise SystemExit(f"❌ lark-cli 失败:{(p.stderr or p.stdout).strip()[:300]}")
56
+ try:
57
+ return json.loads(p.stdout or "{}")
58
+ except json.JSONDecodeError:
59
+ raise SystemExit(f"❌ lark-cli 返回不是 JSON:{(p.stdout or '')[:200]}")
60
+
61
+
62
+ def field_map(profile: str, base: str, table: str) -> dict:
63
+ d = lark(profile, ["+field-list", "--base-token", base, "--table-id", table])
64
+ items = ((d.get("data") or {}).get("fields") or d.get("fields") or [])
65
+ return {str(f.get("field_name") or f.get("name")): str(f.get("field_id") or f.get("id")) for f in items}
66
+
67
+
68
+ def read_row(profile: str, base: str, table: str, record: str, fmap: dict) -> dict:
69
+ """读整行(按**字段名**取值)。
70
+
71
+ 2026-09-17 实测:`+record-get` 返回**位置数组**形态 ——
72
+ `data.data=[值,…]` + `data.field_id_list=[字段id,…]`(与 record-list 同款,不带表头名),
73
+ 另有两种 dict 形态(`data.record.fields` / `data.fields`)在老版本里出现过。
74
+ 第一版只认 dict ⇒ **静默读成空行**(差点把"失败"误判成"已被重置")⇒
75
+ 现在三种形态都试,且**一个字段都读不出来就抛错**(宁可失败也不拿空行往下走)。
76
+ """
77
+ d = lark(profile, ["+record-get", "--base-token", base, "--table-id", table, "--record-id", record])
78
+ data = d.get("data") or {}
79
+ for c in (((data.get("record") or {}).get("fields")), data.get("fields")):
80
+ if isinstance(c, dict) and c:
81
+ return c
82
+ # 实测形态:data.data = [[值,…]](**行数组**,每行是位置数组),data.fields = [列名,…](同序);
83
+ # 另有 data.field_id_list 可兜底(列名缺失时按 id→名 映射)。
84
+ vals = data.get("data")
85
+ names = data.get("fields")
86
+ if isinstance(vals, list) and vals and isinstance(vals[0], list):
87
+ row = vals[0]
88
+ if isinstance(names, list) and len(names) >= len(row):
89
+ return {str(n): v for n, v in zip(names, row)}
90
+ ids = data.get("field_id_list") or []
91
+ if ids:
92
+ id2name = {v: k for k, v in fmap.items()}
93
+ return {id2name.get(str(fid), str(fid)): v for fid, v in zip(ids, row)}
94
+ if isinstance(vals, dict) and vals:
95
+ return vals
96
+ raise SystemExit(f"❌ 读不到记录 {record} 的任何字段(返回形状异常:{list(data)[:8]})—— 拒绝继续")
97
+
98
+
99
+ def retry_count(case_id: str) -> int:
100
+ n = 0
101
+ try:
102
+ for line in LEDGER.read_text(encoding="utf-8").splitlines():
103
+ if not line.strip():
104
+ continue
105
+ try:
106
+ e = json.loads(line)
107
+ except Exception:
108
+ continue
109
+ if e.get("caseId") == case_id and e.get("event") == "retry":
110
+ n += 1
111
+ except FileNotFoundError:
112
+ pass
113
+ return n
114
+
115
+
116
+ def first(v):
117
+ if isinstance(v, list):
118
+ return first(v[0]) if v else ""
119
+ if isinstance(v, dict):
120
+ return v.get("text") or v.get("name") or v.get("value") or ""
121
+ return "" if v is None else str(v)
122
+
123
+
124
+ FAILURES_JSONL = DATA_DIR / "fulfillment-logs" / "failures.jsonl"
125
+
126
+
127
+ def last_failed_step(record_id: str) -> int | None:
128
+ """从 failures.jsonl 取该 record **最近一条带 failed_step** 的记录(回退:None)。"""
129
+ try:
130
+ step = None
131
+ for ln in FAILURES_JSONL.read_text(encoding="utf-8").splitlines():
132
+ ln = ln.strip()
133
+ if not ln or record_id not in ln:
134
+ continue
135
+ try:
136
+ d = json.loads(ln)
137
+ except Exception:
138
+ continue
139
+ if d.get("record_id") == record_id and d.get("failed_step") is not None:
140
+ step = int(d["failed_step"])
141
+ return step
142
+ except Exception:
143
+ return None
144
+
145
+
146
+ # 2026-09-18 用户业务规则(逐字):
147
+ # 「FULL流程如果失败了,如果是已经预约了要取消预约的,否则会有违约金;
148
+ # 如果已经取消预约了,就可以从第一步开始重建货件号。」
149
+ # ⇒ 清号重建(本脚本的动作)**必须先确认无预约残留**。判据全部来自本地既有留痕,无需开浏览器:
150
+ def appointment_gate(note: str, failed_step: int | None) -> dict:
151
+ text = str(note or "")
152
+ if "预约已自动取消" in text or "无超时罚款风险" in text:
153
+ return {"ok": True, "why": "备注显示预约已自动取消 ⇒ 无残留,可重建"}
154
+ if "预约取消失败" in text or "请立即人工取消" in text:
155
+ return {"ok": False, "why": "备注显示**预约取消失败** ⇒ 必须先人工取消预约,禁止重建(违约金风险)"}
156
+ if failed_step is not None and failed_step <= 4:
157
+ return {"ok": True, "why": f"失败在步骤{failed_step}(步骤4未完成)⇒ 预约从未提交,无残留,可重建"}
158
+ if failed_step is not None and failed_step >= 5:
159
+ return {"ok": False,
160
+ "why": f"失败在步骤{failed_step}(步骤4已完成 ⇒ 曾建立预约)但备注里**没有任何取消留痕**"
161
+ f"(异常终止/取消未落痕)⇒ 先人工核对门户预约并取消,再重建"}
162
+ return {"ok": False, "why": "无法判定预约状态(备注与 failures.jsonl 都无留痕)⇒ 人工核对门户后放行"}
163
+
164
+
165
+ def main(argv=None) -> int:
166
+ ap = argparse.ArgumentParser(description="流程重跑(重置后重建,带守卫)")
167
+ ap.add_argument("--flow", required=True, help="流程 id(如 full)")
168
+ ap.add_argument("--case", help="台账 case id(full__<record_id>)")
169
+ ap.add_argument("--record", help="直接给 record_id")
170
+ ap.add_argument("--max", type=int, default=DEFAULT_MAX, help=f"自动重跑上限(默认 {DEFAULT_MAX},同 AUTO_RETRY_MAX)")
171
+ ap.add_argument("--reason", default="", help="重跑原因(写进审计/台账)")
172
+ ap.add_argument("--yes", action="store_true", help="真写(默认 dry-run)")
173
+ ap.add_argument("--allow-clear-shipment", action="store_true",
174
+ help="显式放行**清空已有货件号**(缺陷④ 闸门;默认拒绝——清号后重建会在平台留孤儿货件)")
175
+ ap.add_argument("--override", action="store_true",
176
+ help="人工授权覆盖分类闸门(仅当人已判断应重跑;必须配 --reason,会记审计)")
177
+ a = ap.parse_args(argv)
178
+
179
+ cfg = flow_config.load_config(a.flow)
180
+ src = cfg.get("source") or {}
181
+ base, table, profile = str(src.get("base") or ""), str(src.get("table") or ""), str(src.get("identity") or "")
182
+ if not (base and table and profile):
183
+ print("❌ 流程未解析出 base/table/identity", file=sys.stderr)
184
+ return 2
185
+ names = src.get("fields") or {}
186
+ f_status, f_ready = names.get("status"), names.get("ready")
187
+ f_note, f_ship = names.get("note"), None
188
+ # 货件号:票号列(source.ticketField 或默认「货件号」)
189
+ f_ship = src.get("ticketField") or "货件号"
190
+
191
+ record = a.record or (a.case.split("__", 1)[1] if a.case and "__" in a.case else "")
192
+ if not record:
193
+ print("❌ 需要 --case full__<record_id> 或 --record <record_id>(记录白名单,一次一条)", file=sys.stderr)
194
+ return 2
195
+ case_id = a.case or f"{a.flow}__{record}"
196
+
197
+ fmap = field_map(profile, base, table)
198
+ need = {f_status: "状态", f_ready: "就绪", f_note: "备注", f_ship: "货件号"}
199
+ missing = [n for n in need if not n or n not in fmap]
200
+ if missing:
201
+ print(f"❌ 表里缺这些列:{missing}(可用:{', '.join(list(fmap)[:12])})", file=sys.stderr)
202
+ return 2
203
+
204
+ row = read_row(profile, base, table, record, fmap)
205
+ status = first(row.get(f_status))
206
+ note = first(row.get(f_note))
207
+ ship = first(row.get(f_ship))
208
+ kind = classify(note, attempt=retry_count(case_id) + 1, max_attempts=a.max)
209
+ print(f"── 现场 ──")
210
+ print(f" 流程/表 : {a.flow}|{table}")
211
+ print(f" 记录 : {record}({f_ship}={ship or '(空)'})")
212
+ print(f" 状态 : {status or '(空)'}|就绪={first(row.get(f_ready)) or '(空)'}")
213
+ print(f" 备注 : {note[:120] or '(空)'}")
214
+ print(f" 分类 : {kind.get('kind')}|已重跑 {retry_count(case_id)}/{a.max} 次")
215
+
216
+ if status != "失败":
217
+ print(f"❌ 只重置「失败」行(当前={status or '空'})—— 其余状态请走正常链路", file=sys.stderr)
218
+ return 2
219
+ override = bool(a.override)
220
+ if kind.get("kind") != "retryable" and not override:
221
+ print(f"❌ 分类不是 retryable({kind.get('kind')})⇒ 拒绝重置,交人裁定:{kind.get('reason')}", file=sys.stderr)
222
+ print(" 若人工已判断「应当重跑」(例如原始失败是瞬时类、只是备注被取证结论覆盖),"
223
+ "用 --override --reason '<授权说明>' 显式放行;该动作会记进审计与台账。", file=sys.stderr)
224
+ return 3
225
+ if override and kind.get("kind") != "retryable":
226
+ print(f"⚠️ 人工覆盖分类闸门(原分类 {kind.get('kind')})—— 授权说明:{a.reason or '(未填写)'}")
227
+ # 预约闸门(用户业务规则):清号重建前必须确认**无预约残留**,否则违约金。
228
+ fstep = last_failed_step(record)
229
+ gate = appointment_gate(note, fstep)
230
+ print(f" 预约闸门: {'✅ 放行' if gate['ok'] else '⛔ 拒绝'}|{gate['why']}")
231
+ if not gate["ok"] and not override:
232
+ print(f"❌ 预约闸门拒绝({gate['why']})", file=sys.stderr)
233
+ print(" 业务规则:失败时若已预约必须先取消预约(否则违约金);确认无残留后才可从第一步重建。"
234
+ "\n 人工已核对门户确认无预约残留时,用 --override --reason '<核对说明>' 显式放行。", file=sys.stderr)
235
+ return 3
236
+ if retry_count(case_id) >= a.max:
237
+ print(f"❌ 已达自动重跑上限({a.max})⇒ 拒绝重置,保持失败走人工", file=sys.stderr)
238
+ return 3
239
+
240
+ # ── 缺陷④ 闸门(2026-09-23 角色交接):行内**已有货件号**时,默认**拒绝清号重置** ────────
241
+ # 为什么必须拦:货件号是我们找回"平台半成品货件"的唯一线索(MEDg 77832069 就是活例:
242
+ # 脚本清号重建过一次,平台侧那个货件仍在 En preparación)。清号 = 平台多一个孤儿货件。
243
+ # 实测三例(角色给的夹具):Ujxv 77840232 / KSA5 77843263 / r9ZC 77849070。
244
+ # 正确路径排序:① 改号后续跑(保留货件号,见缺陷⑰);② 确认平台侧已取消/无残留后**显式**放行;
245
+ # ③ 交人工裁定。
246
+ # ⚠️ 2026-10-13 修复(**死守卫**):此处原为 `one(row.get(f_ship))`,而本文件**没有** `one()` 定义
247
+ # ⇒ NameError 被下面的 `except Exception` 静默吞掉 ⇒ `ship_now=None` ⇒ **已有货件号也会被清号重置**:
248
+ # 这条"必须拦"的红线事实上从未生效。本文件其它取值点一律用 `first()`(见 :205-207)。
249
+ # 修法:改用唯一取值助手 `first()`(对 list/dict/None 都是全函数 ⇒ 不会抛),并**去掉那层吞噬**
250
+ # ⇒ 将来再写错名字会**响亮炸**,而不是静默退化成"守卫不生效"。
251
+ # 门禁:`scripts/tests/flow-retry-guard4.test.py` 钉住"本块不得再出现未定义助手 / 不得再被 except 吞"。
252
+ ship_now = first(row.get(f_ship))
253
+ if ship_now and not getattr(a, "allow_clear_shipment", False):
254
+ print(f"❌ 拒绝清号重置:本行已有货件号 {ship_now!r}(record={record})。", file=sys.stderr)
255
+ print(" 货件号是找回平台半成品货件的唯一线索;清掉再重建会在平台留下**孤儿货件**。", file=sys.stderr)
256
+ print(" 正确路径:① 若号码错位(缺陷⑰)⇒ 改号后保留货件号续跑;"
257
+ "② 已核对门户确认该货件无残留 ⇒ 用 --allow-clear-shipment --reason '<核对说明>' 显式放行;"
258
+ "③ 否则交人工裁定。", file=sys.stderr)
259
+ return 3
260
+
261
+ # lip 语义:清货件号 + 清备注 + 状态→待执行 + 就绪=true(下一轮从头重建)
262
+ pending = "待执行"
263
+ patch = {fmap[f_status]: pending, fmap[f_ready]: True, fmap[f_note]: "", fmap[f_ship]: ""}
264
+ allowed = {fmap[f_status], fmap[f_ready], fmap[f_note], fmap[f_ship]}
265
+ assert set(patch) <= allowed, "内部错误:补丁越界"
266
+
267
+ print("── 计划(重置后重建)──")
268
+ for k, v in patch.items():
269
+ label = {fmap[f_status]: f_status, fmap[f_ready]: f_ready, fmap[f_note]: f_note, fmap[f_ship]: f_ship}[k]
270
+ print(f" {label:8} → {v!r}")
271
+ if not a.yes:
272
+ print("\n[dry-run] 未写入。加 --yes 才落库。")
273
+ return 0
274
+
275
+ BACKUP_DIR.mkdir(parents=True, exist_ok=True)
276
+ at = datetime.datetime.now().isoformat(timespec="seconds")
277
+ bk = BACKUP_DIR / f"{a.flow}__{record}__{at.replace(':', '')}.json"
278
+ bk.write_text(json.dumps({"flow": a.flow, "base": base, "table": table, "record": record,
279
+ "at": at, "fields": row}, ensure_ascii=False, indent=2), encoding="utf-8")
280
+ # 载荷形状以**同表已验证**的发票回写器为准(wenyao-invoice-writeback.mjs:updateFields):
281
+ # {"update_records": {"<record_id>": {"<field_id>": 值, …}}}
282
+ # 第一版用了 {"records":[{record_id,fields}]} ⇒ 800010701 校验失败(record_id_list 等 3 处不合法)。
283
+ lark(profile, ["+record-batch-update", "--base-token", base, "--table-id", table,
284
+ "--json", json.dumps({"update_records": {record: patch}}, ensure_ascii=False)])
285
+ after = read_row(profile, base, table, record, fmap)
286
+ ok = (first(after.get(f_status)) == pending and str(after.get(f_ship) or "") == ""
287
+ and not first(after.get(f_note)))
288
+ with AUDIT.open("a", encoding="utf-8") as fh:
289
+ fh.write(json.dumps({"at": at, "flow": a.flow, "record": record, "caseId": case_id,
290
+ "action": "reset-rebuild", "kind": kind.get("kind"), "reason": a.reason,
291
+ "before": {"status": status, "shipment": ship, "note": note[:200]},
292
+ "after": {"status": first(after.get(f_status)), "shipment": first(after.get(f_ship))},
293
+ "backup": str(bk), "ok": bool(ok)}, ensure_ascii=False) + "\n")
294
+ subprocess.run([sys.executable, str(HERE / "flow_ledger.py"), "--ledger", str(LEDGER), "--case-id", case_id,
295
+ "--event", "retry", "--attempt", str(retry_count(case_id) + 1), "--by", "flow_retry",
296
+ "--detail", f"重置后重建({kind.get('kind')}):状态→{pending}|清货件号/备注|就绪=true"
297
+ + (f"|{a.reason}" if a.reason else "")], check=False)
298
+ print(f" ✅ 已重置并读回{'一致' if ok else '**不一致**'}|备份 {bk.name}|审计 flow-retry-audit.jsonl")
299
+ print(f" 下一轮该行会以「{pending}」+ 就绪=true 被识别为执行相,从头重建新货件(无需运营操作)")
300
+ return 0 if ok else 4
301
+
302
+
303
+ if __name__ == "__main__":
304
+ sys.exit(main())