@whyshu/argent-engine 0.0.0-stage → 0.6.1

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 (340) hide show
  1. package/MANIFEST.json +341 -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 +576 -0
  55. package/engine/scripts/billing-mode-daily.sh +13 -0
  56. package/engine/scripts/build-engine-package.mjs +126 -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 +378 -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/collect-evidence.mjs +42 -0
  81. package/engine/scripts/config-explain.mjs +278 -0
  82. package/engine/scripts/configure-provider.rb +29 -0
  83. package/engine/scripts/customer-config-migrate.mjs +179 -0
  84. package/engine/scripts/customer-preflight.py +892 -0
  85. package/engine/scripts/customer-readiness.sh +135 -0
  86. package/engine/scripts/daily-report-inbox.py +242 -0
  87. package/engine/scripts/daily-report-reconcile.mjs +312 -0
  88. package/engine/scripts/daily-report.mjs +1310 -0
  89. package/engine/scripts/daily-report.sh +47 -0
  90. package/engine/scripts/data/lip-product-dict.json +306 -0
  91. package/engine/scripts/deliver-customer.mjs +932 -0
  92. package/engine/scripts/delivery-faces.txt +31 -0
  93. package/engine/scripts/desktop-trial.sh +146 -0
  94. package/engine/scripts/dev-window.sh +261 -0
  95. package/engine/scripts/drill-clean-machine.sh +315 -0
  96. package/engine/scripts/dsh-api-client.mjs +215 -0
  97. package/engine/scripts/dsh-backup-scope.sh +83 -0
  98. package/engine/scripts/dsh-offline-pack.mjs +77 -0
  99. package/engine/scripts/dsh-overlay-pin.sh +87 -0
  100. package/engine/scripts/dsh-plane.mjs +153 -0
  101. package/engine/scripts/dsh-portforward.py +223 -0
  102. package/engine/scripts/dsh-preflight.mjs +179 -0
  103. package/engine/scripts/dsh-release-watch-daily.sh +19 -0
  104. package/engine/scripts/dsh-release-watch.mjs +458 -0
  105. package/engine/scripts/dsh-remote-check.sh +99 -0
  106. package/engine/scripts/dsh-restart.sh +179 -0
  107. package/engine/scripts/dsh-restore.sh +111 -0
  108. package/engine/scripts/dsh-scope-rewire.mjs +803 -0
  109. package/engine/scripts/dsh-snapshot.mjs +116 -0
  110. package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
  111. package/engine/scripts/dsh-upgrade.mjs +612 -0
  112. package/engine/scripts/dsh-url.sh +86 -0
  113. package/engine/scripts/dsh-web-launch.mjs +155 -0
  114. package/engine/scripts/ensure-store-windows.py +509 -0
  115. package/engine/scripts/entitlements.py +458 -0
  116. package/engine/scripts/env_probe.py +411 -0
  117. package/engine/scripts/feishu-card.mjs +252 -0
  118. package/engine/scripts/final-check.mjs +43 -0
  119. package/engine/scripts/fix-skill-frontmatter.py +209 -0
  120. package/engine/scripts/flow-auto-retry.py +454 -0
  121. package/engine/scripts/flow-requirements.json +735 -0
  122. package/engine/scripts/flow-scaffold.py +275 -0
  123. package/engine/scripts/flow-timers-plist.sh +87 -0
  124. package/engine/scripts/flow-timers.sh +184 -0
  125. package/engine/scripts/flow-trigger-wake.mjs +284 -0
  126. package/engine/scripts/flow-trigger.sh +571 -0
  127. package/engine/scripts/flow_billing.py +354 -0
  128. package/engine/scripts/flow_capability_stats.py +314 -0
  129. package/engine/scripts/flow_case.py +194 -0
  130. package/engine/scripts/flow_claim.py +497 -0
  131. package/engine/scripts/flow_config.py +1025 -0
  132. package/engine/scripts/flow_failure.py +588 -0
  133. package/engine/scripts/flow_learn.py +105 -0
  134. package/engine/scripts/flow_ledger.py +235 -0
  135. package/engine/scripts/flow_policy.py +238 -0
  136. package/engine/scripts/flow_preflight.py +339 -0
  137. package/engine/scripts/flow_registry.py +341 -0
  138. package/engine/scripts/flow_retry.py +304 -0
  139. package/engine/scripts/flow_return_to_ops.py +191 -0
  140. package/engine/scripts/flow_source.py +315 -0
  141. package/engine/scripts/flow_sources/__init__.py +53 -0
  142. package/engine/scripts/flow_sources/cases_json.py +106 -0
  143. package/engine/scripts/flow_sources/common.py +428 -0
  144. package/engine/scripts/flow_sources/feishu_base.py +273 -0
  145. package/engine/scripts/flow_stale.py +185 -0
  146. package/engine/scripts/flow_status_reconcile.py +344 -0
  147. package/engine/scripts/flow_triage.py +849 -0
  148. package/engine/scripts/flow_write_verify.py +496 -0
  149. package/engine/scripts/fulfillment.js +586 -0
  150. package/engine/scripts/gen-agent-bundles.mjs +276 -0
  151. package/engine/scripts/gen-preset-rows.mjs +259 -0
  152. package/engine/scripts/init-ml.sh +2278 -0
  153. package/engine/scripts/install-internal-role.sh +190 -0
  154. package/engine/scripts/install-license.mjs +641 -0
  155. package/engine/scripts/install-one-shot.mjs +665 -0
  156. package/engine/scripts/integration_contract.py +220 -0
  157. package/engine/scripts/integrations/wenyao/adapter.py +98 -0
  158. package/engine/scripts/integrations/xzh/adapter.py +75 -0
  159. package/engine/scripts/invoice-mapping.example.json +40 -0
  160. package/engine/scripts/invoice-table.sh +195 -0
  161. package/engine/scripts/invoice_carrier_notice.py +279 -0
  162. package/engine/scripts/invoice_fail_closeout.py +297 -0
  163. package/engine/scripts/invoice_lip_runner.py +1508 -0
  164. package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
  165. package/engine/scripts/invoice_ready_audit.py +191 -0
  166. package/engine/scripts/invoice_runner.py +675 -0
  167. package/engine/scripts/invoice_stageb.py +516 -0
  168. package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
  169. package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
  170. package/engine/scripts/lease.py +377 -0
  171. package/engine/scripts/lib/agent-install.mjs +649 -0
  172. package/engine/scripts/lib/argent-datadir.mjs +16 -0
  173. package/engine/scripts/lib/argent-datadir.sh +18 -0
  174. package/engine/scripts/lib/argent_datadir.py +285 -0
  175. package/engine/scripts/lib/argent_filelock.py +507 -0
  176. package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
  177. package/engine/scripts/lib/cdp_ports.py +197 -0
  178. package/engine/scripts/lib/customer-env.mjs +102 -0
  179. package/engine/scripts/lib/daily-report-core.mjs +517 -0
  180. package/engine/scripts/lib/dep-declaration.mjs +81 -0
  181. package/engine/scripts/lib/deps-migrate.mjs +310 -0
  182. package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
  183. package/engine/scripts/lib/dsh-scope.mjs +500 -0
  184. package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
  185. package/engine/scripts/lib/dsh-versions.mjs +96 -0
  186. package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
  187. package/engine/scripts/lib/is-direct-run.mjs +61 -0
  188. package/engine/scripts/lib/patch-compose.mjs +241 -0
  189. package/engine/scripts/lib/patch-target.mjs +35 -0
  190. package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
  191. package/engine/scripts/lib/plugin-packages.mjs +332 -0
  192. package/engine/scripts/lib/preset-compat.mjs +127 -0
  193. package/engine/scripts/lib/preset-install.mjs +575 -0
  194. package/engine/scripts/lib/preset-mechanism.mjs +56 -0
  195. package/engine/scripts/lib/preset-sync.mjs +59 -0
  196. package/engine/scripts/lib/profile-retire.mjs +97 -0
  197. package/engine/scripts/lib/python-runtime.mjs +190 -0
  198. package/engine/scripts/lib/registry.mjs +72 -0
  199. package/engine/scripts/lib/repo-root.sh +56 -0
  200. package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
  201. package/engine/scripts/lib/schedule-probe.mjs +835 -0
  202. package/engine/scripts/lib/scope-pointer.mjs +164 -0
  203. package/engine/scripts/lib/settings-panels.mjs +816 -0
  204. package/engine/scripts/lib/stdio_utf8.py +86 -0
  205. package/engine/scripts/lib/wake-auth.mjs +392 -0
  206. package/engine/scripts/lib/webdriver_ipc.py +264 -0
  207. package/engine/scripts/lib/zstd-frames.mjs +104 -0
  208. package/engine/scripts/license-e2e-drill.mjs +426 -0
  209. package/engine/scripts/license-gate.mjs +245 -0
  210. package/engine/scripts/lip_backlog.py +171 -0
  211. package/engine/scripts/lip_table.py +185 -0
  212. package/engine/scripts/local-usage.mjs +214 -0
  213. package/engine/scripts/migrate-providers.mjs +127 -0
  214. package/engine/scripts/migrate-skills.sh +65 -0
  215. package/engine/scripts/ml-oauth-setup.mjs +550 -0
  216. package/engine/scripts/ml-store-ids.json +26 -0
  217. package/engine/scripts/ml_artifact_attribution.py +333 -0
  218. package/engine/scripts/ml_capacity_probe.py +1158 -0
  219. package/engine/scripts/ml_hub_status.py +1556 -0
  220. package/engine/scripts/ml_schema_check.py +115 -0
  221. package/engine/scripts/ml_store_ids.py +139 -0
  222. package/engine/scripts/npm-publish-argent.mjs +499 -0
  223. package/engine/scripts/npm-publish-argent.sh +20 -0
  224. package/engine/scripts/orchestrator.py +13811 -0
  225. package/engine/scripts/pack-plugins.mjs +140 -0
  226. package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
  227. package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
  228. package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
  229. package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
  230. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
  231. package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
  232. package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
  233. package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
  234. package/engine/scripts/patches/verify-hardening.mjs +167 -0
  235. package/engine/scripts/patches/verify-session-migration.mjs +149 -0
  236. package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
  237. package/engine/scripts/plan-role-rename.mjs +125 -0
  238. package/engine/scripts/plugin-bootcheck.mjs +451 -0
  239. package/engine/scripts/plugin-composition.json +15 -0
  240. package/engine/scripts/poc-mcp-host.mjs +455 -0
  241. package/engine/scripts/poll-fulfillment.sh +295 -0
  242. package/engine/scripts/poll-invoice.sh +76 -0
  243. package/engine/scripts/profile-doctor.mjs +686 -0
  244. package/engine/scripts/provider-select.sh +71 -0
  245. package/engine/scripts/provider-switch.sh +146 -0
  246. package/engine/scripts/provider-sync-target.check.mjs +64 -0
  247. package/engine/scripts/python-runtime.sh +78 -0
  248. package/engine/scripts/query_runner.py +310 -0
  249. package/engine/scripts/release-argent.sh +574 -0
  250. package/engine/scripts/repair-unresolved-tools.mjs +178 -0
  251. package/engine/scripts/retire-inventory.mjs +40 -0
  252. package/engine/scripts/run-tests.sh +257 -0
  253. package/engine/scripts/s1-audit.mjs +85 -0
  254. package/engine/scripts/self-sign-license.mjs +179 -0
  255. package/engine/scripts/session-health-scan.mjs +63 -0
  256. package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
  257. package/engine/scripts/setup.sh +22 -0
  258. package/engine/scripts/skill-install.mjs +629 -0
  259. package/engine/scripts/sku_translate.py +148 -0
  260. package/engine/scripts/sync-plugins.sh +90 -0
  261. package/engine/scripts/sync-presets.sh +27 -0
  262. package/engine/scripts/sync-scripts.sh +445 -0
  263. package/engine/scripts/telemetry.py +873 -0
  264. package/engine/scripts/ui-copy-count.mjs +220 -0
  265. package/engine/scripts/usage-anchor.sh +137 -0
  266. package/engine/scripts/usage_report.py +531 -0
  267. package/engine/scripts/validate-preset-schema.mjs +208 -0
  268. package/engine/scripts/vertical-init.mjs +323 -0
  269. package/engine/scripts/vertical-lint.mjs +482 -0
  270. package/engine/scripts/wenyao-browser.sh +74 -0
  271. package/engine/scripts/wenyao-fixture-export.py +116 -0
  272. package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
  273. package/engine/scripts/wenyao-login.sh +35 -0
  274. package/engine/scripts/wenyao-template-map.json +292 -0
  275. package/engine/scripts/wenyao_attach.py +131 -0
  276. package/engine/scripts/wenyao_download_template.py +144 -0
  277. package/engine/scripts/wenyao_fill_template.py +568 -0
  278. package/engine/scripts/wenyao_login.py +145 -0
  279. package/engine/scripts/wenyao_portal.py +633 -0
  280. package/engine/scripts/wenyao_recon.py +230 -0
  281. package/engine/scripts/wenyao_run.py +503 -0
  282. package/engine/scripts/wenyao_shadow_diff.py +160 -0
  283. package/engine/scripts/wenyao_skill_contract.py +156 -0
  284. package/engine/scripts/wenyao_template_fetch.py +175 -0
  285. package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
  286. package/engine/scripts/ziniao-auth-probe.sh +268 -0
  287. package/engine/scripts/ziniao-keepalive.sh +217 -0
  288. package/engine/scripts/ziniao-post-upgrade-check.sh +121 -0
  289. package/engine/scripts/ziniao-windows.sh +154 -0
  290. package/engine/skills/README.md +86 -0
  291. package/engine/skills/argent-dev/SKILL.md +41 -0
  292. package/engine/skills/internal.json +37 -0
  293. package/engine/skills/lktop-pm/SKILL.md +35 -0
  294. package/engine/skills/lktopapp-dev/SKILL.md +34 -0
  295. package/engine/skills/ml-config-guide/SKILL.md +50 -0
  296. package/engine/skills/ml-fulfillment-sop/SKILL.md +178 -0
  297. package/engine/skills/ml-fulfillment-sop/references/andes-components.md +69 -0
  298. package/engine/skills/ml-fulfillment-sop/references/cdp-port-mapping.md +35 -0
  299. package/engine/skills/ml-fulfillment-sop/references/feishu-base-fulfillment-schema.md +136 -0
  300. package/engine/skills/ml-fulfillment-sop/references/multi-window-concurrency.md +69 -0
  301. package/engine/skills/ml-fulfillment-sop/references/pitfalls.md +77 -0
  302. package/engine/skills/ml-fulfillment-sop/references/selectors.md +243 -0
  303. package/engine/skills/ml-fulfillment-sop/scripts/fulfillment.js +586 -0
  304. package/engine/skills/ml-fulfillment-sop/scripts/poll-and-execute.sh +271 -0
  305. package/engine/skills/ml-fulfillment-sop/templates/report.md +55 -0
  306. package/engine/skills/ml-invoice/SKILL.md +506 -0
  307. package/engine/skills/ml-invoice/references/pitfalls.md +268 -0
  308. package/engine/skills/ml-invoice/references/portal-flow.md +360 -0
  309. package/engine/skills/ml-invoice/references/template-fill-contract.md +133 -0
  310. package/engine/skills/ml-invoice-xzh/SKILL.md +133 -0
  311. package/engine/skills/ml-invoice-xzh/references/pitfalls.md +230 -0
  312. package/engine/skills/ml-store-daily-report/SKILL.md +224 -0
  313. package/engine/skills/ml-store-daily-report/scripts/README.md +25 -0
  314. package/engine/skills/ml-store-daily-report/scripts/extract.js +691 -0
  315. package/engine/skills/ml-store-daily-report/scripts/generate.js +299 -0
  316. package/engine/skills/pet-dev/SKILL.md +42 -0
  317. package/engine/skills/whyshu-pm/SKILL.md +35 -0
  318. package/engine/skills/whyshu-web-dev/SKILL.md +50 -0
  319. package/engine/version.mjs +1 -0
  320. package/engine/verticals/README.md +103 -0
  321. package/engine/verticals/_engine-words.baseline.txt +237 -0
  322. package/engine/verticals/_engine-words.json +19 -0
  323. package/engine/verticals/mercadolibre/README.md +43 -0
  324. package/engine/verticals/mercadolibre/config.json +709 -0
  325. package/engine/verticals/mercadolibre/flows.json +738 -0
  326. package/engine/verticals/mercadolibre/manifest.json +538 -0
  327. package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
  328. package/engine/verticals/mercadolibre/preflight.py +143 -0
  329. package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
  330. package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
  331. package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
  332. package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
  333. package/engine/verticals/mercadolibre/prompts/full.md +91 -0
  334. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
  335. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
  336. package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
  337. package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
  338. package/package.json +22 -4
  339. package/requirements.txt +5 -0
  340. package/README.md +0 -3
