@sema-agent/client-core 0.32.0 → 0.34.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
@@ -16,6 +16,45 @@
16
16
  > 🔴 **互链**(web [C166]⑦):各版「已知局限」段只记**该版新增**;接入面已知局限的完整台账在
17
17
  > `docs/INTEGRATION-CLIENTS.md` §6e/§7 —— **只读其一会漏**,两处都过。
18
18
 
19
+ ## 0.34.0(2026-08-17)
20
+
21
+ **#292 P1(cli 黑板 [4285]/[4309] 定谳)——`REQUEST_FIELD_MATRIX` 的 `settings.<resolved>` 行补
22
+ `print` 车道、撤 `gap:true`。行为面**一条**:print 车道的 `buildTaskRequest` 从此会把端给的
23
+ `input.settings.resolved` 摊开上 wire(端不给值 ⇒ 仍零 stamp,不会凭空出现)。**
24
+
25
+ - **修的是什么**:这一行自 B4 起就自标 `gap:true`(表内定义逐字 = 「这条差异**没有正当理由,
26
+ 是漏的**」),而它的缺席是**权限方向**的 —— cli [4208] 实测:`-p` 车道上用户 settings 的
27
+ `permissions.deny/ask` 规则**整体不被引擎求值**(deny 拦不住),交互 REPL 同一份文件照拦。
28
+ 同一份配置在两条车道上给出相反的安全结论,不是「车道本来就该不同」。
29
+ - 🔴 **配对纪律(这条修为什么等了这么久)**:包内 stamp 门只是**一半**。另一半在端 ——
30
+ 端必须把 resolver 快照放进 `input.settings.resolved`;cli 的 print 车道修前把 boot 期算好的
31
+ stamp 写进一个只有交互构造器读的模块级 store(死写)。**任一半单独改都无效**,故本条与 cli
32
+ 同批发([4309] 定谳)。
33
+ - **端影响面**:`interactive` 车道逐字节零变化(web / desktop 不经 print 车道 ⇒ 零扰动);
34
+ print 车道的宿主(今天只有 cli)按上一条给值才生效。
35
+ - ⚠️ **可执行判据的口径变化(要知道代价)**:`unregisteredRequestKeys(req,'print')` 对 `settings` 走
36
+ **开放集**口径 —— 只放行**表外**动态子键(resolver 快照自己的 permissions/env/model/…,不可枚举),
37
+ **表内但不属于本车道**的子键(今天 = print 的 `settings.ultracode`)**仍被点名**;顶层键的点名一字
38
+ 未动。构造器侧同一道纪律:`resolvedForLane` 在摊开快照前剥掉本车道不拥有的具名子键,并丢掉
39
+ **函数值**(自有 `toJSON` 能在序列化那一刻整只改写 `settings` 的字节 = 绕过一切按对象查键的判据)。
40
+ 脏快照(null / 非对象 / 数组)一律降空对象 —— 旧式 `?? {}` 的 null 容忍不许因本批变成 TypeError。
41
+ 判据面同款纪律:`settings` 上(自有 / 非枚举 / 原型链)只要有 callable `toJSON`,`unregisteredRequestKeys`
42
+ 一律点名 `settings.toJSON` —— 那种对象「看起来一份、序列化成另一份」,判据不许被它骗过。
43
+ ⚠️ 本层**不做**整份请求体的递归 JSON 净化(函数/访问器/循环/自定义原型):端是同进程受信调用方,
44
+ 而同一条论证对 `agents`/`hooks`/`attachments` 等一切对象位一样成立 ⇒ 归「包级 wire 载荷 JSON-safe
45
+ 规范化」独立工单,不在本批射程。
46
+ - 文档:`docs/INTEGRATION-CLIENTS.md` P-15 的 gap 台账 6 → 5(结清 `settings.<resolved>`,并写明
47
+ print 车道现在**会**摊开快照 + 开放集口径)。
48
+
49
+ ## 0.33.0(2026-08-17)
50
+
51
+ **#289 批面②③(dogfood 案1②/案5,[4199] T2/T5 RED 修)——additive,零 BREAKING。**
52
+
53
+ - `adapter/activeRunSelfHeal`:新 `RejectedSubmissionOrigin`('user'|'injected';`activeRunSelfHealRow` additive 第四参,缺省 'user' 时输出逐字节不变)+ `SelfHealSubmissionDisposition` / `selfHealSubmissionDisposition()`(文案与宿主重排队判据共用同一分类函数)+ `injectedSubmissionRow`。系统注入件(plan-review 回执/cron 唤醒/task 通知)撞 409 不再被说成「Your message was NOT sent … send it again」。
54
+ - `adapter/runStream`:治理终态(`limits.*` 开集前缀 / `output.invalid` / `status:'blocked'`)铸中性行——新 `RUN_STOPPED_MESSAGE_PREFIX` / `RUN_BLOCKED_MESSAGE_PREFIX` / `isGovernanceStopRowText()` / `isGovernanceTerminal()`;真 provider 错与认不出的码保留 `API Error:` 身份。
55
+ - `engineErrorCodes`:新 `LIMITS_ERROR_CODE_PREFIX` + `isLimitsExceededCode()`(开集前缀谓词)。
56
+ - 新常驻门 `run-terminal-identity-copy-test`(107 断言);public-export-baseline 741→748。
57
+
19
58
  ## 0.32.0(2026-08-16)
20
59
 
21
60
  **1.0.80 发包前扫码三件(#280 件A / #281 / #284)。公开面 additive 两件 + 行为面修一件;
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.31.0
38
+ **Version:** 0.33.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
@@ -254,6 +254,7 @@ public-surface guard checks that last one).
254
254
  | `scripts/run-rules-side-test.mjs` | The persisted-permission-rules lane's shared decision half. The two capability bits are checked as **two independent gates** — a worker can honestly advertise the rules lane while predating the revoke routes, and that shape must *hide* the governance surface rather than render a dead entry. Failure classification is by **disposition, not cause**: the two 404s (route missing vs. dead ticket) never share a bucket, a 503 `rule_import_retry` means *the ticket is still alive* (the opposite handling of a dead one), and a stale-cursor 400 drops the cursor and re-lists from the top exactly once — never resuming a stale keyset, never surfacing a partial governance list, and never paging past the hard cap. The persist-ack reader is **merged into** `readToolApprovalRespondAck`: the three-state verdict (`persisted` / `refused` / `unknown`) is derived only from an ack that passed the package's structural narrowing, and a half-shaped object such as `{rulePersisted: true}` with no `delivery` reads as `unknown` — the pre-merge shell read would have said `persisted`, which is precisely the double-ledger drift this file closes, so that case is pinned in reverse. The local-allow-rule skeleton pins all five narrowings (whole-tool, tool-name match, literal anchor with the escaped-star counter-example, bare interpreter prefix consulted only for Bash, and the canonical dangerous-pattern overlay) **with their refusal strings byte-for-byte** — the cli's 128-assertion suite anchors the same strings, so a one-character edit here changes observable behaviour on three clients — and asserts the parse is a pure function of its input, because the same call backs both "render the option" and "resolve the selected value" |
255
255
  | `scripts/run-park-decision-layer-test.mjs` | The decision layer behind the "stuck behind a card" family, shared by every client. A pending row that is **not in the queue** is three states, not one: a bounded, interruptible re-probe loop distinguishes *a decidable row*, *not born yet* (no positive evidence that anything settled — an empty queue proves nothing) and *settled elsewhere*, always probes at least once so a zero budget keeps the pre-fix semantics verbatim, cuts a hung read face off at the window rather than only noticing afterwards, and reports the honest failure when the window is spent instead of inventing a decision. The decision-note reader is likewise three-state: an explicit `noteRecorded: false` outranks an echoed note body, absence renders **no line at all**, and untrusted note text is flattened and bounded before it ever reaches a renderer. Row routing anchors on the deciding quantity — a row carrying `gateKind: "human"` with `toolName: "Write"` is a tool gate, because `human` is the engine's *generic* "someone must decide", not a synonym for a question — and the queue scan refuses to surface a row it cannot positively prove belongs to this session. A chain that fails after the row vanished is split by whether a card was ever presented: decided-elsewhere, or not-its-turn-yet. A row-level single-flight makes "at most one card per pending item" structural rather than incidental. The resume three-way card pins the option **order** (the zero-effect choice sits at index 0, because the frame carries no default-focus field and a stray Enter must not attach or cancel), renders only options the wired verbs can honour, collapses every ambiguous answer to zero action, omits the liveness line entirely when the engine gave no evidence, and — when there is no card lane at all — prints three real routes and exits on a dedicated code rather than reporting success |
256
256
  | `scripts/run-selfheal-reopen-test.mjs` | The 409 active-run self-heal decision chain: `governanceForced` narrows on strict `true` only; triage prefers the wire's `pendingGate.kind` and falls back to the status table (an off-table kind is never guessed into a card arm — hands-off plus the honest wording); a first-sight card makes zero closed/reopened claims and a host presentation receipt of `presented: false` demotes the outcome to reopen-failed; park-row ownership is a fail-closed positive proof (own-run ledger or session id — unprovable is not owned); the three gate-identity key literals live in exactly one mint (`hitl/gateIdentity.ts`, AST string-token scan); the armed-gate presentation ledger is per-session; and the `plan_review` reopen arm shares the arm arm's card body, three-state verdict and delivery pipe, consuming the presentation history once a decision is delivered. The same chain also carries the `running` three-way card: both plan-family gate kinds route to the plan arm and all four ask-family kinds to the ask arm (an off-table kind still never gets guessed into either); the card is offered only for verbs that can actually be honoured and a missing presenter means zero action rather than a silent cancel; a steer is sent **exactly once** with its three delivery outcomes worded apart (a `queued` receipt is the wire correcting the triage input, so the named park word decides which card gets reopened, and an unrecognised park word drives neither arm), and a steer failure is split into *provably not delivered* (4xx) and *delivery unknown*, because telling a user to resend a non-idempotent instruction that may already have landed is how duplicates get made. After a user-chosen cancel, "the session is free" is asserted only from a whitelist of terminal states — park states hold the claim, an unrecognised state word is not a release, a failed read is *unknown* rather than a release, and only a 404 counts as one — and the honest timeout line quotes how long it really waited |
