@itpay/cli 2.1.18 → 2.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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";
@@ -544,7 +545,33 @@ export async function runServicesAction(backend, serviceExecutionID, actionType,
544
545
  request.result_item_id = resultItemID;
545
546
  if (options.requiredBefore)
546
547
  request.required_before = options.requiredBefore;
547
- const response = await backend.recordServiceExecutionAction(serviceExecutionID, request);
548
+ let response;
549
+ try {
550
+ response = await backend.recordServiceExecutionAction(serviceExecutionID, request);
551
+ }
552
+ catch (error) {
553
+ if (error instanceof HttpError && ["booking_review_invalid", "booking_review_changed", "booking_review_locked"].includes(error.code)) {
554
+ throw new CommandContractError(error.code, error.message, error.code === "booking_review_invalid"
555
+ ? "确认输入与当前草稿不符。重新读取同一booking的review模板,席别使用返回的代码,不填显示名或猜新值。若仅修正代码写法,且车次、实际席别、人数、价格及条款等已确认内容未变,可沿用用户已有明确确认后提交;草稿实质变化或缺少真实同意时,先请用户确认变化内容。"
556
+ : error.code === "booking_review_changed"
557
+ ? "草稿版本已变化;重新读取同一booking的当前review与revision,经用户核对后再提交。"
558
+ : "当前购买已有付款尝试;读取同一booking及订单状态,不创建新订单或重做付款。", [{ command: `itpay services next ${serviceExecutionID} --json`, reason: "读取同一booking的当前状态与review" }]);
559
+ }
560
+ throw error;
561
+ }
562
+ if ("state" in response) {
563
+ const updated = await backend.getServiceExecution(serviceExecutionID);
564
+ const envelope = servicesNextEnvelope(updated);
565
+ envelope.result = {
566
+ ...envelope.result,
567
+ planning_action: { action_id: response.action_id, state: response.state, replayed: response.replayed },
568
+ };
569
+ writeCommandEnvelope(envelope, {
570
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
571
+ ...(options.output ? { output: options.output } : {}),
572
+ });
573
+ return;
574
+ }
548
575
  if (selection && actionType === "select_candidate" && response.status === "approved") {
549
576
  const updated = await backend.getServiceExecution(serviceExecutionID);
550
577
  const preferred = updated.allowed_actions?.[0];
@@ -792,6 +819,7 @@ export async function runServicesGet(backend, serviceExecutionID, options = {})
792
819
  const lockedRefund = response.refunds.find((refund) => refund.access_locked);
793
820
  const nextState = servicesNextEnvelope(response);
794
821
  const result = {
822
+ ...nextState.result,
795
823
  service_execution_id: execution.service_execution_id,
796
824
  service_id: execution.service_id,
797
825
  status: execution.status,
@@ -809,13 +837,11 @@ export async function runServicesGet(backend, serviceExecutionID, options = {})
809
837
  } : {}),
810
838
  };
811
839
  const envelope = {
812
- status: "shown",
840
+ status: nextState.status,
813
841
  result,
814
- instruction: lockedRefund || isTerminalServiceExecutionStatus(execution.status)
815
- ? nextState.instruction
816
- : "时间线仅用于解释和恢复;按当前首选动作继续,不要重放已完成步骤。",
842
+ instruction: nextState.instruction,
817
843
  next: nextState.next ? { command: nextState.next.command, reason: "继续当前首选动作" } : null,
818
- recovery: [{ command: `itpay services events ${serviceExecutionID} --json`, reason: "仅在需要完整诊断事件时使用" }],
844
+ recovery: nextState.recovery,
819
845
  };
820
846
  writeCommandEnvelope(envelope, {
821
847
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
@@ -843,7 +869,7 @@ export async function runServicesNext(backend, serviceExecutionID, options = {})
843
869
  }
844
870
  export async function runServicesPage(backend, serviceExecutionID, resultItemID, options = {}) {
845
871
  const offset = options.offset ?? 0;
846
- const limit = options.limit ?? 5;
872
+ const limit = options.limit ?? 20;
847
873
  if (!Number.isInteger(offset) || offset < 0) {
848
874
  throw new CommandContractError("offset_invalid", "--offset must be a non-negative integer", "offset 必须是非负整数;本次未读取服务端分页。", [{ command: `itpay services page ${serviceExecutionID} ${resultItemID} --json`, reason: "从第一页重新读取" }]);
849
875
  }
@@ -873,11 +899,11 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
873
899
  next_offset: nextOffset,
874
900
  page,
875
901
  },
876
- instruction: "读取的是已保存结果的同版本分页,不重新查询、不消耗额度。用普通语言向用户说明本页候选(车次、席别、时刻、费用口径),铁路应付与地面估算费用分开表述;不要提及 safe_payload、Execution 或内部 ID。",
902
+ instruction: "读取的是已保存结果的同版本分页,不重新查询、不消耗额度。先读完所需页,再合并回答用户;不要逐页播报。",
877
903
  next: nextOffset !== null
878
904
  ? { command: response.snapshot_id
879
- ? `itpay services page ${serviceExecutionID} ${resultItemID} --cursor rcur_${nextOffset} --json`
880
- : `itpay services page ${serviceExecutionID} ${resultItemID} --offset ${nextOffset} --json`,
905
+ ? `itpay services page ${serviceExecutionID} ${resultItemID} --cursor rcur_${nextOffset} --limit ${limit} --json`
906
+ : `itpay services page ${serviceExecutionID} ${resultItemID} --offset ${nextOffset} --limit ${limit} --json`,
881
907
  reason: "读取同版本结果的下一页" }
882
908
  : null,
883
909
  recovery: offset > 0
@@ -958,10 +984,8 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
958
984
  query_revision: committed.query_revision,
959
985
  catalog,
960
986
  },
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: "返回规划进展" },
987
+ instruction: "catalog 是本次查询的完整已保存目录。按用户问题筛选全部journeys并一次回答;只有需要单程细节时才读取journey。价格和余票供比较,购买前须实时报价。",
988
+ next: null,
965
989
  recovery: [],
