cflx 0.6.327

Conflux – a spec-driven parallel coding orchestrator that runs AI agents on git worktrees
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
//! Repository-derived classification of sequential resolve batch state.
//!
//! Sequential integration has two mutation locations — each change worktree and
//! the target repository — and both are owned by the resolve agent. This module
//! owns the other half: deciding, purely from Git evidence, what still has to
//! happen and whether the batch is truthfully complete.
//!
//! Everything here is side-effect free. No function commits, stages, or edits
//! anything; the worst outcome of a misclassification is a withheld action, not
//! a wrong mutation. Process-local workspace membership and
//! `workspace.base_revision` are never consulted: authority comes from the
//! ordered batch items supplied at merge admission plus worktree metadata,
//! refs, commit parentage, index stages, committed trees, and cleanliness.

use crate::archive_layout;
use crate::vcs::git::commands::{self as git_commands, CommitDiffEntry, WorktreeIdentity};
use async_trait::async_trait;
use std::path::{Path, PathBuf};

#[cfg(test)]
pub(crate) mod fixtures;
#[cfg(test)]
mod tests;

/// Exact subject of a per-change final integration merge commit.
pub fn final_merge_subject(change_id: &str) -> String {
    format!("Merge change: {}", change_id)
}

/// Exact subject of a worktree pre-sync merge commit.
pub fn presync_subject(change_id: &str) -> String {
    format!("Pre-sync base into {}", change_id)
}

/// Exact subject of the forward post-final resurrection cleanup commit.
pub fn cleanup_subject(change_id: &str) -> String {
    format!("Cleanup resurrected change: {}", change_id)
}

/// Result of a single evidence read.
pub type EvidenceResult<T> = std::result::Result<T, String>;

/// Read-only repository evidence required to classify a sequential resolve batch.
///
/// Split out as a trait so the decision table can be exercised against
/// in-memory doubles as well as a real repository: the classifier holds all of
/// the policy, the adapter holds all of the Git access.
#[async_trait]
pub trait ResolveEvidence: Send + Sync {
    /// Validate a supplied archive worktree path against Git worktree metadata.
    async fn validate_worktree(
        &self,
        supplied_path: &Path,
        expected_branch: &str,
    ) -> WorktreeIdentity;

    /// Whether the given worktree has an unfinished merge.
    async fn worktree_merge_in_progress(&self, worktree: &Path) -> EvidenceResult<bool>;

    /// Unresolved conflict paths inside the given worktree.
    async fn worktree_conflicts(&self, worktree: &Path) -> EvidenceResult<Vec<String>>;

    /// Current target repository `HEAD` commit.
    async fn target_head(&self) -> EvidenceResult<String>;

    /// Target repository `MERGE_HEAD`, when a merge is in progress.
    async fn target_merge_head(&self) -> EvidenceResult<Option<String>>;

    /// Unmerged (stage 1/2/3) target index paths.
    async fn target_conflict_paths(&self) -> EvidenceResult<Vec<String>>;

    /// Whether the target index and worktree match `HEAD`, untracked files included.
    async fn target_is_clean(&self) -> EvidenceResult<bool>;

    /// Stage-0 target index paths under `prefix`.
    async fn target_index_paths(&self, prefix: &str) -> EvidenceResult<Vec<String>>;

    /// Complete ordered parent list of a commit.
    async fn parents_of(&self, commit: &str) -> EvidenceResult<Vec<String>>;

    /// Whether `ancestor` is reachable from `descendant`.
    async fn is_ancestor(&self, ancestor: &str, descendant: &str) -> EvidenceResult<bool>;

    /// First-parent lineage of `tip`, newest first.
    async fn first_parent_lineage(&self, tip: &str) -> EvidenceResult<Vec<String>>;

    /// Merge base of two revisions.
    async fn merge_base(&self, a: &str, b: &str) -> EvidenceResult<Option<String>>;

    /// Commits in `from..to` whose complete subject equals `subject`.
    async fn commits_with_exact_subject(
        &self,
        from: Option<&str>,
        to: &str,
        subject: &str,
    ) -> EvidenceResult<Vec<String>>;

    /// Complete tree diff of a commit against its first parent.
    async fn commit_diff_entries(&self, commit: &str) -> EvidenceResult<Vec<CommitDiffEntry>>;

    /// Committed tree paths under `prefix` at `revision`.
    async fn committed_tree_paths(
        &self,
        revision: &str,
        prefix: &str,
    ) -> EvidenceResult<Vec<String>>;

    /// Committed text of `path` at `revision`, or `None` when it is unreadable.
    async fn committed_file_text(
        &self,
        revision: &str,
        path: &str,
    ) -> EvidenceResult<Option<String>>;
}

/// Closed classification of the batch, ordered by the action it requires.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BatchState {
    /// Identity or topology could not be proven; no guidance and no mutation.
    UnsafeEvidence {
        /// Why the evidence is not trustworthy.
        reason: String,
    },
    /// A worktree still holds an unfinished pre-sync merge or conflicts.
    PreSyncUnfinished {
        /// Change awaiting the finished pre-sync.
        change_id: String,
        /// Validated worktree path.
        worktree: PathBuf,
        /// Concrete unfinished-state detail.
        reason: String,
    },
    /// Pre-sync evidence is missing, duplicated, or has the wrong topology.
    PreSyncInvalid {
        /// Change whose pre-sync is not provable.
        change_id: String,
        /// Validated worktree path.
        worktree: PathBuf,
        /// Required target state the pre-sync must contain.
        required_target_state: String,
        /// Concrete topology detail.
        reason: String,
    },
    /// An identity-verified final merge is in progress and must be committed.
    TargetMergeUnfinished {
        /// Change owning the in-progress target merge.
        change_id: String,
        /// Validated worktree path.
        worktree: PathBuf,
        /// Whether the merged index resurrected the live change directory.
        requires_live_removal: bool,
        /// Concrete state detail.
        reason: String,
    },
    /// Repository-visible task evidence does not authorize a final merge.
    ///
    /// Deliberately distinct from [`BatchState::UnsafeEvidence`]: topology is
    /// fine, the change is simply not finished. It carries no command, is not
    /// agent-actionable, and leaves both the base and the change worktree
    /// exactly as they were.
    MergeNotAuthorized {
        /// Change whose final merge is withheld.
        change_id: String,
        /// Concrete task-evidence or refusal detail.
        reason: String,
    },
    /// The item is next in order and its final merge has not started.
    FinalMergeMissing {
        /// Change awaiting final integration.
        change_id: String,
        /// Validated worktree path.
        worktree: PathBuf,
        /// Required target state the merge's first parent must be.
        required_target_state: String,
    },
    /// Committed integration retains both active and archived identities.
    ResurrectionCleanupRequired {
        /// Change whose live directory was resurrected.
        change_id: String,
        /// Concrete evidence detail.
        reason: String,
    },
    /// Every item is integrated and the target repository is clean.
    Complete,
}

