diff --git a/src/headless.ts b/src/headless.ts index 531fcbcf3..006009edf 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -18,6 +18,7 @@ import { ChildProcess } from 'node:child_process' // RpcClient is not in @gsd/pi-coding-agent's public exports — import from dist directly. // This relative path resolves correctly from both src/ (via tsx) and dist/ (compiled). import { RpcClient } from '../packages/pi-coding-agent/dist/modes/rpc/rpc-client.js' +import { attachJsonlLineReader, serializeJsonLine } from '../packages/pi-coding-agent/dist/modes/rpc/jsonl.js' // --------------------------------------------------------------------------- // Types @@ -34,6 +35,8 @@ export interface HeadlessOptions { auto?: boolean // chain into auto-mode after milestone creation verbose?: boolean // show tool calls in output maxRestarts?: number // auto-restart on crash (default 3, 0 to disable) + supervised?: boolean // supervised mode: forward interactive requests to orchestrator + responseTimeout?: number // timeout for orchestrator response (default 30000ms) } interface ExtensionUIRequest { @@ -99,6 +102,15 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions { process.stderr.write('[headless] Error: --max-restarts must be a non-negative integer\n') process.exit(1) } + } else if (arg === '--supervised') { + options.supervised = true + options.json = true // supervised implies json + } else if (arg === '--response-timeout' && i + 1 < args.length) { + options.responseTimeout = parseInt(args[++i], 10) + if (Number.isNaN(options.responseTimeout) || options.responseTimeout <= 0) { + process.stderr.write('[headless] Error: --response-timeout must be a positive integer (milliseconds)\n') + process.exit(1) + } } } else if (!positionalStarted) { positionalStarted = true @@ -111,14 +123,6 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions { return options } -// --------------------------------------------------------------------------- -// JSONL Helper -// --------------------------------------------------------------------------- - -function serializeJsonLine(obj: Record): string { - return JSON.stringify(obj) + '\n' -} - // --------------------------------------------------------------------------- // Extension UI Auto-Responder // --------------------------------------------------------------------------- @@ -237,6 +241,8 @@ function isMilestoneReadyNotification(event: Record): boolean { // Quick Command Detection // --------------------------------------------------------------------------- +const FIRE_AND_FORGET_METHODS = new Set(['notify', 'setStatus', 'setWidget', 'setTitle', 'set_editor_text']) + const QUICK_COMMANDS = new Set([ 'status', 'queue', 'history', 'hooks', 'export', 'stop', 'pause', 'capture', 'skip', 'undo', 'knowledge', 'config', 'prefs', @@ -248,6 +254,49 @@ function isQuickCommand(command: string): boolean { return QUICK_COMMANDS.has(command) } +// --------------------------------------------------------------------------- +// Supervised Stdin Reader +// --------------------------------------------------------------------------- + +function startSupervisedStdinReader( + stdinWriter: (data: string) => void, + client: RpcClient, + onResponse: (id: string) => void, +): () => void { + return attachJsonlLineReader(process.stdin as import('node:stream').Readable, (line) => { + let msg: Record + try { + msg = JSON.parse(line) + } catch { + process.stderr.write(`[headless] Warning: invalid JSON from orchestrator stdin, skipping\n`) + return + } + + const type = String(msg.type ?? '') + + switch (type) { + case 'extension_ui_response': + stdinWriter(line + '\n') + if (typeof msg.id === 'string') { + onResponse(msg.id) + } + break + case 'prompt': + client.prompt(String(msg.message ?? '')) + break + case 'steer': + client.steer(String(msg.message ?? '')) + break + case 'follow_up': + client.followUp(String(msg.message ?? '')) + break + default: + process.stderr.write(`[headless] Warning: unknown message type "${type}" from orchestrator stdin\n`) + break + } + }) +} + // --------------------------------------------------------------------------- // Main Orchestrator // --------------------------------------------------------------------------- @@ -320,6 +369,12 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): const startTime = Date.now() const isNewMilestone = options.command === 'new-milestone' + // Supervised mode cannot share stdin with --context - + if (options.supervised && options.context === '-') { + process.stderr.write('[headless] Error: --supervised cannot be used with --context - (both require stdin)\n') + process.exit(1) + } + // For new-milestone, load context and bootstrap .gsd/ before spawning RPC child if (isNewMilestone) { if (!options.context && !options.contextText) { @@ -408,6 +463,18 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): // Stdin writer for sending extension_ui_response to child let stdinWriter: ((data: string) => void) | null = null + // Supervised mode state + const pendingResponseTimers = new Map>() + let supervisedFallback = false + let stopSupervisedReader: (() => void) | null = null + const onStdinClose = () => { + supervisedFallback = true + process.stderr.write('[headless] Warning: orchestrator stdin closed, falling back to auto-response\n') + } + if (options.supervised) { + process.stdin.on('close', onStdinClose) + } + // Completion promise let resolveCompletion: () => void const completionPromise = new Promise((resolve) => { @@ -428,6 +495,9 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): } } + // Precompute supervised response timeout + const responseTimeout = options.responseTimeout ?? 30_000 + // Overall timeout const timeoutTimer = setTimeout(() => { process.stderr.write(`[headless] Timeout after ${options.timeout / 1000}s\n`) @@ -466,7 +536,22 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): completed = true } - handleExtensionUIRequest(eventObj as unknown as ExtensionUIRequest, stdinWriter) + const method = String(eventObj.method ?? '') + const shouldSupervise = options.supervised && !supervisedFallback + && !FIRE_AND_FORGET_METHODS.has(method) + + if (shouldSupervise) { + // Interactive request in supervised mode — let orchestrator respond + const eventId = String(eventObj.id ?? '') + const timer = setTimeout(() => { + pendingResponseTimers.delete(eventId) + handleExtensionUIRequest(eventObj as unknown as ExtensionUIRequest, stdinWriter!) + process.stdout.write(JSON.stringify({ type: 'supervised_timeout', id: eventId, method }) + '\n') + }, responseTimeout) + pendingResponseTimers.set(eventId, timer) + } else { + handleExtensionUIRequest(eventObj as unknown as ExtensionUIRequest, stdinWriter) + } // If we detected a terminal notification, resolve after responding if (completed) { @@ -523,6 +608,19 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): internalProcess.stdin!.write(data) } + // Start supervised stdin reader for orchestrator commands + if (options.supervised) { + stopSupervisedReader = startSupervisedStdinReader(stdinWriter, client, (id) => { + const timer = pendingResponseTimers.get(id) + if (timer) { + clearTimeout(timer) + pendingResponseTimers.delete(id) + } + }) + // Ensure stdin is in flowing mode for JSONL reading + process.stdin.resume() + } + // Detect child process crash internalProcess.on('exit', (code) => { if (!completed) { @@ -580,6 +678,10 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): // Cleanup clearTimeout(timeoutTimer) if (idleTimer) clearTimeout(idleTimer) + pendingResponseTimers.forEach((timer) => clearTimeout(timer)) + pendingResponseTimers.clear() + stopSupervisedReader?.() + process.stdin.removeListener('close', onStdinClose) process.removeListener('SIGINT', signalHandler) process.removeListener('SIGTERM', signalHandler) diff --git a/src/help-text.ts b/src/help-text.ts index 8c866b22a..864d85f3d 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -41,6 +41,8 @@ const SUBCOMMAND_HELP: Record = { ' --timeout N Overall timeout in ms (default: 300000)', ' --json JSONL event stream to stdout', ' --model ID Override model', + ' --supervised Forward interactive UI requests to orchestrator via stdout/stdin', + ' --response-timeout N Timeout (ms) for orchestrator response (default: 30000)', '', 'Commands:', ' auto Run all queued units continuously (default)', @@ -62,6 +64,7 @@ const SUBCOMMAND_HELP: Record = { ' gsd headless new-milestone --context spec.md Create milestone from file', ' cat spec.md | gsd headless new-milestone --context - From stdin', ' gsd headless new-milestone --context spec.md --auto Create + auto-execute', + ' gsd headless --supervised auto Supervised orchestrator mode', '', 'Exit codes: 0 = complete, 1 = error/timeout, 2 = blocked', ].join('\n'), diff --git a/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md b/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md new file mode 100644 index 000000000..cbb6ec23c --- /dev/null +++ b/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md @@ -0,0 +1,178 @@ +--- +name: gsd-headless +description: Orchestrate GSD (Get Shit Done) projects programmatically via headless CLI. Use when an agent needs to create milestones from specs, execute software development workflows, monitor task progress, check project status, or control GSD execution (pause/stop/skip/steer). Triggers on requests to "run gsd", "create milestone", "execute project", "check gsd status", "orchestrate development", "run headless workflow", or any programmatic interaction with the GSD project management system. Essential for building orchestrators that coordinate multiple GSD workers. +--- + +# GSD Headless Orchestration + +Run GSD commands without TUI via `gsd headless`. Spawns an RPC child process, auto-responds to UI prompts, streams progress. + +## Command Syntax + +```bash +gsd headless [flags] [command] [args...] +``` + +**Flags:** `--timeout N` (ms, default 300000), `--json` (JSONL to stdout), `--model ID`, `--verbose` +**Exit codes:** 0=complete, 1=error/timeout, 2=blocked + +## Core Workflows + +### 1. Create + Execute a Milestone (end-to-end) + +```bash +gsd headless new-milestone --context spec.md --auto +``` + +Reads spec, bootstraps `.gsd/`, creates milestone, then chains into auto-mode executing all phases (discuss → research → plan → execute → summarize → complete). + +Extra flags for `new-milestone`: `--context ` (use `-` for stdin), `--context-text `, `--auto`. + +### 2. Run All Queued Work + +```bash +gsd headless auto +``` + +Default command. Loops through all pending units until milestone complete or blocked. + +### 3. Run One Unit + +```bash +gsd headless next +``` + +Execute exactly one unit (task/slice/milestone step), then exit. Ideal for step-by-step orchestration with external decision logic between steps. + +### 4. Check Status + +```bash +gsd headless --json status +``` + +Returns project state: active milestone/slice/task, phase, progress counts, blockers. Parse the JSONL output for machine-readable state. + +### 5. Dispatch Specific Phase + +```bash +gsd headless dispatch research|plan|execute|complete|reassess|uat|replan +``` + +Force-route to a specific phase, bypassing normal state-machine routing. + +## Orchestrator Patterns + +### Poll-and-React Loop + +```bash +# Check status, decide what to do +STATUS=$(gsd headless --json status 2>/dev/null) +EXIT=$? + +case $EXIT in + 0) echo "Complete" ;; + 2) echo "Blocked — needs intervention" ;; + *) echo "Error" ;; +esac +``` + +### Step-by-Step with Monitoring + +```bash +while true; do + gsd headless next + EXIT=$? + [ $EXIT -ne 0 ] && break + # Check progress, log, decide whether to continue + gsd headless --json status +done +``` + +### Multi-Session Orchestration + +GSD tracks concurrent workers via file-based IPC in `.gsd/parallel/`. See [references/multi-session.md](references/multi-session.md) for the full architecture. + +**Quick overview:** + +Each worker spawns with `GSD_MILESTONE_LOCK=M00X` + its own git worktree. Workers write heartbeats to `.gsd/parallel/.status.json`. The orchestrator enumerates all status files to get a dashboard of all workers, and sends commands via signal files. + +```bash +# Spawn a worker for milestone M001 in its worktree +GSD_MILESTONE_LOCK=M001 GSD_PARALLEL_WORKER=1 \ + gsd headless --json auto \ + --cwd .gsd/worktrees/M001 2>worker-M001.log & + +# Monitor all workers: read .gsd/parallel/*.status.json +for f in .gsd/parallel/*.status.json; do + jq '{mid: .milestoneId, state: .state, unit: .currentUnit.id, cost: .cost}' "$f" +done + +# Send pause signal to M001 +echo '{"signal":"pause","sentAt":'$(date +%s000)',"from":"coordinator"}' \ + > .gsd/parallel/M001.signal.json +``` + +**Status file fields:** `milestoneId`, `pid`, `state` (running/paused/stopped/error), `currentUnit`, `completedUnits`, `cost`, `lastHeartbeat`, `startedAt`, `worktreePath`. + +**Signal commands:** `pause`, `resume`, `stop`, `rebase`. + +**Liveness detection:** PID alive check (`kill -0 $pid`) + heartbeat freshness (30s timeout). Stale sessions are auto-cleaned. + +**For multiple projects:** each project has its own `.gsd/` directory. The orchestrator must track `(projectPath, milestoneId)` tuples externally. + +### JSONL Event Stream + +Use `--json` to get real-time events on stdout for downstream processing: + +```bash +gsd headless --json auto 2>/dev/null | while read -r line; do + TYPE=$(echo "$line" | jq -r '.type') + case "$TYPE" in + tool_execution_start) echo "Tool: $(echo "$line" | jq -r '.toolName')" ;; + extension_ui_request) echo "GSD: $(echo "$line" | jq -r '.message // .title // empty')" ;; + agent_end) echo "Session ended" ;; + esac +done +``` + +Event types: `agent_start`, `agent_end`, `tool_execution_start`, `tool_execution_end`, `extension_ui_request`, `message_update`, `error`. + +## Answer Injection + +Pre-supply answers for non-interactive runs. See [references/answer-injection.md](references/answer-injection.md) for schema and usage. + +## GSD Project Structure + +All state lives in `.gsd/` as markdown files (version-controllable): + +``` +.gsd/ + milestones/M001/ + M001-CONTEXT.md # Requirements, scope, decisions + M001-ROADMAP.md # Slices with tasks, dependencies, checkboxes + M001-SUMMARY.md # Completion summary + slices/S01/ + S01-PLAN.md # Task list + S01-SUMMARY.md # Slice summary with frontmatter + tasks/T01-PLAN.md # Individual task spec +``` + +State is derived from files on disk — checkboxes in ROADMAP.md are the source of truth for completion. + +## All Headless Commands + +Quick reference — see [references/commands.md](references/commands.md) for the complete list. + +| Command | Purpose | +|---------|---------| +| `auto` | Run all queued units (default) | +| `next` | Run one unit | +| `status` | Progress dashboard | +| `new-milestone` | Create milestone from spec | +| `queue` | Queue/reorder milestones | +| `history` | View execution history | +| `stop` / `pause` | Control auto-mode | +| `dispatch ` | Force specific phase | +| `skip` / `undo` | Unit control | +| `doctor` | Health check + auto-fix | +| `steer ` | Hard-steer plan mid-execution | diff --git a/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md b/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md new file mode 100644 index 000000000..ecf21f87f --- /dev/null +++ b/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md @@ -0,0 +1,54 @@ +# Answer Injection + +Pre-supply answers to eliminate interactive prompts during headless execution. + +## Answer File Schema + +```json +{ + "questions": { + "question_id": "selected_option_label", + "multi_select_question": ["option_a", "option_b"] + }, + "secrets": { + "API_KEY": "sk-...", + "DATABASE_URL": "postgres://..." + }, + "defaults": { + "strategy": "first_option" + } +} +``` + +### Fields + +- **questions**: Map question ID → answer. String for single-select, string[] for multi-select. +- **secrets**: Map env var name → value. Used for `secure_env_collect` tool calls. Values are never logged. +- **defaults.strategy**: Fallback for unmatched questions. + - `"first_option"` — auto-select first available option + - `"cancel"` — cancel the request + +## How It Works + +Two-phase correlation: +1. **Observe** `tool_execution_start` events for `ask_user_questions` — extracts question metadata (ID, options, allowMultiple) +2. **Match** subsequent `extension_ui_request` events to metadata, respond with pre-supplied answer + +Handles out-of-order events (extension_ui_request can arrive before tool_execution_start in RPC mode) via deferred processing queue. + +## Without Answer Injection + +Headless mode has built-in auto-responders: +- **select** → picks first option +- **confirm** → auto-confirms +- **input** → empty string +- **editor** → returns prefill or empty + +Answer injection overrides these defaults with specific answers when precision matters. + +## Diagnostics + +The injector tracks stats: +- `questionsAnswered` / `questionsDefaulted` +- `secretsProvided` / `secretsMissing` +- `fireAndForgetConsumed` / `confirmationsHandled` diff --git a/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md b/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md new file mode 100644 index 000000000..ac1bf4d00 --- /dev/null +++ b/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md @@ -0,0 +1,59 @@ +# GSD Commands Reference + +All commands can be run via `gsd headless [command]`. + +## Workflow Commands + +| Command | Description | +|---------|-------------| +| `auto` | Autonomous mode — loop until milestone complete (default) | +| `next` | Step mode — execute one unit, then exit | +| `stop` | Stop auto-mode gracefully | +| `pause` | Pause auto-mode (preserves state, resumable) | +| `new-milestone` | Create milestone from specification (requires `--context`) | +| `dispatch ` | Force-dispatch: research, plan, execute, complete, reassess, uat, replan | + +## Status & Monitoring + +| Command | Description | +|---------|-------------| +| `status` | Progress dashboard (active unit, phase, blockers) | +| `visualize` | Workflow visualizer (deps, metrics, timeline) | +| `history` | Execution history (supports --cost, --phase, --model, limit) | + +## Unit Control + +| Command | Description | +|---------|-------------| +| `skip` | Prevent a unit from auto-mode dispatch | +| `undo` | Revert last completed unit (--force flag) | +| `steer ` | Hard-steer plan documents during execution | +| `queue` | Queue and reorder future milestones | +| `capture` | Fire-and-forget thought capture | +| `triage` | Manually trigger triage of pending captures | + +## Configuration & Health + +| Command | Description | +|---------|-------------| +| `prefs` | Manage preferences (global/project/status/wizard/setup) | +| `config` | Set API keys for external tools | +| `doctor` | Runtime health checks with auto-fix | +| `hooks` | Show configured post-unit and pre-dispatch hooks | +| `knowledge ` | Add persistent project knowledge | +| `cleanup` | Remove merged branches or snapshots | +| `export` | Export results (--json, --markdown) | +| `migrate` | Migrate v1 .planning directory to .gsd format | + +## Phases + +GSD workflows progress through these phases: +`pre-planning` → `needs-discussion` → `discussing` → `researching` → `planning` → `executing` → `verifying` → `summarizing` → `advancing` → `validating-milestone` → `completing-milestone` → `complete` + +Special phases: `paused`, `blocked`, `replanning-slice` + +## Hierarchy + +- **Milestone**: Shippable version (4-10 slices, 1-4 weeks) +- **Slice**: One demoable vertical capability (1-7 tasks, 1-3 days) +- **Task**: One context-window-sized unit of work (one session) diff --git a/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md b/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md new file mode 100644 index 000000000..ff24a9461 --- /dev/null +++ b/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md @@ -0,0 +1,185 @@ +# Multi-Session Orchestration + +How to run and monitor multiple concurrent GSD sessions. + +## Architecture + +GSD uses **file-based IPC** — no sockets or ports. All coordination happens through JSON files in `.gsd/parallel/`. + +``` +.gsd/parallel/ +├── M001.status.json # Worker heartbeat + state +├── M001.signal.json # Coordinator → worker commands (ephemeral) +├── M002.status.json +├── M003.status.json +└── ... +``` + +## Worker Isolation + +Each worker gets: +1. **`GSD_MILESTONE_LOCK=M00X`** — state derivation only sees this milestone +2. **`GSD_PARALLEL_WORKER=1`** — prevents nested parallel spawns +3. **Own git worktree** at `.gsd/worktrees/M00X/` — branch `milestone/M00X` + +Workers cannot interfere with each other. Each has its own filesystem and git branch. + +## Status File Schema + +Written atomically (`.tmp` + rename) by each worker at `.gsd/parallel/.status.json`: + +```json +{ + "milestoneId": "M001", + "pid": 12345, + "state": "running", + "currentUnit": { + "type": "task", + "id": "T03", + "startedAt": 1710000000000 + }, + "completedUnits": 7, + "cost": 1.23, + "lastHeartbeat": 1710000015000, + "startedAt": 1710000000000, + "worktreePath": ".gsd/worktrees/M001" +} +``` + +**States:** `running`, `paused`, `stopped`, `error` + +## Signal Files + +Coordinator writes to `.gsd/parallel/.signal.json`. Worker consumes and deletes on next dispatch cycle. + +```json +{ + "signal": "pause", + "sentAt": 1710000020000, + "from": "coordinator" +} +``` + +**Signals:** `pause`, `resume`, `stop`, `rebase` + +## Spawning Workers + +```bash +# Spawn worker in its worktree +GSD_MILESTONE_LOCK=M001 \ +GSD_PARALLEL_WORKER=1 \ +GSD_BIN_PATH=$(which gsd) \ + gsd --mode json --print "/gsd auto" \ + 2>logs/M001.log & +WORKER_PID=$! +``` + +Workers emit NDJSON on stdout. Parse `message_end` events for cost tracking: +```bash +# Extract cost from worker output +gsd --mode json --print "/gsd auto" | while read -r line; do + COST=$(echo "$line" | jq -r 'select(.type=="message_end") | .message.usage.cost.total // empty') + [ -n "$COST" ] && echo "Cost update: $COST" +done +``` + +## Monitoring All Workers + +```bash +# Dashboard: enumerate all status files +for f in .gsd/parallel/*.status.json; do + [ -f "$f" ] || continue + jq -r '[.milestoneId, .state, (.currentUnit.id // "idle"), "\(.cost | tostring)$"] | join("\t")' "$f" +done + +# Liveness check +for f in .gsd/parallel/*.status.json; do + PID=$(jq -r '.pid' "$f") + MID=$(jq -r '.milestoneId' "$f") + if kill -0 "$PID" 2>/dev/null; then + echo "$MID: alive (pid=$PID)" + else + echo "$MID: DEAD (pid=$PID) — cleanup needed" + rm "$f" + fi +done +``` + +## Sending Commands + +```bash +# Pause a worker +send_signal() { + local MID=$1 SIGNAL=$2 + echo "{\"signal\":\"$SIGNAL\",\"sentAt\":$(date +%s000),\"from\":\"coordinator\"}" \ + > ".gsd/parallel/${MID}.signal.json" +} + +send_signal M001 pause +send_signal M002 stop +send_signal M003 resume +``` + +## Budget Enforcement + +Track aggregate cost across all workers: +```bash +TOTAL=$(jq -s 'map(.cost) | add // 0' .gsd/parallel/*.status.json) +CEILING=50.00 +if (( $(echo "$TOTAL > $CEILING" | bc -l) )); then + echo "Budget exceeded ($TOTAL > $CEILING) — stopping all" + for f in .gsd/parallel/*.status.json; do + MID=$(jq -r '.milestoneId' "$f") + send_signal "$MID" stop + done +fi +``` + +## Stale Session Cleanup + +A session is stale when: +- PID is dead (`kill -0 $pid` fails), OR +- `lastHeartbeat` is older than 30 seconds + +```bash +NOW=$(date +%s000) +STALE_THRESHOLD=30000 +for f in .gsd/parallel/*.status.json; do + PID=$(jq -r '.pid' "$f") + HB=$(jq -r '.lastHeartbeat' "$f") + AGE=$((NOW - HB)) + if ! kill -0 "$PID" 2>/dev/null || [ "$AGE" -gt "$STALE_THRESHOLD" ]; then + echo "Stale: $(jq -r '.milestoneId' "$f") — removing" + rm "$f" + fi +done +``` + +## Multi-Project Orchestration + +Within one project, milestones are tracked automatically in `.gsd/parallel/`. For orchestrating across **multiple projects**, maintain an external registry: + +```json +{ + "sessions": [ + { "project": "/path/to/project-a", "milestoneId": "M001" }, + { "project": "/path/to/project-b", "milestoneId": "M001" }, + { "project": "/path/to/project-b", "milestoneId": "M002" } + ] +} +``` + +Then poll each project's `.gsd/parallel/` directory. GSD has no cross-project awareness — the orchestrator must bridge this gap. + +## Built-in Parallel Commands + +Inside an interactive GSD session, these commands manage the parallel orchestrator: + +| Command | Description | +|---------|-------------| +| `/gsd parallel start` | Analyze eligibility, spawn workers | +| `/gsd parallel status` | Show all workers, costs, progress | +| `/gsd parallel stop [MID]` | Stop one or all workers | +| `/gsd parallel pause [MID]` | Pause without killing | +| `/gsd parallel resume [MID]` | Resume paused worker | +| `/gsd parallel merge [MID]` | Merge completed milestone branch |