@lmzhen/dsh-evolution-merge 0.0.0 → 0.29.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/README.md +27 -1
- package/lib/index.js +3814 -0
- package/lib/types/adapters/channel.d.ts +42 -0
- package/lib/types/adapters/engine-screen.d.ts +55 -0
- package/lib/types/adapters/library.d.ts +8 -0
- package/lib/types/adapters/model-runner.d.ts +28 -0
- package/lib/types/adapters/undo.d.ts +9 -0
- package/lib/types/approve-command.d.ts +22 -0
- package/lib/types/authoring-flow.d.ts +40 -0
- package/lib/types/channel-port.d.ts +61 -0
- package/lib/types/commands.d.ts +55 -0
- package/lib/types/commit-assembly.d.ts +27 -0
- package/lib/types/commit-execute.d.ts +29 -0
- package/lib/types/commit-plan.d.ts +45 -0
- package/lib/types/commit-receipt.d.ts +17 -0
- package/lib/types/contracts.d.ts +141 -0
- package/lib/types/coordinator.d.ts +65 -0
- package/lib/types/decision.d.ts +18 -0
- package/lib/types/doctor.d.ts +10 -0
- package/lib/types/gate-cache.d.ts +12 -0
- package/lib/types/index.d.ts +81 -0
- package/lib/types/ladder.d.ts +65 -0
- package/lib/types/merge-command.d.ts +39 -0
- package/lib/types/merge-routes.d.ts +24 -0
- package/lib/types/merge-tool.d.ts +22 -0
- package/lib/types/new-child-authoring.d.ts +29 -0
- package/lib/types/pair-command.d.ts +20 -0
- package/lib/types/params.d.ts +14 -0
- package/lib/types/pending-actions.d.ts +17 -0
- package/lib/types/prompts.d.ts +15 -0
- package/lib/types/read-commands.d.ts +9 -0
- package/lib/types/report.d.ts +10 -0
- package/lib/types/resume-command.d.ts +24 -0
- package/lib/types/round2-outcome.d.ts +13 -0
- package/lib/types/routes.d.ts +64 -0
- package/lib/types/rules.d.ts +24 -0
- package/lib/types/run-shell.d.ts +65 -0
- package/lib/types/runtime.d.ts +37 -0
- package/lib/types/scan-command.d.ts +21 -0
- package/lib/types/scan-ports.d.ts +32 -0
- package/lib/types/scan-run.d.ts +46 -0
- package/lib/types/skill-card.d.ts +12 -0
- package/lib/types/skipped.d.ts +8 -0
- package/lib/types/stage-tool-registry.d.ts +14 -0
- package/lib/types/stage-tool.d.ts +45 -0
- package/lib/types/staged-tree.d.ts +4 -0
- package/lib/types/staging-files.d.ts +15 -0
- package/lib/types/staging.d.ts +49 -0
- package/lib/types/state.d.ts +29 -0
- package/lib/types/status.d.ts +14 -0
- package/lib/types/supersede.d.ts +8 -0
- package/lib/types/undo-merge.d.ts +13 -0
- package/package.json +31 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,3814 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { screen, selectCandidates } from "@lmzhen/dsh-evolution-merge-engine";
|
|
4
|
+
import { DEFAULT_SKILL_LIMITS, authorityFrom, contentHash, createSubagentChannel, evolutionHome, evolutionIoAdapter, executeUnitUndo, isPresent, newRunRegistry, newSkillLibrary, parseFrontmatter, planUnitUndoAcross, policyStageLimits, probeAbsent, probeList, probePresent, probeText, probeUnknown, scanContentThreats, supportRefs, transactIo, validateFrontmatter, validateSupportPath } from "@lmzhen/dsh-evolution-core";
|
|
5
|
+
import { HOST_ROUTES, json, methodNotAllowed, registerHostRoutes } from "@lmzhen/dsh-evolution-core/http-routes";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
//#region lib/types/adapters/engine-screen.js
|
|
8
|
+
/**
|
|
9
|
+
* G3 screen adapter: cards -> corpus snapshot -> EnginePort.screen.
|
|
10
|
+
*
|
|
11
|
+
* The vendor engine reads a corpus from disk; host cards are content. This module
|
|
12
|
+
* writes only SKILL.md support-free snapshots under the run directory. It does not
|
|
13
|
+
* decide candidates: the Q02 selector is injected so engine/report-map stays the
|
|
14
|
+
* single source of that policy.
|
|
15
|
+
*/
|
|
16
|
+
function safeName(name, index) {
|
|
17
|
+
const cleaned = name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
18
|
+
return (cleaned === "" ? "skill-" + String(index) : cleaned) + "__" + String(index);
|
|
19
|
+
}
|
|
20
|
+
/** Write the exact card bodies as a support-free corpus snapshot and return its root. */
|
|
21
|
+
async function snapshotCards(io, home, runId, cards) {
|
|
22
|
+
const root = join(home, "merge", "runs", runId, "corpus");
|
|
23
|
+
for (let index = 0; index < cards.length; index += 1) {
|
|
24
|
+
const card = cards[index];
|
|
25
|
+
if (card === void 0) continue;
|
|
26
|
+
await io.writeText(join(root, safeName(card.name, index), "SKILL.md"), card.body);
|
|
27
|
+
}
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
function createEngineScreening(deps) {
|
|
31
|
+
return async (cards, runId, signal) => {
|
|
32
|
+
const corpusDir = await snapshotCards(deps.io, deps.home, runId, cards);
|
|
33
|
+
const outDir = join(deps.home, "merge", "runs", runId, "engine");
|
|
34
|
+
const result = await deps.engine.screen({
|
|
35
|
+
corpusDir,
|
|
36
|
+
outDir,
|
|
37
|
+
...deps.cfg === void 0 ? {} : { cfg: deps.cfg },
|
|
38
|
+
...deps.runner === void 0 ? {} : { runner: deps.runner },
|
|
39
|
+
signal
|
|
40
|
+
});
|
|
41
|
+
if (!result.ok) throw new Error("engine screen failed: " + result.error.code + (result.error.stage === void 0 ? "" : " @" + result.error.stage) + (result.error.detail === void 0 ? "" : ": " + result.error.detail));
|
|
42
|
+
const selection = deps.select(result.report);
|
|
43
|
+
if (deps.onSelection !== void 0) await deps.onSelection(selection, runId);
|
|
44
|
+
return Object.assign(selection.candidates.slice(), selection.candidatePairs === void 0 ? {} : { candidatePairs: selection.candidatePairs });
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region lib/types/adapters/model-runner.js
|
|
49
|
+
function append(chunk, text) {
|
|
50
|
+
if (chunk.type === "text-delta" || chunk.type === "text") return text + (chunk.text ?? "");
|
|
51
|
+
if (chunk.type === "finish" && chunk.reason?.kind === "error") throw new Error("model call failed: " + (chunk.reason.failure?.message ?? "unknown"));
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
function createEngineRunner(llm, route) {
|
|
55
|
+
return async (prompt) => {
|
|
56
|
+
const selection = route();
|
|
57
|
+
let text = "";
|
|
58
|
+
if (llm.stream !== void 0) {
|
|
59
|
+
for await (const chunk of llm.stream({
|
|
60
|
+
provider: selection.provider,
|
|
61
|
+
model: selection.model,
|
|
62
|
+
messages: [{
|
|
63
|
+
role: "user",
|
|
64
|
+
content: [{
|
|
65
|
+
type: "text",
|
|
66
|
+
text: prompt
|
|
67
|
+
}]
|
|
68
|
+
}],
|
|
69
|
+
temperature: 0
|
|
70
|
+
})) text = append(chunk, text);
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
if (llm.prepareCall === void 0) throw new Error("llm service has neither stream nor prepareCall");
|
|
74
|
+
const call = await llm.prepareCall({
|
|
75
|
+
provider: selection.provider,
|
|
76
|
+
model: selection.model,
|
|
77
|
+
temperature: 0
|
|
78
|
+
});
|
|
79
|
+
for await (const chunk of call.stream({
|
|
80
|
+
...call.config,
|
|
81
|
+
messages: [{
|
|
82
|
+
role: "user",
|
|
83
|
+
content: [{
|
|
84
|
+
type: "text",
|
|
85
|
+
text: prompt
|
|
86
|
+
}]
|
|
87
|
+
}]
|
|
88
|
+
})) text = append(chunk, text);
|
|
89
|
+
return text;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region lib/types/contracts.js
|
|
94
|
+
/**
|
|
95
|
+
* Frozen merge-domain vocabulary. No io, no host, no imports from the plugin shell.
|
|
96
|
+
*
|
|
97
|
+
* The data layer keeps four states; the user face collapses verify/fuse into one retry
|
|
98
|
+
* queue and keeps the stage as a chip. The API queue must never expose the raw state.
|
|
99
|
+
*/
|
|
100
|
+
const MERGE_STATE_VERSION = 1;
|
|
101
|
+
const MERGE_PAIR_STATES = [
|
|
102
|
+
"verify",
|
|
103
|
+
"pending",
|
|
104
|
+
"fuse",
|
|
105
|
+
"skipped"
|
|
106
|
+
];
|
|
107
|
+
const MERGE_QUEUES = [
|
|
108
|
+
"pending",
|
|
109
|
+
"retry",
|
|
110
|
+
"skipped"
|
|
111
|
+
];
|
|
112
|
+
/** User-facing queue of one data state. verify/fuse share retry, not two tabs. */
|
|
113
|
+
function queueOf(state) {
|
|
114
|
+
switch (state) {
|
|
115
|
+
case "pending": return "pending";
|
|
116
|
+
case "verify":
|
|
117
|
+
case "fuse": return "retry";
|
|
118
|
+
case "skipped": return "skipped";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Coarse stage chip; skipped has no active stage. */
|
|
122
|
+
function stageOf(state) {
|
|
123
|
+
switch (state) {
|
|
124
|
+
case "verify": return "verify";
|
|
125
|
+
case "pending": return "decide";
|
|
126
|
+
case "fuse": return "author";
|
|
127
|
+
case "skipped": return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Stable pair identity; NUL cannot appear in a skill name. */
|
|
131
|
+
function pairKey(a, b) {
|
|
132
|
+
return a < b ? a + "\0" + b : b + "\0" + a;
|
|
133
|
+
}
|
|
134
|
+
/** Deterministic, URL-safe id derived from the pair key (FNV-1a 32). */
|
|
135
|
+
function rowIdOf(key) {
|
|
136
|
+
let hash = 2166136261;
|
|
137
|
+
for (let index = 0; index < key.length; index += 1) {
|
|
138
|
+
hash ^= key.charCodeAt(index);
|
|
139
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
140
|
+
}
|
|
141
|
+
return "mr_" + hash.toString(36).padStart(7, "0");
|
|
142
|
+
}
|
|
143
|
+
function emptyMergeState() {
|
|
144
|
+
return {
|
|
145
|
+
schemaVersion: 1,
|
|
146
|
+
rows: []
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** One record can be retried only when the failure says so and names a stage. */
|
|
150
|
+
const MERGE_MAX_ATTEMPTS = 3;
|
|
151
|
+
function canRetry(record) {
|
|
152
|
+
if (record.state === "skipped") return false;
|
|
153
|
+
if ((record.attempts ?? 0) >= 3) return false;
|
|
154
|
+
if (record.failure === void 0) return record.state !== "pending";
|
|
155
|
+
return record.failure.retryable && record.failure.retryFrom !== "none";
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region lib/types/state.js
|
|
159
|
+
/**
|
|
160
|
+
* The one merge-state ledger: <home>/merge/merge-state.json.
|
|
161
|
+
*
|
|
162
|
+
* Reads are pure projections (no write, no reconciliation side effect). Every write goes
|
|
163
|
+
* through mutateMergeState and a single transactIo, so there is no second file and no
|
|
164
|
+
* incremental writer. A row's queue is always derived from its state on read; a hand-edited
|
|
165
|
+
* mismatch is repaired by the next write, never by a GET.
|
|
166
|
+
*/
|
|
167
|
+
function mergeStatePath(home) {
|
|
168
|
+
return join(home, "merge", "merge-state.json");
|
|
169
|
+
}
|
|
170
|
+
function isState(value) {
|
|
171
|
+
return typeof value === "string" && MERGE_PAIR_STATES.includes(value);
|
|
172
|
+
}
|
|
173
|
+
function parseState(raw) {
|
|
174
|
+
if (raw === null) return {
|
|
175
|
+
ok: true,
|
|
176
|
+
rows: []
|
|
177
|
+
};
|
|
178
|
+
let parsed;
|
|
179
|
+
try {
|
|
180
|
+
parsed = JSON.parse(raw);
|
|
181
|
+
} catch {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
rows: []
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const body = parsed;
|
|
188
|
+
if (!Array.isArray(body.rows)) return {
|
|
189
|
+
ok: false,
|
|
190
|
+
rows: []
|
|
191
|
+
};
|
|
192
|
+
const rows = [];
|
|
193
|
+
let skipped = 0;
|
|
194
|
+
for (const value of body.rows) {
|
|
195
|
+
const row = value;
|
|
196
|
+
if (typeof row.id !== "string" || typeof row.pairKey !== "string" || typeof row.a !== "string" || typeof row.b !== "string") {
|
|
197
|
+
skipped += 1;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!isState(row.state) || typeof row.createdAt !== "string" || typeof row.updatedAt !== "string") {
|
|
201
|
+
skipped += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const normalized = {
|
|
205
|
+
id: row.id,
|
|
206
|
+
pairKey: row.pairKey,
|
|
207
|
+
a: row.a,
|
|
208
|
+
b: row.b,
|
|
209
|
+
state: row.state,
|
|
210
|
+
queue: queueOf(row.state),
|
|
211
|
+
createdAt: row.createdAt,
|
|
212
|
+
updatedAt: row.updatedAt,
|
|
213
|
+
...row.failure === void 0 ? {} : { failure: row.failure },
|
|
214
|
+
...row.decision === void 0 ? {} : { decision: row.decision },
|
|
215
|
+
...typeof row.runId === "string" ? { runId: row.runId } : {},
|
|
216
|
+
...typeof row.unitId === "string" ? { unitId: row.unitId } : {},
|
|
217
|
+
...row.origin === "agent" || row.origin === "human" ? { origin: row.origin } : {},
|
|
218
|
+
...typeof row.attempts === "number" ? { attempts: row.attempts } : {}
|
|
219
|
+
};
|
|
220
|
+
rows.push(normalized);
|
|
221
|
+
}
|
|
222
|
+
const versionNote = typeof body.schemaVersion === "number" && body.schemaVersion !== 1 ? "the index declares schemaVersion " + String(body.schemaVersion) + " while this build writes " + String(1) : void 0;
|
|
223
|
+
const note = versionNote === void 0 ? skipped === 0 ? void 0 : String(skipped) + " invalid row(s) skipped" : skipped === 0 ? versionNote : versionNote + "; " + String(skipped) + " invalid row(s) skipped";
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
rows,
|
|
227
|
+
...note === void 0 ? {} : { note }
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** Pure read: never writes, never deletes a row. */
|
|
231
|
+
async function loadMergeState(io, home) {
|
|
232
|
+
let raw = null;
|
|
233
|
+
try {
|
|
234
|
+
raw = await io.readText(mergeStatePath(home));
|
|
235
|
+
} catch {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
state: {
|
|
239
|
+
schemaVersion: 1,
|
|
240
|
+
rows: []
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const parsed = parseState(raw);
|
|
245
|
+
return {
|
|
246
|
+
ok: parsed.ok,
|
|
247
|
+
state: {
|
|
248
|
+
schemaVersion: 1,
|
|
249
|
+
rows: parsed.rows
|
|
250
|
+
},
|
|
251
|
+
...parsed.note === void 0 ? {} : { note: parsed.note }
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/** The ONE write point. The callback returns the whole row set, not a delta. */
|
|
255
|
+
async function mutateMergeState(io, home, now, fn) {
|
|
256
|
+
let result;
|
|
257
|
+
await transactIo(io, mergeStatePath(home), (current) => {
|
|
258
|
+
const out = fn(parseState(current).rows);
|
|
259
|
+
result = out.result;
|
|
260
|
+
return JSON.stringify({
|
|
261
|
+
schemaVersion: 1,
|
|
262
|
+
rows: out.rows
|
|
263
|
+
}, null, 2);
|
|
264
|
+
});
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
async function upsertRow(io, home, now, row) {
|
|
268
|
+
await mutateMergeState(io, home, now, (rows) => {
|
|
269
|
+
const index = rows.findIndex((item) => item.pairKey === row.pairKey);
|
|
270
|
+
const normalized = {
|
|
271
|
+
...row,
|
|
272
|
+
queue: queueOf(row.state)
|
|
273
|
+
};
|
|
274
|
+
const next = [...rows];
|
|
275
|
+
if (index < 0) next.push(normalized);
|
|
276
|
+
else next[index] = {
|
|
277
|
+
...normalized,
|
|
278
|
+
createdAt: rows[index]?.createdAt ?? normalized.createdAt
|
|
279
|
+
};
|
|
280
|
+
return {
|
|
281
|
+
rows: next,
|
|
282
|
+
result: void 0
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
async function moveRow(io, home, now, id, patch) {
|
|
287
|
+
return await mutateMergeState(io, home, now, (rows) => {
|
|
288
|
+
const index = rows.findIndex((item) => item.id === id);
|
|
289
|
+
if (index < 0) return {
|
|
290
|
+
rows,
|
|
291
|
+
result: void 0
|
|
292
|
+
};
|
|
293
|
+
const current = rows[index];
|
|
294
|
+
const state = patch.state ?? current.state;
|
|
295
|
+
const next = {
|
|
296
|
+
...current,
|
|
297
|
+
state,
|
|
298
|
+
queue: queueOf(state),
|
|
299
|
+
updatedAt: new Date(now()).toISOString(),
|
|
300
|
+
...patch.failure === void 0 ? {} : { failure: patch.failure },
|
|
301
|
+
...patch.runId === void 0 ? {} : { runId: patch.runId },
|
|
302
|
+
...patch.unitId === void 0 ? {} : { unitId: patch.unitId },
|
|
303
|
+
...patch.origin === void 0 ? {} : { origin: patch.origin },
|
|
304
|
+
...patch.attempts === void 0 ? {} : { attempts: patch.attempts }
|
|
305
|
+
};
|
|
306
|
+
const copy = [...rows];
|
|
307
|
+
copy[index] = next;
|
|
308
|
+
return {
|
|
309
|
+
rows: copy,
|
|
310
|
+
result: next
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
async function dropRow(io, home, now, id) {
|
|
315
|
+
return await mutateMergeState(io, home, now, (rows) => {
|
|
316
|
+
const next = rows.filter((item) => item.id !== id);
|
|
317
|
+
return {
|
|
318
|
+
rows: next,
|
|
319
|
+
result: next.length !== rows.length
|
|
320
|
+
};
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
async function rowsOf(io, home, queue) {
|
|
324
|
+
const loaded = await loadMergeState(io, home);
|
|
325
|
+
return queue === void 0 ? loaded.state.rows : loaded.state.rows.filter((row) => row.queue === queue);
|
|
326
|
+
}
|
|
327
|
+
//#endregion
|
|
328
|
+
//#region lib/types/coordinator.js
|
|
329
|
+
/**
|
|
330
|
+
* Pure merge transition table. No io, no run shell, no subagents.
|
|
331
|
+
*
|
|
332
|
+
* Every transition returns the whole next row (or a drop), so the caller can persist it
|
|
333
|
+
* through mutateMergeState in one transaction. Invalid transitions are refused by name;
|
|
334
|
+
* a caller never "repairs" a row by guessing which state the operator meant.
|
|
335
|
+
*/
|
|
336
|
+
function invalid(event, state) {
|
|
337
|
+
return {
|
|
338
|
+
ok: false,
|
|
339
|
+
code: "invalid-transition",
|
|
340
|
+
message: "cannot apply " + event.type + " to " + (state ?? "no row")
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/** Drop fields that must not survive a transition; optional fields stay absent. */
|
|
344
|
+
function base(row, at) {
|
|
345
|
+
return {
|
|
346
|
+
id: row.id,
|
|
347
|
+
pairKey: row.pairKey,
|
|
348
|
+
a: row.a,
|
|
349
|
+
b: row.b,
|
|
350
|
+
state: row.state,
|
|
351
|
+
queue: row.queue,
|
|
352
|
+
createdAt: row.createdAt,
|
|
353
|
+
updatedAt: at,
|
|
354
|
+
...row.runId === void 0 ? {} : { runId: row.runId },
|
|
355
|
+
...row.unitId === void 0 ? {} : { unitId: row.unitId },
|
|
356
|
+
...row.origin === void 0 ? {} : { origin: row.origin },
|
|
357
|
+
...row.decision === void 0 ? {} : { decision: row.decision },
|
|
358
|
+
...row.attempts === void 0 ? {} : { attempts: row.attempts },
|
|
359
|
+
...row.stagingRef === void 0 ? {} : { stagingRef: row.stagingRef },
|
|
360
|
+
...row.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: row.stagingExpiresAt }
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function next(row, at, state, patch = {}) {
|
|
364
|
+
return {
|
|
365
|
+
ok: true,
|
|
366
|
+
row: {
|
|
367
|
+
...base(row, at),
|
|
368
|
+
state,
|
|
369
|
+
queue: queueOf(state),
|
|
370
|
+
...patch
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function transition(current, event) {
|
|
375
|
+
if (event.type === "candidate") {
|
|
376
|
+
if (current !== void 0) return {
|
|
377
|
+
ok: true,
|
|
378
|
+
row: current
|
|
379
|
+
};
|
|
380
|
+
const key = pairKey(event.a, event.b);
|
|
381
|
+
return {
|
|
382
|
+
ok: true,
|
|
383
|
+
row: {
|
|
384
|
+
id: rowIdOf(key),
|
|
385
|
+
pairKey: key,
|
|
386
|
+
a: event.a,
|
|
387
|
+
b: event.b,
|
|
388
|
+
state: "verify",
|
|
389
|
+
queue: "retry",
|
|
390
|
+
createdAt: event.at,
|
|
391
|
+
updatedAt: event.at
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
if (event.type === "source-gone") return {
|
|
396
|
+
ok: true,
|
|
397
|
+
row: null
|
|
398
|
+
};
|
|
399
|
+
if (event.type === "gate-keep") {
|
|
400
|
+
if (current !== void 0) return {
|
|
401
|
+
ok: true,
|
|
402
|
+
row: current
|
|
403
|
+
};
|
|
404
|
+
const key = pairKey(event.a, event.b);
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
row: {
|
|
408
|
+
id: rowIdOf(key),
|
|
409
|
+
pairKey: key,
|
|
410
|
+
a: event.a,
|
|
411
|
+
b: event.b,
|
|
412
|
+
state: "skipped",
|
|
413
|
+
queue: "skipped",
|
|
414
|
+
origin: "agent",
|
|
415
|
+
createdAt: event.at,
|
|
416
|
+
updatedAt: event.at
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (current === void 0) return invalid(event, void 0);
|
|
421
|
+
switch (event.type) {
|
|
422
|
+
case "round1-keep": return current.state === "verify" ? next(current, event.at, "skipped", { origin: "agent" }) : invalid(event, current.state);
|
|
423
|
+
case "round1-merge":
|
|
424
|
+
if (current.state !== "verify") return invalid(event, current.state);
|
|
425
|
+
return event.approval === "on" ? next(current, event.at, "pending", {
|
|
426
|
+
...event.decision === void 0 ? {} : { decision: event.decision },
|
|
427
|
+
...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
|
|
428
|
+
...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt }
|
|
429
|
+
}) : next(current, event.at, "fuse", {
|
|
430
|
+
...event.decision === void 0 ? {} : { decision: event.decision },
|
|
431
|
+
...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
|
|
432
|
+
...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt }
|
|
433
|
+
});
|
|
434
|
+
case "round1-fail": return current.state === "verify" ? next(current, event.at, "verify", {
|
|
435
|
+
failure: event.failure,
|
|
436
|
+
attempts: (current.attempts ?? 0) + 1
|
|
437
|
+
}) : invalid(event, current.state);
|
|
438
|
+
case "approve": return current.state === "pending" ? next(current, event.at, "fuse", { ...event.runId === void 0 ? {} : { runId: event.runId } }) : invalid(event, current.state);
|
|
439
|
+
case "reject": return current.state === "pending" ? next(current, event.at, "skipped", { origin: "human" }) : invalid(event, current.state);
|
|
440
|
+
case "round2-fail": return current.state === "fuse" ? next(current, event.at, "fuse", {
|
|
441
|
+
failure: event.failure,
|
|
442
|
+
attempts: (current.attempts ?? 0) + 1
|
|
443
|
+
}) : invalid(event, current.state);
|
|
444
|
+
case "round2-success": return current.state === "fuse" ? {
|
|
445
|
+
ok: true,
|
|
446
|
+
row: null
|
|
447
|
+
} : invalid(event, current.state);
|
|
448
|
+
case "restore": return current.state === "skipped" ? next(current, event.at, "verify") : invalid(event, current.state);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
//#endregion
|
|
452
|
+
//#region lib/types/status.js
|
|
453
|
+
function countRows(rows) {
|
|
454
|
+
const counts = {
|
|
455
|
+
pending: 0,
|
|
456
|
+
retry: 0,
|
|
457
|
+
skipped: 0
|
|
458
|
+
};
|
|
459
|
+
for (const row of rows) counts[queueOf(row.state)] += 1;
|
|
460
|
+
return counts;
|
|
461
|
+
}
|
|
462
|
+
function describe(row) {
|
|
463
|
+
const stage = stageOf(row.state);
|
|
464
|
+
const target = row.decision?.shouldMerge === true ? " → " + row.decision.target : "";
|
|
465
|
+
return row.a + " + " + row.b + " [" + row.state + (stage === null ? "" : "/" + stage) + "]" + target + (row.failure === void 0 ? "" : " — " + row.failure.message);
|
|
466
|
+
}
|
|
467
|
+
function formatStatus(rows) {
|
|
468
|
+
const counts = countRows(rows);
|
|
469
|
+
const lines = ["merge: pending " + String(counts.pending) + ", retry " + String(counts.retry) + ", skipped " + String(counts.skipped)];
|
|
470
|
+
for (const row of rows) lines.push("- " + describe(row));
|
|
471
|
+
return lines.join("\n");
|
|
472
|
+
}
|
|
473
|
+
function formatQueue(rows, queue) {
|
|
474
|
+
const selected = rows.filter((row) => queueOf(row.state) === queue);
|
|
475
|
+
if (selected.length === 0) return "merge " + queue + ": empty";
|
|
476
|
+
return ["merge " + queue + ": " + String(selected.length)].concat(selected.map((row) => "- " + describe(row))).join("\n");
|
|
477
|
+
}
|
|
478
|
+
//#endregion
|
|
479
|
+
//#region lib/types/routes.js
|
|
480
|
+
function toRowView(row) {
|
|
481
|
+
return {
|
|
482
|
+
id: row.id,
|
|
483
|
+
pairKey: row.pairKey,
|
|
484
|
+
a: row.a,
|
|
485
|
+
b: row.b,
|
|
486
|
+
state: row.state,
|
|
487
|
+
queue: queueOf(row.state),
|
|
488
|
+
stage: stageOf(row.state),
|
|
489
|
+
label: row.a + " + " + row.b,
|
|
490
|
+
updatedAt: row.updatedAt,
|
|
491
|
+
createdAt: row.createdAt,
|
|
492
|
+
...row.decision?.target === void 0 ? {} : { target: row.decision.target },
|
|
493
|
+
...row.decision?.confidence === void 0 ? {} : { confidence: row.decision.confidence },
|
|
494
|
+
...row.failure === void 0 ? {} : { failure: {
|
|
495
|
+
stage: row.failure.stage,
|
|
496
|
+
code: row.failure.code,
|
|
497
|
+
message: row.failure.message,
|
|
498
|
+
retryable: row.failure.retryable
|
|
499
|
+
} },
|
|
500
|
+
...row.attempts === void 0 ? {} : { attempts: row.attempts },
|
|
501
|
+
...row.origin === void 0 ? {} : { origin: row.origin },
|
|
502
|
+
...row.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: row.stagingExpiresAt },
|
|
503
|
+
actions: row.state === "pending" ? ["/evolution merge approve " + row.id, "/evolution merge reject " + row.id] : row.state === "skipped" ? ["/evolution merge skipped --restore " + row.id] : ["/evolution merge resume " + row.id]
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function buildMergeView(rows, commands, latestRun, approval) {
|
|
507
|
+
const counts = countRows(rows);
|
|
508
|
+
return {
|
|
509
|
+
schemaVersion: 1,
|
|
510
|
+
counts,
|
|
511
|
+
tabs: [
|
|
512
|
+
{
|
|
513
|
+
queue: "pending",
|
|
514
|
+
count: counts.pending
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
queue: "retry",
|
|
518
|
+
count: counts.retry
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
queue: "skipped",
|
|
522
|
+
count: counts.skipped
|
|
523
|
+
}
|
|
524
|
+
],
|
|
525
|
+
commands,
|
|
526
|
+
...latestRun === void 0 ? {} : { latestRun },
|
|
527
|
+
...approval === void 0 ? {} : { approval }
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function pageQueue(rows, queue, cursor = 0, limit = 50) {
|
|
531
|
+
const filtered = rows.filter((row) => queueOf(row.state) === queue);
|
|
532
|
+
const size = Math.max(1, Math.min(200, limit));
|
|
533
|
+
const start = Math.max(0, cursor);
|
|
534
|
+
const items = filtered.slice(start, start + size).map(toRowView);
|
|
535
|
+
const next = start + size < filtered.length ? String(start + size) : void 0;
|
|
536
|
+
return {
|
|
537
|
+
queue,
|
|
538
|
+
items,
|
|
539
|
+
...next === void 0 ? {} : { nextCursor: next }
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
function toRunFact(run) {
|
|
543
|
+
return {
|
|
544
|
+
id: run.id,
|
|
545
|
+
state: run.state,
|
|
546
|
+
...run.command === void 0 ? {} : { command: run.command },
|
|
547
|
+
...run.stage === void 0 ? {} : { stage: run.stage },
|
|
548
|
+
...run.progress === void 0 ? {} : { progress: run.progress },
|
|
549
|
+
startedAt: run.startedAt,
|
|
550
|
+
...run.endedAt === void 0 ? {} : { endedAt: run.endedAt },
|
|
551
|
+
...run.resultRef === void 0 ? {} : { resultRef: run.resultRef },
|
|
552
|
+
...run.failure === void 0 ? {} : { failure: run.failure },
|
|
553
|
+
...run.supersededAt === void 0 ? {} : { supersededAt: run.supersededAt }
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
//#endregion
|
|
557
|
+
//#region lib/types/merge-routes.js
|
|
558
|
+
const MERGE_ROUTES = HOST_ROUTES.merge;
|
|
559
|
+
function makeMergeRoutes(deps, _audit) {
|
|
560
|
+
const absent = () => json(503, {
|
|
561
|
+
ok: false,
|
|
562
|
+
error: {
|
|
563
|
+
code: "merge-service-absent",
|
|
564
|
+
message: "merge service is not mounted"
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
const get = (request) => request.method === "GET" ? null : methodNotAllowed(["GET"]);
|
|
568
|
+
const url = (request) => new URL(request.url, "http://local");
|
|
569
|
+
return [
|
|
570
|
+
{
|
|
571
|
+
path: MERGE_ROUTES.view,
|
|
572
|
+
methods: ["GET"],
|
|
573
|
+
requestBody: "buffered",
|
|
574
|
+
fetch: async (request) => {
|
|
575
|
+
const denied = get(request);
|
|
576
|
+
if (denied !== null) return denied;
|
|
577
|
+
const source = deps.getSource();
|
|
578
|
+
if (source === void 0) return absent();
|
|
579
|
+
const latestMerge = (await source.runs(void 0, 20)).find((run) => run.kind === "merge");
|
|
580
|
+
return json(200, {
|
|
581
|
+
ok: true,
|
|
582
|
+
data: buildMergeView(await source.rows(), source.commands(), latestMerge === void 0 ? void 0 : toRunFact(latestMerge), source.approval?.())
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
path: MERGE_ROUTES.rows,
|
|
588
|
+
methods: ["GET"],
|
|
589
|
+
requestBody: "buffered",
|
|
590
|
+
fetch: async (request) => {
|
|
591
|
+
const denied = get(request);
|
|
592
|
+
if (denied !== null) return denied;
|
|
593
|
+
const source = deps.getSource();
|
|
594
|
+
if (source === void 0) return absent();
|
|
595
|
+
const query = url(request).searchParams;
|
|
596
|
+
const queue = query.get("queue") ?? "retry";
|
|
597
|
+
if (queue !== "pending" && queue !== "retry" && queue !== "skipped") return json(400, {
|
|
598
|
+
ok: false,
|
|
599
|
+
error: {
|
|
600
|
+
code: "bad-queue",
|
|
601
|
+
message: "queue must be pending/retry/skipped"
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
const cursor = Number.parseInt(query.get("cursor") ?? "0", 10);
|
|
605
|
+
const limit = Number.parseInt(query.get("limit") ?? "50", 10);
|
|
606
|
+
return json(200, {
|
|
607
|
+
ok: true,
|
|
608
|
+
data: pageQueue(await source.rows(), queue, Number.isFinite(cursor) ? cursor : 0, Number.isFinite(limit) ? limit : 50)
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
path: MERGE_ROUTES.run,
|
|
614
|
+
methods: ["GET"],
|
|
615
|
+
requestBody: "buffered",
|
|
616
|
+
fetch: async (request) => {
|
|
617
|
+
const denied = get(request);
|
|
618
|
+
if (denied !== null) return denied;
|
|
619
|
+
const source = deps.getSource();
|
|
620
|
+
if (source === void 0) return absent();
|
|
621
|
+
const id = url(request).searchParams.get("id");
|
|
622
|
+
if (id === null || id === "") return json(400, {
|
|
623
|
+
ok: false,
|
|
624
|
+
error: {
|
|
625
|
+
code: "missing-id",
|
|
626
|
+
message: "id is required"
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
const run = await source.run(id);
|
|
630
|
+
return run === void 0 ? json(404, {
|
|
631
|
+
ok: false,
|
|
632
|
+
error: {
|
|
633
|
+
code: "run-not-found",
|
|
634
|
+
message: "merge run not found: " + id
|
|
635
|
+
}
|
|
636
|
+
}) : json(200, {
|
|
637
|
+
ok: true,
|
|
638
|
+
data: toRunFact(run)
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
path: MERGE_ROUTES.runs,
|
|
644
|
+
methods: ["GET"],
|
|
645
|
+
requestBody: "buffered",
|
|
646
|
+
fetch: async (request) => {
|
|
647
|
+
const denied = get(request);
|
|
648
|
+
if (denied !== null) return denied;
|
|
649
|
+
const source = deps.getSource();
|
|
650
|
+
if (source === void 0) return absent();
|
|
651
|
+
const query = url(request).searchParams;
|
|
652
|
+
const limit = Number.parseInt(query.get("limit") ?? "20", 10);
|
|
653
|
+
return json(200, {
|
|
654
|
+
ok: true,
|
|
655
|
+
data: { items: (await source.runs(query.get("cursor") ?? void 0, Number.isFinite(limit) ? limit : 20)).map(toRunFact) }
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
];
|
|
660
|
+
}
|
|
661
|
+
//#endregion
|
|
662
|
+
//#region lib/types/commands.js
|
|
663
|
+
const USAGE = "usage: /evolution merge scan | pair <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending | approve <id> | reject <id> | resume [id] [--clear <id>] | skipped [--restore <id>] | undo <target> --run <id>";
|
|
664
|
+
function flags(args, allowed) {
|
|
665
|
+
const values = /* @__PURE__ */ new Map();
|
|
666
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
667
|
+
const token = args[index];
|
|
668
|
+
if (!token.startsWith("--")) continue;
|
|
669
|
+
const name = token.slice(2);
|
|
670
|
+
if (!allowed.includes(name)) return {
|
|
671
|
+
values,
|
|
672
|
+
error: "unknown flag: --" + name
|
|
673
|
+
};
|
|
674
|
+
const value = args[index + 1];
|
|
675
|
+
if (value === void 0 || value.startsWith("--")) return {
|
|
676
|
+
values,
|
|
677
|
+
error: "missing value for --" + name
|
|
678
|
+
};
|
|
679
|
+
values.set(name, value);
|
|
680
|
+
index += 1;
|
|
681
|
+
}
|
|
682
|
+
return { values };
|
|
683
|
+
}
|
|
684
|
+
function positional(args) {
|
|
685
|
+
return args.filter((token, index) => !token.startsWith("--") && !(index > 0 && args[index - 1].startsWith("--")));
|
|
686
|
+
}
|
|
687
|
+
function parseMergeCommand(argv) {
|
|
688
|
+
const [verb, ...rest] = argv;
|
|
689
|
+
if (verb === void 0 || verb === "help" || verb === "--help" || verb === "-h") return {
|
|
690
|
+
ok: true,
|
|
691
|
+
command: { verb: "scan" },
|
|
692
|
+
help: true
|
|
693
|
+
};
|
|
694
|
+
const pos = positional(rest);
|
|
695
|
+
switch (verb) {
|
|
696
|
+
case "scan": return pos.length === 0 ? {
|
|
697
|
+
ok: true,
|
|
698
|
+
command: { verb: "scan" }
|
|
699
|
+
} : {
|
|
700
|
+
ok: false,
|
|
701
|
+
code: "usage",
|
|
702
|
+
message: USAGE
|
|
703
|
+
};
|
|
704
|
+
case "pair": return pos.length === 2 ? {
|
|
705
|
+
ok: true,
|
|
706
|
+
command: {
|
|
707
|
+
verb: "pair",
|
|
708
|
+
a: pos[0],
|
|
709
|
+
b: pos[1]
|
|
710
|
+
}
|
|
711
|
+
} : {
|
|
712
|
+
ok: false,
|
|
713
|
+
code: "usage",
|
|
714
|
+
message: USAGE
|
|
715
|
+
};
|
|
716
|
+
case "status": return pos.length <= 1 ? {
|
|
717
|
+
ok: true,
|
|
718
|
+
command: {
|
|
719
|
+
verb: "status",
|
|
720
|
+
...pos[0] === void 0 ? {} : { id: pos[0] }
|
|
721
|
+
}
|
|
722
|
+
} : {
|
|
723
|
+
ok: false,
|
|
724
|
+
code: "usage",
|
|
725
|
+
message: USAGE
|
|
726
|
+
};
|
|
727
|
+
case "report": return pos.length <= 1 ? {
|
|
728
|
+
ok: true,
|
|
729
|
+
command: {
|
|
730
|
+
verb: "report",
|
|
731
|
+
...pos[0] === void 0 ? {} : { id: pos[0] }
|
|
732
|
+
}
|
|
733
|
+
} : {
|
|
734
|
+
ok: false,
|
|
735
|
+
code: "usage",
|
|
736
|
+
message: USAGE
|
|
737
|
+
};
|
|
738
|
+
case "cancel": return pos.length <= 1 ? {
|
|
739
|
+
ok: true,
|
|
740
|
+
command: {
|
|
741
|
+
verb: "cancel",
|
|
742
|
+
...pos[0] === void 0 ? {} : { id: pos[0] }
|
|
743
|
+
}
|
|
744
|
+
} : {
|
|
745
|
+
ok: false,
|
|
746
|
+
code: "usage",
|
|
747
|
+
message: USAGE
|
|
748
|
+
};
|
|
749
|
+
case "runs": return pos.length === 0 ? {
|
|
750
|
+
ok: true,
|
|
751
|
+
command: { verb: "runs" }
|
|
752
|
+
} : {
|
|
753
|
+
ok: false,
|
|
754
|
+
code: "usage",
|
|
755
|
+
message: USAGE
|
|
756
|
+
};
|
|
757
|
+
case "doctor": return pos.length === 0 ? {
|
|
758
|
+
ok: true,
|
|
759
|
+
command: { verb: "doctor" }
|
|
760
|
+
} : {
|
|
761
|
+
ok: false,
|
|
762
|
+
code: "usage",
|
|
763
|
+
message: USAGE
|
|
764
|
+
};
|
|
765
|
+
case "pending": return pos.length === 0 ? {
|
|
766
|
+
ok: true,
|
|
767
|
+
command: { verb: "pending" }
|
|
768
|
+
} : {
|
|
769
|
+
ok: false,
|
|
770
|
+
code: "usage",
|
|
771
|
+
message: USAGE
|
|
772
|
+
};
|
|
773
|
+
case "approve": return pos.length === 1 ? {
|
|
774
|
+
ok: true,
|
|
775
|
+
command: {
|
|
776
|
+
verb: "approve",
|
|
777
|
+
id: pos[0]
|
|
778
|
+
}
|
|
779
|
+
} : {
|
|
780
|
+
ok: false,
|
|
781
|
+
code: "usage",
|
|
782
|
+
message: USAGE
|
|
783
|
+
};
|
|
784
|
+
case "reject": return pos.length === 1 ? {
|
|
785
|
+
ok: true,
|
|
786
|
+
command: {
|
|
787
|
+
verb: "reject",
|
|
788
|
+
id: pos[0]
|
|
789
|
+
}
|
|
790
|
+
} : {
|
|
791
|
+
ok: false,
|
|
792
|
+
code: "usage",
|
|
793
|
+
message: USAGE
|
|
794
|
+
};
|
|
795
|
+
case "resume": {
|
|
796
|
+
const parsed = flags(rest, ["clear"]);
|
|
797
|
+
if (parsed.error !== void 0) return {
|
|
798
|
+
ok: false,
|
|
799
|
+
code: "usage",
|
|
800
|
+
message: parsed.error
|
|
801
|
+
};
|
|
802
|
+
const clear = parsed.values.get("clear");
|
|
803
|
+
if (pos.length > 1) return {
|
|
804
|
+
ok: false,
|
|
805
|
+
code: "usage",
|
|
806
|
+
message: USAGE
|
|
807
|
+
};
|
|
808
|
+
return {
|
|
809
|
+
ok: true,
|
|
810
|
+
command: {
|
|
811
|
+
verb: "resume",
|
|
812
|
+
...pos[0] === void 0 ? {} : { id: pos[0] },
|
|
813
|
+
...clear === void 0 ? {} : { clear }
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
case "skipped": {
|
|
818
|
+
const parsed = flags(rest, ["restore"]);
|
|
819
|
+
if (parsed.error !== void 0) return {
|
|
820
|
+
ok: false,
|
|
821
|
+
code: "usage",
|
|
822
|
+
message: parsed.error
|
|
823
|
+
};
|
|
824
|
+
if (pos.length > 0) return {
|
|
825
|
+
ok: false,
|
|
826
|
+
code: "usage",
|
|
827
|
+
message: USAGE
|
|
828
|
+
};
|
|
829
|
+
const restore = parsed.values.get("restore");
|
|
830
|
+
return {
|
|
831
|
+
ok: true,
|
|
832
|
+
command: {
|
|
833
|
+
verb: "skipped",
|
|
834
|
+
...restore === void 0 ? {} : { restore }
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
case "undo": {
|
|
839
|
+
const parsed = flags(rest, ["run"]);
|
|
840
|
+
if (parsed.error !== void 0) return {
|
|
841
|
+
ok: false,
|
|
842
|
+
code: "usage",
|
|
843
|
+
message: parsed.error
|
|
844
|
+
};
|
|
845
|
+
const target = pos[0];
|
|
846
|
+
const run = parsed.values.get("run");
|
|
847
|
+
if (target === void 0 || run === void 0 || pos.length !== 1) return {
|
|
848
|
+
ok: false,
|
|
849
|
+
code: "usage",
|
|
850
|
+
message: USAGE
|
|
851
|
+
};
|
|
852
|
+
return {
|
|
853
|
+
ok: true,
|
|
854
|
+
command: {
|
|
855
|
+
verb: "undo",
|
|
856
|
+
target,
|
|
857
|
+
run
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
default: return {
|
|
862
|
+
ok: false,
|
|
863
|
+
code: "usage",
|
|
864
|
+
message: "unknown merge command: " + verb + "\nusage: /evolution merge scan | pair <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending | approve <id> | reject <id> | resume [id] [--clear <id>] | skipped [--restore <id>] | undo <target> --run <id>"
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
function mergeUsage() {
|
|
869
|
+
return USAGE;
|
|
870
|
+
}
|
|
871
|
+
//#endregion
|
|
872
|
+
//#region lib/types/read-commands.js
|
|
873
|
+
async function handleReadOnlyMergeCommand(command, io, home) {
|
|
874
|
+
if (command.verb === "status") {
|
|
875
|
+
const loaded = await loadMergeState(io, home);
|
|
876
|
+
if (command.id !== void 0) {
|
|
877
|
+
const row = loaded.state.rows.find((item) => item.id === command.id);
|
|
878
|
+
return row === void 0 ? "merge row not found: " + command.id : formatStatus([row]);
|
|
879
|
+
}
|
|
880
|
+
return formatStatus(loaded.state.rows);
|
|
881
|
+
}
|
|
882
|
+
if (command.verb === "pending") return formatQueue((await loadMergeState(io, home)).state.rows, "pending");
|
|
883
|
+
if (command.verb === "skipped") return formatQueue((await loadMergeState(io, home)).state.rows, "skipped");
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
function mergeCommandHelp() {
|
|
887
|
+
return mergeUsage();
|
|
888
|
+
}
|
|
889
|
+
//#endregion
|
|
890
|
+
//#region lib/types/merge-command.js
|
|
891
|
+
function createReadOnlyMergeService(io, home) {
|
|
892
|
+
return { async execute(command) {
|
|
893
|
+
const text = await handleReadOnlyMergeCommand(command, io, home);
|
|
894
|
+
return text === null ? {
|
|
895
|
+
ok: false,
|
|
896
|
+
text: "merge " + command.verb + " 需要运行引擎(当前只挂载了只读命令)"
|
|
897
|
+
} : {
|
|
898
|
+
ok: true,
|
|
899
|
+
text
|
|
900
|
+
};
|
|
901
|
+
} };
|
|
902
|
+
}
|
|
903
|
+
async function runMergeCommand(service, rawInput, invocation) {
|
|
904
|
+
const tokens = rawInput.trim().split(/\s+/).filter(Boolean);
|
|
905
|
+
if (tokens[0] === "merge" || tokens[0] === "/merge" || tokens[0] === "evolution" || tokens[0] === "/evolution") tokens.shift();
|
|
906
|
+
if (tokens[0] === "merge") tokens.shift();
|
|
907
|
+
const parsed = parseMergeCommand(tokens);
|
|
908
|
+
if (!parsed.ok) return {
|
|
909
|
+
ok: false,
|
|
910
|
+
text: parsed.message
|
|
911
|
+
};
|
|
912
|
+
if (parsed.help === true) return {
|
|
913
|
+
ok: true,
|
|
914
|
+
text: mergeUsage()
|
|
915
|
+
};
|
|
916
|
+
try {
|
|
917
|
+
return await service.execute(parsed.command, invocation?.agent === void 0 ? {} : { parent: invocation.agent });
|
|
918
|
+
} catch (error) {
|
|
919
|
+
return {
|
|
920
|
+
ok: false,
|
|
921
|
+
text: "merge 命令失败:" + (error instanceof Error ? error.message : String(error)) + "\n运行 /evolution merge status 查看状态。"
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
function createMergeCommand(service) {
|
|
926
|
+
return {
|
|
927
|
+
name: "merge",
|
|
928
|
+
description: "技能自动合并:扫描、待批准、重跑、报告与 undo(兼容别名;请用 /evolution merge)",
|
|
929
|
+
input: { hint: mergeUsage() },
|
|
930
|
+
async handler(invocation) {
|
|
931
|
+
const result = await runMergeCommand(service, invocation.rawInput, invocation);
|
|
932
|
+
return {
|
|
933
|
+
kind: result.ok ? "success" : "error",
|
|
934
|
+
text: result.text
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
//#endregion
|
|
940
|
+
//#region lib/types/skipped.js
|
|
941
|
+
/**
|
|
942
|
+
* Skipped filtering: a pair the operator marked as not-worth-merging stays out of automatic
|
|
943
|
+
* screening until mergeSkippedTtlDays expires. Explicit /merge pair ignores this set.
|
|
944
|
+
*/
|
|
945
|
+
function skippedPairKeys(rows, ttlDays, now) {
|
|
946
|
+
const keys = /* @__PURE__ */ new Set();
|
|
947
|
+
for (const row of rows) {
|
|
948
|
+
if (row.state !== "skipped") continue;
|
|
949
|
+
if (ttlDays <= 0) {
|
|
950
|
+
keys.add(row.pairKey);
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
const createdAt = Date.parse(row.createdAt);
|
|
954
|
+
if (!Number.isFinite(createdAt) || now - createdAt < ttlDays * 864e5) keys.add(row.pairKey);
|
|
955
|
+
}
|
|
956
|
+
return keys;
|
|
957
|
+
}
|
|
958
|
+
function isSkippedPair(keys, a, b) {
|
|
959
|
+
return keys.has(pairKey(a, b));
|
|
960
|
+
}
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region lib/types/scan-run.js
|
|
963
|
+
/**
|
|
964
|
+
* The scan pair loop. Registry/report lifecycle stays in withMergeRun; this module owns
|
|
965
|
+
* candidate → round1 → pending/fuse → commit transitions and returns the report draft.
|
|
966
|
+
*/
|
|
967
|
+
function at(now) {
|
|
968
|
+
return new Date(now()).toISOString();
|
|
969
|
+
}
|
|
970
|
+
function failureOf(stage, error) {
|
|
971
|
+
return {
|
|
972
|
+
stage,
|
|
973
|
+
code: "merge-failed",
|
|
974
|
+
message: error instanceof Error ? error.message : String(error),
|
|
975
|
+
retryable: true,
|
|
976
|
+
retryFrom: stage === "decide" ? "agent1" : "agent2"
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
function pairOf(row, candidate, outcome, extra = {}) {
|
|
980
|
+
return {
|
|
981
|
+
id: row.id,
|
|
982
|
+
pairKey: row.pairKey,
|
|
983
|
+
a: row.a,
|
|
984
|
+
b: row.b,
|
|
985
|
+
queue: row.queue,
|
|
986
|
+
candidate,
|
|
987
|
+
...candidate.gate === void 0 ? {} : { gate: candidate.gate },
|
|
988
|
+
outcome,
|
|
989
|
+
archived: [],
|
|
990
|
+
...extra
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
async function runScan(options) {
|
|
994
|
+
const stage = options.stage ?? (async () => {});
|
|
995
|
+
const candidates = await options.ports.screening(options.signal);
|
|
996
|
+
const before = await loadMergeState(options.io, options.home);
|
|
997
|
+
const queueBefore = {
|
|
998
|
+
pending: 0,
|
|
999
|
+
retry: 0,
|
|
1000
|
+
skipped: 0
|
|
1001
|
+
};
|
|
1002
|
+
for (const row of before.state.rows) queueBefore[row.queue] += 1;
|
|
1003
|
+
const startedAt = options.startedAt ?? options.now();
|
|
1004
|
+
const timeoutMs = (options.timeoutMinutes ?? 0) > 0 ? options.timeoutMinutes * 6e4 : 0;
|
|
1005
|
+
const pairs = [];
|
|
1006
|
+
const totals = {
|
|
1007
|
+
merged: 0,
|
|
1008
|
+
kept: 0,
|
|
1009
|
+
pending: 0,
|
|
1010
|
+
failed: 0
|
|
1011
|
+
};
|
|
1012
|
+
const remaining = [];
|
|
1013
|
+
let stopReason = "completed";
|
|
1014
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
1015
|
+
if (options.signal.aborted) {
|
|
1016
|
+
stopReason = "cancelled";
|
|
1017
|
+
remaining.push(...candidates.slice(index).map((candidate) => ({
|
|
1018
|
+
pairKey: pairKey(candidate.a, candidate.b),
|
|
1019
|
+
queue: "retry"
|
|
1020
|
+
})));
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
if (timeoutMs > 0 && options.now() - startedAt >= timeoutMs) {
|
|
1024
|
+
stopReason = "timeout";
|
|
1025
|
+
remaining.push(...candidates.slice(index).map((candidate) => ({
|
|
1026
|
+
pairKey: pairKey(candidate.a, candidate.b),
|
|
1027
|
+
queue: "retry"
|
|
1028
|
+
})));
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
const candidate = candidates[index];
|
|
1032
|
+
await stage("decide", {
|
|
1033
|
+
done: index,
|
|
1034
|
+
total: candidates.length
|
|
1035
|
+
});
|
|
1036
|
+
let row;
|
|
1037
|
+
try {
|
|
1038
|
+
const created = transition(void 0, {
|
|
1039
|
+
type: "candidate",
|
|
1040
|
+
a: candidate.a,
|
|
1041
|
+
b: candidate.b,
|
|
1042
|
+
at: at(options.now)
|
|
1043
|
+
});
|
|
1044
|
+
if (!created.ok || created.row === null) throw new Error(created.ok ? "candidate row missing" : created.message);
|
|
1045
|
+
row = created.row;
|
|
1046
|
+
await upsertRow(options.io, options.home, options.now, row);
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
totals.failed += 1;
|
|
1049
|
+
pairs.push({
|
|
1050
|
+
id: "",
|
|
1051
|
+
pairKey: pairKey(candidate.a, candidate.b),
|
|
1052
|
+
a: candidate.a,
|
|
1053
|
+
b: candidate.b,
|
|
1054
|
+
queue: "retry",
|
|
1055
|
+
candidate,
|
|
1056
|
+
outcome: "failed",
|
|
1057
|
+
archived: [],
|
|
1058
|
+
failure: failureOf("screening", error)
|
|
1059
|
+
});
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
let round1;
|
|
1063
|
+
try {
|
|
1064
|
+
round1 = await options.ports.round1(candidate, options.signal);
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
round1 = {
|
|
1067
|
+
kind: "failed",
|
|
1068
|
+
failure: failureOf("decide", error)
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
if (round1.kind === "failed") {
|
|
1072
|
+
const next = transition(row, {
|
|
1073
|
+
type: "round1-fail",
|
|
1074
|
+
failure: round1.failure ?? failureOf("decide", "round1 failed"),
|
|
1075
|
+
at: at(options.now)
|
|
1076
|
+
});
|
|
1077
|
+
if (next.ok && next.row !== null) {
|
|
1078
|
+
row = next.row;
|
|
1079
|
+
await upsertRow(options.io, options.home, options.now, row);
|
|
1080
|
+
}
|
|
1081
|
+
pairs.push(pairOf(row, candidate, "failed", { failure: round1.failure ?? failureOf("decide", "round1 failed") }));
|
|
1082
|
+
totals.failed += 1;
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
if (round1.kind === "keep") {
|
|
1086
|
+
const next = transition(row, {
|
|
1087
|
+
type: "round1-keep",
|
|
1088
|
+
at: at(options.now)
|
|
1089
|
+
});
|
|
1090
|
+
if (next.ok && next.row !== null) {
|
|
1091
|
+
row = next.row;
|
|
1092
|
+
await upsertRow(options.io, options.home, options.now, row);
|
|
1093
|
+
}
|
|
1094
|
+
pairs.push(pairOf(row, candidate, "kept", round1.decision === void 0 ? {} : { decision: round1.decision }));
|
|
1095
|
+
totals.kept += 1;
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
if (round1.decision === void 0) {
|
|
1099
|
+
pairs.push(pairOf(row, candidate, "failed", { failure: failureOf("decide", "merge decision missing") }));
|
|
1100
|
+
totals.failed += 1;
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
if (options.approval) {
|
|
1104
|
+
const next = transition(row, {
|
|
1105
|
+
type: "round1-merge",
|
|
1106
|
+
approval: "on",
|
|
1107
|
+
decision: round1.decision,
|
|
1108
|
+
...round1.stagingRef === void 0 ? {} : { stagingRef: round1.stagingRef },
|
|
1109
|
+
...round1.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: round1.stagingExpiresAt },
|
|
1110
|
+
at: at(options.now)
|
|
1111
|
+
});
|
|
1112
|
+
if (next.ok && next.row !== null) {
|
|
1113
|
+
row = next.row;
|
|
1114
|
+
await upsertRow(options.io, options.home, options.now, row);
|
|
1115
|
+
}
|
|
1116
|
+
pairs.push(pairOf(row, candidate, "pending", { decision: round1.decision }));
|
|
1117
|
+
totals.pending += 1;
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
const fuse = transition(row, {
|
|
1121
|
+
type: "round1-merge",
|
|
1122
|
+
approval: "off",
|
|
1123
|
+
decision: round1.decision,
|
|
1124
|
+
...round1.stagingRef === void 0 ? {} : { stagingRef: round1.stagingRef },
|
|
1125
|
+
...round1.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: round1.stagingExpiresAt },
|
|
1126
|
+
at: at(options.now)
|
|
1127
|
+
});
|
|
1128
|
+
if (!fuse.ok || fuse.row === null) {
|
|
1129
|
+
pairs.push(pairOf(row, candidate, "failed", {
|
|
1130
|
+
decision: round1.decision,
|
|
1131
|
+
failure: failureOf("decide", "cannot enter fuse")
|
|
1132
|
+
}));
|
|
1133
|
+
totals.failed += 1;
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
row = fuse.row;
|
|
1137
|
+
await upsertRow(options.io, options.home, options.now, row);
|
|
1138
|
+
await stage("author", {
|
|
1139
|
+
done: index,
|
|
1140
|
+
total: candidates.length
|
|
1141
|
+
});
|
|
1142
|
+
let round2;
|
|
1143
|
+
try {
|
|
1144
|
+
round2 = await options.ports.round2(row, round1.decision, options.signal);
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
round2 = {
|
|
1147
|
+
kind: "failed",
|
|
1148
|
+
failure: failureOf("author", error)
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
if (round2.kind === "merged") {
|
|
1152
|
+
if (transition(row, {
|
|
1153
|
+
type: "round2-success",
|
|
1154
|
+
at: at(options.now)
|
|
1155
|
+
}).ok) await dropRow(options.io, options.home, options.now, row.id);
|
|
1156
|
+
pairs.push(pairOf(row, candidate, "merged", {
|
|
1157
|
+
decision: round1.decision,
|
|
1158
|
+
...round2.unitId === void 0 ? {} : { unitId: round2.unitId },
|
|
1159
|
+
...round2.target === void 0 ? {} : { target: round2.target },
|
|
1160
|
+
archived: round2.archived ?? []
|
|
1161
|
+
}));
|
|
1162
|
+
totals.merged += 1;
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
const failed = round2.failure ?? failureOf("author", "round2 failed");
|
|
1166
|
+
const next = transition(row, {
|
|
1167
|
+
type: "round2-fail",
|
|
1168
|
+
failure: failed,
|
|
1169
|
+
at: at(options.now)
|
|
1170
|
+
});
|
|
1171
|
+
if (next.ok && next.row !== null) {
|
|
1172
|
+
row = next.row;
|
|
1173
|
+
await upsertRow(options.io, options.home, options.now, row);
|
|
1174
|
+
}
|
|
1175
|
+
pairs.push(pairOf(row, candidate, "failed", {
|
|
1176
|
+
decision: round1.decision,
|
|
1177
|
+
failure: failed
|
|
1178
|
+
}));
|
|
1179
|
+
totals.failed += 1;
|
|
1180
|
+
}
|
|
1181
|
+
return {
|
|
1182
|
+
stopReason,
|
|
1183
|
+
scope: {
|
|
1184
|
+
candidatePairs: candidates.candidatePairs ?? candidates.length,
|
|
1185
|
+
queueBefore
|
|
1186
|
+
},
|
|
1187
|
+
pairs,
|
|
1188
|
+
totals,
|
|
1189
|
+
notes: [],
|
|
1190
|
+
...remaining.length === 0 ? {} : { remaining }
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
//#endregion
|
|
1194
|
+
//#region lib/types/report.js
|
|
1195
|
+
/**
|
|
1196
|
+
* Merge run report: JSON is the machine source, Markdown is the human digest.
|
|
1197
|
+
*
|
|
1198
|
+
* Reports are written before registry.settle, so a settled run's resultRef is readable.
|
|
1199
|
+
* readMergeReport uses the three-state probe: absent is not "no pairs", unknown is not empty.
|
|
1200
|
+
*/
|
|
1201
|
+
function mergeReportPath(home, runId) {
|
|
1202
|
+
return join(home, "reports", "merge-" + runId + ".json");
|
|
1203
|
+
}
|
|
1204
|
+
function mergeReportDigestPath(home, runId) {
|
|
1205
|
+
return join(home, "reports", "merge-" + runId + ".md");
|
|
1206
|
+
}
|
|
1207
|
+
function isReport(value) {
|
|
1208
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1209
|
+
const report = value;
|
|
1210
|
+
return report.schemaVersion === 1 && report.kind === "merge" && typeof report.runId === "string" && typeof report.command === "string" && typeof report.startedAt === "number" && typeof report.endedAt === "number" && Array.isArray(report.pairs) && typeof report.totals === "object" && Array.isArray(report.notes);
|
|
1211
|
+
}
|
|
1212
|
+
function digest(report) {
|
|
1213
|
+
const lines = [];
|
|
1214
|
+
lines.push("# merge " + report.runId);
|
|
1215
|
+
lines.push("");
|
|
1216
|
+
lines.push("- command: " + report.command);
|
|
1217
|
+
lines.push("- stop: " + report.stopReason);
|
|
1218
|
+
lines.push("- pairs: " + String(report.pairs.length));
|
|
1219
|
+
lines.push("- totals: merged " + String(report.totals.merged) + ", kept " + String(report.totals.kept) + ", pending " + String(report.totals.pending) + ", failed " + String(report.totals.failed));
|
|
1220
|
+
for (const pair of report.pairs) lines.push("- " + pair.a + " + " + pair.b + " -> " + pair.outcome + (pair.target === void 0 ? "" : " (" + pair.target.actual + ")"));
|
|
1221
|
+
if (report.remaining !== void 0 && report.remaining.length > 0) {
|
|
1222
|
+
lines.push("");
|
|
1223
|
+
lines.push("- remaining: " + report.remaining.map((item) => item.pairKey).join(", "));
|
|
1224
|
+
}
|
|
1225
|
+
return lines.join("\n") + "\n";
|
|
1226
|
+
}
|
|
1227
|
+
/** Write JSON first, then the digest; returns the JSON path (the registry resultRef). */
|
|
1228
|
+
async function writeMergeReport(io, home, report) {
|
|
1229
|
+
const path = mergeReportPath(home, report.runId);
|
|
1230
|
+
await io.writeText(path, JSON.stringify(report, null, 2) + "\n");
|
|
1231
|
+
await io.writeText(mergeReportDigestPath(home, report.runId), digest(report));
|
|
1232
|
+
return path;
|
|
1233
|
+
}
|
|
1234
|
+
async function readMergeReport(io, home, runId) {
|
|
1235
|
+
const probe = await probeText(io, mergeReportPath(home, runId));
|
|
1236
|
+
if (!isPresent(probe)) return probe;
|
|
1237
|
+
try {
|
|
1238
|
+
const parsed = JSON.parse(probe.value);
|
|
1239
|
+
return isReport(parsed) ? probePresent(parsed) : probeUnknown("merge report has an unexpected shape");
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
return probeUnknown("merge report is not JSON: " + (error instanceof Error ? error.message : String(error)));
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
/** Pairs this run actually merged (for merge undo / reports). */
|
|
1245
|
+
async function committedPairs(io, home, runId) {
|
|
1246
|
+
const probe = await readMergeReport(io, home, runId);
|
|
1247
|
+
return isPresent(probe) ? probe.value.pairs.filter((pair) => pair.outcome === "merged") : [];
|
|
1248
|
+
}
|
|
1249
|
+
//#endregion
|
|
1250
|
+
//#region lib/types/run-shell.js
|
|
1251
|
+
function emptyDraft(reason) {
|
|
1252
|
+
return {
|
|
1253
|
+
stopReason: "failed",
|
|
1254
|
+
scope: {
|
|
1255
|
+
candidatePairs: 0,
|
|
1256
|
+
queueBefore: {
|
|
1257
|
+
pending: 0,
|
|
1258
|
+
retry: 0,
|
|
1259
|
+
skipped: 0
|
|
1260
|
+
}
|
|
1261
|
+
},
|
|
1262
|
+
pairs: [],
|
|
1263
|
+
totals: {
|
|
1264
|
+
merged: 0,
|
|
1265
|
+
kept: 0,
|
|
1266
|
+
pending: 0,
|
|
1267
|
+
failed: 0
|
|
1268
|
+
},
|
|
1269
|
+
notes: [reason]
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
async function withMergeRun(options) {
|
|
1273
|
+
const handle = await options.registry.begin("merge", {
|
|
1274
|
+
command: options.command,
|
|
1275
|
+
...options.total === void 0 ? {} : { total: options.total }
|
|
1276
|
+
});
|
|
1277
|
+
const startedAt = handle.startedAt;
|
|
1278
|
+
const stage = (name, progress) => options.registry.stage(handle.id, name, progress);
|
|
1279
|
+
await stage("screening", options.total === void 0 ? void 0 : {
|
|
1280
|
+
done: 0,
|
|
1281
|
+
total: options.total
|
|
1282
|
+
});
|
|
1283
|
+
let draft;
|
|
1284
|
+
try {
|
|
1285
|
+
draft = await options.body({
|
|
1286
|
+
runId: handle.id,
|
|
1287
|
+
startedAt,
|
|
1288
|
+
signal: handle.signal,
|
|
1289
|
+
stage
|
|
1290
|
+
});
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
draft = emptyDraft("run body failed: " + (error instanceof Error ? error.message : String(error)));
|
|
1293
|
+
}
|
|
1294
|
+
const report = {
|
|
1295
|
+
schemaVersion: 1,
|
|
1296
|
+
runId: handle.id,
|
|
1297
|
+
kind: "merge",
|
|
1298
|
+
command: options.command,
|
|
1299
|
+
startedAt,
|
|
1300
|
+
endedAt: options.now(),
|
|
1301
|
+
stopReason: draft.stopReason,
|
|
1302
|
+
approval: options.approval,
|
|
1303
|
+
scope: draft.scope,
|
|
1304
|
+
pairs: draft.pairs,
|
|
1305
|
+
totals: draft.totals,
|
|
1306
|
+
notes: draft.notes,
|
|
1307
|
+
...draft.remaining === void 0 ? {} : { remaining: draft.remaining }
|
|
1308
|
+
};
|
|
1309
|
+
let reportPath;
|
|
1310
|
+
try {
|
|
1311
|
+
reportPath = await writeMergeReport(options.io, options.home, report);
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
await options.registry.settle(handle.id, {
|
|
1314
|
+
state: "failed",
|
|
1315
|
+
failure: "report write failed: " + (error instanceof Error ? error.message : String(error))
|
|
1316
|
+
});
|
|
1317
|
+
return {
|
|
1318
|
+
ok: false,
|
|
1319
|
+
runId: handle.id,
|
|
1320
|
+
message: "report write failed"
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
const runState = report.stopReason === "completed" ? "succeeded" : report.stopReason === "cancelled" ? "cancelled" : "failed";
|
|
1324
|
+
await options.registry.settle(handle.id, {
|
|
1325
|
+
state: runState,
|
|
1326
|
+
resultRef: reportPath,
|
|
1327
|
+
...runState === "failed" ? { failure: "merge " + report.stopReason } : {}
|
|
1328
|
+
});
|
|
1329
|
+
if (report.stopReason === "completed") return {
|
|
1330
|
+
ok: true,
|
|
1331
|
+
runId: handle.id,
|
|
1332
|
+
reportPath,
|
|
1333
|
+
report
|
|
1334
|
+
};
|
|
1335
|
+
return {
|
|
1336
|
+
ok: false,
|
|
1337
|
+
runId: handle.id,
|
|
1338
|
+
message: "merge " + report.stopReason,
|
|
1339
|
+
reportPath
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
//#endregion
|
|
1343
|
+
//#region lib/types/scan-command.js
|
|
1344
|
+
async function runScanCommand(options) {
|
|
1345
|
+
return await withMergeRun({
|
|
1346
|
+
io: options.io,
|
|
1347
|
+
home: options.home,
|
|
1348
|
+
registry: options.registry,
|
|
1349
|
+
command: "scan",
|
|
1350
|
+
approval: {
|
|
1351
|
+
platformWrite: options.platformWrite,
|
|
1352
|
+
mergeApproval: options.approval
|
|
1353
|
+
},
|
|
1354
|
+
now: options.now,
|
|
1355
|
+
async body(ctx) {
|
|
1356
|
+
const ports = options.makePorts === void 0 ? options.ports : options.makePorts(ctx.runId);
|
|
1357
|
+
if (ports === void 0) throw new Error("scan requires ports or makePorts");
|
|
1358
|
+
return await runScan({
|
|
1359
|
+
io: options.io,
|
|
1360
|
+
home: options.home,
|
|
1361
|
+
now: options.now,
|
|
1362
|
+
signal: ctx.signal,
|
|
1363
|
+
approval: options.approval,
|
|
1364
|
+
ports,
|
|
1365
|
+
stage: ctx.stage,
|
|
1366
|
+
startedAt: ctx.startedAt,
|
|
1367
|
+
...options.timeoutMinutes === void 0 ? {} : { timeoutMinutes: options.timeoutMinutes }
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
//#endregion
|
|
1373
|
+
//#region lib/types/pending-actions.js
|
|
1374
|
+
async function act(io, home, now, id, type) {
|
|
1375
|
+
return await mutateMergeState(io, home, now, (rows) => {
|
|
1376
|
+
const index = rows.findIndex((row) => row.id === id);
|
|
1377
|
+
if (index < 0) return {
|
|
1378
|
+
rows,
|
|
1379
|
+
result: {
|
|
1380
|
+
ok: false,
|
|
1381
|
+
reason: "not-found",
|
|
1382
|
+
message: "pending row not found: " + id
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
const current = rows[index];
|
|
1386
|
+
if (current.state !== "pending") return {
|
|
1387
|
+
rows,
|
|
1388
|
+
result: {
|
|
1389
|
+
ok: false,
|
|
1390
|
+
reason: "not-pending",
|
|
1391
|
+
message: "row is not pending: " + current.state
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
const next = transition(current, type === "approve" ? {
|
|
1395
|
+
type: "approve",
|
|
1396
|
+
at: new Date(now()).toISOString()
|
|
1397
|
+
} : {
|
|
1398
|
+
type: "reject",
|
|
1399
|
+
at: new Date(now()).toISOString()
|
|
1400
|
+
});
|
|
1401
|
+
if (!next.ok || next.row === null) return {
|
|
1402
|
+
rows,
|
|
1403
|
+
result: {
|
|
1404
|
+
ok: false,
|
|
1405
|
+
reason: "invalid",
|
|
1406
|
+
message: next.ok ? "transition produced no row" : next.message
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
const copy = [...rows];
|
|
1410
|
+
copy[index] = next.row;
|
|
1411
|
+
return {
|
|
1412
|
+
rows: copy,
|
|
1413
|
+
result: {
|
|
1414
|
+
ok: true,
|
|
1415
|
+
row: next.row
|
|
1416
|
+
}
|
|
1417
|
+
};
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
async function approvePending(io, home, now, id) {
|
|
1421
|
+
return await act(io, home, now, id, "approve");
|
|
1422
|
+
}
|
|
1423
|
+
async function rejectPending(io, home, now, id) {
|
|
1424
|
+
return await act(io, home, now, id, "reject");
|
|
1425
|
+
}
|
|
1426
|
+
//#endregion
|
|
1427
|
+
//#region lib/types/round2-outcome.js
|
|
1428
|
+
async function recordRound2Outcome(io, home, now, row, outcome) {
|
|
1429
|
+
const at = new Date(now()).toISOString();
|
|
1430
|
+
if (outcome.kind === "merged") {
|
|
1431
|
+
if (transition(row, {
|
|
1432
|
+
type: "round2-success",
|
|
1433
|
+
at
|
|
1434
|
+
}).ok) await dropRow(io, home, now, row.id);
|
|
1435
|
+
return {
|
|
1436
|
+
merged: true,
|
|
1437
|
+
pair: {
|
|
1438
|
+
id: row.id,
|
|
1439
|
+
pairKey: row.pairKey,
|
|
1440
|
+
a: row.a,
|
|
1441
|
+
b: row.b,
|
|
1442
|
+
queue: row.queue,
|
|
1443
|
+
...row.decision === void 0 ? {} : { decision: row.decision },
|
|
1444
|
+
outcome: "merged",
|
|
1445
|
+
archived: outcome.archived ?? [],
|
|
1446
|
+
...outcome.unitId === void 0 ? {} : { unitId: outcome.unitId },
|
|
1447
|
+
...outcome.target === void 0 ? {} : { target: outcome.target }
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
const failure = outcome.failure ?? {
|
|
1452
|
+
stage: "author",
|
|
1453
|
+
code: "merge-failed",
|
|
1454
|
+
message: "round2 failed",
|
|
1455
|
+
retryable: true,
|
|
1456
|
+
retryFrom: "agent2"
|
|
1457
|
+
};
|
|
1458
|
+
const next = transition(row, {
|
|
1459
|
+
type: "round2-fail",
|
|
1460
|
+
failure,
|
|
1461
|
+
at
|
|
1462
|
+
});
|
|
1463
|
+
if (next.ok && next.row !== null) await upsertRow(io, home, now, next.row);
|
|
1464
|
+
return {
|
|
1465
|
+
merged: false,
|
|
1466
|
+
pair: {
|
|
1467
|
+
id: row.id,
|
|
1468
|
+
pairKey: row.pairKey,
|
|
1469
|
+
a: row.a,
|
|
1470
|
+
b: row.b,
|
|
1471
|
+
queue: row.queue,
|
|
1472
|
+
...row.decision === void 0 ? {} : { decision: row.decision },
|
|
1473
|
+
outcome: "failed",
|
|
1474
|
+
archived: [],
|
|
1475
|
+
failure
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
//#endregion
|
|
1480
|
+
//#region lib/types/approve-command.js
|
|
1481
|
+
async function runApproveCommand(options) {
|
|
1482
|
+
const taken = await approvePending(options.io, options.home, options.now, options.rowId);
|
|
1483
|
+
if (!taken.ok) return {
|
|
1484
|
+
ok: false,
|
|
1485
|
+
message: taken.message
|
|
1486
|
+
};
|
|
1487
|
+
const row = taken.row;
|
|
1488
|
+
const decision = row.decision;
|
|
1489
|
+
if (decision === void 0) return {
|
|
1490
|
+
ok: false,
|
|
1491
|
+
message: "pending row has no decision"
|
|
1492
|
+
};
|
|
1493
|
+
return await withMergeRun({
|
|
1494
|
+
io: options.io,
|
|
1495
|
+
home: options.home,
|
|
1496
|
+
registry: options.registry,
|
|
1497
|
+
command: "approve",
|
|
1498
|
+
approval: {
|
|
1499
|
+
platformWrite: options.platformWrite,
|
|
1500
|
+
mergeApproval: true
|
|
1501
|
+
},
|
|
1502
|
+
now: options.now,
|
|
1503
|
+
async body(ctx) {
|
|
1504
|
+
const stage = (name) => ctx.stage(name, {
|
|
1505
|
+
done: 0,
|
|
1506
|
+
total: 1
|
|
1507
|
+
});
|
|
1508
|
+
await stage("author");
|
|
1509
|
+
let outcome;
|
|
1510
|
+
try {
|
|
1511
|
+
outcome = await options.ports.round2(row, decision, ctx.signal, ctx.runId);
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
outcome = {
|
|
1514
|
+
kind: "failed",
|
|
1515
|
+
failure: {
|
|
1516
|
+
stage: "author",
|
|
1517
|
+
code: "merge-failed",
|
|
1518
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1519
|
+
retryable: true,
|
|
1520
|
+
retryFrom: "agent2"
|
|
1521
|
+
}
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
const base = {
|
|
1525
|
+
scope: {
|
|
1526
|
+
candidatePairs: 1,
|
|
1527
|
+
queueBefore: {
|
|
1528
|
+
pending: 0,
|
|
1529
|
+
retry: 1,
|
|
1530
|
+
skipped: 0
|
|
1531
|
+
}
|
|
1532
|
+
},
|
|
1533
|
+
notes: []
|
|
1534
|
+
};
|
|
1535
|
+
const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome);
|
|
1536
|
+
return {
|
|
1537
|
+
...base,
|
|
1538
|
+
stopReason: record.merged ? "completed" : "failed",
|
|
1539
|
+
pairs: [record.pair],
|
|
1540
|
+
totals: record.merged ? {
|
|
1541
|
+
merged: 1,
|
|
1542
|
+
kept: 0,
|
|
1543
|
+
pending: 0,
|
|
1544
|
+
failed: 0
|
|
1545
|
+
} : {
|
|
1546
|
+
merged: 0,
|
|
1547
|
+
kept: 0,
|
|
1548
|
+
pending: 0,
|
|
1549
|
+
failed: 1
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
//#endregion
|
|
1556
|
+
//#region lib/types/resume-command.js
|
|
1557
|
+
async function runResumeCommand(options) {
|
|
1558
|
+
const row = (await loadMergeState(options.io, options.home)).state.rows.find((item) => item.id === options.rowId);
|
|
1559
|
+
if (row === void 0) return {
|
|
1560
|
+
ok: false,
|
|
1561
|
+
message: "merge row not found: " + options.rowId
|
|
1562
|
+
};
|
|
1563
|
+
if (row.state === "verify") return await runScanCommand({
|
|
1564
|
+
io: options.io,
|
|
1565
|
+
home: options.home,
|
|
1566
|
+
registry: options.registry,
|
|
1567
|
+
approval: true,
|
|
1568
|
+
platformWrite: options.platformWrite,
|
|
1569
|
+
now: options.now,
|
|
1570
|
+
...options.makePorts === void 0 ? { ports: {
|
|
1571
|
+
...options.ports,
|
|
1572
|
+
screening: () => Promise.resolve([{
|
|
1573
|
+
a: row.a,
|
|
1574
|
+
b: row.b
|
|
1575
|
+
}])
|
|
1576
|
+
} } : { makePorts: (runId) => {
|
|
1577
|
+
const makePorts = options.makePorts;
|
|
1578
|
+
if (makePorts === void 0) throw new Error("resume makePorts disappeared");
|
|
1579
|
+
return {
|
|
1580
|
+
...makePorts(runId),
|
|
1581
|
+
screening: () => Promise.resolve([{
|
|
1582
|
+
a: row.a,
|
|
1583
|
+
b: row.b
|
|
1584
|
+
}])
|
|
1585
|
+
};
|
|
1586
|
+
} },
|
|
1587
|
+
...options.timeoutMinutes === void 0 ? {} : { timeoutMinutes: options.timeoutMinutes }
|
|
1588
|
+
});
|
|
1589
|
+
if (row.state !== "fuse") return {
|
|
1590
|
+
ok: false,
|
|
1591
|
+
message: "row is not resumable from state " + row.state
|
|
1592
|
+
};
|
|
1593
|
+
const decision = row.decision;
|
|
1594
|
+
if (decision === void 0) return {
|
|
1595
|
+
ok: false,
|
|
1596
|
+
message: "fuse row has no decision"
|
|
1597
|
+
};
|
|
1598
|
+
return await withMergeRun({
|
|
1599
|
+
io: options.io,
|
|
1600
|
+
home: options.home,
|
|
1601
|
+
registry: options.registry,
|
|
1602
|
+
command: "resume",
|
|
1603
|
+
approval: {
|
|
1604
|
+
platformWrite: options.platformWrite,
|
|
1605
|
+
mergeApproval: true
|
|
1606
|
+
},
|
|
1607
|
+
now: options.now,
|
|
1608
|
+
async body(ctx) {
|
|
1609
|
+
await ctx.stage("author", {
|
|
1610
|
+
done: 0,
|
|
1611
|
+
total: 1
|
|
1612
|
+
});
|
|
1613
|
+
let outcome;
|
|
1614
|
+
try {
|
|
1615
|
+
outcome = options.round2WithRun === void 0 ? await options.ports.round2(row, decision, ctx.signal) : await options.round2WithRun(row, decision, ctx.signal, ctx.runId);
|
|
1616
|
+
} catch (error) {
|
|
1617
|
+
outcome = {
|
|
1618
|
+
kind: "failed",
|
|
1619
|
+
failure: {
|
|
1620
|
+
stage: "author",
|
|
1621
|
+
code: "merge-failed",
|
|
1622
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1623
|
+
retryable: true,
|
|
1624
|
+
retryFrom: "agent2"
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome);
|
|
1629
|
+
return {
|
|
1630
|
+
stopReason: record.merged ? "completed" : "failed",
|
|
1631
|
+
scope: {
|
|
1632
|
+
candidatePairs: 1,
|
|
1633
|
+
queueBefore: {
|
|
1634
|
+
pending: 0,
|
|
1635
|
+
retry: 1,
|
|
1636
|
+
skipped: 0
|
|
1637
|
+
}
|
|
1638
|
+
},
|
|
1639
|
+
pairs: [record.pair],
|
|
1640
|
+
totals: record.merged ? {
|
|
1641
|
+
merged: 1,
|
|
1642
|
+
kept: 0,
|
|
1643
|
+
pending: 0,
|
|
1644
|
+
failed: 0
|
|
1645
|
+
} : {
|
|
1646
|
+
merged: 0,
|
|
1647
|
+
kept: 0,
|
|
1648
|
+
pending: 0,
|
|
1649
|
+
failed: 1
|
|
1650
|
+
},
|
|
1651
|
+
notes: []
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
//#endregion
|
|
1657
|
+
//#region lib/types/staging.js
|
|
1658
|
+
/**
|
|
1659
|
+
* Staging is a run ARTIFACT, not a queue: <home>/merge/runs/<runId>/.
|
|
1660
|
+
*
|
|
1661
|
+
* createStaging writes a sibling .partial tree first and renames it into place; the .stage
|
|
1662
|
+
* marker is the last write inside the partial. A directory without that marker is a
|
|
1663
|
+
* half-created artifact and reads as unknown, never as "empty".
|
|
1664
|
+
*/
|
|
1665
|
+
const STAGING_SCHEMA_VERSION = 1;
|
|
1666
|
+
function stageKeyOf(runId, pairKey) {
|
|
1667
|
+
return runId + "/pairs/" + createHash("sha256").update(pairKey).digest("hex").slice(0, 16);
|
|
1668
|
+
}
|
|
1669
|
+
function stagingRoot(home, runId) {
|
|
1670
|
+
return join(home, "merge", "runs", runId);
|
|
1671
|
+
}
|
|
1672
|
+
/** Relative support paths only; the staging tree must never escape its run directory. */
|
|
1673
|
+
function safeRelative$2(path) {
|
|
1674
|
+
if (path === "" || path.startsWith("/") || path.startsWith("\\") || path.includes("\0")) return false;
|
|
1675
|
+
return path.replace(/\\/g, "/").split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
1676
|
+
}
|
|
1677
|
+
function manifestText(manifest) {
|
|
1678
|
+
return JSON.stringify(manifest, null, 2) + "\n";
|
|
1679
|
+
}
|
|
1680
|
+
async function createStaging(io, home, input) {
|
|
1681
|
+
const final = stagingRoot(home, input.runId);
|
|
1682
|
+
if (await io.exists(final)) return {
|
|
1683
|
+
ok: false,
|
|
1684
|
+
message: "staging already exists for run " + input.runId
|
|
1685
|
+
};
|
|
1686
|
+
const names = /* @__PURE__ */ new Set();
|
|
1687
|
+
for (const source of input.sources) {
|
|
1688
|
+
if (names.has(source.name)) return {
|
|
1689
|
+
ok: false,
|
|
1690
|
+
message: "duplicate source " + source.name
|
|
1691
|
+
};
|
|
1692
|
+
names.add(source.name);
|
|
1693
|
+
for (const file of source.files) if (!safeRelative$2(file.path)) return {
|
|
1694
|
+
ok: false,
|
|
1695
|
+
message: "unsafe support path: " + file.path
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
const partial = final + ".partial";
|
|
1699
|
+
try {
|
|
1700
|
+
await io.remove(partial);
|
|
1701
|
+
for (const source of input.sources) {
|
|
1702
|
+
const dir = join(partial, "sources", source.name);
|
|
1703
|
+
await io.writeText(join(dir, "SKILL.md"), source.body);
|
|
1704
|
+
for (const file of source.files) await io.writeText(join(dir, ...file.path.split("/")), file.content);
|
|
1705
|
+
}
|
|
1706
|
+
const at = input.now();
|
|
1707
|
+
const manifest = {
|
|
1708
|
+
schemaVersion: 1,
|
|
1709
|
+
runId: input.runId,
|
|
1710
|
+
target: input.target,
|
|
1711
|
+
...input.mode === void 0 ? {} : { mode: input.mode },
|
|
1712
|
+
sources: input.sources.map((source) => source.name),
|
|
1713
|
+
promptVersion: input.promptVersion,
|
|
1714
|
+
createdAt: at,
|
|
1715
|
+
expiresAt: at + Math.max(0, input.ttlDays) * 864e5
|
|
1716
|
+
};
|
|
1717
|
+
await io.writeText(join(partial, "manifest.json"), manifestText(manifest));
|
|
1718
|
+
await io.writeText(join(partial, ".stage"), "");
|
|
1719
|
+
await io.rename(partial, final);
|
|
1720
|
+
return {
|
|
1721
|
+
ok: true,
|
|
1722
|
+
path: final,
|
|
1723
|
+
manifest
|
|
1724
|
+
};
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
try {
|
|
1727
|
+
await io.remove(partial);
|
|
1728
|
+
} catch {}
|
|
1729
|
+
return {
|
|
1730
|
+
ok: false,
|
|
1731
|
+
message: "staging create failed: " + (error instanceof Error ? error.message : String(error))
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
async function readStaging(io, home, runId) {
|
|
1736
|
+
const path = stagingRoot(home, runId);
|
|
1737
|
+
if (!await io.exists(path)) return probeAbsent();
|
|
1738
|
+
if (!await io.exists(join(path, ".stage"))) return probeUnknown("staging " + runId + " has no .stage marker (half-created)");
|
|
1739
|
+
const probe = await probeText(io, join(path, "manifest.json"));
|
|
1740
|
+
if (!isPresent(probe)) return probe;
|
|
1741
|
+
try {
|
|
1742
|
+
const parsed = JSON.parse(probe.value);
|
|
1743
|
+
if (parsed.schemaVersion !== 1 || parsed.runId !== runId || typeof parsed.target !== "string" || !Array.isArray(parsed.sources)) return probeUnknown("staging manifest has an unexpected shape");
|
|
1744
|
+
return probePresent({
|
|
1745
|
+
path,
|
|
1746
|
+
manifest: parsed
|
|
1747
|
+
});
|
|
1748
|
+
} catch (error) {
|
|
1749
|
+
return probeUnknown("staging manifest is not JSON: " + (error instanceof Error ? error.message : String(error)));
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
async function ensureStaging(io, home, input) {
|
|
1753
|
+
const existing = await readStaging(io, home, input.runId);
|
|
1754
|
+
if (existing.kind === "present") return {
|
|
1755
|
+
ok: true,
|
|
1756
|
+
path: stagingRoot(home, input.runId),
|
|
1757
|
+
manifest: existing.value.manifest
|
|
1758
|
+
};
|
|
1759
|
+
return createStaging(io, home, input);
|
|
1760
|
+
}
|
|
1761
|
+
async function setStagingTarget(io, home, stageKey, target, mode) {
|
|
1762
|
+
const view = await readStaging(io, home, stageKey);
|
|
1763
|
+
if (view.kind !== "present") return false;
|
|
1764
|
+
const manifest = Object.assign({}, view.value.manifest, { target }, mode === void 0 ? {} : { mode });
|
|
1765
|
+
await io.writeText(join(stagingRoot(home, stageKey), "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
1766
|
+
return true;
|
|
1767
|
+
}
|
|
1768
|
+
async function dropStaging(io, home, stageKey) {
|
|
1769
|
+
await io.remove(stagingRoot(home, stageKey));
|
|
1770
|
+
}
|
|
1771
|
+
//#endregion
|
|
1772
|
+
//#region lib/types/commit-receipt.js
|
|
1773
|
+
/**
|
|
1774
|
+
* Commit receipt: <run>/commit.json, written immediately after a successful commit.
|
|
1775
|
+
* It is the idempotency/recovery marker — merge resume and run start read it before
|
|
1776
|
+
* replaying a commit. Receipt write failure must not turn a landed commit into a failure;
|
|
1777
|
+
* the caller reports the warning instead.
|
|
1778
|
+
*/
|
|
1779
|
+
function commitReceiptPath(home, runId) {
|
|
1780
|
+
return join(stagingRoot(home, runId), "commit.json");
|
|
1781
|
+
}
|
|
1782
|
+
function durableReceiptDir(home, parentRunId) {
|
|
1783
|
+
return join(home, "merge", "receipts", parentRunId);
|
|
1784
|
+
}
|
|
1785
|
+
function durableReceiptPath(home, parentRunId, stageKey) {
|
|
1786
|
+
const leaf = stageKey.split("/").pop() ?? stageKey;
|
|
1787
|
+
return join(durableReceiptDir(home, parentRunId), leaf + ".json");
|
|
1788
|
+
}
|
|
1789
|
+
function parseReceipt(raw) {
|
|
1790
|
+
if (raw === null) return null;
|
|
1791
|
+
try {
|
|
1792
|
+
const parsed = JSON.parse(raw);
|
|
1793
|
+
if (typeof parsed.runId !== "string" || typeof parsed.unitId !== "string" || typeof parsed.target !== "string" || typeof parsed.create !== "boolean" || !Array.isArray(parsed.sources) || !Array.isArray(parsed.archived) || typeof parsed.at !== "number") return null;
|
|
1794
|
+
return parsed;
|
|
1795
|
+
} catch {
|
|
1796
|
+
return null;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
async function writeCommitReceipt(io, home, receipt) {
|
|
1800
|
+
const path = commitReceiptPath(home, receipt.runId);
|
|
1801
|
+
await io.writeText(path, JSON.stringify(receipt, null, 2) + "\n");
|
|
1802
|
+
const parent = receipt.parentRunId ?? receipt.runId.split("/pairs/")[0] ?? receipt.runId;
|
|
1803
|
+
await io.writeText(durableReceiptPath(home, parent, receipt.runId), JSON.stringify({
|
|
1804
|
+
...receipt,
|
|
1805
|
+
parentRunId: parent
|
|
1806
|
+
}, null, 2) + "\n");
|
|
1807
|
+
return path;
|
|
1808
|
+
}
|
|
1809
|
+
async function readCommitReceipt(io, home, runId, target) {
|
|
1810
|
+
const direct = parseReceipt(await io.readText(commitReceiptPath(home, runId)));
|
|
1811
|
+
if (direct !== null && (target === void 0 || direct.target === target)) return direct;
|
|
1812
|
+
try {
|
|
1813
|
+
const names = await io.list(durableReceiptDir(home, runId));
|
|
1814
|
+
for (const name of names.sort()) {
|
|
1815
|
+
const receipt = parseReceipt(await io.readText(join(durableReceiptDir(home, runId), name)));
|
|
1816
|
+
if (receipt !== null && (target === void 0 || receipt.target === target)) return receipt;
|
|
1817
|
+
}
|
|
1818
|
+
} catch {}
|
|
1819
|
+
return null;
|
|
1820
|
+
}
|
|
1821
|
+
//#endregion
|
|
1822
|
+
//#region lib/types/undo-merge.js
|
|
1823
|
+
async function undoMergeReceipt(receipt, port) {
|
|
1824
|
+
const targetVersions = (await port.listVersions(receipt.target)).filter((entry) => entry.unitId === receipt.unitId);
|
|
1825
|
+
if (targetVersions.length === 0) return {
|
|
1826
|
+
ok: false,
|
|
1827
|
+
message: "unit " + receipt.unitId + " is not in the target history; refusing to guess",
|
|
1828
|
+
reverted: []
|
|
1829
|
+
};
|
|
1830
|
+
const members = [{
|
|
1831
|
+
skill: receipt.target,
|
|
1832
|
+
entries: targetVersions,
|
|
1833
|
+
status: "live",
|
|
1834
|
+
...receipt.create ? { createdInUnit: true } : {}
|
|
1835
|
+
}];
|
|
1836
|
+
for (const source of receipt.sources) {
|
|
1837
|
+
const live = await port.readSkill(source);
|
|
1838
|
+
members.push({
|
|
1839
|
+
skill: source,
|
|
1840
|
+
entries: (await port.listVersions(source)).filter((entry) => entry.unitId === receipt.unitId),
|
|
1841
|
+
status: live === null ? "archived" : "live",
|
|
1842
|
+
...live === null ? { archivedPath: "archive" } : {}
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
const plan = planUnitUndoAcross(members);
|
|
1846
|
+
if (plan.refusals.length > 0) return {
|
|
1847
|
+
ok: false,
|
|
1848
|
+
message: "merge undo preflight refused: " + plan.refusals.join("; "),
|
|
1849
|
+
reverted: []
|
|
1850
|
+
};
|
|
1851
|
+
return await executeUnitUndo(plan, port);
|
|
1852
|
+
}
|
|
1853
|
+
//#endregion
|
|
1854
|
+
//#region lib/types/adapters/undo.js
|
|
1855
|
+
/**
|
|
1856
|
+
* SkillLibrary-backed undo sink. Blobs are content-addressed but the library exposes them by
|
|
1857
|
+
* version, so the first readBlob call indexes all version bodies once; an undo is rare enough
|
|
1858
|
+
* that this is preferable to adding a second hash index.
|
|
1859
|
+
*/
|
|
1860
|
+
function skillLibraryUndoPort(skills) {
|
|
1861
|
+
let blobs;
|
|
1862
|
+
const readBlob = async (hash) => {
|
|
1863
|
+
if (blobs === void 0) {
|
|
1864
|
+
blobs = /* @__PURE__ */ new Map();
|
|
1865
|
+
for (const summary of await skills.list()) for (const version of await skills.listVersions(summary.name)) {
|
|
1866
|
+
const body = await skills.readVersion(summary.name, version.v);
|
|
1867
|
+
if (body !== null) blobs.set(contentHash(body), body);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
return blobs.get(hash) ?? null;
|
|
1871
|
+
};
|
|
1872
|
+
return {
|
|
1873
|
+
readSkill: (name) => skills.read(name),
|
|
1874
|
+
readSupport: (name, path) => skills.readSupportFile(name, path),
|
|
1875
|
+
readBlob,
|
|
1876
|
+
async hasArchived(name) {
|
|
1877
|
+
return await skills.read(name) === null;
|
|
1878
|
+
},
|
|
1879
|
+
restoreFromArchive: (name) => skills.restoreFromArchive(name),
|
|
1880
|
+
archive: (name, reason) => skills.archive(name, { reason }),
|
|
1881
|
+
updateSkill: (name, body, anchor) => skills.update(name, body, void 0, anchor),
|
|
1882
|
+
writeSupportFile: (name, path, content, anchor) => skills.writeSupportFile(name, path, content, void 0, anchor),
|
|
1883
|
+
removeSupportFile: (name, path, anchor) => skills.removeSupportFile(name, path, void 0, anchor),
|
|
1884
|
+
listVersions: (name) => skills.listVersions(name)
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
//#endregion
|
|
1888
|
+
//#region lib/types/doctor.js
|
|
1889
|
+
/**
|
|
1890
|
+
* Merge doctor rows: state, pending, gate cache and staging are each probed read-only.
|
|
1891
|
+
* A missing file is an empty feature, not a failure; an unreadable file is a warning the
|
|
1892
|
+
* operator can act on. This module never repairs or writes.
|
|
1893
|
+
*/
|
|
1894
|
+
const MERGE_DOCTOR_SCHEMA_VERSION = 3;
|
|
1895
|
+
async function mergeDoctor(io, home, configuredRoot) {
|
|
1896
|
+
const rows = [];
|
|
1897
|
+
const loaded = await loadMergeState(io, home);
|
|
1898
|
+
const counts = countRows(loaded.state.rows);
|
|
1899
|
+
rows.push({
|
|
1900
|
+
key: "state",
|
|
1901
|
+
label: "合并状态",
|
|
1902
|
+
status: loaded.note === void 0 ? "ok" : "warn",
|
|
1903
|
+
detail: loaded.note ?? "pairs " + String(loaded.state.rows.length) + ";pending " + String(counts.pending) + " / retry " + String(counts.retry) + " / skipped " + String(counts.skipped)
|
|
1904
|
+
});
|
|
1905
|
+
const modelRoot = configuredRoot !== void 0 && configuredRoot !== "" ? configuredRoot : process.env.SKILL_MERGE_MODELS_ROOT;
|
|
1906
|
+
if (modelRoot === void 0 || modelRoot === "") rows.push({
|
|
1907
|
+
key: "models",
|
|
1908
|
+
label: "合并模型",
|
|
1909
|
+
status: "warn",
|
|
1910
|
+
detail: "未设置 SKILL_MERGE_MODELS_ROOT;合并扫描会 fail loud"
|
|
1911
|
+
});
|
|
1912
|
+
else {
|
|
1913
|
+
const int8 = join(modelRoot, "potion-multilingual-128M-int8");
|
|
1914
|
+
const pca = join(modelRoot, "potion-multilingual-128M-pca64-int8");
|
|
1915
|
+
const ok = await io.exists(int8) && await io.exists(pca);
|
|
1916
|
+
rows.push({
|
|
1917
|
+
key: "models",
|
|
1918
|
+
label: "合并模型",
|
|
1919
|
+
status: ok ? "ok" : "warn",
|
|
1920
|
+
detail: ok ? modelRoot : "缺少 int8/pca64:" + modelRoot
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
const runsDir = join(home, "merge", "runs");
|
|
1924
|
+
const listed = await probeList(io, runsDir);
|
|
1925
|
+
rows.push({
|
|
1926
|
+
key: "staging",
|
|
1927
|
+
label: "暂存树",
|
|
1928
|
+
status: listed.kind === "unknown" ? "unknown" : "ok",
|
|
1929
|
+
detail: listed.kind === "unknown" ? "无法列出 " + runsDir + ":" + listed.reason : listed.kind === "absent" ? "0 个 run 目录" : String(listed.value.length) + " 个 run 目录"
|
|
1930
|
+
});
|
|
1931
|
+
rows.push({
|
|
1932
|
+
key: "staging-path",
|
|
1933
|
+
label: "暂存路径",
|
|
1934
|
+
status: "ok",
|
|
1935
|
+
detail: stagingRoot(home, "<runId>")
|
|
1936
|
+
});
|
|
1937
|
+
return rows;
|
|
1938
|
+
}
|
|
1939
|
+
//#endregion
|
|
1940
|
+
//#region lib/types/decision.js
|
|
1941
|
+
function extractJson(text) {
|
|
1942
|
+
const body = /\`\`\`(?:json)?\s*([\s\S]*?)\`\`\`/i.exec(text)?.[1] ?? text;
|
|
1943
|
+
const start = body.indexOf("{");
|
|
1944
|
+
if (start < 0) return void 0;
|
|
1945
|
+
let depth = 0;
|
|
1946
|
+
for (let index = start; index < body.length; index += 1) if (body[index] === "{") depth += 1;
|
|
1947
|
+
else if (body[index] === "}") {
|
|
1948
|
+
depth -= 1;
|
|
1949
|
+
if (depth === 0) try {
|
|
1950
|
+
return JSON.parse(body.slice(start, index + 1));
|
|
1951
|
+
} catch {
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
function parseDecision(text, pair) {
|
|
1957
|
+
if (text === void 0 || text.trim() === "") return {
|
|
1958
|
+
ok: false,
|
|
1959
|
+
code: "decision-invalid",
|
|
1960
|
+
message: "empty decision text"
|
|
1961
|
+
};
|
|
1962
|
+
const value = extractJson(text);
|
|
1963
|
+
if (typeof value !== "object" || value === null) return {
|
|
1964
|
+
ok: false,
|
|
1965
|
+
code: "decision-invalid",
|
|
1966
|
+
message: "decision is not a JSON object"
|
|
1967
|
+
};
|
|
1968
|
+
const record = value;
|
|
1969
|
+
const shouldMerge = record.shouldMerge === true || record.should_merge === true;
|
|
1970
|
+
const mode = record.mode;
|
|
1971
|
+
if (mode !== "merge-into-existing" && mode !== "create-umbrella" && mode !== "keep") return {
|
|
1972
|
+
ok: false,
|
|
1973
|
+
code: "decision-invalid",
|
|
1974
|
+
message: "mode must be merge-into-existing, create-umbrella or keep"
|
|
1975
|
+
};
|
|
1976
|
+
const target = typeof record.target === "string" ? record.target.trim() : "";
|
|
1977
|
+
const sources = Array.isArray(record.sources) ? record.sources.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [];
|
|
1978
|
+
const rationale = typeof record.rationale === "string" ? record.rationale.trim() : "";
|
|
1979
|
+
if (!shouldMerge || mode === "keep") return {
|
|
1980
|
+
ok: true,
|
|
1981
|
+
decision: {
|
|
1982
|
+
shouldMerge: false,
|
|
1983
|
+
mode: "keep",
|
|
1984
|
+
target,
|
|
1985
|
+
sources,
|
|
1986
|
+
rationale
|
|
1987
|
+
}
|
|
1988
|
+
};
|
|
1989
|
+
if (target === "") return {
|
|
1990
|
+
ok: false,
|
|
1991
|
+
code: "decision-invalid",
|
|
1992
|
+
message: "merge decision needs target"
|
|
1993
|
+
};
|
|
1994
|
+
if (sources.length === 0) return {
|
|
1995
|
+
ok: false,
|
|
1996
|
+
code: "decision-invalid",
|
|
1997
|
+
message: "merge decision needs sources"
|
|
1998
|
+
};
|
|
1999
|
+
if (!sources.includes(pair.a) || !sources.includes(pair.b)) return {
|
|
2000
|
+
ok: false,
|
|
2001
|
+
code: "decision-invalid",
|
|
2002
|
+
message: "sources must include both pair skills"
|
|
2003
|
+
};
|
|
2004
|
+
return {
|
|
2005
|
+
ok: true,
|
|
2006
|
+
decision: {
|
|
2007
|
+
shouldMerge: true,
|
|
2008
|
+
mode,
|
|
2009
|
+
target,
|
|
2010
|
+
sources,
|
|
2011
|
+
rationale,
|
|
2012
|
+
...typeof record.confidence === "number" ? { confidence: record.confidence } : {}
|
|
2013
|
+
}
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
//#endregion
|
|
2017
|
+
//#region lib/types/prompts.js
|
|
2018
|
+
const MERGE_PROMPT_VERSION = "merge-b/v1";
|
|
2019
|
+
const STANDBY_PROMPT = "You are a merge worker. Wait for instructions.";
|
|
2020
|
+
function sourcePath(name) {
|
|
2021
|
+
return "sources/" + name;
|
|
2022
|
+
}
|
|
2023
|
+
function mergedPath(target) {
|
|
2024
|
+
return "merged/" + target;
|
|
2025
|
+
}
|
|
2026
|
+
function wireDecision(decision) {
|
|
2027
|
+
return {
|
|
2028
|
+
shouldMerge: decision.shouldMerge,
|
|
2029
|
+
mode: decision.mode,
|
|
2030
|
+
target: decision.target,
|
|
2031
|
+
sources: decision.sources,
|
|
2032
|
+
rationale: decision.rationale,
|
|
2033
|
+
confidence: decision.confidence,
|
|
2034
|
+
a_summary: decision.aSummary,
|
|
2035
|
+
b_summary: decision.bSummary
|
|
2036
|
+
};
|
|
2037
|
+
}
|
|
2038
|
+
function buildRound1Prompt(candidate, cards) {
|
|
2039
|
+
return [
|
|
2040
|
+
"你是技能合并决策 agent。第 1 轮只做判断,不要写文件、不要修改任何源目录。",
|
|
2041
|
+
"",
|
|
2042
|
+
"在当前工作目录下用 merge_stage 的 list/read 完整阅读两个源技能:",
|
|
2043
|
+
"- A: " + sourcePath(candidate.a) + "(name=" + candidate.a + ")",
|
|
2044
|
+
"- B: " + sourcePath(candidate.b) + "(name=" + candidate.b + ")",
|
|
2045
|
+
"包含 SKILL.md 与 references/templates/scripts 等支撑文件。",
|
|
2046
|
+
"",
|
|
2047
|
+
"判断规则:",
|
|
2048
|
+
"1) 两者是否语义等价/互补到值得合并,而不是词面相似;",
|
|
2049
|
+
"2) 合并后是否更容易发现和使用,而不是变成更大的巨技能;",
|
|
2050
|
+
"3) 若合并会破坏触发面/生命周期/可发现性,应输出 keep。",
|
|
2051
|
+
"",
|
|
2052
|
+
"只输出一个 JSON(不要 markdown 围栏):",
|
|
2053
|
+
"{\"should_merge\":true|false,\"mode\":\"merge-into-existing|create-umbrella|keep\",\"target\":\"kebab-case\",\"sources\":[\"源技能名\"],\"rationale\":\"<=150字\",\"confidence\":0.0,\"a_summary\":\"A 的一句话摘要\",\"b_summary\":\"B 的一句话摘要\"}"
|
|
2054
|
+
].join("\n");
|
|
2055
|
+
}
|
|
2056
|
+
function buildRound2Prompt(row, decision, mode = "continue") {
|
|
2057
|
+
const decisionJson = JSON.stringify(wireDecision(decision));
|
|
2058
|
+
const head = [
|
|
2059
|
+
"现在执行第 2 轮:把两个技能融合成一个完整、可用的技能,直接在当前工作目录的 merged/ 下写文件。",
|
|
2060
|
+
"不要修改 " + sourcePath(row.a) + " 或 " + sourcePath(row.b) + "(只读源副本);不要访问工作目录以外的路径。",
|
|
2061
|
+
""
|
|
2062
|
+
];
|
|
2063
|
+
const body = [
|
|
2064
|
+
"决策结果:" + decisionJson,
|
|
2065
|
+
"目标技能名:" + decision.target,
|
|
2066
|
+
"",
|
|
2067
|
+
"工作方式:",
|
|
2068
|
+
"- 用 merge_stage 的 list/read/write/copy 创建 " + mergedPath(decision.target) + "/SKILL.md 与需要的 references/templates/scripts;",
|
|
2069
|
+
"- 可以复制源文件作为草稿基础,然后逐段修改;可以多轮读写;",
|
|
2070
|
+
"- 保留两源独特内容,不编造事实,不留下 TODO/占位符;",
|
|
2071
|
+
"- SKILL.md 以合法 YAML frontmatter 开头,name 等于目标名,description 不超过 60 字符;",
|
|
2072
|
+
"- 正文与支撑文件的引用必须成对,路径只允许 references/templates/scripts/assets 下的相对路径。",
|
|
2073
|
+
"",
|
|
2074
|
+
"完成后只输出一个 JSON(不要 markdown 围栏):",
|
|
2075
|
+
"{\"target\":\"" + decision.target + "\",\"files\":[\"SKILL.md\",\"references/...\"],\"changelog\":[\"一条变更\"],\"notes\":\"可选的一句话\"}"
|
|
2076
|
+
];
|
|
2077
|
+
return (mode === "fresh" ? [
|
|
2078
|
+
"基于已持久化的 decision。",
|
|
2079
|
+
...head,
|
|
2080
|
+
...body
|
|
2081
|
+
] : [...head, ...body]).join("\n");
|
|
2082
|
+
}
|
|
2083
|
+
function buildStandbyPrompt() {
|
|
2084
|
+
return STANDBY_PROMPT;
|
|
2085
|
+
}
|
|
2086
|
+
//#endregion
|
|
2087
|
+
//#region lib/types/channel-port.js
|
|
2088
|
+
async function runRound1OverChannel(options, candidate, signal) {
|
|
2089
|
+
let childId;
|
|
2090
|
+
const dispose = async () => {
|
|
2091
|
+
if (childId !== void 0) {
|
|
2092
|
+
const id = childId;
|
|
2093
|
+
childId = void 0;
|
|
2094
|
+
try {
|
|
2095
|
+
await options.channel.dispose(id);
|
|
2096
|
+
} catch {}
|
|
2097
|
+
}
|
|
2098
|
+
};
|
|
2099
|
+
const opened = await options.channel.open({
|
|
2100
|
+
parent: options.parent,
|
|
2101
|
+
label: "merge-round1",
|
|
2102
|
+
promptText: STANDBY_PROMPT,
|
|
2103
|
+
kind: "reviewer",
|
|
2104
|
+
allow: options.allow,
|
|
2105
|
+
...options.cwd === void 0 ? {} : { cwd: options.cwd },
|
|
2106
|
+
timeoutMs: options.timeoutMs,
|
|
2107
|
+
signal
|
|
2108
|
+
});
|
|
2109
|
+
if (opened.kind === "unavailable") return {
|
|
2110
|
+
outcome: {
|
|
2111
|
+
kind: "failed",
|
|
2112
|
+
failure: {
|
|
2113
|
+
stage: "decide",
|
|
2114
|
+
code: opened.code,
|
|
2115
|
+
message: opened.reason,
|
|
2116
|
+
retryable: true,
|
|
2117
|
+
retryFrom: "agent1"
|
|
2118
|
+
}
|
|
2119
|
+
},
|
|
2120
|
+
dispose
|
|
2121
|
+
};
|
|
2122
|
+
if (opened.kind === "aborted") return {
|
|
2123
|
+
outcome: {
|
|
2124
|
+
kind: "failed",
|
|
2125
|
+
failure: {
|
|
2126
|
+
stage: "decide",
|
|
2127
|
+
code: "aborted",
|
|
2128
|
+
message: "round1 aborted",
|
|
2129
|
+
retryable: false,
|
|
2130
|
+
retryFrom: "none"
|
|
2131
|
+
}
|
|
2132
|
+
},
|
|
2133
|
+
dispose
|
|
2134
|
+
};
|
|
2135
|
+
childId = opened.childId;
|
|
2136
|
+
if (opened.turn.stopReason !== "completed") {
|
|
2137
|
+
await dispose();
|
|
2138
|
+
return {
|
|
2139
|
+
outcome: {
|
|
2140
|
+
kind: "failed",
|
|
2141
|
+
failure: {
|
|
2142
|
+
stage: "decide",
|
|
2143
|
+
code: opened.turn.stopReason,
|
|
2144
|
+
message: "standby did not complete",
|
|
2145
|
+
retryable: true,
|
|
2146
|
+
retryFrom: "agent1"
|
|
2147
|
+
}
|
|
2148
|
+
},
|
|
2149
|
+
dispose
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
try {
|
|
2153
|
+
options.onChildOpened?.(opened.childId);
|
|
2154
|
+
} catch (error) {
|
|
2155
|
+
await dispose();
|
|
2156
|
+
return {
|
|
2157
|
+
outcome: {
|
|
2158
|
+
kind: "failed",
|
|
2159
|
+
failure: {
|
|
2160
|
+
stage: "decide",
|
|
2161
|
+
code: "child-bind",
|
|
2162
|
+
message: String(error),
|
|
2163
|
+
retryable: true,
|
|
2164
|
+
retryFrom: "agent1"
|
|
2165
|
+
}
|
|
2166
|
+
},
|
|
2167
|
+
dispose
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
const round1 = await options.channel.continueTurn(opened.childId, buildRound1Prompt(candidate, options.cards), {
|
|
2171
|
+
timeoutMs: options.timeoutMs,
|
|
2172
|
+
signal
|
|
2173
|
+
});
|
|
2174
|
+
if (round1.kind !== "ok") {
|
|
2175
|
+
await dispose();
|
|
2176
|
+
return {
|
|
2177
|
+
outcome: {
|
|
2178
|
+
kind: "failed",
|
|
2179
|
+
failure: {
|
|
2180
|
+
stage: "decide",
|
|
2181
|
+
code: round1.kind,
|
|
2182
|
+
message: round1.kind === "unavailable" ? round1.reason : "round1 aborted",
|
|
2183
|
+
retryable: round1.kind !== "aborted",
|
|
2184
|
+
retryFrom: round1.kind === "aborted" ? "none" : "agent1"
|
|
2185
|
+
}
|
|
2186
|
+
},
|
|
2187
|
+
dispose
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
if (round1.turn.stopReason !== "completed") {
|
|
2191
|
+
await dispose();
|
|
2192
|
+
return {
|
|
2193
|
+
outcome: {
|
|
2194
|
+
kind: "failed",
|
|
2195
|
+
failure: {
|
|
2196
|
+
stage: "decide",
|
|
2197
|
+
code: round1.turn.stopReason,
|
|
2198
|
+
message: "round1 did not complete",
|
|
2199
|
+
retryable: true,
|
|
2200
|
+
retryFrom: "agent1"
|
|
2201
|
+
}
|
|
2202
|
+
},
|
|
2203
|
+
dispose
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
const parsed = parseDecision(round1.turn.text, candidate);
|
|
2207
|
+
if (!parsed.ok) {
|
|
2208
|
+
await dispose();
|
|
2209
|
+
return {
|
|
2210
|
+
outcome: {
|
|
2211
|
+
kind: "failed",
|
|
2212
|
+
failure: {
|
|
2213
|
+
stage: "decide",
|
|
2214
|
+
code: parsed.code,
|
|
2215
|
+
message: parsed.message,
|
|
2216
|
+
retryable: true,
|
|
2217
|
+
retryFrom: "agent1"
|
|
2218
|
+
}
|
|
2219
|
+
},
|
|
2220
|
+
dispose
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
if (!parsed.decision.shouldMerge || parsed.decision.mode === "keep") {
|
|
2224
|
+
await dispose();
|
|
2225
|
+
return {
|
|
2226
|
+
outcome: {
|
|
2227
|
+
kind: "keep",
|
|
2228
|
+
decision: parsed.decision
|
|
2229
|
+
},
|
|
2230
|
+
dispose
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
const child = opened.childId;
|
|
2234
|
+
return {
|
|
2235
|
+
outcome: {
|
|
2236
|
+
kind: "merge",
|
|
2237
|
+
decision: parsed.decision
|
|
2238
|
+
},
|
|
2239
|
+
childId: child,
|
|
2240
|
+
dispose,
|
|
2241
|
+
async author(row, decision, turnSignal) {
|
|
2242
|
+
const turn = await options.channel.continueTurn(child, buildRound2Prompt(row, decision), {
|
|
2243
|
+
timeoutMs: options.timeoutMs,
|
|
2244
|
+
signal: turnSignal
|
|
2245
|
+
});
|
|
2246
|
+
if (turn.kind !== "ok") throw new Error(turn.kind === "unavailable" ? turn.reason : "round2 aborted");
|
|
2247
|
+
if (turn.turn.stopReason !== "completed") throw new Error("round2 did not complete: " + turn.turn.stopReason);
|
|
2248
|
+
return turn.turn.text;
|
|
2249
|
+
}
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
//#endregion
|
|
2253
|
+
//#region lib/types/staged-tree.js
|
|
2254
|
+
/**
|
|
2255
|
+
* Read the merged tree a child wrote into a staging run.
|
|
2256
|
+
*
|
|
2257
|
+
* The tree is the commit source: SKILL.md is the body, every other file is a support file.
|
|
2258
|
+
* A missing SKILL.md is unknown, never an empty merge; the caller then refuses commit.
|
|
2259
|
+
*/
|
|
2260
|
+
function safeRelative$1(path) {
|
|
2261
|
+
if (path === "" || path.startsWith("/") || path.startsWith("\\") || path.includes("\0")) return false;
|
|
2262
|
+
return path.replace(/\\/g, "/").split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
2263
|
+
}
|
|
2264
|
+
async function walkFiles(io, root, rel = "") {
|
|
2265
|
+
const abs = rel === "" ? root : join(root, ...rel.split("/"));
|
|
2266
|
+
try {
|
|
2267
|
+
if (await io.readText(abs) !== null) return [rel];
|
|
2268
|
+
} catch {}
|
|
2269
|
+
const listed = await probeList(io, abs);
|
|
2270
|
+
if (listed.kind === "unknown") throw new Error("cannot list " + abs + ": " + listed.reason);
|
|
2271
|
+
if (listed.kind === "absent") return [];
|
|
2272
|
+
const out = [];
|
|
2273
|
+
for (const entry of listed.value) {
|
|
2274
|
+
if (entry === "" || entry === "." || entry === "..") continue;
|
|
2275
|
+
const child = rel === "" ? entry : rel + "/" + entry;
|
|
2276
|
+
if (!safeRelative$1(child)) continue;
|
|
2277
|
+
out.push(...await walkFiles(io, root, child));
|
|
2278
|
+
}
|
|
2279
|
+
return out;
|
|
2280
|
+
}
|
|
2281
|
+
async function readStagedTree(io, home, runId) {
|
|
2282
|
+
const staging = await readStaging(io, home, runId);
|
|
2283
|
+
if (staging.kind === "absent") return { kind: "absent" };
|
|
2284
|
+
if (staging.kind === "unknown") return probeUnknown(staging.reason);
|
|
2285
|
+
const target = staging.value.manifest.target;
|
|
2286
|
+
const root = join(stagingRoot(home, runId), "merged", target);
|
|
2287
|
+
let paths;
|
|
2288
|
+
try {
|
|
2289
|
+
paths = await walkFiles(io, root);
|
|
2290
|
+
} catch (error) {
|
|
2291
|
+
return probeUnknown("cannot list merged tree: " + (error instanceof Error ? error.message : String(error)));
|
|
2292
|
+
}
|
|
2293
|
+
if (!paths.includes("SKILL.md")) return probeUnknown("merged tree has no SKILL.md");
|
|
2294
|
+
const files = [];
|
|
2295
|
+
for (const path of paths.sort()) {
|
|
2296
|
+
if (path === "SKILL.md") continue;
|
|
2297
|
+
try {
|
|
2298
|
+
const content = await io.readText(join(root, ...path.split("/")));
|
|
2299
|
+
if (content === null) return probeUnknown("merged file disappeared: " + path);
|
|
2300
|
+
files.push({
|
|
2301
|
+
path,
|
|
2302
|
+
content
|
|
2303
|
+
});
|
|
2304
|
+
} catch (error) {
|
|
2305
|
+
return probeUnknown("cannot read merged file " + path + ": " + (error instanceof Error ? error.message : String(error)));
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
const body = await io.readText(join(root, "SKILL.md"));
|
|
2309
|
+
if (body === null) return probeUnknown("merged SKILL.md disappeared");
|
|
2310
|
+
return {
|
|
2311
|
+
kind: "present",
|
|
2312
|
+
value: {
|
|
2313
|
+
target,
|
|
2314
|
+
body,
|
|
2315
|
+
files
|
|
2316
|
+
}
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
//#endregion
|
|
2320
|
+
//#region lib/types/rules.js
|
|
2321
|
+
/**
|
|
2322
|
+
* Deterministic gate for a staged merge tree. No io, no model.
|
|
2323
|
+
*
|
|
2324
|
+
* block = commit must not proceed; warn = report only. The path and reference checks reuse
|
|
2325
|
+
* the skill-store's own `validateSupportPath` / `supportRefs`, so there is one whitelist.
|
|
2326
|
+
*/
|
|
2327
|
+
const PLACEHOLDER = /\bTODO\b|\bxxx\b|\{\{[^}]*\}\}/i;
|
|
2328
|
+
const DESC_WARN_LENGTH = 60;
|
|
2329
|
+
function validateStagedTree(tree, limits = DEFAULT_SKILL_LIMITS) {
|
|
2330
|
+
const findings = [];
|
|
2331
|
+
const block = (rule, path, message) => {
|
|
2332
|
+
findings.push({
|
|
2333
|
+
rule,
|
|
2334
|
+
path,
|
|
2335
|
+
severity: "block",
|
|
2336
|
+
message
|
|
2337
|
+
});
|
|
2338
|
+
};
|
|
2339
|
+
const warn = (rule, path, message) => {
|
|
2340
|
+
findings.push({
|
|
2341
|
+
rule,
|
|
2342
|
+
path,
|
|
2343
|
+
severity: "warn",
|
|
2344
|
+
message
|
|
2345
|
+
});
|
|
2346
|
+
};
|
|
2347
|
+
const parsed = parseFrontmatter(tree.body);
|
|
2348
|
+
if (parsed === null) block("frontmatter", "SKILL.md", "SKILL.md has no valid frontmatter");
|
|
2349
|
+
else {
|
|
2350
|
+
const invalid = validateFrontmatter(tree.body, tree.target, limits);
|
|
2351
|
+
if (invalid !== null) block("frontmatter", "SKILL.md", invalid);
|
|
2352
|
+
if ((parsed.frontmatter.description ?? "").length > DESC_WARN_LENGTH) warn("desc-length", "SKILL.md", "description is longer than " + String(DESC_WARN_LENGTH) + " characters");
|
|
2353
|
+
}
|
|
2354
|
+
if (Buffer.byteLength(tree.body, "utf8") > limits.maxSkillFileBytes) block("bytes", "SKILL.md", "SKILL.md exceeds maxSkillFileBytes");
|
|
2355
|
+
if (scanContentThreats(tree.body) !== null) block("threat", "SKILL.md", "SKILL.md tripped the content threat scanner");
|
|
2356
|
+
if (PLACEHOLDER.test(tree.body)) warn("placeholder", "SKILL.md", "SKILL.md contains a TODO/xxx/{{...}} placeholder");
|
|
2357
|
+
const paths = /* @__PURE__ */ new Set();
|
|
2358
|
+
for (const file of tree.files) {
|
|
2359
|
+
const badPath = validateSupportPath(file.path);
|
|
2360
|
+
if (badPath !== null) {
|
|
2361
|
+
block("path", file.path, badPath);
|
|
2362
|
+
continue;
|
|
2363
|
+
}
|
|
2364
|
+
paths.add(file.path);
|
|
2365
|
+
if (Buffer.byteLength(file.content, "utf8") > limits.maxSkillFileBytes) block("bytes", file.path, "file exceeds maxSkillFileBytes");
|
|
2366
|
+
if (scanContentThreats(file.content) !== null) block("threat", file.path, "file tripped the content threat scanner");
|
|
2367
|
+
if (PLACEHOLDER.test(file.content)) warn("placeholder", file.path, "file contains a TODO/xxx/{{...}} placeholder");
|
|
2368
|
+
}
|
|
2369
|
+
for (const ref of supportRefs(tree.body)) if (!paths.has(ref)) block("refs-pair", ref, "body references a support file that is not staged");
|
|
2370
|
+
return findings;
|
|
2371
|
+
}
|
|
2372
|
+
function hasBlockingFinding(findings) {
|
|
2373
|
+
return findings.some((finding) => finding.severity === "block");
|
|
2374
|
+
}
|
|
2375
|
+
//#endregion
|
|
2376
|
+
//#region lib/types/commit-plan.js
|
|
2377
|
+
const MAX_SKILL_NAME_LENGTH = 64;
|
|
2378
|
+
/** Pick the first free target: requested, then -v2, -v3, ... (never truncate a name). */
|
|
2379
|
+
function resolveTargetName(requested, exists) {
|
|
2380
|
+
if (requested.length === 0 || requested.length > MAX_SKILL_NAME_LENGTH) return {
|
|
2381
|
+
ok: false,
|
|
2382
|
+
message: "target name must be 1.." + String(MAX_SKILL_NAME_LENGTH) + " characters"
|
|
2383
|
+
};
|
|
2384
|
+
if (!exists(requested)) return {
|
|
2385
|
+
ok: true,
|
|
2386
|
+
name: requested,
|
|
2387
|
+
suffix: 1
|
|
2388
|
+
};
|
|
2389
|
+
for (let suffix = 2; suffix <= 99; suffix += 1) {
|
|
2390
|
+
const candidate = requested + "-v" + String(suffix);
|
|
2391
|
+
if (candidate.length > MAX_SKILL_NAME_LENGTH) break;
|
|
2392
|
+
if (!exists(candidate)) return {
|
|
2393
|
+
ok: true,
|
|
2394
|
+
name: candidate,
|
|
2395
|
+
suffix
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
return {
|
|
2399
|
+
ok: false,
|
|
2400
|
+
message: "no free target name for " + requested + " (tried up to -v99)"
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
/** Rewrite only the frontmatter name; body bytes stay otherwise intact. */
|
|
2404
|
+
function rewriteFrontmatterName(body, name) {
|
|
2405
|
+
const lines = body.split("\n");
|
|
2406
|
+
if (lines[0]?.trim() !== "---") return body;
|
|
2407
|
+
const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
2408
|
+
if (end < 0) return body;
|
|
2409
|
+
for (let index = 1; index < end; index += 1) if (/^name\s*:/.test(lines[index] ?? "")) {
|
|
2410
|
+
lines[index] = "name: " + name;
|
|
2411
|
+
return lines.join("\n");
|
|
2412
|
+
}
|
|
2413
|
+
return body;
|
|
2414
|
+
}
|
|
2415
|
+
function buildCommitPlan(input) {
|
|
2416
|
+
if (input.create !== (input.targetCurrent === null)) return {
|
|
2417
|
+
ok: false,
|
|
2418
|
+
message: "create flag disagrees with the live target"
|
|
2419
|
+
};
|
|
2420
|
+
const sources = input.sourceBodies.filter((source) => source.name !== input.target);
|
|
2421
|
+
if (sources.length === 0) return {
|
|
2422
|
+
ok: false,
|
|
2423
|
+
message: "a merge needs at least one source different from the target"
|
|
2424
|
+
};
|
|
2425
|
+
const body = rewriteFrontmatterName(input.mergedBody, input.target);
|
|
2426
|
+
return {
|
|
2427
|
+
ok: true,
|
|
2428
|
+
plan: {
|
|
2429
|
+
target: input.target,
|
|
2430
|
+
targetExists: !input.create,
|
|
2431
|
+
sources: sources.map((source) => source.name),
|
|
2432
|
+
body,
|
|
2433
|
+
supportFiles: input.mergedFiles,
|
|
2434
|
+
expectedTargetBody: input.targetCurrent,
|
|
2435
|
+
expectedSourceBodies: sources,
|
|
2436
|
+
origin: input.origin,
|
|
2437
|
+
summary: input.summary
|
|
2438
|
+
}
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
//#endregion
|
|
2442
|
+
//#region lib/types/commit-assembly.js
|
|
2443
|
+
async function assembleStagedCommit(io, home, runId, port, options) {
|
|
2444
|
+
const staging = await readStaging(io, home, runId);
|
|
2445
|
+
if (!isPresent(staging)) return {
|
|
2446
|
+
ok: false,
|
|
2447
|
+
message: "staging is not present for run " + runId
|
|
2448
|
+
};
|
|
2449
|
+
const tree = await readStagedTree(io, home, runId);
|
|
2450
|
+
if (!isPresent(tree)) return {
|
|
2451
|
+
ok: false,
|
|
2452
|
+
message: "merged tree is not readable for run " + runId
|
|
2453
|
+
};
|
|
2454
|
+
const requested = staging.value.manifest.target;
|
|
2455
|
+
let target = requested;
|
|
2456
|
+
let targetCurrent = await port.readSkill(requested);
|
|
2457
|
+
const requestedArchived = targetCurrent === null && await port.isArchived?.(requested) === true;
|
|
2458
|
+
if (staging.value.manifest.mode === "create-umbrella" && (targetCurrent !== null || requestedArchived)) {
|
|
2459
|
+
target = requested;
|
|
2460
|
+
for (let suffix = 2; suffix <= 99; suffix += 1) {
|
|
2461
|
+
const candidate = requested + "-v" + String(suffix);
|
|
2462
|
+
if (await port.readSkill(candidate) === null && await port.isArchived?.(candidate) !== true) {
|
|
2463
|
+
target = candidate;
|
|
2464
|
+
targetCurrent = null;
|
|
2465
|
+
break;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
if (target === requested) return {
|
|
2469
|
+
ok: false,
|
|
2470
|
+
message: "no free umbrella target for " + requested
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
const sources = [];
|
|
2474
|
+
for (const name of staging.value.manifest.sources) {
|
|
2475
|
+
const body = await port.readSkill(name);
|
|
2476
|
+
if (body === null) return {
|
|
2477
|
+
ok: false,
|
|
2478
|
+
message: "source skill is not live: " + name
|
|
2479
|
+
};
|
|
2480
|
+
sources.push({
|
|
2481
|
+
name,
|
|
2482
|
+
body
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
return buildCommitPlan({
|
|
2486
|
+
target,
|
|
2487
|
+
create: targetCurrent === null,
|
|
2488
|
+
mergedBody: tree.value.body,
|
|
2489
|
+
mergedFiles: tree.value.files,
|
|
2490
|
+
targetCurrent,
|
|
2491
|
+
sourceBodies: sources,
|
|
2492
|
+
origin: options.origin,
|
|
2493
|
+
summary: options.summary
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
//#endregion
|
|
2497
|
+
//#region lib/types/commit-execute.js
|
|
2498
|
+
async function commitStagedRun(io, home, runId, port, options) {
|
|
2499
|
+
const assembled = await assembleStagedCommit(io, home, runId, port, {
|
|
2500
|
+
origin: options.origin,
|
|
2501
|
+
summary: options.summary
|
|
2502
|
+
});
|
|
2503
|
+
if (!assembled.ok) return {
|
|
2504
|
+
ok: false,
|
|
2505
|
+
message: assembled.message
|
|
2506
|
+
};
|
|
2507
|
+
const result = await port.commitMerge(assembled.plan);
|
|
2508
|
+
if (!result.ok) return {
|
|
2509
|
+
ok: false,
|
|
2510
|
+
message: result.message,
|
|
2511
|
+
plan: assembled.plan
|
|
2512
|
+
};
|
|
2513
|
+
if (result.unitId === void 0) return {
|
|
2514
|
+
ok: true,
|
|
2515
|
+
plan: assembled.plan,
|
|
2516
|
+
result,
|
|
2517
|
+
warning: "commit landed without a unitId; receipt not written"
|
|
2518
|
+
};
|
|
2519
|
+
const receipt = {
|
|
2520
|
+
runId,
|
|
2521
|
+
parentRunId: runId.split("/pairs/")[0] ?? runId,
|
|
2522
|
+
unitId: result.unitId,
|
|
2523
|
+
target: assembled.plan.target,
|
|
2524
|
+
create: !assembled.plan.targetExists,
|
|
2525
|
+
sources: assembled.plan.sources,
|
|
2526
|
+
archived: result.archived ?? [],
|
|
2527
|
+
at: options.now()
|
|
2528
|
+
};
|
|
2529
|
+
try {
|
|
2530
|
+
await writeCommitReceipt(io, home, receipt);
|
|
2531
|
+
return {
|
|
2532
|
+
ok: true,
|
|
2533
|
+
plan: assembled.plan,
|
|
2534
|
+
result,
|
|
2535
|
+
receipt
|
|
2536
|
+
};
|
|
2537
|
+
} catch (error) {
|
|
2538
|
+
return {
|
|
2539
|
+
ok: true,
|
|
2540
|
+
plan: assembled.plan,
|
|
2541
|
+
result,
|
|
2542
|
+
warning: "commit landed but receipt write failed: " + (error instanceof Error ? error.message : String(error))
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
//#endregion
|
|
2547
|
+
//#region lib/types/authoring-flow.js
|
|
2548
|
+
async function runAuthoring(options) {
|
|
2549
|
+
const targetCard = options.cards.find((card) => card.name === options.target);
|
|
2550
|
+
if (targetCard?.protectionUnknown === true || targetCard?.protectedBy === "pinned") return {
|
|
2551
|
+
ok: false,
|
|
2552
|
+
message: "target is pinned: " + options.target
|
|
2553
|
+
};
|
|
2554
|
+
const stageKey = options.stageKey ?? options.runId;
|
|
2555
|
+
if (options.reuseStaging === true) {
|
|
2556
|
+
if (!await setStagingTarget(options.io, options.home, stageKey, options.target, options.mode)) return {
|
|
2557
|
+
ok: false,
|
|
2558
|
+
message: "staging missing or unreadable: " + stageKey
|
|
2559
|
+
};
|
|
2560
|
+
} else {
|
|
2561
|
+
const created = await createStaging(options.io, options.home, {
|
|
2562
|
+
runId: stageKey,
|
|
2563
|
+
target: options.target,
|
|
2564
|
+
...options.mode === void 0 ? {} : { mode: options.mode },
|
|
2565
|
+
sources: options.cards.map((card) => ({
|
|
2566
|
+
name: card.name,
|
|
2567
|
+
body: card.body,
|
|
2568
|
+
files: card.files ?? []
|
|
2569
|
+
})),
|
|
2570
|
+
promptVersion: "merge-b/v1",
|
|
2571
|
+
ttlDays: 7,
|
|
2572
|
+
now: options.now
|
|
2573
|
+
});
|
|
2574
|
+
if (!created.ok) return {
|
|
2575
|
+
ok: false,
|
|
2576
|
+
message: created.message
|
|
2577
|
+
};
|
|
2578
|
+
}
|
|
2579
|
+
try {
|
|
2580
|
+
await options.author({
|
|
2581
|
+
home: options.home,
|
|
2582
|
+
runId: stageKey
|
|
2583
|
+
});
|
|
2584
|
+
} catch (error) {
|
|
2585
|
+
return {
|
|
2586
|
+
ok: false,
|
|
2587
|
+
message: "author failed: " + (error instanceof Error ? error.message : String(error))
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
const tree = await readStagedTree(options.io, options.home, stageKey);
|
|
2591
|
+
if (tree.kind !== "present") return {
|
|
2592
|
+
ok: false,
|
|
2593
|
+
message: "staged tree is not readable: " + (tree.kind === "unknown" ? tree.reason : "absent")
|
|
2594
|
+
};
|
|
2595
|
+
const findings = validateStagedTree(tree.value);
|
|
2596
|
+
if (hasBlockingFinding(findings)) return {
|
|
2597
|
+
ok: false,
|
|
2598
|
+
message: "staged tree failed the rule gate",
|
|
2599
|
+
findings
|
|
2600
|
+
};
|
|
2601
|
+
const commit = await commitStagedRun(options.io, options.home, stageKey, options.commitPort, {
|
|
2602
|
+
origin: "subagent",
|
|
2603
|
+
summary: options.summary,
|
|
2604
|
+
now: options.now
|
|
2605
|
+
});
|
|
2606
|
+
if (!commit.ok) return {
|
|
2607
|
+
ok: false,
|
|
2608
|
+
message: commit.message
|
|
2609
|
+
};
|
|
2610
|
+
return {
|
|
2611
|
+
ok: true,
|
|
2612
|
+
tree: tree.value,
|
|
2613
|
+
commit
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
//#endregion
|
|
2617
|
+
//#region lib/types/scan-ports.js
|
|
2618
|
+
function createChannelScanPorts(options) {
|
|
2619
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
2620
|
+
return {
|
|
2621
|
+
screening: options.screening,
|
|
2622
|
+
async round1(candidate, signal) {
|
|
2623
|
+
const stageKey = stageKeyOf(options.runId, pairKey(candidate.a, candidate.b));
|
|
2624
|
+
const staged = await ensureStaging(options.io, options.home, {
|
|
2625
|
+
runId: stageKey,
|
|
2626
|
+
target: candidate.a + "-" + candidate.b,
|
|
2627
|
+
sources: options.cards.filter((card) => card.name === candidate.a || card.name === candidate.b).map((card) => ({
|
|
2628
|
+
name: card.name,
|
|
2629
|
+
body: card.body,
|
|
2630
|
+
files: card.files ?? []
|
|
2631
|
+
})),
|
|
2632
|
+
promptVersion: "merge-b/v1",
|
|
2633
|
+
ttlDays: 7,
|
|
2634
|
+
now: options.now
|
|
2635
|
+
});
|
|
2636
|
+
if (!staged.ok) return {
|
|
2637
|
+
kind: "failed",
|
|
2638
|
+
failure: {
|
|
2639
|
+
stage: "decide",
|
|
2640
|
+
code: "staging-create",
|
|
2641
|
+
message: staged.message,
|
|
2642
|
+
retryable: true,
|
|
2643
|
+
retryFrom: "agent1"
|
|
2644
|
+
}
|
|
2645
|
+
};
|
|
2646
|
+
const result = await runRound1OverChannel({
|
|
2647
|
+
channel: options.channel,
|
|
2648
|
+
parent: options.parent,
|
|
2649
|
+
cards: options.cards,
|
|
2650
|
+
allow: options.allow,
|
|
2651
|
+
cwd: stagingRoot(options.home, stageKey),
|
|
2652
|
+
timeoutMs: options.timeoutMs,
|
|
2653
|
+
onChildOpened: (childId) => options.onChild?.({
|
|
2654
|
+
runId: stageKey,
|
|
2655
|
+
childId,
|
|
2656
|
+
expectedChildId: childId,
|
|
2657
|
+
phase: "decide"
|
|
2658
|
+
})
|
|
2659
|
+
}, candidate, signal);
|
|
2660
|
+
if (result.outcome.kind !== "merge") await dropStaging(options.io, options.home, stageKey);
|
|
2661
|
+
else if (options.approval) await result.dispose();
|
|
2662
|
+
else {
|
|
2663
|
+
sessions.set(pairKey(candidate.a, candidate.b), {
|
|
2664
|
+
result,
|
|
2665
|
+
stageKey
|
|
2666
|
+
});
|
|
2667
|
+
if (result.childId !== void 0) options.onChild?.({
|
|
2668
|
+
runId: stageKey,
|
|
2669
|
+
childId: result.childId,
|
|
2670
|
+
expectedChildId: result.childId,
|
|
2671
|
+
phase: "author"
|
|
2672
|
+
});
|
|
2673
|
+
}
|
|
2674
|
+
return result.outcome.kind === "merge" ? {
|
|
2675
|
+
...result.outcome,
|
|
2676
|
+
stagingRef: stageKey,
|
|
2677
|
+
stagingExpiresAt: options.now() + 7 * 864e5
|
|
2678
|
+
} : result.outcome;
|
|
2679
|
+
},
|
|
2680
|
+
async round2(row, decision, signal) {
|
|
2681
|
+
const key = pairKey(row.a, row.b);
|
|
2682
|
+
const entry = sessions.get(key);
|
|
2683
|
+
if (entry === void 0) return {
|
|
2684
|
+
kind: "failed",
|
|
2685
|
+
failure: {
|
|
2686
|
+
stage: "author",
|
|
2687
|
+
code: "no-session",
|
|
2688
|
+
message: "no same-child round2 session; approve must open a new child",
|
|
2689
|
+
retryable: false,
|
|
2690
|
+
retryFrom: "none"
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2693
|
+
sessions.delete(key);
|
|
2694
|
+
try {
|
|
2695
|
+
const authored = await runAuthoring({
|
|
2696
|
+
io: options.io,
|
|
2697
|
+
home: options.home,
|
|
2698
|
+
runId: options.runId,
|
|
2699
|
+
stageKey: entry.stageKey,
|
|
2700
|
+
reuseStaging: true,
|
|
2701
|
+
target: decision.target,
|
|
2702
|
+
mode: decision.mode,
|
|
2703
|
+
cards: options.cards,
|
|
2704
|
+
commitPort: options.commitPort,
|
|
2705
|
+
now: options.now,
|
|
2706
|
+
summary: "merge " + row.a + " + " + row.b,
|
|
2707
|
+
author: async () => {
|
|
2708
|
+
await entry.result.author?.(row, decision, signal);
|
|
2709
|
+
return "authored";
|
|
2710
|
+
}
|
|
2711
|
+
});
|
|
2712
|
+
if (!authored.ok) return {
|
|
2713
|
+
kind: "failed",
|
|
2714
|
+
failure: {
|
|
2715
|
+
stage: "author",
|
|
2716
|
+
code: "authoring-failed",
|
|
2717
|
+
message: authored.message,
|
|
2718
|
+
retryable: true,
|
|
2719
|
+
retryFrom: "agent2"
|
|
2720
|
+
}
|
|
2721
|
+
};
|
|
2722
|
+
const receipt = authored.commit.receipt;
|
|
2723
|
+
return {
|
|
2724
|
+
kind: "merged",
|
|
2725
|
+
...receipt?.unitId === void 0 ? {} : { unitId: receipt.unitId },
|
|
2726
|
+
archived: receipt?.archived ?? [],
|
|
2727
|
+
target: {
|
|
2728
|
+
requested: decision.target,
|
|
2729
|
+
actual: authored.commit.plan.target,
|
|
2730
|
+
created: receipt?.create ?? false
|
|
2731
|
+
}
|
|
2732
|
+
};
|
|
2733
|
+
} finally {
|
|
2734
|
+
await entry.result.dispose();
|
|
2735
|
+
if (entry.result.childId !== void 0) options.onChildClosed?.(entry.result.childId);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
//#endregion
|
|
2741
|
+
//#region lib/types/new-child-authoring.js
|
|
2742
|
+
async function runNewChildAuthoring(options, row, decision, signal) {
|
|
2743
|
+
const targetCard = options.cards.find((card) => card.name === decision.target);
|
|
2744
|
+
if (targetCard?.protectionUnknown === true || targetCard?.protectedBy === "pinned") return {
|
|
2745
|
+
kind: "failed",
|
|
2746
|
+
failure: {
|
|
2747
|
+
stage: "author",
|
|
2748
|
+
code: "target-pinned",
|
|
2749
|
+
message: "target is pinned: " + decision.target,
|
|
2750
|
+
retryable: false,
|
|
2751
|
+
retryFrom: "none"
|
|
2752
|
+
}
|
|
2753
|
+
};
|
|
2754
|
+
const stageKey = row.stagingRef ?? stageKeyOf(options.runId, pairKey(row.a, row.b));
|
|
2755
|
+
if ((await readStaging(options.io, options.home, stageKey)).kind === "present") await setStagingTarget(options.io, options.home, stageKey, decision.target, decision.mode);
|
|
2756
|
+
else {
|
|
2757
|
+
const staging = await createStaging(options.io, options.home, {
|
|
2758
|
+
runId: stageKey,
|
|
2759
|
+
target: decision.target,
|
|
2760
|
+
mode: decision.mode,
|
|
2761
|
+
sources: options.cards.filter((card) => card.name === row.a || card.name === row.b).map((card) => ({
|
|
2762
|
+
name: card.name,
|
|
2763
|
+
body: card.body,
|
|
2764
|
+
files: card.files ?? []
|
|
2765
|
+
})),
|
|
2766
|
+
promptVersion: "merge-b/v1",
|
|
2767
|
+
ttlDays: 7,
|
|
2768
|
+
now: options.now
|
|
2769
|
+
});
|
|
2770
|
+
if (!staging.ok) return {
|
|
2771
|
+
kind: "failed",
|
|
2772
|
+
failure: {
|
|
2773
|
+
stage: "author",
|
|
2774
|
+
code: "staging-create",
|
|
2775
|
+
message: staging.message,
|
|
2776
|
+
retryable: true,
|
|
2777
|
+
retryFrom: "agent2"
|
|
2778
|
+
}
|
|
2779
|
+
};
|
|
2780
|
+
}
|
|
2781
|
+
const opened = await options.channel.open({
|
|
2782
|
+
parent: options.parent,
|
|
2783
|
+
label: "merge-round2",
|
|
2784
|
+
promptText: STANDBY_PROMPT,
|
|
2785
|
+
kind: "maintainer",
|
|
2786
|
+
allow: options.allow,
|
|
2787
|
+
cwd: stagingRoot(options.home, stageKey),
|
|
2788
|
+
timeoutMs: options.timeoutMs,
|
|
2789
|
+
signal
|
|
2790
|
+
});
|
|
2791
|
+
if (opened.kind !== "ok") return {
|
|
2792
|
+
kind: "failed",
|
|
2793
|
+
failure: {
|
|
2794
|
+
stage: "author",
|
|
2795
|
+
code: opened.kind,
|
|
2796
|
+
message: opened.kind === "unavailable" ? opened.reason : "round2 aborted",
|
|
2797
|
+
retryable: opened.kind !== "aborted",
|
|
2798
|
+
retryFrom: opened.kind === "aborted" ? "none" : "agent2"
|
|
2799
|
+
}
|
|
2800
|
+
};
|
|
2801
|
+
options.onChild?.({
|
|
2802
|
+
runId: stageKey,
|
|
2803
|
+
childId: opened.childId,
|
|
2804
|
+
expectedChildId: opened.childId,
|
|
2805
|
+
phase: "author"
|
|
2806
|
+
});
|
|
2807
|
+
try {
|
|
2808
|
+
if (opened.turn.stopReason !== "completed") return {
|
|
2809
|
+
kind: "failed",
|
|
2810
|
+
failure: {
|
|
2811
|
+
stage: "author",
|
|
2812
|
+
code: opened.turn.stopReason,
|
|
2813
|
+
message: "standby did not complete",
|
|
2814
|
+
retryable: true,
|
|
2815
|
+
retryFrom: "agent2"
|
|
2816
|
+
}
|
|
2817
|
+
};
|
|
2818
|
+
const turn = await options.channel.continueTurn(opened.childId, buildRound2Prompt(row, decision, "fresh"), {
|
|
2819
|
+
timeoutMs: options.timeoutMs,
|
|
2820
|
+
signal
|
|
2821
|
+
});
|
|
2822
|
+
if (turn.kind !== "ok") return {
|
|
2823
|
+
kind: "failed",
|
|
2824
|
+
failure: {
|
|
2825
|
+
stage: "author",
|
|
2826
|
+
code: turn.kind,
|
|
2827
|
+
message: turn.kind === "unavailable" ? turn.reason : "round2 aborted",
|
|
2828
|
+
retryable: turn.kind !== "aborted",
|
|
2829
|
+
retryFrom: turn.kind === "aborted" ? "none" : "agent2"
|
|
2830
|
+
}
|
|
2831
|
+
};
|
|
2832
|
+
if (turn.turn.stopReason !== "completed") return {
|
|
2833
|
+
kind: "failed",
|
|
2834
|
+
failure: {
|
|
2835
|
+
stage: "author",
|
|
2836
|
+
code: turn.turn.stopReason,
|
|
2837
|
+
message: "round2 did not complete",
|
|
2838
|
+
retryable: true,
|
|
2839
|
+
retryFrom: "agent2"
|
|
2840
|
+
}
|
|
2841
|
+
};
|
|
2842
|
+
const tree = await readStagedTree(options.io, options.home, stageKey);
|
|
2843
|
+
if (tree.kind !== "present") return {
|
|
2844
|
+
kind: "failed",
|
|
2845
|
+
failure: {
|
|
2846
|
+
stage: "author",
|
|
2847
|
+
code: "staging-missing",
|
|
2848
|
+
message: tree.kind === "unknown" ? tree.reason : "staged tree absent",
|
|
2849
|
+
retryable: true,
|
|
2850
|
+
retryFrom: "agent2"
|
|
2851
|
+
}
|
|
2852
|
+
};
|
|
2853
|
+
if (hasBlockingFinding(validateStagedTree(tree.value))) return {
|
|
2854
|
+
kind: "failed",
|
|
2855
|
+
failure: {
|
|
2856
|
+
stage: "validate",
|
|
2857
|
+
code: "rule-gate",
|
|
2858
|
+
message: "staged tree failed the rule gate",
|
|
2859
|
+
retryable: true,
|
|
2860
|
+
retryFrom: "agent2"
|
|
2861
|
+
}
|
|
2862
|
+
};
|
|
2863
|
+
const commit = await commitStagedRun(options.io, options.home, stageKey, options.commitPort, {
|
|
2864
|
+
origin: "subagent",
|
|
2865
|
+
summary: "merge " + row.a + " + " + row.b,
|
|
2866
|
+
now: options.now
|
|
2867
|
+
});
|
|
2868
|
+
if (!commit.ok) return {
|
|
2869
|
+
kind: "failed",
|
|
2870
|
+
failure: {
|
|
2871
|
+
stage: "commit",
|
|
2872
|
+
code: "commit-failed",
|
|
2873
|
+
message: commit.message,
|
|
2874
|
+
retryable: true,
|
|
2875
|
+
retryFrom: "agent2"
|
|
2876
|
+
}
|
|
2877
|
+
};
|
|
2878
|
+
const receipt = commit.receipt;
|
|
2879
|
+
return {
|
|
2880
|
+
kind: "merged",
|
|
2881
|
+
...receipt?.unitId === void 0 ? {} : { unitId: receipt.unitId },
|
|
2882
|
+
archived: receipt?.archived ?? [],
|
|
2883
|
+
target: {
|
|
2884
|
+
requested: decision.target,
|
|
2885
|
+
actual: commit.plan.target,
|
|
2886
|
+
created: receipt?.create ?? false
|
|
2887
|
+
}
|
|
2888
|
+
};
|
|
2889
|
+
} finally {
|
|
2890
|
+
try {
|
|
2891
|
+
await options.channel.dispose(opened.childId);
|
|
2892
|
+
} catch {}
|
|
2893
|
+
options.onChildClosed?.(opened.childId);
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
function createNewChildRound2(options) {
|
|
2897
|
+
return (row, decision, signal, runId) => runNewChildAuthoring({
|
|
2898
|
+
...options,
|
|
2899
|
+
runId
|
|
2900
|
+
}, row, decision, signal);
|
|
2901
|
+
}
|
|
2902
|
+
//#endregion
|
|
2903
|
+
//#region lib/types/adapters/library.js
|
|
2904
|
+
function skillLibraryPort(skills) {
|
|
2905
|
+
return {
|
|
2906
|
+
readSkill: (name) => skills.read(name),
|
|
2907
|
+
isArchived: async (name) => {
|
|
2908
|
+
if (typeof skills.archivedNames !== "function") return false;
|
|
2909
|
+
const names = await skills.archivedNames();
|
|
2910
|
+
return names === null ? true : names.includes(name);
|
|
2911
|
+
},
|
|
2912
|
+
commitMerge: (plan) => skills.commitMerge(plan)
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
//#endregion
|
|
2916
|
+
//#region lib/types/runtime.js
|
|
2917
|
+
/**
|
|
2918
|
+
* The merge command runtime: cards -> screening -> channel ports -> scan/approve.
|
|
2919
|
+
* Plugin wiring supplies the channel, model and library; this module keeps the orchestration
|
|
2920
|
+
* out of apply() and makes the mutating verbs testable with fakes.
|
|
2921
|
+
*/
|
|
2922
|
+
function createMergeRuntime(deps) {
|
|
2923
|
+
const readOnly = createReadOnlyMergeService(deps.io, deps.home);
|
|
2924
|
+
const timeout = deps.timeoutMinutes > 0 ? deps.timeoutMinutes : void 0;
|
|
2925
|
+
const common = {
|
|
2926
|
+
io: deps.io,
|
|
2927
|
+
home: deps.home,
|
|
2928
|
+
registry: deps.registry,
|
|
2929
|
+
platformWrite: deps.platformWrite,
|
|
2930
|
+
now: deps.now,
|
|
2931
|
+
...timeout === void 0 ? {} : { timeoutMinutes: timeout }
|
|
2932
|
+
};
|
|
2933
|
+
const stage = deps.stage;
|
|
2934
|
+
const stageHooks = stage === void 0 ? {} : {
|
|
2935
|
+
onChild: (context) => {
|
|
2936
|
+
stage.bind(context);
|
|
2937
|
+
},
|
|
2938
|
+
onChildClosed: (childId) => {
|
|
2939
|
+
stage.unbind(childId);
|
|
2940
|
+
}
|
|
2941
|
+
};
|
|
2942
|
+
return { async execute(command, context) {
|
|
2943
|
+
const parent = context?.parent ?? deps.parent;
|
|
2944
|
+
if (command.verb === "pair") {
|
|
2945
|
+
const signal = new AbortController().signal;
|
|
2946
|
+
const cards = await deps.cards(signal);
|
|
2947
|
+
const pair = [{
|
|
2948
|
+
a: command.a,
|
|
2949
|
+
b: command.b
|
|
2950
|
+
}];
|
|
2951
|
+
const result = await runScanCommand({
|
|
2952
|
+
...common,
|
|
2953
|
+
approval: deps.params.mergeApproval,
|
|
2954
|
+
makePorts: (runId) => createChannelScanPorts({
|
|
2955
|
+
io: deps.io,
|
|
2956
|
+
home: deps.home,
|
|
2957
|
+
runId,
|
|
2958
|
+
cards,
|
|
2959
|
+
screening: () => Promise.resolve(pair),
|
|
2960
|
+
channel: deps.channel,
|
|
2961
|
+
parent,
|
|
2962
|
+
allow: deps.allow,
|
|
2963
|
+
...deps.cwd === void 0 ? {} : { cwd: deps.cwd },
|
|
2964
|
+
timeoutMs: deps.params.mergePairTimeoutMinutes * 6e4,
|
|
2965
|
+
approval: deps.params.mergeApproval,
|
|
2966
|
+
commitPort: skillLibraryPort(deps.skills),
|
|
2967
|
+
now: deps.now,
|
|
2968
|
+
...stageHooks
|
|
2969
|
+
})
|
|
2970
|
+
});
|
|
2971
|
+
return result.ok ? {
|
|
2972
|
+
ok: true,
|
|
2973
|
+
text: "merge pair 完成:merged " + String(result.report.totals.merged) + " / pending " + String(result.report.totals.pending) + " / failed " + String(result.report.totals.failed)
|
|
2974
|
+
} : {
|
|
2975
|
+
ok: false,
|
|
2976
|
+
text: result.message
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2979
|
+
if (command.verb === "scan") {
|
|
2980
|
+
const signal = new AbortController().signal;
|
|
2981
|
+
const cards = await deps.cards(signal);
|
|
2982
|
+
const loadedState = await loadMergeState(deps.io, deps.home);
|
|
2983
|
+
const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
|
|
2984
|
+
const held = new Set(loadedState.state.rows.filter((row) => row.queue === "pending" || !canRetry(row)).map((row) => row.pairKey));
|
|
2985
|
+
const result = await runScanCommand({
|
|
2986
|
+
...common,
|
|
2987
|
+
approval: deps.params.mergeApproval,
|
|
2988
|
+
makePorts: (runId) => createChannelScanPorts({
|
|
2989
|
+
io: deps.io,
|
|
2990
|
+
home: deps.home,
|
|
2991
|
+
runId,
|
|
2992
|
+
cards,
|
|
2993
|
+
screening: async (turnSignal) => {
|
|
2994
|
+
const raw = await deps.screeningForRun(cards, runId, turnSignal);
|
|
2995
|
+
const filtered = raw.filter((candidate) => {
|
|
2996
|
+
const key = pairKey(candidate.a, candidate.b);
|
|
2997
|
+
return !skipped.has(key) && !held.has(key);
|
|
2998
|
+
});
|
|
2999
|
+
const candidatePairs = raw.candidatePairs;
|
|
3000
|
+
return candidatePairs === void 0 ? filtered : Object.assign(filtered, { candidatePairs });
|
|
3001
|
+
},
|
|
3002
|
+
channel: deps.channel,
|
|
3003
|
+
parent,
|
|
3004
|
+
allow: deps.allow,
|
|
3005
|
+
...deps.cwd === void 0 ? {} : { cwd: deps.cwd },
|
|
3006
|
+
timeoutMs: deps.params.mergePairTimeoutMinutes * 6e4,
|
|
3007
|
+
approval: deps.params.mergeApproval,
|
|
3008
|
+
commitPort: skillLibraryPort(deps.skills),
|
|
3009
|
+
now: deps.now,
|
|
3010
|
+
...stageHooks
|
|
3011
|
+
})
|
|
3012
|
+
});
|
|
3013
|
+
return result.ok ? {
|
|
3014
|
+
ok: true,
|
|
3015
|
+
text: "merge scan 完成:merged " + String(result.report.totals.merged) + " / pending " + String(result.report.totals.pending) + " / failed " + String(result.report.totals.failed)
|
|
3016
|
+
} : {
|
|
3017
|
+
ok: false,
|
|
3018
|
+
text: result.message
|
|
3019
|
+
};
|
|
3020
|
+
}
|
|
3021
|
+
if (command.verb === "reject") {
|
|
3022
|
+
const row = (await loadMergeState(deps.io, deps.home)).state.rows.find((item) => item.id === command.id);
|
|
3023
|
+
if (row === void 0) return {
|
|
3024
|
+
ok: false,
|
|
3025
|
+
text: "merge row not found: " + command.id
|
|
3026
|
+
};
|
|
3027
|
+
const next = transition(row, {
|
|
3028
|
+
type: "reject",
|
|
3029
|
+
at: new Date(deps.now()).toISOString()
|
|
3030
|
+
});
|
|
3031
|
+
if (!next.ok || next.row === null) return {
|
|
3032
|
+
ok: false,
|
|
3033
|
+
text: "merge row is not pending: " + command.id
|
|
3034
|
+
};
|
|
3035
|
+
await upsertRow(deps.io, deps.home, deps.now, next.row);
|
|
3036
|
+
return {
|
|
3037
|
+
ok: true,
|
|
3038
|
+
text: "已拒绝合并:" + command.id
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
3041
|
+
if (command.verb === "doctor") return {
|
|
3042
|
+
ok: true,
|
|
3043
|
+
text: (await mergeDoctor(deps.io, deps.home, deps.params.mergeModelsRoot)).map((row) => row.key + " [" + row.status + "] " + row.label + ":" + row.detail).join("\n")
|
|
3044
|
+
};
|
|
3045
|
+
if (command.verb === "report" || command.verb === "runs" || command.verb === "cancel") {
|
|
3046
|
+
const registry = deps.registry;
|
|
3047
|
+
if (command.verb === "runs") {
|
|
3048
|
+
const mergeRuns = registry.runs("merge");
|
|
3049
|
+
return {
|
|
3050
|
+
ok: true,
|
|
3051
|
+
text: mergeRuns.length === 0 ? "merge runs: empty" : ["merge runs: " + String(mergeRuns.length)].concat(mergeRuns.map((run) => "- " + run.id + " [" + run.state + "]" + (run.resultRef === void 0 ? "" : " " + run.resultRef))).join("\n")
|
|
3052
|
+
};
|
|
3053
|
+
}
|
|
3054
|
+
if (command.verb === "cancel") {
|
|
3055
|
+
if (command.id !== void 0) {
|
|
3056
|
+
const cancelled = registry.cancel(command.id);
|
|
3057
|
+
return {
|
|
3058
|
+
ok: cancelled,
|
|
3059
|
+
text: cancelled ? "已请求取消 " + command.id : "没有可取消的 run: " + command.id
|
|
3060
|
+
};
|
|
3061
|
+
}
|
|
3062
|
+
const count = registry.cancelAll();
|
|
3063
|
+
return {
|
|
3064
|
+
ok: true,
|
|
3065
|
+
text: "已请求取消 " + String(count) + " 个运行"
|
|
3066
|
+
};
|
|
3067
|
+
}
|
|
3068
|
+
const run = command.id === void 0 ? registry.runs("merge")[0] : registry.find(command.id);
|
|
3069
|
+
if (run === void 0) return {
|
|
3070
|
+
ok: false,
|
|
3071
|
+
text: "merge run not found: " + (command.id ?? "(latest)")
|
|
3072
|
+
};
|
|
3073
|
+
if (run.resultRef === void 0) return {
|
|
3074
|
+
ok: false,
|
|
3075
|
+
text: "merge run has no report yet: " + run.id
|
|
3076
|
+
};
|
|
3077
|
+
const report = await probeText(deps.io, run.resultRef);
|
|
3078
|
+
if (report.kind === "unknown") return {
|
|
3079
|
+
ok: false,
|
|
3080
|
+
text: "report is unreadable: " + run.resultRef + ":" + report.reason
|
|
3081
|
+
};
|
|
3082
|
+
return report.kind === "absent" ? {
|
|
3083
|
+
ok: false,
|
|
3084
|
+
text: "report is missing: " + run.resultRef
|
|
3085
|
+
} : {
|
|
3086
|
+
ok: true,
|
|
3087
|
+
text: report.value.slice(0, 12e3)
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
if (command.verb === "undo") {
|
|
3091
|
+
const receipt = await readCommitReceipt(deps.io, deps.home, command.run, command.target);
|
|
3092
|
+
if (receipt === null) return {
|
|
3093
|
+
ok: false,
|
|
3094
|
+
text: "merge run has no commit receipt: " + command.run
|
|
3095
|
+
};
|
|
3096
|
+
if (receipt.target !== command.target) return {
|
|
3097
|
+
ok: false,
|
|
3098
|
+
text: "receipt target is " + receipt.target + ", not " + command.target
|
|
3099
|
+
};
|
|
3100
|
+
const result = await undoMergeReceipt(receipt, skillLibraryUndoPort(deps.skills));
|
|
3101
|
+
return {
|
|
3102
|
+
ok: result.ok,
|
|
3103
|
+
text: result.message
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
3106
|
+
if (command.verb === "skipped" && command.restore !== void 0) {
|
|
3107
|
+
const row = (await loadMergeState(deps.io, deps.home)).state.rows.find((item) => item.id === command.restore);
|
|
3108
|
+
if (row === void 0 || row.state !== "skipped") return {
|
|
3109
|
+
ok: false,
|
|
3110
|
+
text: "skipped row not found: " + command.restore
|
|
3111
|
+
};
|
|
3112
|
+
await dropRow(deps.io, deps.home, deps.now, row.id);
|
|
3113
|
+
return {
|
|
3114
|
+
ok: true,
|
|
3115
|
+
text: "已恢复参与筛选:" + row.id
|
|
3116
|
+
};
|
|
3117
|
+
}
|
|
3118
|
+
if (command.verb === "resume") {
|
|
3119
|
+
if (command.clear !== void 0) {
|
|
3120
|
+
const cleared = (await loadMergeState(deps.io, deps.home)).state.rows.find((item) => item.id === command.clear);
|
|
3121
|
+
if (cleared === void 0) return {
|
|
3122
|
+
ok: false,
|
|
3123
|
+
text: "merge row not found: " + command.clear
|
|
3124
|
+
};
|
|
3125
|
+
await dropRow(deps.io, deps.home, deps.now, cleared.id);
|
|
3126
|
+
return {
|
|
3127
|
+
ok: true,
|
|
3128
|
+
text: "已清除合并行:" + cleared.id
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
3131
|
+
const loaded = await loadMergeState(deps.io, deps.home);
|
|
3132
|
+
const row = command.id === void 0 ? loaded.state.rows.find((item) => item.state === "fuse" || item.state === "verify") : loaded.state.rows.find((item) => item.id === command.id);
|
|
3133
|
+
if (row === void 0) return {
|
|
3134
|
+
ok: false,
|
|
3135
|
+
text: "merge row not found: " + (command.id ?? "(auto)")
|
|
3136
|
+
};
|
|
3137
|
+
const signal = new AbortController().signal;
|
|
3138
|
+
const cards = await deps.cards(signal);
|
|
3139
|
+
const pair = () => Promise.resolve([{
|
|
3140
|
+
a: row.a,
|
|
3141
|
+
b: row.b
|
|
3142
|
+
}]);
|
|
3143
|
+
const failed = (stage, code, message) => Promise.resolve({
|
|
3144
|
+
kind: "failed",
|
|
3145
|
+
failure: {
|
|
3146
|
+
stage,
|
|
3147
|
+
code,
|
|
3148
|
+
message,
|
|
3149
|
+
retryable: false,
|
|
3150
|
+
retryFrom: "none"
|
|
3151
|
+
}
|
|
3152
|
+
});
|
|
3153
|
+
const basePorts = {
|
|
3154
|
+
screening: pair,
|
|
3155
|
+
round1: () => failed("decide", "resume-needs-ports", "verify resume needs makePorts"),
|
|
3156
|
+
round2: () => failed("author", "resume-needs-child", "fuse resume needs a new child")
|
|
3157
|
+
};
|
|
3158
|
+
const result = await runResumeCommand({
|
|
3159
|
+
...common,
|
|
3160
|
+
rowId: row.id,
|
|
3161
|
+
ports: basePorts,
|
|
3162
|
+
makePorts: (runId) => createChannelScanPorts({
|
|
3163
|
+
io: deps.io,
|
|
3164
|
+
home: deps.home,
|
|
3165
|
+
runId,
|
|
3166
|
+
cards,
|
|
3167
|
+
screening: pair,
|
|
3168
|
+
channel: deps.channel,
|
|
3169
|
+
parent,
|
|
3170
|
+
allow: deps.allow,
|
|
3171
|
+
...deps.cwd === void 0 ? {} : { cwd: deps.cwd },
|
|
3172
|
+
timeoutMs: deps.params.mergePairTimeoutMinutes * 6e4,
|
|
3173
|
+
approval: deps.params.mergeApproval,
|
|
3174
|
+
commitPort: skillLibraryPort(deps.skills),
|
|
3175
|
+
now: deps.now
|
|
3176
|
+
}),
|
|
3177
|
+
round2WithRun: createNewChildRound2({
|
|
3178
|
+
io: deps.io,
|
|
3179
|
+
home: deps.home,
|
|
3180
|
+
cards,
|
|
3181
|
+
channel: deps.channel,
|
|
3182
|
+
parent,
|
|
3183
|
+
allow: deps.allow,
|
|
3184
|
+
...deps.cwd === void 0 ? {} : { cwd: deps.cwd },
|
|
3185
|
+
timeoutMs: deps.params.mergePairTimeoutMinutes * 6e4,
|
|
3186
|
+
commitPort: skillLibraryPort(deps.skills),
|
|
3187
|
+
now: deps.now,
|
|
3188
|
+
...stageHooks
|
|
3189
|
+
})
|
|
3190
|
+
});
|
|
3191
|
+
return result.ok ? {
|
|
3192
|
+
ok: true,
|
|
3193
|
+
text: "merge resume 完成:merged " + String(result.report.totals.merged)
|
|
3194
|
+
} : {
|
|
3195
|
+
ok: false,
|
|
3196
|
+
text: result.message
|
|
3197
|
+
};
|
|
3198
|
+
}
|
|
3199
|
+
if (command.verb === "approve") {
|
|
3200
|
+
const signal = new AbortController().signal;
|
|
3201
|
+
const cards = await deps.cards(signal);
|
|
3202
|
+
const result = await runApproveCommand({
|
|
3203
|
+
...common,
|
|
3204
|
+
rowId: command.id,
|
|
3205
|
+
ports: { round2: createNewChildRound2({
|
|
3206
|
+
io: deps.io,
|
|
3207
|
+
home: deps.home,
|
|
3208
|
+
cards,
|
|
3209
|
+
channel: deps.channel,
|
|
3210
|
+
parent,
|
|
3211
|
+
allow: deps.allow,
|
|
3212
|
+
...deps.cwd === void 0 ? {} : { cwd: deps.cwd },
|
|
3213
|
+
timeoutMs: deps.params.mergePairTimeoutMinutes * 6e4,
|
|
3214
|
+
commitPort: skillLibraryPort(deps.skills),
|
|
3215
|
+
now: deps.now
|
|
3216
|
+
}) }
|
|
3217
|
+
});
|
|
3218
|
+
return result.ok ? {
|
|
3219
|
+
ok: true,
|
|
3220
|
+
text: "merge approve 完成:merged " + String(result.report.totals.merged)
|
|
3221
|
+
} : {
|
|
3222
|
+
ok: false,
|
|
3223
|
+
text: result.message
|
|
3224
|
+
};
|
|
3225
|
+
}
|
|
3226
|
+
const text = await handleReadOnlyMergeCommand(command, deps.io, deps.home);
|
|
3227
|
+
if (text !== null) return {
|
|
3228
|
+
ok: true,
|
|
3229
|
+
text
|
|
3230
|
+
};
|
|
3231
|
+
return readOnly.execute(command);
|
|
3232
|
+
} };
|
|
3233
|
+
}
|
|
3234
|
+
//#endregion
|
|
3235
|
+
//#region lib/types/params.js
|
|
3236
|
+
const MERGE_PARAM_DEFAULTS = {
|
|
3237
|
+
mergeApproval: false,
|
|
3238
|
+
mergeSkippedTtlDays: 0,
|
|
3239
|
+
mergeRunTimeoutMinutes: 0,
|
|
3240
|
+
mergePairTimeoutMinutes: 15,
|
|
3241
|
+
mergeModelsRoot: ""
|
|
3242
|
+
};
|
|
3243
|
+
const KEYS = new Set(Object.keys(MERGE_PARAM_DEFAULTS));
|
|
3244
|
+
function materialize(value) {
|
|
3245
|
+
if (typeof value === "object" && value !== null && typeof value.get === "function") return value.get();
|
|
3246
|
+
return value;
|
|
3247
|
+
}
|
|
3248
|
+
function nonNegative(name, value) {
|
|
3249
|
+
const resolved = materialize(value);
|
|
3250
|
+
if (typeof resolved !== "number" || !Number.isInteger(resolved) || resolved < 0) throw new Error(name + " must be a non-negative integer");
|
|
3251
|
+
return resolved;
|
|
3252
|
+
}
|
|
3253
|
+
function resolveMergeParams(input = {}) {
|
|
3254
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) throw new Error("merge params must be an object");
|
|
3255
|
+
for (const key of Object.keys(input)) if (!KEYS.has(key)) throw new Error("unknown merge parameter: " + key);
|
|
3256
|
+
const record = input;
|
|
3257
|
+
const approval = record.mergeApproval === void 0 ? MERGE_PARAM_DEFAULTS.mergeApproval : materialize(record.mergeApproval);
|
|
3258
|
+
if (typeof approval !== "boolean") throw new Error("mergeApproval must be a boolean");
|
|
3259
|
+
return {
|
|
3260
|
+
mergeApproval: approval,
|
|
3261
|
+
mergeSkippedTtlDays: record.mergeSkippedTtlDays === void 0 ? MERGE_PARAM_DEFAULTS.mergeSkippedTtlDays : nonNegative("mergeSkippedTtlDays", record.mergeSkippedTtlDays),
|
|
3262
|
+
mergeRunTimeoutMinutes: record.mergeRunTimeoutMinutes === void 0 ? MERGE_PARAM_DEFAULTS.mergeRunTimeoutMinutes : nonNegative("mergeRunTimeoutMinutes", record.mergeRunTimeoutMinutes),
|
|
3263
|
+
mergeModelsRoot: record.mergeModelsRoot === void 0 ? MERGE_PARAM_DEFAULTS.mergeModelsRoot : (() => {
|
|
3264
|
+
const value = materialize(record.mergeModelsRoot);
|
|
3265
|
+
if (typeof value !== "string") throw new Error("mergeModelsRoot must be a string");
|
|
3266
|
+
return value;
|
|
3267
|
+
})(),
|
|
3268
|
+
mergePairTimeoutMinutes: record.mergePairTimeoutMinutes === void 0 ? MERGE_PARAM_DEFAULTS.mergePairTimeoutMinutes : (() => {
|
|
3269
|
+
const value = nonNegative("mergePairTimeoutMinutes", record.mergePairTimeoutMinutes);
|
|
3270
|
+
if (value === 0) throw new Error("mergePairTimeoutMinutes must be > 0");
|
|
3271
|
+
return value;
|
|
3272
|
+
})()
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
3275
|
+
//#endregion
|
|
3276
|
+
//#region lib/types/adapters/channel.js
|
|
3277
|
+
function convert(outcome) {
|
|
3278
|
+
if (outcome.kind !== "ok") return outcome;
|
|
3279
|
+
return {
|
|
3280
|
+
kind: "ok",
|
|
3281
|
+
childId: String(outcome.childId),
|
|
3282
|
+
turn: {
|
|
3283
|
+
text: outcome.turn.text,
|
|
3284
|
+
stopReason: outcome.turn.stopReason
|
|
3285
|
+
}
|
|
3286
|
+
};
|
|
3287
|
+
}
|
|
3288
|
+
function subagentChannelAdapter(channel) {
|
|
3289
|
+
return {
|
|
3290
|
+
async open(spec) {
|
|
3291
|
+
if (channel.open === void 0) return {
|
|
3292
|
+
kind: "unavailable",
|
|
3293
|
+
code: "E-323",
|
|
3294
|
+
reason: "subagent channel has no open()"
|
|
3295
|
+
};
|
|
3296
|
+
return convert(await channel.open(spec));
|
|
3297
|
+
},
|
|
3298
|
+
async continueTurn(childId, promptText, options) {
|
|
3299
|
+
if (channel.continueTurn === void 0) return {
|
|
3300
|
+
kind: "unavailable",
|
|
3301
|
+
code: "E-323",
|
|
3302
|
+
reason: "subagent channel has no continueTurn()"
|
|
3303
|
+
};
|
|
3304
|
+
return convert(await channel.continueTurn(childId, promptText, options));
|
|
3305
|
+
},
|
|
3306
|
+
async dispose(childId) {
|
|
3307
|
+
if (channel.dispose === void 0) return;
|
|
3308
|
+
await channel.dispose(childId);
|
|
3309
|
+
}
|
|
3310
|
+
};
|
|
3311
|
+
}
|
|
3312
|
+
//#endregion
|
|
3313
|
+
//#region lib/types/stage-tool-registry.js
|
|
3314
|
+
var StageToolRegistry = class {
|
|
3315
|
+
bindings = /* @__PURE__ */ new Map();
|
|
3316
|
+
bind(childId, context) {
|
|
3317
|
+
this.bindings.set(childId, context);
|
|
3318
|
+
}
|
|
3319
|
+
unbind(childId) {
|
|
3320
|
+
this.bindings.delete(childId);
|
|
3321
|
+
}
|
|
3322
|
+
clear() {
|
|
3323
|
+
this.bindings.clear();
|
|
3324
|
+
}
|
|
3325
|
+
resolve(exec) {
|
|
3326
|
+
const childId = childIdOf(exec);
|
|
3327
|
+
return childId === void 0 ? void 0 : this.bindings.get(childId);
|
|
3328
|
+
}
|
|
3329
|
+
};
|
|
3330
|
+
function childIdOf(exec) {
|
|
3331
|
+
if (typeof exec !== "object" || exec === null) return void 0;
|
|
3332
|
+
const agent = exec.agent;
|
|
3333
|
+
if (typeof agent !== "object" || agent === null) return void 0;
|
|
3334
|
+
const session = agent.session;
|
|
3335
|
+
if (typeof session === "object" && session !== null) {
|
|
3336
|
+
const id = session.id;
|
|
3337
|
+
if (typeof id === "string") return id;
|
|
3338
|
+
}
|
|
3339
|
+
const sessionId = agent.sessionId;
|
|
3340
|
+
return typeof sessionId === "string" ? sessionId : void 0;
|
|
3341
|
+
}
|
|
3342
|
+
//#endregion
|
|
3343
|
+
//#region lib/types/staging-files.js
|
|
3344
|
+
/**
|
|
3345
|
+
* Staged file operations used by the authoring tool.
|
|
3346
|
+
*
|
|
3347
|
+
* The child never receives a general filesystem write. Every path is resolved under
|
|
3348
|
+
* <run>/sources/<source>/ or <run>/merged/<target>/ and must stay there; the staging
|
|
3349
|
+
* manifest supplies the target name. Reads are three-state, writes are atomic at the
|
|
3350
|
+
* file level, and copy only goes sources -> merged.
|
|
3351
|
+
*/
|
|
3352
|
+
function safeRelative(path) {
|
|
3353
|
+
if (path === "" || path.startsWith("/") || path.startsWith("\\") || path.includes("\0")) return false;
|
|
3354
|
+
return path.replace(/\\/g, "/").split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
3355
|
+
}
|
|
3356
|
+
function areaRoot(home, runId, target, area, source) {
|
|
3357
|
+
const root = stagingRoot(home, runId);
|
|
3358
|
+
if (area === "merged") return join(root, "merged", target);
|
|
3359
|
+
if (source === void 0 || source === "") return null;
|
|
3360
|
+
return join(root, "sources", source);
|
|
3361
|
+
}
|
|
3362
|
+
async function writeStagedFile(io, home, runId, path, content, source) {
|
|
3363
|
+
if (!safeRelative(path)) return {
|
|
3364
|
+
ok: false,
|
|
3365
|
+
message: "unsafe staged path: " + path
|
|
3366
|
+
};
|
|
3367
|
+
const staging = await readStaging(io, home, runId);
|
|
3368
|
+
if (!isPresent(staging)) return {
|
|
3369
|
+
ok: false,
|
|
3370
|
+
message: "staging is not present for run " + runId
|
|
3371
|
+
};
|
|
3372
|
+
const root = areaRoot(home, runId, staging.value.manifest.target, "merged", source);
|
|
3373
|
+
if (root === null) return {
|
|
3374
|
+
ok: false,
|
|
3375
|
+
message: "staged write requires merged area"
|
|
3376
|
+
};
|
|
3377
|
+
const target = join(root, ...path.split("/"));
|
|
3378
|
+
try {
|
|
3379
|
+
await io.writeText(target, content);
|
|
3380
|
+
return {
|
|
3381
|
+
ok: true,
|
|
3382
|
+
path
|
|
3383
|
+
};
|
|
3384
|
+
} catch (error) {
|
|
3385
|
+
return {
|
|
3386
|
+
ok: false,
|
|
3387
|
+
message: "staged write failed: " + (error instanceof Error ? error.message : String(error))
|
|
3388
|
+
};
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
async function readStagedFile(io, home, runId, path, area, source) {
|
|
3392
|
+
if (!safeRelative(path)) return probeUnknown("unsafe staged path: " + path);
|
|
3393
|
+
const staging = await readStaging(io, home, runId);
|
|
3394
|
+
if (!isPresent(staging)) return staging.kind === "absent" ? { kind: "absent" } : probeUnknown(staging.reason);
|
|
3395
|
+
const root = areaRoot(home, runId, staging.value.manifest.target, area, source);
|
|
3396
|
+
if (root === null) return probeUnknown("missing source name for sources area");
|
|
3397
|
+
try {
|
|
3398
|
+
const value = await io.readText(join(root, ...path.split("/")));
|
|
3399
|
+
return value === null ? { kind: "absent" } : {
|
|
3400
|
+
kind: "present",
|
|
3401
|
+
value
|
|
3402
|
+
};
|
|
3403
|
+
} catch (error) {
|
|
3404
|
+
return probeUnknown("staged read failed: " + (error instanceof Error ? error.message : String(error)));
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
async function listStagedFiles(io, home, runId, area, path = "", source) {
|
|
3408
|
+
if (path !== "" && !safeRelative(path)) return probeUnknown("unsafe staged path: " + path);
|
|
3409
|
+
const staging = await readStaging(io, home, runId);
|
|
3410
|
+
if (!isPresent(staging)) return staging.kind === "absent" ? { kind: "absent" } : probeUnknown(staging.reason);
|
|
3411
|
+
const root = areaRoot(home, runId, staging.value.manifest.target, area, source);
|
|
3412
|
+
if (root === null) return probeUnknown("missing source name for sources area");
|
|
3413
|
+
try {
|
|
3414
|
+
return {
|
|
3415
|
+
kind: "present",
|
|
3416
|
+
value: [...await io.list(join(root, ...path.split("/").filter(Boolean)))].sort()
|
|
3417
|
+
};
|
|
3418
|
+
} catch (error) {
|
|
3419
|
+
return probeUnknown("staged list failed: " + (error instanceof Error ? error.message : String(error)));
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
/** Copy one source file into the merged target; the only cross-area operation allowed. */
|
|
3423
|
+
async function copyStagedFile(io, home, runId, source, from, to) {
|
|
3424
|
+
if (!safeRelative(from) || !safeRelative(to)) return {
|
|
3425
|
+
ok: false,
|
|
3426
|
+
message: "unsafe staged copy path"
|
|
3427
|
+
};
|
|
3428
|
+
const sourceFile = await readStagedFile(io, home, runId, from, "sources", source);
|
|
3429
|
+
if (!isPresent(sourceFile)) return {
|
|
3430
|
+
ok: false,
|
|
3431
|
+
message: "source file is not readable: " + from
|
|
3432
|
+
};
|
|
3433
|
+
return await writeStagedFile(io, home, runId, to, sourceFile.value);
|
|
3434
|
+
}
|
|
3435
|
+
//#endregion
|
|
3436
|
+
//#region lib/types/stage-tool.js
|
|
3437
|
+
async function applyStageTool(io, home, ctx, op) {
|
|
3438
|
+
if (ctx.childId !== ctx.expectedChildId) return {
|
|
3439
|
+
ok: false,
|
|
3440
|
+
code: "not-merge-child",
|
|
3441
|
+
message: "merge_stage is only available to the bound merge child"
|
|
3442
|
+
};
|
|
3443
|
+
if (op.op === "write" || op.op === "copy") {
|
|
3444
|
+
if (ctx.phase !== "author") return {
|
|
3445
|
+
ok: false,
|
|
3446
|
+
code: "phase",
|
|
3447
|
+
message: "merge_stage write/copy is only allowed in the author phase"
|
|
3448
|
+
};
|
|
3449
|
+
}
|
|
3450
|
+
if (op.op === "list") return probeResult(await listStagedFiles(io, home, ctx.runId, op.area, op.path ?? "", op.source), "list");
|
|
3451
|
+
if (op.op === "read") return probeResult(await readStagedFile(io, home, ctx.runId, op.path, op.area, op.source), "read");
|
|
3452
|
+
if (op.op === "write") {
|
|
3453
|
+
const result = await writeStagedFile(io, home, ctx.runId, op.path, op.content);
|
|
3454
|
+
return result.ok ? {
|
|
3455
|
+
ok: true,
|
|
3456
|
+
value: { path: result.path }
|
|
3457
|
+
} : {
|
|
3458
|
+
ok: false,
|
|
3459
|
+
code: "io",
|
|
3460
|
+
message: result.message
|
|
3461
|
+
};
|
|
3462
|
+
}
|
|
3463
|
+
const result = await copyStagedFile(io, home, ctx.runId, op.source, op.from, op.to);
|
|
3464
|
+
return result.ok ? {
|
|
3465
|
+
ok: true,
|
|
3466
|
+
value: { path: result.path }
|
|
3467
|
+
} : {
|
|
3468
|
+
ok: false,
|
|
3469
|
+
code: "io",
|
|
3470
|
+
message: result.message
|
|
3471
|
+
};
|
|
3472
|
+
}
|
|
3473
|
+
function probeResult(probe, what) {
|
|
3474
|
+
if (probe.kind === "present") return {
|
|
3475
|
+
ok: true,
|
|
3476
|
+
value: probe.value
|
|
3477
|
+
};
|
|
3478
|
+
if (probe.kind === "absent") return {
|
|
3479
|
+
ok: false,
|
|
3480
|
+
code: "not-found",
|
|
3481
|
+
message: what + ": not found"
|
|
3482
|
+
};
|
|
3483
|
+
return {
|
|
3484
|
+
ok: false,
|
|
3485
|
+
code: "io",
|
|
3486
|
+
message: what + ": " + probe.reason
|
|
3487
|
+
};
|
|
3488
|
+
}
|
|
3489
|
+
//#endregion
|
|
3490
|
+
//#region lib/types/merge-tool.js
|
|
3491
|
+
const MERGE_STAGE_TOOL_NAME = "merge_stage";
|
|
3492
|
+
function shapeError(message) {
|
|
3493
|
+
return {
|
|
3494
|
+
ok: false,
|
|
3495
|
+
code: "bad-path",
|
|
3496
|
+
message
|
|
3497
|
+
};
|
|
3498
|
+
}
|
|
3499
|
+
function operation(args) {
|
|
3500
|
+
const op = args.op;
|
|
3501
|
+
if (op === "list") {
|
|
3502
|
+
const area = args.area;
|
|
3503
|
+
if (area !== "sources" && area !== "merged") return shapeError("list.area must be sources or merged");
|
|
3504
|
+
return {
|
|
3505
|
+
op,
|
|
3506
|
+
area,
|
|
3507
|
+
...typeof args.path === "string" ? { path: args.path } : {},
|
|
3508
|
+
...typeof args.source === "string" ? { source: args.source } : {}
|
|
3509
|
+
};
|
|
3510
|
+
}
|
|
3511
|
+
if (op === "read") {
|
|
3512
|
+
const area = args.area;
|
|
3513
|
+
if (area !== "sources" && area !== "merged") return shapeError("read.area must be sources or merged");
|
|
3514
|
+
if (typeof args.path !== "string" || args.path === "") return shapeError("read.path is required");
|
|
3515
|
+
return {
|
|
3516
|
+
op,
|
|
3517
|
+
area,
|
|
3518
|
+
path: args.path,
|
|
3519
|
+
...typeof args.source === "string" ? { source: args.source } : {}
|
|
3520
|
+
};
|
|
3521
|
+
}
|
|
3522
|
+
if (op === "write") {
|
|
3523
|
+
if (typeof args.path !== "string" || args.path === "") return shapeError("write.path is required");
|
|
3524
|
+
if (typeof args.content !== "string") return shapeError("write.content must be a string");
|
|
3525
|
+
return {
|
|
3526
|
+
op,
|
|
3527
|
+
path: args.path,
|
|
3528
|
+
content: args.content
|
|
3529
|
+
};
|
|
3530
|
+
}
|
|
3531
|
+
if (op === "copy") {
|
|
3532
|
+
if (typeof args.source !== "string" || typeof args.from !== "string" || typeof args.to !== "string") return shapeError("copy requires source/from/to");
|
|
3533
|
+
return {
|
|
3534
|
+
op,
|
|
3535
|
+
source: args.source,
|
|
3536
|
+
from: args.from,
|
|
3537
|
+
to: args.to
|
|
3538
|
+
};
|
|
3539
|
+
}
|
|
3540
|
+
return shapeError("op must be list/read/write/copy");
|
|
3541
|
+
}
|
|
3542
|
+
function createMergeStageTool(io, home, context) {
|
|
3543
|
+
return {
|
|
3544
|
+
name: MERGE_STAGE_TOOL_NAME,
|
|
3545
|
+
description: "Read source copies and write the merged skill into the staging tree. list/read use area=sources|merged plus source (for sources); write/copy paths are relative to merged/<target> (for example path=SKILL.md, from=SKILL.md, to=references/x.md). Write/copy are only allowed in the author phase.",
|
|
3546
|
+
parameters: {
|
|
3547
|
+
type: "object",
|
|
3548
|
+
properties: {
|
|
3549
|
+
op: {
|
|
3550
|
+
type: "string",
|
|
3551
|
+
enum: [
|
|
3552
|
+
"list",
|
|
3553
|
+
"read",
|
|
3554
|
+
"write",
|
|
3555
|
+
"copy"
|
|
3556
|
+
]
|
|
3557
|
+
},
|
|
3558
|
+
area: {
|
|
3559
|
+
type: "string",
|
|
3560
|
+
enum: ["sources", "merged"]
|
|
3561
|
+
},
|
|
3562
|
+
path: { type: "string" },
|
|
3563
|
+
source: { type: "string" },
|
|
3564
|
+
content: { type: "string" },
|
|
3565
|
+
from: { type: "string" },
|
|
3566
|
+
to: { type: "string" }
|
|
3567
|
+
},
|
|
3568
|
+
required: ["op"],
|
|
3569
|
+
additionalProperties: false
|
|
3570
|
+
},
|
|
3571
|
+
output: {
|
|
3572
|
+
schema: { type: "string" },
|
|
3573
|
+
render: (_args, value) => [{
|
|
3574
|
+
type: "text",
|
|
3575
|
+
text: value
|
|
3576
|
+
}]
|
|
3577
|
+
},
|
|
3578
|
+
async execute(args, exec) {
|
|
3579
|
+
const op = operation(args);
|
|
3580
|
+
if ("ok" in op) return JSON.stringify(op);
|
|
3581
|
+
const ctx = context(exec);
|
|
3582
|
+
if (ctx === void 0) return JSON.stringify({
|
|
3583
|
+
ok: false,
|
|
3584
|
+
code: "not-merge-child",
|
|
3585
|
+
message: "merge_stage is only available to an active merge child"
|
|
3586
|
+
});
|
|
3587
|
+
return JSON.stringify(await applyStageTool(io, home, ctx, op));
|
|
3588
|
+
}
|
|
3589
|
+
};
|
|
3590
|
+
}
|
|
3591
|
+
//#endregion
|
|
3592
|
+
//#region lib/types/supersede.js
|
|
3593
|
+
function mergeRunIdsToSupersede(runs) {
|
|
3594
|
+
return runs.filter((run) => run.kind === "merge" && run.supersededAt === void 0).map((run) => run.id);
|
|
3595
|
+
}
|
|
3596
|
+
async function supersedeMergeRuns(registry) {
|
|
3597
|
+
const shared = registry.supersedeKind;
|
|
3598
|
+
if (typeof shared === "function") return await shared.call(registry, "merge");
|
|
3599
|
+
const ids = mergeRunIdsToSupersede(registry.runs());
|
|
3600
|
+
if (ids.length === 0) return 0;
|
|
3601
|
+
await registry.supersede(ids);
|
|
3602
|
+
return ids.length;
|
|
3603
|
+
}
|
|
3604
|
+
//#endregion
|
|
3605
|
+
//#region lib/types/pair-command.js
|
|
3606
|
+
async function runPairCommand(options) {
|
|
3607
|
+
return await runScanCommand({
|
|
3608
|
+
io: options.io,
|
|
3609
|
+
home: options.home,
|
|
3610
|
+
registry: options.registry,
|
|
3611
|
+
approval: options.approval,
|
|
3612
|
+
platformWrite: options.platformWrite,
|
|
3613
|
+
now: options.now,
|
|
3614
|
+
ports: {
|
|
3615
|
+
...options.ports,
|
|
3616
|
+
screening: () => Promise.resolve([{
|
|
3617
|
+
a: options.a,
|
|
3618
|
+
b: options.b
|
|
3619
|
+
}])
|
|
3620
|
+
},
|
|
3621
|
+
...options.timeoutMinutes === void 0 ? {} : { timeoutMinutes: options.timeoutMinutes }
|
|
3622
|
+
});
|
|
3623
|
+
}
|
|
3624
|
+
//#endregion
|
|
3625
|
+
//#region lib/types/index.js
|
|
3626
|
+
/** E3 knobs are volatile (user layer); the E2 pair timeout is deployment-only. */
|
|
3627
|
+
const Config = z.object({
|
|
3628
|
+
mergeApproval: z.boolean().default(false).volatile(),
|
|
3629
|
+
mergeSkippedTtlDays: z.number().min(0).default(0).volatile(),
|
|
3630
|
+
mergeRunTimeoutMinutes: z.number().min(0).default(0).volatile(),
|
|
3631
|
+
mergePairTimeoutMinutes: z.number().min(1).default(15),
|
|
3632
|
+
mergeModelsRoot: z.string().default("")
|
|
3633
|
+
});
|
|
3634
|
+
function engineConfigFromEnv(configuredRoot) {
|
|
3635
|
+
const root = configuredRoot !== void 0 && configuredRoot !== "" ? configuredRoot : process.env.SKILL_MERGE_MODELS_ROOT;
|
|
3636
|
+
if (root === void 0 || root === "") return void 0;
|
|
3637
|
+
return {
|
|
3638
|
+
models_dir: "",
|
|
3639
|
+
offline: true,
|
|
3640
|
+
auto_provision: false,
|
|
3641
|
+
embed_backend: "local",
|
|
3642
|
+
embedder_model: join(root, "potion-multilingual-128M-int8"),
|
|
3643
|
+
embedder_model_last: join(root, "potion-multilingual-128M-pca64-int8")
|
|
3644
|
+
};
|
|
3645
|
+
}
|
|
3646
|
+
/** Suppress the platform auto form; the registry renders the merge card instead. */
|
|
3647
|
+
function configureMergePresentation(ctx) {
|
|
3648
|
+
ctx.inject(["settings"], (scope) => {
|
|
3649
|
+
const settings = scope.settings;
|
|
3650
|
+
if (typeof settings?.configure !== "function") return;
|
|
3651
|
+
const disposer = settings.configure({ auto: false }, ctx.fiber);
|
|
3652
|
+
if (typeof disposer === "function") scope.effect(() => disposer, "evolution-merge: settings presentation");
|
|
3653
|
+
});
|
|
3654
|
+
}
|
|
3655
|
+
const inject = [];
|
|
3656
|
+
function apply(ctx, rawConfig = {}) {
|
|
3657
|
+
configureMergePresentation(ctx);
|
|
3658
|
+
const params = resolveMergeParams(rawConfig);
|
|
3659
|
+
let mergeRun;
|
|
3660
|
+
const mergeService = { async run(rawInput, invocation) {
|
|
3661
|
+
return mergeRun === void 0 ? {
|
|
3662
|
+
ok: false,
|
|
3663
|
+
text: "merge 未就绪:evolutionIo 尚未挂载"
|
|
3664
|
+
} : mergeRun(rawInput, invocation);
|
|
3665
|
+
} };
|
|
3666
|
+
const mergeContext = ctx;
|
|
3667
|
+
if (typeof mergeContext.provide === "function") mergeContext.provide("evolutionMerge", mergeService);
|
|
3668
|
+
let source;
|
|
3669
|
+
registerHostRoutes(ctx, (audit) => makeMergeRoutes({ getSource: () => source }, audit));
|
|
3670
|
+
ctx.inject(["commands", "evolutionIo"], (scope) => {
|
|
3671
|
+
const registry = scope.get("evolutionIo");
|
|
3672
|
+
if (registry === void 0) return;
|
|
3673
|
+
const io = evolutionIoAdapter(() => registry.provider());
|
|
3674
|
+
const home = evolutionHome();
|
|
3675
|
+
const runs = newRunRegistry({
|
|
3676
|
+
io,
|
|
3677
|
+
home,
|
|
3678
|
+
warn: (message) => {
|
|
3679
|
+
ctx.logger.warn(message);
|
|
3680
|
+
}
|
|
3681
|
+
});
|
|
3682
|
+
runs.load();
|
|
3683
|
+
ctx.effect(() => {
|
|
3684
|
+
const host = ctx;
|
|
3685
|
+
if (typeof host.on !== "function") return () => {};
|
|
3686
|
+
return host.on("evolution/skill-mutated", (event) => {
|
|
3687
|
+
if (event.action === "restore") supersedeMergeRuns(runs);
|
|
3688
|
+
});
|
|
3689
|
+
}, "evolution-merge: restore supersede");
|
|
3690
|
+
const policySnapshot = ctx.get("evolutionPolicy");
|
|
3691
|
+
const skills = newSkillLibrary({
|
|
3692
|
+
io,
|
|
3693
|
+
ctx,
|
|
3694
|
+
authority: authorityFrom({ origin: "subagent" }),
|
|
3695
|
+
limits: {
|
|
3696
|
+
...DEFAULT_SKILL_LIMITS,
|
|
3697
|
+
...policyStageLimits(policySnapshot?.get?.())
|
|
3698
|
+
}
|
|
3699
|
+
});
|
|
3700
|
+
const channel = subagentChannelAdapter(createSubagentChannel(ctx));
|
|
3701
|
+
const stage = new StageToolRegistry();
|
|
3702
|
+
const engineConfig = engineConfigFromEnv(params.mergeModelsRoot);
|
|
3703
|
+
const llm = ctx.get("llm");
|
|
3704
|
+
const defaultModel = ctx.get("agentDefaultModel");
|
|
3705
|
+
const runner = llm === void 0 || defaultModel === void 0 ? void 0 : createEngineRunner(llm, () => defaultModel.currentSelection());
|
|
3706
|
+
const engineScreening = createEngineScreening({
|
|
3707
|
+
io,
|
|
3708
|
+
home,
|
|
3709
|
+
engine: { screen },
|
|
3710
|
+
select: selectCandidates,
|
|
3711
|
+
...engineConfig === void 0 ? {} : { cfg: engineConfig },
|
|
3712
|
+
...runner === void 0 ? {} : { runner },
|
|
3713
|
+
async onSelection(selection) {
|
|
3714
|
+
const loaded = await loadMergeState(io, home);
|
|
3715
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
3716
|
+
for (const skipped of selection.skipped) {
|
|
3717
|
+
if (loaded.state.rows.some((row) => row.pairKey === skipped.pairKey)) continue;
|
|
3718
|
+
const next = transition(void 0, {
|
|
3719
|
+
type: "gate-keep",
|
|
3720
|
+
a: skipped.a,
|
|
3721
|
+
b: skipped.b,
|
|
3722
|
+
...skipped.reason === void 0 ? {} : { reason: skipped.reason },
|
|
3723
|
+
at
|
|
3724
|
+
});
|
|
3725
|
+
if (next.ok && next.row !== null) await upsertRow(io, home, () => Date.now(), next.row);
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
});
|
|
3729
|
+
source = {
|
|
3730
|
+
rows() {
|
|
3731
|
+
return loadMergeState(io, home).then((loaded) => loaded.state.rows);
|
|
3732
|
+
},
|
|
3733
|
+
run(id) {
|
|
3734
|
+
return Promise.resolve(runs.runs().find((record) => record.id === id));
|
|
3735
|
+
},
|
|
3736
|
+
runs() {
|
|
3737
|
+
return Promise.resolve(runs.runs());
|
|
3738
|
+
},
|
|
3739
|
+
approval() {
|
|
3740
|
+
return params.mergeApproval;
|
|
3741
|
+
},
|
|
3742
|
+
commands() {
|
|
3743
|
+
return [
|
|
3744
|
+
"merge scan",
|
|
3745
|
+
"merge doctor",
|
|
3746
|
+
"merge status",
|
|
3747
|
+
"merge pending",
|
|
3748
|
+
"merge skipped",
|
|
3749
|
+
"merge report",
|
|
3750
|
+
"merge runs",
|
|
3751
|
+
"merge cancel"
|
|
3752
|
+
];
|
|
3753
|
+
}
|
|
3754
|
+
};
|
|
3755
|
+
const commands = scope.get("commands");
|
|
3756
|
+
if (commands === void 0) return;
|
|
3757
|
+
const runtime = createMergeRuntime({
|
|
3758
|
+
io,
|
|
3759
|
+
home,
|
|
3760
|
+
registry: runs,
|
|
3761
|
+
skills,
|
|
3762
|
+
now: () => Date.now(),
|
|
3763
|
+
params,
|
|
3764
|
+
platformWrite: true,
|
|
3765
|
+
channel,
|
|
3766
|
+
parent: void 0,
|
|
3767
|
+
allow: ["merge_stage"],
|
|
3768
|
+
timeoutMinutes: params.mergeRunTimeoutMinutes,
|
|
3769
|
+
async cards(signal) {
|
|
3770
|
+
const listed = await skills.list({ withContent: true });
|
|
3771
|
+
if (signal.aborted) return [];
|
|
3772
|
+
return Promise.all(listed.map(async (item) => {
|
|
3773
|
+
const probe = await skills.listSupportFiles(item.name);
|
|
3774
|
+
const paths = probe.kind === "present" ? probe.value : [];
|
|
3775
|
+
const files = await Promise.all(paths.map(async (path) => ({
|
|
3776
|
+
path,
|
|
3777
|
+
content: await skills.readSupportFile(item.name, path) ?? ""
|
|
3778
|
+
})));
|
|
3779
|
+
return {
|
|
3780
|
+
name: item.name,
|
|
3781
|
+
description: item.description,
|
|
3782
|
+
body: item.content ?? "",
|
|
3783
|
+
files,
|
|
3784
|
+
protectedBy: item.protectedBy,
|
|
3785
|
+
protectionUnknown: item.protectionUnknown
|
|
3786
|
+
};
|
|
3787
|
+
}));
|
|
3788
|
+
},
|
|
3789
|
+
screeningForRun: engineScreening,
|
|
3790
|
+
stage: {
|
|
3791
|
+
bind: (context) => {
|
|
3792
|
+
stage.bind(context.childId, context);
|
|
3793
|
+
},
|
|
3794
|
+
unbind: (childId) => {
|
|
3795
|
+
stage.unbind(childId);
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
});
|
|
3799
|
+
mergeRun = (rawInput, invocation) => runMergeCommand(runtime, rawInput, invocation);
|
|
3800
|
+
const command = createMergeCommand(runtime);
|
|
3801
|
+
scope.effect(() => commands.register(command), "evolution-merge: command");
|
|
3802
|
+
ctx.inject(["tools"], (toolScope) => {
|
|
3803
|
+
const tools = toolScope.get("tools");
|
|
3804
|
+
if (tools === void 0) return;
|
|
3805
|
+
const tool = createMergeStageTool(io, home, (exec) => stage.resolve(exec));
|
|
3806
|
+
toolScope.effect(() => tools.register(tool), "evolution-merge: merge_stage");
|
|
3807
|
+
});
|
|
3808
|
+
scope.effect(() => () => {
|
|
3809
|
+
runs.cancelAll();
|
|
3810
|
+
}, "evolution-merge: runs");
|
|
3811
|
+
});
|
|
3812
|
+
}
|
|
3813
|
+
//#endregion
|
|
3814
|
+
export { Config, MERGE_DOCTOR_SCHEMA_VERSION, MERGE_MAX_ATTEMPTS, MERGE_PAIR_STATES, MERGE_PARAM_DEFAULTS, MERGE_PROMPT_VERSION, MERGE_QUEUES, MERGE_ROUTES, MERGE_STAGE_TOOL_NAME, MERGE_STATE_VERSION, STAGING_SCHEMA_VERSION, STANDBY_PROMPT, StageToolRegistry, apply, applyStageTool, approvePending, assembleStagedCommit, buildCommitPlan, buildMergeView, buildRound1Prompt, buildRound2Prompt, buildStandbyPrompt, canRetry, commitReceiptPath, commitStagedRun, committedPairs, configureMergePresentation, copyStagedFile, countRows, createChannelScanPorts, createEngineRunner, createEngineScreening, createMergeCommand, createMergeRuntime, createMergeStageTool, createNewChildRound2, createReadOnlyMergeService, createStaging, dropRow, dropStaging, durableReceiptDir, durableReceiptPath, emptyMergeState, engineConfigFromEnv, ensureStaging, formatQueue, formatStatus, handleReadOnlyMergeCommand, hasBlockingFinding, inject, isSkippedPair, listStagedFiles, loadMergeState, makeMergeRoutes, mergeCommandHelp, mergeDoctor, mergeReportDigestPath, mergeReportPath, mergeRunIdsToSupersede, mergeStatePath, mergeUsage, moveRow, mutateMergeState, pageQueue, pairKey, parseDecision, parseMergeCommand, queueOf, readCommitReceipt, readMergeReport, readStagedFile, readStagedTree, readStaging, recordRound2Outcome, rejectPending, resolveMergeParams, resolveTargetName, rewriteFrontmatterName, rowIdOf, rowsOf, runApproveCommand, runAuthoring, runMergeCommand, runNewChildAuthoring, runPairCommand, runResumeCommand, runRound1OverChannel, runScan, runScanCommand, setStagingTarget, skillLibraryPort, skillLibraryUndoPort, skippedPairKeys, snapshotCards, stageKeyOf, stageOf, stagingRoot, subagentChannelAdapter, supersedeMergeRuns, toRowView, toRunFact, transition, undoMergeReceipt, upsertRow, validateStagedTree, withMergeRun, writeCommitReceipt, writeMergeReport, writeStagedFile };
|