@mars.liu/dsh-cybernetics 0.6.0 → 0.7.8

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
@@ -20,6 +20,99 @@ task agent → verify agent → triage agent,controller 在轮间推进,用
20
20
 
21
21
  ## 状态
22
22
 
23
+ v0.7:**全量 goal 模式(`allGoalsMode`,默认 false)**——开启后注入「ALL-GOALS MODE ACTIVE」
24
+ 路由协议:任何用户请求一律先建 goal 再执行(禁止就地分析);简单只读 → quick 单轮;
25
+ 复杂分析/写任务 → full 多轮渐进(start 异步 + collect 收敛);revise 缺省 `abortPrevious`
26
+ 改为自动决策树(查 goal 板 running 状态:旧轮在跑且 objective/depth 被改写 → 自动中止;
27
+ 旧轮在跑但目标不变 → 等待自然结束;旧轮未跑 → 直启),显式传值仍是人工控制。
28
+ 每次修订的 `revisions-<新goalId>.jsonl` 审计行新增 `abort: {abort, mode, basis}`;
29
+ `revise` 返回值同样携带 `abort`。决策树纯函数在 `src/controller.ts` 的 `decideAbort`。
30
+ 关闭模式时提示段与 revise 行为与 v0.6 完全一致(离线测试 48–50 锁死)。`src/` 改动需宿主重载。
31
+ v0.7.1:**插件配置面板可视化配置**——`src/index.ts` 导出 schemastery `Config` schema,宿主
32
+ 0.1.7 settings model 据此派生 Web 编辑器插件条目的设置页(机制同 `@linxin666/dsh-ssh` /
33
+ `@linxin666/dsh-usage`:loader 经 `runtime.Config['~standard'].validate` 解析配置,面板由
34
+ dsh-client-ui-settings 的共享配置表单渲染,含每个字段的 `description` 文案)。
35
+ `allGoalsMode` 标记 `.volatile()`:在面板上切换保存后 loader 直接把新值提交进运行实例
36
+ (不 remount),提示段经 `loader/volatile-update` 重挂即时生效;`hardGate`/`gateMode`/
37
+ `gatedTools`/`roles` 等结构性字段为普通字段,保存后触发插件行 remount(需宿主重载完成)。
38
+ 注意:schema 解析会把未设置的 `z.array` 字段变成 `[]` 而非 `undefined`,`apply()` 的门
39
+ 回退因此按「空数组 = 未设置」处理(离线测试 51 锁死)。此前版本只能手改 profile JSON
40
+ 里的 `allGoalsMode: true`;现在在 Web 编辑器「插件」页的本插件卡片上直接切换
41
+ 即可(保存 volatile 字段无需重启宿主,刷新浏览器可见最新面板)。
42
+ v0.7.2/0.7.3:**插件页设置卡片**——实测仅有服务端 Config schema 不会在「插件」页产生任何
43
+ 卡片(本 profile 的 @linxin666/dsh-client-ui-plugin-manager 停用了官方 ui-settings-plugins
44
+ 设置分区,schema 自动面板无处落脚)。官方插件页(@deepseek-ai/dsh-client-ui-plugin-manager)
45
+ 只通过它声明的 slot 渲染插件配置:`plugins.item`(「内置插件配置」分组列表卡)、
46
+ `plugins.bundle.config`(按 bundle 名分发,bundle 详情页配置区)、`plugins.row.config`
47
+ (按 `<bundle>#<rowId>` 分发,组件详情页)。`lib/client.js` 因此注册三个席位:
48
+ `plugins.item`(id = `dsh-cybernetics`)、`plugins.bundle.config`(key 同时注册
49
+ profile 依赖名 `dsh-cybernetics` 与包名 `@mars.liu/dsh-cybernetics`)、`plugins.row.config`
50
+ (key `<bundle>#dsh-cybernetics`)。`plugins.item` 无条件注册(v0.7.5 加固):早先的
51
+ cut 用 `configForms.whileServed` 门控,宿主尚未服务该命名空间时整张卡消失,与「注册
52
+ 从未运行」不可区分;现改为 `ctx.configForms.get()` 绑定一个跟随共享 describe mirror
53
+ 的活 ConfigFormController,命名空间就绪的瞬间卡片翻成开关,未就绪时渲染
54
+ 「配置不可用」行——可见、可诊断。卡片读写 `ctx.configForms.get('dsh-cybernetics')`
55
+ 的 ConfigForm;v0.7.3 修复:详情页的 owner prop `form`(宿主 ConfigPageForm
56
+ `{state, mutate}`)在渲染 seam 上覆盖 inject 的 form(renderer renderEntry 先展开
57
+ inject 后展开 ownerProps),卡片两种形态都支持(离线测试 52/53 锁死)。
58
+ `dsh.client.inject` 增补 `@deepseek-ai/dsh-client-ui-settings` 保证 configForms 提供方
59
+ 先到。改动 `lib/client.js` 后需重启宿主(客户端模块图在宿主启动时构建)并刷新浏览器。
60
+ v0.7.4:**保存失败可见化**——实机验证(2026-11-03,浏览器实测):卡片注册、summary 文案、开关渲染
61
+ 均正常,但切换保存经 `configForms`→`remote.settings.mutate` 到宿主后被**静默拒绝**(写不落盘:
62
+ profile `cordis.patch.yml` 未变更,RPC 错误不上控制台,卡片此前 `.catch(() => {})` 吞掉失败,
63
+ 第一次点击甚至把 promise 挂死在「保存中」)。离线模拟宿主 `dsh-config-editor.edit` 的文档校验
64
+ (`composeEntries([patches])` 含 bundle insert 层 + 新增 override 行)是**通过**的,说明拒绝发生在
65
+ 更早的环节(`reconcileProfilePatches` 预检 / loader 热重载路径),宿主日志在本环境不可读,确切
66
+ 拒绝原因待宿主侧确认。本轮改动:卡片不再吞错——写入被拒(settles false / reject)或 15 s 未返回
67
+ 都显示「保存未生效(宿主拒绝了写入)」并立即把开关还原到宿主确认的快照值;成功路径语义不变
68
+ (volatile 即时生效)。根治写路径需要宿主侧 settings.mutate 的拒绝日志(`harness-log` 或
69
+ `dsh plugin --profile web` 诊断),超出本轮插件仓库边界。
70
+ v0.7.5:**卡片注册无条件化**——见上 v0.7.2/0.7.3 段的 `plugins.item` 说明;
71
+ 宿主日志复核(2026-11-03,`~/.dsh/logs/`):未发现 cybernetics settings mutate 的拒绝
72
+ 记录(最近一次启动日志是 EADDRINUSE 的失败实例,当前宿主进程无对应落盘日志),
73
+ 写路径的宿主侧根因仍待带日志的复现。
74
+ v0.7.6:**路径 (b) 落点取证 + 拒绝原因透出**——代码级取证(2026-11-04,宿主 checkout 只读)确认:
75
+ `settings.mutate` 的唯一宿主写路径是 `@deepseek-ai/dsh-settings SettingsForms.write` →
76
+ `@deepseek-ai/dsh-config-editor ConfigEditor.edit`,后者写入的就是**用户 patch 行**(在 profile
77
+ `cordis.patch.yml` 追加/更新 `{id, name, config}` 非 insert 行,再以
78
+ `reconcileProfilePatches(root, patches, "dsh", [entry.id])` 应用——见
79
+ `dsh-config-editor/lib/index.js` edit() 的 `findLastIndex` + `document.add({id, name, config})`)。
80
+ 即插件侧写入落点**从来就是**宿主允许的用户 patch 行,"写到了 bundle insert 行"的假设不成立;
81
+ 实测复现(2026-11-04 浏览器点击开关 + `cordis.patch.yml` mtime 前后比对)显示拒绝发生在宿主
82
+ `edit()` **落盘之前**(文件未被触碰、快速返回 settings/rejected),确切 throw 行仍需宿主侧
83
+ stderr(tty,本环境不可读)。本轮插件侧改动:写入被拒时经插件自有的 `remote.settings.mutate`
84
+ (同一宿主写路径)做**一次无条件 revision 回退写**(expectedRevision 省略 = 宿主文档化的
85
+ 无条件写),陈旧 revision 冲突可就地自愈;仍被拒时卡片以
86
+ 「保存未生效,宿主拒绝原因:{message}」透出宿主 RemoteError 原文(ConfigFormController 原本把
87
+ 错误坍缩成裸 `false`)。离线测试增至 54 项,新增写入落点断言(回退写无 revision、主写成功不触
88
+ 回退、无 remote 降级不崩)。
89
+ v0.7.7:**agent 模型字段 volatile 化(表单可写性根治)**——本轮实机复现(2026-11-04 浏览器点击
90
+ 插件详情页开关):写仍被宿主拒绝(快照回弹「全目标模式:关」,profile `cordis.patch.yml` mtime
91
+ 21:58:35 未变,拒绝发生在落盘之前),且当前宿主进程 2026-11-03 22:04 启动、早于 v0.7.6 提交
92
+ (00:40),运行中的仍是旧客户端模块。新增宿主侧取证:`@deepseek-ai/dsh-settings` 的
93
+ `volatileForm(schema)`(lib/index.js:122-131)只保留 `.volatile()` 字段作为整个设置表单,
94
+ `write()`(同文件 502-531 行)对每个写入路径做 `isVolatilePath` 检查并抛
95
+ 「Config field "…" is not volatile」——即宿主表单**只服务也只接受** volatile 字段;据此把
96
+ taskAgentModel / verifyAgentModel 两个扁平字段也改为 `.volatile()`(此前非 volatile =
97
+ 在插件页既不可见也不可写),读取点改为 `readConfigField(config.taskAgentModel, '')` 解包
98
+ 活引用('' = 与 session agent 一致),语义保持「保存后下一轮 goal 即生效,无需重载」。
99
+ 离线测试 55 项全过(测试 55 改为断言两字段 volatile + 活引用解析 + readConfigField 解包布线)。
100
+ 生效步骤:重启宿主(客户端模块图 + 服务端 schema 都在启动时装配)+ 强刷浏览器;验证步骤:
101
+ 插件详情页拨「全目标模式」→ 开关保持 + 摘要行变「全目标模式:开」+ profile patch 出现
102
+ `dsh-cybernetics` 用户行;仍失败时卡片将显示宿主拒绝原文(v0.7.6 面)。
103
+ v0.7.8:**写入落点证明测试(测试 56)**——把宿主 `@deepseek-ai/dsh-settings` write() 的全部
104
+ 预写闸门(volatileForm / isVolatilePath / projectForm / mutate op reduce / validatePaths /
105
+ strip + mergeLayers,lib/index.js 122-179 与 488-534 行)逐行复刻,对**真实导出的 Config
106
+ schema** 重放卡片的 toggle 写:开关写通过每一道闸门、经 schemastery 校验
107
+ (= cordis `resolveConfig` 的精确检查)后产出持久化 config,最终落点形状恰为
108
+ `ConfigEditor.edit`(dsh-config-editor lib/index.js 150-155 行)追加到 profile
109
+ `cordis.patch.yml` 的**用户 patch 行** `{id, name, config}`(无 `insert` 键)——即
110
+ `reconcileProfilePatches`(dsh-app-boot lib/index.js:3794)唯一接受的落点。
111
+ 负向对照:`hardGate` 等非 volatile 路径在写前即被同一闸门以
112
+ 「Config field "hardGate" is not volatile」拒绝、文件不被触碰——这正是 v0.7.4/v0.7.5
113
+ 实机观测到的拒绝签名(profile patch mtime 不变、快速 rejected):**运行中宿主装配的
114
+ 插件 schema 若早于 volatile 标注,宿主即在写前拒绝**;生效手段不变:重启宿主装配
115
+ v0.7.7 schema + 强刷浏览器。离线测试增至 56 项。
23
116
  v0.6:多 goal session 投影(`cyberneticsGoals`)——goal 板每次变化把本 session 全部 goal 的