impl BatchState {
    /// Stable machine-readable phase name.
    pub fn phase(&self) -> &'static str {
        match self {
            Self::UnsafeEvidence { .. } => "unsafe_evidence",
            Self::PreSyncUnfinished { .. } => "presync_unfinished",
            Self::PreSyncInvalid { .. } => "presync_invalid",
            Self::TargetMergeUnfinished { .. } => "target_merge_unfinished",
            Self::MergeNotAuthorized { .. } => "merge_not_authorized",
            Self::FinalMergeMissing { .. } => "final_merge_missing",
            Self::ResurrectionCleanupRequired { .. } => "resurrection_cleanup_required",
            Self::Complete => "complete",
        }
    }

    /// Whether the batch is truthfully complete.
    pub fn is_complete(&self) -> bool {
        matches!(self, Self::Complete)
    }

    /// Whether continuation may ask the agent to mutate the repository.
    ///
    /// `UnsafeEvidence` deliberately answers `false`: unproven identity must
    /// preserve state rather than trigger a blind commit. `MergeNotAuthorized`
    /// answers `false` for the mirror-image reason: the evidence is trustworthy
    /// and says the change is not finished, so handing it to another agent
    /// would only re-issue the instruction that must not run.
    pub fn allows_agent_action(&self) -> bool {
        !matches!(
            self,
            Self::UnsafeEvidence { .. } | Self::MergeNotAuthorized { .. } | Self::Complete
        )
    }

    /// Structured, bounded phase diagnosis for resolve continuation context.
    ///
    /// Every field is bounded here, before assembly, so the fixed
    /// `<resolve_context>` wrapper plus the newest diagnosis alone can never
    /// exceed the resolve context byte budget.
    pub fn diagnosis(&self) -> String {
        /// Byte cap for one assembled diagnosis field.
        const FIELD_MAX_BYTES: usize = 384;

        let mut lines = vec![format!("phase: {}", self.phase())];
        match self {
            Self::UnsafeEvidence { reason } => {
                lines.push(format!("detail: {}", reason));
                lines.push(
                    "required_action: none; preserve repository state and do not commit"
                        .to_string(),
                );
            }
            Self::PreSyncUnfinished {
                change_id,
                worktree,
                reason,
            } => {
                lines.push(format!("change_id: {}", change_id));
                lines.push(format!("worktree: {}", worktree.display()));
                lines.push(format!("detail: {}", reason));
                lines.push(format!(
                    "required_action: finish the in-progress worktree merge and commit it with subject '{}'",
                    presync_subject(change_id)
                ));
            }
            Self::PreSyncInvalid {
                change_id,
                worktree,
                required_target_state,
                reason,
            } => {
                lines.push(format!("change_id: {}", change_id));
                lines.push(format!("worktree: {}", worktree.display()));
                lines.push(format!("required_target_state: {}", required_target_state));
                lines.push(format!("detail: {}", reason));
                lines.push(format!(
                    "required_action: in the worktree run 'git merge --no-ff -m \"{}\" {}' so the pre-sync commit has exactly two parents with non-first parent {}",
                    presync_subject(change_id),
                    required_target_state,
                    required_target_state
                ));
            }
            Self::TargetMergeUnfinished {
                change_id,
                worktree,
                requires_live_removal,
                reason,
            } => {
                lines.push(format!("change_id: {}", change_id));
                lines.push(format!("worktree: {}", worktree.display()));
                lines.push(format!("requires_live_removal: {}", requires_live_removal));
                lines.push(format!("detail: {}", reason));
                if *requires_live_removal {
                    lines.push(format!(
                        "required_action: run 'git rm -r --cached -f openspec/changes/{}' and remove the directory, then commit the in-progress merge with subject '{}'",
                        change_id,
                        final_merge_subject(change_id)
                    ));
                } else {
                    lines.push(format!(
                        "required_action: commit the in-progress target merge with subject '{}'",
                        final_merge_subject(change_id)
                    ));
                }
            }
            Self::MergeNotAuthorized { change_id, reason } => {
                lines.push(format!("change_id: {}", change_id));
                lines.push(format!("detail: {}", reason));
                // No command, no commit subject, no branch: an unauthorized
                // merge must not be describable as a next step, or the next
                // attempt simply performs it.
                lines.push(
                    "required_action: none; the change is not proven complete, so do not integrate it and do not mutate any repository state; operator action is required"
                        .to_string(),
                );
            }
            Self::FinalMergeMissing {
                change_id,
                worktree,
                required_target_state,
            } => {
                lines.push(format!("change_id: {}", change_id));
                lines.push(format!("worktree: {}", worktree.display()));
                lines.push(format!("required_target_state: {}", required_target_state));
                lines.push(format!(
                    "required_action: in the repo root run 'git merge --no-ff --no-commit <branch>' and commit with subject '{}'",
                    final_merge_subject(change_id)
                ));
            }
            Self::ResurrectionCleanupRequired { change_id, reason } => {
                lines.push(format!("change_id: {}", change_id));
                lines.push(format!("detail: {}", reason));
                lines.push(format!(
                    "required_action: remove only openspec/changes/{} and record a forward commit with subject '{}'; never amend or rewrite history",
                    change_id,
                    cleanup_subject(change_id)
                ));
            }
            Self::Complete => {
                lines.push(
                    "detail: every batch item is integrated and the target is clean".to_string(),
                );
                lines.push("required_action: none".to_string());
            }
        }
        lines
            .into_iter()
            .map(|line| crate::history::bounded_head(&line, FIELD_MAX_BYTES))
            .collect::<Vec<_>>()
            .join("\n")
    }
}

pub use super::merge::SequentialMergeItem;

/// A batch item whose worktree identity has been proven.
#[derive(Debug, Clone)]
pub struct ValidatedItem {
    /// Change identifier.
    pub change_id: String,
    /// Workspace branch name.
    pub revision: String,
    /// Validated (possibly rediscovered) worktree path.
    pub worktree: PathBuf,
    /// Branch tip commit.
    pub tip: String,
    /// Branch base recorded at admission, when available.
    pub branch_base: Option<String>,
}

