@yangdcm/dsh-expert-team 1.3.13 → 1.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,73 @@
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
+
37
+ ## 1.3.14
38
+
39
+ **性能收尾:同一时刻至多一条重活 + profile 补账**(性能修复 #4)
40
+
41
+ - **真相换了一次**:上一版(1.3.13)把首屏从 3.4 s 打到 9–16 ms ✓,但真机连续采样发现
42
+ **重分节单次 4 805 ms、每 ≥6 s 一次**,而且**重活在飞时 summary 被拖到 772 ms**。
43
+ 本轮先补账、再按数据改。
44
+ - **profile 补账(`DSH_EXPERT_TEAM_STATE_PROFILE=1`)**:原先只有 `runs+select / subs / wfLabels / roles / tail`
45
+ 五个点,其中 `tail` 把"从角色解析之后到回响应"的**全部**工作都吞进一段(真机上那段约 2 s 无账)。
46
+ 现按执行顺序补齐:`people-enrich`(过滤/映射/登记/workflow 元数据/enrich)、`agents`(agents 映射 + 实时投影)、
47
+ `assemble`(违规/schema 校验/告警)、`artifacts`(`RUN.log` 尾 + git)、`tail`(持久化 + 序列化)
48
+ ⇒ **账目逐段对齐,不再有"无账的 2 秒"**。
49
+ - **`feed` 也依赖 subs**:原先只有 `want('people')` 才算 subs,于是单独请求 `section=feed` 会**静默返回空 feed**
50
+ —— 典型的"两种零分不清"。现在 `needSubs = want('people') || want('feed')`,该花的一次枚举照花。
51
+ - **新增两道上限(都带如实降级)**:
52
+ - `DSH_EXPERT_TEAM_ARTIFACTS_DEADLINE_MS`(默认 2 500 ms):artifacts 段要读 `RUN.log` 尾、还要跑 `git`,
53
+ 是**全链最不可控**的一段。超期限就跳过,并如实记 `artifacts:deadline`(读完日志再超就记 `files:deadline`),
54
+ 跳过时**不写**那些键 —— 缺块 ≠ 空数据。
55
+ - `DSH_EXPERT_TEAM_MAX_FEED_AGENTS`(默认 60):feed 按子代理条数封顶,被挡住的条数记 `feed:<n>`。
56
+ - **客户端:重分节改为"只拉当前可见子标签那一块"**(此前无条件每 ≥6 s 拉 `people,feed,artifacts`):
57
+ - `team`(人)→ `people,feed`;`tasks`(事)→ `people,feed`;`info`(料)→ `artifacts`;
58
+ **`board`(盘)→ 不拉任何重分节**(它只渲染 `runs[]`,而 runs 在 `summary` 里就有);
59
+ - 「事」点开任务详情(需要 `files`/`logTail`)时才**一次性**补拉 `artifacts`,不常驻;
60
+ - 映射收敛成**只有两个 canonical 重分节 URL**(`people,feed` / `artifacts`)⇒ `stateHub` 的同 URL 在飞合并
61
+ 能把面板、画布、徽章压成**同一条**。
62
+ - **徽章不再与「料」标签并发两条重活**:徽章原先一直自己订阅 `people,feed`(2.5 s 一拍 ⇒ 重活常驻)。现在
63
+ 面板/画布开着时由它们**发布**同一份 payload(`publishToLive`,发布时**合并**:缺块 ≠ 空数据),
64
+ 徽章只在**无人发布**时才自己轮询,且节奏放宽到 **10 s**(子代理出现时另有 `liveTick()` 主动催一次)。
65
+ - **真机数字的解释(诚实标注口径)**:`4 805 ms ≈ 2 × 2 400 ms` —— 当时画布同时订阅了 `people,feed` 与
66
+ `people,feed,artifacts` 两条**不同 URL** 的重活,服务端在同一事件循环上串行处理 ⇒ 各自看着慢一倍,
67
+ 并把 `summary` 拖到 772 ms。本轮把重活收敛成"同一时刻至多一条",这类并发在结构上不再可能。
68
+ - 本机可复现的旁证(真实 run、进程内计时):`snapshotRun` **1.3 ms**、`runLogTail` **0.3 ms**、
69
+ `liveFiles`(git)**0.3 ms 冷 / 0.0 ms 命中缓存** ⇒ artifacts 段**不是**那 2 s 的来源,
70
+ 与"并发才是主因"的判断一致。
71
+ - 端到端复测仍归用户(本机装不进运行中的宿主):预期**同一时刻只有一条重活**、
72
+ `summary` 在重活在飞时仍为**十毫秒级**(此前 772 ms)、停留在某标签时后台不再出现不相干分节。
6
73
  ## 1.3.13
7
74
 
8
75
  **渐进式状态:`/state` 支持 `?section=`,首屏与会话体量解耦**(性能修复 #3)
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 | 80 test files | 0 runtime deps | 0 build steps |
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 136-entry mutation catalog and several ratchets | `dependencies: {}` | no bundler, no `prepare` hook |
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
- - **80 test files plus a 136-entry mutation catalog.** `npm run test:all` needs no `install` (it is what CI runs);
160
- the mutation catalog requires every mutant to be killed by at least one test the suite is not "green",
161
- it is *able to catch errors*.
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`
@@ -386,7 +391,7 @@ evolve with the skill, without shipping a new package.
386
391
  ## Development
387
392
 
388
393
  ```sh
389
- npm run test:all # 80 test files, zero dependencies, no install needed (this is what CI runs)
394
+ npm run test:all # 84 test files, zero dependencies, no install needed (this is what CI runs)
390
395
  npm run rename <name> # after forking: syncs 4 package-name spellings across 13 files
391
396
  npm run check:name # check for leftover placeholder package names
392
397
  ```
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 阶段 | 80 个测试文件 | 0 运行时依赖 | 0 构建步骤 |
17
+ | 12 角色 | 9 阶段 | 84 个测试文件 | 0 运行时依赖 | 0 构建步骤 |
18
18
  |---|---|---|---|---|
19
- | 各带人设 / `toolFilter` / `maxDepth: 1` | 含 1 道硬门 + 1 道确认门 | 含 136 条变异目录与多组棘轮 | `dependencies: {}` | 无 bundler、无 `prepare` 钩子 |
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
- - **80 个测试文件 + 136 条变异目录。** `npm run test:all` 无需 `install` 即可跑(CI 跑的就是它);
142
- `mutation-catalog` 要求每个变异体都至少被一个测试杀掉 —— 测试不是"跑绿了",而是"能抓到错"
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`(**每个设置项都必须有消费者**,白名单集合相等 ⇒ 只减不增)、
@@ -344,7 +346,7 @@ presets/expert-team/ 「专家团模式」preset:12 个角色 subagent 工
344
346
  ## 开发
345
347
 
346
348
  ```sh
347
- npm run test:all # 80 个测试文件,零依赖、无需 install(CI 跑的就是它)
349
+ npm run test:all # 84 个测试文件,零依赖、无需 install(CI 跑的就是它)
348
350
  npm run rename <新包名> # fork 后改名:自动同步 13 个文件里 4 种包名写法
349
351
  npm run check:name # 检查占位包名残留
350
352
  ```
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
 
@@ -1245,6 +1259,21 @@ window.__ModuleLoader__.load({
1245
1259
  h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 6 } }, options))
