dsh-subagent-profile 0.3.4 → 0.5.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.
Files changed (38) hide show
  1. package/README.md +13 -8
  2. package/README.zh.md +13 -8
  3. package/index.mjs +99 -95
  4. package/lib/client.js +1288 -189
  5. package/lib/core/decision-trace.mjs +20 -1
  6. package/lib/core/delegation.mjs +3 -2
  7. package/lib/core/dispatch-gates.mjs +122 -9
  8. package/lib/core/dispatch-schema.mjs +45 -7
  9. package/lib/core/dispatch-tool.mjs +24 -25
  10. package/lib/core/evolution-advice.mjs +154 -34
  11. package/lib/core/evolution-assets.mjs +167 -0
  12. package/lib/core/evolution-canary.mjs +427 -0
  13. package/lib/core/evolution-draft.mjs +333 -0
  14. package/lib/core/evolution-engine.mjs +413 -0
  15. package/lib/core/evolution-generate.mjs +180 -0
  16. package/lib/core/evolution-lag.mjs +78 -0
  17. package/lib/core/evolution-ledger.mjs +135 -27
  18. package/lib/core/evolution-persistence.mjs +213 -0
  19. package/lib/core/evolution-renewal.mjs +64 -0
  20. package/lib/core/evolution-routes.mjs +234 -0
  21. package/lib/core/http-helpers.mjs +35 -0
  22. package/lib/core/http-routes.mjs +35 -39
  23. package/lib/core/model-policy.mjs +77 -0
  24. package/lib/core/presets-sync.mjs +24 -2
  25. package/lib/core/profile-directory.mjs +17 -0
  26. package/lib/core/profile-provider.mjs +9 -3
  27. package/lib/core/profiles-query.mjs +178 -0
  28. package/lib/core/profiles-store.mjs +56 -8
  29. package/lib/core/pure.mjs +1 -1
  30. package/lib/core/session-read.mjs +17 -0
  31. package/lib/core/shims.mjs +2 -1
  32. package/package.json +2 -2
  33. package/presets/orchestrator-v2/NOTICE +7 -0
  34. package/presets/{orchestrator → orchestrator-v2}/agent.cordis.yml +78 -7
  35. package/presets/orchestrator-v2/custom-bash.mjs +213 -0
  36. package/presets/orchestrator-v2/preset.yml +2 -0
  37. package/presets/orchestrator-v2/tool-bootstrap.mjs +620 -0
  38. package/presets/orchestrator/preset.yml +0 -2
@@ -1,13 +1,16 @@
1
1
  // lib/core/evolution-advice.mjs — evolution:advice 派发优化建议链(从 evolution-summary.mjs
2
2
  // 拆出以守文件行门):suggestAdvice(纯函数四条件 + 升/降轴硬规则)、readSummaries /
3
- // refreshSummaries / computeAdvice / buildAdviceText(生产聚合触发与注入文本)。
3
+ // refreshSummaries / computeAdvice / renderAdviceText(人类面板数据源)。
4
4
  // 聚合本体(computeSummaries/writeSummaries)仍驻 evolution-summary.mjs;本模块只读
5
5
  // 台账并调度聚合,绝无写回台账路径。fail-soft 纪律与 evolution-summary 同源。
6
+ // 本轮起模型侧注入已下架(index.mjs 的 evolution:advice section 恒空):本链只为
7
+ // 人类面板(建议卡/提醒中心/审计行)服务,不再生成模型可见文本。
6
8
  //
7
9
  // 依赖方向:本模块 → evolution-summary.mjs(单向,无环)。
8
10
 
9
11
  import { existsSync, readFileSync, statSync } from 'node:fs';
10
- import { computeSummaries, parseDispatchRecords, writeSummaries, confidenceLevel } from './evolution-summary.mjs';
12
+ import { computeSummaries, parseDispatchRecords, writeSummaries, confidenceLevel, profileKeyOf } from './evolution-summary.mjs';
13
+ import { loadWithMigration } from './evolution-persistence.mjs';
11
14
 
12
15
  // --- 派发优化建议(suggestAdvice)-----------------------------------------------
