feat: add gsd headless query for instant state inspection (#951)

* feat: add `gsd headless query` for structured state inspection

Add read-only query commands that return parseable JSON without
spawning an LLM session. Decouples orchestrators from .gsd/ internals.

Targets: phase, cost, progress, next

* simplify: single `query` command returning full snapshot

Replace 4 query targets (phase/cost/progress/next) with one command
that returns everything in a single JSON object. Caller uses jq.

Also document query in README.md and docs/commands.md.

* docs: update gsd-headless skill and references

- SKILL.md: add missing flags (--supervised, --max-restarts, --response-timeout)
- references/commands.md: add query, discuss, remote, inspect, forensics
- references/multi-session.md: fix spawning syntax, use query for budget

* fix: remove integration tests that entered via merge

These files belong to the feat/headless-orchestration-skill branch
and were accidentally included during the upstream/main merge.
They contain TS errors (sessionTerminated scope issue) that break CI.

* fix: restore headless-command.ts deleted by accident
This commit is contained in:
Juan Francisco Lebrero 2026-03-17 19:03:59 -03:00 committed by GitHub
parent 0e0f47ef9f
commit 99c3375f18
9 changed files with 362 additions and 34 deletions

View file

@ -249,14 +249,14 @@ gsd headless new-milestone --context spec.md --auto
# One unit at a time (cron-friendly)
gsd headless next
# Machine-readable JSONL event stream
gsd headless --json status
# Instant JSON snapshot (no LLM, ~50ms)
gsd headless query
# Force a specific pipeline phase
gsd headless dispatch plan
```
Headless auto-responds to interactive prompts, detects completion, and exits with structured codes: `0` complete, `1` error/timeout, `2` blocked. Auto-restarts on crash with exponential backoff. Pair with [remote questions](./docs/remote-questions.md) to route decisions to Slack or Discord when human input is needed.
Headless auto-responds to interactive prompts, detects completion, and exits with structured codes: `0` complete, `1` error/timeout, `2` blocked. Auto-restarts on crash with exponential backoff. Use `gsd headless query` for instant, machine-readable state inspection — returns phase, next dispatch preview, and parallel worker costs as a single JSON object without spawning an LLM session. Pair with [remote questions](./docs/remote-questions.md) to route decisions to Slack or Discord when human input is needed.
**Multi-session orchestration** — headless mode supports file-based IPC in `.gsd/parallel/` for coordinating multiple GSD workers across milestones. Build orchestrators that spawn, monitor, and budget-cap a fleet of GSD workers.
@ -297,6 +297,7 @@ On first run, GSD launches a branded setup wizard that walks you through LLM pro
| `gsd config` | Re-run the setup wizard (LLM provider + tool keys) |
| `gsd update` | Update GSD to the latest version |
| `gsd headless [cmd]` | Run `/gsd` commands without TUI (CI, cron, scripts) |
| `gsd headless query` | Instant JSON snapshot — state, next dispatch, costs (no LLM) |
| `gsd --continue` (`-c`) | Resume the most recent session for the current directory |
| `gsd sessions` | Interactive session picker — browse and resume any saved session |

View file

@ -109,8 +109,8 @@ gsd headless
# Run a single unit
gsd headless next
# Machine-readable output
gsd headless --json status
# Instant JSON snapshot — no LLM, ~50ms
gsd headless query
# With timeout for CI
gsd headless --timeout 600000 auto
@ -142,6 +142,46 @@ echo "Build a CLI tool" | gsd headless new-milestone --context -
Any `/gsd` subcommand works as a positional argument — `gsd headless status`, `gsd headless doctor`, `gsd headless dispatch execute`, etc.
### `gsd headless query`
Returns a single JSON object with the full project snapshot — no LLM session, no RPC child, instant response (~50ms). This is the recommended way for orchestrators and scripts to inspect GSD state.
```bash
gsd headless query | jq '.state.phase'
# "executing"
gsd headless query | jq '.next'
# {"action":"dispatch","unitType":"execute-task","unitId":"M001/S01/T03"}
gsd headless query | jq '.cost.total'
# 4.25
```
**Output schema:**
```json
{
"state": {
"phase": "executing",
"activeMilestone": { "id": "M001", "title": "..." },
"activeSlice": { "id": "S01", "title": "..." },
"activeTask": { "id": "T01", "title": "..." },
"registry": [{ "id": "M001", "status": "active" }, ...],
"progress": { "milestones": { "done": 0, "total": 2 }, "slices": { "done": 1, "total": 3 } },
"blockers": []
},
"next": {
"action": "dispatch",
"unitType": "execute-task",
"unitId": "M001/S01/T01"
},
"cost": {
"workers": [{ "milestoneId": "M001", "cost": 1.50, "state": "running", ... }],
"total": 1.50
}
}
```
## MCP Server Mode
`gsd --mode mcp` runs GSD as a [Model Context Protocol](https://modelcontextprotocol.io) server over stdin/stdout. This exposes all GSD tools (read, write, edit, bash, etc.) to external AI clients — Claude Desktop, VS Code Copilot, and any MCP-compatible host.

93
src/headless-query.ts Normal file
View file

@ -0,0 +1,93 @@
/**
* Headless Query `gsd headless query`
*
* Single read-only command that returns the full project snapshot as JSON
* to stdout, without spawning an LLM session. Instant (~50ms).
*
* Output: { state, next, cost }
* state deriveState() output (phase, milestones, progress, blockers)
* next dry-run dispatch preview (what auto-mode would do next)
* cost aggregated parallel worker costs
*/
import { deriveState } from './resources/extensions/gsd/state.js'
import { resolveDispatch } from './resources/extensions/gsd/auto-dispatch.js'
import { readAllSessionStatuses } from './resources/extensions/gsd/session-status-io.js'
import { loadEffectiveGSDPreferences } from './resources/extensions/gsd/preferences.js'
import type { GSDState } from './resources/extensions/gsd/types.js'
// ─── Types ──────────────────────────────────────────────────────────────────
export interface QuerySnapshot {
state: GSDState
next: {
action: 'dispatch' | 'stop' | 'skip'
unitType?: string
unitId?: string
reason?: string
}
cost: {
workers: Array<{
milestoneId: string
pid: number
state: string
cost: number
lastHeartbeat: number
}>
total: number
}
}
export interface QueryResult {
exitCode: number
data?: QuerySnapshot
}
// ─── Implementation ─────────────────────────────────────────────────────────
export async function handleQuery(basePath: string): Promise<QueryResult> {
const state = await deriveState(basePath)
// Derive next dispatch action
let next: QuerySnapshot['next']
if (!state.activeMilestone) {
next = {
action: 'stop',
reason: state.phase === 'complete' ? 'All milestones complete.' : state.nextAction,
}
} else {
const loaded = loadEffectiveGSDPreferences()
const dispatch = await resolveDispatch({
basePath,
mid: state.activeMilestone.id,
midTitle: state.activeMilestone.title,
state,
prefs: loaded?.preferences,
})
next = {
action: dispatch.action,
unitType: dispatch.action === 'dispatch' ? dispatch.unitType : undefined,
unitId: dispatch.action === 'dispatch' ? dispatch.unitId : undefined,
reason: dispatch.action === 'stop' ? dispatch.reason : undefined,
}
}
// Aggregate parallel worker costs
const statuses = readAllSessionStatuses(basePath)
const workers = statuses.map((s) => ({
milestoneId: s.milestoneId,
pid: s.pid,
state: s.state,
cost: s.cost,
lastHeartbeat: s.lastHeartbeat,
}))
const snapshot: QuerySnapshot = {
state,
next,
cost: { workers, total: workers.reduce((sum, w) => sum + w.cost, 0) },
}
process.stdout.write(JSON.stringify(snapshot) + '\n')
return { exitCode: 0, data: snapshot }
}

View file

@ -413,6 +413,13 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
process.exit(1)
}
// Query: read-only state snapshot, no RPC child needed
if (options.command === 'query') {
const { handleQuery } = await import('./headless-query.js')
const result = await handleQuery(process.cwd())
return { exitCode: result.exitCode, interrupted: false }
}
// Resolve CLI path for the child process
const cliPath = process.env.GSD_BIN_PATH || process.argv[1]
if (!cliPath) {

View file

@ -49,6 +49,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
' next Run one unit',
' status Show progress dashboard',
' new-milestone Create a milestone from a specification document',
' query JSON snapshot: state + next dispatch + costs (no LLM)',
'',
'new-milestone flags:',
' --context <path> Path to spec/PRD file (use \'-\' for stdin)',
@ -65,6 +66,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
' 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',
' gsd headless query Instant JSON state snapshot',
'',
'Exit codes: 0 = complete, 1 = error/timeout, 2 = blocked',
].join('\n'),

View file

@ -13,7 +13,15 @@ Run GSD commands without TUI via `gsd headless`. Spawns an RPC child process, au
gsd headless [flags] [command] [args...]
```
**Flags:** `--timeout N` (ms, default 300000), `--json` (JSONL to stdout), `--model ID`, `--verbose`
**Flags:**
- `--timeout N` — overall timeout in ms (default 300000)
- `--json` — JSONL event stream to stdout
- `--model ID` — override LLM model
- `--verbose` — show tool calls in progress output
- `--supervised` — forward interactive UI requests to orchestrator via stdout/stdin
- `--response-timeout N` — timeout for orchestrator response in supervised mode (default 30000)
- `--max-restarts N` — auto-restart on crash with backoff (default 3, 0 to disable)
**Exit codes:** 0=complete, 1=error/timeout, 2=blocked
## Core Workflows
@ -44,13 +52,32 @@ 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
### 4. Instant State Snapshot (no LLM)
```bash
gsd headless --json status
gsd headless query
```
Returns project state: active milestone/slice/task, phase, progress counts, blockers. Parse the JSONL output for machine-readable state.
Returns a single JSON object with the full project snapshot — no LLM session, instant (~50ms). **This is the recommended way for orchestrators to inspect state.**
```json
{
"state": { "phase": "executing", "activeMilestone": {...}, "activeSlice": {...}, "progress": {...}, "registry": [...] },
"next": { "action": "dispatch", "unitType": "execute-task", "unitId": "M001/S01/T01" },
"cost": { "workers": [{ "milestoneId": "M001", "cost": 1.50, ... }], "total": 1.50 }
}
```
```bash
# What phase is the project in?
gsd headless query | jq '.state.phase'
# What would auto-mode do next?
gsd headless query | jq '.next'
# Total spend across parallel workers
gsd headless query | jq '.cost.total'
```
### 5. Dispatch Specific Phase
@ -65,14 +92,14 @@ Force-route to a specific phase, bypassing normal state-machine routing.
### Poll-and-React Loop
```bash
# Check status, decide what to do
STATUS=$(gsd headless --json status 2>/dev/null)
EXIT=$?
# Instant state check — no LLM cost
PHASE=$(gsd headless query | jq -r '.state.phase')
NEXT_ACTION=$(gsd headless query | jq -r '.next.action')
case $EXIT in
0) echo "Complete" ;;
2) echo "Blocked — needs intervention" ;;
*) echo "Error" ;;
case "$PHASE" in
complete) echo "Done" ;;
blocked) echo "Needs intervention" ;;
*) [ "$NEXT_ACTION" = "dispatch" ] && gsd headless next ;;
esac
```
@ -83,8 +110,8 @@ while true; do
gsd headless next
EXIT=$?
[ $EXIT -ne 0 ] && break
# Check progress, log, decide whether to continue
gsd headless --json status
# Instant progress check between steps
gsd headless query | jq '{phase: .state.phase, progress: .state.progress}'
done
```
@ -167,7 +194,7 @@ Quick reference — see [references/commands.md](references/commands.md) for the
|---------|---------|
| `auto` | Run all queued units (default) |
| `next` | Run one unit |
| `status` | Progress dashboard |
| `query` | Instant JSON snapshot — state, next dispatch, costs (no LLM) |
| `new-milestone` | Create milestone from spec |
| `queue` | Queue/reorder milestones |
| `history` | View execution history |

View file

@ -12,12 +12,14 @@ All commands can be run via `gsd headless [command]`.
| `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 |
| `discuss` | Start guided milestone/slice discussion |
## Status & Monitoring
## State Inspection
| Command | Description |
|---------|-------------|
| `status` | Progress dashboard (active unit, phase, blockers) |
| `query` | **Instant JSON snapshot** — state, next dispatch, parallel costs. No LLM, ~50ms. Recommended for orchestrators. |
| `status` | Progress dashboard (TUI overlay — useful interactively, not for parsing) |
| `visualize` | Workflow visualizer (deps, metrics, timeline) |
| `history` | Execution history (supports --cost, --phase, --model, limit) |
@ -44,6 +46,9 @@ All commands can be run via `gsd headless [command]`.
| `cleanup` | Remove merged branches or snapshots |
| `export` | Export results (--json, --markdown) |
| `migrate` | Migrate v1 .planning directory to .gsd format |
| `remote` | Control remote auto-mode (slack, discord, status, disconnect) |
| `inspect` | Show SQLite DB diagnostics (schema, row counts) |
| `forensics` | Post-mortem investigation of auto-mode failures |
## Phases

View file

@ -68,20 +68,11 @@ Coordinator writes to `.gsd/parallel/<milestoneId>.signal.json`. Worker consumes
# 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 &
gsd headless --json 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
```
Workers emit JSONL events on stdout when `--json` is set.
## Monitoring All Workers
@ -122,9 +113,9 @@ send_signal M003 resume
## Budget Enforcement
Track aggregate cost across all workers:
Use `gsd headless query` for instant aggregate cost:
```bash
TOTAL=$(jq -s 'map(.cost) | add // 0' .gsd/parallel/*.status.json)
TOTAL=$(gsd headless query | jq -r '.cost.total')
CEILING=50.00
if (( $(echo "$TOTAL > $CEILING" | bc -l) )); then
echo "Budget exceeded ($TOTAL > $CEILING) — stopping all"

View file

@ -0,0 +1,162 @@
/**
* Tests for `gsd headless query` single JSON snapshot command.
*
* Validates that the snapshot contains state, next dispatch preview,
* and parallel worker costs in one response.
*/
import { describe, it, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { handleQuery } from '../../../../headless-query.ts'
import type { QuerySnapshot } from '../../../../headless-query.ts'
import { invalidateStateCache } from '../state.ts'
// ─── Fixture Helpers ────────────────────────────────────────────────────────
function createFixture(): string {
const base = mkdtempSync(join(tmpdir(), 'gsd-query-test-'))
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true })
return base
}
function writeRoadmap(base: string, mid: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid)
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, `${mid}-ROADMAP.md`), content)
}
function writeContext(base: string, mid: string): void {
const dir = join(base, '.gsd', 'milestones', mid)
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, `${mid}-CONTEXT.md`), `---\ntitle: Test Milestone\n---\n\n# Context\nTest.`)
}
function writeSlicePlan(base: string, mid: string, sid: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid)
mkdirSync(join(dir, 'tasks'), { recursive: true })
writeFileSync(join(dir, `${sid}-PLAN.md`), content)
}
function writeTaskPlan(base: string, mid: string, sid: string, tid: string): void {
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid, 'tasks')
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, `${tid}-PLAN.md`), `---\nestimated_steps: 3\nestimated_files: 2\n---\n\n# ${tid}: Test Task\nDo something.`)
}
function writeParallelStatus(base: string, mid: string, cost: number): void {
const dir = join(base, '.gsd', 'parallel')
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, `${mid}.status.json`), JSON.stringify({
milestoneId: mid,
pid: process.pid,
state: 'running',
currentUnit: { type: 'execute-task', id: `${mid}/S01/T01`, startedAt: Date.now() },
completedUnits: 2,
cost,
lastHeartbeat: Date.now(),
startedAt: Date.now() - 60_000,
worktreePath: `/tmp/worktrees/${mid}`,
}))
}
function createExecutingFixture(base: string): void {
writeContext(base, 'M001')
writeRoadmap(base, 'M001', `# M001: Test Milestone
**Vision:** Build something.
## Slices
- [ ] **S01: First Slice** \`risk:low\` \`depends:[]\`
> After this: The first slice works.
`)
writeSlicePlan(base, 'M001', 'S01', `# S01: First Slice
**Goal:** Implement something.
**Demo:** It works.
## Tasks
- [ ] **T01: First Task** Do the first thing
- Files: foo.ts
- Verify: run tests
- [ ] **T02: Second Task** Do the second thing
- Files: bar.ts
`)
writeTaskPlan(base, 'M001', 'S01', 'T01')
}
// ─── Tests ──────────────────────────────────────────────────────────────────
describe('headless query', () => {
let base: string
beforeEach(() => {
base = createFixture()
invalidateStateCache()
})
afterEach(() => {
rmSync(base, { recursive: true, force: true })
})
it('returns snapshot with state, next, and cost', async () => {
createExecutingFixture(base)
const result = await handleQuery(base)
const snap = result.data as QuerySnapshot
assert.equal(result.exitCode, 0)
// state
assert.equal(snap.state.phase, 'executing')
assert.equal(snap.state.activeMilestone!.id, 'M001')
assert.equal(snap.state.activeSlice!.id, 'S01')
assert.equal(snap.state.activeTask!.id, 'T01')
assert.ok(Array.isArray(snap.state.registry))
assert.ok(snap.state.progress)
// next
assert.equal(snap.next.action, 'dispatch')
assert.equal(snap.next.unitType, 'execute-task')
assert.ok(snap.next.unitId)
// cost (no parallel workers)
assert.equal(snap.cost.workers.length, 0)
assert.equal(snap.cost.total, 0)
})
it('returns stop when no milestones exist', async () => {
const result = await handleQuery(base)
const snap = result.data as QuerySnapshot
assert.equal(result.exitCode, 0)
assert.equal(snap.state.phase, 'pre-planning')
assert.equal(snap.state.activeMilestone, null)
assert.equal(snap.next.action, 'stop')
assert.ok(snap.next.reason)
})
it('aggregates parallel worker costs', async () => {
createExecutingFixture(base)
writeParallelStatus(base, 'M001', 1.50)
writeParallelStatus(base, 'M002', 2.75)
const result = await handleQuery(base)
const snap = result.data as QuerySnapshot
assert.equal(snap.cost.workers.length, 2)
assert.equal(snap.cost.total, 4.25)
assert.ok(snap.cost.workers.some(w => w.milestoneId === 'M001' && w.cost === 1.50))
assert.ok(snap.cost.workers.some(w => w.milestoneId === 'M002' && w.cost === 2.75))
})
it('shows dispatch preview for pre-planning with context', async () => {
writeContext(base, 'M001')
const result = await handleQuery(base)
const snap = result.data as QuerySnapshot
assert.equal(snap.state.phase, 'pre-planning')
assert.equal(snap.state.activeMilestone!.id, 'M001')
assert.equal(snap.next.action, 'dispatch')
})
})