@quan-huayan/dsh-research-engine 0.1.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,848 @@
1
+ // task-dispatch —— 实例 / 执行 / 证据(7 工具)
2
+ //
3
+ // 拥有数据:templates.yaml(环境:可执行声明)、scripts/*(环境:辅助脚本 + *.meta.json)、
4
+ // experiments/<runId>/{config,manifest,observations}.json(证据与证据权威)。
5
+ // raw/ 由进程写,run_launch 登记并归档;observations.json 只能由 run_observe 从已登记文件里提取。
6
+ //
7
+ // 后台执行与 dsh-tool-pwsh 同构:dshEnv + sandboxPolicy 必须一起给,否则受限执行器起不来(exit 127 现场教训)。
8
+
9
+ import { join, dirname, resolve, relative, sep, basename } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { readFile, writeFile, mkdir, stat, rename, readdir } from 'node:fs/promises';
12
+ import os from 'node:os';
13
+ import { createRequire } from 'node:module';
14
+ import { createHash, randomBytes } from 'node:crypto';
15
+
16
+ const dshHome = process.env.DSH_HOME || join(os.homedir(), '.dsh');
17
+ // 依赖解析:先按插件自身位置解析(npm/pnpm 随包安装的 node_modules),
18
+ // 再回退到 harness profile 的扁平安装 —— 把 preset 目录直接拷进 $DSH_HOME/.agent-presets 时没有自己的 node_modules。
19
+ const REQUIRE_ANCHORS = [
20
+ import.meta.url,
21
+ join(dshHome, 'profiles', 'node_modules', 'js-yaml', 'package.json'),
22
+ join(dshHome, 'profiles', 'web', 'package.json'),
23
+ ];
24
+ function loadDep(id) {
25
+ let last = null;
26
+ for (const anchor of REQUIRE_ANCHORS) {
27
+ try { return createRequire(anchor)(id); } catch (error) { last = error; }
28
+ }
29
+ throw new Error(`缺少依赖 ${id}:请在该 preset 所在位置安装它,或让 $DSH_HOME/profiles/node_modules 里存在它(${last && last.message})`);
30
+ }
31
+ const yaml = loadDep('js-yaml');
32
+ let Ajv2020 = null;
33
+ try { Ajv2020 = loadDep('ajv/dist/2020'); } catch { Ajv2020 = null; }
34
+
35
+ const SCHEMA_VERSION = 3;
36
+ const TEMPLATES_YAML = 'templates.yaml';
37
+ const REGISTRY = 'registry.jsonl';
38
+ // 词边界替换:不误伤用户数据里的同形子串(如 usergit-probe / shanghai),但独立出现的版本库词汇一律替换。
39
+ const SCRUB = /(^|[^A-Za-z0-9_-])(git|commit|HEAD|diff|hash|sha)(?![A-Za-z0-9_-])/gi;
40
+ const RESERVED = ['__raw__', '__runId__', '__project__'];
41
+
42
+ export const name = 'task-dispatch';
43
+ export const inject = ['tools'];
44
+
45
+ function iso() { return new Date().toISOString(); }
46
+ function sha256(s) { return createHash('sha256').update(s).digest('hex'); }
47
+ function scrub(t) { return String(t ?? '').replace(SCRUB, (_m, pre) => `${pre}·`); }
48
+ function rand3() { return randomBytes(2).toString('hex').slice(0, 3); }
49
+ function posix(p) { return String(p).replace(/\\/g, '/'); }
50
+ function isWithin(root, p) {
51
+ const rel = relative(resolve(root), resolve(p));
52
+ return rel === '' || (!rel.startsWith('..' + sep) && rel !== '..');
53
+ }
54
+ async function pathExists(p) { try { await stat(p); return true; } catch { return false; } }
55
+ async function ensureDir(p) { await mkdir(p, { recursive: true }); }
56
+ async function atomicWrite(p, body) {
57
+ await ensureDir(dirname(p));
58
+ const tmp = `${p}.tmp-${sha256(iso() + Math.random()).slice(0, 8)}`;
59
+ await writeFile(tmp, body, 'utf8');
60
+ await rename(tmp, p);
61
+ }
62
+ function tsCompact(ts) {
63
+ return ts.replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z').replace(/[TZ]/g, (m) => (m === 'T' ? '-' : ''));
64
+ }
65
+ function placeholdersIn(template) {
66
+ if (typeof template !== 'string' || template === '') return [];
67
+ return [...new Set([...template.matchAll(/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g)].map((m) => m[1]))];
68
+ }
69
+ function interpFor(p, allow) {
70
+ const ext = (/\.([A-Za-z0-9]+)$/.exec(String(p))?.[1] ?? '').toLowerCase();
71
+ const map = { py: 'python', js: 'node', sh: 'bash', ps1: 'pwsh', bat: 'cmd', cmd: 'cmd' };
72
+ return map[ext] ?? (Array.isArray(allow) && allow.length ? allow[0] : 'python');
73
+ }
74
+ function quoteToken(t) { return /^[A-Za-z0-9_./:\\-]+$/.test(t) ? t : JSON.stringify(t); }
75
+ function roleOf(rel) {
76
+ const n = basename(rel).toLowerCase();
77
+ const ext = (/\.([a-z0-9]+)$/.exec(n)?.[1] ?? '');
78
+ if (n === 'stdout.log' || n === 'stderr.log') return 'stdout';
79
+ if (['log', 'txt', 'out', 'err'].includes(ext)) return 'log';
80
+ if (['json', 'yaml', 'yml', 'csv'].includes(ext)) return 'result';
81
+ if (['png', 'jpg', 'jpeg', 'svg', 'pdf'].includes(ext)) return 'figure';
82
+ if (['pth', 'pt', 'ckpt', 'npz', 'bin'].includes(ext)) return 'checkpoint';
83
+ return 'raw';
84
+ }
85
+
86
+ function buildService(ctx) {
87
+ const led = () => ctx.get('research.expLedger');
88
+ const eg = () => ctx.get('research.engineGit');
89
+
90
+ async function readTemplatesDoc(root) {
91
+ const p = join(root, TEMPLATES_YAML);
92
+ if (!(await pathExists(p))) return { schemaVersion: SCHEMA_VERSION, templates: {} };
93
+ try {
94
+ const d = yaml.load(await readFile(p, 'utf8'));
95
+ return d && typeof d === 'object' ? d : { schemaVersion: SCHEMA_VERSION, templates: {} };
96
+ } catch (e) { throw new Error(`${TEMPLATES_YAML} 解析失败(${e.message})`); }
97
+ }
98
+ async function readScriptMeta(root, name) {
99
+ const p = join(root, 'scripts', `${name}.meta.json`);
100
+ if (!(await pathExists(p))) return null;
101
+ try { return JSON.parse(await readFile(p, 'utf8')); } catch { return null; }
102
+ }
103
+ async function resolveScript(root, meta) {
104
+ if (!meta) return null;
105
+ const rel = posix(meta.mode === 'adopt' ? meta.source : join('scripts', `${meta.name}.py`));
106
+ const abs = join(root, rel);
107
+ if (!(await pathExists(abs))) return null;
108
+ const sha = eg().sha256File(abs);
109
+ return { rel, abs, sha256: sha, meta };
110
+ }
111
+ function validateParams(schema, params) {
112
+ if (!Ajv2020) return [];
113
+ try {
114
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
115
+ const v = ajv.compile(schema ?? { type: 'object' });
116
+ if (v(params)) return [];
117
+ return (v.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`);
118
+ } catch (e) { return [`参数契约无法编译:${e.message}`]; }
119
+ }
120
+ function renderCommand(root, runId, tpl, params, scriptRel) {
121
+ const allow = Array.isArray(tpl?.allow) ? tpl.allow.map(String) : [];
122
+ const tokens = [];
123
+ if (scriptRel) { tokens.push(interpFor(scriptRel, allow)); tokens.push(scriptRel); }
124
+ else tokens.push(allow[0]);
125
+ const raw = posix(join('experiments', runId, 'raw'));
126
+ const sub = (s) => String(s).replace(/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (_m, k) => {
127
+ if (k === '__raw__') return raw;
128
+ if (k === '__runId__') return runId;
129
+ if (k === '__project__') return root;
130
+ return k in params ? String(params[k]) : '';
131
+ });
132
+ const argsTpl = typeof tpl?.args === 'string' ? tpl.args.trim() : '';
133
+ if (argsTpl) for (const part of sub(argsTpl).split(/\s+/).filter(Boolean)) tokens.push(part);
134
+ return tokens.map(quoteToken).join(' ');
135
+ }
136
+
137
+ return {
138
+ readTemplatesDoc, readScriptMeta, resolveScript, validateParams, renderCommand, roleOf,
139
+
140
+ /** 生成/刷新 manifest:把 raw/ 下所有产出登记(含指纹)。 */
141
+ async refreshManifest(root, runId, { producer = 'process' } = {}) {
142
+ const dir = join(root, 'experiments', runId);
143
+ await ensureDir(dir);
144
+ const manPath = join(dir, 'manifest.json');
145
+ let man = { schemaVersion: SCHEMA_VERSION, runId, entries: [], complete: true };
146
+ if (await pathExists(manPath)) {
147
+ try { man = JSON.parse(await readFile(manPath, 'utf8')); } catch { /* 重建 */ }
148
+ }
149
+ const known = new Map((man.entries ?? []).map((e) => [posix(e.path), e]));
150
+ const files = [];
151
+ for (const f of eg().listFiles(root, `experiments/${runId}/raw`)) files.push(f);
152
+ for (const f of [`experiments/${runId}/config.json`]) if (await pathExists(join(root, f))) files.push(f);
153
+ const entries = [];
154
+ let missing = 0;
155
+ for (const f of files) {
156
+ const rel = posix(f);
157
+ const abs = join(root, rel);
158
+ const sha = eg().sha256File(abs);
159
+ if (sha === null) { missing += 1; continue; }
160
+ const st = eg().sizeMtime(abs);
161
+ const prev = known.get(rel);
162
+ entries.push({
163
+ path: rel,
164
+ role: rel.endsWith('config.json') ? 'config' : roleOf(rel),
165
+ sha256: sha,
166
+ bytes: st?.bytes ?? 0,
167
+ producer: prev?.producer ?? producer,
168
+ ts: prev?.ts ?? iso(),
169
+ stored: man.entries?.[0]?.stored === 'hash-only' ? 'hash-only' : 'versioned',
170
+ });
171
+ }
172
+ for (const [p, e] of known) if (!files.some((f) => posix(f) === p)) entries.push(e);
173
+ man.entries = entries;
174
+ man.complete = missing === 0;
175
+ man.updatedAt = iso();
176
+ await atomicWrite(manPath, JSON.stringify(man, null, 2) + '\n');
177
+ return man;
178
+ },
179
+ };
180
+ }
181
+
182
+ function genericCall(title, kind, paths) {
183
+ return { card: 'generic', title, kind, ...(paths && paths.length ? { locations: paths.map((p) => ({ path: p })) } : {}) };
184
+ }
185
+ function registerTextTool(ctx, tool) {
186
+ ctx.tools.register({
187
+ name: tool.name,
188
+ description: tool.description,
189
+ parameters: { type: 'object', properties: tool.properties || {}, ...(tool.required?.length ? { required: tool.required } : {}) },
190
+ output: {
191
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } }, required: ['text'] },
192
+ render: (_a, v) => [{ type: 'text', text: v.text }],
193
+ },
194
+ ...(tool.presentCall ? { presentCall: tool.presentCall } : {}),
195
+ execute: async (args, exec) => {
196
+ const r = await tool.execute(args, exec);
197
+ return { text: scrub(typeof r?.text === 'string' ? r.text : String(r?.text ?? '')) };
198
+ },
199
+ });
200
+ }
201
+
202
+ function apply(ctx) {
203
+ const svc = buildService(ctx);
204
+ ctx.provide('research.taskDispatch', svc);
205
+
206
+ // ---------- script_declare ----------
207
+ registerTextTool(ctx, {
208
+ name: 'script_declare',
209
+ description: '声明辅助脚本:mode=create 写入 scripts/<name>.py + 元数据;mode=adopt 只登记工程内既有脚本(不改文件、只写元数据)。两者都要求 allowlist 非空。',
210
+ properties: {
211
+ root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
212
+ name: { type: 'string', description: '脚本名(kebab-case,不含扩展名)。' },
213
+ mode: { type: 'string', enum: ['create', 'adopt'], description: '缺省 create。' },
214
+ purpose: { type: 'string', description: '用途说明(必填)。' },
215
+ entrypoint: { type: 'string', description: '入口形式(如 `python scripts/x.py --seed 0`)。' },
216
+ allowlist: { type: 'array', items: { type: 'string' }, description: '允许的命令前缀白名单(必填、非空)。' },
217
+ source: { type: 'string', description: 'mode=adopt:工程内既有脚本的相对路径。' },
218
+ content: { type: 'string', description: 'mode=create:脚本正文。' },
219
+ owner: { type: 'string', description: '脚本属主(人/角色)。' },
220
+ overwrite: { type: 'boolean', description: '允许覆盖同名脚本。' },
221
+ },
222
+ required: ['name', 'purpose', 'allowlist'],
223
+ execute: async (args, exec) => {
224
+ const led = ctx.get('research.expLedger');
225
+ const eg = () => ctx.get('research.engineGit');
226
+ const root = led.root(args, exec);
227
+ try { await led.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
228
+ const name = led.slugify(args?.name ?? '');
229
+ if (!name) return { text: '拒绝:脚本名非法。\n修法:用 kebab-case(小写字母、数字、连字符)。' };
230
+ const mode = args?.mode === 'adopt' ? 'adopt' : 'create';
231
+ const purpose = typeof args?.purpose === 'string' ? args.purpose.trim() : '';
232
+ if (!purpose) return { text: '拒绝:缺少 purpose。' };
233
+ const allowlist = Array.isArray(args?.allowlist) ? args.allowlist.map(String).filter(Boolean) : [];
234
+ if (!allowlist.length) return { text: '拒绝:allowlist 为空。\n修法:至少给出一个允许的命令前缀(如 python)。' };
235
+ const guard = led.guard(root, ['scripts/**']);
236
+ if (!guard.ok) return { text: guard.text };
237
+ const metaPath = join(root, 'scripts', `${name}.meta.json`);
238
+ const existed = await pathExists(metaPath);
239
+ if (existed && args?.overwrite !== true) {
240
+ return { text: `拒绝:脚本 ${name} 已声明。\n修法:加 overwrite=true 覆盖,或换名。` };
241
+ }
242
+ let sourceRel = null; let sha = null;
243
+ if (mode === 'create') {
244
+ const content = typeof args?.content === 'string' ? args.content : '';
245
+ if (!content.trim()) return { text: '拒绝:mode=create 必须提供 content(脚本正文)。\n修法:把正文写进 content;要收编磁盘上已有的脚本,用 mode=adopt。' };
246
+ sourceRel = posix(join('scripts', `${name}.py`));
247
+ await atomicWrite(join(root, sourceRel), content.endsWith('\n') ? content : `${content}\n`);
248
+ sha = eg().sha256File(join(root, sourceRel));
249
+ } else {
250
+ const src = typeof args?.source === 'string' ? posix(args.source.trim()) : '';
251
+ if (!src) return { text: '拒绝:mode=adopt 必须提供 source(工程内既有脚本路径)。' };
252
+ if (/^[A-Za-z]:/.test(src) || src.startsWith('/') || src.split('/').includes('..')) {
253
+ return { text: `拒绝:source 越出工程范围:${src}。\n修法:只收编工程内的脚本。` };
254
+ }
255
+ const abs = join(root, src);
256
+ if (!(await pathExists(abs))) return { text: `拒绝:工程内没有这个脚本:${src}。` };
257
+ sourceRel = src; sha = eg().sha256File(abs);
258
+ }
259
+ const meta = {
260
+ schemaVersion: SCHEMA_VERSION, name, mode, purpose,
261
+ entrypoint: typeof args?.entrypoint === 'string' ? args.entrypoint : '',
262
+ allowlist, owner: typeof args?.owner === 'string' && args.owner.trim() ? args.owner.trim() : 'user',
263
+ ...(mode === 'adopt' ? { source: sourceRel } : {}),
264
+ sha256: sha, version: existed ? (JSON.parse(await readFile(metaPath, 'utf8')).version ?? 1) + 1 : 1,
265
+ createdAt: iso(),
266
+ };
267
+ await atomicWrite(metaPath, JSON.stringify(meta, null, 2) + '\n');
268
+ const paths = [posix(join('scripts', `${name}.meta.json`)), ...(mode === 'create' ? [sourceRel] : [])];
269
+ const stored = eg().record(root, paths, `script_declare: ${name} (${mode})`);
270
+ const rec = await led.ledgerAppend(root, { record: 'note', name: `script-${name}`, kind: 'script', status: 'active', authority: 'user', mode, source: sourceRel }, { commit: stored.commit ?? undefined });
271
+ return {
272
+ text: [
273
+ `脚本已声明:${name}(${mode === 'create' ? '新建' : '收编既有文件'})`,
274
+ `路径:${sourceRel} 指纹:${sha} 版本:${meta.version}`,
275
+ `用途:${purpose}`,
276
+ `命令白名单:${allowlist.join('、')}`,
277
+ `台账第 ${rec.seq} 行。下一步:template_declare 引用它。`,
278
+ ].join('\n'),
279
+ };
280
+ },
281
+ presentCall: (args) => genericCall(`声明脚本 ${args?.name ?? ''}`.trim(), 'edit', [`scripts/${args?.name ?? ''}.meta.json`]),
282
+ });
283
+
284
+ // ---------- template_declare ----------
285
+ registerTextTool(ctx, {
286
+ name: 'template_declare',
287
+ description: '声明可执行模板:命令白名单 allow、参数契约 paramsSchema、脚本引用 scriptRef、可提取字段 observables。缺 allow 或 observables 未显式给出即拒绝。',
288
+ properties: {
289
+ root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
290
+ id: { type: 'string', description: '模板 id(kebab-case)。' },
291
+ description: { type: 'string', description: '模板说明。' },
292
+ allow: { type: 'array', items: { type: 'string' }, description: '命令前缀白名单(非空)。' },
293
+ scriptRef: { type: 'string', description: '已声明脚本的名字。' },
294
+ args: { type: 'string', description: '参数模板,支持 {{key}} 与 {{__raw__}}/{{__runId__}}/{{__project__}}。' },
295
+ paramsSchema: { type: 'object', description: '参数 JSON Schema(object 根)。' },
296
+ observables: {
297
+ type: 'array',
298
+ description: '可提取字段声明(可空数组,但必须显式给出)。',
299
+ items: {
300
+ type: 'object',
301
+ properties: {
302
+ field: { type: 'string' },
303
+ role: { type: 'string', description: '从 manifest 中哪个角色的产出里提取(log/stdout/result/figure/raw…)。' },
304
+ pattern: { type: 'string', description: '正则,第 1 个捕获组 = 数值。' },
305
+ unit: { type: 'string' },
306
+ description: { type: 'string' },
307
+ },
308
+ },
309
+ },
310
+ overwrite: { type: 'boolean', description: '覆盖同名模板(版本递增)。' },
311
+ },
312
+ required: ['id', 'allow', 'scriptRef', 'observables'],
313
+ execute: async (args, exec) => {
314
+ const led = ctx.get('research.expLedger');
315
+ const eg = () => ctx.get('research.engineGit');
316
+ const root = led.root(args, exec);
317
+ try { await led.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
318
+ const id = led.slugify(args?.id ?? '');
319
+ if (!id) return { text: '拒绝:模板 id 非法。' };
320
+ const allow = Array.isArray(args?.allow) ? args.allow.map(String).filter(Boolean) : [];
321
+ if (!allow.length) return { text: '拒绝:allow 为空。\n修法:至少给出一个允许的命令前缀(如 python)。' };
322
+ const refName = led.slugify(args?.scriptRef ?? '');
323
+ const meta = await svc.readScriptMeta(root, refName);
324
+ if (!meta) return { text: `拒绝:脚本 ${refName} 尚未声明。\n修法:先 script_declare(create 新建 / adopt 收编既有文件)。` };
325
+ const resolved = await svc.resolveScript(root, meta);
326
+ if (!resolved) return { text: `拒绝:脚本 ${refName} 的文件不存在(${meta.mode === 'adopt' ? meta.source : `scripts/${refName}.py`})。` };
327
+ if (!Array.isArray(args?.observables)) {
328
+ return { text: '拒绝:observables 必须显式给出(可为空数组)。\n修法:列出本模板允许提取的字段;没有就写 []。这一条防「事后挑数字」。' };
329
+ }
330
+ const observables = [];
331
+ const seen = new Set();
332
+ for (const o of args.observables) {
333
+ const field = typeof o?.field === 'string' ? o.field.trim() : '';
334
+ const role = typeof o?.role === 'string' ? o.role.trim() : '';
335
+ const pattern = typeof o?.pattern === 'string' ? o.pattern : '';
336
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(field)) return { text: `拒绝:可提取字段名非法:${JSON.stringify(field)}。` };
337
+ if (seen.has(field)) return { text: `拒绝:可提取字段重复:${field}。` };
338
+ seen.add(field);
339
+ if (!role) return { text: `拒绝:字段 ${field} 缺少 role。` };
340
+ try { new RegExp(pattern); } catch (e) { return { text: `拒绝:字段 ${field} 的正则无法编译(${e.message})。` }; }
341
+ observables.push({ field, role, pattern, ...(o?.unit ? { unit: String(o.unit) } : {}), ...(o?.description ? { description: String(o.description) } : {}) });
342
+ }
343
+ const guard = led.guard(root, [TEMPLATES_YAML]);
344
+ if (!guard.ok) return { text: guard.text };
345
+ const doc = await svc.readTemplatesDoc(root);
346
+ const tpls = doc.templates && typeof doc.templates === 'object' ? doc.templates : {};
347
+ const existed = Object.hasOwn(tpls, id);
348
+ if (existed && args?.overwrite !== true) {
349
+ return { text: `拒绝:模板 ${id} 已存在(v${tpls[id].version})。\n修法:加 overwrite=true 覆盖(版本会递增),或换 id。` };
350
+ }
351
+ const paramsSchema = (args?.paramsSchema && typeof args.paramsSchema === 'object' && !Array.isArray(args.paramsSchema))
352
+ ? args.paramsSchema : { type: 'object', properties: {} };
353
+ const argsTpl = typeof args?.args === 'string' ? args.args.trim() : '';
354
+ const tpl = {
355
+ version: existed ? (tpls[id].version ?? 1) + 1 : 1,
356
+ status: 'active',
357
+ description: typeof args?.description === 'string' ? args.description : '',
358
+ allow,
359
+ scriptRef: { name: refName, mode: meta.mode, path: resolved.rel, sha256: resolved.sha256, version: meta.version },
360
+ ...(argsTpl ? { args: argsTpl } : {}),
361
+ paramsSchema,
362
+ observables,
363
+ updatedAt: iso(),
364
+ };
365
+ tpls[id] = tpl;
366
+ doc.schemaVersion = SCHEMA_VERSION;
367
+ doc.templates = tpls;
368
+ await led.writeYamlFile(join(root, TEMPLATES_YAML), doc);
369
+ const stored = eg().record(root, [TEMPLATES_YAML], `template_declare: ${id} v${tpl.version}`);
370
+ const rec = await led.ledgerAppend(root, { record: 'note', name: `template-${id}`, kind: 'template', status: 'active', authority: 'user', version: tpl.version }, { commit: stored.commit ?? undefined });
371
+ return {
372
+ text: [
373
+ `模板已声明:${id}@v${tpl.version}`,
374
+ `脚本:${refName}(${resolved.rel}) 命令白名单:${allow.join('、')}`,
375
+ `可提取字段:${observables.length ? observables.map((o) => `${o.field}[${o.role}]`).join('、') : '(无)'}`,
376
+ `参数契约:${(paramsSchema.required ?? []).length ? `必填 ${(paramsSchema.required ?? []).join('、')}` : '无必填项'}`,
377
+ `台账第 ${rec.seq} 行。下一步:run_draft。`,
378
+ ].join('\n'),
379
+ };
380
+ },
381
+ presentCall: (args) => genericCall(`声明模板 ${args?.id ?? ''}`.trim(), 'edit', [TEMPLATES_YAML]),
382
+ });
383
+
384
+ // ---------- run_draft ----------
385
+ registerTextTool(ctx, {
386
+ name: 'run_draft',
387
+ description: '冻结一次运行实例:校验参数与占位符 → 写 experiments/<runId>/config.json(模板版本/参数/完整命令/脚本指纹/阶段/图版本)→ 写证据清单初稿 → 台账 status=draft。',
388
+ properties: {
389
+ root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
390
+ template: { type: 'string', description: '模板 id。' },
391
+ params: { type: 'object', description: '模板参数。' },
392
+ stage: { type: 'string', description: '所属阶段;缺省 = 当前阶段。' },
393
+ kind: { type: 'string', description: '运行类别(train/eval/smoke…),缺省 run。' },
394
+ from: { type: 'string', description: '复现某个已有 run 的参数(runId)。' },
395
+ },
396
+ required: ['template'],
397
+ execute: async (args, exec) => {
398
+ const led = ctx.get('research.expLedger');
399
+ const eg = () => ctx.get('research.engineGit');
400
+ const root = led.root(args, exec);
401
+ try { await led.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
402
+ const tplId = led.slugify(args?.template ?? '');
403
+ if (!tplId) return { text: '拒绝:缺少 template。' };
404
+ const guard = led.guardAll(root);
405
+ if (!guard.ok) return { text: `拒绝:工程有未修复的受管改动,不能新建实例。\n${guard.text}` };
406
+ let doc;
407
+ try { doc = await svc.readTemplatesDoc(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
408
+ const tpl = doc.templates?.[tplId];
409
+ if (!tpl) return { text: `拒绝:模板不存在:${tplId}。\n修法:project_load 看已有模板,或 template_declare 声明。` };
410
+ if ((tpl.status ?? 'active') !== 'active') return { text: `拒绝:模板 ${tplId} 已停用。` };
411
+ const meta = await svc.readScriptMeta(root, tpl.scriptRef?.name ?? '');
412
+ const resolved = await svc.resolveScript(root, meta);
413
+ if (!resolved) return { text: `拒绝:模板 ${tplId} 引用的脚本不可用(${tpl.scriptRef?.name})。` };
414
+ if (tpl.scriptRef?.sha256 && resolved.sha256 !== tpl.scriptRef.sha256) {
415
+ return { text: `拒绝:脚本内容已变化(${resolved.rel})。\n修法:script_declare overwrite=true 重新声明,再 template_declare overwrite=true 更新引用。` };
416
+ }
417
+ let params = (args?.params && typeof args.params === 'object' && !Array.isArray(args.params)) ? args.params : {};
418
+ if (typeof args?.from === 'string' && args.from.trim()) {
419
+ const fold = await led.ledgerFold(root);
420
+ const src = fold.runs.get(args.from.trim());
421
+ if (!src) return { text: `拒绝:找不到要复现的 run:${args.from}。` };
422
+ params = { ...(src.params ?? {}), ...params };
423
+ }
424
+ const errs = svc.validateParams(tpl.paramsSchema, params);
425
+ if (errs.length) return { text: `拒绝:参数不符合模板契约:\n${errs.map((e) => ` - ${e}`).join('\n')}\n修法:补齐/改正 params。` };
426
+ const needed = placeholdersIn(tpl.args).filter((k) => !RESERVED.includes(k));
427
+ const missing = needed.filter((k) => !(k in params));
428
+ if (missing.length) {
429
+ return { text: `拒绝:模板 ${tplId} 的参数模板需要 ${missing.join('、')},但 params 里没有。\n修法:补齐这些键——缺参会把占位符展开为空、命令行残缺。` };
430
+ }
431
+ const state = await led.state(root);
432
+ const stage = (typeof args?.stage === 'string' && args.stage.trim()) ? args.stage.trim() : (state.stage ?? null);
433
+ const pipeline = await led.readPipeline(root);
434
+ if (stage && pipeline && !(pipeline.stages ?? []).some((s) => s.id === stage)) {
435
+ return { text: `拒绝:阶段 ${stage} 未声明。\n修法:stage_declare 声明它,或换一个已声明的阶段。` };
436
+ }
437
+ const kind = typeof args?.kind === 'string' && args.kind.trim() ? args.kind.trim() : 'run';
438
+ const projectId = (await led.readProject(root)).project?.id ?? basename(root);
439
+ const runId = `${projectId}-${tplId}-${tsCompact(iso())}-${rand3()}`;
440
+ const command = svc.renderCommand(root, runId, tpl, params, resolved.rel);
441
+ const config = {
442
+ schemaVersion: SCHEMA_VERSION,
443
+ runId, projectId,
444
+ template: tplId, templateVersion: tpl.version,
445
+ params, paramsHash: sha256(JSON.stringify(params)),
446
+ stage, kind,
447
+ graphVersion: pipeline?.graphVersion ?? null,
448
+ command,
449
+ scriptRef: { name: tpl.scriptRef?.name, mode: meta?.mode ?? 'create', path: resolved.rel, sha256: resolved.sha256 },
450
+ interpreter: interpFor(resolved.rel, tpl.allow),
451
+ workdir: root,
452
+ envExpectation: { seed: params?.seed ?? null, device: params?.device ?? null, probed: false },
453
+ ts: iso(),
454
+ };
455
+ await ensureDir(join(root, 'experiments', runId, 'raw'));
456
+ await atomicWrite(join(root, 'experiments', runId, 'config.json'), JSON.stringify(config, null, 2) + '\n');
457
+ await svc.refreshManifest(root, runId, { producer: 'run_draft' });
458
+ const stored = eg().record(root, [`experiments/${runId}/config.json`, `experiments/${runId}/manifest.json`], `run_draft: ${runId}`);
459
+ const rec = await led.ledgerAppend(root, {
460
+ record: 'run', runId, template: tplId, templateVersion: tpl.version,
461
+ stage, kind, status: 'draft', params, paramsHash: config.paramsHash,
462
+ graphVersion: pipeline?.graphVersion ?? null,
463
+ }, { commit: stored.commit ?? undefined });
464
+ return {
465
+ text: [
466
+ `实例已冻结:runId=${runId}`,
467
+ `模板:${tplId}@v${tpl.version} 阶段:${stage ?? '(未进入阶段)'} 类别:${kind}`,
468
+ `命令:${command}`,
469
+ `参数:${JSON.stringify(params)}`,
470
+ `台账第 ${rec.seq} 行(status=draft)。下一步:run_launch runId=${runId}。`,
471
+ ].join('\n'),
472
+ };
473
+ },
474
+ presentCall: (args) => genericCall(`冻结实例 ${args?.template ?? ''}`.trim(), 'edit', ['experiments/']),
475
+ });
476
+
477
+ // ---------- run_launch ----------
478
+ registerTextTool(ctx, {
479
+ name: 'run_launch',
480
+ description: '安全闸 + 后台派发:以冻结的 config 为权威 → 校验命令前缀与脚本位置 → 起后台任务(不阻塞本轮)→ 结束时登记 raw/ 产出并归档 → 台账 done/failed。',
481
+ properties: {
482
+ root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
483
+ runId: { type: 'string', description: '已冻结的 runId。' },
484
+ },
485
+ required: ['runId'],
486
+ execute: async (args, exec) => {
487
+ const led = ctx.get('research.expLedger');
488
+ const eg = () => ctx.get('research.engineGit');
489
+ const root = led.root(args, exec);
490
+ try { await led.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
491
+ const runId = typeof args?.runId === 'string' ? args.runId.trim() : '';
492
+ if (!runId) return { text: '拒绝:缺少 runId。' };
493
+ const guard = led.guardAll(root);
494
+ if (!guard.ok) return { text: `拒绝:工程有未修复的受管改动,不能启动。\n${guard.text}` };
495
+ const fold = await led.ledgerFold(root);
496
+ const rec = fold.runs.get(runId);
497
+ if (!rec) return { text: `拒绝:台账里没有 runId=${runId}。\n修法:先 run_draft。` };
498
+ if (rec.status !== 'draft') return { text: `拒绝:run ${runId} 当前状态是 ${rec.status},只有 draft 可以启动。` };
499
+ const cfgPath = join(root, 'experiments', runId, 'config.json');
500
+ let config;
501
+ try { config = JSON.parse(await readFile(cfgPath, 'utf8')); } catch { return { text: `拒绝:读不到冻结的实例定义(experiments/${runId}/config.json)。` }; }
502
+ let doc;
503
+ try { doc = await svc.readTemplatesDoc(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
504
+ const tpl = doc.templates?.[config.template];
505
+ if (!tpl) return { text: `拒绝:模板 ${config.template} 已不存在 → 该 run 不能启动。\n修法:重新声明模板后 run_draft 新实例。` };
506
+ const allow = Array.isArray(tpl.allow) ? tpl.allow.map(String) : [];
507
+ if (!allow.length) return { text: `拒绝(安全闸):模板 ${config.template} 没有命令白名单。` };
508
+ const command = String(config.command ?? '');
509
+ const firstTok = command.split(/\s+/)[0] ?? '';
510
+ const allowHit = allow.some((a) => firstTok === a || firstTok.startsWith(`${a}=`));
511
+ const scriptPath = config.scriptRef?.path ?? '';
512
+ const scriptAbs = scriptPath ? resolve(root, scriptPath) : null;
513
+ const inside = scriptAbs === null || isWithin(root, scriptAbs);
514
+ if (!allowHit || !inside) {
515
+ const why = !allowHit
516
+ ? `命令前缀 ${JSON.stringify(firstTok)} 不在白名单 ${JSON.stringify(allow)} 内`
517
+ : `脚本位置越出工程:${scriptPath}`;
518
+ const kb = ctx.get('research.kbCore');
519
+ if (kb && typeof kb.recordLesson === 'function') {
520
+ try { await kb.recordLesson({ root, name: `lesson-blocked-${runId}`, description: `安全闸拦截:${why}`, statement: `安全闸拦截:${why}。run 保持 draft,未启动任何进程。` }, exec); } catch { /* 教训写失败不阻断拒绝 */ }
521
+ }
522
+ return { text: `拒绝(安全闸):${why}。\nrun ${runId} 保持 draft,未启动任何进程;已记入教训库。\n修法:修正模板白名单或脚本位置,重新 run_draft。` };
523
+ }
524
+ const jobs = ctx.get('jobs');
525
+ const shellSvc = ctx.get('shell');
526
+ if (jobs === undefined) return { text: '拒绝:后台任务能力不可用,无法启动。' };
527
+ if (shellSvc === undefined) return { text: '拒绝:命令执行器不可用,无法启动。' };
528
+ const shellEnvSvc = ctx.get('shellEnv');
529
+ const confining = shellSvc.sandboxMode !== undefined;
530
+ const policySvc = confining ? ctx.get('sandboxPolicy') : undefined;
531
+ if (confining && policySvc === undefined) return { text: '拒绝:沙箱执行器已挂载但策略服务缺失(组合不完整)。' };
532
+ const sandboxPolicy = policySvc ? policySvc.resolve(exec.agent ? { session: exec.agent.session } : {}) : undefined;
533
+ // Windows 宿主用 PowerShell 5.1 跑命令,它会把任何非零退出码归一为 1。
534
+ // 显式回传真实退出码,否则失败 run 的 detail 会失真(现场验收 D4 观察到 3 被记成 1)。
535
+ const execCommand = process.platform === 'win32' ? `${command}; exit $LASTEXITCODE` : command;
536
+ const request = {
537
+ command: execCommand,
538
+ workdir: root,
539
+ ...(shellEnvSvc ? { dshEnv: shellEnvSvc.collect(exec) } : {}),
540
+ ...(sandboxPolicy !== undefined ? { sandboxPolicy } : {}),
541
+ };
542
+ await led.ledgerAppend(root, { record: 'run', runId, status: 'queued', template: config.template, stage: config.stage, kind: config.kind, paramsHash: config.paramsHash, graphVersion: config.graphVersion });
543
+ const label = command.length > 120 ? `${command.slice(0, 120)}…` : command;
544
+ const captured = [];
545
+ let jobId = null;
546
+ try {
547
+ jobId = jobs.start({
548
+ kind: 'research.run',
549
+ label,
550
+ ...(exec.agent ? { owner: exec.agent } : {}),
551
+ run: () => {
552
+ const proc = shellSvc.start(shellSvc.resolve(request));
553
+ const settle = async () => {
554
+ let code = null; let sb = null;
555
+ try { code = proc.exitCode ?? null; } catch { /* ignore */ }
556
+ try { sb = proc.sandbox ?? null; } catch { /* ignore */ }
557
+ const runnerFailed = sb?.runnerFailed === true;
558
+ // 进程输出落盘为证据(stdout.log):先把尚未被读走的部分读干净,
559
+ // 否则证据是否落盘取决于模型有没有先 job_output(时序不确定)。
560
+ try {
561
+ const rest = proc.readOutput();
562
+ if (typeof rest?.delta === 'string' && rest.delta) captured.push(rest.delta);
563
+ } catch { /* ignore */ }
564
+ try {
565
+ if (captured.length) {
566
+ await atomicWrite(join(root, 'experiments', runId, 'raw', 'stdout.log'), captured.join(''));
567
+ }
568
+ } catch { /* ignore */ }
569
+ const man = await svc.refreshManifest(root, runId, { producer: 'process' });
570
+ const paths = [`experiments/${runId}/manifest.json`, ...man.entries.map((e) => e.path).filter((p) => p.includes('/raw/'))];
571
+ const stored = eg().record(root, paths, `run_launch settle: ${runId}`);
572
+ const okRun = !runnerFailed && (code === null || code === 0);
573
+ const detail = runnerFailed
574
+ ? `沙箱运行器失败(mode=${sb?.mode ?? '?'},命令未运行)`
575
+ : (code === null ? '已结束' : `退出码 ${code}`);
576
+ await led.ledgerAppend(root, {
577
+ record: 'run', runId, status: okRun ? 'done' : 'failed', detail,
578
+ jobId, outputs: man.entries.length, complete: man.complete,
579
+ }, { commit: stored.commit ?? undefined });
580
+ return { status: okRun ? 'completed' : 'failed', detail };
581
+ };
582
+ return {
583
+ cancel: () => { try { proc.kill(); } catch { /* ignore */ } },
584
+ done: proc.done.then(settle, settle),
585
+ readOutput: () => {
586
+ try {
587
+ const read = proc.readOutput();
588
+ const delta = typeof read?.delta === 'string' ? read.delta : '';
589
+ if (delta) captured.push(delta);
590
+ const notices = [];
591
+ if (read?.lossy) notices.push('[部分输出已丢弃]');
592
+ const sb = proc.sandbox;
593
+ if (sb?.runnerFailed) notices.push(`[沙箱运行器自身失败(mode=${sb?.mode ?? '?'})——命令未运行]`);
594
+ else if (sb?.denied) notices.push(`[沙箱拒绝(mode=${sb?.mode ?? '?'})]`);
595
+ if (!notices.length) return delta;
596
+ return delta + (delta.length > 0 && !delta.endsWith('\n') ? '\n' : '') + notices.join('\n');
597
+ } catch { return ''; }
598
+ },
599
+ };
600
+ },
601
+ });
602
+ } catch (err) {
603
+ await led.ledgerAppend(root, { record: 'run', runId, status: 'failed', detail: `启动失败:${String(err?.message ?? err).slice(0, 200)}` });
604
+ return { text: `拒绝:启动失败 —— ${String(err?.message ?? err).slice(0, 200)}\nrun ${runId} 已标 failed,冻结的实例定义仍保留。` };
605
+ }
606
+ await led.ledgerAppend(root, { record: 'run', runId, status: 'running', jobId, template: config.template, stage: config.stage, kind: config.kind, paramsHash: config.paramsHash, graphVersion: config.graphVersion });
607
+ return {
608
+ text: [
609
+ `已在后台启动:${runId}`,
610
+ `任务标识:${jobId} 模板:${config.template}@v${config.templateVersion} 阶段:${config.stage ?? '(未进入阶段)'}`,
611
+ `命令:${command}`,
612
+ `执行环境:沙箱模式=${shellSvc.sandboxMode ?? '(无沙箱)'}|环境变量=${shellEnvSvc ? '已注入' : '缺失'}|策略=${sandboxPolicy !== undefined ? '已解析' : '未提供'}`,
613
+ '说明:不阻塞本轮;结束后自动登记产出并把状态置 done/failed。',
614
+ `运行中读日志:job_output(job_id="${jobId}");取消:job_kill(job_id="${jobId}")。`,
615
+ `完整输出(含 stderr)在结束后会被收进证据:experiments/${runId}/raw/stdout.log;任务结束后优先读它(job_output 可能已被引擎收空)。`,
616
+ ].join('\n'),
617
+ };
618
+ },
619
+ presentCall: (args) => genericCall(`启动后台任务 ${args?.runId ?? ''}`.trim(), 'execute', [REGISTRY]),
620
+ });
621
+
622
+ // ---------- run_observe ----------
623
+ registerTextTool(ctx, {
624
+ name: 'run_observe',
625
+ description: '从已登记的产出文件里提取数值,形成证据权威:字段必须由模板声明、来源文件必须已登记且指纹一致。可对比另一 run。',
626
+ properties: {
627
+ root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
628
+ runId: { type: 'string', description: '目标 run。' },
629
+ fields: { type: 'array', items: { type: 'string' }, description: '要提取的字段;缺省 = 模板全部可提取字段。' },
630
+ compareTo: { type: 'string', description: '与之对比的 runId。' },
631
+ tolerancePct: { type: 'number', description: '对比容差(百分比,缺省 0)。' },
632
+ },
633
+ required: ['runId'],
634
+ execute: async (args, exec) => {
635
+ const led = ctx.get('research.expLedger');
636
+ const eg = () => ctx.get('research.engineGit');
637
+ const root = led.root(args, exec);
638
+ try { await led.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
639
+ const runId = typeof args?.runId === 'string' ? args.runId.trim() : '';
640
+ if (!runId) return { text: '拒绝:缺少 runId。' };
641
+ const fold = await led.ledgerFold(root);
642
+ const rec = fold.runs.get(runId);
643
+ if (!rec) return { text: `拒绝:台账里没有 runId=${runId}。` };
644
+ if (!['done', 'failed'].includes(rec.status)) {
645
+ return { text: `拒绝:run ${runId} 状态是 ${rec.status},还没结束,不能提取。\n修法:等任务结束(job_output 看日志)后再提取。` };
646
+ }
647
+ const cfgPath = join(root, 'experiments', runId, 'config.json');
648
+ let config = null;
649
+ try { config = JSON.parse(await readFile(cfgPath, 'utf8')); } catch { /* 允许缺 config */ }
650
+ const doc = await svc.readTemplatesDoc(root);
651
+ const tpl = doc.templates?.[config?.template ?? rec.template];
652
+ if (!tpl) return { text: `拒绝:找不到 run 对应的模板声明(${config?.template ?? rec.template})。` };
653
+ const obsDecl = Array.isArray(tpl.observables) ? tpl.observables : [];
654
+ const want = Array.isArray(args?.fields) && args.fields.length ? args.fields.map(String) : obsDecl.map((o) => o.field);
655
+ const undeclared = want.filter((f) => !obsDecl.some((o) => o.field === f));
656
+ if (undeclared.length) {
657
+ return { text: `拒绝:这些字段没有在模板里声明过:${undeclared.join('、')}。\n模板声明:${obsDecl.map((o) => o.field).join('、') || '(无)'}\n修法:先用 template_declare 声明可提取字段(防事后挑数字)。` };
658
+ }
659
+ const manPath = join(root, 'experiments', runId, 'manifest.json');
660
+ let man = null;
661
+ try { man = JSON.parse(await readFile(manPath, 'utf8')); } catch { man = null; }
662
+ if (!man) return { text: `拒绝:run ${runId} 缺少证据清单(manifest.json)。\n修法:证据不完整的 run 不能据此下结论。` };
663
+ const guard = led.guard(root, [`experiments/${runId}/manifest.json`, `experiments/${runId}/observations.json`]);
664
+ if (!guard.ok) return { text: guard.text };
665
+ const results = []; const problems = [];
666
+ for (const f of want) {
667
+ const decl = obsDecl.find((o) => o.field === f);
668
+ const candidates = (man.entries ?? []).filter((e) => e.role === decl.role);
669
+ if (!candidates.length) { problems.push(`字段 ${f}:清单里没有角色为 ${decl.role} 的产出文件`); continue; }
670
+ let hit = null;
671
+ for (const cand of candidates) {
672
+ const abs = join(root, cand.path);
673
+ const now = eg().sha256File(abs);
674
+ if (now === null) { problems.push(`字段 ${f}:产出文件缺失 ${cand.path}`); continue; }
675
+ if (now !== cand.sha256) { problems.push(`字段 ${f}:产出文件已被改动 ${cand.path}`); continue; }
676
+ let text = '';
677
+ try { text = await readFile(abs, 'utf8'); } catch { continue; }
678
+ const re = new RegExp(decl.pattern);
679
+ const lines = text.split(/\r?\n/);
680
+ for (let i = 0; i < lines.length; i += 1) {
681
+ const m = re.exec(lines[i]);
682
+ if (!m) continue;
683
+ const rawVal = m[1] ?? m[0];
684
+ const num = Number(String(rawVal).replace(/[^0-9eE+\-.]/g, ''));
685
+ hit = {
686
+ field: f,
687
+ value: Number.isFinite(num) && /[0-9]/.test(String(rawVal)) ? num : String(rawVal).trim(),
688
+ ...(decl.unit ? { unit: decl.unit } : {}),
689
+ file: posix(cand.path), line: i + 1, sha256: cand.sha256, extractedAt: iso(),
690
+ };
691
+ break;
692
+ }
693
+ if (hit) break;
694
+ }
695
+ if (hit) results.push(hit);
696
+ else problems.push(`字段 ${f}:按声明的规则在角色 ${decl.role} 的产出里没有匹配到`);
697
+ }
698
+ if (!results.length) {
699
+ return { text: `拒绝:没有提取到任何字段。\n${problems.map((p) => ` - ${p}`).join('\n')}\n修法:确认产出文件已由 run_launch 登记且未被外部改动;若文件被改,用 project_reconcile mode=restore 取回,再重新提取。` };
700
+ }
701
+ // 对比
702
+ if (typeof args?.compareTo === 'string' && args.compareTo.trim()) {
703
+ const other = args.compareTo.trim();
704
+ const otherPath = join(root, 'experiments', other, 'observations.json');
705
+ let otherObs = null;
706
+ try { otherObs = JSON.parse(await readFile(otherPath, 'utf8')); } catch { otherObs = null; }
707
+ const tol = Number.isFinite(Number(args?.tolerancePct)) ? Number(args.tolerancePct) : 0;
708
+ for (const o of results) {
709
+ const ref = (otherObs?.observations ?? []).find((x) => x.field === o.field);
710
+ if (!ref) { o.compareTo = { runId: other, verdict: 'missing', otherValue: null, delta: null, deltaPct: null, tolerancePct: tol }; continue; }
711
+ const a = Number(o.value); const b = Number(ref.value);
712
+ if (!Number.isFinite(a) || !Number.isFinite(b)) {
713
+ o.compareTo = { runId: other, verdict: a === b ? 'match' : 'mismatch', otherValue: ref.value, delta: null, deltaPct: null, tolerancePct: tol };
714
+ continue;
715
+ }
716
+ const delta = a - b;
717
+ const pct = b === 0 ? null : Math.abs(delta / b) * 100;
718
+ o.compareTo = { runId: other, verdict: (pct === null ? (delta === 0 ? 'match' : 'mismatch') : (pct <= tol ? 'match' : 'mismatch')), otherValue: ref.value, delta, deltaPct: pct, tolerancePct: tol };
719
+ }
720
+ }
721
+ const obsPath = join(root, 'experiments', runId, 'observations.json');
722
+ let store = { schemaVersion: SCHEMA_VERSION, runId, observations: [] };
723
+ if (await pathExists(obsPath)) {
724
+ try { store = JSON.parse(await readFile(obsPath, 'utf8')); } catch { /* 重建 */ }
725
+ }
726
+ const byField = new Map((store.observations ?? []).map((o) => [o.field, o]));
727
+ for (const o of results) byField.set(o.field, o);
728
+ store.observations = [...byField.values()];
729
+ store.updatedAt = iso();
730
+ await atomicWrite(obsPath, JSON.stringify(store, null, 2) + '\n');
731
+ const stored = eg().record(root, [`experiments/${runId}/observations.json`], `run_observe: ${runId}`);
732
+ const ev = await led.ledgerAppend(root, {
733
+ record: 'observe', runId, fields: results.map((o) => o.field),
734
+ ...(args?.compareTo ? { compareTo: args.compareTo } : {}),
735
+ }, { commit: stored.commit ?? undefined });
736
+ const lines = [
737
+ `已提取 ${results.length} 个字段(来源均为已登记产出):`,
738
+ ...results.map((o) => ` - ${o.field} = ${o.value}${o.unit ? ` ${o.unit}` : ''} 来源:${o.file}:${o.line}`
739
+ + (o.compareTo ? ` 对比 ${o.compareTo.runId}:${o.compareTo.verdict}${o.compareTo.deltaPct !== null && o.compareTo.deltaPct !== undefined ? `(偏差 ${Number(o.compareTo.deltaPct).toFixed(2)}%)` : ''}` : '')),
740
+ ...(problems.length ? ['未能提取:', ...problems.map((p) => ` - ${p}`)] : []),
741
+ `台账第 ${ev.seq} 行。`,
742
+ ];
743
+ return { text: lines.join('\n') };
744
+ },
745
+ presentCall: (args) => genericCall(`提取观察值 ${args?.runId ?? ''}`.trim(), 'edit', [`experiments/${args?.runId ?? ''}/observations.json`]),
746
+ });
747
+
748
+ // ---------- run_query ----------
749
+ registerTextTool(ctx, {
750
+ name: 'run_query',
751
+ description: '只读查询运行台账与产出清单(可按 runId/模板/阶段/状态过滤)。',
752
+ properties: {
753
+ root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
754
+ runId: { type: 'string', description: '按 runId 过滤。' },
755
+ template: { type: 'string', description: '按模板过滤。' },
756
+ stage: { type: 'string', description: '按阶段过滤。' },
757
+ status: { type: 'string', description: '按状态过滤(draft/queued/running/done/failed/invalidated/archived)。' },
758
+ limit: { type: 'number', description: '返回条数上限(默认 50)。' },
759
+ },
760
+ execute: async (args, exec) => {
761
+ const led = ctx.get('research.expLedger');
762
+ const eg = () => ctx.get('research.engineGit');
763
+ const root = led.root(args, exec);
764
+ try { await led.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
765
+ const fold = await led.ledgerFold(root);
766
+ let list = [...fold.runs.values()];
767
+ if (typeof args?.runId === 'string') list = list.filter((r) => r.runId === args.runId);
768
+ if (typeof args?.template === 'string') list = list.filter((r) => r.template === args.template);
769
+ if (typeof args?.stage === 'string') list = list.filter((r) => r.stage === args.stage);
770
+ if (typeof args?.status === 'string') list = list.filter((r) => r.status === args.status);
771
+ const limit = Number.isFinite(Number(args?.limit)) && Number(args.limit) > 0 ? Math.floor(Number(args.limit)) : 50;
772
+ list = list.slice(-limit);
773
+ if (!list.length) return { text: '没有匹配的运行记录。' };
774
+ const lines = [];
775
+ for (const r of list) {
776
+ let outs = 0; let obs = 0;
777
+ const manPath = join(root, 'experiments', r.runId, 'manifest.json');
778
+ if (await pathExists(manPath)) {
779
+ try { outs = (JSON.parse(await readFile(manPath, 'utf8')).entries ?? []).length; } catch { outs = 0; }
780
+ }
781
+ const obsPath = join(root, 'experiments', r.runId, 'observations.json');
782
+ if (await pathExists(obsPath)) {
783
+ try { obs = (JSON.parse(await readFile(obsPath, 'utf8')).observations ?? []).length; } catch { obs = 0; }
784
+ }
785
+ const size = await pathExists(join(root, 'experiments', r.runId)) ? (eg().listFiles(root, `experiments/${r.runId}/raw`).length) : 0;
786
+ lines.push(`- ${r.runId} ${r.template ?? '?'} 阶段 ${r.stage ?? '—'} ${r.status} 参数 ${JSON.stringify(r.params ?? {})} 产出 ${outs}(raw ${size}) 观察 ${obs}${r.detail ? ` ${r.detail}` : ''}`);
787
+ }
788
+ return { text: `运行记录 ${list.length} 条:\n${lines.join('\n')}` };
789
+ },
790
+ presentCall: (args) => genericCall('查询运行台账', 'read', [REGISTRY]),
791
+ });
792
+
793
+ // ---------- run_close ----------
794
+ registerTextTool(ctx, {
795
+ name: 'run_close',
796
+ description: '把 run 标记为作废(invalidated)或归档(archived):只改状态、不删除任何产出。归档需要用户裁决。',
797
+ properties: {
798
+ root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
799
+ runId: { type: 'string', description: '目标 run。' },
800
+ status: { type: 'string', enum: ['invalidated', 'archived'], description: '目标状态。' },
801
+ reason: { type: 'string', description: '原因(必填)。' },
802
+ decision: {
803
+ type: 'object',
804
+ description: 'archived 必填:用户裁决引用。',
805
+ properties: { askCallId: { type: 'string' }, answer: { type: 'string' }, attested: { type: 'boolean' } },
806
+ },
807
+ },
808
+ required: ['runId', 'status', 'reason'],
809
+ execute: async (args, exec) => {
810
+ const led = ctx.get('research.expLedger');
811
+ const root = led.root(args, exec);
812
+ try { await led.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
813
+ const runId = typeof args?.runId === 'string' ? args.runId.trim() : '';
814
+ const status = args?.status;
815
+ const reason = typeof args?.reason === 'string' ? args.reason.trim() : '';
816
+ if (!runId) return { text: '拒绝:缺少 runId。' };
817
+ if (!['invalidated', 'archived'].includes(status)) return { text: '拒绝:status 只能是 invalidated 或 archived。' };
818
+ if (!reason) return { text: '拒绝:缺少 reason。' };
819
+ const fold = await led.ledgerFold(root);
820
+ const rec = fold.runs.get(runId);
821
+ if (!rec) return { text: `拒绝:台账里没有 runId=${runId}。` };
822
+ if (['invalidated', 'archived'].includes(rec.status)) return { text: `无需处理:run ${runId} 已是 ${rec.status}。` };
823
+ let decision = null;
824
+ if (status === 'archived') {
825
+ const v = led.verifyDecision(exec, args?.decision ?? {});
826
+ if (!v.ok) return { text: `${v.text}\n(归档需要用户裁决。)` };
827
+ decision = v.decision;
828
+ }
829
+ const guard = led.guard(root, [REGISTRY]);
830
+ if (!guard.ok) return { text: guard.text };
831
+ const ev = await led.ledgerAppend(root, {
832
+ record: 'run', runId, status, reason,
833
+ ...(decision ? { decision } : {}),
834
+ }, { actor: decision ? 'user' : 'engine' });
835
+ if (decision) {
836
+ await led.ledgerAppend(root, {
837
+ record: 'decision', kind: 'run-archive', id: runId,
838
+ askCallId: decision.askCallId, question: decision.question, answer: decision.answer,
839
+ sessionId: decision.sessionId, ...(decision.attested ? { attested: true } : {}),
840
+ }, { actor: 'user' });
841
+ }
842
+ return { text: `run ${runId} 已标记为 ${status}。\n原因:${reason}\n产出与观察值一律保留,未删除任何文件。\n台账第 ${ev.seq} 行。` };
843
+ },
844
+ presentCall: (args) => genericCall(`关闭 run(${args?.status ?? '?'})`, 'edit', [REGISTRY]),
845
+ });
846
+ }
847
+
848
+ export { apply };