Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion packages/specflow/src/commands/complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,25 @@ function runTests(): { pass: boolean; output: string } {
/**
* Validate verify.md has required sections
*/
/**
* Check if a section's content indicates it is not applicable.
* Returns true if the content between this heading and the next heading
* contains "N/A", "Not applicable", "Not required", or "CLI only" (case-insensitive).
*/
function isSectionNotApplicable(content: string, sectionHeading: string): boolean {
const headingIndex = content.indexOf(sectionHeading);
if (headingIndex === -1) return false;

const afterHeading = content.slice(headingIndex + sectionHeading.length);
const nextHeadingMatch = afterHeading.match(/\n## /);
const sectionContent = nextHeadingMatch
? afterHeading.slice(0, nextHeadingMatch.index)
: afterHeading;

const naPattern = /\b(n\/a|not applicable|not required|cli only)\b/i;
return naPattern.test(sectionContent);
}

function validateVerifyFile(verifyPath: string): string[] {
const errors: string[] = [];

Expand All @@ -120,8 +139,21 @@ function validateVerifyFile(verifyPath: string): string[] {
}

// Check that verification was actually completed (not just template)
// But skip placeholder checks for sections marked as N/A
if (content.includes("[paste actual output]") || content.includes("[paste actual response]")) {
errors.push("verify.md contains unfilled placeholders - actual verification not performed");
// Only flag unfilled placeholders if the section containing them is not marked N/A
const placeholderPattern = /\[paste actual (?:output|response)\]/g;
let match;
while ((match = placeholderPattern.exec(content)) !== null) {
const beforeMatch = content.slice(0, match.index);
const lastHeadingMatch = beforeMatch.match(/## [^\n]+/g);
const lastHeading = lastHeadingMatch ? lastHeadingMatch[lastHeadingMatch.length - 1] : null;

if (!lastHeading || !isSectionNotApplicable(content, lastHeading)) {
errors.push("verify.md contains unfilled placeholders - actual verification not performed");
break;
}
}
}

return errors;
Expand Down
72 changes: 72 additions & 0 deletions packages/specflow/src/commands/pipeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Pipeline Command
* Runs the full SpecFlow pipeline for a feature in headless mode:
* specify -> plan -> tasks -> implement -> complete
*/

import { specifyCommand } from "./specify";
import { planCommand } from "./plan";
import { tasksCommand } from "./tasks";
import { implementCommand } from "./implement";
import { completeCommand } from "./complete";

export interface PipelineCommandOptions {
/** Stop after this phase (for partial runs) */
stopAfter?: string;
}

/**
* Execute the full pipeline for a feature
*/
export async function pipelineCommand(
featureId: string,
options: PipelineCommandOptions = {}
): Promise<void> {
// Force headless mode for entire pipeline
process.env.SPECFLOW_HEADLESS = "true";

const phases: Array<{ name: string; run: () => Promise<void> }> = [
{
name: "SPECIFY",
run: () => specifyCommand(featureId, { batch: true }),
},
{
name: "PLAN",
run: () => planCommand(featureId),
},
{
name: "TASKS",
run: () => tasksCommand(featureId),
},
{
name: "IMPLEMENT",
run: () => implementCommand({ featureId }),
},
{
name: "COMPLETE",
run: () => completeCommand(featureId, { force: false }),
},
];

console.log(`\n=== SpecFlow Pipeline: ${featureId} ===\n`);

for (const phase of phases) {
console.log(`\n--- Phase: ${phase.name} ---\n`);

try {
await phase.run();
console.log(`\n--- ${phase.name}: OK ---\n`);
} catch (error) {
console.error(`\n--- ${phase.name}: FAILED ---`);
console.error(`Error: ${error}`);
process.exit(1);
}

if (options.stopAfter && phase.name.toLowerCase() === options.stopAfter.toLowerCase()) {
console.log(`\nStopping after ${phase.name} (--stop-after)`);
break;
}
}

console.log(`\n=== Pipeline complete: ${featureId} ===\n`);
}
20 changes: 20 additions & 0 deletions packages/specflow/src/commands/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { fileURLToPath } from "url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { isHeadlessMode, runClaudeHeadless } from "../lib/headless";
import {
initDatabase,
closeDatabase,
Expand Down Expand Up @@ -250,6 +251,25 @@ async function runClaude(
prompt: string,
cwd: string
): Promise<{ success: boolean; output: string; error?: string }> {
// Headless mode: use claude -p --output-format json
if (isHeadlessMode()) {
console.log("[headless] Running plan phase via claude -p...");
const systemPrompt =
"You are a technical planning agent. Follow the instructions exactly. " +
"Write the plan file to disk at the path specified. " +
"Output [PHASE COMPLETE: PLAN] when done.";
const result = await runClaudeHeadless(prompt, {
systemPrompt,
cwd,
timeout: 180_000,
});
if (result.output) {
process.stdout.write(result.output);
}
return result;
}

// Interactive mode: unchanged
return new Promise((resolve) => {
const proc = spawn("claude", ["--print", "--dangerously-skip-permissions", prompt], {
cwd,
Expand Down
30 changes: 30 additions & 0 deletions packages/specflow/src/commands/specify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { fileURLToPath } from "url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { isHeadlessMode, runClaudeHeadless } from "../lib/headless";
import {
initDatabase,
closeDatabase,
Expand Down Expand Up @@ -78,6 +79,16 @@ export async function specifyCommand(
return;
}

// In headless mode, auto-enable batch if decomposition data is available
if (isHeadlessMode() && !options.batch) {
const decomposedFeature = feature as unknown as DecomposedFeature;
const batchCheck = validateBatchReady(decomposedFeature);
if (batchCheck.ready) {
options.batch = true;
console.log("[headless] Auto-enabling batch mode (rich decomposition available)");
}
}

// Batch mode validation
if (options.batch) {
// Cast feature to include decomposition fields for validation
Expand Down Expand Up @@ -233,6 +244,25 @@ async function runClaude(
prompt: string,
cwd: string
): Promise<{ success: boolean; output: string; error?: string }> {
// Headless mode: use claude -p --output-format json
if (isHeadlessMode()) {
console.log("[headless] Running specify phase via claude -p...");
const systemPrompt =
"You are a specification agent. Follow the instructions exactly. " +
"Write the spec file to disk at the path specified. " +
"Output [PHASE COMPLETE: SPECIFY] when done.";
const result = await runClaudeHeadless(prompt, {
systemPrompt,
cwd,
timeout: 180_000,
});
if (result.output) {
process.stdout.write(result.output);
}
return result;
}

// Interactive mode: unchanged
return new Promise((resolve) => {
const proc = spawn("claude", ["--print", "--dangerously-skip-permissions", prompt], {
cwd,
Expand Down
24 changes: 23 additions & 1 deletion packages/specflow/src/commands/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { join } from "path";
import { existsSync, readFileSync } from "fs";
import { spawn } from "child_process";
import { createInterface } from "readline";
import { isHeadlessMode, runClaudeHeadless } from "../lib/headless";
import {
initDatabase,
closeDatabase,
Expand Down Expand Up @@ -79,8 +80,10 @@ export async function tasksCommand(
process.exit(1);
}

// In headless mode, force autoChain to "always" (skip readline prompt)
const autoChainOverride = isHeadlessMode() ? "always" : options.autoChain;
// Get auto-chain configuration for display
const autoChainConfig = getAutoChainConfig(options.autoChain, projectPath);
const autoChainConfig = getAutoChainConfig(autoChainOverride, projectPath);

console.log(`\n📝 Starting TASKS phase for: ${feature.id} - ${feature.name}\n`);
console.log(`Auto-chain: ${getAutoChainDescription(autoChainConfig)}`);
Expand Down Expand Up @@ -285,6 +288,25 @@ async function runClaude(
prompt: string,
cwd: string
): Promise<{ success: boolean; output: string; error?: string }> {
// Headless mode: use claude -p --output-format json
if (isHeadlessMode()) {
console.log("[headless] Running tasks phase via claude -p...");
const systemPrompt =
"You are a task breakdown agent. Follow the instructions exactly. " +
"Write the tasks file to disk at the path specified. " +
"Output [PHASE COMPLETE: TASKS] when done.";
const result = await runClaudeHeadless(prompt, {
systemPrompt,
cwd,
timeout: 180_000,
});
if (result.output) {
process.stdout.write(result.output);
}
return result;
}

// Interactive mode: unchanged
return new Promise((resolve) => {
const proc = spawn("claude", ["--print", "--dangerously-skip-permissions", prompt], {
cwd,
Expand Down
8 changes: 8 additions & 0 deletions packages/specflow/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { reviseCommand } from "./commands/revise";
import { specifyAllCommand } from "./commands/specify-all";
import { enrichCommand } from "./commands/enrich";
import { contribPrepCommand } from "./commands/contrib-prep";
import { pipelineCommand } from "./commands/pipeline";

// =============================================================================
// Main Program
Expand Down Expand Up @@ -233,6 +234,13 @@ program
})
);

program
.command("pipeline")
.description("Run full SpecFlow pipeline for a feature (specify -> plan -> tasks -> implement -> complete)")
.argument("<feature-id>", "Feature ID to process (e.g., F-1)")
.option("--stop-after <phase>", "Stop after this phase (specify, plan, tasks, implement, complete)")
.action((featureId, options) => pipelineCommand(featureId, { stopAfter: options.stopAfter }));

// Register phase command (uses Commander directly for flexibility)
phaseCommand(program);

Expand Down
Loading