@yangdcm/dsh-expert-team 1.3.0 → 1.3.2

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/CHANGELOG.md CHANGED
@@ -3,6 +3,125 @@
3
3
  本包遵循[语义化版本](https://semver.org/lang/zh-CN/)。dsh 宿主版本线的对应关系写在
4
4
  `package.json` 的 `engines.dsh` 与 `dsh.compatibility` 里,插件市场按它判断"这个插件跟你的宿主兼不兼容"。
5
5
 
6
+ ## 1.3.2
7
+
8
+ **诚实性收口:把"点了没反应"的设置项要么接线、要么如实标注。**
9
+ 起因是一次审核(两轮独立只读核验 + 全库 grep):20 个设置项里 **10 项的设置值没有任何消费者** ——
10
+ 用户在设置页勾了、改了,什么都不会发生。本版处理其中 3 项,并把剩下 7 项**如实标注** +
11
+ 登记在**单一真源** `INERT_SETTINGS` 里(界面上直接可见)。
12
+
13
+ ### 接线:`gates.loopGuard`(唯一"变成真的"的开关)
14
+
15
+ - 事实:行为在(`lib/loop-guard.js`)、模块能力也在(`loop-guard.test.mjs` 已证明 `{enabled:false}` 完全放行),
16
+ 但**设置值从未被读** —— 真正决定开关的是 `config.loopGuard.enabled`,而默认 bundle 的
17
+ `cordis.patch.yml` 两条 insert 行都没有 config 字段 ⇒ 设置页那个复选框是空转的。
18
+ - 接法照 `resolveTierGate` / `resolveLeadToolFace` 同一套:**config > env(`DSH_EXPERT_TEAM_LOOP_GUARD`)> 设置 > 默认(on)**,
19
+ 两个汇合点(`apply()` 与 `reapplySettingsDerived()`)各解析一次,非法值一律回默认(不猜不报错)。
20
+ - **即时生效**:`createLoopGuard` 在**构造时**就把 `enabled` 解构成常量,因此 `lib/loop-guard.js`
21
+ 现在也接受**函数/getter**,插件传 `enabled: () => LOOP_GUARD_ENABLED` ⇒ 改设置**当场生效**,
22
+ 不必重建钩子(重建会丢掉每 agent 的振荡链)。
23
+ - 关掉时**出声一次**(照 1.2.3 为 `leadToolFace` 立的纪律:"否则『我明明关了』与『开关没生效』看起来一模一样")。
24
+
25
+ ### 删除两条**装饰性**门禁开关
26
+
27
+ - `gates.boundaryTasks` / `gates.boundarySpec`:两条行为**本就无条件强制**
28
+ (`lib/interception.js` 的 `HARD_GRAPH_CODES` 与 `SPEC_COMPLETE_PHASES`;装配处 `createBoundaryInterceptor`
29
+ 的 deps 根本没有开关参数),README 也把"门禁由插件代码强制、不是提示词请求"当卖点 ⇒
30
+ 做成可关开关是**安全回退**,故**删除 spec**(共 18 项:4+5+4+5)。只把 hint 改成"预留"更糟:
31
+ UI 上留一个点了没反应的复选框本身就是一句承诺。
32
+ - 容错:设置文件里残留这两个键时**不崩、也不产生噪声 repair**(有断言守着)。
33
+
34
+ ### 回执与界面不再说谎
35
+
36
+ - `POST /settings` 的 `needsRestart` **恒为 false**:逐项查明上限/轮次/档位门/振荡开关都由
37
+ `reapplySettingsDerived()` 当场重算、`identity.*` 每次建 run 经 `compilePolicy(currentSettings())` 现读、
38
+ `SETTINGS_CACHE` 就地刷新 ⇒ **没有任何一项需要重启**。此前按"补丁顶层键 ∈ {roster,gates}"置 true
39
+ 并回一句"重启后生效",是假话(同一个补丁刚在上一行被重算过)。
40
+ - 客户端那句死分支(`needsRestart ? '已保存 —— 重启后生效' : '已保存'`)一并删除;设置页头部改为
41
+ "改动即保存并即时生效",并加一句指向逐项的「暂未生效」标记。
42
+ - 相关测试与注释同步改准(`settings.test.mjs` 的项数与 `needsRestart` 断言、`settings-page.test.mjs`
43
+ 的死分支断言、路由处的"诚实边界"注释)。
44
+
45
+ ### 未接线的 7 项:如实标注 + 单一真源
46
+
47
+ - `lib/settings.js` 新增 `INERT_SETTINGS`(键 = 限定路径,值 = "为什么现在没用",写**实测来源**):
48
+ `display.pollMs` / `display.capsuleMs` / `display.panelWidth` / `display.defaultTab` /
49
+ `roster.deliverable` / `roster.persist` / `roster.defaultRoles`。
50
+ - `decorateHint()` 是**唯一加工点**:设置页 schema(`settingsSchema()`)与扁平视图(`flatSpec()`,
51
+ 宿主 schema 侧的取值口)都经它 ⇒ 两处结果必然一致;标记**幂等**(不重复追加)。
52
+ - 这些项的 hint 现在带「(暂未生效:待 1.3.3 接线 —— …)」。**1.3.3 接线后从该表删一行,标注自动消失。**
53
+ - 设计已验证:删掉 `INERT_SETTINGS` 的某一行 ⇒ 该项界面标注**自动消失**,同时新测试转红
54
+ ("无消费者又没登记");恢复后两者复原、文件逐字节一致。
55
+
56
+ ### 新增 ratchet 测试 `settings-consumers.test.mjs`
57
+
58
+ - 每个设置项要么在生产代码里被读、要么在 `INERT_SETTINGS` 里;语义是**集合相等**(不是包含):
59
+ 新增死开关 ⇒ 红;接线后忘了删白名单 ⇒ 也红(白名单**只减不增**)。
60
+ - 判真口径处理了本仓的"同名不同物":`mode.deliverable` / `mode.persist` / `TIER_SPEC[].defaultRoles`
61
+ 都不是设置消费者 ⇒ 歧义叶子必须**限定路径**命中;**注释行不算消费者**;`lib/settings.js`
62
+ **不能整文件排除**(`compilePolicy` 在里面,否则 `identity.askBudget`/`offerDecideForMe` 会被误判)。
63
+ - 该测试写出来就当场纠正了一次误判(上条),并确认 7 项白名单与实测完全一致。
64
+
65
+ ### 设置页枚举下拉改为中文标签(**值仍是英文标识**)
66
+
67
+ - 症结:`settingsFormModel()` 把 `it.values` 原样交给渲染器 ⇒ 下拉里显示的是 `developer` /
68
+ `code+artifacts` / `team` / `soft` / `on` 这些**标识**。
69
+ - 修法:中文标签做成**规格层单一真源** —— `lib/settings.js` 的每个 enum 项新增
70
+ `labels: { '<值>': '<中文>' }`,**值域 / 类型 / 默认值 / 持久化格式 / 宿主 schema 一律不变**;
71
+ `settingsSchema()` 把 `labels` **显式透传**(该响应按字段白名单构造,漏一个字段就会静默消失),
72
+ `client.js` 的 `<option>` 文本用 `labels[值] || 值`(**缺标签退回裸值,绝不留空白**)。
73
+ - 为什么必须走响应体:`client.js` 是手写 bundle(`__ModuleLoader__` 工厂)**不能 import `lib/`**
74
+ ⇒ 标签只能随 `GET /plugins/dsh-expert-team/settings` 的 schema 到达设置页。
75
+ - 防回归:`settings.test.mjs` 断言每个 enum 项的 `Object.keys(labels)` 与 `values` **集合相等**
76
+ (新增枚举值忘配中文会红);`settings-page.test.mjs` 断言模型带出 `labels` 且渲染取用 `labels[值] || 值`。
77
+ - **宿主的 schemastery schema 不带标签**:本仓的 `@deepseek-ai/schemastery` 里无法核实"给枚举成员挂描述"
78
+ 的可靠写法,而 schema 建错会让官方设置注册失败(本仓既有纪律:"表达不可靠就退回文件层")⇒ **明确跳过**,不硬做。
79
+
80
+ ### 仍未接线(计划 1.3.3)
81
+
82
+ `display.*` 4 项与 `roster.deliverable` / `roster.persist` / `roster.defaultRoles` 3 项 ——
83
+ **本版只标注、不接线**(不碰它们的行为),接线后从 `INERT_SETTINGS` 删行即可。
84
+
85
+ ## 1.3.1
86
+
87
+ **只改文案与文档,不动任何功能逻辑。**
88
+
89
+ ### 设置页那句"需重启"改成与事实一致
90
+
91
+ - 页面顶部原文是「改动即保存(`编制` / `门禁` 两类需重启 `dsh web` 后生效)」——这在 1.1.x 成立,
92
+ **1.2.0 起不再成立**:设置写入后两条路径都会在进程内重算派生值(容量上限 `resolveLimits`、
93
+ 轮次上限 `resolveRoundLimits`、档位门 `resolveTierGate`、A 线开关 `resolveLeadToolFace`,见
94
+ `reapplySettingsDerived()`):
95
+ - **宿主路径**(宿主有 settings 服务时,也就是官方面板/浮层实际在用的那条):
96
+ `scope.watch()` → `onResolved` → 重算;保存回执原文即"已在进程内重算,**无需重启**";
97
+ - **文件回退路径**(宿主不可用、写 `settings.json`):路由里同样显式调用了重算;
98
+ 但该路径的保存回执仍按既有契约返回 `needsRestart: true`(被 `settings.test.mjs` 钉住),
99
+ 因此文案**不能一概而论**。
100
+ - 新文案如实覆盖两种形态:「改动即保存。宿主设置可用时(默认)上限与档位门在进程内即时重算,
101
+ 无需重启;若保存提示"重启后生效",按提示操作。」(英文版同步)
102
+ - 没有测试断言过这句话,故无需改任何断言。
103
+
104
+ ### README 新增「装上之后怎么用」
105
+
106
+ - 写实"装完**重启一次** `dsh web` 即自动就位":插件加载时把「专家团模式」预设铺到
107
+ `$DSH_HOME/.agent-presets/expert-team`(带版本戳、升级整目录重铺),**不需要手工创建预设**。
108
+ - 实测依据(2026-09-15):删掉该目录模拟全新安装 → 仅重启一次 ⇒ 目录被**自动新建**
109
+ (APFS birth time 落在该次启动内,比插件落盘晚 621 秒 ⇒ 排除"安装时铺")、版本戳 = 插件版本、
110
+ 与包内逐字节一致;选择器里随即出现「专家团模式」,`会话插件` 页 12/12 角色行齐全。
111
+
112
+ ### README 新增「自定义预设」
113
+
114
+ - 创建入口与机制(`设置 → Agent 预设 → 用「创造模式」创作自定义预设`,本质是"复制一份既有预设")。
115
+ - 陷阱写清:**不要**用 `expert-team` 这个 id 去「创建 preset」——那只会得到所选**源**的组合
116
+ (通常是标准模式),既不是我们的预设,还会占住该 id 让自动重铺失效;要给专家团做定制,
117
+ 请以「专家团模式」为源、**另用一个自己的 id**,改动只写在自己的目录里(`expert-team/` 归插件所有,
118
+ 加载/升级会整目录重铺)。
119
+ > ⚠️ **更正(1.3.2 补记)**:本节曾写"该路径的保存回执仍按既有契约返回 `needsRestart: true`",
120
+ > 读起来像"回执已与事实一致"——**实际那次只改了设置页顶部那一句文案**
121
+ > (`git show --stat bad5468` = 仅 `client.js` 一个文件),`lib/command.js` 的保存回执**一字未动**,
122
+ > 当时仍在说"`编制` / `门禁` 两类设置在插件加载时解析 ⇒ **重启 dsh web 后生效**"(假话)。
123
+ > 1.3.2 才真正修掉:`needsRestart` 恒 false、回执如实、客户端死分支删除。
124
+
6
125
  ## 1.3.0
7
126
 
8
127
  **设置页搬进官方「设置」菜单 + 预设在插件加载时就位 + 门禁两处修复**
package/README.en.md CHANGED
@@ -101,6 +101,32 @@ pnpm install && dsh web
101
101
  > decides whether execution tools (`bash/write/edit/grep/glob`) are taken away from the lead and given
102
102
  > to the role subagents. Turn it off with `config.leadToolFace` or `DSH_EXPERT_TEAM_LEAD_TOOLFACE=off`.
103
103
 
104
+ ### After installing
105
+
106
+ 1. **Restart `dsh web` once**: on load the plugin lays the 「专家团模式」 preset down into
107
+ `$DSH_HOME/.agent-presets/expert-team` (version-stamped; upgrades re-lay the whole directory) —
108
+ **no manual preset creation needed**. After that restart the preset picker lists it, and all 12 role
109
+ subagent tools are in place (visible under `Settings → Plugins → Plugin list → Session plugins`).
110
+ 2. Switch the session to that preset, then run `/team <one-line goal>`.
111
+ 3. Change settings under **Settings →「专家团」** (values live in the host namespace `expert-team`, so they
112
+ travel with the plugin market's backup/restore).
113
+
114
+ **Troubleshooting**: a missing preset, or a same-id preset squatting on ours, is healed by **one `dsh web`
115
+ restart** (the plugin re-lays on load); running `/team <task>` once does the same. See the
116
+ [Troubleshooting](#troubleshooting) section below for the details — including the easiest trap to fall into:
117
+ **never** create a preset with the id `expert-team`.
118
+
119
+ ### Custom presets (when you want to change expert-team's defaults)
120
+
121
+ - **Create**: `Settings → Agent presets → create a custom preset with "creation mode"` (its mechanism is
122
+ "copy an existing preset"; the result lands in `$DSH_HOME/.agent-presets/<id>/`).
123
+ - **To customise expert-team, copy 「专家团模式」 as the source under an id of your own** (e.g. `my-team`): the
124
+ copy already carries the 12 role tools and their skill directory, and your edits stay inside `my-team/`.
125
+ **Never edit files under `expert-team/`** — that copy belongs to the plugin and is re-laid wholesale on
126
+ load/upgrade.
127
+ - A newly created preset **may only appear in the picker after a `dsh web` restart** (the host reads its roster
128
+ at startup).
129
+
104
130
  ## Quick start
105
131
 
106
132
  ```
package/README.md CHANGED
@@ -93,6 +93,24 @@ pnpm install && dsh web
93
93
  > 是否把执行类工具(`bash/write/edit/grep/glob`)从 lead 手上拿走、交给角色子代理。
94
94
  > 也可用 `config.leadToolFace` 或环境变量 `DSH_EXPERT_TEAM_LEAD_TOOLFACE=off` 关闭。
95
95
 
96
+ ### 装上之后怎么用
97
+
98
+ 1. 装完**重启一次 `dsh web`**:插件加载时会把「专家团模式」预设自动铺到 `$DSH_HOME/.agent-presets/expert-team`
99
+ (带版本戳,升级会整目录重铺)—— **不需要手工创建预设**。重启后预设选择器里就有「专家团模式」,
100
+ 12 个角色化专家 subagent 工具也全部就位(可在 `设置 → 插件 → 插件列表 → 会话插件` 看到它们)。
101
+ 2. 会话切到「专家团模式」,再 `/team <一句话目标>`。
102
+ 3. 改设置走 **设置 →「专家团」**(值存在宿主命名空间 `expert-team`,随插件市场的备份/恢复一起走)。
103
+
104
+ **排障**:预设丢了、或被同名预设占住 —— **重启一次 `dsh web` 即自愈**(插件加载会重铺),也可跑一次 `/team <任务>`。
105
+ 细节见下面「排障」一节,其中包含那条最容易踩的坑:**不要**用 `expert-team` 这个 id 去「创建 preset」。
106
+
107
+ ### 自定义预设(想改专家团默认行为时)
108
+
109
+ - **创建**:`设置 → Agent 预设 → 用「创造模式」创作自定义预设`(其机制是"复制一份既有预设",产出落在 `$DSH_HOME/.agent-presets/<id>/`)。
110
+ - **要定制专家团,请以「专家团模式」为源、换一个你自己的 id**(例如 `my-team`):复制出来的目录天然带上 12 个角色工具与它的 skill 目录,
111
+ 你的改动只写在 `my-team/` 里。**永远不要改 `expert-team/` 里的文件** —— 那份是插件所有的,加载/升级会整目录重铺覆盖。
112
+ - 新建的预设**可能要重启 `dsh web` 后**才出现在选择器里(宿主名册在启动时读取)。
113
+
96
114
  ## 快速上手
97
115
 
98
116
  ```
package/client.js CHANGED
@@ -1050,7 +1050,7 @@ window.__ModuleLoader__.load({
1050
1050
  var value = typeof cur === 'undefined' ? it.default : cur
1051
1051
  return {
1052
1052
  path: it.path, key: it.key, type: it.type, label: it.label, hint: it.hint || '',
1053
- values: it.values || null, min: typeof it.min === 'number' ? it.min : null, max: typeof it.max === 'number' ? it.max : null,
1053
+ values: it.values || null, labels: it.labels || null, min: typeof it.min === 'number' ? it.min : null, max: typeof it.max === 'number' ? it.max : null,
1054
1054
  value: it.type === 'roles' ? (Array.isArray(value) ? value.join(', ') : '') : value,
1055
1055
  known: ['bool', 'int', 'enum', 'roles'].indexOf(it.type) >= 0,
1056
1056
  }
@@ -1107,7 +1107,7 @@ window.__ModuleLoader__.load({
1107
1107
  return
1108
1108
  }
1109
1109
  setData(function (prev) { return { schema: (prev && prev.schema) || [], settings: res.d.settings, ok: true } })
1110
- setMsg(res.d.needsRestart ? '已保存 —— **重启 dsh web 后生效**' : '已保存')
1110
+ setMsg('已保存') // 1.3.2:经逐项核查,没有任何设置需要重启(见 lib/command.js needsRestart 注释)⇒ 死分支已删
1111
1111
  }).catch(function (e) { setErr(String(e && e.message ? e.message : e)); setMsg('') })
1112
1112
  }
1113
1113
  if (loading) return h('div', { className: 'exp-settings' }, h('div', { className: 'exp-empty' }, t('(正在读取设置…)', '(loading settings…)')))
@@ -1133,7 +1133,7 @@ window.__ModuleLoader__.load({
1133
1133
  onChange: function (e) { var v = e.target.value; if (v !== '' && /^-?\d+$/.test(v)) save({ [r.path]: Number(v) }) } })
1134
1134
  } else if (r.type === 'enum') {
1135
1135
  ctl = h('select', { value: String(r.value), onChange: function (e) { save({ [r.path]: e.target.value }) } },
1136
- (r.values || []).map(function (v) { return h('option', { key: v, value: v }, esc(v)) }))
1136
+ (r.values || []).map(function (v) { return h('option', { key: v, value: v }, esc((r.labels && r.labels[v]) || v)) }))
1137
1137
  } else {
1138
1138
  ctl = h('input', { type: 'text', value: String(r.value), placeholder: t('留空 = 按档位默认', 'empty = tier default'), style: { width: 168 },
1139
1139
  onBlur: function (e) { var v = e.target.value.trim(); save({ [r.path]: v === '' ? null : v.split(',').map(function (x) { return x.trim() }).filter(Boolean) }) } })
@@ -1145,7 +1145,13 @@ window.__ModuleLoader__.load({
1145
1145
  })
1146
1146
  })
