@sema-agent/client-core 0.46.0 → 0.48.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.
package/CHANGELOG.md CHANGED
@@ -28,6 +28,194 @@
28
28
  > 不许悄悄漂:豁免登记的 `releasedAt` 与 `FROZEN` 账上 0.36.0 那一行逐字相等;本段(点名版本号
29
29
  > `0.36.0` + 关键字「勘误」)必须还在这份头注里 —— 删掉本段而不同批把门侧豁免一起处理,门当场红。
30
30
 
31
+ ## 0.48.0(未发布)
32
+
33
+ 五件 additive 投影/上收批(0.48.0 提货窗:sdk **7.4.0** / server **7.54.0** / core **7.1.0**)。
34
+ 🔴 **唯一的非 additive 面 = peer 地板抬到 `@sema-agent/sdk >=7.4.0`**(见文末「地板影响面账」);
35
+ 既有键与文案逐字节不变,新增 **4** 个运行期导出(公开导出面 790 → **794**)。
36
+
37
+ - **件① `task_progress` 的 `model` cast 退役 + `GateCurrentPending` 单源化**。
38
+ 0.47.0 那两处各自写死了退役条件,本批**按条款兑现**,不是顺手整理:
39
+ - `eventToSdkMessage` 的 `const model: unknown = (ev as {model?:unknown}).model` 整条删,改类型面
40
+ 直读 —— sdk 7.4.0 已声明 `task_progress.model?: string`。**退役的是 cast,不是运行期判**:
41
+ `typeof` 门保留(旧 server 缺席 ⇒ 键不 stamp),端侧行为逐字节不变。
42
+ - `GateCurrentPending` 由**自铸 interface** 改成上游形的**别名**,自铸记账段整段删。
43
+ ⚠️ **绕道取形,原委成文**:`ApprovalStaleCurrentPending` 在 sdk 7.4.0 里
44
+ `dist/types.d.ts` 有 `export interface`,但 `dist/index.d.ts` 的 re-export 名单里**没有它**,
45
+ 且包 exports 映射只开 `.` / `./registry` ⇒ **按名 import 不通**(本批亲验)。故用
46
+ `Readonly<NonNullable<ApprovalStaleError['currentPending']>>` 取**同一个声明**,单源化目的一字不打折。
47
+ 🔴 **`Readonly<>` 不是装饰**:0.47.0 自铸形三位全 `readonly`(它们是 D-1 坐标,契约=**逐字回显、
48
+ 绝不本地重算**),而上游形的成员是**可写**的 —— 裸别名会把这条已发布的类型契约悄悄弱一档,
49
+ 且**没有任何行为门看得见**(不改变任何运行期字节)。同批铸编译期钉
50
+ `AllReadonly<GateCurrentPending>`(新 `src/typePins.ts` 原语),摘掉 `Readonly<>` 当场编译错。
51
+ 📋 上游候件:请 sdk 把该名字加进 `index.d.ts` 的 `export type {…}`(纯 additive,零行为)。
52
+ - `seq` **仍然剥**:sdk 7.4.0 虽已声明它,但本层白名单的准入条件是「说得出谁读它」,答案仍是
53
+ 零消费方。🔴 **声明到货不是透传的理由** —— 否则白名单会随上游类型面自动变宽。
54
+
55
+ - **件② durable park 行的 bidi 披露位 `hasBidiControls` 随卡透传**(S-30①,server ≥7.53 / core 5.60.0 #438)。
56
+ `PendingCheckpoint.hasBidiControls` → `ApprovalCardRequest.hasBidiControls`。病形与 [C170] 那次
57
+ `ruleSuggestions`/`governanceForced` **逐字同一个坐标**:「行 → 卡」重铸处闭形,行上新键整键丢失。
58
+ 🔴 落**新键**,**绝不并进活卡腿的 `inputHasBidi`**:两者同语义、**不同源、不同算点**
59
+ (前者 core 在 park mint 时算并反范式成 durable 列、server 读列不重算;后者 server 对帧自身序列化后的
60
+ args 现算)⇒ 同一只 ask 两条腿的在场性**可以不一致**,合流就是拿一个量冒充另一个。
61
+ 只认严格 `true`;缺席 = **没检出**,禁读成「已确认干净」。
62
+ ⭕ **另两条上游读面如实记零施工**:`ApprovalStreamEvent.hasBidiControls`(本包 feed 只把 stream
63
+ 当「变了」信号,权威列表恒来自 `list()`,该帧不经任何投影)与 `InboxRow.hasBidiControls`
64
+ (本包**没有** inbox 投影面,全仓零 `InboxRow` 引用)。
65
+
66
+ - **件③ `engine_notice` 的 raw 预分派退役 + server 7.54.0 两新码**(`task.halt_unconsumed` / `task.late_approval`)。
67
+ 🔴 **两新码的投影施工量 = 零,而这不是偷懒**:本臂按开集消费(库里一个码都不硬编),新码**按构造**
68
+ 就到得了端。真正的施工是另一件:sdk 7.4.0 已声明 `engine_notice` 臂 ⇒ 0.47.0 留的那颗**自退休钉
69
+ 本批真的响了**(devDep 抬版当拍 `tsc` 就报 `assertNeverArm` 收不下这条臂),按原定条款把 raw 预分派
70
+ 搬进 `case`,**行为一字不改**,本函数重回「每一条臂都受编译期穷举保护」。
71
+ 🔴 投影仍走 raw `Record` 视图**刻意不改吃 SDK 收窄形**:SDK 把五键记成全必填,而本层对每一键都做
72
+ 诚实缺席处理,这些分支在收窄形上会被判成死码而**静默失效**;且两个重放身份键(`eventId`/`eventSeq`)
73
+ 根本不在 SDK 臂声明里。⚠️ `text_end` 的预分派**照旧保留**(sdk 7.4.0 全树仍零命中,退役条件未满足)。
74
+
75
+ - **件④ `status`(BrainStatus)臂补 `errorStatus` **与** `retryAtMs` 两键**(core 7.0.x #506 ㋑,server ≥7.53)。
76
+ 🔴 **族扫的产物,不是只补被点名的那一个**:本批派工只点名 `errorStatus`;族扫 = 把**实装 core** 的
77
+ `BrainStatus` 整个键集与本包镜像逐一对表,当场捞出**存量**漏键 `retryAtMs`(同批进 core、同在 server
78
+ `brainStatusEventData` 白名单里真发,只是没人提)。镜像 7 键 → **9 键**。
79
+ 🔴 **同形存量清剿:这条腿上有两层白名单,两层同批修** —— `eventToSdkMessage` 的 `case 'status'`
80
+ 与 `adapt/arms.ts` 的 `retryStatusArm`;只修一层键仍到不了宿主(常驻门 G4 驱**两段合成**的全链,
81
+ 漏任一层都红)。同批**顺手退役**该臂 attempt/maxRetries/retryInMs/errClass 四位的 cast(sdk 7.4.0
82
+ 已声明这 7 键);`retryAtMs`/`errorStatus` 两位 SDK 锚仍未跟 ⇒ 保结构视图读 + 登记退役条件。
83
+ 🔴 **`deadline` 的语义与字节一字未改**:`retryAtMs` 是**产生者铸**的墙钟截止点,端在场时应优先用它
84
+ (本包现算的 `deadline` 已被跨进程跳的传输耗时推后,且在 core 每 30s 的重播片上会重新起跳);
85
+ 两位**并存**,端自己选 —— 换算法 = 一次静默的行为改动,本包不做(门 G4c 是这条方向钉的反钉)。
86
+
87
+ - **件⑤ `sessions.memoryStatus` 的三端公共读面**(S-53;新文件 `src/sessionMemoryStatus.ts`,+4 导出)。
88
+ 收在库里的理由是**两处判定**,三端各写一遍必然各错一遍:
89
+ ① **同 status 不同码** —— 本路由的 404 有两个互不相干的含义(`not_found.session` 会话未知/非属主 /
90
+ `not_found.route` <7.53 老 server 无此路由),按 **status** 分诊必然把「你的部署没这个面」说成
91
+ 「你这个会话不存在」。判据锚 `errorCode`。🔴 **无码的 404 落 `failed`(如实说判不出),绝不猜** ——
92
+ 猜错任一向都是一句用户会照着去排错的假话;501 才允许无码兜底(本路由两条 501 臂同义,无歧义)。
93
+ `capability.*` 与 `feature.*` **分列不合流**(同为 501 而处置相反)。
94
+ ② **五键缺席语义逐键不同** —— `optOutSource`/`lastCaptureAt` 在健康会话上就合法缺席,零历史会话真形 =
95
+ `{captureOptedOut:false, committedCount:0, foldedCount:0}`(**无任何降级**)。两个合读器:
96
+ `readCaptureOptOut`(两键合读,fault ⇒ `indeterminate`,绝不读成「记忆开着」)与 `readLastCapture`
97
+ (**三态**:单读 `lastCaptureAt` 判不出任何东西,判别材料是**另一键** `committedCount`)。
98
+ 纯判定 + 薄封装,IO 归宿主注入(`MemoryStatusClientLike`,与 `HitlClientLike` 同款 duck-type);
99
+ 永不抛;畸形键降缺席不采信;**空 `sessionId` 不发请求**。
100
+ 🔴 **身份绑定**:回声 `sessionId` 必须与请求值**逐字相等**,否则整只落 `failed` —— 收下不相等的
101
+ 回声 = 把**另一条会话**的记忆元数据呈现在当前会话面板上(上游同一件事的纪律是「宁缺席不串台」)。
102
+ 🔴 **矛盾形不产出确定判决**:capture 轴按**完整真值表**判(只有 `true`×`record` 与 `false`×缺席
103
+ 两格有定义,其余含 `false`×`record` / `true`×缺席 一律 `indeterminate`);lastCapture 的 `known`
104
+ 是**合取**(时刻在场 ∧ `committedCount` 是数 ∧ >0)。这些矛盾载荷版本斜差 / 畸形 200 体 / 中间层
105
+ 改写都造得出,而端拿到确定值之后不会再问第二遍 —— capture 轴尤其是**隐私姿态**断言,两个方向都危险。
106
+ 🔴 **「永不抛」成功路与失败路都设防**:归一化整段在 `try` 内;`classifyMemoryStatusFailure`
107
+ **自己**取属性也带保护 —— 它是在 `catch` **块内**被调用的,`catch` 里抛出的异常不会再被同一个
108
+ `try` 接住,分类器一抛就击穿这句承诺(同形第二处)。带抛错 getter 的对象 / 敌意 `Proxy` 作**返回体**
109
+ 或作**拒因**都落 `failed`,不向外 reject。
110
+ 🔴 **端必读**:`sessionId` 必须取**引擎捕获值** —— server 侧记录/台账按**裸 sessionId** 键控且
111
+ **活过会话**,喂一个被回收的 id 会读到**上一代**的计数/opt-out(元数据,无内容字节)。
112
+
113
+ **新增导出(4)**:`readSessionMemoryStatus` / `classifyMemoryStatusFailure` / `readCaptureOptOut` /
114
+ `readLastCapture`。装 ≤0.47.0 的端按名 import 会在 ESM 实例化当场炸 —— 提货前先抬依赖。
115
+
116
+ **门**:36 套全绿(新增 1 套 `run-session-memory-status-test.mjs` **53** checks);
117
+ `additive-key-passthrough` 45 → **73** checks(新增 G3 engine_notice 开集消费 + G4 BrainStatus 两新键);
118
+ `durable-card-display-keys` 43 → **54** checks(新增 ⑪ 段 hasBidiControls 正负控);
119
+ 登记物:导出面 790 → **794** / gates 35 → **36** / portability 138 → **139** / typeshape unknown 258 → **260** /
120
+ pure BrainStatus 镜像 7 → **9** 键。
121
+
122
+ **🔴 peer 地板抬版影响面账(`@sema-agent/sdk` `>=7.2.0` → `>=7.4.0`)**:
123
+ - **为什么必须抬**(四条,任一条单独都足以钉死地板,不是顺手跟版本):① `AgentEvent.engine_notice`
124
+ 臂(<7.4.0 上 `case 'engine_notice'` 是编译错);② `task_progress.model`(cast 退役后 `ev.model`
125
+ 在旧版是编译错);③ `PendingCheckpoint.hasBidiControls`(件② 读的就是这一位);
126
+ ④ `sessions.memoryStatus` + `SessionMemoryStatus`(件⑤ 直接 `import type`)。
127
+ - **消费方要跟的**:三端装 <7.4.0 的 SDK 会看到 peer 警告;本包**运行期**不因此变化。
128
+ - **随之退役的自铸形**:`GateCurrentPending`(自铸 interface → 上游形别名)。
129
+ - **未退役、如实记**:`text_end` 的 raw 预分派(sdk 7.4.0 仍零命中);`status` 臂的
130
+ `retryAtMs`/`errorStatus` 两位仍走结构视图读(SDK 锚未跟);`ApprovalStaleCurrentPending`
131
+ 按名 import 仍不通(上游 re-export 缺口,已登记候件)。
132
+ - **core devDep 5.57.0 → 7.1.0 是 devDep-only**(core 不在 peerDependencies 里),它是 engine-vocab
133
+ 门的对账锚,**对消费方零要求**。同批亲验:`BrainStatusPhase`(6 相)与 `BrainRetryErrClass`(6 桶)
134
+ 两版**逐词未变** ⇒ 本次提级的行为面影响只有 `BrainStatus` 键集这一处。
135
+
136
+ ## 0.47.0(2026-08-31)
137
+
138
+ 三件 additive 上收/透传批(#363;取证与定谳出自同批 CLI 提货)。**零 BREAKING**:
139
+ peer 地板不动(`@sema-agent/sdk >=7.2.0` / `@sema-agent/agent-types >=0.2.0`),既有键与文案
140
+ 逐字节不变,新增三个运行期导出(公开导出面 787 → **790**)。
141
+
142
+ - **件① `task_progress` 白名单补 `model`**(`src/adapter/downstream/eventToSdkMessage.ts`)。
143
+ server ≥**7.52.1** 的投影白名单(`trace/project.js` 的 `taskProgressEventData`)真发这一位 ——
144
+ 子 run 的**模型 id**,core 铸点 `prepared.model.id`;修前本层的闭形白名单把它整键剥掉,
145
+ 与 `workflowRunId`([1616]/[1617]②)是**同形第二例**:上游真发、白名单剥掉、两边代码看着都对。
146
+ 条件与 server 投影**同形**:`string` 且非空才带(空串既不是模型 id 也不是「不知道」)。
147
+ ⚠️ sdk **7.3.0** 的 `task_progress` 臂尚未声明这一位(与 `requiresRealApproval` / `ruleOffers`
148
+ 同形:server 已真发、SDK 锚未跟)⇒ 本层是**结构视图读**,SDK 补上当天那处 cast 可整条删。
149
+ 同批做了这条白名单的**族扫**并把账写进码与档:server 投影发 13 键、本层转 8 键,
150
+ **仍被剥掉的五键**(`seq` / `taskType` / `parentTaskId` / `status` / `eventId`)逐条登记了
151
+ 「谁没在读它」,见 `docs/INTEGRATION-CLIENTS.md` §3d。**刻意不静默多透**:透一位的前置条件是
152
+ 说得出谁读它、读来干什么。
153
+ - **件② decide 失败臂透传 `currentPending`**(`src/hitl/hitlBridge.ts` 新读口 +
154
+ `toolApprovalWire.ts` / `parkResolver.ts` 三个拍平点)。server S-02(≥7.52)在 409
155
+ `approval_stale` 拒体上铸的 **additive 指路键** —— 本会话**当前**那条 pending 的三件 D-1 坐标
156
+ (`toolName` / `boundCallId` / `boundInputHash?`),壳拿它**一跳重定位**再 decide,免整队重拉。
157
+ 新增公开读口 `readDecideCurrentPending(e)` + 类型 `GateCurrentPending`;`FsApprovalOutcome`
158
+ 与(包内型)`GateOutcome` 的 `failed` 臂各补一个**可选**位。
159
+ 🔴 **它不参与任何裁决**(server 自己的登记原话:纯指路/便利面,不进任何门/CAS/resume 判定)——
160
+ 缺席是常态且什么都不证明(老 server / 非工具门 / 行读失败 / 本次失败根本不是 stale 臂,四形同形)。
161
+ 🔴 读口是**结构视图读**不是 `instanceof`:decide 出站走的是宿主注入的 client,抛出物的类身份
162
+ 不归本包假设(跨 realm / 双实例 = `HitlSafetyError` 判型契约踩过的同一个坑);而且
163
+ `ApprovalStaleError.currentPending` 只在 sdk ≥7.3.0 上声明,本包地板是 7.2.0。
164
+ ⚠️ **可达性如实登记**:sdk 的 `ApprovalDecision` 自 1.0.0 起刻意无 `checkpointToken`,而 server
165
+ 的 stale 臂只在调用方**回显该 token** 时触发 ⇒ **经 SDK client 的 decide 今天拿不到这枚 409**。
166
+ 本位是给「注入自有传输层 / 读别人写的 wire」的宿主与将来上游放行准备的通路,**不是**今天就能
167
+ 在 cli 上观察到的行为改动。
168
+ - **件③ 交互 Esc 停止判定上收**(新模块 `src/interactiveHalt.ts`)。`planInteractiveHalt(...)` +
169
+ 升级码闭集 `RUN_LEVEL_STOP_ERROR_CODES`:「Esc ⇒ 先发 **turn 级** halt;升级成 **run 级** cancel
170
+ 恰有**两格** —— ① 引擎自己回了升级闭集里的 409(它在说「这里没有在飞 turn 可切,run 级停止请用
171
+ cancel」),或 ② 这一发**连判决都没拿到**且屏上确实挂着审批卡;其余一律不升级」。这条判定 TUI/desktop/web
172
+ 三端都要(都会 Esc、都会撞同一个 parked 格),此前整条住在 cli 壳里。**纯判定**:零 IO、零
173
+ import、零 module 级状态;发射(裸 fetch / SDK verb)、台账、留痕、UI 反馈仍归各端。
174
+ 不对称是刻意的:判**不**升级 = 用户退回「Session busy」卡再选一次(可恢复);判**错**升级 =
175
+ 拆掉一条其实还活着的 run 并连坐它身上的在飞工具(不可恢复)。
176
+ 🔴 **本批只出口子,壳侧换装不在本批** —— cli 那条「裸 fetch 直拨 interrupt」的网络面豁免,
177
+ 退役条件就是端接上这个口子(见 §7d)。
178
+
179
+ **异源对抗复审三条 finding 全数采纳并修**:
180
+ - [high] `RUN_LEVEL_STOP_ERROR_CODES` 原本导出成 `ReadonlySet` —— **只在类型面只读**,运行期
181
+ `.add('interrupt.not_held')` 就能把「不许升级」变成「升级」,把一条**还活着**的 run 不可恢复地
182
+ 拆掉,而模块头注那句「纯判定、零可变态」当场变成假话。改成 `Object.freeze` 的数组
183
+ (运行期真改不动,公开面与判定源可以安全同物),判据形随之 `.has()` → `.includes()`;
184
+ 门补 G4b 负控:**先真的去改公开面**,再确认判决零漂移。
185
+ - [high] accept-session(`allowSession`)腿的回退臂会**吞掉带 `currentPending` 的 stale 拒绝**,
186
+ 然后用人对**旧卡**给的答案自动重发一次纯 approve(§4d/§4e 铁律禁止的动作),并把首发的指路键
187
+ 顶掉。修:拒体带指路键 ⇒ 立即上抛(只发一次 decide,指路键原样进 outcome);老 server 的
188
+ 400 未知键**照旧回退**,既有兼容腿宽度一字未改。回退臂**更宽的那一半**(404/5xx/无 status 错误
189
+ 仍会回退)是**存量**问题,如实登记为 §7b **P-43**,不在本 additive 批射程内。
190
+ - [medium] §10 / CHANGELOG / README 的总契约句只写了「拿不到判决且 parked 才升级」,漏掉**引擎
191
+ 明确指路**那一格,与同节分支表自相矛盾;端照那句接线会漏接 409 升级分支、让 parked run 留锁。
192
+ 四处文案统一成「升级恰有两格」。
193
+
194
+ **新增常驻门两道**(`scripts/gates-manifest.json` 同批登记,35 套):
195
+ `run-additive-key-passthrough-test.mjs`(45 checks:白名单键账双向 + §3d 档账对账 + 指路键窄读表
196
+ + durable 两腿真行为 + accept-session 腿真路径 + 三拍平点同形自证)、`run-esc-halt-plan-test.mjs`
197
+ (39 checks:分支表逐格 + 全体负控 + 冻结闭集变异负控 + 纯度/导出形)。两道门各以**单点变异**自证
198
+ 判别力(去掉 model 透传 / 只修 allow 不修 deny / 拿掉 ask 腿透传 / 把 parked 闸改恒真 / 拿掉 409
199
+ 合取项 / 把闭集改回可变 Set / 拿掉 accept-session 再抛闸 —— 七发全被抓)。
200
+
201
+ **门侧盲区清账(件③ 换形逼出来的,不是行为改动)**:`RUN_LEVEL_STOP_ERROR_CODES` 改成
202
+ `Object.freeze` 的 `readonly string[]` 之后,singleton 门的枚举器 ⑤ 命中不了它 ——
203
+ `readonly T[]` 在 AST 上是 `TypeOperator(readonly) → ArrayType`,外面那层壳把整条判据挡住了。
204
+ 按那道门头注自己的规矩「**扩枚举器 + 同批补清单,别扩豁免**」办:枚举器补一层 readonly 解包
205
+ (自检语料同批加一条),随即浮现 **10 条此前从未被登记**的模块级容器(fleet 四张键清单 /
206
+ headless permissionMode 词表 / seat 四张词表 / `seam.CHROME_ARMS`)—— 全部补登,均 `low`
207
+ (九条是与**已登记** `_TUPLE` 同一个数组对象的只读公开投影,一条是已登记表的一次性 `.map()` 派生)。
208
+ 清单 265 → **275** 条,`high` 94 不动。
209
+
210
+
211
+ ### 已知局限(本版新增)
212
+
213
+ - 件② 的 `GateOutcome.currentPending`(ask 腿)今天在**包内无消费方**:`GateOutcome` 是包内型、
214
+ 决断结局不出包。它的存在理由是**同形存量清剿**(两条 decide 失败腿一次改齐,不许只修当格),
215
+ 消费条件登记在 `docs/INTEGRATION-CLIENTS.md` §7b。
216
+ - 件① 的 `model` 只**透传**,本包不据它做任何判定/渲染(面板 tick 的事件形没有这一位);
217
+ 端要用就从投影臂上读。
218
+
31
219
  ## 0.46.0(2026-08-30)
32
220
 
33
221
  两件修复批(#354/#355 双 P0 现网案的 client-core 半场):
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sema-agent
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
35
35
 
36
36
  ## Scope
37
37
 
38
- **Version:** 0.46.0
38
+ **Version:** 0.48.0
39
39
 
40
40
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
41
41
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -65,7 +65,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
65
65
  `SseIdleError`, `probeHealth`, `APIError` and `TaskStopConflictError` are imported as values in
66
66
  five modules, and the browser bundle really bundles the SDK through (the portability guard would
67
67
  exit 3 rather than quietly mark it external).
68
- - The declared floor is `>=7.2.0`, and it is *witnessed*: the guard checks that an actually
68
+ - The declared floor is `>=7.4.0`, and it is *witnessed*: the guard checks that an actually
69
69
  installed SDK at that line still exports every value-level symbol this package imports and still
70
70
  declares `TaskStats.costMicroUsd` (the key `costOrNull` reads). A floor nobody ever ran is a
71
71
  promise, not a contract.
@@ -250,6 +250,7 @@ public-surface guard checks that last one).
250
250
  | `scripts/run-catalog-loader-gates-test.mjs` | The model-catalog candidate chain (`loadCatalogWithSources`) and the provider device-code seam: offline ⇒ `bundled` with an honest `online.reason`, a good source ⇒ `online` plus a cache write, a second offline run ⇒ `cacheHit`; the three hostile source shapes (malformed JSON, `schemaVersion: 99`, off-domain `http`) each fall through to the bundled table, and an off-allowlist target is **never dialled** — including a `302` to another host, proven by a real loopback server's hit counter staying at zero; a one-byte edit to `catalog.sha256` drops that source while an unavailable sidecar only warns; and the device-code poller's `pending → ok` / `expired` arms run against a real loopback HTTP server with an injected clock |
251
251
  | `scripts/run-abortable-sleep-test.mjs` | The shared `abortableSleep(ms, signal)` leaf (consumed by `workflowClient.ts` and `agentSession/backgroundView.ts`'s poll backoff): normal timeout resolution, immediate wake-up on `abort` mid-wait, `clearTimeout` really firing on that path, and a post-resolve late abort staying a no-op |
252
252
  | `scripts/run-durable-card-display-keys-test.mjs` | The durable approval row's two display keys survive the row→card recast in `surfaceFsApprovalAndDecide`: `governanceForced` stamps on strict `true` only (absence is "no evidence", never `false`), `ruleSuggestions` passes through the same shape-narrowing reader as the live-frame leg and lands on the **read-only** card key — plus a standing pin that the durable leg never stamps the redeemable `ruleSuggestions` card position (the `/decide` body has no rule slot; offering a "don't ask again" option there would be an affordance nothing can honour) |
253
+ | `scripts/run-session-memory-status-test.mjs` | The session **memory-status** read face (S-53): the two judgements three clients would otherwise each get wrong. First, *same status, different code* — this route's 404 carries two unrelated meanings (`not_found.session` = unknown or non-owned session; `not_found.route` = a pre-7.53 server that has no such route at all), so dispatching on the **status** would report "your deployment lacks this surface" as "your session does not exist". The verdict is anchored on `errorCode`, the two 404s are pinned to **different** verdicts, and — the load-bearing negative control — a 404 carrying **no** code falls to `failed` rather than guessing either way, since a wrong guess in either direction is a false statement a user would act on. 501 is allowed a codeless fallback because both of its arms mean the same thing here, and `capability.*` stays split from `feature.*` because those two share a status while their dispositions are opposite. Second, *absence means something different per key*: `optOutSource` and `lastCaptureAt` are legitimately absent on a **healthy** session (a zero-history session really is `{captureOptedOut:false, committedCount:0, foldedCount:0}` with no degradation at all), so reading absence as "off/none/0" asserts something unprovable. Two combined readers are pinned: capture opt-out is read from **both** its keys (a record-store fault yields `indeterminate`, never `active` — the difference between "your conversation is being remembered" and "nobody knows"), and last-capture is a **three-state** read whose discriminator is the *other* key, because `lastCaptureAt`'s absence alone covers both "ledger unreadable" and "genuinely no contributions" and therefore decides nothing; the two shapes are pinned to different verdicts so a single-key read turns red. The thin wrapper is the only IO: it never throws, drops malformed keys to absence rather than trusting them (an unreadable value must answer "don't know", never render as truth), refuses to spend a request on an empty `sessionId`, and passes `signal` through untouched |
253
254
  | `scripts/run-package-hygiene-test.mjs` | Everything `package.json` `files` ships — dist JS/typings and the Markdown docs — is screened line-by-line against a deny-list of strings that must never appear in a published artefact. The guard first proves each pattern still bites on a constructed sample (a screen that cannot fail is worse than none) and honours a per-pattern allow-list for legitimate product vocabulary, so the verdict is "clean surface", not "quiet grep". |
254
255
  | `scripts/run-integration-doc-freshness-test.mjs` | The **integration contract** (`docs/INTEGRATION-CLIENTS.md`) and the **changelog** (`CHANGELOG.md`) checked against the code, because a document with no guard rots — this one had a whole nest of drift found on it within a day of being written. Five directions, each a claim a machine can actually evaluate. (1) *Counting discipline*: the version-anchor row for the guard count may no longer carry a hand-copied number at all — it changes every time a guard is added, and writing it down is planting a timer; the export counts that are still hand-copied (the surface total, the test-hook count, the sentence describing the surface's internal composition, the sum of the sixteen domain rows, and the three sub-counts) are each compared against a value **derived** from `public-export-baseline.json`, which is the drift a human reviewer caught last time. (2) *Coordinates alive*: every `src/` `scripts/` `docs/` path the doc quotes must be on disk **and tracked by git** — on disk is not in the repo, and a doc that points readers at a file living only in its author's working tree sends every clone to nothing. A file landing in the same commit takes a named carve-out that **stops applying** the moment the file is really tracked (it can no longer let anything through, and the guard prints a line asking for it to be deleted) — deliberately not a red, since turning red on the very commit that lands the file would just manufacture a break that only a follow-up commit could clear. (3) *Arm tables*: the `hitl_out_of_slice` row and the `not_in_slice` fenced list must equal, name for name and in **both** directions, the case labels that really fall into those two buckets — read through the **TypeScript AST**, since which bucket an arm lands in is decided by the argument to `nothing(...)` and by nothing a comment says. The extractor is anchored to the one production projector: exactly one function named `eventToSdkMessage`, exactly one `switch (ev.type)` inside it, and no repeated case label — anything else is a broken anchor rather than a verdict, because a second same-shaped switch elsewhere in the file would otherwise overwrite the real one's conclusions and leave the doc agreeing with a switch nobody runs. The list is delimited by a machine-readable fence rather than by section headings, because the same section also names the terminal arms as a counter-example and prose boundaries cannot tell a member from a foil. (4) *Released sections are frozen*: an **append-only ledger** carries every version ever published — its number, the commit it was published from, and the sha256 of its section — and each one is checked, not just the current release, since pinning only the latest would set every earlier version free the moment the next one ships. The ledger cannot vouch for itself either: each recorded hash is **re-derived from that release commit** through git, so editing an old section and its constant together no longer passes — and the commit the row names is in turn checked against the `gitHead` npm recorded at publish time, which is the one value this repository cannot rewrite, so pointing an old version at a freshly written commit does not pass either. The *set* of versions that must be frozen comes from the registry too, so deleting an old row together with its section — which would otherwise remove that version from every set the guard looks at — is red rather than invisible. A failed registry call is classified rather than swallowed, and the classification consults the registry's own status code *before* it considers connection-level symptoms, so an auth refusal whose body happens to mention the network is still red rather than a skip. The version set is compared as full SemVer including prereleases — matching only `x.y.z` would silently drop a published `0.30.0-beta.1` and reopen the very hole this direction closes — and section headings are matched on a whole-version boundary so a stable release cannot bind itself to the release-candidate section sitting above it. Publishing itself is a two-phase protocol rather than a paradox: before a release, exactly one row may be marked pending and must name the current `package.json` version, exempt from the checks whose inputs do not exist yet; once the registry has that version the row must be promoted, so the temporary state cannot survive its own release. And because the pending exemption rests entirely on "this version is not out yet," it is refused outright when the registry cannot be reached to confirm that — an unverifiable premise is not a licence. Three reverse directions close the rest: a section claiming to be released but absent from the ledger, a ledger entry whose section has vanished, and a `package.json` version that was never frozen. Publishing appends a row; it never rewrites one. (6) *Sentinels*: the readers §5a hands hosts for "is this port installed" are checked against what the source actually declares it returns — `hasXxx()` is a `boolean`, the card port / HITL surface / wire target return `T | null`, the `installHost` family returns `T | undefined`. Testing a `null`-returning reader for `!== undefined` is *always true*, and a self-check that passes whether or not the port is installed is worse than none, because hosts retire their own fallback on the strength of it. Both directions are red: an implementation that changes its sentinel without the doc following, and a doc that names the wrong one. The roster covers the zero-argument readers and their `*For` variants alike — a multi-session host reads the variants, so leaving them off would let exactly the surface desktop depends on drift unwatched — and the §5a table and the §8-B checklist line are each checked against the source, because hosts tick the checklist, and a guard that only watches the prose table misses the line people actually follow. (5) *Packaging*: the README ships with the package and opens by pointing hosts at the integration doc, and the checklist names two more files as required reading before an upgrade — all three must really appear in the `npm pack` manifest, or an npm consumer follows a relative link that npmjs rewrites onto a private repository. Missing tooling never takes the whole verdict down with it: when git, npm or the registry is unreachable those legs print the `SKIPPED-SECTION` marker and the rest still judges, while a release commit the ledger names but git cannot resolve is red rather than skipped. The guard says in its own header what it does **not** do: it judges counts, coordinates, arm sets, released bytes and the packing list — whether a sentence is *right* is still for review and for the hosts to report |
255
256
  | `scripts/run-type-superset-ledger-test.mjs` | The type/wire **superset ledger** (`docs/type-superset.json`): positions this package adds on top of a CC-shaped contract, each carrying the evidence for what CC's own type surface does or does not have there. Completeness is deliberately uneven and the ledger says so. The `_sema_*` private-key class is checked in **both** directions (a key in the source that never entered the ledger is red, naming key and file; a ledger row whose key left the source is red) — but only for keys written as literals, which is the convention the ledger mandates. A key assembled by string arithmetic is beyond what any static rule can enumerate, so the guard fails closed on every shape it *can* decide (a bare `_sema_` prefix is red wherever it appears, save one pinned guard site) and leaves the rest as a convention violation for review to catch, rather than claiming a completeness it does not have. The two hand-surveyed classes are only checked for coordinate and evidence integrity, never discovered. Both directions read the source through the **TypeScript AST**, not a text scan, and they read two different sets out of it. A *key site* is an identifier, or a string whose whole value is the key — so `'_sema_decision-v2'` is carried whole rather than truncated at the first non-identifier character into some *other* key that happens to be registered. A *mention* is the key appearing inside a longer string, which is prose, not usage. The staleness direction counts key sites only: a comment or a doc sentence left behind after the last real mint site is deleted must not keep the row alive (mutation-proven — with both the comment and the prose string untouched, removing the one real site turns the guard red). And because a prefix can be concatenated or interpolated into a key no static set will ever see, the bare `_sema_` literal is refused outright rather than traced: every occurrence is red except the single inline `startsWith` guard the sanitizer needs, because the set of expressions a bare prefix can travel through on its way to a concatenation is open-ended and enumerating it is always one form behind. Every row's `host` must still resolve, with the key being a real **member of that declaration** rather than a string occurring somewhere in the same file — `governanceForced`/`delegation` each live on two different shapes in one file, and a member commented out is a member deleted, which a text-shaped check happily reads as still present. And the direction worth the most: each machine-form `ccAbsenceEvidence` is re-derived from the row's own `key` — the ledger's recorded string must match that derivation verbatim, since a row quietly witnessing `\bnever_present\b` is green forever while watching nothing (mutation-proven: the same edit passes the unbound form and is caught by the bound one) — and the check runs against the names the installed `@sema-agent/agent-types` `.d.ts` set actually declares, parsed with the TypeScript AST rather than grepped, so a name CC merely mentions in a comment cannot force the row into the manual escape hatch and thereby retire the very witness that was supposed to fire the day CC declares that name for real. That escape hatch is gated by an allowlist living **in the guard**, not the ledger, so claiming it costs a reviewed diff. Missing material never reads as a pass, and the verdict splits by *why* it is missing: no TypeScript parser skips the suite before it starts; a missing `agent-types` still runs and prints the first three directions, then exits **1** when `package.json` declares the mirror but it is not installed — a broken install must not retire the repository's only "the day CC declares this name" alarm, and reporting it as a skip would leave "never evaluated" and "evaluated, no drift" indistinguishable to the runner — and exits 3 only when nothing declares the mirror at all, which is the one case where the direction genuinely does not apply. Either way a run that evaluated no witness is never counted as one that did. When the mirror *is* present its **installed version** is witnessed too (the two declared floors must agree with each other and the installed copy must meet them), since four preflight probes are satisfied by an arbitrarily stale mirror — they prove the extractor speaks, not that it is current. Every direction carries a positive control — known-present CC symbols, a comment-only sample proving the extractor distinguishes declaration from mention, and synthetic corpora fed through the **same** discriminator function the real verdict uses, so a verdict quietly rewritten to return nothing takes its own control down with it |
@@ -257,6 +258,8 @@ public-surface guard checks that last one).
257
258
  | `scripts/run-park-decision-layer-test.mjs` | The decision layer behind the "stuck behind a card" family, shared by every client. A pending row that is **not in the queue** is three states, not one: a bounded, interruptible re-probe loop distinguishes *a decidable row*, *not born yet* (no positive evidence that anything settled — an empty queue proves nothing) and *settled elsewhere*, always probes at least once so a zero budget keeps the pre-fix semantics verbatim, cuts a hung read face off at the window rather than only noticing afterwards, and reports the honest failure when the window is spent instead of inventing a decision. The decision-note reader is likewise three-state: an explicit `noteRecorded: false` outranks an echoed note body, absence renders **no line at all**, and untrusted note text is flattened and bounded before it ever reaches a renderer. Row routing anchors on the deciding quantity — a row carrying `gateKind: "human"` with `toolName: "Write"` is a tool gate, because `human` is the engine's *generic* "someone must decide", not a synonym for a question — and the queue scan refuses to surface a row it cannot positively prove belongs to this session. A chain that fails after the row vanished is split by whether a card was ever presented: decided-elsewhere, or not-its-turn-yet. A row-level single-flight makes "at most one card per pending item" structural rather than incidental. The resume three-way card pins the option **order** (the zero-effect choice sits at index 0, because the frame carries no default-focus field and a stray Enter must not attach or cancel), renders only options the wired verbs can honour, collapses every ambiguous answer to zero action, omits the liveness line entirely when the engine gave no evidence, and — when there is no card lane at all — prints three real routes and exits on a dedicated code rather than reporting success |
258
259
  | `scripts/run-selfheal-reopen-test.mjs` | The 409 active-run self-heal decision chain: `governanceForced` narrows on strict `true` only; triage prefers the wire's `pendingGate.kind` and falls back to the status table (an off-table kind is never guessed into a card arm — hands-off plus the honest wording); a first-sight card makes zero closed/reopened claims and a host presentation receipt of `presented: false` demotes the outcome to reopen-failed; park-row ownership is a fail-closed positive proof (own-run ledger or session id — unprovable is not owned); the three gate-identity key literals live in exactly one mint (`hitl/gateIdentity.ts`, AST string-token scan); the armed-gate presentation ledger is per-session; and the `plan_review` reopen arm shares the arm arm's card body, three-state verdict and delivery pipe, consuming the presentation history once a decision is delivered. The same chain also carries the `running` three-way card: both plan-family gate kinds route to the plan arm and all four ask-family kinds to the ask arm (an off-table kind still never gets guessed into either); the card is offered only for verbs that can actually be honoured and a missing presenter means zero action rather than a silent cancel; a steer is sent **exactly once** with its three delivery outcomes worded apart (a `queued` receipt is the wire correcting the triage input, so the named park word decides which card gets reopened, and an unrecognised park word drives neither arm), and a steer failure is split into *provably not delivered* (4xx) and *delivery unknown*, because telling a user to resend a non-idempotent instruction that may already have landed is how duplicates get made. After a user-chosen cancel, "the session is free" is asserted only from a whitelist of terminal states — park states hold the claim, an unrecognised state word is not a release, a failed read is *unknown* rather than a release, and only a 404 counts as one — and the honest timeout line quotes how long it really waited |
259
260
  | `scripts/run-terminal-identity-copy-test.mjs` | Terminal-state **identity**, in both lanes where a stop gets a name. A run stopped by this deployment's own governance knobs — the open-set `limits.*` family, `output.invalid`, and the `blocked` contract terminal a ReportBlocked agent produces — is not a provider failure, and labelling it `API Error:` sends the reader to check the network, the key and the quota when the handle is the `--max-turns` they passed themselves. Those terminals now render a neutral row; the reverse direction is guarded just as hard, because asserting "this is *not* an API error" on a code the package does not recognise is the same misfiling pointed the other way — a real `gateway HTTP 502`, a `conflict.session_active_run` and any unknown code all keep the `API Error:` prefix, and the row keeps its `isApiErrorMessage` class flag so brief-mode visibility filtering does not silently drop it. The second half is who the rejected submission belonged to: the self-heal copy told every caller "Your message was NOT sent … send it again", which is three separate untruths for a system injection (a plan-review outcome, a cron wake-up, a task notification) — not the user's message, and not re-sendable, since a host queue marks those non-editable and non-recallable. The injected form says so instead, and the one sentence that promises re-delivery is pinned to the single disposition that earns it: `selfHealSubmissionDisposition` is the same function the host consults before putting the item back on its queue, so the promise and the behaviour cannot drift apart, and the arms where no card could be surfaced state plainly that nothing was delivered and nothing will retry |
261
+ | `scripts/run-additive-key-passthrough-test.mjs` | The one disease shape behind two legs: a **closed whitelist / flattening arm** dropping a fact that is already on the wire, while both sides of the seam look correct. (1) The `task_progress` projection carries a registered **key ledger** — a frame populated with every key the service really projects is pushed through the shipped `eventToSdkMessage`, and the set of wire keys that survive must equal the registered pass-through list **name for name in both directions**, so quietly forwarding one more key is as red as quietly dropping one. `model` (the child run's model id, minted by core as `prepared.model.id` and projected by the server since 7.52.1) is the key this batch adds, with the same conditional the server itself applies: a non-empty string or no key at all — an empty string is neither a model id nor "unknown". The ledger is also checked against the fenced list in `docs/INTEGRATION-CLIENTS.md` §3d, so a doc that still says seven keys while the code forwards eight is red rather than merely stale. (2) The decide-failure arms carry the server's S-02 `currentPending` pointer key from a 409 `approval_stale` refusal onto the outcome the host reads. The reader is structural rather than `instanceof`, because the client is host-injected and the class identity is not this package's to assume; a half triple never mints (half a pointer cannot relocate anything), an empty string is not presence, and `checkpointToken` never transits. Both the allow and the deny leg are driven end to end through the real durable approval path — as is the accept-session leg, where a refusal carrying the pointer key must now re-raise instead of silently re-sending the human's answer for the **old** card as a plain approve (one decide call, pointer preserved), while a legacy 400 still falls back exactly as before — and all three flattening points must call the one shared reader — the same-shape residue check that makes "fixed one arm and left the twin" red instead of invisible |
262
+ | `scripts/run-esc-halt-plan-test.mjs` | The Esc stop decision every client shares: fire the **turn-level** halt first, and escalate to a **run-level** cancel in exactly two cases — the engine itself answered with a 409 from the closed code set (it is saying "there is no in-flight turn here; use cancel for a run-level stop"), or that shot came back with no verdict at all *and* the shell can independently prove a permission card was on screen. Everything else does not escalate. The asymmetry is the whole point and every negative control guards the same direction — deciding *not* to escalate costs the user one more choice on a busy-session card (recoverable), deciding to escalate wrongly tears down a run that was alive and takes every in-flight tool with it (not). So: the closed code set is a **frozen** value, not a `ReadonlySet` — type-level immutability does not stop a consumer's `.add()`, and the guard proves it by really trying to mutate the exported value and then checking the verdict did not drift; the escalation gate is the **conjunction** of that closed set and the 409 status, since honouring the code alone lets a 500 that merely quotes it drive a destructive call; `interrupt.not_held` and `steering.not_running` are deliberately outside the set (the first means *this replica* has no live face — the run may be perfectly alive on another); an unreadable code falls to the no-escalation side; a `parked` flag never overrides a verdict the engine did give, and only strict `true` counts when it did not. The first shot is unconditional by construction — it does not consult `parked`, because the 409 it earns is exactly the verdict the gate wants — and the verdict itself is a closed machine-readable reason word, not display copy |
260
263
 
261
264
  Each suite carries a floor that only moves up — a refactor that stops executing a group of
262
265
  assertions is a failure, not a quieter pass. Guards anchor on the **installed artefact's content**
@@ -751,6 +751,16 @@ const retryStatusArm = function* (m, { ctx, flags }) {
751
751
  // 七键(#307 S44,2026-08-19):core 5.43.0 起 `BrainStatus` 带 `errClass`(等待原因分桶)。
752
752
  // 剥掉它 = 用户看得到「在等」看不到「为什么等」,而供给方是发了的。
753
753
  ...(typeof m.errClass === 'string' ? { errClass: m.errClass } : {}),
754
+ // ── 九键(0.48.0;core 7.0.x #506 ㋑,server ≥7.53 `brainStatusEventData` 两腿全发)────
755
+ // 🔴 与上面那条 errClass 是**同一个病形的第三、第四例**:上游真发、本层这层白名单闭形剥掉。
756
+ // 族扫真源 = 实装 core 的 `BrainStatus` 整个键集(engine-vocab G2-c 逐键等值门),
757
+ // 不是「派工单点名了哪一个」——本批点名的只有 errorStatus,retryAtMs 是族扫捞出的存量漏键。
758
+ // · `retryAtMs` 产生者铸的墙钟截止点(端渲倒计时的权威锚;剥掉它端只能自己 now+剩余量
759
+ // 现算,而那个算法在每个 30s 重播片上重新起跳 —— core 顶注点名的病)。
760
+ // · `errorStatus` 刚失败那次尝试的 HTTP 状态(CC `system/api_retry.error_status` 同一个数;
761
+ // 剥掉它 = 用户看得到「在重试」却看不到「谁失败了/该不该等」)。
762
+ ...(typeof m.retryAtMs === 'number' ? { retryAtMs: m.retryAtMs } : {}),
763
+ ...(typeof m.errorStatus === 'number' ? { errorStatus: m.errorStatus } : {}),
754
764
  }, ctx.now());
755
765
  // `status === null` = 引擎直报 `recovered`(重试成功)。这一拍是**清除腿之一** —— 但间接腿
756
766
  // (TurnFlags 的「主 lane 内容帧到达即清」)可能已经清过了:已安装 core 在内容流跑完的 finally
@@ -91,24 +91,6 @@ function assertNeverArm(_ev) {
91
91
  * 旧写法 `if (msg)` 在新返回型上恒真(对象永远 truthy),所以这是**必须点名**的一类改动。
92
92
  */
93
93
  export function eventToSdkMessage(ev, ctx) {
94
- // ── `engine_notice` raw 预分派(#310 / #318 件①,server ≥7.36,契约 = ASSISTANT-WIRE-CONTRACT 附录 D)──
95
- //
96
- // 🔴 **为什么是 raw 预分派而不是一条 `case`**(与 `workflow_complete` / `human_input` 当年同因):
97
- // 本臂**还没进已发布 SDK 的 `AgentEvent` union**。sdk 仓 `3d6aebc` 确实加了它,但那个 commit
98
- // **尚未出包** —— 亲验 npm `@sema-agent/sdk@7.2.0`(latest,2026-08-16 发布)的真 tarball:
99
- // `dist/` 全树零 `engine_notice`(而同批的 `FleetTaskRow.cycleSeq` 在,证明抽检会说话)。
100
- // 在这样的 union 上写 `case 'engine_notice'` 是编译错,所以先走预分派。
101
- // 🔴 **这不是「按源码将就接」**(接入文档宪法):消费契约取自 server 的**已发布**接入档
102
- // (ASSISTANT-WIRE-CONTRACT 附录 D,server 7.36+)与 openapi `Event_engine_notice`,不是抄 sdk src。
103
- // 档与实装的失真已如实记账(附录 D.3 仍写「起步白名单三码」,而 server main 的白名单已是六码 ——
104
- // `memory.hold_opened` / `hold_released` / `hold_disposed` 随 core 5.47/5.48 的 `NOTICE_AUDIENCE`
105
- // 入册)。**本层对此完全免疫**:白名单是 server 的投递判定,本层按开集消费,一个码都不硬编。
106
- // 🔴 **到期复核(自退休,不靠人记)**:预分派用 `(ev as {type?:unknown})` 形读判别键,**不收窄** `ev`
107
- // ⇒ 臂一进 union,switch 的 `default` 仍看得见它,B5 穷举断言 `assertNeverArm` **编译期真红**,
108
- // 逼下一棒把它搬进 switch。搬进去时行为一字不改(下面的投影函数原样复用)。
109
- if (ev.type === 'engine_notice') {
110
- return engineNoticeProjection(ev, ctx);
111
- }
112
94
  // ── `text_end` raw 预分派(#323 / core #447,core ≥5.63 / server ≥7.50;契约见 textEndProjection)──
113
95
  //
114
96
  // 🔴 **为什么是 raw 预分派而不是一条 `case`**(与 `engine_notice` 当年逐字同因):本臂**还没进
@@ -348,13 +330,29 @@ export function eventToSdkMessage(ev, ctx) {
348
330
  // 键集真源 = retryStatus.ts 的 `BRAIN_STATUS_PAYLOAD_KEYS`(engine-vocab G2-c 对账)。
349
331
  // #307 S44(2026-08-19):core 5.43.0 又加了 `errClass`(这次等待的**原因分桶**,
350
332
  // provider 中立闭集)—— SDK 的 `status` 臂类型同样还没跟,同款 unknown 读 + 串窄化透传。
351
- const st = ev;
333
+ // 🔴 **四键的 cast 已退(0.48.0)**:上面那段说的「SDK 的 `status` 臂类型只声明
334
+ // phase/detail/retryInSec」在 sdk 7.4.0 上**不再成立** —— 该臂现已声明 **7 键**
335
+ // (phase/detail/retryInSec/retryInMs/attempt/maxRetries/errClass,`dist/events.d.ts` 直读)。
336
+ // peer 地板同批抬到 >=7.4.0 ⇒ attempt/maxRetries/retryInMs/errClass 四位改类型面直读。
337
+ // 退役的是 **cast**,不是运行期窄化:`num()` / 非空串门全部保留(wire 是 JSON,旧 server
338
+ // 仍可能发别的形;类型声明是上游承诺,不是本层前提)。
339
+ // 🔴 **两位仍走结构视图读**(0.48.0 新增,**同形第三、第四例**):core 7.0.x #506 ㋑ 起
340
+ // `BrainStatus` 还带 `retryAtMs`(产生者铸的墙钟截止点)与 `errorStatus`(刚失败那次尝试的
341
+ // HTTP 状态),server ≥7.53 的 `brainStatusEventData` **两键全发**(7.54.0 dist 真字节直证),
342
+ // 而 **sdk 7.4.0 的 `status` 臂尚未声明这两位** —— 与 `model`(#363)/`requiresRealApproval`
343
+ // (#283)/`ruleOffers`(#334)同形:server 已真发、SDK 锚未跟。照那几例的先例**先追 server
344
+ // 不等 SDK**,类型面缺席不等于 wire 上缺席。
345
+ // 📋 **退役条件**:sdk 把这两位补进 `status` 臂声明的那一批,把它们并进上面的类型面直读、
346
+ // 删掉本段的 `st2` 结构视图(与本批刚兑现的四键退役逐字同一条路)。
347
+ const st2 = ev;
352
348
  const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
353
- const attempt = num(st.attempt);
354
- const maxRetries = num(st.maxRetries);
355
- const retryInMs = num(st.retryInMs);
349
+ const attempt = num(ev.attempt);
350
+ const maxRetries = num(ev.maxRetries);
351
+ const retryInMs = num(ev.retryInMs);
352
+ const retryAtMs = num(st2.retryAtMs);
353
+ const errorStatus = num(st2.errorStatus);
356
354
  // 非空串才透传(空串既不是桶也不是「不知道」,只会在下游被渲成一个空的原因)。
357
- const errClass = typeof st.errClass === 'string' && st.errClass.length > 0 ? st.errClass : undefined;
355
+ const errClass = typeof ev.errClass === 'string' && ev.errClass.length > 0 ? ev.errClass : undefined;
358
356
  return projected(stamp(ctx, armBody({
359
357
  type: 'retry_status',
360
358
  phase: ev.phase,
@@ -364,6 +362,14 @@ export function eventToSdkMessage(ev, ctx) {
364
362
  ...(attempt !== undefined ? { attempt } : {}),
365
363
  ...(maxRetries !== undefined ? { maxRetries } : {}),
366
364
  ...(errClass !== undefined ? { errClass } : {}),
365
+ // ── 0.48.0 跟车两位(core 7.0.x #506 ㋑ / server ≥7.53 两腿全发)────────────────────
366
+ // 🔴 **这一处是族扫的第二个坐标,不是第一个**:本批的病形(上游真发、闭形白名单剥掉)在
367
+ // `status` 这条腿上有**两层**白名单 —— 本层(wire → 内部 retry_status 臂)与
368
+ // `adapt/arms.ts` 的 `retryStatusArm`(内部臂 → RetryStatus)。只修其中一层,键仍到不了
369
+ // 宿主([same-shape-residue-constitution]:只修当格 = 违宪)。两层同批修,门 G4 驱的是
370
+ // **全链**(adapt 入口 → chrome 事件),所以漏任一层它都红。
371
+ ...(retryAtMs !== undefined ? { retryAtMs } : {}),
372
+ ...(errorStatus !== undefined ? { errorStatus } : {}),
367
373
  // 🔴 §E2 lane 身份必须透传(2026-08-08 对抗复审二轮复审命中的**跨 lane 状态破坏**)。
368
374
  // `status` 臂本来就是 `& EventIdentity`(SDK events.d.ts),server 两腿共用的
369
375
  // `brainStatusEventData` 也经 `identityFields` 发 eventId/parentToolCallId —— 而本层此前
@@ -382,9 +388,34 @@ export function eventToSdkMessage(ev, ctx) {
382
388
  // NEUTRAL internal `task_progress` arm — NOT a renderable CC SDKMessage. The upstream bridge binds it
383
389
  // to the open Task/Agent tool_use card and synthesizes CC-native `progress` messages so CC's own
384
390
  // Task-card rollup (tool uses / tokens climbing) animates for an ENGINE-side subagent exactly like a
385
- // local AgentTool one. Fields are read DEFENSIVELY: the service wire whitelist (server.ts:1334-1342)
386
- // strips `status` + EventIdentity and may omit `name` — mirror what actually rides the wire, not the
387
- // richer core-side arm.
391
+ // local AgentTool one. Fields are read DEFENSIVELY: the service wire whitelist may omit `name` —
392
+ // mirror what actually rides the wire, not the richer core-side arm.
393
+ // 🔴 **就地订正**(#363,0.47.0,真字节实证):本句原文写的是「the service wire whitelist
394
+ // (server.ts:1334-1342) strips `status` + EventIdentity」—— 那是**错的**,而且方向正好相反:
395
+ // server 7.52.1 的 `taskProgressEventData` **发** `status`,`identityFields` 也**发**
396
+ // `eventId` + `parentToolCallId`(见下面的族扫账)。剥掉它们的是**本层**,不是 service。
397
+ // 这句错话正是「两边代码看着都对」的一部分:它把本层自己的白名单说成了上游的缺席。
398
+ //
399
+ // ── 🔴 本臂白名单的**族扫账**(#363,0.47.0;真源 = server 7.52.1 `trace/project.js` 的
400
+ // `taskProgressEventData` + `identityFields` 真字节,不是推断)────────────────────────────
401
+ // server 投影**发** 13 键:taskId · seq · taskType · parentTaskId · name · model · status ·
402
+ // usage · currentAction · workflowRunId · workflowAgentLabel · eventId · parentToolCallId。
403
+ // 本臂**转** 8 键:taskId · name · usage · model · currentAction · workflowRunId ·
404
+ // workflowAgentLabel · parentToolCallId。
405
+ // ⇒ **仍被本层剥掉的 5 键**(逐条登记,刻意不静默多透 —— 每一位都要有消费方与语义才配上臂):
406
+ // · `taskType` / `status` / `parentTaskId` —— 早已在册的 §7 缺口 **P-1**(lane 归属今天改用
407
+ // id 形状 / `workflowRunId` 启发式判,见 `src/adapt/arms.ts`);
408
+ // · `seq` —— core #258 的 stop-cycle 代际号(复活即 +1)。fleet 面已有同轴的
409
+ // `wireCycleSeq`(0.38.0),tick 这条腿今天**没有**消费方。
410
+ // ⚠️ **就地订正(0.48.0)**:本条 0.47.0 的原文写着「SDK 7.3.0 的 `task_progress` 臂连声明
411
+ // 都没有」—— sdk **7.4.0 已声明** `seq?: number`(与 `model` 同批补上,JSDoc 逐字点名
412
+ // core 5.36.0 #258 两铸点)。⇒ **剥它的理由换了一条,但仍然剥**:剥的判据从来不是「SDK 没
413
+ // 声明」(那只是当时顺带成立的第二个事实),而是本白名单的准入条件 ——「说得出谁读它、读来
414
+ // 干什么」。今天这条腿的答案仍是零消费方 ⇒ 照旧不透,P-1 族账不动。声明到货**不是**透传的
415
+ // 理由,否则这张白名单会随上游类型面自动变宽,准入条件形同虚设。
416
+ // · `eventId` —— EventIdentity 的另一半。`status` 臂 2026-08-08 已因跨 lane 状态破坏补齐两键,
417
+ // 本臂只补了 `parentToolCallId`(lane 判据要它),`eventId` 至今无消费方。
418
+ // 上面三档的共同纪律:要透哪一位,先说得出**谁读它、读来干什么**,并同批更新 §3d 的键级剥离账。
388
419
  case 'task_progress': {
389
420
  if (typeof ev.taskId !== 'string')
390
421
  return dropped('malformed', 'task_progress');
@@ -400,11 +431,32 @@ export function eventToSdkMessage(ev, ctx) {
400
431
  // 仍剥这两键(server a76bd77 才修,[1619])——真 wire 于 1.273+ 才带,此处先备好通路。
401
432
  const workflowRunId = ev.workflowRunId;
402
433
  const workflowAgentLabel = ev.workflowAgentLabel;
434
+ // #363(0.47.0,提货批取证定谳)— `model`:这条 tick 所属子 run 的**模型 id**。
435
+ // 🔴 上游真字节(不是推断):server 7.52.1 `dist/trace/project.js` 的
436
+ // `taskProgressEventData` 白名单里逐字有
437
+ // `...(typeof ev.model === "string" && ev.model.length > 0 ? { model: ev.model } : {})`,
438
+ // 三条腿(bg runs.ts / resume append / 同步 live SSE)共用它;core 铸点 = `prepared.model.id`。
439
+ // cli 侧 live 采帧同批实证过一条真帧带 `"model":"deepseek-v4-flash"`。
440
+ // 🔴 病形与 `workflowRunId` **同形第二例**([1616]/[1617]②):这层白名单是闭形的,于是一条
441
+ // **已经在 wire 上**的事实到不了宿主,而两边代码看着都对(「送到了但没人读」的精确坐标
442
+ // 其实是「没转」)。additive 透传,旧 server 缺席 ⇒ 键不 stamp,形状逐字节不变。
443
+ // 🔴 **cast 已退(0.48.0,退役条件兑现)**:0.47.0 这一行是 `const model: unknown =
444
+ // (ev as { model?: unknown }).model`,理由 = sdk 7.3.0 的 `task_progress` 臂尚未声明这一位
445
+ // (与 `requiresRealApproval` #283 / `ruleOffers` #334 同形:**server 已真发、SDK 锚未跟**),
446
+ // 头注同批写死了退役条件「SDK 补上当天这处 cast 可整条删掉」。sdk **7.4.0 已声明**
447
+ // `model?: string`(`dist/events.d.ts` task_progress 臂,JSDoc 逐字点名 server ≥7.53 /
448
+ // core 7.0.1 两处无条件铸点)+ 本包 peer 地板同批抬到 `>=7.4.0` ⇒ 按条款兑现,改类型面直读。
449
+ // 退役的是 **cast**,不是运行期判:`typeof` 门保留(旧 server 缺席 ⇒ 键不 stamp;
450
+ // wire 是 JSON,类型声明是上游承诺不是本层前提)。
451
+ // 🔴 条件 spread 与 server 投影**同形同条件**:`string` 且非空才带。空串既不是模型 id 也不是
452
+ // 「不知道」,透下去只会在面板上渲成一个空模型名([honest-absence-not-fabricated-zero])。
453
+ const model = ev.model;
403
454
  return projected(stamp(ctx, armBody({
404
455
  type: 'task_progress',
405
456
  taskId: ev.taskId,
406
457
  ...(ev.name !== undefined ? { name: ev.name } : {}),
407
458
  ...(ev.usage !== undefined ? { usage: ev.usage } : {}),
459
+ ...(typeof model === 'string' && model.length > 0 ? { model } : {}),
408
460
  ...(typeof currentAction === 'string' && currentAction.length > 0
409
461
  ? { currentAction }
410
462
  : {}),
@@ -618,6 +670,24 @@ export function eventToSdkMessage(ev, ctx) {
618
670
  */
619
671
  case 'approval_request':
620
672
  return dropped('unsupported_arm', 'approval_request');
673
+ // ── `engine_notice`(#310 / #318 件①,server ≥7.36,契约 = ASSISTANT-WIRE-CONTRACT 附录 D)────
674
+ // 🔴 **到期复核已兑现(sdk 7.4.0 提货,0.48.0)** —— 与 `human_input`(sdk 6.9.0)逐字同一条路。
675
+ // 本臂此前是 switch **之前**的一条 raw 预分派,理由 = 它还没进已发布 SDK 的 `AgentEvent`
676
+ // union(亲验 7.2.0 真 tarball `dist/` 全树零 `engine_notice`)。sdk **7.4.0 已声明该臂**
677
+ // (`dist/events.d.ts`:`{type:"engine_notice"; code; message; detail; sessionId; ts}`,
678
+ // 五键**全必填**、无 `& EventIdentity`)⇒ 按原定的自退休条款搬进 switch。
679
+ // 这不是可选的整理:预分派用的是 `(ev as {type?:unknown})` 形、**不收窄** `ev`,所以臂一进
680
+ // union,`default` 就还看得见它 ⇒ B5 穷举断言 `assertNeverArm` **编译期真红**(本批实翻:
681
+ // devDep 抬到 7.4.0 的当拍 `tsc` 就是这一条错)。搬进来后本函数重回「每一条臂都受编译期
682
+ // 穷举保护」,行为**一字不改**(下面的投影函数原样复用,连入参的 raw 视图形都不动)。
683
+ // 🔴 **投影仍走 raw `Record` 视图,不改吃 SDK 收窄形**(刻意,不是省事):SDK 的臂声明把五键
684
+ // 记成**全必填**,而本层对每一键都做诚实缺席处理(`code` 空 ⇒ malformed;`message`/`detail`
685
+ // 坏 ⇒ 降级不丢帧;`sessionId`/`ts` 非法 ⇒ 不 stamp),这些分支在收窄形上会被类型面判成
686
+ // 死码而**静默失效** —— wire 是 JSON,必填是 server 的承诺不是本层的前提
687
+ // ([jsdoc-untrusted-verify-implementation])。且 `eventId` / `id`(→ `eventSeq`)两个重放
688
+ // 身份键**根本不在** SDK 臂声明里(SDK 从 SSE `id:` stamp),收窄形上读它们是编译错。
689
+ case 'engine_notice':
690
+ return engineNoticeProjection(ev, ctx);
621
691
  default:
622
692
  // B5 穷举:SDK union 加成员必须在**编译期**打红这一行(而不是让新臂在用户面静默丢帧)。
623
693
  assertNeverArm(ev);
@@ -211,10 +211,31 @@ export interface GateLedger {
211
211
  * 「已解决」,吃满 `MAX_GATE_HOPS` 才吐一句 `gate hop limit exceeded` —— 用户白等 24 轮往返,
212
212
  * 拿到的还是一句与真因无关的话(真因被预算话术顶掉了)。
213
213
  * 消费一次即失效之后:第一次(= 真的重放)照旧救回,第二次就是诚实的 fail-soft。
214
+ *
215
+ * ✅ **同形存量已销**(#357,0.47.0):上面这段描述的病形在**身份判据**这一腿由本动词的一次性
216
+ * 语义关掉,但它的**孪生腿**(`isAlreadyResolvedFailure` 的 code/文案判据)当时没有任何限次 ——
217
+ * 那一腿在「run 仍 parked 而取件读到空表」的坐标失配态下每轮都命中,照样烧满 24 个 hop。
218
+ * 闸坐标 = {@link noteAlreadyResolvedGate} + `parkResolver.MAX_ALREADY_RESOLVED_REATTACHES`
219
+ * (同因连续第 2 次即不再 reattach),主修 = `parkResolver` 的坐标重探臂。
214
220
  */
215
221
  takeDecided(callId: string): boolean;
216
222
  /** 只给 debug 串用的规模位(`[decided so far: N]`)。 */
217
223
  decidedCount(): number;
224
+ /**
225
+ * 记一次「这张 park 被判成**已解决**」的命中,返回**同一判据源**的连续命中次数(第一次 = 1)。
226
+ * 判据源换了(`key` 不等)⇒ 从 1 重新计。
227
+ *
228
+ * 🔴 为什么闸态放在台账而不是 `resolvePark` 里:`resolvePark` 是**无实例态的纯函数**,而这条闸
229
+ * 要跨多次 park 才成立 —— 与 `hops` 同源(那也是驱动侧跨 park 累加、经 `ParkResolveCtx` 传进去
230
+ * 的量)。台账是本 turn 里唯一活得比一次 park 长、且**只有一个写者**的载体。
231
+ *
232
+ * 🔴 `key` 必须是**归一化的判据源**(命中它的那个码 / 那个文案子串),不是原始 reason 串 ——
233
+ * reason 里带着每轮都不一样的尾巴(`decide failed: Error: …`),拿它当键会让「连续」永远不成立,
234
+ * 闸当场退化成空转([anchor-on-the-deciding-quantity])。
235
+ */
236
+ noteAlreadyResolvedGate(key: string): number;
237
+ /** 判据链上出现了真进展(park 真被决断 / 重探把新坐标的卡呈出去了)⇒ 连续计数归零。 */
238
+ resetAlreadyResolvedGate(): void;
218
239
  }
219
240
  /** 造一份 turn 级 gate 台账(**不是单例**,见文件头注)。 */
220
241
  export declare function createGateLedger(): GateLedger;
@@ -20,6 +20,9 @@ export function createGateLedger() {
20
20
  const pendingFsCalls = [];
21
21
  const resolvedAnswers = new Map();
22
22
  const decidedGates = new Set();
23
+ /** 「已解决」判据的**同因连续命中**闸(#357):判据源 + 连续次数,见 `noteAlreadyResolvedGate`。 */
24
+ let alreadyResolvedKey;
25
+ let alreadyResolvedStreak = 0;
23
26
  // ── 批级判据(四样同生共死,退休点见 `retireBatchState` 的两处调用)────────────────────────
24
27
  /** 本批 park 属 fs/shell 族的准入证(唯一性兜底的前置)。 */
25
28
  let fsOrShellFamilyGate = false;
@@ -266,5 +269,19 @@ export function createGateLedger() {
266
269
  decidedCount() {
267
270
  return decidedGates.size;
268
271
  },
272
+ noteAlreadyResolvedGate(key) {
273
+ if (key === alreadyResolvedKey) {
274
+ alreadyResolvedStreak += 1;
275
+ }
276
+ else {
277
+ alreadyResolvedKey = key;
278
+ alreadyResolvedStreak = 1;
279
+ }
280
+ return alreadyResolvedStreak;
281
+ },
282
+ resetAlreadyResolvedGate() {
283
+ alreadyResolvedKey = undefined;
284
+ alreadyResolvedStreak = 0;
285
+ },
269
286
  };
270
287
  }