@danceiny/gotry 0.0.1-rc.21 → 0.0.1-rc.24

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 (327) hide show
  1. package/README.md +102 -194
  2. package/README.zh-CN.md +115 -210
  3. package/bin/gotry-backend.js +53 -0
  4. package/bin/gotry-bootstrap.js +466 -35
  5. package/bin/gotry-inner.js +255 -36
  6. package/bin/gotry-process-liveness.d.ts +27 -0
  7. package/bin/gotry-process-liveness.js +97 -0
  8. package/bin/gotry-runtime-resolution.d.ts +1 -1
  9. package/bin/gotry-runtime-resolution.js +2 -2
  10. package/client/client.js +174 -0
  11. package/cordis.gotry-patch.yml +70 -93
  12. package/dist/capabilities/agent-reach-deep.js +45 -122
  13. package/dist/capabilities/agent-reach.js +63 -148
  14. package/dist/capabilities/anything.js +89 -75
  15. package/dist/capabilities/artifacts.js +123 -104
  16. package/dist/capabilities/channel-health.js +65 -45
  17. package/dist/capabilities/channel-registry.js +108 -87
  18. package/dist/capabilities/doctor.js +357 -235
  19. package/dist/capabilities/effect.js +223 -309
  20. package/dist/capabilities/fact-log.js +28 -14
  21. package/dist/capabilities/flyai.js +245 -156
  22. package/dist/capabilities/ground-transfer.js +509 -0
  23. package/dist/capabilities/hbcli.js +144 -148
  24. package/dist/capabilities/hotelbyte-transaction.js +194 -0
  25. package/dist/capabilities/incident-log.js +131 -61
  26. package/dist/capabilities/model-override.js +30 -10
  27. package/dist/capabilities/opensky.js +54 -43
  28. package/dist/capabilities/resilience.js +50 -32
  29. package/dist/capabilities/session/action-cache.js +50 -44
  30. package/dist/capabilities/session/adapters/ctrip-flight.js +119 -62
  31. package/dist/capabilities/session/adapters/ctrip-hotel.js +125 -110
  32. package/dist/capabilities/session/adapters/dida-portal.js +206 -0
  33. package/dist/capabilities/session/adapters/meituan-local.js +35 -54
  34. package/dist/capabilities/session/adapters/rail-12306.js +169 -112
  35. package/dist/capabilities/session/benchmark.js +85 -149
  36. package/dist/capabilities/session/extension-bridge.js +320 -259
  37. package/dist/capabilities/session/extension-channel.js +57 -76
  38. package/dist/capabilities/session/extension-distribution.js +115 -119
  39. package/dist/capabilities/session/extract.js +31 -21
  40. package/dist/capabilities/session/golden-score.js +55 -33
  41. package/dist/capabilities/session/health-watch.js +146 -154
  42. package/dist/capabilities/session/read-guard.js +55 -47
  43. package/dist/capabilities/session/static-flight-golden.js +49 -36
  44. package/dist/capabilities/session/transport.js +54 -61
  45. package/dist/capabilities/session/wizard.js +63 -93
  46. package/dist/capabilities/session-consent.js +143 -47
  47. package/dist/capabilities/session-login.js +95 -126
  48. package/dist/capabilities/session-search.js +391 -253
  49. package/dist/capabilities/sponsor-plugin.js +341 -0
  50. package/dist/capabilities/visa-policy.js +87 -73
  51. package/dist/capabilities/weather.js +163 -235
  52. package/dist/scripts/action-cache-tests.js +24 -59
  53. package/dist/scripts/agent-planning-turn-deadline-e2e.js +91 -142
  54. package/dist/scripts/agent-planning-turn-deadline-tests.js +80 -176
  55. package/dist/scripts/agent-reach-deep-tests.js +30 -40
  56. package/dist/scripts/agent-reach-tests.js +26 -18
  57. package/dist/scripts/agent-reach-wrapper-tests.js +46 -50
  58. package/dist/scripts/anything-tests.js +58 -62
  59. package/dist/scripts/artifact-client-contract-tests.js +69 -0
  60. package/dist/scripts/artifacts-capability-tests.js +235 -0
  61. package/dist/scripts/async-collect.js +26 -15
  62. package/dist/scripts/benchmark-environment-bridge-e2e.js +287 -684
  63. package/dist/scripts/benchmark-environment-bridge-tests.js +818 -3123
  64. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +66 -279
  65. package/dist/scripts/booking-copilot-availability-policy-tests.js +153 -1257
  66. package/dist/scripts/booking-copilot-bin-proof-tests.js +22 -45
  67. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +64 -76
  68. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +69 -199
  69. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +393 -759
  70. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +33 -75
  71. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +60 -110
  72. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +28 -66
  73. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +72 -271
  74. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +73 -275
  75. package/dist/scripts/booking-copilot-runtime-proof-tests.js +784 -3398
  76. package/dist/scripts/booking-copilot-server-proof-tests.js +63 -168
  77. package/dist/scripts/booking-copilot-startup-proof-tests.js +38 -124
  78. package/dist/scripts/booking-executor-tests.js +80 -0
  79. package/dist/scripts/booking-recovery-chain-tests.js +182 -0
  80. package/dist/scripts/booking-saga-tests.js +61 -123
  81. package/dist/scripts/booking-surface-contract-proof-tests.js +152 -1167
  82. package/dist/scripts/bootstrap-tests.js +1383 -128
  83. package/dist/scripts/build-changelog.js +89 -130
  84. package/dist/scripts/build-metrics-report.js +135 -143
  85. package/dist/scripts/changelog-tests.js +35 -82
  86. package/dist/scripts/channel-probe-tests.js +44 -116
  87. package/dist/scripts/channel-probe.js +84 -167
  88. package/dist/scripts/channel-registry-tests.js +97 -185
  89. package/dist/scripts/companion-tests.js +25 -70
  90. package/dist/scripts/dida-cookie-probe.js +87 -0
  91. package/dist/scripts/dida-sw-probe.js +114 -0
  92. package/dist/scripts/diff-test.js +17 -12
  93. package/dist/scripts/doctor-tests.js +428 -120
  94. package/dist/scripts/dsh-artifact-e2e.js +150 -0
  95. package/dist/scripts/dsh-artifact-web-e2e.js +443 -0
  96. package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
  97. package/dist/scripts/dsh-runtime-closure-tests.js +110 -153
  98. package/dist/scripts/dsh-runtime-closure.js +75 -63
  99. package/dist/scripts/dsh-session-v3-migration-proof.js +106 -0
  100. package/dist/scripts/dsh-subprocess-local-proof.js +149 -0
  101. package/dist/scripts/dsh-target-closure-proof.js +178 -0
  102. package/dist/scripts/effect-tests.js +165 -552
  103. package/dist/scripts/engine-run.js +1 -3
  104. package/dist/scripts/engine-tests.js +16 -10
  105. package/dist/scripts/evaluation-cadence-tests.js +69 -242
  106. package/dist/scripts/evaluation-contract-tests.js +193 -499
  107. package/dist/scripts/extension-distribution-cli.js +19 -15
  108. package/dist/scripts/extension-distribution-tests.js +98 -211
  109. package/dist/scripts/extension-tests.js +366 -389
  110. package/dist/scripts/fact-gate-tests.js +725 -263
  111. package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
  112. package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
  113. package/dist/scripts/flight-malformed-tests.js +159 -0
  114. package/dist/scripts/flyai-tests.js +325 -68
  115. package/dist/scripts/g5-guard-tests.js +131 -0
  116. package/dist/scripts/g5-guard.js +189 -0
  117. package/dist/scripts/gen-lunar-anchors.js +106 -0
  118. package/dist/scripts/gotry-backend-kernel-tests.js +79 -0
  119. package/dist/scripts/ground-transfer-tests.js +956 -0
  120. package/dist/scripts/hbcli-e2e-tests.js +71 -90
  121. package/dist/scripts/hbcli-live-optin-tests.js +96 -0
  122. package/dist/scripts/hbcli-release-contract-tests.js +122 -0
  123. package/dist/scripts/hbcli-tests.js +41 -115
  124. package/dist/scripts/health-watch-cli.js +42 -28
  125. package/dist/scripts/hotel-date-gate-tests.js +496 -0
  126. package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
  127. package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
  128. package/dist/scripts/i18n-tests.js +16 -9
  129. package/dist/scripts/incident-tests.js +281 -87
  130. package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
  131. package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
  132. package/dist/scripts/issue-271-liveness-tests.js +547 -0
  133. package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
  134. package/dist/scripts/issue-289-model-retry-tests.js +441 -0
  135. package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
  136. package/dist/scripts/issue-338-home-city-e2e.js +577 -0
  137. package/dist/scripts/issue343-real-entry-e2e.js +160 -0
  138. package/dist/scripts/journey-tests.js +22 -27
  139. package/dist/scripts/kernel-manifest-evidence.js +109 -0
  140. package/dist/scripts/kernel-manifest-gate.js +126 -0
  141. package/dist/scripts/kernel-manifest-tests.js +212 -0
  142. package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
  143. package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
  144. package/dist/scripts/kernel-manifest.js +369 -0
  145. package/dist/scripts/ledger-repair-apply-tests.js +316 -0
  146. package/dist/scripts/ledger-repair-plan-tests.js +375 -0
  147. package/dist/scripts/ledger-tests.js +325 -340
  148. package/dist/scripts/ledger-workflow-crash.js +10 -20
  149. package/dist/scripts/map-tools-vendor-package-proof.js +206 -134
  150. package/dist/scripts/memory-capture-tests.js +242 -61
  151. package/dist/scripts/memory-decay-tests.js +18 -52
  152. package/dist/scripts/memory-lifecycle-tests.js +657 -0
  153. package/dist/scripts/memory-lifecycle.js +196 -0
  154. package/dist/scripts/memory-metrics.js +24 -12
  155. package/dist/scripts/memory-value-report.js +474 -222
  156. package/dist/scripts/memory-value-tests.js +328 -0
  157. package/dist/scripts/metrics-report-tests.js +62 -199
  158. package/dist/scripts/model-override-e2e.js +66 -105
  159. package/dist/scripts/nightly-evidence-tests.js +64 -89
  160. package/dist/scripts/nightly-evidence.js +108 -111
  161. package/dist/scripts/nudge-digest.js +39 -28
  162. package/dist/scripts/onboarding-tests.js +63 -88
  163. package/dist/scripts/opensky-check.js +18 -13
  164. package/dist/scripts/opensky-tests.js +28 -28
  165. package/dist/scripts/persona-surface-guard-tests.js +37 -12
  166. package/dist/scripts/pnpm-dsh-closure-proof.js +2 -5
  167. package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
  168. package/dist/scripts/price-drift-tests.js +126 -227
  169. package/dist/scripts/price-drift-watch.js +209 -253
  170. package/dist/scripts/probe-poi-tests.js +52 -45
  171. package/dist/scripts/product-metrics.js +187 -273
  172. package/dist/scripts/publish-preverify.js +47 -41
  173. package/dist/scripts/realtime-pricing-tests.js +37 -76
  174. package/dist/scripts/replay-async.js +20 -18
  175. package/dist/scripts/replay-real.js +16 -18
  176. package/dist/scripts/replay.js +52 -82
  177. package/dist/scripts/session-attach-diagnose.js +16 -12
  178. package/dist/scripts/session-attach-poc.js +34 -37
  179. package/dist/scripts/session-benchmark.js +66 -147
  180. package/dist/scripts/session-dida-live-e2e.js +151 -0
  181. package/dist/scripts/session-extract-tests.js +14 -35
  182. package/dist/scripts/session-login.js +14 -13
  183. package/dist/scripts/session-tests.js +545 -583
  184. package/dist/scripts/sf-live-benchmark.js +92 -110
  185. package/dist/scripts/sf-live-cli-tests.js +2 -9
  186. package/dist/scripts/sf-soft-score-tests.js +19 -60
  187. package/dist/scripts/sf-summary-tests.js +149 -0
  188. package/dist/scripts/sf-summary.js +466 -84
  189. package/dist/scripts/skeleton-check.js +16 -14
  190. package/dist/scripts/skeleton-integration-test.js +11 -10
  191. package/dist/scripts/skills-contract-tests.js +51 -46
  192. package/dist/scripts/smoke-session-gate-tests.js +12 -20
  193. package/dist/scripts/smoke.js +541 -422
  194. package/dist/scripts/sponsor-reuse-tests.js +262 -0
  195. package/dist/scripts/state-cli-tests.js +226 -101
  196. package/dist/scripts/state-cli.js +549 -212
  197. package/dist/scripts/static-golden-tests.js +46 -180
  198. package/dist/scripts/time-eval-tests.js +210 -192
  199. package/dist/scripts/travel-timeline-tests.js +29 -93
  200. package/dist/scripts/turn-handoff-collect-tests.js +46 -93
  201. package/dist/scripts/turn-handoff-collect.js +71 -99
  202. package/dist/scripts/turn-policy-tests.js +28 -29
  203. package/dist/scripts/typed-contract-canary.js +77 -162
  204. package/dist/scripts/tz-resolver-sanity.js +50 -0
  205. package/dist/scripts/tz-resolver-tests.js +584 -0
  206. package/dist/scripts/unified-tests.js +23 -19
  207. package/dist/scripts/visa-policy-tests.js +31 -44
  208. package/dist/scripts/weather-tests.js +121 -543
  209. package/dist/scripts/wish-channel-gate-tests.js +29 -86
  210. package/dist/scripts/write-gate-tests.js +522 -0
  211. package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
  212. package/dist/scripts/z3-lifecycle-tests.js +170 -0
  213. package/dist/scripts/z3-race-repeat-tests.js +39 -0
  214. package/dist/scripts/z3-race-tests.js +18 -12
  215. package/dist/src/artifact-gate.js +652 -221
  216. package/dist/src/backend/kernel.js +91 -0
  217. package/dist/src/backend/modules/booking-copilot.js +55 -0
  218. package/dist/src/backend/modules/booking-executor.js +124 -0
  219. package/dist/src/backend/modules/session-search.js +194 -0
  220. package/dist/src/benchmark-agent-conformance.js +296 -262
  221. package/dist/src/benchmark-environment-bridge.js +298 -413
  222. package/dist/src/benchmark-headless-child-diagnostics.js +93 -100
  223. package/dist/src/benchmark-tool-isolation.js +94 -67
  224. package/dist/src/bookable-facts.js +299 -174
  225. package/dist/src/booking-saga.js +64 -120
  226. package/dist/src/booking-surface/availability-policy.js +443 -576
  227. package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
  228. package/dist/src/booking-surface/contracts.js +24 -80
  229. package/dist/src/booking-surface/dsh-planner.js +510 -281
  230. package/dist/src/booking-surface/dsh-plugin.js +17 -1
  231. package/dist/src/booking-surface/error-codes.js +82 -10
  232. package/dist/src/booking-surface/index.js +0 -3
  233. package/dist/src/booking-surface/profile.js +10 -54
  234. package/dist/src/booking-surface/recovery-chain.js +367 -0
  235. package/dist/src/booking-surface/runtime.js +765 -891
  236. package/dist/src/booking-surface/server.js +200 -251
  237. package/dist/src/booking-surface/startup.js +58 -54
  238. package/dist/src/booking-surface/validation.js +150 -218
  239. package/dist/src/bridge.js +20 -13
  240. package/dist/src/companions.js +48 -74
  241. package/dist/src/contracts.js +21 -43
  242. package/dist/src/dsh-llm.js +82 -120
  243. package/dist/src/engine.js +253 -239
  244. package/dist/src/evaluation-cadence.js +72 -209
  245. package/dist/src/evaluation-contracts.js +220 -843
  246. package/dist/src/flight-pack-adapter.js +54 -0
  247. package/dist/src/flight-pack-contract.js +2 -0
  248. package/dist/src/gotry-backend.js +70 -0
  249. package/dist/src/hotel-date-gate.js +118 -0
  250. package/dist/src/i18n.js +18 -7
  251. package/dist/src/index.js +1425 -1629
  252. package/dist/src/journey.js +132 -81
  253. package/dist/src/ledger-repair-apply.js +538 -0
  254. package/dist/src/ledger-repair-plan.js +463 -0
  255. package/dist/src/loop.js +257 -166
  256. package/dist/src/memory-capture.js +180 -32
  257. package/dist/src/memory-decay.js +30 -14
  258. package/dist/src/memory-lifecycle.js +1310 -0
  259. package/dist/src/memory-utility.js +27 -40
  260. package/dist/src/mock-llm.js +36 -61
  261. package/dist/src/model.js +81 -36
  262. package/dist/src/realtime-pricing.js +63 -72
  263. package/dist/src/slot-spec.js +84 -106
  264. package/dist/src/state-ledger.js +310 -471
  265. package/dist/src/subagent-job-id-guard.js +58 -0
  266. package/dist/src/time-anchor.js +231 -33
  267. package/dist/src/tool-packet.js +20 -8
  268. package/dist/src/travel-slots.js +58 -35
  269. package/dist/src/travel-timeline.js +44 -63
  270. package/dist/src/turn-deadline.js +121 -127
  271. package/dist/src/turn-policy.js +61 -89
  272. package/dist/src/tz-resolver.js +143 -0
  273. package/dist/src/unified.js +504 -381
  274. package/dist/src/wish-pool.js +39 -14
  275. package/dist/src/write-gate.js +495 -0
  276. package/dist/src/z3-shared.js +273 -7
  277. package/extension/README.md +4 -3
  278. package/extension/background.js +125 -14
  279. package/extension/content-bridge.js +23 -1
  280. package/extension/content-main.js +15 -4
  281. package/extension/manifest.json +16 -8
  282. package/package.json +251 -220
  283. package/ts/capabilities/agent-reach.ts +4 -4
  284. package/ts/capabilities/anything.ts +1 -1
  285. package/ts/capabilities/artifacts.ts +46 -9
  286. package/ts/capabilities/flyai.ts +156 -37
  287. package/ts/capabilities/hbcli.ts +1 -1
  288. package/ts/capabilities/incident-log.ts +88 -59
  289. package/ts/capabilities/session/adapters/ctrip-flight.ts +99 -36
  290. package/ts/capabilities/session/adapters/dida-portal.ts +250 -0
  291. package/ts/capabilities/session/adapters/rail-12306.ts +123 -24
  292. package/ts/capabilities/session/extension-bridge.ts +226 -101
  293. package/ts/capabilities/session/extension-channel.ts +22 -9
  294. package/ts/capabilities/session/wizard.ts +2 -2
  295. package/ts/capabilities/session-consent.ts +98 -14
  296. package/ts/capabilities/session-login.ts +1 -0
  297. package/ts/capabilities/session-search.ts +315 -31
  298. package/ts/dsh-runtime/vendor/README.md +8 -4
  299. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +7 -1
  300. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +5 -3
  301. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +1 -1
  302. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +1 -1
  303. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +6 -6
  304. package/ts/package.json +236 -16
  305. package/ts/scripts/map-tools-vendor-package-proof.ts +152 -31
  306. package/ts/scripts/state-cli.ts +418 -51
  307. package/ts/src/artifact-gate.ts +561 -15
  308. package/ts/src/benchmark-environment-bridge.ts +37 -20
  309. package/ts/src/bookable-facts.ts +183 -7
  310. package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
  311. package/ts/src/booking-surface/dsh-planner.ts +240 -32
  312. package/ts/src/booking-surface/dsh-plugin.js +17 -1
  313. package/ts/src/booking-surface/error-codes.ts +75 -0
  314. package/ts/src/booking-surface/recovery-chain.ts +409 -0
  315. package/ts/src/booking-surface/server.ts +84 -51
  316. package/ts/src/booking-surface/validation.ts +2 -0
  317. package/ts/src/contracts.ts +12 -2
  318. package/ts/src/dsh-llm.ts +4 -8
  319. package/ts/src/index.ts +609 -90
  320. package/ts/src/loop.ts +116 -7
  321. package/ts/src/memory-capture.ts +227 -3
  322. package/ts/src/mock-llm.ts +4 -8
  323. package/ts/src/model.ts +88 -2
  324. package/ts/src/state-ledger.ts +47 -29
  325. package/ts/src/subagent-job-id-guard.ts +72 -0
  326. package/ts/src/time-anchor.ts +217 -13
  327. package/ts/src/unified.ts +316 -54
