@itpay/cli 2.1.18 → 2.1.22

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 (75) hide show
  1. package/dist/src/commands/catalog.js +1 -0
  2. package/dist/src/commands/rail_catalog.js +160 -11
  3. package/dist/src/commands/services.js +289 -71
  4. package/dist/src/commands/skill.js +1 -1
  5. package/dist/src/main.js +54 -19
  6. package/dist/src/render/markdown.js +0 -3
  7. package/dist/src/state/account_auth.js +6 -2
  8. package/dist/src/state/cart_session.js +4 -1
  9. package/dist/src/state/config.js +1 -1
  10. package/dist/src/state/device_authority.js +69 -13
  11. package/dist/src/state/task_journal.js +2 -0
  12. package/docs/agent/buyer/cart-checkout.json +5 -4
  13. package/docs/agent/buyer/catalog-list.json +24 -6
  14. package/docs/agent/buyer/identity-and-sessions.json +15 -10
  15. package/docs/agent/buyer/install-and-setup.json +7 -6
  16. package/docs/agent/buyer/orders-refunds.json +4 -4
  17. package/docs/agent/buyer/payment-flow.json +3 -3
  18. package/docs/agent/buyer/purchased-content.json +17 -3
  19. package/docs/agent/buyer/quickstart.json +18 -11
  20. package/docs/agent/buyer/rail-booking.json +79 -167
  21. package/docs/agent/buyer/rail-fast-checkout.json +21 -43
  22. package/docs/agent/buyer/render-hosts.json +8 -5
  23. package/docs/agent/buyer/service-feedback.json +2 -1
  24. package/docs/cli-reference/agent-types.md +1 -1
  25. package/docs/cli-reference/commands/auth.md +2 -0
  26. package/docs/cli-reference/commands/buy.md +1 -1
  27. package/docs/cli-reference/commands/cart/add.md +1 -1
  28. package/docs/cli-reference/commands/cart/clear.md +1 -1
  29. package/docs/cli-reference/commands/cart/index.md +1 -1
  30. package/docs/cli-reference/commands/cart/next.md +1 -1
  31. package/docs/cli-reference/commands/cart/remove.md +1 -1
  32. package/docs/cli-reference/commands/cart/show.md +1 -1
  33. package/docs/cli-reference/commands/catalog/index.md +1 -1
  34. package/docs/cli-reference/commands/catalog/list.md +1 -1
  35. package/docs/cli-reference/commands/checkout.md +1 -1
  36. package/docs/cli-reference/commands/device.md +4 -2
  37. package/docs/cli-reference/commands/docs/index.md +1 -1
  38. package/docs/cli-reference/commands/docs/list.md +1 -1
  39. package/docs/cli-reference/commands/docs/search.md +1 -1
  40. package/docs/cli-reference/commands/docs/show.md +1 -1
  41. package/docs/cli-reference/commands/install.md +2 -2
  42. package/docs/cli-reference/commands/next.md +1 -1
  43. package/docs/cli-reference/commands/order.md +1 -1
  44. package/docs/cli-reference/commands/pay.md +1 -1
  45. package/docs/cli-reference/commands/readyz.md +1 -1
  46. package/docs/cli-reference/commands/refund/cancel.md +1 -1
  47. package/docs/cli-reference/commands/refund/create.md +1 -1
  48. package/docs/cli-reference/commands/refund/get.md +2 -2
  49. package/docs/cli-reference/commands/refund/index.md +1 -1
  50. package/docs/cli-reference/commands/refund/list.md +1 -1
  51. package/docs/cli-reference/commands/refund/watch.md +1 -1
  52. package/docs/cli-reference/commands/services/action.md +3 -3
  53. package/docs/cli-reference/commands/services/checkout.md +1 -1
  54. package/docs/cli-reference/commands/services/events.md +1 -1
  55. package/docs/cli-reference/commands/services/get.md +1 -1
  56. package/docs/cli-reference/commands/services/index.md +1 -1
  57. package/docs/cli-reference/commands/services/invoke.md +3 -11
  58. package/docs/cli-reference/commands/services/list.md +1 -1
  59. package/docs/cli-reference/commands/services/next.md +12 -10
  60. package/docs/cli-reference/commands/services/page.md +9 -7
  61. package/docs/cli-reference/commands/services/quote.md +1 -1
  62. package/docs/cli-reference/commands/services/read-result.md +8 -8
  63. package/docs/cli-reference/commands/services/run.md +2 -2
  64. package/docs/cli-reference/commands/services/start.md +1 -1
  65. package/docs/cli-reference/conventions.md +2 -2
  66. package/package.json +7 -5
  67. package/skills/itpay/SKILL.md +70 -186
  68. package/docs/reviews/agent-ux-handoff-2026-09-23/IMPLEMENTATION-EVIDENCE.md +0 -174
  69. package/docs/skill-bundle-rollout/01-mcp-authentication.md +0 -134
  70. package/docs/skill-bundle-rollout/02-platform-bundle-repositories.md +0 -210
  71. package/docs/skill-bundle-rollout/03-platform-publishing.md +0 -161
  72. package/docs/skill-bundle-rollout/04-first-wave-platforms.md +0 -70
  73. package/docs/skill-bundle-rollout/05-sync-operations.md +0 -62
  74. package/docs/skill-bundle-rollout/README.md +0 -151
  75. /package/bin/{itp → itp.mjs} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { HttpError } from "../client/http.js";
2
2
  import { HttpTransportError } from "../client/transport.js";
3
+ import { DeviceLockBusyError, DeviceStateError } from "../state/device_authority.js";
3
4
  import { operationID, taskJournalPath } from "../state/config.js";
4
5
  import { TaskJournal } from "../state/task_journal.js";
5
6
  import { validateContext } from "../state/client_context.js";
