@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,388 @@
|
|
|
1
|
+
// stage-ctrl —— 状态机(3 工具)
|
|
2
|
+
//
|
|
3
|
+
// 拥有数据:pipeline.yaml(环境:阶段机)、.research/state.json(派生状态,由 stage_goto 物化)。
|
|
4
|
+
// 台账事件由 exp-ledger 的 ledger_append 服务追加(跨域只走服务)。
|
|
5
|
+
// 改名策略:旧 id 只 deprecated + replacedBy,永不删除(否则历史 run 的 stage 引用会断)。
|
|
6
|
+
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { readFile, writeFile, mkdir, stat, rename } 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 PIPELINE_YAML = 'pipeline.yaml';
|
|
31
|
+
const REGISTRY = 'registry.jsonl';
|
|
32
|
+
const STATE = '.research/state.json';
|
|
33
|
+
// 词边界替换:不误伤用户数据里的同形子串(如 usergit-probe / shanghai),但独立出现的版本库词汇一律替换。
|
|
34
|
+
const SCRUB = /(^|[^A-Za-z0-9_-])(git|commit|HEAD|diff|hash|sha)(?![A-Za-z0-9_-])/gi;
|
|
35
|
+
|
|
36
|
+
export const name = 'stage-ctrl';
|
|
37
|
+
export const inject = ['tools'];
|
|
38
|
+
|
|
39
|
+
function iso() { return new Date().toISOString(); }
|
|
40
|
+
function sha256(s) { return createHash('sha256').update(s).digest('hex'); }
|
|
41
|
+
function scrub(t) { return String(t ?? '').replace(SCRUB, (_m, pre) => `${pre}·`); }
|
|
42
|
+
async function pathExists(p) { try { await stat(p); return true; } catch { return false; } }
|
|
43
|
+
async function ensureDir(p) { await mkdir(p, { recursive: true }); }
|
|
44
|
+
async function atomicWrite(p, body) {
|
|
45
|
+
await ensureDir(dirname(p));
|
|
46
|
+
const tmp = `${p}.tmp-${sha256(iso() + Math.random()).slice(0, 8)}`;
|
|
47
|
+
await writeFile(tmp, body, 'utf8');
|
|
48
|
+
await rename(tmp, p);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function emptyPipeline() {
|
|
52
|
+
return { schemaVersion: SCHEMA_VERSION, graphVersion: 0, entry: null, stages: [], edges: [] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function applyGraph(doc, args) {
|
|
56
|
+
const next = JSON.parse(JSON.stringify(doc));
|
|
57
|
+
const action = args?.action;
|
|
58
|
+
const fatal = [];
|
|
59
|
+
if (action === 'stage') {
|
|
60
|
+
const id = typeof args?.id === 'string' ? args.id.trim() : '';
|
|
61
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(id)) fatal.push(`阶段 id 非法:${JSON.stringify(id)}(只允许小写字母/数字/下划线/连字符)`);
|
|
62
|
+
const existing = next.stages.find((s) => s.id === id);
|
|
63
|
+
if (args?.deprecate === true) {
|
|
64
|
+
if (!existing) fatal.push(`要停用的阶段不存在:${id}`);
|
|
65
|
+
else {
|
|
66
|
+
existing.status = 'deprecated';
|
|
67
|
+
if (typeof args?.replacedBy === 'string' && args.replacedBy.trim()) existing.replacedBy = args.replacedBy.trim();
|
|
68
|
+
existing.deprecatedAt = iso();
|
|
69
|
+
}
|
|
70
|
+
} else if (existing && args?.overwrite !== true) {
|
|
71
|
+
fatal.push(`阶段已存在:${id}(改名请用「新 id + 旧 id deprecate」;确实要改显示名用 overwrite)`);
|
|
72
|
+
} else {
|
|
73
|
+
const name = typeof args?.name === 'string' && args.name.trim() ? args.name.trim() : id;
|
|
74
|
+
if (existing) { existing.name = name; existing.status = 'active'; }
|
|
75
|
+
else next.stages.push({ id, name, status: 'active' });
|
|
76
|
+
}
|
|
77
|
+
} else if (action === 'edge') {
|
|
78
|
+
const from = typeof args?.from === 'string' ? args.from.trim() : '';
|
|
79
|
+
const to = typeof args?.to === 'string' ? args.to.trim() : '';
|
|
80
|
+
if (!from || !to) fatal.push('边需要 from 与 to');
|
|
81
|
+
else if (!next.stages.some((s) => s.id === from)) fatal.push(`边起点未声明:${from}`);
|
|
82
|
+
else if (!next.stages.some((s) => s.id === to)) fatal.push(`边终点未声明:${to}`);
|
|
83
|
+
else if (from === to) fatal.push(`自环:${from}->${to}`);
|
|
84
|
+
else if (next.edges.some((e) => e.from === from && e.to === to)) fatal.push(`边重复:${from}->${to}`);
|
|
85
|
+
else next.edges.push({ from, to, ...(typeof args?.note === 'string' && args.note.trim() ? { note: args.note.trim() } : {}) });
|
|
86
|
+
} else {
|
|
87
|
+
fatal.push(`action 必须是 stage 或 edge(收到 ${JSON.stringify(action)})`);
|
|
88
|
+
}
|
|
89
|
+
if (typeof args?.entry === 'string' && args.entry.trim()) {
|
|
90
|
+
const e = args.entry.trim();
|
|
91
|
+
if (!next.stages.some((s) => s.id === e)) fatal.push(`入口不是已知阶段:${e}`);
|
|
92
|
+
else if (next.edges.some((x) => x.to === e)) fatal.push(`入口阶段有入边:${e}`);
|
|
93
|
+
else next.entry = e;
|
|
94
|
+
}
|
|
95
|
+
return { next, fatal };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function checkGraph(doc) {
|
|
99
|
+
const fatal = []; const warn = [];
|
|
100
|
+
const stages = Array.isArray(doc?.stages) ? doc.stages : [];
|
|
101
|
+
const edges = Array.isArray(doc?.edges) ? doc.edges : [];
|
|
102
|
+
const ids = new Set();
|
|
103
|
+
for (const s of stages) {
|
|
104
|
+
if (typeof s?.id !== 'string' || s.id === '') { fatal.push('存在缺少 id 的阶段'); continue; }
|
|
105
|
+
if (ids.has(s.id)) fatal.push(`阶段 id 重复:${s.id}`);
|
|
106
|
+
ids.add(s.id);
|
|
107
|
+
}
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
for (const e of edges) {
|
|
110
|
+
if (typeof e?.from !== 'string' || typeof e?.to !== 'string') { fatal.push('边必须含 from/to'); continue; }
|
|
111
|
+
const key = `${e.from}->${e.to}`;
|
|
112
|
+
if (seen.has(key)) fatal.push(`边重复:${key}`);
|
|
113
|
+
seen.add(key);
|
|
114
|
+
if (!ids.has(e.from)) fatal.push(`边起点未声明:${e.from}`);
|
|
115
|
+
if (!ids.has(e.to)) fatal.push(`边终点未声明:${e.to}`);
|
|
116
|
+
if (e.from === e.to) fatal.push(`自环:${key}`);
|
|
117
|
+
}
|
|
118
|
+
const entry = doc?.entry;
|
|
119
|
+
if (entry !== undefined && entry !== null && entry !== '') {
|
|
120
|
+
if (!ids.has(entry)) fatal.push(`入口不是已知阶段:${entry}`);
|
|
121
|
+
else if (edges.some((e) => e?.to === entry)) fatal.push(`入口阶段有入边:${entry}`);
|
|
122
|
+
}
|
|
123
|
+
if (stages.length > 0 && (entry === undefined || entry === null || entry === '')) warn.push('有阶段但未设入口(entry)');
|
|
124
|
+
const adj = new Map();
|
|
125
|
+
for (const e of edges) {
|
|
126
|
+
if (!adj.has(e.from)) adj.set(e.from, new Set());
|
|
127
|
+
adj.get(e.from).add(e.to);
|
|
128
|
+
}
|
|
129
|
+
const reach = new Set(); const stack = entry ? [entry] : [];
|
|
130
|
+
while (stack.length) {
|
|
131
|
+
const n = stack.pop();
|
|
132
|
+
if (reach.has(n)) continue;
|
|
133
|
+
reach.add(n);
|
|
134
|
+
for (const t of adj.get(n) ?? []) stack.push(t);
|
|
135
|
+
}
|
|
136
|
+
for (const s of stages) if (!reach.has(s.id)) warn.push(`阶段不可达(从入口出发):${s.id}`);
|
|
137
|
+
return { fatal, warn };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildService(ctx) {
|
|
141
|
+
const led = () => ctx.get('research.expLedger');
|
|
142
|
+
return {
|
|
143
|
+
async readPipeline(root) {
|
|
144
|
+
const p = join(root, PIPELINE_YAML);
|
|
145
|
+
if (!(await pathExists(p))) return null;
|
|
146
|
+
try { const d = yaml.load(await readFile(p, 'utf8')); return d && typeof d === 'object' ? d : null; } catch { return null; }
|
|
147
|
+
},
|
|
148
|
+
async view(args, exec) {
|
|
149
|
+
const root = led().root(args, exec);
|
|
150
|
+
const pipeline = await this.readPipeline(root);
|
|
151
|
+
const fold = await led().ledgerFold(root);
|
|
152
|
+
const current = fold.currentStage ?? pipeline?.entry ?? null;
|
|
153
|
+
const graph = pipeline ? checkGraph(pipeline) : { fatal: [], warn: [] };
|
|
154
|
+
return { root, pipeline, current, graph, fold };
|
|
155
|
+
},
|
|
156
|
+
async goto(root, from, to) {
|
|
157
|
+
const pipeline = await this.readPipeline(root);
|
|
158
|
+
if (!pipeline) return { ok: false, text: '拒绝:尚未声明阶段机(pipeline.yaml 缺失)。\n修法:先用 stage_declare 声明阶段与边。' };
|
|
159
|
+
const edges = Array.isArray(pipeline.edges) ? pipeline.edges : [];
|
|
160
|
+
const legal = edges.filter((e) => e.from === from).map((e) => e.to);
|
|
161
|
+
if (!edges.some((e) => e.from === from && e.to === to)) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
text: `拒绝:${from} → ${to} 不是已声明的边。\n合法去向:${legal.length ? legal.join('、') : '(无,当前阶段是终点)'}\n`
|
|
165
|
+
+ '修法:走合法边;若这条转移确实该存在,先用 stage_declare action=edge 声明它。',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return { ok: true, legal };
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function genericCall(title, kind, paths) {
|
|
174
|
+
return { card: 'generic', title, kind, ...(paths && paths.length ? { locations: paths.map((p) => ({ path: p })) } : {}) };
|
|
175
|
+
}
|
|
176
|
+
function registerTextTool(ctx, tool) {
|
|
177
|
+
ctx.tools.register({
|
|
178
|
+
name: tool.name,
|
|
179
|
+
description: tool.description,
|
|
180
|
+
parameters: { type: 'object', properties: tool.properties || {}, ...(tool.required?.length ? { required: tool.required } : {}) },
|
|
181
|
+
output: {
|
|
182
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } }, required: ['text'] },
|
|
183
|
+
render: (_a, v) => [{ type: 'text', text: v.text }],
|
|
184
|
+
},
|
|
185
|
+
...(tool.presentCall ? { presentCall: tool.presentCall } : {}),
|
|
186
|
+
execute: async (args, exec) => {
|
|
187
|
+
const r = await tool.execute(args, exec);
|
|
188
|
+
return { text: scrub(typeof r?.text === 'string' ? r.text : String(r?.text ?? '')) };
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function apply(ctx) {
|
|
194
|
+
const svc = buildService(ctx);
|
|
195
|
+
ctx.provide('research.stageCtrl', svc);
|
|
196
|
+
|
|
197
|
+
registerTextTool(ctx, {
|
|
198
|
+
name: 'stage_declare',
|
|
199
|
+
description: '声明阶段机:action=stage 增改阶段、action=edge 增边;可同时设 entry。图有 fatal 违规时整体拒绝,图版本每次成功声明递增。',
|
|
200
|
+
properties: {
|
|
201
|
+
root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
|
|
202
|
+
action: { type: 'string', enum: ['stage', 'edge'], description: 'stage=声明阶段;edge=声明转移边。' },
|
|
203
|
+
id: { type: 'string', description: 'action=stage:阶段 id。' },
|
|
204
|
+
name: { type: 'string', description: 'action=stage:阶段显示名。' },
|
|
205
|
+
from: { type: 'string', description: 'action=edge:起点阶段 id。' },
|
|
206
|
+
to: { type: 'string', description: 'action=edge:终点阶段 id。' },
|
|
207
|
+
entry: { type: 'string', description: '设入口阶段(必须是已知阶段且无入边)。' },
|
|
208
|
+
deprecate: { type: 'boolean', description: 'action=stage:停用该阶段(保留历史引用)。' },
|
|
209
|
+
replacedBy: { type: 'string', description: 'deprecate 时:替代它的新阶段 id。' },
|
|
210
|
+
overwrite: { type: 'boolean', description: 'action=stage:允许覆盖已存在阶段的显示名。' },
|
|
211
|
+
note: { type: 'string', description: 'action=edge:边备注。' },
|
|
212
|
+
},
|
|
213
|
+
required: ['action'],
|
|
214
|
+
execute: async (args, exec) => {
|
|
215
|
+
const led = ctx.get('research.expLedger');
|
|
216
|
+
const eg = ctx.get('research.engineGit');
|
|
217
|
+
const root = led.root(args, exec);
|
|
218
|
+
try { await led.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
|
|
219
|
+
const guard = led.guard(root, [PIPELINE_YAML, REGISTRY]);
|
|
220
|
+
if (!guard.ok) return { text: guard.text };
|
|
221
|
+
const current = (await svc.readPipeline(root)) ?? emptyPipeline();
|
|
222
|
+
const { next, fatal } = applyGraph(current, args ?? {});
|
|
223
|
+
if (fatal.length) return { text: `拒绝:${fatal.join(';')}\n修法:先声明缺失的阶段,再声明边;改名用「新 id + 旧 id deprecate」。` };
|
|
224
|
+
const graph = checkGraph(next);
|
|
225
|
+
if (graph.fatal.length) return { text: `拒绝(图有致命违规):\n${graph.fatal.map((f) => ` - ${f}`).join('\n')}\n修法:先修正这些条目再提交。` };
|
|
226
|
+
next.graphVersion = (Number.isInteger(current.graphVersion) ? current.graphVersion : 1) + 1;
|
|
227
|
+
await led.writeYamlFile(join(root, PIPELINE_YAML), next);
|
|
228
|
+
const stored = eg.record(root, [PIPELINE_YAML], `stage_declare: ${args.action}`);
|
|
229
|
+
// 声明事件用 record:'pipeline'(不是 'stage')——只有 stage_goto 产生真正的转移,
|
|
230
|
+
// 否则 fold 会把声明边误当成「当前阶段」。
|
|
231
|
+
const rec = await led.ledgerAppend(root, {
|
|
232
|
+
record: 'pipeline', action: args.action,
|
|
233
|
+
...(args.action === 'stage' ? { stage: args.id ?? null, deprecated: args.deprecate === true } : {}),
|
|
234
|
+
...(args.action === 'edge' ? { edgeFrom: args.from ?? null, edgeTo: args.to ?? null } : {}),
|
|
235
|
+
entry: next.entry ?? null,
|
|
236
|
+
graphVersion: next.graphVersion,
|
|
237
|
+
}, { commit: stored.commit ?? undefined });
|
|
238
|
+
const lines = [
|
|
239
|
+
`阶段机已更新(图版本 ${next.graphVersion}):${args.action === 'stage' ? `阶段 ${args.deprecate ? '停用' : '声明'} ${args.id}` : `边 ${args.from} → ${args.to}`}`,
|
|
240
|
+
`当前图:${next.stages.length} 个阶段 / ${next.edges.length} 条边 / 入口 ${next.entry ?? '(未设)'}`,
|
|
241
|
+
...(graph.warn.length ? [`提示:${graph.warn.join(';')}`] : []),
|
|
242
|
+
`台账第 ${rec.seq} 行。`,
|
|
243
|
+
];
|
|
244
|
+
return { text: lines.join('\n') };
|
|
245
|
+
},
|
|
246
|
+
presentCall: (args) => genericCall(`声明阶段机(${args?.action ?? '?'})`, 'edit', [PIPELINE_YAML]),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
registerTextTool(ctx, {
|
|
250
|
+
name: 'stage_read',
|
|
251
|
+
description: '只读阶段机视图:阶段、边、入口、当前节点、合法去向、fatal/warn。',
|
|
252
|
+
properties: { root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' } },
|
|
253
|
+
execute: async (args, exec) => {
|
|
254
|
+
const led = ctx.get('research.expLedger');
|
|
255
|
+
const root = led.root(args, exec);
|
|
256
|
+
try { await led.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
|
|
257
|
+
const v = await svc.view(args, exec);
|
|
258
|
+
if (!v.pipeline) return { text: '阶段机尚未声明(pipeline.yaml 缺失)。\n修法:stage_declare action=stage 声明第一个阶段,再用 action=edge 连边。' };
|
|
259
|
+
const legal = (v.pipeline.edges ?? []).filter((e) => e.from === v.current).map((e) => e.to);
|
|
260
|
+
const lines = [
|
|
261
|
+
`阶段(${v.pipeline.stages.length}):${v.pipeline.stages.map((s) => `${s.id}${s.status === 'deprecated' ? '(已停用)' : ''}`).join('、')}`,
|
|
262
|
+
`边(${v.pipeline.edges.length}):${v.pipeline.edges.map((e) => `${e.from}→${e.to}`).join('、') || '(无)'}`,
|
|
263
|
+
`入口:${v.pipeline.entry ?? '(未设)'} 图版本:${v.pipeline.graphVersion}`,
|
|
264
|
+
`当前阶段:${v.current ?? '(未进入任何阶段)'}`,
|
|
265
|
+
`合法去向:${legal.length ? legal.join('、') : '(无)'}`,
|
|
266
|
+
...(v.graph.fatal.length ? [`致命问题:${v.graph.fatal.join(';')}`] : []),
|
|
267
|
+
...(v.graph.warn.length ? [`提示:${v.graph.warn.join(';')}`] : []),
|
|
268
|
+
];
|
|
269
|
+
return { text: lines.join('\n') };
|
|
270
|
+
},
|
|
271
|
+
presentCall: (args) => genericCall('读阶段机', 'read', [PIPELINE_YAML]),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
registerTextTool(ctx, {
|
|
275
|
+
name: 'stage_goto',
|
|
276
|
+
description: '推进阶段(沿已声明的边)或重放物化派生状态:replay=true 时只用台账重放重建 .research/state.json,不产生任何转移。若重放会改变派生状态当前断言的值(两个来源互相矛盾),必须由用户裁决。',
|
|
277
|
+
properties: {
|
|
278
|
+
root: { type: 'string', description: '工程根目录;缺省 = 会话工作区。' },
|
|
279
|
+
from: { type: 'string', description: '起点阶段;缺省 = 当前阶段。' },
|
|
280
|
+
to: { type: 'string', description: '目标阶段;replay=true 时可省略。' },
|
|
281
|
+
replay: { type: 'boolean', description: 'true = 只按台账重放物化派生状态,不推进阶段(派生状态丢了/旧了/形状不对时用)。' },
|
|
282
|
+
decision: {
|
|
283
|
+
type: 'object',
|
|
284
|
+
description: '重放与现有派生状态冲突时必须提供:用户裁决引用。',
|
|
285
|
+
properties: { askCallId: { type: 'string' }, answer: { type: 'string' }, attested: { type: 'boolean' } },
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
required: [],
|
|
289
|
+
execute: async (args, exec) => {
|
|
290
|
+
const led = ctx.get('research.expLedger');
|
|
291
|
+
const eg = ctx.get('research.engineGit');
|
|
292
|
+
const root = led.root(args, exec);
|
|
293
|
+
try { await led.requireProject(root); } catch (e) { return { text: `拒绝:${e.message}` }; }
|
|
294
|
+
const v = await svc.view(args, exec);
|
|
295
|
+
if (!v.pipeline) return { text: '拒绝:尚未声明阶段机。\n修法:先 stage_declare。' };
|
|
296
|
+
const replay = args?.replay === true;
|
|
297
|
+
// 重放是「渲染派生视图」,属于修复路径:不受脏区拦截(它只重写并提交派生状态本身)。
|
|
298
|
+
if (!replay) {
|
|
299
|
+
const guard = led.guard(root, [STATE, REGISTRY]);
|
|
300
|
+
if (!guard.ok) return { text: guard.text };
|
|
301
|
+
}
|
|
302
|
+
const foldBefore = await led.ledgerFold(root);
|
|
303
|
+
const currentAt = foldBefore.currentStage ?? v.pipeline.entry ?? null;
|
|
304
|
+
const to = typeof args?.to === 'string' ? args.to.trim() : '';
|
|
305
|
+
if (!replay && !to) return { text: '拒绝:缺少 to。\n修法:给出目标阶段;只想重建派生状态就加 replay=true。' };
|
|
306
|
+
|
|
307
|
+
// 重放前先看「现有派生状态断言了什么」:与台账重放值不同 = 两个来源矛盾。
|
|
308
|
+
// 冲突的裁决权不在工具也不在 agent(P3),必须由用户拍板。
|
|
309
|
+
let decision = null;
|
|
310
|
+
if (replay) {
|
|
311
|
+
let existing = null; let exists = false;
|
|
312
|
+
try { existing = JSON.parse(await readFile(join(root, STATE), 'utf8')); exists = true; } catch { exists = await pathExists(join(root, STATE)); existing = null; }
|
|
313
|
+
const claimed = existing && typeof existing === 'object' ? (existing.stage ?? null) : undefined;
|
|
314
|
+
const malformed = exists && (existing === null || existing.derived !== true);
|
|
315
|
+
const conflict = exists && (malformed || claimed !== currentAt);
|
|
316
|
+
if (conflict) {
|
|
317
|
+
const what = malformed
|
|
318
|
+
? `派生状态文件形状不符合 v3(断言不出可靠值)`
|
|
319
|
+
: `派生状态文件断言 stage=${claimed ?? '(空)'}`;
|
|
320
|
+
const vv = led.verifyDecision(exec, args?.decision ?? {});
|
|
321
|
+
if (!vv.ok) {
|
|
322
|
+
return {
|
|
323
|
+
text: `拒绝:重放会改变派生状态当前断言的值,这属于两个来源互相矛盾,需要用户裁决。\n`
|
|
324
|
+
+ ` - 现有文件:${what}\n - 台账重放:stage=${currentAt ?? '(未进入阶段)'}(转移记录 ${foldBefore.stages.length} 次)\n`
|
|
325
|
+
+ `${vv.text}\n`
|
|
326
|
+
+ `修法二选一:① 用户确认「以台账为准」后带 decision 重放(把文件改成台账值);② 若研究实际已推进,用 stage_goto to=<真实阶段> 记录一次真实转移。`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
decision = vv.decision;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let from = currentAt ?? '';
|
|
334
|
+
let history;
|
|
335
|
+
if (replay) {
|
|
336
|
+
history = foldBefore.stages.map((s) => ({ from: s.from, to: s.to, ts: s.ts }));
|
|
337
|
+
} else {
|
|
338
|
+
from = (typeof args?.from === 'string' && args.from.trim()) ? args.from.trim() : (currentAt ?? '');
|
|
339
|
+
if (!from) return { text: '拒绝:当前没有所处阶段,且未提供 from。\n修法:给出 from,或用 stage_declare 设 entry 后重试。' };
|
|
340
|
+
const chk = await svc.goto(root, from, to);
|
|
341
|
+
if (!chk.ok) return { text: chk.text };
|
|
342
|
+
history = [...foldBefore.stages.map((s) => ({ from: s.from, to: s.to, ts: s.ts })), { from, to, ts: iso() }];
|
|
343
|
+
}
|
|
344
|
+
const stageNow = replay ? (currentAt ?? null) : to;
|
|
345
|
+
const state = {
|
|
346
|
+
derived: true, generatedAt: iso(), source: 'registry.jsonl',
|
|
347
|
+
graphVersion: v.pipeline.graphVersion, stage: stageNow, history,
|
|
348
|
+
};
|
|
349
|
+
await atomicWrite(join(root, STATE), JSON.stringify(state, null, 2) + '\n');
|
|
350
|
+
eg.record(root, [STATE], replay ? 'stage_goto: replay' : `stage_goto: ${from} -> ${to}`);
|
|
351
|
+
const unignored = await led.removeIgnored(root, [STATE]);
|
|
352
|
+
let rec;
|
|
353
|
+
if (replay) {
|
|
354
|
+
rec = await led.ledgerAppend(root, { record: 'render', kind: 'state', paths: [STATE], count: 1, replay: true, ...(decision ? { decidedBy: true } : {}) });
|
|
355
|
+
if (decision) {
|
|
356
|
+
await led.ledgerAppend(root, {
|
|
357
|
+
record: 'decision', kind: 'state-replay', id: 'research-state',
|
|
358
|
+
askCallId: decision.askCallId, question: decision.question, answer: decision.answer,
|
|
359
|
+
sessionId: decision.sessionId, ...(decision.attested ? { attested: true } : {}),
|
|
360
|
+
}, { actor: 'user' });
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
rec = await led.ledgerAppend(root, { record: 'stage', from, to, currentAt, graphVersion: v.pipeline.graphVersion });
|
|
364
|
+
await led.ledgerAppend(root, { record: 'render', kind: 'state', paths: [STATE], count: 1 });
|
|
365
|
+
}
|
|
366
|
+
const lines = replay
|
|
367
|
+
? [
|
|
368
|
+
`派生状态已按台账重放物化:${STATE}`,
|
|
369
|
+
`当前阶段:${stageNow ?? '(未进入阶段)'} 图版本:${v.pipeline.graphVersion} 历史转移 ${history.length} 次`,
|
|
370
|
+
'本次没有推进阶段(未产生转移事件)。',
|
|
371
|
+
...(decision ? [`依据用户裁决:${decision.answer}`] : []),
|
|
372
|
+
...(unignored ? [`同时把它从「有意忽略」清单里移出(${unignored} 条)。`] : []),
|
|
373
|
+
`台账第 ${rec.seq} 行。`,
|
|
374
|
+
]
|
|
375
|
+
: [
|
|
376
|
+
`阶段已推进:${from} → ${to}`,
|
|
377
|
+
`当前阶段:${to} 图版本:${v.pipeline.graphVersion}`,
|
|
378
|
+
...(currentAt !== null && currentAt !== from ? [`注意:本次显式给出的起点 ${from} 与推进前的当前阶段 ${currentAt} 不一致,台账已同时记录两者。`] : []),
|
|
379
|
+
...(unignored ? [`派生状态已重新纳入常规管辖(从「有意忽略」清单移出 ${unignored} 条)。`] : []),
|
|
380
|
+
`历史转移 ${history.length} 次;台账第 ${rec.seq} 行。`,
|
|
381
|
+
];
|
|
382
|
+
return { text: lines.join('\n') };
|
|
383
|
+
},
|
|
384
|
+
presentCall: (args) => genericCall(args?.replay === true ? '重放物化派生状态' : `推进阶段 → ${args?.to ?? ''}`.trim(), 'edit', [STATE]),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export { apply };
|