@sema-agent/client-core 0.62.1 → 0.63.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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * src/toolRoster.ts — 一条腿的**工具名册**(`wiring_manifest.tools: ToolRoster`)窄读器 +
3
+ * `tool_roster_delta` 应用(0.63.0;L-161;engine >=7.66.0 有臂 / >=7.9.0 真推 /
4
+ * sdk 8.6.0 型面 + 8.7.0 `pathTarget` 三键)。
5
+ *
6
+ * -- 名册回答的是三端一直在猜的那一问 --------------------------------------------------------
7
+ * 「这条腿到底挂了哪些工具、每一只是什么面」此前只能猜:`liveInitToolFace.ts` 那三张词表
8
+ * (`ENGINE_HANDS_BAND` / `ENGINE_SCENARIO_EXTRAS_DEFAULT` / `ENGINE_RUNNER_FACE`)是 2026-07-16
9
+ * 一次 tap 实测的**估计值**,按 pin 的引擎版本定稿;外接别的引擎、别的场景、别的 env 门就会偏。
10
+ * 名册是**引擎自己说的**:每只工具的身份(契约 id / 形状指纹 / 能力 id)、轴(effect / egress /
11
+ * irreversibility)、面(family / pathTarget / renderHints)。
12
+ * 🔴 **名册在场用名册,缺席才回落三表**。本批**不删**那三张表(名册只在 effective 半场、且只有
13
+ * >=7.9.0 的引擎才真推,回落还得留着);`liveInitToolFace.ts` 的头注登记了退役条款:
14
+ * **名册恒在场的版本到货即删表**。
15
+ *
16
+ * -- 🔴 绝不半张名册(照抄上游的纪律)--------------------------------------------------------
17
+ * 引擎的 fail-open 台账逐字写着:载荷过不了 core 自己的判形器 => 那一段缺席 / 那一帧不发,
18
+ * **绝不半张脸上 wire**;并写明反方向更坏 ——「把一份判不了形的名册照发,消费端会把读不出的行
19
+ * 当成**这只工具没挂**」。本窄读器照抄这条:**任一行读不出 => 整只 `undefined`**;`count` 与真实
20
+ * 行数对不上同理(core 在铸点 run-time 断言两者相等)。少几行的名册比没有名册坏。
21
+ * ⚠️ 例外只有**面**:一行的 `pathTarget` / `renderHints` 自己形坏时只丢那一格,行还在 —— 面不是
22
+ * 身份,丢一张面不等于「这只工具没挂」。
23
+ *
24
+ * -- 🔴 `fromDigest` 对不上**不是拒绝** ------------------------------------------------------
25
+ * 契约里唯一的硬话:`fromDigest` 对不上时,**携带的整只名册无论如何都是新状态**,只有 `summary`
26
+ * 变得不可用(消费端记一次 skew 后按快照重同步)。所以 {@link applyToolRosterDelta} 在 skew 时
27
+ * **照换名册、只丢 summary**。拒绝换会让消费端永远抱着一份过期名册 —— 那比一次 skew 坏得多,
28
+ * 而且它把「位置从名册来、永远不从 summary 来」这条契约反过来用了。
29
+ *
30
+ * -- 开集读 -----------------------------------------------------------------------------------
31
+ * `source`(七词)/ `effect` / `family` / `access` 等词表的属主都是引擎,一律**原样透传**,不窄读成
32
+ * 枚举 —— 那会在引擎加词当天把一份真名册判没。
33
+ */
34
+ /** 一只工具的**路径目标**(引擎的 `pathTarget`;`base`/`absent`/`patternParam` 是 core 7.9.1 #635 加的)。 */
35
+ export interface ToolRosterPathTargetView {
36
+ /** 输入里哪个键是路径。 */
37
+ param: string;
38
+ /** `read` / `create` / `edit`;**开集读**。 */
39
+ access: string;
40
+ /** 同义键。 */
41
+ aliases?: readonly string[];
42
+ skillScopeEligible?: true;
43
+ /** 相对路径按哪个基解析(`cwd` 活目录 / `root` 任务根);**声明时才在场**,缺席就是缺席。 */
44
+ base?: string;
45
+ /** 参数缺席时目标是什么(`none` 无 / `base` 基目录)。同上,缺席不铸默认。 */
46
+ absent?: string;
47
+ /** 承载 glob **模式**的输入键(如 `Glob.pattern`)。 */
48
+ patternParam?: string;
49
+ }
50
+ /** 一只工具的**渲染提示**(引擎自己给的人话名 / 进行时文案 / 摘要参数 / 结果卡 / 审批卡型)。
51
+ * 🔴 端渲这只工具时用它,**别按工具名猜** —— 按名字猜正是 `liveInitToolFace` 三张估计词表的病根。 */
52
+ export interface ToolRosterRenderHintsView {
53
+ userFacingName?: string;
54
+ activity?: string;
55
+ summaryParams?: readonly string[];
56
+ resultCards?: readonly string[];
57
+ approvalCard?: string;
58
+ }
59
+ /** 名册里的一行。身份两键必填,其余全可选(缺席即缺席,绝不铸默认)。 */
60
+ export interface ToolRosterEntryView {
61
+ name: string;
62
+ aliases?: readonly string[];
63
+ /** `builtin` / `caller` / `host` / `mcp` / `a2a` / `external` / `synthetic`;**开集读**。 */
64
+ source: string;
65
+ /** mcp / a2a / external 行才有的出身(`peer` = 声明名)。 */
66
+ origin?: {
67
+ peer: string;
68
+ declaredBy?: string;
69
+ };
70
+ contract?: {
71
+ contractId: string;
72
+ implementationRevision?: string;
73
+ };
74
+ shapeDigest?: string;
75
+ wireSchemaDigest?: string;
76
+ /** 卡的身份。**同名不同身的两行按两只 id 去重,不按 `name`**。 */
77
+ cardId?: string;
78
+ capabilityId?: string;
79
+ /** `read` / `write` / `idempotent`;开集读。 */
80
+ effect?: string;
81
+ egress?: boolean;
82
+ /** `never` / `maybe` / `always`;开集读。 */
83
+ irreversibility?: string;
84
+ contentOrigin?: string;
85
+ family?: string;
86
+ pathTarget?: ToolRosterPathTargetView;
87
+ renderHints?: ToolRosterRenderHintsView;
88
+ ruleFace?: {
89
+ primaryParams?: readonly string[];
90
+ params?: readonly string[];
91
+ };
92
+ }
93
+ /** 一条腿的整只名册。 */
94
+ export interface ToolRosterView {
95
+ schemaVersion: number;
96
+ /** 引擎自报的行数;本读器已核过它等于 `entries.length`(对不上 => 整只判没)。 */
97
+ count: number;
98
+ /** 整只名册的指纹,**恰 16 位小写 hex**;任一轴/面/契约/别名变化都会移动它。重同步的锚。 */
99
+ digest: string;
100
+ /** **声明序 = 模型看到的 `tools[]` 序**(不排序、不去重、不重排)。 */
101
+ entries: readonly ToolRosterEntryView[];
102
+ }
103
+ /**
104
+ * `wiring_manifest` 的 `tools` 段 → 名册视图;整段缺席 / 任一行读不出 / `count` 对不上 =>
105
+ * `undefined`,绝不抛出。
106
+ *
107
+ * ⚠️ **空名册是正面事实**(这条腿一只工具都没挂),照读成 0 行 —— 与 `wiring_manifest.mcp[]` 的
108
+ * 空数组同一条已定谳的纪律,把它折成缺席等于把引擎明说的一句话读成「不知道」。
109
+ * ⚠️ 只在 **effective 半场**(live 帧)才有;诊断端点返回的 `static` 半场恒无 —— 那里的缺席是
110
+ * 结构性的,不是「这台引擎太老」。
111
+ */
112
+ export declare function projectToolRoster(manifest: unknown): ToolRosterView | undefined;
113
+ /** 名册里的工具名,**按声明序**(= 模型看到的 `tools[]` 序)。 */
114
+ export declare function toolRosterNames(roster: ToolRosterView | undefined): readonly string[];
115
+ /**
116
+ * 端渲一只工具时要用的那张**面**(`liveInitToolFace.ts` 三张估计词表的继任形)。
117
+ *
118
+ * 🔴 **面只从名册的行取,绝不从工具名猜**:一个叫 `Bash` 的行不一定是 shell 家族(caller 挂的
119
+ * 同名工具、mcp 命名空间下的同名工具都可能),而「按名字猜出来的面」正是三张估计词表的病根。
120
+ * 🔴 **缺席就是缺席**:轴(`egress` / `irreversibility` / `effect`)读不出时留空,**绝不**填
121
+ * `false` / `'never'` —— 那会把「不知道」渲成「安全」,是这条面上最贵的一种谎。
122
+ */
123
+ export interface ToolShim {
124
+ name: string;
125
+ source: string;
126
+ family?: string;
127
+ userFacingName?: string;
128
+ activity?: string;
129
+ approvalCard?: string;
130
+ summaryParams?: readonly string[];
131
+ effect?: string;
132
+ egress?: boolean;
133
+ irreversibility?: string;
134
+ /** 输入里哪个键是路径(端渲写卡/围栏提示要用)。 */
135
+ pathTargetParam?: string;
136
+ /** mcp / a2a / external 行的声明名。 */
137
+ peer?: string;
138
+ /** 身份两只 id:**同名不同身的两行按它们去重,不按 `name`**。 */
139
+ cardId?: string;
140
+ capabilityId?: string;
141
+ }
142
+ /** 名册的一行 → 端用的面。行不成形(身份两键读不出)=> `undefined`,绝不抛出。 */
143
+ export declare function toolShimFromRoster(entry: unknown): ToolShim | undefined;
144
+ /** 一次 `tool_roster_delta` 应用之后的状态。 */
145
+ export interface ToolRosterDeltaApplication {
146
+ /** 应用后消费端该持有的名册。帧判不出形 => 原样是入参那一份(什么都没发生)。 */
147
+ roster: ToolRosterView | undefined;
148
+ /** 这一帧被采纳了吗(帧判不出形 => `false`)。 */
149
+ applied: boolean;
150
+ /**
151
+ * `fromDigest` 与手上那份对不上(或手上压根没有名册)。
152
+ * 🔴 **这不是拒绝** —— 名册照换,只有 {@link summary} 变得不可用。消费端据此记一次偏斜。
153
+ */
154
+ skew: boolean;
155
+ /**
156
+ * 三张**互不相交**的名单。`skew` 为真、或这一段自己形坏时**缺席** —— 交一份对不上的名单比
157
+ * 不交更坏(位置永远从名册来,从来不从 summary 来)。
158
+ * ⚠️ 三张全空是**正面事实**(一次字节相同的 re-mint 也会发帧),不是「读不出」。
159
+ */
160
+ summary?: ToolRosterDeltaSummary;
161
+ }
162
+ /**
163
+ * 一次名册变更的**名字摘要**:三张**互不相交**的名单。
164
+ * ⚠️ 位置永远从名册来,从来不从这里来 —— 这三张名单只回答「变了哪些名字」。
165
+ * ⚠️ 三张全空是**正面事实**(一次字节相同的 re-mint 也会发帧),不是「读不出」。
166
+ */
167
+ export interface ToolRosterDeltaSummary {
168
+ added: readonly string[];
169
+ removed: readonly string[];
170
+ changed: readonly string[];
171
+ }
172
+ /**
173
+ * `tool_roster_delta` → 新的持有状态。**纯函数**(不改入参)。
174
+ *
175
+ * 🔴 `fromDigest` 对不上**不是拒绝**(契约里唯一的硬话):携带的整只名册无论如何都是新状态,
176
+ * 只有 `summary` 变得不可用。拒绝换会让消费端永远抱着一份过期名册 —— 比一次 skew 坏得多。
177
+ * 🔴 手上**没有**名册(首帧就是 delta)同样算 `skew`:没有可比的 digest 就是比不出来,而
178
+ * 「比不出来」不许被读成「对上了」。
179
+ * 🔴 帧本身判不出形 => **什么都没发生**:原样交回入参那一份(不清空 —— 清空会把一次读不懂的帧
180
+ * 变成一次「所有工具都没挂」)。
181
+ */
182
+ export declare function applyToolRosterDelta(held: ToolRosterView | undefined, delta: unknown): ToolRosterDeltaApplication;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * src/toolRoster.ts — 一条腿的**工具名册**(`wiring_manifest.tools: ToolRoster`)窄读器 +
3
+ * `tool_roster_delta` 应用(0.63.0;L-161;engine >=7.66.0 有臂 / >=7.9.0 真推 /
4
+ * sdk 8.6.0 型面 + 8.7.0 `pathTarget` 三键)。
5
+ *
6
+ * -- 名册回答的是三端一直在猜的那一问 --------------------------------------------------------
7
+ * 「这条腿到底挂了哪些工具、每一只是什么面」此前只能猜:`liveInitToolFace.ts` 那三张词表
8
+ * (`ENGINE_HANDS_BAND` / `ENGINE_SCENARIO_EXTRAS_DEFAULT` / `ENGINE_RUNNER_FACE`)是 2026-07-16
9
+ * 一次 tap 实测的**估计值**,按 pin 的引擎版本定稿;外接别的引擎、别的场景、别的 env 门就会偏。
10
+ * 名册是**引擎自己说的**:每只工具的身份(契约 id / 形状指纹 / 能力 id)、轴(effect / egress /
11
+ * irreversibility)、面(family / pathTarget / renderHints)。
12
+ * 🔴 **名册在场用名册,缺席才回落三表**。本批**不删**那三张表(名册只在 effective 半场、且只有
13
+ * >=7.9.0 的引擎才真推,回落还得留着);`liveInitToolFace.ts` 的头注登记了退役条款:
14
+ * **名册恒在场的版本到货即删表**。
15
+ *
16
+ * -- 🔴 绝不半张名册(照抄上游的纪律)--------------------------------------------------------
17
+ * 引擎的 fail-open 台账逐字写着:载荷过不了 core 自己的判形器 => 那一段缺席 / 那一帧不发,
18
+ * **绝不半张脸上 wire**;并写明反方向更坏 ——「把一份判不了形的名册照发,消费端会把读不出的行
19
+ * 当成**这只工具没挂**」。本窄读器照抄这条:**任一行读不出 => 整只 `undefined`**;`count` 与真实
20
+ * 行数对不上同理(core 在铸点 run-time 断言两者相等)。少几行的名册比没有名册坏。
21
+ * ⚠️ 例外只有**面**:一行的 `pathTarget` / `renderHints` 自己形坏时只丢那一格,行还在 —— 面不是
22
+ * 身份,丢一张面不等于「这只工具没挂」。
23
+ *
24
+ * -- 🔴 `fromDigest` 对不上**不是拒绝** ------------------------------------------------------
25
+ * 契约里唯一的硬话:`fromDigest` 对不上时,**携带的整只名册无论如何都是新状态**,只有 `summary`
26
+ * 变得不可用(消费端记一次 skew 后按快照重同步)。所以 {@link applyToolRosterDelta} 在 skew 时
27
+ * **照换名册、只丢 summary**。拒绝换会让消费端永远抱着一份过期名册 —— 那比一次 skew 坏得多,
28
+ * 而且它把「位置从名册来、永远不从 summary 来」这条契约反过来用了。
29
+ *
30
+ * -- 开集读 -----------------------------------------------------------------------------------
31
+ * `source`(七词)/ `effect` / `family` / `access` 等词表的属主都是引擎,一律**原样透传**,不窄读成
32
+ * 枚举 —— 那会在引擎加词当天把一份真名册判没。
33
+ */
34
+ const str = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
35
+ const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === 'string') : undefined;
36
+ const obj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) ? v : undefined;
37
+ /** 指纹判据:**恰** 16 位小写 hex(引擎铸的就是这个形;放宽会让一个坏锚看起来能用)。 */
38
+ const DIGEST_RE = /^[0-9a-f]{16}$/;
39
+ function readPathTarget(raw) {
40
+ const o = obj(raw);
41
+ if (o === undefined)
42
+ return undefined;
43
+ const param = str(o.param);
44
+ const access = str(o.access);
45
+ // 两必填键读不出 => 这张面不成形。**只丢面,不丢行**(面不是身份)。
46
+ if (param === undefined || access === undefined)
47
+ return undefined;
48
+ const aliases = strArr(o.aliases);
49
+ const base = str(o.base);
50
+ const absent = str(o.absent);
51
+ const patternParam = str(o.patternParam);
52
+ return {
53
+ param,
54
+ access,
55
+ ...(aliases !== undefined ? { aliases } : {}),
56
+ ...(o.skillScopeEligible === true ? { skillScopeEligible: true } : {}),
57
+ ...(base !== undefined ? { base } : {}),
58
+ ...(absent !== undefined ? { absent } : {}),
59
+ ...(patternParam !== undefined ? { patternParam } : {}),
60
+ };
61
+ }
62
+ function readEntry(raw) {
63
+ const o = obj(raw);
64
+ if (o === undefined)
65
+ return undefined;
66
+ const name = str(o.name);
67
+ const source = str(o.source);
68
+ // 身份两键读不出 => 这一行不成形 => 由调用方把**整只名册**判没(见模块顶注)。
69
+ if (name === undefined || source === undefined)
70
+ return undefined;
71
+ const originRaw = obj(o.origin);
72
+ const peer = originRaw !== undefined ? str(originRaw.peer) : undefined;
73
+ const declaredBy = originRaw !== undefined ? str(originRaw.declaredBy) : undefined;
74
+ const contractRaw = obj(o.contract);
75
+ const contractId = contractRaw !== undefined ? str(contractRaw.contractId) : undefined;
76
+ const implementationRevision = contractRaw !== undefined ? str(contractRaw.implementationRevision) : undefined;
77
+ const hintsRaw = obj(o.renderHints);
78
+ const ruleFaceRaw = obj(o.ruleFace);
79
+ return {
80
+ name,
81
+ source,
82
+ ...(strArr(o.aliases) !== undefined ? { aliases: strArr(o.aliases) } : {}),
83
+ ...(peer !== undefined
84
+ ? { origin: { peer, ...(declaredBy !== undefined ? { declaredBy } : {}) } }
85
+ : {}),
86
+ ...(contractId !== undefined
87
+ ? { contract: { contractId, ...(implementationRevision !== undefined ? { implementationRevision } : {}) } }
88
+ : {}),
89
+ ...(str(o.shapeDigest) !== undefined ? { shapeDigest: str(o.shapeDigest) } : {}),
90
+ ...(str(o.wireSchemaDigest) !== undefined ? { wireSchemaDigest: str(o.wireSchemaDigest) } : {}),
91
+ ...(str(o.cardId) !== undefined ? { cardId: str(o.cardId) } : {}),
92
+ ...(str(o.capabilityId) !== undefined ? { capabilityId: str(o.capabilityId) } : {}),
93
+ ...(str(o.effect) !== undefined ? { effect: str(o.effect) } : {}),
94
+ ...(typeof o.egress === 'boolean' ? { egress: o.egress } : {}),
95
+ ...(str(o.irreversibility) !== undefined ? { irreversibility: str(o.irreversibility) } : {}),
96
+ ...(str(o.contentOrigin) !== undefined ? { contentOrigin: str(o.contentOrigin) } : {}),
97
+ ...(str(o.family) !== undefined ? { family: str(o.family) } : {}),
98
+ ...(readPathTarget(o.pathTarget) !== undefined
99
+ ? { pathTarget: readPathTarget(o.pathTarget) }
100
+ : {}),
101
+ ...(hintsRaw !== undefined
102
+ ? {
103
+ renderHints: {
104
+ ...(str(hintsRaw.userFacingName) !== undefined ? { userFacingName: str(hintsRaw.userFacingName) } : {}),
105
+ ...(str(hintsRaw.activity) !== undefined ? { activity: str(hintsRaw.activity) } : {}),
106
+ ...(strArr(hintsRaw.summaryParams) !== undefined ? { summaryParams: strArr(hintsRaw.summaryParams) } : {}),
107
+ ...(strArr(hintsRaw.resultCards) !== undefined ? { resultCards: strArr(hintsRaw.resultCards) } : {}),
108
+ ...(str(hintsRaw.approvalCard) !== undefined ? { approvalCard: str(hintsRaw.approvalCard) } : {}),
109
+ },
110
+ }
111
+ : {}),
112
+ ...(ruleFaceRaw !== undefined
113
+ ? {
114
+ ruleFace: {
115
+ ...(strArr(ruleFaceRaw.primaryParams) !== undefined ? { primaryParams: strArr(ruleFaceRaw.primaryParams) } : {}),
116
+ ...(strArr(ruleFaceRaw.params) !== undefined ? { params: strArr(ruleFaceRaw.params) } : {}),
117
+ },
118
+ }
119
+ : {}),
120
+ };
121
+ }
122
+ /** 一只**已经剥出来**的名册体(不带 `tools` 包层)→ 视图;坏形 => `undefined`。 */
123
+ function readRoster(raw) {
124
+ const o = obj(raw);
125
+ if (o === undefined)
126
+ return undefined;
127
+ const digest = typeof o.digest === 'string' && DIGEST_RE.test(o.digest) ? o.digest : undefined;
128
+ if (digest === undefined)
129
+ return undefined;
130
+ if (!Array.isArray(o.entries))
131
+ return undefined;
132
+ if (typeof o.count !== 'number' || !Number.isInteger(o.count) || o.count < 0)
133
+ return undefined;
134
+ // 🔴 count 是引擎在铸点 run-time 断言过的量;对不上 => 这份载荷在路上被改过或被半截序列化。
135
+ if (o.count !== o.entries.length)
136
+ return undefined;
137
+ const entries = [];
138
+ for (const raw2 of o.entries) {
139
+ const e = readEntry(raw2);
140
+ // 🔴 绝不半张名册:一行读不出 => 整只判没(见模块顶注)。
141
+ if (e === undefined)
142
+ return undefined;
143
+ entries.push(e);
144
+ }
145
+ const schemaVersion = typeof o.schemaVersion === 'number' && Number.isFinite(o.schemaVersion) ? o.schemaVersion : 1;
146
+ return { schemaVersion, count: o.count, digest, entries };
147
+ }
148
+ /**
149
+ * `wiring_manifest` 的 `tools` 段 → 名册视图;整段缺席 / 任一行读不出 / `count` 对不上 =>
150
+ * `undefined`,绝不抛出。
151
+ *
152
+ * ⚠️ **空名册是正面事实**(这条腿一只工具都没挂),照读成 0 行 —— 与 `wiring_manifest.mcp[]` 的
153
+ * 空数组同一条已定谳的纪律,把它折成缺席等于把引擎明说的一句话读成「不知道」。
154
+ * ⚠️ 只在 **effective 半场**(live 帧)才有;诊断端点返回的 `static` 半场恒无 —— 那里的缺席是
155
+ * 结构性的,不是「这台引擎太老」。
156
+ */
157
+ export function projectToolRoster(manifest) {
158
+ const o = obj(manifest);
159
+ if (o === undefined)
160
+ return undefined;
161
+ if (!('tools' in o))
162
+ return undefined;
163
+ return readRoster(o.tools);
164
+ }
165
+ /** 名册里的工具名,**按声明序**(= 模型看到的 `tools[]` 序)。 */
166
+ export function toolRosterNames(roster) {
167
+ return roster?.entries.map((e) => e.name) ?? [];
168
+ }
169
+ /** 名册的一行 → 端用的面。行不成形(身份两键读不出)=> `undefined`,绝不抛出。 */
170
+ export function toolShimFromRoster(entry) {
171
+ const e = readEntry(entry);
172
+ if (e === undefined)
173
+ return undefined;
174
+ return {
175
+ name: e.name,
176
+ source: e.source,
177
+ ...(e.family !== undefined ? { family: e.family } : {}),
178
+ ...(e.renderHints?.userFacingName !== undefined ? { userFacingName: e.renderHints.userFacingName } : {}),
179
+ ...(e.renderHints?.activity !== undefined ? { activity: e.renderHints.activity } : {}),
180
+ ...(e.renderHints?.approvalCard !== undefined ? { approvalCard: e.renderHints.approvalCard } : {}),
181
+ ...(e.renderHints?.summaryParams !== undefined ? { summaryParams: e.renderHints.summaryParams } : {}),
182
+ ...(e.effect !== undefined ? { effect: e.effect } : {}),
183
+ ...(e.egress !== undefined ? { egress: e.egress } : {}),
184
+ ...(e.irreversibility !== undefined ? { irreversibility: e.irreversibility } : {}),
185
+ ...(e.pathTarget?.param !== undefined ? { pathTargetParam: e.pathTarget.param } : {}),
186
+ ...(e.origin?.peer !== undefined ? { peer: e.origin.peer } : {}),
187
+ ...(e.cardId !== undefined ? { cardId: e.cardId } : {}),
188
+ ...(e.capabilityId !== undefined ? { capabilityId: e.capabilityId } : {}),
189
+ };
190
+ }
191
+ function readSummary(raw) {
192
+ const o = obj(raw);
193
+ if (o === undefined)
194
+ return undefined;
195
+ const added = strArr(o.added);
196
+ const removed = strArr(o.removed);
197
+ const changed = strArr(o.changed);
198
+ if (added === undefined || removed === undefined || changed === undefined)
199
+ return undefined;
200
+ // 数组里混进非串时 `strArr` 会静默滤掉 —— 那等于交一份少了名字的名单,与「绝不半张」同罪。
201
+ if (!Array.isArray(o.added) || added.length !== o.added.length ||
202
+ !Array.isArray(o.removed) || removed.length !== o.removed.length ||
203
+ !Array.isArray(o.changed) || changed.length !== o.changed.length) {
204
+ return undefined;
205
+ }
206
+ // 契约:三张名单**互不相交**。交了集就是坏 summary。
207
+ const all = [...added, ...removed, ...changed];
208
+ if (new Set(all).size !== all.length)
209
+ return undefined;
210
+ return { added, removed, changed };
211
+ }
212
+ /**
213
+ * `tool_roster_delta` → 新的持有状态。**纯函数**(不改入参)。
214
+ *
215
+ * 🔴 `fromDigest` 对不上**不是拒绝**(契约里唯一的硬话):携带的整只名册无论如何都是新状态,
216
+ * 只有 `summary` 变得不可用。拒绝换会让消费端永远抱着一份过期名册 —— 比一次 skew 坏得多。
217
+ * 🔴 手上**没有**名册(首帧就是 delta)同样算 `skew`:没有可比的 digest 就是比不出来,而
218
+ * 「比不出来」不许被读成「对上了」。
219
+ * 🔴 帧本身判不出形 => **什么都没发生**:原样交回入参那一份(不清空 —— 清空会把一次读不懂的帧
220
+ * 变成一次「所有工具都没挂」)。
221
+ */
222
+ export function applyToolRosterDelta(held, delta) {
223
+ const d = obj(delta);
224
+ const next = d !== undefined ? readRoster(d.roster) : undefined;
225
+ if (d === undefined || next === undefined || typeof d.fromDigest !== 'string') {
226
+ return { roster: held, applied: false, skew: false };
227
+ }
228
+ const skew = held === undefined || held.digest !== d.fromDigest;
229
+ const summary = skew ? undefined : readSummary(d.summary);
230
+ return {
231
+ roster: next,
232
+ applied: true,
233
+ skew,
234
+ ...(summary !== undefined ? { summary } : {}),
235
+ };
236
+ }