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
# altered by line-ending normalization. Treat it as binary so autocrlf/eol
# settings can never corrupt the ciphertext.
+16 -16
View File
@@ -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
+82 -82
View File
@@ -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<AgentExecutionResult> {
// 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<boolean> {
// 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<AgentExecutionResult> {
// 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<boolean> {
// 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();
+70 -70
View File
@@ -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<AgentExecutionResult> {
// 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<boolean> {
// 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<AgentExecutionResult> {
// 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<boolean> {
// 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();
+34 -34
View File
@@ -1,34 +1,34 @@
import type { Agent } from './types.js';
class AgentRegistry {
private agents: Map<string, Agent> = 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<Agent[]> {
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<string, Agent> = 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<Agent[]> {
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();
+34 -34
View File
@@ -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();
File diff suppressed because it is too large Load Diff
+96 -96
View File
@@ -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<LoopResult | null> {
// 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<LoopResult | null> {
// 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';
+39 -39
View File
@@ -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<string> {
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<string> {
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;
}
+6 -6
View File
@@ -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';
+200 -200
View File
@@ -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>"
\`\`\`
**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>"
\`\`\`
**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.
`;
+4 -4
View File
@@ -1,4 +1,4 @@
export {
detectSpecDirectories,
getSpecProgress,
} from './utils.js';
export {
detectSpecDirectories,
getSpecProgress,
} from './utils.js';
+70 -70
View File
@@ -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<string[]> {
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<string[]> {
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,
};
}
+9 -9
View File
@@ -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;
}
+10 -10
View File
@@ -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';
+231 -231
View File
@@ -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<boolean> {
const existed = await fs.pathExists(this.stateDir);
await fs.ensureDir(this.stateDir);
return existed;
}
getStateDir(): string {
return this.stateDir;
}
// Config operations
async readConfig(): Promise<SessionConfig | null> {
try {
const content = await fs.readFile(this.configPath, 'utf8');
return JSON.parse(content) as SessionConfig;
} catch {
return null;
}
}
async writeConfig(config: SessionConfig): Promise<void> {
await fs.writeFile(
this.configPath,
JSON.stringify(config, null, 2),
'utf8'
);
}
async updateConfig(updates: Partial<SessionConfig>): Promise<SessionConfig> {
const existing = await this.readConfig();
const updated = { ...DEFAULT_CONFIG, ...existing, ...updates } as SessionConfig;
await this.writeConfig(updated);
return updated;
}
async incrementIteration(): Promise<number> {
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<boolean> {
const [configExists, scratchpadExists] = await Promise.all([
fs.pathExists(this.configPath),
fs.pathExists(this.scratchpadPath),
]);
return configExists || scratchpadExists;
}
async detectSessionState(specPath: string): Promise<SessionState> {
// 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<string> {
const overviewPath = path.join(specPath, 'overview.md');
try {
const content = await fs.readFile(overviewPath, 'utf8');
return computeHash(content);
} catch {
return '';
}
}
async readStoredHash(): Promise<string | null> {
try {
return await fs.readFile(this.hashPath, 'utf8');
} catch {
return null;
}
}
async storeHash(hash: string): Promise<void> {
await fs.writeFile(this.hashPath, hash, 'utf8');
}
async hasSpecChanged(specPath: string): Promise<boolean> {
const stored = await this.readStoredHash();
if (!stored) return true;
const current = await this.computeSpecHash(specPath);
return !hashesMatch(stored, current);
}
async updateSpecHash(specPath: string): Promise<void> {
const hash = await this.computeSpecHash(specPath);
await this.storeHash(hash);
}
// Scratchpad operations
async initializeScratchpad(): Promise<void> {
await fs.writeFile(this.scratchpadPath, SCRATCHPAD_TEMPLATE, 'utf8');
}
async readScratchpad(): Promise<string> {
try {
return await fs.readFile(this.scratchpadPath, 'utf8');
} catch {
return '';
}
}
async writeScratchpad(content: string): Promise<void> {
await fs.writeFile(this.scratchpadPath, content, 'utf8');
}
// Iteration log operations
async appendIterationLog(entry: IterationLogEntry): Promise<void> {
const line = JSON.stringify(entry) + '\n';
await fs.appendFile(this.logPath, line, 'utf8');
}
async readIterationLog(): Promise<IterationLogEntry[]> {
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<IterationLogEntry | null> {
const log = await this.readIterationLog();
return log.length > 0 ? log[log.length - 1] : null;
}
// State clearing and initialization
async clearState(): Promise<void> {
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<void> {
// 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<boolean> {
const existed = await fs.pathExists(this.stateDir);
await fs.ensureDir(this.stateDir);
return existed;
}
getStateDir(): string {
return this.stateDir;
}
// Config operations
async readConfig(): Promise<SessionConfig | null> {
try {
const content = await fs.readFile(this.configPath, 'utf8');
return JSON.parse(content) as SessionConfig;
} catch {
return null;
}
}
async writeConfig(config: SessionConfig): Promise<void> {
await fs.writeFile(
this.configPath,
JSON.stringify(config, null, 2),
'utf8'
);
}
async updateConfig(updates: Partial<SessionConfig>): Promise<SessionConfig> {
const existing = await this.readConfig();
const updated = { ...DEFAULT_CONFIG, ...existing, ...updates } as SessionConfig;
await this.writeConfig(updated);
return updated;
}
async incrementIteration(): Promise<number> {
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<boolean> {
const [configExists, scratchpadExists] = await Promise.all([
fs.pathExists(this.configPath),
fs.pathExists(this.scratchpadPath),
]);
return configExists || scratchpadExists;
}
async detectSessionState(specPath: string): Promise<SessionState> {
// 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<string> {
const overviewPath = path.join(specPath, 'overview.md');
try {
const content = await fs.readFile(overviewPath, 'utf8');
return computeHash(content);
} catch {
return '';
}
}
async readStoredHash(): Promise<string | null> {
try {
return await fs.readFile(this.hashPath, 'utf8');
} catch {
return null;
}
}
async storeHash(hash: string): Promise<void> {
await fs.writeFile(this.hashPath, hash, 'utf8');
}
async hasSpecChanged(specPath: string): Promise<boolean> {
const stored = await this.readStoredHash();
if (!stored) return true;
const current = await this.computeSpecHash(specPath);
return !hashesMatch(stored, current);
}
async updateSpecHash(specPath: string): Promise<void> {
const hash = await this.computeSpecHash(specPath);
await this.storeHash(hash);
}
// Scratchpad operations
async initializeScratchpad(): Promise<void> {
await fs.writeFile(this.scratchpadPath, SCRATCHPAD_TEMPLATE, 'utf8');
}
async readScratchpad(): Promise<string> {
try {
return await fs.readFile(this.scratchpadPath, 'utf8');
} catch {
return '';
}
}
async writeScratchpad(content: string): Promise<void> {
await fs.writeFile(this.scratchpadPath, content, 'utf8');
}
// Iteration log operations
async appendIterationLog(entry: IterationLogEntry): Promise<void> {
const line = JSON.stringify(entry) + '\n';
await fs.appendFile(this.logPath, line, 'utf8');
}
async readIterationLog(): Promise<IterationLogEntry[]> {
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<IterationLogEntry | null> {
const log = await this.readIterationLog();
return log.length > 0 ? log[log.length - 1] : null;
}
// State clearing and initialization
async clearState(): Promise<void> {
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<void> {
// 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);
}
}
+169 -169
View File
@@ -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 };
}
+136 -136
View File
@@ -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<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/', '.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<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}\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<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/', '.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<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}\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;
}
}
+19 -19
View File
@@ -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';
+81 -81
View File
@@ -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<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,
};
}
}
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,
};
}
}
+20 -20
View File
@@ -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"]
}
+15 -15
View File
@@ -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',
},
});