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.
@@ -1,8 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
 
3
3
  import { execSync, execFileSync } from "child_process";
4
- import { writeFileSync, unlinkSync, mkdirSync } from "fs";
5
- import { join } from "path";
4
+ import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync } from "fs";
5
+ import { join, resolve } from "path";
6
6
 
7
7
  import {
8
8
  DEFAULT_ORCHESTRATOR_CONFIG,
@@ -11,6 +11,7 @@ import {
11
11
  ORCH_MESSAGES,
12
12
  StateFileError,
13
13
  WorkspaceConfigError,
14
+ computeIntegrateCleanupResult,
14
15
  computeWaveAssignments,
15
16
  createOrchWidget,
16
17
  deleteBatchState,
@@ -25,10 +26,13 @@ import {
25
26
  freshOrchBatchState,
26
27
  getCurrentBranch,
27
28
  listOrchSessions,
29
+ listWorktrees,
28
30
  loadBatchState,
29
31
  loadOrchestratorConfig,
30
32
  loadTaskRunnerConfig,
31
33
  parseOrchSessionNames,
34
+ resolveOperatorId,
35
+ resolveWorktreeBasePath,
32
36
  resumeOrchBatch,
33
37
  runDiscovery,
34
38
  runGit,
@@ -39,6 +43,7 @@ import { openSettingsTui } from "./settings-tui.ts";
39
43
  import type {
40
44
  AbortMode,
41
45
  ExecutionContext,
46
+ IntegrateCleanupRepoFindings,
42
47
  MonitorState,
43
48
  OrchestratorConfig,
44
49
  PersistedBatchState,
@@ -106,6 +111,42 @@ export function parseIntegrateArgs(raw: string | undefined): IntegrateArgs | { e
106
111
  };
107
112
  }
108
113
 
114
+ // ── Resume Args Parsing ───────────────────────────────────────────────
115
+
116
+ export interface ResumeArgs {
117
+ force: boolean;
118
+ }
119
+
120
+ /**
121
+ * Parse `/orch-resume` command arguments.
122
+ *
123
+ * Supported flags: --force
124
+ * No positional arguments accepted.
125
+ *
126
+ * Returns parsed args or an error string if arguments are invalid.
127
+ */
128
+ export function parseResumeArgs(raw: string | undefined): ResumeArgs | { error: string } {
129
+ const input = raw?.trim() ?? "";
130
+ if (!input) return { force: false };
131
+
132
+ const tokens = input.split(/\s+/).filter(Boolean);
133
+ let force = false;
134
+
135
+ for (const token of tokens) {
136
+ if (token === "--force") {
137
+ force = true;
138
+ } else if (token === "--help") {
139
+ return { error: "Usage: /orch-resume [--force]\n\n --force Resume from stopped or failed state (runs pre-resume diagnostics first)" };
140
+ } else if (token.startsWith("--")) {
141
+ return { error: `Unknown flag: ${token}\n\nUsage: /orch-resume [--force]` };
142
+ } else {
143
+ return { error: `Unexpected argument: ${token}\n\nUsage: /orch-resume [--force]` };
144
+ }
145
+ }
146
+
147
+ return { force };
148
+ }
149
+
109
150
  // ── Integration Context Resolution ────────────────────────────────────
110
151
 
111
152
  /**
@@ -493,6 +534,155 @@ function performCleanup(
493
534
  return result;
494
535
  }
495
536
 
537
+ // ── Post-Integration Cleanup Helpers (TP-029 Step 3) ─────────────────
538
+
539
+ /**
540
+ * Drop batch-scoped autostash entries from a repo.
541
+ *
542
+ * Targets two stash message patterns created during orchestration:
543
+ * - "orch-integrate-autostash-{batchId}" (from /orch-integrate ff/merge modes)
544
+ * - "merge-agent-autostash-w{N}-{batchId}" (from merge.ts wave ff)
545
+ *
546
+ * Git stash subjects include a branch prefix ("On <branch>: <message>"), so
547
+ * we match with `String.includes()` or a regex test against the full subject.
548
+ *
549
+ * Scans the stash list bottom-to-top so that dropping entries doesn't
550
+ * invalidate remaining indices. Non-matching stashes are never touched.
551
+ */
552
+ export function dropBatchAutostash(repoRoot: string, batchId: string): void {
553
+ if (!batchId) return;
554
+
555
+ const stashList = runGit(["stash", "list", "--format=%gd %s"], repoRoot);
556
+ if (!stashList.ok || !stashList.stdout.trim()) return;
557
+
558
+ // Collect indices to drop (bottom-up order — highest index first)
559
+ const lines = stashList.stdout.trim().split("\n");
560
+ const indicesToDrop: number[] = [];
561
+
562
+ // Match patterns within the full stash subject (includes "On <branch>: " prefix)
563
+ const integrateSubstring = `orch-integrate-autostash-${batchId}`;
564
+ const mergePattern = new RegExp(`merge-agent-autostash-w\\d+-${escapeRegexStr(batchId)}`);
565
+
566
+ for (const line of lines) {
567
+ // Format: "stash@{N} <subject>"
568
+ const match = line.match(/^stash@\{(\d+)\}\s+(.*)$/);
569
+ if (!match) continue;
570
+ const idx = parseInt(match[1], 10);
571
+ const subject = match[2];
572
+ if (subject.includes(integrateSubstring) || mergePattern.test(subject)) {
573
+ indicesToDrop.push(idx);
574
+ }
575
+ }
576
+
577
+ // Sort descending so we drop from bottom up
578
+ indicesToDrop.sort((a, b) => b - a);
579
+ for (const idx of indicesToDrop) {
580
+ runGit(["stash", "drop", `stash@{${idx}}`], repoRoot);
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Escape a string for use in a RegExp.
586
+ */
587
+ function escapeRegexStr(s: string): string {
588
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
589
+ }
590
+
591
+ /**
592
+ * Collect cleanup acceptance findings for a single repo.
593
+ *
594
+ * Checks the five acceptance criteria from roadmap 2d:
595
+ * 1. No registered lane worktrees (via listWorktrees)
596
+ * 2. No lane branches (task/{opId}-lane-*)
597
+ * 3. No orch branches (the specific orch branch for this batch)
598
+ * 4. No batch-scoped autostash entries
599
+ * 5. No non-empty .worktrees/ containers
600
+ */
601
+ export function collectRepoCleanupFindings(
602
+ repoRoot: string,
603
+ repoId: string | undefined,
604
+ opId: string,
605
+ batchId: string,
606
+ worktreePrefix: string,
607
+ orchBranch: string,
608
+ orchConfig: OrchestratorConfig,
609
+ options?: { skipOrchBranch?: boolean },
610
+ ): IntegrateCleanupRepoFindings {
611
+ const findings: IntegrateCleanupRepoFindings = {
612
+ repoRoot,
613
+ repoId,
614
+ staleWorktrees: [],
615
+ staleLaneBranches: [],
616
+ staleOrchBranches: [],
617
+ staleAutostashEntries: [],
618
+ nonEmptyWorktreeContainers: [],
619
+ };
620
+
621
+ // 1. Stale lane worktrees — check for any worktrees belonging to this operator+batch
622
+ try {
623
+ const wts = listWorktrees(worktreePrefix, repoRoot, opId, batchId);
624
+ findings.staleWorktrees = wts.map(wt => wt.path);
625
+ } catch { /* best effort — git worktree list may fail in unusual states */ }
626
+
627
+ // 2. Lane branches — task/{opId}-lane-*
628
+ try {
629
+ const branchResult = runGit(["branch", "--list", `task/${opId}-lane-*`], repoRoot);
630
+ if (branchResult.ok && branchResult.stdout.trim()) {
631
+ findings.staleLaneBranches = branchResult.stdout
632
+ .split("\n")
633
+ .map(b => b.replace(/^\*?\s+/, "").trim())
634
+ .filter(Boolean);
635
+ }
636
+ } catch { /* best effort */ }
637
+
638
+ // 3. Orch branch — check if the specific orch branch still exists
639
+ // Skip in PR mode where the orch branch is intentionally preserved for the PR.
640
+ if (!options?.skipOrchBranch) {
641
+ try {
642
+ const orchCheck = runGit(["rev-parse", "--verify", `refs/heads/${orchBranch}`], repoRoot);
643
+ if (orchCheck.ok) {
644
+ findings.staleOrchBranches = [orchBranch];
645
+ }
646
+ } catch { /* best effort */ }
647
+ }
648
+
649
+ // 4. Autostash entries — same patterns as dropBatchAutostash
650
+ // Git stash subjects include branch prefix ("On <branch>: <message>"),
651
+ // so we use substring/regex matching against the full subject.
652
+ if (batchId) {
653
+ try {
654
+ const stashList = runGit(["stash", "list", "--format=%gd %s"], repoRoot);
655
+ if (stashList.ok && stashList.stdout.trim()) {
656
+ const integrateSubstring = `orch-integrate-autostash-${batchId}`;
657
+ const mergePattern = new RegExp(`merge-agent-autostash-w\\d+-${escapeRegexStr(batchId)}`);
658
+ for (const line of stashList.stdout.trim().split("\n")) {
659
+ const match = line.match(/^stash@\{(\d+)\}\s+(.*)$/);
660
+ if (!match) continue;
661
+ const subject = match[2];
662
+ if (subject.includes(integrateSubstring) || mergePattern.test(subject)) {
663
+ findings.staleAutostashEntries.push(match[1]); // stash index
664
+ }
665
+ }
666
+ }
667
+ } catch { /* best effort */ }
668
+ }
669
+
670
+ // 5. Non-empty .worktrees/ containers (subdirectory mode only)
671
+ if (orchConfig.orchestrator.worktree_location !== "sibling") {
672
+ try {
673
+ const basePath = resolveWorktreeBasePath(repoRoot, orchConfig);
674
+ if (existsSync(basePath)) {
675
+ const entries = readdirSync(basePath);
676
+ if (entries.length > 0) {
677
+ findings.nonEmptyWorktreeContainers = [basePath];
678
+ }
679
+ }
680
+ } catch { /* best effort */ }
681
+ }
682
+
683
+ return findings;
684
+ }
685
+
496
686
  // ── Extension ────────────────────────────────────────────────────────
497
687
 
498
688
  export default function (pi: ExtensionAPI) {
@@ -615,7 +805,7 @@ export default function (pi: ExtensionAPI) {
615
805
  return;
616
806
 
617
807
  case "cleanup-stale":
618
- // No orphans + stale/invalid state file — auto-delete and continue
808
+ // No orphans + stale/completed state file — auto-delete and continue
619
809
  try {
620
810
  deleteBatchState(repoRoot);
621
811
  } catch {
@@ -626,6 +816,16 @@ export default function (pi: ExtensionAPI) {
626
816
  }
627
817
  break;
628
818
 
819
+ case "paused-corrupt":
820
+ // Corrupt/unreadable state file — do NOT auto-delete.
821
+ // Enter paused phase so operator-visible state reflects the issue,
822
+ // notify user, refresh widget, then stop.
823
+ orchBatchState.phase = "paused";
824
+ orchBatchState.errors.push(orphanResult.userMessage);
825
+ updateOrchWidget();
826
+ ctx.ui.notify(orphanResult.userMessage, "warning");
827
+ return;
828
+
629
829
  case "start-fresh":
630
830
  // No orphans, no state file — proceed normally
631
831
  break;
@@ -821,10 +1021,17 @@ export default function (pi: ExtensionAPI) {
821
1021
  });
822
1022
 
823
1023
  pi.registerCommand("orch-resume", {
824
- description: "Resume a paused or interrupted batch",
825
- handler: async (_args, ctx) => {
1024
+ description: "Resume a paused or interrupted batch: /orch-resume [--force]",
1025
+ handler: async (args, ctx) => {
826
1026
  if (!requireExecCtx(ctx)) return;
827
1027
 
1028
+ // Parse arguments
1029
+ const parsed = parseResumeArgs(args);
1030
+ if ("error" in parsed) {
1031
+ ctx.ui.notify(`❌ ${parsed.error}`, "error");
1032
+ return;
1033
+ }
1034
+
828
1035
  // Prevent resume if a batch is actively running
829
1036
  if (orchBatchState.phase === "executing" || orchBatchState.phase === "merging" || orchBatchState.phase === "planning") {
830
1037
  ctx.ui.notify(
@@ -855,6 +1062,7 @@ export default function (pi: ExtensionAPI) {
855
1062
  execCtx!.workspaceConfig,
856
1063
  execCtx!.workspaceRoot,
857
1064
  execCtx!.pointer?.agentRoot,
1065
+ parsed.force,
858
1066
  );
859
1067
 
860
1068
  // Final widget update
@@ -1253,14 +1461,57 @@ export default function (pi: ExtensionAPI) {
1253
1461
 
1254
1462
  if (!allSucceeded) return;
1255
1463
 
1256
- // Clean up batch state after all repos integrated
1464
+ // ── Step 4: Post-integration cleanup & acceptance ────────
1465
+ // Run acceptance checks BEFORE deleting batch state so recovery
1466
+ // context is still available if something goes wrong.
1467
+
1468
+ // Resolve all repos to verify (all workspace repos, not just those
1469
+ // that had the orch branch — roadmap 2d requires "any workspace repo").
1470
+ const allRepos: { id: string; root: string }[] = [];
1471
+ if (wsConfig) {
1472
+ for (const [repoId, repoConf] of wsConfig.repos) {
1473
+ allRepos.push({ id: repoId, root: repoConf.path });
1474
+ }
1475
+ } else {
1476
+ allRepos.push({ id: "(default)", root: repoRoot });
1477
+ }
1478
+
1479
+ const opId = resolveOperatorId(orchConfig);
1480
+ const orchPrefix = orchConfig.orchestrator.worktree_prefix;
1481
+
1482
+ // Drop batch-scoped autostash entries from all repos.
1483
+ // Patterns: "orch-integrate-autostash-{batchId}" (from extension.ts)
1484
+ // "merge-agent-autostash-w*-{batchId}" (from merge.ts)
1485
+ for (const repo of allRepos) {
1486
+ dropBatchAutostash(repo.root, batchId);
1487
+ }
1488
+
1489
+ // Run acceptance checks across all workspace repos.
1490
+ // In PR mode, the orch branch is intentionally preserved for the PR,
1491
+ // so we skip orch branch detection to avoid contradictory output.
1492
+ const skipOrchBranch = parsed.mode === "pr";
1493
+ const repoFindings: IntegrateCleanupRepoFindings[] = [];
1494
+ for (const repo of allRepos) {
1495
+ const findings = collectRepoCleanupFindings(
1496
+ repo.root, repo.id === "(default)" ? undefined : repo.id,
1497
+ opId, batchId, orchPrefix, resolvedOrchBranch, orchConfig,
1498
+ { skipOrchBranch },
1499
+ );
1500
+ repoFindings.push(findings);
1501
+ }
1502
+
1503
+ const cleanupResult = computeIntegrateCleanupResult(repoFindings);
1504
+
1505
+ // NOW delete batch state (acceptance checks are done)
1257
1506
  try { deleteBatchState(repoRoot); } catch { /* best effort */ }
1258
1507
 
1259
- const summary = wsConfig
1508
+ const integrationSummary = wsConfig
1260
1509
  ? `✅ Integrated ${resolvedOrchBranch} across ${reposToIntegrate.length} repo(s).\n${repoMessages.join("\n")}\n${totalCommits} total commit(s) applied.`
1261
1510
  : `${repoMessages[0] || "✅ Integrated."}\n${commitsAhead} commit(s) applied.`;
1262
1511
 
1263
- ctx.ui.notify(summary, "info");
1512
+ const summary = integrationSummary + "\n" + cleanupResult.report;
1513
+
1514
+ ctx.ui.notify(summary, cleanupResult.notifyLevel);
1264
1515
  },
1265
1516
  });
1266
1517
 
@@ -22,3 +22,4 @@ export * from "./engine.ts";
22
22
  export * from "./resume.ts";
23
23
  export * from "./abort.ts";
24
24
  export * from "./workspace.ts";
25
+ export * from "./diagnostics.ts";