@lmzhen/dsh-evolution-merge 0.30.16 → 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 +2325 -855
  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 +116 -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 +72 -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,21 +817,81 @@ 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;
347
885
  await transactIo(io, mergeStatePath(home), (current) => {
348
- const out = fn(parseState(current).rows);
886
+ const parsed = parseState(current);
887
+ if (!parsed.ok || parsed.note === "merge-state-forward-only") throw new Error(parsed.note ?? "merge-state-invalid");
888
+ const out = fn(parsed.rows);
349
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");
350
892
  return JSON.stringify({
351
- schemaVersion: 2,
352
- rows: out.rows
893
+ schemaVersion: 3,
894
+ rows
353
895
  }, null, 2);
354
896
  });
355
897
  return result;
@@ -423,6 +965,19 @@ async function moveRow(io, home, now, id, patch) {
423
965
  };
424
966
  });
425
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
+ }
426
981
  async function dropRow(io, home, now, id) {
427
982
  return await mutateMergeState(io, home, now, (rows) => {
428
983
  const next = rows.filter((item) => item.id !== id);
@@ -594,20 +1149,6 @@ async function cleanupExpiredStaging(io, home, now, keep = /* @__PURE__ */ new S
594
1149
  };
595
1150
  }
596
1151
  //#endregion
597
- //#region lib/types/domain/commit-journal.js
598
- /** What a restart may do with the last durable phase. */
599
- function recoveryFor(journal) {
600
- switch (journal.phase) {
601
- case "prepared": return "retry-commit";
602
- case "target-written": return "rollback-target";
603
- case "sources-archived": return "rollback-sources";
604
- case "committed":
605
- case "rolled-back":
606
- case "failed": return "none";
607
- default: return "refuse";
608
- }
609
- }
610
- //#endregion
611
1152
  //#region lib/types/domain/commit-journal-store.js
612
1153
  /** Durable commit journal: <home>/merge/journals/<run>.json. */
613
1154
  function journalPath(home, runId) {
@@ -615,9 +1156,20 @@ function journalPath(home, runId) {
615
1156
  }
616
1157
  async function writeJournal(io, home, journal) {
617
1158
  const path = journalPath(home, journal.runId);
618
- 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);
619
1162
  return path;
620
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
+ }
621
1173
  //#endregion
622
1174
  //#region lib/types/commit-snapshot.js
623
1175
  /** Pre-commit snapshot: enough to restore the target and archived sources after a crash. */
@@ -652,32 +1204,9 @@ async function writeCommitSnapshot(io, home, runId, plan, now, port) {
652
1204
  await io.writeText(snapshotPath(home, runId), JSON.stringify(snapshot, null, 2));
653
1205
  return snapshot;
654
1206
  }
655
- async function readCommitSnapshot(io, home, runId) {
656
- const raw = await io.readText(snapshotPath(home, runId));
657
- if (raw === null) return null;
658
- try {
659
- return JSON.parse(raw);
660
- } catch {
661
- return null;
662
- }
663
- }
664
1207
  function isSafeSkillName(name) {
665
1208
  return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && name !== "." && name !== "..";
666
1209
  }
667
- function skillFilePath(root, name, path) {
668
- if (!isSafeSkillName(name) || path.startsWith("/") || path.split("/").includes("..")) throw new Error("unsafe skill file: " + name + "/" + path);
669
- const base = resolve(root, name);
670
- const file = resolve(base, path);
671
- if (!file.startsWith(base + sep)) throw new Error("skill file escapes skill: " + name + "/" + path);
672
- return file;
673
- }
674
- function skillBodyPath(root, name) {
675
- if (!isSafeSkillName(name)) throw new Error("unsafe skill name: " + name);
676
- const base = resolve(root);
677
- const path = resolve(base, name, "SKILL.md");
678
- if (!path.startsWith(base + sep)) throw new Error("skill path escapes root: " + name);
679
- return path;
680
- }
681
1210
  //#endregion
682
1211
  //#region lib/types/commit-receipt.js
683
1212
  /**
@@ -755,448 +1284,488 @@ async function readCommitReceipt(io, home, runId, target) {
755
1284
  }
756
1285
  //#endregion
757
1286
  //#region lib/types/commit-recovery.js
758
- /** Startup replay for non-terminal commit journals. */
759
- 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) {
760
1289
  const dir = join(home, "merge", "journals");
761
- if (!await io.exists(dir)) return [];
762
- const files = (await io.list(dir)).filter((name) => name.endsWith(".json"));
763
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
+ }
764
1303
  for (const file of files) {
765
- let journal;
766
- try {
767
- journal = JSON.parse(await io.readText(join(dir, file)) ?? "");
768
- } catch {
769
- continue;
770
- }
771
- if (isActive?.(journal.runId) === true) {
772
- reports.push({
773
- runId: journal.runId,
774
- phase: journal.phase,
775
- action: "skipped-active-run",
776
- ok: true
777
- });
778
- continue;
779
- }
780
- const recovery = recoveryFor(journal);
781
- if (recovery === "none") continue;
782
- if (recovery === "refuse") {
783
- reports.push({
784
- runId: journal.runId,
785
- phase: journal.phase,
786
- action: "unknown-phase-refused",
787
- ok: false
788
- });
789
- continue;
790
- }
791
- let receipt = null;
1304
+ const path = join(dir, file);
792
1305
  try {
793
- receipt = await readCommitReceipt(io, home, journal.runId, journal.target);
794
- } catch {
795
- reports.push({
796
- runId: journal.runId,
797
- phase: journal.phase,
798
- action: "receipt-read-failed",
799
- ok: false
800
- });
801
- continue;
802
- }
803
- if (receipt !== null) {
804
- await writeJournal(io, home, {
805
- ...journal,
806
- phase: "committed",
807
- unitId: receipt.unitId,
808
- updatedAt: new Date(now).toISOString()
809
- });
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;
810
1378
  reports.push({
811
1379
  runId: journal.runId,
812
1380
  phase: journal.phase,
813
- action: "receipt-committed",
814
- 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)
815
1387
  });
816
- continue;
817
- }
818
- let snapshot;
819
- try {
820
- snapshot = await readCommitSnapshot(io, home, journal.runId);
821
1388
  } catch (error) {
822
1389
  reports.push({
823
- runId: journal.runId,
824
- phase: journal.phase,
825
- action: "snapshot-read-failed: " + (error instanceof Error ? error.message : String(error)),
826
- 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
827
1395
  });
828
- continue;
829
1396
  }
830
- let action = "report-only";
831
- let ok = true;
832
- if (recovery === "retry-commit") action = "no-host-write";
833
- else if (snapshot === null) {
834
- action = "snapshot-missing";
835
- ok = false;
836
- } else try {
837
- if (recovery === "rollback-sources") {
838
- let restoredArchived = restoreSource !== void 0;
839
- if (restoreSource !== void 0) {
840
- 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;
841
1420
  }
842
- 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
+ });
843
1453
  }
844
- if (snapshot.create && snapshot.targetBody === null) {
845
- try {
846
- await io.remove(skillBodyPath(skillRoot, snapshot.target));
847
- } catch {}
848
- try {
849
- const dir = join(skillRoot, snapshot.target);
850
- if ((await io.list(dir)).length === 0) await io.remove(dir);
851
- } catch {}
852
- } else if (snapshot.targetBody !== null) await io.writeText(skillBodyPath(skillRoot, snapshot.target), snapshot.targetBody);
853
- const oldPaths = new Set((snapshot.targetSupport ?? []).map((file) => file.path));
854
- for (const file of snapshot.targetSupport ?? []) await io.writeText(skillFilePath(skillRoot, snapshot.target, file.path), file.content);
855
- for (const file of snapshot.targetSupportAfter ?? []) if (!oldPaths.has(file.path)) try {
856
- await io.remove(skillFilePath(skillRoot, snapshot.target, file.path));
857
- } catch {}
858
- for (const file of snapshot.sourceSupport ?? []) await io.writeText(skillFilePath(skillRoot, file.name, file.path), file.content);
859
- action = recovery === "rollback-sources" ? "restored-sources-and-target" : "restored-target";
860
- } catch (error) {
861
- action = "restore-failed: " + (error instanceof Error ? error.message : String(error));
862
- ok = false;
863
1454
  }
864
- if (ok) await writeJournal(io, home, {
865
- ...journal,
866
- phase: "rolled-back",
867
- updatedAt: new Date(now).toISOString()
868
- });
869
- reports.push({
870
- runId: journal.runId,
871
- phase: journal.phase,
872
- action,
873
- ok
874
- });
875
1455
  }
876
- 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 {}
877
1459
  return reports;
878
1460
  }
879
1461
  //#endregion
