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 <phase> 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.
This commit is contained in:
frizynn 2026-03-16 18:23:07 -03:00
parent 93ee6646f1
commit 8ddea154e5
4 changed files with 267 additions and 32 deletions

View file

@ -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<string, unknown>): 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<void> {
}
}
// 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<void> {
})
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) {

View file

@ -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<void> {
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 },
);
}

View file

@ -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 <phase> (research|plan|execute|complete|reassess|uat|replan)", "warning");
return;
}
await dispatchDirectPhase(ctx, pi, phase, projectRoot());
return;
}
if (trimmed === "inspect") {
await handleInspect(ctx);
return;

View file

@ -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<void> {
// ── 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<void> {
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"],