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:
parent
93ee6646f1
commit
8ddea154e5
4 changed files with 267 additions and 32 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue