@lmzhen/dsh-evolution-merge 0.29.0 → 0.30.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.
Files changed (97) hide show
  1. package/lib/index.js +1125 -568
  2. package/lib/types/adapters/channel.js +29 -0
  3. package/lib/types/adapters/engine-screen.d.ts +1 -1
  4. package/lib/types/adapters/engine-screen.js +45 -0
  5. package/lib/types/adapters/library.js +14 -0
  6. package/lib/types/adapters/model-runner.js +34 -0
  7. package/lib/types/adapters/select.d.ts +3 -0
  8. package/lib/types/adapters/select.js +35 -0
  9. package/lib/types/adapters/undo.js +35 -0
  10. package/lib/types/approve-command.js +42 -0
  11. package/lib/types/authoring-flow.js +48 -0
  12. package/lib/types/channel-port.js +74 -0
  13. package/lib/types/commands.d.ts +9 -0
  14. package/lib/types/commands.js +79 -0
  15. package/lib/types/commit-assembly.js +48 -0
  16. package/lib/types/commit-execute.js +52 -0
  17. package/lib/types/commit-plan.js +56 -0
  18. package/lib/types/commit-receipt.d.ts +2 -0
  19. package/lib/types/commit-receipt.js +85 -0
  20. package/lib/types/contracts.d.ts +14 -5
  21. package/lib/types/contracts.js +57 -0
  22. package/lib/types/coordinator.d.ts +3 -0
  23. package/lib/types/coordinator.js +131 -0
  24. package/lib/types/decision.js +61 -0
  25. package/lib/types/doctor.js +50 -0
  26. package/lib/types/domain/claim.d.ts +10 -0
  27. package/lib/types/domain/claim.js +12 -0
  28. package/lib/types/domain/commit-journal-store.d.ts +6 -0
  29. package/lib/types/domain/commit-journal-store.js +22 -0
  30. package/lib/types/domain/commit-journal.d.ts +16 -0
  31. package/lib/types/domain/commit-journal.js +25 -0
  32. package/lib/types/domain/errors.d.ts +10 -0
  33. package/lib/types/domain/errors.js +7 -0
  34. package/lib/types/domain/failure-route.d.ts +25 -0
  35. package/lib/types/domain/failure-route.js +22 -0
  36. package/lib/types/domain/indexes.d.ts +21 -0
  37. package/lib/types/domain/indexes.js +15 -0
  38. package/lib/types/domain/ledger.d.ts +17 -0
  39. package/lib/types/domain/ledger.js +65 -0
  40. package/lib/types/domain/markdown.d.ts +2 -0
  41. package/lib/types/domain/markdown.js +57 -0
  42. package/lib/types/domain/migrate-v1.d.ts +41 -0
  43. package/lib/types/domain/migrate-v1.js +23 -0
  44. package/lib/types/domain/pair-index-store.d.ts +6 -0
  45. package/lib/types/domain/pair-index-store.js +25 -0
  46. package/lib/types/domain/pair-index.d.ts +9 -0
  47. package/lib/types/domain/pair-index.js +28 -0
  48. package/lib/types/domain/resolve-pair.d.ts +11 -0
  49. package/lib/types/domain/resolve-pair.js +17 -0
  50. package/lib/types/domain/row.d.ts +113 -0
  51. package/lib/types/domain/row.js +111 -0
  52. package/lib/types/domain/run-record.d.ts +45 -0
  53. package/lib/types/domain/run-record.js +24 -0
  54. package/lib/types/domain/state.d.ts +57 -0
  55. package/lib/types/domain/state.js +65 -0
  56. package/lib/types/gate-cache.js +36 -0
  57. package/lib/types/index.js +241 -0
  58. package/lib/types/ladder.js +162 -0
  59. package/lib/types/merge-command.js +43 -0
  60. package/lib/types/merge-routes.d.ts +14 -4
  61. package/lib/types/merge-routes.js +99 -0
  62. package/lib/types/merge-tool.js +69 -0
  63. package/lib/types/new-child-authoring.js +83 -0
  64. package/lib/types/pair-command.js +18 -0
  65. package/lib/types/params.js +49 -0
  66. package/lib/types/pending-actions.js +28 -0
  67. package/lib/types/prompts.js +60 -0
  68. package/lib/types/read-commands.js +32 -0
  69. package/lib/types/report.js +70 -0
  70. package/lib/types/resume-command.js +57 -0
  71. package/lib/types/round2-outcome.js +50 -0
  72. package/lib/types/routes.d.ts +5 -2
  73. package/lib/types/routes.js +75 -0
  74. package/lib/types/rules.js +55 -0
  75. package/lib/types/run-shell.d.ts +7 -0
  76. package/lib/types/run-shell.js +69 -0
  77. package/lib/types/runtime.d.ts +2 -0
  78. package/lib/types/runtime.js +269 -0
  79. package/lib/types/scan-command.d.ts +1 -0
  80. package/lib/types/scan-command.js +30 -0
  81. package/lib/types/scan-ports.js +93 -0
  82. package/lib/types/scan-run.d.ts +2 -0
  83. package/lib/types/scan-run.js +186 -0
  84. package/lib/types/skill-card.js +2 -0
  85. package/lib/types/skipped.js +24 -0
  86. package/lib/types/stage-tool-registry.js +32 -0
  87. package/lib/types/stage-tool.js +31 -0
  88. package/lib/types/staged-tree.js +75 -0
  89. package/lib/types/staging-files.js +86 -0
  90. package/lib/types/staging.d.ts +7 -0
  91. package/lib/types/staging.js +149 -0
  92. package/lib/types/state.js +103 -0
  93. package/lib/types/status.d.ts +2 -1
  94. package/lib/types/status.js +28 -0
  95. package/lib/types/supersede.js +14 -0
  96. package/lib/types/undo-merge.js +23 -0
  97. package/package.json +4 -4
package/lib/index.js CHANGED
@@ -1,9 +1,44 @@
1
1
  import { join } from "node:path";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import { screen, selectCandidates } from "@lmzhen/dsh-evolution-merge-engine";
3
+ import { pairKeyOf, screen, selectCandidates, toEngineCandidate } from "@lmzhen/dsh-evolution-merge-engine";
4
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
5
  import { HOST_ROUTES, json, methodNotAllowed, registerHostRoutes } from "@lmzhen/dsh-evolution-core/http-routes";
6
6
  import { createHash } from "node:crypto";
7
+ //#region lib/types/adapters/select.js
8
+ /** Host selector correction: engine selector does not route judge errors to candidates. */
9
+ function selectWithErrors(report) {
10
+ const base = selectCandidates(report);
11
+ const doc = report.reportDoc ?? report;
12
+ const rows = Array.isArray(doc.pairs_final) ? doc.pairs_final : [];
13
+ const errors = /* @__PURE__ */ new Set();
14
+ for (const row of rows) {
15
+ if (row.error !== true) continue;
16
+ const a = typeof row.a === "string" ? row.a : "";
17
+ const b = typeof row.b === "string" ? row.b : "";
18
+ if (a !== "" && b !== "") errors.add(pairKeyOf(a, b));
19
+ }
20
+ const candidates = base.candidates.filter((candidate) => !errors.has(pairKeyOf(candidate.a, candidate.b)));
21
+ const skipped = base.skipped.filter((item) => !errors.has(item.pairKey));
22
+ const reportOnly = base.reportOnly.filter((row) => {
23
+ const value = row;
24
+ if (typeof value.a !== "string" || typeof value.b !== "string") return true;
25
+ return !errors.has(pairKeyOf(value.a, value.b));
26
+ });
27
+ for (const row of rows) {
28
+ if (row.error !== true) continue;
29
+ const a = typeof row.a === "string" ? row.a : "";
30
+ const b = typeof row.b === "string" ? row.b : "";
31
+ if (a === "" || b === "") continue;
32
+ candidates.push(toEngineCandidate(row, typeof row.reason === "string" ? row.reason : "judge-error"));
33
+ }
34
+ return {
35
+ ...base,
36
+ candidates,
37
+ skipped,
38
+ reportOnly
39
+ };
40
+ }
41
+ //#endregion
7
42
  //#region lib/types/adapters/engine-screen.js
8
43
  /**
9
44
  * G3 screen adapter: cards -> corpus snapshot -> EnginePort.screen.
@@ -97,24 +132,27 @@ function createEngineRunner(llm, route) {
97
132
  * The data layer keeps four states; the user face collapses verify/fuse into one retry
98
133
  * queue and keeps the stage as a chip. The API queue must never expose the raw state.
99
134
  */
100
- const MERGE_STATE_VERSION = 1;
135
+ const MERGE_STATE_VERSION = 2;
101
136
  const MERGE_PAIR_STATES = [
102
137
  "verify",
103
138
  "pending",
139
+ "approved",
104
140
  "fuse",
105
141
  "skipped"
106
142
  ];
107
143
  const MERGE_QUEUES = [
144
+ "verify",
108
145
  "pending",
109
- "retry",
146
+ "fuse",
110
147
  "skipped"
111
148
  ];
112
149
  /** User-facing queue of one data state. verify/fuse share retry, not two tabs. */
113
150
  function queueOf(state) {
114
151
  switch (state) {
152
+ case "verify": return "verify";
115
153
  case "pending": return "pending";
116
- case "verify":
117
- case "fuse": return "retry";
154
+ case "approved":
155
+ case "fuse": return "fuse";
118
156
  case "skipped": return "skipped";
119
157
  }
120
158
  }
