@lmzhen/dsh-evolution-merge 0.30.17 → 0.30.18

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 (90) hide show
  1. package/README.md +4 -3
  2. package/lib/index.js +2321 -854
  3. package/lib/types/adapters/library.js +4 -1
  4. package/lib/types/adapters/model.d.ts +31 -0
  5. package/lib/types/adapters/model.js +46 -0
  6. package/lib/types/approve-command.d.ts +2 -0
  7. package/lib/types/approve-command.js +41 -46
  8. package/lib/types/authoring-flow.d.ts +3 -1
  9. package/lib/types/authoring-flow.js +15 -5
  10. package/lib/types/command-surface.d.ts +5 -0
  11. package/lib/types/command-surface.js +44 -0
  12. package/lib/types/commit-assembly.d.ts +2 -0
  13. package/lib/types/commit-assembly.js +7 -2
  14. package/lib/types/commit-execute.d.ts +7 -1
  15. package/lib/types/commit-execute.js +40 -10
  16. package/lib/types/commit-recovery.d.ts +7 -2
  17. package/lib/types/commit-recovery.js +73 -96
  18. package/lib/types/contracts.d.ts +1 -1
  19. package/lib/types/contracts.js +1 -1
  20. package/lib/types/coordinator.d.ts +3 -2
  21. package/lib/types/coordinator.js +4 -3
  22. package/lib/types/decision.js +1 -1
  23. package/lib/types/doctor.d.ts +7 -0
  24. package/lib/types/doctor.js +104 -4
  25. package/lib/types/domain/claim.d.ts +4 -1
  26. package/lib/types/domain/claim.js +2 -2
  27. package/lib/types/domain/commit-journal-store.js +5 -1
  28. package/lib/types/domain/commit-journal.js +1 -1
  29. package/lib/types/domain/consistency.d.ts +22 -0
  30. package/lib/types/domain/consistency.js +50 -0
  31. package/lib/types/domain/ledger.js +10 -1
  32. package/lib/types/domain/migrate-v3.d.ts +20 -0
  33. package/lib/types/domain/migrate-v3.js +68 -0
  34. package/lib/types/domain/row-tx.d.ts +27 -0
  35. package/lib/types/domain/row-tx.js +31 -0
  36. package/lib/types/domain/row-v3-bridge.d.ts +6 -0
  37. package/lib/types/domain/row-v3-bridge.js +47 -0
  38. package/lib/types/domain/row-v3.d.ts +113 -0
  39. package/lib/types/domain/row-v3.js +107 -0
  40. package/lib/types/domain/row.d.ts +1 -0
  41. package/lib/types/domain/staging-index.d.ts +14 -0
  42. package/lib/types/domain/staging-index.js +41 -0
  43. package/lib/types/domain/state-v3.d.ts +15 -0
  44. package/lib/types/domain/state-v3.js +80 -0
  45. package/lib/types/facts.d.ts +24 -0
  46. package/lib/types/facts.js +68 -0
  47. package/lib/types/gate-filter.d.ts +31 -0
  48. package/lib/types/gate-filter.js +64 -0
  49. package/lib/types/index.d.ts +5 -2
  50. package/lib/types/index.js +16 -26
  51. package/lib/types/ladder.js +1 -1
  52. package/lib/types/merge-routes.d.ts +2 -2
  53. package/lib/types/merge-routes.js +21 -22
  54. package/lib/types/metrics.d.ts +16 -0
  55. package/lib/types/metrics.js +27 -0
  56. package/lib/types/new-child-authoring.d.ts +3 -1
  57. package/lib/types/new-child-authoring.js +14 -3
  58. package/lib/types/pending-actions.d.ts +2 -2
  59. package/lib/types/pending-actions.js +17 -17
  60. package/lib/types/propose.d.ts +26 -0
  61. package/lib/types/propose.js +69 -0
  62. package/lib/types/read-commands.d.ts +2 -2
  63. package/lib/types/read-commands.js +5 -3
  64. package/lib/types/resume-command.d.ts +2 -0
  65. package/lib/types/resume-command.js +4 -1
  66. package/lib/types/round2-outcome.js +17 -9
  67. package/lib/types/runtime.d.ts +4 -1
  68. package/lib/types/runtime.js +99 -29
  69. package/lib/types/scan-ports.d.ts +3 -1
  70. package/lib/types/scan-ports.js +2 -0
  71. package/lib/types/scan-run.js +114 -51
  72. package/lib/types/screening-port.d.ts +47 -0
  73. package/lib/types/screening-port.js +93 -0
  74. package/lib/types/screening.d.ts +30 -0
  75. package/lib/types/screening.js +75 -0
  76. package/lib/types/state.d.ts +14 -0
  77. package/lib/types/state.js +70 -1
  78. package/lib/types/status.d.ts +3 -0
  79. package/lib/types/status.js +17 -0
  80. package/lib/types/target-claim.d.ts +1 -0
  81. package/lib/types/target-claim.js +63 -18
  82. package/lib/types/unit-claim.d.ts +5 -0
  83. package/lib/types/unit-claim.js +15 -0
  84. package/lib/types/wire-projection.d.ts +36 -0
  85. package/lib/types/wire-projection.js +69 -0
  86. package/lib/types/wire.d.ts +254 -0
  87. package/lib/types/wire.js +3 -0
  88. package/package.json +8 -4
  89. package/lib/types/routes.d.ts +0 -70
  90. package/lib/types/routes.js +0 -78
package/lib/index.js CHANGED
@@ -1,9 +1,10 @@
1
- import { join, resolve, sep } from "node:path";
1
+ import { dirname, join } from "node:path";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { pairKeyOf, screen, selectCandidates, toEngineCandidate } from "@lmzhen/dsh-evolution-merge-engine";
4
- import { DEFAULT_SKILL_LIMITS, authorityFrom, contentHash, createSubagentChannel, evolutionHome, evolutionIoAdapter, executeUnitUndo, isPresent, newRunRegistry, newSkillLibrary, parseFrontmatter, planUnitUndoAcross, policyStageLimits, probeAbsent, probeList, probePresent, probeText, probeUnknown, scanContentThreats, skillsRoot, supportRefs, transactIo, validateFrontmatter, validateSupportPath } from "@lmzhen/dsh-evolution-core";
4
+ import { DEFAULT_SKILL_LIMITS, SkillLibrary, UnitTransactions, authorityFrom, contentHash, createSubagentChannel, evolutionHome, evolutionIoAdapter, executeUnitUndo, isPresent, isProcessAlive, newRunRegistry, newSkillLibrary, parseFrontmatter, planUnitUndoAcross, policyStageLimits, probeAbsent, probeList, probePresent, probeText, probeUnknown, readTreeLock, readUnitEvidence, runsFile, scanContentThreats, skillLibraryUnitBackend, 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, randomUUID } from "node:crypto";
7
+ import { mkdir, open, readFile, unlink } from "node:fs/promises";
7
8
  //#region lib/types/adapters/select.js
8
9
  /** Host selector correction: engine selector does not route judge errors to candidates. */