13
16
  // 从单条 L1 聚合(l1Entry)产出「一项确定性建议」或 null。四条件齐备才产出:
@@ -17,6 +20,8 @@ import { computeSummaries, parseDispatchRecords, writeSummaries, confidenceLevel
17
20
  // (平)。阈值 successThreshold / confidenceMinLevel / cooldownMs 为建议初值,
18
21
  // 待实测后校准。
19
22
  const CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
23
+ // 置信度级别中文映射(面板文本人话化,与客户端 ZH.confidenceZh 同口径)。
24
+ const CONFIDENCE_ZH = { low: '低', medium: '中等', high: '高' };
20
25
 
21
26
  function hasCapabilityAxis(profileKey) {
22
27
  return /(^|\|)(preset|provider|model|persona|toolFilter):/.test(String(profileKey ?? ''));
@@ -50,7 +55,7 @@ function performanceText(entry) {
50
55
  const perf = entry?.perf ?? {};
51
56
  const parts = [
52
57
  `过去 ${entry?.deployments_total ?? 0} 次:完成 ${outcome.completed ?? 0}、失败 ${outcome.failed ?? 0}、终止 ${outcome.killed ?? 0}`,
53
- `加权成功分 ${entry?.score?.weighted_success ?? 0}`,
58
+ `近期表现分 ${entry?.score?.weighted_success ?? 0}`,
54
59
  ];
55
60
  if (perf.avg_elapsed_ms !== null && perf.avg_elapsed_ms !== undefined) parts.push(`平均耗时 ${perf.avg_elapsed_ms}ms`);
56
61
  if (perf.avg_output_len !== null && perf.avg_output_len !== undefined) parts.push(`平均输出 ${perf.avg_output_len} 字符`);
@@ -63,9 +68,71 @@ function directionFor(profileKey, capability, budget) {
63
68
  return '平';
64
69
  }
65
70
 
71
+ // --- 建议对象与理由(确定性人话)-----------------------------------------------
72
+ // objectText:建议直接带对象(模型改用更低档 / 去掉自定义人格……),按最保守改变
73
+ // 取一段(自定义人格/工具过滤先、模型次之、预设最后——与候选生成降档的保守序一致);
74
+ // 升预算方向读 L2 预算轴存在性给「推理强度上调一档 / maxTokens 上调」。
75
+ // reasonText:理由一句话,从身份键段 + 聚合计数推导(同输入恒同输出,不含任何
76
+ // 配置原文)。
77
+
78
+ function adviceKeySegments(profileKey) {
79
+ const parsed = { preset: false, model: false, persona: false, toolFilter: false };
80
+ for (const part of String(profileKey ?? '').split('|')) {
81
+ if (part.startsWith('preset:')) parsed.preset = true;
82
+ else if (part.startsWith('model:')) parsed.model = true;
83
+ else if (part === 'persona:1') parsed.persona = true;
84
+ else if (part === 'toolFilter:1') parsed.toolFilter = true;
85
+ }
86
+ return parsed;
87
+ }
88
+
89
+ function downObjectText(segments) {
90
+ if (segments.persona) return '去掉自定义人格';
91
+ if (segments.toolFilter) return '移除工具过滤';
92
+ if (segments.model) return '模型改用更低档';
93
+ if (segments.preset) return '预设改用更低档';
94
+ return '能力配置降级';
95
+ }
96
+
97
+ function budgetObjectText(profileKey, l2) {
98
+ const source = l2 !== null && typeof l2 === 'object' ? l2 : {};
99
+ let hasEffort = false;
100
+ let hasTokens = false;
101
+ for (const axisKey of Object.keys(source)) {
102
+ if (typeof axisKey !== 'string') continue;
103
+ if (axisKey.startsWith(`${profileKey}:reasoningEffort:`)) hasEffort = true;
104
+ else if (axisKey.startsWith(`${profileKey}:maxTokens:`)) hasTokens = true;
105
+ }
106
+ if (hasEffort) return '推理强度上调一档';
107
+ if (hasTokens) return 'maxTokens 上调';
108
+ return '预算上调';
109
+ }
110
+
111
+ function downReasonText(segments, entry) {
112
+ const n = Number.isFinite(entry?.confidence?.n) ? entry.confidence.n : (entry?.deployments_total ?? 0);
113
+ const killed = Number.isFinite(entry?.outcome?.killed) ? entry.outcome.killed : 0;
114
+ if (segments.persona) {
115
+ return killed > 0
116
+ ? `带自定义人格的派发 ${n} 次里 ${killed} 次被终止,去掉后可复用父会话统一提示`
117
+ : '去掉自定义人格后,子 Agent 复用父会话统一提示,配置更简单';
118
+ }
119
+ if (segments.toolFilter) return '移除工具过滤后,子 Agent 的工具面回到父会话范围,配置更简单';
120
+ if (segments.model) return '同类任务改用更低档模型,成本通常更低';
121
+ if (segments.preset) return '相邻低档预设能力相近,成本通常更低';
122
+ return `近期表现低于预期(近期表现分 ${entry?.score?.weighted_success ?? 0}),降低能力配置是更稳妥的方向`;
123
+ }
124
+
125
+ function upReasonText(profileKey, l2) {
126
+ const text = budgetObjectText(profileKey, l2);
127
+ if (text === '推理强度上调一档') return '推理强度上调一档可提升复杂任务完成质量';
128
+ if (text === 'maxTokens 上调') return 'token 上限上调后可减少中途截断';
129
+ return '预算上调后可提升复杂任务完成质量';
130
+ }
131
+
66
132
  export function suggestAdvice({
67
133
  l1Entry,
68
134
  profileKey = '',
135
+ l2 = null,
69
136
  minN = 3,
70
137
  cooldownMs = 600000,
71
138
  now = Date.now(),
@@ -90,35 +157,31 @@ export function suggestAdvice({
90
157
  const axes = entry.axes !== null && typeof entry.axes === 'object' ? entry.axes : null;
91
158
  const capability = axes !== null && typeof axes.capability === 'boolean' ? axes.capability : hasCapabilityAxis(key);
92
159
  const budget = axes !== null && typeof axes.budget === 'boolean' ? axes.budget : hasBudgetAxis(key);
160
+ const suggestion = directionFor(key, capability, budget);
161
+ const segments = adviceKeySegments(key);
93
162
 
94
163
  return {
95
164
  profileKey: key,
96
165
  performanceText: performanceText(entry),
97
- suggestion: directionFor(key, capability, budget),
166
+ suggestion,
167
+ // 建议对象与理由:确定性人话,同输入恒同输出;面板渲染「建议:…」与「理由:…」。
168
+ objectText: suggestion === '降' ? downObjectText(segments) : suggestion === '升' ? budgetObjectText(key, l2) : '',
169
+ reasonText: suggestion === '降' ? downReasonText(segments, entry) : suggestion === '升' ? upReasonText(key, l2) : '',
98
170
  confidence: level,
99
171
  cooldownUntil: now + cooldownMs,
100
172
  };
101
173
  }
102
174
 
103
- // --- 派发优化建议(evolution:advice 注入段,默认关)----------------------------
175
+ // --- 派发优化建议(面板侧;模型侧注入下架,注入段恒空)-------------------
104
176
 
105
- // 读 summaries.json(存在才读;损坏/形状不符 → null,fail-soft)。
106
- // 版本化:只接受 v:1;未知/超前版本 fail-soft 跳过(v1 无前驱,迁移表空)。
107
- export function readSummaries(file, logger) {
108
- try {
109
- if (!existsSync(file)) return null;
110
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
111
- if (parsed === null || typeof parsed !== 'object' || parsed.l1 === null || typeof parsed.l1 !== 'object') return null;
112
- if (parsed.v !== 1) {
113
- if (logger !== undefined && typeof logger.warn === 'function') {
114
- logger.warn(`[dsh-subagent-profile] summaries.json 版本 ${String(parsed.v)} 未知,跳过该资产(fail-soft)`);
115
- }
116
- return null;
117
- }
118
- return parsed;
119
- } catch {
120
- return null;
121
- }
177
+ // 读 summaries.json(存在才读)。版本化经通用持久化机制:v 低于当前版本时内存
178
+ // 前向迁移后原子写回;损坏/形状不符/未知或超前版本 fail-soft 返回 null,并告警
179
+ // + onLoss(迁移丢失计数)。l1 形状校验保留(读端数据契约)。
180
+ export function readSummaries(file, logger, opts = {}) {
181
+ const data = loadWithMigration(file, 'summaries', { logger, onLoss: opts.onLoss });
182
+ if (data === null) return null;
183
+ if (data.l1 === null || typeof data.l1 !== 'object') return null;
184
+ return data;
122
185
  }
123
186
 
124
187
  // 生产聚合触发点(T1 修复):读 dispatch.jsonl → parseDispatchRecords → computeSummaries
@@ -152,14 +215,15 @@ function summariesStale(dispatchFile, summariesFile) {
152
215
  }
153
216
  }
154
217
 
155
- // 注入段文本:全局聚合摘要 + 各 profile 一条确定性建议(人类读、非指令)。
218
+ // 面板侧文本:全局聚合摘要 + 各 profile 一条确定性建议(人类读、非指令;
219
+ // 只供人类面板渲染,不再进模型侧注入段)。
156
220
  export function renderAdviceText(advice, global) {
157
221
  const direction = (value) => (value === '升' ? '可上调预算' : value === '降' ? '建议降级' : '维持现状');
158
222
  const lines = advice.map((a) => {
159
223
  const untilText = Number.isFinite(a.cooldownUntil)
160
224
  ? `(冷却至 ${new Date(a.cooldownUntil).toLocaleTimeString('zh-CN', { hour12: false })})`
161
225
  : '';
162
- return `- ${a.profileKey}:${a.performanceText};${direction(a.suggestion)}(置信度 ${a.confidence})${untilText}`;
226
+ return `- ${a.profileKey}:${a.performanceText};${direction(a.suggestion)}(置信度 ${CONFIDENCE_ZH[a.confidence] ?? a.confidence})${untilText}`;
163
227
  });
164
228
  const total = global.completed + global.failed + global.killed;
165
229
  return `以下为过去派发的只读统计与确定性建议(仅供人类参考,不改变派发行为):\n` +
@@ -168,8 +232,9 @@ export function renderAdviceText(advice, global) {
168
232
 
169
233
  // 建议产出后把各 profile 的新 cooldown.until_ts 写回 summaries.json(原子写、
170
234
  // fail-soft)。写前重读磁盘再合并:并发注入各自基于最新磁盘态写回,把互相覆盖
171
- // 窗口压到最小(残余竞态:读-写间隙,彻底解决需进程内锁,成本不值)。
172
- function writeBackCooldowns(file, data, produced, logger) {
235
+ // 窗口压到最小(残余竞态:读-写间隙,彻底解决需进程内锁,成本不值)。回滚冷却
236
+ // 注入(suggestion-rollback 后同键冷却)复用同一写入口。
237
+ export function writeBackCooldowns(file, data, produced, logger) {
173
238
  const fresh = readSummaries(file, logger);
174
239
  const base = fresh !== null && typeof fresh === 'object' ? fresh : data;
175
240
  const l1 = { ...(base.l1 ?? {}) };
@@ -183,15 +248,31 @@ function writeBackCooldowns(file, data, produced, logger) {
183
248
  }
184
249
  }
185
250
 
186
- // 结构化建议(读+算,不写回 cooldown、不渲染文本):供注入段与只读面板共用。
187
- export function computeAdvice({ summariesFile, dispatchFile, whitelist, logger }) {
251
+ // 滞后防振荡 streak 写回 summaries.json(原子写、fail-soft):与 cooldown 同口径
252
+ // 重读磁盘再合并;写入失败只 warn,内存计数仍驱动本轮生成。
253
+ export function writeBackStreaks(summariesFile, streaks, logger) {
254
+ const fresh = readSummaries(summariesFile, logger);
255
+ if (fresh === null || typeof fresh !== 'object') return;
256
+ const l1 = { ...(fresh.l1 ?? {}) };
257
+ for (const streak of streaks) {
258
+ const group = l1[streak.profileKey];
259
+ l1[streak.profileKey] = { ...group, streak: { axis: streak.axis, direction: streak.direction, count: streak.count, last_ts: streak.last_ts } };
260
+ }
261
+ const res = writeSummaries(summariesFile, { l1, l2: fresh.l2 ?? {} });
262
+ if (res.persisted !== true) {
263
+ logger.warn(`[dsh-subagent-profile] evolution:advice streak 写回失败:${res.error ?? '未知错误'}`);
264
+ }
265
+ }
266
+
267
+ // 结构化建议(读+算,不写回 cooldown、不渲染文本):供人类面板与只读展示共用。
268
+ export function computeAdvice({ summariesFile, dispatchFile, whitelist, logger, onLoss }) {
188
269
  if (dispatchFile !== undefined && summariesStale(dispatchFile, summariesFile)) refreshSummaries({ dispatchFile, summariesFile, logger });
189
- let data = readSummaries(summariesFile, logger);
270
+ let data = readSummaries(summariesFile, logger, { onLoss });
190
271
  // summaries.json 存在但不可读(损坏/版本不符)→ fail-soft 重建(幂等可复算),
191
272
  // 损坏资产不阻断建议产出;台账缺失时维持空建议(refreshSummaries 的 no-ledger 语义)。
192
273
  if (data === null && dispatchFile !== undefined && existsSync(summariesFile)) {
193
274
  refreshSummaries({ dispatchFile, summariesFile, logger });
194
- data = readSummaries(summariesFile, logger);
275
+ data = readSummaries(summariesFile, logger, { onLoss });
195
276
  }
196
277
  if (data === null) return { advice: [], global: { completed: 0, failed: 0, killed: 0, total: 0 }, produced: [], summaries: null };
197
278
  const l1 = data.l1 ?? {};
@@ -205,7 +286,7 @@ export function computeAdvice({ summariesFile, dispatchFile, whitelist, logger }
205
286
  global.completed += group.outcome?.completed ?? 0;
206
287
  global.failed += group.outcome?.failed ?? 0;
207
288
  global.killed += group.outcome?.killed ?? 0;
208
- const suggestion = suggestAdvice({ l1Entry: group, profileKey });
289
+ const suggestion = suggestAdvice({ l1Entry: group, profileKey, l2: data.l2 });
209
290
  if (suggestion === null) continue;
210
291
  // 只对实际产出的建议键校验候选(N 不足不产出建议的组不要求 system-trust)。
211
292
  assertSystemCandidate(whitelist, presetFromKey(profileKey));
@@ -215,10 +296,49 @@ export function computeAdvice({ summariesFile, dispatchFile, whitelist, logger }
215
296
  return { advice, global: { ...global, total: global.completed + global.failed + global.killed }, produced, summaries: data };
216
297
  }
217
298
 
218
- // 派发优化建议段文本生成:computeAdvice → 写回 cooldown(防每请求重复注入)→ 渲染;空建议返回空串。
219
- export function buildAdviceText({ summariesFile, dispatchFile, whitelist, logger }) {
220
- const { advice, global, produced, summaries } = computeAdvice({ summariesFile, dispatchFile, whitelist, logger });
299
+ // 面板侧建议文本生成:computeAdvice → 写回 cooldown(防每请求重复注入)→ 渲染;空建议返回空串。
300
+ // 仅供人类面板(/list 建议区)调用;模型侧注入段已下架(index.mjs 恒空)。
301
+ export function buildAdviceText({ summariesFile, dispatchFile, whitelist, logger, onLoss }) {
302
+ const { advice, global, produced, summaries } = computeAdvice({ summariesFile, dispatchFile, whitelist, logger, onLoss });
221
303
  if (advice.length === 0) return '';
222
304
  writeBackCooldowns(summariesFile, summaries, produced, logger);
223
305
  return renderAdviceText(advice, global);
224
306
  }
307
+
308
+ // --- 近期派发明细(面板折叠表数据源)------------------------------------------
309
+ // 从派发台账读行,用「行生效配置 → profileKeyOf(与聚合层同口径)」匹配各建议键,
310
+ // 取最近 limit 条(按 ts 倒序)。只取渲染所需字段(时间/会话/子 Agent/结果/耗时/模式),
311
+ // 不含任何原文。台账缺失/读失败/无匹配 → 空表(fail-soft,面板显示 0 次)。
312
+ export function recentDispatchDetails(dispatchFile, profileKeys, { limit = 5 } = {}) {
313
+ const out = {};
314
+ const wanted = new Set(Array.isArray(profileKeys) ? profileKeys.filter((key) => typeof key === 'string') : []);
315
+ if (typeof dispatchFile !== 'string' || !existsSync(dispatchFile) || wanted.size === 0) return out;
316
+ let lines;
317
+ try {
318
+ lines = readFileSync(dispatchFile, 'utf8').split('\n');
319
+ } catch {
320
+ return out;
321
+ }
322
+ const { records } = parseDispatchRecords(lines);
323
+ const byKey = new Map();
324
+ for (const record of records) {
325
+ if (record === null || typeof record !== 'object') continue;
326
+ const key = profileKeyOf(record.effective);
327
+ if (!wanted.has(key)) continue;
328
+ const rows = byKey.get(key) ?? [];
329
+ rows.push({
330
+ ts: typeof record.ts === 'number' ? record.ts : null,
331
+ sessionId: typeof record.session_id === 'string' ? record.session_id : null,
332
+ childId: typeof record.child_id === 'string' ? record.child_id : null,
333
+ mode: typeof record.mode === 'string' ? record.mode : '',
334
+ outcome: record.outcome !== null && typeof record.outcome === 'object' && typeof record.outcome.status === 'string' ? record.outcome.status : '',
335
+ elapsedMs: record.outcome !== null && typeof record.outcome === 'object' && Number.isFinite(record.outcome.elapsed_ms) ? record.outcome.elapsed_ms : null,
336
+ });
337
+ byKey.set(key, rows);
338
+ }
339
+ for (const [key, rows] of byKey) {
340
+ rows.sort((a, b) => (b.ts ?? 0) - (a.ts ?? 0));
341
+ out[key] = rows.slice(0, limit);
342
+ }
343
+ return out;
344
+ }
@@ -0,0 +1,167 @@
1
+ // lib/core/evolution-assets.mjs — 进化候选资产域(subagent-evolution/assets/)。
2
+ // 与 drafts-store 隔离:drafts 是「存为方案」通道(source='human'/'dispatch',无
3
+ // 生命周期);evolution 资产带五态生命周期(draft/proposed/applied/expired/
4
+ // rolled_back)与治理审计,本域只落进化候选。资产文件与索引各自原子写
5
+ // (tmp+rename),损坏/缺失 fail-soft;写失败 warn + 通知治理审计钩子(不抛)。
6
+ // 状态流转(本域职责内):draft →(apply 确认后由装配层驱动)→ proposed;
7
+ // 过期判定与换代由装配层驱动(先比对数据变化再转 expired 并留审计);
8
+ // applied/rolled_back 供金丝雀判定与回滚流转。
9
+
10
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+
13
+ const ASSETS_VERSION = 1;
14
+ const ASSET_STATES = new Set(['draft', 'proposed', 'applied', 'expired', 'rolled_back']);
15
+ const UNRESOLVED_STATES = new Set(['draft', 'proposed']);
16
+ const ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
17
+
18
+ // 原子写(tmp+rename):崩溃不产生截断文件;失败 fail-soft 清理 tmp + warn +
19
+ // 治理审计钩子。返回 {persisted, error?} 由调用方按 fail-soft 处理。
20
+ function atomicWrite(file, payload, logger, onGovernanceFailure) {
21
+ const tmp = `${file}.tmp`;
22
+ try {
23
+ mkdirSync(dirname(file), { recursive: true });
24
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
25
+ renameSync(tmp, file);
26
+ return { persisted: true };
27
+ } catch (error) {
28
+ try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
29
+ if (logger !== undefined && typeof logger.warn === 'function') {
30
+ logger.warn(`[dsh-subagent-profile] evolution assets: 资产写入失败:${error instanceof Error ? error.message : String(error)}`);
31
+ }
32
+ onGovernanceFailure();
33
+ return { persisted: false, error: error instanceof Error ? error.message : String(error) };
34
+ }
35
+ }
36
+
37
+ // 读索引(assets.index.json);缺失/损坏/形状不符回退空索引并 warn。
38
+ function loadIndex(file, logger) {
39
+ if (!existsSync(file)) return { v: ASSETS_VERSION, assets: [] };
40
+ try {
41
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
42
+ if (parsed !== null && typeof parsed === 'object' && parsed.v === ASSETS_VERSION && Array.isArray(parsed.assets)) {
43
+ return { v: ASSETS_VERSION, assets: parsed.assets.filter((e) => e !== null && typeof e === 'object' && typeof e.id === 'string') };
44
+ }
45
+ } catch { /* fail-soft 从零 */ }
46
+ if (logger !== undefined && typeof logger.warn === 'function') {
47
+ logger.warn('[dsh-subagent-profile] evolution assets: 索引文件形状不符或损坏,按空索引处理');
48
+ }
49
+ return { v: ASSETS_VERSION, assets: [] };
50
+ }
51
+
52
+ // 读单个资产文件;缺失/损坏/状态非法返回 null(fail-soft,索引条目调用方跳过)。
53
+ function loadAssetFile(file) {
54
+ if (!existsSync(file)) return null;
55
+ try {
56
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
57
+ if (parsed !== null && typeof parsed === 'object' && parsed.v === ASSETS_VERSION
58
+ && typeof parsed.id === 'string' && ASSET_STATES.has(parsed.state)) {
59
+ return parsed;
60
+ }
61
+ } catch { /* fail-soft */ }
62
+ return null;
63
+ }
64
+
65
+ // 资产 id 白名单校验(文件名直接由 id 拼接,防路径穿越)。
66
+ function safeAssetId(id) {
67
+ return typeof id === 'string' && ID_PATTERN.test(id) ? id : null;
68
+ }
69
+
70
+ // 索引条目 → 完整资产(读文件合并;文件缺失/损坏返回 null 由调用方跳过)。
71
+ function readAsset(state, entry) {
72
+ const asset = loadAssetFile(join(state.dir, `${entry.id}.json`));
73
+ if (asset === null) return null;
74
+ return { ...asset, index: entry };
75
+ }
76
+
77
+ // 过期判定与换代由装配层(evolution-engine)驱动:候选过期后需先比对数据变化
78
+ // 再决定是否转 expired 并留审计,本域不再按时间盲扫 draft。此函数保留为稳定
79
+ // 接口(list/get 的读前钩子),恒返回 0、不改任何资产。
80
+ function sweepExpiredAssets() {
81
+ return 0;
82
+ }
83
+
84
+ // 状态流转:资产文件先行(合并 patch + 状态 + updatedAt),成功后同步索引。
85
+ function transitionAsset(state, id, nextState, patch) {
86
+ const safe = safeAssetId(id);
87
+ if (safe === null) return { persisted: false, error: '资产 id 非法' };
88
+ const entry = state.index.assets.find((e) => e.id === safe);
89
+ if (entry === undefined) return { persisted: false, error: '资产不存在' };
90
+ if (!ASSET_STATES.has(nextState)) return { persisted: false, error: '资产状态非法' };
91
+ const asset = loadAssetFile(join(state.dir, safe + '.json'));
92
+ if (asset === null) return { persisted: false, error: '资产文件缺失或损坏' };
93
+ const next = { ...asset, ...patch, state: nextState, updatedAt: Date.now() };
94
+ const persisted = state.persistAsset(next);
95
+ if (persisted.persisted) {
96
+ entry.state = nextState;
97
+ entry.updatedAt = next.updatedAt;
98
+ // 索引镜像有效期(换代判定按索引条目读 expiry,patch 后必须同步)。
99
+ entry.expiry = typeof next.expiry === 'number' ? next.expiry : null;
100
+ state.persistIndex();
101
+ }
102
+ return persisted;
103
+ }
104
+
105
+ // 新资产落盘:校验 → 写资产文件 → 追加索引条目 → 写索引(文件先行,索引为清单
106
+ // 事实源;索引写失败仅告警,内存态继续驱动本进程)。
107
+ function addAsset(state, asset) {
108
+ if (asset === null || typeof asset !== 'object') return { persisted: false, error: '资产必须是对象' };
109
+ const safe = safeAssetId(asset.id);
110
+ if (safe === null || asset.state === undefined || !ASSET_STATES.has(asset.state)) {
111
+ return { persisted: false, error: '资产 id 或状态非法' };
112
+ }
113
+ if (state.index.assets.some((e) => e.id === safe)) return { persisted: false, error: '资产 id 已存在' };
114
+ const persisted = state.persistAsset(asset);
115
+ if (!persisted.persisted) return persisted;
116
+ state.index.assets.push({
117
+ id: asset.id,
118
+ source: typeof asset.source === 'string' ? asset.source : 'evolution',
119
+ profileKey: typeof asset.profile_key === 'string' ? asset.profile_key : '',
120
+ state: asset.state,
121
+ axis: asset.axis,
122
+ direction: asset.direction,
123
+ expiry: typeof asset.expiry === 'number' ? asset.expiry : null,
124
+ createdAt: asset.createdAt,
125
+ updatedAt: asset.updatedAt,
126
+ });
127
+ const indexPersisted = state.persistIndex();
128
+ return indexPersisted.persisted ? { persisted: true, id: asset.id } : { persisted: false, error: indexPersisted.error };
129
+ }
130
+
131
+ // 按 id 读资产(清扫过期草稿后);id 非法或不存在返回 undefined。
132
+ function getAsset(state, id) {
133
+ sweepExpiredAssets(state);
134
+ const safe = safeAssetId(id);
135
+ if (safe === null) return undefined;
136
+ const entry = state.index.assets.find((e) => e.id === safe);
137
+ if (entry === undefined) return undefined;
138
+ return readAsset(state, entry) ?? undefined;
139
+ }
140
+
141
+ export function createEvolutionAssets({ dshHome, logger, onGovernanceFailure = () => {} } = {}) {
142
+ const dir = join(dshHome, 'subagent-evolution', 'assets');
143
+ const indexFile = join(dir, 'assets.index.json');
144
+ const state = {
145
+ dir,
146
+ index: loadIndex(indexFile, logger),
147
+ persistIndex: () => atomicWrite(indexFile, state.index, logger, onGovernanceFailure),
148
+ persistAsset: (asset) => atomicWrite(join(dir, `${asset.id}.json`), asset, logger, onGovernanceFailure),
149
+ };
150
+ return {
151
+ list() {
152
+ sweepExpiredAssets(state);
153
+ return state.index.assets.map((entry) => readAsset(state, entry)).filter((a) => a !== null);
154
+ },
155
+ get: (id) => getAsset(state, id),
156
+ add: (asset) => addAsset(state, asset),
157
+ transition: (id, nextState, patch) => transitionAsset(state, id, nextState, patch),
158
+ // 同身份键是否仍有未处置(draft/proposed)资产——生成去重与观察期隔离判据。
159
+ hasUnresolved: (profileKey) => state.index.assets.some((e) => e.profileKey === profileKey && UNRESOLVED_STATES.has(e.state)),
160
+ findUnresolved: (profileKey) => state.index.assets
161
+ .filter((e) => e.profileKey === profileKey && UNRESOLVED_STATES.has(e.state))
162
+ .map((e) => ({ ...e })),
163
+ countBySource: (source) => state.index.assets.filter((e) => e.source === source).length,
164
+ ids: () => state.index.assets.map((e) => e.id),
165
+ sweepExpired: (now) => sweepExpiredAssets(state, now),
166
+ };
167
+ }