966
990
  }, {
967
991
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
@@ -1126,7 +1150,19 @@ function railJourneySummary(card, serviceExecutionID) {
1126
1150
  command: `itpay services run ${card.booking_offer.service_id} --input-json <file> --json`,
1127
1151
  required_input: ["file"],
1128
1152
  executable: false,
1129
- input_example: { selection: { token: card.booking_offer.selection_token, seat_type: "<席别>" }, passengers: "<n>" },
1153
+ seat_choices: (card.representative_offer?.ticket_offers ?? []).map((offer) => ({
1154
+ seat_type: offer.seat_type,
1155
+ seat_name: offer.seat_name,
1156
+ })).filter((seat) => typeof seat.seat_type === "string"),
1157
+ input_example: {
1158
+ selection: {
1159
+ token: card.booking_offer.selection_token,
1160
+ ...(typeof card.representative_offer?.ticket_offers?.[0]?.seat_type === "string"
1161
+ ? { seat_type: card.representative_offer.ticket_offers[0].seat_type }
1162
+ : {}),
1163
+ },
1164
+ passengers: card.passengers ?? 1,
1165
+ },
1130
1166
  },
1131
1167
  }
1132
1168
  : {}),
@@ -1184,6 +1220,7 @@ function railPlanningEnvelope(model) {
1184
1220
  ...(plan.notices?.length ? { notices: plan.notices } : {}),
1185
1221
  ...(plan.recommendation ? { recommendation: railJourneySummary(plan.recommendation, se) } : {}),
1186
1222
  ...(cards.length ? { journeys: cards } : {}),
1223
+ ...(plan.snapshot_id ? { full_result: { command: `itpay services read-result ${se} --snapshot ${plan.snapshot_id} --json`, meaning: "读取已保存的完整目录;推荐和journeys仅是摘要" } } : {}),
1187
1224
  ...(plan.available_actions?.length ? { available_actions: plan.available_actions } : {}),
1188
1225
  };
