From 48a7cf68bd82ce2104468791d17fab037ac2945b Mon Sep 17 00:00:00 2001 From: Justin Parker Date: Sat, 8 Aug 2026 12:38:58 -0700 Subject: [PATCH] Pin LF line endings and renormalize the working tree scripts/validate-char-count.js measures characters on disk, so a CRLF checkout added ~1 char per line and pushed the 11k-budget prompt files over the limit depending on how the repo was cloned. Pins eol=lf, marks binaries, and renormalizes the files that had CRLF. AI Assisted --- .gitattributes | 26 + .gitignore | 32 +- plan2code-loop/src/agents/claude-code.ts | 164 ++-- plan2code-loop/src/agents/copilot-cli.ts | 140 +-- plan2code-loop/src/agents/registry.ts | 68 +- plan2code-loop/src/bin/plan2code-loop.ts | 68 +- plan2code-loop/src/controller.ts | 1044 +++++++++++----------- plan2code-loop/src/index.ts | 192 ++-- plan2code-loop/src/prompt/builder.ts | 78 +- plan2code-loop/src/prompt/index.ts | 12 +- plan2code-loop/src/prompt/templates.ts | 400 ++++----- plan2code-loop/src/spec/index.ts | 8 +- plan2code-loop/src/spec/utils.ts | 140 +-- plan2code-loop/src/state/hash.ts | 18 +- plan2code-loop/src/state/index.ts | 20 +- plan2code-loop/src/state/manager.ts | 462 +++++----- plan2code-loop/src/utils/completion.ts | 338 +++---- plan2code-loop/src/utils/git.ts | 272 +++--- plan2code-loop/src/utils/index.ts | 38 +- plan2code-loop/src/utils/process.ts | 162 ++-- plan2code-loop/tsconfig.json | 40 +- plan2code-loop/tsup.config.ts | 30 +- 22 files changed, 1889 insertions(+), 1863 deletions(-) diff --git a/.gitattributes b/.gitattributes index 2bf2dc4..7e30ea0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,29 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Line endings +# +# Git stores LF, and every text file is checked out as LF on all platforms. +# This is not cosmetic: scripts/validate-char-count.js measures the characters +# actually on disk, so a CRLF checkout adds ~1 character per line. The workflow +# prompts in src/plan2code-*.md run close to their 11,000 character budget +# (several sit above 10,800), and a CRLF working tree pushes them over — turning +# `npm test` into a check that passes or fails depending on how the repo was +# cloned. Pinning eol=lf makes the count reproducible everywhere. +# ───────────────────────────────────────────────────────────────────────────── +* text=auto eol=lf + +# ───────────────────────────────────────────────────────────────────────────── +# Binary — never line-ending-converted, never diffed as text +# ───────────────────────────────────────────────────────────────────────────── +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.mp4 binary +*.webm binary +*.woff binary +*.woff2 binary + # The encrypted sync-repo blob is base64 text but must never have its bytes # altered by line-ending normalization. Treat it as binary so autocrlf/eol # settings can never corrupt the ciphertext. diff --git a/.gitignore b/.gitignore index 1396d35..c5d33d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,17 @@ -specs/ -specs--completed/ -dist/ -plan2code-loop/dist -plan2code-loop/node_modules -plan2code-loop/package-lock.json -plan2code-metrics/dist -plan2code-metrics/node_modules -plan2code-metrics/package-lock.json -.plan2code-loop -.plan2code-metrics -nul -.cognition/ -handoffs/ -node_modules/ -package-lock.json +specs/ +specs--completed/ +dist/ +plan2code-loop/dist +plan2code-loop/node_modules +plan2code-loop/package-lock.json +plan2code-metrics/dist +plan2code-metrics/node_modules +plan2code-metrics/package-lock.json +.plan2code-loop +.plan2code-metrics +nul +.cognition/ +handoffs/ +node_modules/ +package-lock.json SYNC.md \ No newline at end of file diff --git a/plan2code-loop/src/agents/claude-code.ts b/plan2code-loop/src/agents/claude-code.ts index 9f77cb0..907ff61 100644 --- a/plan2code-loop/src/agents/claude-code.ts +++ b/plan2code-loop/src/agents/claude-code.ts @@ -1,82 +1,82 @@ -import type { Agent, AgentConfig, AgentExecutionOptions, AgentExecutionResult } from './types.js'; -import { executeCommand } from '../utils/process.js'; -import { writeFileSync, unlinkSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; - -const claudeCodeConfig: AgentConfig = { - name: 'claude-code', - displayName: 'Claude Code', - command: 'claude', - models: [ - { value: 'default', label: 'Default (use Claude config)' }, - ], - defaultModel: 'default', - flags: { - prompt: '--print', - model: '--model', - skipPermissions: '--dangerously-skip-permissions', - }, -}; - -class ClaudeCodeAgent implements Agent { - readonly config = claudeCodeConfig; - - async execute(options: AgentExecutionOptions): Promise { - // Write prompt to temp file - more reliable than stdin on Windows - const tempFile = join(tmpdir(), `plan2code-prompt-${Date.now()}.txt`); - writeFileSync(tempFile, options.prompt, 'utf-8'); - - try { - // Build args: flags first, then read prompt from temp file via shell - const args: string[] = [ - this.config.flags.prompt, // --print for non-interactive mode - this.config.flags.skipPermissions, - ]; - - // Only add --model if not using default - if (options.model && options.model !== 'default') { - args.push(this.config.flags.model, options.model); - } - - // Use stdin from the temp file - const result = await executeCommand({ - command: this.config.command, - args, - cwd: options.cwd, - timeout: options.timeout, - signal: options.signal, - stdinFile: tempFile, - }); - - return { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - timedOut: result.timedOut, - cancelled: result.cancelled, - duration: result.duration, - }; - } finally { - // Clean up temp file - try { - unlinkSync(tempFile); - } catch { - // Ignore cleanup errors - } - } - } - - async isAvailable(): Promise { - // Run claude --version to verify it's actually installed and working - const result = await executeCommand({ - command: this.config.command, - args: ['--version'], - cwd: process.cwd(), - timeout: 5000, - }); - return result.exitCode === 0; - } -} - -export const claudeCodeAgent = new ClaudeCodeAgent(); +import type { Agent, AgentConfig, AgentExecutionOptions, AgentExecutionResult } from './types.js'; +import { executeCommand } from '../utils/process.js'; +import { writeFileSync, unlinkSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const claudeCodeConfig: AgentConfig = { + name: 'claude-code', + displayName: 'Claude Code', + command: 'claude', + models: [ + { value: 'default', label: 'Default (use Claude config)' }, + ], + defaultModel: 'default', + flags: { + prompt: '--print', + model: '--model', + skipPermissions: '--dangerously-skip-permissions', + }, +}; + +class ClaudeCodeAgent implements Agent { + readonly config = claudeCodeConfig; + + async execute(options: AgentExecutionOptions): Promise { + // Write prompt to temp file - more reliable than stdin on Windows + const tempFile = join(tmpdir(), `plan2code-prompt-${Date.now()}.txt`); + writeFileSync(tempFile, options.prompt, 'utf-8'); + + try { + // Build args: flags first, then read prompt from temp file via shell + const args: string[] = [ + this.config.flags.prompt, // --print for non-interactive mode + this.config.flags.skipPermissions, + ]; + + // Only add --model if not using default + if (options.model && options.model !== 'default') { + args.push(this.config.flags.model, options.model); + } + + // Use stdin from the temp file + const result = await executeCommand({ + command: this.config.command, + args, + cwd: options.cwd, + timeout: options.timeout, + signal: options.signal, + stdinFile: tempFile, + }); + + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + timedOut: result.timedOut, + cancelled: result.cancelled, + duration: result.duration, + }; + } finally { + // Clean up temp file + try { + unlinkSync(tempFile); + } catch { + // Ignore cleanup errors + } + } + } + + async isAvailable(): Promise { + // Run claude --version to verify it's actually installed and working + const result = await executeCommand({ + command: this.config.command, + args: ['--version'], + cwd: process.cwd(), + timeout: 5000, + }); + return result.exitCode === 0; + } +} + +export const claudeCodeAgent = new ClaudeCodeAgent(); diff --git a/plan2code-loop/src/agents/copilot-cli.ts b/plan2code-loop/src/agents/copilot-cli.ts index ffbb361..893023a 100644 --- a/plan2code-loop/src/agents/copilot-cli.ts +++ b/plan2code-loop/src/agents/copilot-cli.ts @@ -1,70 +1,70 @@ -import type { Agent, AgentConfig, AgentExecutionOptions, AgentExecutionResult } from './types.js'; -import { executeCommand } from '../utils/process.js'; - -const copilotCliConfig: AgentConfig = { - name: 'copilot-cli', - displayName: 'GitHub Copilot CLI', - command: 'copilot', - models: [ - { value: 'claude-sonnet-4', label: 'Claude Sonnet 4 (Default)' }, - { value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' }, - { value: 'claude-opus-4.5', label: 'Claude Opus 4.5' }, - { value: 'gpt-5', label: 'GPT-5' }, - { value: 'gpt-5-mini', label: 'GPT-5 Mini' }, - { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro' }, - ], - defaultModel: 'claude-sonnet-4', - flags: { - prompt: '-p', - model: '--model', - skipPermissions: '--allow-all-tools', - silent: '-s', - }, -}; - -class CopilotCliAgent implements Agent { - readonly config = copilotCliConfig; - - async execute(options: AgentExecutionOptions): Promise { - // Use stdin for prompt to handle multi-line text properly - const args: string[] = []; - - // Only add --model if not using default - if (options.model && options.model !== 'default') { - args.push(this.config.flags.model, options.model); - } - - args.push(this.config.flags.skipPermissions, this.config.flags.silent!); - - const result = await executeCommand({ - command: this.config.command, - args, - cwd: options.cwd, - timeout: options.timeout, - signal: options.signal, - stdin: options.prompt, - }); - - return { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - timedOut: result.timedOut, - cancelled: result.cancelled, - duration: result.duration, - }; - } - - async isAvailable(): Promise { - // Run copilot --version to verify it's installed - const result = await executeCommand({ - command: this.config.command, - args: ['--version'], - cwd: process.cwd(), - timeout: 5000, - }); - return result.exitCode === 0; - } -} - -export const copilotCliAgent = new CopilotCliAgent(); +import type { Agent, AgentConfig, AgentExecutionOptions, AgentExecutionResult } from './types.js'; +import { executeCommand } from '../utils/process.js'; + +const copilotCliConfig: AgentConfig = { + name: 'copilot-cli', + displayName: 'GitHub Copilot CLI', + command: 'copilot', + models: [ + { value: 'claude-sonnet-4', label: 'Claude Sonnet 4 (Default)' }, + { value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' }, + { value: 'claude-opus-4.5', label: 'Claude Opus 4.5' }, + { value: 'gpt-5', label: 'GPT-5' }, + { value: 'gpt-5-mini', label: 'GPT-5 Mini' }, + { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro' }, + ], + defaultModel: 'claude-sonnet-4', + flags: { + prompt: '-p', + model: '--model', + skipPermissions: '--allow-all-tools', + silent: '-s', + }, +}; + +class CopilotCliAgent implements Agent { + readonly config = copilotCliConfig; + + async execute(options: AgentExecutionOptions): Promise { + // Use stdin for prompt to handle multi-line text properly + const args: string[] = []; + + // Only add --model if not using default + if (options.model && options.model !== 'default') { + args.push(this.config.flags.model, options.model); + } + + args.push(this.config.flags.skipPermissions, this.config.flags.silent!); + + const result = await executeCommand({ + command: this.config.command, + args, + cwd: options.cwd, + timeout: options.timeout, + signal: options.signal, + stdin: options.prompt, + }); + + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + timedOut: result.timedOut, + cancelled: result.cancelled, + duration: result.duration, + }; + } + + async isAvailable(): Promise { + // Run copilot --version to verify it's installed + const result = await executeCommand({ + command: this.config.command, + args: ['--version'], + cwd: process.cwd(), + timeout: 5000, + }); + return result.exitCode === 0; + } +} + +export const copilotCliAgent = new CopilotCliAgent(); diff --git a/plan2code-loop/src/agents/registry.ts b/plan2code-loop/src/agents/registry.ts index c90a3d6..bbd91f8 100644 --- a/plan2code-loop/src/agents/registry.ts +++ b/plan2code-loop/src/agents/registry.ts @@ -1,34 +1,34 @@ -import type { Agent } from './types.js'; - -class AgentRegistry { - private agents: Map = new Map(); - - register(agent: Agent): void { - this.agents.set(agent.config.name, agent); - } - - get(name: string): Agent | undefined { - return this.agents.get(name); - } - - getAll(): Agent[] { - return Array.from(this.agents.values()); - } - - getAvailable(): Promise { - return Promise.all( - this.getAll().map(async (agent) => ({ - agent, - available: await agent.isAvailable(), - })) - ).then((results) => - results.filter((r) => r.available).map((r) => r.agent) - ); - } - - getNames(): string[] { - return Array.from(this.agents.keys()); - } -} - -export const agentRegistry = new AgentRegistry(); +import type { Agent } from './types.js'; + +class AgentRegistry { + private agents: Map = new Map(); + + register(agent: Agent): void { + this.agents.set(agent.config.name, agent); + } + + get(name: string): Agent | undefined { + return this.agents.get(name); + } + + getAll(): Agent[] { + return Array.from(this.agents.values()); + } + + getAvailable(): Promise { + return Promise.all( + this.getAll().map(async (agent) => ({ + agent, + available: await agent.isAvailable(), + })) + ).then((results) => + results.filter((r) => r.available).map((r) => r.agent) + ); + } + + getNames(): string[] { + return Array.from(this.agents.keys()); + } +} + +export const agentRegistry = new AgentRegistry(); diff --git a/plan2code-loop/src/bin/plan2code-loop.ts b/plan2code-loop/src/bin/plan2code-loop.ts index fe1d5d3..ca15aab 100644 --- a/plan2code-loop/src/bin/plan2code-loop.ts +++ b/plan2code-loop/src/bin/plan2code-loop.ts @@ -1,34 +1,34 @@ -import { run } from '../index.js'; -import { logger } from '../utils/index.js'; - -async function main() { - try { - const result = await run(); - - if (!result) { - process.exit(0); - } - - // Exit codes per spec - switch (result.exitReason) { - case 'all_complete': - logger.success('Loop completed successfully - all tasks done!'); - process.exit(0); - case 'max_iterations': - logger.warning('Loop ended: max iterations reached'); - process.exit(1); - case 'interrupted': - logger.info('Loop interrupted by user'); - process.exit(2); - case 'error': - logger.error('Loop ended with error'); - process.exit(3); - } - - } catch (error) { - logger.error(error instanceof Error ? error.message : String(error)); - process.exit(3); - } -} - -main(); +import { run } from '../index.js'; +import { logger } from '../utils/index.js'; + +async function main() { + try { + const result = await run(); + + if (!result) { + process.exit(0); + } + + // Exit codes per spec + switch (result.exitReason) { + case 'all_complete': + logger.success('Loop completed successfully - all tasks done!'); + process.exit(0); + case 'max_iterations': + logger.warning('Loop ended: max iterations reached'); + process.exit(1); + case 'interrupted': + logger.info('Loop interrupted by user'); + process.exit(2); + case 'error': + logger.error('Loop ended with error'); + process.exit(3); + } + + } catch (error) { + logger.error(error instanceof Error ? error.message : String(error)); + process.exit(3); + } +} + +main(); diff --git a/plan2code-loop/src/controller.ts b/plan2code-loop/src/controller.ts index 3d18665..dc7f89e 100644 --- a/plan2code-loop/src/controller.ts +++ b/plan2code-loop/src/controller.ts @@ -1,522 +1,522 @@ -import { agentRegistry, type Agent, type AgentExecutionResult } from './agents/index.js'; -import { StateManager, type SessionConfig, type IterationLogEntry } from './state/index.js'; -import { buildLoopPrompt } from './prompt/index.js'; -import { checkForCompletion, checkForAllCompletions, logger, ensureGitRepo, ensureGitignore, type CompletionCheckResult } from './utils/index.js'; - -export interface TaskCompleteInfo { - marker: string; - taskId?: string; - taskName?: string; -} - -export interface ControllerOptions { - config: SessionConfig; - stateManager: StateManager; - onIteration?: (iteration: number, max: number) => void; - onTaskComplete?: (info: TaskCompleteInfo) => void | Promise; - onLoopComplete?: () => void; -} - -export interface LoopResult { - completed: boolean; - iterations: number; - finalMarker?: string; - exitReason: 'all_complete' | 'max_iterations' | 'interrupted' | 'error'; - tasksCompleted: number; - prereqsCompleted: number; - error?: Error; -} - -export class Controller { - private readonly config: SessionConfig; - private readonly stateManager: StateManager; - private readonly agent: Agent; - private readonly onIteration?: (iteration: number, max: number) => void; - private readonly onTaskComplete?: (info: TaskCompleteInfo) => void | Promise; - private readonly onLoopComplete?: () => void; - private interrupted = false; - private abortController: AbortController | null = null; - private tasksCompleted = 0; - private prereqsCompleted = 0; - - constructor(options: ControllerOptions) { - this.config = options.config; - this.stateManager = options.stateManager; - this.onIteration = options.onIteration; - this.onTaskComplete = options.onTaskComplete; - this.onLoopComplete = options.onLoopComplete; - - const agent = agentRegistry.get(this.config.agent); - if (!agent) { - throw new Error(`Agent not found: ${this.config.agent}`); - } - this.agent = agent; - } - - private async buildPrompt(): Promise { - return buildLoopPrompt({ - specPath: this.config.specPath, - iteration: this.config.currentIteration + 1, - maxIterations: this.config.maxIterations, - stateManager: this.stateManager, - loopMode: this.config.loopMode || 'task', - jiraTicketId: this.config.jiraTicketId, - }); - } - - private computeTimeoutMs(attempt: number): number { - const baseMs = this.config.timeout * 60 * 1000; - return baseMs + (attempt * 30 * 1000); - } - - private formatDuration(ms: number): string { - const totalSec = Math.round(ms / 1000); - const min = Math.floor(totalSec / 60); - const sec = totalSec % 60; - if (min === 0) return `${sec}s`; - if (sec === 0) return `${min}m`; - return `${min}m ${sec}s`; - } - - private async executeWithRetry(prompt: string, iterNum: number): Promise { - const maxAttempts = (this.config.maxRetries ?? 5) + 1; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const timeoutMs = this.computeTimeoutMs(attempt); - - if (attempt > 0) { - const prevTimeoutMs = this.computeTimeoutMs(attempt - 1); - logger.warning( - `Timed out after ${this.formatDuration(prevTimeoutMs)}, retrying (${attempt}/${maxAttempts - 1})...` - ); - } - - const spinnerBase = attempt > 0 - ? `Waiting for AI Agent response (retry ${attempt}/${maxAttempts - 1})` - : 'Waiting for AI Agent response (please be patient)'; - const spinner = logger.spinner(spinnerBase); - const startTime = Date.now(); - - const elapsedInterval = setInterval(() => { - const elapsed = Math.round((Date.now() - startTime) / 1000); - spinner.text = `${spinnerBase} ... (${elapsed}s)`; - }, 1000); - - const result = await this.executeIteration(prompt, timeoutMs); - clearInterval(elapsedInterval); - spinner.stop(); - - // Cancelled or interrupted — return immediately, don't retry - if (result.cancelled || this.interrupted) { - return result; - } - - // Completed (success or error exit code) — return to caller - if (!result.timedOut) { - return result; - } - - // Timed out — retry if attempts remain, otherwise fatal - if (attempt < maxAttempts - 1) { - continue; - } - - // All attempts exhausted - logger.error( - `Iteration ${iterNum} timed out on all ${maxAttempts} attempt${maxAttempts === 1 ? '' : 's'}. Stopping loop.` - ); - const entry: IterationLogEntry = { - iteration: iterNum, - timestamp: new Date().toISOString(), - duration: result.duration, - exitCode: -1, - status: 'timeout', - }; - await this.stateManager.appendIterationLog(entry); - return 'fatal_timeout'; - } - - return 'fatal_timeout'; // unreachable, satisfies TS - } - - private async executeIteration(prompt: string, timeoutMs: number): Promise { - // Create new AbortController for this iteration - this.abortController = new AbortController(); - - const result = await this.agent.execute({ - prompt, - model: this.config.model, - timeout: timeoutMs, - verbose: this.config.verbose, - cwd: process.cwd(), - signal: this.abortController.signal, - }); - - this.abortController = null; - return result; - } - - private createLogEntry( - result: AgentExecutionResult, - status: IterationLogEntry['status'], - marker?: string - ): IterationLogEntry { - return { - iteration: this.config.currentIteration + 1, - timestamp: new Date().toISOString(), - duration: result.duration, - exitCode: result.exitCode, - status, - completionMarker: marker, - }; - } - - private formatTaskDisplay(completion: CompletionCheckResult): string { - if (completion.taskId && completion.taskName) { - return `Task ${completion.taskId}: ${completion.taskName}`; - } else if (completion.taskId) { - return `Task ${completion.taskId}`; - } - return ''; - } - - private displayIterationResult(result: AgentExecutionResult, iterNum: number, completion: CompletionCheckResult): void { - const duration = Math.round(result.duration / 1000); - const taskDisplay = this.formatTaskDisplay(completion); - - // Show iteration completion with task info if available - if (taskDisplay) { - logger.iteration(iterNum, this.config.maxIterations, `${taskDisplay} (${duration}s)`); - } else { - logger.iteration(iterNum, this.config.maxIterations, `completed in ${duration}s`); - } - - // Verbose mode: show full output - if (this.config.verbose) { - console.log(); - logger.dim('--- Agent Output ---'); - console.log(result.stdout); - if (result.stderr) { - logger.dim('--- Agent Stderr ---'); - console.log(result.stderr); - } - logger.dim('--- End Output ---'); - console.log(); - } else if (result.exitCode !== 0 && result.stderr.trim()) { - logger.error(` ${result.stderr.trim().split('\n')[0]}`); - } - } - - async run(): Promise { - // Pre-flight: verify agent CLI is available - const isAvailable = await this.agent.isAvailable(); - if (!isAvailable) { - logger.error(`"${this.agent.config.displayName}" is not available!`); - logger.info(`Please ensure the "${this.agent.config.command}" command is installed and available in your PATH.`); - throw new Error(`Agent "${this.agent.config.displayName}" is not available. Please install it and try again.`); - } - - // Ensure git repo and .gitignore are set up before any iterations - const gitReady = await ensureGitRepo(process.cwd()); - if (!gitReady) { - throw new Error('Failed to initialize a git repository in the current working directory. Cannot start Plan2Code Loop.'); - } - ensureGitignore(process.cwd()); - - const loopModeLabel = (this.config.loopMode || 'task') === 'phase' ? 'One phase per loop' : 'One task per loop'; - logger.header('Starting Plan2Code Loop'); - logger.info(`Agent: ${this.agent.config.displayName}`); - logger.info(`Model: ${this.config.model}`); - logger.info(`Spec: ${this.config.specPath}`); - logger.info(`Loop mode: ${loopModeLabel}`); - logger.info(`Max iterations: ${this.config.maxIterations}`); - console.log(); - - while (this.config.currentIteration < this.config.maxIterations) { - if (this.interrupted) { - return { - completed: false, - iterations: this.config.currentIteration, - exitReason: 'interrupted', - tasksCompleted: this.tasksCompleted, - prereqsCompleted: this.prereqsCompleted, - }; - } - - const iterNum = this.config.currentIteration + 1; - this.onIteration?.(iterNum, this.config.maxIterations); - - console.log(); - logger.info(`Iteration ${iterNum}/${this.config.maxIterations}`); - - // Build prompt - simple, just spec path and iteration info - const prompt = await this.buildPrompt(); - - try { - const retryResult = await this.executeWithRetry(prompt, iterNum); - - if (retryResult === 'fatal_timeout') { - return { - completed: false, - iterations: this.config.currentIteration, - exitReason: 'error', - tasksCompleted: this.tasksCompleted, - prereqsCompleted: this.prereqsCompleted, - error: new Error(`Iteration ${iterNum} failed after all retry attempts`), - }; - } - - const result = retryResult; - - // Check if cancelled - if (result.cancelled || this.interrupted) { - logger.info('Agent process cancelled'); - - const entry: IterationLogEntry = { - iteration: iterNum, - timestamp: new Date().toISOString(), - duration: result.duration, - exitCode: -1, - status: 'interrupted', - }; - await this.stateManager.appendIterationLog(entry); - - return { - completed: false, - iterations: this.config.currentIteration, - exitReason: 'interrupted', - tasksCompleted: this.tasksCompleted, - prereqsCompleted: this.prereqsCompleted, - }; - } - - // Branch completion handling based on loop mode - const isPhaseMode = (this.config.loopMode || 'task') === 'phase'; - - if (isPhaseMode) { - // Phase mode: parse ALL completion markers from output - const allCompletions = checkForAllCompletions(result.stdout + result.stderr); - - // Determine status - let status: IterationLogEntry['status'] = 'running'; - if (allCompletions.tasks.length > 0 || allCompletions.loopComplete || allCompletions.phaseComplete) { - const hasBlocked = allCompletions.tasks.some(t => t.marker === 'TASK_BLOCKED'); - const hasCompleted = allCompletions.tasks.some(t => t.marker === 'TASK_COMPLETE' || t.marker === 'PREREQ_COMPLETE' || t.marker === 'PREREQ_ASSUMED'); - status = hasCompleted || allCompletions.phaseComplete || allCompletions.loopComplete ? 'completed' : hasBlocked ? 'blocked' : 'running'; - } else if (result.timedOut) { - status = 'timeout'; - } else if (result.exitCode !== 0) { - status = 'error'; - } - - // Log iteration with count of tasks - const markerSummary = allCompletions.tasks.map(t => `${t.marker}: ${t.taskId}`).join(', '); - const logEntry = this.createLogEntry(result, status, markerSummary || undefined); - await this.stateManager.appendIterationLog(logEntry); - - // Display each completed task - const duration = Math.round(result.duration / 1000); - for (const task of allCompletions.tasks) { - if (task.marker === 'TASK_COMPLETE') { - this.tasksCompleted++; - const taskDisplay = this.formatTaskDisplay(task); - logger.success(taskDisplay ? `Completed: ${taskDisplay}` : 'Task completed!'); - } else if (task.marker === 'PREREQ_COMPLETE') { - this.prereqsCompleted++; - const prereqDisplay = task.taskId && task.taskName - ? `Prereq ${task.taskId}: ${task.taskName}` - : task.taskId ? `Prereq ${task.taskId}` : 'Prerequisite'; - logger.success(`Verified: ${prereqDisplay}`); - } else if (task.marker === 'PREREQ_ASSUMED') { - this.prereqsCompleted++; - const prereqDisplay = task.taskId && task.taskName - ? `Prereq ${task.taskId}: ${task.taskName}` - : task.taskId ? `Prereq ${task.taskId}` : 'Prerequisite'; - logger.success(`Assumed: ${prereqDisplay}`); - } else if (task.marker === 'TASK_BLOCKED') { - const blockInfo = task.taskId - ? `Task ${task.taskId} blocked: ${task.reason || 'Unknown reason'}` - : `Task blocked: ${task.reason || 'Unknown reason'}`; - logger.warning(blockInfo); - } - } - - // Show phase-level summary - if (allCompletions.tasks.length > 0) { - const completedCount = allCompletions.tasks.filter(t => t.marker === 'TASK_COMPLETE').length; - const prereqCount = allCompletions.tasks.filter(t => t.marker === 'PREREQ_COMPLETE' || t.marker === 'PREREQ_ASSUMED').length; - const blockedCount = allCompletions.tasks.filter(t => t.marker === 'TASK_BLOCKED').length; - const parts: string[] = []; - if (completedCount > 0) parts.push(`${completedCount} task(s) completed`); - if (prereqCount > 0) parts.push(`${prereqCount} prereq(s) verified`); - if (blockedCount > 0) parts.push(`${blockedCount} blocked`); - logger.iteration(iterNum, this.config.maxIterations, - `Phase done: ${parts.join(', ')} (${duration}s)`); - } else { - logger.iteration(iterNum, this.config.maxIterations, `completed in ${duration}s`); - } - - // Verbose output - if (this.config.verbose) { - console.log(); - logger.dim('--- Agent Output ---'); - console.log(result.stdout); - if (result.stderr) { - logger.dim('--- Agent Stderr ---'); - console.log(result.stderr); - } - logger.dim('--- End Output ---'); - console.log(); - } else if (result.exitCode !== 0 && result.stderr.trim()) { - logger.error(` ${result.stderr.trim().split('\n')[0]}`); - } - - // Handle LOOP_COMPLETE - if (allCompletions.loopComplete) { - this.onLoopComplete?.(); - logger.success('All tasks complete!'); - return { - completed: true, - iterations: iterNum, - finalMarker: 'LOOP_COMPLETE', - exitReason: 'all_complete', - tasksCompleted: this.tasksCompleted, - prereqsCompleted: this.prereqsCompleted, - }; - } - } else { - // Task mode (default): existing single-marker logic - const completion = checkForCompletion(result.stdout + result.stderr); - - // Determine status - let status: IterationLogEntry['status'] = 'running'; - if (completion.completed) { - if (completion.marker === 'TASK_BLOCKED') { - status = 'blocked'; - } else { - status = 'completed'; - } - } else if (result.timedOut) { - status = 'timeout'; - } else if (result.exitCode !== 0) { - status = 'error'; - } - - // Log iteration - const logEntry = this.createLogEntry(result, status, completion.marker); - await this.stateManager.appendIterationLog(logEntry); - - // Display iteration result with task info from completion marker - this.displayIterationResult(result, iterNum, completion); - - // Handle completion markers - if (completion.completed) { - const taskDisplay = this.formatTaskDisplay(completion); - - if (completion.marker === 'TASK_COMPLETE') { - this.tasksCompleted++; - await this.onTaskComplete?.({ - marker: completion.marker, - taskId: completion.taskId, - taskName: completion.taskName, - }); - logger.success(taskDisplay ? `Completed: ${taskDisplay}` : 'Task completed!'); - } else if (completion.marker === 'PREREQ_COMPLETE' || completion.marker === 'PREREQ_ASSUMED') { - this.prereqsCompleted++; - await this.onTaskComplete?.({ - marker: completion.marker, - taskId: completion.taskId, - taskName: completion.taskName, - }); - const prereqDisplay = completion.taskId && completion.taskName - ? `Prereq ${completion.taskId}: ${completion.taskName}` - : completion.taskId ? `Prereq ${completion.taskId}` : 'Prerequisite'; - const verb = completion.marker === 'PREREQ_COMPLETE' ? 'Verified' : 'Assumed'; - logger.success(`${verb}: ${prereqDisplay}`); - } else if (completion.marker === 'TASK_BLOCKED') { - const blockInfo = completion.taskId - ? `Task ${completion.taskId} blocked: ${completion.reason || 'Unknown reason'}` - : `Task blocked: ${completion.reason || 'Unknown reason'}`; - logger.warning(blockInfo); - } else if (completion.marker === 'LOOP_COMPLETE') { - // Commit any final changes before completing - this.tasksCompleted++; - await this.onTaskComplete?.({ - marker: completion.marker, - taskId: completion.taskId, - taskName: completion.taskName || 'Final implementation complete', - }); - this.onLoopComplete?.(); - logger.success('All tasks complete!'); - return { - completed: true, - iterations: iterNum, - finalMarker: 'LOOP_COMPLETE', - exitReason: 'all_complete', - tasksCompleted: this.tasksCompleted, - prereqsCompleted: this.prereqsCompleted, - }; - } - } - } - - // Increment iteration and update spec hash (so resume doesn't see false changes) - await this.stateManager.incrementIteration(); - await this.stateManager.updateSpecHash(this.config.specPath); - this.config.currentIteration++; - - // Handle error (but continue - LLM might recover) - if (result.exitCode !== 0) { - // In phase mode, check if any tasks completed despite error exit code - const hasCompletions = isPhaseMode - ? checkForAllCompletions(result.stdout + result.stderr).tasks.length > 0 - : checkForCompletion(result.stdout + result.stderr).completed; - if (!hasCompletions) { - logger.warning(`Iteration ${iterNum} exited with code ${result.exitCode}, continuing...`); - } - } - - } catch (error) { - clearInterval(elapsedInterval); - spinner.stop(); - logger.error(`Iteration ${iterNum} failed: ${error}`); - - // Log the error - const entry: IterationLogEntry = { - iteration: iterNum, - timestamp: new Date().toISOString(), - duration: 0, - exitCode: -1, - status: 'error', - }; - await this.stateManager.appendIterationLog(entry); - - return { - completed: false, - iterations: this.config.currentIteration, - exitReason: 'error', - tasksCompleted: this.tasksCompleted, - prereqsCompleted: this.prereqsCompleted, - error: error instanceof Error ? error : new Error(String(error)), - }; - } - } - - // Max iterations reached - logger.warning('Max iterations reached'); - return { - completed: false, - iterations: this.config.currentIteration, - exitReason: 'max_iterations', - tasksCompleted: this.tasksCompleted, - prereqsCompleted: this.prereqsCompleted, - }; - } - - interrupt(): void { - this.interrupted = true; - if (this.abortController) { - this.abortController.abort(); - } - } -} +import { agentRegistry, type Agent, type AgentExecutionResult } from './agents/index.js'; +import { StateManager, type SessionConfig, type IterationLogEntry } from './state/index.js'; +import { buildLoopPrompt } from './prompt/index.js'; +import { checkForCompletion, checkForAllCompletions, logger, ensureGitRepo, ensureGitignore, type CompletionCheckResult } from './utils/index.js'; + +export interface TaskCompleteInfo { + marker: string; + taskId?: string; + taskName?: string; +} + +export interface ControllerOptions { + config: SessionConfig; + stateManager: StateManager; + onIteration?: (iteration: number, max: number) => void; + onTaskComplete?: (info: TaskCompleteInfo) => void | Promise; + onLoopComplete?: () => void; +} + +export interface LoopResult { + completed: boolean; + iterations: number; + finalMarker?: string; + exitReason: 'all_complete' | 'max_iterations' | 'interrupted' | 'error'; + tasksCompleted: number; + prereqsCompleted: number; + error?: Error; +} + +export class Controller { + private readonly config: SessionConfig; + private readonly stateManager: StateManager; + private readonly agent: Agent; + private readonly onIteration?: (iteration: number, max: number) => void; + private readonly onTaskComplete?: (info: TaskCompleteInfo) => void | Promise; + private readonly onLoopComplete?: () => void; + private interrupted = false; + private abortController: AbortController | null = null; + private tasksCompleted = 0; + private prereqsCompleted = 0; + + constructor(options: ControllerOptions) { + this.config = options.config; + this.stateManager = options.stateManager; + this.onIteration = options.onIteration; + this.onTaskComplete = options.onTaskComplete; + this.onLoopComplete = options.onLoopComplete; + + const agent = agentRegistry.get(this.config.agent); + if (!agent) { + throw new Error(`Agent not found: ${this.config.agent}`); + } + this.agent = agent; + } + + private async buildPrompt(): Promise { + return buildLoopPrompt({ + specPath: this.config.specPath, + iteration: this.config.currentIteration + 1, + maxIterations: this.config.maxIterations, + stateManager: this.stateManager, + loopMode: this.config.loopMode || 'task', + jiraTicketId: this.config.jiraTicketId, + }); + } + + private computeTimeoutMs(attempt: number): number { + const baseMs = this.config.timeout * 60 * 1000; + return baseMs + (attempt * 30 * 1000); + } + + private formatDuration(ms: number): string { + const totalSec = Math.round(ms / 1000); + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + if (min === 0) return `${sec}s`; + if (sec === 0) return `${min}m`; + return `${min}m ${sec}s`; + } + + private async executeWithRetry(prompt: string, iterNum: number): Promise { + const maxAttempts = (this.config.maxRetries ?? 5) + 1; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const timeoutMs = this.computeTimeoutMs(attempt); + + if (attempt > 0) { + const prevTimeoutMs = this.computeTimeoutMs(attempt - 1); + logger.warning( + `Timed out after ${this.formatDuration(prevTimeoutMs)}, retrying (${attempt}/${maxAttempts - 1})...` + ); + } + + const spinnerBase = attempt > 0 + ? `Waiting for AI Agent response (retry ${attempt}/${maxAttempts - 1})` + : 'Waiting for AI Agent response (please be patient)'; + const spinner = logger.spinner(spinnerBase); + const startTime = Date.now(); + + const elapsedInterval = setInterval(() => { + const elapsed = Math.round((Date.now() - startTime) / 1000); + spinner.text = `${spinnerBase} ... (${elapsed}s)`; + }, 1000); + + const result = await this.executeIteration(prompt, timeoutMs); + clearInterval(elapsedInterval); + spinner.stop(); + + // Cancelled or interrupted — return immediately, don't retry + if (result.cancelled || this.interrupted) { + return result; + } + + // Completed (success or error exit code) — return to caller + if (!result.timedOut) { + return result; + } + + // Timed out — retry if attempts remain, otherwise fatal + if (attempt < maxAttempts - 1) { + continue; + } + + // All attempts exhausted + logger.error( + `Iteration ${iterNum} timed out on all ${maxAttempts} attempt${maxAttempts === 1 ? '' : 's'}. Stopping loop.` + ); + const entry: IterationLogEntry = { + iteration: iterNum, + timestamp: new Date().toISOString(), + duration: result.duration, + exitCode: -1, + status: 'timeout', + }; + await this.stateManager.appendIterationLog(entry); + return 'fatal_timeout'; + } + + return 'fatal_timeout'; // unreachable, satisfies TS + } + + private async executeIteration(prompt: string, timeoutMs: number): Promise { + // Create new AbortController for this iteration + this.abortController = new AbortController(); + + const result = await this.agent.execute({ + prompt, + model: this.config.model, + timeout: timeoutMs, + verbose: this.config.verbose, + cwd: process.cwd(), + signal: this.abortController.signal, + }); + + this.abortController = null; + return result; + } + + private createLogEntry( + result: AgentExecutionResult, + status: IterationLogEntry['status'], + marker?: string + ): IterationLogEntry { + return { + iteration: this.config.currentIteration + 1, + timestamp: new Date().toISOString(), + duration: result.duration, + exitCode: result.exitCode, + status, + completionMarker: marker, + }; + } + + private formatTaskDisplay(completion: CompletionCheckResult): string { + if (completion.taskId && completion.taskName) { + return `Task ${completion.taskId}: ${completion.taskName}`; + } else if (completion.taskId) { + return `Task ${completion.taskId}`; + } + return ''; + } + + private displayIterationResult(result: AgentExecutionResult, iterNum: number, completion: CompletionCheckResult): void { + const duration = Math.round(result.duration / 1000); + const taskDisplay = this.formatTaskDisplay(completion); + + // Show iteration completion with task info if available + if (taskDisplay) { + logger.iteration(iterNum, this.config.maxIterations, `${taskDisplay} (${duration}s)`); + } else { + logger.iteration(iterNum, this.config.maxIterations, `completed in ${duration}s`); + } + + // Verbose mode: show full output + if (this.config.verbose) { + console.log(); + logger.dim('--- Agent Output ---'); + console.log(result.stdout); + if (result.stderr) { + logger.dim('--- Agent Stderr ---'); + console.log(result.stderr); + } + logger.dim('--- End Output ---'); + console.log(); + } else if (result.exitCode !== 0 && result.stderr.trim()) { + logger.error(` ${result.stderr.trim().split('\n')[0]}`); + } + } + + async run(): Promise { + // Pre-flight: verify agent CLI is available + const isAvailable = await this.agent.isAvailable(); + if (!isAvailable) { + logger.error(`"${this.agent.config.displayName}" is not available!`); + logger.info(`Please ensure the "${this.agent.config.command}" command is installed and available in your PATH.`); + throw new Error(`Agent "${this.agent.config.displayName}" is not available. Please install it and try again.`); + } + + // Ensure git repo and .gitignore are set up before any iterations + const gitReady = await ensureGitRepo(process.cwd()); + if (!gitReady) { + throw new Error('Failed to initialize a git repository in the current working directory. Cannot start Plan2Code Loop.'); + } + ensureGitignore(process.cwd()); + + const loopModeLabel = (this.config.loopMode || 'task') === 'phase' ? 'One phase per loop' : 'One task per loop'; + logger.header('Starting Plan2Code Loop'); + logger.info(`Agent: ${this.agent.config.displayName}`); + logger.info(`Model: ${this.config.model}`); + logger.info(`Spec: ${this.config.specPath}`); + logger.info(`Loop mode: ${loopModeLabel}`); + logger.info(`Max iterations: ${this.config.maxIterations}`); + console.log(); + + while (this.config.currentIteration < this.config.maxIterations) { + if (this.interrupted) { + return { + completed: false, + iterations: this.config.currentIteration, + exitReason: 'interrupted', + tasksCompleted: this.tasksCompleted, + prereqsCompleted: this.prereqsCompleted, + }; + } + + const iterNum = this.config.currentIteration + 1; + this.onIteration?.(iterNum, this.config.maxIterations); + + console.log(); + logger.info(`Iteration ${iterNum}/${this.config.maxIterations}`); + + // Build prompt - simple, just spec path and iteration info + const prompt = await this.buildPrompt(); + + try { + const retryResult = await this.executeWithRetry(prompt, iterNum); + + if (retryResult === 'fatal_timeout') { + return { + completed: false, + iterations: this.config.currentIteration, + exitReason: 'error', + tasksCompleted: this.tasksCompleted, + prereqsCompleted: this.prereqsCompleted, + error: new Error(`Iteration ${iterNum} failed after all retry attempts`), + }; + } + + const result = retryResult; + + // Check if cancelled + if (result.cancelled || this.interrupted) { + logger.info('Agent process cancelled'); + + const entry: IterationLogEntry = { + iteration: iterNum, + timestamp: new Date().toISOString(), + duration: result.duration, + exitCode: -1, + status: 'interrupted', + }; + await this.stateManager.appendIterationLog(entry); + + return { + completed: false, + iterations: this.config.currentIteration, + exitReason: 'interrupted', + tasksCompleted: this.tasksCompleted, + prereqsCompleted: this.prereqsCompleted, + }; + } + + // Branch completion handling based on loop mode + const isPhaseMode = (this.config.loopMode || 'task') === 'phase'; + + if (isPhaseMode) { + // Phase mode: parse ALL completion markers from output + const allCompletions = checkForAllCompletions(result.stdout + result.stderr); + + // Determine status + let status: IterationLogEntry['status'] = 'running'; + if (allCompletions.tasks.length > 0 || allCompletions.loopComplete || allCompletions.phaseComplete) { + const hasBlocked = allCompletions.tasks.some(t => t.marker === 'TASK_BLOCKED'); + const hasCompleted = allCompletions.tasks.some(t => t.marker === 'TASK_COMPLETE' || t.marker === 'PREREQ_COMPLETE' || t.marker === 'PREREQ_ASSUMED'); + status = hasCompleted || allCompletions.phaseComplete || allCompletions.loopComplete ? 'completed' : hasBlocked ? 'blocked' : 'running'; + } else if (result.timedOut) { + status = 'timeout'; + } else if (result.exitCode !== 0) { + status = 'error'; + } + + // Log iteration with count of tasks + const markerSummary = allCompletions.tasks.map(t => `${t.marker}: ${t.taskId}`).join(', '); + const logEntry = this.createLogEntry(result, status, markerSummary || undefined); + await this.stateManager.appendIterationLog(logEntry); + + // Display each completed task + const duration = Math.round(result.duration / 1000); + for (const task of allCompletions.tasks) { + if (task.marker === 'TASK_COMPLETE') { + this.tasksCompleted++; + const taskDisplay = this.formatTaskDisplay(task); + logger.success(taskDisplay ? `Completed: ${taskDisplay}` : 'Task completed!'); + } else if (task.marker === 'PREREQ_COMPLETE') { + this.prereqsCompleted++; + const prereqDisplay = task.taskId && task.taskName + ? `Prereq ${task.taskId}: ${task.taskName}` + : task.taskId ? `Prereq ${task.taskId}` : 'Prerequisite'; + logger.success(`Verified: ${prereqDisplay}`); + } else if (task.marker === 'PREREQ_ASSUMED') { + this.prereqsCompleted++; + const prereqDisplay = task.taskId && task.taskName + ? `Prereq ${task.taskId}: ${task.taskName}` + : task.taskId ? `Prereq ${task.taskId}` : 'Prerequisite'; + logger.success(`Assumed: ${prereqDisplay}`); + } else if (task.marker === 'TASK_BLOCKED') { + const blockInfo = task.taskId + ? `Task ${task.taskId} blocked: ${task.reason || 'Unknown reason'}` + : `Task blocked: ${task.reason || 'Unknown reason'}`; + logger.warning(blockInfo); + } + } + + // Show phase-level summary + if (allCompletions.tasks.length > 0) { + const completedCount = allCompletions.tasks.filter(t => t.marker === 'TASK_COMPLETE').length; + const prereqCount = allCompletions.tasks.filter(t => t.marker === 'PREREQ_COMPLETE' || t.marker === 'PREREQ_ASSUMED').length; + const blockedCount = allCompletions.tasks.filter(t => t.marker === 'TASK_BLOCKED').length; + const parts: string[] = []; + if (completedCount > 0) parts.push(`${completedCount} task(s) completed`); + if (prereqCount > 0) parts.push(`${prereqCount} prereq(s) verified`); + if (blockedCount > 0) parts.push(`${blockedCount} blocked`); + logger.iteration(iterNum, this.config.maxIterations, + `Phase done: ${parts.join(', ')} (${duration}s)`); + } else { + logger.iteration(iterNum, this.config.maxIterations, `completed in ${duration}s`); + } + + // Verbose output + if (this.config.verbose) { + console.log(); + logger.dim('--- Agent Output ---'); + console.log(result.stdout); + if (result.stderr) { + logger.dim('--- Agent Stderr ---'); + console.log(result.stderr); + } + logger.dim('--- End Output ---'); + console.log(); + } else if (result.exitCode !== 0 && result.stderr.trim()) { + logger.error(` ${result.stderr.trim().split('\n')[0]}`); + } + + // Handle LOOP_COMPLETE + if (allCompletions.loopComplete) { + this.onLoopComplete?.(); + logger.success('All tasks complete!'); + return { + completed: true, + iterations: iterNum, + finalMarker: 'LOOP_COMPLETE', + exitReason: 'all_complete', + tasksCompleted: this.tasksCompleted, + prereqsCompleted: this.prereqsCompleted, + }; + } + } else { + // Task mode (default): existing single-marker logic + const completion = checkForCompletion(result.stdout + result.stderr); + + // Determine status + let status: IterationLogEntry['status'] = 'running'; + if (completion.completed) { + if (completion.marker === 'TASK_BLOCKED') { + status = 'blocked'; + } else { + status = 'completed'; + } + } else if (result.timedOut) { + status = 'timeout'; + } else if (result.exitCode !== 0) { + status = 'error'; + } + + // Log iteration + const logEntry = this.createLogEntry(result, status, completion.marker); + await this.stateManager.appendIterationLog(logEntry); + + // Display iteration result with task info from completion marker + this.displayIterationResult(result, iterNum, completion); + + // Handle completion markers + if (completion.completed) { + const taskDisplay = this.formatTaskDisplay(completion); + + if (completion.marker === 'TASK_COMPLETE') { + this.tasksCompleted++; + await this.onTaskComplete?.({ + marker: completion.marker, + taskId: completion.taskId, + taskName: completion.taskName, + }); + logger.success(taskDisplay ? `Completed: ${taskDisplay}` : 'Task completed!'); + } else if (completion.marker === 'PREREQ_COMPLETE' || completion.marker === 'PREREQ_ASSUMED') { + this.prereqsCompleted++; + await this.onTaskComplete?.({ + marker: completion.marker, + taskId: completion.taskId, + taskName: completion.taskName, + }); + const prereqDisplay = completion.taskId && completion.taskName + ? `Prereq ${completion.taskId}: ${completion.taskName}` + : completion.taskId ? `Prereq ${completion.taskId}` : 'Prerequisite'; + const verb = completion.marker === 'PREREQ_COMPLETE' ? 'Verified' : 'Assumed'; + logger.success(`${verb}: ${prereqDisplay}`); + } else if (completion.marker === 'TASK_BLOCKED') { + const blockInfo = completion.taskId + ? `Task ${completion.taskId} blocked: ${completion.reason || 'Unknown reason'}` + : `Task blocked: ${completion.reason || 'Unknown reason'}`; + logger.warning(blockInfo); + } else if (completion.marker === 'LOOP_COMPLETE') { + // Commit any final changes before completing + this.tasksCompleted++; + await this.onTaskComplete?.({ + marker: completion.marker, + taskId: completion.taskId, + taskName: completion.taskName || 'Final implementation complete', + }); + this.onLoopComplete?.(); + logger.success('All tasks complete!'); + return { + completed: true, + iterations: iterNum, + finalMarker: 'LOOP_COMPLETE', + exitReason: 'all_complete', + tasksCompleted: this.tasksCompleted, + prereqsCompleted: this.prereqsCompleted, + }; + } + } + } + + // Increment iteration and update spec hash (so resume doesn't see false changes) + await this.stateManager.incrementIteration(); + await this.stateManager.updateSpecHash(this.config.specPath); + this.config.currentIteration++; + + // Handle error (but continue - LLM might recover) + if (result.exitCode !== 0) { + // In phase mode, check if any tasks completed despite error exit code + const hasCompletions = isPhaseMode + ? checkForAllCompletions(result.stdout + result.stderr).tasks.length > 0 + : checkForCompletion(result.stdout + result.stderr).completed; + if (!hasCompletions) { + logger.warning(`Iteration ${iterNum} exited with code ${result.exitCode}, continuing...`); + } + } + + } catch (error) { + clearInterval(elapsedInterval); + spinner.stop(); + logger.error(`Iteration ${iterNum} failed: ${error}`); + + // Log the error + const entry: IterationLogEntry = { + iteration: iterNum, + timestamp: new Date().toISOString(), + duration: 0, + exitCode: -1, + status: 'error', + }; + await this.stateManager.appendIterationLog(entry); + + return { + completed: false, + iterations: this.config.currentIteration, + exitReason: 'error', + tasksCompleted: this.tasksCompleted, + prereqsCompleted: this.prereqsCompleted, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } + + // Max iterations reached + logger.warning('Max iterations reached'); + return { + completed: false, + iterations: this.config.currentIteration, + exitReason: 'max_iterations', + tasksCompleted: this.tasksCompleted, + prereqsCompleted: this.prereqsCompleted, + }; + } + + interrupt(): void { + this.interrupted = true; + if (this.abortController) { + this.abortController.abort(); + } + } +} diff --git a/plan2code-loop/src/index.ts b/plan2code-loop/src/index.ts index 0488e4e..8825856 100644 --- a/plan2code-loop/src/index.ts +++ b/plan2code-loop/src/index.ts @@ -1,96 +1,96 @@ -import path from 'path'; -import { StateManager } from './state/index.js'; -import { Controller, type LoopResult, type TaskCompleteInfo } from './controller.js'; -import { setupSession } from './cli.js'; -import { logger, createTaskCommit } from './utils/index.js'; - -export async function run(): Promise { - // Ensure agents are registered - await import('./agents/index.js'); - - const stateManager = new StateManager(); - - const result = await setupSession(stateManager); - if (!result) { - return null; - } - - const { config, isResume } = result; - - if (isResume) { - logger.info(`Resuming from iteration ${config.currentIteration}`); - } - - const controller = new Controller({ - config, - stateManager, - onIteration: (iter, max) => { - // Could add git checkpoint logic here if needed - }, - onTaskComplete: async (info: TaskCompleteInfo) => { - // Create git commit for completed task - const taskName = info.taskName || info.taskId || 'Task completed'; - await createTaskCommit({ - taskName, - jiraTicketId: config.jiraTicketId, - cwd: process.cwd(), - }); - }, - onLoopComplete: () => { - // All tasks completed callback - }, - }); - - // Setup interrupt handler - const handleInterrupt = () => { - logger.warning('\nInterrupt received, saving state...'); - controller.interrupt(); - }; - - process.on('SIGINT', handleInterrupt); - process.on('SIGTERM', handleInterrupt); - - try { - const loopResult = await controller.run(); - - // Display summary - console.log(); - logger.header('Session Summary'); - logger.info(`Total iterations: ${loopResult.iterations}`); - logger.info(`Tasks completed: ${loopResult.tasksCompleted}`); - if (loopResult.prereqsCompleted > 0) { - logger.info(`Prerequisites verified: ${loopResult.prereqsCompleted}`); - } - logger.info(`Exit reason: ${loopResult.exitReason}`); - if (loopResult.finalMarker) { - logger.info(`Completion marker: ${loopResult.finalMarker}`); - } - if (loopResult.error) { - logger.error(`Error: ${loopResult.error.message}`); - } - - // Show completion celebration and finalize reminder when all phases complete - if (loopResult.exitReason === 'all_complete') { - logger.allPhasesComplete(); - } - - // Show state file locations (now per-spec) - console.log(); - logger.dim(`Session files saved to ${path.relative(process.cwd(), stateManager.getStateDir())}:`); - logger.dim(' - config.json (session configuration)'); - logger.dim(' - scratchpad.md (LLM-managed notes)'); - logger.dim(' - iteration.log (history)'); - - return loopResult; - } finally { - process.off('SIGINT', handleInterrupt); - process.off('SIGTERM', handleInterrupt); - } -} - -// Re-export types and classes -export { Controller, type ControllerOptions, type LoopResult, type TaskCompleteInfo } from './controller.js'; -export { StateManager } from './state/index.js'; -export { setupSession } from './cli.js'; -export { agentRegistry, type Agent, type AgentConfig } from './agents/index.js'; -export { detectSpecDirectories, getSpecProgress } from './spec/index.js'; +import path from 'path'; +import { StateManager } from './state/index.js'; +import { Controller, type LoopResult, type TaskCompleteInfo } from './controller.js'; +import { setupSession } from './cli.js'; +import { logger, createTaskCommit } from './utils/index.js'; + +export async function run(): Promise { + // Ensure agents are registered + await import('./agents/index.js'); + + const stateManager = new StateManager(); + + const result = await setupSession(stateManager); + if (!result) { + return null; + } + + const { config, isResume } = result; + + if (isResume) { + logger.info(`Resuming from iteration ${config.currentIteration}`); + } + + const controller = new Controller({ + config, + stateManager, + onIteration: (iter, max) => { + // Could add git checkpoint logic here if needed + }, + onTaskComplete: async (info: TaskCompleteInfo) => { + // Create git commit for completed task + const taskName = info.taskName || info.taskId || 'Task completed'; + await createTaskCommit({ + taskName, + jiraTicketId: config.jiraTicketId, + cwd: process.cwd(), + }); + }, + onLoopComplete: () => { + // All tasks completed callback + }, + }); + + // Setup interrupt handler + const handleInterrupt = () => { + logger.warning('\nInterrupt received, saving state...'); + controller.interrupt(); + }; + + process.on('SIGINT', handleInterrupt); + process.on('SIGTERM', handleInterrupt); + + try { + const loopResult = await controller.run(); + + // Display summary + console.log(); + logger.header('Session Summary'); + logger.info(`Total iterations: ${loopResult.iterations}`); + logger.info(`Tasks completed: ${loopResult.tasksCompleted}`); + if (loopResult.prereqsCompleted > 0) { + logger.info(`Prerequisites verified: ${loopResult.prereqsCompleted}`); + } + logger.info(`Exit reason: ${loopResult.exitReason}`); + if (loopResult.finalMarker) { + logger.info(`Completion marker: ${loopResult.finalMarker}`); + } + if (loopResult.error) { + logger.error(`Error: ${loopResult.error.message}`); + } + + // Show completion celebration and finalize reminder when all phases complete + if (loopResult.exitReason === 'all_complete') { + logger.allPhasesComplete(); + } + + // Show state file locations (now per-spec) + console.log(); + logger.dim(`Session files saved to ${path.relative(process.cwd(), stateManager.getStateDir())}:`); + logger.dim(' - config.json (session configuration)'); + logger.dim(' - scratchpad.md (LLM-managed notes)'); + logger.dim(' - iteration.log (history)'); + + return loopResult; + } finally { + process.off('SIGINT', handleInterrupt); + process.off('SIGTERM', handleInterrupt); + } +} + +// Re-export types and classes +export { Controller, type ControllerOptions, type LoopResult, type TaskCompleteInfo } from './controller.js'; +export { StateManager } from './state/index.js'; +export { setupSession } from './cli.js'; +export { agentRegistry, type Agent, type AgentConfig } from './agents/index.js'; +export { detectSpecDirectories, getSpecProgress } from './spec/index.js'; diff --git a/plan2code-loop/src/prompt/builder.ts b/plan2code-loop/src/prompt/builder.ts index c0f81b0..e9ac52d 100644 --- a/plan2code-loop/src/prompt/builder.ts +++ b/plan2code-loop/src/prompt/builder.ts @@ -1,39 +1,39 @@ -import type { StateManager, LoopMode } from '../state/index.js'; -import { LOOP_PROMPT_TEMPLATE, LOOP_PROMPT_TEMPLATE_PHASE } from './templates.js'; - -export interface PromptContext { - specPath: string; - iteration: number; - maxIterations: number; - stateManager: StateManager; - loopMode: LoopMode; - jiraTicketId?: string; -} - -/** - * Build the prompt for the AI agent - * Selects template based on loop mode (task vs phase) - */ -export async function buildLoopPrompt(context: PromptContext): Promise { - const { specPath, iteration, maxIterations, stateManager, loopMode, jiraTicketId } = context; - - // Read scratchpad content for session continuity (LLM writes to this) - const scratchpadContent = await stateManager.readScratchpad(); - - // Project root is where plan2code-loop was invoked from - const projectRoot = process.cwd(); - - // Select template based on loop mode - const template = loopMode === 'phase' ? LOOP_PROMPT_TEMPLATE_PHASE : LOOP_PROMPT_TEMPLATE; - - // Template substitution - const prompt = template - .replace(/{{projectRoot}}/g, projectRoot) - .replace(/{{specPath}}/g, specPath) - .replace(/{{iteration}}/g, iteration.toString()) - .replace(/{{maxIterations}}/g, maxIterations.toString()) - .replace(/{{scratchpadContent}}/g, scratchpadContent || '(First iteration - no previous progress)') - .replace(/{{jiraTicketId}}/g, jiraTicketId || ''); - - return prompt; -} +import type { StateManager, LoopMode } from '../state/index.js'; +import { LOOP_PROMPT_TEMPLATE, LOOP_PROMPT_TEMPLATE_PHASE } from './templates.js'; + +export interface PromptContext { + specPath: string; + iteration: number; + maxIterations: number; + stateManager: StateManager; + loopMode: LoopMode; + jiraTicketId?: string; +} + +/** + * Build the prompt for the AI agent + * Selects template based on loop mode (task vs phase) + */ +export async function buildLoopPrompt(context: PromptContext): Promise { + const { specPath, iteration, maxIterations, stateManager, loopMode, jiraTicketId } = context; + + // Read scratchpad content for session continuity (LLM writes to this) + const scratchpadContent = await stateManager.readScratchpad(); + + // Project root is where plan2code-loop was invoked from + const projectRoot = process.cwd(); + + // Select template based on loop mode + const template = loopMode === 'phase' ? LOOP_PROMPT_TEMPLATE_PHASE : LOOP_PROMPT_TEMPLATE; + + // Template substitution + const prompt = template + .replace(/{{projectRoot}}/g, projectRoot) + .replace(/{{specPath}}/g, specPath) + .replace(/{{iteration}}/g, iteration.toString()) + .replace(/{{maxIterations}}/g, maxIterations.toString()) + .replace(/{{scratchpadContent}}/g, scratchpadContent || '(First iteration - no previous progress)') + .replace(/{{jiraTicketId}}/g, jiraTicketId || ''); + + return prompt; +} diff --git a/plan2code-loop/src/prompt/index.ts b/plan2code-loop/src/prompt/index.ts index 245a4e7..9a47038 100644 --- a/plan2code-loop/src/prompt/index.ts +++ b/plan2code-loop/src/prompt/index.ts @@ -1,6 +1,6 @@ -export { - buildLoopPrompt, - type PromptContext, -} from './builder.js'; - -export { LOOP_PROMPT_TEMPLATE, LOOP_PROMPT_TEMPLATE_PHASE } from './templates.js'; +export { + buildLoopPrompt, + type PromptContext, +} from './builder.js'; + +export { LOOP_PROMPT_TEMPLATE, LOOP_PROMPT_TEMPLATE_PHASE } from './templates.js'; diff --git a/plan2code-loop/src/prompt/templates.ts b/plan2code-loop/src/prompt/templates.ts index 91c2d09..c58fa83 100644 --- a/plan2code-loop/src/prompt/templates.ts +++ b/plan2code-loop/src/prompt/templates.ts @@ -1,200 +1,200 @@ -export const LOOP_PROMPT_TEMPLATE = `# PLAN2CODE-LOOP: Autonomous Task Implementation - -## CRITICAL CONSTRAINT -**IMPLEMENT EXACTLY ONE TASK PER ITERATION.** -Do NOT implement multiple tasks. Do NOT complete an entire phase. -Find the FIRST unchecked task, implement ONLY that task, then STOP and report. - -## Project Information -- **Project Root:** \`{{projectRoot}}\` -- **Spec Location:** \`{{specPath}}\` -- Read \`AGENTS.md\` for project-specific guidance if available - -## IMPORTANT: File Locations -- Write ALL code files relative to the **project root** (\`{{projectRoot}}\`) -- The spec directory (\`{{specPath}}\`) is for documentation ONLY - never write code there -- Example: Create \`{{projectRoot}}/src/index.ts\`, NOT \`{{specPath}}/src/index.ts\` - -## Iteration -{{iteration}} of {{maxIterations}} - -## Task Discovery Process -1. Read \`{{specPath}}/overview.md\` to see all phases -2. Find the FIRST phase with an unchecked checkbox (\`- [ ]\` or \`- [/]\`) -3. Read that phase's file (e.g., \`phase-1.md\`) -4. Check the \`## Prerequisites\` section FIRST -5. Find the FIRST unverified prerequisite (no "VERIFIED" or "ASSUMED" annotation) - - If found, verify/complete it, then annotate "VERIFIED" or "ASSUMED: [reason]" inline -6. Only if ALL prerequisites are verified or assumed, find the FIRST unchecked task (\`- [ ]\`) -7. That is your ONE task - implement ONLY that task - -## Checkbox States (Task items only) -- \`[ ]\` = incomplete/pending (do the FIRST one you find) -- \`[x]\` = complete (skip) -- \`[?]\` = assumed complete, couldn't verify (skip) -- \`[!]\` = blocked (skip) - -Prerequisites use plain bullets with inline annotations, not checkboxes. - -## Implementation Steps -1. Read and understand the single task -2. Implement it completely -3. Validate it works (run tests if applicable and double-check code) -4. Mark ONLY that task's checkbox as \`[x]\` in the phase file -5. If that was the LAST task in the phase, also mark the phase \`[x]\` in overview.md -6. Output your completion marker and STOP - -## Git Policy -**DO NOT create git commits.** The orchestration system handles commits automatically after each task completion. Just implement the code and leave changes uncommitted. - -## Completion Markers (REQUIRED FORMAT) -Output exactly ONE of these at the end, including the task ID and description: - -**PREREQ_COMPLETE: [prereq_id] - [description]** -Example: \`PREREQ_COMPLETE: P1.1 - Verified Phase 1 complete\` - -**PREREQ_ASSUMED: [prereq_id] - [description]** -Example: \`PREREQ_ASSUMED: P2.1 - Design approval (cannot verify)\` - -**TASK_COMPLETE: [task_id] - [task_description]** -Example: \`TASK_COMPLETE: 1.1 - Initialize project structure\` - -**TASK_BLOCKED: [task_id] - [reason]** -Example: \`TASK_BLOCKED: 2.3 - Missing API credentials\` - -**LOOP_COMPLETE** -Use only when ALL phases in overview.md are marked complete. - -## Scratchpad Management - -After completing each task, add a new entry at the **bottom** of \`{{specPath}}/.plan2code-loop/scratchpad.md\`. -Never edit, reorganize, or insert into existing content — only append new entries to the end of the file. - -Each entry should include: -- Task completed and Phase item reference -- Key decisions made and reasoning -- Files changed -- Any blockers or notes for next iteration - -Keep entries concise. Sacrifice grammar for concision. This file helps future iterations skip exploration. - -If key patterns or learnings were discovered, update \`./AGENTS.md\` if it exists. - -## Previous Session Context -{{scratchpadContent}} - ---- - -Remember: ONE TASK ONLY. Find it, implement it, mark it done, output TASK_COMPLETE with the task ID and description, then stop. -`; - -export const LOOP_PROMPT_TEMPLATE_PHASE = `# PLAN2CODE-LOOP: Autonomous Phase Implementation - -## CRITICAL CONSTRAINT -**IMPLEMENT ALL REMAINING TASKS IN THE CURRENT PHASE.** -Find the first incomplete phase, then implement every remaining task in that phase before stopping. -Complete each task fully before moving to the next task within the phase. - -## Project Information -- **Project Root:** \`{{projectRoot}}\` -- **Spec Location:** \`{{specPath}}\` -- Read \`AGENTS.md\` for project-specific guidance if available - -## IMPORTANT: File Locations -- Write ALL code files relative to the **project root** (\`{{projectRoot}}\`) -- The spec directory (\`{{specPath}}\`) is for documentation ONLY - never write code there -- Example: Create \`{{projectRoot}}/src/index.ts\`, NOT \`{{specPath}}/src/index.ts\` - -## Iteration -{{iteration}} of {{maxIterations}} - -## Phase Discovery Process -1. Read \`{{specPath}}/overview.md\` to see all phases -2. Find the FIRST phase with an unchecked checkbox (\`- [ ]\` or \`- [/]\`) -3. Read that phase's file (e.g., \`phase-1.md\`) -4. Check the \`## Prerequisites\` section FIRST -5. Verify ALL unverified prerequisites first, in order - - Annotate each "VERIFIED" or "ASSUMED: [reason]" inline -6. Once ALL prerequisites are verified, implement ALL unchecked tasks in order -7. Continue until every task in the phase is marked \`[x]\` - -## Checkbox States (Task items only) -- \`[ ]\` = incomplete/pending -- \`[x]\` = complete (skip) -- \`[?]\` = assumed complete, couldn't verify (skip) -- \`[!]\` = blocked (skip, note in scratchpad) - -Prerequisites use plain bullets with inline annotations, not checkboxes. - -## Implementation Steps (repeat for EACH task in the phase) -1. Read and understand the task -2. Implement it completely -3. Validate it works (run tests if applicable and double-check code) -4. Mark that task's checkbox as \`[x]\` in the phase file -5. **Create a git commit** for this task (see Git Policy below) -6. Output a TASK_COMPLETE marker for this task -7. Move to the next unchecked task in the same phase -8. When ALL tasks in the phase are done, mark the phase \`[x]\` in overview.md - -## Git Policy -**YOU are responsible for creating git commits after each task.** The orchestration system does NOT handle commits in phase mode. - -After completing each task: -\`\`\`bash -git add -A -git commit -m "" -\`\`\` - -**Commit message format:** -\`\`\` -git add -A -git commit -m "Task X.Y: description" -m "{{jiraTicketId}}" -m "AI Assisted" -\`\`\` -- With JIRA ticket: three \`-m\` flags (description, ticket ID, AI Assisted) -- Without JIRA ticket: two \`-m\` flags (description, AI Assisted) -- ALWAYS include "AI Assisted" as the final \`-m\` flag - -Replace X.Y with the actual task ID and description with a concise summary of what was implemented. - -## Completion Markers (REQUIRED FORMAT) -Output one of these **after each task** you complete: - -**PREREQ_COMPLETE: [prereq_id] - [description]** -Example: \`PREREQ_COMPLETE: P1.1 - Verified Phase 1 complete\` - -**PREREQ_ASSUMED: [prereq_id] - [description]** -Example: \`PREREQ_ASSUMED: P2.1 - Design approval (cannot verify)\` - -**TASK_COMPLETE: [task_id] - [task_description]** -Example: \`TASK_COMPLETE: 1.1 - Initialize project structure\` - -**TASK_BLOCKED: [task_id] - [reason]** -Example: \`TASK_BLOCKED: 2.3 - Missing API credentials\` -If a task is blocked, skip it and continue to the next task. - -After ALL tasks in the phase are complete (or blocked), output: -**PHASE_COMPLETE** - if only this phase is done -**LOOP_COMPLETE** - if ALL phases in overview.md are now marked complete - -## Scratchpad Management - -After completing each task, add a new entry at the **bottom** of \`{{specPath}}/.plan2code-loop/scratchpad.md\`. -Never edit, reorganize, or insert into existing content — only append new entries to the end of the file. - -Each entry should include: -- Task completed and Phase item reference -- Key decisions made and reasoning -- Files changed -- Any blockers or notes for next iteration - -Keep entries concise. Sacrifice grammar for concision. This file helps future iterations skip exploration. - -If key patterns or learnings were discovered, update \`./AGENTS.md\` if it exists. - -## Previous Session Context -{{scratchpadContent}} - ---- - -Remember: Complete ALL tasks in the current phase. Implement each task, commit it, output TASK_COMPLETE, then continue to the next. Stop only when the phase is done. -`; +export const LOOP_PROMPT_TEMPLATE = `# PLAN2CODE-LOOP: Autonomous Task Implementation + +## CRITICAL CONSTRAINT +**IMPLEMENT EXACTLY ONE TASK PER ITERATION.** +Do NOT implement multiple tasks. Do NOT complete an entire phase. +Find the FIRST unchecked task, implement ONLY that task, then STOP and report. + +## Project Information +- **Project Root:** \`{{projectRoot}}\` +- **Spec Location:** \`{{specPath}}\` +- Read \`AGENTS.md\` for project-specific guidance if available + +## IMPORTANT: File Locations +- Write ALL code files relative to the **project root** (\`{{projectRoot}}\`) +- The spec directory (\`{{specPath}}\`) is for documentation ONLY - never write code there +- Example: Create \`{{projectRoot}}/src/index.ts\`, NOT \`{{specPath}}/src/index.ts\` + +## Iteration +{{iteration}} of {{maxIterations}} + +## Task Discovery Process +1. Read \`{{specPath}}/overview.md\` to see all phases +2. Find the FIRST phase with an unchecked checkbox (\`- [ ]\` or \`- [/]\`) +3. Read that phase's file (e.g., \`phase-1.md\`) +4. Check the \`## Prerequisites\` section FIRST +5. Find the FIRST unverified prerequisite (no "VERIFIED" or "ASSUMED" annotation) + - If found, verify/complete it, then annotate "VERIFIED" or "ASSUMED: [reason]" inline +6. Only if ALL prerequisites are verified or assumed, find the FIRST unchecked task (\`- [ ]\`) +7. That is your ONE task - implement ONLY that task + +## Checkbox States (Task items only) +- \`[ ]\` = incomplete/pending (do the FIRST one you find) +- \`[x]\` = complete (skip) +- \`[?]\` = assumed complete, couldn't verify (skip) +- \`[!]\` = blocked (skip) + +Prerequisites use plain bullets with inline annotations, not checkboxes. + +## Implementation Steps +1. Read and understand the single task +2. Implement it completely +3. Validate it works (run tests if applicable and double-check code) +4. Mark ONLY that task's checkbox as \`[x]\` in the phase file +5. If that was the LAST task in the phase, also mark the phase \`[x]\` in overview.md +6. Output your completion marker and STOP + +## Git Policy +**DO NOT create git commits.** The orchestration system handles commits automatically after each task completion. Just implement the code and leave changes uncommitted. + +## Completion Markers (REQUIRED FORMAT) +Output exactly ONE of these at the end, including the task ID and description: + +**PREREQ_COMPLETE: [prereq_id] - [description]** +Example: \`PREREQ_COMPLETE: P1.1 - Verified Phase 1 complete\` + +**PREREQ_ASSUMED: [prereq_id] - [description]** +Example: \`PREREQ_ASSUMED: P2.1 - Design approval (cannot verify)\` + +**TASK_COMPLETE: [task_id] - [task_description]** +Example: \`TASK_COMPLETE: 1.1 - Initialize project structure\` + +**TASK_BLOCKED: [task_id] - [reason]** +Example: \`TASK_BLOCKED: 2.3 - Missing API credentials\` + +**LOOP_COMPLETE** +Use only when ALL phases in overview.md are marked complete. + +## Scratchpad Management + +After completing each task, add a new entry at the **bottom** of \`{{specPath}}/.plan2code-loop/scratchpad.md\`. +Never edit, reorganize, or insert into existing content — only append new entries to the end of the file. + +Each entry should include: +- Task completed and Phase item reference +- Key decisions made and reasoning +- Files changed +- Any blockers or notes for next iteration + +Keep entries concise. Sacrifice grammar for concision. This file helps future iterations skip exploration. + +If key patterns or learnings were discovered, update \`./AGENTS.md\` if it exists. + +## Previous Session Context +{{scratchpadContent}} + +--- + +Remember: ONE TASK ONLY. Find it, implement it, mark it done, output TASK_COMPLETE with the task ID and description, then stop. +`; + +export const LOOP_PROMPT_TEMPLATE_PHASE = `# PLAN2CODE-LOOP: Autonomous Phase Implementation + +## CRITICAL CONSTRAINT +**IMPLEMENT ALL REMAINING TASKS IN THE CURRENT PHASE.** +Find the first incomplete phase, then implement every remaining task in that phase before stopping. +Complete each task fully before moving to the next task within the phase. + +## Project Information +- **Project Root:** \`{{projectRoot}}\` +- **Spec Location:** \`{{specPath}}\` +- Read \`AGENTS.md\` for project-specific guidance if available + +## IMPORTANT: File Locations +- Write ALL code files relative to the **project root** (\`{{projectRoot}}\`) +- The spec directory (\`{{specPath}}\`) is for documentation ONLY - never write code there +- Example: Create \`{{projectRoot}}/src/index.ts\`, NOT \`{{specPath}}/src/index.ts\` + +## Iteration +{{iteration}} of {{maxIterations}} + +## Phase Discovery Process +1. Read \`{{specPath}}/overview.md\` to see all phases +2. Find the FIRST phase with an unchecked checkbox (\`- [ ]\` or \`- [/]\`) +3. Read that phase's file (e.g., \`phase-1.md\`) +4. Check the \`## Prerequisites\` section FIRST +5. Verify ALL unverified prerequisites first, in order + - Annotate each "VERIFIED" or "ASSUMED: [reason]" inline +6. Once ALL prerequisites are verified, implement ALL unchecked tasks in order +7. Continue until every task in the phase is marked \`[x]\` + +## Checkbox States (Task items only) +- \`[ ]\` = incomplete/pending +- \`[x]\` = complete (skip) +- \`[?]\` = assumed complete, couldn't verify (skip) +- \`[!]\` = blocked (skip, note in scratchpad) + +Prerequisites use plain bullets with inline annotations, not checkboxes. + +## Implementation Steps (repeat for EACH task in the phase) +1. Read and understand the task +2. Implement it completely +3. Validate it works (run tests if applicable and double-check code) +4. Mark that task's checkbox as \`[x]\` in the phase file +5. **Create a git commit** for this task (see Git Policy below) +6. Output a TASK_COMPLETE marker for this task +7. Move to the next unchecked task in the same phase +8. When ALL tasks in the phase are done, mark the phase \`[x]\` in overview.md + +## Git Policy +**YOU are responsible for creating git commits after each task.** The orchestration system does NOT handle commits in phase mode. + +After completing each task: +\`\`\`bash +git add -A +git commit -m "" +\`\`\` + +**Commit message format:** +\`\`\` +git add -A +git commit -m "Task X.Y: description" -m "{{jiraTicketId}}" -m "AI Assisted" +\`\`\` +- With JIRA ticket: three \`-m\` flags (description, ticket ID, AI Assisted) +- Without JIRA ticket: two \`-m\` flags (description, AI Assisted) +- ALWAYS include "AI Assisted" as the final \`-m\` flag + +Replace X.Y with the actual task ID and description with a concise summary of what was implemented. + +## Completion Markers (REQUIRED FORMAT) +Output one of these **after each task** you complete: + +**PREREQ_COMPLETE: [prereq_id] - [description]** +Example: \`PREREQ_COMPLETE: P1.1 - Verified Phase 1 complete\` + +**PREREQ_ASSUMED: [prereq_id] - [description]** +Example: \`PREREQ_ASSUMED: P2.1 - Design approval (cannot verify)\` + +**TASK_COMPLETE: [task_id] - [task_description]** +Example: \`TASK_COMPLETE: 1.1 - Initialize project structure\` + +**TASK_BLOCKED: [task_id] - [reason]** +Example: \`TASK_BLOCKED: 2.3 - Missing API credentials\` +If a task is blocked, skip it and continue to the next task. + +After ALL tasks in the phase are complete (or blocked), output: +**PHASE_COMPLETE** - if only this phase is done +**LOOP_COMPLETE** - if ALL phases in overview.md are now marked complete + +## Scratchpad Management + +After completing each task, add a new entry at the **bottom** of \`{{specPath}}/.plan2code-loop/scratchpad.md\`. +Never edit, reorganize, or insert into existing content — only append new entries to the end of the file. + +Each entry should include: +- Task completed and Phase item reference +- Key decisions made and reasoning +- Files changed +- Any blockers or notes for next iteration + +Keep entries concise. Sacrifice grammar for concision. This file helps future iterations skip exploration. + +If key patterns or learnings were discovered, update \`./AGENTS.md\` if it exists. + +## Previous Session Context +{{scratchpadContent}} + +--- + +Remember: Complete ALL tasks in the current phase. Implement each task, commit it, output TASK_COMPLETE, then continue to the next. Stop only when the phase is done. +`; diff --git a/plan2code-loop/src/spec/index.ts b/plan2code-loop/src/spec/index.ts index ba42c81..fe69bf0 100644 --- a/plan2code-loop/src/spec/index.ts +++ b/plan2code-loop/src/spec/index.ts @@ -1,4 +1,4 @@ -export { - detectSpecDirectories, - getSpecProgress, -} from './utils.js'; +export { + detectSpecDirectories, + getSpecProgress, +} from './utils.js'; diff --git a/plan2code-loop/src/spec/utils.ts b/plan2code-loop/src/spec/utils.ts index e9ea1ae..86b2cce 100644 --- a/plan2code-loop/src/spec/utils.ts +++ b/plan2code-loop/src/spec/utils.ts @@ -1,70 +1,70 @@ -import path from 'path'; -import fs from 'fs-extra'; - -/** - * Auto-detect spec directories in the project - * Looks for directories containing overview.md - */ -export async function detectSpecDirectories(cwd: string = process.cwd()): Promise { - const specsDir = path.join(cwd, 'specs'); - const specsDirs: string[] = []; - - if (await fs.pathExists(specsDir)) { - // Look for overview.md files in subdirectories - const entries = await fs.readdir(specsDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory()) { - const overviewPath = path.join(specsDir, entry.name, 'overview.md'); - if (await fs.pathExists(overviewPath)) { - specsDirs.push(path.join(specsDir, entry.name)); - } - } - } - - // Also check if specs/ itself contains overview.md - const rootOverview = path.join(specsDir, 'overview.md'); - if (await fs.pathExists(rootOverview)) { - specsDirs.push(specsDir); - } - } - - return specsDirs; -} - -/** - * Simple progress stats by counting phase-*.md files - * Used for CLI display only - LLM handles actual task discovery - */ -export async function getSpecProgress(specPath: string): Promise<{ - featureName: string; - totalPhases: number; -}> { - const overviewPath = path.join(specPath, 'overview.md'); - - // Extract feature name from overview.md - let featureName = path.basename(specPath); - try { - const overviewContent = await fs.readFile(overviewPath, 'utf8'); - const h1Match = overviewContent.match(/^#\s+(.+)$/m); - if (h1Match) { - featureName = h1Match[1].trim(); - } - } catch { - // Use directory name as fallback - } - - // Count phase-*.md files - let totalPhases = 0; - try { - const entries = await fs.readdir(specPath); - totalPhases = entries.filter(name => /^phase-\d+\.md$/i.test(name)).length; - } catch { - // Directory read failed - } - - return { - featureName, - totalPhases, - }; -} +import path from 'path'; +import fs from 'fs-extra'; + +/** + * Auto-detect spec directories in the project + * Looks for directories containing overview.md + */ +export async function detectSpecDirectories(cwd: string = process.cwd()): Promise { + const specsDir = path.join(cwd, 'specs'); + const specsDirs: string[] = []; + + if (await fs.pathExists(specsDir)) { + // Look for overview.md files in subdirectories + const entries = await fs.readdir(specsDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + const overviewPath = path.join(specsDir, entry.name, 'overview.md'); + if (await fs.pathExists(overviewPath)) { + specsDirs.push(path.join(specsDir, entry.name)); + } + } + } + + // Also check if specs/ itself contains overview.md + const rootOverview = path.join(specsDir, 'overview.md'); + if (await fs.pathExists(rootOverview)) { + specsDirs.push(specsDir); + } + } + + return specsDirs; +} + +/** + * Simple progress stats by counting phase-*.md files + * Used for CLI display only - LLM handles actual task discovery + */ +export async function getSpecProgress(specPath: string): Promise<{ + featureName: string; + totalPhases: number; +}> { + const overviewPath = path.join(specPath, 'overview.md'); + + // Extract feature name from overview.md + let featureName = path.basename(specPath); + try { + const overviewContent = await fs.readFile(overviewPath, 'utf8'); + const h1Match = overviewContent.match(/^#\s+(.+)$/m); + if (h1Match) { + featureName = h1Match[1].trim(); + } + } catch { + // Use directory name as fallback + } + + // Count phase-*.md files + let totalPhases = 0; + try { + const entries = await fs.readdir(specPath); + totalPhases = entries.filter(name => /^phase-\d+\.md$/i.test(name)).length; + } catch { + // Directory read failed + } + + return { + featureName, + totalPhases, + }; +} diff --git a/plan2code-loop/src/state/hash.ts b/plan2code-loop/src/state/hash.ts index cb1ea0f..c1e9e3f 100644 --- a/plan2code-loop/src/state/hash.ts +++ b/plan2code-loop/src/state/hash.ts @@ -1,9 +1,9 @@ -import { createHash } from 'crypto'; - -export function computeHash(content: string): string { - return createHash('sha256').update(content).digest('hex').slice(0, 16); -} - -export function hashesMatch(a: string, b: string): boolean { - return a === b; -} +import { createHash } from 'crypto'; + +export function computeHash(content: string): string { + return createHash('sha256').update(content).digest('hex').slice(0, 16); +} + +export function hashesMatch(a: string, b: string): boolean { + return a === b; +} diff --git a/plan2code-loop/src/state/index.ts b/plan2code-loop/src/state/index.ts index a9bb58f..a2fd21e 100644 --- a/plan2code-loop/src/state/index.ts +++ b/plan2code-loop/src/state/index.ts @@ -1,10 +1,10 @@ -export { - type SessionConfig, - type IterationLogEntry, - type SessionState, - type LoopMode, - DEFAULT_CONFIG, -} from './config.js'; - -export { StateManager } from './manager.js'; -export { computeHash, hashesMatch } from './hash.js'; +export { + type SessionConfig, + type IterationLogEntry, + type SessionState, + type LoopMode, + DEFAULT_CONFIG, +} from './config.js'; + +export { StateManager } from './manager.js'; +export { computeHash, hashesMatch } from './hash.js'; diff --git a/plan2code-loop/src/state/manager.ts b/plan2code-loop/src/state/manager.ts index c00b6f0..982ea49 100644 --- a/plan2code-loop/src/state/manager.ts +++ b/plan2code-loop/src/state/manager.ts @@ -1,231 +1,231 @@ -import path from 'path'; -import fs from 'fs-extra'; -import type { SessionConfig, IterationLogEntry, SessionState } from './config.js'; -import { DEFAULT_CONFIG } from './config.js'; -import { computeHash, hashesMatch } from './hash.js'; - -const SCRATCHPAD_TEMPLATE = `# Scratchpad - -Session notes appended by LLM during implementation. - ---- - -`; - -export class StateManager { - private readonly stateDir: string; - private readonly configPath: string; - private readonly scratchpadPath: string; - private readonly logPath: string; - private readonly hashPath: string; - private specPath: string | null = null; - - constructor(cwd: string = process.cwd()) { - // Default to project root - will be updated when spec is selected - this.stateDir = path.join(cwd, '.plan2code-loop'); - this.configPath = path.join(this.stateDir, 'config.json'); - this.scratchpadPath = path.join(this.stateDir, 'scratchpad.md'); - this.logPath = path.join(this.stateDir, 'iteration.log'); - this.hashPath = path.join(this.stateDir, 'spec.hash'); - } - - /** - * Set the spec path and update all state paths to be inside the spec directory - */ - setSpecPath(specPath: string): void { - this.specPath = specPath; - const stateDir = path.join(specPath, '.plan2code-loop'); - // Update all paths to be relative to spec directory - (this as any).stateDir = stateDir; - (this as any).configPath = path.join(stateDir, 'config.json'); - (this as any).scratchpadPath = path.join(stateDir, 'scratchpad.md'); - (this as any).logPath = path.join(stateDir, 'iteration.log'); - (this as any).hashPath = path.join(stateDir, 'spec.hash'); - } - - // Directory operations - - async ensureStateDir(): Promise { - const existed = await fs.pathExists(this.stateDir); - await fs.ensureDir(this.stateDir); - return existed; - } - - getStateDir(): string { - return this.stateDir; - } - - // Config operations - - async readConfig(): Promise { - try { - const content = await fs.readFile(this.configPath, 'utf8'); - return JSON.parse(content) as SessionConfig; - } catch { - return null; - } - } - - async writeConfig(config: SessionConfig): Promise { - await fs.writeFile( - this.configPath, - JSON.stringify(config, null, 2), - 'utf8' - ); - } - - async updateConfig(updates: Partial): Promise { - const existing = await this.readConfig(); - const updated = { ...DEFAULT_CONFIG, ...existing, ...updates } as SessionConfig; - await this.writeConfig(updated); - return updated; - } - - async incrementIteration(): Promise { - const config = await this.readConfig(); - if (!config) throw new Error('No session config found'); - config.currentIteration++; - await this.writeConfig(config); - return config.currentIteration; - } - - // Session state detection - - async hasExistingSession(): Promise { - const [configExists, scratchpadExists] = await Promise.all([ - fs.pathExists(this.configPath), - fs.pathExists(this.scratchpadPath), - ]); - return configExists || scratchpadExists; - } - - async detectSessionState(specPath: string): Promise { - // Ensure we're using the correct spec path - this.setSpecPath(specPath); - - const hasSession = await this.hasExistingSession(); - if (!hasSession) { - return 'new'; - } - - // Check if the spec path has changed - const existingConfig = await this.readConfig(); - if (existingConfig && existingConfig.specPath !== specPath) { - return 'changed'; - } - - // Check if spec content has changed (using hash of overview.md) - const specChanged = await this.hasSpecChanged(specPath); - return specChanged ? 'changed' : 'continue'; - } - - // Hash management for spec change detection - - async computeSpecHash(specPath: string): Promise { - const overviewPath = path.join(specPath, 'overview.md'); - try { - const content = await fs.readFile(overviewPath, 'utf8'); - return computeHash(content); - } catch { - return ''; - } - } - - async readStoredHash(): Promise { - try { - return await fs.readFile(this.hashPath, 'utf8'); - } catch { - return null; - } - } - - async storeHash(hash: string): Promise { - await fs.writeFile(this.hashPath, hash, 'utf8'); - } - - async hasSpecChanged(specPath: string): Promise { - const stored = await this.readStoredHash(); - if (!stored) return true; - const current = await this.computeSpecHash(specPath); - return !hashesMatch(stored, current); - } - - async updateSpecHash(specPath: string): Promise { - const hash = await this.computeSpecHash(specPath); - await this.storeHash(hash); - } - - // Scratchpad operations - - async initializeScratchpad(): Promise { - await fs.writeFile(this.scratchpadPath, SCRATCHPAD_TEMPLATE, 'utf8'); - } - - async readScratchpad(): Promise { - try { - return await fs.readFile(this.scratchpadPath, 'utf8'); - } catch { - return ''; - } - } - - async writeScratchpad(content: string): Promise { - await fs.writeFile(this.scratchpadPath, content, 'utf8'); - } - - // Iteration log operations - - async appendIterationLog(entry: IterationLogEntry): Promise { - const line = JSON.stringify(entry) + '\n'; - await fs.appendFile(this.logPath, line, 'utf8'); - } - - async readIterationLog(): Promise { - try { - const content = await fs.readFile(this.logPath, 'utf8'); - return content - .trim() - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line) as IterationLogEntry); - } catch { - return []; - } - } - - async getLastIteration(): Promise { - const log = await this.readIterationLog(); - return log.length > 0 ? log[log.length - 1] : null; - } - - // State clearing and initialization - - async clearState(): Promise { - const filesToDelete = [ - this.scratchpadPath, - this.configPath, - this.logPath, - this.hashPath, - ]; - - await Promise.all( - filesToDelete.map((file) => fs.remove(file).catch(() => {})) - ); - } - - async initializeNewSession(config: SessionConfig): Promise { - // Ensure spec path is set before initializing - this.setSpecPath(config.specPath); - - await this.clearState(); - await this.ensureStateDir(); - await this.initializeScratchpad(); - await this.writeConfig({ - ...config, - startedAt: new Date().toISOString(), - currentIteration: 0, - }); - const hash = await this.computeSpecHash(config.specPath); - await this.storeHash(hash); - } -} +import path from 'path'; +import fs from 'fs-extra'; +import type { SessionConfig, IterationLogEntry, SessionState } from './config.js'; +import { DEFAULT_CONFIG } from './config.js'; +import { computeHash, hashesMatch } from './hash.js'; + +const SCRATCHPAD_TEMPLATE = `# Scratchpad + +Session notes appended by LLM during implementation. + +--- + +`; + +export class StateManager { + private readonly stateDir: string; + private readonly configPath: string; + private readonly scratchpadPath: string; + private readonly logPath: string; + private readonly hashPath: string; + private specPath: string | null = null; + + constructor(cwd: string = process.cwd()) { + // Default to project root - will be updated when spec is selected + this.stateDir = path.join(cwd, '.plan2code-loop'); + this.configPath = path.join(this.stateDir, 'config.json'); + this.scratchpadPath = path.join(this.stateDir, 'scratchpad.md'); + this.logPath = path.join(this.stateDir, 'iteration.log'); + this.hashPath = path.join(this.stateDir, 'spec.hash'); + } + + /** + * Set the spec path and update all state paths to be inside the spec directory + */ + setSpecPath(specPath: string): void { + this.specPath = specPath; + const stateDir = path.join(specPath, '.plan2code-loop'); + // Update all paths to be relative to spec directory + (this as any).stateDir = stateDir; + (this as any).configPath = path.join(stateDir, 'config.json'); + (this as any).scratchpadPath = path.join(stateDir, 'scratchpad.md'); + (this as any).logPath = path.join(stateDir, 'iteration.log'); + (this as any).hashPath = path.join(stateDir, 'spec.hash'); + } + + // Directory operations + + async ensureStateDir(): Promise { + const existed = await fs.pathExists(this.stateDir); + await fs.ensureDir(this.stateDir); + return existed; + } + + getStateDir(): string { + return this.stateDir; + } + + // Config operations + + async readConfig(): Promise { + try { + const content = await fs.readFile(this.configPath, 'utf8'); + return JSON.parse(content) as SessionConfig; + } catch { + return null; + } + } + + async writeConfig(config: SessionConfig): Promise { + await fs.writeFile( + this.configPath, + JSON.stringify(config, null, 2), + 'utf8' + ); + } + + async updateConfig(updates: Partial): Promise { + const existing = await this.readConfig(); + const updated = { ...DEFAULT_CONFIG, ...existing, ...updates } as SessionConfig; + await this.writeConfig(updated); + return updated; + } + + async incrementIteration(): Promise { + const config = await this.readConfig(); + if (!config) throw new Error('No session config found'); + config.currentIteration++; + await this.writeConfig(config); + return config.currentIteration; + } + + // Session state detection + + async hasExistingSession(): Promise { + const [configExists, scratchpadExists] = await Promise.all([ + fs.pathExists(this.configPath), + fs.pathExists(this.scratchpadPath), + ]); + return configExists || scratchpadExists; + } + + async detectSessionState(specPath: string): Promise { + // Ensure we're using the correct spec path + this.setSpecPath(specPath); + + const hasSession = await this.hasExistingSession(); + if (!hasSession) { + return 'new'; + } + + // Check if the spec path has changed + const existingConfig = await this.readConfig(); + if (existingConfig && existingConfig.specPath !== specPath) { + return 'changed'; + } + + // Check if spec content has changed (using hash of overview.md) + const specChanged = await this.hasSpecChanged(specPath); + return specChanged ? 'changed' : 'continue'; + } + + // Hash management for spec change detection + + async computeSpecHash(specPath: string): Promise { + const overviewPath = path.join(specPath, 'overview.md'); + try { + const content = await fs.readFile(overviewPath, 'utf8'); + return computeHash(content); + } catch { + return ''; + } + } + + async readStoredHash(): Promise { + try { + return await fs.readFile(this.hashPath, 'utf8'); + } catch { + return null; + } + } + + async storeHash(hash: string): Promise { + await fs.writeFile(this.hashPath, hash, 'utf8'); + } + + async hasSpecChanged(specPath: string): Promise { + const stored = await this.readStoredHash(); + if (!stored) return true; + const current = await this.computeSpecHash(specPath); + return !hashesMatch(stored, current); + } + + async updateSpecHash(specPath: string): Promise { + const hash = await this.computeSpecHash(specPath); + await this.storeHash(hash); + } + + // Scratchpad operations + + async initializeScratchpad(): Promise { + await fs.writeFile(this.scratchpadPath, SCRATCHPAD_TEMPLATE, 'utf8'); + } + + async readScratchpad(): Promise { + try { + return await fs.readFile(this.scratchpadPath, 'utf8'); + } catch { + return ''; + } + } + + async writeScratchpad(content: string): Promise { + await fs.writeFile(this.scratchpadPath, content, 'utf8'); + } + + // Iteration log operations + + async appendIterationLog(entry: IterationLogEntry): Promise { + const line = JSON.stringify(entry) + '\n'; + await fs.appendFile(this.logPath, line, 'utf8'); + } + + async readIterationLog(): Promise { + try { + const content = await fs.readFile(this.logPath, 'utf8'); + return content + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as IterationLogEntry); + } catch { + return []; + } + } + + async getLastIteration(): Promise { + const log = await this.readIterationLog(); + return log.length > 0 ? log[log.length - 1] : null; + } + + // State clearing and initialization + + async clearState(): Promise { + const filesToDelete = [ + this.scratchpadPath, + this.configPath, + this.logPath, + this.hashPath, + ]; + + await Promise.all( + filesToDelete.map((file) => fs.remove(file).catch(() => {})) + ); + } + + async initializeNewSession(config: SessionConfig): Promise { + // Ensure spec path is set before initializing + this.setSpecPath(config.specPath); + + await this.clearState(); + await this.ensureStateDir(); + await this.initializeScratchpad(); + await this.writeConfig({ + ...config, + startedAt: new Date().toISOString(), + currentIteration: 0, + }); + const hash = await this.computeSpecHash(config.specPath); + await this.storeHash(hash); + } +} diff --git a/plan2code-loop/src/utils/completion.ts b/plan2code-loop/src/utils/completion.ts index 2bd632c..4040024 100644 --- a/plan2code-loop/src/utils/completion.ts +++ b/plan2code-loop/src/utils/completion.ts @@ -1,169 +1,169 @@ -// Completion markers for plan2code-loop -const COMPLETION_MARKERS = ['TASK_COMPLETE', 'TASK_BLOCKED', 'LOOP_COMPLETE', 'PREREQ_COMPLETE', 'PREREQ_ASSUMED'] as const; - -export type CompletionMarker = (typeof COMPLETION_MARKERS)[number]; - -export interface CompletionCheckResult { - completed: boolean; - marker?: CompletionMarker; - taskId?: string; // e.g., "1.1", "2.3" - taskName?: string; // e.g., "Initialize project structure" - reason?: string; // For TASK_BLOCKED: reason -} - -export function checkForCompletion(output: string): CompletionCheckResult { - // Check for LOOP_COMPLETE first (highest priority) - if (output.includes('LOOP_COMPLETE')) { - return { completed: true, marker: 'LOOP_COMPLETE' }; - } - - // Check for TASK_COMPLETE with task info - // Format: TASK_COMPLETE: 1.1 - Task description - // Or: TASK_COMPLETE: 1.1: Task description - // Or: TASK_COMPLETE[1.1]: Task description - const completeMatch = output.match(/TASK_COMPLETE[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/i); - if (completeMatch) { - return { - completed: true, - marker: 'TASK_COMPLETE', - taskId: completeMatch[1], - taskName: completeMatch[2].trim(), - }; - } - - // Simple TASK_COMPLETE without structured info - try to extract task from context - if (output.includes('TASK_COMPLETE')) { - // Try to find task info nearby - const taskMatch = output.match(/(?:task|completed?)\s+(\d+\.\d+)[:\s]+([^\n]{5,80})/i); - return { - completed: true, - marker: 'TASK_COMPLETE', - taskId: taskMatch?.[1], - taskName: taskMatch?.[2]?.trim(), - }; - } - - // Check for PREREQ_COMPLETE with prereq info - // Format: PREREQ_COMPLETE: P1.1 - Verified Phase 1 complete - const prereqMatch = output.match(/PREREQ_COMPLETE[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/i); - if (prereqMatch) { - return { - completed: true, - marker: 'PREREQ_COMPLETE', - taskId: prereqMatch[1], - taskName: prereqMatch[2].trim(), - }; - } - - // Check for PREREQ_ASSUMED with prereq info - // Format: PREREQ_ASSUMED: P2.1 - Design approval (cannot verify) - const assumedMatch = output.match(/PREREQ_ASSUMED[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/i); - if (assumedMatch) { - return { - completed: true, - marker: 'PREREQ_ASSUMED', - taskId: assumedMatch[1], - taskName: assumedMatch[2].trim(), - }; - } - - // Check for TASK_BLOCKED with task info and reason - // Format: TASK_BLOCKED: 1.1 - Reason why blocked - const blockedWithTaskMatch = output.match(/TASK_BLOCKED[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/i); - if (blockedWithTaskMatch) { - return { - completed: true, - marker: 'TASK_BLOCKED', - taskId: blockedWithTaskMatch[1], - reason: blockedWithTaskMatch[2].trim(), - }; - } - - // TASK_BLOCKED with just reason (no task ID) - const blockedMatch = output.match(/TASK_BLOCKED:\s*(.+?)(?:\n|$)/); - if (blockedMatch) { - return { - completed: true, - marker: 'TASK_BLOCKED', - reason: blockedMatch[1].trim() - }; - } - - // Simple TASK_BLOCKED without any info - if (output.includes('TASK_BLOCKED')) { - return { completed: true, marker: 'TASK_BLOCKED', reason: 'No reason provided' }; - } - - return { completed: false }; -} - -/** - * Extract ALL completion markers from output (for phase mode). - * Returns an array of all TASK_COMPLETE/TASK_BLOCKED markers found, - * plus whether LOOP_COMPLETE or PHASE_COMPLETE was present. - */ -export function checkForAllCompletions(output: string): { - tasks: CompletionCheckResult[]; - loopComplete: boolean; - phaseComplete: boolean; -} { - const tasks: CompletionCheckResult[] = []; - let loopComplete = false; - let phaseComplete = false; - - if (output.includes('LOOP_COMPLETE')) { - loopComplete = true; - } - - if (output.includes('PHASE_COMPLETE')) { - phaseComplete = true; - } - - // Find all TASK_COMPLETE markers with task info - // Format: TASK_COMPLETE: 1.1 - Task description - const completeRegex = /TASK_COMPLETE[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/gi; - let match: RegExpExecArray | null; - while ((match = completeRegex.exec(output)) !== null) { - tasks.push({ - completed: true, - marker: 'TASK_COMPLETE', - taskId: match[1], - taskName: match[2].trim(), - }); - } - - // Find all TASK_BLOCKED markers with task info - const blockedRegex = /TASK_BLOCKED[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/gi; - while ((match = blockedRegex.exec(output)) !== null) { - tasks.push({ - completed: true, - marker: 'TASK_BLOCKED', - taskId: match[1], - reason: match[2].trim(), - }); - } - - // Find PREREQ_COMPLETE markers - const prereqRegex = /PREREQ_COMPLETE[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/gi; - while ((match = prereqRegex.exec(output)) !== null) { - tasks.push({ - completed: true, - marker: 'PREREQ_COMPLETE', - taskId: match[1], - taskName: match[2].trim(), - }); - } - - // Find PREREQ_ASSUMED markers - const assumedRegex = /PREREQ_ASSUMED[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/gi; - while ((match = assumedRegex.exec(output)) !== null) { - tasks.push({ - completed: true, - marker: 'PREREQ_ASSUMED', - taskId: match[1], - taskName: match[2].trim(), - }); - } - - return { tasks, loopComplete, phaseComplete }; -} +// Completion markers for plan2code-loop +const COMPLETION_MARKERS = ['TASK_COMPLETE', 'TASK_BLOCKED', 'LOOP_COMPLETE', 'PREREQ_COMPLETE', 'PREREQ_ASSUMED'] as const; + +export type CompletionMarker = (typeof COMPLETION_MARKERS)[number]; + +export interface CompletionCheckResult { + completed: boolean; + marker?: CompletionMarker; + taskId?: string; // e.g., "1.1", "2.3" + taskName?: string; // e.g., "Initialize project structure" + reason?: string; // For TASK_BLOCKED: reason +} + +export function checkForCompletion(output: string): CompletionCheckResult { + // Check for LOOP_COMPLETE first (highest priority) + if (output.includes('LOOP_COMPLETE')) { + return { completed: true, marker: 'LOOP_COMPLETE' }; + } + + // Check for TASK_COMPLETE with task info + // Format: TASK_COMPLETE: 1.1 - Task description + // Or: TASK_COMPLETE: 1.1: Task description + // Or: TASK_COMPLETE[1.1]: Task description + const completeMatch = output.match(/TASK_COMPLETE[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/i); + if (completeMatch) { + return { + completed: true, + marker: 'TASK_COMPLETE', + taskId: completeMatch[1], + taskName: completeMatch[2].trim(), + }; + } + + // Simple TASK_COMPLETE without structured info - try to extract task from context + if (output.includes('TASK_COMPLETE')) { + // Try to find task info nearby + const taskMatch = output.match(/(?:task|completed?)\s+(\d+\.\d+)[:\s]+([^\n]{5,80})/i); + return { + completed: true, + marker: 'TASK_COMPLETE', + taskId: taskMatch?.[1], + taskName: taskMatch?.[2]?.trim(), + }; + } + + // Check for PREREQ_COMPLETE with prereq info + // Format: PREREQ_COMPLETE: P1.1 - Verified Phase 1 complete + const prereqMatch = output.match(/PREREQ_COMPLETE[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/i); + if (prereqMatch) { + return { + completed: true, + marker: 'PREREQ_COMPLETE', + taskId: prereqMatch[1], + taskName: prereqMatch[2].trim(), + }; + } + + // Check for PREREQ_ASSUMED with prereq info + // Format: PREREQ_ASSUMED: P2.1 - Design approval (cannot verify) + const assumedMatch = output.match(/PREREQ_ASSUMED[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/i); + if (assumedMatch) { + return { + completed: true, + marker: 'PREREQ_ASSUMED', + taskId: assumedMatch[1], + taskName: assumedMatch[2].trim(), + }; + } + + // Check for TASK_BLOCKED with task info and reason + // Format: TASK_BLOCKED: 1.1 - Reason why blocked + const blockedWithTaskMatch = output.match(/TASK_BLOCKED[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/i); + if (blockedWithTaskMatch) { + return { + completed: true, + marker: 'TASK_BLOCKED', + taskId: blockedWithTaskMatch[1], + reason: blockedWithTaskMatch[2].trim(), + }; + } + + // TASK_BLOCKED with just reason (no task ID) + const blockedMatch = output.match(/TASK_BLOCKED:\s*(.+?)(?:\n|$)/); + if (blockedMatch) { + return { + completed: true, + marker: 'TASK_BLOCKED', + reason: blockedMatch[1].trim() + }; + } + + // Simple TASK_BLOCKED without any info + if (output.includes('TASK_BLOCKED')) { + return { completed: true, marker: 'TASK_BLOCKED', reason: 'No reason provided' }; + } + + return { completed: false }; +} + +/** + * Extract ALL completion markers from output (for phase mode). + * Returns an array of all TASK_COMPLETE/TASK_BLOCKED markers found, + * plus whether LOOP_COMPLETE or PHASE_COMPLETE was present. + */ +export function checkForAllCompletions(output: string): { + tasks: CompletionCheckResult[]; + loopComplete: boolean; + phaseComplete: boolean; +} { + const tasks: CompletionCheckResult[] = []; + let loopComplete = false; + let phaseComplete = false; + + if (output.includes('LOOP_COMPLETE')) { + loopComplete = true; + } + + if (output.includes('PHASE_COMPLETE')) { + phaseComplete = true; + } + + // Find all TASK_COMPLETE markers with task info + // Format: TASK_COMPLETE: 1.1 - Task description + const completeRegex = /TASK_COMPLETE[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/gi; + let match: RegExpExecArray | null; + while ((match = completeRegex.exec(output)) !== null) { + tasks.push({ + completed: true, + marker: 'TASK_COMPLETE', + taskId: match[1], + taskName: match[2].trim(), + }); + } + + // Find all TASK_BLOCKED markers with task info + const blockedRegex = /TASK_BLOCKED[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/gi; + while ((match = blockedRegex.exec(output)) !== null) { + tasks.push({ + completed: true, + marker: 'TASK_BLOCKED', + taskId: match[1], + reason: match[2].trim(), + }); + } + + // Find PREREQ_COMPLETE markers + const prereqRegex = /PREREQ_COMPLETE[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/gi; + while ((match = prereqRegex.exec(output)) !== null) { + tasks.push({ + completed: true, + marker: 'PREREQ_COMPLETE', + taskId: match[1], + taskName: match[2].trim(), + }); + } + + // Find PREREQ_ASSUMED markers + const assumedRegex = /PREREQ_ASSUMED[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/gi; + while ((match = assumedRegex.exec(output)) !== null) { + tasks.push({ + completed: true, + marker: 'PREREQ_ASSUMED', + taskId: match[1], + taskName: match[2].trim(), + }); + } + + return { tasks, loopComplete, phaseComplete }; +} diff --git a/plan2code-loop/src/utils/git.ts b/plan2code-loop/src/utils/git.ts index fabf340..2787507 100644 --- a/plan2code-loop/src/utils/git.ts +++ b/plan2code-loop/src/utils/git.ts @@ -1,136 +1,136 @@ -import { execa } from 'execa'; -import { existsSync, readFileSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { logger } from './logger.js'; - -export interface GitCommitOptions { - taskName: string; - jiraTicketId?: string; - cwd?: string; -} - -/** - * Check if directory is a git repository - */ -export async function isGitRepo(cwd: string): Promise { - const result = await execa('git', ['rev-parse', '--git-dir'], { cwd, reject: false }); - return result.exitCode === 0; -} - -/** - * Initialize a git repository if one doesn't exist - */ -export async function ensureGitRepo(cwd: string): Promise { - if (await isGitRepo(cwd)) { - return true; - } - - logger.info('Initializing git repository...'); - const result = await execa('git', ['init'], { cwd, reject: false }); - - if (result.exitCode !== 0) { - logger.error(`Failed to initialize git repo: ${result.stderr}`); - return false; - } - - logger.success('Git repository initialized'); - return true; -} - -/** - * Required entries for the .gitignore file - */ -const REQUIRED_GITIGNORE_ENTRIES = ['specs/', 'specs--completed/', '.plan2code-loop', '.plan2code-metrics', 'nul', 'node_modules/']; - -/** - * Ensure .gitignore exists with required entries - */ -export function ensureGitignore(cwd: string): void { - const gitignorePath = join(cwd, '.gitignore'); - let content = ''; - - if (existsSync(gitignorePath)) { - content = readFileSync(gitignorePath, 'utf-8'); - } - - const lines = content.split('\n').map(line => line.trim()); - const missingEntries = REQUIRED_GITIGNORE_ENTRIES.filter(entry => !lines.includes(entry)); - - if (missingEntries.length > 0) { - const needsNewline = content.length > 0 && !content.endsWith('\n'); - const newContent = content + (needsNewline ? '\n' : '') + missingEntries.join('\n') + '\n'; - writeFileSync(gitignorePath, newContent); - logger.dim(`Added to .gitignore: ${missingEntries.join(', ')}`); - } -} - -/** - * Create a local git commit for a completed task - */ -export async function createTaskCommit(options: GitCommitOptions): Promise { - const { taskName, jiraTicketId, cwd = process.cwd() } = options; - - logger.dim(`Git commit check in: ${cwd}`); - - try { - // Ensure we have a git repo - if (!await ensureGitRepo(cwd)) { - return false; - } - - // Ensure .gitignore exists with required entries - ensureGitignore(cwd); - - // Check if there are any changes to commit - const statusResult = await execa('git', ['status', '--porcelain'], { cwd, reject: false }); - - // Debug: show what git status returned - if (statusResult.stdout?.trim()) { - logger.dim(`Git status found changes:\n${statusResult.stdout.slice(0, 500)}`); - } - - if (statusResult.exitCode !== 0) { - logger.error(`Git status failed: ${statusResult.stderr}`); - return false; - } - - if (!statusResult.stdout?.trim()) { - logger.dim('No changes to commit'); - return false; - } - - // Stage all changes - const addResult = await execa('git', ['add', '-A'], { cwd, reject: false }); - if (addResult.exitCode !== 0) { - logger.error(`Git add failed: ${addResult.stderr}`); - return false; - } - - // Build commit message - let commitMessage = taskName; - if (jiraTicketId) { - commitMessage = `${taskName}\n\n${jiraTicketId}\nAI Assisted`; - } else { - commitMessage = `${taskName}\n\nAI Assisted`; - } - - // Create the commit - const commitResult = await execa('git', ['commit', '-m', commitMessage], { cwd, reject: false }); - - if (commitResult.exitCode !== 0) { - // Check if it's just "nothing to commit" vs actual error - if (commitResult.stdout?.includes('nothing to commit') || commitResult.stderr?.includes('nothing to commit')) { - logger.dim('No changes to commit'); - return false; - } - logger.error(`Git commit failed: ${commitResult.stderr || commitResult.stdout}`); - return false; - } - - logger.success(`Created commit: ${taskName}${jiraTicketId ? ` (${jiraTicketId})` : ''}`); - return true; - } catch (error) { - logger.error(`Failed to create commit: ${error instanceof Error ? error.message : String(error)}`); - return false; - } -} +import { execa } from 'execa'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { logger } from './logger.js'; + +export interface GitCommitOptions { + taskName: string; + jiraTicketId?: string; + cwd?: string; +} + +/** + * Check if directory is a git repository + */ +export async function isGitRepo(cwd: string): Promise { + const result = await execa('git', ['rev-parse', '--git-dir'], { cwd, reject: false }); + return result.exitCode === 0; +} + +/** + * Initialize a git repository if one doesn't exist + */ +export async function ensureGitRepo(cwd: string): Promise { + if (await isGitRepo(cwd)) { + return true; + } + + logger.info('Initializing git repository...'); + const result = await execa('git', ['init'], { cwd, reject: false }); + + if (result.exitCode !== 0) { + logger.error(`Failed to initialize git repo: ${result.stderr}`); + return false; + } + + logger.success('Git repository initialized'); + return true; +} + +/** + * Required entries for the .gitignore file + */ +const REQUIRED_GITIGNORE_ENTRIES = ['specs/', 'specs--completed/', '.plan2code-loop', '.plan2code-metrics', 'nul', 'node_modules/']; + +/** + * Ensure .gitignore exists with required entries + */ +export function ensureGitignore(cwd: string): void { + const gitignorePath = join(cwd, '.gitignore'); + let content = ''; + + if (existsSync(gitignorePath)) { + content = readFileSync(gitignorePath, 'utf-8'); + } + + const lines = content.split('\n').map(line => line.trim()); + const missingEntries = REQUIRED_GITIGNORE_ENTRIES.filter(entry => !lines.includes(entry)); + + if (missingEntries.length > 0) { + const needsNewline = content.length > 0 && !content.endsWith('\n'); + const newContent = content + (needsNewline ? '\n' : '') + missingEntries.join('\n') + '\n'; + writeFileSync(gitignorePath, newContent); + logger.dim(`Added to .gitignore: ${missingEntries.join(', ')}`); + } +} + +/** + * Create a local git commit for a completed task + */ +export async function createTaskCommit(options: GitCommitOptions): Promise { + const { taskName, jiraTicketId, cwd = process.cwd() } = options; + + logger.dim(`Git commit check in: ${cwd}`); + + try { + // Ensure we have a git repo + if (!await ensureGitRepo(cwd)) { + return false; + } + + // Ensure .gitignore exists with required entries + ensureGitignore(cwd); + + // Check if there are any changes to commit + const statusResult = await execa('git', ['status', '--porcelain'], { cwd, reject: false }); + + // Debug: show what git status returned + if (statusResult.stdout?.trim()) { + logger.dim(`Git status found changes:\n${statusResult.stdout.slice(0, 500)}`); + } + + if (statusResult.exitCode !== 0) { + logger.error(`Git status failed: ${statusResult.stderr}`); + return false; + } + + if (!statusResult.stdout?.trim()) { + logger.dim('No changes to commit'); + return false; + } + + // Stage all changes + const addResult = await execa('git', ['add', '-A'], { cwd, reject: false }); + if (addResult.exitCode !== 0) { + logger.error(`Git add failed: ${addResult.stderr}`); + return false; + } + + // Build commit message + let commitMessage = taskName; + if (jiraTicketId) { + commitMessage = `${taskName}\n\n${jiraTicketId}\nAI Assisted`; + } else { + commitMessage = `${taskName}\n\nAI Assisted`; + } + + // Create the commit + const commitResult = await execa('git', ['commit', '-m', commitMessage], { cwd, reject: false }); + + if (commitResult.exitCode !== 0) { + // Check if it's just "nothing to commit" vs actual error + if (commitResult.stdout?.includes('nothing to commit') || commitResult.stderr?.includes('nothing to commit')) { + logger.dim('No changes to commit'); + return false; + } + logger.error(`Git commit failed: ${commitResult.stderr || commitResult.stdout}`); + return false; + } + + logger.success(`Created commit: ${taskName}${jiraTicketId ? ` (${jiraTicketId})` : ''}`); + return true; + } catch (error) { + logger.error(`Failed to create commit: ${error instanceof Error ? error.message : String(error)}`); + return false; + } +} diff --git a/plan2code-loop/src/utils/index.ts b/plan2code-loop/src/utils/index.ts index 1a25328..0ba8adf 100644 --- a/plan2code-loop/src/utils/index.ts +++ b/plan2code-loop/src/utils/index.ts @@ -1,19 +1,19 @@ -export { logger, MASCOT, type Logger } from './logger.js'; -export { - checkForCompletion, - checkForAllCompletions, - type CompletionMarker, - type CompletionCheckResult -} from './completion.js'; -export { - executeCommand, - type ExecuteOptions, - type ExecuteResult -} from './process.js'; -export { - createTaskCommit, - isGitRepo, - ensureGitRepo, - ensureGitignore, - type GitCommitOptions -} from './git.js'; +export { logger, MASCOT, type Logger } from './logger.js'; +export { + checkForCompletion, + checkForAllCompletions, + type CompletionMarker, + type CompletionCheckResult +} from './completion.js'; +export { + executeCommand, + type ExecuteOptions, + type ExecuteResult +} from './process.js'; +export { + createTaskCommit, + isGitRepo, + ensureGitRepo, + ensureGitignore, + type GitCommitOptions +} from './git.js'; diff --git a/plan2code-loop/src/utils/process.ts b/plan2code-loop/src/utils/process.ts index de5a521..b015506 100644 --- a/plan2code-loop/src/utils/process.ts +++ b/plan2code-loop/src/utils/process.ts @@ -1,81 +1,81 @@ -import { execa, type Options as ExecaOptions } from 'execa'; - -const MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10MB - -function truncateOutput(output: string, maxSize: number): string { - if (output.length > maxSize) { - return output.slice(0, maxSize) + '\n...[truncated]'; - } - return output; -} - -export interface ExecuteOptions { - command: string; - args: string[]; - cwd: string; - timeout: number; // milliseconds - env?: Record; - signal?: AbortSignal; // For cancellation - stdin?: string; // Input to pass via stdin as string - stdinFile?: string; // Path to file to pipe as stdin -} - -export interface ExecuteResult { - stdout: string; - stderr: string; - exitCode: number; - timedOut: boolean; - cancelled: boolean; - duration: number; // milliseconds -} - -export async function executeCommand( - options: ExecuteOptions -): Promise { - const startTime = Date.now(); - - try { - const execaOptions: ExecaOptions = { - cwd: options.cwd, - timeout: options.timeout, - env: { ...process.env, ...options.env }, - reject: false, - all: true, - }; - - // Add cancellation signal if provided - if (options.signal) { - (execaOptions as any).cancelSignal = options.signal; - } - - // Add stdin input if provided (string or file) - if (options.stdin) { - (execaOptions as any).input = options.stdin; - } else if (options.stdinFile) { - (execaOptions as any).inputFile = options.stdinFile; - } - - const result = await execa(options.command, options.args, execaOptions); - - return { - stdout: truncateOutput(result.stdout || '', MAX_OUTPUT_SIZE), - stderr: truncateOutput(result.stderr || '', MAX_OUTPUT_SIZE), - exitCode: result.exitCode ?? 1, - timedOut: result.timedOut ?? false, - cancelled: result.isCanceled ?? false, - duration: Date.now() - startTime, - }; - } catch (error: any) { - // Check if this was a cancellation - const isCancelled = error?.isCanceled || options.signal?.aborted; - - return { - stdout: error?.stdout || '', - stderr: error?.stderr || (error instanceof Error ? error.message : String(error)), - exitCode: isCancelled ? -1 : 1, - timedOut: false, - cancelled: isCancelled, - duration: Date.now() - startTime, - }; - } -} +import { execa, type Options as ExecaOptions } from 'execa'; + +const MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10MB + +function truncateOutput(output: string, maxSize: number): string { + if (output.length > maxSize) { + return output.slice(0, maxSize) + '\n...[truncated]'; + } + return output; +} + +export interface ExecuteOptions { + command: string; + args: string[]; + cwd: string; + timeout: number; // milliseconds + env?: Record; + signal?: AbortSignal; // For cancellation + stdin?: string; // Input to pass via stdin as string + stdinFile?: string; // Path to file to pipe as stdin +} + +export interface ExecuteResult { + stdout: string; + stderr: string; + exitCode: number; + timedOut: boolean; + cancelled: boolean; + duration: number; // milliseconds +} + +export async function executeCommand( + options: ExecuteOptions +): Promise { + const startTime = Date.now(); + + try { + const execaOptions: ExecaOptions = { + cwd: options.cwd, + timeout: options.timeout, + env: { ...process.env, ...options.env }, + reject: false, + all: true, + }; + + // Add cancellation signal if provided + if (options.signal) { + (execaOptions as any).cancelSignal = options.signal; + } + + // Add stdin input if provided (string or file) + if (options.stdin) { + (execaOptions as any).input = options.stdin; + } else if (options.stdinFile) { + (execaOptions as any).inputFile = options.stdinFile; + } + + const result = await execa(options.command, options.args, execaOptions); + + return { + stdout: truncateOutput(result.stdout || '', MAX_OUTPUT_SIZE), + stderr: truncateOutput(result.stderr || '', MAX_OUTPUT_SIZE), + exitCode: result.exitCode ?? 1, + timedOut: result.timedOut ?? false, + cancelled: result.isCanceled ?? false, + duration: Date.now() - startTime, + }; + } catch (error: any) { + // Check if this was a cancellation + const isCancelled = error?.isCanceled || options.signal?.aborted; + + return { + stdout: error?.stdout || '', + stderr: error?.stderr || (error instanceof Error ? error.message : String(error)), + exitCode: isCancelled ? -1 : 1, + timedOut: false, + cancelled: isCancelled, + duration: Date.now() - startTime, + }; + } +} diff --git a/plan2code-loop/tsconfig.json b/plan2code-loop/tsconfig.json index c8f5baa..dc96b58 100644 --- a/plan2code-loop/tsconfig.json +++ b/plan2code-loop/tsconfig.json @@ -1,20 +1,20 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022"], - "outDir": "dist", - "rootDir": ".", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/plan2code-loop/tsup.config.ts b/plan2code-loop/tsup.config.ts index e402377..55909c3 100644 --- a/plan2code-loop/tsup.config.ts +++ b/plan2code-loop/tsup.config.ts @@ -1,15 +1,15 @@ -import { defineConfig } from 'tsup'; - -export default defineConfig({ - entry: { - 'bin/plan2code-loop': 'src/bin/plan2code-loop.ts', - index: 'src/index.ts', - }, - format: ['esm'], - dts: false, - clean: true, - sourcemap: true, - banner: { - js: '#!/usr/bin/env node', - }, -}); +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + 'bin/plan2code-loop': 'src/bin/plan2code-loop.ts', + index: 'src/index.ts', + }, + format: ['esm'], + dts: false, + clean: true, + sourcemap: true, + banner: { + js: '#!/usr/bin/env node', + }, +});