@sema-agent/server 7.6.0 → 7.7.1

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/USAGE.md CHANGED
@@ -332,7 +332,7 @@ QUESTION_TTL_MS=300000
332
332
  | `sessionId` | — | 续聊:带上次返回的 `sessionId`,服务端自动 wake 历史 |
333
333
  | `images` | — | 图文输入 `[{data,mimeType}|{url}]`(模型需支持 vision) |
334
334
  | `attachmentIds` | — | D-1 通用文件上传(1.289+):先 `POST /v1/attachments?name=…`(raw body,content-type=mime)拿句柄,提交时引用 ≤16 个;文件物化到执行环境工作目录 `attachments/` 下,objective 尾部自动追加文件清单(内容不进会话流)。单文件缺省 ≤32 MiB(`ATTACHMENT_MAX_BYTES`);可配 mime 白名单(`ATTACHMENT_MIME_ALLOWLIST` CSV,缺省不限);上传后未引用的按 `ATTACHMENT_UNBOUND_TTL_MS`(缺省 24h)回收。**云形态(tidb/pg)字节本体存对象存储——MinIO 必配**(`MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY`,与快照 lane 同一组变量),未配则附件面 501;local 形走本地文件店。 |
335
- | `scenario` | — | `default`(默认)/ `code-review`(见 §5)/ `scan`(同 §5 的 repo 只读工具但**中性无框架提示词**——objective+中心下发 skill 全权主导输出,OA 扫描类用)/ **配置控制面可声明任意新场景**(`{name, toolset: none\|repo-readonly, prompt?}`,组合即配置、能力钉死在部署;restart-to-apply;center 可覆盖内建名,boot 日志 `config_center_scenarios.shadowsBuiltin` 可审计) |
335
+ | `scenario` | — | `default`(默认)/ `code-review`(见 §5)/ `scan`(同 §5 的 repo 只读工具但**中性无框架提示词**——objective+中心下发 skill 全权主导输出,OA 扫描类用;与 `code-review`/`team` 同属 clone-free 无执行环境场景,见 §5 末)/ **配置控制面可声明任意新场景**(`{name, toolset: none\|repo-readonly, prompt?}`,组合即配置、能力钉死在部署;restart-to-apply;center 可覆盖内建名,boot 日志 `config_center_scenarios.shadowsBuiltin` 可审计) |
336
336
  | `repo` / `council` / `debate` | — | `repo` 为 `code-review`/`scan` 必填;`council`/`debate` 仅 `code-review`,见 §5 |
337
337
  | ~~model / tools / prompt~~ | 🚫 | **不接受**——服务端注入 |
338
338
 
@@ -409,6 +409,22 @@ curl -s http://<host>:8090/v1/runs/<taskId> -H 'x-agent-principal: user:42' #
409
409
  ```
410
410
  服务端需配 `GIT_API_BASEURL` + `GIT_API_TOKEN`(只读,服务端持有);缺则 501,缺 `repo` 则 400。建议走异步 `/v1/runs`(council/debate 几分钟级)。
411
411
 
412
+ > **`GIT_API_BASEURL` 写法**:必须是**含 scheme 的完整 baseURL**(如 `https://git.example.com`),
413
+ > 不是裸主机名——服务端直接拼 `${GIT_API_BASEURL}/api/v1/...` 发请求,裸 `git.example.com` 会拼出非法 URL 而 fetch 失败。
414
+ > 末尾不要带 `/`,也不要把 `/api/v1` 写进来。
415
+
416
+ > **升级注记(7.7.0)**:本版起 `code-review` / `scan` / `team` / `toolset: repo-readonly|none` 的场景改跑
417
+ > 无执行环境的引擎。**升级前**若还有这些场景的 durable 挂起任务(需同时满足:配了 `REMOTE_EXEC`、开了
418
+ > `DURABLE_APPROVAL`、任务已 park),它们在新版上续跑会以 409 `checkpoint.unsupported_version` 响亮失败
419
+ > (run 行落 failed 并释放会话,不会占住会话)——重新提交该任务即可。升级前排空这类挂起任务可完全避免。
420
+
421
+ **clone-free 承诺(`code-review` / `scan`)**:这两个场景(含 `council`/`debate` 的镜头与仲裁子任务、以及
422
+ 配置控制面用 `toolset: repo-readonly` / `none` 声明的场景)一律跑在**不带执行环境的引擎**上——
423
+ 模型看到的工具面只有声明的只读仓库工具(`repo_tree` / `repo_read_file` / `repo_pull_diff`)加 `Now`,
424
+ **没有** `Bash` / `Edit` / `Write` / `Read` 等落盘工具,不 clone、不写盘、不起沙箱。
425
+ 即使部署配了 `REMOTE_EXEC`(host/e2b/k8s/ssh/adb/local-docker)也如此:执行环境只属于 `default` / `code`
426
+ 这类需要动手的场景。`team` 场景同理(协调者只调 `run_team`,成员是纯讨论人格)。
427
+
412
428
  ## 6. 逐 token 直播(同步流式,连接挂着)
