@sema-agent/client-core 0.63.2 → 0.64.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,96 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.64.0(2026-09-09)
53
+
54
+ ### 新增
55
+
56
+ - **件①:模型「会不会思考、关不关得掉」的三端公共探测**(`src/modelCapabilityProbe.ts`)。一台 vLLM/Qwen
57
+ 类网关默认就在思考,而「关掉」各家拼法不同(顶层 `enable_thinking` / 模板参数 `chat_template_kwargs` /
58
+ `thinking:{type:"disabled"}` / `reasoning:{enabled:false}`),没有人能靠看型号名答出是哪一种。新导出九件:
59
+ `THINKING_FORMATS`(引擎七词方言闭集的镜像)/ `THINKING_FORMAT_WIRE_TWINS`(线上逐字节同形的两对)/
60
+ `THINKING_DISABLE_PROBE_ORDER`(**试关序,公面承诺**)/ `MODEL_PROBE_VERDICTS` / `PROBE_PROMPT` /
61
+ `PROBE_MAX_TOKENS` / `probeRequestBody` / `probeModelCapability` / `applyProbeToEntry`。
62
+ 🔴 **包内零网络**:传输与凭证经注入的 `ProbeSend` 端口(与 `model/catalogLoader.ts` 的 `CatalogFetchJson`
63
+ 同一条纪律),包内零 `fetch`、零 URL、零凭证键 —— 门有源码级反向钉。
64
+ 🔴 **每一发先判「证据力」三态,判词只建在正面证据上**(`off` 看见关掉了 / `on` 看见还开着 /
65
+ `moot` 什么都没证明)—— 「**没看见思考**」不等于「**思考关掉了**」。`moot` 有**四个同形**
66
+ (两轮异源复审各抓出一个):被拒(抛错 / 4xx)/ 正文空 / 正文**纯空白**(只差一个空格就能骗过
67
+ 「长度 > 0」)/ 这一发**被截断**(`finish_reason: "length"` —— `max_tokens` 只有 64,一台在思考的
68
+ 网关很容易先烧完额度再被截掉,那时思考通道可能一个字都没送出来)。⚠️ 截断只否掉 `off` 那一边。
69
+ 于是:baseline 没有证据力 ⇒ `inconclusive` 且结果里**压根没有 `reasoning` 这一格**;
70
+ `cannot_disable` 要求四臂**逐臂给出正面反证**。`applyProbeToEntry` 在 `inconclusive` 上**原样退回
71
+ 同一只条目**,所以一次偶发的网关抖动**抹不掉**一份正确的持久配置。
72
+ `evidence` 把**原始** `contentLen` 与判据用的 `hasText` 分成两格 —— 一格当两用会逼着判据去读一个
73
+ 它不该读的量。
74
+ 🔴 **`cannot_disable` 会把条目上原有的 `thinkingFormat` 删掉**:一个被实测证伪的拼法留着,引擎会继续
75
+ 发一种网关不认的字节,而目录/卡面照那一格渲「思考已关」的假话。
76
+ ⚠️ 方言闭集的属主是引擎(core `OpenAICompletionsCompat.thinkingFormat`),**不是** settings-schema ——
77
+ 后者 1.10.0 的 `ModelEntry.compat` 是 parse-transparent 的 `z.unknown()`,其 JSDoc 逐字声明刻意不复述
78
+ 词表。故按 core 真字节镜像 + 常驻门双向对账(上游加一个方言,门当天先红)。
79
+ 门:`scripts/run-model-capability-probe-test.mjs`(87 格)。宿主换装见 `docs/INTEGRATION-CLIENTS.md` §28。
80
+
81
+ - **件②:auto 分类器的状态面读器 + 唯一措辞铸点**(`src/classifierStatus.ts`;熔断窄读落在
82
+ `src/autoModeUnavailable.ts`)。诊断行 / 模型设置页 / 权限卡状态栏要答的是「这个**会话**上分类器
83
+ 现在是什么状态」——与审批卡那一问(「这一刻为什么在问我」)**不是同一问**,所以句子也分家(门有
84
+ 反向钉守着两张面的句子零重合)。新导出四件:`CLASSIFIER_STATUS_STATES` / `classifierBreakerOf` /
85
+ `classifierStatusOf` / `classifierStatusDetail`。
86
+ **wire 加员**:`WiringManifestAutoMode` 上 additive 一键 `breaker`(core 7.10.0 `AutoModeBreakerTrip`
87
+ 的 `{openedAtMs, lastCause, failures, runId}`;**server ≥7.69.0 才投** ⇒ 缺席 = 老引擎,**不是**
88
+ 「没熔断过」),随 `_sema_autoMode` 段过帧。
89
+ 🔴 **熔断记录与 `armed`/`reason` 之间零互证**:闩合上之后被闩住的那条腿不再把 auto 意图传给后续腿,
90
+ 于是后续腿读作 `no_intent` 而不是 `latch_open` —— `{armed:false, reason:"no_intent", breaker:{…}}`
91
+ 是最常见的合法形。补一条「有 breaker 就该 latch_open」的互证会把真读数整段判没。
92
+ 🔴 **优先序:本轮事实 > 这条腿的 `armed` > 历史熔断记录**。判据锚在**真正决定「分类器现在跑不跑」
93
+ 的量**上,而那个量是这条腿的 `armed` —— core 顶注逐字「a trip names one leg … the ledger carries
94
+ the most recent one forward per session」⇒ 同一会话的后一条腿**完全可以重新武装**,而账本仍带着那次
95
+ 旧 trip。`armed === true` 时判 `breaker_open` 就是把一个**正在放行**的分类器显示成已经关掉了。
96
+ 历史记录**不因为被压下去就丢掉**(任何一态上 `view.breaker` 都照带),所以 `available` 有**两句**
97
+ 措辞。🔴 两句熔断措辞都**不说**「不新开会话就恢复不了」—— 闩是一条腿的事实,那句话是编的。
98
+ **没武装且既无本轮事实也无历史记录**、以及**段缺席**,都答 `undefined`,绝不折成 `available`。
99
+ ⚠️ `breaker` 缺席有**四种不可分辨的成因**(从没熔断过 / 账本有界 FIFO 淘汰 / standalone prepare /
100
+ 老引擎),只能读成「没有可用的熔断记录」。
101
+ 🔴 **渲染路径不许抛**:`openedAtMs` 按 `Date` 的真实值域收(一个 wire 上的 `1e20` 会让
102
+ `toISOString()` 当场 `RangeError` —— 那是整屏崩不是一行渲不出);时刻一律 **UTC ISO**,不猜用户时区。
103
+ 归层:窄读物理落在 `autoModeUnavailable.ts`(那条熔断轴表的属主,且是**零 import 的纯叶**)——
104
+ 投影臂在内核闭包里,而内核棘轮的判据逐字是「新进来的这一件自己有没有 import」;状态面有值级
105
+ import,故刻意留在闭包外。内核上限 9→10、index 闭包 156→157,均逐件记账。
106
+ 门:`scripts/run-classifier-status-test.mjs`(69 格)。宿主换装见 §28②。
107
+
108
+ - **件③:`DecisionReason` +`read_only` 的 uplift 锚**(L-178;`scripts/run-gate-vocabulary-test.mjs`
109
+ D 段)。**零导出、零自铸** —— 本批只登记一条触发器。亲验的事实(别只信版本号):实装 **sdk 8.8.0**
110
+ 全树**零** `DecisionReason`(那个联合上游至今没镜像给 sdk);实装 **core 7.10.0** 的
111
+ `DECISION_REASONS` 是**九词**、**没有** `read_only`(而 npm 上 `@sema-agent/core` 的 latest 就是
112
+ 7.10.0 —— 7.11.0 尚未发布)。`gateVocabulary.ts` 今天承载的是 `DeniedBy`(谁拒的)与 `AskOrigin`
113
+ (谁问的),`DecisionReason`(**为什么这么判**)是**第三个**语义面,本包一张表都还没有。
114
+ ⇒ 不自铸(下游自铸的联合在上游镜像的那天就是第二个会漂的源),改为**两端各断言一次缺席**:
115
+ sdk 那端(镜像到货时红)与 devDep core 那端(提货批抬版时先红)。红的那一刻 = 「该给这第三个
116
+ 语义面铸一句 `allowed: read-only shell command` 并给三端一个铸点」的施工令,**不是**删锚的理由。
117
+
118
+ ### 订正
119
+
120
+ - **件④:§27j 的 G-20 黑盒判据口径**(test [6783] G-1/G-2 提的,亲核属实)。原文写「监视器里那一行的
121
+ 状态词逐字是 `parked`」,而**投影产物的 agent 行上压根没有 `displayStatus` 这一格** ——
122
+ `displayStatus` 是 service detail **入参**上的键,`projectWorkflowRun` 把它折进 `state` 之后产物上
123
+ 就不再有它;显示词由 `agentDisplayStatus(agent, workflowActive)` **二次派生**。按原文字面去读
124
+ `row.displayStatus` 的 test 永远读到 `undefined`。判据改成**两条**:①`agent.state === 'parked'`;
125
+ ②`agentDisplayStatus(agent, false) === 'parked'`(第二参刻意传 `false`,把「run 不活跃 ⇒
126
+ interrupted」那条会抢答的老臂放到台面上)。机读锚:`run-client-core-pure-test.mjs` G-20 段新增一格,
127
+ 逐字断言那一行**没有** `displayStatus`。**零产品改动**。
128
+
129
+ ### 依赖
130
+
131
+ - **件⑤:devDep 新增 `@sema-agent/settings-schema ^1.10.0`**(**不进 peer** —— 本包运行期与 `.d.ts`
132
+ 都不引用它,进 peer 会逼每个下游装一份用不上的包)。它买的是一条**跨包前提门**:
133
+ `applyProbeToEntry` 的产物最终要经 `ModelEntry` 的 zod 解析落库,而 settings-schema 1.10.0 把
134
+ `compat` 声明成 **parse-transparent 的 `z.unknown()`**;那一格哪天改成窄类型,探针写的
135
+ `thinkingFormat` 会在落库那一刻被**静默剥掉**,而三端看不到任何错误(一个 additive 键被剥掉长得就像
136
+ 「探测没跑」)。⇒ `run-model-capability-probe-test.mjs` 新增 I 段,**真跑一次那只解析器**拿真产物对照,
137
+ 并用两个「必须被拒」的形(凭证形自由字段 / 取值器)证明它不是一台「什么都收」的解析器。
138
+ ⚠️ **同批更正一条前提**:方言七词闭集**不能**从 settings-schema import —— 那一格的 JSDoc 逐字声明
139
+ 它刻意不复述词表(「the SHAPE and the VOCABULARY … are the consuming side's single point」),
140
+ 真源是引擎 core 的 `OpenAICompletionsCompat.thinkingFormat`。件① 因此走镜像 + 双向对账门。
141
+
52
142
  ## 0.63.2(2026-09-09)
53
143
 
54
144
  ### 修复
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.63.2
38
+ **Version:** 0.64.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
@@ -241,7 +241,7 @@ public-surface guard checks that last one).
241
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
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
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) |
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) Alongside them sits an **uplift anchor** rather than a third table: the reason a call was decided the way it was is a distinct semantic face from who denied it and who asked, one upstream has not mirrored into the SDK at all, and one whose newest member — a shell command allowed because it only reads — has no sentence anywhere yet. Minting the union here would create the second drifting source the day upstream publishes it, so the guard instead asserts the **absence** from both ends: the SDK declarations carry no such union near that word, and the installed engine’s own list does not carry the word either. The engine end fires first, on the batch that raises the dependency, which is exactly when the ownership question should be answered; the SDK end fires when the mirror lands. Either red is the work order to mint the sentence, never a reason to delete the anchor |
245
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
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 |
247
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 |
@@ -287,6 +287,8 @@ public-surface guard checks that last one).
287
287
  | `scripts/run-rule-offers-reader-test.mjs` | The narrowing reader behind the "don't ask again" options, now a public entry point rather than a card-port-only one. Hosts that render the frame themselves (a browser has no three-way terminal card) previously had to rebuild this reader on their side, and what it carries is a **redemption-safety** judgement, not a convenience: the batch arm is redeemed by **index**, so a reader that compacts the array after dropping a malformed entry makes the k-th option a person clicked and the k-th rule the server writes two different rules. So: a bad entry is dropped **on its own** (one bad option must not make a real one disappear) while every surviving entry keeps its **original wire index** — pinned from both ends, with the bad entries leading and trailing. A batch's *members* are the opposite: any malformed member drops the whole batch, because a conjunctive batch is one "yes" to all of them and a batch missing a member is a different grant; its honest-remainder count is a reading, not decoration, so a non-integer or negative value drops the batch rather than rendering a fabricated zero. An empty array, a non-array, an over-cap array and an all-bad array all read as **absence** rather than an empty list, because an empty list renders as "there is an option lane with nothing in it". The two wire generations are ordered by a rule, not a preference: the newer key wins outright, a newer key that is **present but unreadable** does not fall back to the retired key (borrowing the older material would pass someone else's options off as this request's), and a `null` newer key reads as absence so a relaying layer that serialises "missing" as null cannot delete the whole lane on older engines. The public entry is finally reconciled against **both** card-port legs on the same material, byte for byte, so the exported reader and the one the card sees can never become two. Two upstream vocabularies used to be **hand-copied** here, and both had fallen behind: a match word outside the copied pair dropped an otherwise valid option outright, and a batch carrying a directory-read member — a member kind the copy did not know — dropped the whole batch. Both tables now come from one place upstream and are re-exported verbatim, pinned in both directions: every word in the table must be accepted (a narrower copy reds on the words it never learned) and a word constructed to be outside it must still be refused (a reader widened to "any string" reds too), with the retired-key normalising leg sharing the same narrowing so the fix cannot land on one leg only. A member whose kind is genuinely unknown still drops **the whole batch and only that batch** — never one member, because a conjunctive batch one member short renders "yes to N" as "yes to N−1", and never the card, because the honest single beside it is intact — while a member from before the discriminant existed normalises to the historical kind rather than being refused. The additive per-segment reasons ride through verbatim, drop only the row that is malformed, and stay **absent rather than empty** when nothing survives, since an empty list would read as "confirmed nothing uncovered" while the count remains the only source of truth |
