Files
get-shit-done/get-shit-done/bin/lib/core.cjs
0Shard 7156f02ed5 fix: semver 3+ segment parsing and CRLF frontmatter corruption recovery
- fix(core): getMilestoneInfo() version regex `\d+\.\d+` only matched
  2-segment versions (v1.2). Changed to `\d+(?:\.\d+)+` to support
  3+ segments (v1.2.1, v2.0.1). Same fix in roadmap.cjs milestone
  extraction pattern.

- fix(state): stripFrontmatter() used `^---\n` (LF-only) which failed
  to strip CRLF frontmatter blocks. When STATE.md had dual frontmatter
  blocks from prior CRLF corruption, each writeStateMd() call preserved
  the stale block and prepended a new wrong one. Now handles CRLF and
  strips all stacked frontmatter blocks.

- fix(frontmatter): extractFrontmatter() always used the first ---
  block. When dual blocks exist from corruption, the first is stale.
  Now uses the last block (most recent sync).
2026-03-17 17:18:48 +02:00

605 lines
22 KiB
JavaScript

/**
* Core — Shared utilities, constants, and internal helpers
*/
const fs = require('fs');
const path = require('path');
const { execSync, 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('/');
}
// ─── 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 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) {
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;
}
}
// ─── 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(),
};
}
// ─── 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 milestone scoping ───────────────────────────────────────────────
/**
* Strip shipped milestone content wrapped in <details> 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(/<details>[\s\S]*?<\/details>/gi, '');
}
/**
* Replace a pattern only in the current milestone section of ROADMAP.md
* (everything after the last </details> close tag). Used for write operations
* that must not accidentally modify archived milestone checkboxes/tables.
*/
function replaceInCurrentMilestone(content, pattern, replacement) {
const lastDetailsClose = content.lastIndexOf('</details>');
if (lastDetailsClose === -1) {
return content.replace(pattern, replacement);
}
const offset = lastDetailsClose + '</details>'.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(cwd, '.planning', 'ROADMAP.md');
if (!fs.existsSync(roadmapPath)) return null;
try {
const content = stripShippedMilestones(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;
}
// 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';
return agentModels[profile] || agentModels['balanced'] || 'sonnet';
}
// ─── 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)"
// 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 <details> 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 = stripShippedMilestones(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 = {
output,
error,
safeReadFile,
loadConfig,
isGitIgnored,
execGit,
normalizeMd,
escapeRegex,
normalizePhaseName,
comparePhaseNum,
searchPhaseInDir,
findPhaseInternal,
getArchivedPhaseDirs,
getRoadmapPhaseInternal,
resolveModelInternal,
pathExistsInternal,
generateSlugInternal,
getMilestoneInfo,
getMilestonePhaseFilter,
stripShippedMilestones,
replaceInCurrentMilestone,
toPosixPath,
};