880
- //#region lib/types/coordinator.js
881
- /**
882
- * Pure merge transition table. No io, no run shell, no subagents.
883
- *
884
- * Every transition returns the whole next row (or a drop), so the caller can persist it
885
- * through mutateMergeState in one transaction. Invalid transitions are refused by name;
886
- * a caller never "repairs" a row by guessing which state the operator meant.
887
- */
888
- function invalid(event, state) {
889
- return {
890
- ok: false,
891
- code: "invalid-transition",
892
- message: "cannot apply " + event.type + " to " + (state ?? "no row")
893
- };
894
- }
895
- /** Drop fields that must not survive a transition; optional fields stay absent. */
896
- 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) {
897
1479
  return {
898
- id: row.id,
899
- pairKey: row.pairKey,
900
- a: row.a,
901
- b: row.b,
902
- state: row.state,
903
- queue: row.queue,
904
- createdAt: row.createdAt,
905
- updatedAt: at,
906
- ...row.runId === void 0 ? {} : { runId: row.runId },
907
- ...row.unitId === void 0 ? {} : { unitId: row.unitId },
908
- ...row.origin === void 0 ? {} : { origin: row.origin },
909
- ...row.decision === void 0 ? {} : { decision: row.decision },
910
- ...row.sourceRevision === void 0 ? {} : { sourceRevision: row.sourceRevision },
911
- ...row.targetRevision === void 0 ? {} : { targetRevision: row.targetRevision },
912
- ...row.attempts === void 0 ? {} : { attempts: row.attempts },
913
- ...row.stagingRef === void 0 ? {} : { stagingRef: row.stagingRef },
914
- ...row.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: row.stagingExpiresAt },
915
- ...row.expired === void 0 ? {} : { expired: row.expired },
916
- ...row.reason === void 0 ? {} : { reason: row.reason },
917
- ...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
918
1490
  };
919
1491
  }
920
- function next(row, at, state, patch = {}) {
921
- return {
922
- ok: true,
923
- row: {
924
- ...base(row, at),
925
- state,
926
- queue: queueOf(state),
927
- ...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"
928
1552
  }
929
- };
930
- }
931
- function transition(current, event) {
932
- if (event.type === "candidate") {
933
- if (current !== void 0) return {
934
- ok: true,
935
- row: current
936
- };
937
- const key = pairKey(event.a, event.b);
938
- return {
939
- ok: true,
940
- row: {
941
- id: rowIdOf(key),
942
- pairKey: key,
943
- a: event.a,
944
- b: event.b,
945
- state: "verify",
946
- queue: "verify",
947
- createdAt: event.at,
948
- updatedAt: event.at
949
- }
950
- };
951
- }
952
- if (event.type === "source-gone") return {
953
- ok: true,
954
- row: null
955
- };
956
- if (event.type === "gate-keep") {
957
- if (current !== void 0) return {
958
- ok: true,
959
- row: current
960
- };
961
- const key = pairKey(event.a, event.b);
962
- return {
963
- ok: true,
964
- row: {
965
- id: rowIdOf(key),
966
- pairKey: key,
967
- a: event.a,
968
- b: event.b,
969
- state: "skipped",
970
- queue: "skipped",
971
- origin: "agent",
972
- createdAt: event.at,
973
- updatedAt: event.at
974
- }
975
- };
976
- }
977
- if (current === void 0) return invalid(event, void 0);
978
- switch (event.type) {
979
- case "round1-keep": return current.state === "verify" ? next(current, event.at, "skipped", { origin: "agent" }) : invalid(event, current.state);
980
- case "round1-merge":
981
- if (current.state !== "verify") return invalid(event, current.state);
982
- return event.approval === "on" ? next(current, event.at, "pending", {
983
- ...event.decision === void 0 ? {} : { decision: event.decision },
984
- ...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
985
- ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt },
986
- ...event.sourceRevision === void 0 ? {} : { sourceRevision: event.sourceRevision },
987
- ...event.targetRevision === void 0 ? {} : { targetRevision: event.targetRevision }
988
- }) : next(current, event.at, "approved", {
989
- ...event.decision === void 0 ? {} : { decision: event.decision },
990
- ...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
991
- ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt },
992
- ...event.sourceRevision === void 0 ? {} : { sourceRevision: event.sourceRevision },
993
- ...event.targetRevision === void 0 ? {} : { targetRevision: event.targetRevision }
994
- });
995
- case "round1-fail": return current.state === "verify" ? next(current, event.at, "verify", {
996
- failure: event.failure,
997
- attempts: (current.attempts ?? 0) + 1
998
- }) : invalid(event, current.state);
999
- case "approve": return current.state === "pending" ? next(current, event.at, "approved", { ...event.runId === void 0 ? {} : { runId: event.runId } }) : invalid(event, current.state);
1000
- case "reject": return current.state === "pending" ? next(current, event.at, "skipped", { origin: "human" }) : invalid(event, current.state);
1001
- case "round2-cancel": return current.state === "fuse" || current.state === "approved" ? next(current, event.at, "approved") : invalid(event, current.state);
1002
- case "round2-fail": return current.state === "fuse" || current.state === "approved" ? next(current, event.at, "approved", {
1003
- failure: event.failure,
1004
- attempts: (current.attempts ?? 0) + 1
1005
- }) : invalid(event, current.state);
1006
- case "round2-success": return current.state === "fuse" || current.state === "approved" ? {
1007
- ok: true,
1008
- row: null
1009
- } : invalid(event, current.state);
1010
- case "target-taken":
1011
- 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);
1012
- case "restore": return current.state === "skipped" ? next(current, event.at, "verify") : invalid(event, current.state);
1013
- }
1014
- }
1015
- //#endregion
1016
- //#region lib/types/presentation-budget.js
1017
- /** Tool-result budget: keep the head and tail, mark what was omitted. */
1018
- function fitToolText(text, max = 8e3) {
1019
- if (text.length <= max) return text;
1020
- const marker = "\n... [truncated " + String(text.length - max) + " chars] ...\n";
1021
- const keep = Math.max(0, max - marker.length);
1022
- const head = Math.ceil(keep * .7);
1023
- const tail = keep - head;
1024
- return text.slice(0, head) + marker + (tail === 0 ? "" : text.slice(-tail));
1025
- }
1553
+ ]
1554
+ };
1026
1555
  //#endregion
1027
- //#region lib/types/status.js
1028
- function countRows(rows) {
1029
- 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 = {
1030
1561
  verify: 0,
1031
1562
  pending: 0,
1032
1563
  fuse: 0,
1033
1564
  skipped: 0
1034
1565
  };
1035
- for (const row of rows) counts[queueOf(row.state)] += 1;
1036
- return counts;
1037
- }
1038
- function describe(row) {
1039
- const stage = stageOf(row.state);
1040
- const target = row.decision?.shouldMerge === true ? " → " + row.decision.target : "";
1041
- 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
+ };
1042
1578
  }
1043
- function formatStatus(rows) {
1044
- const counts = countRows(rows);
1045
- const lines = ["merge: verify " + String(counts.verify) + ", pending " + String(counts.pending) + ", fuse " + String(counts.fuse) + ", skipped " + String(counts.skipped)];
1046
- for (const row of rows) lines.push("- " + describe(row));
1047
- 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
+ };
1048
1624
  }
1049
- function formatQueue(rows, queue) {
1050
- const selected = rows.filter((row) => queueOf(row.state) === queue);
1051
- if (selected.length === 0) return "merge " + queue + ": empty";
1052
- 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
+ };
1053
1645
  }
1054
1646
  //#endregion
1055
- //#region lib/types/routes.js
1056
- 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)) {
1057
1682
  return {
1058
1683
  id: row.id,
1059
- pairKey: row.pairKey,
1684
+ state: row.state,
1685
+ queue: queueOf$1(row),
1686
+ ...row.state === "verify" ? { verifySubState: "queued" } : {},
1060
1687
  a: row.a,
1061
1688
  b: row.b,
1062
- state: row.state,
1063
- queue: queueOf(row.state),
1064
- stage: stageOf(row.state),
1065
- label: row.a + " + " + row.b,
1066
- updatedAt: row.updatedAt,
1067
- createdAt: row.createdAt,
1068
- ...row.decision?.target === void 0 ? {} : { target: row.decision.target },
1069
- ...row.decision?.confidence === void 0 ? {} : { confidence: row.decision.confidence },
1070
- ...row.decision?.aSummary === void 0 ? {} : { aSummary: row.decision.aSummary },
1071
- ...row.decision?.bSummary === void 0 ? {} : { bSummary: row.decision.bSummary },
1072
- ...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" } : {},
1073
1701
  ...row.failure === void 0 ? {} : { failure: {
1074
- 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",
1075
1703
  code: row.failure.code,
1076
- message: row.failure.message,
1077
- 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)
1078
1714
  } },
1079
1715
  ...row.attempts === void 0 ? {} : { attempts: row.attempts },
1080
- ...row.origin === void 0 ? {} : { origin: row.origin },
1081
- ...row.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: row.stagingExpiresAt },
1082
- ...row.expired === void 0 ? {} : { expired: row.expired },
1083
- ...row.reason === void 0 ? {} : { reason: row.reason },
1084
- summary: [
1085
- row.decision?.aSummary ?? "未提供技能1摘要",
1086
- row.decision?.bSummary ?? "未提供技能2摘要",
1087
- row.decision?.rationale ?? row.reason ?? "未提供融合原因"
1088
- ],
1089
- 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
1090
1721
  };
