gsd-pi 2.13.0 → 2.14.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.
Files changed (99) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.js +1 -0
  3. package/dist/loader.js +50 -6
  4. package/dist/resource-loader.d.ts +7 -6
  5. package/dist/resource-loader.js +15 -8
  6. package/dist/resources/extensions/gsd/auto-worktree.ts +29 -183
  7. package/dist/resources/extensions/gsd/auto.ts +252 -370
  8. package/dist/resources/extensions/gsd/commands.ts +118 -34
  9. package/dist/resources/extensions/gsd/doctor.ts +29 -4
  10. package/dist/resources/extensions/gsd/git-self-heal.ts +0 -71
  11. package/dist/resources/extensions/gsd/git-service.ts +8 -431
  12. package/dist/resources/extensions/gsd/gitignore.ts +11 -4
  13. package/dist/resources/extensions/gsd/guided-flow.ts +141 -5
  14. package/dist/resources/extensions/gsd/preferences.ts +18 -17
  15. package/dist/resources/extensions/gsd/prompts/discuss.md +35 -0
  16. package/dist/resources/extensions/gsd/prompts/queue.md +7 -1
  17. package/dist/resources/extensions/gsd/state.ts +26 -8
  18. package/dist/resources/extensions/gsd/templates/state.md +0 -1
  19. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +3 -2
  20. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +1 -1
  21. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +35 -0
  22. package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +22 -4
  23. package/dist/resources/extensions/gsd/tests/draft-promotion.test.ts +2 -1
  24. package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +8 -111
  25. package/dist/resources/extensions/gsd/tests/git-service.test.ts +11 -770
  26. package/dist/resources/extensions/gsd/tests/idle-recovery.test.ts +21 -113
  27. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +16 -82
  28. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +29 -50
  29. package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +17 -91
  30. package/dist/resources/extensions/gsd/tests/worktree-integration.test.ts +28 -55
  31. package/dist/resources/extensions/gsd/tests/worktree.test.ts +1 -426
  32. package/dist/resources/extensions/gsd/types.ts +0 -1
  33. package/dist/resources/extensions/gsd/worktree-manager.ts +7 -3
  34. package/dist/resources/extensions/gsd/worktree.ts +7 -65
  35. package/dist/resources/extensions/search-the-web/command-search-provider.ts +3 -1
  36. package/package.json +1 -1
  37. package/packages/pi-ai/dist/providers/google.d.ts.map +1 -1
  38. package/packages/pi-ai/dist/providers/google.js +12 -4
  39. package/packages/pi-ai/dist/providers/google.js.map +1 -1
  40. package/packages/pi-ai/dist/providers/mistral.d.ts.map +1 -1
  41. package/packages/pi-ai/dist/providers/mistral.js +10 -2
  42. package/packages/pi-ai/dist/providers/mistral.js.map +1 -1
  43. package/packages/pi-ai/src/providers/google.ts +20 -8
  44. package/packages/pi-ai/src/providers/mistral.ts +14 -2
  45. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts +3 -0
  46. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  47. package/packages/pi-coding-agent/dist/core/extensions/loader.js +10 -7
  48. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  49. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
  51. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js +4 -1
  52. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js.map +1 -1
  53. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  54. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +12 -3
  55. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  56. package/packages/pi-coding-agent/src/core/extensions/loader.ts +13 -9
  57. package/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +4 -1
  58. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +14 -3
  59. package/packages/pi-tui/dist/components/input.d.ts +1 -0
  60. package/packages/pi-tui/dist/components/input.d.ts.map +1 -1
  61. package/packages/pi-tui/dist/components/input.js +10 -0
  62. package/packages/pi-tui/dist/components/input.js.map +1 -1
  63. package/packages/pi-tui/src/components/input.ts +11 -0
  64. package/src/resources/extensions/gsd/auto-worktree.ts +29 -183
  65. package/src/resources/extensions/gsd/auto.ts +252 -370
  66. package/src/resources/extensions/gsd/commands.ts +118 -34
  67. package/src/resources/extensions/gsd/doctor.ts +29 -4
  68. package/src/resources/extensions/gsd/git-self-heal.ts +0 -71
  69. package/src/resources/extensions/gsd/git-service.ts +8 -431
  70. package/src/resources/extensions/gsd/gitignore.ts +11 -4
  71. package/src/resources/extensions/gsd/guided-flow.ts +141 -5
  72. package/src/resources/extensions/gsd/preferences.ts +18 -17
  73. package/src/resources/extensions/gsd/prompts/discuss.md +35 -0
  74. package/src/resources/extensions/gsd/prompts/queue.md +7 -1
  75. package/src/resources/extensions/gsd/state.ts +26 -8
  76. package/src/resources/extensions/gsd/templates/state.md +0 -1
  77. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +3 -2
  78. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +1 -1
  79. package/src/resources/extensions/gsd/tests/derive-state.test.ts +35 -0
  80. package/src/resources/extensions/gsd/tests/doctor-git.test.ts +22 -4
  81. package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +2 -1
  82. package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +8 -111
  83. package/src/resources/extensions/gsd/tests/git-service.test.ts +11 -770
  84. package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +21 -113
  85. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +16 -82
  86. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +29 -50
  87. package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +17 -91
  88. package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +28 -55
  89. package/src/resources/extensions/gsd/tests/worktree.test.ts +1 -426
  90. package/src/resources/extensions/gsd/types.ts +0 -1
  91. package/src/resources/extensions/gsd/worktree-manager.ts +7 -3
  92. package/src/resources/extensions/gsd/worktree.ts +7 -65
  93. package/src/resources/extensions/search-the-web/command-search-provider.ts +3 -1
  94. package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +0 -282
  95. package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +0 -107
  96. package/dist/resources/extensions/gsd/tests/orphaned-branch.test.ts +0 -353
  97. package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +0 -282
  98. package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +0 -107
  99. package/src/resources/extensions/gsd/tests/orphaned-branch.test.ts +0 -353
@@ -69,22 +69,20 @@ import {
69
69
  getProjectTotals, formatCost, formatTokenCount,
70
70
  } from "./metrics.js";
71
71
  import { dirname, join } from "node:path";
