@sema-agent/client-core 0.72.12 → 0.72.13

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.
@@ -37,6 +37,7 @@
37
37
  * app-state seam 不在本模块可达面,记 rc.47 接 REPL 层;engine 侧 handsReadOnly 已由 resume 处理。
38
38
  */
39
39
  import { type QuestionAnswer } from '../liveQuestionStore.js';
40
+ import { type PlanReviewModeAfter } from './hitlBridge.js';
40
41
  import type { ReopenCardVerdict } from '../adapter/activeRunSelfHeal.js';
41
42
  /**
42
43
  * REF-CC-026:此前是 module-private 常量,唯一另一个消费者(cli `planReviewReopen.ts`)只能靠
@@ -46,6 +47,17 @@ import type { ReopenCardVerdict } from '../adapter/activeRunSelfHeal.js';
46
47
  */
47
48
  export declare const PLAN_REVIEW_APPROVE_LABEL = "Yes, approve and run the plan";
48
49
  export declare const PLAN_REVIEW_REJECT_LABEL = "No, reject it (keep planning)";
50
+ /**
51
+ * 0.72.13 CC-46(engine ≥7.86.0 S-433;契约 §4c):三选卡的两个 Yes —— 对齐 CC `ExitPlanMode` 卡
52
+ * 「Yes, and auto-accept edits」/「Yes, and manually approve edits」。只在**双闸**都过时才出三选卡(见 {@link planReviewCardOptions}
53
+ * 的调用点):宿主声明这条任务是它自己用 `permissionMode:"plan"` 提交的(适用面读不到 wire)∧ 引擎版本证据 ≥7.86.0。
54
+ */
55
+ export declare const PLAN_REVIEW_APPROVE_AUTO_LABEL = "Yes, and auto-accept edits";
56
+ export declare const PLAN_REVIEW_APPROVE_MANUAL_LABEL = "Yes, and manually approve edits";
57
+ /** `permissionModeAfter` 的引擎地板(server 7.86.0;更老的 server 静默丢这个键 = 承诺了却不兑现)。 */
58
+ export declare const PLAN_REVIEW_MODE_AFTER_MIN_ENGINE: readonly [number, number, number];
59
+ /** 引擎版本串够不够新:三态 —— 读不出(缺席 / 非 x.y.z)⇒ `unknown`,**不折 supported**(没证据不许出三选卡)。 */
60
+ export declare function versionSupportsPlanReviewModeAfter(v: string | undefined): 'supported' | 'unsupported' | 'unknown';
49
61
  /**
50
62
  * REF-CC-026:三值判决(此前 `selected === APPROVE_LABEL ? 'approve' : 'reject'` 把「没读懂/没
51
63
  * 选」的第三种输入静默塌缩进危险的 reject 臂——overlay 的 dismiss/拒答语义是「送空 answers」,
@@ -53,7 +65,28 @@ export declare const PLAN_REVIEW_REJECT_LABEL = "No, reject it (keep planning)";
53
65
  * (含 undefined)落 `'dismissed'`,由调用方决定是保持 park 还是显式当 reject 处理——本函数只
54
66
  * 负责如实分类,不替调用方做选择。
55
67
  */
56
- export declare function planReviewDecisionFromAnswer(answer: QuestionAnswer | null | undefined): 'approve' | 'reject' | 'dismissed';
68
+ export declare function planReviewDecisionFromAnswer(answer: QuestionAnswer | null | undefined,
69
+ /** 本次实际展示的那张卡的标签;不给 = 老两选(见 {@link planReviewChoiceFromAnswer})。 */
70
+ shownLabels?: readonly string[]): 'approve' | 'reject' | 'dismissed';
71
+ /** 0.72.13 CC-46:带批准后模式的判读(三选卡的两个 Yes 各带一档;老两选的 Yes 不带键)。标签显式等值比对,第三种输入 ⇒ dismissed。 */
72
+ export type PlanReviewChoice = {
73
+ decision: 'approve';
74
+ permissionModeAfter?: PlanReviewModeAfter;
75
+ } | {
76
+ decision: 'reject';
77
+ };
78
+ export declare function planReviewChoiceFromAnswer(answer: QuestionAnswer | null | undefined,
79
+ /**
80
+ * 本次**实际展示**的那张卡的标签(`planReviewCardOptions(offer).map(o => o.label)`);只认其中的标签。
81
+ * 🔴 **不给 = 老两选**:三选卡的两个 Yes 只有调用方显式说「我展示的是三选卡」才读得成批准 —— 缺省不许替老调用方
82
+ * 悄悄扩大批准的输入域(异源对抗复审:重开腿恒两选,缺省放行时一个没展示过的 auto 标签会被投递成 approve)。
83
+ */
84
+ shownLabels?: readonly string[]): PlanReviewChoice | 'dismissed';
85
+ /** 卡选项的**单源**:`offerModeAfter=false` ⇒ 老两选(逐字同旧);`true` ⇒ 三选(两个 Yes + No)。 */
86
+ export declare function planReviewCardOptions(offerModeAfter: boolean): Array<{
87
+ label: string;
88
+ description: string;
89
+ }>;
57
90
  /**
58
91
  * 推代的**门控口**(A-024.4;#244 F1 上收,语义照壳现实现):只有**决断性**作答(approve/
59
92
  * reject 标签命中 —— {@link planReviewDecisionFromAnswer} 的官方三值分类)才消费当代 plan 门。
@@ -85,13 +118,27 @@ export declare function _resetArmedPlanReviewsForTest(): void;
85
118
  * REF-CC-027 above); false = caller renders the terminal as usual. Fail-soft: any error inside must
86
119
  * never break the stream drain.
87
120
  */