1091
1722
  }
1092
- function buildMergeView(rows, commands, latestRun, approval) {
1093
- const counts = countRows(rows);
1094
- return {
1095
- schemaVersion: 2,
1096
- counts,
1097
- tabs: [
1098
- {
1099
- queue: "verify",
1100
- count: counts.verify
1101
- },
1102
- {
1103
- queue: "pending",
1104
- count: counts.pending
1105
- },
1106
- {
1107
- queue: "fuse",
1108
- count: counts.fuse
1109
- },
1110
- {
1111
- queue: "skipped",
1112
- count: counts.skipped
1113
- }
1114
- ],
1115
- commands,
1116
- ...latestRun === void 0 ? {} : { latestRun },
1117
- ...approval === void 0 ? {} : { approval }
1118
- };
1119
- }
1120
- function pageQueue(rows, queue, cursor = 0, limit = 50) {
1121
- const filtered = rows.filter((row) => queueOf(row.state) === queue);
1122
- const size = Math.max(1, Math.min(200, limit));
1123
- const start = Math.max(0, cursor);
1124
- const items = filtered.slice(start, start + size).map(toRowView);
1125
- 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)));
1126
1726
  return {
1727
+ wireVersion: 1,
1728
+ readAt,
1127
1729
  queue,
1128
- items,
1129
- ...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: []
1130
1735
  };
1131
1736
  }
1132
- function toRunFact(run) {
1737
+ function toRunsView(runs, readAt) {
1133
1738
  return {
1134
- id: run.id,
1135
- state: run.state,
1136
- ...run.command === void 0 ? {} : { command: run.command },
1137
- ...run.stage === void 0 ? {} : { stage: run.stage },
1138
- ...run.progress === void 0 ? {} : { progress: run.progress },
1139
- startedAt: run.startedAt,
1140
- ...run.endedAt === void 0 ? {} : { endedAt: run.endedAt },
1141
- ...run.resultRef === void 0 ? {} : { resultRef: run.resultRef },
1142
- ...run.failure === void 0 ? {} : { failure: run.failure },
1143
- ...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))
1144
1742
  };
1145
1743
  }
1146
- //#endregion
1147
- //#region lib/types/domain/markdown.js
1148
- /** Minimal safe Markdown renderer for the read-only body route. */
1149
- function escapeHtml(text) {
1150
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1151
- }
1152
- function renderMarkdown(source) {
1153
- const fence = String.fromCharCode(96, 96, 96);
1154
- const lines = source.split(/\r?\n/);
1155
- const out = [];
1156
- let inCode = false;
1157
- let inList = false;
1158
- for (const line of lines) {
1159
- if (line.startsWith(fence)) {
1160
- if (inCode) {
1161
- out.push("</code></pre>");
1162
- inCode = false;
1163
- } else {
1164
- out.push("<pre><code>");
1165
- inCode = true;
1166
- }
1167
- continue;
1168
- }
1169
- if (inCode) {
1170
- out.push(escapeHtml(line));
1171
- continue;
1172
- }
1173
- const heading = /^(#{1,6})\s+(.*)$/.exec(line);
1174
- if (heading !== null) {
1175
- if (inList) {
1176
- out.push("</ul>");
1177
- inList = false;
1178
- }
1179
- const level = heading[1]?.length ?? 1;
1180
- out.push("<h" + String(level) + ">" + escapeHtml(heading[2] ?? "") + "</h" + String(level) + ">");
1181
- continue;
1182
- }
1183
- if (/^[-*]\s+/.test(line)) {
1184
- if (!inList) {
1185
- out.push("<ul>");
1186
- inList = true;
1187
- }
1188
- out.push("<li>" + escapeHtml(line.replace(/^[-*]\s+/, "")) + "</li>");
1189
- continue;
1190
- }
1191
- if (inList) {
1192
- out.push("</ul>");
1193
- inList = false;
1194
- }
1195
- out.push(line.trim() === "" ? "" : "<p>" + escapeHtml(line) + "</p>");
1196
- }
1197
- if (inList) out.push("</ul>");
1198
- if (inCode) out.push("</code></pre>");
1199
- 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
+ };
1200
1769
  }
1201
1770
  //#endregion
1202
1771
  //#region lib/types/merge-routes.js
@@ -1209,12 +1778,15 @@ const MERGE_ROUTES = {
1209
1778
  history: MERGE_PATHS.history ?? MERGE_BASE + "history"
1210
1779
  };
1211
1780
  function makeMergeRoutes(deps, _audit) {
1212
- const absent = () => json(503, {
1213
- ok: false,
1214
- error: {
1215
- code: "merge-service-absent",
1216
- message: "merge service is not mounted"
1217
- }
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
1218
1790
  });
1219
1791
  const get = (request) => request.method === "GET" ? null : methodNotAllowed(["GET"]);
1220
1792
  const url = (request) => new URL(request.url, "http://local");
@@ -1228,11 +1800,13 @@ function makeMergeRoutes(deps, _audit) {
1228
1800
  if (denied !== null) return denied;
1229
1801
  const source = deps.getSource();
1230
1802
  if (source === void 0) return absent();
1231
- const latestMerge = (await source.runs(void 0, 20)).find((run) => run.kind === "merge");
1232
- return json(200, {
1233
- ok: true,
1234
- data: buildMergeView(await source.rows(), source.commands(), latestMerge === void 0 ? void 0 : toRunFact(latestMerge), source.approval?.())
1235
- });
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
+ }));
1236
1810
  }
1237
1811
  },
1238
1812
  {
@@ -1246,19 +1820,16 @@ function makeMergeRoutes(deps, _audit) {
1246
1820
  if (source === void 0) return absent();
1247
1821
  const query = url(request).searchParams;
1248
1822
  const queue = query.get("queue") ?? "verify";
1249
- if (queue !== "verify" && queue !== "pending" && queue !== "fuse" && queue !== "skipped") return json(400, {
1250
- ok: false,
1251
- error: {
1252
- code: "bad-queue",
1253
- message: "queue must be verify/pending/fuse/skipped"
1254
- }
1255
- });
1256
- 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");
1257
1826
  const limit = Number.parseInt(query.get("limit") ?? "50", 10);
1258
- return json(200, {
1259
- ok: true,
1260
- data: pageQueue(await source.rows(), queue, Number.isFinite(cursor) ? cursor : 0, Number.isFinite(limit) ? limit : 50)
1261
- });
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);
1262
1833
  }
1263
1834
  },
1264
1835
  {
@@ -1273,28 +1844,13 @@ function makeMergeRoutes(deps, _audit) {
1273
1844
  const query = url(request).searchParams;
1274
1845
  const id = query.get("id");
1275
1846
  const side = query.get("side");
1276
- if (id === null || id === "" || side !== "a" && side !== "b") return json(400, {
1277
- ok: false,
1278
- error: {
1279
- code: "bad-body-query",
1280
- message: "id and side=a|b are required"
1281
- }
1282
- });
1847
+ if (id === null || id === "" || side !== "a" && side !== "b") return bad("id and side=a|b are required");
1283
1848
  const content = await source.body(id, side);
1284
- return content === null ? json(404, {
1285
- ok: false,
1286
- error: {
1287
- code: "body-not-found",
1288
- message: "merge body not found: " + id
1289
- }
1290
- }) : json(200, {
1291
- ok: true,
1292
- data: {
1293
- content,
1294
- markdown: true,
1295
- html: renderMarkdown(content)
1296
- }
1297
- });
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()));
1298
1854
  }
1299
1855
  },
1300
1856
  {
@@ -1306,10 +1862,7 @@ function makeMergeRoutes(deps, _audit) {
1306
1862
  if (denied !== null) return denied;
1307
1863
  const source = deps.getSource();
1308
1864
  if (source === void 0) return absent();
1309
- return json(200, {
1310
- ok: true,
1311
- data: { items: await source.history() }
1312
- });
1865
+ return json(200, toHistoryView(await source.history(), Date.now()));
1313
1866
  }
1314
1867
  }
