@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,101 @@
1
+ /**
2
+ * 通知**幂等**与审计(判断⑩;J10 的"通知不重发"落点)—— P2,2026-10-03。
3
+ *
4
+ * ## 判据从哪来(不是本文件新立的)
5
+ * | 事实 | 唯一权威 |
6
+ * |---|---|
7
+ * | 通知幂等 | `core/contracts/argent.contracts.json#judgments.J10-sideeffect-boundary`("同一事实不重发") |
8
+ * | 存储位置的链 | `core/lib/argent-datadir.mjs#resolveDataDir`(**唯一判据**;本文件不复制那条链) |
9
+ * | 语义先例 | `scripts/daily-report.mjs` 的 `.pushed-<date>-<storeKey>` 标记("推送过就不再推") |
10
+ *
11
+ * ## 判据(可判)
12
+ * · **键**:显式 `--idempotency-key` 优先;未给则用**内容摘要**(`channel|target|kind|summary|text`
13
+ * 的 sha256 前 16 位)当键 ⇒ "同一事实"天然同键。两个来源都在回显里如实标注
14
+ * (`idempotencyKeySource: explicit|derived`)—— 客户不会莫名其妙收到"被跳过"。
15
+ * · **标记落盘**:`$ARGENT_DATA_DIR/notify-outbox/.sent-<key16>.json`(0600),内容 = 那次投递的
16
+ * `{key, at, channel, target, kind, summary, status, bytes}` —— 既是去重证据,也是投递回执。
17
+ * · **审计**:`$ARGENT_DATA_DIR/notify-outbox/audit.jsonl`(每**真发**一次一行;dry-run 一行都不落)。
18
+ * · **只有投递成功才落标记** ⇒ 失败可以原样重试(不会因为"试过一次"而被永久跳过)。
19
+ * · **判据 = 跑两次 dry-run 后数据目录逐字节不变**(A3 的 `judge`;由
20
+ * `scripts/tests/notify-verb.test.mjs` 断言)。
21
+ *
22
+ * ⛔ 本文件不判断"这条通知该不该发"(那是业务判断,归 L1 的调用方);
23
+ * 它只回答"这一份**已经决定要发**的意图,是否已经发过"。
24
+ */
25
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
26
+ import { createHash } from 'node:crypto'
27
+ import { join } from 'node:path'
28
+
29
+ /** 数据目录下的通知收件箱(标记 + 审计同处一处,便于"这个目录能删"一句话解释)。 */
30
+ export const OUTBOX_DIRNAME = 'notify-outbox'
31
+ /** 投递审计文件名(与 `invoice-writeback/audit.jsonl`、`license-gate.jsonl` 同为审计 JSONL)。 */
32
+ export const AUDIT_NAME = 'audit.jsonl'
33
+
34
+ /** 数据目录内的收件箱路径。 */
35
+ export const outboxDir = (dataDir) => join(String(dataDir), OUTBOX_DIRNAME)
36
+
37
+ /** 投递审计文件路径。 */
38
+ export const auditPath = (dataDir) => join(outboxDir(dataDir), AUDIT_NAME)
39
+
40
+ /** 内容 → 幂等键(未显式给键时的**确定性**来源:同样的事实 ⇒ 同样的键)。 */
41
+ export function deriveIdempotencyKey(intent = {}) {
42
+ const parts = [intent.channel, intent.target, intent.kind, intent.summary, intent.text].map((v) => String(v == null ? '' : v))
43
+ return createHash('sha256').update(parts.join('\u0000')).digest('hex').slice(0, 16)
44
+ }
45
+
46
+ /**
47
+ * 解析幂等键。
48
+ * @returns {{key:string, source:'explicit'|'derived'}}
49
+ */
50
+ export function resolveIdempotencyKey(intent = {}, explicit = null) {
51
+ const raw = explicit == null ? '' : String(explicit).trim()
52
+ if (raw) return { key: raw, source: 'explicit' }
53
+ return { key: deriveIdempotencyKey(intent), source: 'derived' }
54
+ }
55
+
56
+ /** 去重标记路径(键取 sha256 前 16 位 ⇒ 文件名恒定长度、不含用户输入)。 */
57
+ export function dedupMarkerPath(dataDir, key) {
58
+ const h = createHash('sha256').update(String(key)).digest('hex').slice(0, 16)
59
+ return join(outboxDir(dataDir), `.sent-${h}.json`)
60
+ }
61
+
62
+ /** 读标记(不存在/坏文件 ⇒ null,**不抛**:读不到就是"没发过",这是安全方向)。 */
63
+ export function readDedupMarker(dataDir, key) {
64
+ const p = dedupMarkerPath(dataDir, key)
65
+ if (!existsSync(p)) return null
66
+ try { return JSON.parse(readFileSync(p, 'utf8')) } catch { return { raw: true, path: p } }
67
+ }
68
+
69
+ /**
70
+ * 落标记(**只有投递成功才调用**)。目录不可写 ⇒ 抛出,由调用方转成 `internal_error`(fail-closed)。
71
+ */
72
+ export function writeDedupMarker(dataDir, key, record = {}) {
73
+ const dir = outboxDir(dataDir)
74
+ mkdirSync(dir, { recursive: true })
75
+ const p = dedupMarkerPath(dataDir, key)
76
+ writeFileSync(p, `${JSON.stringify({ ...record, key, at: new Date().toISOString() })}\n`, { mode: 0o600 })
77
+ return p
78
+ }
79
+
80
+ /** 追一行审计(每次**真发**一行;dry-run 不调用它)。 */
81
+ export function appendNotifyAudit(dataDir, record = {}) {
82
+ const dir = outboxDir(dataDir)
83
+ mkdirSync(dir, { recursive: true })
84
+ const line = JSON.stringify({ at: new Date().toISOString(), ...record })
85
+ appendFileSync(auditPath(dataDir), `${line}\n`, { mode: 0o600 })
86
+ return auditPath(dataDir)
87
+ }
88
+
89
+ /** 只读快照(dry-run 用:回答"这一份**若真发**会不会被跳过",且不落任何文件)。 */
90
+ export function inspectDedup(dataDir, key) {
91
+ const marker = readDedupMarker(dataDir, key)
92
+ return {
93
+ applied: true,
94
+ key,
95
+ marker: dedupMarkerPath(dataDir, key),
96
+ exists: marker !== null,
97
+ sentAt: marker && marker.at ? marker.at : null,
98
+ status: marker && marker.status != null ? marker.status : null,
99
+ decision: marker !== null ? 'skip' : 'send',
100
+ }
101
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `feishu` 通道 —— **预留位**(P2 第二批;本批不实现)。
3
+ *
4
+ * 为什么现在只放一个空壳:端口契约(`core/lib/notify/index.mjs` 头部)必须先定下来,
5
+ * 后来者才知道"新增一条通道要导出什么"。**选了但没实现 ⇒ 明确报缺声明(exit 2)**,
6
+ * 绝不放行成"看起来发了"(那正是 §2.2④ 那类恒 `ok:true` 的假绿)。
7
+ *
8
+ * 落地时的判据(先写在这里,免得实现时又自创一套):
9
+ * · 投递实现**照抄** `scripts/feishu-card.mjs`(它已默认 dry-run、`--send` 才发)——
10
+ * ⛔ 不要把它的 `--send` 语义搬进通道:dry-run 由 verb 层决定"根本不调 send"(一处判据)。
11
+ * · 目标 = 群 webhook 或 `chat_id`(身份/profile 走既有 `LARK_PROFILE` 链,不新开一套凭据)。
12
+ * · 错误映射:未登录/缺权限 ⇒ `needs_human`(要人授权);网络/半开连接 ⇒ `retryable`
13
+ * (唯一权威 = `scripts/flow-requirements.json#failureClasses`;本文件只做**翻译**)。
14
+ */
15
+ export const CHANNEL = 'feishu'
16
+ export const IMPLEMENTED = false
17
+ export const NOTE = '飞书卡片投递(P2 第二批);本批只落端口位,实现在 scripts/feishu-card.mjs 之上收口'
18
+ export const TARGET = {
19
+ required: true,
20
+ label: '--target',
21
+ schemes: ['http:', 'https:'],
22
+ note: '群 webhook / 群 chat_id(待实现)',
23
+ }
24
+
25
+ export function validateTarget(target) {
26
+ const raw = String(target == null ? '' : target).trim()
27
+ if (!raw) return { ok: false, reason: 'target_required', error: 'feishu 通道缺少目标(群 webhook 或 chat_id)' }
28
+ return { ok: true, target: raw }
29
+ }
30
+
31
+ /** 预留位:**永不**返回成功(否则调用方会以为真发了)。 */
32
+ export function send() {
33
+ return {
34
+ ok: false,
35
+ reason: 'channel_not_implemented',
36
+ error: 'feishu 通道尚未实现(预留位)⇒ 本批请用 --channel webhook;实现落点见本文件头注释',
37
+ }
38
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * 通知**端口**(P2,2026-10-03)—— 通道注册表 + 投递意图的路由。
3
+ *
4
+ * ## 为什么有它(缺口②)
5
+ * 实施方案 §2.2②:`notify` 通道今天**一个都没有命令入口**;对外发声散在角色提示词与
6
+ * `scripts/daily-report.mjs` 里。本目录把"投递"变成**可替换件**:每个通道一个文件、
7
+ * 导出同一个契约,新增通道 = 新增一个文件(照 `scripts/flow_sources/` 的端口形态)。
8
+ *
9
+ * ## 分工(判据不许下沉,§3.5②/P2①)
10
+ * | 层 | 做什么 | 在哪 |
11
+ * |---|---|---|
12
+ * | **判断(L1)** | 通道选择、**幂等键与去重**、dry-run、审计 —— 产出"投递意图" | `core/lib/notify/dedup.mjs`、`core/lib/notify/verb.mjs` |
13
+ * | **投递(端口实现)** | 把已决定要发的意图送到目标 | 本文件登记的通道模块(`webhook.mjs` / `feishu.mjs` / `wecom.mjs`) |
14
+ *
15
+ * ⛔ **通道实现里不得出现"该不该发"**:不读业务表、不看开关、不做重试策略、不自己判 dry-run
16
+ * (dry-run 由 verb 层决定"根本不调 send")。通道只回答一件事:**这一份意图,送到了没有**。
17
+ *
18
+ * ## 通道契约(新增实现照抄,别自创)
19
+ * ```js
20
+ * export const CHANNEL = 'webhook' // 通道 id(与 --channel 的取值逐字一致)
21
+ * export const IMPLEMENTED = true // false = 预留位(选了 ⇒ usage/缺声明,不是静默成功)
22
+ * export const TARGET = { // 目标形态(verb 层据此做**用法**校验,不做业务判断)
23
+ * required: true, label: '--url', schemes: ['http:', 'https:'],
24
+ * note: '通用 HTTP POST',
25
+ * }
26
+ * export function validateTarget(target) // → {ok:true, target} | {ok:false, reason, error}
27
+ * export function send(intent, opts) // → {ok:true, status, ...} | {ok:false, reason, error}
28
+ * ```
29
+ *
30
+ * ## 层归属说明(免得被物理路径骗,§P2① 括号那段的同一口径)
31
+ * 通道实现物理落在 `core/lib/` 只表示它是**宿主无关的投递实现**(换宿主不用改内核),
32
+ * 不表示"投递进了判据层"。实施方案 §P2① 建议落在 `scripts/notify/`;本批落在 `core/lib/notify/`
33
+ * 的理由:`core/lib/*.mjs` 是 CLI/MCP **共用**的内核模块(MCP 侧要复用同一个端口),
34
+ * 而 `scripts/notify/` 需要额外的分发/同步面。**判据与投递的分工不因路径改变**(上表仍是判据)。
35
+ */
36
+ import * as webhook from './webhook.mjs'
37
+ import * as feishu from './feishu.mjs'
38
+ import * as wecom from './wecom.mjs'
39
+
40
+ /** 通道模块表(id → 模块)。**新增通道只改这一行**(不改分发逻辑)。 */
41
+ export const CHANNEL_MODULES = { webhook, feishu, wecom }
42
+
43
+ /** 已登记的通道 id(顺序即 --help 的展示顺序)。 */
44
+ export const CHANNEL_IDS = Object.keys(CHANNEL_MODULES)
45
+
46
+ /** 通道清单(只读;给 `--help`/画像/宿主读,不复制任何判据)。 */
47
+ export function listChannels() {
48
+ return CHANNEL_IDS.map((id) => {
49
+ const m = CHANNEL_MODULES[id]
50
+ return {
51
+ channel: id,
52
+ implemented: m.IMPLEMENTED === true,
53
+ targetRequired: Boolean(m.TARGET && m.TARGET.required),
54
+ targetLabel: (m.TARGET && m.TARGET.label) || null,
55
+ schemes: (m.TARGET && m.TARGET.schemes) || [],
56
+ note: (m.TARGET && m.TARGET.note) || '',
57
+ }
58
+ })
59
+ }
60
+
61
+ /**
62
+ * 解析通道。
63
+ * @returns {{ok:true, module:object} | {ok:false, reason:'unknown_channel'|'channel_not_implemented', error:string}}
64
+ */
65
+ export function resolveChannel(id) {
66
+ const key = String(id || '')
67
+ const m = CHANNEL_MODULES[key]
68
+ if (!m) {
69
+ return { ok: false, reason: 'unknown_channel', error: `未知通道:${key || '(缺)'}(已登记:${CHANNEL_IDS.join('、')})` }
70
+ }
71
+ if (m.IMPLEMENTED !== true) {
72
+ return {
73
+ ok: false,
74
+ reason: 'channel_not_implemented',
75
+ error: `通道 ${key} 是**预留位**:端口契约已定,投递实现未落地`
76
+ + `${(m.NOTE && `(${m.NOTE})`) || ''} ⇒ 本批只有 webhook 可用`,
77
+ }
78
+ }
79
+ return { ok: true, module: m }
80
+ }
81
+
82
+ /**
83
+ * 投递一份**已经决定要发**的意图(dry-run 不该走到这里)。
84
+ * @param {object} intent `{channel, target, kind, summary, text, idempotencyKey}`
85
+ * @param {{timeoutMs?:number, deps?:object}} [opts]
86
+ */
87
+ export async function deliver(intent, opts = {}) {
88
+ const r = resolveChannel(intent && intent.channel)
89
+ if (!r.ok) return { ok: false, reason: r.reason, error: r.error }
90
+ const m = r.module
91
+ const v = typeof m.validateTarget === 'function' ? m.validateTarget(intent.target) : { ok: true, target: intent.target }
92
+ if (!v.ok) return { ok: false, reason: v.reason, error: v.error }
93
+ const out = await m.send({ ...intent, target: v.target }, { timeoutMs: opts.timeoutMs, deps: opts.deps })
94
+ return { ...out, ok: out.ok === true, reason: out.ok === true ? null : (out.reason || 'delivery_failed') }
95
+ }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * `argent notify` 适配层(P2,2026-10-03)—— 把"投递"这件事接成机器可判的 verb(A1/A2/A3)。
3
+ *
4
+ * ## 用法
5
+ * ```
6
+ * argent notify --channel webhook --url <http(s)://…> [--text <s>|--file <path>|--body-json <json>]
7
+ * [--kind <k>] [--summary <s>] [--header 'K: V']… [--idempotency-key <k>]
8
+ * [--timeout-ms <n>] [--apply|--send|--confirm|--yes] [--dry-run] [--json]
9
+ * ```
10
+ *
11
+ * ## A1 退出码(`$?` 即结论)
12
+ * | 码 | 何时 | reason |
13
+ * |---|---|---|
14
+ * | `0` | 投递成功 / **成功但被去重跳过** / dry-run 预演 | `null` | `duplicate_suppressed`(带 `empty:true`) |
15
+ * | `2` | 用法:未知通道 / 通道是预留位 / 缺目标 / 目标非法 / 缺正文 / 正文文件读不到 / 正文不是 JSON / 未知参数 | `unknown_channel` `channel_not_implemented` `target_required` `bad_target` `missing_body` `body_unreadable` `bad_body_json` `usage_error` |
16
+ * | `1` | 运行失败:投递失败(非 2xx、超时、连接失败)|数据目录写不下(fail-closed) | `delivery_failed` `internal_error` |
17
+ *
18
+ * ## A2 JSON
19
+ * `ok/empty/code/reason` 恒在;`$? == json.code`;`--json` 时 stdout 只有一份 JSON(诊断走 stderr)。
20
+ * 回显**实际生效值**:`channel`/`target`(**去掉凭据与 query**)/`kind`/`bodySource`/`bodyBytes`/
21
+ * `idempotencyKey` + `idempotencyKeySource`/`dryRun`/`sent`/`skipped`/`dedup{...}`/`http{...}`。
22
+ *
23
+ * ## A3 dry-run
24
+ * **默认即 dry-run**;真发要显式确权(`--apply`/`--send`/`--confirm`/`--yes`)。
25
+ * dry-run **零持久副作用**:不投递、**不落去重标记、不写审计**(判据 = 跑两次后数据目录逐字节不变)。
26
+ * dry-run 与真跑**共用同一条判据路径**(通道校验、目标校验、去重查询、正文装载全部照跑),
27
+ * 只是最后不调 `send`、不落盘 ⇒ 输出仍带 `dedup.decision`(`skip`/`send`)回答"真发会怎样"。
28
+ *
29
+ * ## 与实施方案的一处用词差异(如实标注)
30
+ * P2① 的验收示例写的是 `--target`,本 verb 的主名字用 `--url`(`--target` 作同义词保留);
31
+ * `--json <payload>` 与 A2 的输出开关**同名**:**后跟值 ⇒ 当正文**,**裸写 ⇒ 当输出开关**
32
+ * (两种用法都在回显里说明,不做静默猜测)。
33
+ */
34
+ import { readFileSync } from 'node:fs'
35
+ import { resolveDataDir } from '../argent-datadir.mjs'
36
+ import { listChannels, deliver as defaultDeliver, resolveChannel } from './index.mjs'
37
+ import { appendNotifyAudit, inspectDedup, outboxDir, resolveIdempotencyKey, writeDedupMarker } from './dedup.mjs'
38
+ import { sanitizeTarget } from './webhook.mjs'
39
+
40
+ /** 带值参数(重复 ⇒ 用法错误;`--header` 例外,可重复)。 */
41
+ export const VALUE_FLAGS = ['--channel', '--url', '--target', '--text', '--file', '--body-json', '--kind', '--summary', '--idempotency-key', '--timeout-ms']
42
+ /** 可重复参数。 */
43
+ export const REPEAT_FLAGS = ['--header']
44
+ /** 布尔参数。 */
45
+ export const BOOL_FLAGS = ['--dry-run', ...CONFIRM_LIST()]
46
+ /** 显式确权(真发)。 */
47
+ function CONFIRM_LIST() { return ['--apply', '--send', '--confirm', '--yes'] }
48
+ export const CONFIRM_FLAGS = CONFIRM_LIST()
49
+ /** 默认投递超时(ms)。 */
50
+ export const DEFAULT_TIMEOUT_MS = 10000
51
+
52
+ const flag = (v) => (v === undefined || v === null ? null : v)
53
+
54
+ /** 首行摘要(正文没给 `--summary` 时的**确定性**来源;不猜语义)。 */
55
+ export function firstLineSummary(text, max = 200) {
56
+ const line = String(text == null ? '' : text).split('\n').map((l) => l.trim()).find(Boolean) || ''
57
+ return line.length > max ? `${line.slice(0, max)}…` : line
58
+ }
59
+
60
+ /**
61
+ * 参数解析(纯函数;未知参数一律拒绝 —— "别的参数我替你吃了"是错误推迟暴露的经典坑)。
62
+ * @returns {{ok:true, plan:object} | {ok:false, code:2, reason:string, error:string}}
63
+ */
64
+ export function parseNotifyArgs(argv = []) {
65
+ const a = [...argv]
66
+ const values = {}
67
+ const headers = []
68
+ const bools = new Set()
69
+ let jsonOut = false
70
+ for (let i = 0; i < a.length; i++) {
71
+ const t = a[i]
72
+ if (t === '--json') {
73
+ const nxt = a[i + 1]
74
+ if (nxt !== undefined && !String(nxt).startsWith('--')) { // 带值 ⇒ 正文 JSON
75
+ if (values['--body-json'] !== undefined) return { ok: false, code: 2, reason: 'usage_error', error: '正文给了两次(--body-json / --json)' }
76
+ values['--body-json'] = nxt
77
+ i++
78
+ }
79
+ jsonOut = true // 裸写 ⇒ A2 的输出开关
80
+ continue
81
+ }
82
+ if (BOOL_FLAGS.includes(t)) { bools.add(t); continue }
83
+ if (REPEAT_FLAGS.includes(t)) {
84
+ const v = a[i + 1]
85
+ if (v === undefined || String(v).startsWith('--')) return { ok: false, code: 2, reason: 'usage_error', error: `参数 ${t} 缺值` }
86
+ headers.push(v); i++
87
+ continue
88
+ }
89
+ if (VALUE_FLAGS.includes(t)) {
90
+ const v = a[i + 1]
91
+ if (v === undefined || String(v).startsWith('--')) return { ok: false, code: 2, reason: 'usage_error', error: `参数 ${t} 缺值` }
92
+ if (values[t] !== undefined) return { ok: false, code: 2, reason: 'usage_error', error: `参数 ${t} 给了两次(值:${JSON.stringify(values[t])} / ${JSON.stringify(v)})` }
93
+ values[t] = v; i++
94
+ continue
95
+ }
96
+ return { ok: false, code: 2, reason: 'usage_error', error: `未知参数:${t}(用法见 core/lib/notify/verb.mjs 头注释或 argent notify --help)` }
97
+ }
98
+
99
+ // 通道
100
+ const channel = flag(values['--channel'])
101
+ if (!channel) return { ok: false, code: 2, reason: 'usage_error', error: `缺必填参数 --channel(已登记:${listChannels().map((c) => c.channel).join('、')})` }
102
+ const ch = resolveChannel(channel)
103
+ if (!ch.ok) return { ok: false, code: 2, reason: ch.reason, error: ch.error }
104
+
105
+ // 目标:`--url` 与 `--target` 是同义词;同时给且不同值 ⇒ 明确报错(不静默取一个)
106
+ const url = flag(values['--url'])
107
+ const target = flag(values['--target'])
108
+ if (url && target && url !== target) {
109
+ return { ok: false, code: 2, reason: 'usage_error', error: `--url 与 --target 是同义词,不能给不同的值(${url} / ${target})` }
110
+ }
111
+ const targetInput = url || target || ''
112
+ const tv = typeof ch.module.validateTarget === 'function' ? ch.module.validateTarget(targetInput) : { ok: true, target: targetInput }
113
+ if (!tv.ok) return { ok: false, code: 2, reason: tv.reason, error: tv.error }
114
+
115
+ // 正文:三选一
116
+ const sources = ['--text', '--file', '--body-json'].filter((f) => values[f] !== undefined)
117
+ if (!sources.length) {
118
+ return { ok: false, code: 2, reason: 'missing_body', error: '缺正文:请给 --text <s> 或 --file <path> 或 --body-json <json>' }
119
+ }
120
+ if (sources.length > 1) {
121
+ return { ok: false, code: 2, reason: 'usage_error', error: `正文只能给一个来源,实得 ${sources.join(' + ')}` }
122
+ }
123
+ const source = sources[0]
124
+ let text = ''
125
+ let payload = null
126
+ if (source === '--text') {
127
+ text = String(values['--text'])
128
+ if (!text.trim()) return { ok: false, code: 2, reason: 'missing_body', error: '--text 是空串 ⇒ 没有可投递的正文(空通知不是"成功但空")' }
129
+ } else if (source === '--file') {
130
+ const p = String(values['--file'])
131
+ try { text = readFileSync(p, 'utf8') } catch (e) {
132
+ return { ok: false, code: 2, reason: 'body_unreadable', error: `正文文件读不到:${p}(${(e && e.message) || e})` }
133
+ }
134
+ } else {
135
+ const raw = String(values['--body-json'])
136
+ try { payload = JSON.parse(raw) } catch (e) {
137
+ return { ok: false, code: 2, reason: 'bad_body_json', error: `--body-json 不是合法 JSON:${(e && e.message) || e}` }
138
+ }
139
+ text = typeof payload === 'string' ? payload : JSON.stringify(payload)
140
+ }
141
+
142
+ const timeoutRaw = values['--timeout-ms']
143
+ const timeoutMs = timeoutRaw === undefined ? DEFAULT_TIMEOUT_MS : Number(timeoutRaw)
144
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1) {
145
+ return { ok: false, code: 2, reason: 'usage_error', error: `--timeout-ms 必须是 ≥1 的整数,实得 ${JSON.stringify(timeoutRaw)}` }
146
+ }
147
+
148
+ const confirmed = CONFIRM_FLAGS.some((f) => bools.has(f))
149
+ const dryRun = bools.has('--dry-run') || !confirmed // A3:默认 dry-run;--dry-run 恒优先(fail-safe)
150
+ const kind = flag(values['--kind']) || (payload && typeof payload === 'object' && payload.kind ? String(payload.kind) : 'generic')
151
+ const summary = flag(values['--summary']) || (payload && typeof payload === 'object' && payload.summary ? String(payload.summary) : firstLineSummary(text))
152
+ return {
153
+ ok: true,
154
+ plan: {
155
+ channel, target: tv.target, targetInput: String(targetInput),
156
+ kind, summary, text, bodySource: source.slice(2), bodyBytes: Buffer.byteLength(text, 'utf8'),
157
+ headers, timeoutMs, dryRun, confirmed, jsonOut,
158
+ idempotencyKeyInput: flag(values['--idempotency-key']),
159
+ },
160
+ }
161
+ }
162
+
163
+ const parseHeaders = (list) => {
164
+ const out = {}
165
+ for (const h of list) {
166
+ const i = String(h).indexOf(':')
167
+ if (i < 1) return { ok: false, error: `--header 形态应为 'Name: value',实得 ${JSON.stringify(h)}` }
168
+ out[String(h).slice(0, i).trim()] = String(h).slice(i + 1).trim()
169
+ }
170
+ return { ok: true, headers: out }
171
+ }
172
+
173
+ /** 失败信封(照 A2:ok/empty/code/reason 恒在)。 */
174
+ function fail(code, reason, error, echo = {}) {
175
+ return {
176
+ json: { ok: false, empty: false, code, reason, error, verb: 'notify', ...echo },
177
+ human: `❌ ${reason}:${error}`,
178
+ stderr: '',
179
+ exit: code,
180
+ }
181
+ }
182
+
183
+ export const NOTIFY_USAGE = () => [
184
+ '用法:argent notify --channel <c> --url <http(s)://…> [--text <s>|--file <path>|--body-json <json>]',
185
+ ` [--kind <k>] [--summary <s>] [--header 'K: V']… [--idempotency-key <k>] [--timeout-ms <n>]`,
186
+ ` [--apply|--send|--confirm|--yes] [--dry-run] [--json]`,
187
+ '',
188
+ '通道:' + listChannels().map((c) => `${c.channel}${c.implemented ? '' : '(预留位)'}`).join('、'),
189
+ '默认 dry-run(只打印投递意图);真发加 --apply。去重按幂等键(未给则用内容摘要)。',
190
+ ].join('\n')
191
+
192
+ /**
193
+ * verb 入口:`argent notify …`。
194
+ * @param {string[]} argv
195
+ * @param {{repo?:string, env?:object, dataDir?:string, deliver?:Function, now?:Date}} [deps]
196
+ * @returns {Promise<{json:object, human:string, stderr:string, exit:number}>}
197
+ */
198
+ export async function runNotifyVerb(argv = [], deps = {}) {
199
+ if (argv.includes('--help') || argv.includes('-h')) {
200
+ return { json: { ok: true, empty: false, code: 0, reason: null, verb: 'notify', usage: NOTIFY_USAGE(), channels: listChannels() }, human: NOTIFY_USAGE(), stderr: '', exit: 0 }
201
+ }
202
+ const parsed = parseNotifyArgs(argv)
203
+ if (!parsed.ok) return fail(parsed.code, parsed.reason, parsed.error, { channel: null, target: null, dryRun: null })
204
+ const plan = parsed.plan
205
+
206
+ const hv = parseHeaders(plan.headers)
207
+ if (!hv.ok) return fail(2, 'usage_error', hv.error, { channel: plan.channel, target: sanitizeTarget(plan.target), dryRun: plan.dryRun })
208
+
209
+ const env = deps.env || process.env
210
+ const dataDir = deps.dataDir || defaultDataDirOf(env)
211
+ const sentAt = (deps.now || new Date()).toISOString()
212
+ const { key, source: keySource } = resolveIdempotencyKey(
213
+ { channel: plan.channel, target: plan.target, kind: plan.kind, summary: plan.summary, text: plan.text },
214
+ plan.idempotencyKeyInput,
215
+ )
216
+ const echo = {
217
+ channel: plan.channel,
218
+ target: sanitizeTarget(plan.target),
219
+ targetHasCredentials: /\/\/[^/@]*@/.test(plan.targetInput),
220
+ kind: plan.kind,
221
+ summary: plan.summary,
222
+ bodySource: plan.bodySource,
223
+ bodyBytes: plan.bodyBytes,
224
+ idempotencyKey: key,
225
+ idempotencyKeySource: keySource,
226
+ timeoutMs: plan.timeoutMs,
227
+ dryRun: plan.dryRun,
228
+ dataDir,
229
+ outbox: outboxDir(dataDir),
230
+ }
231
+
232
+ // 同一条判据路径:dry-run 也查去重(只读)⇒ 能回答"真发会怎样"
233
+ const before = inspectDedup(dataDir, key)
234
+ const dedup = { applied: true, key, marker: before.marker, preexisting: before.exists, preexistingSentAt: before.sentAt, decision: 'would_send', sentAt: null, status: null }
235
+ if (before.decision === 'skip') {
236
+ const json = {
237
+ ok: true, empty: true, code: 0, reason: 'duplicate_suppressed', verb: 'notify',
238
+ sent: false, skipped: true, skippedReason: 'already_delivered',
239
+ dedup: { ...dedup, decision: 'skip', sentAt: before.sentAt, status: before.status },
240
+ http: null, audit: { appended: false, file: null }, deliveredAt: before.sentAt, ...echo,
241
+ note: `同一事实已投递过(幂等键 ${key}${keySource === 'derived' ? ',由内容摘要推出' : ''},${before.sentAt || '时间未知'})`
242
+ + ';要重发请显式换一个 --idempotency-key。',
243
+ }
244
+ return {
245
+ json,
246
+ human: `⏭️ 已跳过(duplicate_suppressed):同一事实投递过(key=${key} @ ${before.sentAt || '?'})\n 要重发:换一个 --idempotency-key`,
247
+ stderr: '', exit: 0,
248
+ }
249
+ }
250
+
251
+ if (plan.dryRun) {
252
+ const json = {
253
+ ok: true, empty: false, code: 0, reason: null, verb: 'notify',
254
+ sent: false, skipped: false, wouldSend: true,
255
+ dedup, http: null, audit: { appended: false, file: null }, deliveredAt: null, ...echo,
256
+ note: 'dry-run(默认):未投递、未落去重标记、未写审计;真发加 --apply',
257
+ }
258
+ return {
259
+ json,
260
+ human: `🧪 [dry-run] notify ${plan.channel} → ${json.target}\n`
261
+ + ` kind=${plan.kind} 正文=${plan.bodySource} ${plan.bodyBytes} B 幂等键=${key}(${keySource})\n`
262
+ + ` 摘要:${plan.summary || '(空)'}\n`
263
+ + ' ⛔ 未投递(真发加 --apply);去重标记与审计都未落盘',
264
+ stderr: '', exit: 0,
265
+ }
266
+ }
267
+
268
+ // ── 真发(唯一副作用点:一次 HTTP 请求 + 标记 + 审计)────────────────────
269
+ const deliver = deps.deliver || defaultDeliver
270
+ let res = null
271
+ try {
272
+ res = await deliver({ channel: plan.channel, target: plan.target, kind: plan.kind, summary: plan.summary, text: plan.text, headers: hv.headers, idempotencyKey: key, sentAt }, { timeoutMs: plan.timeoutMs, deps: deps.deliverDeps })
273
+ } catch (e) {
274
+ res = { ok: false, reason: 'delivery_failed', error: `通道抛异常:${(e && e.message) || e}` }
275
+ }
276
+ const http = { status: res && res.status != null ? res.status : null, responseExcerpt: (res && res.responseExcerpt) || null, bytes: (res && res.bytes != null ? res.bytes : plan.bodyBytes) }
277
+
278
+ if (!res || res.ok !== true) {
279
+ const code = res && res.reason === 'internal_error' ? 1 : 1
280
+ const json = {
281
+ ok: false, empty: false, code, reason: res && res.reason && res.reason !== 'delivery_failed' ? res.reason : 'delivery_failed',
282
+ error: (res && res.error) || '投递失败(未知原因)', verb: 'notify',
283
+ sent: false, skipped: false, dedup, http, audit: { appended: false, file: null }, deliveredAt: null, ...echo,
284
+ note: '投递失败 ⇒ **未落去重标记**(原样重试即可,不会被"试过一次"永久跳过)',
285
+ }
286
+ return { json, human: `❌ 投递失败(${json.reason}):${json.error}\n 目标 ${json.target}|HTTP ${http.status ?? '—'}|未落标记、可原样重试`, stderr: '', exit: code }
287
+ }
288
+
289
+ // 成功 ⇒ 落标记 + 审计(fail-closed:写不下就报 internal_error,绝不当成功)
290
+ try {
291
+ writeDedupMarker(dataDir, key, { channel: plan.channel, target: echo.target, kind: plan.kind, summary: plan.summary, status: http.status, bytes: http.bytes })
292
+ appendNotifyAudit(dataDir, {
293
+ verb: 'notify', channel: plan.channel, target: echo.target, kind: plan.kind, summary: plan.summary,
294
+ idempotencyKey: key, idempotencyKeySource: keySource, status: http.status, bytes: http.bytes, ok: true,
295
+ who: 'argent/notify P2',
296
+ })
297
+ } catch (e) {
298
+ const json = {
299
+ ok: false, empty: false, code: 1, reason: 'internal_error',
300
+ error: `投递已发出但**标记/审计写不下**(${(e && e.message) || e})⇒ 按 fail-closed 报失败;`
301
+ + '后果:下一次调用会**再发一次**(去重标记没落成),请先修数据目录可写性。',
302
+ verb: 'notify', sent: true, skipped: false,
303
+ dedup: { ...dedup, decision: 'sent', sentAt, status: http.status },
304
+ http, audit: { appended: false, file: null }, deliveredAt: sentAt, ...echo,
305
+ }
306
+ return { json, human: `⚠️ ${json.error}`, stderr: '', exit: 1 }
307
+ }
308
+
309
+ const json = {
310
+ ok: true, empty: false, code: 0, reason: null, verb: 'notify',
311
+ sent: true, skipped: false, wouldSend: null,
312
+ dedup: { ...dedup, decision: 'sent', sentAt, status: http.status },
313
+ http, audit: { appended: true, file: `${outboxDir(dataDir)}/audit.jsonl` }, deliveredAt: sentAt, ...echo,
314
+ note: null,
315
+ }
316
+ return {
317
+ json,
318
+ human: `✅ 已投递:${plan.channel} → ${json.target}(HTTP ${http.status},${http.bytes} B,幂等键 ${key})\n`
319
+ + ` 去重标记与审计已落:${json.outbox}`,
320
+ stderr: '', exit: 0,
321
+ }
322
+ }
323
+
324
+ /** 数据目录:**唯一判据**在 `core/lib/argent-datadir.mjs`(本文件不复制那条链)。 */
325
+ function defaultDataDirOf(env) {
326
+ return resolveDataDir({ env }).dir
327
+ }