@sema-agent/client-core 0.41.0 → 0.43.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.
@@ -81,7 +81,7 @@ import { DEFAULT_DENY_REASON, DecideTransportRetryExhaustedError, HitlBridge, Hi
81
81
  import { hostLog } from '../host.js';
82
82
  import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
83
83
  import { readEngineActiveBgTasks } from '../fleet/fleetLedger.js';
84
- import { observeCancelByDeny, surfaceRememberNotApplied, surfaceEditNotForwarded } from './hitlHostSurface.js';
84
+ import { observeCancelByDeny, surfaceRememberNotApplied, surfaceEditNotForwarded, surfaceRuleArmNotSent, surfaceRuleArmRejected } from './hitlHostSurface.js';
85
85
  import { approvalCallKey, liveFrameCallKey } from './gateIdentity.js';
86
86
  /** fs 写权限 gate 判定:未来的一等 kind(tool_approval)或按 toolName(server 桥首批=fs 写三件,
87
87
  * [820] 表)。AskUserQuestion 永不进这里(ask 桥先判)。 */
@@ -269,10 +269,13 @@ parkGatedCallId) {
269
269
  // [C170] 答问②半场(0.29.0):durable 富行的两个展示键随卡透传 —— 修前这里是四位闭集,
270
270
  // server 7.16.0 起行上就有的 ruleSuggestions/governanceForced 在「行 → 卡」重铸处整段丢失
271
271
  // (feed 行原样透传零丢失,丢的只有这处)。governanceForced 条件 stamp 只认 === true(缺席
272
- // 纪律与活卡腿同款:缺席=无治理来源证据,绝不写 false);候选走 readRuleSuggestions 同一把
273
- // 合形窄化,落**只读键**(红线见 ApprovalCardRequest.ruleSuggestionsReadOnly 顶注:/decide
272
+ // 纪律与活卡腿同款:缺席=无治理来源证据,绝不写 false);候选走 readOfferSupply 同一把
273
+ // 合形窄化,落**只读键**(红线见 ApprovalCardRequest.ruleOffersReadOnly 顶注:/decide
274
274
  // 无规则位,落可兑付位=假 affordance)。
275
- const ruleSuggestionsReadOnly = readRuleSuggestions(pending.ruleSuggestions);
275
+ // #334(0.43.0):行上的活键换成 `ruleOffers`(server ≥7.46.0 `PendingCheckpoint.ruleOffers`);
276
+ // SDK 7.2.0 的 `PendingCheckpoint` 声明尚无此键 ⇒ **结构视图读**(与本函数下方 `riskDescriptor.probeCause`
277
+ // 同款姿势),旧键 `ruleSuggestions` 保读兼容(7.44 及更旧引擎仍在场),两键经同一把窄读器归一。
278
+ const ruleOffersReadOnly = readOfferSupply(pending.ruleOffers, pending.ruleSuggestions);
276
279
  // #280 件1(0.30.4):durable 行腿的探针因由载体 = `riskDescriptor.probeCause`(server 对
277
280
  // riskDescriptor 整体透传,与活卡帧同值)。SDK 6.17.2 的 riskDescriptor 声明尚无此键 ⇒ 结构
278
281
  // 视图读(与 caps 防御读同款姿势),类型半场候 SDK 班车;载体形不合 ⇒ 不铸键(内部结构不校,
@@ -290,14 +293,16 @@ parkGatedCallId) {
290
293
  callKey,
291
294
  ...(signal ? { signal } : {}),
292
295
  ...(pending.governanceForced === true ? { governanceForced: true } : {}),
293
- ...(ruleSuggestionsReadOnly !== undefined ? { ruleSuggestionsReadOnly } : {}),
296
+ ...(ruleOffersReadOnly !== undefined ? { ruleOffersReadOnly } : {}),
294
297
  ...(isWireRecordCarrier(durableProbeCause) ? { probeCause: durableProbeCause } : {}),
295
298
  });
