feat(#1990): add onboard command for brownfield setup

This commit is contained in:
Jeremy McSpadden
2026-07-03 09:28:58 -05:00
committed by Codesmith
parent ed79902509
commit 896c2740d3
16 changed files with 704 additions and 65 deletions

View File

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

46
commands/gsd/onboard.md Normal file
View File

@@ -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]
---
<runtime_note>
**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.
</runtime_note>
<objective>
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.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/onboard.md
@~/.claude/gsd-core/references/ui-brand.md
@~/.claude/gsd-core/references/gate-prompts.md
</execution_context>
<context>
Arguments: $ARGUMENTS
Flags:
- `--fast` — prefer `/gsd:map-codebase --fast` for the mapping handoff.
- `--text` — use plain-text numbered lists instead of TUI menus.
</context>
<process>
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.
</process>

View File

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

View File

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

View File

@@ -7,7 +7,8 @@ One-liner refresher for returning users. Output ONLY the `<reference>` 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 <N> Create a phase plan
/gsd:execute-phase <N> Execute a phase
/gsd:progress Where am I, what's next

View File

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

View File

@@ -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 <area>] [--query <term>]`**
Map an existing codebase for brownfield projects.

View File

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

View File

@@ -0,0 +1,246 @@
<purpose>
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.
</purpose>
<required_reading>
Read all files referenced by the invoking prompt's execution_context before starting.
</required_reading>
<process>
## 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
───────────────────────────────────────────────────────────────
```
</process>
<success_criteria>
- [ ] 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
</success_criteria>

View File

@@ -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
---
<runtime_note>
**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.
</runtime_note>
<objective>
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.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/onboard.md
@~/.claude/gsd-core/references/ui-brand.md
@~/.claude/gsd-core/references/gate-prompts.md
</execution_context>
<context>
Arguments: $ARGUMENTS
Flags:
- `--fast` — prefer `/gsd-map-codebase --fast` for the mapping handoff.
- `--text` — use plain-text numbered lists instead of TUI menus.
</context>
<process>
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.
</process>

View File

@@ -33,6 +33,7 @@ export const CLUSTERS: ClusterMap = Object.freeze({
core_loop: Object.freeze([
'next',
'new-project',
'onboard',
'discuss-phase',
'plan-phase',
'execute-phase',

View File

@@ -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": [

View File

@@ -28,6 +28,7 @@ interface InitModule {
cmdInitPlanPhase(cwd: string, phase: string | undefined, raw: boolean, opts: Record<string, string | boolean | null>): 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),

View File

@@ -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<string>();
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<string, string[]>)['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<string, unknown> = {
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<string, unknown> = {
commit_docs: config.commit_docs,
text_mode:
!!config.text_mode || !!((config.workflow ?? {}) as Record<string, unknown>)['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,

View File

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

View File

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