From d5161fddb9e0d1a146acace3f7e256a8f562ca51 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 17 Mar 2026 17:31:52 -0500 Subject: [PATCH] feat: add project onboarding detection and init wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the silent .gsd/ bootstrap with an interactive init wizard that detects project state, offers v1 migration, and guides users through per-project configuration before their first milestone. New commands: - /gsd init — project init wizard (detect, configure, bootstrap .gsd/) - /gsd setup — global setup status and routing to existing config commands Detection engine (detection.ts): - detectProjectState() identifies none/v1-planning/v2-gsd/v2-gsd-empty - detectProjectSignals() scans for language, monorepo, CI, tests, package manager - Auto-detects verification commands from package.json, Cargo.toml, go.mod, etc. - isFirstEverLaunch() / hasGlobalSetup() for global state checks Init wizard (init-wizard.ts): - 8-step wizard: git → mode → verification → git prefs → instructions → advanced → bootstrap - Every step skippable with sensible defaults - offerMigration() when .planning/ detected (migrate/fresh/cancel) - handleReinit() for safe re-init on existing projects - Writes preferences.md from wizard answers + seeds CONTEXT.md with detected signals Smart entry integration (guided-flow.ts): - showSmartEntry() now runs detection before any bootstrap - v1 .planning/ → migration offer before anything else - No .gsd/ → init wizard instead of silent bootstrap - Existing .gsd/ → unchanged behavior (zero regression) --- .plans/onboarding-detection-wizard.md | 431 ++++++++++++ src/resources/extensions/gsd/commands.ts | 87 ++- src/resources/extensions/gsd/detection.ts | 469 ++++++++++++++ src/resources/extensions/gsd/guided-flow.ts | 45 +- src/resources/extensions/gsd/init-wizard.ts | 611 ++++++++++++++++++ .../extensions/gsd/tests/detection.test.ts | 398 ++++++++++++ .../extensions/gsd/tests/init-wizard.test.ts | 197 ++++++ 7 files changed, 2217 insertions(+), 21 deletions(-) create mode 100644 .plans/onboarding-detection-wizard.md create mode 100644 src/resources/extensions/gsd/detection.ts create mode 100644 src/resources/extensions/gsd/init-wizard.ts create mode 100644 src/resources/extensions/gsd/tests/detection.test.ts create mode 100644 src/resources/extensions/gsd/tests/init-wizard.test.ts diff --git a/.plans/onboarding-detection-wizard.md b/.plans/onboarding-detection-wizard.md new file mode 100644 index 000000000..0f6d0044f --- /dev/null +++ b/.plans/onboarding-detection-wizard.md @@ -0,0 +1,431 @@ +# Plan: Onboarding, Detection & Init Wizard + +> **Status**: Phase 1 (Detection), Phase 3 (Init Wizard), Phase 4 (Integration), Phase 5 (Tests) — IMPLEMENTED +> **Branch**: `feature/onboarding-detection-wizard` +> **Phase 2 (Global Setup refactor)**: Deferred — `/gsd setup` provides routing to existing commands for now + +## Problem Statement + +GSD currently has two disconnected onboarding paths: + +1. **App onboarding** (`onboarding.ts`) — pre-TUI, only handles LLM/tool auth. Runs once ever, no project awareness. +2. **Project bootstrap** (`showSmartEntry()`) — silently creates `.gsd/` and drops you into the discuss prompt with zero explanation. + +Neither detects v1 `.planning/` directories, explains what GSD is, offers project-level configuration, or helps returning users entering a new folder. + +## Design Decisions + +### Q1: Should bare `/gsd` show a menu or auto-start? + +**Answer: Contextual behavior based on detection.** + +| Detected State | Behavior | +|---|---| +| No `.gsd/`, no `.planning/` | **Init Wizard** (new project onboarding) | +| No `.gsd/`, has `.planning/` | **Migration offer** → `/gsd migrate` or skip | +| Has `.gsd/`, no milestones | Current flow (discuss prompt) | +| Has `.gsd/`, has milestones | Current flow (smart entry / auto resume) | +| First-ever GSD launch (no `~/.gsd/`) | **Global setup** first, then project init | + +### Q2: Should there be an onboarding wizard? + +**Answer: Yes — two-tier wizard.** + +- **Global wizard** (`/gsd setup`) — runs once per machine. Handles: LLM auth (absorb current `onboarding.ts`), global preferences (default model, mode, timeout defaults), tool keys, remote questions. +- **Project wizard** (`/gsd init`) — runs once per project folder. Handles: project-type detection, preferences template, git init, `.gitignore`, optional CONTEXT.md seeding. + +Both wizards should be: +- Skippable at every step +- Re-runnable (`/gsd setup` and `/gsd init` work any time) +- Non-blocking (sensible defaults if skipped entirely) + +--- + +## Architecture + +### New File: `src/resources/extensions/gsd/init-wizard.ts` + +Project-level init wizard. Responsible for the interactive experience when entering a new folder. + +### New File: `src/resources/extensions/gsd/detection.ts` + +Pure detection functions. No UI, no side effects. + +### Modified: `src/resources/extensions/gsd/guided-flow.ts` + +`showSmartEntry()` gains a detection preamble before the current logic. + +### Modified: `src/resources/extensions/gsd/commands.ts` + +New subcommands: `/gsd init`, `/gsd setup`. Existing `/gsd migrate` stays. + +### Modified: `src/onboarding.ts` + +Refactored to be callable from both pre-TUI boot and `/gsd setup`. + +--- + +## Phase 1: Detection Engine (`detection.ts`) + +Pure functions, zero UI dependencies. + +### Task 1.1: `detectProjectState(basePath: string): ProjectDetection` + +```typescript +interface ProjectDetection { + /** What kind of GSD state exists */ + state: 'none' | 'v1-planning' | 'v2-gsd' | 'v2-gsd-empty'; + + /** Is this the first time GSD has been used on this machine? */ + isFirstEverLaunch: boolean; + + /** Does ~/.gsd/ exist with preferences? */ + hasGlobalSetup: boolean; + + /** v1 details (if state === 'v1-planning') */ + v1?: { + path: string; + hasPhasesDir: boolean; + hasRoadmap: boolean; + phaseCount: number; + }; + + /** v2 details (if state === 'v2-gsd' or 'v2-gsd-empty') */ + v2?: { + milestoneCount: number; + hasPreferences: boolean; + hasContext: boolean; + }; + + /** Detected project ecosystem signals */ + projectSignals: ProjectSignals; +} + +interface ProjectSignals { + /** Detected package managers / project files */ + detectedFiles: string[]; // e.g. ['package.json', 'Cargo.toml', 'go.mod'] + /** Is this a git repo already? */ + isGitRepo: boolean; + /** Is this a monorepo? (workspaces, lerna, nx, turborepo) */ + isMonorepo: boolean; + /** Primary language hint */ + primaryLanguage?: string; + /** Has existing CI? */ + hasCI: boolean; + /** Has existing tests? */ + hasTests: boolean; +} +``` + +### Task 1.2: `detectV1Planning(basePath: string): V1Detection | null` + +Checks for `.planning/` directory with v1 markers: +- `ROADMAP.md`, `PROJECT.md`, `REQUIREMENTS.md`, `STATE.md` +- `phases/` directory with numbered phases +- Returns null if no `.planning/` found + +### Task 1.3: `detectProjectSignals(basePath: string): ProjectSignals` + +Quick filesystem scan (no heavy reads): +- Check for `package.json`, `Cargo.toml`, `go.mod`, `pyproject.toml`, `Gemfile`, etc. +- Check for monorepo markers (`workspaces` in package.json, `lerna.json`, `nx.json`, `turbo.json`, `pnpm-workspace.yaml`) +- Check for `.github/workflows/`, `.gitlab-ci.yml`, `Jenkinsfile` +- Check for test directories (`__tests__`, `tests/`, `test/`, `spec/`) + +### Task 1.4: `isFirstEverLaunch(): boolean` + +Returns `true` if `~/.gsd/` doesn't exist or has no `preferences.md`. + +--- + +## Phase 2: Global Setup Wizard (`/gsd setup`) + +Absorbs and extends current `onboarding.ts` functionality. + +### Task 2.1: Refactor `onboarding.ts` into composable steps + +Extract each step into a standalone async function that can be called from: +- Pre-TUI boot (current behavior) +- `/gsd setup` command (new) + +Steps become: +- `runLlmSetupStep()` — already exists, just needs export +- `runWebSearchStep()` — already exists +- `runRemoteQuestionsStep()` — already exists +- `runToolKeysStep()` — already exists +- **NEW** `runGlobalPreferencesStep()` — default mode (solo/team), default model routing, timeout defaults + +### Task 2.2: `/gsd setup` command handler + +``` +/gsd setup → full wizard (all steps) +/gsd setup llm → just LLM auth +/gsd setup search → just web search +/gsd setup remote → just remote questions +/gsd setup keys → just tool keys +/gsd setup prefs → just global preferences +``` + +Shows a summary dashboard at the end: +``` +┌ Global Setup ─────────────────────────────────┐ +│ │ +│ ✓ LLM: Anthropic (Claude) │ +│ ✓ Web search: Brave Search │ +│ ✓ Remote questions: Discord #gsd-bot │ +│ ✓ Tool keys: 2/3 configured │ +│ ✓ Preferences: solo mode, Sonnet default │ +│ │ +└───────────────────────────────────────────────┘ +``` + +### Task 2.3: Pre-TUI boot integration + +Modify `shouldRunOnboarding()` to also check `isFirstEverLaunch()`. +When it runs, use the refactored steps so the experience is identical. + +--- + +## Phase 3: Project Init Wizard (`/gsd init`) + +### Per-Project Preferences Strategy + +Not all preferences belong in the init wizard. The filter: **"What would you regret not setting before your first milestone?"** + +#### Tier 1: Ask in init wizard (high impact, easy to answer) + +| Pref | Why at init time | Default | +|------|-----------------|---------| +| **`mode`** (solo / team) | Changes git strategy, merge behavior, everything downstream. Wrong default = friction on every milestone. | `solo` | +| **`git.commit_docs`** | Whether `.gsd/` plans get committed to git. Team projects usually want `true`, throwaway prototypes want `false`. Affects the very first commit. | `true` | +| **`git.isolation`** (worktree / branch / none) | Worktree isolation fails in some setups (submodules, shallow clones). Better to detect + ask upfront than crash during first auto run. | `worktree` | +| **`verification_commands`** | "How do we verify code works?" — e.g. `npm test`, `cargo test`, `make check`. Auto-detected from project signals (package.json scripts, Makefile, etc.) and confirmed. Critical for auto-mode to actually validate work. | `[]` (auto-detect) | +| **`custom_instructions`** | Project-specific rules the LLM should follow. E.g. "use Tailwind, not CSS modules", "always write tests", "this is a monorepo, only touch packages/api". First milestone benefits hugely from these. | `[]` | + +#### Tier 2: Show but default-skip (power users, "Advanced" expandable section) + +| Pref | Why offer but not push | Default | +|------|----------------------|---------| +| **`token_profile`** (budget / balanced / quality) | Cost-conscious users want to set this early, but `balanced` works fine for most. | `balanced` | +| **`phases.skip_research`** | Small projects don't need a research phase. Detectable from project signals (tiny repo = suggest skipping). | `false` | +| **`git.main_branch`** | Usually `main` or `master` — auto-detected from git, confirm only if ambiguous. | auto-detect | +| **`git.auto_push`** | Whether auto-mode pushes after merging. Solo users usually want this; team users may want PR review first. | `true` (solo) / `false` (team) | + +#### Tier 3: Don't ask at init (defer to `/gsd prefs project`) + +| Pref | Why defer | +|------|----------| +| `models` (per-phase model config) | Complex, per-phase config. Global default is fine to start. | +| `auto_supervisor` (timeouts) | Needs experience with the tool to calibrate. | +| `budget_ceiling` / `budget_enforcement` | Users don't know their budget on a new project. | +| `notifications` | Defaults work fine. | +| `skill_rules` / `always_use_skills` / `avoid_skills` | Too advanced for init — needs milestone experience first. | +| `post_unit_hooks` / `pre_dispatch_hooks` | Power-user territory. | +| `dynamic_routing` | Requires understanding model routing. | +| `parallel` (workers, merge strategy) | Needs milestones defined first. | +| `unique_milestone_ids` | Niche preference. | +| `uat_dispatch` | Niche. | +| `remote_questions` | Already handled in global setup. | +| `context_pause_threshold` | Internal tuning, not user-facing at init. | +| `skill_discovery` / `skill_staleness_days` | Defaults are sensible. | +| `auto_visualize` / `auto_report` | Nice-to-have, defaults fine. | + +### Verification Command Auto-Detection + +The wizard auto-populates `verification_commands` from project signals: + +| Signal | Suggested command | +|--------|------------------| +| `package.json` with `scripts.test` | `npm test` (or `pnpm test` / `yarn test` if lockfile detected) | +| `package.json` with `scripts.build` | `npm run build` | +| `package.json` with `scripts.lint` | `npm run lint` | +| `package.json` with `scripts.typecheck` or `scripts.tsc` | `npm run typecheck` | +| `Cargo.toml` | `cargo test`, `cargo clippy` | +| `go.mod` | `go test ./...`, `go vet ./...` | +| `pyproject.toml` or `setup.py` | `pytest` or `python -m pytest` | +| `Makefile` with `test` target | `make test` | +| `Gemfile` | `bundle exec rspec` or `bundle exec rake test` | +| `.github/workflows/*.yml` | Parse for test commands (informational, not auto-added) | + +User sees: "I detected these verification commands — confirm, edit, or add more." + +### Task 3.1: `showProjectInit()` — the main wizard + +Flow: +``` +Step 1: Detection scan (automatic, instant, no prompt) + ├─ v1 .planning/ found? → Offer migration (Task 3.2) + ├─ .gsd/ already exists? → Re-init safety (Task 3.3) + └─ Clean folder → Continue to step 2 + +Step 2: Project Recognition (informational, no prompt needed) + "Detected: Node.js monorepo, 3 packages, Jest tests, GitHub Actions CI" + → Displayed as context, saved to CONTEXT.md seed + +Step 3: Git Setup + ├─ Already a git repo? → Auto-detect main branch, skip init + └─ Not a git repo → "Initialize git?" (default: yes) + +Step 4: Mode Selection + "How are you working on this project?" + > Solo (just me) ← default + Team (multiple contributors) + +Step 5: Verification Commands (auto-populated from detection) + "How should GSD verify code changes?" + > npm test ← auto-detected from package.json + npm run build ← auto-detected + Add more commands... + Skip verification + +Step 6: Git Preferences + "Git settings for this project:" + Commit .gsd/ plans to git? [Y/n] ← default yes + Isolation strategy: [worktree] ← default, warn if submodules + Main branch: [main] ← auto-detected, confirm if ambiguous + +Step 7: Project Instructions (optional, skippable) + "Any rules GSD should follow for this project?" + > (text input, multi-line or one-liner) + e.g. "Use TypeScript strict mode", "Follow existing patterns in src/" + Hint: "These become custom_instructions in your project preferences" + +Step 8: Advanced (collapsed by default, expandable) + "Advanced settings (press Enter to skip):" + Token profile: [balanced] / budget / quality + Skip research? [no] / yes + Auto-push on merge? [yes] / no + +Step 9: Bootstrap .gsd/ structure + - Creates .gsd/milestones/ + - Creates .gsd/preferences.md (from wizard answers) + - Creates .gitignore entries + - Seeds CONTEXT.md with detected project signals + - Commits "chore: init gsd" (if commit_docs enabled) + +Step 10: Transition + → Auto-transition to discuss prompt for first milestone + (Fluid experience — wizard flows directly into "tell me about your project") +``` + +### Task 3.2: v1 migration detection + offer + +When `.planning/` is detected in `showSmartEntry()`: + +``` +┌ GSD — Legacy Project Detected ────────────────┐ +│ │ +│ Found .planning/ directory (GSD v1 format) │ +│ 3 phases, 12 tasks detected │ +│ │ +│ > Migrate to GSD v2 (recommended) │ +│ Start fresh │ +│ Cancel │ +│ │ +└───────────────────────────────────────────────┘ +``` + +"Migrate" → delegates to existing `handleMigrate()` pipeline. +"Start fresh" → runs the normal init wizard, ignoring `.planning/`. + +### Task 3.3: Re-init safety + +If `.gsd/` already exists when `/gsd init` is run: +- Show current state (X milestones, Y slices) +- Offer: "Reset preferences" / "Re-run project detection" / "Cancel" +- Never destructively delete milestones via init + +--- + +## Phase 4: Smart Entry Integration + +### Task 4.1: Update `showSmartEntry()` detection preamble + +Before the current logic, add: + +```typescript +const detection = detectProjectState(basePath); + +// First-ever launch — run global setup first +if (detection.isFirstEverLaunch) { + await showGlobalSetupWizard(ctx); +} + +// v1 detected, no v2 — offer migration +if (detection.state === 'v1-planning') { + const choice = await offerMigration(ctx, detection.v1!); + if (choice === 'migrate') { + await handleMigrate('', ctx, pi); + return; + } + // 'fresh' falls through to normal init +} + +// No .gsd/ — run project init wizard +if (detection.state === 'none') { + await showProjectInit(ctx, pi, basePath, detection); + return; +} + +// Existing .gsd/ — current logic continues unchanged +``` + +### Task 4.2: Preserve zero-friction for returning users + +The detection + init wizard only triggers when `.gsd/` doesn't exist. +Once `.gsd/` exists, the flow is identical to today — no regressions. + +--- + +## Phase 5: Tests + +### Task 5.1: Detection engine tests + +- `detectProjectState()` with various folder layouts +- `detectV1Planning()` with real and fake `.planning/` dirs +- `detectProjectSignals()` with different project types +- `isFirstEverLaunch()` with/without `~/.gsd/` + +### Task 5.2: Init wizard integration tests + +- New folder → wizard triggers → `.gsd/` created correctly +- v1 folder → migration offer shown +- Existing `.gsd/` → wizard skipped, normal flow +- Re-run `/gsd init` on existing project → safe behavior + +### Task 5.3: Global setup tests + +- `/gsd setup` from command handler +- Individual sub-steps (`/gsd setup llm`, etc.) +- Pre-TUI boot still works with refactored steps + +--- + +## File Inventory + +| File | Action | Purpose | +|------|--------|---------| +| `src/resources/extensions/gsd/detection.ts` | **NEW** | Pure detection functions | +| `src/resources/extensions/gsd/init-wizard.ts` | **NEW** | Project init wizard UI | +| `src/resources/extensions/gsd/global-setup.ts` | **NEW** | Global setup wizard (refactored from onboarding.ts) | +| `src/onboarding.ts` | **MODIFY** | Delegate to global-setup.ts, keep boot integration | +| `src/resources/extensions/gsd/guided-flow.ts` | **MODIFY** | Add detection preamble to showSmartEntry() | +| `src/resources/extensions/gsd/commands.ts` | **MODIFY** | Add `/gsd init` and `/gsd setup` subcommands | +| Tests (TBD paths) | **NEW** | Detection, init, setup tests | + +## Open Questions for Discussion + +1. **Should `/gsd init` auto-transition to the discuss prompt?** Or end with "Run /gsd to start"? Auto-transition is more fluid but might feel jarring after a wizard. + +2. **Should project signals (detected language, CI, etc.) be persisted?** They're useful context for the discuss prompt but could go stale. Option: seed into CONTEXT.md as a starting point the user can edit. + +3. **Should `/gsd setup` be accessible outside the TUI?** e.g. `gsd setup` from the shell before launching. Currently `onboarding.ts` handles this but it's limited. + +4. **Migration: should we auto-detect `.planning/` in parent directories?** Some users might run GSD from a subdirectory while `.planning/` is at the repo root. + +## Estimated Scope + +- **3 new files**, ~400-600 lines total +- **3 modified files**, ~50-80 lines of changes +- **Test files**, ~200-300 lines +- No breaking changes to existing behavior diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 9b2ee15dc..8c93cd0f8 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -106,6 +106,8 @@ export function registerGSDCommand(pi: ExtensionAPI): void { { cmd: "skill-health", desc: "Skill lifecycle dashboard" }, { cmd: "doctor", desc: "Runtime health checks with auto-fix" }, { cmd: "forensics", desc: "Examine execution logs" }, + { cmd: "init", desc: "Project init wizard — detect, configure, bootstrap .gsd/" }, + { cmd: "setup", desc: "Global setup status and configuration" }, { cmd: "migrate", desc: "Migrate a v1 .planning directory to .gsd format" }, { cmd: "remote", desc: "Control remote auto-mode" }, { cmd: "steer", desc: "Hard-steer plan documents during execution" }, @@ -148,6 +150,13 @@ export function registerGSDCommand(pi: ExtensionAPI): void { .map((cmd) => ({ value: `parallel ${cmd}`, label: cmd })); } + if (parts[0] === "setup" && parts.length <= 2) { + const subPrefix = parts[1] ?? ""; + return ["llm", "search", "remote", "keys", "prefs"] + .filter((cmd) => cmd.startsWith(subPrefix)) + .map((cmd) => ({ value: `setup ${cmd}`, label: cmd })); + } + if (parts[0] === "prefs" && parts.length <= 2) { const subPrefix = parts[1] ?? ""; return ["global", "project", "status", "wizard", "setup", "import-claude"] @@ -256,6 +265,25 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return; } + if (trimmed === "init") { + const { detectProjectState } = await import("./detection.js"); + const { showProjectInit, handleReinit } = await import("./init-wizard.js"); + const basePath = projectRoot(); + const detection = detectProjectState(basePath); + if (detection.state === "v2-gsd" || detection.state === "v2-gsd-empty") { + await handleReinit(ctx, detection); + } else { + await showProjectInit(ctx, pi, basePath, detection); + } + return; + } + + if (trimmed === "setup" || trimmed.startsWith("setup ")) { + const setupArgs = trimmed.replace(/^setup\s*/, "").trim(); + await handleSetup(setupArgs, ctx); + return; + } + if (trimmed === "doctor" || trimmed.startsWith("doctor ")) { await handleDoctor(trimmed.replace(/^doctor\s*/, "").trim(), ctx, pi); return; @@ -623,7 +651,9 @@ function showHelp(ctx: ExtensionCommandContext): void { "PROJECT KNOWLEDGE", " /gsd knowledge Add rule, pattern, or lesson to KNOWLEDGE.md", "", - "CONFIGURATION", + "SETUP & CONFIGURATION", + " /gsd init Project init wizard — detect, configure, bootstrap .gsd/", + " /gsd setup Global setup status [llm|search|remote|keys|prefs]", " /gsd mode Set workflow mode (solo/team) [global|project]", " /gsd prefs Manage preferences [global|project|status|wizard|setup]", " /gsd config Set API keys for external tools", @@ -633,7 +663,7 @@ function showHelp(ctx: ExtensionCommandContext): void { " /gsd doctor Diagnose and repair .gsd/ state [audit|fix|heal] [scope]", " /gsd export Export milestone/slice results [--json|--markdown|--html] [--all]", " /gsd cleanup Remove merged branches or snapshots [branches|snapshots]", - " /gsd migrate Upgrade .gsd/ structures to new format", + " /gsd migrate Migrate .planning/ (v1) to .gsd/ (v2) format", " /gsd remote Control remote auto-mode [slack|discord|status|disconnect]", " /gsd inspect Show SQLite DB diagnostics (schema, row counts, recent entries)", " /gsd update Update GSD to the latest version via npm", @@ -694,6 +724,59 @@ async function handleVisualize(ctx: ExtensionCommandContext): Promise { ); } +async function handleSetup(args: string, ctx: ExtensionCommandContext): Promise { + const { detectProjectState, hasGlobalSetup } = await import("./detection.js"); + + // Show current global setup status + const globalConfigured = hasGlobalSetup(); + const detection = detectProjectState(projectRoot()); + + const statusLines = ["GSD Setup Status\n"]; + statusLines.push(` Global preferences: ${globalConfigured ? "configured" : "not set"}`); + statusLines.push(` Project state: ${detection.state}`); + if (detection.projectSignals.primaryLanguage) { + statusLines.push(` Detected: ${detection.projectSignals.primaryLanguage}`); + } + + if (args === "llm" || args === "auth") { + ctx.ui.notify("Use /login to configure LLM authentication.", "info"); + return; + } + + if (args === "search") { + ctx.ui.notify("Use /search-provider to configure web search.", "info"); + return; + } + + if (args === "remote") { + ctx.ui.notify("Use /gsd remote to configure remote questions.", "info"); + return; + } + + if (args === "keys") { + await handleConfig(ctx); + return; + } + + if (args === "prefs") { + await ensurePreferencesFile(getGlobalGSDPreferencesPath(), ctx, "global"); + await handlePrefsWizard(ctx, "global"); + return; + } + + // Full setup summary + ctx.ui.notify(statusLines.join("\n"), "info"); + ctx.ui.notify( + "Available setup commands:\n" + + " /gsd setup llm — LLM authentication\n" + + " /gsd setup search — Web search provider\n" + + " /gsd setup remote — Remote questions (Discord/Slack/Telegram)\n" + + " /gsd setup keys — Tool API keys\n" + + " /gsd setup prefs — Global preferences wizard", + "info", + ); +} + async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise { const trimmed = args.trim(); diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts new file mode 100644 index 000000000..5e3e1776b --- /dev/null +++ b/src/resources/extensions/gsd/detection.ts @@ -0,0 +1,469 @@ +/** + * GSD Detection — Project state and ecosystem detection. + * + * Pure functions, zero UI dependencies, zero side effects. + * Used by init-wizard.ts and guided-flow.ts to determine what onboarding + * flow to show when entering a project directory. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +// ─── Types ────────────────────────────────────────────────────────────────────── + +export interface ProjectDetection { + /** What kind of GSD state exists in this directory */ + state: "none" | "v1-planning" | "v2-gsd" | "v2-gsd-empty"; + + /** Is this the first time GSD has been used on this machine? */ + isFirstEverLaunch: boolean; + + /** Does ~/.gsd/ exist with preferences? */ + hasGlobalSetup: boolean; + + /** v1 details (only when state === 'v1-planning') */ + v1?: V1Detection; + + /** v2 details (only when state === 'v2-gsd' or 'v2-gsd-empty') */ + v2?: V2Detection; + + /** Detected project ecosystem signals */ + projectSignals: ProjectSignals; +} + +export interface V1Detection { + path: string; + hasPhasesDir: boolean; + hasRoadmap: boolean; + phaseCount: number; +} + +export interface V2Detection { + milestoneCount: number; + hasPreferences: boolean; + hasContext: boolean; +} + +export interface ProjectSignals { + /** Detected project/package files */ + detectedFiles: string[]; + /** Is this already a git repo? */ + isGitRepo: boolean; + /** Is this a monorepo? */ + isMonorepo: boolean; + /** Primary language hint */ + primaryLanguage?: string; + /** Has existing CI configuration? */ + hasCI: boolean; + /** Has existing test setup? */ + hasTests: boolean; + /** Detected package manager */ + packageManager?: string; + /** Auto-detected verification commands */ + verificationCommands: string[]; +} + +// ─── Project File Markers ─────────────────────────────────────────────────────── + +const PROJECT_FILES = [ + "package.json", + "Cargo.toml", + "go.mod", + "pyproject.toml", + "setup.py", + "Gemfile", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "CMakeLists.txt", + "Makefile", + "composer.json", + "pubspec.yaml", + "Package.swift", + "mix.exs", + "deno.json", + "deno.jsonc", +] as const; + +const LANGUAGE_MAP: Record = { + "package.json": "javascript/typescript", + "Cargo.toml": "rust", + "go.mod": "go", + "pyproject.toml": "python", + "setup.py": "python", + "Gemfile": "ruby", + "pom.xml": "java", + "build.gradle": "java/kotlin", + "build.gradle.kts": "kotlin", + "CMakeLists.txt": "c/c++", + "composer.json": "php", + "pubspec.yaml": "dart/flutter", + "Package.swift": "swift", + "mix.exs": "elixir", + "deno.json": "typescript/deno", + "deno.jsonc": "typescript/deno", +}; + +const MONOREPO_MARKERS = [ + "lerna.json", + "nx.json", + "turbo.json", + "pnpm-workspace.yaml", +] as const; + +const CI_MARKERS = [ + ".github/workflows", + ".gitlab-ci.yml", + "Jenkinsfile", + ".circleci", + ".travis.yml", + "azure-pipelines.yml", + "bitbucket-pipelines.yml", +] as const; + +const TEST_MARKERS = [ + "__tests__", + "tests", + "test", + "spec", + "jest.config.js", + "jest.config.ts", + "vitest.config.ts", + "vitest.config.js", + ".mocharc.yml", + "pytest.ini", + "conftest.py", + "phpunit.xml", +] as const; + +// ─── Core Detection ───────────────────────────────────────────────────────────── + +/** + * Detect the full project state for a given directory. + * This is the main entry point — calls all sub-detectors. + */ +export function detectProjectState(basePath: string): ProjectDetection { + const v1 = detectV1Planning(basePath); + const v2 = detectV2Gsd(basePath); + const projectSignals = detectProjectSignals(basePath); + const globalSetup = hasGlobalSetup(); + const firstEver = isFirstEverLaunch(); + + let state: ProjectDetection["state"]; + if (v2 && v2.milestoneCount > 0) { + state = "v2-gsd"; + } else if (v2 && v2.milestoneCount === 0) { + state = "v2-gsd-empty"; + } else if (v1) { + state = "v1-planning"; + } else { + state = "none"; + } + + return { + state, + isFirstEverLaunch: firstEver, + hasGlobalSetup: globalSetup, + v1: v1 ?? undefined, + v2: v2 ?? undefined, + projectSignals, + }; +} + +// ─── V1 Planning Detection ────────────────────────────────────────────────────── + +/** + * Detect a v1 .planning/ directory with GSD v1 markers. + * Returns null if no .planning/ directory found. + */ +export function detectV1Planning(basePath: string): V1Detection | null { + const planningPath = join(basePath, ".planning"); + + if (!existsSync(planningPath)) return null; + + try { + const stat = statSync(planningPath); + if (!stat.isDirectory()) return null; + } catch { + return null; + } + + const hasRoadmap = existsSync(join(planningPath, "ROADMAP.md")); + const phasesPath = join(planningPath, "phases"); + const hasPhasesDir = existsSync(phasesPath); + + let phaseCount = 0; + if (hasPhasesDir) { + try { + const entries = readdirSync(phasesPath, { withFileTypes: true }); + phaseCount = entries.filter(e => e.isDirectory()).length; + } catch { + // unreadable — report 0 + } + } + + return { + path: planningPath, + hasPhasesDir, + hasRoadmap, + phaseCount, + }; +} + +// ─── V2 GSD Detection ────────────────────────────────────────────────────────── + +function detectV2Gsd(basePath: string): V2Detection | null { + const gsdPath = join(basePath, ".gsd"); + + if (!existsSync(gsdPath)) return null; + + const hasPreferences = + existsSync(join(gsdPath, "preferences.md")) || + existsSync(join(gsdPath, "PREFERENCES.md")); + + const hasContext = existsSync(join(gsdPath, "CONTEXT.md")); + + let milestoneCount = 0; + const milestonesPath = join(gsdPath, "milestones"); + if (existsSync(milestonesPath)) { + try { + const entries = readdirSync(milestonesPath, { withFileTypes: true }); + milestoneCount = entries.filter(e => e.isDirectory()).length; + } catch { + // unreadable — report 0 + } + } + + return { milestoneCount, hasPreferences, hasContext }; +} + +// ─── Project Signals Detection ────────────────────────────────────────────────── + +/** + * Quick filesystem scan for project ecosystem markers. + * Reads only file existence + minimal content (package.json for monorepo/scripts). + */ +export function detectProjectSignals(basePath: string): ProjectSignals { + const detectedFiles: string[] = []; + let primaryLanguage: string | undefined; + + // Detect project files + for (const file of PROJECT_FILES) { + if (existsSync(join(basePath, file))) { + detectedFiles.push(file); + if (!primaryLanguage) { + primaryLanguage = LANGUAGE_MAP[file]; + } + } + } + + // Git repo detection + const isGitRepo = existsSync(join(basePath, ".git")); + + // Monorepo detection + let isMonorepo = false; + for (const marker of MONOREPO_MARKERS) { + if (existsSync(join(basePath, marker))) { + isMonorepo = true; + break; + } + } + // Also check package.json workspaces + if (!isMonorepo && detectedFiles.includes("package.json")) { + isMonorepo = packageJsonHasWorkspaces(basePath); + } + + // CI detection + let hasCI = false; + for (const marker of CI_MARKERS) { + if (existsSync(join(basePath, marker))) { + hasCI = true; + break; + } + } + + // Test detection + let hasTests = false; + for (const marker of TEST_MARKERS) { + if (existsSync(join(basePath, marker))) { + hasTests = true; + break; + } + } + + // Package manager detection + const packageManager = detectPackageManager(basePath); + + // Verification commands + const verificationCommands = detectVerificationCommands(basePath, detectedFiles, packageManager); + + return { + detectedFiles, + isGitRepo, + isMonorepo, + primaryLanguage, + hasCI, + hasTests, + packageManager, + verificationCommands, + }; +} + +// ─── Package Manager Detection ────────────────────────────────────────────────── + +function detectPackageManager(basePath: string): string | undefined { + if (existsSync(join(basePath, "pnpm-lock.yaml"))) return "pnpm"; + if (existsSync(join(basePath, "yarn.lock"))) return "yarn"; + if (existsSync(join(basePath, "bun.lockb")) || existsSync(join(basePath, "bun.lock"))) return "bun"; + if (existsSync(join(basePath, "package-lock.json"))) return "npm"; + if (existsSync(join(basePath, "package.json"))) return "npm"; + return undefined; +} + +// ─── Verification Command Detection ───────────────────────────────────────────── + +/** + * Auto-detect verification commands from project files. + * Returns commands in priority order (test first, then build, then lint). + */ +function detectVerificationCommands( + basePath: string, + detectedFiles: string[], + packageManager?: string, +): string[] { + const commands: string[] = []; + const pm = packageManager ?? "npm"; + const run = pm === "npm" ? "npm run" : pm === "yarn" ? "yarn" : pm === "bun" ? "bun run" : `${pm} run`; + + if (detectedFiles.includes("package.json")) { + const scripts = readPackageJsonScripts(basePath); + if (scripts) { + // Test commands (highest priority) + if (scripts.test && scripts.test !== "echo \"Error: no test specified\" && exit 1") { + commands.push(pm === "npm" ? "npm test" : `${pm} test`); + } + // Build commands + if (scripts.build) { + commands.push(`${run} build`); + } + // Lint commands + if (scripts.lint) { + commands.push(`${run} lint`); + } + // Typecheck commands + if (scripts.typecheck) { + commands.push(`${run} typecheck`); + } else if (scripts.tsc) { + commands.push(`${run} tsc`); + } + } + } + + if (detectedFiles.includes("Cargo.toml")) { + commands.push("cargo test"); + commands.push("cargo clippy"); + } + + if (detectedFiles.includes("go.mod")) { + commands.push("go test ./..."); + commands.push("go vet ./..."); + } + + if (detectedFiles.includes("pyproject.toml") || detectedFiles.includes("setup.py")) { + commands.push("pytest"); + } + + if (detectedFiles.includes("Gemfile")) { + // Check for rspec vs minitest + if (existsSync(join(basePath, "spec"))) { + commands.push("bundle exec rspec"); + } else { + commands.push("bundle exec rake test"); + } + } + + if (detectedFiles.includes("Makefile")) { + const makeTargets = readMakefileTargets(basePath); + if (makeTargets.includes("test")) { + commands.push("make test"); + } + } + + return commands; +} + +// ─── Global Setup Detection ───────────────────────────────────────────────────── + +/** + * Check if global GSD setup exists (has ~/.gsd/ with preferences). + */ +export function hasGlobalSetup(): boolean { + const gsdHome = join(homedir(), ".gsd"); + return ( + existsSync(join(gsdHome, "preferences.md")) || + existsSync(join(gsdHome, "PREFERENCES.md")) + ); +} + +/** + * Check if this is the very first time GSD has been used on this machine. + * Returns true if ~/.gsd/ doesn't exist or has no preferences or auth. + */ +export function isFirstEverLaunch(): boolean { + const gsdHome = join(homedir(), ".gsd"); + if (!existsSync(gsdHome)) return true; + + // If we have preferences, not first launch + if ( + existsSync(join(gsdHome, "preferences.md")) || + existsSync(join(gsdHome, "PREFERENCES.md")) + ) { + return false; + } + + // If we have auth.json, not first launch (onboarding.ts already ran) + if (existsSync(join(gsdHome, "agent", "auth.json"))) return false; + + // Check legacy path too + const legacyPath = join(homedir(), ".pi", "agent", "gsd-preferences.md"); + if (existsSync(legacyPath)) return false; + + return true; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────────── + +function packageJsonHasWorkspaces(basePath: string): boolean { + try { + const raw = readFileSync(join(basePath, "package.json"), "utf-8"); + const pkg = JSON.parse(raw); + return Array.isArray(pkg.workspaces) || (pkg.workspaces && typeof pkg.workspaces === "object"); + } catch { + return false; + } +} + +function readPackageJsonScripts(basePath: string): Record | null { + try { + const raw = readFileSync(join(basePath, "package.json"), "utf-8"); + const pkg = JSON.parse(raw); + return pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : null; + } catch { + return null; + } +} + +function readMakefileTargets(basePath: string): string[] { + try { + const raw = readFileSync(join(basePath, "Makefile"), "utf-8"); + const targets: string[] = []; + for (const line of raw.split("\n")) { + const match = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):/); + if (match) targets.push(match[1]); + } + return targets; + } catch { + return []; + } +} diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 4cb6c416c..cb0b9416e 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -26,6 +26,8 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync import { nativeIsRepo, nativeInit, nativeAddPaths, nativeCommit } from "./native-git-bridge.js"; import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; +import { detectProjectState } from "./detection.js"; +import { showProjectInit, offerMigration } from "./init-wizard.js"; import { showConfirm } from "../shared/confirm-ui.js"; import { loadQueueOrder, sortByQueueOrder, saveQueueOrder } from "./queue-order.js"; @@ -1067,6 +1069,30 @@ export async function showSmartEntry( ): Promise { const stepMode = options?.step; + // ── Detection preamble — run before any bootstrap ──────────────────── + if (!existsSync(join(basePath, ".gsd"))) { + const detection = detectProjectState(basePath); + + // v1 .planning/ detected — offer migration before anything else + if (detection.state === "v1-planning" && detection.v1) { + const migrationChoice = await offerMigration(ctx, detection.v1); + if (migrationChoice === "cancel") return; + if (migrationChoice === "migrate") { + const { handleMigrate } = await import("./migrate/command.js"); + await handleMigrate("", ctx, pi); + return; + } + // "fresh" — fall through to init wizard + } + + // No .gsd/ — run the project init wizard + const result = await showProjectInit(ctx, pi, basePath, detection); + if (!result.completed) return; // User cancelled + + // Init wizard bootstrapped .gsd/ — fall through to the normal flow below + // which will detect "no milestones" and start the discuss prompt + } + // ── Ensure git repo exists — GSD needs it for worktree isolation ────── if (!nativeIsRepo(basePath)) { const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main"; @@ -1078,25 +1104,6 @@ export async function showSmartEntry( ensureGitignore(basePath, { commitDocs }); untrackRuntimeFiles(basePath); - // ── No GSD project OR no milestone → Create first/next milestone ──── - if (!existsSync(join(basePath, ".gsd"))) { - // Bootstrap .gsd/ silently — the user wants a milestone, not to "init" - const gsd = gsdRoot(basePath); - mkdirSync(join(gsd, "milestones"), { recursive: true }); - - // ── Create PREFERENCES.md template ──────────────────────────────── - ensurePreferences(basePath); - // Only commit .gsd/ init when commit_docs is not explicitly false - if (commitDocs !== false) { - try { - nativeAddPaths(basePath, [".gsd", ".gitignore"]); - nativeCommit(basePath, "chore: init gsd"); - } catch { - // nothing to commit — that's fine - } - } - } - // ── Self-heal stale runtime records from crashed auto-mode sessions ── selfHealRuntimeRecords(basePath, ctx); diff --git a/src/resources/extensions/gsd/init-wizard.ts b/src/resources/extensions/gsd/init-wizard.ts new file mode 100644 index 000000000..d6e962130 --- /dev/null +++ b/src/resources/extensions/gsd/init-wizard.ts @@ -0,0 +1,611 @@ +/** + * 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 "@gsd/pi-coding-agent"; +import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { showNextAction } from "../shared/next-action-ui.js"; +import { nativeIsRepo, nativeInit, nativeAddPaths, nativeCommit } from "./native-git-bridge.js"; +import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js"; +import { gsdRoot } from "./paths.js"; +import type { ProjectDetection, ProjectSignals } from "./detection.js"; + +// ─── Types ────────────────────────────────────────────────────────────────────── + +interface InitWizardResult { + /** Whether the wizard completed (vs cancelled) */ + completed: boolean; + /** Whether .gsd/ was created */ + bootstrapped: boolean; +} + +interface ProjectPreferences { + mode: "solo" | "team"; + commitDocs: boolean; + gitIsolation: "worktree" | "branch" | "none"; + mainBranch: string; + verificationCommands: string[]; + customInstructions: string[]; + tokenProfile: "budget" | "balanced" | "quality"; + skipResearch: boolean; + autoPush: boolean; +} + +// ─── Defaults ─────────────────────────────────────────────────────────────────── + +const DEFAULT_PREFS: ProjectPreferences = { + mode: "solo", + commitDocs: true, + 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 { + 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(`Commit .gsd/ plans to git: yes`); + 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: Bootstrap .gsd/ ──────────────────────────────────────────────── + bootstrapGsdDirectory(basePath, prefs, signals); + + // Ensure .gitignore + ensureGitignore(basePath, { commitDocs: prefs.commitDocs }); + untrackRuntimeFiles(basePath); + + // Commit if enabled + if (prefs.commitDocs && nativeIsRepo(basePath)) { + try { + nativeAddPaths(basePath, [".gsd", ".gitignore"]); + nativeCommit(basePath, "chore: init gsd"); + } catch { + // nothing to commit — that's fine + } + } + + 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, +): 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 { + 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 { + // Commit docs + const commitChoice = await showNextAction(ctx, { + title: "Commit .gsd/ plans to git?", + summary: [ + "When enabled, .gsd/ planning docs are tracked in version control.", + "Team projects usually want this. Throwaway prototypes may not.", + ], + actions: [ + { id: "yes", label: "Yes", description: "Track .gsd/ in git", recommended: true }, + { id: "no", label: "No", description: "Keep .gsd/ local-only" }, + ], + }); + prefs.commitDocs = commitChoice !== "no"; + + // 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 { + // 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" }, + ], + }); + if (profileChoice !== "not_yet") { + prefs.tokenProfile = profileChoice as "budget" | "balanced" | "quality"; + } + + // 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 { + const gsd = gsdRoot(basePath); + mkdirSync(join(gsd, "milestones"), { 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(` commit_docs: ${prefs.commitDocs}`); + 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; +} diff --git a/src/resources/extensions/gsd/tests/detection.test.ts b/src/resources/extensions/gsd/tests/detection.test.ts new file mode 100644 index 000000000..8e68524e1 --- /dev/null +++ b/src/resources/extensions/gsd/tests/detection.test.ts @@ -0,0 +1,398 @@ +/** + * Unit tests for GSD Detection — project state and ecosystem detection. + * + * Exercises the pure detection functions in detection.ts: + * - detectProjectState() with various folder layouts + * - detectV1Planning() with real and fake .planning/ dirs + * - detectProjectSignals() with different project types + * - isFirstEverLaunch() / hasGlobalSetup() + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + detectProjectState, + detectV1Planning, + detectProjectSignals, +} from "../detection.ts"; + +function makeTempDir(prefix: string): string { + const dir = join( + tmpdir(), + `gsd-detection-test-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function cleanup(dir: string): void { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // best-effort + } +} + +// ─── detectProjectState ───────────────────────────────────────────────────────── + +test("detectProjectState: empty directory returns state=none", () => { + const dir = makeTempDir("empty"); + try { + const result = detectProjectState(dir); + assert.equal(result.state, "none"); + assert.equal(result.v1, undefined); + assert.equal(result.v2, undefined); + } finally { + cleanup(dir); + } +}); + +test("detectProjectState: directory with .gsd/milestones/M001 returns v2-gsd", () => { + const dir = makeTempDir("v2-gsd"); + try { + mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true }); + const result = detectProjectState(dir); + assert.equal(result.state, "v2-gsd"); + assert.ok(result.v2); + assert.equal(result.v2!.milestoneCount, 1); + } finally { + cleanup(dir); + } +}); + +test("detectProjectState: directory with empty .gsd/milestones returns v2-gsd-empty", () => { + const dir = makeTempDir("v2-empty"); + try { + mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true }); + const result = detectProjectState(dir); + assert.equal(result.state, "v2-gsd-empty"); + assert.ok(result.v2); + assert.equal(result.v2!.milestoneCount, 0); + } finally { + cleanup(dir); + } +}); + +test("detectProjectState: directory with .planning/ returns v1-planning", () => { + const dir = makeTempDir("v1-planning"); + try { + mkdirSync(join(dir, ".planning", "phases", "01-setup"), { recursive: true }); + writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# Roadmap\n", "utf-8"); + const result = detectProjectState(dir); + assert.equal(result.state, "v1-planning"); + assert.ok(result.v1); + assert.equal(result.v1!.hasRoadmap, true); + assert.equal(result.v1!.hasPhasesDir, true); + assert.equal(result.v1!.phaseCount, 1); + } finally { + cleanup(dir); + } +}); + +test("detectProjectState: v2 takes priority over v1 when both exist", () => { + const dir = makeTempDir("both"); + try { + mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true }); + mkdirSync(join(dir, ".planning"), { recursive: true }); + const result = detectProjectState(dir); + assert.equal(result.state, "v2-gsd"); + } finally { + cleanup(dir); + } +}); + +test("detectProjectState: detects preferences in .gsd/", () => { + const dir = makeTempDir("prefs"); + try { + mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true }); + writeFileSync(join(dir, ".gsd", "preferences.md"), "---\nversion: 1\n---\n", "utf-8"); + const result = detectProjectState(dir); + assert.ok(result.v2); + assert.equal(result.v2!.hasPreferences, true); + } finally { + cleanup(dir); + } +}); + +// ─── detectV1Planning ─────────────────────────────────────────────────────────── + +test("detectV1Planning: returns null for missing .planning/", () => { + const dir = makeTempDir("no-v1"); + try { + assert.equal(detectV1Planning(dir), null); + } finally { + cleanup(dir); + } +}); + +test("detectV1Planning: returns null when .planning is a file", () => { + const dir = makeTempDir("v1-file"); + try { + writeFileSync(join(dir, ".planning"), "not a directory", "utf-8"); + assert.equal(detectV1Planning(dir), null); + } finally { + cleanup(dir); + } +}); + +test("detectV1Planning: detects phases directory with multiple phases", () => { + const dir = makeTempDir("v1-phases"); + try { + mkdirSync(join(dir, ".planning", "phases", "01-setup"), { recursive: true }); + mkdirSync(join(dir, ".planning", "phases", "02-core"), { recursive: true }); + mkdirSync(join(dir, ".planning", "phases", "03-deploy"), { recursive: true }); + const result = detectV1Planning(dir); + assert.ok(result); + assert.equal(result!.phaseCount, 3); + assert.equal(result!.hasPhasesDir, true); + } finally { + cleanup(dir); + } +}); + +test("detectV1Planning: detects ROADMAP.md", () => { + const dir = makeTempDir("v1-roadmap"); + try { + mkdirSync(join(dir, ".planning"), { recursive: true }); + writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# Roadmap", "utf-8"); + const result = detectV1Planning(dir); + assert.ok(result); + assert.equal(result!.hasRoadmap, true); + assert.equal(result!.hasPhasesDir, false); + assert.equal(result!.phaseCount, 0); + } finally { + cleanup(dir); + } +}); + +// ─── detectProjectSignals ─────────────────────────────────────────────────────── + +test("detectProjectSignals: empty directory", () => { + const dir = makeTempDir("signals-empty"); + try { + const signals = detectProjectSignals(dir); + assert.deepEqual(signals.detectedFiles, []); + assert.equal(signals.isGitRepo, false); + assert.equal(signals.isMonorepo, false); + assert.equal(signals.primaryLanguage, undefined); + assert.equal(signals.hasCI, false); + assert.equal(signals.hasTests, false); + assert.deepEqual(signals.verificationCommands, []); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: Node.js project", () => { + const dir = makeTempDir("signals-node"); + try { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "test-project", + scripts: { + test: "jest", + build: "tsc", + lint: "eslint .", + }, + }), + "utf-8", + ); + writeFileSync(join(dir, "package-lock.json"), "{}", "utf-8"); + mkdirSync(join(dir, ".git"), { recursive: true }); + + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("package.json")); + assert.equal(signals.primaryLanguage, "javascript/typescript"); + assert.equal(signals.isGitRepo, true); + assert.equal(signals.packageManager, "npm"); + assert.ok(signals.verificationCommands.includes("npm test")); + assert.ok(signals.verificationCommands.some(c => c.includes("build"))); + assert.ok(signals.verificationCommands.some(c => c.includes("lint"))); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: Rust project", () => { + const dir = makeTempDir("signals-rust"); + try { + writeFileSync(join(dir, "Cargo.toml"), '[package]\nname = "test"\n', "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("Cargo.toml")); + assert.equal(signals.primaryLanguage, "rust"); + assert.ok(signals.verificationCommands.includes("cargo test")); + assert.ok(signals.verificationCommands.includes("cargo clippy")); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: Go project", () => { + const dir = makeTempDir("signals-go"); + try { + writeFileSync(join(dir, "go.mod"), "module example.com/test\n", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("go.mod")); + assert.equal(signals.primaryLanguage, "go"); + assert.ok(signals.verificationCommands.includes("go test ./...")); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: Python project", () => { + const dir = makeTempDir("signals-python"); + try { + writeFileSync(join(dir, "pyproject.toml"), "[tool.poetry]\n", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("pyproject.toml")); + assert.equal(signals.primaryLanguage, "python"); + assert.ok(signals.verificationCommands.includes("pytest")); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: monorepo detection via workspaces", () => { + const dir = makeTempDir("signals-monorepo"); + try { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "mono", workspaces: ["packages/*"] }), + "utf-8", + ); + const signals = detectProjectSignals(dir); + assert.equal(signals.isMonorepo, true); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: monorepo detection via turbo.json", () => { + const dir = makeTempDir("signals-turbo"); + try { + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "test" }), "utf-8"); + writeFileSync(join(dir, "turbo.json"), "{}", "utf-8"); + const signals = detectProjectSignals(dir); + assert.equal(signals.isMonorepo, true); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: CI detection", () => { + const dir = makeTempDir("signals-ci"); + try { + mkdirSync(join(dir, ".github", "workflows"), { recursive: true }); + const signals = detectProjectSignals(dir); + assert.equal(signals.hasCI, true); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: test detection via jest config", () => { + const dir = makeTempDir("signals-tests"); + try { + writeFileSync(join(dir, "jest.config.ts"), "export default {}", "utf-8"); + const signals = detectProjectSignals(dir); + assert.equal(signals.hasTests, true); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: package manager detection", () => { + const dir1 = makeTempDir("pm-pnpm"); + const dir2 = makeTempDir("pm-yarn"); + const dir3 = makeTempDir("pm-bun"); + try { + writeFileSync(join(dir1, "pnpm-lock.yaml"), "", "utf-8"); + writeFileSync(join(dir1, "package.json"), "{}", "utf-8"); + assert.equal(detectProjectSignals(dir1).packageManager, "pnpm"); + + writeFileSync(join(dir2, "yarn.lock"), "", "utf-8"); + writeFileSync(join(dir2, "package.json"), "{}", "utf-8"); + assert.equal(detectProjectSignals(dir2).packageManager, "yarn"); + + writeFileSync(join(dir3, "bun.lockb"), "", "utf-8"); + writeFileSync(join(dir3, "package.json"), "{}", "utf-8"); + assert.equal(detectProjectSignals(dir3).packageManager, "bun"); + } finally { + cleanup(dir1); + cleanup(dir2); + cleanup(dir3); + } +}); + +test("detectProjectSignals: skips default npm test script", () => { + const dir = makeTempDir("signals-default-test"); + try { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "test", + scripts: { test: 'echo "Error: no test specified" && exit 1' }, + }), + "utf-8", + ); + const signals = detectProjectSignals(dir); + // Should NOT include the default npm test script + assert.equal( + signals.verificationCommands.some(c => c.includes("test")), + false, + ); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: pnpm uses pnpm commands", () => { + const dir = makeTempDir("signals-pnpm-cmds"); + try { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "test", + scripts: { test: "vitest", build: "tsc" }, + }), + "utf-8", + ); + writeFileSync(join(dir, "pnpm-lock.yaml"), "", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.verificationCommands.includes("pnpm test")); + assert.ok(signals.verificationCommands.includes("pnpm run build")); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: Ruby project with rspec", () => { + const dir = makeTempDir("signals-ruby"); + try { + writeFileSync(join(dir, "Gemfile"), 'source "https://rubygems.org"\n', "utf-8"); + mkdirSync(join(dir, "spec"), { recursive: true }); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("Gemfile")); + assert.equal(signals.primaryLanguage, "ruby"); + assert.ok(signals.verificationCommands.includes("bundle exec rspec")); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: Makefile with test target", () => { + const dir = makeTempDir("signals-make"); + try { + writeFileSync(join(dir, "Makefile"), "test:\n\tgo test ./...\n\nbuild:\n\tgo build\n", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("Makefile")); + assert.ok(signals.verificationCommands.includes("make test")); + } finally { + cleanup(dir); + } +}); diff --git a/src/resources/extensions/gsd/tests/init-wizard.test.ts b/src/resources/extensions/gsd/tests/init-wizard.test.ts new file mode 100644 index 000000000..cf10d2754 --- /dev/null +++ b/src/resources/extensions/gsd/tests/init-wizard.test.ts @@ -0,0 +1,197 @@ +/** + * Unit tests for GSD Init Wizard — project onboarding flow. + * + * Tests the bootstrap logic and preferences file generation + * without requiring interactive UI (tests the pure functions). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, readFileSync, rmSync, existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// We test the detection module integration since the wizard's UI +// requires interactive ctx/pi which can't be unit-tested directly. +// The bootstrap and preferences generation are tested via detection + filesystem checks. + +import { detectProjectState } from "../detection.ts"; + +function makeTempDir(prefix: string): string { + const dir = join( + tmpdir(), + `gsd-init-test-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function cleanup(dir: string): void { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // best-effort + } +} + +// ─── Detection Integration Tests ──────────────────────────────────────────────── + +test("init-wizard: clean folder detected as state=none", () => { + const dir = makeTempDir("clean"); + try { + const detection = detectProjectState(dir); + assert.equal(detection.state, "none"); + assert.equal(detection.v1, undefined); + assert.equal(detection.v2, undefined); + } finally { + cleanup(dir); + } +}); + +test("init-wizard: v1 .planning/ triggers v1-planning state", () => { + const dir = makeTempDir("v1"); + try { + mkdirSync(join(dir, ".planning", "phases", "01"), { recursive: true }); + mkdirSync(join(dir, ".planning", "phases", "02"), { recursive: true }); + writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# v1 roadmap\n", "utf-8"); + + const detection = detectProjectState(dir); + assert.equal(detection.state, "v1-planning"); + assert.ok(detection.v1); + assert.equal(detection.v1!.phaseCount, 2); + assert.equal(detection.v1!.hasRoadmap, true); + } finally { + cleanup(dir); + } +}); + +test("init-wizard: existing .gsd/ with milestones skips init", () => { + const dir = makeTempDir("existing"); + try { + mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true }); + mkdirSync(join(dir, ".gsd", "milestones", "M002"), { recursive: true }); + + const detection = detectProjectState(dir); + assert.equal(detection.state, "v2-gsd"); + assert.ok(detection.v2); + assert.equal(detection.v2!.milestoneCount, 2); + } finally { + cleanup(dir); + } +}); + +test("init-wizard: empty .gsd/ (no milestones) returns v2-gsd-empty", () => { + const dir = makeTempDir("empty-gsd"); + try { + mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true }); + + const detection = detectProjectState(dir); + assert.equal(detection.state, "v2-gsd-empty"); + assert.ok(detection.v2); + assert.equal(detection.v2!.milestoneCount, 0); + } finally { + cleanup(dir); + } +}); + +test("init-wizard: project signals populate from Node.js project", () => { + const dir = makeTempDir("node-project"); + try { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "my-app", + scripts: { test: "vitest", build: "tsc", lint: "eslint ." }, + }), + "utf-8", + ); + mkdirSync(join(dir, ".git"), { recursive: true }); + mkdirSync(join(dir, ".github", "workflows"), { recursive: true }); + mkdirSync(join(dir, "__tests__"), { recursive: true }); + + const detection = detectProjectState(dir); + const signals = detection.projectSignals; + assert.equal(signals.primaryLanguage, "javascript/typescript"); + assert.equal(signals.isGitRepo, true); + assert.equal(signals.hasCI, true); + assert.equal(signals.hasTests, true); + assert.ok(signals.verificationCommands.length > 0); + } finally { + cleanup(dir); + } +}); + +test("init-wizard: v2 .gsd/ preferences detected", () => { + const dir = makeTempDir("prefs-detect"); + try { + mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true }); + writeFileSync(join(dir, ".gsd", "preferences.md"), "---\nversion: 1\nmode: solo\n---\n", "utf-8"); + + const detection = detectProjectState(dir); + assert.ok(detection.v2); + assert.equal(detection.v2!.hasPreferences, true); + } finally { + cleanup(dir); + } +}); + +test("init-wizard: v2 uppercase PREFERENCES.md also detected", () => { + const dir = makeTempDir("prefs-upper"); + try { + mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true }); + writeFileSync(join(dir, ".gsd", "PREFERENCES.md"), "---\nversion: 1\n---\n", "utf-8"); + + const detection = detectProjectState(dir); + assert.ok(detection.v2); + assert.equal(detection.v2!.hasPreferences, true); + } finally { + cleanup(dir); + } +}); + +test("init-wizard: CONTEXT.md detected in v2", () => { + const dir = makeTempDir("context"); + try { + mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true }); + writeFileSync(join(dir, ".gsd", "CONTEXT.md"), "# Project Context\n", "utf-8"); + + const detection = detectProjectState(dir); + assert.ok(detection.v2); + assert.equal(detection.v2!.hasContext, true); + } finally { + cleanup(dir); + } +}); + +test("init-wizard: multiple project files detected together", () => { + const dir = makeTempDir("multi-files"); + try { + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "test" }), "utf-8"); + writeFileSync(join(dir, "Makefile"), "build:\n\techo ok\n", "utf-8"); + mkdirSync(join(dir, ".git"), { recursive: true }); + + const detection = detectProjectState(dir); + const signals = detection.projectSignals; + assert.ok(signals.detectedFiles.includes("package.json")); + assert.ok(signals.detectedFiles.includes("Makefile")); + assert.equal(signals.isGitRepo, true); + } finally { + cleanup(dir); + } +}); + +test("init-wizard: v1 with both .planning/ and .gsd/ prioritizes v2", () => { + const dir = makeTempDir("both-v1-v2"); + try { + mkdirSync(join(dir, ".planning", "phases"), { recursive: true }); + mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true }); + + const detection = detectProjectState(dir); + // v2 should take priority + assert.equal(detection.state, "v2-gsd"); + // But v1 info should still be available for migration reference + assert.ok(detection.v1); + } finally { + cleanup(dir); + } +});