diff --git a/docs/user-docs/commands.md b/docs/user-docs/commands.md index 0f75ad381..2c089ee4a 100644 --- a/docs/user-docs/commands.md +++ b/docs/user-docs/commands.md @@ -189,9 +189,12 @@ Enable with `github.enabled: true` in preferences. Requires `gh` CLI installed a `sf headless` runs `/sf` commands without a TUI — designed for CI, cron jobs, and scripted automation. It spawns a child process in RPC mode, auto-responds to interactive prompts, detects completion, and exits with meaningful exit codes. ```bash -# Run autonomous mode (default) +# Show headless command help sf headless +# Run autonomous mode without the TUI +sf headless autonomous + # Run a single unit sf headless next diff --git a/src/cli.ts b/src/cli.ts index 21c56c761..59fe96d0d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -119,7 +119,7 @@ function printNonTtyErrorAndExit( ); if (includeWebHint) { process.stderr.write( - "[sf] sf headless Autonomous mode without TUI\n", + "[sf] sf headless autonomous Autonomous mode without TUI\n", ); } process.exit(1); @@ -561,7 +561,7 @@ if (cliFlags.messages[0] === "sessions") { cliFlags._selectedSessionPath = selected.path; } -// `sf headless` — run autonomous mode without TUI +// `sf headless ...` — run explicit /sf commands without the TUI if (cliFlags.messages[0] === "headless") { await ensureRtkBootstrap(); // Sync bundled resources before headless runs (#3471). Without this, diff --git a/src/headless.ts b/src/headless.ts index 418c3c2e8..f52b42ca4 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -294,7 +294,7 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions { timeout: 300_000, json: false, outputFormat: "text", - command: "autonomous", + command: "help", commandExplicit: false, commandArgs: [], }; @@ -488,6 +488,11 @@ async function runHeadlessOnce( ): Promise<{ exitCode: number; interrupted: boolean }> { let interrupted = false; const startTime = Date.now(); + if (options.command === "help") { + const { printSubcommandHelp } = await import("./help-text.js"); + printSubcommandHelp("headless", process.env.SF_VERSION || "0.0.0"); + return { exitCode: EXIT_SUCCESS, interrupted: false }; + } if (options.command === "autonomous" && !options.resumeSession) { bootstrapProject(process.cwd()); if (!hasMilestones(process.cwd())) { @@ -521,29 +526,12 @@ async function runHeadlessOnce( options.command === "discuss" || options.command === "plan"; - // Auto-mode defaults to supervised: wait for user input instead of exiting on questions - // This is the desired behavior - auto should wait, not exit on blocked - // Can be disabled via --no-supervised or preferences.auto_supervisor.supervised_mode: false + // Headless uses the same SF command flow as TUI, but it is unattended by + // default: questions are answered by headless policy, not by waiting for a + // separate orchestrator. Opt into external question forwarding with + // --supervised when a caller really wants stdin/stdout mediation. if (options.command === "autonomous" && options.supervised === undefined) { - // Check preferences for default - try { - const { loadEffectiveSFPreferences } = await import( - "./resources/extensions/sf/preferences.js" - ); - const prefs = loadEffectiveSFPreferences(); - // Default to true unless explicitly set to false in preferences - const autoSupervisor = prefs?.preferences - ? (prefs.preferences as Record)["auto_supervisor"] - : undefined; - options.supervised = - autoSupervisor !== undefined - ? (((autoSupervisor as Record)?.[ - "supervised_mode" - ] as boolean | undefined) ?? true) - : true; - } catch { - options.supervised = true; - } + options.supervised = false; } if (isAutoMode && options.timeout === 300_000) { diff --git a/src/help-text.ts b/src/help-text.ts index 3492eee94..f1c8bb038 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -171,7 +171,7 @@ const SUBCOMMAND_HELP: Record = { headless: [ "Usage: sf headless [flags] [command] [args...]", "", - "Run /sf commands without the TUI. Default command: autonomous", + "Run /sf commands without the TUI. Pass an explicit command such as autonomous, next, status, or query.", "", "Flags:", " --timeout N Overall timeout in ms (default: 300000)", @@ -186,7 +186,7 @@ const SUBCOMMAND_HELP: Record = { " --events Filter JSONL output to specific event types (comma-separated)", "", "Commands:", - " autonomous Run all queued product units continuously (default)", + " autonomous Run all queued product units continuously", " next Run one unit", " status Show progress dashboard", " new-milestone Create a milestone from a specification document", @@ -204,7 +204,8 @@ const SUBCOMMAND_HELP: Record = { " stream-json Stream JSONL events to stdout in real time (same as --json)", "", "Examples:", - " sf headless Run /sf autonomous", + " sf headless Show this help", + " sf headless autonomous Run /sf autonomous without the TUI", " sf headless next Run one unit", " sf headless --output-format json autonomous Structured JSON result on stdout", " sf headless --json status Machine-readable JSONL stream", @@ -288,7 +289,7 @@ export function printHelp(version: string): void { " autonomous [args] Run autonomous mode without TUI (pipeable)\n", ); process.stdout.write( - " headless [cmd] [args] Run /sf commands without TUI (default: autonomous)\n", + " headless [cmd] [args] Run explicit /sf commands without TUI\n", ); process.stdout.write( " graph Manage knowledge graph (build, query, status, diff)\n", diff --git a/src/loader.ts b/src/loader.ts index 79eb79f8a..66350ad1e 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -82,9 +82,10 @@ if ( process.exit(0); } -// Fast-path invalid headless invocations before importing cli.ts. These paths -// are commonly used by smoke tests and orchestrators; they should return a -// clear diagnostic without paying extension/resource startup cost. +// Fast-path invalid headless flags before importing cli.ts. Project-state +// validation belongs to headless.ts because bare `sf headless` is help, `init` +// is allowed before .sf exists, and explicit commands decide their own state +// requirements. if (firstArg === "headless") { for (let i = 1; i < args.length; i += 1) { const arg = args[i]; @@ -98,13 +99,6 @@ if (firstArg === "headless") { } } } - - if (args.length === 1 && !existsSync(join(process.cwd(), ".sf"))) { - process.stderr.write( - "[headless] Error: No .sf/ directory found. Run 'sf headless init' or pass an explicit headless subcommand.\n", - ); - process.exit(1); - } } // Schedule due-items banner — lightweight check before heavy imports diff --git a/src/tests/headless-cli-surface.test.ts b/src/tests/headless-cli-surface.test.ts index 6174f3866..9c652da64 100644 --- a/src/tests/headless-cli-surface.test.ts +++ b/src/tests/headless-cli-surface.test.ts @@ -49,7 +49,7 @@ function parseHeadlessArgs(argv: string[]): HeadlessOptions { timeout: 300_000, json: false, outputFormat: "text", - command: "autonomous", + command: "help", commandArgs: [], }; @@ -126,6 +126,13 @@ function parseHeadlessArgs(argv: string[]): HeadlessOptions { // ─── --output-format flag parsing ────────────────────────────────────────── +test("bare headless defaults to help, not autonomous", () => { + const opts = parseHeadlessArgs(["node", "sf", "headless"]); + assert.equal(opts.command, "help"); + assert.equal(opts.auto, undefined); + assert.deepEqual(opts.commandArgs, []); +}); + test("--output-format text sets outputFormat to text", () => { const opts = parseHeadlessArgs([ "node", diff --git a/src/tests/headless-events.test.ts b/src/tests/headless-events.test.ts index da9bace4a..72e51d806 100644 --- a/src/tests/headless-events.test.ts +++ b/src/tests/headless-events.test.ts @@ -34,7 +34,7 @@ function parseHeadlessArgs(argv: string[]): HeadlessOptions { const options: HeadlessOptions = { timeout: 300_000, json: false, - command: "auto", + command: "help", commandArgs: [], }; @@ -75,7 +75,7 @@ function parseHeadlessArgs(argv: string[]): HeadlessOptions { } } else if (!positionalStarted) { positionalStarted = true; - options.command = arg === "autonomous" ? "auto" : arg; + options.command = arg; } else { options.commandArgs.push(arg); }