@sema-agent/client-core 0.76.0 → 0.76.1

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +6 -1
  3. package/dist/adapt/arms.js +51 -48
  4. package/dist/adapt/ids.d.ts +29 -2
  5. package/dist/adapt/ids.js +29 -2
  6. package/dist/adapt/panelTasks.d.ts +4 -3
  7. package/dist/adapt/panelTasks.js +29 -11
  8. package/dist/adapt/textStream.js +5 -5
  9. package/dist/adapt/toolCards.js +2 -2
  10. package/dist/adapt/turnFlags.js +5 -5
  11. package/dist/adapt.d.ts +1 -1
  12. package/dist/adapt.js +8 -8
  13. package/dist/adapter/downstream/eventToSdkMessage.d.ts +12 -0
  14. package/dist/adapter/downstream/eventToSdkMessage.js +13 -20
  15. package/dist/adapter/runStream.js +14 -5
  16. package/dist/adapter/types.d.ts +12 -0
  17. package/dist/adapter/types.js +30 -0
  18. package/dist/approvalsStreamLiveCapability.js +15 -35
  19. package/dist/deviceExecutorManagementCapability.js +15 -35
  20. package/dist/engineCapReader.d.ts +77 -0
  21. package/dist/engineCapReader.js +87 -0
  22. package/dist/executionLaneCapability.js +15 -38
  23. package/dist/hitl/persistedRulesWire.d.ts +20 -0
  24. package/dist/hitl/persistedRulesWire.js +23 -0
  25. package/dist/index.d.ts +6 -0
  26. package/dist/index.js +22 -1
  27. package/dist/mcpLiveness.d.ts +179 -0
  28. package/dist/mcpLiveness.js +218 -0
  29. package/dist/mcpPanel.d.ts +17 -0
  30. package/dist/mcpPanel.js +21 -0
  31. package/dist/mcpProbeCapability.d.ts +84 -0
  32. package/dist/mcpProbeCapability.js +124 -0
  33. package/dist/memoryComplianceCapability.d.ts +73 -0
  34. package/dist/memoryComplianceCapability.js +109 -0
  35. package/dist/memoryEntriesWire.d.ts +302 -0
  36. package/dist/memoryEntriesWire.js +595 -0
  37. package/dist/memoryOriginCapability.d.ts +68 -0
  38. package/dist/memoryOriginCapability.js +102 -0
  39. package/dist/peerLaneCapability.d.ts +62 -0
  40. package/dist/peerLaneCapability.js +100 -0
  41. package/dist/permissionRulesWriteCapability.d.ts +64 -0
  42. package/dist/permissionRulesWriteCapability.js +98 -0
  43. package/dist/selfOrchestrationDenial.js +7 -2
  44. package/dist/sqlEngineCapability.js +15 -35
  45. package/dist/webSearchBackendCapability.js +15 -35
  46. package/dist/writeProtectionCapability.js +15 -35
  47. package/docs/INTEGRATION-CLIENTS.md +89 -11
  48. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -49,6 +49,32 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.76.1(2026-09-20)
