feat(manager): add interactive command center with dependency-aware recommendations

Add /gsd:manager — a single-terminal dashboard for managing milestones.
Shows all phases with visual status indicators (D/P/E columns), computes
recommended next actions, and dispatches discuss inline while plan/execute
run as background agents.

Key behaviors:
- Recommendation engine prioritizes execute > plan > discuss
- Filters parallel execute/plan when phases share dependency chains
- Independent phases (no direct or transitive dep relationship) CAN
  run in parallel — dependent phases are serialized
- Dashboard shows compact Deps column for at-a-glance dependency view
- Sliding window limits discuss to one phase at a time
- Activity detection via file mtime (5-min window) for is_active flag

New files:
- commands/gsd/manager.md — skill definition
- get-shit-done/workflows/manager.md — full workflow spec
- get-shit-done/bin/lib/init.cjs — cmdInitManager() with phase parsing,
  dependency graph traversal, and recommendation filtering
- get-shit-done/bin/gsd-tools.cjs — route 'init manager' to new command
- tests/init-manager.test.cjs — 16 tests covering status detection,
  deps, sliding window, recommendations, and edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
marcus.salinas
2026-03-21 07:22:21 -05:00
parent d585612afa
commit afcd2a8ac2
7 changed files with 1014 additions and 4 deletions

View File

@@ -614,8 +614,11 @@ async function main() {
case 'progress':
init.cmdInitProgress(cwd, raw);
break;
case 'manager':
init.cmdInitManager(cwd, raw);
break;
default:
error(`Unknown init workflow: ${workflow}\nAvailable: execute-phase, plan-phase, new-project, new-milestone, quick, resume, verify-work, phase-op, todos, milestone-op, map-codebase, progress`);
error(`Unknown init workflow: ${workflow}\nAvailable: execute-phase, plan-phase, new-project, new-milestone, quick, resume, verify-work, phase-op, todos, milestone-op, map-codebase, progress, manager`);
}
break;
}

View File

