singularity-forge/src/resources/extensions/gsd/init-wizard.ts
ace-pm 172753c3b2 refactor(forge): complete gsd → forge rebrand across native, logging, and build system
- Rename native Rust crates: gsd-engine → forge-engine, gsd-ast → forge-ast, gsd-grep → forge-grep
- Update all crate dependencies (Cargo.toml, .rs source) and N-API artifacts
- Mass rename log prefix [gsd] → [forge] across 81 files (scripts, src/, extensions, tests)
- Rename log prefix "gsd-db:" → "forge-db:" in template literals
- Update nix flake: add sf-run-native devShell with Rust toolchain for native addon builds
- Update CI workflow artifact names (build-native.yml)
- Verify only packages/native/* touched (no upstream pi-* packages renamed)

Rationale: Complete gsd-2 → singularity-forge rebrand (2026-04-15). Native addon is
sf-run-specific; all gsd-prefixed logging and crate names must align with new identity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:11:45 +02:00

638 lines
23 KiB
TypeScript

/**
* GSD Init Wizard — Per-project onboarding.
*
* Guides users through project setup when entering a directory without .gsd/.
* Detects project ecosystem, offers v1 migration, configures project preferences,
* bootstraps .gsd/ structure, and transitions to the first milestone discussion.
*/
import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent";
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { showNextAction } from "../shared/tui.js";
import { nativeIsRepo, nativeInit } from "./native-git-bridge.js";
import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js";
import { gsdRoot } from "./paths.js";
import { assertSafeDirectory } from "./validate-directory.js";
import type { ProjectDetection, ProjectSignals } from "./detection.js";
import { runSkillInstallStep } from "./skill-catalog.js";
import { generateCodebaseMap, writeCodebaseMap } from "./codebase-generator.js";
// ─── Types ──────────────────────────────────────────────────────────────────────
interface InitWizardResult {
/** Whether the wizard completed (vs cancelled) */
completed: boolean;
/** Whether .gsd/ was created */
bootstrapped: boolean;
}
interface ProjectPreferences {
mode: "solo" | "team";
gitIsolation: "worktree" | "branch" | "none";
mainBranch: string;
verificationCommands: string[];
customInstructions: string[];
tokenProfile: "budget" | "balanced" | "quality" | "burn-max";
skipResearch: boolean;
autoPush: boolean;
}
// ─── Defaults ───────────────────────────────────────────────────────────────────
const DEFAULT_PREFS: ProjectPreferences = {
mode: "solo",
gitIsolation: "worktree",
mainBranch: "main",
verificationCommands: [],
customInstructions: [],
tokenProfile: "balanced",
skipResearch: false,
autoPush: true,
};
// ─── Main Wizard ────────────────────────────────────────────────────────────────
/**
* Run the project init wizard.
* Called when entering a directory without .gsd/ (or via /gsd init).
*/
export async function showProjectInit(
ctx: ExtensionCommandContext,
pi: ExtensionAPI,
basePath: string,
detection: ProjectDetection,
): Promise<InitWizardResult> {
const signals = detection.projectSignals;
const prefs = { ...DEFAULT_PREFS };
// ── Step 1: Show what we detected ──────────────────────────────────────────
const detectionSummary = buildDetectionSummary(signals);
if (detectionSummary.length > 0) {
ctx.ui.notify(`Project detected:\n${detectionSummary.join("\n")}`, "info");
}
// ── Step 2: Git setup ──────────────────────────────────────────────────────
if (!signals.isGitRepo) {
const gitChoice = await showNextAction(ctx, {
title: "GSD — Project Setup",
summary: ["This folder is not a git repository. GSD uses git for version control and isolation."],
actions: [
{ id: "init_git", label: "Initialize git", description: "Create a git repo in this folder", recommended: true },
{ id: "skip_git", label: "Skip", description: "Continue without git (limited functionality)" },
],
notYetMessage: "Run /gsd init when ready.",
});
if (gitChoice === "not_yet") return { completed: false, bootstrapped: false };
if (gitChoice === "init_git") {
nativeInit(basePath, prefs.mainBranch);
}
} else {
// Auto-detect main branch from existing repo
const detectedBranch = detectMainBranch(basePath);
if (detectedBranch) prefs.mainBranch = detectedBranch;
}
// ── Step 3: Mode selection ─────────────────────────────────────────────────
const modeChoice = await showNextAction(ctx, {
title: "GSD — Workflow Mode",
summary: ["How are you working on this project?"],
actions: [
{
id: "solo",
label: "Solo",
description: "Just me — auto-push, squash merge, worktree isolation",
recommended: true,
},
{
id: "team",
label: "Team",
description: "Multiple contributors — branch-based, PR-friendly workflow",
},
],
notYetMessage: "Run /gsd init when ready.",
});
if (modeChoice === "not_yet") return { completed: false, bootstrapped: false };
prefs.mode = modeChoice as "solo" | "team";
// Apply mode-driven defaults
if (prefs.mode === "team") {
prefs.autoPush = false;
}
// ── Step 4: Verification commands ──────────────────────────────────────────
prefs.verificationCommands = signals.verificationCommands;
if (signals.verificationCommands.length > 0) {
const verifyLines = signals.verificationCommands.map((cmd, i) => ` ${i + 1}. ${cmd}`);
const verifyChoice = await showNextAction(ctx, {
title: "GSD — Verification Commands",
summary: [
"Auto-detected verification commands:",
...verifyLines,
"",
"GSD runs these after each code change to verify nothing is broken.",
],
actions: [
{ id: "accept", label: "Use these commands", description: "Accept auto-detected commands", recommended: true },
{ id: "skip", label: "Skip verification", description: "Don't verify after changes" },
],
notYetMessage: "Run /gsd init when ready.",
});
if (verifyChoice === "not_yet") return { completed: false, bootstrapped: false };
if (verifyChoice === "skip") prefs.verificationCommands = [];
}
// ── Step 5: Git preferences ────────────────────────────────────────────────
const gitSummary: string[] = [];
gitSummary.push(`Git isolation: worktree`);
gitSummary.push(`Main branch: ${prefs.mainBranch}`);
const gitChoice = await showNextAction(ctx, {
title: "GSD — Git Settings",
summary: ["Default git settings for this project:", ...gitSummary],
actions: [
{ id: "accept", label: "Accept defaults", description: "Use standard git settings", recommended: true },
{ id: "customize", label: "Customize", description: "Change git settings" },
],
notYetMessage: "Run /gsd init when ready.",
});
if (gitChoice === "not_yet") return { completed: false, bootstrapped: false };
if (gitChoice === "customize") {
await customizeGitPrefs(ctx, prefs, signals);
}
// ── Step 6: Custom instructions ────────────────────────────────────────────
const instructionChoice = await showNextAction(ctx, {
title: "GSD — Project Instructions",
summary: [
"Any rules GSD should follow for this project?",
"",
"Examples:",
' - "Use TypeScript strict mode"',
' - "Always write tests for new code"',
' - "This is a monorepo, only touch packages/api"',
"",
"You can always add more later via /gsd prefs project.",
],
actions: [
{ id: "skip", label: "Skip for now", description: "No special instructions", recommended: true },
{ id: "add", label: "Add instructions", description: "Enter project-specific rules" },
],
notYetMessage: "Run /gsd init when ready.",
});
if (instructionChoice === "not_yet") return { completed: false, bootstrapped: false };
if (instructionChoice === "add") {
const input = await ctx.ui.input(
"Enter instructions (one per line, or comma-separated):",
"e.g., Use Tailwind CSS, Always write tests",
);
if (input && input.trim()) {
// Split on newlines or commas
prefs.customInstructions = input
.split(/[,\n]/)
.map(s => s.trim())
.filter(s => s.length > 0);
}
}
// ── Step 7: Advanced (optional) ────────────────────────────────────────────
const advancedChoice = await showNextAction(ctx, {
title: "GSD — Advanced Settings",
summary: [
`Token profile: ${prefs.tokenProfile}`,
`Skip research phase: ${prefs.skipResearch ? "yes" : "no"}`,
`Auto-push on merge: ${prefs.autoPush ? "yes" : "no"}`,
],
actions: [
{ id: "accept", label: "Accept defaults", description: "Use standard settings", recommended: true },
{ id: "customize", label: "Customize", description: "Change advanced settings" },
],
notYetMessage: "Run /gsd init when ready.",
});
if (advancedChoice === "not_yet") return { completed: false, bootstrapped: false };
if (advancedChoice === "customize") {
await customizeAdvancedPrefs(ctx, prefs);
}
// ── Step 8: Skill Installation ─────────────────────────────────────────────
try {
await runSkillInstallStep(ctx, signals);
} catch {
// Non-fatal — skill installation failure should never block project init
}
// ── Step 9: Bootstrap .gsd/ ────────────────────────────────────────────────
bootstrapGsdDirectory(basePath, prefs, signals);
// Initialize SQLite database so GSD starts in full-capability mode (#3880).
// Without this, isDbAvailable() returns false and GSD enters degraded
// markdown-only mode until a tool handler happens to call ensureDbOpen().
try {
const { ensureDbOpen } = await import("./bootstrap/dynamic-tools.js");
await ensureDbOpen(basePath);
} catch {
// Non-fatal — DB creation failure should not block project init
}
// Ensure .gitignore
ensureGitignore(basePath);
untrackRuntimeFiles(basePath);
// Auto-generate codebase map for instant agent orientation
try {
const result = generateCodebaseMap(basePath);
if (result.fileCount > 0) {
writeCodebaseMap(basePath, result.content);
ctx.ui.notify(`Codebase map generated: ${result.fileCount} files`, "info");
}
} catch {
// Non-fatal — codebase map generation failure should never block project init
}
// Write initial STATE.md so it exists before the first /gsd invocation.
// The explicit /gsd init path (ops.ts) returns without entering showWorkflowEntry(),
// which would otherwise generate STATE.md at guided-flow.ts:1358.
try {
const { deriveState } = await import("./state.js");
const { buildStateMarkdown } = await import("./doctor.js");
const { saveFile } = await import("./files.js");
const { resolveGsdRootFile } = await import("./paths.js");
const state = await deriveState(basePath);
await saveFile(resolveGsdRootFile(basePath, "STATE"), buildStateMarkdown(state));
} catch {
// Non-fatal — STATE.md will be regenerated on next /gsd invocation
}
{
const { prepareWorkflowMcpForProject } = await import("./workflow-mcp-auto-prep.js");
prepareWorkflowMcpForProject(ctx, basePath);
}
ctx.ui.notify("GSD initialized. Starting your first milestone...", "info");
return { completed: true, bootstrapped: true };
}
// ─── V1 Migration Offer ─────────────────────────────────────────────────────────
/**
* Show migration offer when .planning/ is detected.
* Returns 'migrate', 'fresh', or 'cancel'.
*/
export async function offerMigration(
ctx: ExtensionCommandContext,
v1: NonNullable<ProjectDetection["v1"]>,
): Promise<"migrate" | "fresh" | "cancel"> {
const summary = [
"Found .planning/ directory (GSD v1 format)",
];
if (v1.phaseCount > 0) {
summary.push(`${v1.phaseCount} phase${v1.phaseCount > 1 ? "s" : ""} detected`);
}
if (v1.hasRoadmap) {
summary.push("Has ROADMAP.md");
}
const choice = await showNextAction(ctx, {
title: "GSD — Legacy Project Detected",
summary,
actions: [
{
id: "migrate",
label: "Migrate to GSD v2",
description: "Convert .planning/ to .gsd/ format",
recommended: true,
},
{
id: "fresh",
label: "Start fresh",
description: "Ignore .planning/ and create new .gsd/",
},
],
notYetMessage: "Run /gsd init when ready.",
});
if (choice === "not_yet") return "cancel";
return choice as "migrate" | "fresh";
}
// ─── Re-init Handler ────────────────────────────────────────────────────────────
/**
* Handle /gsd init when .gsd/ already exists.
* Offers preference reset without destructive milestone deletion.
*/
export async function handleReinit(
ctx: ExtensionCommandContext,
detection: ProjectDetection,
): Promise<void> {
const summary = ["GSD is already initialized in this project."];
if (detection.v2) {
summary.push(`${detection.v2.milestoneCount} milestone(s) found`);
summary.push(`Preferences: ${detection.v2.hasPreferences ? "configured" : "not set"}`);
}
const choice = await showNextAction(ctx, {
title: "GSD — Already Initialized",
summary,
actions: [
{
id: "prefs",
label: "Re-configure preferences",
description: "Update project preferences without affecting milestones",
recommended: true,
},
{
id: "cancel",
label: "Cancel",
description: "Keep everything as-is",
},
],
notYetMessage: "Run /gsd init when ready.",
});
if (choice === "prefs") {
ctx.ui.notify("Use /gsd prefs project to update project preferences.", "info");
}
}
// ─── Git Preferences Customization ──────────────────────────────────────────────
async function customizeGitPrefs(
ctx: ExtensionCommandContext,
prefs: ProjectPreferences,
signals: ProjectSignals,
): Promise<void> {
// Isolation strategy
const hasSubmodules = existsSync(join(process.cwd(), ".gitmodules"));
const isolationActions = [
{ id: "worktree", label: "Worktree", description: "Isolated git worktree per milestone (recommended)", recommended: !hasSubmodules },
{ id: "branch", label: "Branch", description: "Work on branches in project root (better for submodules)", recommended: hasSubmodules },
{ id: "none", label: "None", description: "No isolation — commits on current branch" },
];
const isolationSummary = hasSubmodules
? ["Submodules detected — branch mode recommended over worktree."]
: ["Worktree isolation creates a separate copy for each milestone."];
const isolationChoice = await showNextAction(ctx, {
title: "Git isolation strategy",
summary: isolationSummary,
actions: isolationActions,
});
if (isolationChoice !== "not_yet") {
prefs.gitIsolation = isolationChoice as "worktree" | "branch" | "none";
}
}
// ─── Advanced Preferences Customization ─────────────────────────────────────────
async function customizeAdvancedPrefs(
ctx: ExtensionCommandContext,
prefs: ProjectPreferences,
): Promise<void> {
// Token profile
const profileChoice = await showNextAction(ctx, {
title: "Token usage profile",
summary: [
"Controls how much context GSD uses per task.",
"Budget: cheaper, faster. Quality: thorough, more expensive.",
],
actions: [
{ id: "balanced", label: "Balanced", description: "Good trade-off (default)", recommended: true },
{ id: "budget", label: "Budget", description: "Minimize token usage" },
{ id: "quality", label: "Quality", description: "Maximize thoroughness" },
{ id: "burn-max", label: "Burn Max", description: "Maximum depth, no phase skips" },
],
});
if (profileChoice !== "not_yet") {
prefs.tokenProfile = profileChoice as "budget" | "balanced" | "quality" | "burn-max";
}
// Skip research
const researchChoice = await showNextAction(ctx, {
title: "Research phase",
summary: [
"GSD can research the codebase before planning each milestone.",
"Small projects may not need this step.",
],
actions: [
{ id: "keep", label: "Keep research", description: "Explore codebase before planning", recommended: true },
{ id: "skip", label: "Skip research", description: "Go straight to planning" },
],
});
prefs.skipResearch = researchChoice === "skip";
// Auto-push
const pushChoice = await showNextAction(ctx, {
title: "Auto-push after merge",
summary: [
"After merging a milestone branch, auto-push to remote?",
prefs.mode === "team"
? "Team mode: usually disabled so changes go through PR review."
: "Solo mode: usually enabled for convenience.",
],
actions: [
{ id: "yes", label: "Yes", description: "Push automatically", recommended: prefs.mode === "solo" },
{ id: "no", label: "No", description: "Manual push only", recommended: prefs.mode === "team" },
],
});
prefs.autoPush = pushChoice !== "no";
}
// ─── Bootstrap ──────────────────────────────────────────────────────────────────
function bootstrapGsdDirectory(
basePath: string,
prefs: ProjectPreferences,
signals: ProjectSignals,
): void {
// Final safety check before writing any files
assertSafeDirectory(basePath);
const gsd = gsdRoot(basePath);
mkdirSync(join(gsd, "milestones"), { recursive: true });
mkdirSync(join(gsd, "runtime"), { recursive: true });
// Write PREFERENCES.md from wizard answers
const preferencesContent = buildPreferencesFile(prefs);
writeFileSync(join(gsd, "PREFERENCES.md"), preferencesContent, "utf-8");
// Seed CONTEXT.md with detected project signals
const contextContent = buildContextSeed(signals);
if (contextContent) {
writeFileSync(join(gsd, "CONTEXT.md"), contextContent, "utf-8");
}
}
function buildPreferencesFile(prefs: ProjectPreferences): string {
const lines: string[] = ["---"];
lines.push("version: 1");
lines.push(`mode: ${prefs.mode}`);
// Git preferences
lines.push("git:");
lines.push(` isolation: ${prefs.gitIsolation}`);
lines.push(` main_branch: ${prefs.mainBranch}`);
lines.push(` auto_push: ${prefs.autoPush}`);
// Verification commands
if (prefs.verificationCommands.length > 0) {
lines.push("verification_commands:");
for (const cmd of prefs.verificationCommands) {
lines.push(` - "${cmd}"`);
}
}
// Custom instructions
if (prefs.customInstructions.length > 0) {
lines.push("custom_instructions:");
for (const inst of prefs.customInstructions) {
lines.push(` - "${inst.replace(/"/g, '\\"')}"`);
}
}
// Token profile (only if non-default)
if (prefs.tokenProfile !== "balanced") {
lines.push(`token_profile: ${prefs.tokenProfile}`);
}
// Phase skips
if (prefs.skipResearch) {
lines.push("phases:");
lines.push(" skip_research: true");
}
// Defaults for wizard-generated files
lines.push("always_use_skills: []");
lines.push("prefer_skills: []");
lines.push("avoid_skills: []");
lines.push("skill_rules: []");
lines.push("---");
lines.push("");
lines.push("# GSD Project Preferences");
lines.push("");
lines.push("Generated by `/gsd init`. Edit directly or use `/gsd prefs project` to modify.");
lines.push("");
lines.push("See `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation.");
lines.push("");
return lines.join("\n");
}
function buildContextSeed(signals: ProjectSignals): string | null {
const lines: string[] = [];
if (signals.detectedFiles.length === 0 && !signals.isGitRepo) {
return null; // Empty folder, no context to seed
}
lines.push("# Project Context");
lines.push("");
lines.push("Auto-detected by GSD init wizard. Edit or expand as needed.");
lines.push("");
if (signals.primaryLanguage) {
lines.push(`## Language / Stack`);
lines.push("");
lines.push(`Primary: ${signals.primaryLanguage}`);
if (signals.isMonorepo) {
lines.push("Structure: monorepo");
}
lines.push("");
}
if (signals.detectedFiles.length > 0) {
lines.push("## Project Files");
lines.push("");
for (const f of signals.detectedFiles) {
lines.push(`- ${f}`);
}
lines.push("");
}
if (signals.hasCI) {
lines.push("## CI/CD");
lines.push("");
lines.push("CI configuration detected.");
lines.push("");
}
if (signals.hasTests) {
lines.push("## Testing");
lines.push("");
lines.push("Test infrastructure detected.");
if (signals.verificationCommands.length > 0) {
lines.push("");
lines.push("Verification commands:");
for (const cmd of signals.verificationCommands) {
lines.push(`- \`${cmd}\``);
}
}
lines.push("");
}
return lines.join("\n");
}
// ─── Helpers ────────────────────────────────────────────────────────────────────
function buildDetectionSummary(signals: ProjectSignals): string[] {
const lines: string[] = [];
if (signals.primaryLanguage) {
const typeStr = signals.isMonorepo ? "monorepo" : "project";
lines.push(` ${signals.primaryLanguage} ${typeStr}`);
}
if (signals.detectedFiles.length > 0) {
lines.push(` Project files: ${signals.detectedFiles.join(", ")}`);
}
if (signals.packageManager) {
lines.push(` Package manager: ${signals.packageManager}`);
}
if (signals.hasCI) lines.push(" CI/CD: detected");
if (signals.hasTests) lines.push(" Tests: detected");
if (signals.verificationCommands.length > 0) {
lines.push(` Verification: ${signals.verificationCommands.join(", ")}`);
}
return lines;
}
function detectMainBranch(basePath: string): string | null {
try {
// Check HEAD reference for common branch names
const headPath = join(basePath, ".git", "HEAD");
if (existsSync(headPath)) {
const head = readFileSync(headPath, "utf-8").trim();
const match = head.match(/^ref: refs\/heads\/(.+)$/);
if (match) return match[1];
}
// Check for common remote branches
const refsPath = join(basePath, ".git", "refs", "remotes", "origin");
if (existsSync(refsPath)) {
if (existsSync(join(refsPath, "main"))) return "main";
if (existsSync(join(refsPath, "master"))) return "master";
}
} catch {
// Fall through to null
}
return null;
}