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:
22
commands/gsd/health.md
Normal file
22
commands/gsd/health.md
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
156
get-shit-done/workflows/health.md
Normal file
156
get-shit-done/workflows/health.md
Normal 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>
|
||||
Reference in New Issue
Block a user