/** * Phase Runner — core state machine driving the full phase lifecycle. * * Orchestrates: discuss → research → plan → execute → verify → advance * with config-driven step skipping, human gate callbacks, event emission, * and structured error handling per step. */ import type { PhaseOpInfo, PhaseStepResult, PhaseRunnerResult, HumanGateCallbacks, PhaseRunnerOptions, PlanResult, SessionOptions, ParsedPlan, PhasePlanIndex, PlanInfo, } from './types.js'; import { PhaseStepType, PhaseType, GSDEventType } from './types.js'; import type { GSDConfig } from './config.js'; import type { GSDTools } from './gsd-tools.js'; import type { GSDEventStream } from './event-stream.js'; import type { PromptFactory } from './phase-prompt.js'; import type { ContextEngine } from './context-engine.js'; import type { GSDLogger } from './logger.js'; import { runPhaseStepSession, runPlanSession } from './session-runner.js'; import { parsePlanFile } from './plan-parser.js'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { checkResearchGate } from './research-gate.js'; // ─── Error type ────────────────────────────────────────────────────────────── export class PhaseRunnerError extends Error { constructor( message: string, public readonly phaseNumber: string, public readonly step: PhaseStepType, public readonly cause?: Error, ) { super(message); this.name = 'PhaseRunnerError'; } } // ─── Verification result enum ──────────────────────────────────────────────── export type VerificationOutcome = 'passed' | 'human_needed' | 'gaps_found'; // ─── PhaseRunner deps interface ────────────────────────────────────────────── export interface PhaseRunnerDeps { projectDir: string; tools: GSDTools; promptFactory: PromptFactory; contextEngine: ContextEngine; eventStream: GSDEventStream; config: GSDConfig; logger?: GSDLogger; } // ─── PhaseRunner ───────────────────────────────────────────────────────────── export class PhaseRunner { private readonly projectDir: string; private readonly tools: GSDTools; private readonly promptFactory: PromptFactory; private readonly contextEngine: ContextEngine; private readonly eventStream: GSDEventStream; private readonly config: GSDConfig; private readonly logger?: GSDLogger; constructor(deps: PhaseRunnerDeps) { this.projectDir = deps.projectDir; this.tools = deps.tools; this.promptFactory = deps.promptFactory; this.contextEngine = deps.contextEngine; this.eventStream = deps.eventStream; this.config = deps.config; this.logger = deps.logger; } /** * Run a full phase lifecycle: discuss → research → plan → plan-check → execute → verify → advance. * * Each step is gated by config flags and phase state. Human gate callbacks * are invoked at decision points; when not provided, auto-approve is used. */ async run(phaseNumber: string, options?: PhaseRunnerOptions): Promise { const startTime = Date.now(); const steps: PhaseStepResult[] = []; const callbacks = options?.callbacks ?? {}; // ── Init: query phase state ── let phaseOp: PhaseOpInfo; try { phaseOp = await this.tools.initPhaseOp(phaseNumber); } catch (err) { throw new PhaseRunnerError( `Failed to initialize phase ${phaseNumber}: ${err instanceof Error ? err.message : String(err)}`, phaseNumber, PhaseStepType.Discuss, err instanceof Error ? err : undefined, ); } // Validate phase exists if (!phaseOp.phase_found) { throw new PhaseRunnerError( `Phase ${phaseNumber} not found on disk`, phaseNumber, PhaseStepType.Discuss, ); } const phaseName = phaseOp.phase_name; // Emit phase_start this.eventStream.emitEvent({ type: GSDEventType.PhaseStart, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, phaseName, }); const sessionOpts: SessionOptions = { maxTurns: options?.maxTurnsPerStep ?? 50, maxBudgetUsd: options?.maxBudgetPerStep ?? 5.0, model: options?.model, cwd: this.projectDir, }; let halted = false; // ── Step 1: Discuss ── if (!halted) { const shouldSkip = phaseOp.has_context || this.config.workflow.skip_discuss; if (shouldSkip && !(this.config.workflow.auto_advance && !phaseOp.has_context && !this.config.workflow.skip_discuss)) { this.logger?.debug(`Skipping discuss: has_context=${phaseOp.has_context}, skip_discuss=${this.config.workflow.skip_discuss}`); } else if (!phaseOp.has_context && !this.config.workflow.skip_discuss && this.config.workflow.auto_advance) { // AI self-discuss: auto-mode with no context — run a self-discuss session const result = await this.retryOnce('self-discuss', () => this.runSelfDiscussStep(phaseNumber, sessionOpts)); steps.push(result); // Re-query phase state to check if context was created try { phaseOp = await this.tools.initPhaseOp(phaseNumber); } catch { // If re-query fails, proceed with original state } if (!phaseOp.has_context) { const decision = await this.invokeBlockerCallback(callbacks, phaseNumber, PhaseStepType.Discuss, 'No context after self-discuss step'); if (decision === 'stop') { halted = true; } } } else if (!shouldSkip) { const result = await this.retryOnce('discuss', () => this.runStep(PhaseStepType.Discuss, phaseNumber, sessionOpts)); steps.push(result); // Re-query phase state to check if context was created try { phaseOp = await this.tools.initPhaseOp(phaseNumber); } catch { // If re-query fails, proceed with original state } if (!phaseOp.has_context) { // No context after discuss — invoke blocker callback const decision = await this.invokeBlockerCallback(callbacks, phaseNumber, PhaseStepType.Discuss, 'No context after discuss step'); if (decision === 'stop') { halted = true; } } } } // ── Step 2: Research ── if (!halted) { if (!this.config.workflow.research) { this.logger?.debug('Skipping research: config.workflow.research=false'); } else { const result = await this.retryOnce('research', () => this.runStep(PhaseStepType.Research, phaseNumber, sessionOpts)); steps.push(result); } } // ── Step 2.5: Research gate (#1602) ── // Check RESEARCH.md for unresolved open questions before planning if (!halted && phaseOp.has_research) { const gateResult = await this.checkResearchGate(phaseOp); if (!gateResult.pass) { const questionList = gateResult.unresolvedQuestions.join(', '); const error = `RESEARCH.md has unresolved open questions: ${questionList}`; this.logger?.warn(error, { phase: phaseNumber }); const decision = await this.invokeBlockerCallback(callbacks, phaseNumber, PhaseStepType.Research, error); if (decision === 'stop') { halted = true; } } } // ── Step 3: Plan ── if (!halted) { const result = await this.retryOnce('plan', () => this.runStep(PhaseStepType.Plan, phaseNumber, sessionOpts)); steps.push(result); // Re-query to check for plans try { phaseOp = await this.tools.initPhaseOp(phaseNumber); } catch { // Proceed with prior state } if (!phaseOp.has_plans || phaseOp.plan_count === 0) { const decision = await this.invokeBlockerCallback(callbacks, phaseNumber, PhaseStepType.Plan, 'No plans created after plan step'); if (decision === 'stop') { halted = true; } } } // ── Step 3.5: Plan Check ── if (!halted && this.config.workflow.plan_check) { const planCheckResult = await this.retryOnce('plan-check', () => this.runPlanCheckStep(phaseNumber, sessionOpts)); steps.push(planCheckResult); // If plan-check failed, re-plan once then re-check once (D023) if (!planCheckResult.success) { this.logger?.info(`Plan check failed for phase ${phaseNumber}, re-planning once (D023)`); // Re-run plan step with feedback const replanResult = await this.runStep(PhaseStepType.Plan, phaseNumber, sessionOpts); steps.push(replanResult); // Re-check once const recheckResult = await this.runPlanCheckStep(phaseNumber, sessionOpts); steps.push(recheckResult); if (!recheckResult.success) { this.logger?.warn(`Plan check failed again after re-plan for phase ${phaseNumber}. Proceeding with warning (D023).`); } } } // ── Step 4: Execute ── if (!halted) { const executeResult = await this.retryOnce('execute', () => this.runExecuteStep(phaseNumber, sessionOpts)); steps.push(executeResult); } // ── Step 5: Verify ── if (!halted) { if (!this.config.workflow.verifier) { this.logger?.debug('Skipping verify: config.workflow.verifier=false'); } else { // Verify has its own internal retry logic (gap closure). retryOnce only // retries on unexpected session throws, not on verification outcomes like gaps_found. const verifyResult = await this.retryOnce('verify', () => this.runVerifyStep(phaseNumber, sessionOpts, callbacks, options)); steps.push(verifyResult); // Check if verify resulted in a halt if (!verifyResult.success && verifyResult.error === 'halted_by_callback') { halted = true; } } } // ── Step 6: Advance ── // Only advance if verify passed — never mark a phase complete when gaps were found. const verifyPassed = steps.every(s => s.step !== PhaseStepType.Verify || s.success); if (!halted && verifyPassed) { const advanceResult = await this.runAdvanceStep(phaseNumber, sessionOpts, callbacks); steps.push(advanceResult); } else if (!halted && !verifyPassed) { this.logger?.warn(`Skipping advance for phase ${phaseNumber}: verification found gaps`); } const totalDurationMs = Date.now() - startTime; const totalCostUsd = steps.reduce((sum, s) => { const stepCost = s.planResults?.reduce((c, pr) => c + pr.totalCostUsd, 0) ?? 0; return sum + stepCost; }, 0); const success = !halted && steps.every(s => s.success); // Emit phase_complete this.eventStream.emitEvent({ type: GSDEventType.PhaseComplete, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, phaseName, success, totalCostUsd, totalDurationMs, stepsCompleted: steps.length, }); return { phaseNumber, phaseName, steps, success, totalCostUsd, totalDurationMs, }; } // ─── Step runners ────────────────────────────────────────────────────── /** * Retry a step function once on failure. * On first error/failure, logs a warning and calls the function once more. * Returns the result from the last attempt. */ private async retryOnce(label: string, fn: () => Promise): Promise { const result = await fn(); if (result.success) return result; // Don't retry verify outcomes (gaps_found, human_needed) — they have their own retry logic. if (result.error?.startsWith('verification_')) return result; this.logger?.warn(`Step "${label}" failed, retrying once...`); return fn(); } /** * Run the plan-check step. * Loads the gsd-plan-checker agent definition, runs a Verify-scoped session, * and parses output for PASS/FAIL signals. */ private async runPlanCheckStep( phaseNumber: string, sessionOpts: SessionOptions, ): Promise { const stepStart = Date.now(); this.eventStream.emitEvent({ type: GSDEventType.PhaseStepStart, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step: PhaseStepType.PlanCheck, }); let planResult: PlanResult; try { // Load plan-checker agent definition (same pattern as PromptFactory.loadAgentDef) const agentDef = await this.promptFactory.loadAgentDef(PhaseType.Verify); // Build prompt using Verify phase type for context resolution const contextFiles = await this.contextEngine.resolveContextFiles(PhaseType.Verify); let prompt = await this.promptFactory.buildPrompt(PhaseType.Verify, null, contextFiles); // Supplement with plan-checker instructions prompt += '\n\n## Plan Checker Instructions\n\nYou are a plan checker. Review the plans for this phase and verify they are well-formed, complete, and achievable. If all plans pass, output "VERIFICATION PASSED". If any issues are found, output "ISSUES FOUND" followed by a description of each issue.'; planResult = await runPhaseStepSession( prompt, PhaseStepType.PlanCheck, this.config, sessionOpts, this.eventStream, { phase: PhaseType.Verify, planName: undefined }, ); } catch (err) { const durationMs = Date.now() - stepStart; const errorMsg = err instanceof Error ? err.message : String(err); this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step: PhaseStepType.PlanCheck, success: false, durationMs, error: errorMsg, }); return { step: PhaseStepType.PlanCheck, success: false, durationMs, error: errorMsg, }; } const durationMs = Date.now() - stepStart; // Parse plan-check outcome: success if the session succeeded (real output parsing would check for VERIFICATION PASSED / ISSUES FOUND) const success = planResult.success; this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: planResult.sessionId, phaseNumber, step: PhaseStepType.PlanCheck, success, durationMs, error: planResult.error?.messages.join('; ') || undefined, }); return { step: PhaseStepType.PlanCheck, success, durationMs, error: planResult.error?.messages.join('; ') || undefined, planResults: [planResult], }; } /** * Run the self-discuss step for auto-mode. * When auto_advance is true and no context exists, run an AI self-discuss * session that identifies gray areas and makes opinionated decisions. */ private async runSelfDiscussStep( phaseNumber: string, sessionOpts: SessionOptions, ): Promise { const stepStart = Date.now(); this.eventStream.emitEvent({ type: GSDEventType.PhaseStepStart, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step: PhaseStepType.Discuss, }); let planResult: PlanResult; try { const contextFiles = await this.contextEngine.resolveContextFiles(PhaseType.Discuss); let prompt = await this.promptFactory.buildPrompt(PhaseType.Discuss, null, contextFiles); // Prepend self-discuss override BEFORE the workflow prompt. // The workflow prompt contains interactive patterns (user questions, area selection) // that the agent will follow unless explicitly overridden up front. const maxPasses = this.config.workflow.max_discuss_passes ?? 3; const selfDiscussOverride = [ '## HEADLESS MODE — MANDATORY OVERRIDE', '', '**This session is running headless with no human present.**', '', 'You MUST NOT:', '- Use AskUserQuestion or any interactive tools', '- Invoke Skill() or SlashCommand()', '- Ask the user anything or wait for input', '- Use multi-select, checkbox, or prompt UIs', '', 'You MUST:', '- Make all decisions autonomously and opinionatedly', '- Identify 3-5 gray areas in the project scope', '- Reason through each one yourself and pick the best option', '- Write CONTEXT.md with your decisions in a single pass', `- Complete within ${maxPasses} pass(es) maximum — do not re-read your own output to find gaps`, '', 'Any instructions below about "asking the user", "discussing with the user", or "interactive" should be read as "decide yourself."', '', '---', '', ].join('\n'); prompt = selfDiscussOverride + prompt; planResult = await runPhaseStepSession( prompt, PhaseStepType.Discuss, this.config, sessionOpts, this.eventStream, { phase: PhaseType.Discuss, planName: undefined }, ); } catch (err) { const durationMs = Date.now() - stepStart; const errorMsg = err instanceof Error ? err.message : String(err); this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step: PhaseStepType.Discuss, success: false, durationMs, error: errorMsg, }); return { step: PhaseStepType.Discuss, success: false, durationMs, error: errorMsg, }; } const durationMs = Date.now() - stepStart; const success = planResult.success; this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: planResult.sessionId, phaseNumber, step: PhaseStepType.Discuss, success, durationMs, error: planResult.error?.messages.join('; ') || undefined, }); return { step: PhaseStepType.Discuss, success, durationMs, error: planResult.error?.messages.join('; ') || undefined, planResults: [planResult], }; } /** * Run a single phase step session (discuss, research, plan). * Emits step start/complete events and captures errors. */ private async runStep( step: PhaseStepType, phaseNumber: string, sessionOpts: SessionOptions, ): Promise { const stepStart = Date.now(); this.eventStream.emitEvent({ type: GSDEventType.PhaseStepStart, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step, }); let planResult: PlanResult; try { // Map step to PhaseType for prompt/context resolution const phaseType = this.stepToPhaseType(step); const contextFiles = await this.contextEngine.resolveContextFiles(phaseType); const prompt = await this.promptFactory.buildPrompt(phaseType, null, contextFiles); planResult = await runPhaseStepSession( prompt, step, this.config, sessionOpts, this.eventStream, { phase: phaseType, planName: undefined }, ); } catch (err) { const durationMs = Date.now() - stepStart; const errorMsg = err instanceof Error ? err.message : String(err); this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step, success: false, durationMs, error: errorMsg, }); return { step, success: false, durationMs, error: errorMsg, }; } const durationMs = Date.now() - stepStart; const success = planResult.success; this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: planResult.sessionId, phaseNumber, step, success, durationMs, error: planResult.error?.messages.join('; ') || undefined, }); return { step, success, durationMs, error: planResult.error?.messages.join('; ') || undefined, planResults: [planResult], }; } /** * Run the execute step — uses phase-plan-index for wave-grouped parallel execution. * Plans in the same wave run concurrently via Promise.allSettled(). * Waves execute sequentially (wave 1 completes before wave 2 starts). * Respects config.parallelization: false to fall back to sequential execution. * Filters out plans with has_summary: true (already completed). */ private async runExecuteStep( phaseNumber: string, sessionOpts: SessionOptions, ): Promise { const stepStart = Date.now(); this.eventStream.emitEvent({ type: GSDEventType.PhaseStepStart, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step: PhaseStepType.Execute, }); // Get the plan index from gsd-tools let planIndex: PhasePlanIndex; try { planIndex = await this.tools.phasePlanIndex(phaseNumber); } catch (err) { const durationMs = Date.now() - stepStart; const errorMsg = err instanceof Error ? err.message : String(err); this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step: PhaseStepType.Execute, success: false, durationMs, error: errorMsg, }); return { step: PhaseStepType.Execute, success: false, durationMs, error: errorMsg, }; } // Filter to incomplete plans only (has_summary === false) const incompletePlans = planIndex.plans.filter(p => !p.has_summary); if (incompletePlans.length === 0) { const durationMs = Date.now() - stepStart; this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step: PhaseStepType.Execute, success: true, durationMs, }); return { step: PhaseStepType.Execute, success: true, durationMs, planResults: [], }; } const planResults: PlanResult[] = []; // Sequential fallback when parallelization is disabled if (this.config.parallelization === false) { for (const plan of incompletePlans) { const result = await this.executeSinglePlan(phaseNumber, plan.id, sessionOpts); planResults.push(result); } } else { // Group incomplete plans by wave, sort waves numerically const waveMap = new Map(); for (const plan of incompletePlans) { const existing = waveMap.get(plan.wave) ?? []; existing.push(plan); waveMap.set(plan.wave, existing); } const sortedWaves = [...waveMap.keys()].sort((a, b) => a - b); for (const waveNum of sortedWaves) { const wavePlans = waveMap.get(waveNum)!; const wavePlanIds = wavePlans.map(p => p.id); // Emit wave_start this.eventStream.emitEvent({ type: GSDEventType.WaveStart, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, waveNumber: waveNum, planCount: wavePlans.length, planIds: wavePlanIds, }); const waveStart = Date.now(); // Execute all plans in this wave concurrently const settled = await Promise.allSettled( wavePlans.map(plan => this.executeSinglePlan(phaseNumber, plan.id, sessionOpts)), ); // Map settled results to PlanResult[] let successCount = 0; let failureCount = 0; for (const outcome of settled) { if (outcome.status === 'fulfilled') { planResults.push(outcome.value); if (outcome.value.success) successCount++; else failureCount++; } else { failureCount++; planResults.push({ success: false, sessionId: '', totalCostUsd: 0, durationMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 }, numTurns: 0, error: { subtype: 'error_during_execution', messages: [outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)], }, }); } } // Emit wave_complete this.eventStream.emitEvent({ type: GSDEventType.WaveComplete, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, waveNumber: waveNum, successCount, failureCount, durationMs: Date.now() - waveStart, }); } } const durationMs = Date.now() - stepStart; const allSucceeded = planResults.every(r => r.success); this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step: PhaseStepType.Execute, success: allSucceeded, durationMs, }); return { step: PhaseStepType.Execute, success: allSucceeded, durationMs, planResults, }; } /** * Execute a single plan by ID within the execute step. * Loads the plan file, parses it, and passes the parsed plan to the prompt * builder so the executor gets the full plan content (tasks, objectives, etc.). */ private async executeSinglePlan( phaseNumber: string, planId: string, sessionOpts: SessionOptions, ): Promise { try { // Resolve the plan file path from phase directory + planId const phaseOp = await this.tools.initPhaseOp(phaseNumber); const planFilename = planId === 'PLAN' ? 'PLAN.md' : `${planId}-PLAN.md`; const planPath = join(this.projectDir, phaseOp.phase_dir, planFilename); // Parse the plan file so the executor prompt includes the actual tasks const parsedPlan = await parsePlanFile(planPath); const phaseType = PhaseType.Execute; const contextFiles = await this.contextEngine.resolveContextFiles(phaseType); const prompt = await this.promptFactory.buildPrompt(phaseType, parsedPlan, contextFiles, phaseOp.phase_dir); return await runPhaseStepSession( prompt, PhaseStepType.Execute, this.config, sessionOpts, this.eventStream, { phase: phaseType, planName: planId }, ); } catch (err) { return { success: false, sessionId: '', totalCostUsd: 0, durationMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 }, numTurns: 0, error: { subtype: 'error_during_execution', messages: [err instanceof Error ? err.message : String(err)], }, }; } } /** * Run the verify step with full gap closure cycle. * Verification outcome routing: * - passed → proceed to advance * - human_needed → invoke onVerificationReview callback * - gaps_found → plan (create gap plans) → execute (run gap plans) → re-verify * Gap closure retries are capped at configurable maxGapRetries (default 1). */ private async runVerifyStep( phaseNumber: string, sessionOpts: SessionOptions, callbacks: HumanGateCallbacks, options?: PhaseRunnerOptions, ): Promise { const stepStart = Date.now(); this.eventStream.emitEvent({ type: GSDEventType.PhaseStepStart, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step: PhaseStepType.Verify, }); const maxGapRetries = options?.maxGapRetries ?? 1; let gapRetryCount = 0; let lastResult: PlanResult | undefined; let outcome: VerificationOutcome = 'passed'; const allPlanResults: PlanResult[] = []; while (true) { try { const phaseType = PhaseType.Verify; const contextFiles = await this.contextEngine.resolveContextFiles(phaseType); const prompt = await this.promptFactory.buildPrompt(phaseType, null, contextFiles); lastResult = await runPhaseStepSession( prompt, PhaseStepType.Verify, this.config, sessionOpts, this.eventStream, { phase: phaseType }, ); allPlanResults.push(lastResult); } catch (err) { const durationMs = Date.now() - stepStart; const errorMsg = err instanceof Error ? err.message : String(err); this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step: PhaseStepType.Verify, success: false, durationMs, error: errorMsg, }); return { step: PhaseStepType.Verify, success: false, durationMs, error: errorMsg, planResults: allPlanResults.length > 0 ? allPlanResults : undefined, }; } // Parse verification outcome from VERIFICATION.md (not just session exit code) outcome = await this.parseVerificationOutcome(lastResult, phaseNumber); if (outcome === 'passed') { break; } if (outcome === 'human_needed') { // Invoke verification review callback const decision = await this.invokeVerificationCallback(callbacks, phaseNumber, { step: PhaseStepType.Verify, success: lastResult.success, durationMs: Date.now() - stepStart, planResults: allPlanResults, }); if (decision === 'accept') { outcome = 'passed'; break; // Treat as passed } else if (decision === 'retry' && gapRetryCount < maxGapRetries) { gapRetryCount++; continue; } else { // reject or exceeded retries const durationMs = Date.now() - stepStart; this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: lastResult.sessionId, phaseNumber, step: PhaseStepType.Verify, success: false, durationMs, error: 'halted_by_callback', }); return { step: PhaseStepType.Verify, success: false, durationMs, error: 'halted_by_callback', planResults: allPlanResults, }; } } if (outcome === 'gaps_found') { if (gapRetryCount < maxGapRetries) { gapRetryCount++; this.logger?.info(`Gap closure attempt ${gapRetryCount}/${maxGapRetries} for phase ${phaseNumber}`); // ── Gap closure cycle: plan → execute → re-verify ── // 1. Run a plan step to create gap plans try { const planResult = await this.runStep(PhaseStepType.Plan, phaseNumber, sessionOpts); if (planResult.planResults) { allPlanResults.push(...planResult.planResults); } } catch (err) { this.logger?.warn(`Gap closure plan step failed: ${err instanceof Error ? err.message : String(err)}`); // Proceed to re-verify anyway } // 2. Re-query phase state to discover newly created gap plans try { await this.tools.initPhaseOp(phaseNumber); } catch (err) { this.logger?.warn(`Gap closure re-query failed, proceeding with stale state: ${err instanceof Error ? err.message : String(err)}`); } // 3. Execute gap plans via the wave-capable runExecuteStep try { const executeResult = await this.runExecuteStep(phaseNumber, sessionOpts); if (executeResult.planResults) { allPlanResults.push(...executeResult.planResults); } } catch (err) { this.logger?.warn(`Gap closure execute step failed: ${err instanceof Error ? err.message : String(err)}`); // Proceed to re-verify anyway } // 4. Continue the loop to re-verify continue; } // Exceeded gap closure retries — proceed break; } break; // Safety: unknown outcome → proceed } const durationMs = Date.now() - stepStart; const verifySuccess = outcome === 'passed'; this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: lastResult?.sessionId ?? '', phaseNumber, step: PhaseStepType.Verify, success: verifySuccess, durationMs, ...(!verifySuccess && { error: `verification_${outcome}` }), }); return { step: PhaseStepType.Verify, success: verifySuccess, durationMs, planResults: allPlanResults, ...(!verifySuccess && { error: `verification_${outcome}` }), }; } /** * Run the advance step — mark phase complete. * Gated by config.workflow.auto_advance or callback approval. */ private async runAdvanceStep( phaseNumber: string, _sessionOpts: SessionOptions, callbacks: HumanGateCallbacks, ): Promise { const stepStart = Date.now(); this.eventStream.emitEvent({ type: GSDEventType.PhaseStepStart, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step: PhaseStepType.Advance, }); // Check if auto_advance or callback approves let shouldAdvance = this.config.workflow.auto_advance; if (!shouldAdvance && callbacks.onBlockerDecision) { try { const decision = await callbacks.onBlockerDecision({ phaseNumber, step: PhaseStepType.Advance, error: undefined, }); shouldAdvance = decision !== 'stop'; } catch (err) { this.logger?.warn(`Advance callback threw, auto-approving: ${err instanceof Error ? err.message : String(err)}`); shouldAdvance = true; // Auto-approve on callback error } } else if (!shouldAdvance) { // No callback, auto-approve shouldAdvance = true; } if (!shouldAdvance) { const durationMs = Date.now() - stepStart; this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step: PhaseStepType.Advance, success: false, durationMs, error: 'advance_rejected', }); return { step: PhaseStepType.Advance, success: false, durationMs, error: 'advance_rejected', }; } try { await this.tools.phaseComplete(phaseNumber); } catch (err) { const durationMs = Date.now() - stepStart; const errorMsg = err instanceof Error ? err.message : String(err); this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step: PhaseStepType.Advance, success: false, durationMs, error: errorMsg, }); return { step: PhaseStepType.Advance, success: false, durationMs, error: errorMsg, }; } const durationMs = Date.now() - stepStart; this.eventStream.emitEvent({ type: GSDEventType.PhaseStepComplete, timestamp: new Date().toISOString(), sessionId: '', phaseNumber, step: PhaseStepType.Advance, success: true, durationMs, }); return { step: PhaseStepType.Advance, success: true, durationMs, }; } // ─── Helpers ─────────────────────────────────────────────────────────── /** * Map PhaseStepType to PhaseType for prompt/context resolution. */ private stepToPhaseType(step: PhaseStepType): PhaseType { const mapping: Record = { [PhaseStepType.Discuss]: PhaseType.Discuss, [PhaseStepType.Research]: PhaseType.Research, [PhaseStepType.Plan]: PhaseType.Plan, [PhaseStepType.PlanCheck]: PhaseType.Verify, [PhaseStepType.Execute]: PhaseType.Execute, [PhaseStepType.Verify]: PhaseType.Verify, }; return mapping[step] ?? PhaseType.Execute; } /** * Parse the verification outcome by checking VERIFICATION.md on disk. * The verify session may succeed (no runtime errors) while writing * status: gaps_found to VERIFICATION.md — we need to check the file, * not just the session exit code. * * Falls back to session result if VERIFICATION.md can't be parsed. */ private async parseVerificationOutcome(result: PlanResult, phaseNumber: string): Promise { // If the session itself crashed, that's a clear failure if (!result.success) { if (result.error?.subtype === 'human_review_needed') return 'human_needed'; return 'gaps_found'; } // Session succeeded — check what the verifier actually wrote to VERIFICATION.md try { const verStatus = await this.tools.exec('check.verification-status', [phaseNumber]); const data = typeof verStatus === 'string' ? JSON.parse(verStatus) : verStatus; const status = (data?.status ?? '').toLowerCase(); if (status === 'pass' || status === 'passed') return 'passed'; if (status === 'fail' || status === 'gaps_found') return 'gaps_found'; if (status === 'missing') { // VERIFICATION.md doesn't exist yet — treat session success as passed return 'passed'; } // Unknown status — log and treat as gaps_found to be safe this.logger?.warn(`Unknown verification status '${status}' for phase ${phaseNumber}, treating as gaps_found`); return 'gaps_found'; } catch (err) { // Can't parse VERIFICATION.md — fall back to session result this.logger?.warn(`Could not check verification status for phase ${phaseNumber}: ${err instanceof Error ? err.message : String(err)}`); return 'passed'; } } /** * Check RESEARCH.md for unresolved open questions (#1602). * Returns the gate result — pass means safe to proceed to planning. */ private async checkResearchGate(phaseOp: PhaseOpInfo): Promise<{ pass: boolean; unresolvedQuestions: string[] }> { try { const researchPath = phaseOp.research_path || join(phaseOp.phase_dir, `${phaseOp.padded_phase}-RESEARCH.md`); const content = await readFile(researchPath, 'utf-8'); return checkResearchGate(content); } catch { // File doesn't exist or can't be read — pass (nothing to gate on) return { pass: true, unresolvedQuestions: [] }; } } /** * Invoke the onBlockerDecision callback, falling back to auto-approve. */ private async invokeBlockerCallback( callbacks: HumanGateCallbacks, phaseNumber: string, step: PhaseStepType, error?: string, ): Promise<'retry' | 'skip' | 'stop'> { if (!callbacks.onBlockerDecision) { return 'skip'; // Auto-approve: skip the blocker } try { const decision = await callbacks.onBlockerDecision({ phaseNumber, step, error }); // Validate return value if (decision === 'retry' || decision === 'skip' || decision === 'stop') { return decision; } this.logger?.warn(`Unexpected blocker callback return value: ${String(decision)}, falling back to skip`); return 'skip'; } catch (err) { this.logger?.warn(`Blocker callback threw, auto-approving: ${err instanceof Error ? err.message : String(err)}`); return 'skip'; // Auto-approve on error } } /** * Invoke the onVerificationReview callback, falling back to auto-accept. */ private async invokeVerificationCallback( callbacks: HumanGateCallbacks, phaseNumber: string, stepResult: PhaseStepResult, ): Promise<'accept' | 'reject' | 'retry'> { if (!callbacks.onVerificationReview) { return 'accept'; // Auto-approve } try { const decision = await callbacks.onVerificationReview({ phaseNumber, stepResult }); if (decision === 'accept' || decision === 'reject' || decision === 'retry') { return decision; } this.logger?.warn(`Unexpected verification callback return value: ${String(decision)}, falling back to accept`); return 'accept'; } catch (err) { this.logger?.warn(`Verification callback threw, auto-accepting: ${err instanceof Error ? err.message : String(err)}`); return 'accept'; // Auto-approve on error } } }