@@ -669,6 +669,252 @@ function cmdInitMapCodebase(cwd, raw) {
output(result, raw);
}
function cmdInitManager(cwd, raw) {
const config = loadConfig(cwd);
const milestone = getMilestoneInfo(cwd);
// Validate prerequisites
if (!pathExistsInternal(cwd, '.planning/ROADMAP.md')) {
error('No ROADMAP.md found. Run /gsd:new-milestone first.');
}
if (!pathExistsInternal(cwd, '.planning/STATE.md')) {
error('No STATE.md found. Run /gsd:new-milestone first.');
}
// Use roadmap analysis for rich phase data (depends_on, disk_status, has_context, etc.)
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd);
const phasesDir = path.join(cwd, '.planning', 'phases');
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
const phases = [];
let match;
while ((match = phasePattern.exec(content)) !== null) {
const phaseNum = match[1];
const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
const sectionStart = match.index;
const restOfContent = content.slice(sectionStart);
const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
const section = content.slice(sectionStart, sectionEnd);
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
const depends_on = dependsMatch ? dependsMatch[1].trim() : null;
const normalized = normalizePhaseName(phaseNum);
let diskStatus = 'no_directory';
let planCount = 0;
let summaryCount = 0;
let hasContext = false;
let hasResearch = false;
let lastActivity = null;
let isActive = false;
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
const dirMatch = dirs.find(d => d.startsWith(normalized + '-') || d === normalized);
if (dirMatch) {
const fullDir = path.join(phasesDir, dirMatch);
const phaseFiles = fs.readdirSync(fullDir);
planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete';
else if (summaryCount > 0) diskStatus = 'partial';
else if (planCount > 0) diskStatus = 'planned';
else if (hasResearch) diskStatus = 'researched';
else if (hasContext) diskStatus = 'discussed';
else diskStatus = 'empty';
// Activity detection: check most recent file mtime
const now = Date.now();
let newestMtime = 0;
for (const f of phaseFiles) {
try {
const stat = fs.statSync(path.join(fullDir, f));
if (stat.mtimeMs > newestMtime) newestMtime = stat.mtimeMs;
} catch { /* intentionally empty */ }
}
if (newestMtime > 0) {
lastActivity = new Date(newestMtime).toISOString();
isActive = (now - newestMtime) < 300000; // 5 minutes
}
}
} catch { /* intentionally empty */ }
// Check ROADMAP checkbox status
const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseNum.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[:\\s]`, 'i');
const checkboxMatch = content.match(checkboxPattern);
const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
if (roadmapComplete && diskStatus !== 'complete') {
diskStatus = 'complete';
}
phases.push({
number: phaseNum,
name: phaseName,
goal,
depends_on,
disk_status: diskStatus,
has_context: hasContext,
has_research: hasResearch,
plan_count: planCount,
summary_count: summaryCount,
roadmap_complete: roadmapComplete,
last_activity: lastActivity,
is_active: isActive,
});
}
// Compute display names: truncate to keep table aligned
const MAX_NAME_WIDTH = 20;
for (const phase of phases) {
if (phase.name.length > MAX_NAME_WIDTH) {
phase.display_name = phase.name.slice(0, MAX_NAME_WIDTH - 1) + '…';
} else {
phase.display_name = phase.name;
}
}
// Dependency satisfaction: check if all depends_on phases are complete
const completedNums = new Set(phases.filter(p => p.disk_status === 'complete').map(p => p.number));
for (const phase of phases) {
if (!phase.depends_on || /^none$/i.test(phase.depends_on.trim())) {
phase.deps_satisfied = true;
} else {
// Parse "Phase 1, Phase 3" or "1, 3" formats
const depNums = phase.depends_on.match(/\d+(?:\.\d+)*/g) || [];
phase.deps_satisfied = depNums.every(n => completedNums.has(n));
phase.dep_phases = depNums;
}
}
// Compact dependency display for dashboard
for (const phase of phases) {
phase.deps_display = (phase.dep_phases && phase.dep_phases.length > 0)
? phase.dep_phases.join(',')
: '—';
}
// Sliding window: discuss is sequential — only the first undiscussed phase is available
let foundNextToDiscuss = false;
for (const phase of phases) {
if (!foundNextToDiscuss && (phase.disk_status === 'empty' || phase.disk_status === 'no_directory')) {
phase.is_next_to_discuss = true;
foundNextToDiscuss = true;
} else {
phase.is_next_to_discuss = false;
}
}
// Check for WAITING.json signal
let waitingSignal = null;
try {
const waitingPath = path.join(cwd, '.planning', 'WAITING.json');
if (fs.existsSync(waitingPath)) {
waitingSignal = JSON.parse(fs.readFileSync(waitingPath, 'utf-8'));
}
} catch { /* intentionally empty */ }
// Compute recommended actions (execute > plan > discuss)
const recommendedActions = [];
for (const phase of phases) {
if (phase.disk_status === 'complete') continue;
if (phase.disk_status === 'planned' && phase.deps_satisfied) {
recommendedActions.push({
phase: phase.number,
phase_name: phase.name,
action: 'execute',
reason: `${phase.plan_count} plans ready, dependencies met`,
command: `/gsd:execute-phase ${phase.number}`,
});
} else if (phase.disk_status === 'discussed' || phase.disk_status === 'researched') {
recommendedActions.push({
phase: phase.number,
phase_name: phase.name,
action: 'plan',
reason: 'Context gathered, ready for planning',
command: `/gsd:plan-phase ${phase.number}`,
});
} else if ((phase.disk_status === 'empty' || phase.disk_status === 'no_directory') && phase.is_next_to_discuss) {
recommendedActions.push({
phase: phase.number,
phase_name: phase.name,
action: 'discuss',
reason: 'Unblocked, ready to gather context',
command: `/gsd:discuss-phase ${phase.number}`,
});
}
}
// Filter recommendations: no parallel execute/plan unless phases are independent
// Two phases are "independent" if neither depends on the other (directly or transitively)
const phaseMap = new Map(phases.map(p => [p.number, p]));
function reaches(from, to, visited = new Set()) {
if (visited.has(from)) return false;
visited.add(from);
const p = phaseMap.get(from);
if (!p || !p.dep_phases || p.dep_phases.length === 0) return false;
if (p.dep_phases.includes(to)) return true;
return p.dep_phases.some(dep => reaches(dep, to, visited));
}
function hasDepRelationship(numA, numB) {
return reaches(numA, numB) || reaches(numB, numA);
}
// Detect phases with active work (file modified in last 5 min)
const activeExecuting = phases.filter(p =>
p.disk_status === 'partial' ||
(p.disk_status === 'planned' && p.is_active)
);
const activePlanning = phases.filter(p =>
p.is_active && (p.disk_status === 'discussed' || p.disk_status === 'researched')
);
const filteredActions = recommendedActions.filter(action => {
if (action.action === 'execute' && activeExecuting.length > 0) {
// Only allow if independent of ALL actively-executing phases
return activeExecuting.every(active => !hasDepRelationship(action.phase, active.number));
}
if (action.action === 'plan' && activePlanning.length > 0) {
// Only allow if independent of ALL actively-planning phases
return activePlanning.every(active => !hasDepRelationship(action.phase, active.number));
}
return true;
});
const completedCount = phases.filter(p => p.disk_status === 'complete').length;
const result = {
milestone_version: milestone.version,
milestone_name: milestone.name,
phases,
phase_count: phases.length,
completed_count: completedCount,
in_progress_count: phases.filter(p => ['partial', 'planned', 'discussed', 'researched'].includes(p.disk_status)).length,
recommended_actions: filteredActions,
waiting_signal: waitingSignal,
all_complete: completedCount === phases.length && phases.length > 0,
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
roadmap_exists: true,
state_exists: true,
};
output(result, raw);
}
function cmdInitProgress(cwd, raw) {
const config = loadConfig(cwd);
const milestone = getMilestoneInfo(cwd);
@@ -829,4 +1075,5 @@ module.exports = {
cmdInitMilestoneOp,
cmdInitMapCodebase,
cmdInitProgress,
cmdInitManager,
};