@yangdcm/dsh-expert-team 1.1.1 → 1.2.0
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 +56 -0
- package/README.en.md +27 -6
- package/README.md +22 -5
- package/lib/command-parse.js +3 -0
- package/lib/command.js +325 -41
- package/lib/host-settings.js +216 -0
- package/lib/routes/shared.js +134 -0
- package/package.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,62 @@
|
|
|
3
3
|
本包遵循[语义化版本](https://semver.org/lang/zh-CN/)。dsh 宿主版本线的对应关系写在
|
|
4
4
|
`package.json` 的 `engines.dsh` 与 `dsh.compatibility` 里,插件市场按它判断"这个插件跟你的宿主兼不兼容"。
|
|
5
5
|
|
|
6
|
+
## 1.2.0
|
|
7
|
+
|
|
8
|
+
**安全加固(本机来源守卫)+ 自举安装收口 + 设置接进宿主命名空间**
|
|
9
|
+
|
|
10
|
+
### 设置接进宿主命名空间
|
|
11
|
+
|
|
12
|
+
- 插件加载时把设置注册为宿主命名空间 `expert-team`(`ctx.inject(['settings'], …)` 作为优雅降级边界):
|
|
13
|
+
- 出现在 **设置 → 插件 → 插件配置**,由宿主按我们给的 schema 渲染 —— schema 由
|
|
14
|
+
`lib/settings.js` 的 `SETTINGS_SPEC` **生成**(字段/范围/默认值都是单一真源,不抄第二份);
|
|
15
|
+
- 随插件市场的**备份与恢复**一起走(市场只备份"profile 插件清单 + 设置",自造 JSON 不在内);
|
|
16
|
+
- 改动**即时生效**:`scope.watch()` → 用同一份 `config` 重算容量上限/轮次上限/档位门,不必重启。
|
|
17
|
+
- `base` 用**当前生效设置**填充 ⇒ 面板一打开看到的就是真正在用的值,**不需要任何一次性迁移**;
|
|
18
|
+
优先级不变:`schema 默认 < 我们传的 base < 官方面板用户层`。
|
|
19
|
+
- **诚实边界**:`roster.defaultRoles`(角色 id 数组或 null)**不上**官方面板 —— 它在 schemastery 里的
|
|
20
|
+
表达方式跨版本没把握,一旦 schema 建错宿主会拒绝注册、面板里什么都看不到。该字段继续由浮层设置
|
|
21
|
+
页签与 `$DSH_HOME/expert-team/settings.json` 负责;写入时**每个字段只进一个存储**
|
|
22
|
+
(可表达的进宿主、表达不了的进文件),不制造"一个字段两个家"。
|
|
23
|
+
- 宿主没有 settings 服务、或 `@deepseek-ai/schemastery` 解析不到(例如在仓库里跑测试)⇒ 完全回退到
|
|
24
|
+
`settings.json`,行为与 1.1.x 一致;失败原因会被记下并出声,不静默。
|
|
25
|
+
- `POST /settings` 响应新增 `settingsSource: 'host' | 'file'`,便于排查"这次改到底写哪儿了"。
|
|
26
|
+
|
|
27
|
+
### 自举安装收口
|
|
28
|
+
|
|
29
|
+
- **skill 改为运行时注册**(`ctx.skills.register`):默认**一个文件都不再写到 `$DSH_HOME/skills`**,
|
|
30
|
+
相对资源用 `resourceBase` 指回包内目录。宿主没有 skill 注册表时才回退到复制(行为与 1.1.x 相同)。
|
|
31
|
+
宿主优先级是 项目条目 > 运行时条目 > 用户根,因此运行时注册还能压过历史遗留的旧副本。
|
|
32
|
+
- **preset 仍走复制**(宿主 `dsh-agent-presets` 没有"运行时加扫描根"的 API,roots 只来自配置),
|
|
33
|
+
但现在:① 带**版本戳** `.expert-team-version`,升级后**整目录重铺**(旧实现"存在即 return",
|
|
34
|
+
于是插件升级后运行时永远停在旧副本,且没有任何报错);② 登记到
|
|
35
|
+
`$DSH_HOME/expert-team/installed.json`;③ 只在目标**确属本插件产物**时才覆盖
|
|
36
|
+
(用户自己写的同名 preset 不认、不动)。
|
|
37
|
+
- 新增 **`/team uninstall`**:回收本插件铺到 `$DSH_HOME` 的副本(skill/preset 与清单本身),
|
|
38
|
+
**只删带我们戳或身份可判定为本插件产物的目录**,用户内容一律保留并如实列出;幂等。
|
|
39
|
+
它不碰工作区里的 `team/` 运行目录,也不碰插件包本身(那由插件市场卸载)。
|
|
40
|
+
- `scripts/check-sync.mjs`:skill 那一份副本改为**可选目标** —— 1.2.0 起它默认不存在,
|
|
41
|
+
再按硬性目标比对就会在正常安装上误报漂移。
|
|
42
|
+
|
|
43
|
+
### 安全加固
|
|
44
|
+
|
|
45
|
+
**安全加固:浮层路由的本机来源守卫 + `/file` 走宿主策略**
|
|
46
|
+
|
|
47
|
+
- 11 条浮层路由(`/state` `/artifact` `/codeidx` `/file` `/decide` `/plan` `/plan/approve`
|
|
48
|
+
`/plan/discard` `/task` `/metrics/refresh` `/settings`)此前只校验 HTTP 方法,现在在**唯一的注册入口**
|
|
49
|
+
`registerLocal` 上统一套 `localOnly` 守卫:
|
|
50
|
+
- `Host` 非回环 ⇒ 403 —— 挡 **DNS rebinding**(攻击页把自己的域名解析到 127.0.0.1 后即为同源)
|
|
51
|
+
- **写方法**的 `Origin` 非回环 ⇒ 403 —— 挡跨站写入;GET 刻意放行,因为浏览器不会把无 CORS 头的
|
|
52
|
+
响应体交给跨站页面,而放行它能保住 `/team canvas --watch` 生成的 `file://` 页面(`Origin: null`)
|
|
53
|
+
- `socket.remoteAddress` 非回环 ⇒ 403 —— 挡局域网(宿主配 `host: 0.0.0.0` 时)
|
|
54
|
+
- **写方法** `content-type` 非 `application/json` ⇒ 415 —— 挡 form / text-plain 这类不触发预检的简单请求
|
|
55
|
+
- 三条规则都**只在相应头部存在时生效**:浏览器必然带 Host、POST 必然带 Origin,而本地非浏览器
|
|
56
|
+
客户端本就能自造头部(插件没有鉴权模型,这一点写在 README 的已知限制里)
|
|
57
|
+
- `/file`:有宿主 `ctx.fs` 时改走宿主策略(`resolve` + `readText`),**策略拒绝时如实报 403,
|
|
58
|
+
不再退回裸 `readFile`**;响应新增 `source: 'host-fs' | 'node-fs'` 便于排查
|
|
59
|
+
- 新增棘轮:`routes-shared.test.mjs` 断言 `lib/command.js` 里真正调用宿主 `register` 的地方只有
|
|
60
|
+
`registerLocal` 内部那一处、11 条路由全部声明 `methods` —— 新加路由不可能忘记加固
|
|
61
|
+
|
|
6
62
|
## 1.1.1
|
|
7
63
|
|
|
8
64
|
**兼容性声明 + 可移植性修复**(不含功能变更)
|
package/README.en.md
CHANGED
|
@@ -74,8 +74,23 @@ cd ~/.dsh/profiles/web
|
|
|
74
74
|
pnpm install && dsh web
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
> The
|
|
78
|
-
>
|
|
77
|
+
> **The skill is never copied to disk**: when the plugin loads it registers the `expert-team` skill as a
|
|
78
|
+
> **runtime entry** in the host skill registry (relative resources resolve through `resourceBase` back into
|
|
79
|
+
> the package), so nothing appears under `$DSH_HOME/skills/` — uninstalling stays clean. Only when the host
|
|
80
|
+
> has no skill registry does it fall back to copying.
|
|
81
|
+
>
|
|
82
|
+
> **The 「专家团模式」 preset is still copied** into `$DSH_HOME/.agent-presets/` (the host offers no
|
|
83
|
+
> runtime API to add a preset scan root), but it carries a version stamp and is re-materialised in full on
|
|
84
|
+
> upgrade instead of silently going stale. `/team uninstall` reclaims the copies this plugin laid down —
|
|
85
|
+
> it only removes directories carrying our stamp, and never touches content you authored yourself.
|
|
86
|
+
>
|
|
87
|
+
> **Where settings live**: on load the plugin registers its settings as the host namespace `expert-team`,
|
|
88
|
+
> so they appear under **Settings → Plugins → Plugin configuration** and take effect immediately (limits,
|
|
89
|
+
> round caps and the tier gate are recomputed in-process — no restart), and they travel with the plugin
|
|
90
|
+
> market's **backup and restore**. **Honest boundary**: the default roster (an array of role ids) is *not*
|
|
91
|
+
> on the official page — its value type cannot be expressed reliably in the host schema, so it stays with
|
|
92
|
+
> the overlay's settings tab and `$DSH_HOME/expert-team/settings.json`. On a host with no settings service
|
|
93
|
+
> every setting falls back to that file.
|
|
79
94
|
|
|
80
95
|
## Quick start
|
|
81
96
|
|
|
@@ -128,12 +143,18 @@ npm run check:name # verify no placeholder package name is left behind
|
|
|
128
143
|
|
|
129
144
|
## Known limitations
|
|
130
145
|
|
|
131
|
-
- **The local
|
|
132
|
-
|
|
133
|
-
|
|
146
|
+
- **The local routes are guarded, but that is not authentication**: all 11 overlay routes now check
|
|
147
|
+
`Host` (blocks DNS rebinding), the `Origin` of write requests (blocks cross-site writes), and the
|
|
148
|
+
client address (blocks non-loopback clients); write requests must also send `application/json`
|
|
149
|
+
(blocks form / text-plain "simple requests" that never trigger a preflight). `/file` now goes through
|
|
150
|
+
the host `ctx.fs` policy and reports 403 instead of falling back to a raw read when the policy denies it.
|
|
151
|
+
**Residual risk, stated plainly**: a local non-browser process can forge any header, and dsh plugins
|
|
152
|
+
have no authentication model — so do not expose `dsh web` to an untrusted network (with
|
|
153
|
+
`host: 0.0.0.0` the guard only stops clients without a loopback address).
|
|
134
154
|
- **Web profile only**: the overlay and routes need `webServer`. The command and artifacts still work without it.
|
|
135
155
|
- **Preset drift**: the bundled 「专家团模式」 is a copy of the official `standard` preset plus the role
|
|
136
|
-
tools; upstream preset restructuring needs a matching update here.
|
|
156
|
+
tools; upstream preset restructuring needs a matching update here. Upgrades re-materialise it by version
|
|
157
|
+
stamp, and `/team uninstall` reclaims it (a same-named preset you authored yourself is left alone).
|
|
137
158
|
- Calls `git status --porcelain` (read-only) to judge artifact freshness.
|
|
138
159
|
|
|
139
160
|
## License
|
package/README.md
CHANGED
|
@@ -71,8 +71,19 @@ cd ~/.dsh/profiles/web
|
|
|
71
71
|
pnpm install && dsh web
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
>
|
|
75
|
-
> `$DSH_HOME/skills/`
|
|
74
|
+
> **skill 不落地**:插件加载时就把 `expert-team` skill 作为**运行时条目**注册进宿主的 skill 注册表
|
|
75
|
+
> (相对资源用 `resourceBase` 指回包内目录),所以 `$DSH_HOME/skills/` 下不会出现副本 ——
|
|
76
|
+
> 卸载即干净。宿主没有 skill 注册表时才会回退为复制到 `$DSH_HOME/skills/`。
|
|
77
|
+
>
|
|
78
|
+
> **「专家团模式」preset 仍会复制**到 `$DSH_HOME/.agent-presets/`(宿主没有"运行时加扫描根"的
|
|
79
|
+
> API),但带版本戳:升级后整目录重铺,不会静默停在旧版本。用 `/team uninstall` 可回收本插件
|
|
80
|
+
> 铺下的副本(只删带我们版本戳的目录,用户自己写的同名内容一律保留)。
|
|
81
|
+
>
|
|
82
|
+
> **设置在哪改**:插件加载时把设置注册成宿主命名空间 `expert-team` ⇒ 出现在
|
|
83
|
+
> **设置 → 插件 → 插件配置**,改动**即时生效**(上限/轮次/档位门在进程内重算,不必重启),
|
|
84
|
+
> 并且随插件市场的**备份与恢复**一起走。**诚实边界**:`默认班底`(角色 id 数组)不在官方页面里
|
|
85
|
+
> —— 它的取值类型在宿主 schema 里表达不可靠,继续由浮层设置页签与
|
|
86
|
+
> `$DSH_HOME/expert-team/settings.json` 负责;宿主没有 settings 服务时,全部设置退回该文件。
|
|
76
87
|
|
|
77
88
|
## 快速上手
|
|
78
89
|
|
|
@@ -81,6 +92,7 @@ pnpm install && dsh web
|
|
|
81
92
|
/team --persist 重构订单模块 # 持久化活团队:成员可反复指挥、跨会话恢复
|
|
82
93
|
/team --no-code 评审现有 API 设计 # 只产出计划/评审/测试工件,不改代码
|
|
83
94
|
/team --confirm 大改版需求 # 先建 run、不自动派工,浮层点「执行」才开工
|
|
95
|
+
/team uninstall # 回收本插件铺到 $DSH_HOME 的副本(skill 默认走运行时注册,本就不落地)
|
|
84
96
|
/team status # 所有 run 的阶段、成员、模型计划、实时违规
|
|
85
97
|
/team resume <run-id> # 跨会话恢复
|
|
86
98
|
```
|
|
@@ -123,11 +135,16 @@ dsh 安装里插件自带的 Config schema(dsh 路径自动探测,可用 `DS
|
|
|
123
135
|
|
|
124
136
|
## 已知限制
|
|
125
137
|
|
|
126
|
-
-
|
|
127
|
-
|
|
138
|
+
- **本机来源守卫已就位,但不是鉴权**:11 条浮层路由现在统一校验 Host(挡 DNS rebinding)、
|
|
139
|
+
写方法的 Origin(挡跨站写入)、客户端地址(挡局域网),写方法还要求 `application/json`
|
|
140
|
+
(挡 form / text-plain 这类不触发预检的"简单请求");`/file` 改走宿主 `ctx.fs` 策略,
|
|
141
|
+
读被策略拒绝时如实报 403 而**不退回裸读**。
|
|
142
|
+
**残余风险如实说明**:本地非浏览器进程本来就能自造任意请求头,而 dsh 插件没有鉴权模型 ——
|
|
143
|
+
所以别把 `dsh web` 暴露到不可信网络(宿主配置 `host: 0.0.0.0` 时,守卫只挡住"没有回环地址"的客户端)。
|
|
128
144
|
- **仅 web profile**:浮层与路由依赖 `webServer`;无浮层时命令与工件仍然可用。
|
|
129
145
|
- **preset 漂移**:随包的「专家团模式」是官方 `standard` preset 的拷贝 + 角色工具,
|
|
130
|
-
宿主若调整内置 preset
|
|
146
|
+
宿主若调整内置 preset 结构,需要同步更新。升级插件时会按版本戳整目录重铺;
|
|
147
|
+
`/team uninstall` 可回收它(用户自己写的同名 preset 不会被碰)。
|
|
131
148
|
- 会调用 `git status --porcelain`(只读)用于工件新鲜度判断。
|
|
132
149
|
|
|
133
150
|
## License
|
package/lib/command-parse.js
CHANGED
|
@@ -57,6 +57,7 @@ export const USAGE = [
|
|
|
57
57
|
' /team settle [<run>] 清算冷启动后「没人做」的在飞任务(→ pending + attempt+1 + 留痕)',
|
|
58
58
|
' /team wait [<run>] 查看在飞任务进展(不阻塞;无进展超 5 分钟会提示)',
|
|
59
59
|
' /team clear [<run>] 清理一个或全部 run',
|
|
60
|
+
' /team uninstall 回收本插件铺到 $DSH_HOME 的副本(skill 现默认走运行时注册,本就不落地)',
|
|
60
61
|
].join('\n');
|
|
61
62
|
|
|
62
63
|
// ── command parsing ────────────────────────────────────────────────────────
|
|
@@ -117,6 +118,8 @@ export function parseTeamCommand(rawInput) {
|
|
|
117
118
|
}
|
|
118
119
|
if (lower === 'clear') return { kind: 'clear' };
|
|
119
120
|
if (lower.startsWith('clear ')) return { kind: 'clear', run: input.slice(6).trim() };
|
|
121
|
+
// 卸载收口:把本插件铺到 $DSH_HOME 的副本(历史 skill/preset)收回来
|
|
122
|
+
if (lower === 'uninstall' || lower === '卸载') return { kind: 'uninstall' };
|
|
120
123
|
// L3-2′ 可清算:显式清算冷启动后的搁浅任务(回 pending + attempt+1 + 留痕)
|
|
121
124
|
if (lower === 'settle') return { kind: 'settle', run: null };
|
|
122
125
|
if (lower.startsWith('settle ')) return { kind: 'settle', run: input.slice(7).trim() };
|
package/lib/command.js
CHANGED
|
@@ -27,7 +27,7 @@ import { createWriteTracer, formatConflict } from './write-tracer.js';
|
|
|
27
27
|
import { createLoopGuard } from './loop-guard.js';
|
|
28
28
|
// C 线第 17 项:派工即回写(派工成功后由**代码**把任务置 in_progress + owner,不靠模型记得改)。
|
|
29
29
|
import { createDispatchLedger } from './dispatch-ledger.js';
|
|
30
|
-
import { readRequestBody, withRoute } from './routes/shared.js';
|
|
30
|
+
import { readRequestBody, withRoute, localOnly } from './routes/shared.js';
|
|
31
31
|
import { renderMetrics } from './metrics/render.js';
|
|
32
32
|
// E 线 E1/E2:首产物耗时(`first-runnable` − `run:started`)与收尾预算(实现期 vs 冻结后收尾)。
|
|
33
33
|
// 时钟算术**只此一份** —— 两处各写一遍等于故意埋一次「一个事实多份拷贝」(本仓头号返工源)。
|
|
@@ -71,6 +71,9 @@ import {
|
|
|
71
71
|
import { USAGE, resolveProfile, parseTeamCommand } from './command-parse.js';
|
|
72
72
|
// F 线:设置控制台的 spec / 校验 / 合并(纯函数模块,UI 表单结构也由它生成)。
|
|
73
73
|
import { SETTINGS_GROUPS, SETTINGS_SPEC, defaultSettings, normalizeSettings, mergeSettings, settingsSchema, compilePolicy, policyBlockFrom } from './settings.js';
|
|
74
|
+
// 宿主设置命名空间接线(1.2.0):设置进官方面板 + 随市场备份恢复 + 改动即时生效。
|
|
75
|
+
// 模块内部用**动态 import** 取 schemastery,因此本仓在没有 profile 的环境里照样能跑测试。
|
|
76
|
+
import { installHostSettings, hostValues, hostScope, hostSettingsNote, updateHostSettings, pickFileOnly, pickHostExpressible, HOST_SETTINGS_NAMESPACE, buildHostSchema, hostBase, hostSchemaPaths } from './host-settings.js';
|
|
74
77
|
|
|
75
78
|
/**
|
|
76
79
|
* 工件写入的受控入口(批 2-1)。
|
|
@@ -87,6 +90,13 @@ import { SETTINGS_GROUPS, SETTINGS_SPEC, defaultSettings, normalizeSettings, mer
|
|
|
87
90
|
let WRITER_IDENTITY = null;
|
|
88
91
|
const writerEvents = (e) => { try { pushActivityEvent(e); } catch { /* 观测不该影响写入 */ } };
|
|
89
92
|
let ARTIFACT = createArtifactWriter({ fsPort: nodeFsPort(), onEvent: writerEvents, identity: () => WRITER_IDENTITY });
|
|
93
|
+
/**
|
|
94
|
+
* 已被接管的宿主 `ctx.fs`(若宿主提供)。**只在读路径(`/file`)里用**。
|
|
95
|
+
*
|
|
96
|
+
* 为什么单独留一个引用:工件写入从批 2-1 起就走宿主策略了,但浮层的文件预览一直在用裸
|
|
97
|
+
* `readFile` —— 同一台机器上两套读规则,等于绕开宿主的 fs 策略(观察策略/沙箱)。
|
|
98
|
+
*/
|
|
99
|
+
let ADOPTED_HOST_FS = null;
|
|
90
100
|
|
|
91
101
|
/** 让宿主 `ctx.fs` 接管写入(若可用);返回是否接管成功。 */
|
|
92
102
|
function adoptHostFs(ctx) {
|
|
@@ -94,6 +104,7 @@ function adoptHostFs(ctx) {
|
|
|
94
104
|
const hostFs = ctx && typeof ctx.get === 'function' ? ctx.get('fs') : null;
|
|
95
105
|
if (hostFs && typeof hostFs.writeText === 'function' && typeof hostFs.resolve === 'function') {
|
|
96
106
|
ARTIFACT = createArtifactWriter({ fsPort: cordisFsPort(hostFs), onEvent: writerEvents, identity: () => WRITER_IDENTITY });
|
|
107
|
+
ADOPTED_HOST_FS = hostFs;
|
|
97
108
|
return true;
|
|
98
109
|
}
|
|
99
110
|
} catch { /* 宿主 fs 不可用则保持回退实现 */ }
|
|
@@ -441,6 +452,12 @@ const THIS_DIR = dirname(fileURLToPath(import.meta.url));
|
|
|
441
452
|
// Single source of truth: the skill + its assets ship inside the bundle and are
|
|
442
453
|
// copied verbatim into the user skill root on demand.
|
|
443
454
|
const SKILL_SRC = new URL('../skills/expert-team/', import.meta.url);
|
|
455
|
+
/**
|
|
456
|
+
* 包内 skill 目录的**规范化绝对路径**(去掉 URL→路径留下的尾部分隔符)。
|
|
457
|
+
* 尾斜杠看着无害,但它会被原样塞进 `resourceBase.path` 传给宿主,也可能让按字符串比较路径的
|
|
458
|
+
* 消费方(以及我们自己的测试)判不等 —— 归一化一次,比在每个使用点各修一遍可靠。
|
|
459
|
+
*/
|
|
460
|
+
const SKILL_DIR = fileURLToPath(SKILL_SRC).replace(/[\\/]+$/, '');
|
|
444
461
|
const TEMPLATES_SRC = new URL('../skills/expert-team/assets/templates/', import.meta.url);
|
|
445
462
|
// The `expert-team` agent preset (role subagent tool instances) ships alongside
|
|
446
463
|
// the skill and is copied into the user preset root so the roster can discover it.
|
|
@@ -499,10 +516,28 @@ function loadSettingsSync() {
|
|
|
499
516
|
return SETTINGS_CACHE;
|
|
500
517
|
}
|
|
501
518
|
|
|
502
|
-
/**
|
|
519
|
+
/**
|
|
520
|
+
* 取当前设置(读失败也返回默认,不抛)。
|
|
521
|
+
*
|
|
522
|
+
* **"设置"这一档的唯一读取口**:宿主命名空间可用时,以它的解析值为准
|
|
523
|
+
* (构成为 `schema 默认 < 我们传入的 base < 官方面板用户层`),再用文件补上宿主表达不了的字段
|
|
524
|
+
* (如 `roster.defaultRoles` —— 见 host-settings.js 里为什么它不上官方面板)。
|
|
525
|
+
* 宿主不可用时,与 1.1.x 完全一致:直接读 `settings.json`。
|
|
526
|
+
*/
|
|
503
527
|
function currentSettings() {
|
|
504
|
-
|
|
528
|
+
const file = (SETTINGS_CACHE || loadSettingsSync()).settings;
|
|
529
|
+
const host = hostValues();
|
|
530
|
+
if (!host) return file;
|
|
531
|
+
const merged = mergeSettings(file, host);
|
|
532
|
+
if (merged.ok) return merged.value;
|
|
533
|
+
// 宿主给了我们认不出的键(schema 漂移)⇒ 如实出声一次,然后按文件值走,绝不静默改语义
|
|
534
|
+
if (!HOST_SETTINGS_WARNED) {
|
|
535
|
+
HOST_SETTINGS_WARNED = true;
|
|
536
|
+
console.warn('[expert-team] 宿主设置里有本插件认不出的键,已退回 settings.json:' + merged.errors.slice(0, 3).join(';'));
|
|
537
|
+
}
|
|
538
|
+
return file;
|
|
505
539
|
}
|
|
540
|
+
let HOST_SETTINGS_WARNED = false;
|
|
506
541
|
|
|
507
542
|
/** 设置 → 容量上限的**基础值**(优先级最低的一档:config > env > 设置 > 内置默认)。 */
|
|
508
543
|
function limitsBaseFromSettings(s = currentSettings()) {
|
|
@@ -514,6 +549,28 @@ function roundLimitsBaseFromSettings(s = currentSettings()) {
|
|
|
514
549
|
return { maxReviewRounds: s.gates.maxReviewRounds, maxTestRounds: s.gates.maxTestRounds };
|
|
515
550
|
}
|
|
516
551
|
|
|
552
|
+
/** 插件加载时传给 `apply(ctx, config)` 的 config(重算时要沿用,不能丢覆写)。 */
|
|
553
|
+
let PLUGIN_CONFIG = {};
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* 用**当前设置**重算三处派生值:容量上限、轮次上限、档位门。
|
|
557
|
+
*
|
|
558
|
+
* 为什么需要它:这三处在 1.1.x 里只在 `apply()` 时算一次,于是"设置改了要重启才生效"。
|
|
559
|
+
* 现在官方面板/浮层改设置都会走到这里 ⇒ 不重启即生效。用 `PLUGIN_CONFIG` 而不是空对象,
|
|
560
|
+
* 是为了让 `config` 覆写(优先级最高的一档)在重算后依然压得住设置值。
|
|
561
|
+
*/
|
|
562
|
+
function reapplySettingsDerived() {
|
|
563
|
+
try {
|
|
564
|
+
const s = currentSettings();
|
|
565
|
+
resolveLimits(PLUGIN_CONFIG, limitsBaseFromSettings(s));
|
|
566
|
+
resolveRoundLimits(PLUGIN_CONFIG, roundLimitsBaseFromSettings(s));
|
|
567
|
+
resolveTierGate(PLUGIN_CONFIG, s.gates);
|
|
568
|
+
return true;
|
|
569
|
+
} catch (e) {
|
|
570
|
+
console.warn('[expert-team] 重算设置派生值失败(保持上一次生效值):' + String(e && e.message ? e.message : e));
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
517
574
|
|
|
518
575
|
function teamRoot(cwd) {
|
|
519
576
|
return join(cwd, 'team');
|
|
@@ -666,36 +723,197 @@ async function copyDir(src, dest) {
|
|
|
666
723
|
}
|
|
667
724
|
}
|
|
668
725
|
|
|
726
|
+
// ── 自举安装的收口(2026-09-14)─────────────────────────────────────────────
|
|
727
|
+
//
|
|
728
|
+
// 背景:skill 与「专家团模式」preset 一直靠"首次 /team 时复制到 $DSH_HOME"就位。两个真问题:
|
|
729
|
+
// ① **静默过期**:旧实现是"目标存在就直接 return",于是插件升级后运行时永远停在旧副本
|
|
730
|
+
// (改了 skill 却在真实会话里不生效,且没有任何报错 —— 本仓最隐蔽的一类假完成);
|
|
731
|
+
// ② **卸载残留**:插件市场卸载只删 node_modules,$DSH_HOME 下那份副本会留下:
|
|
732
|
+
// 一个孤儿 skill,以及一个"选中就报错"的 preset。
|
|
733
|
+
// 现在:skill 改为**运行时注册**(一个文件都不写,见 registerRuntimeSkill);preset 因为宿主
|
|
734
|
+
// 没有"运行时加扫描根"的 API(`dsh-agent-presets` 的 roots 只来自配置),仍走复制,但加
|
|
735
|
+
// **版本戳**(升级即整目录重铺)并**登记到清单**,由 `/team uninstall` 精确回收 ——
|
|
736
|
+
// 且只回收带我们戳(或身份可判定为本插件产物)的目录,绝不删用户自己写的同名内容。
|
|
737
|
+
|
|
738
|
+
/** 本插件版本,用作副本的版本戳(从包内 package.json 读,避免 ESM import JSON 的兼容问题)。 */
|
|
739
|
+
const PLUGIN_VERSION = (() => {
|
|
740
|
+
try {
|
|
741
|
+
return String(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.0.0');
|
|
742
|
+
} catch { return '0.0.0'; }
|
|
743
|
+
})();
|
|
744
|
+
/** 版本戳文件名:目录里出现它,就说明这个目录是**本插件铺的**。 */
|
|
745
|
+
const INSTALL_STAMP = '.expert-team-version';
|
|
746
|
+
/** 已铺副本的登记表(供 `/team uninstall` 精确回收)。 */
|
|
747
|
+
function installedManifestPath() { return join(dshHome(), 'expert-team', 'installed.json'); }
|
|
748
|
+
|
|
749
|
+
/** 解析 SKILL.md 的 YAML frontmatter(只支持本文件实际使用的单行标量)。 */
|
|
750
|
+
function parseSkillMarkdown(text) {
|
|
751
|
+
const src = String(text ?? '');
|
|
752
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src);
|
|
753
|
+
if (!m) return { meta: {}, body: src };
|
|
754
|
+
const meta = {};
|
|
755
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
756
|
+
const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
|
|
757
|
+
if (!kv) continue;
|
|
758
|
+
let v = kv[2].trim();
|
|
759
|
+
if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) v = v.slice(1, -1);
|
|
760
|
+
if (v) meta[kv[1]] = v;
|
|
761
|
+
}
|
|
762
|
+
return { meta, body: src.slice(m[0].length) };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** 组装"运行时 skill"注册对象;读不到就返回 null(调用方回退到复制)。 */
|
|
766
|
+
function buildSkillRegistration() {
|
|
767
|
+
const { meta, body } = parseSkillMarkdown(readFileSync(new URL('SKILL.md', SKILL_SRC), 'utf8'));
|
|
768
|
+
if (!meta.name || !body.trim()) return null;
|
|
769
|
+
const reg = {
|
|
770
|
+
name: meta.name,
|
|
771
|
+
description: meta.description || '',
|
|
772
|
+
content: body,
|
|
773
|
+
source: 'runtime',
|
|
774
|
+
resourceBase: { kind: 'directory', path: SKILL_DIR },
|
|
775
|
+
metadata: { plugin: '@yangdcm/dsh-expert-team', version: PLUGIN_VERSION },
|
|
776
|
+
};
|
|
777
|
+
if (meta.whenToUse) reg.whenToUse = meta.whenToUse;
|
|
778
|
+
return reg;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** 运行时 skill 是否注册成功(成功 ⇒ 不再往 $DSH_HOME 写任何 skill 文件)。 */
|
|
782
|
+
let RUNTIME_SKILL_REGISTERED = false;
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* 把 skill 作为**运行时条目**注册进宿主的 skill 注册表。
|
|
786
|
+
* 宿主优先级为 项目条目 > 运行时条目 > 用户根 ⇒ 运行时注册还能压过 `$DSH_HOME/skills` 里的历史副本。
|
|
787
|
+
*/
|
|
788
|
+
function registerRuntimeSkill(ctx) {
|
|
789
|
+
try {
|
|
790
|
+
const skills = ctx && typeof ctx.get === 'function' ? ctx.get('skills') : null;
|
|
791
|
+
if (!skills || typeof skills.register !== 'function') return false;
|
|
792
|
+
const reg = buildSkillRegistration();
|
|
793
|
+
if (!reg) return false;
|
|
794
|
+
const dispose = skills.register(reg);
|
|
795
|
+
RUNTIME_SKILL_REGISTERED = true;
|
|
796
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => dispose);
|
|
797
|
+
console.log('[expert-team] skill 已作为运行时条目注册(不再写入 $DSH_HOME/skills)');
|
|
798
|
+
return true;
|
|
799
|
+
} catch (e) {
|
|
800
|
+
console.warn('[expert-team] 运行时注册 skill 失败,回退到自举复制:' + String(e && e.message ? e.message : e));
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function pathExists(p) { try { await stat(p); return true; } catch { return false; } }
|
|
806
|
+
async function readTextSafe(p) { try { return await readFile(p, 'utf8'); } catch { return null; } }
|
|
807
|
+
|
|
808
|
+
/** 记下"这个目录是本插件铺的"(卸载据此精确回收)。 */
|
|
809
|
+
async function recordInstalled(kind, dir) {
|
|
810
|
+
const p = installedManifestPath();
|
|
811
|
+
try {
|
|
812
|
+
let cur = {};
|
|
813
|
+
try { cur = JSON.parse(await readFile(p, 'utf8')) || {}; } catch { cur = {}; }
|
|
814
|
+
cur[kind] = { path: dir, version: PLUGIN_VERSION, at: new Date().toISOString() };
|
|
815
|
+
await mkdir(dirname(p), { recursive: true });
|
|
816
|
+
await ARTIFACT.must(p, JSON.stringify(cur, null, 2) + '\n');
|
|
817
|
+
} catch (e) {
|
|
818
|
+
// 登记失败不该让安装本身失败,但**绝不能静默** —— 清单缺失会让 `/team uninstall` 回收不到
|
|
819
|
+
// 这次铺的副本(那正是"卸载残留"这个坑本身)。如实告警并给出路径。
|
|
820
|
+
console.warn(`[expert-team] 未能登记已铺副本(${kind}: ${dir})→ /team uninstall 可能回收不到它:${String(e && e.message ? e.message : e)}`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
async function readInstalled() {
|
|
824
|
+
try { return JSON.parse(await readFile(installedManifestPath(), 'utf8')) || {}; } catch { return {}; }
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* 这个目录是否可以安全地当成"本插件的副本"来覆盖/删除。
|
|
829
|
+
* 判据两条,任一成立即可:① 带我们的版本戳;② 身份可判定为本插件产物
|
|
830
|
+
* (1.2.0 之前铺的副本没有戳 —— 不认它们,升级时就永远刷不掉旧 skill)。
|
|
831
|
+
*/
|
|
832
|
+
async function isOwnedCopy(dir, kind) {
|
|
833
|
+
if ((await readTextSafe(join(dir, INSTALL_STAMP))) !== null) return true;
|
|
834
|
+
if (kind === 'skill') {
|
|
835
|
+
const md = await readTextSafe(join(dir, 'SKILL.md'));
|
|
836
|
+
if (md === null) return false;
|
|
837
|
+
return parseSkillMarkdown(md).meta.name === 'expert-team';
|
|
838
|
+
}
|
|
839
|
+
const yml = await readTextSafe(join(dir, 'agent.cordis.yml'));
|
|
840
|
+
return yml !== null && /tool-subagent-pm/.test(yml);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* `/team uninstall`:回收本插件铺到 `$DSH_HOME` 的副本(skill / preset)。
|
|
845
|
+
* 安全规则:**只回收"本插件的副本"** —— 用户自己写的同名 preset/skill 不带我们的戳、
|
|
846
|
+
* 身份也对不上,绝不会被删。工作区里的 `team/` 运行目录与插件包本身都不动。
|
|
847
|
+
*/
|
|
848
|
+
async function uninstallInstalled() {
|
|
849
|
+
const rec = await readInstalled();
|
|
850
|
+
const removed = [];
|
|
851
|
+
const skipped = [];
|
|
852
|
+
for (const [kind, info] of Object.entries(rec)) {
|
|
853
|
+
const dir = info && info.path;
|
|
854
|
+
if (!dir) continue;
|
|
855
|
+
if (await pathExists(dir) && await isOwnedCopy(dir, kind)) {
|
|
856
|
+
try { await rm(dir, { recursive: true, force: true }); removed.push(`${kind}:${dir}`); }
|
|
857
|
+
catch (e) { skipped.push(`${kind}:${dir}(删除失败:${String(e && e.message ? e.message : e)})`); }
|
|
858
|
+
} else if (await pathExists(dir)) {
|
|
859
|
+
skipped.push(`${kind}:${dir}(不是本插件的副本 ⇒ 保留)`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
try { await rm(installedManifestPath(), { force: true }); } catch { /* 清单删不掉就留着 */ }
|
|
863
|
+
const lines = ['# /team uninstall', ''];
|
|
864
|
+
lines.push(removed.length ? '已回收:' : '没有需要回收的副本。');
|
|
865
|
+
for (const r of removed) lines.push('- ' + r);
|
|
866
|
+
if (skipped.length) { lines.push('', '保留(未删除):'); for (const s of skipped) lines.push('- ' + s); }
|
|
867
|
+
lines.push(
|
|
868
|
+
'',
|
|
869
|
+
'说明:skill 现在默认走**运行时注册**,本来就不写盘;这里回收的是历史版本留下的副本,',
|
|
870
|
+
'或宿主没有 skill 注册表时的回退副本。',
|
|
871
|
+
'本命令不碰工作区里的 `team/` 运行目录,也不碰插件包本身(那由插件市场卸载)。',
|
|
872
|
+
);
|
|
873
|
+
return { kind: 'success', text: lines.join('\n') };
|
|
874
|
+
}
|
|
875
|
+
|
|
669
876
|
async function ensureSkillInstalled() {
|
|
877
|
+
// 运行时注册成功 ⇒ 一个文件都不写(默认路径;也是"卸载即干净"的前提)
|
|
878
|
+
if (RUNTIME_SKILL_REGISTERED) return SKILL_DIR;
|
|
670
879
|
const target = join(dshHome(), 'skills', 'expert-team');
|
|
671
|
-
|
|
672
|
-
await
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
880
|
+
if (await pathExists(target)) {
|
|
881
|
+
if (!(await isOwnedCopy(target, 'skill'))) {
|
|
882
|
+
console.warn('[expert-team] $DSH_HOME/skills/expert-team 已存在且不像本插件的产物,未覆盖');
|
|
883
|
+
return target;
|
|
884
|
+
}
|
|
885
|
+
const fresh = (await readTextSafe(join(target, INSTALL_STAMP)))?.trim() === PLUGIN_VERSION
|
|
886
|
+
&& ((await readTextSafe(join(target, 'SKILL.md'))) || '').trim().length > 0;
|
|
887
|
+
if (fresh) return target;
|
|
888
|
+
// 旧版本副本:整目录重铺(逐个覆盖会留下新版本已删除的文件)
|
|
889
|
+
try { await rm(target, { recursive: true, force: true }); } catch { /* 删不掉就试着覆盖 */ }
|
|
676
890
|
}
|
|
677
891
|
try {
|
|
678
|
-
await copyDir(
|
|
892
|
+
await copyDir(SKILL_DIR, target);
|
|
893
|
+
await ARTIFACT.must(join(target, INSTALL_STAMP), PLUGIN_VERSION + '\n');
|
|
894
|
+
await recordInstalled('skill', target);
|
|
679
895
|
return target;
|
|
680
|
-
} catch
|
|
681
|
-
return null; // best-effort; the agent can still run from the bundle path if present
|
|
682
|
-
}
|
|
896
|
+
} catch { return null; }
|
|
683
897
|
}
|
|
684
898
|
|
|
685
899
|
async function ensurePresetInstalled() {
|
|
686
900
|
const target = join(dshHome(), '.agent-presets', 'expert-team');
|
|
687
|
-
|
|
688
|
-
await
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
901
|
+
if (await pathExists(target)) {
|
|
902
|
+
if (!(await isOwnedCopy(target, 'preset'))) {
|
|
903
|
+
console.warn('[expert-team] $DSH_HOME/.agent-presets/expert-team 已存在且不是本插件所铺,未覆盖(用户的定制优先)');
|
|
904
|
+
return target;
|
|
905
|
+
}
|
|
906
|
+
const fresh = (await readTextSafe(join(target, INSTALL_STAMP)))?.trim() === PLUGIN_VERSION
|
|
907
|
+
&& ((await readTextSafe(join(target, 'agent.cordis.yml'))) || '').trim().length > 0;
|
|
908
|
+
if (fresh) return target;
|
|
909
|
+
try { await rm(target, { recursive: true, force: true }); } catch { /* 删不掉就试着覆盖 */ }
|
|
692
910
|
}
|
|
693
911
|
try {
|
|
694
912
|
await copyDir(fileURLToPath(PRESET_SRC), target);
|
|
913
|
+
await ARTIFACT.must(join(target, INSTALL_STAMP), PLUGIN_VERSION + '\n');
|
|
914
|
+
await recordInstalled('preset', target);
|
|
695
915
|
return target;
|
|
696
|
-
} catch
|
|
697
|
-
return null; // best-effort
|
|
698
|
-
}
|
|
916
|
+
} catch { return null; }
|
|
699
917
|
}
|
|
700
918
|
|
|
701
919
|
// ── scaffold ───────────────────────────────────────────────────────────────
|
|
@@ -4163,6 +4381,7 @@ async function executeTeamCommand(ctx, invocation) {
|
|
|
4163
4381
|
case 'resume': return resumeRun(ctx, invocation, cwd, c.run);
|
|
4164
4382
|
case 'rules': return rulesRun(cwd, c.action, c.rule);
|
|
4165
4383
|
case 'clear': return clearRuns(cwd, c.run);
|
|
4384
|
+
case 'uninstall': return uninstallInstalled();
|
|
4166
4385
|
case 'settle': return settleRuns(cwd, c.run, ctx);
|
|
4167
4386
|
case 'wait': return waitRun(cwd, c.run);
|
|
4168
4387
|
case 'create': return createRun(ctx, invocation, cwd, c);
|
|
@@ -4174,9 +4393,12 @@ async function executeTeamCommand(ctx, invocation) {
|
|
|
4174
4393
|
/** 并发写追踪器:**进程级单例**(跨工具调用累积;`_live` 暴露以便单测重置与断言)。 */
|
|
4175
4394
|
const WRITE_TRACER = createWriteTracer();
|
|
4176
4395
|
|
|
4177
|
-
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 };
|
|
4396
|
+
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, installHostSettings, hostValues, hostScope, hostSettingsNote, updateHostSettings, pickFileOnly, pickHostExpressible, buildHostSchema, hostBase, hostSchemaPaths, reapplySettingsDerived, currentSettings, mergeSettings };
|
|
4178
4397
|
|
|
4179
4398
|
export function apply(ctx, config) {
|
|
4399
|
+
// 留一份 config:设置在运行时改变(官方面板 / 浮层)时要**用同一份 config** 重算上限与档位门,
|
|
4400
|
+
// 否则 config 覆写会在重算时被悄悄丢掉("改了设置,用户配的 config 上限没了"这种伤最难查)。
|
|
4401
|
+
PLUGIN_CONFIG = config || {};
|
|
4180
4402
|
// F 线:先读设置(同步、一次性)—— 它是**最低优先级**的来源(config > env > 设置 > 内置默认)。
|
|
4181
4403
|
const settingsRepairs = loadSettingsSync().repaired;
|
|
4182
4404
|
if (settingsRepairs.length) {
|
|
@@ -4193,6 +4415,15 @@ export function apply(ctx, config) {
|
|
|
4193
4415
|
// 机会式探测而非 inject —— 见 ARTIFACT 声明处的说明(inject 缺失服务会让整条命令挂不上)。
|
|
4194
4416
|
const hostFsAdopted = adoptHostFs(ctx);
|
|
4195
4417
|
if (hostFsAdopted) console.log('[expert-team] 工件写入已接管宿主 ctx.fs 版本栅栏');
|
|
4418
|
+
// skill 走**运行时注册**(不写盘):这是默认路径,也是"卸载即干净"的前提。
|
|
4419
|
+
// 宿主没有 skill 注册表时返回 false,`/team` 会退回自举复制(行为与从前一致)。
|
|
4420
|
+
registerRuntimeSkill(ctx);
|
|
4421
|
+
// 设置注册到宿主命名空间:官方面板可改、随市场的备份/恢复走、改动即时生效(watch → 重算)。
|
|
4422
|
+
// `base` 用**当前生效设置**填充 ⇒ 面板一打开看到的就是真正在用的值,不需要任何一次性迁移。
|
|
4423
|
+
installHostSettings(ctx, {
|
|
4424
|
+
base: currentSettings(),
|
|
4425
|
+
onResolved: () => reapplySettingsDerived(),
|
|
4426
|
+
});
|
|
4196
4427
|
void loadSessionRuns(); // session→run memory for overlay auto-select
|
|
4197
4428
|
ctx.commands.register({
|
|
4198
4429
|
name: 'team',
|
|
@@ -4378,9 +4609,24 @@ export function apply(ctx, config) {
|
|
|
4378
4609
|
try {
|
|
4379
4610
|
ctx.inject(['webServer'], (wctx) => {
|
|
4380
4611
|
wctx.effect(() => {
|
|
4381
|
-
|
|
4612
|
+
/**
|
|
4613
|
+
* 注册一条**只服务本机同源**的路由。
|
|
4614
|
+
*
|
|
4615
|
+
* 为什么要有这层包装:这 11 条路由以前各自决定要不要校验来源,实际结果是**一条都没校验**
|
|
4616
|
+
* (只校验了 HTTP 方法)。把守卫放在唯一的注册入口上,"新加一条路由"就不可能忘记加固 ——
|
|
4617
|
+
* routes-shared.test.mjs 里还有一条棘轮盯着这里不许再出现裸的 register。
|
|
4618
|
+
*
|
|
4619
|
+
* methods 走行内字段:它决定守卫对哪些方法做 Origin / content-type 校验,且**不会**被传给
|
|
4620
|
+
* 宿主(宿主只认 kind/path/handler,多传字段可能被 schema 拒)。
|
|
4621
|
+
*/
|
|
4622
|
+
const registerLocal = (row = {}) => {
|
|
4623
|
+
const { methods = ['GET'], ...rest } = row;
|
|
4624
|
+
return wctx.webServer.register({ ...rest, handler: localOnly(rest.handler, { methods }) });
|
|
4625
|
+
};
|
|
4626
|
+
const dispose = registerLocal({
|
|
4382
4627
|
kind: 'exact',
|
|
4383
4628
|
path: '/plugins/dsh-expert-team/state',
|
|
4629
|
+
methods: ['GET'],
|
|
4384
4630
|
handler: async (req, res) => {
|
|
4385
4631
|
const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
|
|
4386
4632
|
try {
|
|
@@ -4642,9 +4888,10 @@ export function apply(ctx, config) {
|
|
|
4642
4888
|
|
|
4643
4889
|
// Artifact content route (client overlay right-side preview). Resolves
|
|
4644
4890
|
// cwd from sessionId (or cwd param) + run; serves only known artifacts.
|
|
4645
|
-
const disposeArtifact =
|
|
4891
|
+
const disposeArtifact = registerLocal({
|
|
4646
4892
|
kind: 'exact',
|
|
4647
4893
|
path: '/plugins/dsh-expert-team/artifact',
|
|
4894
|
+
methods: ['GET'],
|
|
4648
4895
|
// 试点路由:改走 `lib/routes/shared.js` 的 `withRoute`(消掉本条自己的
|
|
4649
4896
|
// json 助手 / 405 判断 / 500 兜底三份样板)。行为逐字保持:参数校验仍由本函数负责。
|
|
4650
4897
|
handler: withRoute(async ({ res, json, query }) => {
|
|
@@ -4662,9 +4909,10 @@ export function apply(ctx, config) {
|
|
|
4662
4909
|
|
|
4663
4910
|
// Code index search route (Qoder-style): instant keyword hits from
|
|
4664
4911
|
// team/CODEINDEX.json. Builds lazily if the index is missing.
|
|
4665
|
-
const disposeCodeidx =
|
|
4912
|
+
const disposeCodeidx = registerLocal({
|
|
4666
4913
|
kind: 'exact',
|
|
4667
4914
|
path: '/plugins/dsh-expert-team/codeidx',
|
|
4915
|
+
methods: ['GET'],
|
|
4668
4916
|
handler: async (req, res) => {
|
|
4669
4917
|
const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
|
|
4670
4918
|
try {
|
|
@@ -4684,9 +4932,10 @@ export function apply(ctx, config) {
|
|
|
4684
4932
|
|
|
4685
4933
|
// Read-only file preview route (A①: click a code-index hit → preview).
|
|
4686
4934
|
// Path must be a RELATIVE workspace path (no `..`, no absolute).
|
|
4687
|
-
const disposeFileView =
|
|
4935
|
+
const disposeFileView = registerLocal({
|
|
4688
4936
|
kind: 'exact',
|
|
4689
4937
|
path: '/plugins/dsh-expert-team/file',
|
|
4938
|
+
methods: ['GET'],
|
|
4690
4939
|
handler: async (req, res) => {
|
|
4691
4940
|
const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
|
|
4692
4941
|
try {
|
|
@@ -4695,8 +4944,23 @@ export function apply(ctx, config) {
|
|
|
4695
4944
|
const ws = (url.searchParams.get('workspace') ?? '').trim();
|
|
4696
4945
|
const rel = (url.searchParams.get('path') ?? '').trim();
|
|
4697
4946
|
if (!ws || !rel || rel.startsWith('/') || rel.includes('..') || !/^[\w@./-]+$/.test(rel)) { json(400, { ok: false, error: 'bad params' }); return; }
|
|
4698
|
-
const
|
|
4699
|
-
|
|
4947
|
+
const abs = join(ws, rel);
|
|
4948
|
+
// 有宿主 fs 就走宿主策略;**读被拒时如实报 403,绝不退回裸 readFile** ——
|
|
4949
|
+
// 退回等于"策略拒绝 + 我们绕开它",比压根不做策略校验更糟。
|
|
4950
|
+
if (ADOPTED_HOST_FS) {
|
|
4951
|
+
try {
|
|
4952
|
+
const resolved = await ADOPTED_HOST_FS.resolve(abs);
|
|
4953
|
+
const text = await ADOPTED_HOST_FS.readText(resolved);
|
|
4954
|
+
json(200, { ok: true, path: rel, text: String(text).slice(0, 120000), source: 'host-fs' });
|
|
4955
|
+
} catch (e) {
|
|
4956
|
+
const msg = String(e && e.message ? e.message : e);
|
|
4957
|
+
const notFound = (e && e.code === 'ENOENT') || /ENOENT|not found/i.test(msg);
|
|
4958
|
+
json(notFound ? 404 : 403, { ok: false, error: notFound ? 'not found' : 'read denied by host fs policy: ' + msg });
|
|
4959
|
+
}
|
|
4960
|
+
return;
|
|
4961
|
+
}
|
|
4962
|
+
const text = await readFile(abs, 'utf8');
|
|
4963
|
+
json(200, { ok: true, path: rel, text: text.slice(0, 120000), source: 'node-fs' });
|
|
4700
4964
|
} catch (e) {
|
|
4701
4965
|
json(404, { ok: false, error: String(e && e.code === 'ENOENT' ? 'not found' : (e && e.message ? e.message : e)) });
|
|
4702
4966
|
}
|
|
@@ -4707,9 +4971,10 @@ export function apply(ctx, config) {
|
|
|
4707
4971
|
// Human-in-loop decision route: the user makes a choice in the overlay
|
|
4708
4972
|
// (方案确认门 / 模糊选择 / quality-gate 升级). Records it into RUN.log +
|
|
4709
4973
|
// DECISIONS.md and clears STATE.pendingDecision so the lead can proceed.
|
|
4710
|
-
const disposeDecide =
|
|
4974
|
+
const disposeDecide = registerLocal({
|
|
4711
4975
|
kind: 'exact',
|
|
4712
4976
|
path: '/plugins/dsh-expert-team/decide',
|
|
4977
|
+
methods: ['POST'],
|
|
4713
4978
|
handler: async (req, res) => {
|
|
4714
4979
|
const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
|
|
4715
4980
|
try {
|
|
@@ -4854,9 +5119,10 @@ export function apply(ctx, config) {
|
|
|
4854
5119
|
const state = await readJsonSafe(join(dir, 'STATE.json'));
|
|
4855
5120
|
return state ? { dir, state, cwd, run } : null;
|
|
4856
5121
|
};
|
|
4857
|
-
const disposePlan =
|
|
5122
|
+
const disposePlan = registerLocal({
|
|
4858
5123
|
kind: 'exact',
|
|
4859
5124
|
path: '/plugins/dsh-expert-team/plan',
|
|
5125
|
+
methods: ['POST'],
|
|
4860
5126
|
handler: async (req, res) => {
|
|
4861
5127
|
const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
|
|
4862
5128
|
try {
|
|
@@ -4906,9 +5172,10 @@ export function apply(ctx, config) {
|
|
|
4906
5172
|
});
|
|
4907
5173
|
wctx.effect(() => disposePlan);
|
|
4908
5174
|
|
|
4909
|
-
const disposePlanApprove =
|
|
5175
|
+
const disposePlanApprove = registerLocal({
|
|
4910
5176
|
kind: 'exact',
|
|
4911
5177
|
path: '/plugins/dsh-expert-team/plan/approve',
|
|
5178
|
+
methods: ['POST'],
|
|
4912
5179
|
handler: async (req, res) => {
|
|
4913
5180
|
const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
|
|
4914
5181
|
try {
|
|
@@ -4969,9 +5236,10 @@ export function apply(ctx, config) {
|
|
|
4969
5236
|
});
|
|
4970
5237
|
wctx.effect(() => disposePlanApprove);
|
|
4971
5238
|
|
|
4972
|
-
const disposePlanDiscard =
|
|
5239
|
+
const disposePlanDiscard = registerLocal({
|
|
4973
5240
|
kind: 'exact',
|
|
4974
5241
|
path: '/plugins/dsh-expert-team/plan/discard',
|
|
5242
|
+
methods: ['POST'],
|
|
4975
5243
|
handler: async (req, res) => {
|
|
4976
5244
|
const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
|
|
4977
5245
|
try {
|
|
@@ -4998,9 +5266,10 @@ export function apply(ctx, config) {
|
|
|
4998
5266
|
|
|
4999
5267
|
// Overlay task operation: change one task's status (writes TASKS.json
|
|
5000
5268
|
// + RUN.log). Same trust level as decide.
|
|
5001
|
-
const disposeTaskOp =
|
|
5269
|
+
const disposeTaskOp = registerLocal({
|
|
5002
5270
|
kind: 'exact',
|
|
5003
5271
|
path: '/plugins/dsh-expert-team/task',
|
|
5272
|
+
methods: ['POST'],
|
|
5004
5273
|
handler: async (req, res) => {
|
|
5005
5274
|
const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
|
|
5006
5275
|
try {
|
|
@@ -5025,9 +5294,10 @@ export function apply(ctx, config) {
|
|
|
5025
5294
|
|
|
5026
5295
|
// ── GET /state 不再聚合(2026-09-13):显式刷新入口。浮层要刷新指标时用 **POST**,
|
|
5027
5296
|
// 不要用 GET —— 一个 GET 写盘既破坏幂等,又给 run 工件引入并发写面。
|
|
5028
|
-
const disposeMetrics =
|
|
5297
|
+
const disposeMetrics = registerLocal({
|
|
5029
5298
|
kind: 'exact',
|
|
5030
5299
|
path: '/plugins/dsh-expert-team/metrics/refresh',
|
|
5300
|
+
methods: ['POST'],
|
|
5031
5301
|
handler: async (req, res) => {
|
|
5032
5302
|
const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
|
|
5033
5303
|
try {
|
|
@@ -5051,9 +5321,10 @@ export function apply(ctx, config) {
|
|
|
5051
5321
|
// **诚实边界**:`roster` / `gates` 两类设置是在 `apply()` 时解析进内存的
|
|
5052
5322
|
// (容量上限、轮次上限、档位门),所以改完**要重启 `dsh web` 才生效**;
|
|
5053
5323
|
// `display` 类是浮层每次拉设置时读的,即时生效。响应里如实分开说,不假装"立刻全部生效"。
|
|
5054
|
-
const disposeSettings =
|
|
5324
|
+
const disposeSettings = registerLocal({
|
|
5055
5325
|
kind: 'exact',
|
|
5056
5326
|
path: '/plugins/dsh-expert-team/settings',
|
|
5327
|
+
methods: ['GET', 'POST'],
|
|
5057
5328
|
handler: withRoute(async ({ req, res, json, body }) => {
|
|
5058
5329
|
const method = String((req && req.method) || 'GET').toUpperCase();
|
|
5059
5330
|
if (method === 'GET') {
|
|
@@ -5068,24 +5339,37 @@ export function apply(ctx, config) {
|
|
|
5068
5339
|
json(400, { ok: false, errors: merged.errors, settings: merged.value });
|
|
5069
5340
|
return;
|
|
5070
5341
|
}
|
|
5342
|
+
// 宿主命名空间可用 ⇒ 可表达的字段写**宿主**(官方面板与浮层共用同一份真源,
|
|
5343
|
+
// 且随市场备份/恢复);宿主表达不了的字段(如 roster.defaultRoles)继续写 settings.json。
|
|
5344
|
+
// 两边都只写自己拥有的字段 —— 同一个字段在两个存储里各留一份,是这个仓最忌讳的形态。
|
|
5345
|
+
const viaHost = await updateHostSettings(patch);
|
|
5346
|
+
const fileOnly = pickFileOnly(patch);
|
|
5347
|
+
const writeFile = !viaHost.ok || Object.keys(fileOnly).length > 0;
|
|
5071
5348
|
try {
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5349
|
+
if (writeFile) {
|
|
5350
|
+
// `$DSH_HOME/expert-team/` 在全新环境里可能还不存在(受控写入口不会替你建目录)。
|
|
5351
|
+
await mkdir(dirname(settingsPath()), { recursive: true });
|
|
5352
|
+
const forFile = viaHost.ok ? pickFileOnly(merged.value) : merged.value;
|
|
5353
|
+
await ARTIFACT.must(settingsPath(), JSON.stringify(forFile, null, 2) + '\n');
|
|
5354
|
+
}
|
|
5075
5355
|
} catch (e) {
|
|
5076
5356
|
json(500, { ok: false, errors: [`设置写入失败:${String((e && e.message) || e)}`], settings: currentSettings() });
|
|
5077
5357
|
return;
|
|
5078
5358
|
}
|
|
5079
5359
|
const touched = new Set(Object.keys(patch && typeof patch === 'object' ? patch : {}).flatMap((k) => (k.includes('.') ? [k.split('.')[0]] : [k])));
|
|
5080
5360
|
SETTINGS_CACHE = { settings: merged.value, repaired: [], at: new Date().toISOString() };
|
|
5081
|
-
|
|
5361
|
+
if (!viaHost.ok) reapplySettingsDerived(); // 文件路径:与 1.1.x 一样在进程内重算
|
|
5362
|
+
const needsRestart = !viaHost.ok && (touched.has('roster') || touched.has('gates'));
|
|
5082
5363
|
json(200, {
|
|
5083
5364
|
ok: true,
|
|
5084
5365
|
settings: merged.value,
|
|
5085
5366
|
needsRestart,
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5367
|
+
settingsSource: viaHost.ok ? 'host' : 'file',
|
|
5368
|
+
note: viaHost.ok
|
|
5369
|
+
? `已保存到宿主设置(命名空间 ${HOST_SETTINGS_NAMESPACE}):官方面板与浮层共用同一份,且随插件市场的备份/恢复一起走;上限与档位门已在进程内重算,**无需重启**。`
|
|
5370
|
+
: needsRestart
|
|
5371
|
+
? '已保存。`编制` / `门禁` 两类设置在插件加载时解析 ⇒ **重启 dsh web 后生效**;`显示` 类即时生效。'
|
|
5372
|
+
: '已保存并即时生效。',
|
|
5089
5373
|
});
|
|
5090
5374
|
}, { methods: ['GET', 'POST'] }),
|
|
5091
5375
|
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// 宿主设置命名空间接线(1.2.0)
|
|
2
|
+
//
|
|
3
|
+
// ── 为什么要有它(三条具体收益,不是"架构更漂亮")────────────────────────────
|
|
4
|
+
// ① 设置出现在 **设置 → 插件 → 插件配置**:由宿主按我们给的 schema 渲染,插件不必自己画表单;
|
|
5
|
+
// ② 随插件市场的**备份与恢复**一起走 —— 市场只备份"profile 插件清单 + 设置",自造的
|
|
6
|
+
// `settings.json` 不在它的备份范围里;
|
|
7
|
+
// ③ 值改变能**立刻生效**:`scope.watch()` 一到,重算上限/轮次/档位门,不必重启。
|
|
8
|
+
//
|
|
9
|
+
// ── 为什么用**动态 import**,而不是像 dshmarket 那样静态 import schemastery ──────────
|
|
10
|
+
// dshmarket 那样写是对的:它的包只会在 profile 里被加载,`@deepseek-ai/schemastery`
|
|
11
|
+
// 在 profile 的上一级 node_modules 里解析得到。但**本仓的 76 个测试文件都从仓库路径**
|
|
12
|
+
// import `lib/command.js` —— 仓库不在任何 profile 里,静态 import 会直接
|
|
13
|
+
// ERR_MODULE_NOT_FOUND,把整套测试带崩(这正是"零依赖、CI 免 install"的代价)。
|
|
14
|
+
// 动态 import 失败 ⇒ 优雅回退到 settings.json,行为与 1.1.x 一致;成功 ⇒ 上一个微任务后注册完成。
|
|
15
|
+
//
|
|
16
|
+
// ── 优先级(未变):config > env > **设置** > 内置默认 ──────────────────────────
|
|
17
|
+
// 宿主可用时,"设置"这一档 = 宿主命名空间的解析值,其构成为
|
|
18
|
+
// `schema 默认 < 我们传入的 base < 用户在官方面板的覆盖`;base 用**当前生效设置**填充,
|
|
19
|
+
// 所以面板一打开看到的就是真正在用的值(不需要任何一次性迁移)。
|
|
20
|
+
// 宿主不可用(或 schemastery 解析不到)时,"设置"这一档 = `$DSH_HOME/expert-team/settings.json`。
|
|
21
|
+
|
|
22
|
+
import { SETTINGS_GROUPS, SETTINGS_SPEC, flatSpec } from './settings.js';
|
|
23
|
+
|
|
24
|
+
/** 注册到宿主的命名空间(宿主对命名空间有文法要求:小写字母/数字/短横线)。 */
|
|
25
|
+
export const HOST_SETTINGS_NAMESPACE = 'expert-team';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 能上官方面板的类型。
|
|
29
|
+
*
|
|
30
|
+
* `roles` 刻意**不上**:它的取值是"角色 id 数组或 null",在 schemastery 里要表达成
|
|
31
|
+
* `union([const(null), array(string)])`,跨版本行为没把握;一旦 schema 建错,宿主会拒绝注册
|
|
32
|
+
* 甚至让整条插件挂不上。这个字段继续由浮层的设置页签 + settings.json 负责,
|
|
33
|
+
* 官方页面只显示其余三类(bool / int / enum)。少一个开关,好过"注册失败、面板里什么都不出现"。
|
|
34
|
+
*/
|
|
35
|
+
const HOST_SCHEMA_TYPES = new Set(['bool', 'int', 'enum']);
|
|
36
|
+
|
|
37
|
+
/** 宿主 schema 覆盖的扁平路径(`identity.profile` 这种)。 */
|
|
38
|
+
export function hostSchemaPaths() {
|
|
39
|
+
return Object.entries(flatSpec())
|
|
40
|
+
.filter(([, item]) => HOST_SCHEMA_TYPES.has(item.type))
|
|
41
|
+
.map(([path]) => path);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 由我们的 `SETTINGS_SPEC` 生成嵌套的 schemastery schema。
|
|
46
|
+
*
|
|
47
|
+
* 单一真源:字段、取值范围、默认值全部来自 `lib/settings.js` 的 spec —— 不在这里抄第二份。
|
|
48
|
+
* 代价是必须**容忍 schemastery 的 API 差异**:只有确实存在的方法才调用(`.min/.max/.default`)。
|
|
49
|
+
*
|
|
50
|
+
* @param z - schemastery 的默认导出(由调用方动态 import 得到)。
|
|
51
|
+
*/
|
|
52
|
+
export function buildHostSchema(z) {
|
|
53
|
+
const shape = {};
|
|
54
|
+
for (const group of SETTINGS_GROUPS) {
|
|
55
|
+
const inner = {};
|
|
56
|
+
for (const [key, item] of Object.entries(SETTINGS_SPEC[group].items)) {
|
|
57
|
+
if (!HOST_SCHEMA_TYPES.has(item.type)) continue;
|
|
58
|
+
let field;
|
|
59
|
+
if (item.type === 'bool') field = z.boolean();
|
|
60
|
+
else if (item.type === 'int') {
|
|
61
|
+
field = z.number();
|
|
62
|
+
if (item.min != null && typeof field.min === 'function') field = field.min(item.min);
|
|
63
|
+
if (item.max != null && typeof field.max === 'function') field = field.max(item.max);
|
|
64
|
+
} else field = z.union(item.values.map((v) => z.const(v)));
|
|
65
|
+
if (typeof field.default === 'function') field = field.default(item.default);
|
|
66
|
+
inner[key] = field;
|
|
67
|
+
}
|
|
68
|
+
if (Object.keys(inner).length) shape[group] = z.object(inner);
|
|
69
|
+
}
|
|
70
|
+
return z.object(shape);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 从"当前生效设置"里挑出宿主能表达的那些(用作 `register(..., { base })`)。 */
|
|
74
|
+
export function hostBase(settings) {
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const path of hostSchemaPaths()) {
|
|
77
|
+
const [group, key] = path.split('.');
|
|
78
|
+
const value = settings && settings[group] ? settings[group][key] : undefined;
|
|
79
|
+
if (value === undefined || value === null) continue;
|
|
80
|
+
out[group] = out[group] || {};
|
|
81
|
+
out[group][key] = value;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 把补丁裁成"宿主能表达"的嵌套对象。
|
|
88
|
+
* 补丁同时支持嵌套(`{roster:{maxTasks:10}}`)与扁平(`{'roster.maxTasks':10}`),与
|
|
89
|
+
* `mergeSettings` 同口径 —— 否则同一份补丁在两处会有两种解释。
|
|
90
|
+
*/
|
|
91
|
+
export function pickHostExpressible(patch) {
|
|
92
|
+
const flat = {};
|
|
93
|
+
const put = (path, value) => {
|
|
94
|
+
const item = flatSpec()[path];
|
|
95
|
+
if (!item || !HOST_SCHEMA_TYPES.has(item.type)) return;
|
|
96
|
+
flat[path] = value;
|
|
97
|
+
};
|
|
98
|
+
if (patch && typeof patch === 'object' && !Array.isArray(patch)) {
|
|
99
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
100
|
+
if (k.includes('.')) { put(k, v); continue; }
|
|
101
|
+
if (!SETTINGS_SPEC[k] || !v || typeof v !== 'object' || Array.isArray(v)) continue;
|
|
102
|
+
for (const [k2, v2] of Object.entries(v)) put(`${k}.${k2}`, v2);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const out = {};
|
|
106
|
+
for (const [path, value] of Object.entries(flat)) {
|
|
107
|
+
const [group, key] = path.split('.');
|
|
108
|
+
out[group] = out[group] || {};
|
|
109
|
+
out[group][key] = value;
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 补丁里**宿主表达不了**的那部分(继续由 settings.json 负责)。 */
|
|
115
|
+
export function pickFileOnly(patch) {
|
|
116
|
+
const expressible = pickHostExpressible(patch);
|
|
117
|
+
const out = {};
|
|
118
|
+
const put = (path, value) => {
|
|
119
|
+
const item = flatSpec()[path];
|
|
120
|
+
if (!item || HOST_SCHEMA_TYPES.has(item.type)) return;
|
|
121
|
+
const [group, key] = path.split('.');
|
|
122
|
+
out[group] = out[group] || {};
|
|
123
|
+
out[group][key] = value;
|
|
124
|
+
};
|
|
125
|
+
if (patch && typeof patch === 'object' && !Array.isArray(patch)) {
|
|
126
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
127
|
+
if (k.includes('.')) { put(k, v); continue; }
|
|
128
|
+
if (!SETTINGS_SPEC[k] || !v || typeof v !== 'object' || Array.isArray(v)) continue;
|
|
129
|
+
for (const [k2, v2] of Object.entries(v)) put(`${k}.${k2}`, v2);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
void expressible;
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── 运行时状态(进程内单例;插件卸载时清空)──────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/** 已注册的宿主作用域;null = 宿主没有 settings 服务(或还没注册完)。 */
|
|
139
|
+
let HOST_SCOPE = null;
|
|
140
|
+
/** 最近一次从宿主读到的解析值。 */
|
|
141
|
+
let HOST_VALUES = null;
|
|
142
|
+
/** 注册失败的原因(供 `/team status` 与诊断如实显示)。 */
|
|
143
|
+
let HOST_NOTE = '';
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 把设置注册进宿主命名空间。**同步返回**,注册本身在下一次微任务完成(动态 import)。
|
|
147
|
+
*
|
|
148
|
+
* @param ctx - 插件上下文(用 `ctx.inject(['settings'], …)` 作为优雅降级边界:宿主没有该服务时回调不执行)
|
|
149
|
+
* @param options.base - 当前生效设置(决定面板初始值)
|
|
150
|
+
* @param options.onResolved - 拿到(或更新)宿主解析值时的回调;用于重算上限/档位
|
|
151
|
+
* @param options.loadSchema - **仅供测试**注入 schema 加载器(默认动态 import schemastery)。
|
|
152
|
+
* 仓库里解析不到 schemastery,没有这个接缝就只能测到"回退"那一半,成功路径永远没被测过。
|
|
153
|
+
* @returns 是否成功挂上了 inject(不代表注册已完成)
|
|
154
|
+
*/
|
|
155
|
+
export function installHostSettings(ctx, { base, onResolved, loadSchema } = {}) {
|
|
156
|
+
if (!ctx || typeof ctx.inject !== 'function') return false;
|
|
157
|
+
const load = typeof loadSchema === 'function' ? loadSchema : () => import('@deepseek-ai/schemastery');
|
|
158
|
+
try {
|
|
159
|
+
ctx.inject(['settings'], (scoped) => {
|
|
160
|
+
const service = scoped && scoped.settings;
|
|
161
|
+
if (!service || typeof service.register !== 'function') return;
|
|
162
|
+
load().then((mod) => {
|
|
163
|
+
const z = (mod && mod.default) ? mod.default : mod;
|
|
164
|
+
const scope = service.register(HOST_SETTINGS_NAMESPACE, buildHostSchema(z), { base: hostBase(base) });
|
|
165
|
+
HOST_SCOPE = scope;
|
|
166
|
+
const push = () => {
|
|
167
|
+
try {
|
|
168
|
+
HOST_VALUES = typeof scope.get === 'function' ? scope.get() : null;
|
|
169
|
+
if (onResolved) onResolved(HOST_VALUES);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
HOST_NOTE = '读取宿主设置失败:' + String(e && e.message ? e.message : e);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
try { if (typeof scope.watch === 'function') scope.watch(() => push()); } catch { /* watch 缺失不该影响注册 */ }
|
|
175
|
+
push();
|
|
176
|
+
if (typeof scoped.effect === 'function') {
|
|
177
|
+
scoped.effect(() => () => { HOST_SCOPE = null; HOST_VALUES = null; });
|
|
178
|
+
}
|
|
179
|
+
console.log(`[expert-team] 设置已注册到宿主命名空间「${HOST_SETTINGS_NAMESPACE}」(官方面板可改,且随市场备份/恢复)`);
|
|
180
|
+
}).catch((e) => {
|
|
181
|
+
HOST_NOTE = 'schemastery 不可解析,保持 settings.json:' + String(e && e.message ? e.message : e);
|
|
182
|
+
console.warn('[expert-team] ' + HOST_NOTE);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
return true;
|
|
186
|
+
} catch (e) {
|
|
187
|
+
HOST_NOTE = 'inject(settings) 失败:' + String(e && e.message ? e.message : e);
|
|
188
|
+
console.warn('[expert-team] ' + HOST_NOTE);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 宿主作用域是否已就绪。 */
|
|
194
|
+
export function hostScope() { return HOST_SCOPE; }
|
|
195
|
+
/** 最近一次宿主解析值(null = 未就绪)。 */
|
|
196
|
+
export function hostValues() { return HOST_VALUES; }
|
|
197
|
+
/** 诊断文案(为什么没用上宿主设置)。 */
|
|
198
|
+
export function hostSettingsNote() { return HOST_NOTE; }
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 把补丁写进宿主命名空间(只写它表达得了的那部分)。
|
|
202
|
+
* @returns `{ ok: true, written: 命中字段数 }`,或 `{ ok: false, reason }`
|
|
203
|
+
*/
|
|
204
|
+
export async function updateHostSettings(patch) {
|
|
205
|
+
if (!HOST_SCOPE || typeof HOST_SCOPE.update !== 'function') return { ok: false, reason: 'no-host-scope' };
|
|
206
|
+
const expressible = pickHostExpressible(patch);
|
|
207
|
+
const groups = Object.keys(expressible);
|
|
208
|
+
const count = groups.reduce((n, g) => n + Object.keys(expressible[g]).length, 0);
|
|
209
|
+
if (count === 0) return { ok: false, reason: 'nothing-expressible' };
|
|
210
|
+
try {
|
|
211
|
+
await HOST_SCOPE.update(expressible);
|
|
212
|
+
return { ok: true, written: count };
|
|
213
|
+
} catch (e) {
|
|
214
|
+
return { ok: false, reason: 'update-failed', error: String(e && e.message ? e.message : e) };
|
|
215
|
+
}
|
|
216
|
+
}
|
package/lib/routes/shared.js
CHANGED
|
@@ -81,3 +81,137 @@ export function withRoute(handler, { methods = ['GET'] } = {}) {
|
|
|
81
81
|
}
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
// ── 本机来源守卫(安全加固 · 2026-09-14)────────────────────────────────────
|
|
86
|
+
//
|
|
87
|
+
// 为什么要有它:这 11 条路由由 dsh 的 webServer 暴露在本机 HTTP 端口上,此前**只校验
|
|
88
|
+
// HTTP 方法**。于是有三条真实可达的攻击路径:
|
|
89
|
+
// ① **DNS rebinding**:恶意页面把自己的域名解析到 127.0.0.1,浏览器就把它当同源 ⇒
|
|
90
|
+
// 可读 `/state`、`/file`(工作区任意文本)、并写入 `/task`、`/plan`、`/settings`。
|
|
91
|
+
// ② **跨站简单请求(CSRF)**:跨站 `fetch` 带 `text/plain`、或 `<form>` 提交,都不触发
|
|
92
|
+
// CORS 预检 ⇒ 写操作照常执行(响应读不到,但**副作用已经发生**)。
|
|
93
|
+
// ③ **非回环暴露**:宿主配置 `host: 0.0.0.0` 时,同网段任何机器都能打这些接口。
|
|
94
|
+
//
|
|
95
|
+
// 判定规则(每条都对应上面一条攻击路径):
|
|
96
|
+
// · `Host` 头存在且主机名不是回环 ⇒ 403。**这是挡 DNS rebinding 的那一条**:浏览器一定会带
|
|
97
|
+
// Host,而攻击页带的 Host 是它自己的域名。
|
|
98
|
+
// · `Origin` 头存在且不是回环来源 ⇒ **只在变更方法上** 403。GET 放行是有意的:浏览器不会把
|
|
99
|
+
// 没有 CORS 头的响应体交给跨站页面(我们从不发 `Access-Control-Allow-Origin`),所以跨站
|
|
100
|
+
// GET 读不到任何东西;而放行它才能保住 `/team canvas --watch` 生成的本地 HTML 文件——
|
|
101
|
+
// 那个页面以 `file://` 打开,发请求时带的是 `Origin: null`。
|
|
102
|
+
// · `req.socket.remoteAddress` 存在且不是回环 ⇒ 403(挡局域网)。
|
|
103
|
+
// · 变更方法上 `content-type` 存在且不是 JSON ⇒ 415。浏览器的 form 与"简单请求"只能发
|
|
104
|
+
// urlencoded / multipart / text-plain,JSON 必须走预检,而我们不回应预检。
|
|
105
|
+
//
|
|
106
|
+
// **三条规则都只在"头存在"时生效** —— 这是刻意的,不是宽松:
|
|
107
|
+
// · 浏览器(也就是这三条攻击路径里唯一的攻击载体)**必然**带上 Host,POST 必然带 Origin;
|
|
108
|
+
// · 本地非浏览器客户端本来就能自造任意头,任何头校验都拦不住它 —— 这类客户端需要的是
|
|
109
|
+
// 鉴权,而 dsh 插件没有鉴权模型(市场的同源检查也只是回环+同源)。这一点如实写进 README。
|
|
110
|
+
// · 副作用:单元测试里的假 req 没有 headers/socket,因此不受影响(不必为测试开口子)。
|
|
111
|
+
//
|
|
112
|
+
// 事实来源:`dshmarket` 对 install/restart 这类端点就是这么做的("只接受同源 POST"、
|
|
113
|
+
// "重启要求客户端直接来自环回地址、拒绝代理转发请求")。
|
|
114
|
+
|
|
115
|
+
/** 回环主机名(Host 头 / Origin 里的主机名都按这个判)。 */
|
|
116
|
+
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
117
|
+
|
|
118
|
+
/** `Host: 127.0.0.1:3080` → `127.0.0.1`;`[::1]:3080` → `[::1]`;解析不出返回 ''。 */
|
|
119
|
+
export function hostnameOf(hostHeader) {
|
|
120
|
+
const raw = String(hostHeader == null ? '' : hostHeader).trim().toLowerCase();
|
|
121
|
+
if (!raw) return '';
|
|
122
|
+
if (raw.startsWith('[')) { // IPv6 字面量:[::1]:3080
|
|
123
|
+
const end = raw.indexOf(']');
|
|
124
|
+
return end === -1 ? raw : raw.slice(0, end + 1);
|
|
125
|
+
}
|
|
126
|
+
const colon = raw.lastIndexOf(':');
|
|
127
|
+
return colon === -1 ? raw : raw.slice(0, colon);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Host 头是否是回环地址。 */
|
|
131
|
+
export function isLoopbackHost(hostHeader) {
|
|
132
|
+
const h = hostnameOf(hostHeader);
|
|
133
|
+
return h !== '' && LOOPBACK_HOSTNAMES.has(h);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 来源(Origin)是否可信:`http://127.0.0.1:3080`、`http://localhost:3080`、`https://[::1]` 可信;
|
|
138
|
+
* `null`(file:// 页面)与一切外站**不可信**。
|
|
139
|
+
*/
|
|
140
|
+
export function isLoopbackOrigin(origin) {
|
|
141
|
+
const raw = String(origin == null ? '' : origin).trim();
|
|
142
|
+
if (!raw || raw.toLowerCase() === 'null') return false;
|
|
143
|
+
try {
|
|
144
|
+
const u = new URL(raw);
|
|
145
|
+
return LOOPBACK_HOSTNAMES.has(u.hostname.toLowerCase());
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** socket 地址是否是回环(`127.x.x.x`、`::1`、IPv4-mapped `::ffff:127.x.x.x`)。 */
|
|
152
|
+
export function isLoopbackAddress(addr) {
|
|
153
|
+
const a = String(addr == null ? '' : addr).trim().toLowerCase();
|
|
154
|
+
if (!a) return false;
|
|
155
|
+
if (a === '::1') return true;
|
|
156
|
+
const v4 = a.startsWith('::ffff:') ? a.slice(7) : a;
|
|
157
|
+
return /^127\./.test(v4);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 本机来源守卫:返回 `null` 表示放行,否则返回 `{ code, error }`(由调用方写响应)。
|
|
162
|
+
*
|
|
163
|
+
* @param req - 请求对象(`headers` / `socket.remoteAddress` 缺失时按"非浏览器客户端"处理)
|
|
164
|
+
* @param options.methods - 该路由允许的方法;**变更方法**才做 Origin 与 content-type 校验
|
|
165
|
+
*/
|
|
166
|
+
export function guardLocalRequest(req, { methods = ['GET'] } = {}) {
|
|
167
|
+
const headers = (req && req.headers) || null;
|
|
168
|
+
const method = String((req && req.method) || 'GET').toUpperCase();
|
|
169
|
+
const mutating = !['GET', 'HEAD', 'OPTIONS'].includes(method);
|
|
170
|
+
|
|
171
|
+
if (headers) {
|
|
172
|
+
const host = headers.host ?? headers[':authority'];
|
|
173
|
+
if (host != null && String(host).trim() !== '' && !isLoopbackHost(host)) {
|
|
174
|
+
return { code: 403, error: 'forbidden: non-loopback Host' };
|
|
175
|
+
}
|
|
176
|
+
const origin = headers.origin;
|
|
177
|
+
if (mutating && origin != null && String(origin).trim() !== '' && !isLoopbackOrigin(origin)) {
|
|
178
|
+
return { code: 403, error: 'forbidden: cross-origin write' };
|
|
179
|
+
}
|
|
180
|
+
if (mutating) {
|
|
181
|
+
const ct = headers['content-type'];
|
|
182
|
+
if (ct != null && String(ct).trim() !== '' && !/^application\/json\b/i.test(String(ct).trim())) {
|
|
183
|
+
return { code: 415, error: 'unsupported media type: expected application/json' };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const addr = req && req.socket ? req.socket.remoteAddress : null;
|
|
189
|
+
if (addr != null && String(addr).trim() !== '' && !isLoopbackAddress(addr)) {
|
|
190
|
+
return { code: 403, error: 'forbidden: non-loopback client' };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 方法校验放在守卫里一起做:路由注册处只包一层就不会漏(见 routes-guard 棘轮测试)。
|
|
194
|
+
const allow = new Set(methods.map((m) => String(m).toUpperCase()));
|
|
195
|
+
if (!allow.has(method)) return { code: 405, error: '' };
|
|
196
|
+
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 给任意处理器套上"只服务本机同源"的守卫。**注册每一路由时都要用它包一层** ——
|
|
202
|
+
* `routes-shared.test.mjs` 有一条棘轮断言:`lib/command.js` 里不允许出现裸的 `handler:`。
|
|
203
|
+
*
|
|
204
|
+
* @param handler - `(req, res) => Promise<void> | void`
|
|
205
|
+
* @param options.methods - 允许的方法(默认 `['GET']`)
|
|
206
|
+
*/
|
|
207
|
+
export function localOnly(handler, { methods = ['GET'] } = {}) {
|
|
208
|
+
return async function guarded(req, res) {
|
|
209
|
+
const denied = guardLocalRequest(req, { methods });
|
|
210
|
+
if (denied) {
|
|
211
|
+
if (denied.code === 405) { res.writeHead(405); res.end(); return; }
|
|
212
|
+
json(res, denied.code, { ok: false, error: denied.error });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
return handler(req, res);
|
|
216
|
+
};
|
|
217
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yangdcm/dsh-expert-team",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"check:name": "node scripts/rename-package.mjs --check",
|
|
84
84
|
"test:regression": "node regression.test.mjs",
|
|
85
85
|
"test:e2e": "node e2e.test.mjs",
|
|
86
|
-
"test:all": "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",
|
|
86
|
+
"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
87
|
"test:toolcard": "node tool-card-status.test.mjs",
|
|
88
88
|
"test:flow": "node flow.test.mjs",
|
|
89
89
|
"test:decide": "node plan-decide.test.mjs",
|
|
@@ -133,6 +133,8 @@
|
|
|
133
133
|
"test:wait": "node wait-run.test.mjs",
|
|
134
134
|
"test:loop": "node rework-loop.test.mjs",
|
|
135
135
|
"test:tokens": "node token-accounting.test.mjs",
|
|
136
|
-
"test:toolface": "node lead-toolface.test.mjs"
|
|
136
|
+
"test:toolface": "node lead-toolface.test.mjs",
|
|
137
|
+
"test:bootstrap": "node bootstrap.test.mjs",
|
|
138
|
+
"test:host-settings": "node host-settings.test.mjs"
|
|
137
139
|
}
|
|
138
140
|
}
|