@sema-agent/client-core 0.76.1 → 0.77.0

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.
@@ -62,16 +62,27 @@ export const REQUEST_FIELD_MATRIX = [
62
62
  { field: 'limits', lanes: ['print'], live: true, why: 'P2-3-b:`-p` 的预算护栏(--max-* flag 族),交互 REPL 由人随时 Esc' },
63
63
  { field: 'interactiveTools', lanes: ['print'], live: true, why: '[909]B 件3:无人值守 stamp false,从 roster 源头灭掉 AskUserQuestion/plan 门。交互车道 stamp false 等于自废武功' },
64
64
  { field: 'oneShot', lanes: ['print'], live: true, why: 'SDK TaskRequest.oneShot(server ≥7.12.0 消费):`-p` 提交是一次性的——没有后续回合接住异步后台通知,core 据此把「结束回合等通知」指引换成 block-wait;交互车道有后续回合,stamp true 就是谎报。老 worker 静默忽略(开集提交体),故发送侧不设能力位前置门(oneShotWireCaps 头注)' },
65
+ // ── 0.77.0(CC-101)print 车道补座两件:无人值守车道才有来源的请求位 ─────────────────────────
66
+ // 加座判据三处同时成立才加(缺一处 ⇒ 不加座,登 `upstream_absent`,见 {@link UPSTREAM_ABSENT_REQUEST_INTENTS}):
67
+ // ① 装着的 sdk `TaskRequest` 型面上有这一位的**具名**声明(索引签名 `[k: string]: unknown` 不算 ——
68
+ // 它对任何键都成立,拿它当证据等于没有证据);② 上游提交体真读这一键;③ 引擎 spec 真消费它。
69
+ { field: 'outputSchema', lanes: ['print'], live: true, why: '结构化输出约束(sdk `TaskRequest.outputSchema`:最终答案必须匹配的 JSON Schema)。上游只验形状与序列化尺寸(坏形 400 响亮拒)、深层合法性归引擎 ⇒ 本层原样透传、不预铸第二判官。来源只在无人值守车道(`--json-schema` 一类旗);交互面今天没有入口 ⇒ 缺席是**没有来源**,不是漏。⚠️ 本行只管**上行**这一位:结果一侧的结构化产出落不落 CC 形 result 帧是下行投影的事,不在本表' },
70
+ { field: 'maxCostUsd', lanes: ['print'], live: true, why: '花费上限声明(sdk `TaskRequest.maxCostUsd`,请求体**顶层**位;上游向下夹到部署上限 —— 只许要得更少)。与 `limits` 同一条理由只在无人值守车道:交互面由人随时中断。🔴 坏值(非数 / 非有限 / 非正)在本层**响亮拒**:上游对这一位的坏值是安静忽略并回落部署上限,不是 4xx(见 `refuseBadMaxCostUsd`)' },
65
71
  // ── interactive 车道独有 ────────────────────────────────────────────────────────────────────
66
72
  // ⚠️ 「interactive」**不等于**「TUI」:web 的 BFF lane 也报 `interactive`
67
73
  // (`sema-web-client` `WEB_REQUEST_LANE`)。下面多数条目的理由写的是壳的 TUI 面 —— 它们是
68
74
  // B4 从壳的三个构造器逐行读出来的,那时这条车道只有壳一个消费者。`attachmentIds` 是**反过来**
69
75
  // 的第一条:它今天只有 web/desktop 在发,壳反而没有这个入口。别把本节读成「壳独有」。
70
76
  { field: 'attachmentIds', lanes: ['interactive'], live: true, why: '附件**字节通道**(uploadAttachment 回执 id;server ≥1.289 绑定会话并把文件物化进 run 的 attachments/)。有上传入口的端才有 id 可引用:web/desktop 有,壳走 images / 本地文件路径,`-p` 车道连 uploadAttachment 都没有 ⇒ print 缺席是**没有来源**,不是漏。🔴 与上面的 `attachments` 同名不同物(那是 turn 边界的配置键),两条永远不许合并' },
71
- { field: 'excludeTools', lanes: ['interactive'], live: true, why: 'design/161 W3(2026-07-30):真 wire 键(SDK types `excludeTools`)且早在 seatContract `START_SESSION_OPTION_KEYS` 里,却是 desktop 手写 taskReq 的第 9 键、矩阵外——而 stamp 门对未登记键**静默丢弃**,真实危险形=「用户显式排除的工具被静默放回」(权限方向回归,类型层不报)。今天只有 desktop 在发;壳的工具面走 roster/interactiveTools 另一条路,print 无排除入口' },
77
+ { field: 'excludeTools', lanes: ['interactive', 'print'], live: true, why: 'design/161 W3(2026-07-30):真 wire 键(SDK types `excludeTools`)且早在 seatContract `START_SESSION_OPTION_KEYS` 里,却是 desktop 手写 taskReq 的第 9 键、矩阵外——而 stamp 门对未登记键**静默丢弃**,真实危险形=「用户显式排除的工具被静默放回」(权限方向回归,类型层不报)。今天只有 desktop 在发;壳的工具面走 roster/interactiveTools 另一条路。0.77.0 补 print 座(三问:① wire 位与车道无关 —— sdk 具名位、提交体真收 `body.excludeTools`;② 来源 —— print 今天没有排除入口,但这是**收紧方向**的声明,传了只进回执 = 端不读回执就把用户排除的工具放回;③ 口径 —— 仍是 live 门后的位,与交互面同一行)' },
72
78
  { field: 'settings.ultracode', lanes: ['interactive'], live: true, why: 'design/111:sticky `/effort ultracode` 拨盘 + 当轮关键词嗅探,两个来源都只在交互面存在', gap: true },
73
- { field: 'systemPrompt', lanes: ['interactive'], live: false, why: 'CC QueryParams.systemPrompt;print 腿的 params 没有这一位' },
74
- { field: 'reasoningEffort', lanes: ['interactive'], live: false, why: '`/effort` 拨盘存在 AppState,print 无 AppState', gap: true },
79
+ // ⚠️ 下面两行 0.77.0(CC-101)之前是 `lanes:['interactive']`。修前的理由句(「print 腿的 params 没有这一位」/
80
+ // 「print 无 AppState」)描述的是搬迁当天壳内两个构造器的**形参**,不是 wire 事实:两位在 sdk `TaskRequest`
81
+ // 上都是与车道无关的具名顶层位,上游提交体与引擎 spec 两层都真消费。后果 = 无人值守车道传了也整键不上
82
+ // 请求体,而调用方拿不到任何信号(下游壳实测:只好把对应的旗判成「未接线」如实告知用户)。
83
+ // 行留在本节(而不是搬到「两条车道都有」那一节)只为让 diff 停在一行上;`lanes` 才是事实。
84
+ { field: 'systemPrompt', lanes: ['interactive', 'print'], live: false, why: '整段替换系统提示(sdk `TaskRequest.systemPrompt`;上游字符数超限 400 响亮拒,本层原样透传)。两条车道都有来源:交互面 = CC QueryParams.systemPrompt,无人值守面 = `--system-prompt[-file]` 一类旗。与追加位 `appendSystemPrompt` 不是一个位(那一位归 live 兜底层,见 `LIVE_DEFAULT_FIELDS`)' },
85
+ { field: 'reasoningEffort', lanes: ['interactive', 'print'], live: false, why: '思考档位(sdk `TaskRequest.reasoningEffort` 七词闭集;上游对表外词 400 响亮拒 ⇒ 本层不预铸第二判官)。档位是请求级的位、与车道无关:交互面 = `/effort` 拨盘,无人值守面 = 档位旗。🔴 端交的必须是**档位词**:「思考 token 数」今天没有上游对位(见 `UPSTREAM_ABSENT_REQUEST_INTENTS` 的 `maxThinkingTokens`),别把一个数字折成档位词发出去' },
75
86
  { field: 'model', lanes: ['interactive'], live: false, why: '`/model` 中途换模型读 toolUseContext.options.mainLoopModel;print 的模型走 MODEL_ID/--model 另一条路', gap: true },
76
87
  { field: 'images', lanes: ['interactive'], live: false, why: '贴图提交是交互动作,`-p` 的 stdin 没有图片块' },
77
88
  // ⚠️ 四键一条(0.35.0 补第四键 `resumeAtMode`):它是 `rewindWireCaps` 的 `SeamRewindSpec` 真 wire 键,
@@ -95,7 +106,226 @@ export const LIVE_DEFAULT_FIELDS = [
95
106
  'compactionModel',
96
107
  'sessionId(三态解析)',
97
108
  ];
98
- const laneHas = (field, lane) => REQUEST_FIELD_MATRIX.find(f => f.field === field)?.lanes.includes(lane) ?? false;
109
+ // ══ 逐键去向(0.77.0 / CC-101)════════════════════════════════════════════════════════════════════
110
+ //
111
+ // 修前的病形:stamp 门 `on()` 有**四种**说「不」的理由(值缺席 / 表里没有这一行 / 本车道不在 lanes 里 /
112
+ // live 门没开),四种都折成同一个 `false`;而入参上构造器**压根不读**的键(表外键)连这个 `false` 都轮不到。
113
+ // 调用方拿到的只有一份请求体 —— 少了哪个键、为什么少,没有任何位能答。
114
+ //
115
+ // 修形分两半,**刻意不合并**:
116
+ // (甲) 本包**认识**的键这一次没上请求体 ⇒ 不抛,照常装配,逐键给成因({@link TaskRequestOmission})。
117
+ // 一个多余但无害的键不该把提交升级成硬失败;该知道的人从回执上知道。
118
+ // (乙) 本包**不认识**的键 ⇒ 响亮拒(`TypeError`)。不认识 = 三种可能分不开:打错的在座键名 / 真 wire 位而
119
+ // 本包的表还没登记 / 纯垃圾。前两种里都可能是**收紧方向**的声明(排除工具、关记忆、花费上限),
120
+ // 安静丢掉 = 这次运行比声明人以为的更宽,而且没有任何人会知道 —— 与 `memoryCapture` 坏拼写同一条
121
+ // 纪律,只是错在键名那一轴。🔴 把这一档折成 (甲) 的某个成因词 = 拿「不知道」冒充「明确不带」。
122
+ /**
123
+ * 「入参里在场、却没上请求体」的**成因词表**(冻结)。🔴 词序就是判定序(先中先得),判定逐词走
124
+ * {@link OMISSION_JUDGES} —— 那张判官表的键型就是本词表的成员型:加词不加判官 / 加判官不加词,编译当场红。
125
+ *
126
+ * · `upstream_absent` —— 本包认识这个意图,但上游没有对位(sdk 型面无具名位,提交体与引擎 spec 也不读)。
127
+ * 见 {@link UPSTREAM_ABSENT_REQUEST_INTENTS}。恒不发,与车道 / live 无关。
128
+ * · `other_channel` —— 是请求体上的真位,但**不归本构造器**盖:归 live 兜底层({@link LIVE_DEFAULT_FIELDS},
129
+ * 端在构造之后经 {@link applyLiveRequestDefaults} 落位)。塞进本构造器的入参 = 没发出去。
130
+ * · `off_lane` —— 在车道表里,但本车道不在那一行的 `lanes` 里(理由读那一行的 `why`)。
131
+ * · `not_live` —— 本车道有座,但那一行是 live 门后的位,而这一次 `input.live` 为假(mock / fixture 车道)。
132
+ *
133
+ * 🔴 本词表只答「**本构造器**这一次带没带」。带了 ≠ 引擎兑现了:老版本上游静默忽略新键、部署策略拒、
134
+ * 能力位没开,那些只有引擎知道 —— 端不许拿「回执里没有这一键」去渲一句「已生效」。
135
+ * ⚠️ **值级等价缺席不入回执**:空数组 / `council:false` / `oneShot` 非 `true` 这类值在 wire 上与缺席同义
136
+ * (构造器发小的那个),没有意图被丢,报它们只是噪音。
137
+ */
138
+ export const TASK_REQUEST_OMISSION_CAUSES = Object.freeze([
139
+ 'upstream_absent',
140
+ 'other_channel',
141
+ 'off_lane',
142
+ 'not_live',
143
+ ]);
144
+ const row = (field) => ({ via: 'matrix', field });
145
+ /**
146
+ * 🔴 键型 = `keyof Required<TaskRequestInput>`:入参型加一个键而这里不登记 ⇒ 编译红;这里多一个入参型没有的
147
+ * 键 ⇒ 同样编译红。「入参型上声明了、构造器却从不读」的键因此不可能再安静存在(修前 `agents` 正是这一形)。
148
+ */
149
+ const INPUT_KEY_ROUTES = {
150
+ objective: { via: 'always' },
151
+ sessionId: { via: 'always' },
152
+ live: { via: 'control' },
153
+ scenario: row('scenario'),
154
+ systemPrompt: row('systemPrompt'),
155
+ reasoningEffort: row('reasoningEffort'),
156
+ model: row('model'),
157
+ images: row('images'),
158
+ skills: row('skills'),
159
+ mcpServers: row('mcpServers'),
160
+ clientContext: row('clientContext'),
161
+ scratchpadDir: row('scratchpadDir'),
162
+ sandboxImageProfile: row('sandboxImageProfile'),
163
+ permissionMode: row('permissionMode'),
164
+ selfOrchestration: row('selfOrchestration'),
165
+ deferTools: row('deferTools'),
166
+ retainBackgroundProcesses: row('retainBackgroundProcesses'),
167
+ promptProfile: row('promptProfile'),
168
+ enableFork: row('enableFork'),
169
+ attachments: row('attachments'),
170
+ attachmentIds: row('attachmentIds'),
171
+ excludeTools: row('excludeTools'),
172
+ finalVerification: row('finalVerification'),
173
+ limits: row('limits'),
174
+ interactiveTools: row('interactiveTools'),
175
+ oneShot: row('oneShot'),
176
+ outputSchema: row('outputSchema'),
177
+ maxCostUsd: row('maxCostUsd'),
178
+ taskBudget: {
179
+ via: 'fixed',
180
+ cause: 'upstream_absent',
181
+ why: '整任务 token 预算:sdk `TaskRequest` 无具名位,上游提交体与引擎 spec 也没有请求级的对位(引擎侧的整任务总额只从部署配置来)',
182
+ },
183
+ fallbackModel: {
184
+ via: 'fixed',
185
+ cause: 'upstream_absent',
186
+ why: '过载后备模型:sdk `TaskRequest` 无具名位;上游的降级目标是部署配置,请求级只有「允不允许降级」的意图位(`resilience`),没有「降到哪个模型」这一位',
187
+ },
188
+ maxThinkingTokens: {
189
+ via: 'fixed',
190
+ cause: 'upstream_absent',
191
+ why: '思考 token 数上限:sdk `TaskRequest` 上思考强度只有档位词(`reasoningEffort` 七词闭集),没有 token 数这一位;把数字折成档位词 = 替用户编一个他没说过的档位',
192
+ },
193
+ memoryCapture: row('memoryCapture'),
194
+ memory: row('memory'),
195
+ rewind: row('resumeAt/resumeAtMode/rewindFiles/rewindFilesTo'),
196
+ repo: row('repo'),
197
+ council: row('council'),
198
+ debate: row('debate'),
199
+ rounds: row('rounds'),
200
+ lenses: row('lenses'),
201
+ settings: { via: 'settings' },
202
+ };
203
+ const SETTINGS_KEY_ROUTES = {
204
+ resolved: row('settings.<resolved>'),
205
+ webSearch: row('settings.webSearch'),
206
+ ultracode: row('settings.ultracode'),
207
+ hooks: row('settings.hooks'),
208
+ // 没有车道行:它是 live 兜底层登记的位(`appendSystemPrompt|settings.outputStyle`),构造器无条件原样带。
209
+ outputStyle: { via: 'always' },
210
+ };
211
+ export const UPSTREAM_ABSENT_REQUEST_INTENTS = Object.freeze(Object.entries(INPUT_KEY_ROUTES).flatMap(([key, route]) => route.via === 'fixed' && route.cause === 'upstream_absent' ? [Object.freeze({ key, why: route.why })] : []));
212
+ const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
213
+ /** live 兜底层的裸键名(表项写法含 `|` 备选位与括号说明)。判据面与装配面同读这一只,不各抄一份解析。 */
214
+ const liveDefaultKeyNames = () => new Set(LIVE_DEFAULT_FIELDS.flatMap(item => item.split('|')).map(name => name.replace(/\(.*\)$/, '').trim()));
215
+ /**
216
+ * **顶层**入参键 → 路由:入参型上的键 / 入参型之外的 live 兜底**裸**键(按 `other_channel` 认)/ 其余 = 不认识。
217
+ * 🔴 点分名在这里**一律不认**:一个字面叫 `settings.ultracode` 的顶层键不是 `settings` 的子键 —— 构造器不读它,
218
+ * 认它 = 判官说「带」而请求体上没有。
219
+ */
220
+ const routeOfTopLevelKey = (key) => {
221
+ if (hasOwn(INPUT_KEY_ROUTES, key))
222
+ return INPUT_KEY_ROUTES[key];
223
+ if (!key.includes('.') && liveDefaultKeyNames().has(key))
224
+ return { via: 'fixed', cause: 'other_channel' };
225
+ return undefined;
226
+ };
227
+ /** 座位读数用的键名 → 路由:`settings.<sub>` 走点分名,其余同顶层。 */
228
+ const routeOfInputKey = (key) => {
229
+ if (key.startsWith('settings.')) {
230
+ const sub = key.slice('settings.'.length);
231
+ return hasOwn(SETTINGS_KEY_ROUTES, sub) ? SETTINGS_KEY_ROUTES[sub] : undefined;
232
+ }
233
+ return routeOfTopLevelKey(key);
234
+ };
235
+ /**
236
+ * 车道表的一行。🔴 查不到 = **本包自己的表漂了**(装配代码点名了一行、表里却没有),不是调用方的错:
237
+ * 修前这里是 `return false` —— 改名一行而漏改装配代码,那个键就此安静地永不上请求体,没有一道门会响。
238
+ */
239
+ const matrixRow = (field) => {
240
+ const spec = REQUEST_FIELD_MATRIX.find(f => f.field === field);
241
+ if (spec === undefined) {
242
+ throw new Error(`buildTaskRequest: 车道表 REQUEST_FIELD_MATRIX 里没有「${field}」这一行 —— 装配代码与表漂开了。这是本包内部缺陷,不是调用方入参的问题。`);
243
+ }
244
+ return spec;
245
+ };
246
+ const standingOf = (route) => route.via === 'matrix' ? { via: 'matrix', spec: matrixRow(route.field) } : { via: 'fixed', cause: route.cause };
247
+ /** 逐词判官 —— 键型就是词表的成员型(与词表的双向耦合在编译期咬死,见词表头注)。 */
248
+ const OMISSION_JUDGES = {
249
+ upstream_absent: s => s.via === 'fixed' && s.cause === 'upstream_absent',
250
+ other_channel: s => s.via === 'fixed' && s.cause === 'other_channel',
251
+ off_lane: (s, lane) => s.via === 'matrix' && !s.spec.lanes.includes(lane),
252
+ not_live: (s, _lane, live) => s.via === 'matrix' && s.spec.live && !live,
253
+ };
254
+ /** 唯一的「带不带」判官:`undefined` = 带。stamp 门与回执同读这一只 ⇒ 「回执说带了而请求体上没有」不可能成立。 */
255
+ const omissionCauseOf = (s, lane, live) => TASK_REQUEST_OMISSION_CAUSES.find(cause => OMISSION_JUDGES[cause](s, lane, live));
256
+ /** 车道闭集(`Record<RequestLane, true>`:型上加一条车道而这里不加 ⇒ 编译红)。 */
257
+ const REQUEST_LANE_SET = { interactive: true, print: true };
258
+ /**
259
+ * 车道实参的运行期形核。🔴 修前一个表外的车道词(JS 调用方 / 拼写错)让**每一行**的 `lanes.includes` 都答假 ⇒
260
+ * 产出只剩 `{objective, sessionId}`,而调用方拿不到任何信号 —— 与表外键同一个病,只是错在控制位上。
261
+ */
262
+ const refuseBadLane = (lane) => {
263
+ if (typeof lane === 'string' && hasOwn(REQUEST_LANE_SET, lane))
264
+ return;
265
+ throw new TypeError(`buildTaskRequest: 车道实参只接受 ${Object.keys(REQUEST_LANE_SET).map(l => `'${l}'`).join(' / ')} —— 收到 ${coarseShape(lane)}。` +
266
+ '表外的车道词会让车道表里每一行都判「不属于本车道」,请求体只剩 objective 与 sessionId 而没有任何信号,所以在装配前拒。错误文本只报形状,不回显值。');
267
+ };
268
+ const present = (v) => v !== undefined && v !== null;
269
+ /** `rewind` 合写项的四个子键(从车道表派生,不手抄;表漂 ⇒ 空集 ⇒ 值级恒缺席,门 A 段当场红)。 */
270
+ const rewindSubKeys = () => {
271
+ const r = INPUT_KEY_ROUTES.rewind;
272
+ return r.via === 'matrix' ? r.field.split('/') : [];
273
+ };
274
+ /**
275
+ * 值级等价缺席(**唯一**判据表;stamp 与回执两口共用,不许各写一份):这些键的某些值在 wire 上与「没带」同义
276
+ * (上游按 `=== true` / `=== false` / `=== 'off'` / 非空读;空数组 = 「带了 0 个」= 没带),本包发小的那个。
277
+ * 🔴 回执口也按它判:一个值级缺席的键既不 stamp、也**不进回执** —— 否则 `oneShot:false` 在无座车道上会生一行
278
+ * 「这一次没发出去」,而它本来就没有东西可发。表外的键值级恒在场(`present` 即在场)。
279
+ */
280
+ const VALUE_LEVEL_IN_PLAY = Object.freeze({
281
+ council: (v) => v === true,
282
+ debate: (v) => v === true,
283
+ oneShot: (v) => v === true,
284
+ interactiveTools: (v) => v === false,
285
+ images: (v) => Array.isArray(v) && v.length > 0,
286
+ attachmentIds: (v) => Array.isArray(v) && v.length > 0,
287
+ excludeTools: (v) => Array.isArray(v) && v.length > 0,
288
+ memoryCapture: (v) => v === 'off',
289
+ memory: (v) => v === 'off',
290
+ rewind: (v) => typeof v === 'object' && v !== null && rewindSubKeys().some((k) => hasOwn(v, k) && present(v[k])),
291
+ });
292
+ /** 在场 ∧ 值级在场。 */
293
+ const inPlay = (key, v) => present(v) && (VALUE_LEVEL_IN_PLAY[key]?.(v) ?? true);
294
+ /**
295
+ * 按名各读一次成一份无原型快照(读法 = 属性读,继承位 / 访问器与修前同一种读法;每键恰一次)。
296
+ * `seed` = 调用方**已经读过**的位:照抄进快照,不读第二次(权限面那三位要先于一切别的读取,见装配口读序注)。
297
+ */
298
+ const snapshotByName = (source, keys, seed = {}) => {
299
+ const out = Object.create(null);
300
+ for (const k of keys)
301
+ out[k] = hasOwn(seed, String(k)) ? seed[k] : source[k];
302
+ return out;
303
+ };
304
+ /** 拒绝文本里单个键名的显示上限与清单上限(键名可能来自不可信入参 ⇒ 转义 + 封长;只进文案,不进判据)。 */
305
+ const UNKNOWN_KEY_DISPLAY_MAX = 64;
306
+ const MAX_UNKNOWN_KEYS_SHOWN = 16;
307
+ const keyForDisplay = (k) => k.length > UNKNOWN_KEY_DISPLAY_MAX ? `${JSON.stringify(k.slice(0, UNKNOWN_KEY_DISPLAY_MAX))}(已截短)` : JSON.stringify(k);
308
+ /**
309
+ * 座位读数(静态口:不装配请求就能问「这一键在这条车道上有没有座」)。`key` 是**入参键名**
310
+ * (`settings` 的子键写点分名 `settings.ultracode`);与装配口同读一张路由表、同一只判官。
311
+ */
312
+ export function taskRequestSeatOf(key, lane) {
313
+ refuseBadLane(lane);
314
+ const route = typeof key === 'string' ? routeOfInputKey(key) : undefined;
315
+ if (route === undefined)
316
+ return SEAT_UNKNOWN;
317
+ if (route.via === 'always' || route.via === 'control' || route.via === 'settings')
318
+ return SEATED_UNGATED;
319
+ const standing = standingOf(route);
320
+ // live 取真 = 问的是「车道上有没有座」;`not_live` 的判官要 live 为假才中,故此处永不返回它(型上同样排除)。
321
+ const cause = omissionCauseOf(standing, lane, true);
322
+ if (cause !== undefined && cause !== 'not_live')
323
+ return Object.freeze({ seat: 'none', cause });
324
+ return standing.via === 'matrix' && standing.spec.live ? SEATED_LIVE_GATED : SEATED_UNGATED;
325
+ }
326
+ const SEAT_UNKNOWN = Object.freeze({ seat: 'unknown' });
327
+ const SEATED_UNGATED = Object.freeze({ seat: 'seated', liveGated: false });
328
+ const SEATED_LIVE_GATED = Object.freeze({ seat: 'seated', liveGated: true });
99
329
  /**
100
330
  * 本车道**不拥有**的具名 settings 子键(= 表里有 `settings.<sub>` 行、而该行的 lanes 不含本车道)。
101
331
  *
@@ -528,101 +758,248 @@ const refuseBadMemoryCapture = (v) => {
528
758
  '而声明人不会知道自己的 opt-out 丢了 —— 把一条隐私请求按打字错误静默丢掉是**禁的方向**。' +
529
759
  '错误文本只报形状,不回显值。');
530
760
  };
531
- export function buildTaskRequest(input, lane) {
761
+ /**
762
+ * `maxCostUsd` 的**响亮拒**(0.77.0)。
763
+ *
764
+ * 🔴 这一位是**收紧方向**的声明(「这一次最多花这么多」),而上游对它的坏值**不是** 4xx:非数 / 非正一律按
765
+ * 「调用方没给」处理、回落到部署上限 —— 部署没设上限时 = 这一次运行**没有花费闸**。一个把 `--max-budget-usd 5`
766
+ * 原样当字符串 `"5"` 递进来的端,请求照发、运行照跑、上限无声消失,没有任何人会知道。
767
+ * ⇒ 本层是这条声明最后一处能响的地方(与 `memoryCapture` 坏拼写同一条纪律:收紧声明不许被打字错误安静丢掉)。
768
+ * ⚠️ 判据只有「**有限正数**」这一条,是上游自己的接受域(`typeof === 'number' && > 0`)再去掉序列化不了的
769
+ * `Infinity`(JSON 里是 `null`,到上游同样被当成没给)—— 不猜上限、不预夹:向下夹到部署上限是上游的事。
770
+ * ⚠️ `undefined` / `null` = 合法缺席。与 `memoryCapture` 同律**不分车道、不受 live 门**:拒绝不是「盖一个键」。
771
+ */
772
+ const refuseBadMaxCostUsd = (v) => {
773
+ if (v === undefined || v === null)
774
+ return;
775
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0)
776
+ return;
777
+ throw new TypeError(`buildTaskRequest: maxCostUsd 只接受有限正数或缺席(undefined/null)—— 收到 ${coarseShape(v)}。` +
778
+ '这一位是花费上限声明(收紧方向),而上游对坏值(非数 / 非正)不是 4xx,是按「没给」处理并回落部署上限:' +
779
+ '部署没设上限时这一次运行就没有花费闸,而声明人不会知道。数字串请先在端侧解析成数。错误文本只报形状,不回显值。');
780
+ };
781
+ /**
782
+ * **装配口**(0.77.0):请求体 + 逐键回执,同一次装配、同一只判官。
783
+ *
784
+ * 与 {@link buildTaskRequest} 的关系:后者**就是**本函数的 `.request`(一行转调)—— 两个口不可能装出两份
785
+ * 不同的请求体。要知道「我给的键这一次到底发没发」的端用本口;只要请求体的端两个口随意。
786
+ *
787
+ * 🔴 两个口的**拒绝面完全相同**(拒绝发生在装配里,不在回执里):
788
+ * · 表外的车道词 ⇒ `TypeError`;
789
+ * · 入参(或 `settings`)上**在场**的、本包不认识的键 ⇒ `TypeError`,点名键名(值为 `undefined` / `null` 的键
790
+ * = 缺席,不算);
791
+ * · `settings` 在场却不是对象 ⇒ `TypeError`;
792
+ * · `memoryCapture` / `memory` 坏拼写、`maxCostUsd` 坏值、畸形权限快照 ⇒ `TypeError`(各见其注)。
793
+ * 🔴 入参的每个已知键**恰读一次**,装配与回执共用那一份快照:带 getter / proxy 的入参在两次读取之间可以变值,
794
+ * 那样回执说的与请求体带的就可以不是同一件事(`memoryCapture` 那条「校验与 stamp 共用同一次读取」的推广)。
795
+ */
796
+ export function assembleTaskRequest(input, lane) {
797
+ refuseBadLane(lane);
798
+ // ── 读序纪律:声明位先读、权限面先物化,然后才碰入参上任何别的键 ────────────────────────────────────
799
+ // 🔴 调用方可执行属性(getter / Proxy)之间可以互相改写,而快照存的是**引用**不是内容:
800
+ // · 两个记忆声明是**原始值**,读走就改不了 —— 所以它们排在一切之前(修前 0.76.x 就是这个次序);
801
+ // · `resolved` 是对象,物化之前跑过的任何一个 getter(`settings.webSearch` / 顶层任意键 / 不在场的 stray 键)都能把
802
+ // `resolved.permissions.deny` 就地清空,请求体照发 `deny: []`、回执 `[]` —— 所以它紧随其后物化;
803
+ // · `maxCostUsd`(本版新键)排在物化之后、其余键之前:它与权限面不能同时排第一,权限变宽比花费闸丢失更坏,
804
+ // 于是只有 settings 容器 / resolved 上的 getter 还能在读到它之前动它 —— 这是**明写的取舍**,不是遗漏;
805
+ // · 其余已知键一次性快照(每键恰一次),unknown 键检测与三条值拒绝都在这些读取之后。
806
+ // 修前的相对次序(live → 两记忆键 → settings → 物化 → 其余)一条不变,权限面只比修前更早。
807
+ // (异源复审 R1 [high] / R2 [high] 各实撞一条:把「其余键」的读取排到物化 / 记忆声明之前,就是本包自己引入的
808
+ // 权限放宽 / 隐私声明丢失路径 —— 两条门 F12 / F17 钉着。)
532
809
  const live = input.live;
533
- // ⑤ 响亮拒排在最前面:一条打错字的隐私声明绝不许被后面任何一条「不 stamp」路径吞掉。
534
- // 🔴 只读一次(异源复审第二轮 [medium] 真修):校验与 stamp 共用**同一次读取**。带 getter / proxy 的入参在两次读取之间可以
535
- // 变值 —— 第一次 'off' 过了校验、第二次 'OFF' 在 stamp 处被折成缺席,一条收紧声明就这样静默丢了。
536
810
  const memoryCaptureIn = input.memoryCapture;
537
811
  const memoryIn = input.memory;
812
+ const settingsIn = input.settings;
813
+ if (present(settingsIn) && (typeof settingsIn !== 'object' || Array.isArray(settingsIn))) {
814
+ throw new TypeError(`buildTaskRequest: settings 只接受对象或缺席(undefined/null)—— 收到 ${coarseShape(settingsIn)}。` +
815
+ '这一位装着已解析权限面与 hooks:把畸形容器当成「没给」= 请求带着被剥掉的权限面照发。错误文本只报形状,不回显值。');
816
+ }
817
+ const settingsObj = present(settingsIn) ? settingsIn : {};
818
+ const resolvedIn = settingsObj.resolved;
819
+ // resolved 两车道都进表(#292 P1)——它是**开放集 spread**(子键即 wire 键):值缺席时照旧过一遍
820
+ // `resolvedSnapshotForWire`(它对 undefined/null 返回空对象),所以这里问的是「车道 + live」而不是「值在场」。
821
+ const resolvedRoute = SETTINGS_KEY_ROUTES.resolved;
822
+ const resolvedOpen = resolvedRoute.via === 'matrix' && omissionCauseOf(standingOf(resolvedRoute), lane, live) === undefined;
823
+ const resolvedWire = resolvedOpen ? resolvedSnapshotForWire(resolvedIn) : {};
824
+ const maxCostUsdIn = input.maxCostUsd;
825
+ // ── 其余已知键一次性快照(已读过的位用种子,不读第二次;按名读 = 与修前同一种读法)────────────────
826
+ const INPUT_KEYS = Object.keys(INPUT_KEY_ROUTES);
827
+ const i = snapshotByName(input, INPUT_KEYS, {
828
+ live,
829
+ memoryCapture: memoryCaptureIn,
830
+ memory: memoryIn,
831
+ maxCostUsd: maxCostUsdIn,
832
+ settings: settingsIn,
833
+ });
834
+ const SETTINGS_KEYS = Object.keys(SETTINGS_KEY_ROUTES);
835
+ const s = snapshotByName(settingsObj, SETTINGS_KEYS, { resolved: resolvedIn });
836
+ // ⑤ 响亮拒:一条打错字的隐私声明绝不许被后面任何一条「不 stamp」路径吞掉。校验与 stamp 用的是上面同一次读取。
538
837
  refuseBadMemoryCapture(memoryCaptureIn);
539
838
  refuseBadMemoryOff(memoryIn);
540
- /** stamp 门:字段在本车道的表里 ∧(非 live-gated ∨ 本次是 live 车道)∧ 值非空。 */
541
- const on = (field, value) => {
542
- if (value === undefined || value === null)
543
- return false;
544
- const spec = REQUEST_FIELD_MATRIX.find(f => f.field === field);
545
- if (spec === undefined)
546
- return false;
547
- if (!spec.lanes.includes(lane))
839
+ refuseBadMaxCostUsd(maxCostUsdIn);
840
+ // ── rewind 合写项:四个具名键按名投影,不整只展开 ──────────────────────────────────────────────────
841
+ // 🔴 修前 `...input.rewind` 整只展开:一个 `rewind: { maxCostUsd: 2 }` 能把回执说「没带」的键塞上请求体、
842
+ // 还覆盖同名顶层键、绕过该键的坏值门;`rewind: 'abc'` 会把下标键摊上 wire。四个键名取自车道表那一行的
843
+ // 合写字段名(`resumeAt/resumeAtMode/rewindFiles/rewindFilesTo`),不另抄一份。
844
+ const rewindRoute = INPUT_KEY_ROUTES.rewind;
845
+ const REWIND_KEYS = rewindRoute.via === 'matrix' ? rewindRoute.field.split('/') : [];
846
+ const rewindIn = i.rewind;
847
+ if (present(rewindIn) && (typeof rewindIn !== 'object' || Array.isArray(rewindIn))) {
848
+ throw new TypeError(`buildTaskRequest: rewind 只接受对象或缺席(undefined/null)—— 收到 ${coarseShape(rewindIn)}。` +
849
+ '它是四个具名请求键(resumeAt / resumeAtMode / rewindFiles / rewindFilesTo)的合写位,不是任意展开口。错误文本只报形状,不回显值。');
850
+ }
851
+ const rewindObj = present(rewindIn) ? rewindIn : {};
852
+ const rewindWire = {};
853
+ for (const k of REWIND_KEYS) {
854
+ const v = rewindObj[k];
855
+ if (present(v))
856
+ rewindWire[k] = v;
857
+ }
858
+ // ── (乙) 不认识的键:响亮拒(理由见本节头注;只看**在场**的自有可枚举键)──────────────────────────
859
+ const omitted = [];
860
+ const omit = (key, cause) => {
861
+ omitted.push(Object.freeze({ key, cause }));
862
+ };
863
+ const unknownKeys = [];
864
+ const lateOmitted = [];
865
+ for (const key of Object.keys(input)) {
866
+ if (hasOwn(INPUT_KEY_ROUTES, key))
867
+ continue;
868
+ const stray = Reflect.get(input, key);
869
+ if (!present(stray))
870
+ continue;
871
+ const route = routeOfTopLevelKey(key);
872
+ if (route === undefined) {
873
+ unknownKeys.push(key);
874
+ continue;
875
+ }
876
+ // 入参型之外、但本包认识的键今天只有一类:live 兜底层的位(`cwd` / `appendSystemPrompt` / …)。成因照样问判官,不在这里另写一个词。
877
+ if (route.via !== 'matrix' && route.via !== 'fixed')
878
+ continue;
879
+ const cause = omissionCauseOf(standingOf(route), lane, live);
880
+ if (cause !== undefined)
881
+ lateOmitted.push(Object.freeze({ key, cause }));
882
+ }
883
+ for (const sub of Object.keys(settingsObj)) {
884
+ if (hasOwn(SETTINGS_KEY_ROUTES, sub))
885
+ continue;
886
+ const stray = Reflect.get(settingsObj, sub);
887
+ if (!present(stray))
888
+ continue;
889
+ unknownKeys.push(`settings.${sub}`);
890
+ }
891
+ for (const sub of Object.keys(rewindObj)) {
892
+ if (REWIND_KEYS.includes(sub))
893
+ continue;
894
+ const stray = rewindObj[sub];
895
+ if (!present(stray))
896
+ continue;
897
+ unknownKeys.push(`rewind.${sub}`);
898
+ }
899
+ if (unknownKeys.length > 0) {
900
+ const shown = unknownKeys.slice(0, MAX_UNKNOWN_KEYS_SHOWN).map(keyForDisplay).join(', ');
901
+ const more = unknownKeys.length > MAX_UNKNOWN_KEYS_SHOWN ? ` 等共 ${unknownKeys.length} 个` : '';
902
+ throw new TypeError(`buildTaskRequest: 入参带了本包不认识的键 ${shown}${more}。` +
903
+ '不认识 = 分不开「打错的键名」「真请求位而车道表还没登记」「多余的键」三种,而前两种里可能是收紧方向的声明' +
904
+ '(排除工具 / 关记忆 / 花费上限)—— 安静丢掉它,这一次运行就比声明人以为的更宽。所以在装配前拒。' +
905
+ '处置:键名打错 ⇒ 改键名;真请求位 ⇒ 先进车道表 REQUEST_FIELD_MATRIX(带理由);' +
906
+ '想问某个键有没有座 ⇒ taskRequestSeatOf(key, lane)。`settings` 快照里的开放集子键放进 settings.resolved。' +
907
+ '错误文本只报键名,不回显值。');
908
+ }
909
+ /** stamp 门:本键在场 ∧ 判官说带。🔴 先问判官再看值 —— 表漂(`matrixRow` 抛)因此每次装配都响,不挑输入。 */
910
+ const carries = (route, value, key) => {
911
+ if (route.via === 'always')
912
+ return present(value);
913
+ if (route.via === 'control' || route.via === 'settings')
548
914
  return false;
549
- if (spec.live && !live)
915
+ const cause = omissionCauseOf(standingOf(route), lane, live);
916
+ if (!inPlay(key, value))
550
917
  return false;
551
- return true;
918
+ if (cause === undefined)
919
+ return true;
920
+ omit(key, cause);
921
+ return false;
552
922
  };
923
+ // 每个键的门**只问一次**(问一次记一次回执),装配处只读结果 —— 回执行数 = 被拒的在场键数,不多不少。
924
+ const gate = Object.create(null);
925
+ for (const key of INPUT_KEYS) {
926
+ const route = INPUT_KEY_ROUTES[key];
927
+ if (route.via === 'settings') {
928
+ for (const sub of SETTINGS_KEYS)
929
+ gate[`settings.${sub}`] = carries(SETTINGS_KEY_ROUTES[sub], s[sub], `settings.${sub}`);
930
+ continue;
931
+ }
932
+ gate[key] = carries(route, i[key], key);
933
+ }
934
+ const on = (key) => gate[key] === true;
935
+ const onSetting = (sub) => gate[`settings.${sub}`] === true;
553
936
  // settings 四源合并(cli 原式:任一存在则 stamp,全缺则整个 `settings` 键都不出现)。
554
937
  // ultracode 只在交互车道进表 ⇒ print 传了也不 stamp(表说了算,不是调用方说了算)。
555
- // resolved 两车道都进表(#292 P1)——它是**开放集 spread**(子键即 wire 键),所以走
556
- // `laneHas + live` 而不是 `on()`:`on()` 判的是「这个键值非空」,而这里要判的是「这份快照要不要
557
- // 摊开」。
558
938
  // 🔴 具名通道**赢过快照**这件事不靠下面的合并序(0.35.0):合并序只在具名通道**有值**时管用,
559
939
  // 而具名门否决时的表现恰恰是**没值**(端不给这个键)。让位改由 `resolvedSnapshotForWire`
560
940
  // 做**结构剥离** —— 具名通道管着的子键快照一概不产,顺序此后只是可读性,不再是安全依据。
561
- const s = input.settings ?? {};
941
+ // (那一份快照在本函数开头就物化好了 —— `resolvedWire`,读序理由见那里。)
562
942
  const settingsOut = {
563
- ...(laneHas('settings.<resolved>', lane) && live ? resolvedSnapshotForWire(s.resolved) : {}),
564
- ...(on('settings.webSearch', s.webSearch) ? { webSearch: s.webSearch } : {}),
565
- ...(on('settings.ultracode', s.ultracode) ? { ultracode: s.ultracode } : {}),
566
- ...(on('settings.hooks', s.hooks) ? { hooks: s.hooks } : {}),
943
+ ...resolvedWire,
944
+ ...(onSetting('webSearch') ? { webSearch: s.webSearch } : {}),
945
+ ...(onSetting('ultracode') ? { ultracode: s.ultracode } : {}),
946
+ ...(onSetting('hooks') ? { hooks: s.hooks } : {}),
567
947
  ...(s.outputStyle !== undefined ? { outputStyle: s.outputStyle } : {}),
568
948
  };
569
949
  const out = {
570
- objective: input.objective,
571
- sessionId: input.sessionId,
572
- ...(on('scenario', input.scenario) ? { scenario: input.scenario } : {}),
573
- ...(on('systemPrompt', input.systemPrompt) ? { systemPrompt: input.systemPrompt } : {}),
574
- ...(on('reasoningEffort', input.reasoningEffort) ? { reasoningEffort: input.reasoningEffort } : {}),
575
- ...(on('model', input.model) ? { model: input.model } : {}),
576
- ...(on('images', input.images) && (input.images?.length ?? 0) > 0 ? { images: input.images } : {}),
577
- ...(on('skills', input.skills) ? { skills: input.skills } : {}),
578
- ...(on('mcpServers', input.mcpServers) ? { mcpServers: input.mcpServers } : {}),
579
- ...(on('resumeAt/resumeAtMode/rewindFiles/rewindFilesTo', input.rewind) ? input.rewind : {}),
580
- ...(on('permissionMode', input.permissionMode) ? { permissionMode: input.permissionMode } : {}),
581
- ...(on('sandboxImageProfile', input.sandboxImageProfile)
582
- ? { sandboxImageProfile: input.sandboxImageProfile }
583
- : {}),
584
- ...(on('selfOrchestration', input.selfOrchestration) ? { selfOrchestration: input.selfOrchestration } : {}),
585
- ...(on('deferTools', input.deferTools) ? { deferTools: input.deferTools } : {}),
586
- ...(on('retainBackgroundProcesses', input.retainBackgroundProcesses)
587
- ? { retainBackgroundProcesses: input.retainBackgroundProcesses }
588
- : {}),
589
- ...(on('promptProfile', input.promptProfile) ? { promptProfile: input.promptProfile } : {}),
590
- ...(on('enableFork', input.enableFork) ? { enableFork: input.enableFork } : {}),
591
- ...(on('attachments', input.attachments) ? { attachments: input.attachments } : {}),
592
- // repo 场景五键([C135]):值驱动 stamp;council/debate 非 true 不 stamp(与 server `=== true`
593
- // 读法合拍——false 与缺席在 wire 上同义,发小的那个)。
594
- ...(on('repo', input.repo) ? { repo: input.repo } : {}),
595
- ...(on('council', input.council) && input.council === true ? { council: true } : {}),
596
- ...(on('debate', input.debate) && input.debate === true ? { debate: true } : {}),
597
- ...(on('rounds', input.rounds) ? { rounds: input.rounds } : {}),
598
- ...(on('lenses', input.lenses) ? { lenses: input.lenses } : {}),
599
- // 空数组按 `images` 同款处理:没带附件与「带了 0 个附件」在 wire 上是同一件事,发小的那个。
600
- ...(on('attachmentIds', input.attachmentIds) && (input.attachmentIds?.length ?? 0) > 0
601
- ? { attachmentIds: [...(input.attachmentIds ?? [])] }
602
- : {}),
603
- // 同款空数组语义:「不排除任何工具」不 stamp(缺席与空排除在 wire 上等价,发小的那个)。
604
- ...(on('excludeTools', input.excludeTools) && (input.excludeTools?.length ?? 0) > 0
605
- ? { excludeTools: [...(input.excludeTools ?? [])] }
606
- : {}),
607
- ...(on('clientContext', input.clientContext) ? { clientContext: input.clientContext } : {}),
608
- ...(on('scratchpadDir', input.scratchpadDir) ? { scratchpadDir: input.scratchpadDir } : {}),
609
- ...(on('finalVerification', input.finalVerification)
610
- ? { finalVerification: input.finalVerification }
611
- : {}),
612
- ...(on('limits', input.limits) ? { limits: input.limits } : {}),
613
- ...(on('interactiveTools', input.interactiveTools) && input.interactiveTools === false
614
- ? { interactiveTools: false }
615
- : {}),
616
- ...(on('oneShot', input.oneShot) && input.oneShot === true ? { oneShot: true } : {}),
950
+ objective: i.objective,
951
+ sessionId: i.sessionId,
952
+ ...(on('scenario') ? { scenario: i.scenario } : {}),
953
+ ...(on('systemPrompt') ? { systemPrompt: i.systemPrompt } : {}),
954
+ ...(on('reasoningEffort') ? { reasoningEffort: i.reasoningEffort } : {}),
955
+ ...(on('model') ? { model: i.model } : {}),
956
+ ...(on('images') ? { images: i.images } : {}),
957
+ ...(on('skills') ? { skills: i.skills } : {}),
958
+ ...(on('mcpServers') ? { mcpServers: i.mcpServers } : {}),
959
+ ...(on('rewind') ? rewindWire : {}),
960
+ ...(on('permissionMode') ? { permissionMode: i.permissionMode } : {}),
961
+ ...(on('sandboxImageProfile') ? { sandboxImageProfile: i.sandboxImageProfile } : {}),
962
+ ...(on('selfOrchestration') ? { selfOrchestration: i.selfOrchestration } : {}),
963
+ ...(on('deferTools') ? { deferTools: i.deferTools } : {}),
964
+ ...(on('retainBackgroundProcesses') ? { retainBackgroundProcesses: i.retainBackgroundProcesses } : {}),
965
+ ...(on('promptProfile') ? { promptProfile: i.promptProfile } : {}),
966
+ ...(on('enableFork') ? { enableFork: i.enableFork } : {}),
967
+ ...(on('attachments') ? { attachments: i.attachments } : {}),
968
+ // repo 场景五键([C135]):值级判据(council/debate 非 true 不 stamp,与 server `=== true` 读法合拍——
969
+ // false 与缺席在 wire 上同义,发小的那个)已折进 `VALUE_LEVEL_IN_PLAY`,门 `on()` 一并判过。
970
+ ...(on('repo') ? { repo: i.repo } : {}),
971
+ ...(on('council') ? { council: true } : {}),
972
+ ...(on('debate') ? { debate: true } : {}),
973
+ ...(on('rounds') ? { rounds: i.rounds } : {}),
974
+ ...(on('lenses') ? { lenses: i.lenses } : {}),
975
+ // 空数组语义(没带附件与「带了 0 个附件」同义,发小的那个)在 `VALUE_LEVEL_IN_PLAY` 里,门一并判过。
976
+ ...(on('attachmentIds') ? { attachmentIds: [...(i.attachmentIds ?? [])] } : {}),
977
+ // 「不排除任何工具」同款空数组语义,同上。
978
+ ...(on('excludeTools') ? { excludeTools: [...(i.excludeTools ?? [])] } : {}),
979
+ ...(on('clientContext') ? { clientContext: i.clientContext } : {}),
980
+ ...(on('scratchpadDir') ? { scratchpadDir: i.scratchpadDir } : {}),
981
+ ...(on('finalVerification') ? { finalVerification: i.finalVerification } : {}),
982
+ ...(on('limits') ? { limits: i.limits } : {}),
983
+ ...(on('interactiveTools') ? { interactiveTools: false } : {}),
984
+ ...(on('oneShot') ? { oneShot: true } : {}),
985
+ // 0.77.0 print 补座两件(原样透传;`maxCostUsd` 的坏值已在上面响亮拒)。
986
+ ...(on('outputSchema') ? { outputSchema: i.outputSchema } : {}),
987
+ ...(on('maxCostUsd') ? { maxCostUsd: maxCostUsdIn } : {}),
617
988
  // ⑤ 单成员闭集:值**恰是** `'off'` 才 stamp。表外拼写在上面已经**响亮拒**(见 `refuseBadMemoryCapture`)。
618
- ...(on('memoryCapture', memoryCaptureIn) && memoryCaptureIn === 'off'
619
- ? { memoryCapture: 'off' }
620
- : {}),
989
+ ...(on('memoryCapture') ? { memoryCapture: 'off' } : {}),
621
990
  // CC-39③:姊妹键同律(值恰是 'off' 才 stamp;坏拼写已在上面响亮拒)。
622
- ...(on('memory', memoryIn) && memoryIn === 'off' ? { memory: 'off' } : {}),
991
+ ...(on('memory') ? { memory: 'off' } : {}),
623
992
  ...(Object.keys(settingsOut).length > 0 ? { settings: settingsOut } : {}),
624
993
  };
625
- return out;
994
+ return Object.freeze({ request: out, omitted: Object.freeze([...omitted, ...lateOmitted]) });
995
+ }
996
+ /**
997
+ * 只要请求体的口 —— **就是** {@link assembleTaskRequest} 的 `.request`(拒绝面完全相同,见那里的头注)。
998
+ * ⚠️ 本口没有回执通道:认识而没带的键(车道不符 / live 门没开 / 上游无对位)在这里照旧没有信号。要逐键答案
999
+ * 用装配口;要在不装配的情况下问一个键有没有座用 {@link taskRequestSeatOf}。
1000
+ */
1001
+ export function buildTaskRequest(input, lane) {
1002
+ return assembleTaskRequest(input, lane).request;
626
1003
  }
627
1004
  /**
628
1005
  * live 兜底层(cli `liveClient.toLiveRequest` 的**包内半场**)—— 两条车道共用。
@@ -712,8 +1089,8 @@ export function applyLiveRequestDefaults(req, host) {
712
1089
  * 而不是绕过本函数:改表这个动作本身就会在 diff 里显形,这正是本判据存在的意义。
713
1090
  */
714
1091
  export function unregisteredRequestKeys(req, lane) {
715
- /** live 兜底层的键名(表项写法含 `|` 备选位与括号说明,取裸键名)。 */
716
- const liveDefaults = new Set(LIVE_DEFAULT_FIELDS.flatMap(row => row.split('|')).map(name => name.replace(/\(.*\)$/, '').trim()));
1092
+ /** live 兜底层的键名(表项写法含 `|` 备选位与括号说明,取裸键名;与装配口同读一只解析)。 */
1093
+ const liveDefaults = liveDefaultKeyNames();
717
1094
  const laneRows = REQUEST_FIELD_MATRIX.filter(f => f.lanes.includes(lane));
718
1095
  /** 顶层键 → 表项(`resumeAt/resumeAtMode/rewindFiles/rewindFilesTo` 这类斜杠合写项逐键展开)。 */
719
1096
  const laneTopKeys = new Set();