@@ -34,7 +35,7 @@ function journalTaskState(serviceExecutionID, envelope, env) {
34
35
  const serviceActionStatuses = new Set(["pending", "approved", "rejected", "expired", "cancelled"]);
35
36
  const RAIL_SERVICE_GUIDANCE = {
36
37
  "itpay-rail-exact": {
37
- when_to_use: "出发和到达都是明确的火车站名时使用本服务(直达查询,最快)。只有城市、模糊位置或想要方案推荐时改用 itpay-rail-smart。",
38
+ when_to_use: "已知可信站对时先用本服务核验直达;城市需求可先形成有依据的站对假设。只覆盖这一站对,不代表全城市最优。完整流程:itpay docs show rail-booking --json。",
38
39
  input_fields: [
39
40
  { name: "origin", required: true, description: "出发火车站名(如'古镇'),不能是城市或地址", example: "古镇" },
40
41
  { name: "destination", required: true, description: "到达火车站名(如'广州南')", example: "广州南" },
@@ -43,11 +44,11 @@ const RAIL_SERVICE_GUIDANCE = {
43
44
  input_example: { origin: "古镇", destination: "广州南", travel_date: "2026-09-19" },
44
45
  notes: [
45
46
  "本服务只接受这三个字段,多传字段会被供应商拒绝",
46
- "站名不确定就改用 itpay-rail-smart 先解析位置,不要在两个服务之间反复试错",
47
+ "查询为空只说明这一已解析站对;如原意仍需更多覆盖,可验证另一可信站对或使用 Smart",
47
48
  ],
48
49
  },
49
50
  "itpay-rail-smart": {
50
- when_to_use: "只有城市名、模糊位置或需要中转规划与方案推荐时使用本服务;平台负责把位置解析到车站。",
51
+ when_to_use: "需要广泛站点比较、中转或具体地址接驳时使用;有可信站对且只需先核验直达可用 Exact。完整流程:itpay docs show rail-booking --json。",
51
52
  input_fields: [
52
53
  { name: "origin", required: true, description: "出发地:已知最完整的位置名或区域(城市/区县/地址均可)", example: "中山古镇" },
53
54
  { name: "destination", required: true, description: "目的地:与 origin 同样的位置规则", example: "广州南" },
@@ -62,8 +63,9 @@ const RAIL_SERVICE_GUIDANCE = {
62
63
  { name: "arrive_before", description: "不晚于该时间到达" },
63
64
  { name: "priority", enum: "balanced|fastest|cheapest|safest|flexible", description: "方案偏好" },
64
65
  { name: "max_transfers", description: "允许中转次数,0-2" },
66
+ { name: "passengers", description: "成人乘客整数 1-5;不填写身份资料" },
65
67
  ],
66
- input_example: { origin: "中山古镇", destination: "广州南", travel_date: "2026-09-19", priority: "fastest" },
68
+ input_example: { origin: "上海", destination: "长沙", travel_date: "2026-10-06", arrive_before: "15:00", priority: "fastest", passengers: 1 },
67
69
  notes: [
68
70
  "位置有歧义时会进入位置确认步骤,请用户选定后继续同一执行",
69
71
  "推荐结果含每趟车的席别与余票;购票在后续受保护流程完成",
@@ -74,16 +76,16 @@ function railServiceGuidance(serviceID) {
74
76
  return RAIL_SERVICE_GUIDANCE[serviceID];
75
77
  }
76
78
  const WORKFLOW_STEP_GUIDANCE = {
77
- input: { meaning: "输入校验", hint: "对照服务声明的输入契约补齐字段后重新发起" },
78
- quota: { meaning: "额度检查", hint: "免费额度或限流未通过;登录或稍后重试" },
79
- geo: { meaning: "位置解析", hint: "检查 origin/destination 是否为真实地名;可附 origin_city 或坐标对象提示" },
80
- geo_confirm: { meaning: "确认后位置解析", hint: "用户确认的地点仍未解析成功;重新执行并让用户从候选项中按名称+坐标选择" },
81
- resolved: { meaning: "位置解析复核", hint: "位置解析未满足继续条件;检查 origin/destination 后新建执行重试" },
82
- resolved_after_confirm: { meaning: "位置确认复核", hint: "确认后的位置仍未通过复核;重新执行并核对用户所选候选项" },
83
- search: { meaning: "供应商车次检索", hint: "最常见是字段名错误(必须是 travel_date)或站名不存在;按 itpay docs show rail-booking 核对输入" },
84
- catalog: { meaning: "可行车次计算", hint: "位置已解析但无可行车次;换日期或换站点重试" },
85
- recommend: { meaning: "方案推荐", hint: "候选集无法产出推荐;放宽条件或换日期重试" },
86
- delivery: { meaning: "交付", hint: "结果组装失败;稍后重试或联系运营" },
79
+ input: { meaning: "输入校验", hint: "核对本服务的输入契约和服务端错误码;本执行的输入不能凭猜测修复" },
80
+ quota: { meaning: "额度检查", hint: "查看实际额度及授权状态;只按返回的登录或等待动作处理" },
81
+ geo: { meaning: "位置解析", hint: "查看实际地点解析错误;有歧义时让用户选择返回的候选" },
82
+ geo_confirm: { meaning: "确认后位置解析", hint: "核对服务端记录的候选与用户实际选择" },
83
+ resolved: { meaning: "位置解析复核", hint: "查看未满足的实际位置条件" },
84
+ resolved_after_confirm: { meaning: "位置确认复核", hint: "核对已确认地点与服务端复核结果" },
85
+ search: { meaning: "供应商车次检索", hint: "查看供应商返回的错误码与当前站对;不要推断是字段或站名错误" },
86
+ catalog: { meaning: "可行车次计算", hint: "查看已保存结果和实际过滤原因;不要无原因改日期或站点" },
87
+ recommend: { meaning: "方案推荐", hint: "查看已保存候选和实际推荐失败原因;不要无原因放宽条件" },
88
+ delivery: { meaning: "交付", hint: "读取当前执行状态;已有订单或付款状态不明时先核实,不重建" },
87
89
  };
88
90
  function failedWorkflowStep(steps, errorCode) {
89
91
  if (!steps)
@@ -544,7 +546,33 @@ export async function runServicesAction(backend, serviceExecutionID, actionType,
544
546
  request.result_item_id = resultItemID;
545
547
  if (options.requiredBefore)
546
548
  request.required_before = options.requiredBefore;
547
- const response = await backend.recordServiceExecutionAction(serviceExecutionID, request);
549
+ let response;
550
+ try {
551
+ response = await backend.recordServiceExecutionAction(serviceExecutionID, request);
552
+ }
553
+ catch (error) {
554
+ if (error instanceof HttpError && ["booking_review_invalid", "booking_review_changed", "booking_review_locked"].includes(error.code)) {
555
+ throw new CommandContractError(error.code, error.message, error.code === "booking_review_invalid"
556
+ ? "确认输入与当前草稿不符。重新读取同一booking的review模板,席别使用返回的代码,不填显示名或猜新值。若仅修正代码写法,且车次、实际席别、人数、价格及条款等已确认内容未变,可沿用用户已有明确确认后提交;草稿实质变化或缺少真实同意时,先请用户确认变化内容。"
557
+ : error.code === "booking_review_changed"
558
+ ? "草稿版本已变化;重新读取同一booking的当前review与revision,经用户核对后再提交。"
559
+ : "当前购买已有付款尝试;读取同一booking及订单状态,不创建新订单或重做付款。", [{ command: `itpay services next ${serviceExecutionID} --json`, reason: "读取同一booking的当前状态与review" }]);
560
+ }
561
+ throw error;
562
+ }
563
+ if ("state" in response) {
564
+ const updated = await backend.getServiceExecution(serviceExecutionID);
565
+ const envelope = servicesNextEnvelope(updated);
566
+ envelope.result = {
567
+ ...envelope.result,
568
+ planning_action: { action_id: response.action_id, state: response.state, replayed: response.replayed },
569
+ };
570
+ writeCommandEnvelope(envelope, {
571
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
572
+ ...(options.output ? { output: options.output } : {}),
573
+ });
574
+ return;
575
+ }
548
576
  if (selection && actionType === "select_candidate" && response.status === "approved") {
549
577
  const updated = await backend.getServiceExecution(serviceExecutionID);
550
578
  const preferred = updated.allowed_actions?.[0];
@@ -668,6 +696,26 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
668
696
  const checkoutURL = tokenizedCheckoutURL(checkout.checkout_url, displayToken, checkout.qr_payload);
669
697
  const cardURL = localizeCardURL(absolutePublicURL(config.baseURL, checkout.card_url ?? fallbackCardURL(config.baseURL, checkoutID, displayToken)), locale);
670
698
  const cardPNGURL = localizeCardURL(absolutePublicURL(config.baseURL, checkout.card_png_url ?? checkout.qr_png_url ?? fallbackCardPNGURL(config.baseURL, checkoutID, displayToken)), locale);
699
+ let itineraryTitle;
700
+ try {
701
+ const booking = await backend.getServiceExecution(serviceExecutionID);
702
+ if (booking.execution.service_id === "itpay-rail-booking") {
703
+ const leg = booking.rail_booking?.legs?.[0];
704
+ const review = booking.workflow?.human_action?.context?.review;
705
+ const reviewLeg = Array.isArray(review?.legs) ? review.legs[0] : undefined;
706
+ const facts = leg ?? reviewLeg;
707
+ if (facts) {
708
+ itineraryTitle = [facts.travel_date, facts.train_code,
709
+ facts.from ?? facts.from_station,
710
+ "→", facts.to ?? facts.to_station,
711
+ facts.seat_name ?? facts.seat_type_name]
712
+ .filter((value) => typeof value === "string" && value.length > 0).join(" ");
713
+ }
714
+ }
715
+ }
716
+ catch {
717
+ // Checkout already exists; a display-only title lookup cannot hide its official handoff.
718
+ }
671
719
  const plan = buildCheckoutQRPlan({
672
720
  host,
673
721
  checkoutID,
@@ -678,7 +726,7 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
678
726
  qrPNGURL: cardPNGURL,
679
727
  nextAction: checkout.checkout.next_action,
680
728
  orderItems: response.cart.items.map((item) => ({
681
- title: item.title,
729
+ title: itineraryTitle || item.title,
682
730
  quantity: item.quantity,
683
731
  amountMinor: item.amount_minor,
684
732
  currency: item.currency,
@@ -792,6 +840,7 @@ export async function runServicesGet(backend, serviceExecutionID, options = {})
792
840
  const lockedRefund = response.refunds.find((refund) => refund.access_locked);
793
841
  const nextState = servicesNextEnvelope(response);
794
842
  const result = {
843
+ ...nextState.result,
795
844
  service_execution_id: execution.service_execution_id,
796
845
  service_id: execution.service_id,
797
846
  status: execution.status,
@@ -809,13 +858,14 @@ export async function runServicesGet(backend, serviceExecutionID, options = {})
809
858
  } : {}),
810
859
  };
811
860
  const envelope = {
812
- status: "shown",
861
+ status: nextState.status,
813
862
  result,
814
- instruction: lockedRefund || isTerminalServiceExecutionStatus(execution.status)
815
- ? nextState.instruction
816
- : "时间线仅用于解释和恢复;按当前首选动作继续,不要重放已完成步骤。",
817
- next: nextState.next ? { command: nextState.next.command, reason: "继续当前首选动作" } : null,
818
- recovery: [{ command: `itpay services events ${serviceExecutionID} --json`, reason: "仅在需要完整诊断事件时使用" }],
863
+ instruction: nextState.instruction,
864
+ next: nextState.next,
865
+ ...(nextState.interaction ? { interaction: nextState.interaction } : {}),
866
+ ...(nextState.communication ? { communication: nextState.communication } : {}),
867
+ ...(nextState.handoff ? { handoff: nextState.handoff } : {}),
868
+ recovery: nextState.recovery,
819
869
  };
820
870
  writeCommandEnvelope(envelope, {
821
871
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
@@ -829,9 +879,31 @@ export async function runServicesGet(backend, serviceExecutionID, options = {})
829
879
  ],
830
880
  });
831
881
  }
882
+ function shouldWaitForServiceResult(model) {
883
+ if (model.current_delivery || model.rail_booking || isTerminalServiceExecutionStatus(model.execution.status))
884
+ return false;
885
+ if (model.rail_planning) {
886
+ const plan = model.rail_planning;
887
+ return !plan.recommendation && !(plan.alternatives?.length) &&
888
+ !["complete", "paused", "failed", "cancelled", "expired"].includes(plan.search?.expansion_status ?? "") &&
889
+ !plan.available_actions?.some((action) => ["select_journey", "confirm_location", "expand_search"].includes(action.type));
890
+ }
891
+ return ["queued", "running", "delivery"].includes(model.workflow?.status ?? "");
892
+ }
832
893
  export async function runServicesNext(backend, serviceExecutionID, options = {}) {
833
- const response = await backend.getServiceExecution(serviceExecutionID, options.sinceSnapshot ? { sinceSnapshot: options.sinceSnapshot } : {});
894
+ const started = Date.now();
895
+ const until = started + (options.timeoutSeconds ?? 0) * 1000;
896
+ const sleep = options.sleep ?? ((milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)));
897
+ let response = await backend.getServiceExecution(serviceExecutionID, options.sinceSnapshot ? { sinceSnapshot: options.sinceSnapshot } : {});
898
+ while (shouldWaitForServiceResult(response) && Date.now() < until) {
899
+ await sleep(options.pollIntervalMS ?? Math.max(1500, response.rail_planning?.search?.poll_after_ms ?? 0));
900
+ response = await backend.getServiceExecution(serviceExecutionID, options.sinceSnapshot ? { sinceSnapshot: options.sinceSnapshot } : {});
901
+ }
834
902
  const envelope = servicesNextEnvelope(response);
903
+ if (shouldWaitForServiceResult(response) && options.timeoutSeconds) {
904
+ envelope.result = { ...envelope.result, waited_seconds: Math.round((Date.now() - started) / 1000), wait_timed_out: true };
905
+ envelope.next = { command: `itpay services next ${serviceExecutionID} --timeout 120 --json`, reason: "稍后继续等待同一任务;不重新提交查询" };
906
+ }
835
907
  journalTaskState(serviceExecutionID, envelope, options.env ?? process.env);
836
908
  if (response.rail_booking)
837
909
  envelope.result = { ...envelope.result, rail_booking: response.rail_booking };
@@ -843,7 +915,7 @@ export async function runServicesNext(backend, serviceExecutionID, options = {})
843
915
  }
844
916
  export async function runServicesPage(backend, serviceExecutionID, resultItemID, options = {}) {
845
917
  const offset = options.offset ?? 0;
846
- const limit = options.limit ?? 5;
918
+ const limit = options.limit ?? 20;
847
919
  if (!Number.isInteger(offset) || offset < 0) {
848
920
  throw new CommandContractError("offset_invalid", "--offset must be a non-negative integer", "offset 必须是非负整数;本次未读取服务端分页。", [{ command: `itpay services page ${serviceExecutionID} ${resultItemID} --json`, reason: "从第一页重新读取" }]);
849
921
  }
@@ -853,6 +925,33 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
853
925
  const response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, resultItemID, offset, limit);
854
926
  const page = response.page;
855
927
  const container = (page.result ?? page);
928
+ const exactCandidate = limit === 1 && Array.isArray(container.candidates) && container.candidates.length === 1
929
+ ? container.candidates[0] : undefined;
930
+ if (exactCandidate && typeof exactCandidate.train_code === "string" && Array.isArray(exactCandidate.seats)) {
931
+ const offer = exactCandidate.booking_offer;
932
+ const seats = exactCandidate.seats.filter((seat) => seat.purchase_supported === true);
933
+ const seat = seats[0];
934
+ writeCommandEnvelope({
935
+ status: "exact_train_detail",
936
+ result: {
937
+ service_execution_id: serviceExecutionID, result_item_id: resultItemID, result_offset: offset,
938
+ candidate: exactCandidate,
939
+ ...(offer?.service_id && offer.selection_token ? { booking_template: {
940
+ command: `itpay services run ${offer.service_id} --input-json <file> --json`,
941
+ executable: false, required_input: ["file", "passengers", "seat_type"],
942
+ seat_choices: seats.map((choice) => ({ seat_type: choice.seat_type, seat_name: choice.seat_name })),
943
+ input_example: { selection: { token: offer.selection_token, ...(typeof seat?.seat_type === "string" ? { seat_type: seat.seat_type } : {}) }, passengers: 1 },
944
+ } } : {}),
945
+ },
946
+ instruction: offer?.selection_token
947
+ ? "这是已保存车次的当前详情;只按 purchase_supported 席别代码、库存与报价状态选择。购票模板的人数须按用户真实需求填写;查询价需在后续报价确认。若用户仅要求比较,答复后停止。"
948
+ : "这条车次当前没有可用的购买凭据;说明席别与余票事实,不要猜测代码或构造订单。",
949
+ next: null, recovery: [],
950
+ }, { ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
951
+ ...(options.output ? { output: options.output } : {}),
952
+ plainResult: [`${exactCandidate.train_code} ${exactCandidate.from_station ?? ""} → ${exactCandidate.to_station ?? ""}`] });
953
+ return;
954
+ }
856
955
  // rail.progressive.v2 saved pages key journeys/journey_page; legacy result
857
956
  // items key candidates/catalog_page. Both are committed, read-only slices.
858
957
  const v2Page = (container.journey_page ?? {});
@@ -873,11 +972,11 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
873
972
  next_offset: nextOffset,
874
973
  page,
875
974
  },
876
- instruction: "读取的是已保存结果的同版本分页,不重新查询、不消耗额度。用普通语言向用户说明本页候选(车次、席别、时刻、费用口径),铁路应付与地面估算费用分开表述;不要提及 safe_payload、Execution 或内部 ID。",
975
+ instruction: "读取的是已保存结果的同版本分页,不重新查询、不消耗额度。先读完所需页,再合并回答用户;不要逐页播报。",
877
976
  next: nextOffset !== null
878
977
  ? { command: response.snapshot_id
879
- ? `itpay services page ${serviceExecutionID} ${resultItemID} --cursor rcur_${nextOffset} --json`
880
- : `itpay services page ${serviceExecutionID} ${resultItemID} --offset ${nextOffset} --json`,
978
+ ? `itpay services page ${serviceExecutionID} ${resultItemID} --cursor rcur_${nextOffset} --limit ${limit} --json`
979
+ : `itpay services page ${serviceExecutionID} ${resultItemID} --offset ${nextOffset} --limit ${limit} --json`,
881
980
  reason: "读取同版本结果的下一页" }
882
981
  : null,
883
982
  recovery: offset > 0
@@ -930,13 +1029,56 @@ export async function runServicesList(backend, options = {}) {
930
1029
  });
931
1030
  }
932
1031
  export async function runServicesReadResult(backend, serviceExecutionID, options = {}) {
1032
+ if (!options.snapshot && !options.journey) {
1033
+ const model = await backend.getServiceExecution(serviceExecutionID);
1034
+ if (model.execution.service_id === "itpay-rail-exact") {
1035
+ const item = (model.current_result_items?.length ? model.current_result_items : model.result_items)?.[0];
1036
+ if (!item) {
1037
+ await runServicesNext(backend, serviceExecutionID, options);
1038
+ return;
1039
+ }
1040
+ const rows = [];
1041
+ let offset = 0;
1042
+ let total = 0;
1043
+ let resolvedStationPair;
1044
+ let nextOffset = 0;
1045
+ while (nextOffset !== null && rows.length < 100) {
1046
+ const response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, item.service_capability_result_item_id, offset, 20);
1047
+ const container = (response.page.result ?? response.page);
1048
+ const candidates = Array.isArray(container.candidates) ? container.candidates : [];
1049
+ resolvedStationPair ??= container.resolved_station_pair;
1050
+ rows.push(...candidates.map((value, index) => ({
1051
+ ...compactExactTrain(value),
1052
+ result_item_id: item.service_capability_result_item_id,
1053
+ result_offset: offset + index,
1054
+ detail: { command: `itpay services page ${serviceExecutionID} ${item.service_capability_result_item_id} --offset ${offset + index} --limit 1 --json`,
1055
+ reason: "读取此车次的当前席别与服务端购买凭据" },
1056
+ })));
1057
+ const page = (container.catalog_page ?? {});
1058
+ total = typeof container.catalog_total === "number" ? container.catalog_total : Math.max(total, rows.length);
1059
+ nextOffset = typeof page.next_offset === "number" && page.next_offset > offset ? page.next_offset : null;
1060
+ offset = nextOffset ?? offset;
1061
+ }
1062
+ writeCommandEnvelope({
1063
+ status: "ready",
1064
+ result: { service_execution_id: serviceExecutionID, scope: "station_pair", resolved_station_pair: resolvedStationPair,
1065
+ total, read_count: rows.length,
1066
+ trains: rows, ...(nextOffset !== null ? { next_offset: nextOffset } : {}) },
1067
+ instruction: "这是同一已保存站对的车次行。按用户时限、席别和人数筛选;零条仅代表这一站对。纯比较答复后结束;已有购票委托则执行所选行 detail.command 读取当前席别、库存和购买凭据。",
1068
+ next: nextOffset !== null ? { command: `itpay services page ${serviceExecutionID} ${item.service_capability_result_item_id} --offset ${nextOffset} --limit 20 --json`, reason: "继续读取同一结果的剩余车次" } : null,
1069
+ recovery: [],
1070
+ }, { ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
1071
+ ...(options.output ? { output: options.output } : {}),
1072
+ plainResult: rows.map((row) => `${row.train_code} ${row.from_station} ${row.departure} → ${row.to_station} ${row.arrival}`) });
1073
+ return;
1074
+ }
1075
+ }
933
1076
  // rail.progressive.v2 planning read: --journey/--snapshot select committed
934
1077
  // planning evidence — a free owner-validated read that never touches the
935
1078
  // grant/Vault path. Without the selectors the authorized-delivery flow is
936
1079
  // unchanged.
937
- // rail.planning full catalog read: --snapshot alone returns the complete
938
- // committed rail.catalog.v3 — shared dictionaries and field_legend are
939
- // preserved verbatim; one catalog, never split into candidates+journeys.
1080
+ // rail.planning catalog read: --snapshot returns compact rows for the
1081
+ // committed rail.catalog.v3; journey detail remains available separately.
940
1082
  if (options.snapshot && !options.journey) {
941
1083
  const committed = await backend.getRailPlanningCatalog(serviceExecutionID, options.snapshot);
942
1084
  const catalog = committed.catalog;
@@ -946,7 +1088,10 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
946
1088
  const decoded = catalog
947
1089
  ? decodeRailCatalogJourneys(catalog)
948
1090
  : { journeys: [], packed: false };
949
- const journeys = decoded.journeys;
1091
+ const journeys = decoded.journeys.map((journey) => ({ ...journey,
1092
+ detail: { command: `itpay services read-result ${serviceExecutionID} --snapshot ${committed.snapshot_id} --journey ${journey.ref} --json`,
1093
+ reason: "读取同一已保存快照的此行程完整购票条件" },
1094
+ }));
950
1095
  const counts = (catalog?.counts ?? {});
951
1096
  const journeyCount = journeys.length || Number(counts?.combinations ?? 0);
952
1097
  writeCommandEnvelope({
@@ -956,12 +1101,13 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
956
1101
  plan_id: committed.plan_id,
957
1102
  snapshot_id: committed.snapshot_id,
958
1103
  query_revision: committed.query_revision,
959
- catalog,
1104
+ coverage: catalog?.coverage,
1105
+ calculation_scope: catalog?.request_summary,
1106
+ total: journeyCount,
1107
+ journeys,
960
1108
  },
961
- instruction: "catalog 是本次查询的完整无损目录:journeys 为全部可行组合(含超出预览分页的组合),plans 是每个组合下的购票方案,stations/services/offers/ground_options 为共享字典,field_legend 解释紧凑字段。逐组合向用户说明车次、换乘与接驳取舍;席别与价格以 plans 内报价为准、下单前仍需核验;姓名身份证手机号只在 ItPay 网页填写。",
962
- next: journeys.length > 0 && journeys[0]?.ref && journeys[0].ref !== "?"
963
- ? { command: `itpay services read-result ${serviceExecutionID} --snapshot ${committed.snapshot_id} --journey ${journeys[0].ref} --json`, reason: "查看单个行程明细" }
964
- : { command: `itpay services next ${serviceExecutionID} --since-snapshot ${committed.snapshot_id} --json`, reason: "返回规划进展" },
1109
+ instruction: "这是同一已保存快照的紧凑行程目录。按用户问题筛选全部 journeys 并一次回答;未计入接驳或未知费用不能当免费。需要购票详情时执行所选行 detail.command;价格和余票仍须购买前实时报价。",
1110
+ next: null,
965
1111
  recovery: [],
966
1112
  }, {
967
1113
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
@@ -969,7 +1115,7 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
969
1115
  plainResult: journeys.length > 0
970
1116
  ? [
971
1117
  `${journeyCount} combinations:`,
972
- ...journeys.map((j) => `${j.defaultLayer === "backup" ? "[备选] " : "[主选择] "}${j.ref} ${j.route}`),
1118
+ ...journeys.map((j) => `${j.defaultLayer === "backup" ? "[备选] " : "[主选择] "}${j.ref} ${j.route} ${j.rides?.map((r) => `${r.train_code} ${r.departure}–${r.arrival}`).join(" / ") ?? ""}`),
973
1119
  ]
974
1120
  : [`catalog: ${committed.snapshot_id} (no combinations)`],
975
1121
  });
@@ -986,10 +1132,13 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
986
1132
  query_revision: detail.query_revision,
987
1133
  journey: detail.journey,
988
1134
  },
989
- instruction: "展示该 journey 的完整明细(车次、分段、席别报价、接驳估计与风险标注)。rail_payable 只是该行程当前可购报价的参考价,不是锁价;下单前须走受保护 Checkout 收集乘车人。",
990
- next: detail.journey?.booking_support === "single_leg"
991
- ? { command: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${detail.journey.journey_id} --json`, reason: "选定此行程" }
992
- : { command: `itpay services next ${serviceExecutionID} --since-snapshot ${detail.snapshot_id} --json`, reason: "返回规划进展" },
1135
+ instruction: "展示该 journey 的完整明细(车次、分段、席别报价、接驳估计与风险标注)。rail_payable 只是该行程当前可购报价的参考价,不是锁价;下单前须走受保护 Checkout 收集乘车人。用户已明确委托按规则代选时用 select_delegated;用户亲自选定该行程时用 select_user_chosen,不能把 Agent 选择记作人类动作。",
1136
+ next: null,
1137
+ ...(detail.journey?.booking_support === "single_leg" ? { interaction: {
1138
+ schema_version: "itpay.interaction.v1", stage: "query_results_ready",
1139
+ select_user_chosen: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${detail.journey.journey_id} --json`,
1140
+ select_delegated: `itpay services action ${serviceExecutionID} --action select_journey --actor-type agent --status approved --input journey_id=${detail.journey.journey_id} --input selection_mode=delegated --json`,
1141
+ } } : {}),
993
1142
  recovery: [],
994
1143
  }, {
995
1144
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
@@ -1014,6 +1163,25 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
1014
1163
  plainResult: servicesNextPlainResult(envelope.result),
1015
1164
  });
1016
1165
  }
1166
+ function compactExactTrain(candidate) {
1167
+ const seats = Array.isArray(candidate.seats) ? candidate.seats : [];
1168
+ return {
1169
+ candidate_id: candidate.candidate_id,
1170
+ train_code: candidate.train_code,
1171
+ from_station: candidate.from_station,
1172
+ from_station_code: candidate.from_station_code,
1173
+ to_station: candidate.to_station,
1174
+ to_station_code: candidate.to_station_code,
1175
+ travel_date: candidate.travel_date,
1176
+ departure: candidate.departure,
1177
+ arrival: candidate.arrival,
1178
+ purchase_supported: candidate.purchase_supported ?? Boolean(candidate.booking_offer),
1179
+ seats: seats.map((seat) => ({ seat_type: seat.seat_type, seat_name: seat.seat_name,
1180
+ availability_text: seat.availability_text, remaining: seat.remaining,
1181
+ fare_minor: seat.fare_minor, quoted_total_minor: seat.quoted_total_minor,
1182
+ purchase_supported: seat.purchase_supported, requires_price_refresh: seat.requires_price_refresh })),
1183
+ };
1184
+ }
1017
1185
  function railLegSeatSummary(seat) {
1018
1186
  return {
1019
1187
  passenger_index: seat.passenger_index,
@@ -1095,12 +1263,19 @@ function railJourneySummary(card, serviceExecutionID) {
1095
1263
  const first = rides[0];
1096
1264
  const last = rides.at(-1);
1097
1265
  const offer = card.representative_offer;
1266
+ const metrics = card.representative_metrics;
1098
1267
  return {
1099
1268
  journey_id: card.journey_id,
1100
1269
  route: (card.route_names?.length ? card.route_names : card.route ?? []).join("→"),
1101
1270
  ...(trains.length ? { trains } : {}),
1102
1271
  ...(first?.departure || last?.arrival ? { time: `${first?.departure ?? ""}–${last?.arrival ?? ""}` } : {}),
1103
1272
  ...(offer ? { price: formatMoney(offer.rail_payable_minor, offer.currency) } : { price: "unknown" }),
1273
+ ...(metrics ? { calculation_scope: {
1274
+ origin: metrics.origin_scope ?? "legacy_unknown", destination: metrics.destination_scope ?? "legacy_unknown",
1275
+ duration_basis: metrics.duration_basis ?? "legacy_unknown", arrival_basis: metrics.arrival_basis ?? "legacy_unknown",
1276
+ cost_basis: metrics.cost_basis ?? "legacy_unknown",
1277
+ duration_minutes: metrics.door_to_door_min ?? null,
1278
+ } } : {}),
1104
1279
  ...(card.decision_role ? { decision_role: card.decision_role } : {}),
1105
1280
  ...(card.explanation?.length ? { explanation: card.explanation } : {}),
1106
1281
  ...(card.reason_codes?.length ? { reason_codes: card.reason_codes } : {}),
@@ -1113,7 +1288,7 @@ function railJourneySummary(card, serviceExecutionID) {
1113
1288
  ...(card.risk_notes?.length ? { risk_notes: card.risk_notes } : {}),
1114
1289
  ...(card.booking_support === "single_leg"
1115
1290
  ? {
1116
- select: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${card.journey_id} --json`,
1291
+ select_user_chosen: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${card.journey_id} --json`,
1117
1292
  // Delegated selection under explicit user rules is an agent action —
1118
1293
  // never record it as a human pick.
1119
1294
  select_delegated: `itpay services action ${serviceExecutionID} --action select_journey --actor-type agent --status approved --input journey_id=${card.journey_id} --input selection_mode=delegated --json`,
@@ -1126,7 +1301,19 @@ function railJourneySummary(card, serviceExecutionID) {
1126
1301
  command: `itpay services run ${card.booking_offer.service_id} --input-json <file> --json`,
1127
1302
  required_input: ["file"],
1128
1303
  executable: false,
1129
- input_example: { selection: { token: card.booking_offer.selection_token, seat_type: "<席别>" }, passengers: "<n>" },
1304
+ seat_choices: (card.representative_offer?.ticket_offers ?? []).map((offer) => ({
1305
+ seat_type: offer.seat_type,
1306
+ seat_name: offer.seat_name,
1307
+ })).filter((seat) => typeof seat.seat_type === "string"),
1308
+ input_example: {
1309
+ selection: {
1310
+ token: card.booking_offer.selection_token,
1311
+ ...(typeof card.representative_offer?.ticket_offers?.[0]?.seat_type === "string"
1312
+ ? { seat_type: card.representative_offer.ticket_offers[0].seat_type }
1313
+ : {}),
1314
+ },
1315
+ passengers: card.passengers ?? 1,
1316
+ },
1130
1317
  },
1131
1318
  }
1132
1319
  : {}),
@@ -1184,11 +1371,12 @@ function railPlanningEnvelope(model) {
1184
1371
  ...(plan.notices?.length ? { notices: plan.notices } : {}),
1185
1372
  ...(plan.recommendation ? { recommendation: railJourneySummary(plan.recommendation, se) } : {}),
1186
1373
  ...(cards.length ? { journeys: cards } : {}),
1374
+ ...(plan.snapshot_id ? { full_result: { command: `itpay services read-result ${se} --snapshot ${plan.snapshot_id} --json`, meaning: "读取已保存的完整目录;推荐和journeys仅是摘要" } } : {}),
1187
1375
  ...(plan.available_actions?.length ? { available_actions: plan.available_actions } : {}),
1188
1376
  };
1189
1377
  const nextPoll = {
1190
- command: `itpay services next ${se}${plan.snapshot_id ? ` --since-snapshot ${plan.snapshot_id}` : ""} --json`,
1191
- reason: "读取同一规划的增量进展(不触发供应商调用)",
1378
+ command: `itpay services next ${se} --timeout 120${plan.snapshot_id ? ` --since-snapshot ${plan.snapshot_id}` : ""} --json`,
1379
+ reason: "仍需更多结果时有界等待同一规划(不触发供应商调用)",
1192
1380
  };
1193
1381
  // Two-goal interaction contract (spec 03 §2): the local Agent picks compare
1194
1382
  // or prepare_checkout from full context — the envelope teaches both branches
@@ -1207,7 +1395,7 @@ function railPlanningEnvelope(model) {
1207
1395
  ...(bookable
1208
1396
  ? {
1209
1397
  steps: [
1210
- "按用户明确规则在 journeys 中选择合格者(卡片 select_delegated 提交 agent 委托选择;人类亲自选择用 select)",
1398
+ "按用户明确规则在 journeys 中选择合格者(卡片 select_delegated 提交 agent 委托选择;人类亲自选择用 select_user_chosen)",
1211
1399
  `用所选卡片的 booking_template 写 owner-only 临时输入(selection token + 席别 + 人数,不含身份信息),运行 itpay services run itpay-rail-booking --input-json <file> --json`,
1212
1400
  "owner 要求聊天 review 时一次合并补齐必要字段,真实确认后提交原 workflow:confirm_booking;不得伪造 accept",
1213
1401
  `确认后恢复同一购买执行:itpay services run itpay-rail-booking --execution <实际ID> --json,沿用有界等待与受保护 Checkout`,
@@ -1240,15 +1428,20 @@ function railPlanningEnvelope(model) {
1240
1428
  : undefined;
1241
1429
  switch (expansion) {
1242
1430
  case "complete":
1431
+ if (plan.search?.reason === "ground_evidence_required") {
1432
+ return {
1433
+ status: "awaiting_input", result,
1434
+ instruction: "已查到铁路班次,但具体地址的接驳或出站证据不足,无法验证到达时限;剩余站对未继续查询,不能说没有车。向用户说明已查范围,并请其补充准确起终点地址或可验证的接驳/出站信息;取得新事实后再按其要求重新规划,不要默认扩大铁路搜索。",
1435
+ next: null, recovery: [],
1436
+ };
1437
+ }
1243
1438
  return {
1244
1439
  status: "ready",
1245
1440
  result,
1246
1441
  // §7.3: the lead line carries real combination counts bucketed by
1247
1442
  // verified transfer count — seat rows never inflate the journey count.
1248
- instruction: `${journeyMix ? `本次共${journeyMix}(席别不重复计数)。` : ""}${plan.search?.reason === "budget_exhausted" ? "本次查询已达到上限,以下是已查到的方案,搜索范围尚未全部核验。" : "本轮规划已完成。"}compare:解释首选及最多两个有意义备选的时间/费用/便利性取舍后等待用户选择。prepare_checkout:用户已明确委托按规则选择时,按 interaction.recipe 选合格者并继续到官方确认页,不再次问是否下单;身份、review和最终付款边界仍然生效。乘车人身份信息不在此收集,后续购买走受保护 Checkout。`,
1249
- next: journeys[0]?.booking_support === "single_leg"
1250
- ? { command: railJourneySummary(journeys[0], se).select, reason: "用户在 compare 后选定推荐行程时执行;prepare_checkout 且规则命中时用 select_delegated" }
1251
- : null,
1443
+ instruction: `${journeyMix ? `本次共${journeyMix}(席别不重复计数)。` : ""}${plan.search?.reason === "budget_exhausted" ? "本次查询已达到上限,以下是已查到的方案,搜索范围尚未全部核验。" : "本轮规划已完成。"}推荐和journeys是摘要;追问其他车次先读取 full_result 的已保存完整目录。compare:解释首选及最多两个有意义备选的时间/费用/便利性取舍后等待用户选择。prepare_checkout:用户已明确委托按规则选择时,按 interaction.recipe 选合格者并继续到官方确认页,不再次问是否下单;身份、review和最终付款边界仍然生效。乘车人身份信息不在此收集,后续购买走受保护 Checkout。`,
1444
+ next: null,
1252
1445
  ...(queryResultsInteraction ? { interaction: queryResultsInteraction } : {}),
1253
1446
  ...(queryResultsCommunication ? { communication: queryResultsCommunication } : {}),
1254
1447
  recovery: [],
@@ -1319,9 +1512,11 @@ function railPlanningEnvelope(model) {
1319
1512
  return {
1320
1513
  status: expansion,
1321
1514
  result,
1322
- instruction: `${modelDegradedHint}${failedPairsHint}${savedCatalog}${partial}本次规划已结束且不可续用;展示已有卡片后如需重查请用户确认后用同一服务新建执行。不要自动重新发起。`,
1515
+ instruction: `${modelDegradedHint}${failedPairsHint}${savedCatalog}${partial}${expansion === "expired" ? "规划已过期,已保存目录仍可读取并回答历史结果追问;购买须重新核验实时余票与报价。" : "本次规划已结束,展示已有结果;需要新的供应商观测时再明确发起查询。"}`,
1323
1516
  next: null,
1324
- recovery: [{ command: `itpay services events ${se} --json`, reason: "读取同一任务的处理记录" }],
1517
+ recovery: plan.snapshot_id
1518
+ ? [{ command: `itpay services read-result ${se} --snapshot ${plan.snapshot_id} --json`, reason: "读取同一任务已保存的完整目录" }]
1519
+ : [{ command: `itpay services next ${se} --json`, reason: "读取同一任务当前状态" }],
1325
1520
  };
1326
1521
  }
1327
1522
  default: {
@@ -1593,14 +1788,23 @@ function servicesNextEnvelope(model) {
1593
1788
  const failedInvocation = recovery
1594
1789
  ? [...model.provider_invocations].reverse().find((item) => typeof item.status === "string" && item.status.startsWith("failed"))
1595
1790
  : undefined;
1596
- const errorDetail = typeof failedInvocation?.error_message === "string" && failedInvocation.error_message ? failedInvocation.error_message : undefined;
1597
1791
  const providerErrorCode = typeof failedInvocation?.error_code === "string" && failedInvocation.error_code ? failedInvocation.error_code : undefined;
1792
+ const quoteFailure = execution.service_id === "itpay-rail-booking" ? {
1793
+ quote_source_unavailable: "实时车票数据暂不可用;保留原选择,稍后按用户意愿重新核验,不创建替代订单。",
1794
+ quote_train_changed: "所选车次的时刻或运行信息已变化;向用户说明并征询是否重新查票。",
1795
+ quote_seat_changed: "所选席别已变化;向用户说明并征询新的席别选择。",
1796
+ quote_seat_unavailable: "所选席别当前无足够余票;告知用户并由其决定是否换方案。",
1797
+ quote_refresh_expired: "实时报价过程超时;保留原选择,不把旧价当可付款价格。",
1798
+ }[providerErrorCode ?? ""] : undefined;
1799
+ const failureCode = providerErrorCode ?? model.workflow?.error_code;
1598
1800
  const failedInstruction = recovery
1599
- ? failedStepGuidance
1600
- ? `执行在「${failedStepGuidance.meaning}」步失败:${failedStepGuidance.hint}${errorDetail ? `。供应商返回:${errorDetail}` : ""}。本执行已终止不能续用;修正后用同一服务新建执行重试,不要盲目重放。`
1601
- : state === "failed"
1602
- ? `执行已失败${failedStep ? `(失败步骤:${failedStep})` : ""}且不可续用;修正输入后用同一服务新建执行重试。`
1603
- : "执行未完成,请按步骤错误处理;不要重建执行或重复调用。"
1801
+ ? quoteFailure
1802
+ ? quoteFailure
1803
+ : failedStepGuidance
1804
+ ? `执行在「${failedStepGuidance.meaning}」步失败${failureCode ? `(错误码:${failureCode})` : ""}:${failedStepGuidance.hint}。先核对原因和当前执行状态,不要盲目重放。`
1805
+ : state === "failed"
1806
+ ? `执行已失败${failureCode ? `(错误码:${failureCode})` : ""}且不可续用;核对服务端原因、订单与付款事实后再决定下一步。`
1807
+ : "执行未完成,请按步骤错误处理;不要重建执行或重复调用。"
1604
1808
  : undefined;
1605
1809
  return {
1606
1810
  status: state,
@@ -1612,8 +1816,8 @@ function servicesNextEnvelope(model) {
1612
1816
  ...(guidance && state === "input_required" ? { guidance } : {}),
1613
1817
  ...(failedStep ? { failed_step: failedStep, ...(failedStepGuidance ? { failed_step_meaning: failedStepGuidance.meaning } : {}) } : {}),
1614
1818
  ...(providerErrorCode ? { provider_error_code: providerErrorCode } : {}),
1615
- ...(errorDetail ? { error_detail: errorDetail } : {}),
1616
- ...(recovery ? { retryable: state === "failed" } : {}),
1819
+ ...(providerErrorCode && !quoteFailure ? { diagnostic_code: providerErrorCode } : {}),
1820
+ ...(recovery ? { retryable: false } : {}),
1617
1821
  },
1618
1822
  instruction: failedInstruction
1619
1823
  ?? (state === "payment"
@@ -1639,10 +1843,7 @@ function servicesNextEnvelope(model) {
1639
1843
  },
1640
1844
  } : {}),
1641
1845
  recovery: recovery && state === "failed"
1642
- ? [
1643
- { command: `itpay services start ${execution.service_id} --json`, reason: "修正输入后重新发起(本执行已终止不能续用)" },
1644
- ...(guidance ? [{ command: "itpay docs show rail-booking --json", reason: "查看本服务输入字段契约与示例" }] : []),
1645
- ]
1846
+ ? [{ command: `itpay services get ${id} --json`, reason: "核对本执行的错误、结果和付款事实" }]
1646
1847
  : [],
1647
1848
  };
1648
1849
  }
@@ -1659,6 +1860,23 @@ function servicesNextEnvelope(model) {
1659
1860
  }
1660
1861
  const delivery = currentDelivery;
1661
1862
  const deliveryMode = serviceDeliveryMode(model);
1863
+ if (execution.service_id === "itpay-rail-exact" && currentItems.length > 0) {
1864
+ const item = currentItems[0];
1865
+ const container = (item.safe_payload?.result ?? item.safe_payload);
1866
+ const total = typeof container?.catalog_total === "number" ? container.catalog_total :
1867
+ Array.isArray(container?.candidates) ? container.candidates.length : 0;
1868
+ return {
1869
+ status: total > 0 ? "result_ready" : "no_result",
1870
+ result: { service_execution_id: execution.service_execution_id, scope: "station_pair",
1871
+ resolved_station_pair: container?.resolved_station_pair, total,
1872
+ result_item_id: item.service_capability_result_item_id },
1873
+ instruction: total > 0
1874
+ ? "直达站对结果已保存。执行 next.command 一次读取紧凑车次行,按用户条件筛选;若只需比较,答复后结束;已有购票委托则读取所选行详情。"
1875
+ : "这一已解析站对返回 0 条;不代表全城无车。若原需求需要更广覆盖,可核验另一可信站对或 Smart。",
1876
+ next: { command: `itpay services read-result ${execution.service_execution_id} --json`, reason: "读取这一已保存站对的完整紧凑结果" },
1877
+ recovery: [],
1878
+ };
1879
+ }
1662
1880
  const candidateSelection = model.allowed_actions?.find((action) => action.type === "select_candidate");
1663
1881
  if (candidateSelection && currentItems.length > 0) {
1664
1882
  const paidCapability = delivery?.capability_id
@@ -2092,12 +2310,9 @@ export async function runServicesRun(backend, config, serviceID, input, options
2092
2310
  const until = Date.now() + (options.timeoutSeconds ?? 120) * 1000;
2093
2311
  const sleep = options.sleep ?? ((milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)));
2094
2312
  const paid = () => model.payment_bindings.some((binding) => binding.status === "payment_verified") || model.checkout_bindings.some((binding) => binding.status === "payment_verified");
2095
- // §7.2: for progressive services the committed rail_planning projection is
2096
- // itself a deliverable — return the same railPlanningEnvelope as soon as it
2097
- // exists instead of hiding every stage behind the 120s workflow poll. The
2098
- // owner keeps advancing in the background; the CLI returning early never
2099
- // cancels it.
2100
- while ((["queued", "running", "delivery"].includes(model.workflow?.status ?? "") || (model.workflow?.status === "payment" && paid())) && !model.current_delivery && !model.rail_planning && Date.now() < until) {
2313
+ // A planning projection can exist before any usable result or user action.
2314
+ // Keep the existing bounded wait until there is something to hand over.
2315
+ while ((["queued", "running", "delivery"].includes(model.workflow?.status ?? "") || (model.workflow?.status === "payment" && paid())) && shouldWaitForServiceResult(model) && Date.now() < until) {
2101
2316
  await sleep(options.pollIntervalMS ?? 1500);
2102
2317
  model = await backend.getServiceExecution(id);
2103
2318
  }
@@ -2116,7 +2331,10 @@ export async function runServicesRun(backend, config, serviceID, input, options
2116
2331
  await runServicesNext(backend, id, options);
2117
2332
  }
2118
2333
  catch (cause) {
2119
- if (cause instanceof CommandContractError || cause instanceof HttpError)
2334
+ if (cause instanceof HttpError && cause.code === "rail_seat_code_invalid" && id) {
2335
+ throw new CommandContractError(cause.code, cause.message, "席别必须使用原查询 booking_template.seat_choices 中与名称对应的代码,不能填“二等座”等显示名。用原 selection token 与真实人数修正输入,继续同一booking execution;若只是代码写法修正且车次、席别名称、人数等购买条件未变,沿用用户已给的确认,不再重复询问。", [{ command: `itpay services next ${id} --json`, reason: "读取同一booking当前状态后修正输入" }]);
2336
+ }
2337
+ if (cause instanceof CommandContractError || cause instanceof HttpError || cause instanceof DeviceLockBusyError || cause instanceof DeviceStateError)
2120
2338
  throw cause;
2121
2339
  if (cause instanceof HttpTransportError && !id) {
2122
2340
  throw new CommandContractError("workflow_start_outcome_unknown", cause.message, "创建服务执行时没有收到完整响应。先查询当前身份可见的执行;不要直接重跑并创建替代执行。", [{ command: "itpay services list --json", reason: "查找可能已经创建的服务执行" }]);