@sema-agent/client-core 0.47.0 → 0.49.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
@@ -13,6 +13,11 @@
13
13
  > 不在账上,都红。**发包批的义务** = 往 `FROZEN` **追加**一行(版本号 + 发布 commit + 段 sha256),
14
14
  > **绝不改写既有行** —— 既有行是「那一版当时发了什么」的历史,不是可变配置。
15
15
  >
16
+ > 🔴 **阶段一段头义务(0.48.0 勘误后写死,防第三例)**:发布 commit(阶段一)必须**同批**把
17
+ > `## <version>(未发布)` 段头转成日期形 `## <version>(YYYY-MM-DD)`(0.47.0 先例)。发布后该段
18
+ > 字节即被 ④ 门逐字锁定,漏转**不可回改**、只能永久勘误 —— 0.36.0 与 0.48.0 两条勘误的共同根因
19
+ > 就是这一步没做;此前头注只写了「未发的进 `(未发布)` 段」而没写「发布时转正」,歧义已由本句消除。
20
+ >
16
21
  > 🔴 **互链**(web [C166]⑦):各版「已知局限」段只记**该版新增**;接入面已知局限的完整台账在
17
22
  > `docs/INTEGRATION-CLIENTS.md` §6e/§7 —— **只读其一会漏**,两处都过。
18
23
  >
@@ -27,6 +32,156 @@
27
32
  > 都会重新对这个永久冻结的标题判红,退休条件不可能被满足)。门侧核验两件事把这条勘误钉死、
28
33
  > 不许悄悄漂:豁免登记的 `releasedAt` 与 `FROZEN` 账上 0.36.0 那一行逐字相等;本段(点名版本号
29
34
  > `0.36.0` + 关键字「勘误」)必须还在这份头注里 —— 删掉本段而不同批把门侧豁免一起处理,门当场红。
