@danceiny/gotry 0.0.1-rc.18 → 0.0.1-rc.19

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 (137) hide show
  1. package/README.md +42 -16
  2. package/README.zh-CN.md +37 -15
  3. package/bin/gotry-bootstrap.js +236 -3
  4. package/bin/gotry-inner.js +66 -20
  5. package/bin/gotry-runtime-resolution.d.ts +1 -1
  6. package/bin/gotry-runtime-resolution.js +7 -8
  7. package/cordis.gotry-patch.yml +15 -5
  8. package/data/stations-12306-verify.json +524 -0
  9. package/dist/capabilities/agent-reach.js +4 -4
  10. package/dist/capabilities/channel-health.js +103 -0
  11. package/dist/capabilities/channel-registry.js +213 -0
  12. package/dist/capabilities/doctor.js +285 -0
  13. package/dist/capabilities/effect.js +178 -5
  14. package/dist/capabilities/flyai.js +13 -0
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
  16. package/dist/capabilities/session/adapters/rail-12306.js +285 -0
  17. package/dist/capabilities/session/extension-bridge.js +14 -1
  18. package/dist/capabilities/session/extension-channel.js +4 -2
  19. package/dist/capabilities/session/extension-distribution.js +1 -1
  20. package/dist/capabilities/session-search.js +311 -0
  21. package/dist/capabilities/visa-policy.js +126 -0
  22. package/dist/scripts/agent-planning-turn-deadline-e2e.js +304 -0
  23. package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
  24. package/dist/scripts/benchmark-environment-bridge-e2e.js +5 -10
  25. package/dist/scripts/benchmark-environment-bridge-tests.js +148 -142
  26. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
  27. package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
  28. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
  29. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
  30. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
  31. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
  32. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
  33. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
  34. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
  35. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
  36. package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
  37. package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
  38. package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
  39. package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
  40. package/dist/scripts/bootstrap-tests.js +87 -2
  41. package/dist/scripts/build-metrics-report.js +339 -0
  42. package/dist/scripts/channel-probe-tests.js +148 -0
  43. package/dist/scripts/channel-probe.js +252 -0
  44. package/dist/scripts/channel-registry-tests.js +262 -0
  45. package/dist/scripts/doctor-tests.js +121 -0
  46. package/dist/scripts/effect-tests.js +274 -1
  47. package/dist/scripts/extension-tests.js +129 -11
  48. package/dist/scripts/fact-gate-tests.js +91 -2
  49. package/dist/scripts/flyai-tests.js +17 -1
  50. package/dist/scripts/hbcli-e2e-tests.js +1 -3
  51. package/dist/scripts/metrics-report-tests.js +288 -0
  52. package/dist/scripts/persona-surface-guard-tests.js +19 -0
  53. package/dist/scripts/session-tests.js +401 -1
  54. package/dist/scripts/smoke.js +100 -83
  55. package/dist/scripts/turn-handoff-collect-tests.js +216 -0
  56. package/dist/scripts/turn-handoff-collect.js +189 -0
  57. package/dist/scripts/turn-policy-tests.js +56 -0
  58. package/dist/scripts/typed-contract-canary.js +261 -0
  59. package/dist/scripts/visa-policy-tests.js +75 -0
  60. package/dist/scripts/wish-channel-gate-tests.js +111 -0
  61. package/dist/src/artifact-gate.js +91 -3
  62. package/dist/src/benchmark-agent-conformance.js +7 -4
  63. package/dist/src/benchmark-environment-bridge.js +68 -7
  64. package/dist/src/bookable-facts.js +62 -3
  65. package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
  66. package/dist/src/booking-surface/canonical-schema.js +8 -22
  67. package/dist/src/booking-surface/contracts.js +70 -19
  68. package/dist/src/booking-surface/dsh-planner.js +310 -177
  69. package/dist/src/booking-surface/dsh-plugin.js +3 -3
  70. package/dist/src/booking-surface/index.js +1 -5
  71. package/dist/src/booking-surface/profile.js +1 -1
  72. package/dist/src/booking-surface/runtime.js +1682 -247
  73. package/dist/src/booking-surface/server.js +371 -185
  74. package/dist/src/booking-surface/startup.js +17 -27
  75. package/dist/src/booking-surface/validation.js +270 -760
  76. package/dist/src/index.js +735 -149
  77. package/dist/src/turn-deadline.js +292 -0
  78. package/dist/src/turn-policy.js +146 -0
  79. package/dist/src/wish-pool.js +5 -0
  80. package/extension/background.js +23 -4
  81. package/extension/content-main.js +47 -9
  82. package/extension/manifest.json +27 -9
  83. package/package.json +6 -26
  84. package/schemas/booking.surface.schema.json +2593 -0
  85. package/ts/capabilities/agent-reach.ts +4 -4
  86. package/ts/capabilities/flyai.ts +21 -3
  87. package/ts/capabilities/session/action-cache.ts +1 -1
  88. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  89. package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
  90. package/ts/capabilities/session/extension-bridge.ts +32 -2
  91. package/ts/capabilities/session/extension-channel.ts +9 -8
  92. package/ts/capabilities/session/extension-distribution.ts +3 -1
  93. package/ts/capabilities/session/health-watch.ts +1 -1
  94. package/ts/capabilities/session/read-guard.ts +1 -1
  95. package/ts/capabilities/session/wizard.ts +1 -1
  96. package/ts/capabilities/session-search.ts +323 -1
  97. package/ts/package.json +1 -0
  98. package/ts/scripts/turn-handoff-collect.ts +178 -0
  99. package/ts/src/artifact-gate.ts +82 -2
  100. package/ts/src/benchmark-agent-conformance.ts +14 -6
  101. package/ts/src/benchmark-environment-bridge.ts +29 -10
  102. package/ts/src/bookable-facts.ts +109 -5
  103. package/ts/src/booking-saga.ts +1 -1
  104. package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
  105. package/ts/src/booking-surface/canonical-schema.js +8 -22
  106. package/ts/src/booking-surface/contracts.ts +278 -239
  107. package/ts/src/booking-surface/dsh-planner.ts +333 -194
  108. package/ts/src/booking-surface/dsh-plugin.js +3 -3
  109. package/ts/src/booking-surface/index.ts +1 -5
  110. package/ts/src/booking-surface/profile.ts +11 -11
  111. package/ts/src/booking-surface/runtime.ts +1351 -351
  112. package/ts/src/booking-surface/server.ts +282 -175
  113. package/ts/src/booking-surface/startup.ts +36 -48
  114. package/ts/src/booking-surface/validation.ts +194 -442
  115. package/ts/src/contracts.ts +1 -1
  116. package/ts/src/index.ts +489 -171
  117. package/ts/src/loop.ts +1 -1
  118. package/ts/src/state-ledger.ts +1 -1
  119. package/ts/src/turn-deadline.ts +361 -0
  120. package/ts/src/turn-policy.ts +154 -0
  121. package/ts/src/wish-pool.ts +17 -4
  122. package/dist/scripts/agent-planning-budget-e2e.js +0 -227
  123. package/dist/scripts/agent-planning-budget-tests.js +0 -173
  124. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
  125. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
  126. package/dist/src/booking-surface/contracts-v2.js +0 -89
  127. package/dist/src/booking-surface/runtime-v2.js +0 -1771
  128. package/dist/src/booking-surface/server-v2.js +0 -334
  129. package/dist/src/booking-surface/validation-v2.js +0 -319
  130. package/dist/src/tool-budget.js +0 -136
  131. package/schemas/booking.surface.v1.schema.json +0 -927
  132. package/schemas/booking.surface.v2.schema.json +0 -61
  133. package/ts/src/booking-surface/contracts-v2.ts +0 -118
  134. package/ts/src/booking-surface/runtime-v2.ts +0 -1466
  135. package/ts/src/booking-surface/server-v2.ts +0 -247
  136. package/ts/src/booking-surface/validation-v2.ts +0 -205
  137. package/ts/src/tool-budget.ts +0 -165
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 会话扩展桥(extension-bridge,RFC user-session-data-rfc.md §2.2 通道 C——2026-08-29 founder 定案为 PRIMARY 传输)。
2
+ * 会话扩展桥(extension-bridge,RFC docs/rfc/user-session-data-rfc.md §2.2 通道 C——2026-08-29 founder 定案为 PRIMARY 传输)。
3
3
  *