53
+
54
+ > 主题:**能力位读器工厂 + 五只新读口 + 一处别名收口**(patch;零型面 BREAKING;公面值导出 1081 → **1120**)—— 读器工厂(CC-75)· MCP 活性观察读口(CC-36)· `capabilities.peerLane` / `capabilities.permissionRulesWrite` 四态读口(CC-102)· 记忆治理面两只四态读口 + 三只条目面回体读口(CC-97 a)· 主车道证明逐次新建(CC-99)。🔴 **已从本版撤出**:`capabilities.mcpProbe` 读口(CC-74)—— 上游三仓源码里尚无这一格与它的 501 码,上游那张票仍是设计稿;公面一旦发出就是承诺,无一字可验的键名不上公面,候上游真码落地后按字节重做。
55
+
56
+ ### Fixed
57
+
58
+ - 🔴 **身份四键的一次性快照只修了投影口一侧(外部验收方对 0.76.0 的 F1 / F2)**:0.76.0 说「修在共享助手上 ⇒ 全部内部臂连带受益」—— 不成立。① `compaction_outcome` 的 adapt 侧臂两版逐字节相同,仍先判子流再取 `parentToolCallId`(读两次),而投影口已换成每键一读 ⇒ 值随读变的帧让「投影口 → 内部臂」与「直喂 adapt()」两条路**分叉**(直喂丢事件、经投影出 subagent chrome)—— 恰是 0.76.0 自己那格「两入口同答」要禁的形,还落在发车帖第一个点名的臂上;② `text_end` / `reasoning_end` 走的投影函数在同一个表达式里把每只身份键读两次,「每键恰读一次」在这两条臂上两版都不成立(方向 fail-closed:合法子代帧被判 malformed)。现在:快照助手 `snapshotSegmentIdentity` 单源(投影口的 `identitySnapshot` 委托它,adapt 侧 compaction 臂与 `prompt_assembled` 臂改读它;两侧各恰读一次、读抛整帧拒),`text_end` / `reasoning_end` 改走与其余七条内部臂同一只 `identityOrMalformed`;adapt 侧另两处「typeof 再取值」的双读(工具结果收卡的 `eventId` / `parentToolCallId`、`task_progress` 的 `parentToolCallId`)改单读 —— 🔴 **这两处也是修,不是纯重构**:对稳定帧行为不变,对值随读变的帧按设计改变(修前判形看第一份、取值看第二份 ⇒ 收卡丢父卡号、tick 落到主车道;修后一份到底),各给一格判据(compaction 门 A11 / A12)。🔴 **射程如实**:这两处是**存量**(2026-08-02 适配内核搬入那批入库,最早含它的发布版 0.67.0;在已发布的 0.75.1 / 0.76.0 制品上用同一夹具实跑读数一致),不是 0.76.0 引入;只对进程内**非幂等帧**(值随读变的 accessor)可见 —— 经 sdk 事件流进来的帧是其 SSE 读器 `JSON.parse` 的产物(普通数据属性,两次读同一个值),不会触发;不经序列化、把活对象直接喂进 `adapt()` 的路(宿主自建管线 / 重放腿 / 引擎与壳同进程直传)才受影响。F1(compaction 两入口分叉)则是 0.76.0 新引入。判据:compaction 门 A8–A10(两入口同答 ∧ 直喂 / 投影各恰读一次 ∧ 读抛不逸出)、文本段落门 Te0a / Te0b(两臂投影恰读一次且身份原样带出);修前实跑各红。🔴 **0.76.0 已发布且含 F1 / F2**:排了 0.76.0 的下游建议直接提 0.76.1。
59
+ - **0.76.0 发车文案勘误(已发布段冻结,勘误只在这里)**:① 「两条行为面改口**只对直接调读器的端**可见」**被证伪** —— 审批 feed 自己就调 `readLivePendingRows`,只订阅 feed 的端对 `livePending:[{}]` / `pending:[{}]` / `pending:[7]` 三形在 0.76.0 也从「真快照 pending:1」变成 `unknown{unreadable_payload}`;修法方向不改,射程措辞改成「feed 订阅者同样可见」。② CC-77 ①「行为一字未动 … 逐键对拍等价」按字面不成立:12 形里 2 形跨版不等(均 `settled` true → false,fail-open 收紧;包内构造不出,只有宿主直调公面才够得着)。③ 发车帖「24 个 `_*ForTest`」按字面只匹配 22 只(另 2 只是 `_reset*` 无后缀);本版 §0a 改按真匹配数写。④ 「新门四只」实列五只。⑤ 流程:0.76.0 权威全门的第五轮来自与旧轮**无重叠**的一跑(前四轮里有一轮是重叠窗内旧轮负控套篡改共享文件报的假红,当时未写进帖)。
60
+
61
+ ### Changed
62
+
63
+ - **六只能力位读器的四口收成一只工厂(CC-75)**:`sql` / `writeProtection` / `webSearch.backend` / `executionLane` / `approvalsStreamLive` / `deviceExecutor.management` 此前各自持有**逐字相同**的四份代码(caps 探测链上的 tee / per-baseUrl 读口 / 换代失效口 / 测试钩),现在共用包内叶 `createEngineCapReader`(不出公面);每只只剩**投影函数**(键路径 / 在场判据 / 值形校验)与诊断措辞表两件。**等价性三证**:六只读器的 `.d.ts` 与 0.76.0 发布制品**按字节一致**;六只各自的既有门一字未改、格数不变;行为逐条留在原位。🔴 刻意**不做**声明式键路径表 —— 投影函数本身就是那一份判据,压进通用描述表 = 给同一份判据立第二个判官。未观测读数改成**每次新铸**(此前六只各自新建、收编后若共享一只常量会被就地改坏,而失效口清不回它)。工厂门新增两格:**失效口的爆破半径恰是被点名那一格**(宿主同时连两台引擎时,失效掉 A 那台不许把 B 那台当代的读数清成「未观测」—— 那是凭空造出的缺席);**名单等值**(`src/` 里每一个调工厂的文件都必须在门的读器表里有一行,新读器逃不出这道门)。
64
+ - **主车道证明改逐次新建(CC-99)**:`laneProof` 是公开输出面上的一位,此前约五十个主车道发点交出去的是**同一只**模块级常量 `{lane:'main'}` —— 跨适配器实例、跨流、跨 turn 全是它;修前实测两个互不相干的消费者各取一次未登记行的证明 `===` 为真,给第一只补上卡号之后第二只当场读出那张卡,开流序幕四条主车道事件也一起变。🔴 **判据如实**:按语法树穷举,本包内对车道证明**零就地写**,三个消费端的用法全是只读 ⇒ 本条修的是**别名风险**(拿证明做身份去重 / WeakMap 键 / 视图层身份记忆的消费者,在两条无关行共享一只对象时会把它们认成同一条,一个字不写也照错),**不是**一条已经在错归因的行。修法 = 每次新建(`mainLane()` 出厂口),落选的「冻结」只挡写不解别名、且只冻主车道半边会让同一写操作随行是否绑卡漂成间歇抛。同批:终态那两处(关卡 / 终态 tick)此前取一次证明给 stop 与 end 两条事件**共用** ⇒ 改成「归属判一次、证明铸多只」—— 刻意不做「每条事件各调一次」,生成器两次 yield 之间挂起,中间绑卡会把同一行分流到两个归属。
65
+ - **`failloud` 豁免上限收到当日实测(42 → 36)**:那 6 格松量是历次抬数后 catch 又被删掉留下的,留着等于这道棘轮在 6 格之内不判任何新增静默吞。
66
+
67
+ ### Added
68
+
69
+ - **引擎托管 MCP 服务器的活性观察读口(CC-36)**:`wiring_manifest.mcp[].liveness` 答的是**「这台服务器还够得着吗」**,与同一行上的申报位 `status`(拨号那一刻的判决,引擎有意冻结)、re-dial 回执的 `outcome`(一次动作的判词)**各说各的**;`status:"failed"` 与 `liveness.state:"reachable"` 同行并存是真行(答了握手、答的是协议错)。读法是**四种读数**:三个词(`reachable` / `unreachable` / `unknown`)+ **缺席 ⇒ `indeterminate`** —— 缺席在 wire 上四源同形(这条腿没走到那台 / 声明从未被拨过 / 老引擎或老服务端 / 上游投影判形没过),唯一诚实的含义是「没有可用的观察记录」,**不许**据它反推拨没拨过号,不许折成 `unknown` / 健康 / 关着。畸形与缺席**分开**:行上立 never-false 的 `livenessUnreadable`,腿级判词至多退到 `unknown`、**绝不**答 `reachable`;只由读不出的格决定的 `unknown` 另说一句(「记录在场而本端读不出」,主语不是「回来的东西答不了」—— 上游哪天加第四个词,老客户端落的正是这一档)。腿级判词按**最坏事实优先**合成,判词后面的观察时刻**与判词同源**(说「至少有一台够不着」时它是最新那次够不着的时刻,不是这条腿上任何一次观察的最大值)。新导出 `MCP_LIVENESS_STATES` / `readMcpLiveness` / `mcpLivenessRollupOf` / `mcpEngineLegHealthOf` / `mcpEngineLegHealthDetail`;行视图 `WiringManifestMcpEntryView` 加两个可选位(纯 additive;活体腿与面板回放仍是同一只读器)。⚠️ 服务端自 **7.91.2** 起把这一格过境,更老的服务端上恒缺席 = `indeterminate`(不是故障);`/mcp` 面板 `servers[]` 那一面上游投影白名单不带这一格,本包不铸那个位。
70
+ - **两枚具名能力位键的四态读口(CC-102;server ≥7.91.1;sdk 9.8.1 无型 ⇒ 结构读)**:`capabilities.peerLane`(跨会话车道;**不是 HTTP 面**,v1 零新端点,消费方式是渲染 / 预期)与 `capabilities.permissionRulesWrite`(收紧方向的单步写口对这个调用方够不够得着)。四态 `unobserved` / `not_reported` / `absent` / `present`,三种「没有」不许互折。🔴 `permissionRulesWrite` 的读法按上游成文:**键缺席 = 这份二进制比单步写口老 ⇒ 藏入口;键在场 ⇒ 按本键自己的值判**(值与 `permissionRules` 是服务端同一个表达式),**与撤销面那一位读法不同、不许混** ⇒ `src/hitl/persistedRulesWire.ts` 新增便利口 `persistedRulesWriteAvailable(baseUrl)`,只由本键的四态派生,**不合取** `permissionRules`、不再读一遍 caps(反钉:`permissionRules:true` ∧ 写键缺席 ⇒ `false`;写键 `true` ∧ `permissionRules` 缺席 ⇒ `true`);显式 `undefined` 不回落装好的默认读锚(与两只兄弟 gate 同律)。`peerLane` 键缺席按上游成文语义是「老 worker,本部署没有跨会话车道」⇒ `peerLaneAvailable` 答 `no`(在场性即版本信号),只有本进程从没观测过才答 `unknown`。新导出 13 件 + 测试钩 2 件。
71
+ - **记忆治理面两只能力位四态读口 + 三只条目面回体读口(CC-97 a;server ≥7.46.0 起铸两位)**:`capabilities.memoryCompliance`(出处问询 / 抹除两口)与 `capabilities.memoryOrigin`(外源审计 / 清标三口)各一只四态读口(工厂薄包装),🔴 **刻意不合并成一只**:上游把它们做成两个产品面并明说不合并(今天恒同值只是巧合),本包各持一张 per-baseUrl 表;两口 / 三口可用性判词各自归包,记忆治理面缺席时的 501 码 `MEMORY_ENGINE_REQUIRED_CODE` 只铸一次(上游源码里真有这一码)。三只回体读口一个动词都不调(动词半场候抬 sdk 地板):**条目导出行逐行窄读**(空数组 = 真 0 / 非空全坏 = `malformed` / 半坏 = `present` + `dropped`,读不懂的行与没有行永不共用一句话);🔴 **「空答不等于本店干净」现在是代码** —— 这条读口在服务端**缺省把带外源标的条目整条扣留**(消费方以查询参数声明认识 origin 键族后才放行),而装着的 sdk 那只动词不带该参数 ⇒ 经它拿到的回体里带标条目恒被扣留、回体形状与「没有带标条目」完全一样;所以「这个 scope 有没有外源条目」的判词口 `scopeExternalOriginVerdict(reading, asked)` 第二个形参**必填**,没声明 ⇒ 空答一律 `unknown(fenced)`,肯定的否定话最远只到调用方点名的那一个 scope 且带着 scope 串,联合里没有「整店」臂;服务端明说扣留了 n 条时那个计数本身就是「有带标条目」的肯定证据。**抹除证明读口**:「这一次一条都没抹掉」/「200 空体」/「回体读不懂」判成**三回事**;版本信封 `v > 1` 一律拒不重解释;`historyUnknown` 原样三态过境(上游今天只铸 `true` 或整键省掉)且**多出第四态** `unreadable`(键在场读不成,不折回缺席);「判决那一刻没有这条记录」**不**授权任何界面说「它从来不存在」(上游明说这份证明不区分从没存在过 / 自然删掉 / 别的请求删的);重发姿势三态(证据腿幂等 / 降级腿由人决定 / 读不出不当幂等)。**外源清标回执读口**:三把承重键 + 被清掉的标原样带出。🔴 **每一段数组只读一遍**(长度恰读一次、每下标恰读一次,不走调用方迭代器;自报行数大到读不下 ⇒ 如实报读不出,绝不静默截断)—— 异源对抗复审实撞:走的时候报一个长度、走完再报另一个的回体,修前能让一条带标的行被静默丢掉而丢行计数仍为零,一路判成「这个 scope 干净」。「键在场但值读不成」永不折回「键缺席」(历史位 / 扣留计数 / 「这是全量吗」三格各自处置,完备性轴 fail-closed)。新导出 21 件 + 测试钩 2 件。
72
+
73
+ ### Gates
74
+
75
+ - 接入文档冻结账门补**「全量 = A + B = C」算式判据**(§0a 同一格手抄着三个数,此前门只盯行首两个;实翻:改了行首而算式仍是旧三数,门照样全绿)。
76
+ - 新门四只:`run-engine-cap-reader-factory-test.mjs`(表驱动等价门,读器表长即真源)/ `run-mcp-liveness-test.mjs` / `run-peer-lane-rules-write-capability-test.mjs` / `run-lane-proof-identity-test.mjs`(含语法树 + 类型检查器的结构反钉)。
77
+
52
78
  ## 0.76.0(2026-09-20)
