@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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/cordis.patch.yml +32 -0
- package/package.json +59 -0
- package/presets/research-engine/agent.cordis.yml +305 -0
- package/presets/research-engine/contract/conventions.schema.json +41 -0
- package/presets/research-engine/contract/fields.md +150 -0
- package/presets/research-engine/contract/managed.json +36 -0
- package/presets/research-engine/contract/manifest.schema.json +33 -0
- package/presets/research-engine/contract/note.schema.json +56 -0
- package/presets/research-engine/contract/observations.schema.json +46 -0
- package/presets/research-engine/contract/pipeline.schema.json +43 -0
- package/presets/research-engine/contract/project.schema.json +66 -0
- package/presets/research-engine/contract/templates.schema.json +78 -0
- package/presets/research-engine/plugins/engine-git/main.js +284 -0
- package/presets/research-engine/plugins/exp-ledger/main.js +1324 -0
- package/presets/research-engine/plugins/kb-core/main.js +538 -0
- package/presets/research-engine/plugins/stage-ctrl/main.js +388 -0
- package/presets/research-engine/plugins/task-dispatch/main.js +848 -0
- package/presets/research-engine/preset.yml +3 -0
- package/presets/research-engine/skills/research-engine/SKILL.md +114 -0
- package/scripts/install.mjs +116 -0
- package/startup.js +24 -0
|
@@ -0,0 +1,1324 @@
|
|
|
1
|
+
// exp-ledger —— 环境 / 台账 / 校验(8 工具,其中 ledger_append 为内部服务)
|
|
2
|
+
//
|
|
3
|
+
// 拥有数据:project.yaml(环境:身份/受管区/legacy/约定)、registry.jsonl(状态与事件,append-only)。
|
|
4
|
+
// 对外服务 research.expLedger:跨域只走服务,不直接写别人的文件(tools.md §2「跨域只走 id 与属主提供的服务」)。
|
|
5
|
+
//
|
|
6
|
+
// 模型可见文本铁律:返回/描述/错误一律不出现版本库词汇(见 §6.1-2 的自动扫描),
|
|
7
|
+
// 末尾统一过 scrub() 兜底。
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { join, dirname, resolve, relative, sep, basename, isAbsolute } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { readFile, writeFile, mkdir, readdir, stat, rename, rm } from 'node:fs/promises';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import { createHash } 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 PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const PRESET_ROOT = resolve(PLUGIN_DIR, '..', '..');
|
|
37
|
+
const CONTRACT_DIR = join(PRESET_ROOT, 'contract');
|
|
38
|
+
const SCHEMA_VERSION = 3;
|
|
39
|
+
const REGISTRY = 'registry.jsonl';
|
|
40
|
+
const PROJECT_YAML = 'project.yaml';
|
|
41
|
+
const STATE_REL = '.research/state.json';
|
|
42
|
+
const PIPELINE_YAML = 'pipeline.yaml';
|
|
43
|
+
const TEMPLATES_YAML = 'templates.yaml';
|
|
44
|
+
const LEGACY_EXTS = new Set(['.pth', '.png', '.txt', '.txt~', '.ipynb', '.zip', '.bin', '.npz', '.pt']);
|
|
45
|
+
const SKIP_DIRS = new Set(['.research', '.kb', '.git', 'node_modules', '__pycache__', '.dsh', 'engine.git']);
|
|
46
|
+
// 词边界替换:不误伤用户数据里的同形子串(如 usergit-probe / shanghai),但独立出现的版本库词汇一律替换。
|
|
47
|
+
const SCRUB = /(^|[^A-Za-z0-9_-])(git|commit|HEAD|diff|hash|sha)(?![A-Za-z0-9_-])/gi;
|
|
48
|
+
|
|
49
|
+
export const name = 'exp-ledger';
|
|
50
|
+
export const inject = ['tools'];
|
|
51
|
+
|
|
52
|
+
// ---------- 小工具 ----------
|
|
53
|
+
function iso() { return new Date().toISOString(); }
|
|
54
|
+
function sha256(s) { return createHash('sha256').update(s).digest('hex'); }
|
|
55
|
+
function slugify(s) {
|
|
56
|
+
const out = String(s).toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
57
|
+
return out || '';
|
|
58
|
+
}
|
|
59
|
+
function scrub(text) { return String(text ?? '').replace(SCRUB, (_m, pre) => `${pre}·`); }
|
|
60
|
+
function isWithin(root, p) {
|
|
61
|
+
const rel = relative(resolve(root), resolve(p));
|
|
62
|
+
return rel === '' || (!rel.startsWith('..' + sep) && rel !== '..');
|
|
63
|
+
}
|
|
64
|
+
async function pathExists(p) { try { await stat(p); return true; } catch { return false; } }
|
|
65
|
+
async function ensureDir(p) { await mkdir(p, { recursive: true }); }
|
|
66
|
+
async function atomicWrite(p, body) {
|
|
67
|
+
await ensureDir(dirname(p));
|
|
68
|
+
const tmp = `${p}.tmp-${sha256(iso() + Math.random()).slice(0, 8)}`;
|
|
69
|
+
await writeFile(tmp, body, 'utf8');
|
|
70
|
+
await rename(tmp, p);
|
|
71
|
+
}
|
|
72
|
+
function sessionCwd(exec) {
|
|
73
|
+
const cwd = exec?.agent?.session?.header?.cwd;
|
|
74
|
+
return typeof cwd === 'string' && cwd.length > 0 ? cwd : process.cwd();
|
|
75
|
+
}
|
|
76
|
+
function sessionIdOf(exec) {
|
|
77
|
+
const h = exec?.agent?.session?.header;
|
|
78
|
+
return (h && typeof h.id === 'string') ? h.id : '';
|
|
79
|
+
}
|
|
80
|
+
function posix(p) { return String(p).replace(/\\/g, '/'); }
|
|
81
|
+
|
|
82
|
+
// ---------- 契约校验 ----------
|
|
83
|
+
let ajvCache = null;
|
|
84
|
+
async function contractAjv() {
|
|
85
|
+
if (ajvCache) return ajvCache;
|
|
86
|
+
if (!Ajv2020) return null;
|
|
87
|
+
const files = ['project.schema.json', 'pipeline.schema.json', 'templates.schema.json',
|
|
88
|
+
'conventions.schema.json', 'manifest.schema.json', 'observations.schema.json', 'note.schema.json'];
|
|
89
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
90
|
+
for (const f of files) {
|
|
91
|
+
try {
|
|
92
|
+
const doc = JSON.parse(await readFile(join(CONTRACT_DIR, f), 'utf8'));
|
|
93
|
+
ajv.addSchema(doc, doc.$id);
|
|
94
|
+
} catch { /* 缺一份就少一份校验能力,不阻断引擎 */ }
|
|
95
|
+
}
|
|
96
|
+
ajvCache = ajv;
|
|
97
|
+
return ajv;
|
|
98
|
+
}
|
|
99
|
+
async function validateContract(file, doc) {
|
|
100
|
+
const ajv = await contractAjv();
|
|
101
|
+
if (!ajv) return ['契约校验器不可用(跳过结构校验)'];
|
|
102
|
+
const v = ajv.getSchema(`https://research-engine.local/contract/${file}`);
|
|
103
|
+
if (!v) return [`契约缺失:${file}`];
|
|
104
|
+
if (v(doc)) return [];
|
|
105
|
+
return (v.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------- 图校验(stage-ctrl 与 verify 共用)----------
|
|
109
|
+
function checkGraph(doc) {
|
|
110
|
+
const fatal = []; const warn = [];
|
|
111
|
+
const stages = Array.isArray(doc?.stages) ? doc.stages : [];
|
|
112
|
+
const edges = Array.isArray(doc?.edges) ? doc.edges : [];
|
|
113
|
+
const ids = new Set();
|
|
114
|
+
for (const s of stages) {
|
|
115
|
+
const id = s?.id;
|
|
116
|
+
if (typeof id !== 'string' || id === '') { fatal.push('存在缺少 id 的阶段'); continue; }
|
|
117
|
+
if (ids.has(id)) fatal.push(`阶段 id 重复:${id}`);
|
|
118
|
+
ids.add(id);
|
|
119
|
+
}
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
for (const e of edges) {
|
|
122
|
+
if (typeof e?.from !== 'string' || typeof e?.to !== 'string') { fatal.push('边必须含 from/to'); continue; }
|
|
123
|
+
const key = `${e.from}->${e.to}`;
|
|
124
|
+
if (seen.has(key)) fatal.push(`边重复:${key}`);
|
|
125
|
+
seen.add(key);
|
|
126
|
+
if (!ids.has(e.from)) fatal.push(`边起点未声明:${e.from}`);
|
|
127
|
+
if (!ids.has(e.to)) fatal.push(`边终点未声明:${e.to}`);
|
|
128
|
+
if (e.from === e.to) fatal.push(`自环:${key}`);
|
|
129
|
+
}
|
|
130
|
+
const entry = doc?.entry;
|
|
131
|
+
if (entry !== undefined && entry !== null && entry !== '') {
|
|
132
|
+
if (!ids.has(entry)) fatal.push(`入口不是已知阶段:${entry}`);
|
|
133
|
+
else if (edges.some((e) => e?.to === entry)) fatal.push(`入口阶段有入边:${entry}`);
|
|
134
|
+
}
|
|
135
|
+
const active = new Set(stages.filter((s) => (s?.status ?? 'active') === 'active').map((s) => s.id));
|
|
136
|
+
if (active.size > 0 && (entry === undefined || entry === null || entry === '')) {
|
|
137
|
+
warn.push('有阶段但未设入口(entry)');
|
|
138
|
+
}
|
|
139
|
+
const adj = new Map();
|
|
140
|
+
for (const e of edges) {
|
|
141
|
+
if (!adj.has(e.from)) adj.set(e.from, new Set());
|
|
142
|
+
adj.get(e.from).add(e.to);
|
|
143
|
+
}
|
|
144
|
+
const reach = new Set();
|
|
145
|
+
const stack = entry ? [entry] : [];
|
|
146
|
+
while (stack.length) {
|
|
147
|
+
const n = stack.pop();
|
|
148
|
+
if (reach.has(n)) continue;
|
|
149
|
+
reach.add(n);
|
|
150
|
+
for (const t of adj.get(n) ?? []) stack.push(t);
|
|
151
|
+
}
|
|
152
|
+
for (const s of stages) if (!reach.has(s.id)) warn.push(`阶段不可达(从入口出发):${s.id}`);
|
|
153
|
+
return { fatal, warn };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------- 工程文件读写 ----------
|
|
157
|
+
async function readYamlFile(p) {
|
|
158
|
+
const raw = await readFile(p, 'utf8');
|
|
159
|
+
let doc;
|
|
160
|
+
try { doc = yaml.load(raw); } catch (e) { throw new Error(`${basename(p)} 解析失败(${e.message})`); }
|
|
161
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw new Error(`${basename(p)} 顶层必须是映射对象`);
|
|
162
|
+
return doc;
|
|
163
|
+
}
|
|
164
|
+
async function writeYamlFile(p, doc) {
|
|
165
|
+
await atomicWrite(p, yaml.dump(doc, { lineWidth: -1, noRefs: true, skipInvalid: true }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------- registry.jsonl ----------
|
|
169
|
+
async function readRegistryRows(root) {
|
|
170
|
+
const p = join(root, REGISTRY);
|
|
171
|
+
if (!(await pathExists(p))) return [];
|
|
172
|
+
const raw = await readFile(p, 'utf8');
|
|
173
|
+
const rows = [];
|
|
174
|
+
const lines = raw.split(/\r?\n/);
|
|
175
|
+
let prevHash = 'genesis';
|
|
176
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
177
|
+
const t = lines[i].trim();
|
|
178
|
+
if (!t) continue;
|
|
179
|
+
let obj = null;
|
|
180
|
+
try { obj = JSON.parse(t); } catch { obj = null; }
|
|
181
|
+
rows.push({ lineNo: rows.length + 1, raw: t, obj, prevHashOfPrevious: prevHash });
|
|
182
|
+
prevHash = sha256(t);
|
|
183
|
+
}
|
|
184
|
+
return rows;
|
|
185
|
+
}
|
|
186
|
+
function foldLedger(rows) {
|
|
187
|
+
const runs = new Map(); const entities = new Map(); const decisions = []; const stages = [];
|
|
188
|
+
const verifies = []; const notes = new Map(); const renders = [];
|
|
189
|
+
let currentStage = null; let graphVersion = null;
|
|
190
|
+
for (const r of rows) {
|
|
191
|
+
const o = r.obj;
|
|
192
|
+
if (!o || typeof o !== 'object') continue;
|
|
193
|
+
switch (o.record) {
|
|
194
|
+
case 'run': if (o.runId) runs.set(o.runId, { ...(runs.get(o.runId) ?? {}), ...o }); break;
|
|
195
|
+
case 'entity': if (o.id) entities.set(o.id, o); break;
|
|
196
|
+
case 'decision': decisions.push(o); break;
|
|
197
|
+
case 'stage':
|
|
198
|
+
stages.push(o);
|
|
199
|
+
if (typeof o.to === 'string') currentStage = o.to;
|
|
200
|
+
if (Number.isInteger(o.graphVersion)) graphVersion = o.graphVersion;
|
|
201
|
+
break;
|
|
202
|
+
case 'verify': verifies.push(o); break;
|
|
203
|
+
case 'note': if (o.name) notes.set(o.name, { ...(notes.get(o.name) ?? {}), ...o }); break;
|
|
204
|
+
case 'render': renders.push(o); break;
|
|
205
|
+
default: break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { runs, entities, decisions, stages, verifies, notes, renders, currentStage, graphVersion };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------- 服务 ----------
|
|
212
|
+
function buildService(ctx) {
|
|
213
|
+
const eg = () => ctx.get('research.engineGit');
|
|
214
|
+
let chain = Promise.resolve();
|
|
215
|
+
const serial = (fn) => { const next = chain.then(fn, fn); chain = next.catch(() => {}); return next; };
|
|
216
|
+
|
|
217
|
+
const svc = {
|
|
218
|
+
// --- 根解析 ---
|
|
219
|
+
root(args, exec) {
|
|
220
|
+
const base = sessionCwd(exec);
|
|
221
|
+
const r = (typeof args?.root === 'string' && args.root.trim() !== '')
|
|
222
|
+
? resolve(base, args.root.trim()) : resolve(base);
|
|
223
|
+
return r;
|
|
224
|
+
},
|
|
225
|
+
async requireProject(root) {
|
|
226
|
+
const p = join(root, PROJECT_YAML);
|
|
227
|
+
if (!(await pathExists(p))) {
|
|
228
|
+
const e = new Error(`这不是研究工程:${root} 下没有 ${PROJECT_YAML}。\n修法:若这是新工程,先 project_init;若工程在别处,把工作区切到工程根或传 root。`);
|
|
229
|
+
e.code = 'PROJECT_MISSING';
|
|
230
|
+
throw e;
|
|
231
|
+
}
|
|
232
|
+
return p;
|
|
233
|
+
},
|
|
234
|
+
async readProject(root) { await this.requireProject(root); return readYamlFile(join(root, PROJECT_YAML)); },
|
|
235
|
+
async readPipeline(root) {
|
|
236
|
+
const p = join(root, PIPELINE_YAML);
|
|
237
|
+
if (!(await pathExists(p))) return null;
|
|
238
|
+
return readYamlFile(p);
|
|
239
|
+
},
|
|
240
|
+
async readTemplates(root) {
|
|
241
|
+
const p = join(root, TEMPLATES_YAML);
|
|
242
|
+
if (!(await pathExists(p))) return null;
|
|
243
|
+
return readYamlFile(p);
|
|
244
|
+
},
|
|
245
|
+
readYamlFile, writeYamlFile,
|
|
246
|
+
async writeProject(root, doc) { await writeYamlFile(join(root, PROJECT_YAML), doc); },
|
|
247
|
+
contractErrors: validateContract,
|
|
248
|
+
checkGraph,
|
|
249
|
+
sha256, iso, slugify, scrub, isWithin, atomicWrite, pathExists, posix, sessionIdOf, sessionCwd,
|
|
250
|
+
ledgerFold: foldLedger,
|
|
251
|
+
|
|
252
|
+
// --- 台账(单写者)---
|
|
253
|
+
async ledgerRead(root) { return readRegistryRows(root); },
|
|
254
|
+
async ledgerFold(root) { return foldLedger(await readRegistryRows(root)); },
|
|
255
|
+
/** 追加一行;调用方负责在此之前把文件写好(写文件 → 入库 → 追加台账)。 */
|
|
256
|
+
ledgerAppend(root, event, opts = {}) {
|
|
257
|
+
return serial(async () => {
|
|
258
|
+
const rows = await readRegistryRows(root);
|
|
259
|
+
const last = rows[rows.length - 1] ?? null;
|
|
260
|
+
const seq = rows.length + 1;
|
|
261
|
+
const prev = last ? sha256(last.raw) : 'genesis';
|
|
262
|
+
const row = {
|
|
263
|
+
...event,
|
|
264
|
+
seq,
|
|
265
|
+
prev,
|
|
266
|
+
actor: opts.actor ?? 'engine',
|
|
267
|
+
ts: opts.ts ?? iso(),
|
|
268
|
+
};
|
|
269
|
+
if (opts.commit) row.commit = opts.commit;
|
|
270
|
+
const line = JSON.stringify(row);
|
|
271
|
+
const body = rows.map((r) => r.raw).join('\n') + (rows.length ? '\n' : '') + line + '\n';
|
|
272
|
+
await atomicWrite(join(root, REGISTRY), body);
|
|
273
|
+
// 台账本身也是受管文件:写完立刻入库,否则每次追加都会把工程留成 dirty。
|
|
274
|
+
eg().record(root, [REGISTRY], `ledger: ${event.record}${event.action ? ` ${event.action}` : ''}`);
|
|
275
|
+
return { seq, prev, row, line };
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
/** 写文件 → 入库 → 台账,三步一体(崩溃只会留下 dirty,由 reconcile 修)。 */
|
|
279
|
+
async commitAndLog(root, paths, message, event, opts = {}) {
|
|
280
|
+
const r = eg().record(root, paths, message);
|
|
281
|
+
const rec = await this.ledgerAppend(root, event, { ...opts, commit: r.commit ?? undefined });
|
|
282
|
+
return { ...r, seq: rec.seq };
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
// --- 守卫(写类工具前置)---
|
|
286
|
+
managedPaths(root) {
|
|
287
|
+
return [
|
|
288
|
+
PROJECT_YAML, PIPELINE_YAML, TEMPLATES_YAML, REGISTRY,
|
|
289
|
+
'scripts/**', '.kb/**', 'skills/*/SKILL.md', '_report/**',
|
|
290
|
+
'.research/state.json', '.research/managed.json', '.research/ignored.json',
|
|
291
|
+
'experiments/*/config.json', 'experiments/*/manifest.json', 'experiments/*/observations.json',
|
|
292
|
+
];
|
|
293
|
+
},
|
|
294
|
+
/** 被显式忽略的路径(project_reconcile mode=ignore):verify 不再把它们当未知。 */
|
|
295
|
+
async ignoredSet(root) {
|
|
296
|
+
try {
|
|
297
|
+
const doc = JSON.parse(await readFile(join(root, '.research', 'ignored.json'), 'utf8'));
|
|
298
|
+
return new Set((doc?.paths ?? []).map((e) => posix(typeof e === 'string' ? e : e?.path)).filter(Boolean));
|
|
299
|
+
} catch { return new Set(); }
|
|
300
|
+
},
|
|
301
|
+
async addIgnored(root, paths, reason) {
|
|
302
|
+
const p = join(root, '.research', 'ignored.json');
|
|
303
|
+
let doc = { schemaVersion: SCHEMA_VERSION, paths: [] };
|
|
304
|
+
try { doc = JSON.parse(await readFile(p, 'utf8')); } catch { /* 新建 */ }
|
|
305
|
+
const have = new Set((doc.paths ?? []).map((e) => posix(typeof e === 'string' ? e : e?.path)));
|
|
306
|
+
for (const raw of paths) {
|
|
307
|
+
const rel = posix(raw);
|
|
308
|
+
if (have.has(rel)) continue;
|
|
309
|
+
doc.paths.push({ path: rel, reason, ts: iso() });
|
|
310
|
+
have.add(rel);
|
|
311
|
+
}
|
|
312
|
+
doc.schemaVersion = SCHEMA_VERSION;
|
|
313
|
+
await atomicWrite(p, JSON.stringify(doc, null, 2) + '\n');
|
|
314
|
+
return doc.paths.length;
|
|
315
|
+
},
|
|
316
|
+
/** 把路径重新纳入常规管辖(工具已经能产出它时调用)。 */
|
|
317
|
+
async removeIgnored(root, paths) {
|
|
318
|
+
const p = join(root, '.research', 'ignored.json');
|
|
319
|
+
if (!(await pathExists(p))) return 0;
|
|
320
|
+
let doc = { schemaVersion: SCHEMA_VERSION, paths: [] };
|
|
321
|
+
try { doc = JSON.parse(await readFile(p, 'utf8')); } catch { return 0; }
|
|
322
|
+
const drop = new Set((Array.isArray(paths) ? paths : []).map(posix));
|
|
323
|
+
const before = (doc.paths ?? []).length;
|
|
324
|
+
doc.paths = (doc.paths ?? []).filter((e) => !drop.has(posix(typeof e === 'string' ? e : e?.path)));
|
|
325
|
+
if (doc.paths.length === before) return 0;
|
|
326
|
+
doc.schemaVersion = SCHEMA_VERSION;
|
|
327
|
+
await atomicWrite(p, JSON.stringify(doc, null, 2) + '\n');
|
|
328
|
+
eg().record(root, ['.research/ignored.json'], 'reconcile: 重新纳入管辖');
|
|
329
|
+
return before - doc.paths.length;
|
|
330
|
+
},
|
|
331
|
+
/** 守卫:受管区任何文件与已登记版本不一致即拒绝(§6.1-3「一律拒绝」)。 */
|
|
332
|
+
guard(root) {
|
|
333
|
+
const e = eg();
|
|
334
|
+
if (!e.capabilities().available || !e.exists(root)) return { ok: true, dirty: [], degraded: true };
|
|
335
|
+
const dirty = e.changed(root, this.managedPaths(root));
|
|
336
|
+
if (dirty.length) {
|
|
337
|
+
const list = dirty.map((d) => ` - ${d.path}(${d.code === 'D' ? '被删除' : '被外部修改'})`).join('\n');
|
|
338
|
+
return {
|
|
339
|
+
ok: false, dirty,
|
|
340
|
+
text: `拒绝写入:受管区已被工程外部的改动覆盖,当前内容与已登记版本不一致。\n${list}\n`
|
|
341
|
+
+ '修法:project_reconcile mode=restore(默认,取回已登记版本);确实要保留外部改动,则 mode=adopt 并附用户裁决。',
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return { ok: true, dirty: [] };
|
|
345
|
+
},
|
|
346
|
+
/** 与 guard 同义(历史命名)。 */
|
|
347
|
+
guardAll(root) { return this.guard(root); },
|
|
348
|
+
|
|
349
|
+
// --- 用户权威回查 ---
|
|
350
|
+
verifyDecision(exec, input) {
|
|
351
|
+
const askCallId = typeof input?.askCallId === 'string' ? input.askCallId.trim() : '';
|
|
352
|
+
const claimed = typeof input?.answer === 'string' ? input.answer.trim() : '';
|
|
353
|
+
if (!askCallId) {
|
|
354
|
+
return { ok: false, text: '拒绝:缺少 decision.askCallId。\n修法:先用 ask_user_question 向用户提问,再把返回的调用标识与用户答复原文一起传进来。' };
|
|
355
|
+
}
|
|
356
|
+
if (!claimed) {
|
|
357
|
+
return { ok: false, text: '拒绝:缺少 decision.answer。\n修法:把用户答复原文(不要转述、不要润色)填入 decision.answer。' };
|
|
358
|
+
}
|
|
359
|
+
const session = exec?.agent?.session;
|
|
360
|
+
const sessionId = sessionIdOf(exec);
|
|
361
|
+
const events = Array.isArray(session?.events) ? session.events : null;
|
|
362
|
+
if (!events) {
|
|
363
|
+
if (input?.attested === true) {
|
|
364
|
+
return { ok: true, decision: { askCallId, answer: claimed, sessionId, ts: iso(), attested: true } };
|
|
365
|
+
}
|
|
366
|
+
return { ok: false, text: '拒绝:无法回查会话记录,不能确认这条答复确实来自用户。\n修法:若用户确认「原文在案但无法回源」,显式加 decision.attested=true 再提交(记录会标 attested)。' };
|
|
367
|
+
}
|
|
368
|
+
let call = null; let result = null;
|
|
369
|
+
for (const ev of events) {
|
|
370
|
+
if (ev?.type === 'tool/call' && ev.data?.callId === askCallId) call = ev;
|
|
371
|
+
if (ev?.type === 'tool/result') {
|
|
372
|
+
const blk = Array.isArray(ev.data?.message?.content) ? ev.data.message.content[0] : null;
|
|
373
|
+
if (blk && blk.type === 'tool-result' && blk.toolCallId === askCallId) result = ev;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (!call) {
|
|
377
|
+
return { ok: false, text: `拒绝:本会话里找不到标识为 ${askCallId} 的提问记录。\n修法:先运行 entity_query record=ask 取回本会话的真实提问标识,再把它填进 decision.askCallId(问题自己的 id 不是这个标识)。` };
|
|
378
|
+
}
|
|
379
|
+
if (call.data?.name !== 'ask_user_question') {
|
|
380
|
+
return { ok: false, text: `拒绝:标识 ${askCallId} 对应的是一次 ${call.data?.name ?? '未知'} 调用,不是向用户提问。\n修法:用户权威只能来自 ask_user_question。` };
|
|
381
|
+
}
|
|
382
|
+
if (!result) {
|
|
383
|
+
return { ok: false, text: `拒绝:标识 ${askCallId} 的提问还没有用户答复。\n修法:等用户答复后再提交。` };
|
|
384
|
+
}
|
|
385
|
+
let question = '';
|
|
386
|
+
try {
|
|
387
|
+
const a = JSON.parse(String(call.data?.arguments ?? '{}'));
|
|
388
|
+
const qs = Array.isArray(a?.questions) ? a.questions : [];
|
|
389
|
+
question = qs.map((q) => `${q?.id ?? ''}: ${q?.question ?? ''}`).join(' | ');
|
|
390
|
+
} catch { question = ''; }
|
|
391
|
+
const blk = result.data?.message?.content?.[0];
|
|
392
|
+
const text = Array.isArray(blk?.content)
|
|
393
|
+
? blk.content.filter((b) => b?.type === 'text').map((b) => b.text).join('\n') : '';
|
|
394
|
+
let answers = null;
|
|
395
|
+
try { const v = JSON.parse(text); if (v && Array.isArray(v.answers)) answers = v.answers; } catch { answers = null; }
|
|
396
|
+
if (!answers || answers.length === 0) {
|
|
397
|
+
return { ok: false, text: '拒绝:该提问的答复为空,不构成用户权威。\n修法:重新提问并取得明确答复。' };
|
|
398
|
+
}
|
|
399
|
+
const recorded = answers.map((a) => {
|
|
400
|
+
const sel = Array.isArray(a.selected) ? a.selected.join(' / ') : '';
|
|
401
|
+
return `${a.id ?? ''}: ${sel}${a.custom ? ` | 补充:${a.custom}` : ''}`;
|
|
402
|
+
}).join('; ').trim();
|
|
403
|
+
const norm = (s) => String(s).toLowerCase().replace(/[\s,。、,.;;::!!??"'“”‘’()()[\]【】-]/g, '');
|
|
404
|
+
const b = norm(claimed);
|
|
405
|
+
// 一致性:允许「原样拷贝规范串」或「包含选项值/补充说明的自然复述」两种写法。
|
|
406
|
+
const parts = [
|
|
407
|
+
...answers.flatMap((x) => (Array.isArray(x.selected) ? x.selected : [])),
|
|
408
|
+
...answers.map((x) => x.custom).filter(Boolean),
|
|
409
|
+
].map(norm).filter((x) => x.length >= 1);
|
|
410
|
+
const a = norm(recorded);
|
|
411
|
+
const consistent = b !== '' && (
|
|
412
|
+
a.includes(b) || b.includes(a)
|
|
413
|
+
|| parts.some((p) => b.includes(p) || p.includes(b))
|
|
414
|
+
);
|
|
415
|
+
if (!consistent) {
|
|
416
|
+
return { ok: false, text: `拒绝:提交的答复与用户实际答复不一致。\n用户实际答复:${recorded}\n修法:原样拷贝用户答复原文(不得推断、润色、代答)。` };
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
ok: true,
|
|
420
|
+
decision: {
|
|
421
|
+
askCallId, question, answer: recorded,
|
|
422
|
+
selected: answers.flatMap((x) => x.selected ?? []),
|
|
423
|
+
...(answers.some((x) => x.custom) ? { custom: answers.map((x) => x.custom).filter(Boolean).join(' ') } : {}),
|
|
424
|
+
sessionId, ts: iso(),
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
// --- 受管白名单 ---
|
|
430
|
+
async managedList(root) {
|
|
431
|
+
try {
|
|
432
|
+
const m = JSON.parse(await readFile(join(CONTRACT_DIR, 'managed.json'), 'utf8'));
|
|
433
|
+
if (Array.isArray(m.managed) && m.managed.length) return m.managed;
|
|
434
|
+
} catch { /* fall through */ }
|
|
435
|
+
return this.managedPaths(root);
|
|
436
|
+
},
|
|
437
|
+
async reservedPlaceholders() {
|
|
438
|
+
try {
|
|
439
|
+
const m = JSON.parse(await readFile(join(CONTRACT_DIR, 'managed.json'), 'utf8'));
|
|
440
|
+
return m.reservedPlaceholders ?? {};
|
|
441
|
+
} catch { return {}; }
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
// --- 台账折叠视图 ---
|
|
445
|
+
async state(root) {
|
|
446
|
+
const fold = await this.ledgerFold(root);
|
|
447
|
+
const pipeline = await this.readPipeline(root);
|
|
448
|
+
const runs = [...fold.runs.values()];
|
|
449
|
+
const counts = { queued: 0, running: 0, done: 0, failed: 0, draft: 0, invalidated: 0, archived: 0 };
|
|
450
|
+
for (const r of runs) if (r.status && counts[r.status] !== undefined) counts[r.status] += 1;
|
|
451
|
+
return {
|
|
452
|
+
stage: fold.currentStage ?? pipeline?.entry ?? null,
|
|
453
|
+
graphVersion: fold.graphVersion ?? pipeline?.graphVersion ?? null,
|
|
454
|
+
runs, counts, fold,
|
|
455
|
+
};
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
return svc;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---------- 工具注册 ----------
|
|
462
|
+
function genericCall(title, kind, paths) {
|
|
463
|
+
return { card: 'generic', title, kind, ...(paths && paths.length ? { locations: paths.map((p) => ({ path: p })) } : {}) };
|
|
464
|
+
}
|
|
465
|
+
function registerTextTool(ctx, tool) {
|
|
466
|
+
ctx.tools.register({
|
|
467
|
+
name: tool.name,
|
|
468
|
+
description: tool.description,
|
|
469
|
+
parameters: {
|
|
470
|
+
type: 'object',
|
|
471
|
+
properties: tool.properties || {},
|
|
472
|
+
...(tool.required && tool.required.length ? { required: tool.required } : {}),
|
|
473
|
+
},
|
|
474
|
+
output: {
|
|
475
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } }, required: ['text'] },
|
|
476
|
+
render: (_a, v) => [{ type: 'text', text: v.text }],
|
|
477
|
+
},
|
|
478
|
+
...(tool.presentCall ? { presentCall: tool.presentCall } : {}),
|
|
479
|
+
execute: async (args, exec) => {
|
|
480
|
+
const r = await tool.execute(args, exec);
|
|
481
|
+
return { text: scrub(typeof r?.text === 'string' ? r.text : String(r?.text ?? '')) };
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function apply(ctx) {
|
|
487
|
+
const svc = buildService(ctx);
|
|
488
|
+
ctx.provide('research.expLedger', svc);
|
|
489
|
+
|
|
490
|
+
// ---------- project_init ----------
|
|
491
|
+
registerTextTool(ctx, {
|
|
492
|
+
name: 'project_init',
|
|
493
|
+
description: '把一个目录接入研究工程:发现现状 → 建版本库与受管白名单 → 分类登记遗留文件(只登记不删)→ 写 project.yaml 骨架 → 初始化台账 → 首次体检。已存在 project.yaml 时默认拒绝;mode=migrate 用于把旧式单文件工程就地升级(原文先归档,绝不丢内容)。',
|
|
494
|
+
properties: {
|
|
495
|
+
root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
|
|
496
|
+
id: { type: 'string', description: '工程 id(kebab-case);缺省取目录名。' },
|
|
497
|
+
name: { type: 'string', description: '工程显示名。' },
|
|
498
|
+
domain: { type: 'string', description: '领域关键词(逗号分隔)。' },
|
|
499
|
+
description: { type: 'string', description: '工程说明。' },
|
|
500
|
+
mode: { type: 'string', enum: ['init', 'migrate'], description: 'init(默认)接新工程;migrate 升级既有旧式工程。' },
|
|
501
|
+
confirm_no_version_store: { type: 'boolean', description: '版本库不可用时,显式确认以「仅台账」模式接入。' },
|
|
502
|
+
},
|
|
503
|
+
execute: async (args, exec) => {
|
|
504
|
+
const root = svc.root(args, exec);
|
|
505
|
+
await ensureDir(root);
|
|
506
|
+
if (args?.mode === 'migrate') return migrateProject(ctx, svc, root);
|
|
507
|
+
if (await pathExists(join(root, PROJECT_YAML))) {
|
|
508
|
+
return { text: `拒绝:${root} 下已有 ${PROJECT_YAML},不覆盖。\n修法:project_load 查看现状;若这是旧式单文件工程要升级,用 mode=migrate。` };
|
|
509
|
+
}
|
|
510
|
+
const dirName = basename(root) || 'project';
|
|
511
|
+
const id = slugify(args?.id ?? dirName);
|
|
512
|
+
if (!id) return { text: '拒绝:工程 id 非法。\n修法:用 kebab-case(小写字母、数字、连字符)。' };
|
|
513
|
+
const eg = ctx.get('research.engineGit');
|
|
514
|
+
const cap = eg.capabilities();
|
|
515
|
+
if (!cap.available && args?.confirm_no_version_store !== true) {
|
|
516
|
+
return { text: `拒绝:本机没有可用的版本库工具,接入后只能保留台账、无法还原。\n修法:安装后重试;或由用户确认后加 confirm_no_version_store=true(届时 verify 会标注降级状态)。` };
|
|
517
|
+
}
|
|
518
|
+
// 1) 发现现状
|
|
519
|
+
const found = [];
|
|
520
|
+
for (const probe of [PROJECT_YAML, PIPELINE_YAML, TEMPLATES_YAML, REGISTRY, 'scripts', '.kb', 'experiments', '_report', '.research', '.dsh']) {
|
|
521
|
+
if (await pathExists(join(root, probe))) found.push(probe);
|
|
522
|
+
}
|
|
523
|
+
// 2) 遗留文件分类登记
|
|
524
|
+
const legacyCounts = new Map();
|
|
525
|
+
let scriptCount = 0; let dataDirs = 0; let sessionZips = 0;
|
|
526
|
+
const walk = async (dir, depth) => {
|
|
527
|
+
if (depth > 6) return;
|
|
528
|
+
let ents; try { ents = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
529
|
+
for (const e of ents) {
|
|
530
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
531
|
+
const full = join(dir, e.name);
|
|
532
|
+
if (e.isDirectory()) { await walk(full, depth + 1); continue; }
|
|
533
|
+
const ext = /(\.[^.\\/]+)$/.exec(e.name)?.[1]?.toLowerCase() ?? '';
|
|
534
|
+
if (LEGACY_EXTS.has(ext)) legacyCounts.set(ext, (legacyCounts.get(ext) || 0) + 1);
|
|
535
|
+
if (ext === '.py') scriptCount += 1;
|
|
536
|
+
if (e.name.startsWith('dsh-session-') && ext === '.zip') sessionZips += 1;
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
await walk(root, 0);
|
|
540
|
+
const protectedAreas = [];
|
|
541
|
+
for (const d of ['data', 'history', 'x', 'x1', 'x2']) {
|
|
542
|
+
if (await pathExists(join(root, d))) { protectedAreas.push(d); dataDirs += 1; }
|
|
543
|
+
}
|
|
544
|
+
// 3) 基础设施
|
|
545
|
+
const store = eg.init(root);
|
|
546
|
+
const managed = await svc.managedList(root);
|
|
547
|
+
await ensureDir(join(root, '.research'));
|
|
548
|
+
await atomicWrite(join(root, '.research', 'managed.json'), JSON.stringify({
|
|
549
|
+
version: SCHEMA_VERSION,
|
|
550
|
+
mode: store.ok ? 'versioned' : 'ledger-only',
|
|
551
|
+
rawArchive: store.lfs ? 'enabled' : 'hash-only',
|
|
552
|
+
managed,
|
|
553
|
+
note: '受管白名单的工程内副本;契约定义在 preset 的 contract/managed.json。',
|
|
554
|
+
}, null, 2) + '\n');
|
|
555
|
+
// 4) project.yaml
|
|
556
|
+
const doc = {
|
|
557
|
+
schemaVersion: SCHEMA_VERSION,
|
|
558
|
+
project: {
|
|
559
|
+
id,
|
|
560
|
+
name: (typeof args?.name === 'string' && args.name.trim()) ? args.name.trim() : dirName,
|
|
561
|
+
...(typeof args?.domain === 'string' && args.domain.trim() ? { domain: args.domain.trim() } : {}),
|
|
562
|
+
...(typeof args?.description === 'string' && args.description.trim() ? { description: args.description.trim() } : {}),
|
|
563
|
+
},
|
|
564
|
+
managed,
|
|
565
|
+
conventions: [],
|
|
566
|
+
vocabulary: { entityKinds: [], noteKinds: ['proposal', 'claim', 'lesson', 'question'], observableRoles: ['log', 'stdout', 'result', 'metrics', 'figure'] },
|
|
567
|
+
...(legacyCounts.size ? {
|
|
568
|
+
legacy: {
|
|
569
|
+
note: '遗留区只登记 size/mtime,不改、不删、不入库。',
|
|
570
|
+
keep: [...legacyCounts.entries()].sort().map(([ext, count]) => ({ pattern: `**/*${ext}`, count })),
|
|
571
|
+
protectedAreas,
|
|
572
|
+
},
|
|
573
|
+
} : {}),
|
|
574
|
+
};
|
|
575
|
+
await svc.writeProject(root, doc);
|
|
576
|
+
if (!(await pathExists(join(root, REGISTRY)))) await atomicWrite(join(root, REGISTRY), '');
|
|
577
|
+
// 5) 首次入库 + 台账
|
|
578
|
+
const first = [PROJECT_YAML, REGISTRY, '.research/managed.json'];
|
|
579
|
+
const rec = eg.record(root, first, 'project_init: 接入研究工程');
|
|
580
|
+
await svc.ledgerAppend(root, { record: 'project', action: 'init', id, root }, { commit: rec.commit ?? undefined });
|
|
581
|
+
const lines = [
|
|
582
|
+
`工程已接入:${root}`,
|
|
583
|
+
`工程 id:${id} 显示名:${doc.project.name}`,
|
|
584
|
+
`版本能力:${store.ok ? (store.lfs ? '可用(原始产出可还原)' : '可用(原始产出仅记指纹)') : '不可用(仅台账)'}`,
|
|
585
|
+
`遗留登记:${[...legacyCounts.entries()].map(([e, c]) => `${e}×${c}`).join('、') || '(无)'}${sessionZips ? `;对话副本 ${sessionZips} 份(不参与证据)` : ''}`,
|
|
586
|
+
`发现:${found.join('、') || '(空目录)'};未声明脚本 ${scriptCount} 个`,
|
|
587
|
+
'下一步:stage_declare 声明阶段机 → script_declare 收编/新建脚本 → template_declare 声明模板(含 observables)。',
|
|
588
|
+
];
|
|
589
|
+
return { text: lines.join('\n') };
|
|
590
|
+
},
|
|
591
|
+
presentCall: (args) => genericCall(`接入工程 ${args?.id ?? args?.root ?? ''}`.trim(), 'edit', [join(args?.root ?? '', PROJECT_YAML)]),
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// ---------- project_load ----------
|
|
595
|
+
registerTextTool(ctx, {
|
|
596
|
+
name: 'project_load',
|
|
597
|
+
description: '只读载入工程全貌:身份、阶段机摘要、模板(含可提取字段)、生效约定(含裁决引文)、受管区、遗留摘要、最近一次体检状态。',
|
|
598
|
+
properties: { root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' } },
|
|
599
|
+
execute: async (args, exec) => {
|
|
600
|
+
const root = svc.root(args, exec);
|
|
601
|
+
let doc;
|
|
602
|
+
try { doc = await svc.readProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
|
|
603
|
+
const pipeline = await svc.readPipeline(root);
|
|
604
|
+
const templates = await svc.readTemplates(root);
|
|
605
|
+
const fold = await svc.ledgerFold(root);
|
|
606
|
+
const state = await svc.state(root);
|
|
607
|
+
const schemaErrors = await validateContract('project.schema.json', doc);
|
|
608
|
+
const graph = pipeline ? checkGraph(pipeline) : { fatal: ['尚未声明阶段机(pipeline.yaml 缺失)'], warn: [] };
|
|
609
|
+
const tpls = Object.entries(templates?.templates ?? {});
|
|
610
|
+
const convs = Array.isArray(doc.conventions) ? doc.conventions : [];
|
|
611
|
+
const active = convs.filter((c) => (c.status ?? 'active') === 'active');
|
|
612
|
+
const lastVerify = fold.verifies[fold.verifies.length - 1] ?? null;
|
|
613
|
+
const lines = [
|
|
614
|
+
`工程:${doc.project.id} —— ${doc.project.name}`,
|
|
615
|
+
doc.project.domain ? `领域:${doc.project.domain}` : '',
|
|
616
|
+
`阶段机:${pipeline ? `${(pipeline.stages ?? []).length} 个阶段 / ${(pipeline.edges ?? []).length} 条边 / 入口 ${pipeline.entry ?? '(未设)'} / 图版本 ${pipeline.graphVersion}` : '未声明'}`,
|
|
617
|
+
`当前阶段:${state.stage ?? '(未进入任何阶段)'}`,
|
|
618
|
+
`模板:${tpls.length ? tpls.map(([id, t]) => `${id}@${t.version}${(t.observables ?? []).length ? `(可提取 ${t.observables.map((o) => o.field).join('/')})` : ''}`).join(';') : '(无)'}`,
|
|
619
|
+
`约定:${active.length ? active.map((c) => `${c.id}「${c.statement}」〔裁决 ${c.decision?.askCallId ?? '缺失'}〕`).join(';') : '(无生效约定)'}`,
|
|
620
|
+
`受管区:${(doc.managed ?? []).length} 条规则;遗留登记:${(doc.legacy?.keep ?? []).map((k) => `${k.pattern}×${k.count}`).join('、') || '(无)'}`,
|
|
621
|
+
`运行台账:共 ${state.runs.length} 条(done ${state.counts.done} / failed ${state.counts.failed} / running ${state.counts.running} / draft ${state.counts.draft})`,
|
|
622
|
+
`最近体检:${lastVerify ? `${lastVerify.status}(${lastVerify.ts})` : '尚未体检'}`,
|
|
623
|
+
`结构校验:${schemaErrors.length === 0 ? '通过' : `${schemaErrors.length} 项待修`}`,
|
|
624
|
+
graph.fatal.length ? `阶段机 fatal:${graph.fatal.join(';')}` : '',
|
|
625
|
+
].filter(Boolean);
|
|
626
|
+
for (const e of schemaErrors.slice(0, 10)) lines.push(` [结构] ${e}`);
|
|
627
|
+
return { text: lines.join('\n') };
|
|
628
|
+
},
|
|
629
|
+
presentCall: (args) => genericCall('载入工程', 'read', [join(args?.root ?? '', PROJECT_YAML)]),
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// ---------- project_verify ----------
|
|
633
|
+
registerTextTool(ctx, {
|
|
634
|
+
name: 'project_verify',
|
|
635
|
+
description: '体检(只读 + 记一条体检结果):受管区越权改动、台账与版本互证、结构契约、阶段机 fatal、每个 run 的证据完整性、用户权威回查。永远返回报告,不修任何东西。',
|
|
636
|
+
properties: {
|
|
637
|
+
root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
|
|
638
|
+
scope: { type: 'string', description: 'all(默认)/ environment / evidence / authority', enum: ['all', 'environment', 'evidence', 'authority'] },
|
|
639
|
+
},
|
|
640
|
+
execute: async (args, exec) => {
|
|
641
|
+
const root = svc.root(args, exec);
|
|
642
|
+
try { await svc.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
|
|
643
|
+
const scope = ['all', 'environment', 'evidence', 'authority'].includes(args?.scope) ? args.scope : 'all';
|
|
644
|
+
const eg = ctx.get('research.engineGit');
|
|
645
|
+
const cap = eg.capabilities();
|
|
646
|
+
const managed = await svc.managedList(root);
|
|
647
|
+
const dirty = []; const unknown = []; const problems = []; const attested = [];
|
|
648
|
+
if (cap.available && eg.exists(root)) {
|
|
649
|
+
for (const d of eg.changed(root, managed)) dirty.push(`${d.path}(${d.code === 'D' ? '被删除' : '被外部修改'})`);
|
|
650
|
+
for (const u of eg.untracked(root, managed)) unknown.push(u);
|
|
651
|
+
}
|
|
652
|
+
// 结构 + 阶段机
|
|
653
|
+
let doc = null; let pipeline = null; let templates = null;
|
|
654
|
+
try { doc = await svc.readProject(root); } catch (e) { problems.push(e.message); }
|
|
655
|
+
try { pipeline = await svc.readPipeline(root); } catch (e) { problems.push(e.message); }
|
|
656
|
+
try { templates = await svc.readTemplates(root); } catch (e) { problems.push(e.message); }
|
|
657
|
+
if (doc) for (const e of await validateContract('project.schema.json', doc)) problems.push(`project.yaml ${e}`);
|
|
658
|
+
if (pipeline) {
|
|
659
|
+
for (const e of await validateContract('pipeline.schema.json', pipeline)) problems.push(`pipeline.yaml ${e}`);
|
|
660
|
+
for (const f of checkGraph(pipeline).fatal) problems.push(`阶段机:${f}`);
|
|
661
|
+
}
|
|
662
|
+
if (templates) for (const e of await validateContract('templates.schema.json', templates)) problems.push(`templates.yaml ${e}`);
|
|
663
|
+
// 台账链 + 互证
|
|
664
|
+
const rows = await svc.ledgerRead(root);
|
|
665
|
+
let chainBroken = 0; let missingRef = 0;
|
|
666
|
+
let prevHash = 'genesis';
|
|
667
|
+
for (const r of rows) {
|
|
668
|
+
if (!r.obj) { chainBroken += 1; continue; }
|
|
669
|
+
if (r.obj.prev !== undefined && r.obj.prev !== prevHash) chainBroken += 1;
|
|
670
|
+
prevHash = sha256(r.raw);
|
|
671
|
+
if (r.obj.commit && cap.available && eg.exists(root) && !eg.hasObject(root, r.obj.commit)) missingRef += 1;
|
|
672
|
+
}
|
|
673
|
+
if (chainBroken) problems.push(`台账有 ${chainBroken} 行与前一行的指纹不连续(可能被外部改写)`);
|
|
674
|
+
if (missingRef) problems.push(`台账有 ${missingRef} 条事件引用的历史节点已不存在(历史可能被改写)`);
|
|
675
|
+
// 证据完整性
|
|
676
|
+
const fold = await svc.ledgerFold(root);
|
|
677
|
+
const ignored = await svc.ignoredSet(root);
|
|
678
|
+
// 派生状态不得与台账矛盾(派生视图永远不是事实来源)
|
|
679
|
+
const expectedStage = fold.currentStage ?? pipeline?.entry ?? null;
|
|
680
|
+
const statePath = join(root, STATE_REL);
|
|
681
|
+
if (await pathExists(statePath)) {
|
|
682
|
+
let st = null;
|
|
683
|
+
try { st = JSON.parse(await readFile(statePath, 'utf8')); } catch { st = null; }
|
|
684
|
+
const shapeOk = st !== null && st.derived === true && typeof st.generatedAt === 'string'
|
|
685
|
+
&& st.source === 'registry.jsonl' && Number.isInteger(st.graphVersion) && Array.isArray(st.history);
|
|
686
|
+
if (st === null) problems.push(`派生状态文件解析失败:${STATE_REL}(修法:stage_goto replay=true 重放物化)`);
|
|
687
|
+
else if (!shapeOk) problems.push(`派生状态文件形状不符合 v3:${STATE_REL}(修法:stage_goto replay=true 重放物化)`);
|
|
688
|
+
else if ((st.stage ?? null) !== expectedStage) {
|
|
689
|
+
problems.push(`派生状态与台账矛盾:${STATE_REL} 写的是 stage=${st.stage ?? '(空)'},台账重放得到 ${expectedStage ?? '(未进入阶段)'}(修法:stage_goto replay=true 重放物化)`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (scope === 'all' || scope === 'evidence') {
|
|
693
|
+
for (const run of fold.runs.values()) {
|
|
694
|
+
const dir = join(root, 'experiments', run.runId);
|
|
695
|
+
const man = await pathExists(join(dir, 'manifest.json'));
|
|
696
|
+
const rawDir = join(dir, 'raw');
|
|
697
|
+
if (!man) { problems.push(`run ${run.runId}:缺少证据清单(manifest.json)`); continue; }
|
|
698
|
+
let m = null;
|
|
699
|
+
try { m = JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8')); } catch { problems.push(`run ${run.runId}:证据清单解析失败`); continue; }
|
|
700
|
+
for (const e of await validateContract('manifest.schema.json', m)) {
|
|
701
|
+
problems.push(`run ${run.runId} manifest ${e}(修法:project_reconcile mode=restore paths=["experiments/${run.runId}/manifest.json"])`);
|
|
702
|
+
}
|
|
703
|
+
const registered = new Set((m.entries ?? []).map((x) => posix(x.path)));
|
|
704
|
+
if (await pathExists(rawDir)) {
|
|
705
|
+
const files = eg.listFiles(root, `experiments/${run.runId}/raw`);
|
|
706
|
+
for (const f of files) {
|
|
707
|
+
const rel = posix(f);
|
|
708
|
+
if (registered.has(rel) || ignored.has(rel)) continue;
|
|
709
|
+
unknown.push(`${rel}(产出未登记)`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
for (const entry of m.entries ?? []) {
|
|
713
|
+
const abs = join(root, entry.path);
|
|
714
|
+
const got = eg.sha256File(abs);
|
|
715
|
+
if (got === null) problems.push(`run ${run.runId}:登记文件缺失 ${entry.path}`);
|
|
716
|
+
else if (got !== entry.sha256) problems.push(`run ${run.runId}:登记文件已被改动 ${entry.path}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// 用户权威回查
|
|
721
|
+
if (scope === 'all' || scope === 'authority') {
|
|
722
|
+
const convs = Array.isArray(doc?.conventions) ? doc.conventions : [];
|
|
723
|
+
for (const c of convs) {
|
|
724
|
+
if (!c?.decision?.askCallId) problems.push(`约定 ${c?.id ?? '?'}:缺少用户裁决引用`);
|
|
725
|
+
else if (c.decision.attested === true) attested.push(`约定 ${c.id}`);
|
|
726
|
+
}
|
|
727
|
+
for (const d of fold.decisions) if (d.attested === true) attested.push(`裁决 ${d.kind ?? ''} ${d.id ?? ''}`.trim());
|
|
728
|
+
}
|
|
729
|
+
const status = (dirty.length || unknown.length || problems.length) ? 'dirty' : 'clean';
|
|
730
|
+
const todo = [];
|
|
731
|
+
if (!pipeline) todo.push('尚未声明阶段机:stage_declare action=stage → action=edge → entry');
|
|
732
|
+
else if ((pipeline.stages ?? []).length === 0) todo.push('阶段机是空的:stage_declare action=stage');
|
|
733
|
+
if (!templates || Object.keys(templates.templates ?? {}).length === 0) todo.push('尚未声明任何模板:template_declare');
|
|
734
|
+
if (!(Array.isArray(doc?.conventions) ? doc.conventions.length : 0)) todo.push('尚无生效约定(需要时:ask_user_question → convention_declare)');
|
|
735
|
+
await svc.ledgerAppend(root, {
|
|
736
|
+
record: 'verify', status,
|
|
737
|
+
dirty: dirty.map((s) => s.split('(')[0]),
|
|
738
|
+
unknown: unknown.map((s) => s.split('(')[0]),
|
|
739
|
+
attested,
|
|
740
|
+
scope,
|
|
741
|
+
});
|
|
742
|
+
const head = [`体检结果:${status === 'clean' ? '通过(clean)' : status === 'unknown' ? '存在未登记文件(unknown)' : '存在待修问题(dirty)'}`];
|
|
743
|
+
if (!cap.available) head.push('版本能力:不可用 —— 本次体检只能核对台账与结构,无法比对文件版本');
|
|
744
|
+
const body = [];
|
|
745
|
+
if (dirty.length) body.push('受管文件被工程外部改动:', ...dirty.map((d) => ` - ${d}`), ' 修法:project_reconcile mode=restore(取回已登记版本);确需保留外部改动则 mode=adopt + 用户裁决。');
|
|
746
|
+
if (unknown.length) body.push('未登记文件:', ...unknown.map((d) => ` - ${d}`), ' 修法:属于证据的用 run_launch 补登记;其余用 project_reconcile mode=ignore 并说明原因。');
|
|
747
|
+
if (problems.length) body.push('结构 / 证据 / 权威问题:', ...problems.map((d) => ` - ${d}`));
|
|
748
|
+
if (todo.length) body.push('待声明(不算 dirty,但工程还不完整):', ...todo.map((d) => ` - ${d}`));
|
|
749
|
+
if (attested.length) body.push(`降级记录(原文在案、无法回源):${attested.join(';')}`);
|
|
750
|
+
if (!body.length) body.push('没有发现问题。');
|
|
751
|
+
return { text: [...head, '', ...body].join('\n') };
|
|
752
|
+
},
|
|
753
|
+
presentCall: (args) => genericCall('工程体检', 'read', [join(args?.root ?? '', REGISTRY)]),
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// ---------- project_reconcile ----------
|
|
757
|
+
registerTextTool(ctx, {
|
|
758
|
+
name: 'project_reconcile',
|
|
759
|
+
description: '修复受管区与已登记版本不一致的九种情景:restore(默认,取回已登记版本)/ adopt(接受外部改动,需用户裁决,语义文件除外)/ ignore(登记为有意忽略)。',
|
|
760
|
+
properties: {
|
|
761
|
+
root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
|
|
762
|
+
paths: { type: 'array', items: { type: 'string' }, description: '要处理的工程内相对路径。' },
|
|
763
|
+
mode: { type: 'string', enum: ['restore', 'adopt', 'ignore'], description: '缺省 restore。' },
|
|
764
|
+
reason: { type: 'string', description: '本次处理的原因(必填,会记入台账)。' },
|
|
765
|
+
decision: {
|
|
766
|
+
type: 'object',
|
|
767
|
+
description: 'adopt 必填:用户裁决引用。',
|
|
768
|
+
properties: {
|
|
769
|
+
askCallId: { type: 'string' },
|
|
770
|
+
answer: { type: 'string' },
|
|
771
|
+
attested: { type: 'boolean' },
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
required: ['paths', 'reason'],
|
|
776
|
+
execute: async (args, exec) => {
|
|
777
|
+
const root = svc.root(args, exec);
|
|
778
|
+
try { await svc.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
|
|
779
|
+
const mode = ['restore', 'adopt', 'ignore'].includes(args?.mode) ? args.mode : 'restore';
|
|
780
|
+
const reason = typeof args?.reason === 'string' ? args.reason.trim() : '';
|
|
781
|
+
if (!reason) return { text: '拒绝:缺少 reason。\n修法:写清为什么这些路径会与登记版本不一致。' };
|
|
782
|
+
const paths = (Array.isArray(args?.paths) ? args.paths : []).map((p) => posix(String(p).trim())).filter(Boolean);
|
|
783
|
+
if (!paths.length) return { text: '拒绝:paths 为空。\n修法:先用 project_verify 看有哪些路径需要处理。' };
|
|
784
|
+
for (const p of paths) {
|
|
785
|
+
if (isAbsolute(p) || p.split('/').includes('..')) {
|
|
786
|
+
return { text: `拒绝:${p} 越出工程范围。\n修法:只处理工程内相对路径。` };
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
const eg = ctx.get('research.engineGit');
|
|
790
|
+
const cap = eg.capabilities();
|
|
791
|
+
const managed = await svc.managedList(root);
|
|
792
|
+
const dirty = cap.available && eg.exists(root) ? eg.changed(root, paths) : [];
|
|
793
|
+
const untracked = cap.available && eg.exists(root) ? eg.untracked(root, paths) : paths;
|
|
794
|
+
if (!dirty.length && !untracked.length) {
|
|
795
|
+
return { text: `无需处理:这些路径当前与已登记版本一致。\n(若怀疑体检结论,请重跑 project_verify)` };
|
|
796
|
+
}
|
|
797
|
+
const semantic = new Set([PROJECT_YAML, PIPELINE_YAML, TEMPLATES_YAML]);
|
|
798
|
+
if (mode === 'adopt') {
|
|
799
|
+
const v = svc.verifyDecision(exec, args?.decision ?? {});
|
|
800
|
+
if (!v.ok) return { text: `${v.text}\n(adopt 会把这些改动接受为新版本,必须由用户裁决。)` };
|
|
801
|
+
const blocked = paths.filter((p) => semantic.has(p) || p.startsWith('scripts/') || p === '.research/managed.json');
|
|
802
|
+
if (blocked.length) {
|
|
803
|
+
return { text: `拒绝:这些是语义文件,不能在 reconcile 里改语义:${blocked.join('、')}。\n修法:用对应的声明工具重新声明(project_init/convention_declare/stage_declare/template_declare/script_declare)。` };
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
const done = []; const left = [];
|
|
807
|
+
if (mode === 'restore') {
|
|
808
|
+
const tracked = new Set(eg.tracked(root, paths));
|
|
809
|
+
const restorable = paths.filter((p) => tracked.has(p));
|
|
810
|
+
const notRestorable = paths.filter((p) => !tracked.has(p));
|
|
811
|
+
if (restorable.length) {
|
|
812
|
+
const r = eg.restore(root, restorable);
|
|
813
|
+
if (r.ok) done.push(...restorable.map((p) => `${p}(已还原)`));
|
|
814
|
+
else left.push(...restorable.map((p) => `${p}(还原失败)`));
|
|
815
|
+
}
|
|
816
|
+
for (const p of notRestorable) left.push(`${p}(尚无登记版本:需 adopt 或 ignore)`);
|
|
817
|
+
} else if (mode === 'adopt') {
|
|
818
|
+
done.push(...paths.map((p) => `${p}(已接受为新版本)`));
|
|
819
|
+
} else {
|
|
820
|
+
done.push(...paths.map((p) => `${p}(已登记为有意忽略)`));
|
|
821
|
+
await svc.addIgnored(root, paths, reason);
|
|
822
|
+
}
|
|
823
|
+
const commit = mode === 'restore'
|
|
824
|
+
? eg.head(root)
|
|
825
|
+
: (eg.record(root, [...paths, '.research/ignored.json'], `project_reconcile: ${reason}`).commit ?? null);
|
|
826
|
+
await svc.ledgerAppend(root, {
|
|
827
|
+
record: 'reconcile', mode, paths, reason,
|
|
828
|
+
...(mode === 'adopt' ? { decision: args.decision } : {}),
|
|
829
|
+
}, { commit: commit ?? undefined, actor: mode === 'adopt' ? 'user' : 'engine' });
|
|
830
|
+
const lines = [
|
|
831
|
+
`处理方式:${mode === 'restore' ? '取回已登记版本' : mode === 'adopt' ? '接受外部改动为新版本' : '登记为有意忽略'}`,
|
|
832
|
+
`原因:${reason}`,
|
|
833
|
+
...(done.length ? ['已处理:', ...done.map((d) => ` - ${d}`)] : []),
|
|
834
|
+
...(left.length ? ['仍需处理:', ...left.map((d) => ` - ${d}`)] : []),
|
|
835
|
+
'下一步:project_verify 复检。',
|
|
836
|
+
];
|
|
837
|
+
return { text: lines.join('\n') };
|
|
838
|
+
},
|
|
839
|
+
presentCall: (args) => genericCall(`修复受管区(${args?.mode ?? 'restore'})`, 'edit', (args?.paths ?? []).map(String)),
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
// ---------- entity_declare ----------
|
|
843
|
+
registerTextTool(ctx, {
|
|
844
|
+
name: 'entity_declare',
|
|
845
|
+
description: '声明一个研究对象(数据集/方法/模型/产物/遗留区等)到台账,必须带可解析的来源。',
|
|
846
|
+
properties: {
|
|
847
|
+
root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
|
|
848
|
+
kind: { type: 'string', description: '类别:dataset/method/model/output/legacy/…' },
|
|
849
|
+
id: { type: 'string', description: '实体 id;缺省由 name 生成。' },
|
|
850
|
+
name: { type: 'string', description: '显示名。' },
|
|
851
|
+
props: { type: 'object', description: '开放属性。' },
|
|
852
|
+
source: { type: 'string', description: '来源(必填):文件:行 / runId / 实测时间+命令。' },
|
|
853
|
+
note: { type: 'string', description: '备注。' },
|
|
854
|
+
},
|
|
855
|
+
required: ['kind', 'source'],
|
|
856
|
+
execute: async (args, exec) => {
|
|
857
|
+
const root = svc.root(args, exec);
|
|
858
|
+
try { await svc.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
|
|
859
|
+
const kind = typeof args?.kind === 'string' ? args.kind.trim() : '';
|
|
860
|
+
if (!kind) return { text: '拒绝:缺少 kind。\n修法:给出类别(dataset/method/model/output/legacy…)。' };
|
|
861
|
+
const source = typeof args?.source === 'string' ? args.source.trim() : '';
|
|
862
|
+
if (!source) return { text: '拒绝:缺少 source。\n修法:写明依据来源(文件:行 / runId / 实测命令),没有来源的声明不算登记。' };
|
|
863
|
+
const chk = await checkSource(root, source);
|
|
864
|
+
if (!chk.ok) return { text: `拒绝:来源无法解析 —— ${chk.why}\n修法:给出工程内真实存在的文件与行号,或一个已登记的 runId。` };
|
|
865
|
+
const id = slugify(args?.id ?? args?.name ?? '');
|
|
866
|
+
if (!id) return { text: '拒绝:实体 id 非法。\n修法:用 kebab-case,或给出可生成 id 的 name。' };
|
|
867
|
+
const guard = svc.guard(root, [REGISTRY]);
|
|
868
|
+
if (!guard.ok) return { text: guard.text };
|
|
869
|
+
const rec = await svc.ledgerAppend(root, {
|
|
870
|
+
record: 'entity', id, kind,
|
|
871
|
+
...(typeof args?.name === 'string' && args.name.trim() ? { name: args.name.trim() } : {}),
|
|
872
|
+
...(args?.props && typeof args.props === 'object' && !Array.isArray(args.props) ? { props: args.props } : {}),
|
|
873
|
+
source,
|
|
874
|
+
...(typeof args?.note === 'string' && args.note.trim() ? { note: args.note.trim() } : {}),
|
|
875
|
+
});
|
|
876
|
+
const fold = await svc.ledgerFold(root);
|
|
877
|
+
const same = [...fold.entities.values()].filter((e) => e.kind === kind).length;
|
|
878
|
+
return { text: `已登记实体:${id}(kind=${kind})\n来源:${source}\n同类实体现有 ${same} 个;台账第 ${rec.seq} 行。` };
|
|
879
|
+
},
|
|
880
|
+
presentCall: (args) => genericCall(`声明实体 ${args?.id ?? args?.name ?? ''}`.trim(), 'edit', [REGISTRY]),
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
// ---------- entity_query ----------
|
|
884
|
+
registerTextTool(ctx, {
|
|
885
|
+
name: 'entity_query',
|
|
886
|
+
description: '只读查询台账:record=entity(实体)/ record=decision(已登记的用户裁决,含问答原文)/ record=ask(本会话里发生过的提问与答复,用于取得提问标识)。',
|
|
887
|
+
properties: {
|
|
888
|
+
root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
|
|
889
|
+
record: { type: 'string', enum: ['entity', 'decision', 'ask'], description: '缺省 entity。' },
|
|
890
|
+
id: { type: 'string', description: '按 id 过滤。' },
|
|
891
|
+
kind: { type: 'string', description: '按类别过滤。' },
|
|
892
|
+
limit: { type: 'number', description: '返回条数上限(默认 50)。' },
|
|
893
|
+
},
|
|
894
|
+
execute: async (args, exec) => {
|
|
895
|
+
const root = svc.root(args, exec);
|
|
896
|
+
try { await svc.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
|
|
897
|
+
const record = ['decision', 'ask'].includes(args?.record) ? args.record : 'entity';
|
|
898
|
+
const limit = Number.isFinite(Number(args?.limit)) && Number(args.limit) > 0 ? Math.floor(Number(args.limit)) : 50;
|
|
899
|
+
if (record === 'ask') {
|
|
900
|
+
const events = Array.isArray(exec?.agent?.session?.events) ? exec.agent.session.events : null;
|
|
901
|
+
if (!events) return { text: '本会话读不到对话记录,无法列出提问。\n修法:确认会话处于活动状态。' };
|
|
902
|
+
const calls = new Map();
|
|
903
|
+
for (const ev of events) {
|
|
904
|
+
if (ev?.type === 'tool/call' && ev.data?.name === 'ask_user_question') calls.set(ev.data.callId, ev);
|
|
905
|
+
}
|
|
906
|
+
if (!calls.size) return { text: '本会话还没有向用户提过问题。\n修法:需要用户拍板时先用 ask_user_question。' };
|
|
907
|
+
const lines = [];
|
|
908
|
+
for (const [callId, call] of [...calls.entries()].slice(-limit)) {
|
|
909
|
+
let qs = [];
|
|
910
|
+
try { qs = JSON.parse(String(call.data?.arguments ?? '{}')).questions ?? []; } catch { qs = []; }
|
|
911
|
+
let answers = null;
|
|
912
|
+
for (const ev of events) {
|
|
913
|
+
const blk = Array.isArray(ev?.data?.message?.content) ? ev.data.message.content[0] : null;
|
|
914
|
+
if (ev?.type === 'tool/result' && blk?.type === 'tool-result' && blk.toolCallId === callId) {
|
|
915
|
+
const text = (blk.content ?? []).filter((b) => b?.type === 'text').map((b) => b.text).join('\n');
|
|
916
|
+
try { const v = JSON.parse(text); answers = Array.isArray(v?.answers) ? v.answers : null; } catch { answers = null; }
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
lines.push(`- 提问标识:${callId}`);
|
|
920
|
+
lines.push(` 问题:${qs.map((q) => `[${q?.id ?? ''}] ${q?.question ?? ''}`).join(' | ')}`);
|
|
921
|
+
lines.push(` 用户答复:${answers ? answers.map((a) => `${a.id}: ${(a.selected ?? []).join(' / ')}${a.custom ? ` | 补充:${a.custom}` : ''}`).join('; ') : '(尚未答复)'}`);
|
|
922
|
+
}
|
|
923
|
+
return { text: `本会话提问 ${calls.size} 次(最近 ${Math.min(calls.size, limit)} 次):\n${lines.join('\n')}\n\n用法:把「提问标识」填进 convention_declare / note_adjudicate / run_close 的 decision.askCallId。` };
|
|
924
|
+
}
|
|
925
|
+
const fold = await svc.ledgerFold(root);
|
|
926
|
+
if (record === 'decision') {
|
|
927
|
+
let list = fold.decisions;
|
|
928
|
+
if (typeof args?.id === 'string') list = list.filter((d) => d.id === args.id);
|
|
929
|
+
if (typeof args?.kind === 'string') list = list.filter((d) => d.kind === args.kind);
|
|
930
|
+
list = list.slice(-limit);
|
|
931
|
+
if (!list.length) return { text: '台账里没有匹配的用户裁决记录。' };
|
|
932
|
+
return {
|
|
933
|
+
text: `用户裁决 ${list.length} 条:\n` + list.map((d) => `- [${d.kind ?? '?'}] ${d.id ?? ''} 提问:${d.question ?? '(未记录)'}\n 答复原文:${d.answer}${d.attested ? ' (attested:原文在案、无法回源)' : ''} @${d.ts}`).join('\n'),
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
let list = [...fold.entities.values()];
|
|
937
|
+
if (typeof args?.id === 'string') list = list.filter((e) => e.id === args.id);
|
|
938
|
+
if (typeof args?.kind === 'string') list = list.filter((e) => e.kind === args.kind);
|
|
939
|
+
list = list.slice(-limit);
|
|
940
|
+
if (!list.length) return { text: '台账里没有匹配的实体记录。' };
|
|
941
|
+
return {
|
|
942
|
+
text: `实体 ${list.length} 条:\n` + list.map((e) => `- ${e.id} kind=${e.kind}${e.name ? ` ${e.name}` : ''} 来源:${e.source ?? '(缺)'}`).join('\n'),
|
|
943
|
+
};
|
|
944
|
+
},
|
|
945
|
+
presentCall: (args) => genericCall(`查询台账 ${args?.record ?? 'entity'}`, 'read', [REGISTRY]),
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// ---------- convention_declare ----------
|
|
949
|
+
registerTextTool(ctx, {
|
|
950
|
+
name: 'convention_declare',
|
|
951
|
+
description: '把一次用户裁决写成工程约定(用户权威的唯一写入口):必须带 askCallId 与用户答复原文,写入时回查会话强校验。',
|
|
952
|
+
properties: {
|
|
953
|
+
root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
|
|
954
|
+
statement: { type: 'string', description: '约定条文(与用户答复不矛盾)。' },
|
|
955
|
+
scope: { type: 'string', description: '生效范围;缺省 = 全工程。' },
|
|
956
|
+
source: { type: 'string', description: '依据来源(文件:行 / runId / 实测命令)。' },
|
|
957
|
+
decision: {
|
|
958
|
+
type: 'object',
|
|
959
|
+
properties: {
|
|
960
|
+
askCallId: { type: 'string', description: 'ask_user_question 的调用标识。' },
|
|
961
|
+
answer: { type: 'string', description: '用户答复原文。' },
|
|
962
|
+
attested: { type: 'boolean', description: '会话记录不可读时,由用户显式确认后置 true。' },
|
|
963
|
+
},
|
|
964
|
+
},
|
|
965
|
+
},
|
|
966
|
+
required: ['statement', 'source', 'decision'],
|
|
967
|
+
execute: async (args, exec) => {
|
|
968
|
+
const root = svc.root(args, exec);
|
|
969
|
+
try { await svc.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
|
|
970
|
+
const statement = typeof args?.statement === 'string' ? args.statement.trim() : '';
|
|
971
|
+
const source = typeof args?.source === 'string' ? args.source.trim() : '';
|
|
972
|
+
if (!statement) return { text: '拒绝:缺少 statement。' };
|
|
973
|
+
if (!source) return { text: '拒绝:缺少 source。\n修法:写明这条约定的依据来源。' };
|
|
974
|
+
const v = svc.verifyDecision(exec, args?.decision ?? {});
|
|
975
|
+
if (!v.ok) return { text: v.text };
|
|
976
|
+
const guard = svc.guard(root, [PROJECT_YAML]);
|
|
977
|
+
if (!guard.ok) return { text: guard.text };
|
|
978
|
+
const doc = await svc.readProject(root);
|
|
979
|
+
const convs = Array.isArray(doc.conventions) ? doc.conventions : [];
|
|
980
|
+
// 答复里的硬约束必须体现在 statement 里(只从选项值/补充说明里抽,不掺 id 前缀)
|
|
981
|
+
const hard = extractHardConstraints(v.decision);
|
|
982
|
+
const flatStatement = statement.replace(/\s+/g, '');
|
|
983
|
+
const missing = hard.filter((h) => !flatStatement.includes(h.replace(/\s+/g, '')));
|
|
984
|
+
if (missing.length) {
|
|
985
|
+
return { text: `拒绝:条文与用户答复不一致 —— 答复里的硬约束没有体现:${missing.join('、')}。\n修法:把答复里的硬约束原样写进 statement。` };
|
|
986
|
+
}
|
|
987
|
+
const wantPolarity = polarityOf(`${(v.decision.selected ?? []).join(' ')} ${v.decision.custom ?? ''}`);
|
|
988
|
+
if (wantPolarity.length && !wantPolarity.some((w) => statement.includes(w))) {
|
|
989
|
+
return { text: `拒绝:条文与用户答复不一致 —— 答复用了「${wantPolarity.join('/')}」这类硬约束词,条文里必须保留同样的约束方向。\n修法:把约束原样写进 statement。` };
|
|
990
|
+
}
|
|
991
|
+
const baseSlug = slugify(statement.slice(0, 24));
|
|
992
|
+
const base = (/^[a-z][a-z0-9-]{3,}$/.test(baseSlug) ? baseSlug : 'convention');
|
|
993
|
+
let id = base; let n = 2;
|
|
994
|
+
while (convs.some((c) => c.id === id)) { id = `${base}-${n}`; n += 1; }
|
|
995
|
+
const entry = {
|
|
996
|
+
id, statement,
|
|
997
|
+
...(typeof args?.scope === 'string' && args.scope.trim() ? { scope: args.scope.trim() } : {}),
|
|
998
|
+
authority: 'user', status: 'active', source,
|
|
999
|
+
decision: v.decision,
|
|
1000
|
+
createdAt: iso(),
|
|
1001
|
+
};
|
|
1002
|
+
doc.conventions = [...convs, entry];
|
|
1003
|
+
await svc.writeProject(root, doc);
|
|
1004
|
+
const eg = ctx.get('research.engineGit');
|
|
1005
|
+
const stored = eg.record(root, [PROJECT_YAML], `convention_declare: ${id}`);
|
|
1006
|
+
const rec = await svc.ledgerAppend(root, {
|
|
1007
|
+
record: 'decision', kind: 'convention', id,
|
|
1008
|
+
askCallId: v.decision.askCallId, question: v.decision.question,
|
|
1009
|
+
answer: v.decision.answer, sessionId: v.decision.sessionId,
|
|
1010
|
+
...(v.decision.attested ? { attested: true } : {}),
|
|
1011
|
+
}, { actor: 'user', commit: stored.commit ?? undefined });
|
|
1012
|
+
return {
|
|
1013
|
+
text: `约定已生效:${id}\n条文:${statement}\n生效范围:${entry.scope ?? '全工程'}\n用户答复原文:${v.decision.answer}${v.decision.attested ? '(attested)' : ''}\n台账第 ${rec.seq} 行。`,
|
|
1014
|
+
};
|
|
1015
|
+
},
|
|
1016
|
+
presentCall: (args) => genericCall('声明工程约定', 'edit', [PROJECT_YAML]),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** 答复里的硬约束:只从「选项值 + 补充说明」里抽,绝不把序列化串的 id 前缀带进来。 */
|
|
1021
|
+
function extractHardConstraints(decision) {
|
|
1022
|
+
const out = new Set();
|
|
1023
|
+
const text = [
|
|
1024
|
+
...(Array.isArray(decision?.selected) ? decision.selected : []),
|
|
1025
|
+
typeof decision?.custom === 'string' ? decision.custom : '',
|
|
1026
|
+
].join(' ').trim();
|
|
1027
|
+
if (!text) return [];
|
|
1028
|
+
for (const m of text.matchAll(/\d+(?:\.\d+)?\s*%?/g)) out.add(m[0].replace(/\s+/g, ''));
|
|
1029
|
+
for (const m of text.matchAll(/[「“"']([^「」“”"']{2,})[」”"']/g)) out.add(m[1]);
|
|
1030
|
+
return [...out].filter((x) => x.length >= 2).slice(0, 6);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** 极性词:答复里出现「必须/不得/…」,条文里至少要保留一个,防止把约束写成反义。 */
|
|
1034
|
+
const POLARITY = ['必须', '不得', '禁止', '不要', '不能', '只能', '一律', '至少', '最多', '不超过'];
|
|
1035
|
+
function polarityOf(text) { return POLARITY.filter((w) => String(text ?? '').includes(w)); }
|
|
1036
|
+
|
|
1037
|
+
/** 旧式单文件工程 → v3 就地升级(阶段 6 迁移)。
|
|
1038
|
+
* 顺序遵守「先归档、再迁移」:旧 project.yaml 原文完整落到 .research/legacy/,任何一项都不丢。 */
|
|
1039
|
+
async function migrateProject(ctx, svc, root) {
|
|
1040
|
+
const eg = ctx.get('research.engineGit');
|
|
1041
|
+
const pjPath = join(root, PROJECT_YAML);
|
|
1042
|
+
if (!(await pathExists(pjPath))) {
|
|
1043
|
+
return { text: `拒绝:${root} 下没有 ${PROJECT_YAML},没有可迁移的旧式工程。\n修法:接新工程用 mode=init(默认)。` };
|
|
1044
|
+
}
|
|
1045
|
+
const oldRaw = await readFile(pjPath, 'utf8');
|
|
1046
|
+
let old;
|
|
1047
|
+
try { old = yaml.load(oldRaw); } catch (e) { return { text: `拒绝:旧 ${PROJECT_YAML} 解析失败(${e.message})。` }; }
|
|
1048
|
+
if (!old || typeof old !== 'object') return { text: '拒绝:旧工程文件顶层不是映射对象。' };
|
|
1049
|
+
const isLegacy = Array.isArray(old.stages) || old.pipeline !== undefined || old.templates !== undefined || old.entities !== undefined || old.kb !== undefined;
|
|
1050
|
+
const alreadyV3 = !isLegacy && old.schemaVersion === SCHEMA_VERSION;
|
|
1051
|
+
const cap = eg.capabilities();
|
|
1052
|
+
const notes = []; const skipped = [];
|
|
1053
|
+
|
|
1054
|
+
// 1) 归档旧文件(零丢失)——仅首次迁移
|
|
1055
|
+
if (!alreadyV3) {
|
|
1056
|
+
await ensureDir(join(root, '.research', 'legacy'));
|
|
1057
|
+
await atomicWrite(join(root, '.research', 'legacy', 'project-v2.yaml'), oldRaw);
|
|
1058
|
+
}
|
|
1059
|
+
// 2) 基础设施
|
|
1060
|
+
const store = eg.init(root);
|
|
1061
|
+
const managed = await svc.managedList(root);
|
|
1062
|
+
await atomicWrite(join(root, '.research', 'managed.json'), JSON.stringify({
|
|
1063
|
+
version: SCHEMA_VERSION, mode: store.ok ? 'versioned' : 'ledger-only',
|
|
1064
|
+
rawArchive: store.lfs ? 'enabled' : 'hash-only', managed,
|
|
1065
|
+
note: '受管白名单的工程内副本;契约定义在 preset 的 contract/managed.json。',
|
|
1066
|
+
}, null, 2) + '\n');
|
|
1067
|
+
const touched = [PROJECT_YAML, REGISTRY, '.research/managed.json'];
|
|
1068
|
+
let doc = old;
|
|
1069
|
+
if (!alreadyV3) {
|
|
1070
|
+
// 3) project.yaml(身份 + 受管区 + 约定 + 词汇 + 遗留登记)
|
|
1071
|
+
doc = {
|
|
1072
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1073
|
+
project: {
|
|
1074
|
+
id: slugify(old.project?.id ?? basename(root)) || 'project',
|
|
1075
|
+
name: old.project?.name ?? basename(root),
|
|
1076
|
+
...(old.project?.domain ? { domain: String(old.project.domain) } : {}),
|
|
1077
|
+
...(old.project?.description ? { description: String(old.project.description) } : {}),
|
|
1078
|
+
},
|
|
1079
|
+
managed,
|
|
1080
|
+
conventions: Array.isArray(old.conventions) ? old.conventions : [],
|
|
1081
|
+
vocabulary: {
|
|
1082
|
+
entityKinds: Array.isArray(old.kb?.types) ? old.kb.types.map(String) : [],
|
|
1083
|
+
noteKinds: ['proposal', 'claim', 'lesson', 'question'],
|
|
1084
|
+
observableRoles: ['log', 'stdout', 'result', 'figure', 'raw'],
|
|
1085
|
+
},
|
|
1086
|
+
...(old.legacy ? { legacy: old.legacy } : {}),
|
|
1087
|
+
};
|
|
1088
|
+
await svc.writeProject(root, doc);
|
|
1089
|
+
touched.push('.research/legacy/project-v2.yaml');
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// 4) pipeline.yaml(把旧 stages/pipeline 逐项搬过来,图版本从 1 起)——仅首次迁移
|
|
1093
|
+
if (!alreadyV3 && Array.isArray(old.stages) && old.stages.length) {
|
|
1094
|
+
const pipeline = {
|
|
1095
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1096
|
+
graphVersion: 1,
|
|
1097
|
+
entry: old.pipeline?.entry ?? old.stages[0]?.id ?? null,
|
|
1098
|
+
stages: old.stages.map((s) => ({ id: String(s?.id ?? ''), name: String(s?.name ?? s?.id ?? ''), status: 'active' })),
|
|
1099
|
+
edges: Array.isArray(old.pipeline?.edges) ? old.pipeline.edges.map((e) => ({ from: String(e?.from ?? ''), to: String(e?.to ?? '') })) : [],
|
|
1100
|
+
};
|
|
1101
|
+
await svc.writeYamlFile(join(root, PIPELINE_YAML), pipeline);
|
|
1102
|
+
touched.push(PIPELINE_YAML);
|
|
1103
|
+
await svc.ledgerAppend(root, {
|
|
1104
|
+
record: 'pipeline', action: 'migrate', entry: pipeline.entry, graphVersion: 1,
|
|
1105
|
+
stages: pipeline.stages.map((s) => s.id), edges: pipeline.edges.map((e) => `${e.from}->${e.to}`),
|
|
1106
|
+
});
|
|
1107
|
+
notes.push(`阶段机:${pipeline.stages.length} 个阶段 / ${pipeline.edges.length} 条边(图版本 1)`);
|
|
1108
|
+
} else if (!alreadyV3) { skipped.push('旧工程没有 stages/pipeline,未生成 pipeline.yaml'); }
|
|
1109
|
+
|
|
1110
|
+
// 5) templates.yaml + 脚本收编(mode=adopt,绝不改既有脚本)——仅首次迁移
|
|
1111
|
+
const oldTpls = (!alreadyV3 && old.templates && typeof old.templates === 'object') ? old.templates : {};
|
|
1112
|
+
const tpls = {};
|
|
1113
|
+
let adopted = 0;
|
|
1114
|
+
for (const [id, t] of Object.entries(oldTpls)) {
|
|
1115
|
+
const scriptRel = typeof t?.command?.script === 'string' ? posix(t.command.script) : '';
|
|
1116
|
+
if (!scriptRel) { skipped.push(`模板 ${id}:没有 command.script,未迁移`); continue; }
|
|
1117
|
+
const abs = join(root, scriptRel);
|
|
1118
|
+
if (!(await pathExists(abs))) { skipped.push(`模板 ${id}:脚本不存在(${scriptRel}),未迁移`); continue; }
|
|
1119
|
+
const name = slugify(basename(scriptRel).replace(/\.[^.]+$/, '')) || 'script';
|
|
1120
|
+
const metaPath = join(root, 'scripts', `${name}.meta.json`);
|
|
1121
|
+
const meta = {
|
|
1122
|
+
schemaVersion: SCHEMA_VERSION, name, mode: 'adopt',
|
|
1123
|
+
purpose: String(t?.description ?? `${id} 的脚本`),
|
|
1124
|
+
entrypoint: `python ${scriptRel} ${String(t?.command?.args ?? '')}`.trim(),
|
|
1125
|
+
allowlist: Array.isArray(t?.command?.allow) && t.command.allow.length ? t.command.allow.map(String) : ['python'],
|
|
1126
|
+
owner: 'user', source: scriptRel, sha256: eg.sha256File(abs), version: 1, createdAt: iso(),
|
|
1127
|
+
};
|
|
1128
|
+
await atomicWrite(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
1129
|
+
touched.push(posix(join('scripts', `${name}.meta.json`)));
|
|
1130
|
+
adopted += 1;
|
|
1131
|
+
tpls[String(id)] = {
|
|
1132
|
+
version: 1, status: 'active',
|
|
1133
|
+
description: String(t?.description ?? ''),
|
|
1134
|
+
allow: meta.allowlist,
|
|
1135
|
+
scriptRef: { name, mode: 'adopt', path: scriptRel, sha256: meta.sha256, version: 1 },
|
|
1136
|
+
...(typeof t?.command?.args === 'string' && t.command.args.trim() ? { args: t.command.args } : {}),
|
|
1137
|
+
paramsSchema: (t?.parameters && typeof t.parameters === 'object') ? t.parameters : { type: 'object', properties: {} },
|
|
1138
|
+
observables: [],
|
|
1139
|
+
updatedAt: iso(),
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
if (Object.keys(tpls).length) {
|
|
1143
|
+
await svc.writeYamlFile(join(root, TEMPLATES_YAML), { schemaVersion: SCHEMA_VERSION, templates: tpls });
|
|
1144
|
+
touched.push(TEMPLATES_YAML);
|
|
1145
|
+
notes.push(`模板:${Object.keys(tpls).length} 个(observables 全部为空,需 template_declare 声明可提取字段)`);
|
|
1146
|
+
} else if (!alreadyV3) { skipped.push('旧工程没有可迁移的模板'); }
|
|
1147
|
+
|
|
1148
|
+
// 6) 既有 run 补证据清单
|
|
1149
|
+
const runDirs = [];
|
|
1150
|
+
try {
|
|
1151
|
+
for (const e of await readdir(join(root, 'experiments'), { withFileTypes: true })) if (e.isDirectory()) runDirs.push(e.name);
|
|
1152
|
+
} catch { /* 无 experiments 目录 */ }
|
|
1153
|
+
let manifests = 0; let incomplete = 0;
|
|
1154
|
+
for (const runId of runDirs) {
|
|
1155
|
+
const dirRel = posix(join('experiments', runId));
|
|
1156
|
+
const files = [];
|
|
1157
|
+
if (await pathExists(join(root, dirRel, 'config.json'))) files.push(`${dirRel}/config.json`);
|
|
1158
|
+
for (const f of eg.listFiles(root, `${dirRel}/raw`)) files.push(posix(f));
|
|
1159
|
+
for (const extra of ['result.json', 'observations.json']) if (await pathExists(join(root, dirRel, extra))) files.push(`${dirRel}/${extra}`);
|
|
1160
|
+
const entries = [];
|
|
1161
|
+
for (const rel of files) {
|
|
1162
|
+
const sha = eg.sha256File(join(root, rel));
|
|
1163
|
+
if (sha === null) continue;
|
|
1164
|
+
const st = eg.sizeMtime(join(root, rel));
|
|
1165
|
+
entries.push({
|
|
1166
|
+
path: rel, role: rel.endsWith('config.json') ? 'config' : roleOfPath(rel),
|
|
1167
|
+
sha256: sha, bytes: st?.bytes ?? 0, producer: rel.endsWith('config.json') ? 'run_draft' : 'process',
|
|
1168
|
+
ts: st?.mtime ?? iso(), stored: store.lfs ? 'versioned' : 'hash-only',
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
const complete = entries.some((e) => e.role !== 'config' && !e.path.endsWith('observations.json'));
|
|
1172
|
+
await atomicWrite(join(root, dirRel, 'manifest.json'), JSON.stringify({
|
|
1173
|
+
schemaVersion: SCHEMA_VERSION, runId, entries, complete, updatedAt: iso(),
|
|
1174
|
+
}, null, 2) + '\n');
|
|
1175
|
+
touched.push(`${dirRel}/manifest.json`);
|
|
1176
|
+
for (const e of entries) touched.push(e.path);
|
|
1177
|
+
manifests += 1;
|
|
1178
|
+
if (!complete) incomplete += 1;
|
|
1179
|
+
}
|
|
1180
|
+
if (manifests) notes.push(`既有 run:${manifests} 个已补证据清单,其中 ${incomplete} 个证据不完整(complete=false,不得据此下结论)`);
|
|
1181
|
+
|
|
1182
|
+
// 6b) 既有派生文件:登记进版本库,否则迁移后立刻是 unknown
|
|
1183
|
+
for (const rel of ['.kb/index.yaml', '.research/state.json']) {
|
|
1184
|
+
if (await pathExists(join(root, rel))) touched.push(rel);
|
|
1185
|
+
}
|
|
1186
|
+
for (const f of eg.listFiles(root, '_report')) touched.push(posix(f));
|
|
1187
|
+
for (const f of eg.listFiles(root, 'skills')) if (posix(f).endsWith('SKILL.md')) touched.push(posix(f));
|
|
1188
|
+
|
|
1189
|
+
// 6c) 旧实体补 source(旧台账的实体事件没有这个必填字段)
|
|
1190
|
+
const legacyRows = await svc.ledgerRead(root);
|
|
1191
|
+
const seenEntity = new Set();
|
|
1192
|
+
let entityFixed = 0;
|
|
1193
|
+
for (const r of legacyRows) {
|
|
1194
|
+
const o = r.obj;
|
|
1195
|
+
if (o?.record !== 'entity' || !o.id || seenEntity.has(o.id)) continue;
|
|
1196
|
+
seenEntity.add(o.id);
|
|
1197
|
+
if (typeof o.source === 'string' && o.source.trim()) continue;
|
|
1198
|
+
await svc.ledgerAppend(root, {
|
|
1199
|
+
record: 'entity', id: o.id, kind: o.kind ?? 'other',
|
|
1200
|
+
...(o.name ? { name: o.name } : {}),
|
|
1201
|
+
...(o.props ? { props: o.props } : {}),
|
|
1202
|
+
source: '.research/legacy/project-v2.yaml',
|
|
1203
|
+
note: '迁移补齐来源(原声明在旧式工程文件里)',
|
|
1204
|
+
});
|
|
1205
|
+
entityFixed += 1;
|
|
1206
|
+
}
|
|
1207
|
+
if (entityFixed) notes.push(`实体:${entityFixed} 条补齐了来源指针`);
|
|
1208
|
+
|
|
1209
|
+
// 7) 笔记补 v3 结构头(不删原文)
|
|
1210
|
+
let noteCount = 0;
|
|
1211
|
+
try {
|
|
1212
|
+
for (const f of (await readdir(join(root, '.kb', 'notes'))).filter((x) => x.endsWith('.md'))) {
|
|
1213
|
+
const rel = posix(join('.kb', 'notes', f));
|
|
1214
|
+
const raw = await readFile(join(root, rel), 'utf8');
|
|
1215
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
|
|
1216
|
+
const legacyFront = m ? (yaml.load(m[1]) ?? {}) : {};
|
|
1217
|
+
const body = m ? m[2] : raw;
|
|
1218
|
+
if (m && legacyFront.schemaVersion === SCHEMA_VERSION) continue; // 已经是 v3
|
|
1219
|
+
const name = slugify(legacyFront.name ?? f.replace(/\.md$/, '')) || 'note';
|
|
1220
|
+
const oldType = String(legacyFront.type ?? legacyFront.kind ?? '');
|
|
1221
|
+
const kind = oldType === 'conclusion' ? 'claim' : (['lesson', 'question', 'proposal', 'claim'].includes(oldType) ? oldType : 'proposal');
|
|
1222
|
+
const evidence = [];
|
|
1223
|
+
for (const mm of body.matchAll(/[\w./-]+\.(?:py|txt|csv|json|log|md|ipynb|png|pth)/g)) {
|
|
1224
|
+
const ref = posix(mm[0]);
|
|
1225
|
+
if (ref.split('/').includes('..')) continue;
|
|
1226
|
+
if (await pathExists(join(root, ref))) evidence.push({ type: 'file', ref });
|
|
1227
|
+
}
|
|
1228
|
+
const uniq = [...new Map(evidence.map((e) => [e.ref, e])).values()].slice(0, 5);
|
|
1229
|
+
const front = {
|
|
1230
|
+
schemaVersion: SCHEMA_VERSION, name, kind,
|
|
1231
|
+
status: kind === 'lesson' ? 'accepted' : 'proposed',
|
|
1232
|
+
authority: kind === 'lesson' ? 'evidence' : 'agent',
|
|
1233
|
+
description: String(legacyFront.description ?? `迁移自旧笔记 ${f}`),
|
|
1234
|
+
evidence: uniq,
|
|
1235
|
+
...(legacyFront.stage ? { scope: String(legacyFront.stage) } : (kind === 'claim' ? { scope: '迁移:范围待补' } : {})),
|
|
1236
|
+
...(Array.isArray(legacyFront.tags) ? { tags: legacyFront.tags.map(String) } : {}),
|
|
1237
|
+
...(legacyFront.stage ? { stage: String(legacyFront.stage) } : {}),
|
|
1238
|
+
legacy: legacyFront,
|
|
1239
|
+
createdAt: iso(), updatedAt: iso(),
|
|
1240
|
+
};
|
|
1241
|
+
await atomicWrite(join(root, rel), `---\n${yaml.dump(front, { lineWidth: -1, noRefs: true, skipInvalid: true })}---\n\n${String(body).replace(/^\n+/, '')}\n`);
|
|
1242
|
+
touched.push(rel);
|
|
1243
|
+
noteCount += 1;
|
|
1244
|
+
}
|
|
1245
|
+
} catch { /* 无 .kb/notes */ }
|
|
1246
|
+
if (noteCount) notes.push(`笔记:${noteCount} 条补了 v3 结构头(原文保留,旧字段存在 legacy 下)`);
|
|
1247
|
+
|
|
1248
|
+
// 8) 旧工程笔记 SKILL 移出环境位置(保留为历史,不再被加载)
|
|
1249
|
+
const retired = [];
|
|
1250
|
+
try {
|
|
1251
|
+
for (const d of await readdir(join(root, '.dsh', 'skills'), { withFileTypes: true })) {
|
|
1252
|
+
if (!d.isDirectory()) continue;
|
|
1253
|
+
const src = join(root, '.dsh', 'skills', d.name, 'SKILL.md');
|
|
1254
|
+
if (!(await pathExists(src))) continue;
|
|
1255
|
+
const dst = join(root, '.research', 'legacy', 'skills', d.name, 'SKILL.md');
|
|
1256
|
+
await ensureDir(dirname(dst));
|
|
1257
|
+
await atomicWrite(dst, await readFile(src, 'utf8'));
|
|
1258
|
+
await rm(src, { force: true });
|
|
1259
|
+
retired.push(d.name);
|
|
1260
|
+
touched.push(posix(join('.research', 'legacy', 'skills', d.name, 'SKILL.md')));
|
|
1261
|
+
}
|
|
1262
|
+
} catch { /* 无 .dsh/skills */ }
|
|
1263
|
+
if (retired.length) notes.push(`旧工程笔记已移出环境位置(保留为历史):${retired.join('、')}`);
|
|
1264
|
+
|
|
1265
|
+
// 9) 入库 + 台账
|
|
1266
|
+
const stored = eg.record(root, touched, alreadyV3 ? 'project_init: 迁移修复复检' : 'project_init: 迁移旧式工程');
|
|
1267
|
+
const rec = await svc.ledgerAppend(root, {
|
|
1268
|
+
record: 'project', action: alreadyV3 ? 'repair' : 'migrate', id: doc.project.id, root,
|
|
1269
|
+
archive: '.research/legacy/project-v2.yaml',
|
|
1270
|
+
}, { commit: stored.commit ?? undefined });
|
|
1271
|
+
|
|
1272
|
+
const lines = [
|
|
1273
|
+
`${alreadyV3 ? '修复复检完成(工程已是 v3,只补齐派生文件 / 证据清单 / 实体来源)' : '迁移完成'}:${root}`,
|
|
1274
|
+
`工程 id:${doc.project.id} 显示名:${doc.project.name}`,
|
|
1275
|
+
...(alreadyV3 ? [] : ['旧文件已归档:.research/legacy/project-v2.yaml(原文完整保留,可逐项核对)']),
|
|
1276
|
+
`版本能力:${store.ok ? (store.lfs ? '可用(原始产出可还原)' : '可用(原始产出仅记指纹)') : '不可用(仅台账)'}`,
|
|
1277
|
+
...notes.map((n) => `· ${n}`),
|
|
1278
|
+
...(skipped.length ? ['未能迁移(需人工确认):', ...skipped.map((s) => ` - ${s}`)] : []),
|
|
1279
|
+
`台账第 ${rec.seq} 行。`,
|
|
1280
|
+
'下一步:project_verify 复检 → template_declare overwrite 补 observables → view_render(report/notes-skill/index)。',
|
|
1281
|
+
];
|
|
1282
|
+
return { text: lines.join('\n') };
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** 旧产物角色推断(迁移用;与 task-dispatch 的 roleOf 同规则)。 */
|
|
1286
|
+
function roleOfPath(rel) {
|
|
1287
|
+
const n = basename(rel).toLowerCase();
|
|
1288
|
+
const ext = (/\.([a-z0-9]+)$/.exec(n)?.[1] ?? '');
|
|
1289
|
+
if (n === 'stdout.log' || n === 'stderr.log') return 'stdout';
|
|
1290
|
+
if (['log', 'txt', 'out', 'err'].includes(ext)) return 'log';
|
|
1291
|
+
if (['json', 'yaml', 'yml', 'csv'].includes(ext)) return 'result';
|
|
1292
|
+
if (['png', 'jpg', 'jpeg', 'svg', 'pdf'].includes(ext)) return 'figure';
|
|
1293
|
+
if (['pth', 'pt', 'ckpt', 'npz', 'bin'].includes(ext)) return 'checkpoint';
|
|
1294
|
+
return 'raw';
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/** source 解析:文件:行 / runId / 实测命令。 */
|
|
1298
|
+
async function checkSource(root, source) {
|
|
1299
|
+
const s = source.trim();
|
|
1300
|
+
const fileLike = /^([^:]+?)(?::(\d+))?$/.exec(s);
|
|
1301
|
+
if (fileLike && /[.\\/]/.test(fileLike[1])) {
|
|
1302
|
+
const rel = posix(fileLike[1]);
|
|
1303
|
+
if (isAbsolute(rel) || rel.split('/').includes('..')) return { ok: false, why: '来源路径越出工程范围' };
|
|
1304
|
+
const abs = join(root, rel);
|
|
1305
|
+
if (!(await pathExists(abs))) return { ok: false, why: `工程内没有这个文件:${rel}` };
|
|
1306
|
+
if (fileLike[2]) {
|
|
1307
|
+
const line = Number(fileLike[2]);
|
|
1308
|
+
const text = await readFile(abs, 'utf8').catch(() => '');
|
|
1309
|
+
const total = text === '' ? 0 : text.split(/\r?\n/).length;
|
|
1310
|
+
if (line < 1 || line > total) return { ok: false, why: `${rel} 只有 ${total} 行,取不到第 ${line} 行` };
|
|
1311
|
+
}
|
|
1312
|
+
return { ok: true };
|
|
1313
|
+
}
|
|
1314
|
+
if (/^[a-z0-9][a-z0-9-]*$/i.test(s)) {
|
|
1315
|
+
const rows = await readRegistryRows(root);
|
|
1316
|
+
const hit = rows.some((r) => r.obj?.record === 'run' && r.obj.runId === s);
|
|
1317
|
+
if (hit) return { ok: true };
|
|
1318
|
+
return { ok: false, why: `台账里没有 runId=${s}` };
|
|
1319
|
+
}
|
|
1320
|
+
if (s.length >= 4) return { ok: true };
|
|
1321
|
+
return { ok: false, why: '来源太短或格式不明' };
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
export { apply };
|