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);
+ });
+});