@itpay/cli 2.1.11 → 2.1.17

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.
@@ -156,6 +156,10 @@ export class BackendClient {
156
156
  getGrantedServiceResult(serviceExecutionID) {
157
157
  return this.http.get(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/granted-result`);
158
158
  }
159
+ getRailPlanningCatalog(serviceExecutionID, snapshotID) {
160
+ const query = snapshotID ? `?snapshot=${encodeURIComponent(snapshotID)}` : "";
161
+ return this.http.get(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/rail-planning/catalog${query}`);
162
+ }
159
163
  getRailJourneyDetail(serviceExecutionID, journeyID, snapshotID) {
160
164
  const query = snapshotID ? `?snapshot=${encodeURIComponent(snapshotID)}` : "";
161
165
  return this.http.get(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/rail-planning/journeys/${encodeURIComponent(journeyID)}${query}`);
@@ -23,12 +23,17 @@ export class CommandContractError extends Error {
23
23
  this.name = "CommandContractError";
24
24
  }
25
25
  }
26
+ // stdoutEnvelopeLimit is the hard byte budget for one emitted JSON envelope:
27
+ // public rail pages/projections must fit 32 KiB end-to-end, so oversized
28
+ // pretty output falls back to a compact line rather than spilling.
29
+ const stdoutEnvelopeLimit = 32 * 1024;
26
30
  export function writeCommandEnvelope(value, options = {}) {
27
31
  const out = resolveOutput(options.output);
28
32
  const agentType = options.agentType ?? declaredAgentType();
29
33
  const qualified = qualifyEnvelope(value, agentType);
30
34
  if (options.jsonOutput) {
31
- out(JSON.stringify(qualified, null, 2) + "\n");
35
+ const pretty = JSON.stringify(qualified, null, 2) + "\n";
36
+ out(pretty.length <= stdoutEnvelopeLimit ? pretty : JSON.stringify(qualified) + "\n");
32
37
  return;
33
38
  }
34
39
  out(`${qualified.status}\n`);
@@ -0,0 +1,112 @@
1
+ // shared_rows.v1 catalog decode for the rail planning full-catalog read.
2
+ // The committed rail.catalog.v3 may ship positional rows (journeys, plans,
3
+ // profiles keyed by journey_columns plus shared ride_table / *_index tables)
4
+ // instead of per-journey objects. The summary path needs only ref + route
5
+ // text + default layer; the full packed document is passed through verbatim
6
+ // for JSON output — decoding is for the human-readable summary and selectors.
7
+ export class RailCatalogEncodingError extends Error {
8
+ constructor(encoding) {
9
+ super(`catalog encoding ${JSON.stringify(encoding)} is not supported by this CLI — ` +
10
+ `run npm install -g @itpay/cli to upgrade; the committed catalog is intact`);
11
+ this.name = "rail_catalog_encoding";
12
+ }
13
+ }
14
+ function asString(v) {
15
+ return typeof v === "string" && v.length > 0 ? v : undefined;
16
+ }
17
+ function asIndex(v, bound) {
18
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v >= bound) {
19
+ throw new Error("shared_rows: index out of bounds");
20
+ }
21
+ return v;
22
+ }
23
+ // _cn_route_text port: "南头 C7608 → 广州南换乘71分 → G2944 重庆西" — kept
24
+ // byte-identical with the planner so a packed journey renders the same text
25
+ // its object form carried.
26
+ function regenerateRoute(ridePairs, rideTable, stations, services) {
27
+ const name = (code, fallback) => asString(stations[asString(code) ?? ""]) ?? asString(fallback) ?? asString(code) ?? "?";
28
+ const rides = ridePairs.map((pair) => {
29
+ if (!Array.isArray(pair) || pair.length !== 2)
30
+ throw new Error("shared_rows: malformed ride pair");
31
+ const src = rideTable[asIndex(pair[0], rideTable.length)];
32
+ if (!Array.isArray(src))
33
+ throw new Error("shared_rows: malformed ride row");
34
+ return { row: src, wait: pair[1] };
35
+ });
36
+ const first = rides[0];
37
+ if (first === undefined)
38
+ return "";
39
+ const trainCodes = (rideRow) => (Array.isArray(rideRow[4]) ? rideRow[4] : [])
40
+ .map((ref) => {
41
+ const svc = services[asString(ref) ?? ""];
42
+ return asString(svc?.tc);
43
+ })
44
+ .filter((c) => Boolean(c));
45
+ const waitOf = (ride) => typeof ride.wait === "number" ? ride.wait
46
+ : typeof ride.row[5] === "number" ? ride.row[5]
47
+ : undefined;
48
+ const sameRun = (rideRow) => rideRow[6] === true;
49
+ const parts = [`${name(first.row[2], undefined)} ${trainCodes(first.row).join("/")}`];
50
+ for (let i = 1; i < rides.length; i += 1) {
51
+ const prev = rides[i - 1].row;
52
+ const ride = rides[i];
53
+ const via = name(prev[3], undefined);
54
+ const wait = waitOf(ride);
55
+ parts.push(sameRun(ride.row)
56
+ ? wait !== undefined ? `${via}同车停${wait}分` : `${via}同车接续`
57
+ : wait !== undefined ? `${via}换乘${wait}分` : `${via}换乘`);
58
+ parts.push(`${trainCodes(ride.row).join("/")} ${name(ride.row[3], undefined)}`);
59
+ }
60
+ if (rides.length === 1) {
61
+ parts.push(name(first.row[3], undefined));
62
+ }
63
+ return parts.filter((s) => s.trim().length > 0).join(" → ");
64
+ }
65
+ // decodeRailCatalogJourneys returns one summary per committed combination in
66
+ // catalog order. Legacy object-form catalogs (no `encoding`) pass through;
67
+ // shared_rows.v1 rows are positional under journey_columns
68
+ // [ref, route, rides, plans, gc, pr, profiles, tc, ev, rep, tw, risk].
69
+ // Any other encoding throws RailCatalogEncodingError — an explicit upgrade
70
+ // hint, never an empty catalog.
71
+ export function decodeRailCatalogJourneys(catalog) {
72
+ const encoding = catalog["encoding"];
73
+ const layerMap = catalog["choice_layers"]?.["journey_layer"] ?? {};
74
+ const layerOf = (ref) => layerMap[ref] === "backup" ? "backup" : "main";
75
+ if (encoding === undefined || encoding === null) {
76
+ const rows = Array.isArray(catalog["journeys"]) ? catalog["journeys"] : [];
77
+ return {
78
+ packed: false,
79
+ journeys: rows.map((j) => {
80
+ const rec = (j ?? {});
81
+ const ref = asString(rec["ref"]) ?? asString(rec["journey_id"]) ?? "?";
82
+ return {
83
+ ref,
84
+ route: asString(rec["route"]) ?? asString(rec["route_text"]) ?? asString(rec["summary"]) ?? "",
85
+ defaultLayer: layerOf(ref),
86
+ };
87
+ }),
88
+ };
89
+ }
90
+ if (encoding !== "shared_rows.v1") {
91
+ throw new RailCatalogEncodingError(encoding);
92
+ }
93
+ const columns = catalog["journey_columns"];
94
+ if (!Array.isArray(columns) || columns[0] !== "ref" || columns[1] !== "route" || columns[2] !== "rides") {
95
+ throw new RailCatalogEncodingError(`${String(encoding)} (unexpected journey_columns)`);
96
+ }
97
+ const rideTable = (Array.isArray(catalog["ride_table"]) ? catalog["ride_table"] : []);
98
+ const stations = (catalog["stations"] ?? {});
99
+ const services = (catalog["services"] ?? {});
100
+ const journeys = (Array.isArray(catalog["journeys"]) ? catalog["journeys"] : []);
101
+ return {
102
+ packed: true,
103
+ journeys: journeys.map((row) => {
104
+ if (!Array.isArray(row))
105
+ throw new Error("shared_rows: journey row is not an array");
106
+ const ref = asString(row[0]) ?? "?";
107
+ const route = asString(row[1]) ??
108
+ (Array.isArray(row[2]) ? regenerateRoute(row[2], rideTable, stations, services) : "");
109
+ return { ref, route, defaultLayer: layerOf(ref) };
110
+ }),
111
+ };
112
+ }
@@ -7,6 +7,7 @@ import { ensureIdeImageAttach } from "../render/ide.js";
7
7
  import { buildCheckoutHandoff, shouldPrepareLocalCheckoutImage } from "./checkout_handoff.js";
8
8
  import { localizeCardURL, normalizeCardLocale } from "../render/locale.js";
9
9
  import { buildAgentChatHandoff } from "../render/markdown.js";
10
+ import { decodeRailCatalogJourneys } from "./rail_catalog.js";
10
11
  import { platformKeyForHost } from "../render/plan.js";
11
12
  import { renderTerminalQR } from "../render/qr.js";
12
13
  // Declared at execution creation: the client understands the rail.progressive.v2
@@ -867,6 +868,47 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
867
868
  // planning evidence — a free owner-validated read that never touches the
868
869
  // grant/Vault path. Without the selectors the authorized-delivery flow is
869
870
  // unchanged.
871
+ // rail.planning full catalog read: --snapshot alone returns the complete
872
+ // committed rail.catalog.v3 — shared dictionaries and field_legend are
873
+ // preserved verbatim; one catalog, never split into candidates+journeys.
874
+ if (options.snapshot && !options.journey) {
875
+ const committed = await backend.getRailPlanningCatalog(serviceExecutionID, options.snapshot);
876
+ const catalog = committed.catalog;
877
+ // The committed catalog may ship shared_rows.v1 positional rows — decode
878
+ // the summary through the column legend; an unknown encoding is an
879
+ // explicit upgrade error, never an empty catalog.
880
+ const decoded = catalog
881
+ ? decodeRailCatalogJourneys(catalog)
882
+ : { journeys: [], packed: false };
883
+ const journeys = decoded.journeys;
884
+ const counts = (catalog?.counts ?? {});
885
+ const journeyCount = journeys.length || Number(counts?.combinations ?? 0);
886
+ writeCommandEnvelope({
887
+ status: "ready",
888
+ result: {
889
+ service_execution_id: serviceExecutionID,
890
+ plan_id: committed.plan_id,
891
+ snapshot_id: committed.snapshot_id,
892
+ query_revision: committed.query_revision,
893
+ catalog,
894
+ },
895
+ instruction: "catalog 是本次查询的完整无损目录:journeys 为全部可行组合(含超出预览分页的组合),plans 是每个组合下的购票方案,stations/services/offers/ground_options 为共享字典,field_legend 解释紧凑字段。逐组合向用户说明车次、换乘与接驳取舍;席别与价格以 plans 内报价为准、下单前仍需核验;姓名身份证手机号只在 ItPay 网页填写。",
896
+ next: journeys.length > 0
897
+ ? { command: `itpay services read-result ${serviceExecutionID} --snapshot ${committed.snapshot_id} --journey ${journeys[0]?.ref ?? '?'} --json`, reason: "查看单个行程明细" }
898
+ : { command: `itpay services next ${serviceExecutionID} --since-snapshot ${committed.snapshot_id} --json`, reason: "返回规划进展" },
899
+ recovery: [],
900
+ }, {
901
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
902
+ ...(options.output ? { output: options.output } : {}),
903
+ plainResult: journeys.length > 0
904
+ ? [
905
+ `${journeyCount} combinations:`,
906
+ ...journeys.map((j) => `${j.defaultLayer === "backup" ? "[备选] " : "[主选择] "}${j.ref} ${j.route}`),
907
+ ]
908
+ : [`catalog: ${committed.snapshot_id} (no combinations)`],
909
+ });
910
+ return;
911
+ }
870
912
  if (options.journey) {
871
913
  const detail = await backend.getRailJourneyDetail(serviceExecutionID, options.journey, options.snapshot);
872
914
  writeCommandEnvelope({
@@ -993,6 +1035,12 @@ function railJourneySummary(card, serviceExecutionID) {
993
1035
  ...(trains.length ? { trains } : {}),
994
1036
  ...(first?.departure || last?.arrival ? { time: `${first?.departure ?? ""}–${last?.arrival ?? ""}` } : {}),
995
1037
  ...(offer ? { price: formatMoney(offer.rail_payable_minor, offer.currency) } : { price: "unknown" }),
1038
+ ...(card.decision_role ? { decision_role: card.decision_role } : {}),
1039
+ ...(card.explanation?.length ? { explanation: card.explanation } : {}),
1040
+ ...(card.reason_codes?.length ? { reason_codes: card.reason_codes } : {}),
1041
+ ...(card.tradeoff_codes?.length ? { tradeoff_codes: card.tradeoff_codes } : {}),
1042
+ ...(card.recommended_profile ? { recommended_profile: card.recommended_profile,
1043
+ price_basis: "price为默认购票方案参考价;推荐对应的席别、接驳及总价以recommended_profile为准,按其中ticket_plan_ref/offer_refs查看并确认后下单。" } : {}),
996
1044
  availability: card.availability,
997
1045
  booking_support: card.booking_support,
998
1046
  ...(card.observed_at ? { observed_at: card.observed_at } : {}),
@@ -1015,8 +1063,27 @@ function railPlanningEnvelope(model) {
1015
1063
  const plan = model.rail_planning;
1016
1064
  const se = model.execution.service_execution_id;
1017
1065
  const expansion = plan.search?.expansion_status ?? "running";
1066
+ const counts = plan.search?.counts;
1018
1067
  const journeys = [plan.recommendation, ...(plan.alternatives ?? [])].filter(Boolean);
1019
1068
  const cards = journeys.map((card) => railJourneySummary(card, se));
1069
+ // Journey counts come from the committed catalog — real combination counts,
1070
+ // never seat-row counts. Transfer buckets follow the verified transfer_count.
1071
+ const journeyMix = counts && (counts.journeys_total ?? 0) > 0
1072
+ ? `${counts.journeys_total}个铁路组合(直达${counts.journeys_direct ?? 0}/1中转${counts.journeys_one_transfer ?? 0}/2中转${counts.journeys_multi_transfer ?? 0})`
1073
+ : "";
1074
+ const pairProgress = counts && (counts.pairs_total ?? 0) > 0
1075
+ ? `已检查${counts.pairs_checked ?? 0}/${counts.pairs_total}个附近站对`
1076
+ : "";
1077
+ // §S5: failed pairs are named honestly — never implied by "checked" and
1078
+ // never rephrased as "no direct exists".
1079
+ // §S5: a rules-only recommendation after a failed model attempt is honest —
1080
+ // "rules finished, AI didn't", never silently dressed as a model pick.
1081
+ const modelDegradedHint = plan.search?.model_outcome === "fallback"
1082
+ ? "规则推荐已完成,AI模型未完成;推荐有效,但置信度说明以规则结果为准。"
1083
+ : "";
1084
+ const failedPairsHint = counts && (counts.pairs_failed ?? 0) > 0
1085
+ ? `有${counts.pairs_failed}个站对暂未查明,不能确认没有直达;已有结果仍可查看。`
1086
+ : "";
1020
1087
  const result = {
1021
1088
  service_execution_id: se,
1022
1089
  rail_planning: {
@@ -1024,12 +1091,21 @@ function railPlanningEnvelope(model) {
1024
1091
  query_revision: plan.query_revision,
1025
1092
  snapshot_id: plan.snapshot_id,
1026
1093
  expansion_status: expansion,
1094
+ ...(plan.search?.phase ? { phase: plan.search.phase } : {}),
1095
+ ...(plan.search?.transfer_status ? { transfer_status: plan.search.transfer_status } : {}),
1096
+ ...(plan.search?.transition_reason ? { transition_reason: plan.search.transition_reason } : {}),
1097
+ ...(plan.search?.authorization ? { authorization: plan.search.authorization } : {}),
1098
+ ...(plan.search?.expansion_target ? { expansion_target: plan.search.expansion_target } : {}),
1099
+ ...(plan.search?.decision_source ? { decision_source: plan.search.decision_source } : {}),
1100
+ ...(plan.search?.model_outcome ? { model_outcome: plan.search.model_outcome } : {}),
1027
1101
  ...(plan.search?.reason ? { reason: plan.search.reason } : {}),
1028
1102
  ...(plan.search?.poll_after_ms ? { poll_after_ms: plan.search.poll_after_ms } : {}),
1029
1103
  ...(plan.result_not_updated ? { result_not_updated: true } : {}),
1104
+ ...(counts ? { counts } : {}),
1030
1105
  ...(plan.budgets ? { budgets: plan.budgets } : {}),
1031
1106
  ...(plan.coverage ? { coverage: plan.coverage } : {}),
1032
1107
  },
1108
+ ...(plan.notices?.length ? { notices: plan.notices } : {}),
1033
1109
  ...(plan.recommendation ? { recommendation: railJourneySummary(plan.recommendation, se) } : {}),
1034
1110
  ...(cards.length ? { journeys: cards } : {}),
1035
1111
  ...(plan.available_actions?.length ? { available_actions: plan.available_actions } : {}),
@@ -1043,38 +1119,106 @@ function railPlanningEnvelope(model) {
1043
1119
  return {
1044
1120
  status: "ready",
1045
1121
  result,
1046
- instruction: "规划已收敛:向用户展示 recommendation 与 journeys 卡片(车次/时间/价格/余票状态),请用户选定 journey 后用其 select 命令提交;乘车人身份信息不在此收集,后续购买走受保护 Checkout。",
1122
+ // §7.3: the lead line carries real combination counts bucketed by
1123
+ // verified transfer count — seat rows never inflate the journey count.
1124
+ instruction: `${journeyMix ? `本次共${journeyMix}(席别不重复计数)。` : ""}${plan.search?.reason === "budget_exhausted" ? "本次查询已达到上限,以下是已查到的方案,搜索范围尚未全部核验。" : "本轮规划已完成。"}向用户展示 recommendation 与 journeys 卡片(车次/时间/价格/余票状态),请用户选定 journey 后用其 select 命令提交;乘车人身份信息不在此收集,后续购买走受保护 Checkout。`,
1047
1125
  next: journeys[0]?.booking_support === "single_leg"
1048
1126
  ? { command: railJourneySummary(journeys[0], se).select, reason: "选定推荐行程" }
1049
1127
  : null,
1050
1128
  recovery: [],
1051
1129
  };
1052
- case "paused":
1130
+ case "paused": {
1131
+ // §7.1 case 1: usable directs committed while ranked scope remains. The
1132
+ // message must carry real counts, and expansion is a user choice — the
1133
+ // Agent must not treat "expandable" as consent already given.
1134
+ const directHint = (plan.search?.reason === "direct_options_ready" || plan.search?.phase === "direct_ready") && journeyMix
1135
+ ? `本次找到${counts?.journeys_direct ?? 0}趟可选直达(${journeyMix}),${pairProgress || "部分站对已核验"},中转尚未展开。可以直接选一趟;时间或价格不合适时可继续搜索其它直达及中转,等待会更久。`
1136
+ : "";
1137
+ // §2.1: a delivered-pause at the authorized transfer depth names what a
1138
+ // consented expand buys next — verified-empty is "nothing at this
1139
+ // depth", never "no routes exist".
1140
+ const transferHint = (plan.search?.reason === "transfer_options_ready" || plan.search?.phase === "transfer_ready") && journeyMix
1141
+ ? `一次中转范围已核验完毕并交付(${journeyMix})。可以直接选用;也可以继续比较两次中转方案,查询会明显更久。`
1142
+ : plan.search?.reason === "no_options_verified"
1143
+ ? `当前授权深度内的范围已核验完、未找到可用方案——这不等于没有其它路线。可授权继续更深的两次中转搜索(耗时显著增加),或停止。`
1144
+ : "";
1145
+ const expandHint = plan.search?.expansion_target === "two_transfer"
1146
+ ? "expand_search 将授权一次更深的两次中转搜索(等待更久)"
1147
+ : plan.search?.expansion_target === "more_direct_and_one_transfer"
1148
+ ? "expand_search 将补齐剩余直达并展开一次中转"
1149
+ : "expand_search 是唯一会再消耗供应商配额的命令,其余均为本地读取";
1150
+ // §7.1 case 3 on a recoverable pause: unverified scope is named with real
1151
+ // counts, and the phrasing never implies the whole nearby range ran.
1152
+ const partialPause = !directHint && counts && (counts.pairs_total ?? 0) > (counts.pairs_checked ?? 0)
1153
+ ? `目前找到${counts.journeys_total ?? 0}个可选组合;还有${(counts.pairs_total ?? 0) - (counts.pairs_checked ?? 0)}个站对/部分中转路径未核验。以下是已确认结果,不能据此断定没有其它走法。`
1154
+ : "";
1155
+ // §S5: a dispatch whose outcome is unknown pauses for reconciliation —
1156
+ // the message names the state, never rephrases it as a normal pause or
1157
+ // a no-directs result, and never offers a blind retry (expand_search is
1158
+ // withheld by the projection too).
1159
+ if (plan.search?.reason === "dispatch_outcome_unknown") {
1160
+ return {
1161
+ status: "awaiting_input",
1162
+ result,
1163
+ instruction: `${counts?.journeys_total ? `已保存${counts.journeys_total}个已核验组合仍可查看。` : ""}上一次查询发送结果未确认(可能已部分查询),系统已暂停并等待对账,不会自动重复发送,也不能据此断定没有车次。展示已有卡片,稍后可重新读取最新状态。`,
1164
+ next: null,
1165
+ recovery: [{ command: `itpay services next ${se} --json`, reason: "稍后读取对账后的最新状态" }],
1166
+ };
1167
+ }
1053
1168
  return {
1054
1169
  status: "awaiting_input",
1055
1170
  result,
1056
- instruction: "规划已发布首批结果并暂停扩展:展示现有卡片与 available_actions,等待用户意图(expand_search 是唯一会再消耗供应商配额的命令,其余均为本地读取)。ready 后 next 为空表示向用户汇报并等待,不是永远没有更多。",
1171
+ instruction: `${modelDegradedHint}${failedPairsHint}${directHint}${transferHint}${partialPause}规划已发布首批结果并暂停扩展:展示现有卡片与 available_actions,等待用户意图(${expandHint})。ready 后 next 为空表示向用户汇报并等待,不是永远没有更多。`,
1057
1172
  next: null,
1058
1173
  recovery: [],
1059
1174
  };
1175
+ }
1060
1176
  case "failed":
1061
1177
  case "cancelled":
1062
- case "expired":
1178
+ case "expired": {
1179
+ // §7.1 case 3: when scope was only partially verified the honest wording
1180
+ // is "N confirmed + X pairs unverified" — never the case-2 phrasing that
1181
+ // implies the whole nearby range was checked.
1182
+ const partial = counts && (counts.pairs_total ?? 0) > (counts.pairs_checked ?? 0)
1183
+ ? `目前找到${counts.journeys_total ?? 0}个可选组合;还有${(counts.pairs_total ?? 0) - (counts.pairs_checked ?? 0)}个站对/部分中转路径未核验。以下是已确认结果,不能据此断定没有其它走法。`
1184
+ : "";
1185
+ // §S5: a processing failure with a committed catalog is "saved N
1186
+ // verified combinations, later expansion/recommend incomplete" — the
1187
+ // existing catalog stays readable and nothing implies no other routes.
1188
+ const savedCatalog = expansion === "failed" && (counts?.journeys_total ?? 0) > 0
1189
+ ? `已保存${counts?.journeys_total}个已核验组合;后续扩展/推荐未完成,原因是本次处理异常。已有方案仍可查看,不代表没有其它路线,也不需要重复提交订单或付款。`
1190
+ : "";
1063
1191
  return {
1064
1192
  status: expansion,
1065
1193
  result,
1066
- instruction: "本次规划已结束且不可续用;展示已有卡片后如需重查请用户确认后用同一服务新建执行。不要自动重新发起。",
1194
+ instruction: `${modelDegradedHint}${failedPairsHint}${savedCatalog}${partial}本次规划已结束且不可续用;展示已有卡片后如需重查请用户确认后用同一服务新建执行。不要自动重新发起。`,
1067
1195
  next: null,
1068
1196
  recovery: [{ command: `itpay services events ${se} --json`, reason: "读取同一任务的处理记录" }],
1069
1197
  };
1070
- default:
1198
+ }
1199
+ default: {
1200
+ // §7.1 case 2: the automatic phase is still running. When the verified
1201
+ // stage is transfer the user must hear "direct scope found nothing
1202
+ // usable, transfer search continues" — never re-submit.
1203
+ const transferSearching = plan.search?.phase === "transfer"
1204
+ || plan.search?.transition_reason === "no_usable_direct"
1205
+ || plan.search?.transfer_status === "in_progress" || plan.search?.transfer_status === "pending";
1206
+ // §S5: an expansion the user authorized keeps earlier directs and says
1207
+ // so — it must not reuse the automatic "no usable direct" wording.
1208
+ const manualExpansion = plan.search?.authorization === "manual"
1209
+ ? `已保留之前的直达结果,正在按你的要求比较更多直达/中转及分段购票方案,需要多一点时间。`
1210
+ : "";
1211
+ const transferHint = !manualExpansion && transferSearching
1212
+ ? `已检查本次附近站范围,暂未找到符合条件且有票的直达,正在继续搜索中转组合,需要多一点时间;不需要重新提交。`
1213
+ : "";
1071
1214
  return {
1072
1215
  status: "planning",
1073
1216
  result,
1074
- instruction: "规划进行中:已提交的卡片可先向用户展示比较;稍后按 next 增量轮询同一执行(since-snapshot 命中时负载会被压缩)。不要重新发起查询。",
1217
+ instruction: `${modelDegradedHint}${manualExpansion}${transferHint}${failedPairsHint}规划进行中:已提交的卡片可先向用户展示比较;稍后按 next 增量轮询同一执行(since-snapshot 命中时负载会被压缩,但阶段与计数仍返回最新值)。不要重新发起查询。`,
1075
1218
  next: nextPoll,
1076
1219
  recovery: [],
1077
1220
  };
1221
+ }
1078
1222
  }
1079
1223
  }
1080
1224
  function terminalExecutionEnvelope(model) {
@@ -1745,7 +1889,12 @@ export async function runServicesRun(backend, config, serviceID, input, options
1745
1889
  const until = Date.now() + (options.timeoutSeconds ?? 120) * 1000;
1746
1890
  const sleep = options.sleep ?? ((milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)));
1747
1891
  const paid = () => model.payment_bindings.some((binding) => binding.status === "payment_verified") || model.checkout_bindings.some((binding) => binding.status === "payment_verified");
1748
- while ((["queued", "running", "delivery"].includes(model.workflow?.status ?? "") || (model.workflow?.status === "payment" && paid())) && !model.current_delivery && Date.now() < until) {
1892
+ // §7.2: for progressive services the committed rail_planning projection is
1893
+ // itself a deliverable — return the same railPlanningEnvelope as soon as it
1894
+ // exists instead of hiding every stage behind the 120s workflow poll. The
1895
+ // owner keeps advancing in the background; the CLI returning early never
1896
+ // cancels it.
1897
+ while ((["queued", "running", "delivery"].includes(model.workflow?.status ?? "") || (model.workflow?.status === "payment" && paid())) && !model.current_delivery && !model.rail_planning && Date.now() < until) {
1749
1898
  await sleep(options.pollIntervalMS ?? 1500);
1750
1899
  model = await backend.getServiceExecution(id);
1751
1900
  }
@@ -15,8 +15,8 @@ import { OperationJournal } from "./operation_journal.js";
15
15
  export const DEFAULT_BASE_URL = "https://app.itpay.ai";
16
16
  export const DEV_BASE_URL = "https://dev.itpay.ai";
17
17
  export const SANDBOX_BASE_URL = "https://sandbox.itpay.ai";
18
- export const CLI_VERSION = "2.1.11";
19
- export const API_CONTRACT_REVISION = "sha256:653bead80f71a65f2edd1356bcdfe0ce3a3b99828a48cbf081f2dadac82c7e02";
18
+ export const CLI_VERSION = "2.1.17";
19
+ export const API_CONTRACT_REVISION = "sha256:c6125896053540d1b56f9d106df499ac90162fb8fa4cce619c8029e90f771f85";
20
20
  const CART_SESSION_DEFAULT_DIR = ".itpay-v3";
21
21
  const CART_SESSION_FILENAME = "cart.json";
22
22
  const OPERATION_JOURNAL_FILENAME = "operations.json";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itpay/cli",
3
- "version": "2.1.11",
3
+ "version": "2.1.17",
4
4
  "description": "The ItPay CLI for services, orders, and human-authorized purchased content.",
5
5
  "type": "module",
6
6
  "bin": {