Includes: CLAUDE.md, settings.json, agents, commands, rules, skills, hooks, contexts, evals, get-shit-done, plugin configs (installed list and marketplace sources). Excludes credentials, runtime caches, telemetry, session data, and plugin binary cache.
251 lines
9.0 KiB
JavaScript
251 lines
9.0 KiB
JavaScript
/**
|
|
* Milestone — Milestone and requirements lifecycle operations
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { escapeRegex, getMilestonePhaseFilter, normalizeMd, output, error } = require('./core.cjs');
|
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
const { writeStateMd } = require('./state.cjs');
|
|
|
|
function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
|
|
if (!reqIdsRaw || reqIdsRaw.length === 0) {
|
|
error('requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02');
|
|
}
|
|
|
|
// Accept comma-separated, space-separated, or bracket-wrapped: [REQ-01, REQ-02]
|
|
const reqIds = reqIdsRaw
|
|
.join(' ')
|
|
.replace(/[\[\]]/g, '')
|
|
.split(/[,\s]+/)
|
|
.map(r => r.trim())
|
|
.filter(Boolean);
|
|
|
|
if (reqIds.length === 0) {
|
|
error('no valid requirement IDs found');
|
|
}
|
|
|
|
const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md');
|
|
if (!fs.existsSync(reqPath)) {
|
|
output({ updated: false, reason: 'REQUIREMENTS.md not found', ids: reqIds }, raw, 'no requirements file');
|
|
return;
|
|
}
|
|
|
|
let reqContent = fs.readFileSync(reqPath, 'utf-8');
|
|
const updated = [];
|
|
const alreadyComplete = [];
|
|
const notFound = [];
|
|
|
|
for (const reqId of reqIds) {
|
|
let found = false;
|
|
const reqEscaped = escapeRegex(reqId);
|
|
|
|
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
|
const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
|
|
if (checkboxPattern.test(reqContent)) {
|
|
reqContent = reqContent.replace(checkboxPattern, '$1x$2');
|
|
found = true;
|
|
}
|
|
|
|
// Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete |
|
|
const tablePattern = new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi');
|
|
if (tablePattern.test(reqContent)) {
|
|
// Re-read since test() advances lastIndex for global regex
|
|
reqContent = reqContent.replace(
|
|
new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'),
|
|
'$1 Complete $2'
|
|
);
|
|
found = true;
|
|
}
|
|
|
|
if (found) {
|
|
updated.push(reqId);
|
|
} else {
|
|
// Check if already complete before declaring not_found
|
|
const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'gi');
|
|
const doneTable = new RegExp(`\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|\\s*Complete\\s*\\|`, 'gi');
|
|
if (doneCheckbox.test(reqContent) || doneTable.test(reqContent)) {
|
|
alreadyComplete.push(reqId);
|
|
} else {
|
|
notFound.push(reqId);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (updated.length > 0) {
|
|
fs.writeFileSync(reqPath, reqContent, 'utf-8');
|
|
}
|
|
|
|
output({
|
|
updated: updated.length > 0,
|
|
marked_complete: updated,
|
|
already_complete: alreadyComplete,
|
|
not_found: notFound,
|
|
total: reqIds.length,
|
|
}, raw, `${updated.length}/${reqIds.length} requirements marked complete`);
|
|
}
|
|
|
|
function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
if (!version) {
|
|
error('version required for milestone complete (e.g., v1.0)');
|
|
}
|
|
|
|
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
|
|
const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md');
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
const milestonesPath = path.join(cwd, '.planning', 'MILESTONES.md');
|
|
const archiveDir = path.join(cwd, '.planning', 'milestones');
|
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
|
const today = new Date().toISOString().split('T')[0];
|
|
const milestoneName = options.name || version;
|
|
|
|
// Ensure archive directory exists
|
|
fs.mkdirSync(archiveDir, { recursive: true });
|
|
|
|
// Scope stats and accomplishments to only the phases belonging to the
|
|
// current milestone's ROADMAP. Uses the shared filter from core.cjs
|
|
// (same logic used by cmdPhasesList and other callers).
|
|
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
|
|
|
// Gather stats from phases (scoped to current milestone only)
|
|
let phaseCount = 0;
|
|
let totalPlans = 0;
|
|
let totalTasks = 0;
|
|
const accomplishments = [];
|
|
|
|
try {
|
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
|
|
|
for (const dir of dirs) {
|
|
if (!isDirInMilestone(dir)) continue;
|
|
|
|
phaseCount++;
|
|
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
|
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
|
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
totalPlans += plans.length;
|
|
|
|
// Extract one-liners from summaries
|
|
for (const s of summaries) {
|
|
try {
|
|
const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8');
|
|
const fm = extractFrontmatter(content);
|
|
if (fm['one-liner']) {
|
|
accomplishments.push(fm['one-liner']);
|
|
}
|
|
// Count tasks
|
|
const taskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
|
|
totalTasks += taskMatches.length;
|
|
} catch {}
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// Archive ROADMAP.md
|
|
if (fs.existsSync(roadmapPath)) {
|
|
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
fs.writeFileSync(path.join(archiveDir, `${version}-ROADMAP.md`), roadmapContent, 'utf-8');
|
|
}
|
|
|
|
// Archive REQUIREMENTS.md
|
|
if (fs.existsSync(reqPath)) {
|
|
const reqContent = fs.readFileSync(reqPath, 'utf-8');
|
|
const archiveHeader = `# Requirements Archive: ${version} ${milestoneName}\n\n**Archived:** ${today}\n**Status:** SHIPPED\n\nFor current requirements, see \`.planning/REQUIREMENTS.md\`.\n\n---\n\n`;
|
|
fs.writeFileSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent, 'utf-8');
|
|
}
|
|
|
|
// Archive audit file if exists
|
|
const auditFile = path.join(cwd, '.planning', `${version}-MILESTONE-AUDIT.md`);
|
|
if (fs.existsSync(auditFile)) {
|
|
fs.renameSync(auditFile, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
|
|
}
|
|
|
|
// Create/append MILESTONES.md entry
|
|
const accomplishmentsList = accomplishments.map(a => `- ${a}`).join('\n');
|
|
const milestoneEntry = `## ${version} ${milestoneName} (Shipped: ${today})\n\n**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`;
|
|
|
|
if (fs.existsSync(milestonesPath)) {
|
|
const existing = fs.readFileSync(milestonesPath, 'utf-8');
|
|
if (!existing.trim()) {
|
|
// Empty file — treat like new
|
|
fs.writeFileSync(milestonesPath, normalizeMd(`# Milestones\n\n${milestoneEntry}`), 'utf-8');
|
|
} else {
|
|
// Insert after the header line(s) for reverse chronological order (newest first)
|
|
const headerMatch = existing.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
|
|
if (headerMatch) {
|
|
const header = headerMatch[1];
|
|
const rest = existing.slice(header.length);
|
|
fs.writeFileSync(milestonesPath, normalizeMd(header + milestoneEntry + rest), 'utf-8');
|
|
} else {
|
|
// No recognizable header — prepend the entry
|
|
fs.writeFileSync(milestonesPath, normalizeMd(milestoneEntry + existing), 'utf-8');
|
|
}
|
|
}
|
|
} else {
|
|
fs.writeFileSync(milestonesPath, normalizeMd(`# Milestones\n\n${milestoneEntry}`), 'utf-8');
|
|
}
|
|
|
|
// Update STATE.md
|
|
if (fs.existsSync(statePath)) {
|
|
let stateContent = fs.readFileSync(statePath, 'utf-8');
|
|
stateContent = stateContent.replace(
|
|
/(\*\*Status:\*\*\s*).*/,
|
|
`$1${version} milestone complete`
|
|
);
|
|
stateContent = stateContent.replace(
|
|
/(\*\*Last Activity:\*\*\s*).*/,
|
|
`$1${today}`
|
|
);
|
|
stateContent = stateContent.replace(
|
|
/(\*\*Last Activity Description:\*\*\s*).*/,
|
|
`$1${version} milestone completed and archived`
|
|
);
|
|
writeStateMd(statePath, stateContent, cwd);
|
|
}
|
|
|
|
// Archive phase directories if requested
|
|
let phasesArchived = false;
|
|
if (options.archivePhases) {
|
|
try {
|
|
const phaseArchiveDir = path.join(archiveDir, `${version}-phases`);
|
|
fs.mkdirSync(phaseArchiveDir, { recursive: true });
|
|
|
|
const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
const phaseDirNames = phaseEntries.filter(e => e.isDirectory()).map(e => e.name);
|
|
let archivedCount = 0;
|
|
for (const dir of phaseDirNames) {
|
|
if (!isDirInMilestone(dir)) continue;
|
|
fs.renameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir));
|
|
archivedCount++;
|
|
}
|
|
phasesArchived = archivedCount > 0;
|
|
} catch {}
|
|
}
|
|
|
|
const result = {
|
|
version,
|
|
name: milestoneName,
|
|
date: today,
|
|
phases: phaseCount,
|
|
plans: totalPlans,
|
|
tasks: totalTasks,
|
|
accomplishments,
|
|
archived: {
|
|
roadmap: fs.existsSync(path.join(archiveDir, `${version}-ROADMAP.md`)),
|
|
requirements: fs.existsSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`)),
|
|
audit: fs.existsSync(path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)),
|
|
phases: phasesArchived,
|
|
},
|
|
milestones_updated: true,
|
|
state_updated: fs.existsSync(statePath),
|
|
};
|
|
|
|
output(result, raw);
|
|
}
|
|
|
|
module.exports = {
|
|
cmdRequirementsMarkComplete,
|
|
cmdMilestoneComplete,
|
|
};
|