24
117
  整值快照 append 成一条 `plugin:cybernetics/goals` 事件(可跳过的插件记录,宿主持久化读取安全),
25
118
  服务端纯折叠、宿主整值推送,「目标」页签逐条渲染并就地更新;空板回退到 dsh-goal 单 goal 渲染。
@@ -0,0 +1,79 @@
1
+ # Issue 草稿 / Issue Draft
2
+
3
+ > 本文件为可直接提交至上游仓库 DeepSeek-AI/deepseek-harness 的 issue 草稿。
4
+ > Title(英文)见下;正文以中文为主,附英文摘要。
5
+
6
+ ---
7
+
8
+ ## Title / 标题
9
+
10
+ **[EN]** Bug: Persistent SSE/long-polling event streams from client plugins exhaust the browser's per-host HTTP/1.1 connection pool (~6 connections), leaving new API requests (e.g. `/api/pluginManager/listBundles`) queued indefinitely and the page unresponsive
11
+
12
+ **[ZH]** 各插件常驻 SSE/长轮询事件流占满浏览器对同一 host 的 HTTP/1.1 连接池(约 6 条),导致新请求(如 `/api/pluginManager/listBundles`)无限排队、页面假死
13
+
14
+ ---
15
+
16
+ ## Summary / 摘要(EN)
17
+
18
+ When multiple DSH web tabs are open against the same host, the browser's per-origin HTTP/1.1 connection limit (~6 sockets in Chrome) is saturated by resident SSE / long-polling event streams opened by client plugins (plus dsh-farm's 5s polling). New requests — e.g. `POST /api/pluginManager/listBundles`, `listPlugins`, `listVersionExemptions`, `schedule/list`, `GET /farm/services` — stay *pending* for 15s+ and end with `net::ERR_ABORTED`, while requests from already-established tabs still return 200. The page appears frozen. Server-side investigation rules out a slow host: `listBundles` is fully synchronous and millisecond-scale, and the host main thread is idle (94% in `kevent`).
19
+
20
+ ---
21
+
22
+ ## 正文 / Body(ZH)
23
+
24
+ ### Environment / 环境
25
+
26
+ - dsh `0.2.1-rc.3`
27
+ - OS: macOS
28
+ - Browser: Chrome(HTTP/1.1;宿主 server 当前仅支持 HTTP/1.1,未启用 h2)
29
+ - profile 挂载了大量客户端插件(含多个带常驻 SSE/长轮询事件流的插件,以及 dsh-farm 服务)
30
+ - 同一 host 下多标签页使用
31
+
32
+ ### Steps to Reproduce / 复现步骤
33
+
34
+ 1. 启动 DSH web profile,浏览器打开 DSH web 页面(标签页 A),登录。
35
+ 2. 再开 1–2 个同 host 的 DSH web 标签页(标签页 B、C),保持打开(各标签页的插件事件流与轮询即开始占用连接)。
36
+ 3. 在新标签页中打开插件页(如 plugin manager UI)。
37
+ 4. 打开 DevTools → Network 面板,观察:
38
+ - `POST /api/pluginManager/listBundles`
39
+ - `POST /api/pluginManager/listPlugins`
40
+ - `POST /api/pluginManager/listVersionExemptions`
41
+ - `POST /api/schedule/list`
42
+ - `GET /farm/services`
43
+
44
+ ### Expected vs Actual / 预期与实际
45
+
46
+ - **Expected**:上述 API 在毫秒~秒级返回 200,页面正常渲染、可交互。
47
+ - **Actual**:新标签页中上述请求全部 **pending 15s+** 后以 `net::ERR_ABORTED` 失败;与此同时老标签页对同一批 API 的请求仍正常返回 **200**。页面交互假死(点击、渲染均无响应)。独立复现 3 次现象一致。
48
+
49
+ ### Analysis / 根因分析(附证据)
50
+
51
+ **已排除服务端问题:**
52
+
53
+ 1. 宿主 `listBundles` 实现为全同步逻辑,单次调用仅毫秒级 —— 证据:`dsh-plugin-manager/lib/index.js:1681-1799`。
54
+ 2. macOS `sample` 抓栈显示宿主进程主线程空闲,94% 时间停留在 `kevent`(事件循环等待),不存在主线程阻塞。
55
+ 3. 未登录状态下直接 curl 该接口,**1ms 返回 401** —— 请求根本没被服务端排队,排除网关/路由层阻塞。
56
+
57
+ **真实机制:**
58
+
59
+ - 宿主 server 仅支持 HTTP/1.1(无 h2 多路复用),Chrome 对同一 host 默认仅约 6 条并发 TCP 连接。
60
+ - 每个标签页除 1 条 `/api/remote.mux` WebSocket(WS 不占用 HTTP 连接池)外,各客户端插件还各自打开常驻 **SSE / 长轮询事件流**;dsh-farm 另有**每 5 秒一次**的轮询请求。这些常驻流把 6 条连接持续占满。
61
+ - 于是新标签页发起的普通 API 请求只能在浏览器 socket 池中**无限排队**,直到超时被浏览器 abort(15s+ 后 `net::ERR_ABORTED`),表现为页面假死。这也一并解释了「多标签页导致浏览器失响应」的现象。
62
+
63
+ ### Suggested Fixes / 修复建议(分层)
64
+
65
+ **宿主 / 上游侧:**
66
+
67
+ 1. **事件通道合并**:将各插件独立的 SSE/长轮询事件流迁移到已存在的 `/api/remote.mux` WebSocket 上复用单一条连接(每标签页一条 WS,多路复用所有插件事件),从根本上消除连接池占用。
68
+ 2. **client RPC 加固**:client RPC 增加请求超时与失败重试,避免排队超时后页面无限等待、无恢复路径。
69
+ 3. **dsh-farm 轮询降频**:dsh-farm 的 5 秒轮询改为指数退避,或迁移为 WS 推送。
70
+ 4. **评估启用 HTTP/2**:宿主 server 评估支持 h2,使浏览器对同一 host 多路复用,绕开 HTTP/1.1 的 6 连接限制。
71
+
72
+ **用户侧临时缓解:**
73
+
74
+ - 减少同一 host 下打开的 DSH web 标签页数量;
75
+ - 停用带常驻 SSE/长轮询事件流的插件。
76
+
77
+ ### Additional Context / 补充
78
+
79
+ 以上结论来自对 `0.2.1-rc.3` 的排查:服务端同步实现(`dsh-plugin-manager/lib/index.js:1681-1799`)+ `sample` 主线程空闲证据 + 401 快速返回,三点共同将问题定位在浏览器连接池耗尽,而非宿主处理能力。如需完整排查记录可进一步提供。
@@ -0,0 +1,93 @@
1
+ # /api/pluginManager/listBundles 挂起问题排查报告(cybernetics 本轮,只读调研)
2
+
3
+ 日期:2026-11-03(本地 23:39–00:02)
4
+ 执行者:task agent(dsh-cybernetics 会话,宿主 checkout 只读)
5
+
6
+ ## 结论(一句话)
7
+
8
+ `/api/pluginManager/listBundles` 挂起**不是** listBundles 实现慢或死锁,也**不是**插件页重复调用导致宿主扫描过重;
9
+ 它是**浏览器对 HTTP/1.1 同一 host 的连接池(约 6 条)被大量常驻长连接(WS 之外的 SSE/事件流 + 轮询)占满后,
10
+ 新请求在浏览器侧排队、永远等不到 socket** 的现象。宿主进程主线程空闲、从未收到/处理这些请求。
11
+ 多标签页打开 GUI 时每个标签都会新增一条 `/api/remote.mux` WebSocket + 多条事件流,迅速耗尽 6 条池连接——
12
+ 这正是"多标签页导致浏览器失去响应"和"插件页卡片链路(listBundles/listPlugins)被阻塞"的共同根源。
13
+
14
+ ## 复现(真实环境,证据)
15
+
16
+ - 三次独立复现(三个全新浏览器会话,23:41 / 23:48 / 23:55):带登录态 `POST /api/pluginManager/listBundles`
17
+ 7.6s/15.6s/8.3s 超时,`net::ERR_ABORTED`,**从未收到响应头**(CDP 网络捕获:request state=failed/pending,
18
+ response_body unavailable)。同法 `pluginManager/listPlugins`、`listVersionExemptions`、`schedule/list` 同样挂起。
19
+ - 未登录 `curl -X POST http://127.0.0.1:3080/api/...` → **401,1ms 内返回**(说明 HTTP 服务、路由、admit 全部正常)。
20
+ - 宿主进程 `sample 39225`(端口 3080 监听者):主线程 2260 个采样中 2121 个停在 `kevent`(事件循环空闲等待),
21
+ **没有阻塞**;常驻内存 1.1GB(另一个值得关注的独立问题)。
22
+ - 用户实际打开插件页(/plugins/??…)时,整页请求全部 pending:`/farm/services`(×6,5 秒重试堆积)、
23
+ `/api/pluginManager/listPlugins`、`/api/pluginManager/listBundles`、`/api/schedule/list`、`/api/commands/list`、
24
+ `/api/skills/list`、`/api/credentials/describe`(×3)、`/api/pet/*`、`/api/skin-center/*`、
25
+ `/api/dsh-usage/overview`(20 秒后被页面自身超时中止)——见 CDP capture `listBundles-hang`(116 条请求,~25 条 pending)。
26
+ - `lsof -nP -iTCP:3080`:Chrome 持有 **17 条** ESTABLISHED 连接;`curl -I` 确认服务器为 **HTTP/1.1**(无 h2)。
27
+ Chromium 对 HTTP/1.1 同一 host 的并发连接上限为 6;WS 不占该池,SSE/普通 fetch 占用。
28
+
29
+ ## 代码证据(文件+行,均为只读引用)
30
+
31
+ 1. listBundles 本身是**全同步**方法,不可能自行挂起:
32
+ - `@deepseek-ai/dsh-plugin-manager/lib/index.js:1681`(`listBundles()`,profile 与全局安装 diff 为 SAME)
33
+ - 实现内只有 `readFileSync`/YAML 解析(`lib/index.js:1766-1799` 收尾 `return Promise.resolve(bundles)`);
34
+ 实测量级:80 个依赖的 package.json 共 223KB + 57 个 patch 文件 15KB,单次调用远小于 100ms。
35
+ 2. 分发链路也没有异步等待点(profile 与全局 SAME):
36
+ - `@deepseek-ai/dsh-client-connection/lib/index.js:1045-1062`:`/api` 路由 admit(401 快路径)→ `bridge` →
37
+ `rpcFetchHandler.fetch`(:871-913)→ gateway `dispatchRpc`。
38
+ - `@deepseek-ai/dsh-api-gateway/lib/index.js:961-978`(dispatchRpc/invokeRpc)、`:1190-1230`(prepareInvocation,
39
+ strict 描述符 `invocation:{kind:'direct'}`,见 `dsh-plugin-manager/lib/typert.host.js:580-604`)→ 同步调用业务方法。
40
+ 3. 浏览器侧传输(挂起真正发生的地方):
41
+ - `@deepseek-ai/dsh-client-connection/lib/client.browser.js:1209-1237`(`createWebConnectionRpc.call`):
42
+ 普通 `fetch(POST /api/<endpoint>)`,**无超时**;请求发不出去(池排队)时 promise 永远 pending。
43
+ - WS 通道 `/api/remote.mux` 定义于 `dsh-api-gateway/lib/index.js:133`;浏览器每页一条
44
+ (`dsh-api-gateway/lib/client.browser.js:1630`),WS 不占 HTTP 池,但每个标签的 SSE/事件流占用。
45
+ 4. 放大器(插件侧):
46
+ - `dsh-farm/client.js:209`:`setInterval(refresh, 5000)` —— 每个挂载了 farm 面板的标签每 5 秒打一次
47
+ `/farm/services`;请求排队后,每个 tick 再堆一条 pending(捕获中同一 tab 堆了 6 条)。
48
+ - 插件页自身的读组逻辑 `@linxin666/dsh-client-ui-plugin-manager/lib/client.js:635`(readGroup 并发调
49
+ listPlugins + listBundles)本身频率合理,不是问题源头。
50
+
51
+ ## 与委托书预期的偏差
52
+
53
+ - 委托书假设"listBundles 读多少文件/扫多少目录/等待锁导致挂起"——已排除:实现同步、量级小、
54
+ 宿主主线程空闲、且同时段 `/farm/services` 等非 RPC 路由同样挂起。
55
+ - 委托书假设"SSE 资源耗尽"——**方向正确**:耗尽的是浏览器每 host 6 条的 HTTP/1.1 连接池,
56
+ 被 GUI 各标签的常驻事件流(以及每标签 1 条 WS 之外的 SSE 类流)占满。
57
+ - 与并行 goal cyb-30cba954 的关系:其结论"listBundles 挂起阻塞插件页"成立,但归因(宿主实现)
58
+ 需修正为浏览器连接池饥饿;其改动(cordis.patch.yml / lib/client.js,dsh-cybernetics 仓库)不受影响。
59
+
60
+ ## 修复建议分层
61
+
62
+ ### A. 用户侧(立即生效,无需改码)
63
+ - 关闭多余的 GUI 标签页(尤其是停留在插件页/看板页的后台标签),挂起的 listBundles 立即恢复返回。
64
+
65
+ ### B. 插件侧(可落地,但不在本轮可写仓库内)
66
+ 本轮可写仓库为 `/Users/mars/jobs/dsh-cybernetics`(dsh-cybernetics),其 client 不发起任何常驻 HTTP 流
67
+ (`lib/client.js` 只使用宿主提供的 faces),**无该问题的可落地修复点** —— 按验收项 2 如实报告"本仓库无可行插件侧方案"。
68
+ 真正值得改的插件(均在只读 profile 内,属下一轮或对应仓库任务):
69
+ - `dsh-farm/client.js:209`:把固定 5s 轮询改为事件驱动/指数退避 + `document.visibilityState==='hidden'` 时暂停;
70
+ - 各自维护 SSE/长轮询的插件(taskboard events、convmap、pet 等)合并为共享一条流或迁移到 `/api/remote.mux` WS 复用;
71
+ - `@linxin666/dsh-client-ui-plugin-manager`:对 readGroup 结果做 30-60s 内存缓存,失败重试加退避,
72
+ 避免排队期间反复堆叠请求。
73
+
74
+ ### C. 宿主/上游侧(建议补丁位置,未修改)
75
+ - 核心:减少长连接对浏览器 6-socket 池的占用。`@deepseek-ai/dsh-api-gateway/lib/index.js:133`
76
+ 已有 WS mux(`/api/remote.mux`)承载 Typert 流;建议上游把插件自定义事件通道统一迁移到该 mux
77
+ 的逻辑流(open/item/end/cancel 帧协议已在 `stream-protocol` 实现),或提供官方 SSE→WS 复用桥。
78
+ - 次要:`@deepseek-ai/dsh-client-connection/lib/client.browser.js:1209` 的 `createWebConnectionRpc.call`
79
+ 增加可配置超时(如 AbortSignal.timeout(30s)),把"永远 pending"变成可见错误;
80
+ `dsh-host-webserver` 可考虑响应头提示或文档说明 HTTP/1.1 单 host 限制。
81
+ - 观察项:宿主进程 RSS 1.1GB(sample 输出 Physical footprint),建议上游加内存基线告警(独立问题)。
82
+
83
+ ## 验证方式(可复现命令)
84
+ 1. `curl -o /dev/null -w '%{http_code} %{time_total}\n' -X POST -H 'content-type: application/json' -d '{}' http://127.0.0.1:3080/api/pluginManager/listBundles` → `401 0.001`(服务正常)。
85
+ 2. `sample <node-pid> 3` → 主线程 kevent 空闲(服务未阻塞)。
86
+ 3. 登录态浏览器 `fetch('/api/pluginManager/listBundles', {method:'POST', ...})` + AbortController(8s) → 挂起;
87
+ 同时 `lsof -nP -iTCP:3080` 数 Chrome ESTABLISHED ≈17(HTTP/1.1,池 6)。
88
+ 4. CDP 网络捕获:新开插件页后 `/api/pluginManager/listBundles`、`/api/schedule/list`、`/farm/services` 全部 pending。
89
+
90
+ ## 风险与残余不确定
91
+ - "池排队"是从浏览器外部可观测行为(请求未送达 + 服务空闲 + 旧连接正常 + HTTP/1.1 + 连接数)反推的最优解释;
92
+ 如需 100% 确证,可用 chrome://net-export 抓一次 net-log(需要用户配合,本轮未做)。
93
+ - 未修改任何文件;未运行测试(纯只读调研轮,无代码变更故无测试项)。
package/docs/protocol.md CHANGED
@@ -1,7 +1,41 @@
1
- # Cybernetics 工作协议 v0.6(设计稿)
1
+ # Cybernetics 工作协议 v0.7(设计稿)
2
2
 
