@yangdcm/dsh-expert-team 1.3.15 → 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 +72 -0
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/lib/artifact-redirect-watch.js +118 -0
- package/lib/command.js +238 -24
- package/lib/effort-preflight.js +135 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -731,3 +731,75 @@ DAG 并行扇出、角色 chip、工件脚注全部保留。
|
|
|
731
731
|
- `/team` 命令面:一次性组队 / 持久化活团队 / 仅工件 / 先确认后开工 / 流程档位 / 画布 / 代码索引 /
|
|
732
732
|
自学习 / 配额 / 冷启动清算
|
|
733
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
|
@@ -341,7 +341,9 @@ Full command list (`/team codeindex` code index, `/team limit` quotas, `/team se
|
|
|
341
341
|
|
|
342
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`).
|
|
343
343
|
|
|
344
|
-
**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`.
|
|
345
347
|
|
|
346
348
|
## Glossary
|
|
347
349
|
|
package/README.md
CHANGED
|
@@ -297,7 +297,9 @@ dsh plugin --profile web add dshmarket
|
|
|
297
297
|
|
|
298
298
|
**不切「专家团模式」preset 也能用吗?** 能。`/team` 是 host 平面命令,任何预设下都能跑;此时退回通用 `subagent`(角色人设写进 prompt),少的是配置层的边界保证(`toolFilter` / `maxDepth: 1`)。
|
|
299
299
|
|
|
300
|
-
**浮层/画布打开很慢?** 见[排障](#排障)
|
|
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` 即可。
|
|
301
303
|
|
|
302
304
|
## 术语 / Glossary
|
|
303
305
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R1 绕过检测:`bash` / `pwsh` 里"重定向写进 run 工件"的**只报不拦**观测器(2026-09-15)。
|
|
3
|
+
*
|
|
4
|
+
* ── 为什么需要它(而不是把它也做成门禁)────────────────────────────────────
|
|
5
|
+
* R1 硬门禁(`lib/artifact-ownership.js`)只覆盖 **`write` / `edit`** 通道。preset 里
|
|
6
|
+
* `backend` / `frontend` / `researcher` / `qa` / `dba` / `devops` **持 `bash`**,理论上可以
|
|
7
|
+
* `cat > SPEC.md` 绕过门禁。**静默绕过**违背本仓纪律("失败要出声"),但**首版刻意不做阻断**:
|
|
8
|
+
* · `bash` 的写目标可以是重定向、`tee`、变量、子命令替换 —— 静态判断**不可靠**;
|
|
9
|
+
* · 一旦误判,代价是"把正常命令判成越权"(比漏报更伤,本仓有前车之鉴:门禁变成故障源)。
|
|
10
|
+
* ⇒ 折中:**只留痕**,用真实运行里的命中频率来决定以后要不要收紧。
|
|
11
|
+
*
|
|
12
|
+
* ── 口径(保守,宁可漏报)────────────────────────────────────────────────
|
|
13
|
+
* 只有同时满足下面三条才算命中:
|
|
14
|
+
* ① 工具是 `bash` / `pwsh`;
|
|
15
|
+
* ② 命令里出现**明显的写目标**:`>` / `>>` / `tee [-a]` 之后紧跟一个路径 token;
|
|
16
|
+
* ③ 该路径解析后**恰好是** `<team 根>/<runId>/<文件名>`(沿用 `runScopedTarget` 的两段口径,
|
|
17
|
+
* 更深的子目录 / 工作区代码 / `/tmp` / `/dev/null` 一律不命中),且文件名属于**已知工件**。
|
|
18
|
+
*
|
|
19
|
+
* 本监听器**绝不**改动结果、**绝不**抛错(沿用 `interception.js` 的纪律:
|
|
20
|
+
* 监听器一旦抛错会被宿主收敛为工具失败,2026-09-12 出过全工具瘫痪事故)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { resolve, basename } from 'node:path';
|
|
24
|
+
import { runScopedTarget } from './interception.js';
|
|
25
|
+
|
|
26
|
+
/** shell 类工具(R1 门禁覆盖不到的那些)。 */
|
|
27
|
+
export const SHELL_WRITE_TOOLS = Object.freeze(new Set(['bash', 'pwsh']));
|
|
28
|
+
|
|
29
|
+
/** 去掉包裹的引号(`"a b"` / `'a b'`),保留原样其余内容。 */
|
|
30
|
+
function unquote(tok) {
|
|
31
|
+
const s = String(tok || '').trim();
|
|
32
|
+
if (s.length >= 2 && ((s[0] === '"' && s[s.length - 1] === '"') || (s[0] === "'" && s[s.length - 1] === "'"))) {
|
|
33
|
+
return s.slice(1, -1);
|
|
34
|
+
}
|
|
35
|
+
return s;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 从命令串里提取**候选写目标**(纯函数、保守)。
|
|
40
|
+
* 只认两种形态:`>` / `>>`(可带 fd 前缀如 `2>`)与 `tee [-a] <path>`。
|
|
41
|
+
* 变量 / 命令替换 / 通配符**不解析**(拿不准就漏报 —— 这是本文件的既定口径)。
|
|
42
|
+
*/
|
|
43
|
+
export function extractWriteTargets(command) {
|
|
44
|
+
const cmd = String(command || '');
|
|
45
|
+
const out = [];
|
|
46
|
+
const push = (raw) => {
|
|
47
|
+
const t = unquote(raw);
|
|
48
|
+
if (!t) return;
|
|
49
|
+
if (t === '/dev/null' || t === '/dev/stdout' || t === '/dev/stderr') return;
|
|
50
|
+
if (/[$`*?]/.test(t)) return; // 含变量/替换/通配 ⇒ 不解析(宁可漏报)
|
|
51
|
+
if (t.startsWith('&')) return; // `&>` 的 fd 形式交给下一条规则
|
|
52
|
+
out.push(t);
|
|
53
|
+
};
|
|
54
|
+
// `>` / `>>`(含 `1>`, `2>>`, `&>`),后跟一个 token
|
|
55
|
+
const redir = /(?:\d?&?>>?|&>>?)\s*("[^"]+"|'[^']+'|[^\s;|&<>()]+)/g;
|
|
56
|
+
let m;
|
|
57
|
+
while ((m = redir.exec(cmd)) !== null) push(m[1]);
|
|
58
|
+
// `tee [-a] <path>`:只取**第一个**路径参数(`tee a b` 少见,且多写不算漏报)
|
|
59
|
+
const tee = /(?:^|[\s;|&(])tee(?:\s+-[a-zA-Z]+)*\s+("[^"]+"|'[^']+'|[^\s;|&<>()]+)/g;
|
|
60
|
+
while ((m = tee.exec(cmd)) !== null) push(m[1]);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 判定命令里是否**明显**写向 `<teamRoot>/<runId>/<已知工件>`。
|
|
66
|
+
* @returns {{abs:string, runId:string, base:string}|null}
|
|
67
|
+
*/
|
|
68
|
+
export function detectArtifactRedirect(command, { cwd, teamRoot, knownArtifacts } = {}) {
|
|
69
|
+
if (!cwd || !teamRoot) return null;
|
|
70
|
+
const known = knownArtifacts instanceof Set ? knownArtifacts : new Set(knownArtifacts || []);
|
|
71
|
+
if (!known.size) return null;
|
|
72
|
+
for (const raw of extractWriteTargets(command)) {
|
|
73
|
+
let abs;
|
|
74
|
+
try { abs = resolve(cwd, raw); } catch { continue; }
|
|
75
|
+
const scoped = runScopedTarget(abs, teamRoot); // 只认 `<teamRoot>/<runId>/<文件名>` 两段
|
|
76
|
+
if (!scoped) continue;
|
|
77
|
+
const base = basename(abs);
|
|
78
|
+
if (!known.has(base)) continue;
|
|
79
|
+
return { abs, runId: scoped.runId, base };
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 创建 `tools/post-execute` 监听器(**纯观测**:不改结果、不阻断、不抛错)。
|
|
86
|
+
* deps:`knownArtifacts`(已知工件文件名集合,由调用方从**单一真源**传入)、
|
|
87
|
+
* `cwdFor(exec)`、`teamRootFor(cwd)`、`onEvent(type, payload)`、可选 `warn(line)`。
|
|
88
|
+
*/
|
|
89
|
+
export function createArtifactRedirectWatcher({ knownArtifacts, cwdFor, teamRootFor, onEvent, warn } = {}) {
|
|
90
|
+
const known = knownArtifacts instanceof Set ? knownArtifacts : new Set(knownArtifacts || []);
|
|
91
|
+
const emit = typeof onEvent === 'function' ? onEvent : () => {};
|
|
92
|
+
const say = typeof warn === 'function' ? warn : (line) => console.warn(line);
|
|
93
|
+
return async function artifactRedirectWatcher(exec, result, next) {
|
|
94
|
+
// 与 `interception.js` 同契约:签名是 (exec, result, next);next 不是函数就降级为不干涉。
|
|
95
|
+
if (typeof next !== 'function') return { kind: 'accept' };
|
|
96
|
+
const downstream = await next();
|
|
97
|
+
try {
|
|
98
|
+
const name = String((exec && exec.name) || '');
|
|
99
|
+
if (!SHELL_WRITE_TOOLS.has(name)) return downstream;
|
|
100
|
+
const args = (exec && exec.arguments) || {};
|
|
101
|
+
const command = String(args.command || args.cmd || args.script || '');
|
|
102
|
+
if (!command) return downstream;
|
|
103
|
+
const cwd = typeof cwdFor === 'function' ? cwdFor(exec) : '';
|
|
104
|
+
if (!cwd) return downstream;
|
|
105
|
+
const teamRoot = typeof teamRootFor === 'function' ? teamRootFor(cwd) : '';
|
|
106
|
+
if (!teamRoot) return downstream;
|
|
107
|
+
const hit = detectArtifactRedirect(command, { cwd, teamRoot, knownArtifacts: known });
|
|
108
|
+
if (!hit) return downstream;
|
|
109
|
+
try {
|
|
110
|
+
say(`[expert-team] R1 绕过检测:${name} 写入 run 工件 ${hit.abs}(write/edit 门禁覆盖不到;仅留痕,不阻断)`);
|
|
111
|
+
} catch { /* 打印失败也不能影响工具调用 */ }
|
|
112
|
+
emit('artifact-redirect-bypass', { tool: name, runId: hit.runId, base: hit.base });
|
|
113
|
+
} catch (e) {
|
|
114
|
+
try { emit('artifact-redirect-watch-error', { error: String((e && e.message) || e) }); } catch { /* ignore */ }
|
|
115
|
+
}
|
|
116
|
+
return downstream;
|
|
117
|
+
};
|
|
118
|
+
}
|
package/lib/command.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { randomUUID } from 'node:crypto';
|
|
16
16
|
import { execFile as execFileCb } from 'node:child_process';
|
|
17
|
-
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
|
|
17
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
18
18
|
import { readFileSync } from 'node:fs';
|
|
19
19
|
import { promisify } from 'node:util';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
@@ -22,9 +22,19 @@ 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
|
+
import { createOwnershipGate, ARTIFACT_OWNERS } from './artifact-ownership.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* **run 模板文件清单**(单一真源)。
|
|
29
|
+
* 谁在用:① 建 run 时按它复制 `skills/expert-team/assets/templates/`;② R1 绕过检测
|
|
30
|
+
* (`bash` 里重定向写 run 工件 ⇒ 只留痕)需要"已知工件名"集合。**不要再复制一份** ——
|
|
31
|
+
* 本仓为"同一事实多份定义"专门有 `vocab-consistency` / `artifact-ownership` 断言。
|
|
32
|
+
*/
|
|
33
|
+
export const ARTIFACT_TEMPLATES = Object.freeze(['TASK.md', 'ROSTER.json', 'STATE.json', '任务看板.md', 'SPEC.md', 'PLAN.md', 'RESEARCH.md', 'TASKS.json', 'REVIEW.md', 'TEST.md', 'SUMMARY.md', 'RETRO.md', 'AUTHORITY.md']);
|
|
26
34
|
// 设计稿 §十二 第 3 步:并发写留痕(只记录、只告警,绝不阻断)。
|
|
27
35
|
import { createWriteTracer, formatConflict } from './write-tracer.js';
|
|
36
|
+
import { createEffortPreflight, effortPreflightPlan, declaredEffortsFromPresetSource, EFFORT_PROBE_DELAYS_MS } from './effort-preflight.js';
|
|
37
|
+
import { createArtifactRedirectWatcher } from './artifact-redirect-watch.js';
|
|
28
38
|
import { createLoopGuard } from './loop-guard.js';
|
|
29
39
|
// C 线第 17 项:派工即回写(派工成功后由**代码**把任务置 in_progress + owner,不靠模型记得改)。
|
|
30
40
|
import { createDispatchLedger } from './dispatch-ledger.js';
|
|
@@ -1059,6 +1069,46 @@ function scheduleOptionalPluginCheck(ctx, delays) {
|
|
|
1059
1069
|
}
|
|
1060
1070
|
return timers;
|
|
1061
1071
|
}
|
|
1072
|
+
// ── 会话模型 effort 预检(2026-09-15,D 项)────────────────────────────────────
|
|
1073
|
+
// 只告警、不阻断:宿主在**任何网络 I/O 之前**就会因"模型未声明 reasoningEfforts"拒绝带 effort 的
|
|
1074
|
+
// 角色派工(`dsh-llm` 的 resolveCallWithInfo)。插件改不了宿主行为,只能**提前说清** + 给出修法。
|
|
1075
|
+
// 纪律:读不到 ⇒ 静默;一次加载最多一行;服务晚挂则就绪后重探(同 OPTIONAL_* 那套)。
|
|
1076
|
+
let EFFORT_PREFLIGHT = null;
|
|
1077
|
+
let EFFORT_TIMERS = [];
|
|
1078
|
+
function scheduleEffortPreflight(ctx, delays) {
|
|
1079
|
+
const plan = Array.isArray(delays) ? delays : EFFORT_PROBE_DELAYS_MS;
|
|
1080
|
+
// 实例**按 ctx 创建**(不搞模块级 ctx 全局):读模型能力的入口在闭包里直接捕获 ctx。
|
|
1081
|
+
EFFORT_PREFLIGHT = createEffortPreflight({
|
|
1082
|
+
readPresetSource: async () => readFile(new URL('../presets/expert-team/agent.cordis.yml', import.meta.url), 'utf8'),
|
|
1083
|
+
readSelection: (c) => {
|
|
1084
|
+
const svc = c && typeof c.get === 'function' ? c.get('agentDefaultModel') : null;
|
|
1085
|
+
const sel = svc && typeof svc.currentSelection === 'function' ? svc.currentSelection() : null;
|
|
1086
|
+
return sel && sel.provider && sel.model ? { provider: String(sel.provider), model: String(sel.model) } : null;
|
|
1087
|
+
},
|
|
1088
|
+
readModelInfo: async (provider, model) => {
|
|
1089
|
+
const llm = ctx && typeof ctx.get === 'function' ? ctx.get('llm') : null;
|
|
1090
|
+
if (!llm || typeof llm.resolveModelInfo !== 'function') throw new Error('llm.resolveModelInfo 不可用');
|
|
1091
|
+
return llm.resolveModelInfo(provider, model);
|
|
1092
|
+
},
|
|
1093
|
+
onEvent: (type, payload) => {
|
|
1094
|
+
try { console.warn('[expert-team] ' + type, JSON.stringify(payload)); } catch { /* 观测失败不影响加载 */ }
|
|
1095
|
+
},
|
|
1096
|
+
});
|
|
1097
|
+
plan.forEach((ms, i) => {
|
|
1098
|
+
const t = setTimeout(() => { void EFFORT_PREFLIGHT(ctx, { isLast: i === plan.length - 1 }); }, ms);
|
|
1099
|
+
if (t && typeof t.unref === 'function') t.unref();
|
|
1100
|
+
EFFORT_TIMERS.push(t);
|
|
1101
|
+
});
|
|
1102
|
+
// 事件驱动兜底:llm / agentDefaultModel 任一晚挂,服务一出现就立刻重探(不出现则永不触发)。
|
|
1103
|
+
for (const svc of ['llm', 'agentDefaultModel']) {
|
|
1104
|
+
try { if (ctx && typeof ctx.inject === 'function') ctx.inject([svc], () => { void EFFORT_PREFLIGHT(ctx, { isLast: true }); }); } catch { /* best-effort */ }
|
|
1105
|
+
}
|
|
1106
|
+
return EFFORT_TIMERS;
|
|
1107
|
+
}
|
|
1108
|
+
function _resetEffortPreflight() {
|
|
1109
|
+
for (const t of EFFORT_TIMERS) { try { clearTimeout(t); } catch { /* ignore */ } }
|
|
1110
|
+
EFFORT_TIMERS = [];
|
|
1111
|
+
}
|
|
1062
1112
|
function _resetOptionalHintOnce() {
|
|
1063
1113
|
OPTIONAL_HINT_DONE = false;
|
|
1064
1114
|
for (const t of OPTIONAL_PROBE_TIMERS) { try { clearTimeout(t); } catch { /* ignore */ } }
|
|
@@ -1149,7 +1199,7 @@ async function scaffoldRun(cwd, mode) {
|
|
|
1149
1199
|
// 测试里的 14 项)**不是同一个集合**:那边多一个 `RUN.log.md`(由 run log 写入器创建)、这边就是
|
|
1150
1200
|
// 模板全集。两者关系由 `artifact-ownership.test.mjs` 的「工件清单一致」断言钉住,避免下次又被
|
|
1151
1201
|
// 当成同一件事去"对齐"(2026-09-15 阶段 C 的口径核查)。
|
|
1152
|
-
const templates =
|
|
1202
|
+
const templates = ARTIFACT_TEMPLATES;
|
|
1153
1203
|
for (const t of templates) {
|
|
1154
1204
|
try {
|
|
1155
1205
|
await ARTIFACT.must(join(runDir, t), await readFile(new URL(t, TEMPLATES_SRC)));
|
|
@@ -1685,6 +1735,44 @@ function deriveMemberEntries(subById, existing) {
|
|
|
1685
1735
|
}
|
|
1686
1736
|
return { entries: out, changed };
|
|
1687
1737
|
}
|
|
1738
|
+
/**
|
|
1739
|
+
* "这串东西像不像 session id"的判据 —— **按角色名精确排除**,不按长度猜。
|
|
1740
|
+
*
|
|
1741
|
+
* 为什么不用长度:可读性好的测试/宿主可能用短 id(`ended-x`、`live-1`),长度阈值会**误伤它们**
|
|
1742
|
+
* ⇒ 把真 id 过滤掉,那才是真丢数据。而"角色名"是可以精确判定的:
|
|
1743
|
+
* · 等于某个角色 id(`backend`)⇒ 排除;
|
|
1744
|
+
* · 首个 `-` 段就是角色 id(`frontend-F4` / `reviewer-R1`,角色名带轮次后缀)⇒ 排除。
|
|
1745
|
+
* 真实 session id 是 UUID(首段 8 位十六进制)或任意非角色名 ⇒ 保留。
|
|
1746
|
+
* 只用于"过滤出真 id",**不**用于判定归属(归属看 `STATE.members` 与 `artifact-ownership`)。
|
|
1747
|
+
*/
|
|
1748
|
+
const ROLE_NAME_SET = new Set(DEFAULT_ROLES.map((r) => String(r).toLowerCase()));
|
|
1749
|
+
export function isAgentIdLike(v) {
|
|
1750
|
+
const s = String(v || '').trim();
|
|
1751
|
+
if (!s || /\s/.test(s)) return false;
|
|
1752
|
+
const lower = s.toLowerCase();
|
|
1753
|
+
if (ROLE_NAME_SET.has(lower)) return false;
|
|
1754
|
+
const head = lower.split('-')[0];
|
|
1755
|
+
if (head && ROLE_NAME_SET.has(head)) return false;
|
|
1756
|
+
return true;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* `STATE.members` → **只含 agent id** 的清单。
|
|
1761
|
+
*
|
|
1762
|
+
* 为什么必须单独有它(2026-09-15 性能修复 #5,真机 profile 定位):`membersFromState().byRole`
|
|
1763
|
+
* 这个 Map **同时**塞了 `role→agentId` 与 `agentId→role` 两种键 ⇒ 直接取 `.values()` 会**混进角色名**
|
|
1764
|
+
* (`backend` / `reviewer` …)。而角色名永远不可能是某个会话 header 的 id ⇒
|
|
1765
|
+
* `missingIds` **永久非空** ⇒ `/state` 每次都认为"还有人查不到" ⇒ **每个请求都重新枚举
|
|
1766
|
+
* 475 个 artifact**(真机 subs 段实测 2.4 s),`SUB_HEADER_MEMO` 因此形同虚设。
|
|
1767
|
+
*
|
|
1768
|
+
* ⇒ 需要 id 的地方一律走这里,**不要**再对 `byRole` 取 `.values()`。
|
|
1769
|
+
*/
|
|
1770
|
+
export function memberAgentIds(members) {
|
|
1771
|
+
const { byRole } = membersFromState(members);
|
|
1772
|
+
const out = [];
|
|
1773
|
+
for (const k of byRole.keys()) if (isAgentIdLike(k)) out.push(k);
|
|
1774
|
+
return out;
|
|
1775
|
+
}
|
|
1688
1776
|
function membersFromState(members) {
|
|
1689
1777
|
const byRole = new Map(), names = new Map();
|
|
1690
1778
|
for (const item of Array.isArray(members) ? members : []) {
|
|
@@ -1713,6 +1801,9 @@ function buildRoleSubMap(subs, stateMembers, wfLabels) {
|
|
|
1713
1801
|
if (precise.byRole.size) {
|
|
1714
1802
|
const m = new Map();
|
|
1715
1803
|
for (const [role, id] of precise.byRole) {
|
|
1804
|
+
// ⚠️ byRole 是**双键** Map(role→id 与 id→role 都在)⇒ 这里必须跳过 id 形态的键,
|
|
1805
|
+
// 否则会把 "agentId→sub" 也当成一条角色映射塞进去(2026-09-15 与 subs 段同一根因)。
|
|
1806
|
+
if (isAgentIdLike(role)) continue;
|
|
1716
1807
|
const hit = subs.find((s) => String(s.id || '') === id);
|
|
1717
1808
|
if (hit) m.set(role, hit);
|
|
1718
1809
|
}
|
|
@@ -2819,24 +2910,123 @@ async function registeredWorkspaces() {
|
|
|
2819
2910
|
}
|
|
2820
2911
|
|
|
2821
2912
|
// List runs under a single workspace; each has a `workspace`.
|
|
2913
|
+
// ── run 列表的**逐 run 戳缓存**(2026-09-15 性能修复 #6:冷启动)────────────────────
|
|
2914
|
+
// 实测:重启后**第一次** `?section=summary` 的 `runs+select` 段 = 876 ms(每个 run 都要读
|
|
2915
|
+
// STATE.json + TASKS.json 再算 health/violations/owner)。而这段数据在"run 目录没动"时**完全可复用**。
|
|
2916
|
+
//
|
|
2917
|
+
// 失效键 = **每个 run 自己的** `STATE.json` 与 `TASKS.json` 的 `(mtimeMs, size)`:
|
|
2918
|
+
// · 改一次 STATE(阶段推进/任务回写)⇒ 该 run 的戳变 ⇒ 只重算**那一个** run;
|
|
2919
|
+
// · 新增 run ⇒ 缓存里没有 ⇒ 计算并写入(**新 run 一律可见**,这是功能不是可牺牲项);
|
|
2920
|
+
// · 删 run ⇒ readdir 里没有 ⇒ 不会被读出(顺带在保存时剪掉)。
|
|
2921
|
+
// ⚠️ 为什么**不能**只戳 run 目录:改文件**不会**改父目录 mtime(只有增删条目会)⇒ 那样会读到旧
|
|
2922
|
+
// 阶段/旧 done 数。戳到文件本身才是"看到的就是真的"。
|
|
2923
|
+
// 落盘($DSH_HOME/expert-team/runs-index.json)是为了**重启后第一次**也不必从零算:本进程只需
|
|
2924
|
+
// stat 校验 + 命中;没有索引文件的那一次仍要算一遍(这是它的性质,如实记档)。
|
|
2925
|
+
const RUNS_INDEX_MAX = 500;
|
|
2926
|
+
let RUNS_INDEX = null; // Map<absRunDir, {stamp, row}>
|
|
2927
|
+
let RUNS_INDEX_LOAD = null; // 载入中的 promise(同刻只读一次盘)
|
|
2928
|
+
let RUNS_INDEX_SAVE_TIMER = null;
|
|
2929
|
+
const RUNS_INDEX_STATS = { hits: 0, misses: 0, loads: 0, saves: 0, saveErrors: 0, computes: 0 };
|
|
2930
|
+
/**
|
|
2931
|
+
* 索引文件位置。可用 `DSH_EXPERT_TEAM_RUNS_INDEX` 覆盖 —— 两个用途:
|
|
2932
|
+
* ① 测试(沙箱里 `~/.dsh` 可能不可写,指到临时目录即可做完整往返验证);
|
|
2933
|
+
* ② 运维(把索引放到更快的盘/容器可写卷)。
|
|
2934
|
+
*/
|
|
2935
|
+
function runsIndexPath() {
|
|
2936
|
+
const override = String((typeof process !== 'undefined' && process.env && process.env.DSH_EXPERT_TEAM_RUNS_INDEX) || '').trim();
|
|
2937
|
+
return override || join(dshHome(), 'expert-team', 'runs-index.json');
|
|
2938
|
+
}
|
|
2939
|
+
async function loadRunsIndex() {
|
|
2940
|
+
if (RUNS_INDEX) return RUNS_INDEX;
|
|
2941
|
+
if (RUNS_INDEX_LOAD) return RUNS_INDEX_LOAD;
|
|
2942
|
+
RUNS_INDEX_STATS.loads += 1;
|
|
2943
|
+
RUNS_INDEX_LOAD = (async () => {
|
|
2944
|
+
let entries = {};
|
|
2945
|
+
try {
|
|
2946
|
+
const raw = await readFile(runsIndexPath(), 'utf8');
|
|
2947
|
+
const j = JSON.parse(raw);
|
|
2948
|
+
if (j && typeof j === 'object' && j.entries && typeof j.entries === 'object') entries = j.entries;
|
|
2949
|
+
} catch { /* 没有索引 / 坏了 ⇒ 空表(下次保存自愈)*/ }
|
|
2950
|
+
RUNS_INDEX = new Map(Object.entries(entries).filter(([, v]) => v && typeof v.stamp === 'string' && v.row && typeof v.row === 'object'));
|
|
2951
|
+
return RUNS_INDEX;
|
|
2952
|
+
})();
|
|
2953
|
+
try { return await RUNS_INDEX_LOAD; } finally { RUNS_INDEX_LOAD = null; }
|
|
2954
|
+
}
|
|
2955
|
+
function scheduleRunsIndexSave() {
|
|
2956
|
+
if (RUNS_INDEX_SAVE_TIMER) return;
|
|
2957
|
+
RUNS_INDEX_SAVE_TIMER = setTimeout(() => {
|
|
2958
|
+
RUNS_INDEX_SAVE_TIMER = null;
|
|
2959
|
+
(async () => {
|
|
2960
|
+
try {
|
|
2961
|
+
const map = RUNS_INDEX || new Map();
|
|
2962
|
+
// 剪枝:按 map 的插入顺序保留最近 RUNS_INDEX_MAX 条(Map 保序 ⇒ 旧的先丢)
|
|
2963
|
+
const entries = {};
|
|
2964
|
+
const keys = [...map.keys()];
|
|
2965
|
+
for (const k of keys.slice(Math.max(0, keys.length - RUNS_INDEX_MAX))) entries[k] = map.get(k);
|
|
2966
|
+
const tmp = runsIndexPath() + '.tmp';
|
|
2967
|
+
await mkdir(dirname(runsIndexPath()), { recursive: true });
|
|
2968
|
+
await writeFile(tmp, JSON.stringify({ version: 1, entries }), 'utf8');
|
|
2969
|
+
await rename(tmp, runsIndexPath());
|
|
2970
|
+
RUNS_INDEX_STATS.saves += 1;
|
|
2971
|
+
} catch { RUNS_INDEX_STATS.saveErrors += 1; /* 索引写不进去只是下次多算一遍,绝不影响功能 */ }
|
|
2972
|
+
})();
|
|
2973
|
+
}, 1500);
|
|
2974
|
+
if (RUNS_INDEX_SAVE_TIMER && typeof RUNS_INDEX_SAVE_TIMER.unref === 'function') RUNS_INDEX_SAVE_TIMER.unref();
|
|
2975
|
+
}
|
|
2976
|
+
/** 仅供测试:丢内存索引并复位计数(不清盘上的索引文件)。 */
|
|
2977
|
+
function _resetRunsIndex() {
|
|
2978
|
+
RUNS_INDEX = null; RUNS_INDEX_LOAD = null;
|
|
2979
|
+
if (RUNS_INDEX_SAVE_TIMER) { clearTimeout(RUNS_INDEX_SAVE_TIMER); RUNS_INDEX_SAVE_TIMER = null; }
|
|
2980
|
+
RUNS_INDEX_STATS.hits = 0; RUNS_INDEX_STATS.misses = 0; RUNS_INDEX_STATS.loads = 0;
|
|
2981
|
+
RUNS_INDEX_STATS.saves = 0; RUNS_INDEX_STATS.saveErrors = 0; RUNS_INDEX_STATS.computes = 0;
|
|
2982
|
+
}
|
|
2983
|
+
async function fileStampOf(p) {
|
|
2984
|
+
try { const st = await stat(p); return Math.round(Number(st.mtimeMs) || 0) + ':' + (Number(st.size) || 0); }
|
|
2985
|
+
catch { return null; }
|
|
2986
|
+
}
|
|
2987
|
+
/** 单个 run 的列表行:戳没变就复用缓存(落盘索引 ⇒ 重启后第一次也能命中)。 */
|
|
2988
|
+
async function cachedRunRow(ws, dir, n) {
|
|
2989
|
+
await loadRunsIndex();
|
|
2990
|
+
const statePath = join(dir, 'STATE.json');
|
|
2991
|
+
const stampState = await fileStampOf(statePath);
|
|
2992
|
+
const stampTasks = await fileStampOf(join(dir, 'TASKS.json'));
|
|
2993
|
+
const stamp = stampState + '|' + stampTasks;
|
|
2994
|
+
const rec = RUNS_INDEX.get(dir);
|
|
2995
|
+
// 缺 STATE.json 时(stampState === null)**不进缓存**:坏 run 每次如实重算,代价是一次失败的读。
|
|
2996
|
+
if (rec && stampState !== null && rec.stamp === stamp) { RUNS_INDEX_STATS.hits += 1; return { ...rec.row }; }
|
|
2997
|
+
RUNS_INDEX_STATS.misses += 1;
|
|
2998
|
+
const st0 = stampState === null ? null : await readJsonSafe(statePath);
|
|
2999
|
+
let row;
|
|
3000
|
+
if (st0) {
|
|
3001
|
+
const tasks0 = taskList(stampTasks === null ? null : await readJsonSafe(join(dir, 'TASKS.json')));
|
|
3002
|
+
const done0 = tasks0.filter((t) => ['completed', 'done'].includes(t.status)).length;
|
|
3003
|
+
const owners = new Set((tasks0.map((t) => t.owner)).filter(Boolean));
|
|
3004
|
+
const h0 = runHealth(st0);
|
|
3005
|
+
row = { runId: n, workspace: ws, phase: st0.phase, status: st0.status, updatedAt: st0.updatedAt, goal: '', done: done0, total: tasks0.length, members: owners.size, violations: checkTasks(tasks0, st0.phase, st0).length, health: h0.health, healthReason: h0.reason, ownerSession: String(st0.ownerSession || ''), ownerResolved: !!st0.ownerSession };
|
|
3006
|
+
} else {
|
|
3007
|
+
// B1:面板的 run 下拉过去同样静默跳过没有 STATE.json 的目录 —— 用户在界面上
|
|
3008
|
+
// **完全看不到**这些遗留/损坏的 run。现在如实列出并标 `health: 'broken'`。
|
|
3009
|
+
row = { runId: n, workspace: ws, phase: '', status: '', updatedAt: '', goal: '', done: 0, total: 0, members: 0, violations: 0, health: 'broken', healthReason: '缺 STATE.json(或不可解析)' };
|
|
3010
|
+
}
|
|
3011
|
+
if (stampState !== null) {
|
|
3012
|
+
RUNS_INDEX_STATS.computes += 1;
|
|
3013
|
+
RUNS_INDEX.set(dir, { stamp, row });
|
|
3014
|
+
scheduleRunsIndexSave();
|
|
3015
|
+
}
|
|
3016
|
+
return { ...row };
|
|
3017
|
+
}
|
|
2822
3018
|
async function listRunsInWorkspace(ws) {
|
|
2823
3019
|
const root0 = teamRoot(ws);
|
|
2824
|
-
let
|
|
2825
|
-
|
|
3020
|
+
let ents0 = [];
|
|
3021
|
+
// ⚠️ 只要**目录**:`team/` 根下还住着普通文件(`CODEINDEX.json`、`LEARNINGS.md`、`REPOWIKI.md`…),
|
|
3022
|
+
// 旧实现把它们也当 run 名去读 `<name>/STATE.json` ⇒ 必然失败 ⇒ 面板的 run 下拉里出现
|
|
3023
|
+
// 一串"broken run"(画布上真能看到这种条目)。run 是目录,这个判据才是对的。
|
|
3024
|
+
try { ents0 = await readdir(root0, { withFileTypes: true }); } catch { return []; }
|
|
2826
3025
|
const out0 = [];
|
|
2827
|
-
for (const
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
const done0 = tasks0.filter((t) => ['completed', 'done'].includes(t.status)).length;
|
|
2832
|
-
const owners = new Set((tasks0.map((t) => t.owner)).filter(Boolean));
|
|
2833
|
-
const h0 = runHealth(st0);
|
|
2834
|
-
out0.push({ runId: n, workspace: ws, phase: st0.phase, status: st0.status, updatedAt: st0.updatedAt, goal: '', done: done0, total: tasks0.length, members: owners.size, violations: checkTasks(tasks0, st0.phase, st0).length, health: h0.health, healthReason: h0.reason, ownerSession: String(st0.ownerSession || ''), ownerResolved: !!st0.ownerSession });
|
|
2835
|
-
} else {
|
|
2836
|
-
// B1:面板的 run 下拉过去同样静默跳过没有 STATE.json 的目录 —— 用户在界面上
|
|
2837
|
-
// **完全看不到**这些遗留/损坏的 run。现在如实列出并标 `health: 'broken'`。
|
|
2838
|
-
out0.push({ runId: n, workspace: ws, phase: '', status: '', updatedAt: '', goal: '', done: 0, total: 0, members: 0, violations: 0, health: 'broken', healthReason: '缺 STATE.json(或不可解析)' });
|
|
2839
|
-
}
|
|
3026
|
+
for (const ent of ents0) {
|
|
3027
|
+
if (!ent || ent.isDirectory?.() !== true) continue;
|
|
3028
|
+
const n = ent.name;
|
|
3029
|
+
out0.push(await cachedRunRow(ws, join(root0, n), n));
|
|
2840
3030
|
}
|
|
2841
3031
|
return out0;
|
|
2842
3032
|
}
|
|
@@ -3079,6 +3269,9 @@ function _resetSubHeaderMemo() {
|
|
|
3079
3269
|
}
|
|
3080
3270
|
|
|
3081
3271
|
async function listSubagentStatusBySession(ctx, sid, knownIds) {
|
|
3272
|
+
// 只认真 id:调用方若传进角色名(历史 bug,见 `memberAgentIds` 的注释),枚举分支会**永久**
|
|
3273
|
+
// 认为"还有人查不到" ⇒ 每请求重枚举。这里再兜一道,防止未来又有人把角色名传进来。
|
|
3274
|
+
const ids = (Array.isArray(knownIds) ? knownIds : []).filter(isAgentIdLike);
|
|
3082
3275
|
const root = sid ? await rootSessionId(ctx, sid) : '';
|
|
3083
3276
|
let rows = [];
|
|
3084
3277
|
if (root) {
|
|
@@ -3093,14 +3286,14 @@ async function listSubagentStatusBySession(ctx, sid, knownIds) {
|
|
|
3093
3286
|
// Cross-session enrich: the run's registered agent ids may belong to a
|
|
3094
3287
|
// DIFFERENT session (viewing run A from session B) — the live `agents`
|
|
3095
3288
|
// registry still reports their status/model keyed by session id.
|
|
3096
|
-
if (
|
|
3289
|
+
if (ids && ids.length) {
|
|
3097
3290
|
try {
|
|
3098
3291
|
const reg = (ctx && typeof ctx.get === 'function') ? ctx.get('agents') : null;
|
|
3099
3292
|
if (reg && typeof reg.list === 'function') {
|
|
3100
3293
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
3101
3294
|
for (const ag of reg.list() || []) {
|
|
3102
3295
|
const aid = ag?.session?.header?.id;
|
|
3103
|
-
if (!aid || byId.has(aid) || !
|
|
3296
|
+
if (!aid || byId.has(aid) || !ids.includes(aid)) continue;
|
|
3104
3297
|
byId.set(aid, { id: aid, mode: 'continuable', label: '', activity: ag.status === 'running' ? 'running' : 'idle', model: ag?.options?.model || '' });
|
|
3105
3298
|
}
|
|
3106
3299
|
rows = [...byId.values()];
|
|
@@ -3114,7 +3307,7 @@ async function listSubagentStatusBySession(ctx, sid, knownIds) {
|
|
|
3114
3307
|
// 第一轮:只有**真的缺人**才花这笔钱(旧实现无条件 listSessions());
|
|
3115
3308
|
// 第二轮:缺的那些**先查 `SUB_HEADER_MEMO`**(已结束会话的 header 不可变 ⇒ 永久备忘),
|
|
3116
3309
|
// 只有仍然未知的才去枚举 ⇒ 第二个请求起通常**零枚举**。
|
|
3117
|
-
const missingIds = (
|
|
3310
|
+
const missingIds = (ids || []).filter((id) => !rows.some((r) => r.id === id));
|
|
3118
3311
|
if (missingIds.length) {
|
|
3119
3312
|
const memoById = new Map(rows.map((r) => [r.id, r]));
|
|
3120
3313
|
const stillUnknown = [];
|
|
@@ -3137,7 +3330,7 @@ async function listSubagentStatusBySession(ctx, sid, knownIds) {
|
|
|
3137
3330
|
for (const rec of sessions || []) {
|
|
3138
3331
|
const h = rec?.header || rec;
|
|
3139
3332
|
const aid = h?.id;
|
|
3140
|
-
if (!aid || byId.has(aid) || !
|
|
3333
|
+
if (!aid || byId.has(aid) || !ids.includes(aid)) continue;
|
|
3141
3334
|
// ⚠️ header 里的 createdAt / parentSession / delegationDepth **必须带出来**。
|
|
3142
3335
|
// 旧实现只取 id,把它们丢掉,导致面板上每个成员都是 createdAt=0 ⇒ 流转图
|
|
3143
3336
|
// 判为「无创建时间记录,无法分批」,尽管会话日志第一行明明写着
|
|
@@ -5039,7 +5232,7 @@ function warnLeadToolFaceOnce(status, detail) {
|
|
|
5039
5232
|
LEAD_TOOLFACE_WARNED.add(status);
|
|
5040
5233
|
console.warn(`[expert-team] lead 工具面**未**收窄(${status}):${detail}`);
|
|
5041
5234
|
}
|
|
5042
|
-
export const _live = { pushActivity, phaseAccountingViolations, loggedPhases, authorityViolations, WRITE_TRACER, createWriteTracer, formatConflict, summarizeTool, parseLogLine, roleOfSub, mapRoleToSub, membersFromState, buildRoleSubMap, resolveSubRoles, childSessionTiming, subHeaderIndex, sessionExists, SUB_HEADER_CACHE, workflowEventIndex, workflowChildLabels, workflowChildMeta, workflowRuns, WF_EVENT_CACHE, rememberSessionRun, sessionRunFor, runOwnerSession, SESSION_RUNS, parseTeamCommand, deriveMemberEntries, schemaViolations, runHealth, RUN_STALL_MS, scaffoldFingerprint, SCAFFOLD_REQUIRED, strandedTasks, settleStranded, IN_FLIGHT_STATUSES, normalizeCoverage, SCHEMA_WARN_SEEN, pushActivityEvent, DEFAULT_LIMITS, LIMITS, resolveLimits, capacityViolations, DEFAULT_ROUND_LIMITS, ROUND_LIMITS, ROUND_LIMIT_ENV, resolveRoundLimits, ROUND_LIMIT_OF_KIND, roundOf, isQualityTask, normTitle, roundLimitViolations, reworkLoopWriteGuard, mutateTasks, readStandingRules, appendStandingRule, rulesRun, scopeOverlapWarnings, applyTaskStatus, waitRun, eventFamily, verdictFromToken, normalizeRoleName, truncateCodepoints, filterRunScopedSubs, runCreatedAtMs, runLogTail, liveFiles, LIVE_FILES_CACHE, DEFAULT_ROLES, resolveTierGate, TIER_GATE_ENV, snapshotRun, parseStateSections, settingsPath, loadSettingsSync, currentSettings, limitsBaseFromSettings, roundLimitsBaseFromSettings, effectiveTierGate: () => TIER_GATE, ensureSkillInstalled, ensurePresetInstalled, uninstallInstalled, buildSkillRegistration, parseSkillMarkdown, PLUGIN_VERSION, INSTALL_STAMP, runtimeSkillRegistered: () => RUNTIME_SKILL_REGISTERED, agentScopedToolNames, warnLeadToolFaceOnce, resolveLeadToolFace, LEAD_TOOLFACE_ENV, effectiveLeadToolFace: () => LEAD_TOOLFACE, resolveLoopGuard, LOOP_GUARD_ENV, effectiveLoopGuard: () => LOOP_GUARD_ENABLED, resolveRosterDefaults, rosterSettings, createRun, watchScript, canvasPollMs, installHostSettings, hostValues, hostScope, hostSettingsNote, updateHostSettings, pickFileOnly, pickHostExpressible, buildHostSchema, hostBase, hostSchemaPaths, reapplySettingsDerived, currentSettings, mergeSettings, detectOptionalPlugins, OPTIONAL_PLUGINS, hintOptionalPluginsOnce, _resetOptionalHintOnce, scheduleOptionalPluginCheck, recheckOptionalPlugins, OPTIONAL_PROBE_DELAYS_MS, loaderEntryNames, hindsightToolReady, costMeterReady, listSubagentStatusBySession, cachedListSessions, _resetListSessionsCache, LIST_SESSIONS_TTL_MS, SUB_HEADER_MEMO, SUB_HEADER_STATS, _resetSubHeaderMemo, sessionsRootStamp, resetRoleReadBudget, roleReadBudgetSnapshot, syncRolePending, _resetRolePending, ROLE_READ_BUDGET_PER_REQUEST, ROLE_READ_LOG_CACHE: SUB_ROLE_LOG_CACHE };
|
|
5235
|
+
export const _live = { pushActivity, phaseAccountingViolations, loggedPhases, authorityViolations, WRITE_TRACER, createWriteTracer, formatConflict, summarizeTool, parseLogLine, roleOfSub, mapRoleToSub, membersFromState, memberAgentIds, isAgentIdLike, buildRoleSubMap, resolveSubRoles, childSessionTiming, subHeaderIndex, sessionExists, SUB_HEADER_CACHE, workflowEventIndex, workflowChildLabels, workflowChildMeta, workflowRuns, WF_EVENT_CACHE, rememberSessionRun, sessionRunFor, runOwnerSession, SESSION_RUNS, parseTeamCommand, deriveMemberEntries, schemaViolations, runHealth, RUN_STALL_MS, scaffoldFingerprint, SCAFFOLD_REQUIRED, strandedTasks, settleStranded, IN_FLIGHT_STATUSES, normalizeCoverage, SCHEMA_WARN_SEEN, pushActivityEvent, DEFAULT_LIMITS, LIMITS, resolveLimits, capacityViolations, DEFAULT_ROUND_LIMITS, ROUND_LIMITS, ROUND_LIMIT_ENV, resolveRoundLimits, ROUND_LIMIT_OF_KIND, roundOf, isQualityTask, normTitle, roundLimitViolations, reworkLoopWriteGuard, mutateTasks, readStandingRules, appendStandingRule, rulesRun, scopeOverlapWarnings, applyTaskStatus, waitRun, eventFamily, verdictFromToken, normalizeRoleName, truncateCodepoints, filterRunScopedSubs, runCreatedAtMs, runLogTail, liveFiles, LIVE_FILES_CACHE, DEFAULT_ROLES, resolveTierGate, TIER_GATE_ENV, snapshotRun, parseStateSections, listRunsInWorkspace, runsIndexPath, RUNS_INDEX_STATS, _resetRunsIndex, settingsPath, loadSettingsSync, currentSettings, limitsBaseFromSettings, roundLimitsBaseFromSettings, effectiveTierGate: () => TIER_GATE, ensureSkillInstalled, ensurePresetInstalled, uninstallInstalled, buildSkillRegistration, parseSkillMarkdown, PLUGIN_VERSION, INSTALL_STAMP, runtimeSkillRegistered: () => RUNTIME_SKILL_REGISTERED, agentScopedToolNames, warnLeadToolFaceOnce, resolveLeadToolFace, LEAD_TOOLFACE_ENV, effectiveLeadToolFace: () => LEAD_TOOLFACE, resolveLoopGuard, LOOP_GUARD_ENV, effectiveLoopGuard: () => LOOP_GUARD_ENABLED, resolveRosterDefaults, rosterSettings, createRun, watchScript, canvasPollMs, installHostSettings, hostValues, hostScope, hostSettingsNote, updateHostSettings, pickFileOnly, pickHostExpressible, buildHostSchema, hostBase, hostSchemaPaths, reapplySettingsDerived, currentSettings, mergeSettings, detectOptionalPlugins, OPTIONAL_PLUGINS, scheduleEffortPreflight, _resetEffortPreflight, effortPreflightPlan, declaredEffortsFromPresetSource, hintOptionalPluginsOnce, _resetOptionalHintOnce, scheduleOptionalPluginCheck, recheckOptionalPlugins, OPTIONAL_PROBE_DELAYS_MS, loaderEntryNames, hindsightToolReady, costMeterReady, listSubagentStatusBySession, cachedListSessions, _resetListSessionsCache, LIST_SESSIONS_TTL_MS, SUB_HEADER_MEMO, SUB_HEADER_STATS, _resetSubHeaderMemo, sessionsRootStamp, resetRoleReadBudget, roleReadBudgetSnapshot, syncRolePending, _resetRolePending, ROLE_READ_BUDGET_PER_REQUEST, ROLE_READ_LOG_CACHE: SUB_ROLE_LOG_CACHE };
|
|
5043
5236
|
|
|
5044
5237
|
export function apply(ctx, config) {
|
|
5045
5238
|
// 留一份 config:设置在运行时改变(官方面板 / 浮层)时要**用同一份 config** 重算上限与档位门,
|
|
@@ -5089,6 +5282,8 @@ export function apply(ctx, config) {
|
|
|
5089
5282
|
// 里还没有它们 ⇒ 已装且可用的插件会被误判成"已安装但当前未就绪"(2026-09-15 真实假警报)。
|
|
5090
5283
|
// 现在改为**就绪后重探**(有界延迟 + `ctx.inject` 事件驱动,见 scheduleOptionalPluginCheck)。
|
|
5091
5284
|
scheduleOptionalPluginCheck(ctx);
|
|
5285
|
+
// D 项:会话模型 effort 预检(缺 reasoningEfforts ⇒ 提前一行告警,不阻断)
|
|
5286
|
+
scheduleEffortPreflight(ctx);
|
|
5092
5287
|
void loadSessionRuns(); // session→run memory for overlay auto-select
|
|
5093
5288
|
ctx.commands.register({
|
|
5094
5289
|
name: 'team',
|
|
@@ -5191,6 +5386,23 @@ export function apply(ctx, config) {
|
|
|
5191
5386
|
ctx.on('tools/pre-execute', ownershipGate);
|
|
5192
5387
|
} catch { /* 门禁挂不上 ⇒ 退回纯协议;绝不让它影响插件加载或工具调用 */ }
|
|
5193
5388
|
|
|
5389
|
+
// ── R1 绕过检测(2026-09-15):**只报不拦**。R1 门禁只覆盖 write/edit;持 bash 的角色
|
|
5390
|
+
// 可以 `cat > SPEC.md` 绕过 —— 静默绕过违背本仓纪律,但静态判断 bash 写目标不可靠
|
|
5391
|
+
// (重定向/变量/子命令),一旦误判就是把正常命令判成越权。⇒ 折中:**留痕**,用真实命中
|
|
5392
|
+
// 频率决定以后是否收紧。口径与"宁可漏报"的理由写在 `lib/artifact-redirect-watch.js` 文件头。
|
|
5393
|
+
try {
|
|
5394
|
+
const knownArtifacts = new Set([...ARTIFACT_TEMPLATES, ...Object.keys(ARTIFACT_OWNERS)]);
|
|
5395
|
+
const redirectWatch = createArtifactRedirectWatcher({
|
|
5396
|
+
knownArtifacts,
|
|
5397
|
+
cwdFor: (exec) => cwdFromSession(ctx, exec?.agent?.session?.header?.id || exec?.agent?.session?.id),
|
|
5398
|
+
teamRootFor: (cwd) => teamRoot(cwd),
|
|
5399
|
+
onEvent: (type, payload) => {
|
|
5400
|
+
try { console.warn('[expert-team] ' + type, JSON.stringify(payload)); } catch { /* 观测失败不影响工具 */ }
|
|
5401
|
+
},
|
|
5402
|
+
});
|
|
5403
|
+
ctx.on('tools/post-execute', redirectWatch);
|
|
5404
|
+
} catch { /* 观测器挂不上 ⇒ 只是少一条留痕;绝不影响插件加载或工具调用 */ }
|
|
5405
|
+
|
|
5194
5406
|
// ── C 线第 16 项(收窄版):**振荡检测**。同工具重复调用由宿主
|
|
5195
5407
|
// `@deepseek-ai/dsh-repeat-tool-reminder` 负责(dsh-base 已启用,thresholds [3,5,8]),
|
|
5196
5408
|
// 不重写;这里只补它明确声明不覆盖的那一种 —— A→B→A→B 在两方案间来回。
|
|
@@ -5419,7 +5631,9 @@ export function apply(ctx, config) {
|
|
|
5419
5631
|
? { sid: sel.stateOwnerSession, ownerResolved: true }
|
|
5420
5632
|
: runOwnerSession(sel.runId);
|
|
5421
5633
|
let peopleSid = owner.sid || sid;
|
|
5422
|
-
|
|
5634
|
+
// ⚠️ **只能用 id**(`memberAgentIds`,不是 `byRole` 的 values 视图):后者混着角色名,
|
|
5635
|
+
// 会让"还有谁查不到"永久非空 ⇒ 每请求重枚举 475 个 artifact(2026-09-15 真机定位)。
|
|
5636
|
+
const knownIds = memberAgentIds(sel.stateMembers);
|
|
5423
5637
|
// 只有 people 分节才解析人员:这一步要枚举子会话 durable 清单(重会话里这是整条链上
|
|
5424
5638
|
// 最大的一块)。首屏用 summary 时不付这个成本 —— `agents` 返回空数组,
|
|
5425
5639
|
// 由客户端 `sections` 字段知道自己拿的是"摘要",UI 如实显示"正在加载成员"。
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话模型的 **reasoningEffort 预检**(2026-09-15,**只告警,绝不阻断**)。
|
|
3
|
+
*
|
|
4
|
+
* ── 为什么需要它(一次真实故障的定性)────────────────────────────────────────
|
|
5
|
+
* 用户报 `model "deepseek-flash" does not support reasoning effort "low"`。定性结论:
|
|
6
|
+
* · **不是本插件的错,也不是宿主缺 low**;
|
|
7
|
+
* · 是用户 `~/.dsh/settings.yaml` 里会话默认路由(命名空间 `agent-default-model`)的
|
|
8
|
+
* `deepseek-flash` 条目**漏写 `reasoningEfforts`** ⇒ 宿主能力表里该模型**只剩 `off`**
|
|
9
|
+
* ⇒ **任何显式 effort 都被拒**(`dsh-llm` 的 `resolveCallWithInfo`:
|
|
10
|
+
* `reasoning === undefined` 时,只要传了 `reasoningEffort` 就抛 `UNSUPPORTED_REASONING_EFFORT`)。
|
|
11
|
+
* · 而本 preset 里 **4 个角色声明 `low`、8 个声明 `high`** ⇒ 在这个路由下 **12 个角色全会失败**。
|
|
12
|
+
* "只有 low 报错"是假象:**先派谁先报谁**。
|
|
13
|
+
* · 宿主在**任何网络 I/O 之前**就抛(`dsh-tool-subagent` 的 preflight → `llm.resolveCallConfig`)。
|
|
14
|
+
*
|
|
15
|
+
* ⇒ 插件**无法**在派工前改变宿主行为(派工由宿主 `tool-subagent` + LLM 运行时执行),
|
|
16
|
+
* 能做的是**提前告警**:在会话路由能力表与 preset 声明对不上时,打**一行**可操作的提示。
|
|
17
|
+
* 这也是本仓唯一诚实的做法 —— 静默失败最贵。
|
|
18
|
+
*
|
|
19
|
+
* ── 纪律 ─────────────────────────────────────────────────────────────────
|
|
20
|
+
* · **只告警,不阻断、不改 preset 的 effort 分档**(那是设计意图);
|
|
21
|
+
* · 任何异常/读不到 ⇒ **静默跳过**(fail-open),绝不让插件加载失败、绝不刷屏;
|
|
22
|
+
* · 一次加载**最多一行**(进程内去重),服务晚挂则**就绪后重探**(同 `scheduleOptionalPluginCheck`)。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** 宿主合法 effort 值域(`dsh-llm` 的 `LlmReasoningEffortId`)。 */
|
|
26
|
+
export const EFFORT_DOMAIN = Object.freeze(['off', 'low', 'high', 'max']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 从 preset 源文件里提取**声明了哪些 effort、各有几个角色**(纯函数,按行正则,不引 YAML 解析器)。
|
|
30
|
+
* 只认 `reasoningEffort: <值>` 这种行;顺序按出现次数降序、值去重。
|
|
31
|
+
* @returns {{efforts:string[], counts:Record<string,number>}}
|
|
32
|
+
*/
|
|
33
|
+
export function declaredEffortsFromPresetSource(src) {
|
|
34
|
+
const counts = Object.create(null);
|
|
35
|
+
const re = /^\s*(?:-\s*)?reasoningEffort:\s*['"]?([A-Za-z]+)['"]?\s*$/gm;
|
|
36
|
+
let m;
|
|
37
|
+
while ((m = re.exec(String(src || ''))) !== null) {
|
|
38
|
+
const v = String(m[1]).toLowerCase();
|
|
39
|
+
if (!v) continue;
|
|
40
|
+
counts[v] = (counts[v] || 0) + 1;
|
|
41
|
+
}
|
|
42
|
+
const efforts = Object.keys(counts).sort((a, b) => counts[b] - counts[a]);
|
|
43
|
+
return { efforts, counts };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 判定"当前会话模型能不能承接 preset 声明的 effort"(纯函数,便于单测)。
|
|
48
|
+
* @param {object} input
|
|
49
|
+
* @param {{provider:string,model:string}|null} input.selection - `agentDefaultModel.currentSelection()`
|
|
50
|
+
* @param {string[]} input.required - preset 声明的 effort 值
|
|
51
|
+
* @param {object|null} input.info - `llm.resolveModelInfo(provider, model)` 的结果
|
|
52
|
+
* @param {Error|null} [input.infoError] - 读模型信息时抛的错(有值 ⇒ 我们不假装知道)
|
|
53
|
+
* @returns {{status:string, warn:boolean, message:string, available:string[], missing:string[]}}
|
|
54
|
+
*/
|
|
55
|
+
export function effortPreflightPlan({ selection, required, info, infoError } = {}) {
|
|
56
|
+
const req = Array.isArray(required) ? required.filter(Boolean) : [];
|
|
57
|
+
const sel = selection && selection.provider && selection.model ? selection : null;
|
|
58
|
+
if (!sel) return { status: 'no-route', warn: false, message: '', available: [], missing: req };
|
|
59
|
+
if (!req.length) return { status: 'no-preset-efforts', warn: false, message: '', available: [], missing: [] };
|
|
60
|
+
const where = `${sel.provider}/${sel.model}`;
|
|
61
|
+
if (infoError || !info) {
|
|
62
|
+
// 读不到能力表 ⇒ **不假装知道**(也不打扰):模型可能还没注册/适配器未就绪。
|
|
63
|
+
return { status: 'unknown', warn: false, message: '', available: [], missing: [] };
|
|
64
|
+
}
|
|
65
|
+
const reasoning = info.reasoning;
|
|
66
|
+
const available = Array.isArray(reasoning && reasoning.efforts) ? reasoning.efforts.map((e) => String(e && e.id)).filter(Boolean) : [];
|
|
67
|
+
const set = new Set(available);
|
|
68
|
+
const missing = req.filter((r) => !set.has(String(r).toLowerCase()));
|
|
69
|
+
if (!reasoning) {
|
|
70
|
+
// 宿主语义:reasoning === undefined ⇒ **任何** reasoningEffort 都会被拒。
|
|
71
|
+
return {
|
|
72
|
+
status: 'no-reasoning-support',
|
|
73
|
+
warn: true,
|
|
74
|
+
available,
|
|
75
|
+
missing: req,
|
|
76
|
+
message: `[expert-team] 当前会话模型 ${where} 未声明任何 reasoningEfforts(宿主视为"不支持 reasoning effort")⇒ preset 里带 effort 的角色派工会被拒(UNSUPPORTED_REASONING_EFFORT)。修法:在 ~/.dsh/settings.yaml 的 agent-default-model 条目给该模型补 reasoningEfforts(${EFFORT_DOMAIN.join('/')}),或把会话切到官方路由。`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (missing.length) {
|
|
80
|
+
return {
|
|
81
|
+
status: 'missing-efforts',
|
|
82
|
+
warn: true,
|
|
83
|
+
available,
|
|
84
|
+
missing,
|
|
85
|
+
message: `[expert-team] 当前会话模型 ${where} 只声明了 reasoningEfforts=[${available.join(',') || '(空)'}],而 preset 里声明的 [${missing.join(',')}] 不在其中 ⇒ 这些角色的派工会被宿主拒绝(UNSUPPORTED_REASONING_EFFORT;宿主在任何网络 I/O 之前就拒)。修法:在 ~/.dsh/settings.yaml 的 agent-default-model 条目补 reasoningEfforts(${EFFORT_DOMAIN.join('/')}),或把会话切到官方路由。`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return { status: 'ok', warn: false, message: '', available, missing: [] };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 默认的有界重探节奏(服务晚挂时用;与 `OPTIONAL_PROBE_DELAYS_MS` 同一套纪律)。 */
|
|
92
|
+
export const EFFORT_PROBE_DELAYS_MS = Object.freeze([250, 1000, 3000]);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 接线:读 preset 声明 → 读当前路由与模型能力 → 下单(不阻断)→ 结论在**最后一轮**才播报。
|
|
96
|
+
* deps 全部注入,便于用假 ctx 单测。
|
|
97
|
+
* @param {object} deps
|
|
98
|
+
* @param {() => Promise<string>} [deps.readPresetSource] - 读 preset 源文本(读不到 ⇒ 静默跳过)
|
|
99
|
+
* @param {(ctx:object)=>{provider:string,model:string}|null} [deps.readSelection]
|
|
100
|
+
* @param {(provider:string, model:string)=>Promise<object>} [deps.readModelInfo]
|
|
101
|
+
* @param {(line:string)=>void} [deps.warn] - 播报出口(默认 console.warn)
|
|
102
|
+
* @param {(type:string,payload:object)=>void} [deps.onEvent]
|
|
103
|
+
*/
|
|
104
|
+
export function createEffortPreflight(deps = {}) {
|
|
105
|
+
const { readPresetSource, readSelection, readModelInfo, onEvent } = deps;
|
|
106
|
+
const say = typeof deps.warn === 'function' ? deps.warn : (line) => console.warn(line);
|
|
107
|
+
const emit = typeof onEvent === 'function' ? onEvent : () => {};
|
|
108
|
+
let done = false;
|
|
109
|
+
return async function runEffortPreflight(ctx, { isLast = true } = {}) {
|
|
110
|
+
if (done) return { status: 'already-done', warn: false };
|
|
111
|
+
try {
|
|
112
|
+
const src = await readPresetSource();
|
|
113
|
+
const { efforts: required, counts } = declaredEffortsFromPresetSource(src);
|
|
114
|
+
const selection = readSelection(ctx);
|
|
115
|
+
if (!selection) { if (isLast) done = true; return { status: 'no-route', warn: false }; }
|
|
116
|
+
let info = null, infoError = null;
|
|
117
|
+
try { info = await readModelInfo(selection.provider, selection.model); } catch (e) { infoError = e; }
|
|
118
|
+
const plan = effortPreflightPlan({ selection, required, info, infoError });
|
|
119
|
+
if (isLast) done = true;
|
|
120
|
+
if (plan.warn) {
|
|
121
|
+
const detail = Object.keys(counts).length ? `(preset 里 ${Object.entries(counts).map(([k, v]) => `${v} 个用 ${k}`).join('、')})` : '';
|
|
122
|
+
try { say(detail ? plan.message.replace('。修法:', `${detail}。修法:`) : plan.message); } catch { /* 打印失败也不能影响加载 */ }
|
|
123
|
+
emit('effort-preflight-warn', { status: plan.status, provider: selection.provider, model: selection.model, available: plan.available, missing: plan.missing });
|
|
124
|
+
}
|
|
125
|
+
return plan;
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// 探测自身出错 ⇒ 静默(fail-open),但留一条事件供排查
|
|
128
|
+
try { emit('effort-preflight-error', { error: String((e && e.message) || e) }); } catch { /* ignore */ }
|
|
129
|
+
if (isLast) done = true;
|
|
130
|
+
return { status: 'error', warn: false };
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** 仅供测试:复位"只播报一次"的开关(由 command.js 包一层,见 `_resetEffortPreflight`)。 */
|
|
135
|
+
export function _resetEffortPreflightState() { /* 开关在 createEffortPreflight 的闭包里,测试用新实例即可 */ }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yangdcm/dsh-expert-team",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.16",
|
|
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 && node subagent-bar.test.mjs && node r1-ownership-gate.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 && node artifact-redirect-watch.test.mjs && node effort-preflight.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",
|