/// Per-item integration evidence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ItemIntegration {
    /// A unique exact final merge commit with proven topology.
    Exact {
        /// The final merge commit.
        commit: String,
        /// Required target state proven by the commit's first parent.
        target_state: String,
    },
    /// No exact candidate exists and the branch tip is already ancestral.
    Historical,
    /// The item still needs final integration.
    NotIntegrated,
}

fn unsafe_evidence(context: &str, error: String) -> BatchState {
    BatchState::UnsafeEvidence {
        reason: format!("{}: {}", context, error),
    }
}

/// Repository-visible task completion of one change, as committed on its branch.
///
/// The three answers are deliberately not two: "cannot be established" is not
/// the same fact as "incomplete", but both withhold the merge. Collapsing them
/// would either hide a broken task list behind a progress count or report a
/// genuinely unfinished change as unreadable.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskCompletion {
    /// Every recorded task in every task list found is complete.
    Complete {
        /// Task lists that proved it, repository-relative.
        sources: Vec<String>,
        /// Total tasks counted across those lists.
        total: u32,
    },
    /// At least one task list records unfinished work.
    Incomplete {
        /// The task list carrying the unfinished work.
        source: String,
        /// Completed tasks in that list.
        completed: u32,
        /// Total tasks in that list.
        total: u32,
    },
    /// Completion could not be established from committed evidence.
    Unestablished {
        /// Why the evidence does not prove completion.
        reason: String,
    },
}

impl TaskCompletion {
    /// One-line evidence detail for the withheld-merge diagnosis.
    fn detail(&self, change_id: &str) -> Option<String> {
        match self {
            Self::Complete { .. } => None,
            Self::Incomplete {
                source,
                completed,
                total,
            } => Some(format!(
                "Change '{}' records {}/{} tasks complete in {}; an unfinished change is never integrated",
                change_id, completed, total, source
            )),
            Self::Unestablished { reason } => Some(format!(
                "Change '{}' has no task evidence that proves completion: {}",
                change_id, reason
            )),
        }
    }
}

/// Repository-relative task lists that can speak for `change_id` at a revision.
///
/// Both identities are accepted because a sequential batch item is read at the
/// point where it may hold either: the active list before archiving, and the
/// archived list after. Anything else under `openspec/changes` — another
/// change, a nested archive layout, a suffix collision — is not this change's
/// evidence and is ignored rather than silently believed.
/// Task evidence for one change at a revision, with the format it is read in.
///
/// A change speaks in exactly one format, so mixed evidence is refused rather
/// than resolved by precedence.
enum TaskEvidence {
    /// One coherent format with at least one path.
    Coherent {
        format: crate::task_file::TaskFileFormat,
        paths: Vec<String>,
    },
    /// No task artifact for this change at this revision.
    Absent,
    /// Two supported basenames speak for the same change at one revision.
    Ambiguous { paths: Vec<String> },
}

fn task_evidence_paths(tree_paths: &[String], change_id: &str) -> TaskEvidence {
    let mut found: Vec<(crate::task_file::TaskFileFormat, String)> = tree_paths
        .iter()
        .filter_map(|path| {
            archive_layout::active_change_tasks_format(path, change_id)
                .or_else(|| archive_layout::archive_tasks_format(path, change_id))
                .map(|format| (format, path.clone()))
        })
        .collect();
    found.sort_by(|left, right| left.1.cmp(&right.1));
    found.dedup_by(|left, right| left.1 == right.1);

    let Some(format) = found.first().map(|(format, _)| *format) else {
        return TaskEvidence::Absent;
    };
    if found.iter().any(|(candidate, _)| *candidate != format) {
        return TaskEvidence::Ambiguous {
            paths: found.into_iter().map(|(_, path)| path).collect(),
        };
    }
    TaskEvidence::Coherent {
        format,
        paths: found.into_iter().map(|(_, path)| path).collect(),
    }
}

/// Read committed task completion for `change_id` at `revision`.
///
/// Fails closed at every step: no task list, an unreadable one, a list with no
/// recorded tasks at all, or an evidence error all answer
/// [`TaskCompletion::Unestablished`]. When several lists exist they must all be
/// complete, because the merge integrates all of them at once.
pub async fn read_task_completion(
    evidence: &dyn ResolveEvidence,
    change_id: &str,
    revision: &str,
    tree_paths: &[String],
) -> TaskCompletion {
    let (format, paths) = match task_evidence_paths(tree_paths, change_id) {
        TaskEvidence::Coherent { format, paths } => (format, paths),
        TaskEvidence::Absent => {
            return TaskCompletion::Unestablished {
                reason: format!(
                    "no active or archived task artifact ({}) exists for '{}' at {}",
                    archive_layout::active_change_tasks_paths(change_id)
                        .into_iter()
                        .map(|(_, path)| path)
                        .collect::<Vec<_>>()
                        .join(" or "),
                    change_id,
                    revision
                ),
            }
        }
        TaskEvidence::Ambiguous { paths } => {
            return TaskCompletion::Unestablished {
                reason: format!(
                    "task evidence for '{}' at {} mixes task-file formats ({}); a change speaks in exactly one",
                    change_id,
                    revision,
                    paths.join(", ")
                ),
            }
        }
    };

    let mut total = 0u32;
    for path in &paths {
        let content = match evidence.committed_file_text(revision, path).await {
            Ok(Some(content)) => content,
            Ok(None) => {
                return TaskCompletion::Unestablished {
                    reason: format!("{} is not readable at {}", path, revision),
                }
            }
            Err(error) => {
                return TaskCompletion::Unestablished {
                    reason: format!("failed to read {} at {}: {}", path, revision, error),
                }
            }
        };
        let progress = match crate::task_file::parse_progress(format, &content, None) {
            Ok(progress) => progress,
            Err(error) => {
                return TaskCompletion::Unestablished {
                    reason: format!(
                        "{} is not valid task evidence at {}: {}",
                        path, revision, error
                    ),
                }
            }
        };
        if progress.total == 0 {
            return TaskCompletion::Unestablished {
                reason: format!("{} records no tasks at all", path),
            };
        }
        if progress.completed < progress.total {
            return TaskCompletion::Incomplete {
                source: path.clone(),
                completed: progress.completed,
                total: progress.total,
            };
        }
        total = total.saturating_add(progress.total);
    }

    TaskCompletion::Complete {
        sources: paths,
        total,
    }
}

