/** * Complex init composition handlers — the 3 heavyweight init commands * that require deep filesystem scanning and ROADMAP.md parsing. * * Composes existing atomic SDK queries into the same flat JSON bundles * that CJS init.cjs produces for the new-project, progress, and manager * workflows. * * Port of get-shit-done/bin/lib/init.cjs cmdInitNewProject (lines 296-399), * cmdInitProgress (lines 1139-1284), cmdInitManager (lines 854-1137). * * @example * ```typescript * import { initProgress, initManager } from './init-complex.js'; * * const result = await initProgress([], '/project'); * // { data: { phases: [...], milestone_version: 'v3.0', ... } } * ``` */ import { existsSync, readdirSync, statSync, type Dirent } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { join, relative } from 'node:path'; import { homedir } from 'node:os'; import { loadConfig } from '../config.js'; import { resolveModel } from './config-query.js'; import { planningPaths, normalizePhaseName, phaseTokenMatches, toPosixPath } from './helpers.js'; import { getMilestoneInfo, extractCurrentMilestone } from './roadmap.js'; import { withProjectRoot } from './init.js'; import type { QueryHandler } from './utils.js'; // ─── Internal helpers ────────────────────────────────────────────────────── /** * Get model alias string from resolveModel result. */ async function getModelAlias(agentType: string, projectDir: string): Promise { const result = await resolveModel([agentType], projectDir); const data = result.data as Record; return (data.model as string) || 'sonnet'; } /** * Check if a file exists at a relative path within projectDir. */ function pathExists(base: string, relPath: string): boolean { return existsSync(join(base, relPath)); } // ─── initNewProject ─────────────────────────────────────────────────────── /** * Init handler for new-project workflow. * * Detects brownfield state (existing code, package files, git), checks * search API availability, and resolves project researcher models. * * Port of cmdInitNewProject from init.cjs lines 296-399. */ export const initNewProject: QueryHandler = async (_args, projectDir, _workstream) => { const config = await loadConfig(projectDir); // Detect search API key availability from env vars and ~/.gsd/ files const gsdHome = join(homedir(), '.gsd'); const hasBraveSearch = !!( process.env.BRAVE_API_KEY || existsSync(join(gsdHome, 'brave_api_key')) ); const hasFirecrawl = !!( process.env.FIRECRAWL_API_KEY || existsSync(join(gsdHome, 'firecrawl_api_key')) ); const hasExaSearch = !!( process.env.EXA_API_KEY || existsSync(join(gsdHome, 'exa_api_key')) ); // Detect existing code (depth-limited scan, no external tools) 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: Dirent[]; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return false; } for (const entry of entries) { if (entry.isFile()) { const ext = entry.name.slice(entry.name.lastIndexOf('.')); if (codeExtensions.has(ext)) return true; } else if (entry.isDirectory() && !skipDirs.has(entry.name)) { if (findCodeFiles(join(dir, entry.name), depth + 1)) return true; } } return false; } let hasExistingCode = false; try { hasExistingCode = findCodeFiles(projectDir, 0); } catch { /* best-effort */ } const hasPackageFile = pathExists(projectDir, 'package.json') || pathExists(projectDir, 'requirements.txt') || pathExists(projectDir, 'Cargo.toml') || pathExists(projectDir, 'go.mod') || pathExists(projectDir, 'Package.swift') || pathExists(projectDir, 'build.gradle') || pathExists(projectDir, 'build.gradle.kts') || pathExists(projectDir, 'pom.xml') || pathExists(projectDir, 'Gemfile') || pathExists(projectDir, 'composer.json') || pathExists(projectDir, 'pubspec.yaml') || pathExists(projectDir, 'CMakeLists.txt') || pathExists(projectDir, 'Makefile') || pathExists(projectDir, 'build.zig') || pathExists(projectDir, 'mix.exs') || pathExists(projectDir, 'project.clj'); const [researcherModel, synthesizerModel, roadmapperModel] = await Promise.all([ getModelAlias('gsd-project-researcher', projectDir), getModelAlias('gsd-research-synthesizer', projectDir), getModelAlias('gsd-roadmapper', projectDir), ]); const result: Record = { researcher_model: researcherModel, synthesizer_model: synthesizerModel, roadmapper_model: roadmapperModel, commit_docs: config.commit_docs, project_exists: pathExists(projectDir, '.planning/PROJECT.md'), has_codebase_map: pathExists(projectDir, '.planning/codebase'), planning_exists: pathExists(projectDir, '.planning'), has_existing_code: hasExistingCode, has_package_file: hasPackageFile, is_brownfield: hasExistingCode || hasPackageFile, needs_codebase_map: (hasExistingCode || hasPackageFile) && !pathExists(projectDir, '.planning/codebase'), has_git: pathExists(projectDir, '.git'), brave_search_available: hasBraveSearch, firecrawl_available: hasFirecrawl, exa_search_available: hasExaSearch, project_path: '.planning/PROJECT.md', }; return { data: withProjectRoot(projectDir, result, config as Record) }; }; // ─── initProgress ───────────────────────────────────────────────────────── /** * Init handler for progress workflow. * * Builds phase list with plan/summary counts and paused state detection. * * Port of cmdInitProgress from init.cjs lines 1139-1284. */ export const initProgress: QueryHandler = async (_args, projectDir, _workstream) => { const config = await loadConfig(projectDir); const milestone = await getMilestoneInfo(projectDir); const paths = planningPaths(projectDir); const phases: Record[] = []; let currentPhase: Record | null = null; let nextPhase: Record | null = null; // Build set of phases from ROADMAP for the current milestone const roadmapPhaseNames = new Map(); const seenPhaseNums = new Set(); try { const rawRoadmap = await readFile(paths.roadmap, 'utf-8'); const roadmapContent = await extractCurrentMilestone(rawRoadmap, projectDir); const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; let hm: RegExpExecArray | null; while ((hm = headingPattern.exec(roadmapContent)) !== null) { const pNum = hm[1]; const pName = hm[2].replace(/\(INSERTED\)/i, '').trim(); roadmapPhaseNames.set(pNum, pName); } } catch { /* intentionally empty */ } // Scan phase directories try { const entries = readdirSync(paths.phases, { withFileTypes: true }); const dirs = entries .filter(e => e.isDirectory()) .map(e => e.name) .sort((a, b) => { const pa = a.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); const pb = b.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); if (!pa || !pb) return a.localeCompare(b); return parseInt(pa[1], 10) - parseInt(pb[1], 10); }); for (const dir of dirs) { const match = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); const phaseNumber = match ? match[1] : dir; const phaseName = match && match[2] ? match[2] : null; seenPhaseNums.add(phaseNumber.replace(/^0+/, '') || '0'); const phasePath = join(paths.phases, dir); const phaseFiles = readdirSync(phasePath); const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); const status = summaries.length >= plans.length && plans.length > 0 ? 'complete' : plans.length > 0 ? 'in_progress' : hasResearch ? 'researched' : 'pending'; const phaseInfo: Record = { number: phaseNumber, name: phaseName, directory: toPosixPath(relative(projectDir, join(paths.phases, dir))), status, plan_count: plans.length, summary_count: summaries.length, has_research: hasResearch, }; phases.push(phaseInfo); if (!currentPhase && (status === 'in_progress' || status === 'researched')) { currentPhase = phaseInfo; } if (!nextPhase && status === 'pending') { nextPhase = phaseInfo; } } } catch { /* intentionally empty */ } // Add ROADMAP-only phases not yet on disk for (const [num, name] of roadmapPhaseNames) { const stripped = num.replace(/^0+/, '') || '0'; if (!seenPhaseNums.has(stripped)) { const phaseInfo: Record = { number: num, name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''), directory: null, status: 'not_started', plan_count: 0, summary_count: 0, has_research: false, }; phases.push(phaseInfo); if (!nextPhase && !currentPhase) { nextPhase = phaseInfo; } } } phases.sort((a, b) => parseInt(a.number as string, 10) - parseInt(b.number as string, 10)); // Check paused state in STATE.md let pausedAt: string | null = null; try { const stateContent = await readFile(paths.state, 'utf-8'); const pauseMatch = stateContent.match(/\*\*Paused At:\*\*\s*(.+)/); if (pauseMatch) pausedAt = pauseMatch[1].trim(); } catch { /* intentionally empty */ } const result: Record = { executor_model: await getModelAlias('gsd-executor', projectDir), planner_model: await getModelAlias('gsd-planner', projectDir), commit_docs: config.commit_docs, milestone_version: milestone.version, milestone_name: milestone.name, phases, phase_count: phases.length, completed_count: phases.filter(p => p.status === 'complete').length, in_progress_count: phases.filter(p => p.status === 'in_progress').length, current_phase: currentPhase, next_phase: nextPhase, paused_at: pausedAt, has_work_in_progress: !!currentPhase, project_exists: pathExists(projectDir, '.planning/PROJECT.md'), roadmap_exists: existsSync(paths.roadmap), state_exists: existsSync(paths.state), state_path: toPosixPath(relative(projectDir, paths.state)), roadmap_path: toPosixPath(relative(projectDir, paths.roadmap)), project_path: '.planning/PROJECT.md', config_path: toPosixPath(relative(projectDir, paths.config)), }; return { data: withProjectRoot(projectDir, result, config as Record) }; }; // ─── initManager ───────────────────────────────────────────────────────── /** * Init handler for manager workflow. * * Parses ROADMAP.md for all phases, computes disk status, dependency * graph, and recommended actions per phase. * * Port of cmdInitManager from init.cjs lines 854-1137. */ export const initManager: QueryHandler = async (_args, projectDir, _workstream) => { const config = await loadConfig(projectDir); const milestone = await getMilestoneInfo(projectDir); const paths = planningPaths(projectDir); let rawContent: string; try { rawContent = await readFile(paths.roadmap, 'utf-8'); } catch { return { data: { error: 'No ROADMAP.md found. Run /gsd-new-milestone first.' } }; } const content = await extractCurrentMilestone(rawContent, projectDir); // Pre-compute directory listing once let phaseDirEntries: string[] = []; try { phaseDirEntries = readdirSync(paths.phases, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name); } catch { /* intentionally empty */ } // Pre-extract checkbox states in a single pass const checkboxStates = new Map(); const cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi; let cbMatch: RegExpExecArray | null; while ((cbMatch = cbPattern.exec(content)) !== null) { checkboxStates.set(cbMatch[2], cbMatch[1].toLowerCase() === 'x'); } const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; const phases: Record[] = []; let pMatch: RegExpExecArray | null; while ((pMatch = phasePattern.exec(content)) !== null) { const phaseNum = pMatch[1]; const phaseName = pMatch[2].replace(/\(INSERTED\)/i, '').trim(); const sectionStart = pMatch.index; const restOfContent = content.slice(sectionStart); const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i); const sectionEnd = nextHeader ? sectionStart + (nextHeader.index ?? 0) : content.length; const section = content.slice(sectionStart, sectionEnd); const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i); const goal = goalMatch ? goalMatch[1].trim() : null; const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i); const dependsOn = dependsMatch ? dependsMatch[1].trim() : null; const normalized = normalizePhaseName(phaseNum); let diskStatus = 'no_directory'; let planCount = 0; let summaryCount = 0; let hasContext = false; let hasResearch = false; let lastActivity: string | null = null; let isActive = false; try { const dirMatch = phaseDirEntries.find(d => phaseTokenMatches(d, normalized)); if (dirMatch) { const fullDir = join(paths.phases, dirMatch); const phaseFiles = readdirSync(fullDir); planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length; summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length; hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete'; else if (summaryCount > 0) diskStatus = 'partial'; else if (planCount > 0) diskStatus = 'planned'; else if (hasResearch) diskStatus = 'researched'; else if (hasContext) diskStatus = 'discussed'; else diskStatus = 'empty'; const now = Date.now(); let newestMtime = 0; for (const f of phaseFiles) { try { const st = statSync(join(fullDir, f)); if (st.mtimeMs > newestMtime) newestMtime = st.mtimeMs; } catch { /* intentionally empty */ } } if (newestMtime > 0) { lastActivity = new Date(newestMtime).toISOString(); isActive = (now - newestMtime) < 300000; // 5 minutes } } } catch { /* intentionally empty */ } const roadmapComplete = checkboxStates.get(phaseNum) || false; if (roadmapComplete && diskStatus !== 'complete') { diskStatus = 'complete'; } const MAX_NAME_WIDTH = 20; const displayName = phaseName.length > MAX_NAME_WIDTH ? phaseName.slice(0, MAX_NAME_WIDTH - 1) + '…' : phaseName; phases.push({ number: phaseNum, name: phaseName, display_name: displayName, goal, depends_on: dependsOn, disk_status: diskStatus, has_context: hasContext, has_research: hasResearch, plan_count: planCount, summary_count: summaryCount, roadmap_complete: roadmapComplete, last_activity: lastActivity, is_active: isActive, }); } // Dependency satisfaction const completedNums = new Set( phases.filter(p => p.disk_status === 'complete').map(p => p.number as string), ); for (const phase of phases) { const dependsOnStr = phase.depends_on as string | null; if (!dependsOnStr || /^none$/i.test(dependsOnStr.trim())) { phase.deps_satisfied = true; phase.dep_phases = []; phase.deps_display = '—'; } else { const depNums = dependsOnStr.match(/\d+(?:\.\d+)*/g) || []; phase.deps_satisfied = depNums.every(n => completedNums.has(n)); phase.dep_phases = depNums; phase.deps_display = depNums.length > 0 ? depNums.join(',') : '—'; } } // Sliding window: only first undiscussed phase is available to discuss let foundNextToDiscuss = false; for (const phase of phases) { const status = phase.disk_status as string; if (!foundNextToDiscuss && (status === 'empty' || status === 'no_directory')) { phase.is_next_to_discuss = true; foundNextToDiscuss = true; } else { phase.is_next_to_discuss = false; } } // Check WAITING.json signal let waitingSignal: unknown = null; try { const waitingPath = join(projectDir, '.planning', 'WAITING.json'); if (existsSync(waitingPath)) { const { readFileSync } = await import('node:fs'); waitingSignal = JSON.parse(readFileSync(waitingPath, 'utf-8')); } } catch { /* intentionally empty */ } // Compute recommended actions const phaseMap = new Map(phases.map(p => [p.number as string, p])); function reaches(from: string, to: string, visited = new Set()): boolean { if (visited.has(from)) return false; visited.add(from); const p = phaseMap.get(from); const depPhases = p?.dep_phases as string[] | undefined; if (!depPhases || depPhases.length === 0) return false; if (depPhases.includes(to)) return true; return depPhases.some(dep => reaches(dep, to, visited)); } const activeExecuting = phases.filter(p => { const status = p.disk_status as string; return status === 'partial' || (status === 'planned' && p.is_active); }); const activePlanning = phases.filter(p => { const status = p.disk_status as string; return p.is_active && (status === 'discussed' || status === 'researched'); }); const recommendedActions: Record[] = []; for (const phase of phases) { const status = phase.disk_status as string; if (status === 'complete') continue; if (/^999(?:\.|$)/.test(phase.number as string)) continue; if (status === 'planned' && phase.deps_satisfied) { const action = { phase: phase.number, phase_name: phase.name, action: 'execute', reason: `${phase.plan_count} plans ready, dependencies met`, command: `/gsd-execute-phase ${phase.number}`, }; const isAllowed = activeExecuting.length === 0 || activeExecuting.every(a => !reaches(phase.number as string, a.number as string) && !reaches(a.number as string, phase.number as string)); if (isAllowed) recommendedActions.push(action); } else if (status === 'discussed' || status === 'researched') { const action = { phase: phase.number, phase_name: phase.name, action: 'plan', reason: 'Context gathered, ready for planning', command: `/gsd-plan-phase ${phase.number}`, }; const isAllowed = activePlanning.length === 0 || activePlanning.every(a => !reaches(phase.number as string, a.number as string) && !reaches(a.number as string, phase.number as string)); if (isAllowed) recommendedActions.push(action); } else if ((status === 'empty' || status === 'no_directory') && phase.is_next_to_discuss) { recommendedActions.push({ phase: phase.number, phase_name: phase.name, action: 'discuss', reason: 'Unblocked, ready to gather context', command: `/gsd-discuss-phase ${phase.number}`, }); } } const completedCount = phases.filter(p => p.disk_status === 'complete').length; // Read manager flags from config const managerConfig = (config as Record).manager as Record> | undefined; const sanitizeFlags = (raw: unknown): string => { const val = typeof raw === 'string' ? raw : ''; if (!val) return ''; const tokens = val.split(/\s+/).filter(Boolean); const safe = tokens.every(t => /^--[a-zA-Z0-9][-a-zA-Z0-9]*$/.test(t) || /^[a-zA-Z0-9][-a-zA-Z0-9_.]*$/.test(t)); return safe ? val : ''; }; const managerFlags = { discuss: sanitizeFlags(managerConfig?.flags?.discuss), plan: sanitizeFlags(managerConfig?.flags?.plan), execute: sanitizeFlags(managerConfig?.flags?.execute), }; const result: Record = { milestone_version: milestone.version, milestone_name: milestone.name, phases, phase_count: phases.length, completed_count: completedCount, in_progress_count: phases.filter(p => ['partial', 'planned', 'discussed', 'researched'].includes(p.disk_status as string)).length, recommended_actions: recommendedActions, waiting_signal: waitingSignal, all_complete: completedCount === phases.length && phases.length > 0, project_exists: pathExists(projectDir, '.planning/PROJECT.md'), roadmap_exists: true, state_exists: true, manager_flags: managerFlags, }; return { data: withProjectRoot(projectDir, result, config as Record) }; };