fix(core): add concurrency safety and atomic state writes

Wire withPlanningLock into all ROADMAP.md write paths (phase add/insert/complete/remove, roadmap update-plan-progress) to prevent concurrent corruption when parallel agents modify planning files.

Extract acquireStateLock/releaseStateLock from writeStateMd and add readModifyWriteStateMd helper that holds the lock across the entire read-modify-write cycle, preventing lost updates.

Replace O(n^2) normalizeMd fence detection with single-pass O(n) pre-computed fence state array.

Warn on must_haves parse failure and stateReplaceFieldWithFallback field miss.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tibsfox
2026-04-02 04:14:59 -07:00
parent 94a8005f97
commit d4859220e2
6 changed files with 1212 additions and 333 deletions

View File

@@ -360,20 +360,44 @@ function normalizeMd(content) {
const lines = text.split('\n');
const result = [];
// Pre-compute fence state in a single O(n) pass instead of O(n^2) per-line scanning
const fenceRegex = /^```/;
const insideFence = new Array(lines.length);
let fenceOpen = false;
for (let i = 0; i < lines.length; i++) {
if (fenceRegex.test(lines[i].trimEnd())) {
if (fenceOpen) {
// This is a closing fence — mark as NOT inside (it's the boundary)
insideFence[i] = false;
fenceOpen = false;
} else {
// This is an opening fence
insideFence[i] = false;
fenceOpen = true;
}
} else {
insideFence[i] = fenceOpen;
}
}
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();
const isFenceLine = fenceRegex.test(trimmed);
// 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('');
// MD031: Blank line before fenced code blocks (opening fences only)
if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) {
// Only add blank before opening fences (not closing ones)
if (i === 0 || !insideFence[i - 1]) {
result.push('');
}
}
// MD032: Blank line before lists (- item, * item, N. item, - [ ] item)
@@ -394,7 +418,7 @@ function normalizeMd(content) {
}
// MD031: Blank line after closing fenced code blocks
if (/^```\s*$/.test(trimmed) && isClosingFence(lines, i) && i < lines.length - 1) {
if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1) {
const next = lines[i + 1];
if (next !== undefined && next.trimEnd() !== '') {
result.push('');
@@ -424,24 +448,6 @@ function normalizeMd(content) {
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,

View File

@@ -252,6 +252,19 @@ function parseMustHavesBlock(content, blockName) {
}
if (current) items.push(current);
// Warn when must_haves block exists but parsed as empty -- likely YAML formatting issue.
// This is a critical diagnostic: empty must_haves causes verification to silently degrade
// to Option C (LLM-derived truths) instead of checking documented contracts.
if (items.length === 0 && blockLines.length > 0) {
const nonEmptyLines = blockLines.filter(l => l.trim() !== '').length;
if (nonEmptyLines > 0) {
process.stderr.write(
`[gsd-tools] WARNING: must_haves.${blockName} block has ${nonEmptyLines} content lines but parsed 0 items. ` +
`Possible YAML formatting issue — verification will fall back to LLM-derived truths.\n`
);
}
}
return items;
}

View File

@@ -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, readSubdirectories } = require('./core.cjs');
const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, withPlanningLock, output, error, readSubdirectories } = require('./core.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
const { writeStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } = require('./state.cjs');
@@ -328,57 +328,62 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
error('ROADMAP.md not found');
}
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd);
const slug = generateSlugInternal(description);
let newPhaseId;
let dirName;
// Wrap entire read-modify-write in lock to prevent concurrent corruption
const { newPhaseId, dirName } = withPlanningLock(cwd, () => {
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd);
// Optional project code prefix (e.g., 'CK' → 'CK-01-foundation')
const projectCode = config.project_code || '';
const prefix = projectCode ? `${projectCode}-` : '';
// Optional project code prefix (e.g., 'CK' → 'CK-01-foundation')
const projectCode = config.project_code || '';
const prefix = projectCode ? `${projectCode}-` : '';
if (customId || config.phase_naming === 'custom') {
// Custom phase naming: use provided ID or generate from description
newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-');
if (!newPhaseId) error('--id required when phase_naming is "custom"');
dirName = `${prefix}${newPhaseId}-${slug}`;
} else {
// Sequential mode: find highest integer phase number (in current milestone only)
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
let maxPhase = 0;
let m;
while ((m = phasePattern.exec(content)) !== null) {
const num = parseInt(m[1], 10);
if (num > maxPhase) maxPhase = num;
let _newPhaseId;
let _dirName;
if (customId || config.phase_naming === 'custom') {
// Custom phase naming: use provided ID or generate from description
_newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-');
if (!_newPhaseId) error('--id required when phase_naming is "custom"');
_dirName = `${prefix}${_newPhaseId}-${slug}`;
} else {
// Sequential mode: find highest integer phase number (in current milestone only)
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
let maxPhase = 0;
let m;
while ((m = phasePattern.exec(content)) !== null) {
const num = parseInt(m[1], 10);
if (num > maxPhase) maxPhase = num;
}
_newPhaseId = maxPhase + 1;
const paddedNum = String(_newPhaseId).padStart(2, '0');
_dirName = `${prefix}${paddedNum}-${slug}`;
}
newPhaseId = maxPhase + 1;
const paddedNum = String(newPhaseId).padStart(2, '0');
dirName = `${prefix}${paddedNum}-${slug}`;
}
const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
const dirPath = path.join(planningDir(cwd), 'phases', dirName);
// Create directory with .gitkeep so git tracks empty folders
fs.mkdirSync(dirPath, { recursive: true });
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
// Create directory with .gitkeep so git tracks empty folders
fs.mkdirSync(dirPath, { recursive: true });
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
// Build phase entry
const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
const phaseEntry = `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${_newPhaseId} to break down)\n`;
// Build phase entry
const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${newPhaseId} to break down)\n`;
// Find insertion point: before last "---" or at end
let updatedContent;
const lastSeparator = rawContent.lastIndexOf('\n---');
if (lastSeparator > 0) {
updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
} else {
updatedContent = rawContent + phaseEntry;
}
// Find insertion point: before last "---" or at end
let updatedContent;
const lastSeparator = rawContent.lastIndexOf('\n---');
if (lastSeparator > 0) {
updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
} else {
updatedContent = rawContent + phaseEntry;
}
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
return { newPhaseId: _newPhaseId, dirName: _dirName };
});
const result = {
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
@@ -402,71 +407,75 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
error('ROADMAP.md not found');
}
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd);
const slug = generateSlugInternal(description);
// Normalize input then strip leading zeros for flexible matching
const normalizedAfter = normalizePhaseName(afterPhase);
const unpadded = normalizedAfter.replace(/^0+/, '');
const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
if (!targetPattern.test(content)) {
error(`Phase ${afterPhase} not found in ROADMAP.md`);
}
// Wrap entire read-modify-write in lock to prevent concurrent corruption
const { decimalPhase, dirName } = withPlanningLock(cwd, () => {
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd);
// Calculate next decimal using existing logic
const phasesDir = path.join(planningDir(cwd), 'phases');
const normalizedBase = normalizePhaseName(afterPhase);
let existingDecimals = [];
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${normalizedBase}\\.(\\d+)`);
for (const dir of dirs) {
const dm = dir.match(decimalPattern);
if (dm) existingDecimals.push(parseInt(dm[1], 10));
// Normalize input then strip leading zeros for flexible matching
const normalizedAfter = normalizePhaseName(afterPhase);
const unpadded = normalizedAfter.replace(/^0+/, '');
const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
if (!targetPattern.test(content)) {
error(`Phase ${afterPhase} not found in ROADMAP.md`);
}
} catch { /* intentionally empty */ }
const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
const decimalPhase = `${normalizedBase}.${nextDecimal}`;
// Calculate next decimal using existing logic
const phasesDir = path.join(planningDir(cwd), 'phases');
const normalizedBase = normalizePhaseName(afterPhase);
let existingDecimals = [];
// Optional project code prefix
const config = loadConfig(cwd);
const projectCode = config.project_code || '';
const prefix = projectCode ? `${projectCode}-` : '';
const dirName = `${prefix}${decimalPhase}-${slug}`;
const dirPath = path.join(planningDir(cwd), 'phases', dirName);
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${normalizedBase}\\.(\\d+)`);
for (const dir of dirs) {
const dm = dir.match(decimalPattern);
if (dm) existingDecimals.push(parseInt(dm[1], 10));
}
} catch { /* intentionally empty */ }
// Create directory with .gitkeep so git tracks empty folders
fs.mkdirSync(dirPath, { recursive: true });
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
const _decimalPhase = `${normalizedBase}.${nextDecimal}`;
// Optional project code prefix
const insertConfig = loadConfig(cwd);
const projectCode = insertConfig.project_code || '';
const pfx = projectCode ? `${projectCode}-` : '';
const _dirName = `${pfx}${_decimalPhase}-${slug}`;
const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
// Build phase entry
const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${decimalPhase} to break down)\n`;
// Create directory with .gitkeep so git tracks empty folders
fs.mkdirSync(dirPath, { recursive: true });
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
// Insert after the target phase section
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
const headerMatch = rawContent.match(headerPattern);
if (!headerMatch) {
error(`Could not find Phase ${afterPhase} header`);
}
// Build phase entry
const phaseEntry = `\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${_decimalPhase} to break down)\n`;
const headerIdx = rawContent.indexOf(headerMatch[0]);
const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
// Insert after the target phase section
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
const headerMatch = rawContent.match(headerPattern);
if (!headerMatch) {
error(`Could not find Phase ${afterPhase} header`);
}
let insertIdx;
if (nextPhaseMatch) {
insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
} else {
insertIdx = rawContent.length;
}
const headerIdx = rawContent.indexOf(headerMatch[0]);
const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
const updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
let insertIdx;
if (nextPhaseMatch) {
insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
} else {
insertIdx = rawContent.length;
}
const updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
return { decimalPhase: _decimalPhase, dirName: _dirName };
});
const result = {
phase_number: decimalPhase,
@@ -554,29 +563,32 @@ function renameIntegerPhases(phasesDir, removedInt) {
/**
* 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);
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
// Wrap entire read-modify-write in lock to prevent concurrent corruption
withPlanningLock(cwd, () => {
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'), '');
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}`);
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');
fs.writeFileSync(roadmapPath, content, 'utf-8');
});
}
function cmdPhaseRemove(cwd, targetPhase, options, raw) {
@@ -617,7 +629,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
} catch { /* intentionally empty */ }
// Update ROADMAP.md
updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10));
updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
// Update STATE.md phase count
const statePath = path.join(planningDir(cwd), 'STATE.md');
@@ -686,98 +698,100 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
}
} catch {}
// Update ROADMAP.md: mark phase complete
// Update ROADMAP.md and REQUIREMENTS.md atomically under lock
if (fs.existsSync(roadmapPath)) {
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
withPlanningLock(cwd, () => {
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
// Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
const checkboxPattern = new RegExp(
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`,
'i'
);
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
// Progress table: update Status to Complete, add date (handles 4 or 5 column tables)
const phaseEscaped = escapeRegex(phaseNum);
const tableRowPattern = new RegExp(
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
'im'
);
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
const cells = fullRow.split('|').slice(1, -1);
if (cells.length === 5) {
// 5-col: Phase | Milestone | Plans | Status | Completed
cells[2] = ` ${summaryCount}/${planCount} `;
cells[3] = ' Complete ';
cells[4] = ` ${today} `;
} else if (cells.length === 4) {
// 4-col: Phase | Plans | Status | Completed
cells[1] = ` ${summaryCount}/${planCount} `;
cells[2] = ' Complete ';
cells[3] = ` ${today} `;
}
return '|' + cells.join('|') + '|';
});
// Update plan count in phase section
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
'i'
);
roadmapContent = replaceInCurrentMilestone(
roadmapContent, planCountPattern,
`$1${summaryCount}/${planCount} plans complete`
);
// Mark completed plan checkboxes (safety net for missed per-plan updates)
for (const summaryFile of phaseInfo.summaries) {
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
if (!planId) continue;
const planEscaped = escapeRegex(planId);
const planCheckboxPattern = new RegExp(
`(-\\s*\\[) (\\]\\s*${planEscaped})`,
// Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
const checkboxPattern = new RegExp(
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`,
'i'
);
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
}
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
// Progress table: update Status to Complete, add date (handles 4 or 5 column tables)
const phaseEscaped = escapeRegex(phaseNum);
const tableRowPattern = new RegExp(
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
'im'
);
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
const cells = fullRow.split('|').slice(1, -1);
if (cells.length === 5) {
// 5-col: Phase | Milestone | Plans | Status | Completed
cells[2] = ` ${summaryCount}/${planCount} `;
cells[3] = ' Complete ';
cells[4] = ` ${today} `;
} else if (cells.length === 4) {
// 4-col: Phase | Plans | Status | Completed
cells[1] = ` ${summaryCount}/${planCount} `;
cells[2] = ' Complete ';
cells[3] = ` ${today} `;
}
return '|' + cells.join('|') + '|';
});
// Update REQUIREMENTS.md traceability for this phase's requirements
const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md');
if (fs.existsSync(reqPath)) {
// Extract the current phase section from roadmap (scoped to avoid cross-phase matching)
const phaseEsc = escapeRegex(phaseNum);
const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
const phaseSectionMatch = currentMilestoneRoadmap.match(
new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i')
// Update plan count in phase section
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
'i'
);
roadmapContent = replaceInCurrentMilestone(
roadmapContent, planCountPattern,
`$1${summaryCount}/${planCount} plans complete`
);
const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
const reqMatch = sectionText.match(/\*\*Requirements:\*\*\s*([^\n]+)/i);
if (reqMatch) {
const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
let reqContent = fs.readFileSync(reqPath, 'utf-8');
for (const reqId of reqIds) {
const reqEscaped = escapeRegex(reqId);
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
reqContent = reqContent.replace(
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
'$1x$2'
);
// Update traceability table: | REQ-ID | Phase N | Pending/In Progress | → | REQ-ID | Phase N | Complete |
reqContent = reqContent.replace(
new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'),
'$1 Complete $2'
);
}
fs.writeFileSync(reqPath, reqContent, 'utf-8');
requirementsUpdated = true;
// Mark completed plan checkboxes (safety net for missed per-plan updates)
for (const summaryFile of phaseInfo.summaries) {
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
if (!planId) continue;
const planEscaped = escapeRegex(planId);
const planCheckboxPattern = new RegExp(
`(-\\s*\\[) (\\]\\s*${planEscaped})`,
'i'
);
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
}
}
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
// Update REQUIREMENTS.md traceability for this phase's requirements
const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md');
if (fs.existsSync(reqPath)) {
// Extract the current phase section from roadmap (scoped to avoid cross-phase matching)
const phaseEsc = escapeRegex(phaseNum);
const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
const phaseSectionMatch = currentMilestoneRoadmap.match(
new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i')
);
const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
const reqMatch = sectionText.match(/\*\*Requirements:\*\*\s*([^\n]+)/i);
if (reqMatch) {
const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
let reqContent = fs.readFileSync(reqPath, 'utf-8');
for (const reqId of reqIds) {
const reqEscaped = escapeRegex(reqId);
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
reqContent = reqContent.replace(
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
'$1x$2'
);
// Update traceability table: | REQ-ID | Phase N | Pending/In Progress | → | REQ-ID | Phase N | Complete |
reqContent = reqContent.replace(
new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'),
'$1 Complete $2'
);
}
fs.writeFileSync(reqPath, reqContent, 'utf-8');
requirementsUpdated = true;
}
}
});
}
// Find next phase — check both filesystem AND roadmap