3
3
  目标-行动-测量-校准的迭代回路,构建在 DSH 的 `dsh-workflow` 编排与 `dsh-goal` 持久目标之上。
4
4
 
5
+ ## v0.7 已实现:全量 goal 模式(allGoalsMode)
6
+
7
+ 配置项 `allGoalsMode`(默认 **false**——关闭时注入的提示段、revise 行为与 v0.6 完全一致)。
8
+ 开启后在隐式触发段后追加「ALL-GOALS MODE ACTIVE」路由协议,四条路由规则:
9
+
10
+ > 配置入口(v0.7.1):插件导出 schemastery `Config` schema,`allGoalsMode` 为 volatile 字段——
11
+ > 在 DSH Web 编辑器 Settings → Plugins 的本插件条目面板上直接切换,保存后 loader 把新值
12
+ > 提交进运行实例(不 remount),提示段即时重挂。无需再手改 profile JSON。
13
+
14
+ 1. **全量进 goal**:任何用户请求(问题、检索、分析、改动)一律先建 goal(action `'start'` +
15
+ title/brief)再由 task agent 执行;session agent 禁止用读工具就地完成任务——哪怕单文件
16
+ 查询或一次计算也走回路。硬门关闭时协议同样禁止就地改动:必须委派。
17
+ 2. **简单只读请求 → depth `'quick'`**:单 task agent 一轮,跳过 verify/triage,结果直接反馈
18
+ (有界查询、单文件读取、小型统计)。
19
+ 3. **复杂分析 / 写任务 → depth `'full'` 多轮渐进**:action `'start'` 异步启动,逐轮 `'collect'`
20
+ 收敛,controller 按轮推进 subgoal(task → verify → triage)。分析型 = 取数落盘、数据集、
21
+ 统计、多步查询规划;写任务额外依赖硬门——session agent 永不亲自执行改动,goal 回路是
22
+ 唯一路径,改动再小也一样。
23
+ 4. **revise 自动决策树**:修订仍是 append-only(新 goal + `parentGoalId`,旧 goal 置
24
+ `superseded`,`'revise'` 是唯一目标变更动词),但 `abortPrevious` 缺省时按旧 task agent
25
+ 状态自动决策(数据来自现有 goal 板 running 状态,不新增编排机制):
26
+ - (a) 旧轮**在跑**且修订改写目标本身(传入 `objective`,或 `depth` 变化)→ 目标矛盾/
27
+ 写域冲突 → **自动中止旧轮**(复用 cancel 路径),等价 `abortPrevious: true`;
28
+ - (b) 旧轮**在跑**但目标不变(仅 brief/title/role 修正),旧工作仍可复用/可并行 →
29
+ **等待旧轮自然结束**,等价 `abortPrevious: false`;
30
+ - (c) 旧 task agent **已结束**(goal 板上该 goal 非 running)→ **直接启动新 goal**,
31
+ 不做任何中止动作。
32
+ 显式传 `abortPrevious: true/false` 保持 v0.6 人工控制语义(决策树让位)。每次修订的
33
+ `.cybernetics/revisions-<新goalId>.jsonl` 审计行新增 `abort` 字段
34
+ `{abort, mode: 'auto'|'explicit', basis}`,记录选择的分支与依据;`revise` 返回值同样携带。
35
+
36
+ 实现落点:`src/controller.ts` 的纯函数 `decideAbort`(决策树 + basis 文案);
37
+ `src/index.ts` 的 `ALL_GOALS_GUIDANCE` 提示段与 revise 路径接线。`src/` 改动需宿主重载生效。
38
+
5
39
  ## v0.6 设计:与 Agent Teams 集成