296
299
  switch (card.kind) {
297
300
  case 'failed':
298
301
  return { kind: 'failed', gatedCallId, reason: card.reason };
299
302
  case 'aborted':
300
- // turn 被中断(Esc/Ctrl+C):cancel-by-deny(contract/04 §2.4)。REF-CC-023(2026-08-02):
303
+ // turn 被中断(Esc/Ctrl+C):把挂着的 ask 用一次 **TOOL 级 deny** 结算掉(server
304
+ // `ASSISTANT-WIRE-CONTRACT.md` §4a;0.42.0 撤稿 —— 原文写的是「cancel-by-deny(contract/04
305
+ // §2.4)」,两条前提均已作废,逐条见 `parkResolver.ts` 同臂的撤稿段)。REF-CC-023(2026-08-02):
301
306
  // 有界观察(observeCancelByDeny)取代裸 `.catch(()=>{})` 全吞 —— 与 askGateWire.ts 同款
302
307
  // reason 文案,同一次事故(件3,中断事故修复批 G,2026-07-15)的两条腿现在共用同一个观察器。
303
308
  observeCancelByDeny(bridge.decideTool({ decision: 'deny', reason: 'Interrupted by user' }, gatedCallId, undefined, pending), taskId);
@@ -385,7 +390,22 @@ export const TOOL_APPROVAL_FRAME_KEYS_MIRROR = [
385
390
  'governanceForced',
386
391
  // sdk 6.14.0 追平(#225 件1,client-core 0.26.0):server 7.12.0 起真发规则候选 —— 门
387
392
  // (run-approval-frame-keys-test.mjs)在 bump SDK 当天恰红抓获本键缺席,本次补齐。
393
+ // ⚠️ **退役键**(server 7.46.0 起同文件 0 命中):留在镜像里是因为 sdk 7.2.0 的运行期锚仍含它,
394
+ // 删了对账门的「SDK 锚的每个键本仓都有」当场红;本包对它只做归一读(见帧上同名键的 JSDoc)。
388
395
  'ruleSuggestions',
396
+ // #334/[5223](client-core 0.43.0):server 7.46.0 起把规则候选整体换成判别联合 `ruleOffers`
397
+ // (core 5.58.0 design/375;engine 7.46.0 fixture 的 `@sema-agent/server/dist/tool-approval.d.ts`
398
+ // 直证,同文件旧键 0 命中)。sdk 7.2.0 锚**尚无**本键 ⇒ 与 `requiresRealApproval`/窗三键同形=
399
+ // **领先**,进对账门 AHEAD_OF_ANCHOR 带退出条件登记。
400
+ 'ruleOffers',
401
+ // #341/[5214]③(client-core 0.43.0):server 7.46.0 起真发 `inputHasBidi`(E-14 Trojan Source 族;
402
+ // 7.44 fixture 同文件 0 命中)。同样**领先** sdk 7.2.0 锚 ⇒ AHEAD_OF_ANCHOR 登记。
403
+ 'inputHasBidi',
404
+ // 🔴 **同形族扫的产物**(client-core 0.43.0,[same-shape-residue-constitution]):本批的病形是
405
+ // 「镜像滞后于 server 实发键」。族扫 = 把 engine 7.46.0 fixture 的 `ToolApprovalFrame` 23 个声明键
406
+ // 与本镜像逐一对表,而不是只补触发本批的那两个 —— `parked`(#329,server 7.44.0 及更早就在,
407
+ // `tool_approval_complete` only)就是这次捞出来的**存量**漏键。镜像补齐,消费面另立项(见帧上注)。
408
+ 'parked',
389
409
  // #144(client-core 0.28.0):server 7.13.0 起真发 `persistedRuleShadowed`(证据=engine fixture
390
410
  // 的 `@sema-agent/server/dist/tool-approval.d.ts`)。⚠️ 本键**领先** SDK 运行期锚一代
391
411
  // (sdk 6.14.0/6.15.0 的 TOOL_APPROVAL_FRAME_KEYS 都还没有它)⇒ 对账门里有一条**带退出条件**的
@@ -498,7 +518,7 @@ export function readToolApprovalRespondAck(v) {
498
518
  return undefined;
499
519
  if (!isRespondDecision(o.decision))
500
520
  return undefined;
501
- return {
521
+ const out = {
502
522
  approvalId: o.approvalId,
503
523
  delivery: 'applied',
504
524
  decision: o.decision,
@@ -513,7 +533,119 @@ export function readToolApprovalRespondAck(v) {
513
533
  // #229(sdk 6.16.0):这次回决的 note 有没有**真落进持久行**。缺席 ≠ false(未带 note 的回决/
514
534
  // 老 server 字段省略);非布尔降缺席 —— 它决定「审计理由记没记上」的诚实告知。
515
535
  ...(typeof o.noteRecorded === 'boolean' ? { noteRecorded: o.noteRecorded } : {}),
536
+ // #340 编辑臂回显(server ≥7.44):**真正落盘**的规范文本(可能与人敲的原字节不同)。
537
+ // 非空串才收 —— 空串是坏值不是「空规则」,回显一格空白比不回显更坏。
538
+ ...(typeof o.persistedRule === 'string' && o.persistedRule !== '' ? { persistedRule: o.persistedRule } : {}),
539
+ // #334 批臂回执(server ≥7.46.0):落地的全体成员规范文本,展示序。
540
+ // 🔴 **整只判形**(不逐条丢):合取批的回执是一份清单,漏掉一条就是对人少报了一次授权 ——
541
+ // 非数组 / 空 / 含非串或空串成员 ⇒ 整只降缺席(诚实「不知道」优先于半份清单)。
542
+ ...(isNonEmptyStringArray(o.persistedRules) ? { persistedRules: o.persistedRules } : {}),
516
543
  };
544
+ // 🔴 **两位规范文本回显的「同份 ack 内矛盾形」**(异源对抗复审 [high] 采纳,0.43.0)——
545
+ // 这与本函数不做的**相关性**判定(「这个 ack 是不是这一次审批的回执」,归编排层)是两件事:
546
+ // 下面判的全是**这一个对象自己**说不通的话,属于形的范畴。
547
+ // ① 单数与复数**同场**:server 的两个回显是互斥臂的产物(`editedArmEcho` 只在 single 成功时
548
+ // 铸、`persistedRules` 只在 batch 成功时铸),同场 = 这份回执在说两次不同的授权;
549
+ // ② 回显在场而 `rulePersisted !== true`:server 的成功回显恒随 `withFlag(true, …)` 一起铸,
550
+ // 「没存上」却报出一条「存成了什么」是自相矛盾 —— 照收会让端渲一句「你的规则已存为 X」,
551
+ // 而 `rulePersisted` 那一位同时在说没存上。
552
+ // 两形都**只丢这两位**(不牵连整份 ack):`rememberApplied`/`updatedInputForwarded` 是更重的
553
+ // 安全告知位,不该被一条规则回显的坏形连坐掉。
554
+ if (out.persistedRule !== undefined && out.persistedRules !== undefined) {
555
+ delete out.persistedRule;
556
+ delete out.persistedRules;
557
+ }
558
+ else if ((out.persistedRule !== undefined || out.persistedRules !== undefined) && out.rulePersisted !== true) {
559
+ delete out.persistedRule;
560
+ delete out.persistedRules;
561
+ }
562
+ return out;
563
+ }
564
+ /** {@link ToolApprovalRespondAckView.persistedRules} 的整只判形(见该位注:半份清单比没有更坏)。 */
565
+ function isNonEmptyStringArray(v) {
566
+ return Array.isArray(v) && v.length > 0 && v.every(x => typeof x === 'string' && x !== '');
567
+ }
568
+ /**
569
+ * 从 respond 抛出来的东西上读 {@link ToolApprovalRespondRefusal}。**永不抛、永不造**。
570
+ *
571
+ * 键位口径与 {@link import('../wireErrorTriage.js').classifyTurnWireError} 同源([2055] 死键
572
+ * 纪律:只认活键 `errorCode`,退役的 `code` 槽不做兼容)。
573
+ *
574
+ * @returns 三位**全缺席**时返回 `undefined` —— 一个三位皆空的 refusal 对象是「有拒句」的假象。
575
+ *
576
+ * 🔴 **文本取值序**(异源复审 [medium] 修):`err.message` 非空串 > 抛出物本身是非空串 >
577
+ * 受保护的 `String(err)`,且**只接受**真有内容的结果 —— `[object Object]` / `null` /
578
+ * `undefined` 三种占位串一律当作「读不出」。
579
+ * 🔴 **`String(err)` 用 try 包住**:抛出物可以自带 `Symbol.toPrimitive` / `toString` 钩子并在里面
580
+ * 抛错。本函数的唯一调用点在 `surfaceToolApprovalFrameAndRespond` 的 catch 里,那里的契约是
581
+ * 「respond 失败 ⇒ 返回 unresolved」;让一个不可信的转换钩子把这条收敛路径变成 reject,
582
+ * 等于给注入面开了一个「让整次审批消费抛出去」的口。
583
+ */
584
+ export function readToolApprovalRespondRefusal(err) {
585
+ // 🔴 **取属性本身就可能抛**(异源复审 [medium] 的形比它自己说的还宽一档,施工时实撞):
586
+ // 抛出物可以是一个带 `get message() { throw }` 的对象 —— 连 `typeof e.message` 这一步都会炸,
587
+ // 根本走不到下面的转换。所以逐位读**各自**包 try,读不动就当那一位缺席。
588
+ const pick = (key) => {
589
+ try {
590
+ return err?.[key];
591
+ }
592
+ catch {
593
+ return undefined;
594
+ }
595
+ };
596
+ const rawStatus = pick('status');
597
+ const rawCode = pick('errorCode');
598
+ const status = typeof rawStatus === 'number' && Number.isFinite(rawStatus) ? rawStatus : undefined;
599
+ const errorCode = typeof rawCode === 'string' && rawCode.length > 0 ? rawCode : undefined;
600
+ const message = refusalTextOf(err, pick('message'));
601
+ if (status === undefined && errorCode === undefined && message === undefined)
602
+ return undefined;
603
+ const rest = {
604
+ ...(status !== undefined ? { status } : {}),
605
+ ...(errorCode !== undefined ? { errorCode } : {}),
606
+ ...(message !== undefined ? { message } : {}),
607
+ };
608
+ // 三选一联合的**构造侧证明**:哪一位在场就从哪一位收窄(与上面的 early-return 合起来,
609
+ // 「至少有一位」在编译期与运行期是同一条不变量,不是两份各自维护的承诺)。
610
+ if (status !== undefined)
611
+ return { ...rest, status };
612
+ if (errorCode !== undefined)
613
+ return { ...rest, errorCode };
614
+ return { ...rest, message: message };
615
+ }
616
+ /** 无信息量的占位串:`String()` 对这几类抛出物的产物,它们不是「拒句」。 */
617
+ const USELESS_REFUSAL_TEXTS = new Set(['[object Object]', 'null', 'undefined', '']);
618
+ /** 见 {@link readToolApprovalRespondRefusal} 的取值序与 try 保护。读不出 ⇒ `undefined`。 */
619
+ function refusalTextOf(err, message) {
620
+ if (typeof message === 'string' && message.length > 0)
621
+ return message;
622
+ if (typeof err === 'string' && err.length > 0)
623
+ return err;
624
+ let coerced;
625
+ try {
626
+ coerced = String(err);
627
+ }
628
+ catch {
629
+ // 不可信的 toString/Symbol.toPrimitive 钩子抛了 —— 诚实缺席,绝不让它逃出 catch。
630
+ return undefined;
631
+ }
632
+ return USELESS_REFUSAL_TEXTS.has(coerced) ? undefined : coerced;
633
+ }
634
+ /**
635
+ * 机读码的**日志安全形**(异源复审 [medium] 采纳,0.42.0):`errorCode` 来自 UNTRUSTED 应答体且
636
+ * 无长度/控制字符/换行约束,把它原样插进宿主日志 = 可伪造日志行、可注入终端控制序列、还能把一段
637
+ * 服务端正文当「码」漏进 sink。
638
+ *
639
+ * 🔴 **只有形合的才照写**(真机读码的字符集):`[A-Za-z0-9._:-]{1,64}`。形不合 ⇒ 只报**长度**,
640
+ * 正文一个字节都不进日志(与本文件 note 那条「留痕只写元数据」逐字同纪律)。
641
+ * 🔴 这**不影响**交还调用方的那一份:{@link ToolApprovalRespondRefusal.errorCode} 仍是**原样**
642
+ * ——净化的是日志这条外溢面,不是契约。
643
+ */
644
+ const MACHINE_CODE_SHAPE = /^[A-Za-z0-9._:-]{1,64}$/;
645
+ function logSafeErrorCode(code) {
646
+ if (code === undefined)
647
+ return 'none';
648
+ return MACHINE_CODE_SHAPE.test(code) ? code : `<unprintable len=${code.length}>`;
517
649
  }
518
650
  /** 结构性识别流上的 tool_approval 帧(named SSE frame,payload.type === 帧名)。
519
651
  * ⚠️ 口径更正(2026-08-08):此处原写「非 AgentEvent arm」—— SDK #185a 起这两个帧**是**
@@ -567,28 +699,136 @@ function isNonNegativeFinite(v) {
567
699
  function isWireRecordCarrier(v) {
568
700
  return typeof v === 'object' && v !== null && !Array.isArray(v);
569
701
  }
570
- function readRuleSuggestions(v) {
702
+ /**
703
+ * 一条 offer / 一个 batch 成员共用的三位窄化(`rule` 非空串 + `match` 二词闭集 + `command` 非空串)。
704
+ * 任一不合形 ⇒ `undefined`(半条候选渲出来是一格没有正文、按下去必被 server 拒的选项)。
705
+ */
706
+ function readRuleTriple(v) {
707
+ if (v === null || typeof v !== 'object')
708
+ return undefined;
709
+ const o = v;
710
+ if (typeof o.rule !== 'string' || o.rule === '')
711
+ return undefined;
712
+ if (o.match !== 'exact' && o.match !== 'prefix')
713
+ return undefined;
714
+ // 上游形上 `command` 必填(渲染用的命令模式)——缺席/非串 ⇒ 整项不合形丢弃。
715
+ if (typeof o.command !== 'string' || o.command === '')
716
+ return undefined;
717
+ return { rule: o.rule, match: o.match, command: o.command };
718
+ }
719
+ /** offer **条数**的坏形帽。server 侧执法帽是 `MAX_RULE_OFFERS`=4(core 契约本就 ≤2),本包留 8 ——
720
+ * [4050] 随批立的纪律:**窄读域只许等于或宽于铸点域,绝不更窄**;超帽 = 坏形/注入,整只降缺席
721
+ * (截了不留痕会掩盖注入)。 */
722
+ const MAX_RULE_OFFERS_TOLERATED = 8;
723
+ /** 一条 `batch` 的**成员**条数坏形帽(core 契约 1..5,server 容忍帽 8)。与上面那个刻意分家:
724
+ * 两者数的是不同的东西(几个选项 vs 一个选项里几条规则),混成一个数会让上游放宽任一维时误伤另一维。 */
725
+ const MAX_RULE_OFFER_BATCH_MEMBERS_TOLERATED = 8;
726
+ /**
727
+ * `ruleOffers`(server ≥7.46.0 的判别联合)的结构读。
728
+ *
729
+ * 🔴 **逐条丢坏、原始下标不前移**:坏 offer 逐条丢弃(一条坏的不该让另一条真的消失,与 server
730
+ * `boundedRuleOffers` 同向),但留下来的每一条都带**原始 wire 下标** {@link RuleOffer.offerIndex} ——
731
+ * core 的契约原话:做不到保住原始下标的消费端「must suppress its persistence actions entirely」,
732
+ * 因为 `batch` 臂的兑付键就是下标。压紧 = 人点的第 k 个与服务端兑的第 k 个指向两条不同规则。
733
+ * 🔴 `kind` 是**闭集判别位**:不认识的 kind ⇒ 丢这一条(不猜、不降级成 single)。
734
+ * 全部不合形/非数组/超帽 ⇒ 整体缺席(卡不渲「不再询问」档)。
735
+ */
736
+ function readRuleOffers(v) {
571
737
  if (!Array.isArray(v))
572
738
  return undefined;
573
- if (v.length === 0 || v.length > 8)
739
+ if (v.length === 0 || v.length > MAX_RULE_OFFERS_TOLERATED)
574
740
  return undefined;
575
741
  const out = [];
576
- for (const item of v) {
742
+ for (let i = 0; i < v.length; i++) {
743
+ const item = v[i];
577
744
  if (item === null || typeof item !== 'object')
578
745
  continue;
579
746
  const o = item;
580
- if (typeof o.rule !== 'string' || o.rule === '')
747
+ if (o.kind === 'single') {
748
+ const triple = readRuleTriple(item);
749
+ if (triple === undefined)
750
+ continue;
751
+ out.push({ kind: 'single', offerIndex: i, ...triple });
581
752
  continue;
582
- if (o.match !== 'exact' && o.match !== 'prefix')
753
+ }
754
+ if (o.kind === 'batch') {
755
+ if (!Array.isArray(o.rules))
756
+ continue;
757
+ if (o.rules.length === 0 || o.rules.length > MAX_RULE_OFFER_BATCH_MEMBERS_TOLERATED)
758
+ continue;
759
+ // `uncoveredSegments` 是**诚实余量披露**,不是装饰位:非有限非负整数 ⇒ 整条 batch 不合形。
760
+ // 渲一个编出来的 0 等于对用户说「兑完这批就全覆盖了」,而那句话没有证据
761
+ // ([honest-absence-not-fabricated-zero])。
762
+ const uncovered = o.uncoveredSegments;
763
+ if (typeof uncovered !== 'number' || !Number.isSafeInteger(uncovered) || uncovered < 0)
764
+ continue;
765
+ const members = [];
766
+ let memberShapeOk = true;
767
+ for (const raw of o.rules) {
768
+ const triple = readRuleTriple(raw);
769
+ const seg = raw?.segment;
770
+ // 🔴 batch 成员**不逐条丢**:合取批是「对全体成员一次说是」,少一条成员就是另一次授权 ——
771
+ // 渲一个缺了成员的批,人点的与引擎兑的不是同一批规则。任一成员不合形 ⇒ 整条 batch 丢。
772
+ if (triple === undefined || typeof seg !== 'string' || seg === '') {
773
+ memberShapeOk = false;
774
+ break;
775
+ }
776
+ members.push({ ...triple, segment: seg });
777
+ }
778
+ if (!memberShapeOk)
779
+ continue;
780
+ out.push({ kind: 'batch', offerIndex: i, rules: members, uncoveredSegments: uncovered });
583
781
  continue;
584
- // SDK 形上 `command` 必填(渲染用的命令模式)——缺席/非串 ⇒ 整项不合形丢弃(半条候选
585
- // 渲出来是一格没有正文的选项)
586
- if (typeof o.command !== 'string' || o.command === '')
782
+ }
783
+ // 不认识的 kind(含 kind 缺席)⇒ 丢这一条,原始下标照旧不给后面的成员用。
784
+ }
785
+ return out.length > 0 ? out : undefined;
786
+ }
787
+ /**
788
+ * 退役键 `ruleSuggestions`(server ≤7.45)的**归一读** —— 每一项都是「整条命令一条规则」,
789
+ * 逐字对应新形的 `kind:'single'` 臂。7.44 及更旧引擎仍在场,所以这条兼容臂不是包袱。
790
+ * 🔴 归一后仍带原始下标:旧形没有 batch 臂 ⇒ 下标不会被当选择键用,但保住它让两代素材在
791
+ * 卡面/对账面上是**同一个形**(消费端零分支)。
792
+ */
793
+ function readLegacyRuleSuggestions(v) {
794
+ if (!Array.isArray(v))
795
+ return undefined;
796
+ if (v.length === 0 || v.length > MAX_RULE_OFFERS_TOLERATED)
797
+ return undefined;
798
+ const out = [];
799
+ for (let i = 0; i < v.length; i++) {
800
+ const triple = readRuleTriple(v[i]);
801
+ if (triple === undefined)
587
802
  continue;
588
- out.push({ rule: o.rule, match: o.match, command: o.command });
803
+ out.push({ kind: 'single', offerIndex: i, ...triple });
589
804
  }
590
805
  return out.length > 0 ? out : undefined;
591
806
  }
807
+ /**
808
+ * 两代 wire 供给 → **包内单一形**(#334/[5223],0.43.0):新键优先,新键整只读不出来才看旧键。
809
+ *
810
+ * 🔴 **新键在场即定局,绝不混编**:新键**有载体**而读出空(数组在但全条坏形、或压根不是数组)
811
+ * 也**不**回落旧键 —— 一台 7.46 引擎不会同时按两代形铸候选,拿旧键顶上去等于把一份异源素材
812
+ * 冒充成这次 ask 的候选。
813
+ *
814
+ * 🔴 **`null` 与 `undefined` 同视为「新键缺席」**(异源对抗复审 [medium] 追问后的**明示裁定**,
815
+ * 不是漏判):判据是**代价不对称**——
816
+ * · 认 null 为缺席的失效面:一台 **7.46** 引擎把新键发成 `null` **且**同时发了旧键。这不可能
817
+ * 发生:7.46 的三腿上旧键一个字都不铸(engine fixture 直证,同文件 0 命中)⇒ 回落读到的
818
+ * 只会是 `undefined`,结果与「整只缺席」逐字节相同,没有异源素材可混;
819
+ * · 认 null 为坏形的失效面:任何把「缺席」序列化成 `null` 的中转层(JSON 规范化包装器、
820
+ * 某些 SQL/JSONB 读面、mock)会让**所有 ≤7.45 引擎**的「不再询问」档整段消失 —— 那正是
821
+ * [5223] 这一批要修的病本身,只是换了个触发条件。
822
+ * ⇒ 取「null == 缺席」。安全面上它**不新增**任何攻击面:一个能塞 `{ruleOffers:null, ruleSuggestions:[…]}`
823
+ * 的注入面,同样能只塞 `{ruleSuggestions:[…]}`,而后者为了兼容 7.44 本来就必须收。
824
+ */
825
+ function readOfferSupply(offers, legacy) {
826
+ // 🔴 `null` 与 `undefined` 同视为缺席(理由见顶注的代价不对称段);其余一切载体 = 新键在场,
827
+ // 读出什么就是什么,**绝不**再看旧键。
828
+ if (offers !== undefined && offers !== null)
829
+ return readRuleOffers(offers);
830
+ return readLegacyRuleSuggestions(legacy);
831
+ }
592
832
  /** 子代审批帧的归属徽章([1535] cli 半场):名一手源=帧上 sourceAgentName(core 1.378 RB-39②
593
833
  * 显式展示身份,server redact 后透传,候 server 班车到货即亮);缺席退 fleet 在飞台账行名
594
834
  * (`${agentType}: ${description}` 合成形;⚠️ sourceTaskId 实测=子代 sessionId 非 a… handle,
@@ -652,7 +892,9 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
652
892
  // 🔴 治理位与委派链透传(2026-08-08):两键在帧上**只在为真/在场时**出现,所以这里也按在场性
653
893
  // 条件 stamp —— 绝不 `governanceForced: frame.governanceForced === true`(那会把「缺席」折成
654
894
  // 显式 `false`,而缺席的语义是「没有治理来源的证据」,不是「这门可以被表态掀掉」)。
655
- const ruleSuggestions = readRuleSuggestions(frame.ruleSuggestions);
895
+ // #334(0.43.0):新键 `ruleOffers`(server ≥7.46.0 判别联合)优先,旧键 `ruleSuggestions`
896
+ // (≤7.45)归一成 `kind:'single'` 兜底 —— 两代经同一把窄读器,包内出口只有一个形。
897
+ const ruleOffers = readOfferSupply(frame.ruleOffers, frame.ruleSuggestions);
656
898
  const card = await surfaceApprovalCard({
657
899
  toolName,
658
900
  args: args,
@@ -690,8 +932,9 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
690
932
  ...(isFromSubagent(frame) && isToolApprovalDelegation(frame.delegation)
691
933
  ? { delegation: frame.delegation }
692
934
  : {}),
693
- // #225 件1:规则候选透传(合形项;缺席/坏形 ⇒ 键不 stamp,卡形与 0.25.0 字节不变)。
694
- ...(ruleSuggestions !== undefined ? { ruleSuggestions } : {}),
935
+ // #225 件1 / #334 换形(0.43.0):规则候选透传(合形项;缺席/坏形 ⇒ 键不 stamp,卡形不渲该档)。
936
+ // 落位是**包内单一形出口** `ruleOffers`,两代 wire 键同经 readOfferSupply 归一。
937
+ ...(ruleOffers !== undefined ? { ruleOffers } : {}),
695
938
  // #144:被越级的持久规则原文(UNTRUSTED-for-display)。窄化=**非空白串才 stamp**,坏形降缺席
696
939
  // (server 明说空串不铸键:「空串是坏值不是『空规则』」)—— 一格空白的规则解释比没有解释更坏。
697
940
  // 🔴 stamp 的是**原文字节**(不 trim、不截、不改写):展示值一改写就不再是「你那条规则」的原文;
@@ -713,6 +956,11 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
713
956
  // 「这不是一次安全类 ask」,把它折成显式 false 会让宿主把「没有证据」读成「已确认可自动放行」,
714
957
  // 而本键存在的全部理由正是在这种门上把自动放行(记住的规则 / allow_session / bypass 姿态)拦下。
715
958
  ...(frame.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
959
+ // #341/[5214]③(0.43.0):bidi 披露位透传。条件 stamp **只认严格 true**,与 governanceForced /
960
+ // requiresRealApproval 同一条纪律 —— 缺席同时覆盖「真的没有」与「扫不了」,折成显式 false 会把
961
+ // 「没扫到」渲成「已确认干净」,而这一位存在的全部理由就是「人眼读到的顺序 ≠ 真跑的字节顺序」。
962
+ // 🔴 披露不清洗:args 字节在本层一个都不改(清洗会让卡上显示的与真跑的不是同一个东西)。
963
+ ...(frame.inputHasBidi === true ? { inputHasBidi: true } : {}),
716
964
  });
717
965
  const decision = card.kind === 'allow' ? (card.allowSession ? 'allow_session' : 'allow') : 'deny';
718
966
  if (card.kind === 'failed') {
@@ -750,19 +998,110 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
750
998
  // 按表拒(rule_not_offered),这道核对把坏卡口实现挡在包边界并留痕(丢键不丢决断:人的
751
999
  // allow/deny 已经落定,一条对不上的规则文本不该反过来污染整次回决)。deny 恒不带(server
752
1000
  // 宽收后忽略,发了只留「像是记住了」的错觉)。
1001
+ //
1002
+ // ── #225 编辑臂(0.42.0,[5071] wire 形):自由文本时**表核让位**,但能力位缺席就别发 ────────
1003
+ // 🔴 让位的判据锚在**出身声明** `card.persistRuleEdited === true`,不锚「文本恰好不在表里」——
1004
+ // 后者会让一个坏卡口实现(拼错了候选原文)自动升级成「自由文本臂」,把这道核对彻底架空。
1005
+ // 🔴 让位的**理由**:表核的全部意义是「绝不把没被 offer 过的文本送进规则店」,而编辑臂的定义
1006
+ // 就是人自己打的文本 —— 对它做表核,等于把这条 wire 上刚开出来的能力在包边界原样关掉
1007
+ // ([5111] 探针实测的闭集三处之②:自由文本恒丢键)。真判官在引擎侧同一函数体
1008
+ // (core `confirmRuleApproval`),不在这里。
1009
+ // 🔴 能力位闸(`lane.respondFreeFormRulesCapable`)与 note 那条**同一条纪律**:位缺席就别发。
1010
+ // 编辑臂在老 server 上会诚实降级 `rule_not_offered`(不是静默 200),但那仍然是一次
1011
+ // 「人以为存上了、其实拿回一个拒绝」的往返 —— 能提前知道这台不供,就别把人的编辑送出去。
1012
+ //
1013
+ // ── #334 批臂(0.43.0,design/377 wire 形):选择键=**原始 wire 下标**,表核锚「该下标真是
1014
+ // 一条 batch offer」──────────────────────────────────────────────────────────────────
1015
+ // 🔴 **三臂互斥先判**(server `PERSIST_RULE_BATCH_EXCLUSIVE_ERROR`:同场响亮 400 且**连决断一起拒**)。
1016
+ // 包边界丢整条持久臂而不是让 400 打掉决断 —— 与 note 超限那条同一条纪律:附带愿望的失败
1017
+ // 绝不许升级成主动作的失败。也绝不替人挑一个臂(两臂说的是两次不同的授权)。
753
1018
  let persistRule;
754
- if (card.kind === 'allow' && typeof card.persistRule === 'string' && card.persistRule !== '' && decision !== 'deny') {
755
- if (ruleSuggestions?.some(sug => sug.rule === card.persistRule) === true) {
1019
+ let persistRuleEdited;
1020
+ let persistRuleBatchOfferIndex;
1021
+ /** 真发出去的那条 batch offer(供下方 ack 的**基数**相关性核对;缺席 = 没发批臂)。 */
1022
+ let batchArmTarget;
1023
+ /** 人选了持久臂、而**能力位没确认**所以整条没发(两臂共用)。通知发在 respond 成功之后。 */
1024
+ let ruleArmDroppedForCaps = false;
1025
+ /** 人选了持久臂、而那次选择**没过包内表核/互斥核**(表外文本 / 坏下标 / 两臂同场)。
1026
+ * 与上一位**刻意分两个标记**:对用户的下一步建议不同(见两条文案常量的顶注)。同样发在
1027
+ * respond 成功之后 —— 文案里有「审批本身过了」。 */
1028
+ let ruleArmDroppedForCheck = false;
1029
+ const wantsTextArm = card.kind === 'allow' && typeof card.persistRule === 'string' && card.persistRule !== '';
1030
+ const wantsBatchArm = card.kind === 'allow' && card.persistRuleBatchOfferIndex !== undefined;
1031
+ if (wantsTextArm && wantsBatchArm) {
1032
+ hostLog('error', `liveToolApprovalWire: DROPPING the whole persistRule arm for ${frame.approvalId} — the card returned BOTH a rule ` +
1033
+ 'text and a batchOfferIndex; the server rejects that combination with a 400 that would take the decision down ' +
1034
+ 'with it, and picking one arm here would be deciding on the user\'s behalf (the decision itself still goes through)');
1035
+ // 🔴 十轮 [medium]:人的明确选择被丢了,`hostLog` 那条是给开发者的诊断,用户还需要一句答复。
1036
+ ruleArmDroppedForCheck = true;
1037
+ }
1038
+ else if (card.kind === 'allow' &&
1039
+ card.persistRuleBatchOfferIndex !== undefined &&
1040
+ decision !== 'deny') {
1041
+ // 🔴 表核:下标必须在**本帧**归一后的 offers 里真指向一条 `kind:'batch'`。
1042
+ // 越界 / 指向 single / 负数 / 非整数一律丢键留痕 —— server 对坏下标响亮 400
1043
+ // (`persistRule.batchOfferIndex must be a non-negative integer when present`),
1044
+ // 而一个被 `|0` 折过的下标指向的是**另一条** offer。
1045
+ const idx = card.persistRuleBatchOfferIndex;
1046
+ const target = Number.isSafeInteger(idx) && idx >= 0
1047
+ ? ruleOffers?.find(o => o.offerIndex === idx)
1048
+ : undefined;
1049
+ if (target !== undefined && target.kind === 'batch') {
1050
+ // 🔴 能力位闸(异源对抗复审 [high]):位缺席就别发 —— 见 `respondBatchRuleOffersCapable`
1051
+ // 顶注。「帧上有 batch offer」只证明出帧端认识新形,不证明收 respond 的那台认识它;
1052
+ // 旧解析器对本键响亮 400 且**连决断一起拒**。
1053
+ if (lane?.respondBatchRuleOffersCapable === true) {
1054
+ persistRuleBatchOfferIndex = idx;
1055
+ batchArmTarget = target;
1056
+ }
1057
+ else {
1058
+ hostLog('debug', `liveToolApprovalWire: DROPPING persistRuleBatchOfferIndex(${idx}) for ${frame.approvalId} — the ` +
1059
+ 'respondBatchRuleOffers capability is not confirmed for this engine, so the batch arm is not sent at all ' +
1060
+ '(the decision itself still goes through unchanged; the offer stays on the card for local/display use)');
1061
+ // 🔴 **人按下的意图被丢了就必须上屏**(异源对抗复审三轮 [high]):只记 debug =
1062
+ // 「用户明确选了『不再询问』,系统静默丢掉,下次照旧弹卡」。见 surfaceRuleArmNotSent 顶注。
1063
+ // ⚠️ 但**不在这里发** —— 文案里有「审批本身过了」,而此刻 respond 还没送出去(四轮 [medium]:
1064
+ // respond 随后抛错 ⇒ 决断没落定,用户却已读到「过了」)。只置标记,发在成功之后。
1065
+ ruleArmDroppedForCaps = true;
1066
+ }
1067
+ }
1068
+ else {
1069
+ hostLog('error', `liveToolApprovalWire: DROPPING persistRuleBatchOfferIndex(${String(idx)}) for ${frame.approvalId} — that wire ` +
1070
+ 'index is not a batch offer on this frame (out of range, a single offer, or not a non-negative integer); ' +
1071
+ 'never redeeming an index the engine did not offer as a batch');
1072
+ ruleArmDroppedForCheck = true; // 见上:表核丢键同样要给用户一句答复
1073
+ }
1074
+ }
1075
+ else if (wantsTextArm && card.kind === 'allow' && typeof card.persistRule === 'string' && decision !== 'deny') {
1076
+ const edited = card.persistRuleEdited === true;
1077
+ if (edited) {
1078
+ if (lane?.respondFreeFormRulesCapable === true) {
1079
+ persistRule = card.persistRule;
1080
+ persistRuleEdited = true;
1081
+ }
1082
+ else {
1083
+ hostLog('debug', `liveToolApprovalWire: DROPPING edited persistRule for ${frame.approvalId} (len=${card.persistRule.length}) — the ` +
1084
+ 'respondFreeFormRules capability is not confirmed for this engine, so the free-form arm is not sent at all ' +
1085
+ '(the decision itself still goes through unchanged)');
1086
+ // 🔴 同形存量一并清(0.43.0):这一臂此前也只写 debug —— 与批臂同一个病,共用同一条通知,
1087
+ // 也共用同一条时序契约(发在 respond 成功之后,理由见批臂那处)。
1088
+ ruleArmDroppedForCaps = true;
1089
+ }
1090
+ }
1091
+ else if (ruleOffers?.some(o => o.kind === 'single' && o.rule === card.persistRule) === true) {
756
1092
  persistRule = card.persistRule;
757
1093
  }
758
1094
  else {
759
- hostLog('error', `liveToolApprovalWire: DROPPING persistRule for ${frame.approvalId} — the card returned a rule that is not among the frame's suggestions (never sending un-offered text to the rule store)`);
1095
+ hostLog('error', `liveToolApprovalWire: DROPPING persistRule for ${frame.approvalId} — the card returned a rule that is not among the frame's SINGLE offers (a batch member's text is not a selectable candidate either: a conjunction batch is redeemed by index, all-or-nothing) — never sending un-offered text to the rule store`);
1096
+ ruleArmDroppedForCheck = true; // 同形存量一并清(0.26.0 起这条只写 error 日志)
760
1097
  }
761
1098
  }
762
1099
  const raw = await respond(frame.approvalId, decision, {
763
1100
  ...(signal && !signal.aborted ? { signal } : {}),
764
1101
  ...(card.kind === 'allow' && card.updatedInput !== undefined ? { updatedInput: card.updatedInput } : {}),
765
1102
  ...(persistRule !== undefined ? { persistRule } : {}),
1103
+ ...(persistRuleEdited !== undefined ? { persistRuleEdited } : {}),
1104
+ ...(persistRuleBatchOfferIndex !== undefined ? { persistRuleBatchOfferIndex } : {}),
766
1105
  ...(note !== undefined ? { note } : {}),
767
1106
  });
768
1107
  // 🔴 相关性门(对抗复审二轮):ack 必须是**这一次**审批的回执 —— id 与决断词都要对上。
@@ -777,6 +1116,62 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
777
1116
  'treating as "no ack" (unknown) — never surfacing a safety notice off an unrelated receipt');
778
1117
  ack = undefined;
779
1118
  }
1119
+ // 🔴 **臂相关性门**(异源对抗复审 [high] 采纳,0.43.0):两位规范文本回显必须与**这一次真发出去
1120
+ // 的持久臂**对得上 —— 没发持久臂却拿到「你的规则已存为 X」,或发的是文本臂却拿到一份批清单,
1121
+ // 都是这份回执在说一次**没有发生过**的授权。端拿它渲的是强断言(「已存」+ 具体存了什么),
1122
+ // 据一个对不上的回显去渲就是替引擎编一次授权。
1123
+ // 🔴 **只丢这两位、不牵连整份 ack**(与上面那道 id/decision 相关性门刻意不同姿势):那道门管的是
1124
+ // 「整份回执根本不是我的」,这道门管的是「回执是我的,但其中一格与我发出的臂矛盾」——
1125
+ // 整份丢会把 `rememberApplied`/`updatedInputForwarded` 这两个更重的安全告知位一起连坐掉。
1126
+ if (ack !== undefined && (ack.persistedRule !== undefined || ack.persistedRules !== undefined)) {
1127
+ // 🔴 单数回显**只属编辑臂**(异源对抗复审二轮 [medium] 采纳,server `editedArmEcho` 逐字:
1128
+ // `persistRule.kind !== 'text' || !persistRule.edited || …` ⇒ 返 `{}`)——候选臂成功时
1129
+ // server **不铸**这一位,所以候选臂上收到它同样是「一次没发生过的授权」。
1130
+ const sentEditedArm = persistRule !== undefined && persistRuleEdited === true;
1131
+ // 🔴 复数回显要过**基数**相关性:server 契约逐字「全体成员落地(persisted/deduped 都计)+
1132
+ // `rules` = 规范文本,**展示序**(= batch offer 的成员序,壳逐条回显)」⇒ 一份合规回执的
1133
+ // 条数恒等于所选 batch 的成员数。多报 = 声称存了用户在这只批里**没看见**的规则(授权范围
1134
+ // 被放大);少报 = 这份清单不是它自称的「全体」。两向都不许当成「实际存了这些」渲上屏。
1135
+ // ⚠️ 只做**基数**相关性,**不复判文本**:规范化是引擎的活(`Bash(adb *)` → `Bash(adb:*)`),
1136
+ // 在这里比对文本就是装第二个判官。
1137
+ const expectedBatchCount = batchArmTarget?.rules.length;
1138
+ const batchEchoOk = ack.persistedRules !== undefined &&
1139
+ expectedBatchCount !== undefined &&
1140
+ ack.persistedRules.length === expectedBatchCount;
1141
+ const strayEchoes = [];
1142
+ if (ack.persistedRule !== undefined && !sentEditedArm)
1143
+ strayEchoes.push('persistedRule');
1144
+ if (ack.persistedRules !== undefined && !batchEchoOk)
1145
+ strayEchoes.push('persistedRules');
1146
+ if (strayEchoes.length > 0) {
1147
+ hostLog('error', `liveToolApprovalWire: DISCARDING persisted-rule echo(es) [${strayEchoes.join(', ')}] on the ack for ` +
1148
+ `${frame.approvalId} — they do not correlate with the persistence arm actually sent ` +
1149
+ `(editedArm=${String(sentEditedArm)} batchArmMembers=${String(expectedBatchCount ?? 'none')} ` +
1150
+ `echoedRules=${String(ack.persistedRules?.length ?? 'none')}); never telling the user a rule ` +
1151
+ 'was stored off a receipt that does not line up with what this client actually sent');
1152
+ const { persistedRule: _pr, persistedRules: _prs, ...rest } = ack;
1153
+ void _pr;
1154
+ void _prs;
1155
+ ack = {
1156
+ ...rest,
1157
+ ...(ack.persistedRule !== undefined && sentEditedArm ? { persistedRule: ack.persistedRule } : {}),
1158
+ ...(ack.persistedRules !== undefined && batchEchoOk ? { persistedRules: ack.persistedRules } : {}),
1159
+ };
1160
+ }
1161
+ }
1162
+ // ── 🔴 **同一次 respond 的通知顺序契约**(异源对抗复审十一轮 [high]:同一次 allow 可以同时
1163
+ // 触发多条通知,而 `HitlHostSurface.showNotice` 的语义是**直接顶替 current、不排队** ⇒
1164
+ // **最后发的那条才是用户真正看得到的那条**)。所以三条按**严重度升序**发,最重的压轴:
1165
+ // ① 规则没存(本次「不再询问」丢了)—— 下次还会问,不影响这次跑的是什么;
1166
+ // ② allow_session 没记住 —— 同族,范围更大一点;
1167
+ // ③ 编辑没转发(**工具正在用原始入参跑**)—— 批的那份 ≠ 跑的那份,唯一一条 [high]。
1168
+ // ⚠️ 顺序写反(把①放最后)= 用一条「规则没保存」把「你批的和正在跑的不是同一个东西」顶掉。
1169
+ // ⚠️ 时序契约(四轮 [medium])一并在此兑现:三条**都在 `await respond(...)` 成功之后**才发 ——
1170
+ // ①的文案里有「审批本身过了」,而 respond 抛错那条路上那句是假的(决断没落定,引擎自决)。
1171
+ if (ruleArmDroppedForCaps)
1172
+ surfaceRuleArmNotSent();
1173
+ if (ruleArmDroppedForCheck)
1174
+ surfaceRuleArmRejected();
780
1175
  // 🔴 FIX①:三选卡第 2 项在用户心里签的是「本会话这个工具不再问我」。server 说没记住时,
781
1176
  // 界面必须收敛那个承诺 —— 这里只发一条诚实通知(渲不渲「全放行」徽标由宿主按 ack 决定,
782
1177
  // ack 也随本函数返回值一起交出去)。ack 缺席(旧 server / void 注入面)= 未知,不发。
@@ -801,7 +1196,17 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
801
1196
  return ack !== undefined ? { decision, ack } : { decision };
802
1197
  }
803
1198
  catch (e) {
804
- hostLog('debug', `liveToolApprovalWire: respond(${decision}) failed for ${frame.approvalId}: ${String(e)} engine self-settles (TTL/abort)`);
805
- return { decision: 'unresolved' };
1199
+ // #225 件5(0.42.0):**原文交还调用方**,不再只留一行 debug 就把它吞掉。
1200
+ // 🔴 `decision:'unresolved'` 逐字保留(respond 没落定这件事没变);新增的只是 `respondRefusal`
1201
+ // 这一格诊断面 —— 宿主拿它去渲「你的规则被拒了,引擎说:…」,而不是把一次响亮 400 呈成
1202
+ // 「什么也没发生」([5111] 探针实测的闭集三处之③)。
1203
+ // 🔴 留痕仍只写**元数据**(状态码 + 码 + 长度),正文不进日志:它是 UNTRUSTED 的应答体文本,
1204
+ // 回喂宿主 sink 零诊断增量、还是外溢面(与 note 那条同一条纪律)。
1205
+ const respondRefusal = readToolApprovalRespondRefusal(e);
1206
+ hostLog('debug', `liveToolApprovalWire: respond(${decision}) failed for ${frame.approvalId} ` +
1207
+ `(status=${respondRefusal?.status ?? 'none'} errorCode=${logSafeErrorCode(respondRefusal?.errorCode)} ` +
1208
+ `messageLen=${respondRefusal?.message?.length ?? 0}) — engine self-settles (TTL/abort); ` +
1209
+ 'the refusal text is handed back on outcome.respondRefusal for the host to surface');
1210
+ return respondRefusal !== undefined ? { decision: 'unresolved', respondRefusal } : { decision: 'unresolved' };
806
1211
  }
807
1212
  }
package/dist/index.d.ts CHANGED
@@ -224,6 +224,7 @@ export { toolEndOutputText, ENGINE_ABORT_TOOL_RESULT } from './hitl/frameRouter.
224
224
  export { isAskTool } from './hitl/frameRouter.js';
225
225
  export * from './hitl/hitlHostSurface.js';
226
226
  export * from './hitl/toolApprovalWire.js';
227
+ export * from './hitl/editedRuleTextPrecheck.js';
227
228
  export * from './hitl/askGateWire.js';
228
229
  export * from './hitl/planReviewWire.js';
229
230
  export * from './hitl/gateIdentity.js';
package/dist/index.js CHANGED
@@ -326,6 +326,13 @@ export { toolEndOutputText, ENGINE_ABORT_TOOL_RESULT } from './hitl/frameRouter.
326
326
  export { isAskTool } from './hitl/frameRouter.js';
327
327
  export * from './hitl/hitlHostSurface.js';
328
328
  export * from './hitl/toolApprovalWire.js';
329
+ // ── #225 编辑臂预检的转出口([5076] 自领件,0.42.0)————————————————————————————————————
330
+ // core 5.57.0 导出了 `precheckEditedRuleText`(与 `confirmRuleApproval` **同一函数体**的 text×command
331
+ // 三步门)。本包**不能**做 value 级 re-export —— `@sema-agent/core` 的 barrel 值级拉 `node:crypto`/
332
+ // `node:fs`/`node:path`,而 portability 门的 `EXPECTED_PACKAGES_INDEX` 是等值门 + ③ 段拿 esbuild
333
+ // `--platform=browser` 真打包,一条这样的边会把整台引擎焊进 web/desktop 的产物。
334
+ // ⇒ 转出口取「类型面 + 注入口 + 诚实缺席读口」三件(逐条理由见模块头注)。
335
+ export * from './hitl/editedRuleTextPrecheck.js';
329
336
  export * from './hitl/askGateWire.js';
330
337
  export * from './hitl/planReviewWire.js';
331
338
  // ── A-028.2/.3(#244 族A,2026-08-12):决断卡链的键词汇/呈现台账/归属判据三件 ————————————