View File

@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const { escapeRegex, normalizePhaseName, planningPaths, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = require('./core.cjs');
const { escapeRegex, normalizePhaseName, planningPaths, withPlanningLock, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = require('./core.cjs');
function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
const roadmapPath = planningPaths(cwd).roadmap;
@@ -254,64 +254,67 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
return;
}
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const phaseEscaped = escapeRegex(phaseNum);
// Wrap entire read-modify-write in lock to prevent concurrent corruption
withPlanningLock(cwd, () => {
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const phaseEscaped = escapeRegex(phaseNum);
// Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables)
const tableRowPattern = new RegExp(
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
'im'
);
const dateField = isComplete ? ` ${today} ` : ' ';
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
const cells = fullRow.split('|').slice(1, -1); // drop leading/trailing empty from split
if (cells.length === 5) {
// 5-col: Phase | Milestone | Plans | Status | Completed
cells[2] = ` ${summaryCount}/${planCount} `;
cells[3] = ` ${status.padEnd(11)}`;
cells[4] = dateField;
} else if (cells.length === 4) {
// 4-col: Phase | Plans | Status | Completed
cells[1] = ` ${summaryCount}/${planCount} `;
cells[2] = ` ${status.padEnd(11)}`;
cells[3] = dateField;
// Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables)
const tableRowPattern = new RegExp(
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
'im'
);
const dateField = isComplete ? ` ${today} ` : ' ';
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
const cells = fullRow.split('|').slice(1, -1); // drop leading/trailing empty from split
if (cells.length === 5) {
// 5-col: Phase | Milestone | Plans | Status | Completed
cells[2] = ` ${summaryCount}/${planCount} `;
cells[3] = ` ${status.padEnd(11)}`;
cells[4] = dateField;
} else if (cells.length === 4) {
// 4-col: Phase | Plans | Status | Completed
cells[1] = ` ${summaryCount}/${planCount} `;
cells[2] = ` ${status.padEnd(11)}`;
cells[3] = dateField;
}
return '|' + cells.join('|') + '|';
});
// Update plan count in phase detail section
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
'i'
);
const planCountText = isComplete
? `${summaryCount}/${planCount} plans complete`
: `${summaryCount}/${planCount} plans executed`;
roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
// If complete: check checkbox
if (isComplete) {
const checkboxPattern = new RegExp(
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
'i'
);
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
}
return '|' + cells.join('|') + '|';
// Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md" or "- [ ] 50-01:")
for (const summaryFile of phaseInfo.summaries) {
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
if (!planId) continue;
const planEscaped = escapeRegex(planId);
const planCheckboxPattern = new RegExp(
`(-\\s*\\[) (\\]\\s*${planEscaped})`,
'i'
);
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
}
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
});
// Update plan count in phase detail section
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
'i'
);
const planCountText = isComplete
? `${summaryCount}/${planCount} plans complete`
: `${summaryCount}/${planCount} plans executed`;
roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
// If complete: check checkbox
if (isComplete) {
const checkboxPattern = new RegExp(
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
'i'
);
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
}
// Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md" or "- [ ] 50-01:")
for (const summaryFile of phaseInfo.summaries) {
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
if (!planId) continue;
const planEscaped = escapeRegex(planId);
const planCheckboxPattern = new RegExp(
`(-\\s*\\[) (\\]\\s*${planEscaped})`,
'i'
);
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
}
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
output({
updated: true,
phase: phaseNum,

View File

@@ -141,29 +141,28 @@ function cmdStatePatch(cwd, patches, raw) {
const statePath = planningPaths(cwd).state;
try {
let content = fs.readFileSync(statePath, 'utf-8');
const results = { updated: [], failed: [] };
for (const [field, value] of Object.entries(patches)) {
const fieldEscaped = escapeRegex(field);
// Try **Field:** bold format first, then plain Field: format
const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
// Use atomic read-modify-write to prevent lost updates from concurrent agents
readModifyWriteStateMd(statePath, (content) => {
for (const [field, value] of Object.entries(patches)) {
const fieldEscaped = escapeRegex(field);
// Try **Field:** bold format first, then plain Field: format
const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
if (boldPattern.test(content)) {
content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
results.updated.push(field);
} else if (plainPattern.test(content)) {
content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
results.updated.push(field);
} else {
results.failed.push(field);
if (boldPattern.test(content)) {
content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
results.updated.push(field);
} else if (plainPattern.test(content)) {
content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
results.updated.push(field);
} else {
results.failed.push(field);
}
}
}
if (results.updated.length > 0) {
writeStateMd(statePath, content, cwd);
}
return content;
}, cwd);
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
} catch {
@@ -780,6 +779,50 @@ function syncStateFrontmatter(content, cwd) {
return `---\n${yamlStr}\n---\n\n${body}`;
}
/**
* Acquire a lockfile for STATE.md operations.
* Returns the lock path for later release.
*/
function acquireStateLock(statePath) {
const lockPath = statePath + '.lock';
const maxRetries = 10;
const retryDelay = 200; // ms
for (let i = 0; i < maxRetries; i++) {
try {
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
fs.writeSync(fd, String(process.pid));
fs.closeSync(fd);
return lockPath;
} catch (err) {
if (err.code === 'EEXIST') {
try {
const stat = fs.statSync(lockPath);
if (Date.now() - stat.mtimeMs > 10000) {
fs.unlinkSync(lockPath);
continue;
}
} catch { /* lock was released between check — retry */ }
if (i === maxRetries - 1) {
try { fs.unlinkSync(lockPath); } catch {}
return lockPath;
}
const jitter = Math.floor(Math.random() * 50);
const start = Date.now();
while (Date.now() - start < retryDelay + jitter) { /* busy wait */ }
continue;
}
return lockPath; // non-EEXIST error — proceed without lock
}
}
return statePath + '.lock';
}
function releaseStateLock(lockPath) {
try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
}
/**
* Write STATE.md with synchronized YAML frontmatter.
* All STATE.md writes should use this instead of raw writeFileSync.
@@ -788,48 +831,29 @@ function syncStateFrontmatter(content, cwd) {
*/
function writeStateMd(statePath, content, cwd) {
const synced = syncStateFrontmatter(content, cwd);
const lockPath = statePath + '.lock';
const maxRetries = 10;
const retryDelay = 200; // ms
// Acquire lock (spin with backoff)
for (let i = 0; i < maxRetries; i++) {
try {
// O_EXCL fails if file already exists — atomic lock
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
fs.writeSync(fd, String(process.pid));
fs.closeSync(fd);
break;
} catch (err) {
if (err.code === 'EEXIST') {
// Check for stale lock (> 10s old)
try {
const stat = fs.statSync(lockPath);
if (Date.now() - stat.mtimeMs > 10000) {
fs.unlinkSync(lockPath);
continue; // retry immediately after clearing stale lock
}
} catch { /* lock was released between check — retry */ }
if (i === maxRetries - 1) {
// Last resort: write anyway rather than losing data
try { fs.unlinkSync(lockPath); } catch {}
break;
}
// Spin-wait with small jitter
const jitter = Math.floor(Math.random() * 50);
const start = Date.now();
while (Date.now() - start < retryDelay + jitter) { /* busy wait */ }
continue;
}
break; // non-EEXIST error — proceed without lock
}
}
const lockPath = acquireStateLock(statePath);
try {
fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
} finally {
try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
releaseStateLock(lockPath);
}
}
/**
* Atomic read-modify-write for STATE.md.
* Holds the lock across the entire read -> transform -> write cycle,
* preventing the lost-update problem where two agents read the same
* content and the second write clobbers the first.
*/
function readModifyWriteStateMd(statePath, transformFn, cwd) {
const lockPath = acquireStateLock(statePath);
try {
const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
const modified = transformFn(content);
const synced = syncStateFrontmatter(modified, cwd);
fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
} finally {
releaseStateLock(lockPath);
}
}

View File

@@ -0,0 +1,819 @@
/**
* GSD Tools Tests - Concurrency Safety
*
* Tests for fix/concurrency-safety-1473a:
* - Planning lock integration (withPlanningLock in phase/roadmap operations)
* - readModifyWriteStateMd (atomic state updates)
* - normalizeMd behavioral equivalence (O(n) insideFence rewrite)
* - Warnings (frontmatter parse warning, stateReplaceFieldWithFallback)
* - Performance benchmarks (normalizeMd O(n) verification)
* - Snapshot tests for normalizeMd (regression detection)
* - Multi-process concurrent write tests
* - Stress tests at scale (50+ phases)
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync, exec } = require('child_process');
const { promisify } = require('util');
const { performance } = require('perf_hooks');
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
const {
normalizeMd,
} = require('../get-shit-done/bin/lib/core.cjs');
const execAsync = promisify(exec);
// ─── Helpers ────────────────────────────────────────────────────────────────
function writeMinimalRoadmap(tmpDir, phases = ['1']) {
const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n${lines}\n`
);
}
function writeMinimalStateMd(tmpDir, content) {
const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`;
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
defaultContent
);
}
function writeMinimalProjectMd(tmpDir) {
const sections = ['## What This Is', '## Core Value', '## Requirements'];
const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
`# Project\n\n${content}`
);
}
function writeValidConfigJson(tmpDir, overrides = {}) {
const base = { model_profile: 'balanced', commit_docs: true };
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ ...base, ...overrides }, null, 2)
);
}
/**
* Generate a 50-phase project structure for stress testing.
*/
function create50PhaseProject(tmpDir, completedCount = 25) {
let roadmapContent = '# Roadmap v1.0\n\n';
for (let i = 1; i <= 50; i++) {
roadmapContent += `- [${i <= completedCount ? 'x' : ' '}] Phase ${i}: Feature ${i}\n`;
}
roadmapContent += '\n';
for (let i = 1; i <= 50; i++) {
const pad = String(i).padStart(2, '0');
roadmapContent += `### Phase ${i}: Feature ${i}\n\n`;
roadmapContent += `**Goal:** Build feature ${i}\n`;
roadmapContent += `**Requirements:** REQ-${pad}\n`;
roadmapContent += `**Plans:** 1 plans\n\n`;
roadmapContent += `Plans:\n- [${i <= completedCount ? 'x' : ' '}] ${pad}-01-PLAN.md\n\n`;
}
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
roadmapContent
);
const phasesDir = path.join(tmpDir, '.planning', 'phases');
for (let i = 1; i <= 50; i++) {
const pad = String(i).padStart(2, '0');
const dirName = `${pad}-feature-${i}`;
const phaseDir = path.join(phasesDir, dirName);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, `${pad}-01-PLAN.md`),
`# Phase ${i} Plan 1\n\nBuild feature ${i}.\n`
);
if (i <= completedCount) {
fs.writeFileSync(
path.join(phaseDir, `${pad}-01-SUMMARY.md`),
`# Phase ${i} Plan 1 Summary\n\nFeature ${i} completed.\n`
);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. Planning lock integration
// ─────────────────────────────────────────────────────────────────────────────
describe('planning lock integration', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('phase add creates and releases .planning/.lock during ROADMAP write', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n`
);
const result = runGsdTools('phase add Testing', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const lockPath = path.join(tmpDir, '.planning', '.lock');
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after phase add');
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 2, 'should be phase 2');
});
test('phase complete creates and releases .planning/.lock', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n- [ ] Phase 1: Foundation\n\n### Phase 1: Foundation\n**Goal:** Setup\n**Plans:** 1 plans\n\n### Phase 2: API\n**Goal:** Build\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Foundation\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
const result = runGsdTools('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const lockPath = path.join(tmpDir, '.planning', '.lock');
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after phase complete');
const output = JSON.parse(result.output);
assert.strictEqual(output.completed_phase, '1', 'phase should be completed');
});
test('roadmap update-plan-progress creates and releases .planning/.lock', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n| Phase | Plans | Status | Updated |\n|-------|-------|--------|---------|\n| 1 | 0/0 | Not started | - |\n\n### Phase 1: Foundation\n**Goal:** Setup\n`
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const lockPath = path.join(tmpDir, '.planning', '.lock');
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after roadmap update');
});
test('lock file does NOT persist after successful phase operations', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n`
);
runGsdTools('phase add First Phase', tmpDir);
runGsdTools('phase add Second Phase', tmpDir);
const lockPath = path.join(tmpDir, '.planning', '.lock');
assert.ok(!fs.existsSync(lockPath), '.lock file should not persist after multiple operations');
});
test('phase add still works correctly with lock (behavioral regression)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n### Phase 2: API\n**Goal:** Build API\n\n---\n`
);
const result = runGsdTools('phase add User Dashboard', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 3, 'should be phase 3');
assert.strictEqual(output.slug, 'user-dashboard');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-user-dashboard')),
'directory should be created'
);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('### Phase 3: User Dashboard'), 'roadmap should include new phase');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. readModifyWriteStateMd (tested via CLI commands that use it)
// ─────────────────────────────────────────────────────────────────────────────
describe('readModifyWriteStateMd (via state patch)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('transforms content atomically (read + modify + write under lock)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 03\n**Status:** Planning\n**Current Plan:** 03-01\n`
);
const result = runGsdTools('state patch --Status "In progress" --"Current Plan" 03-02', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(content.includes('**Status:** In progress'), 'Status should be updated');
assert.ok(content.includes('03-02'), 'Current Plan should be updated');
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should be released after patch');
});
test('lock file cleaned up after state patch operation', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Status:** Ready\n`
);
runGsdTools('state patch --Status "In progress"', tmpDir);
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should not persist after operation');
});
test('state patch still works correctly via readModifyWriteStateMd path (behavioral regression)', () => {
const stateMd = [
'# Project State',
'',
'**Current Phase:** 03',
'**Status:** Planning',
'**Current Plan:** 03-01',
'**Last Activity:** 2024-01-15',
].join('\n') + '\n';
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
const result = runGsdTools('state patch --Status Complete --"Current Phase" 04', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('**Status:** Complete'), 'Status should be updated to Complete');
assert.ok(updated.includes('**Last Activity:** 2024-01-15'), 'Last Activity should be unchanged');
});
test('two sequential state patches both persist (patch A then patch B)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Status:** Planning\n**Current Plan:** 01-01\n**Last Activity:** 2024-01-01\n`
);
const resultA = runGsdTools('state patch --Status "In progress"', tmpDir);
assert.ok(resultA.success, `Patch A failed: ${resultA.error}`);
const resultB = runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
assert.ok(resultB.success, `Patch B failed: ${resultB.error}`);
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(content.includes('**Status:** In progress'), 'Patch A (Status) should persist');
assert.ok(content.includes('01-02'), 'Patch B (Current Plan) should persist');
assert.ok(content.includes('**Last Activity:** 2024-01-01'), 'Untouched field should be preserved');
});
test('lock file does not persist after rapid sequential patches', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Status:** Planning\n**Current Plan:** 01-01\n`
);
runGsdTools('state patch --Status "In progress"', tmpDir);
runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
runGsdTools('state patch --Status Complete', tmpDir);
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should not persist after rapid sequential patches');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Multi-process concurrent write tests
// ─────────────────────────────────────────────────────────────────────────────
describe('multi-process concurrent write tests', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('two concurrent state patches to DIFFERENT fields both persist', async () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** In progress',
'**Current Plan:** 01-01',
'**Last Activity:** 2025-01-01',
'**Last Activity Description:** Working',
'',
].join('\n')
);
const toolsPath = TOOLS_PATH;
const cmdA = `node "${toolsPath}" state patch --Status Complete --cwd "${tmpDir}"`;
const cmdB = `node "${toolsPath}" state patch --"Current Plan" 01-02 --cwd "${tmpDir}"`;
const [resultA, resultB] = await Promise.all([
execAsync(cmdA, { encoding: 'utf-8' }).catch(e => e),
execAsync(cmdB, { encoding: 'utf-8' }).catch(e => e),
]);
const aOk = !(resultA instanceof Error);
const bOk = !(resultB instanceof Error);
assert.ok(aOk || bOk, 'At least one concurrent patch should succeed');
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(
content.includes('Complete') || content.includes('01-02'),
`At least one concurrent patch should persist in STATE.md. Content:\n${content}`
);
if (content.includes('Complete') && content.includes('01-02')) {
assert.ok(true, 'Both concurrent patches persisted (lock serialization)');
}
assert.ok(content.includes('**Current Phase:** 01'), 'Untouched field Current Phase should survive');
assert.ok(content.includes('2025-01-01'), 'Untouched field Last Activity should survive');
});
test('lock file does not persist after concurrent operations', async () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** Planning',
'**Current Plan:** 01-01',
'',
].join('\n')
);
const toolsPath = TOOLS_PATH;
const cmdA = `node "${toolsPath}" state patch --Status Complete --cwd "${tmpDir}"`;
const cmdB = `node "${toolsPath}" state patch --"Current Plan" 01-02 --cwd "${tmpDir}"`;
await Promise.all([
execAsync(cmdA, { encoding: 'utf-8' }).catch(() => {}),
execAsync(cmdB, { encoding: 'utf-8' }).catch(() => {}),
]);
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(
!fs.existsSync(lockPath),
'STATE.md.lock should not persist after concurrent operations complete'
);
});
test('three rapid sequential patches all persist', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** Planning',
'**Current Plan:** 01-01',
'**Last Activity:** 2025-01-01',
'',
].join('\n')
);
const r1 = runGsdTools('state patch --Status "In progress"', tmpDir);
assert.ok(r1.success, `Patch 1 failed: ${r1.error}`);
const r2 = runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
assert.ok(r2.success, `Patch 2 failed: ${r2.error}`);
const r3 = runGsdTools('state patch --"Last Activity" 2025-06-15', tmpDir);
assert.ok(r3.success, `Patch 3 failed: ${r3.error}`);
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(content.includes('In progress'), 'Patch 1 (Status) should persist');
assert.ok(content.includes('01-02'), 'Patch 2 (Current Plan) should persist');
assert.ok(content.includes('2025-06-15'), 'Patch 3 (Last Activity) should persist');
assert.ok(content.includes('**Current Phase:** 01'), 'Untouched field should be preserved');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. normalizeMd behavioral equivalence (O(n) insideFence rewrite)
// ─────────────────────────────────────────────────────────────────────────────
describe('normalizeMd behavioral equivalence', () => {
test('simple markdown with headings and paragraphs', () => {
const input = '# Title\nSome text.\n## Section\nMore text.\n';
const result = normalizeMd(input);
assert.ok(result.includes('# Title\n\nSome text.'), 'title heading should have blank line after');
assert.ok(result.includes('\n\n## Section\n\nMore text.'), 'section heading should have blank lines around it');
assert.ok(result.endsWith('\n'), 'should end with newline');
assert.ok(!result.endsWith('\n\n'), 'should not end with double newline');
});
test('single fenced code block gets blank lines before/after', () => {
const input = 'Some text\n```js\nconst x = 1;\n```\nMore text\n';
const result = normalizeMd(input);
assert.ok(result.includes('Some text\n\n```js'), 'code block should have blank line before');
assert.ok(result.includes('```\n\nMore text'), 'code block should have blank line after');
assert.ok(result.includes('const x = 1;'), 'code content should be preserved');
});
test('multiple fenced code blocks', () => {
const input = 'Intro\n```js\nfoo();\n```\nMiddle\n```py\nbar()\n```\nEnd\n';
const result = normalizeMd(input);
assert.ok(result.includes('Intro\n\n```js'), 'first code block should have blank line before');
assert.ok(result.includes('```\n\nMiddle'), 'first code block should have blank line after');
assert.ok(result.includes('Middle\n\n```py'), 'second code block should have blank line before');
assert.ok(result.includes('```\n\nEnd'), 'second code block should have blank line after');
});
test('unclosed fence at end of file (edge case)', () => {
const input = 'Some text\n```js\nconst x = 1;\n';
const result = normalizeMd(input);
assert.ok(typeof result === 'string', 'should return a string');
assert.ok(result.includes('```js'), 'fence opener should be preserved');
assert.ok(result.includes('const x = 1;'), 'content after unclosed fence should be preserved');
assert.ok(result.endsWith('\n'), 'should end with newline');
});
test('empty string input', () => {
assert.strictEqual(normalizeMd(''), '', 'empty string should return empty string');
});
test('mixed headings + lists + fences (complex case)', () => {
const input = [
'# Title',
'## Section One',
'Paragraph text.',
'- item 1',
'- item 2',
'## Section Two',
'```bash',
'echo hello',
'```',
'After code.',
'## Section Three',
'1. First',
'2. Second',
'Done.',
].join('\n') + '\n';
const result = normalizeMd(input);
assert.ok(result.includes('\n\n## Section One\n\n'), 'Section One heading needs blank lines');
assert.ok(result.includes('\n\n## Section Two\n\n'), 'Section Two heading needs blank lines');
assert.ok(result.includes('\n\n## Section Three\n\n'), 'Section Three heading needs blank lines');
assert.ok(result.includes('Paragraph text.\n\n- item 1'), 'list should have blank line before');
assert.ok(result.includes('\n\n```bash'), 'code block should have blank line before');
assert.ok(result.includes('```\n\nAfter code.'), 'code block should have blank line after');
assert.ok(result.includes('echo hello'), 'code content should be preserved');
assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. normalizeMd performance benchmark
// ─────────────────────────────────────────────────────────────────────────────
describe('normalizeMd performance benchmark', () => {
test('processes a 100-line markdown file in under 50ms', () => {
const lines = [];
for (let i = 0; i < 100; i++) {
if (i % 20 === 0) {
lines.push(`## Section ${i / 20 + 1}`);
} else if (i % 30 === 0) {
lines.push('```js');
lines.push(`const x${i} = ${i};`);
lines.push('```');
} else if (i % 5 === 0) {
lines.push(`- List item ${i}`);
} else {
lines.push(`Paragraph text line ${i} with some content to process.`);
}
}
const input = lines.join('\n') + '\n';
const start = performance.now();
const result = normalizeMd(input);
const elapsed = performance.now() - start;
assert.ok(typeof result === 'string', 'should return a string');
assert.ok(result.length > 0, 'result should not be empty');
assert.ok(result.endsWith('\n'), 'result should end with newline');
assert.ok(elapsed < 50, `100-line file should process in under 50ms, took ${elapsed.toFixed(2)}ms`);
});
test('processes a 1000-line markdown file with 20 code blocks in under 200ms', () => {
const lines = [];
let codeBlockCount = 0;
for (let i = 0; i < 1000; i++) {
if (i % 50 === 0 && codeBlockCount < 20) {
lines.push(`## Section ${codeBlockCount + 1}`);
lines.push('');
lines.push('Some introductory text for this section.');
lines.push('');
lines.push('```python');
for (let j = 0; j < 5; j++) {
lines.push(` result_${codeBlockCount}_${j} = compute(${j})`);
}
lines.push('```');
lines.push('');
lines.push('Explanation of the code above.');
codeBlockCount++;
} else if (i % 10 === 0) {
lines.push(`### Subsection at line ${i}`);
} else if (i % 7 === 0) {
lines.push(`- Item ${i}: description of this list item`);
} else if (i % 13 === 0) {
lines.push(`1. Ordered item ${i}`);
} else {
lines.push(`Line ${i}: Regular paragraph content with various markdown elements.`);
}
}
const input = lines.join('\n') + '\n';
normalizeMd(input); // warm up JIT
const start = performance.now();
const result = normalizeMd(input);
const elapsed = performance.now() - start;
assert.ok(typeof result === 'string', 'should return a string');
assert.ok(result.length > 0, 'result should not be empty');
assert.ok(result.endsWith('\n'), 'result should end with newline');
assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines');
assert.ok(elapsed < 200, `1000-line file with 20 code blocks should process in under 200ms, took ${elapsed.toFixed(2)}ms`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 6. normalizeMd snapshot tests
// ─────────────────────────────────────────────────────────────────────────────
describe('normalizeMd snapshot tests', () => {
test('snapshot - heading spacing', () => {
const input = '# Title\nParagraph\n## Section\nMore text';
const expected = '# Title\n\nParagraph\n\n## Section\n\nMore text\n';
const result = normalizeMd(input);
assert.strictEqual(result, expected,
`Heading spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
);
});
test('snapshot - code block spacing', () => {
const input = 'Text before\n```js\nconst x = 1;\n```\nText after\n';
const expected = 'Text before\n\n```js\nconst x = 1;\n```\n\nText after\n';
const result = normalizeMd(input);
assert.strictEqual(result, expected,
`Code block spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
);
});
test('snapshot - list spacing', () => {
const input = 'Paragraph\n- item 1\n- item 2\nAnother paragraph';
const expected = 'Paragraph\n\n- item 1\n- item 2\n\nAnother paragraph\n';
const result = normalizeMd(input);
assert.strictEqual(result, expected,
`List spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
);
});
test('snapshot - complex mixed document', () => {
const input = [
'# Main Title',
'Intro paragraph.',
'## Section One',
'Some text here.',
'```js',
'const a = 1;',
'```',
'- first item',
'- second item',
'## Section Two',
'Final text.',
].join('\n');
const expected = [
'# Main Title',
'',
'Intro paragraph.',
'',
'## Section One',
'',
'Some text here.',
'',
'```js',
'const a = 1;',
'```',
'',
'- first item',
'- second item',
'',
'## Section Two',
'',
'Final text.',
'',
].join('\n');
const result = normalizeMd(input);
assert.strictEqual(result, expected,
`Complex mixed document snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 7. Warnings (frontmatter parse, state field miss)
// ─────────────────────────────────────────────────────────────────────────────
describe('warnings', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('must_haves parse warning fires for block with content but 0 items', () => {
const planDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(planDir, { recursive: true });
fs.writeFileSync(
path.join(planDir, '01-01-PLAN.md'),
`---
phase: "01"
plan: "01"
must_haves:
acceptance:
bare content without dash prefix
another line without dash prefix
---
# Plan 01-01
`
);
const result = runGsdTools(
['frontmatter', 'get', path.join(planDir, '01-01-PLAN.md'), 'must_haves'],
tmpDir
);
const stderr = result.error || '';
assert.ok(
stderr.includes('WARNING') && stderr.includes('must_haves') ||
result.output.includes('acceptance'),
`Expected WARNING about must_haves parse or valid parse result. stderr: ${stderr}, stdout: ${result.output}`
);
});
test('stateReplaceFieldWithFallback logs warning on miss', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Current Plan:** 1\n**Total Plans in Phase:** 3\n`
);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.advanced === true || output.reason === 'last_plan', 'advance should complete');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 8. Malformed input resilience
// ─────────────────────────────────────────────────────────────────────────────
describe('malformed input resilience', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('STATE.md with invalid bold format -- state patch returns gracefully', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Current Phase: 01\n**Status:** Planning\n'
);
const result = runGsdTools('state patch --Status "In progress"', tmpDir);
const didNotCrash = result.success || (result.output !== undefined);
assert.ok(didNotCrash, `state patch should not crash on malformed bold format: ${result.error}`);
if (result.success) {
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(
content.includes('In progress'),
'Status field (with valid bold format) should be updated'
);
}
});
test('STATE.md with only frontmatter, no body -- state patch handles gracefully', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\nphase: "01"\n---\n'
);
const result = runGsdTools('state patch --Status "In progress"', tmpDir);
const didNotCrash = result.success || (result.output !== undefined);
assert.ok(didNotCrash, `state patch should not crash on frontmatter-only STATE.md: ${result.error}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 9. Stress tests with 50+ phases
// ─────────────────────────────────────────────────────────────────────────────
describe('stress tests with 50+ phases', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('roadmap analyze on 50-phase ROADMAP completes in under 2000ms', () => {
create50PhaseProject(tmpDir, 25);
const start = performance.now();
const result = runGsdTools('roadmap analyze', tmpDir);
const elapsed = performance.now() - start;
assert.ok(result.success, `roadmap analyze should succeed: ${result.error}`);
assert.ok(elapsed < 2000, `Should complete in under 2000ms, took ${elapsed.toFixed(0)}ms`);
const output = JSON.parse(result.output);
assert.ok(Array.isArray(output.phases), 'Output should contain a phases array');
assert.strictEqual(output.phases.length, 50, `Should have 50 phases, got ${output.phases.length}`);
const completedPhases = output.phases.filter(p => p.disk_status === 'complete');
assert.strictEqual(completedPhases.length, 25, `Should have 25 complete phases, got ${completedPhases.length}`);
});
test('phase complete on phase 26 of 50-phase project works correctly', () => {
create50PhaseProject(tmpDir, 25);
writeMinimalStateMd(tmpDir, '# Session State\n\n**Current Phase:** 26\n**Status:** In progress\n');
const phase26Dir = path.join(tmpDir, '.planning', 'phases', '26-feature-26');
fs.writeFileSync(
path.join(phase26Dir, '26-01-SUMMARY.md'),
'# Phase 26 Plan 1 Summary\n\nFeature 26 completed.\n'
);
const result = runGsdTools('phase complete 26', tmpDir);
assert.ok(result.success, `phase complete 26 should succeed: ${result.error}`);
const roadmapContent = fs.readFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'utf-8'
);
const phase26Checkbox = roadmapContent.match(/-\s*\[(x| )\]\s*.*Phase\s+26/i);
assert.ok(phase26Checkbox, 'Should find Phase 26 checkbox in ROADMAP');
assert.strictEqual(phase26Checkbox[1], 'x', 'Phase 26 should now be marked as complete [x]');
});
});