refactor: project onboard routing in init

This commit is contained in:
Jeremy McSpadden
2026-07-03 14:11:42 -05:00
committed by Codesmith
parent e3f2070a42
commit a5298c1fc0
7 changed files with 613 additions and 520 deletions

View File

@@ -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<string>();
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<string, string[]>)['summaryFiles'];
}
@@ -912,65 +797,13 @@ function cmdInitOnboard(
options: Record<string, unknown> = {},
): 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<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)),
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<string, unknown>;
const result = {
...buildOnboardProjection(cwd, {
commitDocs: !!config.commit_docs,
fast: options['fast'] === true,
textMode: !!config.text_mode || !!workflowConfig['text_mode'],
}),
...getInitGitState(cwd),
};

383
src/onboard-projection.cts Normal file
View File

@@ -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<string>();
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,
};