@itpay/cli 2.1.10 → 2.1.11

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.
@@ -139,8 +139,9 @@ export class BackendClient {
139
139
  prepareServiceQuote(serviceExecutionID, input) {
140
140
  return this.http.post(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/quotes`, input, { replaySafe: true });
141
141
  }
142
- getServiceExecution(serviceExecutionID) {
143
- return this.http.get(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}`);
142
+ getServiceExecution(serviceExecutionID, opts = {}) {
143
+ const query = opts.sinceSnapshot ? `?since_snapshot=${encodeURIComponent(opts.sinceSnapshot)}` : "";
144
+ return this.http.get(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}${query}`);
144
145
  }
145
146
  listServiceExecutions(limit = 50) {
146
147
  return this.http.get(`/v1/service-executions?limit=${limit}`);
@@ -155,6 +156,10 @@ export class BackendClient {
155
156
  getGrantedServiceResult(serviceExecutionID) {
156
157
  return this.http.get(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/granted-result`);
157
158
  }
159
+ getRailJourneyDetail(serviceExecutionID, journeyID, snapshotID) {
160
+ const query = snapshotID ? `?snapshot=${encodeURIComponent(snapshotID)}` : "";
161
+ return this.http.get(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/rail-planning/journeys/${encodeURIComponent(journeyID)}${query}`);
162
+ }
158
163
  }
159
164
  // --- SSE streaming helper ---
160
165
  async function streamSSE(url, onEvent, signal) {
@@ -38,6 +38,10 @@ function orderEnvelope(order, delivery, lockedRefund) {
38
38
  else if (order.status === "cancelled") {
39
39
  instruction = "先告诉用户这笔订单已经取消,没有可继续的付款或交付;不要创建替代订单,除非用户另行提出新的购买。";
40
40
  }
41
+ else if (order.status === "pending_payment") {
42
+ instruction = "先告诉用户订单正在等待付款,超时未支付会被自动取消;不要创建替代订单,也不要重复发起支付授权。";
43
+ next = { command: `itpay order ${order.order_id} --json`, reason: "刷新订单支付状态" };
44
+ }
41
45
  else if (!["delivered", "refunded", "failed", "cancelled"].includes(order.status)) {
42
46
  instruction = "先告诉用户订单仍在处理,已记录的付款和订单不需要重复创建;稍后查询同一订单,不要创建替代订单。";
43
47
  next = { command: `itpay order ${order.order_id} --json`, reason: "刷新订单状态" };
@@ -48,6 +52,7 @@ function orderEnvelope(order, delivery, lockedRefund) {
48
52
  order_id: order.order_id,
49
53
  ...(order.order_code ? { order_code: order.order_code } : {}),
50
54
  amount: formatMoney(order.amount_minor, order.currency),
55
+ ...(order.payment_deadline_at ? { payment_deadline_at: order.payment_deadline_at } : {}),
51
56
  ...(delivery ? { delivery_mode: delivery.delivery_mode } : {}),
52
57
  access_locked: Boolean(lockedRefund),
53
58
  ...(delivery?.service_execution_id ? { service_execution_id: delivery.service_execution_id } : {}),
@@ -9,6 +9,10 @@ import { localizeCardURL, normalizeCardLocale } from "../render/locale.js";
9
9
  import { buildAgentChatHandoff } from "../render/markdown.js";
10
10
  import { platformKeyForHost } from "../render/plan.js";
11
11
  import { renderTerminalQR } from "../render/qr.js";
12
+ // Declared at execution creation: the client understands the rail.progressive.v2
13
+ // response format (rail_planning projection, snapshot paging, rsel_ handles).
14
+ // The server alone decides service version, quota and provider credentials.
15
+ const RAIL_PROGRESSIVE_FEATURES = ["rail.progressive.v2"];
12
16
  import { buildCheckoutQRPlan } from "./buy.js";
13
17
  import { appendFeedbackPostmortemInstruction, CommandContractError, isTerminalServiceExecutionStatus, writeCommandEnvelope, } from "./guidance.js";
14
18
  const serviceActionStatuses = new Set(["pending", "approved", "rejected", "expired", "cancelled"]);
@@ -87,6 +91,7 @@ export async function runServicesStart(backend, serviceID, options = {}) {
87
91
  service_id: serviceID,
88
92
  client_context: {
89
93
  host,
94
+ features: [...RAIL_PROGRESSIVE_FEATURES],
90
95
  ...(options.target ? { target: options.target } : {}),
91
96
  ...(options.clientContext ?? {}),
92
97
  },
@@ -759,7 +764,7 @@ export async function runServicesGet(backend, serviceExecutionID, options = {})
759
764
  });
760
765
  }
761
766
  export async function runServicesNext(backend, serviceExecutionID, options = {}) {
762
- const response = await backend.getServiceExecution(serviceExecutionID);
767
+ const response = await backend.getServiceExecution(serviceExecutionID, options.sinceSnapshot ? { sinceSnapshot: options.sinceSnapshot } : {});
763
768
  const envelope = servicesNextEnvelope(response);
764
769
  if (response.rail_booking)
765
770
  envelope.result = { ...envelope.result, rail_booking: response.rail_booking };
@@ -781,14 +786,19 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
781
786
  const response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, resultItemID, offset, limit);
782
787
  const page = response.page;
783
788
  const container = (page.result ?? page);
784
- const catalogPage = (container.catalog_page ?? {});
789
+ // rail.progressive.v2 saved pages key journeys/journey_page; legacy result
790
+ // items key candidates/catalog_page. Both are committed, read-only slices.
791
+ const v2Page = (container.journey_page ?? {});
792
+ const catalogPage = (container.catalog_page ?? v2Page);
785
793
  const nextOffset = typeof catalogPage.next_offset === "number" ? catalogPage.next_offset : null;
786
- const candidates = Array.isArray(container.candidates) ? container.candidates : [];
794
+ const journeys = Array.isArray(container.journeys) ? container.journeys : [];
795
+ const candidates = Array.isArray(container.candidates) ? container.candidates : journeys;
787
796
  const envelope = {
788
797
  status: candidates.length > 0 ? "result_page" : "result_page_end",
789
798
  result: {
790
799
  service_execution_id: response.service_execution_id,
791
- service_capability_result_item_id: response.service_capability_result_item_id,
800
+ ...(response.service_capability_result_item_id ? { service_capability_result_item_id: response.service_capability_result_item_id } : {}),
801
+ ...(response.snapshot_id ? { snapshot_id: response.snapshot_id } : {}),
792
802
  offset: catalogPage.offset ?? offset,
793
803
  limit: catalogPage.limit ?? limit,
794
804
  total: catalogPage.total ?? candidates.length,
@@ -798,7 +808,10 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
798
808
  },
799
809
  instruction: "读取的是已保存结果的同版本分页,不重新查询、不消耗额度。用普通语言向用户说明本页候选(车次、席别、时刻、费用口径),铁路应付与地面估算费用分开表述;不要提及 safe_payload、Execution 或内部 ID。",
800
810
  next: nextOffset !== null
801
- ? { command: `itpay services page ${serviceExecutionID} ${resultItemID} --offset ${nextOffset} --json`, reason: "读取同版本结果的下一页" }
811
+ ? { command: response.snapshot_id
812
+ ? `itpay services page ${serviceExecutionID} ${resultItemID} --cursor rcur_${nextOffset} --json`
813
+ : `itpay services page ${serviceExecutionID} ${resultItemID} --offset ${nextOffset} --json`,
814
+ reason: "读取同版本结果的下一页" }
802
815
  : null,
803
816
  recovery: offset > 0
804
817
  ? [{ command: `itpay services page ${serviceExecutionID} ${resultItemID} --json`, reason: "回到第一页" }]
@@ -850,6 +863,33 @@ export async function runServicesList(backend, options = {}) {
850
863
  });
851
864
  }
852
865
  export async function runServicesReadResult(backend, serviceExecutionID, options = {}) {
866
+ // rail.progressive.v2 planning read: --journey/--snapshot select committed
867
+ // planning evidence — a free owner-validated read that never touches the
868
+ // grant/Vault path. Without the selectors the authorized-delivery flow is
869
+ // unchanged.
870
+ if (options.journey) {
871
+ const detail = await backend.getRailJourneyDetail(serviceExecutionID, options.journey, options.snapshot);
872
+ writeCommandEnvelope({
873
+ status: "ready",
874
+ result: {
875
+ service_execution_id: serviceExecutionID,
876
+ plan_id: detail.plan_id,
877
+ snapshot_id: detail.snapshot_id,
878
+ query_revision: detail.query_revision,
879
+ journey: detail.journey,
880
+ },
881
+ instruction: "展示该 journey 的完整明细(车次、分段、席别报价、接驳估计与风险标注)。rail_payable 只是该行程当前可购报价的参考价,不是锁价;下单前须走受保护 Checkout 收集乘车人。",
882
+ next: detail.journey?.booking_support === "single_leg"
883
+ ? { command: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${detail.journey.journey_id} --json`, reason: "选定此行程" }
884
+ : { command: `itpay services next ${serviceExecutionID} --since-snapshot ${detail.snapshot_id} --json`, reason: "返回规划进展" },
885
+ recovery: [],
886
+ }, {
887
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
888
+ ...(options.output ? { output: options.output } : {}),
889
+ plainResult: servicesNextPlainResult(detail.journey),
890
+ });
891
+ return;
892
+ }
853
893
  const response = await backend.getGrantedServiceResult(serviceExecutionID);