6
40
 
7
41
  2026-11-02 与用户逐条定稿的设计决策。第 0 步验证实验已完成(见文末);执行者适配层
package/lib/client.js CHANGED
@@ -1,4 +1,8 @@
1
- // dsh-cybernetics client module: the「目标 / Goal」conversation tab.
1
+ // dsh-cybernetics client module: the「目标 / Goal」conversation tab, the
2
+ //「控制」tab, and (v0.7.2+, fixed in v0.7.3) the allGoalsMode settings card
3
+ // seated on the official Plugins page (plugins.item / plugins.row.config /
4
+ // plugins.bundle.config, bound to the row's ConfigForm in either of the two
5
+ // shapes the seats deliver — volatile writes stay live without a restart).
2
6
  // Rendered from the session's `goal` projection (dsh-goal), pushed live by the
3
7
  // host — no plugin HTTP route needed. Hand-written ModuleLoader wrapper, same
4
8
  // format the host bundler emits (see dsh-context / dsh-maze client.js).
@@ -62,6 +66,22 @@ window.__ModuleLoader__.load({
62
66
  // v0.6 executor line: the durable teammate's name (persistent only)
63
67
  'goal.member': '成员 {name}',
64
68
  'control.goals': '目标',
69
+ // v0.7.2 plugins-page settings card (plugins.item / plugins.row.config /
70
+ // plugins.bundle.config). cardLabel titles the 内置插件配置 group card.
71
+ 'settings.cardLabel': 'Cybernetics 编排循环',
72
+ 'settings.title': '全目标模式',
73
+ 'settings.description': '所有用户请求(含只读问题)必须先经 cybernetics goal。保存后即时生效,无需重载。',
74
+ 'settings.on': '开',
75
+ 'settings.off': '关',
76
+ 'settings.summaryOn': '全目标模式:开',
77
+ 'settings.summaryOff': '全目标模式:关',
78
+ 'settings.unavailable': '配置不可用(设置文档未开放或插件未运行)。',
79
+ 'settings.saving': '保存中…',
80
+ 'settings.saveFailed': '保存未生效(宿主拒绝了写入)。',
81
+ // v0.7.6 path-(b) diagnostics: the direct settings-transport retry
82
+ // surfaces the host's exact RemoteError message (the shared
83
+ // ConfigFormController swallows it into a bare `false`).
84
+ 'settings.saveFailedDetail': '保存未生效,宿主拒绝原因:{message}',
65
85
  };
66
86
  const en = {
67
87
  'view.goal': 'Goal',
@@ -102,6 +122,17 @@ window.__ModuleLoader__.load({
102
122
  'goal.revisedFrom': 'Revised from "{title}"',
103
123
  'goal.member': 'Member {name}',
104
124
  'control.goals': 'Goals',
125
+ 'settings.cardLabel': 'Cybernetics goal loop',
126
+ 'settings.title': 'All-goals mode',
127
+ 'settings.description': 'Every user request (including read-only questions) must go through a cybernetics goal first. Takes effect immediately on save; no reload needed.',
128
+ 'settings.on': 'On',
129
+ 'settings.off': 'Off',
130
+ 'settings.summaryOn': 'All-goals mode: on',
131
+ 'settings.summaryOff': 'All-goals mode: off',
132
+ 'settings.unavailable': 'Configuration unavailable (settings document not served or plugin not running).',
133
+ 'settings.saving': 'Saving…',
134
+ 'settings.saveFailed': 'Save did not take effect (the host refused the write).',
135
+ 'settings.saveFailedDetail': 'Save did not take effect — host refusal: {message}',
105
136
  };
106
137
  //#endregion
107
138
 
@@ -394,14 +425,267 @@ window.__ModuleLoader__.load({
394
425
  }
395
426
  //#endregion
396
427
 
428
+ //#region plugins-page settings card (v0.7.2)
429
+ // The official Plugins page (@deepseek-ai/dsh-client-ui-plugin-manager, the
430
+ // runtime copy that owns the 内置插件配置/实验性插件/已安装插件 groups) renders a
431
+ // bundle's own configuration ONLY through the slots it declares:
432
+ // plugins.bundle.config keyed by the bundle name (profile dep name)
433
+ // plugins.row.config keyed by `<bundle name>#<row id>`
434
+ // (rowConfigKey in that page's client.js; slot contract in
435
+ // dsh-cordis-client-runner's slot catalog). A server-side Config schema
436
+ // alone surfaces nowhere once the ui-settings-plugins section is disabled
437
+ // by the family plugin-manager — exactly the observed gap.
438
+ // Both entries bind the row's own ConfigForm from ctx.configForms (the
439
+ // namespace id is the loader row id `dsh-cybernetics`, same convention
440
+ // @deepseek-ai/dsh-experimental-client-ui-cot-translation uses). A toggle
441
+ // write goes settings mutate → loader volatile commit
442
+ // (`loader/volatile-update`), so allGoalsMode stays live without a
443
+ // restart — the v0.7.1 volatile semantics, unchanged.
444
+ const SETTINGS_ENTRY_ID = 'dsh-cybernetics';
445
+ const NO_FORM_SNAP = { status: 'unavailable', value: undefined, writable: false };
446
+ const noopSubscribe = () => () => {};
447
+
448
+ var settingsStyles = {
449
+ card: { display: 'flex', flexDirection: 'column', gap: 6, padding: '12px 0' },
450
+ row: { display: 'flex', alignItems: 'center', gap: 12 },
451
+ main: { display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 },
452
+ title: { fontSize: 13, fontWeight: 600 },
453
+ desc: { fontSize: 12, opacity: 0.65, lineHeight: 1.5 },
454
+ toggle: {
455
+ cursor: 'pointer', borderRadius: 999, width: 40, height: 22,
456
+ border: '1px solid rgba(127,127,127,0.45)', position: 'relative', flex: 'none',
457
+ background: 'transparent', padding: 0,
458
+ },
459
+ knob: {
460
+ position: 'absolute', top: 2, width: 16, height: 16, borderRadius: 999,
461
+ transition: 'left .15s ease',
462
+ },
463
+ trackOn: { background: '#2da44e', borderColor: '#2da44e' },
464
+ trackOff: { background: 'rgba(127,127,127,0.25)' },
465
+ knobOn: { left: 20, background: '#fff' },
466
+ knobOff: { left: 2, background: 'currentColor' },
467
+ unavailable: { fontSize: 12, opacity: 0.6 },
468
+ };
469
+
470
+ /**
471
+ * One boolean settings row; shared by all three seats. Two form shapes
472
+ * reach it, and the card must speak both:
473
+ * - the LIVE ConfigForm this module injects (ctx.configForms.get):
474
+ * getSnapshot / subscribe / set — the only shape on the
475
+ * plugins.bundle.config page and in summary rows, whose owners pass
476
+ * no form of their own;
477
+ * - the HOST's ConfigPageForm { state, mutate }: the plugins.item and
478
+ * plugins.row.config detail pages compute it from the same shared
479
+ * mirror (configForms.get(id).getSnapshot()/mutate) and pass it as
480
+ * an OWNER prop, which spreads AFTER inject at the render seam
481
+ * (@deepseek-ai/dsh-client-ui-renderer renderEntry: ...injected,
482
+ * ...ownerProps) — on those pages this shape WINS over the injected
483
+ * form. Reading `.subscribe` on it crashed the first v0.7.2 cut.
484
+ * The host shape carries no subscribe; its `state` is re-computed by
485
+ * the owning page on every mirror update, so reading it per render is
486
+ * already live.
487
+ */
488
+ function SettingsCard(props) {
489
+ const t = props.t;
490
+ const form = props.form;
491
+ // v0.7.6 path-(b) fallback write face: the plugin's own bound
492
+ // remote.settings.mutate. SAME host write path as the ConfigForm
493
+ // (remote.settings.mutate → @deepseek-ai/dsh-settings SettingsForms
494
+ // → @deepseek-ai/dsh-config-editor edit → the USER patch row
495
+ // `{id, name, config}` appended to the profile cordis.patch.yml,
496
+ // reconciled by reconcileProfilePatches) — the only落点 the host
497
+ // accepts. Used ONLY when the primary write refuses, without an
498
+ // expectedRevision (the controller documents undefined as an
499
+ // unconditional write), so a stale-revision refusal heals in place
500
+ // and a hard refusal surfaces its exact host message instead of a
501
+ // bare「宿主拒绝了写入」with no reason.
502
+ const directMutate = props.directMutate;
503
+ const view = props.view === 'summary' ? 'summary' : 'page';
504
+ const live = form !== null && typeof form === 'object'
505
+ && typeof form.subscribe === 'function' && typeof form.getSnapshot === 'function' && typeof form.set === 'function'
506
+ ? form : undefined;
507
+ const host = live === undefined && form !== null && typeof form === 'object'
508
+ && typeof form.mutate === 'function' && form.state !== null && typeof form.state === 'object'
509
+ ? form : undefined;
510
+ const liveSnap = react.useSyncExternalStore(
511
+ live ? (cb) => live.subscribe(cb) : noopSubscribe,
512
+ live ? () => live.getSnapshot() : () => NO_FORM_SNAP,
513
+ );
514
+ const snap = host !== undefined ? host.state : liveSnap;
515
+ const [saving, setSaving] = react.useState(false);
516
+ // `false` = no failure; `true` = failure without a host message;
517
+ // a string = the host's exact refusal (RemoteError.message).
518
+ const [failed, setFailed] = react.useState(false);
519
+ const on = snap.status === 'ready' && snap.value != null && snap.value.allGoalsMode === true;
520
+ const disabled = (live === undefined && host === undefined) || snap.status !== 'ready' || snap.writable === false || saving;
521
+
522
+ const toggle = () => {
523
+ if (disabled) return;
524
+ setSaving(true);
525
+ setFailed(false);
526
+ // Both write paths land in the same host mutation: ConfigForm.set
527
+ // is one-field sugar over ConfigForm.mutate, and the host's
528
+ // ConfigPageForm.mutate IS that form's mutate (the expected
529
+ // revision carried explicitly). The host commits the write into
530
+ // the running plugin instance (volatile) and the next snapshot /
531
+ // page render pushes the accepted value back here.
532
+ // Failure policy (v0.7.4): the transport can REFUSE (settles
533
+ // false) or stall (the observed "保存中" hang). Neither is
534
+ // silent: a refused write shows saveFailed for a few seconds,
535
+ // and a stalled write releases the switch after 15 s with the
536
+ // same hint — the switch always returns to the snapshot value,
537
+ // which is the only state the host confirmed.
538
+ // v0.7.6: a refused write is retried ONCE through the direct
539
+ // settings transport (props.directMutate, no expectedRevision).
540
+ // Same落点 (user patch row via configEditor), unconditional
541
+ // revision — a stale-revision conflict therefore heals, and any
542
+ // remaining refusal reports its host message verbatim.
543
+ let settled = false;
544
+ const finish = (ok, detail) => {
545
+ if (settled) return;
546
+ settled = true;
547
+ setSaving(false);
548
+ if (!ok) {
549
+ setFailed(detail || true);
550
+ setTimeout(() => setFailed(false), 6000);
551
+ }
552
+ };
553
+ const guard = setTimeout(() => finish(false), 15000);
554
+ const op = { op: 'set', path: ['allGoalsMode'], value: !on };
555
+ const write = live !== undefined
556
+ ? live.set('allGoalsMode', !on)
557
+ : host.mutate([op], snap.revision);
558
+ Promise.resolve(write)
559
+ .then(async (accepted) => {
560
+ if (accepted !== false) { clearTimeout(guard); finish(true); return; }
561
+ if (typeof directMutate !== 'function') { clearTimeout(guard); finish(false); return; }
562
+ try {
563
+ const answer = await directMutate([op]);
564
+ clearTimeout(guard);
565
+ if (answer && typeof answer === 'object' && answer.ok === false) {
566
+ finish(false, answer.error && answer.error.message);
567
+ } else finish(true);
568
+ } catch (error) {
569
+ clearTimeout(guard);
570
+ finish(false, error && error.message);
571
+ }
572
+ })
573
+ .catch((error) => { clearTimeout(guard); finish(false, error && error.message); });
574
+ };
575
+
576
+ if (view === 'summary') {
577
+ return jsx.jsx('span', { children: snap.status === 'ready' ? t(on ? 'settings.summaryOn' : 'settings.summaryOff') : t('settings.unavailable') });
578
+ }
579
+
580
+ return jsx.jsxs('div', { style: settingsStyles.card, children: [
581
+ jsx.jsxs('div', { style: settingsStyles.row, children: [
582
+ jsx.jsxs('div', { style: settingsStyles.main, children: [
583
+ jsx.jsx('div', { style: settingsStyles.title, children: t('settings.title') }),
584
+ jsx.jsx('div', { style: settingsStyles.desc, children: t('settings.description') }),
585
+ ] }),
586
+ snap.status === 'ready'
587
+ ? jsx.jsx('button', {
588
+ type: 'button', role: 'switch', 'aria-checked': on, 'aria-label': t('settings.title'),
589
+ disabled, onClick: toggle,
590
+ style: { ...settingsStyles.toggle, ...(on ? settingsStyles.trackOn : settingsStyles.trackOff) },
591
+ children: jsx.jsx('span', { style: { ...settingsStyles.knob, ...(on ? settingsStyles.knobOn : settingsStyles.knobOff) } }),
592
+ })
593
+ : null,
594
+ ] }),
595
+ snap.status !== 'ready' ? jsx.jsx('div', { style: settingsStyles.unavailable, children: t('settings.unavailable') }) : null,
596
+ saving ? jsx.jsx('div', { style: settingsStyles.unavailable, children: t('settings.saving') }) : null,
597
+ !saving && failed ? jsx.jsx('div', { style: { ...settingsStyles.unavailable, color: '#d29922' }, children: failed === true ? t('settings.saveFailed') : t('settings.saveFailedDetail', { message: failed }) }) : null,
598
+ ] });
599
+ }
600
+ //#endregion
601
+
397
602
  //#region plugin apply
398
603
  const name = 'dsh-cybernetics';
399
- const inject = ['slots', 'sessions', 'locale'];
604
+ // configForms: the per-entry ConfigForm registry (@deepseek-ai/dsh-client-ui-settings)
605
+ // that backs the plugins-page settings card below.
606
+ const inject = ['slots', 'sessions', 'locale', 'configForms',
607
+ // v0.7.6: the settings card's diagnostic fallback write rides the
608
+ // same namespace remote the official ui-settings declares
609
+ // (["remote", "remote.settings"]). Both are required: ctx access is
610
+ // gated by the inject declaration (client-runner guard), so an
611
+ // undeclared `remote` would throw on read and silently disable the
612
+ // fallback. Optional at runtime (guarded in apply): a host without
613
+ // the namespace parks this client module exactly as it would
614
+ // ui-settings itself — the web profile always serves it.
615
+ 'remote', 'remote.settings'];
400
616
 
401
617
  function apply(ctx) {
402
618
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-cybernetics: dictionaries');
403
619
  const t = ctx.locale.bind(NS);
404
620
 
621
+ // v0.7.2: bind the row's ConfigForm once per apply body. The namespace
622
+ // is the loader row id (`dsh-cybernetics`), the same convention the
623
+ // official cot-translation bundle uses (`ctx.configForms.get(ns)`). If
624
+ // the host does not serve this namespace yet, the card renders its
625
+ // unavailable state instead of throwing.
626
+ let settingsForm;
627
+ try {
628
+ settingsForm = ctx.configForms.get(SETTINGS_ENTRY_ID);
629
+ } catch {
630
+ settingsForm = undefined;
631
+ }
632
+ // v0.7.6 path-(b) diagnostics face: the direct settings transport,
633
+ // bound from this module's own context (`remote.settings` inject).
634
+ // Guarded: a deployment without the namespace leaves it undefined
635
+ // and the card falls back to the v0.7.4 hint-only failure state.
636
+ let settingsDirectMutate;
637
+ try {
638
+ if (ctx.remote && ctx.remote.settings && typeof ctx.remote.settings.mutate === 'function') {
639
+ settingsDirectMutate = (ops, expectedRevision) => ctx.remote.settings.mutate(SETTINGS_ENTRY_ID, ops, expectedRevision);
640
+ }
641
+ } catch {
642
+ settingsDirectMutate = undefined;
643
+ }
644
+ const settingsInject = () => ({ form: settingsForm, directMutate: settingsDirectMutate });
645
+ // v0.7.2 (the actual gap): the official Plugins page renders the
646
+ // 内置插件配置 group cards ONLY from the `plugins.item` slot
647
+ // (ItemCard in @deepseek-ai/dsh-client-ui-plugin-manager client.js:
648
+ // basicCards = ledger.items from slot "plugins.item"; ItemDetail
649
+ // renders view "page" with the host form for item.id). The keyed
650
+ // seats below surface only on an installed bundle's own page, which
651
+ // this linked bundle never reaches in the 已安装插件 group — that was
652
+ // why v0.7.1's schema alone (and the first v0.7.2 cut) showed no card.
653
+ // Register the list card UNCONDITIONALLY (v0.7.5 hardening): the earlier
654
+ // cut gated it on configForms.whileServed, so a deployment where the
655
+ // host does not (yet) serve the `dsh-cybernetics` namespace showed NO
656
+ // card at all — indistinguishable from the registration never running.
657
+ // ctx.configForms.get() binds a live ConfigFormController that follows
658
+ // the shared describe mirror (ConfigForms.get → new ConfigFormController
659
+ // + mirror.ensure), so a card registered before the namespace appears
660
+ // flips to the toggle the moment the host starts serving it; while it
661
+ // does not, the card renders its `settings.unavailable` line — visible
662
+ // and diagnosable instead of absent.
663
+ ctx.effect(() => ctx.slots.inject('plugins.item', () => ctx.slots.register({
664
+ name: 'plugins.item',
665
+ id: SETTINGS_ENTRY_ID,
666
+ order: 80,
667
+ label: () => t('settings.cardLabel'),
668
+ locale: NS,
669
+ inject: settingsInject,
670
+ }, SettingsCard)), 'dsh-cybernetics: plugins-page settings card');
671
+ // The dispatch key bundles use the profile dependency name (the value
672
+ // in dsh.profile.bundles = `dsh-cybernetics`); register the scoped
673
+ // package manifest name too, so either resolution finds the card.
674
+ for (const bundleKey of ['dsh-cybernetics', '@mars.liu/dsh-cybernetics']) {
675
+ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
676
+ name: 'plugins.row.config',
677
+ key: bundleKey + '#' + SETTINGS_ENTRY_ID,
678
+ locale: NS,
679
+ inject: settingsInject,
680
+ }, SettingsCard));
681
+ ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
682
+ name: 'plugins.bundle.config',
683
+ key: bundleKey,
684
+ locale: NS,
685
+ inject: settingsInject,
686
+ }, SettingsCard));
687
+ }
688
+
405
689
  // Conversation-view tab, same slot the trajectory/context tabs use.