/// Ephemeral, process-local record of final merges this run has refused.
///
/// Constitutionally this is in-memory state only: it is never persisted, never
/// consulted across processes, and disappears on restart, after which
/// authorization is recomputed from the workspace alone. Its one job is to be
/// *monotonic* — a refusal recorded during a batch can be observed again but
/// never cleared, so a later attempt inside the same batch cannot talk the
/// classifier back into emitting the merge instruction it already withheld. It
/// authorizes nothing: an empty latch means "no refusal seen", not "approved".
#[derive(Debug, Default)]
pub struct MergeAuthorizationLatch {
    refusals: std::sync::Mutex<std::collections::BTreeMap<String, String>>,
}

impl MergeAuthorizationLatch {
    /// An empty latch, refusing nothing.
    pub fn new() -> Self {
        Self::default()
    }

    fn guard(&self) -> std::sync::MutexGuard<'_, std::collections::BTreeMap<String, String>> {
        // A poisoned latch still holds real refusals; discarding them would
        // re-authorize a merge that was already refused.
        self.refusals
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
    }

    /// Record a refusal for `change_id`, keeping the first reason seen.
    pub fn refuse(&self, change_id: &str, reason: &str) {
        self.guard()
            .entry(change_id.to_string())
            .or_insert_with(|| reason.to_string());
    }

    /// The latched refusal reason for `change_id`, when one exists.
    pub fn refusal(&self, change_id: &str) -> Option<String> {
        self.guard().get(change_id).cloned()
    }

    /// Number of distinct changes this latch has refused.
    ///
    /// Observation only; production code decides from [`Self::refusal`].
    #[allow(dead_code)]
    pub fn refused_count(&self) -> usize {
        self.guard().len()
    }
}

/// Validate every batch item's worktree identity, in declared order.
pub async fn validate_items(
    evidence: &dyn ResolveEvidence,
    items: &[SequentialMergeItem],
) -> std::result::Result<Vec<ValidatedItem>, BatchState> {
    let mut validated = Vec::with_capacity(items.len());
    for item in items {
        match evidence
            .validate_worktree(&item.archive_path, &item.revision)
            .await
        {
            WorktreeIdentity::Supplied { path, tip }
            | WorktreeIdentity::Rediscovered { path, tip } => {
                validated.push(ValidatedItem {
                    change_id: item.change_id.clone(),
                    revision: item.revision.clone(),
                    worktree: path,
                    tip,
                    branch_base: item.branch_base.clone(),
                });
            }
            WorktreeIdentity::Unsafe { reason } => {
                return Err(BatchState::UnsafeEvidence { reason });
            }
        }
    }
    Ok(validated)
}

/// Classify one item's committed integration evidence.
///
/// This is the single final-integration policy shared by resolve retry
/// continuation and terminal merge verification, so the two can never diverge.
pub async fn classify_item_integration(
    evidence: &dyn ResolveEvidence,
    item: &ValidatedItem,
    base_revision: &str,
    target_head: &str,
) -> std::result::Result<ItemIntegration, BatchState> {
    let subject = final_merge_subject(&item.change_id);
    let candidates = evidence
        .commits_with_exact_subject(Some(base_revision), target_head, &subject)
        .await
        .map_err(|error| unsafe_evidence("Failed to enumerate final merge candidates", error))?;

    match candidates.len() {
        0 => {
            let integrated =
                evidence
                    .is_ancestor(&item.tip, target_head)
                    .await
                    .map_err(|error| {
                        unsafe_evidence("Failed to check historical integration", error)
                    })?;
            if integrated {
                Ok(ItemIntegration::Historical)
            } else {
                Ok(ItemIntegration::NotIntegrated)
            }
        }
        1 => {
            let commit = candidates[0].clone();
            let parents = evidence
                .parents_of(&commit)
                .await
                .map_err(|error| unsafe_evidence("Failed to read merge parents", error))?;
            if parents.len() != 2 {
                return Err(BatchState::UnsafeEvidence {
                    reason: format!(
                        "Final merge commit {} for '{}' has {} parent(s); exactly two are required",
                        commit,
                        item.change_id,
                        parents.len()
                    ),
                });
            }
            if parents[1] != item.tip {
                return Err(BatchState::UnsafeEvidence {
                    reason: format!(
                        "Final merge commit {} for '{}' has non-first parent {} but branch '{}' tip is {}",
                        commit, item.change_id, parents[1], item.revision, item.tip
                    ),
                });
            }
            let contained = evidence
                .is_ancestor(&commit, target_head)
                .await
                .map_err(|error| unsafe_evidence("Failed to check merge containment", error))?;
            if !contained {
                return Err(BatchState::UnsafeEvidence {
                    reason: format!(
                        "Final merge commit {} for '{}' is not contained by target HEAD",
                        commit, item.change_id
                    ),
                });
            }
            Ok(ItemIntegration::Exact {
                commit,
                target_state: parents[0].clone(),
            })
        }
        count => Err(BatchState::UnsafeEvidence {
            reason: format!(
                "Found {} commits with subject '{}'; exactly one is required",
                count, subject
            ),
        }),
    }
}

