@sema-agent/client-core 0.66.0 → 0.67.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.
package/CHANGELOG.md CHANGED
@@ -49,6 +49,124 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.67.1(2026-09-12)
53
+
54
+ **test [7055] 对 0.67.0 的 G32-01~30 验证批回帖三处真实发现的修复批**(patch;零公面导出增删、零
55
+ BREAKING;逐项的形 / 判据 / 三端待办见 `docs/INTEGRATION-CLIENTS.md` §32f、§32g 两段「0.67.1 订正」)。
56
+
57
+ - **B-090 `isSubFlow` 的身份键盲区(最高优先级)** —— 子流判据修前只认 `parentToolCallId`,于是一条
58
+ **只带 `sourceTaskId`** 的子代 `turn_end`(§32g 自己承认的合法组合;core `TaskEventIdentity` 上两键
59
+ **同条件**盖章、顶层任务一位都不带)被判成主流:它的 usage 真的驱动了 `last_turn_usage` /
60
+ `turn_usage`(**§E2 断闸要防的 token-jump 原样重现**),同时整条不进 `_sema_nested_usage_by_task`
61
+ (那只子代的账消失)。⇒ 判据改成**身份信封两键任一在场**(按**键在不在**判,空串 / 坏形也算信封在场
62
+ —— 失效方向是安全的那一侧),分表入表条件同批放宽到「行键读得出」。射程边界如实记:那一形上
63
+ chrome 增量臂 `subagent_turn_usage` **不发**(`LaneProof` 的子流臂硬要求 `parentToolCallId`,拿任务 id
64
+ 去填是身份位互串),**主臂断闸照断、终帧分表照带那一行**。判据 **G32-23b**(四组合真值表逐格)。
65
+ - **B-091 `run_cost_reconciled` 臂未取并** —— 「两面共用同一个读器」修前只到 `readRunCostFacts` 这一层,
66
+ **取并那一层**(`stats.usageMissing` ∪ 流内观测 `ctx.usageMissingObserved`)只包在终帧那一面上;臂的
67
+ 铸点直接展开 `readRunCostFacts(doneStats).reconcile` ⇒ 「流内观测到缺口、终局 stats 缄默」那一形上
68
+ 两面各说各的。⇒ 取并**下沉到唯一读器**:`readRunCostFacts(stats, ctx?)` 加 **additive 第二参**
69
+ (不传 = 旧签名逐位不变),两面读**同一次计算**;臂上 `usageLowerBound` 的在场性与终帧
70
+ `_sema_usage_lower_bound` **逐位相等**,两面同律 never false。判据 **G32-21b**。
71
+ - **`__proto__` 行键陷阱(同形族清剿)** —— 以 wire 给的 id / 键名当对象键的表用裸赋值落键时,
72
+ `taskId === "__proto__"` 那一行走的是 `Object.prototype` 上的 accessor ⇒ **不产生自有属性**,该行在
73
+ `Object.keys` / `JSON.stringify` 里整条消失,连 `partial` 的对账量(行数 vs `nested.tasks`)都被带偏。
74
+ ⇒ 落键一律 `Object.defineProperty`(与 `hitl/crashConverged.ts` 交付快照同一条处置),**交付形不变**
75
+ (端拿到的仍是普通原型对象)。同批族扫改完的同形点:分表行 / `modelUsage` 的 `modelId` /
76
+ `costBreakdown` 的开集键名 / `hitl/parkResolver.ts` 的 `answers`·`annotations`。判据 **G32-23c**。
77
+ - **行键出身混合 ⇒ `partial` 恒立(异源复审实抓,B-090 的跟车修)** —— 放宽入表条件之后一条流上的
78
+ 行键可以有两种出身(真身份 `sourceTaskId` / 有损回落 `parentToolCallId`);混合出身时**一次拆行**
79
+ 与**一次合行**的计数误差方向相反、可以恰好抵消,于是「行数 === `nested.tasks` **且** turns 之和
80
+ === `nested.turns`」两条对账同时成立,一张归属错误的表被标成「可证完整」。⇒ 出身混合时诚实位
81
+ 恒立(失效方向仍只会**多**铸 partial)。判据 **G32-25b**。
82
+
83
+ ### 已知局限(本版**刻意不动**,已立案)
84
+
85
+ - **子代正文分流仍只认 `parentToolCallId`** —— `src/adapter/runStream.ts` 的 C1 内容分流六臂判据
86
+ 未随 B-090 一起认 `sourceTaskId`:一条只带 `sourceTaskId` 的 `text_delta` 会投影成主流
87
+ `stream_event`(`parent_tool_use_id: null`),即子代正文渲成 leader 的正文。**非本版引入**
88
+ (main 上逐字同形),但本版把用量那一半修对之后,同一只子代的正文与用量在这一形上落到了两条
89
+ 车道 —— 该不一致由本版显形。两条 honest 修法(身份位互串 / 留痕丢弃)中可取的那条是**行为面
90
+ 改动**,按黑盒验收制要单独出判据帖 + 三端表态 ⇒ 立案候裁,详见 §32g 的「已知局限」段。
91
+
92
+ ## 0.67.0(2026-09-12)
93
+
94
+ **core 7.14.0 → 7.16.0 提货批**(逐键处置表见 `docs/INTEGRATION-CLIENTS.md` §32z;每件的键名 / 形 /
95
+ 缺席语义 / 三端待办 / 黑盒判据 G32-01 ~ G32-27 见 §32b–§32h)。
96
+
97
+ ### BREAKING
98
+
99
+ - **删公面导出 `classifierUnavailableOf`** —— core 7.14.0 的 Retired keys 表把 `classifierUnavailable`
100
+ 从六个载体上整族删掉(ask 那几处自 #661 起引擎就没写过);继任者是 `classifierDenyCauseOf(门记录)`。
101
+ clean-cut,**不留别名**。
102
+ - **删公面导出 `classifierUnavailableDetail`** —— 同上;继任者是 `classifierDenyCauseDetail(cause)`。
103
+ - **删公面类型 `ClassifierUnavailableView`** —— 同上(它是那只读器的读数形,也是卡位与帧位的型)。
104
+ - **删卡面键 `ApprovalCardRequest.classifierUnavailable` 与帧面键
105
+ `ToolApprovalFrame.classifierUnavailable`** —— 同一个装配位换成 `ruleStoreUnreadable`;旧耐久行带旧键时
106
+ 投影**忽略**(不读、不渲、不崩)。
107
+ - **`ASK_ORIGIN_WORDS` 换词:`unresolvable` → `ancestor_marked`(无 alias)** —— core 7.14.0 的
108
+ `ASK_ORIGINS` BREAKING 换词。旧值在每一道成员筛上都是**非成员** ⇒ 投影省略该 origin、措辞走开集兜底句。
109
+ - **`ENGINE_NOTICE_AUDIENCE` 值域变更:`memory.consolidation_withheld` `user` → `operator`** ——
110
+ core 7.14.0 value-domain BREAKING(这条通告没有 `sessionId`,一次固化跑在任何会话之外)。
111
+ - **`classifierStatusOf` 的第二参语义变更**(签名与三态词表**未改**,不是编译期 BREAKING):
112
+ 从「本轮那只 ask」变成「本轮那一次**观测**」(ask / 耐久行 / 带 `gate` 的 `tool_end` 帧 / 门记录本体);
113
+ 喂旧形的端那一态会恒不出现。
114
+
115
+ ### Added
116
+
117
+ - `CLASSIFIER_DENY_CAUSES` / `isClassifierDenyCause` / `classifierDenyCauseOf` /
118
+ `classifierDenyCauseDetail` —— core 7.14.0 `GateDisposition.denied.cause`(闭二词 `unavailable` /
119
+ `parse_error`)的**拒绝面**读器与唯一措辞铸点;`GateOutcomeView.disposition` 的 denied 臂同批多一格
120
+ `cause?`(**开集透传**,判成员留给公面读器,两层分工与退役前逐字同形)。
121
+ - `RULE_STORE_UNREADABLE_KINDS` / `isRuleStoreUnreadableKind` / `ruleStoreUnreadableDetail` ——
122
+ core 7.14.0 #688 C3:`origin: "rule_store_unavailable"` 底下的**机制位**(`store` = 规则店整体读不出来 /
123
+ `call` = 这条调用对不上规则行),两句人话的下一步**相反**;卡面两腿(活卡帧 + durable park 行)
124
+ 经**同一把**窄读器投到 `ApprovalCardRequest.ruleStoreUnreadable`。
125
+ - `WiringManifestAutoMode.deniedSource?: string`(core 7.14.0 `AutoModeArmFact`)—— 哪一层设置面的棘轮
126
+ 关掉了 auto(`org` / `local` / `settings`,**开集读**);**只在 `reason === "denied"` 旁收**(段内自洽,
127
+ 与本段既有的 `armed === (reason === 'armed')` 互证同族),缺席不铸。
128
+ - 终帧 `_sema_usage_lower_bound: true` 与 `RunCostReconcile.usageLowerBound`(core 7.14.0
129
+ `TaskResult.stats.usageMissing`)—— 「这一面的数字是**下界**不是一次测量」的判别位;
130
+ 🔴 CC 同名键 `usage` / `modelUsage` / `total_cost_usd` **语义零改**,never false。
131
+ - **per-subagent usage 分表**(L-228):新 chrome 臂 `subagent_turn_usage`(`required: false`,`laneProof`
132
+ 恒是子流那条)+ 终帧 `_sema_nested_usage_by_task` 与诚实位 `_sema_nested_usage_by_task_partial`。
133
+ 🔴 §E2 的两处主臂断闸**一个字节不动**;`partial` 的判据锚在引擎自己的权威合计 `stats.nested` 上
134
+ (turns 之和 + 行数都对得上才算可证完整),失效方向只会**多**铸 partial。
135
+ - 码册加员 `task.interrupt_unconsumed`(audience `user`)—— **合同外加员**,由
136
+ `run-engine-notice-catalog-test.mjs` 的码数双向对账当天红抓出,如实登记在 §32z ⑤。
137
+
138
+ ### Changed
139
+
140
+ - devDep `@sema-agent/core` `~7.12.0` → `~7.16.0`。
141
+ - `run-gate-vocabulary-test.mjs` 的 `AskOrigin` **对账基准从 sdk 换成 core**(词表属主是 core,
142
+ 而 sdk 8.8.0 的联合滞后一代);sdk 的滞后改成一条**带退出条件的登记**(追平那天自红逼删)。
143
+ - 同批**退役**一条恒真的型面钉 `_askOriginWordsPin`(sdk 的 `AskOrigin` 带 `(string & {})` 逃生口
144
+ ⇒ 任何字符串都满足它,换词当天一声不响);判据整只移交那道门的 B 段。
145
+ - `run-retired-vocabulary-census-test.mjs` 的剥注释器修一处**词法跑偏**:单/双引号串遇裸换行即收口
146
+ (JS 词法本来如此)—— 修前一次误判会让其后的每一段注释都被当成串体保留,而本门的全部意义正是
147
+ 「注释里写了不算数,码才算数」。
148
+
149
+ ### 已知局限(本版新增)
150
+
151
+ - 🔴 **F-6 的供给面未端到端实证**(异源对抗复审 [high] 提出,本批如实登记而不是销格):core 里子代
152
+ 事件有两条外送腿 —— 工具 ctx 的 `forwardEvent` **白名单**(`prepare-run-refs.js`:无条件过
153
+ `task_progress`,开了 `forwardSubagentEvents` 再加五类转录事件,**`turn_end` 不在其中**;`tool-spec.d.ts`
154
+ 顶注逐字「other event types never cross it」)与**委派车道自己的 tap**(同一段顶注逐字「forwards the
155
+ child's FULL event stream」)。本包消费的是后者,而本批**没有**跑通 core→server→父流的真机供给实证
156
+ (本树无 server fixture)。⇒ 只走白名单腿的部署上分表**恒零行**,两个终帧键按设计**整键不铸**
157
+ (端读到的是「说不出来」,不是一个假的零)。本件因此按「包边界承诺」交付:子流 `turn_end` 到得了这条流
158
+ 就有行,到不了就两个键都不在场;端**不许**把键缺席渲成「这条 run 没委派子代」。真机供给与集成门另立。
159
+
160
+ - `WorkflowRun.errorCode`(core 7.14.0)**本包今天到不了它**:本包投影的两条腿(core `formatWorkflowRun`
161
+ 的 TaskOutput JSON / `summarizeWorkflowRun` 的列表行)都不发 run 级 `errorCode`,第三条腿
162
+ `GET /v1/workflows/:id` 的 sdk 型面无此声明且本树未装 server fixture ⇒ 证不出 service 有没有转投。
163
+ 按「core 定型 ≠ 壳可消费」办:**不猜载体**,`pending`(§32z ①-4)。
164
+ - 首请求 `tools[]` 形(core 7.15.0)对自报面有一处影响未修:`ENGINE_RUNNER_FACE` 把 `ToolSearch` 写成
165
+ **无条件**成员,而 7.15.0 之后它**有条件**了(延迟集为空 ⇒ 无 `ToolSearch`)。阈值只有引擎算得出,
166
+ 正解是让自报面改读引擎的 roster 面 ⇒ `pending`(§32z ③-2)。
167
+
168
+ ---
169
+
52
170
  ## 0.66.0(2026-09-11)
53
171
 
54
172
  > **终局真值三件**(cli 台账 L-192①② / B-068 · DEBTS L-198):三件都是同一条病形 ——
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.66.0
38
+ **Version:** 0.67.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
@@ -230,7 +230,7 @@ public-surface guard checks that last one).
230
230
  | `scripts/run-client-core-portability-test.mjs` | Kernel / A-layer / index import closures, the runtime-dependency equality gate, barrel reachability, and a real esbuild `--platform=browser` bundle |
