@mars.liu/dsh-cybernetics 0.25.2 → 0.25.5

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/README.md CHANGED
@@ -172,7 +172,7 @@ The settings card's「预置方案」dropdown — ten options: **auto-recommend*
172
172
  - **Vendor entries (data-driven, `status: 'ready'`)**: each vendor carries per-role model + reasoning-effort token (heterogeneous: OpenAI/Minimax five-level low/medium/high/xhigh/max, DeepSeek thinking switch, others model default), upgrade tier, availability fallback, per-role rationale, per-vendor note (⚠️ caveats — e.g. Qwen's 「verify 绝不用 Turbo」幻觉共振 warning, Kimi's K2-retirement avoidance, DeepSeek's 「成本不应成为降级 verify 的理由」) and VARIANTS (Claude 常规/高要求, Qwen A/B/C, DeepSeek 仅本家/verify 外置, Kimi 长期/最高验收质量, 火山 A/B, GLM 标准组合/重活命名角色). Selecting a preset applies its **default variant** (the first one); the outcome record discloses the variant applied and lists the remaining ones. Vendor-canonical model ids are applied verbatim — an id absent from this deployment's catalog shows the card's model-unavailable warning and the round's availability fallback (v0.8) keeps rounds alive.
173
173
  - **Auto-recommend (since v0.18.2 = a session-agent handoff, user ruling)**: selecting auto runs **NO independent LLM call and spawns NO agent** (the previous one guarded one-shot LLM call plus its route config field were removed — re-submitting the whole context to a second model call is a waste of both time and money). The plugin now probes the live catalog (`probeCatalogOptions`, the same directory smart selection reads), builds the handoff brief — embedding the catalog (the only selectable source), the cross-vendor principles (verify ≥ task; verify/task kept cross-generation/cross-family independent; verify must not be over-confident; cost cuts hit task/triage first and never verify; triage light), the current role config and every vendor preset as advisory reference — writes a `pending` record to the card, and hands the brief to the session agent through the plugin-registered `preset_recommend` tool (a steer/followup reminder is delivered to live session agents). The SESSION AGENT reads the catalog in its OWN context, answers for task/verify/triage, and calls the same tool again to submit; the plugin only validates (catalog-matched models only; per-role `reasoningEffort` validated against `ctx.llm.resolveModelInfo` and dropped to the model default when unsupported) and writes. Until the session agent answers, the card shows the pending note; an unusable answer (off-catalog / unparseable / zero picks) degrades to a written `degraded` record with the reason and is reported back to the session agent for a corrected re-submission — nothing is applied and no round is blocked. The tool survives the hard-gate allowlist alongside the cybernetics tool itself.
174
174
  - **Selecting is one-shot**: re-selecting the same entry does not re-fire (the handler guards on the last handled value; a value persisted across a restart is not re-applied at boot). Switch to「(未选择)」first to re-apply an entry.
175
- - **`/cybernetics use [id]` (v0.22.0, command channel)**: switch the role composition to any preset plan without opening the settings card. With no argument the command replies with a plain-text list of every applicable plan — all built-in registry ids plus the configured custom presets (`custom:<id>`) — and asks the user to confirm with a second `/cybernetics use <id>`. With an id it reuses the SAME apply pipeline as the dropdown (`handlePresetSelection`, six-field-bounded ops + the outcome record), so the reply and the card stay consistent; preset ids are case-preserved (custom ids are matched verbatim). Unlike the dropdown, a repeated `use <id>` DOES re-apply the same plan (the command keeps the one-shot volatile guard in sync and deliberately bypasses it), so no「(未选择)」detour is needed. The preset applied this way is profile-global — it affects every concurrent session.
175
+ - **`/cybernetics use [id]` (v0.22.0; session-only since v0.25.5, user ruling)**: switch the role composition of **THIS SESSION** to any preset plan without opening the settings card. With no argument the command replies with a plain-text list of every applicable plan — all built-in registry ids plus the configured custom presets (`custom:<id>`) — and asks the user to confirm with a second `/cybernetics use <id>`. With an id the resolved task/verify/triage picks (models + reasoning efforts) are stored in an **in-memory per-session override** (`src/sessionRoles.ts`) — **zero settings-service writes**: the six global role fields, the `presetPlan` selector and the `presetRecommendation` record are never touched. The round dispatch folds the override with "session override > global fields" precedence, per role and per field. The receipt states it explicitly: `已在本会话应用预置方案 "<label>":task=... verify=... triage=...;仅本会话生效(会话结束失效),不修改全局配置`. A repeated `use <id>` (same or different plan) simply replaces the override; `/cybernetics use off` clears it and the session falls back to the global fields (the `status` reply carries the current override state and this clear command). To change the GLOBAL configuration use the settings card — note that its writes are subject to the host's Include write-gate defect (see the settings-card section); `use` deliberately takes no part in that path. `use auto` hands the auto-recommendation brief to THIS session's agent and lands the `preset_recommend` result as the session override too (the card's own auto path is unchanged).
176
176
  - **Editor-side preset picker (v0.24.1, popupSelect)**: the picker appears only after you express the `use` intent. Picking `/cybernetics` in the `/` menu completes the composer text to `/cybernetics` (no popup — keep typing `use ...`); pressing Enter on the bare `/cybernetics` token opens a searchable popup listing every applicable plan (the 12 built-in ids and the configured `custom:<id>` entries, grouped, fuzzy-filtered as you type, current plan highlighted); typing `/cybernetics use` and pressing Enter executes the plain-text listing and then opens the same popup over it. Picking a row runs `/cybernetics use <id>` through the same command transport (validation/apply/receipt stay server-authoritative; errors surface in the popup). This rides the host's own mechanism for commands with pickable values (`commandUi.decorate` + the public `popupFor`/`command/executed` faces, the same pattern as the official `/model` picker); hosts without the `commandUi` service keep the plain-text v0.22 behavior, and the typed `/cybernetics use <id>` path is untouched. Note: inline argument completion *inside the draft* (a dropdown right after typing `use `) is not possible with the host protocol — the slash trigger scan stops at the first whitespace (re-verified v0.25.0: the host composer publishes no draft-change event, the four `slash/input-*` scope events are plugin→shell bail-request channels only, and trigger-source outcomes are limited to `{claim}/{text}/{insert}` — nothing can observe the `/cybernetics use` draft before Enter). **Type `/cybernetics use`, then press Enter to open the preset picker** (the bare-enter path lands the plain-text listing first and the popup opens over it).
177
177
 
178
178
  ## Installation and Usage
package/README.zh-CN.md CHANGED
@@ -172,7 +172,7 @@ goal 一经存在不再原地修改。`revise` 是唯一的改目标动词(必
172
172
  - **厂商方案(数据驱动,`status: 'ready'`)**:每厂商含逐角色模型 + 思考强度 token(异构:OpenAI/Minimax 五档 low/medium/high/xhigh/max、DeepSeek thinking 开关、其余模型默认)、可升级档、可用性 fallback、逐角色 rationale、厂商备注(⚠️ 注意事项——如 Qwen 的「verify 绝不用 Turbo(幻觉共振)」、Kimi 的避开退役 K2、DeepSeek 的「成本不应成为降级 verify 的理由」)与**多变体**(Claude 常规/高要求,Qwen A/B/C,DeepSeek 仅本家/verify 外置,Kimi 长期组合/最高验收质量,火山 A/B,GLM 标准组合/重活命名角色)。选择方案应用其**默认变体**(第一个);结果记录披露所应用变体并列出其余变体。厂商原生模型 id 逐字应用——部署目录中不存在的 id 会显示卡片不可用警告,轮内可用性 fallback(v0.8)保证轮次存活。
173
173
  - **自动推荐(v0.18.2 起 = session agent 交办,用户裁定)**:选择 auto 时插件**不发起任何独立 LLM 调用、不新起任何子 agent**(v0.17.2 及之前的「一次一次性 LLM 调用 + 推荐所用模型配置项」已删除——另起一次调用等于把上下文重复提交一遍,时间与经济性都不合理)。现在的流程:插件探测实时目录(`probeCatalogOptions`,与智能选择同源),把推荐交办单——内嵌目录(唯一可选来源)、跨厂商通用原则(task/verify/triage 三角色可灵活配置,允许同款模型或任意档位组合;verify 不能过度自信;省成本先压 task/triage、verify 最后动且成本不是降级 verify 的理由;triage 轻量)、当前角色配置与全部厂商方案(仅作参考)——经插件注册的 `preset_recommend` 工具交给 session agent,并在卡片写入 `pending` 记录、向在线 session agent 投递一条 steer/followup 提醒。session agent 在**自己的上下文中**读取目录、给出 task/verify/triage 的答案,再次调用该工具提交;插件只负责校验(逐字命中目录;逐角色 `reasoningEffort` 经 `ctx.llm.resolveModelInfo` 校验,不支持时降为模型默认并注明)与写入。session agent 未作答时卡片保持 pending;答案不可用(未命中目录/不可解析/零选择)时降级为带原因的 `degraded` 记录并回执给 session agent 修正重交——不应用任何更改、不阻塞任何轮次。该工具在硬门禁 allowlist 下与 cybernetics 工具一同保留。
174
174
  - **选择是一次性的**:重复选择同一项不会再次触发(处理器按最后处理值守卫;跨重启持久化的值不会在启动时重放)。要重复应用某项,先切「(未选择)」再选。
175
- - **`/cybernetics use [方案id]`(v0.22.0,命令通道)**:不开设置卡片,直接把角色组合切换为任意预置方案。无参数时命令回执纯文本清单——全部内置方案 id + 已配置的自定义方案(`custom:<id>`)——并提示用户再次输入 `/cybernetics use <id>` 确认;带 id 时复用与下拉框完全相同的应用管线(`handlePresetSelection`:六字段白名单 ops + 结果记录),回执与卡片保持一致;方案 id 大小写保留(自定义 id 逐字匹配)。与下拉框不同,重复输入同一条 `use <id>` 会**再次应用**(命令路径同步维护一次性守卫 `lastHandledPresetPlan` 并刻意绕过它),无需先切「(未选择)」。此方式应用的方案为 profile 全局生效——影响所有并发会话。
175
+ - **`/cybernetics use [方案id]`(v0.22.0;v0.25.5 起仅当前会话生效,用户裁定)**:不开设置卡片,把**本会话**的角色组合切换为任意预置方案。无参数时命令回执纯文本清单——全部内置方案 id + 已配置的自定义方案(`custom:<id>`)——并提示用户再次输入 `/cybernetics use <id>` 确认;带 id 时解析出的 task/verify/triage picks(模型 + 思考强度)存入**内存中的按会话覆盖表**(`src/sessionRoles.ts`)——**零 settings 写入**:六个全局角色字段、`presetPlan` 选择器、`presetRecommendation` 记录均不触碰。轮次派工按「会话覆盖 > 全局字段」逐角色逐字段合并。回执明确语义:`已在本会话应用预置方案 "<label>":task=... verify=... triage=...;仅本会话生效(会话结束失效),不修改全局配置`。重复 `use <id>`(同方案或换方案)直接覆盖更新;`/cybernetics use off` 清除覆盖、回到全局字段(status 回执带当前覆盖状态与该清除指令)。修改**全局**配置请使用设置卡片——注意卡片的全局写入受宿主 Include 写闸门缺陷影响(见设置卡片章节);`use` 刻意不参与该路径。`use auto` 把自动推荐 brief 交给本会话 agent,`preset_recommend` 结果同样落为会话覆盖(卡片自身的 auto 全局路径不变)。
176
176
  - **编辑器侧方案选择器(v0.24.1,popupSelect)**:选择器只在你表达 `use` 意图之后出现。在 / 菜单点选 `\cybernetics` 只把编辑器文本补全为 `/cybernetics`(不弹层——继续输入 `use ...` 即可);对裸 `/cybernetics` 回车弹出可搜索的方案选择器(12 个内置 id 与已配置的 `custom:<id>` 条目分组列出,输入即模糊过滤,当前方案高亮);键入 `\cybernetics use` 回车则先落地纯文本清单,随后同一弹层在其上弹出。点选一项即经同一条命令通道提交 `/cybernetics use <id>`(校验/应用/回执仍以服务端为唯一权威,错误就地显示在弹层)。实现走宿主为「带可选值的命令」提供的原生机制(`commandUi.decorate` + 公开的 `popupFor`/`command/executed` 面,与官方 `/model` 选择器同一模式);宿主无 commandUi 服务时保持 v0.22 纯文本行为,键入路径 `/cybernetics use <id>` 完全不动。说明:草稿内的参数级补全(输入 `use ` 后就地弹下拉)在宿主协议下不可实现——斜杠触发扫描遇空格即止(v0.25.0 复核:宿主 composer 不对外发射任何草稿变更事件,四个 `slash/input-*` 作用域事件均为插件→shell 的 bail 请求通道,触发源 outcome 仅 `{claim}/{text}/{insert}` 三种,均无法在回车前感知 `/cybernetics use` 草稿)。**请输入 `/cybernetics use` 后按 Enter 唤起方案选择器**(无参回车先落地纯文本清单,弹层随即在其上出现)。
177
177
 
178
178
  ## 安装与使用
package/docs/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@
4
4
  > 当下的功能与使用方式以 [README.md](../README.md) 为准;协议细节见 [protocol.md](protocol.md)。
5
5
  > 约定:`src/` 改动需宿主重载生效;`lib/client.js` 改动需重启宿主(客户端模块图在宿主启动时构建)并刷新浏览器;`workflows/round.js` 改动热生效。
6
6
 
7
+ ## v0.25.5(2026-11-11):`/cybernetics use` 改为仅当前会话生效——零 settings 写入(用户裁定,推翻 v0.25.4 方向)
8
+
9
+ **用户裁定(2026-11-11,最高优先级)**:`use` 一律实现为**仅当前会话生效**——完全不调用 settings mutate(不写六角色字段、不写 `presetPlan`、不写 `presetRecommendation`),不做「先全局再降级」。背景:宿主存在写闸门缺陷(v0.7.10:dsh-app-boot 双模块实例 → WeakMap miss → 所有 settings 写入被拒 "profile reload requires the root Include entry"),某些启动方式下 v0.22–v0.25.4 的全局写路径完全无效。
10
+
11
+ - **实现**:新增 `src/sessionRoles.ts`——进程内存中的按会话角色覆盖表(`{preset, label, roles: {task/verify/triage: {provider, model, reasoningEffort}}, appliedAt}`),参考 `sessionMode.ts` 的会话级覆盖模式但不做任何持久化(随宿主进程/会话生命周期消失)。`use <id>` 解析方案(内置 id / `custom:<id>`)后把 picks 存入覆盖表;轮次派工读取点(src/index.ts 平铺字段折叠处)按「会话覆盖 > 全局字段」逐角色逐字段合并(`mergeSessionRolesOverride`)。
12
+ - **auto**:`use auto` 走会话管道——标记会话 pending 并把 brief 交给本会话 agent,`preset_recommend` 工具第二跳校验后把推荐落为**会话覆盖**(strip 到 provider/model/reasoningEffort),全程零 settings 写入;设置卡片自身的 auto 全局路径(下拉框 → pending 记录 → 全局写)完全不动。
13
+ - **清除**:`/cybernetics use off` 清除覆盖回到全局字段;status 回执新增「会话预置方案覆盖」行(无覆盖显示「无(跟随全局配置)」,有覆盖显示 label + picks + 清除指令)。
14
+ - **回执**:`已在本会话应用预置方案 "<label>":task=... verify=... triage=...;仅本会话生效(会话结束失效),不修改全局配置`。无参数清单、未知方案报错行为保留。
15
+ - **清理**:`writePresetRecord` / `handlePresetSelection` 的 `extraOps` 参数与 use 分支的 `presetPlan` 同步 op 一并移除(v0.25.4 的全局同步属于被推翻方向);卡片路径行为不变;`ALLOWED_OP_PATHS` 未放宽。
16
+ - **诊断**:`presetDiag` 新增 `event:'use-session-apply'`(preset、label、picks、cleared),use 路径不再出现任何 `write-*` 事件。
17
+ - **测试**:`test/v23-use-subcommand.mjs` 重写为会话语义——use 后零 settings mutate(pin)、覆盖含 effort 被 merge 语义取到(pin)、重复 use 更新、`use off` 恢复、status 含覆盖行、`use auto` 落会话不落全局(含 degraded/rejected 也零写入)。全部通过;`tsc --noEmit` 真实错误 0(TS5097 基线噪音除外)。
18
+
19
+ ## v0.25.4(2026-11-11,已被 v0.25.5 推翻):`/cybernetics use` 应用后同步设置卡片「预置方案」下拉框
20
+
21
+ **变更**:`/cybernetics use <id>` 之前只把 task/verify/triage 的模型与思考强度字段写入 settings(模型配置已生效并持久化),但不写 volatile 选择器 `presetPlan`,导致配置界面的预置方案下拉框停留在旧方案。现在 `use` 分支给 `handlePresetSelection`(→ `writePresetRecord` 新增可选 `extraOps` 参数)传入一条 `{op:'set', path:['presetPlan'], value:<id>}`,与六字段 ops + 结果记录在**同一次 mutate** 中原子写入(内置 id、`auto`、`custom:<id>` 均同步)。失败/degraded/placeholder 结果不经过 extraOps 调用点——下拉框保持原值;未知方案在命令层即报错、零写入。one-shot 守卫保持一致:写入触发的 loader volatile-update 回环中 `plan === lastHandledPresetPlan` → `willHandle=false`,不会重复应用(测试 6/6b 已 pin);重复 `use` 同一方案仍可重复应用;设置卡片自身选择路径不变;`ALLOWED_OP_PATHS` 六字段白名单未放宽。测试 `test/v23-use-subcommand.mjs` 更新并新增 6/6b/6c 三个断言组,全部通过。
22
+
23
+ ## v0.25.3(2026-11-10):消除 dshmarket 静态巡检对 `custom:` 自定义方案选择器的误报(纯代码形态修正,无功能变化)
24
+
25
+ **变更**:`lib/client.js` 两处形态修正——①方案选择器 `custom:` 前缀拼接改为数组 `join`(行为等价),②设置卡片注释改写,消除 `configForms.get(id)` 字样。目的为避免 dshmarket 静态巡检把 `custom:` 自定义方案选择器的 `custom:<id>` 字符串误报为配置条目 id。无功能变化;`node --check` 通过。
26
+
7
27
  ## v0.25.1(2026-11-10):去除 verify≥task 且跨家族独立的硬约束,角色配置改为灵活权衡
8
28
 
9
29
  **变更**:删除「verify 档位不得低于 task 且与 task 保持跨代际/跨家族独立(同家族 verify 升高一档补偿)」这条硬约束。保留的底线仅两条:triage 选便宜快速的模型;verify 不能过度自信(优先判断力强、能承认不确定性的模型)。task/verify/triage 三个角色改为可灵活配置——允许同款模型或任意档位组合,由使用者/推荐者按任务需要权衡审计增益与成本。同步修改:`src/modelPresets.ts` 的 `CROSS_VENDOR_PRINCIPLES` 与推荐 system prompt 规则第 2 条(reasoningEffort 规则第 5 条改为「verify 建议 high,可按需调整」)、绑定被删约束的厂商 rationale 措辞改为中性描述(预置方案本身不变)、`test/v17-model-presets.mjs` 断言更新(新原则存在、旧硬约束与「跨家族独立」不存在)、README 预置方案章节描述同步。
package/lib/client.js CHANGED
@@ -1473,7 +1473,7 @@ window.__ModuleLoader__.load({
1473
1473
  }
1474
1474
  } catch { /* corrupt payload → built-ins only */ }
1475
1475
  for (const entry of list) {
1476
- const id = 'custom:' + entry.id;
1476
+ const id = ['custom:', entry.id].join('');
1477
1477
  rows.push({
1478
1478
  id,
1479
1479
  label: entry.name,
@@ -1606,7 +1606,7 @@ window.__ModuleLoader__.load({
1606
1606
  * pass no form of their own;
1607
1607
  * - the HOST's ConfigPageForm { state, mutate }: the plugins.item and
1608
1608
  * plugins.row.config detail pages compute it from the same shared
1609
- * mirror (configForms.get(id).getSnapshot()/mutate) and pass it as
1609
+ * mirror (the configForms getter: getSnapshot()/mutate) and pass it as
1610
1610
  * an OWNER prop, which spreads AFTER inject at the render seam
1611
1611
  * (@deepseek-ai/dsh-client-ui-renderer renderEntry: ...injected,
1612
1612
  * ...ownerProps) — on those pages this shape WINS over the injected
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mars.liu/dsh-cybernetics",
3
- "version": "0.25.2",
3
+ "version": "0.25.5",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
package/src/index.ts CHANGED
@@ -43,6 +43,10 @@ import {
43
43
  import type { CustomPreset, PresetRecommendationRecord, SettingsOp } from './modelPresets.ts'
44
44
  import { linkRunToGoal, noteWorkflowAgent, setBoardSource, unlinkRun } from './agents.ts'
45
45
  import { cyberneticsGoalsProjection, goalsFromBoard, publishGoals, setPublishDebugRoot, appendModeRecord } from './projection.ts'
46
+ import {
47
+ clearSessionRolesOverride, formatSessionRolesOverride, getSessionRolesOverride, mergeSessionRolesOverride,
48
+ peekSessionAutoPending, sessionRolesKeyOf, setSessionAutoPending, setSessionRolesOverride, takeSessionAutoPending,
49
+ } from './sessionRoles.ts'
46
50
  import {
47
51
  MODE_EVENT_TYPE, MODE_LEGACY_EVENT_TYPE, cyberneticsModeProjection, effectiveEnabled, modeOverrideOf, planWanted,
48
52
  } from './sessionMode.ts'
@@ -1512,6 +1516,14 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
1512
1516
  logger?.warn?.(message, error)
1513
1517
  } catch { /* logging must never throw */ }
1514
1518
  }
1519
+ /**
1520
+ * (v0.25.5) writePresetRecord: ONE atomic settings mutate — the six-field
1521
+ * ops (or none for record-only outcomes) plus the outcome record. This is
1522
+ * the settings-CARD path only: the `/cybernetics use` subcommand no longer
1523
+ * routes through here at all (v0.25.5 user ruling: use is session-only,
1524
+ * zero settings writes — see src/sessionRoles.ts), so no extraOps channel
1525
+ * remains and no presetPlan op is ever written from the plugin.
1526
+ */
1515
1527
  const writePresetRecord = async (ops: SettingsOp[], record: PresetRecommendationRecord): Promise<boolean> => {
1516
1528
  const mutate = settingsMutate()
1517
1529
  if (mutate === undefined) {
@@ -1844,16 +1856,20 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
1844
1856
  : 'cybernetics 控制已对本会话关闭:不再注入控制 guidance,硬门禁放行,新输入回归 DSH 原生行为。/cybernetics on 可恢复。',
1845
1857
  }
1846
1858
  }
1847
- // --- `use` subcommand (v0.22.0): switch the role composition (task/
1848
- // verify/triage model + reasoning effort) to any preset plan — the
1849
- // built-in registry ids (PRESET_IDS) or a user-saved custom preset
1850
- // (`custom:<id>`). Reuses the SAME apply pipeline as the settings-card
1851
- // dropdown (handlePresetSelection, same closure) as fire-and-forget;
1852
- // lastHandledPresetPlan is kept in sync here so the one-shot volatile
1853
- // guard stays coherent (a later loader update with the same value is a
1854
- // no-op) while repeated invocations can re-apply the same plan.
1859
+ // --- `use` subcommand (v0.22.0; v0.25.5 session-only, user ruling
1860
+ // 2026-11-11): switch the role composition (task/verify/triage model +
1861
+ // reasoning effort) for THIS SESSION to any preset plan — the built-in
1862
+ // registry ids (PRESET_IDS) or a user-saved custom preset
1863
+ // (`custom:<id>`), or `use off` to clear. ZERO settings writes: the
1864
+ // resolved picks land in the in-memory per-session override store
1865
+ // (src/sessionRoles.ts), which the round-dispatch read point folds in
1866
+ // with "session override > global fields" precedence. The host's
1867
+ // write-gate defect (v0.7.10, dual-module Include instance) is therefore
1868
+ // irrelevant here, and the settings-card dropdown / global fields stay
1869
+ // exactly where the card left them.
1855
1870
  if (arg === 'use' || arg.startsWith('use ')) {
1856
1871
  const planArg = rawInput.slice(3).trim()
1872
+ const sessionKey = sessionRolesKeyOf(agent)
1857
1873
  const listPlans = (): string[] => {
1858
1874
  const lines = PRESET_IDS.map((id) => `- ${id}:${presetChoiceLabel(id)}`)
1859
1875
  try {
@@ -1866,11 +1882,24 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
1866
1882
  if (planArg === '') {
1867
1883
  // No argument: plain-text listing (the command channel has no
1868
1884
  // picker UI); the user confirms with a second invocation.
1885
+ const current = getSessionRolesOverride(sessionKey)
1869
1886
  return {
1870
1887
  kind: 'success' as const,
1871
1888
  text:
1872
1889
  'cybernetics 可用预置方案:\n' + listPlans().join('\n') +
1873
- '\n再次输入 /cybernetics use <id> 应用其中一个方案。',
1890
+ (current ? `\n当前会话覆盖:${current.label}(${formatSessionRolesOverride(current)})` : '') +
1891
+ '\n再次输入 /cybernetics use <id> 应用其中一个方案;/cybernetics use off 清除会话覆盖。',
1892
+ }
1893
+ }
1894
+ // Clear: back to the global fields for this session.
1895
+ if (planArg === 'off') {
1896
+ const had = clearSessionRolesOverride(sessionKey)
1897
+ presetDiag({ event: 'use-session-apply', preset: '', label: '', cleared: true, had })
1898
+ return {
1899
+ kind: 'success' as const,
1900
+ text: had
1901
+ ? 'cybernetics: 已清除本会话的预置方案覆盖,回到全局配置(下一轮 goal 生效)。'
1902
+ : 'cybernetics: 本会话当前没有预置方案覆盖(一直跟随全局配置)。',
1874
1903
  }
1875
1904
  }
1876
1905
  const isCustom = isCustomPresetId(planArg)
@@ -1888,28 +1917,107 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
1888
1917
  '\n输入 /cybernetics use <id> 应用其中一个方案。',
1889
1918
  }
1890
1919
  }
1891
- lastHandledPresetPlan = planArg
1892
- void handlePresetSelection(planArg)
1920
+ if (planArg === PRESET_AUTO_ID) {
1921
+ // Session-only auto: mark this session pending and hand the brief to
1922
+ // THIS session's agent (the preset_recommend tool hop 1), whose hop-2
1923
+ // answer lands in the session override instead of the global fields.
1924
+ // The settings card path (dropdown auto) is untouched.
1925
+ setSessionAutoPending(sessionKey)
1926
+ try {
1927
+ const target = agent as { status?: string; steer?: (m: unknown) => void; followup?: (m: unknown) => void }
1928
+ const message = {
1929
+ id: randomUUID(),
1930
+ role: 'user',
1931
+ content: [{
1932
+ type: 'text',
1933
+ text:
1934
+ '<cybernetics_preset>\n用户通过 /cybernetics use auto 请求本会话的预置方案自动推荐(仅本会话生效,不修改全局配置)。\n' +
1935
+ '请直接调用 preset_recommend 工具(不带 recommendation 参数)获取实时模型目录与推荐要求,' +
1936
+ '在自己的上下文中给出 task/verify/triage 的选择,然后再次调用 preset_recommend 并把推荐 JSON 传入 recommendation 参数提交。\n' +
1937
+ '本次提交将应用为本会话的角色覆盖。\n</cybernetics_preset>',
1938
+ }],
1939
+ source: { kind: 'cybernetics', preset: 'auto', scope: 'session' },
1940
+ }
1941
+ if (target?.status === 'running' && typeof target.steer === 'function') target.steer(message)
1942
+ else if (typeof target.followup === 'function') target.followup(message)
1943
+ } catch { /* best-effort: the tool remains callable from context */ }
1944
+ presetDiag({ event: 'use-session-apply', preset: PRESET_AUTO_ID, label: presetChoiceLabel(PRESET_AUTO_ID), phase: 'pending-handoff' })
1945
+ return {
1946
+ kind: 'success' as const,
1947
+ text:
1948
+ `cybernetics: 已交办本会话的预置方案自动推荐(${presetChoiceLabel(PRESET_AUTO_ID)})。` +
1949
+ 'preset_recommend 的推荐结果将仅应用为本会话的 task/verify/triage 覆盖;不修改全局配置。',
1950
+ }
1951
+ }
1952
+ // Built-in vendor plan or user-saved custom plan: resolve the roles
1953
+ // and store them as the session override (no settings mutate at all).
1954
+ let overrideRoles: Partial<Record<'task' | 'verify' | 'triage', { provider?: string; model?: string; reasoningEffort?: string }>> = {}
1955
+ let overrideLabel = planArg
1956
+ let placeholder = false
1957
+ if (isCustom) {
1958
+ const entryId = customPresetEntryId(planArg)
1959
+ let entry
1960
+ try {
1961
+ entry = parseCustomPresets(readConfigField(config.customPresets, '')).find((p) => p.id === entryId)
1962
+ } catch { entry = undefined }
1963
+ if (entry === undefined) {
1964
+ return { kind: 'error' as const, text: `cybernetics: 自定义方案 "${planArg}" 不存在(可能已被删除),未应用任何更改。` }
1965
+ }
1966
+ const appliedCustom = vendorPresetApplyOps(customPresetAsVendor(entry))
1967
+ overrideRoles = (appliedCustom.variant !== undefined ? appliedCustom.variant.roles : entry.roles) as typeof overrideRoles
1968
+ overrideLabel = entry.name
1969
+ } else {
1970
+ const preset = presetById(planArg)!
1971
+ const applied = vendorPresetApplyOps(preset)
1972
+ if (!applied.ready) placeholder = true
1973
+ else overrideRoles = (applied.variant !== undefined ? applied.variant.roles : preset.roles) as typeof overrideRoles
1974
+ overrideLabel = presetChoiceLabel(planArg)
1975
+ }
1976
+ if (placeholder) {
1977
+ return {
1978
+ kind: 'error' as const,
1979
+ text: `cybernetics: 预置方案 "${planArg}" 为占位条目(模型组合待填充),本次未应用任何更改。`,
1980
+ }
1981
+ }
1982
+ setSessionRolesOverride(sessionKey, {
1983
+ preset: planArg,
1984
+ label: overrideLabel,
1985
+ roles: overrideRoles,
1986
+ appliedAt: new Date().toISOString(),
1987
+ })
1988
+ presetDiag({
1989
+ event: 'use-session-apply',
1990
+ preset: planArg,
1991
+ label: overrideLabel,
1992
+ picks: overrideRoles,
1993
+ cleared: false,
1994
+ })
1893
1995
  return {
1894
1996
  kind: 'success' as const,
1895
1997
  text:
1896
- `cybernetics: 正在应用预置方案 "${planArg}"(${presetChoiceLabel(planArg)})——` +
1897
- 'task/verify/triage 的模型与思考强度将写入插件配置(全局生效,影响所有并发会话)。结果见设置卡片的结果记录。',
1998
+ `已在本会话应用预置方案 "${overrideLabel}":${formatSessionRolesOverride({ preset: planArg, label: overrideLabel, roles: overrideRoles, appliedAt: '' })};` +
1999
+ '仅本会话生效(会话结束失效),不修改全局配置。/cybernetics use off 可清除覆盖、回到全局配置。',
1898
2000
  }
1899
2001
  }
1900
2002
  // No argument (or unknown text) = status report.
1901
2003
  const effective = override === null ? globalOn : override
1902
2004
  const overrideText = override === null ? '未设置(遵循全局配置)' : override ? 'on' : 'off'
2005
+ const sessionOverride = getSessionRolesOverride(sessionRolesKeyOf(agent))
2006
+ const sessionOverrideText = sessionOverride === undefined
2007
+ ? '无(跟随全局配置)'
2008
+ : `${sessionOverride.label}(${formatSessionRolesOverride(sessionOverride)})— 清除:/cybernetics use off` +
2009
+ (peekSessionAutoPending(sessionRolesKeyOf(agent)) ? ';auto 推荐待提交(preset_recommend)' : '')
1903
2010
  return {
1904
2011
  kind: 'success' as const,
1905
2012
  text:
1906
2013
  `cybernetics 本会话状态:${effective ? '生效' : '关闭'}\n` +
1907
2014
  `- 会话 override:${overrideText}\n` +
2015
+ `- 会话预置方案覆盖:${sessionOverrideText}\n` +
1908
2016
  `- 全局 allGoalsMode:${globalOn ? 'on' : 'off'}\n` +
1909
2017
  `- 硬门禁 hardGate:${hardGate() ? (resolveGateMode(config, true) === 'allowlist' ? 'on(allowlist)' : 'on(denylist)') : 'off'}\n` +
1910
2018
  `- 新输入策略 inputPolicy:${readConfigField(config.inputPolicy, 'insert')}\n` +
1911
2019
  `- 轮完成通知策略 noticePolicy:${readConfigField(config.noticePolicy, 'insert')}\n` +
1912
- `用法:/cybernetics on | /cybernetics off | 无参数或 status = 显示状态 | /cybernetics use [方案id] = 应用预置方案(无参数列出全部可用方案)`,
2020
+ `用法:/cybernetics on | /cybernetics off | 无参数或 status = 显示状态 | /cybernetics use [方案id] = 应用预置方案(仅本会话生效;无参数列出全部可用方案;use off 清除覆盖)`,
1913
2021
  }
1914
2022
  }
1915
2023
  const registerCommand = (session: unknown): void => {
@@ -2233,6 +2341,13 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
2233
2341
  if (verifyEffort) roleOverrides.verify = { ...roleOverrides.verify, reasoningEffort: verifyEffort }
2234
2342
  const triageEffort = sanitizeReasoningEffort(readConfigField(config.triageAgentReasoningEffort, ''))
2235
2343
  if (triageEffort) roleOverrides.triage = { ...roleOverrides.triage, reasoningEffort: triageEffort }
2344
+ // v0.25.5 (user ruling 2026-11-11): a `/cybernetics use <id>` session
2345
+ // override wins over the global fields above — applied LAST, per role
2346
+ // and per field, so uncovered roles/fields keep the global value and a
2347
+ // session without `use` behaves exactly as before. In-memory only; the
2348
+ // global fields (and the settings card) are never read-modified here.
2349
+ const sessionOverride = getSessionRolesOverride(sessionRolesKeyOf(parent))
2350
+ const effectiveRoleOverrides = mergeSessionRolesOverride(roleOverrides, sessionOverride)
2236
2351
 
2237
2352
  // The session's durable objective, as a classification-input fallback
2238
2353
  // for targets that carry none (v0.16 smart selection reads it).
@@ -2243,7 +2358,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
2243
2358
  // start so a settings-panel edit governs the next round (0/undefined
2244
2359
  // falls back to the controller's built-in default).
2245
2360
  roundsPerSubgoal: Math.max(1, Math.floor(readConfigField(config.roundsPerSubgoal, 3))),
2246
- roles: roleOverrides,
2361
+ roles: effectiveRoleOverrides,
2247
2362
  }
2248
2363
  /**
2249
2364
  * v0.16 smart agent model selection — per LAUNCH, not per call. A
@@ -2272,15 +2387,15 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
2272
2387
  depth: target.depth ?? 'full',
2273
2388
  executor: target.executor ?? 'ephemeral',
2274
2389
  taskRoleKey: target.role ?? 'default',
2275
- explicit: roleOverrides,
2390
+ explicit: effectiveRoleOverrides,
2276
2391
  options: catalog.options,
2277
2392
  session: sessionRouteOf(parent),
2278
2393
  })
2279
2394
  // The pure merge only produces rows for roles without explicit
2280
2395
  // provider/model, so spreading them over the folded config cannot
2281
2396
  // clobber an explicit value; a merged COPY keeps controllerConfig
2282
- // (and roleOverrides) untouched for the next launch.
2283
- const roles: RolesConfig = { ...roleOverrides }
2397
+ // (and effectiveRoleOverrides) untouched for the next launch.
2398
+ const roles: RolesConfig = { ...effectiveRoleOverrides }
2284
2399
  for (const [roleKey, spec] of Object.entries(overrides)) {
2285
2400
  if (spec) roles[roleKey] = { ...roles[roleKey], ...spec }
2286
2401
  }
@@ -2615,9 +2730,13 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
2615
2730
  return [{ type: 'text', text: `推荐未应用(${value?.status}):${String(value?.reason ?? 'unknown')}` }]
2616
2731
  },
2617
2732
  },
2618
- async execute(args: { recommendation?: string }, _exec: any) {
2733
+ async execute(args: { recommendation?: string }, exec: any) {
2619
2734
  const llmFace = resolveLlmFace()
2620
2735
  const llmCtx = llmFace !== undefined ? { llm: llmFace } : ctx
2736
+ // v0.25.5 session-only auto: when THIS session carries a `use auto`
2737
+ // pending marker, the tool's second hop lands as the session role
2738
+ // override (zero settings writes) instead of the global card path.
2739
+ const sessionKey = sessionRolesKeyOf(exec?.agent)
2621
2740
  const answer = typeof args?.recommendation === 'string' ? args.recommendation.trim() : ''
2622
2741
  if (answer === '') {
2623
2742
  // Hop 1: hand the material over (pure probe + render, zero LLM calls).
@@ -2631,14 +2750,21 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
2631
2750
  const probe = await probeCatalogOptions(llmCtx)
2632
2751
  if (!Array.isArray(probe.options) || probe.options.length === 0) {
2633
2752
  const reason = '当前系统模型目录不可用或为空,无法校验推荐'
2634
- await writePresetRecord([], recordOf(PRESET_AUTO_ID, { status: 'degraded', catalogSize: 0, reason }))
2753
+ if (!takeSessionAutoPending(sessionKey)) {
2754
+ await writePresetRecord([], recordOf(PRESET_AUTO_ID, { status: 'degraded', catalogSize: 0, reason }))
2755
+ } else {
2756
+ presetDiag({ event: 'use-session-apply', preset: PRESET_AUTO_ID, phase: 'degraded-catalog' })
2757
+ }
2635
2758
  return { status: 'degraded', catalogSize: 0, reason }
2636
2759
  }
2637
2760
  const outcome = await applyRecommendationAnswer(llmCtx, answer, probe.options)
2761
+ // Consume the pending marker exactly once (before any early return).
2762
+ const sessionAutoApply = takeSessionAutoPending(sessionKey)
2638
2763
  presetDiag(outcome.ok ? {
2639
2764
  event: 'preset-tool-apply',
2640
2765
  status: 'applied',
2641
2766
  catalogSize: probe.options.length,
2767
+ ...(sessionAutoApply ? { sessionScope: true } : {}),
2642
2768
  picks: Object.fromEntries((['task', 'verify', 'triage'] as const).filter((k) => outcome.picks[k] !== undefined).map((k) => [k, `${outcome.picks[k]!.provider}/${outcome.picks[k]!.model}`])),
2643
2769
  ignoredSession: outcome.ignoredSession,
2644
2770
  notes: outcome.notes,
@@ -2649,8 +2775,11 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
2649
2775
  reason: outcome.reason,
2650
2776
  })
2651
2777
  if (!outcome.ok) {
2652
- // Rejected: the card learns why, the caller gets a correctable error.
2653
- await writePresetRecord([], recordOf(PRESET_AUTO_ID, { status: 'degraded', catalogSize: probe.options.length, reason: outcome.reason }))
2778
+ // Rejected: the card learns why (card path only), the caller gets a
2779
+ // correctable error. The session-only path writes nothing anywhere.
2780
+ if (!sessionAutoApply) {
2781
+ await writePresetRecord([], recordOf(PRESET_AUTO_ID, { status: 'degraded', catalogSize: probe.options.length, reason: outcome.reason }))
2782
+ }
2654
2783
  return { status: 'rejected', catalogSize: probe.options.length, reason: outcome.reason }
2655
2784
  }
2656
2785
  const picks: Record<string, string> = {}
@@ -2661,6 +2790,31 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
2661
2790
  picks[roleKey] = `${pick.provider}/${pick.model}`
2662
2791
  efforts[roleKey] = pick.reasoningEffort ?? ''
2663
2792
  }
2793
+ if (sessionAutoApply) {
2794
+ // v0.25.5 session-only landing: store the validated picks as THIS
2795
+ // session's role override; no settings.mutate, no card record.
2796
+ setSessionRolesOverride(sessionKey, {
2797
+ preset: PRESET_AUTO_ID,
2798
+ label: presetChoiceLabel(PRESET_AUTO_ID),
2799
+ // Keep only the dispatch-relevant fields (the catalog rows carry
2800
+ // display metadata like `name` that roles consumers never read).
2801
+ roles: Object.fromEntries(
2802
+ (['task', 'verify', 'triage'] as const).filter((k) => outcome.picks[k]).map((k) => {
2803
+ const { provider, model, reasoningEffort } = outcome.picks[k]!
2804
+ return [k, { provider, model, ...(reasoningEffort ? { reasoningEffort } : {}) }]
2805
+ }),
2806
+ ),
2807
+ appliedAt: new Date().toISOString(),
2808
+ })
2809
+ presetDiag({ event: 'use-session-apply', preset: PRESET_AUTO_ID, label: presetChoiceLabel(PRESET_AUTO_ID), picks, cleared: false })
2810
+ return {
2811
+ status: 'applied',
2812
+ catalogSize: probe.options.length,
2813
+ picks,
2814
+ ...(outcome.notes.length > 0 ? { notes: outcome.notes } : {}),
2815
+ reason: '已应用为本会话的角色覆盖(仅本会话生效,会话结束失效;不修改全局配置)',
2816
+ }
2817
+ }
2664
2818
  const written = await writePresetRecord(outcome.ops, recordOf(PRESET_AUTO_ID, {
2665
2819
  catalogSize: probe.options.length,
2666
2820
  picks,
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Per-session preset-plan role override (v0.25.5, user ruling 2026-11-11):
3
+ * the durable-free `/cybernetics use <id>` target. Unlike the settings-card
4
+ * dropdown path (which writes the six global role fields through the settings
5
+ * service), `use` resolves a plan (built-in PRESET_IDS id or `custom:<id>`,
6
+ * or auto via the preset_recommend tool) into task/verify/triage
7
+ * {provider, model, reasoningEffort} picks and stores them HERE — an
8
+ * in-memory, per-session map. Zero settings.mutate calls anywhere on the use
9
+ * path: the host's write-gate defect (docs/CHANGELOG.md v0.7.10, dsh-app-boot
10
+ * dual-module instance → WeakMap miss) becomes irrelevant for `use`, and the
11
+ * global configuration is never touched.
12
+ *
13
+ * Lifecycle: the map lives in module scope of the plugin entry — it is not
14
+ * persisted anywhere and dies with the host process, i.e. exactly the
15
+ * "session-only" semantics the ruling requires (a session restart falls back
16
+ * to the global fields). `use off` (or the status line's instruction) clears
17
+ * the override and the session falls back to the global fields immediately.
18
+ *
19
+ * Resolution rule consumed by the round-dispatch read point (src/index.ts,
20
+ * where the flat taskAgentModel/… fields fold into `roles`): SESSION OVERRIDE
21
+ * > GLOBAL FIELDS. Uncovered roles and uncovered fields fall through to the
22
+ * global value / session-agent inheritance, so a session without `use`
23
+ * behaves byte-for-byte as before.
24
+ *
25
+ * @module dsh-cybernetics/sessionRoles
26
+ */
27
+
28
+ /** One role's override spec (same shape as RolesConfig rows). */
29
+ export interface SessionRoleSpec {
30
+ provider?: string
31
+ model?: string
32
+ reasoningEffort?: string
33
+ }
34
+
35
+ /** What `use <id>` stores for one session. */
36
+ export interface SessionRolesOverride {
37
+ preset: string
38
+ label: string
39
+ roles: Partial<Record<'task' | 'verify' | 'triage', SessionRoleSpec>>
40
+ appliedAt: string
41
+ }
42
+
43
+ /** Canonical session key: the session log id, falling back to the agent id. */
44
+ export function sessionRolesKeyOf(agent: unknown): string | undefined {
45
+ const a = agent as { session?: { header?: { id?: unknown } }; id?: unknown } | undefined
46
+ const id = a?.session?.header?.id
47
+ if (typeof id === 'string' && id.length > 0) return id
48
+ if (typeof a?.id === 'string' && a.id.length > 0) return a.id
49
+ return undefined
50
+ }
51
+
52
+ const store = new Map<string, SessionRolesOverride>()
53
+ /** Sessions with a pending `use auto` handoff (the tool's second hop lands here). */
54
+ const autoPending = new Map<string, string>()
55
+
56
+ /** Record (or replace — repeated `use` switches) the override for one session. */
57
+ export function setSessionRolesOverride(key: string | undefined, override: SessionRolesOverride): void {
58
+ if (key === undefined) return
59
+ store.set(key, override)
60
+ }
61
+
62
+ /** Read the override; undefined = no override, the session follows global. */
63
+ export function getSessionRolesOverride(key: string | undefined): SessionRolesOverride | undefined {
64
+ if (key === undefined) return undefined
65
+ return store.get(key)
66
+ }
67
+
68
+ /** Clear the override; the session returns to the global fields. */
69
+ export function clearSessionRolesOverride(key: string | undefined): boolean {
70
+ if (key === undefined) return false
71
+ autoPending.delete(key)
72
+ return store.delete(key)
73
+ }
74
+
75
+ /** Mark a session as waiting for the `use auto` recommendation (tool hop 2). */
76
+ export function setSessionAutoPending(key: string | undefined, preset = 'auto'): void {
77
+ autoPending.set(key!, preset)
78
+ }
79
+
80
+ /** Whether this session's preset_recommend hop 2 must land as a session override. */
81
+ export function takeSessionAutoPending(key: string | undefined): boolean {
82
+ if (key === undefined) return false
83
+ const pending = autoPending.get(key)
84
+ if (pending === undefined) return false
85
+ autoPending.delete(key)
86
+ return true
87
+ }
88
+
89
+ /** Non-consuming check (status rendering). */
90
+ export function peekSessionAutoPending(key: string | undefined): boolean {
91
+ if (key === undefined) return false
92
+ return autoPending.has(key)
93
+ }
94
+
95
+ /**
96
+ * The pure merge the round-dispatch read point applies AFTER folding the
97
+ * global fields: session override rows win per role, per field — a role the
98
+ * override does not mention, and fields the row does not set, keep the global
99
+ * (or inherited) value. Returns a NEW object; inputs are never mutated.
100
+ */
101
+ export function mergeSessionRolesOverride(
102
+ roles: Record<string, SessionRoleSpec | undefined>,
103
+ override: SessionRolesOverride | undefined,
104
+ ): Record<string, SessionRoleSpec | undefined> {
105
+ if (override === undefined) return roles
106
+ const merged: Record<string, SessionRoleSpec | undefined> = { ...roles }
107
+ // The override's task role maps onto the canonical 'default' dispatch key.
108
+ const pairs: Array<['default' | 'verify' | 'triage', SessionRoleSpec | undefined]> = [
109
+ ['default', override.roles.task],
110
+ ['verify', override.roles.verify],
111
+ ['triage', override.roles.triage],
112
+ ]
113
+ for (const [key, spec] of pairs) {
114
+ if (!spec) continue
115
+ merged[key] = { ...merged[key], ...spec }
116
+ }
117
+ return merged
118
+ }
119
+
120
+ /** Render the override's picks as `task=a/b(effort), …` for receipts/status. */
121
+ export function formatSessionRolesOverride(override: SessionRolesOverride): string {
122
+ return (['task', 'verify', 'triage'] as const)
123
+ .map((roleKey) => {
124
+ const spec = override.roles[roleKey]
125
+ if (!spec) return `${roleKey}=(未指定,跟随全局)`
126
+ const target = [spec.provider, spec.model].filter(Boolean).join('/') || '(未指定)'
127
+ return spec.reasoningEffort ? `${roleKey}=${target}(${spec.reasoningEffort})` : `${roleKey}=${target}`
128
+ })
129
+ .join(' ')
130
+ }