/** * Core — Shared utilities, constants, and internal helpers */ const fs = require('fs'); const path = require('path'); const { execSync, execFileSync, spawnSync } = require('child_process'); const { MODEL_PROFILES } = require('./model-profiles.cjs'); // ─── Path helpers ──────────────────────────────────────────────────────────── /** Normalize a relative path to always use forward slashes (cross-platform). */ function toPosixPath(p) { return p.split(path.sep).join('/'); } /** * Scan immediate child directories for separate git repos. * Returns a sorted array of directory names that have their own `.git`. * Excludes hidden directories and node_modules. */ function detectSubRepos(cwd) { const results = []; try { const entries = fs.readdirSync(cwd, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; const gitPath = path.join(cwd, entry.name, '.git'); try { if (fs.existsSync(gitPath)) { results.push(entry.name); } } catch {} } } catch {} return results.sort(); } /** * Walk up from `startDir` to find the project root that owns `.planning/`. * * In multi-repo workspaces, the agent may open inside a sub-repo (e.g. `backend/`) * instead of the project root. This function prevents `.planning/` from being * created inside the sub-repo by locating the nearest ancestor that already has * a `.planning/` directory. * * Detection strategy (checked in order for each ancestor): * 1. Parent has `.planning/config.json` with `sub_repos` listing this directory * 2. Parent has `.planning/config.json` with `multiRepo: true` (legacy format) * 3. Parent has `.planning/` and current dir has its own `.git` (heuristic) * * Returns `startDir` unchanged when no ancestor `.planning/` is found (first-run * or single-repo projects). */ function findProjectRoot(startDir) { const resolved = path.resolve(startDir); const root = path.parse(resolved).root; const homedir = require('os').homedir(); // If startDir already contains .planning/, it IS the project root. // Do not walk up to a parent workspace that also has .planning/ (#1362). const ownPlanning = path.join(resolved, '.planning'); if (fs.existsSync(ownPlanning) && fs.statSync(ownPlanning).isDirectory()) { return startDir; } // Check if startDir or any of its ancestors (up to AND including the // candidate project root) contains a .git directory. This handles both // `backend/` (direct sub-repo) and `backend/src/modules/` (nested inside), // as well as the common case where .git lives at the same level as .planning/. function isInsideGitRepo(candidateParent) { let d = resolved; while (d !== root) { if (fs.existsSync(path.join(d, '.git'))) return true; if (d === candidateParent) break; d = path.dirname(d); } return false; } let dir = resolved; while (dir !== root) { const parent = path.dirname(dir); if (parent === dir) break; // filesystem root if (parent === homedir) break; // never go above home const parentPlanning = path.join(parent, '.planning'); if (fs.existsSync(parentPlanning) && fs.statSync(parentPlanning).isDirectory()) { const configPath = path.join(parentPlanning, 'config.json'); try { const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); const subRepos = config.sub_repos || config.planning?.sub_repos || []; // Check explicit sub_repos list if (Array.isArray(subRepos) && subRepos.length > 0) { const relPath = path.relative(parent, resolved); const topSegment = relPath.split(path.sep)[0]; if (subRepos.includes(topSegment)) { return parent; } } // Check legacy multiRepo flag if (config.multiRepo === true && isInsideGitRepo(parent)) { return parent; } } catch { // config.json missing or malformed — fall back to .git heuristic } // Heuristic: parent has .planning/ and we're inside a git repo if (isInsideGitRepo(parent)) { return parent; } } dir = parent; } return startDir; } // ─── Output helpers ─────────────────────────────────────────────────────────── /** * Remove stale gsd-* temp files/dirs older than maxAgeMs (default: 5 minutes). * Runs opportunistically before each new temp file write to prevent unbounded accumulation. * @param {string} prefix - filename prefix to match (e.g., 'gsd-') * @param {object} opts * @param {number} opts.maxAgeMs - max age in ms before removal (default: 5 min) * @param {boolean} opts.dirsOnly - if true, only remove directories (default: false) */ function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false } = {}) { try { const tmpDir = require('os').tmpdir(); const now = Date.now(); const entries = fs.readdirSync(tmpDir); for (const entry of entries) { if (!entry.startsWith(prefix)) continue; const fullPath = path.join(tmpDir, entry); try { const stat = fs.statSync(fullPath); if (now - stat.mtimeMs > maxAgeMs) { if (stat.isDirectory()) { fs.rmSync(fullPath, { recursive: true, force: true }); } else if (!dirsOnly) { fs.unlinkSync(fullPath); } } } catch { // File may have been removed between readdir and stat — ignore } } } catch { // Non-critical — don't let cleanup failures break output } } function output(result, raw, rawValue) { let data; if (raw && rawValue !== undefined) { data = String(rawValue); } else { const json = JSON.stringify(result, null, 2); // Large payloads exceed Claude Code's Bash tool buffer (~50KB). // Write to tmpfile and output the path prefixed with @file: so callers can detect it. if (json.length > 50000) { reapStaleTempFiles(); const tmpPath = path.join(require('os').tmpdir(), `gsd-${Date.now()}.json`); fs.writeFileSync(tmpPath, json, 'utf-8'); data = '@file:' + tmpPath; } else { data = json; } } // process.stdout.write() is async when stdout is a pipe — process.exit() // can tear down the process before the reader consumes the buffer. // fs.writeSync(1, ...) blocks until the kernel accepts the bytes, and // skipping process.exit() lets the event loop drain naturally. fs.writeSync(1, data); } function error(message) { fs.writeSync(2, '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}', quick_branch_template: null, research: true, plan_checker: true, verifier: true, nyquist_validation: true, parallelization: true, brave_search: false, firecrawl: false, exa_search: false, text_mode: false, // when true, use plain-text numbered lists instead of AskUserQuestion menus sub_repos: [], resolve_model_ids: false, // false: return alias as-is | true: map to full the agent model ID | "omit": return '' (runtime uses its default) context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs) }; 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 { /* intentionally empty */ } } // Auto-detect and sync sub_repos: scan for child directories with .git let configDirty = false; // Migrate legacy "multiRepo: true" boolean → sub_repos array if (parsed.multiRepo === true && !parsed.sub_repos && !parsed.planning?.sub_repos) { const detected = detectSubRepos(cwd); if (detected.length > 0) { parsed.sub_repos = detected; if (!parsed.planning) parsed.planning = {}; parsed.planning.commit_docs = false; delete parsed.multiRepo; configDirty = true; } } // Keep sub_repos in sync with actual filesystem const currentSubRepos = parsed.sub_repos || parsed.planning?.sub_repos || []; if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) { const detected = detectSubRepos(cwd); if (detected.length > 0) { const sorted = [...currentSubRepos].sort(); if (JSON.stringify(sorted) !== JSON.stringify(detected)) { parsed.sub_repos = detected; configDirty = true; } } } // Persist sub_repos changes (migration or sync) if (configDirty) { 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: (() => { const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' }); // If explicitly set in config, respect the user's choice if (explicit !== undefined) return explicit; // Auto-detection: when no explicit value and .planning/ is gitignored, // default to false instead of true if (isGitIgnored(cwd, '.planning/')) return false; return 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, quick_branch_template: get('quick_branch_template', { section: 'git', field: 'quick_branch_template' }) ?? defaults.quick_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, firecrawl: get('firecrawl') ?? defaults.firecrawl, exa_search: get('exa_search') ?? defaults.exa_search, text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode, sub_repos: get('sub_repos', { section: 'planning', field: 'sub_repos' }) ?? defaults.sub_repos, resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids, context_window: get('context_window') ?? defaults.context_window, phase_naming: get('phase_naming') ?? defaults.phase_naming, model_overrides: parsed.model_overrides || null, agent_skills: parsed.agent_skills || {}, }; } 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. // Use execFileSync (array args) to prevent shell interpretation of special characters // in file paths — avoids command injection via crafted path names. execFileSync('git', ['check-ignore', '-q', '--no-index', '--', targetPath], { cwd, stdio: 'pipe', }); return true; } catch { return false; } } // ─── Markdown normalization ───────────────────────────────────────────────── /** * Normalize markdown to fix common markdownlint violations. * Applied at write points so GSD-generated .planning/ files are IDE-friendly. * * Rules enforced: * MD022 — Blank lines around headings * MD031 — Blank lines around fenced code blocks * MD032 — Blank lines around lists * MD012 — No multiple consecutive blank lines (collapsed to 2 max) * MD047 — Files end with a single newline */ function normalizeMd(content) { if (!content || typeof content !== 'string') return content; // Normalize line endings to LF for consistent processing let text = content.replace(/\r\n/g, '\n'); const lines = text.split('\n'); const result = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const prev = i > 0 ? lines[i - 1] : ''; const prevTrimmed = prev.trimEnd(); const trimmed = line.trimEnd(); // MD022: Blank line before headings (skip first line and frontmatter delimiters) if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') { result.push(''); } // MD031: Blank line before fenced code blocks if (/^```/.test(trimmed) && i > 0 && prevTrimmed !== '' && !isInsideFencedBlock(lines, i)) { result.push(''); } // MD032: Blank line before lists (- item, * item, N. item, - [ ] item) if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 && prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) && prevTrimmed !== '---') { result.push(''); } result.push(line); // MD022: Blank line after headings if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1) { const next = lines[i + 1]; if (next !== undefined && next.trimEnd() !== '') { result.push(''); } } // MD031: Blank line after closing fenced code blocks if (/^```\s*$/.test(trimmed) && isClosingFence(lines, i) && i < lines.length - 1) { const next = lines[i + 1]; if (next !== undefined && next.trimEnd() !== '') { result.push(''); } } // MD032: Blank line after last list item in a block if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) { const next = lines[i + 1]; if (next !== undefined && next.trimEnd() !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) && !/^\s/.test(next)) { // Only add blank line if next line is not a continuation/indented line result.push(''); } } } text = result.join('\n'); // MD012: Collapse 3+ consecutive blank lines to 2 text = text.replace(/\n{3,}/g, '\n\n'); // MD047: Ensure file ends with exactly one newline text = text.replace(/\n*$/, '\n'); return text; } /** Check if line index i is inside an already-open fenced code block */ function isInsideFencedBlock(lines, i) { let fenceCount = 0; for (let j = 0; j < i; j++) { if (/^```/.test(lines[j].trimEnd())) fenceCount++; } return fenceCount % 2 === 1; } /** Check if a ``` line is a closing fence (odd number of fences up to and including this one) */ function isClosingFence(lines, i) { let fenceCount = 0; for (let j = 0; j <= i; j++) { if (/^```/.test(lines[j].trimEnd())) fenceCount++; } return fenceCount % 2 === 0; } function execGit(cwd, args) { const result = spawnSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8', }); return { exitCode: result.status ?? 1, stdout: (result.stdout ?? '').toString().trim(), stderr: (result.stderr ?? '').toString().trim(), }; } // ─── Common path helpers ────────────────────────────────────────────────────── /** * Resolve the main worktree root when running inside a git worktree. * In a linked worktree, .planning/ lives in the main worktree, not in the linked one. * Returns the main worktree path, or cwd if not in a worktree. */ function resolveWorktreeRoot(cwd) { // If the current directory already has its own .planning/, respect it. // This handles linked worktrees with independent planning state (e.g., Conductor workspaces). if (fs.existsSync(path.join(cwd, '.planning'))) { return cwd; } // Check if we're in a linked worktree const gitDir = execGit(cwd, ['rev-parse', '--git-dir']); const commonDir = execGit(cwd, ['rev-parse', '--git-common-dir']); if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return cwd; // In a linked worktree, .git is a file pointing to .git/worktrees/ // and git-common-dir points to the main repo's .git directory const gitDirResolved = path.resolve(cwd, gitDir.stdout); const commonDirResolved = path.resolve(cwd, commonDir.stdout); if (gitDirResolved !== commonDirResolved) { // We're in a linked worktree — resolve main worktree root // The common dir is the main repo's .git, so its parent is the main worktree root return path.dirname(commonDirResolved); } return cwd; } /** * Acquire a file-based lock for .planning/ writes. * Prevents concurrent worktrees from corrupting shared planning files. * Lock is auto-released after the callback completes. */ function withPlanningLock(cwd, fn) { const lockPath = path.join(planningDir(cwd), '.lock'); const lockTimeout = 10000; // 10 seconds const retryDelay = 100; const start = Date.now(); // Ensure .planning/ exists try { fs.mkdirSync(planningDir(cwd), { recursive: true }); } catch { /* ok */ } while (Date.now() - start < lockTimeout) { try { // Atomic create — fails if file exists fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, cwd, acquired: new Date().toISOString(), }), { flag: 'wx' }); // Lock acquired — run the function try { return fn(); } finally { try { fs.unlinkSync(lockPath); } catch { /* already released */ } } } catch (err) { if (err.code === 'EEXIST') { // Lock exists — check if stale (>30s old) try { const stat = fs.statSync(lockPath); if (Date.now() - stat.mtimeMs > 30000) { fs.unlinkSync(lockPath); continue; // retry } } catch { continue; } // Wait and retry spawnSync('sleep', ['0.1'], { stdio: 'ignore' }); continue; } throw err; } } // Timeout — force acquire (stale lock recovery) try { fs.unlinkSync(lockPath); } catch { /* ok */ } return fn(); } /** * Get the .planning directory path, workstream-aware. * When a workstream is active (via explicit ws arg or GSD_WORKSTREAM env var), * returns `.planning/workstreams/{ws}/`. Otherwise returns `.planning/`. * * @param {string} cwd - project root * @param {string} [ws] - explicit workstream name; if omitted, checks GSD_WORKSTREAM env var */ function planningDir(cwd, ws) { if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null; if (!ws) return path.join(cwd, '.planning'); return path.join(cwd, '.planning', 'workstreams', ws); } /** Always returns the root .planning/ path, ignoring workstreams. For shared resources. */ function planningRoot(cwd) { return path.join(cwd, '.planning'); } /** * Get common .planning file paths, workstream-aware. * Scoped paths (state, roadmap, phases, requirements) resolve to the active workstream. * Shared paths (project, config) always resolve to the root .planning/. */ function planningPaths(cwd, ws) { const base = planningDir(cwd, ws); const root = path.join(cwd, '.planning'); return { planning: base, state: path.join(base, 'STATE.md'), roadmap: path.join(base, 'ROADMAP.md'), project: path.join(root, 'PROJECT.md'), config: path.join(root, 'config.json'), phases: path.join(base, 'phases'), requirements: path.join(base, 'REQUIREMENTS.md'), }; } // ─── Active Workstream Detection ───────────────────────────────────────────── /** * Get the active workstream name from .planning/active-workstream file. * Returns null if no active workstream or file doesn't exist. */ function getActiveWorkstream(cwd) { const filePath = path.join(planningRoot(cwd), 'active-workstream'); try { const name = fs.readFileSync(filePath, 'utf-8').trim(); if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) return null; const wsDir = path.join(planningRoot(cwd), 'workstreams', name); if (!fs.existsSync(wsDir)) return null; return name; } catch { return null; } } /** * Set the active workstream. Pass null to clear. */ function setActiveWorkstream(cwd, name) { const filePath = path.join(planningRoot(cwd), 'active-workstream'); if (!name) { try { fs.unlinkSync(filePath); } catch {} return; } if (!/^[a-zA-Z0-9_-]+$/.test(name)) { throw new Error('Invalid workstream name: must be alphanumeric, hyphens, and underscores only'); } fs.writeFileSync(filePath, name + '\n', 'utf-8'); } // ─── Phase utilities ────────────────────────────────────────────────────────── function escapeRegex(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function normalizePhaseName(phase) { const str = String(phase); // Standard numeric phases: 1, 01, 12A, 12.1 const match = str.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); if (match) { const padded = match[1].padStart(2, '0'); const letter = match[2] ? match[2].toUpperCase() : ''; const decimal = match[3] || ''; return padded + letter + decimal; } // Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is return str; } 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 either is non-numeric (custom ID), fall back to string comparison 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 dirs = readSubdirectories(baseDir, true); // Match: starts with normalized (numeric) OR contains normalized as prefix segment (custom ID) const match = dirs.find(d => { if (d.startsWith(normalized)) return true; // For custom IDs like PROJ-42, match case-insensitively if (d.toUpperCase().startsWith(normalized.toUpperCase())) return true; return false; }); if (!match) return null; // Extract phase number and name — supports both numeric (01-name) and custom (PROJ-42-name) const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) || match.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-(.+)/i) || [null, match, null]; const phaseNumber = dirMatch ? dirMatch[1] : normalized; const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; const phaseDir = path.join(baseDir, match); const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir); const plans = unsortedPlans.sort(); const summaries = unsortedSummaries.sort(); 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, has_reviews: hasReviews, }; } catch { return null; } } function findPhaseInternal(cwd, phase) { if (!phase) return null; const phasesDir = path.join(planningDir(cwd), 'phases'); const normalized = normalizePhaseName(phase); // Search current phases first const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir)); const current = searchPhaseInDir(phasesDir, relPhasesDir, 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 { /* intentionally empty */ } 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 dirs = readSubdirectories(archivePath, true); for (const dir of dirs) { results.push({ name: dir, milestone: version, basePath: path.join('.planning', 'milestones', archiveName), fullPath: path.join(archivePath, dir), }); } } } catch { /* intentionally empty */ } return results; } // ─── Roadmap milestone scoping ─────────────────────────────────────────────── /** * Strip shipped milestone content wrapped in
blocks. * Used to isolate current milestone phases when searching ROADMAP.md * for phase headings or checkboxes — prevents matching archived milestone * phases that share the same numbers as current milestone phases. */ function stripShippedMilestones(content) { return content.replace(/
[\s\S]*?<\/details>/gi, ''); } /** * Extract the current milestone section from ROADMAP.md by positive lookup. * * Instead of stripping
blocks (negative heuristic that breaks if * agents wrap the current milestone in
), this finds the section * matching the current milestone version and returns only that content. * * Falls back to stripShippedMilestones() if: * - cwd is not provided * - STATE.md doesn't exist or has no milestone field * - Version can't be found in ROADMAP.md * * @param {string} content - Full ROADMAP.md content * @param {string} [cwd] - Working directory for reading STATE.md * @returns {string} Content scoped to current milestone */ function extractCurrentMilestone(content, cwd) { if (!cwd) return stripShippedMilestones(content); // 1. Get current milestone version from STATE.md frontmatter let version = null; try { const statePath = path.join(planningDir(cwd), 'STATE.md'); if (fs.existsSync(statePath)) { const stateRaw = fs.readFileSync(statePath, 'utf-8'); const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m); if (milestoneMatch) { version = milestoneMatch[1].trim(); } } } catch {} // 2. Fallback: derive version from getMilestoneInfo pattern in ROADMAP.md itself if (!version) { // Check for 🚧 in-progress marker const inProgressMatch = content.match(/🚧\s*\*\*v(\d+\.\d+)\s/); if (inProgressMatch) { version = 'v' + inProgressMatch[1]; } } if (!version) return stripShippedMilestones(content); // 3. Find the section matching this version // Match headings like: ## Roadmap v3.0: Name, ## v3.0 Name, etc. const escapedVersion = escapeRegex(version); const sectionPattern = new RegExp( `(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`, 'mi' ); const sectionMatch = content.match(sectionPattern); if (!sectionMatch) return stripShippedMilestones(content); const sectionStart = sectionMatch.index; // Find the end: next milestone heading at same or higher level, or EOF // Milestone headings look like: ## v2.0, ## Roadmap v2.0, ## ✅ v1.0, etc. const headingLevel = sectionMatch[1].match(/^(#{1,3})\s/)[1].length; const restContent = content.slice(sectionStart + sectionMatch[0].length); const nextMilestonePattern = new RegExp( `^#{1,${headingLevel}}\\s+(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'mi' ); const nextMatch = restContent.match(nextMilestonePattern); let sectionEnd; if (nextMatch) { sectionEnd = sectionStart + sectionMatch[0].length + nextMatch.index; } else { sectionEnd = content.length; } // Return everything before the current milestone section (non-milestone content // like title, overview) plus the current milestone section const beforeMilestones = content.slice(0, sectionStart); const currentSection = content.slice(sectionStart, sectionEnd); // Also include any content before the first milestone heading (title, overview, etc.) // but strip any
blocks in it (these are definitely shipped) const preamble = beforeMilestones.replace(/
[\s\S]*?<\/details>/gi, ''); return preamble + currentSection; } /** * Replace a pattern only in the current milestone section of ROADMAP.md * (everything after the last
close tag). Used for write operations * that must not accidentally modify archived milestone checkboxes/tables. */ function replaceInCurrentMilestone(content, pattern, replacement) { const lastDetailsClose = content.lastIndexOf('
'); if (lastDetailsClose === -1) { return content.replace(pattern, replacement); } const offset = lastDetailsClose + '
'.length; const before = content.slice(0, offset); const after = content.slice(offset); return before + after.replace(pattern, replacement); } // ─── Roadmap & model utilities ──────────────────────────────────────────────── function getRoadmapPhaseInternal(cwd, phaseNum) { if (!phaseNum) return null; const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); if (!fs.existsSync(roadmapPath)) return null; try { const content = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd); const escapedPhase = escapeRegex(phaseNum.toString()); // Match both numeric (Phase 1:) and custom (Phase PROJ-42:) headers 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+[\w]/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; } } // ─── Agent installation validation (#1371) ─────────────────────────────────── /** * Resolve the agents directory from the GSD install location. * gsd-tools.cjs lives at /get-shit-done/bin/gsd-tools.cjs, * so agents/ is at /agents/. * * @returns {string} Absolute path to the agents directory */ function getAgentsDir() { // __dirname is get-shit-done/bin/lib/ → go up 3 levels to configDir return path.join(__dirname, '..', '..', '..', 'agents'); } /** * Check which GSD agents are installed on disk. * Returns an object with installation status and details. * * @returns {{ agents_installed: boolean, missing_agents: string[], installed_agents: string[], agents_dir: string }} */ function checkAgentsInstalled() { const agentsDir = getAgentsDir(); const expectedAgents = Object.keys(MODEL_PROFILES); const installed = []; const missing = []; if (!fs.existsSync(agentsDir)) { return { agents_installed: false, missing_agents: expectedAgents, installed_agents: [], agents_dir: agentsDir, }; } for (const agent of expectedAgents) { const agentFile = path.join(agentsDir, `${agent}.md`); if (fs.existsSync(agentFile)) { installed.push(agent); } else { missing.push(agent); } } return { agents_installed: installed.length > 0 && missing.length === 0, missing_agents: missing, installed_agents: installed, agents_dir: agentsDir, }; } // ─── Model alias resolution ─────────────────────────────────────────────────── /** * Map short model aliases to full model IDs. * Updated each release to match current model versions. * Users can override with model_overrides in config.json for custom/latest models. */ const MODEL_ALIAS_MAP = { 'opus': 'claude-opus-4-0', 'sonnet': 'claude-sonnet-4-5', 'haiku': 'claude-haiku-3-5', }; function resolveModelInternal(cwd, agentType) { const config = loadConfig(cwd); // Check per-agent override first — always respected regardless of resolve_model_ids. // Users who set fully-qualified model IDs (e.g., "openai/gpt-5.4") get exactly that. const override = config.model_overrides?.[agentType]; if (override) { return override; } // resolve_model_ids: "omit" — return empty string so the runtime uses its configured // default model. For non-the agent runtimes (OpenCode, Codex, etc.) that don't recognize // the agent aliases (opus/sonnet/haiku/inherit). Set automatically during install. See #1156. if (config.resolve_model_ids === 'omit') { return ''; } // Fall back to profile lookup const profile = String(config.model_profile || 'balanced').toLowerCase(); const agentModels = MODEL_PROFILES[agentType]; if (!agentModels) return 'sonnet'; if (profile === 'inherit') return 'inherit'; const alias = agentModels[profile] || agentModels['balanced'] || 'sonnet'; // resolve_model_ids: true — map alias to full the agent model ID // Prevents 404s when the Task tool passes aliases directly to the API if (config.resolve_model_ids) { return MODEL_ALIAS_MAP[alias] || alias; } return alias; } // ─── Summary body helpers ───────────────────────────────────────────────── /** * Extract a one-liner from the summary body when it's not in frontmatter. * The summary template defines one-liner as a bold markdown line after the heading: * # Phase X: Name Summary * **[substantive one-liner text]** */ function extractOneLinerFromBody(content) { if (!content) return null; // Strip frontmatter first const body = content.replace(/^---\n[\s\S]*?\n---\n*/, ''); // Find the first **...** line after a # heading const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m); return match ? match[1].trim() : null; } // ─── 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(planningDir(cwd), 'ROADMAP.md'), 'utf-8'); // First: check for list-format roadmaps using 🚧 (in-progress) marker // e.g. "- 🚧 **v2.1 Belgium** — Phases 24-28 (in progress)" // e.g. "- 🚧 **v1.2.1 Tech Debt** — Phases 1-8 (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 = stripShippedMilestones(roadmap); // Extract version and name from the same ## heading for consistency // Supports 2+ segment versions: v1.2, v1.2.1, v2.0.1, etc. 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 (greedy — capture longest version string) 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 = extractCurrentMilestone(fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd); // Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:) const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi; let m; while ((m = phasePattern.exec(roadmap)) !== null) { milestonePhaseNums.add(m[1]); } } catch { /* intentionally empty */ } 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) { // Try numeric match first const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/); if (m && normalized.has(m[1].toLowerCase())) return true; // Try custom ID match (e.g. PROJ-42-description → PROJ-42) const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/); if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true; return false; } isDirInMilestone.phaseCount = milestonePhaseNums.size; return isDirInMilestone; } // ─── Phase file helpers ────────────────────────────────────────────────────── /** Filter a file list to just PLAN.md / *-PLAN.md entries. */ function filterPlanFiles(files) { return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); } /** Filter a file list to just SUMMARY.md / *-SUMMARY.md entries. */ function filterSummaryFiles(files) { return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); } /** * Read a phase directory and return counts/flags for common file types. * Returns an object with plans[], summaries[], and boolean flags for * research/context/verification files. */ function getPhaseFileStats(phaseDir) { const files = fs.readdirSync(phaseDir); return { plans: filterPlanFiles(files), summaries: filterSummaryFiles(files), hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'), hasContext: files.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'), hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'), hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'), }; } /** * Read immediate child directories from a path. * Returns [] if the path doesn't exist or can't be read. * Pass sort=true to apply comparePhaseNum ordering. */ function readSubdirectories(dirPath, sort = false) { try { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs; } catch { return []; } } module.exports = { output, error, safeReadFile, loadConfig, isGitIgnored, execGit, normalizeMd, escapeRegex, normalizePhaseName, comparePhaseNum, searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs, getRoadmapPhaseInternal, resolveModelInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, extractOneLinerFromBody, resolveWorktreeRoot, withPlanningLock, findProjectRoot, detectSubRepos, reapStaleTempFiles, MODEL_ALIAS_MAP, planningDir, planningRoot, planningPaths, getActiveWorkstream, setActiveWorkstream, filterPlanFiles, filterSummaryFiles, getPhaseFileStats, readSubdirectories, getAgentsDir, checkAgentsInstalled, };