diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index 0f531325d..e3232f5a7 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -6,7 +6,7 @@ * manages create, enter, detect, and teardown for auto-mode worktrees. */ -import { existsSync, cpSync, readFileSync, realpathSync, utimesSync } from "node:fs"; +import { existsSync, cpSync, readFileSync, writeFileSync, readdirSync, mkdirSync, realpathSync, utimesSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { copyWorktreeDb, reconcileWorktreeDb, isDbAvailable } from "./gsd-db.js"; import { execSync, execFileSync } from "node:child_process"; @@ -134,6 +134,112 @@ export function autoWorktreeBranch(milestoneId: string): string { * Atomic: chdir + originalBase update happen in the same try block * to prevent split-brain. */ + +/** + * Forward-merge plan checkbox state from the project root into a freshly + * re-attached worktree (#778). + * + * When auto-mode stops via crash (not graceful stop), the milestone branch + * HEAD may be behind the filesystem state at the project root because + * syncStateToProjectRoot() runs after every task completion but the final + * git commit may not have happened before the crash. On restart the worktree + * is re-attached to the branch HEAD, which has [ ] for the crashed task, + * causing verifyExpectedArtifact() to fail and triggering an infinite + * dispatch/skip loop. + * + * Fix: after re-attaching, read every *.md plan file in the milestone + * directory at the project root and apply any [x] checkbox states that are + * ahead of the worktree version (forward-only: never downgrade [x] → [ ]). + * + * This is safe because syncStateToProjectRoot() is the authoritative source + * of post-task state at the project root — it writes the same [x] the LLM + * produced, then the auto-commit follows. If the commit never happened, the + * filesystem copy is still valid and correct. + */ +function reconcilePlanCheckboxes(projectRoot: string, wtPath: string, milestoneId: string): void { + const srcMilestone = join(projectRoot, ".gsd", "milestones", milestoneId); + const dstMilestone = join(wtPath, ".gsd", "milestones", milestoneId); + if (!existsSync(srcMilestone) || !existsSync(dstMilestone)) return; + + // Walk all markdown files in the milestone directory (plans, summaries, etc.) + function walkMd(dir: string): string[] { + const results: string[] = []; + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkMd(full)); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + results.push(full); + } + } + } catch { /* non-fatal */ } + return results; + } + + for (const srcFile of walkMd(srcMilestone)) { + const rel = srcFile.slice(srcMilestone.length); + const dstFile = dstMilestone + rel; + if (!existsSync(dstFile)) continue; // only reconcile existing files + + let srcContent: string; + let dstContent: string; + try { + srcContent = readFileSync(srcFile, "utf-8"); + dstContent = readFileSync(dstFile, "utf-8"); + } catch { continue; } + + if (srcContent === dstContent) continue; + + // Extract all checked task IDs from the source (project root) + // Pattern: - [x] **T: or - [x] **S: (case-insensitive x) + const checkedRe = /^- \[[xX]\] \*\*([TS]\d+):/gm; + const srcChecked = new Set(); + for (const m of srcContent.matchAll(checkedRe)) srcChecked.add(m[1]); + + if (srcChecked.size === 0) continue; + + // Forward-apply: replace [ ] → [x] for any IDs that are checked in src + let updated = dstContent; + let changed = false; + for (const id of srcChecked) { + const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const uncheckedRe = new RegExp(`^(- )\\[ \\]( \\*\\*${escapedId}:)`, "gm"); + if (uncheckedRe.test(updated)) { + updated = updated.replace( + new RegExp(`^(- )\\[ \\]( \\*\\*${escapedId}:)`, "gm"), + "$1[x]$2", + ); + changed = true; + } + } + + if (changed) { + try { + writeFileSync(dstFile, updated, "utf-8"); + } catch { /* non-fatal */ } + } + } + + // Also forward-merge completed-units.json (set-union) + const srcKeys = join(projectRoot, ".gsd", "completed-units.json"); + const dstKeys = join(wtPath, ".gsd", "completed-units.json"); + if (existsSync(srcKeys)) { + try { + const src: string[] = JSON.parse(readFileSync(srcKeys, "utf-8")); + let dst: string[] = []; + if (existsSync(dstKeys)) { + try { dst = JSON.parse(readFileSync(dstKeys, "utf-8")); } catch { /* ignore corrupt */ } + } + const merged = [...new Set([...dst, ...src])]; + if (merged.length > dst.length) { + mkdirSync(join(wtPath, ".gsd"), { recursive: true }); + writeFileSync(dstKeys, JSON.stringify(merged), "utf-8"); + } + } catch { /* non-fatal */ } + } +} + export function createAutoWorktree(basePath: string, milestoneId: string): string { const branch = autoWorktreeBranch(milestoneId); @@ -166,6 +272,18 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin // not always fully synced. if (!branchExists) { copyPlanningArtifacts(basePath, info.path); + } else { + // Re-attaching to an existing branch: forward-merge any plan checkpoint + // state from the project root into the worktree (#778). + // + // If auto-mode stopped via crash, the milestone branch HEAD may lag behind + // the project root filesystem because syncStateToProjectRoot() ran after + // task completion but the auto-commit never fired. On restart the worktree + // is re-created from the branch HEAD (which has [ ] for the crashed task), + // causing verifyExpectedArtifact() to return false → stale-key eviction → + // infinite dispatch/skip loop. Reconciling here ensures the worktree sees + // the same [x] state that syncStateToProjectRoot() wrote to the root. + reconcilePlanCheckboxes(basePath, info.path, milestoneId); } // Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets diff --git a/src/resources/extensions/gsd/tests/auto-worktree.test.ts b/src/resources/extensions/gsd/tests/auto-worktree.test.ts index abb93baa2..5ddd3c0f1 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree.test.ts @@ -153,6 +153,64 @@ async function main(): Promise { // After teardown, originalBase should be null assertEq(getAutoWorktreeOriginalBase(), null, "no split-brain: originalBase cleared"); + // ─── #778: reconcile plan checkboxes on re-attach ───────────────── + console.log("\n=== #778: reconcile plan checkboxes on re-attach ==="); + { + // Simulate: T01 [x] was committed to milestone branch, T02 [x] was + // written to project root by syncStateToProjectRoot() but the + // auto-commit crashed before it fired. On restart the worktree is + // re-created from the milestone branch HEAD (T02 still [ ]). + // reconcilePlanCheckboxes should forward-apply T02 [x] from the root. + + const planRelPath = join(".gsd", "milestones", "M004", "slices", "S01", "S01-PLAN.md"); + const planDir = join(tempDir, ".gsd", "milestones", "M004", "slices", "S01"); + const { mkdirSync: mkdir, writeFileSync: write, readFileSync: read } = await import("node:fs"); + + // Plan on integration branch (project root): T01 [x], T02 [x] + mkdir(planDir, { recursive: true }); + write( + join(tempDir, planRelPath), + "# S01 Plan\n- [x] **T01:** task one\n- [x] **T02:** task two\n- [ ] **T03:** task three\n", + ); + + // Write integration-branch plan to git so milestone branch starts from it + run(`git add .`, tempDir); + run(`git commit -m "add plan with T01 and T02 checked" --allow-empty`, tempDir); + + // Create milestone branch with only T01 [x] (simulating crash before T02 commit) + const milestoneBranch = "milestone/M004"; + run(`git checkout -b ${milestoneBranch}`, tempDir); + mkdir(planDir, { recursive: true }); + write( + join(tempDir, planRelPath), + "# S01 Plan\n- [x] **T01:** task one\n- [ ] **T02:** task two\n- [ ] **T03:** task three\n", + ); + run(`git add .`, tempDir); + run(`git commit -m "milestone: only T01 checked"`, tempDir); + run(`git checkout main`, tempDir); + + // Restore project root plan (T01+T02 [x]) — simulates syncStateToProjectRoot + write( + join(tempDir, planRelPath), + "# S01 Plan\n- [x] **T01:** task one\n- [x] **T02:** task two\n- [ ] **T03:** task three\n", + ); + + // Create worktree re-attached to existing milestone branch (T02 still [ ] in branch) + const wtPath = createAutoWorktree(tempDir, "M004"); + + try { + const wtPlanPath = join(wtPath, planRelPath); + assertTrue(existsSync(wtPlanPath), "plan file exists in worktree after re-attach"); + + const wtPlan = read(wtPlanPath, "utf-8"); + assertTrue(wtPlan.includes("- [x] **T02:"), "T02 should be [x] after reconciliation (was [ ] on branch)"); + assertTrue(wtPlan.includes("- [x] **T01:"), "T01 stays [x]"); + assertTrue(wtPlan.includes("- [ ] **T03:"), "T03 stays [ ] (not in root either)"); + } finally { + teardownAutoWorktree(tempDir, "M004"); + } + } + } finally { // Always restore cwd and clean up process.chdir(savedCwd);