231
231
  | `scripts/run-client-core-diff-test.mjs` | Differential equivalence against the CLI reference bridge + replay-id invariant + ledger round-trip |
232
232
  | `scripts/run-seat-contract-keys-test.mjs` | The seat IPC contract: verb list ↔ SPEC ↔ types, element-wise |
233
- | `scripts/run-approval-frame-keys-test.mjs` | The tool-approval frame key mirror, element-wise against the SDK's runtime anchor (one carve-out: AHEAD_OF_ANCHOR entries — keys the server already emits but the SDK anchor has not caught up to — may lead by one generation; the gate turns red the day the SDK catches up, forcing the entry's removal — the register is occupied again by the classifier-unavailable key the server already emits, carrying both the release that minted it and the byte coordinates that prove it, so the lead is a dated record rather than an exemption) |
233
+ | `scripts/run-approval-frame-keys-test.mjs` | The tool-approval frame key mirror, element-wise against the SDK's runtime anchor (one carve-out: AHEAD_OF_ANCHOR entries — keys the server already emits but the SDK anchor has not caught up to — may lead by one generation; the gate turns red the day the SDK catches up, forcing the entry's removal — the register is occupied again — this time by the rule-store-unreadable key the engine now defines, carrying both the release that minted it and the byte coordinates that prove it, so the lead is a dated record rather than an exemption; its predecessor left the register the other way, by being retired upstream rather than by the anchor catching up) |
234
234
  | `scripts/run-print-bash-iserror-test.mjs` | The print lane's Bash `is_error` authority (structured over regex) |