53
79
 
54
80
  > 主题:**六票一批** —— `prompt_assembled` 投影臂(CC-91)· 审批 feed 的「不知道」三态(CC-98,**型面 BREAKING**)· `ApprovalResolution` 单源判别联合(CC-77 ①)· 记忆 spec 读口与未列键判官(CC-96)· 终态词表补上游钉(CC-78 ①)· `tasks_expand` 退役(CC-92,**型面 BREAKING**)。外加一条**跨全部内部臂**的修复(身份四键一次性快照)。**minor**:两处型面 BREAKING + 两条只对「直接调读器的端」可见的行为面改口;公面值导出 1065 → **1081**(+16)。接入面 §76。
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.76.0
38
+ **Version:** 0.76.1
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
@@ -393,6 +393,11 @@ public-surface guard checks that last one).
393
393
  | `scripts/run-assistant-arm-identity-test.mjs` | The identity keys on an assistant row, and an explicit account of the two that are **deliberately not** there. What the renderer received was a bare role-and-content object, so a dozen consumer sites downstream were each estimating what the message envelope should have told them. The id is taken from the engine's own event id rather than minted locally, because it has to be **the same value** on the live leg and on a durable replay — a freshly minted one would make a replayed message look new to a host's dedup and to rewind — and when the wire carries none the key is simply absent rather than filled with a random stand-in wearing an identity it does not have; it is also kept distinct from the envelope's own local render key, which is a different identity. The model name comes from what the host pinned when it opened the stream (the request was the host's to build) and is never guessed, since a wrong model name is worse than none once a billing or capability face looks it up. Usage and stop reason are **not** minted on this arm, and the reason is frame order rather than effort: content arms arrive before the turn's closing frame, so at the moment the arm is emitted the engine has not yet said what the round cost — anything put there would be an estimate, which is the very thing this work exists to remove — and synthesising a follow-up assistant update when the real figure lands is also refused, because that shape does not exist upstream and would place a message in the transcript the engine never sent. Their real values leave through the turn's own neutral arm as two superset keys, the usage one reusing the **same single mint point** the footer rollup already folds so the two faces cannot diverge, and the stop reason passed through verbatim as an open set — the machine signal for *was this turn cut short*, previously blind on both the stream and the trace. The existing behaviours beside them are pinned too: no arm at all when usage is wholly absent, and the sub-flow cut-out that keeps a child's turn from driving the leader's face |
394
394
  | `scripts/run-text-segment-authority-test.mjs` | The **authoritative segment replacement** on `text_end` (L-310, server >=7.75.3). `text_end.content` now goes through the same redactor as `result` and the ledger while `text_delta` stays verbatim, so the two **may differ** — an answer that quoted a credential used to be committed to the local transcript in its unredacted form, because the arm only forwarded the boundary signal. Six timing shapes are pinned, two of which an adversarial review reproduced against the installed engine's real bytes and which the first design got wrong in both directions: a second boundary in the same turn (the per-block case on one provider lane) used to make the first segment's prose vanish, and a boundary that arrives *after* the tool card (the other lane emits it at finalize) used to be read as "this package never handled that segment" and reported nothing at all. Three additive keys, all never-false; the two shapes that look alike are told apart by the second one, because the host's action in them is the opposite. The end-to-end legs drive the real pipeline without hand-inserting a segment commit — doing so is exactly what hid the first defect. A second review round then found two combination timings on top of the first fix — a tool card followed by *more* deltas in the same segment, and a byte count that had been documented as a message count — and both are pinned here too. A third round caught a length that the prose called bytes while the code returned UTF-16 units — harmless in ASCII, and on CJK text enough to leave the credential on screen — plus a backfill ledger that had to be kept in step, so the terminal frame does not re-render the segment a second time — kept in step only where the whole stretch sits in one message, because those ledgers are per-message and a fourth round showed that writing across them charges one message's prose to another. A fifth round settled the whole class into one invariant the guard now checks against the previous release's behaviour: this package only rewrites bytes it is still holding in the current message — once a segment has crossed a package-side boundary it emits the three keys and changes nothing else **0.68.2 (CC-01):** the segment identity is now minted here, not by the host: every committed assistant text row carries a top-level `_sema_segment_id` (stamped once at the `adapt()` exit, so the durable whole-message leg and the streamed-segment leg are covered alike; thinking blocks, tool_use-tailed rows and chrome events are left byte-for-byte), `text_segment_end` carries the same value as `segmentId` before rotating, subagent boundaries never rotate, and a replayed stream yields the same identities. Three mutations (no rotation / no stamping / stamping tool_use rows) each turn the guard red **0.69.0 (CC-02):** the same authority replacement now covers the reasoning face (`reasoning_end`, server >=7.77.0): a thinking block still buffered is swapped whole and its live tail recomputed; one already committed at a boundary (the usual timing, since the first text delta commits it) is left untouched and the host is told the row to replace by its uuid, never re-emitted. Subagent boundaries are ignored and the text-segment identity does not rotate **0.69.1 (CC-09):** the run-stream replay guard still drops a frame whose event id was already seen, but it now reports the drop through the host's dropped-frame sink as `duplicate_seq` instead of vanishing silently (server 7.77.0 reuses the first reasoning delta's id for `reasoning_end`, so that authoritative segment is lost on the print lane until 7.78.1); the interactive adapter has no such guard and keeps receiving it **0.69.1 (CC-10/CC-11):** subagent segment-end frames are fenced on all three identity keys (a frame carrying only `sourceTaskId` no longer masquerades as the leader's), and a reasoning segment that spans tool cards now hands the host every committed row it covers (`committedUuids`) so nothing unredacted is left behind |
395
395
  | `scripts/run-gate-negative-controls-test.mjs` | Whether the registry-shaped guards among the 74 suites above actually turn red when the material they check really breaks — a census had found 16 of them clean enough to rehearse safely (closed sets, mirrors, baselines, floors, a type-shape ratchet) without touching any judgement code. Each is exercised by tampering a disk copy of the real material, spawning the guard's own unmodified script, asserting it exits non-zero and names the disease, then restoring the file byte-for-byte. Seven guards of the same shape and 51 behaviour/projection suites are catalogued rather than rehearsed this round — see `docs/GATE-NEGATIVE-CONTROLS.md` for the full table, the reasons, and a one-minute manual replay recipe for each blind one. The suite cross-checks its own case count against that document's row counts in both directions, so a case quietly dropped from the array without the document following is itself an undeclared blind guard. The backup that makes the restore possible is taken by **exclusive create**: checking for it and then copying are otherwise two steps, and two instances can pass the check together — the later one overwrites the only clean copy with material the earlier one has already tampered, and the rehearsal that promises to leave no trace leaves a permanently corrupted file instead. That interleaving is rehearsed too, in a throwaway directory of its own |