72
- import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
72
+ import { sep as pathSep } from "node:path";
73
+ import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, renameSync, statSync } from "node:fs";
73
74
  import { execSync, execFileSync } from "node:child_process";
74
75
  import {
75
76
  autoCommitCurrentBranch,
76
77
  captureIntegrationBranch,
77
- ensureSliceBranch,
78
+ detectWorktreeName,
78
79
  getCurrentBranch,
79
80
  getMainBranch,
80
81
  MergeConflictError,
81
82
  parseSliceBranch,
82
83
  setActiveMilestoneId,
83
- switchToMain,
84
- mergeSliceToMain,
85
84
  } from "./worktree.js";
86
85
  import { GitServiceImpl, runGit } from "./git-service.js";
87
- import { nativeCommitCountBetween } from "./native-git-bridge.js";
88
86
  import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js";
89
87
  import { formatGitError } from "./git-self-heal.js";
90
88
  import {
@@ -94,10 +92,7 @@ import {
94
92
  isInAutoWorktree,
95
93
  getAutoWorktreePath,
96
94
  getAutoWorktreeOriginalBase,
97
- mergeSliceToMilestone,
98
95
  mergeMilestoneToMain,
99
- shouldUseWorktreeIsolation,
100
- getMergeToMainMode,
101
96
  } from "./auto-worktree.js";
102
97
  import type { GitPreferences } from "./git-service.js";
103
98
  import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
@@ -122,7 +117,10 @@ function persistCompletedKey(base: string, key: string): void {
122
117
  } catch { /* corrupt file — start fresh */ }
123
118
  if (!keys.includes(key)) {
124
119
  keys.push(key);
125
- writeFileSync(file, JSON.stringify(keys), "utf-8");
120
+ // Atomic write: tmp file + rename prevents partial writes on crash
121
+ const tmpFile = file + ".tmp";
122
+ writeFileSync(tmpFile, JSON.stringify(keys), "utf-8");
123
+ renameSync(tmpFile, file);
126
124
  }
127
125
  }
128
126
 
@@ -360,6 +358,8 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
360
358
  clearUnitTimeout();
361
359
  if (basePath) clearLock(basePath);
362
360
  clearSkillSnapshot();
361
+ _dispatching = false;
362
+ _skipDepth = 0;
363
363
 
364
364
  // Remove SIGTERM handler registered at auto-mode start
365
365
  deregisterSigtermHandler();
@@ -468,136 +468,41 @@ async function selfHealRuntimeRecords(base: string, ctx: ExtensionContext): Prom
468
468
  const { listUnitRuntimeRecords } = await import("./unit-runtime.js");
469
469
  const records = listUnitRuntimeRecords(base);
470
470
  let healed = 0;
471
+ const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
472
+ const now = Date.now();
471
473
  for (const record of records) {
472
474
  const { unitType, unitId } = record;
473
475
  const artifactPath = resolveExpectedArtifactPath(unitType, unitId, base);
476
+
477
+ // Case 1: Artifact exists — unit completed but closeout didn't finish
474
478
  if (artifactPath && existsSync(artifactPath)) {
475
- // Artifact exists — unit completed but closeout didn't finish.
476
479
  clearUnitRuntimeRecord(base, unitType, unitId);
480
+ // Also persist completion key if missing
481
+ const key = `${unitType}/${unitId}`;
482
+ if (!completedKeySet.has(key)) {
483
+ persistCompletedKey(base, key);
484
+ completedKeySet.add(key);
485
+ }
477
486
  healed++;
487
+ continue;
488
+ }
489
+
490
+ // Case 2: No artifact but record is stale (dispatched > 1h ago, process crashed)
491
+ const age = now - (record.startedAt ?? 0);
492
+ if (record.phase === "dispatched" && age > STALE_THRESHOLD_MS) {
493
+ clearUnitRuntimeRecord(base, unitType, unitId);
494
+ healed++;
495
+ continue;
478
496
  }
479
497
  }
480
498
  if (healed > 0) {
481
- ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s) with completed artifacts.`, "info");
499
+ ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s).`, "info");
482
500
  }
483
501
  } catch {
484
502
  // Non-fatal — self-heal should never block auto-mode start
485
503
  }
486
504
  }
487
505
 