/// Validate pre-sync topology of one item against required target state `T`.
///
/// Returns `Ok(None)` when pre-sync is provable.
async fn validate_presync(
    evidence: &dyn ResolveEvidence,
    item: &ValidatedItem,
    required_target_state: &str,
) -> std::result::Result<Option<BatchState>, BatchState> {
    let lineage = evidence
        .first_parent_lineage(&item.tip)
        .await
        .map_err(|error| unsafe_evidence("Failed to read worktree first-parent lineage", error))?;
    if lineage.iter().any(|commit| commit == required_target_state) {
        return Ok(None);
    }

    let subject = presync_subject(&item.change_id);
    let from = match &item.branch_base {
        Some(base) => Some(base.clone()),
        None => evidence
            .merge_base(required_target_state, &item.tip)
            .await
            .map_err(|error| unsafe_evidence("Failed to compute pre-sync search base", error))?,
    };
    let candidates = evidence
        .commits_with_exact_subject(from.as_deref(), &item.tip, &subject)
        .await
        .map_err(|error| unsafe_evidence("Failed to enumerate pre-sync candidates", error))?;

    let invalid = |reason: String| {
        Ok(Some(BatchState::PreSyncInvalid {
            change_id: item.change_id.clone(),
            worktree: item.worktree.clone(),
            required_target_state: required_target_state.to_string(),
            reason,
        }))
    };

    match candidates.len() {
        0 => invalid(format!(
            "Required target state {} is not on branch '{}' first-parent lineage and no '{}' commit exists",
            required_target_state, item.revision, subject
        )),
        1 => {
            let commit = &candidates[0];
            let parents = evidence
                .parents_of(commit)
                .await
                .map_err(|error| unsafe_evidence("Failed to read pre-sync parents", error))?;
            if parents.len() != 2 {
                return invalid(format!(
                    "Pre-sync commit {} has {} parent(s); exactly two are required",
                    commit,
                    parents.len()
                ));
            }
            if parents[1] != required_target_state {
                return invalid(format!(
                    "Pre-sync commit {} has non-first parent {} but required target state is {}",
                    commit, parents[1], required_target_state
                ));
            }
            let contained = evidence
                .is_ancestor(commit, &item.tip)
                .await
                .map_err(|error| unsafe_evidence("Failed to check pre-sync containment", error))?;
            if !contained {
                return invalid(format!(
                    "Pre-sync commit {} is not contained by branch '{}' tip {}",
                    commit, item.revision, item.tip
                ));
            }
            Ok(None)
        }
        count => invalid(format!(
            "Found {} commits with subject '{}'; exactly one is required",
            count, subject
        )),
    }
}

/// Validate any existing forward cleanup commit for the change.
///
/// Reachability from target `HEAD` is not enough: a cleanup commit authored on a
/// side branch and merged in later is reachable while the target's own history
/// never moved forward past the resurrection. The commit must therefore sit on
/// the target first-parent lineage and its single parent must be the target
/// state that actually held the live/archive coexistence, at or after this
/// item's committed integration.
async fn validate_cleanup_commits(
    evidence: &dyn ResolveEvidence,
    item: &ValidatedItem,
    integration: &ItemIntegration,
    base_revision: &str,
    target_head: &str,
    target_lineage: &[String],
) -> std::result::Result<(), BatchState> {
    let change_id = item.change_id.as_str();
    let subject = cleanup_subject(change_id);
    let candidates = evidence
        .commits_with_exact_subject(Some(base_revision), target_head, &subject)
        .await
        .map_err(|error| unsafe_evidence("Failed to enumerate cleanup candidates", error))?;

    let commit = match candidates.as_slice() {
        [] => return Ok(()),
        [commit] => commit.clone(),
        _ => {
            return Err(BatchState::UnsafeEvidence {
                reason: format!(
                    "Found {} commits with subject '{}'; exactly one is allowed",
                    candidates.len(),
                    subject
                ),
            })
        }
    };

    if !target_lineage.iter().any(|candidate| candidate == &commit) {
        return Err(BatchState::UnsafeEvidence {
            reason: format!(
                "Cleanup commit {} for '{}' is not on target HEAD {} first-parent lineage; only forward cleanup on the target is accepted",
                commit, change_id, target_head
            ),
        });
    }

    let parents = evidence
        .parents_of(&commit)
        .await
        .map_err(|error| unsafe_evidence("Failed to read cleanup commit parents", error))?;
    if parents.len() != 1 {
        return Err(BatchState::UnsafeEvidence {
            reason: format!(
                "Cleanup commit {} has {} parent(s); a forward cleanup commit has exactly one",
                commit,
                parents.len()
            ),
        });
    }
    let predecessor = parents[0].clone();

    let entries = evidence
        .commit_diff_entries(&commit)
        .await
        .map_err(|error| unsafe_evidence("Failed to read cleanup commit diff", error))?;
    if entries.is_empty() {
        return Err(BatchState::UnsafeEvidence {
            reason: format!("Cleanup commit {} changes nothing", commit),
        });
    }
    for entry in &entries {
        if entry.status != 'D' || !archive_layout::is_active_change_path(&entry.path, change_id) {
            return Err(BatchState::UnsafeEvidence {
                reason: format!(
                    "Cleanup commit {} has entry '{}{}'; it may only delete openspec/changes/{}",
                    commit, entry.status, entry.path, change_id
                ),
            });
        }
    }

    // Bind the predecessor to this item's committed integration state.
    let integrated_at = match integration {
        ItemIntegration::Exact { commit, .. } => commit.clone(),
        ItemIntegration::Historical => item.tip.clone(),
        ItemIntegration::NotIntegrated => {
            return Err(BatchState::UnsafeEvidence {
                reason: format!(
                    "Cleanup commit {} exists for '{}' but the change has no committed integration",
                    commit, change_id
                ),
            })
        }
    };
    let after_integration = evidence
        .is_ancestor(&integrated_at, &predecessor)
        .await
        .map_err(|error| unsafe_evidence("Failed to order cleanup against integration", error))?;
    if !after_integration {
        return Err(BatchState::UnsafeEvidence {
            reason: format!(
                "Cleanup commit {} for '{}' has predecessor {} which does not contain its committed integration {}",
                commit, change_id, predecessor, integrated_at
            ),
        });
    }

    let predecessor_paths = evidence
        .committed_tree_paths(&predecessor, archive_layout::ACTIVE_CHANGES_PREFIX)
        .await
        .map_err(|error| unsafe_evidence("Failed to read cleanup predecessor tree", error))?;
    if !archive_layout::paths_contain_active_change(&predecessor_paths, change_id)
        || !archive_layout::paths_contain_valid_archive(&predecessor_paths, change_id)
    {
        return Err(BatchState::UnsafeEvidence {
            reason: format!(
                "Cleanup commit {} for '{}' has predecessor {} which does not hold the live/archive coexistence it claims to remove",
                commit, change_id, predecessor
            ),
        });
    }

    Ok(())
}

/// Classify the complete ordered batch from repository evidence.
///
/// Equivalent to [`classify_batch_with_latch`] with a latch that has refused
/// nothing, which is the correct reading for a one-shot classification.
///
/// Production callers all run inside a batch and carry their own latch, so this
/// convenience form is currently exercised only by the classifier's own tests.
#[allow(dead_code)]
pub async fn classify_batch(
    evidence: &dyn ResolveEvidence,
    items: &[SequentialMergeItem],
    base_revision: &str,
) -> BatchState {
    classify_batch_with_latch(
        evidence,
        items,
        base_revision,
        &MergeAuthorizationLatch::new(),
    )
    .await
}

