diff --git a/CONTEXT.md b/CONTEXT.md index cb9a1a527..203982abb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -59,7 +59,7 @@ Canonical command normalization and resolution Interface (`query-command-resolut Module owning command resolution, policy projection (`mutation`, `output_mode`), unknown-command diagnosis, and handler Adapter binding at one seam for query dispatch. ### Init Command Module -SDK Module owning the `init.*` family of query handlers that compose atomic SDK queries into the flat JSON bundles consumed by init workflows (`/gsd-execute-phase`, `/gsd-plan-phase`, `/gsd-verify-work`, `/gsd-new-project`, `/gsd-manager`, `/gsd-progress`, `/gsd-resume`, etc.). Divided into two source files: `sdk/src/query/init.ts` (13 basic handlers plus `withProjectRoot` project-identity injection) and `sdk/src/query/init-complex.ts` (the 3 heavyweight handlers: `initNewProject`, `initProgress`, `initManager`). All handlers return `{ data: }` aligned with the CJS `bin/lib/init.cjs` surface. Test seams: `sdk/src/query/init.test.ts` (basic handlers + withProjectRoot precedence), `sdk/src/query/init-complex.test.ts` (complex handlers + progress/manager precedence regression #2674 + workstream scoping regression #3196), `sdk/src/query/init-workstream-milestone-op.test.ts` (initMilestoneOp + roadmapAnalyze + resolveQueryRuntimeContext workstream fallback). CJS CLI surface tested in `tests/init.test.cjs` and `tests/init-manager.test.cjs` (includes cross-milestone dependency regression #2267). +SDK Module owning the `init.*` family of query handlers that compose atomic SDK queries into the flat JSON bundles consumed by init workflows (`/gsd-execute-phase`, `/gsd-plan-phase`, `/gsd-verify-work`, `/gsd-new-project`, `/gsd-manager`, `/gsd-progress`, `/gsd-resume`, etc.). Divided into two source files: `sdk/src/handlers/init/composer.ts` (13 basic handlers plus `withProjectRoot` project-identity injection) and `sdk/src/handlers/init/complex.ts` (the 3 heavyweight handlers: `initNewProject`, `initProgress`, `initManager`). All handlers return `{ data: }` aligned with the CJS `bin/lib/init.cjs` surface. Test seams: `sdk/src/query/init.test.ts` (basic handlers + withProjectRoot precedence), `sdk/src/query/init-complex.test.ts` (complex handlers + progress/manager precedence regression #2674 + workstream scoping regression #3196), `sdk/src/query/init-workstream-milestone-op.test.ts` (initMilestoneOp + roadmapAnalyze + resolveQueryRuntimeContext workstream fallback). CJS CLI surface tested in `tests/init.test.cjs` and `tests/init-manager.test.cjs` (includes cross-milestone dependency regression #2267). ### CJS Command Router Adapter Module Compatibility Adapter Module for `gsd-tools.cjs` command families. Uses generated command metadata plus small argument shapers to route to CJS handlers, rather than calling SDK Command Topology directly. Preserves CJS compatibility startup while reducing hand-written router drift. Per-family migration to call the **Sync Runtime Bridge Module**'s `executeForCjs` in-process — eliminating the remaining parallel CJS handler implementations — is the active work of #3524 Phase 5; the primitive itself ships in #3555, with each canonical command family (`state.*`, `verify.*`, `init.*`, `phase.*`, `phases.*`, `validate.*`, `roadmap.*`, `frontmatter.*`, `config.*`) routing through `executeForCjs` in its own follow-up enhancement. diff --git a/sdk/src/handlers/init/complex.ts b/sdk/src/handlers/init/complex.ts new file mode 100644 index 000000000..2fb1bcff9 --- /dev/null +++ b/sdk/src/handlers/init/complex.ts @@ -0,0 +1,815 @@ +/** + * 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 './complex.js'; + * + * const result = await initProgress([], '/project'); + * // { data: { phases: [...], milestone_version: 'v3.0', ... } } + * ``` + */ + +import { existsSync, readdirSync, statSync, type Dirent } from 'node:fs'; +import { execSync } from 'node:child_process'; +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 '../../query/config-query.js'; +import { + detectRuntime, + planningPaths, + normalizePhaseName, + phaseTokenMatches, + resolveAgentsDir, + toPosixPath, +} from '../../query/helpers.js'; +import { + getMilestoneInfo, + extractCurrentMilestone, + extractNextMilestoneSection, + extractPhasesFromSection, +} from '../../query/roadmap.js'; +import { agentSkills } from '../../query/skills.js'; +import { withProjectRoot } from './composer.js'; +import type { QueryHandler } from '../../query/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 typeof data.model === 'string' ? data.model : 'sonnet'; +} + +/** + * Check if a file exists at a relative path within projectDir. + */ +function pathExists(base: string, relPath: string): boolean { + return existsSync(join(base, relPath)); +} + +/** + * Bug #3491: detect whether `base` is inside any git worktree, and if so, + * return the absolute worktree root. Mirrors the CJS `gitWorktreeInfoInternal` + * in get-shit-done/bin/lib/core.cjs — keep these two implementations behaviour- + * identical so the SDK and CJS init handlers emit the same has_git semantics. + * + * Returns { inside, worktreeRoot } — both fall back to false/null on any error + * (git unavailable, not a repo, timeout) so callers see the conservative + * default that preserves pre-fix behaviour for non-git environments. + */ +function gitWorktreeInfo(base: string): { inside: boolean; worktreeRoot: string | null } { + try { + const inside = execSync('git rev-parse --is-inside-work-tree', { + cwd: base, + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf-8', + timeout: 5000, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }).trim(); + if (inside !== 'true') return { inside: false, worktreeRoot: null }; + try { + const root = execSync('git rev-parse --show-toplevel', { + cwd: base, + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf-8', + timeout: 5000, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }).trim(); + return { inside: true, worktreeRoot: root || null }; + } catch { + return { inside: true, worktreeRoot: null }; + } + } catch { + return { inside: false, worktreeRoot: null }; + } +} + +function detectNestedSubdir(base: string, info: { inside: boolean; worktreeRoot: string | null }): boolean { + if (!info.inside) return false; + try { + const prefix = execSync('git rev-parse --show-prefix', { + cwd: base, + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf-8', + timeout: 5000, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }).trim().replace(/\\/g, '/'); + if (prefix.length > 0) return prefix !== '.' && prefix !== './'; + return false; + } catch {} + + if (!info.worktreeRoot) return false; + const normalize = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase(); + const root = normalize(info.worktreeRoot); + const cwd = normalize(base); + return root !== cwd; +} + + +const NEW_PROJECT_REQUIRED_AGENTS = [ + 'gsd-project-researcher', + 'gsd-research-synthesizer', + 'gsd-roadmapper', +]; + +function hasAgentDefinition(agentsDir: string, agent: string): boolean { + return existsSync(join(agentsDir, `${agent}.md`)) || + existsSync(join(agentsDir, `${agent}.agent.md`)); +} + +async function resolveAgentSkillPayloadAgents( + requiredAgents: string[], + projectDir: string, +): Promise { + const available: string[] = []; + for (const agent of requiredAgents) { + const result = await agentSkills([agent], projectDir); + if (typeof result.data === 'string' && result.data.trim() !== '') { + available.push(agent); + } + } + return available; +} + +/** + * Extract ROADMAP checkbox states: `- [x] Phase N` → true, `- [ ] Phase N` → false. + * Shared by initProgress and initManager so both treat ROADMAP as the + * fallback/override source of truth for completion. + */ +function extractCheckboxStates(content: string): Map { + const states = new Map(); + const pattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi; + let m: RegExpExecArray | null; + while ((m = pattern.exec(content)) !== null) { + states.set(m[2], m[1].toLowerCase() === 'x'); + } + return states; +} + +/** + * Extract terminal phase markers from ROADMAP phase headings, e.g. + * `(COMPLETE)`, `(SHIPPED ...)`, `(DEFERRED)`, `(SUPERSEDED ...)`. + * These labels mean the phase should not be selected as next pending work. + */ +function extractTerminalStatusLabels(content: string): Set { + const terminal = new Set(); + const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; + const terminalRe = /(?:\(|\*\*)\s*(SHIPPED|COMPLETE|DEFERRED|SUPERSEDED|MERGED\s+INTO|FOLDED\s+INTO)\b/i; + let m: RegExpExecArray | null; + while ((m = headingPattern.exec(content)) !== null) { + if (terminalRe.test(m[2])) { + terminal.add(m[1]); + terminal.add(m[1].replace(/^0+/, '') || '0'); + } + } + return terminal; +} + +/** + * Derive progress-level status from a ROADMAP checkbox when the phase has + * no on-disk directory. Returns 'complete' for `[x]`, 'not_started' otherwise. + * Disk status (when present) always wins — it's more recent truth for in-flight work. + */ +function deriveStatusFromCheckbox( + phaseNum: string, + checkboxStates: Map, +): 'complete' | 'not_started' { + const stripped = phaseNum.replace(/^0+/, '') || '0'; + if (checkboxStates.get(phaseNum) === true) return 'complete'; + if (checkboxStates.get(stripped) === true) return 'complete'; + return 'not_started'; +} + +function listPhasePlanAndSummaryCounts(phasePath: string): { plans: string[]; summaries: string[] } { + const phaseFiles = readdirSync(phasePath); + const rootPlans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const rootSummaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + + const plansDir = join(phasePath, 'plans'); + let nestedPlans: string[] = []; + let nestedSummaries: string[] = []; + if (existsSync(plansDir)) { + const files = readdirSync(plansDir); + nestedPlans = files.filter(f => /^PLAN-\d+.*\.md$/i.test(f)); + nestedSummaries = files.filter(f => /^SUMMARY-\d+.*\.md$/i.test(f)); + } + + return { + plans: rootPlans.concat(nestedPlans), + summaries: rootSummaries.concat(nestedSummaries), + }; +} + +// ─── 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, workstream); + + // 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 runtime = detectRuntime(config as { runtime?: unknown }); + const agentsDir = resolveAgentsDir(runtime, projectDir); + const gitInfo = gitWorktreeInfo(projectDir); + const missingRequiredAgents = NEW_PROJECT_REQUIRED_AGENTS.filter( + agent => !hasAgentDefinition(agentsDir, agent), + ); + const agentSkillPayloadAgents = await resolveAgentSkillPayloadAgents( + NEW_PROJECT_REQUIRED_AGENTS, + 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'), + + // Bug #3491: detect parent worktree to avoid nested .git init. + has_git: gitInfo.inside, + git_worktree_root: gitInfo.worktreeRoot, + in_nested_subdir: detectNestedSubdir(projectDir, gitInfo), + + brave_search_available: hasBraveSearch, + firecrawl_available: hasFirecrawl, + exa_search_available: hasExaSearch, + + project_path: '.planning/PROJECT.md', + agent_runtime: runtime, + agents_dir: agentsDir, + required_agents: NEW_PROJECT_REQUIRED_AGENTS, + required_agents_installed: missingRequiredAgents.length === 0, + missing_required_agents: missingRequiredAgents, + agent_skill_payloads_available: agentSkillPayloadAgents.length === NEW_PROJECT_REQUIRED_AGENTS.length, + agent_skill_payload_agents: agentSkillPayloadAgents, + }; + + 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, workstream); + const milestone = await getMilestoneInfo(projectDir, workstream); + const paths = planningPaths(projectDir, workstream); + + 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(); + let checkboxStates = new Map(); + let terminalLabels = new Set(); + + try { + const rawRoadmap = await readFile(paths.roadmap, 'utf-8'); + const roadmapContent = await extractCurrentMilestone(rawRoadmap, projectDir, workstream); + 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); + } + checkboxStates = extractCheckboxStates(roadmapContent); + terminalLabels = extractTerminalStatusLabels(roadmapContent); + } 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, summaries } = listPhasePlanAndSummaryCounts(phasePath); + const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); + + let status = + summaries.length >= plans.length && plans.length > 0 ? 'complete' : + plans.length > 0 ? 'in_progress' : + hasResearch ? 'researched' : 'pending'; + + // #2674: align with initManager — a ROADMAP `- [x] Phase N` checkbox + // wins over disk state. A stub phase dir with no SUMMARY is leftover + // scaffolding; the user's explicit [x] is the authoritative signal. + const strippedNum = phaseNumber.replace(/^0+/, '') || '0'; + const roadmapComplete = + checkboxStates.get(phaseNumber) === true || + checkboxStates.get(strippedNum) === true; + if (roadmapComplete && status !== 'complete') { + status = 'complete'; + } + if (terminalLabels.has(phaseNumber) || terminalLabels.has(strippedNum)) { + status = 'complete'; + } + + 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 phases with a ROADMAP + // `[x]` checkbox, treat them as complete (#2646). + for (const [num, name] of roadmapPhaseNames) { + const stripped = num.replace(/^0+/, '') || '0'; + if (!seenPhaseNums.has(stripped)) { + const status = deriveStatusFromCheckbox(num, checkboxStates); + const terminalComplete = terminalLabels.has(num) || terminalLabels.has(stripped); + const phaseInfo: Record = { + number: num, + name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''), + directory: null, + status: terminalComplete ? 'complete' : status, + plan_count: 0, + summary_count: 0, + has_research: false, + }; + phases.push(phaseInfo); + if (!nextPhase && !currentPhase && phaseInfo.status !== 'complete') { + 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, workstream); + const milestone = await getMilestoneInfo(projectDir, workstream); + const paths = planningPaths(projectDir, workstream); + + 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, workstream); + + // 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 (shared helper — #2646) + const checkboxStates = extractCheckboxStates(content); + + 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); + const counts = listPhasePlanAndSummaryCounts(fullDir); + planCount = counts.plans.length; + summaryCount = counts.summaries.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(',') : '—'; + } + } + + // Bug #2268: mark EVERY undiscussed phase as is_next_to_discuss, not just + // the first one. Multiple independent phases can be discussed in parallel + // — the sliding-window pattern made the manager only recommend one + // discuss action even when callers had free capacity to discuss several. + for (const phase of phases) { + const status = phase.disk_status as string; + phase.is_next_to_discuss = (status === 'empty' || status === 'no_directory'); + } + + // 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; + + // ── Next-milestone surface (issue #2497) ─────────────────────────────── + // Populate queued_phases + metadata with the milestone immediately after + // the active one, so the /gsd-manager dashboard can preview what's coming + // next without mixing it into the active phases grid. Empty/null when the + // active milestone is the last one in ROADMAP. + let queuedPhases: Record[] = []; + let queuedMilestoneVersion: string | null = null; + let queuedMilestoneName: string | null = null; + try { + const next = await extractNextMilestoneSection(rawContent, projectDir); + if (next) { + queuedMilestoneVersion = next.version; + queuedMilestoneName = next.name; + queuedPhases = extractPhasesFromSection(next.section).map(p => { + const MAX_NAME_WIDTH = 20; + const display_name = p.name.length > MAX_NAME_WIDTH + ? p.name.slice(0, MAX_NAME_WIDTH - 1) + '…' + : p.name; + const depNums = p.depends_on && !/^none$/i.test(p.depends_on.trim()) + ? (p.depends_on.match(/\d+(?:\.\d+)*/g) || []) + : []; + return { + number: p.number, + name: p.name, + display_name, + goal: p.goal, + depends_on: p.depends_on, + dep_phases: depNums, + deps_display: depNums.length > 0 ? depNums.join(',') : '—', + }; + }); + } + } catch { /* queued_phases is a non-critical enhancement */ } + + // 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, + queued_phases: queuedPhases, + queued_milestone_version: queuedMilestoneVersion, + queued_milestone_name: queuedMilestoneName, + project_exists: pathExists(projectDir, '.planning/PROJECT.md'), + roadmap_exists: true, + state_exists: true, + manager_flags: managerFlags, + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; diff --git a/sdk/src/handlers/init/composer.ts b/sdk/src/handlers/init/composer.ts new file mode 100644 index 000000000..b0a857574 --- /dev/null +++ b/sdk/src/handlers/init/composer.ts @@ -0,0 +1,1335 @@ +/** + * Init composition handlers — compound init commands for workflow bootstrapping. + * + * Composes existing atomic SDK queries into the same flat JSON bundles + * that CJS init.cjs produces, enabling workflow migration. Each handler + * follows the QueryHandler signature and returns { data: }. + * + * Port of get-shit-done/bin/lib/init.cjs (13 of 16 handlers). + * The 3 complex handlers (new-project, progress, manager) are in complex.ts. + * + * @example + * ```typescript + * import { initExecutePhase, withProjectRoot } from './composer.js'; + * + * const result = await initExecutePhase(['9'], '/project'); + * // { data: { executor_model: 'opus', phase_found: true, ... } } + * ``` + */ + +import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from 'node:fs'; +import { readFile, readdir } from 'node:fs/promises'; +import { join, relative, basename } from 'node:path'; +import { execSync } from 'node:child_process'; +import { homedir } from 'node:os'; +import { GSDError, ErrorClassification } from '../../errors.js'; + +import { loadConfig, type GSDConfig } from '../../config.js'; +import { resolveModel, MODEL_PROFILES } from '../../query/config-query.js'; +import { maskIfSecret } from '../../query/secrets.js'; +import { findPhase } from '../../query/phase.js'; +import { getMilestonePhaseFilter } from '../../query/state.js'; +import { roadmapGetPhase, getMilestoneInfo, extractCurrentMilestone, extractPhasesFromSection } from '../../query/roadmap.js'; +import { determinePhaseStatus } from '../../query/progress.js'; +import { planningPaths, normalizePhaseName, toPosixPath, resolveAgentsDir, detectRuntime } from '../../query/helpers.js'; +import { generatePhaseSlug, assertSafeProjectCode } from '../../query/phase-lifecycle-policy.js'; +import type { QueryHandler } from '../../query/utils.js'; + +// ─── Internal helpers ────────────────────────────────────────────────────── + +/** + * Extract model alias string from a 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'; +} + +/** + * Generate a slug from text (inline, matches CJS generateSlugInternal). + */ +function generateSlugInternal(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .substring(0, 60); +} + +function extractPhaseArg(args: string[]): string | undefined { + const equalsArg = args.find((arg) => arg.startsWith('--phase=')); + if (equalsArg) { + const value = equalsArg.slice('--phase='.length).trim(); + return value || undefined; + } + + const flagIndex = args.indexOf('--phase'); + if (flagIndex !== -1) { + const value = args[flagIndex + 1]; + return value && !value.startsWith('--') ? value : undefined; + } + + const first = args[0]; + return first && !first.startsWith('--') ? first : undefined; +} + +/** + * Check if a path exists on disk. + */ +function pathExists(base: string, relPath: string): boolean { + return existsSync(join(base, relPath)); +} + +/** + * Bug #3491: detect whether `base` is inside any git worktree, and if so, + * return the absolute worktree root. Mirrors the CJS `gitWorktreeInfoInternal` + * in get-shit-done/bin/lib/core.cjs — keep these two implementations behaviour- + * identical so the SDK and CJS init handlers emit the same has_git semantics. + * + * Returns { inside, worktreeRoot } — both fall back to false/null on any error + * (git unavailable, not a repo, timeout) so callers see the conservative + * default that preserves pre-fix behaviour for non-git environments. + */ +function gitWorktreeInfo(base: string): { inside: boolean; worktreeRoot: string | null } { + try { + const inside = execSync('git rev-parse --is-inside-work-tree', { + cwd: base, + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf-8', + timeout: 5000, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }).trim(); + if (inside !== 'true') return { inside: false, worktreeRoot: null }; + try { + const root = execSync('git rev-parse --show-toplevel', { + cwd: base, + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf-8', + timeout: 5000, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }).trim(); + return { inside: true, worktreeRoot: root || null }; + } catch { + return { inside: true, worktreeRoot: null }; + } + } catch { + return { inside: false, worktreeRoot: null }; + } +} + +function detectNestedSubdir(base: string, info: { inside: boolean; worktreeRoot: string | null }): boolean { + if (!info.inside) return false; + try { + const prefix = execSync('git rev-parse --show-prefix', { + cwd: base, + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf-8', + timeout: 5000, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }).trim().replace(/\\/g, '/'); + if (prefix.length > 0) return prefix !== '.' && prefix !== './'; + return false; + } catch {} + + if (!info.worktreeRoot) return false; + const normalize = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase(); + const root = normalize(info.worktreeRoot); + const cwd = normalize(base); + return root !== cwd; +} + + +/** + * Compute the canonical phase directory name for a known phase entry from the + * roadmap when no directory exists yet. Applies the project_code prefix so + * the first-touch creation path used by /gsd-discuss-phase and /gsd-plan-phase + * stays consistent with the prefix produced by `phase.add` / `phase.insert`. + * + * Returns null when phaseNumber or phaseName cannot be determined. + */ +function computeExpectedPhaseDirName( + phaseNumber: string | null, + phaseName: string | null, + projectCode: string, +): string | null { + if (!phaseNumber || !phaseName) return null; + const paddedNum = normalizePhaseName(phaseNumber); + const slug = generatePhaseSlug(phaseName); + if (!slug) return null; + const prefix = projectCode ? `${projectCode}-` : ''; + return `${prefix}${paddedNum}-${slug}`; +} + +async function shouldDropArchivedPhaseMatch( + phaseInfo: Record | null, + roadmapPhase: Record | null, + projectDir: string, + workstream?: string, +): Promise { + // Matches CJS cmdInitPlanPhase / cmdInitExecutePhase / cmdInitVerifyWork: + // if (phaseInfo?.archived && roadmapPhase?.found) phaseInfo = null; + // ROADMAP is authoritative for the current milestone, regardless of what + // archived milestone the on-disk match came from — BUT see #3469 exception. + if (!phaseInfo?.archived) return false; + if (!roadmapPhase || !roadmapPhase.found) return false; + + // #3469: If the archived phase belongs to the CURRENT milestone (e.g. phases + // were cleared via `phases clear` into .planning/milestones/-phases/ + // but the workflow is still on that same milestone), preserve the archived dir + // as the canonical phase location. Only drop when the archived version is from + // a PRIOR milestone (the original anti-ghost-phase guard). + // Note: the milestone-version equality check here does NOT regress #2391 + // because in the #2391 scenario archived.version != current milestone. + const pp = planningPaths(projectDir, workstream); + try { + const stateContent = readFileSync(pp.state, 'utf-8'); + const milestoneMatch = stateContent.match(/^milestone:\s*(.+)$/m); + const currentMilestone = milestoneMatch ? milestoneMatch[1].trim() : null; + if (currentMilestone && phaseInfo.archived === currentMilestone) { + // Same milestone — archived dir is the canonical location. Keep it. + return false; + } + } catch { /* STATE.md unreadable — fall through to default drop */ } + + return true; +} + +/** + * Get the latest completed milestone from MILESTONES.md. + * Port of getLatestCompletedMilestone from init.cjs lines 10-25. + */ +function getLatestCompletedMilestone(projectDir: string): { version: string; name: string } | null { + const milestonesPath = join(projectDir, '.planning', 'MILESTONES.md'); + if (!existsSync(milestonesPath)) return null; + + try { + const content = readFileSync(milestonesPath, 'utf-8'); + const match = content.match(/^##\s+(v[\d.]+)\s+(.+?)\s+\(Shipped:/m); + if (!match) return null; + return { version: match[1], name: match[2].trim() }; + } catch { + return null; + } +} + +/** + * Check which GSD agents are installed on disk. + * + * Runtime-aware per issue #2402: detects the invoking runtime + * (`GSD_RUNTIME` → `config.runtime` → 'claude') and probes that runtime's + * canonical `agents/` directory. `GSD_AGENTS_DIR` still short-circuits. + * + * The optional `projectDir` parameter enables the repo-local `.claude/agents` + * fallback for Claude `--local` installs (bug #3751). + * + * Port of checkAgentsInstalled from core.cjs lines 1274-1306. + */ +function checkAgentsInstalled(config?: { runtime?: unknown }, projectDir?: string): { agents_installed: boolean; missing_agents: string[] } { + const runtime = detectRuntime(config); + const agentsDir = resolveAgentsDir(runtime, projectDir); + const expectedAgents = Object.keys(MODEL_PROFILES); + + if (!existsSync(agentsDir)) { + return { agents_installed: false, missing_agents: expectedAgents }; + } + + const missing: string[] = []; + for (const agent of expectedAgents) { + const agentFile = join(agentsDir, `${agent}.md`); + const agentFileCopilot = join(agentsDir, `${agent}.agent.md`); + if (!existsSync(agentFile) && !existsSync(agentFileCopilot)) { + missing.push(agent); + } + } + + return { + agents_installed: missing.length === 0, + missing_agents: missing, + }; +} + +/** + * Extract phase info from findPhase result, or build fallback from roadmap. + */ +async function getPhaseInfoWithFallback( + phase: string, + projectDir: string, + workstream?: string, +): Promise<{ phaseInfo: Record | null; roadmapPhase: Record | null }> { + const phaseResult = await findPhase([phase], projectDir, workstream); + let phaseInfo = phaseResult.data as Record | null; + // findPhase returns { found: false } when missing; findPhaseInternal returns null — align for init parity. + if (phaseInfo && phaseInfo.found === false) { + phaseInfo = null; + } + + const roadmapResult = await roadmapGetPhase([phase], projectDir, workstream); + const roadmapPhase = roadmapResult.data as Record | null; + + // Match init.cjs: drop archived disk match when the phase is listed in the current ROADMAP + if (await shouldDropArchivedPhaseMatch(phaseInfo, roadmapPhase, projectDir, workstream)) { + phaseInfo = null; + } + + // Fallback to ROADMAP.md if no phase directory exists yet + if ((!phaseInfo || !phaseInfo.found) && roadmapPhase?.found) { + const phaseName = roadmapPhase.phase_name as string; + phaseInfo = { + found: true, + directory: null, + phase_number: roadmapPhase.phase_number, + phase_name: phaseName, + phase_slug: phaseName ? generateSlugInternal(phaseName) : null, + plans: [], + summaries: [], + incomplete_plans: [], + has_research: false, + has_context: false, + has_verification: false, + has_reviews: false, + }; + } + + return { phaseInfo, roadmapPhase }; +} + +/** + * Phase resolution for `init verify-work` — matches init.cjs cmdInitVerifyWork (archived + fallback). + */ +async function getPhaseInfoForVerifyWork( + phase: string, + projectDir: string, + workstream?: string, +): Promise<{ phaseInfo: Record | null }> { + const phaseResult = await findPhase([phase], projectDir, workstream); + let phaseInfo = phaseResult.data as Record | null; + if (phaseInfo && phaseInfo.found === false) { + phaseInfo = null; + } + + const roadmapResult = await roadmapGetPhase([phase], projectDir, workstream); + const roadmapPhase = roadmapResult.data as Record | null; + + if (await shouldDropArchivedPhaseMatch(phaseInfo, roadmapPhase, projectDir, workstream)) { + phaseInfo = null; + } + + if (!phaseInfo && roadmapPhase?.found) { + const phaseName = roadmapPhase.phase_name as string; + phaseInfo = { + found: true, + directory: null, + phase_number: roadmapPhase.phase_number, + phase_name: phaseName, + phase_slug: phaseName ? generateSlugInternal(phaseName) : null, + plans: [], + summaries: [], + incomplete_plans: [], + has_research: false, + has_context: false, + has_verification: false, + }; + } + + return { phaseInfo }; +} + +/** + * Extract requirement IDs from roadmap section text. + */ +function extractReqIds(roadmapPhase: Record | null): string | null { + const section = roadmapPhase?.section as string | undefined; + // Accept all bold/colon variants of the Requirements header. The forms + // **Requirements:** (colon inside bold) + // **Requirements**: (colon outside bold) + // **Requirements** : (space before outside colon) + // render identically in markdown but differ textually. Issue #2769. + const reqMatch = section?.match(/^\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]*)$/m); + const reqExtracted = reqMatch + ? reqMatch[1].replace(/[\[\]]/g, '').split(',').map((s: string) => s.trim()).filter(Boolean).join(', ') + : null; + return (reqExtracted && reqExtracted !== 'TBD') ? reqExtracted : null; +} + +// ─── withProjectRoot ───────────────────────────────────────────────────── + +/** + * Inject project_root, agents_installed, missing_agents, and response_language + * into an init result object. + * + * Port of withProjectRoot from init.cjs lines 32-63. + * + * @param projectDir - Absolute project root path + * @param result - The result object to augment + * @param config - Optional loaded config (avoids re-reading config.json) + * @returns The augmented result object + */ +export function withProjectRoot( + projectDir: string, + result: Record, + config?: Record, +): Record { + result.project_root = projectDir; + + const agentStatus = checkAgentsInstalled(config, projectDir); + result.agents_installed = agentStatus.agents_installed; + result.missing_agents = agentStatus.missing_agents; + + const responseLang = config?.response_language; + if (responseLang) { + result.response_language = responseLang; + } + + const projectCode = config?.project_code; + if (projectCode) { + result.project_code = projectCode; + } + + const projectMdPath = join(projectDir, '.planning', 'PROJECT.md'); + try { + if (existsSync(projectMdPath)) { + const content = readFileSync(projectMdPath, 'utf-8'); + const h1Match = content.match(/^#\s+(.+)$/m); + if (h1Match) { + result.project_title = h1Match[1].trim(); + } + } + } catch { + /* intentionally empty */ + } + + return result; +} + +// ─── initExecutePhase ───────────────────────────────────────────────────── + +/** + * Init handler for execute-phase workflow. + * Port of cmdInitExecutePhase from init.cjs lines 50-171. + */ +export const initExecutePhase: QueryHandler = async (args, projectDir, workstream) => { + const phase = extractPhaseArg(args); + if (!phase) { + return { data: { error: 'phase required for init execute-phase' } }; + } + + // --tdd is a boolean override of config.workflow.tdd_mode — matches the CJS + // path's parseNamedArgs(args, [], ['validate', 'tdd']) projection + // (bin/lib/init-command-router.cjs handler block) which passes options.tdd + // through to cmdInitExecutePhase. Without parsing here, `gsd-tools init + // execute-phase 1 --tdd` would never override a false config value. + const tddFlag = args.includes('--tdd'); + + const config = await loadConfig(projectDir); + const paths = planningPaths(projectDir, workstream); + const planningDir = paths.planning; + + const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir, workstream); + const phase_req_ids = extractReqIds(roadmapPhase); + + const configExists = existsSync(join(planningDir, 'config.json')); + const [executorModel, verifierModel] = configExists + ? await Promise.all([ + getModelAlias('gsd-executor', projectDir), + getModelAlias('gsd-verifier', projectDir), + ]) + : ['', '']; + + const milestone = await getMilestoneInfo(projectDir, workstream); + + const phaseNumber = (phaseInfo?.phase_number as string) || null; + const phaseSlug = (phaseInfo?.phase_slug as string) || null; + const plans = (phaseInfo?.plans || []) as string[]; + const summaries = (phaseInfo?.summaries || []) as string[]; + const incompletePlans = (phaseInfo?.incomplete_plans || []) as string[]; + const projectCode = (config as Record).project_code as string || ''; + + const result: Record = { + executor_model: executorModel, + verifier_model: verifierModel, + tdd_mode: tddFlag || (config.workflow.tdd_mode ?? false), + commit_docs: config.commit_docs, + sub_repos: (config as Record).sub_repos ?? [], + parallelization: config.parallelization, + context_window: (config as Record).context_window ?? 200000, + branching_strategy: config.git.branching_strategy, + phase_branch_template: config.git.phase_branch_template, + milestone_branch_template: config.git.milestone_branch_template, + verifier_enabled: config.workflow.verifier, + phase_found: !!phaseInfo, + phase_dir: (phaseInfo?.directory as string) ?? null, + phase_number: phaseNumber, + phase_name: (phaseInfo?.phase_name as string) ?? null, + phase_slug: phaseSlug, + phase_req_ids, + plans, + summaries, + incomplete_plans: incompletePlans, + plan_count: plans.length, + incomplete_count: incompletePlans.length, + branch_name: config.git.branching_strategy === 'phase' && phaseInfo + ? config.git.phase_branch_template + .replace('{project}', projectCode) + .replace('{phase}', phaseNumber || '') + .replace('{slug}', phaseSlug || 'phase') + : config.git.branching_strategy === 'milestone' + ? config.git.milestone_branch_template + .replace('{milestone}', milestone.version) + .replace('{slug}', generateSlugInternal(milestone.name) || 'milestone') + : null, + milestone_version: milestone.version, + milestone_name: milestone.name, + milestone_slug: generateSlugInternal(milestone.name), + state_exists: existsSync(join(planningDir, 'STATE.md')), + roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), + config_exists: configExists, + state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), + roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), + config_path: toPosixPath(relative(projectDir, join(planningDir, 'config.json'))), + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initPlanPhase ──────────────────────────────────────────────────────── + +/** + * Init handler for plan-phase workflow. + * Port of cmdInitPlanPhase from init.cjs lines 173-293. + */ +export const initPlanPhase: QueryHandler = async (args, projectDir, workstream) => { + const phase = extractPhaseArg(args); + if (!phase) { + return { data: { error: 'phase required for init plan-phase' } }; + } + + // --tdd boolean override (parity with CJS router's parseNamedArgs + the + // legacy cmdInitPlanPhase `options.tdd || config.tdd_mode || false`). + const tddFlag = args.includes('--tdd'); + + const config = await loadConfig(projectDir); + const paths = planningPaths(projectDir, workstream); + const planningDir = paths.planning; + + const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir, workstream); + const phase_req_ids = extractReqIds(roadmapPhase); + + const configExists = existsSync(join(planningDir, 'config.json')); + const [researcherModel, plannerModel, checkerModel] = configExists + ? await Promise.all([ + getModelAlias('gsd-phase-researcher', projectDir), + getModelAlias('gsd-planner', projectDir), + getModelAlias('gsd-plan-checker', projectDir), + ]) + : ['', '', '']; + + const phaseNumber = (phaseInfo?.phase_number as string) || null; + const phaseName = (phaseInfo?.phase_name as string) ?? null; + const phaseDir = (phaseInfo?.directory as string) ?? null; + const plans = (phaseInfo?.plans || []) as string[]; + const summaries = (phaseInfo?.summaries || []) as string[]; + + // #3569: surface phase lifecycle status so /gsd-plan-phase can short-circuit + // on closed (Complete) phases instead of silently replanning over shipped + // code. Reuses determinePhaseStatus — the project-wide vocabulary used by + // `progress` (Pending | Planned | In Progress | Executed | Complete | + // Needs Review). When the phase has no directory on disk yet, treat it as + // Pending (it has not been started). + const phaseStatus = phaseDir + ? await determinePhaseStatus(plans.length, summaries.length, join(projectDir, phaseDir)) + : 'Pending'; + + // #3287: compute the canonical directory name with project_code prefix so + // the first-touch mkdir in /gsd-plan-phase stays consistent with phase.add. + const rawProjectCode = (config as Record).project_code as string || ''; + assertSafeProjectCode(rawProjectCode); + const expectedPhaseDirName = phaseDir + ? null // directory already exists — no need to create + : computeExpectedPhaseDirName(phaseNumber, phaseName, rawProjectCode); + const expectedPhaseDir = expectedPhaseDirName + ? toPosixPath(relative(projectDir, join(paths.phases, expectedPhaseDirName))) + : null; + + const cfg = config as GSDConfig; + const result: Record = { + researcher_model: researcherModel, + planner_model: plannerModel, + checker_model: checkerModel, + tdd_mode: tddFlag || (config.workflow.tdd_mode ?? false), + research_enabled: config.workflow.research, + plan_checker_enabled: config.workflow.plan_check, + nyquist_validation_enabled: config.workflow.nyquist_validation, + commit_docs: config.commit_docs, + text_mode: config.workflow.text_mode, + auto_advance: !!config.workflow.auto_advance, + auto_chain_active: !!config.workflow._auto_chain_active, + mode: cfg.mode ?? 'interactive', + phase_found: !!phaseInfo, + phase_dir: phaseDir, + expected_phase_dir: expectedPhaseDir, + phase_number: phaseNumber, + phase_name: phaseName, + phase_slug: (phaseInfo?.phase_slug as string) ?? null, + padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null, + phase_req_ids, + phase_status: phaseStatus, + has_research: (phaseInfo?.has_research as boolean) || false, + has_context: (phaseInfo?.has_context as boolean) || false, + has_reviews: (phaseInfo?.has_reviews as boolean) || false, + has_plans: plans.length > 0, + plan_count: plans.length, + planning_exists: existsSync(planningDir), + roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), + state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), + roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), + requirements_path: toPosixPath(relative(projectDir, join(planningDir, 'REQUIREMENTS.md'))), + patterns_path: null, + }; + + // Add artifact paths if phase directory exists + if (phaseDir) { + const phaseDirFull = join(projectDir, phaseDir); + try { + const files = readdirSync(phaseDirFull); + const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); + if (contextFile) result.context_path = toPosixPath(join(phaseDir, contextFile)); + const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); + if (researchFile) result.research_path = toPosixPath(join(phaseDir, researchFile)); + const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); + if (verificationFile) result.verification_path = toPosixPath(join(phaseDir, verificationFile)); + const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); + if (uatFile) result.uat_path = toPosixPath(join(phaseDir, uatFile)); + const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'); + if (reviewsFile) result.reviews_path = toPosixPath(join(phaseDir, reviewsFile)); + const patternsFile = files.find(f => f.endsWith('-PATTERNS.md') || f === 'PATTERNS.md'); + if (patternsFile) result.patterns_path = toPosixPath(join(phaseDir, patternsFile)); + } catch { /* intentionally empty */ } + } + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initNewMilestone ───────────────────────────────────────────────────── + +/** + * Init handler for new-milestone workflow. + * Port of cmdInitNewMilestone from init.cjs lines 401-446. + */ +export const initNewMilestone: QueryHandler = async (_args, projectDir) => { + const config = await loadConfig(projectDir); + const planningDir = join(projectDir, '.planning'); + const milestone = await getMilestoneInfo(projectDir); + const latestCompleted = getLatestCompletedMilestone(projectDir); + + const phasesDir = join(planningDir, 'phases'); + let phaseDirCount = 0; + try { + if (existsSync(phasesDir)) { + // Bug #2445 parity with CJS `cmdInitNewMilestone`: filter phase dirs + // to the current milestone so stale dirs from a prior milestone that + // weren't archived don't inflate the count. Without this filter the + // SDK returns the full directory count, which the new-milestone + // workflow then uses to gate "is this a fresh start" decisions. + const isDirInMilestone = await getMilestonePhaseFilter(projectDir); + phaseDirCount = readdirSync(phasesDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && isDirInMilestone(entry.name)) + .length; + } + } catch { /* intentionally empty */ } + + 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, + research_enabled: config.workflow.research, + current_milestone: milestone.version, + current_milestone_name: milestone.name, + latest_completed_milestone: latestCompleted?.version || null, + latest_completed_milestone_name: latestCompleted?.name || null, + phase_dir_count: phaseDirCount, + phase_archive_path: latestCompleted + ? toPosixPath(relative(projectDir, join(projectDir, '.planning', 'milestones', `${latestCompleted.version}-phases`))) + : null, + project_exists: pathExists(projectDir, '.planning/PROJECT.md'), + roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), + state_exists: existsSync(join(planningDir, 'STATE.md')), + project_path: '.planning/PROJECT.md', + roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), + state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initQuick ──────────────────────────────────────────────────────────── + +/** + * Init handler for quick workflow. + * Port of cmdInitQuick from init.cjs lines 448-504. + */ +export const initQuick: QueryHandler = async (args, projectDir) => { + const description = args[0] || null; + const config = await loadConfig(projectDir); + const planningDir = join(projectDir, '.planning'); + const now = new Date(); + const slug = description ? generateSlugInternal(description).substring(0, 40) : null; + + // Generate collision-resistant quick task ID: YYMMDD-xxx + const yy = String(now.getFullYear()).slice(-2); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const dd = String(now.getDate()).padStart(2, '0'); + const dateStr = yy + mm + dd; + const secondsSinceMidnight = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds(); + const timeBlocks = Math.floor(secondsSinceMidnight / 2); + const timeEncoded = timeBlocks.toString(36).padStart(3, '0'); + const quickId = dateStr + '-' + timeEncoded; + const branchSlug = slug || 'quick'; + const quickBranchName = config.git.quick_branch_template + ? config.git.quick_branch_template + .replace('{num}', quickId) + .replace('{quick}', quickId) + .replace('{slug}', branchSlug) + : null; + + const configExists = existsSync(join(planningDir, 'config.json')); + const [plannerModel, executorModel, checkerModel, verifierModel] = configExists + ? await Promise.all([ + getModelAlias('gsd-planner', projectDir), + getModelAlias('gsd-executor', projectDir), + getModelAlias('gsd-plan-checker', projectDir), + getModelAlias('gsd-verifier', projectDir), + ]) + : ['', '', '', '']; + + const result: Record = { + planner_model: plannerModel, + executor_model: executorModel, + checker_model: checkerModel, + verifier_model: verifierModel, + commit_docs: config.commit_docs, + branch_name: quickBranchName, + quick_id: quickId, + slug, + description, + date: now.toISOString().split('T')[0], + timestamp: now.toISOString(), + quick_dir: '.planning/quick', + task_dir: slug ? `.planning/quick/${quickId}-${slug}` : null, + roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), + planning_exists: existsSync(join(projectDir, '.planning')), + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initResume ─────────────────────────────────────────────────────────── + +/** + * Init handler for resume-project workflow. + * Port of cmdInitResume from init.cjs lines 506-536. + */ +export const initResume: QueryHandler = async (_args, projectDir) => { + const config = await loadConfig(projectDir); + const planningDir = join(projectDir, '.planning'); + + let interruptedAgentId: string | null = null; + try { + interruptedAgentId = readFileSync(join(projectDir, '.planning', 'current-agent-id.txt'), 'utf-8').trim(); + } catch { /* intentionally empty */ } + + const result: Record = { + state_exists: existsSync(join(planningDir, 'STATE.md')), + roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), + project_exists: pathExists(projectDir, '.planning/PROJECT.md'), + planning_exists: existsSync(join(projectDir, '.planning')), + state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), + roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), + project_path: '.planning/PROJECT.md', + has_interrupted_agent: !!interruptedAgentId, + interrupted_agent_id: interruptedAgentId, + commit_docs: config.commit_docs, + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initVerifyWork ─────────────────────────────────────────────────────── + +/** + * Init handler for verify-work workflow. + * Port of cmdInitVerifyWork from init.cjs lines 538-586. + */ +export const initVerifyWork: QueryHandler = async (args, projectDir, workstream) => { + const phase = extractPhaseArg(args); + if (!phase) { + return { data: { error: 'phase required for init verify-work' } }; + } + + const config = await loadConfig(projectDir, workstream); + const { phaseInfo } = await getPhaseInfoForVerifyWork(phase, projectDir, workstream); + + const configExists = existsSync(join(projectDir, '.planning', 'config.json')); + const [plannerModel, checkerModel] = configExists + ? await Promise.all([ + getModelAlias('gsd-planner', projectDir), + getModelAlias('gsd-plan-checker', projectDir), + ]) + : ['', '']; + + const result: Record = { + planner_model: plannerModel, + checker_model: checkerModel, + commit_docs: config.commit_docs, + phase_found: !!phaseInfo, + phase_dir: (phaseInfo?.directory as string) ?? null, + phase_number: (phaseInfo?.phase_number as string) ?? null, + phase_name: (phaseInfo?.phase_name as string) ?? null, + has_verification: (phaseInfo?.has_verification as boolean) || false, + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initPhaseOp ────────────────────────────────────────────────────────── + +/** + * Init handler for discuss-phase and similar phase operations. + * Port of cmdInitPhaseOp from init.cjs lines 588-697. + */ +export const initPhaseOp: QueryHandler = async (args, projectDir, workstream) => { + const phase = extractPhaseArg(args); + if (!phase) { + return { data: { error: 'phase required for init phase-op' } }; + } + + const config = await loadConfig(projectDir); + const paths = planningPaths(projectDir, workstream); + const planningDir = paths.planning; + + // findPhase with archived override: if only match is archived, prefer ROADMAP + const phaseResult = await findPhase([phase], projectDir, workstream); + let phaseInfo = phaseResult.data as Record | null; + + const roadmapResult = await roadmapGetPhase([phase], projectDir, workstream); + const roadmapPhase = roadmapResult.data as Record | null; + + // If the only match comes from an archived milestone, prefer current ROADMAP + if (roadmapPhase?.found && await shouldDropArchivedPhaseMatch(phaseInfo, roadmapPhase, projectDir, workstream)) { + const phaseName = roadmapPhase.phase_name as string; + phaseInfo = { + found: true, + directory: null, + phase_number: roadmapPhase.phase_number, + phase_name: phaseName, + phase_slug: phaseName ? generateSlugInternal(phaseName) : null, + plans: [], + summaries: [], + incomplete_plans: [], + has_research: false, + has_context: false, + has_verification: false, + }; + } + + // Fallback to ROADMAP.md if no directory exists + if (!phaseInfo || !phaseInfo.found) { + if (roadmapPhase?.found) { + const phaseName = roadmapPhase.phase_name as string; + phaseInfo = { + found: true, + directory: null, + phase_number: roadmapPhase.phase_number, + phase_name: phaseName, + phase_slug: phaseName ? generateSlugInternal(phaseName) : null, + plans: [], + summaries: [], + incomplete_plans: [], + has_research: false, + has_context: false, + has_verification: false, + }; + } + } + + const phaseFound = !!(phaseInfo && phaseInfo.found); + const phaseNumber = (phaseInfo?.phase_number as string) || null; + const phaseName = (phaseInfo?.phase_name as string) ?? null; + const phaseDir = (phaseInfo?.directory as string) ?? null; + const plans = (phaseInfo?.plans || []) as string[]; + + // #3287: compute the canonical directory name with project_code prefix so + // the first-touch mkdir in /gsd-discuss-phase stays consistent with phase.add. + const rawProjectCode = (config as Record).project_code as string || ''; + assertSafeProjectCode(rawProjectCode); + const expectedPhaseDirName = phaseDir + ? null // directory already exists — no need to create + : computeExpectedPhaseDirName(phaseNumber, phaseName, rawProjectCode); + const expectedPhaseDir = expectedPhaseDirName + ? toPosixPath(relative(projectDir, join(paths.phases, expectedPhaseDirName))) + : null; + + const result: Record = { + commit_docs: config.commit_docs, + // #2997: secret config keys (brave_search, firecrawl, exa_search) may be + // either boolean availability flags OR string API keys depending on how the + // user configured them. Pass booleans through; mask string values so the + // init bundle never echoes plaintext credentials. Mirrors the masking added + // to config-get/config-set in the same fix. + brave_search: typeof config.brave_search === 'string' ? maskIfSecret('brave_search', config.brave_search) : config.brave_search, + firecrawl: typeof config.firecrawl === 'string' ? maskIfSecret('firecrawl', config.firecrawl) : config.firecrawl, + exa_search: typeof config.exa_search === 'string' ? maskIfSecret('exa_search', config.exa_search) : config.exa_search, + phase_found: phaseFound, + phase_dir: phaseDir, + expected_phase_dir: expectedPhaseDir, + phase_number: phaseNumber, + phase_name: phaseName, + phase_slug: (phaseInfo?.phase_slug as string) ?? null, + padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null, + has_research: (phaseInfo?.has_research as boolean) || false, + has_context: (phaseInfo?.has_context as boolean) || false, + has_plans: plans.length > 0, + has_verification: (phaseInfo?.has_verification as boolean) || false, + has_reviews: (phaseInfo?.has_reviews as boolean) || false, + plan_count: plans.length, + roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), + planning_exists: existsSync(planningDir), + state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), + roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), + requirements_path: toPosixPath(relative(projectDir, join(planningDir, 'REQUIREMENTS.md'))), + }; + + // Add artifact paths if phase directory exists + if (phaseDir) { + const phaseDirFull = join(projectDir, phaseDir); + try { + const files = readdirSync(phaseDirFull); + const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); + if (contextFile) result.context_path = toPosixPath(join(phaseDir, contextFile)); + const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); + if (researchFile) result.research_path = toPosixPath(join(phaseDir, researchFile)); + const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); + if (verificationFile) result.verification_path = toPosixPath(join(phaseDir, verificationFile)); + const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); + if (uatFile) result.uat_path = toPosixPath(join(phaseDir, uatFile)); + const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'); + if (reviewsFile) result.reviews_path = toPosixPath(join(phaseDir, reviewsFile)); + } catch { /* intentionally empty */ } + } + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initTodos ──────────────────────────────────────────────────────────── + +/** + * Init handler for check-todos and add-todo workflows. + * Port of cmdInitTodos from init.cjs lines 699-756. + */ +export const initTodos: QueryHandler = async (args, projectDir) => { + const area = args[0] || null; + const config = await loadConfig(projectDir); + const planningDir = join(projectDir, '.planning'); + const now = new Date(); + + const pendingDir = join(planningDir, 'todos', 'pending'); + let count = 0; + const todos: Array> = []; + + try { + const files = readdirSync(pendingDir).filter(f => f.endsWith('.md')); + for (const file of files) { + try { + const content = readFileSync(join(pendingDir, file), 'utf-8'); + const createdMatch = content.match(/^created:\s*(.+)$/m); + const titleMatch = content.match(/^title:\s*(.+)$/m); + const areaMatch = content.match(/^area:\s*(.+)$/m); + const todoArea = areaMatch ? areaMatch[1].trim() : 'general'; + + if (area && todoArea !== area) continue; + + count++; + todos.push({ + file, + created: createdMatch ? createdMatch[1].trim() : 'unknown', + title: titleMatch ? titleMatch[1].trim() : 'Untitled', + area: todoArea, + path: toPosixPath(relative(projectDir, join(pendingDir, file))), + }); + } catch { /* intentionally empty */ } + } + } catch { /* intentionally empty */ } + + const result: Record = { + commit_docs: config.commit_docs, + date: now.toISOString().split('T')[0], + timestamp: now.toISOString(), + todo_count: count, + todos, + area_filter: area, + pending_dir: toPosixPath(relative(projectDir, join(planningDir, 'todos', 'pending'))), + completed_dir: toPosixPath(relative(projectDir, join(planningDir, 'todos', 'completed'))), + planning_exists: existsSync(planningDir), + todos_dir_exists: existsSync(join(planningDir, 'todos')), + pending_dir_exists: existsSync(pendingDir), + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initMilestoneOp ───────────────────────────────────────────────────── + +/** + * Init handler for complete-milestone and audit-milestone workflows. + * Port of cmdInitMilestoneOp from init.cjs lines 758-817. + */ +export const initMilestoneOp: QueryHandler = async (_args, projectDir, workstream) => { + const config = await loadConfig(projectDir); + const paths = planningPaths(projectDir, workstream); + const planningDir = paths.planning; + const milestone = await getMilestoneInfo(projectDir, workstream); + + const phasesDir = join(planningDir, 'phases'); + let phaseCount = 0; + let completedPhases = 0; + + // Bug #2633 — ROADMAP.md (current milestone section) is the authority for + // phase counts, NOT the on-disk `.planning/phases/` directory. After + // `phases clear` between milestones, on-disk dirs will be a subset of the + // roadmap until each phase is materialized, and reading from disk causes + // `all_phases_complete: true` to fire as soon as the materialized subset + // gets summaries — even though the roadmap has phases still to do. + let roadmapPhaseNumbers: string[] = []; + try { + const { readFile } = await import('node:fs/promises'); + const roadmapRaw = await readFile(join(planningDir, 'ROADMAP.md'), 'utf-8'); + const currentSection = await extractCurrentMilestone(roadmapRaw, projectDir, workstream); + roadmapPhaseNumbers = extractPhasesFromSection(currentSection).map(p => p.number); + } catch { /* intentionally empty */ } + + // Build the on-disk index keyed by the canonical full phase token (e.g. + // "3", "3A", "3.1") so distinct tokens with the same integer prefix never + // collide. Roadmap writes "Phase 3", "Phase 3A", and "Phase 3.1" as + // distinct phases and disk dirs preserve those tokens. + // Canonicalize a phase token by stripping leading zeros from the integer + // head while preserving any [A-Z]? suffix and dotted segments. So "03" → + // "3", "03A" → "3A", "03.1" → "3.1", "3A" → "3A". This lets disk dirs that + // pad ("03-alpha") match roadmap tokens ("Phase 3") without ever collapsing + // distinct tokens like "3" / "3A" / "3.1" into the same bucket. + const canonicalizePhase = (tok: string): string => { + const m = tok.match(/^(\d+)([A-Z]?(?:\.\d+)*)$/); + return m ? String(parseInt(m[1], 10)) + m[2] : tok; + }; + const diskPhaseDirs: Map = new Map(); + try { + const entries = readdirSync(phasesDir, { withFileTypes: true }); + for (const e of entries) { + if (!e.isDirectory()) continue; + const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/); + if (!m) continue; + diskPhaseDirs.set(canonicalizePhase(m[1]), e.name); + } + } catch { /* intentionally empty */ } + + if (roadmapPhaseNumbers.length > 0) { + phaseCount = roadmapPhaseNumbers.length; + for (const num of roadmapPhaseNumbers) { + const dirName = diskPhaseDirs.get(canonicalizePhase(num)); + if (!dirName) continue; + try { + const phaseFiles = readdirSync(join(phasesDir, dirName)); + const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + if (hasSummary) completedPhases++; + } catch { /* intentionally empty */ } + } + } else { + // Fallback: no parseable ROADMAP (e.g. brand-new project). Preserve the + // legacy on-disk-count behavior so existing no-roadmap tests still pass. + try { + const entries = readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + phaseCount = dirs.length; + for (const dir of dirs) { + try { + const phaseFiles = readdirSync(join(phasesDir, dir)); + const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + if (hasSummary) completedPhases++; + } catch { /* intentionally empty */ } + } + } catch { /* intentionally empty */ } + } + + const archiveDir = join(planningDir, 'archive'); + let archivedMilestones: string[] = []; + try { + archivedMilestones = readdirSync(archiveDir, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name); + } catch { /* intentionally empty */ } + + const result: Record = { + commit_docs: config.commit_docs, + milestone_version: milestone.version, + milestone_name: milestone.name, + milestone_slug: generateSlugInternal(milestone.name), + phase_count: phaseCount, + completed_phases: completedPhases, + all_phases_complete: phaseCount > 0 && phaseCount === completedPhases, + archived_milestones: archivedMilestones, + archive_count: archivedMilestones.length, + project_exists: pathExists(projectDir, '.planning/PROJECT.md'), + roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), + state_exists: existsSync(join(planningDir, 'STATE.md')), + archive_exists: existsSync(archiveDir), + phases_dir_exists: existsSync(phasesDir), + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initMapCodebase ────────────────────────────────────────────────────── + +/** + * Init handler for map-codebase workflow. + * Port of cmdInitMapCodebase from init.cjs lines 819-852. + */ +export const initMapCodebase: QueryHandler = async (_args, projectDir) => { + const config = await loadConfig(projectDir); + const now = new Date(); + const codebaseDir = join(projectDir, '.planning', 'codebase'); + let existingMaps: string[] = []; + try { + existingMaps = readdirSync(codebaseDir).filter(f => f.endsWith('.md')); + } catch { /* intentionally empty */ } + + const mapperModel = await getModelAlias('gsd-codebase-mapper', projectDir); + + const result: Record = { + mapper_model: mapperModel, + commit_docs: config.commit_docs, + search_gitignored: config.search_gitignored, + parallelization: config.parallelization, + // subagent_timeout lives at workflow.subagent_timeout per the canonical + // Configuration manifest (sdk/shared/config-defaults.manifest.json). Reading + // the top-level config.subagent_timeout returned undefined, so the workflow + // step that consumes this value had to invent its own fallback. Default to + // 300000 (5 min) per the manifest. (#1472) + subagent_timeout: (((config as Record).workflow as Record | undefined)?.subagent_timeout as number | undefined) ?? 300000, + date: now.toISOString().split('T')[0], + timestamp: now.toISOString(), + codebase_dir: '.planning/codebase', + existing_maps: existingMaps, + has_maps: existingMaps.length > 0, + planning_exists: pathExists(projectDir, '.planning'), + codebase_dir_exists: pathExists(projectDir, '.planning/codebase'), + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initNewWorkspace ───────────────────────────────────────────────────── + +/** + * Init handler for new-workspace workflow. + * Port of cmdInitNewWorkspace from init.cjs lines 1311-1335. + * T-14-01: Validates workspace name rejects path separators. + */ +export const initNewWorkspace: QueryHandler = async (_args, projectDir) => { + const home = process.env.HOME || homedir(); + const defaultBase = join(home, 'gsd-workspaces'); + + // Detect child git repos (one level deep) + const childRepos: Array<{ name: string; path: string; has_uncommitted: boolean }> = []; + try { + const entries = readdirSync(projectDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + const fullPath = join(projectDir, entry.name); + if (existsSync(join(fullPath, '.git'))) { + let hasUncommitted = false; + try { + const status = execSync('git status --porcelain', { cwd: fullPath, encoding: 'utf8', timeout: 5000, stdio: 'pipe' }); + hasUncommitted = status.trim().length > 0; + } catch { /* best-effort */ } + childRepos.push({ name: entry.name, path: fullPath, has_uncommitted: hasUncommitted }); + } + } + } catch { /* intentionally empty */ } + + let worktreeAvailable = false; + try { + execSync('git --version', { encoding: 'utf8', timeout: 5000, stdio: 'pipe' }); + worktreeAvailable = true; + } catch { /* no git */ } + + const result: Record = { + default_workspace_base: defaultBase, + child_repos: childRepos, + child_repo_count: childRepos.length, + worktree_available: worktreeAvailable, + is_git_repo: pathExists(projectDir, '.git'), + cwd_repo_name: basename(projectDir), + }; + + return { data: withProjectRoot(projectDir, result) }; +}; + +// ─── initListWorkspaces ─────────────────────────────────────────────────── + +/** + * Init handler for list-workspaces workflow. + * Port of cmdInitListWorkspaces from init.cjs lines 1337-1381. + */ +export const initListWorkspaces: QueryHandler = async (_args, _projectDir) => { + const home = process.env.HOME || homedir(); + const defaultBase = join(home, 'gsd-workspaces'); + + const workspaces: Array> = []; + if (existsSync(defaultBase)) { + let entries: Dirent[] = []; + try { + entries = readdirSync(defaultBase, { withFileTypes: true }); + } catch { entries = []; } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const wsPath = join(defaultBase, String(entry.name)); + const manifestPath = join(wsPath, 'WORKSPACE.md'); + if (!existsSync(manifestPath)) continue; + + let repoCount = 0; + let strategy = 'unknown'; + try { + const manifest = readFileSync(manifestPath, 'utf8'); + const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m); + if (strategyMatch) strategy = strategyMatch[1].trim(); + const tableRows = manifest.split('\n').filter(l => l.match(/^\|\s*\w/) && !l.includes('Repo') && !l.includes('---')); + repoCount = tableRows.length; + } catch { /* best-effort */ } + const hasProject = existsSync(join(wsPath, '.planning', 'PROJECT.md')); + + workspaces.push({ + name: entry.name, + path: wsPath, + repo_count: repoCount, + strategy, + has_project: hasProject, + }); + } + } + + const result: Record = { + workspace_base: defaultBase, + workspaces, + workspace_count: workspaces.length, + }; + + return { data: result }; +}; + +// ─── initRemoveWorkspace ────────────────────────────────────────────────── + +/** + * Init handler for remove-workspace workflow. + * Port of cmdInitRemoveWorkspace from init.cjs lines 1383-1443. + * T-14-01: Validates workspace name rejects path separators and '..' sequences. + */ +export const initRemoveWorkspace: QueryHandler = async (args, _projectDir) => { + const name = args[0]; + if (!name) { + // Throw so the CLI dispatcher projects a non-zero exit + writes the message + // to stderr — returning `{ data: { error } }` was treated as success by + // the CLI output path, hiding the validation failure from callers. + throw new GSDError('workspace name required for init remove-workspace', ErrorClassification.Validation); + } + + // T-14-01: Reject path traversal attempts + if (name.includes('/') || name.includes('\\') || name.includes('..')) { + throw new GSDError( + `Invalid workspace name: ${name} (path separators not allowed)`, + ErrorClassification.Validation, + ); + } + + const home = process.env.HOME || homedir(); + const defaultBase = join(home, 'gsd-workspaces'); + const wsPath = join(defaultBase, name); + const manifestPath = join(wsPath, 'WORKSPACE.md'); + + if (!existsSync(wsPath)) { + throw new GSDError(`Workspace not found: ${wsPath}`, ErrorClassification.Validation); + } + + const repos: Array> = []; + let strategy = 'unknown'; + if (existsSync(manifestPath)) { + try { + const manifest = readFileSync(manifestPath, 'utf8'); + const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m); + if (strategyMatch) strategy = strategyMatch[1].trim(); + + const lines = manifest.split('\n'); + for (const line of lines) { + const match = line.match(/^\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|$/); + if (match && match[1] !== 'Repo' && !match[1].includes('---')) { + repos.push({ name: match[1], source: match[2], branch: match[3], strategy: match[4] }); + } + } + } catch { /* best-effort */ } + } + + // Check for uncommitted changes in workspace repos + const dirtyRepos: string[] = []; + for (const repo of repos) { + const repoPath = join(wsPath, repo.name as string); + if (!existsSync(repoPath)) continue; + try { + const status = execSync('git status --porcelain', { cwd: repoPath, encoding: 'utf8', timeout: 5000, stdio: 'pipe' }); + if (status.trim().length > 0) { + dirtyRepos.push(repo.name as string); + } + } catch { /* best-effort */ } + } + + const result: Record = { + workspace_name: name, + workspace_path: wsPath, + has_manifest: existsSync(manifestPath), + strategy, + repos, + repo_count: repos.length, + dirty_repos: dirtyRepos, + has_dirty_repos: dirtyRepos.length > 0, + }; + + return { data: result }; +}; +// ─── initIngestDocs ─────────────────────────────────────────────────────── + +/** + * Init handler for ingest-docs workflow. + * Mirrors `initResume` shape but without current-agent-id lookup — the + * ingest-docs workflow reads `project_exists`, `planning_exists`, `has_git`, + * and `project_path` to branch between new-project vs merge-milestone modes. + */ +export const initIngestDocs: QueryHandler = async (_args, projectDir) => { + const config = await loadConfig(projectDir); + const gitInfo = gitWorktreeInfo(projectDir); + const result: Record = { + project_exists: pathExists(projectDir, '.planning/PROJECT.md'), + planning_exists: pathExists(projectDir, '.planning'), + // Bug #3491: detect parent worktree to avoid nested .git init. + has_git: gitInfo.inside, + git_worktree_root: gitInfo.worktreeRoot, + in_nested_subdir: detectNestedSubdir(projectDir, gitInfo), + project_path: '.planning/PROJECT.md', + commit_docs: config.commit_docs, + }; + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; diff --git a/sdk/src/query/command-family-handlers.ts b/sdk/src/query/command-family-handlers.ts index d3b00f103..f90a2802e 100644 --- a/sdk/src/query/command-family-handlers.ts +++ b/sdk/src/query/command-family-handlers.ts @@ -34,8 +34,8 @@ import { initIngestDocs, initResume, initVerifyWork, initPhaseOp, initTodos, initMilestoneOp, initMapCodebase, initNewWorkspace, initListWorkspaces, initRemoveWorkspace, -} from './init.js'; -import { initNewProject, initProgress, initManager } from './init-complex.js'; +} from '../handlers/init/composer.js'; +import { initNewProject, initProgress, initManager } from '../handlers/init/complex.js'; export const FAMILY_HANDLERS: Record>> = { state: { diff --git a/sdk/src/query/init-complex.test.ts b/sdk/src/query/init-complex.test.ts index d780fb343..d1bf450b3 100644 --- a/sdk/src/query/init-complex.test.ts +++ b/sdk/src/query/init-complex.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { initNewProject, initProgress, initManager } from './init-complex.js'; +import { initNewProject, initProgress, initManager } from '../handlers/init/complex.js'; let tmpDir: string; let previousGsdAgentsDir: string | undefined; diff --git a/sdk/src/query/init-complex.ts b/sdk/src/query/init-complex.ts index 5e13c738d..0df7f1f38 100644 --- a/sdk/src/query/init-complex.ts +++ b/sdk/src/query/init-complex.ts @@ -1,815 +1 @@ -/** - * 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 { execSync } from 'node:child_process'; -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 { - detectRuntime, - planningPaths, - normalizePhaseName, - phaseTokenMatches, - resolveAgentsDir, - toPosixPath, -} from './helpers.js'; -import { - getMilestoneInfo, - extractCurrentMilestone, - extractNextMilestoneSection, - extractPhasesFromSection, -} from './roadmap.js'; -import { agentSkills } from './skills.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 typeof data.model === 'string' ? data.model : 'sonnet'; -} - -/** - * Check if a file exists at a relative path within projectDir. - */ -function pathExists(base: string, relPath: string): boolean { - return existsSync(join(base, relPath)); -} - -/** - * Bug #3491: detect whether `base` is inside any git worktree, and if so, - * return the absolute worktree root. Mirrors the CJS `gitWorktreeInfoInternal` - * in get-shit-done/bin/lib/core.cjs — keep these two implementations behaviour- - * identical so the SDK and CJS init handlers emit the same has_git semantics. - * - * Returns { inside, worktreeRoot } — both fall back to false/null on any error - * (git unavailable, not a repo, timeout) so callers see the conservative - * default that preserves pre-fix behaviour for non-git environments. - */ -function gitWorktreeInfo(base: string): { inside: boolean; worktreeRoot: string | null } { - try { - const inside = execSync('git rev-parse --is-inside-work-tree', { - cwd: base, - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf-8', - timeout: 5000, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }).trim(); - if (inside !== 'true') return { inside: false, worktreeRoot: null }; - try { - const root = execSync('git rev-parse --show-toplevel', { - cwd: base, - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf-8', - timeout: 5000, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }).trim(); - return { inside: true, worktreeRoot: root || null }; - } catch { - return { inside: true, worktreeRoot: null }; - } - } catch { - return { inside: false, worktreeRoot: null }; - } -} - -function detectNestedSubdir(base: string, info: { inside: boolean; worktreeRoot: string | null }): boolean { - if (!info.inside) return false; - try { - const prefix = execSync('git rev-parse --show-prefix', { - cwd: base, - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf-8', - timeout: 5000, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }).trim().replace(/\\/g, '/'); - if (prefix.length > 0) return prefix !== '.' && prefix !== './'; - return false; - } catch {} - - if (!info.worktreeRoot) return false; - const normalize = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase(); - const root = normalize(info.worktreeRoot); - const cwd = normalize(base); - return root !== cwd; -} - - -const NEW_PROJECT_REQUIRED_AGENTS = [ - 'gsd-project-researcher', - 'gsd-research-synthesizer', - 'gsd-roadmapper', -]; - -function hasAgentDefinition(agentsDir: string, agent: string): boolean { - return existsSync(join(agentsDir, `${agent}.md`)) || - existsSync(join(agentsDir, `${agent}.agent.md`)); -} - -async function resolveAgentSkillPayloadAgents( - requiredAgents: string[], - projectDir: string, -): Promise { - const available: string[] = []; - for (const agent of requiredAgents) { - const result = await agentSkills([agent], projectDir); - if (typeof result.data === 'string' && result.data.trim() !== '') { - available.push(agent); - } - } - return available; -} - -/** - * Extract ROADMAP checkbox states: `- [x] Phase N` → true, `- [ ] Phase N` → false. - * Shared by initProgress and initManager so both treat ROADMAP as the - * fallback/override source of truth for completion. - */ -function extractCheckboxStates(content: string): Map { - const states = new Map(); - const pattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi; - let m: RegExpExecArray | null; - while ((m = pattern.exec(content)) !== null) { - states.set(m[2], m[1].toLowerCase() === 'x'); - } - return states; -} - -/** - * Extract terminal phase markers from ROADMAP phase headings, e.g. - * `(COMPLETE)`, `(SHIPPED ...)`, `(DEFERRED)`, `(SUPERSEDED ...)`. - * These labels mean the phase should not be selected as next pending work. - */ -function extractTerminalStatusLabels(content: string): Set { - const terminal = new Set(); - const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; - const terminalRe = /(?:\(|\*\*)\s*(SHIPPED|COMPLETE|DEFERRED|SUPERSEDED|MERGED\s+INTO|FOLDED\s+INTO)\b/i; - let m: RegExpExecArray | null; - while ((m = headingPattern.exec(content)) !== null) { - if (terminalRe.test(m[2])) { - terminal.add(m[1]); - terminal.add(m[1].replace(/^0+/, '') || '0'); - } - } - return terminal; -} - -/** - * Derive progress-level status from a ROADMAP checkbox when the phase has - * no on-disk directory. Returns 'complete' for `[x]`, 'not_started' otherwise. - * Disk status (when present) always wins — it's more recent truth for in-flight work. - */ -function deriveStatusFromCheckbox( - phaseNum: string, - checkboxStates: Map, -): 'complete' | 'not_started' { - const stripped = phaseNum.replace(/^0+/, '') || '0'; - if (checkboxStates.get(phaseNum) === true) return 'complete'; - if (checkboxStates.get(stripped) === true) return 'complete'; - return 'not_started'; -} - -function listPhasePlanAndSummaryCounts(phasePath: string): { plans: string[]; summaries: string[] } { - const phaseFiles = readdirSync(phasePath); - const rootPlans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const rootSummaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - - const plansDir = join(phasePath, 'plans'); - let nestedPlans: string[] = []; - let nestedSummaries: string[] = []; - if (existsSync(plansDir)) { - const files = readdirSync(plansDir); - nestedPlans = files.filter(f => /^PLAN-\d+.*\.md$/i.test(f)); - nestedSummaries = files.filter(f => /^SUMMARY-\d+.*\.md$/i.test(f)); - } - - return { - plans: rootPlans.concat(nestedPlans), - summaries: rootSummaries.concat(nestedSummaries), - }; -} - -// ─── 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, workstream); - - // 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 runtime = detectRuntime(config as { runtime?: unknown }); - const agentsDir = resolveAgentsDir(runtime, projectDir); - const gitInfo = gitWorktreeInfo(projectDir); - const missingRequiredAgents = NEW_PROJECT_REQUIRED_AGENTS.filter( - agent => !hasAgentDefinition(agentsDir, agent), - ); - const agentSkillPayloadAgents = await resolveAgentSkillPayloadAgents( - NEW_PROJECT_REQUIRED_AGENTS, - 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'), - - // Bug #3491: detect parent worktree to avoid nested .git init. - has_git: gitInfo.inside, - git_worktree_root: gitInfo.worktreeRoot, - in_nested_subdir: detectNestedSubdir(projectDir, gitInfo), - - brave_search_available: hasBraveSearch, - firecrawl_available: hasFirecrawl, - exa_search_available: hasExaSearch, - - project_path: '.planning/PROJECT.md', - agent_runtime: runtime, - agents_dir: agentsDir, - required_agents: NEW_PROJECT_REQUIRED_AGENTS, - required_agents_installed: missingRequiredAgents.length === 0, - missing_required_agents: missingRequiredAgents, - agent_skill_payloads_available: agentSkillPayloadAgents.length === NEW_PROJECT_REQUIRED_AGENTS.length, - agent_skill_payload_agents: agentSkillPayloadAgents, - }; - - 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, workstream); - const milestone = await getMilestoneInfo(projectDir, workstream); - const paths = planningPaths(projectDir, workstream); - - 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(); - let checkboxStates = new Map(); - let terminalLabels = new Set(); - - try { - const rawRoadmap = await readFile(paths.roadmap, 'utf-8'); - const roadmapContent = await extractCurrentMilestone(rawRoadmap, projectDir, workstream); - 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); - } - checkboxStates = extractCheckboxStates(roadmapContent); - terminalLabels = extractTerminalStatusLabels(roadmapContent); - } 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, summaries } = listPhasePlanAndSummaryCounts(phasePath); - const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - - let status = - summaries.length >= plans.length && plans.length > 0 ? 'complete' : - plans.length > 0 ? 'in_progress' : - hasResearch ? 'researched' : 'pending'; - - // #2674: align with initManager — a ROADMAP `- [x] Phase N` checkbox - // wins over disk state. A stub phase dir with no SUMMARY is leftover - // scaffolding; the user's explicit [x] is the authoritative signal. - const strippedNum = phaseNumber.replace(/^0+/, '') || '0'; - const roadmapComplete = - checkboxStates.get(phaseNumber) === true || - checkboxStates.get(strippedNum) === true; - if (roadmapComplete && status !== 'complete') { - status = 'complete'; - } - if (terminalLabels.has(phaseNumber) || terminalLabels.has(strippedNum)) { - status = 'complete'; - } - - 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 phases with a ROADMAP - // `[x]` checkbox, treat them as complete (#2646). - for (const [num, name] of roadmapPhaseNames) { - const stripped = num.replace(/^0+/, '') || '0'; - if (!seenPhaseNums.has(stripped)) { - const status = deriveStatusFromCheckbox(num, checkboxStates); - const terminalComplete = terminalLabels.has(num) || terminalLabels.has(stripped); - const phaseInfo: Record = { - number: num, - name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''), - directory: null, - status: terminalComplete ? 'complete' : status, - plan_count: 0, - summary_count: 0, - has_research: false, - }; - phases.push(phaseInfo); - if (!nextPhase && !currentPhase && phaseInfo.status !== 'complete') { - 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, workstream); - const milestone = await getMilestoneInfo(projectDir, workstream); - const paths = planningPaths(projectDir, workstream); - - 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, workstream); - - // 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 (shared helper — #2646) - const checkboxStates = extractCheckboxStates(content); - - 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); - const counts = listPhasePlanAndSummaryCounts(fullDir); - planCount = counts.plans.length; - summaryCount = counts.summaries.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(',') : '—'; - } - } - - // Bug #2268: mark EVERY undiscussed phase as is_next_to_discuss, not just - // the first one. Multiple independent phases can be discussed in parallel - // — the sliding-window pattern made the manager only recommend one - // discuss action even when callers had free capacity to discuss several. - for (const phase of phases) { - const status = phase.disk_status as string; - phase.is_next_to_discuss = (status === 'empty' || status === 'no_directory'); - } - - // 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; - - // ── Next-milestone surface (issue #2497) ─────────────────────────────── - // Populate queued_phases + metadata with the milestone immediately after - // the active one, so the /gsd-manager dashboard can preview what's coming - // next without mixing it into the active phases grid. Empty/null when the - // active milestone is the last one in ROADMAP. - let queuedPhases: Record[] = []; - let queuedMilestoneVersion: string | null = null; - let queuedMilestoneName: string | null = null; - try { - const next = await extractNextMilestoneSection(rawContent, projectDir); - if (next) { - queuedMilestoneVersion = next.version; - queuedMilestoneName = next.name; - queuedPhases = extractPhasesFromSection(next.section).map(p => { - const MAX_NAME_WIDTH = 20; - const display_name = p.name.length > MAX_NAME_WIDTH - ? p.name.slice(0, MAX_NAME_WIDTH - 1) + '…' - : p.name; - const depNums = p.depends_on && !/^none$/i.test(p.depends_on.trim()) - ? (p.depends_on.match(/\d+(?:\.\d+)*/g) || []) - : []; - return { - number: p.number, - name: p.name, - display_name, - goal: p.goal, - depends_on: p.depends_on, - dep_phases: depNums, - deps_display: depNums.length > 0 ? depNums.join(',') : '—', - }; - }); - } - } catch { /* queued_phases is a non-critical enhancement */ } - - // 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, - queued_phases: queuedPhases, - queued_milestone_version: queuedMilestoneVersion, - queued_milestone_name: queuedMilestoneName, - project_exists: pathExists(projectDir, '.planning/PROJECT.md'), - roadmap_exists: true, - state_exists: true, - manager_flags: managerFlags, - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; +export * from '../handlers/init/complex.js'; diff --git a/sdk/src/query/init-workstream-milestone-op.test.ts b/sdk/src/query/init-workstream-milestone-op.test.ts index 6edc3a929..c39f2bf33 100644 --- a/sdk/src/query/init-workstream-milestone-op.test.ts +++ b/sdk/src/query/init-workstream-milestone-op.test.ts @@ -11,7 +11,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { initMilestoneOp } from './init.js'; +import { initMilestoneOp } from '../handlers/init/composer.js'; import { roadmapAnalyze } from './roadmap.js'; import { resolveQueryRuntimeContext } from './query-runtime-context.js'; diff --git a/sdk/src/query/init.test.ts b/sdk/src/query/init.test.ts index 2fd1c66c0..9bc9d492d 100644 --- a/sdk/src/query/init.test.ts +++ b/sdk/src/query/init.test.ts @@ -25,7 +25,7 @@ import { initListWorkspaces, initRemoveWorkspace, initIngestDocs, -} from './init.js'; +} from '../handlers/init/composer.js'; let tmpDir: string; diff --git a/sdk/src/query/init.ts b/sdk/src/query/init.ts index a310c63cd..b97fe1c0c 100644 --- a/sdk/src/query/init.ts +++ b/sdk/src/query/init.ts @@ -1,1335 +1 @@ -/** - * Init composition handlers — compound init commands for workflow bootstrapping. - * - * Composes existing atomic SDK queries into the same flat JSON bundles - * that CJS init.cjs produces, enabling workflow migration. Each handler - * follows the QueryHandler signature and returns { data: }. - * - * Port of get-shit-done/bin/lib/init.cjs (13 of 16 handlers). - * The 3 complex handlers (new-project, progress, manager) are in init-complex.ts. - * - * @example - * ```typescript - * import { initExecutePhase, withProjectRoot } from './init.js'; - * - * const result = await initExecutePhase(['9'], '/project'); - * // { data: { executor_model: 'opus', phase_found: true, ... } } - * ``` - */ - -import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from 'node:fs'; -import { readFile, readdir } from 'node:fs/promises'; -import { join, relative, basename } from 'node:path'; -import { execSync } from 'node:child_process'; -import { homedir } from 'node:os'; -import { GSDError, ErrorClassification } from '../errors.js'; - -import { loadConfig, type GSDConfig } from '../config.js'; -import { resolveModel, MODEL_PROFILES } from './config-query.js'; -import { maskIfSecret } from './secrets.js'; -import { findPhase } from './phase.js'; -import { getMilestonePhaseFilter } from './state.js'; -import { roadmapGetPhase, getMilestoneInfo, extractCurrentMilestone, extractPhasesFromSection } from './roadmap.js'; -import { determinePhaseStatus } from './progress.js'; -import { planningPaths, normalizePhaseName, toPosixPath, resolveAgentsDir, detectRuntime } from './helpers.js'; -import { generatePhaseSlug, assertSafeProjectCode } from './phase-lifecycle-policy.js'; -import type { QueryHandler } from './utils.js'; - -// ─── Internal helpers ────────────────────────────────────────────────────── - -/** - * Extract model alias string from a 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'; -} - -/** - * Generate a slug from text (inline, matches CJS generateSlugInternal). - */ -function generateSlugInternal(text: string): string { - return text - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .substring(0, 60); -} - -function extractPhaseArg(args: string[]): string | undefined { - const equalsArg = args.find((arg) => arg.startsWith('--phase=')); - if (equalsArg) { - const value = equalsArg.slice('--phase='.length).trim(); - return value || undefined; - } - - const flagIndex = args.indexOf('--phase'); - if (flagIndex !== -1) { - const value = args[flagIndex + 1]; - return value && !value.startsWith('--') ? value : undefined; - } - - const first = args[0]; - return first && !first.startsWith('--') ? first : undefined; -} - -/** - * Check if a path exists on disk. - */ -function pathExists(base: string, relPath: string): boolean { - return existsSync(join(base, relPath)); -} - -/** - * Bug #3491: detect whether `base` is inside any git worktree, and if so, - * return the absolute worktree root. Mirrors the CJS `gitWorktreeInfoInternal` - * in get-shit-done/bin/lib/core.cjs — keep these two implementations behaviour- - * identical so the SDK and CJS init handlers emit the same has_git semantics. - * - * Returns { inside, worktreeRoot } — both fall back to false/null on any error - * (git unavailable, not a repo, timeout) so callers see the conservative - * default that preserves pre-fix behaviour for non-git environments. - */ -function gitWorktreeInfo(base: string): { inside: boolean; worktreeRoot: string | null } { - try { - const inside = execSync('git rev-parse --is-inside-work-tree', { - cwd: base, - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf-8', - timeout: 5000, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }).trim(); - if (inside !== 'true') return { inside: false, worktreeRoot: null }; - try { - const root = execSync('git rev-parse --show-toplevel', { - cwd: base, - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf-8', - timeout: 5000, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }).trim(); - return { inside: true, worktreeRoot: root || null }; - } catch { - return { inside: true, worktreeRoot: null }; - } - } catch { - return { inside: false, worktreeRoot: null }; - } -} - -function detectNestedSubdir(base: string, info: { inside: boolean; worktreeRoot: string | null }): boolean { - if (!info.inside) return false; - try { - const prefix = execSync('git rev-parse --show-prefix', { - cwd: base, - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf-8', - timeout: 5000, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }).trim().replace(/\\/g, '/'); - if (prefix.length > 0) return prefix !== '.' && prefix !== './'; - return false; - } catch {} - - if (!info.worktreeRoot) return false; - const normalize = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase(); - const root = normalize(info.worktreeRoot); - const cwd = normalize(base); - return root !== cwd; -} - - -/** - * Compute the canonical phase directory name for a known phase entry from the - * roadmap when no directory exists yet. Applies the project_code prefix so - * the first-touch creation path used by /gsd-discuss-phase and /gsd-plan-phase - * stays consistent with the prefix produced by `phase.add` / `phase.insert`. - * - * Returns null when phaseNumber or phaseName cannot be determined. - */ -function computeExpectedPhaseDirName( - phaseNumber: string | null, - phaseName: string | null, - projectCode: string, -): string | null { - if (!phaseNumber || !phaseName) return null; - const paddedNum = normalizePhaseName(phaseNumber); - const slug = generatePhaseSlug(phaseName); - if (!slug) return null; - const prefix = projectCode ? `${projectCode}-` : ''; - return `${prefix}${paddedNum}-${slug}`; -} - -async function shouldDropArchivedPhaseMatch( - phaseInfo: Record | null, - roadmapPhase: Record | null, - projectDir: string, - workstream?: string, -): Promise { - // Matches CJS cmdInitPlanPhase / cmdInitExecutePhase / cmdInitVerifyWork: - // if (phaseInfo?.archived && roadmapPhase?.found) phaseInfo = null; - // ROADMAP is authoritative for the current milestone, regardless of what - // archived milestone the on-disk match came from — BUT see #3469 exception. - if (!phaseInfo?.archived) return false; - if (!roadmapPhase || !roadmapPhase.found) return false; - - // #3469: If the archived phase belongs to the CURRENT milestone (e.g. phases - // were cleared via `phases clear` into .planning/milestones/-phases/ - // but the workflow is still on that same milestone), preserve the archived dir - // as the canonical phase location. Only drop when the archived version is from - // a PRIOR milestone (the original anti-ghost-phase guard). - // Note: the milestone-version equality check here does NOT regress #2391 - // because in the #2391 scenario archived.version != current milestone. - const pp = planningPaths(projectDir, workstream); - try { - const stateContent = readFileSync(pp.state, 'utf-8'); - const milestoneMatch = stateContent.match(/^milestone:\s*(.+)$/m); - const currentMilestone = milestoneMatch ? milestoneMatch[1].trim() : null; - if (currentMilestone && phaseInfo.archived === currentMilestone) { - // Same milestone — archived dir is the canonical location. Keep it. - return false; - } - } catch { /* STATE.md unreadable — fall through to default drop */ } - - return true; -} - -/** - * Get the latest completed milestone from MILESTONES.md. - * Port of getLatestCompletedMilestone from init.cjs lines 10-25. - */ -function getLatestCompletedMilestone(projectDir: string): { version: string; name: string } | null { - const milestonesPath = join(projectDir, '.planning', 'MILESTONES.md'); - if (!existsSync(milestonesPath)) return null; - - try { - const content = readFileSync(milestonesPath, 'utf-8'); - const match = content.match(/^##\s+(v[\d.]+)\s+(.+?)\s+\(Shipped:/m); - if (!match) return null; - return { version: match[1], name: match[2].trim() }; - } catch { - return null; - } -} - -/** - * Check which GSD agents are installed on disk. - * - * Runtime-aware per issue #2402: detects the invoking runtime - * (`GSD_RUNTIME` → `config.runtime` → 'claude') and probes that runtime's - * canonical `agents/` directory. `GSD_AGENTS_DIR` still short-circuits. - * - * The optional `projectDir` parameter enables the repo-local `.claude/agents` - * fallback for Claude `--local` installs (bug #3751). - * - * Port of checkAgentsInstalled from core.cjs lines 1274-1306. - */ -function checkAgentsInstalled(config?: { runtime?: unknown }, projectDir?: string): { agents_installed: boolean; missing_agents: string[] } { - const runtime = detectRuntime(config); - const agentsDir = resolveAgentsDir(runtime, projectDir); - const expectedAgents = Object.keys(MODEL_PROFILES); - - if (!existsSync(agentsDir)) { - return { agents_installed: false, missing_agents: expectedAgents }; - } - - const missing: string[] = []; - for (const agent of expectedAgents) { - const agentFile = join(agentsDir, `${agent}.md`); - const agentFileCopilot = join(agentsDir, `${agent}.agent.md`); - if (!existsSync(agentFile) && !existsSync(agentFileCopilot)) { - missing.push(agent); - } - } - - return { - agents_installed: missing.length === 0, - missing_agents: missing, - }; -} - -/** - * Extract phase info from findPhase result, or build fallback from roadmap. - */ -async function getPhaseInfoWithFallback( - phase: string, - projectDir: string, - workstream?: string, -): Promise<{ phaseInfo: Record | null; roadmapPhase: Record | null }> { - const phaseResult = await findPhase([phase], projectDir, workstream); - let phaseInfo = phaseResult.data as Record | null; - // findPhase returns { found: false } when missing; findPhaseInternal returns null — align for init parity. - if (phaseInfo && phaseInfo.found === false) { - phaseInfo = null; - } - - const roadmapResult = await roadmapGetPhase([phase], projectDir, workstream); - const roadmapPhase = roadmapResult.data as Record | null; - - // Match init.cjs: drop archived disk match when the phase is listed in the current ROADMAP - if (await shouldDropArchivedPhaseMatch(phaseInfo, roadmapPhase, projectDir, workstream)) { - phaseInfo = null; - } - - // Fallback to ROADMAP.md if no phase directory exists yet - if ((!phaseInfo || !phaseInfo.found) && roadmapPhase?.found) { - const phaseName = roadmapPhase.phase_name as string; - phaseInfo = { - found: true, - directory: null, - phase_number: roadmapPhase.phase_number, - phase_name: phaseName, - phase_slug: phaseName ? generateSlugInternal(phaseName) : null, - plans: [], - summaries: [], - incomplete_plans: [], - has_research: false, - has_context: false, - has_verification: false, - has_reviews: false, - }; - } - - return { phaseInfo, roadmapPhase }; -} - -/** - * Phase resolution for `init verify-work` — matches init.cjs cmdInitVerifyWork (archived + fallback). - */ -async function getPhaseInfoForVerifyWork( - phase: string, - projectDir: string, - workstream?: string, -): Promise<{ phaseInfo: Record | null }> { - const phaseResult = await findPhase([phase], projectDir, workstream); - let phaseInfo = phaseResult.data as Record | null; - if (phaseInfo && phaseInfo.found === false) { - phaseInfo = null; - } - - const roadmapResult = await roadmapGetPhase([phase], projectDir, workstream); - const roadmapPhase = roadmapResult.data as Record | null; - - if (await shouldDropArchivedPhaseMatch(phaseInfo, roadmapPhase, projectDir, workstream)) { - phaseInfo = null; - } - - if (!phaseInfo && roadmapPhase?.found) { - const phaseName = roadmapPhase.phase_name as string; - phaseInfo = { - found: true, - directory: null, - phase_number: roadmapPhase.phase_number, - phase_name: phaseName, - phase_slug: phaseName ? generateSlugInternal(phaseName) : null, - plans: [], - summaries: [], - incomplete_plans: [], - has_research: false, - has_context: false, - has_verification: false, - }; - } - - return { phaseInfo }; -} - -/** - * Extract requirement IDs from roadmap section text. - */ -function extractReqIds(roadmapPhase: Record | null): string | null { - const section = roadmapPhase?.section as string | undefined; - // Accept all bold/colon variants of the Requirements header. The forms - // **Requirements:** (colon inside bold) - // **Requirements**: (colon outside bold) - // **Requirements** : (space before outside colon) - // render identically in markdown but differ textually. Issue #2769. - const reqMatch = section?.match(/^\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]*)$/m); - const reqExtracted = reqMatch - ? reqMatch[1].replace(/[\[\]]/g, '').split(',').map((s: string) => s.trim()).filter(Boolean).join(', ') - : null; - return (reqExtracted && reqExtracted !== 'TBD') ? reqExtracted : null; -} - -// ─── withProjectRoot ───────────────────────────────────────────────────── - -/** - * Inject project_root, agents_installed, missing_agents, and response_language - * into an init result object. - * - * Port of withProjectRoot from init.cjs lines 32-63. - * - * @param projectDir - Absolute project root path - * @param result - The result object to augment - * @param config - Optional loaded config (avoids re-reading config.json) - * @returns The augmented result object - */ -export function withProjectRoot( - projectDir: string, - result: Record, - config?: Record, -): Record { - result.project_root = projectDir; - - const agentStatus = checkAgentsInstalled(config, projectDir); - result.agents_installed = agentStatus.agents_installed; - result.missing_agents = agentStatus.missing_agents; - - const responseLang = config?.response_language; - if (responseLang) { - result.response_language = responseLang; - } - - const projectCode = config?.project_code; - if (projectCode) { - result.project_code = projectCode; - } - - const projectMdPath = join(projectDir, '.planning', 'PROJECT.md'); - try { - if (existsSync(projectMdPath)) { - const content = readFileSync(projectMdPath, 'utf-8'); - const h1Match = content.match(/^#\s+(.+)$/m); - if (h1Match) { - result.project_title = h1Match[1].trim(); - } - } - } catch { - /* intentionally empty */ - } - - return result; -} - -// ─── initExecutePhase ───────────────────────────────────────────────────── - -/** - * Init handler for execute-phase workflow. - * Port of cmdInitExecutePhase from init.cjs lines 50-171. - */ -export const initExecutePhase: QueryHandler = async (args, projectDir, workstream) => { - const phase = extractPhaseArg(args); - if (!phase) { - return { data: { error: 'phase required for init execute-phase' } }; - } - - // --tdd is a boolean override of config.workflow.tdd_mode — matches the CJS - // path's parseNamedArgs(args, [], ['validate', 'tdd']) projection - // (bin/lib/init-command-router.cjs handler block) which passes options.tdd - // through to cmdInitExecutePhase. Without parsing here, `gsd-tools init - // execute-phase 1 --tdd` would never override a false config value. - const tddFlag = args.includes('--tdd'); - - const config = await loadConfig(projectDir); - const paths = planningPaths(projectDir, workstream); - const planningDir = paths.planning; - - const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir, workstream); - const phase_req_ids = extractReqIds(roadmapPhase); - - const configExists = existsSync(join(planningDir, 'config.json')); - const [executorModel, verifierModel] = configExists - ? await Promise.all([ - getModelAlias('gsd-executor', projectDir), - getModelAlias('gsd-verifier', projectDir), - ]) - : ['', '']; - - const milestone = await getMilestoneInfo(projectDir, workstream); - - const phaseNumber = (phaseInfo?.phase_number as string) || null; - const phaseSlug = (phaseInfo?.phase_slug as string) || null; - const plans = (phaseInfo?.plans || []) as string[]; - const summaries = (phaseInfo?.summaries || []) as string[]; - const incompletePlans = (phaseInfo?.incomplete_plans || []) as string[]; - const projectCode = (config as Record).project_code as string || ''; - - const result: Record = { - executor_model: executorModel, - verifier_model: verifierModel, - tdd_mode: tddFlag || (config.workflow.tdd_mode ?? false), - commit_docs: config.commit_docs, - sub_repos: (config as Record).sub_repos ?? [], - parallelization: config.parallelization, - context_window: (config as Record).context_window ?? 200000, - branching_strategy: config.git.branching_strategy, - phase_branch_template: config.git.phase_branch_template, - milestone_branch_template: config.git.milestone_branch_template, - verifier_enabled: config.workflow.verifier, - phase_found: !!phaseInfo, - phase_dir: (phaseInfo?.directory as string) ?? null, - phase_number: phaseNumber, - phase_name: (phaseInfo?.phase_name as string) ?? null, - phase_slug: phaseSlug, - phase_req_ids, - plans, - summaries, - incomplete_plans: incompletePlans, - plan_count: plans.length, - incomplete_count: incompletePlans.length, - branch_name: config.git.branching_strategy === 'phase' && phaseInfo - ? config.git.phase_branch_template - .replace('{project}', projectCode) - .replace('{phase}', phaseNumber || '') - .replace('{slug}', phaseSlug || 'phase') - : config.git.branching_strategy === 'milestone' - ? config.git.milestone_branch_template - .replace('{milestone}', milestone.version) - .replace('{slug}', generateSlugInternal(milestone.name) || 'milestone') - : null, - milestone_version: milestone.version, - milestone_name: milestone.name, - milestone_slug: generateSlugInternal(milestone.name), - state_exists: existsSync(join(planningDir, 'STATE.md')), - roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), - config_exists: configExists, - state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), - roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), - config_path: toPosixPath(relative(projectDir, join(planningDir, 'config.json'))), - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initPlanPhase ──────────────────────────────────────────────────────── - -/** - * Init handler for plan-phase workflow. - * Port of cmdInitPlanPhase from init.cjs lines 173-293. - */ -export const initPlanPhase: QueryHandler = async (args, projectDir, workstream) => { - const phase = extractPhaseArg(args); - if (!phase) { - return { data: { error: 'phase required for init plan-phase' } }; - } - - // --tdd boolean override (parity with CJS router's parseNamedArgs + the - // legacy cmdInitPlanPhase `options.tdd || config.tdd_mode || false`). - const tddFlag = args.includes('--tdd'); - - const config = await loadConfig(projectDir); - const paths = planningPaths(projectDir, workstream); - const planningDir = paths.planning; - - const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir, workstream); - const phase_req_ids = extractReqIds(roadmapPhase); - - const configExists = existsSync(join(planningDir, 'config.json')); - const [researcherModel, plannerModel, checkerModel] = configExists - ? await Promise.all([ - getModelAlias('gsd-phase-researcher', projectDir), - getModelAlias('gsd-planner', projectDir), - getModelAlias('gsd-plan-checker', projectDir), - ]) - : ['', '', '']; - - const phaseNumber = (phaseInfo?.phase_number as string) || null; - const phaseName = (phaseInfo?.phase_name as string) ?? null; - const phaseDir = (phaseInfo?.directory as string) ?? null; - const plans = (phaseInfo?.plans || []) as string[]; - const summaries = (phaseInfo?.summaries || []) as string[]; - - // #3569: surface phase lifecycle status so /gsd-plan-phase can short-circuit - // on closed (Complete) phases instead of silently replanning over shipped - // code. Reuses determinePhaseStatus — the project-wide vocabulary used by - // `progress` (Pending | Planned | In Progress | Executed | Complete | - // Needs Review). When the phase has no directory on disk yet, treat it as - // Pending (it has not been started). - const phaseStatus = phaseDir - ? await determinePhaseStatus(plans.length, summaries.length, join(projectDir, phaseDir)) - : 'Pending'; - - // #3287: compute the canonical directory name with project_code prefix so - // the first-touch mkdir in /gsd-plan-phase stays consistent with phase.add. - const rawProjectCode = (config as Record).project_code as string || ''; - assertSafeProjectCode(rawProjectCode); - const expectedPhaseDirName = phaseDir - ? null // directory already exists — no need to create - : computeExpectedPhaseDirName(phaseNumber, phaseName, rawProjectCode); - const expectedPhaseDir = expectedPhaseDirName - ? toPosixPath(relative(projectDir, join(paths.phases, expectedPhaseDirName))) - : null; - - const cfg = config as GSDConfig; - const result: Record = { - researcher_model: researcherModel, - planner_model: plannerModel, - checker_model: checkerModel, - tdd_mode: tddFlag || (config.workflow.tdd_mode ?? false), - research_enabled: config.workflow.research, - plan_checker_enabled: config.workflow.plan_check, - nyquist_validation_enabled: config.workflow.nyquist_validation, - commit_docs: config.commit_docs, - text_mode: config.workflow.text_mode, - auto_advance: !!config.workflow.auto_advance, - auto_chain_active: !!config.workflow._auto_chain_active, - mode: cfg.mode ?? 'interactive', - phase_found: !!phaseInfo, - phase_dir: phaseDir, - expected_phase_dir: expectedPhaseDir, - phase_number: phaseNumber, - phase_name: phaseName, - phase_slug: (phaseInfo?.phase_slug as string) ?? null, - padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null, - phase_req_ids, - phase_status: phaseStatus, - has_research: (phaseInfo?.has_research as boolean) || false, - has_context: (phaseInfo?.has_context as boolean) || false, - has_reviews: (phaseInfo?.has_reviews as boolean) || false, - has_plans: plans.length > 0, - plan_count: plans.length, - planning_exists: existsSync(planningDir), - roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), - state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), - roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), - requirements_path: toPosixPath(relative(projectDir, join(planningDir, 'REQUIREMENTS.md'))), - patterns_path: null, - }; - - // Add artifact paths if phase directory exists - if (phaseDir) { - const phaseDirFull = join(projectDir, phaseDir); - try { - const files = readdirSync(phaseDirFull); - const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); - if (contextFile) result.context_path = toPosixPath(join(phaseDir, contextFile)); - const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - if (researchFile) result.research_path = toPosixPath(join(phaseDir, researchFile)); - const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); - if (verificationFile) result.verification_path = toPosixPath(join(phaseDir, verificationFile)); - const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); - if (uatFile) result.uat_path = toPosixPath(join(phaseDir, uatFile)); - const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'); - if (reviewsFile) result.reviews_path = toPosixPath(join(phaseDir, reviewsFile)); - const patternsFile = files.find(f => f.endsWith('-PATTERNS.md') || f === 'PATTERNS.md'); - if (patternsFile) result.patterns_path = toPosixPath(join(phaseDir, patternsFile)); - } catch { /* intentionally empty */ } - } - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initNewMilestone ───────────────────────────────────────────────────── - -/** - * Init handler for new-milestone workflow. - * Port of cmdInitNewMilestone from init.cjs lines 401-446. - */ -export const initNewMilestone: QueryHandler = async (_args, projectDir) => { - const config = await loadConfig(projectDir); - const planningDir = join(projectDir, '.planning'); - const milestone = await getMilestoneInfo(projectDir); - const latestCompleted = getLatestCompletedMilestone(projectDir); - - const phasesDir = join(planningDir, 'phases'); - let phaseDirCount = 0; - try { - if (existsSync(phasesDir)) { - // Bug #2445 parity with CJS `cmdInitNewMilestone`: filter phase dirs - // to the current milestone so stale dirs from a prior milestone that - // weren't archived don't inflate the count. Without this filter the - // SDK returns the full directory count, which the new-milestone - // workflow then uses to gate "is this a fresh start" decisions. - const isDirInMilestone = await getMilestonePhaseFilter(projectDir); - phaseDirCount = readdirSync(phasesDir, { withFileTypes: true }) - .filter(entry => entry.isDirectory() && isDirInMilestone(entry.name)) - .length; - } - } catch { /* intentionally empty */ } - - 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, - research_enabled: config.workflow.research, - current_milestone: milestone.version, - current_milestone_name: milestone.name, - latest_completed_milestone: latestCompleted?.version || null, - latest_completed_milestone_name: latestCompleted?.name || null, - phase_dir_count: phaseDirCount, - phase_archive_path: latestCompleted - ? toPosixPath(relative(projectDir, join(projectDir, '.planning', 'milestones', `${latestCompleted.version}-phases`))) - : null, - project_exists: pathExists(projectDir, '.planning/PROJECT.md'), - roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), - state_exists: existsSync(join(planningDir, 'STATE.md')), - project_path: '.planning/PROJECT.md', - roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), - state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initQuick ──────────────────────────────────────────────────────────── - -/** - * Init handler for quick workflow. - * Port of cmdInitQuick from init.cjs lines 448-504. - */ -export const initQuick: QueryHandler = async (args, projectDir) => { - const description = args[0] || null; - const config = await loadConfig(projectDir); - const planningDir = join(projectDir, '.planning'); - const now = new Date(); - const slug = description ? generateSlugInternal(description).substring(0, 40) : null; - - // Generate collision-resistant quick task ID: YYMMDD-xxx - const yy = String(now.getFullYear()).slice(-2); - const mm = String(now.getMonth() + 1).padStart(2, '0'); - const dd = String(now.getDate()).padStart(2, '0'); - const dateStr = yy + mm + dd; - const secondsSinceMidnight = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds(); - const timeBlocks = Math.floor(secondsSinceMidnight / 2); - const timeEncoded = timeBlocks.toString(36).padStart(3, '0'); - const quickId = dateStr + '-' + timeEncoded; - const branchSlug = slug || 'quick'; - const quickBranchName = config.git.quick_branch_template - ? config.git.quick_branch_template - .replace('{num}', quickId) - .replace('{quick}', quickId) - .replace('{slug}', branchSlug) - : null; - - const configExists = existsSync(join(planningDir, 'config.json')); - const [plannerModel, executorModel, checkerModel, verifierModel] = configExists - ? await Promise.all([ - getModelAlias('gsd-planner', projectDir), - getModelAlias('gsd-executor', projectDir), - getModelAlias('gsd-plan-checker', projectDir), - getModelAlias('gsd-verifier', projectDir), - ]) - : ['', '', '', '']; - - const result: Record = { - planner_model: plannerModel, - executor_model: executorModel, - checker_model: checkerModel, - verifier_model: verifierModel, - commit_docs: config.commit_docs, - branch_name: quickBranchName, - quick_id: quickId, - slug, - description, - date: now.toISOString().split('T')[0], - timestamp: now.toISOString(), - quick_dir: '.planning/quick', - task_dir: slug ? `.planning/quick/${quickId}-${slug}` : null, - roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), - planning_exists: existsSync(join(projectDir, '.planning')), - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initResume ─────────────────────────────────────────────────────────── - -/** - * Init handler for resume-project workflow. - * Port of cmdInitResume from init.cjs lines 506-536. - */ -export const initResume: QueryHandler = async (_args, projectDir) => { - const config = await loadConfig(projectDir); - const planningDir = join(projectDir, '.planning'); - - let interruptedAgentId: string | null = null; - try { - interruptedAgentId = readFileSync(join(projectDir, '.planning', 'current-agent-id.txt'), 'utf-8').trim(); - } catch { /* intentionally empty */ } - - const result: Record = { - state_exists: existsSync(join(planningDir, 'STATE.md')), - roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), - project_exists: pathExists(projectDir, '.planning/PROJECT.md'), - planning_exists: existsSync(join(projectDir, '.planning')), - state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), - roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), - project_path: '.planning/PROJECT.md', - has_interrupted_agent: !!interruptedAgentId, - interrupted_agent_id: interruptedAgentId, - commit_docs: config.commit_docs, - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initVerifyWork ─────────────────────────────────────────────────────── - -/** - * Init handler for verify-work workflow. - * Port of cmdInitVerifyWork from init.cjs lines 538-586. - */ -export const initVerifyWork: QueryHandler = async (args, projectDir, workstream) => { - const phase = extractPhaseArg(args); - if (!phase) { - return { data: { error: 'phase required for init verify-work' } }; - } - - const config = await loadConfig(projectDir, workstream); - const { phaseInfo } = await getPhaseInfoForVerifyWork(phase, projectDir, workstream); - - const configExists = existsSync(join(projectDir, '.planning', 'config.json')); - const [plannerModel, checkerModel] = configExists - ? await Promise.all([ - getModelAlias('gsd-planner', projectDir), - getModelAlias('gsd-plan-checker', projectDir), - ]) - : ['', '']; - - const result: Record = { - planner_model: plannerModel, - checker_model: checkerModel, - commit_docs: config.commit_docs, - phase_found: !!phaseInfo, - phase_dir: (phaseInfo?.directory as string) ?? null, - phase_number: (phaseInfo?.phase_number as string) ?? null, - phase_name: (phaseInfo?.phase_name as string) ?? null, - has_verification: (phaseInfo?.has_verification as boolean) || false, - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initPhaseOp ────────────────────────────────────────────────────────── - -/** - * Init handler for discuss-phase and similar phase operations. - * Port of cmdInitPhaseOp from init.cjs lines 588-697. - */ -export const initPhaseOp: QueryHandler = async (args, projectDir, workstream) => { - const phase = extractPhaseArg(args); - if (!phase) { - return { data: { error: 'phase required for init phase-op' } }; - } - - const config = await loadConfig(projectDir); - const paths = planningPaths(projectDir, workstream); - const planningDir = paths.planning; - - // findPhase with archived override: if only match is archived, prefer ROADMAP - const phaseResult = await findPhase([phase], projectDir, workstream); - let phaseInfo = phaseResult.data as Record | null; - - const roadmapResult = await roadmapGetPhase([phase], projectDir, workstream); - const roadmapPhase = roadmapResult.data as Record | null; - - // If the only match comes from an archived milestone, prefer current ROADMAP - if (roadmapPhase?.found && await shouldDropArchivedPhaseMatch(phaseInfo, roadmapPhase, projectDir, workstream)) { - const phaseName = roadmapPhase.phase_name as string; - phaseInfo = { - found: true, - directory: null, - phase_number: roadmapPhase.phase_number, - phase_name: phaseName, - phase_slug: phaseName ? generateSlugInternal(phaseName) : null, - plans: [], - summaries: [], - incomplete_plans: [], - has_research: false, - has_context: false, - has_verification: false, - }; - } - - // Fallback to ROADMAP.md if no directory exists - if (!phaseInfo || !phaseInfo.found) { - if (roadmapPhase?.found) { - const phaseName = roadmapPhase.phase_name as string; - phaseInfo = { - found: true, - directory: null, - phase_number: roadmapPhase.phase_number, - phase_name: phaseName, - phase_slug: phaseName ? generateSlugInternal(phaseName) : null, - plans: [], - summaries: [], - incomplete_plans: [], - has_research: false, - has_context: false, - has_verification: false, - }; - } - } - - const phaseFound = !!(phaseInfo && phaseInfo.found); - const phaseNumber = (phaseInfo?.phase_number as string) || null; - const phaseName = (phaseInfo?.phase_name as string) ?? null; - const phaseDir = (phaseInfo?.directory as string) ?? null; - const plans = (phaseInfo?.plans || []) as string[]; - - // #3287: compute the canonical directory name with project_code prefix so - // the first-touch mkdir in /gsd-discuss-phase stays consistent with phase.add. - const rawProjectCode = (config as Record).project_code as string || ''; - assertSafeProjectCode(rawProjectCode); - const expectedPhaseDirName = phaseDir - ? null // directory already exists — no need to create - : computeExpectedPhaseDirName(phaseNumber, phaseName, rawProjectCode); - const expectedPhaseDir = expectedPhaseDirName - ? toPosixPath(relative(projectDir, join(paths.phases, expectedPhaseDirName))) - : null; - - const result: Record = { - commit_docs: config.commit_docs, - // #2997: secret config keys (brave_search, firecrawl, exa_search) may be - // either boolean availability flags OR string API keys depending on how the - // user configured them. Pass booleans through; mask string values so the - // init bundle never echoes plaintext credentials. Mirrors the masking added - // to config-get/config-set in the same fix. - brave_search: typeof config.brave_search === 'string' ? maskIfSecret('brave_search', config.brave_search) : config.brave_search, - firecrawl: typeof config.firecrawl === 'string' ? maskIfSecret('firecrawl', config.firecrawl) : config.firecrawl, - exa_search: typeof config.exa_search === 'string' ? maskIfSecret('exa_search', config.exa_search) : config.exa_search, - phase_found: phaseFound, - phase_dir: phaseDir, - expected_phase_dir: expectedPhaseDir, - phase_number: phaseNumber, - phase_name: phaseName, - phase_slug: (phaseInfo?.phase_slug as string) ?? null, - padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null, - has_research: (phaseInfo?.has_research as boolean) || false, - has_context: (phaseInfo?.has_context as boolean) || false, - has_plans: plans.length > 0, - has_verification: (phaseInfo?.has_verification as boolean) || false, - has_reviews: (phaseInfo?.has_reviews as boolean) || false, - plan_count: plans.length, - roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), - planning_exists: existsSync(planningDir), - state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), - roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), - requirements_path: toPosixPath(relative(projectDir, join(planningDir, 'REQUIREMENTS.md'))), - }; - - // Add artifact paths if phase directory exists - if (phaseDir) { - const phaseDirFull = join(projectDir, phaseDir); - try { - const files = readdirSync(phaseDirFull); - const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); - if (contextFile) result.context_path = toPosixPath(join(phaseDir, contextFile)); - const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - if (researchFile) result.research_path = toPosixPath(join(phaseDir, researchFile)); - const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); - if (verificationFile) result.verification_path = toPosixPath(join(phaseDir, verificationFile)); - const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); - if (uatFile) result.uat_path = toPosixPath(join(phaseDir, uatFile)); - const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'); - if (reviewsFile) result.reviews_path = toPosixPath(join(phaseDir, reviewsFile)); - } catch { /* intentionally empty */ } - } - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initTodos ──────────────────────────────────────────────────────────── - -/** - * Init handler for check-todos and add-todo workflows. - * Port of cmdInitTodos from init.cjs lines 699-756. - */ -export const initTodos: QueryHandler = async (args, projectDir) => { - const area = args[0] || null; - const config = await loadConfig(projectDir); - const planningDir = join(projectDir, '.planning'); - const now = new Date(); - - const pendingDir = join(planningDir, 'todos', 'pending'); - let count = 0; - const todos: Array> = []; - - try { - const files = readdirSync(pendingDir).filter(f => f.endsWith('.md')); - for (const file of files) { - try { - const content = readFileSync(join(pendingDir, file), 'utf-8'); - const createdMatch = content.match(/^created:\s*(.+)$/m); - const titleMatch = content.match(/^title:\s*(.+)$/m); - const areaMatch = content.match(/^area:\s*(.+)$/m); - const todoArea = areaMatch ? areaMatch[1].trim() : 'general'; - - if (area && todoArea !== area) continue; - - count++; - todos.push({ - file, - created: createdMatch ? createdMatch[1].trim() : 'unknown', - title: titleMatch ? titleMatch[1].trim() : 'Untitled', - area: todoArea, - path: toPosixPath(relative(projectDir, join(pendingDir, file))), - }); - } catch { /* intentionally empty */ } - } - } catch { /* intentionally empty */ } - - const result: Record = { - commit_docs: config.commit_docs, - date: now.toISOString().split('T')[0], - timestamp: now.toISOString(), - todo_count: count, - todos, - area_filter: area, - pending_dir: toPosixPath(relative(projectDir, join(planningDir, 'todos', 'pending'))), - completed_dir: toPosixPath(relative(projectDir, join(planningDir, 'todos', 'completed'))), - planning_exists: existsSync(planningDir), - todos_dir_exists: existsSync(join(planningDir, 'todos')), - pending_dir_exists: existsSync(pendingDir), - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initMilestoneOp ───────────────────────────────────────────────────── - -/** - * Init handler for complete-milestone and audit-milestone workflows. - * Port of cmdInitMilestoneOp from init.cjs lines 758-817. - */ -export const initMilestoneOp: QueryHandler = async (_args, projectDir, workstream) => { - const config = await loadConfig(projectDir); - const paths = planningPaths(projectDir, workstream); - const planningDir = paths.planning; - const milestone = await getMilestoneInfo(projectDir, workstream); - - const phasesDir = join(planningDir, 'phases'); - let phaseCount = 0; - let completedPhases = 0; - - // Bug #2633 — ROADMAP.md (current milestone section) is the authority for - // phase counts, NOT the on-disk `.planning/phases/` directory. After - // `phases clear` between milestones, on-disk dirs will be a subset of the - // roadmap until each phase is materialized, and reading from disk causes - // `all_phases_complete: true` to fire as soon as the materialized subset - // gets summaries — even though the roadmap has phases still to do. - let roadmapPhaseNumbers: string[] = []; - try { - const { readFile } = await import('node:fs/promises'); - const roadmapRaw = await readFile(join(planningDir, 'ROADMAP.md'), 'utf-8'); - const currentSection = await extractCurrentMilestone(roadmapRaw, projectDir, workstream); - roadmapPhaseNumbers = extractPhasesFromSection(currentSection).map(p => p.number); - } catch { /* intentionally empty */ } - - // Build the on-disk index keyed by the canonical full phase token (e.g. - // "3", "3A", "3.1") so distinct tokens with the same integer prefix never - // collide. Roadmap writes "Phase 3", "Phase 3A", and "Phase 3.1" as - // distinct phases and disk dirs preserve those tokens. - // Canonicalize a phase token by stripping leading zeros from the integer - // head while preserving any [A-Z]? suffix and dotted segments. So "03" → - // "3", "03A" → "3A", "03.1" → "3.1", "3A" → "3A". This lets disk dirs that - // pad ("03-alpha") match roadmap tokens ("Phase 3") without ever collapsing - // distinct tokens like "3" / "3A" / "3.1" into the same bucket. - const canonicalizePhase = (tok: string): string => { - const m = tok.match(/^(\d+)([A-Z]?(?:\.\d+)*)$/); - return m ? String(parseInt(m[1], 10)) + m[2] : tok; - }; - const diskPhaseDirs: Map = new Map(); - try { - const entries = readdirSync(phasesDir, { withFileTypes: true }); - for (const e of entries) { - if (!e.isDirectory()) continue; - const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/); - if (!m) continue; - diskPhaseDirs.set(canonicalizePhase(m[1]), e.name); - } - } catch { /* intentionally empty */ } - - if (roadmapPhaseNumbers.length > 0) { - phaseCount = roadmapPhaseNumbers.length; - for (const num of roadmapPhaseNumbers) { - const dirName = diskPhaseDirs.get(canonicalizePhase(num)); - if (!dirName) continue; - try { - const phaseFiles = readdirSync(join(phasesDir, dirName)); - const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - if (hasSummary) completedPhases++; - } catch { /* intentionally empty */ } - } - } else { - // Fallback: no parseable ROADMAP (e.g. brand-new project). Preserve the - // legacy on-disk-count behavior so existing no-roadmap tests still pass. - try { - const entries = readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); - phaseCount = dirs.length; - for (const dir of dirs) { - try { - const phaseFiles = readdirSync(join(phasesDir, dir)); - const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - if (hasSummary) completedPhases++; - } catch { /* intentionally empty */ } - } - } catch { /* intentionally empty */ } - } - - const archiveDir = join(planningDir, 'archive'); - let archivedMilestones: string[] = []; - try { - archivedMilestones = readdirSync(archiveDir, { withFileTypes: true }) - .filter(e => e.isDirectory()) - .map(e => e.name); - } catch { /* intentionally empty */ } - - const result: Record = { - commit_docs: config.commit_docs, - milestone_version: milestone.version, - milestone_name: milestone.name, - milestone_slug: generateSlugInternal(milestone.name), - phase_count: phaseCount, - completed_phases: completedPhases, - all_phases_complete: phaseCount > 0 && phaseCount === completedPhases, - archived_milestones: archivedMilestones, - archive_count: archivedMilestones.length, - project_exists: pathExists(projectDir, '.planning/PROJECT.md'), - roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), - state_exists: existsSync(join(planningDir, 'STATE.md')), - archive_exists: existsSync(archiveDir), - phases_dir_exists: existsSync(phasesDir), - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initMapCodebase ────────────────────────────────────────────────────── - -/** - * Init handler for map-codebase workflow. - * Port of cmdInitMapCodebase from init.cjs lines 819-852. - */ -export const initMapCodebase: QueryHandler = async (_args, projectDir) => { - const config = await loadConfig(projectDir); - const now = new Date(); - const codebaseDir = join(projectDir, '.planning', 'codebase'); - let existingMaps: string[] = []; - try { - existingMaps = readdirSync(codebaseDir).filter(f => f.endsWith('.md')); - } catch { /* intentionally empty */ } - - const mapperModel = await getModelAlias('gsd-codebase-mapper', projectDir); - - const result: Record = { - mapper_model: mapperModel, - commit_docs: config.commit_docs, - search_gitignored: config.search_gitignored, - parallelization: config.parallelization, - // subagent_timeout lives at workflow.subagent_timeout per the canonical - // Configuration manifest (sdk/shared/config-defaults.manifest.json). Reading - // the top-level config.subagent_timeout returned undefined, so the workflow - // step that consumes this value had to invent its own fallback. Default to - // 300000 (5 min) per the manifest. (#1472) - subagent_timeout: (((config as Record).workflow as Record | undefined)?.subagent_timeout as number | undefined) ?? 300000, - date: now.toISOString().split('T')[0], - timestamp: now.toISOString(), - codebase_dir: '.planning/codebase', - existing_maps: existingMaps, - has_maps: existingMaps.length > 0, - planning_exists: pathExists(projectDir, '.planning'), - codebase_dir_exists: pathExists(projectDir, '.planning/codebase'), - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initNewWorkspace ───────────────────────────────────────────────────── - -/** - * Init handler for new-workspace workflow. - * Port of cmdInitNewWorkspace from init.cjs lines 1311-1335. - * T-14-01: Validates workspace name rejects path separators. - */ -export const initNewWorkspace: QueryHandler = async (_args, projectDir) => { - const home = process.env.HOME || homedir(); - const defaultBase = join(home, 'gsd-workspaces'); - - // Detect child git repos (one level deep) - const childRepos: Array<{ name: string; path: string; has_uncommitted: boolean }> = []; - try { - const entries = readdirSync(projectDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - const fullPath = join(projectDir, entry.name); - if (existsSync(join(fullPath, '.git'))) { - let hasUncommitted = false; - try { - const status = execSync('git status --porcelain', { cwd: fullPath, encoding: 'utf8', timeout: 5000, stdio: 'pipe' }); - hasUncommitted = status.trim().length > 0; - } catch { /* best-effort */ } - childRepos.push({ name: entry.name, path: fullPath, has_uncommitted: hasUncommitted }); - } - } - } catch { /* intentionally empty */ } - - let worktreeAvailable = false; - try { - execSync('git --version', { encoding: 'utf8', timeout: 5000, stdio: 'pipe' }); - worktreeAvailable = true; - } catch { /* no git */ } - - const result: Record = { - default_workspace_base: defaultBase, - child_repos: childRepos, - child_repo_count: childRepos.length, - worktree_available: worktreeAvailable, - is_git_repo: pathExists(projectDir, '.git'), - cwd_repo_name: basename(projectDir), - }; - - return { data: withProjectRoot(projectDir, result) }; -}; - -// ─── initListWorkspaces ─────────────────────────────────────────────────── - -/** - * Init handler for list-workspaces workflow. - * Port of cmdInitListWorkspaces from init.cjs lines 1337-1381. - */ -export const initListWorkspaces: QueryHandler = async (_args, _projectDir) => { - const home = process.env.HOME || homedir(); - const defaultBase = join(home, 'gsd-workspaces'); - - const workspaces: Array> = []; - if (existsSync(defaultBase)) { - let entries: Dirent[] = []; - try { - entries = readdirSync(defaultBase, { withFileTypes: true }); - } catch { entries = []; } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const wsPath = join(defaultBase, String(entry.name)); - const manifestPath = join(wsPath, 'WORKSPACE.md'); - if (!existsSync(manifestPath)) continue; - - let repoCount = 0; - let strategy = 'unknown'; - try { - const manifest = readFileSync(manifestPath, 'utf8'); - const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m); - if (strategyMatch) strategy = strategyMatch[1].trim(); - const tableRows = manifest.split('\n').filter(l => l.match(/^\|\s*\w/) && !l.includes('Repo') && !l.includes('---')); - repoCount = tableRows.length; - } catch { /* best-effort */ } - const hasProject = existsSync(join(wsPath, '.planning', 'PROJECT.md')); - - workspaces.push({ - name: entry.name, - path: wsPath, - repo_count: repoCount, - strategy, - has_project: hasProject, - }); - } - } - - const result: Record = { - workspace_base: defaultBase, - workspaces, - workspace_count: workspaces.length, - }; - - return { data: result }; -}; - -// ─── initRemoveWorkspace ────────────────────────────────────────────────── - -/** - * Init handler for remove-workspace workflow. - * Port of cmdInitRemoveWorkspace from init.cjs lines 1383-1443. - * T-14-01: Validates workspace name rejects path separators and '..' sequences. - */ -export const initRemoveWorkspace: QueryHandler = async (args, _projectDir) => { - const name = args[0]; - if (!name) { - // Throw so the CLI dispatcher projects a non-zero exit + writes the message - // to stderr — returning `{ data: { error } }` was treated as success by - // the CLI output path, hiding the validation failure from callers. - throw new GSDError('workspace name required for init remove-workspace', ErrorClassification.Validation); - } - - // T-14-01: Reject path traversal attempts - if (name.includes('/') || name.includes('\\') || name.includes('..')) { - throw new GSDError( - `Invalid workspace name: ${name} (path separators not allowed)`, - ErrorClassification.Validation, - ); - } - - const home = process.env.HOME || homedir(); - const defaultBase = join(home, 'gsd-workspaces'); - const wsPath = join(defaultBase, name); - const manifestPath = join(wsPath, 'WORKSPACE.md'); - - if (!existsSync(wsPath)) { - throw new GSDError(`Workspace not found: ${wsPath}`, ErrorClassification.Validation); - } - - const repos: Array> = []; - let strategy = 'unknown'; - if (existsSync(manifestPath)) { - try { - const manifest = readFileSync(manifestPath, 'utf8'); - const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m); - if (strategyMatch) strategy = strategyMatch[1].trim(); - - const lines = manifest.split('\n'); - for (const line of lines) { - const match = line.match(/^\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|$/); - if (match && match[1] !== 'Repo' && !match[1].includes('---')) { - repos.push({ name: match[1], source: match[2], branch: match[3], strategy: match[4] }); - } - } - } catch { /* best-effort */ } - } - - // Check for uncommitted changes in workspace repos - const dirtyRepos: string[] = []; - for (const repo of repos) { - const repoPath = join(wsPath, repo.name as string); - if (!existsSync(repoPath)) continue; - try { - const status = execSync('git status --porcelain', { cwd: repoPath, encoding: 'utf8', timeout: 5000, stdio: 'pipe' }); - if (status.trim().length > 0) { - dirtyRepos.push(repo.name as string); - } - } catch { /* best-effort */ } - } - - const result: Record = { - workspace_name: name, - workspace_path: wsPath, - has_manifest: existsSync(manifestPath), - strategy, - repos, - repo_count: repos.length, - dirty_repos: dirtyRepos, - has_dirty_repos: dirtyRepos.length > 0, - }; - - return { data: result }; -}; -// ─── initIngestDocs ─────────────────────────────────────────────────────── - -/** - * Init handler for ingest-docs workflow. - * Mirrors `initResume` shape but without current-agent-id lookup — the - * ingest-docs workflow reads `project_exists`, `planning_exists`, `has_git`, - * and `project_path` to branch between new-project vs merge-milestone modes. - */ -export const initIngestDocs: QueryHandler = async (_args, projectDir) => { - const config = await loadConfig(projectDir); - const gitInfo = gitWorktreeInfo(projectDir); - const result: Record = { - project_exists: pathExists(projectDir, '.planning/PROJECT.md'), - planning_exists: pathExists(projectDir, '.planning'), - // Bug #3491: detect parent worktree to avoid nested .git init. - has_git: gitInfo.inside, - git_worktree_root: gitInfo.worktreeRoot, - in_nested_subdir: detectNestedSubdir(projectDir, gitInfo), - project_path: '.planning/PROJECT.md', - commit_docs: config.commit_docs, - }; - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; +export * from '../handlers/init/composer.js'; diff --git a/sdk/src/runtime/name-policy.ts b/sdk/src/runtime/name-policy.ts index a4cf85940..df969dd49 100644 --- a/sdk/src/runtime/name-policy.ts +++ b/sdk/src/runtime/name-policy.ts @@ -1,12 +1,12 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { SUPPORTED_RUNTIMES, type Runtime } from './model-catalog.js'; +import { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js'; interface RuntimeAliasManifest { [canonicalRuntime: string]: string[]; } -const MANIFEST_PATH = new URL('../shared/runtime-aliases.manifest.json', import.meta.url); +const MANIFEST_PATH = new URL('../../shared/runtime-aliases.manifest.json', import.meta.url); const manifest: RuntimeAliasManifest = JSON.parse(readFileSync(fileURLToPath(MANIFEST_PATH), 'utf-8')); function normalizeRuntimeToken(value: string): string { @@ -29,4 +29,3 @@ export function canonicalizeRuntimeName(value: unknown): Runtime | null { if (typeof value !== 'string') return null; return aliasToCanonical.get(normalizeRuntimeToken(value)) ?? null; } - diff --git a/tests/bug-3751-init-local-agents.test.cjs b/tests/bug-3751-init-local-agents.test.cjs index 9cad9a557..87ab716f2 100644 --- a/tests/bug-3751-init-local-agents.test.cjs +++ b/tests/bug-3751-init-local-agents.test.cjs @@ -63,12 +63,12 @@ const helpersTs = fs.readFileSync( ); const initTs = fs.readFileSync( - path.join(__dirname, '../sdk/src/query/init.ts'), + path.join(__dirname, '../sdk/src/handlers/init/composer.ts'), 'utf-8', ); const initComplexTs = fs.readFileSync( - path.join(__dirname, '../sdk/src/query/init-complex.ts'), + path.join(__dirname, '../sdk/src/handlers/init/complex.ts'), 'utf-8', ); @@ -140,8 +140,8 @@ describe('#3751: resolveAgentsDir() repo-local fallback — structural contracts ); }); - // ─── Contract 5: init.ts passes projectDir to resolveAgentsDir ────────── - test('init.ts checkAgentsInstalled passes projectDir to resolveAgentsDir', () => { + // ─── Contract 5: init composer passes projectDir to resolveAgentsDir ───── + test('init composer checkAgentsInstalled passes projectDir to resolveAgentsDir', () => { // After fix: resolveAgentsDir must be called with projectDir (not just runtime) // The call site at init.ts must thread projectDir through. // We verify either checkAgentsInstalled gains projectDir param or @@ -151,12 +151,12 @@ describe('#3751: resolveAgentsDir() repo-local fallback — structural contracts const checkFnBody = initTs.slice(checkFnStart, checkFnEnd === -1 ? undefined : checkFnEnd); assert.ok( checkFnBody.includes('projectDir') || checkFnBody.includes('resolveAgentsDir(runtime, '), - 'checkAgentsInstalled in init.ts must pass projectDir to resolveAgentsDir (#3751)', + 'checkAgentsInstalled in init composer must pass projectDir to resolveAgentsDir (#3751)', ); }); - // ─── Contract 6: init-complex.ts passes projectDir to resolveAgentsDir ── - test('init-complex.ts initNewProject passes projectDir to resolveAgentsDir', () => { + // ─── Contract 6: init complex passes projectDir to resolveAgentsDir ────── + test('init complex initNewProject passes projectDir to resolveAgentsDir', () => { const callIdx = initComplexTs.indexOf('resolveAgentsDir(runtime)'); assert.strictEqual( callIdx,