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
This commit is contained in:
2026-08-08 12:38:58 -07:00
parent 30860a7654
commit 48a7cf68bd
22 changed files with 1889 additions and 1863 deletions
+26
View File
@@ -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 # 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 # altered by line-ending normalization. Treat it as binary so autocrlf/eol
# settings can never corrupt the ciphertext. # settings can never corrupt the ciphertext.
+16 -16
View File
@@ -1,17 +1,17 @@
specs/ specs/
specs--completed/ specs--completed/
dist/ dist/
plan2code-loop/dist plan2code-loop/dist
plan2code-loop/node_modules plan2code-loop/node_modules
plan2code-loop/package-lock.json plan2code-loop/package-lock.json
plan2code-metrics/dist plan2code-metrics/dist
plan2code-metrics/node_modules plan2code-metrics/node_modules
plan2code-metrics/package-lock.json plan2code-metrics/package-lock.json
.plan2code-loop .plan2code-loop
.plan2code-metrics .plan2code-metrics
nul nul
.cognition/ .cognition/
handoffs/ handoffs/
node_modules/ node_modules/
package-lock.json package-lock.json
SYNC.md SYNC.md
+82 -82
View File
@@ -1,82 +1,82 @@
import type { Agent, AgentConfig, AgentExecutionOptions, AgentExecutionResult } from './types.js'; import type { Agent, AgentConfig, AgentExecutionOptions, AgentExecutionResult } from './types.js';
import { executeCommand } from '../utils/process.js'; import { executeCommand } from '../utils/process.js';
import { writeFileSync, unlinkSync } from 'fs'; import { writeFileSync, unlinkSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
const claudeCodeConfig: AgentConfig = { const claudeCodeConfig: AgentConfig = {
name: 'claude-code', name: 'claude-code',
displayName: 'Claude Code', displayName: 'Claude Code',
command: 'claude', command: 'claude',
models: [ models: [
{ value: 'default', label: 'Default (use Claude config)' }, { value: 'default', label: 'Default (use Claude config)' },
], ],
defaultModel: 'default', defaultModel: 'default',
flags: { flags: {
prompt: '--print', prompt: '--print',
model: '--model', model: '--model',
skipPermissions: '--dangerously-skip-permissions', skipPermissions: '--dangerously-skip-permissions',
}, },
}; };
class ClaudeCodeAgent implements Agent { class ClaudeCodeAgent implements Agent {
readonly config = claudeCodeConfig; readonly config = claudeCodeConfig;
async execute(options: AgentExecutionOptions): Promise<AgentExecutionResult> { async execute(options: AgentExecutionOptions): Promise<AgentExecutionResult> {
// Write prompt to temp file - more reliable than stdin on Windows // Write prompt to temp file - more reliable than stdin on Windows
const tempFile = join(tmpdir(), `plan2code-prompt-${Date.now()}.txt`); const tempFile = join(tmpdir(), `plan2code-prompt-${Date.now()}.txt`);
writeFileSync(tempFile, options.prompt, 'utf-8'); writeFileSync(tempFile, options.prompt, 'utf-8');
try { try {
// Build args: flags first, then read prompt from temp file via shell // Build args: flags first, then read prompt from temp file via shell
const args: string[] = [ const args: string[] = [
this.config.flags.prompt, // --print for non-interactive mode this.config.flags.prompt, // --print for non-interactive mode
this.config.flags.skipPermissions, this.config.flags.skipPermissions,
]; ];
// Only add --model if not using default // Only add --model if not using default
if (options.model && options.model !== 'default') { if (options.model && options.model !== 'default') {
args.push(this.config.flags.model, options.model); args.push(this.config.flags.model, options.model);
} }
// Use stdin from the temp file // Use stdin from the temp file
const result = await executeCommand({ const result = await executeCommand({
command: this.config.command, command: this.config.command,
args, args,
cwd: options.cwd, cwd: options.cwd,
timeout: options.timeout, timeout: options.timeout,
signal: options.signal, signal: options.signal,
stdinFile: tempFile, stdinFile: tempFile,
}); });
return { return {
stdout: result.stdout, stdout: result.stdout,
stderr: result.stderr, stderr: result.stderr,
exitCode: result.exitCode, exitCode: result.exitCode,
timedOut: result.timedOut, timedOut: result.timedOut,
cancelled: result.cancelled, cancelled: result.cancelled,
duration: result.duration, duration: result.duration,
}; };
} finally { } finally {
// Clean up temp file // Clean up temp file
try { try {
unlinkSync(tempFile); unlinkSync(tempFile);
} catch { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
} }
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
// Run claude --version to verify it's actually installed and working // Run claude --version to verify it's actually installed and working
const result = await executeCommand({ const result = await executeCommand({
command: this.config.command, command: this.config.command,
args: ['--version'], args: ['--version'],
cwd: process.cwd(), cwd: process.cwd(),
timeout: 5000, timeout: 5000,
}); });
return result.exitCode === 0; return result.exitCode === 0;
} }
} }
export const claudeCodeAgent = new ClaudeCodeAgent(); export const claudeCodeAgent = new ClaudeCodeAgent();
+70 -70
View File
@@ -1,70 +1,70 @@
import type { Agent, AgentConfig, AgentExecutionOptions, AgentExecutionResult } from './types.js'; import type { Agent, AgentConfig, AgentExecutionOptions, AgentExecutionResult } from './types.js';
import { executeCommand } from '../utils/process.js'; import { executeCommand } from '../utils/process.js';
const copilotCliConfig: AgentConfig = { const copilotCliConfig: AgentConfig = {
name: 'copilot-cli', name: 'copilot-cli',
displayName: 'GitHub Copilot CLI', displayName: 'GitHub Copilot CLI',
command: 'copilot', command: 'copilot',
models: [ models: [
{ value: 'claude-sonnet-4', label: 'Claude Sonnet 4 (Default)' }, { value: 'claude-sonnet-4', label: 'Claude Sonnet 4 (Default)' },
{ value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' }, { value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' },
{ value: 'claude-opus-4.5', label: 'Claude Opus 4.5' }, { value: 'claude-opus-4.5', label: 'Claude Opus 4.5' },
{ value: 'gpt-5', label: 'GPT-5' }, { value: 'gpt-5', label: 'GPT-5' },
{ value: 'gpt-5-mini', label: 'GPT-5 Mini' }, { value: 'gpt-5-mini', label: 'GPT-5 Mini' },
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro' }, { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro' },
], ],
defaultModel: 'claude-sonnet-4', defaultModel: 'claude-sonnet-4',
flags: { flags: {
prompt: '-p', prompt: '-p',
model: '--model', model: '--model',
skipPermissions: '--allow-all-tools', skipPermissions: '--allow-all-tools',
silent: '-s', silent: '-s',
}, },
}; };
class CopilotCliAgent implements Agent { class CopilotCliAgent implements Agent {
readonly config = copilotCliConfig; readonly config = copilotCliConfig;
async execute(options: AgentExecutionOptions): Promise<AgentExecutionResult> { async execute(options: AgentExecutionOptions): Promise<AgentExecutionResult> {
// Use stdin for prompt to handle multi-line text properly // Use stdin for prompt to handle multi-line text properly
const args: string[] = []; const args: string[] = [];
// Only add --model if not using default // Only add --model if not using default
if (options.model && options.model !== 'default') { if (options.model && options.model !== 'default') {
args.push(this.config.flags.model, options.model); args.push(this.config.flags.model, options.model);
} }
args.push(this.config.flags.skipPermissions, this.config.flags.silent!); args.push(this.config.flags.skipPermissions, this.config.flags.silent!);
const result = await executeCommand({ const result = await executeCommand({
command: this.config.command, command: this.config.command,
args, args,
cwd: options.cwd, cwd: options.cwd,
timeout: options.timeout, timeout: options.timeout,
signal: options.signal, signal: options.signal,
stdin: options.prompt, stdin: options.prompt,
}); });
return { return {
stdout: result.stdout, stdout: result.stdout,
stderr: result.stderr, stderr: result.stderr,
exitCode: result.exitCode, exitCode: result.exitCode,
timedOut: result.timedOut, timedOut: result.timedOut,
cancelled: result.cancelled, cancelled: result.cancelled,
duration: result.duration, duration: result.duration,
}; };
} }
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
// Run copilot --version to verify it's installed // Run copilot --version to verify it's installed
const result = await executeCommand({ const result = await executeCommand({
command: this.config.command, command: this.config.command,
args: ['--version'], args: ['--version'],
cwd: process.cwd(), cwd: process.cwd(),
timeout: 5000, timeout: 5000,
}); });
return result.exitCode === 0; return result.exitCode === 0;
} }
} }
export const copilotCliAgent = new CopilotCliAgent(); export const copilotCliAgent = new CopilotCliAgent();
+34 -34
View File
@@ -1,34 +1,34 @@
import type { Agent } from './types.js'; import type { Agent } from './types.js';
class AgentRegistry { class AgentRegistry {
private agents: Map<string, Agent> = new Map(); private agents: Map<string, Agent> = new Map();
register(agent: Agent): void { register(agent: Agent): void {
this.agents.set(agent.config.name, agent); this.agents.set(agent.config.name, agent);
} }
get(name: string): Agent | undefined { get(name: string): Agent | undefined {
return this.agents.get(name); return this.agents.get(name);
} }
getAll(): Agent[] { getAll(): Agent[] {
return Array.from(this.agents.values()); return Array.from(this.agents.values());
} }
getAvailable(): Promise<Agent[]> { getAvailable(): Promise<Agent[]> {
return Promise.all( return Promise.all(
this.getAll().map(async (agent) => ({ this.getAll().map(async (agent) => ({
agent, agent,
available: await agent.isAvailable(), available: await agent.isAvailable(),
})) }))
).then((results) => ).then((results) =>
results.filter((r) => r.available).map((r) => r.agent) results.filter((r) => r.available).map((r) => r.agent)
); );
} }
getNames(): string[] { getNames(): string[] {
return Array.from(this.agents.keys()); return Array.from(this.agents.keys());
} }
} }
export const agentRegistry = new AgentRegistry(); export const agentRegistry = new AgentRegistry();
+34 -34
View File
@@ -1,34 +1,34 @@
import { run } from '../index.js'; import { run } from '../index.js';
import { logger } from '../utils/index.js'; import { logger } from '../utils/index.js';
async function main() { async function main() {
try { try {
const result = await run(); const result = await run();
if (!result) { if (!result) {
process.exit(0); process.exit(0);
} }
// Exit codes per spec // Exit codes per spec
switch (result.exitReason) { switch (result.exitReason) {
case 'all_complete': case 'all_complete':
logger.success('Loop completed successfully - all tasks done!'); logger.success('Loop completed successfully - all tasks done!');
process.exit(0); process.exit(0);
case 'max_iterations': case 'max_iterations':
logger.warning('Loop ended: max iterations reached'); logger.warning('Loop ended: max iterations reached');
process.exit(1); process.exit(1);
case 'interrupted': case 'interrupted':
logger.info('Loop interrupted by user'); logger.info('Loop interrupted by user');
process.exit(2); process.exit(2);
case 'error': case 'error':
logger.error('Loop ended with error'); logger.error('Loop ended with error');
process.exit(3); process.exit(3);
} }
} catch (error) { } catch (error) {
logger.error(error instanceof Error ? error.message : String(error)); logger.error(error instanceof Error ? error.message : String(error));
process.exit(3); process.exit(3);
} }
} }
main(); main();
File diff suppressed because it is too large Load Diff
+96 -96
View File
@@ -1,96 +1,96 @@
import path from 'path'; import path from 'path';
import { StateManager } from './state/index.js'; import { StateManager } from './state/index.js';
import { Controller, type LoopResult, type TaskCompleteInfo } from './controller.js'; import { Controller, type LoopResult, type TaskCompleteInfo } from './controller.js';
import { setupSession } from './cli.js'; import { setupSession } from './cli.js';
import { logger, createTaskCommit } from './utils/index.js'; import { logger, createTaskCommit } from './utils/index.js';
export async function run(): Promise<LoopResult | null> { export async function run(): Promise<LoopResult | null> {
// Ensure agents are registered // Ensure agents are registered
await import('./agents/index.js'); await import('./agents/index.js');
const stateManager = new StateManager(); const stateManager = new StateManager();
const result = await setupSession(stateManager); const result = await setupSession(stateManager);
if (!result) { if (!result) {
return null; return null;
} }
const { config, isResume } = result; const { config, isResume } = result;
if (isResume) { if (isResume) {
logger.info(`Resuming from iteration ${config.currentIteration}`); logger.info(`Resuming from iteration ${config.currentIteration}`);
} }
const controller = new Controller({ const controller = new Controller({
config, config,
stateManager, stateManager,
onIteration: (iter, max) => { onIteration: (iter, max) => {
// Could add git checkpoint logic here if needed // Could add git checkpoint logic here if needed
}, },
onTaskComplete: async (info: TaskCompleteInfo) => { onTaskComplete: async (info: TaskCompleteInfo) => {
// Create git commit for completed task // Create git commit for completed task
const taskName = info.taskName || info.taskId || 'Task completed'; const taskName = info.taskName || info.taskId || 'Task completed';
await createTaskCommit({ await createTaskCommit({
taskName, taskName,
jiraTicketId: config.jiraTicketId, jiraTicketId: config.jiraTicketId,
cwd: process.cwd(), cwd: process.cwd(),
}); });
}, },
onLoopComplete: () => { onLoopComplete: () => {
// All tasks completed callback // All tasks completed callback
}, },
}); });
// Setup interrupt handler // Setup interrupt handler
const handleInterrupt = () => { const handleInterrupt = () => {
logger.warning('\nInterrupt received, saving state...'); logger.warning('\nInterrupt received, saving state...');
controller.interrupt(); controller.interrupt();
}; };
process.on('SIGINT', handleInterrupt); process.on('SIGINT', handleInterrupt);
process.on('SIGTERM', handleInterrupt); process.on('SIGTERM', handleInterrupt);
try { try {
const loopResult = await controller.run(); const loopResult = await controller.run();
// Display summary // Display summary
console.log(); console.log();
logger.header('Session Summary'); logger.header('Session Summary');
logger.info(`Total iterations: ${loopResult.iterations}`); logger.info(`Total iterations: ${loopResult.iterations}`);
logger.info(`Tasks completed: ${loopResult.tasksCompleted}`); logger.info(`Tasks completed: ${loopResult.tasksCompleted}`);
if (loopResult.prereqsCompleted > 0) { if (loopResult.prereqsCompleted > 0) {
logger.info(`Prerequisites verified: ${loopResult.prereqsCompleted}`); logger.info(`Prerequisites verified: ${loopResult.prereqsCompleted}`);
} }
logger.info(`Exit reason: ${loopResult.exitReason}`); logger.info(`Exit reason: ${loopResult.exitReason}`);
if (loopResult.finalMarker) { if (loopResult.finalMarker) {
logger.info(`Completion marker: ${loopResult.finalMarker}`); logger.info(`Completion marker: ${loopResult.finalMarker}`);
} }
if (loopResult.error) { if (loopResult.error) {
logger.error(`Error: ${loopResult.error.message}`); logger.error(`Error: ${loopResult.error.message}`);
} }
// Show completion celebration and finalize reminder when all phases complete // Show completion celebration and finalize reminder when all phases complete
if (loopResult.exitReason === 'all_complete') { if (loopResult.exitReason === 'all_complete') {
logger.allPhasesComplete(); logger.allPhasesComplete();
} }
// Show state file locations (now per-spec) // Show state file locations (now per-spec)
console.log(); console.log();
logger.dim(`Session files saved to ${path.relative(process.cwd(), stateManager.getStateDir())}:`); logger.dim(`Session files saved to ${path.relative(process.cwd(), stateManager.getStateDir())}:`);
logger.dim(' - config.json (session configuration)'); logger.dim(' - config.json (session configuration)');
logger.dim(' - scratchpad.md (LLM-managed notes)'); logger.dim(' - scratchpad.md (LLM-managed notes)');
logger.dim(' - iteration.log (history)'); logger.dim(' - iteration.log (history)');
return loopResult; return loopResult;
} finally { } finally {
process.off('SIGINT', handleInterrupt); process.off('SIGINT', handleInterrupt);
process.off('SIGTERM', handleInterrupt); process.off('SIGTERM', handleInterrupt);
} }
} }
// Re-export types and classes // Re-export types and classes
export { Controller, type ControllerOptions, type LoopResult, type TaskCompleteInfo } from './controller.js'; export { Controller, type ControllerOptions, type LoopResult, type TaskCompleteInfo } from './controller.js';
export { StateManager } from './state/index.js'; export { StateManager } from './state/index.js';
export { setupSession } from './cli.js'; export { setupSession } from './cli.js';
export { agentRegistry, type Agent, type AgentConfig } from './agents/index.js'; export { agentRegistry, type Agent, type AgentConfig } from './agents/index.js';
export { detectSpecDirectories, getSpecProgress } from './spec/index.js'; export { detectSpecDirectories, getSpecProgress } from './spec/index.js';
+39 -39
View File
@@ -1,39 +1,39 @@
import type { StateManager, LoopMode } from '../state/index.js'; import type { StateManager, LoopMode } from '../state/index.js';
import { LOOP_PROMPT_TEMPLATE, LOOP_PROMPT_TEMPLATE_PHASE } from './templates.js'; import { LOOP_PROMPT_TEMPLATE, LOOP_PROMPT_TEMPLATE_PHASE } from './templates.js';
export interface PromptContext { export interface PromptContext {
specPath: string; specPath: string;
iteration: number; iteration: number;
maxIterations: number; maxIterations: number;
stateManager: StateManager; stateManager: StateManager;
loopMode: LoopMode; loopMode: LoopMode;
jiraTicketId?: string; jiraTicketId?: string;
} }
/** /**
* Build the prompt for the AI agent * Build the prompt for the AI agent
* Selects template based on loop mode (task vs phase) * Selects template based on loop mode (task vs phase)
*/ */
export async function buildLoopPrompt(context: PromptContext): Promise<string> { export async function buildLoopPrompt(context: PromptContext): Promise<string> {
const { specPath, iteration, maxIterations, stateManager, loopMode, jiraTicketId } = context; const { specPath, iteration, maxIterations, stateManager, loopMode, jiraTicketId } = context;
// Read scratchpad content for session continuity (LLM writes to this) // Read scratchpad content for session continuity (LLM writes to this)
const scratchpadContent = await stateManager.readScratchpad(); const scratchpadContent = await stateManager.readScratchpad();
// Project root is where plan2code-loop was invoked from // Project root is where plan2code-loop was invoked from
const projectRoot = process.cwd(); const projectRoot = process.cwd();
// Select template based on loop mode // Select template based on loop mode
const template = loopMode === 'phase' ? LOOP_PROMPT_TEMPLATE_PHASE : LOOP_PROMPT_TEMPLATE; const template = loopMode === 'phase' ? LOOP_PROMPT_TEMPLATE_PHASE : LOOP_PROMPT_TEMPLATE;
// Template substitution // Template substitution
const prompt = template const prompt = template
.replace(/{{projectRoot}}/g, projectRoot) .replace(/{{projectRoot}}/g, projectRoot)
.replace(/{{specPath}}/g, specPath) .replace(/{{specPath}}/g, specPath)
.replace(/{{iteration}}/g, iteration.toString()) .replace(/{{iteration}}/g, iteration.toString())
.replace(/{{maxIterations}}/g, maxIterations.toString()) .replace(/{{maxIterations}}/g, maxIterations.toString())
.replace(/{{scratchpadContent}}/g, scratchpadContent || '(First iteration - no previous progress)') .replace(/{{scratchpadContent}}/g, scratchpadContent || '(First iteration - no previous progress)')
.replace(/{{jiraTicketId}}/g, jiraTicketId || ''); .replace(/{{jiraTicketId}}/g, jiraTicketId || '');
return prompt; return prompt;
} }
+6 -6
View File
@@ -1,6 +1,6 @@
export { export {
buildLoopPrompt, buildLoopPrompt,
type PromptContext, type PromptContext,
} from './builder.js'; } from './builder.js';
export { LOOP_PROMPT_TEMPLATE, LOOP_PROMPT_TEMPLATE_PHASE } from './templates.js'; export { LOOP_PROMPT_TEMPLATE, LOOP_PROMPT_TEMPLATE_PHASE } from './templates.js';
+200 -200
View File
@@ -1,200 +1,200 @@
export const LOOP_PROMPT_TEMPLATE = `# PLAN2CODE-LOOP: Autonomous Task Implementation export const LOOP_PROMPT_TEMPLATE = `# PLAN2CODE-LOOP: Autonomous Task Implementation
## CRITICAL CONSTRAINT ## CRITICAL CONSTRAINT
**IMPLEMENT EXACTLY ONE TASK PER ITERATION.** **IMPLEMENT EXACTLY ONE TASK PER ITERATION.**
Do NOT implement multiple tasks. Do NOT complete an entire phase. Do NOT implement multiple tasks. Do NOT complete an entire phase.
Find the FIRST unchecked task, implement ONLY that task, then STOP and report. Find the FIRST unchecked task, implement ONLY that task, then STOP and report.
## Project Information ## Project Information
- **Project Root:** \`{{projectRoot}}\` - **Project Root:** \`{{projectRoot}}\`
- **Spec Location:** \`{{specPath}}\` - **Spec Location:** \`{{specPath}}\`
- Read \`AGENTS.md\` for project-specific guidance if available - Read \`AGENTS.md\` for project-specific guidance if available
## IMPORTANT: File Locations ## IMPORTANT: File Locations
- Write ALL code files relative to the **project root** (\`{{projectRoot}}\`) - Write ALL code files relative to the **project root** (\`{{projectRoot}}\`)
- The spec directory (\`{{specPath}}\`) is for documentation ONLY - never write code there - The spec directory (\`{{specPath}}\`) is for documentation ONLY - never write code there
- Example: Create \`{{projectRoot}}/src/index.ts\`, NOT \`{{specPath}}/src/index.ts\` - Example: Create \`{{projectRoot}}/src/index.ts\`, NOT \`{{specPath}}/src/index.ts\`
## Iteration ## Iteration
{{iteration}} of {{maxIterations}} {{iteration}} of {{maxIterations}}
## Task Discovery Process ## Task Discovery Process
1. Read \`{{specPath}}/overview.md\` to see all phases 1. Read \`{{specPath}}/overview.md\` to see all phases
2. Find the FIRST phase with an unchecked checkbox (\`- [ ]\` or \`- [/]\`) 2. Find the FIRST phase with an unchecked checkbox (\`- [ ]\` or \`- [/]\`)
3. Read that phase's file (e.g., \`phase-1.md\`) 3. Read that phase's file (e.g., \`phase-1.md\`)
4. Check the \`## Prerequisites\` section FIRST 4. Check the \`## Prerequisites\` section FIRST
5. Find the FIRST unverified prerequisite (no "VERIFIED" or "ASSUMED" annotation) 5. Find the FIRST unverified prerequisite (no "VERIFIED" or "ASSUMED" annotation)
- If found, verify/complete it, then annotate "VERIFIED" or "ASSUMED: [reason]" inline - 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 (\`- [ ]\`) 6. Only if ALL prerequisites are verified or assumed, find the FIRST unchecked task (\`- [ ]\`)
7. That is your ONE task - implement ONLY that task 7. That is your ONE task - implement ONLY that task
## Checkbox States (Task items only) ## Checkbox States (Task items only)
- \`[ ]\` = incomplete/pending (do the FIRST one you find) - \`[ ]\` = incomplete/pending (do the FIRST one you find)
- \`[x]\` = complete (skip) - \`[x]\` = complete (skip)
- \`[?]\` = assumed complete, couldn't verify (skip) - \`[?]\` = assumed complete, couldn't verify (skip)
- \`[!]\` = blocked (skip) - \`[!]\` = blocked (skip)
Prerequisites use plain bullets with inline annotations, not checkboxes. Prerequisites use plain bullets with inline annotations, not checkboxes.
## Implementation Steps ## Implementation Steps
1. Read and understand the single task 1. Read and understand the single task
2. Implement it completely 2. Implement it completely
3. Validate it works (run tests if applicable and double-check code) 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 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 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 6. Output your completion marker and STOP
## Git Policy ## 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. **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) ## Completion Markers (REQUIRED FORMAT)
Output exactly ONE of these at the end, including the task ID and description: Output exactly ONE of these at the end, including the task ID and description:
**PREREQ_COMPLETE: [prereq_id] - [description]** **PREREQ_COMPLETE: [prereq_id] - [description]**
Example: \`PREREQ_COMPLETE: P1.1 - Verified Phase 1 complete\` Example: \`PREREQ_COMPLETE: P1.1 - Verified Phase 1 complete\`
**PREREQ_ASSUMED: [prereq_id] - [description]** **PREREQ_ASSUMED: [prereq_id] - [description]**
Example: \`PREREQ_ASSUMED: P2.1 - Design approval (cannot verify)\` Example: \`PREREQ_ASSUMED: P2.1 - Design approval (cannot verify)\`
**TASK_COMPLETE: [task_id] - [task_description]** **TASK_COMPLETE: [task_id] - [task_description]**
Example: \`TASK_COMPLETE: 1.1 - Initialize project structure\` Example: \`TASK_COMPLETE: 1.1 - Initialize project structure\`
**TASK_BLOCKED: [task_id] - [reason]** **TASK_BLOCKED: [task_id] - [reason]**
Example: \`TASK_BLOCKED: 2.3 - Missing API credentials\` Example: \`TASK_BLOCKED: 2.3 - Missing API credentials\`
**LOOP_COMPLETE** **LOOP_COMPLETE**
Use only when ALL phases in overview.md are marked complete. Use only when ALL phases in overview.md are marked complete.
## Scratchpad Management ## Scratchpad Management
After completing each task, add a new entry at the **bottom** of \`{{specPath}}/.plan2code-loop/scratchpad.md\`. 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. Never edit, reorganize, or insert into existing content — only append new entries to the end of the file.
Each entry should include: Each entry should include:
- Task completed and Phase item reference - Task completed and Phase item reference
- Key decisions made and reasoning - Key decisions made and reasoning
- Files changed - Files changed
- Any blockers or notes for next iteration - Any blockers or notes for next iteration
Keep entries concise. Sacrifice grammar for concision. This file helps future iterations skip exploration. 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. If key patterns or learnings were discovered, update \`./AGENTS.md\` if it exists.
## Previous Session Context ## Previous Session Context
{{scratchpadContent}} {{scratchpadContent}}
--- ---
Remember: ONE TASK ONLY. Find it, implement it, mark it done, output TASK_COMPLETE with the task ID and description, then stop. 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 export const LOOP_PROMPT_TEMPLATE_PHASE = `# PLAN2CODE-LOOP: Autonomous Phase Implementation
## CRITICAL CONSTRAINT ## CRITICAL CONSTRAINT
**IMPLEMENT ALL REMAINING TASKS IN THE CURRENT PHASE.** **IMPLEMENT ALL REMAINING TASKS IN THE CURRENT PHASE.**
Find the first incomplete phase, then implement every remaining task in that phase before stopping. 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. Complete each task fully before moving to the next task within the phase.
## Project Information ## Project Information
- **Project Root:** \`{{projectRoot}}\` - **Project Root:** \`{{projectRoot}}\`
- **Spec Location:** \`{{specPath}}\` - **Spec Location:** \`{{specPath}}\`
- Read \`AGENTS.md\` for project-specific guidance if available - Read \`AGENTS.md\` for project-specific guidance if available
## IMPORTANT: File Locations ## IMPORTANT: File Locations
- Write ALL code files relative to the **project root** (\`{{projectRoot}}\`) - Write ALL code files relative to the **project root** (\`{{projectRoot}}\`)
- The spec directory (\`{{specPath}}\`) is for documentation ONLY - never write code there - The spec directory (\`{{specPath}}\`) is for documentation ONLY - never write code there
- Example: Create \`{{projectRoot}}/src/index.ts\`, NOT \`{{specPath}}/src/index.ts\` - Example: Create \`{{projectRoot}}/src/index.ts\`, NOT \`{{specPath}}/src/index.ts\`
## Iteration ## Iteration
{{iteration}} of {{maxIterations}} {{iteration}} of {{maxIterations}}
## Phase Discovery Process ## Phase Discovery Process
1. Read \`{{specPath}}/overview.md\` to see all phases 1. Read \`{{specPath}}/overview.md\` to see all phases
2. Find the FIRST phase with an unchecked checkbox (\`- [ ]\` or \`- [/]\`) 2. Find the FIRST phase with an unchecked checkbox (\`- [ ]\` or \`- [/]\`)
3. Read that phase's file (e.g., \`phase-1.md\`) 3. Read that phase's file (e.g., \`phase-1.md\`)
4. Check the \`## Prerequisites\` section FIRST 4. Check the \`## Prerequisites\` section FIRST
5. Verify ALL unverified prerequisites first, in order 5. Verify ALL unverified prerequisites first, in order
- Annotate each "VERIFIED" or "ASSUMED: [reason]" inline - Annotate each "VERIFIED" or "ASSUMED: [reason]" inline
6. Once ALL prerequisites are verified, implement ALL unchecked tasks in order 6. Once ALL prerequisites are verified, implement ALL unchecked tasks in order
7. Continue until every task in the phase is marked \`[x]\` 7. Continue until every task in the phase is marked \`[x]\`
## Checkbox States (Task items only) ## Checkbox States (Task items only)
- \`[ ]\` = incomplete/pending - \`[ ]\` = incomplete/pending
- \`[x]\` = complete (skip) - \`[x]\` = complete (skip)
- \`[?]\` = assumed complete, couldn't verify (skip) - \`[?]\` = assumed complete, couldn't verify (skip)
- \`[!]\` = blocked (skip, note in scratchpad) - \`[!]\` = blocked (skip, note in scratchpad)
Prerequisites use plain bullets with inline annotations, not checkboxes. Prerequisites use plain bullets with inline annotations, not checkboxes.
## Implementation Steps (repeat for EACH task in the phase) ## Implementation Steps (repeat for EACH task in the phase)
1. Read and understand the task 1. Read and understand the task
2. Implement it completely 2. Implement it completely
3. Validate it works (run tests if applicable and double-check code) 3. Validate it works (run tests if applicable and double-check code)
4. Mark that task's checkbox as \`[x]\` in the phase file 4. Mark that task's checkbox as \`[x]\` in the phase file
5. **Create a git commit** for this task (see Git Policy below) 5. **Create a git commit** for this task (see Git Policy below)
6. Output a TASK_COMPLETE marker for this task 6. Output a TASK_COMPLETE marker for this task
7. Move to the next unchecked task in the same phase 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 8. When ALL tasks in the phase are done, mark the phase \`[x]\` in overview.md
## Git Policy ## Git Policy
**YOU are responsible for creating git commits after each task.** The orchestration system does NOT handle commits in phase mode. **YOU are responsible for creating git commits after each task.** The orchestration system does NOT handle commits in phase mode.
After completing each task: After completing each task:
\`\`\`bash \`\`\`bash
git add -A git add -A
git commit -m "<commit message>" git commit -m "<commit message>"
\`\`\` \`\`\`
**Commit message format:** **Commit message format:**
\`\`\` \`\`\`
git add -A git add -A
git commit -m "Task X.Y: description" -m "{{jiraTicketId}}" -m "AI Assisted" git commit -m "Task X.Y: description" -m "{{jiraTicketId}}" -m "AI Assisted"
\`\`\` \`\`\`
- With JIRA ticket: three \`-m\` flags (description, ticket ID, AI Assisted) - With JIRA ticket: three \`-m\` flags (description, ticket ID, AI Assisted)
- Without JIRA ticket: two \`-m\` flags (description, AI Assisted) - Without JIRA ticket: two \`-m\` flags (description, AI Assisted)
- ALWAYS include "AI Assisted" as the final \`-m\` flag - 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. Replace X.Y with the actual task ID and description with a concise summary of what was implemented.
## Completion Markers (REQUIRED FORMAT) ## Completion Markers (REQUIRED FORMAT)
Output one of these **after each task** you complete: Output one of these **after each task** you complete:
**PREREQ_COMPLETE: [prereq_id] - [description]** **PREREQ_COMPLETE: [prereq_id] - [description]**
Example: \`PREREQ_COMPLETE: P1.1 - Verified Phase 1 complete\` Example: \`PREREQ_COMPLETE: P1.1 - Verified Phase 1 complete\`
**PREREQ_ASSUMED: [prereq_id] - [description]** **PREREQ_ASSUMED: [prereq_id] - [description]**
Example: \`PREREQ_ASSUMED: P2.1 - Design approval (cannot verify)\` Example: \`PREREQ_ASSUMED: P2.1 - Design approval (cannot verify)\`
**TASK_COMPLETE: [task_id] - [task_description]** **TASK_COMPLETE: [task_id] - [task_description]**
Example: \`TASK_COMPLETE: 1.1 - Initialize project structure\` Example: \`TASK_COMPLETE: 1.1 - Initialize project structure\`
**TASK_BLOCKED: [task_id] - [reason]** **TASK_BLOCKED: [task_id] - [reason]**
Example: \`TASK_BLOCKED: 2.3 - Missing API credentials\` Example: \`TASK_BLOCKED: 2.3 - Missing API credentials\`
If a task is blocked, skip it and continue to the next task. If a task is blocked, skip it and continue to the next task.
After ALL tasks in the phase are complete (or blocked), output: After ALL tasks in the phase are complete (or blocked), output:
**PHASE_COMPLETE** - if only this phase is done **PHASE_COMPLETE** - if only this phase is done
**LOOP_COMPLETE** - if ALL phases in overview.md are now marked complete **LOOP_COMPLETE** - if ALL phases in overview.md are now marked complete
## Scratchpad Management ## Scratchpad Management
After completing each task, add a new entry at the **bottom** of \`{{specPath}}/.plan2code-loop/scratchpad.md\`. 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. Never edit, reorganize, or insert into existing content — only append new entries to the end of the file.
Each entry should include: Each entry should include:
- Task completed and Phase item reference - Task completed and Phase item reference
- Key decisions made and reasoning - Key decisions made and reasoning
- Files changed - Files changed
- Any blockers or notes for next iteration - Any blockers or notes for next iteration
Keep entries concise. Sacrifice grammar for concision. This file helps future iterations skip exploration. 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. If key patterns or learnings were discovered, update \`./AGENTS.md\` if it exists.
## Previous Session Context ## Previous Session Context
{{scratchpadContent}} {{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. 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.
`; `;
+4 -4
View File
@@ -1,4 +1,4 @@
export { export {
detectSpecDirectories, detectSpecDirectories,
getSpecProgress, getSpecProgress,
} from './utils.js'; } from './utils.js';
+70 -70
View File
@@ -1,70 +1,70 @@
import path from 'path'; import path from 'path';
import fs from 'fs-extra'; import fs from 'fs-extra';
/** /**
* Auto-detect spec directories in the project * Auto-detect spec directories in the project
* Looks for directories containing overview.md * Looks for directories containing overview.md
*/ */
export async function detectSpecDirectories(cwd: string = process.cwd()): Promise<string[]> { export async function detectSpecDirectories(cwd: string = process.cwd()): Promise<string[]> {
const specsDir = path.join(cwd, 'specs'); const specsDir = path.join(cwd, 'specs');
const specsDirs: string[] = []; const specsDirs: string[] = [];
if (await fs.pathExists(specsDir)) { if (await fs.pathExists(specsDir)) {
// Look for overview.md files in subdirectories // Look for overview.md files in subdirectories
const entries = await fs.readdir(specsDir, { withFileTypes: true }); const entries = await fs.readdir(specsDir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
if (entry.isDirectory()) { if (entry.isDirectory()) {
const overviewPath = path.join(specsDir, entry.name, 'overview.md'); const overviewPath = path.join(specsDir, entry.name, 'overview.md');
if (await fs.pathExists(overviewPath)) { if (await fs.pathExists(overviewPath)) {
specsDirs.push(path.join(specsDir, entry.name)); specsDirs.push(path.join(specsDir, entry.name));
} }
} }
} }
// Also check if specs/ itself contains overview.md // Also check if specs/ itself contains overview.md
const rootOverview = path.join(specsDir, 'overview.md'); const rootOverview = path.join(specsDir, 'overview.md');
if (await fs.pathExists(rootOverview)) { if (await fs.pathExists(rootOverview)) {
specsDirs.push(specsDir); specsDirs.push(specsDir);
} }
} }
return specsDirs; return specsDirs;
} }
/** /**
* Simple progress stats by counting phase-*.md files * Simple progress stats by counting phase-*.md files
* Used for CLI display only - LLM handles actual task discovery * Used for CLI display only - LLM handles actual task discovery
*/ */
export async function getSpecProgress(specPath: string): Promise<{ export async function getSpecProgress(specPath: string): Promise<{
featureName: string; featureName: string;
totalPhases: number; totalPhases: number;
}> { }> {
const overviewPath = path.join(specPath, 'overview.md'); const overviewPath = path.join(specPath, 'overview.md');
// Extract feature name from overview.md // Extract feature name from overview.md
let featureName = path.basename(specPath); let featureName = path.basename(specPath);
try { try {
const overviewContent = await fs.readFile(overviewPath, 'utf8'); const overviewContent = await fs.readFile(overviewPath, 'utf8');
const h1Match = overviewContent.match(/^#\s+(.+)$/m); const h1Match = overviewContent.match(/^#\s+(.+)$/m);
if (h1Match) { if (h1Match) {
featureName = h1Match[1].trim(); featureName = h1Match[1].trim();
} }
} catch { } catch {
// Use directory name as fallback // Use directory name as fallback
} }
// Count phase-*.md files // Count phase-*.md files
let totalPhases = 0; let totalPhases = 0;
try { try {
const entries = await fs.readdir(specPath); const entries = await fs.readdir(specPath);
totalPhases = entries.filter(name => /^phase-\d+\.md$/i.test(name)).length; totalPhases = entries.filter(name => /^phase-\d+\.md$/i.test(name)).length;
} catch { } catch {
// Directory read failed // Directory read failed
} }
return { return {
featureName, featureName,
totalPhases, totalPhases,
}; };
} }
+9 -9
View File
@@ -1,9 +1,9 @@
import { createHash } from 'crypto'; import { createHash } from 'crypto';
export function computeHash(content: string): string { export function computeHash(content: string): string {
return createHash('sha256').update(content).digest('hex').slice(0, 16); return createHash('sha256').update(content).digest('hex').slice(0, 16);
} }
export function hashesMatch(a: string, b: string): boolean { export function hashesMatch(a: string, b: string): boolean {
return a === b; return a === b;
} }
+10 -10
View File
@@ -1,10 +1,10 @@
export { export {
type SessionConfig, type SessionConfig,
type IterationLogEntry, type IterationLogEntry,
type SessionState, type SessionState,
type LoopMode, type LoopMode,
DEFAULT_CONFIG, DEFAULT_CONFIG,
} from './config.js'; } from './config.js';
export { StateManager } from './manager.js'; export { StateManager } from './manager.js';
export { computeHash, hashesMatch } from './hash.js'; export { computeHash, hashesMatch } from './hash.js';
+231 -231
View File
@@ -1,231 +1,231 @@
import path from 'path'; import path from 'path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import type { SessionConfig, IterationLogEntry, SessionState } from './config.js'; import type { SessionConfig, IterationLogEntry, SessionState } from './config.js';
import { DEFAULT_CONFIG } from './config.js'; import { DEFAULT_CONFIG } from './config.js';
import { computeHash, hashesMatch } from './hash.js'; import { computeHash, hashesMatch } from './hash.js';
const SCRATCHPAD_TEMPLATE = `# Scratchpad const SCRATCHPAD_TEMPLATE = `# Scratchpad
Session notes appended by LLM during implementation. Session notes appended by LLM during implementation.
--- ---
`; `;
export class StateManager { export class StateManager {
private readonly stateDir: string; private readonly stateDir: string;
private readonly configPath: string; private readonly configPath: string;
private readonly scratchpadPath: string; private readonly scratchpadPath: string;
private readonly logPath: string; private readonly logPath: string;
private readonly hashPath: string; private readonly hashPath: string;
private specPath: string | null = null; private specPath: string | null = null;
constructor(cwd: string = process.cwd()) { constructor(cwd: string = process.cwd()) {
// Default to project root - will be updated when spec is selected // Default to project root - will be updated when spec is selected
this.stateDir = path.join(cwd, '.plan2code-loop'); this.stateDir = path.join(cwd, '.plan2code-loop');
this.configPath = path.join(this.stateDir, 'config.json'); this.configPath = path.join(this.stateDir, 'config.json');
this.scratchpadPath = path.join(this.stateDir, 'scratchpad.md'); this.scratchpadPath = path.join(this.stateDir, 'scratchpad.md');
this.logPath = path.join(this.stateDir, 'iteration.log'); this.logPath = path.join(this.stateDir, 'iteration.log');
this.hashPath = path.join(this.stateDir, 'spec.hash'); this.hashPath = path.join(this.stateDir, 'spec.hash');
} }
/** /**
* Set the spec path and update all state paths to be inside the spec directory * Set the spec path and update all state paths to be inside the spec directory
*/ */
setSpecPath(specPath: string): void { setSpecPath(specPath: string): void {
this.specPath = specPath; this.specPath = specPath;
const stateDir = path.join(specPath, '.plan2code-loop'); const stateDir = path.join(specPath, '.plan2code-loop');
// Update all paths to be relative to spec directory // Update all paths to be relative to spec directory
(this as any).stateDir = stateDir; (this as any).stateDir = stateDir;
(this as any).configPath = path.join(stateDir, 'config.json'); (this as any).configPath = path.join(stateDir, 'config.json');
(this as any).scratchpadPath = path.join(stateDir, 'scratchpad.md'); (this as any).scratchpadPath = path.join(stateDir, 'scratchpad.md');
(this as any).logPath = path.join(stateDir, 'iteration.log'); (this as any).logPath = path.join(stateDir, 'iteration.log');
(this as any).hashPath = path.join(stateDir, 'spec.hash'); (this as any).hashPath = path.join(stateDir, 'spec.hash');
} }
// Directory operations // Directory operations
async ensureStateDir(): Promise<boolean> { async ensureStateDir(): Promise<boolean> {
const existed = await fs.pathExists(this.stateDir); const existed = await fs.pathExists(this.stateDir);
await fs.ensureDir(this.stateDir); await fs.ensureDir(this.stateDir);
return existed; return existed;
} }
getStateDir(): string { getStateDir(): string {
return this.stateDir; return this.stateDir;
} }
// Config operations // Config operations
async readConfig(): Promise<SessionConfig | null> { async readConfig(): Promise<SessionConfig | null> {
try { try {
const content = await fs.readFile(this.configPath, 'utf8'); const content = await fs.readFile(this.configPath, 'utf8');
return JSON.parse(content) as SessionConfig; return JSON.parse(content) as SessionConfig;
} catch { } catch {
return null; return null;
} }
} }
async writeConfig(config: SessionConfig): Promise<void> { async writeConfig(config: SessionConfig): Promise<void> {
await fs.writeFile( await fs.writeFile(
this.configPath, this.configPath,
JSON.stringify(config, null, 2), JSON.stringify(config, null, 2),
'utf8' 'utf8'
); );
} }
async updateConfig(updates: Partial<SessionConfig>): Promise<SessionConfig> { async updateConfig(updates: Partial<SessionConfig>): Promise<SessionConfig> {
const existing = await this.readConfig(); const existing = await this.readConfig();
const updated = { ...DEFAULT_CONFIG, ...existing, ...updates } as SessionConfig; const updated = { ...DEFAULT_CONFIG, ...existing, ...updates } as SessionConfig;
await this.writeConfig(updated); await this.writeConfig(updated);
return updated; return updated;
} }
async incrementIteration(): Promise<number> { async incrementIteration(): Promise<number> {
const config = await this.readConfig(); const config = await this.readConfig();
if (!config) throw new Error('No session config found'); if (!config) throw new Error('No session config found');
config.currentIteration++; config.currentIteration++;
await this.writeConfig(config); await this.writeConfig(config);
return config.currentIteration; return config.currentIteration;
} }
// Session state detection // Session state detection
async hasExistingSession(): Promise<boolean> { async hasExistingSession(): Promise<boolean> {
const [configExists, scratchpadExists] = await Promise.all([ const [configExists, scratchpadExists] = await Promise.all([
fs.pathExists(this.configPath), fs.pathExists(this.configPath),
fs.pathExists(this.scratchpadPath), fs.pathExists(this.scratchpadPath),
]); ]);
return configExists || scratchpadExists; return configExists || scratchpadExists;
} }
async detectSessionState(specPath: string): Promise<SessionState> { async detectSessionState(specPath: string): Promise<SessionState> {
// Ensure we're using the correct spec path // Ensure we're using the correct spec path
this.setSpecPath(specPath); this.setSpecPath(specPath);
const hasSession = await this.hasExistingSession(); const hasSession = await this.hasExistingSession();
if (!hasSession) { if (!hasSession) {
return 'new'; return 'new';
} }
// Check if the spec path has changed // Check if the spec path has changed
const existingConfig = await this.readConfig(); const existingConfig = await this.readConfig();
if (existingConfig && existingConfig.specPath !== specPath) { if (existingConfig && existingConfig.specPath !== specPath) {
return 'changed'; return 'changed';
} }
// Check if spec content has changed (using hash of overview.md) // Check if spec content has changed (using hash of overview.md)
const specChanged = await this.hasSpecChanged(specPath); const specChanged = await this.hasSpecChanged(specPath);
return specChanged ? 'changed' : 'continue'; return specChanged ? 'changed' : 'continue';
} }
// Hash management for spec change detection // Hash management for spec change detection
async computeSpecHash(specPath: string): Promise<string> { async computeSpecHash(specPath: string): Promise<string> {
const overviewPath = path.join(specPath, 'overview.md'); const overviewPath = path.join(specPath, 'overview.md');
try { try {
const content = await fs.readFile(overviewPath, 'utf8'); const content = await fs.readFile(overviewPath, 'utf8');
return computeHash(content); return computeHash(content);
} catch { } catch {
return ''; return '';
} }
} }
async readStoredHash(): Promise<string | null> { async readStoredHash(): Promise<string | null> {
try { try {
return await fs.readFile(this.hashPath, 'utf8'); return await fs.readFile(this.hashPath, 'utf8');
} catch { } catch {
return null; return null;
} }
} }
async storeHash(hash: string): Promise<void> { async storeHash(hash: string): Promise<void> {
await fs.writeFile(this.hashPath, hash, 'utf8'); await fs.writeFile(this.hashPath, hash, 'utf8');
} }
async hasSpecChanged(specPath: string): Promise<boolean> { async hasSpecChanged(specPath: string): Promise<boolean> {
const stored = await this.readStoredHash(); const stored = await this.readStoredHash();
if (!stored) return true; if (!stored) return true;
const current = await this.computeSpecHash(specPath); const current = await this.computeSpecHash(specPath);
return !hashesMatch(stored, current); return !hashesMatch(stored, current);
} }
async updateSpecHash(specPath: string): Promise<void> { async updateSpecHash(specPath: string): Promise<void> {
const hash = await this.computeSpecHash(specPath); const hash = await this.computeSpecHash(specPath);
await this.storeHash(hash); await this.storeHash(hash);
} }
// Scratchpad operations // Scratchpad operations
async initializeScratchpad(): Promise<void> { async initializeScratchpad(): Promise<void> {
await fs.writeFile(this.scratchpadPath, SCRATCHPAD_TEMPLATE, 'utf8'); await fs.writeFile(this.scratchpadPath, SCRATCHPAD_TEMPLATE, 'utf8');
} }
async readScratchpad(): Promise<string> { async readScratchpad(): Promise<string> {
try { try {
return await fs.readFile(this.scratchpadPath, 'utf8'); return await fs.readFile(this.scratchpadPath, 'utf8');
} catch { } catch {
return ''; return '';
} }
} }
async writeScratchpad(content: string): Promise<void> { async writeScratchpad(content: string): Promise<void> {
await fs.writeFile(this.scratchpadPath, content, 'utf8'); await fs.writeFile(this.scratchpadPath, content, 'utf8');
} }
// Iteration log operations // Iteration log operations
async appendIterationLog(entry: IterationLogEntry): Promise<void> { async appendIterationLog(entry: IterationLogEntry): Promise<void> {
const line = JSON.stringify(entry) + '\n'; const line = JSON.stringify(entry) + '\n';
await fs.appendFile(this.logPath, line, 'utf8'); await fs.appendFile(this.logPath, line, 'utf8');
} }
async readIterationLog(): Promise<IterationLogEntry[]> { async readIterationLog(): Promise<IterationLogEntry[]> {
try { try {
const content = await fs.readFile(this.logPath, 'utf8'); const content = await fs.readFile(this.logPath, 'utf8');
return content return content
.trim() .trim()
.split('\n') .split('\n')
.filter(Boolean) .filter(Boolean)
.map((line) => JSON.parse(line) as IterationLogEntry); .map((line) => JSON.parse(line) as IterationLogEntry);
} catch { } catch {
return []; return [];
} }
} }
async getLastIteration(): Promise<IterationLogEntry | null> { async getLastIteration(): Promise<IterationLogEntry | null> {
const log = await this.readIterationLog(); const log = await this.readIterationLog();
return log.length > 0 ? log[log.length - 1] : null; return log.length > 0 ? log[log.length - 1] : null;
} }
// State clearing and initialization // State clearing and initialization
async clearState(): Promise<void> { async clearState(): Promise<void> {
const filesToDelete = [ const filesToDelete = [
this.scratchpadPath, this.scratchpadPath,
this.configPath, this.configPath,
this.logPath, this.logPath,
this.hashPath, this.hashPath,
]; ];
await Promise.all( await Promise.all(
filesToDelete.map((file) => fs.remove(file).catch(() => {})) filesToDelete.map((file) => fs.remove(file).catch(() => {}))
); );
} }
async initializeNewSession(config: SessionConfig): Promise<void> { async initializeNewSession(config: SessionConfig): Promise<void> {
// Ensure spec path is set before initializing // Ensure spec path is set before initializing
this.setSpecPath(config.specPath); this.setSpecPath(config.specPath);
await this.clearState(); await this.clearState();
await this.ensureStateDir(); await this.ensureStateDir();
await this.initializeScratchpad(); await this.initializeScratchpad();
await this.writeConfig({ await this.writeConfig({
...config, ...config,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
currentIteration: 0, currentIteration: 0,
}); });
const hash = await this.computeSpecHash(config.specPath); const hash = await this.computeSpecHash(config.specPath);
await this.storeHash(hash); await this.storeHash(hash);
} }
} }
+169 -169
View File
@@ -1,169 +1,169 @@
// Completion markers for plan2code-loop // Completion markers for plan2code-loop
const COMPLETION_MARKERS = ['TASK_COMPLETE', 'TASK_BLOCKED', 'LOOP_COMPLETE', 'PREREQ_COMPLETE', 'PREREQ_ASSUMED'] as const; const COMPLETION_MARKERS = ['TASK_COMPLETE', 'TASK_BLOCKED', 'LOOP_COMPLETE', 'PREREQ_COMPLETE', 'PREREQ_ASSUMED'] as const;
export type CompletionMarker = (typeof COMPLETION_MARKERS)[number]; export type CompletionMarker = (typeof COMPLETION_MARKERS)[number];
export interface CompletionCheckResult { export interface CompletionCheckResult {
completed: boolean; completed: boolean;
marker?: CompletionMarker; marker?: CompletionMarker;
taskId?: string; // e.g., "1.1", "2.3" taskId?: string; // e.g., "1.1", "2.3"
taskName?: string; // e.g., "Initialize project structure" taskName?: string; // e.g., "Initialize project structure"
reason?: string; // For TASK_BLOCKED: reason reason?: string; // For TASK_BLOCKED: reason
} }
export function checkForCompletion(output: string): CompletionCheckResult { export function checkForCompletion(output: string): CompletionCheckResult {
// Check for LOOP_COMPLETE first (highest priority) // Check for LOOP_COMPLETE first (highest priority)
if (output.includes('LOOP_COMPLETE')) { if (output.includes('LOOP_COMPLETE')) {
return { completed: true, marker: 'LOOP_COMPLETE' }; return { completed: true, marker: 'LOOP_COMPLETE' };
} }
// Check for TASK_COMPLETE with task info // Check for TASK_COMPLETE with task info
// Format: TASK_COMPLETE: 1.1 - Task description // Format: TASK_COMPLETE: 1.1 - Task description
// Or: TASK_COMPLETE: 1.1: Task description // Or: 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); const completeMatch = output.match(/TASK_COMPLETE[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/i);
if (completeMatch) { if (completeMatch) {
return { return {
completed: true, completed: true,
marker: 'TASK_COMPLETE', marker: 'TASK_COMPLETE',
taskId: completeMatch[1], taskId: completeMatch[1],
taskName: completeMatch[2].trim(), taskName: completeMatch[2].trim(),
}; };
} }
// Simple TASK_COMPLETE without structured info - try to extract task from context // Simple TASK_COMPLETE without structured info - try to extract task from context
if (output.includes('TASK_COMPLETE')) { if (output.includes('TASK_COMPLETE')) {
// Try to find task info nearby // Try to find task info nearby
const taskMatch = output.match(/(?:task|completed?)\s+(\d+\.\d+)[:\s]+([^\n]{5,80})/i); const taskMatch = output.match(/(?:task|completed?)\s+(\d+\.\d+)[:\s]+([^\n]{5,80})/i);
return { return {
completed: true, completed: true,
marker: 'TASK_COMPLETE', marker: 'TASK_COMPLETE',
taskId: taskMatch?.[1], taskId: taskMatch?.[1],
taskName: taskMatch?.[2]?.trim(), taskName: taskMatch?.[2]?.trim(),
}; };
} }
// Check for PREREQ_COMPLETE with prereq info // Check for PREREQ_COMPLETE with prereq info
// Format: PREREQ_COMPLETE: P1.1 - Verified Phase 1 complete // Format: PREREQ_COMPLETE: P1.1 - Verified Phase 1 complete
const prereqMatch = output.match(/PREREQ_COMPLETE[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/i); const prereqMatch = output.match(/PREREQ_COMPLETE[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/i);
if (prereqMatch) { if (prereqMatch) {
return { return {
completed: true, completed: true,
marker: 'PREREQ_COMPLETE', marker: 'PREREQ_COMPLETE',
taskId: prereqMatch[1], taskId: prereqMatch[1],
taskName: prereqMatch[2].trim(), taskName: prereqMatch[2].trim(),
}; };
} }
// Check for PREREQ_ASSUMED with prereq info // Check for PREREQ_ASSUMED with prereq info
// Format: PREREQ_ASSUMED: P2.1 - Design approval (cannot verify) // Format: PREREQ_ASSUMED: P2.1 - Design approval (cannot verify)
const assumedMatch = output.match(/PREREQ_ASSUMED[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/i); const assumedMatch = output.match(/PREREQ_ASSUMED[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/i);
if (assumedMatch) { if (assumedMatch) {
return { return {
completed: true, completed: true,
marker: 'PREREQ_ASSUMED', marker: 'PREREQ_ASSUMED',
taskId: assumedMatch[1], taskId: assumedMatch[1],
taskName: assumedMatch[2].trim(), taskName: assumedMatch[2].trim(),
}; };
} }
// Check for TASK_BLOCKED with task info and reason // Check for TASK_BLOCKED with task info and reason
// Format: TASK_BLOCKED: 1.1 - Reason why blocked // Format: TASK_BLOCKED: 1.1 - Reason why blocked
const blockedWithTaskMatch = output.match(/TASK_BLOCKED[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/i); const blockedWithTaskMatch = output.match(/TASK_BLOCKED[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/i);
if (blockedWithTaskMatch) { if (blockedWithTaskMatch) {
return { return {
completed: true, completed: true,
marker: 'TASK_BLOCKED', marker: 'TASK_BLOCKED',
taskId: blockedWithTaskMatch[1], taskId: blockedWithTaskMatch[1],
reason: blockedWithTaskMatch[2].trim(), reason: blockedWithTaskMatch[2].trim(),
}; };
} }
// TASK_BLOCKED with just reason (no task ID) // TASK_BLOCKED with just reason (no task ID)
const blockedMatch = output.match(/TASK_BLOCKED:\s*(.+?)(?:\n|$)/); const blockedMatch = output.match(/TASK_BLOCKED:\s*(.+?)(?:\n|$)/);
if (blockedMatch) { if (blockedMatch) {
return { return {
completed: true, completed: true,
marker: 'TASK_BLOCKED', marker: 'TASK_BLOCKED',
reason: blockedMatch[1].trim() reason: blockedMatch[1].trim()
}; };
} }
// Simple TASK_BLOCKED without any info // Simple TASK_BLOCKED without any info
if (output.includes('TASK_BLOCKED')) { if (output.includes('TASK_BLOCKED')) {
return { completed: true, marker: 'TASK_BLOCKED', reason: 'No reason provided' }; return { completed: true, marker: 'TASK_BLOCKED', reason: 'No reason provided' };
} }
return { completed: false }; return { completed: false };
} }
/** /**
* Extract ALL completion markers from output (for phase mode). * Extract ALL completion markers from output (for phase mode).
* Returns an array of all TASK_COMPLETE/TASK_BLOCKED markers found, * Returns an array of all TASK_COMPLETE/TASK_BLOCKED markers found,
* plus whether LOOP_COMPLETE or PHASE_COMPLETE was present. * plus whether LOOP_COMPLETE or PHASE_COMPLETE was present.
*/ */
export function checkForAllCompletions(output: string): { export function checkForAllCompletions(output: string): {
tasks: CompletionCheckResult[]; tasks: CompletionCheckResult[];
loopComplete: boolean; loopComplete: boolean;
phaseComplete: boolean; phaseComplete: boolean;
} { } {
const tasks: CompletionCheckResult[] = []; const tasks: CompletionCheckResult[] = [];
let loopComplete = false; let loopComplete = false;
let phaseComplete = false; let phaseComplete = false;
if (output.includes('LOOP_COMPLETE')) { if (output.includes('LOOP_COMPLETE')) {
loopComplete = true; loopComplete = true;
} }
if (output.includes('PHASE_COMPLETE')) { if (output.includes('PHASE_COMPLETE')) {
phaseComplete = true; phaseComplete = true;
} }
// Find all TASK_COMPLETE markers with task info // Find all TASK_COMPLETE markers with task info
// Format: TASK_COMPLETE: 1.1 - Task description // Format: TASK_COMPLETE: 1.1 - Task description
const completeRegex = /TASK_COMPLETE[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/gi; const completeRegex = /TASK_COMPLETE[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/gi;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = completeRegex.exec(output)) !== null) { while ((match = completeRegex.exec(output)) !== null) {
tasks.push({ tasks.push({
completed: true, completed: true,
marker: 'TASK_COMPLETE', marker: 'TASK_COMPLETE',
taskId: match[1], taskId: match[1],
taskName: match[2].trim(), taskName: match[2].trim(),
}); });
} }
// Find all TASK_BLOCKED markers with task info // Find all TASK_BLOCKED markers with task info
const blockedRegex = /TASK_BLOCKED[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/gi; const blockedRegex = /TASK_BLOCKED[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/gi;
while ((match = blockedRegex.exec(output)) !== null) { while ((match = blockedRegex.exec(output)) !== null) {
tasks.push({ tasks.push({
completed: true, completed: true,
marker: 'TASK_BLOCKED', marker: 'TASK_BLOCKED',
taskId: match[1], taskId: match[1],
reason: match[2].trim(), reason: match[2].trim(),
}); });
} }
// Find PREREQ_COMPLETE markers // Find PREREQ_COMPLETE markers
const prereqRegex = /PREREQ_COMPLETE[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/gi; const prereqRegex = /PREREQ_COMPLETE[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/gi;
while ((match = prereqRegex.exec(output)) !== null) { while ((match = prereqRegex.exec(output)) !== null) {
tasks.push({ tasks.push({
completed: true, completed: true,
marker: 'PREREQ_COMPLETE', marker: 'PREREQ_COMPLETE',
taskId: match[1], taskId: match[1],
taskName: match[2].trim(), taskName: match[2].trim(),
}); });
} }
// Find PREREQ_ASSUMED markers // Find PREREQ_ASSUMED markers
const assumedRegex = /PREREQ_ASSUMED[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/gi; const assumedRegex = /PREREQ_ASSUMED[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/gi;
while ((match = assumedRegex.exec(output)) !== null) { while ((match = assumedRegex.exec(output)) !== null) {
tasks.push({ tasks.push({
completed: true, completed: true,
marker: 'PREREQ_ASSUMED', marker: 'PREREQ_ASSUMED',
taskId: match[1], taskId: match[1],
taskName: match[2].trim(), taskName: match[2].trim(),
}); });
} }
return { tasks, loopComplete, phaseComplete }; return { tasks, loopComplete, phaseComplete };
} }
+136 -136
View File
@@ -1,136 +1,136 @@
import { execa } from 'execa'; import { execa } from 'execa';
import { existsSync, readFileSync, writeFileSync } from 'fs'; import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { logger } from './logger.js'; import { logger } from './logger.js';
export interface GitCommitOptions { export interface GitCommitOptions {
taskName: string; taskName: string;
jiraTicketId?: string; jiraTicketId?: string;
cwd?: string; cwd?: string;
} }
/** /**
* Check if directory is a git repository * Check if directory is a git repository
*/ */
export async function isGitRepo(cwd: string): Promise<boolean> { export async function isGitRepo(cwd: string): Promise<boolean> {
const result = await execa('git', ['rev-parse', '--git-dir'], { cwd, reject: false }); const result = await execa('git', ['rev-parse', '--git-dir'], { cwd, reject: false });
return result.exitCode === 0; return result.exitCode === 0;
} }
/** /**
* Initialize a git repository if one doesn't exist * Initialize a git repository if one doesn't exist
*/ */
export async function ensureGitRepo(cwd: string): Promise<boolean> { export async function ensureGitRepo(cwd: string): Promise<boolean> {
if (await isGitRepo(cwd)) { if (await isGitRepo(cwd)) {
return true; return true;
} }
logger.info('Initializing git repository...'); logger.info('Initializing git repository...');
const result = await execa('git', ['init'], { cwd, reject: false }); const result = await execa('git', ['init'], { cwd, reject: false });
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
logger.error(`Failed to initialize git repo: ${result.stderr}`); logger.error(`Failed to initialize git repo: ${result.stderr}`);
return false; return false;
} }
logger.success('Git repository initialized'); logger.success('Git repository initialized');
return true; return true;
} }
/** /**
* Required entries for the .gitignore file * Required entries for the .gitignore file
*/ */
const REQUIRED_GITIGNORE_ENTRIES = ['specs/', 'specs--completed/', '.plan2code-loop', '.plan2code-metrics', 'nul', 'node_modules/']; const REQUIRED_GITIGNORE_ENTRIES = ['specs/', 'specs--completed/', '.plan2code-loop', '.plan2code-metrics', 'nul', 'node_modules/'];
/** /**
* Ensure .gitignore exists with required entries * Ensure .gitignore exists with required entries
*/ */
export function ensureGitignore(cwd: string): void { export function ensureGitignore(cwd: string): void {
const gitignorePath = join(cwd, '.gitignore'); const gitignorePath = join(cwd, '.gitignore');
let content = ''; let content = '';
if (existsSync(gitignorePath)) { if (existsSync(gitignorePath)) {
content = readFileSync(gitignorePath, 'utf-8'); content = readFileSync(gitignorePath, 'utf-8');
} }
const lines = content.split('\n').map(line => line.trim()); const lines = content.split('\n').map(line => line.trim());
const missingEntries = REQUIRED_GITIGNORE_ENTRIES.filter(entry => !lines.includes(entry)); const missingEntries = REQUIRED_GITIGNORE_ENTRIES.filter(entry => !lines.includes(entry));
if (missingEntries.length > 0) { if (missingEntries.length > 0) {
const needsNewline = content.length > 0 && !content.endsWith('\n'); const needsNewline = content.length > 0 && !content.endsWith('\n');
const newContent = content + (needsNewline ? '\n' : '') + missingEntries.join('\n') + '\n'; const newContent = content + (needsNewline ? '\n' : '') + missingEntries.join('\n') + '\n';
writeFileSync(gitignorePath, newContent); writeFileSync(gitignorePath, newContent);
logger.dim(`Added to .gitignore: ${missingEntries.join(', ')}`); logger.dim(`Added to .gitignore: ${missingEntries.join(', ')}`);
} }
} }
/** /**
* Create a local git commit for a completed task * Create a local git commit for a completed task
*/ */
export async function createTaskCommit(options: GitCommitOptions): Promise<boolean> { export async function createTaskCommit(options: GitCommitOptions): Promise<boolean> {
const { taskName, jiraTicketId, cwd = process.cwd() } = options; const { taskName, jiraTicketId, cwd = process.cwd() } = options;
logger.dim(`Git commit check in: ${cwd}`); logger.dim(`Git commit check in: ${cwd}`);
try { try {
// Ensure we have a git repo // Ensure we have a git repo
if (!await ensureGitRepo(cwd)) { if (!await ensureGitRepo(cwd)) {
return false; return false;
} }
// Ensure .gitignore exists with required entries // Ensure .gitignore exists with required entries
ensureGitignore(cwd); ensureGitignore(cwd);
// Check if there are any changes to commit // Check if there are any changes to commit
const statusResult = await execa('git', ['status', '--porcelain'], { cwd, reject: false }); const statusResult = await execa('git', ['status', '--porcelain'], { cwd, reject: false });
// Debug: show what git status returned // Debug: show what git status returned
if (statusResult.stdout?.trim()) { if (statusResult.stdout?.trim()) {
logger.dim(`Git status found changes:\n${statusResult.stdout.slice(0, 500)}`); logger.dim(`Git status found changes:\n${statusResult.stdout.slice(0, 500)}`);
} }
if (statusResult.exitCode !== 0) { if (statusResult.exitCode !== 0) {
logger.error(`Git status failed: ${statusResult.stderr}`); logger.error(`Git status failed: ${statusResult.stderr}`);
return false; return false;
} }
if (!statusResult.stdout?.trim()) { if (!statusResult.stdout?.trim()) {
logger.dim('No changes to commit'); logger.dim('No changes to commit');
return false; return false;
} }
// Stage all changes // Stage all changes
const addResult = await execa('git', ['add', '-A'], { cwd, reject: false }); const addResult = await execa('git', ['add', '-A'], { cwd, reject: false });
if (addResult.exitCode !== 0) { if (addResult.exitCode !== 0) {
logger.error(`Git add failed: ${addResult.stderr}`); logger.error(`Git add failed: ${addResult.stderr}`);
return false; return false;
} }
// Build commit message // Build commit message
let commitMessage = taskName; let commitMessage = taskName;
if (jiraTicketId) { if (jiraTicketId) {
commitMessage = `${taskName}\n\n${jiraTicketId}\nAI Assisted`; commitMessage = `${taskName}\n\n${jiraTicketId}\nAI Assisted`;
} else { } else {
commitMessage = `${taskName}\n\nAI Assisted`; commitMessage = `${taskName}\n\nAI Assisted`;
} }
// Create the commit // Create the commit
const commitResult = await execa('git', ['commit', '-m', commitMessage], { cwd, reject: false }); const commitResult = await execa('git', ['commit', '-m', commitMessage], { cwd, reject: false });
if (commitResult.exitCode !== 0) { if (commitResult.exitCode !== 0) {
// Check if it's just "nothing to commit" vs actual error // Check if it's just "nothing to commit" vs actual error
if (commitResult.stdout?.includes('nothing to commit') || commitResult.stderr?.includes('nothing to commit')) { if (commitResult.stdout?.includes('nothing to commit') || commitResult.stderr?.includes('nothing to commit')) {
logger.dim('No changes to commit'); logger.dim('No changes to commit');
return false; return false;
} }
logger.error(`Git commit failed: ${commitResult.stderr || commitResult.stdout}`); logger.error(`Git commit failed: ${commitResult.stderr || commitResult.stdout}`);
return false; return false;
} }
logger.success(`Created commit: ${taskName}${jiraTicketId ? ` (${jiraTicketId})` : ''}`); logger.success(`Created commit: ${taskName}${jiraTicketId ? ` (${jiraTicketId})` : ''}`);
return true; return true;
} catch (error) { } catch (error) {
logger.error(`Failed to create commit: ${error instanceof Error ? error.message : String(error)}`); logger.error(`Failed to create commit: ${error instanceof Error ? error.message : String(error)}`);
return false; return false;
} }
} }
+19 -19
View File
@@ -1,19 +1,19 @@
export { logger, MASCOT, type Logger } from './logger.js'; export { logger, MASCOT, type Logger } from './logger.js';
export { export {
checkForCompletion, checkForCompletion,
checkForAllCompletions, checkForAllCompletions,
type CompletionMarker, type CompletionMarker,
type CompletionCheckResult type CompletionCheckResult
} from './completion.js'; } from './completion.js';
export { export {
executeCommand, executeCommand,
type ExecuteOptions, type ExecuteOptions,
type ExecuteResult type ExecuteResult
} from './process.js'; } from './process.js';
export { export {
createTaskCommit, createTaskCommit,
isGitRepo, isGitRepo,
ensureGitRepo, ensureGitRepo,
ensureGitignore, ensureGitignore,
type GitCommitOptions type GitCommitOptions
} from './git.js'; } from './git.js';
+81 -81
View File
@@ -1,81 +1,81 @@
import { execa, type Options as ExecaOptions } from 'execa'; import { execa, type Options as ExecaOptions } from 'execa';
const MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10MB const MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10MB
function truncateOutput(output: string, maxSize: number): string { function truncateOutput(output: string, maxSize: number): string {
if (output.length > maxSize) { if (output.length > maxSize) {
return output.slice(0, maxSize) + '\n...[truncated]'; return output.slice(0, maxSize) + '\n...[truncated]';
} }
return output; return output;
} }
export interface ExecuteOptions { export interface ExecuteOptions {
command: string; command: string;
args: string[]; args: string[];
cwd: string; cwd: string;
timeout: number; // milliseconds timeout: number; // milliseconds
env?: Record<string, string>; env?: Record<string, string>;
signal?: AbortSignal; // For cancellation signal?: AbortSignal; // For cancellation
stdin?: string; // Input to pass via stdin as string stdin?: string; // Input to pass via stdin as string
stdinFile?: string; // Path to file to pipe as stdin stdinFile?: string; // Path to file to pipe as stdin
} }
export interface ExecuteResult { export interface ExecuteResult {
stdout: string; stdout: string;
stderr: string; stderr: string;
exitCode: number; exitCode: number;
timedOut: boolean; timedOut: boolean;
cancelled: boolean; cancelled: boolean;
duration: number; // milliseconds duration: number; // milliseconds
} }
export async function executeCommand( export async function executeCommand(
options: ExecuteOptions options: ExecuteOptions
): Promise<ExecuteResult> { ): Promise<ExecuteResult> {
const startTime = Date.now(); const startTime = Date.now();
try { try {
const execaOptions: ExecaOptions = { const execaOptions: ExecaOptions = {
cwd: options.cwd, cwd: options.cwd,
timeout: options.timeout, timeout: options.timeout,
env: { ...process.env, ...options.env }, env: { ...process.env, ...options.env },
reject: false, reject: false,
all: true, all: true,
}; };
// Add cancellation signal if provided // Add cancellation signal if provided
if (options.signal) { if (options.signal) {
(execaOptions as any).cancelSignal = options.signal; (execaOptions as any).cancelSignal = options.signal;
} }
// Add stdin input if provided (string or file) // Add stdin input if provided (string or file)
if (options.stdin) { if (options.stdin) {
(execaOptions as any).input = options.stdin; (execaOptions as any).input = options.stdin;
} else if (options.stdinFile) { } else if (options.stdinFile) {
(execaOptions as any).inputFile = options.stdinFile; (execaOptions as any).inputFile = options.stdinFile;
} }
const result = await execa(options.command, options.args, execaOptions); const result = await execa(options.command, options.args, execaOptions);
return { return {
stdout: truncateOutput(result.stdout || '', MAX_OUTPUT_SIZE), stdout: truncateOutput(result.stdout || '', MAX_OUTPUT_SIZE),
stderr: truncateOutput(result.stderr || '', MAX_OUTPUT_SIZE), stderr: truncateOutput(result.stderr || '', MAX_OUTPUT_SIZE),
exitCode: result.exitCode ?? 1, exitCode: result.exitCode ?? 1,
timedOut: result.timedOut ?? false, timedOut: result.timedOut ?? false,
cancelled: result.isCanceled ?? false, cancelled: result.isCanceled ?? false,
duration: Date.now() - startTime, duration: Date.now() - startTime,
}; };
} catch (error: any) { } catch (error: any) {
// Check if this was a cancellation // Check if this was a cancellation
const isCancelled = error?.isCanceled || options.signal?.aborted; const isCancelled = error?.isCanceled || options.signal?.aborted;
return { return {
stdout: error?.stdout || '', stdout: error?.stdout || '',
stderr: error?.stderr || (error instanceof Error ? error.message : String(error)), stderr: error?.stderr || (error instanceof Error ? error.message : String(error)),
exitCode: isCancelled ? -1 : 1, exitCode: isCancelled ? -1 : 1,
timedOut: false, timedOut: false,
cancelled: isCancelled, cancelled: isCancelled,
duration: Date.now() - startTime, duration: Date.now() - startTime,
}; };
} }
} }
+20 -20
View File
@@ -1,20 +1,20 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"lib": ["ES2022"], "lib": ["ES2022"],
"outDir": "dist", "outDir": "dist",
"rootDir": ".", "rootDir": ".",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"sourceMap": true "sourceMap": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }
+15 -15
View File
@@ -1,15 +1,15 @@
import { defineConfig } from 'tsup'; import { defineConfig } from 'tsup';
export default defineConfig({ export default defineConfig({
entry: { entry: {
'bin/plan2code-loop': 'src/bin/plan2code-loop.ts', 'bin/plan2code-loop': 'src/bin/plan2code-loop.ts',
index: 'src/index.ts', index: 'src/index.ts',
}, },
format: ['esm'], format: ['esm'],
dts: false, dts: false,
clean: true, clean: true,
sourcemap: true, sourcemap: true,
banner: { banner: {
js: '#!/usr/bin/env node', js: '#!/usr/bin/env node',
}, },
}); });