257
+ | `scripts/run-terminal-identity-copy-test.mjs` | Terminal-state **identity**, in both lanes where a stop gets a name. A run stopped by this deployment's own governance knobs — the open-set `limits.*` family, `output.invalid`, and the `blocked` contract terminal a ReportBlocked agent produces — is not a provider failure, and labelling it `API Error:` sends the reader to check the network, the key and the quota when the handle is the `--max-turns` they passed themselves. Those terminals now render a neutral row; the reverse direction is guarded just as hard, because asserting "this is *not* an API error" on a code the package does not recognise is the same misfiling pointed the other way — a real `gateway HTTP 502`, a `conflict.session_active_run` and any unknown code all keep the `API Error:` prefix, and the row keeps its `isApiErrorMessage` class flag so brief-mode visibility filtering does not silently drop it. The second half is who the rejected submission belonged to: the self-heal copy told every caller "Your message was NOT sent … send it again", which is three separate untruths for a system injection (a plan-review outcome, a cron wake-up, a task notification) — not the user's message, and not re-sendable, since a host queue marks those non-editable and non-recallable. The injected form says so instead, and the one sentence that promises re-delivery is pinned to the single disposition that earns it: `selfHealSubmissionDisposition` is the same function the host consults before putting the item back on its queue, so the promise and the behaviour cannot drift apart, and the arms where no card could be surfaced state plainly that nothing was delivered and nothing will retry |
257
258
 
258
259
  Each suite carries a floor that only moves up — a refactor that stops executing a group of
259
260
  assertions is a failure, not a quieter pass. Guards anchor on the **installed artefact's content**
@@ -321,6 +321,35 @@ export type SelfHealOutcome =
321
321
  detail: string;
322
322
  delivery: 'rejected' | 'unknown';
323
323
  };
324
+ /**
325
+ * 撞 409 的那条提交是谁写的。
326
+ *
327
+ * `'user'`(缺省)= 用户在输入框里敲的那条 —— 现有全部文案的隐含前提,不传即逐字节不变。
328
+ * `'injected'` = 宿主代模型/排程注入的**系统件**:`<plan-review-outcome>` 回执、cron /
329
+ * ScheduleWakeup 唤醒、task 通知。对这一类说「Your message was NOT sent … send it again」是
330
+ * 两重谎:①那不是用户的消息;②它在队列里不可编辑不可 ↑ 召回(壳侧 `isQueuedCommandEditable`
331
+ * 对 isMeta 恒 false),用户**在结构上就没有**「再发一次」这个动作。
332
+ */
333
+ export type RejectedSubmissionOrigin = 'user' | 'injected';
334
+ /**
335
+ * 结局 → 这条被拒提交**到底怎么了**。文案(本文件)与宿主的重排队判据(壳
336
+ * `queueProcessor`/`handlePromptSubmit`)读的是**同一个**分类函数 —— 两边各写一份必漂,
337
+ * 而漂的后果是「话说会重试、实际没重排队」(或反之),正是本文件头在骂的假承诺形。
338
+ *
339
+ * · `held-for-decision` —— 没送出去,**而且用户手上此刻真有一张答了就能放行的卡**。
340
+ * ⇒ 宿主可以安全地把系统注入件按原优先级放回队列,答完卡自然重投。
341
+ * · `resending` —— 调用方马上就会重发同一条(cancel 已确认释放)。
342
+ * · `handed-off` —— 正文已经交给引擎了(steer),不是「没送出去」。
343
+ * · `not-delivered` —— 没送出去,且**没有**一张此刻能答的卡 ⇒ 重排队只会空转,如实告知。
344
+ */
345
+ export type SelfHealSubmissionDisposition = 'held-for-decision' | 'resending' | 'handed-off' | 'not-delivered';
346
+ /**
347
+ * 🔴 判据锚在「**有没有一张现在就能答、答了就放行的卡**」这个决定量上,不是锚在「是不是 park
348
+ * 态」这个前置条件上([anchor-on-the-deciding-quantity])。所以三张 reopen-failed / not-parked /
349
+ * state-unknown 全部落 `not-delivered`:它们同样是 park,但卡没能呈到用户面前,把注入件放回队列
350
+ * 只会在下一拍再撞一次同样的 409。
351
+ */
352
+ export declare function selfHealSubmissionDisposition(outcome: SelfHealOutcome): SelfHealSubmissionDisposition;
324
353
  /**
325
354
  * 一次**至多一次**(non-idempotent)POST 失败之后:到底是「服务端明确拒了」还是「不知道有没有
326
355
  * 落地」。steer 与 cancel 两条腿共用这一把尺 —— 它们同属「这一枪不能盲发第二次」的族,而两类
@@ -418,7 +447,7 @@ export declare const INTERACTIVE_WAY_OUT = "run /clear to keep working in a fres
418
447
  * wire 真到得了端的两个 additive 键是 `activeTaskStatus` 与 `pendingGate`,各自有真消费点;
419
448
  * 不为「说不定哪天上游会发」的键留悬空分支。
420
449
  */
421
- export declare function activeRunSelfHealRow(outcome: SelfHealOutcome, signal?: ActiveRunBusySignal | null, copy?: ActiveRunSelfHealCopy): string;
450
+ export declare function activeRunSelfHealRow(outcome: SelfHealOutcome, signal?: ActiveRunBusySignal | null, copy?: ActiveRunSelfHealCopy, origin?: RejectedSubmissionOrigin): string;
422
451
  /**
423
452
  * 非交互车道(headless `-p` 等)对同一事实的诚实说法:那里没有 `/clear`,默认串指的是「别再
424
453
  * `--resume` 到这个会话」和引擎自己的 cancel/decide 端点 —— 都是真的存在、真的能做的动作。
@@ -121,6 +121,29 @@ export const CLAIM_RELEASED_STATES = ['completed', 'failed', 'blocked', 'timeout
121
121
  * 收口成「确认不了」。
122
122
  */
123
123
  export const CLAIM_HELD_STATES = ['running', 'suspended', 'needs_review'];
124
+ /**
125
+ * 🔴 判据锚在「**有没有一张现在就能答、答了就放行的卡**」这个决定量上,不是锚在「是不是 park
126
+ * 态」这个前置条件上([anchor-on-the-deciding-quantity])。所以三张 reopen-failed / not-parked /
127
+ * state-unknown 全部落 `not-delivered`:它们同样是 park,但卡没能呈到用户面前,把注入件放回队列
128
+ * 只会在下一拍再撞一次同样的 409。
129
+ */
130
+ export function selfHealSubmissionDisposition(outcome) {
131
+ switch (outcome.kind) {
132
+ case 'decision-pending':
133
+ return 'held-for-decision';
134
+ case 'ask-reopened':
135
+ case 'plan-review-reopened':
136
+ // 重开成功 = 卡真的呈上去了(`ReopenCardVerdict` 带宿主呈现回执,呈不出来会降级成
137
+ // reopen-failed 那一臂)⇒ 用户手上确有把手。
138
+ return 'held-for-decision';
139
+ case 'running-cancelled':
140
+ return 'resending';
141
+ case 'running-steered':
142
+ return 'handed-off';
143
+ default:
144
+ return 'not-delivered';
145
+ }
146
+ }
124
147
  /** 会话作用域位的透传口(缺席即不置键 —— `exactOptionalPropertyTypes` 下 `{session: undefined}`
125
148
  * 与「没有这个键」不是一回事,而 SDK 那一侧读的正是「在不在场」)。 */