406
690
  // `t` reaches the view as a slot-provided prop from `locale: NS`
407
691
  // (PropsLocale) — do NOT also pass a hand-bound t through inject:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mars.liu/dsh-cybernetics",
3
- "version": "0.6.0",
3
+ "version": "0.7.8",
4
4
  "private": false,
5
5
  "files": [
6
6
  "src",
@@ -33,12 +33,16 @@
33
33
  "@deepseek-ai/dsh-client-locale",
34
34
  "@deepseek-ai/dsh-client-ui-conversation",
35
35
  "@deepseek-ai/dsh-client-ui-renderer",
36
- "@deepseek-ai/dsh-client-ui-session"
36
+ "@deepseek-ai/dsh-client-ui-session",
37
+ "@deepseek-ai/dsh-client-ui-settings"
37
38
  ],
38
39
  "platform": "web",
39
40
  "immediately": true
40
41
  }
41
42
  },
43
+ "dependencies": {
44
+ "@deepseek-ai/schemastery": "^3.18.5"
45
+ },
42
46
  "peerDependencies": {
43
47
  "@deepseek-ai/dsh-session": "*"
44
48
  }
package/src/controller.ts CHANGED
@@ -32,6 +32,24 @@ export interface RoleSpec {
32
32
  model?: string
33
33
  }