@@ -123,6 +161,7 @@ function stageOf(state) {
123
161
  switch (state) {
124
162
  case "verify": return "verify";
125
163
  case "pending": return "decide";
164
+ case "approved":
126
165
  case "fuse": return "author";
127
166
  case "skipped": return null;
128
167
  }
@@ -142,7 +181,7 @@ function rowIdOf(key) {
142
181
  }
143
182
  function emptyMergeState() {
144
183
  return {
145
- schemaVersion: 1,
184
+ schemaVersion: 2,
146
185
  rows: []
147
186
  };
148
187
  }
@@ -155,25 +194,43 @@ function canRetry(record) {
155
194
  return record.failure.retryable && record.failure.retryFrom !== "none";
156
195
  }
157
196
  //#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");
197
+ //#region lib/types/domain/migrate-v1.js
198
+ function migrateV1Row(row) {
199
+ const queue = row.state === "pending" ? "pending" : row.state === "skipped" ? "skipped" : row.state === "fuse" ? "fuse" : "verify";
200
+ return {
201
+ schemaVersion: 2,
202
+ id: row.id,
203
+ pairKey: row.pairKey,
204
+ a: row.a,
205
+ b: row.b,
206
+ state: row.state,
207
+ queue,
208
+ ...row.failure === void 0 ? {} : { failure: row.failure },
209
+ ...row.decision === void 0 ? {} : { decision: row.decision },
210
+ ...row.runId === void 0 ? {} : { runId: row.runId },
211
+ ...row.unitId === void 0 ? {} : { unitId: row.unitId },
212
+ ...row.origin === void 0 ? {} : { origin: row.origin },
213
+ ...row.attempts === void 0 ? {} : { attempts: row.attempts },
214
+ ...row.stagingRef === void 0 ? {} : { stagingRef: row.stagingRef },
215
+ ...row.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: row.stagingExpiresAt },
216
+ createdAt: row.createdAt,
217
+ updatedAt: row.updatedAt
218
+ };
169
219
  }