235
235
  | `scripts/run-bash-benign-exit-interpretation-test.mjs` | Benign non-zero Bash exits (`returnCodeInterpretation`) stay non-errors across all three derivation arms, and the annotation transits to the card |
236
236
  | `scripts/run-sdk-floor-test.mjs` | The SDK version floor — and, more to the point, that the *installed* type declarations still carry the keys this package reads |
@@ -250,6 +250,7 @@ public-surface guard checks that last one).
250
250
  | `scripts/run-wire-auth-source-test.mjs` | **When** the outbound credential is read. A literal string is consumed at construction — the transport captures it in a closure and every later request reuses that one copy — so once the engine is replaced by another session and the credential rotates, a long-lived client keeps presenting the old one and the only way out is to rebuild the client along with everything hanging off it. The credential position now also accepts a getter that is called **once per outbound request**. The guard anchors on the deciding quantity, which is not "was the getter called" — reading once at construction and reusing the result would satisfy that too, and is exactly the shape being removed — but *which read produced the value on the wire*: it changes the getter's answer between two requests through the same client and requires the second request to carry the new one, and it requires construction to read the getter **zero** times. The three-state credential semantics are replayed per request rather than assumed: on loopback an unavailable credential sends **no** authorization header at all rather than a fabricated one, off loopback it sends the fail-closed anonymous identity so the deployment answers with an honest 401, and the guard shows a single client moving between those states across successive requests. A getter that throws is fail-soft — the request still goes out under the no-credential branch, because a broken credential port should not take the whole wire down, and the exception may itself carry credential material. The same-origin relay form is checked to stay out of the getter path entirely, and every request is checked to keep the credential in the authorization header only — never in the URL, never in another header |
251
251
  | `scripts/run-subagent-durable-divert-test.mjs` | The side-channel that keeps a **sub-agent's** content out of the leader's transcript, on the replay leg. A content frame stamped with a parent tool-call id belongs to a child, and rendering a child's tokens as the leader's own text is the pollution this divert exists to prevent — but the predicate only listed the four **live** frame shapes, while the durable leg replays the same segment in its **aggregated** form. Those frames fell straight through onto the main projection path, which is how a reconnect or a resumed session ended up with the child's answer printed as the leader's. The anchor is unchanged and shared: the parent tool-call id is what says whose frame this is, and whether the frame is an increment or a whole segment has nothing to do with whose it is — judging the two shapes separately is exactly how one of them got missed. Folding the aggregate into a synthetic increment would have been the smaller diff and the wrong one: an increment means *append*, so a segment that already streamed live and then replays whole would be counted **twice**. The two are kept distinct and the aggregate absorbs instead — a whole segment whose prefix is what the buffer already holds replaces it, which also makes a redelivery of the same frame idempotent, and a prefix that does not match falls back to appending both rather than deciding on the engine's behalf which version counts. Segment boundaries stay with the tool frames rather than moving into the aggregate arm, since closing there would turn a second replay of one segment into a second entry, and the increment arm is pinned to keep appending so a token run that happens to be a prefix of the next does not silently lose characters |
252
252
  | `scripts/run-subagent-content-budget-test.mjs` | The **byte** budget on the sub-agent transcript ledger. It used to be bounded only by *counts* — so many entries per child, so many children — and a count is not a budget when a single entry has no ceiling of its own: one tool result carrying an inlined attachment, or one long model answer, and a single slot sits on tens of megabytes. The guard anchors on how many bytes are **still held** after over-filling, not on whether truncation fired, because an implementation that flags the overflow without actually dropping anything satisfies the second and not the first. Dropping is required to leave a record — how much went and where the retained content now starts — and that record has to reach the render plan, because content that vanishes with no marker gives the reader a transcript shorter than what happened with nothing to say so; the record is one per child, updated in place, pinned to the front, and excluded from the budget it describes. Order matters and is checked: oldest entries go first and the live tail is trimmed only as a last resort, since taking the text the user is watching stream while older history survives is the wrong end. The total budget evicts a whole least-recently-used child rather than shaving every child, and the configuration surface is fail-loud on zero, negatives, non-finite and non-integer values — a silently ignored budget is the exact failure this exists to remove — with the rejection proven atomic so a bad second field cannot leave half a configuration behind. The defaults are checked to be a magnitude that can really be reached, since a number too large to hit is a field rather than a budget |
