Merge pull request #976 from jeremymcs/feature/onboarding-detection-wizard
feat: project onboarding detection and init wizard
This commit is contained in:
commit
fbadd369f4
8 changed files with 2218 additions and 23 deletions
431
.plans/onboarding-detection-wizard.md
Normal file
431
.plans/onboarding-detection-wizard.md
Normal 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
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
469
src/resources/extensions/gsd/detection.ts
Normal file
469
src/resources/extensions/gsd/detection.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
611
src/resources/extensions/gsd/init-wizard.ts
Normal file
611
src/resources/extensions/gsd/init-wizard.ts
Normal 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;
|
||||
}
|
||||
398
src/resources/extensions/gsd/tests/detection.test.ts
Normal file
398
src/resources/extensions/gsd/tests/detection.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
197
src/resources/extensions/gsd/tests/init-wizard.test.ts
Normal file
197
src/resources/extensions/gsd/tests/init-wizard.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
|
|
@ -23,7 +23,6 @@ import { StringEnum } from "@gsd/pi-ai";
|
|||
import { type ExtensionAPI, getMarkdownTheme } from "@gsd/pi-coding-agent";
|
||||
import { Container, Markdown, Spacer, Text } from "@gsd/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { parseBundledExtensionPaths } from "../../../bundled-extension-paths.js";
|
||||
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
|
||||
import {
|
||||
type IsolationEnvironment,
|
||||
|
|
@ -333,7 +332,7 @@ async function runSingleAgent(
|
|||
let wasAborted = false;
|
||||
|
||||
const exitCode = await new Promise<number>((resolve) => {
|
||||
const bundledPaths = parseBundledExtensionPaths(process.env.GSD_BUNDLED_EXTENSION_PATHS);
|
||||
const bundledPaths = (process.env.GSD_BUNDLED_EXTENSION_PATHS ?? "").split(path.delimiter).map(s => s.trim()).filter(Boolean);
|
||||
const extensionArgs = bundledPaths.flatMap(p => ["--extension", p]);
|
||||
const proc = spawn(
|
||||
process.execPath,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue