diff --git a/.changeset/steady-mice-frolic.md b/.changeset/steady-mice-frolic.md new file mode 100644 index 000000000..cf3a8ba59 --- /dev/null +++ b/.changeset/steady-mice-frolic.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 1991 +--- +**`/gsd:onboard` guides brownfield setup** — existing repos now have a top-level onboarding command that routes through codebase mapping, docs ingest, project initialization, and an onboarding summary without silently overwriting planning files. diff --git a/commands/gsd/onboard.md b/commands/gsd/onboard.md new file mode 100644 index 000000000..ae2517b1c --- /dev/null +++ b/commands/gsd/onboard.md @@ -0,0 +1,46 @@ +--- +name: gsd:onboard +description: Guide existing codebase onboarding through mapping, doc ingest, and planning setup +argument-hint: "[--fast] [--text]" +allowed-tools: + - Read + - Bash + - Write + - Glob + - Grep + - Agent + - AskUserQuestion +requires: [config, new-project, map-codebase, ingest-docs] +--- + +**Copilot (VS Code):** Use `vscode_askquestions` wherever this workflow calls `AskUserQuestion`. They are equivalent — `vscode_askquestions` is the VS Code Copilot implementation of the same interactive question API. + + + +Guide brownfield onboarding for an existing codebase by routing through the existing GSD primitives in the safe order: codebase map → docs ingest → project initialization → onboarding summary. + +**Creates or confirms:** +- `.planning/codebase/` — evidence-backed codebase map from `/gsd:map-codebase` +- `.planning/PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, `STATE.md` — project setup from `/gsd:new-project` or `/gsd:ingest-docs` +- `.planning/onboarding/SUMMARY.md` — lightweight index of what was learned and the next command + +**Non-goals:** This command does not execute phases, ship work, or overwrite existing planning artifacts without an explicit gate. + + + +@~/.claude/gsd-core/workflows/onboard.md +@~/.claude/gsd-core/references/ui-brand.md +@~/.claude/gsd-core/references/gate-prompts.md + + + +Arguments: $ARGUMENTS + +Flags: +- `--fast` — prefer `/gsd:map-codebase --fast` for the mapping handoff. +- `--text` — use plain-text numbered lists instead of TUI menus. + + + +Execute the onboard workflow end-to-end. Preserve all safety gates, text-mode fallbacks, idempotency checks, and top-level handoff rules for nested interactive commands. + diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index e8f968d1b..5e25aeffd 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -58,6 +58,25 @@ Initialize a new project with deep context gathering. --- +### `/gsd-onboard` + +Guide an existing codebase through first-time GSD onboarding. The command checks repo state, routes you through codebase mapping, optional docs ingest, project initialization, and creates an onboarding summary once planning exists. + +| Flag | Description | +|------|-------------| +| `--fast` | Prefer the lightweight `/gsd-map-codebase --fast` mapping handoff | +| `--text` | Use numbered plain-text gates instead of TUI menus | + +**Prerequisites:** Existing repo or planning docs. For empty greenfield projects, use `/gsd-new-project`. +**Produces:** `.planning/codebase/` via map-codebase, `.planning/` via new-project or ingest-docs, and `.planning/onboarding/SUMMARY.md` after project setup. + +```bash +/gsd-onboard # Guided brownfield onboarding +/gsd-onboard --fast # Use lightweight codebase mapping first +``` + +--- + ### `/gsd-workspace` Manage GSD workspaces — create, list, or remove isolated workspace environments with repo copies and independent `.planning/` directories. diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index aa41acbdf..a05d6e304 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -79,6 +79,7 @@ These six routers are descriptor-only entries that the model picks first; the bo | Command | Role | Source | |---------|------|--------| | `/gsd-new-project` | Initialize a new project with deep context gathering and PROJECT.md. | [commands/gsd/new-project.md](../commands/gsd/new-project.md) | +| `/gsd-onboard` | Guide existing codebase onboarding through mapping, docs ingest, project setup, and onboarding summary. | [commands/gsd/onboard.md](../commands/gsd/onboard.md) | | `/gsd-workspace` | Manage GSD workspaces — create (`--new`), list (`--list`), or remove (`--remove`) isolated workspace environments. | [commands/gsd/workspace.md](../commands/gsd/workspace.md) | | `/gsd-discuss-phase` | Gather phase context through adaptive questioning before planning. | [commands/gsd/discuss-phase.md](../commands/gsd/discuss-phase.md) | | `/gsd-mvp-phase` | Plan a phase as a vertical MVP slice — user story, SPIDR splitting, then plan-phase. | [commands/gsd/mvp-phase.md](../commands/gsd/mvp-phase.md) | @@ -223,6 +224,7 @@ Full roster at `gsd-core/workflows/*.md`. Workflows are thin orchestrators that | `milestone-summary.md` | Milestone summary synthesis — onboarding and review artifact from milestone artifacts. | `/gsd-milestone-summary` | | `new-milestone.md` | Start a new milestone cycle — load project context, gather goals, update PROJECT.md/STATE.md. | `/gsd-new-milestone` | | `new-project.md` | Unified new-project flow — questioning, research (optional), requirements, roadmap. | `/gsd-new-project` | +| `onboard.md` | Brownfield onboarding orchestration — map codebase, ingest docs, initialize planning, summarize next step. | `/gsd-onboard` | | `new-workspace.md` | Create an isolated workspace with repo worktrees/clones and an independent `.planning/`. | `/gsd-workspace --new` | | `next.md` | Detect current project state and automatically advance to the next logical step. | `/gsd-progress --next` | | `node-repair.md` | Autonomous repair operator for failed task verification; invoked by `execute-plan`. | `execute-plan.md` (recovery) | diff --git a/gsd-core/workflows/help/modes/brief.md b/gsd-core/workflows/help/modes/brief.md index 359c32f1a..46a1cd41f 100644 --- a/gsd-core/workflows/help/modes/brief.md +++ b/gsd-core/workflows/help/modes/brief.md @@ -7,7 +7,8 @@ One-liner refresher for returning users. Output ONLY the `` content b ```text /gsd:new-project Initialize a project (greenfield) -/gsd:map-codebase Map an existing codebase (brownfield) +/gsd:onboard Onboard an existing codebase (brownfield) +/gsd:map-codebase Refresh/map codebase intelligence /gsd:plan-phase Create a phase plan /gsd:execute-phase Execute a phase /gsd:progress Where am I, what's next diff --git a/gsd-core/workflows/help/modes/default.md b/gsd-core/workflows/help/modes/default.md index 866f1d7dd..86274d63b 100644 --- a/gsd-core/workflows/help/modes/default.md +++ b/gsd-core/workflows/help/modes/default.md @@ -11,11 +11,12 @@ Plan-driven development for solo agentic work with Claude Code. GSD Core turns a ```text /gsd:new-project # Greenfield: questioning → research → requirements → roadmap +/gsd:onboard # Existing codebase: map → ingest docs → initialize planning /gsd:plan-phase 1 # Create a detailed plan for phase 1 /gsd:execute-phase 1 # Execute all plans in the phase ``` -Existing codebase? Run `/gsd:map-codebase` first to ground GSD in your code. +Existing codebase? Run `/gsd:onboard` to map the repo, ingest existing docs, and initialize planning safely. ## Common commands diff --git a/gsd-core/workflows/help/modes/full.md b/gsd-core/workflows/help/modes/full.md index a6686bbae..d571e66ca 100644 --- a/gsd-core/workflows/help/modes/full.md +++ b/gsd-core/workflows/help/modes/full.md @@ -62,6 +62,16 @@ Creates all `.planning/` artifacts: Usage: `/gsd:new-project` +**`/gsd:onboard [--fast] [--text]`** +Guide first-time onboarding for an existing codebase. + +- Detects brownfield code, existing planning docs, and partial `.planning/` state +- Routes through `/gsd:map-codebase`, `/gsd:ingest-docs`, and `/gsd:new-project` in the safe order +- Creates `.planning/onboarding/SUMMARY.md` after project setup +- Idempotent: confirms existing artifacts and does not overwrite planning silently + +Usage: `/gsd:onboard` + **`/gsd:map-codebase [--fast] [--focus ] [--query ]`** Map an existing codebase for brownfield projects. diff --git a/gsd-core/workflows/help/modes/topic.md b/gsd-core/workflows/help/modes/topic.md index d89229607..89b380263 100644 --- a/gsd-core/workflows/help/modes/topic.md +++ b/gsd-core/workflows/help/modes/topic.md @@ -9,7 +9,7 @@ Emit a section from the full reference for the topic in `$ARGUMENTS`. Read `work |---|---| | `next`, `smart-entry` | `### Smart Entry` | | `workflow`, `core`, `core-workflow` | `## Core Workflow` (entire section through end of `### Quick Mode`) | -| `init`, `new-project` | `### Project Initialization` | +| `init`, `new-project`, `onboard`, `onboarding`, `brownfield` | `### Project Initialization` | | `map`, `map-codebase` | The `/gsd:map-codebase` block under `### Project Initialization` | | `discuss`, `discuss-phase` | The `/gsd:discuss-phase` block under `### Phase Planning` | | `plan`, `planning`, `plan-phase` | `### Phase Planning` | diff --git a/gsd-core/workflows/onboard.md b/gsd-core/workflows/onboard.md new file mode 100644 index 000000000..851a7f642 --- /dev/null +++ b/gsd-core/workflows/onboard.md @@ -0,0 +1,246 @@ + +Guide existing-codebase onboarding by sequencing the already-owned GSD primitives: +`/gsd:map-codebase`, `/gsd:ingest-docs`, and `/gsd:new-project`. The workflow is +idempotent: it confirms existing artifacts, refuses to overwrite planning data silently, +and stops with the exact next top-level command whenever a nested interactive workflow +would be unsafe. + + + +Read all files referenced by the invoking prompt's execution_context before starting. + + + + +## 1. Initialize + +Display banner: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + GSD ► ONBOARDING +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +Parse `$ARGUMENTS`: +- `--fast` sets `MAP_COMMAND` to `/gsd:map-codebase --fast`; otherwise `/gsd:map-codebase`. +- `--text` sets `TEXT_MODE=true`. + +Run the init projection: + +```bash +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found. Run: npx -y @opengsd/gsd-core@latest --local" >&2; exit 1; fi +INIT=$(gsd_run init onboard) +if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi +``` + +Parse JSON fields: `planning_exists`, `project_exists`, `roadmap_exists`, `state_exists`, +`has_existing_code`, `has_package_file`, `is_brownfield`, `has_codebase_map`, +`missing_codebase_map_files`, `has_docs_candidates`, `doc_candidate_count`, +`onboarding_summary_exists`, `text_mode`, `agents_installed`, `missing_agents`, +`has_git`, `git_worktree_root`, `in_nested_subdir`. + +Set `TEXT_MODE=true` if `--text` is present OR `text_mode` from INIT is true. + +## 2. Git and Existing Planning Safety + +If `has_git` is true and `in_nested_subdir` is true, warn that onboarding artifacts will +belong to the outer worktree at `git_worktree_root`. Do not run `git init` here. + +If `planning_exists` is true, do not overwrite PROJECT/ROADMAP/STATE. Onboarding is +idempotent and must only confirm or summarize existing files unless the user explicitly +runs the lower-level refresh commands. + +## 3. Codebase Mapping Gate + +If `is_brownfield` is true and `has_codebase_map` is false: + +- If `TEXT_MODE=true`, print: + +```text +Existing code was detected, but the complete .planning/codebase/ map is missing. +Missing map files: {missing_codebase_map_files} + +1. Map codebase first — run {MAP_COMMAND} to understand the repo before project setup (Recommended) +2. Skip mapping — continue with weaker onboarding context + +Enter number: +``` + +Stop and wait for the user's reply. + +- Otherwise use AskUserQuestion: + - header: "Codebase" + - question: "Existing code was detected, but the complete .planning/codebase/ map is missing. Map it first?" + - options: + - "Map codebase first" — Run `{MAP_COMMAND}` to understand the repo before project setup (Recommended) + - "Skip mapping" — Continue with weaker onboarding context + +If the user chooses mapping, do not nest the interactive map-codebase workflow. Print: + +```text +Run this top-level command first, then rerun /gsd:onboard: + +{MAP_COMMAND} +``` + +Exit. + +If the user skips mapping, continue with a warning. + +If `is_brownfield` is false and `planning_exists` is false, print: + +```text +No existing code was detected. For a greenfield project, run: + +/gsd:new-project +``` + +Exit. + +## 4. Existing Docs Gate + +If `has_docs_candidates` is true and `planning_exists` is false: + +- If `TEXT_MODE=true`, print: + +```text +Detected {doc_candidate_count} possible ADR/PRD/SPEC/RFC document(s). + +1. Ingest docs first — run /gsd:ingest-docs to bootstrap planning from existing docs (Recommended) +2. Skip docs ingest — continue to /gsd:new-project + +Enter number: +``` + +Stop and wait for the user's reply. + +- Otherwise use AskUserQuestion: + - header: "Docs" + - question: "Detected {doc_candidate_count} possible ADR/PRD/SPEC/RFC document(s). Ingest them first?" + - options: + - "Ingest docs first" — Run `/gsd:ingest-docs` to bootstrap planning from existing docs (Recommended) + - "Skip docs ingest" — Continue to `/gsd:new-project` + +If the user chooses ingest, do not nest the interactive ingest-docs workflow. Print: + +```text +Run this top-level command first, then rerun /gsd:onboard: + +/gsd:ingest-docs +``` + +Exit. + +## 5. Project Initialization Gate + +If `project_exists` is false: + +Print: + +```text +Codebase context is ready for project initialization. + +Run this top-level command, then rerun /gsd:onboard: + +/gsd:new-project +``` + +Exit. + +If `project_exists` is true, continue. If `roadmap_exists` or `state_exists` is false, +warn that `.planning/` is partial and route to `/gsd:ingest-docs --mode merge` or +`/gsd:new-project` rather than overwriting files in-place. + +## 6. Write Onboarding Summary + +Create `.planning/onboarding/SUMMARY.md` only after PROJECT.md exists. If it already +exists, update it only after confirmation; do not overwrite silently. + +Summary contents: + +```markdown +# Onboarding Summary + +**Generated:** {YYYY-MM-DD} +**Status:** Ready for GSD workflow + +## Confirmed Artifacts + +- Project: .planning/PROJECT.md +- Roadmap: .planning/ROADMAP.md +- State: .planning/STATE.md +- Codebase map: .planning/codebase/ + +## Codebase Map + +- STACK.md +- ARCHITECTURE.md +- STRUCTURE.md +- CONVENTIONS.md +- TESTING.md +- INTEGRATIONS.md +- CONCERNS.md + +## Existing Docs + +Detected planning docs: {doc_candidate_count} + +## Recommended Next Step + +/gsd:discuss-phase 1 +``` + +Commit the summary if `commit_docs` is true: + +```bash +gsd_run query commit "docs: create onboarding summary" --files .planning/onboarding/SUMMARY.md +``` + +## 7. Final Status + +Print: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + GSD ► ONBOARDING COMPLETE ✓ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Created / confirmed: +- .planning/codebase/ +- .planning/PROJECT.md +- .planning/REQUIREMENTS.md +- .planning/ROADMAP.md +- .planning/STATE.md +- .planning/onboarding/SUMMARY.md + +─────────────────────────────────────────────────────────────── + +## ▶ Next Up + +**Discuss Phase 1** — capture implementation decisions before planning. + +`/gsd:discuss-phase 1` + +─────────────────────────────────────────────────────────────── + +**Also available:** +- `/gsd:map-codebase` — refresh the codebase map after significant changes +- `/gsd:ingest-docs --mode merge` — merge new ADR/PRD/SPEC docs into planning +- `/gsd:progress` — inspect current workflow state + +─────────────────────────────────────────────────────────────── +``` + + + + +- [ ] Existing codebase detected before project initialization +- [ ] Missing codebase map routes to `/gsd:map-codebase` or `/gsd:map-codebase --fast` +- [ ] Existing planning docs route to `/gsd:ingest-docs` +- [ ] Missing project setup routes to `/gsd:new-project` +- [ ] Existing `.planning/` files are not overwritten silently +- [ ] Text mode stops and waits at numbered-list gates +- [ ] `.planning/onboarding/SUMMARY.md` is created only after PROJECT.md exists +- [ ] User sees next command + diff --git a/skills/gsd-onboard/SKILL.md b/skills/gsd-onboard/SKILL.md new file mode 100644 index 000000000..3625c3ef1 --- /dev/null +++ b/skills/gsd-onboard/SKILL.md @@ -0,0 +1,46 @@ +--- +name: gsd-onboard +description: "Guide existing codebase onboarding through mapping, doc ingest, and planning setup" +argument-hint: "[--fast] [--text]" +allowed-tools: + - Read + - Bash + - Write + - Glob + - Grep + - Agent + - AskUserQuestion +--- + + +**Copilot (VS Code):** Use `vscode_askquestions` wherever this workflow calls `AskUserQuestion`. They are equivalent — `vscode_askquestions` is the VS Code Copilot implementation of the same interactive question API. + + + +Guide brownfield onboarding for an existing codebase by routing through the existing GSD primitives in the safe order: codebase map → docs ingest → project initialization → onboarding summary. + +**Creates or confirms:** +- `.planning/codebase/` — evidence-backed codebase map from `/gsd-map-codebase` +- `.planning/PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, `STATE.md` — project setup from `/gsd-new-project` or `/gsd-ingest-docs` +- `.planning/onboarding/SUMMARY.md` — lightweight index of what was learned and the next command + +**Non-goals:** This command does not execute phases, ship work, or overwrite existing planning artifacts without an explicit gate. + + + +@~/.claude/gsd-core/workflows/onboard.md +@~/.claude/gsd-core/references/ui-brand.md +@~/.claude/gsd-core/references/gate-prompts.md + + + +Arguments: $ARGUMENTS + +Flags: +- `--fast` — prefer `/gsd-map-codebase --fast` for the mapping handoff. +- `--text` — use plain-text numbered lists instead of TUI menus. + + + +Execute the onboard workflow end-to-end. Preserve all safety gates, text-mode fallbacks, idempotency checks, and top-level handoff rules for nested interactive commands. + diff --git a/src/clusters.cts b/src/clusters.cts index 2ea92abfd..7ba2ff05b 100644 --- a/src/clusters.cts +++ b/src/clusters.cts @@ -33,6 +33,7 @@ export const CLUSTERS: ClusterMap = Object.freeze({ core_loop: Object.freeze([ 'next', 'new-project', + 'onboard', 'discuss-phase', 'plan-phase', 'execute-phase', diff --git a/src/command-aliases.cts b/src/command-aliases.cts index 9df935e33..19c2a36be 100644 --- a/src/command-aliases.cts +++ b/src/command-aliases.cts @@ -296,6 +296,14 @@ export const INIT_COMMAND_ALIASES: CommandAlias[] = [ "subcommand": "new-milestone", "mutation": false }, + { + "canonical": "init.onboard", + "aliases": [ + "init onboard" + ], + "subcommand": "onboard", + "mutation": false + }, { "canonical": "init.quick", "aliases": [ diff --git a/src/init-command-router.cts b/src/init-command-router.cts index 317ce0059..29b84ea59 100644 --- a/src/init-command-router.cts +++ b/src/init-command-router.cts @@ -28,6 +28,7 @@ interface InitModule { cmdInitPlanPhase(cwd: string, phase: string | undefined, raw: boolean, opts: Record): void; cmdInitNewProject(cwd: string, raw: boolean): void; cmdInitNewMilestone(cwd: string, raw: boolean): void; + cmdInitOnboard(cwd: string, raw: boolean): void; cmdInitQuick(cwd: string, name: string, raw: boolean): void; cmdInitIngestDocs(cwd: string, raw: boolean): void; cmdInitResume(cwd: string, raw: boolean): void; @@ -71,6 +72,7 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio }, 'new-project': () => init.cmdInitNewProject(cwd, raw), 'new-milestone': () => init.cmdInitNewMilestone(cwd, raw), + onboard: () => init.cmdInitOnboard(cwd, raw), quick: () => init.cmdInitQuick(cwd, args.slice(2).join(' '), raw), 'ingest-docs': () => init.cmdInitIngestDocs(cwd, raw), resume: () => init.cmdInitResume(cwd, raw), diff --git a/src/init.cts b/src/init.cts index 1b9de1cb0..a533d65fd 100644 --- a/src/init.cts +++ b/src/init.cts @@ -86,6 +86,106 @@ void stripShippedMilestones; // Accept all bold/colon variants of the Requirements header (#2769) const REQUIREMENTS_HEADER_RE = /^\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]*)$/m; +const CODE_EXTENSIONS = new Set([ + '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.swift', '.java', + '.kt', '.kts', '.c', '.cpp', '.cc', '.h', '.hpp', '.cs', '.rb', '.php', '.dart', + '.m', '.mm', '.scala', '.groovy', '.lua', '.r', '.R', '.zig', '.ex', '.exs', '.clj', +]); + +const CODE_SCAN_SKIP_DIRS = new Set([ + 'node_modules', '.git', '.planning', '.claude', '.codex', '__pycache__', 'target', + 'dist', 'build', '.next', '.nuxt', '.svelte-kit', 'coverage', 'vendor', '.venv', 'venv', +]); + +const PACKAGE_FILES = [ + 'package.json', 'requirements.txt', 'pyproject.toml', 'Cargo.toml', 'go.mod', + 'Package.swift', 'build.gradle', 'build.gradle.kts', 'pom.xml', 'Gemfile', + 'composer.json', 'pubspec.yaml', 'CMakeLists.txt', 'Makefile', 'build.zig', + 'mix.exs', 'project.clj', +]; + +const REQUIRED_CODEBASE_MAP_FILES = [ + 'STACK.md', 'ARCHITECTURE.md', 'STRUCTURE.md', 'CONVENTIONS.md', 'TESTING.md', + 'INTEGRATIONS.md', 'CONCERNS.md', +]; + +function hasCodeFilesInternal(dir: string, depth = 0): boolean { + if (depth > 3) return false; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + + for (const entry of entries) { + if (entry.isFile() && CODE_EXTENSIONS.has(path.extname(entry.name))) return true; + if (entry.isDirectory() && !CODE_SCAN_SKIP_DIRS.has(entry.name)) { + if (hasCodeFilesInternal(path.join(dir, entry.name), depth + 1)) return true; + } + } + + return false; +} + +function hasPackageFileInternal(cwd: string): boolean { + return PACKAGE_FILES.some((file) => pathExistsInternal(cwd, file)); +} + +function listPlanningDocCandidates(cwd: string): string[] { + const roots = ['docs', 'adr', 'adrs', 'prd', 'prds', 'spec', 'specs', 'rfcs']; + const candidates = new Set(); + + const visit = (dir: string, relDir: string, depth: number): void => { + if (depth > 3) return; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const rel = relDir ? `${relDir}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (!CODE_SCAN_SKIP_DIRS.has(entry.name)) { + visit(path.join(dir, entry.name), rel, depth + 1); + } + continue; + } + + if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) continue; + const upperName = entry.name.toUpperCase(); + const relLower = rel.toLowerCase(); + if ( + /(^|[-_ ])(ADR|PRD|SPEC|RFC)([-_ ]|\.)/i.test(entry.name) || + /^\d{4}[-_].+\.md$/i.test(entry.name) || + relLower.includes('/adr') || + relLower.includes('/prd') || + relLower.includes('/spec') || + upperName === 'REQUIREMENTS.md' + ) { + candidates.add(toPosixPath(rel)); + } + } + }; + + for (const root of roots) { + const full = path.join(cwd, root); + if (fs.existsSync(full)) visit(full, root, 0); + } + + return [...candidates].sort(); +} + +function listCodebaseMapFiles(cwd: string): string[] { + const codebaseDir = path.join(planningRoot(cwd), 'codebase'); + if (!fs.existsSync(codebaseDir)) return []; + return REQUIRED_CODEBASE_MAP_FILES.filter((file) => + fs.existsSync(path.join(codebaseDir, file)), + ); +} + function listPhaseSummaryFiles(phaseDir: string): string[] { return (scanPhasePlans(phaseDir) as unknown as Record)['summaryFiles']; } @@ -625,68 +725,8 @@ function cmdInitNewProject(cwd: string, raw: boolean): void { const exaKeyFile = path.join(homedir, '.gsd', 'exa_api_key'); const hasExaSearch = !!(process.env['EXA_API_KEY'] || fs.existsSync(exaKeyFile)); - let hasCode = false; - let hasPackageFile = false; - try { - const codeExtensions = new Set([ - '.ts', '.js', '.py', '.go', '.rs', '.swift', '.java', - '.kt', '.kts', - '.c', '.cpp', '.h', - '.cs', - '.rb', - '.php', - '.dart', - '.m', '.mm', - '.scala', - '.groovy', - '.lua', - '.r', '.R', - '.zig', - '.ex', '.exs', - '.clj', - ]); - const skipDirs = new Set([ - 'node_modules', '.git', '.planning', '.claude', '.codex', - '__pycache__', 'target', 'dist', 'build', - ]); - function findCodeFiles(dir: string, depth: number): boolean { - if (depth > 3) return false; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return false; - } - for (const entry of entries) { - if (entry.isFile() && codeExtensions.has(path.extname(entry.name))) return true; - if (entry.isDirectory() && !skipDirs.has(entry.name)) { - if (findCodeFiles(path.join(dir, entry.name), depth + 1)) return true; - } - } - return false; - } - hasCode = findCodeFiles(cwd, 0); - } catch { - /* intentionally empty — best-effort detection */ - } - - hasPackageFile = - pathExistsInternal(cwd, 'package.json') || - pathExistsInternal(cwd, 'requirements.txt') || - pathExistsInternal(cwd, 'Cargo.toml') || - pathExistsInternal(cwd, 'go.mod') || - pathExistsInternal(cwd, 'Package.swift') || - pathExistsInternal(cwd, 'build.gradle') || - pathExistsInternal(cwd, 'build.gradle.kts') || - pathExistsInternal(cwd, 'pom.xml') || - pathExistsInternal(cwd, 'Gemfile') || - pathExistsInternal(cwd, 'composer.json') || - pathExistsInternal(cwd, 'pubspec.yaml') || - pathExistsInternal(cwd, 'CMakeLists.txt') || - pathExistsInternal(cwd, 'Makefile') || - pathExistsInternal(cwd, 'build.zig') || - pathExistsInternal(cwd, 'mix.exs') || - pathExistsInternal(cwd, 'project.clj'); + const hasCode = hasCodeFilesInternal(cwd); + const hasPackageFile = hasPackageFileInternal(cwd); const result: Record = { researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'), @@ -840,6 +880,57 @@ function cmdInitIngestDocs(cwd: string, raw: boolean): void { output(withProjectRoot(cwd, result), raw); } +function cmdInitOnboard(cwd: string, raw: boolean): void { + const config = loadConfig(cwd); + const codebaseMapFiles = listCodebaseMapFiles(cwd); + const missingCodebaseMapFiles = REQUIRED_CODEBASE_MAP_FILES.filter( + (file) => !codebaseMapFiles.includes(file), + ); + const docCandidates = listPlanningDocCandidates(cwd); + const hasCode = hasCodeFilesInternal(cwd); + const hasPackageFile = hasPackageFileInternal(cwd); + const isBrownfield = hasCode || hasPackageFile; + const hasCodebaseMap = codebaseMapFiles.length === REQUIRED_CODEBASE_MAP_FILES.length; + + const result: Record = { + commit_docs: config.commit_docs, + text_mode: + !!config.text_mode || !!((config.workflow ?? {}) as Record)['text_mode'], + + project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), + planning_exists: fs.existsSync(planningRoot(cwd)), + roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')), + state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')), + config_exists: fs.existsSync(path.join(planningDir(cwd), 'config.json')), + + has_existing_code: hasCode, + has_package_file: hasPackageFile, + is_brownfield: isBrownfield, + needs_codebase_map: isBrownfield && !hasCodebaseMap, + has_codebase_map: hasCodebaseMap, + codebase_dir_exists: fs.existsSync(path.join(planningRoot(cwd), 'codebase')), + codebase_map_files_present: codebaseMapFiles, + missing_codebase_map_files: missingCodebaseMapFiles, + + has_docs_candidates: docCandidates.length > 0, + doc_candidate_count: docCandidates.length, + doc_candidates: docCandidates, + + onboarding_summary_exists: pathExistsInternal(cwd, '.planning/onboarding/SUMMARY.md'), + onboarding_summary_path: '.planning/onboarding/SUMMARY.md', + + project_path: '.planning/PROJECT.md', + roadmap_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'ROADMAP.md'))), + state_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'STATE.md'))), + codebase_dir: toPosixPath(path.relative(cwd, path.join(planningRoot(cwd), 'codebase'))), + onboarding_dir: toPosixPath(path.relative(cwd, path.join(planningRoot(cwd), 'onboarding'))), + + ...getInitGitState(cwd), + }; + + output(withProjectRoot(cwd, result), raw); +} + function cmdInitResume(cwd: string, raw: boolean): void { const config = loadConfig(cwd); @@ -2606,6 +2697,7 @@ export = { cmdInitNewMilestone, cmdInitQuick, cmdInitIngestDocs, + cmdInitOnboard, cmdInitResume, cmdInitVerifyWork, cmdInitPhaseOp, diff --git a/src/install-profiles.cts b/src/install-profiles.cts index 90674c367..5db870050 100644 --- a/src/install-profiles.cts +++ b/src/install-profiles.cts @@ -40,6 +40,7 @@ const { const PROFILES = Object.freeze({ core: Object.freeze([ 'new-project', + 'onboard', 'discuss-phase', 'plan-phase', 'execute-phase', @@ -51,6 +52,7 @@ const PROFILES = Object.freeze({ standard: Object.freeze([ // Core loop 'new-project', + 'onboard', 'discuss-phase', 'plan-phase', 'execute-phase', diff --git a/tests/onboard-command.test.cjs b/tests/onboard-command.test.cjs new file mode 100644 index 000000000..a7903fe90 --- /dev/null +++ b/tests/onboard-command.test.cjs @@ -0,0 +1,158 @@ +// allow-test-rule: source-text-is-the-product +// Command/workflow markdown is deployed runtime product; source-text assertions +// below verify the installed command contract. CLI assertions exercise real +// gsd-tools behavior through the public command boundary. + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, cleanup } = require('./helpers.cjs'); +const { createFixture } = require('./fixtures/index.cjs'); + +const ROOT = path.join(__dirname, '..'); +const CMD_PATH = path.join(ROOT, 'commands', 'gsd', 'onboard.md'); +const WF_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'onboard.md'); + +describe('init onboard public CLI projection', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createFixture({ planning: false, projectDoc: false }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('reports brownfield code, docs, and missing planning state', () => { + fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'src', 'server.ts'), 'export const server = true;\n'); + fs.mkdirSync(path.join(tmpDir, 'docs', 'adr'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'docs', 'adr', '0001-runtime.md'), '# ADR: Runtime\n'); + + const result = runGsdTools('init onboard --raw', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init onboard should succeed: ${result.error}`); + + const parsed = JSON.parse(result.output); + assert.strictEqual(parsed.planning_exists, false); + assert.strictEqual(parsed.project_exists, false); + assert.strictEqual(parsed.has_existing_code, true); + assert.strictEqual(parsed.has_codebase_map, false); + assert.strictEqual(parsed.has_docs_candidates, true); + assert.strictEqual(parsed.doc_candidate_count, 1); + assert.deepStrictEqual(parsed.codebase_map_files_present, []); + assert.ok(parsed.doc_candidates.includes('docs/adr/0001-runtime.md')); + for (const file of ['STACK.md', 'ARCHITECTURE.md', 'STRUCTURE.md', 'CONVENTIONS.md', 'TESTING.md', 'INTEGRATIONS.md', 'CONCERNS.md']) { + assert.ok(parsed.missing_codebase_map_files.includes(file), `missing map files should include ${file}`); + } + assert.strictEqual(parsed.onboarding_summary_exists, false); + assert.strictEqual(parsed.text_mode, false); + }); + + test('reports complete codebase map and onboarding summary in existing planning', () => { + fs.mkdirSync(path.join(tmpDir, '.planning', 'codebase'), { recursive: true }); + for (const name of ['STACK', 'ARCHITECTURE', 'STRUCTURE', 'CONVENTIONS', 'TESTING', 'INTEGRATIONS', 'CONCERNS']) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'codebase', `${name}.md`), `# ${name}\n`); + } + fs.mkdirSync(path.join(tmpDir, '.planning', 'onboarding'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'onboarding', 'SUMMARY.md'), '# Onboarding Summary\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ workflow: { text_mode: true } })); + + const trackedFiles = [ + path.join(tmpDir, '.planning', 'PROJECT.md'), + path.join(tmpDir, '.planning', 'ROADMAP.md'), + path.join(tmpDir, '.planning', 'STATE.md'), + path.join(tmpDir, '.planning', 'onboarding', 'SUMMARY.md'), + ]; + const before = new Map(trackedFiles.map(file => [file, fs.readFileSync(file, 'utf8')])); + + const result = runGsdTools('init onboard --raw', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init onboard should succeed: ${result.error}`); + + for (const file of trackedFiles) { + assert.strictEqual(fs.readFileSync(file, 'utf8'), before.get(file), `${path.basename(file)} must not be mutated by init onboard`); + } + + const parsed = JSON.parse(result.output); + assert.strictEqual(parsed.planning_exists, true); + assert.strictEqual(parsed.project_exists, true); + assert.strictEqual(parsed.roadmap_exists, true); + assert.strictEqual(parsed.state_exists, true); + assert.strictEqual(parsed.has_codebase_map, true); + assert.deepStrictEqual(parsed.missing_codebase_map_files, []); + assert.strictEqual(parsed.onboarding_summary_exists, true); + assert.strictEqual(parsed.onboarding_summary_path, '.planning/onboarding/SUMMARY.md'); + assert.strictEqual(parsed.text_mode, true); + }); + + test('ignores generated and vendor directories when detecting existing code', () => { + fs.mkdirSync(path.join(tmpDir, 'node_modules', 'pkg'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'node_modules', 'pkg', 'index.ts'), 'export const ignored = true;\n'); + fs.mkdirSync(path.join(tmpDir, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'dist', 'bundle.js'), 'console.log("ignored");\n'); + + const result = runGsdTools('init onboard --raw', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init onboard should succeed: ${result.error}`); + + const parsed = JSON.parse(result.output); + assert.strictEqual(parsed.has_existing_code, false); + assert.strictEqual(parsed.has_package_file, false); + assert.strictEqual(parsed.is_brownfield, false); + assert.strictEqual(parsed.needs_codebase_map, false); + }); + + test('treats package manifests as brownfield even without source files', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"fixture"}\n'); + + const result = runGsdTools('init onboard --raw', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init onboard should succeed: ${result.error}`); + + const parsed = JSON.parse(result.output); + assert.strictEqual(parsed.has_existing_code, false); + assert.strictEqual(parsed.has_package_file, true); + assert.strictEqual(parsed.is_brownfield, true); + assert.strictEqual(parsed.needs_codebase_map, true); + }); + + test('dotted query init.onboard matches direct init onboard', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"fixture"}\n'); + + const direct = runGsdTools(['init', 'onboard', '--raw'], tmpDir, { HOME: tmpDir }); + const query = runGsdTools(['query', 'init.onboard', '--raw'], tmpDir, { HOME: tmpDir }); + assert.equal(direct.success, true, direct.error || direct.output); + assert.equal(query.success, true, query.error || query.output); + assert.deepStrictEqual(JSON.parse(query.output), JSON.parse(direct.output)); + }); + +}); + +describe('/gsd:onboard command contract', () => { + test('command file declares the onboard command and loads its workflow', () => { + const content = fs.readFileSync(CMD_PATH, 'utf8'); + assert.match(content, /^name:\s*gsd:onboard$/m); + assert.match(content, /^description:\s*.*(?:existing codebase|brownfield|onboard).*$/mi); + assert.match(content, /^\s*- AskUserQuestion$/m); + assert.match(content, /^\s*- Agent$/m); + assert.ok(content.includes('@~/.claude/gsd-core/workflows/onboard.md')); + assert.ok(content.includes('@~/.claude/gsd-core/references/ui-brand.md')); + assert.ok(content.includes('@~/.claude/gsd-core/references/gate-prompts.md')); + }); + + test('workflow routes through existing primitives and protects existing planning', () => { + const content = fs.readFileSync(WF_PATH, 'utf8'); + + assert.ok(content.includes('init onboard'), 'workflow must use init onboard projection'); + assert.ok(content.includes('map-codebase'), 'workflow must route to map-codebase'); + assert.ok(content.includes('ingest-docs'), 'workflow must route to ingest-docs'); + assert.ok(content.includes('new-project'), 'workflow must route to new-project'); + assert.ok(content.includes('.planning/onboarding/SUMMARY.md'), 'workflow must create onboarding summary'); + assert.match(content, /overwrite|idempotent|do not overwrite/i, 'workflow must protect existing planning'); + assert.ok(content.includes('--text'), 'workflow must document text-mode fallback'); + assert.ok(!content.includes('execute-phase'), 'onboarding must not execute implementation phases'); + assert.ok(!content.includes('gsd:ship'), 'onboarding must not ship work'); + }); +});