@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,538 @@
|
|
|
1
|
+
// kb-core —— 知识 / 裁决 / 渲染(4 工具)
|
|
2
|
+
//
|
|
3
|
+
// 拥有数据:.kb/notes/*.md(提议与结论)、skills/<project>/SKILL.md、_report/*、.kb/index.yaml(三者都是派生视图)。
|
|
4
|
+
// 权威分离:agent 提议只能 status=proposed;升级只有两条路——证据可解析(→证据权威)或用户裁决(→用户权威)。
|
|
5
|
+
// 位置即权威:提议只放数据位置(.kb/),不得放环境位置(project.yaml / skills 由工具渲染)。
|
|
6
|
+
|
|
7
|
+
import { join, dirname, basename, relative, resolve, sep } from 'node:path';
|
|
8
|
+
import { readFile, writeFile, mkdir, stat, rename, readdir } from 'node:fs/promises';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
const dshHome = process.env.DSH_HOME || join(os.homedir(), '.dsh');
|
|
14
|
+
// 依赖解析:先按插件自身位置解析(npm/pnpm 随包安装的 node_modules),
|
|
15
|
+
// 再回退到 harness profile 的扁平安装 —— 把 preset 目录直接拷进 $DSH_HOME/.agent-presets 时没有自己的 node_modules。
|
|
16
|
+
const REQUIRE_ANCHORS = [
|
|
17
|
+
import.meta.url,
|
|
18
|
+
join(dshHome, 'profiles', 'node_modules', 'js-yaml', 'package.json'),
|
|
19
|
+
join(dshHome, 'profiles', 'web', 'package.json'),
|
|
20
|
+
];
|
|
21
|
+
function loadDep(id) {
|
|
22
|
+
let last = null;
|
|
23
|
+
for (const anchor of REQUIRE_ANCHORS) {
|
|
24
|
+
try { return createRequire(anchor)(id); } catch (error) { last = error; }
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`缺少依赖 ${id}:请在该 preset 所在位置安装它,或让 $DSH_HOME/profiles/node_modules 里存在它(${last && last.message})`);
|
|
27
|
+
}
|
|
28
|
+
const yaml = loadDep('js-yaml');
|
|
29
|
+
const SCHEMA_VERSION = 3;
|
|
30
|
+
const NOTES = '.kb/notes';
|
|
31
|
+
const REGISTRY = 'registry.jsonl';
|
|
32
|
+
// 词边界替换:不误伤用户数据里的同形子串(如 usergit-probe / shanghai),但独立出现的版本库词汇一律替换。
|
|
33
|
+
const SCRUB = /(^|[^A-Za-z0-9_-])(git|commit|HEAD|diff|hash|sha)(?![A-Za-z0-9_-])/gi;
|
|
34
|
+
|
|
35
|
+
export const name = 'kb-core';
|
|
36
|
+
export const inject = ['tools'];
|
|
37
|
+
|
|
38
|
+
function iso() { return new Date().toISOString(); }
|
|
39
|
+
function sha256(s) { return createHash('sha256').update(s).digest('hex'); }
|
|
40
|
+
function scrub(t) { return String(t ?? '').replace(SCRUB, (_m, pre) => `${pre}·`); }
|
|
41
|
+
function posix(p) { return String(p).replace(/\\/g, '/'); }
|
|
42
|
+
function isWithin(root, p) {
|
|
43
|
+
const rel = relative(resolve(root), resolve(p));
|
|
44
|
+
return rel === '' || (!rel.startsWith('..' + sep) && rel !== '..');
|
|
45
|
+
}
|
|
46
|
+
async function pathExists(p) { try { await stat(p); return true; } catch { return false; } }
|
|
47
|
+
async function ensureDir(p) { await mkdir(p, { recursive: true }); }
|
|
48
|
+
async function atomicWrite(p, body) {
|
|
49
|
+
await ensureDir(dirname(p));
|
|
50
|
+
const tmp = `${p}.tmp-${sha256(iso() + Math.random()).slice(0, 8)}`;
|
|
51
|
+
await writeFile(tmp, body, 'utf8');
|
|
52
|
+
await rename(tmp, p);
|
|
53
|
+
}
|
|
54
|
+
async function readJson(p, fallback) { try { return JSON.parse(await readFile(p, 'utf8')); } catch { return fallback; } }
|
|
55
|
+
|
|
56
|
+
function parseNote(raw) {
|
|
57
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
|
|
58
|
+
if (!m) return { front: null, body: raw };
|
|
59
|
+
let front = null;
|
|
60
|
+
try { front = yaml.load(m[1]); } catch { front = null; }
|
|
61
|
+
return { front: front && typeof front === 'object' ? front : null, body: m[2] };
|
|
62
|
+
}
|
|
63
|
+
function renderNote(front, body) {
|
|
64
|
+
return `---\n${yaml.dump(front, { lineWidth: -1, noRefs: true, skipInvalid: true })}---\n\n${String(body ?? '').replace(/^\n+/, '')}\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildService(ctx) {
|
|
68
|
+
const led = () => ctx.get('research.expLedger');
|
|
69
|
+
const eg = () => ctx.get('research.engineGit');
|
|
70
|
+
|
|
71
|
+
async function listNotes(root) {
|
|
72
|
+
const dir = join(root, NOTES);
|
|
73
|
+
const out = [];
|
|
74
|
+
if (!(await pathExists(dir))) return out;
|
|
75
|
+
let names = [];
|
|
76
|
+
try { names = await readdir(dir); } catch { return out; }
|
|
77
|
+
for (const n of names.filter((x) => x.endsWith('.md')).sort()) {
|
|
78
|
+
const raw = await readFile(join(dir, n), 'utf8');
|
|
79
|
+
const p = parseNote(raw);
|
|
80
|
+
out.push({ file: n, name: n.replace(/\.md$/, ''), raw, front: p.front, body: p.body });
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function resolvePointer(root, ptr) {
|
|
86
|
+
const type = ptr?.type; const ref = typeof ptr?.ref === 'string' ? ptr.ref.trim() : '';
|
|
87
|
+
if (!ref) return { ok: false, why: '指针为空' };
|
|
88
|
+
if (type === 'run') {
|
|
89
|
+
const fold = await led().ledgerFold(root);
|
|
90
|
+
if (!fold.runs.has(ref)) return { ok: false, why: `台账里没有 runId=${ref}` };
|
|
91
|
+
return { ok: true, display: `运行 ${ref}` };
|
|
92
|
+
}
|
|
93
|
+
if (type === 'observation') {
|
|
94
|
+
const [rid, field] = ref.split('#');
|
|
95
|
+
const fold = await led().ledgerFold(root);
|
|
96
|
+
if (!fold.runs.has(rid)) return { ok: false, why: `台账里没有 runId=${rid}` };
|
|
97
|
+
const p = join(root, 'experiments', rid, 'observations.json');
|
|
98
|
+
if (!(await pathExists(p))) return { ok: false, why: `run ${rid} 还没有观察值` };
|
|
99
|
+
const obs = await readJson(p, null);
|
|
100
|
+
if (field && !(obs?.observations ?? []).some((o) => o.field === field)) {
|
|
101
|
+
return { ok: false, why: `run ${rid} 没有观察字段 ${field}` };
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, display: `观察 ${ref}` };
|
|
104
|
+
}
|
|
105
|
+
if (type === 'file') {
|
|
106
|
+
const [rel, line] = ref.split(':');
|
|
107
|
+
const clean = posix(rel);
|
|
108
|
+
if (!isWithin(root, join(root, clean))) return { ok: false, why: `指针越出工程:${clean}` };
|
|
109
|
+
const abs = join(root, clean);
|
|
110
|
+
if (!(await pathExists(abs))) return { ok: false, why: `工程内没有这个文件:${clean}` };
|
|
111
|
+
if (line) {
|
|
112
|
+
const text = await readFile(abs, 'utf8').catch(() => '');
|
|
113
|
+
const total = text === '' ? 0 : text.split(/\r?\n/).length;
|
|
114
|
+
if (Number(line) < 1 || Number(line) > total) return { ok: false, why: `${clean} 只有 ${total} 行,取不到第 ${line} 行` };
|
|
115
|
+
}
|
|
116
|
+
return { ok: true, display: `文件 ${ref}` };
|
|
117
|
+
}
|
|
118
|
+
return { ok: false, why: `指针类型必须是 run / file / observation(收到 ${JSON.stringify(type)})` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function writeNote(root, name, front, body) {
|
|
122
|
+
const rel = posix(join(NOTES, `${name}.md`));
|
|
123
|
+
await atomicWrite(join(root, rel), renderNote(front, body));
|
|
124
|
+
return rel;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
listNotes, resolvePointer, writeNote, parseNote, renderNote,
|
|
129
|
+
|
|
130
|
+
/** 安全闸命中等场景由 task-dispatch 调用的教训写入。 */
|
|
131
|
+
async recordLesson(args, exec) {
|
|
132
|
+
const root = led().root(args, exec);
|
|
133
|
+
const name = led().slugify(args?.name ?? 'lesson') || 'lesson';
|
|
134
|
+
const front = {
|
|
135
|
+
schemaVersion: SCHEMA_VERSION, name,
|
|
136
|
+
kind: 'lesson', status: 'accepted', authority: 'evidence',
|
|
137
|
+
description: String(args?.description ?? '教训').slice(0, 200),
|
|
138
|
+
evidence: [], scope: 'engine-safety',
|
|
139
|
+
createdAt: iso(), updatedAt: iso(),
|
|
140
|
+
};
|
|
141
|
+
const rel = await writeNote(root, name, front, String(args?.statement ?? args?.description ?? ''));
|
|
142
|
+
const stored = eg().record(root, [rel], `note_write: ${name}`);
|
|
143
|
+
await led().ledgerAppend(root, { record: 'note', name, kind: 'lesson', status: 'accepted', authority: 'evidence' }, { commit: stored.commit ?? undefined });
|
|
144
|
+
return { ok: true, name, path: rel };
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function genericCall(title, kind, paths) {
|
|
150
|
+
return { card: 'generic', title, kind, ...(paths && paths.length ? { locations: paths.map((p) => ({ path: p })) } : {}) };
|
|
151
|
+
}
|
|
152
|
+
function registerTextTool(ctx, tool) {
|
|
153
|
+
ctx.tools.register({
|
|
154
|
+
name: tool.name,
|
|
155
|
+
description: tool.description,
|
|
156
|
+
parameters: { type: 'object', properties: tool.properties || {}, ...(tool.required?.length ? { required: tool.required } : {}) },
|
|
157
|
+
output: {
|
|
158
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } }, required: ['text'] },
|
|
159
|
+
render: (_a, v) => [{ type: 'text', text: v.text }],
|
|
160
|
+
},
|
|
161
|
+
...(tool.presentCall ? { presentCall: tool.presentCall } : {}),
|
|
162
|
+
execute: async (args, exec) => {
|
|
163
|
+
const r = await tool.execute(args, exec);
|
|
164
|
+
return { text: scrub(typeof r?.text === 'string' ? r.text : String(r?.text ?? '')) };
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function apply(ctx) {
|
|
170
|
+
const svc = buildService(ctx);
|
|
171
|
+
ctx.provide('research.kbCore', svc);
|
|
172
|
+
|
|
173
|
+
// ---------- note_write ----------
|
|
174
|
+
registerTextTool(ctx, {
|
|
175
|
+
name: 'note_write',
|
|
176
|
+
description: '写一条知识记录:kind=proposal/claim/lesson/question。claim 必须有可解析的 evidence 与 scope;与已生效结论同 scope 冲突时只能写 proposal。',
|
|
177
|
+
properties: {
|
|
178
|
+
root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
|
|
179
|
+
kind: { type: 'string', enum: ['proposal', 'claim', 'lesson', 'question'], description: '记录类型。' },
|
|
180
|
+
name: { type: 'string', description: '笔记名(kebab-case)。' },
|
|
181
|
+
description: { type: 'string', description: '一句话摘要。' },
|
|
182
|
+
statement: { type: 'string', description: '正文(主张/提议/教训/问题)。' },
|
|
183
|
+
evidence: {
|
|
184
|
+
type: 'array',
|
|
185
|
+
description: '证据指针(claim 必填)。',
|
|
186
|
+
items: {
|
|
187
|
+
type: 'object',
|
|
188
|
+
properties: {
|
|
189
|
+
type: { type: 'string', enum: ['run', 'file', 'observation'] },
|
|
190
|
+
ref: { type: 'string', description: 'runId / 工程内相对路径(可带 :行) / runId#字段。' },
|
|
191
|
+
note: { type: 'string' },
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
scope: { type: 'string', description: '成立条件 / 适用范围(claim 必填)。' },
|
|
196
|
+
tags: { type: 'array', items: { type: 'string' }, description: '标签。' },
|
|
197
|
+
stage: { type: 'string', description: '所属阶段。' },
|
|
198
|
+
overwrite: { type: 'boolean', description: '允许覆盖同名笔记。' },
|
|
199
|
+
},
|
|
200
|
+
required: ['kind', 'name', 'description', 'statement'],
|
|
201
|
+
execute: async (args, exec) => {
|
|
202
|
+
const led = ctx.get('research.expLedger');
|
|
203
|
+
const eg = () => ctx.get('research.engineGit');
|
|
204
|
+
const root = led.root(args, exec);
|
|
205
|
+
try { await led.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
|
|
206
|
+
const kind = args?.kind;
|
|
207
|
+
if (!['proposal', 'claim', 'lesson', 'question'].includes(kind)) {
|
|
208
|
+
return { text: '拒绝:kind 只能是 proposal / claim / lesson / question。' };
|
|
209
|
+
}
|
|
210
|
+
const name = led.slugify(args?.name ?? '');
|
|
211
|
+
if (!name) return { text: '拒绝:笔记名非法。\n修法:用 kebab-case(小写字母、数字、连字符)。' };
|
|
212
|
+
const description = typeof args?.description === 'string' ? args.description.trim() : '';
|
|
213
|
+
const statement = typeof args?.statement === 'string' ? args.statement.trim() : '';
|
|
214
|
+
if (!description) return { text: '拒绝:缺少 description。' };
|
|
215
|
+
if (!statement) return { text: '拒绝:缺少 statement。' };
|
|
216
|
+
const notes = await svc.listNotes(root);
|
|
217
|
+
if (notes.some((n) => n.name === name) && args?.overwrite !== true) {
|
|
218
|
+
return { text: `拒绝:笔记 ${name} 已存在。\n修法:换名,或加 overwrite=true 覆盖(状态会重置为 proposed)。` };
|
|
219
|
+
}
|
|
220
|
+
const evidence = Array.isArray(args?.evidence) ? args.evidence : [];
|
|
221
|
+
if (kind === 'claim') {
|
|
222
|
+
if (!evidence.length) {
|
|
223
|
+
return { text: '拒绝:claim 必须带 evidence。\n修法:指向可复现的证据(runId / 已登记文件:行 / runId#字段);没有证据的推断写成 kind=proposal。' };
|
|
224
|
+
}
|
|
225
|
+
if (typeof args?.scope !== 'string' || !args.scope.trim()) {
|
|
226
|
+
return { text: '拒绝:claim 必须带 scope(成立条件)。\n修法:写清这条结论在什么条件下成立。' };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const resolved = [];
|
|
230
|
+
for (const e of evidence) {
|
|
231
|
+
const r = await svc.resolvePointer(root, e);
|
|
232
|
+
if (!r.ok) return { text: `拒绝:证据指针无法解析 —— ${r.why}\n修法:先确认证据确实存在(run_query / project_load),再写结论。` };
|
|
233
|
+
resolved.push({ type: e.type, ref: String(e.ref).trim(), ...(e.note ? { note: String(e.note) } : {}) });
|
|
234
|
+
}
|
|
235
|
+
const scope = typeof args?.scope === 'string' ? args.scope.trim() : '';
|
|
236
|
+
if (kind === 'claim') {
|
|
237
|
+
const conflict = notes.find((n) => n.front?.kind === 'claim' && n.front?.status === 'accepted' && (n.front?.scope ?? '') === scope && n.name !== name);
|
|
238
|
+
if (conflict) {
|
|
239
|
+
return { text: `拒绝:范围「${scope}」已有生效结论 ${conflict.name}。\n修法:新结果与它冲突时必须显式提出——写成 kind=proposal(再由 note_adjudicate 裁决),不得静默合并。` };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const guard = led.guard(root, ['.kb/**']);
|
|
243
|
+
if (!guard.ok) return { text: guard.text };
|
|
244
|
+
const front = {
|
|
245
|
+
schemaVersion: SCHEMA_VERSION, name, kind, status: 'proposed', authority: 'agent',
|
|
246
|
+
description, evidence: resolved, ...(scope ? { scope } : {}),
|
|
247
|
+
...(Array.isArray(args?.tags) && args.tags.length ? { tags: args.tags.map(String) } : {}),
|
|
248
|
+
...(typeof args?.stage === 'string' && args.stage.trim() ? { stage: args.stage.trim() } : {}),
|
|
249
|
+
createdAt: iso(), updatedAt: iso(),
|
|
250
|
+
};
|
|
251
|
+
const rel = await svc.writeNote(root, name, front, statement);
|
|
252
|
+
const stored = eg().record(root, [rel], `note_write: ${name}`);
|
|
253
|
+
const rec = await led.ledgerAppend(root, { record: 'note', name, kind, status: 'proposed', authority: 'agent', evidence: resolved.map((e) => e.ref) }, { commit: stored.commit ?? undefined });
|
|
254
|
+
return {
|
|
255
|
+
text: [
|
|
256
|
+
`已写入笔记:${rel}`,
|
|
257
|
+
`类型:${kind} 状态:proposed 权威:agent`,
|
|
258
|
+
...(resolved.length ? [`证据:${resolved.map((e) => e.ref).join('、')}`] : []),
|
|
259
|
+
...(scope ? [`范围:${scope}`] : []),
|
|
260
|
+
`台账第 ${rec.seq} 行。`,
|
|
261
|
+
(kind === 'claim' || kind === 'proposal') ? '下一步:note_adjudicate(有可解析证据的 claim 可依证据生效;proposal 需用户裁决)。' : '',
|
|
262
|
+
].filter(Boolean).join('\n'),
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
presentCall: (args) => genericCall(`写笔记 ${args?.name ?? ''}`.trim(), 'edit', [`${NOTES}/${args?.name ?? ''}.md`]),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ---------- note_adjudicate ----------
|
|
269
|
+
registerTextTool(ctx, {
|
|
270
|
+
name: 'note_adjudicate',
|
|
271
|
+
description: '裁决一条笔记:accept(生效)/ retract(撤回)/ supersede(被取代)。有可解析证据的 claim 可依证据生效;撤回已生效结论或取代结论必须带用户裁决。',
|
|
272
|
+
properties: {
|
|
273
|
+
root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
|
|
274
|
+
note: { type: 'string', description: '笔记名(不含扩展名)。' },
|
|
275
|
+
action: { type: 'string', enum: ['accept', 'retract', 'supersede'], description: '裁决动作。' },
|
|
276
|
+
supersedes: { type: 'string', description: 'action=supersede 时:被取代的笔记名。' },
|
|
277
|
+
decision: {
|
|
278
|
+
type: 'object',
|
|
279
|
+
description: '需要用户权威时必须提供。',
|
|
280
|
+
properties: { askCallId: { type: 'string' }, answer: { type: 'string' }, attested: { type: 'boolean' } },
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
required: ['note', 'action'],
|
|
284
|
+
execute: async (args, exec) => {
|
|
285
|
+
const led = ctx.get('research.expLedger');
|
|
286
|
+
const eg = () => ctx.get('research.engineGit');
|
|
287
|
+
const root = led.root(args, exec);
|
|
288
|
+
try { await led.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
|
|
289
|
+
const name = led.slugify(args?.note ?? '');
|
|
290
|
+
const action = args?.action;
|
|
291
|
+
if (!['accept', 'retract', 'supersede'].includes(action)) return { text: '拒绝:action 只能是 accept / retract / supersede。' };
|
|
292
|
+
const notes = await svc.listNotes(root);
|
|
293
|
+
const note = notes.find((n) => n.name === name);
|
|
294
|
+
if (!note || !note.front) return { text: `拒绝:找不到带结构头的笔记 ${name}。` };
|
|
295
|
+
const front = { ...note.front };
|
|
296
|
+
const cur = front.status ?? 'proposed';
|
|
297
|
+
if (['superseded', 'retracted'].includes(cur)) return { text: `拒绝:笔记 ${name} 已是 ${cur},不能再次裁决(状态只前进,不回头)。` };
|
|
298
|
+
let decision = null;
|
|
299
|
+
const hasEvidence = Array.isArray(front.evidence) && front.evidence.length > 0;
|
|
300
|
+
const needsUser = action === 'retract' || action === 'supersede'
|
|
301
|
+
|| (action === 'accept' && !hasEvidence);
|
|
302
|
+
if (needsUser) {
|
|
303
|
+
const v = led.verifyDecision(exec, args?.decision ?? {});
|
|
304
|
+
if (!v.ok) {
|
|
305
|
+
const why = action === 'accept'
|
|
306
|
+
? '这条笔记没有任何证据指针,生效只能由用户裁决'
|
|
307
|
+
: action === 'retract' ? '撤回已生效结论必须由用户裁决' : '取代一条结论必须由用户裁决';
|
|
308
|
+
return { text: `${v.text}\n(${why}。)` };
|
|
309
|
+
}
|
|
310
|
+
decision = v.decision;
|
|
311
|
+
} else {
|
|
312
|
+
for (const e of front.evidence ?? []) {
|
|
313
|
+
const r = await svc.resolvePointer(root, e);
|
|
314
|
+
if (!r.ok) return { text: `拒绝:证据已失效 —— ${r.why}\n修法:先补齐证据,再裁决。` };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const guard = led.guard(root, ['.kb/**']);
|
|
318
|
+
if (!guard.ok) return { text: guard.text };
|
|
319
|
+
const touch = [posix(join(NOTES, `${name}.md`))];
|
|
320
|
+
if (action === 'accept') {
|
|
321
|
+
front.status = 'accepted';
|
|
322
|
+
front.authority = decision ? 'user' : 'evidence';
|
|
323
|
+
if (decision) front.decidedBy = decision;
|
|
324
|
+
} else if (action === 'retract') {
|
|
325
|
+
front.status = 'retracted';
|
|
326
|
+
front.authority = 'user';
|
|
327
|
+
front.decidedBy = decision;
|
|
328
|
+
} else {
|
|
329
|
+
const target = led.slugify(args?.supersedes ?? '');
|
|
330
|
+
if (!target) return { text: '拒绝:supersede 必须给出 supersedes(被取代的笔记名)。' };
|
|
331
|
+
const old = notes.find((n) => n.name === target);
|
|
332
|
+
if (!old || !old.front) return { text: `拒绝:找不到被取代的笔记 ${target}。` };
|
|
333
|
+
if ((old.front.status ?? 'proposed') !== 'accepted') return { text: `拒绝:${target} 当前不是 accepted,不能被取代。` };
|
|
334
|
+
front.status = 'accepted';
|
|
335
|
+
front.authority = 'user';
|
|
336
|
+
front.decidedBy = decision;
|
|
337
|
+
front.supersedes = [...new Set([...(front.supersedes ?? []), target])];
|
|
338
|
+
const oldFront = { ...old.front, status: 'superseded', supersededBy: name, updatedAt: iso() };
|
|
339
|
+
touch.push(await svc.writeNote(root, target, oldFront, old.body));
|
|
340
|
+
}
|
|
341
|
+
front.updatedAt = iso();
|
|
342
|
+
const rel = await svc.writeNote(root, name, front, note.body);
|
|
343
|
+
const stored = eg().record(root, [...new Set([rel, ...touch])], `note_adjudicate: ${name} ${action}`);
|
|
344
|
+
const rec = await led.ledgerAppend(root, { record: 'note', name, kind: front.kind, status: front.status, authority: front.authority, ...(decision ? { decidedBy: decision } : {}) }, { commit: stored.commit ?? undefined, actor: decision ? 'user' : 'engine' });
|
|
345
|
+
if (decision) {
|
|
346
|
+
await led.ledgerAppend(root, {
|
|
347
|
+
record: 'decision', kind: 'note', id: name,
|
|
348
|
+
askCallId: decision.askCallId, question: decision.question, answer: decision.answer,
|
|
349
|
+
sessionId: decision.sessionId, ...(decision.attested ? { attested: true } : {}),
|
|
350
|
+
}, { actor: 'user' });
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
text: [
|
|
354
|
+
`笔记 ${name} 状态:${cur} → ${front.status}(权威:${front.authority})`,
|
|
355
|
+
...(decision ? [`用户答复原文:${decision.answer}`] : ['依据:证据逐条复核可解析']),
|
|
356
|
+
...(action === 'supersede' ? [`被取代:${args.supersedes}(状态置 superseded,内容保留)`] : []),
|
|
357
|
+
'建议:view_render 重新生成派生视图。',
|
|
358
|
+
`台账第 ${rec.seq} 行。`,
|
|
359
|
+
].join('\n'),
|
|
360
|
+
};
|
|
361
|
+
},
|
|
362
|
+
presentCall: (args) => genericCall(`裁决笔记 ${args?.note ?? ''}`.trim(), 'edit', [`${NOTES}/${args?.note ?? ''}.md`]),
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// ---------- note_query ----------
|
|
366
|
+
registerTextTool(ctx, {
|
|
367
|
+
name: 'note_query',
|
|
368
|
+
description: '只读检索笔记:默认只列已生效(accepted)结论,proposed 单列并标状态;可按 kind/status/scope/证据过滤。',
|
|
369
|
+
properties: {
|
|
370
|
+
root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
|
|
371
|
+
kind: { type: 'string', description: '按类型过滤。' },
|
|
372
|
+
status: { type: 'string', description: '按状态过滤;缺省只返回 accepted。' },
|
|
373
|
+
scope: { type: 'string', description: '按范围过滤。' },
|
|
374
|
+
evidence: { type: 'string', description: '按证据指针过滤(runId 或文件路径片段)。' },
|
|
375
|
+
query: { type: 'string', description: '在名称/摘要/正文里做子串匹配。' },
|
|
376
|
+
limit: { type: 'number', description: '返回条数上限(默认 30)。' },
|
|
377
|
+
},
|
|
378
|
+
execute: async (args, exec) => {
|
|
379
|
+
const led = ctx.get('research.expLedger');
|
|
380
|
+
const root = led.root(args, exec);
|
|
381
|
+
try { await led.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
|
|
382
|
+
const all = await svc.listNotes(root);
|
|
383
|
+
const limit = Number.isFinite(Number(args?.limit)) && Number(args.limit) > 0 ? Math.floor(Number(args.limit)) : 30;
|
|
384
|
+
const match = (n) => {
|
|
385
|
+
const f = n.front ?? {};
|
|
386
|
+
if (typeof args?.kind === 'string' && f.kind !== args.kind) return false;
|
|
387
|
+
if (typeof args?.scope === 'string' && (f.scope ?? '') !== args.scope) return false;
|
|
388
|
+
if (typeof args?.evidence === 'string') {
|
|
389
|
+
if (!(f.evidence ?? []).some((e) => String(e?.ref ?? '').includes(args.evidence))) return false;
|
|
390
|
+
}
|
|
391
|
+
if (typeof args?.query === 'string' && args.query.trim()) {
|
|
392
|
+
const q = args.query.trim().toLowerCase();
|
|
393
|
+
if (!`${n.name} ${f.description ?? ''} ${n.body}`.toLowerCase().includes(q)) return false;
|
|
394
|
+
}
|
|
395
|
+
return true;
|
|
396
|
+
};
|
|
397
|
+
const pool = all.filter(match);
|
|
398
|
+
const wanted = typeof args?.status === 'string' ? args.status : null;
|
|
399
|
+
const accepted = wanted ? pool.filter((n) => (n.front?.status ?? 'proposed') === wanted) : pool.filter((n) => (n.front?.status ?? 'proposed') === 'accepted');
|
|
400
|
+
const proposed = wanted ? [] : pool.filter((n) => (n.front?.status ?? 'proposed') === 'proposed');
|
|
401
|
+
const other = wanted ? [] : pool.filter((n) => !['accepted', 'proposed'].includes(n.front?.status ?? 'proposed'));
|
|
402
|
+
const fmt = (n) => {
|
|
403
|
+
const f = n.front ?? {};
|
|
404
|
+
return `- ${n.name} [${f.kind ?? '?'} / ${f.status ?? '?'} / ${f.authority ?? '?'}] ${f.description ?? ''}`
|
|
405
|
+
+ ((f.evidence ?? []).length ? ` 证据:${(f.evidence ?? []).map((e) => e.ref).join('、')}` : '')
|
|
406
|
+
+ (f.scope ? ` 范围:${f.scope}` : '')
|
|
407
|
+
+ (f.decidedBy ? ` 裁决:${f.decidedBy.answer}` : '');
|
|
408
|
+
};
|
|
409
|
+
const lines = [];
|
|
410
|
+
if (wanted) {
|
|
411
|
+
if (accepted.length) lines.push(`匹配 status=${wanted} 的笔记 ${accepted.length} 条:`, ...accepted.slice(0, limit).map(fmt));
|
|
412
|
+
else lines.push(`没有 status=${wanted} 的笔记。`);
|
|
413
|
+
} else {
|
|
414
|
+
if (accepted.length) lines.push(`生效结论 ${accepted.length} 条:`, ...accepted.slice(0, limit).map(fmt));
|
|
415
|
+
else lines.push('没有生效结论。');
|
|
416
|
+
if (proposed.length) lines.push('', `待裁决(proposed,尚不生效、不得当规则引用)${proposed.length} 条:`, ...proposed.slice(0, limit).map(fmt));
|
|
417
|
+
if (other.length) lines.push('', `已失效 / 被取代 ${other.length} 条:`, ...other.slice(0, limit).map(fmt));
|
|
418
|
+
}
|
|
419
|
+
return { text: lines.join('\n') };
|
|
420
|
+
},
|
|
421
|
+
presentCall: () => genericCall('检索知识库', 'read', [NOTES]),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ---------- view_render ----------
|
|
425
|
+
registerTextTool(ctx, {
|
|
426
|
+
name: 'view_render',
|
|
427
|
+
description: '渲染派生视图(唯一写入者):kind=notes-skill 生成工程笔记;report 生成运行报表;index 生成笔记索引。三者头部都标「派生视图 + 生成时间 + 来源」。',
|
|
428
|
+
properties: {
|
|
429
|
+
root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
|
|
430
|
+
kind: { type: 'string', enum: ['notes-skill', 'report', 'index'], description: '派生视图类型。' },
|
|
431
|
+
},
|
|
432
|
+
required: ['kind'],
|
|
433
|
+
execute: async (args, exec) => {
|
|
434
|
+
const led = ctx.get('research.expLedger');
|
|
435
|
+
const eg = () => ctx.get('research.engineGit');
|
|
436
|
+
const root = led.root(args, exec);
|
|
437
|
+
try { await led.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
|
|
438
|
+
const kind = args?.kind;
|
|
439
|
+
if (!['notes-skill', 'report', 'index'].includes(kind)) return { text: '拒绝:kind 只能是 notes-skill / report / index。' };
|
|
440
|
+
const doc = await led.readProject(root);
|
|
441
|
+
const projectId = doc.project?.id ?? basename(root);
|
|
442
|
+
const notes = await svc.listNotes(root);
|
|
443
|
+
const stamp = iso();
|
|
444
|
+
let paths = []; let count = 0; let pending = 0;
|
|
445
|
+
|
|
446
|
+
if (kind === 'notes-skill') {
|
|
447
|
+
const convs = (Array.isArray(doc.conventions) ? doc.conventions : []).filter((c) => (c.status ?? 'active') === 'active');
|
|
448
|
+
const claims = notes.filter((n) => n.front?.kind === 'claim' && n.front?.status === 'accepted');
|
|
449
|
+
pending = notes.filter((n) => (n.front?.status ?? 'proposed') === 'proposed').length;
|
|
450
|
+
const lines = [
|
|
451
|
+
`# ${doc.project?.name ?? projectId} —— 工程笔记`,
|
|
452
|
+
'',
|
|
453
|
+
`> 派生视图:由工具从「生效约定 + 已生效结论」渲染,生成时间 ${stamp},来源:工程约定与知识记录。`,
|
|
454
|
+
'> 请勿手改本文件(手改会被体检点名);知识变化后重新渲染即可。',
|
|
455
|
+
'',
|
|
456
|
+
'## 生效约定(用户权威)',
|
|
457
|
+
];
|
|
458
|
+
if (!convs.length) lines.push('', '(暂无)');
|
|
459
|
+
for (const c of convs) {
|
|
460
|
+
lines.push('', `### ${c.statement}`, `- 范围:${c.scope ?? '全工程'}`, `- 来源:${c.source}`, `- 依据答复:${c.decision?.answer ?? '(缺失)'}`, `- 标识:${c.id}`);
|
|
461
|
+
}
|
|
462
|
+
lines.push('', '## 已生效结论(证据权威)');
|
|
463
|
+
if (!claims.length) lines.push('', '(暂无)');
|
|
464
|
+
for (const n of claims) {
|
|
465
|
+
const f = n.front;
|
|
466
|
+
lines.push('', `### ${f.description}`,
|
|
467
|
+
`- 主张:${String(n.body).trim().split(/\r?\n/)[0] ?? ''}`,
|
|
468
|
+
`- 成立条件:${f.scope ?? '(未写)'}`,
|
|
469
|
+
`- 证据:${(f.evidence ?? []).map((e) => e.ref).join('、') || '(无)'}`,
|
|
470
|
+
`- 标识:${n.name}`);
|
|
471
|
+
}
|
|
472
|
+
lines.push('', '## 待裁决', '', pending ? `共 ${pending} 条提议尚未裁决(不进本文件的结论区)。` : '(无)', '');
|
|
473
|
+
const rel = posix(join('skills', projectId, 'SKILL.md'));
|
|
474
|
+
await atomicWrite(join(root, rel), lines.join('\n'));
|
|
475
|
+
paths = [rel]; count = convs.length + claims.length;
|
|
476
|
+
} else if (kind === 'report') {
|
|
477
|
+
const fold = await led.ledgerFold(root);
|
|
478
|
+
const runs = [...fold.runs.values()];
|
|
479
|
+
const cell = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
|
480
|
+
const rows = [['runId', '模板', '阶段', '状态', '参数', '产出数', '观察值', '说明'].join(',')];
|
|
481
|
+
for (const r of runs) {
|
|
482
|
+
const man = await readJson(join(root, 'experiments', r.runId, 'manifest.json'), { entries: [] });
|
|
483
|
+
const obs = await readJson(join(root, 'experiments', r.runId, 'observations.json'), { observations: [] });
|
|
484
|
+
rows.push([cell(r.runId), cell(r.template), cell(r.stage ?? ''), cell(r.status), cell(JSON.stringify(r.params ?? {})),
|
|
485
|
+
cell((man.entries ?? []).length), cell((obs.observations ?? []).map((o) => `${o.field}=${o.value}`).join('; ')), cell(r.detail ?? '')].join(','));
|
|
486
|
+
}
|
|
487
|
+
await atomicWrite(join(root, '_report', 'runs.csv'), `\ufeff${rows.join('\n')}\n`);
|
|
488
|
+
const proposals = notes.filter((n) => (n.front?.status ?? 'proposed') === 'proposed');
|
|
489
|
+
pending = proposals.length;
|
|
490
|
+
const summary = {
|
|
491
|
+
derived: true, generatedAt: stamp, source: 'registry.jsonl',
|
|
492
|
+
project: projectId,
|
|
493
|
+
counts: {
|
|
494
|
+
runs: runs.length,
|
|
495
|
+
done: runs.filter((r) => r.status === 'done').length,
|
|
496
|
+
failed: runs.filter((r) => r.status === 'failed').length,
|
|
497
|
+
invalidated: runs.filter((r) => r.status === 'invalidated').length,
|
|
498
|
+
},
|
|
499
|
+
runs: runs.map((r) => ({ runId: r.runId, template: r.template, stage: r.stage ?? null, status: r.status, detail: r.detail ?? null })),
|
|
500
|
+
pendingAdjudication: proposals.map((n) => ({ name: n.name, kind: n.front?.kind, description: n.front?.description })),
|
|
501
|
+
conflicts: runs.filter((r) => r.status === 'failed').map((r) => ({ runId: r.runId, detail: r.detail ?? null })),
|
|
502
|
+
};
|
|
503
|
+
await atomicWrite(join(root, '_report', 'summary.json'), JSON.stringify(summary, null, 2) + '\n');
|
|
504
|
+
paths = ['_report/runs.csv', '_report/summary.json']; count = runs.length;
|
|
505
|
+
} else {
|
|
506
|
+
const items = notes.map((n) => ({
|
|
507
|
+
name: n.name, kind: n.front?.kind ?? null, status: n.front?.status ?? null,
|
|
508
|
+
authority: n.front?.authority ?? null, description: n.front?.description ?? null,
|
|
509
|
+
scope: n.front?.scope ?? null,
|
|
510
|
+
evidence: (n.front?.evidence ?? []).map((e) => e.ref),
|
|
511
|
+
updatedAt: n.front?.updatedAt ?? null,
|
|
512
|
+
}));
|
|
513
|
+
const idx = {
|
|
514
|
+
derived: true, generatedAt: stamp, source: '.kb/notes',
|
|
515
|
+
count: items.length,
|
|
516
|
+
byStatus: items.reduce((acc, i) => { const k = i.status ?? 'unknown'; acc[k] = (acc[k] ?? 0) + 1; return acc; }, {}),
|
|
517
|
+
notes: items,
|
|
518
|
+
};
|
|
519
|
+
pending = items.filter((i) => i.status === 'proposed').length;
|
|
520
|
+
await atomicWrite(join(root, '.kb', 'index.yaml'), yaml.dump(idx, { lineWidth: -1, noRefs: true }));
|
|
521
|
+
paths = ['.kb/index.yaml']; count = items.length;
|
|
522
|
+
}
|
|
523
|
+
const stored = eg().record(root, paths, `view_render: ${kind}`);
|
|
524
|
+
const rec = await led.ledgerAppend(root, { record: 'render', kind, paths, count }, { commit: stored.commit ?? undefined });
|
|
525
|
+
return {
|
|
526
|
+
text: [
|
|
527
|
+
`已渲染派生视图(${kind}):${paths.join('、')}`,
|
|
528
|
+
`条目数:${count}${pending ? ` 待裁决:${pending}` : ''}`,
|
|
529
|
+
`生成时间:${stamp}(文件头已标注「派生视图」)`,
|
|
530
|
+
`台账第 ${rec.seq} 行。`,
|
|
531
|
+
].join('\n'),
|
|
532
|
+
};
|
|
533
|
+
},
|
|
534
|
+
presentCall: (args) => genericCall(`渲染派生视图(${args?.kind ?? '?'})`, 'edit', []),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export { apply };
|