1246
1260
  }
1247
1261
 
1262
+ // ── 标签 → 重分节映射(性能收尾批次:把"后台一直在拉三块"变成"只拉当前可见的那一块")──
1263
+ // 依据是各标签**实际渲染所用的字段**(读代码得出,不是猜):
1264
+ // `team`(人):agents/members/roles/wfRuns(people)+ 每个成员的实时操作流(feed)
1265
+ // `tasks`(事):依赖图的人员标签来自 people;任务本身在 summary 里(便宜);
1266
+ // 任务详情要 files/logTail(artifacts)⇒ 选中时**一次性**补拉(见下)
1267
+ // `info`(料):违规/覆盖率在 summary 里;实时日志(logTail)需要 artifacts
1268
+ // `board`(盘):只渲染 runs[](summary 里已有)⇒ **不需要任何重分节**
1269
+ // 这条映射是单一真源:订阅与"选中任务补拉"都读它,护栏测试也钉它。
1270
+ function heavySectionsForTab(tb) {
1271
+ if (tb === 'info') return 'artifacts'
1272
+ if (tb === 'tasks') return 'people,feed' // feed 是内存切片(近乎免费),与徽章同 URL ⇒ 合并成一条
1273
+ if (tb === 'board') return ''
1274
+ return 'people,feed' // team(默认)与人相关视图
1275
+ }
1276
+
1248
1277
  function Panel(props) {
1249
1278
  var sidProp = (props && props.sessionId) || ''
1250
1279
  var viewMode = (props && props.mode) === 'view'
@@ -1373,6 +1402,7 @@ window.__ModuleLoader__.load({
1373
1402
  function onState(d) {
1374
1403
  if (d && d.ok) {
1375
1404
  setData(function (prev) { return mergeStatePayload(prev, d) }); setErr(''); reschedule(d)
1405
+ try { publishToLive(d) } catch (e) {}
1376
1406
  // N2: keep the composer takeover in sync with pendingDecision
1377
1407
  // 批 0-2:把 /state 已经返回的 runId/workspace 一并传出,供横幅拍板时回传给 /decide
1378
1408
  try { if (exports._syncPending) exports._syncPending(d.pendingDecision, sessionId, d.runId, d.workspace) } catch (e) {}
@@ -1407,19 +1437,35 @@ window.__ModuleLoader__.load({
1407
1437
  }
1408
1438
  useEffect(function () {
1409
1439
  if (!isOpen && !viewMode) return undefined // panel closed & not canvas → no polling
1440
+ setLivePublisher(true) // 面板/画布开着 ⇒ 接管徽章的数据(见 publishToLive 的注释)
1410
1441
  // **双订阅**(性能修复 #3):`summary` 便宜(阶段/进度/计数)⇒ 按 `pollMs` 快拉,
1411
1442
  // 首屏立刻有内容;`people,feed,artifacts` 贵(成员解析/事件流/日志尾+git)⇒ 至少 6 秒一次,
1412
1443
  // 到达后由 `mergeStatePayload` 合并进同一份 data(**不会**把摘要拍掉的字段抹掉)。
1413
1444
  // stateHub 按 URL 各自计时(per-URL due),所以"一快一慢"不会互相拖。
1414
1445
  var off = stateHubSubscribe(stateUrl('summary'), dispCfg.pollMs, onState)
1415
- var offDetail = stateHubSubscribe(stateUrl('people,feed,artifacts'), Math.max(dispCfg.pollMs, 6000), onState)
1446
+ // 重分节**按当前可见子标签**订阅(不再无条件拉 people,feed,artifacts):切标签时旧的 URL
1447
+ // 随卸载退订、新的立即拉一次 ⇒ 后台只保留"你正在看的那一块"。
1448
+ var heavy = heavySectionsForTab(tab)
1449
+ var offDetail = heavy ? stateHubSubscribe(stateUrl(heavy), Math.max(dispCfg.pollMs, 6000), onState) : function () {}
1416
1450
  // catch up instantly when the user returns to the tab / window(只补便宜那一份)
1417
1451
  var onVis = function () { if (document.visibilityState === 'visible') stateHubFetch(stateUrl('summary'), onState) }
1418
1452
  var onFocus = function () { stateHubFetch(stateUrl('summary'), onState) }
1419
1453
  document.addEventListener('visibilitychange', onVis)
1420
1454
  window.addEventListener('focus', onFocus)
1421
- return function () { off(); offDetail(); document.removeEventListener('visibilitychange', onVis); window.removeEventListener('focus', onFocus) }
1422
- }, [sessionId, selRunV, isOpen, viewMode, dispCfg.pollMs])
1455
+ return function () {
1456
+ off(); offDetail()
1457
+ setLivePublisher(false)
1458
+ document.removeEventListener('visibilitychange', onVis); window.removeEventListener('focus', onFocus)
1459
+ }
1460
+ }, [sessionId, selRunV, isOpen, viewMode, dispCfg.pollMs, tab])
1461
+
1462
+ // 任务详情(files / logTail)属于 artifacts 分节:**只在真的点开任务时补拉一次**,
1463
+ // 不在后台常驻(否则等于把 artifacts 又变回常驻重分节)。切换选中任务会重新拉一次。
1464
+ useEffect(function () {
1465
+ if (!selTaskV) return
1466
+ if (tab !== 'tasks') return // 任务详情(files/logTail)只在「事」标签渲染
1467
+ try { stateHubFetch(stateUrl('artifacts'), onState) } catch (e) {}
1468
+ }, [selTaskV && selTaskV.id])
1423
1469
 
1424
1470
  // Make the docked panel reserve space (shift the shell frame) instead of
1425
1471
  // covering the workspace; clears when closed or floating (md-preview trick).
@@ -2442,7 +2488,11 @@ window.__ModuleLoader__.load({
2442
2488
 
2443
2489
  // ── 共享 state 轮询(HeaderButton / LiveCapsule 复用,单例;无订阅者即停)──
2444
2490
  var liveStore = { data: null, sid: '', off: null, subs: new Set() }
2445
- var LIVE_BASE_MS = 2500 // 徽章/画布的基础节奏(与面板的 dispCfg.pollMs 取更小者由 hub 统一决定)
2491
+ // 徽章/胶囊的基础节奏:它订阅的是 `people,feed`(重分节)。原先 2.5 s 等于**让重活常驻**
2492
+ // (即使没开面板也在拉),实测那正是"重请求在飞时 summary 被拖到 772 ms"的来源之一。
2493
+ // 徽章是环境状态(有没有人在跑/有没有待决策),10 s 的延迟可以接受;
2494
+ // 需要立刻刷新时另有 `liveTick()`(子代理出现时主动催一次),不靠高频轮询。
2495
+ var LIVE_BASE_MS = 10000
2446
2496
  function liveUrl(sid) { return '/plugins/dsh-expert-team/state?sessionId=' + encodeURIComponent(sid) + '&section=people,feed' }
2447
2497
  // 手动催一次(子代理刚出现时立刻刷新一次徽章,不必等下一个 tick)
2448
2498
  function liveTick() {
@@ -2461,20 +2511,77 @@ window.__ModuleLoader__.load({
2461
2511
  var m = (d && d.members) || {}
2462
2512
  return Object.keys(m).some(function (k) { var v = m[k]; return v && typeof v === 'object' && (v.activity === 'running' || v.shortStatus === 'running') })
2463
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
+ }
2546
+ // ── 面板/画布 → 徽章的**发布**路径(性能收尾批次)────────────────────────────
2547
+ // 为什么需要:徽章订阅的是 `people,feed`(重分节)。若它一直自己轮询,而当前标签是「料」
2548
+ // (重分节 = `artifacts`),同一时刻就有**两条重活并发** —— 真机上那正是单发从 2.4 s 变
2549
+ // 4.8 s 的原因(同一事件循环上串行,各自看着都慢一倍),连带把 9 ms 的 summary 拖到 772 ms。
2550
+ // 现在:面板/画布开着时由它们**发布**同一份 payload(零额外请求);都没开时徽章才自己轮询。
2551
+ var livePublishers = 0
2552
+ function publishToLive(d) {
2553
+ if (!d || typeof d !== 'object') return
2554
+ // 合并而不是替换:面板按标签只发一部分块(缺块 ≠ 空数据),合并后徽章不会因为
2555
+ // 收到一份只有 summary 的负载就把已知的成员/活动抹掉。
2556
+ liveStore.data = Object.assign({}, liveStore.data || {}, d)
2557
+ try { harvestActivity(liveStore.data) } catch (e) {}
2558
+ try { stateHub.busy = teamBusy(liveStore.data) } catch (e) {}
2559
+ liveStore.subs.forEach(function (f) { try { f(liveStore.data) } catch (e) {} })
2560
+ }
2561
+ function setLivePublisher(on) {
2562
+ livePublishers += on ? 1 : -1
2563
+ if (livePublishers < 0) livePublishers = 0
2564
+ notify()
2565
+ }
2566
+ function useLivePublishers() { return useExternal(function () { return livePublishers }) }
2567
+
2464
2568
  function useLiveState(sid) {
2465
2569
  var st = useState(liveStore.data); var data = st[0], setData = st[1]
2570
+ var pubs = useLivePublishers()
2466
2571
  useEffect(function () {
2467
2572
  if (!sid) return undefined
2468
2573
  liveStore.sid = sid
2469
2574
  var fn = function (d) { setData(d) }
2470
2575
  liveStore.subs.add(fn)
2576
+ // 有人发布(面板/画布开着)⇒ 徽章**不再另开一条重分节**,靠发布的数据保持新鲜。
2577
+ if (pubs > 0) return function () { liveStore.subs.delete(fn) }
2471
2578
  // 经 hub 订阅:与面板/画布共用同一个时钟与在飞守卫(这里**不再**自己 setInterval)
2472
2579
  var off = stateHubSubscribe(liveUrl(sid), LIVE_BASE_MS, liveDeliver)
2473
2580
  return function () {
2474
2581
  liveStore.subs.delete(fn)
2475
2582
  off()
2476
2583
  }
2477
- }, [sid])
2584
+ }, [sid, pubs])
2478
2585
  return data
2479
2586
  }
2480
2587
  // 头部徽章文案:让「并排面板」入口自带团队实时状态(与画布标签职责区分)
@@ -2494,6 +2601,59 @@ window.__ModuleLoader__.load({
2494
2601
  return { text: '🧑‍💼 ' + done + '/' + tasks.length, title: phaseLabel(live.phase || '') + '/' + statusLabel(live.status || '') }
2495
2602
  }
2496
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
+
2497
2657
  function HeaderButton(props) {
2498
2658
  var sid = props && props.sessionId
2499
2659
  useEffect(function () { if (sid) setCurrentSession(sid) }, [sid])
@@ -2621,9 +2781,13 @@ window.__ModuleLoader__.load({
2621
2781
  // Test hook (same convention as the host half's `_live`): lets the regression
2622
2782
  // suite assert text safety without booting the overlay in a browser.
2623
2783
  exports._live = { esc: esc, tierBadge: tierBadge, TIER_LABELS_ZH: TIER_LABELS_ZH, settingsFormModel: settingsFormModel }
2784
+ // 测试钩子(沿用 `_live` 的约定):状态条的纯函数可脱离浏览器直接断言。
2785
+ exports._subagentBar = { subagentBarModel: subagentBarModel, runningAgents: runningAgents }
2624
2786
  exports.inject = ['slots', 'sessions', 'remote', 'uiSession', 'uiConversation', 'locale']
2787
+ var ctxUISession = null
2625
2788
  exports.apply = function (ctx) {
2626
2789
  console.log('[dsh-expert-team] client apply() called')
2790
+ try { ctxUISession = ctx && ctx.uiSession ? ctx.uiSession : null } catch (e) { ctxUISession = null }
2627
2791
  try {
2628
2792
  // N2:待拍板 pendingInteraction 发布(composer select 据此接管)
2629
2793
  var publishPending = (ctx.uiSession && ctx.uiSession.registerPendingInteraction) ? ctx.uiSession.registerPendingInteraction(function () { return 5 }) : null
@@ -2666,6 +2830,13 @@ window.__ModuleLoader__.load({
2666
2830
  ctx.slots.inject('conversation.session.header.actions', function () {
2667
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))
2668
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
+ })
2669
2840
  ctx.slots.inject('shell.overlay', function () {
2670
2841
  return ctx.slots.register({ name: 'shell.overlay', id: 'expert-team-panel', order: 100 }, wrap(Panel))
2671
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
+ }
package/lib/command.js CHANGED
@@ -22,6 +22,7 @@ import { join, dirname, basename } from 'node:path';
22
22
  import { fileURLToPath } from 'node:url';
23
23
  import { createArtifactWriter, nodeFsPort, cordisFsPort, DEFAULT_SCHEMA_GUARDS } from './artifact-writer.js';
24
24
  import { createBoundaryInterceptor } from './interception.js';
25
+ import { createOwnershipGate } from './artifact-ownership.js';
25
26
  // 设计稿 §十二 第 3 步:并发写留痕(只记录、只告警,绝不阻断)。
26
27
  import { createWriteTracer, formatConflict } from './write-tracer.js';
27
28
  import { createLoopGuard } from './loop-guard.js';
@@ -2690,6 +2691,60 @@ function cwdFromSession(ctx, sessionId) {
2690
2691
  } catch { return undefined; }
2691
2692
  }
2692
2693
 
2694
+ /**
2695
+ * 由 `exec` 的**调用者**解析出角色(R1 归属门禁与并发写留痕**共用同一份**解析)。
2696
+ *
2697
+ * 来源:`STATE.members` 的 `<agentSessionId>:<role>` 行 —— 宿主在派工时自动登记
2698
+ *("派工即登记"),所以这里不需要猜 label/事件流。
2699
+ * **认不出就返回空串**:调用方据此 fail-open(宁可漏拦,不可误伤),绝不臆造角色。
2700
+ * 角色可带后缀(`frontend-F4`、`reviewer-R1`)——原样返回,归一由查表一侧负责。
2701
+ *
2702
+ * @param ctx - 插件 ctx(用 ctx.get('sessions') 反查 cwd)
2703
+ * @param exec - 工具执行记录(`exec.agent.session` 给出调用者会话 id)
2704
+ * @param runId - 目标 run(STATE.json 所在目录名)
2705
+ * @returns {Promise<string>} 角色串;解析不出返回 `''`。
2706
+ */
2707
+ async function roleOfAgent(ctx, exec, runId) {
2708
+ const sid = String(exec?.agent?.session?.id || exec?.agent?.session?.header?.id || '');
2709
+ if (!sid || !runId) return '';
2710
+ try {
2711
+ const cwd = cwdFromSession(ctx, exec?.agent?.session?.header?.id || sid)
2712
+ || String(exec?.agent?.session?.header?.cwd || '');
2713
+ if (!cwd) return '';
2714
+ const st = await readJsonSafe(join(teamRoot(cwd), runId, 'STATE.json'));
2715
+ for (const m of (st && Array.isArray(st.members)) ? st.members : []) {
2716
+ const str = String(m || '');
2717
+ const i = str.indexOf(':');
2718
+ if (i > 0 && str.slice(0, i) === sid) return str.slice(i + 1);
2719
+ }
2720
+ } catch { /* 读不到 ⇒ 空串(fail-open) */ }
2721
+ return '';
2722
+ }
2723
+
2724
+ /**
2725
+ * 目标文件**是否已存在** —— R1 门禁的"创建放行、覆写才拦"就靠这一个判据(不需要读内容)。
2726
+ *
2727
+ * 与读文本同一套纪律:**宿主 fs 优先**(run 目录可能落在宿主虚拟路径体系里),拿不到再退 node。
2728
+ * 返回三态:`true`(在)/ `false`(明确不存在)/ `undefined`(**查不到**)。
2729
+ * 调用方必须把 `undefined` 当"不知道"而放行并留痕 —— 把"查不到"当成"不存在"会让门禁
2730
+ * 在最需要它的时候静默失效(本轮反复强调的"两种零要分得清")。
2731
+ */
2732
+ async function pathExistsFor(ctx, abs) {
2733
+ try {
2734
+ const hostFs = typeof ctx.get === 'function' ? ctx.get('fs') : null;
2735
+ if (hostFs && typeof hostFs.stat === 'function' && typeof hostFs.resolve === 'function') {
2736
+ const resolvedTarget = await hostFs.resolve(abs);
2737
+ try {
2738
+ const st = await hostFs.stat(resolvedTarget);
2739
+ if (st) return true;
2740
+ // 宿主明确说"没有":再用 node 复核一次(宿主实现语义可能不同,宁可多问一次)
2741
+ } catch { /* 宿主抛错 ⇒ 交给 node */ }
2742
+ }
2743
+ } catch { /* 宿主解析失败 ⇒ 交给 node */ }
2744
+ try { await stat(abs); return true; }
2745
+ catch (e) { return (e && e.code === 'ENOENT') ? false : undefined; }
2746
+ }
2747
+
2693
2748
  // Pick the most recently updated run under <cwd>/team.
2694
2749
  async function pickLatestRun(cwd) {
2695
2750
  const root = teamRoot(cwd);
@@ -5094,20 +5149,12 @@ export function apply(ctx, config) {
5094
5149
  },
5095
5150
  // 并发写留痕(设计稿 §十二 第 3 步):写者身份 = 会话 id + 从 `STATE.members`
5096
5151
  //(格式就是 `<agentSessionId>:<role>`)解出的角色。**解不出角色就如实退到会话 id,不编造角色。**
5152
+ // 写者身份 = 会话 id + 角色。**角色解析与 R1 门禁共用 `roleOfAgent`**
5153
+ //(两处各写一遍口径迟早漂移:一处认得出、另一处认不出)。
5097
5154
  whoFor: async (exec, target) => {
5098
5155
  const sid = String(exec?.agent?.session?.id || exec?.agent?.session?.header?.id || '');
5099
5156
  if (!sid) return '';
5100
- let role = '';
5101
- try {
5102
- const cwd = cwdFromSession(ctx, exec?.agent?.session?.header?.id || sid)
5103
- || String(exec?.agent?.session?.header?.cwd || '');
5104
- const st = cwd ? await readJsonSafe(join(teamRoot(cwd), target.runId, 'STATE.json')) : null;
5105
- for (const m of (st && Array.isArray(st.members)) ? st.members : []) {
5106
- const str = String(m || '');
5107
- const i = str.indexOf(':');
5108
- if (i > 0 && str.slice(0, i) === sid) { role = str.slice(i + 1); break; }
5109
- }
5110
- } catch { /* 读不到就退到会话 id */ }
5157
+ const role = await roleOfAgent(ctx, exec, target.runId);
5111
5158
  return role ? `${role}#${sid.slice(0, 6)}` : `session:${sid.slice(0, 6)}`;
5112
5159
  },
5113
5160
  writeTracer: WRITE_TRACER,
@@ -5121,6 +5168,29 @@ export function apply(ctx, config) {
5121
5168
  });
5122
5169
  ctx.on('tools/post-execute', boundary);
5123
5170
 
5171
+ // ── R1 工件归属**硬门禁**(2026-09-15):把"工件由产出它的角色自己落盘"从**协议约定**
5172
+ // 升级成**可执行的门禁**。挂在 `tools/pre-execute`(**写盘之前**)而不是 post:
5173
+ // 判据是"**创建放行、覆写才拦**"——只需一次存在性查询、不读内容,因此拦得住;
5174
+ // 而内容级的台账/边界校验依赖已落盘内容,继续留在上面的 post-execute,两者分工。
5175
+ // 语义、表格真源与诚实边界(只覆盖 write/edit 通道;持 bash 的角色仍可能绕过)
5176
+ // 全部写在 `lib/artifact-ownership.js` 的文件头,别在这里复制一份。
5177
+ try {
5178
+ const ownershipGate = createOwnershipGate({
5179
+ cwdFor: (exec) => cwdFromSession(ctx, exec?.agent?.session?.header?.id || exec?.agent?.session?.id),
5180
+ teamRootFor: (cwd) => teamRoot(cwd),
5181
+ statusFor: async (runId, cwd) => {
5182
+ const st = await readJsonSafe(join(teamRoot(cwd), runId, 'STATE.json'));
5183
+ return String((st && st.status) || '');
5184
+ },
5185
+ roleFor: (exec, runId) => roleOfAgent(ctx, exec, runId),
5186
+ existsFor: (abs) => pathExistsFor(ctx, abs),
5187
+ onEvent: (type, payload) => {
5188
+ try { console.warn(`[expert-team] ${type}`, JSON.stringify(payload)); } catch { /* 观测失败不影响工具 */ }
5189
+ },
5190
+ });
5191
+ ctx.on('tools/pre-execute', ownershipGate);
5192
+ } catch { /* 门禁挂不上 ⇒ 退回纯协议;绝不让它影响插件加载或工具调用 */ }
5193
+
5124
5194
  // ── C 线第 16 项(收窄版):**振荡检测**。同工具重复调用由宿主
5125
5195
  // `@deepseek-ai/dsh-repeat-tool-reminder` 负责(dsh-base 已启用,thresholds [3,5,8]),
5126
5196
  // 不重写;这里只补它明确声明不覆盖的那一种 —— A→B→A→B 在两方案间来回。
@@ -5291,6 +5361,13 @@ export function apply(ctx, config) {
5291
5361
  const MAX_ROLE_SUBS = Math.max(1, Number((typeof process !== 'undefined' && process.env && process.env.DSH_EXPERT_TEAM_MAX_ROLE_SUBS) || 0) || 60);
5292
5362
  // people 分节的软期限:超时后跳过更贵的可选增强(workflow 元数据),同样如实标注。
5293
5363
  const PEOPLE_DEADLINE_MS = Math.max(200, Number((typeof process !== 'undefined' && process.env && process.env.DSH_EXPERT_TEAM_PEOPLE_DEADLINE_MS) || 0) || 2500);
5364
+ // artifacts 分节的软期限:它要读 RUN.log 尾、还要跑 git(liveFiles)——后者在大工作区
5365
+ // 里是最不可控的一段。超过就从**请求开始**算起跳过,并**如实进 degraded**(缺块 ≠ 空数据:
5366
+ // 跳过时键不存在,客户端据 degraded 知道"不是没有,是没来得及")。
5367
+ const ARTIFACTS_DEADLINE_MS = Math.max(200, Number((typeof process !== 'undefined' && process.env && process.env.DSH_EXPERT_TEAM_ARTIFACTS_DEADLINE_MS) || 0) || 2500);
5368
+ // feed 分节的硬上限:每 agent 只嵌最近 12 条事件,但 agent 数本身要封顶(重会话里
5369
+ // subs 可能几十上百条),超出的条数**如实进 degraded**。
5370
+ const MAX_FEED_AGENTS = Math.max(1, Number((typeof process !== 'undefined' && process.env && process.env.DSH_EXPERT_TEAM_MAX_FEED_AGENTS) || 0) || 60);
5294
5371
  const peopleStartedAt = Date.now();
5295
5372
  const degraded = [];
5296
5373
  const reg = (await registeredWorkspaces()).filter(Boolean);
@@ -5346,9 +5423,12 @@ export function apply(ctx, config) {
5346
5423
  // 只有 people 分节才解析人员:这一步要枚举子会话 durable 清单(重会话里这是整条链上
5347
5424
  // 最大的一块)。首屏用 summary 时不付这个成本 —— `agents` 返回空数组,
5348
5425
  // 由客户端 `sections` 字段知道自己拿的是"摘要",UI 如实显示"正在加载成员"。
5349
- let subs = want('people') ? await listSubagentStatusBySession(ctx, peopleSid, knownIds) : [];
5426
+ // `feed` 也必须先有 subs(它按子会话遍历活动缓存)。若只想要 feed 却跳过枚举,
5427
+ // feed 会**静默变空** —— 那正是本仓最忌讳的"两种零分不清"(缺块 ≠ 空数据)。
5428
+ const needSubs = want('people') || want('feed');
5429
+ let subs = needSubs ? await listSubagentStatusBySession(ctx, peopleSid, knownIds) : [];
5350
5430
  // 归属会话拿不到人时退回请求会话(否则面板整块空掉),但**如实标注**这不是归属会话的人。
5351
- if (want('people') && !subs.length && peopleSid !== sid) {
5431
+ if (needSubs && !subs.length && peopleSid !== sid) {
5352
5432
  const fallback = await listSubagentStatusBySession(ctx, sid, knownIds);
5353
5433
  if (fallback.length) { subs = fallback; peopleSid = sid; }
5354
5434
  }
@@ -5463,6 +5543,7 @@ export function apply(ctx, config) {
5463
5543
  if (want('people')) sel.members = enrichMembers(sel.runId, sel.roles, sel.members, subById, sel.agents);
5464
5544
  // cost/model plan: expose each role's planned model so the overlay
5465
5545
  // shows the scheme even before dispatch (live model wins once running).
5546
+ mark('people-enrich');
5466
5547
  const modelPlan = sel.models || modelPlanFor(sel.roles || []);
5467
5548
  for (const role of Object.keys(sel.members)) {
5468
5549
  const m = sel.members[role];
@@ -5514,13 +5595,19 @@ export function apply(ctx, config) {
5514
5595
  }));
5515
5596
  sel.tasksEmpty = true;
5516
5597
  }
5598
+ mark('agents');
5517
5599
  // Live per-agent activity feed (Qoder-style): last tool events
5518
5600
  // per subagent session, keyed by agent id.
5519
5601
  // 只在 feed 分节里收集(它按子会话遍历活动缓存;summary 首屏不需要)。
5602
+ // 上限:agent 数封顶 MAX_FEED_AGENTS,被截断的条数如实进 degraded。
5520
5603
  const feed = {};
5521
- if (want('feed')) for (const s of subs) {
5522
- const arr = ACTIVITY.get(s.id);
5523
- if (arr && arr.length) feed[s.id] = arr.slice(-12);
5604
+ if (want('feed')) {
5605
+ const feedTargets = subs.slice(0, MAX_FEED_AGENTS);
5606
+ if (subs.length > MAX_FEED_AGENTS) degraded.push('feed:' + (subs.length - MAX_FEED_AGENTS));
5607
+ for (const s of feedTargets) {
5608
+ const arr = ACTIVITY.get(s.id);
5609
+ if (arr && arr.length) feed[s.id] = arr.slice(-12);
5610
+ }
5524
5611
  }
5525
5612
  sel.feed = feed;
5526
5613
  // 用同一批 feed 事件回填 lastEventAt(不额外读日志)
@@ -5549,13 +5636,20 @@ export function apply(ctx, config) {
5549
5636
  if (fp.selfBuilt) sel.violations = sel.violations.concat([`非脚手架 run(疑似绕过 /team 手写):缺 ${fp.missing.join('、')}`]);
5550
5637
  } catch { /* best-effort */ }
5551
5638
  try { sel.warnings = checkKindWarnings(taskList(sel.tasks)); } catch { sel.warnings = []; }
5639
+ mark('assemble');
5552
5640
  // Live monitoring: RUN.log feed (what just happened) + git
5553
5641
  // changed files (what is being touched right now).
5554
5642
  // 只在 artifacts 分节里读(`liveFiles` 要跑 git、`runLogTail` 要读日志尾部)。
5555
5643
  if (want('artifacts')) {
5556
- try { sel.logTail = await runLogTail(join(sel.workspace, 'team', sel.runId)); } catch { sel.logTail = ''; }
5557
- try { sel.files = await liveFiles(sel.workspace, sel.runId); } catch { sel.files = []; }
5644
+ const withinArtifacts = () => (Date.now() - peopleStartedAt) < ARTIFACTS_DEADLINE_MS;
5645
+ if (!withinArtifacts()) degraded.push('artifacts:deadline');
5646
+ else {
5647
+ try { sel.logTail = await runLogTail(join(sel.workspace, 'team', sel.runId)); } catch { sel.logTail = ''; }
5648
+ if (!withinArtifacts()) degraded.push('files:deadline');
5649
+ else { try { sel.files = await liveFiles(sel.workspace, sel.runId); } catch { sel.files = []; } }
5650
+ }
5558
5651
  }
5652
+ mark('artifacts');
5559
5653
  // **GET 不写 run 工件**(2026-09-13 修):这里原先每 ≤5 分钟触发一次
5560
5654
  // `autoAggregate`(`Date.now() - lastAggAt > 5*60*1000`),于是浮层每 3 秒的轮询
5561
5655
  // 会**周期性改写 `team/METRICS.md` 与 `team/LEARNINGS.md`** —— 而 `LEARNINGS.md`
@@ -66,13 +66,14 @@ export function runScopedTarget(absPath, teamRootAbs) {
66
66
  const parts = abs.slice(root.length + sep.length).split(sep).filter(Boolean);
67
67
  if (parts.length !== 2) return null;
68
68
  const [runId, base] = parts;
69
- if (base === 'TASKS.json') return { runId, kind: 'tasks', abs };
70
- if (base === 'SPEC.md') return { runId, kind: 'spec', abs };
69
+ // `base`(文件名)随 target 一起返回:R1 工件归属门禁要靠它查表(见 `lib/artifact-ownership.js`)。
70
+ if (base === 'TASKS.json') return { runId, kind: 'tasks', base, abs };
71
+ if (base === 'SPEC.md') return { runId, kind: 'spec', base, abs };
71
72
  // 其余 run 内文件:**看得见,但不拦**(`violationsFor` 对非 tasks/spec 一律返回空)。
72
73
  // 为什么现在要「看见」它们(2026-09-13,设计稿 §十二 第 3 步):并发写留痕需要观察**所有** run 工件 ——
73
74
  // 而实测那次并发事故发生在 `CONTRACT.md` 上(T29「CONTRACT 单写者收口」),恰恰不是这两份受门禁保护的文件。
74
75
  // ⚠️ 这是**只读扩展**:对 tasks/spec 的判定与拦截行为**逐字未变**。
75
- return { runId, kind: 'other', abs };
76
+ return { runId, kind: 'other', base, abs };
76
77
  }
77
78
 
78
79
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yangdcm/dsh-expert-team",
3
- "version": "1.3.13",
3
+ "version": "1.3.15",
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",
@@ -88,7 +88,7 @@
88
88
  "check:name": "node scripts/rename-package.mjs --check",
89
89
  "test:regression": "node regression.test.mjs",
90
90
  "test:e2e": "node e2e.test.mjs",
91
- "test:all": "node bootstrap.test.mjs && node host-settings.test.mjs && node smoke.test.mjs && node regression.test.mjs && node e2e.test.mjs && node flow.test.mjs && node plan-decide.test.mjs && node plan-discard.test.mjs && node models-honesty.test.mjs && node evidence-gate.test.mjs && node sync-gate.test.mjs && node artifact-writer.test.mjs && node dag.test.mjs && node metrics.test.mjs && node broken-chain.test.mjs && node schema-warn.test.mjs && node template-copy.test.mjs && node task-binding.test.mjs && node role-merge.test.mjs && node wf-recovery.test.mjs && node panorama.test.mjs && node run-ownership.test.mjs && node role-identity.test.mjs && node command-parse.test.mjs && node member-registry.test.mjs && node schema-check.test.mjs && node run-health.test.mjs && node cordis-fs-port.test.mjs && node scaffold-fingerprint.test.mjs && node preset-lint.test.mjs && node stranded-tasks.test.mjs && node agent-scope-guard.test.mjs && node schema-warn-noise.test.mjs && node tool-card-status.test.mjs && node dag-status.test.mjs && node capacity-limits.test.mjs && node task-cas.test.mjs && node write-lock.test.mjs && node card-stall.test.mjs && node multi-session-and-confirm.test.mjs && node standing-rules.test.mjs && node scope-overlap.test.mjs && node task-cancel-protect.test.mjs && node wait-run.test.mjs && node rework-loop.test.mjs && node client-css-integrity.test.mjs && node interception.test.mjs && node mutation-catalog.test.mjs && node write-bypass-ratchet.test.mjs && node asi-hazards.test.mjs && node dispatch-contract.test.mjs && node closure-ledger.test.mjs && node rework-nature.test.mjs && node loop-guard.test.mjs && node vocab-consistency.test.mjs && node state-no-write.test.mjs && node routes-shared.test.mjs && node metrics-render.test.mjs && node metrics-collect.test.mjs && node first-runnable.test.mjs && node closing-budget.test.mjs && node scan-single-source.test.mjs && node tier.test.mjs && node dispatch-ledger.test.mjs && node tier-gate.test.mjs && node tier-badge.test.mjs && node validate-module.test.mjs && node settings.test.mjs && node settings-page.test.mjs && node policy.test.mjs && node phase-accounting.test.mjs && node log-parse-module.test.mjs && node command-parse-module.test.mjs && node authority.test.mjs && node write-tracer.test.mjs && node token-accounting.test.mjs && node lead-toolface.test.mjs && node settings-consumers.test.mjs && node settings-wiring.test.mjs && node state-perf-guard.test.mjs && node state-sections.test.mjs && node artifact-ownership.test.mjs",
91
+ "test:all": "node bootstrap.test.mjs && node host-settings.test.mjs && node smoke.test.mjs && node regression.test.mjs && node e2e.test.mjs && node flow.test.mjs && node plan-decide.test.mjs && node plan-discard.test.mjs && node models-honesty.test.mjs && node evidence-gate.test.mjs && node sync-gate.test.mjs && node artifact-writer.test.mjs && node dag.test.mjs && node metrics.test.mjs && node broken-chain.test.mjs && node schema-warn.test.mjs && node template-copy.test.mjs && node task-binding.test.mjs && node role-merge.test.mjs && node wf-recovery.test.mjs && node panorama.test.mjs && node run-ownership.test.mjs && node role-identity.test.mjs && node command-parse.test.mjs && node member-registry.test.mjs && node schema-check.test.mjs && node run-health.test.mjs && node cordis-fs-port.test.mjs && node scaffold-fingerprint.test.mjs && node preset-lint.test.mjs && node stranded-tasks.test.mjs && node agent-scope-guard.test.mjs && node schema-warn-noise.test.mjs && node tool-card-status.test.mjs && node dag-status.test.mjs && node capacity-limits.test.mjs && node task-cas.test.mjs && node write-lock.test.mjs && node card-stall.test.mjs && node multi-session-and-confirm.test.mjs && node standing-rules.test.mjs && node scope-overlap.test.mjs && node task-cancel-protect.test.mjs && node wait-run.test.mjs && node rework-loop.test.mjs && node client-css-integrity.test.mjs && node interception.test.mjs && node mutation-catalog.test.mjs && node write-bypass-ratchet.test.mjs && node asi-hazards.test.mjs && node dispatch-contract.test.mjs && node closure-ledger.test.mjs && node rework-nature.test.mjs && node loop-guard.test.mjs && node vocab-consistency.test.mjs && node state-no-write.test.mjs && node routes-shared.test.mjs && node metrics-render.test.mjs && node metrics-collect.test.mjs && node first-runnable.test.mjs && node closing-budget.test.mjs && node scan-single-source.test.mjs && node tier.test.mjs && node dispatch-ledger.test.mjs && node tier-gate.test.mjs && node tier-badge.test.mjs && node validate-module.test.mjs && node settings.test.mjs && node settings-page.test.mjs && node policy.test.mjs && node phase-accounting.test.mjs && node log-parse-module.test.mjs && node command-parse-module.test.mjs && node authority.test.mjs && node write-tracer.test.mjs && node token-accounting.test.mjs && node lead-toolface.test.mjs && node settings-consumers.test.mjs && node settings-wiring.test.mjs && node state-perf-guard.test.mjs && node state-sections.test.mjs && node artifact-ownership.test.mjs && node subagent-bar.test.mjs && node r1-ownership-gate.test.mjs",
92
92
  "test:toolcard": "node tool-card-status.test.mjs",
93
93
  "test:flow": "node flow.test.mjs",
94
94
  "test:decide": "node plan-decide.test.mjs",
@@ -87,7 +87,7 @@ whenToUse: 用户用 /team 发起、要求组建「专家团」,**或(自动
87
87
 
88
88
  团队通过 `<run-dir>/` 下的工件共享上下文,而不是互相看对方的完整对话历史。规则:
89
89
 
90
- - **R1(唯一权威表述 · 本节是唯一权威处,其它文件只许引用、不得另写定义)**:**run 工件一律由产出它的角色自己 `write` 到 `<run-dir>/`**;角色只回 `path` + 摘要 + `verdict`;lead **没有 `write` 工具,只读工件做门控与裁决**;交付时用 `dsh_im_return_file` 把关键工件发给用户。
90
+ - **R1(唯一权威表述 · 本节是唯一权威处,其它文件只许引用、不得另写定义)**:**run 工件一律由产出它的角色自己 `write` 到 `<run-dir>/`**;角色只回 `path` + 摘要 + `verdict`;lead **没有 `write` 工具,只读工件做门控与裁决**;交付时用 `dsh_im_return_file` 把关键工件发给用户。 **机读真源**:`lib/artifact-ownership.js` 的 `ARTIFACT_OWNERS`("哪份工件归谁"只有这一份;协议文本与它不一致时**以代码为准**)。 **门禁强度(如实说,别当成"不可能违反")**:`write`/`edit` 通道有**硬门禁** —— 挂在宿主 `tools/pre-execute`(**写盘之前**),判据是**创建放行 / 覆写别人的工件当场拒绝**;但 preset 里持有 `bash` 的角色(backend/frontend/researcher/qa/dba/devops)理论上可用重定向绕过,首版**刻意不对 bash 参数做启发式检查**(易误伤)。
91
91
  - 每个角色**只读它上一阶段产出的工件 + 直接输入**,结论写进 `<run-dir>/` 的对应工件(写法见上一条 R1),返回值只带 `path` + 摘要 + `verdict` 这些小字段。
92
92
  - **任务看板(唯一名 = `任务看板.md`)**:`team/<run-id>/任务看板.md`(任务计划 + 状态表 + 当前阶段)**由你指派的一名成员维护**(有 `write` 的那个,通常 pm;每个阶段结束时更新一次),把「任务安排 + 执行进度」写进该工件(工件以 `path` 可核验,交付时由 lead 用 `dsh_im_return_file` 发给用户;「聊天框可点击产出文件行」的机制未独立证实,不作为承诺)。
93
93
  - **为什么要角色落盘**:工件必须成为**产出者本轮的产出文件** —— `path` 可核验、可复算,lead 只按 `path` 读盘做门控与裁决;交付时由 lead 用 `dsh_im_return_file` 把关键工件直接发给用户。⚠️ **如实标注**:(该"只认 lead 的 write"机制未独立证实,见 `RESEARCH.md` §7),本协议不依赖它。
@@ -109,7 +109,7 @@ persist 模式下,`members` 记录每个角色的可继续子 agent id,供 r
109
109
  "mode": "one-shot | persist",
110
110
  "deliverable": "code+artifacts | artifacts-only",
111
111
  "coverage": [ { "constraint": "<用户约束>", "tasks": ["be-1", "fe-2"] } ],
112
- "members": ["backend:<subagentId>", "frontend:<subagentId>"],
112
+ "members": ["<agentSessionId>:backend", "<agentSessionId>:frontend"], // 形状 = <agentId>:<role>(真源:lib/command.js 的 roleOfAgent / membersFromState)
113
113
  "updatedAt": "<iso>"
114
114
  }
115
115
  ```