1147
1147
  return h('div', { className: 'exp-settings' },
1148
- h('div', { className: 'exp-settings-head' }, esc(t('改动即保存(`编制` / `门禁` 两类需重启 dsh web 后生效)。', 'Saved on change; `roster` and `gates` apply after a dsh web restart.'))),
1148
+ // 这句必须按**事实**说(2026-09-15 审核逐项查过消费者后定的口径):
1149
+ // ① 上限 / 轮次 / 档位门 / 振荡检测开关 ⇒ `reapplySettingsDerived()` 在**进程内即时重算**;
1150
+ // ② `身份`、`班底` 等 ⇒ 下一次 `/team` 建 run 时现读;
1151
+ // ③ **没有任何一项需要重启** ⇒ 回执只说"已保存"(`needsRestart` 恒 false)。
1152
+ // ④ 标着「暂未生效」的项 = **还没接线**(`INERT_SETTINGS`,见 lib/settings.js):写在这里
1153
+ // 不是承诺,而是如实告知;逐项标记由 per-item hint 携带,不在这里重复。
1154
+ h('div', { className: 'exp-settings-head' }, esc(t('改动即保存并即时生效(上限 / 轮次 / 档位门 / 振荡检测开关在进程内重算)。标着「暂未生效」的项尚未接线,改了不会有作用。', 'Saved on change and applied immediately (caps, rounds, the tier gate and the oscillation switch are recomputed in-process). Items marked as not yet in effect are not wired up — changing them does nothing.'))),
1149
1155
  rows,
1150
1156
  h('div', { className: 'exp-settings-msg' + (err ? ' bad' : '') }, esc(err ? '✗ ' + err : (msg || ''))))
