@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.
package/CHANGELOG.md CHANGED
@@ -49,6 +49,141 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.63.0(2026-09-09)
53
+
54
+ 对表 **sdk 8.8.0 / engine ≥7.67.0 / core 7.10.0**(devDep core `~7.3.0` → `~7.10.0`)。sdk 地板 `>=8.5.0` → `>=8.8.0`
55
+ (peerDependencies / devDependencies / README 地板句 / `run-sdk-floor-test.mjs` 的 `FLOOR` 四处同批抬齐)。
56
+
57
+ ### BREAKING 迁移表(消费端要改的点)
58
+
59
+ | 迁移前 | 迁移后 | 为什么 |
60
+ | --- | --- | --- |
61
+ | `serverGates.durableApproval`(裸布尔) | `serverGates.durableApproval.value`,来源读 `postureKnobSourceOf()` | 一个裸值答不出「这台机器为什么是这个值、怎么钉回」;单机 turnkey 上两根旋钮的缺席默认已翻转,不显形就没人看得见 |
62
+ | `rules.revoke({rule, scope})` 两元组 | `revokeTargetFromPersistedRule(row)`(铸 `{behavior, rule, scope}`) | deny/ask 也进同一个规则店后,同文本的 `deny` 与 `allow` 是**两条不同的行**;猜一态会不可逆地删掉另一态,而调用方收到 200 |
63
+
64
+ ### 新增
65
+
66
+ - **`src/postureKnob.ts`** —— operator 面 `serverGates` 三根 posture 旋钮的读数窄读器:
67
+ `projectServerGateKnobs()` 三键窄读(老 worker 的裸值折进同一只壳并立 `legacy` 位,另两根整键缺席
68
+ 时**诚实缺席、不铸默认**;主锚坏 ⇒ 整段缺席,旁枝坏 ⇒ 只丢那一根)、`postureKnobSourceOf()`
69
+ (legacy 行答 `undefined` —— 那台 worker 没说过是谁定的)、`postureSourceIsOperatorPinned()`
70
+ (只有 `env`/`center` 算运维表过态)、`postureKnobDetail()` 四句逐字互异的唯一措辞铸点(毫秒渲人话)。
71
+ 来源四词表 `POSTURE_SOURCE_WORDS` 在本包**只有这一份**,与 `readFacePosture` 共用。
72
+ 门:`scripts/run-posture-knob-projection-test.mjs`。
73
+
74
+ - **`hitl/persistedRulesWire.ts` 三态规则身份** —— `PERSISTED_RULE_BEHAVIORS`(sdk `RuleBehavior` 的
75
+ 运行期镜像,逐词对账)、`persistedRuleBehaviorOf()`(闭三词之外含缺席一律 `undefined`,**不补默认**)、
76
+ `persistedRuleBehaviorLabel()` / `PERSISTED_RULE_BEHAVIOR_UNKNOWN`(表外词原样渲、缺席渲 `(unknown)`,
77
+ 不冒充三态)、`revokeTargetFromPersistedRule()`(身份三元组逐字回传 + 可选 `principal`;**`behavior`
78
+ 读不出即拒铸**)。`listAllPersistedRules` 行为不变:缺 `behavior` 的行**仍进治理清单**(丢行 =
79
+ 把一条活规则藏起来),态那一列渲 `(unknown)`、撤销入口藏起来。
80
+ 门:`scripts/run-rules-side-test.mjs` 新增 G9 段(38 checks)。
81
+
82
+ - **`src/engineIdentity.ts`** —— `/health` 三只**代际锚**(`pid` / `instanceId` / `startedAt`)的窄读器
83
+ 与纯比较。`/health` 是唯一免凭证的门、心跳恒绿,`startedAt`(server ≥7.67.0 无条件发)把「这条端口
84
+ 现在服务的是另一条命」从「等一次 401」变成一等事实。判词是**三态**(`changed` / `same` /
85
+ `unknown`)不是布尔:锚**交集为空** ⇒ `unknown`,绝不折成 `same`;**任一**可比锚不同即 `changed`
86
+ (不让 `startedAt` 独裁);布尔口 `engineIdentityChanged()` 只有 `true` 是一句断言。
87
+ 「换代之后做什么」是宿主的状态机,不在本包。
88
+ 门:`scripts/run-engine-identity-test.mjs`(77 checks)。
89
+
90
+ - **`src/gateVocabulary.ts`** —— 门词汇两张表的唯一措辞铸点:`GATE_DENIED_BY_WORDS`(**九词**,
91
+ core 7.9.0 加 `persisted_rule`)+ `gateDeniedByDetail()`;`ASK_ORIGIN_WORDS`(**十一词**,
92
+ core 7.9.0 加 `rule_store_unavailable`)+ `askOriginDetail()`。两张表逐词逐序抄自 sdk 联合声明,
93
+ 门从实装 `.d.ts` 解出成员**双向等值**对账。🔴 两张表的开闭**各按其出处**,兜底句因此逐字分家:
94
+ `DeniedBy` 在 wire 上是真闭集(出集记录被引擎整条 withhold)⇒ 表外词 = **坏记录**;`AskOrigin`
95
+ 是真开集(server 只判非空串)⇒ 表外词 = **这个词比这一端新**,照常回答那只 ask。
96
+ 门:`scripts/run-gate-vocabulary-test.mjs`(85 checks)。
97
+
98
+ - **`src/permissionRuleIssue.ts`** —— 权限规则 lint 的拒因码表(`PERMISSION_RULE_ISSUE_CODES`,十五码)
99
+ + 唯一措辞铸点 `permissionRuleIssueDetail(code, message)`。sdk 8.8.0 **既无 spec schema 也无导出型**
100
+ (sdk README 自己登记),故按引擎真字节铸;头注写清**候上游导出即整只退役改成从 sdk 取**,并给下游
101
+ 留了反漂移锚(`dts-pattern` 盯 sdk `PermissionRuleIssue` 上的 `code` 声明)。
102
+ 措辞按前缀两族分家:`invalid.*` = 规则写错了(改规则)/ `unsupported.*` = 规则合法但这条车道不接
103
+ (换写法或换个面落),两族逐字互斥。引擎那句 `message` 是**散文**:带上、呈前消毒 + 转义后封长,
104
+ 绝不当码用。另设 `RETIRED_PERMISSION_RULE_ISSUE_CODES` 记账座显形 core 声明里那一员改名遗留。
105
+ 门:`scripts/run-permission-rule-issue-codes-test.mjs`(92 checks;注入 `SEMA_FENCE_ENGINE_DIR` 后 94)。
106
+
107
+ - **`src/toolRoster.ts`** —— 一条腿的**工具名册**(`wiring_manifest.tools`)投影 +
108
+ `tool_roster_delta` 应用。`projectToolRoster()` / `toolRosterNames()` / `toolShimFromRoster()` /
109
+ `applyToolRosterDelta()`。🔴 照抄引擎自己的**绝不半张名册**纪律:任一行读不出、或 `count` 与真实
110
+ 行数对不上 ⇒ **整只判没**(交一份少了行的名册,消费端会把读不出的行当成「这只工具没挂」);
111
+ 例外只有**面**(`pathTarget` / `renderHints` 坏 ⇒ 只丢那一格,行还在)。`pathTarget` 的
112
+ `base` / `absent` / `patternParam`(sdk 8.7.0 / core 7.9.1 #635)可选放行、缺席不铸默认。
113
+ 🔴 `fromDigest` 对不上**不是拒绝**(契约里唯一的硬话):名册照换、只丢 `summary` + 立 `skew` 位 ——
114
+ 拒绝换会让消费端永远抱着一份过期名册。
115
+ `liveInitToolFace.ts` 的三张 tap 实测估计词表**本批不删**(名册只在 effective 半场、且 ≥7.9.0 才真推,
116
+ 回落还得留着),头注登记退役条款:**名册恒在场的引擎版本成为支持底线即删表**。
117
+ 门:`scripts/run-tool-roster-projection-test.mjs`(127 checks)。
118
+
119
+ - **`src/engineNoticeCodes.ts`** —— `engine_notice` 的**码册**(`ENGINE_NOTICE_CODES`,core 7.9.x 的
120
+ 四十八码逐字镜像,含本批加员 `mcp.injection_dropped`)与 **audience 表**
121
+ (`ENGINE_NOTICE_AUDIENCE` / `noticeAudienceOf()`,含 `mcp.injection_dropped` 的 **user** 行)+
122
+ `engineNoticeInCatalog()` + `MCP_INJECTION_DROP_REASONS` + `readMcpInjectionDrop()`。
123
+ 「渲不渲」的判据是**上游有没有把这个码铸进成文码册**,不是这一端有没有那一格文案;audience 答的是
124
+ 另一问(说给谁听),表外码**保守判 operator**。在册判据**认原始值** —— 消毒只进文案,不进判据。
125
+ 🔴 **单铸律**:上游对 `mcp.injection_dropped` 立了 `settlement.single_mint`(宿主供事实、core 铸句子),
126
+ 所以本包**只给事实窄读器、一句措辞都不抄**,转录直接用引擎给的 `message`;门里有反向钉守着。
127
+ 🆕 **core 7.10.0 加员 +2**:`delegation.ask_unresolvable`(audience **user** —— 委派链上那只 ask
128
+ 判不出归属、没有人可问,收件人是发起委派的用户)/ `config.read_face_swapped`(audience **operator**
129
+ —— READ 容纳面被换过档,是部署事实)。码册 48 ⇒ **50**,audience 表同批。
130
+ 门:`scripts/run-engine-notice-catalog-test.mjs`(163 checks)。
131
+
132
+ - **`src/autoModeUnavailable.ts`**(件⑧;core 7.10.0 #616)—— 「这只 ask 是因为**分类器跑不了**才问人」
133
+ 的事实读器 + 唯一措辞铸点:`AUTO_MODE_UNAVAILABLE_CAUSES` / `AUTO_MODE_BREAKER_CAUSES` 两张镜像表、
134
+ `classifierUnavailableOf()`(**一只读器吃两处** —— `AskRequest.classifierUnavailable` 与 durable park 行的
135
+ `PendingAction.tool_approval.classifierUnavailable` 键路同形)、`classifierUnavailableDetail()`。
136
+ 🔴 **两条 cause 轴刻意不合并**:一条答「这一轮分类为什么没跑成」、一条答「熔断闩为什么合上」,
137
+ 交于 `error`/`timeout`、各有一个独占成员。🔴 **`parse_error` 只在熔断轴上** —— 上游顶注逐字
138
+ 「stamps nothing」:分类器**跑了并且答了**,只是答在契约之外;读器按不可用轴收窄,读到它判**缺席**,
139
+ 把它当成一个「没跑成」的成因就是替引擎编一件它明说没发生的事。
140
+ 🔴 它是**显示元数据不是裁决位**;**缺席 ≠「分类器跑成了」**(绝大多数 ask 根本没咨询过分类器)。
141
+ 门:`scripts/run-auto-mode-unavailable-test.mjs`(92 checks)。
142
+
143
+ - **`WorkflowRunStatus` 的 `parked`:零改**。`src/workflow.ts` 两处读法本来就把 `status` 当**开集
144
+ 字符串**原样透出,从不窄读成枚举 ⇒ 新词自动逐字到达端上。详见 §27h3(同处如实登记了一条**先于
145
+ 本批存在**的已知形:`status` 读不出时回落 `'completed'`)。
146
+
147
+ ## 0.62.2(2026-09-09)
148
+
149
+ > patch 内容批(一件)。**本段只记内容**:`package.json.version` 未动、README `Version` 行未动、
150
+ > `FROZEN` 未动 —— bump 与段头转日期形归**发包批**(阶段一义务见本档头注)。**零新导出、零型面变化**,
151
+ > 老宿主一个字节不改照常跑;`peerDependencies` 未动。
152
+
153
+ - 🔴 **子代内容账本:工具卡的阶段账与卡上的内容改成同源**(行为**修复**,异源复审第四轮四条)。
154
+ 0.62.1 把「这张卡现在承载的是哪一阶段」记在一本**只在 `eventId` 在场时才写、且 `start`/`end`
155
+ 两半各自比较**的水位上,而内容(名 / 参 / 结果 / 段边界)不论身份在不在都照落 —— 身份账与内容账
156
+ **不同源**。同一个病根开出四个用户可见的口子,本批一并修掉:
157
+ - **无身份帧改了内容,旧水位还替它背书**:先落新参数 + 真结果(带身份),再收一发**不带**
158
+ `eventId` 的旧参数 / 失败结果 ⇒ 内容按「后到落账」被覆盖,而水位仍停在覆盖**之前**那一阶段
159
+ ⇒ 随后正确的那一发**原样重放**被自己的水位整帧拦掉,卡**永久**停在旧参数与失败结果上。
160
+ 改为:身份缺席的那一发**若真的改了**那一半的内容,落账时同拍把对应那一半水位清掉(判不出新旧
161
+ 就诚实地说判不出);**内容一个字节没变**(重放送来的是同一份值)⇒ 水位原样保留 —— 它仍如实描述
162
+ 卡上这份内容,清掉等于顺手把「旧一阶段不许盖新一阶段」那道闸一起卸了。一句话:**这一半的水位
163
+ 永远描述卡上这一半现在这份内容,内容没动它就没理由动**。
164
+ - **收口不看 `start` 半边**:两条独立的流交错时(恢复阶段的 `tool_start` 已到、旧一阶段的
165
+ `tool_end` 才姗姗来迟),旧收口会落到新阶段那张**开着**的卡上并把它关掉 ⇒ 新参数配上旧阶段的
166
+ 失败结果;新流若中断,这个错误状态一直留在屏上。改为:收口**两半都比**,早于当前 `start` 的
167
+ 整帧丢弃 —— 更新的 `start` 已经建立了新的一阶段,旧收口不属于它(那张卡确实还在飞,
168
+ 显示在飞是**如实**的)。
169
+ - **「水位缺席」被当成「首个段边界还没给过」**:上游不给 `eventId` 时 `start` 水位永远抬不起来
170
+ ⇒ **每一次重放都再段闭合一次**,还在流的活体尾段被切成历史 item,随后该段的聚合全文在空缓冲上
171
+ 吸不到前缀、整只落回缓冲 ⇒ 前缀重复上屏。改为:段边界给过没有是**卡上的一位**,不依赖可选身份。
172
+ - **段边界只按内容串记、不按段身份记**:`AAA` → 收口先到 → `BBB` → 迟到的 `tool_start` 这种乱序里,
173
+ 那一发 start 把 `AAABBB` 整只闭合成**一段** ⇒ 整轮重放送来 `AAA`、`BBB` 两段时两道内容闸都不命中,
174
+ 历史与缓冲各留一份。改为:收口先到那一刻把缓冲上的位置记进**这条子代的账本**(不是记在卡上),
175
+ 段闭合时在**所有**还没兑现的位置上一起切,并把在场每张卡的「还欠不欠一次段闭合」一并置上。
176
+ 位置是**缓冲的**属性、不是那张卡的私产:①兑现者不限于那一发迟到的 `tool_start`(别的工具起头、
177
+ `settle` 同样要切);②那一发迟到的 start 若还当自己欠着,会在别人切完之后再切一刀,切掉的是
178
+ 当前那条与它无关的活体尾段;③卡被帽丢掉不等于缓冲被丢掉(帽先丢 item、丢不够才裁缓冲),
179
+ 边界跟着卡消失就是把「这里有过一次工具」丢了;④缓冲头部被裁时边界同拍左移。上限 64 条。
180
+ 同批把「开着的卡 → 下标」那张表删掉(卡在不在、在哪里只由 `items` 说了算,那是同一件事的第二处
181
+ 真源),`tool_end` 的两支从此共用同一条定位与同一道闸。接入面逐条见 `docs/INTEGRATION-CLIENTS.md`
182
+ §26b;门 `run-subagent-durable-divert-test.mjs` 新增 G13–G16 四块(90 ⇒ 108 checks),每块除终态外都
183
+ 断言**中间快照**、**段条数**与**字节账**(留痕 `droppedBytes` 与门自算的真实字节逐字节相等);另含
184
+ 代理对被拆两帧时段边界的退让格、`settle` 兑现边界格、两张收口先到的卡交错格、切点被别人兑现之后
185
+ 迟到 start 不再切格、卡被裁而缓冲完好格、缓冲头部被裁后边界左移格。
186
+
52
187
  ## 0.62.1(2026-09-09)
53
188
 
54
189
  > patch 内容批(两件)。**本段只记内容**:`package.json.version` 未动、README `Version` 行未动、
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.62.1
38
+ **Version:** 0.63.0
39
39
 
40
40
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
41
41
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -67,7 +67,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
67
67
  against — the tables live upstream precisely so this package does not keep a second copy that can
68
68
  fall behind. The browser bundle really bundles the SDK through (the portability guard would
69
69
  exit 3 rather than quietly mark it external).
70
- - The declared floor is `>=8.5.0`, and it is *witnessed*: the guard checks that an actually
70
+ - The declared floor is `>=8.8.0`, and it is *witnessed*: the guard checks that an actually
71
71
  installed SDK at that line still exports every value-level symbol this package imports and still
72
72
  declares `TaskStats.costMicroUsd` (the key `costOrNull` reads). A floor nobody ever ran is a
73
73
  promise, not a contract.
@@ -237,6 +237,13 @@ public-surface guard checks that last one).
237
237
  | `scripts/run-engine-caps-ledger-test.mjs` | A per-key disposition ledger for `GET /v1/capabilities`. The SDK's `Capabilities` grew from 74 keys to 93 in one release and nothing on the board could see it: this package consumes that table through four synchronous readers, and *nineteen new positions arriving while the package does not move* is exactly the disease shape this repo keeps logging on other axes — the fact is already on the wire, the package boundary is the cell that swallows it, and no client can read it however they write their side. So the ledger is reconciled **element-wise against the SDK interface in both directions**: a key the SDK added with no ledger row is red (someone must classify it), and a row for a key the SDK removed is red too (a registration that no longer does anything). Each row then has to survive its own claim — a `read` row names the source file, and the **code** there (comments stripped) must really mention the key, because prose asserting an alignment is the classic way these guards go hollow; a `not_read` row must have **zero** read sites in the tree, so wiring one up while the ledger still says the package ignores it is red rather than invisible. The census behind those two directions recognises five call shapes, each of which really occurs here — a reader whose base argument carries its own parentheses, a direct `caps.<key>`, a narrowing cast, an own-property read helper, and a `*_CAP` constant — and proves it on fabricated samples first, since a census that recognises one shape reports "nothing here" for the other four. What the guard deliberately does **not** judge is whether a position *ought* to be read: that is a design call, and the ledger only pins that every capability was looked at once by a person and that what they wrote down does not contradict the code |
238
238
  | `scripts/run-sql-engine-capability-test.mjs` | The SQL-posture read face and the four-state capability reader underneath it. One capability cell here carries **four different things**, and each one points an operator somewhere else: nothing has been observed yet in this process (a one-shot doctor run is always in that state), the response arrived but carries no such key (an older engine), the engine explicitly answered `null` — *this deployment has no SQL backend*, which is a **positive fact** rather than an absence — and a full reading. Fold any two together and the screen states something flatly, confidently, and wrongly, so every positive control here is paired with a control pointing the opposite way, and the four sentences the doctor row can print are checked to be pairwise distinct and non-implying. The reading itself is narrowed no tighter than the mint: `txnMode: null` is a **legal value** — two of the three engines always report it that way, and the upstream type note names reading it as "optimistic" as the error — so treating it as malformed would throw away the entire reading for ordinary deployments, which is the same disease this repo logged when a consumer's domain was narrower than the producer's. A response that cannot be parsed **clears** the cell rather than leaving the previous engine's answer in place, and a separate invalidation port exists for the case the generation latch cannot catch — a same-port respawn whose new probe never succeeded, where the stale reading would otherwise be answered as current fact. Untrusted values (the isolation string is read back from a database server variable) are sanitised and bounded before display, and the bound is applied **before** escaping so a visible escape never gets cut in half. Finally the export names are themselves a guard: the shell still carries a copy that is meant to go red on the package's same-named export and be swapped out, so renaming anything here would silently disarm that lock |
239
239
  | `scripts/run-terminal-cause-projection-test.mjs` | The `7.64.0` wire reshape, projected. A run's ending stopped being eight parallel flat keys and became **one tagged cause** (`completed | failed | blocked | paused`), and a tool call's gate stopped being four orthogonal words and became **one record** (`disposition` / `settlement?` / `origin?`). Both are read in exactly one place in this package, and this guard pins them at **two levels**, because the dangerous seam is "the reader was updated, the consumer was not": each terminal arm is checked on the reader *and* on the `subtype` / `is_error` / `errors[]` the projector actually emits. Two properties carry most of the weight. First, a terminal word this reader does not know is **never** laundered into an empty success — it lands on an `unknown` arm carrying the word verbatim, while a payload with no terminal word at all (the mock lane) keeps the success arm exactly as before, which is the one and only case the reader answers `null`. Second, the three window words (`approval_window_expired`, `denial_limit_window_expired`, `park_sla_expired`) must each be told apart by a different predicate: the previous generation collapsed all three onto one `timeout`, and re-merging them would throw away the discrimination this reshape just restored. Two byte generations are read by one reader, keyed on the discriminator upstream nailed (`"terminal" in result`): the current cause form, and the **flat** form that a current engine still emits on two lanes — replayed persisted bytes, which the service passes through verbatim rather than back-filling, and the service's own rejection envelope. A cause-form payload that also carries stale flat keys must ignore them entirely: keeping one compatibility read is what gives a single fact two sources. The same file also pins the MCP delivery verdict and HTTP status riding the wiring manifest, the four-state write-protection reading (where three of the four states mean *cannot tell*, and none of them may be printed as "there is no table"), and the park-reopen fetch identity: that predicate is asserted through the **real entry point**, since the defect being fixed was precisely a call site wired to a different predicate than the one that routed the row there |
240
+ | `scripts/run-auto-mode-unavailable-test.mjs` | The fact behind "you are being asked because the auto-mode classifier could not run", and the one place its sentence is minted. Two cause axes exist upstream and the guard pins them apart: why one classification round failed, and why the session breaker latched. They overlap on two words and each owns a third, so folding them into one table would throw the distinction away — and one of those third words, `parse_error`, is stamped nowhere near this fact, because the classifier did run and did answer, just outside its contract. The reader therefore narrows on the availability axis and treats that word as absence rather than inventing a failure the engine says did not happen. One reader serves both the live ask and its durable parked twin, since the two carry the same key path and a second copy is how two ledgers drift apart. Absence is pinned as absence — most asks never consulted a classifier at all — and the sentences are checked mutually distinct, prototype-safe, and reconciled against the engine's own bytes |
241
+ | `scripts/run-engine-notice-catalog-test.mjs` | The engine-notice catalog and its audience table. Whether a notice deserves a person's attention is not decided by whether this end happens to have a phrasing for it — that drifts with each client's build order — but by whether the engine minted the code into its own written catalog; the audience row answers the separate question of *who* the fact is for, since an operations fact pushed at an end user is noise and a user-facing fact buried in an operator log is something withheld from the person who could act on it. Both tables are reconciled against the installed engine's own artefacts in both directions and pinned in lockstep with each other, unknown codes fall back to the conservative operator side, and catalog membership is tested on the raw value so a code carrying control characters cannot impersonate a registered one after sanitizing. The reader for a dropped MCP injection keys on its own code alone and treats a missing session, server or reason as absence rather than throwing at a read site. A reverse pin enforces the upstream's single-mint contract: the engine composes those sentences from the host's facts, so a copy of them appearing in this package's source or build is a second source that would drift, and fails |
242
+ | `scripts/run-tool-roster-projection-test.mjs` | The leg's tool roster — what the engine says it actually mounted and what face each tool wears — replacing three word lists that were only ever an estimate taken from one traffic capture against one pinned engine. The reader copies the engine's own all-or-nothing discipline: a roster whose row cannot be read, or whose declared count disagrees with the rows, is dropped whole rather than handed over short, because a consumer reading a short roster concludes the missing tools are not mounted — the upstream says in as many words that this is worse than sending nothing. A malformed *face* on a row (path target, render hints) drops only that face, since a face is not an identity. Shims are built strictly from roster rows and never guessed from a tool's name, and an axis that cannot be read stays absent rather than defaulting to `false` or `never`, which would render "unknown" as "safe". For run-time changes the guard pins the one hard rule in the contract: a digest that does not match is **not** a rejection — the carried roster is the new state regardless and only the summary becomes unusable, because refusing the swap would leave the consumer holding a stale roster forever |
243
+ | `scripts/run-permission-rule-issue-codes-test.mjs` | The rule-lint refusal codes an engine reports when it will not compile a permission rule. The SDK publishes neither a schema nor a type for them, so the package mints the table from the engine's own bytes and the guard pays the cost of that copy instead of leaving it to somebody remembering: it parses the codes the engine actually mints and reconciles them against the table in both directions, so a code added upstream (the user would see a bare code) and a code only the package believes in (a branch that can never fire) both fail. It also reconciles the table plus a small retired ledger against the engine's declared union, which is deliberately not the same set — one member was renamed and its old name is still declared — so reviving a code the engine will never mint again is impossible and a future stale member shows up immediately. Sentences are pinned one per code, mutually distinct, and split by family: a rule that is wrong and a rule that is legal but unsupported on this lane are different next steps and may not share a sentence. The engine's own message rides along as prose — sanitized and capped after escaping, never matched on |
244
+ | `scripts/run-gate-vocabulary-test.mjs` | The two gate vocabularies — who denied a call (`DeniedBy`, nine words) and who asked about it (`AskOrigin`, eleven) — together with the one place their sentences are minted, so the same denial does not read three different ways across three clients. The tables are copies, not opinions: the gate parses the members straight out of the installed SDK's declarations and reconciles them against the package's tables in both directions, so a word added upstream (nobody renders it, the user sees a bare code) and a word only the package believes in (a branch that can never fire) both fail. Every word must carry its own literal sentence and no two may collide, including the sibling pairs the upstream deliberately split apart — an organization store and a personal rule store being unreadable send you to different people, and the two tighten origins exist precisely to name which layer of engine logic asked. The two fallbacks are pinned distinct because the sets differ in kind: one is genuinely closed on the wire (an out-of-set record is withheld by the engine, so reading one means the record is damaged) while the other is genuinely open (the server only checks for a non-empty string, so an unknown word just means the client is older than the engine) |
245
+ | `scripts/run-engine-identity-test.mjs` | The engine generation anchors on `/health` (`pid`, `instanceId`, `startedAt`; engine >=7.67.0). `/health` is the one unauthenticated door and its heartbeat is always green, so "another host restarted the shared engine" used to be discoverable only by having some authenticated request hit a 401 first — a path that misreads a restart as a network fault. The reader narrows each anchor independently (one malformed field never hides the other two) and always hands back a reading object rather than an absence, because the caller is asking which anchors answered, not whether there was a response. The comparison is a three-word verdict, not a boolean: `unknown` when the two readings share no comparable anchor at all — an empty intersection means nothing could be compared, never that nothing changed — and the boolean convenience is pinned so that only `true` is an assertion. Any comparable anchor differing decides `changed`, so a reading whose `startedAt` matches while its `instanceId` does not cannot be waved through as the same life; precedence only decides which anchor gets named in the diagnosis |
246
+ | `scripts/run-posture-knob-projection-test.mjs` | The three deployment knobs on the operator face (`serverGates.durableApproval` / `streamAskWindowMs` / `sessionAutoTitle`, engine >=7.67.0), each read as a value **plus who set it plus one operator-facing pointer** rather than a bare value — a bare boolean cannot answer why this particular machine is on this setting or how to pin it back, and a default that flips with the deployment shape is invisible without that. A worker too old to report readings still sends a bare boolean; the reader folds it into the same shell so consumers keep one branch, but raises a `legacy` bit, answers `undefined` from the machine-readable source accessor, and mints a sentence that contains no source word at all — claiming a source nobody reported is worse than admitting the worker cannot say. The other two knobs are honestly absent on such a worker rather than defaulted, a malformed side knob drops only itself while the anchor knob drops the whole reading, and the four sentences are pinned literally distinct so an operator can tell "not observed" from "not reported" from a real value. The last leg reads the installed SDK's `openapi.yaml` and `types.d.ts` directly, including a pin that exactly one knob on this face is numeric — the premise the millisecond-to-prose rendering rests on |
240
247
  | `scripts/run-read-face-posture-projection-test.mjs` | The operator-face `readFace: ReadFacePosture` reader (server >=7.65.0). Three ways of "can't say" are pinned to three different, literal sentences, and none of them may read as "nothing is pinned" — that statement belongs to exactly one case, `face: null`, which is a positive fact reported by the engine, not an absence: not having read an operator response yet, having read one from an engine too old to report the key, and the engine actually saying nothing is pinned are three different next steps for an operator and must not collapse into each other. `source` is read as an open set (the server's closed four words plus an escape hatch) rather than narrowed to an enum, so a new word added upstream is not silently turned into a bad reading. The free-text `note` is sanitized and length-capped before it is ever rendered. A companion pure function flags disagreement between this face and the tenant-facing `capabilities.readFace` — silent only when the two actually agree, honest-absent when either side cannot be read at all, never asserting agreement as a fact. The gate's last leg reads the installed SDK's own `openapi.yaml` directly rather than restating the schema in prose, so the package's leniency cannot quietly drift from the real contract |
241
248
  | `scripts/run-display-cap-order-test.mjs` | The order in which untrusted text is sanitised and length-capped, across every mint point that puts an engine- or database-supplied string on a screen. The sanitiser rewrites each invisible character as a six-character escape, so capping the **raw** string first and escaping afterwards hands the screen six times the width that was budgeted — a forty-character allowance becomes two hundred and forty. The guard does not hardcode that allowance, because each mint point wraps its field in different fixed prose and the prose moves: it anchors on the deciding quantity instead, feeding one benign and one control-character input of the same length through the same mint and requiring the second not to come out longer. That criterion is immune to wording changes and stays sensitive to the expansion, and it is `<=` rather than `==` on purpose — a correct escape-then-cap backs the cut off a partially-consumed escape token, so the control-character line is legitimately the shorter of the two, and demanding equality would score that avoidance as a regression. Each mint is bracketed by two positive controls (the input really reaches the screen; the cap really engages) and the expansion predicate is shown to turn red against a deliberately cap-then-escape reference, so an all-green run cannot mean the guard simply measured nothing. The shared mint point is checked directly for the two avoidances it owes — never splitting an escape token in half, which would leave something on screen that looks like the beginning of a complete answer, and never splitting a legal surrogate pair, which would manufacture the very lone surrogate the sanitiser exists to catch |
242
249
  | `scripts/run-seat-task-request-origin-test.mjs` | Where every field of the seat lane's send-message payload comes from, and whether it actually lands anywhere. The seat payload is a closed interface this package mints itself, and most of its fields are meant to ride verbatim onto the engine's request body — two facts nothing used to connect, so both directions could drift in silence. A seat field could be named after a request position that does not exist, in which case a client writes to it, the wire carries it, the engine ignores the whole key, and the screen shows a switch that does nothing; conversely a new request position could arrive with no seat to sit in, which is **structural** absence — the closed set *is* the carrier, so a decision missing from it has nowhere to be put at all, the same shape logged when the effort dial had no seat. The guard turns each field's origin into data: either it names the request position it forwards to, or it is declared seat-local with a written reason, and the two are mutually exclusive. Forwarding claims are then checked against the **installed** SDK's type declarations, parsed rather than restated — a hand-copied list of position names would only ever prove that two transcriptions agree. The parser is held to reading top-level positions only, since a nested option object's inner keys would otherwise be mistaken for positions of the request itself, and it proves that discrimination on synthetic input before any verdict is given. The two subagent fields carry a standing regression pin, and the retention window's inner keys are read from the declaration the same way, so a seat that offers a tunable window cannot offer one the wire has no room for |
@@ -0,0 +1,77 @@
1
+ /**
2
+ * src/autoModeUnavailable.ts — 「这只 ask 是因为**分类器跑不了**才问人」的事实读器 + 唯一措辞铸点
3
+ * (0.63.0 件⑧;core 7.10.0 #616)。
4
+ *
5
+ * -- 它答的是哪一问 ---------------------------------------------------------------------------
6
+ * auto 模式下,门会就一只 ask 去咨询分类器。分类器**没跑成**时,引擎在这只 ask 上盖一格
7
+ * `classifierUnavailable: { cause }` —— 卡上那句「问你是因为分类器这次跑不了(超时)」的事实来源。
8
+ * 🔴 **它是显示元数据,不是裁决位**:没有任何东西读它来决定放行与否(引擎在门上已经判完了),
9
+ * 所以消费端只拿它渲一句话,**绝不**据它翻转任何裁决。
10
+ * 🔴 **缺席 ≠「分类器跑成了」**:绝大多数 ask 根本没咨询过分类器(部署没武装 auto、或这只 ask 走的
11
+ * 是别的门)。缺席只意味着「这只 ask 上没有这条事实」。
12
+ *
13
+ * -- 🔴 两条 cause 轴,不是一张表 --------------------------------------------------------------
14
+ * · {@link AUTO_MODE_UNAVAILABLE_CAUSES}(`error` / `timeout` / `breaker_open`)—— 「这一轮分类
15
+ * **为什么没跑成**」。这是 `classifierUnavailable.cause` 的值域。
16
+ * · {@link AUTO_MODE_BREAKER_CAUSES}(`error` / `timeout` / `parse_error`)—— 「**熔断闩为什么合上**」。
17
+ * 两集交于 `error` / `timeout`,各有一个独占成员。合成一张表就把两条轴的差别扔了。
18
+ * 🔴 **`parse_error` 只在熔断轴上**:core 顶注逐字 ——「`parse_error` stamps nothing」:分类器
19
+ * **跑了并且答了**,只是答在契约之外,那是**另一句话**。所以它**一个字节都不 stamp** 到
20
+ * `classifierUnavailable` 上;本读器按 unavailable 那一集收窄,读到 `parse_error` 判**缺席** ——
21
+ * 把它读成一个「没跑成」的成因就是替引擎编一件它明说没发生的事。
22
+ *
23
+ * -- 为什么是镜像而不是 import ---------------------------------------------------------------
24
+ * 这两张表与 `classifierUnavailable` 的型面在 sdk 8.8.0 与 agent-types 上**都还没有**(亲验:两棵树
25
+ * 全树零命中),唯一的出处是 `@sema-agent/core` 的 `dist/core/auto-mode.js` / `tool-policy.d.ts` /
26
+ * `checkpoint-store.d.ts`。而 core **不是本包消费者的依赖**(既非 peer 也非 runtime dep),本包的
27
+ * `.d.ts` 一旦引用它,装了本包却没装 core 的下游会当场编译不过。⇒ 与 `engineNoticeCodes.ts` /
28
+ * `toolResult.ts` / `retryStatus.ts` 同一条处置:**按真字节镜像,把代价交给门** ——
29
+ * `run-auto-mode-unavailable-test.mjs` 对**实装 devDep core** 的产物逐词双向对账,core 一动这里就先红。
30
+ * 🔴 **候上游导出即换**:sdk 哪天镜像了这两张表与那一格,本模块的表应当整只退役改成从 sdk 取。
31
+ */
32
+ /**
33
+ * 一轮分类**为什么没跑成**(core `AUTO_MODE_UNAVAILABLE_CAUSES`;逐词逐序镜像)。
34
+ * · `error` —— 模型那条腿抛了/被拒(分类时的路由失败也读在这里:派生路由的前置在任何 decide
35
+ * 之前就回落了,所以没有单独的词);
36
+ * · `timeout` —— 往返上限到了;
37
+ * · `breaker_open` —— 本会话的熔断闩**已经**合上,这一轮被短路,压根没发出去。
38
+
39
+ * 🔴 形制:`Object.freeze` 的数组,**不是**只在类型面只读的 `readonly T[]` —— 后者一行 `.splice()`
40
+ * 就能改,而公面消费者拿到的正是这个实例(本仓已定谳的病形,同 `RESUME_RETRY_LATER_CODES`)。
41
+ */
42
+ export declare const AUTO_MODE_UNAVAILABLE_CAUSES: readonly string[];
43
+ /**
44
+ * **熔断闩为什么合上**(core `AUTO_MODE_BREAKER_CAUSES`;逐词逐序镜像)——**另一条轴**,
45
+ * 与上面那张表刻意不合并(见模块顶注)。`parse_error` 是它的独占成员。
46
+
47
+ * 🔴 形制:`Object.freeze` 的数组,**不是**只在类型面只读的 `readonly T[]` —— 后者一行 `.splice()`
48
+ * 就能改,而公面消费者拿到的正是这个实例(本仓已定谳的病形,同 `RESUME_RETRY_LATER_CODES`)。
49
+ */
50
+ export declare const AUTO_MODE_BREAKER_CAUSES: readonly string[];
51
+ /** 「分类器这次跑不了」的事实(只有成因一格 —— 它是显示元数据,不是裁决位)。 */
52
+ export interface ClassifierUnavailableView {
53
+ /** {@link AUTO_MODE_UNAVAILABLE_CAUSES} 之一。 */
54
+ cause: string;
55
+ }
56
+ /**
57
+ * 一只 ask(或一条 durable park 行的 `tool_approval` 载荷)→ 「分类器跑不了」的事实;
58
+ * 没有这条事实 / 形坏 ⇒ `undefined`,绝不抛出。
59
+ *
60
+ * 🔴 **两处同一只读器**:`AskRequest.classifierUnavailable` 与
61
+ * `PendingAction.tool_approval.classifierUnavailable`(durable park 行的孪生位)**键路同形**,
62
+ * 所以一只读器吃两处 —— 各写一份就是两份台账各漂各的,本包一贯要根治的形。
63
+ * 🔴 **`cause` 按闭集读**(unavailable 那一集):它是消费端分支的键。表外词(含
64
+ * **`parse_error`** —— 那是熔断轴的独占成员,core 明说它不 stamp 到这里)一律判**缺席**,
65
+ * 绝不当成一个能拿去渲「没跑成」的成因。
66
+ * 🔴 **只交 `cause` 一格**:顺手把整只 ask 的别的键带出来会长成第二份 ask 读面。
67
+ */
68
+ export declare function classifierUnavailableOf(ask: unknown): ClassifierUnavailableView | undefined;
69
+ /**
70
+ * 一个成因词 → 一句人话。**唯一措辞铸点**(三端共用;端零自拼)。
71
+ *
72
+ * 🔴 **按自有属性查表**(与本包其余措辞铸点同一条纪律):`Object.freeze` 不移除原型,裸下标会让
73
+ * 一个来自 wire 的 `constructor` / `toString` 命中 `Object.prototype` 上的**函数**并被当成一句话。
74
+ * 🔴 表外词 / 坏值 ⇒ 一句**兜底**:仍然告诉用户「这只 ask 是分类器那条腿引出来的」,但**不冒充**
75
+ * 四句里的任何一句(成因读不懂 ≠ 成因是别的什么);原样带上那个词供运维追问上游。
76
+ */
77
+ export declare function classifierUnavailableDetail(cause: unknown): string;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * src/autoModeUnavailable.ts — 「这只 ask 是因为**分类器跑不了**才问人」的事实读器 + 唯一措辞铸点
3
+ * (0.63.0 件⑧;core 7.10.0 #616)。
4
+ *
5
+ * -- 它答的是哪一问 ---------------------------------------------------------------------------
6
+ * auto 模式下,门会就一只 ask 去咨询分类器。分类器**没跑成**时,引擎在这只 ask 上盖一格
7
+ * `classifierUnavailable: { cause }` —— 卡上那句「问你是因为分类器这次跑不了(超时)」的事实来源。
8
+ * 🔴 **它是显示元数据,不是裁决位**:没有任何东西读它来决定放行与否(引擎在门上已经判完了),
9
+ * 所以消费端只拿它渲一句话,**绝不**据它翻转任何裁决。
10
+ * 🔴 **缺席 ≠「分类器跑成了」**:绝大多数 ask 根本没咨询过分类器(部署没武装 auto、或这只 ask 走的
11
+ * 是别的门)。缺席只意味着「这只 ask 上没有这条事实」。
12
+ *
13
+ * -- 🔴 两条 cause 轴,不是一张表 --------------------------------------------------------------
14
+ * · {@link AUTO_MODE_UNAVAILABLE_CAUSES}(`error` / `timeout` / `breaker_open`)—— 「这一轮分类
15
+ * **为什么没跑成**」。这是 `classifierUnavailable.cause` 的值域。
16
+ * · {@link AUTO_MODE_BREAKER_CAUSES}(`error` / `timeout` / `parse_error`)—— 「**熔断闩为什么合上**」。
17
+ * 两集交于 `error` / `timeout`,各有一个独占成员。合成一张表就把两条轴的差别扔了。
18
+ * 🔴 **`parse_error` 只在熔断轴上**:core 顶注逐字 ——「`parse_error` stamps nothing」:分类器
19
+ * **跑了并且答了**,只是答在契约之外,那是**另一句话**。所以它**一个字节都不 stamp** 到
20
+ * `classifierUnavailable` 上;本读器按 unavailable 那一集收窄,读到 `parse_error` 判**缺席** ——
21
+ * 把它读成一个「没跑成」的成因就是替引擎编一件它明说没发生的事。
22
+ *
23
+ * -- 为什么是镜像而不是 import ---------------------------------------------------------------
24
+ * 这两张表与 `classifierUnavailable` 的型面在 sdk 8.8.0 与 agent-types 上**都还没有**(亲验:两棵树
25
+ * 全树零命中),唯一的出处是 `@sema-agent/core` 的 `dist/core/auto-mode.js` / `tool-policy.d.ts` /
26
+ * `checkpoint-store.d.ts`。而 core **不是本包消费者的依赖**(既非 peer 也非 runtime dep),本包的
27
+ * `.d.ts` 一旦引用它,装了本包却没装 core 的下游会当场编译不过。⇒ 与 `engineNoticeCodes.ts` /
28
+ * `toolResult.ts` / `retryStatus.ts` 同一条处置:**按真字节镜像,把代价交给门** ——
29
+ * `run-auto-mode-unavailable-test.mjs` 对**实装 devDep core** 的产物逐词双向对账,core 一动这里就先红。
30
+ * 🔴 **候上游导出即换**:sdk 哪天镜像了这两张表与那一格,本模块的表应当整只退役改成从 sdk 取。
31
+ */
32
+ /**
33
+ * 一轮分类**为什么没跑成**(core `AUTO_MODE_UNAVAILABLE_CAUSES`;逐词逐序镜像)。
34
+ * · `error` —— 模型那条腿抛了/被拒(分类时的路由失败也读在这里:派生路由的前置在任何 decide
35
+ * 之前就回落了,所以没有单独的词);
36
+ * · `timeout` —— 往返上限到了;
37
+ * · `breaker_open` —— 本会话的熔断闩**已经**合上,这一轮被短路,压根没发出去。
38
+
39
+ * 🔴 形制:`Object.freeze` 的数组,**不是**只在类型面只读的 `readonly T[]` —— 后者一行 `.splice()`
40
+ * 就能改,而公面消费者拿到的正是这个实例(本仓已定谳的病形,同 `RESUME_RETRY_LATER_CODES`)。
41
+ */
42
+ export const AUTO_MODE_UNAVAILABLE_CAUSES = Object.freeze(['error', 'timeout', 'breaker_open']);
43
+ /**
44
+ * **熔断闩为什么合上**(core `AUTO_MODE_BREAKER_CAUSES`;逐词逐序镜像)——**另一条轴**,
45
+ * 与上面那张表刻意不合并(见模块顶注)。`parse_error` 是它的独占成员。
46
+
47
+ * 🔴 形制:`Object.freeze` 的数组,**不是**只在类型面只读的 `readonly T[]` —— 后者一行 `.splice()`
48
+ * 就能改,而公面消费者拿到的正是这个实例(本仓已定谳的病形,同 `RESUME_RETRY_LATER_CODES`)。
49
+ */
50
+ export const AUTO_MODE_BREAKER_CAUSES = Object.freeze(['error', 'timeout', 'parse_error']);
51
+ const UNAVAILABLE = new Set(AUTO_MODE_UNAVAILABLE_CAUSES);
52
+ /**
53
+ * 一只 ask(或一条 durable park 行的 `tool_approval` 载荷)→ 「分类器跑不了」的事实;
54
+ * 没有这条事实 / 形坏 ⇒ `undefined`,绝不抛出。
55
+ *
56
+ * 🔴 **两处同一只读器**:`AskRequest.classifierUnavailable` 与
57
+ * `PendingAction.tool_approval.classifierUnavailable`(durable park 行的孪生位)**键路同形**,
58
+ * 所以一只读器吃两处 —— 各写一份就是两份台账各漂各的,本包一贯要根治的形。
59
+ * 🔴 **`cause` 按闭集读**(unavailable 那一集):它是消费端分支的键。表外词(含
60
+ * **`parse_error`** —— 那是熔断轴的独占成员,core 明说它不 stamp 到这里)一律判**缺席**,
61
+ * 绝不当成一个能拿去渲「没跑成」的成因。
62
+ * 🔴 **只交 `cause` 一格**:顺手把整只 ask 的别的键带出来会长成第二份 ask 读面。
63
+ */
64
+ export function classifierUnavailableOf(ask) {
65
+ if (typeof ask !== 'object' || ask === null || Array.isArray(ask))
66
+ return undefined;
67
+ const raw = ask.classifierUnavailable;
68
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
69
+ return undefined;
70
+ const cause = raw.cause;
71
+ if (typeof cause !== 'string' || !UNAVAILABLE.has(cause))
72
+ return undefined;
73
+ return { cause };
74
+ }
75
+ /**
76
+ * 逐成因一句人话。**四句**(两轴的并集)刻意逐字互异 —— 对用户是四条不同的下一步。
77
+ * 🔴 `parse_error` 那一句说的是「分类器**跑了、答了**,只是答在契约之外」,与另外三句的
78
+ * 「没跑成」是两回事;它**永远不会**从 {@link classifierUnavailableOf} 出来(那一格按
79
+ * unavailable 轴收窄),放在这里是给**渲熔断面**的消费端用同一个铸点。
80
+ */
81
+ const CAUSE_SENTENCES = Object.freeze({
82
+ error: 'asked because the auto-mode classifier could not run (it errored)',
83
+ timeout: 'asked because the auto-mode classifier could not run (it timed out)',
84
+ breaker_open: 'asked because the auto-mode classifier is latched off for this session (its breaker tripped), so this round was never sent',
85
+ parse_error: 'the auto-mode classifier answered outside its contract (its reply could not be parsed)',
86
+ });
87
+ /**
88
+ * 一个成因词 → 一句人话。**唯一措辞铸点**(三端共用;端零自拼)。
89
+ *
90
+ * 🔴 **按自有属性查表**(与本包其余措辞铸点同一条纪律):`Object.freeze` 不移除原型,裸下标会让
91
+ * 一个来自 wire 的 `constructor` / `toString` 命中 `Object.prototype` 上的**函数**并被当成一句话。
92
+ * 🔴 表外词 / 坏值 ⇒ 一句**兜底**:仍然告诉用户「这只 ask 是分类器那条腿引出来的」,但**不冒充**
93
+ * 四句里的任何一句(成因读不懂 ≠ 成因是别的什么);原样带上那个词供运维追问上游。
94
+ */
95
+ export function classifierUnavailableDetail(cause) {
96
+ const known = typeof cause === 'string' && Object.hasOwn(CAUSE_SENTENCES, cause) ? CAUSE_SENTENCES[cause] : undefined;
97
+ if (known !== undefined)
98
+ return known;
99
+ const word = typeof cause === 'string' && cause.length > 0 ? cause : '(none)';
100
+ return `asked because of the auto-mode classifier; its reported cause ${word} is a word newer than this client`;
101
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * src/engineIdentity.ts — 引擎**代际锚**读器(`/health` 的 `pid` / `instanceId` / `startedAt`)
3
+ * 三端共用件(0.63.0;engine ≥7.67.0 / sdk 8.8.0 / S-179)。
4
+ *
5
+ * ── 为什么这一面值得一个模块 ─────────────────────────────────────────────────────────────────
6
+ * `/health` 是一台 worker **唯一免凭证**的门,心跳恒绿。于是「另一个宿主把这台共用引擎重启了」
7
+ * 此前只能靠某个**带凭证**的请求先撞上 401 才被发现 —— 而那条路会把一次重启误读成网络故障
8
+ * (处置完全相反:一个要重新握手,一个要重试)。engine 7.67.0 起 `/health` 无条件带上
9
+ * `startedAt`(这个**进程**自己的起点,epoch ms,模块加载时按 `process.uptime()` 铸一次),
10
+ * 换代因此可以被当成**一等事实**读出来,而不是等一次失败。
11
+ *
12
+ * ── 归层:本模块只读与比,不做状态机 ────────────────────────────────────────────────────────
13
+ * 「读出换代之后要做什么」(丢连接、重握手、提示用户)是**宿主的状态机**,壳有自己的那一只闸。
14
+ * 本包给的是两件纯物:一只窄读器 {@link engineIdentityOf} 与一族纯比较
15
+ * ({@link engineIdentityVerdict} / {@link engineIdentityChanged} / {@link engineIdentityChangedBy})。
16
+ * 三端共用同一套判据,才不会一端把「判不出」当「没换」、另一端把它当「换了」。
17
+ *
18
+ * ── 🔴 三态,不是布尔 ───────────────────────────────────────────────────────────────────────
19
+ * 一个布尔把「没换」与「判不出」压进同一个 `false`,而这两件事的下一步相反(前者继续用,后者
20
+ * 要么再探一次要么按最坏情况握手)。所以真源是三态判词;布尔口保留是因为大多数调用点只关心
21
+ * 「有没有**正面观察到**换代」,它的 `true` 是一句断言、`false` **不是**
22
+ * ([honest-absence-not-fabricated-zero])。
23
+ *
24
+ * ── 🔴 三只锚并列,不是「startedAt 说了算」───────────────────────────────────────────────────
25
+ * `instanceId` 答「是不是同一条命」,`startedAt` 还答「从什么时候起」——上游声明里逐字写着两者
26
+ * **刻意不合并**。所以判据是:**任一**在两侧都在场的锚不同 ⇒ 换代。只看 startedAt 会把
27
+ * 「startedAt 相同而 instanceId 不同」这类真实的坏读数判成「同一条命」。优先序
28
+ * ({@link ENGINE_IDENTITY_ANCHORS})只决定**判词报哪一只**,不决定看不看别的。
29
+ */
30
+ /** 一台 worker 的代际锚读数。三格**并列**,各自独立在场/缺席。 */
31
+ export interface EngineIdentityView {
32
+ /** 这台 worker 的进程号(server 无条件发)。 */
33
+ pid?: number;
34
+ /** 这条命的实例 id(答「是不是同一条命」)。 */
35
+ instanceId?: string;
36
+ /** 这个进程的起点,epoch ms(server ≥7.67.0 无条件发;答「从什么时候起」)。
37
+ * 🔴 缺席 = **这台 worker 说不出来**(<7.67.0),**绝不是**「它没重启过」。 */
38
+ startedAt?: number;
39
+ }
40
+ /**
41
+ * 代际锚的**优先序**。序:`startedAt`(最具体:同一条命还答得出从何时起)→ `instanceId`
42
+ * (同一条命吗)→ `pid`(端口上还是那个进程吗)。
43
+ *
44
+ * 🔴 **顺序**只决定 {@link engineIdentityChangedBy} 报哪一只;但**成员集合是承重的** ——
45
+ * 比较器**遍历的正是这张表**,少一只锚就等于那只锚不再参与判定(异源对抗复审 R2 [medium] 实撞:
46
+ * 移掉 `instanceId` 之后,两份 `instanceId` 不同、别的锚相同的合法读数从 `changed` 翻成 `same`,
47
+ * 宿主据此漏掉一次重握手)。
48
+ * 🔴 因此它是 `Object.freeze` 的数组,**不是**只在类型面只读的 `readonly T[]`:后者一行
49
+ * `.splice()` 就能改,而公面消费者拿到的正是这个实例。这是本仓**已定谳的病形**
50
+ * (`RESUME_RETRY_LATER_CODES` / `RUN_LEVEL_STOP_ERROR_CODES` 两次同款处置)。
51
+ * 运行期反钉在 `run-engine-identity-test.mjs` 的 Bz 段(试改 + 试改后判词没漂,两半都断言)。
52
+ */
53
+ export declare const ENGINE_IDENTITY_ANCHORS: readonly (keyof EngineIdentityView)[];
54
+ /**
55
+ * `/health` 200 体 → 代际锚读数;**畸形一律缺席**,绝不抛出。
56
+ *
57
+ * 🔴 **逐格独立**:一只锚形坏只丢那一格(三只锚是三条独立的证据,一只坏不该把另两只藏起来)。
58
+ * 🔴 **恒返回一只读数对象**(读不出任何一格时是 `{}`),不返回 `undefined` —— 调用方读的是
59
+ * 「哪几只锚这次答得出来」,而不是「这次有没有响应」(后者是它自己的探测腿知道的事)。
60
+ * ⚠️ 只取三只锚;`/health` 上其余的键(`version` / `configHash` / `dataRoot` / 降级位…)各有
61
+ * 自己的读面,不在这里搭便车 —— 一个「顺手多带两个键」的读数会变成第二份 health 台账。
62
+ */
63
+ export declare function engineIdentityOf(health: unknown): EngineIdentityView;
64
+ /** 换代判词。`unknown` = 两侧**没有一只锚可比**(诚实缺席,绝不折成 `same`)。 */
65
+ export type EngineIdentityVerdict = 'changed' | 'same' | 'unknown';
66
+ /**
67
+ * 两份读数说的是不是**同一条命**。
68
+ *
69
+ * 判据(与 {@link engineIdentityChangedBy} 同一套,只是那一只多报一个名字):
70
+ * · **任一**在两侧都在场的锚**不同** ⇒ `changed`;
71
+ * · 至少一只锚可比、且没有一只不同 ⇒ `same`;
72
+ * · **一只可比的锚都没有** ⇒ `unknown`。
73
+ *
74
+ * 🔴 **锚交集为空 ⇒ `unknown` 而不是 `same`**:老 worker 只报 `pid`、新 worker 只报 `startedAt`
75
+ * 时,「没发现不同」是因为**没得比**,不是因为它没换。把这一格读成 `same` 正是本模块要根治的
76
+ * 那类静默病。
77
+ * 🔴 单侧在场的锚**不是反证**:一侧有 `startedAt` 另一侧没有,只说明其中一台答不出这只锚;
78
+ * 只要另有一只锚可比且相同,判词照给 `same`。
79
+ */
80
+ export declare function engineIdentityVerdict(a: unknown, b: unknown): EngineIdentityVerdict;
81
+ /**
82
+ * 换代是**哪一只锚**看出来的(优先序最高的那一只不同的锚)。没换 / 判不出 ⇒ `undefined`。
83
+ * 给的是一句人可读的诊断线索(「端口上换了个进程」vs「同一个进程报了另一条命」),
84
+ * **不是**裁决位 —— 裁决位是 {@link engineIdentityVerdict}。
85
+ */
86
+ export declare function engineIdentityChangedBy(a: unknown, b: unknown): keyof EngineIdentityView | undefined;
87
+ /**
88
+ * **正面观察到换代了吗**。
89
+ * 🔴 `true` 是一句断言(某只可比的锚确实变了);`false` **不是** —— 它同时覆盖「没换」与
90
+ * 「判不出」。要分清这两件事,读 {@link engineIdentityVerdict}(那是本族的真源)。
91
+ * 这个方向是刻意的:据 `true` 做的事(重新握手)在误判时代价可控,据 `false` 做的事
92
+ * (继续用这条连接)在误判时会一直对着一台已经换掉的引擎说话。
93
+ */
94
+ export declare function engineIdentityChanged(a: unknown, b: unknown): boolean;