413
429
  ```bash
414
430
  curl -N http://<host>:8090/v1/tasks/stream -H 'content-type: application/json' \
@@ -423,46 +439,92 @@ curl -N http://<host>:8090/v1/tasks/stream -H 'content-type: application/json' \
423
439
  | `GET /v1/sessions/<id>` | 审计回溯:当前上下文 + 摘要(owner 校验) |
424
440
  | `GET /v1/approvals?owner=user:42` | 高危写审批(durable,需 `DURABLE_APPROVAL=true`):**operator** 看待办队列(可按 owner 过滤);非 operator 只看自己的 |
425
441
  | `POST /v1/approvals/<sessionId>/decide` `{"decision":"approve"|"deny","reason":"…"}` | 批/否并恢复挂起任务(CAS,重复决议 409);**仅 operator**(非 operator → 403)。5.0.0 起旧轮询腿 `POST /v1/approvals/<id>` 已退役 |
442
+ | `GET /v1/capabilities/scenarios/<name>` | 单个场景的只读详情:工具面、提示词概览、**本部署现在跑不跑得动**(见下) |
426
443
  | `GET /health` | 健康(无需鉴权) |
427
444
  | `GET /metrics` | Prometheus 指标(有 token 时需带) |
428
445
 
446
+ > **场景可用性(7.7.0 起)**:场景详情里 `enabled` 与 `available` 是**两件事**。`enabled` = 这条场景
447
+ > **被声明**了(内建恒 true);`available` = **本部署现在真跑得动**——后端依赖到位没有。不可用时另带一个
448
+ > **机读**原因键 `unavailableReason`(当前唯一取值 `git_client_unconfigured` = 没配 `GIT_API_BASEURL`,
449
+ > 命中 `scan` / `code-review` 这类只读仓库场景)。契约:`available:false` **⟺** 真发一次该场景的请求会
450
+ > 吃 **501**;`available:true` 时 `unavailableReason` **整键缺席**(缺席 = 没有理由,别读成空串)。
451
+ > 两面共用同一份判据,所以列表上画得出的场景点下去不会再突然 501。请按 `unavailableReason` 的**键**分支,
452
+ > 不要去匹配英文文案——文案会改,键不会(新增取值只会追加,现役键不改语义)。
453
+
429
454
  > **operator 鉴权(审批队列)**:`OPERATOR_PRINCIPALS=ops:alice,ops:bob`(CSV)= 谁能当 operator——列任意 owner 待办 + 决议(批/否)。**单租户部署不设=旧行为**(握 service token 即 operator,向后兼容);设了之后,非名单 principal 列待办只看自己的、且**不能决议**(403,防"请求方批自己的高危操作"绕过 F4 闸)。⚠️ **多租户形拒启**(#157-①):`DURABLE_APPROVAL=true` + `REQUIRE_PRINCIPAL=true` 而 `OPERATOR_PRINCIPALS` 空 ⇒ 进程启动失败并点名修法——否则空名单会让任一已验证租户读到其他租户的待批队列(读面 true-for-all)。设名单,或确属单租户则不设 `REQUIRE_PRINCIPAL`。
430
455
  >
431
- > **⚠️ 引擎 core 5.19.0 起:hook-wired 部署里,parked 后台子代赎回不了(常态,不是升级窗口)。**
432
- > 5.19.0 让一个任务的 **PreToolUse screening 面下延管辖它委派出去的子代**,于是 hook-wired 父派出的
433
- > 子代 park 时,checkpoint 记的祖先约束层数是 **2**(screening 席 + 父自己的策略席);而本服务的赎回腿
434
- > 重建得出的只有 **1** 层。引擎按**层数**做 pre-CAS 校验 ⇒ 每次赎回都被响亮拒
456
+ > **⚠️ hook-wired 部署里,parked 后台子代可能赎回不了(常态,不是升级窗口)。**
457
+ > 引擎 5.19.0 起,一个任务的 **PreToolUse screening 面下延管辖它委派出去的子代**,于是 hook-wired
458
+ > 派出的子代 park 时,checkpoint 记的祖先约束层数是 **2**(screening 席 + 父自己的策略席);而本服务的
459
+ > 赎回腿重建得出的只有 **1** 层。引擎按**层数**做 pre-CAS 校验 ⇒ 每次赎回都被响亮拒
435
460
  > (`resume.parent_constraint_mismatch`,checkpoint **保持 pending 不被消费**,不静默降级成更松的链)。
436
461
  >
437
- > **判断本部署在不在射程内**(任一为真即在):① `TOOL_TRACE=true` —— 它装的诊断 tracer 自带 PreToolUse 面,
438
- > **哪怕它一条裁决都不出**(纯观察者),引擎的判据是「席位在不在」而不是「它说了什么」;
439
- > 调用方提交里带 `settings.hooks.PreToolUse`(未开 `REQUIRE_PRINCIPAL` 的部署对外开放此面)。
440
- > 两条都不沾的缺省部署**逐字旧行为**,完全不受影响。
462
+ > **7.7.0(引擎 5.20.0) `TOOL_TRACE` 已退出射程。** 引擎 5.20.0 新增「这条 PreToolUse 面只观察、
463
+ > 不裁决」的声明口,本服务的诊断 tracer(`TOOL_TRACE=true` 装的那只,恒不出判词)已按实声明 ⇒ 它**不再
464
+ > 铸筛查席**,该部署的层数回到 1、赎回照常通。**开着 `TOOL_TRACE` 不再需要在「诊断」与「后台子代能不能
465
+ > 赎回」之间二选一。**
441
466
  >
442
- > **精确的兼容矩阵**(本服务恒供 1 层;引擎只比层数,所以下表就是全部情形。已在 5.18.1 与 5.19.0 两个
443
- > 引擎上实测过):
467
+ > 📎 连带的一处**日志**变化(不是回归):引擎那行「可写工具面没有 effect-aware 门」的启动告警,此前把
468
+ > 诊断 tracer 当成一道门而被抑制;声明之后不再抑制,所以「没配任何工具策略层 + 开着 `TOOL_TRACE`」的
469
+ > 部署升级后会多出那行告警。tracer 从来没有门住任何东西,原先的抑制本身才是问题。
444
470
  >
445
- > | 挂起的是谁 | checkpoint 是哪版铸的 | 父是否 hook-wired | 行里记的层数 | 本服务供的层数 | 结果 |
471
+ > **仍在射程内的只剩一条**:调用方提交里带 `settings.hooks.PreToolUse`(未开 `REQUIRE_PRINCIPAL` 的部署
472
+ > 对外开放此面)。那是**会真裁决**的面(出得来 deny/ask),按契约**不能**打观察标——打了等于让引擎把
473
+ > 调用方的判词静默丢弃,比拒绝本身坏得多。这条面上的席位是每请求闭包、park 时未持久化,跨副本重建不出,
474
+ > 所以拒绝仍是正确行为。不带该字段的部署**完全不受影响**。
475
+ >
476
+ > **精确的兼容矩阵**(本服务恒供 1 层;引擎只比层数,所以下表就是全部情形。5.18.1 / 5.19.0 / 5.20.0 三个
477
+ > 引擎上都实测过):
478
+ >
479
+ > | 挂起的是谁 | checkpoint 是哪版铸的 | 父有没有**会裁决**的 PreToolUse 面 | 行里记的层数 | 本服务供的层数 | 结果 |
446
480
  > | --- | --- | --- | --- | --- | --- |
447
- > | **第一代**子代 | ≤ 5.18.1 | | 1 | 1 | ✅ 照常赎回 |
448
- > | **第一代**子代 | ≤ 5.18.1 | **是** | 1 | 1 | ✅ 照常赎回 —— **升级本身不会弄坏存量行** |
481
+ > | **第一代**子代 | ≤ 5.18.1 | 任意 | 1 | 1 | ✅ 照常赎回 —— **升级本身不会弄坏存量行** |
449
482
  > | **第一代**子代 | 5.19.0 | 否 | 1 | 1 | ✅ 照常赎回 |
450
- > | **第一代**子代 | 5.19.0 | **是** | 2 | 1 | ❌ 永久拒(见下「没有恢复路径」) |
451
- > | **嵌套**(孙代及更深) | 任意 | 任意 | **≥2** | 1 | 永久拒(5.19.0 之前就如此,本版无变化) |
483
+ > | **第一代**子代 | 5.19.0 | **是**,或**只是开了 `TOOL_TRACE`** | 2 | 1 | ❌ 永久拒(见下「没有恢复路径」) |
484
+ > | **第一代**子代 | 5.20.0 | 否(含只开 `TOOL_TRACE`) | 1 | 1 | 照常赎回 |
485
+ > | **第一代**子代 | ≥ 5.20.0 | **是**(调用方 `settings.hooks.PreToolUse`) | 2 | 1 | ❌ 永久拒 |
486
+ > | **嵌套**(孙代及更深) | 任意 | 任意 | **≥2** | 1 | ❌ 永久拒(5.19.0 之前就如此,历版无变化) |
452
487
  >
453
- > ⚠️ 嵌套那一行**不是恒等于 2**:引擎的子代链是「继承来的整条 + ( hook 就加一席) + 自己那一层」逐层
454
- > 追加,所以嵌套与 hook 叠加时记的层数会**超过** 2(有钉实测:`test/parked-revive-e2e.test.ts` 的计数锚)。
488
+ > ⚠️ 嵌套那一行**不是恒等于 2**:引擎的子代链是「继承来的整条 + (有会裁决的 hook 就加一席) + 自己那一层」
489
+ > 逐层追加,所以嵌套与 hook 叠加时记的层数会**超过** 2(有钉实测:`test/parked-revive-e2e.test.ts` 的计数锚)。
455
490
  > 对本服务而言结论一样(供 1,任何 ≥2 都拒),但**别把错误文案里的那个数字当成层深的可靠读数**。
456
491
  >
457
492
  > ⇒ **纠正一个容易想当然的说法**:上游 CHANGELOG 写的「升级前后跨版本 drain」对本服务**不是硬要求**——
458
493
  > 旧行记的就是 1、我们供的也是 1,升级方向不产生错配(反向回滚同理)。滚版前把 `GET /v1/approvals` 排空
459
494
  > 仍是好习惯(减少活过开关切换的行),但它**解决不了**下面这条。
460
495
  >
461
- > **没有恢复路径,只有预防旋钮。** 层数是 park 那一刻**写死进 checkpoint** 的:事后再批一次、事后关掉
462
- > `TOOL_TRACE`、事后回滚引擎版本,都不改行里记的 2 也不改我们供的 1 ⇒ **已经搁浅的行赎回不回来**
463
- > (它们保持 pending 直到 TTL/reap;那次操作只能作为**新任务**重跑)。**预防旋钮 = 关掉 `TOOL_TRACE`**
464
- > (它本就是 default-OFF 的诊断面)**,或在开着 PreToolUse hooks 的部署上不依赖后台子代的 durable 审批**
465
- > —— 只对**此后**新铸的行生效。
496
+ > **没有恢复路径,只有预防旋钮。** 层数是 park 那一刻**写死进 checkpoint** 的:事后再批一次、事后改配置、
497
+ > 事后升级或回滚引擎版本,都不改行里记的 2 也不改我们供的 1 ⇒ **已经搁浅的行赎回不回来**
498
+ > (它们保持 pending 直到 TTL/reap;那次操作只能作为**新任务**重跑)
499
+ > **这条对 7.6.0(引擎 5.19.0)期间开着 `TOOL_TRACE` 铸下的行同样成立**:升到 7.7.0 只让**此后**新铸的行
500
+ > 回到 1 层,那批老行仍记着 2,批不动——请把它们当作**新任务**重跑,或等 TTL/reap 收走。
501
+ > 今天仍有效的预防旋钮 = **在开放 `settings.hooks.PreToolUse` 的部署上不依赖后台子代的 durable 审批**
502
+ > (或用 `REQUIRE_PRINCIPAL` 关掉该面),同样只对**此后**新铸的行生效。
503
+ >
504
+ > **🔧 升级到 7.7.0(引擎 core 5.20.0)前:检查数值旋钮的写法。** 5.20.0 把「坏数值旋钮被接受、然后
505
+ > 悄悄做**相反**的事」这一类全部改成响亮拒或响亮钳位。两条与运维直接相关:
506
+ >
507
+ > - **写成 `1e9` / `1_800_000` 的毫秒旋钮会跳回字面值。** 引擎自己读的那五个 MCP **毫秒**旋钮
508
+ > (`MCP_TOOL_TIMEOUT`、`MCP_TOOL_TIMEOUT_TOTAL`、`MCP_IDLE_TIMEOUT_STDIO`、`MCP_IDLE_TIMEOUT_HTTP`、
509
+ > `MCP_TIMEOUT`)此前是 `parseInt` 语义:`1e9` 实际生效成 **1 毫秒**,`30s` 生效成 30 毫秒。升级后它们
510
+ > 按**字面值**生效并钳进 `[1000, 2147483647]` **毫秒**。
511
+ > ⛔ **恰恰是这两种迁移写法不会有任何告警**(亲读引擎 `parseEnvMs` 确认):`1e9` / `1_800_000` 升级后
512
+ > 是**合法且在区间内**的值,于是既不钳位也不告警——旧部署上「1 毫秒」会**静默**跳成十亿毫秒 / 三十分钟。
513
+ > 告警只在两种情形打:值**读不成数**(整条忽略、回落内置默认)或**越出区间**(钳位后点名)。而且解析发生在
514
+ > **首次真用到该 MCP 设置**时,不是进程启动时——所以「启动没看到告警」不代表没变。
515
+ > ⇒ **升级前逐个人工核对这五个值,把非纯数字的写法改成纯数字**;别指望日志替你发现。
516
+ > ⚠️ `MAX_MCP_OUTPUT_TOKENS` **不是毫秒旋钮**,别按上面那个区间去改它:它是 **token 数**,本次只是
517
+ > 换用同一套数字文法(此前 `1e5` 被读成 4;换文法后按字面值),**取值范围照旧不设上限**。
518
+ > 上面这组旋钮由**引擎**直接读 `process.env`,本服务不经手。本服务自己解析的数值旋钮走的是另一条
519
+ > 判据(不受本次变更影响):承重旋钮**非数字即启动失败并点名**、越界即启动失败,少数被显式标成
520
+ > fail-safe 的可选旋钮回落默认值并打一行 warn。
521
+ > - **坏的 retention 旋钮升级后是「拒绝」,不是「清洗」。** 引擎的 `reap` 家族
522
+ > (后台代理行 / 信箱 / workflow run / 两个花名册店)对**非有限或负**的界改抛 `config.retention_policy_invalid`。
523
+ > 症状是**每一次 reap 都抛、行只进不出**(此前 `NaN` 会塌成「删掉该 scope 下每一条终态行」,
524
+ > 花名册的 `maxAgeMs` 则让每个 durable 地址都读成已过期)。**先把旋钮改对再升级,引擎不会替你修**。
525
+ > 本服务的 `BG_AGENT_RETENTION_MS` / `WORKFLOW_RUN_RETENTION_MS` / `WORKFLOW_JOURNAL_RETENTION_MS` /
526
+ > `ROSTER_RETENTION_MS` 在 config 层已是「非数字启动即失败、负值钳到 1 分钟下限」,所以经**文档化的
527
+ > env 通道**配置的部署碰不到这条;它是给「自带注入式配置」的集成方与「看到这个错误码时怎么读」准备的。
466
528
  >
467
529
  > **🔧 升级到 7.5.0(引擎 core 5.17.0)前:把待决审批排空。** 5.17.0 起,park 铸行按**后端能承载的
468
530
  > 宽度**落——审批人看到的 args / 预览、盘上躺着的行、resume 真正执行的那份参数,以及运维在 `/decide`
@@ -59,7 +59,7 @@ export function createBudgetAndTracing(ctx) {
59
59
  const sideQueryAccounting = createSideQueryAccountant(metrics, costQuota, fleetUsage, fleetLease, (m) => config.modelQuotaWeights[m] ?? 1);
60
60
  // Durable offload store (core 1.47/1.49): large tool results survive a cross-replica wake. Without a
61
61
  // pool, core's task-scoped in-memory default applies (graceful: cross-wake fetch misses → preview stands).
62
- const toolResultStore = backend?.toolResult ? backend.toolResult() : undefined; // tidb/pg = SQL twins; local = core's FileToolResultStore (core 1.219 — restart-durable refs); no backend → core's in-memory default
62
+ const toolResultStore = backend?.toolResult ? backend.toolResult() : undefined; // tidb/pg = SQL twins; local = core's FileToolResultStore (core 1.219 — restart-durable refs); no backend → main.ts 的 runnerOffloadStore(Runner 侧兜底,本键的「present ⇔ durable 后端」语义不动)
63
63
  // E6 durable SessionPolicyStore — operator-tightened per-session tool rules core reads at prepare-time (subtract-only).
64
64
  // Present on every backend (incl local = core's InMemorySessionPolicyStore); undefined only on the env-only/no-backend
65
65
  // worker → feature OFF (core reads no rules). Wired into the PRIMARY Runner below + the PUT/GET policy route + E21 purge.
@@ -643,7 +643,8 @@ export async function createConfigCenterRuntime(ctx) {
643
643
  if (effective?.scenarios) {
644
644
  const { overlay, shadows } = centerScenarios(scenarioDeps, effective.scenarios.scenarios, Object.keys(scenarios), logger);
645
645
  Object.assign(scenarios, overlay);
646
- Object.assign(scenarioDetails, centerScenarioDetails(effective.scenarios.scenarios, builtinScenarioNames));
646
+ // [C132]:可用性判据同源 —— 详情表与运行工厂读的是**同一只** scenarioDeps(repoClient 在场性)
647
+ Object.assign(scenarioDetails, centerScenarioDetails(effective.scenarios.scenarios, builtinScenarioNames, scenarioDeps));
647
648
  if (Object.keys(overlay).length > 0)
648
649
  logger.info("sema_registry_scenarios", { scenarios: Object.keys(overlay), ...(shadows.length > 0 ? { shadowsBuiltin: shadows } : {}) });
649
650
  }
@@ -3,6 +3,7 @@ import type { PromptsDomainFaces } from "../prompts-domain-validate.js";
3
3
  import { SessionEnvironmentSelection, selectEnvironmentTool } from "../capabilities/select-environment-tool.js";
4
4
  import { sendUserFileTool } from "../capabilities/send-user-file-tool.js";
5
5
  import { selectScenario } from "../capabilities/scenarios.js";
6
+ import { type HandsLaneRegistry } from "../capabilities/hands-lane.js";
6
7
  import type { ServiceConfig } from "../config.js";
7
8
  import { type LiveQuestionFace } from "../deployment-governance.js";
8
9
  import { FleetEventBus } from "../fleet/fleet-bus.js";
@@ -21,6 +22,10 @@ type PrincipalCaps = ReturnType<typeof createPrincipalEntitlementsClient>;
21
22
  /** `resolveSpec` 原先从 `main()` 闭包里拿到的全部 boot 局部量。 */
22
23
  export interface ResolveSpecCtx {
23
24
  config: ServiceConfig;
25
+ /** #196:hands lane 登记簿。resolveSpec 在产出 TaskSpec 时按场景判别位登记本请求的表态,HTTP 执行点
26
+ * 凭同一只登记簿选 Runner —— **必须与 main.ts 传给 `runnerFor` 的是同一实例**(异实例 = 每个请求都
27
+ * 落回 full,收窄静默失效)。 */
28
+ handsLanes: HandsLaneRegistry;
24
29
  logger: Logger;
25
30
  metrics: Metrics;
26
31
  localRoot: string;
@@ -25,6 +25,7 @@ import { centerPromptProvider, centerIdentityAssembled } from "../capabilities/c
25
25
  import { SessionEnvironmentSelection, selectEnvironmentTool } from "../capabilities/select-environment-tool.js";
26
26
  import { sendUserFileTool } from "../capabilities/send-user-file-tool.js";
27
27
  import { gateScenarioRequest, mergeUserSkills, selectScenario } from "../capabilities/scenarios.js";
28
+ import { toolPolicyForHands } from "../capabilities/hands-lane.js";
28
29
  import { applyLongtailDefer } from "../capabilities/tool-defer.js";
29
30
  import { assertGuardPatternsUsable, buildOnlySensitiveBaselineWarning, createApprovalBaselinePolicy, createDeploymentGovernanceInputs } from "../deployment-governance.js";
30
31
  import { acceptShellScratchpadDir, buildEnvFacts, egressForRemoteExec, ensureScratchpadDir, resumeFactsForLane } from "../env-facts.js";
@@ -50,7 +51,7 @@ import { enableForkFromBody, normalizeRetainSubagentSessions, selfOrchestrationF
50
51
  import { redactSecrets } from "../trace/redact.js";
51
52
  import { DeferredSandboxPathEnv, isSandboxPathAdjudicationLane, sandboxPathEnvSlots } from "./deferred-sandbox-path-env.js";
52
53
  export function createResolveSpec(ctx) {
53
- const { config, logger, metrics, localRoot, scenarios, principalCaps, centerRuntimeCapsResolver, getCenterPrompts, getKeyResolver, taskAttachmentStore, perSessionCwd, setSessionCwd, setSessionShellEnv, hookLlm, hookAgent, fleetBus, hookWakeBus, resumeAnchorStore, ownerAware, taskLimitCaps, taskTimeoutSec, selectEnvTool, sendUserFileToolSpec, memoryEngine, durableEnabled, approvalExemptionStore, singleUserAutoAcceptBaseline, checkpointStore, deploymentHooks, imageIndex, perTaskImage, sessionEnvSelection, liveQuestionFace, } = ctx;
54
+ const { config, logger, metrics, localRoot, scenarios, principalCaps, centerRuntimeCapsResolver, handsLanes, getCenterPrompts, getKeyResolver, taskAttachmentStore, perSessionCwd, setSessionCwd, setSessionShellEnv, hookLlm, hookAgent, fleetBus, hookWakeBus, resumeAnchorStore, ownerAware, taskLimitCaps, taskTimeoutSec, selectEnvTool, sendUserFileToolSpec, memoryEngine, durableEnabled, approvalExemptionStore, singleUserAutoAcceptBaseline, checkpointStore, deploymentHooks, imageIndex, perTaskImage, sessionEnvSelection, liveQuestionFace, } = ctx;
54
55
  // ── #177 的两件 boot 期收口(把守卫集搬到 governance 拍之后才成立的两条)────────────────────────
55
56
  //
56
57
  // ① 守卫集的**编译**从此每个请求都发生(旧家只在 default/auto/acceptEdits 三个模式臂里发生)。
@@ -80,11 +81,15 @@ export function createResolveSpec(ctx) {
80
81
  const anchors = await resolveHistoryAnchors(body, auth);
81
82
  const spec = assembleSpecLiteral(body, auth, opts, { ...gated, ...lane, ...anchors });
82
83
  const folded = await foldGovernanceAndSettings(body, auth, spec, gated);
83
- return applyImageFactsAndRouting(body, auth, folded.governed, {
84
+ const final = await applyImageFactsAndRouting(body, auth, folded.governed, {
84
85
  scenarioName: gated.scenarioName,
85
86
  scratchpadDir: folded.scratchpadDir,
86
87
  hostSemanticsLane: folded.hostSemanticsLane,
87
88
  });
89
+ // #196:登记本请求的 hands lane。**必须是最终对象**(阶段⑤/⑥ 会换新对象 —— 登记中间态 = 执行点
90
+ // 查不到、静默落回 full)。resume 腿也走同一条 resolveSpec,故续跑与首跑选同一只 Runner。
91
+ handsLanes.stamp(final, gated.hands);
92
+ return final;
88
93
  };
89
94
  /** 阶段①(场景/append 门):吃 请求体 + auth + `opts.leg`,吐 本请求的场景绑定(scenarioName/cap)、center-pack
90
95
  * 的**单次**快照与 append 门结论(centerDecls/appendLessPack/acceptedAppend)、objective、解析后的 settings 戳,
@@ -194,7 +199,7 @@ export function createResolveSpec(ctx) {
194
199
  }
195
200
  }
196
201
  }
197
- return { scenarioName, cap, centerDecls, appendLessPack, acceptedAppend, objective, parsedSettings, attachmentNotice };
202
+ return { scenarioName, cap, hands: cap.hands, centerDecls, appendLessPack, acceptedAppend, objective, parsedSettings, attachmentNotice };
198
203
  };
199
204
  /** 阶段②(settings·cwd·env):吃 请求体 + auth + 阶段①的 objective/parsedSettings,吐 taskHooks、
200
205
  * additionalDirectories、档位展开后的 wireCatalog 与选中的 picked;副作用=per-session cwd/shellEnv 注册与
@@ -405,7 +410,7 @@ export function createResolveSpec(ctx) {
405
410
  /** 阶段④(spec 字面量):吃 请求体 + auth + `opts.leg` + 前三阶段的全部产出,吐**尚未过治理层**的 TaskSpec
406
411
  * 字面量 —— 部署方拥有的 body→TaskSpec 映射本体(compactionModel 的 fresh 腿 400 也在这一段)。 */
407
412
  const assembleSpecLiteral = (body, auth, opts, parts) => {
408
- const { scenarioName, cap, centerDecls, acceptedAppend, parsedSettings, attachmentNotice, taskHooks, additionalDirectories, additionalReadDirectories, wireCatalog, picked, resumeAtEntryId, rewindFilesToEntryId, s4DefaultScopes, } = parts;
413
+ const { scenarioName, cap, hands, centerDecls, acceptedAppend, parsedSettings, attachmentNotice, taskHooks, additionalDirectories, additionalReadDirectories, wireCatalog, picked, resumeAtEntryId, rewindFilesToEntryId, s4DefaultScopes, } = parts;
409
414
  const spec = {
410
415
  // D-1:附件告知随 objective 进 durable 流(只有名字/mime/尺寸——**内容永不进流**,这正是
411
416
  // 「拼进 objective 是伪方案」的账要划清的线;文件名已消毒为安全字符集,无注入面)。
@@ -745,7 +750,11 @@ export function createResolveSpec(ctx) {
745
750
  // (the live human answers over the stream) instead of unconditionally parking; see durableQuestionPolicy.
746
751
  // design/181 件一:基线的**构造**归 `createApprovalBaselinePolicy`(三腿单一属主);本处只做部署形
747
752
  // 判断——哪一形该有 durable 轴、哪一形铺 allow-all、哪一形留 `undefined`(见下两条注)。
748
- toolPolicy: durableEnabled
753
+ // #196 纵深(设计小票 §2b):hands=none 的场景在 spec 上叠一份 band 名单 deny —— **不是**主保证
754
+ // (主保证=这类任务跑在不挂 executionEnvFactory 的 Runner 上,band 根本不 mount),而是「将来
755
+ // 有人把它误接回有手 Runner」时的第二层。合成走 tighten-only 的 combinePolicies(TRAP #1:
756
+ // 直接赋值会静默丢掉下方那条 durable 审批基线 / auto-accept 座),见 capabilities/hands-lane.ts。
757
+ toolPolicy: toolPolicyForHands(hands, durableEnabled
749
758
  ? createApprovalBaselinePolicy(config, {
750
759
  question: liveQuestionFace,
751
760
  // The probe key is the CONTINUED session (auth.sessionId — the same id that keys the
@@ -772,7 +781,7 @@ export function createResolveSpec(ctx) {
772
781
  // ZERO gate intent — a single-user op who SET approval flags but wired no store falls to `undefined`
773
782
  // (core warns = real misconfig, not masked); multi-tenant likewise stays `undefined` (approval required).
774
783
  ? createApprovalBaselinePolicy(config)
775
- : undefined,
784
+ : undefined),
776
785
  // Durable suspend needs both the store (here, per-task) and the opt-in scope key (multi-tenant =
777
786
  // principal; "_" when auth is off). core suspends on a policy `ask` only when these are present.
778
787
  ...(durableEnabled
@@ -0,0 +1,100 @@
1
+ /**
2
+ * #196 场景 hands lane —— 「要手的场景」与「无手的场景」在装配层分家。
3
+ *
4
+ * 病根(设计小票 §1,案源 [3208]/[3209]/[3212]):core 的手带工具面是 **per-Runner 表态**
5
+ * (`prepare-task`:`handsEnabled = ownedEnv || deps.executionEnv`,ownedEnv 只要挂了
6
+ * `executionEnvFactory` 就每任务必铸)。server 此前全场景共享一只 runner + 一只 subRunner,
7
+ * 于是任何 REMOTE_EXEC 部署里,声明 clone-free 的 `scan`、只有 `run_council` 的评审团、
8
+ * 零工具人格的 team 成员,最终 roster 都被并集进全量可写手带(Bash/Edit/Write/…)。
9
+ *
10
+ * 本模块提供三件:
11
+ * ① {@link ScenarioHands} 判别位 + {@link pickHandsRunner} 的**穷举** switch —— 新场景不表态 = 编译红,
12
+ * 不是静默继承 full(词表闭集纪律)。
13
+ * ② {@link HANDS_EXCLUSIVE_TOOL_NAMES} + {@link handsDenyPolicy} —— 纵深:hands=none 的任务即便被误接到
14
+ * 有手 Runner 上,手独占名单逐一 deny。**不是**主保证(主保证是根本不挂 factory ⇒ 工具面不可见)。
15
+ * ③ {@link createHandsLaneRegistry} —— resolveSpec 产出 spec 与执行点选 Runner 之间的 lane 传递。
16
+ */
17
+ import { type Runner, type TaskSpec, type ToolPolicy } from "@sema-agent/core";
18
+ /** 场景对手带工具面的表态(闭集)。`full` = core 手带 band 照常 mount;`none` = 不挂 executionEnvFactory 的 Runner。 */
19
+ export type ScenarioHands = "full" | "none";
20
+ /**
21
+ * 无手孪生 Runner 的 deps 构造:把 `executionEnvFactory` **摘键**(不是置 undefined)。
22
+ * 摘键让「这只 Runner 没有手」在 deps 字面量上自证;置 undefined 留下一个值为 undefined 的键,
23
+ * 与 core 现在的 `deps.executionEnvFactory ||` 判读结果相同,但把正确性寄托在真值判断上。
24
+ */
25
+ export declare function withoutExecutionEnv<T extends {
26
+ executionEnvFactory?: unknown;
27
+ }>(deps: T): Omit<T, "executionEnvFactory">;
28
+ /** 一对 Runner:同 deps,唯一差别是 `handsless` 不挂 `executionEnvFactory`。 */
29
+ export interface HandsRunnerPair {
30
+ full: Runner;
31
+ handsless: Runner;
32
+ }
33
+ /** 按场景表态取 Runner。**穷举 switch**:`ScenarioHands` 加词而此处不改 = 编译错(无 default 臂)。 */
34
+ export declare function pickHandsRunner(hands: ScenarioHands, pair: HandsRunnerPair): Runner;
35
+ /**
36
+ * core 手带 band 的工具名**全集** —— 「挂 executionEnvFactory 才出现、不挂就消失」的那一批。
37
+ *
38
+ * 两段来源,都不是拍脑袋:
39
+ * · `HAND_TOOL_EFFECTS`(core 导出面,`tools/fs`)—— band 的 effect 表,11 名。
40
+ * · 下方钉死补集 —— `createHandsToolkit` / 手带块还会铸但**不在** effect 表里的 5 名(实测差集,见
41
+ * test/scenario-hands-lane.test.ts 的对表格:它用真 Runner 跑「有手 roster ∖ 无手 roster」,与本常量逐名对账,
42
+ * core 加/改 band 成员时红,防漂移)。
43
+ *
44
+ * ⚠️ 本全集是**观测口径**(用于防漂移对账),**不是** deny 名单 —— deny 用下面的
45
+ * {@link HANDS_EXCLUSIVE_TOOL_NAMES},理由见那里。
46
+ *
47
+ * 🚨 [3248] P0(2026-08-09):派生时必须过滤 core 的 `RETIRED_TOOL_NAMES` —— core 5.21.0 的
48
+ * `HAND_TOOL_EFFECTS` 里残留已退役的 `MultiEdit`,而 core `prepare-task` 的策略名审计见退役名
49
+ * **无条件 throw**(`config.legacy_tool_name`);不过滤,deny 名单一进 `spec.toolPolicy`,
50
+ * `hands: none` 三场景(scan/code-review/team)在**所有部署**上零 turn 全灭。core 5.21.1 已删
51
+ * 那条死条目([3249]),这道 filter 是结构性防御:将来 core 再退役任何 band 名都不会重演
52
+ * (test/scenario-hands-lane.test.ts 格7/格8 双钉:真 Runner 过审计 + 零交集门)。
53
+ * filter 打在**整表**(effect 表 + 钉死补集)之后 —— caret floor 下装置会解析到更新的 core,
54
+ * 补集名哪天被退役,只滤前半就是同一次故障换个名字(codex 复审 finding,2026-08-09)。
55
+ */
56
+ export declare const HANDS_BAND_TOOL_NAMES: readonly string[];
57
+ /**
58
+ * band 里**只有手才可能出现**的那批(= deny 名单)。
59
+ *
60
+ * 🔴 codex 复审(2026-08-09 finding-3,亲验 core prepare-task):band 全集里有四件是**两用**的 ——
61
+ * `prepare-task` 的挂载条件是 `if (backgroundTaskToolsActive || workflowToolsActive)`,而
62
+ * `workflowToolsActive` 只要 `spec.selfOrchestration`(caller 可设,与场景正交)+ 部署的 workflow deps
63
+ * 就成立,**与手无关**。拿全集去 deny,会让「hands=none 场景 + selfOrchestration:true」的请求挂上
64
+ * `Workflow` 却读不到它的轮询/停止/消息/转录 —— 一个半瘫的工作流面,比不给还糟。
65
+ * 故 deny 名单 = 全集 ∖ 两用四件。Monitor 不在两用之列(core 的挂载条件是纯
66
+ * `if (backgroundTaskToolsActive)`,而它 = handsIncludeShell ∧ …,无手不成立)。
67
+ */
68
+ export declare const HANDS_DUAL_USE_TOOL_NAMES: readonly string[];
69
+ export declare const HANDS_EXCLUSIVE_TOOL_NAMES: readonly string[];
70
+ /** 手独占名单的 deny 策略(纵深层)。名单是常量 ⇒ 每次调用产出等价策略,构造成本可忽略。 */
71
+ export declare function handsDenyPolicy(): ToolPolicy;
72
+ /**
73
+ * 把 band deny 叠到既有基线上 —— **tighten-only**。
74
+ *
75
+ * 🪤 TRAP #1(runtime-governance.ts 头注逐字):`TaskSpec.toolPolicy` 是**覆盖**而非合并
76
+ * (`spec.toolPolicy ?? deps.toolPolicy`),直接赋值会静默丢掉部署的 durable 审批基线 /
77
+ * 单用户 auto-accept 座。走 `combinePolicies(base, deny)`(deny 胜、ask 不被 allow 冲掉),
78
+ * 只会加 deny,永不放松。base 缺席时单挂 deny —— 该形下 band 本就不 mount,core 的
79
+ * "write-capable hand tools are present but UNGATED" 信号不受影响(它以 Write 在场为条件)。
80
+ */
81
+ export declare function tightenWithHandsDeny(base: ToolPolicy | undefined): ToolPolicy;
82
+ /** 装配点的取用形:`full` 原样放行基线,`none` 叠 deny。**穷举 switch**,无缺省臂。 */
83
+ export declare function toolPolicyForHands(hands: ScenarioHands, base: ToolPolicy | undefined): ToolPolicy | undefined;
84
+ /**
85
+ * 请求级 lane 登记簿 —— resolveSpec 在产出 TaskSpec 时登记本请求的场景表态,执行点凭 spec 取回选 Runner。
86
+ *
87
+ * 为什么不把 lane 写进 TaskSpec:那是 core 的类型,server 无自有位;而 `resolveSpec` 的返回形是
88
+ * ServiceDeps 的公开签名,80+ 测试文件在桩它 —— 改返回形的射程远大于本件。登记簿以 spec **对象身份**
89
+ * 为键(每请求一只新对象,并发无串台;WeakMap 弱引用,零清理欠账),由同一只 boot 装配同时交给
90
+ * resolveSpec 与 HTTP 执行点,配对是结构性的。
91
+ *
92
+ * 未登记 ⇒ `full`:唯一可达形是「这只 spec 不是本 resolver 产的」(测试桩)。生产链上 stamp 与 laneOf
93
+ * 同一实例,不存在半接;真误配时纵深 deny 仍在(见 {@link tightenWithHandsDeny})。
94
+ */
95
+ export interface HandsLaneRegistry {
96
+ stamp(spec: TaskSpec, hands: ScenarioHands): void;
97
+ laneOf(spec: TaskSpec): ScenarioHands;
98
+ }
99
+ export declare function createHandsLaneRegistry(): HandsLaneRegistry;
100
+ //# sourceMappingURL=hands-lane.d.ts.map
@@ -0,0 +1,113 @@
1
+ /**
2
+ * #196 场景 hands lane —— 「要手的场景」与「无手的场景」在装配层分家。
3
+ *
4
+ * 病根(设计小票 §1,案源 [3208]/[3209]/[3212]):core 的手带工具面是 **per-Runner 表态**
5
+ * (`prepare-task`:`handsEnabled = ownedEnv || deps.executionEnv`,ownedEnv 只要挂了
6
+ * `executionEnvFactory` 就每任务必铸)。server 此前全场景共享一只 runner + 一只 subRunner,
7
+ * 于是任何 REMOTE_EXEC 部署里,声明 clone-free 的 `scan`、只有 `run_council` 的评审团、
8
+ * 零工具人格的 team 成员,最终 roster 都被并集进全量可写手带(Bash/Edit/Write/…)。
9
+ *
10
+ * 本模块提供三件:
11
+ * ① {@link ScenarioHands} 判别位 + {@link pickHandsRunner} 的**穷举** switch —— 新场景不表态 = 编译红,
12
+ * 不是静默继承 full(词表闭集纪律)。
13
+ * ② {@link HANDS_EXCLUSIVE_TOOL_NAMES} + {@link handsDenyPolicy} —— 纵深:hands=none 的任务即便被误接到
14
+ * 有手 Runner 上,手独占名单逐一 deny。**不是**主保证(主保证是根本不挂 factory ⇒ 工具面不可见)。
15
+ * ③ {@link createHandsLaneRegistry} —— resolveSpec 产出 spec 与执行点选 Runner 之间的 lane 传递。
16
+ */
17
+ import { HAND_TOOL_EFFECTS, RETIRED_TOOL_NAMES, combinePolicies, createAllowDenyPolicy } from "@sema-agent/core";
18
+ /**
19
+ * 无手孪生 Runner 的 deps 构造:把 `executionEnvFactory` **摘键**(不是置 undefined)。
20
+ * 摘键让「这只 Runner 没有手」在 deps 字面量上自证;置 undefined 留下一个值为 undefined 的键,
21
+ * 与 core 现在的 `deps.executionEnvFactory ||` 判读结果相同,但把正确性寄托在真值判断上。
22
+ */
23
+ export function withoutExecutionEnv(deps) {
24
+ const { executionEnvFactory: _handsDropped, ...rest } = deps;
25
+ return rest;
26
+ }
27
+ /** 按场景表态取 Runner。**穷举 switch**:`ScenarioHands` 加词而此处不改 = 编译错(无 default 臂)。 */
28
+ export function pickHandsRunner(hands, pair) {
29
+ switch (hands) {
30
+ case "full":
31
+ return pair.full;
32
+ case "none":
33
+ return pair.handsless;
34
+ }
35
+ }
36
+ /**
37
+ * core 手带 band 的工具名**全集** —— 「挂 executionEnvFactory 才出现、不挂就消失」的那一批。
38
+ *
39
+ * 两段来源,都不是拍脑袋:
40
+ * · `HAND_TOOL_EFFECTS`(core 导出面,`tools/fs`)—— band 的 effect 表,11 名。
41
+ * · 下方钉死补集 —— `createHandsToolkit` / 手带块还会铸但**不在** effect 表里的 5 名(实测差集,见
42
+ * test/scenario-hands-lane.test.ts 的对表格:它用真 Runner 跑「有手 roster ∖ 无手 roster」,与本常量逐名对账,
43
+ * core 加/改 band 成员时红,防漂移)。
44
+ *
45
+ * ⚠️ 本全集是**观测口径**(用于防漂移对账),**不是** deny 名单 —— deny 用下面的
46
+ * {@link HANDS_EXCLUSIVE_TOOL_NAMES},理由见那里。
47
+ *
48
+ * 🚨 [3248] P0(2026-08-09):派生时必须过滤 core 的 `RETIRED_TOOL_NAMES` —— core 5.21.0 的
49
+ * `HAND_TOOL_EFFECTS` 里残留已退役的 `MultiEdit`,而 core `prepare-task` 的策略名审计见退役名
50
+ * **无条件 throw**(`config.legacy_tool_name`);不过滤,deny 名单一进 `spec.toolPolicy`,
51
+ * `hands: none` 三场景(scan/code-review/team)在**所有部署**上零 turn 全灭。core 5.21.1 已删
52
+ * 那条死条目([3249]),这道 filter 是结构性防御:将来 core 再退役任何 band 名都不会重演
53
+ * (test/scenario-hands-lane.test.ts 格7/格8 双钉:真 Runner 过审计 + 零交集门)。
54
+ * filter 打在**整表**(effect 表 + 钉死补集)之后 —— caret floor 下装置会解析到更新的 core,
55
+ * 补集名哪天被退役,只滤前半就是同一次故障换个名字(codex 复审 finding,2026-08-09)。
56
+ */
57
+ export const HANDS_BAND_TOOL_NAMES = Object.freeze([
58
+ ...Object.keys(HAND_TOOL_EFFECTS),
59
+ "SendMessage",
60
+ "AgentTranscript",
61
+ "Monitor",
62
+ "EnterWorktree",
63
+ "ExitWorktree",
64
+ ].filter((n) => !RETIRED_TOOL_NAMES.has(n)));
65
+ /**
66
+ * band 里**只有手才可能出现**的那批(= deny 名单)。
67
+ *
68
+ * 🔴 codex 复审(2026-08-09 finding-3,亲验 core prepare-task):band 全集里有四件是**两用**的 ——
69
+ * `prepare-task` 的挂载条件是 `if (backgroundTaskToolsActive || workflowToolsActive)`,而
70
+ * `workflowToolsActive` 只要 `spec.selfOrchestration`(caller 可设,与场景正交)+ 部署的 workflow deps
71
+ * 就成立,**与手无关**。拿全集去 deny,会让「hands=none 场景 + selfOrchestration:true」的请求挂上
72
+ * `Workflow` 却读不到它的轮询/停止/消息/转录 —— 一个半瘫的工作流面,比不给还糟。
73
+ * 故 deny 名单 = 全集 ∖ 两用四件。Monitor 不在两用之列(core 的挂载条件是纯
74
+ * `if (backgroundTaskToolsActive)`,而它 = handsIncludeShell ∧ …,无手不成立)。
75
+ */
76
+ export const HANDS_DUAL_USE_TOOL_NAMES = Object.freeze(["TaskOutput", "TaskStop", "SendMessage", "AgentTranscript"]);
77
+ export const HANDS_EXCLUSIVE_TOOL_NAMES = Object.freeze(HANDS_BAND_TOOL_NAMES.filter((n) => !HANDS_DUAL_USE_TOOL_NAMES.includes(n)));
78
+ /** 手独占名单的 deny 策略(纵深层)。名单是常量 ⇒ 每次调用产出等价策略,构造成本可忽略。 */
79
+ export function handsDenyPolicy() {
80
+ return createAllowDenyPolicy({ deny: [...HANDS_EXCLUSIVE_TOOL_NAMES] });
81
+ }
82
+ /**
83
+ * 把 band deny 叠到既有基线上 —— **tighten-only**。
84
+ *
85
+ * 🪤 TRAP #1(runtime-governance.ts 头注逐字):`TaskSpec.toolPolicy` 是**覆盖**而非合并
86
+ * (`spec.toolPolicy ?? deps.toolPolicy`),直接赋值会静默丢掉部署的 durable 审批基线 /
87
+ * 单用户 auto-accept 座。走 `combinePolicies(base, deny)`(deny 胜、ask 不被 allow 冲掉),
88
+ * 只会加 deny,永不放松。base 缺席时单挂 deny —— 该形下 band 本就不 mount,core 的
89
+ * "write-capable hand tools are present but UNGATED" 信号不受影响(它以 Write 在场为条件)。
90
+ */
91
+ export function tightenWithHandsDeny(base) {
92
+ const deny = handsDenyPolicy();
93
+ return base ? combinePolicies(base, deny) : deny;
94
+ }
95
+ /** 装配点的取用形:`full` 原样放行基线,`none` 叠 deny。**穷举 switch**,无缺省臂。 */
96
+ export function toolPolicyForHands(hands, base) {
97
+ switch (hands) {
98
+ case "full":
99
+ return base;
100
+ case "none":
101
+ return tightenWithHandsDeny(base);
102
+ }
103
+ }
104
+ export function createHandsLaneRegistry() {
105
+ const lanes = new WeakMap();
106
+ return {
107
+ stamp: (spec, hands) => {
108
+ lanes.set(spec, hands);
109
+ },
110
+ laneOf: (spec) => lanes.get(spec) ?? "full",
111
+ };
112
+ }
113
+ //# sourceMappingURL=hands-lane.js.map
@@ -3,6 +3,7 @@ import type { Metrics } from "../observability/metrics.js";
3
3
  import type { Logger } from "../observability/logger.js";
4
4
  import { GiteaClient } from "./repo-tools.js";
5
5
  import { type LoadedSkill } from "./skills.js";
6
+ import { type ScenarioHands } from "./hands-lane.js";
6
7
  /**
7
8
  * Scenario routing — a deployment serves many scenarios from one image. Each request's `scenario`
8
9
  * selects a pre-wired capability bundle: tools (incl. subagent roster) + a whole-harness prompt +
@@ -17,6 +18,14 @@ export interface ScenarioRequest {
17
18
  export interface ScenarioBundle {
18
19
  tools: ToolSpec[];
19
20
  skills: SkillSpec[];
21
+ /**
22
+ * #196:本场景对 core 手带工具面(Bash/Edit/Write/Read/Grep/…)的表态 —— **必填闭集**。
23
+ * `full` = 任务跑在挂了 `executionEnvFactory` 的 Runner 上(core 把 band 并集进 roster);
24
+ * `none` = 跑在不挂 factory 的 Runner 上(band 根本不 mount,工具 schema 对模型不可见)。
25
+ * 必填是设计的一半:新场景不表态 = 编译红,而不是静默继承 full(那正是 [3208] 的病灶——
26
+ * `scan` 声明 clone-free 四件、实际拿到全量可写手)。
27
+ */
28
+ hands: ScenarioHands;
20
29
  promptProvider?: PromptProvider;
21
30
  /** [849] 场景解释层定死的终验开关(core TaskSpec.finalVerification):bench 实测产品默认请求与
22
31
  * bench 态差距三件之一。[2400] 起无场景定死(autonomous 退役)——纯 caller 显式旋钮;spec 组装处 OR 折入(场景只会
@@ -28,6 +37,13 @@ export interface ScenarioDeps {
28
37
  runner: Runner;
29
38
  /** In-memory runner for ephemeral sub-tasks (council lenses/arbiter) — keeps them out of TiDB. */
30
39
  subRunner: Runner;
40
+ /**
41
+ * #196:`subRunner` 的无手孪生 —— 同 deps,唯独不挂 `executionEnvFactory`。hands=none 的场景把它交给
42
+ * 自己造的子任务工具(council 的 lens/arbiter、team 的成员/synthesizer):那些子任务只该读仓库/讨论,
43
+ * 拿全量可写手是 [3208] 病灶里最意外的一支(零工具人格照拿 Bash)。取用一律走 {@link pickHandsRunner},
44
+ * 不在场景体内手挑 —— 判别位是唯一开关。
45
+ */
46
+ handslessSubRunner: Runner;
31
47
  model: string;
32
48
  skills: LoadedSkill[];
33
49
  repoClient?: GiteaClient;
@@ -144,21 +160,52 @@ export interface ScenarioDetail {
144
160
  tools: string[];
145
161
  /** 内建=概览(不外泄提示词资产全文);center 条目=prompt 原文(本就 ≤4KB 明文在配置域)。 */
146
162
  promptSummary: string;
163
+ /** **已声明**(内建恒 true;center 条目 `enabled !== false`)。⚠️ 不是「跑得动」——那是 {@link available}。 */
147
164
  enabled: boolean;
165
+ /**
166
+ * [C132] **本部署现在真跑得动吗**:后端依赖(如 repo 型场景的 `GIT_API_BASEURL`)是否就位。
167
+ * 与请求面的 501 拒绝臂**单一属主**({@link scenarioAvailability});false ⟺ 良性请求真吃 501。
168
+ */
169
+ available: boolean;
170
+ /** 机读原因键;仅 `available:false` 时在场(缺席=没有理由)。消费方按键分支,禁匹配英文文案。 */
171
+ unavailableReason?: ScenarioUnavailableReason;
148
172
  }
149
173
  /** 内建五场景详情。default/code/team 工厂对良性请求无副作用可真调(拿真实工具名单);code-review/scan 是
150
174
  * fail-loud 语义(缺 GIT_API 配置/principal 即 throw)→ 探针失败落静态表兜底(表↔工厂一致性由测试锁:
151
175
  * 测试喂 fake deps 真调工厂对账工具名)。enabled 对内建恒 true(约定②)。 */
152
- export declare function builtinScenarioDetails(scenarios: Record<string, Scenario>, deps: Pick<ScenarioDeps, "brandIdentity">): Record<string, ScenarioDetail>;
176
+ export declare function builtinScenarioDetails(scenarios: Record<string, Scenario>, deps: Pick<ScenarioDeps, "brandIdentity" | "repoClient">): Record<string, ScenarioDetail>;
153
177
  /** center 条目详情(有效性判定与 centerScenarios 完全同款:无效条目既不进 overlay 也不进详情——
154
178
  * 保证约定①「详情显示的来源=运行实际用的定义」永不错位)。 */
155
- export declare function centerScenarioDetails(specs: CenterScenarioSpec[] | undefined, builtinNames: string[]): Record<string, ScenarioDetail>;
179
+ export declare function centerScenarioDetails(specs: CenterScenarioSpec[] | undefined, builtinNames: string[], deps: Pick<ScenarioDeps, "repoClient">): Record<string, ScenarioDetail>;
156
180
  export interface CenterScenarioSpec {
157
181
  name: string;
158
182
  toolset: string;
159
183
  prompt?: string;
160
184
  enabled?: boolean;
161
185
  }
186
+ /** 场景不可用的**机读原因**(闭集)。新增成员必须在 {@link scenarioUnavailableMessage} 的穷举 switch
187
+ * 里表态——漏表态是编译错误,不是运行期 miss 臂。消费方按这个键分支,禁去正则匹配英文文案。 */
188
+ export type ScenarioUnavailableReason = "git_client_unconfigured";
189
+ /** 判别式:可用臂**不带**原因键(缺席=没有理由),不可用臂必带。 */
190
+ export type ScenarioAvailability = {
191
+ readonly available: true;
192
+ } | {
193
+ readonly available: false;
194
+ readonly reason: ScenarioUnavailableReason;
195
+ };
196
+ /**
197
+ * 🔴 场景可用性的**唯一属主**。列举面(`ScenarioDetail.available` / `unavailableReason`)与请求面
198
+ * ({@link requireRepoClient} 的 501 拒绝臂,三处调用点)都只从这里取值——两处各写一份就是本仓反复
199
+ * 吃过的「同一语义两个属主」病:判据一漂,列举面开始说谎而没人先红。一致性由 capabilities.test 的
200
+ * **对表格**逐名钉住(`available:false` ⟺ 良性请求真吃 501),而不是靠这段注释。
201
+ *
202
+ * 判据键 = **toolset**:内建详情与 center 条目都带这个字段,故两条产线天然共用同一份判据。
203
+ * `requiresRepo` 的 toolset 需要部署配好 git 后端(`GIT_API_BASEURL` ⇒ `deps.repoClient`)。
204
+ * 词表外的 toolset(内建的 `full-body`/`team`)不依赖后端 ⇒ 恒可用。
205
+ */
206
+ export declare function scenarioAvailability(deps: Pick<ScenarioDeps, "repoClient">, toolset: string): ScenarioAvailability;
207
+ /** 拒绝文案的唯一属主:闭集穷举 switch(新增原因词不在这里表态即编译红)。 */
208
+ export declare function scenarioUnavailableMessage(reason: ScenarioUnavailableReason, scenarioLabel: string): string;
162
209
  export declare const SCENARIO_NAME_RE: RegExp;
163
210
  /**
164
211
  * Build the center-declared scenario overlay. Invalid specs are SKIPPED with a warning (never fail