1189
1226
  const nextPoll = {
@@ -1245,7 +1282,7 @@ function railPlanningEnvelope(model) {
1245
1282
  result,
1246
1283
  // §7.3: the lead line carries real combination counts bucketed by
1247
1284
  // 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。`,
1285
+ instruction: `${journeyMix ? `本次共${journeyMix}(席别不重复计数)。` : ""}${plan.search?.reason === "budget_exhausted" ? "本次查询已达到上限,以下是已查到的方案,搜索范围尚未全部核验。" : "本轮规划已完成。"}推荐和journeys是摘要;追问其他车次先读取 full_result 的已保存完整目录。compare:解释首选及最多两个有意义备选的时间/费用/便利性取舍后等待用户选择。prepare_checkout:用户已明确委托按规则选择时,按 interaction.recipe 选合格者并继续到官方确认页,不再次问是否下单;身份、review和最终付款边界仍然生效。乘车人身份信息不在此收集,后续购买走受保护 Checkout。`,
1249
1286
  next: journeys[0]?.booking_support === "single_leg"
1250
1287
  ? { command: railJourneySummary(journeys[0], se).select, reason: "用户在 compare 后选定推荐行程时执行;prepare_checkout 且规则命中时用 select_delegated" }
1251
1288
  : null,
@@ -1319,9 +1356,11 @@ function railPlanningEnvelope(model) {
1319
1356
  return {
1320
1357
  status: expansion,
1321
1358
  result,
1322
- instruction: `${modelDegradedHint}${failedPairsHint}${savedCatalog}${partial}本次规划已结束且不可续用;展示已有卡片后如需重查请用户确认后用同一服务新建执行。不要自动重新发起。`,
1359
+ instruction: `${modelDegradedHint}${failedPairsHint}${savedCatalog}${partial}${expansion === "expired" ? "规划已过期,已保存目录仍可读取并回答历史结果追问;购买须重新核验实时余票与报价。" : "本次规划已结束,展示已有结果;需要新的供应商观测时再明确发起查询。"}`,
1323
1360
  next: null,
1324
- recovery: [{ command: `itpay services events ${se} --json`, reason: "读取同一任务的处理记录" }],
1361
+ recovery: plan.snapshot_id
1362
+ ? [{ command: `itpay services read-result ${se} --snapshot ${plan.snapshot_id} --json`, reason: "读取同一任务已保存的完整目录" }]
1363
+ : [{ command: `itpay services next ${se} --json`, reason: "读取同一任务当前状态" }],
1325
1364
  };
1326
1365
  }
1327
1366
  default: {
@@ -1593,14 +1632,22 @@ function servicesNextEnvelope(model) {
1593
1632
  const failedInvocation = recovery
1594
1633
  ? [...model.provider_invocations].reverse().find((item) => typeof item.status === "string" && item.status.startsWith("failed"))
1595
1634
  : undefined;
1596
- const errorDetail = typeof failedInvocation?.error_message === "string" && failedInvocation.error_message ? failedInvocation.error_message : undefined;
1597
1635
  const providerErrorCode = typeof failedInvocation?.error_code === "string" && failedInvocation.error_code ? failedInvocation.error_code : undefined;
1636
+ const quoteFailure = execution.service_id === "itpay-rail-booking" ? {
1637
+ quote_source_unavailable: "实时车票数据暂不可用;保留原选择,稍后按用户意愿重新核验,不创建替代订单。",
1638
+ quote_train_changed: "所选车次的时刻或运行信息已变化;向用户说明并征询是否重新查票。",
1639
+ quote_seat_changed: "所选席别已变化;向用户说明并征询新的席别选择。",
1640
+ quote_seat_unavailable: "所选席别当前无足够余票;告知用户并由其决定是否换方案。",
1641
+ quote_refresh_expired: "实时报价过程超时;保留原选择,不把旧价当可付款价格。",
1642
+ }[providerErrorCode ?? ""] : undefined;
1598
1643
  const failedInstruction = recovery
1599
- ? failedStepGuidance
1600
- ? `执行在「${failedStepGuidance.meaning}」步失败:${failedStepGuidance.hint}${errorDetail ? `。供应商返回:${errorDetail}` : ""}。本执行已终止不能续用;修正后用同一服务新建执行重试,不要盲目重放。`
1601
- : state === "failed"
1602
- ? `执行已失败${failedStep ? `(失败步骤:${failedStep})` : ""}且不可续用;修正输入后用同一服务新建执行重试。`
1603
- : "执行未完成,请按步骤错误处理;不要重建执行或重复调用。"
1644
+ ? quoteFailure
1645
+ ? quoteFailure
1646
+ : failedStepGuidance
1647
+ ? `执行在「${failedStepGuidance.meaning}」步失败:${failedStepGuidance.hint}。本执行已终止不能续用;先核对原因,不要盲目重放。`
1648
+ : state === "failed"
1649
+ ? `执行已失败${failedStep ? `(失败步骤:${failedStep})` : ""}且不可续用;修正输入后用同一服务新建执行重试。`
1650
+ : "执行未完成,请按步骤错误处理;不要重建执行或重复调用。"
1604
1651
  : undefined;
1605
1652
  return {
1606
1653
  status: state,
@@ -1612,7 +1659,7 @@ function servicesNextEnvelope(model) {
1612
1659
  ...(guidance && state === "input_required" ? { guidance } : {}),
1613
1660
  ...(failedStep ? { failed_step: failedStep, ...(failedStepGuidance ? { failed_step_meaning: failedStepGuidance.meaning } : {}) } : {}),
1614
1661
  ...(providerErrorCode ? { provider_error_code: providerErrorCode } : {}),
1615
- ...(errorDetail ? { error_detail: errorDetail } : {}),
1662
+ ...(providerErrorCode && !quoteFailure ? { diagnostic_code: providerErrorCode } : {}),
1616
1663
  ...(recovery ? { retryable: state === "failed" } : {}),
1617
1664
  },
1618
1665
  instruction: failedInstruction
@@ -1638,7 +1685,7 @@ function servicesNextEnvelope(model) {
1638
1685
  } : {}),
1639
1686
  },
1640
1687
  } : {}),
1641
- recovery: recovery && state === "failed"
1688
+ recovery: recovery && state === "failed" && !quoteFailure
1642
1689
  ? [
1643
1690
  { command: `itpay services start ${execution.service_id} --json`, reason: "修正输入后重新发起(本执行已终止不能续用)" },
1644
1691
  ...(guidance ? [{ command: "itpay docs show rail-booking --json", reason: "查看本服务输入字段契约与示例" }] : []),
@@ -2092,12 +2139,12 @@ export async function runServicesRun(backend, config, serviceID, input, options
2092
2139
  const until = Date.now() + (options.timeoutSeconds ?? 120) * 1000;
2093
2140
  const sleep = options.sleep ?? ((milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)));
2094
2141
  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) {
2142
+ // A planning projection can exist before any usable result or user action.
2143
+ // Keep the existing bounded wait until there is something to hand over.
2144
+ const planningDeliverable = () => Boolean(model.rail_planning && (model.rail_planning.recommendation || model.rail_planning.alternatives?.length ||
2145
+ ["complete", "paused", "failed", "cancelled", "expired"].includes(model.rail_planning.search?.expansion_status ?? "") ||
2146
+ model.rail_planning.available_actions?.some((action) => ["select_journey", "expand_search"].includes(action.type))));
2147
+ while ((["queued", "running", "delivery"].includes(model.workflow?.status ?? "") || (model.workflow?.status === "payment" && paid())) && !model.current_delivery && !planningDeliverable() && Date.now() < until) {
2101
2148
  await sleep(options.pollIntervalMS ?? 1500);
2102
2149
  model = await backend.getServiceExecution(id);
2103
2150
  }
@@ -2116,7 +2163,10 @@ export async function runServicesRun(backend, config, serviceID, input, options
2116
2163
  await runServicesNext(backend, id, options);
2117
2164
  }
2118
2165
  catch (cause) {
2119
- if (cause instanceof CommandContractError || cause instanceof HttpError)
2166
+ if (cause instanceof HttpError && cause.code === "rail_seat_code_invalid" && id) {
2167
+ throw new CommandContractError(cause.code, cause.message, "席别必须使用原查询 booking_template.seat_choices 中与名称对应的代码,不能填“二等座”等显示名。用原 selection token 与真实人数修正输入,继续同一booking execution;若只是代码写法修正且车次、席别名称、人数等购买条件未变,沿用用户已给的确认,不再重复询问。", [{ command: `itpay services next ${id} --json`, reason: "读取同一booking当前状态后修正输入" }]);
2168
+ }
2169
+ if (cause instanceof CommandContractError || cause instanceof HttpError || cause instanceof DeviceLockBusyError || cause instanceof DeviceStateError)
2120
2170
  throw cause;
2121
2171
  if (cause instanceof HttpTransportError && !id) {
2122
2172
  throw new CommandContractError("workflow_start_outcome_unknown", cause.message, "创建服务执行时没有收到完整响应。先查询当前身份可见的执行;不要直接重跑并创建替代执行。", [{ command: "itpay services list --json", reason: "查找可能已经创建的服务执行" }]);
package/dist/src/main.js CHANGED
@@ -7,7 +7,7 @@ import { runServicesRun } from "./commands/services.js";
7
7
  // orchestrate; HTTP and rendering live in src/client and src/render.
8
8
  import { Command } from "commander";
9
9
  import { BackendOverrideError, CLI_VERSION, cliDistribution, loadConfig, cartSessionPath, newBackendClient } from "./state/config.js";
10
- import { DeviceAuthority, DeviceAuthorizationError, DeviceStateError } from "./state/device_authority.js";
10
+ import { DeviceAuthority, DeviceAuthorizationError, DeviceLockBusyError, DeviceStateError } from "./state/device_authority.js";
11
11
  import { CartSession } from "./state/cart_session.js";
12
12
  import { defaultHostForAgentType, normalizeHost, validateContext } from "./state/client_context.js";
13
13
  import { HttpError } from "./client/http.js";
@@ -116,6 +116,7 @@ function reportCLIError(error, contract) {
116
116
  const backendOverrideError = error instanceof BackendOverrideError ? error : undefined;
117
117
  const deviceError = error instanceof DeviceAuthorizationError ? error : undefined;
118
118
  const stateError = error instanceof DeviceStateError ? error : undefined;
119
+ const lockError = error instanceof DeviceLockBusyError ? error : undefined;
119
120
  const transportError = error instanceof HttpTransportError ? error : undefined;
120
121
  const httpRecovery = errorRecoveryActions(error).map((action) => ({
121
122
  command: action.command,
@@ -154,24 +155,30 @@ function reportCLIError(error, contract) {
154
155
  command: "itpay skill show itpay --json",
155
156
  reason: "读取 Device 状态边界;修复当前 Host 的持久写权限后重试原命令",
156
157
  }] : [];
158
+ const lockRecovery = lockError ? [{
159
+ command: "itpay device repair-lock --json",
160
+ reason: "检查锁持有者;只回收已退出进程留下的锁,不改变设备身份",
161
+ }] : [];
157
162
  const authorizationInstruction = stateError
158
163
  ? "当前运行环境无法写入 owner-only Device 状态;请保持同一 Node、CLI 和 Agent Type,在允许持久写入 ~/.itpay-v3 的执行环境中重试。不要手工创建 lock、删除 identity 或换运行时碰运气。"
159
- : error instanceof HttpError && error.code === "agent_device_session_required"
160
- ? "CLI 已自动续期并重试同一请求一次,仍被拒绝;停止重试,不要切换 Agent Type 或旋转身份。"
161
- : deviceError?.code === "agent_device_revoked"
162
- ? "Backend 已撤销当前 Device 登记;CLI 没有自动创建替代身份。停止重试并请用户或运营恢复登记。"
163
- : deviceKeyResettable && deviceError?.status === 500
164
- ? "服务端未能完成设备登记。先原样重试一次原命令:服务端会把已登记的同一公钥幂等挂回原设备。若仍返回 internal_error(Backend 未含该修复),执行 itpay device reset-key --confirm-key-reset 生成全新密钥后重试。"
165
- : deviceKeyResettable
166
- ? "服务端记录显示当前设备私钥已不再有效(已轮换或与既有登记冲突)。执行 itpay device reset-key --confirm-key-reset 生成全新密钥并重新登记。"
167
- : deviceError
168
- ? "Device 身份验证失败;停止重试,不要切换 Agent Type、删除状态或旋转私钥。"
169
- : undefined;
170
- if (contract || commandError || backendOverrideError) {
164
+ : lockError
165
+ ? "另一个进程仍在更新本地身份。执行 recovery 检查;若持有者仍活跃,等待后继续原任务,不要删除锁或重建身份。"
166
+ : error instanceof HttpError && error.code === "agent_device_session_required"
167
+ ? "CLI 已自动续期并重试同一请求一次,仍被拒绝;停止重试,不要切换 Agent Type 或旋转身份。"
168
+ : deviceError?.code === "agent_device_revoked"
169
+ ? "Backend 已撤销当前 Device 登记;CLI 没有自动创建替代身份。停止重试并请用户或运营恢复登记。"
170
+ : deviceKeyResettable && deviceError?.status === 500
171
+ ? "服务端未能完成设备登记。先原样重试一次原命令:服务端会把已登记的同一公钥幂等挂回原设备。若仍返回 internal_error(Backend 未含该修复),执行 itpay device reset-key --confirm-key-reset 生成全新密钥后重试。"
172
+ : deviceKeyResettable
173
+ ? "服务端记录显示当前设备私钥已不再有效(已轮换或与既有登记冲突)。执行 itpay device reset-key --confirm-key-reset 生成全新密钥并重新登记。"
174
+ : deviceError
175
+ ? "Device 身份验证失败;停止重试,不要切换 Agent Type、删除状态或旋转私钥。"
176
+ : undefined;
177
+ if (contract || commandError || backendOverrideError || lockError || stateError) {
171
178
  writeCommandEnvelope({
172
179
  status: "error",
173
180
  error: {
174
- code: incompatible ? "backend_contract_incompatible" : backendOverrideError?.code ?? commandError?.code ?? (error instanceof HttpError ? error.code : transportError?.code ?? stateError?.code ?? deviceError?.code ?? contract?.code ?? "command_failed"),
181
+ code: incompatible ? "backend_contract_incompatible" : backendOverrideError?.code ?? commandError?.code ?? (error instanceof HttpError ? error.code : transportError?.code ?? stateError?.code ?? lockError?.code ?? deviceError?.code ?? contract?.code ?? "command_failed"),
175
182
  message: error instanceof Error ? error.message : String(error),
176
183
  },
177
184
  ...(requiredCLIVersion ? {
@@ -228,9 +235,9 @@ function reportCLIError(error, contract) {
228
235
  : []
229
236
  : backendInternal || providerConnectionUnavailable || providerTemporary || providerInputRejected || providerContractMismatch || providerRejected || capabilityInputInvalid
230
237
  ? []
231
- : backendOverrideError ? [] : commandError?.recovery ?? (stateError ? stateRecovery : deviceError ? deviceRecovery : identityRecovery ? httpRecovery : contract?.recovery ?? []),
238
+ : backendOverrideError ? [] : commandError?.recovery ?? (stateError ? stateRecovery : lockError ? lockRecovery : deviceError ? deviceRecovery : identityRecovery ? httpRecovery : contract?.recovery ?? []),
232
239
  }, {
233
- ...(contract?.jsonOutput !== undefined ? { jsonOutput: contract.jsonOutput } : backendOverrideError ? { jsonOutput: process.argv.includes("--json") } : {}),
240
+ ...(contract?.jsonOutput !== undefined ? { jsonOutput: contract.jsonOutput } : backendOverrideError || lockError || stateError ? { jsonOutput: process.argv.includes("--json") } : {}),
234
241
  output: (text) => { process.stderr.write(text); },
235
242
  });
236
243
  process.exitCode = 1;
@@ -331,6 +338,30 @@ for (const action of ["login", "status"]) {
331
338
  }
332
339
  // --- device ---------------------------------------------------------------
333
340
  const deviceCmd = program.command("device").description("Recover the current official Backend registration after an operator-confirmed reset");
341
+ deviceCmd
342
+ .command("repair-lock")
343
+ .description("Safely inspect and recover a local Device identity lock left by an exited process")
344
+ .option("--json", "output JSON instead of terminal text")
345
+ .action((options) => {
346
+ try {
347
+ const config = loadConfig();
348
+ const status = new DeviceAuthority({
349
+ baseURL: config.baseURL,
350
+ ...(config.agentType ? { requestedAgentType: config.agentType } : {}),
351
+ compatibilityHeaders: {},
352
+ }).repairLock();
353
+ writeCommandEnvelope({
354
+ status: status === "recovered" ? "device_lock_recovered" : status === "active" ? "device_lock_active" : "device_lock_absent",
355
+ result: { lock_status: status, device_identity_preserved: true },
356
+ instruction: status === "active" ? "本地身份正由存活进程更新;稍后继续原任务。" : "本地锁无需进一步处理;继续原任务。",
357
+ next: null,
358
+ recovery: [],
359
+ }, { jsonOutput: Boolean(options.json), plainResult: [`lock: ${status}`, "device_identity: preserved"] });
360
+ }
361
+ catch (error) {
362
+ reportCLIError(error, { jsonOutput: Boolean(options.json), code: "device_lock_repair_failed", instruction: "本地锁无法安全检查;保留设备身份并联系维护者。" });
363
+ }
364
+ });
334
365
  deviceCmd
335
366
  .command("recover")
336
367
  .description("Forget only the current official Backend registration while preserving the local private key")
@@ -15,7 +15,7 @@ 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.18";
18
+ export const CLI_VERSION = "2.1.19";
19
19
  export const API_CONTRACT_REVISION = "sha256:2f9fbcdc5cd0630910abb52671304cc019e348e9a778017ec92ddc3513b3639f";
20
20
  const CART_SESSION_DEFAULT_DIR = ".itpay-v3";
21
21
  const CART_SESSION_FILENAME = "cart.json";
@@ -43,12 +43,25 @@ export class DeviceAuthority {
43
43
  }
44
44
  async ensureAuthorization() {
45
45
  if (!this.pending) {
46
- this.pending = withFileLock(`${this.statePath}.lock`, () => this.prepareAuthorization()).finally(() => {
46
+ this.pending = this.cachedAuthorization() ?? withFileLock(`${this.statePath}.lock`, () => this.prepareAuthorization());
47
+ this.pending = this.pending.finally(() => {
47
48
  this.pending = undefined;
48
49
  });
49
50
  }
50
51
  return this.pending;
51
52
  }
53
+ cachedAuthorization() {
54
+ const agentType = this.requestedAgentType;
55
+ if (!agentType)
56
+ return undefined;
57
+ const state = this.readState();
58
+ const registration = state?.registrations[this.backendKey];
59
+ const session = registration?.sessions[agentType];
60
+ if (!registration?.agentInstances[agentType] || !session || Date.parse(session.expiresAt) <= Date.now() + 60_000)
61
+ return undefined;
62
+ const privateKey = this.readPrivateKey();
63
+ return privateKey ? Promise.resolve({ state: registration, agentType, session, privateKey }) : undefined;
64
+ }
52
65
  async recoverAuthorization() {
53
66
  await withFileLock(`${this.statePath}.lock`, async () => {
54
67
  const state = this.readState();
@@ -61,6 +74,9 @@ export class DeviceAuthority {
61
74
  this.writeState(state);
62
75
  });
63
76
  }
77
+ repairLock() {
78
+ return inspectAndRecoverLock(`${this.statePath}.lock`);
79
+ }
64
80
  async recoverBackendReset() {
65
81
  return withFileLock(`${this.statePath}.lock`, async () => {
66
82
  const state = this.readState();
@@ -236,6 +252,7 @@ export class DeviceAuthority {
236
252
  method: "POST",
237
253
  headers: { "Content-Type": "application/json", Accept: "application/json", ...this.compatibilityHeaders, ...extraHeaders },
238
254
  body,
255
+ signal: AbortSignal.timeout(15_000),
239
256
  });
240
257
  const payload = await response.json().catch(() => ({}));
241
258
  if (!response.ok)
@@ -300,6 +317,13 @@ export class DeviceStateError extends Error {
300
317
  this.name = "DeviceStateError";
301
318
  }
302
319
  }
320
+ export class DeviceLockBusyError extends Error {
321
+ code = "device_lock_busy";
322
+ constructor() {
323
+ super("ItPay device identity is being updated by another process");
324
+ this.name = "DeviceLockBusyError";
325
+ }
326
+ }
303
327
  function emptyDeviceState() {
304
328
  return { schemaVersion: "itpay.device.v2", registrations: {} };
305
329
  }
@@ -344,9 +368,9 @@ async function withFileLock(path, run) {
344
368
  catch (error) {
345
369
  throw asDeviceStatePathError(error, "prepare_lock") ?? error;
346
370
  }
347
- const ownerToken = randomUUID();
371
+ const ownerToken = `${process.pid}:${randomUUID()}`;
348
372
  let acquired = false;
349
- for (let attempt = 0; attempt < 200; attempt += 1) {
373
+ for (let attempt = 0; attempt < 800; attempt += 1) {
350
374
  try {
351
375
  writeFileSync(path, ownerToken, { encoding: "utf8", flag: "wx", mode: 0o600 });
352
376
  chmodSync(path, 0o600);
@@ -357,20 +381,12 @@ async function withFileLock(path, run) {
357
381
  const code = error.code;
358
382
  if (code !== "EEXIST")
359
383
  throw asDeviceStateError(error, "acquire_lock") ?? error;
360
- try {
361
- if (Date.now() - statSync(path).mtimeMs > 30_000)
362
- moveLockAside(path, "stale", "remove_stale_lock");
363
- }
364
- catch (statError) {
365
- if (statError.code !== "ENOENT") {
366
- throw asDeviceStateError(statError, "inspect_lock") ?? statError;
367
- }
368
- }
384
+ inspectAndRecoverLock(path);
369
385
  await new Promise((resolve) => setTimeout(resolve, 25));
370
386
  }
371
387
  }
372
388
  if (!acquired)
373
- throw new Error("timed out waiting for ItPay device identity lock");
389
+ throw new DeviceLockBusyError();
374
390
  try {
375
391
  return await run();
376
392
  }
@@ -378,6 +394,46 @@ async function withFileLock(path, run) {
378
394
  releaseLock(path, ownerToken);
379
395
  }
380
396
  }
397
+ function inspectAndRecoverLock(path) {
398
+ let token;
399
+ let age;
400
+ try {
401
+ const lockStat = statSync(path);
402
+ age = Date.now() - lockStat.mtimeMs;
403
+ token = lockStat.isDirectory() ? "" : readFileSync(path, "utf8");
404
+ }
405
+ catch (error) {
406
+ if (error.code === "ENOENT")
407
+ return "absent";
408
+ throw asDeviceStateError(error, "inspect_lock") ?? error;
409
+ }
410
+ const ownerPID = /^(\d+):[0-9a-f-]+$/.exec(token)?.[1];
411
+ if (ownerPID) {
412
+ try {
413
+ process.kill(Number(ownerPID), 0);
414
+ return "active";
415
+ }
416
+ catch (error) {
417
+ if (error.code !== "ESRCH")
418
+ return "active";
419
+ }
420
+ }
421
+ else if (age <= 30_000) {
422
+ return "active";
423
+ }
424
+ // Recheck the exact owner before moving the lock; another process may have renewed it.
425
+ try {
426
+ if (token ? readFileSync(path, "utf8") !== token : !statSync(path).isDirectory())
427
+ return "active";
428
+ }
429
+ catch (error) {
430
+ if (error.code === "ENOENT")
431
+ return "absent";
432
+ throw asDeviceStateError(error, "inspect_lock") ?? error;
433
+ }
434
+ moveLockAside(path, "stale", "remove_stale_lock");
435
+ return "recovered";
436
+ }
381
437
  function releaseLock(path, ownerToken) {
382
438
  try {
383
439
  if (readFileSync(path, "utf8") !== ownerToken)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "itp.agent_doc.v1",
3
3
  "role": "buyer",
4
- "product_scope": "itpay is the single public CLI entry point, and $itpay is its user-facing Skill invocation. Under that one product entry point, the two top-level commerce actions are buy and sell: Buyer workflows are available now; Seller workflows will use the same entry point and are not implemented yet.",
4
+ "product_scope": "itpay is the public CLI entry point for Buyer services and existing itpay sell Seller workflows.",
5
5
  "topic": "install-and-setup",
6
6
  "title": "Install And Identify The ItPay Agent Runtime",
7
7
  "purpose": "Select the real Agent Type, verify one official Backend, and load the packaged Skill through one linear setup path.",
@@ -32,7 +32,7 @@
32
32
  }
33
33
  ],
34
34
  "agent_rules": [
35
- "Start with install, identify the current real runtime from result.agent_types, run itpay install <agent_type> --json yourself, and then execute each returned next.command unchanged until the Skill is loaded.",
35
+ "For first setup or a runtime change, identify the real Agent Type once, run itpay install <agent_type> --json yourself, and follow its setup continuation until the packaged Skill is loaded. Ordinary service requests start from the Skill's service command, without repeating install or readyz.",
36
36
  "After setup, tell the human only that ItPay is ready; never ask them to run setup commands or explain technical setup steps.",
37
37
  "Use the CLI distribution already provided by the current npm package, Skill, or plugin; update only through that distribution's own instructions.",
38
38
  "Production defaults to https://app.itpay.ai. Testing may set ITPAY_BACKEND_URL to the exact official URL https://sandbox.itpay.ai; no other Backend is allowed.",
@@ -13,9 +13,9 @@
13
13
  "A query returned login_required or rate_limited."
14
14
  ],
15
15
  "service_selection": {
16
- "itpay-rail-exact": "Use when the buyer knows BOTH the exact departure and arrival railway station names (e.g. 古镇 → 广州南). It is one station-pair + one travel_date query: filter the returned list locally for remembered train numbers or time windows — do NOT re-query per train. Do NOT use it with only a city or fuzzy location.",
16
+ "itpay-rail-exact": "Use when both exact railway station names are known, or when a few credible station-pair hypotheses can be checked with exact (e.g. 古镇 → 广州南). It is one station-pair + one travel_date query: filter the returned list locally for remembered train numbers or time windows — do NOT re-query per train. Do not silently treat a city name as its station.",
17
17
  "itpay-rail-smart": "Use when the buyer has a city name, an area, an address, or wants transfer planning and recommendations. The platform resolves locations to stations itself; never silently convert a city into its namesake station.",
18
- "itpay-rail-booking": "Purchase service — use ONLY after the buyer explicitly chose a train and seat from a query result. Starts quote → protected checkout → payment → asynchronous issuance on one execution. Never queried for schedules.",
18
+ "itpay-rail-booking": "Purchase service — use after the buyer chooses a train/seat or delegates selection under clear rules. Starts draft/review → live quote → protected checkout → asynchronous issuance on one execution. Never queried for schedules.",
19
19
  "svc_qizhidao_company_lookup": "Not a railway service; do not mix intents."
20
20
  },
21
21
  "input_contract": {
@@ -62,7 +62,7 @@
62
62
  "passengers": "Integer 1-5: number of passengers, NOT passenger identity records.",
63
63
  "selection": {
64
64
  "token": "The booking_offer.selection_token carried on the chosen result item.",
65
- "seat_type": "Seat code when the user picked a specific class, e.g. O."
65
+ "seat_type": "Use the seat_type code shown with seat_name in the returned booking_template.seat_choices (e.g. O = 二等座); never submit the display name as the code. Omit this optional field when the returned offer has no code; never guess."
66
66
  }
67
67
  },
68
68
  "explicit_legs": {
@@ -74,10 +74,10 @@
74
74
  },
75
75
  "selection_flow": [
76
76
  "Exact results carry per-train candidates; smart results carry journeys. Bookable options carry booking_offer with a server-signed selection_token.",
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.",
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
+ "The token binds the stored result snapshot and the chosen option. Submit it with passengers (and the exact seat_type code from booking_template.seat_choices 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. If only the code spelling is corrected and the confirmed train, actual seat class, passenger count, price, and terms remain identical, reuse the buyer's explicit approval. Material draft changes or missing approval require confirmation.",
78
+ "A historical selection identifies the original choice; booking refreshes current fare and inventory. A foreign buyer is rejected. Never guess token contents or hand-build legs as recovery.",
79
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.",
80
+ "v2 single-leg cards carry an opaque rsel_ selection_token bound to the buyer and saved journey/offer. The original selection can enter booking after its search window expires; live quote determines whether that exact train and seat remain purchasable.",
81
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."
82
82
  ],
83
83
  "trial_and_login": {
@@ -88,8 +88,7 @@
88
88
  "purchase_after_trial": "Selecting a saved option and buying it does not consume search quota and works even when trial is exhausted."
89
89
  },
90
90
  "flow": [
91
- "services start <service> --json returns input_required plus the guidance block for rail services.",
92
- "Submit input with services run <service> --execution <id> --input-json <file> --json on the SAME execution.",
91
+ "For a known railway service, submit input directly with services run <service> --input-json <file> --json; keep the returned execution for follow-ups.",
93
92
  "itpay-rail-smart may stop at a location confirmation step; show the candidate places to the buyer, then continue the same execution after the human confirms.",
94
93
  "After the buyer explicitly picks a train/seat, run itpay-rail-booking with the selection token (preferred) or explicit legs. The execution then pauses at the booking review (workflow:confirm_booking): services next returns booking_review_required with human_action.context.review carrying draft_revision, legs, seat_options and notice_version.",
95
94
  "Whether or not the query was free, the answer is the same shape under compare: one preferred option, at most two meaningful alternatives, the key trade-offs — then stop and wait for the buyer. Do not stack more options or chain into purchase.",
@@ -122,7 +121,7 @@
122
121
  "revision": "While payment has not started, the same execution accepts a revised review: resubmit confirm_booking with the new draft_revision; the old unpaid quote/checkout is retired and re-quoted. After a payment attempt the review is closed — never create a replacement purchase."
123
122
  },
124
123
  "agent_rules": [
125
- "Pick the service by the buyer's input precision: exact station names → itpay-rail-exact; anything fuzzier → itpay-rail-smart; explicit purchase intent after a selection → itpay-rail-booking.",
124
+ "Pick the service by the buyer's input precision: known stations or a few credible station-pair hypotheses → itpay-rail-exact; broader fuzzy locations → itpay-rail-smart; explicit purchase intent after a selection → itpay-rail-booking.",
126
125
  "Choose the goal first (see goals): compare presents one preferred option plus at most two meaningful alternatives then waits; prepare_checkout selects under the buyer's rules and continues to the official confirmation entry without re-asking.",
127
126
  "Never invent input field names. If the guidance block or this doc does not list a field, do not send it.",
128
127
  "Never ask the buyer for passenger names, ID numbers, or phone numbers in chat; those are collected only on the protected checkout page.",
@@ -200,6 +199,6 @@
200
199
  "A pending/login/error state is never new intent; withdrawal updates the goal but never rewrites a completed payment.",
201
200
  "Neither goal authorizes payment or skips confirmation: payment, terms and passenger identity stay behind their own gates."
202
201
  ],
203
- "recipe": "For the query→booking→review→checkout continuity after a valid selection, load the rail-fast-checkout topic (itpay docs show rail-fast-checkout --json)."
202
+ "recipe": "The Skill first screen describes query→booking→review→checkout. Load rail-fast-checkout only when the current response leaves a stage detail unclear."
204
203
  }
205
204
  }
@@ -21,7 +21,7 @@
21
21
  {
22
22
  "step": "start_booking",
23
23
  "command": "itpay services run itpay-rail-booking --input-json /tmp/itpay-booking-input.json --json",
24
- "then": "itpay services next <execution_id> --json",
24
+ "then": "Use the booking run response directly; call its next only if it is still waiting for progress.",
25
25
  "expected_status": "booking_review_required"
26
26
  },
27
27
  {
@@ -33,13 +33,14 @@
33
33
  "notes": [
34
34
  "draft_revision must equal the current server value; booking_review_changed means re-read services next and resubmit, never bump locally.",
35
35
  "accept_non_guaranteed is true only when the buyer actually agreed; never fabricate a human approval.",
36
+ "On booking_review_invalid, re-read this booking. A seat-code spelling correction may reuse the buyer's explicit approval only if train, actual seat class, passenger count, price, and terms are unchanged; confirm material changes or missing approval.",
36
37
  "Passenger names, ID numbers and phones are NOT in this input — they belong to the protected checkout page."
37
38
  ]
38
39
  },
39
40
  {
40
41
  "step": "checkout_handoff",
41
- "read": "services next returns checkout_required/payment_pending with handoff.url (the protected checkout page).",
42
- "command": "itpay checkout --id <checkout_id> --token <display_token> --present auto --json",
42
+ "read": "The current booking response may already contain handoff.url (the protected checkout page).",
43
+ "command": "Only if the current handoff was not presented, use the exact checkout command returned by the CLI.",
43
44
  "notes": [
44
45
  "Show or open the checkout exactly once; pass --viewer mobile when the buyer is on a phone.",
45
46
  "The buyer completes identity and payment on that page (Path B — no forced separate login). Payment handoff is not payment success; wallet return is not confirmation."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itpay/cli",
3
- "version": "2.1.18",
3
+ "version": "2.1.19",
4
4
  "description": "The ItPay CLI for services, orders, and human-authorized purchased content.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,12 +13,62 @@ Use the `itpay` CLI as the only ItPay control surface. Infer the human's goal,
13
13
  choose one first command, then follow each returned envelope. Run technology
14
14
  for the human; never ask them to run commands or learn internal concepts.
15
15
 
16
+ ## The Whole Trip
17
+
18
+ ItPay finds services, runs them and delivers results. A known railway request
19
+ starts with the railway commands below; use catalog only to discover an unknown
20
+ service. Existing purchases resume from their original execution or order.
21
+ Seller work uses `itpay sell`.
22
+
23
+ Railway: understand date, places, time limits, passengers and purchase intent →
24
+ query one station pair with exact, or plan uncertain places/routes with smart →
25
+ select a matching offer → create booking draft → obtain genuine human review
26
+ when required → refresh quote and hand over the official page → human pays →
27
+ check issuance. A comparison stops at the query result. A delegated purchase
28
+ continues until a required human step or the official handoff. Login and review
29
+ are pause points; a draft, payment entry, paid order and issued ticket are
30
+ different states.
31
+
32
+ | Stage | Command shape | What comes back |
33
+ | --- | --- | --- |
34
+ | Query | `itpay services run itpay-rail-exact --input-json <query-file> --json` or `itpay-rail-smart` | Query execution, effective request, recommendation, saved snapshot and bookable selection |
35
+ | Follow-up | `itpay services read-result <query-execution> --snapshot <snapshot> --json` | Full saved catalog; filter it for the new question before making another supplier query |
36
+ | Booking | `itpay services run itpay-rail-booking --input-json <booking-file> --json` | Separate booking execution, draft and current review requirement |
37
+ | Human review, if requested | `itpay services action <booking-execution> --action workflow:confirm_booking --actor-type human --status approved --input-json <review-file> --json` | Recorded confirmation and actual next step; approve only after real human consent, using the returned template |
38
+ | Continue, if requested | `itpay services run itpay-rail-booking --execution <booking-execution> --json` | Fresh quote and official page, or the next required action |
39
+ | After human payment | `itpay services next <booking-execution> --json` | Payment and issuance facts; report issuance only when confirmed |
40
+
41
+ Create the JSON files yourself. Exact query needs `origin`, `destination`,
42
+ `travel_date`; filter time limits against returned rows. Smart accepts those
43
+ fields plus actual constraints such as `arrive_before`, `priority`, and
44
+ `passengers`. Booking input uses the returned `selection.token`, selected
45
+ `seat_type`, and passenger count; never assemble legs from prose. Copy the
46
+ complete review template and revision from the current booking response.
47
+ For `booking_review_invalid`, re-read that same booking. If only a seat code
48
+ was written incorrectly and the confirmed train, actual seat class, passenger
49
+ count, price, and terms are unchanged, reuse the buyer's explicit approval.
50
+ Ask for confirmation if the draft materially changed or approval is missing.
51
+ Keep the query execution/snapshot for follow-ups, selection for booking, and
52
+ booking execution/order for review and payment. Do not substitute one ID for
53
+ another. `result` is the current fact, `instruction` its meaning, `next` the
54
+ current continuation, `handoff` the human entry, and `recovery` an exceptional
55
+ path. `next: null` can mean comparison complete or a human/template pause.
56
+
57
+ One exact comparison normally takes one query run. Chat review of a typical
58
+ booking targets query run, booking run, review action, and sometimes one
59
+ booking continuation before the official page; extra detail reads, login and
60
+ location ambiguity add calls. This is a guide, never a reason to skip consent.
61
+ You may test a few credible station-pair hypotheses with exact, reuse saved
62
+ results, continue under an existing delegation, and combine missing facts in
63
+ one question. Do not change date, relax a hard constraint, expand paid search
64
+ or pay without authorization.
65
+
16
66
  ## Route The Human's Intent
17
67
 
18
68
  | Human intent | First action |
19
69
  | --- | --- |
20
70
  | Create, sell or publish a service | `itpay sell guide --json`, then `itpay sell status --json` |
21
- | Discover services or make a new query | `itpay catalog list --json` |
71
+ | Discover an unknown service | `itpay catalog list --json` |
22
72
  | View previously purchased content | `itpay vault list --json` |
23
73
  | Find a previous result by subject | `itpay vault list --query <subject> --json` |
24
74
  | Inspect purchase history | `itpay orders --json` |
@@ -38,7 +88,7 @@ Three services, three different jobs — pick by the human's input, never merge
38
88
  | Human has | Service |
39
89
  | --- | --- |
40
90
  | Exact departure AND arrival station names | `itpay-rail-exact` (direct query) |
41
- | City/area/address, or wants transfer planning | `itpay-rail-smart` (location resolution + planning) |
91
+ | Station pair or route is uncertain after a few credible hypotheses, or needs broad transfer planning | `itpay-rail-smart` (location resolution + planning) |
42
92
  | Chose a train/seat and wants to buy | `itpay-rail-booking` (quote → protected checkout → issuance) |
43
93
 
44
94
  ### Choose The Goal First
@@ -74,11 +124,8 @@ supports route hypotheses only, never same-day schedule/price/inventory facts.
74
124
  A saved snapshot's pages, journey detail and rerank are free reads; a new live
75
125
  observation needs the owner's authorization.
76
126
 
77
- After a valid selection under `prepare_checkout`, the short
78
- `rail-fast-checkout` recipe covers query→booking→review→checkout continuity:
79
- `itpay docs show rail-fast-checkout --json`. It reuses existing
80
- `services run`/`action`/`checkout` — no fast-buy command, no payment bypass,
81
- no fabricated human approval.
127
+ Read the `rail-fast-checkout` topic only for a stage detail the current return
128
+ does not explain. Continue from the original execution and current return.
82
129
 
83
130
  Query results carry a server-issued `booking_offer.selection_token` on bookable
84
131
  options; for a purchase submit that token plus passenger count — never
@@ -90,6 +137,11 @@ failure — resume the same execution after `itpay auth login`. For the full
90
137
  input contract and states, load the rail-booking topic via
91
138
  `itpay docs search rail-booking --json`.
92
139
 
140
+ At checkout, an official login page can be the normal entry to traveler
141
+ confirmation and then payment. Tell the human what the page currently asks
142
+ them to do; do not call a draft a reserved ticket or a payment link a paid
143
+ order. A seat preference may be requested but is not a seat guarantee.
144
+
93
145
  ## Follow One Envelope
94
146
 
95
147
  For each JSON response: