@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,776 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * argent-capabilities —— 「按能力交付」的纯逻辑层(批 B3/B4,2026-09-21)
4
+ *
5
+ * ## 为什么单独一个文件
6
+ * 安装器 `install-argent.mjs` 是**单文件分发物**(`release-argent.sh` 的 CORE_FILES →
7
+ * 官网 `curl -fsSL …/install-argent.mjs | node`):它只能在克隆完仓库之后**动态**加载本模块,
8
+ * 不能静态 import(否则单文件分发断链)。而医生 `scripts/profile-doctor.mjs` 要读同一份
9
+ * install.json、跑同一个 `capability_pack.py --describe`。把「装机记录的构造 / 合并 / 校验」
10
+ * 放在这里,安装器与医生**共用一份**,避免两处各写一遍(第二份真相 = 漂移)。
11
+ *
12
+ * ## 分工
13
+ * - 纯函数(可单测、无 IO):`computeTicketFingerprint` / `parseCapabilityList` /
14
+ * `validateAddCapabilities` / `normalizePack` / `mergeCapabilityPacks` /
15
+ * `buildInstallJson` / `parseCapabilityManifest`
16
+ * - 轻 IO:`installJsonPath` / `readInstallJson` / `writeInstallJson` /
17
+ * `sha16File` / `describeCapability`
18
+ *
19
+ * ## 两条红线
20
+ * 1. **票据/API Key 绝不落盘**:install.json 只写 `ticketFingerprint`
21
+ * (= `sha256(整张 ticket)` 的前 12 位十六进制)。本模块不接受"把原票据写进去"的用法。
22
+ * 2. **多包共用引擎 sha 必须一致**:同一份文件在两个能力包里内容不同 ⇒ `mergeCapabilityPacks` 抛错
23
+ * (同一台机器上的所有能力包必须来自同一版代码)。**唯一例外**是包内注册表投影
24
+ * (`PACK.json#sourceRegistry`,按能力过滤 ⇒ 跨包必然不同)—— 判据 = `isRegistryProjection()`,
25
+ * 计划阶段(`planPackInstall`)/ 落盘阶段(`installPackFiles`)/ 合并与本模块的消费者**共用同一个函数**。
26
+ *
27
+ * 测试:`node scripts/tests/install-capabilities.test.mjs`
28
+ */
29
+ import { createHash } from 'node:crypto'
30
+ import { execFileSync } from 'node:child_process'
31
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
32
+ import { dirname, join, posix, resolve } from 'node:path'
33
+ import { resolveInterpreterPath } from './lib/python-runtime.mjs'
34
+
35
+ let _resolvedPython
36
+
37
+ /**
38
+ * 能力包工具用哪个 Python(与 `capability_pack.py --describe` 对齐;测试/换解释器可用 ARGENT_PY 覆盖)。
39
+ *
40
+ * P0-⑦(2026-10-08):此前硬编码裸 `python3` —— 而 **Windows 上 `python3` 通常是 Microsoft Store
41
+ * 的占位符**(运行即失败/弹商店)⇒ 所有 `describeCapability` 失败 ⇒ 装机清单 `integrity: unverified`、
42
+ * `files` 空(实测现场见 docs/Argent-Windows-安装与HR能力验证-2026-10-08.md §3.5)。
43
+ * 仓库**早已有**多候选 + 版本门禁的口径(scripts/lib/python-runtime.mjs,含 win32 分支),这里接上它;
44
+ * 结果**记住**(describe 按能力被调用多次,不必每次重探十几个候选)。
45
+ */
46
+ export function pythonBin() {
47
+ if (process.env.ARGENT_PY) return process.env.ARGENT_PY
48
+ if (_resolvedPython === undefined) _resolvedPython = resolveInterpreterPath({})
49
+ // 一个都跑不起来时保留旧兜底名:错误由调用方 catch 后**如实报告**(不静默判过)
50
+ return _resolvedPython || 'python3'
51
+ }
52
+
53
+ /** 装机清单路径:`<dsh-home>/argent/install.json`(默认 `~/.dsh/argent/install.json`)。 */
54
+ export function installJsonPath(dshHome) {
55
+ return join(resolve(dshHome || join(process.env.HOME || '', '.dsh')), 'argent', 'install.json')
56
+ }
57
+
58
+ // ── 纯函数 ────────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * 票据指纹:`sha256(ticket 的前 12 字符)` 取前 12 位十六进制。
62
+ * 只用于"这次装的是哪张票"的对照,**不可**据此反推票据(也绝不写原票据)。
63
+ */
64
+ export function computeTicketFingerprint(ticket) {
65
+ // 2026-09-21 修正:早前按"票据前 12 字符"算 —— 票据是 `base64(payload).hexSig`,
66
+ // payload 前缀(版本/账号结构)在多张票上**经常相同** ⇒ 不同票据会撞同一个指纹,
67
+ // "这台机器装的时候用的是哪张票"就核不准了。改为对**整张票据**求摘要。
68
+ const s = String(ticket || '')
69
+ if (!s) return ''
70
+ return createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 12)
71
+ }
72
+
73
+ /**
74
+ * `--add-capability <id>[,<id>…]` 的参数解析:逗号分隔、去空白、**去重保序**。
75
+ * 空串/只有一个逗号 ⇒ 抛错(加购是个明确动作,不接受空参数)。
76
+ */
77
+ export function parseCapabilityList(raw) {
78
+ const ids = String(raw ?? '')
79
+ .split(',')
80
+ .map((x) => x.trim())
81
+ .filter(Boolean)
82
+ const seen = new Set()
83
+ const out = []
84
+ for (const id of ids) {
85
+ if (seen.has(id)) continue
86
+ seen.add(id)
87
+ out.push(id)
88
+ }
89
+ if (!out.length) throw new Error('--add-capability 需要至少一个能力 id(逗号分隔,如 full,invoice)')
90
+ return out
91
+ }
92
+
93
+ /**
94
+ * 加购的**授权校验**:参数里出现清单未授予的能力 ⇒ 拒绝(调用方据此响亮退出)。
95
+ *
96
+ * `granted` 语义:
97
+ * - 字符串数组 = 服务端清单里该账号有权的能力 id;
98
+ * - `null`/非数组 = 服务端没给清单(旧服务端 / 未部署)⇒ `checked:false`,**不判死**
99
+ * (与"缺字段时按旧行为继续"一致),由调用方打印告警。
100
+ */
101
+ export function validateAddCapabilities(requested, granted) {
102
+ const req = [...new Set((requested || []).map((x) => String(x).trim()).filter(Boolean))]
103
+ if (!req.length) return { ok: false, ungranted: [], checked: false, reason: 'empty-request' }
104
+ if (!Array.isArray(granted)) return { ok: true, ungranted: [], checked: false, reason: 'manifest-unavailable' }
105
+ const have = new Set(granted.map((x) => String(x)))
106
+ const ungranted = req.filter((id) => !have.has(id))
107
+ return { ok: ungranted.length === 0, ungranted, checked: true }
108
+ }
109
+
110
+ /**
111
+ * 包内相对路径的**唯一归一入口**:一律 POSIX 形态(正斜杠)。
112
+ *
113
+ * 为什么必须有它(2026-10-08,Windows 装机验证):包内路径的全部下游用法都假定正斜杠 ——
114
+ * `PACK.json#files` / 版本锁 / `install.json#files` 的键、Node 侧 `join(packDir, rel)`、
115
+ * Python 侧 `_rel()`/`_expand_files()`。而 Windows 上(`path.relative`/`str(Path)`)给的是
116
+ * **反斜杠** ⇒ 两种故障:① 路径解析崩(`split("/")[1]` 类);② **反斜杠键与正斜杠键永不相等**,
117
+ * 于是"与锁/基线逐文件比对"被**静默绕过**(`mergeCapabilityPacks` 的共用引擎守卫、
118
+ * 医生侧 sha 核对都会因此变成空判)。
119
+ * ⇒ 归一化是这一层唯一正确的做法;`path.posix.normalize` 顺带压掉 `./`、`//`、`a/../b`。
120
+ * ⛔ 不要自己拼字符串(先替换分隔符再 split 会漏掉 `a//b`、`./a` 这类形态)。
121
+ *
122
+ * @param {string} rel 包内相对路径(正斜杠或反斜杠形态)
123
+ * @returns {string} POSIX 形态(空串原样返回空串)
124
+ */
125
+ export function normalizePackPath(rel) {
126
+ const s = String(rel == null ? '' : rel)
127
+ if (!s) return ''
128
+ const norm = posix.normalize(s.replaceAll('\\', '/'))
129
+ // 与 Python 侧 `PurePosixPath(...).as_posix()` **逐字同口径**:尾斜杠也要压掉
130
+ // (`posix.normalize` 会保留尾斜杠,而 PurePosixPath 不会 ⇒ 不压就会让两侧算出不同的键)
131
+ return norm.length > 1 ? norm.replace(/\/+$/u, '') : norm
132
+ }
133
+
134
+ /** 把一个"文件 → sha16"映射的键统一成 POSIX 形态(历史产物可能是反斜杠:Windows 上装/盖过章)。 */
135
+ export function normalizePackPaths(files) {
136
+ const out = {}
137
+ for (const [k, v] of Object.entries(files && typeof files === 'object' ? files : {})) {
138
+ out[normalizePackPath(k)] = v == null ? '' : String(v)
139
+ }
140
+ return out
141
+ }
142
+
143
+ /**
144
+ * 把一个"包"归一成合并用的形状。接受的输入形态:
145
+ * - `{ id|capability, version, files: {relPath: sha16} }`(装机记录 / 本地 describe 的并集形态)
146
+ * - `{ integrity: {relPath: sha16} }`(`capability_pack.py --describe --json` 的原样输出)
147
+ * - `{ sha256: '' }`(服务端清单未填 sha256 ⇒ 该包整体 unverified)
148
+ * - 数组形态的 `capabilities: [{id, version}]`(install.json 顶层)
149
+ *
150
+ * 键一律过 `normalizePackPath()`(见上:分隔符不统一 = 比对被绕过)。
151
+ */
152
+ export function normalizePack(pack, fallbackId = '') {
153
+ const src = pack || {}
154
+ const filesRaw =
155
+ src.files && typeof src.files === 'object' ? src.files
156
+ : src.integrity && typeof src.integrity === 'object' ? src.integrity
157
+ : {}
158
+ const files = normalizePackPaths(filesRaw)
159
+ const caps = Array.isArray(src.capabilities)
160
+ ? src.capabilities.map((c) => ({ id: String((c || {}).id || ''), version: String((c || {}).version || '') })).filter((c) => c.id)
161
+ : []
162
+ const id = String(src.capability || src.id || fallbackId || '')
163
+ const version = String(src.version || '')
164
+ // `sourceRegistry` 原样带过来:它是"包内注册表投影路径"的**声明**(`packProjectionPath()` 读它)⇒
165
+ // 归一后的形状也要能喂给 `isRegistryProjection()`(合并/装载的投影豁免都靠它)。缺声明 ⇒ 空串,
166
+ // 由 `packProjectionPath()` 落回历史约定的默认路径。
167
+ const sourceRegistry = String(src.sourceRegistry || '')
168
+ if (!caps.length && (id || version)) caps.push({ id: id || fallbackId || '(未命名)', version })
169
+ // unverified 的三种来源:显式标记 / 服务端 sha256 为空串 / 文件 sha 有空值
170
+ const unverified =
171
+ src.integrity === 'unverified' ||
172
+ src.integrityState === 'unverified' ||
173
+ (Object.prototype.hasOwnProperty.call(src, 'sha256') && String(src.sha256 || '') === '') ||
174
+ Object.values(files).some((v) => !v)
175
+ return { id, version, files, caps, unverified, sourceRegistry }
176
+ }
177
+
178
+ function packLabel(p) {
179
+ return `${p.id || '(未命名)'}${p.version ? ` v${p.version}` : ''}`
180
+ }
181
+
182
+ /**
183
+ * 合并两个能力包清单(**多包共用引擎 sha 必须一致**)。
184
+ *
185
+ * 规则(批 B3 验收):
186
+ * 1. 同一文件在两侧都出现、两边 sha 都非空且不同 ⇒ **抛错**(点名文件与两侧版本);
187
+ * **例外(⑤)**:`isRegistryProjection()` 认下的**注册表投影**(每包一份、按能力过滤)⇒
188
+ * 差异是设计使然,跳过(取并集时保留先出现的那份 sha)。判据与计划/落盘两阶段**同一个函数**。
189
+ * 2. 任一侧文件 sha 为空串(服务端尚未填 / 本地取不到)⇒ **跳过**该校验,结果 `integrity: 'unverified'`;
190
+ * 3. 其余取并集(文件、能力条目按 id 去重)。
191
+ *
192
+ * @returns {{files: Object<string,string>, capabilities: Array<{id:string,version:string}>, integrity: 'verified'|'unverified'}}
193
+ */
194
+ export function mergeCapabilityPacks(existing, incoming) {
195
+ const a = normalizePack(existing, 'existing')
196
+ const b = normalizePack(incoming, 'incoming')
197
+ const files = { ...a.files }
198
+ for (const [f, sha] of Object.entries(b.files)) {
199
+ if (!sha) {
200
+ if (!(f in files)) files[f] = sha
201
+ continue
202
+ }
203
+ const prev = files[f]
204
+ if (prev === undefined) {
205
+ files[f] = sha
206
+ continue
207
+ }
208
+ if (!prev) {
209
+ files[f] = sha
210
+ continue
211
+ }
212
+ if (prev !== sha) {
213
+ // ⑤ 同源(判据 = `isRegistryProjection()`,**唯一真源**):注册表投影按能力过滤 ⇒ 两个能力的
214
+ // 投影**必然**不同 sha。而调用方(`install-argent.mjs#packOf()`)会把**多个能力**的文件拍平
215
+ // 成一个映射 ⇒ 这里若不豁免,同一台机器装了 ≥2 个能力后 `--add-capability`(add 模式 = die)
216
+ // 必失败,且报错点名的是"注册表投影"这个**设计使然**的差异(同根因的第三处落点)。
217
+ if (isRegistryProjection(f, [a, b])) continue
218
+ throw new Error(
219
+ `能力包共用文件 sha 不一致:${f}(${packLabel(a)} = ${prev} vs ${packLabel(b)} = ${sha})` +
220
+ '—— 同一份文件在两个包里内容不同,拒绝装配(同机所有能力包必须来自同一版代码)',
221
+ )
222
+ }
223
+ }
224
+ const unverified = a.unverified || b.unverified || Object.values(files).some((v) => !v)
225
+ const seen = new Map()
226
+ for (const c of [...a.caps, ...b.caps]) if (!seen.has(c.id)) seen.set(c.id, c)
227
+ return { files, capabilities: [...seen.values()], integrity: unverified ? 'unverified' : 'verified' }
228
+ }
229
+
230
+ /**
231
+ * 解析 install-ticket 响应里的能力清单(**兼容旧服务端**:没有 `plan`/`capabilities`
232
+ * 就返回 `available:false`,不报错——调用方按旧行为继续)。
233
+ */
234
+ export function parseCapabilityManifest(payload) {
235
+ const p = payload || {}
236
+ const hasPlan = typeof p.plan === 'string' && p.plan.length > 0
237
+ const hasCaps = Array.isArray(p.capabilities)
238
+ const capabilities = hasCaps
239
+ ? p.capabilities
240
+ .map((c) => {
241
+ const s = c || {}
242
+ return {
243
+ id: String(s.id || s.capability || ''),
244
+ minPlan: String(s.minPlan || ''),
245
+ version: String(s.version || ''),
246
+ sha256: String(s.sha256 ?? ''),
247
+ url: String(s.url || ''),
248
+ }
249
+ })
250
+ .filter((c) => c.id)
251
+ : []
252
+ return {
253
+ available: hasPlan || hasCaps,
254
+ plan: hasPlan ? p.plan : '',
255
+ capabilities,
256
+ // ⭐「服务端**确实**回了 capabilities 数组」的判据(2026-10-09):空数组 = 服务端明确说"该账号
257
+ // 当前一个能力都没有"(合法状态:没买 / 档位不够),而**没有这个字段** = 服务端还是旧版本、
258
+ // 压根没实现 B2 ⇒ 两件事都表现为 `capabilities.length === 0`,只看长度必然把前者误报成后者的
259
+ // 故障(`--check-update` 曾因此 exit 1 报"拿不到服务端能力清单")。判据必须是"字段在不在"。
260
+ capabilitiesPresent: hasCaps,
261
+ manifestError: typeof p.capabilityManifestError === 'string' ? p.capabilityManifestError : '',
262
+ }
263
+ }
264
+
265
+ /**
266
+ * 构造 `install.json` 的内容(**纯函数**,便于单测)。
267
+ *
268
+ * 只接受票据**原文**用于算指纹:返回值里只会出现 `ticketFingerprint`,
269
+ * 绝不会出现票据/API Key 原文(红线见文件头)。
270
+ *
271
+ * 每个能力条目:`{id, minPlan, version, sha256, url}` + 本地可核对时的 `files`(文件 → sha16)
272
+ * 与 `integrity`(`verified` / `unverified`,后者附 `integrityNote` 说明降级原因)。
273
+ */
274
+ export function buildInstallJson({
275
+ plan, capabilities, profile, installedAt, ticket, ticketFingerprint, versions, existing,
276
+ } = {}) {
277
+ const caps = (capabilities || []).map((c) => {
278
+ const src = c || {}
279
+ const sha256 = String(src.sha256 ?? '')
280
+ // 键归一(⑧):install.json 的 files 是后来与 `--describe` 输出/锁文件比对的**同一张表**,
281
+ // 分隔符不统一 ⇒ 比对恒不命中(Windows 反斜杠键静默绕过校验)。
282
+ const files = normalizePackPaths(src.files && typeof src.files === 'object' ? src.files : {})
283
+ const out = {
284
+ id: String(src.id || src.capability || ''),
285
+ minPlan: String(src.minPlan || ''),
286
+ version: String(src.version || ''),
287
+ sha256,
288
+ url: String(src.url || ''),
289
+ }
290
+ if (Object.keys(files).length) out.files = files
291
+ let integrity = 'verified'
292
+ let note = ''
293
+ if (sha256 === '') {
294
+ integrity = 'unverified'
295
+ note = '服务端清单未填 sha256(待发布流程填充)'
296
+ } else if (!Object.keys(files).length) {
297
+ integrity = 'unverified'
298
+ note = '本地未取到文件 sha(缺 capability_pack.py / 该能力无文件清单)'
299
+ } else if (Object.values(files).some((v) => !v)) {
300
+ integrity = 'unverified'
301
+ note = '部分文件 sha 为空'
302
+ }
303
+ if (src.integrityNote) note = String(src.integrityNote)
304
+ out.integrity = src.integrity === 'unverified' ? 'unverified' : integrity
305
+ if (note) out.integrityNote = note
306
+ return out
307
+ })
308
+ const doc = { plan: String(plan || (existing && existing.plan) || 'unknown'), capabilities: caps }
309
+ if (profile) doc.profile = String(profile)
310
+ doc.installedAt = installedAt || new Date().toISOString()
311
+ const fp = ticketFingerprint || computeTicketFingerprint(ticket) || String((existing && existing.ticketFingerprint) || '')
312
+ if (fp) doc.ticketFingerprint = fp
313
+ doc.integrity = caps.length > 0 && caps.every((c) => c.integrity === 'verified') ? 'verified' : 'unverified'
314
+ const vers = {}
315
+ for (const [k, v] of Object.entries(versions || {})) if (v) vers[String(k)] = String(v)
316
+ if (Object.keys(vers).length) doc.versions = vers
317
+ return doc
318
+ }
319
+
320
+ // ── 轻 IO ─────────────────────────────────────────────────────────────
321
+
322
+ /** 读装机清单;不存在 / 解析失败 ⇒ `null`(调用方自行决定 WARN 还是 FAIL)。 */
323
+ export function readInstallJson(file) {
324
+ try {
325
+ const doc = JSON.parse(readFileSync(file, 'utf8'))
326
+ return doc && typeof doc === 'object' ? doc : null
327
+ } catch {
328
+ return null
329
+ }
330
+ }
331
+
332
+ /** 写装机清单:目录 0700(新建时)、文件 0600(已存在且更宽时收紧)。返回写入路径。 */
333
+ export function writeInstallJson(file, doc) {
334
+ const dir = dirname(file)
335
+ const existed = existsSync(dir)
336
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
337
+ if (!existed) {
338
+ try { chmodSync(dir, 0o700) } catch { /* umask/平台差异:不阻断 */ }
339
+ }
340
+ writeFileSync(file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 })
341
+ try { chmodSync(file, 0o600) } catch { /* 同上 */ }
342
+ return file
343
+ }
344
+
345
+ /** 文件 sha256 的前 16 位十六进制(与 `capability_pack.py` 的 `_sha()` 同口径);读不到 ⇒ null。 */
346
+ export function sha16File(abs) {
347
+ try {
348
+ return createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 16)
349
+ } catch {
350
+ return null
351
+ }
352
+ }
353
+
354
+ /** 能力包工具路径。 */
355
+ export function capabilityPackScript(repoDir) {
356
+ return join(repoDir, 'scripts', 'capability_pack.py')
357
+ }
358
+
359
+ /**
360
+ * 跑 `capability_pack.py --describe <id> --json` 取本地能力包清单(派生自仓库当前文件)。
361
+ * 失败一律返回 `{ok:false, error}`——调用方只 WARN,不判死(客户机上可能没装 python)。
362
+ */
363
+ export function describeCapability(repoDir, id, { python = pythonBin(), timeoutMs = 120000 } = {}) {
364
+ const script = capabilityPackScript(repoDir)
365
+ if (!existsSync(script)) return { ok: false, error: `未找到能力包工具 ${script}` }
366
+ try {
367
+ const out = execFileSync(python, [script, '--describe', String(id), '--json'], {
368
+ encoding: 'utf8',
369
+ timeout: timeoutMs,
370
+ env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' },
371
+ })
372
+ const m = JSON.parse(out)
373
+ return {
374
+ ok: true,
375
+ manifest: m,
376
+ version: String(m.version || ''),
377
+ minPlan: String(m.minPlan || ''),
378
+ files: Array.isArray(m.files) ? m.files : Object.keys(m.integrity || {}),
379
+ integrity: m.integrity && typeof m.integrity === 'object' ? m.integrity : {},
380
+ missing: Array.isArray(m.missing) ? m.missing : [],
381
+ }
382
+ } catch (e) {
383
+ const msg = String((e && (e.stderr || e.message)) || e).trim().split('\n').filter(Boolean).pop() || '未知错误'
384
+ return { ok: false, error: msg.slice(0, 200) }
385
+ }
386
+ }
387
+
388
+ // ── 批 B5(2026-09-21):从能力包**真装**(解包 + 逐文件校验 + 落盘)──────────────
389
+ // 为什么需要:`--add-capability` 只登记/校验,包内容仍来自 git 克隆 ⇒ "只买 HR 的机器上
390
+ // 不该有 FULL 的脚本"这句话当时并不成立。这一层让安装器从 PACK.json 描述的 tgz/目录装,
391
+ // 客户机因此**不需要 git**,且"装了哪几个能力的哪些文件"可逐文件核对。
392
+
393
+ /** 读 PACK.json;缺失/损坏 ⇒ throw(调用方给人工指引)。
394
+ *
395
+ * **包的文件边界**:`files` 的键在这里就归一成 POSIX 形态(见 `normalizePackPath()`)——
396
+ * 下游(`verifyPackDir` / `planPackInstall` / `installPackFiles` / `cachePack` /
397
+ * `packContentDigest`)全部假定正斜杠;Windows 上生成的包若带反斜杠键,
398
+ * 在 POSIX 机器上解包会"找不到文件"、在 Windows 上则会与锁/基线**绕过比对**。
399
+ */
400
+ export function readPackJson(packDir) {
401
+ const p = join(packDir, 'PACK.json')
402
+ let doc
403
+ try { doc = JSON.parse(readFileSync(p, 'utf8')) } catch (e) {
404
+ throw new Error(`能力包缺 PACK.json 或无法解析:${p}(${String(e.message || e)})`)
405
+ }
406
+ if (!doc || typeof doc !== 'object' || !doc.capability) throw new Error(`PACK.json 结构不对(缺 capability):${p}`)
407
+ if (doc.files && typeof doc.files === 'object') doc.files = normalizePackPaths(doc.files)
408
+ return doc
409
+ }
410
+
411
+ /** 校验包内每个文件的 sha16 是否与 PACK.json 一致 ⇒ {ok, files, bad:[{file,want,got}]}。 */
412
+ export function verifyPackDir(packDir, packJson) {
413
+ const want = (packJson && packJson.files) || {}
414
+ const bad = []
415
+ for (const [rel, sha] of Object.entries(want)) {
416
+ if (!sha) continue
417
+ const got = sha16File(join(packDir, rel))
418
+ if (got !== String(sha)) bad.push({ file: rel, want: String(sha), got: got === null ? '(缺失)' : got })
419
+ }
420
+ return { ok: bad.length === 0, files: Object.keys(want).length, bad }
421
+ }
422
+
423
+ /**
424
+ * 多包合并计划:文件 → 来源包。**同一文件在不同包里 sha 不同 ⇒ 抛错**(点名文件与两侧版本),
425
+ * **注册表投影除外**(见 `isRegistryProjection()`:它按能力过滤,两个包的投影必然不同 sha)。
426
+ * 这是"同一台机器上的能力包必须来自同一版代码"的守卫 —— 引擎文件被两个包各带一份。
427
+ *
428
+ * ⚠️ 判据只有一份:本函数与 `installPackFiles()` 都只经 `isRegistryProjection()` 决定"哪些路径
429
+ * 属投影"。2026-10-08 的事故正是**判据写了两份**——⑤ 只给执行阶段加了豁免,计划阶段仍是裸 sha
430
+ * 比对 ⇒ 安装器**自己推荐**的 `--from-packs a.tgz,b.tgz` 必失败(新旧两代引擎都栽在这里)。
431
+ */
432
+ export function planPackInstall(packs) {
433
+ const files = {}
434
+ for (const p of packs || []) {
435
+ const id = String((p.packJson && p.packJson.capability) || p.capability || '')
436
+ const ver = String((p.packJson && p.packJson.version) || '')
437
+ for (const [rel, sha] of Object.entries((p.packJson && p.packJson.files) || {})) {
438
+ const prev = files[rel]
439
+ if (prev && String(prev.sha) !== String(sha)) {
440
+ if (isRegistryProjection(rel, [prev.packJson, p.packJson])) {
441
+ // ⑤:注册表投影(`PACK.json#sourceRegistry`)按能力过滤 ⇒ 跨包 sha **必然**不同,
442
+ // 不是"混版"。(落盘阶段由 `installPackFiles()` 按 保留/合并 处理 ⇒ 结果是各投影的并集。)
443
+ // 计划里保留**首个**包的那份 sha,并记下还有谁带这个投影(诊断用;不参与后续判定)。
444
+ prev.projection = true
445
+ prev.divergent = [...(prev.divergent || []), `${id} v${ver}`]
446
+ continue
447
+ }
448
+ throw new Error(`能力包引擎文件版本不一致:${rel}(${prev.id} v${prev.version} sha=${prev.sha} ≠ ${id} v${ver} sha=${sha})`
449
+ + ' —— 同一台机器上的能力包必须来自同一版代码,请用同一批发布的包')
450
+ }
451
+ if (!prev) files[rel] = { sha: String(sha), id, version: ver, packJson: p.packJson }
452
+ }
453
+ }
454
+ return files
455
+ }
456
+
457
+ // ── ⑤(2026-10-08):包内注册表是**投影**,不是"冲突" ────────────────────────────
458
+ // 事故:`--from-packs` 在**已装机器**上必然失败 —— 包内 `scripts/flow-requirements.json` 是
459
+ // `capability_pack.py#packed_registry()` 按能力**过滤后的注册表投影**(设计如此),而目标机上那份
460
+ // 是**完整注册表**(git 克隆树)或**另一批能力的投影** ⇒ 两者 sha **永不相等** ⇒ 旧实现把它当"文件冲突"
461
+ // 拒绝安装;而 `force` 只在"该能力已登记"时为真 ⇒ **首次安装必冲突**,报错还让你用一个不存在的 `--force`。
462
+ //
463
+ // 口径(本项目期确定,三条状态都不误判):
464
+ // ① 首次安装(目标机没有注册表)⇒ 写入包内投影;
465
+ // ② 已装机器重装同一能力 / ③ 已装机器装新能力 ⇒ 目标机那份**覆盖**包内投影时**保留目标机的**
466
+ // (绝不用一份单能力投影去盖掉完整注册表 —— 那会把别家能力的流程/声明一起抹掉);
467
+ // 覆盖不了(缺本包的流程/能力)⇒ **合并**:目标机独有的条目原样保留、本包条目以本包为准。
468
+
469
+ /** 包内**注册表投影**的仓库相对路径(`PACK.json#sourceRegistry`;缺省按历史约定)。 */
470
+ export function packProjectionPath(packJson) {
471
+ const declared = String((packJson && packJson.sourceRegistry) || '').trim()
472
+ return normalizePackPath(declared || 'scripts/flow-requirements.json')
473
+ }
474
+
475
+ /**
476
+ * 「该路径是不是注册表投影」—— **唯一真源**(⑤,2026-10-09 收口)。
477
+ *
478
+ * `planPackInstall()`(多包计划阶段)与 `installPackFiles()`(落盘阶段)**都只经这一个函数**
479
+ * 决定"哪些路径属投影、因而不算冲突":计划阶段要豁免的、执行阶段要 保留/合并 的,必须是
480
+ * **同一个路径集合**。两处各写一份判断 = 2026-10-08 的老 bug(⑤ 只修了执行阶段 ⇒ 安装器自己
481
+ * 推荐的"一起装"路径必失败);这条注释就是防它再漂移的护栏。
482
+ *
483
+ * @param {string} rel 仓库相对路径(`PACK.json#files` 的键)
484
+ * @param {Array<object>} packJsons 参与本次比较的能力包声明。
485
+ * · 计划阶段传**两侧**(两个包都是 PACK.json 声明,"任一侧声明它是投影"即属投影);
486
+ * · 执行阶段只传落盘的那一个包(目标机上的那份没有声明,判据由**写入方**给出)。
487
+ * @returns {boolean} true = 注册表投影(差异是设计使然);false = 普通文件(不同 sha 即冲突)
488
+ */
489
+ export function isRegistryProjection(rel, packJsons) {
490
+ const want = normalizePackPath(rel)
491
+ for (const pj of (Array.isArray(packJsons) ? packJsons : [packJsons])) {
492
+ if (pj && packProjectionPath(pj) === want) return true
493
+ }
494
+ return false
495
+ }
496
+
497
+ /** 读一份注册表文档(对象);读不了 ⇒ null(判据要区分"读不懂"与"内容不同")。 */
498
+ function readRegistryDoc(abs) {
499
+ try {
500
+ const d = JSON.parse(readFileSync(abs, 'utf8'))
501
+ return d && typeof d === 'object' && !Array.isArray(d) ? d : null
502
+ } catch { return null }
503
+ }
504
+
505
+ /** 注册表里"对外可见"的 id 集合(`_` 前缀是内部键,不参与覆盖判定)。 */
506
+ function registryIds(doc, key) {
507
+ const src = doc && typeof doc[key] === 'object' && doc[key] ? doc[key] : {}
508
+ return new Set(Object.keys(src).filter((k) => !k.startsWith('_')))
509
+ }
510
+
511
+ /**
512
+ * 目标机的注册表是否**覆盖**包内投影:包里的每条 flow / capability / 别名都能在目标机找到。
513
+ * 覆盖 ⇒ 目标机那份是超集(完整注册表 / 多能力合并后的),**保留它**(否则会抹掉别家能力)。
514
+ */
515
+ export function registryCovers(destDoc, packDoc) {
516
+ if (!destDoc || !packDoc) return false
517
+ for (const key of ['flows', 'capabilities', 'aliases']) {
518
+ const have = registryIds(destDoc, key)
519
+ for (const id of registryIds(packDoc, key)) if (!have.has(id)) return false
520
+ }
521
+ return true
522
+ }
523
+
524
+ /** 合并标记(幂等:重复合并不会越加越长)。 */
525
+ const REGISTRY_MERGE_MARK = '(本机已与既有注册表条目合并:保留别家能力的流程/声明,本包条目以本包为准)'
526
+
527
+ // ── P2a 不变量(2026-10-09,P0 修复):**引擎级 base 不含 flow 声明** ─────────────────
528
+ // 投影的落点 `scripts/flow-requirements.json` 是 P2a 契约里的**引擎级 base**(引擎契约/错误词表/
529
+ // lease/billing/pack.engine);flow 声明属业务线 `verticals/<id>/flows.json`(由包同批带上)。
530
+ // 而 2026-10-09 之前的包把 flow 抄进了投影 ⇒ 落到 base 上就与业务线那份**重复定义** ⇒
531
+ // `flow_registry.py --json` exit 2(源码树);纯包装机则因包没带 flows.json 而 exit 2。
532
+ // ⇒ 写入 base 前**先规整**:把不属于 base 的段摘掉(并说明)。这条只作用于安装/回滚的**写入边界**,
533
+ // **不动** `flow_registry.py` 的冲突判据(base 里真出现重复定义时仍然是响亮的红,见门禁用例【⑤】)。
534
+ // 对**新格式包**(投影里这两个段恒为空)本函数是**空操作** ⇒ 逐字节落盘、重装幂等、sha 与 PACK.json 一致。
535
+
536
+ /** 不属于引擎级 base 的段(判据只有这一份,`installPackFiles()` 与回归都用它)。 */
537
+ export const BASE_FOREIGN_SECTIONS = ['flows', 'aliases']
538
+
539
+ /**
540
+ * 把包内投影规整成"引擎级 base"形态 ⇒ `{doc, dropped}`(`dropped` 非空 = 该包是**旧格式**)。
541
+ * @param {object} packDoc 包内 `scripts/flow-requirements.json` 解析后的对象
542
+ */
543
+ export function baseProjectionDoc(packDoc) {
544
+ const doc = { ...(packDoc || {}) }
545
+ const dropped = []
546
+ for (const key of BASE_FOREIGN_SECTIONS) {
547
+ const v = doc[key]
548
+ if (!v || typeof v !== 'object') continue
549
+ const n = Object.keys(v).filter((k) => !k.startsWith('_')).length
550
+ if (!n) continue
551
+ dropped.push(`${key}(${n} 条)`)
552
+ delete doc[key]
553
+ }
554
+ return { doc, dropped }
555
+ }
556
+
557
+ /**
558
+ * 投影合并:`flows`/`capabilities`/`aliases` 逐段并集(**包内胜** = 同一能力重装即升级),
559
+ * 其余引擎级段以包内为准,目标机独有的键保留。
560
+ *
561
+ * ⚠️ P2a(2026-10-09)起调用方传进来的 `packDoc` 已经过 `baseProjectionDoc()` 规整(flows/aliases 为空),
562
+ * 所以写进 base 的 `flows` 恒等于目标机原有的那份(= 通常是空);这里保留并集语义只为兼容旧调用方/旧包。
563
+ */
564
+ export function mergeRegistryProjections(destDoc, packDoc) {
565
+ const out = { ...(destDoc || {}) }
566
+ for (const key of ['flows', 'capabilities', 'aliases']) {
567
+ const b = packDoc && packDoc[key]
568
+ if (!b || typeof b !== 'object') continue
569
+ out[key] = { ...((destDoc && destDoc[key] && typeof destDoc[key] === 'object') ? destDoc[key] : {}), ...b }
570
+ }
571
+ for (const [k, v] of Object.entries(packDoc || {})) {
572
+ if (k === 'flows' || k === 'capabilities' || k === 'aliases') continue
573
+ out[k] = v
574
+ }
575
+ const base = typeof (packDoc || {})._doc === 'string' ? packDoc._doc : ''
576
+ out._doc = base.includes(REGISTRY_MERGE_MARK) ? base : base + REGISTRY_MERGE_MARK
577
+ return out
578
+ }
579
+
580
+ /**
581
+ * 目标机已有一份注册表(且与包内投影不同 sha)时怎么办 ⇒ `{action:'keep'|'merged'|'conflict', note}`。
582
+ * · `keep`:目标机那份覆盖本包 ⇒ 一个字都不写(保持机器口径;这就是"已知差异"的正解);
583
+ * · `merged`:目标机那份缺本包条目 ⇒ 写回并集(不丢别家能力,也不让本包跑不起来);
584
+ * · `conflict`:有一侧读不懂(不是 JSON 对象)⇒ 不猜,交给调用方按既有冲突/force 路径处理。
585
+ *
586
+ * `packDoc`/`targetDoc` 可直接给**已解析**的文档(`installPackFiles()` 用:包侧已按 `baseProjectionDoc()`
587
+ * 规整过,不能再从盘上读原始字节);不给则按路径读。
588
+ */
589
+ export function reconcileRegistryProjection({ packRegistry, targetRegistry, packDoc, targetDoc }) {
590
+ const pd = packDoc || readRegistryDoc(packRegistry)
591
+ const dd = targetDoc || readRegistryDoc(targetRegistry)
592
+ if (!pd || !dd) {
593
+ return { action: 'conflict', note: '注册表读不懂(不是 JSON 对象)⇒ 不当"已知差异"处理,走冲突/force 路径' }
594
+ }
595
+ if (registryCovers(dd, pd)) {
596
+ return {
597
+ action: 'keep',
598
+ note: '目标机已有**覆盖**本包的注册表(完整注册表或另一批能力的投影)⇒ 保留它(属已知差异,不是冲突)',
599
+ }
600
+ }
601
+ const merged = mergeRegistryProjections(dd, pd)
602
+ return {
603
+ action: 'merged',
604
+ doc: merged,
605
+ note: '目标机的注册表缺本包的流程/能力条目 ⇒ **合并**写入(别家能力的条目原样保留)',
606
+ }
607
+ }
608
+
609
+ /**
610
+ * 把包内文件落到 targetHome(**只写不冲突的文件**:同 sha 视为已装并跳过,不同 sha 需 force)。
611
+ *
612
+ * 例外(⑤,见上):`PACK.json#sourceRegistry` 指的那份**注册表投影**按"覆盖/合并"判定,
613
+ * **永不**计入 `conflicts`(它与目标机的差异是设计使然)。判据 = `isRegistryProjection()`
614
+ * —— **与计划阶段 `planPackInstall()` 同一个函数**(唯一真源,见该函数注释)。
615
+ *
616
+ * @returns {{written:number, skipped:number, projected:number, merged:number,
617
+ * conflicts:Array<{file:string,existing:string,incoming:string}>, notes:string[], total:number}}
618
+ * `projected` = 因"目标机已覆盖"而**有意保留**的文件数;`merged` = 投影合并写入的数;`notes` = 人读说明。
619
+ */
620
+ export function installPackFiles({ packDir, packJson, targetHome, force = false }) {
621
+ const planned = planPackInstall([{ packDir, packJson }])
622
+ let written = 0, skipped = 0, projected = 0, merged = 0
623
+ const conflicts = []
624
+ const notes = []
625
+ for (const [rel, meta] of Object.entries(planned)) {
626
+ const src = join(packDir, rel)
627
+ const dst = join(targetHome, rel)
628
+ const cur = sha16File(dst)
629
+ if (cur === meta.sha) { skipped++; continue }
630
+ // 判据与计划阶段**同一个**(`isRegistryProjection`,唯一真源)——不得在此另写一份。
631
+ if (isRegistryProjection(rel, [packJson])) {
632
+ const packDoc = readRegistryDoc(src)
633
+ if (packDoc) {
634
+ // P2a 不变量:写进 base 的那份**只留引擎级段**(见 `baseProjectionDoc()` 的事故说明)。
635
+ // 新格式包的投影里 flows/aliases 恒为空 ⇒ `dropped` 为空 ⇒ 下面走逐字节落盘(幂等不变)。
636
+ const { doc: incoming, dropped } = baseProjectionDoc(packDoc)
637
+ if (dropped.length) {
638
+ notes.push(`${rel}:包内投影把 ${dropped.join('、')} 写进了**引擎级 base**(P2a 起 base 不含 flow 声明)`
639
+ + '⇒ 这几段未写入;flow 由 `verticals/<id>/flows.json` 提供(本批发布的包会带上它)——'
640
+ + '这是**旧格式包**,建议重装本批发布')
641
+ }
642
+ if (cur === null) {
643
+ // 首次安装:新格式包逐字节落盘(sha 与 PACK.json 一致 ⇒ 重装幂等);旧格式包落规整后的 base
644
+ mkdirSync(dirname(dst), { recursive: true })
645
+ if (dropped.length) writeFileSync(dst, `${JSON.stringify(incoming, null, 2)}\n`)
646
+ else copyFileSync(src, dst)
647
+ written++
648
+ continue
649
+ }
650
+ const r = reconcileRegistryProjection({ packDoc: incoming, targetDoc: readRegistryDoc(dst) })
651
+ if (r.action === 'keep') { projected++; notes.push(`${rel}:${r.note}`); continue }
652
+ if (r.action === 'merged') {
653
+ mkdirSync(dirname(dst), { recursive: true })
654
+ writeFileSync(dst, `${JSON.stringify(r.doc, null, 2)}\n`)
655
+ written++; merged++
656
+ notes.push(`${rel}:${r.note}`)
657
+ continue
658
+ }
659
+ // action === 'conflict':读不懂就不猜 —— 落到下面的既有 conflict/force 路径
660
+ notes.push(`${rel}:${r.note}`)
661
+ }
662
+ }
663
+ if (cur !== null && !force) { conflicts.push({ file: rel, existing: cur, incoming: meta.sha }); continue }
664
+ mkdirSync(dirname(dst), { recursive: true })
665
+ copyFileSync(src, dst)
666
+ written++
667
+ }
668
+ return { written, skipped, projected, merged, conflicts, notes, total: Object.keys(planned).length }
669
+ }
670
+
671
+ /** 解包 tgz 到 destDir(系统 tar;确定性包无软链/绝对路径,安全)。 */
672
+ export function extractPack(tgz, destDir) {
673
+ mkdirSync(destDir, { recursive: true })
674
+ execFileSync('tar', ['-xzf', tgz, '-C', destDir], { stdio: ['ignore', 'pipe', 'pipe'] })
675
+ return destDir
676
+ }
677
+
678
+ /** 一个来源(.tgz 或已解开的目录)→ { packDir, packJson }。 */
679
+ export function openPackSource(spec, tmpRoot) {
680
+ if (existsSync(spec) && readFileSync && spec.endsWith('.tgz')) {
681
+ const dir = join(tmpRoot, `pack-${createHash('sha256').update(spec).digest('hex').slice(0, 8)}`)
682
+ extractPack(spec, dir)
683
+ return { packDir: dir, packJson: readPackJson(dir) }
684
+ }
685
+ return { packDir: spec, packJson: readPackJson(spec) }
686
+ }
687
+
688
+ /** 从 URL 下载能力包(带重试 + 可选整包 sha256 校验)⇒ 本地 .tgz 路径。 */
689
+ export async function downloadPack(url, destDir, { expectSha256 = '', retries = 3 } = {}) {
690
+ mkdirSync(destDir, { recursive: true })
691
+ const name = (url.split('?')[0].split('/').pop() || 'pack.tgz')
692
+ const dest = join(destDir, name)
693
+ let lastErr = null
694
+ for (let i = 0; i < Math.max(1, retries); i++) {
695
+ try {
696
+ const res = await fetch(url, { redirect: 'follow' })
697
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
698
+ const buf = Buffer.from(await res.arrayBuffer())
699
+ if (buf.length < 100) throw new Error(`下载内容过小(${buf.length} 字节)`)
700
+ writeFileSync(dest, buf)
701
+ const got = createHash('sha256').update(buf).digest('hex')
702
+ if (expectSha256 && got !== String(expectSha256).toLowerCase()) {
703
+ rmSync(dest, { force: true })
704
+ throw new Error(`整包 sha256 不符(期望 ${String(expectSha256).slice(0, 16)}… 实际 ${got.slice(0, 16)}…)`)
705
+ }
706
+ return { file: dest, sha256: got, bytes: buf.length }
707
+ } catch (e) {
708
+ lastErr = e
709
+ if (i < retries - 1) await new Promise((r) => setTimeout(r, 1500 * (i + 1)))
710
+ }
711
+ }
712
+ throw new Error(`下载失败(已重试 ${retries} 次):${url} —— ${String(lastErr && lastErr.message)}`)
713
+ }
714
+
715
+ /** 整包内容摘要(与 python `capability_pack.pack_digest()` 同口径:对 `文件:sha16` 排序后求 sha256)。
716
+ * 服务端清单里的 `sha256` 就是它 ⇒ 下载后可比一次,确认"包内容与服务端发布的一致"。 */
717
+ export function packContentDigest(packJson) {
718
+ const lines = Object.entries((packJson && packJson.files) || {})
719
+ .map(([rel, sha]) => `${rel}:${sha}`)
720
+ .sort()
721
+ return createHash('sha256').update(lines.join('\n'), 'utf8').digest('hex')
722
+ }
723
+
724
+ // ── 升级/回滚(缺口④,2026-09-21):把"装过的包"留在 packs-cache,随时能装回去 ──────────
725
+ // 设计取舍:不做"逐文件差异备份"(复杂、易错),而是**整包留档** —— 每次装包把该包原样拷进
726
+ // `<dataDir>/packs-cache/<cap>-<ver>/`(包本来就带 PACK.json 的逐文件 sha,回滚时走同一条
727
+ // 校验+落盘路径)⇒ 回滚 = "用旧包再装一次",行为与正向安装完全一致。
728
+
729
+ /** 把装过的包留档(同版本已存在则跳过)。返回留档目录。 */
730
+ export function cachePack(packDir, packJson, dataDir) {
731
+ const id = String(packJson.capability || '')
732
+ const ver = String(packJson.version || '')
733
+ if (!id || !ver) return ''
734
+ const dest = join(dataDir, 'packs-cache', `${id}-${ver}`)
735
+ if (existsSync(join(dest, 'PACK.json'))) return dest
736
+ mkdirSync(dest, { recursive: true, mode: 0o700 })
737
+ for (const rel of Object.keys(packJson.files || {})) {
738
+ const src = join(packDir, rel)
739
+ if (!existsSync(src)) continue
740
+ const dst = join(dest, rel)
741
+ mkdirSync(dirname(dst), { recursive: true })
742
+ copyFileSync(src, dst)
743
+ }
744
+ writeFileSync(join(dest, 'PACK.json'), JSON.stringify(packJson, null, 2) + '\n', { mode: 0o600 })
745
+ return dest
746
+ }
747
+
748
+ /** 列出某个能力在 cache 里的版本(新→旧)。 */
749
+ export function cachedVersions(capability, dataDir) {
750
+ const root = join(dataDir, 'packs-cache')
751
+ let names = []
752
+ try { names = readdirSync(root) } catch { return [] }
753
+ const prefix = `${capability}-`
754
+ return names.filter((n) => n.startsWith(prefix) && existsSync(join(root, n, 'PACK.json')))
755
+ .map((n) => ({ version: n.slice(prefix.length), dir: join(root, n) }))
756
+ .sort((a, b) => cmpVersion(b.version, a.version))
757
+ }
758
+
759
+ /** 语义化版本比较(只认数字段,够用;返回 -1/0/1)。 */
760
+ export function cmpVersion(a, b) {
761
+ const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0)
762
+ const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0)
763
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
764
+ const d = (pa[i] || 0) - (pb[i] || 0)
765
+ if (d) return d > 0 ? 1 : -1
766
+ }
767
+ return 0
768
+ }
769
+
770
+ /** 选回滚目标:给了 --to 用它;否则取 cache 里"与当前不同"的最新版本。 */
771
+ export function pickRollback(capability, dataDir, { current = '', to = '' } = {}) {
772
+ const list = cachedVersions(capability, dataDir)
773
+ if (!list.length) return null
774
+ if (to) return list.find((x) => x.version === String(to)) || null
775
+ return list.find((x) => x.version !== String(current)) || null
776
+ }