396
+ | `scripts/run-engine-cap-reader-factory-test.mjs` | The one shared implementation behind every capability reader's four ports (cache, generation gate, probe tee, invalidation), exercised as a table: every reader in the table runs the *same* criteria (the table length is the source of truth, and a roster check fails the gate if any source file calls the factory without having a row) — the four states, a throwing projection treated exactly like an unreadable one (and never escaping the tee), the generation rules (a stale generation is dropped before the projection even runs; a projection that changes the generation mid-flight cannot overwrite the newer value, whether it returns or throws; the caller's `opts` is snapshotted once; and omitting the generation still writes, because that supply is additive and this refactor does not quietly tighten it), the top-level key's getter being read exactly once, a freshly minted "unobserved" reading on every miss (two misses are never the same object, so a consumer that mutates one cannot taint another base URL), one independent table per reader that never take each other down, invalidating one base URL leaving every other base URL's reading untouched, `forget`/tee being no-ops on an empty or non-string base URL, the read anchor being resolved dynamically, and the deliberate split in how presence is judged per reader. |
397
+ | `scripts/run-mcp-liveness-test.mjs` | The engine's **liveness observation** about each MCP server it hosts (`wiring_manifest.mcp[].liveness`, engine-side from core 7.24.3 / server 7.91.2): one reader, one word list, one leg-level verdict. The cell answers *can this server still be reached* — it is not the connect-time verdict beside it, which the engine deliberately freezes (a server that died mid-run still reads `connected`), and it is not a re-dial's judgement either, so `status: "failed"` next to `liveness.state: "reachable"` is a **real row**: the server answered the handshake and answered with a protocol error — up, and misconfigured. The three words are read as a closed set (an exchange completed / it was lost in transport or the clock / what came back does not answer the question), and a word outside it is malformed rather than rendered, because a word nobody upstream has defined is not a sentence worth putting on screen. **Absence is the fourth reading and is not one of the words**: it means *no liveness record is available*, which on the wire covers a server this leg never reached, a declaration that could not be dialled, an older engine, and a record the projection ahead of us dropped — all indistinguishable, so it is never read as "we looked and could not tell" (a strictly stronger claim), never as healthy and never as off. The failure-class footnote rides the unreachable word only, and one that turns up anywhere else, or that is malformed, loses **just the footnote** while the word and its timestamp stay: the honest reading is then "cannot be reached, reason not given", not "this record is broken". Malformed never becomes healthy: a cell that is present but unreadable marks its row and pulls the leg-level verdict back to *cannot tell*, since letting it sit beside a reachable row would report a leg as reachable on the strength of a record that may well have said the opposite. The verdict takes the worst fact first rather than a majority or the newest reading, carries no server count — so there is no fabricated zero to be read as "no problems" — and its timestamp belongs to **that leg's** observation, not to now: the engine runs no probe and adds no traffic of its own, so this is a per-leg snapshot rather than a heartbeat. The replayed roster on the session panel and the live leg go through the same reader, and the panel's own deployment-side rows carry no liveness position today, so the verdict does not borrow a word from that face. The word list is bitten in both directions where an installed witness exists and the absence of one is itself asserted against the installed engine's version, so the day it ships the comparison starts on its own; the day the wire types declare the cell, the guard turns red and asks for the anchor to move there |
398
+ | `scripts/run-peer-lane-rules-write-capability-test.mjs` | Two more engine self-descriptions read the same four-state way as their seven sibling capability readers (`capabilities.peerLane`, `capabilities.permissionRulesWrite`): an absent key is not reported (an older engine that predates the position, never folded into `false`), `true` is present, `false` is a positive absent (the cross-session lane not being mounted on this deployment, or this particular call not being able to reach the tightening-direction write entry), and anything else is unreadable and drops the cell. Each carries its own single-source verdict (`peerLaneAvailable` returns `yes`/`no`/`unknown`; `permissionRulesWriteAvailable` collapses to a plain boolean, present being the only `true`). The write-entry position pairs with a boolean convenience port in the persisted-rules module, and this guard pins that port to derive from nothing but this one reader's own reading — never a conjunction with the lane-reachable position, and never a second read of the deployment-level existence signal the revoke surface uses (the two are documented as reading differently on purpose): a deployment where the lane answers true but the write entry's key is simply absent (an older binary) must still come back `false`, a deployment where the write entry answers true while the lane key is entirely unseen must still come back `true` (proving no silent conjunction crept in), seeding only the general capabilities cache — never this reader's own feed — must still come back `false` (proving the convenience port cannot be satisfied by the wrong table), and passing an explicit `undefined` base URL must still come back `false` even while a different, already-installed engine target answers `true` for the same position (an adversarial pass found the naive forward of that parameter falls through to the reader's own convenience default, silently answering for whichever engine happens to be installed rather than the caller's absent target — the fix routes an explicit absence through the same empty-string path the reader treats as unobserved). |
399
+ | `scripts/run-lane-proof-identity-test.mjs` | The **instance identity of a lane proof**: the main-lane proof is minted fresh on every emission. Previously a single module-level constant object was handed both to `laneOf(an unregistered task id)` and to some fifty main-lane emission points, so two unrelated consumers — across adapter instances, across streams, across turns — held the same object: writing a card id onto one of them was readable on the other, and the four opening main-lane events changed together. Nothing in this package writes to a lane proof and the known consumers only read it, so this is an **aliasing hazard on a published output surface** rather than an observed corruption — a consumer that uses the proof as an identity key, for dedup, or as a view-layer identity would conflate two unrelated rows without writing a single byte, which is precisely the half that freezing the object would not solve. The gate therefore anchors on instance identity: two independent adapter instances, two rows inside one instance, the same id read twice, and two arms in one beat are each distinct references; mutating one leaves the others byte-identical; and the subagent lane, which already minted fresh, is the control that proves the criterion discriminates. The main-lane **value** is unchanged — an unregistered id still answers `{lane:"main"}` with exactly one own key and still emits its events, so absence is not turned into a second kind of absence — with ordering pinned three ways (registered-then-read, read-then-registered with no retroactive edit of an already delivered proof, the same id twice) and the id failure classes pinned four ways (unregistered, empty string, absent, non-string, the last two emitting no panel event at all rather than an ownerless proof). Where one row emits **two** events — the terminal-tick and card-close legs, which each yield a lifecycle stop and a panel end — the attribution is decided once (a consumer binding a card between the two yields must not split one row across two lanes) while each event still gets its own proof, so a host consuming them one at a time cannot poison the second before it is even yielded. The run stream leg is covered as the same shape, and a syntax-tree check forbids reintroducing a module-level lane-proof object literal or a module-level `LaneProof`-annotated binding (judged on the type node, not on text, so a compile-time pin tuple that merely mentions the type is not miscaught), backed by a type-checker pass that also catches an un-annotated module-level cache such as `const x = mainLane()` while letting the callable factory itself through, while the module-private three-state sentinels of the untrusted read are frozen instead — only `Object.freeze` counts, never `Object.seal`, which still permits writes to existing keys — their exposure being confined to one module |
400
+ | `scripts/run-memory-entries-wire-test.mjs` | The two memory-governance capability bits and the three memory-entry response readers. Each bit (`capabilities.memoryCompliance`, for the entry-provenance and erasure endpoints; `capabilities.memoryOrigin`, for the external-origin listing and clearance endpoints) is read the same four-state way as its sibling capability readers: an absent key is reported as not reported (never folded into `false` — an older engine simply does not answer, and the right next step is to try the endpoint and read its 501), `true` is the face being mounted, `false` is a positive "not on this deployment" (the wire does not distinguish a backend without control-plane ownership from an empty operator roster, so the wording never guesses which), any non-boolean value is unreadable and drops the cell instead of being folded into "absent", and a capabilities body that is not an object at all is unreadable rather than "not reported". The two bits deliberately stay **two** readers with two separate per-engine tables, because the engine deliberately keeps them two separate product faces even while they happen to carry the same value today: feeding one an unreadable body, or invalidating one, leaves the other's reading untouched, and a body where one is on and the other off is answered one bit at a time. The entry-export reader narrows each row on its own (an empty array really is zero rows, a non-empty array with nothing readable in it is reported as unreadable rather than as "no rows", and partly bad rows are kept with a dropped count), reads the external-origin marker as three states rather than a boolean (the two structural carriers mark a row; a row whose frontmatter cannot be read, or which carries the third, suspended-form carrier, is undecidable, because the judge for that carrier lives in the engine and this package refuses to mint a second copy of it), and treats an unreadable "is this the whole scope" flag as "not the whole scope". Its verdict port implements — in code, not in a comment — the rule that an empty answer is never a clean store: the caller must state whether the request declared origin-awareness, because this endpoint withholds marked entries by default and the two bodies are shaped identically, so without that statement an empty answer is only ever "unknown"; the affirmative answer is scoped to the one named scope and carries that scope with it, and the type has no store-wide arm at all. The erasure receipt reader keeps three things apart that are easy to collapse: "this call erased nothing" (a real receipt whose erased list is empty and whose not-found list explains why, per id), "a 200 with an empty body", and "a body that could not be read" — at the reading, the counting and the verdict layer alike; it refuses a version envelope it does not recognise instead of reinterpreting it, treats the three closed vocabularies as closed (an unknown word is unreadable, never folded into a known arm), keeps an unreadable binding as unknown instead of claiming "unbound", passes the "history cannot be judged" flag through as four states (set, explicitly unset, absent, and present-but-unreadable — an unreadable flag is kept distinct from an absent one, and the history verdict then answers "unknown" rather than the stronger claim), and answers the replay question as three states so that the degraded lane is never retried automatically. The clearance receipt reader carries the cleared marker through verbatim and says separately whether it was reported at all. Every array in every response is snapshotted once — the length is read exactly once and each index exactly once, rather than iterating the caller's own iterator — because an array that reports one length while being walked and another afterwards could otherwise have a marked row quietly dropped while the "was anything unreadable" check saw nothing, which ends in calling the scope clean; an array that reports an absurd length is reported as unreadable rather than silently truncated to its first rows. All three readers never throw. |
396
401
 
