taskplane 0.5.12 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,8 @@
2
2
  * User-facing message templates (ORCH_MESSAGES)
3
3
  * @module orch/messages
4
4
  */
5
- import type { AbortMode, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome } from "./types.ts";
5
+ import type { AbortMode, MergeFailureClassification, MergeRetryCallbacks, MergeRetryDecision, MergeRetryLoopOutcome, MergeRetryPolicy, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome } from "./types.ts";
6
+ import { MERGE_RETRY_POLICY_MATRIX } from "./types.ts";
6
7
 
7
8
  // ── Message Templates ────────────────────────────────────────────────
8
9
 
@@ -110,6 +111,13 @@ export const ORCH_MESSAGES = {
110
111
  resumeComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number) =>
111
112
  `\n🏁 Resumed batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s total)`,
112
113
 
114
+ // /orch-resume --force
115
+ forceResumeStarting: (batchId: string, phase: string) =>
116
+ `⚠️ Force-resuming batch ${batchId} from ${phase} state. Running pre-resume diagnostics...`,
117
+ forceResumeDiagnosticsFailed: (batchId: string) =>
118
+ `❌ Cannot force-resume batch ${batchId}: pre-resume diagnostics failed.\n` +
119
+ ` Fix the issues above, then retry /orch-resume --force.`,
120
+
113
121
  // /orch-abort
114
122
  abortGracefulStarting: (batchId: string, sessionCount: number) =>
115
123
  `⏳ Graceful abort of batch ${batchId}: signaling ${sessionCount} session(s) to checkpoint and exit...`,