4
4
  * 形态:Node http 服务,懒启动单例,只绑 127.0.0.1,端口池 8791-8795(与 extension/manifest.json
5
5
  * host_permissions 一一对应,防漂移测试守住);**零新依赖**(node:http 手写,对齐已退役 shell 薄壳
@@ -22,6 +22,9 @@
22
22
  */
23
23
 
24
24
  import { randomUUID } from 'node:crypto'
25
+ import { existsSync } from 'node:fs'
26
+ import { homedir } from 'node:os'
27
+ import { join } from 'node:path'
25
28
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
26
29
 
27
30
  /** 桥端口池(extension/manifest.json host_permissions 必须同面;§38 断言) */
@@ -41,6 +44,32 @@ export const EXTENSION_ORIGIN_STORE = `chrome-extension://${EXTENSION_ID_STORE}`
41
44
  export const EXTENSION_STORE_URL = `https://chromewebstore.google.com/detail/gotry-session-bridge/${EXTENSION_ID_STORE}`
42
45
  /** 桥信任的扩展 Origin 白名单:unpacked(固定 key)+ 商店版,双通道同一扩展同源信任 */
43
46
  export const EXTENSION_ORIGINS: readonly string[] = [EXTENSION_ORIGIN, EXTENSION_ORIGIN_STORE]
47
+
48
+ /**
49
+ * 本地 unpacked 通道是否已落位(~/.gotry/extension/manifest.json)。
50
+ * 商店版没有本地文件面(装在 Chrome 里),离线不可探测——本函数只回答「本地通道在不在」,
51
+ * 商店版用户在这里恒 false,这正是 D-24 自适应文案的判定输入。
52
+ */
53
+ export function localExtensionInstalled(home = homedir()): boolean {
54
+ return existsSync(join(home, '.gotry', 'extension', 'manifest.json'))
55
+ }
56
+
57
+ /**
58
+ * needs-extension 自适应摘要(issue #117,D-24 残量):
59
+ * - 本地 unpacked 已落位 → 完整双通道指引(商店 + GitHub/开发者模式本地通道);
60
+ * - 未落位(商店版用户,或完全未装)→ 只推商店一键装,并给「已装商店版?打开 Chrome 即可」
61
+ * 提示——**不再出现开发者模式/本地通道文案**(已装商店版的用户不该再被指导 load unpacked)。
62
+ * 纯函数(默认 HOME 可注入)。
63
+ */
64
+ export function needsExtensionSummary(opts: { localInstalled?: boolean; home?: string } = {}): string {
65
+ const local = opts.localInstalled ?? localExtensionInstalled(opts.home)
66
+ if (local) {
67
+ return 'GoTry Session Bridge 扩展未连接(未安装或已停用)。推荐 Chrome 应用商店一键装(自动更新) '
68
+ + `${EXTENSION_STORE_URL} ;或 npx gotry setup 落位后 chrome://extensions 开发者模式「加载已解压的扩展程序」指向 ~/.gotry/extension`
69
+ }
70
+ return 'GoTry Session Bridge 扩展未连接。若你已从 Chrome 商店安装(自动更新):打开 Chrome 并确认扩展已启用即可自动连接,无需再装。'
71
+ + `若尚未安装:应用商店一键装 ${EXTENSION_STORE_URL} (装完零弹窗,浏览器自己当安装器)`
72
+ }
44
73
  /** 扩展在线判定:/health 心跳 ≤30s 一次 + 轮询重连,1.5 倍容差 */
45
74
  export const EXTENSION_CONNECTED_WINDOW_MS = 45_000
46
75
  /** /jobs 长轮询 hold 上限(必须 < MV3 SW 30s 存活窗口) */
@@ -292,7 +321,8 @@ export async function createSessionBridge(opts: SessionBridgeOptions = {}): Prom
292
321
  settle({
293
322
  ok: false,
294
323
  reason: 'extension-not-connected',
295
- summary: `GoTry Session Bridge 扩展未连接(未安装或已停用)。一次性安装(装完零弹窗):推荐 Chrome 应用商店一键装(自动更新) ${EXTENSION_STORE_URL} ;或 npx gotry setup 落位后 chrome://extensions 开发者模式「加载已解压的扩展程序」指向 ~/.gotry/extension`,
324
+ // D-24 自适应文案(issue #117):按本地通道是否落位决定是否提及开发者模式
325
+ summary: needsExtensionSummary(),
296
326
  })
297
327
  }