1315
1868
  ];
@@ -1557,8 +2110,58 @@ function mergeUsage() {
1557
2110
  return USAGE;
1558
2111
  }
1559
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
1560
2163
  //#region lib/types/read-commands.js
1561
- async function handleReadOnlyMergeCommand(command, io, home, cards) {
2164
+ async function handleReadOnlyMergeCommand(command, io, home, cards, runs, approval = false) {
1562
2165
  const withNote = (text, note) => note === void 0 ? text : "note: " + note + "\n" + text;
1563
2166
  const load = async () => {
1564
2167
  const result = await loadMergeState(io, home);
@@ -1571,7 +2174,13 @@ async function handleReadOnlyMergeCommand(command, io, home, cards) {
1571
2174
  const row = loaded.state.rows.find((item) => item.id === command.id);
1572
2175
  return withNote(row === void 0 ? "merge row not found: " + command.id : formatStatus([row]), loaded.note);
1573
2176
  }
1574
- 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);
1575
2184
  }
1576
2185
  if (command.verb === "pending") {
1577
2186
  const loaded = await load();
@@ -1657,6 +2266,115 @@ function createMergeCommand(service) {
1657
2266
  };
1658
2267
  }
1659
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
1660
2378
  //#region lib/types/skipped.js
1661
2379
  /**
1662
2380
  * Skipped filtering: a pair the operator marked as not-worth-merging stays out of automatic
@@ -1761,6 +2479,7 @@ async function runScan(options) {
1761
2479
  const stage = options.stage ?? (async () => {});
1762
2480
  const candidates = await options.ports.screening(options.signal);
1763
2481
  const before = await loadMergeState(options.io, options.home);
2482
+ if (!before.ok) throw new Error(before.note ?? "merge-state-invalid");
1764
2483
  const queueBefore = {
1765
2484
  verify: 0,
1766
2485
  pending: 0,
@@ -1803,29 +2522,46 @@ async function runScan(options) {
1803
2522
  });
1804
2523
  let row;
1805
2524
  try {
1806
- const created = transition(void 0, {
1807
- 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,
1808
2530
  a: candidate.a,
1809
2531
  b: candidate.b,
1810
- at: at(options.now)
1811
- });
1812
- if (!created.ok || created.row === null) throw new Error(created.ok ? "candidate row missing" : created.message);
1813
- row = options.runId === void 0 ? created.row : {
1814
- ...created.row,
1815
- runId: options.runId
2532
+ state: "verify",
2533
+ queue: "verify",
2534
+ createdAt: atNow,
2535
+ updatedAt: atNow,
2536
+ ...options.runId === void 0 ? {} : { runId: options.runId }
1816
2537
  };
1817
2538
  const before = (await loadMergeState(options.io, options.home)).state.rows.find((item) => item.pairKey === row.pairKey);
1818
2539
  if (before?.state === "verify" && before.runId !== void 0 && before.runId !== options.runId && await options.isOwnerActive?.(before.runId) === true) continue;
1819
- const placed = await upsertRow(options.io, options.home, options.now, row, "verify", before?.updatedAt ?? null);
1820
- if (!placed.ok && before?.state === "skipped") {
1821
- const restored = transition(before, {
1822
- type: "restore",
1823
- 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 }
1824
2560
  });
1825
- if (!restored.ok || restored.row === null) throw new Error("cannot restore skipped pair");
1826
- if (!(await upsertRow(options.io, options.home, options.now, restored.row, "any", before.updatedAt)).ok) throw new Error("pair changed while restoring skipped row");
1827
- row = restored.row;
1828
- } 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);
1829
2565
  } catch (error) {
1830
2566
  totals.failed += 1;
1831
2567
  pairs.push({
@@ -1859,33 +2595,47 @@ async function runScan(options) {
1859
2595
  break;
1860
2596
  }
1861
2597
  if (round1.kind === "failed") {
1862
- const next = transition(row, {
1863
- type: "round1-fail",
1864
- failure: round1.failure ?? failureOf("decide", "round1 failed"),
1865
- 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;
1866
2607
  });
1867
- if (next.ok && next.row !== null) {
1868
- row = next.row;
1869
- 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;
1870
2612
  }
1871
- pairs.push(pairOf(row, candidate, "failed", { failure: round1.failure ?? failureOf("decide", "round1 failed") }));
2613
+ row = projected;
2614
+ pairs.push(pairOf(row, candidate, "failed", { failure }));
1872
2615
  totals.failed += 1;
1873
2616
  continue;
1874
2617
  }
1875
2618
  if (round1.kind === "keep") {
1876
- const next = transition(row, {
1877
- type: "round1-keep",
1878
- 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;
1879
2627
  });
1880
- if (next.ok && next.row !== null) {
1881
- row = next.row;
1882
- await upsertRow(options.io, options.home, options.now, row);
1883
- await recordPair(options.io, options.home, {
1884
- pairKey: row.pairKey,
1885
- runId: row.runId ?? "merge",
1886
- result: "kept"
1887
- });
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;
1888
2632
  }
2633
+ row = projected;
2634
+ await recordPair(options.io, options.home, {
2635
+ pairKey: row.pairKey,
2636
+ runId: row.runId ?? "merge",
2637
+ result: "kept"
2638
+ });
1889
2639
  pairs.push(pairOf(row, candidate, "kept", round1.decision === void 0 ? {} : { decision: round1.decision }));
1890
2640
  totals.kept += 1;
1891
2641
  continue;
@@ -1896,49 +2646,79 @@ async function runScan(options) {
1896
2646
  continue;
1897
2647
  }
1898
2648
  if (options.approval) {
1899
- const next = transition(row, {
1900
- type: "round1-merge",
1901
- approval: "on",
2649
+ const event = {
2650
+ type: "verify-passed",
1902
2651
  decision: round1.decision,
1903
- ...round1.sourceRevision === void 0 ? {} : { sourceRevision: round1.sourceRevision },
1904
- ...round1.targetRevision === void 0 ? {} : { targetRevision: round1.targetRevision },
1905
- 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;
1906
2660
  });
1907
- if (next.ok && next.row !== null) {
1908
- row = next.row;
1909
- 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;
1910
2668
  }
2669
+ row = projected;
1911
2670
  pairs.push(pairOf(row, candidate, "pending", { decision: round1.decision }));
1912
2671
  totals.pending += 1;
1913
2672
  continue;
1914
2673
  }
1915
- const fuse = transition(row, {
1916
- type: "round1-merge",
1917
- approval: "off",
1918
- decision: round1.decision,
1919
- ...round1.stagingRef === void 0 ? {} : { stagingRef: round1.stagingRef },
1920
- ...round1.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: round1.stagingExpiresAt },
1921
- 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;
1922
2690
  });
1923
- if (!fuse.ok || fuse.row === null) {
2691
+ if (staged === null) {
1924
2692
  pairs.push(pairOf(row, candidate, "failed", {
1925
2693
  decision: round1.decision,
1926
- failure: failureOf("decide", "cannot enter fuse")
2694
+ failure: failureOf("author", "row changed before staging record")
1927
2695
  }));
1928
2696
  totals.failed += 1;
1929
2697
  continue;
1930
2698
  }
1931
- row = fuse.row;
1932
- 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
+ }
1933
2711
  if (isAborted(options.signal)) {
1934
- const cancelled = transition(row, {
1935
- type: "round2-cancel",
1936
- 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;
1937
2720
  });
1938
- if (cancelled.ok && cancelled.row !== null) {
1939
- row = cancelled.row;
1940
- await upsertRow(options.io, options.home, options.now, row);
1941
- }
2721
+ if (projected !== null) row = projected;
1942
2722
  stopReason = "cancelled";
1943
2723
  remaining.push(...candidates.slice(index).map((candidate) => ({
1944
2724
  pairKey: pairKey(candidate.a, candidate.b),
@@ -1960,10 +2740,14 @@ async function runScan(options) {
1960
2740
  };
1961
2741
  }
1962
2742
  if (round2.kind === "merged") {
1963
- if (transition(row, {
1964
- type: "round2-success",
1965
- at: at(options.now)
1966
- }).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
+ }
1967
2751
  pairs.push(pairOf(row, candidate, "merged", {
1968
2752
  decision: round1.decision,
1969
2753
  ...round2.unitId === void 0 ? {} : { unitId: round2.unitId },
@@ -1974,15 +2758,24 @@ async function runScan(options) {
1974
2758
  continue;
1975
2759
  }
1976
2760
  const failed = round2.failure ?? failureOf("author", "round2 failed");
1977
- const next = transition(row, {
1978
- type: "round2-fail",
1979
- failure: failed,
1980
- 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;
1981
2769
  });
1982
- if (next.ok && next.row !== null) {
1983
- row = next.row;
1984
- 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;
1985
2777
  }
2778
+ row = projected;
1986
2779
  pairs.push(pairOf(row, candidate, "failed", {
1987
2780
  decision: round1.decision,
1988
2781
  failure: failed
@@ -2202,48 +2995,40 @@ async function runScanCommand(options) {
2202
2995
  //#endregion
2203
2996
  //#region lib/types/pending-actions.js
2204
2997
  async function act(io, home, now, id, type) {
2205
- return await mutateMergeState(io, home, now, (rows) => {
2206
- const index = rows.findIndex((row) => row.id === id);
2207
- if (index < 0) return {
2208
- rows,
2209
- result: {
2210
- ok: false,
2211
- reason: "not-found",
2212
- message: "pending row not found: " + id
2213
- }
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
2214
3004
  };
2215
- const current = rows[index];
2216
- if (current.state !== "pending") return {
2217
- rows,
2218
- result: {
2219
- ok: false,
2220
- reason: "not-pending",
2221
- message: "row is not pending: " + current.state
2222
- }
3005
+ if (row.state !== "pending") return {
3006
+ ok: false,
3007
+ reason: "not-pending",
3008
+ message: "row is not pending: " + row.state
2223
3009
  };
2224
- const next = transition(current, type === "approve" ? {
2225
- type: "approve",
2226
- at: new Date(now()).toISOString()
3010
+ const event = type === "approve" ? {
3011
+ type: "approved",
3012
+ runId: row.runId ?? "approve:" + id
2227
3013
  } : {
2228
- type: "reject",
2229
- at: new Date(now()).toISOString()
2230
- });
2231
- if (!next.ok || next.row === null) return {
2232
- rows,
2233
- result: {
2234
- ok: false,
2235
- reason: "invalid",
2236
- message: next.ok ? "transition produced no row" : next.message
2237
- }
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"
2238
3028
  };
2239
- const copy = [...rows];
2240
- copy[index] = next.row;
2241
3029
  return {
2242
- rows: copy,
2243
- result: {
2244
- ok: true,
2245
- row: next.row
2246
- }
3030
+ ok: true,
3031
+ row: v3ToLegacy(moved.row)
2247
3032
  };
2248
3033
  });
2249
3034
  }
@@ -2254,79 +3039,29 @@ async function rejectPending(io, home, now, id) {
2254
3039
  return await act(io, home, now, id, "reject");
2255
3040
  }
2256
3041
  //#endregion
2257
- //#region lib/types/domain/claim.js
2258
- function acquireClaim(current, now, leaseMs, token) {
2259
- if (current !== void 0 && current.expiresAt > now) return void 0;
2260
- return {
2261
- id: "claim",
2262
- token,
2263
- expiresAt: now + Math.max(1, leaseMs)
2264
- };
2265
- }
2266
- function releaseClaim(current, token) {
2267
- return current?.token === token ? void 0 : current;
2268
- }
2269
- //#endregion
2270
- //#region lib/types/target-claim.js
2271
- /** Durable target claim: one approve writer per merge target at a time. */
2272
- const LEASE_MS = 30 * 6e4;
2273
- function claimPath(home, target) {
2274
- return join(home, "merge", "claims", contentHash(target) + ".json");
2275
- }
2276
- function parseClaim(raw) {
2277
- if (raw === null) return void 0;
2278
- const parsed = JSON.parse(raw);
2279
- if (typeof parsed.token !== "string" || typeof parsed.expiresAt !== "number") throw new Error("corrupt target claim");
2280
- return {
2281
- id: "claim",
2282
- token: parsed.token,
2283
- expiresAt: parsed.expiresAt
2284
- };
2285
- }
2286
- async function compareAndSwap(io, path, task) {
2287
- if (io.transact !== void 0) {
2288
- await io.transact(path, (current) => task(current));
2289
- return;
2290
- }
2291
- const current = await io.exists(path) ? await io.readText(path) : null;
2292
- const next = task(current);
2293
- if (next === null) {
2294
- if (current !== null) try {
2295
- await io.remove(path);
2296
- } catch {}
2297
- } else if (next !== current) await io.writeText(path, next);
2298
- }
2299
- async function acquireTargetClaim(io, home, target, now) {
2300
- const path = claimPath(home, target);
2301
- const token = randomUUID();
2302
- let granted;
2303
- await compareAndSwap(io, path, (raw) => {
2304
- const claim = acquireClaim(parseClaim(raw), now, LEASE_MS, token);
2305
- if (claim === void 0) return raw;
2306
- granted = claim;
2307
- return JSON.stringify(claim) + "\n";
2308
- });
2309
- if (granted === void 0) return null;
2310
- return {
2311
- token,
2312
- async release() {
2313
- await compareAndSwap(io, path, (raw) => {
2314
- const next = releaseClaim(parseClaim(raw), token);
2315
- return next === void 0 ? null : JSON.stringify(next) + "\n";
2316
- });
2317
- }
2318
- };
2319
- }
2320
- //#endregion
2321
3042
  //#region lib/types/round2-outcome.js
2322
3043
  async function recordRound2Outcome(io, home, now, row, outcome) {
2323
- const at = new Date(now()).toISOString();
2324
3044
  if (outcome.kind === "merged") {
2325
- if (transition(row, {
2326
- type: "round2-success",
2327
- at
2328
- }).ok) {
2329
- 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
+ };
2330
3065
  const target = outcome.target?.actual ?? outcome.target?.requested ?? row.decision?.target;
2331
3066
  await recordPair(io, home, {
2332
3067
  pairKey: row.pairKey,
@@ -2359,20 +3094,37 @@ async function recordRound2Outcome(io, home, now, row, outcome) {
2359
3094
  retryable: true,
2360
3095
  retryFrom: "agent2"
2361
3096
  };
2362
- const next = transition(row, {
2363
- type: "round2-fail",
2364
- failure,
2365
- at
2366
- });
2367
- if (next.ok && next.row !== null) {
2368
- await upsertRow(io, home, now, next.row);
2369
- 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,
2370
3109
  pairKey: row.pairKey,
2371
- runId: row.runId ?? "merge",
2372
- result: "failed",
2373
- ...row.decision?.target === void 0 ? {} : { target: row.decision.target }
2374
- });
2375
- }
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
+ });
2376
3128
  return {
2377
3129
  merged: false,
2378
3130
  pair: {
@@ -2391,89 +3143,89 @@ async function recordRound2Outcome(io, home, now, row, outcome) {
2391
3143
  //#endregion
2392
3144
  //#region lib/types/approve-command.js
2393
3145
  async function runApproveCommand(options) {
2394
- const target = (await loadMergeState(options.io, options.home)).state.rows.find((item) => item.id === options.rowId)?.decision?.target;
2395
- const claim = target === void 0 ? null : await acquireTargetClaim(options.io, options.home, target, options.now());
2396
- 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 {
2397
3148
  ok: false,
2398
- message: "target already claimed by another approve: " + target
3149
+ message: "pending row not found: " + options.rowId
2399
3150
  };
2400
- try {
2401
- const taken = await approvePending(options.io, options.home, options.now, options.rowId);
2402
- if (!taken.ok) return {
2403
- ok: false,
2404
- message: taken.message
2405
- };
2406
- const row = taken.row;
2407
- const decision = row.decision;
2408
- if (decision === void 0) return {
2409
- ok: false,
2410
- message: "pending row has no decision"
2411
- };
2412
- return await withMergeRun({
2413
- io: options.io,
2414
- home: options.home,
2415
- registry: options.registry,
2416
- command: "approve",
2417
- approval: {
2418
- platformWrite: options.platformWrite,
2419
- mergeApproval: true
2420
- },
2421
- now: options.now,
2422
- async body(ctx) {
2423
- const stage = (name) => ctx.stage(name, {
2424
- done: 0,
2425
- total: 1
2426
- });
2427
- await stage("author");
2428
- let outcome;
2429
- try {
2430
- outcome = await options.ports.round2(row, decision, ctx.signal, ctx.runId);
2431
- } catch (error) {
2432
- outcome = {
2433
- kind: "failed",
2434
- failure: {
2435
- stage: "author",
2436
- code: "merge-failed",
2437
- message: error instanceof Error ? error.message : String(error),
2438
- retryable: true,
2439
- retryFrom: "agent2"
2440
- }
2441
- };
2442
- }
2443
- const base = {
2444
- scope: {
2445
- candidatePairs: 1,
2446
- queueBefore: {
2447
- verify: 0,
2448
- pending: 0,
2449
- fuse: 1,
2450
- skipped: 0
2451
- }
2452
- },
2453
- notes: []
2454
- };
2455
- const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome);
2456
- return {
2457
- ...base,
2458
- stopReason: record.merged ? "completed" : "failed",
2459
- pairs: [record.pair],
2460
- totals: record.merged ? {
2461
- merged: 1,
2462
- kept: 0,
2463
- pending: 0,
2464
- failed: 0
2465
- } : {
2466
- merged: 0,
2467
- kept: 0,
2468
- pending: 0,
2469
- 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"
2470
3195
  }
2471
3196
  };
2472
3197
  }
2473
- });
2474
- } finally {
2475
- await claim?.release();
2476
- }
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
+ });
2477
3229
  }
2478
3230
  //#endregion
2479
3231
  //#region lib/types/resume-command.js
@@ -2495,6 +3247,7 @@ async function runResumeCommand(options) {
2495
3247
  approval: true,
2496
3248
  platformWrite: options.platformWrite,
2497
3249
  now: options.now,
3250
+ ...options.detached === true ? { detached: true } : {},
2498
3251
  ...options.makePorts === void 0 ? { ports: {
2499
3252
  ...options.ports,
2500
3253
  screening: () => Promise.resolve([{
@@ -2539,6 +3292,7 @@ async function runResumeCommand(options) {
2539
3292
  mergeApproval: true
2540
3293
  },
2541
3294
  now: options.now,
3295
+ ...options.detached === true ? { detached: true } : {},
2542
3296
  async body(ctx) {
2543
3297
  await ctx.stage("author", {
2544
3298
  done: 0,
@@ -2655,6 +3409,245 @@ function skillLibraryUndoPort(skills) {
2655
3409
  };
2656
3410
  }
2657
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
2658
3651
  //#region lib/types/doctor.js
2659
3652
  /**
2660
3653
  * Merge doctor rows: state, pending, gate cache and staging are each probed read-only.
@@ -2662,6 +3655,13 @@ function skillLibraryUndoPort(skills) {
2662
3655
  * operator can act on. This module never repairs or writes.
2663
3656
  */
2664
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
+ }
2665
3665
  async function mergeDoctor(io, home, configuredRoot) {
2666
3666
  const rows = [];
2667
3667
  const loaded = await loadMergeState(io, home);
@@ -2672,12 +3672,120 @@ async function mergeDoctor(io, home, configuredRoot) {
2672
3672
  status: loaded.note === void 0 ? "ok" : "warn",
2673
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)
2674
3674
  });
2675
- const modelRoot = configuredRoot !== void 0 && configuredRoot !== "" ? configuredRoot : process.env.SKILL_MERGE_MODELS_ROOT;
2676
- 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({
2677
3785
  key: "models",
2678
3786
  label: "合并模型",
2679
3787
  status: "warn",
2680
- detail: "未设置 SKILL_MERGE_MODELS_ROOT;合并扫描会 fail loud"
3788
+ detail: "未配置 mergeModelsRoot;合并扫描会 fail loud"
2681
3789
  });
2682
3790
  else {
2683
3791
  const int8 = join(modelRoot, "potion-multilingual-128M-int8");
@@ -2704,12 +3812,32 @@ async function mergeDoctor(io, home, configuredRoot) {
2704
3812
  status: "ok",
2705
3813
  detail: stagingRoot(home, "<runId>")
2706
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
+ }
2707
3835
  return rows;
2708
3836
  }
2709
3837
  //#endregion
2710
3838
  //#region lib/types/decision.js
2711
3839
  function extractJson(text) {
2712
- const body = /\`\`\`(?:json)?\s*([\s\S]*?)\`\`\`/i.exec(text)?.[1] ?? text;
3840
+ const body = /`\``(?:json)?\s*([\s\S]*?)`\``/i.exec(text)?.[1] ?? text;
2713
3841
  const start = body.indexOf("{");
2714
3842
  if (start < 0) return void 0;
2715
3843
  let depth = 0;
@@ -3236,12 +4364,17 @@ async function assembleStagedCommit(io, home, runId, port, options) {
3236
4364
  const requested = staging.value.manifest.target;
3237
4365
  let target = requested;
3238
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
+ };
3239
4371
  const requestedArchived = targetCurrent === null && await port.isArchived?.(requested) === true;
3240
- 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)) {
3241
4374
  target = requested;
3242
4375
  for (let suffix = 2; suffix <= 99; suffix += 1) {
3243
4376
  const candidate = requested + "-v" + String(suffix);
3244
- 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) {
3245
4378
  target = candidate;
3246
4379
  targetCurrent = null;
3247
4380
  break;
@@ -3286,27 +4419,87 @@ async function commitStagedRun(io, home, runId, port, options) {
3286
4419
  ok: false,
3287
4420
  message: assembled.message
3288
4421
  };
3289
- try {
3290
- await writeCommitSnapshot(io, home, runId, assembled.plan, options.now(), port);
3291
- } catch (error) {
3292
- return {
3293
- ok: false,
3294
- message: "commit snapshot failed; refusing to write: " + (error instanceof Error ? error.message : String(error)),
3295
- plan: assembled.plan
3296
- };
3297
- }
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
+ };
3298
4427
  const journal = {
3299
4428
  runId,
3300
4429
  target: assembled.plan.target,
3301
4430
  create: !assembled.plan.targetExists,
3302
4431
  sources: assembled.plan.sources,
3303
- phase: "sources-archived",
4432
+ phase: "prepared",
3304
4433
  updatedAt: new Date(options.now()).toISOString()
3305
4434
  };
3306
- try {
3307
- await writeJournal(io, home, journal);
3308
- } catch {}
3309
- 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
+ }
3310
4503
  if (!result.ok) {
3311
4504
  try {
3312
4505
  await writeJournal(io, home, {
@@ -3324,7 +4517,8 @@ async function commitStagedRun(io, home, runId, port, options) {
3324
4517
  const committed = {
3325
4518
  ...journal,
3326
4519
  phase: "committed",
3327
- updatedAt: new Date(options.now()).toISOString()
4520
+ updatedAt: new Date(options.now()).toISOString(),
4521
+ ...result.unitId === void 0 ? {} : { unitId: result.unitId }
3328
4522
  };
3329
4523
  try {
3330
4524
  await writeJournal(io, home, committed);
@@ -3370,13 +4564,25 @@ async function commitStagedRun(io, home, runId, port, options) {
3370
4564
  //#region lib/types/authoring-flow.js
3371
4565
  async function runAuthoring(options) {
3372
4566
  const targetCard = options.cards.find((card) => card.name === options.target);
3373
- 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 {
3374
4580
  ok: false,
3375
- message: "target is pinned: " + options.target
4581
+ message: "target is blocked by curator: " + options.target
3376
4582
  };
3377
4583
  const stageKey = options.stageKey ?? options.runId;
3378
4584
  if (options.reuseStaging === true) {
3379
- 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 {
3380
4586
  ok: false,
3381
4587
  message: "staging missing or unreadable: " + stageKey
3382
4588
  };
@@ -3384,7 +4590,7 @@ async function runAuthoring(options) {
3384
4590
  const created = await createStaging(options.io, options.home, {
3385
4591
  runId: stageKey,
3386
4592
  target: options.target,
3387
- ...options.mode === void 0 ? {} : { mode: options.mode },
4593
+ mode,
3388
4594
  sources: options.cards.map((card) => ({
3389
4595
  name: card.name,
3390
4596
  body: card.body,
@@ -3422,9 +4628,11 @@ async function runAuthoring(options) {
3422
4628
  findings
3423
4629
  };
3424
4630
  const commit = await commitStagedRun(options.io, options.home, stageKey, options.commitPort, {
3425
- origin: "subagent",
4631
+ origin: "foreground",
3426
4632
  summary: options.summary,
3427
- now: options.now
4633
+ now: options.now,
4634
+ ...options.isBlocked === void 0 ? {} : { isBlocked: options.isBlocked },
4635
+ ...options.unit === void 0 ? {} : { unit: options.unit }
3428
4636
  });
3429
4637
  if (!commit.ok) return {
3430
4638
  ok: false,
@@ -3550,7 +4758,9 @@ function createChannelScanPorts(options) {
3550
4758
  mode: decision.mode,
3551
4759
  cards: options.cards,
3552
4760
  commitPort: options.commitPort,
4761
+ ...options.unit === void 0 ? {} : { unit: options.unit },
3553
4762
  now: options.now,
4763
+ ...options.isBlocked === void 0 ? {} : { isBlocked: options.isBlocked },
3554
4764
  summary: "merge " + row.a + " + " + row.b,
3555
4765
  author: async () => {
3556
4766
  await entry.result.author?.(row, decision, signal);
@@ -3589,12 +4799,35 @@ function createChannelScanPorts(options) {
3589
4799
  //#region lib/types/new-child-authoring.js
3590
4800
  async function runNewChildAuthoring(options, row, decision, signal) {
3591
4801
  const targetCard = options.cards.find((card) => card.name === decision.target);
3592
- 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 {
3593
4826
  kind: "failed",
3594
4827
  failure: {
3595
4828
  stage: "author",
3596
- code: "target-pinned",
3597
- message: "target is pinned: " + decision.target,
4829
+ code: "target-blocked",
4830
+ message: "target is blocked by curator: " + decision.target,
3598
4831
  retryable: false,
3599
4832
  retryFrom: "none"
3600
4833
  }
@@ -3709,9 +4942,11 @@ async function runNewChildAuthoring(options, row, decision, signal) {
3709
4942
  }
3710
4943
  };
3711
4944
  const commit = await commitStagedRun(options.io, options.home, stageKey, options.commitPort, {
3712
- origin: "subagent",
4945
+ origin: "foreground",
3713
4946
  summary: "merge " + row.a + " + " + row.b,
3714
- now: options.now
4947
+ now: options.now,
4948
+ ...options.isBlocked === void 0 ? {} : { isBlocked: options.isBlocked },
4949
+ ...options.unit === void 0 ? {} : { unit: options.unit }
3715
4950
  });
3716
4951
  if (!commit.ok) return {
3717
4952
  kind: "failed",
@@ -3758,7 +4993,10 @@ function skillLibraryPort(skills) {
3758
4993
  const names = await archivedNames.call(skills);
3759
4994
  return names === null ? true : names.includes(name);
3760
4995
  },
3761
- 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
+ },
3762
5000
  async readSupportFiles(name) {
3763
5001
  if (typeof skills.listSupportFiles !== "function") return [];
3764
5002
  const probe = await skills.listSupportFiles(name);
@@ -3792,6 +5030,17 @@ function forcedDecisionFor(a, b) {
3792
5030
  rationale: "forced pair merge of " + sorted.join(" and ")
3793
5031
  };
3794
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
+ }
3795
5044
  function createMergeRuntime(deps) {
3796
5045
  const readOnly = createReadOnlyMergeService(deps.io, deps.home);
3797
5046
  const timeout = deps.timeoutMinutes > 0 ? deps.timeoutMinutes : void 0;
@@ -3842,7 +5091,12 @@ function createMergeRuntime(deps) {
3842
5091
  b: command.b
3843
5092
  }];
3844
5093
  const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find((row) => row.pairKey === pairKey(command.a, command.b));
3845
- 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
+ }
3846
5100
  if (existing?.state === "pending") return {
3847
5101
  ok: false,
3848
5102
  text: "该 pair 已在待批准队列,先 approve/reject/discard: " + existing.id
@@ -3872,6 +5126,8 @@ function createMergeRuntime(deps) {
3872
5126
  approval,
3873
5127
  commitPort: skillLibraryPort(deps.skills),
3874
5128
  now: deps.now,
5129
+ ...unitFor(deps) === void 0 ? {} : { unit: unitFor(deps) },
5130
+ ...deps.isBlocked === void 0 ? {} : { isBlocked: deps.isBlocked },
3875
5131
  ...stageHooks
3876
5132
  });
3877
5133
  return forced === void 0 ? ports : {
@@ -3937,6 +5193,8 @@ function createMergeRuntime(deps) {
3937
5193
  approval: deps.params.mergeApproval,
3938
5194
  commitPort: skillLibraryPort(deps.skills),
3939
5195
  now: deps.now,
5196
+ ...unitFor(deps) === void 0 ? {} : { unit: unitFor(deps) },
5197
+ ...deps.isBlocked === void 0 ? {} : { isBlocked: deps.isBlocked },
3940
5198
  ...stageHooks
3941
5199
  })
3942
5200
  });
@@ -3963,15 +5221,18 @@ function createMergeRuntime(deps) {
3963
5221
  ok: false,
3964
5222
  text: "merge row not found: " + command.id
3965
5223
  };
3966
- const next = transition(row, {
3967
- type: "reject",
3968
- at: new Date(deps.now()).toISOString()
3969
- });
3970
- 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 {
3971
5233
  ok: false,
3972
- text: "merge row is not pending: " + command.id
5234
+ text: "merge row is not pending or changed: " + command.id
3973
5235
  };
3974
- await upsertRow(deps.io, deps.home, deps.now, next.row);
3975
5236
  return {
3976
5237
  ok: true,
3977
5238
  text: "已拒绝合并:" + command.id
@@ -3983,13 +5244,10 @@ function createMergeRuntime(deps) {
3983
5244
  };
3984
5245
  if (command.verb === "report" || command.verb === "runs" || command.verb === "cancel") {
3985
5246
  const registry = deps.registry;
3986
- if (command.verb === "runs") {
3987
- const mergeRuns = registry.runs("merge");
3988
- return {
3989
- ok: true,
3990
- 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")
3991
- };
3992
- }
5247
+ if (command.verb === "runs") return {
5248
+ ok: true,
5249
+ text: formatRunsView(toRunsView(registry.runs("merge"), deps.now()))
5250
+ };
3993
5251
  if (command.verb === "cancel") {
3994
5252
  if (command.id !== void 0) {
3995
5253
  const cancelled = registry.cancel(command.id);
@@ -4036,7 +5294,10 @@ function createMergeRuntime(deps) {
4036
5294
  ok: false,
4037
5295
  text: "merge row is not discardable: " + command.id
4038
5296
  };
4039
- 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
+ };
4040
5301
  return {
4041
5302
  ok: true,
4042
5303
  text: "已丢弃合并行:" + command.id
@@ -4070,7 +5331,10 @@ function createMergeRuntime(deps) {
4070
5331
  ok: false,
4071
5332
  text: "skipped row not found: " + command.restore
4072
5333
  };
4073
- 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
+ };
4074
5338
  return {
4075
5339
  ok: true,
4076
5340
  text: "已恢复参与筛选:" + row.id
@@ -4083,7 +5347,10 @@ function createMergeRuntime(deps) {
4083
5347
  ok: false,
4084
5348
  text: "merge row not found: " + command.clear
4085
5349
  };
4086
- 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
+ };
4087
5354
  return {
4088
5355
  ok: true,
4089
5356
  text: "已清除合并行:" + cleared.id
@@ -4103,6 +5370,21 @@ function createMergeRuntime(deps) {
4103
5370
  };
4104
5371
  const signal = new AbortController().signal;
4105
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
+ }
4106
5388
  const pair = () => Promise.resolve([{
4107
5389
  a: row.a,
4108
5390
  b: row.b
@@ -4126,6 +5408,8 @@ function createMergeRuntime(deps) {
4126
5408
  ...common,
4127
5409
  rowId: row.id,
4128
5410
  ports: basePorts,
5411
+ ...detached ? { detached: true } : {},
5412
+ pairTimeoutMinutes: deps.params.mergePairTimeoutMinutes,
4129
5413
  ...deps.isOwnerActive === void 0 ? {} : { isOwnerActive: deps.isOwnerActive },
4130
5414
  makePorts: (runId) => createChannelScanPorts({
4131
5415
  io: deps.io,
@@ -4140,7 +5424,9 @@ function createMergeRuntime(deps) {
4140
5424
  timeoutMs: deps.params.mergePairTimeoutMinutes * 6e4,
4141
5425
  approval: deps.params.mergeApproval,
4142
5426
  commitPort: skillLibraryPort(deps.skills),
4143
- now: deps.now
5427
+ now: deps.now,
5428
+ ...unitFor(deps) === void 0 ? {} : { unit: unitFor(deps) },
5429
+ ...deps.isBlocked === void 0 ? {} : { isBlocked: deps.isBlocked }
4144
5430
  }),
4145
5431
  round2WithRun: createNewChildRound2({
4146
5432
  io: deps.io,
@@ -4153,6 +5439,8 @@ function createMergeRuntime(deps) {
4153
5439
  timeoutMs: deps.params.mergePairTimeoutMinutes * 6e4,
4154
5440
  commitPort: skillLibraryPort(deps.skills),
4155
5441
  now: deps.now,
5442
+ ...unitFor(deps) === void 0 ? {} : { unit: unitFor(deps) },
5443
+ ...deps.isBlocked === void 0 ? {} : { isBlocked: deps.isBlocked },
4156
5444
  ...stageHooks
4157
5445
  })
4158
5446
  });
@@ -4176,11 +5464,22 @@ function createMergeRuntime(deps) {
4176
5464
  if (command.verb === "approve") {
4177
5465
  const signal = new AbortController().signal;
4178
5466
  const cards = await deps.cards(signal);
4179
- 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
+ }
4180
5476
  if (candidate !== void 0 && candidate.state === "pending" && candidate.decision !== void 0) {
4181
5477
  const missingSource = [candidate.a, candidate.b].find((name) => !cards.some((card) => card.name === name && card.managed === true));
4182
5478
  if (missingSource !== void 0) {
4183
- 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
+ };
4184
5483
  return {
4185
5484
  ok: false,
4186
5485
  text: "source-gone:" + missingSource + " 已不在 family-managed 技能库,已从 active 队列清除;可 merge skipped --discard 清理"
@@ -4188,11 +5487,16 @@ function createMergeRuntime(deps) {
4188
5487
  }
4189
5488
  const currentRevision = sourceRevisionOf(cards, candidate.a, candidate.b);
4190
5489
  if (candidate.sourceRevision !== void 0 && currentRevision !== void 0 && currentRevision !== candidate.sourceRevision) {
4191
- const changed = transition(candidate, {
4192
- type: "source-changed",
4193
- at: (/* @__PURE__ */ new Date()).toISOString()
4194
- });
4195
- 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
+ };
4196
5500
  return {
4197
5501
  ok: false,
4198
5502
  text: "source-changed:源技能内容在决策后变化,拒绝照跑;出口:重新 merge pair,或 merge skipped --discard"
@@ -4200,22 +5504,47 @@ function createMergeRuntime(deps) {
4200
5504
  }
4201
5505
  const currentTargetRevision = targetRevisionOf(cards, candidate.decision.target);
4202
5506
  if (candidate.targetRevision !== void 0 && currentTargetRevision !== void 0 && currentTargetRevision !== candidate.targetRevision) {
4203
- const taken = transition(candidate, {
4204
- type: "target-taken",
4205
- at: (/* @__PURE__ */ new Date()).toISOString()
4206
- });
4207
- 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
+ };
4208
5517
  return {
4209
5518
  ok: false,
4210
5519
  text: "target-taken:目标 " + candidate.decision.target + " 在决策后变化,拒绝照跑;可重新 merge pair 或 discard"
4211
5520
  };
4212
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
+ }
4213
5537
  if (candidate.decision.mode === "merge-into-existing" && !cards.some((card) => card.name === candidate.decision?.target)) {
4214
- const expired = transition(candidate, {
4215
- type: "target-taken",
4216
- at: (/* @__PURE__ */ new Date()).toISOString()
4217
- });
4218
- 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
+ };
4219
5548
  return {
4220
5549
  ok: false,
4221
5550
  text: "target-taken:目标 " + candidate.decision.target + " 已不存在,行保留 pending --expired;出口:重新 merge pair,或 merge skipped --discard"
@@ -4225,6 +5554,8 @@ function createMergeRuntime(deps) {
4225
5554
  const result = await runApproveCommand({
4226
5555
  ...common,
4227
5556
  rowId: command.id,
5557
+ ...detached ? { detached: true } : {},
5558
+ pairTimeoutMinutes: deps.params.mergePairTimeoutMinutes,
4228
5559
  ports: { round2: createNewChildRound2({
4229
5560
  io: deps.io,
4230
5561
  home: deps.home,
@@ -4235,7 +5566,9 @@ function createMergeRuntime(deps) {
4235
5566
  ...deps.cwd === void 0 ? {} : { cwd: deps.cwd },
4236
5567
  timeoutMs: deps.params.mergePairTimeoutMinutes * 6e4,
4237
5568
  commitPort: skillLibraryPort(deps.skills),
4238
- now: deps.now
5569
+ now: deps.now,
5570
+ ...unitFor(deps) === void 0 ? {} : { unit: unitFor(deps) },
5571
+ ...deps.isBlocked === void 0 ? {} : { isBlocked: deps.isBlocked }
4239
5572
  }) }
4240
5573
  });
4241
5574
  if (!result.ok) return {
@@ -4256,7 +5589,9 @@ function createMergeRuntime(deps) {
4256
5589
  };
4257
5590
  }
4258
5591
  const cards = command.verb === "pending" ? await deps.cards(new AbortController().signal) : void 0;
4259
- 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);
4260
5595
  if (text !== null) return {
4261
5596
  ok: true,
4262
5597
  text
@@ -4635,6 +5970,143 @@ async function supersedeMergeRuns(registry) {
4635
5970
  return ids.length;
4636
5971
  }
4637
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
4638
6110
  //#region lib/types/pair-command.js
4639
6111
  async function runPairCommand(options) {
4640
6112
  return await runScanCommand({
@@ -4664,14 +6136,10 @@ const Config = z.object({
4664
6136
  mergePairTimeoutMinutes: z.number().min(1).default(15),
4665
6137
  mergeModelsRoot: z.string().default("")
4666
6138
  });
4667
- function engineConfigFromEnv(configuredRoot) {
4668
- const root = configuredRoot !== void 0 && configuredRoot !== "" ? configuredRoot : process.env.SKILL_MERGE_MODELS_ROOT;
4669
- if (root === void 0 || root === "") return void 0;
6139
+ function engineConfigFromRoot(configuredRoot) {
6140
+ const root = configuredRoot?.trim() ?? "";
6141
+ if (root === "") return void 0;
4670
6142
  return {
4671
- models_dir: "",
4672
- offline: true,
4673
- auto_provision: false,
4674
- embed_backend: "local",
4675
6143
  embedder_model: join(root, "potion-multilingual-128M-int8"),
4676
6144
  embedder_model_last: join(root, "potion-multilingual-128M-pca64-int8")
4677
6145
  };
@@ -4715,7 +6183,6 @@ function apply(ctx, rawConfig = {}) {
4715
6183
  ctx.logger.warn(message);
4716
6184
  }
4717
6185
  });
4718
- runs.load();
4719
6186
  ctx.effect(() => {
4720
6187
  const host = ctx;
4721
6188
  if (typeof host.on !== "function") return () => {};
@@ -4733,20 +6200,17 @@ function apply(ctx, rawConfig = {}) {
4733
6200
  ...policyStageLimits(policySnapshot?.get?.())
4734
6201
  }
4735
6202
  });
4736
- const mergeSkillsRoot = skills.root ?? skillsRoot();
4737
- recoverCommitJournals(io, home, Date.now(), mergeSkillsRoot, async (name) => {
4738
- return (await skills.restoreFromArchive(name)).ok;
4739
- }, (runId) => {
6203
+ runs.load().then(() => recoverCommitJournals(io, home, Date.now(), (runId) => {
4740
6204
  const run = runs.find(runId);
4741
6205
  return run !== void 0 && run.state === "running";
4742
- }).then((reports) => {
6206
+ })).then((reports) => {
4743
6207
  if (reports.length > 0) ctx.logger.info("merge commit recovery: " + reports.map((report) => report.runId + "=" + report.action).join(", "));
4744
6208
  }).catch((error) => {
4745
6209
  ctx.logger.warn("merge commit recovery failed: " + (error instanceof Error ? error.message : String(error)));
4746
6210
  });
4747
6211
  const channel = subagentChannelAdapter(createSubagentChannel(ctx));
4748
6212
  const stage = new StageToolRegistry();
4749
- const engineConfig = engineConfigFromEnv(params.mergeModelsRoot);
6213
+ const engineConfig = engineConfigFromRoot(params.mergeModelsRoot);
4750
6214
  const llm = ctx.get("llm");
4751
6215
  const defaultModel = ctx.get("agentDefaultModel");
4752
6216
  const runner = llm === void 0 || defaultModel === void 0 ? void 0 : createEngineRunner(llm, () => defaultModel.currentSelection());
@@ -4756,7 +6220,7 @@ function apply(ctx, rawConfig = {}) {
4756
6220
  engine: { screen },
4757
6221
  select: selectWithErrors,
4758
6222
  cfg: {
4759
- ...engineConfig ?? {},
6223
+ ...engineConfig,
4760
6224
  judge: "deepseek"
4761
6225
  },
4762
6226
  ...runner === void 0 ? {} : { runner },
@@ -4765,14 +6229,20 @@ function apply(ctx, rawConfig = {}) {
4765
6229
  const at = (/* @__PURE__ */ new Date()).toISOString();
4766
6230
  for (const skipped of selection.skipped) {
4767
6231
  if (loaded.state.rows.some((row) => row.pairKey === skipped.pairKey)) continue;
4768
- const next = transition(void 0, {
4769
- 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,
4770
6236
  a: skipped.a,
4771
6237
  b: skipped.b,
4772
- ...skipped.reason === void 0 ? {} : { reason: skipped.reason },
4773
- at
4774
- });
4775
- 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);
4776
6246
  }
4777
6247
  }
4778
6248
  });
@@ -4903,4 +6373,4 @@ function apply(ctx, rawConfig = {}) {
4903
6373
  });
4904
6374
  }
4905
6375
  //#endregion
4906
- 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 };