253
+ | `scripts/run-subagent-usage-projection-test.mjs` | Per-subagent usage, split by task. The engine's final accounting carries the delegated spend as **one total** — tokens, turns, task count — and no per-task breakdown, while every sub-flow turn on the stream carries its own usage. This package used to fold that away at the leader/sub-flow divide (a child's output tokens must never reconcile the leader's response length), so a client showing a subagent's detail pane had nothing to print. The split table can therefore only be accumulated from the stream, and this guard pins what that costs. The two existing leader-only arms stay **byte-for-byte unchanged** — the new arm is additive and always carries the sub-flow's own lane proof, so a host cannot mistake a child's numbers for the session window. Attribution is by the engine's own originating-task id — deliberately not a second `taskId`, which the event identity does not carry and whose absence would silently collapse every child under one parent call — falling back to the parent call id; a turn that answers neither is dropped rather than filed under an invented row, because merging two children's ledgers is worse than missing one. Cache-read tokens are read from the **engine's own shape** rather than the mirrored one, since the mirror fills that member with zero when the wire omits it and reading it there would erase the difference between *not reported* and *no cache hit*. A turn that reported no usage at all still counts as a turn and still adds its zeros — the numbers are a lower bound, and dropping the round would make the bound less true, so the honesty bit rides on the row instead and is never spelled `false`; such a round still emits its live arm, because the frame that says "this round has no account" is the one a real-time consumer most needs and the easiest one to drop. The same honesty bit also survives a terminal that carries no statistics at all: what the stream observed is unioned with what the final record says, so a run that already reported an unmeasured round cannot come out the other end looking like an exact zero. Finally the table says whether it is **partial**, and that verdict is anchored on the quantity that actually decides it: the engine's own totals. Turn count and row count must both reconcile before the table claims to cover the whole run; anything else — including totals that cannot be read — marks it partial, so the failure direction is always the safe one (a complete table called partial, never the reverse). The two accounts are kept separate and are never added together or used to correct each other |
253
254
  | `scripts/run-result-text-backfill-test.mjs` | What happens when the terminal frame's answer text and the text already on screen do not match. A turn's answer normally streams in and the terminal frame carries the same words again, so the two agree — but when the connection drops mid-answer and the reconnect brings the finished version, "this turn already produced assistant text" is true, the terminal fallback is skipped entirely, and the screen stays permanently short of whatever arrived while the stream was down, with nothing to say so. Four cases are pinned. Nothing on screen yet: render the terminal text whole, byte for byte the previous behaviour. On-screen text is a **prefix** of the terminal text: emit only the missing tail, and the guard measures the deciding quantity — the total bytes that reached the screen must equal the terminal text, which fails both for a missing tail and for a re-render that would print the first half twice; when the two are already equal, nothing is emitted at all. Terminal text is a prefix of what is on screen (an engine-side trim): touch nothing, since there is nothing missing and overwriting with the shorter version would erase what the reader already saw. Neither is a prefix of the other: emit **nothing** and raise a fact instead — which version counts is the engine's to say, and appending the terminal version after the streamed one composes a passage nobody ever wrote. That fact carries lengths rather than text, so a renderer is not handed a third version to choose from, and its declared duty is to *reword* the transcript line, never to render more. A cross-segment case proves the comparison reads the whole committed answer rather than the last segment, and the whole thing is driven through the real two-stage path rather than hand-built messages |
254
255
  | `scripts/run-engine-vocab-floor-test.mjs` | Engine-mirrored vocabularies (structured card whitelist, self-reported tool face, control verbs, recogniser sets) against the *installed* `@sema-agent/core` |
255
256
  | `scripts/run-limits-env-failloud-test.mjs` | `SEMA_HEADLESS_*` env-lane limits reject invalid values as loudly as the flag lane (no silent "no budget" runs) |
@@ -723,8 +723,9 @@ async function staleParkArm(taskId, busy, runs, deps) {
723
723
  // —— 「不知道」和「行又回来了」都不构成销毁一条 run 的授权。
724
724
  // 🔴 **在册边界(P-44,异源对抗复审 R2 [high] 如实登记)**:这是「查了再做」,**不是原子条件取消**。
725
725
  // 复证与那一枪之间仍有毫秒级窗口,行恰在此间恢复的话那一枪照样落下去。客户端关不死它 ——
726
- // 真正的关法是引擎侧的**条件取消**(带审批快照版本 / checkpoint 标识,条件变了回 409 且不取消),
727
- // 那是 wire 能力不是壳能自造的语义。这里能做的是把窗口从「人看卡的任意长时间」压到最小,
726
+ // 真正的关法是 **server 读写面**的条件取消(core [7006] 定谳:core 无席——`TaskSpec.signal` 无条件中止、
727
+ // `claimTerminal` 是 ask 行 CAS 非 run lease;server [7007] 认领:cancel 已是两臂状态 CAS,7.73.0 契约明写 + 可选
728
+ // 版本前置,S-122 车),那是 wire 能力不是壳能自造的语义。这里能做的是把窗口从「人看卡的任意长时间」压到最小,
728
729
  // 并把剩余风险登记在册(docs/INTEGRATION-CLIENTS.md §7b P-44),不假装它不存在。
729
730
  const stillGone = await readOwnedPendingCount(recheckOwnedPending, deps);
730
731
  if (stillGone !== 0)
@@ -169,6 +169,20 @@ export interface WiringManifestAutoMode {
169
169
  armed: boolean;
170
170
  /** core **六词逐字透传**。🔴 不映射 `/v1/capabilities.permissionModeAuto.reason` —— 见投影函数头注。 */
171
171
  reason: string;
172
+ /**
173
+ * 0.67.0(core 7.14.0 `AutoModeArmFact.deniedSource`,`wiring-manifest.d.ts:87`)——
174
+ * **哪一层设置面**的棘轮把 auto 模式关掉了(core `AUTO_MODE_DENY_SOURCES`:`org` / `local` /
175
+ * `settings`)。解析器**自己的词,原样带过来,从不推断**(合同顶注 `:79-82` 逐字)。
176
+ *
177
+ * 🔴 **只在 `reason === "denied"` 旁在场**;`denied` 却没有它 = 那个解析器**不记来源**
178
+ * (一句正面事实,不是「不知道」的同义词);`resolver_fault` / `armed` / `no_intent` /
179
+ * `no_face` 上**恒不在场**(屏掉一个它拒绝了的来源是 core 自己做的)。
180
+ * 🔴 **开集读**(与同段 `reason` 逐字同规):词表属主在 core,包在边界抄一份闭集只会在 core
181
+ * 加词那天把一个合法值判没。端的 `switch` 必须带 `default`。
182
+ * 🔴 **缺席不铸**:绝不折成空串,更不折成 `"settings"` 这种看起来最像的默认值 —— 那是替引擎
183
+ * 指认一个它没点名的设置面,而用户会照着去改错的那一层。
184
+ */
185
+ deniedSource?: string;
172
186
  }
173
187
  /**
174
188
  * {@link wiringManifestSupersetBody} 的 `mcp[]` 一条目(S-124 / core 7.5.0,server ≥7.60.0 的形)。
@@ -1036,7 +1036,19 @@ function projectAutoModeSection(raw) {
1036
1036
  return undefined;
1037
1037
  if (armed !== (reason === 'armed'))
1038
1038
  return undefined;
1039
- return { armed, reason };
1039
+ // ── 0.67.0(core 7.14.0):`deniedSource` 补位 ───────────────────────────────────────────────
1040
+ // 见 {@link WiringManifestAutoMode.deniedSource}。本层**只在 `reason === "denied"` 上收** ——
1041
+ // core 的段内规矩是它只站在 `denied` 旁边,而这一条与上面那条 `armed === (reason === 'armed')`
1042
+ // 互证判据**同族**:非投影口(宿主自建管线 / 重放存量转录)喂进来的帧不过 server,一个
1043
+ // `{reason:'armed', deniedSource:'org'}` 会让消费端同时读到「武装了」和「被 org 关掉了」。
1044
+ // ⚠️ 与 `errorCode`/`origin` 的「不校配对」不同裁,理由也是上游自己给的:那些是**跨系统的
1045
+ // 不变量**(server 已按它铸),而本条是**同一条帧上的段内自洽**(gateOutcome.ts 顶注点名的
1046
+ // 那条反向先例,逐字就是本函数)。
1047
+ // 🔴 开集读 + 缺席不铸键(绝不折成空串/默认来源)。
1048
+ const deniedSource = reason === 'denied' && typeof a.deniedSource === 'string' && a.deniedSource.length > 0
1049
+ ? a.deniedSource
1050
+ : undefined;
1051
+ return { armed, reason, ...(deniedSource !== undefined ? { deniedSource } : {}) };
1040
1052
  }
1041
1053
  /**
1042
1054
  * `wiring_manifest` 帧 → 两个超集键的**纯投影**(公面导出;三端共用,壳侧绝不自抄一份形校验)。
@@ -210,6 +210,18 @@ export interface RunCostReconcile {
210
210
  readonly costAbsent?: true;
211
211
  /** `true` ⇒ 委派过,但那本账**没定价**(不是 0)。绝不铸 `false`。 */
212
212
  readonly nestedCostAbsent?: true;
213
+ /**
214
+ * 0.67.0(core 7.14.0 `TaskResult.stats.usageMissing`)—— `true` ⇒ 这条 run 上**至少有一轮**
215
+ * 没报 usage,所以本对账里的每一个数字(以及终帧 `usage` / `modelUsage` / `_sema_nested_usage`
216
+ * 的每一个 token 数)都是**下界**,不是一笔已知的账。
217
+ *
218
+ * 🔴 **`tokens: 0` 在这一位在场时读作「不知道」,不是「免费」**(core 合同逐字)。
219
+ * 🔴 **never false**:core 只在真缺 usage 时铸 `true`,缺席 ⇔ 每一轮都报了 usage ⇒ 本包同律
220
+ * **缺席不铸**(铸一个 `false` 就是替引擎说「我全都数到了」)。
221
+ * ⚠️ 它**与成本位正交**:`costAbsent` 说的是「没定价」,本位说的是「数得不全」——
222
+ * 一笔定了价、但少数了几轮的账,两位可以同时在场,渲染面要分别说。
223
+ */
224
+ readonly usageLowerBound?: true;
213
225
  }
