@mars.liu/dsh-cybernetics 0.8.9 → 0.8.10

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
@@ -42,7 +42,9 @@ flowchart TB
42
42
 
43
43
  When enabled, injects the "ALL-GOALS MODE ACTIVE" routing protocol: **every user request first creates a goal, then executes**; the session agent is forbidden from completing tasks in place with read tools — even a single-file lookup or one calculation goes through the loop. Simple read-only requests → a single `quick` round; complex analysis / write tasks → progressive multi-round `full` (async `start` + round-by-round `collect` convergence). When the mode is off, the prompt segments and revise behavior are exactly the same as v0.6.
44
44
 
45
- ### Hard gate (`hardGate`, default true — active out of the box)
45
+ ### Hard gate (`hardGate`, default true — active out of the box; v0.8.10: toggleable on the settings card)
46
+
47
+ **Hot toggle (v0.8.10).** `hardGate` is a **volatile** field: the plugins-page settings card renders a live switch for it. Saving the toggle commits into the running instance and the plugin disposes or (re)installs the gate listeners (assembly strip + dispatch deny + approval relay) immediately — no restart, no plugin reload. A hot false→true re-derives the mode from the current `gateMode` resolution (explicit config wins; otherwise the structural allowlist). The other gate fields (`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`/`gateApprovalRelay`) remain non-volatile config-document fields; edit those in the profile config document (saving triggers a plugin reload). Deployment nuance: the first time this build ships, the running host still holds the pre-0.8.10 code — restart the host once to load it; after that the toggle is hot (`lib/client.js` needs only a browser refresh).
46
48
 
47
49
  **Effective mode (v0.8.6, default-safe).** When `hardGate` is on and `gateMode` is not configured, the gate runs in **`allowlist`** mode: the top-level session agent keeps only read + conversation + presentation + goal/orchestration verbs (the built-in `DEFAULT_GATE_ALLOWLIST`, extendable via `gateAllowlist` / `gateAllowlistExtra`), and every change-class tool is removed at assembly **and denied at dispatch**. This is the default stance because the old `denylist` default was a trap: it hid only the three names in `gatedTools` (bash/edit/write) and left `bsk_*`, `taskboard_*`, `schedule_*`, `farm_*`, `uno_*`, `ppt_*`, `config_restore` and the rest of the mutation long tail callable — a hard gate in name only. `hardGate: false` installs no gate, exactly as before. An explicitly configured `gateMode` is honored verbatim; the schema has **no default** for `gateMode`, so "unset" stays distinguishable from "explicitly denylist".
48
50
 
@@ -145,7 +147,8 @@ Configure directly on this plugin's card on the Web editor's "Plugins" page —
145
147
  - **All-goals mode** (`allGoalsMode`, volatile): toggle, takes effect immediately on save, prompt segments remount instantly;
146
148
  - **task/verify/triage agent models** (`taskAgentModel` / `verifyAgentModel` / `triageAgentModel`, volatile): dropdown-backed text fields — pick from the live system model list (shown as `providerName · provider/model`) or type "provider/model" / a bare model name; Enter or blur saves, empty inherits from the session agent (triage = the convergence-decision role, full rounds only). A configured value missing from the live model list shows an unavailable warning (runtime falls back to the session agent model) with a one-click Clear;
147
149
  - **Recursion depth / parallel cap** (`roundsPerSubgoal` integer ≥1 / `maxConcurrentRounds` integer ≥0, volatile): number fields, Enter or blur saves, blank restores the default (an `unset` write);
148
- - **Hard gate fields** (`hardGate`/`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`/`gateApprovalRelay`, non-volatile): not served to the card; edit them in the profile config document (saving triggers a plugin reload). `gateMode` has no default: unset + `hardGate: true` runs `allowlist`; an explicit `denylist` is honored and carries the residual risk documented above.
150
+ - **Hard gate** (`hardGate`, volatile since v0.8.10): a boolean toggle on the card — strips write/execution tools from the top-level agent at assembly and routes every change through the goal loop; takes effect immediately on save (gate listeners hot-installed/disposed), no restart needed;
151
+ - **Other gate fields** (`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`/`gateApprovalRelay`, non-volatile): not served to the card; edit them in the profile config document (saving triggers a plugin reload). `gateMode` has no default: unset + `hardGate: true` runs `allowlist`; an explicit `denylist` is honored and carries the residual risk documented above.
149
152
 
150
153
  ### Advanced configuration: roles (profile yaml, agent-guided)
151
154
 
@@ -204,7 +207,7 @@ Companion auditing: each round's structured result lands in `.cybernetics/round-
204
207
  ## Known Limitations
205
208
 
