dsh-subagent-profile 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,312 @@
1
+ // lib/core/evolution-draft.mjs — 进化建议 → 可应用候选资产的确定性生成器。
2
+ // 纯函数 + 数据注入(catalog 快照 / 白名单 / 已占用 id 集),零 LLM、零 fs、零新依赖:
3
+ // 同输入恒同输出,可裸 import 单测;只产出候选 config,落盘/审计由装配层负责。
4
+ // 推导规则(确定性,一条建议 → ≤3 条互斥候选,保守 → 更激进排序):
5
+ // 降(能力轴只降):单步 = 模型更低档 / 移除 persona·toolFilter 结构段 / 白名单内
6
+ // 相邻低档预设;组合 = 单步并列补充(如模型低档 + 移除工具过滤)。
7
+ // 升(预算轴只升):推理强度升一级(阶梯与派发档位同源)、maxTokens ×2 封顶
8
+ // 硬上限,可组合。跨方向禁止(全降或全升);全无候选返回空表。平 → 空表。
9
+ // 隐私:不引入 prompt/persona 原文;config 只含字段与值,basis 只含摘要文本。
10
+
11
+ import { createHash } from 'node:crypto';
12
+ import { MAX_TOKENS, sanitizeProfile } from './pure.mjs';
13
+ import { avgCostFor } from './prices.mjs';
14
+
15
+ // 推理档位阶梯:升档「升一级」的口径与派发工具档位同源;未知档位不升(保守)。
16
+ export const EFFORT_LADDER = ['off', 'low', 'medium', 'high', 'max'];
17
+
18
+ const ID_MAX_LEN = 32;
19
+ const ID_RETRY_MAX = 5;
20
+
21
+ // profileKey 轴段解析(evolution-summary 的 L1 身份键口径):
22
+ // preset:<名> / provider:<名> / model:<名> / persona:1 / toolFilter:1。
23
+ function parseProfileKey(profileKey) {
24
+ const parsed = { preset: null, provider: null, model: null, persona: false, toolFilter: false };
25
+ const text = typeof profileKey === 'string' ? profileKey : '';
26
+ for (const part of text.split('|')) {
27
+ if (part.startsWith('preset:')) parsed.preset = part.slice('preset:'.length);
28
+ else if (part.startsWith('provider:')) parsed.provider = part.slice('provider:'.length);
29
+ else if (part.startsWith('model:')) parsed.model = part.slice('model:'.length);
30
+ else if (part === 'persona:1') parsed.persona = true;
31
+ else if (part === 'toolFilter:1') parsed.toolFilter = true;
32
+ }
33
+ return parsed;
34
+ }
35
+
36
+ // 身份摘要:只保留小写字母/数字/连字符,供候选 id 使用(sanitize 友好)。
37
+ export function lowerHyphen(text) {
38
+ return String(text ?? '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
39
+ }
40
+
41
+ // 候选 id 基名:evo-<方向>-<身份摘要>,总长 ≤32。摘要在长键时截断。
42
+ function baseDraftId(direction, profileKey) {
43
+ const slug = lowerHyphen(profileKey).slice(0, 24) || 'inline';
44
+ return `evo-${direction}-${slug}`.slice(0, ID_MAX_LEN);
45
+ }
46
+
47
+ // id 冲突重试:与已占用 id(profiles + 资产)冲突时追加 -2、-3……(≤5 次),
48
+ // 仍冲突返回 null(装配层放弃生成,不产出会撞名的候选)。基名可由调用方给定
49
+ // (多候选在基名后追加 -c<N> 序号)。
50
+ function nextIdForBase(base, taken) {
51
+ const ids = taken instanceof Set ? taken : new Set(Array.isArray(taken) ? taken : []);
52
+ if (!ids.has(base)) return base;
53
+ for (let i = 2; i <= ID_RETRY_MAX + 1; i += 1) {
54
+ const candidate = `${base}-${i}`.slice(0, ID_MAX_LEN);
55
+ if (!ids.has(candidate)) return candidate;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ export function nextDraftId(direction, profileKey, taken) {
61
+ return nextIdForBase(baseDraftId(direction, profileKey), taken);
62
+ }
63
+
64
+ // 同 provider 单价更低且最接近的模型档。当前模型无单价或同 provider 无更低档时
65
+ // 返回 null(调用方跳过该步,走更保守的降档策略)。
66
+ function modelDowngrade(modelName, providerName, models) {
67
+ if (typeof modelName !== 'string' || modelName === '') return null;
68
+ const currentCost = avgCostFor(modelName);
69
+ if (currentCost === undefined) return null;
70
+ const list = Array.isArray(models) ? models : [];
71
+ let provider = providerName;
72
+ if (provider === null || provider === '') {
73
+ const current = list.find((m) => m && m.id === modelName);
74
+ provider = current !== undefined && typeof current.provider === 'string' ? current.provider : null;
75
+ }
76
+ if (provider === null) return null;
77
+ const candidates = list
78
+ .filter((m) => m && typeof m.id === 'string' && m.id !== modelName && m.provider === provider)
79
+ .map((m) => ({ id: m.id, provider: m.provider, cost: avgCostFor(m.id) }))
80
+ .filter((m) => m.cost !== undefined && m.cost < currentCost)
81
+ .sort((a, b) => b.cost - a.cost);
82
+ const pick = candidates[0];
83
+ return pick === undefined ? null : { provider: pick.provider, model: pick.id };
84
+ }
85
+
86
+ // 同 L1 身份键下的预算轴当前值(从 L2 键反查):maxTokens 取组内最大值,
87
+ // reasoningEffort 取组内阶梯最高档。
88
+ function higherEffort(a, b) {
89
+ const ia = a === null ? -1 : EFFORT_LADDER.indexOf(a);
90
+ const ib = b === null ? -1 : EFFORT_LADDER.indexOf(b);
91
+ return ib > ia ? b : a;
92
+ }
93
+
94
+ function budgetFromL2(profileKey, l2) {
95
+ const source = l2 !== null && typeof l2 === 'object' ? l2 : {};
96
+ let maxTokens = null;
97
+ let effort = null;
98
+ for (const [axisKey] of Object.entries(source)) {
99
+ if (typeof axisKey !== 'string') continue;
100
+ const tokensPrefix = `${profileKey}:maxTokens:`;
101
+ if (axisKey.startsWith(tokensPrefix)) {
102
+ const value = Number(axisKey.slice(tokensPrefix.length));
103
+ if (Number.isFinite(value) && value > 0) maxTokens = maxTokens === null ? value : Math.max(maxTokens, value);
104
+ continue;
105
+ }
106
+ const effortPrefix = `${profileKey}:reasoningEffort:`;
107
+ if (axisKey.startsWith(effortPrefix)) effort = higherEffort(effort, axisKey.slice(effortPrefix.length));
108
+ }
109
+ return { maxTokens, reasoningEffort: effort };
110
+ }
111
+
112
+ // 变更前配置(审计 from 口径):能力轴取值来自身份键,预算轴取值反查 L2。
113
+ export function sourceConfigFromKey(profileKey, l2) {
114
+ const parsed = parseProfileKey(profileKey);
115
+ const budget = budgetFromL2(profileKey, l2);
116
+ const config = {};
117
+ if (parsed.preset !== null) config.preset = parsed.preset;
118
+ if (parsed.provider !== null) config.provider = parsed.provider;
119
+ if (parsed.model !== null) config.model = parsed.model;
120
+ if (parsed.persona) config.persona_present = true;
121
+ if (parsed.toolFilter) config.toolFilter_present = true;
122
+ if (budget.reasoningEffort !== null) config.reasoningEffort = budget.reasoningEffort;
123
+ if (budget.maxTokens !== null) config.maxTokens = budget.maxTokens;
124
+ return config;
125
+ }
126
+
127
+ // 候选名中的改动摘要(人话,零配置原文):模型/预设给新值,结构移除按存在性组词。
128
+ function structureChangeSummary(parsed) {
129
+ if (parsed.persona && parsed.toolFilter) return '去掉自定义人格与工具过滤';
130
+ if (parsed.persona) return '去掉自定义人格';
131
+ return '移除工具过滤';
132
+ }
133
+
134
+ // 保守度标签:按「保守 → 更激进」排序,两端为保守/更激进,三档时中间为均衡。
135
+ function radicalLabel(index, total) {
136
+ if (total <= 1) return '保守';
137
+ if (index === 0) return '保守';
138
+ if (index === total - 1) return '更激进';
139
+ return '均衡';
140
+ }
141
+
142
+ // 白名单内相邻低档预设(名册口径);无可用低档返回 null。
143
+ function downPresetPick(parsed, whitelist, presets) {
144
+ if (parsed.preset === null) return null;
145
+ const roster = Array.isArray(presets) ? presets : [];
146
+ const index = roster.findIndex((p) => p && p.id === parsed.preset);
147
+ for (let i = index - 1; i >= 0; i -= 1) {
148
+ const candidate = roster[i];
149
+ if (candidate === null || candidate === undefined || typeof candidate.id !== 'string' || candidate.id === '') continue;
150
+ const allowed = whitelist instanceof Set ? whitelist.has(candidate.id) : Array.isArray(whitelist) && whitelist.includes(candidate.id);
151
+ if (allowed) return { id: candidate.id };
152
+ }
153
+ return null;
154
+ }
155
+
156
+ // 降档候选清单(能力轴只降,互斥替代):单步(模型 → 结构移除 → 预设)在前,
157
+ // 组合在后(候选 2 可含候选 1 全部改动再加一个);同一建议只产同一方向。
158
+ // 每个候选是完整 config 快照(身份键内其余能力字段保留、被移除的结构段缺席)。
159
+ function downCandidateConfigs(parsed, models, whitelist, presets) {
160
+ const out = [];
161
+ const modelPick = parsed.model !== null ? modelDowngrade(parsed.model, parsed.provider, models) : null;
162
+ if (modelPick !== null) {
163
+ const config = { provider: modelPick.provider, model: modelPick.model };
164
+ if (parsed.preset !== null) config.preset = parsed.preset;
165
+ out.push({ config, summary: `模型改 ${modelPick.model}` });
166
+ }
167
+ if (parsed.persona || parsed.toolFilter) {
168
+ const config = {};
169
+ if (parsed.preset !== null) config.preset = parsed.preset;
170
+ if (parsed.provider !== null) config.provider = parsed.provider;
171
+ if (parsed.model !== null) config.model = parsed.model;
172
+ out.push({ config, summary: structureChangeSummary(parsed) });
173
+ }
174
+ const presetPick = downPresetPick(parsed, whitelist, presets);
175
+ if (presetPick !== null) {
176
+ out.push({ config: { preset: presetPick.id }, summary: `预设改 ${presetPick.id}` });
177
+ }
178
+ if (modelPick !== null && (parsed.persona || parsed.toolFilter)) {
179
+ const config = { provider: modelPick.provider, model: modelPick.model };
180
+ if (parsed.preset !== null) config.preset = parsed.preset;
181
+ out.push({ config, summary: `模型改 ${modelPick.model} + ${structureChangeSummary(parsed)}` });
182
+ }
183
+ if (modelPick !== null && presetPick !== null) {
184
+ out.push({ config: { provider: modelPick.provider, model: modelPick.model, preset: presetPick.id }, summary: `模型改 ${modelPick.model} + 预设改 ${presetPick.id}` });
185
+ }
186
+ return out;
187
+ }
188
+
189
+ // 推理强度下一档(阶梯同派发档位);已在顶档或无值返回 null。
190
+ function nextEffortOf(effort) {
191
+ if (typeof effort !== 'string' || effort === '') return null;
192
+ const index = EFFORT_LADDER.indexOf(effort);
193
+ if (index < 0 || index >= EFFORT_LADDER.length - 1) return null;
194
+ return EFFORT_LADDER[index + 1];
195
+ }
196
+
197
+ // maxTokens ×2 封顶硬上限;已在上限或无值返回 null。
198
+ function nextTokensOf(maxTokens) {
199
+ if (typeof maxTokens !== 'number' || maxTokens <= 0) return null;
200
+ const capped = Math.min(maxTokens * 2, MAX_TOKENS);
201
+ return capped > maxTokens ? capped : null;
202
+ }
203
+
204
+ // 升档候选清单(预算轴只升):单步(推理强度 → token 上限)在前、组合在后;
205
+ // 每个候选是完整配置快照(未升级的预算字段保留现值)。
206
+ function upCandidateConfigs(fromConfig) {
207
+ const out = [];
208
+ const effortNext = nextEffortOf(fromConfig.reasoningEffort);
209
+ const tokensNext = nextTokensOf(fromConfig.maxTokens);
210
+ if (effortNext !== null) {
211
+ const config = { reasoningEffort: effortNext };
212
+ if (typeof fromConfig.maxTokens === 'number' && fromConfig.maxTokens > 0) config.maxTokens = fromConfig.maxTokens;
213
+ out.push({ config, summary: '推理强度上调一档' });
214
+ }
215
+ if (tokensNext !== null) {
216
+ const config = { maxTokens: tokensNext };
217
+ if (typeof fromConfig.reasoningEffort === 'string' && fromConfig.reasoningEffort !== '') config.reasoningEffort = fromConfig.reasoningEffort;
218
+ out.push({ config, summary: 'token 上限上调' });
219
+ }
220
+ if (effortNext !== null && tokensNext !== null) {
221
+ out.push({ config: { reasoningEffort: effortNext, maxTokens: tokensNext }, summary: '推理强度上调一档 + token 上限上调' });
222
+ }
223
+ return out;
224
+ }
225
+
226
+ // 候选经 sanitizeProfile(strict)校验;有警告即返回 null(生成器输入受控,
227
+ // 此分支只防御未来 schema 变更)。
228
+ function validDraftOrNull(config, name, description) {
229
+ const { clean, warnings } = sanitizeProfile({ ...config, name, description }, { strict: true });
230
+ if (warnings.length > 0) return null;
231
+ return clean;
232
+ }
233
+
234
+ // 组装候选清单:一条建议 → ≤maxCandidates 条互斥候选(保守 → 更激进排序)。
235
+ // 每条候选 = { config, name, description, basis };config 含 id(必填非空)+ 能力/预算
236
+ // 字段;name 是「改动摘要(保守度标签)」;basis 供「为什么生成」展示(只含摘要,
237
+ // 无原文)。id = evo-<方向>-<摘要>-c<N>(N=1..3),冲突追加 -2/-3 同旧规则;
238
+ // 跨方向禁止(全降或全升,方向由建议本身决定)。sanitize 不过的候选整条丢弃。
239
+ export function buildEvolutionCandidates({ advice, l1Entry, l2, snapshot, whitelist, takenIds, now = Date.now(), maxCandidates = 3 } = {}) {
240
+ const a = advice !== null && typeof advice === 'object' ? advice : {};
241
+ const key = typeof a.profileKey === 'string' ? a.profileKey : '';
242
+ const suggestion = a.suggestion;
243
+ if (suggestion !== '降' && suggestion !== '升') return [];
244
+ const entry = l1Entry !== null && typeof l1Entry === 'object' ? l1Entry : {};
245
+ const snap = snapshot !== null && typeof snapshot === 'object' ? snapshot : {};
246
+ const from = sourceConfigFromKey(key, l2);
247
+ const direction = suggestion === '降' ? 'down' : 'up';
248
+ const steps = suggestion === '降'
249
+ ? downCandidateConfigs(parseProfileKey(key), snap.models, whitelist, snap.presets)
250
+ : upCandidateConfigs(from);
251
+ const taken = takenIds instanceof Set ? new Set(takenIds) : new Set(Array.isArray(takenIds) ? takenIds : []);
252
+ const base = baseDraftId(direction, key);
253
+ const directionText = suggestion === '降' ? '降级能力配置' : '上调预算';
254
+ const total = Math.min(steps.length, maxCandidates);
255
+ const out = [];
256
+ for (const [index, step] of steps.slice(0, maxCandidates).entries()) {
257
+ // -c<N> 序号与冲突重试后缀(-2)都必须完整落在 32 字符内:基名先让位再拼。
258
+ const suffix = `-c${index + 1}`;
259
+ const stem = base.slice(0, ID_MAX_LEN - suffix.length - 2);
260
+ const id = nextIdForBase(`${stem}${suffix}`, taken);
261
+ if (id === null) continue;
262
+ const name = `${step.summary}(${radicalLabel(index, total)})`;
263
+ const description = `${a.performanceText ?? ''};建议:${directionText}`;
264
+ const clean = validDraftOrNull({ ...step.config, id }, name, description);
265
+ if (clean === null) continue;
266
+ const configClean = { ...clean };
267
+ delete configClean.name;
268
+ delete configClean.description;
269
+ taken.add(id);
270
+ out.push({
271
+ config: configClean,
272
+ name,
273
+ description,
274
+ basis: {
275
+ profileKey: key,
276
+ suggestion,
277
+ confidence: a.confidence ?? entry.confidence?.level ?? '',
278
+ score: entry.score?.weighted_success ?? 0,
279
+ n: entry.confidence?.n ?? 0,
280
+ performanceText: a.performanceText ?? '',
281
+ generatedAt: now,
282
+ },
283
+ });
284
+ }
285
+ return out;
286
+ }
287
+
288
+ // 单候选兼容入口:取候选清单第一条(最保守);无候选返回 null。
289
+ export function buildEvolutionDraft(options = {}) {
290
+ const list = buildEvolutionCandidates(options);
291
+ return list.length > 0 ? list[0] : null;
292
+ }
293
+
294
+ // 对象键排序归一(供指纹输入稳定化)。
295
+ function canonicalEntry(entry) {
296
+ if (entry === null || typeof entry !== 'object') return entry;
297
+ const out = {};
298
+ for (const key of Object.keys(entry).sort()) out[key] = entry[key];
299
+ return out;
300
+ }
301
+
302
+ // 候选携带的「生成时刻 profile 指纹」:身份键 + 变更前配置 + 匹配该身份键的
303
+ // 注册表方案(排序归一后哈希)。人工编辑方案或聚合数据变化都会使指纹失配,
304
+ // 装配层据此拒绝应用(不静默应用过期候选)。
305
+ export function profileFingerprint({ profileKey, from, registryEntries = [] } = {}) {
306
+ const entries = Array.isArray(registryEntries) ? registryEntries : [];
307
+ const sorted = entries
308
+ .map(canonicalEntry)
309
+ .sort((a, b) => String(a?.id ?? '').localeCompare(String(b?.id ?? '')));
310
+ const payload = JSON.stringify({ profileKey, from: canonicalEntry(from), registry: sorted });
311
+ return createHash('sha1').update(payload, 'utf8').digest('hex');
312
+ }
@@ -0,0 +1,269 @@
1
+ // lib/core/evolution-engine.mjs — 进化候选装配(生成 + 应用写路径)。
2
+ // 生成:computeAdvice 之后把每条建议推导成 ≤3 条互斥候选资产(evolution-draft),
3
+ // 逐候选过三道闸(复用 assessApplyGates 全量)后落资产域。更新策略与有效期用户
4
+ // 可配(state.json):自动换新在「候选到期且数据显著变化」时把旧候选转 expired
5
+ // (留审计)再产新一代;手动维护不自动换代、不自动过期,只经「重新生成候选」
6
+ // 刷新(留审计)。建议链开关(evolutionAdvice)与建议面板同生共死;apply 开关
7
+ // 只门应用写路径,不门生成。
8
+ // 应用:只从受控路由进来——人工确认 → 指纹校验(编辑后过期)→ 观察期隔离 →
9
+ // 三道闸(whitelist → cost → intersection,审批永不)→ 落写新 profile 并立即
10
+ // 持久化 → 资产 draft→proposed;通过/拒绝两分支都写 suggestion-apply 审计。
11
+ // 串行化:进程级按身份键单写锁(同键排队,跨键并行);候选池恒 system-trust
12
+ // 白名单(不叠加逃生舱放行集);apply 只改新方案默认值,per-call 优先级不变。
13
+
14
+ import { join } from 'node:path';
15
+ import { sanitizeProfile } from './pure.mjs';
16
+ import { assertCostGuard } from './cost-guard.mjs';
17
+ import { profileFingerprint, sourceConfigFromKey } from './evolution-draft.mjs';
18
+ import { createEvolutionAssets } from './evolution-assets.mjs';
19
+ import { computeAdvice, readSummaries, recentDispatchDetails } from './evolution-advice.mjs';
20
+ import { generateCandidates, regenerateCandidates, matchingRegistryProfiles } from './evolution-generate.mjs';
21
+
22
+ // --- 三道闸(apply 口径,对齐派发守卫同源逻辑)-------------------------------
23
+
24
+ function whitelistGate(config, whitelist) {
25
+ const preset = typeof config.preset === 'string' && config.preset !== '' ? config.preset : null;
26
+ if (preset === null || preset === 'inherit') return { verdict: 'pass', reason: '未指定预设' };
27
+ const allowed = whitelist instanceof Set ? whitelist.has(preset) : Array.isArray(whitelist) && whitelist.includes(preset);
28
+ return allowed
29
+ ? { verdict: 'pass' }
30
+ : { verdict: 'fail', reason: `目标预设 ${preset} 不在 system-trust 白名单(候选池恒 system-trust)` };
31
+ }
32
+
33
+ // 成本闸:硬上限 + 能力面核验(与派发 cost guard 同一实现);parent 用插件 ctx
34
+ // 占位(apply 无父会话,llm 取进程级服务)。失败不 throw,返回 verdict+reason。
35
+ async function costGate(config, llm, allowFailOpen, logger, catalog) {
36
+ const checks = [];
37
+ const parent = { ctx: { get: (name) => (name === 'llm' ? llm : undefined) }, options: {} };
38
+ try {
39
+ await assertCostGuard(parent, config, allowFailOpen, logger, catalog, (entry) => checks.push(entry));
40
+ return { verdict: 'pass', checks };
41
+ } catch (error) {
42
+ return { verdict: 'fail', reason: error instanceof Error ? error.message : String(error), checks };
43
+ }
44
+ }
45
+
46
+ // 交集闸(apply 口径):apply 无父/子会话,真交集在派发时由宿主计算——此处按
47
+ // 工具目录作父集代理校验 toolFilter 只减不增:allow 必须全部是已知工具,
48
+ // 收窄后(去 deny/∩allow)不得为空(空集 fail-loud 语义与派发一致)。
49
+ function intersectionGate(config, toolNames) {
50
+ const tf = config.toolFilter;
51
+ if (tf === undefined || tf === null) {
52
+ return { verdict: 'pass', reason: '无工具过滤器(真实交集在派发时由宿主计算)' };
53
+ }
54
+ const allow = Array.isArray(tf.allow) ? tf.allow : [];
55
+ const deny = Array.isArray(tf.deny) ? tf.deny : [];
56
+ const known = new Set(toolNames);
57
+ const unknownAllow = allow.filter((name) => !known.has(name));
58
+ if (unknownAllow.length > 0) {
59
+ return { verdict: 'fail', reason: `toolFilter.allow 含未知工具:${unknownAllow.slice(0, 3).join('、')}` };
60
+ }
61
+ const effective = toolNames.filter((name) => !deny.includes(name) && (allow.length === 0 || allow.includes(name)));
62
+ if (effective.length === 0) {
63
+ return { verdict: 'fail', reason: '工具交集为空:toolFilter 收窄后无可派发工具,拒绝' };
64
+ }
65
+ return { verdict: 'pass' };
66
+ }
67
+
68
+ // 三道闸串行评估:whitelist → cost → intersection(approval 恒 'never')。
69
+ // 任一失败立即返回该闸 verdict+reason;未评估的后续闸保留 skipped 占位(审计
70
+ // gates 逐项 verdict 齐全);全过返回 gates 快照供审计。
71
+ async function assessApplyGates(deps, config) {
72
+ const skipped = { verdict: 'skipped', reason: '前序闸未通过,未评估' };
73
+ const gates = { whitelist: { ...skipped }, cost: { ...skipped }, intersection: { ...skipped }, approval: 'never' };
74
+ gates.whitelist = whitelistGate(config, deps.whitelist);
75
+ if (gates.whitelist.verdict === 'fail') return { ok: false, gates, reason: gates.whitelist.reason, checks: [] };
76
+ gates.cost = await costGate(config, deps.getLlm(), deps.getAllowFailOpen(), deps.logger, deps.catalog);
77
+ if (gates.cost.verdict === 'fail') return { ok: false, gates, reason: gates.cost.reason, checks: gates.cost.checks };
78
+ const snapshot = await deps.catalog.getSnapshot();
79
+ const toolNames = (Array.isArray(snapshot.tools) ? snapshot.tools : [])
80
+ .map((t) => t?.name)
81
+ .filter((name) => typeof name === 'string');
82
+ gates.intersection = intersectionGate(config, toolNames);
83
+ if (gates.intersection.verdict === 'fail') return { ok: false, gates, reason: gates.intersection.reason, checks: [] };
84
+ return { ok: true, gates, checks: gates.cost.checks };
85
+ }
86
+
87
+ // --- 审计(suggestion-apply)--------------------------------------------------
88
+
89
+ function skippedGates(reason) {
90
+ const skipped = { verdict: 'skipped', reason };
91
+ return { whitelist: skipped, cost: skipped, intersection: skipped, approval: 'never' };
92
+ }
93
+
94
+ // suggestion-apply 事件:from/to/axis/direction/gates/human_confirmed/
95
+ // baseline_snapshot 字段级齐备;拒绝分支附 reason。
96
+ function applyAuditEntry(asset, fields) {
97
+ const entry = {
98
+ event: 'suggestion-apply',
99
+ profile_id: fields.profileId !== undefined ? fields.profileId : asset.id,
100
+ from: asset.from,
101
+ to: asset.to,
102
+ axis: asset.axis,
103
+ direction: asset.direction,
104
+ gates: fields.gates ?? skippedGates(fields.reason ?? '未评估'),
105
+ human_confirmed: fields.humanConfirmed === true,
106
+ baseline_snapshot: asset.baseline_snapshot,
107
+ };
108
+ if (fields.reason !== undefined) entry.reason = fields.reason;
109
+ return entry;
110
+ }
111
+
112
+ // --- 应用写路径(受控路由专用;绝不自动落写)-----------------------------------
113
+
114
+ // 应用时刻指纹重算:读当前聚合(summaries)+ 当前注册表,与生成时刻指纹比对。
115
+ function currentFingerprint(deps, asset) {
116
+ const summaries = deps.getSummaries();
117
+ if (summaries === null) return { ok: false, reason: '聚合数据缺失,无法校验候选指纹' };
118
+ const l1 = summaries.l1 !== null && typeof summaries.l1 === 'object' ? summaries.l1 : {};
119
+ if (l1[asset.profile_key] === undefined) return { ok: false, reason: '候选指纹失配:聚合组已不存在' };
120
+ const from = sourceConfigFromKey(asset.profile_key, summaries.l2);
121
+ const registry = matchingRegistryProfiles(deps.store, asset.profile_key);
122
+ return { ok: true, value: profileFingerprint({ profileKey: asset.profile_key, from, registryEntries: registry }) };
123
+ }
124
+
125
+ // 单次应用(在身份键锁内执行):确认 → 观察期隔离 → 指纹校验 → 三道闸 →
126
+ // 落写 + 持久化 → 资产转 proposed;每个拒绝/通过分支都写审计。
127
+ async function applyOnce(deps, asset, humanConfirmed) {
128
+ const audit = (entry) => deps.evoLedger.recordGovernanceAudit(entry);
129
+ const reject = (reason, extra = {}) => {
130
+ audit(applyAuditEntry(asset, { reason, humanConfirmed, ...extra }));
131
+ return { ok: false, reason, ...(extra.checks !== undefined ? { checks: extra.checks } : {}) };
132
+ };
133
+ const expireAndReject = (reason, extra = {}) => {
134
+ deps.assets.transition(asset.id, 'expired');
135
+ return reject(reason, extra);
136
+ };
137
+ if (asset.state === 'proposed' || asset.state === 'applied') {
138
+ return reject('该候选已处置(观察期或已应用),不能重复应用');
139
+ }
140
+ if (asset.state !== 'draft') return reject(`候选状态不允许应用(${String(asset.state)})`);
141
+ if (typeof asset.expiry === 'number' && Date.now() >= asset.expiry) {
142
+ return expireAndReject('候选已过期,需重新生成');
143
+ }
144
+ if (humanConfirmed !== true) return reject('必须人工确认后才能应用(human_confirmed=true)');
145
+ const otherProposed = deps.assets.findUnresolved(asset.profile_key)
146
+ .some((e) => e.id !== asset.id && e.state === 'proposed');
147
+ if (otherProposed) return reject('该方案处于观察期,禁止再次应用(观察期隔离)');
148
+ const fingerprint = currentFingerprint(deps, asset);
149
+ if (fingerprint.ok !== true) return expireAndReject(fingerprint.reason);
150
+ if (fingerprint.value !== asset.profile_fingerprint) {
151
+ return expireAndReject('候选指纹失配:方案已被编辑或聚合数据已变化,需重新生成');
152
+ }
153
+ if (deps.store.profiles.has(asset.config?.id)) {
154
+ return reject('候选 profile id 已存在(请先删除或改名)');
155
+ }
156
+ const gates = await assessApplyGates(deps, asset.config ?? {});
157
+ if (!gates.ok) return reject(gates.reason, { gates: gates.gates, checks: gates.checks });
158
+ const { clean, warnings } = sanitizeProfile(
159
+ { ...(asset.config ?? {}), name: asset.name, description: asset.description },
160
+ { strict: true }
161
+ );
162
+ if (warnings.length > 0) {
163
+ const detail = warnings.map((w) => `${w.field}:${w.reason}`).join(';');
164
+ return reject(`写入被拒绝:${detail}`, { gates: gates.gates });
165
+ }
166
+ deps.store.profiles.set(clean.id, { ...clean, persisted: true });
167
+ const persisted = deps.store.persistProfiles();
168
+ deps.assets.transition(asset.id, 'proposed', { applied_at: Date.now() });
169
+ audit(applyAuditEntry(asset, { profileId: clean.id, gates: gates.gates, humanConfirmed: true }));
170
+ return { ok: true, id: clean.id, persisted: persisted?.persisted === true, state: 'proposed', checks: gates.checks };
171
+ }
172
+
173
+ // 进程级按身份键单写锁:同键 apply 排队(并发只生效一次),跨键并行。
174
+ function withApplyLock(locks, key, fn) {
175
+ const previous = locks.get(key) ?? Promise.resolve();
176
+ const run = previous.then(fn, fn);
177
+ locks.set(key, run.catch(() => {}));
178
+ return run;
179
+ }
180
+
181
+ // 应用入口:apply 开关门 → 读资产 → 在身份键锁内执行单次应用。
182
+ function applyCandidate(deps, locks, { assetId, humanConfirmed }) {
183
+ if (!deps.getApplyEnabled()) return { ok: false, reason: '进化 apply 开关未开启(默认关,可在设置页开启)' };
184
+ const asset = deps.assets.get(assetId);
185
+ if (asset === undefined) return { ok: false, reason: '候选资产不存在(可能已过期或被清理)' };
186
+ const key = typeof asset.profile_key === 'string' ? asset.profile_key : assetId;
187
+ return withApplyLock(locks, key, () => applyOnce(deps, deps.assets.get(assetId), humanConfirmed === true));
188
+ }
189
+
190
+ // 进化装配 helper:资产域 + 引擎 + 建议/候选生成 helper。adviceSource 是
191
+ // /list(建议面板数据)的唯一入口——computeAdvice 之后随建议链开关把建议推导
192
+ // 成候选资产(换代判定按用户设置),避免多处重复生成;生成失败只 warn 不影响
193
+ // 建议展示(fail-soft)。注入段文本走 buildAdviceText。
194
+ export function createEvolutionAssembly({
195
+ ctx,
196
+ home,
197
+ store,
198
+ catalog,
199
+ evoLedger,
200
+ adviceWhitelist,
201
+ getEvolutionAdvice,
202
+ pluginVersion,
203
+ }) {
204
+ const logger = ctx.logger;
205
+ const assets = createEvolutionAssets({ dshHome: home, logger, onGovernanceFailure: () => evoLedger.markGovernanceFailure() });
206
+ const applyEnabled = store.loadApplyEnabled();
207
+ const summariesFile = join(home, 'subagent-evolution', 'summaries.json');
208
+ const dispatchFile = join(home, 'subagent-evolution', 'dispatch.jsonl');
209
+ const engine = createEvolutionEngine({
210
+ store,
211
+ catalog,
212
+ assets,
213
+ evoLedger,
214
+ whitelist: adviceWhitelist,
215
+ logger,
216
+ getApplyEnabled: () => applyEnabled,
217
+ getAdviceEnabled: getEvolutionAdvice,
218
+ getAllowFailOpen: () => store.getAllowFailOpen(),
219
+ getLlm: () => ctx.get('llm'),
220
+ getSummaries: () => readSummaries(summariesFile, logger, { onLoss: () => evoLedger.markMigrationLoss() }),
221
+ getCandidateMode: () => store.loadCandidateMode(),
222
+ getCandidateTtlH: () => store.loadCandidateTtlH(),
223
+ pluginVersion,
224
+ });
225
+ const adviceSource = async () => {
226
+ const result = computeAdvice({ summariesFile, dispatchFile, whitelist: adviceWhitelist, logger, onLoss: () => evoLedger.markMigrationLoss() });
227
+ // 候选生成与建议链开关同生共死(同一 getter):面板开才生成与下发候选,
228
+ // 面板关不悄悄生成;apply 开关只门应用写路径。
229
+ let generated = { generated: [], skipped: [] };
230
+ let candidates = [];
231
+ if (getEvolutionAdvice()) {
232
+ try {
233
+ generated = await engine.generate({ advice: result.advice, summaries: result.summaries });
234
+ candidates = engine.list().filter((a) => a !== null && typeof a === 'object' && a.source === 'evolution' && a.state === 'draft');
235
+ } catch (error) {
236
+ logger.warn(`[dsh-subagent-profile] 进化候选生成失败:${error instanceof Error ? error.message : String(error)}`);
237
+ }
238
+ }
239
+ const adviceDetails = recentDispatchDetails(dispatchFile, result.advice.map((a) => a.profileKey), { limit: 5 }); // 近期派发明细:按建议键读台账最近 5 次(只含渲染字段,无原文)
240
+ return { ...result, generated, candidates, adviceDetails };
241
+ };
242
+ return { assets, evolution: engine, adviceSource };
243
+ }
244
+
245
+ export function createEvolutionEngine({
246
+ store,
247
+ catalog,
248
+ assets,
249
+ evoLedger,
250
+ whitelist,
251
+ logger,
252
+ getApplyEnabled,
253
+ getAdviceEnabled,
254
+ getAllowFailOpen,
255
+ getLlm,
256
+ getSummaries,
257
+ pluginVersion,
258
+ getCandidateMode = () => 'auto',
259
+ getCandidateTtlH = () => 24,
260
+ }) {
261
+ const deps = { store, catalog, assets, evoLedger, whitelist, logger, getApplyEnabled, getAdviceEnabled, getAllowFailOpen, getLlm, getSummaries, pluginVersion, getCandidateMode, getCandidateTtlH, assessGates: (config) => assessApplyGates(deps, config) };
262
+ const applyLocks = new Map();
263
+ return {
264
+ list: () => assets.list(),
265
+ generate: (input) => generateCandidates(deps, input),
266
+ regenerate: (input) => regenerateCandidates(deps, input),
267
+ apply: (input) => applyCandidate(deps, applyLocks, input),
268
+ };
269
+ }