diff --git a/.gitignore b/.gitignore index 9562ecd17..c44176af8 100644 --- a/.gitignore +++ b/.gitignore @@ -192,6 +192,7 @@ build/ /gsd-core/bin/lib/eval.cjs /gsd-core/bin/lib/eval-command-router.cjs /gsd-core/bin/lib/init-command-router.cjs +/gsd-core/bin/lib/onboard-projection.cjs /gsd-core/bin/lib/agent-command-router.cjs /gsd-core/bin/lib/agent-install-check.cjs /gsd-core/bin/lib/task-command-router.cjs diff --git a/eslint.config.mjs b/eslint.config.mjs index 580c90822..a000dd984 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -162,6 +162,7 @@ export default tseslint.config( 'gsd-core/bin/lib/eval.cjs', 'gsd-core/bin/lib/eval-command-router.cjs', 'gsd-core/bin/lib/init-command-router.cjs', + 'gsd-core/bin/lib/onboard-projection.cjs', 'gsd-core/bin/lib/agent-command-router.cjs', 'gsd-core/bin/lib/agent-install-check.cjs', 'gsd-core/bin/lib/task-command-router.cjs', diff --git a/gsd-core/references/gsd-run-resolver.md b/gsd-core/references/gsd-run-resolver.md new file mode 100644 index 000000000..dd584b755 --- /dev/null +++ b/gsd-core/references/gsd-run-resolver.md @@ -0,0 +1,8 @@ +# Standard `gsd_run` Resolver + +Use this bootstrap block when a workflow must call `gsd-tools` from the active worktree/runtime. +Keep this resolver centralized; workflows should reference this file instead of copying the shell probe. + +```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 "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/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" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi +``` diff --git a/gsd-core/workflows/onboard.md b/gsd-core/workflows/onboard.md index 6dc5a1f97..d955e3048 100644 --- a/gsd-core/workflows/onboard.md +++ b/gsd-core/workflows/onboard.md @@ -1,360 +1,219 @@ - -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. - +# /gsd:onboard Workflow - -Read all files referenced by the invoking prompt's execution_context before starting. - +One-command onboarding for an existing or unknown repo. This workflow is a thin +renderer around `init onboard`; deterministic routing lives in the CLI projection. - +@~/.claude/gsd-core/references/gsd-run-resolver.md -## 1. Initialize - -Display banner: - -``` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - GSD ► ONBOARDING -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -``` +## 1. Render the Onboarding Projection Parse `$ARGUMENTS`: -- `--fast` sets `MAP_COMMAND` to `/gsd:map-codebase --fast`, passes `--fast` to the init projection, and accepts the default fast map subset (STACK, INTEGRATIONS, ARCHITECTURE, STRUCTURE) as sufficient onboarding context; otherwise `MAP_COMMAND` is `/gsd:map-codebase`. -- `--text` sets `TEXT_MODE=true`. +- `--fast` passes `--fast` to `init onboard`. Fast mode accepts the fast map for lightweight onboarding only; `next_action` still decides whether complete map work is required before project setup. +- `--text` forces text-mode choices for runtimes without `AskUserQuestion`. -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 "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/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" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi -``` - -Run init from parsed prompt args, not shell `$ARGUMENTS`: `--fast` => `INIT=$(gsd_run --cwd "$_GSD_RUNTIME_ROOT" init onboard --fast)`; default omits `--fast`. +Run the standard `gsd_run` resolver from the reference above, then run the projection from the runtime root: ```bash +# If --fast was parsed from $ARGUMENTS: +INIT=$(gsd_run --cwd "$_GSD_RUNTIME_ROOT" init onboard --fast --raw) +# Otherwise: +INIT=$(gsd_run --cwd "$_GSD_RUNTIME_ROOT" init onboard --raw) if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ``` -Parse JSON fields: `planning_exists`, `project_exists`, `requirements_exists`, -`roadmap_exists`, `state_exists`, `has_existing_code`, `has_package_file`, -`is_brownfield`, `fast_mode`, `has_codebase_map`, `has_fast_codebase_map`, -`codebase_map_files_present`, `missing_codebase_map_files`, `missing_fast_codebase_map_files`, -`has_docs_candidates`, -`doc_candidate_count`, `onboarding_summary_exists`, `text_mode`, `commit_docs`, `agents_installed`, -`missing_agents`, `has_git`, `git_worktree_root`, `in_nested_subdir`. +Parse JSON fields from `INIT`: -Set `FAST_MODE=fast_mode` from INIT. -Set `TEXT_MODE=true` if `--text` is present OR `text_mode` from INIT is true. -Set `CODEBASE_MAP_READY=has_fast_codebase_map` when `FAST_MODE` is true; otherwise set -`CODEBASE_MAP_READY=has_codebase_map`. +- `next_action.kind`, `next_action.command`, `next_action.reason`, `next_action.missing`, `next_action.summary_path` +- `map_readiness`, `codebase_map_summary_status`, `codebase_map_final_status` +- `planning_exists`, `project_exists`, `requirements_exists`, `roadmap_exists`, `state_exists` +- `is_brownfield`, `fast_mode`, `has_codebase_map`, `has_fast_codebase_map` +- `missing_codebase_map_files`, `missing_fast_codebase_map_files` +- `has_docs_candidates`, `doc_candidate_count`, `onboarding_summary_exists` +- `commit_docs`, `text_mode`, `has_git`, `git_worktree_root`, `in_nested_subdir` -**Text mode (`workflow.text_mode: true` in config or `--text` flag):** When `TEXT_MODE` is active, replace every `AskUserQuestion` call in this workflow with a plain-text numbered list and ask the user to type their choice number. This is required for non-Claude runtimes (OpenAI Codex, Gemini CLI, etc.) where `AskUserQuestion` is not available and would otherwise render as an inert code block. +Set: +- `TEXT_MODE=true` if `--text` is present or `text_mode` is true. +- `ONBOARDING_ROOT={git_worktree_root || _GSD_RUNTIME_ROOT}`. -## 2. Git and Existing Planning Safety +If `has_git` and `in_nested_subdir` are true, warn that onboarding artifacts belong to the outer worktree at `git_worktree_root`. Do not run `git init`. -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. +## 2. Execute `next_action` -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. +### `map-codebase` -## 3. Codebase Mapping Gate - -If `is_brownfield` is true and `CODEBASE_MAP_READY` is false: +If `next_action.kind == "map-codebase"`: - If `TEXT_MODE=true`, print: ```text -Existing code was detected, but the required .planning/codebase/ map is missing. -Missing map files: {--fast ? missing_fast_codebase_map_files : missing_codebase_map_files} +{next_action.reason} +Missing map files: {fast_mode ? missing_fast_codebase_map_files : missing_codebase_map_files} -1. Map codebase first — run {MAP_COMMAND} from worktree root {git_worktree_root || _GSD_RUNTIME_ROOT} to understand the repo before project setup (Recommended) +1. Map codebase first — run {next_action.command} from worktree root {ONBOARDING_ROOT} (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 required .planning/codebase/ map is missing. Map it first?" + - question: "{next_action.reason} Map it first?" - options: - - "Map codebase first" — Run `{MAP_COMMAND}` from worktree root `{git_worktree_root || _GSD_RUNTIME_ROOT}` to understand the repo before project setup (Recommended) + - "Map codebase first" — Run `{next_action.command}` from worktree root `{ONBOARDING_ROOT}` (Recommended) - "Skip mapping" — Continue with weaker onboarding context -If the user chooses mapping, do not nest the interactive map-codebase workflow. Print: +If the user chooses mapping, do not nest the interactive workflow. Print: ```text -Run from worktree root {git_worktree_root || _GSD_RUNTIME_ROOT}: +Run from worktree root {ONBOARDING_ROOT}: -{MAP_COMMAND} +{next_action.command} Then rerun /gsd:onboard from the same worktree root. ``` -Exit. +Exit. If the user skips mapping, continue with a warning. -If the user skips mapping, continue with a warning. +### `ingest-docs` -If `is_brownfield` is false and `planning_exists` is false and `has_docs_candidates` is false, print: - -```text -No existing code was detected. For a greenfield project, run: - -Run from worktree root {git_worktree_root || _GSD_RUNTIME_ROOT}: - -/gsd:new-project -``` - -Exit. - -## 4. Existing Docs Gate - -If `has_docs_candidates` is true and `project_exists` is false: +If `next_action.kind == "ingest-docs"`: - If `TEXT_MODE=true`, print: ```text +{next_action.reason} Detected {doc_candidate_count} possible ADR/PRD/SPEC/RFC document(s). -1. Ingest docs first — run /gsd:ingest-docs from worktree root {git_worktree_root || _GSD_RUNTIME_ROOT} to bootstrap planning from existing docs (Recommended) +1. Ingest docs first — run {next_action.command} from worktree root {ONBOARDING_ROOT} (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` from worktree root `{git_worktree_root || _GSD_RUNTIME_ROOT}` to bootstrap planning from existing docs (Recommended) + - "Ingest docs first" — Run `{next_action.command}` from worktree root `{ONBOARDING_ROOT}` (Recommended) - "Skip docs ingest" — Continue to `/gsd:new-project` -If the user chooses ingest, do not nest the interactive ingest-docs workflow. Print: +If the user chooses ingest, print: ```text -Run from worktree root {git_worktree_root || _GSD_RUNTIME_ROOT}: +Run from worktree root {ONBOARDING_ROOT}: -/gsd:ingest-docs +{next_action.command} + +Then rerun /gsd:onboard from the same worktree root. +``` + +Exit. If the user skips docs ingest, continue to the new-project handoff below. + +### `complete-map-before-new-project` + +If `next_action.kind == "complete-map-before-new-project"`, print: + +```text +{next_action.reason} + +Run from worktree root {ONBOARDING_ROOT}: + +{next_action.command} Then rerun /gsd:onboard from the same worktree root. ``` Exit. -## 5. Project Initialization Gate +### `new-project` -If `project_exists` is false and `fast_mode && has_fast_codebase_map && !has_codebase_map` is true: - -Print: +If `next_action.kind == "new-project"`, print: ```text -Fast codebase context is accepted for onboarding, but /gsd:new-project still requires the complete codebase map. +{next_action.reason} -Run from worktree root {git_worktree_root || _GSD_RUNTIME_ROOT}: +Run from worktree root {ONBOARDING_ROOT}: -/gsd:map-codebase - -Then rerun /gsd:onboard from the same worktree root. +{next_action.command} ``` Exit. -If `project_exists` is false: +### `partial-planning` -Print: +If `next_action.kind == "partial-planning"`, print: ```text -Codebase context is ready for project initialization. +Project planning exists but is incomplete. -Run from worktree root {git_worktree_root || _GSD_RUNTIME_ROOT}: +Missing files: {next_action.missing} +REQUIREMENTS.md: {requirements_exists ? "present" : "missing"} +ROADMAP.md: {roadmap_exists ? "present" : "missing"} +STATE.md: {state_exists ? "present" : "missing"} -/gsd:new-project - -Then rerun /gsd:onboard from the same worktree root. +Run the appropriate lower-level command to fill the missing planning artifact(s), then rerun /gsd:onboard. ``` Exit. -If `project_exists` is true and either `requirements_exists`, `roadmap_exists`, or -`state_exists` is false, print: +### `ready` -```text -Existing PROJECT.md was found, but planning is incomplete. +If `next_action.kind == "ready"`, print the final status section and exit. -Planning file status: -- REQUIREMENTS.md: {requirements_exists ? "present" : "missing"} -- ROADMAP.md: {roadmap_exists ? "present" : "missing"} -- STATE.md: {state_exists ? "present" : "missing"} +### `write-summary` -Run one of these top-level commands from worktree root {git_worktree_root || _GSD_RUNTIME_ROOT}: +If `next_action.kind == "write-summary"`, continue to summary creation. -/gsd:ingest-docs --mode merge -/gsd:new-milestone +## 3. Create Onboarding Summary -Then rerun /gsd:onboard from the same worktree root. -``` +Create `{ONBOARDING_ROOT}/{next_action.summary_path}`. Do not overwrite an existing summary; the projection should only route here when the summary is missing. -Exit. Do not write `.planning/onboarding/SUMMARY.md` and do not print the onboarding -complete status for partial planning. - -If `project_exists`, `requirements_exists`, `roadmap_exists`, and `state_exists` are all -true, continue. - -## 6. Write Onboarding Summary - -Resolve `ONBOARDING_ROOT={git_worktree_root || _GSD_RUNTIME_ROOT}` before this step. -Use `ONBOARDING_ROOT` for every filesystem write and commit in this step so nested-cwd -invocations do not create `subdir/.planning` artifacts. - -Create `{ONBOARDING_ROOT}/.planning/onboarding/SUMMARY.md` only after PROJECT.md exists. -If it already exists, update it only after confirmation; do not overwrite silently. - -Before summary keep/update choices, derive map status values safely: - -- If `has_codebase_map` is true: - - `CODEBASE_MAP_SUMMARY_STATUS=.planning/codebase/ (complete)` - - `CODEBASE_MAP_FINAL_STATUS=(complete)` -- Else if `fast_mode && has_fast_codebase_map` is true: - - `CODEBASE_MAP_SUMMARY_STATUS=.planning/codebase/ (fast/partial-but-accepted codebase map)` - - `CODEBASE_MAP_FINAL_STATUS=(fast/partial-but-accepted; required fast files present)` -- Else: - - `CODEBASE_MAP_SUMMARY_STATUS=.planning/codebase/ (incomplete or skipped)` - - `CODEBASE_MAP_FINAL_STATUS=(incomplete or skipped; missing: {missing_codebase_map_files})` - -If `onboarding_summary_exists` is true: - -- If `TEXT_MODE=true`, print: - -```text -Onboarding summary already exists at .planning/onboarding/SUMMARY.md. - -1. Update summary — regenerate from current artifact status (Recommended) -2. Keep existing summary — skip writing - -Enter number: -``` - -Stop and wait for the user's reply. - -- Otherwise use AskUserQuestion: - - header: "Onboarding Summary" - - question: "Onboarding summary already exists. Update it from current artifact status?" - - options: - - "Update summary" — Regenerate `.planning/onboarding/SUMMARY.md` (Recommended) - - "Keep existing summary" — Skip writing - -If the user chooses to keep the existing summary, skip to Step 7 without writing. - -If `onboarding_summary_exists` is false or the user confirms an update, write the summary -using the template below. - -Summary contents: +Summary template: ```markdown # Onboarding Summary -**Generated:** {YYYY-MM-DD} -**Status:** Ready for GSD workflow +## Project State +- PROJECT.md: {project_exists ? "present" : "missing"} +- REQUIREMENTS.md: {requirements_exists ? "present" : "missing"} +- ROADMAP.md: {roadmap_exists ? "present" : "missing"} +- STATE.md: {state_exists ? "present" : "missing"} -## Artifact Status +## Codebase Context +- Brownfield repo: {is_brownfield ? "yes" : "no"} +- Map readiness: {map_readiness} +- Codebase map: {codebase_map_summary_status} +- Fast map available: {has_fast_codebase_map ? "yes" : "no"} -- Project: .planning/PROJECT.md -- Requirements: .planning/REQUIREMENTS.md -- Roadmap: .planning/ROADMAP.md -- State: .planning/STATE.md -- Codebase map: {CODEBASE_MAP_SUMMARY_STATUS} - -## Codebase Map - -{if has_codebase_map} -Confirmed files: -- STACK.md -- ARCHITECTURE.md -- STRUCTURE.md -- CONVENTIONS.md -- TESTING.md -- INTEGRATIONS.md -- CONCERNS.md -{else if fast_mode && has_fast_codebase_map} -Accepted fast map files: -- STACK.md -- INTEGRATIONS.md -- ARCHITECTURE.md -- STRUCTURE.md -Context strength: fast/partial-but-accepted codebase map; not all seven map files are present. -{else} -Present files: {codebase_map_files_present} -Missing files: {missing_codebase_map_files} -Context strength: weaker because codebase mapping was skipped or is incomplete. -{endif} - -## Existing Docs - -Detected planning docs: {doc_candidate_count} +## Docs Context +- Existing ADR/PRD/SPEC/RFC candidates: {has_docs_candidates ? doc_candidate_count : 0} ## Recommended Next Step - -/gsd:discuss-phase 1 +- {next_action.reason} ``` -Commit the summary if `commit_docs` is true: +If `commit_docs` is true, commit only the summary path from the onboarding root: ```bash gsd_run --cwd "$ONBOARDING_ROOT" query commit "docs: create onboarding summary" --files .planning/onboarding/SUMMARY.md ``` -## 7. Final Status +Continue to final status. + +## 4. Final Status Print: -``` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - GSD ► ONBOARDING COMPLETE ✓ -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +```text +Onboarding status: +- PROJECT.md: {project_exists ? "present" : "missing"} +- REQUIREMENTS.md: {requirements_exists ? "present" : "missing"} +- ROADMAP.md: {roadmap_exists ? "present" : "missing"} +- STATE.md: {state_exists ? "present" : "missing"} +- Codebase map: {codebase_map_final_status} +- Onboarding summary: present -Created / confirmed / status: -- .planning/codebase/ {CODEBASE_MAP_FINAL_STATUS} -- .planning/PROJECT.md -- .planning/REQUIREMENTS.md {requirements_exists ? "(present)" : "(missing; onboarding incomplete)"} -- .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 - -─────────────────────────────────────────────────────────────── +Next recommended command: /gsd:manager ``` - - - -- [ ] 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 - +Do not run implementation execution or shipping from onboarding. diff --git a/src/init.cts b/src/init.cts index 853e186cf..5c781ba25 100644 --- a/src/init.cts +++ b/src/init.cts @@ -51,6 +51,15 @@ const { checkAgentsInstalled } = agentInstallCheck; import gitBaseBranch = require('./git-base-branch.cjs'); const { gitWorktreeInfoInternal } = gitBaseBranch; import { makeResolution } from './resolution.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- onboard-projection.cjs is an export= CommonJS module +import onboardProjection = require('./onboard-projection.cjs'); +const { + REQUIRED_CODEBASE_MAP_FILES, + buildOnboardProjection, + hasCodeFilesInternal, + hasPackageFileInternal, + listCodebaseMapFiles, +} = onboardProjection; const { output, error } = io; const { loadConfig, loadConfigResolved } = configLoader; @@ -86,130 +95,6 @@ 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', -]; - -const FAST_CODEBASE_MAP_FILES = [ - 'STACK.md', 'INTEGRATIONS.md', 'ARCHITECTURE.md', 'STRUCTURE.md', -]; - -const PLANNING_DOC_SEGMENTS = new Set([ - 'adr', 'adrs', 'prd', 'prds', 'spec', 'specs', 'rfc', 'rfcs', -]); - -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', 'rfc', 'rfcs']; - const candidates = new Set(); - - const isPlanningDocCandidate = (rel: string, name: string): boolean => { - const upperName = name.toUpperCase(); - const relLower = rel.toLowerCase(); - const pathSegments = relLower.split('/'); - return ( - /(^|[-_ ])(ADR|PRD|SPEC|RFC)([-_ ]|\.)/i.test(name) || - /^\d{4}[-_].+\.md$/i.test(name) || - pathSegments.some((segment) => PLANNING_DOC_SEGMENTS.has(segment)) || - upperName === 'REQUIREMENTS.MD' - ); - }; - - const addCandidate = (rel: string, name: string): void => { - if (name.toLowerCase().endsWith('.md') && isPlanningDocCandidate(rel, name)) { - candidates.add(toPosixPath(rel)); - } - }; - - 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()) addCandidate(rel, entry.name); - } - }; - - let rootEntries: fs.Dirent[] = []; - try { - rootEntries = fs.readdirSync(cwd, { withFileTypes: true }); - } catch { - rootEntries = []; - } - for (const entry of rootEntries) { - if (entry.isFile()) addCandidate(entry.name, entry.name); - } - - 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']; } @@ -912,65 +797,13 @@ function cmdInitOnboard( options: Record = {}, ): void { const config = loadConfig(cwd); - const codebaseMapFiles = listCodebaseMapFiles(cwd); - const missingCodebaseMapFiles = REQUIRED_CODEBASE_MAP_FILES.filter( - (file) => !codebaseMapFiles.includes(file), - ); - const missingFastCodebaseMapFiles = FAST_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 fastMode = options['fast'] === true; - const hasFastCodebaseMap = missingFastCodebaseMapFiles.length === 0; - const needsCodebaseMap = isBrownfield && ( - fastMode ? !hasFastCodebaseMap : !hasCodebaseMap - ); - - 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)), - requirements_exists: fs.existsSync(path.join(planningDir(cwd), 'REQUIREMENTS.md')), - 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, - fast_mode: fastMode, - needs_codebase_map: needsCodebaseMap, - has_codebase_map: hasCodebaseMap, - has_fast_codebase_map: hasFastCodebaseMap, - codebase_dir_exists: fs.existsSync(path.join(planningRoot(cwd), 'codebase')), - fast_codebase_map_files_required: FAST_CODEBASE_MAP_FILES, - codebase_map_files_present: codebaseMapFiles, - missing_codebase_map_files: missingCodebaseMapFiles, - missing_fast_codebase_map_files: missingFastCodebaseMapFiles, - - 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', - requirements_path: toPosixPath( - path.relative(cwd, path.join(planningDir(cwd), 'REQUIREMENTS.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'))), - + const workflowConfig = (config.workflow ?? {}) as Record; + const result = { + ...buildOnboardProjection(cwd, { + commitDocs: !!config.commit_docs, + fast: options['fast'] === true, + textMode: !!config.text_mode || !!workflowConfig['text_mode'], + }), ...getInitGitState(cwd), }; diff --git a/src/onboard-projection.cts b/src/onboard-projection.cts new file mode 100644 index 000000000..26ba11305 --- /dev/null +++ b/src/onboard-projection.cts @@ -0,0 +1,383 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module +import coreUtils = require('./core-utils.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module +import planningWorkspace = require('./planning-workspace.cjs'); + +const { pathExistsInternal, toPosixPath } = coreUtils; +const { planningDir, planningRoot } = planningWorkspace; + +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', +]; + +const FAST_CODEBASE_MAP_FILES = [ + 'STACK.md', 'INTEGRATIONS.md', 'ARCHITECTURE.md', 'STRUCTURE.md', +]; + +const PLANNING_DOC_SEGMENTS = new Set([ + 'adr', 'adrs', 'prd', 'prds', 'spec', 'specs', 'rfc', 'rfcs', +]); + +type MapReadiness = 'none' | 'fast' | 'complete'; + +type OnboardNextAction = + | { kind: 'map-codebase'; command: string; reason: string } + | { kind: 'ingest-docs'; command: string; reason: string } + | { kind: 'new-project'; command: string; reason: string } + | { kind: 'complete-map-before-new-project'; command: string; reason: string } + | { kind: 'partial-planning'; missing: string[]; reason: string } + | { kind: 'write-summary'; summary_path: string; reason: string } + | { kind: 'ready'; reason: string }; + +interface BuildOnboardProjectionOptions { + commitDocs: boolean; + fast: boolean; + textMode: boolean; +} + +interface OnboardProjection { + commit_docs: boolean; + text_mode: boolean; + + project_exists: boolean; + planning_exists: boolean; + requirements_exists: boolean; + roadmap_exists: boolean; + state_exists: boolean; + config_exists: boolean; + + has_existing_code: boolean; + has_package_file: boolean; + is_brownfield: boolean; + fast_mode: boolean; + map_readiness: MapReadiness; + next_action: OnboardNextAction; + needs_codebase_map: boolean; + has_codebase_map: boolean; + has_fast_codebase_map: boolean; + codebase_dir_exists: boolean; + fast_codebase_map_files_required: string[]; + codebase_map_files_present: string[]; + missing_codebase_map_files: string[]; + missing_fast_codebase_map_files: string[]; + codebase_map_summary_status: string; + codebase_map_final_status: string; + + has_docs_candidates: boolean; + doc_candidate_count: number; + doc_candidates: string[]; + + onboarding_summary_exists: boolean; + onboarding_summary_path: string; + + project_path: string; + requirements_path: string; + roadmap_path: string; + state_path: string; + codebase_dir: string; + onboarding_dir: string; +} + +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', 'rfc', 'rfcs']; + const candidates = new Set(); + + function isPlanningDocCandidate(rel: string, name: string): boolean { + const upperName = name.toUpperCase(); + const relLower = rel.toLowerCase(); + const pathSegments = relLower.split('/'); + return ( + /(^|[-_ ])(ADR|PRD|SPEC|RFC)([-_ ]|\.)/i.test(name) || + /^\d{4}[-_].+\.md$/i.test(name) || + pathSegments.some((segment) => PLANNING_DOC_SEGMENTS.has(segment)) || + upperName === 'REQUIREMENTS.MD' + ); + } + + function addCandidate(rel: string, name: string): void { + if (name.toLowerCase().endsWith('.md') && isPlanningDocCandidate(rel, name)) { + candidates.add(toPosixPath(rel)); + } + } + + function 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()) addCandidate(rel, entry.name); + } + } + + let rootEntries: fs.Dirent[] = []; + try { + rootEntries = fs.readdirSync(cwd, { withFileTypes: true }); + } catch { + rootEntries = []; + } + + for (const entry of rootEntries) { + if (entry.isFile()) addCandidate(entry.name, entry.name); + } + + 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 getMapReadiness(hasCompleteMap: boolean, hasFastMap: boolean): MapReadiness { + if (hasCompleteMap) return 'complete'; + if (hasFastMap) return 'fast'; + return 'none'; +} + +function mapSummaryStatus(mapReadiness: MapReadiness): string { + if (mapReadiness === 'complete') return '.planning/codebase/ (complete codebase map)'; + if (mapReadiness === 'fast') return '.planning/codebase/ (fast/partial codebase map; complete map still required for project setup)'; + return 'missing'; +} + +function mapFinalStatus(mapReadiness: MapReadiness): string { + if (mapReadiness === 'complete') return 'complete'; + if (mapReadiness === 'fast') return 'fast/partial; complete map still required for project setup'; + return 'missing'; +} + +function planningMissing(requirementsExists: boolean, roadmapExists: boolean, stateExists: boolean): string[] { + const missing: string[] = []; + if (!requirementsExists) missing.push('REQUIREMENTS.md'); + if (!roadmapExists) missing.push('ROADMAP.md'); + if (!stateExists) missing.push('STATE.md'); + return missing; +} + +function nextAction(params: { + fastMode: boolean; + isBrownfield: boolean; + needsCodebaseMap: boolean; + hasDocsCandidates: boolean; + projectExists: boolean; + mapReadiness: MapReadiness; + onboardingSummaryExists: boolean; + missingPlanningFiles: string[]; +}): OnboardNextAction { + if (params.isBrownfield && params.needsCodebaseMap) { + return { + kind: 'map-codebase', + command: params.fastMode ? '/gsd:map-codebase --fast' : '/gsd:map-codebase', + reason: 'Existing code was detected, but the required .planning/codebase/ map is missing.', + }; + } + + if (params.hasDocsCandidates && !params.projectExists) { + return { + kind: 'ingest-docs', + command: '/gsd:ingest-docs', + reason: 'Detected existing ADR/PRD/SPEC/RFC document(s) before project setup.', + }; + } + + if (!params.isBrownfield && !params.projectExists && !params.hasDocsCandidates) { + return { + kind: 'new-project', + command: '/gsd:new-project', + reason: 'No existing code or planning docs were detected.', + }; + } + + if (!params.projectExists && params.fastMode && params.mapReadiness === 'fast') { + return { + kind: 'complete-map-before-new-project', + command: '/gsd:map-codebase', + reason: 'The fast map is enough for lightweight onboarding, but project setup still requires the complete codebase map.', + }; + } + + if (!params.projectExists) { + return { + kind: 'new-project', + command: '/gsd:new-project', + reason: 'Codebase context is ready for project initialization.', + }; + } + + if (params.missingPlanningFiles.length > 0) { + return { + kind: 'partial-planning', + missing: params.missingPlanningFiles, + reason: 'Project planning exists but required planning files are missing.', + }; + } + + if (!params.onboardingSummaryExists) { + return { + kind: 'write-summary', + summary_path: '.planning/onboarding/SUMMARY.md', + reason: 'Onboarding summary is missing.', + }; + } + + return { + kind: 'ready', + reason: 'Onboarding summary already exists.', + }; +} + +function buildOnboardProjection(cwd: string, options: BuildOnboardProjectionOptions): OnboardProjection { + const codebaseMapFiles = listCodebaseMapFiles(cwd); + const missingCodebaseMapFiles = REQUIRED_CODEBASE_MAP_FILES.filter( + (file) => !codebaseMapFiles.includes(file), + ); + const missingFastCodebaseMapFiles = FAST_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 hasFastCodebaseMap = missingFastCodebaseMapFiles.length === 0; + const mapReadinessValue = getMapReadiness(hasCodebaseMap, hasFastCodebaseMap); + const needsCodebaseMap = isBrownfield && ( + options.fast ? !hasFastCodebaseMap : !hasCodebaseMap + ); + const projectExists = pathExistsInternal(cwd, '.planning/PROJECT.md'); + const requirementsExists = fs.existsSync(path.join(planningDir(cwd), 'REQUIREMENTS.md')); + const roadmapExists = fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')); + const stateExists = fs.existsSync(path.join(planningDir(cwd), 'STATE.md')); + const onboardingSummaryExists = pathExistsInternal(cwd, '.planning/onboarding/SUMMARY.md'); + const missingPlanningFiles = planningMissing(requirementsExists, roadmapExists, stateExists); + + return { + commit_docs: options.commitDocs, + text_mode: options.textMode, + + project_exists: projectExists, + planning_exists: fs.existsSync(planningRoot(cwd)), + requirements_exists: requirementsExists, + roadmap_exists: roadmapExists, + state_exists: stateExists, + config_exists: fs.existsSync(path.join(planningDir(cwd), 'config.json')), + + has_existing_code: hasCode, + has_package_file: hasPackageFile, + is_brownfield: isBrownfield, + fast_mode: options.fast, + map_readiness: mapReadinessValue, + next_action: nextAction({ + fastMode: options.fast, + isBrownfield, + needsCodebaseMap, + hasDocsCandidates: docCandidates.length > 0, + projectExists, + mapReadiness: mapReadinessValue, + onboardingSummaryExists, + missingPlanningFiles, + }), + needs_codebase_map: needsCodebaseMap, + has_codebase_map: hasCodebaseMap, + has_fast_codebase_map: hasFastCodebaseMap, + codebase_dir_exists: fs.existsSync(path.join(planningRoot(cwd), 'codebase')), + fast_codebase_map_files_required: FAST_CODEBASE_MAP_FILES, + codebase_map_files_present: codebaseMapFiles, + missing_codebase_map_files: missingCodebaseMapFiles, + missing_fast_codebase_map_files: missingFastCodebaseMapFiles, + codebase_map_summary_status: mapSummaryStatus(mapReadinessValue), + codebase_map_final_status: mapFinalStatus(mapReadinessValue), + + has_docs_candidates: docCandidates.length > 0, + doc_candidate_count: docCandidates.length, + doc_candidates: docCandidates, + + onboarding_summary_exists: onboardingSummaryExists, + onboarding_summary_path: '.planning/onboarding/SUMMARY.md', + + project_path: '.planning/PROJECT.md', + requirements_path: toPosixPath( + path.relative(cwd, path.join(planningDir(cwd), 'REQUIREMENTS.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'))), + }; +} + +export = { + REQUIRED_CODEBASE_MAP_FILES, + FAST_CODEBASE_MAP_FILES, + buildOnboardProjection, + hasCodeFilesInternal, + hasPackageFileInternal, + listCodebaseMapFiles, + listPlanningDocCandidates, +}; diff --git a/tests/onboard-command.test.cjs b/tests/onboard-command.test.cjs index 2201b3730..30bb98716 100644 --- a/tests/onboard-command.test.cjs +++ b/tests/onboard-command.test.cjs @@ -138,7 +138,7 @@ describe('init onboard public CLI projection', () => { assert.strictEqual(parsed.text_mode, true); }); - test('reports fast codebase map readiness for the default fast subset', () => { + test('reports fast codebase map readiness and a complete-map handoff before new-project', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'codebase'), { recursive: true }); for (const name of ['STACK', 'INTEGRATIONS', 'ARCHITECTURE', 'STRUCTURE']) { fs.writeFileSync(path.join(tmpDir, '.planning', 'codebase', `${name}.md`), `# ${name}\n`); @@ -149,9 +149,13 @@ describe('init onboard public CLI projection', () => { assert.ok(result.success, `init onboard should succeed: ${result.error}`); const parsed = JSON.parse(result.output); + assert.strictEqual(parsed.map_readiness, 'fast'); assert.strictEqual(parsed.has_codebase_map, false); assert.strictEqual(parsed.has_fast_codebase_map, true); assert.strictEqual(parsed.needs_codebase_map, false); + assert.strictEqual(parsed.next_action.kind, 'complete-map-before-new-project'); + assert.strictEqual(parsed.next_action.command, '/gsd:map-codebase'); + assert.match(parsed.next_action.reason, /complete codebase map/i); assert.deepStrictEqual(parsed.fast_codebase_map_files_required, [ 'STACK.md', 'INTEGRATIONS.md', @@ -161,6 +165,74 @@ describe('init onboard public CLI projection', () => { assert.deepStrictEqual(parsed.missing_fast_codebase_map_files, []); }); + test('projects the next action for code, docs, greenfield, partial planning, and summary states', () => { + fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'src', 'server.ts'), 'export const server = true;\n'); + let result = runGsdTools('init onboard --raw', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init onboard should succeed: ${result.error}`); + assert.deepStrictEqual(JSON.parse(result.output).next_action, { + kind: 'map-codebase', + command: '/gsd:map-codebase', + reason: 'Existing code was detected, but the required .planning/codebase/ map is missing.', + }); + + cleanup(tmpDir); + tmpDir = createFixture({ planning: false, projectDoc: false }); + fs.mkdirSync(path.join(tmpDir, 'docs', 'adr'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'docs', 'adr', '0001-runtime.md'), '# ADR: Runtime\n'); + result = runGsdTools('init onboard --raw', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init onboard should succeed: ${result.error}`); + assert.deepStrictEqual(JSON.parse(result.output).next_action, { + kind: 'ingest-docs', + command: '/gsd:ingest-docs', + reason: 'Detected existing ADR/PRD/SPEC/RFC document(s) before project setup.', + }); + + cleanup(tmpDir); + tmpDir = createFixture({ planning: false, projectDoc: false }); + result = runGsdTools('init onboard --raw', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init onboard should succeed: ${result.error}`); + assert.deepStrictEqual(JSON.parse(result.output).next_action, { + kind: 'new-project', + command: '/gsd:new-project', + reason: 'No existing code or planning docs were detected.', + }); + + cleanup(tmpDir); + tmpDir = createFixture({ planning: false, projectDoc: false }); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + 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'); + result = runGsdTools('init onboard --raw', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init onboard should succeed: ${result.error}`); + const partial = JSON.parse(result.output); + assert.strictEqual(partial.next_action.kind, 'partial-planning'); + assert.deepStrictEqual(partial.next_action.missing, ['REQUIREMENTS.md']); + + 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.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n'); + result = runGsdTools('init onboard --raw', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init onboard should succeed: ${result.error}`); + assert.deepStrictEqual(JSON.parse(result.output).next_action, { + kind: 'write-summary', + summary_path: '.planning/onboarding/SUMMARY.md', + reason: 'Onboarding summary is missing.', + }); + + fs.mkdirSync(path.join(tmpDir, '.planning', 'onboarding'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'onboarding', 'SUMMARY.md'), '# Onboarding Summary\n'); + result = runGsdTools('init onboard --raw', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init onboard should succeed: ${result.error}`); + assert.deepStrictEqual(JSON.parse(result.output).next_action, { + kind: 'ready', + reason: 'Onboarding summary already exists.', + }); + }); + test('reports missing requirements in otherwise existing planning', () => { fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n'); @@ -230,101 +302,37 @@ describe('/gsd:onboard command contract', () => { assert.ok(content.includes('@~/.claude/gsd-core/references/gate-prompts.md')); }); - test('workflow routes through existing primitives and protects existing planning', () => { + test('workflow renders the init projection without owning the route state machine', () => { const content = fs.readFileSync(WF_PATH, 'utf8'); - assert.ok(content.includes('init onboard'), 'workflow must use init onboard projection'); - assert.match(content, /gsd_run --cwd "\$_GSD_RUNTIME_ROOT" init onboard/, 'workflow must anchor init onboard at runtime root'); + assert.ok(content.includes('@~/.claude/gsd-core/references/gsd-run-resolver.md')); assert.ok( - !content.includes('ARGUMENTS:-'), - 'workflow must not depend on an unexported shell ARGUMENTS variable', + !content.includes('_GSD_SHIM_NAME="gsd-tools.cjs"'), + 'workflow must reference the shared resolver instead of inlining it', ); - assert.match( - content, - /Run init from parsed prompt args, not shell `\$ARGUMENTS`/, - 'workflow must forward fast mode from parsed prompt arguments into init onboard', - ); - assert.match( - content, - /gsd_run --cwd "\$_GSD_RUNTIME_ROOT" init onboard --fast/, - 'workflow must provide a literal fast init command', - ); - assert.ok(content.includes('has_fast_codebase_map'), 'workflow must parse fast map readiness'); - assert.match(content, /CODEBASE_MAP_READY=.*has_fast_codebase_map/s, 'workflow must treat the fast subset as acceptable in fast mode'); - assert.ok(content.includes('Run from worktree root'), 'workflow handoffs must be anchored at the worktree root'); - for (const runtimeHome of [ - 'HERMES_HOME', - 'CURSOR_CONFIG_DIR', - 'CODEX_HOME', - 'GEMINI_CONFIG_DIR', - 'WINDSURF_CONFIG_DIR', - 'OPENCODE_CONFIG_DIR', + assert.match(content, /init onboard --fast --raw/); + assert.match(content, /init onboard --raw/); + assert.match(content, /next_action\.kind/); + assert.match(content, /map_readiness/); + assert.match(content, /codebase_map_summary_status/); + assert.match(content, /ONBOARDING_ROOT=\{git_worktree_root \|\| _GSD_RUNTIME_ROOT\}/); + + for (const action of [ + 'map-codebase', + 'ingest-docs', + 'complete-map-before-new-project', + 'new-project', + 'partial-planning', + 'write-summary', + 'ready', ]) { - assert.ok(content.includes(runtimeHome), `workflow resolver must include ${runtimeHome}`); + assert.ok(content.includes(`next_action.kind == "${action}"`), `workflow must render ${action}`); } - assert.match(content, /Parse JSON fields:.*`commit_docs`/s, 'workflow must parse commit_docs before using it'); - 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.match( - content, - /If `has_docs_candidates` is true and `project_exists` is false:/, - 'workflow must offer docs ingest before project setup even when codebase mapping already created .planning', - ); - assert.match( - content, - /If `is_brownfield` is false and `planning_exists` is false and `has_docs_candidates` is false, print:/, - 'workflow must not send doc-only repos to new-project before the docs ingest gate', - ); - 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, - /ONBOARDING_ROOT=\{git_worktree_root \|\| _GSD_RUNTIME_ROOT\}/, - 'workflow must resolve a single root for summary writes', - ); - assert.match( - content, - /Create `\{ONBOARDING_ROOT\}\/\.planning\/onboarding\/SUMMARY\.md`/, - 'workflow must anchor summary writes at onboarding root', - ); - assert.match( - content, - /gsd_run --cwd "\$ONBOARDING_ROOT" query commit "docs: create onboarding summary" --files \.planning\/onboarding\/SUMMARY\.md/, - 'workflow must commit the root-relative summary path from onboarding root', - ); - assert.match( - content, - /CODEBASE_MAP_SUMMARY_STATUS=\.planning\/codebase\/ \(fast\/partial-but-accepted codebase map\)/, - 'workflow summary must label fast accepted maps without implying a complete map', - ); - assert.match( - content, - /CODEBASE_MAP_FINAL_STATUS=\(fast\/partial-but-accepted; required fast files present\)/, - 'final status must distinguish complete maps from accepted fast maps', - ); - assert.match( - content, - /If `project_exists` is false and `fast_mode && has_fast_codebase_map && !has_codebase_map` is true:/, - 'workflow must not hand fast-partial maps directly to new-project', - ); - assert.match( - content, - /Fast codebase context is accepted for onboarding, but \/gsd:new-project still requires the complete codebase map\./, - 'workflow must explain why fast-partial onboarding stops before new-project', - ); - const deriveMapStatusIndex = content.indexOf('Before summary keep/update choices, derive map status values safely:'); - const keepExistingSummaryIndex = content.indexOf('If `onboarding_summary_exists` is true:'); - assert.ok(deriveMapStatusIndex >= 0, 'workflow must derive map status values'); - assert.ok(keepExistingSummaryIndex >= 0, 'workflow must define the existing-summary branch'); - assert.ok( - deriveMapStatusIndex < keepExistingSummaryIndex, - 'workflow must derive final map status before a keep-existing-summary path can skip to final status', - ); - assert.match(content, /overwrite|idempotent|do not overwrite/i, 'workflow must protect existing planning'); - assert.ok(content.includes('requirements_exists'), 'workflow must parse requirements existence'); - assert.match(content, /REQUIREMENTS\.md: \{requirements_exists \? "present" : "missing"\}/, 'workflow must report missing requirements in partial planning'); - assert.match(content, /If `project_exists` is true and .*`requirements_exists`.* is false/s, 'workflow must gate partial planning on missing requirements'); + + assert.ok(content.includes('AskUserQuestion'), 'workflow must still support interactive choices'); assert.ok(content.includes('--text'), 'workflow must document text-mode fallback'); + assert.match(content, /do not overwrite/i, 'workflow must protect existing summary/planning'); + assert.match(content, /query commit "docs: create onboarding summary" --files \.planning\/onboarding\/SUMMARY\.md/); assert.ok(!content.includes('execute-phase'), 'onboarding must not execute implementation phases'); assert.ok(!content.includes('gsd:ship'), 'onboarding must not ship work'); });