206
209
  1. **Host defect in settings writes**: as described in the "Plugin-page configuration" section, on this runtime, card saves may be rejected by the host before reaching disk (upstream [#12045](https://github.com/deepseek-ai/dsh/discussions/12045)); workarounds: restart the host + hard-refresh the browser, or manual patch rows + restart.
207
- 2. **Save-semantics difference between volatile and non-volatile fields**: volatile fields (`allGoalsMode`, `taskAgentModel`, `verifyAgentModel`, `triageAgentModel`, `roundsPerSubgoal`, `maxConcurrentRounds`) take effect immediately on save without remount; non-volatile fields (`roles` since v0.8.4, `hardGate`, `gateMode`, `gatedTools`, `gateAllowlist*`, `gateApprovalRelay`, `toolName`, `implicitTrigger`) trigger a plugin-row remount on save, requiring a host reload to complete. Moreover, the host settings form only serves and only accepts volatile paths — if the running host's schema is too old, even volatile writes are rejected (a restart fixes it).
210
+ 2. **Save-semantics difference between volatile and non-volatile fields**: volatile fields (`allGoalsMode`, `hardGate` since v0.8.10, `taskAgentModel`, `verifyAgentModel`, `triageAgentModel`, `roundsPerSubgoal`, `maxConcurrentRounds`) take effect immediately on save without remount; non-volatile fields (`roles` since v0.8.4, `gateMode`, `gatedTools`, `gateAllowlist*`, `gateApprovalRelay`, `toolName`, `implicitTrigger`) trigger a plugin-row remount on save, requiring a host reload to complete. Moreover, the host settings form only serves and only accepts volatile paths — if the running host's schema is too old, even volatile writes are rejected (a restart fixes it).
208
211
  3. **`link:` installation caveats**: the client bundle's registration id must equal the package.json `name` (`@mars.liu/dsh-cybernetics`); the loader row id may use the short name `dsh-cybernetics`. The plugin must hang at the **top level** of the profile's loader entries (the client scan enumerates only top-level rows); `src/` changes require a host reload, `lib/` changes require a host restart + browser refresh.
209
212
  4. Other protocol boundaries (the hard gate operating at the assembly layer rather than the execution layer, dsh-goal's focus slot not migrating on supersede, etc.) are covered in the "Known Boundaries" subsections of [docs/protocol.md](docs/protocol.md).
210
213
 
package/README.zh-CN.md CHANGED
@@ -42,7 +42,9 @@ flowchart TB
42
42
 
43
43
  开启后注入「ALL-GOALS MODE ACTIVE」路由协议:**任何用户请求一律先建 goal 再执行**,session agent 禁止用读工具就地完成任务——哪怕单文件查询或一次计算也走回路。简单只读请求 → `quick` 单轮;复杂分析/写任务 → `full` 多轮渐进(`start` 异步启动 + 逐轮 `collect` 收敛)。关闭模式时提示段与 revise 行为与 v0.6 完全一致。
44
44
 
45
- ### 硬门(`hardGate`,默认 true——安装即激活)
45
+ ### 硬门(`hardGate`,默认 true——安装即激活;v0.8.10 起设置卡片可开关)
46
+
47
+ **热切换(v0.8.10)**:`hardGate` 已是 **volatile** 字段——插件页设置卡片上有实时开关。保存即把新值提交进运行实例,插件随即卸载或重装门禁监听(装配摘工具 + 分发拒绝 + 审批透传),**即时生效,无需重启、无需插件重载**。从关到开的热启按当前 `gateMode` 解析结果接线(显式配置优先,否则结构性 allowlist)。其余门字段(`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`/`gateApprovalRelay`)仍是非 volatile 配置文档字段,请在 profile 配置文档中维护(保存触发插件重载)。部署注意:本构建首次上线时运行中宿主仍是旧代码——重启宿主一次加载新代码,此后开关即热生效(`lib/client.js` 只需刷新浏览器)。
46
48
 
47
49
  **生效语义(v0.8.6,默认安全)**:`hardGate` 开启且未配置 `gateMode` 时,门按 **`allowlist`** 运行——顶层 session agent 只保留读 + 对话 + 展示 + goal/编排动词(内置 `DEFAULT_GATE_ALLOWLIST`,可经 `gateAllowlist`/`gateAllowlistExtra` 扩展),所有变更类工具在装配期被**移除**,并在**调用分发层被拒绝**。之所以改为这个默认:旧的 `denylist` 默认是个陷阱——它只隐藏 `gatedTools` 的三个名字(bash/edit/write),`bsk_*`、`taskboard_*`、`schedule_*`、`farm_*`、`uno_*`、`ppt_*`、`config_restore` 以及整个变更工具长尾全都仍可调用,名为硬门、实为半门。`hardGate: false` 与以前完全一致(不装门);显式配置的 `gateMode` 一律原样尊重;schema 不再给 `gateMode` 设默认值,因此「未设置」与「显式 denylist」可以被区分。
48
50
 
@@ -146,7 +148,8 @@ Web 编辑器「插件」页的本插件卡片上直接配置——v0.8.1 起卡
146
148
  - **task/verify/triage agent 模型**(`taskAgentModel` / `verifyAgentModel` / `triageAgentModel`,volatile):下拉加持的文本框——从实时系统模型列表选择(显示 `providerName · provider/model`)或手工输入「provider/model」/裸模型名,回车或移开焦点即保存,留空继承 session agent(triage = 收敛决策角色,仅 full 轮启用)。配置值不在实时模型列表中时显示不可用警告(运行时回退到 session agent 模型)并附一键清除;
147
149
  - **递归深度 / 并行上限**(`roundsPerSubgoal` 整数 ≥1 / `maxConcurrentRounds` 整数 ≥0,volatile):数字框,回车或失焦保存,留空恢复默认(`unset` 写入);
148
150
  - **roles 高级配置**(`roles`,非 volatile):profile 配置 yaml 直接维护——见下方小节;
149
- - **硬门相关**(`hardGate`/`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`/`gateApprovalRelay`,非 volatile):不进入卡片,请在 profile 配置文档中维护(保存触发插件重载)。`gateMode` 无默认值:未设置 + `hardGate: true` 按 `allowlist` 运行;显式 `denylist` 会被尊重,并带有上文记录的残留风险。
151
+ - **硬编排门**(`hardGate`,v0.8.10 起 volatile):卡片布尔开关——装配期从顶层 agent 摘除写/执行工具,所有变更经 goal 循环完成;保存即时生效(门禁监听热装卸),无需重启;
152
+ - **其余门字段**(`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`/`gateApprovalRelay`,非 volatile):不进入卡片,请在 profile 配置文档中维护(保存触发插件重载)。`gateMode` 无默认值:未设置 + `hardGate: true` 按 `allowlist` 运行;显式 `denylist` 会被尊重,并带有上文记录的残留风险。
150
153
 
151
154
  ### roles 高级配置(profile yaml,agent 指导)
152
155
 
@@ -205,7 +208,7 @@ session agent 在会话内通过 `cybernetics` 工具(`toolName` 可配置)
205
208
  ## 已知限制
206
209
 
207
210
  1. **settings 写入的宿主缺陷**:如「插件页配置」一节所述,该运行时上卡片保存可能被宿主在落盘前拒绝(上游 [#12045](https://github.com/deepseek-ai/dsh/discussions/12045));workaround:重启宿主 + 强刷浏览器,或手工 patch 行 + 重启。
208
- 2. **volatile 与非 volatile 字段的保存语义差异**:volatile 字段(`allGoalsMode`、`taskAgentModel`、`verifyAgentModel`、`triageAgentModel`、`roundsPerSubgoal`、`maxConcurrentRounds`)保存即时生效、不 remount;非 volatile 字段(v0.8.4 起的 `roles`、`hardGate`、`gateMode`、`gatedTools`、`gateAllowlist*`、`gateApprovalRelay`、`toolName`、`implicitTrigger`)保存触发插件行 remount,需宿主重载完成。且宿主设置表单只服务也只接受 volatile 路径——运行中宿主的 schema 过旧时,volatile 写也会被拒(重启即愈)。
211
+ 2. **volatile 与非 volatile 字段的保存语义差异**:volatile 字段(`allGoalsMode`、v0.8.10 起的 `hardGate`、`taskAgentModel`、`verifyAgentModel`、`triageAgentModel`、`roundsPerSubgoal`、`maxConcurrentRounds`)保存即时生效、不 remount;非 volatile 字段(v0.8.4 起的 `roles`、`gateMode`、`gatedTools`、`gateAllowlist*`、`gateApprovalRelay`、`toolName`、`implicitTrigger`)保存触发插件行 remount,需宿主重载完成。且宿主设置表单只服务也只接受 volatile 路径——运行中宿主的 schema 过旧时,volatile 写也会被拒(重启即愈)。
209
212
  3. **`link:` 安装注意事项**:客户端 bundle 的注册 id 必须等于 package.json 的 `name`(`@mars.liu/dsh-cybernetics`);loader 行 id 可用短名 `dsh-cybernetics`。插件需挂在本 profile 的**顶层** loader entries(客户端扫描只枚举顶层行);`src/` 改动需宿主重载、`lib/` 改动需重启宿主 + 刷新浏览器。
210
213
  4. 其他协议边界(硬门在装配层不在执行层、dsh-goal 焦点槽不随 superseded 迁移等)见 [docs/protocol.md](docs/protocol.md) 各「已知边界」小节。
211
214
 
package/docs/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@
4
4
  > 当下的功能与使用方式以 [README.md](../README.md) 为准;协议细节见 [protocol.md](protocol.md)。
5
5
  > 约定:`src/` 改动需宿主重载生效;`lib/client.js` 改动需重启宿主(客户端模块图在宿主启动时构建)并刷新浏览器;`workflows/round.js` 改动热生效。
6
6
 
7
+ ## v0.8.10(2026-11-05):hardGate 上设置卡片,volatile 热切换
8
+
9
+ **需求**:用户要在插件页配置卡片上直接开关硬编排门(像 `allGoalsMode` 一样保存即时生效,无需重启)。此前 `hardGate` 是非 volatile 字段,宿主设置表单只服务 volatile 路径,GUI 完全写不了它。
10
+
11
+ - **schema(`src/index.ts`)**:`hardGate` 加 `.volatile()`(保留 `default(true)`),description 更新为「设置卡片可开关,保存后即时生效(热装卸门禁监听),无需重启宿主」。`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`/`gateApprovalRelay` 保持非 volatile 不动。
12
+ - **apply() 门禁接线可装卸**:装配 waterfall 摘工具 + `tools/pre-execute` 分发拒绝 + `gateApprovalRelay` 透传(hardGate 伴随项)提取为 `installGate()`(内部 `gateDisposers`,每次调用先卸旧装再按需安装,幂等)。`hardGate` 改为按使用时读取的函数(`readConfigField` 解 volatile live reference;普通配置缺省仍为不装门,与旧语义一致,schema 默认 true 经 live ref 到达运行时)。监听 `loader/volatile-update`:翻转时先 `registerGuidanceSection()`(HARD GATE 提示段随开关重推导)再 `installGate()`——与 `registerGuidanceSection` 同一重建模式。热关→开按当刻 `resolveGateMode()` 解析结果接线(显式 gateMode 优先,否则结构性 allowlist)。插件卸载 disposer 链不变。
13
+ - **client 卡片(`lib/client.js`)**:`SETTINGS_FIELDS` 首位新增 `{ field: 'hardGate', kind: 'boolean' }`;`fieldRow` 新增 boolean 分支(`booleanRow`,与头部 allGoalsMode 开关同款 switch 控件,走同一 `writeField` 写路径)。双语 locale 补 `settings.hardGate` / `settings.hardGateHint`:开启后顶层 agent 写工具被摘除、所有变更经 goal 循环;即时生效,无需重启。
14
+ - **测试(`test/v03-offline.mjs`,增至 72 项)**:新增用例 71——(a) 初始 `hardGate=false` 经 live ref 热切 true:装配中 bash 被摘、pre-execute 拒绝、guidance 含 HARD GATE;(b) true→false:bash 恢复、assemble/pre-execute 监听已 dispose、guidance 摘除段;(c) 反复切换幂等(始终恰好一组活监听,末态 hardGate=true 仍拒绝);(d) schema 断言 hardGate volatile、其余门字段非 volatile。同步更新既有断言:用例 51/56/57 中 hardGate 从「非 volatile 负向对照」改为 volatile 正向(负向对照换 `gateMode`),volatileForm 服务字段六→七。既有全部用例通过。
15
+ - **文档**:README / README.zh-CN「硬门」小节与「插件页配置」「已知限制」更新——hardGate 从「配置文件维护/重启生效」改为「GUI 卡片开关、volatile 即时生效」;注明首次部署需重启宿主一次加载新代码,此后开关热生效。
16
+ - **生效手段**:`src/index.ts` 改动——重启宿主一次加载新代码,此后开关热生效;`lib/client.js` 改动——重启宿主 + 刷新浏览器(本仓库 `lib/` 即源码)。
17
+
7
18
  ## v0.8.7(2026-11-04):模型下拉 focus-parking(lib/client.js,仅客户端)
8
19
 
9
20
  **问题**:原生 datalist 按输入框当前文本过滤选项——已配置模型的字段聚焦后下拉只剩当前那一个模型,用户看不到完整目录,容易误以为没有别的选择。改进方向不是自绘下拉,而是顺应原生 datalist 的过滤语义。
package/lib/client.js CHANGED
@@ -115,6 +115,9 @@ window.__ModuleLoader__.load({
115
115
  // v0.8.4: roles 高级配置不再上卡片(手工编辑 JSON 对用户不友善)——
116
116
  // 由 agent 指导用户直接编辑 profile 配置 yaml(见 README)。
117
117
  'settings.invalidNumber': '请输入符合范围的整数,或留空恢复默认。',
118
+ // v0.8.10: hardGate 上卡片(volatile,热切换)。
119
+ 'settings.hardGate': '硬编排门',
120
+ 'settings.hardGateHint': '开启后,顶层 agent 的写/执行工具在装配期被摘除,所有变更必须经 cybernetics goal + task agent 委派完成。开关保存后即时生效(热装卸门禁监听),无需重启。',
118
121
  };
119
122
  const en = {
120
123
  'view.goal': 'Goal',
@@ -180,6 +183,9 @@ window.__ModuleLoader__.load({
180
183
  'settings.maxConcurrentRounds': 'Concurrent round cap',
181
184
  'settings.maxConcurrentRoundsHint': 'Global cap on rounds in flight (integer ≥0, default 0 = unlimited); empty restores the default. Enter or blur saves.',
182
185
  'settings.invalidNumber': 'Enter an integer in range, or leave blank to restore the default.',
186
+ // v0.8.10: hardGate joins the card (volatile, hot-toggled).
187
+ 'settings.hardGate': 'Hard orchestration gate',
188
+ 'settings.hardGateHint': 'When on, write/execution tools are stripped from the TOP-LEVEL agent at assembly time; every change must go through a cybernetics goal + task agent. Takes effect immediately on save (gate listeners are hot-installed/disposed); no restart needed.',
183
189
  };
184
190
  //#endregion
185
191
 
@@ -544,10 +550,16 @@ window.__ModuleLoader__.load({
544
550
  // an `unset` op). v0.8.4: roles is no longer on the card — hand-editing
545
551
  // a JSON object in the GUI is unfriendly; roles became non-volatile and
546
552
  // is configured directly in the profile yaml (agent-guided, README).
547
- // The card's field list is back to six:
548
- // allGoalsMode / taskAgentModel / verifyAgentModel / triageAgentModel /
553
+ // The card's field list is back to seven (v0.8.10):
554
+ // hardGate (boolean toggle) / allGoalsMode-toggle header +
555
+ // taskAgentModel / verifyAgentModel / triageAgentModel /
549
556
  // roundsPerSubgoal / maxConcurrentRounds.
550
557
  const SETTINGS_FIELDS = [
558
+ // v0.8.10: hardGate is volatile and joins the card as a boolean toggle
559
+ // (same write path as allGoalsMode: settings mutate → loader volatile
560
+ // commit → loader/volatile-update → the server hot-installs/disposes
561
+ // the gate listeners, no restart).
562
+ { field: 'hardGate', labelKey: 'settings.hardGate', hintKey: 'settings.hardGateHint', kind: 'boolean' },
551
563
  // v0.8.3: the three model fields are kind 'model' — datalist-backed
552
564
  // dropdown over the live host catalog, manual entry still accepted
553
565
  // (a datalist input is a free text input); invalid configured values
@@ -814,7 +826,31 @@ window.__ModuleLoader__.load({
814
826
  return jsx.jsx('span', { children: snap.status === 'ready' ? t(on ? 'settings.summaryOn' : 'settings.summaryOff') : t('settings.unavailable') });
815
827
  }
816
828
 
829
+ // v0.8.10: boolean fields render the same switch face as the header
830
+ // allGoalsMode toggle, per field. The write is the shared
831
+ // writeField(field, !current) path (set op, no drafts involved).
832
+ const booleanRow = (spec) => {
833
+ const on = snap.status === 'ready' && snap.value != null && snap.value[spec.field] === true;
834
+ return jsx.jsx('div', { style: settingsStyles.field, children:
835
+ jsx.jsxs('div', { style: { ...settingsStyles.row }, children: [
836
+ jsx.jsxs('div', { style: settingsStyles.main, children: [
837
+ jsx.jsx('div', { style: settingsStyles.title, children: t(spec.labelKey) }),
838
+ jsx.jsx('div', { style: settingsStyles.desc, children: t(spec.hintKey) }),
839
+ ] }),
840
+ snap.status === 'ready'
841
+ ? jsx.jsx('button', {
842
+ type: 'button', role: 'switch', 'aria-checked': on, 'aria-label': t(spec.labelKey),
843
+ disabled, onClick: () => writeField(spec.field, !on),
844
+ style: { ...settingsStyles.toggle, ...(on ? settingsStyles.trackOn : settingsStyles.trackOff) },
845
+ children: jsx.jsx('span', { style: { ...settingsStyles.knob, ...(on ? settingsStyles.knobOn : settingsStyles.knobOff) } }),
846
+ })
847
+ : null,
848
+ ] })
849
+ }, spec.field);
850
+ };
851
+
817
852
  const fieldRow = (spec) => {
853
+ if (spec.kind === 'boolean') return booleanRow(spec);
818
854
  // v0.8.3: availability verdict for the configured (snapshot) value —
819
855
  // drafts are uncommitted text and never warn. Only a loaded catalog
820
856
  // can flag; an empty/failed catalog keeps every value "available".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mars.liu/dsh-cybernetics",
3
- "version": "0.8.9",
3
+ "version": "0.8.10",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
package/src/controller.ts CHANGED
@@ -322,7 +322,11 @@ export async function startRound(
322
322
  goal: { objective: goal.objective },
323
323
  state: { round, doneSummary: state.doneSummary },
324
324
  subgoal: state.subgoal,
325
- brief: state.brief,
325
+ // Never hand the workflow binder an own property whose value is
326
+ // undefined: structuredClone keeps it and the host-side json()
327
+ // (snapshotJsonValue) rejects nested undefined — the round dies at
328
+ // bind time. Omit the key entirely when the goal has no work order.
329
+ ...(state.brief !== undefined ? { brief: state.brief } : {}),
326
330
  depth: state.depth ?? 'full',
327
331
  role: state.role,
328
332
  roles: config.roles ?? {},
package/src/executor.ts CHANGED
@@ -366,7 +366,10 @@ export async function startTeamRound(
366
366
  goal: { objective: input.objective },
367
367
  state: { round: input.round, doneSummary: input.doneSummary },
368
368
  subgoal: input.subgoal,
369
- brief: state.brief,
369
+ // Same binder rule as controller.startRound: an own property with
370
+ // value undefined is kept by structuredClone and rejected by the
371
+ // host-side json() — omit the key when there is no work order.
372
+ ...(state.brief !== undefined ? { brief: state.brief } : {}),
370
373
  depth: 'full',
371
374
  role: state.role,
372
375
  roles: config.roles ?? {},
package/src/index.ts CHANGED
@@ -148,9 +148,11 @@ export interface Config extends ControllerConfig {
148
148
  // effect on the next goal round. roles stays in the schema but is NOT
149
149
  // volatile (v0.8.4): the panel serves only volatile fields, so it no longer
150
150
  // shows in the GUI — advanced users (agent-guided) configure it directly in
151
- // the profile config yaml; a host reload applies it. hardGate and the gate fields stay non-volatile: the panel
152
- // cannot write them (they change structural wiring — tool stripping listener —
153
- // so they belong in the profile config document instead).
151
+ // the profile config yaml; a host reload applies it. v0.8.10: hardGate itself
152
+ // is volatile and toggleable from the settings card (the gate wiring is
153
+ // installed/disposed live on loader/volatile-update); the fine-grained gate
154
+ // fields (gateMode/gatedTools/gateAllowlist/gateAllowlistExtra) and
155
+ // gateApprovalRelay stay non-volatile config-document fields.
154
156
  export const Config = z.object({
155
157
  /** Model-facing tool name. */
156
158
  toolName: z.string().default('cybernetics').description('模型侧工具名(缺省 cybernetics)。'),
@@ -206,8 +208,14 @@ export const Config = z.object({
206
208
  * Volatile: enforced at every launch, live on the next tool call.
207
209
  */
208
210
  maxConcurrentRounds: z.number().default(0).volatile().description('全局并行轮数上限(缺省 0 = 不限):在跑轮数达到上限时拒绝启动新一轮,防止并行风暴。保存后即时生效,无需重载。'),
209
- /** Hard orchestration gate (default true). */
210
- hardGate: z.boolean().default(true).description('硬编排门(默认激活):装配期从顶层 session agent 摘除执行工具,所有改动必须经 cybernetics goal + task agent。修改会触发插件重载。'),
211
+ /**
212
+ * Hard orchestration gate (default true). Volatile (v0.8.10): the settings
213
+ * card exposes a live toggle; on loader/volatile-update apply() disposes or
214
+ * (re)installs the gate wiring (assemble waterfall + tools/pre-execute deny
215
+ * + approval relay) without a plugin remount, mirroring the
216
+ * registerGuidanceSection re-derive pattern.
217
+ */
218
+ hardGate: z.boolean().default(true).volatile().description('硬编排门(默认开启):装配期从顶层 session agent 摘除执行工具,所有改动必须经 cybernetics goal + task agent。设置卡片可开关,保存后即时生效(热装卸门禁监听),无需重启宿主。'),
211
219
  /** Tool names hidden in denylist mode (default DEFAULT_GATED_TOOLS). */
212
220
  gatedTools: z.array(z.string()).description('denylist 模式下隐藏的工具名(缺省内置 DEFAULT_GATED_TOOLS)。'),
213
221
  /**
@@ -586,15 +594,20 @@ function resolveGateMode(config: Partial<Config>, hardGate: boolean): 'denylist'
586
594
 
587
595
  export function apply(ctx: any, config: Partial<Config> = {}) {
588
596
  const toolName = config.toolName ?? 'cybernetics'
589
- const hardGate = config.hardGate === true
597
+ // v0.8.10: hardGate is a volatile field, so it may arrive as a live
598
+ // reference whose value the settings card commits in place — read it at use
599
+ // time instead of capturing it once (same pattern as allGoalsMode below).
600
+ // Fallback false keeps the plain-object contract unchanged: a config that
601
+ // simply omits hardGate installs no gate, exactly as before; the schema's
602
+ // default(true) reaches the runtime through the live reference the volatile
603
+ // resolution provides.
604
+ const hardGate = (): boolean => readConfigField(config.hardGate, false) === true
590
605
  // v0.8.6 default-safe gate semantics: a user who turns hardGate on but says
591
606
  // nothing about gateMode gets the structural allowlist, not the leaky
592
607
  // denylist. hardGate=false resolves to denylist but no gate is installed,
593
- // so its behavior is unchanged.
594
- const gateMode = resolveGateMode(config, hardGate)
595
- const hardGateGuidance = hardGate
596
- ? (gateMode === 'allowlist' ? HARD_GATE_GUIDANCE_ALLOWLIST : HARD_GATE_GUIDANCE_DENYLIST)
597
- : ''
608
+ // so its behavior is unchanged. gateMode itself stays non-volatile: its
609
+ // plain value is re-read on every (re)install, so a hot hardGate flip
610
+ // re-derives the mode via resolveGateMode at that moment.
598
611
  // allGoalsMode is a volatile field: under the loader's schema resolution it
599
612
  // arrives as a live reference whose value the settings panel commits in
600
613
  // place (no remount), so read it at use time instead of capturing it once.
@@ -624,6 +637,12 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
624
637
  if (disposeSection !== undefined) disposeSection()
625
638
  disposeSection = undefined
626
639
  if (config.implicitTrigger === false) return
640
+ // v0.8.10: the hard-gate wording is re-derived per call — hardGate is
641
+ // volatile, so its guidance segment follows the live toggle too.
642
+ const gateMode = resolveGateMode(config, hardGate())
643
+ const hardGateGuidance = hardGate()
644
+ ? (gateMode === 'allowlist' ? HARD_GATE_GUIDANCE_ALLOWLIST : HARD_GATE_GUIDANCE_DENYLIST)
645
+ : ''
627
646
  disposeSection = ctx.systemPrompt.section({
628
647
  name: `tool:${toolName}`,
629
648
  order: ctx.systemPrompt.getSectionOrder('TOOL_WORKFLOW'),
@@ -632,15 +651,22 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
632
651
  }
633
652
  registerGuidanceSection()
634
653
  disposers.push(() => { if (disposeSection !== undefined) disposeSection() })
635
- ctx.on?.('loader/volatile-update', () => { registerGuidanceSection() })
636
654
 
637
- if (hardGate) {
638
- // The hard gate: a system-prompt/assemble waterfall listener that strips
639
- // execution tools from TOP-LEVEL agents' assemblies. Workflow subagents
640
- // (task/verify/triage) and team teammates are never in agents.roots(),
641
- // so their assemblies pass through untouched with full tool access.
642
- // hardGate/gateMode are non-volatile: the resolved mode is fixed for this
643
- // plugin instance and re-derived on reload (gateMode was resolved above).
655
+ /**
656
+ * v0.8.10: install (or tear down) the hard-gate wiring in step with the
657
+ * volatile hardGate toggle. Idempotent: each call first disposes the
658
+ * previous installation, so repeated toggles never double-register. A
659
+ * hot false→true re-derives gateMode via resolveGateMode from the CURRENT
660
+ * resolution (explicit gateMode config wins; otherwise allowlist).
661
+ */
662
+ let disposeGate: (() => void) | undefined
663
+ function installGate(): void {
664
+ if (disposeGate !== undefined) { disposeGate(); disposeGate = undefined }
665
+ if (!hardGate()) return
666
+ const gateDisposers: Array<() => void> = []
667
+ // The gate is being installed, so hardGate() is true here; resolveGateMode
668
+ // then honors an explicit gateMode or derives the structural allowlist.
669
+ const gateMode = resolveGateMode(config, true)
644
670
  const allowlistMode = gateMode === 'allowlist'
645
671
  // NOTE: schema-resolved config turns an unset z.array field into [] (not
646
672
  // undefined), so fall back on length, not on ?? — otherwise the gate
@@ -651,6 +677,10 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
651
677
  toolName,
652
678
  ])
653
679
  const gated = new Set(config.gatedTools?.length ? config.gatedTools : DEFAULT_GATED_TOOLS)
680
+ // The hard gate: a system-prompt/assemble waterfall listener that strips
681
+ // execution tools from TOP-LEVEL agents' assemblies. Workflow subagents
682
+ // (task/verify/triage) and team teammates are never in agents.roots(),
683
+ // so their assemblies pass through untouched with full tool access.
654
684
  const listenerDisposer = ctx.on('system-prompt/assemble', (assembly: any, context: any, next: () => unknown) => {
655
685
  const scope = context?.scope
656
686
  if (!scope) return next()
@@ -673,7 +703,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
673
703
  }
674
704
  return next()
675
705
  })
676
- disposers.push(() => listenerDisposer?.())
706
+ gateDisposers.push(() => listenerDisposer?.())
677
707
 
678
708
  // Execution-layer gate (the assembly filter above only hides names; the
679
709
  // host resolves any registered tool by name, so an unadvertised call must
@@ -699,7 +729,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
699
729
  'Delegate the change: create a cybernetics goal (action "start" with title+brief) and let a task agent execute it.',
700
730
  })
701
731
  })
702
- disposers.push(() => preDisposer?.())
732
+ gateDisposers.push(() => preDisposer?.())
703
733
 
704
734
  // v0.8.5 approval relay (gateApprovalRelay, default true): delegated
705
735
  // children (workflow subagents, teammates) inherit the host's PINNED
@@ -806,7 +836,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
806
836
  // A relay must never break delegation — leave the pinned policy.
807
837
  }
808
838
  })
809
- disposers.push(() => createdDisposer?.())
839
+ gateDisposers.push(() => createdDisposer?.())
810
840
 
811
841
  const RELAY_QUESTION_ID = 'cybernetics-approval-relay'
812
842
  const RELAY_ALLOW = '允许一次(Allow once)'
@@ -957,9 +987,17 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
957
987
  const outcome = await relayToSessionAgent(req, root)
958
988
  return outcome === undefined ? next() : outcome
959
989
  }, { prepend: true })
960
- disposers.push(() => relayDisposer?.())
990
+ gateDisposers.push(() => relayDisposer?.())
961
991
  }
992
+
993
+ disposeGate = () => { gateDisposers.forEach((d) => d()) }
962
994
  }
995
+ installGate()
996
+ disposers.push(() => { if (disposeGate !== undefined) disposeGate() })
997
+ ctx.on?.('loader/volatile-update', () => {
998
+ registerGuidanceSection()
999
+ installGate()
1000
+ })
963
1001
 
964
1002
  const disposer = (ctx.tools as { register: (d: unknown) => () => void }).register({
965
1003
  name: toolName,
@@ -1217,9 +1255,12 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
1217
1255
  }
1218
1256
  }
1219
1257
 
1220
- // Per-call overrides.
1258
+ // Per-call overrides. 'revise' is excluded from the brief override: the
1259
+ // call targets the OLD goal (which gets frozen as superseded below), so
1260
+ // writing the new work order onto it would pollute frozen history — the
1261
+ // revise branch assigns the brief to the successor goal itself.
1221
1262
  if (args.subgoal) loop.subgoal = args.subgoal
1222
- if (args.brief) loop.brief = args.brief
1263
+ if (args.brief && action !== 'revise') loop.brief = args.brief
1223
1264
  if (args.depth) loop.depth = args.depth
1224
1265
  if (args.executor) loop.executor = args.executor
1225
1266
  if (args.role) loop.role = args.role
@@ -1263,6 +1304,20 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
1263
1304
  ? `cybernetics: 已有 ${running.size} 个运行中的轮,达到全局并行上限 ${cap}(可在设置面板调高 maxConcurrentRounds 或等待完成)`
1264
1305
  : undefined
1265
1306
  }
1307
+ /**
1308
+ * Brief visibility in tool results: without it the model cannot tell
1309
+ * whether a round was launched WITH a work order — the result looked
1310
+ * identical either way, and a briefless round silently sent the task
1311
+ * agent in with only objective/title (a no-feedback loop).
1312
+ */
1313
+ const briefWarning = (target: LoopState): { warning?: string } =>
1314
+ typeof target.brief === 'string' && target.brief.trim().length > 0
1315
+ ? {}
1316
+ : {
1317
+ warning:
1318
+ '该 goal 没有工作委托书(brief):task agent 只能看到 objective/title。' +
1319
+ '复杂任务请用 cybernetics revise 补 brief(含背景、路径、验收标准)。',
1320
+ }
1266
1321
  const objective = loop.objective ?? goalView?.objective ?? loop.title ?? ''
1267
1322
 
1268
1323
  /**
@@ -1396,10 +1451,16 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
1396
1451
  revised: true,
1397
1452
  round,
1398
1453
  started: true,
1454
+ // Work-order visibility: the successor's effective brief (supplied
1455
+ // or inherited), so the caller can verify the task agent's mandate.
1456
+ workOrder: next.brief
1457
+ ? { present: true, chars: next.brief.length, preview: next.brief.slice(0, 80) }
1458
+ : { present: false },
1399
1459
  ...(aborted ? { aborted } : {}),
1400
1460
  abort: effectiveAbort,
1401
1461
  revision: { from: previous.goalId, to: newGoalId, reason, at },
1402
1462
  ...(revisionLog ? { revisionLog } : {}),
1463
+ ...briefWarning(next),
1403
1464
  }
1404
1465
  }
1405
1466
 
@@ -1449,7 +1510,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
1449
1510
 
1450
1511
  if (action === 'start') {
1451
1512
  const { round } = await launchBackground(loop)
1452
- return { goalId: loop.goalId, round, started: true }
1513
+ return { goalId: loop.goalId, round, started: true, ...briefWarning(loop) }
1453
1514
  }
1454
1515
 
1455
1516
  // Blocking round (default). Both substrates expose {round, result, dispose}.
@@ -1464,7 +1525,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
1464
1525
  applyDecision(loop, result.round, result, decision)
1465
1526
  // Round settlement decision applied — republish (round / status / subgoal).
1466
1527
  await publishBoard()
1467
- return { goalId: loop.goalId, round: result.round, decision, result }
1528
+ return { goalId: loop.goalId, round: result.round, decision, result, ...briefWarning(loop) }
1468
1529
  },
1469
1530
  })
1470
1531