854
894
  let orderID;
855
895
  try {
@@ -941,6 +981,102 @@ function railBookingEnvelope(model) {
941
981
  recovery: [],
942
982
  };
943
983
  }
984
+ function railJourneySummary(card, serviceExecutionID) {
985
+ const rides = Array.isArray(card.rides) ? card.rides : [];
986
+ const trains = rides.map((ride) => ride.train_code).filter(Boolean);
987
+ const first = rides[0];
988
+ const last = rides.at(-1);
989
+ const offer = card.representative_offer;
990
+ return {
991
+ journey_id: card.journey_id,
992
+ route: (card.route_names?.length ? card.route_names : card.route ?? []).join("→"),
993
+ ...(trains.length ? { trains } : {}),
994
+ ...(first?.departure || last?.arrival ? { time: `${first?.departure ?? ""}–${last?.arrival ?? ""}` } : {}),
995
+ ...(offer ? { price: formatMoney(offer.rail_payable_minor, offer.currency) } : { price: "unknown" }),
996
+ availability: card.availability,
997
+ booking_support: card.booking_support,
998
+ ...(card.observed_at ? { observed_at: card.observed_at } : {}),
999
+ ...(card.risk_notes?.length ? { risk_notes: card.risk_notes } : {}),
1000
+ ...(card.booking_support === "single_leg"
1001
+ ? { select: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${card.journey_id} --json` }
1002
+ : {}),
1003
+ ...(card.booking_offer
1004
+ ? {
1005
+ booking_offer: card.booking_offer,
1006
+ book: `itpay services run ${card.booking_offer.service_id} --input-json <file> --json # file = {"selection":{"token":"${card.booking_offer.selection_token}","seat_type":"<席别>"},"passengers":<n>}`,
1007
+ }
1008
+ : {}),
1009
+ };
1010
+ }
1011
+ // rail.progressive.v2 read projection: committed snapshots only; polling never
1012
+ // triggers supplier calls. Recommendation is absent until the decision stage
1013
+ // commits — alternatives still render so users can compare early.
1014
+ function railPlanningEnvelope(model) {
1015
+ const plan = model.rail_planning;
1016
+ const se = model.execution.service_execution_id;
1017
+ const expansion = plan.search?.expansion_status ?? "running";
1018
+ const journeys = [plan.recommendation, ...(plan.alternatives ?? [])].filter(Boolean);
1019
+ const cards = journeys.map((card) => railJourneySummary(card, se));
1020
+ const result = {
1021
+ service_execution_id: se,
1022
+ rail_planning: {
1023
+ readiness: plan.readiness,
1024
+ query_revision: plan.query_revision,
1025
+ snapshot_id: plan.snapshot_id,
1026
+ expansion_status: expansion,
1027
+ ...(plan.search?.reason ? { reason: plan.search.reason } : {}),
1028
+ ...(plan.search?.poll_after_ms ? { poll_after_ms: plan.search.poll_after_ms } : {}),
1029
+ ...(plan.result_not_updated ? { result_not_updated: true } : {}),
1030
+ ...(plan.budgets ? { budgets: plan.budgets } : {}),
1031
+ ...(plan.coverage ? { coverage: plan.coverage } : {}),
1032
+ },
1033
+ ...(plan.recommendation ? { recommendation: railJourneySummary(plan.recommendation, se) } : {}),
1034
+ ...(cards.length ? { journeys: cards } : {}),
1035
+ ...(plan.available_actions?.length ? { available_actions: plan.available_actions } : {}),
1036
+ };
1037
+ const nextPoll = {
1038
+ command: `itpay services next ${se}${plan.snapshot_id ? ` --since-snapshot ${plan.snapshot_id}` : ""} --json`,
1039
+ reason: "读取同一规划的增量进展(不触发供应商调用)",
1040
+ };
1041
+ switch (expansion) {
1042
+ case "complete":
1043
+ return {
1044
+ status: "ready",
1045
+ result,
1046
+ instruction: "规划已收敛:向用户展示 recommendation 与 journeys 卡片(车次/时间/价格/余票状态),请用户选定 journey 后用其 select 命令提交;乘车人身份信息不在此收集,后续购买走受保护 Checkout。",
1047
+ next: journeys[0]?.booking_support === "single_leg"
1048
+ ? { command: railJourneySummary(journeys[0], se).select, reason: "选定推荐行程" }
1049
+ : null,
1050
+ recovery: [],
1051
+ };
1052
+ case "paused":
1053
+ return {
1054
+ status: "awaiting_input",
1055
+ result,
1056
+ instruction: "规划已发布首批结果并暂停扩展:展示现有卡片与 available_actions,等待用户意图(expand_search 是唯一会再消耗供应商配额的命令,其余均为本地读取)。ready 后 next 为空表示向用户汇报并等待,不是永远没有更多。",
1057
+ next: null,
1058
+ recovery: [],
1059
+ };
1060
+ case "failed":
1061
+ case "cancelled":
1062
+ case "expired":
1063
+ return {
1064
+ status: expansion,
1065
+ result,
1066
+ instruction: "本次规划已结束且不可续用;展示已有卡片后如需重查请用户确认后用同一服务新建执行。不要自动重新发起。",
1067
+ next: null,
1068
+ recovery: [{ command: `itpay services events ${se} --json`, reason: "读取同一任务的处理记录" }],
1069
+ };
1070
+ default:
1071
+ return {
1072
+ status: "planning",
1073
+ result,
1074
+ instruction: "规划进行中:已提交的卡片可先向用户展示比较;稍后按 next 增量轮询同一执行(since-snapshot 命中时负载会被压缩)。不要重新发起查询。",
1075
+ next: nextPoll,
1076
+ recovery: [],
1077
+ };
1078
+ }
1079
+ }
944
1080
  function terminalExecutionEnvelope(model) {
945
1081
  const execution = model.execution;
946
1082
  const currentDelivery = model.current_delivery ?? model.delivery_bindings.at(-1);
@@ -1026,6 +1162,8 @@ function servicesNextEnvelope(model) {
1026
1162
  }
1027
1163
  if (model.rail_booking)
1028
1164
  return railBookingEnvelope(model);
1165
+ if (model.rail_planning)
1166
+ return railPlanningEnvelope(model);
1029
1167
  if (latestRun && ["failed", "cancelled"].includes(latestRun.status) && !model.workflow_entry) {
1030
1168
  return {
1031
1169
  status: latestRun.status,
@@ -1582,7 +1720,7 @@ export async function runServicesRun(backend, config, serviceID, input, options
1582
1720
  if (!id) {
1583
1721
  const started = await backend.startServiceExecution({
1584
1722
  service_id: serviceID,
1585
- client_context: { host: options.host ?? "terminal", ...(options.target ? { target: options.target } : {}) },
1723
+ client_context: { host: options.host ?? "terminal", features: [...RAIL_PROGRESSIVE_FEATURES], ...(options.target ? { target: options.target } : {}) },
1586
1724
  ...(input !== undefined ? { input } : {}),
1587
1725
  });
1588
1726
  id = started.execution.service_execution_id;
package/dist/src/main.js CHANGED
@@ -1356,14 +1356,21 @@ services
1356
1356
  .argument("<service_execution_id>")
1357
1357
  .argument("<result_item_id>")
1358
1358
  .option("--offset <offset>", "zero-based candidate offset", Number, 0)
1359
+ .option("--cursor <cursor>", "opaque v2 page cursor (rcur_<offset>); takes precedence over --offset")
1359
1360
  .option("--limit <limit>", "page size (1-20)", Number, 5)
1360
1361
  .option("--json", "output JSON")
1361
1362
  .action(async (serviceExecutionID, resultItemID, options) => {
1362
1363
  const config = loadConfig();
1363
1364
  const backend = newBackendClient(config);
1364
1365
  try {
1366
+ const cursorOffset = typeof options.cursor === "string" && /^rcur_\d+$/.test(options.cursor)
1367
+ ? Number(options.cursor.slice(5))
1368
+ : undefined;
1369
+ if (options.cursor !== undefined && cursorOffset === undefined) {
1370
+ throw new CommandContractError("cursor_invalid", "--cursor must be an opaque rcur_<offset> token from a previous page response", "--cursor 需为上一页返回的 rcur_<offset> 不透明游标。", []);
1371
+ }
1365
1372
  await runServicesPage(backend, serviceExecutionID, resultItemID, {
1366
- offset: options.offset,
1373
+ offset: cursorOffset ?? options.offset,
1367
1374
  limit: options.limit,
1368
1375
  jsonOutput: Boolean(options.json),
1369
1376
  });
@@ -1601,12 +1608,13 @@ services
1601
1608
  .command("next")
1602
1609
  .description("Show the next recommended agent action for a Service Execution")
1603
1610
  .argument("<service_execution_id>")
1611
+ .option("--since-snapshot <snapshot_id>", "delta read: suppress the journey payload when unchanged")
1604
1612
  .option("--json", "output JSON instead of terminal text")
1605
1613
  .action(async (serviceExecutionID, options) => {
1606
1614
  const config = loadConfig();
1607
1615
  const backend = newBackendClient(config);
1608
1616
  try {
1609
- await runServicesNext(backend, serviceExecutionID, { jsonOutput: Boolean(options.json) });
1617
+ await runServicesNext(backend, serviceExecutionID, { jsonOutput: Boolean(options.json), sinceSnapshot: options.sinceSnapshot });
1610
1618
  }
1611
1619
  catch (error) {
1612
1620
  reportCLIError(error, {
@@ -1621,12 +1629,14 @@ services
1621
1629
  .command("read-result")
1622
1630
  .description("Read a human-granted service result for this agent")
1623
1631
  .argument("<service_execution_id>")
1632
+ .option("--snapshot <snapshot_id>", "rail.progressive.v2: read one journey from a committed planning snapshot")
1633
+ .option("--journey <journey_id>", "rail.progressive.v2: journey id to read")
1624
1634
  .option("--json", "output JSON instead of terminal text")
1625
1635
  .action(async (serviceExecutionID, options) => {
1626
1636
  const config = loadConfig();
1627
1637
  const backend = newBackendClient(config);
1628
1638
  try {
1629
- await runServicesReadResult(backend, serviceExecutionID, { jsonOutput: Boolean(options.json) });
1639
+ await runServicesReadResult(backend, serviceExecutionID, { jsonOutput: Boolean(options.json), snapshot: options.snapshot, journey: options.journey });
1630
1640
  }
1631
1641
  catch (error) {
1632
1642
  reportCLIError(error, {
@@ -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.10";
19
- export const API_CONTRACT_REVISION = "sha256:66a95bbc5ec9d7b35747cf87206c63dd45983f26de21afab582d8dd63bf198f7";
18
+ export const CLI_VERSION = "2.1.11";
19
+ export const API_CONTRACT_REVISION = "sha256:653bead80f71a65f2edd1356bcdfe0ce3a3b99828a48cbf081f2dadac82c7e02";
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";
@@ -46,7 +46,9 @@
46
46
  "depart_after": "Do not depart before this time.",
47
47
  "arrive_before": "Do not arrive after this time.",
48
48
  "priority": "One of balanced | fastest | cheapest | safest | flexible.",
49
- "max_transfers": "Integer 0-2, allowed transfer count."
49
+ "max_transfers": "Integer 0-2, allowed transfer count.",
50
+ "passengers": "Integer 1-5, default 1: adult passenger count reflected in per-leg inventory and amounts. NOT identity records.",
51
+ "preference_context": "Bounded object, max 4 string-valued keys (<=200 chars each) of soft ranking preferences, e.g. {\"no_taxi\":\"true\"}. Validated allowlist; never carry passenger identity."
50
52
  },
51
53
  "example": {
52
54
  "origin": "中山古镇",
@@ -74,7 +76,9 @@
74
76
  "Exact results carry per-train candidates; smart results carry journeys. Bookable options carry booking_offer with a server-signed selection_token.",
75
77
  "The token binds the stored result snapshot and the chosen option. Submit it with passengers (and seat_type when the user picked a class); the server derives the authoritative legs and re-quotes live. Do NOT hand-build legs JSON from the visible result.",
76
78
  "An expired or foreign token returns a clear error; recover by running a fresh query or by explicit legs — never by guessing token contents.",
77
- "Repeating the same selection restores the same booking execution instead of creating a second one."
79
+ "Repeating the same selection restores the same booking execution instead of creating a second one.",
80
+ "v2 single-leg cards carry an opaque rsel_ selection_token bound to owner, execution, query revision, snapshot, journey/offer, policy and expiry; a stale or foreign token returns selection_stale — re-read the snapshot for a fresh offer instead of retrying.",
81
+ "v2 separate_legs_only journeys never carry a whole-journey token: open the journey detail, pick one leg, and use that leg's owner-issued booking_offer; each leg is quoted and confirmed independently with no joint guarantee."
78
82
  ],
79
83
  "trial_and_login": {
80
84
  "policy": "itpay-rail-exact and itpay-rail-smart each allow 2 anonymous queries per enrolled device lineage — independent counters, not a shared pool of 4.",
@@ -100,7 +104,14 @@
100
104
  "catalog_or_recommend": "Location resolved but no feasible trains: change the date or stations.",
101
105
  "login_required_is_not_failure": "login_required / rate_limited pause the execution; they are NOT failures and never justify a replacement execution.",
102
106
  "general_rule": "Never replay a failed execution and never guess different field names; correct the input and start a new execution of the same service.",
103
- "booking_review_changed": "The submitted draft_revision is stale: re-read services next for the current review projection and resubmit the full input; do not bump the revision yourself."
107
+ "booking_review_changed": "The submitted draft_revision is stale: re-read services next for the current review projection and resubmit the full input; do not bump the revision yourself.",
108
+ "plan_conflict": "409: the action_request_id/expected_query_revision/snapshot_id no longer matches owner state. Re-read services next and reuse the fresh action handle; an identical replay of an accepted action still returns its original acceptance.",
109
+ "stale_cursor": "The paging cursor was minted against an older snapshot; restart paging from the current snapshot_id.",
110
+ "snapshot_not_found": "Unknown or superseded snapshot id; read the current projection for the latest snapshot.",
111
+ "journey_not_in_snapshot": "The rj_ reference is not inside the saved snapshot; pick a journey from the current page/projection.",
112
+ "plan_expired": "The 15-minute plan lifecycle ended: expansion stopped and the snapshot is read-only evidence; start a new execution for a fresh search. In-flight checkout/payment is unaffected.",
113
+ "action_request_invalid": "The action payload failed validation (unknown patch fields, malformed handle); copy the command from available_actions instead of composing it.",
114
+ "selection_stale": "The rsel_ token no longer matches the current snapshot/policy; re-read the journey and take the fresh booking_offer."
104
115
  },
105
116
  "seat_preferences": {
106
117
  "meaning": "A buyer's seat-letter preference may be planned per leg before payment. The plan is submitted to the supplier when encodable; unsupported combinations fall back to automatic assignment.",
@@ -118,7 +129,12 @@
118
129
  "Take draft_revision only from the current services next review projection; on booking_review_changed re-read instead of retrying the stale value.",
119
130
  "login_required and rate_limited are pauses, not failures: resume the same execution, never spawn a replacement.",
120
131
  "Do not repeat or replay a failed execution; correct the input and start a new one.",
121
- "Do not claim payment equals ticket issuance; issuance is asynchronous and confirmed only by the issued seat status."
132
+ "Do not claim payment equals ticket issuance; issuance is asynchronous and confirmed only by the issued seat status.",
133
+ "For v2 plans, act only on available_actions[] from the current projection: run the exact command the backend returned, then re-read; never invent action types, request ids, revisions or fields.",
134
+ "expand_search is the only intent that may spend provider budget; refine_preferences re-ranks saved data first and does not by itself expand.",
135
+ "A paused/expired/complete plan is still readable and selectable from its saved snapshot; never start a replacement execution to keep polling.",
136
+ "stop_search pauses scheduling but keeps saved data and existing selections; booking stays reachable only through the allowed actions the owner reports.",
137
+ "When since_snapshot returns result_not_updated, keep the previous snapshot and still surface the latest expansion status/end reason to the user."
122
138
  ],
123
139
  "human_language": {
124
140
  "prefer": [
@@ -161,5 +177,15 @@
161
177
  "status_read": "The order read exposes masked per-leg notification entries (keys: issued, details_pending, seat_ready) with channel, status and masked recipient — never raw numbers.",
162
178
  "truthfulness": "Provider acceptance is not delivery; an uncertain send outcome is reconciled, not silently retried. A notification failure never changes ticket or refund state; report it as a delivery detail, not an order failure.",
163
179
  "unavailable": "If SMS is not configured or a recipient is unverified, email still satisfies the contact policy — never present an unverified phone as enabled."
180
+ },
181
+ "progressive_v2": {
182
+ "capability": "New CLI declares client_context.features=[\"rail.progressive.v2\"] at service start. It is a response-format declaration only; quota, service version and provider credentials stay server-decided. Old clients keep the legacy projection; never create a second execution to probe.",
183
+ "read_model": "While the plan workflow is active, services next carries rail_planning: schema_version, plan_id (jp_), query_revision, snapshot_id (rps_), readiness, expansion {status, reason, poll_after_ms, new_results_guaranteed}, coverage, budgets, recommendation + up to 4 alternatives (journey cards rj_), available_actions with concrete commands, result_item_id.",
184
+ "expansion_states": "queued/running: automatic work remains; paused: batch sufficient or auto window ended, expand only on explicit intent; complete/cancelled: budget or frontier exhausted — snapshot stays read-only, expand action absent; failed: reason visible after a published snapshot, readiness stays ready; expired: 15-minute lifecycle end — evidence read-only, in-flight purchase unaffected.",
185
+ "reads": "services next --since-snapshot <rps_…> suppresses the journey payload but always returns the latest expansion status. read-result gains --snapshot, --journey <rj_…> and an opaque --cursor bound to snapshot+filter+sort (stale -> stale_cursor). Reads never call the provider, never extend lifecycle, never invoke the model.",
186
+ "actions": "Three allowlisted planning actions ride POST actions with action_type workflow:expand_search | workflow:refine_preferences | workflow:stop_search. input_snapshot carries server-issued action_request_id plus expected_query_revision and snapshot_id; refine adds a validated preference_patch. Always copy the concrete command from available_actions[]; never assemble fields. Same id + same payload returns the stored acceptance; same id + different payload -> plan_conflict.",
187
+ "journey_card": "Cards carry station names, train codes, dated times, transfer/same-run notes, observed_at price time and handles. representative_offer.ticket_offers[] lists one purchasable segment per entry (leg_index, offer_id, service_ref, station codes, travel_date, seat_type, passengers, integer-minor amounts); per-item amounts sum to the total. rides[] (ridden legs) and ticket_offers[] (purchasable segments) are different lists — never infer transfers or fees from array lengths.",
188
+ "selection": "booking_support=single_leg cards carry booking_offer {service_id: itpay-rail-booking, selection_token: rsel_…}; submit it to itpay-rail-booking with passengers as today. separate_legs_only cards expose no whole-journey token — each leg is quoted and confirmed separately via its protected detail; never assemble legs JSON yourself. booking_support=none has no selection action.",
189
+ "honesty": "Coverage is bounded: repeat searched_scope/notices verbatim, never claim national-exhaustive search, and surface missing prices as unknown — never 0."
164
190
  }
165
191
  }
@@ -27,6 +27,19 @@ itpay services action <service_execution_id> --action <action_type>
27
27
 
28
28
  普通 Agent 优先使用 `--candidate <rank>`。CLI 只从当前 Execution 的 `current_result_items` 解析 Result Item ID;Backend 再读取权威 Invocation 和 Stable Hash。Agent 不提交 Hash,也不能使用其他 Execution 或外部来源的候选。`--result-item` 只用于已持有当前 Execution 内部句柄的受控恢复,不应要求用户提供。
29
29
 
30
+ ## 规划动作(rail.progressive.v2)
31
+
32
+ `services next` 的 `rail_planning.available_actions` 会给出可直接执行的完整命令。规划动作只允许以下类型,由 planning owner 校验执行归属、plan 状态与幂等键,不影响 Arazzo `human_action` 状态机或公共执行状态:
33
+
34
+ | action_type | 供应商消耗 | 说明 |
35
+ | --- | --- | --- |
36
+ | `workflow:expand_search` | 有界新增查询 | 仅在扩展暂停(`expansion_status=paused`)时可用;必须带服务端下发的 `action_request_id`(`pa_…`)与 `expected_query_revision`,一轮一柄、用完作废。 |
37
+ | `workflow:refine_preferences` | 无(本地重排) | 用 `--input-json` 提交偏好补丁,对已保存证据重排序。 |
38
+ | `workflow:stop_search` | 无 | 停止未发送的扩展任务;不影响已开始或已完成的出票。 |
39
+ | `select_journey` | 无 | 记录用户选定:`--input journey_id=<rj_…>`;仅记录选择、不改排名,后续购买仍走既有报价与受保护 Checkout。 |
40
+
41
+ 同一 `action_request_id` 相同内容重放返回原受理结果;相同 ID 不同内容会被判冲突。过期 `expected_query_revision` 的新动作被拒绝。
42
+
30
43
  ## 标准输出
31
44
 
32
45
  ```json
@@ -22,6 +22,7 @@ itpay services next <service_execution_id> [--json]
22
22
  | 参数 | 必填 | 说明 |
23
23
  | --- | --- | --- |
24
24
  | `service_execution_id` | 是 | `services start` 或后续命令返回的 execution ID。 |
25
+ | `--since-snapshot <snapshot_id>` | 否 | rail.progressive.v2 增量读:传上次看到的 `snapshot_id`,未变化时只回扩展状态与 `result_not_updated`,不重复下发 journey 负载;永远不触发供应商调用。 |
25
26
  | `--json` | 否 | 输出稳定 JSON 信封;未指定时输出相同事实的简洁文本。 |
26
27
 
27
28
  需要有效 Agent Device session。命令不接受 Buyer token、capability 或服务输入。
@@ -291,3 +292,12 @@ itpay services get <service_execution_id> --json
291
292
  ## Agent Type / Host
292
293
 
293
294
  所有正式支持的 Local Agent Type 返回完全相同的状态、safe payload、instruction 和 next。本命令不渲染二维码,也不包含 Host handoff。
295
+
296
+ ## 渐进规划(rail.progressive.v2)
297
+
298
+ 智能铁路规划服务在执行读模型上附带 `rail_planning` 投影:`readiness`(pending/ready/expired)、`search.expansion_status`(queued/running/paused/complete/cancelled/failed/expired)、`recommendation` 与 `alternatives` 行程卡片、以及服务端给出的完整 `available_actions` 命令。
299
+
300
+ - `running`/`queued`:稍后按 `next.command` 增量轮询同一执行;不要重新发起查询。
301
+ - `paused`:首批结果已就绪、扩展暂停等待用户意图;`expand_search` 是唯一会再消耗供应商配额的动作。
302
+ - `complete`:扩展收敛;展示推荐并等待用户选票。
303
+ - 选票走 `select_journey` 动作(命令在 `available_actions` 或卡片的 `select` 字段里,原样执行);乘车人身份信息永远只在受保护 Checkout 页面填写。
@@ -14,7 +14,9 @@
14
14
  ## 语法与参数
15
15
 
16
16
  ```bash
17
- itpay services page <service_execution_id> <result_item_id> [--offset <offset>] [--limit <limit>] [--json]
17
+ itpay services page <service_execution_id> <result_item_id> [--offset <offset>] [--limit <limit>] [--cursor <rcur_n>] [--json]
18
+
19
+ rail.progressive.v2 中 `<result_item_id>` 也可以是规划快照 ID(`rps_…`,见 `services next` 返回的 `snapshot_id`):分页读取该不可变快照内的 journey 卡片,`--cursor` 使用上一页返回的 `rcur_<offset>` 不透明游标。
18
20
  ```
19
21
 
20
22
  | 参数 | 必填 | 说明 |
@@ -18,8 +18,12 @@ itpay services read-result <service_execution_id> [--json]
18
18
  | 参数 | 必填 | 说明 |
19
19
  | --- | --- | --- |
20
20
  | `service_execution_id` | 是 | Vault 交付对应的 execution ID。 |
21
+ | `--journey <journey_id>` | 否 | rail.progressive.v2 规划明细:读取指定 journey 的完整卡片(分段、席别报价、接驳估计、风险标注)。免费、属主校验、不触发供应商调用。 |
22
+ | `--snapshot <snapshot_id>` | 否 | 与 `--journey` 配合,钉住某个已提交快照(`rps_…`);缺省时读最新 catalog 快照。 |
21
23
  | `--json` | 否 | 输出稳定 JSON 信封;未指定时输出相同事实的简洁文本。 |
22
24
 
25
+ 带 `--journey` 时走规划读取分支:不经过 grant/Vault、不受 `access_locked` 影响,因为规划明细本就是免付费证据。未带选择器时原授权路径完全不变。
26
+
23
27
  CLI 使用已登记设备的签名 session,不接受 Checkout token、Buyer token、`agent_device_id` 参数或开发者凭证。
24
28
 
25
29
  CLI 直接请求 Backend 的当前有效 Grant。历史 `delivery_bindings` 不作为访问判断;Backend 负责验证当前 Vault、Agent instance、Buyer、scope、TTL 和退款锁。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itpay/cli",
3
- "version": "2.1.10",
3
+ "version": "2.1.11",
4
4
  "description": "The ItPay CLI for services, orders, and human-authorized purchased content.",
5
5
  "type": "module",
6
6
  "bin": {