397
402
  Each suite carries a floor that only moves up — a refactor that stops executing a group of
398
403
  assertions is a failure, not a quieter pass. Guards anchor on the **installed artefact's content**
@@ -1,4 +1,4 @@
1
- import { isSubFlowSegmentEnd, SEGMENT_END_IDENTITY_KEYS } from '../adapter/types.js';
1
+ import { isSubFlowSegmentEnd, SEGMENT_END_IDENTITY_KEYS, snapshotSegmentIdentity, snapshotIsSubFlow } from '../adapter/types.js';
2
2
  import { classifyPeerNotification, renderPeerFrameTranscriptText } from '../peerFrames.js';
3
3
  import { dropQueuedNotificationsForRun, isEngineWorkflowNotified, isWorkflowCompletionCardEnqueued, markEngineWorkflowNotified, noteOwnWorkflowRun, normalizeTaskNotification, noteWorkflowCompletionCardEnqueued, renderTaskNotificationXml, taskNotificationDedupKey } from '../notifications.js';
4
4
  import { mapBrainStatusToRetry } from '../retryStatus.js';
@@ -13,7 +13,7 @@ import { isTerminalStatus } from '../runTerminal.js';
13
13
  import { resolveEnginePanelTaskId } from '../engineAgentPanelStore.js';
14
14
  // 0.75.1 CC-95(b):子代类型的诚实来源台账(fleet 行帧折进 name 之前留的那一份;包内叶)。
15
15
  import { fleetRowAgentType } from '../fleet/fleetRowAgentType.js';
16
- import { chrome, MAIN, transcript, messageIdentityOf } from './ids.js';
16
+ import { chrome, mainLane, transcript, messageIdentityOf } from './ids.js';
17
17
  import { CANCEL_MESSAGE, decisionOf, flattenWireOutput, REJECT_MESSAGE, sanitizeToolUseBlock, shortTaskLabel, TASK_TOOL_NAMES, WORKFLOW_TOOL_NAMES, } from './wireShapes.js';
18
18
  // ══ M2/M1 —— assistant(tool_use 半场 + durable 文本/思考半场)══════════════════════════════════
