feat(health): add /gsd:health command for planning directory validation

Validates .planning/ integrity and reports actionable issues:
- Missing/malformed files (PROJECT.md, ROADMAP.md, STATE.md, config.json)
- Phase directory naming, orphaned plans, roadmap/disk consistency
- Auto-repair for config.json and STATE.md with --repair flag

Closes #338

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson
2026-02-15 16:25:01 -06:00
parent 9aca081d2f
commit cb7d4dbc3c
3 changed files with 424 additions and 1 deletions

22
commands/gsd/health.md Normal file
View File

@@ -0,0 +1,22 @@
---
name: gsd:health
description: Diagnose planning directory health and optionally repair issues
argument-hint: [--repair]
allowed-tools:
- Read
- Bash
- Write
- AskUserQuestion
---
<objective>
Validate `.planning/` directory integrity and report actionable issues. Checks for missing files, invalid configurations, inconsistent state, and orphaned plans.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/health.md
</execution_context>
<process>
Execute the health workflow from @~/.claude/get-shit-done/workflows/health.md end-to-end.
Parse --repair flag from arguments and pass to workflow.
</process>

View File

@@ -48,6 +48,7 @@
*
* Validation:
* validate consistency Check phase numbering, disk/roadmap sync
* validate health [--repair] Check .planning/ integrity, optionally repair
*
* Progress:
* progress [json|table|bar] Render progress in various formats
@@ -3524,6 +3525,247 @@ function cmdValidateConsistency(cwd, raw) {
output({ passed, errors, warnings, warning_count: warnings.length }, raw, passed ? 'passed' : 'failed');
}
// ─── Validate Health ──────────────────────────────────────────────────────────
function cmdValidateHealth(cwd, options, raw) {
const planningDir = path.join(cwd, '.planning');
const projectPath = path.join(planningDir, 'PROJECT.md');
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
const statePath = path.join(planningDir, 'STATE.md');
const configPath = path.join(planningDir, 'config.json');
const phasesDir = path.join(planningDir, 'phases');
const errors = [];
const warnings = [];
const info = [];
const repairs = [];
// Helper to add issue
const addIssue = (severity, code, message, fix, repairable = false) => {
const issue = { code, message, fix, repairable };
if (severity === 'error') errors.push(issue);
else if (severity === 'warning') warnings.push(issue);
else info.push(issue);
};
// ─── Check 1: .planning/ exists ───────────────────────────────────────────
if (!fs.existsSync(planningDir)) {
addIssue('error', 'E001', '.planning/ directory not found', 'Run /gsd:new-project to initialize');
output({
status: 'broken',
errors,
warnings,
info,
repairable_count: 0,
}, raw);
return;
}
// ─── Check 2: PROJECT.md exists and has required sections ─────────────────
if (!fs.existsSync(projectPath)) {
addIssue('error', 'E002', 'PROJECT.md not found', 'Run /gsd:new-project to create');
} else {
const content = fs.readFileSync(projectPath, 'utf-8');
const requiredSections = ['## What This Is', '## Core Value', '## Requirements'];
for (const section of requiredSections) {
if (!content.includes(section)) {
addIssue('warning', 'W001', `PROJECT.md missing section: ${section}`, 'Add section manually');
}
}
}
// ─── Check 3: ROADMAP.md exists ───────────────────────────────────────────
if (!fs.existsSync(roadmapPath)) {
addIssue('error', 'E003', 'ROADMAP.md not found', 'Run /gsd:new-milestone to create roadmap');
}
// ─── Check 4: STATE.md exists and references valid phases ─────────────────
if (!fs.existsSync(statePath)) {
addIssue('error', 'E004', 'STATE.md not found', 'Run /gsd:health --repair to regenerate', true);
repairs.push('regenerateState');
} else {
const stateContent = fs.readFileSync(statePath, 'utf-8');
// Extract phase references from STATE.md
const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)?)/g)].map(m => m[1]);
// Get disk phases
const diskPhases = new Set();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
const m = e.name.match(/^(\d+(?:\.\d+)?)/);
if (m) diskPhases.add(m[1]);
}
}
} catch {}
// Check for invalid references
for (const ref of phaseRefs) {
const normalizedRef = String(parseInt(ref, 10)).padStart(2, '0');
if (!diskPhases.has(ref) && !diskPhases.has(normalizedRef) && !diskPhases.has(String(parseInt(ref, 10)))) {
// Only warn if phases dir has any content (not just an empty project)
if (diskPhases.size > 0) {
addIssue('warning', 'W002', `STATE.md references phase ${ref}, but only phases ${[...diskPhases].sort().join(', ')} exist`, 'Run /gsd:health --repair to regenerate STATE.md', true);
if (!repairs.includes('regenerateState')) repairs.push('regenerateState');
}
}
}
}
// ─── Check 5: config.json valid JSON + valid schema ───────────────────────
if (!fs.existsSync(configPath)) {
addIssue('warning', 'W003', 'config.json not found', 'Run /gsd:health --repair to create with defaults', true);
repairs.push('createConfig');
} else {
try {
const raw = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw);
// Validate known fields
const validProfiles = ['quality', 'balanced', 'budget'];
if (parsed.model_profile && !validProfiles.includes(parsed.model_profile)) {
addIssue('warning', 'W004', `config.json: invalid model_profile "${parsed.model_profile}"`, `Valid values: ${validProfiles.join(', ')}`);
}
} catch (err) {
addIssue('error', 'E005', `config.json: JSON parse error - ${err.message}`, 'Run /gsd:health --repair to reset to defaults', true);
repairs.push('resetConfig');
}
}
// ─── Check 6: Phase directory naming (NN-name format) ─────────────────────
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory() && !e.name.match(/^\d{2}(?:\.\d+)?-[\w-]+$/)) {
addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)');
}
}
} catch {}
// ─── Check 7: Orphaned plans (PLAN without SUMMARY) ───────────────────────
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (!e.isDirectory()) continue;
const phaseFiles = fs.readdirSync(path.join(phasesDir, e.name));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
const summaryBases = new Set(summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')));
for (const plan of plans) {
const planBase = plan.replace('-PLAN.md', '').replace('PLAN.md', '');
if (!summaryBases.has(planBase)) {
addIssue('info', 'I001', `${e.name}/${plan} has no SUMMARY.md`, 'May be in progress');
}
}
}
} catch {}
// ─── Check 8: Run existing consistency checks ─────────────────────────────
// Inline subset of cmdValidateConsistency
if (fs.existsSync(roadmapPath)) {
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const roadmapPhases = new Set();
const phasePattern = /#{2,4}\s*Phase\s+(\d+(?:\.\d+)?)\s*:/gi;
let m;
while ((m = phasePattern.exec(roadmapContent)) !== null) {
roadmapPhases.add(m[1]);
}
const diskPhases = new Set();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
const dm = e.name.match(/^(\d+(?:\.\d+)?)/);
if (dm) diskPhases.add(dm[1]);
}
}
} catch {}
// Phases in ROADMAP but not on disk
for (const p of roadmapPhases) {
const padded = String(parseInt(p, 10)).padStart(2, '0');
if (!diskPhases.has(p) && !diskPhases.has(padded)) {
addIssue('warning', 'W006', `Phase ${p} in ROADMAP.md but no directory on disk`, 'Create phase directory or remove from roadmap');
}
}
// Phases on disk but not in ROADMAP
for (const p of diskPhases) {
const unpadded = String(parseInt(p, 10));
if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded)) {
addIssue('warning', 'W007', `Phase ${p} exists on disk but not in ROADMAP.md`, 'Add to roadmap or remove directory');
}
}
}
// ─── Perform repairs if requested ─────────────────────────────────────────
const repairActions = [];
if (options.repair && repairs.length > 0) {
for (const repair of repairs) {
try {
switch (repair) {
case 'createConfig':
case 'resetConfig': {
const defaults = {
model_profile: 'balanced',
commit_docs: true,
search_gitignored: false,
branching_strategy: 'none',
research: true,
plan_checker: true,
verifier: true,
parallelization: true,
};
fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8');
repairActions.push({ action: repair, success: true, path: 'config.json' });
break;
}
case 'regenerateState': {
// Generate minimal STATE.md from ROADMAP.md structure
const milestone = getMilestoneInfo(cwd);
let stateContent = `# Session State\n\n`;
stateContent += `## Project Reference\n\n`;
stateContent += `See: .planning/PROJECT.md\n\n`;
stateContent += `## Position\n\n`;
stateContent += `**Milestone:** ${milestone.version} ${milestone.name}\n`;
stateContent += `**Current phase:** (determining...)\n`;
stateContent += `**Status:** Resuming\n\n`;
stateContent += `## Session Log\n\n`;
stateContent += `- ${new Date().toISOString().split('T')[0]}: STATE.md regenerated by /gsd:health --repair\n`;
fs.writeFileSync(statePath, stateContent, 'utf-8');
repairActions.push({ action: repair, success: true, path: 'STATE.md' });
break;
}
}
} catch (err) {
repairActions.push({ action: repair, success: false, error: err.message });
}
}
}
// ─── Determine overall status ─────────────────────────────────────────────
let status;
if (errors.length > 0) {
status = 'broken';
} else if (warnings.length > 0) {
status = 'degraded';
} else {
status = 'healthy';
}
const repairableCount = errors.filter(e => e.repairable).length +
warnings.filter(w => w.repairable).length;
output({
status,
errors,
warnings,
info,
repairable_count: repairableCount,
repairs_performed: repairActions.length > 0 ? repairActions : undefined,
}, raw);
}
// ─── Progress Render ──────────────────────────────────────────────────────────
function cmdProgressRender(cwd, format, raw) {
@@ -4838,8 +5080,11 @@ async function main() {
const subcommand = args[1];
if (subcommand === 'consistency') {
cmdValidateConsistency(cwd, raw);
} else if (subcommand === 'health') {
const repairFlag = args.includes('--repair');
cmdValidateHealth(cwd, { repair: repairFlag }, raw);
} else {
error('Unknown validate subcommand. Available: consistency');
error('Unknown validate subcommand. Available: consistency, health');
}
break;
}

View File

@@ -0,0 +1,156 @@
<purpose>
Validate `.planning/` directory integrity and report actionable issues. Checks for missing files, invalid configurations, inconsistent state, and orphaned plans. Optionally repairs auto-fixable issues.
</purpose>
<required_reading>
Read all files referenced by the invoking prompt's execution_context before starting.
</required_reading>
<process>
<step name="parse_args">
**Parse arguments:**
Check if `--repair` flag is present in the command arguments.
```
REPAIR_FLAG=""
if arguments contain "--repair"; then
REPAIR_FLAG="--repair"
fi
```
</step>
<step name="run_health_check">
**Run health validation:**
```bash
node ~/.claude/get-shit-done/bin/gsd-tools.cjs validate health $REPAIR_FLAG
```
Parse JSON output:
- `status`: "healthy" | "degraded" | "broken"
- `errors[]`: Critical issues (code, message, fix, repairable)
- `warnings[]`: Non-critical issues
- `info[]`: Informational notes
- `repairable_count`: Number of auto-fixable issues
- `repairs_performed[]`: Actions taken if --repair was used
</step>
<step name="format_output">
**Format and display results:**
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GSD Health Check
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Status: HEALTHY | DEGRADED | BROKEN
Errors: N | Warnings: N | Info: N
```
**If repairs were performed:**
```
## Repairs Performed
- ✓ config.json: Created with defaults
- ✓ STATE.md: Regenerated from roadmap
```
**If errors exist:**
```
## Errors
- [E001] config.json: JSON parse error at line 5
Fix: Run /gsd:health --repair to reset to defaults
- [E002] PROJECT.md not found
Fix: Run /gsd:new-project to create
```
**If warnings exist:**
```
## Warnings
- [W001] STATE.md references phase 5, but only phases 1-3 exist
Fix: Run /gsd:health --repair to regenerate
- [W005] Phase directory "1-setup" doesn't follow NN-name format
Fix: Rename to match pattern (e.g., 01-setup)
```
**If info exists:**
```
## Info
- [I001] 02-implementation/02-01-PLAN.md has no SUMMARY.md
Note: May be in progress
```
**Footer (if repairable issues exist and --repair was NOT used):**
```
---
N issues can be auto-repaired. Run: /gsd:health --repair
```
</step>
<step name="offer_repair">
**If repairable issues exist and --repair was NOT used:**
Ask user if they want to run repairs:
```
Would you like to run /gsd:health --repair to fix N issues automatically?
```
If yes, re-run with --repair flag and display results.
</step>
<step name="verify_repairs">
**If repairs were performed:**
Re-run health check without --repair to confirm issues are resolved:
```bash
node ~/.claude/get-shit-done/bin/gsd-tools.cjs validate health
```
Report final status.
</step>
</process>
<error_codes>
| Code | Severity | Description | Repairable |
|------|----------|-------------|------------|
| E001 | error | .planning/ directory not found | No |
| E002 | error | PROJECT.md not found | No |
| E003 | error | ROADMAP.md not found | No |
| E004 | error | STATE.md not found | Yes |
| E005 | error | config.json parse error | Yes |
| W001 | warning | PROJECT.md missing required section | No |
| W002 | warning | STATE.md references invalid phase | Yes |
| W003 | warning | config.json not found | Yes |
| W004 | warning | config.json invalid field value | No |
| W005 | warning | Phase directory naming mismatch | No |
| W006 | warning | Phase in ROADMAP but no directory | No |
| W007 | warning | Phase on disk but not in ROADMAP | No |
| I001 | info | Plan without SUMMARY (may be in progress) | No |
</error_codes>
<repair_actions>
| Action | Effect | Risk |
|--------|--------|------|
| createConfig | Create config.json with defaults | None |
| resetConfig | Delete + recreate config.json | Loses custom settings |
| regenerateState | Create STATE.md from ROADMAP structure | Loses session history |
**Not repairable (too risky):**
- PROJECT.md, ROADMAP.md content
- Phase directory renaming
- Orphaned plan cleanup
</repair_actions>