feat: add project onboarding detection and init wizard

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)
This commit is contained in:
Jeremy McSpadden 2026-03-17 17:31:52 -05:00
parent ecedbfe9df
commit d5161fddb9
7 changed files with 2217 additions and 21 deletions

View file

@ -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

View file

@ -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 <type> <text> 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<void> {
);
}
async function handleSetup(args: string, ctx: ExtensionCommandContext): Promise<void> {
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<void> {
const trimmed = args.trim();

View file

@ -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<string, string> = {
"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<string, string> | 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 [];
}
}

View file

@ -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<void> {
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);

View file

@ -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<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(`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<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> {
// 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<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" },
],
});
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;
}

View file

@ -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);
}
});

View file

@ -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);
}
});