1151
1157
  }
package/lib/command.js CHANGED
@@ -553,9 +553,9 @@ function roundLimitsBaseFromSettings(s = currentSettings()) {
553
553
  let PLUGIN_CONFIG = {};
554
554
 
555
555
  /**
556
- * 用**当前设置**重算三处派生值:容量上限、轮次上限、档位门。
556
+ * 用**当前设置**重算四处派生值:容量上限、轮次上限、档位门、振荡检测开关。
557
557
  *
558
- * 为什么需要它:这三处在 1.1.x 里只在 `apply()` 时算一次,于是"设置改了要重启才生效"。
558
+ * 为什么需要它:这几处在 1.1.x 里只在 `apply()` 时算一次,于是"设置改了要重启才生效"。
559
559
  * 现在官方面板/浮层改设置都会走到这里 ⇒ 不重启即生效。用 `PLUGIN_CONFIG` 而不是空对象,
560
560
  * 是为了让 `config` 覆写(优先级最高的一档)在重算后依然压得住设置值。
561
561
  */
@@ -566,6 +566,7 @@ function reapplySettingsDerived() {
566
566
  resolveRoundLimits(PLUGIN_CONFIG, roundLimitsBaseFromSettings(s));
567
567
  resolveTierGate(PLUGIN_CONFIG, s.gates);
568
568
  resolveLeadToolFace(PLUGIN_CONFIG, s.gates);
569
+ resolveLoopGuard(PLUGIN_CONFIG, s.gates);
569
570
  return true;
570
571
  } catch (e) {
571
572
  console.warn('[expert-team] 重算设置派生值失败(保持上一次生效值):' + String(e && e.message ? e.message : e));
@@ -1664,6 +1665,45 @@ function resolveLeadToolFace(config, base) {
1664
1665
  return LEAD_TOOLFACE;
1665
1666
  }
1666
1667
 
1668
+ // ── 振荡检测(C 线收窄版)的开关:**把接错的源接回来**(2026-09-15,1.3.2)─────────
1669
+ // 事实(两轮独立只读核验,含全库 grep 与应用内实测):行为在、模块能力也在
1670
+ // (`loop-guard.test.mjs` 已证明 `{enabled:false}` 完全放行),但**设置值从未被读** ——
1671
+ // 真正决定开关的是 `config.loopGuard.enabled`,而默认 bundle 的 `cordis.patch.yml` 两条 insert
1672
+ // 行都没有 config 字段 ⇒ 用户在设置页勾那个"振荡检测"复选框**没有任何效果**。
1673
+ // 这里按 tierGate/leadToolFace 同一套优先级把它接上:**config > env > 设置 > 默认(on)**。
1674
+ //
1675
+ // ⚠️ 两个"不许造假"的细节:
1676
+ // ① 解析结果是**每次工具调用现读**的(`createLoopGuard({ enabled: () => LOOP_GUARD_ENABLED })`)。
1677
+ // 本模块在构造时就把 enabled 解构成常量,若这里只传布尔值,改设置要等下次加载才生效 ——
1678
+ // 那又是一个"看起来即时、其实不即时"的假象,正是本版要消灭的那一类。
1679
+ // ② 关掉时**出声一次**(照 `LEAD_TOOLFACE_OFF_LOGGED` 的纪律):否则"我明明关了"与
1680
+ // "开关没生效"看起来一模一样。
1681
+ const DEFAULT_LOOP_GUARD = true;
1682
+ const LOOP_GUARD_ENV = 'DSH_EXPERT_TEAM_LOOP_GUARD';
1683
+ let LOOP_GUARD_ENABLED = DEFAULT_LOOP_GUARD;
1684
+ let LOOP_GUARD_OFF_LOGGED = false;
1685
+
1686
+ /** 布尔开关的容错解析:只认 `false`/`0`/`off` 与 `true`/`1`/`on`,其余(含未表态)返回 undefined。 */
1687
+ function parseBoolSwitch(raw) {
1688
+ const s = String(raw === undefined || raw === null ? '' : raw).trim().toLowerCase();
1689
+ if (s === 'false' || s === '0' || s === 'off') return false;
1690
+ if (s === 'true' || s === '1' || s === 'on') return true;
1691
+ return undefined;
1692
+ }
1693
+
1694
+ /** 解析振荡检测开关(非法值一律回默认 on —— 与档位门同理,不猜不报错)。 */
1695
+ function resolveLoopGuard(config, base) {
1696
+ const cfg = parseBoolSwitch(config && typeof config === 'object' && config.loopGuard ? config.loopGuard.enabled : undefined);
1697
+ const env = parseBoolSwitch(((typeof process !== 'undefined' && process.env) || {})[LOOP_GUARD_ENV]);
1698
+ const b = parseBoolSwitch(base && typeof base === 'object' ? base.loopGuard : base);
1699
+ LOOP_GUARD_ENABLED = cfg !== undefined ? cfg : (env !== undefined ? env : (b !== undefined ? b : DEFAULT_LOOP_GUARD));
1700
+ if (!LOOP_GUARD_ENABLED && !LOOP_GUARD_OFF_LOGGED) {
1701
+ LOOP_GUARD_OFF_LOGGED = true;
1702
+ console.log(`[expert-team] 振荡检测已关闭(${LOOP_GUARD_ENV}=false 或设置 gates.loopGuard=false)⇒ 不再拦截 A→B→A→B 来回切换`);
1703
+ }
1704
+ return LOOP_GUARD_ENABLED;
1705
+ }
1706
+
1667
1707
  /**
1668
1708
  * 解析档位门是软还是硬。**非法值一律回默认(soft)**,不猜也不报错 ——
1669
1709
  * 一个拼错的开关值把流程卡死(hard)比静默放行更糟,而"卡死"正是最贵的失败模式。
@@ -4475,7 +4515,7 @@ function warnLeadToolFaceOnce(status, detail) {
4475
4515
  LEAD_TOOLFACE_WARNED.add(status);
4476
4516
  console.warn(`[expert-team] lead 工具面**未**收窄(${status}):${detail}`);
4477
4517
  }
4478
- export const _live = { pushActivity, phaseAccountingViolations, loggedPhases, authorityViolations, WRITE_TRACER, createWriteTracer, formatConflict, summarizeTool, parseLogLine, roleOfSub, mapRoleToSub, membersFromState, buildRoleSubMap, resolveSubRoles, childSessionTiming, SUB_HEADER_CACHE, workflowEventIndex, workflowChildLabels, workflowChildMeta, workflowRuns, WF_EVENT_CACHE, rememberSessionRun, sessionRunFor, runOwnerSession, SESSION_RUNS, parseTeamCommand, deriveMemberEntries, schemaViolations, runHealth, RUN_STALL_MS, scaffoldFingerprint, SCAFFOLD_REQUIRED, strandedTasks, settleStranded, IN_FLIGHT_STATUSES, normalizeCoverage, SCHEMA_WARN_SEEN, pushActivityEvent, DEFAULT_LIMITS, LIMITS, resolveLimits, capacityViolations, DEFAULT_ROUND_LIMITS, ROUND_LIMITS, ROUND_LIMIT_ENV, resolveRoundLimits, ROUND_LIMIT_OF_KIND, roundOf, isQualityTask, normTitle, roundLimitViolations, reworkLoopWriteGuard, mutateTasks, readStandingRules, appendStandingRule, rulesRun, scopeOverlapWarnings, applyTaskStatus, waitRun, eventFamily, verdictFromToken, normalizeRoleName, truncateCodepoints, filterRunScopedSubs, runCreatedAtMs, runLogTail, liveFiles, LIVE_FILES_CACHE, DEFAULT_ROLES, resolveTierGate, TIER_GATE_ENV, snapshotRun, settingsPath, loadSettingsSync, currentSettings, limitsBaseFromSettings, roundLimitsBaseFromSettings, effectiveTierGate: () => TIER_GATE, ensureSkillInstalled, ensurePresetInstalled, uninstallInstalled, buildSkillRegistration, parseSkillMarkdown, PLUGIN_VERSION, INSTALL_STAMP, runtimeSkillRegistered: () => RUNTIME_SKILL_REGISTERED, agentScopedToolNames, warnLeadToolFaceOnce, resolveLeadToolFace, LEAD_TOOLFACE_ENV, effectiveLeadToolFace: () => LEAD_TOOLFACE, installHostSettings, hostValues, hostScope, hostSettingsNote, updateHostSettings, pickFileOnly, pickHostExpressible, buildHostSchema, hostBase, hostSchemaPaths, reapplySettingsDerived, currentSettings, mergeSettings };
4518
+ export const _live = { pushActivity, phaseAccountingViolations, loggedPhases, authorityViolations, WRITE_TRACER, createWriteTracer, formatConflict, summarizeTool, parseLogLine, roleOfSub, mapRoleToSub, membersFromState, buildRoleSubMap, resolveSubRoles, childSessionTiming, SUB_HEADER_CACHE, workflowEventIndex, workflowChildLabels, workflowChildMeta, workflowRuns, WF_EVENT_CACHE, rememberSessionRun, sessionRunFor, runOwnerSession, SESSION_RUNS, parseTeamCommand, deriveMemberEntries, schemaViolations, runHealth, RUN_STALL_MS, scaffoldFingerprint, SCAFFOLD_REQUIRED, strandedTasks, settleStranded, IN_FLIGHT_STATUSES, normalizeCoverage, SCHEMA_WARN_SEEN, pushActivityEvent, DEFAULT_LIMITS, LIMITS, resolveLimits, capacityViolations, DEFAULT_ROUND_LIMITS, ROUND_LIMITS, ROUND_LIMIT_ENV, resolveRoundLimits, ROUND_LIMIT_OF_KIND, roundOf, isQualityTask, normTitle, roundLimitViolations, reworkLoopWriteGuard, mutateTasks, readStandingRules, appendStandingRule, rulesRun, scopeOverlapWarnings, applyTaskStatus, waitRun, eventFamily, verdictFromToken, normalizeRoleName, truncateCodepoints, filterRunScopedSubs, runCreatedAtMs, runLogTail, liveFiles, LIVE_FILES_CACHE, DEFAULT_ROLES, resolveTierGate, TIER_GATE_ENV, snapshotRun, settingsPath, loadSettingsSync, currentSettings, limitsBaseFromSettings, roundLimitsBaseFromSettings, effectiveTierGate: () => TIER_GATE, ensureSkillInstalled, ensurePresetInstalled, uninstallInstalled, buildSkillRegistration, parseSkillMarkdown, PLUGIN_VERSION, INSTALL_STAMP, runtimeSkillRegistered: () => RUNTIME_SKILL_REGISTERED, agentScopedToolNames, warnLeadToolFaceOnce, resolveLeadToolFace, LEAD_TOOLFACE_ENV, effectiveLeadToolFace: () => LEAD_TOOLFACE, resolveLoopGuard, LOOP_GUARD_ENV, effectiveLoopGuard: () => LOOP_GUARD_ENABLED, installHostSettings, hostValues, hostScope, hostSettingsNote, updateHostSettings, pickFileOnly, pickHostExpressible, buildHostSchema, hostBase, hostSchemaPaths, reapplySettingsDerived, currentSettings, mergeSettings };
4479
4519
 
4480
4520
  export function apply(ctx, config) {
4481
4521
  // 留一份 config:设置在运行时改变(官方面板 / 浮层)时要**用同一份 config** 重算上限与档位门,
@@ -4495,6 +4535,8 @@ export function apply(ctx, config) {
4495
4535
  resolveTierGate(config, currentSettings().gates);
4496
4536
  // A 线开关:config > env > 设置 > 默认(on)。关掉即 lead 保留全部工具。
4497
4537
  resolveLeadToolFace(config, currentSettings().gates);
4538
+ // C 线振荡检测开关:config > env > 设置 > 默认(on)。此前只读 config ⇒ 设置页那个复选框是空转的。
4539
+ resolveLoopGuard(config, currentSettings().gates);
4498
4540
  // 批 2-1:宿主若提供 ctx.fs,就让它接管工件写入(真实版本栅栏);否则保持同语义的回退实现。
4499
4541
  // 机会式探测而非 inject —— 见 ARTIFACT 声明处的说明(inject 缺失服务会让整条命令挂不上)。
4500
4542
  const hostFsAdopted = adoptHostFs(ctx);
@@ -4610,7 +4652,9 @@ export function apply(ctx, config) {
4610
4652
  // 不重写;这里只补它明确声明不覆盖的那一种 —— A→B→A→B 在两方案间来回。
4611
4653
  // 刻意用 `block`(宿主那份是 advisory):**完全相同的轮询可能合法,但振荡从来不是**。
4612
4654
  const loopGuard = createLoopGuard({
4613
- enabled: !(config && config.loopGuard && config.loopGuard.enabled === false),
4655
+ // **函数入参**:本模块在构造时就把 enabled 解构成常量,只有传函数才能让设置改动即时生效
4656
+ // (传布尔值 ⇒ 改完设置要等下次加载,那是"看起来即时、其实不即时"的假象)。
4657
+ enabled: () => LOOP_GUARD_ENABLED,
4614
4658
  onEvent: (type, payload) => {
4615
4659
  try { console.warn(`[expert-team] ${type}`, JSON.stringify(payload)); } catch { /* 观测失败不影响工具结果 */ }
4616
4660
  },
@@ -5425,9 +5469,10 @@ export function apply(ctx, config) {
5425
5469
 
5426
5470
  // ── F 线:设置控制台路由(GET 读 / POST 写)──────────────────────────────
5427
5471
  // 走 `withRoute`(B 线第 9 项抽出的共享件):方法校验 / 405 空体 / 500 兜底一条不落。
5428
- // **诚实边界**:`roster` / `gates` 两类设置是在 `apply()` 时解析进内存的
5429
- // (容量上限、轮次上限、档位门),所以改完**要重启 `dsh web` 才生效**;
5430
- // `display` 类是浮层每次拉设置时读的,即时生效。响应里如实分开说,不假装"立刻全部生效"。
5472
+ // **诚实边界(2026-09-15 更正)**:此前这里写"`roster`/`gates` 两类改完要重启、`display`
5473
+ // 类即时生效"—— **两句都不实**:前者由 `reapplySettingsDerived()` 当场重算(见下),
5474
+ // 后者在 1.3.2 审核中查明**根本没有消费者**(`display.*` 只有存储层,待 1.3.3 接线,
5475
+ // 已在 hint 上如实标注「暂未生效」)。响应里不再声称任何一类需要重启。
5431
5476
  const disposeSettings = registerLocal({
5432
5477
  kind: 'exact',
5433
5478
  path: '/plugins/dsh-expert-team/settings',
@@ -5463,20 +5508,23 @@ export function apply(ctx, config) {
5463
5508
  json(500, { ok: false, errors: [`设置写入失败:${String((e && e.message) || e)}`], settings: currentSettings() });
5464
5509
  return;
5465
5510
  }
5466
- const touched = new Set(Object.keys(patch && typeof patch === 'object' ? patch : {}).flatMap((k) => (k.includes('.') ? [k.split('.')[0]] : [k])));
5467
5511
  SETTINGS_CACHE = { settings: merged.value, repaired: [], at: new Date().toISOString() };
5468
5512
  if (!viaHost.ok) reapplySettingsDerived(); // 文件路径:与 1.1.x 一样在进程内重算
5469
- const needsRestart = !viaHost.ok && (touched.has('roster') || touched.has('gates'));
5513
+ // `needsRestart` **恒为 false**(2026-09-15 逐项查明):能改的项里,容量/轮次上限、
5514
+ // 档位门、振荡检测开关都由上面 `reapplySettingsDerived()` 当场重算;
5515
+ // `identity.*` 每次建 run 时经 `compilePolicy(currentSettings())` 现读;
5516
+ // `SETTINGS_CACHE` 就在上一行刷新 ⇒ **没有任何一项需要重启**。
5517
+ // 此前按"补丁顶层键 ∈ {roster,gates}"置 true 并回一句"重启后生效",那是**假话**:
5518
+ // 同一个补丁刚在上一行被重算过。字段保留是为了不破坏客户端契约,但它只能说真话。
5519
+ const needsRestart = false;
5470
5520
  json(200, {
5471
5521
  ok: true,
5472
5522
  settings: merged.value,
5473
5523
  needsRestart,
5474
5524
  settingsSource: viaHost.ok ? 'host' : 'file',
5475
5525
  note: viaHost.ok
5476
- ? `已保存到宿主设置(命名空间 ${HOST_SETTINGS_NAMESPACE}):官方面板与浮层共用同一份,且随插件市场的备份/恢复一起走;上限与档位门已在进程内重算,**无需重启**。`
5477
- : needsRestart
5478
- ? '已保存。`编制` / `门禁` 两类设置在插件加载时解析 ⇒ **重启 dsh web 后生效**;`显示` 类即时生效。'
5479
- : '已保存并即时生效。',
5526
+ ? `已保存到宿主设置(命名空间 ${HOST_SETTINGS_NAMESPACE}):官方面板与浮层共用同一份,且随插件市场的备份/恢复一起走;上限、轮次、档位门与振荡检测开关已在进程内重算,**无需重启**。`
5527
+ : '已保存并即时生效(上限 / 轮次 / 档位门 / 振荡检测开关在进程内重算;`身份`、`班底` 等在下一次 `/team` 建 run 时读取)。',
5480
5528
  });
5481
5529
  }, { methods: ['GET', 'POST'] }),
5482
5530
  });