298
328
  }, 250)
@@ -10,15 +10,16 @@
10
10
  * 检索页无回包不是车道失败:与 CDP 车道同语义(到点无 hints 命中 ⇒ miss,页标题命挑战 ⇒ challenged)。
11
11
  */
12
12
 
13
- import { getOrCreateSessionBridge, EXTENSION_STORE_URL, type SessionJobHandle } from './extension-bridge.ts'
13
+ import { getOrCreateSessionBridge, needsExtensionSummary, type SessionJobHandle } from './extension-bridge.ts'
14
14
 
15
- /** 一次性用户门文案(needs-extension 统一指引,与 bootstrap setupExtension 输出同口径) */
16
- export const NEEDS_EXTENSION_HINT =
17
- '需要一次性安装 GoTry Session Bridge 浏览器扩展(装完零弹窗):'
18
- + `推荐 Chrome 应用商店一键安装(自动更新) ${EXTENSION_STORE_URL} ;`
19
- + '免审核、版本更新更快的 GitHub 通道:npx gotry setup --extension-from=github(自动下载落位 ~/.gotry/extension;'
20
- + '手动下载 github.com/Danceiny/gotry/releases 标签 ext-* 的 gotry-session-bridge.tar.gz),'
21
- + '再在 chrome://extensions 开发者模式「加载已解压的扩展程序」指向解压目录'
15
+ /**
16
+ * needs-extension 用户门文案(D-24 自适应,issue #117):按本地通道是否落位自动跳过
17
+ * 开发者模式/本地通道指引——商店版用户只看到商店一键装与「已装即可」提示。
18
+ * 旧静态常量已退役:静态文案无法感知安装状态,正是 D-24 残量的病灶。
19
+ */
20
+ export function needsExtensionHint(): string {
21
+ return needsExtensionSummary()
22
+ }
22
23
 
23
24
  export type BridgeFailureKind = 'bridge-unavailable' | 'extension-not-connected' | 'timeout' | 'job-error'
24
25
 
@@ -104,7 +104,9 @@ export function parseDistManifest(raw: string): ExtensionDistManifest {
104
104
  const tarballSha256 = obj.tarballSha256
105
105
  const zip = obj.zip
106
106
  const zipSha256 = obj.zipSha256
107
- if (typeof version !== 'string' || !/^\d+\.\d+\.\d+$/.test(version)) throw new Error('dist-manifest.version 缺失或非 x.y.z')
107
+ // Chrome manifest version 形态:1-4 段点分整数(扩展版本跟随 gotry 主版本
108
+ // 0.0.1-rc.N → 四段投影 0.0.1.N,展示名走 version_name 原样)
109
+ if (typeof version !== 'string' || !/^\d+(\.\d+){1,3}$/.test(version)) throw new Error('dist-manifest.version 缺失或非 1-4 段点分整数')
108
110
  if (tarball !== DIST_ASSET_TARBALL) throw new Error(`dist-manifest.tarball 资产名漂移(期望 ${DIST_ASSET_TARBALL})`)
109
111
  if (zip !== DIST_ASSET_STORE_ZIP) throw new Error(`dist-manifest.zip 资产名漂移(期望 ${DIST_ASSET_STORE_ZIP})`)
110
112
  if (typeof tarballSha256 !== 'string' || !SHA256_RE.test(tarballSha256)) throw new Error('dist-manifest.tarballSha256 非 64-hex')
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 扩展健康检查 + 自动重放(issue #21 onboarding UX,P3.6 gotry-session-onboarding-goal,
3
- * RFC user-session-data-rfc.md §3.3)。
3
+ * RFC docs/rfc/user-session-data-rfc.md §3.3)。
4
4
  *
5
5
  * 动机:首次 `sessionFlightSearch` 遇 `needs-extension` 时,旧版要求用户装完扩展后**自己重跑命令**;
6
6
  * 本模块把「用户手工重跑」改成「后台有界轮询 + 扩展一就位自动重放同一 query_id」——
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ReadGuard:会话检索面的只读物理隔离(RFC user-session-data-rfc.md §3.3)。
2
+ * ReadGuard:会话检索面的只读物理隔离(RFC docs/rfc/user-session-data-rfc.md §3.3)。
3
3
  *
4
4
  * 写不是「被禁止的行为」,是「不存在的原语」——WriteGate(L0-L4)的检索态前置:
5
5
  * ① 网络层:route 拦截——命中硬支付模式(任意方法)或写方法+写URL模式 → abort + 审计落盘;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 会话扩展 onboarding wizard(RFC user-session-data-rfc.md §3.3,ADR-21 上架后重设)。
2
+ * 会话扩展 onboarding wizard(RFC docs/rfc/user-session-data-rfc.md §3.3,ADR-21 上架后重设)。
3
3
  *
4
4
  * 单一职责(2026-09-02 重设):**只做 Node 端两件事**——
5
5
  * ① 扩展文件从源落位到 `~/.gotry/extension`(幂等)
@@ -6,7 +6,7 @@
6
6
  * cdp(attach 日常 Chrome,Chrome 144+ 每连接弹权限框)降为显式后备
7
7
  * (`GOTRY_SESSION_TRANSPORT=cdp` opt-in,诊断/测试用);persistent 仅测试。
8
8
  *
9
- * 证据链(L4 增补):[会话:ctrip-flight@ts] = 用户本人会话内实时检索,非官方 API;
9
+ * 证据链(L4 增补):[会话:ctrip-flight@ts] / [会话:ctrip-hotel@ts] = 用户本人会话内实时检索,非官方 API;
10
10
  * 风控命中(verdict='challenged')= degraded,绝不重试、绝不绕过(合规支柱②)。
11
11
  * 节律(§3.4):同站点 ≥30s 间隔 + 单调冷却;超间隔返回 verdict='cooldown'。
12
12
  * 永不抛错;扩展车道 fail-closed(桥/扩展不可用即 verdict,零花费);测试/巡检用隔离 profile 与 stateRoot。
@@ -17,6 +17,8 @@ import { extensionCookieNames, extensionSearchJob, classifyBridgeFailure } from
17
17
  import { appendFileSync, mkdirSync } from 'node:fs'
18
18
  import { dirname } from 'node:path'