/// Classify the batch, consulting and extending an in-process refusal latch.
///
/// The latch is only ever read to withhold and written to refuse, so passing a
/// shared one across the attempts of a single batch can turn an authorized
/// merge into a withheld one but never the reverse.
pub async fn classify_batch_with_latch(
    evidence: &dyn ResolveEvidence,
    items: &[SequentialMergeItem],
    base_revision: &str,
    latch: &MergeAuthorizationLatch,
) -> BatchState {
    match classify_batch_inner(evidence, items, base_revision, latch).await {
        Ok(state) | Err(state) => state,
    }
}

async fn classify_batch_inner(
    evidence: &dyn ResolveEvidence,
    items: &[SequentialMergeItem],
    base_revision: &str,
    latch: &MergeAuthorizationLatch,
) -> std::result::Result<BatchState, BatchState> {
    if items.is_empty() {
        return Err(BatchState::UnsafeEvidence {
            reason: "Sequential resolve received an empty batch".to_string(),
        });
    }

    let validated = validate_items(evidence, items).await?;

    let target_head = evidence
        .target_head()
        .await
        .map_err(|error| unsafe_evidence("Failed to read target HEAD", error))?;
    let merge_head = evidence
        .target_merge_head()
        .await
        .map_err(|error| unsafe_evidence("Failed to read target MERGE_HEAD", error))?;

    let mut integrations = Vec::with_capacity(validated.len());
    for item in &validated {
        integrations
            .push(classify_item_integration(evidence, item, base_revision, &target_head).await?);
    }

    // Global target merge ownership is decided before any per-item guidance so
    // an in-progress merge can never be attributed to the wrong change.
    let owner = match merge_head.as_deref() {
        Some(merge_head) => {
            let matched: Vec<usize> = validated
                .iter()
                .enumerate()
                .filter(|(_, item)| item.tip == merge_head)
                .map(|(index, _)| index)
                .collect();
            match matched.as_slice() {
                [index] => Some(*index),
                [] => {
                    return Err(BatchState::UnsafeEvidence {
                        reason: format!(
                            "Target MERGE_HEAD {} does not match any batch branch tip",
                            merge_head
                        ),
                    })
                }
                _ => {
                    return Err(BatchState::UnsafeEvidence {
                        reason: format!(
                            "Target MERGE_HEAD {} matches {} batch branch tips",
                            merge_head,
                            matched.len()
                        ),
                    })
                }
            }
        }
        None => None,
    };

    let first_incomplete = integrations
        .iter()
        .position(|integration| matches!(integration, ItemIntegration::NotIntegrated));

    if let Some(first) = first_incomplete {
        if let Some((offset, _)) = integrations
            .iter()
            .enumerate()
            .skip(first + 1)
            .find(|(_, integration)| !matches!(integration, ItemIntegration::NotIntegrated))
        {
            return Err(BatchState::UnsafeEvidence {
                reason: format!(
                    "Item '{}' is integrated while earlier item '{}' is not; declared order was violated",
                    validated[offset].change_id, validated[first].change_id
                ),
            });
        }
    }

    if let Some(owner) = owner {
        if first_incomplete != Some(owner) {
            return Err(BatchState::UnsafeEvidence {
                reason: format!(
                    "Target MERGE_HEAD owner '{}' is not the first incomplete batch item",
                    validated[owner].change_id
                ),
            });
        }
    }

    // Declared order must be provable from the target's own forward history, not
    // only from the presence of a hole. When every item already has an exact
    // integration commit there is no `NotIntegrated` marker left, so a history
    // that integrated B before A has to be rejected by chaining the exact
    // commits along the target first-parent lineage.
    let target_lineage = evidence
        .first_parent_lineage(&target_head)
        .await
        .map_err(|error| unsafe_evidence("Failed to read target first-parent lineage", error))?;

    let mut previous: Option<(&str, String, usize)> = None;
    for (item, integration) in validated.iter().zip(integrations.iter()) {
        // Historical ancestry-only evidence has no exact commit to place on the
        // lineage, and is exempt from the ordering proof by design.
        let ItemIntegration::Exact {
            commit,
            target_state,
        } = integration
        else {
            continue;
        };
        let Some(position) = target_lineage
            .iter()
            .position(|candidate| candidate == commit)
        else {
            return Err(BatchState::UnsafeEvidence {
                reason: format!(
                    "Final merge commit {} for '{}' is not on target HEAD {} first-parent lineage",
                    commit, item.change_id, target_head
                ),
            });
        };
        if let Some((previous_id, previous_commit, previous_position)) = &previous {
            // The lineage is newest first, so each later declared item must sit
            // strictly closer to target HEAD than the item before it.
            if position >= *previous_position {
                return Err(BatchState::UnsafeEvidence {
                    reason: format!(
                        "Item '{}' was integrated before earlier item '{}' on the target first-parent history; declared order was violated",
                        item.change_id, previous_id
                    ),
                });
            }
            let chained = evidence
                .is_ancestor(previous_commit, target_state)
                .await
                .map_err(|error| {
                    unsafe_evidence("Failed to chain declared-order integrations", error)
                })?;
            if !chained {
                return Err(BatchState::UnsafeEvidence {
                    reason: format!(
                        "Final merge commit {} for '{}' does not build on the committed integration {} of earlier item '{}'; declared order was violated",
                        commit, item.change_id, previous_commit, previous_id
                    ),
                });
            }
        }
        previous = Some((item.change_id.as_str(), commit.clone(), position));
    }

    // Committed items: prove the pre-sync topology their merge claims.
    for (item, integration) in validated.iter().zip(integrations.iter()) {
        match integration {
            ItemIntegration::Exact { target_state, .. } => {
                if let Some(state) = validate_presync(evidence, item, target_state).await? {
                    return Ok(state);
                }
            }
            // Historical ancestry-only integration has no exact final commit
            // from which to reconstruct `T`, so pre-sync reconstruction is
            // exempt; archive/live and clean-target invariants still apply.
            ItemIntegration::Historical => {}
            ItemIntegration::NotIntegrated => break,
        }
    }

    if let Some(index) = first_incomplete {
        let item = &validated[index];

        if evidence
            .worktree_merge_in_progress(&item.worktree)
            .await
            .map_err(|error| unsafe_evidence("Failed to read worktree merge state", error))?
        {
            return Ok(BatchState::PreSyncUnfinished {
                change_id: item.change_id.clone(),
                worktree: item.worktree.clone(),
                reason: "Worktree merge is in progress (MERGE_HEAD exists)".to_string(),
            });
        }

        let worktree_conflicts = evidence
            .worktree_conflicts(&item.worktree)
            .await
            .map_err(|error| unsafe_evidence("Failed to read worktree conflicts", error))?;
        if !worktree_conflicts.is_empty() {
            return Ok(BatchState::PreSyncUnfinished {
                change_id: item.change_id.clone(),
                worktree: item.worktree.clone(),
                reason: format!(
                    "Worktree has unresolved conflicts: {}",
                    worktree_conflicts.join(", ")
                ),
            });
        }

        // Final merge has not been committed, so required target state is the
        // current cumulative target HEAD (every prior item is already complete).
        if let Some(state) = validate_presync(evidence, item, &target_head).await? {
            return Ok(state);
        }

        let target_conflicts = evidence
            .target_conflict_paths()
            .await
            .map_err(|error| unsafe_evidence("Failed to read target index stages", error))?;

        if owner == Some(index) {
            if !target_conflicts.is_empty() {
                return Ok(BatchState::TargetMergeUnfinished {
                    change_id: item.change_id.clone(),
                    worktree: item.worktree.clone(),
                    requires_live_removal: false,
                    reason: format!(
                        "Target index still has conflict stages: {}",
                        target_conflicts.join(", ")
                    ),
                });
            }

            let index_paths = evidence
                .target_index_paths(archive_layout::ACTIVE_CHANGES_PREFIX)
                .await
                .map_err(|error| unsafe_evidence("Failed to read target index entries", error))?;
            let live = archive_layout::paths_contain_active_change(&index_paths, &item.change_id);
            let archived =
                archive_layout::paths_contain_valid_archive(&index_paths, &item.change_id);

            return Ok(BatchState::TargetMergeUnfinished {
                change_id: item.change_id.clone(),
                worktree: item.worktree.clone(),
                requires_live_removal: live && archived,
                reason: if live && archived {
                    format!(
                        "Merged stage-0 index holds both openspec/changes/{} and its valid archive entry",
                        item.change_id
                    )
                } else {
                    "Target merge is conflict-free and awaiting its commit".to_string()
                },
            });
        }

        if !target_conflicts.is_empty() {
            return Err(BatchState::UnsafeEvidence {
                reason: format!(
                    "Target index has conflict stages without MERGE_HEAD: {}",
                    target_conflicts.join(", ")
                ),
            });
        }

        // Archive evidence before the final merge starts comes from the
        // validated worktree tip's committed tree — not the filesystem, and not
        // the target index, which has not seen this branch yet. The final merge
        // integrates exactly this commit, so an unarchived or invalidly archived
        // tip must never be allowed to start it.
        let worktree_tree_paths = evidence
            .committed_tree_paths(&item.tip, archive_layout::ACTIVE_CHANGES_PREFIX)
            .await
            .map_err(|error| {
                unsafe_evidence("Failed to read validated worktree committed tree", error)
            })?;
        if !archive_layout::paths_contain_valid_archive(&worktree_tree_paths, &item.change_id) {
            let nested = archive_layout::paths_contain_invalid_nested_archive(
                &worktree_tree_paths,
                &item.change_id,
            );
            return Err(BatchState::UnsafeEvidence {
                reason: if nested {
                    format!(
                        "Branch '{}' tip {} archives '{}' under an invalid nested layout; expected {}/YYYY-MM-DD-{}/proposal.md",
                        item.revision,
                        item.tip,
                        item.change_id,
                        archive_layout::ARCHIVE_PREFIX,
                        item.change_id
                    )
                } else {
                    format!(
                        "Branch '{}' tip {} has no valid archive proposal for '{}'; the final merge would integrate an unarchived change",
                        item.revision, item.tip, item.change_id
                    )
                },
            });
        }

        // Last gate before the one instruction that integrates the change:
        // repository-visible task completion. Topology being provable says the
        // merge *can* be made, not that it *may* be. Everything before this
        // point is worktree-side pre-sync and conflict work, which stays
        // available either way — conflict resolution and merge authorization are
        // separate questions, and resolving a conflict never authorizes a merge.
        //
        // Placing the gate here also means an unfinished change never reaches
        // the state where a final merge is in progress, so `TargetMergeUnfinished`
        // needs no gate of its own: an in-progress merge can only exist because
        // authorization already succeeded, or because an operator started it by
        // hand, and abandoning that merge mid-flight would be a worse outcome
        // than committing what the operator explicitly asked for.
        if let Some(reason) = latch.refusal(&item.change_id) {
            return Ok(BatchState::MergeNotAuthorized {
                change_id: item.change_id.clone(),
                reason,
            });
        }
        // Evidence is the validated branch tip's own committed tree — exactly
        // what the final merge would integrate — so a task list completed only
        // in an uncommitted worktree cannot authorize anything.
        let completion =
            read_task_completion(evidence, &item.change_id, &item.tip, &worktree_tree_paths).await;
        if let Some(reason) = completion.detail(&item.change_id) {
            // Latched before returning: inside this batch the same unchanged
            // evidence must keep producing the same refusal, so no later attempt
            // can be handed the merge instruction again.
            latch.refuse(&item.change_id, &reason);
            return Ok(BatchState::MergeNotAuthorized {
                change_id: item.change_id.clone(),
                reason,
            });
        }

        return Ok(BatchState::FinalMergeMissing {
            change_id: item.change_id.clone(),
            worktree: item.worktree.clone(),
            required_target_state: target_head.clone(),
        });
    }

    // Every item has committed integration evidence.
    let tree_paths = evidence
        .committed_tree_paths(&target_head, archive_layout::ACTIVE_CHANGES_PREFIX)
        .await
        .map_err(|error| unsafe_evidence("Failed to read committed target tree", error))?;

    for (item, integration) in validated.iter().zip(integrations.iter()) {
        validate_cleanup_commits(
            evidence,
            item,
            integration,
            base_revision,
            &target_head,
            &target_lineage,
        )
        .await?;

        let live = archive_layout::paths_contain_active_change(&tree_paths, &item.change_id);
        let archived = archive_layout::paths_contain_valid_archive(&tree_paths, &item.change_id);
        if live && archived {
            return Ok(BatchState::ResurrectionCleanupRequired {
                change_id: item.change_id.clone(),
                reason: format!(
                    "Committed target HEAD {} holds both openspec/changes/{} and its valid archive entry",
                    target_head, item.change_id
                ),
            });
        }
    }

    let target_conflicts = evidence
        .target_conflict_paths()
        .await
        .map_err(|error| unsafe_evidence("Failed to read target index stages", error))?;
    if !target_conflicts.is_empty() {
        return Err(BatchState::UnsafeEvidence {
            reason: format!(
                "Every item is integrated but the target index has conflict stages: {}",
                target_conflicts.join(", ")
            ),
        });
    }

    let clean = evidence
        .target_is_clean()
        .await
        .map_err(|error| unsafe_evidence("Failed to read target cleanliness", error))?;
    if !clean {
        return Err(BatchState::UnsafeEvidence {
            reason: "Every item is integrated but the target index or worktree is not clean"
                .to_string(),
        });
    }

    Ok(BatchState::Complete)
}