35
+ >
36
+ > 🔴 **勘误(0.48.0 发包批,2026-09-01,永久记录 —— 同案第二例,机理同上)**:`## 0.48.0` 段标题的
37
+ > `(未发布)` 是发布批的操作失误 —— 0.48.0 **已经发布**(releasedAt=npm gitHead `4fae01b`,registry
38
+ > 在架),阶段一 commit 应当把段头转正为日期形(0.47.0 先例)而没转,主收货复核也漏了这一格,
39
+ > publish 后才发现。该段字节已被 ④a 逐字锁定在 `4fae01b` 上 ⇒ 标题**不回改、且永远不会被改**;
40
+ > 门侧窄豁免同批登记(`KNOWN_HEADING_ERRATA` `version: '0.48.0', releasedAt: '4fae01b'`),
41
+ > 与本段互钉,均为永久记录。
42
+
43
+ ## 0.49.0(2026-09-03)
44
+
45
+ ### 新增:崩溃收敛的孤儿审批读面(L-38;server ≥7.55.0)
46
+
47
+ `GET /v1/approvals` 的 additive 键 `crashConverged` —— local 引擎在人还挂在审批门上时崩掉,
48
+ 那些孤儿 ask 被 server 重启后收敛成 DENIED 同码。本版把「上一条命留下了什么」做成三端公共读面。
49
+
50
+ **新增导出**(1 个运行期符号,additive;公面 794 → **795**)
51
+ - `projectCrashConverged(env: unknown): CrashConvergedProjection | undefined` —— 纯投影,零 IO / 零文案 / 零 module 级状态
52
+ - 类型(type-only,不进运行期基线):`CrashConvergedRow` / `CrashConvergedOrphanState` /
53
+ `ApprovalsListEnvelope` / `CrashConvergedProjection`
54
+
55
+ **类型面 additive 放宽**
56
+ - `ApprovalsResourceLike.list()` 返回位:匿名 `{ pending }` → 具名 `ApprovalsListEnvelope`
57
+ (`pending` + additive `livePending?` / `crashConverged?`)。老形 `{ pending }` 的 mock 与真
58
+ `AgentClient.approvals` 都仍可赋值,`hitlBridge` / `approvalsFeed` 两处 `.pending` 消费点零改动。
59
+ 入参收 `unknown` 而非「带可选 `crashConverged` 的对象」:后者是 TS 弱类型,SDK 自己的
60
+ `list()` 回体与它无共同属性 ⇒ TS2559。
61
+
62
+ **三条判定纪律**(端接线前必读,详见 `docs/INTEGRATION-CLIENTS.md` §12)
63
+ 1. **缺席 ≠ 空数组**:键缺席 / 载体不是数组 / 载体读不出 / 行数超上限 ⇒ `undefined`(端零渲染,
64
+ 绝不渲「0 个」);空数组 ⇒ 在场的零计数对象(server 明说「一条都没有」)。
65
+ 2. **进 resumeSafe 是五项合取**:`orphanState==='pending'` ∧ `resumeSafe===true` ∧
66
+ `originalDecision` 缺席 ∧ `decidedAtMs` 缺席 ∧ 行不带 accessor / 自定义原型。
67
+ decided 臂与一切说不清的组合恒落 `needsHuman`(**落桶不丢**)。
68
+ 3. **坏行按类型面丢弃并计数**,`dropped` 与 `total` 分账、坏行不连坐;退化但合型的值
69
+ (`ts:NaN` / 空 `toolName`)不丢 —— 唯一越过类型面的严格是行身份 `approvalId` 非空。
70
+
71
+ **caveat**:`resumeSafe` 是以账本完整为前提的**缺省值不是铁证**,文案只许写「按记录看可以重跑」;
72
+ `crashConverged` 键在场 ≠ 流内协议上场,别当能力位读。
73
+
74
+ **新增常驻门**:`scripts/run-crash-converged-projection-test.mjs`(118 checks)。
75
+
76
+ **已知边界**(§12e 成文):真供给 = server JSON 经 `JSON.parse`。被中间层合成的非 JSON 载荷 /
77
+ 敌意 `Proxy` / 原型注射不在射程内 —— 只承诺不抛、不同步阻塞、绝不产出「可安全重跑」判决。
78
+
79
+ **已知局限**:壳侧接点(`--resume` 后的一行披露)不在本版,cli 下一批认领。
80
+
81
+ ## 0.48.0(未发布)
82
+
83
+ 五件 additive 投影/上收批(0.48.0 提货窗:sdk **7.4.0** / server **7.54.0** / core **7.1.0**)。
84
+ 🔴 **唯一的非 additive 面 = peer 地板抬到 `@sema-agent/sdk >=7.4.0`**(见文末「地板影响面账」);
85
+ 既有键与文案逐字节不变,新增 **4** 个运行期导出(公开导出面 790 → **794**)。
86
+
87
+ - **件① `task_progress` 的 `model` cast 退役 + `GateCurrentPending` 单源化**。
88
+ 0.47.0 那两处各自写死了退役条件,本批**按条款兑现**,不是顺手整理:
89
+ - `eventToSdkMessage` 的 `const model: unknown = (ev as {model?:unknown}).model` 整条删,改类型面
90
+ 直读 —— sdk 7.4.0 已声明 `task_progress.model?: string`。**退役的是 cast,不是运行期判**:
91
+ `typeof` 门保留(旧 server 缺席 ⇒ 键不 stamp),端侧行为逐字节不变。
92
+ - `GateCurrentPending` 由**自铸 interface** 改成上游形的**别名**,自铸记账段整段删。
93
+ ⚠️ **绕道取形,原委成文**:`ApprovalStaleCurrentPending` 在 sdk 7.4.0 里
94
+ `dist/types.d.ts` 有 `export interface`,但 `dist/index.d.ts` 的 re-export 名单里**没有它**,
95
+ 且包 exports 映射只开 `.` / `./registry` ⇒ **按名 import 不通**(本批亲验)。故用
96
+ `Readonly<NonNullable<ApprovalStaleError['currentPending']>>` 取**同一个声明**,单源化目的一字不打折。
97
+ 🔴 **`Readonly<>` 不是装饰**:0.47.0 自铸形三位全 `readonly`(它们是 D-1 坐标,契约=**逐字回显、
98
+ 绝不本地重算**),而上游形的成员是**可写**的 —— 裸别名会把这条已发布的类型契约悄悄弱一档,
99
+ 且**没有任何行为门看得见**(不改变任何运行期字节)。同批铸编译期钉
100
+ `AllReadonly<GateCurrentPending>`(新 `src/typePins.ts` 原语),摘掉 `Readonly<>` 当场编译错。
101
+ 📋 上游候件:请 sdk 把该名字加进 `index.d.ts` 的 `export type {…}`(纯 additive,零行为)。
102
+ - `seq` **仍然剥**:sdk 7.4.0 虽已声明它,但本层白名单的准入条件是「说得出谁读它」,答案仍是
103
+ 零消费方。🔴 **声明到货不是透传的理由** —— 否则白名单会随上游类型面自动变宽。
104
+
105
+ - **件② durable park 行的 bidi 披露位 `hasBidiControls` 随卡透传**(S-30①,server ≥7.53 / core 5.60.0 #438)。
106
+ `PendingCheckpoint.hasBidiControls` → `ApprovalCardRequest.hasBidiControls`。病形与 [C170] 那次
107
+ `ruleSuggestions`/`governanceForced` **逐字同一个坐标**:「行 → 卡」重铸处闭形,行上新键整键丢失。
108
+ 🔴 落**新键**,**绝不并进活卡腿的 `inputHasBidi`**:两者同语义、**不同源、不同算点**
109
+ (前者 core 在 park mint 时算并反范式成 durable 列、server 读列不重算;后者 server 对帧自身序列化后的
110
+ args 现算)⇒ 同一只 ask 两条腿的在场性**可以不一致**,合流就是拿一个量冒充另一个。
111
+ 只认严格 `true`;缺席 = **没检出**,禁读成「已确认干净」。
112
+ ⭕ **另两条上游读面如实记零施工**:`ApprovalStreamEvent.hasBidiControls`(本包 feed 只把 stream
113
+ 当「变了」信号,权威列表恒来自 `list()`,该帧不经任何投影)与 `InboxRow.hasBidiControls`
114
+ (本包**没有** inbox 投影面,全仓零 `InboxRow` 引用)。
115
+
116
+ - **件③ `engine_notice` 的 raw 预分派退役 + server 7.54.0 两新码**(`task.halt_unconsumed` / `task.late_approval`)。
117
+ 🔴 **两新码的投影施工量 = 零,而这不是偷懒**:本臂按开集消费(库里一个码都不硬编),新码**按构造**
118
+ 就到得了端。真正的施工是另一件:sdk 7.4.0 已声明 `engine_notice` 臂 ⇒ 0.47.0 留的那颗**自退休钉
119
+ 本批真的响了**(devDep 抬版当拍 `tsc` 就报 `assertNeverArm` 收不下这条臂),按原定条款把 raw 预分派
120
+ 搬进 `case`,**行为一字不改**,本函数重回「每一条臂都受编译期穷举保护」。
121
+ 🔴 投影仍走 raw `Record` 视图**刻意不改吃 SDK 收窄形**:SDK 把五键记成全必填,而本层对每一键都做
122
+ 诚实缺席处理,这些分支在收窄形上会被判成死码而**静默失效**;且两个重放身份键(`eventId`/`eventSeq`)
123
+ 根本不在 SDK 臂声明里。⚠️ `text_end` 的预分派**照旧保留**(sdk 7.4.0 全树仍零命中,退役条件未满足)。
124
+
125
+ - **件④ `status`(BrainStatus)臂补 `errorStatus` **与** `retryAtMs` 两键**(core 7.0.x #506 ㋑,server ≥7.53)。
126
+ 🔴 **族扫的产物,不是只补被点名的那一个**:本批派工只点名 `errorStatus`;族扫 = 把**实装 core** 的
127
+ `BrainStatus` 整个键集与本包镜像逐一对表,当场捞出**存量**漏键 `retryAtMs`(同批进 core、同在 server
128
+ `brainStatusEventData` 白名单里真发,只是没人提)。镜像 7 键 → **9 键**。
129
+ 🔴 **同形存量清剿:这条腿上有两层白名单,两层同批修** —— `eventToSdkMessage` 的 `case 'status'`
130
+ 与 `adapt/arms.ts` 的 `retryStatusArm`;只修一层键仍到不了宿主(常驻门 G4 驱**两段合成**的全链,
131
+ 漏任一层都红)。同批**顺手退役**该臂 attempt/maxRetries/retryInMs/errClass 四位的 cast(sdk 7.4.0
132
+ 已声明这 7 键);`retryAtMs`/`errorStatus` 两位 SDK 锚仍未跟 ⇒ 保结构视图读 + 登记退役条件。
133
+ 🔴 **`deadline` 的语义与字节一字未改**:`retryAtMs` 是**产生者铸**的墙钟截止点,端在场时应优先用它
134
+ (本包现算的 `deadline` 已被跨进程跳的传输耗时推后,且在 core 每 30s 的重播片上会重新起跳);
135
+ 两位**并存**,端自己选 —— 换算法 = 一次静默的行为改动,本包不做(门 G4c 是这条方向钉的反钉)。
136
+
137
+ - **件⑤ `sessions.memoryStatus` 的三端公共读面**(S-53;新文件 `src/sessionMemoryStatus.ts`,+4 导出)。
138
+ 收在库里的理由是**两处判定**,三端各写一遍必然各错一遍:
139
+ ① **同 status 不同码** —— 本路由的 404 有两个互不相干的含义(`not_found.session` 会话未知/非属主 /
140
+ `not_found.route` <7.53 老 server 无此路由),按 **status** 分诊必然把「你的部署没这个面」说成
141
+ 「你这个会话不存在」。判据锚 `errorCode`。🔴 **无码的 404 落 `failed`(如实说判不出),绝不猜** ——
142
+ 猜错任一向都是一句用户会照着去排错的假话;501 才允许无码兜底(本路由两条 501 臂同义,无歧义)。
143
+ `capability.*` 与 `feature.*` **分列不合流**(同为 501 而处置相反)。
144
+ ② **五键缺席语义逐键不同** —— `optOutSource`/`lastCaptureAt` 在健康会话上就合法缺席,零历史会话真形 =
145
+ `{captureOptedOut:false, committedCount:0, foldedCount:0}`(**无任何降级**)。两个合读器:
146
+ `readCaptureOptOut`(两键合读,fault ⇒ `indeterminate`,绝不读成「记忆开着」)与 `readLastCapture`
147
+ (**三态**:单读 `lastCaptureAt` 判不出任何东西,判别材料是**另一键** `committedCount`)。
148
+ 纯判定 + 薄封装,IO 归宿主注入(`MemoryStatusClientLike`,与 `HitlClientLike` 同款 duck-type);
149
+ 永不抛;畸形键降缺席不采信;**空 `sessionId` 不发请求**。
150
+ 🔴 **身份绑定**:回声 `sessionId` 必须与请求值**逐字相等**,否则整只落 `failed` —— 收下不相等的
151
+ 回声 = 把**另一条会话**的记忆元数据呈现在当前会话面板上(上游同一件事的纪律是「宁缺席不串台」)。
152
+ 🔴 **矛盾形不产出确定判决**:capture 轴按**完整真值表**判(只有 `true`×`record` 与 `false`×缺席
153
+ 两格有定义,其余含 `false`×`record` / `true`×缺席 一律 `indeterminate`);lastCapture 的 `known`
154
+ 是**合取**(时刻在场 ∧ `committedCount` 是数 ∧ >0)。这些矛盾载荷版本斜差 / 畸形 200 体 / 中间层
155
+ 改写都造得出,而端拿到确定值之后不会再问第二遍 —— capture 轴尤其是**隐私姿态**断言,两个方向都危险。
156
+ 🔴 **「永不抛」成功路与失败路都设防**:归一化整段在 `try` 内;`classifyMemoryStatusFailure`
157
+ **自己**取属性也带保护 —— 它是在 `catch` **块内**被调用的,`catch` 里抛出的异常不会再被同一个
158
+ `try` 接住,分类器一抛就击穿这句承诺(同形第二处)。带抛错 getter 的对象 / 敌意 `Proxy` 作**返回体**
159
+ 或作**拒因**都落 `failed`,不向外 reject。
160
+ 🔴 **端必读**:`sessionId` 必须取**引擎捕获值** —— server 侧记录/台账按**裸 sessionId** 键控且
161
+ **活过会话**,喂一个被回收的 id 会读到**上一代**的计数/opt-out(元数据,无内容字节)。
162
+
163
+ **新增导出(4)**:`readSessionMemoryStatus` / `classifyMemoryStatusFailure` / `readCaptureOptOut` /
164
+ `readLastCapture`。装 ≤0.47.0 的端按名 import 会在 ESM 实例化当场炸 —— 提货前先抬依赖。
165
+
166
+ **门**:36 套全绿(新增 1 套 `run-session-memory-status-test.mjs` **53** checks);
167
+ `additive-key-passthrough` 45 → **73** checks(新增 G3 engine_notice 开集消费 + G4 BrainStatus 两新键);
168
+ `durable-card-display-keys` 43 → **54** checks(新增 ⑪ 段 hasBidiControls 正负控);
169
+ 登记物:导出面 790 → **794** / gates 35 → **36** / portability 138 → **139** / typeshape unknown 258 → **260** /
170
+ pure BrainStatus 镜像 7 → **9** 键。
171
+
172
+ **🔴 peer 地板抬版影响面账(`@sema-agent/sdk` `>=7.2.0` → `>=7.4.0`)**:
173
+ - **为什么必须抬**(四条,任一条单独都足以钉死地板,不是顺手跟版本):① `AgentEvent.engine_notice`
174
+ 臂(<7.4.0 上 `case 'engine_notice'` 是编译错);② `task_progress.model`(cast 退役后 `ev.model`
175
+ 在旧版是编译错);③ `PendingCheckpoint.hasBidiControls`(件② 读的就是这一位);
176
+ ④ `sessions.memoryStatus` + `SessionMemoryStatus`(件⑤ 直接 `import type`)。
177
+ - **消费方要跟的**:三端装 <7.4.0 的 SDK 会看到 peer 警告;本包**运行期**不因此变化。
178
+ - **随之退役的自铸形**:`GateCurrentPending`(自铸 interface → 上游形别名)。
179
+ - **未退役、如实记**:`text_end` 的 raw 预分派(sdk 7.4.0 仍零命中);`status` 臂的
180
+ `retryAtMs`/`errorStatus` 两位仍走结构视图读(SDK 锚未跟);`ApprovalStaleCurrentPending`
181
+ 按名 import 仍不通(上游 re-export 缺口,已登记候件)。
182
+ - **core devDep 5.57.0 → 7.1.0 是 devDep-only**(core 不在 peerDependencies 里),它是 engine-vocab
183
+ 门的对账锚,**对消费方零要求**。同批亲验:`BrainStatusPhase`(6 相)与 `BrainRetryErrClass`(6 桶)
184
+ 两版**逐词未变** ⇒ 本次提级的行为面影响只有 `BrainStatus` 键集这一处。
30
185
 
31
186
  ## 0.47.0(2026-08-31)
32
187
 
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.47.0
38
+ **Version:** 0.49.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,8 @@ 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 |
254
+ | `scripts/run-crash-converged-projection-test.mjs` | The `crashConverged` read face on `GET /v1/approvals` (L-38): what the *previous life* of a crashed local engine left behind, projected for every client. Three judgements are pinned. First, **absence is not an empty list** — a missing key (an older server, deps not present, or a carrier that is not an array at all) returns `undefined`, and the client renders nothing; an empty array returns a present zero-count object, which is the server actually saying "none". Folding the first into `{total:0}` would have the client assert "nothing was left behind" on a surface a person uses to decide whether it is safe to re-run something — the worst possible direction for a false statement — so the two cases are pinned to different **return shapes** and a test asserts the two verdicts are unequal. Second, bucketing is a **four-term conjunction**: `orphanState === 'pending'` *and* `resumeSafe === true` *and* both approval-evidence keys (`originalDecision`, `decidedAtMs`) absent. A fifth term rejects any row carrying an **accessor**, and accessors are never invoked at all — reading one means synchronously running someone else's code, and `catch` catches throwing, not *never returning*, so a looping getter would pin the startup thread forever (the row cap does nothing against that shape). The same rule covers the three untrusted reads outside the row as well — the envelope's `crashConverged` key, the carrier's `length`, and every numeric index are read as own property *descriptors* and only data descriptors are used, so accessors and prototype entries read as absent and are never invoked. Such a key is treated as absent: if it was a required field the row is counted as dropped, if it was optional or additive the row survives without it. That also closes the ordering attack, since spreading runs getters in property order and an earlier one could `delete` the approval evidence before it is ever copied (measured before the fix: such a row reached the resume-safe bucket), and the check therefore moves ahead of the read, onto the property descriptors — from which the snapshot is then built directly, because checking descriptors and *then* spreading is two independent observations of the same row, and a non-throwing proxy can make the two `ownKeys` calls disagree (first showing `originalDecision: 'approve'` so the row reads as plain data, then omitting that configurable key so the snapshot loses the evidence; measured before the fix: the dangerous row reached the resume-safe bucket after exactly two enumerations, and after it, one). Keys are written with `Object.defineProperty` rather than plain assignment, because `'__proto__'` is a legal own enumerable key and `o['__proto__'] = x` does not store a value — it calls the prototype setter, letting a row whose own properties are all plain data (so the accessor gate never fires) inject a prototype whose `sessionId` getter deletes the approval evidence from the snapshot during validation; `defineProperty` fires no setter, so the key survives as ordinary additive data and the snapshot keeps `Object.prototype`. A row that simply arrives with a custom prototype is treated the same way, since the snapshot only enumerates own properties: approval evidence sitting on the prototype would never reach it, and a perfectly ordinary object with no proxy and no accessors could otherwise be called safe to re-run — real bodies come from `JSON.parse` and always carry `Object.prototype`, so nothing genuine trips it). Validation itself runs on a **null-prototype** dictionary and the bucketing verdict is carried out of that same pass rather than re-read from the delivered row, because every property lookup on an ordinary `{}` reaches `Object.prototype`: a polluted `sessionId` getter there would delete the approval evidence from the snapshot mid-validation and send the row to the safe bucket (measured before the fix). The row handed to the client is still an ordinary object — the null prototype is an implementation detail of the check, not of the value) — real JSON bodies are all data properties, so only a middle-layer-synthesised payload ever trips it, and it too lands in the human bucket rather than being dropped. The `decided` arm means the human had already approved and side effects may be half-landed, so it always goes to the human bucket, as does `resumeSafe === false` and — the last two terms — any row whose own fields contradict each other, since `pending` claims nothing ran while that evidence says somebody pressed approve. Deciding "not safe" costs one extra question (recoverable); deciding "safe" wrongly has somebody re-run work that already partly happened (not). A 2x2 truth table pins that exactly one cell is resume-safe, so reading either key alone turns red, and the contradictory rows are routed to the human bucket rather than dropped — they are real orphans, and the ones most worth showing. Third, unreadable rows are **dropped and counted**, never thrown and never passed through: the product is declared as `CrashConvergedRow`, so letting a row missing a required field — or carrying one of the wrong type — past would be a lie at the type level, and the closed literal discriminators (`decision` / `cause` / `orphanState`) decide family membership rather than being an open vocabulary. The measuring stick stops at the **type** floor, though: degenerate-but-well-typed values (`ts: NaN`, an empty `toolName`) are kept, because swallowing a real orphan over a decorative field is the worse direction, and the one deliberate exception is `approvalId`, which must be non-empty to be a row identity at all. `dropped` is kept separate from `total` so unreadable rows never inflate "N approvals were affected"; each row is a **one-shot snapshot** — every own enumerable key is read exactly once, and validation, bucketing and the handed-back value all read that same snapshot, so additive upstream keys survive while a **non-idempotent** getter (one that never throws, just answers differently on a second read) can no longer erase the approval evidence between the check and the bucketing (measured before the fix: such a row landed in the resume-safe bucket while its checked value was `"approve"`). Hostile carriers are counted rather than allowed to reject: **every** touch of the carrier is guarded — envelope property reads, `Array.isArray` itself (it throws on a revoked proxy), the `length` read, each indexed read and each row's property reads — and a traversal that dies halfway returns absence rather than a half-counted total. A row that cannot be read never takes the batch with it: its own shape check is inside its own guard, so one revoked-proxy row costs a `dropped` tick rather than collapsing the whole projection to absence — which a client would have read as "this deployment does not offer the surface". Traversal goes by **numeric index, never the carrier's own iterator protocol**, because `for...of` hands the carrier the question of which rows exist: an array carrying an overridden `Symbol.iterator` can yield nothing (measured before the fix: a real orphan became `{total:0}`, which a client reads as "the server said there are none") or swap a dangerous `decided` row for a safe-looking one (measured: `fake-safe` was returned in place of `real-danger`). Row count is capped at 100000 and the cap is checked **before** the walk: requiring only a non-negative integer `length` does not stop a proxy trap reporting a billion, and this surface runs on the startup / `--resume` path, where a synchronous spin freezes the thread (measured before the cap: twenty million rows took 18.3 seconds and twenty million index reads; a billion does not come back). The honest boundary is stated rather than overclaimed — a proxy can still lie in its `length` or index traps, which is the same thing as a host injecting a lying transport — and the widening of `ApprovalsResourceLike.list()` is proven **additive** by really running tsc over a legacy `{ pending }` mock *and* over the real `AgentClient` path — the projector takes `unknown` precisely because a parameter shaped as "an object with an optional `crashConverged`" is a TypeScript weak type that the installed SDK's own `list()` return shape shares no property with, which only a real-client compile would have caught — with a known-red control so a clean run means the checker spoke |
253
255
  | `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
256
  | `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
257
  | `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 |
@@ -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 —— 而本层此前
@@ -400,8 +406,13 @@ export function eventToSdkMessage(ev, ctx) {
400
406
  // · `taskType` / `status` / `parentTaskId` —— 早已在册的 §7 缺口 **P-1**(lane 归属今天改用
401
407
  // id 形状 / `workflowRunId` 启发式判,见 `src/adapt/arms.ts`);
402
408
  // · `seq` —— core #258 的 stop-cycle 代际号(复活即 +1)。fleet 面已有同轴的
403
- // `wireCycleSeq`(0.38.0),tick 这条腿今天**没有**消费方;SDK 7.3.0 的 `task_progress` 臂
404
- // 连声明都没有(core/server 两侧都有)。
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
+ // 理由,否则这张白名单会随上游类型面自动变宽,准入条件形同虚设。
405
416
  // · `eventId` —— EventIdentity 的另一半。`status` 臂 2026-08-08 已因跨 lane 状态破坏补齐两键,
406
417
  // 本臂只补了 `parentToolCallId`(lane 判据要它),`eventId` 至今无消费方。
407
418
  // 上面三档的共同纪律:要透哪一位,先说得出**谁读它、读来干什么**,并同批更新 §3d 的键级剥离账。
@@ -429,9 +440,14 @@ export function eventToSdkMessage(ev, ctx) {
429
440
  // 🔴 病形与 `workflowRunId` **同形第二例**([1616]/[1617]②):这层白名单是闭形的,于是一条
430
441
  // **已经在 wire 上**的事实到不了宿主,而两边代码看着都对(「送到了但没人读」的精确坐标
431
442
  // 其实是「没转」)。additive 透传,旧 server 缺席 ⇒ 键不 stamp,形状逐字节不变。
432
- // 🔴 **结构视图读**:sdk 7.3.0(今天 npm 上的最新版)的 `task_progress` 臂**尚未声明**这一位
433
- // —— `requiresRealApproval`(#283)/ `ruleOffers`(#334)同形:**server 已真发、SDK 锚未跟**。
434
- // 照那两例的先例先追 server 不等 SDK;SDK 补上当天这处 cast 可整条删掉(退役条件)
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,类型声明是上游承诺不是本层前提)。
435
451
  // 🔴 条件 spread 与 server 投影**同形同条件**:`string` 且非空才带。空串既不是模型 id 也不是
436
452
  // 「不知道」,透下去只会在面板上渲成一个空模型名([honest-absence-not-fabricated-zero])。
437
453
  const model = ev.model;
@@ -654,6 +670,24 @@ export function eventToSdkMessage(ev, ctx) {
654
670
  */
655
671
  case 'approval_request':
656
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);
657
691
  default:
658
692
  // B5 穷举:SDK union 加成员必须在**编译期**打红这一行(而不是让新臂在用户面静默丢帧)。
659
693
  assertNeverArm(ev);
@@ -0,0 +1,176 @@
1
+ /**
2
+ * crashConverged.ts — `GET /v1/approvals` 的 additive 键 `crashConverged` 的**三端公共读面**
3
+ * (L-38;server 7.55.0 起在场,fixture 直证坐标 `dist/boot/coordinators.js` 的 `GET /v1/approvals`
4
+ * 分支 + `dist/approval-ask-audit-store.d.ts` 的 `CrashConvergedRow`)。
5
+ *
6
+ * ## 这一格是什么(先把语义说准,文案才有得写)
7
+ * local 引擎在**人还挂在审批门上**的时候崩了:那一条 ask 既没被批也没被拒,进程一死就成了孤儿。
8
+ * server 重启后把这些孤儿**收敛成 DENIED 同码**(`decision:'denied'` + `cause:'crashed_before_park'`),
9
+ * 并把收敛结果作为一条 `crashConverged` 行挂在同一个 `/v1/approvals` 信封上。
10
+ * 对端(TUI / web / desktop)而言这是**上一条命的残留**:重连 / `--resume` 之后,人有权知道
11
+ * 「上次那次崩溃,把哪些审批替我拒掉了、其中哪几件可能已经落了一半副作用」。
12
+ *
13
+ * ## 两个桶的语义(端**唯一**需要分的那一刀)
14
+ * · `orphanState === 'pending'` = 崩的时候**工具一步都没执行**(还停在门上)。这一档配
15
+ * `resumeSafe === true` 时,重跑同一件事是安全的 ⇒ **resumeSafe 桶**。
16
+ * · `orphanState === 'decided'` = 人**当时已经批了 approve**,收敛把它翻成了 denied ——
17
+ * 可能有半截副作用落地了(文件写了一半 / 命令跑了一半)。⇒ **needsHuman 桶**,人工确认。
18
+ * · 其余一切(`resumeSafe === false`、字段自相矛盾、说不清的组合)一律进 **needsHuman**:
19
+ * 判**不**安全的代价是多问人一句(可恢复);判**错**安全的代价是让人闭眼重跑一件已经落过
20
+ * 副作用的事(不可恢复)。凡证不出来一律落保守侧。
21
+ *
22
+ * ## 🔴 caveat(DEBTS L-38 收执逐字,写文案前必读)
23
+ * `resumeSafe` 是**以账本完整为前提**算出来的**缺省值**,不是铁证 —— 崩溃现场本来就是账本最可能
24
+ * 缺页的时刻。⇒ 端的文案只许写「按记录看可以重跑」,**绝不**写「已确认没有副作用」。
25
+ * 同理:**键在场 ≠ 流内协议上场**。`crashConverged` 只是这一次 `list()` 回体上的一个 additive 键,
26
+ * 它既不宣示引擎具备什么能力,也不代表有一条推送通道会再告诉端第二次。别拿它当能力位读。
27
+ *
28
+ * ## 🔴 非目标与已知边界(对手模型成文,详见 `docs/INTEGRATION-CLIENTS.md` §12e)
29
+ * 真供给 = server JSON → SDK `JSON.parse` → 端:每一位都是**自有数据属性**,无代理、无 accessor、
30
+ * 原型恒 `Object.prototype`。**被中间层合成的非 JSON 载荷 / 敌意 `Proxy` / 原型注射**这一族**不在
31
+ * 射程内**:本件对它们只承诺三件 —— **不抛**、**不同步阻塞**、**绝不产出「可安全重跑」这个判决**
32
+ * (一律落 needsHuman 或 dropped);**不承诺**还原出「真实内容到底是什么」。一只代理在它唯一那次
33
+ * 被观察时就能给出假答案,而那与「宿主注入了一个会撒谎的传输层」是同一件事 —— 那种进程里每个对象
34
+ * 都不可信,本件不是能修好它的那一层。下面那一串防御的**唯一**目的是把这一族挡在「安全」判决之外
35
+ * 并保住线程,不是为了在敌意宿主上还原真相。
36
+ *
37
+ * ## 分工(与仓内既有形同款)
38
+ * **纯投影,零 IO、零 module 级可变态、零文案**:本件不构造 client、不认 baseUrl、不碰凭据,
39
+ * 也**一句面向用户的话都不铸** —— 措辞、是否上屏、排序与折叠全归端。
40
+ * 取件仍走既有的权威 `client.approvals.list()`(见 `hitlBridge.ts` 的 `ApprovalsResourceLike`),
41
+ * 本件只吃它的回体。
42
+ */
43
+ import type { PendingCheckpoint } from '@sema-agent/sdk';
44
+ /** 孤儿行在崩溃那一刻的状态。`'pending'` = 工具零执行;`'decided'` = 人已批,可能有半截副作用。 */
45
+ export type CrashConvergedOrphanState = 'pending' | 'decided';
46
+ /**
47
+ * `GET /v1/approvals` 回体 `crashConverged[]` 的**逐形镜像**(server `CrashConvergedRow`)。
48
+ *
49
+ * 🔴 **本包刻意不从引擎侧 re-export 这个形**(与 `editedRuleTextPrecheck.ts` 头注同一条硬约束):
50
+ * `scripts/run-client-core-portability-test.mjs` 的 `EXPECTED_PACKAGES_INDEX` 是**等值门**,
51
+ * 包总入口闭包的外部包集合恒等于 `{diff, @sema-agent/sdk}`,多一个当场红;同门还拿 esbuild
52
+ * `--platform=browser` 真打一次包。为一个纯数据形把引擎焊进每一个装本包的端,代价与收益不成比例。
53
+ *
54
+ * 🔴 `decision` / `cause` 是**闭集字面量**,不是开集词表:这两位是本族行的**判别式** ——
55
+ * 收敛出来的行按定义就是「因为崩在 park 之前所以被判 denied」那一种。别的 `decision`/`cause`
56
+ * 组合不是「本族的新成员」,而是「这不是本族的行」⇒ 投影侧丢弃(见 {@link projectCrashConverged})。
57
+ * (对比:`engineErrorCodes` 那批 `ReadonlySet` 是**识别表**、开集纪律相反 —— 两者刻意不同族。)
58
+ */
59
+ export interface CrashConvergedRow {
60
+ /** 被收敛的那条 ask 的审批 id(端的行身份;去重/引用都按它)。 */
61
+ readonly approvalId: string;
62
+ /** 崩溃时挂在门上的工具名(展示用;UNTRUSTED-for-display,端自己消毒控制字符)。 */
63
+ readonly toolName: string;
64
+ /** 归属的 task。 */
65
+ readonly taskId: string;
66
+ /** 归属的会话(server 在场才发)。 */
67
+ readonly sessionId?: string;
68
+ /** 原 ask 铸出的时刻(epoch ms)。 */
69
+ readonly ts: number;
70
+ /** 原 ask 的到期时刻(epoch ms)。 */
71
+ readonly expiresAtMs: number;
72
+ /** 收敛结果恒为 denied(闭集判别式,见本 interface 头注)。 */
73
+ readonly decision: 'denied';
74
+ /** 收敛原因恒为「崩在 park 之前」(闭集判别式)。 */
75
+ readonly cause: 'crashed_before_park';
76
+ /** 崩溃那一刻的状态 —— 分桶的**主判据**。 */
77
+ readonly orphanState: CrashConvergedOrphanState;
78
+ /** `orphanState==='decided'` 时人当时按下的那一下(server 在场才发)。 */
79
+ readonly originalDecision?: 'approve';
80
+ /** 人按下那一下的时刻(epoch ms;server 在场才发)。 */
81
+ readonly decidedAtMs?: number;
82
+ /** 收敛动作自己发生的时刻(epoch ms)。 */
83
+ readonly convergedAtMs: number;
84
+ /** 🔴 **缺省值不是铁证**(见文件头 caveat):以账本完整为前提算出来的「重跑安全」缺省。 */
85
+ readonly resumeSafe: boolean;
86
+ }
87
+ /**
88
+ * `GET /v1/approvals` 的**信封**形。`pending` 是老形的全部;后两键 additive
89
+ * (server 按 deps 在场才发)⇒ **老形 `{ pending }` 仍然可赋值到本型**,现有消费点一个字节不用改。
90
+ *
91
+ * 🔴 `livePending` 在本包是**不透明载体**,留 `unknown[]` 有两条**互相独立**的理由:
92
+ * ① 本包对它零消费点,在这里手抄一份结构 = 手抄一份会漂的上游形(与 `ToolApprovalFrame.probeCause`
93
+ * 同一条理由);
94
+ * ② **SDK barrel 根本没把 `LivePendingRow` 导出**(实证:`@sema-agent/sdk` 7.4.0 的 `dist/index.d.ts`
95
+ * 那张 `export type { … } from "./types.js"` 名单里没有它)⇒ 想「用单一真源」也拿不到,
96
+ * 剩下的选项只有深路径 import(不是公面)或手抄(①)。
97
+ * ⇒ 想读它的端自己窄读,别指望本包替它断言形状。
98
+ * ⚠️ 方向是安全的:SDK 真实回体的 `livePending?: LivePendingRow[]` **可赋值**到 `unknown[]`,
99
+ * 所以真 `AgentClient.approvals` 仍然可赋值到 {@link ApprovalsResourceLike}(G7 段真跑 tsc 证)。
100
+ */
101
+ export interface ApprovalsListEnvelope {
102
+ pending: PendingCheckpoint[];
103
+ livePending?: unknown[];
104
+ crashConverged?: CrashConvergedRow[];
105
+ }
106
+ /**
107
+ * {@link projectCrashConverged} 的产出。**分桶 + 计数,零文案**。
108
+ *
109
+ * 🔴 `total` 只数**读得出来的**行(= `resumeSafe.length + needsHuman.length`)。供给总行数 =
110
+ * `total + dropped` —— 两个数刻意分开,免得端把「读不出来的行」算进「上次崩溃影响了 N 件」。
111
+ */
112
+ export interface CrashConvergedProjection {
113
+ /** 读得出来的行数(不含 `dropped`)。 */
114
+ readonly total: number;
115
+ /** `orphanState==='pending'` **且** `resumeSafe===true` 的行(按记录看重跑安全)。 */
116
+ readonly resumeSafe: readonly CrashConvergedRow[];
117
+ /** 其余一切(decided 臂 / `resumeSafe!==true` / 字段矛盾)—— 留人工确认。 */
118
+ readonly needsHuman: readonly CrashConvergedRow[];
119
+ /**
120
+ * 供给里**读不出来**的行数(缺必填位 / 判别式对不上 / 取属性就抛)。
121
+ * 🔴 `dropped > 0` 本身是**一条要显形的事实**:那是上游形漂了或载体坏了,端不许把它静默吃掉
122
+ * (至少留一条 debug 痕;真行被丢掉的方向,代价是人不知道有一件孤儿审批在)。
123
+ */
124
+ readonly dropped: number;
125
+ }
126
+ /**
127
+ * `GET /v1/approvals` 回体 → `crashConverged` 的**分桶投影**。纯函数,**永不抛**。
128
+ *
129
+ * ## 🔴 缺席 vs 空数组:两件不同的事,判据不许合流
130
+ * · **键缺席**(老 server / deps 不在场 / 读不动)⇒ 返回 `undefined` ——「本部署没告诉我这件事」。
131
+ * 端此时**零渲染**:绝不渲「0 个」「本次无崩溃遗留」之类的话,那是替 server 下一个它没说过的
132
+ * 断言([honest-absence-not-fabricated-zero])。
133
+ * · **键在场且是空数组** ⇒ 返回 `{ total: 0, resumeSafe: [], needsHuman: [], dropped: 0 }` ——
134
+ * 「server 明说:一条都没有」。这一档端**可以**渲「没有崩溃遗留」。
135
+ * ⇒ 两档的**返回形不同**(`undefined` vs 对象),端拿 `=== undefined` 一刀分开,不必读计数。
136
+ *
137
+ * ## 🔴 载体在场却不是数组 ⇒ 同样 `undefined`(**不**折成 `total:0`)
138
+ * 那是形漂了或中间层改写了,本包**读不出**这次的供给。折成 `{total:0}` 会让端渲出
139
+ * 「没有崩溃遗留」——在一条给人判断「能不能闭眼重跑」的面上,这是最坏方向的假断言。
140
+ * 两档合流的代价只是「都零渲染」,而分错的代价是一句用户会照着去操作的谎。
141
+ *
142
+ * ## 🔴 三处不可信读取只认**自有数据位**
143
+ * 信封的 `crashConverged`、载体的 `length`、载体的每个数字下标 —— 三处都走 `ownDataValue`:
144
+ * accessor / 缺席 / 只挂在原型上的东西一律**当缺席**,一次别人的代码都不执行(`catch` 接得住
145
+ * 「抛」,接不住「不返回」)。真供给来自 `JSON.parse`,每一位都是自有数据位 ⇒ 对真行零影响。
146
+ *
147
+ * ## 🔴 载体**读不出**(已撤销 `Proxy` / `length` 或描述符取值抛)⇒ 也是 `undefined`
148
+ * 触碰载体的每一处都在保护内 —— 连 `Array.isArray()` 自己都是(对已撤销的 `Proxy` 调用它直接抛)。
149
+ * 走到一半炸掉时刻意**不交还半程结果**:一个自己都知道不全的计数,拿去渲「上次崩溃影响了 N 件」
150
+ * 比不说话更坏。
151
+ * 🔴 遍历**按数字下标**,不用载体自己的迭代协议:自带 `Symbol.iterator` 覆盖的数组能一条都不产出
152
+ * (于是真孤儿被伪造成「server 明说一条都没有」),也能把危险行替换成安全行。下标读不问载体
153
+ * 「有哪些行」这个问题。⚠️ 边界:代理仍能在 `length`/下标 trap 上撒谎 —— 那等同于「宿主注入了
154
+ * 会撒谎的传输层」,本包挡不住也不假装挡得住;本条守的是协议面。
155
+ *
156
+ * ## 🔴 进 resumeSafe 桶的合取有**五项**(两项主判据 + 两项跨位自洽 + 一项载体自证)
157
+ * `orphanState==='pending'`(工具零执行)∧ `resumeSafe===true` ∧ `originalDecision` 缺席 ∧
158
+ * `decidedAtMs` 缺席 ∧ 这一行**不带 accessor**。中间两项是**矛盾闸**:`pending` 说「一步都没执行」,
159
+ * 而那两位是「人已经按下过 approve」的证据 —— 同时在场 = 自相矛盾的载荷。最后一项是**顺序重入闸**:
160
+ * 带 getter 的行能在展开过程中把证据位删掉(见行读口头注的实测)。三者都**只挡「安全」这一侧**,
161
+ * 一律落 needsHuman 而**不丢** —— 它们可能是真孤儿,而且恰恰是最该给人看的那几条。
162
+ *
163
+ * ## 🔴 行数硬上限
164
+ * `length` 超过 100000 ⇒ 与「载体读不出」同一档(`undefined`)。判在遍历**之前**,所以一个谎报
165
+ * 十亿的 `length` trap 连一次下标读都触发不了 —— 否则同步空转会把启动/`--resume` 路的主线程冻住。
166
+ *
167
+ * ## 🔴 入参是 `unknown`,不是「带一个可选 `crashConverged` 的对象」
168
+ * 写成 `{ crashConverged?: unknown }` 会造出一个 TypeScript **弱类型**(成员全可选):把真
169
+ * `client.approvals.list()` 的回体喂进来时,SDK 7.4.0 声明的 `{ pending; livePending? }` 与它
170
+ * **一个共同属性都没有** ⇒ TS2559「has no properties in common」,文档里那句最主要的用法当场编不过
171
+ * (而只用本包自己的 `ApprovalsListEnvelope` 去测发现不了 —— 那个形恰好声明了 `crashConverged`)。
172
+ * 收 `unknown` 也更诚实:本函数对入参的全部工作**就是**窄化它,形状是运行期才知道的事。
173
+ *
174
+ * @param env 任意 `/v1/approvals` 回体(只读 `crashConverged` 一键;非对象 / `null` ⇒ `undefined`)。
175
+ */
176
+ export declare function projectCrashConverged(env: unknown): CrashConvergedProjection | undefined;