package/ts/src/index.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * - gotry_wish_pool_add 「下一次出发」清单(憧憬不被拒绝)
8
8
  *
9
9
  * 插件形态遵循 dsh 约定(name/inject/Config/apply + ctx.tools.register(defineTool(...))),
10
- * 对齐 @deepseek-ai/dsh-tools@0.1.2-alpha.3 的契约:
10
+ * 对齐 @deepseek-ai/dsh-tools@0.1.5-rc.1 的契约:
11
11
  * render 位于 output 对象内,参数属性是 ValueSchemaSpec(支持 type:'json')。
12
12
  *
13
13
  * @module @gotry/plugin
@@ -17,7 +17,7 @@ import { join } from 'node:path'
17
17
 
18
18
  import type { Context } from '@deepseek-ai/cordis'
19
19
  import z from '@deepseek-ai/schemastery'
20
- import { defineTool } from '@deepseek-ai/dsh-tools'
20
+ import { defineTool, type ToolResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
21
21
  import { ensureStateDir, recordLatency } from './bridge.ts'
22
22
  import { segmentsFromCandidate, solveChoiceSegment } from './unified.ts'
23
23
  import { checkConnectivity } from '../scripts/skeleton-check.ts'
@@ -28,20 +28,23 @@ import { projectUtility } from './memory-utility.ts'
28
28
  import { pickNudgeWish, type WishPoolEntry } from './wish-pool.ts'
29
29
  import { resolveTimelineDate } from './travel-timeline.ts'
30
30
  import { ensureLedger, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback, readWishPoolWithFallback } from './state-ledger.ts'
31
- import { buildTimeAnchor } from './time-anchor.ts'
32
- import { resolveSlotDate } from './slot-spec.ts'
31
+ import { isValidHomeCityPreference, resolveDefaultOrigin, type MergedProfile, type ProfilePatch } from './memory-capture.ts'
32
+ import { applyPlanningWindow } from './loop.ts'
33
+ import { buildTimeAnchor, type PlanningWindow } from './time-anchor.ts'
34
+ import { evaluateHotelStayDates } from './hotel-date-gate.ts'
33
35
  import { wmoLabel } from '../capabilities/weather.ts'
34
36
  // D-23 收编(issue #115):anything/web/github/video/agent_reach/session_login 六渠道改经
35
37
  // 效应注册表(退避/熔断/declined 面统一);能力层直接 import 仅留类型位(reach/reachStatus)
36
38
  import { reach, reachStatus } from '../capabilities/agent-reach.ts'
37
- import { runDoctorChecks, renderDoctorReportMd } from '../capabilities/doctor.ts'
39
+ import { runDoctorChecks, renderDoctorReportMd, runDoctorRepair, createRepairApprovalGate, type DoctorRepairOptions } from '../capabilities/doctor.ts'
38
40
  import { EXTENSION_STORE_URL } from '../capabilities/session/extension-bridge.ts'
39
- import { createConsentGate, approvalFromContext } from '../capabilities/session-consent.ts'
41
+ import { createConsentGate, approvalFromContext, resolveSessionSearchKind } from '../capabilities/session-consent.ts'
40
42
  import { installModelOverride } from '../capabilities/model-override.ts'
41
43
  import { listArtifacts, readArtifact } from '../capabilities/artifacts.ts'
42
44
  import { interpretEffect, declinedObservation } from '../capabilities/effect.ts'
43
45
  import { appendFacts, loadFactRegistry } from '../capabilities/fact-log.ts'
44
- import { factsFromFlyai, factsFromHotel, factsFromSession } from './bookable-facts.ts'
46
+ import { factsFromFlyai, factsFromHotel, factsFromSession, factsFromSessionTrain } from './bookable-facts.ts'
47
+ import { hasRecognizedAvailableSeat } from '../capabilities/session/adapters/rail-12306.ts'
45
48
  import { gateArtifact, type AirlineAirportMap } from './artifact-gate.ts'
46
49
  import { installTurnDeadline, listTurnHandoffTickets } from './turn-deadline.ts'
47
50
  import { noteChannelVerdict, recordChannelEvent, readLatestChannelEvents } from '../capabilities/channel-health.ts'
@@ -49,6 +52,15 @@ import { routingAdvice, renderRoutingCard, toolRoutingHeadline, type ChannelInte
49
52
  import { registerBenchmarkEnvironmentBridge, type BenchmarkSubprocessService } from './benchmark-environment-bridge.ts'
50
53
  import { installBenchmarkToolIsolation } from './benchmark-tool-isolation.ts'
51
54
  import { installBenchmarkAgentConformance } from './benchmark-agent-conformance.ts'
55
+ import { installSubagentJobIdGuard } from './subagent-job-id-guard.ts'
56
+ import {
57
+ createGroundTransferResolver,
58
+ createPublicMapDrivingRouteProvider,
59
+ exposeGroundTransferEvidence,
60
+ resolveGroundTransferPayload,
61
+ type GroundTransferProvider,
62
+ type GroundTransferResolution,
63
+ } from '../capabilities/ground-transfer.ts'
52
64
 
53
65
  /** 航司→机场映射表(issue #46 冲突检测面;data/airline-airports.json,as_of 快照) */
54
66
  let airlineAirportMapCache: AirlineAirportMap | null = null
@@ -71,6 +83,13 @@ export const name = 'gotry-tools'
71
83
  // depend on a service that is only needed for explicit benchmark opt-in.
72
84
  export const inject = ['tools', 'systemPrompt']
73
85
 
86
+ function sessionCwd(exec: unknown): string | undefined {
87
+ const candidate = exec as { agent?: { session?: { header?: { cwd?: unknown } } } } | null
88
+ return typeof candidate?.agent?.session?.header?.cwd === 'string'
89
+ ? candidate.agent.session.header.cwd
90
+ : undefined
91
+ }
92
+
74
93
  export interface Config {
75
94
  /** 状态根目录(动机画像、wish pool、延迟日志) */
76
95
  stateRoot: string
@@ -96,12 +115,31 @@ interface FeasibilityResult {
96
115
  answer_md?: string
97
116
  recommended?: string | null
98
117
  verdicts?: Array<Record<string, unknown>>
118
+ ground_transfer?: GroundTransferResolution
119
+ }
120
+
121
+ interface FeasibilityValidationFailure extends Record<string, Json> {
122
+ ok: false
123
+ code: 'invalid_planning_context' | 'planning_window_rejected'
124
+ summary: string
125
+ rejected: Array<{ segmentId: string; optionId: string; date: string }>
99
126
  }
100
127
 
101
128
  interface MotivationProfileInput {
102
129
  weights?: Record<string, number>
103
130
  evidence?: string[]
104
131
  hard?: Record<string, unknown>
132
+ /**
133
+ * 持久化用户常驻城市/默认出发地(issue #338)。
134
+ * - string = 设置默认城市(用户原话指明常驻)
135
+ * - null = 显式清除(审计一行留痕,用于遗忘/迁居)
136
+ * - undefined = 字段未传,保持当前值不动
137
+ * 改值须伴新 evidence(同 weights/hard 的 P0 守卫);evidence 恰好一条且非空时
138
+ * 可省略 homeCityEvidence,多条 evidence 必须显式绑定。
139
+ */
140
+ homeCity?: string | null
141
+ /** homeCity 对应的本次用户原话;仅与 homeCity 一起传入。 */
142
+ homeCityEvidence?: string
105
143
  }
106
144
 
107
145
  interface WishPoolEntryInput {
@@ -130,6 +168,22 @@ function renderMotivationBrief(stateRoot: string): string {
130
168
  if (weights.length) lines.push(`- 动机权重: ${weights.map(([k, v]) => `${k}=${v}`).join(', ')}(证据 ${p.evidence?.length ?? 0} 条)`)
131
169
  const hard = Object.entries(p.hard ?? {})
132
170
  if (hard.length) lines.push(`- 硬约束: ${hard.map(([k, v]) => `${k}=${String(v)}`).join(', ')}`)
171
+ // 持久默认城市(issue #338):生产 brief 复用纯 resolver 的 precedence
172
+ // 契约;这里仍只负责读回渲染,不把 resolver 接入 feasibility/evaluate。
173
+ const profile = p as MergedProfile
174
+ const homeResolution = resolveDefaultOrigin(undefined, profile)
175
+ const homePref = profile.homeCityPreference
176
+ const typedPreferenceIsValid = isValidHomeCityPreference(profile)
177
+ const hasHomeCityData = Object.prototype.hasOwnProperty.call(profile, 'homeCity')
178
+ || Object.prototype.hasOwnProperty.call(profile, 'homeCityPreference')
179
+ if (homeResolution.source === 'home_default' && homeResolution.origin && typedPreferenceIsValid && homePref) {
180
+ const evidence = homeResolution.homeCityEvidence?.[0] ?? ''
181
+ lines.push(`- 常驻城市: ${homeResolution.origin}(原话证据:${evidence};记录于 ${homePref.updated_at})——本轮未指明出发地时作为软默认;本轮显式出发地优先;不进可行性硬约束,不删候选`)
182
+ } else if (typedPreferenceIsValid && homePref?.value === null) {
183
+ lines.push(`- 常驻城市: 已显式清除(原话证据:${homePref.evidence};记录于 ${homePref.updated_at})——已无持久软默认;每次行程必须由用户明确出发地,不猜测`)
184
+ } else if (hasHomeCityData) {
185
+ lines.push('- 常驻城市: 无可验证的持久默认——本轮请明确出发地;不从旧画像或全局 evidence 猜测')
186
+ }
133
187
  // 旅行时间线(memory-design P1):去过的地方不再主动推荐,除非用户点名
134
188
  const trips = readTimelineTrips(stateRoot)
135
189
  if (trips.length) {
@@ -142,10 +196,11 @@ function renderMotivationBrief(stateRoot: string): string {
142
196
  }
143
197
  lines.push(`- 愿望池: 用 gotry_wish_pool_list 按条件召回(0..1),勿直接堆砌`)
144
198
  if (p.updated_at) lines.push(`- 更新于: ${p.updated_at}`)
145
- return weights.length || hard.length ? lines.join('\n') : ''
199
+ return weights.length || hard.length || hasHomeCityData ? lines.join('\n') : ''
146
200
  }
147
201
 
148
- /** 时间线摘要(brief 用):最近 3 次行程(目的地+年月);账本优先,未迁移回退文件 */
202
+ /**
203
+ * 时间线摘要(brief 用):最近 3 次行程(目的地+年月);账本优先,未迁移回退文件 */
149
204
  /** 同行人摘要(brief 用):label + 约束串 */
150
205
  function readCompanions(stateRoot: string): Array<{ label: string; brief: string }> {
151
206
  return readCompanionsWithFallback(stateRoot)
@@ -184,7 +239,62 @@ function benchmarkPinnedPolicy(): { softMs: number; hardMs: number; exit: 'conve
184
239
  return { softMs, hardMs, exit: 'converge' }
185
240
  }
186
241
 
187
- export function apply(ctx: Context, config: Config): void {
242
+ /** hotel-date-gate.message 里的模型转手措辞(请告诉用户 / 请告诉 gotry /
243
+ * 请向用户确认)归一为直接对用户措辞(请补充 / 请提供)。仅做这一类短语的字面
244
+ * 替换,日期示例等可行动细节保留不动;只服务 hotel_search input_required 卡
245
+ * 单点,不动 date-gate 契约、不动其它 presentResult 路径。 */
246
+ function normalizeHotelGateHandoff(message: unknown): string {
247
+ const raw = String(message ?? '')
248
+ if (!raw) return ''
249
+ return raw
250
+ .replace(/请告诉\s*(?:用户|gotry)\s*/g, '请补充 ')
251
+ .replace(/请向用户确认/g, '请提供')
252
+ }
253
+
254
+ export interface ApplyTestSeams {
255
+ doctor?: {
256
+ check?: typeof runDoctorChecks
257
+ repair?: DoctorRepairOptions
258
+ }
259
+ /** Isolated test-only effect interpreter; production keeps interpretEffect. */
260
+ effect?: typeof interpretEffect
261
+ /** Test-only clock injection; production derives the reference date from Date. */
262
+ clock?: () => Date
263
+ /** Isolated public map-tool result seam; production uses nested dsh dispatch. */
264
+ groundTransfer?: { provider?: GroundTransferProvider }
265
+ }
266
+
267
+ function parseFeasibilityPlanning(raw: unknown, anchor: ReturnType<typeof buildTimeAnchor>): PlanningWindow | null {
268
+ if (raw === undefined) return null
269
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('planning must be an object')
270
+ const planning = raw as Record<string, unknown>
271
+ const intent = planning['intent']
272
+ const requestedYear = planning['requested_year']
273
+ if (intent !== 'future' && intent !== 'historical') {
274
+ throw new Error('planning.intent must be future or historical')
275
+ }
276
+ if (!Number.isInteger(requestedYear) || Number(requestedYear) < 1_000 || Number(requestedYear) > 9_999) {
277
+ throw new Error('planning.requested_year must be a four-digit integer')
278
+ }
279
+ // referenceDate is always the host clock; the payload cannot supply a stale now.
280
+ return { intent, requestedYear: Number(requestedYear), referenceDate: anchor.today }
281
+ }
282
+
283
+ function feasibilityValidationFailure(
284
+ code: FeasibilityValidationFailure['code'],
285
+ summary: string,
286
+ rejected?: FeasibilityValidationFailure['rejected'],
287
+ ): FeasibilityValidationFailure {
288
+ return { ok: false, code, summary, rejected: rejected ?? [] }
289
+ }
290
+
291
+ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}): void {
292
+ const clock = seams.clock ?? (() => new Date())
293
+ const runFlyaiEffect = seams.effect ?? interpretEffect
294
+ const groundTransferResolver = createGroundTransferResolver({
295
+ provider: seams.groundTransfer?.provider ?? createPublicMapDrivingRouteProvider(ctx),
296
+ clock,
297
+ })
188
298
  const rawBenchmarkEnvironmentConfigPath = config.benchmarkEnvironmentConfigPath ?? ''
189
299
  // ADR-24 v2:产品路径装「路由 + wall-clock 双出口」——用户主观时间是唯一
190
300
  // 预算,复杂度决定出口结构(converge/handoff)。benchmark opt-in 钉死
@@ -239,7 +349,7 @@ export function apply(ctx: Context, config: Config): void {
239
349
  sp?.variable?.('motivation_brief', () => renderMotivationBrief(config.stateRoot ?? '.'))
240
350
 
241
351
  // 通道路由卡(通道注册表生成,docs/design/tool-orchestration-design.md §2.1/D-8):
242
- // persona (19) 只留行为契约,机/火/酒通道顺位与额度口径查卡——prose 教义
352
+ // persona 检索条款只留行为契约,机/火/酒通道顺位与额度口径查卡——prose 教义
243
353
  // 变查表教义,注册表加通道卡片自动一致。
244
354
  sp?.variable?.('channel_routing_card', () => renderRoutingCard())
245
355
 
@@ -266,14 +376,29 @@ export function apply(ctx: Context, config: Config): void {
266
376
  // (headless 无用户在场)一律 fail-closed 拒绝。sessionAccess: ask(默认)|allow|off。
267
377
  // v1 教训(2026-08-29 founder 实测「每次都要弹,经常无法点击」):逐调用弹卡 = 骚扰,
268
378
  // 会话态收归 capabilities/session-consent.ts。防御:极简宿主/mock ctx 无事件总线时跳过。
379
+ // issue #194 A-轨道 guard 先注册;生产 Cordis 的 prepend 保证它在授权闸
380
+ // 前运行,smoke 的轻量事件采集器也继续把授权闸作为最后一个观察项。
381
+ installSubagentJobIdGuard(ctx)
269
382
  const ctxOn = (ctx as unknown as { on?: unknown }).on
270
383
  if (typeof ctxOn === 'function') {
271
- ctx.on('tools/pre-execute', createConsentGate({
384
+ const gate = createConsentGate({
272
385
  access: () => config.sessionAccess ?? 'ask',
273
386
  approval: approvalFromContext(ctx),
274
- }))
387
+ })
388
+ // site 绑定(#308):dsh pre-execute 实际派发的 arguments 原样进入 gate;
389
+ // gate 与 execute 共用 resolveSessionSearchKind/interpretArgs,因此 wrapped
390
+ // query.*、缺省 flight、unknown/malformed 的选择与授权和效应路径保持一致。
391
+ ctx.on('tools/pre-execute', gate)
275
392
  }
276
393
 
394
+ // issue #284 修复闸:gotry_doctor.action='repair' 走同一审批缝(scope-keyed,
395
+ // WeakMap<agent>)——同 agent + 同 scope 复用批准,不同 scope 重问,rejected/cancelled
396
+ // = 本会话吊销,unavailable 通道不记。同口径 session-consent,不增新持久面。
397
+ // 注册早于 gotry_doctor(下方),保证工具面闭包捕获当前会话的闸实例。
398
+ const repairApprovalGate = createRepairApprovalGate({
399
+ approval: approvalFromContext(ctx),
400
+ })
401
+
277
402
  // LLM_MODEL → dsh 会话面模型覆盖(issue #77;机制与分层见
278
403
  // capabilities/model-override.ts 头注)。GOTRY_LLM_MODEL 未设时零行为变化。
279
404
  installModelOverride(ctx as unknown as Parameters<typeof installModelOverride>[0])
@@ -315,7 +440,90 @@ export function apply(ctx: Context, config: Config): void {
315
440
  required: true,
316
441
  properties: {
317
442
  request: { type: 'object', additionalProperties: true, required: true, description: '引擎请求:{ motivation weights, hard constraints, window, budget, home hubs }' },
318
- candidates: { type: 'array', items: { type: 'object', additionalProperties: true }, required: true, description: '候选列表:[{ id, label, services, transfers, stay, minDays }]' },
443
+ candidates: { type: 'array', items: { type: 'object', additionalProperties: true }, required: true, description: '候选列表:[{ id, label, date?, services, transfers, stay, minDays }]' },
444
+ planning: {
445
+ type: 'object', additionalProperties: false,
446
+ description: '可选显式规划上下文;dated 候选省略时默认按宿主时钟作 future 下界过滤,指定 future 年份再加年末上界,historical 仅用于明确历史/回测查询;完全 dateless 输入保持旧可行性计算',
447
+ properties: {
448
+ intent: { type: 'string', enum: ['future', 'historical'], required: true },
449
+ requested_year: { type: 'integer', required: true, description: '用户明确请求的四位年份,不传宿主当前年份' },
450
+ },
451
+ },
452
+ ground_transfer: {
453
+ type: 'object',
454
+ additionalProperties: false,
455
+ description: '可选显式地面接驳覆盖:仅 destination 位置、显式经纬度和 mode=driving;路线同时绑定抵达方向(A→B)与返程方向(B→A),两方向分别请求/缓存/降级,价格仍来自静态包;缓存 TTL 由能力层固定',
456
+ properties: {
457
+ candidate_id: { type: 'string', description: '要绑定的候选 id;不会按相似度推断' },
458
+ transfer_index: { type: 'integer', description: 'dest_transfers 的零基位置;不会按 mode 推断' },
459
+ position: { type: 'string', description: '当前切片固定为 destination' },
460
+ mode: { type: 'string', description: '当前只接受 driving;其它/缺省模式显式回退静态分钟' },
461
+ origin: {
462
+ type: 'object',
463
+ additionalProperties: false,
464
+ description: '旧版单对坐标:等同于 outbound.origin(能力层自动把返程方向设为其互换)',
465
+ properties: {
466
+ longitude: { type: 'number' },
467
+ latitude: { type: 'number' },
468
+ },
469
+ },
470
+ destination: {
471
+ type: 'object',
472
+ additionalProperties: false,
473
+ description: '旧版单对坐标:等同于 outbound.destination',
474
+ properties: {
475
+ longitude: { type: 'number' },
476
+ latitude: { type: 'number' },
477
+ },
478
+ },
479
+ outbound: {
480
+ type: 'object',
481
+ additionalProperties: false,
482
+ description: '显式抵达方向坐标对(A→B):不指定时回退 origin/destination',
483
+ properties: {
484
+ origin: {
485
+ type: 'object',
486
+ additionalProperties: false,
487
+ properties: {
488
+ longitude: { type: 'number' },
489
+ latitude: { type: 'number' },
490
+ },
491
+ },
492
+ destination: {
493
+ type: 'object',
494
+ additionalProperties: false,
495
+ properties: {
496
+ longitude: { type: 'number' },
497
+ latitude: { type: 'number' },
498
+ },
499
+ },
500
+ },
501
+ },
502
+ return: {
503
+ type: 'object',
504
+ additionalProperties: false,
505
+ description: '显式返程方向坐标对(B→A):不指定时回退 outbound 互换',
506
+ properties: {
507
+ origin: {
508
+ type: 'object',
509
+ additionalProperties: false,
510
+ properties: {
511
+ longitude: { type: 'number' },
512
+ latitude: { type: 'number' },
513
+ },
514
+ },
515
+ destination: {
516
+ type: 'object',
517
+ additionalProperties: false,
518
+ properties: {
519
+ longitude: { type: 'number' },
520
+ latitude: { type: 'number' },
521
+ },
522
+ },
523
+ },
524
+ },
525
+ },
526
+ },
319
527
  },
320
528
  },
321
529
  },
@@ -331,10 +539,38 @@ export function apply(ctx: Context, config: Config): void {
331
539
  // 路径——候选形态枚举求解,~6ms/次)。unified 内部有 try-catch 护栏覆盖 wasm 异常。
332
540
  const started = Date.now()
333
541
  const payload = args.payload as Record<string, unknown>
542
+ const anchor = buildTimeAnchor(clock())
543
+ let planning: PlanningWindow | null
544
+ try {
545
+ planning = parseFeasibilityPlanning(payload['planning'], anchor)
546
+ } catch (error) {
547
+ const summary = error instanceof Error ? error.message : 'planning context is invalid'
548
+ return feasibilityValidationFailure('invalid_planning_context', summary)
549
+ }
334
550
  const req = parseRequest(payload['request'] as Record<string, unknown>)
335
551
  const cands = (payload['candidates'] as Record<string, unknown>[]).map(parseCandidate)
336
- const spec = segmentsFromCandidate(req, cands)
337
- const result = solveChoiceSegment(spec, req) as Record<string, unknown>
552
+ const execution = _exec && typeof _exec === 'object' ? _exec as ToolRunContext : undefined
553
+ let solverCandidates = cands
554
+ let groundTransferEvidence: GroundTransferResolution | undefined
555
+ if (payload['ground_transfer'] !== undefined) {
556
+ const prepared = await resolveGroundTransferPayload(
557
+ payload['ground_transfer'],
558
+ cands,
559
+ groundTransferResolver,
560
+ { signal: execution?.signal, execution },
561
+ )
562
+ solverCandidates = prepared.candidates
563
+ groundTransferEvidence = prepared.resolution
564
+ }
565
+ const spec = segmentsFromCandidate(req, solverCandidates)
566
+ const planningCheck = applyPlanningWindow(spec, planning, anchor, { defaultDatedFuture: true })
567
+ if (planningCheck.error) {
568
+ return feasibilityValidationFailure('planning_window_rejected', planningCheck.error, planningCheck.rejected)
569
+ }
570
+ const solved = solveChoiceSegment(planningCheck.spec, req) as Record<string, unknown>
571
+ const result = groundTransferEvidence
572
+ ? exposeGroundTransferEvidence(solved, groundTransferEvidence)
573
+ : solved
338
574
  const dir = await ensureStateDir(config.stateRoot)
339
575
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'feasibility_check:in-process-unified').catch(() => {})
340
576
  return { ok: true, ...result, latency_ms: Date.now() - started, via: 'in-process-unified' }
@@ -371,6 +607,9 @@ export function apply(ctx: Context, config: Config): void {
371
607
  + 'existing history is never deleted. Requires evidence on every call (P0 anti-fabrication rule).',
372
608
  // D-30 第五刀(issue #112):profile blob → 结构化闭合对象;evidence(P0 反伪造红线)进嵌套
373
609
  // schema 由宿主权闸——无证据的保存请求入口即被结构化拒绝(ToolFailure 形状)
610
+ // issue #338:homeCity 字段(string=设置,null=显式清除,undefined=不动);单条非空
611
+ // evidence 可省略 binding,多条 evidence 必须显式 homeCityEvidence。
612
+ // dsh 参数 schema 仅支持单值 type,string|null 用 oneOf 表达。
374
613
  parameters: {
375
614
  profile: {
376
615
  type: 'object',
@@ -380,6 +619,17 @@ export function apply(ctx: Context, config: Config): void {
380
619
  weights: { type: 'object', additionalProperties: true, description: '动机权重增量,如 { escape_rest: 0.7 }(可选,权重变更须伴新证据)' },
381
620
  evidence: { type: 'array', items: { type: 'string' }, required: true, description: '本轮新事实的用户原话数组(P0:必带)' },
382
621
  hard: { type: 'object', additionalProperties: true, description: '硬约束:{ wake_not_before, min_arrival_energy_pct }(可选)' },
622
+ homeCity: {
623
+ oneOf: [
624
+ { type: 'string', description: '设置持久常驻城市/默认出发地(用户原话指明常驻),如 "上海"' },
625
+ { type: 'null', description: '显式清除常驻城市(用于遗忘/迁居)' },
626
+ ],
627
+ description: '持久常驻城市/默认出发地(string=设置,null=显式清除);改值须伴新 evidence(P0);恰一条非空可省略 binding,多条须显式;不删候选/不压当轮 origin',
628
+ },
629
+ homeCityEvidence: {
630
+ type: 'string',
631
+ description: 'homeCity 对应的 exact 用户原话;显式传入时必须逐字出现在本次 profile.evidence 中且为新证据(同值同绑定重复调用例外为幂等);evidence 恰好一条非空时可省略并绑定该条,多条时必须显式传入',
632
+ },
383
633
  },
384
634
  },
385
635
  },
@@ -388,17 +638,48 @@ export function apply(ctx: Context, config: Config): void {
388
638
  schema: { type: 'json' },
389
639
  render: (_args, value) => [{ type: 'text', text: `动机画像已保存:${String((value as { path?: string }).path ?? '')}` }],
390
640
  },
391
- async execute(args: { profile: unknown }, _exec: unknown) {
641
+ async execute(args, _exec: unknown) {
392
642
  // T1 接线:本工具是增量补丁语义(契约 18——模型每轮把对话新事实带进来),
393
643
  // 经 mergeProfile 守门合并进既有画像(追加不删史/幂等/权重变更须伴新证据),
394
644
  // 不再整档覆盖。首次调用(无档案)= 全量建立。
395
645
  // ADR-15:守门+事件+投影在账本单事务内完成,evidence 红线拒绝即回滚,账本无痕。
396
- const incoming = (args.profile ?? {}) as { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown> }
646
+ // issue #338:homeCity 同走 mergeProfile 守门(null=显式清除)。单条非空
647
+ // evidence 可提供省略的 unambiguous binding;多条 evidence 不按数组位置推断。
648
+ const incoming = (args.profile ?? {}) as { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown>; homeCity?: string | null; homeCityEvidence?: string }
397
649
  if (!incoming.evidence?.length) {
398
650
  throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)')
399
651
  }
652
+ // issue #338:trim 后空白拒绝(零事件落账)。空白字符串「无值」≠
653
+ // 「显式清除」——后者是 null,前者是没有声明。不擅自解释,显式拒收。
654
+ if (typeof incoming.homeCity === 'string' && incoming.homeCity.trim().length === 0) {
655
+ throw new Error('gotry_motivation_save.homeCity 是空白字符串,既不是声明也不是清除;请明确传城市名或 null')
656
+ }
657
+ let boundHomeCityEvidence: string | undefined
658
+ if (incoming.homeCity !== undefined) {
659
+ if (incoming.homeCityEvidence !== undefined) {
660
+ if (typeof incoming.homeCityEvidence !== 'string' || incoming.homeCityEvidence.trim().length === 0) {
661
+ throw new Error('gotry_motivation_save.homeCityEvidence 为空白;homeCity 必须绑定本次 evidence 中的一条 exact 用户原话')
662
+ }
663
+ if (!incoming.evidence.some(e => e === incoming.homeCityEvidence)) {
664
+ throw new Error('gotry_motivation_save.homeCityEvidence 必须逐字匹配本次 profile.evidence 中的一条用户原话')
665
+ }
666
+ boundHomeCityEvidence = incoming.homeCityEvidence
667
+ } else if (incoming.evidence.length === 1 && typeof incoming.evidence[0] === 'string' && incoming.evidence[0].trim().length > 0) {
668
+ boundHomeCityEvidence = incoming.evidence[0]
669
+ } else {
670
+ throw new Error('gotry_motivation_save.homeCityEvidence 缺失或有歧义;evidence 恰好一条非空时可省略,多条 evidence 必须显式绑定 exact 用户原话')
671
+ }
672
+ } else if (incoming.homeCityEvidence !== undefined) {
673
+ throw new Error('gotry_motivation_save.homeCityEvidence 只能与 homeCity 一起传入')
674
+ }
400
675
  const ledger = ensureLedger(config.stateRoot)
401
- const res = ledger.appendMotivationPatch({ weights: incoming.weights, evidence: incoming.evidence, hard: incoming.hard })
676
+ const res = ledger.appendMotivationPatch({
677
+ weights: incoming.weights,
678
+ evidence: incoming.evidence,
679
+ hard: incoming.hard,
680
+ homeCity: incoming.homeCity,
681
+ homeCityEvidence: boundHomeCityEvidence,
682
+ } as ProfilePatch)
402
683
  const profileJson = JSON.parse(JSON.stringify(res.profile)) as JsonObject
403
684
  return res.saved
404
685
  ? { ok: true, saved: true, path: ledger.dbPath, profile: profileJson, summary: '画像已合并入账本(单事务)' }
@@ -651,14 +932,18 @@ export function apply(ctx: Context, config: Config): void {
651
932
  description: routed('gotry_hotel_search',
652
933
  'Search hotels via hotelbyte-cli (real-time when hbcli credentials exist, falls back to the static pack with explicit evidence tagging). '
653
934
  + 'Input: destination city name + optional dates/adults. Dates accept verbatim natural expressions (下周五 / 8.20 / 下周五+3) — '
654
- + 'the code layer resolves them against the time anchor; unresolved expressions degrade to an undated search with an explicit '
655
- + 'date_notes entry instead of guessing. Output: hotel list with evidence chain ([realtime-API:hbcli] + fetch timestamp, '
935
+ + 'the code layer resolves them against the time anchor. **Dates are required for a supplier search** (issue #283): missing, blank, '
936
+ + 'single-side, unresolvable, or checkOut checkIn returns an actionable input_required result WITHOUT spawning the vendor CLI; '
937
+ + 'only after a complete valid stay window is fixed do we forward to hbcli. '
938
+ + 'Output: hotel list with evidence chain ([realtime-API:hbcli] + fetch timestamp, '
656
939
  + 'or [static-pack:estimate]) per the L4 invariant.'),
657
940
  // D-30 第二刀(issue #112):query blob → 平铺 typed 契约(同 flyai 刀法)。
658
941
  parameters: {
659
942
  destination: { type: 'string', required: true, description: '目的地城市,如 大理' },
660
- checkIn: { type: 'string', description: '入住日:YYYY-MM-DD 或自然表达(下周五/8.20/下周五+3);解析不了降级无日期检索并记 date_notes' },
661
- checkOut: { type: 'string', description: 'checkOut 退房日:YYYY-MM-DD 或自然表达,形态同 checkIn' },
943
+ // issue #283 闸:日期字段对模型面仍是契约必填(required 标在 description,schema 不强制,
944
+ // gate 产出更可行动的错误——dsh schema 拒绝信息太泛)
945
+ checkIn: { type: 'string', description: '入住日:YYYY-MM-DD 或自然表达(下周五/8.20/下周五+3);REQUIRED——缺失/空白/无法解析由 date-gate 返回 input_required,不查' },
946
+ checkOut: { type: 'string', description: 'checkOut 退房日:YYYY-MM-DD 或自然表达,形态同 checkIn;REQUIRED——必须晚于入住日,否则不查' },
662
947
  adults: { type: 'integer', description: '成人数(顶层字段,默认 2)' },
663
948
  },
664
949
  output: {
@@ -670,25 +955,37 @@ export function apply(ctx: Context, config: Config): void {
670
955
  if (!q.destination) throw new Error('gotry_hotel_search requires destination')
671
956
  const started = Date.now()
672
957
  const fallbackPath = join(import.meta.dirname, '..', '..', 'data', 'hotels_2026.json')
673
- // D-10 切片 B:日期槽位接受逐字自然表达(下周五/8.20/+N),代码层换算(slot-spec);
674
- // unresolved 不猜——降级为无日期搜索并显式记 note,由模型向用户追问
675
- const anchor = buildTimeAnchor(new Date())
676
- const dateNotes: string[] = []
677
- const resolveDate = (expr?: string): string | undefined => {
678
- if (!expr) return undefined
679
- const r = resolveSlotDate(expr, anchor)
680
- if (!r.date) {
681
- dateNotes.push(`日期未解析:${r.raw}——请向用户确认具体日期`)
682
- return undefined
683
- }
684
- if (r.raw !== r.date) dateNotes.push(`slot-resolved: ${r.raw} → ${r.date}`)
685
- return r.date
958
+ // issue #283 闸:完整、有效且退房晚于入住才能派发 hbcli;失败直接返回
959
+ // input_required,不发供应商命令,不把当前窗口查询当所需日期结果。
960
+ // 复用既有 time-anchor/slot-spec:词表内自然表达 绝对日期;
961
+ // 词表外 unresolved,闸失败,不再走「无日期降级」伪装默认窗口价。
962
+ const gate = evaluateHotelStayDates(q.checkIn, q.checkOut)
963
+ if (!gate.ok) {
964
+ // 闸失败:不调用 interpretEffect,不调 hbcli;延迟日志不写(no spawn)
965
+ // summary 用「需要 X 信息才能查询酒店」开头的可行动句式,presentResult 直接
966
+ // 渲成「酒店:<dest> 缺日期」行动卡(替代旧版「无结果」空卡)
967
+ const summary = `需要补充${(gate.missing ?? []).map(m => m === 'checkIn' ? '入住日' : '退房日').join('和')}才能查询酒店「${q.destination ?? ''}」:${gate.message}`
968
+ return {
969
+ ok: false,
970
+ verdict: gate.verdict,
971
+ reason: gate.reason,
972
+ missing: gate.missing,
973
+ raw: gate.raw,
974
+ destination: q.destination,
975
+ message: gate.message,
976
+ summary,
977
+ evidence: gate.evidence,
978
+ latency_ms: Date.now() - started,
979
+ } as never
686
980
  }
981
+ // 闸通过:绝对日期已就位;slot 解析过程产生的原话 → 绝对日期 注记回显
982
+ // (工具层 date_notes 与既有 D-10 切片 B 形态兼容)
983
+ const dateNotes = [...gate.notes]
687
984
  // 效应解译层(ADR-18):渠道选择/韧性(重试/熔断)在解译器,result 原样透传——
688
985
  // 断路拒绝时返回平铺失败面(不发起查询,不伪装成 miss)
689
986
  const itp = await interpretEffect({
690
987
  effect: 'HBCLI_HOTEL_SEARCH',
691
- params: { destination: q.destination, checkIn: resolveDate(q.checkIn), checkOut: resolveDate(q.checkOut), adults: q.adults, hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs, fallbackPath },
988
+ params: { destination: q.destination, checkIn: gate.checkIn, checkOut: gate.checkOut, adults: q.adults, hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs, fallbackPath },
692
989
  })
693
990
  if (!itp.result) return declinedObservation('HBCLI_HOTEL_SEARCH', itp.trace)
694
991
  const resp = itp.result
@@ -711,7 +1008,25 @@ export function apply(ctx: Context, config: Config): void {
711
1008
  },
712
1009
  presentCall: args => ({ card: 'generic', title: `酒店搜索:${args.destination ?? ''}`, kind: 'search', rawInput: args }),
713
1010
  presentResult: (_args, value) => {
714
- const r = value as { hotels?: unknown; via?: string; destination?: string; summary?: string }
1011
+ // 闸失败:渲染为「input_required」行动卡,引导模型向用户追问日期;
1012
+ // 不再退化为「无结果」空卡——闸拒绝的可行动语义必须显式进 UI。
1013
+ // UI 面禁用内部工程词(check_in_blank / 闸拒绝 / ISO 字符串),只露「入住日/退房日」
1014
+ // 与具体日期示例供模型追问。
1015
+ const r = value as { hotels?: unknown; via?: string; destination?: string; summary?: string; verdict?: string; reason?: string; missing?: string[]; message?: string }
1016
+ if (r.verdict === 'input_required') {
1017
+ const missingLabels = (r.missing ?? []).map(m => m === 'checkIn' ? '入住日' : '退房日')
1018
+ const fieldText = missingLabels.length ? missingLabels.join('和') : '入住日和退房日'
1019
+ const dateHint = !missingLabels.length || missingLabels.length === 2
1020
+ ? '例如 2026-09-18 / 2026-09-20'
1021
+ : missingLabels[0] === '入住日' ? '例如 2026-09-18' : '例如 2026-09-20'
1022
+ return {
1023
+ card: 'generic',
1024
+ title: `酒店:${r.destination ?? ''} 需要${fieldText}`,
1025
+ // P2 措辞修正:直接对用户说话的句式,不再绕一道「请告诉.../请向用户确认」转给模型;
1026
+ // gate.message 里残留的字段指引也归一为直接对用户措辞,保留日期示例等可行动细节。
1027
+ content: [{ type: 'text', text: `请补充 ${fieldText}(${dateHint})后再次查询。\n${normalizeHotelGateHandoff(r.message)}` }],
1028
+ }
1029
+ }
715
1030
  const h = r.hotels
716
1031
  const liveCount = Array.isArray(h) ? h.length : 0
717
1032
  // 静态包是 {meta, stays} 对象而非数组(issue #24:此前计数恒 0 → UI 显示「无结果」)
@@ -915,7 +1230,7 @@ export function apply(ctx: Context, config: Config): void {
915
1230
  return { ok: false, summary: 'checkOut 需 YYYY-MM-DD(与 checkIn 成对)' } as const
916
1231
  }
917
1232
  }
918
- const itp = await interpretEffect({ effect: 'FLYAI_SEARCH', params: { kind: 'hotel', destName: dest, checkInDate: q.checkIn, checkOutDate: q.checkOut, keyWords: q.keyWords } })
1233
+ const itp = await runFlyaiEffect({ effect: 'FLYAI_SEARCH', params: { kind: 'hotel', destName: dest, checkInDate: q.checkIn, checkOutDate: q.checkOut, keyWords: q.keyWords } })
919
1234
  if (!itp.result) return declinedObservation('FLYAI_SEARCH', itp.trace)
920
1235
  const r = itp.result
921
1236
  await noteChannel('flyai', r.verdict)
@@ -948,7 +1263,7 @@ export function apply(ctx: Context, config: Config): void {
948
1263
  + `多为用户时间表达未带年份所致——向用户确认年份(或按未来最近的同月日修正)后再查。`,
949
1264
  })) as Record<string, never>
950
1265
  }
951
- const itp = await interpretEffect({ effect: 'FLYAI_SEARCH', params: { kind, origin: q.from, destination: q.to, depDate: q.date } })
1266
+ const itp = await runFlyaiEffect({ effect: 'FLYAI_SEARCH', params: { kind, origin: q.from, destination: q.to, depDate: q.date } })
952
1267
  if (!itp.result) return declinedObservation('FLYAI_SEARCH', itp.trace)
953
1268
  const r = itp.result
954
1269
  await noteChannel('flyai', r.verdict)
@@ -1039,6 +1354,7 @@ export function apply(ctx: Context, config: Config): void {
1039
1354
  + 'Transport: GoTry Session Bridge browser extension (one-time install) — the agent side never talks to Chrome debugging, ZERO system dialogs; read-only by construction (the extension never issues requests; it only passively forwards the site\'s own search responses; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
1040
1355
  + 'kind="flight": from/to 中文城市名 + date YYYY-MM-DD — sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. '
1041
1356
  + 'kind="hotel": to=目的地中文, cityId? = the numeric city= in a hotels.ctrip.com list URL (web-search it when the destination is outside the built-in city table), checkIn?/checkOut? (YYYY-MM-DD), adults?; hotel prices are the user\'s real logged-in prices. Evidence [会话:ctrip-hotel@ts]. '
1357
+ + 'kind="dida": Dida supplier-portal realtime hotel rates on the employee\'s own logged-in session (hotel-be portal integration line) — no from/to needed; the portal find page\'s own requests are sniffed passively. Rates carry ratePlanId/referenceNo for the server-side booking chain. Evidence [会话:dida-portal@ts]. '
1042
1358
  + 'kind="train": from/to/date(YYYY-MM-DD) + fromStationTelecode?/toStationTelecode? (three-letter codes in the kyfw query URL, for cities outside the built-in table) — 12306 left-ticket query (public face): train codes, times, durations, seat availability; the list API carries NO prices (prices live on the 12306 page). Evidence [会话:train-12306@ts]. '
1043
1359
  + 'verdict needs-login = call gotry_session_login (opens the Ctrip login entry in the user\'s own foreground tab — no terminal, no credentials through GoTry); '
1044
1360
  + `needs-extension = one-time browser-extension install (Chrome Web Store one-click, installUrl is also surfaced as a clickable link in the verdict field for dsh UI to render) — the DEFAULT transport; cdp (chrome://inspect remote debugging) is a diagnostic fallback only via GOTRY_SESSION_TRANSPORT=cdp. `
@@ -1049,7 +1365,7 @@ export function apply(ctx: Context, config: Config): void {
1049
1365
  // (docs/design/tool-orchestration-design.md §4③「interpretArgs 留作旧形态容忍层」),blob 调用在
1050
1366
  // execute 内归一后走原条件闸,结构化报错不崩。flyai 因 kind required 仍在宿主权即拒。
1051
1367
  parameters: {
1052
- kind: { type: 'string', enum: ['flight', 'hotel', 'train'], description: '默认 flight 机票;hotel 携程酒店(用户登录态真实价);train 12306 余票(公开面)' },
1368
+ kind: { type: 'string', enum: ['flight', 'hotel', 'train', 'dida'], description: '默认 flight 机票;hotel 携程酒店(用户登录态真实价);train 12306 余票(公开面);dida 供应商门户实时价(员工登录态)' },
1053
1369
  from: { type: 'string', description: '出发城市中文(词表内),如 上海——kind=flight|train 必填' },
1054
1370
  to: { type: 'string', description: '到达城市中文——kind=flight|train 必填;kind=hotel 时为目的地(必填)' },
1055
1371
  date: { type: 'string', description: '出发日期 YYYY-MM-DD——kind=flight|train 必填,须为今天或未来' },
@@ -1062,7 +1378,34 @@ export function apply(ctx: Context, config: Config): void {
1062
1378
  },
1063
1379
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { summary?: string }).summary ?? JSON.stringify(v).slice(0, 600)) }] },
1064
1380
  async execute(args, _exec) {
1065
- const q = unwrapQuery<{ kind?: string; from?: string; to?: string; date?: string; cityId?: number | string; checkIn?: string; checkOut?: string; adults?: number; fromStationTelecode?: string; toStationTelecode?: string }>(args)
1381
+ const selectedKind = resolveSessionSearchKind(args)
1382
+ if (!selectedKind.ok) return { ok: false, summary: selectedKind.reason } as const
1383
+ const q = { ...unwrapQuery<{ kind?: string; from?: string; to?: string; date?: string; cityId?: number | string; checkIn?: string; checkOut?: string; adults?: number; fromStationTelecode?: string; toStationTelecode?: string }>(args), kind: selectedKind.kind }
1384
+ const runSessionEffect = seams.effect ?? interpretEffect
1385
+ // ---- Dida 供应商门户(2026-09-09 实装;hotel-be portal integration 迁移线)----
1386
+ if (q.kind === 'dida') {
1387
+ const itpD = await runSessionEffect({
1388
+ effect: 'SESSION_DIDA_SEARCH',
1389
+ params: {
1390
+ auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
1391
+ },
1392
+ })
1393
+ if (!itpD.result) return declinedObservation('SESSION_DIDA_SEARCH', itpD.trace)
1394
+ const rD = itpD.result
1395
+ await noteChannel('session:dida-portal', rD.verdict)
1396
+ const topD = (rD.rates ?? []).slice(0, 8).map(x => `${x.hotelName ?? x.hotelId ?? '?'} ${x.roomName ?? ''} ${x.ratePlanId ?? ''} ${x.currency ?? ''}${x.price} 库存${x.inventory ?? '?'}${x.referenceNo ? ` ref=${x.referenceNo.slice(0, 12)}…` : ''}`)
1397
+ const summaryD = rD.verdict === 'hit'
1398
+ ? `Dida 门户实时价(员工本人登录态,被动嗅探)前 ${topD.length} 条(预订由服务端适配器凭 referenceNo 续链,gotry 不碰下单):\n${topD.join('\n')}\n${rD.evidence}`
1399
+ : rD.verdict === 'cooldown'
1400
+ ? `会话检索节律闸冷却中(两次会话检索需 ≥30s 间隔)——稍候重试或先用其他工具推进。${rD.evidence}`
1401
+ : rD.verdict === 'needs-login'
1402
+ ? `${rD.error ?? ''} ${rD.evidence}`
1403
+ : `Dida 门户会话检索未取回(${rD.verdict}):${rD.error ?? ''} ${rD.evidence}`
1404
+ return JSON.parse(JSON.stringify({
1405
+ ...rD, summary: summaryD,
1406
+ ...(rD.verdict !== 'hit' ? routingField('search-hotel', 'session:dida-portal') : {}),
1407
+ })) as Record<string, never>
1408
+ }
1066
1409
  // ---- 会话酒店(2026-09-03 实装;2026-09-02 迪拜 session:用户要携程找酒店,会话面却只有机票)----
1067
1410
  // ---- 会话火车(2026-09-03 实装;12306 公开查询面,无登录闸)----
1068
1411
  if (q.kind === 'train') {
@@ -1077,7 +1420,7 @@ export function apply(ctx: Context, config: Config): void {
1077
1420
  summary: `未发起查询:日期 ${q.date} 已是过去(今天 ${todayT}),过去不存在在售火车票。向用户确认日期后再查。`,
1078
1421
  })) as Record<string, never>
1079
1422
  }
1080
- const itpT = await interpretEffect({
1423
+ const itpT = await runSessionEffect({
1081
1424
  effect: 'SESSION_TRAIN_SEARCH',
1082
1425
  params: {
1083
1426
  from: q.from, to: q.to, date: q.date,
@@ -1088,7 +1431,11 @@ export function apply(ctx: Context, config: Config): void {
1088
1431
  if (!itpT.result) return declinedObservation('SESSION_TRAIN_SEARCH', itpT.trace)
1089
1432
  const rT = itpT.result
1090
1433
  await noteChannel('session:12306-train', rT.verdict)
1091
- const topT = (rT.trains ?? []).slice(0, 10).map(t => `${t.trainCode} ${t.depTime}→${t.arrTime} 历时${Math.round(t.durationMin / 60 * 10) / 10}h ${t.canWebBuy === 'Y' ? '可订' : t.canWebBuy}${Object.entries(t.seats).filter(([, v]) => v && v !== '--' && v !== '无').slice(0, 3).map(([k, v]) => `${k}:${v}`).join(' ')}`)
1434
+ // #355:only the invocation-produced binding may enter the fact log;
1435
+ // malformed/transport/challenge states remain no-fact, and Y alone is
1436
+ // not rendered as bookable without a recognized available seat token.
1437
+ await appendFacts(config.stateRoot ?? '.', factsFromSessionTrain({ from: q.from, to: q.to, date: q.date }, rT, clock()))
1438
+ const topT = (rT.trains ?? []).slice(0, 10).map(t => `${t.trainCode} ${t.depTime}→${t.arrTime} 历时${Math.round(t.durationMin / 60 * 10) / 10}h ${t.canWebBuy === 'Y' && hasRecognizedAvailableSeat(t) ? '可订' : '余票待确认'}${Object.entries(t.seats).filter(([, v]) => v && v !== '--' && v !== '无').slice(0, 3).map(([k, v]) => `${k}:${v}`).join(' ')}`)
1092
1439
  const summaryT = rT.verdict === 'hit'
1093
1440
  ? `${q.from}→${q.to} ${q.date} 余票(12306 公开查询面,${(rT.trains ?? []).length} 趟)前 ${topT.length} 条(列表接口不含票价,票价以 12306 落地页为准):\n${topT.join('\n')}\n${rT.evidence}`
1094
1441
  : rT.verdict === 'cooldown'
@@ -1103,7 +1450,7 @@ export function apply(ctx: Context, config: Config): void {
1103
1450
  if (!q.to) {
1104
1451
  return { ok: false, summary: 'kind=hotel 需要 to(目的地中文;城市码表外带 cityId=携程酒店 list 页 URL 里的 city= 数字)' } as const
1105
1452
  }
1106
- const itp = await interpretEffect({
1453
+ const itp = await runSessionEffect({
1107
1454
  effect: 'SESSION_HOTEL_SEARCH',
1108
1455
  params: {
1109
1456
  to: q.to, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
@@ -1136,7 +1483,7 @@ export function apply(ctx: Context, config: Config): void {
1136
1483
  }
1137
1484
  // 效应解译层(ADR-18):SESSION 通道策略=永不重试/不熔断,节律闸在渠道内;
1138
1485
  // 解译器只做分发与证据拼装,verdict 语义(risk 型 needs-login/challenged)原样透传
1139
- const itp = await interpretEffect({
1486
+ const itp = await runSessionEffect({
1140
1487
  effect: 'SESSION_FLIGHT_SEARCH',
1141
1488
  params: {
1142
1489
  from: q.from, to: q.to, date: q.date,
@@ -1160,12 +1507,17 @@ export function apply(ctx: Context, config: Config): void {
1160
1507
  })) as Record<string, never>
1161
1508
  },
1162
1509
  presentCall: args => {
1163
- const callTitle = args.kind === 'hotel' ? `会话酒店:${args.to ?? ''}` : args.kind === 'train' ? `会话火车:${args.from ?? ''}` : `会话检索:${args.from ?? ''}`
1510
+ const selected = resolveSessionSearchKind(args)
1511
+ const kind = selected.ok ? selected.kind : undefined
1512
+ const raw = args as { to?: unknown; from?: unknown }
1513
+ const callTitle = kind === 'hotel' ? `会话酒店:${raw.to ?? ''}` : kind === 'train' ? `会话火车:${raw.from ?? ''}` : kind === 'dida' ? '会话酒店:Dida 门户实时价' : `会话检索:${raw.from ?? ''}`
1164
1514
  return { card: 'generic', title: callTitle, kind: 'fetch', rawInput: args }
1165
1515
  },
1166
1516
  presentResult: (args, value) => {
1517
+ const selected = resolveSessionSearchKind(args)
1518
+ const kind = selected.ok ? selected.kind : undefined
1167
1519
  const r = value as { verdict?: string; options?: unknown[]; hotels?: unknown[]; installUrl?: string }
1168
- const isHotel = args.kind === 'hotel'
1520
+ const isHotel = kind === 'hotel'
1169
1521
  const n = Math.max((r.options ?? []).length, (r.hotels ?? []).length)
1170
1522
  const needsExt = r.verdict === 'needs-extension'
1171
1523
  const label = r.verdict === 'hit'
@@ -1177,7 +1529,7 @@ export function apply(ctx: Context, config: Config): void {
1177
1529
  : r.verdict ?? '降级'
1178
1530
  const content: Array<{ type: 'text'; text: string }> = [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }]
1179
1531
  if (needsExt && r.installUrl) content.push({ type: 'text', text: `安装链接:${r.installUrl}` })
1180
- const face = args.kind === 'hotel' ? '会话酒店' : args.kind === 'train' ? '会话火车' : '会话检索'
1532
+ const face = kind === 'hotel' ? '会话酒店' : kind === 'train' ? '会话火车' : kind === 'dida' ? '会话酒店:Dida' : '会话检索'
1181
1533
  return { card: 'generic', title: `${face}:${label}`, content }
1182
1534
  },
1183
1535
  }))
@@ -1397,7 +1749,7 @@ export function apply(ctx: Context, config: Config): void {
1397
1749
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'agent-reach:doctor').catch(() => {})
1398
1750
  const summary = st.via === 'agent-reach-cli'
1399
1751
  ? `Agent Reach doctor(上游 CLI,原样透传):\n${st.output}\n${st.evidence}`
1400
- : `Agent Reach 未装配(.venv 缺失)——可选依赖,机/酒/网页社媒读取之外的工具不受影响。\n补装:终端跑 npx gotry doctor --fix;整体依赖状态可调 gotry_doctor 工具查看。\n${st.evidence}`
1752
+ : `Agent Reach 未装配(.venv 缺失)——可选依赖,机/酒/网页社媒读取之外的工具不受影响。\n补装:终端跑 npx @danceiny/gotry doctor --fix;整体依赖状态可调 gotry_doctor 工具查看。\n${st.evidence}`
1401
1753
  return JSON.parse(JSON.stringify({
1402
1754
  ok: st.ok, via: st.via, output: st.output, evidence: st.evidence, summary,
1403
1755
  latency_ms: Date.now() - started,
@@ -1432,7 +1784,7 @@ export function apply(ctx: Context, config: Config): void {
1432
1784
  : r.verdict === 'needs-setup'
1433
1785
  ? `${q.channel}.${q.method} → 需配置(上游 check() 原话): ${r.setup ?? ''}\n${r.evidence}`
1434
1786
  : r.verdict === 'not-installed'
1435
- ? `${q.channel}.${q.method} → 上游未装配(可选依赖): ${r.setup ?? ''}\n整体依赖状态可调 gotry_doctor 查看;补装: npx gotry doctor --fix\n${r.evidence}`
1787
+ ? `${q.channel}.${q.method} → 上游未装配(可选依赖): ${r.setup ?? ''}\n整体依赖状态可调 gotry_doctor 查看;补装: npx @danceiny/gotry doctor --fix\n${r.evidence}`
1436
1788
  : `${q.channel}.${q.method} → ${r.error ?? 'error'}${r.inventory ? `\n上游清单: ${JSON.stringify(r.inventory).slice(0, 1200)}` : ''}\n${r.evidence}`
1437
1789
  return JSON.parse(JSON.stringify({
1438
1790
  ok: r.ok, channel: r.channel, method: q.method, verdict: r.verdict,
@@ -1460,13 +1812,19 @@ export function apply(ctx: Context, config: Config): void {
1460
1812
  registerGuarded(defineTool({
1461
1813
  name: 'gotry_doctor',
1462
1814
  description:
1463
- 'Check optional-dependency health for ALL gotry tools (read-only, never installs): GoTry Session Bridge extension / Agent Reach (.venv) / hbcli (hotel realtime) / FlyAI key + recent trial-quota exhaustion time / dsh-calendar mount state / dsh-better-sidebar. '
1815
+ 'Check optional-dependency health for ALL gotry tools (read-only by default). Coverage: GoTry Session Bridge extension / Agent Reach (.venv) / hbcli (hotel realtime) / FlyAI key + recent trial-quota exhaustion time / dsh-calendar mount state / dsh-better-sidebar / dsh-map-tools / dsh-tool-ask-user. '
1464
1816
  + 'Call this when ANY gotry tool returns not-installed / needs-setup, when the user asks 「体检/依赖状态/工具为什么不可用」, or BEFORE leaning on a channel for a plan. '
1465
1817
  + 'Returns per-item status (ok/degraded/missing) with exact fix commands. '
1466
- + 'Repair = `npx gotry doctor --fix` run BY THE USER in a terminal (this tool never installs anything); LLM keys are the dsh host\'s business and are deliberately out of scope. '
1818
+ + 'Action=diagnose (default): pure read-only never installs anything, only renders the report. '
1819
+ + 'Action=repair: optional autonomous repair — only attempts items the user named via `items` (stable doctor item ids, e.g. ["agent-reach","sidebar"]); empty/missing items means repair every auto-repairable gap. Items requiring browser-store install / credentials / API key / profile config / package reinstall / Node upgrade are NEVER auto-installed — they are reported as needs-user-action / unavailable and require the user to handle them. '
1820
+ + 'Repair flow is observable: diagnosis → scope-keyed plan (selected auto-repairable items + skipped) → runtime approval card (per agent, per scope; same agent+scope reuses approval, different scope re-asks) → execute installer → recheck → per-item installed/failed with retry command. '
1821
+ + 'Rejected/cancelled = no execution; unavailable approval channel = no execution (clear structured outcome). LLM keys are the dsh host\'s business and are deliberately out of scope. '
1467
1822
  + 'A markdown report is rendered for the workspace (gotry-state/doctor-report.md) so the sidebar workbench can preview it.',
1468
1823
  // D-30 第三刀(issue #112):query blob → 平铺 typed;全字段可选 → interpretArgs 容忍层
1824
+ // issue #284 增量:action + items 两参数;默认 diagnose 保持读-only 不变(向后兼容)
1469
1825
  parameters: {
1826
+ action: { type: 'string', enum: ['diagnose', 'repair'], description: '默认 diagnose;repair 时按 items 范围补装可选依赖,user-action / unavailable 项永不冒充 auto' },
1827
+ items: { type: 'array', items: { type: 'string', enum: ['node', 'extension', 'agent-reach', 'hbcli', 'flyai', 'sidebar', 'llm-key', 'calendar', 'map-tools', 'ask-user'] }, description: 'repair 时的 doctor item id 范围;空 = 全部 auto-repairable' },
1470
1828
  writeReport: { type: 'boolean', description: '默认 true,把 markdown 报告写进 gotry-state/doctor-report.md(侧栏工作台可预览)' },
1471
1829
  },
1472
1830
  output: {
@@ -1474,29 +1832,126 @@ export function apply(ctx: Context, config: Config): void {
1474
1832
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 900)) }],
1475
1833
  },
1476
1834
  async execute(args, _exec: unknown) {
1477
- const q = unwrapQuery<{ writeReport?: boolean }>(args, 'writeReport')
1478
- const report = await runDoctorChecks({ stateRoot: config.stateRoot ?? '.' })
1479
- // 报告落 gotry-state(侧栏工作台预览面);写失败不阻塞体检结论本身
1835
+ const q = unwrapQuery<{ action?: 'diagnose' | 'repair'; items?: string[]; writeReport?: boolean }>(args, 'action')
1836
+ const action = q?.action === 'repair' ? 'repair' : 'diagnose'
1837
+ const check = seams.doctor?.check ?? runDoctorChecks
1838
+ const report = await check({ stateRoot: config.stateRoot ?? '.' })
1839
+
1840
+ // repair 时必须把复检后的报告写盘,避免侧栏仍显示修复前状态。
1480
1841
  let reportPath: string | undefined
1481
- if (q?.writeReport !== false) {
1842
+ const writeReport = async (current: typeof report): Promise<void> => {
1843
+ if (q?.writeReport === false) return
1482
1844
  try {
1483
1845
  const { writeFile } = await import('node:fs/promises')
1484
1846
  const dir = await ensureStateDir(config.stateRoot)
1485
1847
  reportPath = join(dir, 'doctor-report.md')
1486
- await writeFile(reportPath, renderDoctorReportMd(report), 'utf-8')
1848
+ await writeFile(reportPath, renderDoctorReportMd(current), 'utf-8')
1487
1849
  } catch { reportPath = undefined }
1488
1850
  }
1489
1851
  const icon = (s: string) => (s === 'ok' ? '✅' : s === 'degraded' ? '⚠️' : '❌')
1490
- const lines = report.items.map(i => `${icon(i.status)} ${i.label}:${i.detail}${i.fix ? `\n ↳ 修复: ${i.fix}` : ''}`)
1491
- const summary = `${report.summary}\n${lines.join('\n')}${reportPath ? `\n报告已写: ${reportPath}(侧栏工作台可直接预览)` : '\n(报告写盘失败,仅本对话展示)'}`
1492
- return JSON.parse(JSON.stringify({ ok: true, verdict: report.ok ? 'all-clear' : 'needs-attention', items: report.items, report_path: reportPath, evidence: `[doctor@${new Date().toISOString()}]`, summary })) as Record<string, never>
1852
+ const reportLines = (current: typeof report) => current.items.map(i => `${icon(i.status)} ${i.label}:${i.detail}${i.fix ? `\n ↳ 修复: ${i.fix}` : ''}`)
1853
+
1854
+ if (action === 'diagnose') {
1855
+ await writeReport(report)
1856
+ const diagSummary = `${report.summary}\n${reportLines(report).join('\n')}${reportPath ? `\n报告已写: ${reportPath}(侧栏工作台可直接预览)` : '\n(报告写盘失败,仅本对话展示)'}`
1857
+ // 读-only 路径:保持向后兼容,模型拿到的 verdict/items 与 issue #284 之前一致
1858
+ return JSON.parse(JSON.stringify({
1859
+ ok: true,
1860
+ action: 'diagnose',
1861
+ verdict: report.ok ? 'all-clear' : 'needs-attention',
1862
+ items: report.items,
1863
+ report_path: reportPath,
1864
+ evidence: `[doctor@${new Date().toISOString()}]`,
1865
+ summary: diagSummary,
1866
+ })) as Record<string, never>
1867
+ }
1868
+
1869
+ // action='repair':从诊断 → 选定 auto-repairable → scope-keyed 审批 → 真执行(走 bootstrap.runOnboardingFix)→ 复检。
1870
+ // 范围过滤:items 非空时只考虑这些 id(未知 id 静默剔除,落 skipped 不冒充 auto)。
1871
+ // runDoctorRepair 内部懒加载 bin/gotry-bootstrap.js,自适应 status→level 适配 + 真实安装器。
1872
+ const requestedItems = Array.isArray(q?.items) && q!.items!.length > 0 ? new Set(q!.items!) : null
1873
+ const scopedReport = requestedItems
1874
+ ? { ...report, items: report.items.filter((i) => requestedItems.has(i.id)) }
1875
+ : report
1876
+ const exec = _exec as { agent?: object; callId?: string; signal?: AbortSignal } | undefined
1877
+ // 生产 repair 让 bootstrap 安装编排复用本工具的同一诊断注册表做终态复检;
1878
+ // fixture 若显式提供 recheck,则保留其隔离实现,再单独取一次工具报告。
1879
+ let recheck: typeof report | undefined
1880
+ const injectedRepair = seams.doctor?.repair
1881
+ const repairOptions: DoctorRepairOptions = {
1882
+ ...injectedRepair,
1883
+ recheck: injectedRepair?.recheck ?? (async () => {
1884
+ recheck = await check({ stateRoot: config.stateRoot ?? '.' })
1885
+ return recheck.items.map((item) => ({
1886
+ label: item.label,
1887
+ level: item.status,
1888
+ detail: item.detail,
1889
+ fix: item.fix,
1890
+ }))
1891
+ }),
1892
+ }
1893
+ const repair = await runDoctorRepair(
1894
+ scopedReport,
1895
+ (scopeKey, reason) => repairApprovalGate.request(exec?.agent, scopeKey, reason, { callId: exec?.callId, signal: exec?.signal }),
1896
+ repairOptions,
1897
+ )
1898
+ if (repair.approval === 'granted' && !recheck) {
1899
+ recheck = await check({ stateRoot: config.stateRoot ?? '.' })
1900
+ }
1901
+ const finalReport = recheck ?? report
1902
+ await writeReport(finalReport)
1903
+ const diagSummary = `${report.summary}\n${reportLines(report).join('\n')}`
1904
+ // 整合诊断 + 修复双视角 summary(模型可分清哪段是体检、哪段是修复)
1905
+ const repairLines: string[] = []
1906
+ for (const r of repair.repairs) {
1907
+ repairLines.push(`${r.status === 'installed' ? '✅' : '❌'} ${r.label}: ${r.reason}${r.nextAction ? ` → ${r.nextAction}` : ''}`)
1908
+ }
1909
+ for (const s of repair.skipped) {
1910
+ repairLines.push(`⏭ ${s.label}: ${s.reason}(bucket=${s.bucket})`)
1911
+ }
1912
+ const recheckSummary = recheck
1913
+ ? `\n\n[recheck] ${recheck.summary}\n${reportLines(recheck).join('\n')}`
1914
+ : ''
1915
+ const reportSummary = reportPath ? `\n报告已写: ${reportPath}(侧栏工作台可直接预览)` : '\n(报告写盘失败,仅本对话展示)'
1916
+ const combinedSummary = `${diagSummary}\n\n[repair] verdict=${repair.verdict}, approval=${repair.approval}, plan=${repair.selected.length} 项 auto / ${repair.skipped.length} 项 skipped\n${repairLines.join('\n')}\n${repair.summary}${recheckSummary}${reportSummary}`
1917
+ return JSON.parse(JSON.stringify({
1918
+ ok: repair.ok,
1919
+ action: 'repair',
1920
+ verdict: repair.verdict,
1921
+ diagnosis: report,
1922
+ recheck,
1923
+ plan: {
1924
+ selected: repair.selected,
1925
+ needs_user_action: repair.skipped.filter((s) => s.bucket === 'user-action').map((s) => ({ id: s.id, label: s.label, reason: s.reason })),
1926
+ unavailable: repair.skipped.filter((s) => s.bucket === 'unavailable').map((s) => ({ id: s.id, label: s.label, reason: s.reason })),
1927
+ scope_key: repair.scopeKey,
1928
+ },
1929
+ approval: repair.approval,
1930
+ repairs: repair.repairs,
1931
+ skipped: repair.skipped,
1932
+ evidence: `[doctor@${new Date().toISOString()}]`,
1933
+ summary: combinedSummary,
1934
+ report_path: reportPath,
1935
+ })) as Record<string, never>
1936
+ },
1937
+ presentCall: args => {
1938
+ const q = unwrapQuery<{ action?: string }>(args, 'action')
1939
+ const a = q?.action === 'repair'
1940
+ return { card: 'generic', title: a ? '🩺 依赖体检+修复(待批准)' : '🩺 依赖体检', kind: 'execute', rawInput: args }
1493
1941
  },
1494
- presentCall: _args => ({ card: 'generic', title: '🩺 依赖体检', kind: 'execute', rawInput: {} }),
1495
1942
  presentResult: (_args, value) => {
1496
- const r = value as { verdict?: string; summary?: string }
1943
+ const r = value as { verdict?: string; summary?: string; action?: string; approval?: string }
1944
+ const deniedLabel = r.approval === 'cancelled'
1945
+ ? '↩️ 已取消'
1946
+ : r.approval === 'unavailable'
1947
+ ? '⚠️ 无审批通道'
1948
+ : '🚫 已拒绝'
1949
+ const title = r.action === 'repair'
1950
+ ? `🩺 修复:${r.verdict === 'repaired' ? '✅ 已就位' : r.verdict === 'partial-repair' ? '⚠️ 部分成功' : r.verdict === 'repair-blocked' ? '❌ 复检未通过' : r.verdict === 'approval-denied' ? deniedLabel : '🔧 无可补装项'}`
1951
+ : `🩺 依赖体检:${r.verdict === 'all-clear' ? '✅ 全部就绪' : '🔧 有项待处理'}`
1497
1952
  return {
1498
1953
  card: 'generic',
1499
- title: `🩺 依赖体检:${r.verdict === 'all-clear' ? '✅ 全部就绪' : '🔧 有项待处理'}`,
1954
+ title,
1500
1955
  content: [{ type: 'text', text: String(r.summary ?? '') }],
1501
1956
  }
1502
1957
  },
@@ -1518,10 +1973,22 @@ export function apply(ctx: Context, config: Config): void {
1518
1973
  output: {
1519
1974
  schema: { type: 'json' },
1520
1975
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
1976
+ presentationMeta: (_args, value) => {
1977
+ const r = value as { artifacts?: Array<{ path?: string }>; total?: number; truncated?: boolean }
1978
+ const paths = Array.isArray(r.artifacts)
1979
+ ? r.artifacts.map(a => String(a.path ?? '')).filter(Boolean)
1980
+ : []
1981
+ return {
1982
+ shape: 'paths',
1983
+ paths,
1984
+ truncated: Boolean(r.truncated),
1985
+ total: r.total ?? paths.length,
1986
+ }
1987
+ },
1521
1988
  },
1522
- async execute(args, _exec: unknown) {
1989
+ async execute(args, exec: unknown) {
1523
1990
  const q = unwrapQuery<{ limit?: number }>(args, 'limit')
1524
- const r = await listArtifacts({ stateRoot: config.stateRoot ?? '.', limit: q.limit })
1991
+ const r = await listArtifacts({ stateRoot: config.stateRoot ?? '.', cwd: sessionCwd(exec), limit: q.limit })
1525
1992
  const lines = r.artifacts.map(a =>
1526
1993
  `- [${a.source}] ${a.title}${a.status ? `(${a.status})` : ''} — ${a.path}${a.updated ? ` @ ${a.updated.slice(0, 16).replace('T', ' ')}` : ''}`)
1527
1994
  const summary = r.artifacts.length
@@ -1529,13 +1996,23 @@ export function apply(ctx: Context, config: Config): void {
1529
1996
  : '无在册产物(异步深度规划交付与工作目录 md 文件都会出现在这里)'
1530
1997
  return JSON.parse(JSON.stringify({ ok: true, artifacts: r.artifacts, total: r.total, truncated: r.truncated, summary })) as Record<string, never>
1531
1998
  },
1532
- presentCall: () => ({ card: 'generic', title: '列出产物', kind: 'search' }),
1533
- presentResult: (_args, value) => {
1534
- const r = value as { summary?: string; total?: number }
1999
+ // Host presentationMeta remains persisted with the ToolResult for Host-side
2000
+ // consumers; the packaged public Client separately renders the runtime block
2001
+ // for these custom wire names in DSH Web.
2002
+ presentResult: (_args, result: ToolResult) => {
2003
+ if (result.isError || typeof result.meta !== 'object' || result.meta === null || Array.isArray(result.meta)) return undefined
2004
+ const meta = result.meta as { shape?: unknown; paths?: unknown; truncated?: unknown; total?: unknown }
2005
+ if (meta.shape !== 'paths' || !Array.isArray(meta.paths) || !meta.paths.every(p => typeof p === 'string') ||
2006
+ typeof meta.truncated !== 'boolean' || typeof meta.total !== 'number' || !Number.isInteger(meta.total) || meta.total < 0) {
2007
+ return undefined
2008
+ }
1535
2009
  return {
1536
- card: 'generic',
1537
- title: `产物:${r.total ?? 0} 项在册`,
1538
- content: [{ type: 'text', text: String(r.summary ?? '') }],
2010
+ card: 'search' as const,
2011
+ shape: 'paths' as const,
2012
+ title: `产物:${meta.total} 项在册${meta.truncated ? '(已截断)' : ''}`,
2013
+ paths: meta.paths,
2014
+ truncated: meta.truncated,
2015
+ total: meta.total,
1539
2016
  }
1540
2017
  },
1541
2018
  }))
@@ -1556,34 +2033,71 @@ export function apply(ctx: Context, config: Config): void {
1556
2033
  output: {
1557
2034
  schema: { type: 'json' },
1558
2035
  render: (_args, value) => [{ type: 'text', text: String((value as { content?: string }).content ?? JSON.stringify(value).slice(0, 600)) }],
2036
+ presentationMeta: (_args, value) => {
2037
+ const r = value as { path?: string; offset?: number; lines?: Array<{ number: number; text: string }>; totalLines?: number; lang?: string; source?: string; version?: string }
2038
+ return {
2039
+ path: String(r.path ?? ''),
2040
+ offset: r.offset ?? 1,
2041
+ lines: Array.isArray(r.lines) ? r.lines.map(line => ({ number: line.number, text: line.text })) : [],
2042
+ totalLines: r.totalLines ?? 0,
2043
+ ...(r.lang ? { lang: r.lang } : {}),
2044
+ ...(r.source ? { source: r.source } : {}),
2045
+ ...(r.version ? { version: r.version } : {}),
2046
+ }
2047
+ },
1559
2048
  },
1560
- async execute(args, _exec) {
2049
+ async execute(args, exec) {
1561
2050
  const q = args
1562
2051
  if (!q.path) throw new Error('gotry_artifacts_read requires path')
1563
2052
  if (!q.path) return JSON.parse(JSON.stringify({ ok: false, error: 'path 必填(来自 gotry_artifacts_list)' })) as Record<string, never>
1564
- const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', path: q.path, offset: q.offset, limit: q.limit })
2053
+ const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', cwd: sessionCwd(exec), path: q.path, offset: q.offset, limit: q.limit })
1565
2054
  if (!r.ok) return JSON.parse(JSON.stringify(r)) as Record<string, never>
2055
+ // 身份/来源展示(issue #285 第 1 条「显示身份/来源」):行号视图保留 read 卡的
2056
+ // path/offset/lines/totalLines/lang;fallback content 用 source label 前缀,
2057
+ // 让无法渲染 read 卡的 UI 也能区分「本会话选了哪个产物」,避免把旧摘要当新内容。
1566
2058
  return JSON.parse(JSON.stringify({
1567
2059
  ...r,
2060
+ source: r.source,
1568
2061
  summary: `${r.path}(${r.totalLines} 行)第 ${r.offset}-${r.offset + r.lines.length - 1} 行${r.windowed ? `(共 ${r.totalLines} 行,可翻页)` : ''}`,
1569
2062
  })) as Record<string, never>
1570
2063
  },
1571
- presentCall: args => ({ card: 'generic', title: `读产物:${args.path ?? ''}`, kind: 'read', rawInput: args }),
1572
- presentResult: (_args, value) => {
1573
- const r = value as unknown as { ok?: boolean; path?: string; offset?: number; lines?: Array<{ number: number; text: string }>; totalLines?: number; lang?: string; content?: string; error?: string }
1574
- if (!r.ok) {
1575
- return { card: 'generic', title: '读产物失败', content: [{ type: 'text', text: String(r.error ?? '') }] }
2064
+ // Keep the standard location payload for Host consumers. The Web client only
2065
+ // renders its built-in keyed tools, so this is not claimed as native custom-card E2E.
2066
+ presentCall: args => ({
2067
+ card: 'generic',
2068
+ title: `读产物:${args.path ?? ''}`,
2069
+ kind: 'read',
2070
+ rawInput: args,
2071
+ ...(args.path ? { locations: [{ path: String(args.path), line: 1 }] } : {}),
2072
+ }),
2073
+ presentResult: (_args, result: ToolResult) => {
2074
+ if (result.isError || typeof result.meta !== 'object' || result.meta === null || Array.isArray(result.meta)) return undefined
2075
+ const meta = result.meta as { path?: unknown; source?: unknown; offset?: unknown; lines?: unknown; totalLines?: unknown; lang?: unknown }
2076
+ if (typeof meta.path !== 'string' || meta.path === '' || typeof meta.offset !== 'number' || !Number.isInteger(meta.offset) || meta.offset < 1 ||
2077
+ !Array.isArray(meta.lines) || !meta.lines.every(line => typeof line === 'object' && line !== null && !Array.isArray(line) &&
2078
+ typeof (line as { number?: unknown }).number === 'number' && Number.isInteger((line as { number: number }).number) &&
2079
+ typeof (line as { text?: unknown }).text === 'string') ||
2080
+ typeof meta.totalLines !== 'number' || !Number.isInteger(meta.totalLines) || meta.totalLines < 0) {
2081
+ return undefined
1576
2082
  }
1577
- // dsh read 卡:UI 渲染为行号文件视图(issue #25 的「插件能力查看 artifacts」落点)
2083
+ const source = typeof meta.source === 'string' ? meta.source : 'unknown'
2084
+ const identityLine = `source: ${source} · path: ${meta.path} · ${meta.totalLines} lines`
2085
+ const rawContent = result.content
2086
+ .filter(block => block.type === 'text')
2087
+ .map(block => block.text)
2088
+ .join('\n')
1578
2089
  return {
1579
2090
  card: 'read' as const,
1580
- title: r.path?.split('/').pop() ?? r.path ?? '',
1581
- path: r.path ?? '',
1582
- offset: r.offset ?? 1,
1583
- lines: r.lines ?? [],
1584
- totalLines: r.totalLines ?? 0,
1585
- lang: r.lang,
1586
- content: [{ type: 'text', text: r.content ?? '' }],
2091
+ title: meta.path.split('/').pop() ?? meta.path,
2092
+ path: meta.path,
2093
+ offset: meta.offset,
2094
+ lines: meta.lines as Array<{ number: number; text: string }>,
2095
+ totalLines: meta.totalLines,
2096
+ ...(typeof meta.lang === 'string' ? { lang: meta.lang } : {}),
2097
+ content: [
2098
+ { type: 'text', text: identityLine },
2099
+ { type: 'text', text: rawContent },
2100
+ ],
1587
2101
  }
1588
2102
  },
1589
2103
  }))
@@ -1616,7 +2130,7 @@ export function apply(ctx: Context, config: Config): void {
1616
2130
  schema: { type: 'json' },
1617
2131
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
1618
2132
  },
1619
- async execute(args, _exec: unknown) {
2133
+ async execute(args, exec: unknown) {
1620
2134
  const q = unwrapQuery<{ markdown?: string; path?: string; tripYear?: number; itinerary?: Record<string, unknown> }>(args, 'markdown')
1621
2135
  const avMap = await loadAirlineAirportMap()
1622
2136
  if (!avMap) {
@@ -1624,14 +2138,19 @@ export function apply(ctx: Context, config: Config): void {
1624
2138
  }
1625
2139
  let markdown = q.markdown
1626
2140
  if (!markdown && q.path) {
1627
- const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', path: q.path })
2141
+ const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', cwd: sessionCwd(exec), path: q.path })
1628
2142
  if (!r.ok) return JSON.parse(JSON.stringify({ ok: false, summary: `产物读取失败:${String((r as { error?: string }).error ?? '')}` })) as Record<string, never>
1629
2143
  markdown = r.content
1630
2144
  }
1631
2145
  if (!markdown) return JSON.parse(JSON.stringify({ ok: false, summary: '需要 markdown(产物全文)或 path(产物路径)' })) as Record<string, never>
1632
2146
  const itinerary = q.itinerary as import('./bookable-facts.ts').ItineraryFacts | undefined
1633
2147
  const registry = await loadFactRegistry(config.stateRoot ?? '.')
1634
- const report = gateArtifact(markdown, registry, avMap, { trip_year: q.tripYear, itinerary })
2148
+ // 政策复核提醒桥接(issue #359):闸侧 opts.tripStart renderer 同一传入
2149
+ // 才能让合法 `renderPolicyFact(f, tripStart)` 行通过公开闸。已加载的
2150
+ // itinerary.trip_start 是权威源(同次闸同时驱动 itineraryInvariants),不再
2151
+ // 额外要求调用方传 opts.tripStart;空 itinerary/无 trip_start 即无提醒。
2152
+ const tripStart = typeof itinerary?.trip_start === 'string' ? itinerary.trip_start : undefined
2153
+ const report = gateArtifact(markdown, registry, avMap, { trip_year: q.tripYear, itinerary, tripStart })
1635
2154
  const lines = report.violations.slice(0, 20).map(v => ` L${v.line} [${v.kind}] ${v.detail}`)
1636
2155
  const summary = report.verdict === 'pass'
1637
2156
  ? `事实闸 PASS:${report.traceable}/${report.claims_checked} 可下单 claim 全部回溯到 exact-date 工具结果(query_id 可重放)——可宣称「已验证方案」。`