From 5f95fea4d7a8cf7dc729e58c20f3b9f079c933f8 Mon Sep 17 00:00:00 2001 From: SalesTeamToolbox Date: Fri, 20 Mar 2026 23:00:40 -0700 Subject: [PATCH] refactor: extract shared utilities to reduce duplication across codebase - core.cjs: add filterPlanFiles, filterSummaryFiles, getPhaseFileStats, readSubdirectories helpers; use them in searchPhaseInDir and getArchivedPhaseDirs - gsd-tools.cjs: add parseNamedArgs() helper; replace ~50 repetitive indexOf/ternary patterns in state record-metric, add-decision, add-blocker, record-session, begin-phase, signal-waiting, template fill, and frontmatter subcommands - phase.cjs: decompose 250-line cmdPhaseRemove into renameDecimalPhases(), renameIntegerPhases(), and updateRoadmapAfterPhaseRemoval(); import readSubdirectories - workstream.cjs: import stateExtractField from state.cjs and shared helpers from core.cjs; replace all inline regex state parsing and readdirSync+filter+map patterns All 1062 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- get-shit-done/bin/gsd-tools.cjs | 139 +++++------ get-shit-done/bin/lib/core.cjs | 65 ++++- get-shit-done/bin/lib/phase.cjs | 339 ++++++++++----------------- get-shit-done/bin/lib/workstream.cjs | 109 ++++----- 4 files changed, 281 insertions(+), 371 deletions(-) diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 8f72776d6..aa6abfa90 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -151,6 +151,30 @@ const profilePipeline = require('./lib/profile-pipeline.cjs'); const profileOutput = require('./lib/profile-output.cjs'); const workstream = require('./lib/workstream.cjs'); +// ─── Arg parsing helpers ────────────────────────────────────────────────────── + +/** + * Extract named --flag pairs from an args array. + * Returns an object mapping flag names to their values (null if absent). + * Flags listed in `booleanFlags` are treated as boolean (no value consumed). + * + * parseNamedArgs(args, 'phase', 'plan') → { phase: '3', plan: '1' } + * parseNamedArgs(args, [], ['amend', 'force']) → { amend: true, force: false } + */ +function parseNamedArgs(args, valueFlags = [], booleanFlags = []) { + const result = {}; + for (const flag of valueFlags) { + const idx = args.indexOf(`--${flag}`); + result[flag] = idx !== -1 && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--') + ? args[idx + 1] + : null; + } + for (const flag of booleanFlags) { + result[flag] = args.includes(`--${flag}`); + } + return result; +} + // ─── CLI Router ─────────────────────────────────────────────────────────────── async function main() { @@ -329,74 +353,27 @@ async function runCommand(command, args, cwd, raw) { } else if (subcommand === 'advance-plan') { state.cmdStateAdvancePlan(cwd, raw); } else if (subcommand === 'record-metric') { - const phaseIdx = args.indexOf('--phase'); - const planIdx = args.indexOf('--plan'); - const durationIdx = args.indexOf('--duration'); - const tasksIdx = args.indexOf('--tasks'); - const filesIdx = args.indexOf('--files'); - state.cmdStateRecordMetric(cwd, { - phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null, - plan: planIdx !== -1 ? args[planIdx + 1] : null, - duration: durationIdx !== -1 ? args[durationIdx + 1] : null, - tasks: tasksIdx !== -1 ? args[tasksIdx + 1] : null, - files: filesIdx !== -1 ? args[filesIdx + 1] : null, - }, raw); + const { phase: p, plan, duration, tasks, files } = parseNamedArgs(args, ['phase', 'plan', 'duration', 'tasks', 'files']); + state.cmdStateRecordMetric(cwd, { phase: p, plan, duration, tasks, files }, raw); } else if (subcommand === 'update-progress') { state.cmdStateUpdateProgress(cwd, raw); } else if (subcommand === 'add-decision') { - const phaseIdx = args.indexOf('--phase'); - const summaryIdx = args.indexOf('--summary'); - const summaryFileIdx = args.indexOf('--summary-file'); - const rationaleIdx = args.indexOf('--rationale'); - const rationaleFileIdx = args.indexOf('--rationale-file'); - state.cmdStateAddDecision(cwd, { - phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null, - summary: summaryIdx !== -1 ? args[summaryIdx + 1] : null, - summary_file: summaryFileIdx !== -1 ? args[summaryFileIdx + 1] : null, - rationale: rationaleIdx !== -1 ? args[rationaleIdx + 1] : '', - rationale_file: rationaleFileIdx !== -1 ? args[rationaleFileIdx + 1] : null, - }, raw); + const { phase: p, summary, 'summary-file': summary_file, rationale, 'rationale-file': rationale_file } = parseNamedArgs(args, ['phase', 'summary', 'summary-file', 'rationale', 'rationale-file']); + state.cmdStateAddDecision(cwd, { phase: p, summary, summary_file, rationale: rationale || '', rationale_file }, raw); } else if (subcommand === 'add-blocker') { - const textIdx = args.indexOf('--text'); - const textFileIdx = args.indexOf('--text-file'); - state.cmdStateAddBlocker(cwd, { - text: textIdx !== -1 ? args[textIdx + 1] : null, - text_file: textFileIdx !== -1 ? args[textFileIdx + 1] : null, - }, raw); + const { text, 'text-file': text_file } = parseNamedArgs(args, ['text', 'text-file']); + state.cmdStateAddBlocker(cwd, { text, text_file }, raw); } else if (subcommand === 'resolve-blocker') { - const textIdx = args.indexOf('--text'); - state.cmdStateResolveBlocker(cwd, textIdx !== -1 ? args[textIdx + 1] : null, raw); + state.cmdStateResolveBlocker(cwd, parseNamedArgs(args, ['text']).text, raw); } else if (subcommand === 'record-session') { - const stoppedIdx = args.indexOf('--stopped-at'); - const resumeIdx = args.indexOf('--resume-file'); - state.cmdStateRecordSession(cwd, { - stopped_at: stoppedIdx !== -1 ? args[stoppedIdx + 1] : null, - resume_file: resumeIdx !== -1 ? args[resumeIdx + 1] : 'None', - }, raw); + const { 'stopped-at': stopped_at, 'resume-file': resume_file } = parseNamedArgs(args, ['stopped-at', 'resume-file']); + state.cmdStateRecordSession(cwd, { stopped_at, resume_file: resume_file || 'None' }, raw); } else if (subcommand === 'begin-phase') { - const phaseIdx = args.indexOf('--phase'); - const nameIdx = args.indexOf('--name'); - const plansIdx = args.indexOf('--plans'); - state.cmdStateBeginPhase( - cwd, - phaseIdx !== -1 ? args[phaseIdx + 1] : null, - nameIdx !== -1 ? args[nameIdx + 1] : null, - plansIdx !== -1 ? parseInt(args[plansIdx + 1], 10) : null, - raw - ); + const { phase: p, name, plans } = parseNamedArgs(args, ['phase', 'name', 'plans']); + state.cmdStateBeginPhase(cwd, p, name, plans !== null ? parseInt(plans, 10) : null, raw); } else if (subcommand === 'signal-waiting') { - const typeIdx = args.indexOf('--type'); - const qIdx = args.indexOf('--question'); - const optIdx = args.indexOf('--options'); - const phaseIdx = args.indexOf('--phase'); - state.cmdSignalWaiting( - cwd, - typeIdx !== -1 ? args[typeIdx + 1] : null, - qIdx !== -1 ? args[qIdx + 1] : null, - optIdx !== -1 ? args[optIdx + 1] : null, - phaseIdx !== -1 ? args[phaseIdx + 1] : null, - raw - ); + const { type, question, options, phase: p } = parseNamedArgs(args, ['type', 'question', 'options', 'phase']); + state.cmdSignalWaiting(cwd, type, question, options, p, raw); } else if (subcommand === 'signal-resume') { state.cmdSignalResume(cwd, raw); } else { @@ -452,24 +429,18 @@ async function runCommand(command, args, cwd, raw) { template.cmdTemplateSelect(cwd, args[2], raw); } else if (subcommand === 'fill') { const templateType = args[2]; - const phaseIdx = args.indexOf('--phase'); - const planIdx = args.indexOf('--plan'); - const nameIdx = args.indexOf('--name'); - const typeIdx = args.indexOf('--type'); - const waveIdx = args.indexOf('--wave'); - const fieldsIdx = args.indexOf('--fields'); + const { phase, plan, name, type, wave, fields: fieldsRaw } = parseNamedArgs(args, ['phase', 'plan', 'name', 'type', 'wave', 'fields']); + let fields = {}; + if (fieldsRaw) { + const { safeJsonParse } = require('./lib/security.cjs'); + const result = safeJsonParse(fieldsRaw, { label: '--fields' }); + if (!result.ok) error(result.error); + fields = result.value; + } template.cmdTemplateFill(cwd, templateType, { - phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null, - plan: planIdx !== -1 ? args[planIdx + 1] : null, - name: nameIdx !== -1 ? args[nameIdx + 1] : null, - type: typeIdx !== -1 ? args[typeIdx + 1] : 'execute', - wave: waveIdx !== -1 ? args[waveIdx + 1] : '1', - fields: fieldsIdx !== -1 ? (() => { - const { safeJsonParse } = require('./lib/security.cjs'); - const result = safeJsonParse(args[fieldsIdx + 1], { label: '--fields' }); - if (!result.ok) error(result.error); - return result.value; - })() : {}, + phase, plan, name, fields, + type: type || 'execute', + wave: wave || '1', }, raw); } else { error('Unknown template subcommand. Available: select, fill'); @@ -481,18 +452,14 @@ async function runCommand(command, args, cwd, raw) { const subcommand = args[1]; const file = args[2]; if (subcommand === 'get') { - const fieldIdx = args.indexOf('--field'); - frontmatter.cmdFrontmatterGet(cwd, file, fieldIdx !== -1 ? args[fieldIdx + 1] : null, raw); + frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgs(args, ['field']).field, raw); } else if (subcommand === 'set') { - const fieldIdx = args.indexOf('--field'); - const valueIdx = args.indexOf('--value'); - frontmatter.cmdFrontmatterSet(cwd, file, fieldIdx !== -1 ? args[fieldIdx + 1] : null, valueIdx !== -1 ? args[valueIdx + 1] : undefined, raw); + const { field, value } = parseNamedArgs(args, ['field', 'value']); + frontmatter.cmdFrontmatterSet(cwd, file, field, value !== null ? value : undefined, raw); } else if (subcommand === 'merge') { - const dataIdx = args.indexOf('--data'); - frontmatter.cmdFrontmatterMerge(cwd, file, dataIdx !== -1 ? args[dataIdx + 1] : null, raw); + frontmatter.cmdFrontmatterMerge(cwd, file, parseNamedArgs(args, ['data']).data, raw); } else if (subcommand === 'validate') { - const schemaIdx = args.indexOf('--schema'); - frontmatter.cmdFrontmatterValidate(cwd, file, schemaIdx !== -1 ? args[schemaIdx + 1] : null, raw); + frontmatter.cmdFrontmatterValidate(cwd, file, parseNamedArgs(args, ['schema']).schema, raw); } else { error('Unknown frontmatter subcommand. Available: get, set, merge, validate'); } diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 65ce7be88..b5ffdfe5c 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -643,8 +643,7 @@ function comparePhaseNum(a, b) { 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 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; @@ -661,14 +660,9 @@ function searchPhaseInDir(baseDir, relBase, normalized) { 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 hasReviews = phaseFiles.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'); + 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', '')) @@ -753,8 +747,7 @@ function getArchivedPhaseDirs(cwd) { 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)); + const dirs = readSubdirectories(archivePath, true); for (const dir of dirs) { results.push({ @@ -1077,6 +1070,50 @@ function getMilestonePhaseFilter(cwd) { 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, @@ -1113,4 +1150,8 @@ module.exports = { planningPaths, getActiveWorkstream, setActiveWorkstream, + filterPlanFiles, + filterSummaryFiles, + getPhaseFileStats, + readSubdirectories, }; diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index 97a075316..cfb9d3669 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); -const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, output, error } = require('./core.cjs'); +const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, output, error, readSubdirectories } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); const { writeStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } = require('./state.cjs'); @@ -461,256 +461,169 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) { output(result, raw, decimalPhase); } -function cmdPhaseRemove(cwd, targetPhase, options, raw) { - if (!targetPhase) { - error('phase number required for phase remove'); +/** + * Renumber sibling decimal phases after a decimal phase is removed. + * e.g. removing 06.2 → 06.3 becomes 06.2, 06.4 becomes 06.3, etc. + * Returns { renamedDirs, renamedFiles }. + */ +function renameDecimalPhases(phasesDir, baseInt, removedDecimal) { + const renamedDirs = [], renamedFiles = []; + const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`); + const dirs = readSubdirectories(phasesDir, true); + const toRename = dirs + .map(dir => { const m = dir.match(decPattern); return m ? { dir, oldDecimal: parseInt(m[1], 10), slug: m[2] } : null; }) + .filter(item => item && item.oldDecimal > removedDecimal) + .sort((a, b) => b.oldDecimal - a.oldDecimal); // descending to avoid conflicts + + for (const item of toRename) { + const newDecimal = item.oldDecimal - 1; + const oldPhaseId = `${baseInt}.${item.oldDecimal}`; + const newPhaseId = `${baseInt}.${newDecimal}`; + const newDirName = `${baseInt}.${newDecimal}-${item.slug}`; + fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); + renamedDirs.push({ from: item.dir, to: newDirName }); + for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) { + if (f.includes(oldPhaseId)) { + const newFileName = f.replace(oldPhaseId, newPhaseId); + fs.renameSync(path.join(phasesDir, newDirName, f), path.join(phasesDir, newDirName, newFileName)); + renamedFiles.push({ from: f, to: newFileName }); + } + } } + return { renamedDirs, renamedFiles }; +} + +/** + * Renumber all integer phases after removedInt. + * e.g. removing phase 5 → phase 6 becomes 5, phase 7 becomes 6, etc. + * Returns { renamedDirs, renamedFiles }. + */ +function renameIntegerPhases(phasesDir, removedInt) { + const renamedDirs = [], renamedFiles = []; + const dirs = readSubdirectories(phasesDir, true); + const toRename = dirs + .map(dir => { + const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i); + if (!m) return null; + const dirInt = parseInt(m[1], 10); + return dirInt > removedInt ? { dir, oldInt: dirInt, letter: m[2] ? m[2].toUpperCase() : '', decimal: m[3] ? parseInt(m[3], 10) : null, slug: m[4] } : null; + }) + .filter(Boolean) + .sort((a, b) => a.oldInt !== b.oldInt ? b.oldInt - a.oldInt : (b.decimal || 0) - (a.decimal || 0)); + + for (const item of toRename) { + const newInt = item.oldInt - 1; + const newPadded = String(newInt).padStart(2, '0'); + const oldPadded = String(item.oldInt).padStart(2, '0'); + const letterSuffix = item.letter || ''; + const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : ''; + const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`; + const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`; + const newDirName = `${newPrefix}-${item.slug}`; + fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); + renamedDirs.push({ from: item.dir, to: newDirName }); + for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) { + if (f.startsWith(oldPrefix)) { + const newFileName = newPrefix + f.slice(oldPrefix.length); + fs.renameSync(path.join(phasesDir, newDirName, f), path.join(phasesDir, newDirName, newFileName)); + renamedFiles.push({ from: f, to: newFileName }); + } + } + } + return { renamedDirs, renamedFiles }; +} + +/** + * Remove a phase section from ROADMAP.md and renumber all subsequent integer phases. + */ +function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt) { + let content = fs.readFileSync(roadmapPath, 'utf-8'); + const escaped = escapeRegex(targetPhase); + + content = content.replace(new RegExp(`\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i'), ''); + content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), ''); + content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), ''); + + if (!isDecimal) { + const MAX_PHASE = 99; + for (let oldNum = MAX_PHASE; oldNum > removedInt; oldNum--) { + const newNum = oldNum - 1; + const oldStr = String(oldNum), newStr = String(newNum); + const oldPad = oldStr.padStart(2, '0'), newPad = newStr.padStart(2, '0'); + content = content.replace(new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), `$1${newStr}$2`); + content = content.replace(new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), `$1${newStr}$2`); + content = content.replace(new RegExp(`${oldPad}-(\\d{2})`, 'g'), `${newPad}-$1`); + content = content.replace(new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), `$1${newStr}. `); + content = content.replace(new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), `$1${newStr}`); + } + } + + fs.writeFileSync(roadmapPath, content, 'utf-8'); +} + +function cmdPhaseRemove(cwd, targetPhase, options, raw) { + if (!targetPhase) error('phase number required for phase remove'); const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); const phasesDir = path.join(planningDir(cwd), 'phases'); - const force = options.force || false; - if (!fs.existsSync(roadmapPath)) { - error('ROADMAP.md not found'); - } + if (!fs.existsSync(roadmapPath)) error('ROADMAP.md not found'); - // Normalize the target const normalized = normalizePhaseName(targetPhase); const isDecimal = targetPhase.includes('.'); + const force = options.force || false; - // Find and validate target directory - let targetDir = null; - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); - targetDir = dirs.find(d => d.startsWith(normalized + '-') || d === normalized); - } catch { /* intentionally empty */ } + // Find target directory + const targetDir = readSubdirectories(phasesDir, true) + .find(d => d.startsWith(normalized + '-') || d === normalized) || null; - // Check for executed work (SUMMARY.md files) + // Guard against removing executed work if (targetDir && !force) { - const targetPath = path.join(phasesDir, targetDir); - const files = fs.readdirSync(targetPath); + const files = fs.readdirSync(path.join(phasesDir, targetDir)); const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); if (summaries.length > 0) { error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`); } } - // Delete target directory - if (targetDir) { - fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true }); - } + if (targetDir) fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true }); - // Renumber subsequent phases - const renamedDirs = []; - const renamedFiles = []; - - if (isDecimal) { - // Decimal removal: renumber sibling decimals (e.g., removing 06.2 → 06.3 becomes 06.2) - const baseParts = normalized.split('.'); - const baseInt = baseParts[0]; - const removedDecimal = parseInt(baseParts[1], 10); - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); - - // Find sibling decimals with higher numbers - const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`); - const toRename = []; - for (const dir of dirs) { - const dm = dir.match(decPattern); - if (dm && parseInt(dm[1], 10) > removedDecimal) { - toRename.push({ dir, oldDecimal: parseInt(dm[1], 10), slug: dm[2] }); - } - } - - // Sort descending to avoid conflicts - toRename.sort((a, b) => b.oldDecimal - a.oldDecimal); - - for (const item of toRename) { - const newDecimal = item.oldDecimal - 1; - const oldPhaseId = `${baseInt}.${item.oldDecimal}`; - const newPhaseId = `${baseInt}.${newDecimal}`; - const newDirName = `${baseInt}.${newDecimal}-${item.slug}`; - - // Rename directory - fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); - renamedDirs.push({ from: item.dir, to: newDirName }); - - // Rename files inside - const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName)); - for (const f of dirFiles) { - // Files may have phase prefix like "06.2-01-PLAN.md" - if (f.includes(oldPhaseId)) { - const newFileName = f.replace(oldPhaseId, newPhaseId); - fs.renameSync( - path.join(phasesDir, newDirName, f), - path.join(phasesDir, newDirName, newFileName) - ); - renamedFiles.push({ from: f, to: newFileName }); - } - } - } - } catch { /* intentionally empty */ } - - } else { - // Integer removal: renumber all subsequent integer phases - const removedInt = parseInt(normalized, 10); - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); - - // Collect directories that need renumbering (integer phases > removed, and their decimals/letters) - const toRename = []; - for (const dir of dirs) { - const dm = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i); - if (!dm) continue; - const dirInt = parseInt(dm[1], 10); - if (dirInt > removedInt) { - toRename.push({ - dir, - oldInt: dirInt, - letter: dm[2] ? dm[2].toUpperCase() : '', - decimal: dm[3] ? parseInt(dm[3], 10) : null, - slug: dm[4], - }); - } - } - - // Sort descending to avoid conflicts - toRename.sort((a, b) => { - if (a.oldInt !== b.oldInt) return b.oldInt - a.oldInt; - return (b.decimal || 0) - (a.decimal || 0); - }); - - for (const item of toRename) { - const newInt = item.oldInt - 1; - const newPadded = String(newInt).padStart(2, '0'); - const oldPadded = String(item.oldInt).padStart(2, '0'); - const letterSuffix = item.letter || ''; - const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : ''; - const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`; - const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`; - const newDirName = `${newPrefix}-${item.slug}`; - - // Rename directory - fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); - renamedDirs.push({ from: item.dir, to: newDirName }); - - // Rename files inside - const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName)); - for (const f of dirFiles) { - if (f.startsWith(oldPrefix)) { - const newFileName = newPrefix + f.slice(oldPrefix.length); - fs.renameSync( - path.join(phasesDir, newDirName, f), - path.join(phasesDir, newDirName, newFileName) - ); - renamedFiles.push({ from: f, to: newFileName }); - } - } - } - } catch { /* intentionally empty */ } - } + // Renumber subsequent phases on disk + let renamedDirs = [], renamedFiles = []; + try { + const renamed = isDecimal + ? renameDecimalPhases(phasesDir, normalized.split('.')[0], parseInt(normalized.split('.')[1], 10)) + : renameIntegerPhases(phasesDir, parseInt(normalized, 10)); + renamedDirs = renamed.renamedDirs; + renamedFiles = renamed.renamedFiles; + } catch { /* intentionally empty */ } // Update ROADMAP.md - let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); - - // Remove the target phase section - const targetEscaped = escapeRegex(targetPhase); - const sectionPattern = new RegExp( - `\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, - 'i' - ); - roadmapContent = roadmapContent.replace(sectionPattern, ''); - - // Remove from phase list (checkbox) - const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi'); - roadmapContent = roadmapContent.replace(checkboxPattern, ''); - - // Remove from progress table - const tableRowPattern = new RegExp(`\\n?\\|\\s*${targetEscaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'); - roadmapContent = roadmapContent.replace(tableRowPattern, ''); - - // Renumber references in ROADMAP for subsequent phases - if (!isDecimal) { - const removedInt = parseInt(normalized, 10); - - // Collect all integer phases > removedInt - const maxPhase = 99; // reasonable upper bound - for (let oldNum = maxPhase; oldNum > removedInt; oldNum--) { - const newNum = oldNum - 1; - const oldStr = String(oldNum); - const newStr = String(newNum); - const oldPad = oldStr.padStart(2, '0'); - const newPad = newStr.padStart(2, '0'); - - // Phase headings: ## Phase 18: or ### Phase 18: → ## Phase 17: or ### Phase 17: - roadmapContent = roadmapContent.replace( - new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), - `$1${newStr}$2` - ); - - // Checkbox items: - [ ] **Phase 18:** → - [ ] **Phase 17:** - roadmapContent = roadmapContent.replace( - new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), - `$1${newStr}$2` - ); - - // Plan references: 18-01 → 17-01 - roadmapContent = roadmapContent.replace( - new RegExp(`${oldPad}-(\\d{2})`, 'g'), - `${newPad}-$1` - ); - - // Table rows: | 18. → | 17. - roadmapContent = roadmapContent.replace( - new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), - `$1${newStr}. ` - ); - - // Depends on references - roadmapContent = roadmapContent.replace( - new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), - `$1${newStr}` - ); - } - } - - fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); + updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10)); // Update STATE.md phase count const statePath = path.join(planningDir(cwd), 'STATE.md'); if (fs.existsSync(statePath)) { let stateContent = fs.readFileSync(statePath, 'utf-8'); - // Update "Total Phases" field — supports both bold and plain formats const totalRaw = stateExtractField(stateContent, 'Total Phases'); if (totalRaw) { - const oldTotal = parseInt(totalRaw, 10); - stateContent = stateReplaceField(stateContent, 'Total Phases', String(oldTotal - 1)) || stateContent; + stateContent = stateReplaceField(stateContent, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) || stateContent; } - // Update "Phase: X of Y" pattern - const ofPattern = /(\bof\s+)(\d+)(\s*(?:\(|phases?))/i; - const ofMatch = stateContent.match(ofPattern); + const ofMatch = stateContent.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i); if (ofMatch) { - const oldTotal = parseInt(ofMatch[2], 10); - stateContent = stateContent.replace(ofPattern, `$1${oldTotal - 1}$3`); + stateContent = stateContent.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${parseInt(ofMatch[2], 10) - 1}$3`); } writeStateMd(statePath, stateContent, cwd); } - const result = { + output({ removed: targetPhase, - directory_deleted: targetDir || null, + directory_deleted: targetDir, renamed_directories: renamedDirs, renamed_files: renamedFiles, roadmap_updated: true, state_updated: fs.existsSync(statePath), - }; - - output(result, raw); + }, raw); } function cmdPhaseComplete(cwd, phaseNum, raw) { diff --git a/get-shit-done/bin/lib/workstream.cjs b/get-shit-done/bin/lib/workstream.cjs index 2aeaf63ec..63e209c5b 100644 --- a/get-shit-done/bin/lib/workstream.cjs +++ b/get-shit-done/bin/lib/workstream.cjs @@ -10,7 +10,8 @@ const fs = require('fs'); const path = require('path'); -const { output, error, planningPaths, planningRoot, toPosixPath, getMilestoneInfo, generateSlugInternal, setActiveWorkstream, getActiveWorkstream } = require('./core.cjs'); +const { output, error, planningPaths, planningRoot, toPosixPath, getMilestoneInfo, generateSlugInternal, setActiveWorkstream, getActiveWorkstream, filterPlanFiles, filterSummaryFiles, readSubdirectories } = require('./core.cjs'); +const { stateExtractField } = require('./state.cjs'); // ─── Migration ────────────────────────────────────────────────────────────── @@ -184,25 +185,23 @@ function cmdWorkstreamList(cwd, raw) { const wsDir = path.join(wsRoot, entry.name); const phasesDir = path.join(wsDir, 'phases'); - let phaseCount = 0, completedCount = 0; - try { - const dirs = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory()); - phaseCount = dirs.length; - for (const d of dirs) { - const files = fs.readdirSync(path.join(phasesDir, d.name)); - const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const phaseDirs = readSubdirectories(phasesDir); + const phaseCount = phaseDirs.length; + let completedCount = 0; + for (const d of phaseDirs) { + try { + const phaseFiles = fs.readdirSync(path.join(phasesDir, d)); + const plans = filterPlanFiles(phaseFiles); + const summaries = filterSummaryFiles(phaseFiles); if (plans.length > 0 && summaries.length >= plans.length) completedCount++; - } - } catch {} + } catch {} + } let status = 'unknown', currentPhase = null; try { const stateContent = fs.readFileSync(path.join(wsDir, 'STATE.md'), 'utf-8'); - const statusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/); - if (statusMatch) status = statusMatch[1].trim(); - const phaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(.+)/); - if (phaseMatch) currentPhase = phaseMatch[1].trim(); + status = stateExtractField(stateContent, 'Status') || 'unknown'; + currentPhase = stateExtractField(stateContent, 'Current Phase'); } catch {} workstreams.push({ @@ -240,14 +239,11 @@ function cmdWorkstreamStatus(cwd, name, raw) { }; const phases = []; - try { - const dirs = fs.readdirSync(p.phases, { withFileTypes: true }) - .filter(e => e.isDirectory()).map(e => e.name).sort(); - for (const dir of dirs) { - const phasePath = path.join(p.phases, dir); - const phaseFiles = fs.readdirSync(phasePath); - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + for (const dir of readSubdirectories(p.phases).sort()) { + try { + const phaseFiles = fs.readdirSync(path.join(p.phases, dir)); + const plans = filterPlanFiles(phaseFiles); + const summaries = filterSummaryFiles(phaseFiles); phases.push({ directory: dir, status: summaries.length >= plans.length && plans.length > 0 ? 'complete' : @@ -255,19 +251,16 @@ function cmdWorkstreamStatus(cwd, name, raw) { plan_count: plans.length, summary_count: summaries.length, }); - } - } catch {} + } catch {} + } let stateInfo = {}; try { const stateContent = fs.readFileSync(p.state, 'utf-8'); - const statusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/); - const phaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(.+)/); - const activityMatch = stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/); stateInfo = { - status: statusMatch ? statusMatch[1].trim() : 'unknown', - current_phase: phaseMatch ? phaseMatch[1].trim() : null, - last_activity: activityMatch ? activityMatch[1].trim() : null, + status: stateExtractField(stateContent, 'Status') || 'unknown', + current_phase: stateExtractField(stateContent, 'Current Phase'), + last_activity: stateExtractField(stateContent, 'Last Activity'), }; } catch {} @@ -392,19 +385,19 @@ function cmdWorkstreamProgress(cwd, raw) { const wsDir = path.join(wsRoot, entry.name); const phasesDir = path.join(wsDir, 'phases'); - let phaseCount = 0, completedCount = 0, totalPlans = 0, completedPlans = 0; - try { - const dirs = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory()); - phaseCount = dirs.length; - for (const d of dirs) { - const files = fs.readdirSync(path.join(phasesDir, d.name)); - const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const phaseDirsProgress = readSubdirectories(phasesDir); + const phaseCount = phaseDirsProgress.length; + let completedCount = 0, totalPlans = 0, completedPlans = 0; + for (const d of phaseDirsProgress) { + try { + const phaseFiles = fs.readdirSync(path.join(phasesDir, d)); + const plans = filterPlanFiles(phaseFiles); + const summaries = filterSummaryFiles(phaseFiles); totalPlans += plans.length; completedPlans += Math.min(summaries.length, plans.length); if (plans.length > 0 && summaries.length >= plans.length) completedCount++; - } - } catch {} + } catch {} + } let roadmapPhaseCount = phaseCount; try { @@ -416,10 +409,8 @@ function cmdWorkstreamProgress(cwd, raw) { let status = 'unknown', currentPhase = null; try { const stateContent = fs.readFileSync(path.join(wsDir, 'STATE.md'), 'utf-8'); - const statusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/); - if (statusMatch) status = statusMatch[1].trim(); - const phaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(.+)/); - if (phaseMatch) currentPhase = phaseMatch[1].trim(); + status = stateExtractField(stateContent, 'Status') || 'unknown'; + currentPhase = stateExtractField(stateContent, 'Current Phase'); } catch {} workstreams.push({ @@ -459,10 +450,8 @@ function getOtherActiveWorkstreams(cwd, excludeWs) { let status = 'unknown', currentPhase = null; try { const content = fs.readFileSync(statePath, 'utf-8'); - const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/); - if (statusMatch) status = statusMatch[1].trim(); - const phaseMatch = content.match(/\*\*Current Phase:\*\*\s*(.+)/); - if (phaseMatch) currentPhase = phaseMatch[1].trim(); + status = stateExtractField(content, 'Status') || 'unknown'; + currentPhase = stateExtractField(content, 'Current Phase'); } catch {} if (status.toLowerCase().includes('milestone complete') || @@ -470,18 +459,18 @@ function getOtherActiveWorkstreams(cwd, excludeWs) { continue; } - let phaseCount = 0, completedCount = 0; - try { - const phasesDir = path.join(wsDir, 'phases'); - const dirs = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory()); - phaseCount = dirs.length; - for (const d of dirs) { - const files = fs.readdirSync(path.join(phasesDir, d.name)); - const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const phasesDir = path.join(wsDir, 'phases'); + const phaseDirsOther = readSubdirectories(phasesDir); + const phaseCount = phaseDirsOther.length; + let completedCount = 0; + for (const d of phaseDirsOther) { + try { + const phaseFiles = fs.readdirSync(path.join(phasesDir, d)); + const plans = filterPlanFiles(phaseFiles); + const summaries = filterSummaryFiles(phaseFiles); if (plans.length > 0 && summaries.length >= plans.length) completedCount++; - } - } catch {} + } catch {} + } others.push({ name: entry.name, status, current_phase: currentPhase, phases: `${completedCount}/${phaseCount}` }); }