/// Verify final integration for every batch item.
///
/// Shares [`classify_item_integration`] with retry classification so terminal
/// verification cannot accept a topology the retry loop rejects, or vice versa.
pub async fn verify_final_integration(
    evidence: &dyn ResolveEvidence,
    items: &[SequentialMergeItem],
    base_revision: &str,
) -> std::result::Result<(), String> {
    let validated = validate_items(evidence, items)
        .await
        .map_err(|state| state.diagnosis())?;
    let target_head = evidence
        .target_head()
        .await
        .map_err(|error| format!("Failed to read target HEAD: {}", error))?;

    let mut missing = Vec::new();
    for item in &validated {
        match classify_item_integration(evidence, item, base_revision, &target_head)
            .await
            .map_err(|state| state.diagnosis())?
        {
            ItemIntegration::Exact { .. } | ItemIntegration::Historical => {}
            ItemIntegration::NotIntegrated => missing.push(item.change_id.clone()),
        }
    }

    if missing.is_empty() {
        Ok(())
    } else {
        Err(format!(
            "Missing merge commit message containing change_id(s): {}",
            missing.join(", ")
        ))
    }
}

/// Git-backed [`ResolveEvidence`] over a target repository root.
pub struct GitResolveEvidence {
    repo_root: PathBuf,
}