170
- function isState(value) {
171
- return typeof value === "string" && MERGE_PAIR_STATES.includes(value);
220
+ //#endregion
221
+ //#region lib/types/domain/ledger.js
222
+ /** Pure v2 ledger normalization/projection. No IO. */
223
+ function isRow(value) {
224
+ const row = value;
225
+ return typeof row.id === "string" && typeof row.pairKey === "string" && typeof row.a === "string" && typeof row.b === "string" && typeof row.state === "string" && typeof row.createdAt === "string" && typeof row.updatedAt === "string";
172
226
  }
173
- function parseState(raw) {
227
+ function parseLedger(raw) {
174
228
  if (raw === null) return {
175
229
  ok: true,
176
- rows: []
230
+ ledger: {
231
+ schemaVersion: 2,
232
+ rows: []
233
+ }
177
234
  };
178
235
  let parsed;
179
236
  try {
@@ -181,52 +238,76 @@ function parseState(raw) {
181
238
  } catch {
182
239
  return {
183
240
  ok: false,
184
- rows: []
241
+ ledger: {
242
+ schemaVersion: 2,
243
+ rows: []
244
+ },
245
+ note: "invalid json"
185
246
  };
186
247
  }
187
248
  const body = parsed;
188
249
  if (!Array.isArray(body.rows)) return {
189
250
  ok: false,
190
- rows: []
251
+ ledger: {
252
+ schemaVersion: 2,
253
+ rows: []
254
+ },
255
+ note: "rows missing"
191
256
  };
192
257
  const rows = [];
193
258
  let skipped = 0;
194
259
  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;
260
+ if (body.schemaVersion === 1) {
261
+ const legacy = value;
262
+ if (typeof legacy.id !== "string" || typeof legacy.pairKey !== "string" || typeof legacy.a !== "string" || typeof legacy.b !== "string" || typeof legacy.state !== "string" || typeof legacy.createdAt !== "string" || typeof legacy.updatedAt !== "string") {
263
+ skipped += 1;
264
+ continue;
265
+ }
266
+ const row = migrateV1Row(legacy);
267
+ rows.push(row);
198
268
  continue;
199
269
  }
200
- if (!isState(row.state) || typeof row.createdAt !== "string" || typeof row.updatedAt !== "string") {
270
+ if (!isRow(value)) {
201
271
  skipped += 1;
202
272
  continue;
203
273
  }
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);
274
+ rows.push(value);
221
275
  }
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";
276
+ const note = skipped === 0 ? void 0 : String(skipped) + " invalid row(s) skipped";
224
277
  return {
225
278
  ok: true,
226
- rows,
279
+ ledger: {
280
+ schemaVersion: 2,
281
+ rows
282
+ },
227
283
  ...note === void 0 ? {} : { note }
228
284
  };
229
285
  }
286
+ //#endregion
287
+ //#region lib/types/state.js
288
+ /**
289
+ * The one merge-state ledger: <home>/merge/merge-state.json.
290
+ *
291
+ * Reads are pure projections (no write, no reconciliation side effect). Every write goes
292
+ * through mutateMergeState and a single transactIo, so there is no second file and no
293
+ * incremental writer. A row's queue is always derived from its state on read; a hand-edited
294
+ * mismatch is repaired by the next write, never by a GET.
295
+ */
296
+ function mergeStatePath(home) {
297
+ return join(home, "merge", "merge-state.json");
298
+ }
299
+ function parseState(raw) {
300
+ const parsed = parseLedger(raw);
301
+ if (!parsed.ok) return {
302
+ ok: false,
303
+ rows: []
304
+ };
305
+ return {
306
+ ok: true,
307
+ rows: parsed.ledger.rows,
308
+ ...parsed.note === void 0 ? {} : { note: parsed.note }
309
+ };
310
+ }
230
311
  /** Pure read: never writes, never deletes a row. */
231
312
  async function loadMergeState(io, home) {
232
313
  let raw = null;
@@ -236,7 +317,7 @@ async function loadMergeState(io, home) {
236
317
  return {
237
318
  ok: false,
238
319
  state: {
239
- schemaVersion: 1,
320
+ schemaVersion: 2,
240
321
  rows: []
241
322
  }
242
323
  };
@@ -245,7 +326,7 @@ async function loadMergeState(io, home) {
245
326
  return {
246
327
  ok: parsed.ok,
247
328
  state: {
248
- schemaVersion: 1,
329
+ schemaVersion: 2,
249
330
  rows: parsed.rows
250
331
  },
251
332
  ...parsed.note === void 0 ? {} : { note: parsed.note }
@@ -258,7 +339,7 @@ async function mutateMergeState(io, home, now, fn) {
258
339
  const out = fn(parseState(current).rows);
259
340
  result = out.result;
260
341
  return JSON.stringify({
261
- schemaVersion: 1,
342
+ schemaVersion: 2,
262
343
  rows: out.rows
263
344
  }, null, 2);
264
345
  });
@@ -325,135 +406,374 @@ async function rowsOf(io, home, queue) {
325
406
  return queue === void 0 ? loaded.state.rows : loaded.state.rows.filter((row) => row.queue === queue);
326
407
  }
327
408
  //#endregion
328
- //#region lib/types/coordinator.js
409
+ //#region lib/types/staging.js
329
410
  /**
330
- * Pure merge transition table. No io, no run shell, no subagents.
411
+ * Staging is a run ARTIFACT, not a queue: <home>/merge/runs/<runId>/.
331
412
  *
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.
413
+ * createStaging writes a sibling .partial tree first and renames it into place; the .stage
414
+ * marker is the last write inside the partial. A directory without that marker is a
415
+ * half-created artifact and reads as unknown, never as "empty".
335
416
  */
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
- };
417
+ const STAGING_SCHEMA_VERSION = 1;
418
+ function stageKeyOf(runId, pairKey) {
419
+ return runId + "/pairs/" + createHash("sha256").update(pairKey).digest("hex").slice(0, 16);
342
420
  }
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
- };
421
+ function stagingRoot(home, runId) {
422
+ return join(home, "merge", "runs", runId);
362
423
  }
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
- };
424
+ /** Relative support paths only; the staging tree must never escape its run directory. */
425
+ function safeRelative$2(path) {
426
+ if (path === "" || path.startsWith("/") || path.startsWith("\\") || path.includes("\0")) return false;
427
+ return path.replace(/\\/g, "/").split("/").every((part) => part !== "" && part !== "." && part !== "..");
373
428
  }
374
- function transition(current, event) {
375
- if (event.type === "candidate") {
376
- if (current !== void 0) return {
377
- ok: true,
378
- row: current
429
+ function manifestText(manifest) {
430
+ return JSON.stringify(manifest, null, 2) + "\n";
431
+ }
432
+ async function createStaging(io, home, input) {
433
+ const final = stagingRoot(home, input.runId);
434
+ if (await io.exists(final)) return {
435
+ ok: false,
436
+ message: "staging already exists for run " + input.runId
437
+ };
438
+ const names = /* @__PURE__ */ new Set();
439
+ for (const source of input.sources) {
440
+ if (names.has(source.name)) return {
441
+ ok: false,
442
+ message: "duplicate source " + source.name
379
443
  };
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
- }
444
+ names.add(source.name);
445
+ for (const file of source.files) if (!safeRelative$2(file.path)) return {
446
+ ok: false,
447
+ message: "unsafe support path: " + file.path
393
448
  };
394
449
  }
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
450
+ const partial = final + ".partial";
451
+ try {
452
+ await io.remove(partial);
453
+ for (const source of input.sources) {
454
+ const dir = join(partial, "sources", source.name);
455
+ await io.writeText(join(dir, "SKILL.md"), source.body);
456
+ for (const file of source.files) await io.writeText(join(dir, ...file.path.split("/")), file.content);
457
+ }
458
+ const at = input.now();
459
+ const manifest = {
460
+ schemaVersion: 1,
461
+ runId: input.runId,
462
+ target: input.target,
463
+ ...input.mode === void 0 ? {} : { mode: input.mode },
464
+ sources: input.sources.map((source) => source.name),
465
+ promptVersion: input.promptVersion,
466
+ createdAt: at,
467
+ expiresAt: at + Math.max(0, input.ttlDays) * 864e5
403
468
  };
404
- const key = pairKey(event.a, event.b);
469
+ await io.writeText(join(partial, "manifest.json"), manifestText(manifest));
470
+ await io.writeText(join(partial, ".stage"), "");
471
+ await io.rename(partial, final);
405
472
  return {
406
473
  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
- }
474
+ path: final,
475
+ manifest
476
+ };
477
+ } catch (error) {
478
+ try {
479
+ await io.remove(partial);
480
+ } catch {}
481
+ return {
482
+ ok: false,
483
+ message: "staging create failed: " + (error instanceof Error ? error.message : String(error))
418
484
  };
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
485
  }
450
486
  }
451
- //#endregion
452
- //#region lib/types/status.js
453
- function countRows(rows) {
454
- const counts = {
455
- pending: 0,
456
- retry: 0,
487
+ async function readStaging(io, home, runId) {
488
+ const path = stagingRoot(home, runId);
489
+ if (!await io.exists(path)) return probeAbsent();
490
+ if (!await io.exists(join(path, ".stage"))) return probeUnknown("staging " + runId + " has no .stage marker (half-created)");
491
+ const probe = await probeText(io, join(path, "manifest.json"));
492
+ if (!isPresent(probe)) return probe;
493
+ try {
494
+ const parsed = JSON.parse(probe.value);
495
+ if (parsed.schemaVersion !== 1 || parsed.runId !== runId || typeof parsed.target !== "string" || !Array.isArray(parsed.sources)) return probeUnknown("staging manifest has an unexpected shape");
496
+ return probePresent({
497
+ path,
498
+ manifest: parsed
499
+ });
500
+ } catch (error) {
501
+ return probeUnknown("staging manifest is not JSON: " + (error instanceof Error ? error.message : String(error)));
502
+ }
503
+ }
504
+ async function ensureStaging(io, home, input) {
505
+ const existing = await readStaging(io, home, input.runId);
506
+ if (existing.kind === "present") return {
507
+ ok: true,
508
+ path: stagingRoot(home, input.runId),
509
+ manifest: existing.value.manifest
510
+ };
511
+ return createStaging(io, home, input);
512
+ }
513
+ async function setStagingTarget(io, home, stageKey, target, mode) {
514
+ const view = await readStaging(io, home, stageKey);
515
+ if (view.kind !== "present") return false;
516
+ const manifest = Object.assign({}, view.value.manifest, { target }, mode === void 0 ? {} : { mode });
517
+ await io.writeText(join(stagingRoot(home, stageKey), "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
518
+ return true;
519
+ }
520
+ async function dropStaging(io, home, stageKey) {
521
+ await io.remove(stagingRoot(home, stageKey));
522
+ }
523
+ /** Remove expired run-level staging trees; `keep` names runs a live row still points at. */
524
+ async function cleanupExpiredStaging(io, home, now, keep = /* @__PURE__ */ new Set()) {
525
+ let runs;
526
+ try {
527
+ runs = await io.list(join(home, "merge", "runs"));
528
+ } catch {
529
+ return {
530
+ removed: [],
531
+ kept: [],
532
+ unknown: []
533
+ };
534
+ }
535
+ const removed = [];
536
+ const kept = [];
537
+ const unknown = [];
538
+ for (const runId of runs.sort()) {
539
+ if (keep.has(runId)) {
540
+ kept.push(runId);
541
+ continue;
542
+ }
543
+ const view = await readStaging(io, home, runId);
544
+ if (view.kind === "unknown") {
545
+ unknown.push(runId);
546
+ continue;
547
+ }
548
+ if (view.kind !== "present" || view.value.manifest.expiresAt > now) {
549
+ kept.push(runId);
550
+ continue;
551
+ }
552
+ try {
553
+ await io.remove(stagingRoot(home, runId));
554
+ removed.push(runId);
555
+ } catch {
556
+ unknown.push(runId);
557
+ }
558
+ }
559
+ return {
560
+ removed,
561
+ kept,
562
+ unknown
563
+ };
564
+ }
565
+ //#endregion
566
+ //#region lib/types/commit-receipt.js
567
+ /**
568
+ * Commit receipt: <run>/commit.json, written immediately after a successful commit.
569
+ * It is the idempotency/recovery marker — merge resume and run start read it before
570
+ * replaying a commit. Receipt write failure must not turn a landed commit into a failure;
571
+ * the caller reports the warning instead.
572
+ */
573
+ function commitReceiptPath(home, runId) {
574
+ return join(stagingRoot(home, runId), "commit.json");
575
+ }
576
+ function durableReceiptDir(home, parentRunId) {
577
+ return join(home, "merge", "receipts", parentRunId);
578
+ }
579
+ function durableReceiptPath(home, parentRunId, stageKey) {
580
+ const leaf = stageKey.split("/").pop() ?? stageKey;
581
+ return join(durableReceiptDir(home, parentRunId), leaf + ".json");
582
+ }
583
+ function parseReceipt(raw) {
584
+ if (raw === null) return null;
585
+ try {
586
+ const parsed = JSON.parse(raw);
587
+ 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;
588
+ return parsed;
589
+ } catch {
590
+ return null;
591
+ }
592
+ }
593
+ async function writeCommitReceipt(io, home, receipt) {
594
+ const path = commitReceiptPath(home, receipt.runId);
595
+ await io.writeText(path, JSON.stringify(receipt, null, 2) + "\n");
596
+ const parent = receipt.parentRunId ?? receipt.runId.split("/pairs/")[0] ?? receipt.runId;
597
+ await io.writeText(durableReceiptPath(home, parent, receipt.runId), JSON.stringify({
598
+ ...receipt,
599
+ parentRunId: parent
600
+ }, null, 2) + "\n");
601
+ return path;
602
+ }
603
+ async function readCommitReceipts(io, home) {
604
+ const root = join(home, "merge", "receipts");
605
+ const out = [];
606
+ let runs;
607
+ try {
608
+ runs = await io.list(root);
609
+ } catch {
610
+ return out;
611
+ }
612
+ for (const run of runs.sort()) {
613
+ const dir = join(root, run);
614
+ let files;
615
+ try {
616
+ files = await io.list(dir);
617
+ } catch {
618
+ continue;
619
+ }
620
+ for (const file of files.sort()) {
621
+ if (!file.endsWith(".json")) continue;
622
+ const receipt = parseReceipt(await io.readText(join(dir, file)));
623
+ if (receipt !== null) out.push(receipt);
624
+ }
625
+ }
626
+ return out;
627
+ }
628
+ async function readCommitReceipt(io, home, runId, target) {
629
+ const direct = parseReceipt(await io.readText(commitReceiptPath(home, runId)));
630
+ if (direct !== null && (target === void 0 || direct.target === target)) return direct;
631
+ try {
632
+ const names = await io.list(durableReceiptDir(home, runId));
633
+ for (const name of names.sort()) {
634
+ const receipt = parseReceipt(await io.readText(join(durableReceiptDir(home, runId), name)));
635
+ if (receipt !== null && (target === void 0 || receipt.target === target)) return receipt;
636
+ }
637
+ } catch {}
638
+ return null;
639
+ }
640
+ //#endregion
641
+ //#region lib/types/coordinator.js
642
+ /**
643
+ * Pure merge transition table. No io, no run shell, no subagents.
644
+ *
645
+ * Every transition returns the whole next row (or a drop), so the caller can persist it
646
+ * through mutateMergeState in one transaction. Invalid transitions are refused by name;
647
+ * a caller never "repairs" a row by guessing which state the operator meant.
648
+ */
649
+ function invalid(event, state) {
650
+ return {
651
+ ok: false,
652
+ code: "invalid-transition",
653
+ message: "cannot apply " + event.type + " to " + (state ?? "no row")
654
+ };
655
+ }
656
+ /** Drop fields that must not survive a transition; optional fields stay absent. */
657
+ function base(row, at) {
658
+ return {
659
+ id: row.id,
660
+ pairKey: row.pairKey,
661
+ a: row.a,
662
+ b: row.b,
663
+ state: row.state,
664
+ queue: row.queue,
665
+ createdAt: row.createdAt,
666
+ updatedAt: at,
667
+ ...row.runId === void 0 ? {} : { runId: row.runId },
668
+ ...row.unitId === void 0 ? {} : { unitId: row.unitId },
669
+ ...row.origin === void 0 ? {} : { origin: row.origin },
670
+ ...row.decision === void 0 ? {} : { decision: row.decision },
671
+ ...row.sourceRevision === void 0 ? {} : { sourceRevision: row.sourceRevision },
672
+ ...row.targetRevision === void 0 ? {} : { targetRevision: row.targetRevision },
673
+ ...row.attempts === void 0 ? {} : { attempts: row.attempts },
674
+ ...row.stagingRef === void 0 ? {} : { stagingRef: row.stagingRef },
675
+ ...row.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: row.stagingExpiresAt },
676
+ ...row.expired === void 0 ? {} : { expired: row.expired },
677
+ ...row.reason === void 0 ? {} : { reason: row.reason },
678
+ ...row.target === void 0 ? {} : { target: row.target }
679
+ };
680
+ }
681
+ function next(row, at, state, patch = {}) {
682
+ return {
683
+ ok: true,
684
+ row: {
685
+ ...base(row, at),
686
+ state,
687
+ queue: queueOf(state),
688
+ ...patch
689
+ }
690
+ };
691
+ }
692
+ function transition(current, event) {
693
+ if (event.type === "candidate") {
694
+ if (current !== void 0) return {
695
+ ok: true,
696
+ row: current
697
+ };
698
+ const key = pairKey(event.a, event.b);
699
+ return {
700
+ ok: true,
701
+ row: {
702
+ id: rowIdOf(key),
703
+ pairKey: key,
704
+ a: event.a,
705
+ b: event.b,
706
+ state: "verify",
707
+ queue: "verify",
708
+ createdAt: event.at,
709
+ updatedAt: event.at
710
+ }
711
+ };
712
+ }
713
+ if (event.type === "source-gone") return {
714
+ ok: true,
715
+ row: null
716
+ };
717
+ if (event.type === "gate-keep") {
718
+ if (current !== void 0) return {
719
+ ok: true,
720
+ row: current
721
+ };
722
+ const key = pairKey(event.a, event.b);
723
+ return {
724
+ ok: true,
725
+ row: {
726
+ id: rowIdOf(key),
727
+ pairKey: key,
728
+ a: event.a,
729
+ b: event.b,
730
+ state: "skipped",
731
+ queue: "skipped",
732
+ origin: "agent",
733
+ createdAt: event.at,
734
+ updatedAt: event.at
735
+ }
736
+ };
737
+ }
738
+ if (current === void 0) return invalid(event, void 0);
739
+ switch (event.type) {
740
+ case "round1-keep": return current.state === "verify" ? next(current, event.at, "skipped", { origin: "agent" }) : invalid(event, current.state);
741
+ case "round1-merge":
742
+ if (current.state !== "verify") return invalid(event, current.state);
743
+ return event.approval === "on" ? next(current, event.at, "pending", {
744
+ ...event.decision === void 0 ? {} : { decision: event.decision },
745
+ ...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
746
+ ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt }
747
+ }) : next(current, event.at, "approved", {
748
+ ...event.decision === void 0 ? {} : { decision: event.decision },
749
+ ...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
750
+ ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt }
751
+ });
752
+ case "round1-fail": return current.state === "verify" ? next(current, event.at, "verify", {
753
+ failure: event.failure,
754
+ attempts: (current.attempts ?? 0) + 1
755
+ }) : invalid(event, current.state);
756
+ case "approve": return current.state === "pending" ? next(current, event.at, "approved", { ...event.runId === void 0 ? {} : { runId: event.runId } }) : invalid(event, current.state);
757
+ case "reject": return current.state === "pending" ? next(current, event.at, "skipped", { origin: "human" }) : invalid(event, current.state);
758
+ case "round2-cancel": return current.state === "fuse" || current.state === "approved" ? next(current, event.at, "approved") : invalid(event, current.state);
759
+ case "round2-fail": return current.state === "fuse" || current.state === "approved" ? next(current, event.at, "approved", {
760
+ failure: event.failure,
761
+ attempts: (current.attempts ?? 0) + 1
762
+ }) : invalid(event, current.state);
763
+ case "round2-success": return current.state === "fuse" || current.state === "approved" ? {
764
+ ok: true,
765
+ row: null
766
+ } : invalid(event, current.state);
767
+ case "restore": return current.state === "skipped" ? next(current, event.at, "verify") : invalid(event, current.state);
768
+ }
769
+ }
770
+ //#endregion
771
+ //#region lib/types/status.js
772
+ function countRows(rows) {
773
+ const counts = {
774
+ verify: 0,
775
+ pending: 0,
776
+ fuse: 0,
457
777
  skipped: 0
458
778
  };
459
779
  for (const row of rows) counts[queueOf(row.state)] += 1;
@@ -466,7 +786,7 @@ function describe(row) {
466
786
  }
467
787
  function formatStatus(rows) {
468
788
  const counts = countRows(rows);
469
- const lines = ["merge: pending " + String(counts.pending) + ", retry " + String(counts.retry) + ", skipped " + String(counts.skipped)];
789
+ const lines = ["merge: verify " + String(counts.verify) + ", pending " + String(counts.pending) + ", fuse " + String(counts.fuse) + ", skipped " + String(counts.skipped)];
470
790
  for (const row of rows) lines.push("- " + describe(row));
471
791
  return lines.join("\n");
472
792
  }
@@ -500,22 +820,33 @@ function toRowView(row) {
500
820
  ...row.attempts === void 0 ? {} : { attempts: row.attempts },
501
821
  ...row.origin === void 0 ? {} : { origin: row.origin },
502
822
  ...row.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: row.stagingExpiresAt },
823
+ ...row.expired === void 0 ? {} : { expired: row.expired },
824
+ ...row.reason === void 0 ? {} : { reason: row.reason },
825
+ summary: [
826
+ row.a + " + " + row.b,
827
+ row.decision?.rationale ?? row.reason ?? "",
828
+ row.failure?.message ?? ""
829
+ ],
503
830
  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
831
  };
505
832
  }
506
833
  function buildMergeView(rows, commands, latestRun, approval) {
507
834
  const counts = countRows(rows);
508
835
  return {
509
- schemaVersion: 1,
836
+ schemaVersion: 2,
510
837
  counts,
511
838
  tabs: [
839
+ {
840
+ queue: "verify",
841
+ count: counts.verify
842
+ },
512
843
  {
513
844
  queue: "pending",
514
845
  count: counts.pending
515
846
  },
516
847
  {
517
- queue: "retry",
518
- count: counts.retry
848
+ queue: "fuse",
849
+ count: counts.fuse
519
850
  },
520
851
  {
521
852
  queue: "skipped",
@@ -554,8 +885,70 @@ function toRunFact(run) {
554
885
  };
555
886
  }
556
887
  //#endregion
888
+ //#region lib/types/domain/markdown.js
889
+ /** Minimal safe Markdown renderer for the read-only body route. */
890
+ function escapeHtml(text) {
891
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
892
+ }
893
+ function renderMarkdown(source) {
894
+ const fence = String.fromCharCode(96, 96, 96);
895
+ const lines = source.split(/\r?\n/);
896
+ const out = [];
897
+ let inCode = false;
898
+ let inList = false;
899
+ for (const line of lines) {
900
+ if (line.startsWith(fence)) {
901
+ if (inCode) {
902
+ out.push("</code></pre>");
903
+ inCode = false;
904
+ } else {
905
+ out.push("<pre><code>");
906
+ inCode = true;
907
+ }
908
+ continue;
909
+ }
910
+ if (inCode) {
911
+ out.push(escapeHtml(line));
912
+ continue;
913
+ }
914
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
915
+ if (heading !== null) {
916
+ if (inList) {
917
+ out.push("</ul>");
918
+ inList = false;
919
+ }
920
+ const level = heading[1]?.length ?? 1;
921
+ out.push("<h" + String(level) + ">" + escapeHtml(heading[2] ?? "") + "</h" + String(level) + ">");
922
+ continue;
923
+ }
924
+ if (/^[-*]\s+/.test(line)) {
925
+ if (!inList) {
926
+ out.push("<ul>");
927
+ inList = true;
928
+ }
929
+ out.push("<li>" + escapeHtml(line.replace(/^[-*]\s+/, "")) + "</li>");
930
+ continue;
931
+ }
932
+ if (inList) {
933
+ out.push("</ul>");
934
+ inList = false;
935
+ }
936
+ out.push(line.trim() === "" ? "" : "<p>" + escapeHtml(line) + "</p>");
937
+ }
938
+ if (inList) out.push("</ul>");
939
+ if (inCode) out.push("</code></pre>");
940
+ return out.join("\n");
941
+ }
942
+ //#endregion
557
943
  //#region lib/types/merge-routes.js
558
- const MERGE_ROUTES = HOST_ROUTES.merge;
944
+ const MERGE_PATHS = HOST_ROUTES.merge;
945
+ const MERGE_BASE = MERGE_PATHS.view.slice(0, -4);
946
+ const MERGE_ROUTES = {
947
+ view: MERGE_PATHS.view,
948
+ rows: MERGE_PATHS.rows,
949
+ body: MERGE_PATHS.body ?? MERGE_BASE + "body",
950
+ history: MERGE_PATHS.history ?? MERGE_BASE + "history"
951
+ };
559
952
  function makeMergeRoutes(deps, _audit) {
560
953
  const absent = () => json(503, {
561
954
  ok: false,
@@ -593,12 +986,12 @@ function makeMergeRoutes(deps, _audit) {
593
986
  const source = deps.getSource();
594
987
  if (source === void 0) return absent();
595
988
  const query = url(request).searchParams;
596
- const queue = query.get("queue") ?? "retry";
597
- if (queue !== "pending" && queue !== "retry" && queue !== "skipped") return json(400, {
989
+ const queue = query.get("queue") ?? "verify";
990
+ if (queue !== "verify" && queue !== "pending" && queue !== "fuse" && queue !== "skipped") return json(400, {
598
991
  ok: false,
599
992
  error: {
600
993
  code: "bad-queue",
601
- message: "queue must be pending/retry/skipped"
994
+ message: "queue must be verify/pending/fuse/skipped"
602
995
  }
603
996
  });
604
997
  const cursor = Number.parseInt(query.get("cursor") ?? "0", 10);
@@ -610,7 +1003,7 @@ function makeMergeRoutes(deps, _audit) {
610
1003
  }
611
1004
  },
612
1005
  {
613
- path: MERGE_ROUTES.run,
1006
+ path: MERGE_ROUTES.body,
614
1007
  methods: ["GET"],
615
1008
  requestBody: "buffered",
616
1009
  fetch: async (request) => {
@@ -618,29 +1011,35 @@ function makeMergeRoutes(deps, _audit) {
618
1011
  if (denied !== null) return denied;
619
1012
  const source = deps.getSource();
620
1013
  if (source === void 0) return absent();
621
- const id = url(request).searchParams.get("id");
622
- if (id === null || id === "") return json(400, {
1014
+ const query = url(request).searchParams;
1015
+ const id = query.get("id");
1016
+ const side = query.get("side");
1017
+ if (id === null || id === "" || side !== "a" && side !== "b") return json(400, {
623
1018
  ok: false,
624
1019
  error: {
625
- code: "missing-id",
626
- message: "id is required"
1020
+ code: "bad-body-query",
1021
+ message: "id and side=a|b are required"
627
1022
  }
628
1023
  });
629
- const run = await source.run(id);
630
- return run === void 0 ? json(404, {
1024
+ const content = await source.body(id, side);
1025
+ return content === null ? json(404, {
631
1026
  ok: false,
632
1027
  error: {
633
- code: "run-not-found",
634
- message: "merge run not found: " + id
1028
+ code: "body-not-found",
1029
+ message: "merge body not found: " + id
635
1030
  }
636
1031
  }) : json(200, {
637
1032
  ok: true,
638
- data: toRunFact(run)
1033
+ data: {
1034
+ content,
1035
+ markdown: true,
1036
+ html: renderMarkdown(content)
1037
+ }
639
1038
  });
640
1039
  }
641
1040
  },
642
1041
  {
643
- path: MERGE_ROUTES.runs,
1042
+ path: MERGE_ROUTES.history,
644
1043
  methods: ["GET"],
645
1044
  requestBody: "buffered",
646
1045
  fetch: async (request) => {
@@ -648,11 +1047,9 @@ function makeMergeRoutes(deps, _audit) {
648
1047
  if (denied !== null) return denied;
649
1048
  const source = deps.getSource();
650
1049
  if (source === void 0) return absent();
651
- const query = url(request).searchParams;
652
- const limit = Number.parseInt(query.get("limit") ?? "20", 10);
653
1050
  return json(200, {
654
1051
  ok: true,
655
- data: { items: (await source.runs(query.get("cursor") ?? void 0, Number.isFinite(limit) ? limit : 20)).map(toRunFact) }
1052
+ data: { items: await source.history() }
656
1053
  });
657
1054
  }
658
1055
  }
@@ -660,12 +1057,12 @@ function makeMergeRoutes(deps, _audit) {
660
1057
  }
661
1058
  //#endregion
662
1059
  //#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>";
1060
+ const USAGE = "usage: /evolution merge scan | pair [--force] <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending [--expired] | candidates [--retry] | approve <id> | reject <id> | resume [id] [--retry] [--clear <id>] | skipped [--restore <id>] | discard <id> | undo <target> --run <id>";
664
1061
  function flags(args, allowed) {
665
1062
  const values = /* @__PURE__ */ new Map();
666
1063
  for (let index = 0; index < args.length; index += 1) {
667
1064
  const token = args[index];
668
- if (!token.startsWith("--")) continue;
1065
+ if (!token.startsWith("--") || token === "--force" || token === "--expired" || token === "--retry") continue;
669
1066
  const name = token.slice(2);
670
1067
  if (!allowed.includes(name)) return {
671
1068
  values,
@@ -681,8 +1078,12 @@ function flags(args, allowed) {
681
1078
  }
682
1079
  return { values };
683
1080
  }
1081
+ function switches(args) {
1082
+ return new Set(args.filter((token) => token === "--force" || token === "--expired" || token === "--retry").map((token) => token.slice(2)));
1083
+ }
684
1084
  function positional(args) {
685
- return args.filter((token, index) => !token.startsWith("--") && !(index > 0 && args[index - 1].startsWith("--")));
1085
+ const filtered = args.filter((token) => token !== "--force" && token !== "--expired" && token !== "--retry");
1086
+ return filtered.filter((token, index) => !token.startsWith("--") && !(index > 0 && filtered[index - 1].startsWith("--")));
686
1087
  }
687
1088
  function parseMergeCommand(argv) {
688
1089
  const [verb, ...rest] = argv;
@@ -692,6 +1093,7 @@ function parseMergeCommand(argv) {
692
1093
  help: true
693
1094
  };
694
1095
  const pos = positional(rest);
1096
+ const marked = switches(rest);
695
1097
  switch (verb) {
696
1098
  case "scan": return pos.length === 0 ? {
697
1099
  ok: true,
@@ -706,7 +1108,8 @@ function parseMergeCommand(argv) {
706
1108
  command: {
707
1109
  verb: "pair",
708
1110
  a: pos[0],
709
- b: pos[1]
1111
+ b: pos[1],
1112
+ ...marked.has("force") ? { force: true } : {}
710
1113
  }
711
1114
  } : {
712
1115
  ok: false,
@@ -764,7 +1167,21 @@ function parseMergeCommand(argv) {
764
1167
  };
765
1168
  case "pending": return pos.length === 0 ? {
766
1169
  ok: true,
767
- command: { verb: "pending" }
1170
+ command: {
1171
+ verb: "pending",
1172
+ ...marked.has("expired") ? { expired: true } : {}
1173
+ }
1174
+ } : {
1175
+ ok: false,
1176
+ code: "usage",
1177
+ message: USAGE
1178
+ };
1179
+ case "candidates": return pos.length === 0 ? {
1180
+ ok: true,
1181
+ command: {
1182
+ verb: "candidates",
1183
+ ...marked.has("retry") ? { retry: true } : {}
1184
+ }
768
1185
  } : {
769
1186
  ok: false,
770
1187
  code: "usage",
@@ -810,7 +1227,8 @@ function parseMergeCommand(argv) {
810
1227
  command: {
811
1228
  verb: "resume",
812
1229
  ...pos[0] === void 0 ? {} : { id: pos[0] },
813
- ...clear === void 0 ? {} : { clear }
1230
+ ...clear === void 0 ? {} : { clear },
1231
+ ...marked.has("retry") ? { retry: true } : {}
814
1232
  }
815
1233
  };
816
1234
  }
@@ -835,6 +1253,17 @@ function parseMergeCommand(argv) {
835
1253
  }
836
1254
  };
837
1255
  }
1256
+ case "discard": return pos.length === 1 ? {
1257
+ ok: true,
1258
+ command: {
1259
+ verb: "discard",
1260
+ id: pos[0]
1261
+ }
1262
+ } : {
1263
+ ok: false,
1264
+ code: "usage",
1265
+ message: USAGE
1266
+ };
838
1267
  case "undo": {
839
1268
  const parsed = flags(rest, ["run"]);
840
1269
  if (parsed.error !== void 0) return {
@@ -861,7 +1290,7 @@ function parseMergeCommand(argv) {
861
1290
  default: return {
862
1291
  ok: false,
863
1292
  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>"
1293
+ message: "unknown merge command: " + verb + "\nusage: /evolution merge scan | pair [--force] <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending [--expired] | candidates [--retry] | approve <id> | reject <id> | resume [id] [--retry] [--clear <id>] | skipped [--restore <id>] | discard <id> | undo <target> --run <id>"
865
1294
  };
866
1295
  }
867
1296
  }
@@ -879,7 +1308,15 @@ async function handleReadOnlyMergeCommand(command, io, home) {
879
1308
  }
880
1309
  return formatStatus(loaded.state.rows);
881
1310
  }
882
- if (command.verb === "pending") return formatQueue((await loadMergeState(io, home)).state.rows, "pending");
1311
+ if (command.verb === "pending") {
1312
+ const loaded = await loadMergeState(io, home);
1313
+ const rows = command.expired === true ? loaded.state.rows.filter((row) => row.expired !== void 0) : loaded.state.rows;
1314
+ return command.expired === true ? formatStatus(rows) : formatQueue(rows, "pending");
1315
+ }
1316
+ if (command.verb === "candidates") {
1317
+ const loaded = await loadMergeState(io, home);
1318
+ return formatQueue(command.retry === true ? loaded.state.rows.filter((row) => row.state === "approved") : loaded.state.rows, command.retry === true ? "fuse" : "verify");
1319
+ }
883
1320
  if (command.verb === "skipped") return formatQueue((await loadMergeState(io, home)).state.rows, "skipped");
884
1321
  return null;
885
1322
  }
@@ -887,6 +1324,13 @@ function mergeCommandHelp() {
887
1324
  return mergeUsage();
888
1325
  }
889
1326
  //#endregion
1327
+ //#region lib/types/domain/errors.js
1328
+ function formatMergeError(error) {
1329
+ if (error.kind === "parameter") return "merge: " + error.message + " (" + error.code + ")";
1330
+ const detail = "\n" + (error.detail ?? "no additional detail");
1331
+ return "merge failed: " + error.message + " (" + error.code + ")" + detail + "\nnext: fix the target or rerun after the cause is gone";
1332
+ }
1333
+ //#endregion
890
1334
  //#region lib/types/merge-command.js
891
1335
  function createReadOnlyMergeService(io, home) {
892
1336
  return { async execute(command) {
@@ -918,7 +1362,11 @@ async function runMergeCommand(service, rawInput, invocation) {
918
1362
  } catch (error) {
919
1363
  return {
920
1364
  ok: false,
921
- text: "merge 命令失败:" + (error instanceof Error ? error.message : String(error)) + "\n运行 /evolution merge status 查看状态。"
1365
+ text: formatMergeError({
1366
+ kind: "runtime",
1367
+ code: "merge-command-failed",
1368
+ message: error instanceof Error ? error.message : String(error)
1369
+ })
922
1370
  };
923
1371
  }
924
1372
  }
@@ -959,11 +1407,58 @@ function isSkippedPair(keys, a, b) {
959
1407
  return keys.has(pairKey(a, b));
960
1408
  }
961
1409
  //#endregion
1410
+ //#region lib/types/domain/pair-index.js
1411
+ function parsePairIndex(raw) {
1412
+ const index = /* @__PURE__ */ new Map();
1413
+ if (raw === null) return index;
1414
+ let parsed;
1415
+ try {
1416
+ parsed = JSON.parse(raw);
1417
+ } catch {
1418
+ return index;
1419
+ }
1420
+ const body = parsed;
1421
+ if (!Array.isArray(body.entries)) return index;
1422
+ for (const value of body.entries) {
1423
+ const entry = value;
1424
+ if (typeof entry.pairKey !== "string" || typeof entry.runId !== "string" || typeof entry.result !== "string") continue;
1425
+ if (![
1426
+ "merged",
1427
+ "kept",
1428
+ "failed",
1429
+ "cancelled",
1430
+ "undone"
1431
+ ].includes(entry.result)) continue;
1432
+ index.set(entry.pairKey, entry);
1433
+ }
1434
+ return index;
1435
+ }
1436
+ function serializePairIndex(index) {
1437
+ return JSON.stringify({
1438
+ schemaVersion: 1,
1439
+ entries: [...index.values()]
1440
+ }, null, 2);
1441
+ }
1442
+ //#endregion
1443
+ //#region lib/types/domain/pair-index-store.js
1444
+ /** Durable pairKey index at <home>/merge/pair-index.json. */
1445
+ function pairIndexPath(home) {
1446
+ return join(home, "merge", "pair-index.json");
1447
+ }
1448
+ async function recordPair(io, home, entry) {
1449
+ await transactIo(io, pairIndexPath(home), (current) => {
1450
+ const index = parsePairIndex(current);
1451
+ index.set(entry.pairKey, entry);
1452
+ return serializePairIndex(index);
1453
+ });
1454
+ }
1455
+ //#endregion
962
1456
  //#region lib/types/scan-run.js
963
1457
  /**
964
1458
  * The scan pair loop. Registry/report lifecycle stays in withMergeRun; this module owns
965
1459
  * candidate → round1 → pending/fuse → commit transitions and returns the report draft.
966
1460
  */
1461
+ const isAborted = (signal) => signal.aborted;
967
1462
  function at(now) {
968
1463
  return new Date(now()).toISOString();
969
1464
  }
@@ -995,8 +1490,9 @@ async function runScan(options) {
995
1490
  const candidates = await options.ports.screening(options.signal);
996
1491
  const before = await loadMergeState(options.io, options.home);
997
1492
  const queueBefore = {
1493
+ verify: 0,
998
1494
  pending: 0,
999
- retry: 0,
1495
+ fuse: 0,
1000
1496
  skipped: 0
1001
1497
  };
1002
1498
  for (const row of before.state.rows) queueBefore[row.queue] += 1;
@@ -1012,11 +1508,11 @@ async function runScan(options) {
1012
1508
  const remaining = [];
1013
1509
  let stopReason = "completed";
1014
1510
  for (let index = 0; index < candidates.length; index += 1) {
1015
- if (options.signal.aborted) {
1511
+ if (isAborted(options.signal)) {
1016
1512
  stopReason = "cancelled";
1017
1513
  remaining.push(...candidates.slice(index).map((candidate) => ({
1018
1514
  pairKey: pairKey(candidate.a, candidate.b),
1019
- queue: "retry"
1515
+ queue: "verify"
1020
1516
  })));
1021
1517
  break;
1022
1518
  }
@@ -1024,7 +1520,7 @@ async function runScan(options) {
1024
1520
  stopReason = "timeout";
1025
1521
  remaining.push(...candidates.slice(index).map((candidate) => ({
1026
1522
  pairKey: pairKey(candidate.a, candidate.b),
1027
- queue: "retry"
1523
+ queue: "verify"
1028
1524
  })));
1029
1525
  break;
1030
1526
  }
@@ -1051,7 +1547,7 @@ async function runScan(options) {
1051
1547
  pairKey: pairKey(candidate.a, candidate.b),
1052
1548
  a: candidate.a,
1053
1549
  b: candidate.b,
1054
- queue: "retry",
1550
+ queue: "verify",
1055
1551
  candidate,
1056
1552
  outcome: "failed",
1057
1553
  archived: [],
@@ -1068,6 +1564,14 @@ async function runScan(options) {
1068
1564
  failure: failureOf("decide", error)
1069
1565
  };
1070
1566
  }
1567
+ if (isAborted(options.signal)) {
1568
+ stopReason = "cancelled";
1569
+ remaining.push(...candidates.slice(index).map((candidate) => ({
1570
+ pairKey: pairKey(candidate.a, candidate.b),
1571
+ queue: "verify"
1572
+ })));
1573
+ break;
1574
+ }
1071
1575
  if (round1.kind === "failed") {
1072
1576
  const next = transition(row, {
1073
1577
  type: "round1-fail",
@@ -1090,6 +1594,11 @@ async function runScan(options) {
1090
1594
  if (next.ok && next.row !== null) {
1091
1595
  row = next.row;
1092
1596
  await upsertRow(options.io, options.home, options.now, row);
1597
+ await recordPair(options.io, options.home, {
1598
+ pairKey: row.pairKey,
1599
+ runId: row.runId ?? "merge",
1600
+ result: "kept"
1601
+ });
1093
1602
  }
1094
1603
  pairs.push(pairOf(row, candidate, "kept", round1.decision === void 0 ? {} : { decision: round1.decision }));
1095
1604
  totals.kept += 1;
@@ -1105,8 +1614,8 @@ async function runScan(options) {
1105
1614
  type: "round1-merge",
1106
1615
  approval: "on",
1107
1616
  decision: round1.decision,
1108
- ...round1.stagingRef === void 0 ? {} : { stagingRef: round1.stagingRef },
1109
- ...round1.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: round1.stagingExpiresAt },
1617
+ ...round1.sourceRevision === void 0 ? {} : { sourceRevision: round1.sourceRevision },
1618
+ ...round1.targetRevision === void 0 ? {} : { targetRevision: round1.targetRevision },
1110
1619
  at: at(options.now)
1111
1620
  });
1112
1621
  if (next.ok && next.row !== null) {
@@ -1135,6 +1644,22 @@ async function runScan(options) {
1135
1644
  }
1136
1645
  row = fuse.row;
1137
1646
  await upsertRow(options.io, options.home, options.now, row);
1647
+ if (isAborted(options.signal)) {
1648
+ const cancelled = transition(row, {
1649
+ type: "round2-cancel",
1650
+ at: at(options.now)
1651
+ });
1652
+ if (cancelled.ok && cancelled.row !== null) {
1653
+ row = cancelled.row;
1654
+ await upsertRow(options.io, options.home, options.now, row);
1655
+ }
1656
+ stopReason = "cancelled";
1657
+ remaining.push(...candidates.slice(index).map((candidate) => ({
1658
+ pairKey: pairKey(candidate.a, candidate.b),
1659
+ queue: "fuse"
1660
+ })));
1661
+ break;
1662
+ }
1138
1663
  await stage("author", {
1139
1664
  done: index,
1140
1665
  total: candidates.length
@@ -1207,7 +1732,7 @@ function mergeReportDigestPath(home, runId) {
1207
1732
  function isReport(value) {
1208
1733
  if (typeof value !== "object" || value === null) return false;
1209
1734
  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);
1735
+ return report.schemaVersion === 2 && 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
1736
  }
1212
1737
  function digest(report) {
1213
1738
  const lines = [];
@@ -1254,8 +1779,9 @@ function emptyDraft(reason) {
1254
1779
  scope: {
1255
1780
  candidatePairs: 0,
1256
1781
  queueBefore: {
1782
+ verify: 0,
1257
1783
  pending: 0,
1258
- retry: 0,
1784
+ fuse: 0,
1259
1785
  skipped: 0
1260
1786
  }
1261
1787
  },
@@ -1272,6 +1798,7 @@ function emptyDraft(reason) {
1272
1798
  async function withMergeRun(options) {
1273
1799
  const handle = await options.registry.begin("merge", {
1274
1800
  command: options.command,
1801
+ mergeStatus: "running",
1275
1802
  ...options.total === void 0 ? {} : { total: options.total }
1276
1803
  });
1277
1804
  const startedAt = handle.startedAt;
@@ -1280,64 +1807,77 @@ async function withMergeRun(options) {
1280
1807
  done: 0,
1281
1808
  total: options.total
1282
1809
  });
1283
- let draft;
1284
- try {
1285
- draft = await options.body({
1810
+ const finish = async () => {
1811
+ let draft;
1812
+ try {
1813
+ draft = await options.body({
1814
+ runId: handle.id,
1815
+ startedAt,
1816
+ signal: handle.signal,
1817
+ stage
1818
+ });
1819
+ } catch (error) {
1820
+ draft = emptyDraft("run body failed: " + (error instanceof Error ? error.message : String(error)));
1821
+ }
1822
+ const report = {
1823
+ schemaVersion: 2,
1286
1824
  runId: handle.id,
1825
+ kind: "merge",
1826
+ command: options.command,
1287
1827
  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) {
1828
+ endedAt: options.now(),
1829
+ stopReason: draft.stopReason,
1830
+ approval: options.approval,
1831
+ scope: draft.scope,
1832
+ pairs: draft.pairs,
1833
+ totals: draft.totals,
1834
+ notes: draft.notes,
1835
+ ...draft.remaining === void 0 ? {} : { remaining: draft.remaining }
1836
+ };
1837
+ let reportPath;
1838
+ try {
1839
+ reportPath = await writeMergeReport(options.io, options.home, report);
1840
+ } catch (error) {
1841
+ await options.registry.settle(handle.id, {
1842
+ state: "failed",
1843
+ failure: "report write failed: " + (error instanceof Error ? error.message : String(error))
1844
+ });
1845
+ return {
1846
+ ok: false,
1847
+ runId: handle.id,
1848
+ message: "report write failed"
1849
+ };
1850
+ }
1851
+ const runState = report.stopReason === "completed" ? "succeeded" : report.stopReason === "cancelled" ? "cancelled" : "failed";
1852
+ const mergeStatus = report.pairs.some((pair) => pair.outcome === "pending") ? "awaiting-approval" : runState;
1313
1853
  await options.registry.settle(handle.id, {
1314
- state: "failed",
1315
- failure: "report write failed: " + (error instanceof Error ? error.message : String(error))
1854
+ state: runState,
1855
+ resultRef: reportPath,
1856
+ mergeStatus,
1857
+ ...runState === "failed" ? { failure: "merge " + report.stopReason } : {}
1316
1858
  });
1859
+ if (report.stopReason === "completed") return {
1860
+ ok: true,
1861
+ runId: handle.id,
1862
+ reportPath,
1863
+ report
1864
+ };
1317
1865
  return {
1318
1866
  ok: false,
1319
1867
  runId: handle.id,
1320
- message: "report write failed"
1868
+ message: "merge " + report.stopReason,
1869
+ reportPath
1321
1870
  };
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
1871
  };
1872
+ if (options.detached === true) {
1873
+ finish().catch(() => {});
1874
+ return {
1875
+ ok: true,
1876
+ accepted: true,
1877
+ runId: handle.id
1878
+ };
1879
+ }
1880
+ return await finish();
1341
1881
  }
1342
1882
  //#endregion
1343
1883
  //#region lib/types/scan-command.js
@@ -1352,6 +1892,7 @@ async function runScanCommand(options) {
1352
1892
  mergeApproval: options.approval
1353
1893
  },
1354
1894
  now: options.now,
1895
+ ...options.detached === void 0 ? {} : { detached: options.detached },
1355
1896
  async body(ctx) {
1356
1897
  const ports = options.makePorts === void 0 ? options.ports : options.makePorts(ctx.runId);
1357
1898
  if (ports === void 0) throw new Error("scan requires ports or makePorts");
@@ -1431,7 +1972,17 @@ async function recordRound2Outcome(io, home, now, row, outcome) {
1431
1972
  if (transition(row, {
1432
1973
  type: "round2-success",
1433
1974
  at
1434
- }).ok) await dropRow(io, home, now, row.id);
1975
+ }).ok) {
1976
+ await dropRow(io, home, now, row.id);
1977
+ const target = outcome.target?.actual ?? outcome.target?.requested ?? row.decision?.target;
1978
+ await recordPair(io, home, {
1979
+ pairKey: row.pairKey,
1980
+ runId: row.runId ?? "merge",
1981
+ result: "merged",
1982
+ ...target === void 0 ? {} : { target },
1983
+ ...outcome.unitId === void 0 ? {} : { reportRef: outcome.unitId }
1984
+ });
1985
+ }
1435
1986
  return {
1436
1987
  merged: true,
1437
1988
  pair: {
@@ -1460,7 +2011,15 @@ async function recordRound2Outcome(io, home, now, row, outcome) {
1460
2011
  failure,
1461
2012
  at
1462
2013
  });
1463
- if (next.ok && next.row !== null) await upsertRow(io, home, now, next.row);
2014
+ if (next.ok && next.row !== null) {
2015
+ await upsertRow(io, home, now, next.row);
2016
+ await recordPair(io, home, {
2017
+ pairKey: row.pairKey,
2018
+ runId: row.runId ?? "merge",
2019
+ result: "failed",
2020
+ ...row.decision?.target === void 0 ? {} : { target: row.decision.target }
2021
+ });
2022
+ }
1464
2023
  return {
1465
2024
  merged: false,
1466
2025
  pair: {
@@ -1525,8 +2084,9 @@ async function runApproveCommand(options) {
1525
2084
  scope: {
1526
2085
  candidatePairs: 1,
1527
2086
  queueBefore: {
2087
+ verify: 0,
1528
2088
  pending: 0,
1529
- retry: 1,
2089
+ fuse: 1,
1530
2090
  skipped: 0
1531
2091
  }
1532
2092
  },
@@ -1547,276 +2107,112 @@ async function runApproveCommand(options) {
1547
2107
  kept: 0,
1548
2108
  pending: 0,
1549
2109
  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));
2110
+ }
2111
+ };
2112
+ }
2113
+ });
1770
2114
  }
1771
2115
  //#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;
2116
+ //#region lib/types/resume-command.js
2117
+ async function runResumeCommand(options) {
2118
+ const row = (await loadMergeState(options.io, options.home)).state.rows.find((item) => item.id === options.rowId);
2119
+ if (row === void 0) return {
2120
+ ok: false,
2121
+ message: "merge row not found: " + options.rowId
2122
+ };
2123
+ if (row.state === "verify") return await runScanCommand({
2124
+ io: options.io,
2125
+ home: options.home,
2126
+ registry: options.registry,
2127
+ approval: true,
2128
+ platformWrite: options.platformWrite,
2129
+ now: options.now,
2130
+ ...options.makePorts === void 0 ? { ports: {
2131
+ ...options.ports,
2132
+ screening: () => Promise.resolve([{
2133
+ a: row.a,
2134
+ b: row.b
2135
+ }])
2136
+ } } : { makePorts: (runId) => {
2137
+ const makePorts = options.makePorts;
2138
+ if (makePorts === void 0) throw new Error("resume makePorts disappeared");
2139
+ return {
2140
+ ...makePorts(runId),
2141
+ screening: () => Promise.resolve([{
2142
+ a: row.a,
2143
+ b: row.b
2144
+ }])
2145
+ };
2146
+ } },
2147
+ ...options.timeoutMinutes === void 0 ? {} : { timeoutMinutes: options.timeoutMinutes }
2148
+ });
2149
+ if (row.state !== "fuse" && row.state !== "approved") return {
2150
+ ok: false,
2151
+ message: "row is not resumable from state " + row.state
2152
+ };
2153
+ const decision = row.decision;
2154
+ if (decision === void 0) return {
2155
+ ok: false,
2156
+ message: "fuse row has no decision"
2157
+ };
2158
+ return await withMergeRun({
2159
+ io: options.io,
2160
+ home: options.home,
2161
+ registry: options.registry,
2162
+ command: "resume",
2163
+ approval: {
2164
+ platformWrite: options.platformWrite,
2165
+ mergeApproval: true
2166
+ },
2167
+ now: options.now,
2168
+ async body(ctx) {
2169
+ await ctx.stage("author", {
2170
+ done: 0,
2171
+ total: 1
2172
+ });
2173
+ let outcome;
2174
+ try {
2175
+ outcome = options.round2WithRun === void 0 ? await options.ports.round2(row, decision, ctx.signal) : await options.round2WithRun(row, decision, ctx.signal, ctx.runId);
2176
+ } catch (error) {
2177
+ outcome = {
2178
+ kind: "failed",
2179
+ failure: {
2180
+ stage: "author",
2181
+ code: "merge-failed",
2182
+ message: error instanceof Error ? error.message : String(error),
2183
+ retryable: true,
2184
+ retryFrom: "agent2"
2185
+ }
2186
+ };
2187
+ }
2188
+ const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome);
2189
+ return {
2190
+ stopReason: record.merged ? "completed" : "failed",
2191
+ scope: {
2192
+ candidatePairs: 1,
2193
+ queueBefore: {
2194
+ verify: 0,
2195
+ pending: 0,
2196
+ fuse: 1,
2197
+ skipped: 0
2198
+ }
2199
+ },
2200
+ pairs: [record.pair],
2201
+ totals: record.merged ? {
2202
+ merged: 1,
2203
+ kept: 0,
2204
+ pending: 0,
2205
+ failed: 0
2206
+ } : {
2207
+ merged: 0,
2208
+ kept: 0,
2209
+ pending: 0,
2210
+ failed: 1
2211
+ },
2212
+ notes: []
2213
+ };
1817
2214
  }
1818
- } catch {}
1819
- return null;
2215
+ });
1820
2216
  }
1821
2217
  //#endregion
1822
2218
  //#region lib/types/undo-merge.js
@@ -1900,7 +2296,7 @@ async function mergeDoctor(io, home, configuredRoot) {
1900
2296
  key: "state",
1901
2297
  label: "合并状态",
1902
2298
  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)
2299
+ detail: loaded.note ?? "pairs " + String(loaded.state.rows.length) + ";verify " + String(counts.verify) + " / pending " + String(counts.pending) + " / fuse " + String(counts.fuse) + " / skipped " + String(counts.skipped)
1904
2300
  });
1905
2301
  const modelRoot = configuredRoot !== void 0 && configuredRoot !== "" ? configuredRoot : process.env.SKILL_MERGE_MODELS_ROOT;
1906
2302
  if (modelRoot === void 0 || modelRoot === "") rows.push({
@@ -2494,6 +2890,17 @@ async function assembleStagedCommit(io, home, runId, port, options) {
2494
2890
  });
2495
2891
  }
2496
2892
  //#endregion
2893
+ //#region lib/types/domain/commit-journal-store.js
2894
+ /** Durable commit journal: <home>/merge/journals/<run>.json. */
2895
+ function journalPath(home, runId) {
2896
+ return join(home, "merge", "journals", runId.replace(/[^A-Za-z0-9_-]+/g, "_") + ".json");
2897
+ }
2898
+ async function writeJournal(io, home, journal) {
2899
+ const path = journalPath(home, journal.runId);
2900
+ await io.writeText(path, JSON.stringify(journal, null, 2));
2901
+ return path;
2902
+ }
2903
+ //#endregion
2497
2904
  //#region lib/types/commit-execute.js
2498
2905
  async function commitStagedRun(io, home, runId, port, options) {
2499
2906
  const assembled = await assembleStagedCommit(io, home, runId, port, {
@@ -2504,12 +2911,32 @@ async function commitStagedRun(io, home, runId, port, options) {
2504
2911
  ok: false,
2505
2912
  message: assembled.message
2506
2913
  };
2507
- const result = await port.commitMerge(assembled.plan);
2508
- if (!result.ok) return {
2509
- ok: false,
2510
- message: result.message,
2511
- plan: assembled.plan
2914
+ const journal = {
2915
+ runId,
2916
+ target: assembled.plan.target,
2917
+ create: !assembled.plan.targetExists,
2918
+ sources: assembled.plan.sources,
2919
+ phase: "prepared",
2920
+ updatedAt: new Date(options.now()).toISOString()
2512
2921
  };
2922
+ try {
2923
+ await writeJournal(io, home, journal);
2924
+ } catch {}
2925
+ const result = await port.commitMerge(assembled.plan);
2926
+ if (!result.ok) {
2927
+ try {
2928
+ await writeJournal(io, home, {
2929
+ ...journal,
2930
+ phase: "failed",
2931
+ updatedAt: new Date(options.now()).toISOString()
2932
+ });
2933
+ } catch {}
2934
+ return {
2935
+ ok: false,
2936
+ message: result.message,
2937
+ plan: assembled.plan
2938
+ };
2939
+ }
2513
2940
  if (result.unitId === void 0) return {
2514
2941
  ok: true,
2515
2942
  plan: assembled.plan,
@@ -2523,11 +2950,20 @@ async function commitStagedRun(io, home, runId, port, options) {
2523
2950
  target: assembled.plan.target,
2524
2951
  create: !assembled.plan.targetExists,
2525
2952
  sources: assembled.plan.sources,
2953
+ ...assembled.plan.sources[0] === void 0 ? {} : { pairKey: pairKey(assembled.plan.target, assembled.plan.sources[0]) },
2526
2954
  archived: result.archived ?? [],
2527
2955
  at: options.now()
2528
2956
  };
2529
2957
  try {
2530
2958
  await writeCommitReceipt(io, home, receipt);
2959
+ try {
2960
+ await writeJournal(io, home, {
2961
+ ...journal,
2962
+ unitId: result.unitId,
2963
+ phase: "committed",
2964
+ updatedAt: new Date(options.now()).toISOString()
2965
+ });
2966
+ } catch {}
2531
2967
  return {
2532
2968
  ok: true,
2533
2969
  plan: assembled.plan,
@@ -2902,11 +3338,12 @@ function createNewChildRound2(options) {
2902
3338
  //#endregion
2903
3339
  //#region lib/types/adapters/library.js
2904
3340
  function skillLibraryPort(skills) {
3341
+ const archivedNames = skills.archivedNames;
2905
3342
  return {
2906
3343
  readSkill: (name) => skills.read(name),
2907
3344
  isArchived: async (name) => {
2908
- if (typeof skills.archivedNames !== "function") return false;
2909
- const names = await skills.archivedNames();
3345
+ if (typeof archivedNames !== "function") return false;
3346
+ const names = await archivedNames.call(skills);
2910
3347
  return names === null ? true : names.includes(name);
2911
3348
  },
2912
3349
  commitMerge: (plan) => skills.commitMerge(plan)
@@ -2941,6 +3378,7 @@ function createMergeRuntime(deps) {
2941
3378
  };
2942
3379
  return { async execute(command, context) {
2943
3380
  const parent = context?.parent ?? deps.parent;
3381
+ const detached = deps.detached !== false;
2944
3382
  if (command.verb === "pair") {
2945
3383
  const signal = new AbortController().signal;
2946
3384
  const cards = await deps.cards(signal);
@@ -2948,33 +3386,62 @@ function createMergeRuntime(deps) {
2948
3386
  a: command.a,
2949
3387
  b: command.b
2950
3388
  }];
3389
+ let forced;
3390
+ if (command.force === true) {
3391
+ const key = pairKey(command.a, command.b);
3392
+ forced = (await loadMergeState(deps.io, deps.home)).state.rows.find((row) => row.pairKey === key);
3393
+ if (forced === void 0 || forced.decision === void 0) return {
3394
+ ok: false,
3395
+ text: "pair --force 需要已有决策,找不到该 pair 的 decision: " + key
3396
+ };
3397
+ }
3398
+ const approval = command.force === true ? false : deps.params.mergeApproval;
2951
3399
  const result = await runScanCommand({
2952
3400
  ...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
- })
3401
+ approval,
3402
+ ...detached ? { detached: true } : {},
3403
+ makePorts: (runId) => {
3404
+ const ports = createChannelScanPorts({
3405
+ io: deps.io,
3406
+ home: deps.home,
3407
+ runId,
3408
+ cards,
3409
+ screening: () => Promise.resolve(pair),
3410
+ channel: deps.channel,
3411
+ parent,
3412
+ allow: deps.allow,
3413
+ ...deps.cwd === void 0 ? {} : { cwd: deps.cwd },
3414
+ timeoutMs: deps.params.mergePairTimeoutMinutes * 6e4,
3415
+ approval,
3416
+ commitPort: skillLibraryPort(deps.skills),
3417
+ now: deps.now,
3418
+ ...stageHooks
3419
+ });
3420
+ return forced === void 0 ? ports : {
3421
+ ...ports,
3422
+ round1: () => Promise.resolve({
3423
+ kind: "merge",
3424
+ decision: forced.decision
3425
+ })
3426
+ };
3427
+ }
2970
3428
  });
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
- } : {
3429
+ if (!result.ok) return {
2975
3430
  ok: false,
2976
3431
  text: result.message
2977
3432
  };
3433
+ if ("accepted" in result) return {
3434
+ ok: true,
3435
+ text: "merge pair 已启动:" + result.runId
3436
+ };
3437
+ if (!("report" in result)) return {
3438
+ ok: false,
3439
+ text: "merge pair started without a report"
3440
+ };
3441
+ return {
3442
+ ok: true,
3443
+ text: "merge pair 完成:merged " + String(result.report.totals.merged) + " / pending " + String(result.report.totals.pending) + " / failed " + String(result.report.totals.failed)
3444
+ };
2978
3445
  }
2979
3446
  if (command.verb === "scan") {
2980
3447
  const signal = new AbortController().signal;
@@ -2985,6 +3452,7 @@ function createMergeRuntime(deps) {
2985
3452
  const result = await runScanCommand({
2986
3453
  ...common,
2987
3454
  approval: deps.params.mergeApproval,
3455
+ ...detached ? { detached: true } : {},
2988
3456
  makePorts: (runId) => createChannelScanPorts({
2989
3457
  io: deps.io,
2990
3458
  home: deps.home,
@@ -3010,13 +3478,22 @@ function createMergeRuntime(deps) {
3010
3478
  ...stageHooks
3011
3479
  })
3012
3480
  });
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
- } : {
3481
+ if (!result.ok) return {
3017
3482
  ok: false,
3018
3483
  text: result.message
3019
3484
  };
3485
+ if ("accepted" in result) return {
3486
+ ok: true,
3487
+ text: "merge scan 已启动:" + result.runId
3488
+ };
3489
+ if (!("report" in result)) return {
3490
+ ok: false,
3491
+ text: "merge scan started without a report"
3492
+ };
3493
+ return {
3494
+ ok: true,
3495
+ text: "merge scan 完成:merged " + String(result.report.totals.merged) + " / pending " + String(result.report.totals.pending) + " / failed " + String(result.report.totals.failed)
3496
+ };
3020
3497
  }
3021
3498
  if (command.verb === "reject") {
3022
3499
  const row = (await loadMergeState(deps.io, deps.home)).state.rows.find((item) => item.id === command.id);
@@ -3087,6 +3564,22 @@ function createMergeRuntime(deps) {
3087
3564
  text: report.value.slice(0, 12e3)
3088
3565
  };
3089
3566
  }
3567
+ if (command.verb === "discard") {
3568
+ const row = (await loadMergeState(deps.io, deps.home)).state.rows.find((item) => item.id === command.id);
3569
+ if (row === void 0) return {
3570
+ ok: false,
3571
+ text: "merge row not found: " + command.id
3572
+ };
3573
+ if (row.state !== "pending" && row.state !== "approved" && row.state !== "fuse") return {
3574
+ ok: false,
3575
+ text: "merge row is not discardable: " + command.id
3576
+ };
3577
+ await dropRow(deps.io, deps.home, deps.now, row.id);
3578
+ return {
3579
+ ok: true,
3580
+ text: "已丢弃合并行:" + command.id
3581
+ };
3582
+ }
3090
3583
  if (command.verb === "undo") {
3091
3584
  const receipt = await readCommitReceipt(deps.io, deps.home, command.run, command.target);
3092
3585
  if (receipt === null) return {
@@ -3098,6 +3591,12 @@ function createMergeRuntime(deps) {
3098
3591
  text: "receipt target is " + receipt.target + ", not " + command.target
3099
3592
  };
3100
3593
  const result = await undoMergeReceipt(receipt, skillLibraryUndoPort(deps.skills));
3594
+ if (result.ok && receipt.pairKey !== void 0) await recordPair(deps.io, deps.home, {
3595
+ pairKey: receipt.pairKey,
3596
+ runId: receipt.parentRunId ?? receipt.runId,
3597
+ result: "undone",
3598
+ target: receipt.target
3599
+ });
3101
3600
  return {
3102
3601
  ok: result.ok,
3103
3602
  text: result.message
@@ -3129,7 +3628,7 @@ function createMergeRuntime(deps) {
3129
3628
  };
3130
3629
  }
3131
3630
  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);
3631
+ const row = command.id === void 0 ? loaded.state.rows.find((item) => item.state === "fuse" || item.state === "verify" || command.retry === true && item.state === "approved") : loaded.state.rows.find((item) => item.id === command.id);
3133
3632
  if (row === void 0) return {
3134
3633
  ok: false,
3135
3634
  text: "merge row not found: " + (command.id ?? "(auto)")
@@ -3188,13 +3687,22 @@ function createMergeRuntime(deps) {
3188
3687
  ...stageHooks
3189
3688
  })
3190
3689
  });
3191
- return result.ok ? {
3192
- ok: true,
3193
- text: "merge resume 完成:merged " + String(result.report.totals.merged)
3194
- } : {
3690
+ if (!result.ok) return {
3195
3691
  ok: false,
3196
3692
  text: result.message
3197
3693
  };
3694
+ if ("accepted" in result) return {
3695
+ ok: true,
3696
+ text: "merge resume 已启动:" + result.runId
3697
+ };
3698
+ if (!("report" in result)) return {
3699
+ ok: false,
3700
+ text: "merge resume started without a report"
3701
+ };
3702
+ return {
3703
+ ok: true,
3704
+ text: "merge resume 完成:merged " + String(result.report.totals.merged)
3705
+ };
3198
3706
  }
3199
3707
  if (command.verb === "approve") {
3200
3708
  const signal = new AbortController().signal;
@@ -3215,13 +3723,22 @@ function createMergeRuntime(deps) {
3215
3723
  now: deps.now
3216
3724
  }) }
3217
3725
  });
3218
- return result.ok ? {
3219
- ok: true,
3220
- text: "merge approve 完成:merged " + String(result.report.totals.merged)
3221
- } : {
3726
+ if (!result.ok) return {
3222
3727
  ok: false,
3223
3728
  text: result.message
3224
3729
  };
3730
+ if ("accepted" in result) return {
3731
+ ok: true,
3732
+ text: "merge approve 已启动:" + result.runId
3733
+ };
3734
+ if (!("report" in result)) return {
3735
+ ok: false,
3736
+ text: "merge approve started without a report"
3737
+ };
3738
+ return {
3739
+ ok: true,
3740
+ text: "merge approve 完成:merged " + String(result.report.totals.merged)
3741
+ };
3225
3742
  }
3226
3743
  const text = await handleReadOnlyMergeCommand(command, deps.io, deps.home);
3227
3744
  if (text !== null) return {
@@ -3672,6 +4189,9 @@ function apply(ctx, rawConfig = {}) {
3672
4189
  if (registry === void 0) return;
3673
4190
  const io = evolutionIoAdapter(() => registry.provider());
3674
4191
  const home = evolutionHome();
4192
+ loadMergeState(io, home).then((loaded) => cleanupExpiredStaging(io, home, Date.now(), new Set(loaded.state.rows.map((row) => row.runId).filter((id) => id !== void 0)))).catch((error) => {
4193
+ ctx.logger.warn("merge staging cleanup failed: " + (error instanceof Error ? error.message : String(error)));
4194
+ });
3675
4195
  const runs = newRunRegistry({
3676
4196
  io,
3677
4197
  home,
@@ -3707,8 +4227,11 @@ function apply(ctx, rawConfig = {}) {
3707
4227
  io,
3708
4228
  home,
3709
4229
  engine: { screen },
3710
- select: selectCandidates,
3711
- ...engineConfig === void 0 ? {} : { cfg: engineConfig },
4230
+ select: selectWithErrors,
4231
+ cfg: {
4232
+ ...engineConfig ?? {},
4233
+ judge: "deepseek"
4234
+ },
3712
4235
  ...runner === void 0 ? {} : { runner },
3713
4236
  async onSelection(selection) {
3714
4237
  const loaded = await loadMergeState(io, home);
@@ -3736,6 +4259,40 @@ function apply(ctx, rawConfig = {}) {
3736
4259
  runs() {
3737
4260
  return Promise.resolve(runs.runs());
3738
4261
  },
4262
+ async body(id, side) {
4263
+ const row = (await loadMergeState(io, home)).state.rows.find((item) => item.id === id);
4264
+ if (row === void 0) return null;
4265
+ return await skills.read(side === "a" ? row.a : row.b);
4266
+ },
4267
+ async history() {
4268
+ const receipts = await readCommitReceipts(io, home);
4269
+ const items = /* @__PURE__ */ new Map();
4270
+ for (const receipt of receipts) for (const source of receipt.sources) {
4271
+ const key = source + ":" + receipt.runId;
4272
+ if (items.has(key)) continue;
4273
+ items.set(key, {
4274
+ name: source,
4275
+ kind: "merged",
4276
+ reason: "Merged into " + receipt.target,
4277
+ lastVersion: 1,
4278
+ archivedAt: new Date(receipt.at).toISOString(),
4279
+ sourceRunId: receipt.runId
4280
+ });
4281
+ }
4282
+ const archived = await skills.archivedNames?.() ?? [];
4283
+ const seen = new Set([...items.values()].map((item) => item.name));
4284
+ for (const name of archived) {
4285
+ if (seen.has(name)) continue;
4286
+ items.set("archived:" + name, {
4287
+ name,
4288
+ kind: "other",
4289
+ reason: "archived",
4290
+ lastVersion: 1,
4291
+ archivedAt: new Date(Date.now()).toISOString()
4292
+ });
4293
+ }
4294
+ return [...items.values()];
4295
+ },
3739
4296
  approval() {
3740
4297
  return params.mergeApproval;
3741
4298
  },
@@ -3811,4 +4368,4 @@ function apply(ctx, rawConfig = {}) {
3811
4368
  });
3812
4369
  }
3813
4370
  //#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 };
4371
+ 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, cleanupExpiredStaging, 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, readCommitReceipts, 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 };