dsh-subagent-profile 0.3.2 → 0.3.4

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 (40) hide show
  1. package/README.md +77 -40
  2. package/README.zh.md +111 -74
  3. package/docs/screenshots/dispatch-card.png +0 -0
  4. package/docs/screenshots/settings-page1.png +0 -0
  5. package/docs/screenshots/settings-page2.png +0 -0
  6. package/index.mjs +276 -81
  7. package/lib/client.js +3218 -166
  8. package/lib/core/adoption-reminder.mjs +48 -0
  9. package/lib/core/adoption-tracker.mjs +430 -0
  10. package/lib/core/background-ledger.mjs +71 -0
  11. package/lib/core/catalog-cache.mjs +45 -7
  12. package/lib/core/catalog.mjs +6 -6
  13. package/lib/core/cost-evidence.mjs +145 -0
  14. package/lib/core/cost-guard.mjs +71 -44
  15. package/lib/core/decision-trace.mjs +413 -0
  16. package/lib/core/delegation.mjs +111 -50
  17. package/lib/core/dispatch-gates.mjs +153 -0
  18. package/lib/core/dispatch-guard.mjs +156 -0
  19. package/lib/core/dispatch-schema.mjs +103 -14
  20. package/lib/core/dispatch-tool.mjs +220 -204
  21. package/lib/core/draft-gates.mjs +45 -0
  22. package/lib/core/drafts-store.mjs +45 -0
  23. package/lib/core/escape.mjs +130 -0
  24. package/lib/core/evolution-advice.mjs +224 -0
  25. package/lib/core/evolution-ledger.mjs +300 -0
  26. package/lib/core/evolution-summary.mjs +255 -0
  27. package/lib/core/http-routes.mjs +256 -72
  28. package/lib/core/intersection.mjs +6 -9
  29. package/lib/core/presets-sync.mjs +161 -43
  30. package/lib/core/prices.mjs +46 -0
  31. package/lib/core/profile-directory.mjs +139 -0
  32. package/lib/core/profile-provider.mjs +42 -39
  33. package/lib/core/profiles-store.mjs +103 -76
  34. package/lib/core/pure.mjs +110 -66
  35. package/lib/core/reminder-store.mjs +172 -0
  36. package/lib/core/shims.mjs +67 -76
  37. package/lib/core/whitelist.mjs +23 -17
  38. package/package.json +82 -83
  39. package/presets/orchestrator/agent.cordis.yml +59 -87
  40. package/presets/orchestrator/NOTICE +0 -3
