diff --git a/src/headless.ts b/src/headless.ts index dacdb40d7..eee138ae5 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -11,8 +11,8 @@ * 2 — blocked (command reported a blocker) */ -import { existsSync } from 'node:fs' -import { join } from 'node:path' +import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' import { ChildProcess } from 'node:child_process' // RpcClient is not in @gsd/pi-coding-agent's public exports — import from dist directly. @@ -29,6 +29,10 @@ export interface HeadlessOptions { model?: string command: string commandArgs: string[] + context?: string // file path or '-' for stdin + contextText?: string // inline text + auto?: boolean // chain into auto-mode after milestone creation + verbose?: boolean // show tool calls in output } interface ExtensionUIRequest { @@ -80,6 +84,14 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions { } else if (arg === '--model' && i + 1 < args.length) { // --model can also be passed from the main CLI; headless-specific takes precedence options.model = args[++i] + } else if (arg === '--context' && i + 1 < args.length) { + options.context = args[++i] + } else if (arg === '--context-text' && i + 1 < args.length) { + options.contextText = args[++i] + } else if (arg === '--auto') { + options.auto = true + } else if (arg === '--verbose') { + options.verbose = true } } else if (!positionalStarted) { positionalStarted = true @@ -144,22 +156,26 @@ function handleExtensionUIRequest( // Progress Formatter // --------------------------------------------------------------------------- -function formatProgress(event: Record): string | null { +function formatProgress(event: Record, verbose: boolean): string | null { const type = String(event.type ?? '') switch (type) { case 'tool_execution_start': - return `[tool] ${event.toolName ?? 'unknown'}` + if (verbose) return ` [tool] ${event.toolName ?? 'unknown'}` + return null case 'agent_start': - return '[agent] Session started' + return '[agent] Session started' case 'agent_end': - return '[agent] Session ended' + return '[agent] Session ended' case 'extension_ui_request': if (event.method === 'notify') { - return `[gsd] ${event.message ?? ''}` + return `[gsd] ${event.message ?? ''}` + } + if (event.method === 'setStatus') { + return `[status] ${event.message ?? ''}` } return null @@ -186,6 +202,11 @@ function isBlockedNotification(event: Record): boolean { return String(event.message ?? '').toLowerCase().includes('blocked') } +function isMilestoneReadyNotification(event: Record): boolean { + if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false + return /milestone\s+m\d+.*ready/i.test(String(event.message ?? '')) +} + // --------------------------------------------------------------------------- // Quick Command Detection // --------------------------------------------------------------------------- @@ -205,12 +226,76 @@ function isQuickCommand(command: string): boolean { // Main Orchestrator // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Context Loading (new-milestone) +// --------------------------------------------------------------------------- + +async function readStdin(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer) + } + return Buffer.concat(chunks).toString('utf-8') +} + +async function loadContext(options: HeadlessOptions): Promise { + if (options.contextText) return options.contextText + if (options.context === '-') { + return readStdin() + } + if (options.context) { + return readFileSync(resolve(options.context), 'utf-8') + } + throw new Error('No context provided. Use --context or --context-text ') +} + +/** + * Bootstrap .gsd/ directory structure for headless new-milestone. + * Mirrors the bootstrap logic from guided-flow.ts showSmartEntry(). + */ +function bootstrapGsdProject(basePath: string): void { + const gsdDir = join(basePath, '.gsd') + mkdirSync(join(gsdDir, 'milestones'), { recursive: true }) + mkdirSync(join(gsdDir, 'runtime'), { recursive: true }) +} + export async function runHeadless(options: HeadlessOptions): Promise { const startTime = Date.now() + const isNewMilestone = options.command === 'new-milestone' - // Validate .gsd/ directory + // For new-milestone, load context and bootstrap .gsd/ before spawning RPC child + if (isNewMilestone) { + if (!options.context && !options.contextText) { + process.stderr.write('[headless] Error: new-milestone requires --context or --context-text \n') + process.exit(1) + } + + let contextContent: string + try { + contextContent = await loadContext(options) + } catch (err) { + process.stderr.write(`[headless] Error loading context: ${err instanceof Error ? err.message : String(err)}\n`) + process.exit(1) + } + + // Bootstrap .gsd/ if needed + const gsdDir = join(process.cwd(), '.gsd') + if (!existsSync(gsdDir)) { + if (!options.json) { + process.stderr.write('[headless] Bootstrapping .gsd/ project structure...\n') + } + bootstrapGsdProject(process.cwd()) + } + + // Write context to temp file for the RPC child to read + const runtimeDir = join(gsdDir, 'runtime') + mkdirSync(runtimeDir, { recursive: true }) + writeFileSync(join(runtimeDir, 'headless-context.md'), contextContent, 'utf-8') + } + + // Validate .gsd/ directory (skip for new-milestone since we just bootstrapped it) const gsdDir = join(process.cwd(), '.gsd') - if (!existsSync(gsdDir)) { + if (!isNewMilestone && !existsSync(gsdDir)) { process.stderr.write('[headless] Error: No .gsd/ directory found in current directory.\n') process.stderr.write("[headless] Run 'gsd' interactively first to initialize a project.\n") process.exit(1) @@ -240,6 +325,7 @@ export async function runHeadless(options: HeadlessOptions): Promise { let blocked = false let completed = false let exitCode = 0 + let milestoneReady = false // tracks "Milestone X ready." for auto-chaining const recentEvents: TrackedEvent[] = [] function trackEvent(event: Record): void { @@ -302,7 +388,7 @@ export async function runHeadless(options: HeadlessOptions): Promise { process.stdout.write(JSON.stringify(eventObj) + '\n') } else { // Progress output to stderr - const line = formatProgress(eventObj) + const line = formatProgress(eventObj, !!options.verbose) if (line) process.stderr.write(line + '\n') } @@ -312,6 +398,12 @@ export async function runHeadless(options: HeadlessOptions): Promise { if (isBlockedNotification(eventObj)) { blocked = true } + + // Detect "Milestone X ready." for auto-mode chaining + if (isMilestoneReadyNotification(eventObj)) { + milestoneReady = true + } + if (isTerminalNotification(eventObj)) { completed = true } @@ -400,6 +492,32 @@ export async function runHeadless(options: HeadlessOptions): Promise { await completionPromise } + // Auto-mode chaining: if --auto and milestone creation succeeded, send /gsd auto + if (isNewMilestone && options.auto && milestoneReady && !blocked && exitCode === 0) { + if (!options.json) { + process.stderr.write('[headless] Milestone ready — chaining into auto-mode...\n') + } + + // Reset completion state for the auto-mode phase + completed = false + milestoneReady = false + blocked = false + const autoCompletionPromise = new Promise((resolve) => { + resolveCompletion = resolve + }) + + try { + await client.prompt('/gsd auto') + } catch (err) { + process.stderr.write(`[headless] Error: Failed to start auto-mode: ${err instanceof Error ? err.message : String(err)}\n`) + exitCode = 1 + } + + if (exitCode === 0 || exitCode === 2) { + await autoCompletionPromise + } + } + // Cleanup clearTimeout(timeoutTimer) if (idleTimer) clearTimeout(idleTimer) diff --git a/src/help-text.ts b/src/help-text.ts index dc63b1198..8c866b22a 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -38,15 +38,30 @@ const SUBCOMMAND_HELP: Record = { 'Run /gsd commands without the TUI. Default command: auto', '', 'Flags:', - ' --timeout N Overall timeout in ms (default: 300000)', - ' --json JSONL event stream to stdout', - ' --model ID Override model', + ' --timeout N Overall timeout in ms (default: 300000)', + ' --json JSONL event stream to stdout', + ' --model ID Override model', + '', + 'Commands:', + ' auto Run all queued units continuously (default)', + ' next Run one unit', + ' status Show progress dashboard', + ' new-milestone Create a milestone from a specification document', + '', + 'new-milestone flags:', + ' --context Path to spec/PRD file (use \'-\' for stdin)', + ' --context-text Inline specification text', + ' --auto Start auto-mode after milestone creation', + ' --verbose Show tool calls in progress output', '', 'Examples:', - ' gsd headless Run /gsd auto', - ' gsd headless next Run one unit', - ' gsd headless --json status Machine-readable status', - ' gsd headless --timeout 60000 With 1-minute timeout', + ' gsd headless Run /gsd auto', + ' gsd headless next Run one unit', + ' gsd headless --json status Machine-readable status', + ' gsd headless --timeout 60000 With 1-minute timeout', + ' gsd headless new-milestone --context spec.md Create milestone from file', + ' cat spec.md | gsd headless new-milestone --context - From stdin', + ' gsd headless new-milestone --context spec.md --auto Create + auto-execute', '', 'Exit codes: 0 = complete, 1 = error/timeout, 2 = blocked', ].join('\n'), diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 55df698cc..447b977df 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -6,14 +6,14 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; import { AuthStorage } from "@gsd/pi-coding-agent"; -import { existsSync, readFileSync, mkdirSync } from "node:fs"; +import { existsSync, readFileSync, mkdirSync, unlinkSync } from "node:fs"; import { join, dirname } from "node:path"; import { enableDebug, isDebugEnabled } from "./debug-logger.js"; import { fileURLToPath } from "node:url"; 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 { showQueue, showDiscuss, showHeadlessMilestoneCreation } from "./guided-flow.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"; @@ -77,7 +77,7 @@ 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|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|hooks|run-hook|skill-health|doctor|forensics|migrate|remote|steer|knowledge|parallel", + 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|new-milestone|parallel", getArgumentCompletions: (prefix: string) => { const subcommands = [ { cmd: "help", desc: "Categorized command reference with descriptions" }, @@ -111,6 +111,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { { cmd: "steer", desc: "Hard-steer plan documents during execution" }, { cmd: "inspect", desc: "Show SQLite DB diagnostics" }, { cmd: "knowledge", desc: "Add persistent project knowledge (rule, pattern, or lesson)" }, + { cmd: "new-milestone", desc: "Create a milestone from a specification document (headless)" }, { cmd: "parallel", desc: "Parallel milestone orchestration (start, status, stop, merge)" }, ]; const parts = prefix.trim().split(/\s+/); @@ -463,6 +464,21 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return; } + if (trimmed === "new-milestone") { + const basePath = projectRoot(); + const headlessContextPath = join(basePath, ".gsd", "runtime", "headless-context.md"); + if (existsSync(headlessContextPath)) { + const seedContext = readFileSync(headlessContextPath, "utf-8"); + try { unlinkSync(headlessContextPath); } catch { /* non-fatal */ } + await showHeadlessMilestoneCreation(ctx, pi, basePath, seedContext); + } else { + // No headless context — fall back to interactive smart entry + const { showSmartEntry } = await import("./guided-flow.js"); + await showSmartEntry(ctx, pi, basePath); + } + return; + } + if (trimmed.startsWith("capture ") || trimmed === "capture") { await handleCapture(trimmed.replace(/^capture\s*/, "").trim(), ctx); return; @@ -583,6 +599,7 @@ function showHelp(ctx: ExtensionCommandContext): void { " /gsd stop Stop auto-mode gracefully", " /gsd pause Pause auto-mode (preserves state, /gsd auto to resume)", " /gsd discuss Start guided milestone/slice discussion", + " /gsd new-milestone Create milestone from headless context (used by gsd headless)", "", "VISIBILITY", " /gsd status Show progress dashboard (Ctrl+Alt+G)", diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 52b33605d..76fad5447 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -201,6 +201,81 @@ function buildDiscussPrompt(nextId: string, preamble: string, _basePath: string) }); } +/** + * Build the discuss prompt for headless milestone creation. + * Uses the discuss-headless prompt template with seed context injected. + */ +function buildHeadlessDiscussPrompt(nextId: string, seedContext: string, _basePath: string): string { + const milestoneRel = `.gsd/milestones/${nextId}`; + const inlinedTemplates = [ + inlineTemplate("project", "Project"), + inlineTemplate("requirements", "Requirements"), + inlineTemplate("context", "Context"), + inlineTemplate("roadmap", "Roadmap"), + inlineTemplate("decisions", "Decisions"), + ].join("\n\n---\n\n"); + return loadPrompt("discuss-headless", { + milestoneId: nextId, + seedContext, + contextPath: `${milestoneRel}/${nextId}-CONTEXT.md`, + roadmapPath: `${milestoneRel}/${nextId}-ROADMAP.md`, + inlinedTemplates, + }); +} + +/** + * Bootstrap a .gsd/ project from scratch for headless use. + * Ensures git repo, .gsd/ structure, gitignore, and preferences all exist. + */ +function bootstrapGsdProject(basePath: string): void { + if (!nativeIsRepo(basePath)) { + const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main"; + nativeInit(basePath, mainBranch); + } + + const root = gsdRoot(basePath); + mkdirSync(join(root, "milestones"), { recursive: true }); + mkdirSync(join(root, "runtime"), { recursive: true }); + + const commitDocs = loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs; + ensureGitignore(basePath, { commitDocs }); + ensurePreferences(basePath); + untrackRuntimeFiles(basePath); +} + +/** + * Headless milestone creation from a seed specification document. + * Bootstraps the project if needed, generates the next milestone ID, + * and dispatches the headless discuss prompt (no Q&A rounds). + */ +export async function showHeadlessMilestoneCreation( + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + basePath: string, + seedContext: string, +): Promise { + // Ensure .gsd/ is bootstrapped + bootstrapGsdProject(basePath); + + // Generate next milestone ID + const existingIds = findMilestoneIds(basePath); + const prefs = loadEffectiveGSDPreferences(); + const nextId = nextMilestoneId(existingIds, prefs?.preferences?.unique_milestone_ids ?? false); + + // Create milestone directory + const milestoneDir = join(basePath, ".gsd", "milestones", nextId, "slices"); + mkdirSync(milestoneDir, { recursive: true }); + + // Build and dispatch the headless discuss prompt + const prompt = buildHeadlessDiscussPrompt(nextId, seedContext, basePath); + + // Set pending auto start (auto-mode triggers on "Milestone X ready." via checkAutoStartAfterDiscuss) + pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId }; + + // Dispatch + dispatchWorkflow(pi, prompt); +} + export function findMilestoneIds(basePath: string): string[] { const dir = milestonesDir(basePath); try { diff --git a/src/resources/extensions/gsd/prompts/discuss-headless.md b/src/resources/extensions/gsd/prompts/discuss-headless.md new file mode 100644 index 000000000..8e2191667 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/discuss-headless.md @@ -0,0 +1,86 @@ +# Headless Milestone Creation + +You are creating a GSD milestone from a provided specification document. This is a **headless** (non-interactive) flow — do NOT ask the user any questions. Work entirely from the provided specification. + +## Provided Specification + +{{seedContext}} + +## Your Task + +### Step 1: Reflect + +Summarize your understanding of the specification concretely: +- What is being built +- Major capabilities/features +- Scope estimate (how many milestones × slices) +- Any ambiguities or gaps you notice + +### Step 2: Investigate + +Scout the codebase to understand what already exists: +- `ls` the project root and key directories +- Search for relevant existing code, patterns, dependencies +- Check library docs if needed (`resolve_library` / `get_library_docs`) + +### Step 3: Make Decisions + +For any ambiguities or gaps in the specification: +- Make your best-guess decision based on the spec's intent, codebase patterns, and domain conventions +- Document each assumption clearly in the Context file + +### Step 4: Assess Scope + +Based on reflection + investigation: +- Is this a single milestone or multiple milestones? +- If multi-milestone: plan the full sequence with dependencies + +### Step 5: Write Artifacts + +**Milestone ID**: {{milestoneId}} + +Use these templates exactly: + +{{inlinedTemplates}} + +**For single milestone**, write in this order: +1. `mkdir -p .gsd/milestones/{{milestoneId}}/slices` +2. Write `.gsd/PROJECT.md` (using Project template) +3. Write `.gsd/REQUIREMENTS.md` (using Requirements template) +4. Write `{{contextPath}}` (using Context template) — preserve the specification's exact terminology, emphasis, and specific framing. Do not paraphrase domain-specific language into generics. Document assumptions under an "Assumptions" section. +5. Write `{{roadmapPath}}` (using Roadmap template) — decompose into demoable vertical slices with checkboxes, risk, depends, demo sentences, proof strategy, verification classes, milestone definition of done, requirement coverage, and a boundary map. If the milestone crosses multiple runtime boundaries, include an explicit final integration slice. +6. Seed `.gsd/DECISIONS.md` (using Decisions template) +7. Update `.gsd/STATE.md` +8. Commit: `docs({{milestoneId}}): context, requirements, and roadmap` +9. Say exactly: "Milestone {{milestoneId}} ready." + +**For multi-milestone**, write in this order: +1. Create all milestone directories: `mkdir -p .gsd/milestones/{M###}/slices` for each +2. Write `.gsd/PROJECT.md` — full vision across ALL milestones (using Project template) +3. Write `.gsd/REQUIREMENTS.md` — full capability contract (using Requirements template) +4. Seed `.gsd/DECISIONS.md` (using Decisions template) +5. Write PRIMARY `{{contextPath}}` — full context with all assumptions documented +6. Write PRIMARY `{{roadmapPath}}` — detailed slices for the first milestone only +7. For each remaining milestone, write full CONTEXT.md with `depends_on` frontmatter: + ```yaml + --- + depends_on: [M001, M002] + --- + + # M003: Title + ``` + Each context file should be rich enough that a future agent — with no memory of this conversation — can understand the intent, constraints, dependencies, what the milestone unlocks, and what "done" looks like. +8. Update `.gsd/STATE.md` +9. Commit: `docs: project plan — N milestones` +10. Say exactly: "Milestone {{milestoneId}} ready." + +## Critical Rules + +- **DO NOT ask the user any questions** — this is headless mode +- **Preserve the specification's terminology** — don't paraphrase domain-specific language +- **Document assumptions** — when you make a judgment call, note it in CONTEXT.md under "Assumptions" +- **Investigate before writing** — always scout the codebase first +- **Use depends_on frontmatter** for multi-milestone sequences (the state machine reads this field to determine execution order) +- **Anti-reduction rule** — if the spec describes a big vision, plan the big vision. Do not ask "what's the minimum viable version?" or reduce scope. Phase complex/risky work into later milestones — do not cut it. +- **Naming convention** — directories use bare IDs (`M001/`, `S01/`), files use ID-SUFFIX format (`M001-CONTEXT.md`, `M001-ROADMAP.md`) +- **End with "Milestone {{milestoneId}} ready."** — this triggers auto-start detection