diff --git a/commands/gsd/manager.md b/commands/gsd/manager.md new file mode 100644 index 00000000..b387dd1e --- /dev/null +++ b/commands/gsd/manager.md @@ -0,0 +1,39 @@ +--- +name: gsd:manager +description: Interactive command center for managing multiple phases from one terminal +allowed-tools: + - Read + - Write + - Bash + - Glob + - Grep + - AskUserQuestion + - Task +--- + +Single-terminal command center for managing a milestone. Shows a dashboard of all phases with visual status indicators, recommends optimal next actions, and dispatches work — discuss runs inline, plan/execute run as background agents. + +Designed for power users who want to parallelize work across phases from one terminal: discuss a phase while another plans or executes in the background. + +**Creates/Updates:** +- No files created directly — dispatches to existing GSD commands via Skill() and background Task agents. +- Reads `.planning/STATE.md`, `.planning/ROADMAP.md`, phase directories for status. + +**After:** User exits when done managing, or all phases complete and milestone lifecycle is suggested. + + + +@~/.claude/get-shit-done/workflows/manager.md +@~/.claude/get-shit-done/references/ui-brand.md + + + +No arguments required. Requires an active milestone with ROADMAP.md and STATE.md. + +Project context, phase list, dependencies, and recommendations are resolved inside the workflow using `gsd-tools.cjs init manager`. No upfront context loading needed. + + + +Execute the manager workflow from @~/.claude/get-shit-done/workflows/manager.md end-to-end. +Maintain the dashboard refresh loop until the user exits or all phases complete. + diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index b05a9899..65b59e28 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -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; } diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 3f21237a..9234897c 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -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, }; diff --git a/get-shit-done/workflows/manager.md b/get-shit-done/workflows/manager.md new file mode 100644 index 00000000..6ee575ab --- /dev/null +++ b/get-shit-done/workflows/manager.md @@ -0,0 +1,360 @@ + + +Interactive command center for managing a milestone from a single terminal. Shows a dashboard of all phases with visual status, dispatches discuss inline and plan/execute as background agents, and loops back to the dashboard after each action. Enables parallel phase work from one terminal. + + + + + +Read all files referenced by the invoking prompt's execution_context before starting. + + + + + + + +## 1. Initialize + +Bootstrap via manager init: + +```bash +INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init manager) +``` + +Parse JSON for: `milestone_version`, `milestone_name`, `phase_count`, `completed_count`, `in_progress_count`, `phases`, `recommended_actions`, `all_complete`, `waiting_signal`. + +**If error:** Display the error message and exit. + +Display startup banner: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + GSD ► MANAGER +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + {milestone_version} — {milestone_name} + {phase_count} phases · {completed_count} complete + + ✓ Discuss → inline ◆ Plan/Execute → background + Dashboard auto-refreshes when background work is active. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +Proceed to dashboard step. + + + + + +## 2. Dashboard (Refresh Point) + +**Every time this step is reached**, re-read state from disk to pick up changes from background agents: + +```bash +INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init manager) +``` + +Parse the full JSON. Build the dashboard display. + +Build dashboard from JSON. Symbols: `✓` done, `◆` active, `○` pending, `·` queued. Progress bar: 20-char `█░`. + +**Status mapping** (disk_status → D P E Status): + +- `complete` → `✓ ✓ ✓` `✓ Complete` +- `partial` → `✓ ✓ ◆` `◆ Executing...` +- `planned` → `✓ ✓ ○` `○ Ready to execute` +- `discussed` → `✓ ○ ·` `○ Ready to plan` +- `researched` → `◆ · ·` `○ Ready to plan` +- `empty`/`no_directory` + `is_next_to_discuss` → `○ · ·` `○ Ready to discuss` +- `empty`/`no_directory` otherwise → `· · ·` `· Up next` +- If `is_active`, replace status icon with `◆` and append `(active)` + +If any `is_active` phases, show: `◆ Background: {action} Phase {N}, ...` above grid. + +Use `display_name` (not `name`) for the Phase column — it's pre-truncated to 20 chars with `…` if clipped. Pad all phase names to the same width for alignment. + +Use `deps_display` from init JSON for the Deps column — shows which phases this phase depends on (e.g. `1,3`) or `—` for none. + +Example output: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + GSD ► DASHBOARD +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ████████████░░░░░░░░ 60% (3/5 phases) + ◆ Background: Planning Phase 4 + | # | Phase | Deps | D | P | E | Status | + |---|----------------------|------|---|---|---|---------------------| + | 1 | Foundation | — | ✓ | ✓ | ✓ | ✓ Complete | + | 2 | API Layer | 1 | ✓ | ✓ | ◆ | ◆ Executing (active)| + | 3 | Auth System | 1 | ✓ | ✓ | ○ | ○ Ready to execute | + | 4 | Dashboard UI & Set… | 1,2 | ✓ | ◆ | · | ◆ Planning (active) | + | 5 | Notifications | — | ○ | · | · | ○ Ready to discuss | + | 6 | Polish & Final Mail… | 1-5 | · | · | · | · Up next | +``` + +**Recommendations section:** + +If `all_complete` is true: + +``` +╔══════════════════════════════════════════════════════════════╗ +║ MILESTONE COMPLETE ║ +╚══════════════════════════════════════════════════════════════╝ + +All {phase_count} phases done. Ready for final steps: + → /gsd:verify-work — run acceptance testing + → /gsd:complete-milestone — archive and wrap up +``` + +Ask user via AskUserQuestion: +- **question:** "All phases complete. What next?" +- **options:** "Verify work" / "Complete milestone" / "Exit manager" + +Handle responses: +- "Verify work": `Skill(skill="gsd:verify-work")` then loop to dashboard. +- "Complete milestone": `Skill(skill="gsd:complete-milestone")` then exit. +- "Exit manager": Go to exit step. + +**If NOT all_complete**, build compound options from `recommended_actions`: + +**Compound option logic:** Group background actions (plan/execute) together, and pair them with the single inline action (discuss) when one exists. The goal is to present the fewest options possible — one option can dispatch multiple background agents plus one inline action. + +**Building options:** + +1. Collect all background actions (execute and plan recommendations) — there can be multiple of each. +2. Collect the inline action (discuss recommendation, if any — there will be at most one since discuss is sequential). +3. Build compound options: + + **If there are ANY recommended actions (background, inline, or both):** + Create ONE primary "Continue" option that dispatches ALL of them together: + - Label: `"Continue"` — always this exact word + - Below the label, list every action that will happen. Enumerate ALL recommended actions — do not cap or truncate: + ``` + Continue: + → Execute Phase 32 (background) + → Plan Phase 34 (background) + → Discuss Phase 35 (inline) + ``` + - This dispatches all background agents first, then runs the inline discuss (if any). + - If there is no inline discuss, the dashboard refreshes after spawning background agents. + + **Important:** The Continue option must include EVERY action from `recommended_actions` — not just 2. If there are 3 actions, list 3. If there are 5, list 5. + +4. Always add: + - `"Refresh dashboard"` + - `"Exit manager"` + +Display recommendations compactly: + +``` +─────────────────────────────────────────────────────────────── +▶ Next Steps +─────────────────────────────────────────────────────────────── + +Continue: + → Execute Phase 32 (background) + → Plan Phase 34 (background) + → Discuss Phase 35 (inline) +``` + +**Auto-refresh:** If background agents are running (`is_active` is true for any phase), set a 60-second auto-refresh cycle. After presenting the action menu, if no user input is received within 60 seconds, automatically refresh the dashboard. This interval is configurable via `manager_refresh_interval` in GSD config (default: 60 seconds, set to 0 to disable). + +Present via AskUserQuestion: +- **question:** "What would you like to do?" +- **options:** (compound options as built above + refresh + exit, AskUserQuestion auto-adds "Other") + +**On "Other" (free text):** Parse intent — if it mentions a phase number and action, dispatch accordingly. If unclear, display available actions and loop to action_menu. + +Proceed to handle_action step with the selected action. + + + + + +## 4. Handle Action + +### Refresh Dashboard + +Loop back to dashboard step. + +### Exit Manager + +Go to exit step. + +### Compound Action (background + inline) + +When the user selects a compound option: + +1. **Spawn all background agents first** (plan/execute) — dispatch them in parallel using the Plan Phase N / Execute Phase N handlers below. +2. **Then run the inline discuss:** + +``` +Skill(skill="gsd:discuss-phase", args="{PHASE_NUM}") +``` + +After discuss completes, loop back to dashboard step (background agents continue running). + +### Discuss Phase N + +Discussion is interactive — needs user input. Run inline: + +``` +Skill(skill="gsd:discuss-phase", args="{PHASE_NUM}") +``` + +After discuss completes, loop back to dashboard step. + +### Plan Phase N + +Planning runs autonomously. Spawn a background agent: + +``` +Task( + description="Plan phase {N}: {phase_name}", + run_in_background=true, + prompt="You are running the GSD plan-phase workflow for phase {N} of the project. + +Working directory: {cwd} +Phase: {N} — {phase_name} +Goal: {goal} + +Steps: +1. Read the plan-phase workflow: cat ~/.claude/get-shit-done/workflows/plan-phase.md +2. Run: node \"$HOME/.claude/get-shit-done/bin/gsd-tools.cjs\" init plan-phase {N} +3. Follow the workflow steps to produce PLAN.md files for this phase. +4. If research is enabled in config, run the research step first. +5. Spawn a gsd-planner subagent via Task() to create the plans. +6. If plan-checker is enabled, spawn a gsd-plan-checker subagent to verify. +7. Commit plan files when complete. + +Important: You are running in the background. Do NOT use AskUserQuestion — make autonomous decisions based on project context. If you hit a blocker, write it to STATE.md as a blocker and stop. Do NOT silently work around permission or file access errors — let them fail so the manager can surface them with resolution hints." +) +``` + +Display: + +``` +◆ Spawning planner for Phase {N}: {phase_name}... +``` + +Loop back to dashboard step. + +### Execute Phase N + +Execution runs autonomously. Spawn a background agent: + +``` +Task( + description="Execute phase {N}: {phase_name}", + run_in_background=true, + prompt="You are running the GSD execute-phase workflow for phase {N} of the project. + +Working directory: {cwd} +Phase: {N} — {phase_name} +Goal: {goal} + +Steps: +1. Read the execute-phase workflow: cat ~/.claude/get-shit-done/workflows/execute-phase.md +2. Run: node \"$HOME/.claude/get-shit-done/bin/gsd-tools.cjs\" init execute-phase {N} +3. Follow the workflow steps: discover plans, analyze dependencies, group into waves. +4. For each wave, spawn gsd-executor subagents via Task() to execute plans in parallel. +5. After all waves complete, spawn a gsd-verifier subagent if verifier is enabled. +6. Update ROADMAP.md and STATE.md with progress. +7. Commit all changes. + +Important: You are running in the background. Do NOT use AskUserQuestion — make autonomous decisions. Use --no-verify on git commits. If you hit a permission error, file lock, or any access issue, do NOT work around it — let it fail and write the error to STATE.md as a blocker so the manager can surface it with resolution guidance." +) +``` + +Display: + +``` +◆ Spawning executor for Phase {N}: {phase_name}... +``` + +Loop back to dashboard step. + + + + + +## 5. Background Agent Completion + +When notified that a background agent completed: + +1. Read the result message from the agent. +2. Display a brief notification: + +``` +✓ {description} + {brief summary from agent result} +``` + +3. Loop back to dashboard step. + +**If the agent reported an error or blocker:** + +Classify the error: + +**Permission / tool access error** (e.g. tool not allowed, permission denied, sandbox restriction): +- Parse the error to identify which tool or command was blocked. +- Display the error clearly, then offer to fix it: + - **question:** "Phase {N} failed — permission denied for `{tool_or_command}`. Want me to add it to settings.local.json so it's allowed?" + - **options:** "Add permission and retry" / "Run this phase inline instead" / "Skip and continue" + - "Add permission and retry": Use `Skill(skill="update-config")` to add the permission to `settings.local.json`, then re-spawn the background agent. Loop to dashboard. + - "Run this phase inline instead": Dispatch the same action (plan/execute) inline via `Skill()` instead of a background Task. Loop to dashboard after. + - "Skip and continue": Loop to dashboard (phase stays in current state). + +**Other errors** (git lock, file conflict, logic error, etc.): +- Display the error, then offer options via AskUserQuestion: + - **question:** "Background agent for Phase {N} encountered an issue: {error}. What next?" + - **options:** "Retry" / "Run inline instead" / "Skip and continue" / "View details" + - "Retry": Re-spawn the same background agent. Loop to dashboard. + - "Run inline instead": Dispatch the action inline via `Skill()`. Loop to dashboard after. + - "Skip and continue": Loop to dashboard (phase stays in current state). + - "View details": Read STATE.md blockers section, display, then re-present options. + + + + + +## 6. Exit + +Display final status with progress bar: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + GSD ► SESSION END +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + {milestone_version} — {milestone_name} + {PROGRESS_BAR} {progress_pct}% ({completed_count}/{phase_count} phases) + + Resume anytime: /gsd:manager +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +**Note:** Any background agents still running will continue to completion. Their results will be visible on next `/gsd:manager` or `/gsd:progress` invocation. + + + + + + +- [ ] Dashboard displays all phases with correct status indicators (D/P/E/V columns) +- [ ] Progress bar shows accurate completion percentage +- [ ] Dependency resolution: blocked phases show which deps are missing +- [ ] Recommendations prioritize: execute > plan > discuss +- [ ] Discuss phases run inline via Skill() — interactive questions work +- [ ] Plan phases spawn background Task agents — return to dashboard immediately +- [ ] Execute phases spawn background Task agents — return to dashboard immediately +- [ ] Dashboard refreshes pick up changes from background agents via disk state +- [ ] Background agent completion triggers notification and dashboard refresh +- [ ] Background agent errors present retry/skip options +- [ ] All-complete state offers verify-work and complete-milestone +- [ ] Exit shows final status with resume instructions +- [ ] "Other" free-text input parsed for phase number and action +- [ ] Manager loop continues until user exits or milestone completes + diff --git a/package-lock.json b/package-lock.json index 2e1af42c..c08d252d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "esbuild": "^0.24.0" }, "engines": { - "node": ">=16.7.0" + "node": ">=20.0.0" } }, "node_modules/@bcoe/v8-coverage": { diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index ee162d4c..90264409 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -625,7 +625,7 @@ describe('copyCommandsAsCopilotSkills', () => { // Count gsd-* directories — should be 31 const dirs = fs.readdirSync(tempDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); - assert.strictEqual(dirs.length, 50, `expected 50 skill folders, got ${dirs.length}`); + assert.strictEqual(dirs.length, 51, `expected 51 skill folders, got ${dirs.length}`); } finally { fs.rmSync(tempDir, { recursive: true }); } @@ -1119,7 +1119,7 @@ const { execFileSync } = require('child_process'); const crypto = require('crypto'); const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js'); -const EXPECTED_SKILLS = 50; +const EXPECTED_SKILLS = 51; const EXPECTED_AGENTS = 16; function runCopilotInstall(cwd) { diff --git a/tests/init-manager.test.cjs b/tests/init-manager.test.cjs new file mode 100644 index 00000000..7972cc3d --- /dev/null +++ b/tests/init-manager.test.cjs @@ -0,0 +1,361 @@ +/** + * GSD Tools Tests - Init Manager + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +// Helper: write a minimal ROADMAP.md with phases +function writeRoadmap(tmpDir, phases) { + const sections = phases.map(p => { + let section = `### Phase ${p.number}: ${p.name}\n\n**Goal:** ${p.goal || 'Do the thing'}\n`; + if (p.depends_on) section += `**Depends on:** ${p.depends_on}\n`; + return section; + }).join('\n'); + + const checklist = phases.map(p => { + const mark = p.complete ? 'x' : ' '; + return `- [${mark}] **Phase ${p.number}: ${p.name}**`; + }).join('\n'); + + const content = `# Roadmap\n\n## Progress\n\n${checklist}\n\n${sections}`; + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content); +} + +// Helper: write a minimal STATE.md +function writeState(tmpDir) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nstatus: active\n---\n# State\n'); +} + +// Helper: scaffold a phase directory with specific artifacts +function scaffoldPhase(tmpDir, num, opts = {}) { + const padded = String(num).padStart(2, '0'); + const slug = opts.slug || 'test-phase'; + const dir = path.join(tmpDir, '.planning', 'phases', `${padded}-${slug}`); + fs.mkdirSync(dir, { recursive: true }); + + if (opts.context) fs.writeFileSync(path.join(dir, `${padded}-CONTEXT.md`), '# Context'); + if (opts.research) fs.writeFileSync(path.join(dir, `${padded}-RESEARCH.md`), '# Research'); + if (opts.plans) { + for (let i = 1; i <= opts.plans; i++) { + const planPad = String(i).padStart(2, '0'); + fs.writeFileSync(path.join(dir, `${padded}-${planPad}-PLAN.md`), `# Plan ${i}`); + } + } + if (opts.summaries) { + for (let i = 1; i <= opts.summaries; i++) { + const sumPad = String(i).padStart(2, '0'); + fs.writeFileSync(path.join(dir, `${padded}-${sumPad}-SUMMARY.md`), `# Summary ${i}`); + } + } + + return dir; +} + +describe('init manager', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('fails without ROADMAP.md', () => { + writeState(tmpDir); + const result = runGsdTools('init manager', tmpDir); + assert.ok(!result.success); + assert.ok(result.error.includes('ROADMAP.md')); + }); + + test('fails without STATE.md', () => { + writeRoadmap(tmpDir, [{ number: '1', name: 'Setup' }]); + const result = runGsdTools('init manager', tmpDir); + assert.ok(!result.success); + assert.ok(result.error.includes('STATE.md')); + }); + + test('returns basic structure with phases', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Foundation' }, + { number: '2', name: 'API Layer' }, + { number: '3', name: 'UI' }, + ]); + + const result = runGsdTools('init manager', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_count, 3); + assert.strictEqual(output.completed_count, 0); + assert.strictEqual(output.roadmap_exists, true); + assert.strictEqual(output.state_exists, true); + assert.ok(Array.isArray(output.phases)); + assert.ok(Array.isArray(output.recommended_actions)); + }); + + test('detects disk status correctly for each phase state', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Complete Phase', complete: true }, + { number: '2', name: 'Planned Phase' }, + { number: '3', name: 'Discussed Phase' }, + { number: '4', name: 'Empty Phase' }, + { number: '5', name: 'Not Started' }, + ]); + + // Phase 1: complete (plans + matching summaries) + scaffoldPhase(tmpDir, 1, { slug: 'complete-phase', context: true, plans: 2, summaries: 2 }); + // Phase 2: planned (plans, no summaries) + scaffoldPhase(tmpDir, 2, { slug: 'planned-phase', context: true, plans: 3 }); + // Phase 3: discussed (context only) + scaffoldPhase(tmpDir, 3, { slug: 'discussed-phase', context: true }); + // Phase 4: empty directory + scaffoldPhase(tmpDir, 4, { slug: 'empty-phase' }); + // Phase 5: no directory at all + + const result = runGsdTools('init manager', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phases[0].disk_status, 'complete'); + assert.strictEqual(output.phases[1].disk_status, 'planned'); + assert.strictEqual(output.phases[2].disk_status, 'discussed'); + assert.strictEqual(output.phases[3].disk_status, 'empty'); + assert.strictEqual(output.phases[4].disk_status, 'no_directory'); + }); + + test('dependency satisfaction: deps on complete phases = satisfied', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Foundation', complete: true }, + { number: '2', name: 'Depends on 1', depends_on: 'Phase 1' }, + ]); + scaffoldPhase(tmpDir, 1, { slug: 'foundation', plans: 1, summaries: 1 }); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + assert.strictEqual(output.phases[0].deps_satisfied, true); + assert.strictEqual(output.phases[1].deps_satisfied, true); + }); + + test('dependency satisfaction: deps on incomplete phases = not satisfied', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Foundation' }, + { number: '2', name: 'Depends on 1', depends_on: 'Phase 1' }, + ]); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + assert.strictEqual(output.phases[0].deps_satisfied, true); // no deps + assert.strictEqual(output.phases[1].deps_satisfied, false); // phase 1 not complete + }); + + test('sliding window: only first undiscussed phase is next to discuss', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Foundation' }, + { number: '2', name: 'API Layer' }, + { number: '3', name: 'UI' }, + ]); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + // Only phase 1 should be discussable + assert.strictEqual(output.phases[0].is_next_to_discuss, true); + assert.strictEqual(output.phases[1].is_next_to_discuss, false); + assert.strictEqual(output.phases[2].is_next_to_discuss, false); + + // Only recommendation should be discuss phase 1 + assert.strictEqual(output.recommended_actions.length, 1); + assert.strictEqual(output.recommended_actions[0].action, 'discuss'); + assert.strictEqual(output.recommended_actions[0].phase, '1'); + }); + + test('sliding window: after discussing N, plan N + discuss N+1', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Foundation' }, + { number: '2', name: 'API Layer' }, + { number: '3', name: 'UI' }, + ]); + + // Phase 1 discussed + scaffoldPhase(tmpDir, 1, { slug: 'foundation', context: true }); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + // Phase 1 is discussed, phase 2 is next to discuss + assert.strictEqual(output.phases[0].is_next_to_discuss, false); + assert.strictEqual(output.phases[1].is_next_to_discuss, true); + assert.strictEqual(output.phases[2].is_next_to_discuss, false); + + // Should recommend plan phase 1 AND discuss phase 2 + const phase1Rec = output.recommended_actions.find(r => r.phase === '1'); + const phase2Rec = output.recommended_actions.find(r => r.phase === '2'); + assert.strictEqual(phase1Rec.action, 'plan'); + assert.strictEqual(phase2Rec.action, 'discuss'); + }); + + test('sliding window: full pipeline — execute N, plan N+1, discuss N+2', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Foundation', complete: true }, + { number: '2', name: 'API Layer' }, + { number: '3', name: 'Auth' }, + { number: '4', name: 'UI' }, + { number: '5', name: 'Polish' }, + ]); + + scaffoldPhase(tmpDir, 1, { slug: 'foundation', plans: 1, summaries: 1 }); + scaffoldPhase(tmpDir, 2, { slug: 'api-layer', context: true, plans: 2 }); // planned + scaffoldPhase(tmpDir, 3, { slug: 'auth', context: true }); // discussed + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + // Phase 4 is first undiscussed + assert.strictEqual(output.phases[3].is_next_to_discuss, true); + assert.strictEqual(output.phases[4].is_next_to_discuss, false); + + // Recommendations: execute 2, plan 3, discuss 4 + assert.strictEqual(output.recommended_actions[0].action, 'execute'); + assert.strictEqual(output.recommended_actions[0].phase, '2'); + assert.strictEqual(output.recommended_actions[1].action, 'plan'); + assert.strictEqual(output.recommended_actions[1].phase, '3'); + assert.strictEqual(output.recommended_actions[2].action, 'discuss'); + assert.strictEqual(output.recommended_actions[2].phase, '4'); + }); + + test('recommendation ordering: execute > plan > discuss', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Complete', complete: true }, + { number: '2', name: 'Ready to Execute' }, + { number: '3', name: 'Ready to Plan' }, + { number: '4', name: 'Ready to Discuss' }, + ]); + + scaffoldPhase(tmpDir, 1, { slug: 'complete', plans: 1, summaries: 1 }); + scaffoldPhase(tmpDir, 2, { slug: 'ready-to-execute', context: true, plans: 2 }); + scaffoldPhase(tmpDir, 3, { slug: 'ready-to-plan', context: true }); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + assert.ok(output.recommended_actions.length >= 3); + assert.strictEqual(output.recommended_actions[0].action, 'execute'); + assert.strictEqual(output.recommended_actions[0].phase, '2'); + assert.strictEqual(output.recommended_actions[1].action, 'plan'); + assert.strictEqual(output.recommended_actions[1].phase, '3'); + assert.strictEqual(output.recommended_actions[2].action, 'discuss'); + assert.strictEqual(output.recommended_actions[2].phase, '4'); + }); + + test('blocked phases not recommended', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'In Progress' }, + { number: '2', name: 'Blocked', depends_on: 'Phase 1' }, + ]); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + // Phase 2 should not appear in recommendations (blocked by phase 1) + const phase2Rec = output.recommended_actions.find(r => r.phase === '2'); + assert.strictEqual(phase2Rec, undefined); + }); + + test('all phases complete sets all_complete flag', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Done', complete: true }, + { number: '2', name: 'Also Done', complete: true }, + ]); + scaffoldPhase(tmpDir, 1, { slug: 'done', plans: 1, summaries: 1 }); + scaffoldPhase(tmpDir, 2, { slug: 'also-done', plans: 1, summaries: 1 }); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + assert.strictEqual(output.all_complete, true); + assert.strictEqual(output.recommended_actions.length, 0); + }); + + test('WAITING.json detected when present', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [{ number: '1', name: 'Test' }]); + + const waiting = { type: 'decision', phase: '1', question: 'Pick one' }; + fs.writeFileSync( + path.join(tmpDir, '.planning', 'WAITING.json'), + JSON.stringify(waiting) + ); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + assert.deepStrictEqual(output.waiting_signal, waiting); + }); + + test('phase fields include goal and depends_on from roadmap', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Foundation', goal: 'Set up the base' }, + { number: '2', name: 'API', goal: 'Build endpoints', depends_on: 'Phase 1' }, + ]); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + assert.strictEqual(output.phases[0].goal, 'Set up the base'); + assert.strictEqual(output.phases[0].depends_on, null); + assert.strictEqual(output.phases[1].goal, 'Build endpoints'); + assert.strictEqual(output.phases[1].depends_on, 'Phase 1'); + }); + + test('display_name truncates long phase names', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Short' }, + { number: '2', name: 'Exactly Twenty Chars' }, + { number: '3', name: 'This Name Is Way Too Long For The Table' }, + ]); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + assert.strictEqual(output.phases[0].display_name, 'Short'); + assert.strictEqual(output.phases[1].display_name, 'Exactly Twenty Chars'); + assert.strictEqual(output.phases[2].display_name, 'This Name Is Way To…'); + assert.strictEqual(output.phases[2].display_name.length, 20); + // Full name is preserved + assert.strictEqual(output.phases[2].name, 'This Name Is Way Too Long For The Table'); + }); + + test('activity detection: recent file = active', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [{ number: '1', name: 'Active Phase' }]); + + // Scaffold with a file — it will have current mtime (within 5 min) + scaffoldPhase(tmpDir, 1, { slug: 'active-phase', context: true }); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + assert.strictEqual(output.phases[0].is_active, true); + assert.ok(output.phases[0].last_activity !== null); + }); +});