@@ -0,0 +1,45 @@
1
+ // lib/core/drafts-store.mjs — auto-profile draft 容器。
2
+ // 与生产注册表 subagent-profiles.json 隔离:draft 只写 subagent-evolution/drafts.json,
3
+ // 未经人确认并过闸不得进入 profiles Map。原子写(tmp+rename),损坏 fail-soft 从空。
4
+
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ function loadDrafts(file) {
9
+ if (!existsSync(file)) return [];
10
+ try {
11
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
12
+ if (parsed !== null && typeof parsed === 'object' && Array.isArray(parsed.drafts)) {
13
+ return parsed.drafts.filter((d) => d !== null && typeof d === 'object' && typeof d.id === 'string');
14
+ }
15
+ } catch { /* fail-soft 从空 */ }
16
+ return [];
17
+ }
18
+
19
+ function createDraftsStore({ dshHome, onGovernanceFailure = () => {} } = {}) {
20
+ const dir = join(dshHome, 'subagent-evolution');
21
+ const file = join(dir, 'drafts.json');
22
+ const drafts = loadDrafts(file);
23
+ const persist = () => {
24
+ const tmp = file + '.tmp';
25
+ try {
26
+ mkdirSync(dir, { recursive: true });
27
+ writeFileSync(tmp, JSON.stringify({ v: 1, drafts }, null, 2), 'utf8');
28
+ renameSync(tmp, file);
29
+ return { persisted: true };
30
+ } catch (error) {
31
+ try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
32
+ onGovernanceFailure();
33
+ return { persisted: false, error: error instanceof Error ? error.message : String(error) };
34
+ }
35
+ };
36
+ return {
37
+ list: () => drafts.map((d) => ({ ...d })),
38
+ get: (id) => { const d = drafts.find((x) => x.id === id); return d === undefined ? undefined : { ...d }; },
39
+ add: (draft) => { drafts.push(draft); return persist(); },
40
+ remove: (id) => { const i = drafts.findIndex((x) => x.id === id); if (i < 0) return { persisted: true, removed: false }; drafts.splice(i, 1); return { ...persist(), removed: true }; },
41
+ clear: () => { drafts.length = 0; },
42
+ };
43
+ }
44
+
45
+ export { createDraftsStore };
@@ -0,0 +1,130 @@
1
+ // lib/core/escape.mjs — 逃生舱放行集(evolution-escape.json)管理 + 放行审计快照。
2
+ // 逃生舱默认关(开关态在 state.json,由 profiles-store 持久化);放行集文件只
3
+ // 存「被显式 opt-in 的非 system-trust 预设 id」——文件损坏/缺失/形状不符一律
4
+ // fail-soft 空集(绝不阻断启动,也绝不把坏数据当放行成员)。
5
+ //
6
+ // 放行集独立追踪、独立落盘于 subagent-evolution/ 域(与派发台账同根),不被 preset
7
+ // 同步裁剪。写路径原子写(tmp+rename),失败仅 warn + 通知治理审计钩子(不抛)。
8
+ // 逃生舱语义:成员只在「其余三道闸全过」时放行;非成员即便开关开也恒拒——这里只
9
+ // 提供名单,不参与任何闸判定。
10
+
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+
14
+ const ESCAPE_VERSION = 1;
15
+
16
+ // 文件形状判定:只接受 {version:1, presets:[string...]}。版本/形状不符一律判空集
17
+ // (fail-soft),并把坏文件当「未配置」而非「配置了危险成员」。
18
+ function classifyEscapeFile(parsed) {
19
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.version === 1 && Array.isArray(parsed.presets)) {
20
+ return parsed.presets.filter((id) => typeof id === 'string' && id !== '');
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ // 读放行集文件 → 内存 Set。缺失/损坏/形状不符均回退空集并 warn,绝不 throw。
26
+ function loadEscapeSet(file, logger) {
27
+ const presets = new Set();
28
+ if (!existsSync(file)) return presets;
29
+ try {
30
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
31
+ const entries = classifyEscapeFile(parsed);
32
+ if (entries === undefined) {
33
+ logger.warn('[dsh-subagent-profile] escape set: 放行集文件形状不符,按空集处理');
34
+ return presets;
35
+ }
36
+ for (const id of entries) presets.add(id);
37
+ } catch {
38
+ logger.warn('[dsh-subagent-profile] escape set: 放行集文件损坏,按空集处理');
39
+ }
40
+ return presets;
41
+ }
42
+
43
+ // 原子写放行集文件(tmp+rename);失败 fail-soft:清理 tmp + warn + 通知治理审计
44
+ // 钩子(写失败计一次治理丢失),返回 {persisted}。内存 Set 仍驱动本进程。
45
+ function persistEscapeSet(file, presets, logger, onGovernanceFailure) {
46
+ const payload = { version: ESCAPE_VERSION, presets: [...presets] };
47
+ const tmp = `${file}.tmp`;
48
+ try {
49
+ mkdirSync(dirname(file), { recursive: true });
50
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
51
+ renameSync(tmp, file);
52
+ return { persisted: true };
53
+ } catch (error) {
54
+ try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
55
+ logger.warn('[dsh-subagent-profile] escape set: 放行集写入失败:', error instanceof Error ? error.message : String(error));
56
+ if (typeof onGovernanceFailure === 'function') onGovernanceFailure();
57
+ return { persisted: false, error: error instanceof Error ? error.message : String(error) };
58
+ }
59
+ }
60
+
61
+ // 放行集工厂:per-apply 单例(与 profiles store 同口径)。返回 { list, add,
62
+ // remove };add/remove 都原子落盘并回传 {persisted}。list 返回数组副本(供设置页
63
+ // 只读列表与 resolveWhitelist 叠加)。成员判定由调用方对 list() 结果做 includes。
64
+ export function createEscapeStore({ dshHome, logger, onGovernanceFailure = () => {} }) {
65
+ const file = join(dshHome, 'subagent-evolution', 'evolution-escape.json');
66
+ const presets = loadEscapeSet(file, logger);
67
+ return {
68
+ list: () => [...presets],
69
+ add(id) {
70
+ if (typeof id !== 'string' || id === '') return { persisted: false, error: '放行集成员必须是非空字符串' };
71
+ presets.add(id);
72
+ return persistEscapeSet(file, presets, logger, onGovernanceFailure);
73
+ },
74
+ remove(id) {
75
+ presets.delete(id);
76
+ return persistEscapeSet(file, presets, logger, onGovernanceFailure);
77
+ },
78
+ };
79
+ }
80
+
81
+ // 放行审计快照:从 decisionTrace.gates 抽取「白名单 + 其余三道闸」的 verdict。
82
+ // cost 保留硬上限/llm 能力检查的 field→verdict,whitelist/intersection/approval
83
+ // 只留 verdict 字符串。只读、不 throw——trace 缺 gates 或某闸缺失时对应键缺席
84
+ // (审计记录仍恒可写)。
85
+ export function escapeGatesSnapshot(trace) {
86
+ const gates = Array.isArray(trace?.gates) ? trace.gates : [];
87
+ const snapshot = {};
88
+ for (const gate of gates) {
89
+ if (!gate || typeof gate !== 'object') continue;
90
+ if (gate.name === 'cost') {
91
+ snapshot.cost = {
92
+ verdict: gate.verdict,
93
+ checks: (Array.isArray(gate.output?.checks) ? gate.output.checks : [])
94
+ .map((check) => ({ field: check?.field, verdict: check?.verdict })),
95
+ };
96
+ } else if (gate.name === 'whitelist' || gate.name === 'intersection' || gate.name === 'approval') {
97
+ snapshot[gate.name] = gate.verdict;
98
+ }
99
+ }
100
+ return snapshot;
101
+ }
102
+
103
+ // 逃生舱放行审计:非 system 预设经逃生舱通过闸链时写一条高可见治理审计事件(含
104
+ // preset、来源会话、白名单+三道闸快照)。调用点在闸链末尾(三道闸 + 预算闸之后、
105
+ // 分派之前)。getEscapeSet 在开关关时返回空数组 → includes 恒 false → 零审计;成员
106
+ // 判定即「开关开且 preset 在放行集」。写失败绝不静默(recordGovernanceAudit 内部
107
+ // warn + 治理丢失计数)。deps 由调用方注入(含 getEscapeSet / evoLedger)。
108
+ export function recordEscapeAllow(deps, parent, merged, trace) {
109
+ if (typeof merged.preset !== 'string' || merged.preset === 'inherit') return;
110
+ if (!deps.getEscapeSet().includes(merged.preset)) return;
111
+ deps.evoLedger.recordGovernanceAudit({
112
+ kind: 'escape-allow',
113
+ preset: merged.preset,
114
+ session_id: parent.session?.header?.id ?? null,
115
+ gates_snapshot: escapeGatesSnapshot(trace),
116
+ });
117
+ }
118
+
119
+ // provider 渠道放行审计:profile provider 的 preflight 白名单放行 escape 成员时
120
+ // (非 dispatch 工具的宿主原生 subagent 渠道),同样写高可见审计——「每个放行都
121
+ // 留痕」是绝对语义。快照为 provider 渠道口径:whitelist/cost 在 preflight 已过、
122
+ // intersection 在子会话 setup 时执行(deferred)、审批不豁免(never)。
123
+ export function recordEscapeAllowProvider(evoLedger, parent, preset) {
124
+ evoLedger.recordGovernanceAudit({
125
+ kind: 'escape-allow-provider',
126
+ preset,
127
+ session_id: parent?.session?.header?.id ?? null,
128
+ gates_snapshot: { whitelist: 'pass', cost: 'pass', intersection: 'deferred', approval: 'never' },
129
+ });
130
+ }
@@ -0,0 +1,224 @@
1
+ // lib/core/evolution-advice.mjs — evolution:advice 派发优化建议链(从 evolution-summary.mjs
2
+ // 拆出以守文件行门):suggestAdvice(纯函数四条件 + 升/降轴硬规则)、readSummaries /
3
+ // refreshSummaries / computeAdvice / buildAdviceText(生产聚合触发与注入文本)。
4
+ // 聚合本体(computeSummaries/writeSummaries)仍驻 evolution-summary.mjs;本模块只读
5
+ // 台账并调度聚合,绝无写回台账路径。fail-soft 纪律与 evolution-summary 同源。
6
+ //
7
+ // 依赖方向:本模块 → evolution-summary.mjs(单向,无环)。
8
+
9
+ import { existsSync, readFileSync, statSync } from 'node:fs';
10
+ import { computeSummaries, parseDispatchRecords, writeSummaries, confidenceLevel } from './evolution-summary.mjs';
11
+
12
+ // --- 派发优化建议(suggestAdvice)-----------------------------------------------
13
+ // 从单条 L1 聚合(l1Entry)产出「一项确定性建议」或 null。四条件齐备才产出:
14
+ // N>=minN、weighted_success 低于阈值(表现不佳才建议)、置信度达标、cooldown
15
+ // 未过期。方向按升/降轴硬规则:能力轴(preset/model/provider/persona/
16
+ // toolFilter)只建议降;预算轴(effort)可建议升;无法定位安全轴的键保守维持
17
+ // (平)。阈值 successThreshold / confidenceMinLevel / cooldownMs 为建议初值,
18
+ // 待实测后校准。
19
+ const CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
20
+
21
+ function hasCapabilityAxis(profileKey) {
22
+ return /(^|\|)(preset|provider|model|persona|toolFilter):/.test(String(profileKey ?? ''));
23
+ }
24
+
25
+ function hasBudgetAxis(profileKey) {
26
+ return /(^|\|)effort:/.test(String(profileKey ?? ''));
27
+ }
28
+
29
+ // 从 profileKey 提取 preset 候选值(无 preset 段返回 null,如 inline / 纯预算键)。
30
+ export function presetFromKey(profileKey) {
31
+ if (typeof profileKey !== 'string' || profileKey === '' || profileKey === '(inline)') return null;
32
+ for (const part of profileKey.split('|')) {
33
+ if (part.startsWith('preset:')) return part.slice('preset:'.length);
34
+ }
35
+ return null;
36
+ }
37
+
38
+ // 建议候选校验:preset 候选非 system-trust 白名单即 fail-loud 抛错(与派发守卫
39
+ // whitelist 闸一致,不静默滤掉)。whitelist 接受 Set 或数组。
40
+ export function assertSystemCandidate(whitelist, preset) {
41
+ if (preset === null || preset === undefined || preset === '' || preset === 'inherit') return;
42
+ const inList = whitelist instanceof Set ? whitelist.has(preset) : Array.isArray(whitelist) && whitelist.includes(preset);
43
+ if (!inList) {
44
+ throw new Error(`evolution:advice: 候选预设 "${preset}" 不在 system-trust 白名单(拒绝非 system 候选)`);
45
+ }
46
+ }
47
+
48
+ function performanceText(entry) {
49
+ const outcome = entry?.outcome ?? {};
50
+ const perf = entry?.perf ?? {};
51
+ const parts = [
52
+ `过去 ${entry?.deployments_total ?? 0} 次:完成 ${outcome.completed ?? 0}、失败 ${outcome.failed ?? 0}、终止 ${outcome.killed ?? 0}`,
53
+ `加权成功分 ${entry?.score?.weighted_success ?? 0}`,
54
+ ];
55
+ if (perf.avg_elapsed_ms !== null && perf.avg_elapsed_ms !== undefined) parts.push(`平均耗时 ${perf.avg_elapsed_ms}ms`);
56
+ if (perf.avg_output_len !== null && perf.avg_output_len !== undefined) parts.push(`平均输出 ${perf.avg_output_len} 字符`);
57
+ return parts.join(',');
58
+ }
59
+
60
+ function directionFor(profileKey, capability, budget) {
61
+ if (budget && !capability) return '升';
62
+ if (capability) return '降';
63
+ return '平';
64
+ }
65
+
66
+ export function suggestAdvice({
67
+ l1Entry,
68
+ profileKey = '',
69
+ minN = 3,
70
+ cooldownMs = 600000,
71
+ now = Date.now(),
72
+ successThreshold = 0.6,
73
+ confidenceMinLevel = 'medium',
74
+ } = {}) {
75
+ const entry = l1Entry !== null && typeof l1Entry === 'object' ? l1Entry : {};
76
+ const key = typeof profileKey === 'string' ? profileKey : (typeof entry.profileKey === 'string' ? entry.profileKey : '');
77
+ const n = Number.isFinite(entry.confidence?.n) ? entry.confidence.n : 0;
78
+ const level = typeof entry.confidence?.level === 'string' ? entry.confidence.level : confidenceLevel(n, minN);
79
+ const score = Number.isFinite(entry.score?.weighted_success) ? entry.score.weighted_success : 0;
80
+ const until = entry.cooldown?.until_ts;
81
+
82
+ // 四条件齐备才产出(任一不满足返回 null)。
83
+ if (n < minN) return null;
84
+ if ((CONFIDENCE_RANK[level] ?? -1) < (CONFIDENCE_RANK[confidenceMinLevel] ?? 0)) return null;
85
+ if (until !== null && until !== undefined && now < until) return null;
86
+ if (!(score < successThreshold)) return null;
87
+
88
+ // 方向判定:优先读组内轴存在性(L1 键去 effort 后由聚合写入);legacy summaries
89
+ // 无 axes 字段时回退键文本正则判定(保持旧 summaries.json 可读)。
90
+ const axes = entry.axes !== null && typeof entry.axes === 'object' ? entry.axes : null;
91
+ const capability = axes !== null && typeof axes.capability === 'boolean' ? axes.capability : hasCapabilityAxis(key);
92
+ const budget = axes !== null && typeof axes.budget === 'boolean' ? axes.budget : hasBudgetAxis(key);
93
+
94
+ return {
95
+ profileKey: key,
96
+ performanceText: performanceText(entry),
97
+ suggestion: directionFor(key, capability, budget),
98
+ confidence: level,
99
+ cooldownUntil: now + cooldownMs,
100
+ };
101
+ }
102
+
103
+ // --- 派发优化建议(evolution:advice 注入段,默认关)----------------------------
104
+
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
+ }
122
+ }
123
+
124
+ // 生产聚合触发点(T1 修复):读 dispatch.jsonl → parseDispatchRecords → computeSummaries
125
+ // → writeSummaries。opts 可注入 parentAdoptedConfirmedFalse join(adoption-tracker 的
126
+ // 「已确认未采纳」计数,惰性 join 进 weighted_success)。缺失台账返回 skipped:'no-ledger'。
127
+ export function refreshSummaries({ dispatchFile, summariesFile, logger, opts = {} } = {}) {
128
+ if (!existsSync(dispatchFile)) return { persisted: false, skipped: 'no-ledger' };
129
+ let lines;
130
+ try {
131
+ lines = readFileSync(dispatchFile, 'utf8').split('\n');
132
+ } catch (error) {
133
+ const message = error instanceof Error ? error.message : String(error);
134
+ if (logger !== undefined && typeof logger.warn === 'function') {
135
+ logger.warn(`[dsh-subagent-profile] evolution:advice 读派发台账失败:${message}`);
136
+ }
137
+ return { persisted: false, error: message };
138
+ }
139
+ const { records, skipped } = parseDispatchRecords(lines);
140
+ const summaries = computeSummaries(records, opts);
141
+ const res = writeSummaries(summariesFile, summaries);
142
+ return { ...res, records: records.length, skippedLines: skipped };
143
+ }
144
+
145
+ // summaries.json 是否过期(缺失但台账存在,或台账 mtime 更新)——惰性重算判据。
146
+ function summariesStale(dispatchFile, summariesFile) {
147
+ if (!existsSync(summariesFile)) return existsSync(dispatchFile);
148
+ try {
149
+ return statSync(dispatchFile).mtimeMs > statSync(summariesFile).mtimeMs;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ // 注入段文本:全局聚合摘要 + 各 profile 一条确定性建议(人类读、非指令)。
156
+ export function renderAdviceText(advice, global) {
157
+ const direction = (value) => (value === '升' ? '可上调预算' : value === '降' ? '建议降级' : '维持现状');
158
+ const lines = advice.map((a) => {
159
+ const untilText = Number.isFinite(a.cooldownUntil)
160
+ ? `(冷却至 ${new Date(a.cooldownUntil).toLocaleTimeString('zh-CN', { hour12: false })})`
161
+ : '';
162
+ return `- ${a.profileKey}:${a.performanceText};${direction(a.suggestion)}(置信度 ${a.confidence})${untilText}`;
163
+ });
164
+ const total = global.completed + global.failed + global.killed;
165
+ return `以下为过去派发的只读统计与确定性建议(仅供人类参考,不改变派发行为):\n` +
166
+ `全局:累计 ${total} 次(完成 ${global.completed}、失败 ${global.failed}、终止 ${global.killed})。\n${lines.join('\n')}`;
167
+ }
168
+
169
+ // 建议产出后把各 profile 的新 cooldown.until_ts 写回 summaries.json(原子写、
170
+ // fail-soft)。写前重读磁盘再合并:并发注入各自基于最新磁盘态写回,把互相覆盖
171
+ // 窗口压到最小(残余竞态:读-写间隙,彻底解决需进程内锁,成本不值)。
172
+ function writeBackCooldowns(file, data, produced, logger) {
173
+ const fresh = readSummaries(file, logger);
174
+ const base = fresh !== null && typeof fresh === 'object' ? fresh : data;
175
+ const l1 = { ...(base.l1 ?? {}) };
176
+ for (const [profileKey, until] of produced) {
177
+ const group = l1[profileKey];
178
+ l1[profileKey] = { ...group, cooldown: { ...(group?.cooldown ?? {}), until_ts: until } };
179
+ }
180
+ const res = writeSummaries(file, { l1, l2: base.l2 ?? {} });
181
+ if (res.persisted !== true) {
182
+ logger.warn(`[dsh-subagent-profile] evolution:advice cooldown 写回失败:${res.error ?? '未知错误'}`);
183
+ }
184
+ }
185
+
186
+ // 结构化建议(读+算,不写回 cooldown、不渲染文本):供注入段与只读面板共用。
187
+ export function computeAdvice({ summariesFile, dispatchFile, whitelist, logger }) {
188
+ if (dispatchFile !== undefined && summariesStale(dispatchFile, summariesFile)) refreshSummaries({ dispatchFile, summariesFile, logger });
189
+ let data = readSummaries(summariesFile, logger);
190
+ // summaries.json 存在但不可读(损坏/版本不符)→ fail-soft 重建(幂等可复算),
191
+ // 损坏资产不阻断建议产出;台账缺失时维持空建议(refreshSummaries 的 no-ledger 语义)。
192
+ if (data === null && dispatchFile !== undefined && existsSync(summariesFile)) {
193
+ refreshSummaries({ dispatchFile, summariesFile, logger });
194
+ data = readSummaries(summariesFile, logger);
195
+ }
196
+ if (data === null) return { advice: [], global: { completed: 0, failed: 0, killed: 0, total: 0 }, produced: [], summaries: null };
197
+ const l1 = data.l1 ?? {};
198
+ const keys = Object.keys(l1).sort();
199
+ const advice = [];
200
+ const produced = [];
201
+ const global = { completed: 0, failed: 0, killed: 0 };
202
+ for (const profileKey of keys) {
203
+ const group = l1[profileKey];
204
+ if (group === null || typeof group !== 'object') continue;
205
+ global.completed += group.outcome?.completed ?? 0;
206
+ global.failed += group.outcome?.failed ?? 0;
207
+ global.killed += group.outcome?.killed ?? 0;
208
+ const suggestion = suggestAdvice({ l1Entry: group, profileKey });
209
+ if (suggestion === null) continue;
210
+ // 只对实际产出的建议键校验候选(N 不足不产出建议的组不要求 system-trust)。
211
+ assertSystemCandidate(whitelist, presetFromKey(profileKey));
212
+ advice.push(suggestion);
213
+ produced.push([profileKey, suggestion.cooldownUntil]);
214
+ }
215
+ return { advice, global: { ...global, total: global.completed + global.failed + global.killed }, produced, summaries: data };
216
+ }
217
+
218
+ // 派发优化建议段文本生成:computeAdvice → 写回 cooldown(防每请求重复注入)→ 渲染;空建议返回空串。
219
+ export function buildAdviceText({ summariesFile, dispatchFile, whitelist, logger }) {
220
+ const { advice, global, produced, summaries } = computeAdvice({ summariesFile, dispatchFile, whitelist, logger });
221
+ if (advice.length === 0) return '';
222
+ writeBackCooldowns(summariesFile, summaries, produced, logger);
223
+ return renderAdviceText(advice, global);
224
+ }