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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <value> 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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user