288
288
  | `scripts/run-resume-refusal-copy-test.mjs` | The **words** a client says when a resume is refused, minted once here instead of three times. The facts behind them already lived in this package; the sentences did not, so each client wrote its own — and those sentences answer a safety question (was my decision consumed, can this token still be redeemed), which is exactly the kind of answer that must not vary by client. Two closed sets meet here and the guard pins their relationship in both directions, because it is a premise rather than a coincidence: one set answers *can waiting help* (the codes the server mints a wait on), the other answers *what should a person be told*, they **intersect in exactly one code**, and each keeps a member the other must not have — a placement mismatch is never waitable no matter what arrives on the response, since its remedy is a changed argument rather than elapsed time, and a full governance window needs no prose because "you can wait" is the whole message. The overlapping code delegates its wait and its disposition to the existing reading rather than judging again: nine shapes of input drive both entry points and the two readings must agree byte for byte, the absent case included, because two judges always diverge somewhere. The wait is narrowed to the domain the server mints it in, which is **stricter than the shell's own copy was** — a zero now reads as no window rather than as "retry now", and the wake-up it would retry is an at-most-once action with real side effects. The third sentence is chosen by the disposition, never by the engine's prose: rewriting the message to either upstream branch's exact wording, with the window untouched, must leave all three sentences unchanged, while adding a window must change the third one and only the third one |