19
19
  import { buildEntryUrl, NETWORK_HINTS, parseBatchSearch, LOGIN_COOKIE_NAMES, SITE_DOMAIN, type SessionFlightOption } from './session/adapters/ctrip-flight.ts'
20
+ import { buildHotelEntryUrl, parseCtripHotelList, HOTEL_SITE_HOST, type SessionHotelOption } from './session/adapters/ctrip-hotel.ts'
21
+ import { buildTrainEntryUrl, parseLeftTicketQuery, TRAIN_SITE_HOST, type SessionTrainOption } from './session/adapters/rail-12306.ts'
20
22
  import { EXTENSION_STORE_URL } from './session/extension-bridge.ts'
21
23
 
22
24
  export type SessionVerdict = 'hit' | 'miss' | 'error' | 'challenged' | 'cooldown' | 'needs-login' | 'needs-attach' | 'needs-extension'
@@ -109,6 +111,326 @@ export function appendExtensionAudit(auditPath: string | undefined, entry: { kin
109
111
  } catch { /* 审计失败不阻塞检索 */ }
110
112
  }
111
113
 
114
+ // ---------------------------------------------------------------------------
115
+ // 酒店会话检索(2026-09-03 实装;与机票同构:节律闸/登录闸/needs-extension 映射/
116
+ // 挑战红线/证据链。酒店页与机票页同属 ctrip.com 风控域,但通道键独立——
117
+ // 各自 30s 预算已足够礼貌,跨通道合并预算留首个真会话风控观测后校准)
118
+ // ---------------------------------------------------------------------------
119
+
120
+ export interface SessionHotelQuery {
121
+ /** 目的地(中文);城市码表未收录时须带 cityId */
122
+ to: string
123
+ /** 显式城市 id(携程/trip.com 酒店 list 页 URL 的 city= 数字;覆盖码表) */
124
+ cityId?: number | string
125
+ /** YYYY-MM-DD */
126
+ checkIn?: string
127
+ /** YYYY-MM-DD(与 checkIn 成对) */
128
+ checkOut?: string
129
+ adults?: number
130
+ /** 隔离 profile 目录(测试必传;默认 /tmp 专用目录) */
131
+ profileDir?: string
132
+ headless?: boolean
133
+ /** ReadGuard 审计路径(测试传隔离 stateRoot 下) */
134
+ auditPath?: string
135
+ /** 等嗅探回包的上限,默认 30_000(酒店列表接口比机票慢) */
136
+ timeoutMs?: number
137
+ /** 允许匿名实例(默认 false);true 仅适配器链路自检,证据链标 anonymous */
138
+ allowAnonymous?: boolean
139
+ }
140
+
141
+ export interface SessionHotelResult {
142
+ ok: boolean
143
+ via: 'session-ctrip-hotel' | 'session-ctrip-hotel-error'
144
+ evidence: string
145
+ latencyMs: number
146
+ verdict: SessionVerdict
147
+ hotels?: SessionHotelOption[]
148
+ error?: string
149
+ /** needs-extension 时给出 Chrome Web Store URL(dsh UI 渲成可点链接) */
150
+ installUrl?: string
151
+ installAction?: 'add-to-chrome'
152
+ }
153
+
154
+ /** 酒店城市码表未收录时的人话指引(纯函数,测试锚点) */
155
+ export function hotelCityUnresolvedHint(cities: string[]): string {
156
+ return `城市 ${cities.join('/')} 不在携程酒店城市码表——先 web 搜「hotels.ctrip.com ${cities[0] ?? ''} 酒店」拿到 list 页 URL 里的 city= 数字,带 cityId 重试`
157
+ }
158
+
159
+ export async function sessionHotelSearch(q: SessionHotelQuery): Promise<SessionHotelResult> {
160
+ const started = Date.now()
161
+ const ts = new Date().toISOString()
162
+ const site = 'ctrip-hotel'
163
+ const err = (verdict: SessionVerdict, error: string): SessionHotelResult => ({
164
+ ok: false, via: 'session-ctrip-hotel-error', evidence: `[会话:${site}@error@${ts}] ${error}`, latencyMs: Date.now() - started, verdict, error,
165
+ })
166
+
167
+ // 日期对闸(与 flyai hotel 同口径):成对且 YYYY-MM-DD;过去日期不发上游
168
+ if ((q.checkIn ? 1 : 0) !== (q.checkOut ? 1 : 0) || (q.checkIn && !/^\d{4}-\d{2}-\d{2}$/.test(q.checkIn))) {
169
+ return err('error', 'checkIn/checkOut 须成对且为 YYYY-MM-DD(未定档期可不带日期)')
170
+ }
171
+ if (q.checkIn) {
172
+ const now = new Date()
173
+ const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
174
+ if (q.checkIn < today || (q.checkOut ?? '') < q.checkIn) {
175
+ return err('error', `入住 ${q.checkIn}/退房 ${q.checkOut ?? ''} 不是未来合法区间(今天 ${today})——向用户确认日期后再查`)
176
+ }
177
+ }
178
+
179
+ // 节律闸:超间隔即拒,不发起导航
180
+ const last = lastCallAt.get(site) ?? 0
181
+ if (Date.now() - last < MIN_INTERVAL_MS) {
182
+ return err('cooldown', `rate limit: last call ${Date.now() - last}ms ago, min ${MIN_INTERVAL_MS}ms`)
183
+ }
184
+ lastCallAt.set(site, Date.now())
185
+
186
+ const entry = buildHotelEntryUrl({ to: q.to, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults })
187
+ if (!entry.ok || !entry.url) {
188
+ return err('error', hotelCityUnresolvedHint(entry.unresolved ?? [q.to]))
189
+ }
190
+
191
+ const mode = resolveTransportMode(q.profileDir)
192
+
193
+ if (mode === 'extension') {
194
+ // ① 登录态快查(与机票同账号体系,票据 cookie 名一致)
195
+ const login = await extensionCookieNames({ site, domain: SITE_DOMAIN.replace(/^\./, ''), ticketNames: LOGIN_COOKIE_NAMES })
196
+ if (!login.ok) {
197
+ const verdict = classifyBridgeFailure(login.kind)
198
+ if (verdict === 'needs-extension') {
199
+ return { ok: false, via: 'session-ctrip-hotel-error', evidence: '[会话:ctrip-hotel-needs-extension@ts]', latencyMs: Date.now() - started, verdict, error: login.summary, installUrl: EXTENSION_STORE_URL, installAction: 'add-to-chrome' as const }
200
+ }
201
+ return err(verdict, login.summary)
202
+ }
203
+ if (login.tickets.length === 0 && !q.allowAnonymous) {
204
+ return err('needs-login', '未检出你本人登录态——调用 gotry_session_login 为用户打开携程登录入口(登录在携程官网完成;gotry 永不经手密码/验证码/cookie 值)')
205
+ }
206
+ // ② 检索 job:后台标签 + 被动嗅探(URL hint + 形状兜底;扩展零写行为)
207
+ const r = await extensionSearchJob({ site, url: entry.url, timeoutMs: q.timeoutMs })
208
+ appendExtensionAudit(q.auditPath, {
209
+ kind: 'extension-session-job', site, url: entry.url, jobId: 'search',
210
+ result: r.ok ? (r.timedOut ? 'timeout' : `body ${r.body.length}B title="${r.title.slice(0, 60)}"`) : `${r.kind}:${r.summary.slice(0, 120)}`,
211
+ })
212
+ if (!r.ok) {
213
+ const verdict = classifyBridgeFailure(r.kind)
214
+ if (verdict === 'needs-extension') {
215
+ return { ok: false, via: 'session-ctrip-hotel-error', evidence: '[会话:ctrip-hotel-needs-extension@ts]', latencyMs: Date.now() - started, verdict, error: r.summary, installUrl: EXTENSION_STORE_URL, installAction: 'add-to-chrome' as const }
216
+ }
217
+ return err(verdict, r.summary)
218
+ }
219
+ const title = r.title
220
+ const head = r.body.slice(0, 5000)
221
+ if (CHALLENGE_RE.test(title + head)) {
222
+ return err('challenged', `风控/验证码命中(title=${title.slice(0, 60)});按红线不重试不绕过,交还用户`)
223
+ }
224
+ const hotels = parseCtripHotelList(r.body)
225
+ const verdict: SessionVerdict = hotels.length > 0 ? 'hit' : 'miss'
226
+ return {
227
+ ok: true,
228
+ via: 'session-ctrip-hotel',
229
+ evidence: `[会话:${site}@${ts}] ${hotels.length} hotels;transport=extension(被动嗅探,零系统弹窗;扩展零写行为=物理只读)${q.allowAnonymous ? ';anonymous=自检态' : ''}`,
230
+ latencyMs: Date.now() - started,
231
+ verdict,
232
+ hotels,
233
+ }
234
+ }
235
+
236
+ // cdp/persistent 车道:与机票同构——挂监听等 NETWORK hint 回包(酒店用 HOTEL hints + 形状兜底)
237
+ const t = await openSession({ profileDir: q.profileDir, headless: q.headless, auditPath: q.auditPath, mode: mode === 'persistent' ? 'persistent' : 'cdp', newPage: true })
238
+ if (!t.ok) {
239
+ return err(classifyTransportFailure(t.summary, q.profileDir === undefined), t.summary)
240
+ }
241
+ try {
242
+ const loggedIn = async (): Promise<boolean> => {
243
+ const cookies = await t.browser.cookies().catch(() => [])
244
+ return cookies.some((c) => c.domain.includes(SITE_DOMAIN.replace(/^\./, '')) && LOGIN_COOKIE_NAMES.includes(c.name))
245
+ }
246
+ if (!(await loggedIn()) && !q.allowAnonymous) {
247
+ return err('needs-login', '未检出你本人登录态——调用 gotry_session_login 为用户打开携程登录入口(登录在携程官网完成;gotry 永不经手密码/验证码/cookie 值)')
248
+ }
249
+ let settled = false
250
+ let body = ''
251
+ const heard = new Promise<void>((resolve) => {
252
+ t.page.on('response', async (res) => {
253
+ if (settled) return
254
+ const u = res.url()
255
+ const hostHit = u.includes(HOTEL_SITE_HOST)
256
+ if (!hostHit) return
257
+ try {
258
+ const text = await res.text()
259
+ if (text && (hostHit && /hotels\.ctrip\.com\/(hotels\/api|domestic\/pc\/api)|GetHotelListBySOA|GetHotelListByCity|HotelSearch|hotelsearch/i.test(u) || text.length <= 2_000_000 && /"hotelList"|"hotelMatchInfos"|"hotelName"/.test(text))) {
260
+ body = text
261
+ settled = true
262
+ resolve()
263
+ }
264
+ } catch { /* 流式/竞态不可读则继续等下一个 */ }
265
+ })
266
+ setTimeout(() => resolve(), q.timeoutMs ?? 30_000)
267
+ })
268
+ await t.page.goto(entry.url, { waitUntil: 'domcontentloaded', timeout: 30_000 })
269
+ await heard
270
+ const title = await t.page.title().catch(() => '')
271
+ const headHtml = (await t.page.content().catch(() => '')).slice(0, 5000)
272
+ if (CHALLENGE_RE.test(title + headHtml)) {
273
+ return err('challenged', `风控/验证码命中(title=${title.slice(0, 60)});按红线不重试不绕过,交还用户`)
274
+ }
275
+ const hotels = parseCtripHotelList(body)
276
+ const verdict: SessionVerdict = hotels.length > 0 ? 'hit' : 'miss'
277
+ return {
278
+ ok: true,
279
+ via: 'session-ctrip-hotel',
280
+ evidence: `[会话:${site}@${ts}] ${hotels.length} hotels;guard blocked=${t.guard.blockedCount()}/${t.guard.requestCount()}${q.allowAnonymous ? ';anonymous=自检态' : ''}`,
281
+ latencyMs: Date.now() - started,
282
+ verdict,
283
+ hotels,
284
+ }
285
+ } catch (e) {
286
+ return err('error', e instanceof Error ? e.message.slice(0, 200) : String(e))
287
+ } finally {
288
+ await t.close()
289
+ }
290
+ }
291
+
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // 火车会话检索(2026-09-03 实装;12306 余票查询是公开面——登录只关系下单,
295
+ // 无账号数据过手,故无登录闸;扩展照样只读被动嗅探,证据链标注「公开查询面」)
296
+ // ---------------------------------------------------------------------------
297
+
298
+ export interface SessionTrainQuery {
299
+ from: string
300
+ to: string
301
+ /** YYYY-MM-DD */
302
+ date: string
303
+ /** 显式城市电报码(覆盖码表;kyfw 查询页 URL fs=城市,XXX 的三位码) */
304
+ fromStationTelecode?: string
305
+ toStationTelecode?: string
306
+ /** 隔离 profile 目录(测试必传;默认 /tmp 专用目录) */
307
+ profileDir?: string
308
+ headless?: boolean
309
+ /** ReadGuard 审计路径(测试传隔离 stateRoot 下) */
310
+ auditPath?: string
311
+ /** 等嗅探回包的上限,默认 25_000 */
312
+ timeoutMs?: number
313
+ }
314
+
315
+ export interface SessionTrainResult {
316
+ ok: boolean
317
+ via: 'session-train-12306' | 'session-train-12306-error'
318
+ evidence: string
319
+ latencyMs: number
320
+ verdict: SessionVerdict
321
+ trains?: SessionTrainOption[]
322
+ error?: string
323
+ /** needs-extension 时给出 Chrome Web Store URL(dsh UI 渲成可点链接) */
324
+ installUrl?: string
325
+ installAction?: 'add-to-chrome'
326
+ }
327
+
328
+ /** 车站电报码表未收录时的人话指引(纯函数,测试锚点) */
329
+ export function trainStationUnresolvedHint(cities: string[]): string {
330
+ return `城市 ${cities.join('/')} 不在 12306 城市电报码表——web 搜「12306 ${cities[0] ?? ''} 余票」拿到 kyfw 查询页 URL 里的 fs=城市,XXX 三位码,带 fromStationTelecode/toStationTelecode 重试`
331
+ }
332
+
333
+ export async function sessionTrainSearch(q: SessionTrainQuery): Promise<SessionTrainResult> {
334
+ const started = Date.now()
335
+ const ts = new Date().toISOString()
336
+ const site = 'train-12306'
337
+ const err = (verdict: SessionVerdict, error: string): SessionTrainResult => ({
338
+ ok: false, via: 'session-train-12306-error', evidence: `[会话:${site}@error@${ts}] ${error}`, latencyMs: Date.now() - started, verdict, error,
339
+ })
340
+
341
+ // 节律闸:超间隔即拒,不发起导航
342
+ const last = lastCallAt.get(site) ?? 0
343
+ if (Date.now() - last < MIN_INTERVAL_MS) {
344
+ return err('cooldown', `rate limit: last call ${Date.now() - last}ms ago, min ${MIN_INTERVAL_MS}ms`)
345
+ }
346
+ lastCallAt.set(site, Date.now())
347
+
348
+ const entry = buildTrainEntryUrl({ from: q.from, to: q.to, date: q.date, fromStationTelecode: q.fromStationTelecode, toStationTelecode: q.toStationTelecode })
349
+ if (!entry.ok || !entry.url) {
350
+ return err('error', trainStationUnresolvedHint(entry.unresolved ?? [q.from, q.to]))
351
+ }
352
+
353
+ const mode = resolveTransportMode(q.profileDir)
354
+
355
+ if (mode === 'extension') {
356
+ // 无登录闸(公开查询面):直接发起检索 job
357
+ const r = await extensionSearchJob({ site, url: entry.url, timeoutMs: q.timeoutMs })
358
+ appendExtensionAudit(q.auditPath, {
359
+ kind: 'extension-session-job', site, url: entry.url, jobId: 'search',
360
+ result: r.ok ? (r.timedOut ? 'timeout' : `body ${r.body.length}B title="${r.title.slice(0, 60)}"`) : `${r.kind}:${r.summary.slice(0, 120)}`,
361
+ })
362
+ if (!r.ok) {
363
+ const verdict = classifyBridgeFailure(r.kind)
364
+ if (verdict === 'needs-extension') {
365
+ return { ok: false, via: 'session-train-12306-error', evidence: '[会话:train-12306-needs-extension@ts]', latencyMs: Date.now() - started, verdict, error: r.summary, installUrl: EXTENSION_STORE_URL, installAction: 'add-to-chrome' as const }
366
+ }
367
+ return err(verdict, r.summary)
368
+ }
369
+ const title = r.title
370
+ const head = r.body.slice(0, 5000)
371
+ if (CHALLENGE_RE.test(title + head)) {
372
+ return err('challenged', `风控/验证码命中(title=${title.slice(0, 60)});按红线不重试不绕过,交还用户`)
373
+ }
374
+ const trains = parseLeftTicketQuery(r.body, entry.url)
375
+ const verdict: SessionVerdict = trains.length > 0 ? 'hit' : 'miss'
376
+ return {
377
+ ok: true,
378
+ via: 'session-train-12306',
379
+ evidence: `[会话:${site}@${ts}] ${trains.length} trains;transport=extension(公开查询面,被动嗅探,零系统弹窗;扩展零写行为=物理只读)`,
380
+ latencyMs: Date.now() - started,
381
+ verdict,
382
+ trains,
383
+ }
384
+ }
385
+
386
+ // cdp/persistent 车道:与机/酒同构
387
+ const t = await openSession({ profileDir: q.profileDir, headless: q.headless, auditPath: q.auditPath, mode: mode === 'persistent' ? 'persistent' : 'cdp', newPage: true })
388
+ if (!t.ok) {
389
+ return err(classifyTransportFailure(t.summary, q.profileDir === undefined), t.summary)
390
+ }
391
+ try {
392
+ let settled = false
393
+ let body = ''
394
+ const heard = new Promise<void>((resolve) => {
395
+ t.page.on('response', async (res) => {
396
+ if (settled) return
397
+ const u = res.url()
398
+ if (!/leftTicket\/query/i.test(u)) return
399
+ try {
400
+ const text = await res.text()
401
+ if (text) {
402
+ body = text
403
+ settled = true
404
+ resolve()
405
+ }
406
+ } catch { /* 流式/竞态不可读则继续等下一个 */ }
407
+ })
408
+ setTimeout(() => resolve(), q.timeoutMs ?? 25_000)
409
+ })
410
+ await t.page.goto(entry.url, { waitUntil: 'domcontentloaded', timeout: 30_000 })
411
+ await heard
412
+ const title = await t.page.title().catch(() => '')
413
+ const headHtml = (await t.page.content().catch(() => '')).slice(0, 5000)
414
+ if (CHALLENGE_RE.test(title + headHtml)) {
415
+ return err('challenged', `风控/验证码命中(title=${title.slice(0, 60)});按红线不重试不绕过,交还用户`)
416
+ }
417
+ const trains = parseLeftTicketQuery(body, entry.url)
418
+ const verdict: SessionVerdict = trains.length > 0 ? 'hit' : 'miss'
419
+ return {
420
+ ok: true,
421
+ via: 'session-train-12306',
422
+ evidence: `[会话:${site}@${ts}] ${trains.length} trains(公开查询面);guard blocked=${t.guard.blockedCount()}/${t.guard.requestCount()}`,
423
+ latencyMs: Date.now() - started,
424
+ verdict,
425
+ trains,
426
+ }
427
+ } catch (e) {
428
+ return err('error', e instanceof Error ? e.message.slice(0, 200) : String(e))
429
+ } finally {
430
+ await t.close()
431
+ }
432
+ }
433
+
112
434
  export async function sessionFlightSearch(q: SessionFlightQuery): Promise<SessionSearchResult> {
113
435
  const started = Date.now()
114
436
  const ts = new Date().toISOString()
package/ts/package.json CHANGED
@@ -15,6 +15,7 @@
15
15
  "@deepseek-ai/schemastery": "^3.18.2",
16
16
  "@types/better-sqlite3": "^9.6.0",
17
17
  "better-sqlite3": "^13.0.3",
18
+ "dsh-map-tools": "0.5.1",
18
19
  "z3-solver": "^5.2.0"
19
20
  },