88
- export declare function armPlanReviewApproval(result: unknown, sessionKey?: string): boolean;
121
+ export declare function armPlanReviewApproval(result: unknown, sessionKey?: string,
122
+ /**
123
+ * 0.72.13 CC-46:`submittedInPlanMode: true` = 宿主声明「这条任务是我用 `permissionMode:"plan"` 提交的」(只读起步)。
124
+ * 🔴 这件事**读不到 wire 上**(契约 §4c:两种任务产出同一张卡),所以只能由提交方声明;不声明 / false ⇒ 老两选卡、决断体不带键。
125
+ * 声明了还要过第二道闸:本进程 caps 缓存里的引擎版本 ≥7.86.0(更老的 server 静默丢这个键 = 卡上承诺了却不兑现)。
126
+ */
127
+ opts?: {
128
+ submittedInPlanMode?: boolean;
129
+ }): boolean;
89
130
  /** POST the decision; on settle enqueue an isMeta turn so the model reports the resume outcome.
90
131
  * SDK 宪法迁移批:SDK 0.0.52 assistant.planReview verb(同 wire `POST /v1/assistant/tasks/:id/
91
132
  * plan_review` 同 body {decision})——raw fetch 退役。错误模型:非 2xx 抛 APIError(message =
92
133
  * server body.error 原文)→ 同款「HTTP <status> <error>」outcome 文案;网络失败走原「could not
93
134
  * reach the engine」臂。 */
94
- export declare function decidePlanReview(taskId: string, decision: 'approve' | 'reject'): Promise<void>;
135
+ export declare function decidePlanReview(taskId: string, decision: 'approve' | 'reject',
136
+ /**
137
+ * 0.72.13 CC-46(engine ≥7.86.0;契约 §4c):批准之后用哪一档。缺席 ⇒ 体逐字节同旧 `{decision}`。只配 approve、闭集两词 ——
138
+ * 坏形本地拒(零 POST,outcome 如实说没送出去,不回显值)。引擎答 400 `request.field_conflict`(这条任务不是只读起步):
139
+ * `default` 与缺席同义 ⇒ 去键重发恰一次;`acceptEdits` **不**重发(不静默降成逐次征询),outcome 说批准没生效。
140
+ */
141
+ permissionModeAfter?: string): Promise<void>;
95
142
  /**
96
143
  * {@link reopenPlanReviewCard} 的宿主参数。
97
144
  */
@@ -41,6 +41,8 @@ import { hostLog } from '../host.js';
41
41
  import { makeEngineWireClient } from '../engineWireSdk.js';
42
42
  import { engineWireTarget } from '../engineWireTarget.js';
43
43
  import { enqueuePlanReviewOutcome } from '../notifications.js';
44
+ import { isPlanReviewModeAfter } from './hitlBridge.js';
45
+ import { engineCapString } from '../engineCapsCache.js';
44
46
  import { planReviewQuestionId, REOPEN_ID_TAIL } from './gateIdentity.js';
45
47
  import { notePlanReviewAnswered, notePlanReviewAnsweredFor, planReviewArmedKeyFor, registerArmedGateFor, waitForGateArmedFor, wasGateArmedFor, } from './armedGateRegistry.js';
46
48
  import { DEFAULT_SESSION_KEY } from '../sessionSlot.js';
@@ -58,8 +60,42 @@ const PLAN_REVIEW_RESUME_TIMEOUT_MS = 6 * 60 * 60_000;
58
60
  */
59
61
  export const PLAN_REVIEW_APPROVE_LABEL = 'Yes, approve and run the plan';
60
62
  export const PLAN_REVIEW_REJECT_LABEL = 'No, reject it (keep planning)';