9
10
  function selectWithErrors(report) {
@@ -132,7 +133,7 @@ function createEngineRunner(llm, route) {
132
133
  * The data layer keeps four states; the user face collapses verify/fuse into one retry
133
134
  * queue and keeps the stage as a chip. The API queue must never expose the raw state.
134
135
  */
135
- const MERGE_STATE_VERSION = 2;
136
+ const MERGE_STATE_VERSION = 3;
136
137
  const MERGE_PAIR_STATES = [
137
138
  "verify",
138
139
  "pending",
@@ -181,7 +182,7 @@ function rowIdOf(key) {
181
182
  }
182
183
  function emptyMergeState() {
183
184
  return {
184
- schemaVersion: 2,
185
+ schemaVersion: 3,
185
186
  rows: []
186
187
  };
187
188
  }
@@ -218,6 +219,396 @@ function migrateV1Row(row) {
218
219
  };
219
220
  }
220
221
  //#endregion
222
+ //#region lib/types/domain/row-v3.js
223
+ function bad$1(code, message) {
224
+ return {
225
+ ok: false,
226
+ code,
227
+ message
228
+ };
229
+ }
230
+ function advance(row, at, patch) {
231
+ return {
232
+ ...row,
233
+ ...patch,
234
+ version: row.version + 1,
235
+ updatedAt: at
236
+ };
237
+ }
238
+ function transitionV3(row, event, expectedVersion, at = (/* @__PURE__ */ new Date()).toISOString()) {
239
+ if (row.version !== expectedVersion) return bad$1("version-conflict", "expected " + String(expectedVersion) + ", got " + String(row.version));
240
+ switch (row.state) {
241
+ case "verify":
242
+ if (event.type === "verify-passed") return {
243
+ ok: true,
244
+ row: advance(row, at, {
245
+ state: "pending",
246
+ target: event.target,
247
+ decision: event.decision,
248
+ sourceRevision: event.sourceRevision,
249
+ attempts: 0
250
+ }),
251
+ version: expectedVersion + 1
252
+ };
253
+ if (event.type === "auto-approved") return {
254
+ ok: true,
255
+ row: advance(row, at, {
256
+ state: "approved",
257
+ target: event.target,
258
+ decision: event.decision,
259
+ attempts: 0,
260
+ ...event.runId === void 0 ? {} : { runId: event.runId }
261
+ }),
262
+ version: expectedVersion + 1
263
+ };
264
+ if (event.type === "failed") return {
265
+ ok: true,
266
+ row: advance(row, at, {
267
+ failure: event.failure,
268
+ attempts: row.attempts + 1
269
+ }),
270
+ version: expectedVersion + 1
271
+ };
272
+ if (event.type === "skipped") return {
273
+ ok: true,
274
+ row: advance(row, at, {
275
+ state: "skipped",
276
+ reason: event.reason,
277
+ attempts: row.attempts
278
+ }),
279
+ version: expectedVersion + 1
280
+ };
281
+ if (event.type === "superseded") return {
282
+ ok: true,
283
+ row: null,
284
+ version: expectedVersion + 1
285
+ };
286
+ return bad$1("illegal-transition", "verify/" + event.type);
287
+ case "pending":
288
+ if (event.type === "approved") return {
289
+ ok: true,
290
+ row: advance(row, at, {
291
+ state: "approved",
292
+ runId: event.runId
293
+ }),
294
+ version: expectedVersion + 1
295
+ };
296
+ if (event.type === "auto-approved") return {
297
+ ok: true,
298
+ row: advance(row, at, {
299
+ state: "approved",
300
+ ...event.runId === void 0 ? {} : { runId: event.runId }
301
+ }),
302
+ version: expectedVersion + 1
303
+ };
304
+ if (event.type === "staged") return {
305
+ ok: true,
306
+ row: advance(row, at, {
307
+ state: "fuse",
308
+ staging: event.ref === void 0 ? { kind: "absent" } : {
309
+ kind: "present",
310
+ ref: event.ref,
311
+ expiresAt: event.expiresAt ?? Date.now() + 7 * 864e5
312
+ }
313
+ }),
314
+ version: expectedVersion + 1
315
+ };
316
+ if (event.type === "skipped") return {
317
+ ok: true,
318
+ row: advance(row, at, {
319
+ state: "skipped",
320
+ reason: event.reason,
321
+ ...event.origin === void 0 ? {} : { origin: event.origin }
322
+ }),
323
+ version: expectedVersion + 1
324
+ };
325
+ if (event.type === "expired") return {
326
+ ok: true,
327
+ row: advance(row, at, { expired: event.kind }),
328
+ version: expectedVersion + 1
329
+ };
330
+ if (event.type === "failed") return {
331
+ ok: true,
332
+ row: advance(row, at, {
333
+ failure: event.failure,
334
+ attempts: row.attempts + 1
335
+ }),
336
+ version: expectedVersion + 1
337
+ };
338
+ if (event.type === "superseded") return {
339
+ ok: true,
340
+ row: null,
341
+ version: expectedVersion + 1
342
+ };
343
+ return bad$1("illegal-transition", "pending/" + event.type);
344
+ case "approved":
345
+ if (event.type === "staged") return {
346
+ ok: true,
347
+ row: advance(row, at, {
348
+ state: "fuse",
349
+ staging: event.ref === void 0 ? { kind: "absent" } : {
350
+ kind: "present",
351
+ ref: event.ref,
352
+ expiresAt: event.expiresAt ?? Date.now() + 7 * 864e5
353
+ }
354
+ }),
355
+ version: expectedVersion + 1
356
+ };
357
+ if (event.type === "committed") return {
358
+ ok: true,
359
+ row: null,
360
+ version: expectedVersion + 1
361
+ };
362
+ if (event.type === "skipped") return {
363
+ ok: true,
364
+ row: advance(row, at, {
365
+ state: "skipped",
366
+ reason: event.reason
367
+ }),
368
+ version: expectedVersion + 1
369
+ };
370
+ if (event.type === "failed") return event.failure.retryable ? {
371
+ ok: true,
372
+ row: advance(row, at, {
373
+ state: "pending",
374
+ failure: event.failure,
375
+ attempts: row.attempts + 1
376
+ }),
377
+ version: expectedVersion + 1
378
+ } : {
379
+ ok: true,
380
+ row: advance(row, at, {
381
+ state: "skipped",
382
+ reason: event.failure.code,
383
+ failure: event.failure,
384
+ attempts: row.attempts + 1
385
+ }),
386
+ version: expectedVersion + 1
387
+ };
388
+ if (event.type === "superseded") return {
389
+ ok: true,
390
+ row: null,
391
+ version: expectedVersion + 1
392
+ };
393
+ return bad$1("illegal-transition", "approved/" + event.type);
394
+ case "fuse":
395
+ if (event.type === "staged") return {
396
+ ok: true,
397
+ row: advance(row, at, { staging: event.ref === void 0 ? { kind: "absent" } : {
398
+ kind: "present",
399
+ ref: event.ref,
400
+ expiresAt: event.expiresAt ?? Date.now() + 7 * 864e5
401
+ } }),
402
+ version: expectedVersion + 1
403
+ };
404
+ if (event.type === "committed") return {
405
+ ok: true,
406
+ row: null,
407
+ version: expectedVersion + 1
408
+ };
409
+ if (event.type === "skipped") return {
410
+ ok: true,
411
+ row: advance(row, at, {
412
+ state: "skipped",
413
+ reason: event.reason
414
+ }),
415
+ version: expectedVersion + 1
416
+ };
417
+ if (event.type === "failed") return event.failure.retryable ? {
418
+ ok: true,
419
+ row: advance(row, at, {
420
+ state: "pending",
421
+ failure: event.failure,
422
+ attempts: row.attempts + 1
423
+ }),
424
+ version: expectedVersion + 1
425
+ } : {
426
+ ok: true,
427
+ row: advance(row, at, {
428
+ state: "skipped",
429
+ reason: event.failure.code,
430
+ failure: event.failure,
431
+ attempts: row.attempts + 1
432
+ }),
433
+ version: expectedVersion + 1
434
+ };
435
+ if (event.type === "superseded") return {
436
+ ok: true,
437
+ row: null,
438
+ version: expectedVersion + 1
439
+ };
440
+ return bad$1("illegal-transition", "fuse/" + event.type);
441
+ case "skipped":
442
+ if (event.type === "restored" || event.type === "superseded") return {
443
+ ok: true,
444
+ row: null,
445
+ version: expectedVersion + 1
446
+ };
447
+ return bad$1("illegal-transition", "skipped is terminal");
448
+ }
449
+ }
450
+ function object(value) {
451
+ return value !== null && typeof value === "object";
452
+ }
453
+ function string(value) {
454
+ return typeof value === "string" && value.length > 0;
455
+ }
456
+ function number(value) {
457
+ return typeof value === "number" && Number.isFinite(value);
458
+ }
459
+ /** Runtime validation for a persisted v3 row. */
460
+ function isV3Row(value) {
461
+ if (!object(value)) return false;
462
+ if (!string(value.id) || !string(value.pairKey) || !string(value.a) || !string(value.b) || !number(value.version)) return false;
463
+ if (!string(value.createdAt) || !string(value.updatedAt)) return false;
464
+ if (value.origin !== "agent" && value.origin !== "human" && value.origin !== "system") return false;
465
+ if (value.failure !== void 0 && !object(value.failure)) return false;
466
+ if (!number(value.attempts)) return false;
467
+ switch (value.state) {
468
+ case "verify": return value.verifySubState === "candidate" || value.verifySubState === "screened" || value.verifySubState === "unverified";
469
+ case "pending": return string(value.target) && object(value.decision);
470
+ case "approved": return string(value.target) && object(value.decision);
471
+ case "fuse":
472
+ if (!string(value.target) || !object(value.decision) || !object(value.staging)) return false;
473
+ if (value.staging.kind === "present") return string(value.staging.ref) && number(value.staging.expiresAt);
474
+ if (value.staging.kind === "expired") return number(value.staging.expiresAt);
475
+ return value.staging.kind === "absent";
476
+ case "skipped": return string(value.reason);
477
+ default: return false;
478
+ }
479
+ }
480
+ //#endregion
481
+ //#region lib/types/domain/row-v3-bridge.js
482
+ function v3ToLegacy(row) {
483
+ const common = {
484
+ id: row.id,
485
+ pairKey: row.pairKey,
486
+ a: row.a,
487
+ b: row.b,
488
+ state: row.state,
489
+ queue: row.state === "verify" ? "verify" : row.state === "pending" ? "pending" : row.state === "skipped" ? "skipped" : "fuse",
490
+ version: row.version,
491
+ origin: row.origin,
492
+ ...row.runId === void 0 ? {} : { runId: row.runId },
493
+ ...row.failure === void 0 ? {} : { failure: row.failure },
494
+ createdAt: row.createdAt,
495
+ updatedAt: row.updatedAt
496
+ };
497
+ if (row.state === "verify") return {
498
+ ...common,
499
+ ...row.sourceRevision === void 0 ? {} : { sourceRevision: row.sourceRevision },
500
+ attempts: row.attempts
501
+ };
502
+ if (row.state === "skipped") return {
503
+ ...common,
504
+ reason: row.reason,
505
+ queue: "skipped",
506
+ ...row.expiredAt === void 0 ? {} : { expired: "target-taken" },
507
+ attempts: row.attempts
508
+ };
509
+ const target = {
510
+ requested: row.target,
511
+ ...row.state === "fuse" ? { actual: row.target } : {}
512
+ };
513
+ if (row.state === "fuse") return {
514
+ ...common,
515
+ target,
516
+ decision: row.decision,
517
+ attempts: row.attempts,
518
+ ...row.sourceRevision === void 0 ? {} : { sourceRevision: row.sourceRevision },
519
+ ...row.runId === void 0 ? {} : { runId: row.runId },
520
+ ...row.staging.kind === "present" ? {
521
+ stagingRef: row.staging.ref,
522
+ stagingExpiresAt: row.staging.expiresAt
523
+ } : row.staging.kind === "expired" ? {
524
+ stagingExpiresAt: row.staging.expiresAt,
525
+ ...row.staging.ref === void 0 ? {} : { stagingRef: row.staging.ref }
526
+ } : {}
527
+ };
528
+ return {
529
+ ...common,
530
+ target,
531
+ decision: row.decision,
532
+ attempts: row.attempts,
533
+ ...row.sourceRevision === void 0 ? {} : { sourceRevision: row.sourceRevision },
534
+ ...row.state === "pending" && row.targetRevision !== void 0 ? { targetRevision: row.targetRevision } : {},
535
+ ...row.state === "pending" && row.expired !== void 0 ? { expired: row.expired } : {},
536
+ ...row.state === "approved" && row.runId !== void 0 ? { runId: row.runId } : {}
537
+ };
538
+ }
539
+ function legacyToV3(row) {
540
+ const base = {
541
+ id: row.id,
542
+ pairKey: row.pairKey,
543
+ a: row.a,
544
+ b: row.b,
545
+ version: row.version ?? 0,
546
+ createdAt: row.createdAt,
547
+ updatedAt: row.updatedAt,
548
+ origin: row.origin ?? "system",
549
+ ...row.runId === void 0 ? {} : { runId: row.runId },
550
+ ...row.failure === void 0 ? {} : { failure: row.failure }
551
+ };
552
+ if (row.state === "verify") return {
553
+ ...base,
554
+ state: "verify",
555
+ verifySubState: "unverified",
556
+ attempts: row.attempts ?? 0,
557
+ ...row.sourceRevision === void 0 ? {} : { sourceRevision: row.sourceRevision }
558
+ };
559
+ if (row.state === "skipped") return {
560
+ ...base,
561
+ state: "skipped",
562
+ reason: row.reason ?? "skipped",
563
+ attempts: row.attempts ?? 0
564
+ };
565
+ const targetName = row.target?.requested ?? row.a;
566
+ const decision = row.decision ?? {
567
+ shouldMerge: false,
568
+ mode: "keep",
569
+ target: targetName,
570
+ sources: [row.b],
571
+ rationale: "legacy row without decision"
572
+ };
573
+ if (row.state === "pending") return {
574
+ ...base,
575
+ state: "pending",
576
+ target: targetName,
577
+ decision,
578
+ attempts: row.attempts ?? 0,
579
+ ...row.sourceRevision === void 0 ? {} : { sourceRevision: row.sourceRevision },
580
+ ...row.targetRevision === void 0 ? {} : { targetRevision: row.targetRevision },
581
+ ...row.expired === void 0 ? {} : { expired: row.expired }
582
+ };
583
+ if (row.state === "approved") return {
584
+ ...base,
585
+ state: "approved",
586
+ target: targetName,
587
+ decision,
588
+ attempts: row.attempts ?? 0,
589
+ ...row.sourceRevision === void 0 ? {} : { sourceRevision: row.sourceRevision },
590
+ ...row.runId === void 0 ? {} : { runId: row.runId }
591
+ };
592
+ const staging = row.stagingRef !== void 0 ? row.stagingExpiresAt !== void 0 ? {
593
+ kind: "present",
594
+ ref: row.stagingRef,
595
+ expiresAt: row.stagingExpiresAt
596
+ } : {
597
+ kind: "expired",
598
+ expiresAt: 0
599
+ } : { kind: "absent" };
600
+ return {
601
+ ...base,
602
+ state: "fuse",
603
+ target: targetName,
604
+ decision,
605
+ attempts: row.attempts ?? 0,
606
+ ...row.sourceRevision === void 0 ? {} : { sourceRevision: row.sourceRevision },
607
+ ...row.runId === void 0 ? {} : { runId: row.runId },
608
+ staging
609
+ };
610
+ }
611
+ //#endregion
221
612
  //#region lib/types/domain/ledger.js
222
613
  /** Pure v2 ledger normalization/projection. No IO. */
223
614
  function isRow(value) {
@@ -246,7 +637,7 @@ function parseLedger(raw) {
246
637
  };
247
638
  }
248
639
  const body = parsed;
249
- if (typeof body.schemaVersion === "number" && body.schemaVersion > 2) return {
640
+ if (typeof body.schemaVersion === "number" && body.schemaVersion > 3) return {
250
641
  ok: false,
251
642
  ledger: {
252
643
  schemaVersion: 2,
@@ -262,6 +653,23 @@ function parseLedger(raw) {
262
653
  },
263
654
  note: "rows missing"
264
655
  };
656
+ if (body.schemaVersion === 3) {
657
+ if (!body.rows.every(isV3Row)) return {
658
+ ok: false,
659
+ ledger: {
660
+ schemaVersion: 2,
661
+ rows: []
662
+ },
663
+ note: body.rows.some((row) => row !== null && typeof row === "object" && "pairKey" in row && "version" in row) ? "merge-state-needs-repair" : "merge-state-forward-only"
664
+ };
665
+ return {
666
+ ok: true,
667
+ ledger: {
668
+ schemaVersion: 2,
669
+ rows: body.rows.map(v3ToLegacy)
670
+ }
671
+ };
672
+ }
265
673
  const rows = [];
266
674
  let skipped = 0;
267
675
  for (const value of body.rows) {
@@ -292,6 +700,80 @@ function parseLedger(raw) {
292
700
  };
293
701
  }
294
702
  //#endregion
703
+ //#region lib/types/domain/row-tx.js
704
+ /** G3-5 RowTx pure write API: only createVerify/createSkipped/transition. */
705
+ function bad(code, message) {
706
+ return {
707
+ ok: false,
708
+ code,
709
+ message
710
+ };
711
+ }
712
+ function applyRowWrite(rows, write, at) {
713
+ if (write.kind === "transition") {
714
+ const index = rows.findIndex((row) => row.id === write.id);
715
+ if (index < 0) return bad("row-not-found", "row not found: " + write.id);
716
+ const current = rows[index];
717
+ const moved = transitionV3(current, write.event, write.expectedVersion, at);
718
+ if (!moved.ok) return bad(moved.code, moved.message);
719
+ if (moved.row === null) return {
720
+ ok: true,
721
+ rows: rows.filter((_, atIndex) => atIndex !== index),
722
+ row: null
723
+ };
724
+ const next = [...rows];
725
+ next[index] = moved.row;
726
+ return {
727
+ ok: true,
728
+ rows: next,
729
+ row: moved.row
730
+ };
731
+ }
732
+ if (write.row.version !== 0) return bad("row-invalid", "create requires version 0");
733
+ if (write.kind === "createVerify" && (write.row.state !== "verify" || write.row.verifySubState !== "unverified")) return bad("row-invalid", "createVerify requires verify/unverified");
734
+ if (write.kind === "createSkipped" && write.row.state !== "skipped") return bad("row-invalid", "createSkipped requires skipped");
735
+ if (!isV3Row(write.row)) return bad("row-invalid", "created row is invalid");
736
+ if (rows.some((row) => row.id === write.row.id)) return bad("row-duplicate", "duplicate row id: " + write.row.id);
737
+ return {
738
+ ok: true,
739
+ rows: [...rows, write.row],
740
+ row: write.row
741
+ };
742
+ }
743
+ //#endregion
744
+ //#region lib/types/domain/staging-index.js
745
+ /** Durable staging index for v3 pending/approved rows (rowId-keyed). */
746
+ function stagingIndexPath(home, rowId) {
747
+ return join(home, "merge", "staging-index", encodeURIComponent(rowId) + ".json");
748
+ }
749
+ async function readStagingIndex(io, home, rowId, now = Date.now()) {
750
+ let raw;
751
+ try {
752
+ raw = await io.readText(stagingIndexPath(home, rowId));
753
+ } catch {
754
+ return null;
755
+ }
756
+ if (raw === null) return null;
757
+ try {
758
+ const entry = JSON.parse(raw);
759
+ if (entry.rowId !== rowId || typeof entry.ref !== "string" || typeof entry.expiresAt !== "number") return null;
760
+ if (entry.expiresAt <= now) return null;
761
+ return entry;
762
+ } catch {
763
+ return null;
764
+ }
765
+ }
766
+ async function writeStagingIndex(io, home, entry) {
767
+ const text = JSON.stringify(entry, null, 2) + "\n";
768
+ if (io.transact !== void 0) await io.transact(stagingIndexPath(home, entry.rowId), () => text);
769
+ else await io.writeText(stagingIndexPath(home, entry.rowId), text);
770
+ }
771
+ async function deleteStagingIndex(io, home, rowId) {
772
+ try {
773
+ await io.remove(stagingIndexPath(home, rowId));
774
+ } catch {}
775
+ }
776
+ //#endregion
295
777
  //#region lib/types/state.js
296
778
  /**
297
779
  * The one merge-state ledger: <home>/merge/merge-state.json.
@@ -326,7 +808,7 @@ async function loadMergeState(io, home) {
326
808
  return {
327
809
  ok: false,
328
810
  state: {
329
- schemaVersion: 2,
811
+ schemaVersion: 3,
330
812
  rows: []
331
813
  }
332
814
  };
@@ -335,12 +817,68 @@ async function loadMergeState(io, home) {
335
817
  return {
336
818
  ok: parsed.ok,
337
819
  state: {
338
- schemaVersion: 2,
820
+ schemaVersion: 3,
339
821
  rows: parsed.rows
340
822
  },
341
823
  ...parsed.note === void 0 ? {} : { note: parsed.note }
342
824
  };
343
825
  }
826
+ /** Schema probe for doctor; the only other reader of mergeStatePath. */
827
+ async function loadStateSchema(io, home) {
828
+ try {
829
+ const raw = await io.readText(mergeStatePath(home));
830
+ if (raw === null) return null;
831
+ const parsed = JSON.parse(raw);
832
+ return typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : null;
833
+ } catch {
834
+ return null;
835
+ }
836
+ }
837
+ /** G3-5 runtime write API: only createVerify/createSkipped/transition. v2 writes are rejected. */
838
+ async function transactRows(io, home, fn) {
839
+ let result;
840
+ await transactIo(io, mergeStatePath(home), (current) => {
841
+ let rows = [];
842
+ if (current !== null) {
843
+ let parsed;
844
+ try {
845
+ parsed = JSON.parse(current);
846
+ } catch {
847
+ throw new Error("merge-state-needs-repair");
848
+ }
849
+ const body = parsed;
850
+ if (body.schemaVersion !== 3 || !Array.isArray(body.rows) || !body.rows.every(isV3Row)) throw new Error(body.schemaVersion === 2 ? "merge-state-migration-required" : "merge-state-needs-repair");
851
+ rows = body.rows;
852
+ }
853
+ const apply = (write) => {
854
+ const next = applyRowWrite(rows, write, (/* @__PURE__ */ new Date()).toISOString());
855
+ if (next.ok) rows = next.rows;
856
+ return next;
857
+ };
858
+ result = fn({
859
+ rows: () => rows,
860
+ createVerify: (row) => apply({
861
+ kind: "createVerify",
862
+ row
863
+ }),
864
+ createSkipped: (row) => apply({
865
+ kind: "createSkipped",
866
+ row
867
+ }),
868
+ transition: (id, event, expectedVersion) => apply({
869
+ kind: "transition",
870
+ id,
871
+ event,
872
+ expectedVersion
873
+ })
874
+ });
875
+ return JSON.stringify({
876
+ schemaVersion: 3,
877
+ rows
878
+ }, null, 2);
879
+ });
880
+ return result;
881
+ }
344
882
  /** The ONE write point. The callback returns the whole row set, not a delta. */
345
883
  async function mutateMergeState(io, home, now, fn) {
346
884
  let result;
@@ -349,9 +887,11 @@ async function mutateMergeState(io, home, now, fn) {
349
887
  if (!parsed.ok || parsed.note === "merge-state-forward-only") throw new Error(parsed.note ?? "merge-state-invalid");
350
888
  const out = fn(parsed.rows);
351
889
  result = out.result;
890
+ const rows = out.rows.map((row) => legacyToV3(row));
891
+ if (rows.some((row) => row === null)) throw new Error("merge-state-v3-row-invalid");
352
892
  return JSON.stringify({
353
- schemaVersion: 2,
354
- rows: out.rows
893
+ schemaVersion: 3,
894
+ rows
355
895
  }, null, 2);
356
896
  });
357
897
  return result;
@@ -425,6 +965,19 @@ async function moveRow(io, home, now, id, patch) {
425
965
  };
426
966
  });
427
967
  }
968
+ /** G3-5 v3-aware drop: only terminal events. */
969
+ async function dropRowV3(io, home, id, event, unitId = "unknown") {
970
+ const dropped = await transactRows(io, home, (tx) => {
971
+ const row = tx.rows().find((item) => item.id === id);
972
+ if (row === void 0) return false;
973
+ return tx.transition(id, event === "committed" ? {
974
+ type: "committed",
975
+ unitId
976
+ } : { type: event }, row.version).ok;
977
+ });
978
+ if (dropped) await deleteStagingIndex(io, home, id);
979
+ return dropped;
980
+ }
428
981
  async function dropRow(io, home, now, id) {
429
982
  return await mutateMergeState(io, home, now, (rows) => {
430
983
  const next = rows.filter((item) => item.id !== id);
@@ -596,20 +1149,6 @@ async function cleanupExpiredStaging(io, home, now, keep = /* @__PURE__ */ new S
596
1149
  };
597
1150
  }
598
1151
  //#endregion
599
- //#region lib/types/domain/commit-journal.js
600
- /** What a restart may do with the last durable phase. */
601
- function recoveryFor(journal) {
602
- switch (journal.phase) {
603
- case "prepared": return "retry-commit";
604
- case "target-written": return "rollback-target";
605
- case "sources-archived": return "rollback-sources";
606
- case "committed":
607
- case "rolled-back":
608
- case "failed": return "none";
609
- default: return "refuse";
610
- }
611
- }
612
- //#endregion
613
1152
  //#region lib/types/domain/commit-journal-store.js
614
1153
  /** Durable commit journal: <home>/merge/journals/<run>.json. */
615
1154
  function journalPath(home, runId) {
@@ -617,9 +1156,20 @@ function journalPath(home, runId) {
617
1156
  }
618
1157
  async function writeJournal(io, home, journal) {
619
1158
  const path = journalPath(home, journal.runId);
620
- await io.writeText(path, JSON.stringify(journal, null, 2));
1159
+ const content = JSON.stringify(journal, null, 2);
1160
+ if (io.transact !== void 0) await io.transact(path, () => content);
1161
+ else await io.writeText(path, content);
621
1162
  return path;
622
1163
  }
1164
+ async function readJournal(io, home, runId) {
1165
+ const raw = await io.readText(journalPath(home, runId));
1166
+ if (raw === null) return null;
1167
+ try {
1168
+ return JSON.parse(raw);
1169
+ } catch {
1170
+ return null;
1171
+ }
1172
+ }
623
1173
  //#endregion
624
1174
  //#region lib/types/commit-snapshot.js
625
1175
  /** Pre-commit snapshot: enough to restore the target and archived sources after a crash. */
@@ -654,32 +1204,9 @@ async function writeCommitSnapshot(io, home, runId, plan, now, port) {
654
1204
  await io.writeText(snapshotPath(home, runId), JSON.stringify(snapshot, null, 2));
655
1205
  return snapshot;
656
1206
  }
657
- async function readCommitSnapshot(io, home, runId) {
658
- const raw = await io.readText(snapshotPath(home, runId));
659
- if (raw === null) return null;
660
- try {
661
- return JSON.parse(raw);
662
- } catch {
663
- return null;
664
- }
665
- }
666
1207
  function isSafeSkillName(name) {
667
1208
  return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && name !== "." && name !== "..";
668
1209
  }
669
- function skillFilePath(root, name, path) {
670
- if (!isSafeSkillName(name) || path.startsWith("/") || path.split("/").includes("..")) throw new Error("unsafe skill file: " + name + "/" + path);
671
- const base = resolve(root, name);
672
- const file = resolve(base, path);
673
- if (!file.startsWith(base + sep)) throw new Error("skill file escapes skill: " + name + "/" + path);
674
- return file;
675
- }
676
- function skillBodyPath(root, name) {
677
- if (!isSafeSkillName(name)) throw new Error("unsafe skill name: " + name);
678
- const base = resolve(root);
679
- const path = resolve(base, name, "SKILL.md");
680
- if (!path.startsWith(base + sep)) throw new Error("skill path escapes root: " + name);
681
- return path;
682
- }
683
1210
  //#endregion
684
1211
  //#region lib/types/commit-receipt.js
685
1212
  /**
@@ -757,448 +1284,488 @@ async function readCommitReceipt(io, home, runId, target) {
757
1284
  }
758
1285
  //#endregion
759
1286
  //#region lib/types/commit-recovery.js
760
- /** Startup replay for non-terminal commit journals. */
761
- async function recoverCommitJournals(io, home, now, skillRoot, restoreSource, isActive) {
1287
+ /** Startup replay for non-terminal commit journals: report only, never write skills. */
1288
+ async function recoverCommitJournals(io, home, now, isActive) {
762
1289
  const dir = join(home, "merge", "journals");
763
- if (!await io.exists(dir)) return [];
764
- const files = (await io.list(dir)).filter((name) => name.endsWith(".json"));
765
1290
  const reports = [];
1291
+ let files = [];
1292
+ if (await io.exists(dir)) try {
1293
+ files = (await io.list(dir)).filter((name) => name.endsWith(".json"));
1294
+ } catch {
1295
+ reports.push({
1296
+ runId: "(list)",
1297
+ phase: "unknown",
1298
+ action: "journal-list-failed",
1299
+ ok: false,
1300
+ journalPath: dir
1301
+ });
1302
+ }
766
1303
  for (const file of files) {
767
- let journal;
768
- try {
769
- journal = JSON.parse(await io.readText(join(dir, file)) ?? "");
770
- } catch {
771
- continue;
772
- }
773
- if (isActive?.(journal.runId) === true) {
774
- reports.push({
775
- runId: journal.runId,
776
- phase: journal.phase,
777
- action: "skipped-active-run",
778
- ok: true
779
- });
780
- continue;
781
- }
782
- const recovery = recoveryFor(journal);
783
- if (recovery === "none") continue;
784
- if (recovery === "refuse") {
785
- reports.push({
786
- runId: journal.runId,
787
- phase: journal.phase,
788
- action: "unknown-phase-refused",
789
- ok: false
790
- });
791
- continue;
792
- }
793
- let receipt = null;
1304
+ const path = join(dir, file);
794
1305
  try {
795
- receipt = await readCommitReceipt(io, home, journal.runId, journal.target);
796
- } catch {
797
- reports.push({
798
- runId: journal.runId,
799
- phase: journal.phase,
800
- action: "receipt-read-failed",
801
- ok: false
802
- });
803
- continue;
804
- }
805
- if (receipt !== null) {
806
- await writeJournal(io, home, {
807
- ...journal,
808
- phase: "committed",
809
- unitId: receipt.unitId,
810
- updatedAt: new Date(now).toISOString()
811
- });
1306
+ let journal;
1307
+ try {
1308
+ journal = JSON.parse(await io.readText(path) ?? "");
1309
+ } catch {
1310
+ reports.push({
1311
+ runId: file.replace(/\.json$/, ""),
1312
+ phase: "unknown",
1313
+ action: "journal-unreadable",
1314
+ ok: false,
1315
+ journalPath: path
1316
+ });
1317
+ continue;
1318
+ }
1319
+ if (isActive?.(journal.runId) === true) {
1320
+ reports.push({
1321
+ runId: journal.runId,
1322
+ phase: journal.phase,
1323
+ action: "skipped-active-run",
1324
+ ok: true,
1325
+ target: journal.target,
1326
+ sources: journal.sources,
1327
+ journalPath: path
1328
+ });
1329
+ continue;
1330
+ }
1331
+ let receipt = null;
1332
+ try {
1333
+ receipt = await readCommitReceipt(io, home, journal.runId, journal.target);
1334
+ } catch {
1335
+ reports.push({
1336
+ runId: journal.runId,
1337
+ phase: journal.phase,
1338
+ action: "receipt-read-failed",
1339
+ ok: false,
1340
+ target: journal.target,
1341
+ sources: journal.sources,
1342
+ journalPath: path
1343
+ });
1344
+ continue;
1345
+ }
1346
+ if (receipt !== null) {
1347
+ await writeJournal(io, home, {
1348
+ ...journal,
1349
+ phase: "committed",
1350
+ unitId: receipt.unitId,
1351
+ updatedAt: new Date(now).toISOString()
1352
+ });
1353
+ reports.push({
1354
+ runId: journal.runId,
1355
+ phase: journal.phase,
1356
+ action: "receipt-committed",
1357
+ ok: true,
1358
+ target: journal.target,
1359
+ sources: journal.sources,
1360
+ journalPath: path,
1361
+ unitId: receipt.unitId
1362
+ });
1363
+ continue;
1364
+ }
1365
+ if (journal.phase === "committed") {
1366
+ reports.push({
1367
+ runId: journal.runId,
1368
+ phase: journal.phase,
1369
+ action: "receipt-missing",
1370
+ ok: false,
1371
+ target: journal.target,
1372
+ sources: journal.sources,
1373
+ journalPath: path
1374
+ });
1375
+ continue;
1376
+ }
1377
+ if (journal.phase === "failed" || journal.phase === "rolled-back") continue;
812
1378
  reports.push({
813
1379
  runId: journal.runId,
814
1380
  phase: journal.phase,
815
- action: "receipt-committed",
816
- ok: true
1381
+ action: "manual-required",
1382
+ ok: false,
1383
+ target: journal.target,
1384
+ sources: journal.sources,
1385
+ journalPath: path,
1386
+ snapshotPath: snapshotPath(home, journal.runId)
817
1387
  });
818
- continue;
819
- }
820
- let snapshot;
821
- try {
822
- snapshot = await readCommitSnapshot(io, home, journal.runId);
823
1388
  } catch (error) {
824
1389
  reports.push({
825
- runId: journal.runId,
826
- phase: journal.phase,
827
- action: "snapshot-read-failed: " + (error instanceof Error ? error.message : String(error)),
828
- ok: false
1390
+ runId: file.replace(/\.json$/, ""),
1391
+ phase: "unknown",
1392
+ action: "recovery-error: " + (error instanceof Error ? error.message : String(error)),
1393
+ ok: false,
1394
+ journalPath: path
829
1395
  });
830
- continue;
831
1396
  }
832
- let action = "report-only";
833
- let ok = true;
834
- if (recovery === "retry-commit") action = "no-host-write";
835
- else if (snapshot === null) {
836
- action = "snapshot-missing";
837
- ok = false;
838
- } else try {
839
- if (recovery === "rollback-sources") {
840
- let restoredArchived = restoreSource !== void 0;
841
- if (restoreSource !== void 0) {
842
- for (const source of snapshot.sourceBodies) if (!await restoreSource(source.name)) restoredArchived = false;
1397
+ }
1398
+ const unitDir = join(home, "merge", "unit-journals");
1399
+ if (await io.exists(unitDir)) {
1400
+ let unitFiles = [];
1401
+ try {
1402
+ unitFiles = (await io.list(unitDir)).filter((name) => name.endsWith(".json"));
1403
+ } catch {}
1404
+ for (const file of unitFiles) {
1405
+ const path = join(unitDir, file);
1406
+ try {
1407
+ const journal = JSON.parse(await io.readText(path) ?? "");
1408
+ if (await readUnitEvidence(io, home, journal.unitId) !== null) {
1409
+ reports.push({
1410
+ runId: journal.runId,
1411
+ phase: "committed",
1412
+ action: "unit-committed",
1413
+ ok: true,
1414
+ target: journal.target,
1415
+ sources: journal.sources,
1416
+ journalPath: path,
1417
+ unitId: journal.unitId
1418
+ });
1419
+ continue;
843
1420
  }
844
- if (!restoredArchived) for (const source of snapshot.sourceBodies) await io.writeText(skillBodyPath(skillRoot, source.name), source.body);
1421
+ if (journal.phase === "committed") {
1422
+ reports.push({
1423
+ runId: journal.runId,
1424
+ phase: "committed",
1425
+ action: "unit-evidence-missing",
1426
+ ok: false,
1427
+ target: journal.target,
1428
+ sources: journal.sources,
1429
+ journalPath: path,
1430
+ unitId: journal.unitId
1431
+ });
1432
+ continue;
1433
+ }
1434
+ if (journal.phase === "failed" || journal.phase === "rolled-back") continue;
1435
+ reports.push({
1436
+ runId: journal.runId,
1437
+ phase: journal.phase,
1438
+ action: "unit-manual-required",
1439
+ ok: false,
1440
+ target: journal.target,
1441
+ sources: journal.sources,
1442
+ journalPath: path,
1443
+ unitId: journal.unitId
1444
+ });
1445
+ } catch {
1446
+ reports.push({
1447
+ runId: "(unit)",
1448
+ phase: "unknown",
1449
+ action: "unit-journal-unreadable",
1450
+ ok: false,
1451
+ journalPath: path
1452
+ });
845
1453
  }
846
- if (snapshot.create && snapshot.targetBody === null) {
847
- try {
848
- await io.remove(skillBodyPath(skillRoot, snapshot.target));
849
- } catch {}
850
- try {
851
- const dir = join(skillRoot, snapshot.target);
852
- if ((await io.list(dir)).length === 0) await io.remove(dir);
853
- } catch {}
854
- } else if (snapshot.targetBody !== null) await io.writeText(skillBodyPath(skillRoot, snapshot.target), snapshot.targetBody);
855
- const oldPaths = new Set((snapshot.targetSupport ?? []).map((file) => file.path));
856
- for (const file of snapshot.targetSupport ?? []) await io.writeText(skillFilePath(skillRoot, snapshot.target, file.path), file.content);
857
- for (const file of snapshot.targetSupportAfter ?? []) if (!oldPaths.has(file.path)) try {
858
- await io.remove(skillFilePath(skillRoot, snapshot.target, file.path));
859
- } catch {}
860
- for (const file of snapshot.sourceSupport ?? []) await io.writeText(skillFilePath(skillRoot, file.name, file.path), file.content);
861
- action = recovery === "rollback-sources" ? "restored-sources-and-target" : "restored-target";
862
- } catch (error) {
863
- action = "restore-failed: " + (error instanceof Error ? error.message : String(error));
864
- ok = false;
865
1454
  }
866
- if (ok) await writeJournal(io, home, {
867
- ...journal,
868
- phase: "rolled-back",
869
- updatedAt: new Date(now).toISOString()
870
- });
871
- reports.push({
872
- runId: journal.runId,
873
- phase: journal.phase,
874
- action,
875
- ok
876
- });
877
1455
  }
878
- await io.writeText(join(home, "merge", "recovery.json"), JSON.stringify(reports, null, 2));
1456
+ try {
1457
+ await io.writeText(join(home, "merge", "recovery.json"), JSON.stringify(reports, null, 2));
1458
+ } catch {}
879
1459
  return reports;
880
1460
  }
881
1461
  //#endregion
882
- //#region lib/types/coordinator.js
883
- /**
884
- * Pure merge transition table. No io, no run shell, no subagents.
885
- *
886
- * Every transition returns the whole next row (or a drop), so the caller can persist it
887
- * through mutateMergeState in one transaction. Invalid transitions are refused by name;
888
- * a caller never "repairs" a row by guessing which state the operator meant.
889
- */
890
- function invalid(event, state) {
891
- return {
892
- ok: false,
893
- code: "invalid-transition",
894
- message: "cannot apply " + event.type + " to " + (state ?? "no row")
895
- };
896
- }
897
- /** Drop fields that must not survive a transition; optional fields stay absent. */
898
- function base(row, at) {
1462
+ //#region lib/types/command-surface.js
1463
+ const flag = (name, kind = "boolean", valueName) => ({
1464
+ name,
1465
+ kind,
1466
+ ...valueName === void 0 ? {} : { valueName }
1467
+ });
1468
+ const pos = (name, kind, required = true) => ({
1469
+ name,
1470
+ kind,
1471
+ required
1472
+ });
1473
+ const action = (id, command, confirm = false) => ({
1474
+ id,
1475
+ command,
1476
+ confirm
1477
+ });
1478
+ function spec(id, words, flags, positional, readOnly, needsRun, rowActions, fallback) {
899
1479
  return {
900
- id: row.id,
901
- pairKey: row.pairKey,
902
- a: row.a,
903
- b: row.b,
904
- state: row.state,
905
- queue: row.queue,
906
- createdAt: row.createdAt,
907
- updatedAt: at,
908
- ...row.runId === void 0 ? {} : { runId: row.runId },
909
- ...row.unitId === void 0 ? {} : { unitId: row.unitId },
910
- ...row.origin === void 0 ? {} : { origin: row.origin },
911
- ...row.decision === void 0 ? {} : { decision: row.decision },
912
- ...row.sourceRevision === void 0 ? {} : { sourceRevision: row.sourceRevision },
913
- ...row.targetRevision === void 0 ? {} : { targetRevision: row.targetRevision },
914
- ...row.attempts === void 0 ? {} : { attempts: row.attempts },
915
- ...row.stagingRef === void 0 ? {} : { stagingRef: row.stagingRef },
916
- ...row.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: row.stagingExpiresAt },
917
- ...row.expired === void 0 ? {} : { expired: row.expired },
918
- ...row.reason === void 0 ? {} : { reason: row.reason },
919
- ...row.target === void 0 ? {} : { target: row.target }
1480
+ id,
1481
+ words,
1482
+ flags,
1483
+ positional,
1484
+ summary: id,
1485
+ readOnly,
1486
+ needsRun,
1487
+ defaults: {},
1488
+ rowActions,
1489
+ fallback
920
1490
  };
921
1491
  }
922
- function next(row, at, state, patch = {}) {
923
- return {
924
- ok: true,
925
- row: {
926
- ...base(row, at),
927
- state,
928
- queue: queueOf(state),
929
- ...patch
1492
+ const MERGE_COMMAND_SURFACE = {
1493
+ wireVersion: 1,
1494
+ prefix: "/evolution merge",
1495
+ commands: [
1496
+ spec("merge.scan", ["scan"], [], [], false, true, [], "/evolution merge scan"),
1497
+ spec("merge.pair", ["pair"], [], [pos("a", "skill"), pos("b", "skill")], false, true, [action("pair", "/evolution merge pair", true)], "/evolution merge pair <a> <b>"),
1498
+ spec("merge.pair.force", ["pair"], [flag("force")], [pos("a", "skill"), pos("b", "skill")], false, true, [], "/evolution merge pair --force <a> <b>"),
1499
+ spec("merge.status", ["status"], [], [pos("id", "id", false)], true, false, [action("status", "/evolution merge status")], "/evolution merge status"),
1500
+ spec("merge.report", ["report"], [], [pos("id", "id", false)], true, false, [action("report", "/evolution merge report")], "/evolution merge report"),
1501
+ spec("merge.cancel", ["cancel"], [], [pos("id", "id", false)], false, false, [action("cancel", "/evolution merge cancel")], "/evolution merge cancel <id>"),
1502
+ spec("merge.runs", ["runs"], [], [], true, false, [], "/evolution merge runs"),
1503
+ spec("merge.pending", ["pending"], [], [], true, false, [], "/evolution merge pending"),
1504
+ spec("merge.pending.expired", ["pending"], [flag("expired")], [], true, false, [], "/evolution merge pending --expired"),
1505
+ spec("merge.approve", ["approve"], [], [pos("id", "id")], false, true, [action("approve", "/evolution merge approve <id>")], "/evolution merge approve <id>"),
1506
+ spec("merge.reject", ["reject"], [], [pos("id", "id")], false, false, [action("reject", "/evolution merge reject <id>", true)], "/evolution merge reject <id>"),
1507
+ spec("merge.skipped", ["skipped"], [], [], true, false, [], "/evolution merge skipped"),
1508
+ spec("merge.skipped.restore", ["skipped"], [flag("restore", "value", "id")], [], false, false, [action("restore", "/evolution merge skipped --restore <id>", true)], "/evolution merge skipped --restore <id>"),
1509
+ spec("merge.candidates", ["candidates"], [], [], true, false, [], "/evolution merge candidates"),
1510
+ spec("merge.candidates.retry", ["candidates"], [flag("retry", "value", "id")], [], false, true, [action("retry-candidates", "/evolution merge candidates --retry <id>")], "/evolution merge candidates --retry <id>"),
1511
+ spec("merge.resume", ["resume"], [], [pos("id", "id", false)], true, false, [], "/evolution merge resume"),
1512
+ spec("merge.resume.retry", ["resume"], [flag("retry", "value", "id")], [], false, true, [action("retry-resume", "/evolution merge resume --retry <id>")], "/evolution merge resume --retry <id>"),
1513
+ spec("merge.discard", ["discard"], [], [pos("id", "id")], false, false, [action("discard", "/evolution merge discard <id>", true)], "/evolution merge discard <id>"),
1514
+ spec("merge.undo", ["undo"], [flag("run", "value", "id")], [pos("target", "target")], false, false, [], "/evolution merge undo <target> --run <id>")
1515
+ ],
1516
+ fallback: [
1517
+ {
1518
+ id: "scan",
1519
+ command: "/evolution merge scan"
1520
+ },
1521
+ {
1522
+ id: "pair",
1523
+ command: "/evolution merge pair <a> <b>"
1524
+ },
1525
+ {
1526
+ id: "candidates",
1527
+ command: "/evolution merge candidates"
1528
+ },
1529
+ {
1530
+ id: "pending",
1531
+ command: "/evolution merge pending"
1532
+ },
1533
+ {
1534
+ id: "skipped",
1535
+ command: "/evolution merge skipped"
1536
+ },
1537
+ {
1538
+ id: "resume",
1539
+ command: "/evolution merge resume"
1540
+ },
1541
+ {
1542
+ id: "status",
1543
+ command: "/evolution merge status"
1544
+ },
1545
+ {
1546
+ id: "report",
1547
+ command: "/evolution merge report"
1548
+ },
1549
+ {
1550
+ id: "doctor",
1551
+ command: "/evolution merge doctor"
930
1552
  }
931
- };
932
- }
933
- function transition(current, event) {
934
- if (event.type === "candidate") {
935
- if (current !== void 0) return {
936
- ok: true,
937
- row: current
938
- };
939
- const key = pairKey(event.a, event.b);
940
- return {
941
- ok: true,
942
- row: {
943
- id: rowIdOf(key),
944
- pairKey: key,
945
- a: event.a,
946
- b: event.b,
947
- state: "verify",
948
- queue: "verify",
949
- createdAt: event.at,
950
- updatedAt: event.at
951
- }
952
- };
953
- }
954
- if (event.type === "source-gone") return {
955
- ok: true,
956
- row: null
957
- };
958
- if (event.type === "gate-keep") {
959
- if (current !== void 0) return {
960
- ok: true,
961
- row: current
962
- };
963
- const key = pairKey(event.a, event.b);
964
- return {
965
- ok: true,
966
- row: {
967
- id: rowIdOf(key),
968
- pairKey: key,
969
- a: event.a,
970
- b: event.b,
971
- state: "skipped",
972
- queue: "skipped",
973
- origin: "agent",
974
- createdAt: event.at,
975
- updatedAt: event.at
976
- }
977
- };
978
- }
979
- if (current === void 0) return invalid(event, void 0);
980
- switch (event.type) {
981
- case "round1-keep": return current.state === "verify" ? next(current, event.at, "skipped", { origin: "agent" }) : invalid(event, current.state);
982
- case "round1-merge":
983
- if (current.state !== "verify") return invalid(event, current.state);
984
- return event.approval === "on" ? next(current, event.at, "pending", {
985
- ...event.decision === void 0 ? {} : { decision: event.decision },
986
- ...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
987
- ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt },
988
- ...event.sourceRevision === void 0 ? {} : { sourceRevision: event.sourceRevision },
989
- ...event.targetRevision === void 0 ? {} : { targetRevision: event.targetRevision }
990
- }) : next(current, event.at, "approved", {
991
- ...event.decision === void 0 ? {} : { decision: event.decision },
992
- ...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
993
- ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt },
994
- ...event.sourceRevision === void 0 ? {} : { sourceRevision: event.sourceRevision },
995
- ...event.targetRevision === void 0 ? {} : { targetRevision: event.targetRevision }
996
- });
997
- case "round1-fail": return current.state === "verify" ? next(current, event.at, "verify", {
998
- failure: event.failure,
999
- attempts: (current.attempts ?? 0) + 1
1000
- }) : invalid(event, current.state);
1001
- case "approve": return current.state === "pending" ? next(current, event.at, "approved", { ...event.runId === void 0 ? {} : { runId: event.runId } }) : invalid(event, current.state);
1002
- case "reject": return current.state === "pending" ? next(current, event.at, "skipped", { origin: "human" }) : invalid(event, current.state);
1003
- case "round2-cancel": return current.state === "fuse" || current.state === "approved" ? next(current, event.at, "approved") : invalid(event, current.state);
1004
- case "round2-fail": return current.state === "fuse" || current.state === "approved" ? next(current, event.at, "approved", {
1005
- failure: event.failure,
1006
- attempts: (current.attempts ?? 0) + 1
1007
- }) : invalid(event, current.state);
1008
- case "round2-success": return current.state === "fuse" || current.state === "approved" ? {
1009
- ok: true,
1010
- row: null
1011
- } : invalid(event, current.state);
1012
- case "target-taken":
1013
- case "source-changed": return current.state === "pending" || current.state === "approved" || current.state === "fuse" ? next(current, event.at, current.state, { expired: event.type }) : invalid(event, current.state);
1014
- case "restore": return current.state === "skipped" ? next(current, event.at, "verify") : invalid(event, current.state);
1015
- }
1016
- }
1017
- //#endregion
1018
- //#region lib/types/presentation-budget.js
1019
- /** Tool-result budget: keep the head and tail, mark what was omitted. */
1020
- function fitToolText(text, max = 8e3) {
1021
- if (text.length <= max) return text;
1022
- const marker = "\n... [truncated " + String(text.length - max) + " chars] ...\n";
1023
- const keep = Math.max(0, max - marker.length);
1024
- const head = Math.ceil(keep * .7);
1025
- const tail = keep - head;
1026
- return text.slice(0, head) + marker + (tail === 0 ? "" : text.slice(-tail));
1027
- }
1553
+ ]
1554
+ };
1028
1555
  //#endregion
1029
- //#region lib/types/status.js
1030
- function countRows(rows) {
1031
- const counts = {
1556
+ //#region lib/types/facts.js
1557
+ const queueOf$2 = (row) => row.state === "approved" ? "fuse" : row.queue;
1558
+ const isExpired = (row) => row.expired !== void 0;
1559
+ function countsOf(rows) {
1560
+ const active = {
1032
1561
  verify: 0,
1033
1562
  pending: 0,
1034
1563
  fuse: 0,
1035
1564
  skipped: 0
1036
1565
  };
1037
- for (const row of rows) counts[queueOf(row.state)] += 1;
1038
- return counts;
1039
- }
1040
- function describe(row) {
1041
- const stage = stageOf(row.state);
1042
- const target = row.decision?.shouldMerge === true ? " → " + row.decision.target : "";
1043
- return row.a + " + " + row.b + " [" + row.state + (stage === null ? "" : "/" + stage) + "]" + target + (row.failure === void 0 ? "" : " — " + row.failure.message);
1566
+ let expired = 0;
1567
+ for (const row of rows) {
1568
+ if (isExpired(row)) {
1569
+ expired += 1;
1570
+ continue;
1571
+ }
1572
+ active[queueOf$2(row)] += 1;
1573
+ }
1574
+ return {
1575
+ active,
1576
+ expired
1577
+ };
1044
1578
  }
1045
- function formatStatus(rows) {
1046
- const counts = countRows(rows);
1047
- const lines = ["merge: verify " + String(counts.verify) + ", pending " + String(counts.pending) + ", fuse " + String(counts.fuse) + ", skipped " + String(counts.skipped)];
1048
- for (const row of rows) lines.push("- " + describe(row));
1049
- return fitToolText(lines.join("\n"));
1579
+ function toRunView(record, readAt) {
1580
+ const state = record.mergeStatus ?? record.state;
1581
+ return {
1582
+ wireVersion: 1,
1583
+ readAt,
1584
+ id: record.id,
1585
+ state,
1586
+ command: record.command ?? "scan",
1587
+ ...record.stage === void 0 ? {} : { stage: record.stage },
1588
+ progress: record.progress === void 0 ? null : {
1589
+ done: record.progress.done,
1590
+ total: record.progress.total
1591
+ },
1592
+ startedAt: record.startedAt,
1593
+ ...record.endedAt === void 0 ? {} : { endedAt: record.endedAt },
1594
+ ...record.failure === void 0 ? {} : { failure: {
1595
+ stage: "settle",
1596
+ code: "run-failed",
1597
+ retryable: false,
1598
+ retryFrom: "none",
1599
+ detail: record.failure
1600
+ } },
1601
+ ...record.parentRunId === void 0 ? {} : { parentRunId: record.parentRunId },
1602
+ ...record.pairKey === void 0 ? {} : { currentPairKey: record.pairKey },
1603
+ actions: [
1604
+ {
1605
+ id: "status",
1606
+ command: "/evolution merge status " + record.id,
1607
+ confirm: false,
1608
+ enabled: true
1609
+ },
1610
+ {
1611
+ id: "report",
1612
+ command: "/evolution merge report " + record.id,
1613
+ confirm: false,
1614
+ enabled: record.resultRef !== void 0
1615
+ },
1616
+ {
1617
+ id: "cancel",
1618
+ command: "/evolution merge cancel " + record.id,
1619
+ confirm: false,
1620
+ enabled: state === "running"
1621
+ }
1622
+ ]
1623
+ };
1050
1624
  }
1051
- function formatQueue(rows, queue) {
1052
- const selected = rows.filter((row) => queueOf(row.state) === queue);
1053
- if (selected.length === 0) return "merge " + queue + ": empty";
1054
- return fitToolText(["merge " + queue + ": " + String(selected.length)].concat(selected.map((row) => "- " + describe(row))).join("\n"));
1625
+ function buildFacts(input) {
1626
+ const ordered = [...input.runs].sort((a, b) => b.startedAt - a.startedAt);
1627
+ const active = ordered.find((run) => (run.mergeStatus ?? run.state) === "running");
1628
+ const last = ordered.find((run) => (run.mergeStatus ?? run.state) !== "running");
1629
+ const notes = input.notes ?? [];
1630
+ return {
1631
+ wireVersion: 1,
1632
+ readAt: input.readAt,
1633
+ approval: { mergeApproval: input.approval },
1634
+ counts: countsOf(input.rows),
1635
+ active: active === void 0 ? null : toRunView(active, input.readAt),
1636
+ last: last === void 0 ? null : toRunView(last, input.readAt),
1637
+ startCommands: {
1638
+ scan: "/evolution merge scan",
1639
+ pair: "/evolution merge pair <a> <b>"
1640
+ },
1641
+ fallback: MERGE_COMMAND_SURFACE.fallback,
1642
+ notes,
1643
+ degraded: notes.length > 0
1644
+ };
1055
1645
  }
1056
1646
  //#endregion
1057
- //#region lib/types/routes.js
1058
- function toRowView(row) {
1647
+ //#region lib/types/wire-projection.js
1648
+ const queueOf$1 = (row) => row.state === "approved" ? "fuse" : row.queue;
1649
+ const expiredOf = (row) => row.expired !== void 0;
1650
+ /** Host-owned row actions; client only renders/copies them. */
1651
+ function actionRefs(row) {
1652
+ const base = "/evolution merge ";
1653
+ const disabled = row.expired === void 0 ? void 0 : "expired";
1654
+ const action = (id, confirm = false, enabled = true) => ({
1655
+ id,
1656
+ command: base + id + " " + row.id,
1657
+ confirm,
1658
+ enabled,
1659
+ ...enabled ? {} : { disabledReason: disabled ?? "invalid" }
1660
+ });
1661
+ if (row.state === "pending" && row.expired !== void 0) return [{
1662
+ id: "discard",
1663
+ command: "/evolution merge discard " + row.id,
1664
+ confirm: true,
1665
+ enabled: true
1666
+ }, {
1667
+ id: "pair",
1668
+ command: "/evolution merge pair " + row.a + " " + row.b,
1669
+ confirm: true,
1670
+ enabled: true
1671
+ }];
1672
+ if (row.state === "pending") return [
1673
+ action("approve"),
1674
+ action("reject", true),
1675
+ action("discard", true)
1676
+ ];
1677
+ if (row.state === "approved" || row.state === "fuse") return [action("discard", true), action("status")];
1678
+ if (row.state === "skipped") return [action("restore", true)];
1679
+ return [action("pair")];
1680
+ }
1681
+ function toRowFact(row, actions = actionRefs(row)) {
1059
1682
  return {
1060
1683
  id: row.id,
1061
- pairKey: row.pairKey,
1684
+ state: row.state,
1685
+ queue: queueOf$1(row),
1686
+ ...row.state === "verify" ? { verifySubState: "queued" } : {},
1062
1687
  a: row.a,
1063
1688
  b: row.b,
1064
- state: row.state,
1065
- queue: queueOf(row.state),
1066
- stage: stageOf(row.state),
1067
- label: row.a + " + " + row.b,
1068
- updatedAt: row.updatedAt,
1069
- createdAt: row.createdAt,
1070
- ...row.decision?.target === void 0 ? {} : { target: row.decision.target },
1071
- ...row.decision?.confidence === void 0 ? {} : { confidence: row.decision.confidence },
1072
- ...row.decision?.aSummary === void 0 ? {} : { aSummary: row.decision.aSummary },
1073
- ...row.decision?.bSummary === void 0 ? {} : { bSummary: row.decision.bSummary },
1074
- ...row.decision?.rationale === void 0 ? {} : { rationale: row.decision.rationale },
1689
+ ...row.target === void 0 ? {} : { target: {
1690
+ requested: row.target.requested,
1691
+ ...row.target.actual === void 0 ? {} : { actual: row.target.actual },
1692
+ ...row.target.created === void 0 ? {} : { created: row.target.created }
1693
+ } },
1694
+ ...row.state === "pending" ? { summary: {
1695
+ a: null,
1696
+ b: null,
1697
+ rationale: row.decision?.rationale ?? null
1698
+ } } : {},
1699
+ ...row.reason === void 0 ? {} : { reason: row.reason },
1700
+ ...row.state === "skipped" ? { origin: row.origin ?? "system" } : {},
1075
1701
  ...row.failure === void 0 ? {} : { failure: {
1076
- stage: row.failure.stage,
1702
+ stage: row.failure.stage === "screening" ? "screening" : row.failure.stage === "decide" ? "decide" : row.failure.stage === "author" ? "author" : row.failure.stage === "validate" ? "validate" : "commit",
1077
1703
  code: row.failure.code,
1078
- message: row.failure.message,
1079
- retryable: row.failure.retryable
1704
+ retryable: row.failure.retryable,
1705
+ retryFrom: row.failure.retryFrom
1706
+ } },
1707
+ ...row.stagingRef === void 0 ? {} : { staging: {
1708
+ state: "kept",
1709
+ ...row.stagingExpiresAt === void 0 ? {} : { expiresAt: row.stagingExpiresAt }
1710
+ } },
1711
+ ...row.expired === void 0 ? {} : { expired: {
1712
+ kind: row.expired,
1713
+ at: Date.parse(row.updatedAt)
1080
1714
  } },
1081
1715
  ...row.attempts === void 0 ? {} : { attempts: row.attempts },
1082
- ...row.origin === void 0 ? {} : { origin: row.origin },
1083
- ...row.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: row.stagingExpiresAt },
1084
- ...row.expired === void 0 ? {} : { expired: row.expired },
1085
- ...row.reason === void 0 ? {} : { reason: row.reason },
1086
- summary: [
1087
- row.decision?.aSummary ?? "未提供技能1摘要",
1088
- row.decision?.bSummary ?? "未提供技能2摘要",
1089
- row.decision?.rationale ?? row.reason ?? "未提供融合原因"
1090
- ],
1091
- 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]
1716
+ ...row.decision?.confidence === void 0 ? {} : { confidence: row.decision.confidence },
1717
+ ...row.state === "skipped" && row.stagingExpiresAt !== void 0 ? { expiresAt: row.stagingExpiresAt } : {},
1718
+ createdAt: Date.parse(row.createdAt),
1719
+ updatedAt: Date.parse(row.updatedAt),
1720
+ actions
1092
1721
  };
1093
1722
  }
1094
- function buildMergeView(rows, commands, latestRun, approval) {
1095
- const counts = countRows(rows);
1096
- return {
1097
- schemaVersion: 2,
1098
- counts,
1099
- tabs: [
1100
- {
1101
- queue: "verify",
1102
- count: counts.verify
1103
- },
1104
- {
1105
- queue: "pending",
1106
- count: counts.pending
1107
- },
1108
- {
1109
- queue: "fuse",
1110
- count: counts.fuse
1111
- },
1112
- {
1113
- queue: "skipped",
1114
- count: counts.skipped
1115
- }
1116
- ],
1117
- commands,
1118
- ...latestRun === void 0 ? {} : { latestRun },
1119
- ...approval === void 0 ? {} : { approval }
1120
- };
1121
- }
1122
- function pageQueue(rows, queue, cursor = 0, limit = 50) {
1123
- const filtered = rows.filter((row) => queueOf(row.state) === queue);
1124
- const size = Math.max(1, Math.min(200, limit));
1125
- const start = Math.max(0, cursor);
1126
- const items = filtered.slice(start, start + size).map(toRowView);
1127
- const next = start + size < filtered.length ? String(start + size) : void 0;
1723
+ function toQueuePage(rows, queue, expiredOnly, readAt, limit = 50) {
1724
+ const filtered = rows.filter((row) => queueOf$1(row) === queue && (expiredOnly ? expiredOf(row) : !expiredOf(row)));
1725
+ const page = filtered.slice(0, Math.max(0, Math.min(200, limit)));
1128
1726
  return {
1727
+ wireVersion: 1,
1728
+ readAt,
1129
1729
  queue,
1130
- items,
1131
- ...next === void 0 ? {} : { nextCursor: next }
1730
+ expiredOnly,
1731
+ counts: countsOf(rows),
1732
+ rows: page.map((row) => toRowFact(row)),
1733
+ ...filtered.length > page.length ? { nextCursor: String(page.length) } : {},
1734
+ notes: []
1132
1735
  };
1133
1736
  }
1134
- function toRunFact(run) {
1737
+ function toRunsView(runs, readAt) {
1135
1738
  return {
1136
- id: run.id,
1137
- state: run.state,
1138
- ...run.command === void 0 ? {} : { command: run.command },
1139
- ...run.stage === void 0 ? {} : { stage: run.stage },
1140
- ...run.progress === void 0 ? {} : { progress: run.progress },
1141
- startedAt: run.startedAt,
1142
- ...run.endedAt === void 0 ? {} : { endedAt: run.endedAt },
1143
- ...run.resultRef === void 0 ? {} : { resultRef: run.resultRef },
1144
- ...run.failure === void 0 ? {} : { failure: run.failure },
1145
- ...run.supersededAt === void 0 ? {} : { supersededAt: run.supersededAt }
1739
+ wireVersion: 1,
1740
+ readAt,
1741
+ runs: [...runs].sort((a, b) => b.startedAt - a.startedAt).slice(0, 50).map((run) => toRunView(run, readAt))
1146
1742
  };
1147
1743
  }
1148
- //#endregion
1149
- //#region lib/types/domain/markdown.js
1150
- /** Minimal safe Markdown renderer for the read-only body route. */
1151
- function escapeHtml(text) {
1152
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1153
- }
1154
- function renderMarkdown(source) {
1155
- const fence = String.fromCharCode(96, 96, 96);
1156
- const lines = source.split(/\r?\n/);
1157
- const out = [];
1158
- let inCode = false;
1159
- let inList = false;
1160
- for (const line of lines) {
1161
- if (line.startsWith(fence)) {
1162
- if (inCode) {
1163
- out.push("</code></pre>");
1164
- inCode = false;
1165
- } else {
1166
- out.push("<pre><code>");
1167
- inCode = true;
1168
- }
1169
- continue;
1170
- }
1171
- if (inCode) {
1172
- out.push(escapeHtml(line));
1173
- continue;
1174
- }
1175
- const heading = /^(#{1,6})\s+(.*)$/.exec(line);
1176
- if (heading !== null) {
1177
- if (inList) {
1178
- out.push("</ul>");
1179
- inList = false;
1180
- }
1181
- const level = heading[1]?.length ?? 1;
1182
- out.push("<h" + String(level) + ">" + escapeHtml(heading[2] ?? "") + "</h" + String(level) + ">");
1183
- continue;
1184
- }
1185
- if (/^[-*]\s+/.test(line)) {
1186
- if (!inList) {
1187
- out.push("<ul>");
1188
- inList = true;
1189
- }
1190
- out.push("<li>" + escapeHtml(line.replace(/^[-*]\s+/, "")) + "</li>");
1191
- continue;
1192
- }
1193
- if (inList) {
1194
- out.push("</ul>");
1195
- inList = false;
1196
- }
1197
- out.push(line.trim() === "" ? "" : "<p>" + escapeHtml(line) + "</p>");
1198
- }
1199
- if (inList) out.push("</ul>");
1200
- if (inCode) out.push("</code></pre>");
1201
- return out.join("\n");
1744
+ function toBodyView(name, side, text, readAt) {
1745
+ return {
1746
+ wireVersion: 1,
1747
+ readAt,
1748
+ name,
1749
+ side,
1750
+ text,
1751
+ markdown: true,
1752
+ truncated: false,
1753
+ totalBytes: Buffer.byteLength(text, "utf8")
1754
+ };
1755
+ }
1756
+ function toHistoryView(items, readAt) {
1757
+ return {
1758
+ wireVersion: 1,
1759
+ readAt,
1760
+ items: items.map((item) => ({
1761
+ name: item.name,
1762
+ kind: item.kind,
1763
+ reason: item.reason,
1764
+ lastVersion: item.lastVersion,
1765
+ archivedAt: Date.parse(item.archivedAt),
1766
+ ...item.sourceRunId === void 0 ? {} : { sourceRunId: item.sourceRunId }
1767
+ }))
1768
+ };
1202
1769
  }
1203
1770
  //#endregion
1204
1771
  //#region lib/types/merge-routes.js
@@ -1211,12 +1778,15 @@ const MERGE_ROUTES = {
1211
1778
  history: MERGE_PATHS.history ?? MERGE_BASE + "history"
1212
1779
  };
1213
1780
  function makeMergeRoutes(deps, _audit) {
1214
- const absent = () => json(503, {
1215
- ok: false,
1216
- error: {
1217
- code: "merge-service-absent",
1218
- message: "merge service is not mounted"
1219
- }
1781
+ const absent = () => json(404, {
1782
+ wireVersion: 1,
1783
+ code: "E-325",
1784
+ message: "merge service is not mounted"
1785
+ });
1786
+ const bad = (message) => json(400, {
1787
+ wireVersion: 1,
1788
+ code: "merge-bad-query",
1789
+ message
1220
1790
  });
1221
1791
  const get = (request) => request.method === "GET" ? null : methodNotAllowed(["GET"]);
1222
1792
  const url = (request) => new URL(request.url, "http://local");
@@ -1230,11 +1800,13 @@ function makeMergeRoutes(deps, _audit) {
1230
1800
  if (denied !== null) return denied;
1231
1801
  const source = deps.getSource();
1232
1802
  if (source === void 0) return absent();
1233
- const latestMerge = (await source.runs(void 0, 20)).find((run) => run.kind === "merge");
1234
- return json(200, {
1235
- ok: true,
1236
- data: buildMergeView(await source.rows(), source.commands(), latestMerge === void 0 ? void 0 : toRunFact(latestMerge), source.approval?.())
1237
- });
1803
+ const readAt = Date.now();
1804
+ return json(200, buildFacts({
1805
+ rows: await source.rows(),
1806
+ runs: await source.runs(void 0, 20),
1807
+ approval: source.approval?.() === true,
1808
+ readAt
1809
+ }));
1238
1810
  }
1239
1811
  },
1240
1812
  {
@@ -1248,19 +1820,16 @@ function makeMergeRoutes(deps, _audit) {
1248
1820
  if (source === void 0) return absent();
1249
1821
  const query = url(request).searchParams;
1250
1822
  const queue = query.get("queue") ?? "verify";
1251
- if (queue !== "verify" && queue !== "pending" && queue !== "fuse" && queue !== "skipped") return json(400, {
1252
- ok: false,
1253
- error: {
1254
- code: "bad-queue",
1255
- message: "queue must be verify/pending/fuse/skipped"
1256
- }
1257
- });
1258
- const cursor = Number.parseInt(query.get("cursor") ?? "0", 10);
1823
+ if (queue !== "verify" && queue !== "pending" && queue !== "fuse" && queue !== "skipped") return bad("queue must be verify/pending/fuse/skipped");
1824
+ const expiredOnly = query.get("expired") === "1";
1825
+ if (expiredOnly && queue !== "pending") return bad("expired is only allowed for pending");
1259
1826
  const limit = Number.parseInt(query.get("limit") ?? "50", 10);
1260
- return json(200, {
1261
- ok: true,
1262
- data: pageQueue(await source.rows(), queue, Number.isFinite(cursor) ? cursor : 0, Number.isFinite(limit) ? limit : 50)
1263
- });
1827
+ const offset = Number.parseInt(query.get("cursor") ?? "0", 10);
1828
+ const page = toQueuePage(await source.rows(), queue, expiredOnly, Date.now(), Number.isFinite(limit) ? limit : 50);
1829
+ return json(200, Number.isFinite(offset) && offset > 0 ? {
1830
+ ...page,
1831
+ rows: page.rows.slice(offset)
1832
+ } : page);
1264
1833
  }
1265
1834
  },
1266
1835
  {
@@ -1275,28 +1844,13 @@ function makeMergeRoutes(deps, _audit) {
1275
1844
  const query = url(request).searchParams;
1276
1845
  const id = query.get("id");
1277
1846
  const side = query.get("side");
1278
- if (id === null || id === "" || side !== "a" && side !== "b") return json(400, {
1279
- ok: false,
1280
- error: {
1281
- code: "bad-body-query",
1282
- message: "id and side=a|b are required"
1283
- }
1284
- });
1847
+ if (id === null || id === "" || side !== "a" && side !== "b") return bad("id and side=a|b are required");
1285
1848
  const content = await source.body(id, side);
1286
- return content === null ? json(404, {
1287
- ok: false,
1288
- error: {
1289
- code: "body-not-found",
1290
- message: "merge body not found: " + id
1291
- }
1292
- }) : json(200, {
1293
- ok: true,
1294
- data: {
1295
- content,
1296
- markdown: true,
1297
- html: renderMarkdown(content)
1298
- }
1299
- });
1849
+ return content === null ? json(200, {
1850
+ wireVersion: 1,
1851
+ code: "merge-body-missing",
1852
+ message: "merge body not found: " + id
1853
+ }) : json(200, toBodyView(id, side === "a" ? "live" : "archive", content, Date.now()));
1300
1854
  }
1301
1855
  },
1302
1856
  {
@@ -1308,10 +1862,7 @@ function makeMergeRoutes(deps, _audit) {
1308
1862
  if (denied !== null) return denied;
1309
1863
  const source = deps.getSource();
1310
1864
  if (source === void 0) return absent();
1311
- return json(200, {
1312
- ok: true,
1313
- data: { items: await source.history() }
1314
- });
1865
+ return json(200, toHistoryView(await source.history(), Date.now()));
1315
1866
  }
1316
1867
  }
1317
1868
  ];
@@ -1559,8 +2110,58 @@ function mergeUsage() {
1559
2110
  return USAGE;
1560
2111
  }
1561
2112
  //#endregion
2113
+ //#region lib/types/presentation-budget.js
2114
+ /** Tool-result budget: keep the head and tail, mark what was omitted. */
2115
+ function fitToolText(text, max = 8e3) {
2116
+ if (text.length <= max) return text;
2117
+ const marker = "\n... [truncated " + String(text.length - max) + " chars] ...\n";
2118
+ const keep = Math.max(0, max - marker.length);
2119
+ const head = Math.ceil(keep * .7);
2120
+ const tail = keep - head;
2121
+ return text.slice(0, head) + marker + (tail === 0 ? "" : text.slice(-tail));
2122
+ }
2123
+ //#endregion
2124
+ //#region lib/types/status.js
2125
+ function countRows(rows) {
2126
+ const counts = {
2127
+ verify: 0,
2128
+ pending: 0,
2129
+ fuse: 0,
2130
+ skipped: 0
2131
+ };
2132
+ for (const row of rows) counts[queueOf(row.state)] += 1;
2133
+ return counts;
2134
+ }
2135
+ function describe(row) {
2136
+ const stage = stageOf(row.state);
2137
+ const target = row.decision?.shouldMerge === true ? " → " + row.decision.target : "";
2138
+ return row.a + " + " + row.b + " [" + row.state + (stage === null ? "" : "/" + stage) + "]" + target + (row.failure === void 0 ? "" : " — " + row.failure.message);
2139
+ }
2140
+ function formatStatus(rows) {
2141
+ const counts = countRows(rows);
2142
+ const lines = ["merge: verify " + String(counts.verify) + ", pending " + String(counts.pending) + ", fuse " + String(counts.fuse) + ", skipped " + String(counts.skipped)];
2143
+ for (const row of rows) lines.push("- " + describe(row));
2144
+ return fitToolText(lines.join("\n"));
2145
+ }
2146
+ function formatFacts(facts) {
2147
+ const lines = ["merge: verify " + String(facts.counts.active.verify) + ", pending " + String(facts.counts.active.pending) + ", fuse " + String(facts.counts.active.fuse) + ", skipped " + String(facts.counts.active.skipped) + ", expired " + String(facts.counts.expired)];
2148
+ if (facts.active !== null) lines.push("active: " + facts.active.id + " [" + facts.active.state + "]");
2149
+ if (facts.last !== null) lines.push("last: " + facts.last.id + " [" + facts.last.state + "]");
2150
+ if (facts.degraded) lines.push("degraded: " + facts.notes.join(","));
2151
+ return fitToolText(lines.join("\n"));
2152
+ }
2153
+ function formatRunsView(view) {
2154
+ if (view.runs.length === 0) return "merge runs: empty";
2155
+ return fitToolText(["merge runs: " + String(view.runs.length)].concat(view.runs.map((run) => "- " + run.id + " [" + run.state + "]" + (run.endedAt === void 0 ? "" : " " + new Date(run.endedAt).toISOString()))).join("\n"));
2156
+ }
2157
+ function formatQueue(rows, queue) {
2158
+ const selected = rows.filter((row) => queueOf(row.state) === queue);
2159
+ if (selected.length === 0) return "merge " + queue + ": empty";
2160
+ return fitToolText(["merge " + queue + ": " + String(selected.length)].concat(selected.map((row) => "- " + describe(row))).join("\n"));
2161
+ }
2162
+ //#endregion
1562
2163
  //#region lib/types/read-commands.js
1563
- async function handleReadOnlyMergeCommand(command, io, home, cards) {
2164
+ async function handleReadOnlyMergeCommand(command, io, home, cards, runs, approval = false) {
1564
2165
  const withNote = (text, note) => note === void 0 ? text : "note: " + note + "\n" + text;
1565
2166
  const load = async () => {
1566
2167
  const result = await loadMergeState(io, home);
@@ -1573,7 +2174,13 @@ async function handleReadOnlyMergeCommand(command, io, home, cards) {
1573
2174
  const row = loaded.state.rows.find((item) => item.id === command.id);
1574
2175
  return withNote(row === void 0 ? "merge row not found: " + command.id : formatStatus([row]), loaded.note);
1575
2176
  }
1576
- return withNote(formatStatus(loaded.state.rows), loaded.note);
2177
+ return withNote(formatFacts(buildFacts({
2178
+ rows: loaded.state.rows,
2179
+ runs: runs ?? [],
2180
+ approval,
2181
+ readAt: Date.now(),
2182
+ ...loaded.note === void 0 ? {} : { notes: [loaded.note] }
2183
+ })), loaded.note);
1577
2184
  }
1578
2185
  if (command.verb === "pending") {
1579
2186
  const loaded = await load();
@@ -1659,6 +2266,115 @@ function createMergeCommand(service) {
1659
2266
  };
1660
2267
  }
1661
2268
  //#endregion
2269
+ //#region lib/types/domain/claim.js
2270
+ function acquireClaim(current, now, leaseMs, token, pid, startedAt) {
2271
+ if (current !== void 0 && current.expiresAt > now) return void 0;
2272
+ return {
2273
+ id: "claim",
2274
+ token,
2275
+ expiresAt: now + Math.max(1, leaseMs),
2276
+ ...pid === void 0 ? {} : { pid },
2277
+ ...startedAt === void 0 ? {} : { startedAt }
2278
+ };
2279
+ }
2280
+ function releaseClaim(current, token) {
2281
+ return current?.token === token ? void 0 : current;
2282
+ }
2283
+ //#endregion
2284
+ //#region lib/types/target-claim.js
2285
+ /** Durable target claim: one approve writer per merge target at a time. */
2286
+ const PROCESS_STARTED_AT = Date.now() - process.uptime() * 1e3;
2287
+ function claimPath(home, target) {
2288
+ return join(home, "merge", "claims", contentHash(target) + ".json");
2289
+ }
2290
+ function parseClaim(raw) {
2291
+ if (raw === null) return void 0;
2292
+ const parsed = JSON.parse(raw);
2293
+ if (typeof parsed.token !== "string" || typeof parsed.expiresAt !== "number") throw new Error("corrupt target claim");
2294
+ return {
2295
+ id: "claim",
2296
+ token: parsed.token,
2297
+ expiresAt: parsed.expiresAt,
2298
+ ...typeof parsed.pid === "number" ? { pid: parsed.pid } : {},
2299
+ ...typeof parsed.startedAt === "number" ? { startedAt: parsed.startedAt } : {}
2300
+ };
2301
+ }
2302
+ async function withGuard(path, fn) {
2303
+ const guard = path + ".guard";
2304
+ await mkdir(dirname(path), { recursive: true });
2305
+ for (let attempt = 0; attempt < 500; attempt += 1) try {
2306
+ const handle = await open(guard, "wx");
2307
+ await handle.writeFile(String(process.pid));
2308
+ await handle.close();
2309
+ try {
2310
+ return await fn();
2311
+ } finally {
2312
+ await unlink(guard).catch(() => {});
2313
+ }
2314
+ } catch (error) {
2315
+ if (error.code !== "EEXIST") throw error;
2316
+ const pid = Number(await readFile(guard, "utf8").catch(() => ""));
2317
+ if (Number.isFinite(pid) && pid > 0 && !isProcessAlive(pid)) await unlink(guard).catch(() => {});
2318
+ await new Promise((resolve) => setTimeout(resolve, 10));
2319
+ }
2320
+ throw new Error("target-claim-guard-timeout");
2321
+ }
2322
+ async function compareAndSwap(io, path, task) {
2323
+ await withGuard(path, async () => {
2324
+ if (io.transact !== void 0) {
2325
+ await io.transact(path, (current) => task(current));
2326
+ return;
2327
+ }
2328
+ const current = await io.exists(path) ? await io.readText(path) : null;
2329
+ const next = task(current);
2330
+ if (next === null) {
2331
+ if (current !== null) try {
2332
+ await io.remove(path);
2333
+ } catch {}
2334
+ } else if (next !== current) await io.writeText(path, next);
2335
+ });
2336
+ }
2337
+ async function releaseTargetClaimQuietly(claim) {
2338
+ if (claim === null || claim === void 0) return;
2339
+ try {
2340
+ await claim.release();
2341
+ } catch {}
2342
+ }
2343
+ async function acquireTargetClaim(io, home, target, now) {
2344
+ const path = claimPath(home, target);
2345
+ const token = randomUUID();
2346
+ let granted;
2347
+ await compareAndSwap(io, path, (raw) => {
2348
+ const current = parseClaim(raw);
2349
+ const claim = acquireClaim(current !== void 0 && current.pid === process.pid && current.startedAt !== void 0 && Math.abs(current.startedAt - PROCESS_STARTED_AT) > 1e3 || current !== void 0 && current.expiresAt > now && current.pid !== void 0 && current.pid !== process.pid && !isProcessAlive(current.pid) ? void 0 : current, now, Number.MAX_SAFE_INTEGER, token, process.pid, PROCESS_STARTED_AT);
2350
+ if (claim === void 0) return raw;
2351
+ granted = claim;
2352
+ return JSON.stringify(claim) + "\n";
2353
+ });
2354
+ if (granted === void 0) return null;
2355
+ return {
2356
+ token,
2357
+ async release() {
2358
+ await compareAndSwap(io, path, (raw) => {
2359
+ const next = releaseClaim(parseClaim(raw), token);
2360
+ return next === void 0 ? null : JSON.stringify(next) + "\n";
2361
+ });
2362
+ }
2363
+ };
2364
+ }
2365
+ //#endregion
2366
+ //#region lib/types/unit-claim.js
2367
+ /** Commit-phase claim: 24h is only stale recovery; commit releases immediately, no approve TTL. */
2368
+ function targetClaimUnitPort(io, home, now) {
2369
+ return { async acquire(target) {
2370
+ const claim = await acquireTargetClaim(io, home, target, now());
2371
+ if (claim === null) throw new Error("target-claim-busy: " + target);
2372
+ return { async release() {
2373
+ await releaseTargetClaimQuietly(claim);
2374
+ } };
2375
+ } };
2376
+ }
2377
+ //#endregion
1662
2378
  //#region lib/types/skipped.js
1663
2379
  /**
1664
2380
  * Skipped filtering: a pair the operator marked as not-worth-merging stays out of automatic
@@ -1806,29 +2522,46 @@ async function runScan(options) {
1806
2522
  });
1807
2523
  let row;
1808
2524
  try {
1809
- const created = transition(void 0, {
1810
- type: "candidate",
2525
+ const key = pairKey(candidate.a, candidate.b);
2526
+ const atNow = at(options.now);
2527
+ row = {
2528
+ id: rowIdOf(key),
2529
+ pairKey: key,
1811
2530
  a: candidate.a,
1812
2531
  b: candidate.b,
1813
- at: at(options.now)
1814
- });
1815
- if (!created.ok || created.row === null) throw new Error(created.ok ? "candidate row missing" : created.message);
1816
- row = options.runId === void 0 ? created.row : {
1817
- ...created.row,
1818
- runId: options.runId
2532
+ state: "verify",
2533
+ queue: "verify",
2534
+ createdAt: atNow,
2535
+ updatedAt: atNow,
2536
+ ...options.runId === void 0 ? {} : { runId: options.runId }
1819
2537
  };
1820
2538
  const before = (await loadMergeState(options.io, options.home)).state.rows.find((item) => item.pairKey === row.pairKey);
1821
2539
  if (before?.state === "verify" && before.runId !== void 0 && before.runId !== options.runId && await options.isOwnerActive?.(before.runId) === true) continue;
1822
- const placed = await upsertRow(options.io, options.home, options.now, row, "verify", before?.updatedAt ?? null);
1823
- if (!placed.ok && before?.state === "skipped") {
1824
- const restored = transition(before, {
1825
- type: "restore",
1826
- at: at(options.now)
2540
+ const status = await transactRows(options.io, options.home, (tx) => {
2541
+ const current = tx.rows().find((item) => item.pairKey === row.pairKey);
2542
+ if (current !== void 0 && current.state !== "verify" && current.state !== "skipped") return "conflict:" + current.state;
2543
+ if (current?.state === "skipped") {
2544
+ if (!tx.transition(current.id, { type: "restored" }, current.version).ok) return "restore-failed";
2545
+ }
2546
+ if (current?.state === "verify") return "existing";
2547
+ const made = tx.createVerify({
2548
+ id: current?.id ?? rowIdOf(row.pairKey),
2549
+ pairKey: row.pairKey,
2550
+ a: row.a,
2551
+ b: row.b,
2552
+ version: 0,
2553
+ createdAt: at(options.now),
2554
+ updatedAt: at(options.now),
2555
+ origin: "agent",
2556
+ state: "verify",
2557
+ verifySubState: "unverified",
2558
+ attempts: 0,
2559
+ ...row.runId === void 0 ? {} : { runId: row.runId }
1827
2560
  });
1828
- if (!restored.ok || restored.row === null) throw new Error("cannot restore skipped pair");
1829
- if (!(await upsertRow(options.io, options.home, options.now, restored.row, "any", before.updatedAt)).ok) throw new Error("pair changed while restoring skipped row");
1830
- row = restored.row;
1831
- } else if (!placed.ok) throw new Error("pair already in " + placed.current.state);
2561
+ return made.ok ? "created" : "invalid:" + made.code;
2562
+ });
2563
+ if (status.startsWith("conflict:")) throw new Error("pair already in " + status.slice(9));
2564
+ if (status === "restore-failed" || status.startsWith("invalid:")) throw new Error("cannot place verify row: " + status);
1832
2565
  } catch (error) {
1833
2566
  totals.failed += 1;
1834
2567
  pairs.push({
@@ -1862,33 +2595,47 @@ async function runScan(options) {
1862
2595
  break;
1863
2596
  }
1864
2597
  if (round1.kind === "failed") {
1865
- const next = transition(row, {
1866
- type: "round1-fail",
1867
- failure: round1.failure ?? failureOf("decide", "round1 failed"),
1868
- at: at(options.now)
2598
+ const failure = round1.failure ?? failureOf("decide", "round1 failed");
2599
+ const projected = await transactRows(options.io, options.home, (tx) => {
2600
+ const current = tx.rows().find((item) => item.id === row.id);
2601
+ if (current === void 0) return null;
2602
+ const moved = tx.transition(row.id, {
2603
+ type: "failed",
2604
+ failure
2605
+ }, current.version);
2606
+ return moved.ok && moved.row !== null ? v3ToLegacy(moved.row) : null;
1869
2607
  });
1870
- if (next.ok && next.row !== null) {
1871
- row = next.row;
1872
- await upsertRow(options.io, options.home, options.now, row);
2608
+ if (projected === null) {
2609
+ pairs.push(pairOf(row, candidate, "failed", { failure: failureOf("decide", "row changed before failure record") }));
2610
+ totals.failed += 1;
2611
+ continue;
1873
2612
  }
1874
- pairs.push(pairOf(row, candidate, "failed", { failure: round1.failure ?? failureOf("decide", "round1 failed") }));
2613
+ row = projected;
2614
+ pairs.push(pairOf(row, candidate, "failed", { failure }));
1875
2615
  totals.failed += 1;
1876
2616
  continue;
1877
2617
  }
1878
2618
  if (round1.kind === "keep") {
1879
- const next = transition(row, {
1880
- type: "round1-keep",
1881
- at: at(options.now)
2619
+ const projected = await transactRows(options.io, options.home, (tx) => {
2620
+ const current = tx.rows().find((item) => item.id === row.id);
2621
+ if (current === void 0) return null;
2622
+ const moved = tx.transition(row.id, {
2623
+ type: "skipped",
2624
+ reason: "kept"
2625
+ }, current.version);
2626
+ return moved.ok && moved.row !== null ? v3ToLegacy(moved.row) : null;
1882
2627
  });
1883
- if (next.ok && next.row !== null) {
1884
- row = next.row;
1885
- await upsertRow(options.io, options.home, options.now, row);
1886
- await recordPair(options.io, options.home, {
1887
- pairKey: row.pairKey,
1888
- runId: row.runId ?? "merge",
1889
- result: "kept"
1890
- });
2628
+ if (projected === null) {
2629
+ pairs.push(pairOf(row, candidate, "failed", { failure: failureOf("decide", "row changed before keep record") }));
2630
+ totals.failed += 1;
2631
+ continue;
1891
2632
  }
2633
+ row = projected;
2634
+ await recordPair(options.io, options.home, {
2635
+ pairKey: row.pairKey,
2636
+ runId: row.runId ?? "merge",
2637
+ result: "kept"
2638
+ });
1892
2639
  pairs.push(pairOf(row, candidate, "kept", round1.decision === void 0 ? {} : { decision: round1.decision }));
1893
2640
  totals.kept += 1;
1894
2641
  continue;
@@ -1899,49 +2646,79 @@ async function runScan(options) {
1899
2646
  continue;
1900
2647
  }
1901
2648
  if (options.approval) {
1902
- const next = transition(row, {
1903
- type: "round1-merge",
1904
- approval: "on",
2649
+ const event = {
2650
+ type: "verify-passed",
1905
2651
  decision: round1.decision,
1906
- ...round1.sourceRevision === void 0 ? {} : { sourceRevision: round1.sourceRevision },
1907
- ...round1.targetRevision === void 0 ? {} : { targetRevision: round1.targetRevision },
1908
- at: at(options.now)
2652
+ target: round1.decision.target,
2653
+ ...round1.sourceRevision === void 0 ? {} : { sourceRevision: round1.sourceRevision }
2654
+ };
2655
+ const projected = await transactRows(options.io, options.home, (tx) => {
2656
+ const current = tx.rows().find((item) => item.id === row.id);
2657
+ if (current === void 0) return null;
2658
+ const moved = tx.transition(row.id, event, current.version);
2659
+ return moved.ok && moved.row !== null ? v3ToLegacy(moved.row) : null;
1909
2660
  });
1910
- if (next.ok && next.row !== null) {
1911
- row = next.row;
1912
- await upsertRow(options.io, options.home, options.now, row);
2661
+ if (projected === null) {
2662
+ pairs.push(pairOf(row, candidate, "failed", {
2663
+ decision: round1.decision,
2664
+ failure: failureOf("decide", "row changed before pending record")
2665
+ }));
2666
+ totals.failed += 1;
2667
+ continue;
1913
2668
  }
2669
+ row = projected;
1914
2670
  pairs.push(pairOf(row, candidate, "pending", { decision: round1.decision }));
1915
2671
  totals.pending += 1;
1916
2672
  continue;
1917
2673
  }
1918
- const fuse = transition(row, {
1919
- type: "round1-merge",
1920
- approval: "off",
1921
- decision: round1.decision,
1922
- ...round1.stagingRef === void 0 ? {} : { stagingRef: round1.stagingRef },
1923
- ...round1.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: round1.stagingExpiresAt },
1924
- at: at(options.now)
2674
+ const staged = await transactRows(options.io, options.home, (tx) => {
2675
+ const current = tx.rows().find((item) => item.id === row.id);
2676
+ if (current === void 0) return null;
2677
+ const approved = tx.transition(row.id, {
2678
+ type: "auto-approved",
2679
+ decision: round1.decision,
2680
+ target: round1.decision.target,
2681
+ ...row.runId === void 0 ? {} : { runId: row.runId }
2682
+ }, current.version);
2683
+ if (!approved.ok || approved.row === null) return null;
2684
+ const moved = tx.transition(row.id, {
2685
+ type: "staged",
2686
+ ...round1.stagingRef === void 0 ? {} : { ref: round1.stagingRef },
2687
+ ...round1.stagingExpiresAt === void 0 ? {} : { expiresAt: round1.stagingExpiresAt }
2688
+ }, approved.row.version);
2689
+ return moved.ok && moved.row !== null ? v3ToLegacy(moved.row) : null;
1925
2690
  });
1926
- if (!fuse.ok || fuse.row === null) {
2691
+ if (staged === null) {
1927
2692
  pairs.push(pairOf(row, candidate, "failed", {
1928
2693
  decision: round1.decision,
1929
- failure: failureOf("decide", "cannot enter fuse")
2694
+ failure: failureOf("author", "row changed before staging record")
1930
2695
  }));
1931
2696
  totals.failed += 1;
1932
2697
  continue;
1933
2698
  }
1934
- row = fuse.row;
1935
- await upsertRow(options.io, options.home, options.now, row);
2699
+ row = staged;
2700
+ if (round1.stagingRef !== void 0) {
2701
+ const expiresAt = round1.stagingExpiresAt ?? options.now() + 7 * 864e5;
2702
+ await writeStagingIndex(options.io, options.home, {
2703
+ rowId: row.id,
2704
+ pairKey: row.pairKey,
2705
+ ref: round1.stagingRef,
2706
+ expiresAt,
2707
+ ...row.runId === void 0 ? {} : { runId: row.runId },
2708
+ createdAt: options.now()
2709
+ });
2710
+ }
1936
2711
  if (isAborted(options.signal)) {
1937
- const cancelled = transition(row, {
1938
- type: "round2-cancel",
1939
- at: at(options.now)
2712
+ const projected = await transactRows(options.io, options.home, (tx) => {
2713
+ const current = tx.rows().find((item) => item.id === row.id);
2714
+ if (current === void 0) return null;
2715
+ const moved = tx.transition(row.id, {
2716
+ type: "skipped",
2717
+ reason: "cancelled"
2718
+ }, current.version);
2719
+ return moved.ok && moved.row !== null ? v3ToLegacy(moved.row) : null;
1940
2720
  });
1941
- if (cancelled.ok && cancelled.row !== null) {
1942
- row = cancelled.row;
1943
- await upsertRow(options.io, options.home, options.now, row);
1944
- }
2721
+ if (projected !== null) row = projected;
1945
2722
  stopReason = "cancelled";
1946
2723
  remaining.push(...candidates.slice(index).map((candidate) => ({
1947
2724
  pairKey: pairKey(candidate.a, candidate.b),
@@ -1963,10 +2740,14 @@ async function runScan(options) {
1963
2740
  };
1964
2741
  }
1965
2742
  if (round2.kind === "merged") {
1966
- if (transition(row, {
1967
- type: "round2-success",
1968
- at: at(options.now)
1969
- }).ok) await dropRow(options.io, options.home, options.now, row.id);
2743
+ if (!await dropRowV3(options.io, options.home, row.id, "committed", round2.unitId ?? "unknown")) {
2744
+ pairs.push(pairOf(row, candidate, "failed", {
2745
+ decision: round1.decision,
2746
+ failure: failureOf("commit", "row changed before commit record")
2747
+ }));
2748
+ totals.failed += 1;
2749
+ continue;
2750
+ }
1970
2751
  pairs.push(pairOf(row, candidate, "merged", {
1971
2752
  decision: round1.decision,
1972
2753
  ...round2.unitId === void 0 ? {} : { unitId: round2.unitId },
@@ -1977,15 +2758,24 @@ async function runScan(options) {
1977
2758
  continue;
1978
2759
  }
1979
2760
  const failed = round2.failure ?? failureOf("author", "round2 failed");
1980
- const next = transition(row, {
1981
- type: "round2-fail",
1982
- failure: failed,
1983
- at: at(options.now)
2761
+ const projected = await transactRows(options.io, options.home, (tx) => {
2762
+ const current = tx.rows().find((item) => item.id === row.id);
2763
+ if (current === void 0) return null;
2764
+ const moved = tx.transition(row.id, {
2765
+ type: "failed",
2766
+ failure: failed
2767
+ }, current.version);
2768
+ return moved.ok && moved.row !== null ? v3ToLegacy(moved.row) : null;
1984
2769
  });
1985
- if (next.ok && next.row !== null) {
1986
- row = next.row;
1987
- await upsertRow(options.io, options.home, options.now, row);
2770
+ if (projected === null) {
2771
+ pairs.push(pairOf(row, candidate, "failed", {
2772
+ decision: round1.decision,
2773
+ failure: failureOf("author", "row changed before failure record")
2774
+ }));
2775
+ totals.failed += 1;
2776
+ continue;
1988
2777
  }
2778
+ row = projected;
1989
2779
  pairs.push(pairOf(row, candidate, "failed", {
1990
2780
  decision: round1.decision,
1991
2781
  failure: failed
@@ -2205,48 +2995,40 @@ async function runScanCommand(options) {
2205
2995
  //#endregion
2206
2996
  //#region lib/types/pending-actions.js
2207
2997
  async function act(io, home, now, id, type) {
2208
- return await mutateMergeState(io, home, now, (rows) => {
2209
- const index = rows.findIndex((row) => row.id === id);
2210
- if (index < 0) return {
2211
- rows,
2212
- result: {
2213
- ok: false,
2214
- reason: "not-found",
2215
- message: "pending row not found: " + id
2216
- }
2998
+ return await transactRows(io, home, (tx) => {
2999
+ const row = tx.rows().find((item) => item.id === id);
3000
+ if (row === void 0) return {
3001
+ ok: false,
3002
+ reason: "not-found",
3003
+ message: "pending row not found: " + id
2217
3004
  };
2218
- const current = rows[index];
2219
- if (current.state !== "pending") return {
2220
- rows,
2221
- result: {
2222
- ok: false,
2223
- reason: "not-pending",
2224
- message: "row is not pending: " + current.state
2225
- }
3005
+ if (row.state !== "pending") return {
3006
+ ok: false,
3007
+ reason: "not-pending",
3008
+ message: "row is not pending: " + row.state
2226
3009
  };
2227
- const next = transition(current, type === "approve" ? {
2228
- type: "approve",
2229
- at: new Date(now()).toISOString()
3010
+ const event = type === "approve" ? {
3011
+ type: "approved",
3012
+ runId: row.runId ?? "approve:" + id
2230
3013
  } : {
2231
- type: "reject",
2232
- at: new Date(now()).toISOString()
2233
- });
2234
- if (!next.ok || next.row === null) return {
2235
- rows,
2236
- result: {
2237
- ok: false,
2238
- reason: "invalid",
2239
- message: next.ok ? "transition produced no row" : next.message
2240
- }
3014
+ type: "skipped",
3015
+ reason: "rejected",
3016
+ origin: "human"
3017
+ };
3018
+ const moved = tx.transition(id, event, row.version);
3019
+ if (!moved.ok) return {
3020
+ ok: false,
3021
+ reason: "invalid",
3022
+ message: moved.message
3023
+ };
3024
+ if (moved.row === null) return {
3025
+ ok: false,
3026
+ reason: "invalid",
3027
+ message: "transition produced no row"
2241
3028
  };
2242
- const copy = [...rows];
2243
- copy[index] = next.row;
2244
3029
  return {
2245
- rows: copy,
2246
- result: {
2247
- ok: true,
2248
- row: next.row
2249
- }
3030
+ ok: true,
3031
+ row: v3ToLegacy(moved.row)
2250
3032
  };
2251
3033
  });
2252
3034
  }
@@ -2257,79 +3039,29 @@ async function rejectPending(io, home, now, id) {
2257
3039
  return await act(io, home, now, id, "reject");
2258
3040
  }
2259
3041
  //#endregion
2260
- //#region lib/types/domain/claim.js
2261
- function acquireClaim(current, now, leaseMs, token) {
2262
- if (current !== void 0 && current.expiresAt > now) return void 0;
2263
- return {
2264
- id: "claim",
2265
- token,
2266
- expiresAt: now + Math.max(1, leaseMs)
2267
- };
2268
- }
2269
- function releaseClaim(current, token) {
2270
- return current?.token === token ? void 0 : current;
2271
- }
2272
- //#endregion
2273
- //#region lib/types/target-claim.js
2274
- /** Durable target claim: one approve writer per merge target at a time. */
2275
- const LEASE_MS = 30 * 6e4;
2276
- function claimPath(home, target) {
2277
- return join(home, "merge", "claims", contentHash(target) + ".json");
2278
- }
2279
- function parseClaim(raw) {
2280
- if (raw === null) return void 0;
2281
- const parsed = JSON.parse(raw);
2282
- if (typeof parsed.token !== "string" || typeof parsed.expiresAt !== "number") throw new Error("corrupt target claim");
2283
- return {
2284
- id: "claim",
2285
- token: parsed.token,
2286
- expiresAt: parsed.expiresAt
2287
- };
2288
- }
2289
- async function compareAndSwap(io, path, task) {
2290
- if (io.transact !== void 0) {
2291
- await io.transact(path, (current) => task(current));
2292
- return;
2293
- }
2294
- const current = await io.exists(path) ? await io.readText(path) : null;
2295
- const next = task(current);
2296
- if (next === null) {
2297
- if (current !== null) try {
2298
- await io.remove(path);
2299
- } catch {}
2300
- } else if (next !== current) await io.writeText(path, next);
2301
- }
2302
- async function acquireTargetClaim(io, home, target, now) {
2303
- const path = claimPath(home, target);
2304
- const token = randomUUID();
2305
- let granted;
2306
- await compareAndSwap(io, path, (raw) => {
2307
- const claim = acquireClaim(parseClaim(raw), now, LEASE_MS, token);
2308
- if (claim === void 0) return raw;
2309
- granted = claim;
2310
- return JSON.stringify(claim) + "\n";
2311
- });
2312
- if (granted === void 0) return null;
2313
- return {
2314
- token,
2315
- async release() {
2316
- await compareAndSwap(io, path, (raw) => {
2317
- const next = releaseClaim(parseClaim(raw), token);
2318
- return next === void 0 ? null : JSON.stringify(next) + "\n";
2319
- });
2320
- }
2321
- };
2322
- }
2323
- //#endregion
2324
3042
  //#region lib/types/round2-outcome.js
2325
3043
  async function recordRound2Outcome(io, home, now, row, outcome) {
2326
- const at = new Date(now()).toISOString();
2327
3044
  if (outcome.kind === "merged") {
2328
- if (transition(row, {
2329
- type: "round2-success",
2330
- at
2331
- }).ok) {
2332
- await dropRow(io, home, now, row.id);
3045
+ {
3046
+ if (!await dropRowV3(io, home, row.id, "committed", outcome.unitId ?? "unknown")) return {
3047
+ merged: false,
3048
+ pair: {
3049
+ id: row.id,
3050
+ pairKey: row.pairKey,
3051
+ a: row.a,
3052
+ b: row.b,
3053
+ queue: row.queue,
3054
+ outcome: "failed",
3055
+ archived: [],
3056
+ failure: {
3057
+ stage: "commit",
3058
+ code: "row-changed",
3059
+ message: "row changed before commit record",
3060
+ retryable: true,
3061
+ retryFrom: "agent2"
3062
+ }
3063
+ }
3064
+ };
2333
3065
  const target = outcome.target?.actual ?? outcome.target?.requested ?? row.decision?.target;
2334
3066
  await recordPair(io, home, {
2335
3067
  pairKey: row.pairKey,
@@ -2362,20 +3094,37 @@ async function recordRound2Outcome(io, home, now, row, outcome) {
2362
3094
  retryable: true,
2363
3095
  retryFrom: "agent2"
2364
3096
  };
2365
- const next = transition(row, {
2366
- type: "round2-fail",
2367
- failure,
2368
- at
2369
- });
2370
- if (next.ok && next.row !== null) {
2371
- await upsertRow(io, home, now, next.row);
2372
- await recordPair(io, home, {
3097
+ if (await transactRows(io, home, (tx) => {
3098
+ const current = tx.rows().find((item) => item.id === row.id);
3099
+ if (current === void 0) return null;
3100
+ const moved = tx.transition(row.id, {
3101
+ type: "failed",
3102
+ failure
3103
+ }, current.version);
3104
+ return moved.ok && moved.row !== null ? v3ToLegacy(moved.row) : null;
3105
+ }) === null) return {
3106
+ merged: false,
3107
+ pair: {
3108
+ id: row.id,
2373
3109
  pairKey: row.pairKey,
2374
- runId: row.runId ?? "merge",
2375
- result: "failed",
2376
- ...row.decision?.target === void 0 ? {} : { target: row.decision.target }
2377
- });
2378
- }
3110
+ a: row.a,
3111
+ b: row.b,
3112
+ queue: row.queue,
3113
+ outcome: "failed",
3114
+ archived: [],
3115
+ failure: {
3116
+ ...failure,
3117
+ code: "row-changed",
3118
+ message: "row changed before failure record"
3119
+ }
3120
+ }
3121
+ };
3122
+ await recordPair(io, home, {
3123
+ pairKey: row.pairKey,
3124
+ runId: row.runId ?? "merge",
3125
+ result: "failed",
3126
+ ...row.decision?.target === void 0 ? {} : { target: row.decision.target }
3127
+ });
2379
3128
  return {
2380
3129
  merged: false,
2381
3130
  pair: {
@@ -2394,89 +3143,89 @@ async function recordRound2Outcome(io, home, now, row, outcome) {
2394
3143
  //#endregion
2395
3144
  //#region lib/types/approve-command.js
2396
3145
  async function runApproveCommand(options) {
2397
- const target = (await loadMergeState(options.io, options.home)).state.rows.find((item) => item.id === options.rowId)?.decision?.target;
2398
- const claim = target === void 0 ? null : await acquireTargetClaim(options.io, options.home, target, options.now());
2399
- if (target !== void 0 && claim === null) return {
3146
+ const before = (await loadMergeState(options.io, options.home)).state.rows.find((item) => item.id === options.rowId);
3147
+ if (before === void 0) return {
2400
3148
  ok: false,
2401
- message: "target already claimed by another approve: " + target
3149
+ message: "pending row not found: " + options.rowId
2402
3150
  };
2403
- try {
2404
- const taken = await approvePending(options.io, options.home, options.now, options.rowId);
2405
- if (!taken.ok) return {
2406
- ok: false,
2407
- message: taken.message
2408
- };
2409
- const row = taken.row;
2410
- const decision = row.decision;
2411
- if (decision === void 0) return {
2412
- ok: false,
2413
- message: "pending row has no decision"
2414
- };
2415
- return await withMergeRun({
2416
- io: options.io,
2417
- home: options.home,
2418
- registry: options.registry,
2419
- command: "approve",
2420
- approval: {
2421
- platformWrite: options.platformWrite,
2422
- mergeApproval: true
2423
- },
2424
- now: options.now,
2425
- async body(ctx) {
2426
- const stage = (name) => ctx.stage(name, {
2427
- done: 0,
2428
- total: 1
2429
- });
2430
- await stage("author");
2431
- let outcome;
2432
- try {
2433
- outcome = await options.ports.round2(row, decision, ctx.signal, ctx.runId);
2434
- } catch (error) {
2435
- outcome = {
2436
- kind: "failed",
2437
- failure: {
2438
- stage: "author",
2439
- code: "merge-failed",
2440
- message: error instanceof Error ? error.message : String(error),
2441
- retryable: true,
2442
- retryFrom: "agent2"
2443
- }
2444
- };
2445
- }
2446
- const base = {
2447
- scope: {
2448
- candidatePairs: 1,
2449
- queueBefore: {
2450
- verify: 0,
2451
- pending: 0,
2452
- fuse: 1,
2453
- skipped: 0
2454
- }
2455
- },
2456
- notes: []
2457
- };
2458
- const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome);
2459
- return {
2460
- ...base,
2461
- stopReason: record.merged ? "completed" : "failed",
2462
- pairs: [record.pair],
2463
- totals: record.merged ? {
2464
- merged: 1,
2465
- kept: 0,
2466
- pending: 0,
2467
- failed: 0
2468
- } : {
2469
- merged: 0,
2470
- kept: 0,
2471
- pending: 0,
2472
- failed: 1
3151
+ if (before.state !== "pending") return {
3152
+ ok: false,
3153
+ message: "row is not pending: " + before.state
3154
+ };
3155
+ const taken = await approvePending(options.io, options.home, options.now, options.rowId);
3156
+ if (!taken.ok) return {
3157
+ ok: false,
3158
+ message: taken.message
3159
+ };
3160
+ const row = taken.row;
3161
+ const decision = row.decision;
3162
+ if (decision === void 0) return {
3163
+ ok: false,
3164
+ message: "pending row has no decision"
3165
+ };
3166
+ return await withMergeRun({
3167
+ io: options.io,
3168
+ home: options.home,
3169
+ registry: options.registry,
3170
+ command: "approve",
3171
+ approval: {
3172
+ platformWrite: options.platformWrite,
3173
+ mergeApproval: true
3174
+ },
3175
+ now: options.now,
3176
+ ...options.detached === true ? { detached: true } : {},
3177
+ async body(ctx) {
3178
+ const stage = (name) => ctx.stage(name, {
3179
+ done: 0,
3180
+ total: 1
3181
+ });
3182
+ await stage("author");
3183
+ let outcome;
3184
+ try {
3185
+ outcome = await options.ports.round2(row, decision, ctx.signal, ctx.runId);
3186
+ } catch (error) {
3187
+ outcome = {
3188
+ kind: "failed",
3189
+ failure: {
3190
+ stage: "author",
3191
+ code: "merge-failed",
3192
+ message: error instanceof Error ? error.message : String(error),
3193
+ retryable: true,
3194
+ retryFrom: "agent2"
2473
3195
  }
2474
3196
  };
2475
3197
  }
2476
- });
2477
- } finally {
2478
- await claim?.release();
2479
- }
3198
+ const base = {
3199
+ scope: {
3200
+ candidatePairs: 1,
3201
+ queueBefore: {
3202
+ verify: 0,
3203
+ pending: 0,
3204
+ fuse: 1,
3205
+ skipped: 0
3206
+ }
3207
+ },
3208
+ notes: []
3209
+ };
3210
+ const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome);
3211
+ return {
3212
+ ...base,
3213
+ stopReason: record.merged ? "completed" : "failed",
3214
+ pairs: [record.pair],
3215
+ totals: record.merged ? {
3216
+ merged: 1,
3217
+ kept: 0,
3218
+ pending: 0,
3219
+ failed: 0
3220
+ } : {
3221
+ merged: 0,
3222
+ kept: 0,
3223
+ pending: 0,
3224
+ failed: 1
3225
+ }
3226
+ };
3227
+ }
3228
+ });
2480
3229
  }
2481
3230
  //#endregion
2482
3231
  //#region lib/types/resume-command.js
@@ -2498,6 +3247,7 @@ async function runResumeCommand(options) {
2498
3247
  approval: true,
2499
3248
  platformWrite: options.platformWrite,
2500
3249
  now: options.now,
3250
+ ...options.detached === true ? { detached: true } : {},
2501
3251
  ...options.makePorts === void 0 ? { ports: {
2502
3252
  ...options.ports,
2503
3253
  screening: () => Promise.resolve([{
@@ -2542,6 +3292,7 @@ async function runResumeCommand(options) {
2542
3292
  mergeApproval: true
2543
3293
  },
2544
3294
  now: options.now,
3295
+ ...options.detached === true ? { detached: true } : {},
2545
3296
  async body(ctx) {
2546
3297
  await ctx.stage("author", {
2547
3298
  done: 0,
@@ -2658,6 +3409,245 @@ function skillLibraryUndoPort(skills) {
2658
3409
  };
2659
3410
  }
2660
3411
  //#endregion
3412
+ //#region lib/types/domain/state-v3.js
3413
+ function v2BackupPath(home) {
3414
+ return join(home, "merge", "merge-state.v2.bak.json");
3415
+ }
3416
+ //#endregion
3417
+ //#region lib/types/domain/consistency.js
3418
+ const ALLOWED = [
3419
+ {
3420
+ row: "verify",
3421
+ run: "none",
3422
+ staging: "none",
3423
+ journal: "none",
3424
+ evidence: "none",
3425
+ activeRuns: 0
3426
+ },
3427
+ {
3428
+ row: "verify",
3429
+ run: "running",
3430
+ staging: "none",
3431
+ journal: "none",
3432
+ evidence: "none",
3433
+ activeRuns: 1
3434
+ },
3435
+ {
3436
+ row: "verify",
3437
+ run: "failed",
3438
+ staging: "none",
3439
+ journal: "none",
3440
+ evidence: "none",
3441
+ activeRuns: 0
3442
+ },
3443
+ {
3444
+ row: "pending",
3445
+ run: "none",
3446
+ staging: "none",
3447
+ journal: "none",
3448
+ evidence: "none",
3449
+ activeRuns: 0
3450
+ },
3451
+ {
3452
+ row: "pending",
3453
+ run: "awaiting-approval",
3454
+ staging: "none",
3455
+ journal: "none",
3456
+ evidence: "none",
3457
+ activeRuns: 1
3458
+ },
3459
+ {
3460
+ row: "approved",
3461
+ run: "none",
3462
+ staging: "none",
3463
+ journal: "none",
3464
+ evidence: "none",
3465
+ activeRuns: 0
3466
+ },
3467
+ {
3468
+ row: "approved",
3469
+ run: "running",
3470
+ staging: "none",
3471
+ journal: "none",
3472
+ evidence: "none",
3473
+ activeRuns: 1
3474
+ },
3475
+ {
3476
+ row: "fuse",
3477
+ run: "none",
3478
+ staging: "kept",
3479
+ journal: "none",
3480
+ evidence: "none",
3481
+ activeRuns: 0
3482
+ },
3483
+ {
3484
+ row: "fuse",
3485
+ run: "running",
3486
+ staging: "kept",
3487
+ journal: "none",
3488
+ evidence: "none",
3489
+ activeRuns: 1
3490
+ },
3491
+ {
3492
+ row: "fuse",
3493
+ run: "failed",
3494
+ staging: "kept",
3495
+ journal: "none",
3496
+ evidence: "none",
3497
+ activeRuns: 0
3498
+ },
3499
+ {
3500
+ row: "fuse",
3501
+ run: "orphan",
3502
+ staging: "kept",
3503
+ journal: "none",
3504
+ evidence: "none",
3505
+ activeRuns: 0
3506
+ },
3507
+ {
3508
+ row: "fuse",
3509
+ run: "none",
3510
+ staging: "none",
3511
+ journal: "none",
3512
+ evidence: "none",
3513
+ activeRuns: 0,
3514
+ recovery: true
3515
+ },
3516
+ {
3517
+ row: "fuse",
3518
+ run: "failed",
3519
+ staging: "none",
3520
+ journal: "none",
3521
+ evidence: "none",
3522
+ activeRuns: 0,
3523
+ recovery: true
3524
+ },
3525
+ {
3526
+ row: "fuse",
3527
+ run: "orphan",
3528
+ staging: "none",
3529
+ journal: "none",
3530
+ evidence: "none",
3531
+ activeRuns: 0,
3532
+ recovery: true
3533
+ },
3534
+ {
3535
+ row: "fuse",
3536
+ run: "running",
3537
+ staging: "none",
3538
+ journal: "none",
3539
+ evidence: "none",
3540
+ activeRuns: 1,
3541
+ recovery: true
3542
+ },
3543
+ {
3544
+ row: "fuse",
3545
+ run: "none",
3546
+ staging: "missing",
3547
+ journal: "none",
3548
+ evidence: "none",
3549
+ activeRuns: 0,
3550
+ recovery: true
3551
+ },
3552
+ {
3553
+ row: "fuse",
3554
+ run: "failed",
3555
+ staging: "missing",
3556
+ journal: "none",
3557
+ evidence: "none",
3558
+ activeRuns: 0,
3559
+ recovery: true
3560
+ },
3561
+ {
3562
+ row: "fuse",
3563
+ run: "orphan",
3564
+ staging: "missing",
3565
+ journal: "none",
3566
+ evidence: "none",
3567
+ activeRuns: 0,
3568
+ recovery: true
3569
+ },
3570
+ {
3571
+ row: "fuse",
3572
+ run: "running",
3573
+ staging: "missing",
3574
+ journal: "none",
3575
+ evidence: "none",
3576
+ activeRuns: 1,
3577
+ recovery: true
3578
+ },
3579
+ {
3580
+ row: "fuse",
3581
+ run: "running",
3582
+ staging: "kept",
3583
+ journal: "committing",
3584
+ evidence: "none",
3585
+ activeRuns: 1
3586
+ },
3587
+ {
3588
+ row: "skipped",
3589
+ run: "none",
3590
+ staging: "none",
3591
+ journal: "none",
3592
+ evidence: "none",
3593
+ activeRuns: 0
3594
+ },
3595
+ {
3596
+ row: "merged",
3597
+ run: "succeeded",
3598
+ staging: "cleaned",
3599
+ journal: "committed",
3600
+ evidence: "receipt",
3601
+ activeRuns: 0
3602
+ },
3603
+ {
3604
+ row: "merged",
3605
+ run: "succeeded",
3606
+ staging: "kept",
3607
+ journal: "committed",
3608
+ evidence: "receipt",
3609
+ activeRuns: 0
3610
+ }
3611
+ ];
3612
+ function same(a, b) {
3613
+ return a.row === b.row && a.run === b.run && a.staging === b.staging && a.journal === b.journal && a.evidence === b.evidence && a.activeRuns === b.activeRuns && (b.recovery ?? false) === (a.recovery ?? false);
3614
+ }
3615
+ function validateArtifacts(state) {
3616
+ if (state.activeRuns > 1) return {
3617
+ ok: false,
3618
+ reason: "multiple-active-runs"
3619
+ };
3620
+ if (state.activeRuns > 0 && state.hasOwner === false) return {
3621
+ ok: false,
3622
+ reason: "active-run-no-owner"
3623
+ };
3624
+ if (ALLOWED.some((item) => same(state, item))) return { ok: true };
3625
+ if (state.row === "pending" && state.staging !== "none") return {
3626
+ ok: false,
3627
+ reason: "pending-staging"
3628
+ };
3629
+ if (state.row === "skipped" && state.run === "running") return {
3630
+ ok: false,
3631
+ reason: "skipped-running"
3632
+ };
3633
+ if (state.row === "fuse" && state.staging === "missing" && state.recovery !== true) return {
3634
+ ok: false,
3635
+ reason: "fuse-lost-staging"
3636
+ };
3637
+ if (state.journal === "committed" && state.evidence !== "receipt") return {
3638
+ ok: false,
3639
+ reason: "committed-no-evidence"
3640
+ };
3641
+ if (state.row === "merged" && state.journal === "rolled-back") return {
3642
+ ok: false,
3643
+ reason: "committed-rollback"
3644
+ };
3645
+ return {
3646
+ ok: false,
3647
+ reason: "not-allowed"
3648
+ };
3649
+ }
3650
+ //#endregion
2661
3651
  //#region lib/types/doctor.js
2662
3652
  /**
2663
3653
  * Merge doctor rows: state, pending, gate cache and staging are each probed read-only.
@@ -2665,6 +3655,13 @@ function skillLibraryUndoPort(skills) {
2665
3655
  * operator can act on. This module never repairs or writes.
2666
3656
  */
2667
3657
  const MERGE_DOCTOR_SCHEMA_VERSION = 3;
3658
+ /** Global doctor host section: a missing merge mount must fail with E-325, not render empty. */
3659
+ function mergeDoctorAvailability(mounted) {
3660
+ return mounted ? { ok: true } : {
3661
+ ok: false,
3662
+ code: "E-325"
3663
+ };
3664
+ }
2668
3665
  async function mergeDoctor(io, home, configuredRoot) {
2669
3666
  const rows = [];
2670
3667
  const loaded = await loadMergeState(io, home);
@@ -2675,12 +3672,120 @@ async function mergeDoctor(io, home, configuredRoot) {
2675
3672
  status: loaded.note === void 0 ? "ok" : "warn",
2676
3673
  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)
2677
3674
  });
2678
- const modelRoot = configuredRoot !== void 0 && configuredRoot !== "" ? configuredRoot : process.env.SKILL_MERGE_MODELS_ROOT;
2679
- if (modelRoot === void 0 || modelRoot === "") rows.push({
3675
+ const recoveryRuns = /* @__PURE__ */ new Set();
3676
+ try {
3677
+ const raw = await io.readText(join(home, "merge", "recovery.json"));
3678
+ if (raw !== null) {
3679
+ for (const report of JSON.parse(raw)) if (report.ok === true && report.runId !== void 0) recoveryRuns.add(report.runId);
3680
+ }
3681
+ } catch {}
3682
+ const receiptRuns = /* @__PURE__ */ new Set();
3683
+ const receiptDir = await probeList(io, join(home, "merge", "receipts"));
3684
+ if (receiptDir.kind === "present") for (const name of receiptDir.value) receiptRuns.add(name);
3685
+ const invalid = [];
3686
+ for (const row of loaded.state.rows) {
3687
+ const run = row.state === "pending" && row.runId !== void 0 ? "awaiting-approval" : row.runId === void 0 ? "none" : "running";
3688
+ const staging = row.stagingRef !== void 0 ? "kept" : row.state === "fuse" ? "missing" : "none";
3689
+ const phase = row.runId === void 0 ? void 0 : (await readJournal(io, home, row.runId))?.phase;
3690
+ const journal = phase === void 0 || phase === null ? "none" : phase === "committed" ? "committed" : phase === "prepared" ? "prepared" : phase === "rolled-back" ? "rolled-back" : phase === "failed" ? "failed" : "committing";
3691
+ const evidence = row.runId !== void 0 && receiptRuns.has(row.runId) ? "receipt" : "none";
3692
+ const recovery = row.runId !== void 0 && recoveryRuns.has(row.runId);
3693
+ if (!validateArtifacts({
3694
+ row: row.state,
3695
+ run,
3696
+ staging,
3697
+ journal,
3698
+ evidence,
3699
+ activeRuns: run === "running" || run === "awaiting-approval" ? 1 : 0,
3700
+ recovery
3701
+ }).ok) invalid.push(row);
3702
+ }
3703
+ const schema = await loadStateSchema(io, home);
3704
+ rows.push({
3705
+ key: "merge-state-v3",
3706
+ label: "state schema",
3707
+ status: schema === 3 ? "ok" : schema === 2 ? "warn" : "unknown",
3708
+ detail: schema === 3 ? "schemaVersion 3" : schema === 2 ? "schemaVersion 2;等待 v3 迁移" : "schemaVersion 未知/不可读"
3709
+ });
3710
+ let quarantineCount = 0;
3711
+ try {
3712
+ const qraw = await io.readText(join(home, "merge", "merge-state.quarantine.json"));
3713
+ if (qraw !== null) {
3714
+ const parsed = JSON.parse(qraw);
3715
+ quarantineCount = Array.isArray(parsed) ? parsed.length : 0;
3716
+ }
3717
+ } catch {
3718
+ quarantineCount = -1;
3719
+ }
3720
+ rows.push({
3721
+ key: "quarantine",
3722
+ label: "quarantine",
3723
+ status: quarantineCount < 0 ? "unknown" : quarantineCount === 0 ? "ok" : "warn",
3724
+ detail: quarantineCount < 0 ? "quarantine 不可读" : quarantineCount + " 行待人工检查"
3725
+ });
3726
+ const holder = await readTreeLock(io, home).catch(() => null);
3727
+ rows.push({
3728
+ key: "tree-lock",
3729
+ label: "tree lock",
3730
+ status: holder == null ? "ok" : "warn",
3731
+ detail: holder == null ? "free" : "holder pid=" + String(holder.pid) + " epoch=" + String(holder.epoch) + " token=" + holder.token.slice(0, 8)
3732
+ });
3733
+ rows.push({
3734
+ key: "backup",
3735
+ label: "v2 backup",
3736
+ status: await io.exists(v2BackupPath(home)) ? "ok" : "unknown",
3737
+ detail: await io.exists(v2BackupPath(home)) ? "merge-state.v2.bak.json" : "no v2 backup (not migrated or already cleaned)"
3738
+ });
3739
+ const claims = await probeList(io, join(home, "merge", "claims"));
3740
+ rows.push({
3741
+ key: "target-claim",
3742
+ label: "target claim",
3743
+ status: claims.kind === "unknown" ? "unknown" : "ok",
3744
+ detail: claims.kind === "unknown" ? "claims unreadable" : claims.kind === "absent" ? "no claims" : String(claims.value.length) + " claim files"
3745
+ });
3746
+ let orphanRuns = 0;
3747
+ let runCount = 0;
3748
+ try {
3749
+ const raw = await io.readText(runsFile(home));
3750
+ if (raw !== null) {
3751
+ const parsed = JSON.parse(raw);
3752
+ runCount = parsed.runs?.length ?? 0;
3753
+ for (const run of parsed.runs ?? []) {
3754
+ if (run.state !== "running" || typeof run.pid !== "number") continue;
3755
+ try {
3756
+ process.kill(run.pid, 0);
3757
+ } catch {
3758
+ orphanRuns += 1;
3759
+ }
3760
+ }
3761
+ }
3762
+ } catch {
3763
+ orphanRuns = -1;
3764
+ }
3765
+ rows.push({
3766
+ key: "runs",
3767
+ label: "run owner",
3768
+ status: orphanRuns < 0 ? "unknown" : orphanRuns === 0 ? "ok" : "warn",
3769
+ detail: orphanRuns < 0 ? "runs.json unreadable" : String(runCount) + " runs; " + String(orphanRuns) + " dead-owner running"
3770
+ });
3771
+ rows.push({
3772
+ key: "install",
3773
+ label: "install form",
3774
+ status: io.exists === void 0 ? "unknown" : "ok",
3775
+ detail: "merge plugin loaded"
3776
+ });
3777
+ rows.push({
3778
+ key: "consistency",
3779
+ label: "一致性",
3780
+ status: invalid.length === 0 ? "ok" : "warn",
3781
+ detail: invalid.length === 0 ? "无非法组合" : String(invalid.length) + " 行需人工检查: " + invalid.map((row) => row.id).join(", ")
3782
+ });
3783
+ const modelRoot = configuredRoot?.trim() ?? "";
3784
+ if (modelRoot === "") rows.push({
2680
3785
  key: "models",
2681
3786
  label: "合并模型",
2682
3787
  status: "warn",
2683
- detail: "未设置 SKILL_MERGE_MODELS_ROOT;合并扫描会 fail loud"
3788
+ detail: "未配置 mergeModelsRoot;合并扫描会 fail loud"
2684
3789
  });
2685
3790
  else {
2686
3791
  const int8 = join(modelRoot, "potion-multilingual-128M-int8");
@@ -2707,12 +3812,32 @@ async function mergeDoctor(io, home, configuredRoot) {
2707
3812
  status: "ok",
2708
3813
  detail: stagingRoot(home, "<runId>")
2709
3814
  });
3815
+ try {
3816
+ const raw = await io.readText(join(home, "merge", "recovery.json"));
3817
+ if (raw !== null) {
3818
+ const reports = JSON.parse(raw);
3819
+ const failed = reports.filter((report) => report.ok !== true);
3820
+ rows.push({
3821
+ key: "recovery",
3822
+ label: "提交恢复",
3823
+ status: failed.length === 0 ? "ok" : "warn",
3824
+ detail: failed.length === 0 ? String(reports.length) + " 条恢复报告,无待处理" : String(failed.length) + " 条待处理:" + failed.map((report) => (report.runId ?? "?") + "=" + (report.action ?? "?")).join(", ")
3825
+ });
3826
+ }
3827
+ } catch {
3828
+ rows.push({
3829
+ key: "recovery",
3830
+ label: "提交恢复",
3831
+ status: "unknown",
3832
+ detail: "无法读取 merge/recovery.json"
3833
+ });
3834
+ }
2710
3835
  return rows;
2711
3836
  }
2712
3837
  //#endregion
2713
3838
  //#region lib/types/decision.js
2714
3839
  function extractJson(text) {
2715
- const body = /\`\`\`(?:json)?\s*([\s\S]*?)\`\`\`/i.exec(text)?.[1] ?? text;
3840
+ const body = /`\``(?:json)?\s*([\s\S]*?)`\``/i.exec(text)?.[1] ?? text;
2716
3841
  const start = body.indexOf("{");
2717
3842
  if (start < 0) return void 0;
2718
3843
  let depth = 0;
@@ -3239,12 +4364,17 @@ async function assembleStagedCommit(io, home, runId, port, options) {
3239
4364
  const requested = staging.value.manifest.target;
3240
4365
  let target = requested;
3241
4366
  let targetCurrent = await port.readSkill(requested);
4367
+ if (staging.value.manifest.mode !== "create-umbrella" && targetCurrent === null) return {
4368
+ ok: false,
4369
+ message: "target is gone; refusing to re-create it: " + requested
4370
+ };
3242
4371
  const requestedArchived = targetCurrent === null && await port.isArchived?.(requested) === true;
3243
- if (staging.value.manifest.mode === "create-umbrella" && (targetCurrent !== null || requestedArchived)) {
4372
+ const requestedOccupied = targetCurrent === null && await port.isNameOccupied?.(requested) === true;
4373
+ if (staging.value.manifest.mode === "create-umbrella" && (targetCurrent !== null || requestedArchived || requestedOccupied)) {
3244
4374
  target = requested;
3245
4375
  for (let suffix = 2; suffix <= 99; suffix += 1) {
3246
4376
  const candidate = requested + "-v" + String(suffix);
3247
- if (await port.readSkill(candidate) === null && await port.isArchived?.(candidate) !== true) {
4377
+ if (await port.readSkill(candidate) === null && await port.isArchived?.(candidate) !== true && await port.isNameOccupied?.(candidate) !== true) {
3248
4378
  target = candidate;
3249
4379
  targetCurrent = null;
3250
4380
  break;
@@ -3289,27 +4419,87 @@ async function commitStagedRun(io, home, runId, port, options) {
3289
4419
  ok: false,
3290
4420
  message: assembled.message
3291
4421
  };
3292
- try {
3293
- await writeCommitSnapshot(io, home, runId, assembled.plan, options.now(), port);
3294
- } catch (error) {
3295
- return {
3296
- ok: false,
3297
- message: "commit snapshot failed; refusing to write: " + (error instanceof Error ? error.message : String(error)),
3298
- plan: assembled.plan
3299
- };
3300
- }
4422
+ if (await options.isBlocked?.(assembled.plan.target) === true) return {
4423
+ ok: false,
4424
+ message: "target is blocked by curator: " + assembled.plan.target,
4425
+ plan: assembled.plan
4426
+ };
3301
4427
  const journal = {
3302
4428
  runId,
3303
4429
  target: assembled.plan.target,
3304
4430
  create: !assembled.plan.targetExists,
3305
4431
  sources: assembled.plan.sources,
3306
- phase: "sources-archived",
4432
+ phase: "prepared",
3307
4433
  updatedAt: new Date(options.now()).toISOString()
3308
4434
  };
3309
- try {
3310
- await writeJournal(io, home, journal);
3311
- } catch {}
3312
- const result = await port.commitMerge(assembled.plan);
4435
+ let result;
4436
+ const transaction = options.unit ?? port.unit;
4437
+ if (transaction !== void 0) {
4438
+ const first = assembled.plan.sources[0] ?? assembled.plan.target;
4439
+ const unitPlan = {
4440
+ runId,
4441
+ pairKey: pairKey(assembled.plan.target, first),
4442
+ target: assembled.plan.target,
4443
+ targetExists: assembled.plan.targetExists,
4444
+ sources: assembled.plan.sources,
4445
+ body: assembled.plan.body,
4446
+ supportFiles: assembled.plan.supportFiles,
4447
+ expectedTargetBody: assembled.plan.expectedTargetBody,
4448
+ expectedSourceBodies: assembled.plan.expectedSourceBodies,
4449
+ origin: options.origin,
4450
+ summary: options.summary
4451
+ };
4452
+ try {
4453
+ await writeJournal(io, home, journal);
4454
+ } catch {}
4455
+ const prepared = await transaction.prepare(unitPlan);
4456
+ if (!prepared.ok) return {
4457
+ ok: false,
4458
+ message: prepared.message,
4459
+ plan: assembled.plan
4460
+ };
4461
+ const committed = await transaction.commit(prepared.prepared);
4462
+ if (!committed.ok) {
4463
+ try {
4464
+ await writeJournal(io, home, {
4465
+ ...journal,
4466
+ phase: "failed",
4467
+ updatedAt: new Date(options.now()).toISOString()
4468
+ });
4469
+ } catch {}
4470
+ return {
4471
+ ok: false,
4472
+ message: committed.message,
4473
+ plan: assembled.plan
4474
+ };
4475
+ }
4476
+ result = {
4477
+ ok: true,
4478
+ message: "merged",
4479
+ unitId: committed.evidence.unitId,
4480
+ archived: committed.evidence.sources.filter((source) => source.archived).map((source) => source.name),
4481
+ mutations: committed.evidence.mutations
4482
+ };
4483
+ } else {
4484
+ try {
4485
+ await writeCommitSnapshot(io, home, runId, assembled.plan, options.now(), port);
4486
+ } catch (error) {
4487
+ return {
4488
+ ok: false,
4489
+ message: "commit snapshot failed; refusing to write: " + (error instanceof Error ? error.message : String(error)),
4490
+ plan: assembled.plan
4491
+ };
4492
+ }
4493
+ try {
4494
+ await writeJournal(io, home, journal);
4495
+ } catch {}
4496
+ if (port.commitMerge === void 0) return {
4497
+ ok: false,
4498
+ message: "commit port missing: UnitTransaction or commitMerge required",
4499
+ plan: assembled.plan
4500
+ };
4501
+ result = await port.commitMerge(assembled.plan);
4502
+ }
3313
4503
  if (!result.ok) {
3314
4504
  try {
3315
4505
  await writeJournal(io, home, {
@@ -3327,7 +4517,8 @@ async function commitStagedRun(io, home, runId, port, options) {
3327
4517
  const committed = {
3328
4518
  ...journal,
3329
4519
  phase: "committed",
3330
- updatedAt: new Date(options.now()).toISOString()
4520
+ updatedAt: new Date(options.now()).toISOString(),
4521
+ ...result.unitId === void 0 ? {} : { unitId: result.unitId }
3331
4522
  };
3332
4523
  try {
3333
4524
  await writeJournal(io, home, committed);
@@ -3373,13 +4564,25 @@ async function commitStagedRun(io, home, runId, port, options) {
3373
4564
  //#region lib/types/authoring-flow.js
3374
4565
  async function runAuthoring(options) {
3375
4566
  const targetCard = options.cards.find((card) => card.name === options.target);
3376
- if (targetCard?.protectionUnknown === true || targetCard?.protectedBy === "pinned") return {
4567
+ const mode = options.mode ?? (targetCard === void 0 ? "create-umbrella" : "merge-into-existing");
4568
+ const createMode = mode === "create-umbrella";
4569
+ if (!createMode && targetCard !== void 0) {
4570
+ if (targetCard.managed !== true) return {
4571
+ ok: false,
4572
+ message: "target is not family-managed: " + options.target
4573
+ };
4574
+ if (targetCard.protectionUnknown === true || targetCard.protectedBy != null) return {
4575
+ ok: false,
4576
+ message: "target is protected (" + (targetCard.protectedBy ?? "unknown") + "): " + options.target
4577
+ };
4578
+ }
4579
+ if (!createMode && await options.isBlocked?.(options.target) === true) return {
3377
4580
  ok: false,
3378
- message: "target is pinned: " + options.target
4581
+ message: "target is blocked by curator: " + options.target
3379
4582
  };
3380
4583
  const stageKey = options.stageKey ?? options.runId;
3381
4584
  if (options.reuseStaging === true) {
3382
- if (!await setStagingTarget(options.io, options.home, stageKey, options.target, options.mode)) return {
4585
+ if (!await setStagingTarget(options.io, options.home, stageKey, options.target, mode)) return {
3383
4586
  ok: false,
3384
4587
  message: "staging missing or unreadable: " + stageKey
3385
4588
  };
@@ -3387,7 +4590,7 @@ async function runAuthoring(options) {
3387
4590
  const created = await createStaging(options.io, options.home, {
3388
4591
  runId: stageKey,
3389
4592
  target: options.target,
3390
- ...options.mode === void 0 ? {} : { mode: options.mode },
4593
+ mode,
3391
4594
  sources: options.cards.map((card) => ({
3392
4595
  name: card.name,
3393
4596
  body: card.body,
@@ -3425,9 +4628,11 @@ async function runAuthoring(options) {
3425
4628
  findings
3426
4629
  };
3427
4630
  const commit = await commitStagedRun(options.io, options.home, stageKey, options.commitPort, {
3428
- origin: "subagent",
4631
+ origin: "foreground",
3429
4632
  summary: options.summary,
3430
- now: options.now
4633
+ now: options.now,
4634
+ ...options.isBlocked === void 0 ? {} : { isBlocked: options.isBlocked },
4635
+ ...options.unit === void 0 ? {} : { unit: options.unit }
3431
4636
  });
3432
4637
  if (!commit.ok) return {
3433
4638
  ok: false,
@@ -3553,7 +4758,9 @@ function createChannelScanPorts(options) {
3553
4758
  mode: decision.mode,
3554
4759
  cards: options.cards,
3555
4760
  commitPort: options.commitPort,
4761
+ ...options.unit === void 0 ? {} : { unit: options.unit },
3556
4762
  now: options.now,
4763
+ ...options.isBlocked === void 0 ? {} : { isBlocked: options.isBlocked },
3557
4764
  summary: "merge " + row.a + " + " + row.b,
3558
4765
  author: async () => {
3559
4766
  await entry.result.author?.(row, decision, signal);
@@ -3592,12 +4799,35 @@ function createChannelScanPorts(options) {
3592
4799
  //#region lib/types/new-child-authoring.js
3593
4800
  async function runNewChildAuthoring(options, row, decision, signal) {
3594
4801
  const targetCard = options.cards.find((card) => card.name === decision.target);
3595
- if (targetCard?.protectionUnknown === true || targetCard?.protectedBy === "pinned") return {
4802
+ const createMode = decision.mode === "create-umbrella";
4803
+ if (!createMode && targetCard !== void 0) {
4804
+ if (targetCard.managed !== true) return {
4805
+ kind: "failed",
4806
+ failure: {
4807
+ stage: "author",
4808
+ code: "target-not-managed",
4809
+ message: "target is not family-managed: " + decision.target,
4810
+ retryable: false,
4811
+ retryFrom: "none"
4812
+ }
4813
+ };
4814
+ if (targetCard.protectionUnknown === true || targetCard.protectedBy != null) return {
4815
+ kind: "failed",
4816
+ failure: {
4817
+ stage: "author",
4818
+ code: "target-protected",
4819
+ message: "target is protected: " + decision.target,
4820
+ retryable: false,
4821
+ retryFrom: "none"
4822
+ }
4823
+ };
4824
+ }
4825
+ if (!createMode && await options.isBlocked?.(decision.target) === true) return {
3596
4826
  kind: "failed",
3597
4827
  failure: {
3598
4828
  stage: "author",
3599
- code: "target-pinned",
3600
- message: "target is pinned: " + decision.target,
4829
+ code: "target-blocked",
4830
+ message: "target is blocked by curator: " + decision.target,
3601
4831
  retryable: false,
3602
4832
  retryFrom: "none"
3603
4833
  }
@@ -3712,9 +4942,11 @@ async function runNewChildAuthoring(options, row, decision, signal) {
3712
4942
  }
3713
4943
  };
3714
4944
  const commit = await commitStagedRun(options.io, options.home, stageKey, options.commitPort, {
3715
- origin: "subagent",
4945
+ origin: "foreground",
3716
4946
  summary: "merge " + row.a + " + " + row.b,
3717
- now: options.now
4947
+ now: options.now,
4948
+ ...options.isBlocked === void 0 ? {} : { isBlocked: options.isBlocked },
4949
+ ...options.unit === void 0 ? {} : { unit: options.unit }
3718
4950
  });
3719
4951
  if (!commit.ok) return {
3720
4952
  kind: "failed",
@@ -3761,7 +4993,10 @@ function skillLibraryPort(skills) {
3761
4993
  const names = await archivedNames.call(skills);
3762
4994
  return names === null ? true : names.includes(name);
3763
4995
  },
3764
- commitMerge: (plan) => skills.commitMerge(plan),
4996
+ isNameOccupied: async (name) => {
4997
+ const probe = skills.skillDirExists;
4998
+ return typeof probe === "function" ? await probe.call(skills, name) : false;
4999
+ },
3765
5000
  async readSupportFiles(name) {
3766
5001
  if (typeof skills.listSupportFiles !== "function") return [];
3767
5002
  const probe = await skills.listSupportFiles(name);
@@ -3795,6 +5030,17 @@ function forcedDecisionFor(a, b) {
3795
5030
  rationale: "forced pair merge of " + sorted.join(" and ")
3796
5031
  };
3797
5032
  }
5033
+ function unitFor(deps) {
5034
+ if (deps.unit !== void 0) return deps.unit;
5035
+ if (!(deps.skills instanceof SkillLibrary)) return void 0;
5036
+ return new UnitTransactions({
5037
+ io: deps.io,
5038
+ home: deps.home,
5039
+ backend: skillLibraryUnitBackend(deps.skills),
5040
+ claim: targetClaimUnitPort(deps.io, deps.home, deps.now),
5041
+ now: deps.now
5042
+ });
5043
+ }
3798
5044
  function createMergeRuntime(deps) {
3799
5045
  const readOnly = createReadOnlyMergeService(deps.io, deps.home);
3800
5046
  const timeout = deps.timeoutMinutes > 0 ? deps.timeoutMinutes : void 0;
@@ -3845,7 +5091,12 @@ function createMergeRuntime(deps) {
3845
5091
  b: command.b
3846
5092
  }];
3847
5093
  const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find((row) => row.pairKey === pairKey(command.a, command.b));
3848
- if (existing?.state === "skipped") await dropRow(deps.io, deps.home, deps.now, existing.id);
5094
+ if (existing?.state === "skipped") {
5095
+ if (!await dropRowV3(deps.io, deps.home, existing.id, "superseded")) return {
5096
+ ok: false,
5097
+ text: "row changed before cleanup: " + existing.id
5098
+ };
5099
+ }
3849
5100
  if (existing?.state === "pending") return {
3850
5101
  ok: false,
3851
5102
  text: "该 pair 已在待批准队列,先 approve/reject/discard: " + existing.id
@@ -3875,6 +5126,8 @@ function createMergeRuntime(deps) {
3875
5126
  approval,
3876
5127
  commitPort: skillLibraryPort(deps.skills),
3877
5128
  now: deps.now,
5129
+ ...unitFor(deps) === void 0 ? {} : { unit: unitFor(deps) },
5130
+ ...deps.isBlocked === void 0 ? {} : { isBlocked: deps.isBlocked },
3878
5131
  ...stageHooks
3879
5132
  });
3880
5133
  return forced === void 0 ? ports : {
@@ -3940,6 +5193,8 @@ function createMergeRuntime(deps) {
3940
5193
  approval: deps.params.mergeApproval,
3941
5194
  commitPort: skillLibraryPort(deps.skills),
3942
5195
  now: deps.now,
5196
+ ...unitFor(deps) === void 0 ? {} : { unit: unitFor(deps) },
5197
+ ...deps.isBlocked === void 0 ? {} : { isBlocked: deps.isBlocked },
3943
5198
  ...stageHooks
3944
5199
  })
3945
5200
  });
@@ -3966,15 +5221,18 @@ function createMergeRuntime(deps) {
3966
5221
  ok: false,
3967
5222
  text: "merge row not found: " + command.id
3968
5223
  };
3969
- const next = transition(row, {
3970
- type: "reject",
3971
- at: new Date(deps.now()).toISOString()
3972
- });
3973
- if (!next.ok || next.row === null) return {
5224
+ if (!await transactRows(deps.io, deps.home, (tx) => {
5225
+ const current = tx.rows().find((item) => item.id === row.id);
5226
+ if (current === void 0 || current.state !== "pending") return false;
5227
+ return tx.transition(row.id, {
5228
+ type: "skipped",
5229
+ reason: "rejected",
5230
+ origin: "human"
5231
+ }, current.version).ok;
5232
+ })) return {
3974
5233
  ok: false,
3975
- text: "merge row is not pending: " + command.id
5234
+ text: "merge row is not pending or changed: " + command.id
3976
5235
  };
3977
- await upsertRow(deps.io, deps.home, deps.now, next.row);
3978
5236
  return {
3979
5237
  ok: true,
3980
5238
  text: "已拒绝合并:" + command.id
@@ -3986,13 +5244,10 @@ function createMergeRuntime(deps) {
3986
5244
  };
3987
5245
  if (command.verb === "report" || command.verb === "runs" || command.verb === "cancel") {
3988
5246
  const registry = deps.registry;
3989
- if (command.verb === "runs") {
3990
- const mergeRuns = registry.runs("merge");
3991
- return {
3992
- ok: true,
3993
- text: mergeRuns.length === 0 ? "merge runs: empty" : ["merge runs: " + String(mergeRuns.length)].concat(mergeRuns.map((run) => "- " + run.id + " [" + run.state + "]" + (run.resultRef === void 0 ? "" : " " + run.resultRef))).join("\n")
3994
- };
3995
- }
5247
+ if (command.verb === "runs") return {
5248
+ ok: true,
5249
+ text: formatRunsView(toRunsView(registry.runs("merge"), deps.now()))
5250
+ };
3996
5251
  if (command.verb === "cancel") {
3997
5252
  if (command.id !== void 0) {
3998
5253
  const cancelled = registry.cancel(command.id);
@@ -4039,7 +5294,10 @@ function createMergeRuntime(deps) {
4039
5294
  ok: false,
4040
5295
  text: "merge row is not discardable: " + command.id
4041
5296
  };
4042
- await dropRow(deps.io, deps.home, deps.now, row.id);
5297
+ if (!await dropRowV3(deps.io, deps.home, row.id, "superseded")) return {
5298
+ ok: false,
5299
+ text: "row changed before discard: " + command.id
5300
+ };
4043
5301
  return {
4044
5302
  ok: true,
4045
5303
  text: "已丢弃合并行:" + command.id
@@ -4073,7 +5331,10 @@ function createMergeRuntime(deps) {
4073
5331
  ok: false,
4074
5332
  text: "skipped row not found: " + command.restore
4075
5333
  };
4076
- await dropRow(deps.io, deps.home, deps.now, row.id);
5334
+ if (!await dropRowV3(deps.io, deps.home, row.id, "restored")) return {
5335
+ ok: false,
5336
+ text: "row changed before restore: " + row.id
5337
+ };
4077
5338
  return {
4078
5339
  ok: true,
4079
5340
  text: "已恢复参与筛选:" + row.id
@@ -4086,7 +5347,10 @@ function createMergeRuntime(deps) {
4086
5347
  ok: false,
4087
5348
  text: "merge row not found: " + command.clear
4088
5349
  };
4089
- await dropRow(deps.io, deps.home, deps.now, cleared.id);
5350
+ if (!await dropRowV3(deps.io, deps.home, cleared.id, "superseded")) return {
5351
+ ok: false,
5352
+ text: "row changed before clear: " + cleared.id
5353
+ };
4090
5354
  return {
4091
5355
  ok: true,
4092
5356
  text: "已清除合并行:" + cleared.id
@@ -4106,6 +5370,21 @@ function createMergeRuntime(deps) {
4106
5370
  };
4107
5371
  const signal = new AbortController().signal;
4108
5372
  const cards = await deps.cards(signal);
5373
+ if ((row.state === "fuse" || row.state === "approved") && row.decision !== void 0 && row.decision.mode !== "create-umbrella") {
5374
+ const targetCard = cards.find((card) => card.name === row.decision?.target);
5375
+ if (targetCard !== void 0 && targetCard.managed !== true) return {
5376
+ ok: false,
5377
+ text: "target 不是 family-managed:" + row.decision.target
5378
+ };
5379
+ if (targetCard !== void 0 && (targetCard.protectionUnknown === true || targetCard.protectedBy != null)) return {
5380
+ ok: false,
5381
+ text: "target 受保护:" + row.decision.target
5382
+ };
5383
+ if (await deps.isBlocked?.(row.decision.target) === true) return {
5384
+ ok: false,
5385
+ text: "target 被范围门拦下:" + row.decision.target
5386
+ };
5387
+ }
4109
5388
  const pair = () => Promise.resolve([{
4110
5389
  a: row.a,
4111
5390
  b: row.b
@@ -4129,6 +5408,8 @@ function createMergeRuntime(deps) {
4129
5408
  ...common,
4130
5409
  rowId: row.id,
4131
5410
  ports: basePorts,
5411
+ ...detached ? { detached: true } : {},
5412
+ pairTimeoutMinutes: deps.params.mergePairTimeoutMinutes,
4132
5413
  ...deps.isOwnerActive === void 0 ? {} : { isOwnerActive: deps.isOwnerActive },
4133
5414
  makePorts: (runId) => createChannelScanPorts({
4134
5415
  io: deps.io,
@@ -4143,7 +5424,9 @@ function createMergeRuntime(deps) {
4143
5424
  timeoutMs: deps.params.mergePairTimeoutMinutes * 6e4,
4144
5425
  approval: deps.params.mergeApproval,
4145
5426
  commitPort: skillLibraryPort(deps.skills),
4146
- now: deps.now
5427
+ now: deps.now,
5428
+ ...unitFor(deps) === void 0 ? {} : { unit: unitFor(deps) },
5429
+ ...deps.isBlocked === void 0 ? {} : { isBlocked: deps.isBlocked }
4147
5430
  }),
4148
5431
  round2WithRun: createNewChildRound2({
4149
5432
  io: deps.io,
@@ -4156,6 +5439,8 @@ function createMergeRuntime(deps) {
4156
5439
  timeoutMs: deps.params.mergePairTimeoutMinutes * 6e4,
4157
5440
  commitPort: skillLibraryPort(deps.skills),
4158
5441
  now: deps.now,
5442
+ ...unitFor(deps) === void 0 ? {} : { unit: unitFor(deps) },
5443
+ ...deps.isBlocked === void 0 ? {} : { isBlocked: deps.isBlocked },
4159
5444
  ...stageHooks
4160
5445
  })
4161
5446
  });
@@ -4179,11 +5464,22 @@ function createMergeRuntime(deps) {
4179
5464
  if (command.verb === "approve") {
4180
5465
  const signal = new AbortController().signal;
4181
5466
  const cards = await deps.cards(signal);
4182
- const candidate = (await loadMergeState(deps.io, deps.home)).state.rows.find((row) => row.id === command.id);
5467
+ let candidate = (await loadMergeState(deps.io, deps.home)).state.rows.find((row) => row.id === command.id);
5468
+ if (candidate !== void 0 && candidate.stagingRef === void 0) {
5469
+ const entry = await readStagingIndex(deps.io, deps.home, candidate.id, deps.now());
5470
+ if (entry !== null) candidate = {
5471
+ ...candidate,
5472
+ stagingRef: entry.ref,
5473
+ stagingExpiresAt: entry.expiresAt
5474
+ };
5475
+ }
4183
5476
  if (candidate !== void 0 && candidate.state === "pending" && candidate.decision !== void 0) {
4184
5477
  const missingSource = [candidate.a, candidate.b].find((name) => !cards.some((card) => card.name === name && card.managed === true));
4185
5478
  if (missingSource !== void 0) {
4186
- await dropRow(deps.io, deps.home, deps.now, candidate.id);
5479
+ if (!await dropRowV3(deps.io, deps.home, candidate.id, "superseded")) return {
5480
+ ok: false,
5481
+ text: "source-gone 但 row 已变化,未清理:" + candidate.id
5482
+ };
4187
5483
  return {
4188
5484
  ok: false,
4189
5485
  text: "source-gone:" + missingSource + " 已不在 family-managed 技能库,已从 active 队列清除;可 merge skipped --discard 清理"
@@ -4191,11 +5487,16 @@ function createMergeRuntime(deps) {
4191
5487
  }
4192
5488
  const currentRevision = sourceRevisionOf(cards, candidate.a, candidate.b);
4193
5489
  if (candidate.sourceRevision !== void 0 && currentRevision !== void 0 && currentRevision !== candidate.sourceRevision) {
4194
- const changed = transition(candidate, {
4195
- type: "source-changed",
4196
- at: (/* @__PURE__ */ new Date()).toISOString()
4197
- });
4198
- if (changed.ok && changed.row !== null) await upsertRow(deps.io, deps.home, deps.now, changed.row);
5490
+ if (!await transactRows(deps.io, deps.home, (tx) => {
5491
+ const current = tx.rows().find((item) => item.id === candidate.id);
5492
+ return current === void 0 ? false : tx.transition(candidate.id, {
5493
+ type: "expired",
5494
+ kind: "source-changed"
5495
+ }, current.version).ok;
5496
+ })) return {
5497
+ ok: false,
5498
+ text: "row changed before source-changed record: " + candidate.id
5499
+ };
4199
5500
  return {
4200
5501
  ok: false,
4201
5502
  text: "source-changed:源技能内容在决策后变化,拒绝照跑;出口:重新 merge pair,或 merge skipped --discard"
@@ -4203,22 +5504,47 @@ function createMergeRuntime(deps) {
4203
5504
  }
4204
5505
  const currentTargetRevision = targetRevisionOf(cards, candidate.decision.target);
4205
5506
  if (candidate.targetRevision !== void 0 && currentTargetRevision !== void 0 && currentTargetRevision !== candidate.targetRevision) {
4206
- const taken = transition(candidate, {
4207
- type: "target-taken",
4208
- at: (/* @__PURE__ */ new Date()).toISOString()
4209
- });
4210
- if (taken.ok && taken.row !== null) await upsertRow(deps.io, deps.home, deps.now, taken.row);
5507
+ if (!await transactRows(deps.io, deps.home, (tx) => {
5508
+ const current = tx.rows().find((item) => item.id === candidate.id);
5509
+ return current === void 0 ? false : tx.transition(candidate.id, {
5510
+ type: "expired",
5511
+ kind: "target-taken"
5512
+ }, current.version).ok;
5513
+ })) return {
5514
+ ok: false,
5515
+ text: "row changed before target-taken record: " + candidate.id
5516
+ };
4211
5517
  return {
4212
5518
  ok: false,
4213
5519
  text: "target-taken:目标 " + candidate.decision.target + " 在决策后变化,拒绝照跑;可重新 merge pair 或 discard"
4214
5520
  };
4215
5521
  }
5522
+ if (candidate.decision.mode !== "create-umbrella") {
5523
+ const targetCard = cards.find((card) => card.name === candidate.decision?.target);
5524
+ if (targetCard !== void 0 && targetCard.managed !== true) return {
5525
+ ok: false,
5526
+ text: "target 不是 family-managed:" + candidate.decision.target
5527
+ };
5528
+ if (targetCard !== void 0 && (targetCard.protectionUnknown === true || targetCard.protectedBy != null)) return {
5529
+ ok: false,
5530
+ text: "target 受保护:" + candidate.decision.target
5531
+ };
5532
+ if (await deps.isBlocked?.(candidate.decision.target) === true) return {
5533
+ ok: false,
5534
+ text: "target 被范围门拦下:" + candidate.decision.target
5535
+ };
5536
+ }
4216
5537
  if (candidate.decision.mode === "merge-into-existing" && !cards.some((card) => card.name === candidate.decision?.target)) {
4217
- const expired = transition(candidate, {
4218
- type: "target-taken",
4219
- at: (/* @__PURE__ */ new Date()).toISOString()
4220
- });
4221
- if (expired.ok && expired.row !== null) await upsertRow(deps.io, deps.home, deps.now, expired.row);
5538
+ if (!await transactRows(deps.io, deps.home, (tx) => {
5539
+ const current = tx.rows().find((item) => item.id === candidate.id);
5540
+ return current === void 0 ? false : tx.transition(candidate.id, {
5541
+ type: "expired",
5542
+ kind: "target-taken"
5543
+ }, current.version).ok;
5544
+ })) return {
5545
+ ok: false,
5546
+ text: "row changed before target-taken record: " + candidate.id
5547
+ };
4222
5548
  return {
4223
5549
  ok: false,
4224
5550
  text: "target-taken:目标 " + candidate.decision.target + " 已不存在,行保留 pending --expired;出口:重新 merge pair,或 merge skipped --discard"
@@ -4228,6 +5554,8 @@ function createMergeRuntime(deps) {
4228
5554
  const result = await runApproveCommand({
4229
5555
  ...common,
4230
5556
  rowId: command.id,
5557
+ ...detached ? { detached: true } : {},
5558
+ pairTimeoutMinutes: deps.params.mergePairTimeoutMinutes,
4231
5559
  ports: { round2: createNewChildRound2({
4232
5560
  io: deps.io,
4233
5561
  home: deps.home,
@@ -4238,7 +5566,9 @@ function createMergeRuntime(deps) {
4238
5566
  ...deps.cwd === void 0 ? {} : { cwd: deps.cwd },
4239
5567
  timeoutMs: deps.params.mergePairTimeoutMinutes * 6e4,
4240
5568
  commitPort: skillLibraryPort(deps.skills),
4241
- now: deps.now
5569
+ now: deps.now,
5570
+ ...unitFor(deps) === void 0 ? {} : { unit: unitFor(deps) },
5571
+ ...deps.isBlocked === void 0 ? {} : { isBlocked: deps.isBlocked }
4242
5572
  }) }
4243
5573
  });
4244
5574
  if (!result.ok) return {
@@ -4259,7 +5589,9 @@ function createMergeRuntime(deps) {
4259
5589
  };
4260
5590
  }
4261
5591
  const cards = command.verb === "pending" ? await deps.cards(new AbortController().signal) : void 0;
4262
- const text = await handleReadOnlyMergeCommand(command, deps.io, deps.home, cards);
5592
+ const registry = deps.registry;
5593
+ const runs = command.verb === "status" ? registry.runs("merge") : void 0;
5594
+ const text = await handleReadOnlyMergeCommand(command, deps.io, deps.home, cards, runs, deps.params.mergeApproval);
4263
5595
  if (text !== null) return {
4264
5596
  ok: true,
4265
5597
  text
@@ -4638,6 +5970,143 @@ async function supersedeMergeRuns(registry) {
4638
5970
  return ids.length;
4639
5971
  }
4640
5972
  //#endregion
5973
+ //#region lib/types/coordinator.js
5974
+ /**
5975
+ * Pure merge transition table. No io, no run shell, no subagents.
5976
+ *
5977
+ * Every transition returns the whole next row (or a drop), so the caller can persist it
5978
+ * through one state transaction. Invalid transitions are refused by name;
5979
+ * a caller never "repairs" a row by guessing which state the operator meant.
5980
+ */
5981
+ function invalid(event, state) {
5982
+ return {
5983
+ ok: false,
5984
+ code: "invalid-transition",
5985
+ message: "cannot apply " + event.type + " to " + (state ?? "no row")
5986
+ };
5987
+ }
5988
+ /** Drop fields that must not survive a transition; optional fields stay absent. */
5989
+ function base(row, at) {
5990
+ return {
5991
+ id: row.id,
5992
+ pairKey: row.pairKey,
5993
+ a: row.a,
5994
+ b: row.b,
5995
+ state: row.state,
5996
+ queue: row.queue,
5997
+ createdAt: row.createdAt,
5998
+ updatedAt: at,
5999
+ ...row.runId === void 0 ? {} : { runId: row.runId },
6000
+ ...row.unitId === void 0 ? {} : { unitId: row.unitId },
6001
+ ...row.origin === void 0 ? {} : { origin: row.origin },
6002
+ ...row.decision === void 0 ? {} : { decision: row.decision },
6003
+ ...row.sourceRevision === void 0 ? {} : { sourceRevision: row.sourceRevision },
6004
+ ...row.targetRevision === void 0 ? {} : { targetRevision: row.targetRevision },
6005
+ ...row.attempts === void 0 ? {} : { attempts: row.attempts },
6006
+ ...row.stagingRef === void 0 ? {} : { stagingRef: row.stagingRef },
6007
+ ...row.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: row.stagingExpiresAt },
6008
+ ...row.expired === void 0 ? {} : { expired: row.expired },
6009
+ ...row.reason === void 0 ? {} : { reason: row.reason },
6010
+ ...row.target === void 0 ? {} : { target: row.target }
6011
+ };
6012
+ }
6013
+ function next(row, at, state, patch = {}) {
6014
+ return {
6015
+ ok: true,
6016
+ row: {
6017
+ ...base(row, at),
6018
+ state,
6019
+ queue: queueOf(state),
6020
+ ...patch
6021
+ }
6022
+ };
6023
+ }
6024
+ /** Legacy v2 projection only; runtime writes must use transitionV3/RowTx. */
6025
+ function transitionLegacyForProjection(current, event) {
6026
+ if (event.type === "candidate") {
6027
+ if (current !== void 0) return {
6028
+ ok: true,
6029
+ row: current
6030
+ };
6031
+ const key = pairKey(event.a, event.b);
6032
+ return {
6033
+ ok: true,
6034
+ row: {
6035
+ id: rowIdOf(key),
6036
+ pairKey: key,
6037
+ a: event.a,
6038
+ b: event.b,
6039
+ state: "verify",
6040
+ queue: "verify",
6041
+ createdAt: event.at,
6042
+ updatedAt: event.at
6043
+ }
6044
+ };
6045
+ }
6046
+ if (event.type === "source-gone") return {
6047
+ ok: true,
6048
+ row: null
6049
+ };
6050
+ if (event.type === "gate-keep") {
6051
+ if (current !== void 0) return {
6052
+ ok: true,
6053
+ row: current
6054
+ };
6055
+ const key = pairKey(event.a, event.b);
6056
+ return {
6057
+ ok: true,
6058
+ row: {
6059
+ id: rowIdOf(key),
6060
+ pairKey: key,
6061
+ a: event.a,
6062
+ b: event.b,
6063
+ state: "skipped",
6064
+ queue: "skipped",
6065
+ origin: "agent",
6066
+ createdAt: event.at,
6067
+ updatedAt: event.at
6068
+ }
6069
+ };
6070
+ }
6071
+ if (current === void 0) return invalid(event, void 0);
6072
+ switch (event.type) {
6073
+ case "round1-keep": return current.state === "verify" ? next(current, event.at, "skipped", { origin: "agent" }) : invalid(event, current.state);
6074
+ case "round1-merge":
6075
+ if (current.state !== "verify") return invalid(event, current.state);
6076
+ return event.approval === "on" ? next(current, event.at, "pending", {
6077
+ ...event.decision === void 0 ? {} : { decision: event.decision },
6078
+ ...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
6079
+ ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt },
6080
+ ...event.sourceRevision === void 0 ? {} : { sourceRevision: event.sourceRevision },
6081
+ ...event.targetRevision === void 0 ? {} : { targetRevision: event.targetRevision }
6082
+ }) : next(current, event.at, "approved", {
6083
+ ...event.decision === void 0 ? {} : { decision: event.decision },
6084
+ ...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
6085
+ ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt },
6086
+ ...event.sourceRevision === void 0 ? {} : { sourceRevision: event.sourceRevision },
6087
+ ...event.targetRevision === void 0 ? {} : { targetRevision: event.targetRevision }
6088
+ });
6089
+ case "round1-fail": return current.state === "verify" ? next(current, event.at, "verify", {
6090
+ failure: event.failure,
6091
+ attempts: (current.attempts ?? 0) + 1
6092
+ }) : invalid(event, current.state);
6093
+ case "approve": return current.state === "pending" ? next(current, event.at, "approved", event.runId === void 0 ? {} : { runId: event.runId }) : invalid(event, current.state);
6094
+ case "reject": return current.state === "pending" ? next(current, event.at, "skipped", { origin: "human" }) : invalid(event, current.state);
6095
+ case "round2-cancel": return current.state === "fuse" || current.state === "approved" ? next(current, event.at, "approved") : invalid(event, current.state);
6096
+ case "round2-fail": return current.state === "fuse" || current.state === "approved" ? next(current, event.at, "approved", {
6097
+ failure: event.failure,
6098
+ attempts: (current.attempts ?? 0) + 1
6099
+ }) : invalid(event, current.state);
6100
+ case "round2-success": return current.state === "fuse" || current.state === "approved" ? {
6101
+ ok: true,
6102
+ row: null
6103
+ } : invalid(event, current.state);
6104
+ case "target-taken":
6105
+ case "source-changed": return current.state === "pending" || current.state === "approved" || current.state === "fuse" ? next(current, event.at, current.state, { expired: event.type }) : invalid(event, current.state);
6106
+ case "restore": return current.state === "skipped" ? next(current, event.at, "verify") : invalid(event, current.state);
6107
+ }
6108
+ }
6109
+ //#endregion
4641
6110
  //#region lib/types/pair-command.js
4642
6111
  async function runPairCommand(options) {
4643
6112
  return await runScanCommand({
@@ -4667,14 +6136,10 @@ const Config = z.object({
4667
6136
  mergePairTimeoutMinutes: z.number().min(1).default(15),
4668
6137
  mergeModelsRoot: z.string().default("")
4669
6138
  });
4670
- function engineConfigFromEnv(configuredRoot) {
4671
- const root = configuredRoot !== void 0 && configuredRoot !== "" ? configuredRoot : process.env.SKILL_MERGE_MODELS_ROOT;
4672
- if (root === void 0 || root === "") return void 0;
6139
+ function engineConfigFromRoot(configuredRoot) {
6140
+ const root = configuredRoot?.trim() ?? "";
6141
+ if (root === "") return void 0;
4673
6142
  return {
4674
- models_dir: "",
4675
- offline: true,
4676
- auto_provision: false,
4677
- embed_backend: "local",
4678
6143
  embedder_model: join(root, "potion-multilingual-128M-int8"),
4679
6144
  embedder_model_last: join(root, "potion-multilingual-128M-pca64-int8")
4680
6145
  };
@@ -4718,7 +6183,6 @@ function apply(ctx, rawConfig = {}) {
4718
6183
  ctx.logger.warn(message);
4719
6184
  }
4720
6185
  });
4721
- runs.load();
4722
6186
  ctx.effect(() => {
4723
6187
  const host = ctx;
4724
6188
  if (typeof host.on !== "function") return () => {};
@@ -4736,20 +6200,17 @@ function apply(ctx, rawConfig = {}) {
4736
6200
  ...policyStageLimits(policySnapshot?.get?.())
4737
6201
  }
4738
6202
  });
4739
- const mergeSkillsRoot = skills.root ?? skillsRoot();
4740
- recoverCommitJournals(io, home, Date.now(), mergeSkillsRoot, async (name) => {
4741
- return (await skills.restoreFromArchive(name)).ok;
4742
- }, (runId) => {
6203
+ runs.load().then(() => recoverCommitJournals(io, home, Date.now(), (runId) => {
4743
6204
  const run = runs.find(runId);
4744
6205
  return run !== void 0 && run.state === "running";
4745
- }).then((reports) => {
6206
+ })).then((reports) => {
4746
6207
  if (reports.length > 0) ctx.logger.info("merge commit recovery: " + reports.map((report) => report.runId + "=" + report.action).join(", "));
4747
6208
  }).catch((error) => {
4748
6209
  ctx.logger.warn("merge commit recovery failed: " + (error instanceof Error ? error.message : String(error)));
4749
6210
  });
4750
6211
  const channel = subagentChannelAdapter(createSubagentChannel(ctx));
4751
6212
  const stage = new StageToolRegistry();
4752
- const engineConfig = engineConfigFromEnv(params.mergeModelsRoot);
6213
+ const engineConfig = engineConfigFromRoot(params.mergeModelsRoot);
4753
6214
  const llm = ctx.get("llm");
4754
6215
  const defaultModel = ctx.get("agentDefaultModel");
4755
6216
  const runner = llm === void 0 || defaultModel === void 0 ? void 0 : createEngineRunner(llm, () => defaultModel.currentSelection());
@@ -4759,7 +6220,7 @@ function apply(ctx, rawConfig = {}) {
4759
6220
  engine: { screen },
4760
6221
  select: selectWithErrors,
4761
6222
  cfg: {
4762
- ...engineConfig ?? {},
6223
+ ...engineConfig,
4763
6224
  judge: "deepseek"
4764
6225
  },
4765
6226
  ...runner === void 0 ? {} : { runner },
@@ -4768,14 +6229,20 @@ function apply(ctx, rawConfig = {}) {
4768
6229
  const at = (/* @__PURE__ */ new Date()).toISOString();
4769
6230
  for (const skipped of selection.skipped) {
4770
6231
  if (loaded.state.rows.some((row) => row.pairKey === skipped.pairKey)) continue;
4771
- const next = transition(void 0, {
4772
- type: "gate-keep",
6232
+ const key = pairKey(skipped.a, skipped.b);
6233
+ await transactRows(io, home, (tx) => tx.createSkipped({
6234
+ id: rowIdOf(key),
6235
+ pairKey: key,
4773
6236
  a: skipped.a,
4774
6237
  b: skipped.b,
4775
- ...skipped.reason === void 0 ? {} : { reason: skipped.reason },
4776
- at
4777
- });
4778
- if (next.ok && next.row !== null) await upsertRow(io, home, () => Date.now(), next.row);
6238
+ version: 0,
6239
+ createdAt: at,
6240
+ updatedAt: at,
6241
+ origin: "agent",
6242
+ state: "skipped",
6243
+ reason: skipped.reason ?? "gate-keep",
6244
+ attempts: 0
6245
+ }).ok);
4779
6246
  }
4780
6247
  }
4781
6248
  });
@@ -4906,4 +6373,4 @@ function apply(ctx, rawConfig = {}) {
4906
6373
  });
4907
6374
  }
4908
6375
  //#endregion
4909
- 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 };
6376
+ 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, buildRound1Prompt, buildRound2Prompt, buildStandbyPrompt, canRetry, cleanupExpiredStaging, commitReceiptPath, commitStagedRun, committedPairs, configureMergePresentation, copyStagedFile, countRows, createChannelScanPorts, createEngineRunner, createEngineScreening, createMergeCommand, createMergeRuntime, createMergeStageTool, createNewChildRound2, createReadOnlyMergeService, createStaging, dropRow, dropRowV3, dropStaging, durableReceiptDir, durableReceiptPath, emptyMergeState, engineConfigFromRoot, ensureStaging, formatFacts, formatQueue, formatRunsView, formatStatus, handleReadOnlyMergeCommand, hasBlockingFinding, inject, isSkippedPair, listStagedFiles, loadMergeState, loadStateSchema, makeMergeRoutes, mergeCommandHelp, mergeDoctor, mergeDoctorAvailability, mergeReportDigestPath, mergeReportPath, mergeRunIdsToSupersede, mergeStatePath, mergeUsage, moveRow, mutateMergeState, 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, transactRows, transitionLegacyForProjection, undoMergeReceipt, unitFor, upsertRow, validateStagedTree, withMergeRun, writeCommitReceipt, writeMergeReport, writeStagedFile };