package/lib/loop-guard.js CHANGED
@@ -88,7 +88,8 @@ export function oscillationPair(history) {
88
88
  *
89
89
  * @param deps - 注入依赖:
90
90
  * `onEvent(type, payload)`(可观测:让"守卫被行使"看得见)、
91
- * `enabled`(默认 true)、`historySize`(默认 8)、`previewChars`(默认 200)、
91
+ * `enabled`(默认 true;**布尔或返回布尔的函数** —— 函数形式让"设置改完即时生效"成为可能,
92
+ * 见下方 `isEnabled` 的说明)、`historySize`(默认 8)、`previewChars`(默认 200)、
92
93
  * `makeFeedback(text)`(把文案包成宿主认的 `ContentBlock[]`;默认 `[{type:'text',text}]`)。
93
94
  * @returns `(exec, result, next) => Promise<PostToolDecision>`
94
95
  */
@@ -101,6 +102,13 @@ export function createLoopGuard(deps = {}) {
101
102
  makeFeedback = (text) => [{ type: 'text', text }],
102
103
  } = deps;
103
104
 
105
+ // `enabled` 是**每次调用时现读**的:布尔值在这里被固化成常量,函数则每次求值。
106
+ // 为什么需要函数形式(2026-09-15 加):插件侧把它接到设置开关上,而本模块在**构造时**就解构了
107
+ // enabled ⇒ 只传布尔的话,用户改了设置必须**重建钩子**才生效(那会丢掉每 agent 的振荡链,
108
+ // 还容易漏 dispose 造成钩子叠加)。传函数则零副作用地即时生效 —— 这是"开关不许是假的"的一部分:
109
+ // 一个"看起来即时、其实要重启"的复选框,与一个没有消费者的复选框一样糟。
110
+ const isEnabled = typeof enabled === 'function' ? enabled : () => enabled !== false;
111
+
104
112
  /** 每个 agent 一条链(用 agent 对象本身做键 ⇒ 不泄漏、不串号)。 */
105
113
  const chains = new WeakMap();
106
114
 
@@ -116,7 +124,7 @@ export function createLoopGuard(deps = {}) {
116
124
  if (typeof next !== 'function') return { kind: 'accept' };
117
125
  const downstream = await next();
118
126
  try {
119
- if (!enabled) return downstream;
127
+ if (!isEnabled()) return downstream;
120
128
  // ② 工具本身已失败 ⇒ 不插嘴(沿用边界拦截器的同一条纪律:不拿无关理由掩盖真错因)
121
129
  if (result && (result.isError === true || result.error)) return downstream;
122
130
  // ③ 只对**会改文件内容**的工具记链(写/编辑);读类工具交替调用是正常干活
package/lib/settings.js CHANGED
@@ -22,7 +22,7 @@ export const SETTINGS_SPEC = {
22
22
  label: '身份',
23
23
  hint: '团队怎么跟你说话、问你什么',
24
24
  items: {
25
- profile: { type: 'enum', values: ['developer', 'non-technical', 'mixed'], default: 'developer', label: '你的身份', hint: '技术开发者 / 无技术经验 / 混合。影响提问口径与是否代你决策' },
25
+ profile: { type: 'enum', values: ['developer', 'non-technical', 'mixed'], default: 'developer', label: '你的身份', hint: '技术开发者 / 无技术经验 / 混合。影响提问口径与是否代你决策', labels: { developer: '技术开发者', 'non-technical': '无技术经验', mixed: '混合' } },
26
26
  askBudget: { type: 'int', min: 0, max: 20, default: 5, label: '一次澄清最多问几个问题', hint: '0 = 不主动问,按保守默认处理并写明' },
27
27
  offerDecideForMe: { type: 'bool', default: false, label: '帮我把技术决策定下来', hint: '开启后由 pm 代决并逐条留痕(产品级/范围级决策仍必须你确认)' },
28
28
  keepPlanGate: { type: 'bool', default: true, label: '保留「方案确认门」', hint: '关掉即起草完方案直接开工 —— 不推荐,方案错了后面全错' },
@@ -33,7 +33,7 @@ export const SETTINGS_SPEC = {
33
33
  hint: '默认班底与容量上限',
34
34
  items: {
35
35
  defaultRoles: { type: 'roles', default: null, label: '默认班底', hint: '留空 = 按档位默认;填了就固定用这套' },
36
- deliverable: { type: 'enum', values: ['code+artifacts', 'artifacts-only'], default: 'code+artifacts', label: '交付口径', hint: 'artifacts-only = 只出工件,不改代码' },
36
+ deliverable: { type: 'enum', values: ['code+artifacts', 'artifacts-only'], default: 'code+artifacts', label: '交付口径', hint: 'artifacts-only = 只出工件,不改代码', labels: { 'code+artifacts': '出代码 + 工件', 'artifacts-only': '只出工件,不改代码' } },
37
37
  persist: { type: 'bool', default: false, label: '默认持久化活团队', hint: '开 = 成员可反复指挥;关 = 一次性自动组队' },
38
38
  maxMembers: { type: 'int', min: 0, max: 500, default: 32, label: '成员数上限', hint: '超过即拒绝落盘并显式报错' },
39
39
  maxTasks: { type: 'int', min: 0, max: 5000, default: 200, label: '任务数上限', hint: '同上' },
@@ -46,29 +46,79 @@ export const SETTINGS_SPEC = {
46
46
  pollMs: { type: 'int', min: 1000, max: 60000, default: 3000, label: '面板轮询间隔(毫秒)', hint: '越小越实时、越费资源' },
47
47
  capsuleMs: { type: 'int', min: 0, max: 60000, default: 4000, label: '胶囊提示停留(毫秒)', hint: '0 = 不自动消失' },
48
48
  panelWidth: { type: 'int', min: 280, max: 900, default: 420, label: '浮层宽度(像素)', hint: '' },
49
- defaultTab: { type: 'enum', values: ['team', 'tasks', 'board', 'info'], default: 'team', label: '默认页签', hint: '' },
49
+ defaultTab: { type: 'enum', values: ['team', 'tasks', 'board', 'info'], default: 'team', label: '默认页签', hint: '面板页签的中文简称:人=团队、事=任务、盘=看板、料=资料', labels: { team: '人(团队)', tasks: '事(任务)', board: '盘(看板)', info: '料(资料)' } },
50
50
  },
51
51
  },
52
52
  gates: {
53
53
  label: '门禁',
54
54
  hint: '写侧门禁与档位选择门(关掉即退回只告警)',
55
55
  items: {
56
- boundaryTasks: { type: 'bool', default: true, label: '台账硬规则(TASKS.json)', hint: '重复 id / 环 / 自依赖 ⇒ 当场顶回' },
57
- boundarySpec: { type: 'bool', default: true, label: '规格边界章节(SPEC.md)', hint: '边界没填就不让进 implement' },
58
56
  loopGuard: { type: 'bool', default: true, label: '振荡检测(A→B→A→B)', hint: '宿主那份只管同工具重复,这份管来回振荡' },
59
- tierGate: { type: 'enum', values: ['soft', 'hard'], default: 'soft', label: '档位选择门', hint: 'soft = 建议先生效;hard = 不选不开工' },
60
- leadToolFace: { type: 'enum', values: ['on', 'off'], default: 'on', label: '收窄 lead 工具面', hint: 'on = 执行类工具(bash/write/edit/grep/glob)从 lead 手上拿走,交给角色子代理(省 token);off = lead 保留全部工具' },
57
+ tierGate: { type: 'enum', values: ['soft', 'hard'], default: 'soft', label: '档位选择门', hint: 'soft = 建议先生效;hard = 不选不开工', labels: { soft: '软门(建议先生效)', hard: '硬门(不选不开工)' } },
58
+ leadToolFace: { type: 'enum', values: ['on', 'off'], default: 'on', label: '收窄 lead 工具面', hint: 'on = 执行类工具(bash/write/edit/grep/glob)从 lead 手上拿走,交给角色子代理(省 token);off = lead 保留全部工具', labels: { on: '开(从 lead 拿走执行工具)', off: '关(lead 保留全部工具)' } },
61
59
  maxReviewRounds: { type: 'int', min: 1, max: 20, default: 3, label: '评审轮次上限', hint: '到顶的正确动作是升级用户,不是再来一轮' },
62
60
  maxTestRounds: { type: 'int', min: 1, max: 20, default: 3, label: '测试轮次上限', hint: '同上' },
63
61
  },
64
62
  },
65
63
  };
66
64
 
67
- /** 全部设置项的扁平视图:`{ 'identity.profile': {…spec, group, key} }`。 */
65
+ // ── 「暂未生效」的项:**单一真源**(2026-09-15,1.3.2 诚实性收口)────────────────────
66
+ //
67
+ // 为什么需要这张表:审核(两轮独立只读核验 + 全库 grep)发现 20 个设置项里有 10 项的**设置值
68
+ // 没有任何消费者** —— 用户在设置页勾了、改了,什么都不会发生。其中 3 项已在本版处理
69
+ // (`gates.boundaryTasks`/`gates.boundarySpec` 的行为本就**无条件强制**,故直接删除 spec;
70
+ // `gates.loopGuard` 是"接错了源",已接线)。剩下 7 项**本版只标注、不接线**(留 1.3.3)。
71
+ //
72
+ // 为什么不干脆删掉这 7 项:它们描述的是**真要实现的能力**(浮层宽度、默认页签、默认班底…),
73
+ // 不是误加的开关。但"存在即承诺"——把 hint 写成承诺、行为却是空的,与删掉那两条装饰开关的理由
74
+ // 完全一样(不能双重标准)。所以:**如实标注"暂未生效"**,直到真的接上。
75
+ //
76
+ // 用法(两处消费,同一个真源):
77
+ // ① `decorateHint()` 把标记追加到 hint(设置页表单可见);
78
+ // ② `settings-consumers.test.mjs` 用它做白名单,语义是**集合相等**:
79
+ // 新增死开关 ⇒ 红;接线后忘了删这一行 ⇒ 也红(逼着清单只减不增)。
80
+ // 键 = 限定路径;值 = 一句"为什么现在没用"(写**实测来源**,不写猜测)。
81
+ export const INERT_SETTINGS = {
82
+ 'display.pollMs': '现值硬编码在浮层(client.js:1317 的 1200/3000)',
83
+ 'display.capsuleMs': '现值硬编码在浮层(client.js:2336 的 4000)',
84
+ 'display.panelWidth': '现值来自 localStorage 的 `et-width`(client.js:309),与设置无关',
85
+ 'display.defaultTab': '现值硬编码为 team(client.js:1198)',
86
+ 'roster.deliverable': '现由 `/team --no-code` 派生(mode.noCode),设置值不参与',
87
+ 'roster.persist': '现仅由 `/team --persist` 置位(mode.persist),设置值不参与',
88
+ 'roster.defaultRoles': '现用常量 DEFAULT_ROLES 或 `--roles`,设置值不参与',
89
+ };
90
+
91
+ /** 「暂未生效」标记文本(只此一份,`decorateHint` 是唯一加工点)。 */
92
+ export function inertMark(path) {
93
+ const why = INERT_SETTINGS[String(path)];
94
+ return why ? `(暂未生效:待 1.3.3 接线 —— ${why})` : '';
95
+ }
96
+
97
+ /**
98
+ * 给 hint 追加「暂未生效」标记。**必须是唯一加工点**:设置页 schema(`settingsSchema()`)与
99
+ * 扁平视图(`flatSpec()`,宿主 schema 侧的取值口)都经它,两处结果必然一致 ——
100
+ * 只标注其中一处等于造了第二个真源。
101
+ *
102
+ * **幂等**:hint 末尾已有该标记就不再追加(防止二次生成/重复加工把文案叠成一串)。
103
+ * @param path - 限定路径(`display.pollMs`)
104
+ * @param hint - 原 hint(可为空串/undefined)
105
+ * @returns 加工后的 hint
106
+ */
107
+ export function decorateHint(path, hint) {
108
+ const mark = inertMark(path);
109
+ const h = hint === undefined || hint === null ? '' : String(hint);
110
+ if (!mark || h.includes(mark)) return h;
111
+ return h ? `${h} ${mark}` : mark;
112
+ }
113
+
114
+ /** 全部设置项的扁平视图:`{ 'identity.profile': {…spec, group, key} }`(hint 已含「暂未生效」标记)。 */
68
115
  export function flatSpec() {
69
116
  const out = {};
70
117
  for (const g of SETTINGS_GROUPS) {
71
- for (const [k, item] of Object.entries(SETTINGS_SPEC[g].items)) out[`${g}.${k}`] = { ...item, group: g, key: k };
118
+ for (const [k, item] of Object.entries(SETTINGS_SPEC[g].items)) {
119
+ const path = `${g}.${k}`;
120
+ out[path] = { ...item, hint: decorateHint(path, item.hint), group: g, key: k };
121
+ }
72
122
  }
73
123
  return out;
74
124
  }
@@ -185,7 +235,10 @@ export function settingsSchema() {
185
235
  items: Object.entries(SETTINGS_SPEC[g].items).map(([k, item]) => ({
186
236
  path: `${g}.${k}`, key: k, type: item.type, values: item.values ?? null,
187
237
  min: item.min ?? null, max: item.max ?? null, default: item.default,
188
- label: item.label, hint: item.hint,
238
+ label: item.label, hint: decorateHint(`${g}.${k}`, item.hint),
239
+ // 枚举的中文标签必须**显式**透传:这里是按字段白名单构造的,漏一个字段它就会静默消失
240
+ // (客户端是手写 bundle、不能 import lib/ ⇒ 标签只能随这个响应体到达设置页)。
241
+ labels: item.labels || null,
189
242
  })),
190
243
  }));
191
244
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yangdcm/dsh-expert-team",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "dsh「专家团」bundle:一句自然语言自动组建/持久化一支 12 角色多智能体团队,共享工作区协议 + 阶段门控编排 + 结构化交接 + 质量门禁/自动调度,实现者直接改代码并产出持久工件;带 live 团队浮层(质量门禁/覆盖率/工件预览)。 · Role-based multi-agent expert team for DeepSeek Harness: one sentence in, a staged and gated team delivery out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -84,7 +84,7 @@
84
84
  "check:name": "node scripts/rename-package.mjs --check",
85
85
  "test:regression": "node regression.test.mjs",
86
86
  "test:e2e": "node e2e.test.mjs",
87
- "test:all": "node bootstrap.test.mjs && node host-settings.test.mjs && node smoke.test.mjs && node regression.test.mjs && node e2e.test.mjs && node flow.test.mjs && node plan-decide.test.mjs && node plan-discard.test.mjs && node models-honesty.test.mjs && node evidence-gate.test.mjs && node sync-gate.test.mjs && node artifact-writer.test.mjs && node dag.test.mjs && node metrics.test.mjs && node broken-chain.test.mjs && node schema-warn.test.mjs && node template-copy.test.mjs && node task-binding.test.mjs && node role-merge.test.mjs && node wf-recovery.test.mjs && node panorama.test.mjs && node run-ownership.test.mjs && node role-identity.test.mjs && node command-parse.test.mjs && node member-registry.test.mjs && node schema-check.test.mjs && node run-health.test.mjs && node cordis-fs-port.test.mjs && node scaffold-fingerprint.test.mjs && node preset-lint.test.mjs && node stranded-tasks.test.mjs && node agent-scope-guard.test.mjs && node schema-warn-noise.test.mjs && node tool-card-status.test.mjs && node dag-status.test.mjs && node capacity-limits.test.mjs && node task-cas.test.mjs && node write-lock.test.mjs && node card-stall.test.mjs && node multi-session-and-confirm.test.mjs && node standing-rules.test.mjs && node scope-overlap.test.mjs && node task-cancel-protect.test.mjs && node wait-run.test.mjs && node rework-loop.test.mjs && node client-css-integrity.test.mjs && node interception.test.mjs && node mutation-catalog.test.mjs && node write-bypass-ratchet.test.mjs && node asi-hazards.test.mjs && node dispatch-contract.test.mjs && node closure-ledger.test.mjs && node rework-nature.test.mjs && node loop-guard.test.mjs && node vocab-consistency.test.mjs && node state-no-write.test.mjs && node routes-shared.test.mjs && node metrics-render.test.mjs && node metrics-collect.test.mjs && node first-runnable.test.mjs && node closing-budget.test.mjs && node scan-single-source.test.mjs && node tier.test.mjs && node dispatch-ledger.test.mjs && node tier-gate.test.mjs && node tier-badge.test.mjs && node validate-module.test.mjs && node settings.test.mjs && node settings-page.test.mjs && node policy.test.mjs && node phase-accounting.test.mjs && node log-parse-module.test.mjs && node command-parse-module.test.mjs && node authority.test.mjs && node write-tracer.test.mjs && node token-accounting.test.mjs && node lead-toolface.test.mjs",
87
+ "test:all": "node bootstrap.test.mjs && node host-settings.test.mjs && node smoke.test.mjs && node regression.test.mjs && node e2e.test.mjs && node flow.test.mjs && node plan-decide.test.mjs && node plan-discard.test.mjs && node models-honesty.test.mjs && node evidence-gate.test.mjs && node sync-gate.test.mjs && node artifact-writer.test.mjs && node dag.test.mjs && node metrics.test.mjs && node broken-chain.test.mjs && node schema-warn.test.mjs && node template-copy.test.mjs && node task-binding.test.mjs && node role-merge.test.mjs && node wf-recovery.test.mjs && node panorama.test.mjs && node run-ownership.test.mjs && node role-identity.test.mjs && node command-parse.test.mjs && node member-registry.test.mjs && node schema-check.test.mjs && node run-health.test.mjs && node cordis-fs-port.test.mjs && node scaffold-fingerprint.test.mjs && node preset-lint.test.mjs && node stranded-tasks.test.mjs && node agent-scope-guard.test.mjs && node schema-warn-noise.test.mjs && node tool-card-status.test.mjs && node dag-status.test.mjs && node capacity-limits.test.mjs && node task-cas.test.mjs && node write-lock.test.mjs && node card-stall.test.mjs && node multi-session-and-confirm.test.mjs && node standing-rules.test.mjs && node scope-overlap.test.mjs && node task-cancel-protect.test.mjs && node wait-run.test.mjs && node rework-loop.test.mjs && node client-css-integrity.test.mjs && node interception.test.mjs && node mutation-catalog.test.mjs && node write-bypass-ratchet.test.mjs && node asi-hazards.test.mjs && node dispatch-contract.test.mjs && node closure-ledger.test.mjs && node rework-nature.test.mjs && node loop-guard.test.mjs && node vocab-consistency.test.mjs && node state-no-write.test.mjs && node routes-shared.test.mjs && node metrics-render.test.mjs && node metrics-collect.test.mjs && node first-runnable.test.mjs && node closing-budget.test.mjs && node scan-single-source.test.mjs && node tier.test.mjs && node dispatch-ledger.test.mjs && node tier-gate.test.mjs && node tier-badge.test.mjs && node validate-module.test.mjs && node settings.test.mjs && node settings-page.test.mjs && node policy.test.mjs && node phase-accounting.test.mjs && node log-parse-module.test.mjs && node command-parse-module.test.mjs && node authority.test.mjs && node write-tracer.test.mjs && node token-accounting.test.mjs && node lead-toolface.test.mjs && node settings-consumers.test.mjs",
88
88
  "test:toolcard": "node tool-card-status.test.mjs",
89
89
  "test:flow": "node flow.test.mjs",
90
90
  "test:decide": "node plan-decide.test.mjs",