63
+ /**
64
+ * 0.72.13 CC-46(engine ≥7.86.0 S-433;契约 §4c):三选卡的两个 Yes —— 对齐 CC `ExitPlanMode` 卡
65
+ * 「Yes, and auto-accept edits」/「Yes, and manually approve edits」。只在**双闸**都过时才出三选卡(见 {@link planReviewCardOptions}
66
+ * 的调用点):宿主声明这条任务是它自己用 `permissionMode:"plan"` 提交的(适用面读不到 wire)∧ 引擎版本证据 ≥7.86.0。
67
+ */
68
+ export const PLAN_REVIEW_APPROVE_AUTO_LABEL = 'Yes, and auto-accept edits';
69
+ export const PLAN_REVIEW_APPROVE_MANUAL_LABEL = 'Yes, and manually approve edits';
70
+ /** `permissionModeAfter` 的引擎地板(server 7.86.0;更老的 server 静默丢这个键 = 承诺了却不兑现)。 */
71
+ export const PLAN_REVIEW_MODE_AFTER_MIN_ENGINE = Object.freeze([7, 86, 0]);
72
+ /** 引擎版本串够不够新:三态 —— 读不出(缺席 / 非 x.y.z)⇒ `unknown`,**不折 supported**(没证据不许出三选卡)。 */
73
+ export function versionSupportsPlanReviewModeAfter(v) {
74
+ // 🔴 **整串**匹配正式版 x.y.z(无前导零):预发布(7.86.0-rc.1 可能还没实现这一位)/ 尾随垃圾 / v 前缀 / 空白 一律 unknown ——
75
+ // 它们不是支持证据(异源对抗复审:前缀匹配曾把这些都放行,宿主声明为 true 时三选卡就出了,而老引擎静默丢键)。
76
+ const mm = v ? /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(v) : null;
77
+ if (!mm)
78
+ return 'unknown';
79
+ const got = [Number(mm[1]), Number(mm[2]), Number(mm[3])];
80
+ for (let i = 0; i < 3; i++) {
81
+ const g = got[i];
82
+ const need = PLAN_REVIEW_MODE_AFTER_MIN_ENGINE[i];
83
+ if (g !== need)
84
+ return g > need ? 'supported' : 'unsupported';
85
+ }
86
+ return 'supported';
87
+ }
61
88
  const APPROVE_LABEL = PLAN_REVIEW_APPROVE_LABEL;
62
89
  const REJECT_LABEL = PLAN_REVIEW_REJECT_LABEL;
90
+ /** 老两选卡的标签集 = 判读口不给展示标签时的缺省读域。 */
91
+ const LEGACY_TWO_CHOICE_LABELS = Object.freeze([PLAN_REVIEW_APPROVE_LABEL, PLAN_REVIEW_REJECT_LABEL]);
92
+ /** 四个已知标签(只供**记账**口判「是不是一次决断性作答」,不驱动任何决断)。 */
93
+ const ALL_KNOWN_LABELS = Object.freeze([
94
+ PLAN_REVIEW_APPROVE_AUTO_LABEL,
95
+ PLAN_REVIEW_APPROVE_MANUAL_LABEL,
96
+ PLAN_REVIEW_APPROVE_LABEL,
97
+ PLAN_REVIEW_REJECT_LABEL,
98
+ ]);
63
99
  /**
64
100
  * REF-CC-026:三值判决(此前 `selected === APPROVE_LABEL ? 'approve' : 'reject'` 把「没读懂/没
65
101
  * 选」的第三种输入静默塌缩进危险的 reject 臂——overlay 的 dismiss/拒答语义是「送空 answers」,
@@ -67,14 +103,55 @@ const REJECT_LABEL = PLAN_REVIEW_REJECT_LABEL;
67
103
  * (含 undefined)落 `'dismissed'`,由调用方决定是保持 park 还是显式当 reject 处理——本函数只
68
104
  * 负责如实分类,不替调用方做选择。
69
105
  */