34
34
 
35
+ /**
36
+ * Parse a flat agent-model field ("provider/model" or a bare "model" name)
37
+ * into a workflow `agent()` override. Empty/whitespace/undefined → undefined,
38
+ * which the caller must treat as「与 session agent 一致」(no override handed
39
+ * to the round script — the workflow inherits the calling model).
40
+ */
41
+ export function parseAgentModelSpec(value?: string): RoleSpec | undefined {
42
+ const raw = (value ?? '').trim()
43
+ if (!raw) return undefined
44
+ const slash = raw.indexOf('/')
45
+ if (slash > 0 && slash < raw.length - 1) {
46
+ const provider = raw.slice(0, slash).trim()
47
+ const model = raw.slice(slash + 1).trim()
48
+ if (provider && model) return { provider, model }
49
+ }
50
+ return { model: raw }
51
+ }
52
+
35
53
  /** Per-role sub-agent configuration: 'default'/'verify'/'triage' plus named task roles. */
36
54
  export interface RolesConfig {
37
55
  default?: RoleSpec
@@ -112,6 +130,68 @@ export interface GoalRevisionRecord {
112
130
  reason: string
113
131
  /** Epoch milliseconds of the revision. */
114
132
  at: number
133
+ /**
134
+ * v0.7: how the old goal's in-flight round was handled, and why. Present on
135
+ * every revision so the audit trail records the decision basis, not just the
136
+ * outcome — see `decideAbort` for the decision tree.
137
+ */
138
+ abort?: { abort: boolean; mode: 'auto' | 'explicit'; basis: string }
139
+ }
140
+
141
+ /**
142
+ * Whether `revise` aborts the old goal's in-flight task agent round, and on
143
+ * what grounds. Pure — the caller supplies the "is a round in flight" fact
144
+ * from its own `running` map (no new orchestration mechanism; this reads the
145
+ * state the controller already maintains).
146
+ *
147
+ * Decision tree (v0.7 all-goals mode; `abortPrevious` explicitly passed keeps
148
+ * the v0.6 manual semantics):
149
+ * 1. `abortPrevious` given explicitly → honor it verbatim (mode 'explicit').
150
+ * 2. Old goal has NO round in flight → no abort, start the successor directly.
151
+ * 3. Old round in flight AND the revision rewrites the goal itself (a new
152
+ * `objective`, or a different `depth`) → the old round is working toward a
153
+ * target that no longer exists — contradictory goals / write-domain
154
+ * conflict → abort it (the cancel path) so it cannot interleave writes.
155
+ * 4. Old round in flight, correction only (new brief / title / role on the
156
+ * same objective) → the old work is still valuable and its result can be
157
+ * reused → let the round finish naturally, no abort.
158
+ */
159
+ export interface AbortDecision {
160
+ abort: boolean
161
+ mode: 'auto' | 'explicit'
162
+ basis: string
163
+ }
164
+
165
+ export function decideAbort(
166
+ previous: LoopState,
167
+ roundInFlight: boolean,
168
+ args: { abortPrevious?: boolean; objective?: string; depth?: 'quick' | 'full' },
169
+ ): AbortDecision {
170
+ if (args.abortPrevious !== undefined) {
171
+ return {
172
+ abort: args.abortPrevious,
173
+ mode: 'explicit',
174
+ basis: args.abortPrevious
175
+ ? '调用方显式要求中止旧轮(abortPrevious: true,复用 cancel 路径)'
176
+ : '调用方显式要求保留旧轮(abortPrevious: false,跑完自然结束)',
177
+ }
178
+ }
179
+ if (!roundInFlight) {
180
+ return { abort: false, mode: 'auto', basis: '旧 task agent 无在跑轮(查 goal 板 running 状态),直接启动新 goal,无需中止' }
181
+ }
182
+ const retargets = args.objective !== undefined || (args.depth !== undefined && args.depth !== (previous.depth ?? 'full'))
183
+ if (retargets) {
184
+ return {
185
+ abort: true,
186
+ mode: 'auto',
187
+ basis: '旧轮在跑且修订改写了目标本身(objective/depth 变化,目标矛盾/写域冲突)——自动中止旧轮,避免旧任务继续写入已被取代的方向',
188
+ }
189
+ }
190
+ return {
191
+ abort: false,
192
+ mode: 'auto',
193
+ basis: '旧轮在跑但任务目标未变(仅 brief/title/role 修正,旧工作仍可复用/可并行)——等待旧轮自然结束,结果由新 goal 继承',
194
+ }
115
195
  }
116
196
 
117
197
  /** Input for the append-only goal creation path. */
package/src/index.ts CHANGED
@@ -25,8 +25,9 @@ import { randomUUID } from 'node:crypto'
25
25
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
26
26
  import { join } from 'node:path'
27
27
  import { fileURLToPath } from 'node:url'
28
+ import z from '@deepseek-ai/schemastery'
28
29
  import {
29
- STATE_DIR, createGoal, foldRound, runRound, startRound, supersedeGoal, writeRevisionLog,
30
+ STATE_DIR, createGoal, decideAbort, foldRound, parseAgentModelSpec, runRound, startRound, supersedeGoal, writeRevisionLog,
30
31
  } from './controller.ts'
31
32
  import type {
32
33
  ControllerConfig, ControllerDecision, LoopState, ResumableReason, RoundResult, RolesConfig,
@@ -68,6 +69,10 @@ export interface Config extends ControllerConfig {
68
69
  implicitTrigger?: boolean
69
70
  /** Per-role sub-agent model/provider overrides. */
70
71
  roles?: RolesConfig
72
+ /** Flat task-agent model override ("provider/model" or bare model); empty = inherit session agent. */
73
+ taskAgentModel?: string
74
+ /** Flat verify-agent model override (same format); empty = inherit session agent. */
75
+ verifyAgentModel?: string
71
76
  /**
72
77
  * Hard orchestration gate (default false): hide execution tools from
73
78
  * TOP-LEVEL session agents at prompt-assembly time, so the session agent
@@ -89,12 +94,83 @@ export interface Config extends ControllerConfig {
89
94
  gateAllowlist?: string[]
90
95
  /** Extra tool names unioned onto the allowlist (default or gateAllowlist). */
91
96
  gateAllowlistExtra?: string[]
97
+ /**
98
+ * All-goals mode (v0.7, default false): EVERY user request — including
99
+ * trivial read-only questions and lightweight analysis — must go through a
100
+ * cybernetics goal first; in-place analysis by the session agent is banned.
101
+ * Off (default) keeps the v0.6 implicit-trigger behavior exactly unchanged.
102
+ */
103
+ allGoalsMode?: boolean
92
104
  }
93
105
 
94
- // No `Config` export: this cordis requires a schemastery schema there// (Config.validate), which a zero-dependency plugin cannot import. The raw
95
- // patch config object passes through to apply() unvalidated; fields are
96
- // optional and read defensively (toolName, roundsPerSubgoal, roundScriptPath,
97
- // implicitTrigger, roles).
106
+ // Under the host's 0.1.7 settings model, an exported schemastery `Config`
107
+ // schema IS the plugin entry's settings page: the Web editor derives the
108
+ // plugin config form from it and serves it through the shared configuration
109
+ // forms (see @linxin666/dsh-ssh and @linxin666/dsh-usage for the same
110
+ // pattern). At least one field must be `.volatile()` for the page to be
111
+ // served at all; a volatile edit is committed into the running fiber's
112
+ // reference without a remount and announced via `loader/volatile-update`.
113
+ // allGoalsMode is volatile so the panel toggle is live; hardGate and the
114
+ // gate/roles fields are deliberately non-volatile — they change structural
115
+ // wiring (tool stripping listener, per-role routing), so an edit remounts
116
+ // the row, which is the correct behavior for them.
117
+ export const Config = z.object({
118
+ /** Model-facing tool name. */
119
+ toolName: z.string().default('cybernetics').description('模型侧工具名(缺省 cybernetics)。'),
120
+ /** Inject the implicit goal-loop guidance section. */
121
+ implicitTrigger: z.boolean().default(true).description('注入隐式 goal-loop 系统提示段(缺省开启)。'),
122
+ /** Per-role sub-agent model/provider overrides: role -> {provider, model}. */
123
+ roles: z.dict(z.object({
124
+ provider: z.string(),
125
+ model: z.string(),
126
+ })).description('按角色的子 agent provider/model 覆盖:role -> {provider, model}(如 default/verify/triage/frontend)。'),
127
+ /**
128
+ * Flat task-agent model override ("provider/model" or a bare model name).
129
+ * Empty = inherit the session agent. Merged over roles.default at wiring
130
+ * time (parseAgentModelSpec) — the flat fields are the settings-panel face;
131
+ * roles stays the canonical runtime shape the round script consumes.
132
+ * Volatile (v0.7.7): the host settings form (@deepseek-ai/dsh-settings
133
+ * volatileForm, lib/index.js:122-131) serves ONLY `.volatile()` fields, and
134
+ * its write() refuses any non-volatile path ("Config field ... is not
135
+ * volatile") — a plain field is invisible AND unwritable on the plugins
136
+ * page. Volatile commit keeps the running instance live; the value is read
137
+ * per round start (readConfigField unwrap below), so an edit takes effect
138
+ * on the next goal round without a remount.
139
+ */
140
+ taskAgentModel: z.string().default('').volatile().description('task agent 模型覆盖(「provider/model」或裸模型名);留空 = 与 session agent 一致。保存后下一轮 goal 即生效,无需重载。'),
141
+ /** Flat verify-agent model override; same format, fallback and volatile semantics as taskAgentModel. */
142
+ verifyAgentModel: z.string().default('').volatile().description('verify agent 模型覆盖(「provider/model」或裸模型名);留空 = 与 session agent 一致。保存后下一轮 goal 即生效,无需重载。'),
143
+ /** Hard orchestration gate (default false). */
144
+ hardGate: z.boolean().default(false).description('硬编排门:装配期从顶层 session agent 摘除执行工具,所有改动必须经 cybernetics goal + task agent。修改会触发插件重载。'),
145
+ /** Tool names hidden in denylist mode (default DEFAULT_GATED_TOOLS). */
146
+ gatedTools: z.array(z.string()).description('denylist 模式下隐藏的工具名(缺省内置 DEFAULT_GATED_TOOLS)。'),
147
+ /** Gate semantics. */
148
+ gateMode: z.union(['denylist', 'allowlist'] as const).default('denylist').description('门语义:denylist 隐藏 gatedTools;allowlist 只保留 gateAllowlist(+gateAllowlistExtra)与 cybernetics 工具本身。'),
149
+ /** Allowlist-mode survival set (default DEFAULT_GATE_ALLOWLIST). */
150
+ gateAllowlist: z.array(z.string()).description('allowlist 模式生存工具集(缺省内置 DEFAULT_GATE_ALLOWLIST)。'),
151
+ /** Extra tool names unioned onto the allowlist. */
152
+ gateAllowlistExtra: z.array(z.string()).description('并集追加进 allowlist 的额外工具名。'),
153
+ /**
154
+ * All-goals mode (v0.7, default false): every user request must go through
155
+ * a cybernetics goal first. Volatile: the settings-panel toggle commits
156
+ * into the running instance without a remount.
157
+ */
158
+ allGoalsMode: z.boolean().default(false).volatile()
159
+ .description('全目标模式(v0.7,缺省关):所有用户请求(含只读问题)都必须先经 cybernetics goal。开关在面板上保存后即时生效,无需重载。'),
160
+ })
161
+
162
+ /** One resolved config field: a live reference (volatile), or a plain value. */
163
+ type ConfigField<T> = { get(): T } | T
164
+
165
+ /** Read one resolved config field, following the live reference the schema produces for volatile fields. */
166
+ function readConfigField<T>(field: ConfigField<T> | undefined, fallback: T): T {
167
+ if (field === undefined) return fallback
168
+ if (typeof field === 'object' && field !== null && typeof (field as { get?: unknown }).get === 'function') {
169
+ const value = (field as { get(): T }).get()
170
+ return value === undefined ? fallback : value
171
+ }
172
+ return field as T
173
+ }
98
174
 
99
175
  /**
100
176
  * Multi-goal loop registry: session -> goalId -> loop. v0.3 lifts the
@@ -318,6 +394,42 @@ yourself — you manage goals and the task agents that execute them. Classify ea
318
394
  'round-cap'/'failed' = report status. Never mark a goal complete yourself; completion is the user's act.
319
395
  Use action 'list' to see every goal on this session's board with its status, round and subgoal.`
320
396
 
397
+ /**
398
+ * v0.7 all-goals mode addendum: turns the implicit trigger from "suggest a
399
+ * goal for substantial tasks" into "every request is a goal". Contains the
400
+ * four routing rules the protocol and tests pin down:
401
+ * (1) everything enters a goal; (2) simple read-only → 'quick' single round;
402
+ * (3) complex analysis / write tasks → progressive multi-round ('full');
403
+ * (4) the revise auto-abort decision tree.
404
+ */
405
+ const ALL_GOALS_GUIDANCE = `
406
+ ALL-GOALS MODE ACTIVE (routing protocol — no exceptions, no in-place analysis):
407
+ - RULE 1 — EVERYTHING ENTERS A GOAL: for ANY user request (question, lookup, analysis, change), first
408
+ create a goal via cybernetics (action 'start' with title + brief), then let the task agent produce the
409
+ answer. NEVER answer a request by doing the task work yourself with read tools — even a one-file lookup
410
+ or a quick calculation goes through the loop. If a change is requested while the hard gate is off, the
411
+ protocol still forbids in-place edits: delegate.
412
+ - RULE 2 — SIMPLE READ-ONLY REQUEST → depth 'quick': one task-agent round, no verify/triage, feed the
413
+ result straight back to the user (bounded lookups, single-file reads, small queries).
414
+ - RULE 3 — COMPLEX ANALYSIS or WRITE TASK → depth 'full', progressive multi-round: launch with action
415
+ 'start' (async), then 'collect' each settled round and advance the subgoal round by round (task →
416
+ verify → triage). Analysis: data fetching to disk, datasets, statistics, multi-step query planning.
417
+ Write tasks additionally rely on the hard gate: the session agent must NEVER execute a change itself —
418
+ delegation through the goal loop is the only path, however small the change.
419
+ - RULE 4 — REVISE DECISION TREE (updating an existing goal stays append-only: a new goal with
420
+ parentGoalId, old goal superseded; 'revise' is the only goal-change verb). Decide abortPrevious by the
421
+ old task agent's state (check with action 'list'):
422
+ (a) old round STILL RUNNING and the revision contradicts it (objective/depth rewritten — conflicting
423
+ goals / write-domain overlap) → let 'revise' auto-abort: do not pass abortPrevious; the plugin
424
+ cancels the old round automatically;
425
+ (b) old round STILL RUNNING but the correction keeps the objective (brief/title/role only) and the old
426
+ work stays valuable / parallelizable / reusable → do not pass abortPrevious; the plugin waits for
427
+ the old round to finish naturally;
428
+ (c) old task agent ALREADY FINISHED (goal not running on the board) → just call 'revise'; no abort is
429
+ needed and none is attempted.
430
+ The chosen branch and its reason are recorded in the .cybernetics/revisions-<newGoalId>.jsonl audit
431
+ line (abort.mode = auto|explicit + basis). Passing abortPrevious explicitly keeps manual control.`
432
+
321
433
  /** Tool names hidden from top-level session agents when the hard gate is on. */
322
434
  const DEFAULT_GATED_TOOLS = ['bash', 'edit', 'write']
323
435
 
@@ -373,6 +485,10 @@ not find it, that is the gate working as intended — delegate instead.`
373
485
  export function apply(ctx: any, config: Partial<Config> = {}) {
374
486
  const toolName = config.toolName ?? 'cybernetics'
375
487
  const hardGate = config.hardGate === true
488
+ // allGoalsMode is a volatile field: under the loader's schema resolution it
489
+ // arrives as a live reference whose value the settings panel commits in
490
+ // place (no remount), so read it at use time instead of capturing it once.
491
+ const allGoalsMode = () => readConfigField(config.allGoalsMode, false) === true
376
492
  const disposers: Array<() => void> = []
377
493
 
378
494
  // v0.6: register the multi-goal session projection, so every goal on the
@@ -389,13 +505,24 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
389
505
  if (typeof projectionDisposer === 'function') disposers.push(projectionDisposer)
390
506
  }
391
507
 
392
- if (config.implicitTrigger !== false) {
393
- ctx.systemPrompt.section({
508
+ // The implicit guidance section embeds the volatile allGoalsMode wording,
509
+ // so it must be re-derived when a settings-panel toggle is committed into
510
+ // the running instance (the loader announces loader/volatile-update on this
511
+ // fiber instead of remounting; same re-derive pattern as dsh-ssh's sync()).
512
+ let disposeSection: (() => void) | undefined
513
+ function registerGuidanceSection() {
514
+ if (disposeSection !== undefined) disposeSection()
515
+ disposeSection = undefined
516
+ if (config.implicitTrigger === false) return
517
+ disposeSection = ctx.systemPrompt.section({
394
518
  name: `tool:${toolName}`,
395
519
  order: ctx.systemPrompt.getSectionOrder('TOOL_WORKFLOW'),
396
- text: IMPLICIT_GUIDANCE + (hardGate ? HARD_GATE_GUIDANCE : ''),
520
+ text: IMPLICIT_GUIDANCE + (allGoalsMode() ? ALL_GOALS_GUIDANCE : '') + (hardGate ? HARD_GATE_GUIDANCE : ''),
397
521
  })
398
522
  }
523
+ registerGuidanceSection()
524
+ disposers.push(() => { if (disposeSection !== undefined) disposeSection() })
525
+ ctx.on?.('loader/volatile-update', () => { registerGuidanceSection() })
399
526
 
400
527
  if (hardGate) {
401
528
  // The hard gate: a system-prompt/assemble waterfall listener that strips
@@ -403,12 +530,15 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
403
530
  // (task/verify/triage) and team teammates are never in agents.roots(),
404
531
  // so their assemblies pass through untouched with full tool access.
405
532
  const allowlistMode = config.gateMode === 'allowlist'
533
+ // NOTE: schema-resolved config turns an unset z.array field into [] (not
534
+ // undefined), so fall back on length, not on ?? — otherwise the gate
535
+ // would silently keep/gate nothing.
406
536
  const keep = new Set([
407
- ...(config.gateAllowlist ?? DEFAULT_GATE_ALLOWLIST),
537
+ ...(config.gateAllowlist?.length ? config.gateAllowlist : DEFAULT_GATE_ALLOWLIST),
408
538
  ...(config.gateAllowlistExtra ?? []),
409
539
  toolName,
410
540
  ])
411
- const gated = new Set(config.gatedTools ?? DEFAULT_GATED_TOOLS)
541
+ const gated = new Set(config.gatedTools?.length ? config.gatedTools : DEFAULT_GATED_TOOLS)
412
542
  const listenerDisposer = ctx.on('system-prompt/assemble', (assembly: any, context: any, next: () => unknown) => {
413
543
  const scope = context?.scope
414
544
  if (!scope) return next()
@@ -524,7 +654,8 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
524
654
  abortPrevious: {
525
655
  type: 'boolean',
526
656
  description:
527
- 'revise:是否中止旧 goal 正在运行的 task agent 轮(默认 false,让它跑完;true 时复用 cancel 路径)。',
657
+ 'revise:是否中止旧 goal 正在运行的 task agent 轮。缺省时按决策树自动判断(查 goal 板:旧轮在跑且目标被改写→自动中止;旧轮在跑但目标不变→等待自然结束;旧轮未跑→直启);' +
658
+ '显式传 true/false 保持人工控制(true 复用 cancel 路径)。',
528
659
  },
529
660
  executor: {
530
661
  type: 'string',
@@ -540,6 +671,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
540
671
  goalId: { type: 'string' },
541
672
  previousGoalId: { type: 'string' },
542
673
  revised: { type: 'boolean' },
674
+ abort: { type: 'object' },
543
675
  revision: { type: 'object' },
544
676
  round: { type: 'number' },
545
677
  decision: { type: 'object' },
@@ -725,10 +857,20 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
725
857
  // it visible in the tab even when this call changes nothing else.
726
858
  await publishBoard()
727
859
 
860
+ // Flat settings-panel model fields fold into the canonical roles shape:
861
+ // an unset field leaves its role untouched = inherit the session agent.
862
+ // Both fields are volatile (v0.7.7), so the loader hands back live
863
+ // references — unwrap through readConfigField ('' = inherit) exactly
864
+ // like the allGoalsMode live ref above.
865
+ const roleOverrides: RolesConfig = { ...(config.roles ?? {}) }
866
+ const taskSpec = parseAgentModelSpec(readConfigField(config.taskAgentModel, ''))
867
+ if (taskSpec) roleOverrides.default = { ...roleOverrides.default, ...taskSpec }
868
+ const verifySpec = parseAgentModelSpec(readConfigField(config.verifyAgentModel, ''))
869
+ if (verifySpec) roleOverrides.verify = { ...roleOverrides.verify, ...verifySpec }
728
870
  const controllerConfig: ControllerConfig = {
729
871
  roundScriptPath: config.roundScriptPath ?? fileURLToPath(new URL('../workflows/round.js', import.meta.url)),
730
872
  roundsPerSubgoal: config.roundsPerSubgoal,
731
- roles: config.roles,
873
+ roles: roleOverrides,
732
874
  }
733
875
  const objective = loop.objective ?? goalView?.objective ?? loop.title ?? ''
734
876
 
@@ -805,7 +947,20 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
805
947
  'revise the successor goal instead of forking the lineage',
806
948
  )
807
949
  }
808
- const abortPrevious = args.abortPrevious === true
950
+ // v0.7 abort policy: explicit abortPrevious keeps the v0.6 manual
951
+ // semantics; when the caller omits it, all-goals mode auto-decides via
952
+ // the decision tree (see decideAbort). With allGoalsMode off, the
953
+ // omitted flag means false exactly as before (backward compat) and the
954
+ // audit records that legacy default instead of a fabricated decision.
955
+ const roundInFlight = running.has(previous.goalId!)
956
+ const abortDecision = decideAbort(previous, roundInFlight, args)
957
+ const legacyDefault = args.abortPrevious === undefined && !allGoalsMode()
958
+ const effectiveAbort = legacyDefault
959
+ ? { abort: false, mode: 'explicit' as const, basis: 'all-goals 模式未开启:abortPrevious 缺省沿用 v0.6 默认 false(不中止旧轮)' }
960
+ : abortDecision
961
+ const abortPrevious = args.abortPrevious !== undefined
962
+ ? args.abortPrevious === true
963
+ : (allGoalsMode() ? abortDecision.abort : false)
809
964
  const previousHandle = running.get(previous.goalId!)
810
965
  let aborted: string | undefined
811
966
  if (abortPrevious && previousHandle) {
@@ -833,7 +988,10 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
833
988
  const at = Date.now()
834
989
  let revisionLog: string | undefined
835
990
  try {
836
- revisionLog = await writeRevisionLog(root, newGoalId, { from: previous.goalId!, to: newGoalId, reason, at })
991
+ revisionLog = await writeRevisionLog(root, newGoalId, {
992
+ from: previous.goalId!, to: newGoalId, reason, at,
993
+ abort: effectiveAbort,
994
+ })
837
995
  } catch {
838
996
  // Audit is observability: a read-only workspace must not break the
839
997
  // revision (the lineage fields on the board still carry the change).
@@ -846,6 +1004,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
846
1004
  round,
847
1005
  started: true,
848
1006
  ...(aborted ? { aborted } : {}),
1007
+ abort: effectiveAbort,
849
1008
  revision: { from: previous.goalId, to: newGoalId, reason, at },
850
1009
  ...(revisionLog ? { revisionLog } : {}),
851
1010
  }