/** * [ref] 场景 hands lane —— 「要手的场景」与「无手的场景」在装配层分家。 * * 根本问题(设计小票 §1,案源 [ref]/[ref]/[ref]):core 的手带工具面是 **per-Runner 表态** * (`prepare-task`:`handsEnabled = ownedEnv || deps.executionEnv`,ownedEnv 只要挂了 * `executionEnvFactory` 就每任务必铸)。server 此前全场景共享一只 runner + 一只 subRunner, * 于是任何 REMOTE_EXEC 部署里,声明 clone-free 的 `scan`、只有 `run_council` 的评审团、 * 零工具人格的 team 成员,最终 roster 都被并集进全量可写手带(Bash/Edit/Write/…)。 * * 本模块提供三件: * ① {@link ScenarioHands} 判别位 + {@link pickHandsRunner} 的**穷举** switch —— 新场景不表态 = 编译红, * 不是静默继承 full(词表闭集纪律)。 * ② {@link HANDS_EXCLUSIVE_TOOL_NAMES} + {@link handsDenyPolicy} —— 纵深:hands=none 的任务即便被误接到 * 有手 Runner 上,手独占名单逐一 deny。**不是**主保证(主保证是根本不挂 factory ⇒ 工具面不可见)。 * ③ {@link stampHandsLane} / {@link handsLaneOf} —— 车道作为 TaskSpec 上的**显式字段**(S-149): * resolveSpec 出口铸一次,执行点读一次,**缺席即拒**(不是回落最宽车道)。 */ import { type Runner, type TaskSpec, type ToolPolicy } from "@sema-agent/core"; /** 场景对手带工具面的表态(闭集)。`full` = core 手带 band 照常 mount;`none` = 不挂 executionEnvFactory 的 Runner。 */ export type ScenarioHands = "full" | "none"; /** * 无手孪生 Runner 的 deps 构造:把 `executionEnvFactory` **摘键**(不是置 undefined)。 * 摘键让「这只 Runner 没有手」在 deps 字面量上自证;置 undefined 留下一个值为 undefined 的键, * 与 core 现在的 `deps.executionEnvFactory ||` 判读结果相同,但把正确性寄托在真值判断上。 */ export declare function withoutExecutionEnv(deps: T): Omit; /** 一对 Runner:同 deps,唯一差别是 `handsless` 不挂 `executionEnvFactory`。 */ export interface HandsRunnerPair { full: Runner; handsless: Runner; } /** 按场景表态取 Runner。**穷举 switch**:`ScenarioHands` 加词而此处不改 = 编译错(无 default 臂)。 */ export declare function pickHandsRunner(hands: ScenarioHands, pair: HandsRunnerPair): Runner; /** * core 手带 band 的工具名**全集** —— 「挂 executionEnvFactory 才出现、不挂就消失」的那一批。 * * 两段来源,都不是拍脑袋: * · `handBandEffects()`(core 7.8.0 B1 起的导出面;此前是手抄表常量 `HAND_TOOL_EFFECTS`)—— band 的 * effect 表,由**静态工具目录**按 `mountedBy.includes("hands")` 派生。名册单源之后这张表不再是一张 * 手抄名表,加/退工具由目录一处说了算。 * · 下方锚定补集 —— `createHandsToolkit` / 手带块还会铸但**不在** effect 表里的 5 名(实测差集,见 * test/scenario-hands-lane.test.ts 的对表格:它用真 Runner 跑「有手 roster ∖ 无手 roster」,与本常量逐名对账, * core 加/改 band 成员时红,防漂移)。 * * ⚠️ 本全集是**观测口径**(用于防漂移对账),**不是** deny 名单 —— deny 用下面的 * {@link HANDS_EXCLUSIVE_TOOL_NAMES},理由见那里。 * * 🚨 [ref] P0(2026-08-09):派生时必须过滤 core 的 `RETIRED_TOOL_NAMES` —— core 5.21.0 的 * `HAND_TOOL_EFFECTS` 里残留已退役的 `MultiEdit`,而 core `prepare-task` 的策略名审计见退役名 * **无条件 throw**(`config.legacy_tool_name`);不过滤,deny 名单一进 `spec.toolPolicy`, * `hands: none` 三场景(scan/code-review/team)在**所有部署**上零 turn 全灭。core 5.21.1 已删 * 那条死条目([ref]),这道 filter 是结构性防御:将来 core 再退役任何 band 名都不会重演 * (test/scenario-hands-lane.test.ts 格7/格8 双钉:真 Runner 过审计 + 零交集门)。 * filter 打在**整表**(effect 表 + 锚定补集)之后 —— caret floor 下装置会解析到更新的 core, * 补集名哪天被退役,只滤前半就是同一次故障换个名字(codex 复审 finding,2026-08-09)。 */ export declare const HANDS_BAND_TOOL_NAMES: readonly string[]; /** * band 里**只有手才可能出现**的那批(= deny 名单)。 * * 🔴 codex 复审(2026-08-09 finding-3,亲验 core prepare-task):band 全集里有四件是**两用**的 —— * `prepare-task` 的挂载条件是 `if (backgroundTaskToolsActive || workflowToolsActive)`,而 * `workflowToolsActive` 只要 `spec.selfOrchestration`(caller 可设,与场景正交)+ 部署的 workflow deps * 就成立,**与手无关**。拿全集去 deny,会让「hands=none 场景 + selfOrchestration:true」的请求挂上 * `Workflow` 却读不到它的轮询/停止/消息/转录 —— 一个半瘫的工作流面,比不给还糟。 * 故 deny 名单 = 全集 ∖ 两用四件。Monitor 不在两用之列(core 的挂载条件是纯 * `if (backgroundTaskToolsActive)`,而它 = handsIncludeShell ∧ …,无手不成立)。 */ export declare const HANDS_DUAL_USE_TOOL_NAMES: readonly string[]; export declare const HANDS_EXCLUSIVE_TOOL_NAMES: readonly string[]; /** 手独占名单的 deny 策略(纵深层)。名单是常量 ⇒ 每次调用产出等价策略,构造成本可忽略。 */ export declare function handsDenyPolicy(): ToolPolicy; /** * 把 band deny 叠到既有基线上 —— **tighten-only**。 * * 🪤 TRAP #1(runtime-governance.ts 头注逐字):`TaskSpec.toolPolicy` 是**覆盖**而非合并 * (`spec.toolPolicy ?? deps.toolPolicy`),直接赋值会静默丢掉部署的 durable 审批基线 / * 单用户 auto-accept 座。走 `combinePolicies(base, deny)`(deny 胜、ask 不被 allow 冲掉), * 只会加 deny,永不放松。base 缺席时单挂 deny —— 该形下 band 本就不 mount,core 的 * "write-capable hand tools are present but UNGATED" 信号不受影响(它以 Write 在场为条件)。 */ export declare function tightenWithHandsDeny(base: ToolPolicy | undefined): ToolPolicy; /** 装配点的取用形:`full` 原样放行基线,`none` 叠 deny。**穷举 switch**,无缺省臂。 */ export declare function toolPolicyForHands(hands: ScenarioHands, base: ToolPolicy | undefined): ToolPolicy | undefined; /** * 执行车道在 TaskSpec 上的**显式字段键**(S-149;修前是一张以 spec **对象身份**为键的 WeakMap 旁表)。 * * 为什么是 symbol 而不是字符串键:`TaskSpec` 是**上游 core 的类型面**,一个字符串键会进 * `Object.keys` / `JSON.stringify` / core 自己的键审计,等于 server 在别人的契约上私自加一个键; * symbol 键随 spread(`{...spec}`)与 rest 解构(`const {objective, ...taskConfig} = spec`)结构性 * 地跟着走,却对上游的键面**不可见**(结构化克隆与 JSON 同样跳过 symbol 键)。于是「车道是 spec 上 * 的一个字段、复制 spec 不丢车道」与「不污染上游契约」两件事同时成立。 * * 修前的旁表要求「从 resolveSpec 出来的那一只对象必须一路原样活到执行点」——一条**隐形**的对象身份 * 契约:任何一次 `{...spec}` 都会静默把车道降级成最宽的 `full`(实测:登记 `none` 的 spec 复制后 * `laneOf` 答 `full`)。字段化把那条隐形要求整条删掉。 */ export declare const HANDS_LANE_FIELD: unique symbol; /** 已铸车道的 TaskSpec。铸点唯一(`boot/resolve-spec.ts` 的 resolveSpec 出口),读点唯一({@link handsLaneOf})。 */ export type LanedTaskSpec = TaskSpec & { readonly [HANDS_LANE_FIELD]: ScenarioHands; }; /** * 铸车道(**单一属主**:只允许 resolveSpec 的出口调用)。 * * 字段是 `enumerable`(所以复制得走)、`writable:false` + `configurable:false`(所以同一只 spec 被 * 第二次铸车道会当场 TypeError —— 两个写者是漂移的成因,响亮好过后到者悄悄赢)。 */ export declare function stampHandsLane(spec: T, hands: ScenarioHands): T & LanedTaskSpec; /** * 车道字段缺席的拒因 —— 具名类,便于调用点/测试按类型判,而不是嗅文案。 * * 🔴 **刻意不是 `HttpError`、刻意不铸新的 wire 错误码**:它落到 `http/server.ts` 的兜底 catch, * wire 上是 500 `internal.error`(内部文本不回显,只进 `request_error` 日志行)。理由两条: * ① 对**消费端**它与任何一次服务端内部故障无差别可操作性(重试一次,仍复现即报运维)—— 附录 A 多一个 * 码却给不出不同的动作,是没有价值的契约面; * ② 本仓的中继码登记表(`test/error-code-catalog-live.test.ts` 的 `RELAYED`,上限 24 条)明写「超了说明 * 『码不写字面量』正在变成默认写法,该反过来改代码」—— 一个只能靠登记才看得见的新码正是它拦的东西。 * 需要分辨的是**运维**,而运维读的是日志行(本 message 逐字在 `request_error` 里)。 */ export declare class HandsLaneUnstampedError extends Error { constructor(); } /** * 读车道 —— **缺席即拒**([ref] P 类:车道选择是保护型量,fail-closed;`observability/fail-open.ts` * 头注把「车道选择」逐字列在「正解是 fail-closed,不是留痕后继续放行」那一族里)。 * * 修前此处是 `?? "full"`:一只没经过本仓 resolver 的 spec 会**静默拿到最宽的车道**(全量可写手带)。 * 「唯一可达形是测试桩」这个论证不是防线 —— 它是一句关于今天调用图的断言,而防线要防的正是明天多出 * 来的第二个执行点。缺席现在整只拒(500 + 日志留原句),而不是把一个未经裁定的任务放进有手车道。 */ export declare function handsLaneOf(spec: TaskSpec): ScenarioHands; /** * 装配点的选路席:`{full, handsless}` 两只 Runner + 车道字段 ⇒ 本任务跑哪一只。 * * 具名工厂而不是在 main.ts 手搓闭包:选路规则(读字段 → 穷举 switch)于是只有**一份实现**, * 测试与生产装配拿的是同一只(手搓等价闭包 = 第二真源,正是本件要删的那一类)。 */ export declare function createHandsRunnerSelector(pair: HandsRunnerPair): (spec: TaskSpec) => Runner; //# sourceMappingURL=hands-lane.d.ts.map