@@ -209,8 +217,9 @@ export function formatRepoMergeSummary(mergeResult: MergeWaveResult): string | n
209
217
  const repoLines = repoResults.map(r => {
210
218
  const repoLabel = r.repoId ?? "(default)";
211
219
  const icon = repoStatusIcon(r.status);
220
+ // TP-032 R006-3: Exclude verification_new_failure lanes from success count
212
221
  const mergedCount = r.laneResults.filter(
213
- lr => lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED",
222
+ lr => !lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED"),
214
223
  ).length;
215
224
  const totalCount = r.laneResults.length;
216
225
  let detail = `${mergedCount}/${totalCount} lane(s) merged`;
@@ -354,6 +363,589 @@ export function computeMergeFailurePolicy(
354
363
  }
355
364
 
356
365
 
366
+ // ── Cleanup Gate Policy (TP-029 Step 2) ──────────────────────────────
367
+
368
+ /**
369
+ * Per-repo cleanup failure detail.
370
+ * Collected during post-merge inter-wave verification.
371
+ */
372
+ export interface CleanupGateRepoFailure {
373
+ /** Repo root path that has stale worktrees */
374
+ repoRoot: string;
375
+ /** Repo ID (undefined for primary/repo-mode) */
376
+ repoId: string | undefined;
377
+ /** Paths of stale worktrees still registered after cleanup */
378
+ staleWorktrees: string[];
379
+ }
380
+
381
+ /**
382
+ * Result of applying the cleanup gate policy.
383
+ *
384
+ * Pure function output — callers use this to perform state mutations
385
+ * and notifications consistently. Ensures engine.ts and resume.ts
386
+ * apply identical pause transitions on cleanup failure.
387
+ */
388
+ export interface CleanupGatePolicyResult {
389
+ /** Always "pause" — cleanup failures block next wave but preserve merged work */
390
+ policy: "pause";
391
+ /** Target phase for batchState.phase */
392
+ targetPhase: "paused";
393
+ /** Error message to push to batchState.errors */
394
+ errorMessage: string;
395
+ /** Persistence trigger label — matches spec classification naming */
396
+ persistTrigger: "cleanup_post_merge_failed";
397
+ /** User-facing notification message */
398
+ notifyMessage: string;
399
+ /** Notification level for onNotify */
400
+ notifyLevel: "error";
401
+ /** Structured log details for execLog */
402
+ logDetails: {
403
+ waveNumber: number;
404
+ failedRepoCount: number;
405
+ totalStaleWorktrees: number;
406
+ repos: Array<{ repoId: string; staleCount: number }>;
407
+ };
408
+ }
409
+
410
+ /**
411
+ * Compute the cleanup gate policy result for post-merge verification failure.
412
+ *
413
+ * This is a **pure function** — it computes all outputs deterministically
414
+ * from the wave index and per-repo failure details, without performing any
415
+ * side effects.
416
+ *
417
+ * Both engine.ts and resume.ts MUST use this function to guarantee
418
+ * identical failure attribution, phase transitions, error messages,
419
+ * and notifications when post-merge cleanup leaves stale worktrees.
420
+ *
421
+ * The cleanup gate always pauses (never aborts) because:
422
+ * - Merged commits are already on the orch branch and must not be lost
423
+ * - The operator can manually remove stale worktrees and `/orch-resume`
424
+ *
425
+ * @param waveIndex - 0-based wave index (displayed as 1-indexed)
426
+ * @param failures - Per-repo cleanup failure details
427
+ * @returns Policy result object for callers to apply
428
+ */
429
+ export function computeCleanupGatePolicy(
430
+ waveIndex: number,
431
+ failures: CleanupGateRepoFailure[],
432
+ ): CleanupGatePolicyResult {
433
+ const waveNum = waveIndex + 1;
434
+ const failedRepoCount = failures.length;
435
+ const totalStaleWorktrees = failures.reduce((sum, f) => sum + f.staleWorktrees.length, 0);
436
+
437
+ const repos = failures.map(f => ({
438
+ repoId: f.repoId ?? "(default)",
439
+ staleCount: f.staleWorktrees.length,
440
+ }));
441
+
442
+ const repoDetail = repos.map(r => `${r.repoId} (${r.staleCount} stale)`).join(", ");
443
+
444
+ const errorMessage =
445
+ `Post-merge cleanup failed at wave ${waveNum}: ${totalStaleWorktrees} stale worktree(s) ` +
446
+ `in ${failedRepoCount} repo(s) [${repoDetail}]. ` +
447
+ `Batch paused. Remove stale worktrees manually and use /orch-resume to continue.`;
448
+
449
+ // Build recovery commands for each failed repo
450
+ const recoveryLines: string[] = [];
451
+ for (const f of failures) {
452
+ const label = f.repoId ?? "default";
453
+ for (const wt of f.staleWorktrees) {
454
+ recoveryLines.push(` git worktree remove --force "${wt}" # repo: ${label}`);
455
+ }
456
+ }
457
+
458
+ const notifyMessage =
459
+ `⏸️ Batch paused: post-merge cleanup failed at wave ${waveNum}.\n` +
460
+ ` ${totalStaleWorktrees} stale worktree(s) in ${failedRepoCount} repo(s): ${repoDetail}\n` +
461
+ ` Manual recovery:\n` +
462
+ recoveryLines.join("\n") + "\n" +
463
+ ` Then: /orch-resume`;
464
+
465
+ return {
466
+ policy: "pause",
467
+ targetPhase: "paused",
468
+ errorMessage,
469
+ persistTrigger: "cleanup_post_merge_failed",
470
+ notifyMessage,
471
+ notifyLevel: "error",
472
+ logDetails: {
473
+ waveNumber: waveNum,
474
+ failedRepoCount,
475
+ totalStaleWorktrees,
476
+ repos,
477
+ },
478
+ };
479
+ }
480
+
481
+ // ── Merge Retry Policy (TP-033 Step 2) ───────────────────────────────
482
+
483
+ /**
484
+ * Classify a merge failure into a MergeFailureClassification.
485
+ *
486
+ * Inspects the MergeWaveResult — lane errors, failure reasons, and merge
487
+ * result statuses — to determine which retry policy class applies.
488
+ *
489
+ * Classification priority (first match wins):
490
+ * 1. `verification_new_failure` — any lane error starts with "verification_new_failure"
491
+ * 2. `merge_conflict_unresolved` — any lane result has CONFLICT_UNRESOLVED status
492
+ * 3. `cleanup_post_merge_failed` — failure reason contains "cleanup" or "stale worktree"
493
+ * 4. `git_lock_file` — failure reason contains "lock" or ".lock"
494
+ * 5. `git_worktree_dirty` — failure reason contains "dirty" or "worktree"
495
+ * 6. `null` — unclassifiable (treated as non-retriable by callers)
496
+ *
497
+ * This is a **pure function** — no side effects.
498
+ *
499
+ * @param mergeResult - The failed MergeWaveResult to classify
500
+ * @returns Classification or null if no merge-retry class matches
501
+ * @since TP-033
502
+ */
503
+ export function classifyMergeFailure(mergeResult: MergeWaveResult): MergeFailureClassification | null {
504
+ // Check lane-level errors first (most specific)
505
+ for (const lr of mergeResult.laneResults) {
506
+ if (lr.error && lr.error.startsWith("verification_new_failure")) {
507
+ return "verification_new_failure";
508
+ }
509
+ }
510
+
511
+ // Check lane result statuses
512
+ for (const lr of mergeResult.laneResults) {
513
+ if (lr.result?.status === "CONFLICT_UNRESOLVED") {
514
+ return "merge_conflict_unresolved";
515
+ }
516
+ }
517
+
518
+ // Check failure reason string patterns
519
+ const reason = (mergeResult.failureReason || "").toLowerCase();
520
+
521
+ // Lock file detection: git operations fail with "Unable to create '.../.git/index.lock': File exists"
522
+ if (reason.includes("lock") || reason.includes(".lock")) {
523
+ return "git_lock_file";
524
+ }
525
+
526
+ // Cleanup failures: stale worktrees or cleanup errors
527
+ if (reason.includes("cleanup") || reason.includes("stale worktree")) {
528
+ return "cleanup_post_merge_failed";
529
+ }
530
+
531
+ // Dirty worktree: git operations fail due to uncommitted changes
532
+ if (reason.includes("dirty") || reason.includes("worktree")) {
533
+ return "git_worktree_dirty";
534
+ }
535
+
536
+ return null;
537
+ }
538
+
539
+ /**
540
+ * Compute the retry decision for a merge failure.
541
+ *
542
+ * Given the failure classification and the current retry count for the
543
+ * relevant scope, returns a decision indicating whether to retry, the
544
+ * cooldown to wait, or the exhaustion action to take.
545
+ *
546
+ * This is a **pure function** — both engine.ts and resume.ts MUST use
547
+ * this function to guarantee identical retry behavior.
548
+ *
549
+ * @param classification - The classified merge failure (null = unclassifiable)
550
+ * @param currentRetryCount - Current retry attempts for this scope (0 = first failure)
551
+ * @returns Retry decision with all fields populated
552
+ * @since TP-033
553
+ */
554
+ export function computeMergeRetryDecision(
555
+ classification: MergeFailureClassification | null,
556
+ currentRetryCount: number,
557
+ ): MergeRetryDecision {
558
+ // Unclassifiable failures are never retried
559
+ if (classification === null) {
560
+ return {
561
+ shouldRetry: false,
562
+ cooldownMs: 0,
563
+ reason: "Unclassifiable merge failure — no retry policy available",
564
+ currentAttempt: currentRetryCount,
565
+ maxAttempts: 0,
566
+ classification: "merge_conflict_unresolved", // placeholder for type safety
567
+ exhaustionAction: "pause",
568
+ };
569
+ }
570
+
571
+ const policy: MergeRetryPolicy = MERGE_RETRY_POLICY_MATRIX[classification];
572
+
573
+ if (!policy.retriable) {
574
+ return {
575
+ shouldRetry: false,
576
+ cooldownMs: 0,
577
+ reason: `${classification} is not retriable — immediate ${policy.exhaustionAction}`,
578
+ currentAttempt: currentRetryCount,
579
+ maxAttempts: 0,
580
+ classification,
581
+ exhaustionAction: policy.exhaustionAction,
582
+ };
583
+ }
584
+
585
+ if (currentRetryCount >= policy.maxAttempts) {
586
+ return {
587
+ shouldRetry: false,
588
+ cooldownMs: 0,
589
+ reason: `${classification} retry exhausted (${currentRetryCount}/${policy.maxAttempts}) — ${policy.exhaustionAction}`,
590
+ currentAttempt: currentRetryCount,
591
+ maxAttempts: policy.maxAttempts,
592
+ classification,
593
+ exhaustionAction: policy.exhaustionAction,
594
+ };
595
+ }
596
+
597
+ return {
598
+ shouldRetry: true,
599
+ cooldownMs: policy.cooldownMs,
600
+ reason: `${classification} retry ${currentRetryCount + 1}/${policy.maxAttempts}` +
601
+ (policy.cooldownMs > 0 ? ` (cooldown: ${policy.cooldownMs}ms)` : ""),
602
+ currentAttempt: currentRetryCount + 1,
603
+ maxAttempts: policy.maxAttempts,
604
+ classification,
605
+ exhaustionAction: policy.exhaustionAction,
606
+ };
607
+ }
608
+
609
+ /**
610
+ * Build the merge retry scope key for persisted retry counters.
611
+ *
612
+ * Format: `{repoId}:w{waveIndex}:l{laneNumber}`
613
+ * - In workspace mode: uses the repo ID (e.g., "api:w0:l1")
614
+ * - In repo mode (repoId undefined/null): uses "default" (e.g., "default:w0:l1")
615
+ *
616
+ * NOTE: This is a different key format from the task-scoped format in v3 types
617
+ * (`{taskId}:w{waveIndex}:l{laneNumber}`). The merge retry scope is intentionally
618
+ * repo-scoped because merge failures are per-repo, not per-task. Both formats
619
+ * coexist in `resilience.retryCountByScope` — the prefix disambiguates them.
620
+ *
621
+ * @param repoId - Repo ID (undefined/null in repo mode)
622
+ * @param waveIndex - 0-based wave index
623
+ * @param laneNumber - Lane number
624
+ * @returns Scope key string
625
+ * @since TP-033
626
+ */
627
+ export function buildMergeRetryScopeKey(
628
+ repoId: string | undefined | null,
629
+ waveIndex: number,
630
+ laneNumber: number,
631
+ ): string {
632
+ const repo = repoId ?? "default";
633
+ return `${repo}:w${waveIndex}:l${laneNumber}`;
634
+ }
635
+
636
+ /**
637
+ * Extract the repo ID for a failed merge from the MergeWaveResult.
638
+ *
639
+ * Priority:
640
+ * 1. Lane-level: find the failed lane result and use its repoId
641
+ * 2. Repo-level: when failedLane is null (setup failure), check repoResults
642
+ * for the first failed repo group
643
+ * 3. Fallback: undefined (will become "default" in scope key)
644
+ *
645
+ * This ensures workspace-mode setup failures (e.g., worktree dirty before
646
+ * any lane starts) still get repo-scoped counters rather than all collapsing
647
+ * into "default:w{N}:l0".
648
+ *
649
+ * @param mergeResult - The failed MergeWaveResult
650
+ * @returns Repo ID or undefined if not determinable
651
+ * @since TP-033 R006
652
+ */
653
+ export function extractFailedRepoId(mergeResult: MergeWaveResult): string | undefined {
654
+ const failedLaneNum = mergeResult.failedLane;
655
+
656
+ // 1. Try lane-level extraction
657
+ if (failedLaneNum !== null && failedLaneNum !== undefined) {
658
+ const failedLaneResult = mergeResult.laneResults.find(
659
+ lr => lr.laneNumber === failedLaneNum &&
660
+ (lr.error || lr.result?.status === "CONFLICT_UNRESOLVED" || lr.result?.status === "BUILD_FAILURE"),
661
+ );
662
+ if (failedLaneResult?.repoId) return failedLaneResult.repoId;
663
+ }
664
+
665
+ // 2. Repo-level fallback for setup failures (failedLane === null)
666
+ if (mergeResult.repoResults && mergeResult.repoResults.length > 0) {
667
+ const failedRepo = mergeResult.repoResults.find(
668
+ rr => rr.status === "failed" || rr.status === "partial",
669
+ );
670
+ if (failedRepo?.repoId) return failedRepo.repoId;
671
+ }
672
+
673
+ // 3. If failureReason mentions a specific repo path, we could parse it,
674
+ // but that's fragile. Return undefined → "default" in scope key.
675
+ return undefined;
676
+ }
677
+
678
+ /**
679
+ * Shared merge retry loop used by both engine.ts and resume.ts.
680
+ *
681
+ * Wraps the retry cycle in a loop: after each failed retry, re-classifies
682
+ * the latest mergeResult, recomputes the retry decision using the persisted
683
+ * counter, and continues until success, safe-stop, or exhaustion/non-retriable.
684
+ *
685
+ * This is the **single implementation** of retry loop semantics.
686
+ * Engine.ts and resume.ts provide callbacks for their specific side effects
687
+ * (persistence, merge invocation, notification) to guarantee parity.
688
+ *
689
+ * **Important:** On retry exhaustion, this returns `kind: "exhausted"` which
690
+ * the caller MUST handle by forcing `paused` phase regardless of
691
+ * `on_merge_failure` config. The exhaustion action from the matrix takes
692
+ * precedence over config policy.
693
+ *
694
+ * @param mergeResult - The initial failed merge result
695
+ * @param waveIdx - 0-based wave index (for logging)
696
+ * @param retryCountByScope - Mutable reference to persisted retry counters
697
+ * @param callbacks - Side-effect callbacks for persistence/merge/logging
698
+ * @returns Outcome describing what happened during the retry cycle
699
+ * @since TP-033 R006
700
+ */
701
+ export function applyMergeRetryLoop(
702
+ mergeResult: MergeWaveResult,
703
+ waveIdx: number,
704
+ retryCountByScope: Record<string, number>,
705
+ callbacks: MergeRetryCallbacks,
706
+ ): MergeRetryLoopOutcome {
707
+ let currentResult = mergeResult;
708
+
709
+ // Classify the initial failure
710
+ let classification = classifyMergeFailure(currentResult);
711
+ const failedRepoId = extractFailedRepoId(currentResult);
712
+ const failedLaneNum = currentResult.failedLane ?? 0;
713
+ const scopeKey = buildMergeRetryScopeKey(failedRepoId, waveIdx, failedLaneNum);
714
+ const currentRetryCount = retryCountByScope[scopeKey] ?? 0;
715
+
716
+ // Check if any retry is possible at all
717
+ const initialDecision = computeMergeRetryDecision(classification, currentRetryCount);
718
+
719
+ if (!initialDecision.shouldRetry) {
720
+ // Non-retriable or already exhausted before we start
721
+ if (classification !== null && initialDecision.currentAttempt > 0) {
722
+ // Previously had retries — this is exhaustion
723
+ return {
724
+ kind: "exhausted",
725
+ mergeResult: currentResult,
726
+ classification,
727
+ scopeKey,
728
+ lastDecision: initialDecision,
729
+ errorMessage: `Merge retry exhausted at wave ${waveIdx + 1}: ${initialDecision.reason}`,
730
+ notifyMessage: `⏸️ Merge retry exhausted at wave ${waveIdx + 1}. ${initialDecision.reason}`,
731
+ };
732
+ }
733
+ // No retry was ever possible
734
+ return {
735
+ kind: "no_retry",
736
+ mergeResult: currentResult,
737
+ classification,
738
+ scopeKey,
739
+ };
740
+ }
741
+
742
+ // Enter retry loop
743
+ let lastDecision = initialDecision;
744
+
745
+ while (lastDecision.shouldRetry) {
746
+ // Increment counter in persisted state
747
+ retryCountByScope[scopeKey] = lastDecision.currentAttempt;
748
+
749
+ callbacks.log(`merge retry: ${lastDecision.reason}`, {
750
+ classification,
751
+ scopeKey,
752
+ attempt: lastDecision.currentAttempt,
753
+ maxAttempts: lastDecision.maxAttempts,
754
+ cooldownMs: lastDecision.cooldownMs,
755
+ });
756
+
757
+ callbacks.persist("merge-retry-increment");
758
+ callbacks.notify(
759
+ `🔄 Merge retry (${lastDecision.reason}) at wave ${waveIdx + 1}. ` +
760
+ (lastDecision.cooldownMs > 0 ? `Waiting ${lastDecision.cooldownMs}ms before retry...` : "Retrying immediately..."),
761
+ "warning",
762
+ );
763
+
764
+ if (lastDecision.cooldownMs > 0) {
765
+ callbacks.sleep(lastDecision.cooldownMs);
766
+ }
767
+
768
+ // Re-invoke merge
769
+ callbacks.persist("merge-retry-start");
770
+ currentResult = callbacks.performMerge();
771
+ callbacks.updateMergeResult(currentResult);
772
+ callbacks.persist("merge-retry-complete");
773
+
774
+ // Check outcome
775
+ if (currentResult.status === "succeeded") {
776
+ callbacks.notify(`✅ Merge retry succeeded at wave ${waveIdx + 1}.`, "info");
777
+ return {
778
+ kind: "retry_succeeded",
779
+ mergeResult: currentResult,
780
+ };
781
+ }
782
+
783
+ if (currentResult.rollbackFailed) {
784
+ // Safe-stop takes priority
785
+ const hasPersistErrors = currentResult.persistenceErrors && currentResult.persistenceErrors.length > 0;
786
+ const persistWarning = hasPersistErrors
787
+ ? ` WARNING: ${currentResult.persistenceErrors!.length} transaction record(s) failed to persist.`
788
+ : "";
789
+
790
+ return {
791
+ kind: "safe_stop",
792
+ mergeResult: currentResult,
793
+ errorMessage:
794
+ `Safe-stop at wave ${waveIdx + 1}: verification rollback failed after retry. ` +
795
+ `Merge worktree and temp branch preserved for recovery.` + persistWarning,
796
+ notifyMessage:
797
+ `🛑 Safe-stop: verification rollback failed at wave ${waveIdx + 1} after retry. ` +
798
+ `Batch force-paused.` + persistWarning,
799
+ };
800
+ }
801
+
802
+ // Retry failed — re-classify and check if we can retry again
803
+ classification = classifyMergeFailure(currentResult);
804
+ const updatedCount = retryCountByScope[scopeKey] ?? 0;
805
+ lastDecision = computeMergeRetryDecision(classification, updatedCount);
806
+ }
807
+
808
+ // Loop ended: exhaustion
809
+ return {
810
+ kind: "exhausted",
811
+ mergeResult: currentResult,
812
+ classification,
813
+ scopeKey,
814
+ lastDecision,
815
+ errorMessage: `Merge retry exhausted at wave ${waveIdx + 1}: ${lastDecision.reason}`,
816
+ notifyMessage: `⏸️ Merge retry exhausted at wave ${waveIdx + 1}. ${lastDecision.reason}`,
817
+ };
818
+ }
819
+
820
+ // ── Integrate Cleanup Acceptance (TP-029 Step 3) ─────────────────────
821
+
822
+ /**
823
+ * Per-repo acceptance check findings after /orch-integrate.
824
+ * Collected by scanning all workspace repos (not just repos that had the orch branch).
825
+ */
826
+ export interface IntegrateCleanupRepoFindings {
827
+ /** Repo root path */
828
+ repoRoot: string;
829
+ /** Repo ID (undefined for repo-mode / primary) */
830
+ repoId: string | undefined;
831
+ /** Stale lane worktrees still registered (git worktree list matches) */
832
+ staleWorktrees: string[];
833
+ /** Stale lane branches (task/{opId}-lane-*) */
834
+ staleLaneBranches: string[];
835
+ /** Stale orch branches (orch/{opId}-{batchId}) */
836
+ staleOrchBranches: string[];
837
+ /** Batch-scoped autostash entries still present */
838
+ staleAutostashEntries: string[];
839
+ /** Non-empty .worktrees/ containers */
840
+ nonEmptyWorktreeContainers: string[];
841
+ }
842
+
843
+ /**
844
+ * Result of the /orch-integrate cleanup acceptance check.
845
+ * Pure function output — callers use this to format the summary notification.
846
+ */
847
+ export interface IntegrateCleanupResult {
848
+ /** True if all repos pass all acceptance criteria */
849
+ clean: boolean;
850
+ /** Notification severity level: "info" when clean, "warning" when dirty */
851
+ notifyLevel: "info" | "warning";
852
+ /** Per-repo findings (only repos with at least one finding) */
853
+ dirtyRepos: IntegrateCleanupRepoFindings[];
854
+ /** User-facing cleanup report (appended to integrate summary) */
855
+ report: string;
856
+ }
857
+
858
+ /**
859
+ * Compute the integrate cleanup result from per-repo acceptance findings.
860
+ *
861
+ * This is a **pure function** — computes all outputs deterministically
862
+ * from the per-repo findings without side effects.
863
+ *
864
+ * The acceptance criteria (roadmap 2d) are:
865
+ * 1. No registered lane worktrees remain in any workspace repo
866
+ * 2. No lane branches remain (task/{opId}-lane-*)
867
+ * 3. No orch branches remain (orch/{opId}-{batchId})
868
+ * 4. No stale autostash from current batch remains
869
+ * 5. No non-empty .worktrees/ containers remain
870
+ *
871
+ * @param repoFindings - Per-repo findings from scanning all workspace repos
872
+ * @returns Cleanup result with pass/fail verdict and human-readable report
873
+ */
874
+ export function computeIntegrateCleanupResult(
875
+ repoFindings: IntegrateCleanupRepoFindings[],
876
+ ): IntegrateCleanupResult {
877
+ // Filter to repos that have at least one issue
878
+ const dirtyRepos = repoFindings.filter(r =>
879
+ r.staleWorktrees.length > 0 ||
880
+ r.staleLaneBranches.length > 0 ||
881
+ r.staleOrchBranches.length > 0 ||
882
+ r.staleAutostashEntries.length > 0 ||
883
+ r.nonEmptyWorktreeContainers.length > 0,
884
+ );
885
+
886
+ if (dirtyRepos.length === 0) {
887
+ return {
888
+ clean: true,
889
+ notifyLevel: "info",
890
+ dirtyRepos: [],
891
+ report: "🧹 Cleanup verified: no stale worktrees, branches, or autostash entries remain.",
892
+ };
893
+ }
894
+
895
+ // Build per-repo detail lines
896
+ const details: string[] = [];
897
+ for (const repo of dirtyRepos) {
898
+ const label = repo.repoId ?? "(default)";
899
+ const issues: string[] = [];
900
+ if (repo.staleWorktrees.length > 0) {
901
+ issues.push(`${repo.staleWorktrees.length} stale worktree(s)`);
902
+ }
903
+ if (repo.staleLaneBranches.length > 0) {
904
+ issues.push(`${repo.staleLaneBranches.length} lane branch(es)`);
905
+ }
906
+ if (repo.staleOrchBranches.length > 0) {
907
+ issues.push(`${repo.staleOrchBranches.length} orch branch(es)`);
908
+ }
909
+ if (repo.staleAutostashEntries.length > 0) {
910
+ issues.push(`${repo.staleAutostashEntries.length} autostash entr(ies)`);
911
+ }
912
+ if (repo.nonEmptyWorktreeContainers.length > 0) {
913
+ issues.push(`${repo.nonEmptyWorktreeContainers.length} non-empty .worktrees/ container(s)`);
914
+ }
915
+ details.push(` ${label}: ${issues.join(", ")}`);
916
+ }
917
+
918
+ // Build recovery commands
919
+ const recovery: string[] = [];
920
+ for (const repo of dirtyRepos) {
921
+ const label = repo.repoId ?? "default";
922
+ for (const wt of repo.staleWorktrees) {
923
+ recovery.push(` git worktree remove --force "${wt}" # repo: ${label}`);
924
+ }
925
+ for (const br of repo.staleLaneBranches) {
926
+ recovery.push(` git branch -D "${br}" # repo: ${label}`);
927
+ }
928
+ for (const br of repo.staleOrchBranches) {
929
+ recovery.push(` git branch -D "${br}" # repo: ${label}`);
930
+ }
931
+ for (const entry of repo.staleAutostashEntries) {
932
+ recovery.push(` git stash drop "${entry}" # repo: ${label}`);
933
+ }
934
+ }
935
+
936
+ const report =
937
+ `⚠️ Cleanup incomplete — residual artifacts found:\n` +
938
+ details.join("\n") +
939
+ (recovery.length > 0 ? `\n Manual cleanup:\n${recovery.join("\n")}` : "");
940
+
941
+ return {
942
+ clean: false,
943
+ notifyLevel: "warning",
944
+ dirtyRepos,
945
+ report,
946
+ };
947
+ }
948
+
357
949
  // ── Resume ORCH_MESSAGES ─────────────────────────────────────────────
358
950
 
359
951
  // Note: These are added via extension to the ORCH_MESSAGES object below.