289
289
  | `scripts/run-resume-retry-later-test.mjs` | The two resume refusals that carry a **wait quantity** — the only members of that refusal family that do, which is the whole reason they form a closed set. Carrying a wait is not the same as being the only ones worth waiting on: a sibling refusal in the same family clears on its own and the engine says so in words, it just cannot put a number on it, so *not recognised here* must never be read as *waiting will not help*. One of the two also has a *terminal* upstream branch that arrives under the same code with the distinguishing detail only in prose, so recognition alone is not permission to say "try again": the disposition is decided by **positive evidence** and pinned from both directions — the quota-window code is evidence in itself, the preflight code counts only when the server really supplied a wait (an upstream fact, not a convention: the terminal branch throws with no detail at all, so a wait value cannot reach the client on that path), and a preflight refusal with no wait reads as *undecidable* (say what is true of both branches — nothing was consumed — and leave redeemability to the engine's own line) rather than being rendered as either a retry or an ending. Every other member means waiting will not help (change a setting, relaunch, the retained session is gone), so the recognition is a **closed set of two codes**: widening it to a family prefix would tell half the users to wait and the other half to keep waiting for something that will never arrive, and the negative controls drive exactly those codes through it, plus a same-named code on a different door (the submission-side quota refusal), the two underscore-form siblings, and a code merely quoted inside a message body. The wait value is narrowed to the same domain the server mints it in (a whole number of seconds, at least one): zero, a negative, a fraction and a non-number all read as **no window given** rather than as zero, because a zero tells the caller to retry immediately and the wake-up it would retry is an at-most-once action with real side effects. Reading is structural rather than `instanceof`, since the client is host-injected and the same class name across two bundles is two classes, and a null-prototype plain object must still be recognised. The failure classifier gains this one disposition without any existing one moving, an unknown code still falls to the honest open-set arm and its wait value is **not** believed, and an end-to-end call proves the disposition and the window reach the host while the call itself is still attempted exactly once. The recognised code set is a **frozen array**, not a type-level readonly set: the latter is a plain mutable collection at runtime and the decision reads the same instance, so one `.add` from any consumer would turn a refusal that waiting cannot fix into one that claims it can — the guard proves it by really trying to mutate the exported value and then checking the verdict did not drift |
290
+ | `scripts/run-model-capability-probe-test.mjs` | Whether a model on the OpenAI-completions lane **thinks**, and whether that thinking can be **turned off** — a question nobody can answer by looking at a model name, and one whose wrong answer costs every later call. The probe is judgement only: the network half arrives as an injected port, so the package mints no URL, reads no credential and never calls `fetch` — pinned by a source-level assertion, because a package that reaches the network once has changed what every host must trust it with. The seven dialect words are a **copy**, reconciled element-wise against the installed engine’s own bytes in both directions, since the words belong upstream and a private table drifts the day a dialect is added; the settings package deliberately declines to restate them, so the table cannot be imported from there and this guard is what stands in for the import. The **order** the dialects are tried in is a public promise rather than an implementation detail — each extra attempt is real money and real latency against someone’s gateway — so the guard pins the exact call sequence a stub records, and reversing it reds on the wasted round trip; the template-parameter spelling leads because an observed gateway keeps thinking, and answers with an empty body, when handed the top-level switch instead. That observation is also why an empty answer is **not** accepted as *thinking is off*: a knob that deletes the reply is not a knob that disabled reasoning, and accepting it would write a spelling into the catalogue that the gateway does not honour. Two dialect words whose request bytes are identical to another’s do not each burn an attempt. The two verdicts that look alike are held apart from both directions: *tried everything, still thinking* requires at least one attempt to have **cleanly answered**, and when every attempt was refused the verdict is *could not tell* instead — and on the unanswerable path the result carries **no** thinking flag at all rather than a fabricated `false`, while the pure write-back returns the very same entry object untouched. A verdict that reasoning cannot be disabled **removes** a previously declared spelling rather than leaving it, since a refuted spelling keeps the engine sending bytes the gateway ignores while the catalogue still renders it as already off. Evidence is lengths, finish positions and status codes only — a planted secret in both the answer and the reasoning channel must appear nowhere in the result, so the record can go into a log or a ticket whole One cross-package premise is checked by really running the other package’s parser rather than quoting its documentation: everything this probe writes eventually passes through the settings schema on its way into a catalogue, and that field is declared parse-transparent precisely so the vocabulary can live on the consuming side. If it ever narrows, the spelling is stripped **silently** — indistinguishable from the probe never having run — so the guard feeds the probe’s real output through the real parser, checks the compat object comes back key for key, and checks a dialect word this client has never heard of survives too. Two shapes that must be rejected really are rejected, since otherwise the survival checks would hold on a parser that accepts anything, and a bare entry is asserted valid first, because the first run of this section reddened on a space in a fixture’s name — a fixture that cannot pass would disguise the real alarm as already having fired |
291
+ | `scripts/run-classifier-status-test.mjs` | What state the auto-mode classifier is in **on this session** — the question a doctor line, a model settings page and a permission card’s status row all ask, and a different question from the one the approval card asks (*why am I being asked right now*), so the sentences are pinned mutually distinct from that face’s as well as from each other. The session-level fact is a **breaker trip** the engine records, reconciled key by key against the installed engine’s own interface **and** against the field it actually hangs on, so a rename upstream reds here rather than silently reading absence. Three of its four keys carry the sentence (when, why, how many consecutive failures) and a missing one voids the whole record, because half a sentence with no exit is worse than none; the fourth identifies the leg for an operator and so drops only itself. The cause word is read as an **open set** — the words belong upstream, and a private copy swallows the next one as absence. The timestamp is narrowed to the range a `Date` can actually represent: a wire number of `1e20` makes `toISOString()` throw, which is a blank screen rather than one unrendered row, and it is rendered as UTC rather than guessed into a local zone the package cannot know. The **absence** of a trip is old-engine, never *never tripped*, and a malformed one drops itself while the arming facts beside it still ride. The guard pins, from both directions, that no cross-check is invented between a trip and the arming reason: once the latch closes the leg stops handing the auto intent forward, so later legs read as *no intent* and the pairing a reader would expect never occurs — inventing it would void the most common real reading. Precedence is load-bearing for the same reason the latch is one-way: after it closes every round also reports itself unavailable, saying the same thing with the count and the time missing, so the session fact outranks the round fact. Not armed, and a section that never arrived, both answer **undefined** rather than *available* — that arming question has its own field and answering it twice grows a second ledger |
290
292
 
291
293
  Each suite carries a floor that only moves up — a refactor that stops executing a group of
292
294
  assertions is a failure, not a quieter pass. Guards anchor on the **installed artefact's content**
@@ -35,6 +35,7 @@
35
35
  */
36
36
  import type { AgentEvent, CheckpointGate } from '@sema-agent/sdk';
37
37
  import { type SDKMessage, type EmitContext, type ModelUsage } from '../types.js';
38
+ import { type ClassifierBreakerView } from '../../autoModeUnavailable.js';
38
39
  /**
39
40
  * 本适配器铸出的**内部臂** `type` 全集(A-004.4 上游半场,2026-08-08)——「内部臂」= 桥面
40
41
  * (bridge-facing)消息,**不是** CC SDK stdout 联合体里的帧形(那些是 assistant/stream_event/
@@ -169,6 +170,15 @@ export interface WiringManifestAutoMode {
169
170
  armed: boolean;
170
171
  /** core **六词逐字透传**。🔴 不映射 `/v1/capabilities.permissionModeAuto.reason` —— 见投影函数头注。 */
171
172
  reason: string;
173
+ /**
174
+ * 这个**会话**上最近一次熔断闩合上的记录(core 7.10.0 `AutoModeBreakerTrip`;
175
+ * **server ≥7.69.0 才投** ⇒ 缺席 = 老引擎,**不是**「没熔断过」)。
176
+ * 🔴 与 {@link armed} / {@link reason} 之间**没有**互证关系:闩合上之后被闩住的那条腿不再把
177
+ * auto 意图传给后续腿,于是后续腿读作 `no_intent` 而不是 `latch_open` ——
178
+ * `{armed:false, reason:"no_intent", breaker:{…}}` 是最常见的合法形。理由与三态优先序写在
179
+ * `src/classifierStatus.ts` 的模块顶注。
180
+ */
181
+ breaker?: ClassifierBreakerView;
172
182
  }