20
21
  "devDependencies": {
@@ -0,0 +1,178 @@
1
+ /**
2
+ * 后台深度规划工单收集器(ADR-24 v2 收集闭环;async-collect 的 turn-handoff 对位)。
3
+ *
4
+ * 「一小时后回来看看」的后台半段:加载 open 工单 → 以 GOTRY_HANDOFF_CHILD=1
5
+ * 派生 headless 规划会话(唯一出口 converge,不回递归)→ 捕获最终答复为
6
+ * 交付物 → settleTurnHandoffTicket 结算(交付物 .deliverable.md + 工单状态
7
+ * 原子更新)。终态幂等:已 settled/failed 的工单复诵交付物与终态 JSON,零
8
+ * 重算、零再花。最后一行固定输出 gotry_turn_handoff_terminal.v1 JSON;
9
+ * succeeded exit 0,failed exit 2。
10
+ *
11
+ * 运行(在 ts/ 下):
12
+ * npx tsx scripts/turn-handoff-collect.ts <ticketId> [stateRoot]
13
+ * npx tsx scripts/turn-handoff-collect.ts --all [stateRoot]
14
+ * 环境变量:
15
+ * GOTRY_HANDOFF_PLANNER_BIN 规划器二进制(默认 <repo>/bin/gotry-inner.js)
16
+ * GOTRY_HANDOFF_PLANNER_TIMEOUT_MS 规划器硬上限(默认 900_000)
17
+ *
18
+ * v1 诚实边界:工单只携带用户原文,规划会话是全新上下文(fresh cwd/DSH_HOME)
19
+ * ——原会话中已读的工作区文件/日历结论不随单迁移,由规划器按需重取。
20
+ */
21
+
22
+ import { spawn } from 'node:child_process'
23
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
24
+ import { tmpdir } from 'node:os'
25
+ import { join } from 'node:path'
26
+ import {
27
+ listTurnHandoffTickets,
28
+ settleTurnHandoffTicket,
29
+ TURN_HANDOFF_SCHEMA,
30
+ type TurnHandoffTicket,
31
+ } from '../src/turn-deadline.ts'
32
+
33
+ const TURN_HANDOFF_TERMINAL_SCHEMA = 'gotry_turn_handoff_terminal.v1' as const
34
+ const ROOT = join(import.meta.dirname, '..', '..')
35
+
36
+ interface TerminalOutcome {
37
+ schema: typeof TURN_HANDOFF_TERMINAL_SCHEMA
38
+ ticket_id: string
39
+ status: 'succeeded' | 'failed'
40
+ deliverable_path?: string
41
+ error?: string
42
+ }
43
+
44
+ function emitTerminal(outcome: TerminalOutcome): never {
45
+ console.log(JSON.stringify(outcome))
46
+ process.exit(outcome.status === 'succeeded' ? 0 : 2)
47
+ }
48
+
49
+ function loadTicket(stateRoot: string, ticketId: string): TurnHandoffTicket | null {
50
+ try {
51
+ const path = join(stateRoot === '.' ? process.cwd() : stateRoot, 'gotry-state', 'turn-handoffs', `${ticketId}.json`)
52
+ const ticket = JSON.parse(readFileSync(path, 'utf-8')) as TurnHandoffTicket
53
+ return ticket?.schema === TURN_HANDOFF_SCHEMA ? ticket : null
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ function replay(ticket: TurnHandoffTicket, stateRoot: string): never {
60
+ const dir = join(stateRoot === '.' ? process.cwd() : stateRoot, 'gotry-state', 'turn-handoffs')
61
+ emitTerminal({
62
+ schema: TURN_HANDOFF_TERMINAL_SCHEMA,
63
+ ticket_id: ticket.id,
64
+ status: ticket.status === 'settled' ? 'succeeded' : 'failed',
65
+ ...(ticket.deliverableFile ? { deliverable_path: join(dir, ticket.deliverableFile) } : {}),
66
+ ...(ticket.error ? { error: ticket.error } : {}),
67
+ })
68
+ }
69
+
70
+ async function runPlanner(ticket: TurnHandoffTicket): Promise<{ ok: boolean; output: string; error?: string }> {
71
+ const plannerBin = process.env.GOTRY_HANDOFF_PLANNER_BIN ?? join(ROOT, 'bin', 'gotry-inner.js')
72
+ if (!existsSync(plannerBin)) {
73
+ return { ok: false, output: '', error: `planner binary unavailable: ${plannerBin}` }
74
+ }
75
+ const timeoutMs = Number.parseInt(process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS ?? '', 10) || 900_000
76
+ const dshHome = mkdtempSync(join(tmpdir(), 'gotry-handoff-child-dsh-'))
77
+ const childCwd = mkdtempSync(join(tmpdir(), 'gotry-handoff-child-cwd-'))
78
+ try {
79
+ const childEnv: NodeJS.ProcessEnv = { ...process.env }
80
+ childEnv.DSH_HOME = dshHome
81
+ // 递归防护(核心):子会话唯一出口 converge + 长 leash;同时清掉父进程的
82
+ // 数值 pin 与工单根,子会话不得再 handoff、也不受测试阈值影响。
83
+ childEnv.GOTRY_HANDOFF_CHILD = '1'
84
+ delete childEnv.GOTRY_TURN_DEADLINE_SOFT_MS
85
+ delete childEnv.GOTRY_TURN_DEADLINE_HARD_MS
86
+ delete childEnv.GOTRY_TURN_HANDOFF_ROOT
87
+ // .js/.mjs/.cjs 经当前 node 派生(测试假 planner 无执行位也成立);
88
+ // 其余(gotry-inner.js 之外的 .bin shim、全局命令)直接执行。
89
+ const isJsScript = /\.(js|mjs|cjs)$/.test(plannerBin)
90
+ const child = isJsScript
91
+ ? spawn(process.execPath, [plannerBin, ticket.userMessage], { cwd: childCwd, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] })
92
+ : spawn(plannerBin, [ticket.userMessage], { cwd: childCwd, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] })
93
+ let output = ''
94
+ child.stdout.on('data', chunk => { output += chunk.toString() })
95
+ child.stderr.on('data', chunk => { output += chunk.toString() })
96
+ const code = await new Promise<number | null>(resolve => {
97
+ const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(null) }, timeoutMs)
98
+ child.once('exit', c => { clearTimeout(timer); resolve(c) })
99
+ })
100
+ if (code === null) return { ok: false, output: '', error: `planner timed out after ${timeoutMs}ms` }
101
+ if (code !== 0) return { ok: false, output: '', error: `planner exited ${code}: ${output.trim().slice(-300)}` }
102
+ const deliverable = output.trim()
103
+ if (!deliverable) return { ok: false, output: '', error: 'planner produced empty output' }
104
+ return { ok: true, output: deliverable }
105
+ } finally {
106
+ await new Promise<void>(resolve => setTimeout(resolve, 200))
107
+ rmSync(dshHome, { recursive: true, force: true })
108
+ rmSync(childCwd, { recursive: true, force: true })
109
+ }
110
+ }
111
+
112
+ async function collectOne(stateRoot: string, ticketId: string): Promise<void> {
113
+ const ticket = loadTicket(stateRoot, ticketId)
114
+ if (!ticket) {
115
+ console.error(`工单 ${ticketId} 不存在(${stateRoot}/gotry-state/turn-handoffs/)`)
116
+ process.exit(1)
117
+ }
118
+ if (ticket.status !== 'open') {
119
+ console.error(`工单 ${ticketId} 已是终态 ${ticket.status},复诵不重算:`)
120
+ replay(ticket, stateRoot)
121
+ }
122
+ const result = await runPlanner(ticket)
123
+ const deliverable = result.ok
124
+ ? `# 后台深度规划交付:${ticket.objective.slice(0, 60)}\n(工单 ${ticket.id},原请求 ${ticket.requestedAt})\n\n${result.output}`
125
+ : `# 后台深度规划失败:${ticket.objective.slice(0, 60)}\n(工单 ${ticket.id},原请求 ${ticket.requestedAt})\n\n未产出交付物:${result.error ?? '未知原因'}。这趟规划没有完成——请重新发起,失败原因如上,无部分结果可交付。`
126
+ const { deliverablePath } = await settleTurnHandoffTicket(
127
+ stateRoot, ticket, result.ok ? 'settled' : 'failed', deliverable, result.ok ? undefined : result.error,
128
+ )
129
+ console.error(`工单 ${ticket.id} ${result.ok ? '已交付' : '失败'}:${deliverablePath}`)
130
+ emitTerminal({
131
+ schema: TURN_HANDOFF_TERMINAL_SCHEMA,
132
+ ticket_id: ticket.id,
133
+ status: result.ok ? 'succeeded' : 'failed',
134
+ ...(result.ok
135
+ ? { deliverable_path: deliverablePath }
136
+ : { error: result.error ?? 'unknown' }),
137
+ })
138
+ }
139
+
140
+ async function collectAll(stateRoot: string): Promise<void> {
141
+ const open = (await listTurnHandoffTickets(stateRoot)).filter(t => t.status === 'open')
142
+ if (open.length === 0) {
143
+ console.error('无待回收的后台规划工单(open=0)')
144
+ process.exit(0)
145
+ }
146
+ let failed = 0
147
+ for (const view of open) {
148
+ const ticket = loadTicket(stateRoot, view.id)
149
+ if (!ticket || ticket.status !== 'open') continue
150
+ const result = await runPlanner(ticket)
151
+ const deliverable = result.ok
152
+ ? `# 后台深度规划交付:${ticket.objective.slice(0, 60)}\n(工单 ${ticket.id},原请求 ${ticket.requestedAt})\n\n${result.output}`
153
+ : `# 后台深度规划失败:${ticket.objective.slice(0, 60)}\n(工单 ${ticket.id},原请求 ${ticket.requestedAt})\n\n未产出交付物:${result.error ?? '未知原因'}。这趟规划没有完成——请重新发起,失败原因如上,无部分结果可交付。`
154
+ const { deliverablePath } = await settleTurnHandoffTicket(
155
+ stateRoot, ticket, result.ok ? 'settled' : 'failed', deliverable, result.ok ? undefined : result.error,
156
+ )
157
+ console.error(`工单 ${ticket.id} ${result.ok ? '已交付' : '失败'}:${deliverablePath}`)
158
+ if (!result.ok) failed += 1
159
+ }
160
+ console.log(JSON.stringify({
161
+ schema: TURN_HANDOFF_TERMINAL_SCHEMA,
162
+ ticket_id: `--all(${open.length})`,
163
+ status: failed === 0 ? 'succeeded' : 'failed',
164
+ error: failed === 0 ? undefined : `${failed}/${open.length} 张工单回收失败`,
165
+ }))
166
+ process.exit(failed === 0 ? 0 : 2)
167
+ }
168
+
169
+ const [target, stateRootArg] = process.argv.slice(2)
170
+ const stateRoot = stateRootArg ?? '.'
171
+ if (target === '--all') {
172
+ await collectAll(stateRoot)
173
+ } else if (target) {
174
+ await collectOne(stateRoot, target)
175
+ } else {
176
+ console.error('用法:npx tsx scripts/turn-handoff-collect.ts <ticketId|--all> [stateRoot]')
177
+ process.exit(1)
178
+ }