agent-trellis 0.19.0 → 0.20.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/dist/cli.js +12 -1
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.js +87 -2
- package/dist/commands/evolve.d.ts +43 -0
- package/dist/commands/evolve.js +479 -0
- package/dist/commands/mcpGateway.d.ts +28 -0
- package/dist/commands/mcpGateway.js +40 -6
- package/dist/evolve/agentView.d.ts +32 -0
- package/dist/evolve/agentView.js +34 -0
- package/dist/evolve/apply.d.ts +71 -0
- package/dist/evolve/apply.js +162 -0
- package/dist/evolve/archive.d.ts +23 -0
- package/dist/evolve/archive.js +58 -0
- package/dist/evolve/erase.d.ts +17 -0
- package/dist/evolve/erase.js +65 -0
- package/dist/evolve/extension.d.ts +37 -0
- package/dist/evolve/extension.js +63 -0
- package/dist/evolve/gate.d.ts +95 -0
- package/dist/evolve/gate.js +295 -0
- package/dist/evolve/importHistory.d.ts +91 -0
- package/dist/evolve/importHistory.js +239 -0
- package/dist/evolve/jobMain.d.ts +22 -0
- package/dist/evolve/jobMain.js +52 -0
- package/dist/evolve/jobRun.d.ts +94 -0
- package/dist/evolve/jobRun.js +273 -0
- package/dist/evolve/jobShell.d.ts +46 -0
- package/dist/evolve/jobShell.js +75 -0
- package/dist/evolve/l2.d.ts +93 -0
- package/dist/evolve/l2.js +81 -0
- package/dist/evolve/lesson.d.ts +57 -0
- package/dist/evolve/lesson.js +97 -0
- package/dist/evolve/pending.d.ts +69 -0
- package/dist/evolve/pending.js +116 -0
- package/dist/evolve/probeWorkspace.d.ts +57 -0
- package/dist/evolve/probeWorkspace.js +69 -0
- package/dist/evolve/propose.d.ts +73 -0
- package/dist/evolve/propose.js +136 -0
- package/dist/evolve/recorder.d.ts +68 -0
- package/dist/evolve/recorder.js +169 -0
- package/dist/evolve/report.d.ts +26 -0
- package/dist/evolve/report.js +37 -0
- package/dist/evolve/reportView.d.ts +96 -0
- package/dist/evolve/reportView.js +311 -0
- package/dist/evolve/risk.d.ts +106 -0
- package/dist/evolve/risk.js +224 -0
- package/dist/evolve/sources.d.ts +19 -0
- package/dist/evolve/sources.js +29 -0
- package/dist/evolve/stateDir.d.ts +21 -0
- package/dist/evolve/stateDir.js +56 -0
- package/dist/evolve/surface.d.ts +34 -0
- package/dist/evolve/surface.js +72 -0
- package/dist/evolve/trajectory.d.ts +25 -0
- package/dist/evolve/trajectory.js +88 -0
- package/dist/evolve/treeToCanonical.d.ts +28 -0
- package/dist/evolve/treeToCanonical.js +79 -0
- package/dist/evolve/trellisSubstrate.d.ts +65 -0
- package/dist/evolve/trellisSubstrate.js +154 -0
- package/dist/evolve/trigger.d.ts +177 -0
- package/dist/evolve/trigger.js +400 -0
- package/dist/evolve/types.d.ts +126 -0
- package/dist/evolve/types.js +15 -0
- package/dist/evolve/validate.d.ts +139 -0
- package/dist/evolve/validate.js +315 -0
- package/dist/host/checks.d.ts +57 -0
- package/dist/host/checks.js +126 -0
- package/dist/host/delivery.d.ts +42 -0
- package/dist/host/delivery.js +59 -0
- package/dist/host/edits.d.ts +87 -0
- package/dist/host/edits.js +102 -0
- package/dist/host/hostEvents.d.ts +74 -0
- package/dist/host/hostEvents.js +45 -0
- package/dist/host/index.d.ts +22 -0
- package/dist/host/index.js +17 -0
- package/dist/host/levels.d.ts +106 -0
- package/dist/host/levels.js +206 -0
- package/dist/host/staging.d.ts +56 -0
- package/dist/host/staging.js +106 -0
- package/dist/host/surface.d.ts +36 -0
- package/dist/host/surface.js +20 -0
- package/dist/lib/backup.js +12 -3
- package/dist/lib/fsAtomic.d.ts +7 -0
- package/dist/lib/fsAtomic.js +14 -0
- package/dist/lib/hostEventBus.d.ts +47 -0
- package/dist/lib/hostEventBus.js +94 -0
- package/dist/lib/hostExtensions.d.ts +75 -0
- package/dist/lib/hostExtensions.js +196 -0
- package/dist/lib/mcpRuntime.d.ts +12 -1
- package/dist/lib/mcpRuntime.js +34 -2
- package/dist/lib/mcpToolRegistry.d.ts +18 -2
- package/dist/lib/mcpToolRegistry.js +29 -9
- package/dist/pi-bridge/bundle.js +16 -6
- package/docs/magpie-gateway-research.md +422 -0
- package/docs/roadmap.md +22 -12
- package/docs/runtime-infra.md +41 -22
- package/docs/self-evolution.md +138 -44
- package/package.json +7 -2
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 doctor-oracle 前闸(trellis-evolve 任务 12.1)。
|
|
3
|
+
*
|
|
4
|
+
* 两副职责:
|
|
5
|
+
*
|
|
6
|
+
* 1. **提案类域分派**:仅 `L1_CONFIG_KINDS` 白名单内的配置类提案进入
|
|
7
|
+
* 提案通道;内容类提案本期不产出(等 L2 选择器执行落地)。design D8
|
|
8
|
+
* "L1 适用域的判定由 L4 治理层按 kind 白名单执行"——本模块就是该判定
|
|
9
|
+
* 点,12.2 的机械校验对提案 kind 的准入与此同源。
|
|
10
|
+
* 2. **确定性候选 verdict 差集**:对真实 baseline 与 staging 候选各运行
|
|
11
|
+
* 一组结构化检查(doctor findings、`sync --check`、secrets audit),
|
|
12
|
+
* 按 (稳定 id, 层归属) 集合差判定。在 L1 适用域内该差集是**唯一
|
|
13
|
+
* verdict**,配置类提案不运行引擎评分链。
|
|
14
|
+
*
|
|
15
|
+
* 可采纳判据(design D8,从严):无新条目 ∧ 全部目标条目被消除 ∧ 其余
|
|
16
|
+
* 条目不变(未声明的消除也算变化)。目标条目 = 提案 evidence 中
|
|
17
|
+
* `doctor-finding` 链接的 `findingId`(即 CheckEntry.id);`Proposal.target`
|
|
18
|
+
* 本身是被编辑面的锚点(spec:"目标为某 MCP server auth 块"、"目标路径
|
|
19
|
+
* 位于某仓库的 .agents/ 内"),不是检查条目——两者职责分开,本闸只消费
|
|
20
|
+
* findingId。目标条目不在 baseline 时"无法确认修复",按不采纳收口。
|
|
21
|
+
*
|
|
22
|
+
* staging 复现层分裂:`createStaging` 拷 scratch 全局 HOME + 临时
|
|
23
|
+
* workspace root(墓碑生效),提案 edits 由本模块把真实面绝对路径重定位
|
|
24
|
+
* 到 scratch(越界不采纳);baseline 与候选都带 `logicalWorkspaceRoot`
|
|
25
|
+
* 归一化,条目比较键含层归属(`workspace(<root>)` 与全局不混)。
|
|
26
|
+
*
|
|
27
|
+
* 失败收口(Risks 8):staging/应用/测量任何意外错误一律"不采纳",
|
|
28
|
+
* 测量结果记 `unknown`(空条目集,绝不表示为零分或通过);域外提案同样
|
|
29
|
+
* 不采纳。全部判定都写入运行档案(kind `"verdict"`,含 baseline 与
|
|
30
|
+
* candidate 两份完整测量)。
|
|
31
|
+
*/
|
|
32
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
33
|
+
import { appendArchiveRecord } from "./archive.js";
|
|
34
|
+
import { applyEdits, createStaging, runChecks, } from "../host/index.js";
|
|
35
|
+
/** 档案 kind:一次 L1 verdict(两份完整测量 + 判定原因)。 */
|
|
36
|
+
export const VERDICT_ARCHIVE_KIND = "verdict";
|
|
37
|
+
/**
|
|
38
|
+
* L1 适用域:配置类 kind 白名单(design D8)。名单内提案进入提案通道,
|
|
39
|
+
* L1 差集作唯一 verdict;名单外(含内容类——skill/记忆/指令正文等)本
|
|
40
|
+
* 期不产出提案,等 L2 选择器执行落地。命名与 12.2 rule-based/editor
|
|
41
|
+
* 提案的产出侧保持一致。
|
|
42
|
+
*/
|
|
43
|
+
export const L1_CONFIG_KINDS = new Set([
|
|
44
|
+
/** workspace 层配置面修复(如 `.codex/config.toml` 解析错误)。 */
|
|
45
|
+
"workspace-config-fix",
|
|
46
|
+
/** MCP 定义/路由等配置面修复(凭据字面值在校验阶段硬拒)。 */
|
|
47
|
+
"mcp-config-fix",
|
|
48
|
+
/** 托管投递面漂移修复(sync 可见面)。 */
|
|
49
|
+
"sync-config-fix",
|
|
50
|
+
/** secrets 策略配置修复(变量名/拒绝模式,不含任何凭据值)。 */
|
|
51
|
+
"secrets-policy-fix",
|
|
52
|
+
/** OAuth 配置元数据修复(kind/owner/client 元数据,不含凭据)。 */
|
|
53
|
+
"oauth-config-fix",
|
|
54
|
+
]);
|
|
55
|
+
/** 提案类域分派:白名单内 = 配置类(进通道),其余 = 内容类(不产出)。 */
|
|
56
|
+
export function dispatchProposalDomain(kind) {
|
|
57
|
+
if (L1_CONFIG_KINDS.has(kind)) {
|
|
58
|
+
return {
|
|
59
|
+
domain: "config",
|
|
60
|
+
entersProposalChannel: true,
|
|
61
|
+
note: `kind "${kind}" 属配置类,进入提案通道(L1 差集为唯一 verdict)`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
domain: "content",
|
|
66
|
+
entersProposalChannel: false,
|
|
67
|
+
note: `kind "${kind}" 不在配置类白名单,本期不产出提案(等 L2 选择器执行落地)`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 条目比较键:(稳定 id, 层归属)。"条目 SHALL 带着层归属参与比较"——
|
|
72
|
+
* 同一 id 在全局层与 workspace 层是两条不同记录,不互相抵消。
|
|
73
|
+
*/
|
|
74
|
+
export function entryKey(entry) {
|
|
75
|
+
const layerKey = entry.layer.kind === "global" ? "global" : `workspace(${entry.layer.root})`;
|
|
76
|
+
return `${entry.id} ${layerKey}`;
|
|
77
|
+
}
|
|
78
|
+
/** 按 (id, layer) 做集合差;message 不参与比较(host/checks.ts 契约)。 */
|
|
79
|
+
export function diffVerdictEntries(baseline, candidate) {
|
|
80
|
+
const baselineKeys = new Set(baseline.map(entryKey));
|
|
81
|
+
const candidateKeys = new Set(candidate.map(entryKey));
|
|
82
|
+
return {
|
|
83
|
+
introduced: candidate.filter((entry) => !baselineKeys.has(entryKey(entry))),
|
|
84
|
+
resolved: baseline.filter((entry) => !candidateKeys.has(entryKey(entry))),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** 提案 evidence 中的目标条目(evidence 的 doctor-finding.findingId
|
|
88
|
+
* 即 CheckEntry.id;`Proposal.target` 是编辑面锚点,不在此列)。 */
|
|
89
|
+
export function doctorFindingTargetIds(evidence) {
|
|
90
|
+
return evidence
|
|
91
|
+
.filter((link) => link.kind === "doctor-finding")
|
|
92
|
+
.map((link) => link.findingId);
|
|
93
|
+
}
|
|
94
|
+
function unknownVerdict(layer) {
|
|
95
|
+
return { status: "unknown", layer, baseline: [], candidate: [] };
|
|
96
|
+
}
|
|
97
|
+
/** `path` 是否落在某个真实面根之下(不含根本身)。 */
|
|
98
|
+
function isInsideRoots(path, roots) {
|
|
99
|
+
const resolved = resolve(path);
|
|
100
|
+
for (const root of roots) {
|
|
101
|
+
if (!root)
|
|
102
|
+
continue;
|
|
103
|
+
const rel = relative(resolve(root), resolved);
|
|
104
|
+
if (rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
/** 真实面绝对路径 → staging scratch 对应路径;越界抛错(不采纳)。
|
|
110
|
+
* workspace 根优先(可能嵌套在 HOME 下,与 checks 的归一化同序)。 */
|
|
111
|
+
function relocateIntoStaging(path, ctx) {
|
|
112
|
+
const roots = [];
|
|
113
|
+
if (ctx.workspaceRoot && ctx.staging.workspaceRoot) {
|
|
114
|
+
roots.push({ real: ctx.workspaceRoot, scratch: ctx.staging.workspaceRoot });
|
|
115
|
+
}
|
|
116
|
+
roots.push({ real: ctx.homeDir, scratch: ctx.staging.homeDir });
|
|
117
|
+
const resolved = resolve(path);
|
|
118
|
+
for (const { real, scratch } of roots) {
|
|
119
|
+
if (!scratch)
|
|
120
|
+
continue;
|
|
121
|
+
const rel = relative(resolve(real), resolved);
|
|
122
|
+
if (rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) {
|
|
123
|
+
return join(scratch, rel);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`edit path escapes the measured surface: ${path}`);
|
|
127
|
+
}
|
|
128
|
+
/** 符号链接 target:落在真实面内才重定位到 scratch(链接不指向真实面);
|
|
129
|
+
* 相对路径与面外绝对路径保持原义(校验阶段的职责不在此)。 */
|
|
130
|
+
function relocateTargetIfInside(target, ctx) {
|
|
131
|
+
if (!isAbsolute(target))
|
|
132
|
+
return target;
|
|
133
|
+
if (!isInsideRoots(target, [ctx.workspaceRoot, ctx.homeDir]))
|
|
134
|
+
return target;
|
|
135
|
+
return relocateIntoStaging(target, ctx);
|
|
136
|
+
}
|
|
137
|
+
function relocateEdits(edits, ctx) {
|
|
138
|
+
return edits.map((edit) => {
|
|
139
|
+
const path = relocateIntoStaging(edit.path, ctx);
|
|
140
|
+
switch (edit.op) {
|
|
141
|
+
case "write-file":
|
|
142
|
+
case "remove-symlink":
|
|
143
|
+
return { ...edit, path };
|
|
144
|
+
case "create-symlink":
|
|
145
|
+
case "repair-symlink":
|
|
146
|
+
return { ...edit, path, target: relocateTargetIfInside(edit.target, ctx) };
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* L1 前闸完整判定:域分派 → staging 副本(层分裂复现)→ edits 重定位并
|
|
152
|
+
* 应用 → baseline/候选各跑三组结构化检查 → (id, layer) 差集 → 判定并
|
|
153
|
+
* 入档。任何意外错误按"不采纳"收口、测量记 unknown;不吞掉入档写失败。
|
|
154
|
+
*/
|
|
155
|
+
export async function evaluateL1Proposal(options) {
|
|
156
|
+
const homeDir = options.homeDir;
|
|
157
|
+
const workspaceRoot = options.workspaceRoot ?? null;
|
|
158
|
+
const proposal = options.proposal;
|
|
159
|
+
const stamp = () => new Date((options.now ?? Date.now)());
|
|
160
|
+
const dispatch = dispatchProposalDomain(proposal.kind);
|
|
161
|
+
const settle = (outcome) => {
|
|
162
|
+
const result = {
|
|
163
|
+
decision: outcome.decision,
|
|
164
|
+
reason: outcome.reason,
|
|
165
|
+
domain: dispatch.domain,
|
|
166
|
+
verdict: outcome.verdict,
|
|
167
|
+
targetIds: outcome.targetIds,
|
|
168
|
+
introduced: outcome.introduced,
|
|
169
|
+
resolvedIds: outcome.resolvedIds,
|
|
170
|
+
};
|
|
171
|
+
// 两份 verdict 入档:baseline 与 candidate 完整条目集 + 判定原因。
|
|
172
|
+
appendArchiveRecord(homeDir, {
|
|
173
|
+
kind: VERDICT_ARCHIVE_KIND,
|
|
174
|
+
payload: {
|
|
175
|
+
proposalId: proposal.id,
|
|
176
|
+
kind: proposal.kind,
|
|
177
|
+
domain: dispatch.domain,
|
|
178
|
+
decision: outcome.decision,
|
|
179
|
+
reason: outcome.reason,
|
|
180
|
+
status: outcome.verdict.status,
|
|
181
|
+
layer: outcome.verdict.layer,
|
|
182
|
+
targetIds: outcome.targetIds,
|
|
183
|
+
introduced: outcome.introduced,
|
|
184
|
+
resolvedIds: outcome.resolvedIds,
|
|
185
|
+
baseline: outcome.verdict.baseline,
|
|
186
|
+
candidate: outcome.verdict.candidate,
|
|
187
|
+
},
|
|
188
|
+
}, stamp);
|
|
189
|
+
return result;
|
|
190
|
+
};
|
|
191
|
+
// 域分派:内容类本期不产出(等 L2)——不测量、不通过。
|
|
192
|
+
if (!dispatch.entersProposalChannel) {
|
|
193
|
+
return settle({
|
|
194
|
+
decision: "not-acceptable",
|
|
195
|
+
reason: dispatch.note,
|
|
196
|
+
verdict: unknownVerdict(proposal.layer),
|
|
197
|
+
targetIds: [],
|
|
198
|
+
introduced: [],
|
|
199
|
+
resolvedIds: [],
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const targetIds = doctorFindingTargetIds(proposal.evidence);
|
|
203
|
+
if (targetIds.length === 0) {
|
|
204
|
+
return settle({
|
|
205
|
+
decision: "not-acceptable",
|
|
206
|
+
reason: "提案证据中没有 doctor-finding 目标条目,无法确认修复",
|
|
207
|
+
verdict: unknownVerdict(proposal.layer),
|
|
208
|
+
targetIds: [],
|
|
209
|
+
introduced: [],
|
|
210
|
+
resolvedIds: [],
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const runChecksFn = options.runChecksFn ?? runChecks;
|
|
214
|
+
const createStagingFn = options.createStagingFn ?? createStaging;
|
|
215
|
+
const applyEditsFn = options.applyEditsFn ?? applyEdits;
|
|
216
|
+
let staging = null;
|
|
217
|
+
try {
|
|
218
|
+
staging = createStagingFn({ homeDir, workspaceRoot });
|
|
219
|
+
const edits = relocateEdits(proposal.edits, { homeDir, workspaceRoot, staging });
|
|
220
|
+
if (edits.length > 0) {
|
|
221
|
+
await applyEditsFn({ homeDir: staging.homeDir, edits, command: "evolve-l1-gate" });
|
|
222
|
+
}
|
|
223
|
+
// baseline:真实面;candidate:staging scratch。两侧都带
|
|
224
|
+
// logicalWorkspaceRoot 归一化,scratch 路径不进比较身份。
|
|
225
|
+
const baselineResult = await runChecksFn({
|
|
226
|
+
homeDir,
|
|
227
|
+
workspaceRoot,
|
|
228
|
+
logicalWorkspaceRoot: workspaceRoot,
|
|
229
|
+
});
|
|
230
|
+
const candidateResult = await runChecksFn({
|
|
231
|
+
homeDir: staging.homeDir,
|
|
232
|
+
workspaceRoot: staging.workspaceRoot,
|
|
233
|
+
logicalWorkspaceRoot: staging.logicalWorkspaceRoot,
|
|
234
|
+
});
|
|
235
|
+
const baseline = baselineResult.entries;
|
|
236
|
+
const candidate = candidateResult.entries;
|
|
237
|
+
const diff = diffVerdictEntries(baseline, candidate);
|
|
238
|
+
const targetIdSet = new Set(targetIds);
|
|
239
|
+
const baselineIds = new Set(baseline.map((entry) => entry.id));
|
|
240
|
+
const missingTargets = targetIds.filter((id) => !baselineIds.has(id));
|
|
241
|
+
const targetKeys = new Set(baseline.filter((entry) => targetIdSet.has(entry.id)).map(entryKey));
|
|
242
|
+
const resolvedKeys = new Set(diff.resolved.map(entryKey));
|
|
243
|
+
const resolvedIds = diff.resolved.map((entry) => entry.id);
|
|
244
|
+
const okVerdict = { status: "ok", layer: proposal.layer, baseline, candidate };
|
|
245
|
+
const common = { verdict: okVerdict, targetIds, introduced: diff.introduced, resolvedIds };
|
|
246
|
+
// 目标条目必须在 baseline 里——不在则"无法确认修复"。
|
|
247
|
+
if (missingTargets.length > 0) {
|
|
248
|
+
return settle({
|
|
249
|
+
...common,
|
|
250
|
+
decision: "not-acceptable",
|
|
251
|
+
reason: `目标条目不在 baseline(${missingTargets.join(", ")}),无法确认修复`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
// 无新条目。
|
|
255
|
+
if (diff.introduced.length > 0) {
|
|
256
|
+
return settle({
|
|
257
|
+
...common,
|
|
258
|
+
decision: "not-acceptable",
|
|
259
|
+
reason: `候选引入 ${diff.introduced.length} 个 baseline 中不存在的新条目`,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
// 目标条目被消除 ∧ 其余不变(未声明的消除也算变化)。
|
|
263
|
+
const targetsAllResolved = [...targetKeys].every((key) => resolvedKeys.has(key));
|
|
264
|
+
const noExtraResolved = resolvedKeys.size === targetKeys.size && [...resolvedKeys].every((key) => targetKeys.has(key));
|
|
265
|
+
if (!targetsAllResolved) {
|
|
266
|
+
return settle({ ...common, decision: "not-acceptable", reason: "目标条目未被完全消除" });
|
|
267
|
+
}
|
|
268
|
+
if (!noExtraResolved) {
|
|
269
|
+
return settle({
|
|
270
|
+
...common,
|
|
271
|
+
decision: "not-acceptable",
|
|
272
|
+
reason: "候选消除了未声明的条目,其余不满足不变约束",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return settle({
|
|
276
|
+
...common,
|
|
277
|
+
decision: "acceptable",
|
|
278
|
+
reason: "候选消除全部目标条目,无新条目,其余不变",
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
// Risks 8:意外错误一律不采纳,测量记 unknown,绝不表示为通过。
|
|
283
|
+
return settle({
|
|
284
|
+
decision: "not-acceptable",
|
|
285
|
+
reason: `测量/应用失败,按'不采纳'收口: ${err instanceof Error ? err.message : String(err)}`,
|
|
286
|
+
verdict: unknownVerdict(proposal.layer),
|
|
287
|
+
targetIds,
|
|
288
|
+
introduced: [],
|
|
289
|
+
resolvedIds: [],
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
staging?.dispose();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 历史数据导入与幂等回填(trellis-evolve-run-backfill 任务 3.1/3.2)。
|
|
3
|
+
*
|
|
4
|
+
* 把用户**显式选定**的既有 session 数据回填为观察信号,供后续作业按
|
|
5
|
+
* 既有水位线消费。设计(D4):
|
|
6
|
+
* - 适配器契约 `{ name, sniff, extract }`,首个适配器 `claude-code-jsonl`
|
|
7
|
+
* (Claude Code 会话 transcript JSONL);
|
|
8
|
+
* - 白名单元数据:timestamp / agent / 会话 / 工具名 / 耗时 / 结果类别 /
|
|
9
|
+
* 层归属 —— 入参、结果载体与自由文本正文一律不入库(与 recorder 的
|
|
10
|
+
* 字段白名单同一条线);所有字符串字段写入前经 `eraseSecrets`;
|
|
11
|
+
* - 台账幂等:`evolve/imports/<sha256(内容)>.json` 记源摘要与已导条目
|
|
12
|
+
* 数,同一源重复导入为空操作;stableId `import:<digest 前 12 位>:<行号>`
|
|
13
|
+
* 确定性(重复导入/重复消费不产生重复信号或重复提案);
|
|
14
|
+
* - 写入复用 recorder 的批量 append(0600/0700、并发安全);整批先
|
|
15
|
+
* extract 校验、后写入,任何源不可解析即非零退出且零写入。
|
|
16
|
+
*
|
|
17
|
+
* 字段缺省语义(格式缺字段时):ImportedEntry 对应字段记 null —— 耗时
|
|
18
|
+
* 缺省 0(白名单无 unknown 态),结果类别缺省 "ok"(无错误指示),agent
|
|
19
|
+
* 缺省适配器常量 "claude-code",会话缺省源文件名(D4"会话 id=文件名")。
|
|
20
|
+
* 无工具名的记录(文本轮次等)不写为 tool-call 信号,计入 skippedNoTool。
|
|
21
|
+
* 结果类别按记录自带 is_error 归类(每条记录独立映射);tool_use 与其
|
|
22
|
+
* tool_result 在 transcript 中是不同记录,跨记录关联留待适配器后续细化。
|
|
23
|
+
*/
|
|
24
|
+
/** 导入条目的白名单元数据(缺字段缺 null;写入侧按文档化缺省补全)。 */
|
|
25
|
+
export interface ImportedEntry {
|
|
26
|
+
readonly timestampMs: number | null;
|
|
27
|
+
readonly agent: string | null;
|
|
28
|
+
readonly session: string | null;
|
|
29
|
+
readonly tool: string | null;
|
|
30
|
+
readonly durationMs: number | null;
|
|
31
|
+
readonly result: "ok" | "error" | "timeout" | null;
|
|
32
|
+
}
|
|
33
|
+
export interface ExtractedEntry {
|
|
34
|
+
/** 源文件物理行号(1 起):stableId 序号,确定性不受过滤影响。 */
|
|
35
|
+
readonly lineNo: number;
|
|
36
|
+
readonly entry: ImportedEntry;
|
|
37
|
+
}
|
|
38
|
+
export interface ExtractResult {
|
|
39
|
+
readonly entries: readonly ExtractedEntry[];
|
|
40
|
+
/** 无法解析的坏行数(跳过计数,与读取侧容错同源)。 */
|
|
41
|
+
readonly badLines: number;
|
|
42
|
+
/** 无工具名的记录数(文本轮次等,不写为 tool-call 信号)。 */
|
|
43
|
+
readonly skippedNoTool: number;
|
|
44
|
+
}
|
|
45
|
+
/** 源适配器契约:sniff 判定形态,extract 抽白名单元数据(不写盘)。 */
|
|
46
|
+
export interface HistoryAdapter {
|
|
47
|
+
readonly name: string;
|
|
48
|
+
sniff(path: string): boolean;
|
|
49
|
+
extract(path: string): ExtractResult;
|
|
50
|
+
}
|
|
51
|
+
/** 源不可解析(不存在/不可读/不匹配任何适配器):整批零写入收口。 */
|
|
52
|
+
export declare class ImportSourceError extends Error {
|
|
53
|
+
readonly path: string;
|
|
54
|
+
constructor(path: string, reason: string);
|
|
55
|
+
}
|
|
56
|
+
/** 首个适配器:Claude Code 会话 transcript(JSONL)。 */
|
|
57
|
+
export declare const claudeCodeJsonlAdapter: HistoryAdapter;
|
|
58
|
+
/** 已注册适配器(按序 sniff;契约先行,更多源后置)。 */
|
|
59
|
+
export declare const HISTORY_ADAPTERS: readonly HistoryAdapter[];
|
|
60
|
+
export interface ImportHistoryOptions {
|
|
61
|
+
readonly homeDir: string;
|
|
62
|
+
/** 显式选定的源路径(仅此处理;不扫描目录、不自动发现)。 */
|
|
63
|
+
readonly paths: readonly string[];
|
|
64
|
+
/** `--workspace` 层归属;缺省全局层。 */
|
|
65
|
+
readonly workspaceRoot?: string;
|
|
66
|
+
/** 时间源(毫秒);缺省 Date.now(采集时刻与台账打戳)。 */
|
|
67
|
+
readonly now?: () => number;
|
|
68
|
+
}
|
|
69
|
+
export interface ImportedSourceResult {
|
|
70
|
+
readonly path: string;
|
|
71
|
+
readonly sourceDigest: string;
|
|
72
|
+
/** 本次写入条目数(已记账源为 0)。 */
|
|
73
|
+
readonly entries: number;
|
|
74
|
+
readonly badLines: number;
|
|
75
|
+
readonly skippedNoTool: number;
|
|
76
|
+
/** 已记账源(重复导入为空操作)。 */
|
|
77
|
+
readonly duplicate: boolean;
|
|
78
|
+
}
|
|
79
|
+
export interface ImportHistoryResult {
|
|
80
|
+
readonly sources: readonly ImportedSourceResult[];
|
|
81
|
+
readonly written: number;
|
|
82
|
+
readonly signalFile: string | null;
|
|
83
|
+
}
|
|
84
|
+
/** 台账文件路径(按源内容摘要命名;内容变 = 新源)。 */
|
|
85
|
+
export declare function importLedgerPath(homeDir: string, digest: string): string;
|
|
86
|
+
/**
|
|
87
|
+
* 整批导入:先对全部源 sniff + extract 校验(任何源不可解析即抛
|
|
88
|
+
* `ImportSourceError`,零写入),后一次性写信号与台账(台账最后记账,
|
|
89
|
+
* 中断重跑安全:未记账则重导,提案去重兜底)。不触发任何作业。
|
|
90
|
+
*/
|
|
91
|
+
export declare function importHistory(options: ImportHistoryOptions): ImportHistoryResult;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 历史数据导入与幂等回填(trellis-evolve-run-backfill 任务 3.1/3.2)。
|
|
3
|
+
*
|
|
4
|
+
* 把用户**显式选定**的既有 session 数据回填为观察信号,供后续作业按
|
|
5
|
+
* 既有水位线消费。设计(D4):
|
|
6
|
+
* - 适配器契约 `{ name, sniff, extract }`,首个适配器 `claude-code-jsonl`
|
|
7
|
+
* (Claude Code 会话 transcript JSONL);
|
|
8
|
+
* - 白名单元数据:timestamp / agent / 会话 / 工具名 / 耗时 / 结果类别 /
|
|
9
|
+
* 层归属 —— 入参、结果载体与自由文本正文一律不入库(与 recorder 的
|
|
10
|
+
* 字段白名单同一条线);所有字符串字段写入前经 `eraseSecrets`;
|
|
11
|
+
* - 台账幂等:`evolve/imports/<sha256(内容)>.json` 记源摘要与已导条目
|
|
12
|
+
* 数,同一源重复导入为空操作;stableId `import:<digest 前 12 位>:<行号>`
|
|
13
|
+
* 确定性(重复导入/重复消费不产生重复信号或重复提案);
|
|
14
|
+
* - 写入复用 recorder 的批量 append(0600/0700、并发安全);整批先
|
|
15
|
+
* extract 校验、后写入,任何源不可解析即非零退出且零写入。
|
|
16
|
+
*
|
|
17
|
+
* 字段缺省语义(格式缺字段时):ImportedEntry 对应字段记 null —— 耗时
|
|
18
|
+
* 缺省 0(白名单无 unknown 态),结果类别缺省 "ok"(无错误指示),agent
|
|
19
|
+
* 缺省适配器常量 "claude-code",会话缺省源文件名(D4"会话 id=文件名")。
|
|
20
|
+
* 无工具名的记录(文本轮次等)不写为 tool-call 信号,计入 skippedNoTool。
|
|
21
|
+
* 结果类别按记录自带 is_error 归类(每条记录独立映射);tool_use 与其
|
|
22
|
+
* tool_result 在 transcript 中是不同记录,跨记录关联留待适配器后续细化。
|
|
23
|
+
*/
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { basename, dirname, join } from "node:path";
|
|
27
|
+
import { eraseSecrets } from "./erase.js";
|
|
28
|
+
import { appendSignalLines, signalFilePath } from "./recorder.js";
|
|
29
|
+
import { evolveStateDir } from "./stateDir.js";
|
|
30
|
+
/** 源不可解析(不存在/不可读/不匹配任何适配器):整批零写入收口。 */
|
|
31
|
+
export class ImportSourceError extends Error {
|
|
32
|
+
path;
|
|
33
|
+
constructor(path, reason) {
|
|
34
|
+
super(`历史源 ${path} 无法导入:${reason}`);
|
|
35
|
+
this.name = "ImportSourceError";
|
|
36
|
+
this.path = path;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function parseTimestamp(raw) {
|
|
40
|
+
if (typeof raw !== "string")
|
|
41
|
+
return null;
|
|
42
|
+
const ms = Date.parse(raw);
|
|
43
|
+
return Number.isNaN(ms) ? null : ms;
|
|
44
|
+
}
|
|
45
|
+
/** 首个 tool_use 块的工具名(一条记录一元数据;多工具块取首个)。 */
|
|
46
|
+
function firstToolName(record) {
|
|
47
|
+
const message = record.message;
|
|
48
|
+
const content = (message?.content ?? record.content);
|
|
49
|
+
if (Array.isArray(content)) {
|
|
50
|
+
for (const block of content) {
|
|
51
|
+
const b = block;
|
|
52
|
+
if (b.type === "tool_use" && typeof b.name === "string")
|
|
53
|
+
return b.name;
|
|
54
|
+
if (b.type === "tool_result" && typeof b.name === "string")
|
|
55
|
+
return b.name;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (typeof record.tool === "string")
|
|
59
|
+
return record.tool;
|
|
60
|
+
if (typeof record.tool_name === "string")
|
|
61
|
+
return record.tool_name;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
/** 记录自带的错误指示(顶层或内容块的 is_error);无指示为 null。 */
|
|
65
|
+
function errorIndicator(record) {
|
|
66
|
+
let seen = typeof record.is_error === "boolean" ? record.is_error : null;
|
|
67
|
+
const message = record.message;
|
|
68
|
+
const content = (message?.content ?? record.content);
|
|
69
|
+
if (Array.isArray(content)) {
|
|
70
|
+
for (const block of content) {
|
|
71
|
+
const b = block;
|
|
72
|
+
if (typeof b.is_error === "boolean")
|
|
73
|
+
seen = seen === true || b.is_error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return seen;
|
|
77
|
+
}
|
|
78
|
+
/** 首个适配器:Claude Code 会话 transcript(JSONL)。 */
|
|
79
|
+
export const claudeCodeJsonlAdapter = {
|
|
80
|
+
name: "claude-code-jsonl",
|
|
81
|
+
sniff(path) {
|
|
82
|
+
if (!path.endsWith(".jsonl"))
|
|
83
|
+
return false;
|
|
84
|
+
try {
|
|
85
|
+
const first = readFileSync(path, "utf-8")
|
|
86
|
+
.split("\n")
|
|
87
|
+
.find((line) => line.trim() !== "");
|
|
88
|
+
if (first === undefined)
|
|
89
|
+
return false;
|
|
90
|
+
const record = JSON.parse(first);
|
|
91
|
+
return (typeof record === "object" &&
|
|
92
|
+
record !== null &&
|
|
93
|
+
("type" in record || "sessionId" in record || "timestamp" in record || "uuid" in record));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
extract(path) {
|
|
100
|
+
const text = readFileSync(path, "utf-8");
|
|
101
|
+
const stem = basename(path).replace(/\.jsonl$/, "");
|
|
102
|
+
const entries = [];
|
|
103
|
+
let badLines = 0;
|
|
104
|
+
let skippedNoTool = 0;
|
|
105
|
+
text.split("\n").forEach((line, index) => {
|
|
106
|
+
const trimmed = line.trim();
|
|
107
|
+
if (trimmed === "")
|
|
108
|
+
return;
|
|
109
|
+
let record;
|
|
110
|
+
try {
|
|
111
|
+
record = JSON.parse(trimmed);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
badLines += 1;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (typeof record !== "object" || record === null) {
|
|
118
|
+
badLines += 1;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const rec = record;
|
|
122
|
+
const tool = firstToolName(rec);
|
|
123
|
+
if (tool === null) {
|
|
124
|
+
skippedNoTool += 1;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const isError = errorIndicator(rec);
|
|
128
|
+
entries.push({
|
|
129
|
+
lineNo: index + 1,
|
|
130
|
+
entry: {
|
|
131
|
+
timestampMs: parseTimestamp(rec.timestamp),
|
|
132
|
+
agent: null,
|
|
133
|
+
session: stem,
|
|
134
|
+
tool,
|
|
135
|
+
durationMs: null,
|
|
136
|
+
result: isError === null ? null : isError ? "error" : "ok",
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
return { entries, badLines, skippedNoTool };
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
/** 已注册适配器(按序 sniff;契约先行,更多源后置)。 */
|
|
144
|
+
export const HISTORY_ADAPTERS = [claudeCodeJsonlAdapter];
|
|
145
|
+
/** 台账文件路径(按源内容摘要命名;内容变 = 新源)。 */
|
|
146
|
+
export function importLedgerPath(homeDir, digest) {
|
|
147
|
+
return join(evolveStateDir(homeDir), "imports", `${digest}.json`);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 整批导入:先对全部源 sniff + extract 校验(任何源不可解析即抛
|
|
151
|
+
* `ImportSourceError`,零写入),后一次性写信号与台账(台账最后记账,
|
|
152
|
+
* 中断重跑安全:未记账则重导,提案去重兜底)。不触发任何作业。
|
|
153
|
+
*/
|
|
154
|
+
export function importHistory(options) {
|
|
155
|
+
const now = options.now ?? Date.now;
|
|
156
|
+
const layer = options.workspaceRoot === undefined ? { kind: "global" } : { kind: "workspace", root: options.workspaceRoot };
|
|
157
|
+
const prepared = [];
|
|
158
|
+
const seenDigests = new Set();
|
|
159
|
+
const duplicates = new Map();
|
|
160
|
+
for (const path of options.paths) {
|
|
161
|
+
let content;
|
|
162
|
+
try {
|
|
163
|
+
content = readFileSync(path, "utf-8");
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
throw new ImportSourceError(path, `不可读(${err instanceof Error ? err.message : String(err)})`);
|
|
167
|
+
}
|
|
168
|
+
const digest = createHash("sha256").update(content).digest("hex");
|
|
169
|
+
if (seenDigests.has(digest) || existsSync(importLedgerPath(options.homeDir, digest))) {
|
|
170
|
+
duplicates.set(path, {
|
|
171
|
+
path,
|
|
172
|
+
sourceDigest: digest,
|
|
173
|
+
entries: 0,
|
|
174
|
+
badLines: 0,
|
|
175
|
+
skippedNoTool: 0,
|
|
176
|
+
duplicate: true,
|
|
177
|
+
});
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const adapter = HISTORY_ADAPTERS.find((candidate) => candidate.sniff(path));
|
|
181
|
+
if (adapter === undefined) {
|
|
182
|
+
throw new ImportSourceError(path, "不匹配任何已注册适配器(当前支持 Claude Code 会话 JSONL)");
|
|
183
|
+
}
|
|
184
|
+
seenDigests.add(digest);
|
|
185
|
+
prepared.push({ path, digest, extracted: adapter.extract(path) });
|
|
186
|
+
}
|
|
187
|
+
// ---- 阶段 2:写入(信号批量 append,台账最后记账) ----
|
|
188
|
+
const nowMs = now();
|
|
189
|
+
const allSignals = [];
|
|
190
|
+
const sources = [];
|
|
191
|
+
for (const item of prepared) {
|
|
192
|
+
const signals = [];
|
|
193
|
+
for (const { lineNo, entry } of item.extracted.entries) {
|
|
194
|
+
signals.push({
|
|
195
|
+
kind: "tool-call",
|
|
196
|
+
source: "history-import",
|
|
197
|
+
stableId: `import:${item.digest.slice(0, 12)}:${lineNo}`,
|
|
198
|
+
collectedAt: nowMs,
|
|
199
|
+
layer,
|
|
200
|
+
ts: entry.timestampMs ?? nowMs,
|
|
201
|
+
agent: eraseSecrets(entry.agent ?? "claude-code"),
|
|
202
|
+
session: eraseSecrets(entry.session ?? basename(item.path).replace(/\.jsonl$/, "")),
|
|
203
|
+
provider: eraseSecrets("claude-code"),
|
|
204
|
+
tool: eraseSecrets(entry.tool ?? "unknown"),
|
|
205
|
+
durationMs: entry.durationMs ?? 0,
|
|
206
|
+
result: entry.result ?? "ok",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
allSignals.push(...signals);
|
|
210
|
+
sources.push({
|
|
211
|
+
path: item.path,
|
|
212
|
+
sourceDigest: item.digest,
|
|
213
|
+
entries: signals.length,
|
|
214
|
+
badLines: item.extracted.badLines,
|
|
215
|
+
skippedNoTool: item.extracted.skippedNoTool,
|
|
216
|
+
duplicate: false,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
let signalFile = null;
|
|
220
|
+
if (allSignals.length > 0) {
|
|
221
|
+
signalFile = signalFilePath(options.homeDir, nowMs);
|
|
222
|
+
appendSignalLines(signalFile, allSignals);
|
|
223
|
+
}
|
|
224
|
+
for (const source of sources) {
|
|
225
|
+
const ledgerPath = importLedgerPath(options.homeDir, source.sourceDigest);
|
|
226
|
+
mkdirSync(dirname(ledgerPath), { recursive: true, mode: 0o700 });
|
|
227
|
+
writeFileSync(ledgerPath, `${JSON.stringify({
|
|
228
|
+
sourceDigest: source.sourceDigest,
|
|
229
|
+
entries: source.entries,
|
|
230
|
+
importedAtMs: nowMs,
|
|
231
|
+
layer,
|
|
232
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
sources: [...sources, ...duplicates.values()],
|
|
236
|
+
written: allSignals.length,
|
|
237
|
+
signalFile,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一次性 detached 作业入口(trellis-evolve 任务 11.5)。
|
|
3
|
+
*
|
|
4
|
+
* 契约:argv = [--hold-ms N] [--trigger event|manual|chain] <homeDir>。
|
|
5
|
+
* `--hold-ms`(持有锁 N 毫秒后再收尾,供集成测试观察"作业运行中"的锁
|
|
6
|
+
* 状态;生产不传,作业即时完成)。`--trigger` 记录触发来源入 job-run
|
|
7
|
+
* 档案(缺省 event;chain 为补跑链起,run-backfill D3)。锁由作业自身
|
|
8
|
+
* 持有(拉起方 spawn 前只做活锁检查,本入口再权威抢一次以防并发双拉起
|
|
9
|
+
* 竞态,抢不到立即退出、不做任何工作、不排队)。壳语义(抢锁/释放、
|
|
10
|
+
* job-run 落档含 trigger 字段、错误收口)在 `jobShell.js`——`trellis
|
|
11
|
+
* evolve run` 前台手动触发走同一壳(D1 不另写执行路径)。作业体是
|
|
12
|
+
* rule-based 治理流水的一次运行:`runEvolveJob`(信号 → rule-based
|
|
13
|
+
* 提案 → 机械校验 → L1 前闸 → 风险/采纳门 → 应用 → lesson,14.1 的
|
|
14
|
+
* fixture E2E 同链)。引擎内核(packages/evolve-engine 的
|
|
15
|
+
* schedule/recursion/proposer/selection/judge)golden 锚定但生产未经
|
|
16
|
+
* 此消费,待 L2/探针任务集接入。级别 off/observe 时作业体零副作用早退。
|
|
17
|
+
*
|
|
18
|
+
* 注意:本文件是入口脚本,被 import 时顶层即执行;测试只应 spawn 它,
|
|
19
|
+
* 不应 import(需要锁原语请从 `trigger.js` 取,作业壳从 `jobShell.js`
|
|
20
|
+
* 取,作业体从 `jobRun.js` 取)。
|
|
21
|
+
*/
|
|
22
|
+
export {};
|