feat(phases): archive completed milestone phase directories (closes #489)

Add optional phase archival to milestone completion and a standalone
/gsd:cleanup command for retroactive use. Phase dirs move to
.planning/milestones/v{X.Y}-phases/, reducing phases/ clutter after
multiple milestones.

Core changes:
- getArchivedPhaseDirs() and searchPhaseInDir() helpers in gsd-tools
- findPhaseInternal() searches archives when phase not found in current
- cmdPhasesList() accepts --include-archived flag
- cmdHistoryDigest() scans both current and archived phases
- cmdMilestoneComplete() accepts --archive-phases flag
- Workflow globs replaced with find-phase/phases-list CLI calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson
2026-02-15 11:45:16 -06:00
parent 8d977328e4
commit 41cb7455f5
8 changed files with 346 additions and 34 deletions

18
commands/gsd/cleanup.md Normal file
View File

@@ -0,0 +1,18 @@
---
name: gsd:cleanup
description: Archive accumulated phase directories from completed milestones
---
<objective>
Archive phase directories from completed milestones into `.planning/milestones/v{X.Y}-phases/`.
Use when `.planning/phases/` has accumulated directories from past milestones.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/cleanup.md
</execution_context>
<process>
Follow the cleanup workflow at @~/.claude/get-shit-done/workflows/cleanup.md.
Identify completed milestones, show a dry-run summary, and archive on confirmation.
</process>

View File

@@ -44,6 +44,7 @@
* Milestone Operations:
* milestone complete <version> Archive milestone, create MILESTONES.md
* [--name <name>]
* [--archive-phases] Move phase dirs to milestones/vX.Y-phases/
*
* Validation:
* validate consistency Check phase numbering, disk/roadmap sync
@@ -694,20 +695,36 @@ function cmdHistoryDigest(cwd, raw) {
const phasesDir = path.join(cwd, '.planning', 'phases');
const digest = { phases: {}, decisions: [], tech_stack: new Set() };
if (!fs.existsSync(phasesDir)) {
// Collect all phase directories: archived + current
const allPhaseDirs = [];
// Add archived phases first (oldest milestones first)
const archived = getArchivedPhaseDirs(cwd);
for (const a of archived) {
allPhaseDirs.push({ name: a.name, fullPath: a.fullPath, milestone: a.milestone });
}
// Add current phases
if (fs.existsSync(phasesDir)) {
try {
const currentDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
for (const dir of currentDirs) {
allPhaseDirs.push({ name: dir, fullPath: path.join(phasesDir, dir), milestone: null });
}
} catch {}
}
if (allPhaseDirs.length === 0) {
digest.tech_stack = [];
output(digest, raw);
return;
}
try {
const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
for (const dir of phaseDirs) {
const dirPath = path.join(phasesDir, dir);
for (const { name: dir, fullPath: dirPath } of allPhaseDirs) {
const summaries = fs.readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
for (const summary of summaries) {
@@ -777,7 +794,7 @@ function cmdHistoryDigest(cwd, raw) {
function cmdPhasesList(cwd, options, raw) {
const phasesDir = path.join(cwd, '.planning', 'phases');
const { type, phase } = options;
const { type, phase, includeArchived } = options;
// If no phases directory, return empty
if (!fs.existsSync(phasesDir)) {
@@ -794,6 +811,14 @@ function cmdPhasesList(cwd, options, raw) {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
let dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
// Include archived phases if requested
if (includeArchived) {
const archived = getArchivedPhaseDirs(cwd);
for (const a of archived) {
dirs.push(`${a.name} [${a.milestone}]`);
}
}
// Sort numerically (handles decimals: 01, 02, 02.1, 02.2, 03)
dirs.sort((a, b) => {
const aNum = parseFloat(a.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0');
@@ -3338,6 +3363,22 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
fs.writeFileSync(statePath, stateContent, 'utf-8');
}
// 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);
for (const dir of phaseDirNames) {
fs.renameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir));
}
phasesArchived = phaseDirNames.length > 0;
} catch {}
}
const result = {
version,
name: milestoneName,
@@ -3350,6 +3391,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
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),
@@ -3662,14 +3704,44 @@ function resolveModelInternal(cwd, agentType) {
return resolved === 'opus' ? 'inherit' : resolved;
}
function findPhaseInternal(cwd, phase) {
if (!phase) return null;
function getArchivedPhaseDirs(cwd) {
const milestonesDir = path.join(cwd, '.planning', 'milestones');
const results = [];
const phasesDir = path.join(cwd, '.planning', 'phases');
const normalized = normalizePhaseName(phase);
if (!fs.existsSync(milestonesDir)) return results;
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
// Find v*-phases directories, sort newest first
const phaseDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
for (const archiveName of phaseDirs) {
const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
const archivePath = path.join(milestonesDir, archiveName);
const entries = fs.readdirSync(archivePath, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
for (const dir of dirs) {
results.push({
name: dir,
milestone: version,
basePath: path.join('.planning', 'milestones', archiveName),
fullPath: path.join(archivePath, dir),
});
}
}
} catch {}
return results;
}
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();
const match = dirs.find(d => d.startsWith(normalized));
if (!match) return null;
@@ -3677,7 +3749,7 @@ function findPhaseInternal(cwd, phase) {
const dirMatch = match.match(/^(\d+(?:\.\d+)?)-?(.*)/);
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
const phaseDir = path.join(phasesDir, match);
const phaseDir = path.join(baseDir, match);
const phaseFiles = fs.readdirSync(phaseDir);
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
@@ -3686,7 +3758,6 @@ function findPhaseInternal(cwd, phase) {
const hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
const hasVerification = phaseFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
// Determine incomplete plans (plans without matching summaries)
const completedPlanIds = new Set(
summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
);
@@ -3697,7 +3768,7 @@ function findPhaseInternal(cwd, phase) {
return {
found: true,
directory: path.join('.planning', 'phases', match),
directory: path.join(relBase, match),
phase_number: phaseNumber,
phase_name: phaseName,
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
@@ -3713,6 +3784,43 @@ function findPhaseInternal(cwd, phase) {
}
}
function findPhaseInternal(cwd, phase) {
if (!phase) return null;
const phasesDir = path.join(cwd, '.planning', 'phases');
const normalized = normalizePhaseName(phase);
// Search current phases first
const current = searchPhaseInDir(phasesDir, path.join('.planning', 'phases'), normalized);
if (current) return current;
// Search archived milestone phases (newest first)
const milestonesDir = path.join(cwd, '.planning', 'milestones');
if (!fs.existsSync(milestonesDir)) return null;
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
const archiveDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
for (const archiveName of archiveDirs) {
const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
const archivePath = path.join(milestonesDir, archiveName);
const relBase = path.join('.planning', 'milestones', archiveName);
const result = searchPhaseInDir(archivePath, relBase, normalized);
if (result) {
result.archived = version;
return result;
}
}
} catch {}
return null;
}
function getRoadmapPhaseInternal(cwd, phaseNum) {
if (!phaseNum) return null;
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
@@ -4660,6 +4768,7 @@ async function main() {
const options = {
type: typeIndex !== -1 ? args[typeIndex + 1] : null,
phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null,
includeArchived: args.includes('--include-archived'),
};
cmdPhasesList(cwd, options, raw);
} else {
@@ -4705,8 +4814,18 @@ async function main() {
const subcommand = args[1];
if (subcommand === 'complete') {
const nameIndex = args.indexOf('--name');
const milestoneName = nameIndex !== -1 ? args.slice(nameIndex + 1).join(' ') : null;
cmdMilestoneComplete(cwd, args[2], { name: milestoneName }, raw);
const archivePhases = args.includes('--archive-phases');
// Collect --name value (everything after --name until next flag or end)
let milestoneName = null;
if (nameIndex !== -1) {
const nameArgs = [];
for (let i = nameIndex + 1; i < args.length; i++) {
if (args[i].startsWith('--')) break;
nameArgs.push(args[i]);
}
milestoneName = nameArgs.join(' ') || null;
}
cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases }, raw);
} else {
error('Unknown milestone subcommand. Available: complete');
}

View File

@@ -38,9 +38,10 @@ node ~/.claude/get-shit-done/bin/gsd-tools.cjs phases list
For each phase directory, read the VERIFICATION.md:
```bash
cat .planning/phases/01-*/*-VERIFICATION.md
cat .planning/phases/02-*/*-VERIFICATION.md
# etc.
# For each phase, use find-phase to resolve the directory (handles archived phases)
PHASE_INFO=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs find-phase 01 --raw)
# Extract directory from JSON, then read VERIFICATION.md from that directory
# Repeat for each phase number from ROADMAP.md
```
From each VERIFICATION.md, extract:

View File

@@ -0,0 +1,152 @@
<purpose>
Archive accumulated phase directories from completed milestones into `.planning/milestones/v{X.Y}-phases/`. Identifies which phases belong to each completed milestone, shows a dry-run summary, and moves directories on confirmation.
</purpose>
<required_reading>
1. `.planning/MILESTONES.md`
2. `.planning/milestones/` directory listing
3. `.planning/phases/` directory listing
</required_reading>
<process>
<step name="identify_completed_milestones">
Read `.planning/MILESTONES.md` to identify completed milestones and their versions.
```bash
cat .planning/MILESTONES.md
```
Extract each milestone version (e.g., v1.0, v1.1, v2.0).
Check which milestone archive dirs already exist:
```bash
ls -d .planning/milestones/v*-phases 2>/dev/null
```
Filter to milestones that do NOT already have a `-phases` archive directory.
If all milestones already have phase archives:
```
All completed milestones already have phase directories archived. Nothing to clean up.
```
Stop here.
</step>
<step name="determine_phase_membership">
For each completed milestone without a `-phases` archive, read the archived ROADMAP snapshot to determine which phases belong to it:
```bash
cat .planning/milestones/v{X.Y}-ROADMAP.md
```
Extract phase numbers and names from the archived roadmap (e.g., Phase 1: Foundation, Phase 2: Auth).
Check which of those phase directories still exist in `.planning/phases/`:
```bash
ls -d .planning/phases/*/ 2>/dev/null
```
Match phase directories to milestone membership. Only include directories that still exist in `.planning/phases/`.
</step>
<step name="show_dry_run">
Present a dry-run summary for each milestone:
```
## Cleanup Summary
### v{X.Y} — {Milestone Name}
These phase directories will be archived:
- 01-foundation/
- 02-auth/
- 03-core-features/
Destination: .planning/milestones/v{X.Y}-phases/
### v{X.Z} — {Milestone Name}
These phase directories will be archived:
- 04-security/
- 05-hardening/
Destination: .planning/milestones/v{X.Z}-phases/
```
If no phase directories remain to archive (all already moved or deleted):
```
No phase directories found to archive. Phases may have been removed or archived previously.
```
Stop here.
AskUserQuestion: "Proceed with archiving?" with options: "Yes — archive listed phases" | "Cancel"
If "Cancel": Stop.
</step>
<step name="archive_phases">
For each milestone, move phase directories:
```bash
mkdir -p .planning/milestones/v{X.Y}-phases
```
For each phase directory belonging to this milestone:
```bash
mv .planning/phases/{dir} .planning/milestones/v{X.Y}-phases/
```
Repeat for all milestones in the cleanup set.
</step>
<step name="commit">
Commit the changes:
```bash
node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit "chore: archive phase directories from completed milestones" --files .planning/milestones/ .planning/phases/
```
</step>
<step name="report">
```
Archived:
{For each milestone}
- v{X.Y}: {N} phase directories → .planning/milestones/v{X.Y}-phases/
.planning/phases/ cleaned up.
```
</step>
</process>
<success_criteria>
- [ ] All completed milestones without existing phase archives identified
- [ ] Phase membership determined from archived ROADMAP snapshots
- [ ] Dry-run summary shown and user confirmed
- [ ] Phase directories moved to `.planning/milestones/v{X.Y}-phases/`
- [ ] Changes committed
</success_criteria>

View File

@@ -359,7 +359,19 @@ Extract from result: `version`, `date`, `phases`, `plans`, `tasks`, `accomplishm
Verify: `✅ Milestone archived to .planning/milestones/`
**Note:** Phase directories (`.planning/phases/`) are NOT deleted — they accumulate across milestones as raw execution history. Phase numbering continues (v1.0 phases 1-4, v1.1 phases 5-8, etc.).
**Phase archival (optional):** After archival completes, ask the user:
AskUserQuestion(header="Archive Phases", question="Archive phase directories to milestones/?", options: "Yes — move to milestones/v[X.Y]-phases/" | "Skip — keep phases in place")
If "Yes": move phase directories to the milestone archive:
```bash
mkdir -p .planning/milestones/v[X.Y]-phases
# For each phase directory in .planning/phases/:
mv .planning/phases/{phase-dir} .planning/milestones/v[X.Y]-phases/
```
Verify: `✅ Phase directories archived to .planning/milestones/v[X.Y]-phases/`
If "Skip": Phase directories remain in `.planning/phases/` as raw execution history. Use `/gsd:cleanup` later to archive retroactively.
After archival, the AI still handles:
- Reorganizing ROADMAP.md with milestone grouping (requires judgment)

View File

@@ -241,7 +241,8 @@ fi
**2. Find parent UAT file:**
```bash
find .planning/phases -path "*${PARENT_PHASE}*/*-UAT.md" -type f 2>/dev/null
PARENT_INFO=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs find-phase "${PARENT_PHASE}" --raw)
# Extract directory from PARENT_INFO JSON, then find UAT file in that directory
```
**If no parent UAT found:** Skip this step (gap-closure may have been triggered by VERIFICATION.md instead).

View File

@@ -15,15 +15,7 @@ Read config.json for planning behavior settings.
Load execution context (uses `init execute-phase` for full context, including file contents):
```bash
INIT_RAW=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs init execute-phase "${PHASE}" --include state,config)
# Large payloads are written to a tmpfile — output starts with @file:/path
if [[ "$INIT_RAW" == @file:* ]]; then
INIT_FILE="${INIT_RAW#@file:}"
INIT=$(cat "$INIT_FILE")
rm -f "$INIT_FILE"
else
INIT="$INIT_RAW"
fi
INIT=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs init execute-phase "${PHASE}" --include state,config)
```
Extract from init JSON: `executor_model`, `commit_docs`, `phase_dir`, `phase_number`, `plans`, `summaries`, `incomplete_plans`.
@@ -136,7 +128,8 @@ This IS the execution instructions. Follow exactly. If plan references CONTEXT.m
<step name="previous_phase_check">
```bash
ls .planning/phases/*/SUMMARY.md 2>/dev/null | sort -r | head -2 | tail -1
node ~/.claude/get-shit-done/bin/gsd-tools.cjs phases list --type summaries --raw
# Extract the second-to-last summary from the JSON result
```
If previous SUMMARY has unresolved "Issues Encountered" or "Next Phase Readiness" blockers: AskUserQuestion(header="Previous Issues", options: "Proceed anyway" | "Address first" | "Review previous").
</step>

View File

@@ -312,6 +312,16 @@ Usage: `/gsd:set-profile budget`
### Utility Commands
**`/gsd:cleanup`**
Archive accumulated phase directories from completed milestones.
- Identifies phases from completed milestones still in `.planning/phases/`
- Shows dry-run summary before moving anything
- Moves phase dirs to `.planning/milestones/v{X.Y}-phases/`
- Use after multiple milestones to reduce `.planning/phases/` clutter
Usage: `/gsd:cleanup`
**`/gsd:help`**
Show this command reference.
@@ -347,6 +357,12 @@ Usage: `/gsd:join-discord`
│ └── done/ # Completed todos
├── debug/ # Active debug sessions
│ └── resolved/ # Archived resolved issues
├── milestones/
│ ├── v1.0-ROADMAP.md # Archived roadmap snapshot
│ ├── v1.0-REQUIREMENTS.md # Archived requirements
│ └── v1.0-phases/ # Archived phase dirs (via /gsd:cleanup or --archive-phases)
│ ├── 01-foundation/
│ └── 02-core-features/
├── codebase/ # Codebase map (brownfield projects)
│ ├── STACK.md # Languages, frameworks, dependencies
│ ├── ARCHITECTURE.md # Patterns, layers, data flow