@@ -0,0 +1,892 @@
1
+ #!/usr/bin/env python3
2
+ """客户机上线核对(**只读自检**)—— 三流程(FULL / 鑫中航 / 上海文鳐)逐项判 PASS/WARN/FAIL。
3
+
4
+ 为什么要有它:客户机与开发机是两台机器,差异恰恰都在"配置/身份/触发"这些不能靠代码门禁覆盖的面上
5
+ (2026-09-17 本机核对已经抓到两类真问题:插件侧 Python 解释器变量名与脚本口径不一致、
6
+ 流程源把"空的放行开关"当成已提供 ⇒ 分诊误报 83 张可派发)。逐条人工核对既慢又容易漏,
7
+ 故把**能自动判定的部分**固化成这个脚本;判定不了的部分(运营口径、门户步骤)留在
8
+ `docs/客户机部署与上线.md §4.3` 的人工确认清单里。
9
+
10
+ 红线:
11
+ · 本脚本**只读**:不写业务表、不建会话、不认领票据、不改配置。probe 落盘只落临时目录,
12
+ 台账/案例目录一律不碰(probe 用 --out-dir 指到 tmp,claim 加 --dry-run)。
13
+ · 默认跑在线探测(读表)——读的是客户生产表,**只读**;`--offline` 可完全离线(测试/无网)。
14
+
15
+ 用法:
16
+ python3 scripts/customer-preflight.py # 全量(环境+凭据+数据面+触发面)
17
+ python3 scripts/customer-preflight.py --offline # 跳过读表探测
18
+ python3 scripts/customer-preflight.py --full # 追加重量级门禁(preset schema + 启动冒烟)
19
+ python3 scripts/customer-preflight.py --json # 机器可读
20
+ python3 scripts/customer-preflight.py --flow full # 只看/只测某个流程
21
+ 退出码:0 = 无 FAIL;1 = 有 FAIL;2 = 脚本自身出错(参数/环境)。
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import datetime as dt
28
+ import hashlib
29
+ import importlib.util
30
+ import json
31
+ import os
32
+ import pathlib
33
+ import re
34
+ import shutil
35
+ import stat
36
+ import subprocess
37
+ import sys
38
+ import tempfile
39
+
40
+ try:
41
+ from flow_registry import consumer_doc as _registry_doc # P2a:base + 业务线合并(唯一真源)
42
+ except Exception: # noqa: BLE001 —— 半坏机器上退回单文件(升级链路要能跑)
43
+ def _registry_doc(path=None):
44
+ _p = pathlib.Path(path) if path else (HERE / "flow-requirements.json")
45
+ return json.loads(_p.read_text(encoding="utf-8"))
46
+
47
+
48
+ HERE = pathlib.Path(__file__).resolve().parent
49
+ DEFAULT_REPO = HERE.parent
50
+ # 数据目录:**唯一判据**在 `scripts/lib/argent_datadir.py`(三级链,与 JS 内核同语义);
51
+ # 客户机自检也必须用同一份实现,否则"自检通过"不代表直跑路径不劈叉。
52
+ sys.path.insert(0, str(HERE / "lib"))
53
+ from argent_datadir import resolve_data_dir # noqa: E402
54
+ FLOWS_DEFAULT = ["wenyao-invoice", "full", "xzh-invoice"]
55
+ # 触发/投递链路上**必须**存在的脚本:缺一个就等于"触发器静默不干活"
56
+ CORE_SCRIPTS = [
57
+ "flow_config.py", "flow_source.py", "flow_triage.py", "flow_claim.py",
58
+ "flow_ledger.py", "flow_failure.py", "flow_case.py", "ml_schema_check.py",
59
+ "flow-trigger.sh", "flow-trigger-wake.mjs", "flow-requirements.json",
60
+ # 数据目录判据(2026-10-02):flow-trigger.sh / dev-window.sh / 常驻巡检脚本**直接调用**
61
+ # 这个 CLI 包装取数据目录(判据本体在 scripts/lib/argent_datadir.py)⇒ 缺了它触发器会
62
+ # 响亮停住(fail-closed)。缺文件必须记 fail,不能只算"孤儿文件"warn。
63
+ "argent-datadir.py", "argent_datadir.py",
64
+ ]
65
+ SKIP_DIRS = {"__pycache__", "attic", "tests", "patches", "launchd", "flow-prompts"}
66
+ LOG_MARKERS = {
67
+ "argent-core": "[argent-core]",
68
+ "argent-gateway": "[argent-gateway]",
69
+ "argent-ml": "[argent-ml]",
70
+ "argent-flow": "[argent-flow]",
71
+ }
72
+
73
+
74
+ class Checks:
75
+ """收集检查结果。level ∈ ok/warn/fail/info。"""
76
+
77
+ def __init__(self) -> None:
78
+ self.items: list[dict] = []
79
+
80
+ def add(self, cid: str, group: str, level: str, title: str, detail: str = "",
81
+ fix: str = "", owner: str = "") -> None:
82
+ self.items.append({"id": cid, "group": group, "level": level, "title": title,
83
+ "detail": detail, "fix": fix, "owner": owner})
84
+
85
+ def counts(self) -> dict:
86
+ out = {"ok": 0, "warn": 0, "fail": 0, "info": 0}
87
+ for it in self.items:
88
+ out[it["level"]] = out.get(it["level"], 0) + 1
89
+ return out
90
+
91
+
92
+ def run(cmd: list[str], timeout: int = 60, env: dict | None = None) -> tuple[int, str, str]:
93
+ try:
94
+ p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout,
95
+ env={**os.environ, **(env or {})})
96
+ return p.returncode, (p.stdout or ""), (p.stderr or "")
97
+ except FileNotFoundError:
98
+ return 127, "", f"命令不存在: {cmd[0]}"
99
+ except subprocess.TimeoutExpired:
100
+ return 124, "", f"超时({timeout}s): {' '.join(cmd[:3])}"
101
+ except Exception as e: # noqa: BLE001
102
+ return 125, "", f"执行失败: {e}"
103
+
104
+
105
+ def sha(path: pathlib.Path) -> str:
106
+ try:
107
+ return hashlib.sha256(path.read_bytes()).hexdigest()
108
+ except Exception: # noqa: BLE001
109
+ return ""
110
+
111
+
112
+ def load_json(path: pathlib.Path):
113
+ try:
114
+ return json.loads(path.read_text(encoding="utf-8"))
115
+ except Exception: # noqa: BLE001
116
+ return None
117
+
118
+
119
+ def ver_tuple(v: str) -> tuple:
120
+ nums = re.findall(r"\d+", v or "")
121
+ return tuple(int(x) for x in nums[:3]) or (0,)
122
+
123
+
124
+ # ── 环境与挂载 ────────────────────────────────────────────────────────────────
125
+
126
+ def variant_gate_level(variant: str, active_mode: str) -> str:
127
+ """FULL 源变体的门禁级别:**当前口径**才算硬门禁,备胎变体只告警。
128
+
129
+ 2026-09-28 客户机实测:正式口径 lip 可读、legacy(另一张老表)不可读,而流程根本
130
+ 不会读 legacy。若把备胎也判 FAIL,预检就**永远挂着 1 项红** ⇒ 反而训练人忽略真红。
131
+ """
132
+ return "fail" if variant == active_mode else "warn"
133
+
134
+
135
+ def check_env(chk: Checks, repo: pathlib.Path, data_dir: pathlib.Path, profile: str) -> None:
136
+ G = "环境"
137
+ node = shutil.which("node")
138
+ if not node:
139
+ chk.add("env.node", G, "fail", "node 缺失", "PATH 里没有 node",
140
+ "安装 node ≥22.6(客户机用 ~/node/bin/node),并把 PATH 写进 LaunchAgent")
141
+ else:
142
+ code, out, _ = run([node, "-v"])
143
+ ver = out.strip()
144
+ if code == 0 and ver_tuple(ver) >= (22, 6):
145
+ chk.add("env.node", G, "ok", f"node {ver}", "满足 dsh ≥22.6 要求")
146
+ else:
147
+ chk.add("env.node", G, "fail", f"node {ver or '未知'}", "版本低于 22.6 ⇒ dsh 启动会崩",
148
+ "升级 node(客户机历史上 node 20 是启动崩溃根因)")
149
+
150
+ dsh = shutil.which("dsh")
151
+ if not dsh:
152
+ chk.add("env.dsh", G, "fail", "dsh 缺失", "PATH 里没有 dsh", "按 docs/客户机部署与上线.md 用本地目录方式安装")
153
+ else:
154
+ code, out, err = run([dsh, "--version"])
155
+ ver = (out or err).strip().splitlines()[0] if (out or err).strip() else ""
156
+ m = re.search(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.\-]+)?", ver)
157
+ ver = m.group(0) if m else ver
158
+ locks = sorted((repo / "dsh-version-lock").glob("*.json")) if (repo / "dsh-version-lock").is_dir() else []
159
+ known = {p.stem for p in locks}
160
+ if known and ver in known:
161
+ chk.add("env.dsh", G, "ok", f"dsh {ver}", f"与版本快照 {ver}.json 一致(快照数 {len(known)})")
162
+ else:
163
+ chk.add("env.dsh", G, "warn", f"dsh {ver or '未知'}",
164
+ f"未命中本仓库快照({', '.join(sorted(known)) or '无快照文件'})",
165
+ "确认升级/回滚是否走 scripts/dsh-upgrade.mjs;不要 npm install -g")
166
+
167
+ code, out, _ = run([sys.executable, "-V"])
168
+ py = (out or "").strip()
169
+ if code == 0 and ver_tuple(py) >= (3, 10):
170
+ chk.add("env.python", G, "ok", f"自检解释器 {py}", sys.executable)
171
+ else:
172
+ chk.add("env.python", G, "warn", f"自检解释器 {py or '未知'}", "流程脚本需要 ≥3.10",
173
+ "客户机用 ~/.local/bin/python3.11 跑脚本与触发器(见下一条 ARGENT_PY 判定)")
174
+
175
+ # 触发器用的解释器:**两侧口径都要看**(脚本侧读 ARGENT_PY;插件侧按候选顺序自探测)
176
+ # 解释器默认值(2026-09-22 重启事故):/tmp 易失 ⇒ 首选持久 venv,其次 PATH 上的 python3。
177
+ _cands = [os.environ.get("ARGENT_PY"), os.environ.get("ARGENT_PYTHON"),
178
+ os.path.expanduser("~/.dsh/argent/venv/bin/python3"),
179
+ shutil.which("python3") or ""]
180
+ eff = next((c for c in _cands if c and os.path.exists(c)), _cands[-1] or "python3")
181
+ plugin_pick = next((c for c in (os.environ.get("ARGENT_PYTHON"), os.environ.get("ARGENT_PY"),
182
+ str(pathlib.Path.home() / ".local" / "bin" / "python3.11"),
183
+ os.path.expanduser("~/.dsh/argent/venv/bin/python3"))
184
+ if c and pathlib.Path(c).exists()), "python3")
185
+ if pathlib.Path(eff).exists():
186
+ chk.add("env.argent_py", G, "ok", f"触发脚本解释器 {eff}",
187
+ f"插件侧(失败回路)会选:{plugin_pick}")
188
+ else:
189
+ chk.add("env.argent_py", G, "fail", f"触发脚本解释器不存在:{eff}",
190
+ f"插件侧当前会选 {plugin_pick}(持久 venv 优先;/tmp 路径重启即失效,已弃用)",
191
+ "在 LaunchAgent 的 EnvironmentVariables 里设 ARGENT_PY=~/.local/bin/python3.11"
192
+ "(插件同时认 ARGENT_PYTHON,两者设一个即可)")
193
+ if plugin_pick == "python3":
194
+ chk.add("env.argent_py_plugin", G, "warn", "插件侧未命中任何候选解释器(回退 PATH 的 python3)",
195
+ "PATH 里若没有 python3,失败回路会静默失效",
196
+ "设 ARGENT_PYTHON 或 ARGENT_PY 指向 ~/.local/bin/python3.11")
197
+
198
+ # 仓库状态:客户机只允许 git pull,不允许本地改动(KB-032:未提交改动阻塞 pull)
199
+ if (repo / ".git").exists():
200
+ code, out, _ = run(["git", "-C", str(repo), "rev-parse", "--short", "HEAD"])
201
+ head = out.strip() if code == 0 else "?"
202
+ code, out, _ = run(["git", "-C", str(repo), "status", "--porcelain"])
203
+ dirty = [l for l in out.splitlines() if l.strip()]
204
+ if dirty:
205
+ chk.add("env.repo", G, "warn", f"仓库有 {len(dirty)} 处未提交改动(HEAD {head})",
206
+ ";".join(dirty[:3]),
207
+ "客户机禁止直接改仓库:改动应回到开发机 commit+push,再 git pull")
208
+ else:
209
+ chk.add("env.repo", G, "ok", f"仓库干净(HEAD {head})", str(repo))
210
+ else:
211
+ chk.add("env.repo", G, "warn", "未找到仓库(或不是 git 仓库)", str(repo),
212
+ "触发器/门禁都假定仓库在 ~/Code/argent-dsh")
213
+
214
+ # 技能 frontmatter 门禁(两个坑:未闭合、中文 name ⇒ 技能静默不加载)
215
+ if (repo / "scripts" / "fix-skill-frontmatter.py").exists():
216
+ code, out, err = run([sys.executable, str(repo / "scripts" / "fix-skill-frontmatter.py"), "--check"],
217
+ timeout=120)
218
+ if code == 0:
219
+ chk.add("env.skills", G, "ok", "技能 frontmatter 门禁通过", (out or err).strip().splitlines()[-1:] and
220
+ (out or err).strip().splitlines()[-1] or "")
221
+ else:
222
+ chk.add("env.skills", G, "fail", "技能 frontmatter 不合法(技能会静默不加载)",
223
+ " | ".join((out or err).strip().splitlines()[:3]),
224
+ "跑 python3 scripts/fix-skill-frontmatter.py --write")
225
+
226
+ # 角色 → preset 映射(三方一致 + 被引用 preset 必须存在)
227
+ if (repo / "scripts" / "check-role-mapping.mjs").exists() and shutil.which("node"):
228
+ code, out, err = run(["node", str(repo / "scripts" / "check-role-mapping.mjs")], timeout=180)
229
+ last = [l for l in (out or err).strip().splitlines() if l.strip()][-1:]
230
+ if code == 0:
231
+ chk.add("env.roles", G, "ok", "角色映射门禁通过", last[0] if last else "")
232
+ else:
233
+ chk.add("env.roles", G, "fail", "角色映射不一致(角色会唤起错的/不存在的 preset)",
234
+ " | ".join((out or err).strip().splitlines()[:3]),
235
+ "对齐 gateway PRESET_BY_ROLE / roles.json / presets 目录,再跑 node scripts/check-role-mapping.mjs --write")
236
+
237
+ # 插件副本一致性(本仓库 vs 安装克隆 vs profile 快照)
238
+ install_home = pathlib.Path(os.environ.get("ARGENT_DISH_HOME") or (pathlib.Path.home() / ".argent-dsh"))
239
+ prof_dir = pathlib.Path.home() / ".dsh" / "profiles" / profile / "node_modules"
240
+ drift, missing, not_installed = [], [], []
241
+ for pdir in sorted((repo / "plugins").glob("*/")):
242
+ name = pdir.name
243
+ for dest_root, label in ((install_home / "plugins" / name, "安装克隆"), (prof_dir / name, "profile")):
244
+ if not dest_root.is_dir():
245
+ not_installed.append(f"{name}@{label}")
246
+ continue # 该目的地没装这个插件(如 argent-flow 只装进 profile)
247
+ for rel in ("src/index.js", "dist/client.js"):
248
+ src = pdir / rel
249
+ if not src.exists():
250
+ continue
251
+ dest = dest_root / rel
252
+ if not dest.exists():
253
+ missing.append(f"{name}/{rel}@{label}")
254
+ elif sha(dest) != sha(src):
255
+ drift.append(f"{name}/{rel}@{label}")
256
+ if drift:
257
+ chk.add("env.plugins", G, "fail", f"{len(drift)} 个插件文件与仓库不一致",
258
+ "、".join(drift[:5]), "在开发机 commit+push 后,客户机跑 bash scripts/sync-plugins.sh(不重启服务不生效)")
259
+ elif missing:
260
+ chk.add("env.plugins", G, "fail", f"插件文件缺失:{'、'.join(missing[:5])}",
261
+ "所在目录存在但缺文件 ⇒ 多半是同步漏项",
262
+ "重跑 bash scripts/sync-plugins.sh")
263
+ else:
264
+ chk.add("env.plugins", G, "ok", "已安装的插件副本与仓库一致",
265
+ ("未装到:" + "、".join(not_installed)) if not_installed else "安装克隆 + profile 快照均一致")
266
+
267
+ # scripts 副本一致性(插件用的是安装克隆里的 scripts ⇒ 漂移会导致"插件跑旧逻辑")
268
+ drift, missing, orphans = [], [], []
269
+ inst_scripts = install_home / "scripts"
270
+ if inst_scripts.is_dir():
271
+ for f in (repo / "scripts").rglob("*"):
272
+ if not f.is_file() or f.suffix not in (".py", ".mjs", ".sh", ".json"):
273
+ continue
274
+ rel = f.relative_to(repo / "scripts")
275
+ if rel.parts and rel.parts[0] in SKIP_DIRS:
276
+ continue
277
+ dest = inst_scripts / rel
278
+ if not dest.exists():
279
+ ((missing.append if f.name in CORE_SCRIPTS else orphans.append))(str(rel))
280
+ elif sha(dest) != sha(f):
281
+ drift.append(str(rel))
282
+ for f in inst_scripts.rglob("*"):
283
+ if f.is_file() and f.suffix in (".py", ".mjs", ".sh", ".json"):
284
+ rel = f.relative_to(inst_scripts)
285
+ if rel.parts and rel.parts[0] in SKIP_DIRS:
286
+ continue
287
+ if not (repo / "scripts" / rel).exists():
288
+ orphans.append(str(rel))
289
+ if drift or missing:
290
+ chk.add("env.scripts", G, "fail", f"安装克隆 scripts 与仓库漂移({len(drift)} 改动 / {len(missing)} 缺失)",
291
+ "、".join((drift + missing)[:6]),
292
+ "客户机跑 bash scripts/sync-plugins.sh 或 rsync 仓库 scripts/ 到 ~/.argent-dsh/scripts/")
293
+ else:
294
+ chk.add("env.scripts", G, "ok", "安装克隆 scripts 与仓库一致",
295
+ f"漂移 0;仓库已删除的残留文件 {len(orphans)} 个(无害)" if orphans else "漂移 0")
296
+ else:
297
+ chk.add("env.scripts", G, "fail", f"安装克隆 scripts 目录不存在:{inst_scripts}",
298
+ "插件默认 ARGENT_DISH_HOME=~/.argent-dsh", "按 docs/客户机部署与上线.md 同步安装克隆")
299
+
300
+ # 组合挂载:patch 行 + bundle
301
+ patch = pathlib.Path.home() / ".dsh" / "profiles" / profile / "cordis.patch.yml"
302
+ pkg = pathlib.Path.home() / ".dsh" / "profiles" / profile / "package.json"
303
+ text = patch.read_text(encoding="utf-8") if patch.exists() else ""
304
+ pj = load_json(pkg) or {}
305
+ bundles = ((pj.get("dsh") or {}).get("profile") or {}).get("bundles") or []
306
+ deps = pj.get("dependencies") or {}
307
+ # 组合目标来自 scripts/plugin-composition.json(**唯一真源**,JS 升级器读同一份)——
308
+ # 2026-09-28 教训:这里曾硬编码 ["argent-core","argent-gateway","argent-flow"],
309
+ # 而升级器当时的目标组合没有 argent-flow ⇒ 预检红、升级器却以为正常(两边知识分叉)。
310
+ comp = load_json(repo / "scripts" / "plugin-composition.json") or {}
311
+ need_patch = list(((comp.get("business") or {}).get("patch")) or [])
312
+ need_bundles = list(((comp.get("business") or {}).get("bundles")) or [])
313
+ lack = [n for n in need_patch if f"id: {n}" not in text]
314
+ lack += [f"{n}(bundle)" for n in need_bundles if n not in bundles]
315
+ if not lack:
316
+ chk.add("env.mount", G, "ok", "组合挂载项齐备",
317
+ f"patch 行 {len(need_patch)} 个({', '.join(need_patch)});"
318
+ f"bundles 含 {', '.join(need_bundles) or '-'};deps 含 argent-ml={'argent-ml' in deps}")
319
+ else:
320
+ chk.add("env.mount", G, "fail", f"组合挂载缺项:{'、'.join(lack)}",
321
+ f"profile={profile} patch={patch}",
322
+ "对照开发机的 cordis.patch.yml / package.json(dsh.profile.bundles)补齐,再重启服务")
323
+ if "argent-ml" not in deps:
324
+ chk.add("env.mount_ml", G, "warn", "profile 依赖里没有 argent-ml",
325
+ "脚本目录约定由 argent-ml 提供(scriptsDir=~/.argent-dsh/scripts)")
326
+
327
+ # 机器相关的**推送时刻**:config.json 的 mlDailyTime(站点本地 HH:MM)决定日报几点发。
328
+ # 2026-09-28 P10 实测:客户机缺这个键 ⇒ 静默退回默认 08:00(站点本地),
329
+ # 而开发机一直是 00:05 ⇒ 客户会在**北京 22:00** 收到卡,而不是习惯的 14:05。
330
+ # 这种"缺了就用另一个默认值"的差异不会报错,只会让人以为"今天怎么没推"。
331
+ cfg = load_json(data_dir / "config.json") or {}
332
+ if cfg.get("mlDailyTime"):
333
+ chk.add("env.daily_time", G, "ok", f"日报推送时刻 {cfg['mlDailyTime']}(站点本地)",
334
+ "config.json:mlDailyTime", "")
335
+ else:
336
+ chk.add("env.daily_time", G, "warn", "config.json 缺 mlDailyTime(默认站点本地 08:00)",
337
+ f"{data_dir / 'config.json'}",
338
+ "若与既有推送时刻不一致,客户会以为「今天没推」;按运营口径补 mlDailyTime(如 00:05)")
339
+
340
+ # 运行副本活着的证据(日志标记)
341
+ log = pathlib.Path("/tmp/dsh-web.log")
342
+ if not log.exists():
343
+ chk.add("env.live", G, "info", "未找到 /tmp/dsh-web.log(跳过存活标记检查)", "",
344
+ "用 --log 指定服务日志路径")
345
+ else:
346
+ tail = "\n".join(log.read_text(errors="replace").splitlines()[-4000:])
347
+ # 只对**组合里该有的**插件要求日志标记:2026-09-28 客户机实测,argent-ml 是按需安装的
348
+ # 独立插件(见 lib/plugin-lifecycle.mjs 的 LEGACY_PLUGIN_SOURCES),**永远不会**出现在
349
+ # 常驻日志里 ⇒ 原来的"四个插件都要有标记"会永久挂一条误导性告警,
350
+ # 措辞还写着"可能未重启"(让人白重启)。期望集合改从共享组合定义来。
351
+ expected = set((comp.get("business") or {}).get("patch") or []) | set((comp.get("business") or {}).get("bundles") or [])
352
+ absent = [n for n, mk in LOG_MARKERS.items() if n in expected and mk not in tail]
353
+ not_expected = [n for n in LOG_MARKERS if n not in expected]
354
+ if absent:
355
+ chk.add("env.live", G, "warn", f"日志尾部未见插件标记:{'、'.join(absent)}",
356
+ "可能是服务未启动,或 host 插件改动后**尚未重启**",
357
+ "重启 dsh web 后复核(host 改动必须重启才生效)")
358
+ else:
359
+ note = f"组合内 {len(expected)} 个 host 插件都在日志里出现过"
360
+ if not_expected:
361
+ note += f";按设计不常驻(非组合插件):{'、'.join(not_expected)}"
362
+ chk.add("env.live", G, "ok", note, "尾部 4000 行内")
363
+ # 「配置不可用」要看**多久以前**:dsh-web.log 是追加型,尾部 4000 行可能横跨好几天,
364
+ # 拿历史遗留当现在的故障会吓人(2026-09-20 实测:命中的是 09-17 的三行)。
365
+ # 判据:能在日志里找到带时间戳的最近一次出现 ⇒ 按 24h 判定;找不到时间戳 ⇒ 保持 warn。
366
+ if "配置不可用" in tail:
367
+ recent, when = False, ""
368
+ watch = data_dir / "flow-watch.log"
369
+ src = watch if watch.exists() else log
370
+ stamp = re.compile(r"\[(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})")
371
+ try:
372
+ for line in src.read_text(errors="replace").splitlines():
373
+ if "配置不可用" not in line:
374
+ continue
375
+ mt = stamp.search(line)
376
+ if not mt:
377
+ recent, when = True, ""
378
+ continue
379
+ seen = dt.datetime.strptime(f"{mt.group(1)} {mt.group(2)}", "%Y-%m-%d %H:%M:%S")
380
+ when = f"{mt.group(1)} {mt.group(2)}"
381
+ recent = recent or (dt.datetime.now() - seen).total_seconds() < 86400
382
+ except Exception: # noqa: BLE001
383
+ recent, when = True, ""
384
+ if recent:
385
+ chk.add("env.live_flow_cfg", G, "warn", "日志里出现「流程配置不可用」",
386
+ f"最近一次:{when or '(无时间戳)'}"
387
+ " —— 失败回路会跳过该流程(常见原因:插件改动后未重启,或部署 env 未加载)",
388
+ "重启服务后确认日志里 4 个流程都能解析;仍失败则查 fulfillment.env")
389
+ else:
390
+ chk.add("env.live_flow_cfg", G, "info",
391
+ f"日志里有「流程配置不可用」,但最近一次是 {when or '更早'}(>24h,历史遗留)",
392
+ "当前 4 个流程均可解析即可忽略;复核命令:python3 scripts/flow_config.py --list")
393
+
394
+
395
+ def check_heavy(chk: Checks, repo: pathlib.Path) -> None:
396
+ """重量级门禁:preset schema + 启动冒烟(会起隔离实例,几十秒)。"""
397
+ G = "环境(--full)"
398
+ if (repo / "scripts" / "validate-preset-schema.mjs").exists():
399
+ code, out, err = run(["node", str(repo / "scripts" / "validate-preset-schema.mjs"), "--runtime"], timeout=300)
400
+ if code == 0:
401
+ chk.add("full.preset_schema", G, "ok", "preset schema 校验通过", "persona 必须用 prefix:(0.1.5 起写 text: 会挂载失败)")
402
+ else:
403
+ chk.add("full.preset_schema", G, "fail", "preset schema 校验失败",
404
+ " | ".join((out or err).strip().splitlines()[:3]),
405
+ "修 presets/*/agent.cordis.yml 后重跑(写错会导致服务起不来)")
406
+ if (repo / "scripts" / "plugin-bootcheck.mjs").exists():
407
+ code, out, err = run(["node", str(repo / "scripts" / "plugin-bootcheck.mjs")], timeout=900)
408
+ line = [l for l in (out or err).splitlines() if "[PASS]" in l or "[FAIL]" in l or "preset-probe" in l][-1:]
409
+ if code == 0:
410
+ chk.add("full.bootcheck", G, "ok", "隔离实例启动冒烟通过", line[0] if line else "")
411
+ else:
412
+ chk.add("full.bootcheck", G, "fail", "启动冒烟失败(preset 挂载或插件树有问题)",
413
+ " | ".join((out or err).strip().splitlines()[-3:]),
414
+ "见 scripts/plugin-bootcheck.mjs 输出;修好前不要重启生产服务")
415
+
416
+
417
+ def check_product_gates(chk: Checks, repo: pathlib.Path) -> None:
418
+ """产品化门禁(迭代 3):档位矩阵 / 能力包 / 适配器契约三处一致性。
419
+
420
+ 为什么要进客户机自检:这三处**改了注册表或客户端就必须同步重生成**,
421
+ 漂移的后果是"客户看到的档位/能力与实际不符"(实测:免费版把企业级角色显示成可用)。
422
+ 三条都是**只读**校验,且都能在开发机先跑(同一份仓库)。
423
+ """
424
+ G = "产品化"
425
+ py = os.environ.get("ARGENT_PY") or sys.executable
426
+ gates = [
427
+ ("entitlements", "scripts/entitlements.py", "档位矩阵(网关表/客户端判断块/安装器免费集合)",
428
+ "跑 python3 scripts/entitlements.py --emit-gateway-table / --emit-client-block 重新生成后提交"),
429
+ ("capability_pack", "scripts/capability_pack.py", "能力包(版本锁 + 文件存在性 + 入口)",
430
+ "跑 python3 scripts/capability_pack.py --stamp 登记新内容(或回退改动)"),
431
+ ("integration_contract", "scripts/integration_contract.py", "货代适配器契约(含人工门与错误词表)",
432
+ "补齐/修正 scripts/integrations/<name>/adapter.py 后重跑"),
433
+ ]
434
+ for cid, rel, what, fix in gates:
435
+ path = repo / rel
436
+ if not path.exists():
437
+ chk.add(f"prod.{cid}", G, "warn", f"{rel} 不存在(旧版仓库?)",
438
+ "迭代 3 起随仓库提供;客户机 git pull 后应有", "git pull --ff-only 更新仓库")
439
+ continue
440
+ code, out, err = run([py, str(path), "--validate"], timeout=120)
441
+ text = (out or err or "").strip()
442
+ if code == 0:
443
+ chk.add(f"prod.{cid}", G, "ok", f"{what} 一致", text.splitlines()[-1][:200] if text else "")
444
+ else:
445
+ chk.add(f"prod.{cid}", G, "fail", f"{what} **不一致**",
446
+ " | ".join(text.splitlines()[:4]), fix)
447
+
448
+
449
+ # ── 凭据与身份 ────────────────────────────────────────────────────────────────
450
+
451
+ def check_credentials(chk: Checks, repo: pathlib.Path, data_dir: pathlib.Path,
452
+ flows: list[str], flows_cfg: dict) -> None:
453
+ G = "凭据"
454
+ # P2a:必须走合并(业务线流程不在 base 里);envFile 可能是规范路径(verticals/<id>/env/x.env)
455
+ registry = _registry_doc() or {}
456
+ env_files: set[str] = set()
457
+ for fid, spec in (registry.get("flows") or {}).items():
458
+ ef = (spec.get("source") or {}).get("envFile")
459
+ if ef:
460
+ env_files.add(str(ef))
461
+ for name in sorted(env_files):
462
+ # 规范位置优先(DATA/verticals/<id>/env/<file>),旧平铺兜底(DATA/<file>)——
463
+ # 与 flow_config._resolve_env_path 同规矩:迁移期两种都要认,否则老机器一律假红。
464
+ _key = pathlib.Path(name).name # 检查 id 用 basename:跨布局稳定
465
+ cands = [data_dir / name, data_dir / _key]
466
+ p = next((c for c in cands if c.exists()), cands[0])
467
+ if not p.exists():
468
+ chk.add(f"cred.env.{_key}", G, "fail", f"部署凭据文件缺失:{_key}", str(p),
469
+ "按 docs/客户机部署与上线.md / 部署清单补齐(内容不进仓库、不进对话)")
470
+ continue
471
+ mode = stat.S_IMODE(p.stat().st_mode)
472
+ if mode & 0o077:
473
+ chk.add(f"cred.env.{_key}", G, "fail", f"{_key} 权限过宽({oct(mode)})",
474
+ "含密钥的文件必须 600", f"chmod 600 {p}")
475
+ else:
476
+ chk.add(f"cred.env.{_key}", G, "ok", f"{_key} 存在且 600", f"{len(p.read_text(errors='replace').splitlines())} 行")
477
+
478
+ # 流程配置能否解析(表 id 是否拿得到)—— 这一条是触发器能否干活的前提
479
+ for fid in flows:
480
+ cfg = flows_cfg.get(fid)
481
+ if cfg is None:
482
+ continue
483
+ if isinstance(cfg, dict) and cfg.get("_error"):
484
+ # 解析失败不能静默跳过:触发器/失败回路都会"该流程不可用",等于这个流程没上线
485
+ chk.add(f"cred.cfg.{fid}", G, "fail", f"{fid} 配置不可解析:{cfg['_error']}",
486
+ "flow_config.py 严格模式失败(缺部署环境变量 / 表 id 拿不到)",
487
+ "补齐该流程声明的 envFile / *_BASE_TOKEN / *_TABLE_ID,或让它读的映射文件可读")
488
+ continue
489
+ src = cfg.get("source") or {}
490
+ base, table = str(src.get("base") or ""), str(src.get("table") or "")
491
+ if base and table:
492
+ extra = ""
493
+ if src.get("identityUnresolved"):
494
+ extra = f"|⚠️ 身份未由 {src['identityUnresolved']} 指定,暂用声明的 {src.get('identity')}"
495
+ chk.add(f"cred.cfg.{fid}", G, "ok", f"{fid} 表已解析",
496
+ f"{src.get('variant') or '-'}|base {base[:10]}…|table {table}|身份 {src.get('identity')}{extra}")
497
+ else:
498
+ chk.add(f"cred.cfg.{fid}", G, "fail", f"{fid} 未解析出 base/table",
499
+ json.dumps({k: v for k, v in src.items() if k.endswith("Unresolved")}, ensure_ascii=False),
500
+ "在部署 env 文件里补 *_BASE_TOKEN / *_TABLE_ID(本机不可见客户机的真实值)")
501
+
502
+ # base/table 来自 *Ref(如鑫中航读 invoice-mapping.json)时确认文件可读
503
+ refs: set[str] = set()
504
+ for spec in (registry.get("flows") or {}).values():
505
+ s = spec.get("source") or {}
506
+ for key in ("baseRef", "tableRef"):
507
+ if s.get(key):
508
+ refs.add(str(s[key]).split("#")[0])
509
+ for r in sorted(refs):
510
+ p = pathlib.Path(os.path.expanduser(r))
511
+ if p.exists():
512
+ chk.add(f"cred.ref.{p.name}", G, "ok", f"映射文件存在:{p.name}", str(p))
513
+ else:
514
+ chk.add(f"cred.ref.{p.name}", G, "fail", f"映射文件缺失:{p}", "流程表 id 由它提供",
515
+ "补齐该文件(或把 base/table 直接写进部署 env)")
516
+
517
+ # 身份 profile:`lark-cli profile list` 是权威清单(config.json 只有 apps 段,别去解析它)
518
+ identities = sorted({str((spec.get("source") or {}).get("identity") or "")
519
+ for spec in (registry.get("flows") or {}).values()} - {""})
520
+ if shutil.which("lark-cli"):
521
+ code, out, err = run(["lark-cli", "profile", "list"], timeout=120)
522
+ try:
523
+ profs = json.loads(out)
524
+ names = {str(p.get("name")) for p in profs if isinstance(p, dict)}
525
+ except Exception: # noqa: BLE001
526
+ names = set(re.findall(r'"name":\s*"([^"]+)"', out or ""))
527
+ if not names:
528
+ chk.add("cred.lark", G, "warn", "读不到 lark-cli profile 清单",
529
+ (err or out).strip().splitlines()[:1] and (err or out).strip().splitlines()[0] or "",
530
+ "跑 lark-cli profile list 确认 CLI 可用(见 skills/lark-shared)")
531
+ else:
532
+ lack = [i for i in identities if i and i not in names]
533
+ if lack:
534
+ chk.add("cred.lark", G, "fail", f"流程声明的身份不存在:{'、'.join(lack)}",
535
+ f"流程 identity={identities}|CLI 现有 {len(names)} 个 profile",
536
+ "在客户机上配好该 profile(或改流程声明的 identity),否则读表/回写会 91403/找不到 profile")
537
+ else:
538
+ chk.add("cred.lark", G, "ok", f"流程身份都存在({len(identities)} 个)",
539
+ "、".join(identities) + f"|CLI 共 {len(names)} 个 profile")
540
+ else:
541
+ chk.add("cred.lark", G, "warn", "PATH 里没有 lark-cli", "读表/回写都依赖它",
542
+ "按 skills/lark-shared 安装并登录")
543
+
544
+
545
+ # ── 模型凭证(装机红灯门禁的运行时复核;2026-09-29 日报事故固化)──────────────
546
+
547
+ def check_model_auth(chk: Checks, repo: pathlib.Path, dsh_home: pathlib.Path, online: bool) -> None:
548
+ """与计费模式匹配的模型凭证必须**在位且可用** —— 否则角色会话第一步就 401、票据空转。
549
+
550
+ 事故:2026-09-14 whyshu.com 硬切换 JWT 密钥 ⇒ 客户机上那把 08-22 签发的 token 当场失效,
551
+ 但「还在、exp 还没到」⇒ 本核对全绿;直到 09-29 日报第一次真调模型才炸
552
+ (会话 401、票据"停滞→回收→重派"循环 60 分钟 ×N、**零告警**,唯一信号是"日报没来")。
553
+ 判据收敛在 `scripts/check-model-auth.py`(安装器与流程预检共用同一份,避免三处漂移)。
554
+ """
555
+ G = "模型凭证"
556
+ script = repo / "scripts" / "check-model-auth.py"
557
+ if not script.exists():
558
+ chk.add("auth.script", G, "warn", "缺 scripts/check-model-auth.py(老克隆)", "",
559
+ "git pull 后重跑(本检查是 2026-09-29 日报事故的固化项)")
560
+ return
561
+ cmd = [sys.executable, str(script), "--dsh-home", str(dsh_home), "--json"]
562
+ if online:
563
+ cmd.append("--online")
564
+ code, out, err = run(cmd, timeout=120)
565
+ txt = (err or out or "").strip()
566
+ if code == 2 or not (out or "").strip():
567
+ chk.add("auth.script", G, "warn", "模型凭证体检没跑成(脚本自身错误)",
568
+ txt.splitlines()[-1] if txt else "", f"手动跑:python3 {script} --online")
569
+ return
570
+ try:
571
+ doc = json.loads(out)
572
+ except Exception: # noqa: BLE001
573
+ chk.add("auth.script", G, "warn", "模型凭证体检输出不是 JSON", (out or "").strip()[:200],
574
+ f"手动跑:python3 {script} --online")
575
+ return
576
+ for it in (doc.get("checks") or []):
577
+ chk.add(str(it.get("id") or "auth"), G, str(it.get("level") or "warn"),
578
+ str(it.get("title") or ""), str(it.get("detail") or ""), str(it.get("fix") or ""))
579
+
580
+
581
+ # ── 数据面(只读探测)─────────────────────────────────────────────────────────
582
+
583
+ def check_data_plane(chk: Checks, repo: pathlib.Path, data_dir: pathlib.Path,
584
+ flows: list[str], flows_cfg: dict, tmp: pathlib.Path) -> None:
585
+ G = "数据面(只读)"
586
+ py = sys.executable
587
+ for fid in flows:
588
+ cfg = flows_cfg.get(fid)
589
+ if not isinstance(cfg, dict) or cfg.get("_error") or not (cfg.get("source") or {}).get("table"):
590
+ chk.add(f"data.{fid}", G, "info", f"{fid} 跳过源探测", "表未解析(见凭据组)")
591
+ continue
592
+ cases_dir = tmp / f"cases-{fid}"
593
+ code, out, err = run([py, str(repo / "scripts" / "flow_source.py"), "--flow", fid,
594
+ "--source", "feishu-base", "--limit", "3", "--json",
595
+ "--out-dir", str(cases_dir)], timeout=420)
596
+ if code == 2:
597
+ chk.add(f"data.{fid}", G, "fail", f"{fid} 读表失败", (err or out).strip().splitlines()[-1:] and
598
+ (err or out).strip().splitlines()[-1] or "",
599
+ "查 identity/表 id/权限(91403 = 该 bot 对该 base 无权限)")
600
+ else:
601
+ n, miss = 0, []
602
+ try:
603
+ d = json.loads(out)
604
+ n = len(d.get("cases") or [])
605
+ miss = (d.get("cases") or [{}])[0].get("missing") or []
606
+ except Exception: # noqa: BLE001
607
+ pass
608
+ chk.add(f"data.{fid}", G, "ok", f"{fid} 源可读:{n} 行(limit 3)",
609
+ f"exit={code}(0=字段齐备 / 1=有缺口,均属正常)|首行缺口 {len(miss)} 个"
610
+ + (":" + "、".join(miss[:4]) if miss else ""))
611
+
612
+ # FULL 的两套表口径都要能读(用户口径:以客户要求为准,先 legacy 后 lip)
613
+ if fid == "full":
614
+ for variant in ("legacy", "lip"):
615
+ code, out, err = run([py, str(repo / "scripts" / "flow_config.py"), "--flow", "full",
616
+ "--json"], timeout=120, env={"FULL_TABLE_MODE": variant})
617
+ if code != 0:
618
+ chk.add(f"data.full.{variant}", G, "warn", f"FULL {variant} 变体配置不可解析",
619
+ (err or out).strip().splitlines()[-1:] and (err or out).strip().splitlines()[-1] or "",
620
+ f"在 fulfillment.env 里补该变体需要的表 id(legacy=FEISHU_*;lip=FULL_*)")
621
+ continue
622
+ cfgv = json.loads(out)
623
+ base = str((cfgv.get("source") or {}).get("base") or "")
624
+ if not base:
625
+ chk.add(f"data.full.{variant}", G, "warn", f"FULL {variant} 变体未解析出表",
626
+ json.dumps({k: v for k, v in (cfgv.get("source") or {}).items()
627
+ if k.endswith("Unresolved")}, ensure_ascii=False),
628
+ "缺哪个 env 就补哪个(本机看不到客户机的真实值)")
629
+ continue
630
+ code, out, err = run([py, str(repo / "scripts" / "flow_source.py"), "--flow", "full",
631
+ "--source", "feishu-base", "--limit", "3", "--json",
632
+ "--out-dir", str(tmp / f"cases-full-{variant}")],
633
+ timeout=420, env={"FULL_TABLE_MODE": variant})
634
+ # 只有**当前口径**(FULL_TABLE_MODE)那个变体是硬门禁;另一个是"备胎":
635
+ # 2026-09-28 客户机实测——正式口径 lip 可读、legacy 不可读(legacy 是另一张
636
+ # 老表,客户机 bot 未必有权限),流程根本不会读它。把它当 FAIL 会让预检
637
+ # 永远"有 1 项红",反而训练人忽略真红。故非当前口径降级为 ⚠️ 并写明原因。
638
+ active_mode = (os.environ.get("FULL_TABLE_MODE") or "lip").strip()
639
+ if code == 2:
640
+ level = variant_gate_level(variant, active_mode)
641
+ title = (f"FULL {variant} 变体读表失败" if level == "fail"
642
+ else f"FULL {variant} 变体读表失败(非当前口径 {active_mode},仅备胎)")
643
+ chk.add(f"data.full.{variant}", G, level, title,
644
+ (err or out).strip().splitlines()[-1:] and (err or out).strip().splitlines()[-1] or "",
645
+ "查该变体的 bot 权限(91403 = 无权限);切到该口径前必须修好")
646
+ else:
647
+ try:
648
+ n = len(json.loads(out).get("cases") or [])
649
+ except Exception: # noqa: BLE001
650
+ n = "?"
651
+ chk.add(f"data.full.{variant}", G, "ok", f"FULL {variant} 变体可读:{n} 行(limit 3)",
652
+ f"base {base[:10]}…|exit={code}",
653
+ "两个变体都能读 = 迁移期可随时切换;能否读只是权限,**写哪张表由运营口径决定**")
654
+
655
+ # 分诊(只读;case 落 tmp,台账只读)
656
+ # ⛔ `--no-ledger-write`:本步是**只读**巡检(见上一行注释、以及 docs/Argent-宿主适配契约.md §9)——
657
+ # 上游数据变化时,分诊会往台账写一条信息性的「放行额度重置」(幂等、不改票据状态),
658
+ # 但**只读**巡检不该写任何文件(真台账由生产链路在它自己那一轮写)。
659
+ code, out, err = run([py, str(repo / "scripts" / "flow_triage.py"), "--flow", fid, "--json",
660
+ "--no-ledger-write",
661
+ "--out-dir", str(cases_dir)], timeout=420)
662
+ try:
663
+ summ = (json.loads(out).get("summary") or {})
664
+ except Exception: # noqa: BLE001
665
+ summ = {}
666
+ if summ:
667
+ chk.add(f"triage.{fid}", G, "ok", f"{fid} 分诊:可派发 {summ.get('to_dispatch', '?')}",
668
+ json.dumps(summ, ensure_ascii=False) + f"|exit={code}",
669
+ "等人补字段数大 = 放行开关未勾/运营字段未填(**这是期望行为**,不是故障)")
670
+ else:
671
+ chk.add(f"triage.{fid}", G, "warn", f"{fid} 分诊无输出",
672
+ (err or out).strip().splitlines()[-1:] and (err or out).strip().splitlines()[-1] or "",
673
+ "先解决源读取问题")
674
+
675
+ # 认领预演(--dry-run:不写台账、不建会话)
676
+ code, out, err = run([py, str(repo / "scripts" / "flow_claim.py"), "--flow", fid, "--dry-run",
677
+ "--json", "--lock", str(tmp / f"claim-{fid}.lock")], timeout=180)
678
+ try:
679
+ claimed = json.loads(out).get("claimed")
680
+ except Exception: # noqa: BLE001
681
+ claimed = None
682
+ if claimed is not None:
683
+ # exit 3 = 无可认领(正常结论);exit 0 = 认领若干。二者都不是故障。
684
+ chk.add(f"claim.{fid}", G, "ok", f"{fid} 认领预演:本轮会认领 {len(claimed)} 张",
685
+ "、".join(claimed[:3]) or "无可认领(计划为空 ⇒ 触发器醒来也会立刻退出)",
686
+ "dry-run 未落盘;真派发由 flow-trigger.sh 负责")
687
+ else:
688
+ chk.add(f"claim.{fid}", G, "warn", f"{fid} 认领预演失败(exit={code})",
689
+ (err or out).strip().splitlines()[-1:] and (err or out).strip().splitlines()[-1] or "",
690
+ "先解决源读取/配置问题")
691
+
692
+ # 未处置失败(只读)
693
+ code, out, err = run([py, str(repo / "scripts" / "flow_failure.py"), "--scan", "--flow", fid,
694
+ "--json"], timeout=180)
695
+ try:
696
+ pend = json.loads(out).get("pending") or []
697
+ except Exception: # noqa: BLE001
698
+ pend = []
699
+ if code == 0:
700
+ lvl = "warn" if pend else "ok"
701
+ chk.add(f"failures.{fid}", G, lvl, f"{fid} 未处置失败:{len(pend)}",
702
+ "、".join(f"{p.get('caseId')}({p.get('level')})" for p in pend[:3]),
703
+ "needs_agent 会被失败回路唤醒角色;retryable 由脚本自重试;needs_human 落卡等人")
704
+
705
+
706
+ # ── 触发面 ────────────────────────────────────────────────────────────────────
707
+
708
+ def check_triggers(chk: Checks, flows_cfg: dict, flows: list[str]) -> None:
709
+ G = "触发面"
710
+ # 暂停开关现状(影子期应该存在;上线时逐流程摘掉)
711
+ # 数据目录:走唯一判据(三级链,同 flow_config 口径);下面用它读 `<数据目录>/*.env`
712
+ ws = pathlib.Path(resolve_data_dir()["dir"])
713
+ for fid in flows:
714
+ cfg = flows_cfg.get(fid) or {}
715
+ pause = (cfg.get("paths") or {}).get("pauseSwitch")
716
+ if not pause:
717
+ continue
718
+ p = pathlib.Path(pause)
719
+ chk.add(f"pause.{fid}", G, "info" if p.exists() else "ok",
720
+ f"{fid} 暂停开关{'存在(不会自动唤起)' if p.exists() else '不存在(会自动唤起)'}",
721
+ str(p), "上线顺序:先带暂停开关装触发器 → 观察影子轮 → 再逐流程摘掉开关")
722
+
723
+ # LaunchAgent / cron:新旧触发器并存 = 双跑(会重复写生产表)
724
+ labels: list[str] = []
725
+ if shutil.which("launchctl"):
726
+ code, out, _ = run(["launchctl", "list"])
727
+ labels = [l.split("\t")[-1].strip() for l in out.splitlines() if "whyshu" in l]
728
+ legacy = [l for l in labels if "fulfillment-poll" in l or "invoice-poll" in l]
729
+ flowtrig = [l for l in labels if "flow-trigger" in l]
730
+ cron_txt = ""
731
+ if shutil.which("crontab"):
732
+ code, out, _ = run(["crontab", "-l"])
733
+ cron_txt = out or ""
734
+ cron_legacy = [l for l in cron_txt.splitlines()
735
+ if l.strip() and not l.strip().startswith("#")
736
+ and ("poll-fulfillment" in l or "poll-invoice" in l)]
737
+ cron_new = [l for l in cron_txt.splitlines()
738
+ if l.strip() and not l.strip().startswith("#") and "flow-trigger" in l]
739
+ chk.add("trig.launchd", G, "info", f"whyshu LaunchAgent:{len(labels)} 个",
740
+ "、".join(labels) or "(无)", "对照 docs/客户机部署与上线.md 的装载清单")
741
+ if cron_legacy or cron_new:
742
+ chk.add("trig.cron", G, "info", f"crontab 里有旧/新轮询 {len(cron_legacy)}/{len(cron_new)} 条",
743
+ "、".join((cron_legacy + cron_new)[:2]), "客户机历史上因 /var/at/tabs 权限弃用 cron,改用 LaunchAgent")
744
+ if (legacy or cron_legacy) and (flowtrig or cron_new):
745
+ chk.add("trig.double", G, "fail", "新旧触发器同时存在 ⇒ 有双跑风险",
746
+ f"旧:{'、'.join(legacy + [l.split('/')[-1] for l in cron_legacy])}|"
747
+ f"新:{'、'.join(flowtrig + [l.split('/')[-1] for l in cron_new])}",
748
+ "同一流程只能有一路触发:先卸旧(launchctl bootout / 删 cron 行),再上新")
749
+ elif not (legacy or cron_legacy or flowtrig or cron_new):
750
+ chk.add("trig.double", G, "warn", "没有任何触发器在跑",
751
+ "影子期可以如此(手工跑 flow-trigger.sh --dry-run 演练)",
752
+ "上线时按 docs/客户机部署与上线.md §4.3 的「定时器安装」一节逐流程装载")
753
+ else:
754
+ chk.add("trig.double", G, "ok", "触发器不并存",
755
+ f"旧 {len(legacy) + len(cron_legacy)} / 新 {len(flowtrig) + len(cron_new)}")
756
+
757
+ # FULL 表口径(二选一必须由客户确认)
758
+ mode = os.environ.get("FULL_TABLE_MODE") or ""
759
+ envf = ws / "fulfillment.env"
760
+ if not mode and envf.exists():
761
+ for line in envf.read_text(errors="replace").splitlines():
762
+ if line.strip().startswith("FULL_TABLE_MODE="):
763
+ mode = line.split("=", 1)[1].strip()
764
+ if mode in ("legacy", "lip"):
765
+ note = "(正式口径)" if mode == "lip" else "(⚠️ 旧测试表:2026-09-17 用户口径现已以 lip 为准,legacy 仅回溯用)"
766
+ chk.add("trig.full_mode", G, "ok", f"FULL_TABLE_MODE={mode}", f"源变体已显式选定{note}")
767
+ else:
768
+ chk.add("trig.full_mode", G, "info", "FULL_TABLE_MODE 未显式设置(按注册表缺省走)",
769
+ "注册表 default 已改为 **lip**(FULL 现在的正式表);legacy=FEISHU_BASE_TOKEN/FEISHU_TABLE_ID 仅回溯",
770
+ "建议在 fulfillment.env 里写 FULL_TABLE_MODE=lip,避免读代码才知道用的是哪张表")
771
+
772
+
773
+ # ── 主流程 ────────────────────────────────────────────────────────────────────
774
+
775
+ def check_verticals(chk: Checks, repo: pathlib.Path, data_dir: pathlib.Path) -> None:
776
+ """**业务线自检段**(P2a 起):跑 `verticals/<id>/preflight.py#checks(env)`。
777
+
778
+ 契约(见 `verticals/README.md`):每个业务线可选提供
779
+ checks(env: dict) -> list[(名称, 级别 ok|warn|fail, 说明, 修复建议)]
780
+ 规矩与平台预检一致:**只读探测**、不写客户数据、缺凭据要能区分"没配"与"配了没权限"。
781
+ 业务线自检失败(自身报错)记 warn 而不是 fail —— 免得一条线的脚手架问题把整机预检判死。
782
+ """
783
+ G = "业务线"
784
+ vroot = repo / "verticals"
785
+ if not vroot.is_dir():
786
+ return
787
+ for d in sorted(vroot.iterdir()):
788
+ if not d.is_dir() or d.name.startswith((".", "_")):
789
+ continue
790
+ pf = d / "preflight.py"
791
+ if not pf.exists():
792
+ chk.add(f"vertical.{d.name}", G, "info", "该业务线没有 preflight.py(可选)", "需要时补 verticals/<id>/preflight.py")
793
+ continue
794
+ try:
795
+ spec = importlib.util.spec_from_file_location(f"argent_vertical_preflight_{d.name}", pf)
796
+ mod = importlib.util.module_from_spec(spec)
797
+ spec.loader.exec_module(mod)
798
+ items = mod.checks({"data_dir": str(data_dir), "repo": str(repo)}) or []
799
+ except Exception as exc: # noqa: BLE001
800
+ chk.add(f"vertical.{d.name}", G, "warn", f"自检段执行失败:{exc}",
801
+ "修 verticals/<id>/preflight.py#checks(签名见 verticals/README.md)")
802
+ continue
803
+ for item in items:
804
+ try:
805
+ name, level, msg, fix = item
806
+ except Exception: # noqa: BLE001
807
+ chk.add(f"vertical.{d.name}", G, "warn", f"自检段返回格式不对:{item!r}",
808
+ "返回 (名称, ok|warn|fail, 说明, 修复建议) 四元组")
809
+ continue
810
+ chk.add(f"vertical.{d.name}.{name}", G, level, msg, fix)
811
+
812
+
813
+ def flow_configs(repo: pathlib.Path, flows: list[str]) -> dict:
814
+ """每流程配置(strict),失败记 _error 而不中断(后面按组报)。"""
815
+ out: dict = {}
816
+ for fid in flows:
817
+ code, so, se = run([sys.executable, str(repo / "scripts" / "flow_config.py"),
818
+ "--flow", fid, "--json"], timeout=120)
819
+ if code == 0 and so.strip().startswith("{"):
820
+ out[fid] = json.loads(so)
821
+ else:
822
+ out[fid] = {"_error": (se or so).strip().splitlines()[-1:] and
823
+ (se or so).strip().splitlines()[-1] or f"exit={code}"}
824
+ return out
825
+
826
+
827
+ def main(argv=None) -> int:
828
+ ap = argparse.ArgumentParser(description="客户机上线核对(只读自检)")
829
+ ap.add_argument("--repo", default=str(DEFAULT_REPO), help="开发仓库路径(默认脚本上一级)")
830
+ ap.add_argument("--profile", default=os.environ.get("DSH_PROFILE", "web"))
831
+ ap.add_argument("--flow", action="append", help="只核对指定流程(可多次);默认三个都查")
832
+ ap.add_argument("--offline", action="store_true", help="跳过读表/分诊/认领等在线探测")
833
+ ap.add_argument("--full", action="store_true", help="追加重量级门禁(preset schema + 启动冒烟)")
834
+ ap.add_argument("--json", action="store_true", help="JSON 输出")
835
+ a = ap.parse_args(argv)
836
+
837
+ repo = pathlib.Path(os.path.expanduser(a.repo)).resolve()
838
+ data_dir = pathlib.Path(resolve_data_dir()["dir"])
839
+ flows = a.flow or FLOWS_DEFAULT
840
+ chk = Checks()
841
+
842
+ if not (repo / "scripts" / "flow-requirements.json").exists():
843
+ print(f"❌ 不是 argent-dsh 仓库(缺 scripts/flow-requirements.json):{repo}", file=sys.stderr)
844
+ return 2
845
+
846
+ cfg = flow_configs(repo, flows)
847
+ tmp = pathlib.Path(tempfile.mkdtemp(prefix="argent-preflight-"))
848
+ try:
849
+ check_env(chk, repo, data_dir, a.profile)
850
+ if a.full:
851
+ check_heavy(chk, repo)
852
+ check_product_gates(chk, repo)
853
+ check_credentials(chk, repo, data_dir, flows, cfg)
854
+ # 模型凭证:DSH_HOME 优先(多实例机器上 data_dir 可能在别处)
855
+ dsh_home = pathlib.Path(os.path.expanduser(os.environ.get("DSH_HOME") or str(data_dir.parent)))
856
+ check_model_auth(chk, repo, dsh_home, online=not a.offline)
857
+ if not a.offline:
858
+ check_data_plane(chk, repo, data_dir, flows, cfg, tmp)
859
+ check_triggers(chk, cfg, flows)
860
+ check_verticals(chk, repo, data_dir)
861
+ finally:
862
+ shutil.rmtree(tmp, ignore_errors=True)
863
+
864
+ counts = chk.counts()
865
+ if a.json:
866
+ print(json.dumps({"repo": str(repo), "dataDir": str(data_dir), "profile": a.profile,
867
+ "offline": a.offline, "flows": flows, "checks": chk.items,
868
+ "summary": counts}, ensure_ascii=False, indent=2))
869
+ else:
870
+ icon = {"ok": "✅", "warn": "⚠️", "fail": "❌", "info": "·"}
871
+ group = None
872
+ for it in chk.items:
873
+ if it["group"] != group:
874
+ group = it["group"]
875
+ print(f"\n─── {group} " + "─" * max(0, 40 - len(group)))
876
+ print(f" {icon.get(it['level'], '?')} {it['title']}")
877
+ if it["detail"]:
878
+ print(f" {it['detail']}")
879
+ if it["level"] == "fail" and it["fix"]:
880
+ print(f" ↳ 修法:{it['fix']}")
881
+ print(f"\n═══ 合计:✅ {counts['ok']}|⚠️ {counts['warn']}|❌ {counts['fail']}|· {counts['info']}")
882
+ if counts["fail"]:
883
+ print(" 有 FAIL 项:先按「修法」处理,再重跑本脚本(脚本本身不写任何生产数据)")
884
+ elif counts["warn"]:
885
+ print(" 无 FAIL;⚠️ 项按清单判断是否阻塞上线(有的只是影子期应有的状态)")
886
+ else:
887
+ print(" 全绿。人工确认清单(运营口径/门户步骤)见 docs/客户机部署与上线.md §4.3")
888
+ return 1 if counts["fail"] else 0
889
+
890
+
891
+ if __name__ == "__main__":
892
+ sys.exit(main())