126
149
  function sessionOpts(deps) {
@@ -604,10 +627,14 @@ function governanceOriginClause(signal) {
604
627
  * wire 真到得了端的两个 additive 键是 `activeTaskStatus` 与 `pendingGate`,各自有真消费点;
605
628
  * 不为「说不定哪天上游会发」的键留悬空分支。
606
629
  */
607
- export function activeRunSelfHealRow(outcome, signal, copy) {
630
+ export function activeRunSelfHealRow(outcome, signal, copy, origin) {
608
631
  const override = copy?.rowFor?.(outcome, signal);
609
632
  if (typeof override === 'string')
610
633
  return override;
634
+ // #289 批面② —— 系统注入件的低噪形。整块**前置**于默认串:注入形与用户形是两句完全不同的话,
635
+ // 不是在同一句上加定语(见 {@link injectedSubmissionRow} 头注)。缺省 'user' ⇒ 下面逐字节原样。
636
+ if (origin === 'injected')
637
+ return injectedSubmissionRow(outcome);
611
638
  const base = activeRunSelfHealBaseRow(outcome, signal, copy?.wayOut ?? DEFAULT_WAY_OUT);
612
639
  switch (outcome.kind) {
613
640
  // 这两条是「用户此刻卡住了、而且没有别的把手」的结局 —— wire 给的 decide 入口在这里才有用。
@@ -620,6 +647,41 @@ export function activeRunSelfHealRow(outcome, signal, copy) {
620
647
  return base + governanceOriginClause(signal);
621
648
  }
622
649
  }
650
+ /**
651
+ * 系统注入件被拒时的**低噪**整行(#289 批面②;dogfood inject C1 文案半场)。
652
+ *
653
+ * ── 为什么不是「在原句上换个主语」──────────────────────────────────────────────────────────
654
+ * 原来那 8 条臂全部以「Your message was NOT sent … send it again」收尾。对注入件这句里的每一个
655
+ * 词都不成立:不是 your、不是 message、而且 send it again 这个动作**在结构上不存在**(注入件在
656
+ * 队列里 isMeta ⇒ 不可编辑、不可 ↑ 召回、队列预览里都不渲)。所以这一形从头写,不复用出路半句。
657
+ *
658
+ * ── 与行为的对账(不许再出一句假承诺)────────────────────────────────────────────────────────
659
+ * 「it will be delivered after you decide」这句**只在** {@link selfHealSubmissionDisposition} 判
660
+ * `held-for-decision` 时才说 —— 那正是宿主真会把它按原优先级放回队列的那一档。判据同源,不是
661
+ * 两边各写一份(记忆 `paired-mechanisms-must-share-premise`)。其余档一律如实说「这一条没有送达」,
662
+ * 绝不承诺一次不会发生的重投。
663
+ *
664
+ * ── 噪音预算 ────────────────────────────────────────────────────────────────────────────────
665
+ * 用户没写过这条消息,所以这一行的读者其实是**将来的自己**(为什么模型没收到 plan 的结果)。
666
+ * 一句话说清三件事:是系统件、当时什么挡住了它、接下来会不会自己再来。不给出路半句 —— 用户此刻
667
+ * 该做的事是答那张卡,而那张卡就在屏幕上。
668
+ */
669
+ function injectedSubmissionRow(outcome) {
670
+ const handle = 'taskId' in outcome && outcome.taskId ? ` (run ${outcome.taskId})` : '';
671
+ switch (selfHealSubmissionDisposition(outcome)) {
672
+ case 'held-for-decision':
673
+ return (`A system notification could not be delivered while an earlier turn${handle} is waiting on a ` +
674
+ `decision — sema kept it queued and will deliver it after you answer the open card.`);
675
+ case 'resending':
676
+ return `A system notification was held while the earlier run${handle} was cancelled; sema is delivering it now.`;
677
+ case 'handed-off':
678
+ return (`A system notification was handed to the run that is already working${handle} instead of starting ` +
679
+ `a new turn — watch that run for what it does with it.`);
680
+ case 'not-delivered':
681
+ return (`A system notification was NOT delivered: this session is still held by an earlier run${handle} and ` +
682
+ `sema could not put a decision for it on screen, so it did not retry. The model was not told about it.`);
683
+ }
684
+ }
623
685
  function activeRunSelfHealBaseRow(outcome, signal, wayOut) {
624
686
  switch (outcome.kind) {
625
687
  case 'decision-pending':
@@ -75,6 +75,41 @@ export interface ActiveRunBusySignal {
75
75
  * ② `failed{errorCode|activeTaskId,…}`(任何把它投影成 failed 臂的上游形)。
76
76
  */
77
77
  export declare function activeRunBusySignal(ev: unknown): ActiveRunBusySignal | null;
78
+ /**
79
+ * 中性终态行的前缀。**刻意不是 `API Error`** —— 这一行说的是「这条 run 被本部署的治理旋钮停了」
80
+ * (轮数/预算/墙钟/token 到限、结构化输出重试耗尽),不是 provider 或 HTTP 层出了故障。
81
+ *
82
+ * 事故形(台账 api C1/C2 逐坐标坐实):`sema -p --max-turns N` 的 STDERR 逐字是
83
+ * `API Error: max turns exceeded`,交互面同一构造点也渲成红色「API 错误」行。用户读到 API Error
84
+ * 会去查网络/密钥/额度,而真正的把手是他自己传的 `--max-turns`。CC 对同一件事写的是
85
+ * `Error: Reached max turns (N)` —— 一个字的 `API` 都没有,本前缀与那条身份声明同向。
86
+ */
87
+ export declare const RUN_STOPPED_MESSAGE_PREFIX = "Run stopped";
88
+ /** blocked 终态自带的前缀(`Run blocked: <reason>`,见 errText 构造)。 */
89
+ export declare const RUN_BLOCKED_MESSAGE_PREFIX = "Run blocked";
90
+ /**
91
+ * 这条终态行是不是「治理/契约终态」而非 provider 故障 —— 渲染层据此给中性行同样的错误行 chrome
92
+ * (它仍然是一条终态错误行,只是**身份**不是 API 错误),而不必按前缀猜。
93
+ *
94
+ * 🔴 与产出侧同一个真源:产出在 {@link runStream} 的 governance 臂,判别在这里,端只 import。
95
+ * 各写一份正则必漂(记忆 `machine-readable-signal-not-visual-anchor`)。
96
+ */
97
+ export declare function isGovernanceStopRowText(text: string): boolean;
98
+ /**
99
+ * 终态是不是治理/契约面的(⇒ 中性行),而不是 provider/HTTP 面的(⇒ 保留 `API Error:`)。
100
+ *
101
+ * 🔴 **开集纪律**:只有**认得出**的治理码才走中性臂;认不出的码一律落回 `API Error:` 原臂
102
+ * (原样透传引擎的话)。反过来做 = 替引擎断言「这不是 API 错误」,而那正是本修在骂的那类错档。
103
+ *
104
+ * 成员:
105
+ * · `status:'blocked'`(ReportBlocked 契约终态 —— agent 自己说它无法继续,零 provider 参与);
106
+ * · `limits.*` 全族(开集前缀,{@link isLimitsExceededCode});
107
+ * · `output.invalid`(结构化输出重试耗尽 —— CC 自己也有专门 subtype,同样不叫 API 错误)。
108
+ */
109
+ export declare function isGovernanceTerminal(input: {
110
+ status: string | undefined;
111
+ errorCode: string | undefined;
112
+ }): boolean;
78
113
  /** 测试钩:清空「已上报过的臂」去重表(去重是**跨调用**状态,不清就只有第一条用例看得见)。 */
79
114
  export declare function _resetDroppedFrameReportForTest(): void;
80
115
  /** 测试钩:去重表当前条数 —— ADAPTER-F5 的上限断言要读的**决定结果的量**(行数只证「吼了几次」,
@@ -7,7 +7,7 @@ import { coerceOutput, publishSubagentContentEvent } from '../subagentContentSto
7
7
  * 引擎把它 stamp 在 `done{status:'failed'}` / `failed` 终帧上,壳据此**结构判读**,不再读人话。
8
8
  * G1 去字面化(2026-08-04):字面收编进 `engineErrorCodes.ts` 单一真源,本文件只 import。
9
9
  */
10
- import { ACTIVE_RUN_BUSY_ERROR_CODE } from '../engineErrorCodes.js';
10
+ import { ACTIVE_RUN_BUSY_ERROR_CODE, OUTPUT_INVALID, isLimitsExceededCode } from '../engineErrorCodes.js';
11
11
  /** 本文件发的 chrome 事件全在 leader lane(子代内容在上面就被 divert 走了)。 */
12
12
  const MAIN = { lane: 'main' };
13
13
  /**
@@ -81,6 +81,48 @@ function classifyActiveRunBusy(input) {
81
81
  return { busy: true, handle };
82
82
  return { busy: false, handle };
83
83
  }
84
+ // ── 治理终态 vs provider/HTTP 真错(#289 批面③ / dogfood api C1+C2+案5)────────────────────────
85
+ /**
86
+ * 中性终态行的前缀。**刻意不是 `API Error`** —— 这一行说的是「这条 run 被本部署的治理旋钮停了」
87
+ * (轮数/预算/墙钟/token 到限、结构化输出重试耗尽),不是 provider 或 HTTP 层出了故障。
88
+ *
89
+ * 事故形(台账 api C1/C2 逐坐标坐实):`sema -p --max-turns N` 的 STDERR 逐字是
90
+ * `API Error: max turns exceeded`,交互面同一构造点也渲成红色「API 错误」行。用户读到 API Error
91
+ * 会去查网络/密钥/额度,而真正的把手是他自己传的 `--max-turns`。CC 对同一件事写的是
92
+ * `Error: Reached max turns (N)` —— 一个字的 `API` 都没有,本前缀与那条身份声明同向。
93
+ */
94
+ export const RUN_STOPPED_MESSAGE_PREFIX = 'Run stopped';
95
+ /** blocked 终态自带的前缀(`Run blocked: <reason>`,见 errText 构造)。 */
96
+ export const RUN_BLOCKED_MESSAGE_PREFIX = 'Run blocked';
97
+ /**
98
+ * 这条终态行是不是「治理/契约终态」而非 provider 故障 —— 渲染层据此给中性行同样的错误行 chrome
99
+ * (它仍然是一条终态错误行,只是**身份**不是 API 错误),而不必按前缀猜。
100
+ *
101
+ * 🔴 与产出侧同一个真源:产出在 {@link runStream} 的 governance 臂,判别在这里,端只 import。
102
+ * 各写一份正则必漂(记忆 `machine-readable-signal-not-visual-anchor`)。
103
+ */
104
+ export function isGovernanceStopRowText(text) {
105
+ const t = text.trimStart();
106
+ return t.startsWith(`${RUN_STOPPED_MESSAGE_PREFIX}:`) || t.startsWith(`${RUN_BLOCKED_MESSAGE_PREFIX}`);
107
+ }
108
+ /**
109
+ * 终态是不是治理/契约面的(⇒ 中性行),而不是 provider/HTTP 面的(⇒ 保留 `API Error:`)。
110
+ *
111
+ * 🔴 **开集纪律**:只有**认得出**的治理码才走中性臂;认不出的码一律落回 `API Error:` 原臂
112
+ * (原样透传引擎的话)。反过来做 = 替引擎断言「这不是 API 错误」,而那正是本修在骂的那类错档。
113
+ *
114
+ * 成员:
115
+ * · `status:'blocked'`(ReportBlocked 契约终态 —— agent 自己说它无法继续,零 provider 参与);
116
+ * · `limits.*` 全族(开集前缀,{@link isLimitsExceededCode});
117
+ * · `output.invalid`(结构化输出重试耗尽 —— CC 自己也有专门 subtype,同样不叫 API 错误)。
118
+ */
119
+ export function isGovernanceTerminal(input) {
120
+ if (input.status === 'blocked')
121
+ return true;
122
+ if (isLimitsExceededCode(input.errorCode))
123
+ return true;
124
+ return input.errorCode === OUTPUT_INVALID;
125
+ }
84
126
  /**
85
127
  * 已上报过的未知帧类型(REF-CC-057:每种臂只吼一次,免得一条流刷屏)。
86
128
  * 🔴 单例登记见 `docs/refactor/p1-scan/singleton-manifest.json`:复制两份的后果 = 同一种臂多打
@@ -353,12 +395,29 @@ async function* runStreamInner(events, ctx, handle = {}) {
353
395
  // 重启引擎捞不回来(boot 期孤儿回收只捞 running)、时间型 reap 挂在
354
396
  // APPROVAL_TIMEOUT_SEC(默认 0 ⇒ 整条腿不跑),唯一兜底窗是 30 天。
355
397
  // 换成真话 + 真的存在的两条动作(引擎的 cancel 端点 / 换一个会话)。
398
+ // #289 批面③ —— **错档前缀**(dogfood api C1/C2 + 案5)。轮数/预算/墙钟/token 到限、
399
+ // 结构化输出重试耗尽、ReportBlocked 的 blocked:这几种是**引擎侧治理/契约终态**,一个
400
+ // provider 请求都没失败过。此前它们与真 401/网关 502 共用 `API Error: ` 身份声明,把
401
+ // 「你自己设的 --max-turns 到了」说成 API 故障(判据与出处见 `isGovernanceTerminal`)。
402
+ // 认得出的治理终态 ⇒ 中性终态行;认不出的码一律走原 `API Error:` 臂(开集纪律)。
403
+ // 🔴 `isApiErrorMessage: true` 仍然置位:它是 CC 的**行类**旗(「这是合成的终态错误行,
404
+ // 不是模型说的话」),不是「这是 provider API 错误」的断言 —— 可见性过滤
405
+ // (`Messages.filterForBriefTool`)、-p 契约投影、tick 熔断都读它,撤掉 = 这一行在
406
+ // brief 模式里静默消失。改的是**身份文案**,不是行类。
407
+ const governance = isGovernanceTerminal({
408
+ status: ev.type === 'failed' ? undefined : failedResult?.status,
409
+ errorCode: ev.type === 'failed' ? ev.errorCode : failedResult?.errorCode,
410
+ });
356
411
  const busyHandle = busy.handle;
357
412
  const rowText = busy.busy
358
413
  ? `This session is locked by an earlier run${busyHandle ? ` (run ${busyHandle})` : ''} that ` +
359
414
  `has not been released, so this message was NOT sent. Nothing releases it on its own — ` +
360
415
  `cancel that run (POST /v1/runs/${busyHandle ?? '<id>'}/cancel), or start a new session.`
361
- : `API Error: ${errText}${maxTokHint}`;
416
+ : governance
417
+ ? // blocked 的 errText 自己就以 `Run blocked` 开头(见上方 errText 构造),再套一层
418
+ // `Run stopped:` 会变成两个前缀叠字;其余治理码用中性前缀 + 引擎原话。
419
+ `${isGovernanceStopRowText(errText) ? errText : `${RUN_STOPPED_MESSAGE_PREFIX}: ${errText}`}${maxTokHint}`
420
+ : `API Error: ${errText}${maxTokHint}`;
362
421
  yield {
363
422
  session_id: ctx.sessionId ?? '',
364
423
  uuid: `err-${Date.now().toString(36)}`,
@@ -33,6 +33,17 @@ export declare const LIMITS_MAX_COST_EXCEEDED = "limits.max_cost_exceeded";
33
33
  export declare const LIMITS_MAX_TURNS_EXCEEDED = "limits.max_turns_exceeded";
34
34
  /** 墙钟预算到限(5.8.0 起是**响亮终局** `status:'failed'`;`status:'timeout'` 该终态词整体退役)。 */
35
35
  export declare const LIMITS_MAX_WALLTIME_EXCEEDED = "limits.max_walltime_exceeded";
36
+ /** 限额族的**族前缀**(单源;上面四个常量都以它开头)。 */
37
+ export declare const LIMITS_ERROR_CODE_PREFIX = "limits.";
38
+ /**
39
+ * 该码是否属「引擎侧治理限额到限」族。**开集前缀判**(与 {@link isConfigRefusalCode} 同款):
40
+ * core 每加一根新的限额轴(`limits.max_*`)判别自动跟上,按成员判的消费点则要跟车。
41
+ *
42
+ * 🔴 用途边界:这一族回答的是「谁停的这条 run」= **本部署的治理旋钮**,不是 provider/HTTP 故障。
43
+ * 呈现层据此避开 `API Error:` 那个身份声明(见 `adapter/runStream.ts` 的 governance 臂)——
44
+ * 把一次「轮数用光了」说成 API 错误,用户会去查网络/密钥/额度,而真正的把手是 `--max-turns`。
45
+ */
46
+ export declare function isLimitsExceededCode(code: string | undefined): boolean;
36
47
  /** 部署级 token 治理窗耗尽。**唯一携带可执行等待量的停因**,与 `retryAfterMs` 配对到货。
37
48
  * 5.10.0 起:治理窗与环境寿命同一轮边界到期时,**窗赢**(此前 env-first 报告把它丢了)。 */
38
49
  export declare const USAGE_WINDOW_EXHAUSTED = "usage.window_exhausted";
@@ -34,6 +34,19 @@ export const LIMITS_MAX_COST_EXCEEDED = 'limits.max_cost_exceeded';
34
34
  export const LIMITS_MAX_TURNS_EXCEEDED = 'limits.max_turns_exceeded';
35
35
  /** 墙钟预算到限(5.8.0 起是**响亮终局** `status:'failed'`;`status:'timeout'` 该终态词整体退役)。 */
36
36
  export const LIMITS_MAX_WALLTIME_EXCEEDED = 'limits.max_walltime_exceeded';
37
+ /** 限额族的**族前缀**(单源;上面四个常量都以它开头)。 */
38
+ export const LIMITS_ERROR_CODE_PREFIX = 'limits.';
39
+ /**
40
+ * 该码是否属「引擎侧治理限额到限」族。**开集前缀判**(与 {@link isConfigRefusalCode} 同款):
41
+ * core 每加一根新的限额轴(`limits.max_*`)判别自动跟上,按成员判的消费点则要跟车。
42
+ *
43
+ * 🔴 用途边界:这一族回答的是「谁停的这条 run」= **本部署的治理旋钮**,不是 provider/HTTP 故障。
44
+ * 呈现层据此避开 `API Error:` 那个身份声明(见 `adapter/runStream.ts` 的 governance 臂)——
45
+ * 把一次「轮数用光了」说成 API 错误,用户会去查网络/密钥/额度,而真正的把手是 `--max-turns`。
46
+ */
47
+ export function isLimitsExceededCode(code) {
48
+ return typeof code === 'string' && code.startsWith(LIMITS_ERROR_CODE_PREFIX);
49
+ }
37
50
  // ── 停钟族(core 5.10.0「三时钟序」:usage_window > env_lifetime > stall)─────────────────────
38
51
  /** 部署级 token 治理窗耗尽。**唯一携带可执行等待量的停因**,与 `retryAfterMs` 配对到货。
39
52
  * 5.10.0 起:治理窗与环境寿命同一轮边界到期时,**窗赢**(此前 env-first 报告把它丢了)。 */
@@ -12,6 +12,8 @@
12
12
  * · **print 独有** `finalVerification` / `limits` / `interactiveTools`;
13
13
  * · **TUI 独有** `ultracode` / `systemPrompt` / `reasoningEffort` / `images` / `model` /
14
14
  * rewind 三件 / `clientContext` / `scratchpadDir` / 已解析 settings。
15
+ * (最后一项 2026-08-17 已补齐两车道 —— #292 P1,理由见矩阵里那一行的行注:它的缺席是
16
+ * **权限方向**的,不是排版问题。)
15
17
  * 这不是「两条车道本来就该不同」——里面**一部分是有理由的,一部分纯粹是漏了**。合一的目的不是
16
18
  * 把它们抹平(那是行为改动),而是把「谁有谁没有、为什么」变成**一张表**:再漏就会在 diff 里显形。
17
19
  *
@@ -164,8 +166,16 @@ export type BuiltTaskRequest = TaskRequest;
164
166
  *
165
167
  * 判据口径(三类不点名):
166
168
  * ① 车道表里的字段(`lanes` 含本车道);② `LIVE_DEFAULT_FIELDS` —— live 兜底层追加、两条车道
167
- * 都经过,故本就不进车道表;③ `settings` 的子键按表认(`settings.<sub>`),但**交互车道**的
168
- * `settings.<resolved>` resolver effective 快照 = **开放集**,子键不可枚举 ⇒ 整体放行。
169
+ * 都经过,故本就不进车道表;③ `settings` 的子键按表认(`settings.<sub>`)—— 登记了
170
+ * `settings.<resolved>` 的车道(#292 P1 = **两条**)那份 resolver effective 快照是**开放集**,
171
+ * 所以**表外**子键(permissions/env/model/… 不可枚举)放行。
172
+ *
173
+ * 🔴 开放集**不是**整只 `settings` 免检(#292 P1 codex 复审第二轮 [medium] 采纳):表里点名了、
174
+ * 而 lanes 不含本车道的子键(如 print 车道的 `settings.ultracode`)照旧**点名** —— 那些键是
175
+ * **可枚举的已知量**,放过它们等于让「ultracode 仅 interactive」这条登记在真 wire 面失效。
176
+ * `buildTaskRequest` 内的 `resolvedForLane` 只管住构造器自己那一份;端在构造之后仍能往请求体上
177
+ * 塞键(print lane 修前正是「自己另拼一份」),而本函数的整个存在理由就是替**真上 wire 的那份**
178
+ * 过表。两道门缺一不可。
169
179
  *
170
180
  * 🔴 它**不判**「该不该有」——只判「有没有登记」。补齐一条真实差异要改表(带理由),
171
181
  * 而不是绕过本函数:改表这个动作本身就会在 diff 里显形,这正是本判据存在的意义。
@@ -24,6 +24,14 @@ export const REQUEST_FIELD_MATRIX = [
24
24
  { field: 'mcpServers', lanes: ['interactive', 'print'], live: true, why: '本地 .mcp.json 投影;service 单用户门决定是否兑现' },
25
25
  { field: 'settings.webSearch', lanes: ['interactive', 'print'], live: true, why: 'SEMA_WEBSEARCH_* / settings.json;per-request 配置赢过部署 env' },
26
26
  { field: 'settings.hooks', lanes: ['interactive', 'print'], live: true, why: '[495]① 用户 settings 文件 hooks 逐字上 wire' },
27
+ // ⚠️ 这一行 2026-08-17 之前是 `lanes:['interactive'] gap:true`(表内自标「这条差异没有正当理由,
28
+ // 是漏的」),#292 P1 把它补齐并搬进本节。补齐的理由不是对称美观,是 [4208] 实测的**权限方向**
29
+ // 后果:`-p` 车道上用户 settings 的 `permissions.deny/ask` 整体不被引擎求值(deny 拦不住),
30
+ // 而交互 REPL 同一份文件照拦 —— 两条车道对同一份配置给出相反的安全结论。
31
+ // 🔴 配对纪律:这一行只管**包内 stamp 门**;端还得把值放进 `input.settings.resolved`
32
+ // (壳 print 车道 = `-p` 前置步装一个晚绑定供给器,`seamQueryEngine` 在拼装请求那一刻求值)。
33
+ // 任一半单独改都无效 —— 这正是本条修前长期停在 gap 的原因([4285]/[4309] 定谳)。
34
+ { field: 'settings.<resolved>', lanes: ['interactive', 'print'], live: true, why: 'settings resolver 的 effective 快照(hooks/env/model + 已解析权限投影)。两条车道同源:交互面 replEntry / print 面 printModeEngine 各跑一遍同一个 resolver,产物经同一个 buildWireSettingsStamp 投影。开放集(子键不可枚举)⇒ unregisteredRequestKeys 放行本车道 settings 的**表外**子键,但表内异车道子键仍点名' },
27
35
  // ── repo 场景五键([C135] 请托,2026-08-08):SDK TaskRequest 成文 passthrough 键(types doc
28
36
  // 「scenario-specific fields like code-review's repo/council/debate/rounds ride here」),server
29
37
  // resolve-spec 顶层直读(council/debate 按 `=== true`,rounds 进 clamp [1,3])。lanes 与
@@ -54,7 +62,6 @@ export const REQUEST_FIELD_MATRIX = [
54
62
  { field: 'resumeAt/rewindFiles/rewindFilesTo', lanes: ['interactive'], live: false, why: 'E18 `/rewind` 是交互命令' },
55
63
  { field: 'clientContext', lanes: ['interactive'], live: false, why: 'IANA 时区 + 可选邮箱 → core 本地化 `# Environment` 的 today;print 同样跑在用户机器上,缺席让引擎误标 (UTC)', gap: true },
56
64
  { field: 'scratchpadDir', lanes: ['interactive'], live: true, why: '[816]③ per-session 暂存目录;print 也有 session,缺席让 `-p` 的工具写不进 exemptDir', gap: true },
57
- { field: 'settings.<resolved>', lanes: ['interactive'], live: true, why: 'settings resolver 的 effective 快照(hooks/env/model + 已解析权限);print 只带 webSearch+hooks 两键', gap: true },
58
65
  ];
59
66
  /** live 兜底层(`toLiveRequest`)追加的字段 —— 两条车道**都**经过,故不进上表。 */
60
67
  export const LIVE_DEFAULT_FIELDS = [
@@ -70,6 +77,55 @@ export const LIVE_DEFAULT_FIELDS = [
70
77
  'sessionId(三态解析)',
71
78
  ];
72
79
  const laneHas = (field, lane) => REQUEST_FIELD_MATRIX.find(f => f.field === field)?.lanes.includes(lane) ?? false;
80
+ /**
81
+ * 本车道**不拥有**的具名 settings 子键(= 表里有 `settings.<sub>` 行、而该行的 lanes 不含本车道)。
82
+ *
83
+ * 为什么要有(#292 P1 codex 对抗复审 [medium] 采纳):`settings.<resolved>` 是**开放集 spread**
84
+ * —— 快照里的子键原样就是 wire 键。所以一旦某条车道能摊开快照,它就能借快照绕过那条车道
85
+ * **专属**子键的 stamp 门:`resolved:{ultracode:true}` 在 print 车道会真的生成
86
+ * `settings.ultracode:true`,而表里那一行写的是 interactive 独有。更坏的是它**不会被点名** ——
87
+ * 修前更坏的是它**不会被点名**(开放集口径曾对整只 `settings` 放行);现在 `unregisteredRequestKeys`
88
+ * 对「表内异车道子键」照旧点名,两道门配对成立 —— 但构造器这一道仍不可省:端在构造之后还能塞键,
89
+ * 而构造器这一道管的是「本包自己产的那份不许含异车道键」。
90
+ * 「表说了算,不是调用方说了算」要成立,就必须对**两条通道**都成立:具名通道过 `on()`,
91
+ * 开放集通道过本函数。今天的端(cli `toWireSettings` 是白名单投影)不产 ultracode,所以这是
92
+ * **潜在**旁路而不是现网缺陷 —— 但下一条车道专属子键落表时,它就是现网缺陷。
93
+ *
94
+ * 🔴 只剥「表里点名了、且不属于本车道」的键:表外子键(快照自己的 permissions/env/model/…)
95
+ * 原样摊开(那是开放集的全部意义),`outputStyle` 无车道行(live 兜底层的位)故不受影响。
96
+ */
97
+ const foreignSettingsSubKeys = (lane) => REQUEST_FIELD_MATRIX.filter(f => f.field.startsWith('settings.') && f.field !== 'settings.<resolved>' && !f.lanes.includes(lane)).map(f => f.field.slice('settings.'.length));
98
+ /**
99
+ * 快照 → 摊开前先剥掉本车道不拥有的具名子键(见 {@link foreignSettingsSubKeys})。
100
+ *
101
+ * 🔴 非对象一律降空对象(#292 P1 codex 复审第二轮 [medium] 采纳):型面写的是
102
+ * `Record<string, unknown> | undefined`,但本包是**发出去的 npm 公开面** —— JS 调用方、版本偏斜的
103
+ * 宿主、以及「端把 null 当空快照传」的老写法都到得了这里。修前那一版只判 `undefined`,`null` 会
104
+ * 走到 `Object.entries(null)` 当场 TypeError ⇒ headless 请求在发出**之前**就崩,而交互车道不崩 =
105
+ * 新造一条两车道差异。旧式 `?? {}` 对 null 是容忍的,这里把容忍面扩到「一切非对象」(降空 =
106
+ * 不 stamp = wire 上最小的诚实形;绝不把一个字符串 spread 成 `{0:'a',1:'b'}` 那种垃圾键)。
107
+ */
108
+ const resolvedForLane = (resolved, lane) => {
109
+ if (resolved === null || resolved === undefined || typeof resolved !== 'object')
110
+ return {};
111
+ if (Array.isArray(resolved))
112
+ return {}; // 数组也是 object,但 spread 出来是索引键,同属垃圾形
113
+ const foreign = foreignSettingsSubKeys(lane);
114
+ const out = {};
115
+ for (const [k, v] of Object.entries(resolved)) {
116
+ if (foreign.includes(k))
117
+ continue;
118
+ // 🔴 函数值一律不进(#292 P1 codex 复审第三轮 [medium] 采纳):JSON wire 上没有函数,而一个
119
+ // **自有 `toJSON`** 会在序列化那一刻整只改写 `settings` 的字节 —— 实测
120
+ // `resolved={permissions:…, toJSON(){return {ultracode:true}}}` 能让 `JSON.stringify(req)` 出
121
+ // `settings:{ultracode:true}`,把上面那道车道剥离(以及一切按对象查的判据)整体绕过。
122
+ // 「真上 wire 的那份」= 序列化后的字节,所以过滤必须发生在**值**这一层,而不是只看键名。
123
+ if (typeof v === 'function')
124
+ continue;
125
+ out[k] = v;
126
+ }
127
+ return out;
128
+ };
73
129
  /**
74
130
  * 合一后的请求构造器 —— **按车道出两形**,字段集差异全部由 `REQUEST_FIELD_MATRIX` 决定。
75
131
  *
@@ -93,9 +149,13 @@ export function buildTaskRequest(input, lane) {
93
149
  };
94
150
  // settings 四源合并(cli 原式:任一存在则 stamp,全缺则整个 `settings` 键都不出现)。
95
151
  // ultracode 只在交互车道进表 ⇒ print 传了也不 stamp(表说了算,不是调用方说了算)。
152
+ // resolved 两车道都进表(#292 P1)——它是**开放集 spread**(子键即 wire 键),所以走
153
+ // `laneHas + live` 而不是 `on()`:`on()` 判的是「这个键值非空」,而这里要判的是「这份快照要不要
154
+ // 摊开」。合并序恒是 resolved 先、具名子键后 —— 具名通道(webSearch/hooks/outputStyle)各有自己
155
+ // 的门,同名时必须赢过快照里那份(见 cli settingsRulesWire 的 hooks 剥离注)。
96
156
  const s = input.settings ?? {};
97
157
  const settingsOut = {
98
- ...(laneHas('settings.<resolved>', lane) && live ? (s.resolved ?? {}) : {}),
158
+ ...(laneHas('settings.<resolved>', lane) && live ? resolvedForLane(s.resolved, lane) : {}),
99
159
  ...(on('settings.webSearch', s.webSearch) ? { webSearch: s.webSearch } : {}),
100
160
  ...(on('settings.ultracode', s.ultracode) ? { ultracode: s.ultracode } : {}),
101
161
  ...(on('settings.hooks', s.hooks) ? { hooks: s.hooks } : {}),
@@ -226,8 +286,16 @@ export function applyLiveRequestDefaults(req, host) {
226
286
  *
227
287
  * 判据口径(三类不点名):
228
288
  * ① 车道表里的字段(`lanes` 含本车道);② `LIVE_DEFAULT_FIELDS` —— live 兜底层追加、两条车道
229
- * 都经过,故本就不进车道表;③ `settings` 的子键按表认(`settings.<sub>`),但**交互车道**的
230
- * `settings.<resolved>` resolver effective 快照 = **开放集**,子键不可枚举 ⇒ 整体放行。
289
+ * 都经过,故本就不进车道表;③ `settings` 的子键按表认(`settings.<sub>`)—— 登记了
290
+ * `settings.<resolved>` 的车道(#292 P1 = **两条**)那份 resolver effective 快照是**开放集**,
291
+ * 所以**表外**子键(permissions/env/model/… 不可枚举)放行。
292
+ *
293
+ * 🔴 开放集**不是**整只 `settings` 免检(#292 P1 codex 复审第二轮 [medium] 采纳):表里点名了、
294
+ * 而 lanes 不含本车道的子键(如 print 车道的 `settings.ultracode`)照旧**点名** —— 那些键是
295
+ * **可枚举的已知量**,放过它们等于让「ultracode 仅 interactive」这条登记在真 wire 面失效。
296
+ * `buildTaskRequest` 内的 `resolvedForLane` 只管住构造器自己那一份;端在构造之后仍能往请求体上
297
+ * 塞键(print lane 修前正是「自己另拼一份」),而本函数的整个存在理由就是替**真上 wire 的那份**
298
+ * 过表。两道门缺一不可。
231
299
  *
232
300
  * 🔴 它**不判**「该不该有」——只判「有没有登记」。补齐一条真实差异要改表(带理由),
233
301
  * 而不是绕过本函数:改表这个动作本身就会在 diff 里显形,这正是本判据存在的意义。
@@ -248,17 +316,38 @@ export function unregisteredRequestKeys(req, lane) {
248
316
  .filter(f => f.field.startsWith('settings.') && f.field !== 'settings.<resolved>')
249
317
  .map(f => f.field.slice('settings.'.length)));
250
318
  const settingsIsOpenSet = laneRows.some(f => f.field === 'settings.<resolved>');
319
+ /** 表里点名了、但 lanes 不含本车道的具名子键 —— 开放集车道也照旧点名(见头注第二条红线)。 */
320
+ const foreignSettingsKeys = new Set(foreignSettingsSubKeys(lane));
251
321
  const out = [];
252
322
  for (const key of Object.keys(req)) {
253
323
  if (key === 'settings') {
254
- if (settingsIsOpenSet)
255
- continue;
256
324
  const settings = req.settings;
257
325
  if (settings === null || typeof settings !== 'object')
258
326
  continue;
259
- for (const sub of Object.keys(settings)) {
327
+ // 🔴 自报会改写自己字节的 `settings` 一律点名(#292 P1 复审第四轮的可采部分):`JSON.stringify`
328
+ // 找 `toJSON` 走的是**属性查找**(原型链上的、非枚举的都算),而下面的逐键遍历只看**可枚举自有**
329
+ // 键 ⇒ 一个挂在原型上的 `toJSON` 能让本函数看到一份对象、wire 上却是另一份字节。本函数的承诺
330
+ // 是「替真上 wire 的那份过表」,所以这种「对象与字节不一致」的形必须先被点名,而不是默默通过。
331
+ // (⚠️ 递归深净化整份请求体不在本层的职责里 —— 那是包级 wire 载荷 JSON-safe 规范化的设计题,
332
+ // 今天对 agents/hooks/attachments 等一切对象位一样成立,已登记为独立工单;端是同进程受信调用方,
333
+ // 这里做的是「判据不许被骗」,不是「防御受信宿主」。)
334
+ if (typeof settings.toJSON === 'function')
335
+ out.push('settings.toJSON');
336
+ for (const [sub, value] of Object.entries(settings)) {
337
+ if (sub === 'toJSON')
338
+ continue; // 上面那条属性查找已点过名(可枚举自有形不重复报)
339
+ // 🔴 函数值恒点名(复审第三轮 [medium]):wire 上没有函数,而自有 `toJSON` 会在序列化时
340
+ // 整只改写 `settings` 的字节 ⇒ 一切「按对象查键」的判据(含本函数)都会被它骗过。所以它
341
+ // 先于任何登记判断被点名 —— 包括登记在册的子键名上挂了函数值的形。
342
+ if (typeof value === 'function') {
343
+ out.push(`settings.${sub}`);
344
+ continue;
345
+ }
260
346
  if (laneSettingsKeys.has(sub) || liveDefaults.has(`settings.${sub}`))
261
347
  continue;
348
+ // 开放集车道:表外子键放行(快照的键不可枚举),表内异车道子键仍点名。
349
+ if (settingsIsOpenSet && !foreignSettingsKeys.has(sub))
350
+ continue;
262
351
  out.push(`settings.${sub}`);
263
352
  }
264
353
  continue;
@@ -23,7 +23,7 @@
23
23
  | peer:wire 契约 | `@sema-agent/sdk` **>=6.17.2**(value-level,非 type-only) | `package.json` `peerDependencies` |
24
24
  | peer:会话词汇表 | `@sema-agent/agent-types` **>=0.2.0**(type-only,零运行时) | 同上 |
25
25
  | runtime dep | `diff` ^9.0.0(**唯一**一条;portability 门按**等值**钉死) | `package.json` `dependencies` |
26
- | 公开导出面 | **741** 个运行期符号(+ 37 个测试钩) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
26
+ | 公开导出面 | **748** 个运行期符号(+ 37 个测试钩) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
27
27
  | 常驻门 | 以 `scripts/gates-manifest.json` 的 `suites` 长度为准(**本档不抄这个数**) | `scripts/gates-manifest.json`;`npm test` 的名单等值门与它逐名对账 |
28
28
  | 沿革档 | 0.29.0 起建 `CHANGELOG.md`;更早批次记账在 `src/index.ts` 文件头 + `docs/REFACTOR-LEDGER.md` | — |
29
29
 
@@ -99,7 +99,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
99
99
 
100
100
  ## §2 公共导出面地图(按域)
101
101
 
102
- > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**741** 项)。
102
+ > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**748** 项)。
103
103
  > 本节**不逐名抄**,只给「域 → 承重导出 → 用途 → 实现锚」。承重导出 = 一个端为了让这个域干活
104
104
  > **必须**直接调到的那几个符号;其余是它们的类型、变体与辅助位。
105
105
  > 单一入口:`import { … } from '@sema-agent/client-core'`(`exports` 只有 `.` 一个;
@@ -109,7 +109,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
109
109
 
110
110
  `public-export-baseline.json` 由 **`dist/index.js` 的运行期导出**生成(生成口径自述见
111
111
  `scripts/run-client-core-typeshape-test.mjs`,双向精确集合门在 `scripts/run-public-surface-test.mjs`)。
112
- 实测:741 项 **100% 是运行期导出,零 type-only**。
112
+ 实测:748 项 **100% 是运行期导出,零 type-only**。
113
113
 
114
114
  **推论(端必须知道)**:
115
115
  - barrel 导出的**类型**面比 707 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
@@ -118,16 +118,16 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
118
118
  端依赖这些类型是合法的,但**不要**拿基线 diff 当"类型面没变"的证据。
119
119
  - `src/agentSession/contract.ts` 对基线贡献 **0** 项(纯类型模块,`export *` 在 dist 里是空转发)。
120
120
 
121
- 741 项的内部构成(帮助端估读表大小):**218** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
121
+ 748 项的内部构成(帮助端估读表大小):**221** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
122
122
  (矩阵、键集、env 名、锚串)而非可调用物;**4** 项是 PascalCase 运行期值
123
123
  (`ControlRouter` / `ControlSafetyError` / `HitlBridge` / `HitlSafetyError`);
124
124
  **39** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6;其中 `engineNamespaceKeyFor` 是命名巧合 —— 参数是 baseUrl 不是 sessionKey,见域 14)。
125
125
 
126
- ### 2b. 域图(16 域,逐域计数之和 = 741)
126
+ ### 2b. 域图(16 域,逐域计数之和 = 748)
127
127
 
128
128
  | # | 域 | 名数 | 承重导出 | 用途 | 实现锚 |
129
129
  |---|---|---|---|---|---|
130
- | 1 | **适配内核(下行主链)** | 29 | `adapt` · `createWireToCcAdapter` · `runStream` · `eventToSdkMessage` · `terminalToSdkResult` · `turnUsageToModelUsage` · `isRunStreamActive` · `ADAPTER_DIVERGENCES` | 引擎 SSE `AgentEvent` → 端要渲的**双面输出**:transcript(`SDKMessage`)+ chrome(瞬态 `ChromeEvent`)。**本包存在的理由** | `src/adapt.ts`、`src/adapt/{arms,wireShapes,panelTasks}.ts`(经 `adapt.ts` 再导出)、`src/adapter/runStream.ts`、`src/adapter/downstream/*`、`src/adapter/types.ts` |
130
+ | 1 | **适配内核(下行主链)** | 33 | `adapt` · `createWireToCcAdapter` · `runStream` · `eventToSdkMessage` · `terminalToSdkResult` · `turnUsageToModelUsage` · `isRunStreamActive` · `ADAPTER_DIVERGENCES` | 引擎 SSE `AgentEvent` → 端要渲的**双面输出**:transcript(`SDKMessage`)+ chrome(瞬态 `ChromeEvent`)。**本包存在的理由** | `src/adapt.ts`、`src/adapt/{arms,wireShapes,panelTasks}.ts`(经 `adapt.ts` 再导出)、`src/adapter/runStream.ts`、`src/adapter/downstream/*`、`src/adapter/types.ts` |
131
131
  | 2 | **seam 公共契约** | 2(其余为 type-only) | `CHROME_ARMS` · `deriveTranscriptId` | 公共词汇 + **id 确定性不变量**(同一条流重放 ⇒ 同一串 id)。`CHROME_ARMS` = 端「我要消费哪些 chrome 臂」的对照清单 | `src/seam.ts` |
132
132
  | 3 | **HITL 决断卡链**(§4/§5 主战场) | 119 | `makeHitlCanUseTool` · `HitlBridge` · `findPendingForTask` · `HitlSafetyError` · `bridgeAskUserQuestionGates` · `surfaceToolApprovalFrameAndRespond` / `surfaceFsApprovalAndDecide` · `readToolApprovalRespondAck` · `installApprovalCardPort(For)` · `installHitlHostSurface(For)` · `armPlanReviewApproval` · `reopenPlanReviewCard` · `decidePlanReview` · `startApprovalsFeed` · `pendingRowIsOwnedByThisSession` · `approvalCallKey`/`liveFrameCallKey`/`planReviewQuestionId` · `registerArmedGateFor`/`wasGateArmedFor`/`clearArmedGateFor` · `waitForGateArmed(For)`/`onGateArmed(For)`/`gateArmedWaitMs`(#244 F1 呈现回执事件源) · `planReviewArmedKey(For)`/`notePlanReviewAnswered(For)`/`notePlanReviewAnsweredIfDecisive(For)`(A-024.4 plan 呈现分代) · `toolEndOutputText` · `isAskTool` · `waitForParkRowBirth` · `classifyAskParkRows` / `askParkRowArm` / `classifyAskParkChainFailure` · `readDecisionNoteAudit` / `decisionNoteAuditLine` · `resumeRunningOptions` / `resumeChoiceFromLabels` · `persistedRulesLaneAvailable`/`persistedRulesGovernanceAvailable` · `classifyRulesFailure` · `listAllPersistedRules` · `classifySkippedReason` · `readRulePersistOutcome`(#244 F2:persist-ack 读口与 `readToolApprovalRespondAck` 合成一处) · `parseLocalAllowRule`(durable 腿本地落规则窄化骨架,谓词经 `LocalAllowRuleDeps` 注入) | suspended→decide→resume 环。🔴 **D-1 两元组 verbatim 回显**是字节级断言的安全不变量,端**不许重实现它的任何一段**。🔴 键空间边界(web [C1] d3 拦截):`gateIdentity` 四常量两函数只覆盖 HITL questionId/callKey 空间;seat 的 `TOOL_PERMISSION_REQUEST_ID_DOMAINS`(`plan:` 等)是另一键空间,**两者绝不合并**(合并=座位校验器静默拒全部 plan-review 卡) | `src/hitl/hitlBridge.ts`、`toolApprovalWire.ts`、`askGateWire.ts`、`planReviewWire.ts`、`hitlHostSurface.ts`、`gateIdentity.ts`、`armedGateRegistry.ts`、`parkOwnership.ts`、`parkResolver.ts`、`approvalsFeed.ts`、`frameRouter.ts`(**只挑名导出** `toolEndOutputText`/`ENGINE_ABORT_TOOL_RESULT`/`isAskTool`/`HITL_REJECT_MESSAGE`)、`parkRowBirthWait.ts`、`approvalDecisionNoteAudit.ts`、`askParkRowRouting.ts`、`resumeRunningCard.ts`(#265 上收的判定层)、`persistedRulesWire.ts`、`localAllowRule.ts`(#244 F2 规则侧) |
133
133
  | 4 | **子代 wire + 面板侧信道台账** | 82 | `tailEngineSubagent` · `installSubagentActivitySink` · `installSubagentTailMetaSink`(#280 件2:tail meta 帧发布口,`contentFrames` 判别位载体)· `stopEngineTask` + `classifyTaskStopConflict` · `fetchEngineSubagentReport` · `steerEngineSubagent`(0.32.0 未发布 #280 件A:additive 第三参 `childTaskId` —— 端有行上下文时**应当**传,传了就走「台账优先 / 缺席即诚实缺席 + `noteBgOwnerAbsence` 留痕」的 Q3 口径,与 tail·taskOutput·subagentOutput 三腿同姿势、与孪生 resume 腿共用同一个 `resolveOwnerRunId` 判据;**不传**则逐字维持旧行为=回落在飞 run)· `resumeSettledSubagent` + `resolveSubagentResumeContext` + `resolveOwnerRunId` + `classifySubagentResumeFailure` + `subagentResumeAvailable`(#242 批 2 A-028.7:resume 判定半场上收,与 steer 孪生同居;取址三态 = 台账有行用行值 / 指名了行但台账缺席则**诚实缺席绝不回落在飞 run** / 没指名行才回落。出路文案归端)· `recordSubagentOwnerFromProgress` + `getBgParentRunOwner`(A-028.6:「子代 → 宿主 run」**单表**,宿主 run 必须由持 stream-local 值的调用方显式传入,包内绝不从 `activeEngineRunId()` 推断)· `noteBgOwnerAbsence`(#242 批 3 [4000] Q3=B:tail/taskOutput·taskStop/subagentOutput 三腿台账缺席即诚实缺席**绝不回落在飞 run**,缺席 warn 留痕每 (腿,taskId) 一条)· `clearBgTerminalFacts`(#242 批 3 扫码修:复活=新周期,旧周期终态事实作废——fleetLedger 复活两腿按尾段清账,factsAccepted 方向核不再拿上周期终态当先例)· `auditRetainWithoutWake`([4000] Q5:引擎宣示 `subagentResume` + 本端在付 `retainSubagentSessions` + 端未实现 `wakeSubagent` ⇒ 响亮一条;`CLIENT_VERBS.wakeSubagent` 维持 fail-soft)· `subscribeSubagentContent` · `subscribeEngineAgentPanel` · `publishQuestionFrame` / `respondToQuestion` | 驱动与观测委派子代;经 module 级台账喂活体 agent/task 面板。全部**能力位 gate**(§5b) | `src/subagent/*.ts`、`src/subagentContentStore.ts`、`src/engineAgentPanelStore.ts`、`src/engineInlineTaskStats.ts`、`src/engineToolLabelStore.ts`、`src/liveQuestionStore.ts` |
@@ -141,8 +141,8 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
141
141
  | 12 | **workflow 与后台工作视图** | 19 | `projectWorkflowRun` · `createLiveWorkflowSource` · `ensureWorkflowActivityLedger` · `readWorkflowActivityLedger` · `stopWorkflowActivityLedger` · `resetWorkflowActivityLedgers` · `createBackgroundView` · `projectBackgroundView` · `recordWorkflowAgentTaskId` · `agentDisplayStatus` | 活过一个 turn 的长任务读面:workflow run + 跨 session 后台任务归一表(`assistant.tasks` 与 fleet SSE **两源独立降级**) | `src/workflow.ts`、`src/workflowClient.ts`、`src/workflowMonitor.ts`、`src/agentSession/backgroundView.ts`(+ 纯类型 `src/agentSession/contract.ts`) |
142
142
  | 13 | **座位 IPC 契约** | 33 | `LOCAL_SESSIONS_SPEC` · `SEAT_METHOD_NAMES` · `SEAT_EVENT_TYPES` · `isLocalSessionEvent` · `isToolPermissionRequest` · `toolPermissionRequestId` · `SEAT_VALIDATOR_KEY_COVERAGE` | desktop↔web 座位 IPC 契约的**单一真源**(此前两边各一份、名字零重合 ⇒ 编译器永远不会告诉你它们漂了)。🔴 加 verb 忘了加 `LOCAL_SESSIONS_SPEC` **不报错**:preload 不注册 channel、渲染端读到 `undefined` | `src/seatContract.ts`(**零 import**,纯类型 + 常量 + 纯谓词) |
143
143
  | 14 | **宿主端口与会话槽** | 26 | `installHost` · `installHostFor` · `hostPortMisses(For)` · `DEFAULT_SESSION_KEY` · `hostEnv` · `unrefTimer` · `parseLocaleTag` / `pickUiLanguage`(#244 F4 族D A-028.20:locale tag 手术单源 + UI 语言判定;与 `resolveRegionHint` 双出口成文 —— 语言偏好域 en/zh ≠ 地址可达域 cn/intl/unknown,`zh-Hant` 前者 zh 后者 intl 是设计)· `engineNamespaceKeyFor` / `mergeSessionMapRecord` / `mergeEngineEntry`(A-028.12:会话 id 映射单一键形 + merge 判定;存储经 `SessionMapStorePort` 归端 —— cli 文件锁/原子写,web localStorage)| 进程/端级装配层(settings/fs/queue/timers/session/log/probe),与 per-turn 的 `AdapterContext` **分层**。头注的判定规则:**这个能力每 turn 都会变吗?** 会 ⇒ `ctx`;不会 ⇒ `installHost` | `src/host.ts`、`src/hostEnv.ts`、`src/sessionSlot.ts`、`src/unrefTimer.ts`、`src/env/{localeGeo,localeTag,uiLanguage}.ts`、`src/sessionMap.ts` |
144
- | 15 | **控制面与传输** | 68 | `ControlRouter`(+ `ControlSafetyError`)· `makeEngineWireClient` + `resolveWireAuth` · `installEngineWireTarget` · `diagnoseSseIdleTear` / `isSseIdleError` · `attemptActiveRunSelfHeal` + `activeRunBusySignal` + `activeRunSelfHealRow` / `activeRunBusyHeadlessRow` · `kickEngineCapsProbe` / `engineCapTrue` · `mapBrainStatusToRetry` · `waitForClaimRelease` + `CLAIM_RELEASED_STATES` / `CLAIM_HELD_STATES` · `atMostOnceFailureClass` / `readSteerDelivery` · `INTERACTIVE_WAY_OUT`(默认出路串单源)· `normalizeWirePrincipal`(A-028.10:principal 在场性 trim 原语 —— 全空白=缺席不发头,engineWireTarget 两臂/makeEngineWireClient/壳 livePrincipal 同尺)· `classifyTurnWireError` / `isWireTransportError` / `isPreStreamDrainingReject` / `isResumeAtRejection` / `drainingRetryDelayMs` / `scenarioDenyFromError` + `WIRE_NETWORK_ERROR_PATTERN`(A-028.11/.13:turn 错误分型判定半场,人话文案与渲染归端) | 上行通道的**监管**半场(submit / steer / kill / 队列命令定序)+ 传输构造、caps 探测、SSE 断流分诊、**409 active-run 自愈** | `src/controlRouter.ts`、`steering.ts`、`sseIdleTriage.ts`、`retryStatus.ts`、`diagnostics.ts`、`engineWireSdk.ts`、`engineWireTarget.ts`、`src/principalWire.ts`、`src/wireErrorTriage.ts`、`engineSessionParam.ts`、`engineCapsCache.ts`、`liveInitToolFace.ts`、`adapter/activeRunSelfHeal.ts` |
145
- | 16 | **引擎词汇表与包自检** | 39 | `CONFIG_REFUSAL_CODES` / `isConfigRefusalCode` · `STOP_CONFLICT_CODES` · `isInterruptedToolEndCode` · `isRewindFamilyCode` · `CLIENT_VERBS` · `compensationSplitViolations` | 三端分臂共用的**去字面化** `errorCode` 词表(病根正是三端各抄一份字面);编译期 verb 闭合门;搬迁补偿登记表 | `src/engineErrorCodes.ts`(32 项;A-028.11/.13 补 `DRAINING_ERROR_CODE`/`SCENARIO_NOT_ALLOWED_ERROR_CODE`/`RESUME_AT_ERROR_CODE_PREFIX`)、`src/classifierVerdictWire.ts`、`src/compensations.ts`、`src/clientSlice.ts` |
144
+ | 15 | **控制面与传输** | 69 | `ControlRouter`(+ `ControlSafetyError`)· `makeEngineWireClient` + `resolveWireAuth` · `installEngineWireTarget` · `diagnoseSseIdleTear` / `isSseIdleError` · `attemptActiveRunSelfHeal` + `activeRunBusySignal` + `activeRunSelfHealRow` / `activeRunBusyHeadlessRow` · `kickEngineCapsProbe` / `engineCapTrue` · `mapBrainStatusToRetry` · `waitForClaimRelease` + `CLAIM_RELEASED_STATES` / `CLAIM_HELD_STATES` · `atMostOnceFailureClass` / `readSteerDelivery` · `INTERACTIVE_WAY_OUT`(默认出路串单源)· `normalizeWirePrincipal`(A-028.10:principal 在场性 trim 原语 —— 全空白=缺席不发头,engineWireTarget 两臂/makeEngineWireClient/壳 livePrincipal 同尺)· `classifyTurnWireError` / `isWireTransportError` / `isPreStreamDrainingReject` / `isResumeAtRejection` / `drainingRetryDelayMs` / `scenarioDenyFromError` + `WIRE_NETWORK_ERROR_PATTERN`(A-028.11/.13:turn 错误分型判定半场,人话文案与渲染归端) | 上行通道的**监管**半场(submit / steer / kill / 队列命令定序)+ 传输构造、caps 探测、SSE 断流分诊、**409 active-run 自愈** | `src/controlRouter.ts`、`steering.ts`、`sseIdleTriage.ts`、`retryStatus.ts`、`diagnostics.ts`、`engineWireSdk.ts`、`engineWireTarget.ts`、`src/principalWire.ts`、`src/wireErrorTriage.ts`、`engineSessionParam.ts`、`engineCapsCache.ts`、`liveInitToolFace.ts`、`adapter/activeRunSelfHeal.ts` |
145
+ | 16 | **引擎词汇表与包自检** | 41 | `CONFIG_REFUSAL_CODES` / `isConfigRefusalCode` · `STOP_CONFLICT_CODES` · `isInterruptedToolEndCode` · `isRewindFamilyCode` · `CLIENT_VERBS` · `compensationSplitViolations` | 三端分臂共用的**去字面化** `errorCode` 词表(病根正是三端各抄一份字面);编译期 verb 闭合门;搬迁补偿登记表 | `src/engineErrorCodes.ts`(34 项;A-028.11/.13 补 `DRAINING_ERROR_CODE`/`SCENARIO_NOT_ALLOWED_ERROR_CODE`/`RESUME_AT_ERROR_CODE_PREFIX`)、`src/classifierVerdictWire.ts`、`src/compensations.ts`、`src/clientSlice.ts` |
146
146
 
147
147
  🔴 **`engineErrorCodes` 的开集纪律**(该文件头注逐字):这些 `ReadonlySet` / 前缀谓词一律是**识别表**,
148
148
  回答的是「我认不认得这个码」,**绝不是**「合法码只有这些」。消费点 `switch` **必须留 `default`**,
@@ -663,6 +663,7 @@ ledger **自己不开流**(帧体归库、连接归端),无从校验;而本包
663
663
  |---|---|---|---|
664
664
  | `server_fail_closed` | meta `bgNotifyFailClosed` ∧ 本连接 session-bound | **会话级证明**(带上述流前提) | 最强:server 侧注入路已 fail-closed,到达即**这条流的**会话。⚠️ 这一臂**完全不读会话端口**,信的就是「这条流替谁开的」;前提破了它不会报错,而它正是**生产 meta 组合**下命中的那一格 |
665
665
  | `own_root` | 通知 `rootSessionId` === `engineSessionParam()`(固定点语义) | **会话级证明**(带上述流前提) | 判据读的是 **`DEFAULT_SESSION_KEY` 槽**的 `SessionPort`,**不是**本 ledger 的 `sessionKey` 槽 —— 单会话宿主两者恒同 |
666
+ | `session_anchor_untrusted` | keyed ledger(非默认 `sessionKey`)上由**会话级臂**放行 | **非证据**(封顶词) | 🔴 会话级臂放行了,但本 ledger 无法信任那个会话锚(开流参数只认默认槽,前提无法成立也无法校验)—— 不冒充 `server_fail_closed`/`own_root`,也不谎标进程成员(帧的 `parentTaskId` 可能是 foreign)。端按非证据档自裁;P-31 正位解落地后此封顶撤销。单会话宿主(默认槽)结构性不出现此词 |
666
667
  | `own_parent` | `parentTaskId`(spawn 该子代的 leader run)∈ own-run 台账 | **进程级成员证明** | 🔴 只证明「本进程曾亲手驱动过这条 run」,**不区分会话代际** —— own-run 台账是进程级 `Set`、按会话零分区,`/clear` 或换会话后**旧会话**的 run 仍命中(= 在册局限 **P-13** 在通知面的同一张脸)。**不得**当作「属于当前会话」;要按会话归属做事,按 P-13 的出路自注入会话粒度判据,或只认上面两格 |
667
668
  | `absent_parent` | 通知**没带** `parentTaskId` | **非证据** | 🔴 absent-放行姿势(老引擎/老帧形不带该键,fail-closed 会把自家通知整批吞掉)。端要拿归属做有副作用的事(落库/跨会话搬运/翻别人的卡)时,这一格应自裁为「未证明」 |
668
669
 
@@ -723,7 +724,7 @@ CHANGELOG 0.29.0「已知局限」段与相应 JSDoc 都有成文。**别在读
723
724
 
724
725
  | ID | 级别 | 一句话现状 | 实现锚 | 端**今天**必须怎么办 |
725
726
  |---|---|---|---|---|
726
- | **P-15** | med | `REQUEST_FIELD_MATRIX` 有 **6 个字段登记为 `gap: true`**(表内 `gap:true` 的定义逐字 = 「这条差异**没有正当理由,是漏的**」),全部是 **print/headless 车道缺席**:`settings.ultracode` · `reasoningEffort` · `model` · `clientContext` · `scratchpadDir` · `settings.<resolved>`。表内点名的后果:`clientContext` 缺席 ⇒ **引擎误标 (UTC)**;`scratchpadDir` 缺席 ⇒ **`-p` 的工具写不进 exemptDir** | `src/request/taskRequest.ts`(`REQUEST_FIELD_MATRIX` 的 `gap` 列) | headless 车道上这六项**确实不上 wire**。端不要在 print 车道假设它们在场;补齐是**行为改动**,要单独一条测试,不许端侧偷加 |
727
+ | **P-15** | med(6 → **5**,#292 P1 结清一条) | `REQUEST_FIELD_MATRIX` 有 **5 个字段登记为 `gap: true`**(表内 `gap:true` 的定义逐字 = 「这条差异**没有正当理由,是漏的**」),全部是 **print/headless 车道缺席**:`settings.ultracode` · `reasoningEffort` · `model` · `clientContext` · `scratchpadDir`。表内点名的后果:`clientContext` 缺席 ⇒ **引擎误标 (UTC)**;`scratchpadDir` 缺席 ⇒ **`-p` 的工具写不进 exemptDir**。**已结清**:`settings.<resolved>`(0.34.0 / #292 P1 —— 版本号与 CHANGELOG 段**同一个**,codex 复审 [low] 抓的正是两处不一致)—— 它的缺席是**权限方向**的(`-p` 上用户 settings 的 `permissions.deny/ask` 整体不被引擎求值,cli [4208] 实测),现两车道都 stamp | `src/request/taskRequest.ts`(`REQUEST_FIELD_MATRIX` 的 `gap` 列) | headless 车道上这五项**确实不上 wire**。端不要在 print 车道假设它们在场;补齐是**行为改动**,要单独一条测试,不许端侧偷加。`settings.<resolved>` 反过来:print 车道现在**会**摊开 resolver 快照 ⇒ 端必须把值放进 `input.settings.resolved`(端不给值仍是零 stamp,不会凭空出现),且该车道 `settings` 子键走**开放集**口径:`unregisteredRequestKeys` 只放行**表外**动态子键(快照自己的 permissions/env/model/… 不可枚举),**表内但不属于本车道**的子键(今天 = print 的 `settings.ultracode`)仍会被点名 —— 端不许把它白名单化,那条红是真的;另:`settings` 子键值为**函数**(如自有 `toJSON`,能在序列化时整只改写字节)恒被点名且构造器不 stamp |
727
728
  | **P-15b** | 🔴 权限方向 | `REQUEST_FIELD_MATRIX` 的 stamp 门对**未登记键静默丢弃** —— 表里点名的真实危险形逐字:**「用户显式排除的工具被静默放回」(权限方向回归,类型层不报)**。`excludeTools` 是真 wire 键、早在 seatContract 的 `START_SESSION_OPTION_KEYS` 里,却曾长期在矩阵外;**今天只有 desktop 在发它** | `src/request/taskRequest.ts`(`excludeTools` 行)、`src/seatContract.ts`(`START_SESSION_OPTION_KEYS`) | 端自拼 taskReq 的键**必须**先进矩阵;上 wire 前跑 `unregisteredRequestKeys(req, lane)` 并**当红对待**,别当 lint |
728
729
  | **P-16** | 提货单 S 组(**BREAKING**,P2 裁决**未填**) | 三条签名级 BREAKING 在册未决,**约束三端的 port 实现**:REF-CC-133(`SettingsPort.getSettingsForSource` 返回裸 `unknown`)· REF-CC-134(`ModelFacingTaskOutput`)· REF-CC-135(`errCodes`,**且是行为改动**:脏形不再原样透传) | `docs/REFACTOR-LEDGER.md` REF-CC-133/134/135 | 端实现 `SettingsPort` 时不要依赖当前的宽返回型;`errCodes` 的脏形透传行为**会变** |
729
730
  | **P-17** | 在册 | 两条 wave2 残余:REF-CC-059(`NeutralDelta` 命名已落,**泄漏门没建**)· REF-CC-063(自锚已修,`gate:line-anchor` 口未做) | `docs/refactor/WAVE2-RESIDUALS.md` | 端无直接动作;别把这两条当已闭合 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Blackboard [1832] design axioms; [1651]/[1652]/[1653] signed seam design. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",