488
- /**
489
- * Startup check: scan for orphaned completed slice branches and merge them.
490
- *
491
- * An orphaned completed slice branch is a `gsd/MID/SID` branch where the slice
492
- * is marked done in the roadmap (on that branch) but hasn't been squash-merged
493
- * to main yet. This happens when `complete-slice` succeeds and commits on the
494
- * slice branch, but the subsequent merge to main is interrupted (crash, timeout,
495
- * Ctrl+C, merge conflict that wasn't auto-resolved).
496
- *
497
- * Without this check, GSD gets stuck in an infinite loop: `deriveState()` on
498
- * main sees no slice artifacts → wants research-slice → idempotency key removed
499
- * (artifact not on main) → ensurePreconditions switches branch → merge guard
500
- * merges → re-derives → repeats.
501
- */
502
- async function mergeOrphanedSliceBranches(
503
- base: string,
504
- ctx: Pick<ExtensionContext, "ui">,
505
- ): Promise<void> {
506
- // List all local gsd/<MID>/<SID> branches (non-worktree pattern).
507
- // Use execFileSync (not runGit/execSync) to avoid shell glob-expanding gsd/*/*
508
- // and to avoid shell syntax errors from %(refname:short) on /bin/sh.
509
- let branchListRaw = "";
510
- try {
511
- branchListRaw = execFileSync(
512
- "git",
513
- ["branch", "--list", "gsd/*/*", "--format=%(refname:short)"],
514
- { cwd: base, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
515
- ).trim();
516
- } catch {
517
- return; // no slice branches or git unavailable
518
- }
519
- if (!branchListRaw) return;
520
-
521
- const branches = branchListRaw.split("\n").map(b => b.trim()).filter(Boolean);
522
- for (const branch of branches) {
523
- const parsed = parseSliceBranch(branch);
524
- // Skip worktree-namespaced branches — those are managed by the worktree
525
- // manager and should not be merged by the main-tree auto-mode.
526
- if (!parsed || parsed.worktreeName) continue;
527
-
528
- const { milestoneId, sliceId } = parsed;
529
-
530
- // Ensure Git operations for this branch use the correct milestone context.
531
- setActiveMilestoneId(base, milestoneId);
532
-
533
- // Skip if already merged (no commits ahead of main)
534
- const mainBranch = getMainBranch(base);
535
- const aheadCount = nativeCommitCountBetween(base, mainBranch, branch);
536
- if (aheadCount === 0) continue;
537
-
538
- // Read the roadmap from the slice branch to check if the slice is done.
539
- // relMilestoneFile resolves the actual directory name on disk (handles
540
- // milestone directories with title suffixes like "M007 Payment System").
541
- const roadmapRelPath = relMilestoneFile(base, milestoneId, "ROADMAP");
542
- let roadmapContent: string | undefined;
543
- try {
544
- roadmapContent = execFileSync(
545
- "git",
546
- ["-C", base, "show", `${branch}:${roadmapRelPath}`],
547
- { encoding: "utf8" },
548
- );
549
- } catch {
550
- roadmapContent = undefined;
551
- }
552
- if (!roadmapContent) continue;
553
-
554
- const roadmap = parseRoadmap(roadmapContent);
555
- const sliceEntry = roadmap.slices.find(s => s.id === sliceId);
556
- if (!sliceEntry?.done) continue;
557
-
558
- // Orphaned completed branch detected — merge it to main now.
559
- ctx.ui.notify(
560
- `Orphaned completed slice branch detected: ${branch}. Merging to main before dispatch...`,
561
- "info",
562
- );
563
- try {
564
- let mergeResult;
565
- if (isInAutoWorktree(base) && getMergeToMainMode() !== "slice") {
566
- mergeResult = mergeSliceToMilestone(
567
- base, milestoneId, sliceId, sliceEntry.title || sliceId,
568
- );
569
- } else {
570
- switchToMain(base);
571
- mergeResult = mergeSliceToMain(
572
- base, milestoneId, sliceId, sliceEntry.title || sliceId,
573
- );
574
- }
575
- ctx.ui.notify(
576
- `Merged orphaned branch ${mergeResult.branch} → ${mainBranch}.`,
577
- "info",
578
- );
579
- } catch (error) {
580
- if (error instanceof MergeConflictError) {
581
- // Abort and reset the incomplete merge so auto-mode can still start cleanly.
582
- runGit(base, ["merge", "--abort"], { allowFailure: true });
583
- runGit(base, ["reset", "--hard", "HEAD"], { allowFailure: true });
584
- ctx.ui.notify(
585
- `Orphaned branch ${branch} has merge conflicts — resolve manually and restart.\nConflicts in: ${error.conflictedFiles.join(", ")}`,
586
- "error",
587
- );
588
- // Stop processing further branches after a conflict to avoid
589
- // leaving the repo in a partially-merged state.
590
- return;
591
- }
592
- const message = error instanceof Error ? error.message : String(error);
593
- ctx.ui.notify(
594
- `Failed to merge orphaned branch ${branch}: ${message}`,
595
- "warning",
596
- );
597
- }
598
- }
599
- }
600
-
601
506
  export async function startAuto(
602
507
  ctx: ExtensionCommandContext,
603
508
  pi: ExtensionAPI,
@@ -625,7 +530,8 @@ export async function startAuto(
625
530
  if (currentMilestoneId) setActiveMilestoneId(base, currentMilestoneId);
626
531
 
627
532
  // ── Auto-worktree: re-enter worktree on resume if not already inside ──
628
- if (currentMilestoneId && originalBasePath && !isInAutoWorktree(basePath) && shouldUseWorktreeIsolation(originalBasePath)) {
533
+ // Skip if already inside a worktree (manual /worktree) to prevent nesting.
534
+ if (currentMilestoneId && originalBasePath && !isInAutoWorktree(basePath) && !detectWorktreeName(basePath) && !detectWorktreeName(originalBasePath)) {
629
535
  try {
630
536
  const existingWtPath = getAutoWorktreePath(originalBasePath, currentMilestoneId);
631
537
  if (existingWtPath) {
@@ -673,7 +579,7 @@ export async function startAuto(
673
579
  return;
674
580
  }
675
581
 
676
- // Ensure git repo exists — GSD needs it for branch-per-slice
582
+ // Ensure git repo exists — GSD needs it for worktree isolation
677
583
  try {
678
584
  execSync("git rev-parse --git-dir", { cwd: base, stdio: "pipe" });
679
585
  } catch {
@@ -788,8 +694,22 @@ export async function startAuto(
788
694
 
789
695
  // ── Auto-worktree: create or enter worktree for the active milestone ──
790
696
  // Store the original project root before any chdir so we can restore on stop.
697
+ // Skip if already inside a worktree (manual /worktree or another auto-worktree)
698
+ // to prevent nested worktree creation.
791
699
  originalBasePath = base;
792
- if (currentMilestoneId && shouldUseWorktreeIsolation(base)) {
700
+
701
+ const isUnderGsdWorktrees = (p: string): boolean => {
702
+ // Prevent creating nested auto-worktrees when running from within any
703
+ // `.gsd/worktrees/...` directory (including manual worktrees).
704
+ const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
705
+ if (p.includes(marker)) {
706
+ return true;
707
+ }
708
+ const worktreesSuffix = `${pathSep}.gsd${pathSep}worktrees`;
709
+ return p.endsWith(worktreesSuffix);
710
+ };
711
+
712
+ if (currentMilestoneId && !detectWorktreeName(base) && !isUnderGsdWorktrees(base)) {
793
713
  try {
794
714
  const existingWtPath = getAutoWorktreePath(base, currentMilestoneId);
795
715
  if (existingWtPath) {
@@ -855,15 +775,46 @@ export async function startAuto(
855
775
  );
856
776
  }
857
777
 
858
- // Merge any orphaned completed slice branches before dispatching.
859
- // Orphaned branches arise when complete-slice commits on the slice branch
860
- // but the merge to main is interrupted (crash, timeout, Ctrl+C).
861
- // Without this check, GSD enters an infinite "Skipping ... Advancing" loop.
862
- await mergeOrphanedSliceBranches(base, ctx);
863
-
864
778
  // Self-heal: clear stale runtime records where artifacts already exist
865
779
  await selfHealRuntimeRecords(base, ctx);
866
780
 
781
+ // Self-heal: remove stale .git/index.lock from prior crash.
782
+ // A stale lock file blocks all git operations (commit, merge, checkout).
783
+ // Only remove if older than 60 seconds (not from a concurrent process).
784
+ try {
785
+ const gitLockFile = join(base, ".git", "index.lock");
786
+ if (existsSync(gitLockFile)) {
787
+ const lockAge = Date.now() - statSync(gitLockFile).mtimeMs;
788
+ if (lockAge > 60_000) {
789
+ unlinkSync(gitLockFile);
790
+ ctx.ui.notify("Removed stale .git/index.lock from prior crash.", "info");
791
+ }
792
+ }
793
+ } catch { /* non-fatal */ }
794
+
795
+ // Pre-flight: validate milestone queue for multi-milestone runs.
796
+ // Warn about issues that will cause auto-mode to pause or block.
797
+ try {
798
+ const msDir = join(base, ".gsd", "milestones");
799
+ if (existsSync(msDir)) {
800
+ const milestoneIds = readdirSync(msDir, { withFileTypes: true })
801
+ .filter(d => d.isDirectory() && /^M\d{3}/.test(d.name))
802
+ .map(d => d.name.match(/^(M\d{3})/)?.[1] ?? d.name);
803
+ if (milestoneIds.length > 1) {
804
+ const issues: string[] = [];
805
+ for (const id of milestoneIds) {
806
+ const draft = resolveMilestoneFile(base, id, "CONTEXT-DRAFT");
807
+ if (draft) issues.push(`${id}: has CONTEXT-DRAFT.md (will pause for discussion)`);
808
+ }
809
+ if (issues.length > 0) {
810
+ ctx.ui.notify(`Pre-flight: ${milestoneIds.length} milestones queued.\n${issues.map(i => ` ⚠ ${i}`).join("\n")}`, "warning");
811
+ } else {
812
+ ctx.ui.notify(`Pre-flight: ${milestoneIds.length} milestones queued. All have full context.`, "info");
813
+ }
814
+ }
815
+ }
816
+ } catch { /* non-fatal — pre-flight should never block auto-mode */ }
817
+
867
818
  // Dispatch the first unit
868
819
  await dispatchNextUnit(ctx, pi);
869
820
  }
@@ -939,17 +890,37 @@ export async function handleAgentEnd(
939
890
  // produced its expected artifact. If so, persist the completion key now so the
940
891
  // idempotency check at the top of dispatchNextUnit() skips it — even if
941
892
  // deriveState() still returns this unit as active (e.g. branch mismatch).
942
- try {
943
- if (verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath)) {
944
- const completionKey = `${currentUnit.type}/${currentUnit.id}`;
945
- if (!completedKeySet.has(completionKey)) {
946
- persistCompletedKey(basePath, completionKey);
947
- completedKeySet.add(completionKey);
893
+ //
894
+ // IMPORTANT: For non-hook units, defer persistence until after the hook check.
895
+ // If a post-unit hook requests a retry, we need to remove the completion key
896
+ // so dispatchNextUnit re-dispatches the trigger unit.
897
+ let triggerArtifactVerified = false;
898
+ if (!currentUnit.type.startsWith("hook/")) {
899
+ try {
900
+ triggerArtifactVerified = verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
901
+ if (triggerArtifactVerified) {
902
+ const completionKey = `${currentUnit.type}/${currentUnit.id}`;
903
+ if (!completedKeySet.has(completionKey)) {
904
+ persistCompletedKey(basePath, completionKey);
905
+ completedKeySet.add(completionKey);
906
+ }
907
+ invalidateStateCache();
948
908
  }
949
- invalidateStateCache();
909
+ } catch {
910
+ // Non-fatal — worst case we fall through to normal dispatch which has its own checks
911
+ }
912
+ } else {
913
+ // Hook unit completed — finalize its runtime record and clear it
914
+ try {
915
+ writeUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, {
916
+ phase: "finalized",
917
+ progressCount: 1,
918
+ lastProgressKind: "hook-completed",
919
+ });
920
+ clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
921
+ } catch {
922
+ // Non-fatal
950
923
  }
951
- } catch {
952
- // Non-fatal — worst case we fall through to normal dispatch which has its own checks
953
924
  }
954
925
  }
955
926
 
@@ -1005,6 +976,31 @@ export async function handleAgentEnd(
1005
976
  writeLock(basePath, hookUnit.unitType, hookUnit.unitId, completedUnits.length, sessionFile);
1006
977
  // Persist hook state so cycle counts survive crashes
1007
978
  persistHookState(basePath);
979
+
980
+ // Start supervision timers for hook units — hooks can get stuck just
981
+ // like normal units, and without a watchdog auto-mode would hang forever.
982
+ clearUnitTimeout();
983
+ const supervisor = resolveAutoSupervisorConfig();
984
+ const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
985
+ unitTimeoutHandle = setTimeout(async () => {
986
+ unitTimeoutHandle = null;
987
+ if (!active) return;
988
+ if (currentUnit) {
989
+ writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, currentUnit.startedAt, {
990
+ phase: "timeout",
991
+ timeoutAt: Date.now(),
992
+ });
993
+ }
994
+ ctx.ui.notify(
995
+ `Hook ${hookUnit.hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`,
996
+ "warning",
997
+ );
998
+ resetHookState();
999
+ await pauseAuto(ctx, pi);
1000
+ }, hookHardTimeoutMs);
1001
+
1002
+ // Guard against race with timeout/pause before sending
1003
+ if (!active) return;
1008
1004
  pi.sendMessage(
1009
1005
  { customType: "gsd-auto", content: hookUnit.prompt, display: verbose },
1010
1006
  { triggerTurn: true },
@@ -1016,6 +1012,11 @@ export async function handleAgentEnd(
1016
1012
  if (isRetryPending()) {
1017
1013
  const trigger = consumeRetryTrigger();
1018
1014
  if (trigger) {
1015
+ // Remove the trigger unit's completion key so dispatchNextUnit
1016
+ // will re-dispatch it instead of skipping it as already-complete.
1017
+ const triggerKey = `${trigger.unitType}/${trigger.unitId}`;
1018
+ completedKeySet.delete(triggerKey);
1019
+ removePersistedKey(basePath, triggerKey);
1019
1020
  ctx.ui.notify(
1020
1021
  `Hook requested retry of ${trigger.unitType} ${trigger.unitId}.`,
1021
1022
  "info",
@@ -1180,7 +1181,6 @@ function unitVerb(unitType: string): string {
1180
1181
  case "replan-slice": return "replanning";
1181
1182
  case "reassess-roadmap": return "reassessing";
1182
1183
  case "run-uat": return "running UAT";
1183
- case "fix-merge": return "resolving conflicts";
1184
1184
  default: return unitType;
1185
1185
  }
1186
1186
  }
@@ -1197,7 +1197,6 @@ function unitPhaseLabel(unitType: string): string {
1197
1197
  case "replan-slice": return "REPLAN";
1198
1198
  case "reassess-roadmap": return "REASSESS";
1199
1199
  case "run-uat": return "UAT";
1200
- case "fix-merge": return "MERGE-FIX";
1201
1200
  default: return unitType.toUpperCase();
1202
1201
  }
1203
1202
  }
@@ -1221,7 +1220,6 @@ function peekNext(unitType: string, state: GSDState): string {
1221
1220
  case "replan-slice": return `re-execute ${sid}`;
1222
1221
  case "reassess-roadmap": return "advance to next slice";
1223
1222
  case "run-uat": return "reassess roadmap";
1224
- case "fix-merge": return "continue merge";
1225
1223
  default: return "";
1226
1224
  }
1227
1225
  }
@@ -1493,6 +1491,16 @@ function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks
1493
1491
 
1494
1492
  // ─── Core Loop ────────────────────────────────────────────────────────────────
1495
1493
 
1494
+ /** Tracks recursive skip depth to prevent TUI freeze on cascading completed-unit skips */
1495
+ let _skipDepth = 0;
1496
+ const MAX_SKIP_DEPTH = 20;
1497
+
1498
+ /** Reentrancy guard for dispatchNextUnit itself (not just handleAgentEnd).
1499
+ * Prevents concurrent dispatch from watchdog timers, step wizard, and direct calls
1500
+ * that bypass the _handlingAgentEnd guard. Recursive calls (from skip paths) are
1501
+ * allowed via _skipDepth > 0. */
1502
+ let _dispatching = false;
1503
+
1496
1504
  async function dispatchNextUnit(
1497
1505
  ctx: ExtensionContext,
1498
1506
  pi: ExtensionAPI,
@@ -1504,6 +1512,22 @@ async function dispatchNextUnit(
1504
1512
  return;
1505
1513
  }
1506
1514
 
1515
+ // Reentrancy guard: allow recursive calls from skip paths (_skipDepth > 0)
1516
+ // but block concurrent external calls (watchdog, step wizard, etc.)
1517
+ if (_dispatching && _skipDepth === 0) {
1518
+ return; // Another dispatch is in progress — bail silently
1519
+ }
1520
+ _dispatching = true;
1521
+
1522
+ // Recursion depth guard: when many units are skipped in sequence (e.g., after
1523
+ // crash recovery with 10+ completed units), recursive dispatchNextUnit calls
1524
+ // can freeze the TUI or overflow the stack. Yield generously after MAX_SKIP_DEPTH.
1525
+ if (_skipDepth > MAX_SKIP_DEPTH) {
1526
+ _skipDepth = 0;
1527
+ ctx.ui.notify(`Skipped ${MAX_SKIP_DEPTH}+ completed units. Yielding to UI before continuing.`, "info");
1528
+ await new Promise(r => setTimeout(r, 200));
1529
+ }
1530
+
1507
1531
  // Clear stale directory listing cache so deriveState sees fresh disk state (#431)
1508
1532
  clearPathCache();
1509
1533
  // Clear parsed roadmap/plan cache — doctor may have re-populated it with
@@ -1551,9 +1575,9 @@ async function dispatchNextUnit(
1551
1575
  return;
1552
1576
  }
1553
1577
 
1554
- // ── Mid-merge safety check: detect leftover state from a prior fix-merge session ──
1555
- // If MERGE_HEAD or SQUASH_MSG exists, a fix-merge session ran previously.
1556
- // Check whether it succeeded (no unmerged entries → finalize) or failed (still conflicted reset + stop).
1578
+ // ── Mid-merge safety check: detect leftover merge state from a prior session ──
1579
+ // If MERGE_HEAD or SQUASH_MSG exists, check whether conflicts are resolved.
1580
+ // If resolved: finalize the commit. If still conflicted: abort and reset.
1557
1581
  {
1558
1582
  const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD");
1559
1583
  const squashMsgPath = join(basePath, ".git", "SQUASH_MSG");
@@ -1562,178 +1586,37 @@ async function dispatchNextUnit(
1562
1586
  if (hasMergeHead || hasSquashMsg) {
1563
1587
  const unmerged = runGit(basePath, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
1564
1588
  if (!unmerged || !unmerged.trim()) {
1565
- // fix-merge succeeded — finalize the commit if needed (squash or normal merge)
1566
- if (hasMergeHead || hasSquashMsg) {
1567
- try {
1568
- runGit(basePath, ["commit", "--no-edit"], { allowFailure: false });
1569
- const mode = hasMergeHead ? "merge" : "squash commit";
1570
- ctx.ui.notify(`Fix-merge session succeeded — finalized ${mode}.`, "info");
1571
- } catch {
1572
- // Commit may already exist; non-fatal
1573
- }
1589
+ // All conflicts resolved — finalize the merge/squash commit
1590
+ try {
1591
+ runGit(basePath, ["commit", "--no-edit"], { allowFailure: false });
1592
+ const mode = hasMergeHead ? "merge" : "squash commit";
1593
+ ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info");
1594
+ } catch {
1595
+ // Commit may already exist; non-fatal
1574
1596
  }
1575
- // Re-derive state from the now-merged working tree
1576
- invalidateStateCache();
1577
- clearParseCache();
1578
- clearPathCache();
1579
- state = await deriveState(basePath);
1580
- mid = state.activeMilestone?.id;
1581
- midTitle = state.activeMilestone?.title;
1582
1597
  } else {
1583
- // fix-merge failedstill has unresolved conflicts, abort merge/squash, reset and stop
1598
+ // Still conflicted — abort and reset
1584
1599
  if (hasMergeHead) {
1585
- // Properly abort an in-progress merge so MERGE_HEAD and related metadata are cleared
1586
1600
  runGit(basePath, ["merge", "--abort"], { allowFailure: true });
1587
1601
  } else if (hasSquashMsg) {
1588
- // Squash-in-progress without MERGE_HEAD: remove stale squash metadata
1589
- try {
1590
- unlinkSync(squashMsgPath);
1591
- } catch {
1592
- // Best-effort cleanup; ignore failures
1593
- }
1602
+ try { unlinkSync(squashMsgPath); } catch { /* best-effort */ }
1594
1603
  }
1595
1604
  runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
1596
1605
  ctx.ui.notify(
1597
- "Fix-merge session failed to resolve all conflicts. Working tree reset. Fix conflicts manually and restart.",
1598
- "error",
1606
+ "Detected leftover merge state with unresolved conflicts cleaned up. Re-deriving state.",
1607
+ "warning",
1599
1608
  );
1600
- if (currentUnit) {
1601
- const modelId = ctx.model?.id ?? "unknown";
1602
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1603
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1604
- }
1605
- await stopAuto(ctx, pi);
1606
- return;
1607
- }
1608
- }
1609
- }
1610
-
1611
- // ── General merge guard: merge completed slice branches before advancing ──
1612
- // If we're on a gsd/MID/SID branch and that slice is done (roadmap [x]),
1613
- // merge to main before dispatching the next unit. This handles:
1614
- // - Normal complete-slice → merge → reassess flow
1615
- // - LLM writes summary during task execution, skipping complete-slice
1616
- // - Doctor post-hook marks everything done, skipping complete-slice
1617
- // - complete-milestone runs on a slice branch (last slice bypass)
1618
- {
1619
- const currentBranch = getCurrentBranch(basePath);
1620
- const parsedBranch = parseSliceBranch(currentBranch);
1621
- if (parsedBranch) {
1622
- const branchMid = parsedBranch.milestoneId;
1623
- const branchSid = parsedBranch.sliceId;
1624
- // Check if this slice is marked done in the roadmap
1625
- const roadmapFile = resolveMilestoneFile(basePath, branchMid, "ROADMAP");
1626
- const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
1627
- if (roadmapContent) {
1628
- const roadmap = parseRoadmap(roadmapContent);
1629
- const sliceEntry = roadmap.slices.find(s => s.id === branchSid);
1630
- if (sliceEntry?.done) {
1631
- try {
1632
- const sliceTitleForMerge = sliceEntry.title || branchSid;
1633
- let mergeResult;
1634
- if (isInAutoWorktree(basePath) && getMergeToMainMode() !== "slice") {
1635
- mergeResult = mergeSliceToMilestone(
1636
- basePath, branchMid, branchSid, sliceTitleForMerge,
1637
- );
1638
- } else {
1639
- switchToMain(basePath);
1640
- mergeResult = mergeSliceToMain(
1641
- basePath, branchMid, branchSid, sliceTitleForMerge,
1642
- );
1643
- }
1644
- const targetBranch = getMainBranch(basePath);
1645
- ctx.ui.notify(
1646
- `Merged ${mergeResult.branch} → ${targetBranch}.`,
1647
- "info",
1648
- );
1649
- // Re-derive state from main so downstream logic sees merged state
1650
- invalidateStateCache();
1651
- clearParseCache();
1652
- clearPathCache();
1653
- state = await deriveState(basePath);
1654
- mid = state.activeMilestone?.id;
1655
- midTitle = state.activeMilestone?.title;
1656
- } catch (error) {
1657
- // MergeConflictError: dispatch a fix-merge session to resolve conflicts
1658
- if (error instanceof MergeConflictError) {
1659
- const fixMergeUnitId = `${parsedBranch.milestoneId}/${parsedBranch.sliceId}`;
1660
- const fixMergePrompt = buildFixMergePrompt(error);
1661
- ctx.ui.notify(
1662
- `Merge conflict in ${error.conflictedFiles.length} file(s) — dispatching fix-merge session.`,
1663
- "warning",
1664
- );
1665
-
1666
- // Close out the previously active unit before overwriting currentUnit.
1667
- if (currentUnit) {
1668
- const modelId = ctx.model?.id ?? "unknown";
1669
- snapshotUnitMetrics(
1670
- ctx,
1671
- currentUnit.type,
1672
- currentUnit.id,
1673
- currentUnit.startedAt,
1674
- modelId,
1675
- );
1676
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1677
- }
1678
-
1679
- // Dispatch fix-merge as the next unit (early-dispatch-and-return)
1680
- const fixMergeUnitType = "fix-merge";
1681
- currentUnit = { type: fixMergeUnitType, id: fixMergeUnitId, startedAt: Date.now() };
1682
- writeUnitRuntimeRecord(basePath, fixMergeUnitType, fixMergeUnitId, currentUnit.startedAt, {
1683
- phase: "dispatched",
1684
- wrapupWarningSent: false,
1685
- timeoutAt: null,
1686
- lastProgressAt: currentUnit.startedAt,
1687
- progressCount: 0,
1688
- lastProgressKind: "dispatch",
1689
- });
1690
- updateProgressWidget(ctx, fixMergeUnitType, fixMergeUnitId, state);
1691
- const result = await cmdCtx!.newSession();
1692
- if (result.cancelled) {
1693
- runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
1694
- await stopAuto(ctx, pi);
1695
- return;
1696
- }
1697
- const sessionFile = ctx.sessionManager.getSessionFile();
1698
- writeLock(basePath, fixMergeUnitType, fixMergeUnitId, completedUnits.length, sessionFile);
1699
- pi.sendMessage(
1700
- { customType: "gsd-auto", content: fixMergePrompt, display: verbose },
1701
- { triggerTurn: true },
1702
- );
1703
- return;
1704
- }
1705
-
1706
- // Non-conflict errors: reset and stop
1707
- const message = formatGitError(error instanceof Error ? error : String(error));
1708
- try {
1709
- const status = runGit(basePath, ["status", "--porcelain"], { allowFailure: true });
1710
- if (status && (status.includes("UU ") || status.includes("AA ") || status.includes("UD "))) {
1711
- runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
1712
- ctx.ui.notify(
1713
- `Cleaned up conflicted merge state after failed squash-merge.`,
1714
- "warning",
1715
- );
1716
- }
1717
- } catch { /* best-effort cleanup */ }
1718
-
1719
- ctx.ui.notify(
1720
- `Slice merge failed — stopping auto-mode. Fix conflicts manually and restart.\n${message}`,
1721
- "error",
1722
- );
1723
- if (currentUnit) {
1724
- const modelId = ctx.model?.id ?? "unknown";
1725
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1726
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1727
- }
1728
- await stopAuto(ctx, pi);
1729
- return;
1730
- }
1731
- }
1732
1609
  }
1610
+ invalidateStateCache();
1611
+ clearParseCache();
1612
+ clearPathCache();
1613
+ state = await deriveState(basePath);
1614
+ mid = state.activeMilestone?.id;
1615
+ midTitle = state.activeMilestone?.title;
1733
1616
  }
1734
1617
  }
1735
1618
 
1736
- // After merge, mid/midTitle may have been re-derived and could be undefined
1619
+ // After merge guard removal (branchless architecture), mid/midTitle could be undefined
1737
1620
  if (!mid || !midTitle) {
1738
1621
  if (currentUnit) {
1739
1622
  const modelId = ctx.model?.id ?? "unknown";
@@ -1763,7 +1646,7 @@ async function dispatchNextUnit(
1763
1646
  } catch { /* non-fatal */ }
1764
1647
 
1765
1648
  // ── Milestone merge: squash-merge milestone branch to main before stopping ──
1766
- if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath && getMergeToMainMode() === "milestone") {
1649
+ if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath) {
1767
1650
  try {
1768
1651
  const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP");
1769
1652
  const roadmapContent = readFileSync(roadmapPath, "utf-8");
@@ -1852,7 +1735,7 @@ async function dispatchNextUnit(
1852
1735
 
1853
1736
  // ── Phase-first dispatch: complete-slice MUST run before reassessment ──
1854
1737
  // If the current phase is "summarizing", complete-slice is responsible for
1855
- // mergeSliceToMain. Reassessment must wait until the merge is done.
1738
+ // complete-slice must run before reassessment.
1856
1739
  if (state.phase === "summarizing") {
1857
1740
  const sid = state.activeSlice!.id;
1858
1741
  const sTitle = state.activeSlice!.title;
@@ -2024,10 +1907,10 @@ async function dispatchNextUnit(
2024
1907
  `Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
2025
1908
  "info",
2026
1909
  );
2027
- // Yield to the event loop before re-dispatching to avoid tight recursion
2028
- // when many units are already completed (e.g., after crash recovery).
2029
- await new Promise(r => setImmediate(r));
1910
+ _skipDepth++;
1911
+ await new Promise(r => setTimeout(r, 50));
2030
1912
  await dispatchNextUnit(ctx, pi);
1913
+ _skipDepth = Math.max(0, _skipDepth - 1);
2031
1914
  return;
2032
1915
  } else {
2033
1916
  // Stale completion record — artifact missing. Remove and re-run.
@@ -2040,6 +1923,26 @@ async function dispatchNextUnit(
2040
1923
  }
2041
1924
  }
2042
1925
 
1926
+ // Fallback: if the idempotency key is missing but the expected artifact already
1927
+ // exists on disk, the task completed in a prior session without persisting the key.
1928
+ // Persist it now and skip re-dispatch. This prevents infinite loops where a task
1929
+ // completes successfully but the completion key was never written (e.g., completed
1930
+ // on the first attempt before hitting the retry-threshold persistence logic).
1931
+ if (verifyExpectedArtifact(unitType, unitId, basePath)) {
1932
+ persistCompletedKey(basePath, idempotencyKey);
1933
+ completedKeySet.add(idempotencyKey);
1934
+ invalidateStateCache();
1935
+ ctx.ui.notify(
1936
+ `Skipping ${unitType} ${unitId} — artifact exists but completion key was missing. Repaired and advancing.`,
1937
+ "info",
1938
+ );
1939
+ _skipDepth++;
1940
+ await new Promise(r => setTimeout(r, 50));
1941
+ await dispatchNextUnit(ctx, pi);
1942
+ _skipDepth = Math.max(0, _skipDepth - 1);
1943
+ return;
1944
+ }
1945
+
2043
1946
  // Stuck detection — tracks total dispatches per unit (not just consecutive repeats).
2044
1947
  // Pattern A→B→A→B would reset retryCount every time; this map catches it.
2045
1948
  const dispatchKey = `${unitType}/${unitId}`;
@@ -2127,6 +2030,29 @@ async function dispatchNextUnit(
2127
2030
  return;
2128
2031
  }
2129
2032
 
2033
+ // Last resort for complete-milestone: generate stub summary to unblock pipeline.
2034
+ // All slices are done (otherwise we wouldn't be in completing-milestone phase),
2035
+ // but the LLM failed to write the summary N times. A stub lets the pipeline advance.
2036
+ if (unitType === "complete-milestone") {
2037
+ try {
2038
+ const mPath = resolveMilestonePath(basePath, unitId);
2039
+ if (mPath) {
2040
+ const stubPath = join(mPath, `${unitId}-SUMMARY.md`);
2041
+ if (!existsSync(stubPath)) {
2042
+ writeFileSync(stubPath, `# ${unitId} Summary\n\nAuto-generated stub — milestone tasks completed but summary generation failed after ${prevCount + 1} attempts.\nReview and replace this stub with a proper summary.\n`);
2043
+ ctx.ui.notify(`Generated stub summary for ${unitId} to unblock pipeline. Review later.`, "warning");
2044
+ persistCompletedKey(basePath, dispatchKey);
2045
+ completedKeySet.add(dispatchKey);
2046
+ unitDispatchCount.delete(dispatchKey);
2047
+ invalidateStateCache();
2048
+ await new Promise(r => setImmediate(r));
2049
+ await dispatchNextUnit(ctx, pi);
2050
+ return;
2051
+ }
2052
+ }
2053
+ } catch { /* non-fatal — fall through to normal stop */ }
2054
+ }
2055
+
2130
2056
  const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
2131
2057
  const remediation = buildLoopRemediationSteps(unitType, unitId, basePath);
2132
2058
  await stopAuto(ctx, pi);
@@ -2207,12 +2133,19 @@ async function dispatchNextUnit(
2207
2133
  // Only mark the previous unit as completed if:
2208
2134
  // 1. We're not about to re-dispatch the same unit (retry scenario)
2209
2135
  // 2. The expected artifact actually exists on disk
2136
+ // For hook units, skip artifact verification — hooks don't produce standard
2137
+ // artifacts and their runtime records were already finalized in handleAgentEnd.
2210
2138
  const closeoutKey = `${currentUnit.type}/${currentUnit.id}`;
2211
2139
  const incomingKey = `${unitType}/${unitId}`;
2212
- const artifactVerified = verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
2140
+ const isHookUnit = currentUnit.type.startsWith("hook/");
2141
+ const artifactVerified = isHookUnit || verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
2213
2142
  if (closeoutKey !== incomingKey && artifactVerified) {
2214
- persistCompletedKey(basePath, closeoutKey);
2215
- completedKeySet.add(closeoutKey);
2143
+ if (!isHookUnit) {
2144
+ // Only persist completion keys for real units — hook keys are
2145
+ // ephemeral and should not pollute the idempotency set.
2146
+ persistCompletedKey(basePath, closeoutKey);
2147
+ completedKeySet.add(closeoutKey);
2148
+ }
2216
2149
 
2217
2150
  completedUnits.push({
2218
2151
  type: currentUnit.type,
@@ -3136,45 +3069,6 @@ async function buildReassessRoadmapPrompt(
3136
3069
  });
3137
3070
  }
3138
3071
 
3139
- /**
3140
- * Build a prompt for the fix-merge LLM session that resolves merge conflicts.
3141
- */
3142
- function buildFixMergePrompt(err: MergeConflictError): string {
3143
- const strategyLabel = err.strategy === "merge" ? "merge --no-ff" : "squash merge";
3144
- const fileList = err.conflictedFiles.map(f => ` - \`${f}\``).join("\n");
3145
-
3146
- return [
3147
- `# Fix Merge Conflicts`,
3148
- ``,
3149
- `A ${strategyLabel} of branch \`${err.branch}\` into \`${err.mainBranch}\` produced conflicts in the following files:`,
3150
- ``,
3151
- fileList,
3152
- ``,
3153
- `## Instructions`,
3154
- ``,
3155
- `1. Read each conflicted file listed above`,
3156
- `2. Resolve all conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) by choosing the correct content`,
3157
- `3. Stage the resolved files with \`git add <file>\``,
3158
- `4. Commit the resolution:`,
3159
- err.strategy === "squash"
3160
- ? ` - This is a squash merge, so run: \`git commit --no-edit\` (the squash message is already prepared)`
3161
- : ` - This is a --no-ff merge, so run: \`git commit --no-edit\` (the merge message is already prepared)`,
3162
- ``,
3163
- `## Rules`,
3164
- ``,
3165
- `- Do NOT run \`git merge --abort\` or \`git reset\``,
3166
- `- Do NOT modify any files other than the conflicted ones listed above`,
3167
- `- Preserve the intent of both sides of the conflict — prefer the slice branch changes when the intent is unclear`,
3168
- ``,
3169
- `## Verification`,
3170
- ``,
3171
- `After committing, verify:`,
3172
- `1. \`git diff --name-only --diff-filter=U\` returns empty (no unmerged files)`,
3173
- `2. The conflicted files no longer contain any \`<<<<<<<\`, \`=======\`, or \`>>>>>>>\` markers`,
3174
- `3. \`git status\` shows a clean working tree`,
3175
- ].join("\n");
3176
- }
3177
-
3178
3072
  function extractSliceExecutionExcerpt(content: string | null, relPath: string): string {
3179
3073
  if (!content) {
3180
3074
  return [
@@ -3342,10 +3236,6 @@ function ensurePreconditions(
3342
3236
  }
3343
3237
  }
3344
3238
 
3345
- if (["research-slice", "plan-slice", "execute-task", "complete-slice", "replan-slice"].includes(unitType) && parts.length >= 2) {
3346
- const sid = parts[1]!;
3347
- ensureSliceBranch(base, mid, sid);
3348
- }
3349
3239
  }
3350
3240
 
3351
3241
  // ─── Diagnostics ──────────────────────────────────────────────────────────────
@@ -3752,8 +3642,6 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
3752
3642
  const dir = resolveMilestonePath(base, mid);
3753
3643
  return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null;
3754
3644
  }
3755
- case "fix-merge":
3756
- return null;
3757
3645
  default:
3758
3646
  return null;
3759
3647
  }
@@ -3772,14 +3660,10 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
3772
3660
  // Clear stale directory listing cache so artifact checks see fresh disk state (#431)
3773
3661
  clearPathCache();
3774
3662
 
3775
- // fix-merge has no file artifact — verify by checking git state
3776
- if (unitType === "fix-merge") {
3777
- const unmerged = runGit(base, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
3778
- if (unmerged && unmerged.trim()) return false;
3779
- if (existsSync(join(base, ".git", "MERGE_HEAD"))) return false;
3780
- if (existsSync(join(base, ".git", "SQUASH_MSG"))) return false;
3781
- return true;
3782
- }
3663
+ // Hook units have no standard artifact — always pass. Their lifecycle
3664
+ // is managed by the hook engine, not the artifact verification system.
3665
+ if (unitType.startsWith("hook/")) return true;
3666
+
3783
3667
 
3784
3668
  const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
3785
3669
  // Unit types with no verifiable artifact always pass (e.g. replan-slice).
@@ -3887,8 +3771,6 @@ function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string
3887
3771
  return `${relSliceFile(base, mid!, sid!, "UAT-RESULT")} (UAT result)`;
3888
3772
  case "complete-milestone":
3889
3773
  return `${relMilestoneFile(base, mid!, "SUMMARY")} (milestone summary)`;
3890
- case "fix-merge":
3891
- return "Clean working tree with no unmerged files, no MERGE_HEAD, no SQUASH_MSG (merge conflict resolution)";
3892
3774
  default:
3893
3775
  return null;
3894
3776
  }