@yangdcm/dsh-expert-team 1.3.14 → 1.3.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +103 -0
- package/README.en.md +15 -8
- package/README.md +11 -7
- package/client.js +110 -0
- package/lib/artifact-ownership.js +198 -0
- package/lib/artifact-redirect-watch.js +118 -0
- package/lib/command.js +318 -34
- package/lib/effort-preflight.js +135 -0
- package/lib/interception.js +4 -3
- package/package.json +2 -2
- package/skills/expert-team/SKILL.md +1 -1
- package/skills/expert-team/references/WORKSPACE.md +1 -1
|
@@ -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,8 +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, 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']);
|
|
25
34
|
// 设计稿 §十二 第 3 步:并发写留痕(只记录、只告警,绝不阻断)。
|
|
26
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';
|
|
27
38
|
import { createLoopGuard } from './loop-guard.js';
|
|
28
39
|
// C 线第 17 项:派工即回写(派工成功后由**代码**把任务置 in_progress + owner,不靠模型记得改)。
|
|
29
40
|
import { createDispatchLedger } from './dispatch-ledger.js';
|
|
@@ -1058,6 +1069,46 @@ function scheduleOptionalPluginCheck(ctx, delays) {
|
|
|
1058
1069
|
}
|
|
1059
1070
|
return timers;
|
|
1060
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
|
+
}
|
|
1061
1112
|
function _resetOptionalHintOnce() {
|
|
1062
1113
|
OPTIONAL_HINT_DONE = false;
|
|
1063
1114
|
for (const t of OPTIONAL_PROBE_TIMERS) { try { clearTimeout(t); } catch { /* ignore */ } }
|
|
@@ -1148,7 +1199,7 @@ async function scaffoldRun(cwd, mode) {
|
|
|
1148
1199
|
// 测试里的 14 项)**不是同一个集合**:那边多一个 `RUN.log.md`(由 run log 写入器创建)、这边就是
|
|
1149
1200
|
// 模板全集。两者关系由 `artifact-ownership.test.mjs` 的「工件清单一致」断言钉住,避免下次又被
|
|
1150
1201
|
// 当成同一件事去"对齐"(2026-09-15 阶段 C 的口径核查)。
|
|
1151
|
-
const templates =
|
|
1202
|
+
const templates = ARTIFACT_TEMPLATES;
|
|
1152
1203
|
for (const t of templates) {
|
|
1153
1204
|
try {
|
|
1154
1205
|
await ARTIFACT.must(join(runDir, t), await readFile(new URL(t, TEMPLATES_SRC)));
|
|
@@ -1684,6 +1735,44 @@ function deriveMemberEntries(subById, existing) {
|
|
|
1684
1735
|
}
|
|
1685
1736
|
return { entries: out, changed };
|
|
1686
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
|
+
}
|
|
1687
1776
|
function membersFromState(members) {
|
|
1688
1777
|
const byRole = new Map(), names = new Map();
|
|
1689
1778
|
for (const item of Array.isArray(members) ? members : []) {
|
|
@@ -1712,6 +1801,9 @@ function buildRoleSubMap(subs, stateMembers, wfLabels) {
|
|
|
1712
1801
|
if (precise.byRole.size) {
|
|
1713
1802
|
const m = new Map();
|
|
1714
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;
|
|
1715
1807
|
const hit = subs.find((s) => String(s.id || '') === id);
|
|
1716
1808
|
if (hit) m.set(role, hit);
|
|
1717
1809
|
}
|
|
@@ -2690,6 +2782,60 @@ function cwdFromSession(ctx, sessionId) {
|
|
|
2690
2782
|
} catch { return undefined; }
|
|
2691
2783
|
}
|
|
2692
2784
|
|
|
2785
|
+
/**
|
|
2786
|
+
* 由 `exec` 的**调用者**解析出角色(R1 归属门禁与并发写留痕**共用同一份**解析)。
|
|
2787
|
+
*
|
|
2788
|
+
* 来源:`STATE.members` 的 `<agentSessionId>:<role>` 行 —— 宿主在派工时自动登记
|
|
2789
|
+
*("派工即登记"),所以这里不需要猜 label/事件流。
|
|
2790
|
+
* **认不出就返回空串**:调用方据此 fail-open(宁可漏拦,不可误伤),绝不臆造角色。
|
|
2791
|
+
* 角色可带后缀(`frontend-F4`、`reviewer-R1`)——原样返回,归一由查表一侧负责。
|
|
2792
|
+
*
|
|
2793
|
+
* @param ctx - 插件 ctx(用 ctx.get('sessions') 反查 cwd)
|
|
2794
|
+
* @param exec - 工具执行记录(`exec.agent.session` 给出调用者会话 id)
|
|
2795
|
+
* @param runId - 目标 run(STATE.json 所在目录名)
|
|
2796
|
+
* @returns {Promise<string>} 角色串;解析不出返回 `''`。
|
|
2797
|
+
*/
|
|
2798
|
+
async function roleOfAgent(ctx, exec, runId) {
|
|
2799
|
+
const sid = String(exec?.agent?.session?.id || exec?.agent?.session?.header?.id || '');
|
|
2800
|
+
if (!sid || !runId) return '';
|
|
2801
|
+
try {
|
|
2802
|
+
const cwd = cwdFromSession(ctx, exec?.agent?.session?.header?.id || sid)
|
|
2803
|
+
|| String(exec?.agent?.session?.header?.cwd || '');
|
|
2804
|
+
if (!cwd) return '';
|
|
2805
|
+
const st = await readJsonSafe(join(teamRoot(cwd), runId, 'STATE.json'));
|
|
2806
|
+
for (const m of (st && Array.isArray(st.members)) ? st.members : []) {
|
|
2807
|
+
const str = String(m || '');
|
|
2808
|
+
const i = str.indexOf(':');
|
|
2809
|
+
if (i > 0 && str.slice(0, i) === sid) return str.slice(i + 1);
|
|
2810
|
+
}
|
|
2811
|
+
} catch { /* 读不到 ⇒ 空串(fail-open) */ }
|
|
2812
|
+
return '';
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
/**
|
|
2816
|
+
* 目标文件**是否已存在** —— R1 门禁的"创建放行、覆写才拦"就靠这一个判据(不需要读内容)。
|
|
2817
|
+
*
|
|
2818
|
+
* 与读文本同一套纪律:**宿主 fs 优先**(run 目录可能落在宿主虚拟路径体系里),拿不到再退 node。
|
|
2819
|
+
* 返回三态:`true`(在)/ `false`(明确不存在)/ `undefined`(**查不到**)。
|
|
2820
|
+
* 调用方必须把 `undefined` 当"不知道"而放行并留痕 —— 把"查不到"当成"不存在"会让门禁
|
|
2821
|
+
* 在最需要它的时候静默失效(本轮反复强调的"两种零要分得清")。
|
|
2822
|
+
*/
|
|
2823
|
+
async function pathExistsFor(ctx, abs) {
|
|
2824
|
+
try {
|
|
2825
|
+
const hostFs = typeof ctx.get === 'function' ? ctx.get('fs') : null;
|
|
2826
|
+
if (hostFs && typeof hostFs.stat === 'function' && typeof hostFs.resolve === 'function') {
|
|
2827
|
+
const resolvedTarget = await hostFs.resolve(abs);
|
|
2828
|
+
try {
|
|
2829
|
+
const st = await hostFs.stat(resolvedTarget);
|
|
2830
|
+
if (st) return true;
|
|
2831
|
+
// 宿主明确说"没有":再用 node 复核一次(宿主实现语义可能不同,宁可多问一次)
|
|
2832
|
+
} catch { /* 宿主抛错 ⇒ 交给 node */ }
|
|
2833
|
+
}
|
|
2834
|
+
} catch { /* 宿主解析失败 ⇒ 交给 node */ }
|
|
2835
|
+
try { await stat(abs); return true; }
|
|
2836
|
+
catch (e) { return (e && e.code === 'ENOENT') ? false : undefined; }
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2693
2839
|
// Pick the most recently updated run under <cwd>/team.
|
|
2694
2840
|
async function pickLatestRun(cwd) {
|
|
2695
2841
|
const root = teamRoot(cwd);
|
|
@@ -2764,24 +2910,123 @@ async function registeredWorkspaces() {
|
|
|
2764
2910
|
}
|
|
2765
2911
|
|
|
2766
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
|
+
}
|
|
2767
3018
|
async function listRunsInWorkspace(ws) {
|
|
2768
3019
|
const root0 = teamRoot(ws);
|
|
2769
|
-
let
|
|
2770
|
-
|
|
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 []; }
|
|
2771
3025
|
const out0 = [];
|
|
2772
|
-
for (const
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
const done0 = tasks0.filter((t) => ['completed', 'done'].includes(t.status)).length;
|
|
2777
|
-
const owners = new Set((tasks0.map((t) => t.owner)).filter(Boolean));
|
|
2778
|
-
const h0 = runHealth(st0);
|
|
2779
|
-
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 });
|
|
2780
|
-
} else {
|
|
2781
|
-
// B1:面板的 run 下拉过去同样静默跳过没有 STATE.json 的目录 —— 用户在界面上
|
|
2782
|
-
// **完全看不到**这些遗留/损坏的 run。现在如实列出并标 `health: 'broken'`。
|
|
2783
|
-
out0.push({ runId: n, workspace: ws, phase: '', status: '', updatedAt: '', goal: '', done: 0, total: 0, members: 0, violations: 0, health: 'broken', healthReason: '缺 STATE.json(或不可解析)' });
|
|
2784
|
-
}
|
|
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));
|
|
2785
3030
|
}
|
|
2786
3031
|
return out0;
|
|
2787
3032
|
}
|
|
@@ -3024,6 +3269,9 @@ function _resetSubHeaderMemo() {
|
|
|
3024
3269
|
}
|
|
3025
3270
|
|
|
3026
3271
|
async function listSubagentStatusBySession(ctx, sid, knownIds) {
|
|
3272
|
+
// 只认真 id:调用方若传进角色名(历史 bug,见 `memberAgentIds` 的注释),枚举分支会**永久**
|
|
3273
|
+
// 认为"还有人查不到" ⇒ 每请求重枚举。这里再兜一道,防止未来又有人把角色名传进来。
|
|
3274
|
+
const ids = (Array.isArray(knownIds) ? knownIds : []).filter(isAgentIdLike);
|
|
3027
3275
|
const root = sid ? await rootSessionId(ctx, sid) : '';
|
|
3028
3276
|
let rows = [];
|
|
3029
3277
|
if (root) {
|
|
@@ -3038,14 +3286,14 @@ async function listSubagentStatusBySession(ctx, sid, knownIds) {
|
|
|
3038
3286
|
// Cross-session enrich: the run's registered agent ids may belong to a
|
|
3039
3287
|
// DIFFERENT session (viewing run A from session B) — the live `agents`
|
|
3040
3288
|
// registry still reports their status/model keyed by session id.
|
|
3041
|
-
if (
|
|
3289
|
+
if (ids && ids.length) {
|
|
3042
3290
|
try {
|
|
3043
3291
|
const reg = (ctx && typeof ctx.get === 'function') ? ctx.get('agents') : null;
|
|
3044
3292
|
if (reg && typeof reg.list === 'function') {
|
|
3045
3293
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
3046
3294
|
for (const ag of reg.list() || []) {
|
|
3047
3295
|
const aid = ag?.session?.header?.id;
|
|
3048
|
-
if (!aid || byId.has(aid) || !
|
|
3296
|
+
if (!aid || byId.has(aid) || !ids.includes(aid)) continue;
|
|
3049
3297
|
byId.set(aid, { id: aid, mode: 'continuable', label: '', activity: ag.status === 'running' ? 'running' : 'idle', model: ag?.options?.model || '' });
|
|
3050
3298
|
}
|
|
3051
3299
|
rows = [...byId.values()];
|
|
@@ -3059,7 +3307,7 @@ async function listSubagentStatusBySession(ctx, sid, knownIds) {
|
|
|
3059
3307
|
// 第一轮:只有**真的缺人**才花这笔钱(旧实现无条件 listSessions());
|
|
3060
3308
|
// 第二轮:缺的那些**先查 `SUB_HEADER_MEMO`**(已结束会话的 header 不可变 ⇒ 永久备忘),
|
|
3061
3309
|
// 只有仍然未知的才去枚举 ⇒ 第二个请求起通常**零枚举**。
|
|
3062
|
-
const missingIds = (
|
|
3310
|
+
const missingIds = (ids || []).filter((id) => !rows.some((r) => r.id === id));
|
|
3063
3311
|
if (missingIds.length) {
|
|
3064
3312
|
const memoById = new Map(rows.map((r) => [r.id, r]));
|
|
3065
3313
|
const stillUnknown = [];
|
|
@@ -3082,7 +3330,7 @@ async function listSubagentStatusBySession(ctx, sid, knownIds) {
|
|
|
3082
3330
|
for (const rec of sessions || []) {
|
|
3083
3331
|
const h = rec?.header || rec;
|
|
3084
3332
|
const aid = h?.id;
|
|
3085
|
-
if (!aid || byId.has(aid) || !
|
|
3333
|
+
if (!aid || byId.has(aid) || !ids.includes(aid)) continue;
|
|
3086
3334
|
// ⚠️ header 里的 createdAt / parentSession / delegationDepth **必须带出来**。
|
|
3087
3335
|
// 旧实现只取 id,把它们丢掉,导致面板上每个成员都是 createdAt=0 ⇒ 流转图
|
|
3088
3336
|
// 判为「无创建时间记录,无法分批」,尽管会话日志第一行明明写着
|
|
@@ -4984,7 +5232,7 @@ function warnLeadToolFaceOnce(status, detail) {
|
|
|
4984
5232
|
LEAD_TOOLFACE_WARNED.add(status);
|
|
4985
5233
|
console.warn(`[expert-team] lead 工具面**未**收窄(${status}):${detail}`);
|
|
4986
5234
|
}
|
|
4987
|
-
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 };
|
|
4988
5236
|
|
|
4989
5237
|
export function apply(ctx, config) {
|
|
4990
5238
|
// 留一份 config:设置在运行时改变(官方面板 / 浮层)时要**用同一份 config** 重算上限与档位门,
|
|
@@ -5034,6 +5282,8 @@ export function apply(ctx, config) {
|
|
|
5034
5282
|
// 里还没有它们 ⇒ 已装且可用的插件会被误判成"已安装但当前未就绪"(2026-09-15 真实假警报)。
|
|
5035
5283
|
// 现在改为**就绪后重探**(有界延迟 + `ctx.inject` 事件驱动,见 scheduleOptionalPluginCheck)。
|
|
5036
5284
|
scheduleOptionalPluginCheck(ctx);
|
|
5285
|
+
// D 项:会话模型 effort 预检(缺 reasoningEfforts ⇒ 提前一行告警,不阻断)
|
|
5286
|
+
scheduleEffortPreflight(ctx);
|
|
5037
5287
|
void loadSessionRuns(); // session→run memory for overlay auto-select
|
|
5038
5288
|
ctx.commands.register({
|
|
5039
5289
|
name: 'team',
|
|
@@ -5094,20 +5344,12 @@ export function apply(ctx, config) {
|
|
|
5094
5344
|
},
|
|
5095
5345
|
// 并发写留痕(设计稿 §十二 第 3 步):写者身份 = 会话 id + 从 `STATE.members`
|
|
5096
5346
|
//(格式就是 `<agentSessionId>:<role>`)解出的角色。**解不出角色就如实退到会话 id,不编造角色。**
|
|
5347
|
+
// 写者身份 = 会话 id + 角色。**角色解析与 R1 门禁共用 `roleOfAgent`**
|
|
5348
|
+
//(两处各写一遍口径迟早漂移:一处认得出、另一处认不出)。
|
|
5097
5349
|
whoFor: async (exec, target) => {
|
|
5098
5350
|
const sid = String(exec?.agent?.session?.id || exec?.agent?.session?.header?.id || '');
|
|
5099
5351
|
if (!sid) return '';
|
|
5100
|
-
|
|
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 */ }
|
|
5352
|
+
const role = await roleOfAgent(ctx, exec, target.runId);
|
|
5111
5353
|
return role ? `${role}#${sid.slice(0, 6)}` : `session:${sid.slice(0, 6)}`;
|
|
5112
5354
|
},
|
|
5113
5355
|
writeTracer: WRITE_TRACER,
|
|
@@ -5121,6 +5363,46 @@ export function apply(ctx, config) {
|
|
|
5121
5363
|
});
|
|
5122
5364
|
ctx.on('tools/post-execute', boundary);
|
|
5123
5365
|
|
|
5366
|
+
// ── R1 工件归属**硬门禁**(2026-09-15):把"工件由产出它的角色自己落盘"从**协议约定**
|
|
5367
|
+
// 升级成**可执行的门禁**。挂在 `tools/pre-execute`(**写盘之前**)而不是 post:
|
|
5368
|
+
// 判据是"**创建放行、覆写才拦**"——只需一次存在性查询、不读内容,因此拦得住;
|
|
5369
|
+
// 而内容级的台账/边界校验依赖已落盘内容,继续留在上面的 post-execute,两者分工。
|
|
5370
|
+
// 语义、表格真源与诚实边界(只覆盖 write/edit 通道;持 bash 的角色仍可能绕过)
|
|
5371
|
+
// 全部写在 `lib/artifact-ownership.js` 的文件头,别在这里复制一份。
|
|
5372
|
+
try {
|
|
5373
|
+
const ownershipGate = createOwnershipGate({
|
|
5374
|
+
cwdFor: (exec) => cwdFromSession(ctx, exec?.agent?.session?.header?.id || exec?.agent?.session?.id),
|
|
5375
|
+
teamRootFor: (cwd) => teamRoot(cwd),
|
|
5376
|
+
statusFor: async (runId, cwd) => {
|
|
5377
|
+
const st = await readJsonSafe(join(teamRoot(cwd), runId, 'STATE.json'));
|
|
5378
|
+
return String((st && st.status) || '');
|
|
5379
|
+
},
|
|
5380
|
+
roleFor: (exec, runId) => roleOfAgent(ctx, exec, runId),
|
|
5381
|
+
existsFor: (abs) => pathExistsFor(ctx, abs),
|
|
5382
|
+
onEvent: (type, payload) => {
|
|
5383
|
+
try { console.warn(`[expert-team] ${type}`, JSON.stringify(payload)); } catch { /* 观测失败不影响工具 */ }
|
|
5384
|
+
},
|
|
5385
|
+
});
|
|
5386
|
+
ctx.on('tools/pre-execute', ownershipGate);
|
|
5387
|
+
} catch { /* 门禁挂不上 ⇒ 退回纯协议;绝不让它影响插件加载或工具调用 */ }
|
|
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
|
+
|
|
5124
5406
|
// ── C 线第 16 项(收窄版):**振荡检测**。同工具重复调用由宿主
|
|
5125
5407
|
// `@deepseek-ai/dsh-repeat-tool-reminder` 负责(dsh-base 已启用,thresholds [3,5,8]),
|
|
5126
5408
|
// 不重写;这里只补它明确声明不覆盖的那一种 —— A→B→A→B 在两方案间来回。
|
|
@@ -5349,7 +5631,9 @@ export function apply(ctx, config) {
|
|
|
5349
5631
|
? { sid: sel.stateOwnerSession, ownerResolved: true }
|
|
5350
5632
|
: runOwnerSession(sel.runId);
|
|
5351
5633
|
let peopleSid = owner.sid || sid;
|
|
5352
|
-
|
|
5634
|
+
// ⚠️ **只能用 id**(`memberAgentIds`,不是 `byRole` 的 values 视图):后者混着角色名,
|
|
5635
|
+
// 会让"还有谁查不到"永久非空 ⇒ 每请求重枚举 475 个 artifact(2026-09-15 真机定位)。
|
|
5636
|
+
const knownIds = memberAgentIds(sel.stateMembers);
|
|
5353
5637
|
// 只有 people 分节才解析人员:这一步要枚举子会话 durable 清单(重会话里这是整条链上
|
|
5354
5638
|
// 最大的一块)。首屏用 summary 时不付这个成本 —— `agents` 返回空数组,
|
|
5355
5639
|
// 由客户端 `sections` 字段知道自己拿的是"摘要",UI 如实显示"正在加载成员"。
|