173
183
  /**
174
184
  * {@link wiringManifestSupersetBody} 的 `mcp[]` 一条目(S-124 / core 7.5.0,server ≥7.60.0 的形)。
@@ -2,6 +2,10 @@ import { stamp, } from '../types.js';
2
2
  import { turnUsageToModelUsage } from './turnUsageToModelUsage.js';
3
3
  // 0.60.0(engine ≥7.64.0 / sdk 8.4.0):`tool_end.gate` 的三端共用窄读器(判定归包,端不许自拼)。
4
4
  import { gateOutcomeOf } from '../../gateOutcome.js';
5
+ // 0.64.0 件②(core 7.10.0 / server ≥7.69.0):`autoMode.breaker` 的**唯一**窄读器 —— 本投影臂与
6
+ // `classifierStatus.ts` 的状态读器共用它,各写一份就是两份台账各漂各的。它住在那张熔断轴表的
7
+ // 同一个文件里(零 import 的纯叶),内核闭包因此只多一件叶子。
8
+ import { classifierBreakerOf } from '../../autoModeUnavailable.js';
5
9
  // #51: `stamp<T extends { uuid?: string; session_id?: string }>` — passing an
6
10
  // arm-body object literal straight to `stamp` triggers a TS quirk: the
7
11
  // literal gets excess-property-checked against the CONSTRAINT (which only
@@ -903,6 +907,15 @@ function projectModelGateSection(raw) {
903
907
  * server 侧另有一条**互证**判据:`armed === (reason === 'armed')`,不满足整段不投。本层照抄它,
904
908
  * 理由不是「重复上游」而是**非投影口**(宿主自建管线 / 重放存量转录)喂进来的帧不过 server ——
905
909
  * 一个 `{armed:true, reason:'settings_denied'}` 会让消费端同时读到「武装了」和「被拒了」。
910
+ *
911
+ * 🆕 **`breaker`(0.64.0;core 7.10.0 `AutoModeBreakerTrip`,server ≥7.69.0 投)**:
912
+ * 窄读**整只委托** {@link classifierBreakerOf} —— 会话轴那一处只有一只读器(见
913
+ * `src/classifierStatus.ts` 顶注)。三条纪律:
914
+ * · **缺席 = 老引擎**,不是「没熔断过」;不铸 `null`、不铸空对象;
915
+ * · **坏形只丢自己**:熔断记录读不出来时,`armed`/`reason` 那两格仍是真读数,照旧过帧
916
+ * (丢整段 = 把「这条腿的 auto 姿态」一起判没,而它与熔断记录是两件事);
917
+ * · 🔴 **绝不**给它与 `armed`/`reason` 补互证:合闩后的腿正是 `{armed:false, reason:"no_intent"}`,
918
+ * 补一条互证会把最常见的那一种真读数整段判没。
906
919
  */
