@sema-agent/client-core 0.12.2 → 0.14.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.
Files changed (131) hide show
  1. package/README.md +15 -2
  2. package/dist/abortableSleep.d.ts +29 -0
  3. package/dist/abortableSleep.js +43 -0
  4. package/dist/adapt/arms.d.ts +67 -0
  5. package/dist/adapt/arms.js +623 -0
  6. package/dist/adapt/ids.d.ts +22 -0
  7. package/dist/adapt/ids.js +34 -0
  8. package/dist/adapt/instanceLedger.d.ts +36 -0
  9. package/dist/adapt/instanceLedger.js +50 -0
  10. package/dist/adapt/panelTasks.d.ts +59 -0
  11. package/dist/adapt/panelTasks.js +193 -0
  12. package/dist/adapt/textStream.d.ts +63 -0
  13. package/dist/adapt/textStream.js +141 -0
  14. package/dist/adapt/toolCards.d.ts +65 -0
  15. package/dist/adapt/toolCards.js +100 -0
  16. package/dist/adapt/turnFlags.d.ts +37 -0
  17. package/dist/adapt/turnFlags.js +55 -0
  18. package/dist/adapt/wireShapes.d.ts +96 -0
  19. package/dist/adapt/wireShapes.js +167 -0
  20. package/dist/adapt.d.ts +33 -60
  21. package/dist/adapt.js +84 -1207
  22. package/dist/adapter/downstream/eventToSdkMessage.d.ts +57 -13
  23. package/dist/adapter/downstream/eventToSdkMessage.js +174 -106
  24. package/dist/adapter/downstream/terminalToSdkResult.js +151 -160
  25. package/dist/adapter/downstream/turnUsageToModelUsage.d.ts +36 -0
  26. package/dist/adapter/downstream/turnUsageToModelUsage.js +34 -6
  27. package/dist/adapter/runStream.d.ts +36 -4
  28. package/dist/adapter/runStream.js +195 -13
  29. package/dist/adapter/types.d.ts +28 -2
  30. package/dist/agentSession/backgroundView.js +4 -15
  31. package/dist/agentsWireCaps.d.ts +10 -6
  32. package/dist/agentsWireCaps.js +21 -7
  33. package/dist/argvFlagValue.d.ts +41 -0
  34. package/dist/argvFlagValue.js +69 -0
  35. package/dist/attachmentsWireCaps.d.ts +3 -2
  36. package/dist/attachmentsWireCaps.js +5 -3
  37. package/dist/classifierVerdictWire.d.ts +8 -0
  38. package/dist/classifierVerdictWire.js +8 -0
  39. package/dist/cloudConfigWireCaps.d.ts +28 -1
  40. package/dist/cloudConfigWireCaps.js +51 -11
  41. package/dist/controlRouter.d.ts +14 -8
  42. package/dist/controlRouter.js +15 -21
  43. package/dist/detachWire.d.ts +15 -5
  44. package/dist/detachWire.js +17 -7
  45. package/dist/effortWire.d.ts +0 -21
  46. package/dist/effortWire.js +6 -20
  47. package/dist/engineInlineTaskStats.d.ts +12 -6
  48. package/dist/engineWireSdk.d.ts +12 -0
  49. package/dist/env/localeGeo.js +2 -1
  50. package/dist/envFlag.d.ts +39 -0
  51. package/dist/envFlag.js +51 -0
  52. package/dist/finalVerifyWire.d.ts +7 -5
  53. package/dist/finalVerifyWire.js +6 -5
  54. package/dist/fleet/fleetLedger.d.ts +32 -9
  55. package/dist/fleet/fleetLedger.js +119 -34
  56. package/dist/fleet/fleetProjection.d.ts +44 -6
  57. package/dist/fleet/fleetProjection.js +52 -10
  58. package/dist/fleetAgentPanelProjection.d.ts +18 -1
  59. package/dist/fleetAgentPanelProjection.js +61 -14
  60. package/dist/forkWireCaps.d.ts +2 -1
  61. package/dist/forkWireCaps.js +5 -11
  62. package/dist/goalStopHook.d.ts +142 -0
  63. package/dist/goalStopHook.js +258 -0
  64. package/dist/headlessPermissionModeWire.d.ts +10 -0
  65. package/dist/headlessPermissionModeWire.js +24 -21
  66. package/dist/headlessReconnectWire.d.ts +7 -1
  67. package/dist/headlessReconnectWire.js +20 -2
  68. package/dist/hitl/approvalsFeed.d.ts +22 -1
  69. package/dist/hitl/approvalsFeed.js +103 -9
  70. package/dist/hitl/askGateWire.d.ts +27 -96
  71. package/dist/hitl/askGateWire.js +69 -618
  72. package/dist/hitl/frameRouter.d.ts +86 -0
  73. package/dist/hitl/frameRouter.js +383 -0
  74. package/dist/hitl/gateLedger.d.ts +119 -0
  75. package/dist/hitl/gateLedger.js +113 -0
  76. package/dist/hitl/hitlBridge.d.ts +75 -15
  77. package/dist/hitl/hitlBridge.js +94 -22
  78. package/dist/hitl/hitlHostSurface.d.ts +49 -0
  79. package/dist/hitl/hitlHostSurface.js +155 -0
  80. package/dist/hitl/parkResolver.d.ts +74 -0
  81. package/dist/hitl/parkResolver.js +250 -0
  82. package/dist/hitl/planReviewWire.d.ts +60 -2
  83. package/dist/hitl/planReviewWire.js +197 -91
  84. package/dist/hitl/toolApprovalWire.d.ts +67 -4
  85. package/dist/hitl/toolApprovalWire.js +129 -31
  86. package/dist/hooksWireCaps.d.ts +1 -82
  87. package/dist/hooksWireCaps.js +44 -237
  88. package/dist/host.d.ts +16 -5
  89. package/dist/index.d.ts +2 -0
  90. package/dist/index.js +15 -1
  91. package/dist/interactiveToolsWire.d.ts +15 -4
  92. package/dist/interactiveToolsWire.js +24 -26
  93. package/dist/limitsWire.js +7 -40
  94. package/dist/liveInitToolFace.d.ts +51 -6
  95. package/dist/liveQuestionStore.d.ts +18 -6
  96. package/dist/liveQuestionStore.js +15 -0
  97. package/dist/model/providerPresets.js +11 -1
  98. package/dist/notifications.d.ts +62 -2
  99. package/dist/notifications.js +306 -49
  100. package/dist/printToolResultFrame.d.ts +12 -4
  101. package/dist/printToolResultFrame.js +11 -21
  102. package/dist/retainBackgroundWireCaps.d.ts +3 -2
  103. package/dist/retainBackgroundWireCaps.js +5 -9
  104. package/dist/sandboxWire.d.ts +9 -31
  105. package/dist/sandboxWire.js +51 -50
  106. package/dist/scenarioWire.d.ts +1 -1
  107. package/dist/scenarioWire.js +25 -36
  108. package/dist/seam.d.ts +23 -6
  109. package/dist/seam.js +40 -30
  110. package/dist/seatContract.d.ts +369 -83
  111. package/dist/seatContract.js +585 -198
  112. package/dist/selfOrchestrationWireCaps.d.ts +6 -5
  113. package/dist/selfOrchestrationWireCaps.js +8 -12
  114. package/dist/sessionSlot.d.ts +8 -0
  115. package/dist/sessionSlot.js +1 -0
  116. package/dist/steering.js +2 -2
  117. package/dist/subagent/engineTaskHandleWire.d.ts +3 -0
  118. package/dist/subagent/engineTaskHandleWire.js +17 -2
  119. package/dist/subagentContentStore.d.ts +5 -5
  120. package/dist/toolResult.d.ts +89 -8
  121. package/dist/toolResult.js +99 -30
  122. package/dist/typePins.d.ts +17 -0
  123. package/dist/typePins.js +1 -0
  124. package/dist/ultracodeWireCaps.js +5 -6
  125. package/dist/unrefTimer.d.ts +30 -0
  126. package/dist/unrefTimer.js +5 -0
  127. package/dist/workflow.d.ts +3 -2
  128. package/dist/workflow.js +3 -2
  129. package/dist/workflowClient.d.ts +7 -0
  130. package/dist/workflowClient.js +47 -12
  131. package/package.json +3 -3
