diff --git a/README.md b/README.md index 721119b79..9063c68cb 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/commands.md b/docs/commands.md index 683768c8d..13ddea04c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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. diff --git a/src/headless-query.ts b/src/headless-query.ts new file mode 100644 index 000000000..ac6803063 --- /dev/null +++ b/src/headless-query.ts @@ -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 { + 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 } +} diff --git a/src/headless.ts b/src/headless.ts index 006009edf..456dc9658 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -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) { diff --git a/src/help-text.ts b/src/help-text.ts index 864d85f3d..37c9c5316 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -49,6 +49,7 @@ const SUBCOMMAND_HELP: Record = { ' 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 to spec/PRD file (use \'-\' for stdin)', @@ -65,6 +66,7 @@ const SUBCOMMAND_HELP: Record = { ' 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'), diff --git a/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md b/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md index cbb6ec23c..b94319073 100644 --- a/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md +++ b/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md @@ -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 | diff --git a/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md b/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md index ac1bf4d00..0bfd26427 100644 --- a/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md +++ b/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md @@ -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 ` | 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 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 index ff24a9461..a1b501ed0 100644 --- a/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +++ b/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md @@ -68,20 +68,11 @@ Coordinator writes to `.gsd/parallel/.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" diff --git a/src/resources/extensions/gsd/tests/headless-query.test.ts b/src/resources/extensions/gsd/tests/headless-query.test.ts new file mode 100644 index 000000000..2d95bdf46 --- /dev/null +++ b/src/resources/extensions/gsd/tests/headless-query.test.ts @@ -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') + }) +})