907
920
  function projectAutoModeSection(raw) {
908
921
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
@@ -914,7 +927,8 @@ function projectAutoModeSection(raw) {
914
927
  return undefined;
915
928
  if (armed !== (reason === 'armed'))
916
929
  return undefined;
917
- return { armed, reason };
930
+ const breaker = classifierBreakerOf(a);
931
+ return { armed, reason, ...(breaker !== undefined ? { breaker } : {}) };
918
932
  }
919
933
  /**
920
934
  * `wiring_manifest` 帧 → 两个超集键的**纯投影**(公面导出;三端共用,壳侧绝不自抄一份形校验)。
@@ -28,6 +28,25 @@
28
28
  * `toolResult.ts` / `retryStatus.ts` 同一条处置:**按真字节镜像,把代价交给门** ——
29
29
  * `run-auto-mode-unavailable-test.mjs` 对**实装 devDep core** 的产物逐词双向对账,core 一动这里就先红。
30
30
  * 🔴 **候上游导出即换**:sdk 哪天镜像了这两张表与那一格,本模块的表应当整只退役改成从 sdk 取。
31
+ *
32
+ * -- 0.64.0 件② 加员:**会话轴**的窄读 --------------------------------------------------------
33
+ * {@link classifierBreakerOf} 读的是同一条熔断轴的**另一端**:不是「这一轮为什么没跑成」,而是
34
+ * 「这个**会话**的闩什么时候合上的、因为什么、连着失败了几次」(core 7.10.0 `AutoModeBreakerTrip`,
35
+ * 挂在 `WiringManifest.autoMode.breaker` 上;**server ≥7.69.0 才投**)。
36
+ * 🔴 **它是历史,不是当前闩状态**:core 顶注逐字「a trip names one leg … the ledger carries the most
37
+ * recent one forward per session」—— 同一会话的后一条腿完全可以重新武装。判「分类器现在跑不跑」
38
+ * 的量是那条腿的 `armed`,不是这条记录在不在(判定在 `classifierStatus.ts`)。
39
+ * 🔴 **缺席有四种成因,不可分辨**:从没熔断过 / 账本(有界 FIFO)把它淘汰了 / 这一次是 standalone
40
+ * prepare(账本没接上)/ 老引擎不投。所以缺席只能读成「**没有可用的熔断记录**」——
41
+ * 既不是「没熔断过」,也不是「一定是老引擎」。
42
+ * 它落在本文件是因为
43
+ * {@link AUTO_MODE_BREAKER_CAUSES} 本来就在这里 —— 表与读它的窄读同居,而不是隔一个文件遥指。
44
+ * 🔴 它同时是那一处的**唯一**窄读:`wiring_manifest` 的投影臂与 `classifierStatus.ts` 的状态读器
45
+ * 共用它。本文件是**零 import 的纯叶**,所以投影臂那条内核闭包只多一件叶子(见
46
+ * `run-client-core-portability-test.mjs` 的内核上限记账)。
47
+ * 🔴 **退役条款**:上游若改采 CC 形而把 `breaker` 这一键整只退役(它是 sema 在 CC 之外自己加的
48
+ * 一格),本读器**零改** —— 它的**缺席臂**当天就是正解:读不到就是没有,状态面自然回落到
49
+ * 本轮轴与「可用」。退役一个 additive 键不该逼三端各改一次。
31
50
  */
32
51
  /**
33
52
  * 一轮分类**为什么没跑成**(core `AUTO_MODE_UNAVAILABLE_CAUSES`;逐词逐序镜像)。
@@ -75,3 +94,31 @@ export declare function classifierUnavailableOf(ask: unknown): ClassifierUnavail
75
94
  * 四句里的任何一句(成因读不懂 ≠ 成因是别的什么);原样带上那个词供运维追问上游。
76
95
  */
77
96
  export declare function classifierUnavailableDetail(cause: unknown): string;
97
+ /**
98
+ * 这个**会话**上最近一次熔断闩合上的记录(core `AutoModeBreakerTrip` 逐键镜像)。
99
+ * 🔴 键名与形的属主是 core;门对实装 devDep core 的 `dist/core/auto-mode.d.ts` 逐键对账。
100
+ */
101
+ export interface ClassifierBreakerView {
102
+ /** 闩合上的时刻(epoch ms;已按 `Date` 值域收窄,渲染路径拿它去 `toISOString()` 不会抛)。 */
103
+ openedAtMs: number;
104
+ /** 压垮它的那一次失败的成因(core `AutoModeBreakerCause`;🔴 **开集读** —— 词的属主在 core)。 */
105
+ lastCause: string;
106
+ /** 合闩时的连续失败次数(正整数;阈值,或并发轮次下更多)。 */
107
+ failures: number;
108
+ /** 哪一条腿的裁决器合的闩(运维追查用;不进那句话,所以缺席只丢自己)。 */
109
+ runId?: string;
110
+ }
111
+ /**
112
+ * `wiring_manifest.autoMode` → 熔断记录;没有这条事实 / 形坏 ⇒ `undefined`,绝不抛。
113
+ *
114
+ * 🔴 **三键承重、一键可缺**:`openedAtMs` / `lastCause` / `failures` 是那句话的承重物
115
+ * (「什么时候、因为什么、几次」),缺任一 ⇒ **整段缺席**(半句没有出口的话不如不渲);
116
+ * `runId` 不进那句话 ⇒ 缺席只丢它自己。这与本包 `projectMcpSection` 的「必需两座 + 其余可缺」
117
+ * 同一条判据。
118
+ * 🔴 **`lastCause` 开集读**:表外词照收 —— 词的属主在 core,抄一张表在这里只会把 core 加的新词
119
+ * 吞成缺席(与 `WiringManifestMcpEntry.errorCode` 同规)。消费端按具名词写的 `switch` 必须带
120
+ * `default` 臂;本模块的措辞铸点自带兜底句。
121
+ * 🔴 **按自有属性读**:一只来自 wire 的对象可以带原型;`Object.create({openedAtMs:1,…})` 上那些
122
+ * 值不是这条帧带来的事实。
123
+ */
124
+ export declare function classifierBreakerOf(autoMode: unknown): ClassifierBreakerView | undefined;
@@ -28,6 +28,25 @@
28
28
  * `toolResult.ts` / `retryStatus.ts` 同一条处置:**按真字节镜像,把代价交给门** ——
29
29
  * `run-auto-mode-unavailable-test.mjs` 对**实装 devDep core** 的产物逐词双向对账,core 一动这里就先红。
30
30
  * 🔴 **候上游导出即换**:sdk 哪天镜像了这两张表与那一格,本模块的表应当整只退役改成从 sdk 取。
31
+ *
32
+ * -- 0.64.0 件② 加员:**会话轴**的窄读 --------------------------------------------------------
33
+ * {@link classifierBreakerOf} 读的是同一条熔断轴的**另一端**:不是「这一轮为什么没跑成」,而是
34
+ * 「这个**会话**的闩什么时候合上的、因为什么、连着失败了几次」(core 7.10.0 `AutoModeBreakerTrip`,
35
+ * 挂在 `WiringManifest.autoMode.breaker` 上;**server ≥7.69.0 才投**)。
36
+ * 🔴 **它是历史,不是当前闩状态**:core 顶注逐字「a trip names one leg … the ledger carries the most
37
+ * recent one forward per session」—— 同一会话的后一条腿完全可以重新武装。判「分类器现在跑不跑」
38
+ * 的量是那条腿的 `armed`,不是这条记录在不在(判定在 `classifierStatus.ts`)。
39
+ * 🔴 **缺席有四种成因,不可分辨**:从没熔断过 / 账本(有界 FIFO)把它淘汰了 / 这一次是 standalone
40
+ * prepare(账本没接上)/ 老引擎不投。所以缺席只能读成「**没有可用的熔断记录**」——
41
+ * 既不是「没熔断过」,也不是「一定是老引擎」。
42
+ * 它落在本文件是因为
43
+ * {@link AUTO_MODE_BREAKER_CAUSES} 本来就在这里 —— 表与读它的窄读同居,而不是隔一个文件遥指。
44
+ * 🔴 它同时是那一处的**唯一**窄读:`wiring_manifest` 的投影臂与 `classifierStatus.ts` 的状态读器
45
+ * 共用它。本文件是**零 import 的纯叶**,所以投影臂那条内核闭包只多一件叶子(见
46
+ * `run-client-core-portability-test.mjs` 的内核上限记账)。
47
+ * 🔴 **退役条款**:上游若改采 CC 形而把 `breaker` 这一键整只退役(它是 sema 在 CC 之外自己加的
48
+ * 一格),本读器**零改** —— 它的**缺席臂**当天就是正解:读不到就是没有,状态面自然回落到
49
+ * 本轮轴与「可用」。退役一个 additive 键不该逼三端各改一次。
31
50
  */
32
51
  /**
33
52
  * 一轮分类**为什么没跑成**(core `AUTO_MODE_UNAVAILABLE_CAUSES`;逐词逐序镜像)。
@@ -99,3 +118,47 @@ export function classifierUnavailableDetail(cause) {
99
118
  const word = typeof cause === 'string' && cause.length > 0 ? cause : '(none)';
100
119
  return `asked because of the auto-mode classifier; its reported cause ${word} is a word newer than this client`;
101
120
  }
121
+ /** JS `Date` 能表示的绝对值上限(±8.64e15 ms);越界的数会让 `toISOString()` 抛 `RangeError`。 */
122
+ const MAX_EPOCH_MS = 8.64e15;
123
+ /**
124
+ * `wiring_manifest.autoMode` → 熔断记录;没有这条事实 / 形坏 ⇒ `undefined`,绝不抛。
125
+ *
126
+ * 🔴 **三键承重、一键可缺**:`openedAtMs` / `lastCause` / `failures` 是那句话的承重物
127
+ * (「什么时候、因为什么、几次」),缺任一 ⇒ **整段缺席**(半句没有出口的话不如不渲);
128
+ * `runId` 不进那句话 ⇒ 缺席只丢它自己。这与本包 `projectMcpSection` 的「必需两座 + 其余可缺」
129
+ * 同一条判据。
130
+ * 🔴 **`lastCause` 开集读**:表外词照收 —— 词的属主在 core,抄一张表在这里只会把 core 加的新词
131
+ * 吞成缺席(与 `WiringManifestMcpEntry.errorCode` 同规)。消费端按具名词写的 `switch` 必须带
132
+ * `default` 臂;本模块的措辞铸点自带兜底句。
133
+ * 🔴 **按自有属性读**:一只来自 wire 的对象可以带原型;`Object.create({openedAtMs:1,…})` 上那些
134
+ * 值不是这条帧带来的事实。
135
+ */
136
+ export function classifierBreakerOf(autoMode) {
137
+ if (typeof autoMode !== 'object' || autoMode === null || Array.isArray(autoMode))
138
+ return undefined;
139
+ if (!Object.hasOwn(autoMode, 'breaker'))
140
+ return undefined;
141
+ const raw = autoMode.breaker;
142
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
143
+ return undefined;
144
+ const r = raw;
145
+ const openedAtMs = Object.hasOwn(r, 'openedAtMs') ? r.openedAtMs : undefined;
146
+ if (typeof openedAtMs !== 'number' || !Number.isFinite(openedAtMs))
147
+ return undefined;
148
+ // 🔴 见模块顶注:越界的数会让渲染路径当场抛 `RangeError`;负数不是一个「闩合上了」的读数。
149
+ if (openedAtMs < 0 || openedAtMs > MAX_EPOCH_MS)
150
+ return undefined;
151
+ const lastCause = Object.hasOwn(r, 'lastCause') ? r.lastCause : undefined;
152
+ if (typeof lastCause !== 'string' || lastCause.length === 0)
153
+ return undefined;
154
+ const failures = Object.hasOwn(r, 'failures') ? r.failures : undefined;
155
+ if (typeof failures !== 'number' || !Number.isInteger(failures) || failures < 1)
156
+ return undefined;
157
+ const runId = Object.hasOwn(r, 'runId') ? r.runId : undefined;
158
+ return {
159
+ openedAtMs,
160
+ lastCause,
161
+ failures,
162
+ ...(typeof runId === 'string' && runId.length > 0 ? { runId } : {}),
163
+ };
164
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * src/classifierStatus.ts — auto 分类器的**状态读器**与唯一措辞铸点(0.64.0 件②;
3
+ * core 7.10.0 `AutoModeBreakerTrip` / `WiringManifest.autoMode.breaker`)。
4
+ *
5
+ * -- 它答的是哪一问(与卡面那一问**不是同一问**)----------------------------------------------
6
+ * `autoModeUnavailable.ts` 答的是**卡面**那一问:「这一刻**为什么在问我**」——一次性的、就这只 ask。
7
+ * 本模块答的是**状态面**那一问:「这个**会话**上,auto 分类器现在是什么状态」——诊断行、模型设置页、
8
+ * 权限卡的状态栏读的是它。两问的下一步不同,所以句子也不同(门里有一条反向钉守着两张面的句子零重合)。
9
+ *
10
+ * -- 两处窄读,一只读器 ----------------------------------------------------------------------
11
+ * · **会话轴**:`wiring_manifest.autoMode.breaker`(core `AutoModeBreakerTrip`;server ≥7.69.0 才投)
12
+ * —— 「这个会话的闩**已经**合上了,是什么时候、因为什么、连着失败了几次」。
13
+ * · **本轮轴**:一只 ask 上的 `classifierUnavailable.cause` —— 复用 0.63.0 已有的
14
+ * {@link classifierUnavailableOf},**不重铸**。
15
+ * `classifierBreakerOf` 是会话轴那一处的**唯一**窄读:`wiring_manifest` 的投影臂与本模块的
16
+ * 状态读器共用它。各写一份就是两份台账各漂各的(本包一贯要根治的形)。
17
+ * 🔴 **它的物理位置在 `autoModeUnavailable.ts` 而不是这里**,理由是**门**:`wiring_manifest` 的
18
+ * 投影臂在**内核闭包**里,而那道闭包的棘轮判据逐字是「新进来的这一件自己有没有 import ——
19
+ * 有,就说明它不是叶子,那种 +1 正是本棘轮要拦的东西」。本模块有值级 import(它复用本轮轴的
20
+ * 读器,刻意不另铸第二只),所以**本模块自己不该进内核**;而那只窄读是零 import 的纯判据,
21
+ * 放进已经在闭包里的那张**熔断轴表**的同一个文件里,内核只多一件叶子。归属上也更顺:
22
+ * `AUTO_MODE_BREAKER_CAUSES` 本来就在那里,窄读读的正是那条轴。
23
+ *
24
+ * -- 🔴 「有熔断记录」与 `armed`/`reason` 之间**没有**互证关系,别去补一条 ---------------------
25
+ * 直觉会想写「有 breaker ⇒ reason 应当是 `latch_open`」。那条互证是**错的**,而且会把真读数整段判没:
26
+ * core 的 arm 词表头注逐字写着 —— 闩合上之后,**被闩住的那条腿不再把 auto 意图传给后续腿**,于是
27
+ * 后续腿的 arm 判读走的是 `no_intent`;`latch_open` 那个词留在词表里只是为了让「中途重读」的消费者
28
+ * 在闭集上完备,而**今天没有任何东西铸它**。所以一份 `{armed:false, reason:"no_intent", breaker:{…}}`
29
+ * 是**最常见**的合法形,不是矛盾。
30
+ *
31
+ * -- 🔴 优先序承重:本轮事实 > 这条腿的 `armed` > 历史熔断记录 -------------------------------
32
+ * 判据锚在**真正决定「分类器现在跑不跑」的量**上,而那个量是**这条腿的 `armed`**,不是账本里有没有
33
+ * 一条旧记录:
34
+ * · **本轮事实最先**(`classifierUnavailable`)—— 它说的是眼前这一只 ask,最具体也最新;
35
+ * · **其次是 `armed`** —— core 顶注逐字:「A decider is minted per run (its latch is a RUN fact),
36
+ * so a trip names one leg; the ledger below carries the most recent one forward per session」。
37
+ * ⇒ 同一会话的**后一条腿**完全可以重新武装(新 decider 的闩由构造关着),而账本仍把那次旧 trip
38
+ * 带着。`armed === true` 时判 `breaker_open`,就是把一个**正在放行**的分类器显示成已经关掉了
39
+ * (异源对抗复审 r2 抓出的真病);
40
+ * · **最后才是历史记录** —— 没武装、而账本里有一条 trip,那条 trip 就是「为什么没武装」的最好解释。
41
+ * 🔴 **历史记录不因为被压下去就丢掉**:任何一态上 {@link ClassifierStatusView.breaker} 都照带,
42
+ * 端可以在「在跑」那一行后面补一句「这个会话上曾经熔断过」——那是**两条不同的下一步**,所以
43
+ * 措辞铸点为它单出一句。
44
+ *
45
+ * -- 🔴 时刻一律 UTC ISO,不猜用户时区 --------------------------------------------------------
46
+ * 时区是宿主的事(它知道用户的 locale,本包不知道)。一个猜错的本地时刻比一个明确的 UTC 时刻更难
47
+ * 排查 —— 运维拿着它去对引擎日志,而引擎日志也是 UTC。
48
+ * 🔴 `openedAtMs` 是 **wire 来的数**:一个 `1e20` 会让 `toISOString()` 当场抛 `RangeError`,那是
49
+ * **整屏崩**不是一行渲不出([render-path-must-not-throw])。窄读因此按 Date 的真实值域收。
50
+ *
51
+ * -- 退役条款 ---------------------------------------------------------------------------------
52
+ * 上游若改采 CC 形而把 `breaker` 这一键退役(它今天是 sema 在 CC 之外自己加的一格),本模块**零改**:
53
+ * {@link classifierBreakerOf} 的**缺席臂**当天就是正解 —— 读不到就是没有,状态面自然回落到本轮轴
54
+ * 与 `available`。这条设计是刻意的:退役一个 additive 键不该逼三端各改一次。
55
+ */
56
+ import { type ClassifierBreakerView } from './autoModeUnavailable.js';
57
+ /**
58
+ * 分类器的三态。**刻意只有三个** —— 「没武装」不是分类器的健康状态,那一问由
59
+ * `wiring_manifest.autoMode.reason` 那一格自己回答(六词闭集),在这里再答一遍会长出第二份台账。
60
+
61
+ * 🔴 形制:`Object.freeze` 的数组,**不是**只在类型面只读的 `readonly T[]`(同
62
+ * `AUTO_MODE_UNAVAILABLE_CAUSES` 的理由)。
63
+ */
64
+ export declare const CLASSIFIER_STATUS_STATES: readonly string[];
65
+ /** 一次状态读数。 */
66
+ export interface ClassifierStatusView {
67
+ /** {@link CLASSIFIER_STATUS_STATES} 之一。 */
68
+ state: string;
69
+ /** `breaker_open` 上在场:合闩记录(渲那句话要的三件都在里面)。 */
70
+ breaker?: ClassifierBreakerView;
71
+ /** `unavailable_this_round` 上在场:本轮没跑成的成因(`AUTO_MODE_UNAVAILABLE_CAUSES` 之一)。 */
72
+ cause?: string;
73
+ }
74
+ /**
75
+ * 「这个会话上,auto 分类器现在是什么状态」——三态,或 `undefined`(**说不出来**)。
76
+ *
77
+ * @param autoMode `wiring_manifest` 的 `autoMode` 段(投影后的或原始的都吃;本函数自己窄读)
78
+ * @param ask 可选:**本轮**那只 ask(或 durable park 行的 `tool_approval` 载荷)
79
+ *
80
+ * 优先序(承重,理由见模块顶注):**熔断记录 > 本轮不可用 > 可用**。
81
+ *
82
+ * 🔴 **`undefined` 是一个诚实的答案,不是一个坏路径**:
83
+ * · `autoMode` 段缺席(老 mint / 外部 derive)⇒ 这一端**没有**分类器的健康读数;
84
+ * · 段在、但没武装,而且既没有本轮失败事实、账本里也没有一条 trip ⇒ 分类器**压根没参与**这条腿,
85
+ * 它的健康无从谈起。
86
+ * 两种情形都**绝不**折成 `available`(那是把「没报」渲成「一切正常」)。
87
+ * ⚠️ 「没武装」本身仍是一条要渲的事实 —— 但它的出处是 `autoMode.reason`,不是本读器。
88
+ */
89
+ export declare function classifierStatusOf(autoMode: unknown, ask?: unknown): ClassifierStatusView | undefined;
90
+ /**
91
+ * 一次状态读数 → 一句人话。**唯一措辞铸点**(三端共用;端零自拼)。
92
+ *
93
+ * 三句逐字互异,且与 `classifierUnavailableDetail` 的四句**零重合**(两张面答两个问题)。
94
+ * 🔴 **表外态 / 熔断态记录读不出来都不抛**:型面挡不住 wire,也挡不住一份从持久态恢复回来的视图
95
+ * —— `{state:'breaker_open'}`(没有 `breaker`)、`breaker: null`、`openedAtMs: 1e20`(会让
96
+ * `toISOString()` 抛 `RangeError`)都必须渲出一句诚实的话,而不是把整屏带崩。本函数因此**自己
97
+ * 复核**那只记录(走同一只窄读),读不出就走「没被告知何时因何」那一句,绝不半渲一个假读数。
98
+ */
99
+ export declare function classifierStatusDetail(view: ClassifierStatusView): string;