@yangdcm/dsh-expert-team 1.3.14 → 1.3.16
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 +103 -0
- package/README.en.md +15 -8
- package/README.md +11 -7
- package/client.js +110 -0
- package/lib/artifact-ownership.js +198 -0
- package/lib/artifact-redirect-watch.js +118 -0
- package/lib/command.js +318 -34
- package/lib/effort-preflight.js +135 -0
- package/lib/interception.js +4 -3
- package/package.json +2 -2
- package/skills/expert-team/SKILL.md +1 -1
- package/skills/expert-team/references/WORKSPACE.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,37 @@
|
|
|
3
3
|
本包遵循[语义化版本](https://semver.org/lang/zh-CN/)。dsh 宿主版本线的对应关系写在
|
|
4
4
|
`package.json` 的 `engines.dsh` 与 `dsh.compatibility` 里,插件市场按它判断"这个插件跟你的宿主兼不兼容"。
|
|
5
5
|
|
|
6
|
+
## 1.3.15
|
|
7
|
+
|
|
8
|
+
**R1 从"协议约定"升级为 `write`/`edit` 通道的硬门禁**(写盘之前拦)+ 三处文档口径改准。
|
|
9
|
+
|
|
10
|
+
- **门禁语义:创建放行、覆写才拦。** 判据只需一次存在性查询、**不读内容**,因此可以挂在宿主
|
|
11
|
+
`tools/pre-execute`(**写盘之前**,`{kind:"deny"}` 会短路在 dispatch 之前 ⇒ 真拦得住),
|
|
12
|
+
与既有的 `post-execute` 内容级校验(台账/规格边界)分工并存、互不冲突。
|
|
13
|
+
为什么必须"创建放行":`SKILL.md` §3 要求**首个成员**一次性把 13 份骨架落到 `team/<run-id>/`,
|
|
14
|
+
其中大多数**不属于它** —— 天真的"角色 ≠ 负责人就拦"会**直接打死建 run**。
|
|
15
|
+
- **唯一机读真源**:`lib/artifact-ownership.js` 的 `ARTIFACT_OWNERS`("哪份工件归谁"只有这一份)。
|
|
16
|
+
表格从两张真源推导(`SKILL.md` 的角色/产出表 + `WORKSPACE.md` 的文件/维护者表),
|
|
17
|
+
口径是**宁可漏拦、不可误伤**:真源有歧义/多负责人就取宽松(`PLAN.md` = pm/architect/dba),
|
|
18
|
+
`STATE.json`/`ROSTER.json` **显式为空数组**(运行时专属 ⇒ 角色一律不得覆写),
|
|
19
|
+
"lead 口述 + 指派成员落盘"的那几份(`TASK.md`/`任务看板.md`/`SUMMARY.md`/`RUN.log.md`/`RETRO.md`)**不限制**。
|
|
20
|
+
- **fail-open 的每一种理由都有断言守着**:角色认不出、存在性查不到、目标不在 run 目录、非 `write`/`edit`、
|
|
21
|
+
终态 run、门禁自身抛错 —— 一律放行,且**降级必留痕**(`ownership-gate-degraded`)。
|
|
22
|
+
真实事故教训(`post-execute` 签名写错曾让**全工具瘫痪**)在这里被写成断言:门禁**绝不允许**成为工具故障源。
|
|
23
|
+
- ⚠️ **诚实边界**:只覆盖 `write`/`edit` 通道。preset 里持有 `bash` 的角色(backend/frontend/researcher/
|
|
24
|
+
qa/dba/devops)理论上可用重定向绕过;**首版刻意不对 bash 参数做启发式检查**(易误伤)。
|
|
25
|
+
对外表述应为"normal 通道有门",不是"不可能违反"。
|
|
26
|
+
- **测试**:新增 `r1-ownership-gate.test.mjs`(判定矩阵 + 接线断言 + "所有权表只有一份"的单源棘轮);
|
|
27
|
+
`artifact-ownership.test.mjs` 扩到 8 条,新增 **F/G 双向一致性**(表→真源防发明拼错;真源→表防漏项);
|
|
28
|
+
变异目录 **+M138**(表失效 ⇒ 门禁静默失效 ⇒ 三处断言必红)。
|
|
29
|
+
- **文档改准**:
|
|
30
|
+
① `WORKSPACE.md` 的 `STATE.json.members` 示例原写作 `"backend:<subagentId>"`(**与代码相反**)
|
|
31
|
+
⇒ 改为 `<agentSessionId>:backend` 并标注真源(`ROSTER.json.members` 的对象形状是另一回事,未动);
|
|
32
|
+
② `SKILL.md` 的 R1 段补"机读真源"与上述诚实边界;
|
|
33
|
+
③ **更正两版 README 里一处不成立的强声明** —— 原文写"`mutation-catalog` 要求每个变异体都至少被一个测试杀掉",
|
|
34
|
+
而 CI 实际只校验目录**形状**(id 唯一 / `find` 恰好命中一次 / 目标测试存在 / 条数一致),**变异体需手动注入**;
|
|
35
|
+
同时把两处数字改准(测试文件 **84**、变异 **138**,中英与 `llms.txt` 一致)。
|
|
36
|
+
|
|
6
37
|
## 1.3.14
|
|
7
38
|
|
|
8
39
|
**性能收尾:同一时刻至多一条重活 + profile 补账**(性能修复 #4)
|
|
@@ -700,3 +731,75 @@ DAG 并行扇出、角色 chip、工件脚注全部保留。
|
|
|
700
731
|
- `/team` 命令面:一次性组队 / 持久化活团队 / 仅工件 / 先确认后开工 / 流程档位 / 画布 / 代码索引 /
|
|
701
732
|
自学习 / 配额 / 冷启动清算
|
|
702
733
|
- 零运行时依赖、无构建步骤、无安装钩子
|
|
734
|
+
|
|
735
|
+
## 1.3.16
|
|
736
|
+
|
|
737
|
+
**三件收尾:`/state` 重活的最大一段(`subs`)、冷启动、以及 R1 的"bash 绕过"坦白。**
|
|
738
|
+
|
|
739
|
+
### A. `subs` 2,398 ms —— 根因不是缓存不够,而是"永远查不完"
|
|
740
|
+
|
|
741
|
+
- **真机 profile 定位**:重活 `?section=people,feed` 单发 3,295 ms = `subs` **2,398** + `roles` 874 + 其余 ~2。
|
|
742
|
+
- **根因(真 bug)**:`STATE.members → membersFromState().byRole` 这个 Map **同时**存了
|
|
743
|
+
`role→agentId` 与 `agentId→role` 两种键,而调用点直接取 `.values()` ⇒ **一半是角色名**
|
|
744
|
+
(`backend` / `reviewer`)。角色名永远不可能是 session header 的 id ⇒ `missingIds` **永久非空**
|
|
745
|
+
⇒ **每个请求都重新枚举 475 个 artifact**,`SUB_HEADER_MEMO`(1.3.11 加的按 id 备忘)因此形同虚设。
|
|
746
|
+
- **修法**:新增 `memberAgentIds()`(只取真 id)并在调用点使用;`isAgentIdLike()` **按角色名精确排除**
|
|
747
|
+
(等于角色 id,或首段是角色 id 的 `frontend-F4`/`reviewer-R1` 这类带后缀标签),**不按长度猜**
|
|
748
|
+
—— 长度阈值会误伤短 id(`ended-x`/`live-1`),那才是真丢数据。函数内**再兜一道过滤**,
|
|
749
|
+
防止未来调用方又把角色名传进来。顺带修掉 `buildRoleSubMap` 把 id 当角色的同一处根因。
|
|
750
|
+
- **效果**(进程内、可复现):同一份数据第二次请求 `listSessions` **调用 0 次**(原为每次 1 次)。
|
|
751
|
+
|
|
752
|
+
### B. 冷启动 1,313 ms → 打**逐 run 戳缓存**(含落盘)
|
|
753
|
+
|
|
754
|
+
- **真机 profile 定位**:重启后第一次 `?section=summary` = 1,313 ms,其中 `runs+select` **876 ms**
|
|
755
|
+
(每个 run 都要读 `STATE.json` + `TASKS.json` 再算 health/violations/owner);随后 267 ms → 17 ms。
|
|
756
|
+
- **修法**:按**每个 run 自己**的 `STATE.json`/`TASKS.json` 的 `(mtimeMs, size)` 作失效键缓存"列表行",
|
|
757
|
+
索引**落盘**到 `$DSH_HOME/expert-team/runs-index.json`(可用 `DSH_EXPERT_TEAM_RUNS_INDEX` 覆盖位置)
|
|
758
|
+
⇒ **重启后第一次**也只是 stat 校验 + 命中,不必从零算。
|
|
759
|
+
- **为什么不能只戳 run 目录**:改文件**不会**改父目录 mtime(只有增删条目会)⇒ 那样会读到旧阶段/旧计数。
|
|
760
|
+
戳到文件本身才是"看到的就是真的"。
|
|
761
|
+
- **实测**(进程内、`/tmp` 索引):第一次算 5 个 run 并落盘 2,558 B;**模拟重启后第一次 1 ms、命中 5、零重算**;
|
|
762
|
+
只改一个 run 的 `STATE.json` ⇒ **恰好重算那 1 个**;新增 run ⇒ **立即可见**。
|
|
763
|
+
- **顺带修**:`team/` 根下的**普通文件**(`CODEINDEX.json` / `LEARNINGS.md`…)过去被当作 run 读
|
|
764
|
+
`STATE.json` ⇒ 面板 run 下拉里出现一串假的 "broken run"(画布上真能看到)。现在**只列目录**。
|
|
765
|
+
|
|
766
|
+
### C. R1 的 `bash` 绕过:**只报不拦**(刻意不阻断)
|
|
767
|
+
|
|
768
|
+
- R1 硬门禁只覆盖 `write`/`edit`;持 `bash` 的 backend/frontend/researcher/qa/dba/devops 理论上可
|
|
769
|
+
`cat > SPEC.md` 绕过。**静默绕过**违背本仓纪律,但静态判断 bash 写目标不可靠(重定向/变量/子命令)
|
|
770
|
+
⇒ 新增 `lib/artifact-redirect-watch.js`:挂在 `tools/post-execute`,**只在**"命令里明显写向
|
|
771
|
+
`<team 根>/<runId>/<已知工件>`"时**留痕一行 + onEvent**。
|
|
772
|
+
- **绝不**阻断、**绝不**改结果、**绝不**抛错(沿用"监听器不得成为故障源"的纪律,有源码级禁令断言);
|
|
773
|
+
含变量/`/dev/null`/工作区代码/更深路径一律**不命中**(宁可漏报,不可误伤)。
|
|
774
|
+
- 已知工件名来自**两处既有真源的并集**(`ARTIFACT_TEMPLATES` ∪ `ARTIFACT_OWNERS`)—— 同时把模板清单
|
|
775
|
+
提升为模块级单一真源 `ARTIFACT_TEMPLATES`,`authority` / `artifact-ownership` 两组断言改为**读这份真源**
|
|
776
|
+
(原先按源码字面量解析,重构后会"失去判据对象"—— 那比断言失败更危险,它看起来像通过)。
|
|
777
|
+
- **验收样例**:7 个命中形态(`cat > SPEC.md`、`>>`、`tee`、`tee -a`、带引号绝对路径、`2>`)
|
|
778
|
+
+ 9 个不命中形态(工作区代码、`/tmp`、无写目标、工作区根的 `SPEC.md`、非工件名、含变量、
|
|
779
|
+
`/dev/null`、只读命令、更深路径)全部符合预期(`artifact-redirect-watch.test.mjs`)。
|
|
780
|
+
|
|
781
|
+
### D. 会话模型 effort 预检(**只告警,不阻断**)—— 一次真实故障的定性
|
|
782
|
+
|
|
783
|
+
- **故障现象**:`model "deepseek-flash" does not support reasoning effort "low"`。**不是插件的错,
|
|
784
|
+
也不是宿主缺 `low`** —— 是用户 `~/.dsh/settings.yaml` 里会话默认路由(命名空间 `agent-default-model`)
|
|
785
|
+
的模型条目**漏写 `reasoningEfforts`** ⇒ 宿主能力表里该模型只剩 `off` ⇒ **任何**显式 effort 都被拒
|
|
786
|
+
(`dsh-llm` 的 `resolveCallWithInfo`:`reasoning === undefined` 时只要传了 `reasoningEffort` 就抛
|
|
787
|
+
`UNSUPPORTED_REASONING_EFFORT`)。而本 preset **8 个角色声明 `high`、4 个声明 `low`** ⇒ 该路由下
|
|
788
|
+
**12 个角色全会失败**;"只有 low 报错"是假象(先派谁先报谁)。宿主在**任何网络 I/O 之前**就拒。
|
|
789
|
+
- **插件能做什么 / 不能做什么(如实写)**:派工由宿主 `tool-subagent` + LLM 运行时执行,插件**无法**
|
|
790
|
+
在派工前改变宿主行为;能做的是**提前一行告警** + 给出修法。因此本项**只告警、不阻断、不改 preset 的
|
|
791
|
+
effort 分档**(那是设计意图)。
|
|
792
|
+
- **实现**:新增 `lib/effort-preflight.js`(纯函数判定 + 有界重探接线):读 preset 声明的 effort(真源,
|
|
793
|
+
按行正则,不引 YAML 解析器)→ 读宿主公开入口 `agentDefaultModel.currentSelection()` 与
|
|
794
|
+
`llm.resolveModelInfo(provider, model)` → 覆盖不全就**打一行**(含"改哪个命名空间/字段/值域")。
|
|
795
|
+
**读不到/抛错一律静默**(fail-open);一次加载最多一行;服务晚挂则 `ctx.inject` 事件驱动重探。
|
|
796
|
+
- **`scripts/validate-agent-preset.mjs`** 补**值域**校验(`reasoningEffort ∈ off/low/high/max`)+ 结尾指路
|
|
797
|
+
(脚本**不读用户机器**,只校验 preset 侧)。
|
|
798
|
+
- **文档**:README 中英 FAQ 各补一条(自然语言问句,便于检索)+ `llms.txt` 一行故障排查指针。
|
|
799
|
+
- **测试**:`effort-preflight.test.mjs`(真源分档 8/4 / 判定矩阵 / 只报一次且可操作 / fail-open 静默 / 只告警不阻断);
|
|
800
|
+
变异体 `M140-effort-preflight-blind`(缺档也不报 ⇒ 预检变睁眼瞎)**已实测**能杀死测试(5 条断言失败)。
|
|
801
|
+
### 其它
|
|
802
|
+
|
|
803
|
+
- 新增变异体 `M139-artifact-redirect-watch-blind`(候选提取恒空 ⇒ 观测器变睁眼瞎),
|
|
804
|
+
catalog 139 条;**已实测**注入后该测试 **10 条断言失败**、还原后逐字节恢复。
|
|
805
|
+
- 测试文件 **85 个**;`npm run test:all` EXIT=0。
|
package/README.en.md
CHANGED
|
@@ -16,9 +16,9 @@ English | [中文](README.md)
|
|
|
16
16
|
A plugin for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh):
|
|
17
17
|
**zero runtime dependencies, no build step, no install hooks.**
|
|
18
18
|
|
|
19
|
-
| 12 roles | 9 phases |
|
|
19
|
+
| 12 roles | 9 phases | 84 test files | 0 runtime deps | 0 build steps |
|
|
20
20
|
|---|---|---|---|---|
|
|
21
|
-
| own persona / `toolFilter` / `maxDepth: 1` | 1 hard gate + 1 approval gate | incl. a
|
|
21
|
+
| own persona / `toolFilter` / `maxDepth: 1` | 1 hard gate + 1 approval gate | incl. a 138-entry mutation catalog and several ratchets | `dependencies: {}` | no bundler, no `prepare` hook |
|
|
22
22
|
|
|
23
23
|
> Zero runtime dependencies. Recommended: also install **Hindsight** (cross-project memory) — see [Dependencies and recommended plugins](#dependencies-and-recommended-plugins).
|
|
24
24
|
|
|
@@ -111,7 +111,7 @@ answer them:
|
|
|
111
111
|
|---|---|
|
|
112
112
|
| **Role separation** | 12 roles, each with its own persona, tool boundary (`toolFilter`) and delegation depth (`maxDepth: 1`); product/architecture roles only read and write planning artifacts, review/security are read-only, only implementers touch code |
|
|
113
113
|
| **Phase gates** | 9 phases; every hand-off travels two channels — a structured return value **and** an artifact file. State never rides on chat history |
|
|
114
|
-
| **Quality gates** | State-machine consistency is **enforced by plugin code**, not requested in a prompt: a task cannot be marked completed while unfinished, quality issues must be adjudicated by qa/reviewer, coverage gaps and over-budget rework are caught — violations show up **live** in the overlay and in `/team status` |
|
|
114
|
+
| **Quality gates** | State-machine consistency is **enforced by plugin code**, not requested in a prompt: a task cannot be marked completed while unfinished, quality issues must be adjudicated by qa/reviewer, coverage gaps and over-budget rework are caught — violations show up **live** in the overlay and in `/team status`; **write-side ownership gate**: overwriting an artifact owned by another role is **rejected outright** on the `write`/`edit` channel (mounted on `tools/pre-execute`; creating is allowed; a role holding `bash` could still bypass — see the honest boundary in `lib/artifact-ownership.js`) |
|
|
115
115
|
| **Convergence & accounting** | Every run records tokens, elapsed time, time-to-first-artifact and a closing budget; `/team learn` distills cross-run experience and feeds it back before the next run starts |
|
|
116
116
|
|
|
117
117
|
## What it looks like in action
|
|
@@ -147,6 +147,8 @@ already passed, and the actual body of the artifact written in that phase (artif
|
|
|
147
147
|
**saved on change and applied immediately** (caps, rounds, the tier gate and the oscillation detector are recomputed
|
|
148
148
|
in-process). Values live in the host namespace `expert-team`, so they travel with the plugin market's backup/restore.</sub>
|
|
149
149
|
|
|
150
|
+
A persistent status bar also sits directly above the chat input box (client slot `conversation.input.dock`, id `expert-team-subagents`, order 200): while at least one subagent is running it shows an amber banner ("N subagents running") with up to three role names and a pulsing dot, and clicking it opens the team panel; when none are running it shows a single dim gray line ("No subagents running"), and it renders nothing at all before a session or status is available (the same predicate as the header badge: `agents[].activity === 'running'` from `/state`).
|
|
151
|
+
|
|
150
152
|
## Why it is dependable
|
|
151
153
|
|
|
152
154
|
- **The state machine is enforced by plugin code, not requested by prompt.** `lib/interception.js` moves the
|
|
@@ -156,9 +158,12 @@ in-process). Values live in the host namespace `expert-team`, so they travel wit
|
|
|
156
158
|
that is the number-one source of rework.**
|
|
157
159
|
- **Zero runtime dependencies, zero devDependencies, no build step, no `prepare`/`postinstall` hooks.**
|
|
158
160
|
What you install is exactly what runs; there is no "unknown script at install time" layer.
|
|
159
|
-
- **
|
|
160
|
-
the
|
|
161
|
-
|
|
161
|
+
- **84 test files plus a 138-entry mutation catalog.** `npm run test:all` needs no `install` (it is what CI runs).
|
|
162
|
+
⚠️ **What the catalog actually guarantees (honest version)**: in CI, `mutation-catalog.test.mjs` validates the catalog SHAPE —
|
|
163
|
+
unique ids, each mutant `find` string matching exactly once in its target file, the target test file existing, and the entry count
|
|
164
|
+
matching the constant. **Mutants themselves must be injected by hand** (swap `find` for `replace` and run the target test to see if it
|
|
165
|
+
turns red); **CI does not execute mutants today**. So it is a guard against drift and typos, not an automatic proof that the suite
|
|
166
|
+
catches errors — please do not read it as the latter.
|
|
162
167
|
- **Several ratchet tests** pin down rules that were already thought through, so they cannot quietly regress:
|
|
163
168
|
`vocab-consistency` (one source for vocabulary and role labels), `scan-single-source` (no fact with two homes),
|
|
164
169
|
`write-bypass-ratchet` (no write path may bypass interception), `settings-consumers`
|
|
@@ -336,7 +341,9 @@ Full command list (`/team codeindex` code index, `/team limit` quotas, `/team se
|
|
|
336
341
|
|
|
337
342
|
**Does it work without the "Expert team mode" preset?** Yes. `/team` is a host-plane command and runs under any preset; it then falls back to the generic `subagent` (role personas go into the prompt), losing only the configuration-level boundaries (`toolFilter` / `maxDepth: 1`).
|
|
338
343
|
|
|
339
|
-
**Why is the overlay/canvas slow to open?** See [Troubleshooting](#troubleshooting)
|
|
344
|
+
**Why is the overlay/canvas slow to open?** See [Troubleshooting](#troubleshooting)
|
|
345
|
+
|
|
346
|
+
**A role subagent fails with `does not support reasoning effort`?** That is the **session route's model** not declaring `reasoningEfforts` — **not this plugin**: the host compares the requested effort against the model's published efforts **before any network I/O** and rejects a mismatch. Fix: add `reasoningEfforts` (`off/low/high/max`) to that model's entry under `agent-default-model` in `~/.dsh/settings.yaml`, or switch the session to an official route. **Note:** this preset declares **`high` for 8 roles and `low` for 4** — with none declared, **every** role that carries an effort is rejected ("only `low` fails" is an illusion: whoever is dispatched first reports first). The plugin runs a **one-line preflight warning** at load (warn only, never blocks). — since 1.3.5 `/state` no longer reads every sub-session log in full; upgrade to ≥ 1.3.5 and restart `dsh web`.
|
|
340
347
|
|
|
341
348
|
## Glossary
|
|
342
349
|
|
|
@@ -386,7 +393,7 @@ evolve with the skill, without shipping a new package.
|
|
|
386
393
|
## Development
|
|
387
394
|
|
|
388
395
|
```sh
|
|
389
|
-
npm run test:all #
|
|
396
|
+
npm run test:all # 84 test files, zero dependencies, no install needed (this is what CI runs)
|
|
390
397
|
npm run rename <name> # after forking: syncs 4 package-name spellings across 13 files
|
|
391
398
|
npm run check:name # check for leftover placeholder package names
|
|
392
399
|
```
|
package/README.md
CHANGED
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
|
|
15
15
|
装在 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 上的 dsh 插件:**零运行时依赖、无构建步骤、无安装钩子**。
|
|
16
16
|
|
|
17
|
-
| 12 角色 | 9 阶段 |
|
|
17
|
+
| 12 角色 | 9 阶段 | 84 个测试文件 | 0 运行时依赖 | 0 构建步骤 |
|
|
18
18
|
|---|---|---|---|---|
|
|
19
|
-
| 各带人设 / `toolFilter` / `maxDepth: 1` | 含 1 道硬门 + 1 道确认门 | 含
|
|
19
|
+
| 各带人设 / `toolFilter` / `maxDepth: 1` | 含 1 道硬门 + 1 道确认门 | 含 138 条变异目录与多组棘轮 | `dependencies: {}` | 无 bundler、无 `prepare` 钩子 |
|
|
20
20
|
|
|
21
21
|
> 零运行时依赖。推荐同时装 **Hindsight**(跨项目记忆)—— 见[依赖与推荐插件](#依赖与推荐插件--dependencies-and-recommended-plugins)。
|
|
22
22
|
|
|
@@ -102,7 +102,7 @@ $ /team 做一个带登录的支付模块
|
|
|
102
102
|
|---|---|
|
|
103
103
|
| **角色分工** | 12 个角色各带独立人设、工具边界(`toolFilter`)、委派深度(`maxDepth: 1`);产品/架构只读写计划工件,审查/安全只读,实现者才动代码 |
|
|
104
104
|
| **阶段门控** | 9 个阶段,每次交接走「结构化返回值 + 工件文件」双通道 —— 状态不靠聊天记录传递 |
|
|
105
|
-
| **质量门禁** | 状态机一致性由**插件代码强制**(不是提示词请求):任务未完成不能标 completed、质量问题必须由 qa/reviewer 裁决、覆盖率缺口、超轮次返工 —— 违规**实时**显示在浮层并计入 `/team status` |
|
|
105
|
+
| **质量门禁** | 状态机一致性由**插件代码强制**(不是提示词请求):任务未完成不能标 completed、质量问题必须由 qa/reviewer 裁决、覆盖率缺口、超轮次返工 —— 违规**实时**显示在浮层并计入 `/team status`;**写侧归属门禁**:非负责人覆写他人工件在 `write`/`edit` 通道**当场拒绝**(挂在 `tools/pre-execute`,创建放行;持 `bash` 的角色仍可能绕过,见 `lib/artifact-ownership.js` 的诚实边界) |
|
|
106
106
|
| **收敛与记账** | 每 run 记 token/耗时/首产物时间/收尾预算;`/team learn` 跨 run 蒸馏经验,并在下次开工前回注 |
|
|
107
107
|
|
|
108
108
|
## 看一眼它在干什么
|
|
@@ -131,6 +131,8 @@ $ /team 做一个带登录的支付模块
|
|
|
131
131
|
|
|
132
132
|
<sub>图 7:**设置**。看官方 `设置 →「专家团」` 这一页 —— 18 个设置项、中文标签、**改动即保存并即时生效**(上限/轮次/档位门/振荡检测在进程内重算);值存在宿主命名空间 `expert-team`,随插件市场的备份/恢复一起走。</sub>
|
|
133
133
|
|
|
134
|
+
**输入框正上方还有一条常驻状态条**(client 槽 `conversation.input.dock`,id `expert-team-subagents`,order 200)—— 有子代理在跑时是琥珀色横幅「N 个子代理运行中」+ 最多 3 个角色名 + 一个跳动圆点,点击它直接打开团队面板;没有在跑时只剩一行暗灰字「无子代理在运行」,会话或状态尚未就绪时则完全不渲染(判据与页头徽章同一条:`/state` 的 `agents[].activity === 'running'`)。
|
|
135
|
+
|
|
134
136
|
## 它为什么可靠
|
|
135
137
|
|
|
136
138
|
- **状态机由插件代码强制,不由提示词请求。** `lib/interception.js` 把「台账契约」与「规格边界」两条规则搬到宿主的
|
|
@@ -138,8 +140,8 @@ $ /team 做一个带登录的支付模块
|
|
|
138
140
|
(`SPEC_COMPLETE_PHASES`)。**规格沉默等于允许,那正是头号返工源。**
|
|
139
141
|
- **零运行时依赖、零 devDependencies、无构建步骤、无 `prepare`/`postinstall` 钩子。** 装完就是能跑的那份代码,
|
|
140
142
|
没有"安装时执行未知脚本"这一层。
|
|
141
|
-
- **
|
|
142
|
-
|
|
143
|
+
- **84 个测试文件 + 138 条变异目录。** `npm run test:all` 无需 `install` 即可跑(CI 跑的就是它)。
|
|
144
|
+
⚠️ **变异目录的实际保障范围(如实说)**:`mutation-catalog.test.mjs` 在 CI 里校验的是目录**形状** —— id 唯一、每个变异体的 `find` 串在目标文件里**恰好命中一次**、目标测试文件存在、条数与常量一致;**变异体本身需要手动注入**(把 `find` 换成 `replace` 再跑对应测试,看它是否变红),**CI 目前不执行变异体**。所以它是"防呆 + 防漂移",不是"自动证明测试能抓错" —— 别把它读成后者。
|
|
143
145
|
- **多组棘轮(ratchet)测试**,把"已经想清楚的规矩"钉住,防止悄悄退化:
|
|
144
146
|
`vocab-consistency`(术语与角色标签单一真源)、`scan-single-source`(同一事实不许有两个家)、
|
|
145
147
|
`write-bypass-ratchet`(写侧不许绕过拦截)、`settings-consumers`(**每个设置项都必须有消费者**,白名单集合相等 ⇒ 只减不增)、
|
|
@@ -295,7 +297,9 @@ dsh plugin --profile web add dshmarket
|
|
|
295
297
|
|
|
296
298
|
**不切「专家团模式」preset 也能用吗?** 能。`/team` 是 host 平面命令,任何预设下都能跑;此时退回通用 `subagent`(角色人设写进 prompt),少的是配置层的边界保证(`toolFilter` / `maxDepth: 1`)。
|
|
297
299
|
|
|
298
|
-
**浮层/画布打开很慢?** 见[排障](#排障)
|
|
300
|
+
**浮层/画布打开很慢?** 见[排障](#排障)
|
|
301
|
+
|
|
302
|
+
**角色子代理起不来、报 `does not support reasoning effort`?** 这是**会话路由的模型没声明** `reasoningEfforts` 造成的,**不是本插件的问题** —— 宿主在**任何网络 I/O 之前**就把"请求的 effort"与"该模型公布的 efforts"比对,不匹配即拒。修法:在 `~/.dsh/settings.yaml` 的 `agent-default-model` 条目里给该模型补 `reasoningEfforts`(`off/low/high/max`),或把会话切到官方路由。**注意**:本 preset 里 **8 个角色声明 `high`、4 个声明 `low`** ⇒ 漏声明时**任何**带 effort 的角色都会被拒("只有 low 失败"是假象:先派谁先报谁)。插件加载时会**预检并告警一行**(只告警、不阻断)。 —— 1.3.5 起 `/state` 不再逐条全量读子会话日志;升级到 ≥ 1.3.5 后重启 `dsh web` 即可。
|
|
299
303
|
|
|
300
304
|
## 术语 / Glossary
|
|
301
305
|
|
|
@@ -344,7 +348,7 @@ presets/expert-team/ 「专家团模式」preset:12 个角色 subagent 工
|
|
|
344
348
|
## 开发
|
|
345
349
|
|
|
346
350
|
```sh
|
|
347
|
-
npm run test:all #
|
|
351
|
+
npm run test:all # 84 个测试文件,零依赖、无需 install(CI 跑的就是它)
|
|
348
352
|
npm run rename <新包名> # fork 后改名:自动同步 13 个文件里 4 种包名写法
|
|
349
353
|
npm run check:name # 检查占位包名残留
|
|
350
354
|
```
|
package/client.js
CHANGED
|
@@ -254,6 +254,20 @@ window.__ModuleLoader__.load({
|
|
|
254
254
|
'.exp-decbar-ic{font-size:18px;flex:none}' +
|
|
255
255
|
'.exp-decbar-t{font-weight:700;font-size:13px;color:var(--dsw-alias-state-business-primary,#0969da)}' +
|
|
256
256
|
'.exp-decbar-p{font-size:12px;color:var(--dsw-alias-label-secondary,#57606a);margin-top:2px}' +
|
|
257
|
+
// ── 子代理运行状态条(常驻在输入框正上方;conversation.input.dock)──
|
|
258
|
+
// 运行中 = 醒目横幅 + 呼吸点(可点击打开团队面板);无人在跑 = 一行灰字(几乎不占位)。
|
|
259
|
+
// 为什么必须有它:原先只有会话页头一个小徽章 + 右侧面板,输入框附近没有任何指示,
|
|
260
|
+
// 用户无法一眼判断"现在到底有没有子代理在跑"。本条的运行态判据与页头徽章**同源**
|
|
261
|
+
// (都取 /state 的 agents[].activity === 'running'),两条指示不会互相矛盾。
|
|
262
|
+
// 宽度:dock 条目渲染在输入框根容器的左右内边距**之外**,必须自己让出 `--dsh-composer-side-clearance` 并 `margin:0 auto` 居中;宽度上限还必须再取卡片自己的上限 `--dsh-composer-card-max-width`(否则在宽窗口下会无视卡片上限、左右各鼓出一段 —— 真机实测过 card 846 vs bar 948)。绝不能写 width:100%。 +
|
|
263
|
+
'.exp-subbusy{display:flex;align-items:center;gap:8px;box-sizing:border-box;flex:none;width:auto;min-width:0;max-width:min(calc(100% - var(--dsh-composer-side-clearance) - var(--dsh-composer-side-clearance)), var(--dsh-composer-card-max-width));margin:0 auto 4px;padding:1px 12px;border:1px solid #f0c36d;border-left:3px solid #e0a83c;border-radius:10px;background:linear-gradient(180deg,rgba(224,168,60,.13),var(--dsw-alias-bg-layer-1,#fff));color:#8a6100;font-size:12px;line-height:1.4}' +
|
|
264
|
+
'.exp-subbusy.on{cursor:pointer}' +
|
|
265
|
+
'.exp-subbusy.on:hover{border-color:#d99b1f}' +
|
|
266
|
+
'.exp-subbusy-dot{width:8px;height:8px;border-radius:50%;background:#e0a83c;flex:none;animation:exp-pulse 1.4s ease-in-out infinite}' +
|
|
267
|
+
'.exp-subbusy-t{font-weight:700}' +
|
|
268
|
+
'.exp-subbusy-who{color:#6a4a00;opacity:.9;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
269
|
+
'.exp-subbusy-go{margin-left:auto;flex:none;font-size:11.5px;font-weight:700;color:#8a6100;opacity:.75}' +
|
|
270
|
+
'.exp-subbusy-idle{box-sizing:border-box;flex:none;width:auto;min-width:0;max-width:min(calc(100% - var(--dsh-composer-side-clearance) - var(--dsh-composer-side-clearance)), var(--dsh-composer-card-max-width));margin:0 auto;padding:1px 12px 4px;font-size:11px;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary,#8b949e))}' +
|
|
257
271
|
// ── 任务人员流转(波次带)样式:来自 UI.md §4.2(.etv-*,主题变量 + 深色覆写,零依赖)──
|
|
258
272
|
'/* ── 任务人员流转:竖直时间轴 + 波次带 ── */\n.etv-root{display:block;position:relative;padding:2px 0 4px}\n.etv-note{font-size:11px;color:var(--dsw-alias-label-secondary);margin:2px 0 8px;display:flex;align-items:center;gap:6px}\n.etv-wide-btn{margin-left:auto;font-size:10.5px;padding:1px 8px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:inherit;cursor:pointer}\n.etv-wave{position:relative;margin:0 0 var(--etv-wave-gap);border-radius:0 8px 8px 0}\n.etv-wave.cur{background:rgba(65,118,230,.055);border-left:2px solid var(--dsw-alias-state-business-primary)}\n.etv-wave-h{display:flex;align-items:center;gap:6px;height:22px;padding-left:var(--etv-indent);font-size:11.5px;font-weight:600;cursor:default}\n.etv-wave.collapsed .etv-wave-h{cursor:pointer}\n.etv-wave-no{display:inline-flex;align-items:center;justify-content:center;height:16px;min-width:16px;padding:0 5px;border-radius:8px;font-size:10px;font-weight:700;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}\n.etv-wave.cur .etv-wave-no{background:var(--dsw-alias-state-business-primary);color:#fff}\n.etv-time{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}\n.etv-delta{font-size:10px;color:var(--dsw-alias-label-tertiary)}\n.etv-wave-n{font-size:10.5px;color:var(--dsw-alias-label-tertiary)}\n.etv-wave-sum{margin-left:auto;font-size:10.5px;color:var(--dsw-alias-label-tertiary);max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.etv-cards{display:flex;flex-wrap:wrap;gap:var(--etv-gap);padding:6px 0 2px var(--etv-indent)}\n.etv-scroll{overflow-x:auto;padding-bottom:4px;scrollbar-width:thin}\n.etv-scroll .etv-cards{flex-wrap:nowrap}\n.etv-card{flex:1 1 var(--etv-card-min);min-width:0;max-width:100%;box-sizing:border-box;border:1.4px solid var(--etv-idle);border-radius:9px;background:var(--dsw-alias-bg-layer-1);padding:6px 8px 6px 7px;min-height:56px;cursor:pointer;position:relative;transition:border-color .12s,background .12s}\n.etv-card:hover{border-color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-interactive-bg-hover)}\n.etv-card.sel{background:var(--dsw-alias-state-business-tertiary);box-shadow:0 0 0 1px var(--dsw-alias-state-business-primary)}\n.etv-card.run{border-color:var(--etv-run);border-width:2px;background:rgba(59,110,245,.08)}\n.etv-card.done{border-color:var(--etv-ok);background:rgba(34,176,125,.10)}\n.etv-card.rework{border-color:var(--etv-rework);border-style:dashed;background:rgba(217,119,6,.10)}\n.etv-card.failed{border-color:var(--etv-fail);background:rgba(192,57,43,.09)}\n.etv-card.more{align-items:center;justify-content:center;display:flex;border-style:dashed;color:var(--dsw-alias-label-secondary);font-size:11.5px}\n.etv-card.tiny{min-height:40px}\n.etv-card.tiny .etv-task{display:none}\n.etv-card-h{display:flex;align-items:center;gap:5px;min-width:0}\n.etv-card .exp-ava{width:18px;height:18px;font-size:10px;margin-right:0;flex:none}\n.etv-role{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto;min-width:0}\n.etv-who{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto;min-width:0}\n.etv-task{margin-top:3px;font-size:11px;line-height:1.35;color:var(--dsw-alias-label-primary);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}\n.etv-card-f{display:flex;align-items:center;gap:6px;margin-top:5px;height:16px}\n.etv-dot{width:8px;height:8px;border-radius:50%;flex:none;display:inline-block}\n.etv-dot.run{animation:exp-pulse 1.4s ease-in-out infinite}\n.etv-dot.hollow{background:transparent!important;border:1.5px solid var(--etv-idle)}\n.etv-dur{margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:10px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}\n.etv-rework-tag{position:absolute;top:-7px;left:6px;font-size:9.5px;line-height:14px;padding:0 5px;border-radius:7px;background:var(--dsw-alias-state-warn-tertiary,#fff7ea);border:1px solid #f3c9a0;color:#a34a00}\n.etv-rail{position:absolute;left:0;top:0;bottom:0;width:16px;pointer-events:none}\n.etv-rail-i{position:absolute;left:7px;top:0;bottom:0;width:2px;background:var(--etv-line)}\n.etv-rail-n{position:absolute;left:3px;width:10px;height:10px;border-radius:50%;border:2px solid var(--dsw-alias-bg-layer-1);box-sizing:content-box}\n.etv-handoff{stroke:#8aa8e8;stroke-width:1.4;fill:none}\n.etv-rework-link{stroke:var(--etv-rework);stroke-width:1.5;stroke-dasharray:4 3;fill:none}\n.etv-para{font-size:10px;color:var(--dsw-alias-label-tertiary);margin-left:2px}\n.etv-group{border:1px dashed var(--dsw-alias-border-l2);border-radius:9px;padding:6px 8px;margin:0 0 var(--etv-gap) var(--etv-indent);background:var(--dsw-alias-bg-module-platform)}\n/* ── 深色主题覆写(零依赖:prefers-color-scheme 兜底;若 shell 在根节点暴露主题标记,优先用它) ── */\n@media (prefers-color-scheme:dark){\n .exp-panel{--etv-run:#679efe;--etv-ok:#4ed17e;--etv-rework:#f7ad31;--etv-fail:#ff7b7b;--etv-idle:#adb2b8;\n --etv-text-ok:#4ed17e;--etv-text-run:#679efe;--etv-text-rework:#f7ad31}\n .etv-handoff{stroke:#679efe}\n .etv-rework-tag{background:#3a2c14;border-color:#7a5a22;color:#f7ad31}\n .etv-degrade.warn{background:rgba(247,173,49,.12);color:#f7ad31}\n .etv-degrade.info{color:#9dc0ff}\n .etv-wave.cur{background:rgba(103,158,254,.10)}\n}\n/* ── 无障碍:减少动效 ── */\n@media (prefers-reduced-motion:reduce){.etv-dot.run,.etv-skel{animation:none!important}}'
|
|
259
273
|
|
|
@@ -2497,6 +2511,38 @@ window.__ModuleLoader__.load({
|
|
|
2497
2511
|
var m = (d && d.members) || {}
|
|
2498
2512
|
return Object.keys(m).some(function (k) { var v = m[k]; return v && typeof v === 'object' && (v.activity === 'running' || v.shortStatus === 'running') })
|
|
2499
2513
|
}
|
|
2514
|
+
// ── 子代理运行状态条的**唯一**判据(纯函数,无 React、无 DOM:便于单测直接断言)──────
|
|
2515
|
+
// 为什么用 /state 的 `agents[]` 而不是别的:它由 host 的 `subagents.listChildren` 产出,
|
|
2516
|
+
// `activity === 'running'` 正是页头徽章用的同一个判据(两种指示同源,不会互相打架)。
|
|
2517
|
+
// 两种"零"必须分清:`agents` 缺键/非数组(老 host、ok:false、?section=summary)是"未知",
|
|
2518
|
+
// 只有拿到数组且里面没有 running 才是"确实没有人在跑"。未知时显示占位符,不谎报"无人在跑"。
|
|
2519
|
+
/** 从一份 /state 负载里取出 activity === 'running' 的子代理行(非数组/缺键 ⇒ 空数组)。 */
|
|
2520
|
+
function runningAgents(d) {
|
|
2521
|
+
var arr = (d && Array.isArray(d.agents)) ? d.agents : []
|
|
2522
|
+
return arr.filter(function (a) { return a && String(a.activity || '') === 'running' })
|
|
2523
|
+
}
|
|
2524
|
+
/**
|
|
2525
|
+
* 状态条视图模型(纯函数)。返回值:
|
|
2526
|
+
* null → 不渲染(没有会话 / 还没拿到负载 / 拿不到 agents 块)
|
|
2527
|
+
* { kind: 'idle', text } → 一行灰字:确实没有子代理在跑
|
|
2528
|
+
* { kind: 'busy', n, text, names } → 运行中横幅
|
|
2529
|
+
* 角色名取不到就回落到 id 前 8 位(如实显示"这是谁",不编造角色名)。
|
|
2530
|
+
*/
|
|
2531
|
+
function subagentBarModel(d, hasSession) {
|
|
2532
|
+
if (!hasSession) return null
|
|
2533
|
+
if (!d || d.ok !== true || !Array.isArray(d.agents)) return null
|
|
2534
|
+
var run = runningAgents(d)
|
|
2535
|
+
if (!run.length) return { kind: 'idle', n: 0, text: t('无子代理在运行', 'No subagents running'), names: [] }
|
|
2536
|
+
var names = run.slice(0, 3).map(function (a) {
|
|
2537
|
+
var nm = roleLabel(a.role)
|
|
2538
|
+
return nm || String(a.id || '').slice(0, 8)
|
|
2539
|
+
})
|
|
2540
|
+
var more = run.length > 3 ? ' +' + (run.length - 3) : ''
|
|
2541
|
+
return {
|
|
2542
|
+
kind: 'busy', n: run.length, names: names,
|
|
2543
|
+
text: t(run.length + ' 个子代理运行中', run.length + ' subagent(s) running') + more
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2500
2546
|
// ── 面板/画布 → 徽章的**发布**路径(性能收尾批次)────────────────────────────
|
|
2501
2547
|
// 为什么需要:徽章订阅的是 `people,feed`(重分节)。若它一直自己轮询,而当前标签是「料」
|
|
2502
2548
|
// (重分节 = `artifacts`),同一时刻就有**两条重活并发** —— 真机上那正是单发从 2.4 s 变
|
|
@@ -2555,6 +2601,59 @@ window.__ModuleLoader__.load({
|
|
|
2555
2601
|
return { text: '🧑💼 ' + done + '/' + tasks.length, title: phaseLabel(live.phase || '') + '/' + statusLabel(live.status || '') }
|
|
2556
2602
|
}
|
|
2557
2603
|
|
|
2604
|
+
/**
|
|
2605
|
+
* 当前会话 id。优先用宿主 `uiSession` 适配器的 current 绑定(贴会话作用域真源);
|
|
2606
|
+
* 拿不到(宿主 API 形状变了)就回落到 HeaderButton 维护的会话 store —— 两种都拿不到
|
|
2607
|
+
* 就返回 null ⇒ 状态条不渲染(宁可不显示,也不显示"别人的会话"的子代理数)。
|
|
2608
|
+
*/
|
|
2609
|
+
function useCurrentSessionId() {
|
|
2610
|
+
var sid = useCurrentSession()
|
|
2611
|
+
var st = useState(function () {
|
|
2612
|
+
try { var u = ctxUISession; var snap = (u && u.current && u.current.getSnapshot) ? u.current.getSnapshot() : null; return (snap && snap.value && snap.value.key) ? snap.value.key : null } catch (e) { return null }
|
|
2613
|
+
})
|
|
2614
|
+
var hostSid = st[0], setHostSid = st[1]
|
|
2615
|
+
useEffect(function () {
|
|
2616
|
+
var u = ctxUISession
|
|
2617
|
+
if (!u || !u.current || typeof u.current.subscribe !== 'function') return undefined
|
|
2618
|
+
function pull() {
|
|
2619
|
+
try { var s = u.current.getSnapshot(); setHostSid((s && s.value && s.value.key) ? s.value.key : null) } catch (e) { setHostSid(null) }
|
|
2620
|
+
}
|
|
2621
|
+
pull()
|
|
2622
|
+
return u.current.subscribe(pull)
|
|
2623
|
+
}, [])
|
|
2624
|
+
return hostSid || sid || null
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
/**
|
|
2628
|
+
* 「子代理运行中」状态条(注册进 conversation.input.dock ⇒ 输入框正上方)。
|
|
2629
|
+
* 常驻:运行中给醒目横幅,无人运行给一行灰字(用户要求"能一眼区分有没有在跑")。
|
|
2630
|
+
*/
|
|
2631
|
+
function SubagentBar(props) {
|
|
2632
|
+
var sid = useCurrentSessionId()
|
|
2633
|
+
var barSessionId = (props && props.sessionId) || sid
|
|
2634
|
+
var live = useLiveState(barSessionId)
|
|
2635
|
+
ensureCss()
|
|
2636
|
+
var m = subagentBarModel(live, !!barSessionId)
|
|
2637
|
+
if (!m) return null
|
|
2638
|
+
if (m.kind === 'idle') return h('div', { className: 'exp-subbusy-idle' }, esc(m.text))
|
|
2639
|
+
function openPanel() {
|
|
2640
|
+
try {
|
|
2641
|
+
if (!open) { dockState = true; saveLS('et-dock', true); notify(); setOpen() }
|
|
2642
|
+
} catch (e) {}
|
|
2643
|
+
focusPanelRight()
|
|
2644
|
+
}
|
|
2645
|
+
return h('div', {
|
|
2646
|
+
className: 'exp-subbusy on',
|
|
2647
|
+
role: 'status',
|
|
2648
|
+
title: t(m.n + ' 个子代理正在运行(本会话)· 点击打开团队面板', m.n + ' subagent(s) running in this session · click to open the team panel'),
|
|
2649
|
+
onClick: openPanel
|
|
2650
|
+
},
|
|
2651
|
+
h('span', { className: 'exp-subbusy-dot' }),
|
|
2652
|
+
h('span', { className: 'exp-subbusy-t' }, esc(m.text)),
|
|
2653
|
+
m.names.length ? h('span', { className: 'exp-subbusy-who' }, esc(m.names.join(' · '))) : null,
|
|
2654
|
+
h('span', { className: 'exp-subbusy-go' }, esc(t('打开面板 ›', 'open panel ›'))))
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2558
2657
|
function HeaderButton(props) {
|
|
2559
2658
|
var sid = props && props.sessionId
|
|
2560
2659
|
useEffect(function () { if (sid) setCurrentSession(sid) }, [sid])
|
|
@@ -2682,9 +2781,13 @@ window.__ModuleLoader__.load({
|
|
|
2682
2781
|
// Test hook (same convention as the host half's `_live`): lets the regression
|
|
2683
2782
|
// suite assert text safety without booting the overlay in a browser.
|
|
2684
2783
|
exports._live = { esc: esc, tierBadge: tierBadge, TIER_LABELS_ZH: TIER_LABELS_ZH, settingsFormModel: settingsFormModel }
|
|
2784
|
+
// 测试钩子(沿用 `_live` 的约定):状态条的纯函数可脱离浏览器直接断言。
|
|
2785
|
+
exports._subagentBar = { subagentBarModel: subagentBarModel, runningAgents: runningAgents }
|
|
2685
2786
|
exports.inject = ['slots', 'sessions', 'remote', 'uiSession', 'uiConversation', 'locale']
|
|
2787
|
+
var ctxUISession = null
|
|
2686
2788
|
exports.apply = function (ctx) {
|
|
2687
2789
|
console.log('[dsh-expert-team] client apply() called')
|
|
2790
|
+
try { ctxUISession = ctx && ctx.uiSession ? ctx.uiSession : null } catch (e) { ctxUISession = null }
|
|
2688
2791
|
try {
|
|
2689
2792
|
// N2:待拍板 pendingInteraction 发布(composer select 据此接管)
|
|
2690
2793
|
var publishPending = (ctx.uiSession && ctx.uiSession.registerPendingInteraction) ? ctx.uiSession.registerPendingInteraction(function () { return 5 }) : null
|
|
@@ -2727,6 +2830,13 @@ window.__ModuleLoader__.load({
|
|
|
2727
2830
|
ctx.slots.inject('conversation.session.header.actions', function () {
|
|
2728
2831
|
return ctx.slots.register({ name: 'conversation.session.header.actions', id: 'expert-team-open', order: 900, inject: function (sessionId) { return sessionId ? { sessionId: sessionId } : {} } }, wrap(HeaderButton))
|
|
2729
2832
|
})
|
|
2833
|
+
// 子代理运行状态条:`conversation.input.dock` 是官方声明在 composer 之上的 list 槽
|
|
2834
|
+
// (文档原文 "Full-width entries above the composer card."),渲染位置在消息列表之后、
|
|
2835
|
+
// 输入框卡片之前 ⇒ 正是"对话框与输入框之间"。list 槽是**纯增**的(不接管、不遮蔽
|
|
2836
|
+
// queue/todo/goal 三个既有条目),所以这里 order 排在它们之后。
|
|
2837
|
+
ctx.slots.inject('conversation.input.dock', function () {
|
|
2838
|
+
return ctx.slots.register({ name: 'conversation.input.dock', id: 'expert-team-subagents', order: 200 }, wrap(SubagentBar))
|
|
2839
|
+
})
|
|
2730
2840
|
ctx.slots.inject('shell.overlay', function () {
|
|
2731
2841
|
return ctx.slots.register({ name: 'shell.overlay', id: 'expert-team-panel', order: 100 }, wrap(Panel))
|
|
2732
2842
|
})
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R1 工件归属的**唯一机读真源** + `tools/pre-execute` 硬门禁(2026-09-15)。
|
|
3
|
+
*
|
|
4
|
+
* ── R1 是什么 ───────────────────────────────────────────────────────────────
|
|
5
|
+
* 协议(`skills/expert-team/SKILL.md` §2 R1,唯一权威表述):**run 工件一律由「产出它的角色」
|
|
6
|
+
* 自己 `write` 到 `<run-dir>/`**;角色只回 `path` + 摘要 + `verdict`;**lead 没有 `write`**,
|
|
7
|
+
* 只读工件做门控与裁决。本文件把这条协议从"约定"升级为**可执行的门禁**。
|
|
8
|
+
*
|
|
9
|
+
* ── 门禁语义(为什么是"创建放行、覆写才拦")────────────────────────────────
|
|
10
|
+
* `SKILL.md` §3 要求**首个成员**按模板一次性把 13 份骨架落到 `team/<run-id>/` —— 其中大多数
|
|
11
|
+
* **不属于它**。若门禁写成"角色 ≠ 负责人 ⇒ 拦",会**直接打死建 run**。因此判据是:
|
|
12
|
+
* **文件不存在(创建)⇒ 放行;文件已存在且写者不是它的负责人 ⇒ deny。**
|
|
13
|
+
* 这条判据只需一次存在性查询、**不读内容**,因此可以放在 `pre-execute`(写盘之前,
|
|
14
|
+
* 真正拦得住);而"内容级"的台账/边界校验依赖已落盘内容,继续留在 `post-execute`,
|
|
15
|
+
* 两者分工、互不冲突。
|
|
16
|
+
*
|
|
17
|
+
* ── 表格怎么来的(逐条可核,不发明所有权)──────────────────────────────────
|
|
18
|
+
* 真源两张表:
|
|
19
|
+
* - `SKILL.md` 的「角色 / 产出」表(角色 → 产出工件)
|
|
20
|
+
* - `references/WORKSPACE.md` 的「文件 / 维护者」表(工件 → 维护者)
|
|
21
|
+
* 取值口径:
|
|
22
|
+
* - **真源有歧义/多负责人 ⇒ 取宽松**(例如 `PLAN.md` 是 pm 骨架 → architect 设计段 → dba 数据段
|
|
23
|
+
* ⇒ 列 3 个 owner);宁可漏拦,不可误伤。
|
|
24
|
+
* - **`[]`(空数组)= 运行时专属 ⇒ 角色一律不得覆写**(`STATE.json` 唯一写者是运行时、
|
|
25
|
+
* `ROSTER.json` 由 /team 命令(宿主)建/更新)。
|
|
26
|
+
* - **不列入本表 = 不限制**(`TASK.md` / `任务看板.md` / `SUMMARY.md` / `RUN.log.md` / `RETRO.md`
|
|
27
|
+
* 在真源里的措辞是"lead 口述 + **指派的有 write 成员**落盘"⇒ 任何角色都可能被指派,
|
|
28
|
+
* 所以不设 owner)。
|
|
29
|
+
* - **表里没有的文件名 = 不限制**(角色自建的临时文件、run 内的子目录文件等)。
|
|
30
|
+
*
|
|
31
|
+
* ── 诚实边界(必须一并引用,别把它说成"不可能违反")────────────────────────
|
|
32
|
+
* 本门禁只覆盖 **`write` / `edit` 通道**。preset 里 `backend`/`frontend`/`researcher`/`qa`/
|
|
33
|
+
* `dba`/`devops` **持有 `bash`**,理论上可用 `cat > SPEC.md` 绕过。**首版刻意不对 bash 参数做
|
|
34
|
+
* 启发式检查**(易误伤,且 bash 的写目标可以是重定向/变量/子命令,静态判断不可靠)。
|
|
35
|
+
* 因此对外表述应为:「`write`/`edit` 通道的硬门禁」。
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { targetOf, runScopedTarget, TERMINAL_RUN_STATUSES } from './interception.js';
|
|
39
|
+
import { DEFAULT_ROLES } from './vocab.js';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* run 工件文件名 → 允许**覆写**它的角色(不含创建;创建一律放行)。
|
|
43
|
+
* `[]` = 运行时专属,角色一律不得覆写。未列入的键 = 不限制。
|
|
44
|
+
*/
|
|
45
|
+
export const ARTIFACT_OWNERS = Object.freeze({
|
|
46
|
+
// ── 单一负责人(SKILL.md「角色 / 产出」+ WORKSPACE.md「文件 / 维护者」一致)──
|
|
47
|
+
'SPEC.md': Object.freeze(['pm']),
|
|
48
|
+
'RESEARCH.md': Object.freeze(['researcher']),
|
|
49
|
+
'UI.md': Object.freeze(['ui']),
|
|
50
|
+
'REVIEW.md': Object.freeze(['reviewer']),
|
|
51
|
+
'REVIEW-SPEC.md': Object.freeze(['reviewer']),
|
|
52
|
+
'TEST.md': Object.freeze(['qa']),
|
|
53
|
+
'DATA.md': Object.freeze(['dba']),
|
|
54
|
+
'SECURITY.md': Object.freeze(['sec']),
|
|
55
|
+
'RELEASE.md': Object.freeze(['devops']),
|
|
56
|
+
'DOCS.md': Object.freeze(['docs']),
|
|
57
|
+
// ── 多负责人(真源就是多段/多角色 ⇒ 取宽松)──
|
|
58
|
+
// PLAN.md:pm 骨架 → architect 设计段 → dba 数据段(WORKSPACE.md 明写)
|
|
59
|
+
'PLAN.md': Object.freeze(['pm', 'architect', 'dba']),
|
|
60
|
+
// TASKS.json:pm 初稿 → architect 细化(lead 用 `/team task` 回写,不走 write 通道)
|
|
61
|
+
'TASKS.json': Object.freeze(['pm', 'architect']),
|
|
62
|
+
// ── 运行时专属:角色不得覆写 ──
|
|
63
|
+
// STATE.json:唯一写者是运行时(`SKILL.md` §2 R1 明写)
|
|
64
|
+
'STATE.json': Object.freeze([]),
|
|
65
|
+
// ROSTER.json:/team 命令(宿主)建/更新,lead 无 write
|
|
66
|
+
'ROSTER.json': Object.freeze([]),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* **认得出的角色**(用于区分"角色认不出 ⇒ 放行"与"角色认得但没权限 ⇒ 拦")。
|
|
71
|
+
* 真源是角色词汇表 `lib/vocab.js` 的 `DEFAULT_ROLES`(12 个固定角色)—— **不能**从上面的所有权表反推:
|
|
72
|
+
* `backend`/`frontend` 只产出工作区代码、不拥有任何工件,但它们**是**可识别的角色,
|
|
73
|
+
* 覆写别人的工件时必须能认出并拦下(冒烟测试抓到的反例:反推会让它们被当成"认不出"而放行)。
|
|
74
|
+
*/
|
|
75
|
+
export const KNOWN_ROLES = Object.freeze([...new Set([...DEFAULT_ROLES, ...Object.values(ARTIFACT_OWNERS).flat()])]);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 查某个工件的负责人。
|
|
79
|
+
* @param {string} base - run 目录内的文件名(如 `SPEC.md`)。
|
|
80
|
+
* @returns {readonly string[] | null} 负责人数组;`null` = **不限制**(不在表里)。
|
|
81
|
+
*/
|
|
82
|
+
export function ownersOf(base) {
|
|
83
|
+
const key = String(base || '');
|
|
84
|
+
if (!Object.prototype.hasOwnProperty.call(ARTIFACT_OWNERS, key)) return null;
|
|
85
|
+
return ARTIFACT_OWNERS[key];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 角色名归一:成员表里允许带后缀(`frontend-F4`、`reviewer-R1`)⇒ 取基名再查表;
|
|
90
|
+
* 认不出的角色**返回空串**(调用方据此 fail-open,而不是把它当成"没有权限")。
|
|
91
|
+
* @param {string} role - `STATE.members` 解出的角色串。
|
|
92
|
+
* @returns {string} 归一后的角色(认不出则为空串)。
|
|
93
|
+
*/
|
|
94
|
+
export function normalizeOwnerRole(role) {
|
|
95
|
+
const raw = String(role || '').trim();
|
|
96
|
+
if (!raw) return '';
|
|
97
|
+
if (KNOWN_ROLES.includes(raw)) return raw;
|
|
98
|
+
const base = raw.includes('-') ? raw.split('-')[0] : raw;
|
|
99
|
+
return KNOWN_ROLES.includes(base) ? base : '';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 判定一次写入是否违反 R1 工件归属(纯函数,零 IO,可单测)。
|
|
104
|
+
* @param {object} input
|
|
105
|
+
* @param {string} input.role - 写者角色(**已归一**;空串 = 认不出)。
|
|
106
|
+
* @param {string} input.base - 目标文件名。
|
|
107
|
+
* @param {boolean} input.exists - 目标是否**已存在**。
|
|
108
|
+
* @returns {string|null} 违规理由;合规返回 null。
|
|
109
|
+
*/
|
|
110
|
+
export function ownerViolation({ role, base, exists } = {}) {
|
|
111
|
+
if (!exists) return null; // 创建 ⇒ 放行(建 run 的骨架落盘依赖这条)
|
|
112
|
+
const r = String(role || '');
|
|
113
|
+
if (!r) return null; // 角色认不出 ⇒ 放行(fail-open,宁可漏拦)
|
|
114
|
+
const owners = ownersOf(base);
|
|
115
|
+
if (owners === null) return null; // 不在表里 ⇒ 不限制
|
|
116
|
+
if (owners.includes(r)) return null; // 是负责人 ⇒ 放行
|
|
117
|
+
const who = owners.length > 0 ? owners.join(' / ') : '运行时(唯一写者)';
|
|
118
|
+
return `R1 工件归属:\`${base}\` 已存在,而角色 \`${r}\` 不是它的负责人(负责人:${who})⇒ 按协议不得覆写。`
|
|
119
|
+
+ '请把结论(path + 摘要 + verdict)回给 lead,由负责角色落盘;创建工作目录/骨架不受此限制。';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 创建 `tools/pre-execute` 监听器(**写盘之前**拦,因此真拦得住)。
|
|
124
|
+
*
|
|
125
|
+
* 纪律(与既有 `post-execute` 拦截器逐条一致):
|
|
126
|
+
* - 监听器**绝不允许**成为工具调用的故障源:任何异常 ⇒ 放行 + 留痕;
|
|
127
|
+
* - 判定不出来(角色认不出 / 存在性查不到 / 路径不在 run 目录)⇒ **放行**;
|
|
128
|
+
* - 已终止的 run 放行(与 `interception.js` 同一理由:冻结的历史工件不该在写侧拦);
|
|
129
|
+
* - 下游(`next()`)已给出 deny/ask 时不插嘴,尊重"最严格优先"。
|
|
130
|
+
*
|
|
131
|
+
* @param deps - 注入依赖:
|
|
132
|
+
* `cwdFor(exec) => string|null`、`teamRootFor(cwd) => string|null`、
|
|
133
|
+
* `statusFor(runId, cwd) => Promise<string>`、`roleFor(exec, runId, cwd) => Promise<string>`、
|
|
134
|
+
* `existsFor(abs) => Promise<boolean|undefined>`(undefined = 查不到 ⇒ 放行)、
|
|
135
|
+
* `onEvent(type, payload)`(可观测:让"门禁被行使/降级"看得见)。
|
|
136
|
+
* @returns `(exec, next) => Promise<PreToolDecision>`
|
|
137
|
+
*/
|
|
138
|
+
export function createOwnershipGate(deps) {
|
|
139
|
+
const {
|
|
140
|
+
cwdFor = () => null,
|
|
141
|
+
teamRootFor = () => null,
|
|
142
|
+
statusFor = async () => '',
|
|
143
|
+
roleFor = async () => '',
|
|
144
|
+
existsFor = async () => undefined,
|
|
145
|
+
onEvent = () => {},
|
|
146
|
+
} = deps || {};
|
|
147
|
+
|
|
148
|
+
const trace = (type, payload) => { try { onEvent(type, payload); } catch { /* 观测失败不影响工具 */ } };
|
|
149
|
+
|
|
150
|
+
return async function ownershipGate(exec, next) {
|
|
151
|
+
try {
|
|
152
|
+
// ① 先让下游表态:它已 deny/ask 就不插嘴(宿主 waterfall 的最严格优先)。
|
|
153
|
+
const downstream = typeof next === 'function' ? await next() : { kind: 'allow' };
|
|
154
|
+
if (downstream && downstream.kind && downstream.kind !== 'allow') return downstream;
|
|
155
|
+
|
|
156
|
+
// ② 只认 write/edit 的 file_path;其余工具(含 bash)**一律放行**(见文件头的诚实边界)。
|
|
157
|
+
const raw = targetOf(exec);
|
|
158
|
+
if (!raw) return { kind: 'allow' };
|
|
159
|
+
|
|
160
|
+
// ③ 只认 `<teamRoot>/<runId>/<文件名>` 恰好两段(与 post-execute 同一作用域)。
|
|
161
|
+
const cwd = cwdFor(exec);
|
|
162
|
+
const teamRootAbs = teamRootFor(cwd);
|
|
163
|
+
if (!cwd || !teamRootAbs) return { kind: 'allow' };
|
|
164
|
+
const target = runScopedTarget(raw, teamRootAbs);
|
|
165
|
+
if (!target || !target.base) return { kind: 'allow' };
|
|
166
|
+
|
|
167
|
+
// ④ 终态 run 放行(冻结的历史工件)
|
|
168
|
+
const status = String((await statusFor(target.runId, cwd)) || '');
|
|
169
|
+
if (TERMINAL_RUN_STATUSES.has(status)) return { kind: 'allow' };
|
|
170
|
+
|
|
171
|
+
// ⑤ 存在性:查不到(undefined)⇒ **放行 + 留痕**(门禁降级必须可见)
|
|
172
|
+
const exists = await existsFor(target.abs);
|
|
173
|
+
if (typeof exists !== 'boolean') {
|
|
174
|
+
trace('ownership-gate-degraded', { runId: target.runId, base: target.base, abs: target.abs, why: 'existence-unknown' });
|
|
175
|
+
return { kind: 'allow' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ⑥ 角色:认不出 ⇒ 放行(fail-open,`normalizeOwnerRole` 已把不认识的角色归一成空串)
|
|
179
|
+
const role = normalizeOwnerRole(await roleFor(exec, target.runId, cwd));
|
|
180
|
+
if (!role) {
|
|
181
|
+
if (exists) trace('ownership-gate-degraded', { runId: target.runId, base: target.base, why: 'role-unknown' });
|
|
182
|
+
return { kind: 'allow' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ⑦ 判定
|
|
186
|
+
const reason = ownerViolation({ role, base: target.base, exists });
|
|
187
|
+
if (reason) {
|
|
188
|
+
trace('ownership-denied', { runId: target.runId, base: target.base, role, abs: target.abs });
|
|
189
|
+
return { kind: 'deny', reason };
|
|
190
|
+
}
|
|
191
|
+
return { kind: 'allow' };
|
|
192
|
+
} catch (e) {
|
|
193
|
+
// **绝不允许**门禁成为工具故障源(真实事故:post-execute 签名写错曾让全工具瘫痪)
|
|
194
|
+
trace('ownership-gate-error', { message: String((e && e.message) || e) });
|
|
195
|
+
return { kind: 'allow' };
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|