19
19
  const assistantArm = function* (m, { ctx, idOf, text, cards, inst }) {
@@ -53,7 +53,7 @@ const assistantArm = function* (m, { ctx, idOf, text, cards, inst }) {
53
53
  if (TASK_TOOL_NAMES.has(toolName)) {
54
54
  yield chrome({
55
55
  kind: 'task_ledger_sync',
56
- laneProof: MAIN,
56
+ laneProof: mainLane(),
57
57
  source: 'tool_use',
58
58
  toolName,
59
59
  toolUseId,
@@ -190,7 +190,7 @@ const systemArm = function* (m, { ctx, idOf }) {
190
190
  continue;
191
191
  yield chrome({
192
192
  kind: 'attachment',
193
- laneProof: MAIN,
193
+ laneProof: mainLane(),
194
194
  id: idOf(m, `attached-file-${i++}`),
195
195
  attachment: {
196
196
  type: 'compact_file_reference',
@@ -207,7 +207,7 @@ const diagnosticsArm = function* (m, { idOf }) {
207
207
  const att = projectDiagnosticsFrame(m);
208
208
  if (att === null)
209
209
  return;
210
- yield chrome({ kind: 'attachment', laneProof: MAIN, id: idOf(m), attachment: att });
210
+ yield chrome({ kind: 'attachment', laneProof: mainLane(), id: idOf(m), attachment: att });
211
211
  };
212
212
  const steeringInjectedArm = function* (m, { idOf }) {
213
213
  if (typeof m.parentToolCallId === 'string')
@@ -220,7 +220,7 @@ const steeringInjectedArm = function* (m, { idOf }) {
220
220
  for (const att of steeringInjectedToAttachments(source, preview)) {
221
221
  yield chrome({
222
222
  kind: 'attachment',
223
- laneProof: MAIN,
223
+ laneProof: mainLane(),
224
224
  id: idOf(m, `steering-${i++}`),
225
225
  attachment: att,
226
226
  });
@@ -234,7 +234,7 @@ const workspaceChangedArm = function* (m, { idOf }) {
234
234
  return;
235
235
  yield chrome({
236
236
  kind: 'attachment',
237
- laneProof: MAIN,
237
+ laneProof: mainLane(),
238
238
  id: idOf(m),
239
239
  attachment: { type: 'workspace_changed', cwd },
240
240
  });
@@ -267,7 +267,7 @@ const humanInputArm = function* (m) {
267
267
  };
268
268
  yield chrome({
269
269
  kind: 'human_input',
270
- laneProof: MAIN,
270
+ laneProof: mainLane(),
271
271
  carrier,
272
272
  ...(str(m.inputId) !== undefined ? { inputId: str(m.inputId) } : {}),
273
273
  ...(typeof m.sessionSeq === 'number' && Number.isFinite(m.sessionSeq) ? { sessionSeq: m.sessionSeq } : {}),
@@ -301,7 +301,7 @@ const messageCommittedArm = function* (m) {
301
301
  return;
302
302
  yield chrome({
303
303
  kind: 'message_committed',
304
- laneProof: MAIN,
304
+ laneProof: mainLane(),
305
305
  entryId,
306
306
  role,
307
307
  ...(typeof m.toolCallId === 'string' && m.toolCallId.length > 0 ? { toolCallId: m.toolCallId } : {}),
@@ -336,7 +336,7 @@ const engineNoticeArm = function* (m) {
336
336
  : {};
337
337
  yield chrome({
338
338
  kind: 'engine_notice',
339
- laneProof: MAIN,
339
+ laneProof: mainLane(),
340
340
  code,
341
341
  message: typeof m.message === 'string' ? m.message : '',
342
342
  detail,
@@ -392,7 +392,7 @@ const wiringManifestArm = function* (m) {
392
392
  return;
393
393
  yield chrome({
394
394
  kind: 'wiring_manifest',
395
- laneProof: MAIN,
395
+ laneProof: mainLane(),
396
396
  ...(hasGate ? { modelGate: modelGate } : {}),
397
397
  ...(hasAuto ? { autoMode: autoMode } : {}),
398
398
  ...(hasMcp ? { mcp: mcp } : {}),
@@ -428,7 +428,7 @@ const approvalFrameArm = (kind) => function* (m) {
428
428
  const frame = m.frame;
429
429
  if (typeof frame !== 'object' || frame === null)
430
430
  return;
431
- yield chrome({ kind, laneProof: MAIN, frame, schemaVersion });
431
+ yield chrome({ kind, laneProof: mainLane(), frame, schemaVersion });
432
432
  };
433
433
  /**
434
434
  * `text_end` 内部臂 → **权威段替换** + chrome `text_segment_end`(#323 / core #447;L-310,
@@ -485,7 +485,7 @@ const toolDisclosureArm = function* (m) {
485
485
  return; // 二道判:非投影口喂进来的帧
486
486
  yield chrome({
487
487
  kind: 'tool_disclosure',
488
- laneProof: MAIN,
488
+ laneProof: mainLane(),
489
489
  policy,
490
490
  ...(typeof m.thresholdPercent === 'number' ? { thresholdPercent: m.thresholdPercent } : {}),
491
491
  deferred,
@@ -508,7 +508,7 @@ const toolProgressArm = function* (m) {
508
508
  const output = typeof m.output === 'string' ? m.output : undefined;
509
509
  yield chrome({
510
510
  kind: 'tool_progress',
511
- laneProof: MAIN,
511
+ laneProof: mainLane(),
512
512
  toolCallId,
513
513
  toolName,
514
514
  ...(typeof m.elapsedTimeSeconds === 'number' ? { elapsedSeconds: m.elapsedTimeSeconds } : {}),
@@ -530,7 +530,7 @@ const toolRosterDeltaArm = function* (m) {
530
530
  return;
531
531
  yield chrome({
532
532
  kind: 'tool_roster_delta',
533
- laneProof: MAIN,
533
+ laneProof: mainLane(),
534
534
  delta: delta,
535
535
  ...(typeof m.eventId === 'string' && m.eventId.length > 0 ? { eventId: m.eventId } : {}),
536
536
  });
@@ -551,7 +551,7 @@ const contextUsageArm = function* (m) {
551
551
  return;
552
552
  yield chrome({
553
553
  kind: 'context_usage',
554
- laneProof: MAIN,
554
+ laneProof: mainLane(),
555
555
  ...(usedTokens !== undefined ? { usedTokens } : {}),
556
556
  ...(windowTokens !== undefined ? { windowTokens } : {}),
557
557
  ...(compactAtTokens !== undefined ? { compactAtTokens } : {}),
@@ -569,16 +569,21 @@ const compactionOutcomeArm = function* (m) {
569
569
  const outcome = str(m.outcome);
570
570
  if (outcome === undefined)
571
571
  return;
572
- let laneProof = MAIN;
573
- if (isSubFlowSegmentEnd(m)) {
574
- const parent = str(m.parentToolCallId);
572
+ // 🔴 身份四键一次性快照(0.76.1;外部验收方对 0.76.0 的 F1):修前先 isSubFlowSegmentEnd(m) 再 str(m.parentToolCallId),
573
+ // parentToolCallId 读了两次 ⇒ 值随读变的帧让直喂入口丢事件、经投影却出 subagent chrome —— 两入口分叉。
574
+ const idSnap = snapshotSegmentIdentity(m);
575
+ if (idSnap === null)
576
+ return; // 读身份就抛 ⇒ 整帧拒(与投影口判 malformed 同一处置,不发 chrome)
577
+ let laneProof = mainLane();
578
+ if (snapshotIsSubFlow(idSnap)) {
579
+ const parent = str(idSnap.parentToolCallId);
575
580
  if (parent === undefined)
576
581
  return;
577
582
  laneProof = { lane: 'subagent', parentToolCallId: parent };
578
583
  }
579
584
  const trigger = str(m.trigger);
580
585
  const reason = str(m.reason);
581
- const eventId = str(m.eventId);
586
+ const eventId = str(idSnap.eventId);
582
587
  yield chrome({
583
588
  kind: 'compaction_outcome',
584
589
  laneProof,
@@ -653,21 +658,12 @@ const promptAssembledArm = function* (m) {
653
658
  // 取 parent 又读一次」会让同一帧在两条路上归属不同 lane —— 子代的组成信息被洗成主会话的
654
659
  // (第三轮复审 [medium] 实测可复现)。所以这里**不**用 `isSubFlowSegmentEnd(m)`(它会再读一遍),
655
660
  // 子流判定直接看快照里「三键有没有在场」,语义与它逐字同(键在场 = 子代帧,空串也算在场)。
656
- const idSnap = {};
657
- try {
658
- for (const k of ['eventId', ...SEGMENT_END_IDENTITY_KEYS])
659
- idSnap[k] = m[k];
660
- }
661
- catch {
662
- // §C1 带理由的吞:读身份键本身抛(进程内帧的 accessor 抛)⇒ **整帧拒**,不发 chrome。
663
- // 为什么这里吞是对的:身份决定的是「这条组成信息算谁的」——读不出归属时,任何一种猜(当主会话 / 当子代)
664
- // 都会把一只子代的 prompt 组成挂到别人名下;而本臂是**可选渲染面**(required:false),不发的代价只是这一帧不上屏。
665
- // 为什么不上报:适配层没有诊断出口(chrome 臂本身就是出口),再向上抛会打断整条 adapt 流、连带吞掉同批的真帧。
666
- return;
667
- }
661
+ const idSnap = snapshotSegmentIdentity(m);
662
+ if (idSnap === null)
663
+ return; // 读身份就抛 ⇒ 整帧拒(理由见 snapshotSegmentIdentity 头注与本臂上方的说明)
668
664
  if (SEGMENT_END_IDENTITY_KEYS.some((k) => idSnap[k] !== undefined && typeof idSnap[k] !== 'string'))
669
665
  return;
670
- let laneProof = MAIN;
666
+ let laneProof = mainLane();
671
667
  if (SEGMENT_END_IDENTITY_KEYS.some((k) => idSnap[k] !== undefined)) {
672
668
  const parent = str(idSnap.parentToolCallId);
673
669
  if (parent === undefined)
@@ -703,7 +699,7 @@ const textSegmentEndArm = function* (m, { text }) {
703
699
  const r = text.replaceAnswerSegment(content, m);
704
700
  yield chrome({
705
701
  kind: 'text_segment_end',
706
- laneProof: MAIN,
702
+ laneProof: mainLane(),
707
703
  content,
708
704
  // CC-01:帧上带的是**轮换前**的段身份 —— 与本段已过境的 committed 行同值(归属的第二把钥匙)。
709
705
  segmentId: text.segmentId(),
@@ -735,7 +731,7 @@ const thinkingSegmentEndArm = function* (m, { text }) {
735
731
  const r = text.replaceThinkingSegment(content, m);
736
732
  yield chrome({
737
733
  kind: 'thinking_segment_end',
738
- laneProof: MAIN,
734
+ laneProof: mainLane(),
739
735
  content,
740
736
  ...(r.diverged ? { diverged: true } : {}),
741
737
  ...(r.committedPrefixDiverged ? { committedPrefixDiverged: true } : {}),
@@ -756,7 +752,7 @@ const promptSuggestionsArm = function* (m) {
756
752
  // 空批不发(cli 同门:`if (list.length > 0)`)—— 作废由 turn 开场那一发负责,
757
753
  // 这里再发一次 null 会把本轮真产出的建议在极端交错下擦掉。
758
754
  if (list.length > 0) {
759
- yield chrome({ kind: 'prompt_suggestions', laneProof: MAIN, suggestions: list });
755
+ yield chrome({ kind: 'prompt_suggestions', laneProof: mainLane(), suggestions: list });
760
756
  }
761
757
  };
762
758
  // ══ M2 —— tool_end_result(关卡 + B/D/E 层卡本体 + 两个侧产物)══════════════════════════════════
@@ -802,14 +798,19 @@ const toolEndResultArm = function* (m, { ctx, idOf, cards, panel }) {
802
798
  if (p === undefined || callId === undefined)
803
799
  return;
804
800
  const structured = m.structured;
801
+ // 身份键各读一次(0.76.1:此前 typeof 再取值 = 读两次;值随读变的帧会让判形与取值各看一份)。
802
+ const closeEventIdRaw = m.eventId;
803
+ const closeParentRaw = m.parentToolCallId;
804
+ const closeEventId = typeof closeEventIdRaw === 'string' ? closeEventIdRaw : undefined;
805
+ const closeParent = typeof closeParentRaw === 'string' ? closeParentRaw : undefined;
805
806
  // 出栈 + S1 复位(**yield 之前**)已收进 close() —— 见 toolCards.ts 的拆分硬约束。
806
807
  yield* cards.close(p, {
807
808
  output: m.output,
808
809
  structured,
809
810
  truncated: m.truncated === true,
810
811
  isError: m.isError === true,
811
- eventId: typeof m.eventId === 'string' ? m.eventId : undefined,
812
- parentToolCallId: typeof m.parentToolCallId === 'string' ? m.parentToolCallId : undefined,
812
+ eventId: closeEventId,
813
+ parentToolCallId: closeParent,
813
814
  });
814
815
  const s = structured !== undefined && structured !== null && typeof structured === 'object'
815
816
  ? structured
@@ -837,7 +838,7 @@ const toolEndResultArm = function* (m, { ctx, idOf, cards, panel }) {
837
838
  if (s && (s.type === 'task' || s.type === 'task-list')) {
838
839
  yield chrome({
839
840
  kind: 'task_ledger_sync',
840
- laneProof: MAIN,
841
+ laneProof: mainLane(),
841
842
  source: 'structured',
842
843
  structuredType: s.type,
843
844
  structured: s,
@@ -860,10 +861,12 @@ const taskProgressArm = function* (m, { ctx, cards, panel, inst }) {
860
861
  // 只带 rollup 数字 = CC `AgentProgress` 同形 ⇒ 忠实的 CC-187 面是 footer 面板行
861
862
  // (CC 自己对进程外 agent 的先例:面板行 + task-notification,从不合成 inline 消息轨)。
862
863
  // **不产任何 transcript 消息**:全是 chrome。
863
- const taskId = typeof m.taskId === 'string' ? m.taskId : undefined;
864
+ const taskIdRaw = m.taskId;
865
+ const taskId = typeof taskIdRaw === 'string' ? taskIdRaw : undefined;
864
866
  if (taskId === undefined)
865
867
  return;
866
- const explicitParent = typeof m.parentToolCallId === 'string' ? m.parentToolCallId : undefined;
868
+ const explicitParentRaw = m.parentToolCallId; // 读一次(0.76.1:typeof 再取值 = 读两次)
869
+ const explicitParent = typeof explicitParentRaw === 'string' ? explicitParentRaw : undefined;
867
870
  // ── W1 段2([1617]②,frame-lane-matrix #2/#8)workflow lane 门:workflow 子代的 tick 属
868
871
  // fleet lane 自描述行(footer 只渲 workflow 聚合行),**绝不**进前台 subagent lane
869
872
  // (不登 alias、不 bind、不 livePanelTasks、不发 panel tick —— 修前每个 workflow agent
@@ -1079,7 +1082,7 @@ function prefixOf(whole, part) {
1079
1082
  return null;
1080
1083
  }
1081
1084
  const resultArm = function* (m, { ctx, idOf, text, cards, panel, flags }) {
1082
- yield chrome({ kind: 'retry_status', laneProof: MAIN, status: null });
1085
+ yield chrome({ kind: 'retry_status', laneProof: mainLane(), status: null });
1083
1086
  // MF-10 防御 sweep(sweep 点 2/4):结掉那些卡从没关过的行(流被截断)。
1084
1087
  // cli 同位:setRetryStatus(null) 之后、开卡兜底关闭之前。
1085
1088
  yield* panel.settle(null, false);
@@ -1170,7 +1173,7 @@ const resultArm = function* (m, { ctx, idOf, text, cards, panel, flags }) {
1170
1173
  // 改口。两个位是**长度**不是内容 —— 不复述任何一份正文。
1171
1174
  yield chrome({
1172
1175
  kind: 'result_text_diverged',
1173
- laneProof: MAIN,
1176
+ laneProof: mainLane(),
1174
1177
  committedLength: committed.length,
1175
1178
  resultLength: terminalText.length,
1176
1179
  });
@@ -1195,7 +1198,7 @@ const taskNotificationArm = function* (m, { ctx, idOf, panel, inst }) {
1195
1198
  if (settleAllowed && terminal) {
1196
1199
  yield chrome({
1197
1200
  kind: 'bgshell_settle',
1198
- laneProof: MAIN,
1201
+ laneProof: mainLane(),
1199
1202
  taskId,
1200
1203
  status: rawStatus,
1201
1204
  ...(typeof n.output_file === 'string' && n.output_file.length > 0
@@ -1218,7 +1221,7 @@ const taskNotificationArm = function* (m, { ctx, idOf, panel, inst }) {
1218
1221
  if (n.task_type !== 'external' && terminal) {
1219
1222
  yield chrome({
1220
1223
  kind: 'notification_terminal',
1221
- laneProof: MAIN,
1224
+ laneProof: mainLane(),
1222
1225
  taskId,
1223
1226
  status: rawStatus,
1224
1227
  ...(typeof n.task_type === 'string' ? { taskType: n.task_type } : {}),
@@ -1322,7 +1325,7 @@ const workflowCompleteArm = function* (m) {
1322
1325
  noteWorkflowCompletionCardEnqueued(runId);
1323
1326
  yield chrome({
1324
1327
  kind: 'workflow_notification_enqueue',
1325
- laneProof: MAIN,
1328
+ laneProof: mainLane(),
1326
1329
  runId,
1327
1330
  status: wfStatus,
1328
1331
  summary: wfSummary,
@@ -1375,10 +1378,10 @@ const retryStatusArm = function* (m, { ctx, flags }) {
1375
1378
  // 首版写成「无条件发 + clearRetryOverlay」,在常态顺序上双清(对抗复审命中)。
1376
1379
  if (status === null) {
1377
1380
  if (flags.takeRetryOverlay())
1378
- yield chrome({ kind: 'retry_status', laneProof: MAIN, status: null });
1381
+ yield chrome({ kind: 'retry_status', laneProof: mainLane(), status: null });
1379
1382
  return;
1380
1383
  }
1381
- yield chrome({ kind: 'retry_status', laneProof: MAIN, status });
1384
+ yield chrome({ kind: 'retry_status', laneProof: mainLane(), status });
1382
1385
  // ⟨帧序耦合 6/7⟩ 挂旗;下一个主 lane 内容帧到达时由 TurnFlags 的帧前置清掉(恢复即清)。
1383
1386
  flags.noteRetryOverlay();
1384
1387
  };
@@ -1,8 +1,35 @@
1
1
  import type { AdapterContext, AdapterOutput, ChromeEvent, LaneProof } from '../seam.js';
2
2
  /** 帧 = wire 投影出来的中性 arm(cli seam/adapter downstream 的 SDKMessage 形),结构化读取。 */
3
3
  export type Frame = Record<string, unknown>;
4
- /** 主车道证明——本批实现的臂全在 leader lane(子代/workflow lane 的 tick 面留 #52b)。 */
5
- export declare const MAIN: LaneProof;
4
+ /**
5
+ * 主车道证明的**出厂口** —— 每次调用交出一只**新的** `{lane:'main'}`(本批实现的臂全在 leader
6
+ * lane;子代/workflow lane 的 tick 面留 #52b)。
7
+ *
8
+ * 🔴 **为什么是函数不是常量**(0.76.1)。此前这里是一只模块级常量对象,`laneOf(未登记 id)` 与本包
9
+ * 约五十个 `laneProof:` 主车道发点交出去的是**同一只**对象 —— 跨适配器实例、跨流、跨 turn 全是
10
+ * 它。实测(修前,本门 L1/L5 段的同一条路径):两个互不相干的消费者各取一次未登记行的证明,
11
+ * `===` 为真;给第一只补上卡号之后,第二只当场读出 `{lane:'subagent', parentToolCallId:'…'}`,
12
+ * 开流序幕那四条主车道事件也一起变 —— **一次写打穿整个进程**,而且静默。
13
+ *
14
+ * 🔴 **判据如实**:这是**别名风险**,不是「已经在串味的现场」。修前逐站点穷举(语法树扫写表达式)
15
+ * 的结论是:本包内对车道证明**零就地写**(两处看起来像写的是给局部变量**换绑**新对象,不改旧的);
16
+ * 三个消费端的证明用法全是只读(读 `lane` 位 / 解构丢弃)。⇒ 本批修的是「交出去的可变引用被
17
+ * 当成缺省值别名」这一形,不是在修一条已经在错归因的行。发布文案不许把它写成用户可见的回归。
18
+ * 这条形本身仍然要修:`laneProof` 是**公开输出面**上的一位,拿它做 WeakMap 键 / 身份去重 /
19
+ * 视图层身份记忆的消费者,在两条无关行共享一只对象时会把它们认成同一条 —— **一个字都不写也照错**。
20
+ * 这也正是冻结那条修法救不了的一半。
21
+ *
22
+ * 🔴 **为什么不是冻结**(两条都摆过,这条落选):
23
+ * ① 冻结只挡「写」,不解「别名」——上一段那三种按身份认物的消费面照错;每次新建两件一起解决。
24
+ * ② 子代臂 `{lane:'subagent', parentToolCallId}` 与 workflow 臂本来就**逐次新建**且可写,只冻主
25
+ * 车道这一半 ⇒ 同一处写操作会因为「这条行恰好绑没绑卡」而一边抛一边收,失败随行漂成间歇形;
26
+ * 要对称就得连十几处内联字面量一起冻,比每次新建更大的改动换更小的收益。
27
+ * ③ 证明是在宿主手里被写的:冻结把一条本包的别名缺陷变成宿主侧的抛错,而宿主的抛会顺着
28
+ * `for await` 打断整条 adapt 流。适配层不该给主链添这种反噬。
29
+ * ⚠️ 代价如实:每条主车道事件多一只小对象(≈ 一个键的字面量)。别为了省它把出厂口缓存成模块级
30
+ * 单例(`const M = mainLane()` 就是把这一形原样种回来)—— 结构反钉门盯着这一形。
31
+ */
32
+ export declare const mainLane: () => LaneProof;
6
33
  /** 帧 → 稳定转录 id(`suffix` = 同一帧派生多条消息时的去撞位)。 */
7
34
  export type IdOf = (frame: Frame, suffix?: string) => string;
8
35
  /**
package/dist/adapt/ids.js CHANGED
@@ -1,6 +1,33 @@
1
1
  import { deriveTranscriptId } from '../seam.js';
2
- /** 主车道证明——本批实现的臂全在 leader lane(子代/workflow lane 的 tick 面留 #52b)。 */
3
- export const MAIN = { lane: 'main' };
2
+ /**
3
+ * 主车道证明的**出厂口** —— 每次调用交出一只**新的** `{lane:'main'}`(本批实现的臂全在 leader
4
+ * lane;子代/workflow lane 的 tick 面留 #52b)。
5
+ *
6
+ * 🔴 **为什么是函数不是常量**(0.76.1)。此前这里是一只模块级常量对象,`laneOf(未登记 id)` 与本包
7
+ * 约五十个 `laneProof:` 主车道发点交出去的是**同一只**对象 —— 跨适配器实例、跨流、跨 turn 全是
8
+ * 它。实测(修前,本门 L1/L5 段的同一条路径):两个互不相干的消费者各取一次未登记行的证明,
9
+ * `===` 为真;给第一只补上卡号之后,第二只当场读出 `{lane:'subagent', parentToolCallId:'…'}`,
10
+ * 开流序幕那四条主车道事件也一起变 —— **一次写打穿整个进程**,而且静默。
11
+ *
12
+ * 🔴 **判据如实**:这是**别名风险**,不是「已经在串味的现场」。修前逐站点穷举(语法树扫写表达式)
13
+ * 的结论是:本包内对车道证明**零就地写**(两处看起来像写的是给局部变量**换绑**新对象,不改旧的);
14
+ * 三个消费端的证明用法全是只读(读 `lane` 位 / 解构丢弃)。⇒ 本批修的是「交出去的可变引用被
15
+ * 当成缺省值别名」这一形,不是在修一条已经在错归因的行。发布文案不许把它写成用户可见的回归。
16
+ * 这条形本身仍然要修:`laneProof` 是**公开输出面**上的一位,拿它做 WeakMap 键 / 身份去重 /
17
+ * 视图层身份记忆的消费者,在两条无关行共享一只对象时会把它们认成同一条 —— **一个字都不写也照错**。
18
+ * 这也正是冻结那条修法救不了的一半。
19
+ *
20
+ * 🔴 **为什么不是冻结**(两条都摆过,这条落选):
21
+ * ① 冻结只挡「写」,不解「别名」——上一段那三种按身份认物的消费面照错;每次新建两件一起解决。
22
+ * ② 子代臂 `{lane:'subagent', parentToolCallId}` 与 workflow 臂本来就**逐次新建**且可写,只冻主
23
+ * 车道这一半 ⇒ 同一处写操作会因为「这条行恰好绑没绑卡」而一边抛一边收,失败随行漂成间歇形;
24
+ * 要对称就得连十几处内联字面量一起冻,比每次新建更大的改动换更小的收益。
25
+ * ③ 证明是在宿主手里被写的:冻结把一条本包的别名缺陷变成宿主侧的抛错,而宿主的抛会顺着
26
+ * `for await` 打断整条 adapt 流。适配层不该给主链添这种反噬。
27
+ * ⚠️ 代价如实:每条主车道事件多一只小对象(≈ 一个键的字面量)。别为了省它把出厂口缓存成模块级
28
+ * 单例(`const M = mainLane()` 就是把这一形原样种回来)—— 结构反钉门盯着这一形。
29
+ */
30
+ export const mainLane = () => ({ lane: 'main' });
4
31
  /**
5
32
  * L-215③(0.65.0)—— 一条 assistant 转录消息的**身份键**(`message.id` / `message.model`)。
6
33
  *
@@ -40,8 +40,9 @@ export interface PanelTaskLedger {
40
40
  unboundCandidates(): number;
41
41
  /** 行的车道证明:绑到卡的 = subagent lane(带父卡 id),未绑的 = 主 lane。 */
42
42
  laneOf(taskId: string): LaneProof;
43
- /** 这条行已经绑过卡了吗(含糊回落的前置判位;别拿 `laneOf(...) === MAIN` 代替 —— 那是
44
- * 按引用相等在推断,判据锚在会决定结果的量上才对)。 */
43
+ /** 这条行已经绑过卡了吗(含糊回落的前置判位;别拿「`laneOf(...)` 是不是那只主车道常量」代替 ——
44
+ * 那是按引用相等在推断,判据锚在会决定结果的量上才对。0.76.1 起主车道证明**逐次新建**,
45
+ * 那种写法连偶然成立都做不到了)。 */
45
46
  isBound(taskId: string): boolean;
46
47
  /**
47
48
  * B-074 —— 这条行绑在一张**本 turn 还开着**的卡上吗(`liveBound`,#6 的 live/inert 判位)。
@@ -75,7 +76,7 @@ export interface PanelTaskLedger {
75
76
  * B-074(0.65.0)**终态 tick 边** —— core 明写的那一拍(`task_progress.status` = `completed`/
76
77
  * `failed`)按 taskId 直接落行。
77
78
  *
78
- * 为什么不复用 {@link settleFromNotification}:那一条是**通知帧**那半场,它按 `MAIN` 车道发
79
+ * 为什么不复用 {@link settleFromNotification}:那一条是**通知帧**那半场,它按**主车道**发
79
80
  * end(bg 子代行本来就是 unbound 的),而终态 tick 的行**可能绑着一张前台委派卡** ⇒ 车道证明
80
81
  * 必须走 {@link laneOf},否则一条 subagent lane 的行会拿 main lane 的证明去落终态(帧×lane
81
82
  * 矩阵那一族的病)。也不复用 {@link settle}:那一条按**卡 id** 收(关卡/防御 sweep 的语义),