This commit is contained in:
2026-01-27 12:11:00 -08:00
parent 474e591f58
commit 34704eaf84
43 changed files with 3603 additions and 217 deletions
+74
View File
@@ -0,0 +1,74 @@
// Completion markers for plan2code-loop
const COMPLETION_MARKERS = ['TASK_COMPLETE', 'TASK_BLOCKED', 'LOOP_COMPLETE'] 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 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 };
}
+134
View File
@@ -0,0 +1,134 @@
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<boolean> {
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<boolean> {
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/', 'nul'];
/**
* 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<boolean> {
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}`;
}
// 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;
}
}
+17
View File
@@ -0,0 +1,17 @@
export { logger, MASCOT, type Logger } from './logger.js';
export {
checkForCompletion,
type CompletionMarker,
type CompletionCheckResult
} from './completion.js';
export {
executeCommand,
type ExecuteOptions,
type ExecuteResult
} from './process.js';
export {
createTaskCommit,
isGitRepo,
ensureGitRepo,
type GitCommitOptions
} from './git.js';
+126
View File
@@ -0,0 +1,126 @@
import chalk from 'chalk';
import ora, { type Ora } from 'ora';
// mascot - our friendly robot assistant
export const MASCOT = {
// Full mascot for headers
full: [
' ╭───╮ ',
' │ ● │ ',
' │ ◡ │ ',
' ╰───╯ ',
],
// Mini mascot for inline use
mini: '(◉‿◉)',
// Waving mascot for greetings
wave: [
' ╭───╮ ',
' │ ● │ ',
' │ ◡ │ ',
' ╰───╯ ',
],
// Celebration mascot for completion
celebrate: [
' ╭───╮ ',
' │ ★ │ ',
' │ ◡ │ ',
' ╰───╯ ',
],
};
export const logger = {
info: (message: string) => console.log(chalk.blue('i'), message),
success: (message: string) => console.log(chalk.green('+'), message),
warning: (message: string) => console.log(chalk.yellow('!'), message),
error: (message: string) => console.log(chalk.red('x'), message),
dim: (message: string) => console.log(chalk.dim(message)),
bold: (message: string) => console.log(chalk.bold(message)),
header: (message: string) => {
console.log();
console.log(chalk.cyan.bold(message));
console.log(chalk.cyan('-'.repeat(message.length)));
},
task: (phase: number, taskId: string, description: string) => {
console.log(
chalk.cyan(`[Phase ${phase}]`),
chalk.yellow(`Task ${taskId}:`),
chalk.white(description)
);
},
iteration: (num: number, max: number, status: string) => {
console.log(
chalk.cyan(`[${num}/${max}]`),
chalk.white(status)
);
},
specContent: (content: string) => {
console.log(chalk.dim('-'.repeat(50)));
console.log(chalk.white(content));
console.log(chalk.dim('-'.repeat(50)));
},
spinner: (text: string): Ora => ora({ text, color: 'cyan' }).start(),
// Display mascot with optional message
mascot: (variant: keyof typeof MASCOT = 'full', message?: string) => {
console.log();
const mascot = MASCOT[variant];
if (Array.isArray(mascot)) {
const mascotLines = [...mascot];
if (message) {
// Add message next to mascot (at the "mouth" line)
mascotLines[4] = mascotLines[4] + ' ' + chalk.cyan(message);
}
mascotLines.forEach((line) => console.log(chalk.yellow(line)));
} else {
// Mini variant
console.log(chalk.yellow(mascot), message ? chalk.cyan(message) : '');
}
console.log();
},
// Welcome banner with mascot
welcome: () => {
console.log();
console.log(chalk.cyan.bold('═'.repeat(50)));
MASCOT.wave.forEach((line, i) => {
if (i === 4) {
console.log(chalk.yellow(line) + ' ' + chalk.cyan.bold("Hi!"));
} else if (i === 5) {
console.log(chalk.yellow(line) + ' ' + chalk.dim('I\'m Your Plan2Code assistant'));
} else {
console.log(chalk.yellow(line));
}
});
console.log(chalk.cyan.bold('═'.repeat(50)));
console.log();
},
// Completion celebration with reminder
allPhasesComplete: () => {
console.log();
console.log(chalk.green.bold('═'.repeat(50)));
MASCOT.celebrate.forEach((line, i) => {
if (i === 4) {
console.log(chalk.yellow(line) + ' ' + chalk.green.bold('All phases complete!'));
} else if (i === 5) {
console.log(chalk.yellow(line) + ' ' + chalk.cyan('Great work!'));
} else {
console.log(chalk.yellow(line));
}
});
console.log(chalk.green.bold('═'.repeat(50)));
console.log();
console.log(chalk.cyan.bold('Next Step:'));
console.log(chalk.white(' Return to your AI Agent and run the'), chalk.yellow.bold('/plan2code-4--finalize'), chalk.white('step.'));
console.log(chalk.dim(' This will ensure quality, completeness, and proper documentation.'));
console.log();
},
};
export type Logger = typeof logger;
+81
View File
@@ -0,0 +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<string, string>;
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<ExecuteResult> {
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,
};
}
}