impl GitResolveEvidence {
    /// Build an adapter rooted at the target repository.
    pub fn new(repo_root: impl Into<PathBuf>) -> Self {
        Self {
            repo_root: repo_root.into(),
        }
    }
}

#[async_trait]
impl ResolveEvidence for GitResolveEvidence {
    async fn validate_worktree(
        &self,
        supplied_path: &Path,
        expected_branch: &str,
    ) -> WorktreeIdentity {
        git_commands::validate_worktree_identity(&self.repo_root, supplied_path, expected_branch)
            .await
    }

    async fn worktree_merge_in_progress(&self, worktree: &Path) -> EvidenceResult<bool> {
        git_commands::is_merge_in_progress(worktree)
            .await
            .map_err(|error| error.to_string())
    }

    async fn worktree_conflicts(&self, worktree: &Path) -> EvidenceResult<Vec<String>> {
        git_commands::get_conflict_files(worktree)
            .await
            .map_err(|error| error.to_string())
    }

    async fn target_head(&self) -> EvidenceResult<String> {
        git_commands::rev_parse_commit(&self.repo_root, "HEAD")
            .await
            .map_err(|error| error.to_string())?
            .ok_or_else(|| "target repository has no HEAD commit".to_string())
    }

    async fn target_merge_head(&self) -> EvidenceResult<Option<String>> {
        git_commands::merge_head(&self.repo_root)
            .await
            .map_err(|error| error.to_string())
    }

    async fn target_conflict_paths(&self) -> EvidenceResult<Vec<String>> {
        let entries = git_commands::index_conflict_entries(&self.repo_root)
            .await
            .map_err(|error| error.to_string())?;
        let mut paths: Vec<String> = entries.into_iter().map(|entry| entry.path).collect();
        paths.sort();
        paths.dedup();
        Ok(paths)
    }

    async fn target_is_clean(&self) -> EvidenceResult<bool> {
        git_commands::is_clean_including_untracked(&self.repo_root)
            .await
            .map_err(|error| error.to_string())
    }

    async fn target_index_paths(&self, prefix: &str) -> EvidenceResult<Vec<String>> {
        git_commands::index_stage0_paths(&self.repo_root, prefix)
            .await
            .map_err(|error| error.to_string())
    }

    async fn parents_of(&self, commit: &str) -> EvidenceResult<Vec<String>> {
        git_commands::parents_of(&self.repo_root, commit)
            .await
            .map_err(|error| error.to_string())
    }

    async fn is_ancestor(&self, ancestor: &str, descendant: &str) -> EvidenceResult<bool> {
        git_commands::is_ancestor(&self.repo_root, ancestor, descendant)
            .await
            .map_err(|error| error.to_string())
    }

    async fn first_parent_lineage(&self, tip: &str) -> EvidenceResult<Vec<String>> {
        git_commands::first_parent_lineage(&self.repo_root, tip)
            .await
            .map_err(|error| error.to_string())
    }

    async fn merge_base(&self, a: &str, b: &str) -> EvidenceResult<Option<String>> {
        git_commands::merge_base(&self.repo_root, a, b)
            .await
            .map_err(|error| error.to_string())
    }

    async fn commits_with_exact_subject(
        &self,
        from: Option<&str>,
        to: &str,
        subject: &str,
    ) -> EvidenceResult<Vec<String>> {
        git_commands::commits_with_exact_subject(&self.repo_root, from, to, subject)
            .await
            .map_err(|error| error.to_string())
    }

    async fn commit_diff_entries(&self, commit: &str) -> EvidenceResult<Vec<CommitDiffEntry>> {
        git_commands::commit_diff_entries(&self.repo_root, commit)
            .await
            .map_err(|error| error.to_string())
    }

    async fn committed_tree_paths(
        &self,
        revision: &str,
        prefix: &str,
    ) -> EvidenceResult<Vec<String>> {
        git_commands::committed_tree_paths(&self.repo_root, revision, prefix)
            .await
            .map_err(|error| error.to_string())
    }

    async fn committed_file_text(
        &self,
        revision: &str,
        path: &str,
    ) -> EvidenceResult<Option<String>> {
        git_commands::committed_file_text(&self.repo_root, revision, path)
            .await
            .map_err(|error| error.to_string())
    }
}