feat: headless orchestration skill + supervised mode (#905)

This commit is contained in:
Juan Francisco Lebrero 2026-03-17 14:08:15 -03:00 committed by GitHub
parent 1dd32c635f
commit bdbe739ebc
6 changed files with 590 additions and 9 deletions

View file

@ -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, unknown>): string {
return JSON.stringify(obj) + '\n'
}
// ---------------------------------------------------------------------------
// Extension UI Auto-Responder
// ---------------------------------------------------------------------------
@ -237,6 +241,8 @@ function isMilestoneReadyNotification(event: Record<string, unknown>): 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<string, unknown>
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<string, ReturnType<typeof setTimeout>>()
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<void>((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)

View file

@ -41,6 +41,8 @@ const SUBCOMMAND_HELP: Record<string, string> = {
' --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<string, string> = {
' 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'),

View file

@ -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 <path>` (use `-` for stdin), `--context-text <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/<milestoneId>.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 <phase>` | Force specific phase |
| `skip` / `undo` | Unit control |
| `doctor` | Health check + auto-fix |
| `steer <desc>` | Hard-steer plan mid-execution |

View file

@ -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`

View file

@ -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 <phase>` | 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 <desc>` | 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 <rule\|pattern\|lesson>` | 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)

View file

@ -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/<milestoneId>.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/<milestoneId>.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 |