feat: add headless new-milestone command for programmatic milestone creation (#781)

Enables fully headless project creation from specification documents via
`gsd headless new-milestone --context spec.md`. Supports file input,
stdin piping, inline text, and optional auto-mode chaining with --auto.

Key changes:
- headless.ts: new CLI flags (--context, --context-text, --auto, --verbose),
  context loading (file/stdin/inline), .gsd/ bootstrapping, auto-mode chaining
- commands.ts: /gsd new-milestone command routing via headless context temp file
- guided-flow.ts: showHeadlessMilestoneCreation(), bootstrapGsdProject(),
  buildHeadlessDiscussPrompt() for non-interactive milestone creation
- prompts/discuss-headless.md: headless variant of discuss.md that skips Q&A
  rounds and works entirely from the provided specification
- help-text.ts: documentation for new-milestone subcommand and flags

Closes #765

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-16 21:28:56 -06:00 committed by GitHub
parent 8dcb2f9195
commit 69d37d3196
5 changed files with 331 additions and 20 deletions

View file

@ -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, unknown>): string | null {
function formatProgress(event: Record<string, unknown>, 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<string, unknown>): boolean {
return String(event.message ?? '').toLowerCase().includes('blocked')
}
function isMilestoneReadyNotification(event: Record<string, unknown>): 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<string> {
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<string> {
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 <file> or --context-text <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<void> {
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 <file> or --context-text <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<void> {
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<string, unknown>): void {
@ -302,7 +388,7 @@ export async function runHeadless(options: HeadlessOptions): Promise<void> {
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<void> {
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<void> {
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<void>((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)

View file

@ -38,15 +38,30 @@ const SUBCOMMAND_HELP: Record<string, string> = {
'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> Path to spec/PRD file (use \'-\' for stdin)',
' --context-text <txt> 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'),

View file

@ -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)",

View file

@ -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<void> {
// 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 {

View file

@ -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