@@ -0,0 +1,155 @@
1
+ /**
2
+ * hitlHostSurface.ts — REF-CC-023(2026-08-02,P3 wave2 卡C-bridge):共享的 HITL 宿主通知面
3
+ * + cancel-by-deny 有界观察,从 `askGateWire.ts` 提出来给 `toolApprovalWire.ts` 也用。
4
+ *
5
+ * 🔴 为什么要提出来(不是顺手重排):`askGateWire.ts` 已经 `import` `toolApprovalWire.ts`(§ ask 桥
6
+ * 消费工具审批帧判定),反向 `toolApprovalWire.ts` → `askGateWire.ts` 会成环。`observeCancelByDeny`
7
+ * 曾经只活在 `askGateWire.ts` 里,而 `toolApprovalWire.ts:surfaceFsApprovalAndDecide` 的中断-deny
8
+ * 臂原样复刻了它要修的那个事故(件3,中断事故修复批 G,2026-07-15):裸 `.catch(()=>{})` 全吞 ⇒
9
+ * deny 丢失时 run 永卡 suspended、session 锁死,用户下一条消息撞 409「active run」还全无线索。
10
+ * 单源到一个两边都能 import 的模块,两条腿共用同一次修复,而不是「同一个仓、同一个事故、
11
+ * 一处修了另一处原样留着」。
12
+ *
13
+ * 这个模块也顺带带走了 `surfaceCancelDenyWarn` 唯一依赖的宿主通知面(`HitlHostSurface` /
14
+ * `installHitlHostSurface*` / `hitlHostSurfaceFor*`)—— 它不只是给 cancel-by-deny 用(分类器 deny
15
+ * 的端侧记账 `surfaceClassifierDeny` 也走它),`askGateWire.ts` 仍然是它的主要消费方,只是不再是
16
+ * 唯一持有方。
17
+ */
18
+ import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
19
+ import { hostLog } from '../host.js';
20
+ import { unrefTimer } from '../unrefTimer.js';
21
+ import { HitlSafetyError } from './hitlBridge.js';
22
+ // W1(design/161):sessionKey → 注册表;零参 API = DEFAULT_SESSION_KEY 兼容层(cli 装配不动)。
23
+ const hostSurfaceByKey = createSessionSlot();
24
+ const hostSurfaceMissesByKey = new Map();
25
+ /** 装 HITL 宿主面(传 null 卸)。返回还原函数。 */
26
+ export function installHitlHostSurface(surface) {
27
+ return installHitlHostSurfaceFor(DEFAULT_SESSION_KEY, surface);
28
+ }
29
+ /** W1 带 key 变体:装/卸某会话的 HITL 宿主面(多会话宿主每会话一键,互不顶盖)。 */
30
+ export function installHitlHostSurfaceFor(sessionKey, surface) {
31
+ const prev = hostSurfaceByKey.get(sessionKey);
32
+ hostSurfaceByKey.set(sessionKey, surface);
33
+ return () => {
34
+ hostSurfaceByKey.set(sessionKey, prev);
35
+ };
36
+ }
37
+ /** 🔴 宿主自检:恒应为 0。非 0 = 有 HITL 副作用发生时口不在,那一行 warn / 那条记账丢了。 */
38
+ export function hitlHostSurfaceMisses() {
39
+ return hitlHostSurfaceMissesFor(DEFAULT_SESSION_KEY);
40
+ }
41
+ /** W1 带 key 变体(miss 计数逐键)。 */
42
+ export function hitlHostSurfaceMissesFor(sessionKey) {
43
+ return hostSurfaceMissesByKey.get(sessionKey) ?? 0;
44
+ }
45
+ /** W1 读端:某会话的 HITL 宿主面(未装 = null;**不计 miss** —— miss 只在副作用真发生时计)。 */
46
+ export function hitlHostSurfaceFor(sessionKey) {
47
+ return hostSurfaceByKey.get(sessionKey) ?? null;
48
+ }
49
+ /** 测试钩:卸口 + 清计数。 */
50
+ export function _resetHitlHostSurfaceForTest() {
51
+ hostSurfaceByKey.clear();
52
+ hostSurfaceMissesByKey.clear();
53
+ }
54
+ /** 内部读点(计 miss)。`askGateWire.ts` 的 `surfaceClassifierDeny` 路径与本文件的
55
+ * `surfaceCancelDenyWarn` 共用它。 */
56
+ export function surfaceForCurrentSession() {
57
+ const hostSurface = hitlHostSurfaceFor(DEFAULT_SESSION_KEY);
58
+ if (hostSurface === null) {
59
+ hostSurfaceMissesByKey.set(DEFAULT_SESSION_KEY, (hostSurfaceMissesByKey.get(DEFAULT_SESSION_KEY) ?? 0) + 1);
60
+ }
61
+ return hostSurface;
62
+ }
63
+ // ── 件3(中断事故修复批 G,2026-07-15)—— 中断 deny 的有界观察 ─────────────────────────────────
64
+ /** cancel-by-deny 的后台 settle 预算。decide 是 SYNC 驱动的(引擎跑到下一 park/终态才返,实测
65
+ * 4-5s+),但 DENY-abort 语义上引擎收到即终结 run;2s 内连收都没收到 ⇒ 按丢失警示(晚到成功
66
+ * 只是多一行良性 warn,比锁死无线索诚实)。 */
67
+ export const CANCEL_DENY_BUDGET_MS = 2000;
68
+ /** warn 行文案(测试锁字面)。 */
69
+ export const CANCEL_DENY_WARN_TEXT = 'could not cancel the pending question — the session may stay locked; the run may need engine-side recovery';
70
+ const CANCEL_DENY_WARN_KEY = 'hitl-cancel-deny-warn';
71
+ const CANCEL_DENY_WARN_TIMEOUT_MS = 10_000;
72
+ /** 失败 warn 上屏:借 footer notifications 通道。壳原文经 `appStateRef` 直写 store
73
+ * (immediate 语义:立即顶到 current + 到点自清「仅当仍是本 warn 时」);本包把那两次
74
+ * setState 拆成宿主口的两个动词,**时序纪律(2s 预算 / 10s 自清)留在库内**——
75
+ * [paired-mechanisms-must-share-premise]:只给「显示」不给「清」,端各写各的清法必然漂。
76
+ * 无口(print/headless 没屏可上)⇒ 静默,与壳原文 `if (!store) return` 同语义。 */
77
+ function surfaceCancelDenyWarn() {
78
+ try {
79
+ const s = surfaceForCurrentSession();
80
+ if (!s)
81
+ return;
82
+ s.showNotice({
83
+ key: CANCEL_DENY_WARN_KEY,
84
+ text: CANCEL_DENY_WARN_TEXT,
85
+ color: 'warning',
86
+ priority: 'immediate',
87
+ timeoutMs: CANCEL_DENY_WARN_TIMEOUT_MS,
88
+ });
89
+ const timer = setTimeout(() => {
90
+ try {
91
+ s.clearNoticeIfCurrent(CANCEL_DENY_WARN_KEY);
92
+ }
93
+ catch {
94
+ /* fail-soft */
95
+ }
96
+ }, CANCEL_DENY_WARN_TIMEOUT_MS);
97
+ unrefTimer(timer);
98
+ }
99
+ catch {
100
+ /* fail-soft:上屏失败绝不再伤害 abort 流 */
101
+ }
102
+ }
103
+ /**
104
+ * cancel-by-deny 的「良性 no_pending」判据。**结构化 `.code` 为主,`instanceof` 只作加强,绝不替代**:
105
+ * - `.code` 是 client-core 自家 `HitlSafetyError` 的契约属性(hitlBridge.ts,REF-CC-036 起是闭集
106
+ * 联合 binding_mismatch|no_pending|wrong_gate|bad_plan_edit)—— 不是 SDK 错误的 `errorCode`;
107
+ * - 只按 `instanceof` 判会在**跨 realm / 双实例**下假阴:打包后同一个包出现两份副本(本仓
108
+ * `singleton-manifest.json` 把双实例当在册风险处理)、或宿主与包各持一份构造器时,同一个
109
+ * 语义错误 `instanceof` 为假 ⇒ 良性 no_pending 被当成「deny 丢失」弹一行吓人的 warn;
110
+ * - 下游 `sema-cli` 的 `src/sema/hitlCancelDeny.test.ts` 用例 3 正是按 `code + name` 鸭子形造错,
111
+ * 那份契约就是「结构判据」本身。
112
+ * ⚠️ 判据不许松成「有 `.code` 就静默」—— 闭集里只有 `no_pending` 是良性,别的 code 照走 warn 臂。
113
+ */
114
+ function isNoPendingError(e) {
115
+ if (e instanceof HitlSafetyError)
116
+ return e.code === 'no_pending';
117
+ if (typeof e !== 'object' || e === null || !('code' in e))
118
+ return false;
119
+ const code = e.code;
120
+ return typeof code === 'string' && code === 'no_pending';
121
+ }
122
+ /**
123
+ * 中断 deny 的有界观察(壳侧单测 `hitlCancelDeny.test.ts` 的被测面;REF-CC-023 起两条决断腿共用)。
124
+ * 铁律:不 await 进 abort 返回路径(用户立即拿回控制);这里只管后台 settle 的«观察»:
125
+ * - 2s 内 settle 成功 ⇒ 零上屏(SEMA_DEBUG 记成功);
126
+ * - 失败/超时 ⇒ 上屏一行 warn + SEMA_DEBUG 记原因(deny 丢失 = run 卡 suspended,下一条消息
127
+ * 撞 409;配合件2b 的专属文案,用户知道现场 + 出路);
128
+ * - `code === 'no_pending'`(判据见上方 `isNoPendingError`)⇒ 良性静默(pending 已被别处消解/
129
+ * 过期 —— run 没锁;hitlBridge decideTool 的同款语义,那里的静默维持不动)。
130
+ */
131
+ export function observeCancelByDeny(settle, taskId) {
132
+ let timer;
133
+ const budget = new Promise((_, reject) => {
134
+ timer = setTimeout(() => reject(new Error(`cancel-by-deny did not settle within ${CANCEL_DENY_BUDGET_MS}ms`)), CANCEL_DENY_BUDGET_MS);
135
+ unrefTimer(timer);
136
+ });
137
+ void Promise.race([settle, budget])
138
+ .then(() => {
139
+ hostLog('debug', `[hitl-cancel-deny] cancel-by-deny settled for run ${taskId} (session unlocked)`);
140
+ })
141
+ .catch((e) => {
142
+ if (isNoPendingError(e)) {
143
+ hostLog('debug', `[hitl-cancel-deny] cancel-by-deny no_pending for run ${taskId} (benign — already resolved)`);
144
+ return;
145
+ }
146
+ hostLog('error', `[hitl-cancel-deny] cancel-by-deny FAILED for run ${taskId}: ${String(e)}`);
147
+ surfaceCancelDenyWarn();
148
+ })
149
+ .finally(() => {
150
+ if (timer !== undefined)
151
+ clearTimeout(timer);
152
+ });
153
+ // 超时后原 promise 迟到 reject 不得变成 unhandledRejection。
154
+ void settle.catch(() => { });
155
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * parkResolver — HITL gate 桥**外环**的 park 决断(REF-CC-032 / SPLIT-12 第三刀,2026-08-02)。
3
+ *
4
+ * 内环(`frameRouter.ts`)带出一个 `GatePark` 之后,这里回答唯一一个问题:**接着读流,还是
5
+ * fail-soft 收场**。三条出路:
6
+ * · 决断成功 ⇒ `reattach`(驱动 attach `runs.events`,`lastEventId` 续传);
7
+ * · 「这个 gate 早就被解决了」(durable re-attach 必然重放已决断的 park)⇒ 同样 `reattach`;
8
+ * · 其余(overlay 缺席 / pending 蒸发 / decide 409/404 / turn 中断 / hop 预算耗尽)⇒ `failsoft`:
9
+ * flush HOLD 的毒化帧 + 原样吐 suspended 终帧(durable 腿无终帧则合成一条可见的 failed)。
10
+ *
11
+ * 🔴 本文件不碰流、不 yield:events 以数组交回驱动。`resolvePark` 的**返回值**就是它的全部对外
12
+ * 效果(台账写入除外,且台账写入全经 `GateLedger` 的动词)—— 于是「禁掉某条真臂」这种变异
13
+ * 在驱动的消费点上必然显形,而不是被本文件自己吞掉。
14
+ */
15
+ import type { AgentEvent } from '@sema-agent/sdk';
16
+ import { type QuestionAnswer } from '../liveQuestionStore.js';
17
+ import type { AskAnsweredOutput, GateLedger } from './gateLedger.js';
18
+ import { type AskGateWireDeps, type GatePark } from './frameRouter.js';
19
+ /**
20
+ * REF-CC-033:`GateOutcome` 的 `failed` 分支闭集判别码。来源两处:①本文件自己的
21
+ * 「pending 行取不到」早退(`surfaceGateAndDecide` 里 `!pending` 那条);②`HitlBridge` 抛出的
22
+ * `HitlSafetyError.code`(hitlBridge.ts 契约:`binding_mismatch|no_pending|wrong_gate|bad_plan_edit`
23
+ * ——该属性今天仍是开集 `string`,TYPESHAPE-07/REF-CC-036 候后续车锁死;这里落地前用
24
+ * `isGateFailureCode` 按闭集白名单过滤,不认得的值一律降级成 `undefined`,退回文案兜底,不让
25
+ * 开集腐蚀这个新判别位)。消费点只认 `'no_pending'` 为「已解决」,其余码(binding_mismatch 等)
26
+ * 是真需要重新呈现给人的失败,不能走「继续读流」这条路。
27
+ */
28
+ export type GateFailureCode = 'no_pending' | 'binding_mismatch' | 'wrong_gate' | 'bad_plan_edit';
29
+ export type GateOutcome = {
30
+ kind: 'decided';
31
+ gatedCallId?: string | undefined;
32
+ answered?: AskAnsweredOutput | undefined;
33
+ } | {
34
+ kind: 'aborted';
35
+ gatedCallId?: string | undefined;
36
+ } | {
37
+ kind: 'failed';
38
+ gatedCallId?: string | undefined;
39
+ reason: string;
40
+ code?: GateFailureCode | undefined;
41
+ };
42
+ /**
43
+ * decide 的 `failed` 原因是不是「这个 gate 早就被解决了」(#110 缺陷② c 的判据)。
44
+ *
45
+ * REF-CC-033:此前判据**只**锚在文案词(`no pending checkpoint` / `resolved` / `already` 三者
46
+ * 任一在场)——server 原文改一个字,整条命中当场失效(FIX-13 / 域词表-01)。现在消费点(见下方
47
+ * `isAlreadyResolvedFailure`)优先判 `GateOutcome.code`(闭集判别位,见 `GateFailureCode`);
48
+ * 这个函数降级为**文案兜底臂**,只在 outcome 没带 code 的时候才被调用(典型:`FsApprovalOutcome`
49
+ * 来自 `toolApprovalWire.ts`,那份类型今天没有 code 字段——C-bridge 卡的文件域,本卡不改)。
50
+ * 🔴 方向没变:这条判**错**的后果不对称 —— 误判成「已解决」最多让流多读一轮(hop 预算兜底),
51
+ * 误判成「真失败」会把一个已经 completed 的 run 谎报成失败并烧掉整条会话转录。所以宁可宽。
52
+ */
53
+ /** #114/[C68] 单源化提升:desktop 照抄件(durable 双实现审计 P0-1 修)到货换包导入删抄件。 */
54
+ export declare function isAlreadyResolvedGateReason(reason: unknown): boolean;
55
+ /** 把 wire 答案({answers:[{header,selected,note?}]})折回 CC 卡片的 Record<question,string> 形状
56
+ * (multiSelect 与对话框同款 ", " lossy join;note → annotations.notes)。 */
57
+ export declare function toAnsweredOutput(questions: unknown[], answer: QuestionAnswer): AskAnsweredOutput;
58
+ /** 外环决断的两种出路(见文件头注)。 */
59
+ export type ParkResolution = {
60
+ kind: 'reattach';
61
+ } | {
62
+ kind: 'failsoft';
63
+ events: readonly AgentEvent[];
64
+ };
65
+ export interface ParkResolveCtx {
66
+ deps: AskGateWireDeps;
67
+ led: GateLedger;
68
+ /** sync leg 捕获的真 run handle(内环 `TaskIdRef` 的当前值)。 */
69
+ taskId: string;
70
+ /** 本 turn 内这是第几次 park(hop 预算的唯一计量;由驱动累加,本函数只读)。 */
71
+ hops: number;
72
+ signal?: AbortSignal | undefined;
73
+ }
74
+ export declare function resolvePark(park: GatePark, ctx: ParkResolveCtx): Promise<ParkResolution>;
@@ -0,0 +1,250 @@
1
+ import { HitlBridge, HitlSafetyError, findPendingForTask } from './hitlBridge.js';
2
+ import { publishQuestionFrame, registerLocalQuestionResponder, hasQuestionOverlay, } from '../liveQuestionStore.js';
3
+ import { hostLog } from '../host.js';
4
+ import { surfaceFsApprovalAndDecide } from './toolApprovalWire.js';
5
+ import { observeCancelByDeny } from './hitlHostSurface.js';
6
+ import { isAskTool } from './frameRouter.js';
7
+ /** 一 turn 内最多循环这么多次 park(防御:引擎/模型病态连环提问时不无限 attach)。 */
8
+ const MAX_GATE_HOPS = 24;
9
+ function isGateFailureCode(v) {
10
+ return v === 'no_pending' || v === 'binding_mismatch' || v === 'wrong_gate' || v === 'bad_plan_edit';
11
+ }
12
+ /**
13
+ * decide 的 `failed` 原因是不是「这个 gate 早就被解决了」(#110 缺陷② c 的判据)。
14
+ *
15
+ * REF-CC-033:此前判据**只**锚在文案词(`no pending checkpoint` / `resolved` / `already` 三者
16
+ * 任一在场)——server 原文改一个字,整条命中当场失效(FIX-13 / 域词表-01)。现在消费点(见下方
17
+ * `isAlreadyResolvedFailure`)优先判 `GateOutcome.code`(闭集判别位,见 `GateFailureCode`);
18
+ * 这个函数降级为**文案兜底臂**,只在 outcome 没带 code 的时候才被调用(典型:`FsApprovalOutcome`
19
+ * 来自 `toolApprovalWire.ts`,那份类型今天没有 code 字段——C-bridge 卡的文件域,本卡不改)。
20
+ * 🔴 方向没变:这条判**错**的后果不对称 —— 误判成「已解决」最多让流多读一轮(hop 预算兜底),
21
+ * 误判成「真失败」会把一个已经 completed 的 run 谎报成失败并烧掉整条会话转录。所以宁可宽。
22
+ */
23
+ /** #114/[C68] 单源化提升:desktop 照抄件(durable 双实现审计 P0-1 修)到货换包导入删抄件。 */
24
+ export function isAlreadyResolvedGateReason(reason) {
25
+ if (typeof reason !== 'string')
26
+ return false;
27
+ const r = reason.toLowerCase();
28
+ return r.includes('no pending checkpoint') || r.includes('resolved') || r.includes('already');
29
+ }
30
+ /** 已解决判据(REF-CC-033):code 在场就只信 code,不在场才落回文案兜底(fs 审批腿的
31
+ * `FsApprovalOutcome` 今天没有 code 字段——C-bridge 卡域,退役条件 = 那边也补上 code)。
32
+ * 🔴 入参**已经**是窄化到 `{kind:'failed'}` 的那半:一个「true ⇒ 是 failed」的判定不能反过来
33
+ * 当「false ⇒ 不是 failed」的类型谓词用(TS 的 `outcome is Extract<…>` 负向窄化会把整个
34
+ * `'failed'` 分支从联合类型里排除,而这个函数对未解决的真失败照样返回 false——那不是「它不是
35
+ * failed」,是「它是 failed 但没解决」。调用点先用 `outcome.kind === 'failed'` 做真正的判别式
36
+ * 窄化,再把窄化后的值交给这里,本函数只回答布尔,不再兼职类型谓词。 */
37
+ function isAlreadyResolvedFailure(outcome) {
38
+ const code = 'code' in outcome && isGateFailureCode(outcome.code) ? outcome.code : undefined;
39
+ return code !== undefined ? code === 'no_pending' : isAlreadyResolvedGateReason(outcome.reason);
40
+ }
41
+ /** 把 wire 答案({answers:[{header,selected,note?}]})折回 CC 卡片的 Record<question,string> 形状
42
+ * (multiSelect 与对话框同款 ", " lossy join;note → annotations.notes)。 */
43
+ export function toAnsweredOutput(questions, answer) {
44
+ const answers = {};
45
+ const annotations = {};
46
+ for (const entry of answer.answers ?? []) {
47
+ const q = questions.find(qq => qq.header === entry.header);
48
+ const key = typeof q?.question === 'string' ? q.question : entry.header;
49
+ answers[key] = (entry.selected ?? []).join(', ');
50
+ if (entry.note)
51
+ annotations[key] = { notes: entry.note };
52
+ }
53
+ return {
54
+ type: 'ask-user-question',
55
+ questions,
56
+ answers,
57
+ ...(Object.keys(annotations).length > 0 ? { annotations } : {}),
58
+ };
59
+ }
60
+ /**
61
+ * 弹既有 AskUserQuestion overlay → 等作答 → 经 HitlBridge decide(答案/拒答)。
62
+ * decide 成功即引擎已把 run 驱到下一状态(sync);调用方随后 attach runs.events 续流。
63
+ */
64
+ async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal) {
65
+ if (!hasQuestionOverlay()) {
66
+ return { kind: 'failed', reason: 'no question overlay mounted (print/non-REPL mode)' };
67
+ }
68
+ // pending 行 = 问题 payload 的权威源 + D-1 绑定的唯一 surface(PendingCheckpoint.input)。
69
+ // REF-CC-029(单源取件)+ REF-CC-033(no_pending 判别码):findPendingForTask 是唯一取件口
70
+ // (三份近似复刻已收敛),其失败形带 code 判别位,本处原样透传给 GateOutcome ——
71
+ // 「没有待决 gate」(良性,park 重放常见)与「approvals.list 真失败」自此结构可分。
72
+ const found = await findPendingForTask(deps.client, taskId, isAskTool, signal ? { signal } : undefined);
73
+ if (!found.ok)
74
+ return { kind: 'failed', reason: found.reason, ...(found.code !== undefined ? { code: found.code } : {}) };
75
+ const { pending, gatedCallId } = found;
76
+ // 问题 payload:优先流上 tool_start.args(零额外语义),缺则 pending.input(service 已 redact + 限长)。
77
+ const fromArgs = gatedCallId
78
+ ? askArgsByCall.get(gatedCallId)?.questions
79
+ : undefined;
80
+ const fromPending = pending.input?.questions;
81
+ const questions = Array.isArray(fromArgs) && fromArgs.length > 0 ? fromArgs : fromPending;
82
+ if (!Array.isArray(questions) || questions.length === 0) {
83
+ // [1543]②:input 超引擎入参帽(MAX_TOOL_INPUT_CHARS,preview 富文本题易中)时 server 降级为
84
+ // {truncated:true} ——无题可渲不是引擎代差,是 payload 被截。分形出诚实理由(fail-soft 链
85
+ // 会把 reason 记入 SEMA_DEBUG,别再误导成 pre-CHANNEL[56] 老引擎)。
86
+ const truncated = pending.input?.truncated === true;
87
+ return {
88
+ kind: 'failed',
89
+ gatedCallId,
90
+ reason: truncated
91
+ ? 'question payload exceeded the engine input cap (input={truncated:true}) — dialog cannot be re-rendered; run stays suspended for out-of-band decide'
92
+ : 'gate has no question payload (pre-CHANNEL[56] engine?)',
93
+ };
94
+ }
95
+ // overlay 往返:合成 frame(id 独占命名空间 hitl-ask:)→ local responder 一次性收答。
96
+ const questionId = `hitl-ask:${gatedCallId ?? taskId}`;
97
+ const answer = await new Promise(resolve => {
98
+ const unregister = registerLocalQuestionResponder(questionId, async (_id, a) => {
99
+ cleanup();
100
+ resolve(a);
101
+ return { ok: true };
102
+ });
103
+ const onAbort = () => {
104
+ cleanup();
105
+ // 撤下还开着的对话框(question_complete = overlay 的既有 dismiss 语义)
106
+ publishQuestionFrame({ type: 'question_complete', questionId });
107
+ resolve(null);
108
+ };
109
+ const cleanup = () => {
110
+ unregister();
111
+ signal?.removeEventListener('abort', onAbort);
112
+ };
113
+ if (signal?.aborted) {
114
+ onAbort();
115
+ return;
116
+ }
117
+ signal?.addEventListener('abort', onAbort, { once: true });
118
+ publishQuestionFrame({ type: 'question', questionId, questions });
119
+ });
120
+ const bridge = new HitlBridge(deps.client, taskId);
121
+ if (answer === null) {
122
+ // turn 被中断(Esc/Ctrl+C):cancel-by-deny(contract/04 §2.4 —— suspended run 不 runs.cancel)。
123
+ // 件3(中断事故修复批 G,2026-07-15,症状1 壳侧配套):此前 .catch(()=>{}) 全吞 = deny 丢失时
124
+ // run 永卡 suspended,session 锁死,用户下一条消息撞 409「active run」还全无线索。改为有界观察
125
+ // (observeCancelByDeny,2s 预算):abort 仍立即返回用户控制(不 await,交互时序不变),后台
126
+ // settle 失败/超时上屏一行 warn + SEMA_DEBUG 记失败原因。引擎侧解锁腿([866] server:cancel
127
+ // suspended 改语义 + reapSuspended TTL)到货前,这是壳能做的最诚实半场。
128
+ observeCancelByDeny(bridge.decideTool({ decision: 'deny', reason: 'Interrupted by user' }, gatedCallId, undefined, pending), taskId);
129
+ return { kind: 'aborted', gatedCallId };
130
+ }
131
+ try {
132
+ // REF-CC-038(2026-08-02):`answer.answers` 已经是 `AskAnswer[]`(liveQuestionStore 的
133
+ // `QuestionAnswer` 直用 hitlBridge 的 `AskAnswer` 为唯一源)—— 不再需要靠 cast 把两个
134
+ // 名不同的等价形状缝合。
135
+ const entries = answer.answers ?? [];
136
+ const hasContent = entries.some(a => (a.selected?.length ?? 0) > 0 || (a.note?.length ?? 0) > 0);
137
+ if (hasContent) {
138
+ await bridge.answerQuestion(entries, gatedCallId, signal ? { signal } : undefined, pending);
139
+ return { kind: 'decided', gatedCallId, answered: toAnsweredOutput(questions, answer) };
140
+ }
141
+ else {
142
+ // 用户拒答(overlay reject/dismiss 送空 answers)= 诚实 deny;模型收 denied 结果自续。
143
+ await bridge.decideTool({ decision: 'deny', reason: 'User declined to answer' }, gatedCallId, signal ? { signal } : undefined, pending);
144
+ }
145
+ return { kind: 'decided', gatedCallId };
146
+ }
147
+ catch (e) {
148
+ // REF-CC-033:HitlSafetyError.code 是 hitlBridge.ts 自己的闭集契约(见其类型头注),
149
+ // 这里 instanceof 窄化后再过 isGateFailureCode 白名单——不认得的码(将来 hitlBridge 那边
150
+ // 万一多加一种)一律降级成 undefined,退回文案兜底,不让开集腐蚀这个新判别位。
151
+ const code = e instanceof HitlSafetyError && isGateFailureCode(e.code) ? e.code : undefined;
152
+ return { kind: 'failed', gatedCallId, reason: `decide failed: ${String(e)}`, ...(code !== undefined ? { code } : {}) };
153
+ }
154
+ }
155
+ /** 台账里那批 gated `tool_start` 当拍 args 快照 → 决断腿要的 `toolCallId → args` 表(UNTRUSTED 原样搬运)。 */
156
+ function argsByCallOf(led) {
157
+ return new Map(led.gatedStartArgs());
158
+ }
159
+ export async function resolvePark(park, ctx) {
160
+ const { deps, led, taskId, hops } = ctx;
161
+ let outcome;
162
+ // REF-CC-034:这次 park 的候选 callId(仅 fs 分支填,已决断身份匹配用)——ask 分支不填,
163
+ // 因为 `surfaceGateAndDecide` 的 `!pending` 早退已经自带 `code:'no_pending'`(REF-CC-033),
164
+ // 不需要这条第二判据。用 `lastFsOrShellGatedCallId()` 而非「未收口 fs call 栈」:durable
165
+ // re-attach 重放的 `suspended` park 没有配套的新 tool_start/tool_end,那个栈在这次重放之前
166
+ // 早被 tool_end 的 drop 清空了(见台账声明处注),取不到候选。
167
+ let candidateGatedCallId;
168
+ if (hops > MAX_GATE_HOPS) {
169
+ outcome = { kind: 'failed', reason: `gate hop limit (${MAX_GATE_HOPS}) exceeded` };
170
+ }
171
+ else if (park.gate === 'fs') {
172
+ candidateGatedCallId = led.lastFsOrShellGatedCallId();
173
+ // [816] 放宽腿:fs 写权限 gate → CC 三选卡(vendored PermissionRequest)→ decide。
174
+ outcome = await surfaceFsApprovalAndDecide({ client: deps.client }, taskId, argsByCallOf(led), ctx.signal);
175
+ // #51: `outcome`'s declared type is the wider `GateOutcome |
176
+ // FsApprovalOutcome`; both unions share a 'decided' kind with
177
+ // different optional fields (`answered` vs `denied`), so a plain
178
+ // `.denied` access doesn't hold for every 'decided' member even
179
+ // though this branch only ever assigns the FsApprovalOutcome shape.
180
+ // 'in' narrowing sidesteps it.
181
+ const outcomeDenied = 'denied' in outcome ? outcome.denied : undefined;
182
+ if (outcome.kind === 'decided' && outcomeDenied === true && outcome.gatedCallId) {
183
+ led.markDenied(outcome.gatedCallId); // 重放的报错帧渲 `User rejected …`(见 tool_end 有序臂 ③)
184
+ }
185
+ }
186
+ else {
187
+ outcome = await surfaceGateAndDecide(deps, taskId, argsByCallOf(led), ctx.signal);
188
+ }
189
+ // #110 缺陷② b/c —— **重放的、早已决断过的 park 不是失败**。
190
+ // durable re-attach 必然会把 park 帧再送一遍(`lastEventId` 再准也只能精确到帧,park 就在
191
+ // 重放窗口里)。这时 approval 行早已 resolved,`surfaceFsApprovalAndDecide` 取不到 pending,
192
+ // 返回 `failed: no pending checkpoint for this run (resolved/expired?)` —— 那句话的语义恰恰是
193
+ // **「这个 gate 已经被解决了」**,正确处置是**继续读流**(引擎那边 run 正常往下跑,实测
194
+ // `run.json status="completed"`),而不是宣布 turn 失败。旧行为的代价不是一句错文案:
195
+ // 终帧被投影成 `error_during_execution` ⇒ 壳走失败路径 ⇒ CC 转录**一行都不写** ⇒
196
+ // 同 session `--resume` 报 `No conversation found` ⇒ **整个会话废掉**。
197
+ // 无限循环由 `hops > MAX_GATE_HOPS` 兜底(本 reattach 照常吃 hop 预算,不绕过它)。
198
+ //
199
+ // REF-CC-034:`isAlreadyResolvedFailure` 是首选判据(code/文案双臂),但 fs 腿的
200
+ // `FsApprovalOutcome` 今天没有 `code`(toolApprovalWire.ts,C-bridge 域),失败原因若不是
201
+ // 那三个文案子串(例如 `approvals.list` 自身网络失败)就会漏判。已决断身份匹配是独立于
202
+ // 文案的第二判据 —— 两臂任一命中都按「已解决」处置,方向偏宽(见台账 `markDecided` 处注)。
203
+ //
204
+ // [2393] hitl-F2(2026-08-02):这条身份判据**一次性消费**(`takeDecided` 而不是 `isDecided`)。
205
+ // 它的正当性只覆盖「durable re-attach 把**那一次**已决断的 park 重放一遍」这一个窗口,而它的两个
206
+ // 输入都是单调的(`lastFsOrShellGatedCallId()` 故意不随 tool_end 清空 + `decidedGates` 只增),
207
+ // 不消费就等于:本 turn 只要成功决断过一次,之后**每一次** park 失败都被判「已解决」——
208
+ // `approvals.list` 网络失败这类真失败会连吃 24 个 hop,最后吐一句与真因无关的 `gate hop limit`。
209
+ // 消费点写在 `outcome.kind === 'failed'` 之内:决断成功的那一轮压根不该动这个记号。
210
+ const alreadyDecidedById = outcome.kind === 'failed' &&
211
+ candidateGatedCallId !== undefined &&
212
+ led.takeDecided(candidateGatedCallId);
213
+ if (outcome.kind === 'failed' && (isAlreadyResolvedFailure(outcome) || alreadyDecidedById)) {
214
+ const seq = led.lastSeq();
215
+ hostLog('debug', `liveHitlAskWire: gate already resolved (${outcome.reason}) — replayed park, re-attaching runs.events(${taskId})${seq ? ` from seq ${seq}` : ''} instead of failing the turn` +
216
+ (alreadyDecidedById ? ` [decidedGates id match: ${candidateGatedCallId}]` : '') +
217
+ (led.decidedCount() > 0 ? ` [decided so far: ${led.decidedCount()}]` : ''));
218
+ return { kind: 'reattach' };
219
+ }
220
+ if (outcome.kind !== 'decided') {
221
+ hostLog('debug', `liveHitlAskWire: gate not decided (${outcome.kind}${'reason' in outcome ? `: ${outcome.reason}` : ''}) — fail-soft to suspended terminal`);
222
+ const events = [...led.flushHeld()]; // 回退:毒化帧照旧渲染(= 修复前的诚实红)
223
+ if (park.pendingDone) {
224
+ events.push(park.pendingDone);
225
+ }
226
+ else if (outcome.kind === 'failed') {
227
+ // 记案(#87 评审② minor,不修):durable re-attach 的第二问(park.pendingDone 无)走 `aborted`
228
+ // 时(仅用户主动 Esc/Ctrl+C 中断触发)不吐终帧——下游正在拆流,合成终帧也没人渲;flushHeld
229
+ // 已把毒化帧诚实吐出。真正的 failed 才合成下面的可见终帧。
230
+ // durable-leg park 无 done 可回吐 —— 合成 failed 让用户看得见为什么停了
231
+ events.push({
232
+ type: 'failed',
233
+ errorCode: 'hitl_unanswered',
234
+ errorMessage: `${park.gate === 'fs' ? 'Tool approval' : 'AskUserQuestion'} gate could not be answered: ${outcome.reason}`,
235
+ });
236
+ }
237
+ return { kind: 'failsoft', events };
238
+ }
239
+ // decide 成功:丢弃该 call 的毒化 HOLD(续流重放会带 isError:false 的解答帧收口卡片),
240
+ // 并记下真实答案供该解答帧 stamp `structured`(否则卡片渲成结果不可用)。
241
+ if (outcome.gatedCallId) {
242
+ led.dropHeld(outcome.gatedCallId);
243
+ led.markDecided(outcome.gatedCallId); // #110 缺陷② b / REF-CC-034:身份留痕,上方候选匹配消费
244
+ if ('answered' in outcome && outcome.answered)
245
+ led.rememberAnswer(outcome.gatedCallId, outcome.answered);
246
+ }
247
+ const seq = led.lastSeq();
248
+ hostLog('debug', `liveHitlAskWire: gate decided (call ${outcome.gatedCallId ?? '?'}) — attaching runs.events(${taskId})${seq ? ` from seq ${seq}` : ''}`);
249
+ return { kind: 'reattach' };
250
+ }
@@ -1,12 +1,70 @@
1
+ /**
2
+ * ⇄ B7 批搬迁(2026-07-27,设计稿 §3 B7):cli `src/sema/planReviewWire.ts`(142 行)**整条进包**。
3
+ *
4
+ * 🔴 **`wireConfig()` 第七份收编闭合**:B6 的 `engineWireTarget.ts` 头注记着「本批搬入的是前六份;
5
+ * 第七份 `planReviewWire` 不在 B6 范围,仍留壳内 —— 它是下一批的收编候选,别以为已经收干净了」。
6
+ * 本批就是那一批:这里改用 `engineWireTarget()`,壳树 `grep -rn 'function wireConfig' src/sema/`
7
+ * 的七份手抄至此**归零**(pure 门 B7 段有断言:壳树在场时命中数 = 0;缺席 ⇒ DEGRADED)。
8
+ *
9
+ * 🔴 其余搬迁差分(零行为变化):
10
+ * 1. `logForDebugging` → `hostLog`。
11
+ * 2. `await import('../utils/messageQueueManager.js').enqueuePendingNotification({...})`
12
+ * → `NotificationQueuePort.enqueueMetaPrompt`(B7 新增的**可选**方法 —— 既有宿主实现不破)。
13
+ * 🔴 缺席后果不是「少个装饰」:模型永远不知道 plan 被批准/驳回后引擎跑出了什么结果,
14
+ * 于是它在 approve 之后**原地不动**。所以缺席计 miss(`notificationQueuePortMisses()`),
15
+ * 并保留壳原文的 try/catch fail-soft(投递失败绝不炸流)。
16
+ * 3. `registerLocalQuestionResponder`/`publishQuestionFrame` → 包内 `liveQuestionStore`
17
+ * (B1 已搬,**同一个 module 台账**;两份实例 ⇒ 卡弹出来没人收答)。
18
+ *
19
+ * ── 以下为原文件的领域说明(逐字保留)────────────────────────────────────────────────────────
20
+ *
21
+ * planReviewWire — plan_review park 的壳侧审批闭环(core [508]③ 形状拍板 + [511]② 实证)。
22
+ *
23
+ * WIRE 全链(server 1.138 × core 1.257.4 探针逐字实证,probe-plan-final-frame.mjs):
24
+ * 1. plan-mode turn 里模型调 ExitPlanMode → 引擎 park:done 帧 `status:"needs_review"` +
25
+ * `checkpointGate:{kind:"plan_review",reason:"ExitPlanMode"}` + `errorCode:"review.pending"`;
26
+ * plan 文本已由 ExitPlanMode tool_end 渲染在转录(fence 块),checkpoint 落 FileCheckpointStore。
27
+ * 2. 壳(本模块,runStream done 分支调用)合成一个 QuestionFrame 借 AskUserQuestion overlay 弹
28
+ * 审批卡(core 拍板:不新造 live question 帧,壳自绘)——合成 id `plan-review:<taskId>` 经
29
+ * liveQuestionStore 的 LOCAL responder 拦截,绝不会打到引擎的 /v1/questions(那里 404)。
30
+ * 3. 决断 → POST /v1/assistant/tasks/:taskId/plan_review `{decision:"approve"|"reject"}`——
31
+ * resume 是 SYNC 驱动到终态的(实证 200 `{taskId,sessionId,status:"completed"}`,可能分钟级),
32
+ * 所以 fire 后卡片即关,完成后经 enqueuePendingNotification 入队一个 isMeta turn:模型收到
33
+ * resume 终态,自然接续汇报(approve 后"继续干活"的 CC 体验由引擎侧 resume 已完成,这里是
34
+ * 结果呈现)。reject 同构(引擎丢 plan 保持 plan 语境)。
35
+ *
36
+ * 挂账(诚实边界):approve 后壳本地的 plan-mode footer 徽章不自动退(CC 会退)——退 mode 的
37
+ * app-state seam 不在本模块可达面,记 rc.47 接 REPL 层;engine 侧 handsReadOnly 已由 resume 处理。
38
+ */
39
+ import { type QuestionAnswer } from '../liveQuestionStore.js';
40
+ /**
41
+ * REF-CC-026:此前是 module-private 常量,唯一另一个消费者(cli `planReviewReopen.ts`)只能靠
42
+ * 人眼手抄同步(头注写着「与 client-core planReviewWire 的原卡逐字一致」)——漂一个字节,判决就
43
+ * 落到危险的默认臂(见下方 `planReviewDecisionFromAnswer` 的头注)。现在导出成唯一真源;cli 侧改
44
+ * import 是后续跟车工单(壳仓文件域,不在本卡范围)。
45
+ */
46
+ export declare const PLAN_REVIEW_APPROVE_LABEL = "Yes, approve and run the plan";
47
+ export declare const PLAN_REVIEW_REJECT_LABEL = "No, reject it (keep planning)";
48
+ /**
49
+ * REF-CC-026:三值判决(此前 `selected === APPROVE_LABEL ? 'approve' : 'reject'` 把「没读懂/没
50
+ * 选」的第三种输入静默塌缩进危险的 reject 臂——overlay 的 dismiss/拒答语义是「送空 answers」,
51
+ * 与用户真的点了 REJECT_LABEL 长得一模一样)。两个标签都做**显式**等值比对,任何第三种输入
52
+ * (含 undefined)落 `'dismissed'`,由调用方决定是保持 park 还是显式当 reject 处理——本函数只
53
+ * 负责如实分类,不替调用方做选择。
54
+ */
55
+ export declare function planReviewDecisionFromAnswer(answer: QuestionAnswer | null | undefined): 'approve' | 'reject' | 'dismissed';
1
56
  /** done 帧的 plan_review park 形状(结构性读,别的终态一律 false)。 */
2
57
  export declare function isPlanReviewPark(result: unknown): result is {
3
58
  taskId: string;
4
59
  sessionId?: string;
5
60
  };
61
+ /** 测试钩:清掉武装态(不动 liveQuestionStore 本身,那边有自己的 reset)。 */
62
+ export declare function _resetArmedPlanReviewsForTest(): void;
6
63
  /**
7
64
  * Arm the approval card for a parked plan_review (call with the RAW done.result). Returns true when
8
- * armed (live path + shape matched); false = caller renders the terminal as usual. Fail-soft: any
9
- * error inside must never break the stream drain.
65
+ * armed (live path + shape matched, INCLUDING the idempotent "already armed" short-circuit — see
66
+ * REF-CC-027 above); false = caller renders the terminal as usual. Fail-soft: any error inside must
67
+ * never break the stream drain.
10
68
  */
11
69
  export declare function armPlanReviewApproval(result: unknown): boolean;
12
70
  /** POST the decision; on settle enqueue an isMeta turn so the model reports the resume outcome.