/** * Core — Shared utilities, constants, and internal helpers */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); // ─── Path helpers ──────────────────────────────────────────────────────────── /** Normalize a relative path to always use forward slashes (cross-platform). */ function toPosixPath(p) { return p.split(path.sep).join('/'); } // ─── Model Profile Table ───────────────────────────────────────────────────── const MODEL_PROFILES = { 'gsd-planner': { quality: 'opus', balanced: 'opus', budget: 'sonnet' }, 'gsd-roadmapper': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, 'gsd-executor': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, 'gsd-phase-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' }, 'gsd-project-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' }, 'gsd-research-synthesizer': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, 'gsd-debugger': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, 'gsd-codebase-mapper': { quality: 'sonnet', balanced: 'haiku', budget: 'haiku' }, 'gsd-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, 'gsd-plan-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, 'gsd-integration-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, 'gsd-nyquist-auditor': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, }; // ─── Output helpers ─────────────────────────────────────────────────────────── function output(result, raw, rawValue) { if (raw && rawValue !== undefined) { process.stdout.write(String(rawValue)); } else { const json = JSON.stringify(result, null, 2); // Large payloads exceed OpenCode's bash tool buffer (~50KB). // write to tmpfile and output the path prefixed with @file: so callers can detect it. if (json.length > 50000) { const tmpPath = path.join(require('os').tmpdir(), `gsd-${Date.now()}.json`); fs.writeFileSync(tmpPath, json, 'utf-8'); process.stdout.write('@file:' + tmpPath); } else { process.stdout.write(json); } } process.exit(0); } function error(message) { process.stderr.write('Error: ' + message + '\n'); process.exit(1); } // ─── File & Config utilities ────────────────────────────────────────────────── function safeReadFile(filePath) { try { return fs.readFileSync(filePath, 'utf-8'); } catch { return null; } } function loadConfig(cwd) { const configPath = path.join(cwd, '.planning', 'config.json'); const defaults = { model_profile: 'balanced', commit_docs: true, search_gitignored: false, branching_strategy: 'none', phase_branch_template: 'gsd/phase-{phase}-{slug}', milestone_branch_template: 'gsd/{milestone}-{slug}', research: true, plan_checker: true, verifier: true, nyquist_validation: true, parallelization: true, brave_search: false, }; try { const raw = fs.readFileSync(configPath, 'utf-8'); const parsed = JSON.parse(raw); // Migrate deprecated "depth" key to "granularity" with value mapping if ('depth' in parsed && !('granularity' in parsed)) { const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' }; parsed.granularity = depthToGranularity[parsed.depth] || parsed.depth; delete parsed.depth; try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch {} } const get = (key, nested) => { if (parsed[key] !== undefined) return parsed[key]; if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) { return parsed[nested.section][nested.field]; } return undefined; }; const parallelization = (() => { const val = get('parallelization'); if (typeof val === 'boolean') return val; if (typeof val === 'object' && val !== null && 'enabled' in val) return val.enabled; return defaults.parallelization; })(); return { model_profile: get('model_profile') ?? defaults.model_profile, commit_docs: get('commit_docs', { section: 'planning', field: 'commit_docs' }) ?? defaults.commit_docs, search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored, branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy, phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template, milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template, research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research, plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker, verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier, nyquist_validation: get('nyquist_validation', { section: 'workflow', field: 'nyquist_validation' }) ?? defaults.nyquist_validation, parallelization, brave_search: get('brave_search') ?? defaults.brave_search, model_overrides: parsed.model_overrides || null, }; } catch { return defaults; } } // ─── Git utilities ──────────────────────────────────────────────────────────── function isGitIgnored(cwd, targetPath) { try { // --no-index checks .gitignore rules regardless of whether the file is tracked. // Without it, git check-ignore returns "not ignored" for tracked files even when // .gitignore explicitly lists them — a common source of confusion when .planning/ // was committed before being added to .gitignore. execSync('git check-ignore -q --no-index -- ' + targetPath.replace(/[^a-zA-Z0-9._\-/]/g, ''), { cwd, stdio: 'pipe', }); return true; } catch { return false; } } function execGit(cwd, args) { try { const escaped = args.map(a => { if (/^[a-zA-Z0-9._\-/=:@]+$/.test(a)) return a; return "'" + a.replace(/'/g, "'\\''") + "'"; }); const stdout = execSync('git ' + escaped.join(' '), { cwd, stdio: 'pipe', encoding: 'utf-8', }); return { exitCode: 0, stdout: stdout.trim(), stderr: '' }; } catch (err) { return { exitCode: err.status ?? 1, stdout: (err.stdout ?? '').toString().trim(), stderr: (err.stderr ?? '').toString().trim(), }; } } // ─── Phase utilities ────────────────────────────────────────────────────────── function escapeRegex(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function normalizePhaseName(phase) { const match = String(phase).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); if (!match) return phase; const padded = match[1].padStart(2, '0'); const letter = match[2] ? match[2].toUpperCase() : ''; const decimal = match[3] || ''; return padded + letter + decimal; } function comparePhaseNum(a, b) { const pa = String(a).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); const pb = String(b).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); if (!pa || !pb) return String(a).localeCompare(String(b)); const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10); if (intDiff !== 0) return intDiff; // No letter sorts before letter: 12 < 12A < 12B const la = (pa[2] || '').toUpperCase(); const lb = (pb[2] || '').toUpperCase(); if (la !== lb) { if (!la) return -1; if (!lb) return 1; return la < lb ? -1 : 1; } // Segment-by-segment decimal comparison: 12A < 12A.1 < 12A.1.2 < 12A.2 const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : []; const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : []; const maxLen = Math.max(aDecParts.length, bDecParts.length); if (aDecParts.length === 0 && bDecParts.length > 0) return -1; if (bDecParts.length === 0 && aDecParts.length > 0) return 1; for (let i = 0; i < maxLen; i++) { const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0; const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0; if (av !== bv) return av - bv; } return 0; } function searchPhaseInDir(baseDir, relBase, normalized) { try { const entries = fs.readdirSync(baseDir, { withFileTypes: true }); const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); const match = dirs.find(d => d.startsWith(normalized)); if (!match) return null; const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); const phaseNumber = dirMatch ? dirMatch[1] : normalized; const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; const phaseDir = path.join(baseDir, match); const phaseFiles = fs.readdirSync(phaseDir); const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort(); const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort(); const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); const hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); const hasVerification = phaseFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); const completedPlanIds = new Set( summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')) ); const incompletePlans = plans.filter(p => { const planId = p.replace('-PLAN.md', '').replace('PLAN.md', ''); return !completedPlanIds.has(planId); }); return { found: true, directory: toPosixPath(path.join(relBase, match)), phase_number: phaseNumber, phase_name: phaseName, phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null, plans, summaries, incomplete_plans: incompletePlans, has_research: hasResearch, has_context: hasContext, has_verification: hasVerification, }; } catch { return null; } } function findPhaseInternal(cwd, phase) { if (!phase) return null; const phasesDir = path.join(cwd, '.planning', 'phases'); const normalized = normalizePhaseName(phase); // Search current phases first const current = searchPhaseInDir(phasesDir, '.planning/phases', normalized); if (current) return current; // Search archived milestone phases (newest first) const milestonesDir = path.join(cwd, '.planning', 'milestones'); if (!fs.existsSync(milestonesDir)) return null; try { const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true }); const archiveDirs = milestoneEntries .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)) .map(e => e.name) .sort() .reverse(); for (const archiveName of archiveDirs) { const version = archiveName.match(/^(v[\d.]+)-phases$/)[1]; const archivePath = path.join(milestonesDir, archiveName); const relBase = '.planning/milestones/' + archiveName; const result = searchPhaseInDir(archivePath, relBase, normalized); if (result) { result.archived = version; return result; } } } catch {} return null; } function getArchivedPhaseDirs(cwd) { const milestonesDir = path.join(cwd, '.planning', 'milestones'); const results = []; if (!fs.existsSync(milestonesDir)) return results; try { const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true }); // Find v*-phases directories, sort newest first const phaseDirs = milestoneEntries .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)) .map(e => e.name) .sort() .reverse(); for (const archiveName of phaseDirs) { const version = archiveName.match(/^(v[\d.]+)-phases$/)[1]; const archivePath = path.join(milestonesDir, archiveName); const entries = fs.readdirSync(archivePath, { withFileTypes: true }); const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); for (const dir of dirs) { results.push({ name: dir, milestone: version, basePath: path.join('.planning', 'milestones', archiveName), fullPath: path.join(archivePath, dir), }); } } } catch {} return results; } // ─── Roadmap & model utilities ──────────────────────────────────────────────── function getRoadmapPhaseInternal(cwd, phaseNum) { if (!phaseNum) return null; const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); if (!fs.existsSync(roadmapPath)) return null; try { const content = fs.readFileSync(roadmapPath, 'utf-8'); const escapedPhase = escapeRegex(phaseNum.toString()); const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i'); const headerMatch = content.match(phasePattern); if (!headerMatch) return null; const phaseName = headerMatch[1].trim(); const headerIndex = headerMatch.index; const restOfContent = content.slice(headerIndex); const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i); const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length; const section = content.slice(headerIndex, sectionEnd).trim(); const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i); const goal = goalMatch ? goalMatch[1].trim() : null; return { found: true, phase_number: phaseNum.toString(), phase_name: phaseName, goal, section, }; } catch { return null; } } function resolveModelInternal(cwd, agentType) { const config = loadConfig(cwd); // Check per-agent override first const override = config.model_overrides?.[agentType]; if (override) { return override === 'opus' ? 'inherit' : override; } // Fall back to profile lookup const profile = config.model_profile || 'balanced'; const agentModels = MODEL_PROFILES[agentType]; if (!agentModels) return 'sonnet'; const resolved = agentModels[profile] || agentModels['balanced'] || 'sonnet'; return resolved === 'opus' ? 'inherit' : resolved; } // ─── Misc utilities ─────────────────────────────────────────────────────────── function pathExistsInternal(cwd, targetPath) { const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath); try { fs.statSync(fullPath); return true; } catch { return false; } } function generateSlugInternal(text) { if (!text) return null; return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); } function getMilestoneInfo(cwd) { try { const roadmap = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8'); // First: check for list-format roadmaps using 🚧 (in-progress) marker // e.g. "- 🚧 **v2.1 Belgium** — Phases 24-28 (in progress)" const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+\.\d+)\s+([^*]+)\*\*/); if (inProgressMatch) { return { version: 'v' + inProgressMatch[1], name: inProgressMatch[2].trim(), }; } // Second: heading-format roadmaps — strip shipped milestones in
blocks const cleaned = roadmap.replace(/
[\s\S]*?<\/details>/gi, ''); // Extract version and name from the same ## heading for consistency const headingMatch = cleaned.match(/## .*v(\d+\.\d+)[:\s]+([^\n(]+)/); if (headingMatch) { return { version: 'v' + headingMatch[1], name: headingMatch[2].trim(), }; } // Fallback: try bare version match const versionMatch = cleaned.match(/v(\d+\.\d+)/); return { version: versionMatch ? versionMatch[0] : 'v1.0', name: 'milestone', }; } catch { return { version: 'v1.0', name: 'milestone' }; } } /** * Returns a filter function that checks whether a phase directory belongs * to the current milestone based on ROADMAP.md phase headings. * If no ROADMAP exists or no phases are listed, returns a pass-all filter. */ function getMilestonePhaseFilter(cwd) { const milestonePhaseNums = new Set(); try { const roadmap = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8'); const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; let m; while ((m = phasePattern.exec(roadmap)) !== null) { milestonePhaseNums.add(m[1]); } } catch {} if (milestonePhaseNums.size === 0) { const passAll = () => true; passAll.phaseCount = 0; return passAll; } const normalized = new Set( [...milestonePhaseNums].map(n => (n.replace(/^0+/, '') || '0').toLowerCase()) ); function isDirInMilestone(dirName) { const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/); if (!m) return false; return normalized.has(m[1].toLowerCase()); } isDirInMilestone.phaseCount = milestonePhaseNums.size; return isDirInMilestone; } module.exports = { MODEL_PROFILES, output, error, safeReadFile, loadConfig, isGitIgnored, execGit, escapeRegex, normalizePhaseName, comparePhaseNum, searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs, getRoadmapPhaseInternal, resolveModelInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, toPosixPath, };