From 8ddea154e5565e9e300e4215a351918e3e37e699 Mon Sep 17 00:00:00 2001 From: frizynn Date: Mon, 16 Mar 2026 18:23:07 -0300 Subject: [PATCH] feat: redesign `gsd headless` for full workflow orchestration Replace --step flag with positional command routing so any /gsd subcommand can run headlessly. Add /gsd dispatch for direct unit-type dispatch (research, plan, execute, complete, reassess, uat, replan) with state-aware resolution. Quick commands (status, queue, doctor, etc.) resolve on first agent_end. Long-running commands (auto, next, dispatch) use idle timer + terminal notification detection. --- src/headless.ts | 81 +++++--- src/resources/extensions/gsd/auto.ts | 189 ++++++++++++++++++ src/resources/extensions/gsd/commands.ts | 23 ++- .../gsd/tests/integration/headless-command.ts | 6 +- 4 files changed, 267 insertions(+), 32 deletions(-) diff --git a/src/headless.ts b/src/headless.ts index 78f87e4ba..bb8d6b646 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -1,14 +1,14 @@ /** * Headless Orchestrator — `gsd headless` * - * Runs GSD's auto-mode (or a single unit via --step) without a TUI by - * spawning a child process in RPC mode, auto-responding to extension UI - * requests, and streaming progress to stderr. + * Runs any /gsd subcommand without a TUI by spawning a child process in + * RPC mode, auto-responding to extension UI requests, and streaming + * progress to stderr. * * Exit codes: - * 0 — complete (auto-mode finished successfully) + * 0 — complete (command finished successfully) * 1 — error or timeout - * 2 — blocked (auto-mode reported a blocker) + * 2 — blocked (command reported a blocker) */ import { existsSync } from 'node:fs' @@ -25,10 +25,11 @@ import { RpcClient } from '../packages/pi-coding-agent/dist/modes/rpc/rpc-client export interface HeadlessOptions { timeout: number - step: boolean json: boolean verbose: boolean model?: string + command: string + commandArgs: string[] } interface ExtensionUIRequest { @@ -56,29 +57,38 @@ interface TrackedEvent { export function parseHeadlessArgs(argv: string[]): HeadlessOptions { const options: HeadlessOptions = { timeout: 300_000, - step: false, json: false, verbose: false, + command: 'auto', + commandArgs: [], } const args = argv.slice(2) + let positionalStarted = false + for (let i = 0; i < args.length; i++) { const arg = args[i] if (arg === 'headless') continue - if (arg === '--timeout' && i + 1 < args.length) { - options.timeout = parseInt(args[++i], 10) - if (Number.isNaN(options.timeout) || options.timeout <= 0) { - process.stderr.write('[headless] Error: --timeout must be a positive integer (milliseconds)\n') - process.exit(1) + + if (!positionalStarted && arg.startsWith('--')) { + if (arg === '--timeout' && i + 1 < args.length) { + options.timeout = parseInt(args[++i], 10) + if (Number.isNaN(options.timeout) || options.timeout <= 0) { + process.stderr.write('[headless] Error: --timeout must be a positive integer (milliseconds)\n') + process.exit(1) + } + } else if (arg === '--json') { + options.json = true + } else if (arg === '--verbose') { + options.verbose = true + } else if (arg === '--model' && i + 1 < args.length) { + options.model = args[++i] } - } else if (arg === '--step') { - options.step = true - } else if (arg === '--json') { - options.json = true - } else if (arg === '--verbose') { - options.verbose = true - } else if (arg === '--model' && i + 1 < args.length) { - options.model = args[++i] + } else if (!positionalStarted) { + positionalStarted = true + options.command = arg + } else { + options.commandArgs.push(arg) } } @@ -197,6 +207,21 @@ function isBlockedNotification(event: Record): boolean { return String(event.message ?? '').toLowerCase().includes('blocked') } +// --------------------------------------------------------------------------- +// Quick Command Detection +// --------------------------------------------------------------------------- + +const QUICK_COMMANDS = new Set([ + 'status', 'queue', 'history', 'hooks', 'export', 'stop', 'pause', + 'capture', 'skip', 'undo', 'knowledge', 'config', 'prefs', + 'cleanup', 'migrate', 'doctor', 'remote', 'help', 'steer', + 'triage', 'visualize', +]) + +function isQuickCommand(command: string): boolean { + return QUICK_COMMANDS.has(command) +} + // --------------------------------------------------------------------------- // Main Orchestrator // --------------------------------------------------------------------------- @@ -326,11 +351,15 @@ export async function runHeadless(options: HeadlessOptions): Promise { } } - // agent_end after tool execution — possible completion - if (eventObj.type === 'agent_end' && sawToolExecution && !completed) { - // Don't immediately resolve — wait for potential terminal notify or idle timeout. - // The idle timer handles this case. + // Quick commands: resolve on first agent_end + if (eventObj.type === 'agent_end' && isQuickCommand(options.command) && !completed) { + completed = true + resolveCompletion() + return } + + // Long-running commands: agent_end after tool execution — possible completion + // The idle timer + terminal notification handle this case. }) // Signal handling @@ -379,11 +408,11 @@ export async function runHeadless(options: HeadlessOptions): Promise { }) if (!options.json) { - process.stderr.write('[headless] Starting auto-mode...\n') + process.stderr.write(`[headless] Running /gsd ${options.command}${options.commandArgs.length > 0 ? ' ' + options.commandArgs.join(' ') : ''}...\n`) } // Send the command - const command = options.step ? '/gsd next' : '/gsd auto' + const command = `/gsd ${options.command}${options.commandArgs.length > 0 ? ' ' + options.commandArgs.join(' ') : ''}` try { await client.prompt(command) } catch (err) { diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 87ef155f4..80799d96a 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -3380,3 +3380,192 @@ export async function dispatchHookUnit( return true; } + + +// ─── Direct Phase Dispatch ──────────────────────────────────────────────────── + +export async function dispatchDirectPhase( + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + phase: string, + base: string, +): Promise { + const state = await deriveState(base); + const mid = state.activeMilestone?.id; + const midTitle = state.activeMilestone?.title ?? ""; + + if (!mid) { + ctx.ui.notify("Cannot dispatch: no active milestone.", "warning"); + return; + } + + const normalized = phase.toLowerCase(); + let unitType: string; + let unitId: string; + let prompt: string; + + switch (normalized) { + case "research": + case "research-milestone": + case "research-slice": { + const isSlice = normalized === "research-slice" || (normalized === "research" && state.phase !== "pre-planning"); + if (isSlice) { + const sid = state.activeSlice?.id; + const sTitle = state.activeSlice?.title ?? ""; + if (!sid) { + ctx.ui.notify("Cannot dispatch research-slice: no active slice.", "warning"); + return; + } + unitType = "research-slice"; + unitId = `${mid}/${sid}`; + prompt = await buildResearchSlicePrompt(mid, midTitle, sid, sTitle, base); + } else { + unitType = "research-milestone"; + unitId = mid; + prompt = await buildResearchMilestonePrompt(mid, midTitle, base); + } + break; + } + + case "plan": + case "plan-milestone": + case "plan-slice": { + const isSlice = normalized === "plan-slice" || (normalized === "plan" && state.phase !== "pre-planning"); + if (isSlice) { + const sid = state.activeSlice?.id; + const sTitle = state.activeSlice?.title ?? ""; + if (!sid) { + ctx.ui.notify("Cannot dispatch plan-slice: no active slice.", "warning"); + return; + } + unitType = "plan-slice"; + unitId = `${mid}/${sid}`; + prompt = await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, base); + } else { + unitType = "plan-milestone"; + unitId = mid; + prompt = await buildPlanMilestonePrompt(mid, midTitle, base); + } + break; + } + + case "execute": + case "execute-task": { + const sid = state.activeSlice?.id; + const sTitle = state.activeSlice?.title ?? ""; + const tid = state.activeTask?.id; + const tTitle = state.activeTask?.title ?? ""; + if (!sid) { + ctx.ui.notify("Cannot dispatch execute-task: no active slice.", "warning"); + return; + } + if (!tid) { + ctx.ui.notify("Cannot dispatch execute-task: no active task.", "warning"); + return; + } + unitType = "execute-task"; + unitId = `${mid}/${sid}/${tid}`; + prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base); + break; + } + + case "complete": + case "complete-slice": + case "complete-milestone": { + const isSlice = normalized === "complete-slice" || (normalized === "complete" && state.phase === "summarizing"); + if (isSlice) { + const sid = state.activeSlice?.id; + const sTitle = state.activeSlice?.title ?? ""; + if (!sid) { + ctx.ui.notify("Cannot dispatch complete-slice: no active slice.", "warning"); + return; + } + unitType = "complete-slice"; + unitId = `${mid}/${sid}`; + prompt = await buildCompleteSlicePrompt(mid, midTitle, sid, sTitle, base); + } else { + unitType = "complete-milestone"; + unitId = mid; + prompt = await buildCompleteMilestonePrompt(mid, midTitle, base); + } + break; + } + + case "reassess": + case "reassess-roadmap": { + const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; + if (!roadmapContent) { + ctx.ui.notify("Cannot dispatch reassess-roadmap: no roadmap found.", "warning"); + return; + } + const roadmap = parseRoadmap(roadmapContent); + const completedSlices = roadmap.slices.filter(s => s.done); + if (completedSlices.length === 0) { + ctx.ui.notify("Cannot dispatch reassess-roadmap: no completed slices.", "warning"); + return; + } + const completedSliceId = completedSlices[completedSlices.length - 1].id; + unitType = "reassess-roadmap"; + unitId = `${mid}/${completedSliceId}`; + prompt = await buildReassessRoadmapPrompt(mid, midTitle, completedSliceId, base); + break; + } + + case "uat": + case "run-uat": { + const sid = state.activeSlice?.id; + if (!sid) { + ctx.ui.notify("Cannot dispatch run-uat: no active slice.", "warning"); + return; + } + const uatFile = resolveSliceFile(base, mid, sid, "UAT"); + if (!uatFile) { + ctx.ui.notify("Cannot dispatch run-uat: no UAT file found.", "warning"); + return; + } + const uatContent = await loadFile(uatFile); + if (!uatContent) { + ctx.ui.notify("Cannot dispatch run-uat: UAT file is empty.", "warning"); + return; + } + const uatPath = relSliceFile(base, mid, sid, "UAT"); + unitType = "run-uat"; + unitId = `${mid}/${sid}`; + prompt = await buildRunUatPrompt(mid, sid, uatPath, uatContent, base); + break; + } + + case "replan": + case "replan-slice": { + const sid = state.activeSlice?.id; + const sTitle = state.activeSlice?.title ?? ""; + if (!sid) { + ctx.ui.notify("Cannot dispatch replan-slice: no active slice.", "warning"); + return; + } + unitType = "replan-slice"; + unitId = `${mid}/${sid}`; + prompt = await buildReplanSlicePrompt(mid, midTitle, sid, sTitle, base); + break; + } + + default: + ctx.ui.notify( + `Unknown phase "${phase}". Valid phases: research, plan, execute, complete, reassess, uat, replan.`, + "warning", + ); + return; + } + + ctx.ui.notify(`Dispatching ${unitType} for ${unitId}...`, "info"); + const result = await ctx.newSession(); + if (result.cancelled) { + ctx.ui.notify("Session creation cancelled.", "warning"); + return; + } + pi.sendMessage( + { customType: "gsd-dispatch", content: prompt, display: false }, + { triggerTurn: true }, + ); +} diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 01d0ee490..76229dfcf 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -14,7 +14,7 @@ import { deriveState } from "./state.js"; import { GSDDashboardOverlay } from "./dashboard-overlay.js"; import { GSDVisualizerOverlay } from "./visualizer-overlay.js"; import { showQueue, showDiscuss } from "./guided-flow.js"; -import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote } from "./auto.js"; +import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote, dispatchDirectPhase } from "./auto.js"; import { resolveProjectRoot } from "./worktree.js"; import { appendCapture, hasPendingCaptures, loadPendingCaptures } from "./captures.js"; import { @@ -69,11 +69,11 @@ function projectRoot(): string { export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - description: "GSD — Get Shit Done: /gsd help|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|history|undo|skip|export|cleanup|mode|prefs|config|hooks|run-hook|skill-health|doctor|forensics|migrate|remote|steer|knowledge", + description: "GSD — Get Shit Done: /gsd help|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|hooks|run-hook|skill-health|doctor|forensics|migrate|remote|steer|knowledge", getArgumentCompletions: (prefix: string) => { const subcommands = [ "help", "next", "auto", "stop", "pause", "status", "visualize", "queue", "quick", "discuss", - "capture", "triage", + "capture", "triage", "dispatch", "history", "undo", "skip", "export", "cleanup", "mode", "prefs", "config", "hooks", "run-hook", "skill-health", "doctor", "forensics", "migrate", "remote", "steer", "inspect", "knowledge", ]; @@ -165,6 +165,13 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return []; } + if (parts[0] === "dispatch" && parts.length <= 2) { + const phasePrefix = parts[1] ?? ""; + return ["research", "plan", "execute", "complete", "reassess", "uat", "replan"] + .filter((cmd) => cmd.startsWith(phasePrefix)) + .map((cmd) => ({ value: `dispatch ${cmd}`, label: cmd })); + } + return []; }, @@ -388,6 +395,16 @@ Examples: return; } + if (trimmed === "dispatch" || trimmed.startsWith("dispatch ")) { + const phase = trimmed.replace(/^dispatch\s*/, "").trim(); + if (!phase) { + ctx.ui.notify("Usage: /gsd dispatch (research|plan|execute|complete|reassess|uat|replan)", "warning"); + return; + } + await dispatchDirectPhase(ctx, pi, phase, projectRoot()); + return; + } + if (trimmed === "inspect") { await handleInspect(ctx); return; diff --git a/src/resources/extensions/gsd/tests/integration/headless-command.ts b/src/resources/extensions/gsd/tests/integration/headless-command.ts index fc5f3582d..870c5c058 100644 --- a/src/resources/extensions/gsd/tests/integration/headless-command.ts +++ b/src/resources/extensions/gsd/tests/integration/headless-command.ts @@ -4,7 +4,7 @@ * Validates that the headless CLI entry point works end-to-end: * 1. Creates a temp dir with a complete .gsd/ project fixture * 2. Initializes a git repo in the temp dir - * 3. Spawns `node dist/loader.js headless --step --json` as a child process + * 3. Spawns `node dist/loader.js headless --json next` as a child process * 4. Waits for the process to exit (with a 5-minute timeout) * 5. Validates exit code, JSONL stdout, stderr progress, and task artifact * @@ -394,7 +394,7 @@ async function main(): Promise { // ── Step 4: Spawn headless command ────────────────────────────────────── console.log("\n[3/6] Spawning headless command..."); - console.log(` Command: node ${loaderPath} headless --step --json`); + console.log(` Command: node ${loaderPath} headless --json next`); console.log(` CWD: ${fixtureDir}`); console.log(` Timeout: ${TIMEOUT_MS / 1000}s`); @@ -407,7 +407,7 @@ async function main(): Promise { let stderrBuf = ""; let settled = false; - const child = spawn("node", [loaderPath, "headless", "--step", "--json"], { + const child = spawn("node", [loaderPath, "headless", "--json", "next"], { cwd: fixtureDir, env: { ...process.env }, stdio: ["ignore", "pipe", "pipe"],