fix: check bootstrap completeness in init wizard gate, not just .gsd/ existence (#2942) (#3237)

A zombie .gsd/ state (symlink exists but missing PREFERENCES.md and
milestones/) caused the init wizard to be skipped entirely, resulting in
an uninitialized project session.

- guided-flow.ts: Replace bare `!existsSync(gsdRoot(basePath))` with a
  compound check for PREFERENCES.md or milestones/ bootstrap artifacts
- auto-start.ts: Check milestones/ path directly instead of .gsd/ which
  ensureGsdSymlink already created (was dead code)
- Add zombie-gsd-state.test.ts verifying both fixes

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:31:10 -04:00 committed by GitHub
parent 0cedaf5fb9
commit 4a82bc01dc
3 changed files with 111 additions and 5 deletions

View file

@ -198,10 +198,13 @@ export async function bootstrapAutoSession(
ensureGitignore(base, { manageGitignore });
if (manageGitignore !== false) untrackRuntimeFiles(base);
// Bootstrap .gsd/ if it doesn't exist
// Bootstrap milestones/ if it doesn't exist.
// Check milestones/ directly — ensureGsdSymlink above already created .gsd/,
// so checking .gsd/ existence would be dead code (#2942).
const gsdDir = join(base, ".gsd");
if (!existsSync(gsdDir)) {
mkdirSync(join(gsdDir, "milestones"), { recursive: true });
const milestonesPath = join(gsdDir, "milestones");
if (!existsSync(milestonesPath)) {
mkdirSync(milestonesPath, { recursive: true });
try {
nativeAddAll(base);
nativeCommit(base, "chore: init gsd");

View file

@ -981,7 +981,15 @@ export async function showSmartEntry(
}
// ── Detection preamble — run before any bootstrap ────────────────────
if (!existsSync(gsdRoot(basePath))) {
// Check bootstrap completeness, not just .gsd/ directory existence.
// A zombie .gsd/ state (symlink exists but missing PREFERENCES.md and
// milestones/) must trigger the init wizard, not skip it (#2942).
const gsdPath = gsdRoot(basePath);
const hasBootstrapArtifacts = existsSync(gsdPath)
&& (existsSync(join(gsdPath, "PREFERENCES.md"))
|| existsSync(join(gsdPath, "milestones")));
if (!hasBootstrapArtifacts) {
const detection = detectProjectState(basePath);
// v1 .planning/ detected — offer migration before anything else
@ -996,7 +1004,7 @@ export async function showSmartEntry(
// "fresh" — fall through to init wizard
}
// No .gsd/ — run the project init wizard
// No .gsd/ or zombie .gsd/ — run the project init wizard
const result = await showProjectInit(ctx, pi, basePath, detection);
if (!result.completed) return; // User cancelled

View file

@ -0,0 +1,95 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createTestContext } from "./test-helpers.ts";
const { assertTrue, assertMatch, assertNoMatch, report } = createTestContext();
// ─── #2942: Zombie .gsd state skips init wizard ─────────────────────────────
//
// A partially initialized .gsd/ (symlink exists but no PREFERENCES.md or
// milestones/) causes the init wizard gate in showSmartEntry to be skipped,
// resulting in an uninitialized project session.
console.log("\n=== #2942: zombie .gsd state must not skip init wizard ===");
// ── guided-flow.ts — init wizard gate must check bootstrap completeness ──
const guidedFlowSrc = readFileSync(
join(import.meta.dirname, "..", "guided-flow.ts"),
"utf-8",
);
// Find the showSmartEntry function
const smartEntryIdx = guidedFlowSrc.indexOf("export async function showSmartEntry(");
assertTrue(smartEntryIdx >= 0, "guided-flow.ts defines showSmartEntry");
// Extract the region between showSmartEntry and the first showProjectInit call
// This is where the init wizard gate lives.
const afterSmartEntry = smartEntryIdx >= 0 ? guidedFlowSrc.slice(smartEntryIdx, smartEntryIdx + 3000) : "";
// The gate must NOT be a bare `!existsSync(gsdRoot(basePath))` check.
// It must also verify that bootstrap artifacts (PREFERENCES.md or milestones/) exist.
assertTrue(
afterSmartEntry.includes("PREFERENCES.md") || afterSmartEntry.includes("PREFERENCES"),
"init wizard gate checks for PREFERENCES.md, not just .gsd/ existence (#2942)",
);
assertTrue(
afterSmartEntry.includes("milestones"),
"init wizard gate checks for milestones/ directory, not just .gsd/ existence (#2942)",
);
// The init wizard should be shown when .gsd/ exists but has no bootstrap artifacts.
// The old code was: if (!existsSync(gsdRoot(basePath))) { ... showProjectInit ... }
// The fix should use a compound check so zombie states trigger the wizard.
// Verify we no longer have the bare existence check as the sole gate.
// Find the specific init wizard gate pattern — the detection preamble block.
const detectionPreambleIdx = afterSmartEntry.indexOf("Detection preamble");
const detectionRegion = detectionPreambleIdx >= 0
? afterSmartEntry.slice(detectionPreambleIdx, detectionPreambleIdx + 600)
: afterSmartEntry.slice(0, 1500);
// The gate condition must reference PREFERENCES.md or milestones (bootstrap artifacts)
assertMatch(
detectionRegion,
/PREFERENCES\.md|milestones/,
"detection preamble gate references bootstrap artifacts, not just directory existence (#2942)",
);
// ── auto-start.ts — milestones/ dir creation must not be dead code ──────────
console.log("\n=== #2942: auto-start milestones/ bootstrap not dead code ===");
const autoStartSrc = readFileSync(
join(import.meta.dirname, "..", "auto-start.ts"),
"utf-8",
);
// After ensureGsdSymlink, the code that creates milestones/ must check for
// the milestones directory specifically (not .gsd/ which ensureGsdSymlink already created).
const symlinkIdx = autoStartSrc.indexOf("ensureGsdSymlink(base)");
assertTrue(symlinkIdx >= 0, "auto-start.ts calls ensureGsdSymlink(base)");
const afterSymlink = symlinkIdx >= 0 ? autoStartSrc.slice(symlinkIdx, symlinkIdx + 800) : "";
// The milestones bootstrap must check milestones path, not gsdDir
// Old (dead) code: if (!existsSync(gsdDir)) { mkdirSync(join(gsdDir, "milestones"), ...) }
// Fixed code should check: if (!existsSync(milestonesPath)) or similar
assertTrue(
afterSymlink.includes("milestones") && afterSymlink.includes("mkdirSync"),
"auto-start.ts creates milestones/ directory after ensureGsdSymlink (#2942)",
);
// The guard for milestones/ creation should NOT be `!existsSync(gsdDir)` —
// that's dead code since ensureGsdSymlink already created gsdDir.
// It should check for the milestones/ dir directly.
const mkdirRegion = afterSymlink.slice(0, afterSymlink.indexOf("mkdirSync") + 200);
assertMatch(
mkdirRegion,
/existsSync\([^)]*milestones/,
"milestones bootstrap checks milestones path existence, not .gsd/ (#2942)",
);
report();