214
226
  /** {@link readRunCostFacts} 的产物:两面(终帧超集键 / chrome 对账臂)各取所需。 */
215
227
  export interface RunCostFacts {
@@ -225,8 +237,62 @@ export interface RunCostFacts {
225
237
  *
226
238
  * 🔴 `stats` 不是可读对象(409 拒绝信封 / park 体 / `failed` 事件帧)⇒ 返 `undefined` =
227
239
  * **这条帧没有账**,调用方据此「不说话」(不发臂、不铸键),而不是发一条全缺席的空账。
240
+ *
241
+ * 🔴 **第二参 `ctx`(0.67.1 / B-091,additive)**:流内观测面。给了它,对账段上的
242
+ * {@link RunCostReconcile.usageLowerBound} 就是**取并后**的读数(见 {@link usageLowerBoundOf});
243
+ * 不给(旧签名)⇒ 只读 `stats` 那一半,既有端逐位不变。
244
+ * ⚠️ 包内的两个调用点(`costFactParts` 与 `runStream` 的 `run_cost_reconciled` 铸点)**都必须**
245
+ * 传它 —— 少传一处就是把本件修的那条不对称原样种回去。
246
+ */
247
+ export declare function readRunCostFacts(stats: TaskStats | undefined, ctx?: {
248
+ readonly usageMissingObserved?: boolean;
249
+ }): RunCostFacts | undefined;
250
+ /**
251
+ * L-228(0.67.0)—— 一只子任务在**这条流上被看见的**那本账(`_sema_nested_usage_by_task` 的行形)。
252
+ *
253
+ * 🔴 **它是流内累加的产物,不是引擎报的一个字段**:core 终局只有合计 `stats.nested`
254
+ * (`{tokens, turns, tasks, costMicroUsd}`),**没有 per-task 分项**(`task-result.d.ts` #594-660 亲验)。
255
+ * ⇒ 这几个数的唯一来源是子流 `turn_end` 的逐轮累加,而「这条流看见了多少」与「这条 run 一共有多少」
256
+ * 可以不相等 —— 那正是 {@link SemaNestedUsageByTask.partial} 存在的理由。
257
+ * 🔴 **没有 `costMicroUsd`**:`turn_end.usage` 上没有钱这一格(定价在终局做),编一个出来就是造账。
228
258
  */
229
- export declare function readRunCostFacts(stats: TaskStats | undefined): RunCostFacts | undefined;
259
+ export interface SemaSubagentUsageRow {
260
+ /** 这条流上看见的该子任务 `turn_end` 条数(**不是** core 的 `nested.turns`,见顶注)。 */
261
+ readonly turns: number;
262
+ /** 逐轮 `inputTokens` 求和。`usageMissing` 在场时它是**下界**。 */
263
+ readonly inputTokens: number;
264
+ /** 逐轮 `outputTokens` 求和。同上。 */
265
+ readonly outputTokens: number;
266
+ /** 逐轮 `cacheReadTokens` 求和;一轮都没报过 ⇒ **键不在**(绝不铸 0 冒充「零命中」)。 */
267
+ readonly cacheReadTokens?: number;
268
+ /** `true` ⇒ 这只子任务**至少有一轮**没报 usage,本行三个数是**下界**。never false。 */
269
+ readonly usageMissing?: true;
270
+ }
271
+ /** {@link SemaSubagentUsageRow} 的累加中间态(runStream 持有;`readonly` 在收口那一拍才加)。 */
272
+ export interface MutableSubagentUsageRow {
273
+ turns: number;
274
+ inputTokens: number;
275
+ outputTokens: number;
276
+ cacheReadTokens?: number;
277
+ usageMissing?: true;
278
+ /**
279
+ * 0.67.1(异源复审 [medium] 实抓)—— **这一行的键是回落来的**(`parentToolCallId`),不是真身份
280
+ * `sourceTaskId`。**只进 `partial` 的判据,不进交付行**({@link SemaSubagentUsageRow} 上没有这一位:
281
+ * 它是本层的对账中间量,不是一条要过 wire 的事实)。
282
+ * 🔴 为什么非记不可:B-090 放宽入表条件之后,一条流上的行键**可以有两种出身**。混合出身时
283
+ * **同一只**子任务可以同时占两行(一轮走回落键、一轮走真身份),而另外两只子任务又可能并进同一个
284
+ * 回落行 —— 一次**拆行**与一次**合行**的计数误差方向相反,于是「行数 === `nested.tasks` **且**
285
+ * turns 之和 === `nested.turns`」两条对账**同时成立**,一张归属错误的表被标成「可证完整」。
286
+ * ⇒ 出身混合时 {@link SemaNestedUsageByTask.partial} **恒立**(见 `nestedUsageByTaskParts`)。
287
+ */
288
+ keyFromParentFallback?: true;
289
+ }
290
+ /** 终帧两个超集键的产物形(见 {@link nestedUsageByTaskParts})。 */
291
+ export interface SemaNestedUsageByTask {
292
+ readonly rows: Readonly<Record<string, SemaSubagentUsageRow>>;
293
+ /** 见 `_sema_nested_usage_by_task_partial`。 */
294
+ readonly partial: boolean;
295
+ }
230
296
  /**
231
297
  * D-2 族扫(0.66.0;异源对抗复审 [medium])—— **终局** per-model 行的 sema 超集位。
232
298
  *
@@ -158,17 +158,62 @@ function permissionDenialParts(stats) {
158
158
  ...(claimable ? {} : { _sema_permission_denials_absent: true }),
159
159
  };
160
160
  }
161
+ /**
162
+ * 0.67.1 —— **以 wire 给的 id / 键名当对象键**时的唯一落键姿势(`__proto__` 陷阱)。
163
+ *
164
+ * 🔴 `Object.prototype.__proto__` 是一个 **accessor**:在一只普通对象上写 `o["__proto__"] = v`
165
+ * 走的是那只 setter ——**不产生自有属性**(v 是对象时还顺手改了 `o` 的原型),于是那一行在
166
+ * `Object.keys` / `JSON.stringify` 里**整条消失**,连行数都少一。而本文件这几张表的键全都来自
167
+ * wire(taskId / modelId / core 开集的 costBreakdown 键名),没有任何一条保证它们不等于这个字面。
168
+ * ⇒ 落键一律走 `defineProperty`,与本包 `hitl/crashConverged.ts` 交付快照时的处置**同一条**
169
+ * (那里逐字:「落键仍走 `defineProperty`(`__proto__` 同理)」)。
170
+ *
171
+ * 🔴 **不改成 null 原型对象交付**:端拿到的仍是一只正常对象(`hasOwnProperty` / `toString` 都在),
172
+ * 本修只改「落键」这一步,不改交付形 —— 换原型会在宿主侧造出一类新的 `TypeError`。
173
+ * 描述符与普通赋值**逐位相同**(`writable/enumerable/configurable` 三真),所以除了 `__proto__`
174
+ * 这一个字面,其余每一个键的行为一个字节都没变。
175
+ */
176
+ function putOwn(table, key, value) {
177
+ Object.defineProperty(table, key, { value, enumerable: true, writable: true, configurable: true });
178
+ }
161
179
  /** 有限数窄化(非数 / 非有限 ⇒ 缺席;`0` 是事实不是缺席)。 */
162
180
  function finiteOrAbsent(v) {
163
181
  return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
164
182
  }
183
+ /**
184
+ * 0.67.1 / B-091 —— usage **下界位**的**唯一判据**(纯函数,两面读的是**同一次计算**)。
185
+ *
186
+ * 🔴 它为什么必须是一只函数、而不是两处各写一遍的表达式:下界位有**两个来源**——
187
+ * · `stats.usageMissing`:只在带得出 `TaskResult` 的终帧上有;
188
+ * · `ctx.usageMissingObserved`:流内观测(`failed` 事件帧 / 409 拒绝信封 / park 体**没有 stats**,
189
+ * 只读 stats 的话一条已经观测到缺口的 run 会在终帧上被读成「每一轮都报了 usage」)。
190
+ * 修前取并只包在**终帧**那一面({@link costFactParts}),而 chrome 对账臂的铸点直接展开
191
+ * `readRunCostFacts(stats).reconcile` ⇒ 「流内观测到缺口、终局 stats 对此缄默」那一形上两面各说
192
+ * 各的(终帧铸了 `_sema_usage_lower_bound`、同一拍的臂上没有 `usageLowerBound`)——
193
+ * [paired-mechanisms-must-share-premise] 的教科书形。⇒ 取并**下沉到这里**,两面共用。
194
+ *
195
+ * 🔴 **严格 true 才认**(core 契约:`true` 或缺席,恒不写 `false`/`null`);`stats` 不是可读对象时
196
+ * 它那一半读作「没说」,而流内观测那一半**照旧成立**。
197
+ */
198
+ function usageLowerBoundOf(stats, ctx) {
199
+ const statsSaid = stats !== null && typeof stats === 'object' && !Array.isArray(stats)
200
+ ? stats.usageMissing === true
201
+ : false;
202
+ return statsSaid || ctx?.usageMissingObserved === true;
203
+ }
165
204
  /**
166
205
  * D-3 / B-068 · L-198 —— 终局成本事实的**唯一读器**(终帧超集键与 chrome 对账臂共用)。
167
206
  *
168
207
  * 🔴 `stats` 不是可读对象(409 拒绝信封 / park 体 / `failed` 事件帧)⇒ 返 `undefined` =
169
208
  * **这条帧没有账**,调用方据此「不说话」(不发臂、不铸键),而不是发一条全缺席的空账。
209
+ *
210
+ * 🔴 **第二参 `ctx`(0.67.1 / B-091,additive)**:流内观测面。给了它,对账段上的
211
+ * {@link RunCostReconcile.usageLowerBound} 就是**取并后**的读数(见 {@link usageLowerBoundOf});
212
+ * 不给(旧签名)⇒ 只读 `stats` 那一半,既有端逐位不变。
213
+ * ⚠️ 包内的两个调用点(`costFactParts` 与 `runStream` 的 `run_cost_reconciled` 铸点)**都必须**
214
+ * 传它 —— 少传一处就是把本件修的那条不对称原样种回去。
170
215
  */
171
- export function readRunCostFacts(stats) {
216
+ export function readRunCostFacts(stats, ctx) {
172
217
  // 数组也不是「一份账」:`typeof [] === 'object'` 会把一条畸形载体放进来,然后它的每一格都读不出
173
218
  // ⇒ 发出一条「own 没定价」的臂,而真相是**这条帧根本没有账**(两句话又折成一句)。
174
219
  if (stats === null || typeof stats !== 'object' || Array.isArray(stats))
@@ -181,7 +226,7 @@ export function readRunCostFacts(stats) {
181
226
  for (const [k, v] of Object.entries(rawBreakdown)) {
182
227
  const n = finiteOrAbsent(v);
183
228
  if (n !== undefined)
184
- out[k] = n;
229
+ putOwn(out, k, n);
185
230
  }
186
231
  if (Object.keys(out).length > 0)
187
232
  breakdown = out;
@@ -212,8 +257,13 @@ export function readRunCostFacts(stats) {
212
257
  // 求和自身也必须是有限数:两个合法但极端的值相加可以溢出成 Infinity,而 Infinity 会被下游
213
258
  // 当成真数字摊进总计(与 `microUsdToUsd` 同一条规约)⇒ 宁可不铸。
214
259
  const sum = ownMicroUsd !== undefined && !nestedUnknown ? ownMicroUsd + (nestedMicroUsd ?? 0) : undefined;
260
+ // 0.67.0(core 7.14.0):usage 下界位。**严格 true 才铸**(core 契约:`true` 或缺席,恒不写
261
+ // `false`/`null`;认宽了就会把一个 falsy 值渲成「数得不全」)。它与成本三段正交,所以读在这里、
262
+ // 与三段同一只读器出 —— 两面(终帧超集键 / chrome 对账臂)因此永远不会各算各的。
263
+ const usageLowerBound = usageLowerBoundOf(stats, ctx);
215
264
  const reconcile = {
216
265
  ...(ownMicroUsd !== undefined ? { ownMicroUsd } : { costAbsent: true }),
266
+ ...(usageLowerBound ? { usageLowerBound: true } : {}),
217
267
  ...(nestedMicroUsd !== undefined ? { nestedMicroUsd } : {}),
218
268
  ...(nestedUnknown ? { nestedCostAbsent: true } : {}),
219
269
  ...(breakdown?.compactionMicroUsd !== undefined ? { compactionMicroUsd: breakdown.compactionMicroUsd } : {}),
@@ -233,13 +283,97 @@ export function readRunCostFacts(stats) {
233
283
  * (超集键纪律:CC 形上已有的位不许塞我们自己的含义)。「fully-reconciled spend」由消费方按
234
284
  * 这两个超集键自己加 —— 包给的是**可对账的事实**,不是一个改了口径的数。
235
285
  */
236
- function costFactParts(stats) {
237
- const facts = readRunCostFacts(stats);
286
+ function costFactParts(stats, ctx) {
287
+ const facts = readRunCostFacts(stats, ctx);
288
+ // 🔴 **下界位与成本三段分开算**(异源对抗复审 [medium] 实抓):`stats` 读不出(`failed` 事件帧 /
289
+ // 409 拒绝信封 / park 体)时**成本**那三段确实没有账、一条都不该说;但「这条流观测到过一轮
290
+ // 没有 usage」这件事**照旧成立**,而那种终帧的 `usage` 恰恰是 `flattenUsage(undefined)` 的
291
+ // 全零 —— 早退回空对象等于把「不知道」渲成精确零(正是本位要修的病的另一条路径)。
292
+ // ⇒ 两者**取并**:stats 说了算一半,流内观测算另一半。
293
+ // 🔴 0.67.1 / B-091:取并本身已经**下沉**到 {@link usageLowerBoundOf} —— 这里与读器内部、与
294
+ // chrome 对账臂读的是**同一只函数**,三面不会各算各的(`facts === undefined` 时读器整只不
295
+ // 返回,所以这一行必须自己再调一次那只判据,而不是回头读 `facts`)。
296
+ const lowerBound = usageLowerBoundOf(stats, ctx);
297
+ const lowerBoundPart = lowerBound ? { _sema_usage_lower_bound: true } : {};
238
298
  if (facts === undefined)
239
- return {};
299
+ return lowerBoundPart;
240
300
  return {
301
+ ...lowerBoundPart,
241
302
  ...(facts.breakdown !== undefined ? { _sema_cost_breakdown: facts.breakdown } : {}),
242
303
  ...(facts.nested !== undefined ? { _sema_nested_usage: facts.nested } : {}),
304
+ // ── 0.67.0(core 7.14.0 `TaskResult.stats.usageMissing`)────────────────────────────────
305
+ // 🔴 CC 同名键**语义零改**:`usage` / `modelUsage` / `total_cost_usd` 的字节一个不动,本位是
306
+ // 一个 `_sema_` 超集**判别位**,说的是「上面那些数字是**下界**」。把它折进数字里(比如
307
+ // 把 tokens 抹成 null)会让每一个既有消费者当场坏掉,而它们今天读到的是一笔**看起来
308
+ // 已知**的账 —— 那正是本位要修的病。
309
+ // 🔴 **缺席不铸**(never false):缺席 ⇔ 每一轮都报了 usage。
310
+ // 🔴 与 §31d 的 `usage._sema_total_input_tokens` **同帧不同问**:那一位答「这一行报了多少总
311
+ // 输入」,本位答「这一整条 run 的账数全了没有」。
312
+ // (本位在上面与流内观测取并后已铸;这里不再重复。)
313
+ };
314
+ }
315
+ /**
316
+ * L-228 —— 流内子代分表 → 终帧两个超集键的**唯一 mint 点**(成功臂与错误信封共用)。
317
+ *
318
+ * **不导出**:它的入参是一个只有 `runStream` 攒得出来的累加表,推上公面等于邀请宿主自己攒一份
319
+ * 「看见了多少」的账(而那份账的 `partial` 判据在宿主手上不成立)。包侧门走**端到端**素材:
320
+ * 喂子流 `turn_end` + `done` 给 `runStream`,断言终帧上那两个键 —— 那也是三端真正拿到它的路。
321
+ *
322
+ * ── 🔴 `partial` 怎么判(设计定谳,与派车单的「恒铸于重连车道」不同,理由写在这里)────────────
323
+ * 派车单给的形是「本条流不是从 run 起点观测 ⇒ 恒铸 partial」。而本层**没有**「从不从起点观测」
324
+ * 这个读数:`runStream` 手上只有**同一条流内**的事件序号去重表(`seen`),它答的是「这一帧重放过
325
+ * 没有」,答不了「这条流之前还有没有别的帧」;`--resume` / 重连开出来的流与首开的流在本层
326
+ * **逐位不可分**。按一个读不出来的量铸判别位 = 编一件事实。
327
+ * ⇒ 判据改锚在**真正决定结果的量**上([anchor-on-the-deciding-quantity]):拿引擎自己的**权威
328
+ * 合计** `stats.nested` 对账 —— 分表的 `turns` 之和等于 `nested.turns` **且**行数等于 `nested.tasks`
329
+ * 时,这张表**可证**覆盖了整条 run ⇒ 不铸 partial;任何一边对不上、或 `stats.nested` 根本读不出来
330
+ * ⇒ **铸 partial**。
331
+ * 🔴 失效方向是安全的那一侧:上游哪天让某类嵌套轮不上子流 `turn_end`(比如更深一层的编排),
332
+ * 本判据只会**多**铸 partial(把一张其实完整的表说成不完整),**永远不会**把一张残表说成完整。
333
+ * 🔴 **两个键不互证、也不相加**:`_sema_nested_usage`(合计,引擎报的)与本表(流内看见的)是
334
+ * 两份独立的账;`partial` 在场时两者**本来就该不等**,消费方不许拿其中一份去「修正」另一份。
335
+ */
336
+ function nestedUsageByTaskParts(stats, rollup) {
337
+ // 🔴 一行都没有 ⇒ **什么都不说**:空表会被读成「这条 run 一个子代都没委派」,而真相可能是
338
+ // 「委派了,但这条流没看见任何一轮」(重连车道)。两句话不许折成一句。
339
+ if (rollup === undefined || rollup.size === 0)
340
+ return {};
341
+ const rows = {};
342
+ let turnsSeen = 0;
343
+ // 🔴 0.67.1:行键的**出身**统计(见 {@link MutableSubagentUsageRow.keyFromParentFallback})。
344
+ // 出身按行是均匀的 —— 一行的键要么恒是 `sourceTaskId`、要么恒是回落的父调用 id
345
+ // (`taskId = sourceTaskId ?? parent`:真身份在场时永远不会落到回落键那一行上)。
346
+ let fallbackKeyedRows = 0;
347
+ let sourceKeyedRows = 0;
348
+ for (const [taskId, r] of rollup) {
349
+ turnsSeen += r.turns;
350
+ if (r.keyFromParentFallback === true)
351
+ fallbackKeyedRows += 1;
352
+ else
353
+ sourceKeyedRows += 1;
354
+ putOwn(rows, taskId, {
355
+ turns: r.turns,
356
+ inputTokens: r.inputTokens,
357
+ outputTokens: r.outputTokens,
358
+ ...(r.cacheReadTokens !== undefined ? { cacheReadTokens: r.cacheReadTokens } : {}),
359
+ ...(r.usageMissing === true ? { usageMissing: true } : {}),
360
+ });
361
+ }
362
+ const nested = stats !== null && typeof stats === 'object' && !Array.isArray(stats)
363
+ ? stats.nested
364
+ : undefined;
365
+ const authTurns = finiteOrAbsent(nested?.turns);
366
+ const authTasks = finiteOrAbsent(nested?.tasks);
367
+ // 🔴 0.67.1:**出身混合 ⇒ 证不出完整**。计数相等只说明「数目对得上」,说不了「逐任务归属对得上」
368
+ // —— 拆一行与并一行的误差方向相反、可以恰好抵消(见 `keyFromParentFallback` 头注的反例)。
369
+ // 出身一致时既有两条对账才是充分的:全真身份 ⇒ 一行一任务;全回落 ⇒ 多任务共父会让行数 <
370
+ // `nested.tasks`,那一格自己会翻。失效方向仍是安全的那一侧(只会**多**铸 partial)。
371
+ const mixedKeyOrigin = fallbackKeyedRows > 0 && sourceKeyedRows > 0;
372
+ const complete = !mixedKeyOrigin && authTurns !== undefined && authTasks !== undefined &&
373
+ authTurns === turnsSeen && authTasks === Object.keys(rows).length;
374
+ return {
375
+ _sema_nested_usage_by_task: rows,
376
+ ...(complete ? {} : { _sema_nested_usage_by_task_partial: true }),
243
377
  };
244
378
  }
245
379
  /** P1-5 — real elapsed ms since the stream opened (runStream stamps startedAtMs); 0 only pre-stamp. */
@@ -315,7 +449,8 @@ function mapModelUsage(stats) {
315
449
  if (u === undefined || u === null || typeof u !== 'object')
316
450
  continue;
317
451
  const m = u;
318
- out[modelId] = withTerminalUsageFacts(toCcModelUsage({
452
+ // 0.67.1 同形族:`modelId` 也是 wire 给的键 ⇒ 落键走 {@link putOwn}。
453
+ putOwn(out, modelId, withTerminalUsageFacts(toCcModelUsage({
319
454
  inputTokens: numOrAbsent(m.inputTokens),
320
455
  outputTokens: numOrAbsent(m.outputTokens),
321
456
  cacheReadTokens: numOrAbsent(m.cacheReadTokens),
@@ -323,7 +458,7 @@ function mapModelUsage(stats) {
323
458
  costMicroUsd: numOrAbsent(m.costMicroUsd),
324
459
  // D-2(0.66.0):per-model 行今天也不带这一格 ⇒ 宽读一次,读不出由 mint 点立判别位。
325
460
  webSearchRequests: numOrAbsent(m.webSearchRequests),
326
- }), m);
461
+ }), m));
327
462
  }
328
463
  return out;
329
464
  }
@@ -493,7 +628,10 @@ function errorResult(ctx, parts) {
493
628
  // D-1 / L-192①:两句话不再折成一句 —— 清单 + 「有没有这本账」的判别位,见 permissionDenialParts。
494
629
  ...permissionDenialParts(parts.stats),
495
630
  // D-3 / B-068:失败/到限/park 的 run 一样花过钱,账不因结局不好就不报。
496
- ...costFactParts(parts.stats),
631
+ ...costFactParts(parts.stats, ctx),
632
+ // L-228(0.67.0):流内 per-subagent 分表的收口快照(判据本体在 `nestedUsageByTaskParts`)。
633
+ // 🔴 与成本三段同理 —— 失败的 run 一样委派过,账不因结局不好就不报。
634
+ ...nestedUsageByTaskParts(parts.stats, ctx.nestedUsageByTask),
497
635
  errors: [...parts.errors],
498
636
  ...(parts.errorCode !== undefined && parts.errorCode.length > 0 ? { errorCode: parts.errorCode } : {}),
499
637
  ...(parts.degraded !== undefined ? { degraded: parts.degraded } : {}),
@@ -686,7 +824,10 @@ export function doneToSdkResult(ev, ctx) {
686
824
  // D-1 / L-192①:同形第二处 —— 与错误信封共用**同一个** mint 点(修前两处各一个字面量 [])。
687
825
  ...permissionDenialParts(stats),
688
826
  // D-3 / B-068:成本明细与子代那本账(micro-USD 原值);`total_cost_usd` 语义一字不动。
689
- ...costFactParts(stats),
827
+ ...costFactParts(stats, ctx),
828
+ // L-228(0.67.0):流内 per-subagent 分表的收口快照;与 `_sema_nested_usage`(引擎报的合计)
829
+ // 是**两份独立的账**,不相加、不互证(见 `nestedUsageByTaskParts` 顶注)。
830
+ ...nestedUsageByTaskParts(stats, ctx.nestedUsageByTask),
690
831
  // MF-25 — the effective served model id (`done.result.model`, e.g. "deepseek-v4-pro"). The CC
691
832
  // SDKResultSuccess schema has no `model` field, so this rides as an additive seam field a cost/overview
692
833
  // consumer reads (it is ALSO surfaced as the `modelUsage` key). Omitted when the wire didn't carry it.