@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,835 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * schedule-probe —— 「宿主原生排程到底还在不在、还在不在干活」的**唯一只读判据**(P1-A,2026-10-12)
4
+ *
5
+ * ══════════════════════════════════════════════════════════════════════════
6
+ * 为什么必须有它(本模块的由来)
7
+ * ══════════════════════════════════════════════════════════════════════════
8
+ * 把定时从「OS 计划任务 + `scripts/flow-trigger*`」换成「宿主原生排程」时,**观测面会整体搬家**:
9
+ * · 现在:定时器在**我们自己的**脚本里 ⇒ 跑没跑看 `verticals/<id>/logs/flow-trigger-<flow>.log`;
10
+ * · 换后:定时在**宿主插件**里 ⇒ 「投递失败**只写宿主自己的日志、不重试**」
11
+ * (上游设计文档《dsh-排程插件评估与迁移设计》§0.4/§6.2 实测),而且**投递回执只证明
12
+ * "消息进了会话收件箱",不证明"模型执行了"**。
13
+ * ⇒ 直接切过去 = **净退步**(定时停了没人知道)。本模块把「怎么证明它还在跑」变成**可复制的判据**:
14
+ * 四道闸(挂载 / 新鲜度 / 执行 / 持久化),每道闸都给出 `state` + 人可读的下一步。
15
+ *
16
+ * ══════════════════════════════════════════════════════════════════════════
17
+ * 纪律(改本文件前先读这三条)
18
+ * ══════════════════════════════════════════════════════════════════════════
19
+ * 1. **纯 L1**:只用 node 内建(`fs`/`path`/`os`)+ 同层 `lib/wake-auth.mjs` 的**调用方注入**形态
20
+ * —— ⛔ 不 import 任何宿主模块(`node scripts/tests/kernel-purity.test.mjs` 会查)。
21
+ * 与宿主的全部交互都是**HTTP + 文件读取**,所以本文件可以在没有宿主、没有 python 的机器上跑判据。
22
+ * 2. **只读**:本文件**不写任何文件、不删任何任务、不读/不落凭据**(cookie 由调用方传进来)。
23
+ * 唯一会改宿主的动作(停/建任务)在 `scripts/argent-schedule.mjs` 里,且默认 dry-run。
24
+ * 3. **判据来自宿主契约,不是猜的**(2026-10-12 读 0.1.7-rc.2 的包内 TYPERT + domain 定义):
25
+ * · 端点 = `POST /api/<endpoint>`,信封 `{type:"client-request",rpcId,method,payload:{args}}`
26
+ * ⇒ 200 `{type:"server-response",result:{ok:true,value:…}}`;**未挂载 = 404 `not found`**
27
+ * (源码:`dsh-client-connection` 的 `rpcFetchHandler` / 共享通道 404 分支)。
28
+ * · 排程域 = `defineDomain({name:"schedule",version:1,tables:{tasks:…}})`,落盘布局
29
+ * `<存储根>/schedule/tasks/<id>.json`(逐记录)+ 旧整体文件 `<存储根>/schedule.json`(保留不动)。
30
+ * · **一条非法任务会拒绝打开整个 domain**(宿主源码原话:malformed tasks reject opening the domain)
31
+ * ⇒ 域文件坏掉会拖垮整个宿主(连对话一起)⇒ 这就是第 4 闸存在的理由。
32
+ *
33
+ * ══════════════════════════════════════════════════════════════════════════
34
+ * 四道闸(判据可复制;`scripts/argent-schedule.mjs check` 是它们的 CLI 出口)
35
+ * ══════════════════════════════════════════════════════════════════════════
36
+ * | # | 闸 | 判据 | 防的静默 |
37
+ * |---|---|---|---|
38
+ * | ① | 挂载 | `POST /api/schedule/catalog` 返回 **404 ⇒ 红**(没挂载);200 ⇒ 绿 | 宿主升级把组合里那三行删掉(0.1→0.2 实测就是这个行为) |
39
+ * | ② | 新鲜度 | `now - lastDelivery.deliveredAt > 1.5 × 周期` ⇒ **红**;无回执且已超期 ⇒ 红 | 宿主死了 / 任务被停 / 定时器没起来 |
40
+ * | ③ | 执行 | 回执推进 **∧** L1(轮次日志 mtime + 台账最新事件)无新证据 ⇒ **红** | 「投递了但没人干活」(排程 prompt 是静态的 ⇒ 确定性从引擎保证退化为模型遵守) |
41
+ * | ④ | 持久化 | 域文件**可解析** ∧ 关键不变量成立(坏 ⇒ 宿主拒启) | 任务库损坏拖垮整个宿主 |
42
+ * | ⑤ | 单一调度者 | 同一条流程被**两条线同时**调度 ⇒ **红**(原生任务 × 激活垫片) | 双跑抢同一批生产行(台账每机独立、lease 无跨调度者互斥) |
43
+ *
44
+ * ⑤ 的**判据实现只有一处**:`scripts/lib/schedule-mechanism.mjs#resolveScheduleMechanism()` /
45
+ * `schedulerConflict()`(0.6.0 W8)。本文件只把它的结论**渲染成一道闸**(⛔ 不重写判据)。
46
+ *
47
+ * 跑法(回归,纯夹具零网络):`node scripts/tests/schedule-probe.test.mjs`
48
+ */
49
+
50
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
51
+ import { homedir } from 'node:os'
52
+ import { isAbsolute, join } from 'node:path'
53
+
54
+ // ══════════════════════════════════════════════════════════════════════════
55
+ // 一、宿主契约常量(**唯一出处**:宿主包的 TYPERT 反射 + domain 定义;⛔ 别在别处再抄一份)
56
+ // ══════════════════════════════════════════════════════════════════════════
57
+
58
+ /** 共享 RPC 通道(宿主 web 的固定前缀)。 */
59
+ export const API_CHANNEL = '/api'
60
+
61
+ /** 挂载判据用的端点(**只读、无参数**):它是"排程服务在不在组合里"的最小探针。 */
62
+ export const MOUNT_ENDPOINT = 'schedule/catalog'
63
+
64
+ /** 排程服务的全部 RPC 端点(0.1.7-rc.2 与 0.2.0-rc.2 实测**完全相同**)。 */
65
+ export const ENDPOINTS = Object.freeze({
66
+ catalog: 'schedule/catalog', // 读:全部任务(含 inactive)+ 会话绑定 + lastDelivery
67
+ history: 'schedule/history', // 读:某个任务的投递历史(分页)
68
+ list: 'schedule/list', // 读:某个会话的 active 任务
69
+ delete: 'schedule/delete', // 写:删任务(行没了 ⇒ 不再排程;投递记录一并消失)
70
+ update: 'schedule/update', // 写:改标题/指令/时机(⛔ **改不了 status**)
71
+ })
72
+
73
+ /**
74
+ * ⚠️ **能力缺口(2026-10-12 读源码确认,影响 P1-B 的 `onPause: deactivate`)**:
75
+ * 宿主**没有**任何"把任务置为 inactive"的公开面 ——
76
+ * · `schedule/create` **不是** Remote 端点(只在会话内由工具调用,且**恒写 `status:"active"`**);
77
+ * · `schedule/update` 明确拒绝已 inactive 的任务,且 `change` 只含 title/prompt/时机;
78
+ * · `schedule/delete` 是**唯一**能让一条重复任务不再触发的远端动作(代价:投递历史随行消失);
79
+ * · `inactive` 只有两种来源:一次性任务投递完成、重复任务算不出下一次发生时点。
80
+ * ⇒ 「`PAUSE_*` 文件 ⇒ 任务 `inactive`」这条收敛**无法用现成 API 实现**;本模块如实判定,
81
+ * 把"停"的可用原语(delete + 审计留档)交给 CLI,把"启"(create 需要**会话内工具**)标成待办。
82
+ */
83
+ export const STATUS_SETTER_AVAILABLE = false
84
+
85
+ /** 排程域(落盘布局与"坏文件 ⇒ 拒绝启动"的判据都在这里)。 */
86
+ export const DOMAIN = Object.freeze({ name: 'schedule', version: 1, table: 'tasks' })
87
+
88
+ /** 选择器与限额(宿主硬约束;超了宿主会拒,所以门禁也要按同一套判)。 */
89
+ export const KINDS = Object.freeze(['after', 'at', 'every', 'daily', 'weekly', 'cron'])
90
+ export const MIN_EVERY_SECONDS = 60
91
+ export const MAX_TITLE_LENGTH = 120
92
+
93
+ /** 新鲜度闸的系数(上游设计 §6.2 第 2 闸:`> 1.5 × 周期` ⇒ 红)。 */
94
+ export const FRESHNESS_FACTOR = 1.5
95
+
96
+ /** 执行闸的宽限(秒):投递到"L1 真的动了"之间的正常延迟(会话恢复 + 派发 + 认领)。 */
97
+ export const DEFAULT_GRACE_SEC = 180
98
+
99
+ /** 台账尾部读取的字节数(台账是追加型 JSONL,可能很大 ⇒ 只读尾巴)。 */
100
+ export const DEFAULT_LEDGER_TAIL_BYTES = 512 * 1024
101
+
102
+ /** 一天的秒数(daily/weekly 的周期由此推出 —— 与宿主"本地钟表时间"语义一致)。 */
103
+ const DAY_SEC = 86400
104
+
105
+ // ══════════════════════════════════════════════════════════════════════════
106
+ // 二、位置判据(存储根 / 域文件 / L1 证据面)
107
+ // ══════════════════════════════════════════════════════════════════════════
108
+
109
+ /**
110
+ * 宿主的**存储根**:`<DSH_HOME>/storages`(`DSH_HOME` 缺省 `~/.dsh`)。
111
+ * ⚠️ 这是**宿主自己的**目录,不是 Argent 数据目录(`<DSH_HOME>/argent`,那个的唯一判据在
112
+ * `core/lib/argent-datadir.mjs`)⇒ 这里不引用它、也不改它。
113
+ */
114
+ export function storageRoot({ dshHome, env = process.env, home = homedir() } = {}) {
115
+ const h = dshHome || (env && env.DSH_HOME) || join(home, '.dsh')
116
+ return join(h, 'storages')
117
+ }
118
+
119
+ /** 排程域的候选落盘路径(**逐记录布局**优先;旧整体文件是历史形态,宿主读取时会迁移但**保留原文件**)。 */
120
+ export function domainPaths(root) {
121
+ const base = root || storageRoot()
122
+ return {
123
+ root: base,
124
+ unitDir: join(base, DOMAIN.name),
125
+ tableDir: join(base, DOMAIN.name, DOMAIN.table),
126
+ globalFile: join(base, DOMAIN.name, 'global.json'),
127
+ legacyFile: join(base, `${DOMAIN.name}.json`),
128
+ }
129
+ }
130
+
131
+ const statOr = (p) => { try { return statSync(p) } catch { return null } }
132
+ const readText = (p) => { try { return { ok: true, text: readFileSync(p, 'utf8') } } catch (e) { return { ok: false, error: String((e && (e.code || e.message)) || e) } } }
133
+
134
+ /** 域文件(`<tableDir>/*.json` + 旧整体文件 + `global.json`)的**只读清单**。 */
135
+ export function listDomainFiles(paths) {
136
+ const out = []
137
+ const dir = paths.tableDir
138
+ const st = statOr(dir)
139
+ if (st && st.isDirectory()) {
140
+ let names = []
141
+ try { names = readdirSync(dir) } catch { names = [] }
142
+ for (const n of names.filter((x) => x.endsWith('.json')).sort()) {
143
+ out.push({ kind: 'record', key: n.slice(0, -5), path: join(dir, n) })
144
+ }
145
+ }
146
+ if (statOr(paths.legacyFile)) out.push({ kind: 'legacy', key: '', path: paths.legacyFile })
147
+ if (statOr(paths.globalFile)) out.push({ kind: 'global', key: '', path: paths.globalFile })
148
+ return out
149
+ }
150
+
151
+ // ══════════════════════════════════════════════════════════════════════════
152
+ // 三、任务记录校验(宿主 domain schema 的**只读镜像**:只覆盖"会让宿主拒绝打开 domain"的那几条)
153
+ // ══════════════════════════════════════════════════════════════════════════
154
+
155
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
156
+ const isStr = (v) => typeof v === 'string'
157
+ const nonEmptyStr = (v) => isStr(v) && v.trim() !== ''
158
+ const isoOrNull = (v) => (nonEmptyStr(v) && Number.isFinite(Date.parse(v)) ? v : null)
159
+ const HHMMSS = /^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,3})?)?$/
160
+
161
+ /**
162
+ * 校验一条 `tasks` 记录(`{sessionId, record, status, lastDelivery?, deliveryHistory?}`)。
163
+ * @returns {string[]} 问题清单(空 = 通过;非空 ⇒ 宿主会拒绝打开整个 domain)
164
+ */
165
+ export function validateTask(task) {
166
+ const p = []
167
+ if (!isObj(task)) return ['任务不是对象']
168
+ if (!nonEmptyStr(task.sessionId)) p.push('缺 sessionId')
169
+ if (!['active', 'inactive'].includes(task.status)) p.push(`status=${JSON.stringify(task.status)}(只允许 active|inactive)`)
170
+ const r = task.record
171
+ if (!isObj(r)) {
172
+ p.push('缺 record')
173
+ } else {
174
+ if (!nonEmptyStr(r.id)) p.push('record 缺 id')
175
+ if (!nonEmptyStr(r.title)) p.push('record 缺 title')
176
+ else if (r.title.length > MAX_TITLE_LENGTH) p.push(`record.title 超长(${r.title.length} > ${MAX_TITLE_LENGTH})`)
177
+ else if (r.title.trim() !== r.title) p.push('record.title 首尾有空白(宿主拒)')
178
+ if (!isStr(r.prompt)) p.push('record 缺 prompt')
179
+ if (!KINDS.includes(r.kind)) p.push(`record.kind=${JSON.stringify(r.kind)}(只允许 ${KINDS.join('|')})`)
180
+ if (!isoOrNull(r.scheduledAt)) p.push('record.scheduledAt 不是可解析的时间')
181
+ if (r.kind === 'every' && !(Number.isFinite(r.everySeconds) && r.everySeconds >= MIN_EVERY_SECONDS)) {
182
+ p.push(`kind=every 的 everySeconds 必须 ≥ ${MIN_EVERY_SECONDS}(实得 ${JSON.stringify(r.everySeconds)})`)
183
+ }
184
+ if (['daily', 'weekly', 'cron'].includes(r.kind) && !nonEmptyStr(r.timeZone)) p.push(`kind=${r.kind} 缺 timeZone`)
185
+ if (['daily', 'weekly'].includes(r.kind) && !HHMMSS.test(String(r.time || ''))) p.push(`kind=${r.kind} 的 time=${JSON.stringify(r.time)} 不是 HH:mm:ss`)
186
+ if (r.kind === 'weekly' && !(Array.isArray(r.weekdays) && r.weekdays.length)) p.push('kind=weekly 缺 weekdays')
187
+ if (r.kind === 'cron' && !nonEmptyStr(r.expression)) p.push('kind=cron 缺 expression')
188
+ if (r.kind === 'after' && !Number.isFinite(r.afterSeconds)) p.push('kind=after 缺 afterSeconds')
189
+ }
190
+ const ld = task.lastDelivery
191
+ if (ld !== undefined) {
192
+ if (!isObj(ld)) p.push('lastDelivery 不是对象')
193
+ else {
194
+ if (!isoOrNull(ld.scheduledAt)) p.push('lastDelivery.scheduledAt 不可解析')
195
+ if (!isoOrNull(ld.deliveredAt)) p.push('lastDelivery.deliveredAt 不可解析')
196
+ if (!nonEmptyStr(ld.messageId)) p.push('lastDelivery 缺 messageId')
197
+ }
198
+ }
199
+ const dh = task.deliveryHistory
200
+ if (dh !== undefined) {
201
+ if (!isObj(dh) || !Array.isArray(dh.records)) {
202
+ p.push('deliveryHistory 缺 records 数组')
203
+ } else {
204
+ if (typeof dh.earlierRecordsUnavailable !== 'boolean') p.push('deliveryHistory 缺 earlierRecordsUnavailable(布尔)')
205
+ const latest = dh.records[dh.records.length - 1]
206
+ // 宿主 schema 的 refine:**最新一条投递记录必须与 lastDelivery 一致**(不一致 ⇒ 拒绝打开 domain)
207
+ if (latest === undefined) {
208
+ if (ld !== undefined) p.push('deliveryHistory 为空但存在 lastDelivery(宿主拒)')
209
+ } else if (ld === undefined) {
210
+ p.push('有 deliveryHistory 但没有 lastDelivery(宿主拒)')
211
+ } else if (latest.scheduledAt !== ld.scheduledAt || latest.deliveredAt !== ld.deliveredAt || latest.messageId !== ld.messageId) {
212
+ p.push('deliveryHistory 最新一条与 lastDelivery 不一致(宿主拒:Last delivery must match the latest saved delivery receipt)')
213
+ }
214
+ }
215
+ }
216
+ return p
217
+ }
218
+
219
+ /**
220
+ * 读并校验排程域文件(**只读**)。
221
+ * @returns {{state:'absent'|'ok'|'corrupt'|'unreadable', paths:object, files:Array, tasks:Array,
222
+ * problems:Array<{path:string, issue:string}>, deliveries:Array}}
223
+ * · `absent` —— 域从未落盘(排程没启用过)⇒ **不是故障**
224
+ * · `ok` —— 全部文件可解析且不变量成立
225
+ * · `corrupt` —— 有文件解析失败或记录非法 ⇒ **宿主会拒绝启动**(红)
226
+ * · `unreadable` —— 目录/文件读不了(权限等)⇒ 证据不足
227
+ */
228
+ export function inspectDomain(paths) {
229
+ const files = listDomainFiles(paths)
230
+ const problems = []
231
+ const tasks = []
232
+ const deliveries = []
233
+ let unreadable = 0
234
+ if (!files.length) {
235
+ return { state: 'absent', paths, files: [], tasks: [], problems: [], deliveries }
236
+ }
237
+ for (const f of files) {
238
+ const got = readText(f.path)
239
+ if (!got.ok) { unreadable += 1; problems.push({ path: f.path, issue: `读不了(${got.error})` }); continue }
240
+ let doc
241
+ try { doc = JSON.parse(got.text) } catch (e) {
242
+ problems.push({ path: f.path, issue: `JSON 解析失败(${e.message})—— 宿主打开该 domain 会**拒绝启动**` })
243
+ continue
244
+ }
245
+ if (f.kind === 'legacy') {
246
+ // 旧整体文件:`{unit:{name,version},tables:{<table>:{<key>:record}}}`(宿主迁移后**保留原文件**)
247
+ const name = doc && doc.unit && doc.unit.name
248
+ const ver = doc && doc.unit && doc.unit.version
249
+ if (name !== DOMAIN.name) { problems.push({ path: f.path, issue: `旧整体文件的 unit.name=${JSON.stringify(name)}(期望 ${DOMAIN.name})⇒ 会被宿主忽略,但它占着名字` }); continue }
250
+ if (ver !== DOMAIN.version) { problems.push({ path: f.path, issue: `旧整体文件的 unit.version=${JSON.stringify(ver)}(期望 ${DOMAIN.version})⇒ 会被宿主忽略` }); continue }
251
+ const table = (doc.tables || {})[DOMAIN.table] || {}
252
+ for (const [key, task] of Object.entries(table)) {
253
+ const issues = validateTask(task)
254
+ for (const issue of issues) problems.push({ path: f.path, issue: `${key}: ${issue}` })
255
+ if (!issues.length) { tasks.push({ id: key, task, from: f.path }); if (task.lastDelivery) deliveries.push({ id: key, ...task.lastDelivery }) }
256
+ }
257
+ continue
258
+ }
259
+ if (f.kind === 'global') continue
260
+ // 逐记录文件:`{version, record}`(version 不匹配 ⇒ 宿主**当作不存在**丢弃,不报错)
261
+ if (!isObj(doc) || typeof doc.version !== 'number' || !isObj(doc.record)) {
262
+ problems.push({ path: f.path, issue: '不是逐记录形态 `{version, record}`' })
263
+ continue
264
+ }
265
+ if (doc.version !== DOMAIN.version) {
266
+ problems.push({ path: f.path, issue: `记录 version=${doc.version}(期望 ${DOMAIN.version})⇒ 宿主会把它**当作不存在**丢弃(静默)` })
267
+ continue
268
+ }
269
+ const issues = validateTask(doc.record)
270
+ for (const issue of issues) problems.push({ path: f.path, issue: `${f.key}: ${issue}` })
271
+ if (!issues.length) {
272
+ tasks.push({ id: f.key, task: doc.record, from: f.path })
273
+ if (doc.record.lastDelivery) deliveries.push({ id: f.key, ...doc.record.lastDelivery })
274
+ }
275
+ }
276
+ const state = problems.length ? 'corrupt' : (unreadable && !tasks.length ? 'unreadable' : 'ok')
277
+ return { state, paths, files, tasks, problems, deliveries }
278
+ }
279
+
280
+ // ══════════════════════════════════════════════════════════════════════════
281
+ // 四、L1 证据面("活干了没"的那半边)
282
+ // ══════════════════════════════════════════════════════════════════════════
283
+
284
+ /** 台账里"这条事件属于该流程"的判据:`caseId = <flowId>__<业务键>`(`flow_case.py` 保证前缀)。 */
285
+ export function caseIdPrefixes(flowIds) {
286
+ return (flowIds || []).filter(Boolean).map((f) => `${f}__`)
287
+ }
288
+
289
+ /**
290
+ * 读 L1 证据(**只读**):
291
+ * · 轮次日志:`mtime` / `size`(定时轮次有没有跑,看它有没有动);
292
+ * · 统一台账:尾部窗口里"属于这些流程"的**最新事件时间**;
293
+ * 返回 `newestMs`(= 两者取大)作为"L1 最近动过"的判据。
294
+ */
295
+ export function readL1Evidence({ logPath, ledgerPath, flowIds = [], tailBytes = DEFAULT_LEDGER_TAIL_BYTES } = {}) {
296
+ const ev = { log: null, ledger: null, newestMs: 0, sources: [] }
297
+ if (logPath) {
298
+ const st = statOr(logPath)
299
+ ev.log = st ? { path: logPath, exists: true, mtimeMs: st.mtimeMs, size: st.size } : { path: logPath, exists: false, mtimeMs: 0, size: 0 }
300
+ if (st) { ev.newestMs = Math.max(ev.newestMs, st.mtimeMs); ev.sources.push(`轮次日志 ${logPath}(mtime)`) }
301
+ }
302
+ if (ledgerPath) {
303
+ const st = statOr(ledgerPath)
304
+ const rec = { path: ledgerPath, exists: Boolean(st), mtimeMs: st ? st.mtimeMs : 0, newestTsSec: 0, newestEvent: null, matched: 0, lines: 0, truncated: false, error: '' }
305
+ if (st) {
306
+ const size = st.size
307
+ const start = Math.max(0, size - Math.max(1024, tailBytes))
308
+ rec.truncated = start > 0
309
+ let text = ''
310
+ try {
311
+ const buf = readFileSync(ledgerPath)
312
+ text = buf.subarray(start).toString('utf8')
313
+ } catch (e) { rec.error = String((e && (e.code || e.message)) || e) }
314
+ if (!rec.error) {
315
+ const lines = text.split('\n')
316
+ // 截断读取的第一个片段多半是半行 ⇒ 丢掉
317
+ for (const line of (rec.truncated ? lines.slice(1) : lines)) {
318
+ const t = line.trim()
319
+ if (!t) continue
320
+ rec.lines += 1
321
+ let e
322
+ try { e = JSON.parse(t) } catch { continue }
323
+ const cid = String((e && e.caseId) || '')
324
+ if (flowIds.length && !caseIdPrefixes(flowIds).some((pre) => cid.startsWith(pre))) continue
325
+ rec.matched += 1
326
+ const ts = Number(e && e.ts)
327
+ if (Number.isFinite(ts) && ts > rec.newestTsSec) { rec.newestTsSec = ts; rec.newestEvent = { ts, event: e.event, caseId: cid } }
328
+ }
329
+ if (rec.newestTsSec) {
330
+ ev.newestMs = Math.max(ev.newestMs, rec.newestTsSec * 1000)
331
+ ev.sources.push(`台账 ${ledgerPath}(最新事件 ${new Date(rec.newestTsSec * 1000).toISOString()}${rec.truncated ? ',只看尾部窗口' : ''})`)
332
+ }
333
+ }
334
+ }
335
+ ev.ledger = rec
336
+ }
337
+ return ev
338
+ }
339
+
340
+ // ══════════════════════════════════════════════════════════════════════════
341
+ // 五、HTTP:RPC 信封与"挂载/没挂载/见不着"三态
342
+ // ══════════════════════════════════════════════════════════════════════════
343
+
344
+ /** 组一个 `client-request` 信封(字段名取自宿主 `dsh-client-connection` 的 schema)。 */
345
+ export function rpcEnvelope(endpoint, args = {}, rpcId = 'argent-schedule-probe-1') {
346
+ return { type: 'client-request', rpcId, method: endpoint, payload: { args } }
347
+ }
348
+
349
+ /**
350
+ * POST 一个 RPC 端点(**只读调用方负责**;本函数不区分读写,判据在调用点)。
351
+ * @returns {Promise<{state:'ok'|'rpc-error'|'unmounted'|'unauthorized'|'unreachable'|'bad-response',
352
+ * httpStatus:number, value:any, code:string, message:string, raw:string}>}
353
+ * · **`state:'ok'` ⇔ 服务在组合里**(挂载闸的判据);
354
+ * `rpc-error` 同样是"已挂载"(宿主对参数错也回 200 + `gateway/*` 码)⇒ 由调用方按 `httpStatus` 判挂载。
355
+ */
356
+ export async function postRpc({ base, endpoint, args = {}, cookie, fetchImpl = fetch, timeoutMs = 8000, rpcId } = {}) {
357
+ const url = `${String(base).replace(/\/+$/, '')}${API_CHANNEL}/${endpoint}`
358
+ const ac = new AbortController()
359
+ const timer = setTimeout(() => ac.abort(), timeoutMs)
360
+ const headers = { 'content-type': 'application/json' }
361
+ if (cookie && cookie.name) headers.cookie = `${cookie.name}=${cookie.value}`
362
+ let res
363
+ try {
364
+ res = await fetchImpl(url, { method: 'POST', headers, body: JSON.stringify(rpcEnvelope(endpoint, args, rpcId)), signal: ac.signal, redirect: 'manual' })
365
+ } catch (e) {
366
+ return { state: 'unreachable', httpStatus: 0, value: null, code: '', message: String((e && (e.message || e.code)) || e), raw: '', url }
367
+ } finally {
368
+ clearTimeout(timer)
369
+ }
370
+ const httpStatus = Number(res && res.status) || 0
371
+ let raw = ''
372
+ try { raw = await res.text() } catch { raw = '' }
373
+ if (httpStatus === 404) return { state: 'unmounted', httpStatus, value: null, code: 'http/404', message: raw.slice(0, 200) || 'not found', raw, url }
374
+ if (httpStatus === 401 || httpStatus === 403) return { state: 'unauthorized', httpStatus, value: null, code: `http/${httpStatus}`, message: raw.slice(0, 200), raw, url }
375
+ if (httpStatus < 200 || httpStatus >= 300) return { state: 'bad-response', httpStatus, value: null, code: `http/${httpStatus}`, message: raw.slice(0, 200), raw, url }
376
+ let doc
377
+ try { doc = JSON.parse(raw) } catch { return { state: 'bad-response', httpStatus, value: null, code: 'envelope/not-json', message: raw.slice(0, 200), raw, url } }
378
+ const result = doc && doc.result
379
+ if (result && result.ok === true) return { state: 'ok', httpStatus, value: result.value, code: '', message: '', raw, url }
380
+ if (result && result.ok === false) {
381
+ const err = result.error || {}
382
+ return { state: 'rpc-error', httpStatus, value: null, code: String(err.code || 'gateway/unknown'), message: String(err.message || ''), raw, url }
383
+ }
384
+ return { state: 'bad-response', httpStatus, value: null, code: 'envelope/unexpected', message: raw.slice(0, 200), raw, url }
385
+ }
386
+
387
+ // ══════════════════════════════════════════════════════════════════════════
388
+ // 六、任务 ↔ 流程的对应("哪条任务属于哪条流程")
389
+ // ══════════════════════════════════════════════════════════════════════════
390
+
391
+ /** 归一化一个 catalog 条目(宿主 `catalog()` 的返回项)。 */
392
+ export function normalizeEntry(e) {
393
+ if (!isObj(e)) return null
394
+ return {
395
+ id: String(e.id || ''),
396
+ kind: String(e.kind || ''),
397
+ title: String(e.title || ''),
398
+ prompt: String(e.prompt || ''),
399
+ sessionId: String(e.sessionId || ''),
400
+ status: String(e.status || ''),
401
+ scheduledAt: String(e.scheduledAt || ''),
402
+ everySeconds: Number.isFinite(e.everySeconds) ? e.everySeconds : null,
403
+ time: e.time === undefined ? null : String(e.time),
404
+ timeZone: e.timeZone === undefined ? null : String(e.timeZone),
405
+ expression: e.expression === undefined ? null : String(e.expression),
406
+ weekdays: Array.isArray(e.weekdays) ? e.weekdays : null,
407
+ lastDelivery: isObj(e.lastDelivery) ? { ...e.lastDelivery } : null,
408
+ }
409
+ }
410
+
411
+ /** 选择器 → 周期(秒);**推不出就返回 null**(宁可不判,也不猜)。 */
412
+ export function periodSecondsOf(entry) {
413
+ if (!entry) return null
414
+ switch (entry.kind) {
415
+ case 'every': return Number.isFinite(entry.everySeconds) ? entry.everySeconds : null
416
+ case 'daily': return DAY_SEC
417
+ case 'weekly': return 7 * DAY_SEC
418
+ case 'after': return null // 一次性(延迟触达)
419
+ case 'at': return null // 一次性(绝对时点)
420
+ // ⛔ cron **刻意不推**:宿主有它自己的五字段解析器(含时区/夏令时语义),
421
+ // 在 L1 再写一份就是"第二份判据"(本仓库为此付过多次代价)。
422
+ // 要判 cron 的新鲜度 ⇒ 调用方显式给周期(`--period-sec`)。
423
+ case 'cron': return null
424
+ default: return null
425
+ }
426
+ }
427
+
428
+ /**
429
+ * 把 catalog 条目对到某条流程。三种判据(**按可靠性排序**):
430
+ * ① `taskTitle` 精确相等(注册表 `trigger.schedule.taskTitle` —— 声明面的唯一真源);
431
+ * ② 任务的 `prompt` 里出现该流程 id(P1-B 的静态指令会写成一条带 `--flow <id>` 的 L1 命令);
432
+ * ③ 会话 id 相等(调用方给了 `--session-id` 时的兜底)。
433
+ */
434
+ export function matchTask(entries, { flowId, alternatives = [], taskTitle, sessionId } = {}) {
435
+ const list = (entries || []).map(normalizeEntry).filter(Boolean)
436
+ const ids = [flowId, ...alternatives].filter(Boolean)
437
+ if (taskTitle) {
438
+ const hit = list.find((e) => e.title === taskTitle)
439
+ if (hit) return { entry: hit, by: 'title' }
440
+ }
441
+ const hit2 = list.find((e) => ids.some((i) => e.prompt.includes(i)))
442
+ if (hit2) return { entry: hit2, by: 'flow-id-in-prompt' }
443
+ if (sessionId) {
444
+ const hit3 = list.find((e) => e.sessionId === sessionId)
445
+ if (hit3) return { entry: hit3, by: 'session-id' }
446
+ }
447
+ return { entry: null, by: 'none', candidates: list }
448
+ }
449
+
450
+ // ══════════════════════════════════════════════════════════════════════════
451
+ // 七、四道闸(每个都返回统一形状:`{gate, state, headline, detail[], next[], evidence}`)
452
+ // ══════════════════════════════════════════════════════════════════════════
453
+ // state:`green`(绿)|`red`(红,必须有人处置)|`yellow`(黄,提醒,不改变退出码)|`unknown`(证据不足)
454
+
455
+ const GATE_ZH = { green: '✅ 绿', red: '❌ 红', yellow: '⚠️ 黄', unknown: '❓ 证据不足' }
456
+
457
+ /** ① 挂载闸:404 ⇒ 红(没挂载);200 ⇒ 绿(含 `gateway/*` 参数错 —— 那也证明服务在)。 */
458
+ export function gateMount(res, { endpoint = MOUNT_ENDPOINT } = {}) {
459
+ const at = `POST ${API_CHANNEL}/${endpoint}`
460
+ if (!res) {
461
+ return {
462
+ gate: 'mount', state: 'unknown', headline: `${at}:没有探针结果`,
463
+ detail: ['调用方没有跑探针'], next: [`跑:node scripts/argent-schedule.mjs probe`],
464
+ }
465
+ }
466
+ if (res.state === 'unmounted') {
467
+ return {
468
+ gate: 'mount', state: 'red',
469
+ headline: `${at} → HTTP 404 ⇒ **排程没挂载**`,
470
+ detail: [
471
+ '404 = 组合里没有这个端点(宿主升级**删掉那三行**时就是这个形态:0.1.x 由 dsh-web-app 自带且 disabled,0.2.x 改由可选 bundle 提供)',
472
+ `探针原文:${String(res.message || '').slice(0, 120)}`,
473
+ ],
474
+ next: [
475
+ '0.1.x:在 profile 的 cordis.patch.yml 里按 id 覆盖三行 `disabled: false`(time-context / schedule / ui-schedule)',
476
+ '0.2.x:把 dsh-experimental-schedule-bundle 加进 profile 的 dsh.profile.bundles',
477
+ '改完**需重启宿主**才生效;⛔ 本工具不重启、不改 profile(那是 P1-B 的装机步骤)',
478
+ ],
479
+ evidence: res,
480
+ }
481
+ }
482
+ if (res.state === 'unauthorized') {
483
+ return {
484
+ gate: 'mount', state: 'unknown', headline: `${at} → HTTP ${res.httpStatus}(鉴权没过,**不能**判"没挂载")`,
485
+ detail: ['"问不进去" ≠ "服务不在":两者必须分开报,否则会把人骗去删组合里的三行'],
486
+ next: ['确认宿主在跑并且唤醒鉴权可用:`node scripts/lib/wake-auth.mjs --log-paths`', '或显式给凭据文件/日志落点(ARGENT_WAKE_TOKEN_FILE / ARGENT_WAKE_LOG_PATHS)'],
487
+ evidence: res,
488
+ }
489
+ }
490
+ if (res.state === 'unreachable' || res.state === 'bad-response') {
491
+ return {
492
+ gate: 'mount', state: 'unknown', headline: `${at} → ${res.state === 'unreachable' ? '连不上宿主' : `响应不是 RPC 信封(HTTP ${res.httpStatus})`}`,
493
+ detail: [String(res.message || '').slice(0, 200)], next: ['确认 dsh web 在跑且 --base 指向它(缺省 http://127.0.0.1:3080)'],
494
+ evidence: res,
495
+ }
496
+ }
497
+ return {
498
+ gate: 'mount', state: 'green',
499
+ headline: `${at} → HTTP ${res.httpStatus} ⇒ **排程已挂载**`,
500
+ detail: [res.state === 'ok' ? `catalog 正常返回(${Array.isArray(res.value) ? res.value.length : '?'} 条任务)` : `服务在组合里(RPC 回了 ${res.code} —— 参数错也是 200,同样证明挂载)`],
501
+ next: [], evidence: res,
502
+ }
503
+ }
504
+
505
+ /**
506
+ * ② 新鲜度闸:`now - lastDelivery.deliveredAt > 1.5 × 周期` ⇒ 红。
507
+ * 无回执:任务 `active` 且**创建时间 + 1.5 周期**已过仍没投递过 ⇒ 红;太新 ⇒ 证据不足(还没到判的时候)。
508
+ */
509
+ export function gateFreshness({ entry, nowMs, periodSec, factor = FRESHNESS_FACTOR, label = '' } = {}) {
510
+ const at = label ? `${label}:` : ''
511
+ if (!entry) {
512
+ return { gate: 'freshness', state: 'unknown', headline: `${at}没有对应的宿主任务 ⇒ 无法判新鲜度`, detail: [], next: ['先建任务(P1-B)或确认 taskTitle 与注册表一致'] }
513
+ }
514
+ const period = Number.isFinite(periodSec) ? periodSec : periodSecondsOf(entry)
515
+ if (!Number.isFinite(period) || period <= 0) {
516
+ return {
517
+ gate: 'freshness', state: 'unknown',
518
+ headline: `${at}周期推不出来(kind=${entry.kind})⇒ 证据不足`,
519
+ detail: [`任务 ${entry.id}(${entry.kind})没有可直接使用的周期;cron 的周期由宿主解析(含时区/夏令时),L1 不另写一份`],
520
+ next: ['显式给周期重跑:`--period-sec <秒>`(daily=86400、weekly=604800)'],
521
+ evidence: { entry, periodSec: null },
522
+ }
523
+ }
524
+ const limitMs = period * factor * 1000
525
+ const ld = entry.lastDelivery
526
+ // ⚠️ **先判 status**:`inactive` 的重复型任务永远不会再触发(宿主只在"算不出下一次发生"时才置它)
527
+ // ⇒ 哪怕它有很新的回执,也已经是死的(回执是它生前留下的)。
528
+ if (entry.status !== 'active') {
529
+ return {
530
+ gate: 'freshness', state: 'red',
531
+ headline: `${at}任务 status=${entry.status}(**不是 active**)⇒ 它永远不会再触发`,
532
+ detail: [`任务 ${entry.id}`, `上次投递 ${ld ? ld.deliveredAt : '(从未)'}(那之后的回执不再推进)`],
533
+ next: ['任务已"死"(重复型任务唯一的 inactive 来源是算不出下一次发生)⇒ 需要重建;见 `pause/apply` 与审计'],
534
+ evidence: { entry, periodSec: period },
535
+ }
536
+ }
537
+ if (!ld || !Number.isFinite(Date.parse(ld.deliveredAt))) {
538
+ const created = Date.parse(entry.scheduledAt)
539
+ const ageMs = Number.isFinite(created) ? (nowMs - created) : null
540
+ if (ageMs !== null && ageMs > limitMs) {
541
+ return {
542
+ gate: 'freshness', state: 'red',
543
+ headline: `${at}建了 ${Math.round(ageMs / 1000)}s(> ${factor}×周期 ${Math.round(limitMs / 1000)}s)却**一次都没投递过**`,
544
+ detail: [`任务 ${entry.id}(kind=${entry.kind},status=${entry.status},scheduledAt=${entry.scheduledAt})`, '宿主投递失败**只写它自己的日志、不重试**,也不会写我们的台账 ⇒ 不会有人来告诉你'],
545
+ next: ['看宿主进程日志里有没有投递准入失败', '`node scripts/argent-schedule.mjs status` 看任务是否 active、下一跳是否在未来'],
546
+ evidence: { entry, periodSec: period, ageMs },
547
+ }
548
+ }
549
+ return {
550
+ gate: 'freshness', state: 'unknown',
551
+ headline: `${at}还没有第一次投递(任务太新:建了 ${ageMs === null ? '?' : Math.round(ageMs / 1000)}s ≤ ${factor}×周期)`,
552
+ detail: ['还没到能判"它是不是死了"的时候'], next: ['过一个周期再跑一次'],
553
+ evidence: { entry, periodSec: period, ageMs },
554
+ }
555
+ }
556
+ const deliveredMs = Date.parse(ld.deliveredAt)
557
+ const ageMs = nowMs - deliveredMs
558
+ if (ageMs > limitMs) {
559
+ return {
560
+ gate: 'freshness', state: 'red',
561
+ headline: `${at}距上次投递 ${Math.round(ageMs / 1000)}s > ${factor}×周期(${Math.round(limitMs / 1000)}s)⇒ 定时已经不正常`,
562
+ detail: [`任务 ${entry.id}:lastDelivery.deliveredAt=${ld.deliveredAt}`],
563
+ next: ['宿主在不在跑?(`launchctl list | grep dsh-web` / Windows 看计划任务 WhyshuDshWeb)', '任务是不是被删/被停?`node scripts/argent-schedule.mjs status`'],
564
+ evidence: { entry, periodSec: period, ageMs, limitMs },
565
+ }
566
+ }
567
+ return {
568
+ gate: 'freshness', state: 'green',
569
+ headline: `${at}上次投递在 ${Math.round(ageMs / 1000)}s 前(≤ ${factor}×周期 ${Math.round(limitMs / 1000)}s)`,
570
+ detail: [`任务 ${entry.id}(kind=${entry.kind},status=${entry.status})`], next: [],
571
+ evidence: { entry, periodSec: period, ageMs },
572
+ }
573
+ }
574
+
575
+ /**
576
+ * ③ 执行闸(**本批的灵魂**):回执推进 **∧** L1 无新证据 ⇒ 红。
577
+ * 为什么不能只看回执:宿主 README 原话 —— 回执"确认收件箱投递,**不表示模型执行**"。
578
+ */
579
+ export function gateExecution({ entry, evidence, nowMs, graceSec = DEFAULT_GRACE_SEC, label = '' } = {}) {
580
+ const at = label ? `${label}:` : ''
581
+ const ld = entry && entry.lastDelivery
582
+ if (!entry || !ld || !Number.isFinite(Date.parse(ld.deliveredAt))) {
583
+ return { gate: 'execution', state: 'unknown', headline: `${at}没有投递回执 ⇒ 无从判"投递之后有没有人干活"`, detail: [], next: ['先有回执(见新鲜度闸)'] }
584
+ }
585
+ if (!evidence || !evidence.newestMs) {
586
+ return {
587
+ gate: 'execution', state: 'unknown',
588
+ headline: `${at}拿不到 L1 证据(轮次日志/台账都读不到)⇒ **不能**下"没人干活"的结论`,
589
+ detail: ['证据不足与"没干活"是两件事:混成一句就会把人骗去查一个没坏的东西'],
590
+ next: ['确认 L1 证据面存在:轮次日志 `<数据目录>/<trigger.log>`、台账 `<数据目录>/flow-ledger.jsonl`'],
591
+ evidence: { entry, evidence },
592
+ }
593
+ }
594
+ const deliveredMs = Date.parse(ld.deliveredAt)
595
+ const l1Ms = evidence.newestMs
596
+ const graceMs = Math.max(0, graceSec) * 1000
597
+ // 证据强度:台账里有**属于该流程**的新事件 = 强(认领/派发/失败都算"活干了");
598
+ // 只有轮次日志 mtime 动了 = 弱("本轮无票可认领"是合法绿,但"收到指令却没跑 L1"也会让它动)。
599
+ const ledgerNew = Boolean(evidence.ledger && evidence.ledger.newestTsSec && evidence.ledger.newestTsSec * 1000 >= deliveredMs - graceMs)
600
+ const strength = ledgerNew ? 'strong' : 'weak'
601
+ if (l1Ms >= deliveredMs - graceMs) {
602
+ return {
603
+ gate: 'execution', state: 'green',
604
+ headline: `${at}回执之后 L1 有新证据(相差 ${Math.round((l1Ms - deliveredMs) / 1000)}s,宽限 ${graceSec}s;${strength === 'strong' ? '强证据=台账' : '**弱证据=只有轮次日志 mtime**'})`,
605
+ detail: [
606
+ `投递 ${ld.deliveredAt}`,
607
+ `L1 最新:${new Date(l1Ms).toISOString()}(${(evidence.sources || []).join(';') || '—'})`,
608
+ strength === 'strong'
609
+ ? '台账里有该流程的新事件(caseId 前缀 <flow>__)⇒ "投递之后确实有人干活"'
610
+ : '⚠️ 弱证据:只有轮次日志被追加过。⛔ 别把"收到指令但没跑 L1"这类结论写进这个日志 —— 那会让本闸**假绿**',
611
+ ],
612
+ next: [], evidence: { entry, evidence, graceSec, strength },
613
+ }
614
+ }
615
+ return {
616
+ gate: 'execution', state: 'red',
617
+ headline: `${at}回执推进了,但 L1 **没有任何新证据**(差 ${Math.round((deliveredMs - l1Ms) / 1000)}s > 宽限 ${graceSec}s)`,
618
+ detail: [
619
+ `投递:${ld.deliveredAt}(messageId=${ld.messageId})`,
620
+ `L1 最新:${new Date(l1Ms).toISOString()}(${(evidence.sources || []).join(';') || '—'})`,
621
+ '这正是排程迁移的**头号语义退化**:宿主只保证"消息进了会话收件箱",排程 prompt 又是**静态**的'
622
+ + '(创建时固定)⇒ 认领只能靠角色自己去调 L1 ⇒ 模型不照做时,回执照样推进',
623
+ `判据:投递时刻(减 ${graceSec}s 宽限)之后,轮次日志 mtime 或台账必须出现**属于该流程**的新事件(caseId 前缀 <flow>__)`,
624
+ ],
625
+ next: [
626
+ `看那一轮到底发生了什么:tail -50 <数据目录>/<trigger.log>`,
627
+ `看台账有没有该流程的新事件:python3 scripts/flow_ledger.py --status --flow <id>`,
628
+ '确认静态 prompt 里那条 L1 命令**存在且可执行**(P1-A 明确没有 `--no-wake` ⇒ 现在还不许建任务)',
629
+ ],
630
+ evidence: { entry, evidence, graceSec, strength: 'none', deliveredMs, l1Ms },
631
+ }
632
+ }
633
+
634
+ /**
635
+ * ④ 持久化闸:域文件可解析 ∧ 关键不变量成立(坏 ⇒ **宿主拒绝启动**,比"定时不跑"严重)。
636
+ * @param {object} inspection `inspectDomain()` 的结果
637
+ * @param {{backupRoots?:string[], nowMs?:number}} [opts] 备份覆盖面检查的候选根
638
+ */
639
+ export function gatePersistence(inspection, { backupRoots = [] } = {}) {
640
+ if (!inspection) return { gate: 'persistence', state: 'unknown', headline: '没有域文件检查结果', detail: [], next: [] }
641
+ const files = inspection.files || []
642
+ if (inspection.state === 'corrupt') {
643
+ return {
644
+ gate: 'persistence', state: 'red',
645
+ headline: `排程域有 ${inspection.problems.length} 处非法 ⇒ **宿主会拒绝启动整个 domain**(连对话一起)`,
646
+ detail: inspection.problems.map((p) => `${p.path}:${p.issue}`),
647
+ next: [
648
+ `先备份现场:cp -a "${inspection.paths.unitDir}" "${inspection.paths.legacyFile}" <备份目录>/ 2>/dev/null`,
649
+ '再按上面点名的文件修(逐记录文件可直接删:宿主把"读不出/版本不符"的记录**当作不存在**丢弃)',
650
+ '⛔ 修之前不要重启宿主(正在跑的进程还用着内存里的旧状态)',
651
+ ],
652
+ evidence: { files: files.map((f) => f.path), problems: inspection.problems },
653
+ }
654
+ }
655
+ if (inspection.state === 'unreadable') {
656
+ return {
657
+ gate: 'persistence', state: 'unknown',
658
+ headline: '排程域文件**读不了**(权限/IO)⇒ 不能判好坏',
659
+ detail: inspection.problems.map((p) => `${p.path}:${p.issue}`), next: ['用能读该文件的身份重跑;或 `ls -la` 看权限'],
660
+ evidence: { files: files.map((f) => f.path) },
661
+ }
662
+ }
663
+ const b = backupCoverage({ files: files.map((f) => f.path), roots: backupRoots })
664
+ const exists = inspection.state === 'ok'
665
+ const detail = exists
666
+ ? [`逐记录 ${files.filter((f) => f.kind === 'record').length} 个、旧整体文件 ${files.filter((f) => f.kind === 'legacy').length} 个,全部可解析且不变量成立("宿主起得来 ⇒ 这个文件是好的"的反向判据也由此成立)`]
667
+ : ['域文件尚未落盘(排程从未启用过)—— **不是故障**;但一旦建了任务,这个文件坏了会拖垮整个宿主']
668
+ const next = []
669
+ if (b.state !== 'covered') {
670
+ detail.push(b.detail)
671
+ next.push(...b.next)
672
+ }
673
+ return {
674
+ gate: 'persistence', state: b.state === 'covered' ? 'green' : (exists ? 'yellow' : 'green'),
675
+ headline: exists ? '排程域文件可解析、关键不变量成立' : '排程域尚未落盘(还未启用)',
676
+ detail, next,
677
+ evidence: { state: inspection.state, files: files.map((f) => f.path), backup: b },
678
+ }
679
+ }
680
+
681
+ /**
682
+ * ⑤ 单一调度者闸(0.6.0 W8):同一条流程被**两条线同时**调度 ⇒ **红**。
683
+ *
684
+ * ⭐ 判据**不在这里**:唯一实现 = `scripts/lib/schedule-mechanism.mjs#resolveScheduleMechanism()`
685
+ * (声明面 = 注册表 `schedule.mechanism`;证据 = 原生包 / 已装计划任务文件)。
686
+ * 本函数只把它的结论渲染成闸的形状(与其它四道闸同一 API)。
687
+ *
688
+ * 三态:
689
+ * · `conflict`(两条线同时在)⇒ **红**,并**逐字给出怎么关掉一条**(⛔ 不替用户选、⛔ 不静默择一);
690
+ * · `native` / `activation-shim` / `none` ⇒ 绿(附现状说明);
691
+ * · `unknown`(探不到 ⇒ 缺证据)⇒ **黄**(不是红:`unknown` 只说明判不了,不说明出事了;
692
+ * 但它**必须可见** —— 否则"以为在跑、其实没人调度"就是静默失败)。
693
+ *
694
+ * @param {{state:string, declared?:string, reason?:string, next?:string[],
695
+ * native?:object|null, shim?:object|null, conflict?:object|null}} res
696
+ * `= detectScheduleMechanism()` / `resolveScheduleMechanism()` 的返回值
697
+ */
698
+ export function gateSchedulerConflict(res, { label = '' } = {}) {
699
+ const at = label ? `${label}:` : ''
700
+ if (!res || !res.state) {
701
+ return {
702
+ gate: 'scheduler-conflict', state: 'unknown',
703
+ headline: `${at}没有定时机制判据结果 ⇒ 无法判"是不是两个调度者"`,
704
+ detail: ['调用方没有跑 scripts/lib/schedule-mechanism.mjs 的判据'],
705
+ next: ['跑:node scripts/argent-schedule.mjs mechanism'],
706
+ }
707
+ }
708
+ const base = {
709
+ gate: 'scheduler-conflict',
710
+ declared: String(res.declared || ''),
711
+ mechanism: String(res.state),
712
+ nativeReason: res.native ? String(res.native.reason || '') : '',
713
+ shimReason: res.shim ? String(res.shim.reason || '') : '',
714
+ flows: (res.conflict && Array.isArray(res.conflict.flows)) ? res.conflict.flows : [],
715
+ }
716
+ if (res.state === 'conflict') {
717
+ const c = res.conflict || {}
718
+ return {
719
+ ...base, state: 'red',
720
+ headline: `${at}❌ **两个调度者同时在**(原生任务 × 激活垫片)—— 单一调度者纪律被破坏`,
721
+ detail: [
722
+ String(c.detail || res.reason || ''),
723
+ `原生:${base.nativeReason || '(在场)'}`,
724
+ `垫片:${base.shimReason || '(在场)'}`,
725
+ ],
726
+ next: [
727
+ ...(Array.isArray(c.next) ? c.next : []),
728
+ ...(Array.isArray(res.next) ? res.next : []),
729
+ ],
730
+ closeOne: String(c.closeOne || ''),
731
+ evidence: { native: res.native || null, shim: res.shim || null, conflict: c },
732
+ }
733
+ }
734
+ if (res.state === 'unknown') {
735
+ return {
736
+ ...base, state: 'yellow',
737
+ headline: `${at}⚠️ 判不了"谁是调度者"(证据不足)——⛔ 别顺手装一份 cron(那会造出第二个调度者)`,
738
+ detail: [String(res.reason || '')],
739
+ next: Array.isArray(res.next) ? res.next : [],
740
+ evidence: { native: res.native || null, shim: res.shim || null },
741
+ }
742
+ }
743
+ const zh = { native: '原生(宿主排程)', 'activation-shim': '激活垫片(flow-timers.sh)', none: '无(这台机器不该有定时器)' }
744
+ return {
745
+ ...base, state: 'green',
746
+ headline: `${at}✅ 单一调度者:${zh[res.state] || res.state}(声明 ${res.declared})`,
747
+ detail: [String(res.reason || '')],
748
+ next: [],
749
+ evidence: { native: res.native || null, shim: res.shim || null },
750
+ }
751
+ }
752
+
753
+ /**
754
+ * 备份覆盖面(**报告式**判据,不是恒红陷阱):
755
+ * 判据 = 给定的备份根下**能不能找到**这份域文件的副本(按文件名/子路径匹配)。
756
+ * ⛔ 不在这里跑任何备份命令(本模块只读);找不到就给出一条**可复制**的备份命令。
757
+ */
758
+ export function backupCoverage({ files = [], roots = [] } = {}) {
759
+ const wanted = files.filter(Boolean)
760
+ if (!wanted.length) {
761
+ return { state: 'none-yet', detail: '还没有域文件 ⇒ 暂无可备份之物', next: [], hits: [] }
762
+ }
763
+ const hits = []
764
+ for (const r of roots.filter(Boolean)) {
765
+ if (!existsSync(r)) continue
766
+ for (const w of wanted) {
767
+ const name = w.split('/').pop()
768
+ const rel = w.split('/').slice(-3).join('/')
769
+ if (findUnder(r, [name, rel], 3)) hits.push({ root: r, file: w })
770
+ }
771
+ }
772
+ if (hits.length) return { state: 'covered', detail: `备份覆盖面:在 ${[...new Set(hits.map((h) => h.root))].join('、')} 下找到域文件副本`, next: [], hits }
773
+ return {
774
+ state: 'uncovered',
775
+ detail: `备份覆盖面:**没有**任何备份包含排程域(候选根:${roots.filter(Boolean).join('、') || '(未给)'})`
776
+ + ' —— 仓库现有的备份面(`dsh-backup-scope.sh` 只备 node scope)**不含**宿主存储目录',
777
+ next: [
778
+ '把宿主存储目录纳入备份面(一条可复制的命令):',
779
+ ` mkdir -p "$HOME/.dsh/argent/backup/schedule-$(date +%Y%m%d-%H%M%S)" && cp -a "$HOME/.dsh/storages/schedule" "$HOME/.dsh/storages/schedule.json" "$HOME/.dsh/argent/backup/schedule-$(date +%Y%m%d-%H%M%S)/" 2>/dev/null; ls -la "$HOME/.dsh/argent/backup" | tail -3`,
780
+ '理由:这个文件坏了 = **宿主拒绝启动**(连对话一起),比"定时不跑"严重',
781
+ ],
782
+ hits: [],
783
+ }
784
+ }
785
+
786
+ /** 在 `dir` 下浅层找一个名字(深度受限;找不到返回 false)。 */
787
+ function findUnder(dir, needles, depth) {
788
+ if (depth < 0) return false
789
+ let entries = []
790
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return false }
791
+ for (const e of entries) {
792
+ if (needles.includes(e.name)) return true
793
+ if (e.isDirectory() && !e.name.startsWith('.')) {
794
+ if (findUnder(join(dir, e.name), needles, depth - 1)) return true
795
+ }
796
+ }
797
+ return false
798
+ }
799
+
800
+ // ══════════════════════════════════════════════════════════════════════════
801
+ // 八、汇总(三态退出码:0 全绿 / 1 有红 / 2 证据不足)
802
+ // ══════════════════════════════════════════════════════════════════════════
803
+
804
+ /** 汇总多道闸 → `{exitCode, red, yellow, unknown, green, lines[]}`。 */
805
+ export function summarize(gates) {
806
+ const list = (gates || []).filter(Boolean)
807
+ const by = (s) => list.filter((g) => g.state === s)
808
+ const red = by('red'); const yellow = by('yellow'); const unknown = by('unknown'); const green = by('green')
809
+ const exitCode = red.length ? 1 : (unknown.length ? 2 : 0)
810
+ const lines = list.map((g) => `${GATE_ZH[g.state] || g.state} [${g.gate}] ${g.headline}`)
811
+ return { gates: list, exitCode, red, yellow, unknown, green, lines }
812
+ }
813
+
814
+ /** 人读的一行结论(CLI 与门禁共用同一句,避免两处口径)。 */
815
+ export function verdictLine(sum) {
816
+ if (sum.exitCode === 1) return `❌ 排程面判红:${sum.red.length} 道闸红了(${sum.red.map((g) => g.gate).join('、')})`
817
+ if (sum.exitCode === 2) return `❓ 排程面证据不足:${sum.unknown.length} 道闸无法判定(${sum.unknown.map((g) => g.gate).join('、')})`
818
+ return sum.yellow.length
819
+ ? `✅ 排程面无红(但有 ${sum.yellow.length} 项黄色提醒:${sum.yellow.map((g) => g.gate).join('、')})`
820
+ : '✅ 排程面全绿'
821
+ }
822
+
823
+ /** 结论 + 每道闸的下一步(人可读;CLI 只负责排版)。 */
824
+ export function formatGates(sum, { prefix = ' ' } = {}) {
825
+ const out = []
826
+ for (const g of sum.gates) {
827
+ out.push(`${prefix}${GATE_ZH[g.state] || g.state} [${g.gate}] ${g.headline}`)
828
+ for (const d of g.detail || []) out.push(`${prefix} · ${d}`)
829
+ for (const n of g.next || []) out.push(`${prefix} ↳ 下一步:${n}`)
830
+ }
831
+ return out.join('\n')
832
+ }
833
+
834
+ /** 绝对路径归一(CLI 传进来的相对路径按 cwd 解析)。 */
835
+ export const absOf = (p) => (p ? (isAbsolute(p) ? p : join(process.cwd(), p)) : p)