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');
+ });
+});