70
- export function planReviewDecisionFromAnswer(answer) {
71
- const selected = answer?.answers?.[0]?.selected?.[0];
106
+ export function planReviewDecisionFromAnswer(answer,
107
+ /** 本次实际展示的那张卡的标签;不给 = 老两选(见 {@link planReviewChoiceFromAnswer})。 */
108
+ shownLabels) {
109
+ const choice = planReviewChoiceFromAnswer(answer, shownLabels);
110
+ if (choice === 'dismissed')
111
+ return 'dismissed';
112
+ return choice.decision;
113
+ }
114
+ export function planReviewChoiceFromAnswer(answer,
115
+ /**
116
+ * 本次**实际展示**的那张卡的标签(`planReviewCardOptions(offer).map(o => o.label)`);只认其中的标签。
117
+ * 🔴 **不给 = 老两选**:三选卡的两个 Yes 只有调用方显式说「我展示的是三选卡」才读得成批准 —— 缺省不许替老调用方
118
+ * 悄悄扩大批准的输入域(异源对抗复审:重开腿恒两选,缺省放行时一个没展示过的 auto 标签会被投递成 approve)。
119
+ */
120
+ shownLabels = LEGACY_TWO_CHOICE_LABELS) {
121
+ // 🔴 这是一张**单选**卡,而其中一项在安全轴上(批准后放宽写征询):恰一题、恰一项才算作答。矛盾多选(`[auto, reject]`)/
122
+ // 多题 / 空 selected 一律 dismissed —— 此前只取 `selected[0]`,拒绝意图会被丢弃、放宽被执行(异源对抗复审 [high])。
123
+ const answers = answer?.answers;
124
+ if (!Array.isArray(answers) || answers.length !== 1)
125
+ return 'dismissed';
126
+ const picked = answers[0]?.selected;
127
+ if (!Array.isArray(picked) || picked.length !== 1)
128
+ return 'dismissed';
129
+ const selected = picked[0];
130
+ // 标签必须属于这次展示的卡:两选卡上不存在「auto-accept edits」,收到它 = 答的不是这张卡。
131
+ if (typeof selected !== 'string' || !shownLabels.includes(selected))
132
+ return 'dismissed';
133
+ if (selected === PLAN_REVIEW_APPROVE_AUTO_LABEL)
134
+ return { decision: 'approve', permissionModeAfter: 'acceptEdits' };
135
+ if (selected === PLAN_REVIEW_APPROVE_MANUAL_LABEL)
136
+ return { decision: 'approve', permissionModeAfter: 'default' };
72
137
  if (selected === APPROVE_LABEL)
73
- return 'approve';
138
+ return { decision: 'approve' };
74
139
  if (selected === REJECT_LABEL)
75
- return 'reject';
140
+ return { decision: 'reject' };
76
141
  return 'dismissed';
77
142
  }
143
+ /** 卡选项的**单源**:`offerModeAfter=false` ⇒ 老两选(逐字同旧);`true` ⇒ 三选(两个 Yes + No)。 */
144
+ export function planReviewCardOptions(offerModeAfter) {
145
+ const reject = { label: REJECT_LABEL, description: 'Discard this plan and stay in plan mode to refine it' };
146
+ if (!offerModeAfter) {
147
+ return [{ label: APPROVE_LABEL, description: 'Resume the task now and execute the plan (runs to completion engine-side)' }, reject];
148
+ }
149
+ return [
150
+ { label: PLAN_REVIEW_APPROVE_AUTO_LABEL, description: 'Resume and execute the plan; edits inside the working directory are not asked about one by one' },
151
+ { label: PLAN_REVIEW_APPROVE_MANUAL_LABEL, description: 'Resume and execute the plan; every edit still asks for approval' },
152
+ reject,
153
+ ];
154
+ }
78
155
  /**
79
156
  * 推代的**门控口**(A-024.4;#244 F1 上收,语义照壳现实现):只有**决断性**作答(approve/
80
157
  * reject 标签命中 —— {@link planReviewDecisionFromAnswer} 的官方三值分类)才消费当代 plan 门。
@@ -90,7 +167,8 @@ export function notePlanReviewAnsweredIfDecisive(questionId, answer) {
90
167
  }
91
168
  /** W1 带 key 变体。 */
92
169
  function notePlanReviewAnsweredIfDecisiveFor(sessionKey, questionId, answer) {
93
- if (planReviewDecisionFromAnswer(answer ?? { answers: [] }) === 'dismissed')
170
+ // 记账口:两种卡的任一已知标签都算决断性作答(这里不投递决断;投递侧各自按**自己展示的卡**校验标签)。
171
+ if (planReviewDecisionFromAnswer(answer ?? { answers: [] }, ALL_KNOWN_LABELS) === 'dismissed')
94
172
  return;
95
173
  notePlanReviewAnsweredFor(sessionKey, questionId);
96
174
  }
@@ -121,9 +199,15 @@ export function isPlanReviewPark(result) {
121
199
  * `registerLocalQuestionResponder` 调用。
122
200
  */
123
201
  const armedPlanReviewTaskIds = new Set();
202
+ /**
203
+ * 0.72.13 CC-46:这张已武装的卡**出的是不是三选卡**(两个 Yes 各带一档批准后模式)。与 {@link armedPlanReviewTaskIds}
204
+ * 同生命周期(arm 写、决断性作答 / arm 失败清);重放重呈读它,保证同一张卡前后两次呈现选项一致。缺席 = 老两选。
205
+ */
206
+ const offerModeAfterByTask = new Map();
124
207
  /** 测试钩:清掉武装态(不动 liveQuestionStore 本身,那边有自己的 reset)。 */
125
208
  export function _resetArmedPlanReviewsForTest() {
126
209
  armedPlanReviewTaskIds.clear();
210
+ offerModeAfterByTask.clear();
127
211
  }
128
212
  /* 合成 questionId 的唯一铸口:A-028.3 起收编进 `gateIdentity.planReviewQuestionId`(公面导出,
129
213
  * 壳侧重开腿从此 import 同一铸口,不再手抄字面)。本文件只 import,不再自铸。 */
@@ -132,15 +216,13 @@ const ARM_QUESTION = 'The plan is ready for review. Approve it and start the imp
132
216
  /** 重开腿的题面正句(点明「这张卡就是占住会话的那件事」;标签/选项与首呈同源)。 */
133
217
  const REOPEN_QUESTION = 'This plan is still waiting for your review — it is what is holding this session. Approve it and start the implementation?';
134
218
  /** 题面唯一构造点 —— 首次 arm、重放重呈、重开三处共用,标签/选项描述不许各写一份。 */
135
- function planReviewQuestions(question) {
219
+ function planReviewQuestions(question, offerModeAfter = false) {
136
220
  return [
137
221
  {
138
222
  header: 'Plan review',
139
223
  question,
140
- options: [
141
- { label: APPROVE_LABEL, description: 'Resume the task now and execute the plan (runs to completion engine-side)' },
142
- { label: REJECT_LABEL, description: 'Discard this plan and stay in plan mode to refine it' },
143
- ],
224
+ // 选项单源 `planReviewCardOptions`:老两选逐字同旧;三选只在 arm 的双闸都过时出(重开腿恒老两选 = 批准不带键 ⇒ default)。
225
+ options: planReviewCardOptions(offerModeAfter),
144
226
  multiSelect: false,
145
227
  },
146
228
  ];
@@ -150,7 +232,7 @@ function publishPlanReviewCard(taskId) {
150
232
  publishQuestionFrame({
151
233
  type: 'question',
152
234
  questionId: planReviewQuestionId(taskId),
153
- questions: planReviewQuestions(ARM_QUESTION),
235
+ questions: planReviewQuestions(ARM_QUESTION, offerModeAfterByTask.get(taskId) === true),
154
236
  });
155
237
  }
156
238
  /**
@@ -159,7 +241,13 @@ function publishPlanReviewCard(taskId) {
159
241
  * REF-CC-027 above); false = caller renders the terminal as usual. Fail-soft: any error inside must
160
242
  * never break the stream drain.
161
243
  */
162
- export function armPlanReviewApproval(result, sessionKey) {
244
+ export function armPlanReviewApproval(result, sessionKey,
245
+ /**
246
+ * 0.72.13 CC-46:`submittedInPlanMode: true` = 宿主声明「这条任务是我用 `permissionMode:"plan"` 提交的」(只读起步)。
247
+ * 🔴 这件事**读不到 wire 上**(契约 §4c:两种任务产出同一张卡),所以只能由提交方声明;不声明 / false ⇒ 老两选卡、决断体不带键。
248
+ * 声明了还要过第二道闸:本进程 caps 缓存里的引擎版本 ≥7.86.0(更老的 server 静默丢这个键 = 卡上承诺了却不兑现)。
249
+ */
250
+ opts) {
163
251
  let armedTaskId;
164
252
  try {
165
253
  if (!isPlanReviewPark(result))
@@ -227,6 +315,13 @@ export function armPlanReviewApproval(result, sessionKey) {
227
315
  }
228
316
  armedPlanReviewTaskIds.add(taskId);
229
317
  armedTaskId = taskId;
318
+ // CC-46 双闸:宿主声明 ∧ 引擎版本证据(读不出 ⇒ unknown ⇒ 不出三选卡)。
319
+ const offerModeAfter = opts?.submittedInPlanMode === true &&
320
+ versionSupportsPlanReviewModeAfter(engineCapString(engineWireTarget()?.baseUrl, 'version')) === 'supported';
321
+ if (offerModeAfter)
322
+ offerModeAfterByTask.set(taskId, true);
323
+ else
324
+ offerModeAfterByTask.delete(taskId);
230
325
  const questionId = planReviewQuestionId(taskId);
231
326
  const unregister = registerLocalQuestionResponder(questionId, async (_id, answer) => {
232
327
  // REF-CC-027:一次性 —— 首句就注销 + 清武装态(照 askGateWire 的 cleanup() 形)。
@@ -234,19 +329,24 @@ export function armPlanReviewApproval(result, sessionKey) {
234
329
  // 第二道钉:武装态一清,同 taskId 才能重新正常 arm(证明 responder 真的落定了)。
235
330
  unregister();
236
331
  armedPlanReviewTaskIds.delete(taskId);
237
- const decided = planReviewDecisionFromAnswer(answer);
238
- if (decided === 'dismissed') {
332
+ const shown = planReviewCardOptions(offerModeAfterByTask.get(taskId) === true).map(o => o.label);
333
+ const choice = planReviewChoiceFromAnswer(answer, shown);
334
+ if (choice === 'dismissed') {
239
335
  // dismiss/无法判读:诚实缺席优先于编造默认值(REF-CC-026)——不静默驱动 decidePlanReview,
240
336
  // 卡的重开路径把决定权还给用户。呈现台账的键**不清**:同一张卡再被重开就是真「reopened」。
241
337
  return { ok: true };
242
338
  }
339
+ const decided = choice.decision;
340
+ // CC-46:只有三选卡的两个 Yes 带批准后模式;老两选的 Yes / No 不带 ⇒ 决断体不落键。
341
+ const modeAfter = choice.decision === 'approve' ? choice.permissionModeAfter : undefined;
342
+ offerModeAfterByTask.delete(taskId);
243
343
  // A-024.4(#244 F1 起走分代):决断一经递交,这个门的呈现史就消费掉(当代键清 + 推代);
244
344
  // 同 run 的**下一个** plan gate(approve 推进后引擎可再 park 一个新 plan)必须读回首见,
245
345
  // 否则文案对一张从未呈现过的新卡说「reopened」。递交后决定未生效的形(RB-471 族)读回
246
346
  // 首见只损失「reopened」一词 —— 首见文案零历史断言,诚实方向安全。
247
347
  notePlanReviewAnswered(questionId);
248
348
  // fire-and-forget:resume 同步驱动到终态可能分钟级,不能挂住 overlay;结果经 queue 通知回来
249
- void decidePlanReview(taskId, decided);
349
+ void decidePlanReview(taskId, decided, modeAfter);
250
350
  return { ok: true };
251
351
  });
252
352
  publishPlanReviewCard(taskId);
@@ -256,8 +356,10 @@ export function armPlanReviewApproval(result, sessionKey) {
256
356
  catch (e) {
257
357
  // REF-CC-027:arm 本身失败(register/publish 抛)绝不能把 taskId 永久锁在武装态里
258
358
  // ——那样这张卡再也无法被正常 arm 一次。
259
- if (armedTaskId !== undefined)
359
+ if (armedTaskId !== undefined) {
260
360
  armedPlanReviewTaskIds.delete(armedTaskId);
361
+ offerModeAfterByTask.delete(armedTaskId);
362
+ }
261
363
  hostLog('debug', `planReviewWire: arm failed (fail-soft): ${String(e)}`);
262
364
  return false;
263
365
  }
@@ -267,7 +369,13 @@ export function armPlanReviewApproval(result, sessionKey) {
267
369
  * plan_review` 同 body {decision})——raw fetch 退役。错误模型:非 2xx 抛 APIError(message =
268
370
  * server body.error 原文)→ 同款「HTTP <status> <error>」outcome 文案;网络失败走原「could not
269
371
  * reach the engine」臂。 */
270
- export async function decidePlanReview(taskId, decision) {
372
+ export async function decidePlanReview(taskId, decision,
373
+ /**
374
+ * 0.72.13 CC-46(engine ≥7.86.0;契约 §4c):批准之后用哪一档。缺席 ⇒ 体逐字节同旧 `{decision}`。只配 approve、闭集两词 ——
375
+ * 坏形本地拒(零 POST,outcome 如实说没送出去,不回显值)。引擎答 400 `request.field_conflict`(这条任务不是只读起步):
376
+ * `default` 与缺席同义 ⇒ 去键重发恰一次;`acceptEdits` **不**重发(不静默降成逐次征询),outcome 说批准没生效。
377
+ */
378
+ permissionModeAfter) {
271
379
  // REF-CC-025:此前 `cfg`/`client` 缺席各自 `return` 静默——卡已经对用户回了「收到」
272
380
  // (armPlanReviewApproval 的 responder 早就 `return {ok:true}` 过了),但决断本身连一次网络
273
381
  // 请求都没发出去,而模型/用户没有任何回程信号。两条早退现在都汇进同一条 outcome 管道(下方
@@ -276,7 +384,12 @@ export async function decidePlanReview(taskId, decision) {
276
384
  // 🔴 design/285 批 3 显式裁定:本处与 `armPlanReviewApproval` 同一笔豁免(理由逐字见那边)——
277
385
  // 决断是从 arm 立的那张卡的 responder 回调进来的,槽键必须与立卡时同源,单换这一跳即两头不一致。
278
386
  const cfg = engineWireTarget();
279
- if (!cfg) {
387
+ const badMode = permissionModeAfter !== undefined && (decision !== 'approve' || !isPlanReviewModeAfter(permissionModeAfter));
388
+ if (badMode) {
389
+ hostLog('error', `planReviewWire: ${decision} NOT sent — permissionModeAfter is only valid with approve and must be "default" or "acceptEdits"`);
390
+ outcome = `The plan_review ${decision} could NOT be sent: the post-approval permission mode given with it is not valid for this decision (it only applies to an approval, and must be "default" or "acceptEdits"). Tell the user plainly that the decision did not go through; the plan is still waiting.`;
391
+ }
392
+ else if (!cfg) {
280
393
  hostLog('error', `planReviewWire: ${decision} NOT sent — engineWireTarget() unavailable`);
281
394
  outcome = `The plan_review ${decision} could NOT be sent: no engine connection is configured on this host. Tell the user plainly that the decision did not go through.`;
282
395
  }
@@ -293,7 +406,27 @@ export async function decidePlanReview(taskId, decision) {
293
406
  }
294
407
  else {
295
408
  try {
296
- const body = await client.assistant.planReview(taskId, { decision });
409
+ // sdk 9.6.0 的 PlanReviewRequest 未声明 permissionModeAfter ⇒ 交集型加一可选位;缺席 = 键不落(体逐字节同旧)。
410
+ const mode = isPlanReviewModeAfter(permissionModeAfter) ? permissionModeAfter : undefined;
411
+ const reqBody = { decision };
412
+ if (mode !== undefined)
413
+ reqBody.permissionModeAfter = mode;
414
+ let body;
415
+ try {
416
+ body = await client.assistant.planReview(taskId, reqBody);
417
+ }
418
+ catch (e1) {
419
+ // 400 request.field_conflict + 带了 default:default ≡ 缺席(契约:缺席 = 这一档)⇒ 去键重发恰一次,零语义损失。
420
+ // 被这道门拒掉的请求一个字节的状态都不动(契约),同一次批准可原样重试。acceptEdits 不走这里(见下方 catch)。
421
+ const conflict = e1;
422
+ if (mode === 'default' && conflict?.status === 400 && conflict?.errorCode === 'request.field_conflict') {
423
+ hostLog('debug', 'planReviewWire: permissionModeAfter "default" refused as not applicable — re-sending the approval without the key (same meaning)');
424
+ body = await client.assistant.planReview(taskId, { decision });
425
+ }
426
+ else {
427
+ throw e1;
428
+ }
429
+ }
297
430
  hostLog('debug', `planReviewWire: ${decision} → ok ${JSON.stringify(body).slice(0, 200)}`);
298
431
  // [2315]/[2316](#109,2026-08-02):decide 的 **2xx 不当终态** —— test 黑盒实测 reject 9/9
299
432
  // 返回 200 而会话仍锁在同一 gate(core RB-471:重开兜底对 reject 腿恒真误触发)。这里回拉
@@ -341,7 +474,16 @@ export async function decidePlanReview(taskId, decision) {
341
474
  catch (e) {
342
475
  // APIError 判型走 status duck-check(sseIdleTriage 同款纪律:双包时 instanceof 会分叉)。
343
476
  const status = e?.status;
344
- if (typeof status === 'number') {
477
+ const refusedCode = e?.errorCode;
478
+ if (status === 400 && refusedCode === 'request.field_conflict' && permissionModeAfter === 'acceptEdits') {
479
+ // CC-46:这条任务不是只读起步的(模型自选 plan 模式那种形)⇒ 引擎拒收「批准后自动接受编辑」。**不**静默降档重发:
480
+ // 用户选的是「不再逐次征询」,悄悄换成「逐次征询」= 替他改了决定。引擎一个字节的状态都没动,计划仍在等。
481
+ hostLog('debug', 'planReviewWire: approve + acceptEdits refused (request.field_conflict) — NOT re-sent with a different mode');
482
+ outcome =
483
+ 'The plan approval was NOT applied: the engine refused "auto-accept edits" for this task (it was not started read-only in plan mode, so that option does not apply). ' +
484
+ 'Nothing changed engine-side — the plan is still waiting for review. Tell the user plainly, and that approving with "manually approve edits" will go through.';
485
+ }
486
+ else if (typeof status === 'number') {
345
487
  const msg = e instanceof Error ? e.message : String(e);
346
488
  hostLog('debug', `planReviewWire: ${decision} → ${status} ${msg.slice(0, 200)}`);
347
489
  outcome = `The plan_review decision failed: HTTP ${status} ${msg}`.trim();
@@ -450,14 +592,15 @@ export function reopenPlanReviewCard(taskId, opts) {
450
592
  publishQuestionFrameFor(sessionKey, {
451
593
  type: 'question',
452
594
  questionId: canonicalId,
453
- questions: planReviewQuestions(ARM_QUESTION),
595
+ questions: planReviewQuestions(ARM_QUESTION, offerModeAfterByTask.get(taskId) === true),
454
596
  });
455
597
  return settleOnReceipt(receipt, firstSight);
456
598
  }
457
599
  publishQuestionFrameFor(sessionKey, {
458
600
  type: 'question',
459
601
  questionId: canonicalId,
460
- questions: planReviewQuestions(ARM_QUESTION),
602
+ // 🔴 沿用 arm responder ⇒ 题面必须是**那张卡**的选项(三选 arm 后重绘成两选 = 屏上的 Yes 不在 responder 的读域里,点了不投递)。
603
+ questions: planReviewQuestions(ARM_QUESTION, offerModeAfterByTask.get(taskId) === true),
461
604
  });
462
605
  registerArmedGateFor(sessionKey, armedKey);
463
606
  return { reopened: true, firstSight };
@@ -489,7 +632,8 @@ export function reopenPlanReviewCard(taskId, opts) {
489
632
  if (activeReopenResponders.get(activeKey)?.questionId === questionId) {
490
633
  activeReopenResponders.delete(activeKey);
491
634
  }
492
- const decided = planReviewDecisionFromAnswer(answer);
635
+ // 本腿展示的恒是老两选 ⇒ 显式按两选标签校验(未展示的 auto / manual 标签 = 答的不是这张卡 ⇒ dismissed)。
636
+ const decided = planReviewDecisionFromAnswer(answer, LEGACY_TWO_CHOICE_LABELS);
493
637
  if (decided === 'dismissed') {
494
638
  // 三态(REF-CC-026):dismiss/读不出不驱动 decidePlanReview;呈现台账不清 ——
495
639
  // 这张卡再被重开就是真「reopened」。
package/dist/index.d.ts CHANGED
@@ -155,6 +155,7 @@ export * from './engineCapsCache.js';
155
155
  export * from './sqlEngineCapability.js';
156
156
  export * from './writeProtectionCapability.js';
157
157
  export * from './webSearchBackendCapability.js';
158
+ export * from './executionLaneCapability.js';
158
159
  export * from './mcpReconnect.js';
159
160
  export * from './leaderConflict.js';
160
161
  export * from './runTerminal.js';
package/dist/index.js CHANGED
@@ -187,6 +187,9 @@ export * from './writeProtectionCapability.js';
187
187
  // S-382(0.72.5;server ≥7.82.1,sdk 尚未声明):`Capabilities.webSearch.backend` 的四态窄读器 ——
188
188
  // 部署默认 WebSearch 后端(`none` = 正面事实 / 键缺席 = 老引擎判不了,两者处置相反)。与上两只同构同纪律。
189
189
  export * from './webSearchBackendCapability.js';
190
+ // 0.72.13 CC-54:`capabilities.executionLane`(server ≥7.86.0 S-426)四态读面 —— 键缺席 = 老 server 判不了(≠ 不是本机车道);
191
+ // 「工具是否跑在本机」判据归包(读到位用位,读不到原样用调用方旁证)。
192
+ export * from './executionLaneCapability.js';
190
193
  // S-381 / core 7.22.0 #857(0.72.9 CC-30;server ≥7.85.0,sdk 尚未声明):会话内 MCP re-dial 的消费口 —— 200 三态按 outcome 分形读、
191
194
  // unsupported 恒五键、名册按 toolNames 在场性、失败按 errorCode 分诊、「重连 = 一次交易」措辞单源;能力位 mcpReconnect 三态读。
192
195
  export * from './mcpReconnect.js';
@@ -902,26 +902,37 @@ export function enqueueBgChildNotification(n) {
902
902
  return;
903
903
  }
904
904
  bgNotifiedKeys.add(key);
905
- // [2393] F-1:周期维必须跟着前进 —— 收摊臂删条目的唯一证据就是这一格。
906
- markRunNotified(n.taskId, cycle);
907
- cardEnqueuedRunIds.add(n.taskId);
905
+ // 🔴 完成三本账(完成去重 / 周期 / 完成卡)与面板 settle **只认通知自己的终态词**(completed / failed / killed / cancelled)。
906
+ // 非终态(running / queued / event / unknown / 未来词)只过上面那本**逐条**去重账:它若占了完成账,同一任务随后的
907
+ // 真终态会被跨通道去重吞掉 ⇒ 零 end、常驻标永不摘(异源对抗复审)。
908
+ const terminal = isTaskNotificationTerminalStatus(n.status);
909
+ if (terminal) {
910
+ // [2393] F-1:周期维必须跟着前进 —— 收摊臂删条目的唯一证据就是这一格。
911
+ markRunNotified(n.taskId, cycle);
912
+ cardEnqueuedRunIds.add(n.taskId);
913
+ }
908
914
  // #6 通知-settle 边(合成半场):bg 子代行不再被 turn sweep 假结(session 常驻台账),真终态
909
915
  // 唯二来源 = 推送帧(bridge task_notification 臂)与本合成链(probe/fleet bg_notification 收敛点)。
910
916
  // 消费端 settle 幂等(running 才动),推送帧先到时此发布为 no-op。
911
- try {
912
- clearEnginePanelTaskResident(n.taskId);
913
- publishEngineAgentPanelEvent({
914
- kind: 'end',
915
- taskId: n.taskId,
916
- // L-215②(0.65.0):读**单铸谓词**而不是内联两词 —— 修前这里只认 `failed`/`killed`,
917
- // 而 core [6908] 的 `blocked` 是 **agent 自报的终态**(不是等人)⇒ 一条自报走不下去的
918
- // 后台 run 在面板上被 settle 成**成功**。🔴 `suspended`/`needs_review` 仍不在表里
919
- // (那两词是「等一次人的决定」,判成终局会把一条正等着你的 run 在面板上判死)。
920
- isError: isTerminalNotSuccess(n.status),
921
- });
922
- }
923
- catch {
924
- /* fail-soft — 面板 settle 失败绝不挡模型通知注入 */
917
+ if (terminal) {
918
+ try {
919
+ // 0.72.13(test 0.72.12 验收 ①;DS-06 同形存量):只对**通知自己的终态词**(completed / failed / killed / cancelled)settle 面板行。
920
+ // 此前对任何 status 都铸 `end`,而 `isError: isTerminalNotSuccess(status)` 对 running / queued / event / 未来词答 false ⇒
921
+ // 面板把一条还在跑 / 认不出状态的行结成 **completed** —— 该谓词的 JSDoc 明禁拿它的 false 当成功。非终态 ⇒ 不 settle、常驻标不摘。
922
+ clearEnginePanelTaskResident(n.taskId);
923
+ publishEngineAgentPanelEvent({
924
+ kind: 'end',
925
+ taskId: n.taskId,
926
+ // L-215②(0.65.0):读**单铸谓词**而不是内联两词 —— 修前这里只认 `failed`/`killed`,
927
+ // 而 core [6908] 的 `blocked` 是 **agent 自报的终态**(不是等人)⇒ 一条自报走不下去的
928
+ // 后台 run 在面板上被 settle 成**成功**。🔴 `suspended`/`needs_review` 仍不在表里
929
+ // (那两词是「等一次人的决定」,判成终局会把一条正等着你的 run 在面板上判死)。
930
+ isError: isTerminalNotSuccess(n.status),
931
+ });
932
+ }
933
+ catch {
934
+ /* fail-soft — 面板 settle 失败绝不挡模型通知注入 */
935
+ }
925
936
  }
926
937
  const message = `<${TASK_NOTIFICATION_TAG}>
927
938
  <${TASK_ID_TAG}>${escapeXml(n.taskId)}</${TASK_ID_TAG}>