mirror of
https://github.com/jparkerweb/plan2code.git
synced 2026-07-21 18:33:22 -07:00
v1.5.2
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
# Plan2Code Loop
|
||||
|
||||
An autonomous CLI tool that implements Plan2Code specs by looping through tasks automatically.
|
||||
|
||||
> **Note:** This is an **alternative** to `/plan2code-3--implement`, not a replacement. Use the manual Step 3 workflow when you want interactive control over each phase, or use this loop when you prefer hands-off autonomous execution.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# From the plan2code root directory:
|
||||
|
||||
# Option 1: Install everything (recommended)
|
||||
node install.js # Select option A
|
||||
|
||||
# Option 2: Install loop only
|
||||
node install.js # Select option O
|
||||
|
||||
# Option 3: Manual build and link
|
||||
cd plan2code-loop
|
||||
npm install
|
||||
npm run build
|
||||
npm link
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Simply run the command - everything is interactive:
|
||||
|
||||
```bash
|
||||
plan2code-loop
|
||||
```
|
||||
|
||||
The CLI will:
|
||||
1. Auto-detect specs in `./specs/` directory
|
||||
2. Let you select a spec if multiple are found
|
||||
3. Prompt to continue if an existing session is found
|
||||
4. Ask for JIRA ticket ID, agent selection, and max iterations
|
||||
|
||||
## How It Works
|
||||
|
||||
The loop uses an **LLM-driven discovery** approach:
|
||||
|
||||
1. **Spec Selection** - Interactive menu to select from discovered specs
|
||||
2. **Task Discovery** - The AI reads `overview.md` and phase files to find unchecked tasks
|
||||
3. **Implementation** - The AI implements ONE task per iteration
|
||||
4. **Checkbox Update** - The AI marks the task complete in the markdown file
|
||||
5. **Scratchpad Update** - The AI appends notes to the per-spec scratchpad
|
||||
6. **Completion Marker** - The AI outputs a structured marker (e.g., `TASK_COMPLETE: 1.1 - description`)
|
||||
7. **Loop** - Repeat until all tasks done or max iterations reached
|
||||
|
||||
### Why LLM-Driven?
|
||||
|
||||
The Node app does NOT parse markdown to find tasks. Instead, the AI reads the spec files directly and decides what to work on. This is:
|
||||
|
||||
- **More flexible** - Works with any reasonable markdown format
|
||||
- **Smarter** - AI can handle edge cases and ambiguity
|
||||
- **Simpler** - Node code is just orchestration, not parsing
|
||||
|
||||
## Completion Markers
|
||||
|
||||
The AI must output one of these markers at the end of each iteration:
|
||||
|
||||
```
|
||||
TASK_COMPLETE: 1.1 - Initialize project structure
|
||||
TASK_BLOCKED: 2.3 - Missing API credentials
|
||||
LOOP_COMPLETE
|
||||
```
|
||||
|
||||
| Marker | Meaning |
|
||||
|--------|---------|
|
||||
| `TASK_COMPLETE: X.X - desc` | Task implemented and marked complete |
|
||||
| `TASK_BLOCKED: X.X - reason` | Cannot complete task (explains why) |
|
||||
| `LOOP_COMPLETE` | All phases finished |
|
||||
|
||||
## Session Files
|
||||
|
||||
Session state is stored **per-spec** inside the spec directory:
|
||||
|
||||
```
|
||||
specs/my-feature/
|
||||
├── overview.md
|
||||
├── phase-1.md
|
||||
├── phase-2.md
|
||||
└── .plan2code-loop/ # Per-spec session state
|
||||
├── config.json # Session configuration
|
||||
├── scratchpad.md # LLM-managed progress notes
|
||||
├── iteration.log # JSON log of each iteration
|
||||
└── spec.hash # Hash for detecting spec changes
|
||||
```
|
||||
|
||||
The scratchpad is managed by the LLM itself - after each task, the AI appends notes about what was done, decisions made, and files changed. This helps future iterations skip exploration.
|
||||
|
||||
## Supported Agents
|
||||
|
||||
| Agent | Status |
|
||||
|-------|--------|
|
||||
| Claude Code | Supported |
|
||||
| GitHub Copilot CLI | Supported |
|
||||
|
||||
The loop uses your configured default model for each agent.
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
$ plan2code-loop
|
||||
|
||||
╭──────────────────────────────────────╮
|
||||
│ │
|
||||
│ 🔮 Plany's Loop │
|
||||
│ Autonomous Implementation │
|
||||
│ │
|
||||
╰──────────────────────────────────────╯
|
||||
|
||||
Found spec: specs/todo-app
|
||||
Feature: Todo App
|
||||
Phases: 0/3
|
||||
Tasks: 0/15
|
||||
|
||||
══════════════════════════════════════════════
|
||||
Spec: Todo App
|
||||
══════════════════════════════════════════════
|
||||
Phases: 0/3
|
||||
Tasks: 0/15
|
||||
|
||||
? JIRA Ticket ID (optional): PROJ-123
|
||||
? Select AI agent: Claude Code
|
||||
? Maximum iterations: 100
|
||||
|
||||
Starting Plan2Code Loop
|
||||
══════════════════════════════════════════════
|
||||
Agent: Claude Code
|
||||
Model: default
|
||||
Spec: C:\projects\my-app\specs\todo-app
|
||||
Max iterations: 100
|
||||
|
||||
Iteration 1/100
|
||||
[1/100] Task 1.1: Initialize project with Vite (45s)
|
||||
✓ Completed: Task 1.1: Initialize project with Vite
|
||||
|
||||
Iteration 2/100
|
||||
[2/100] Task 1.2: Configure TypeScript (32s)
|
||||
✓ Completed: Task 1.2: Configure TypeScript
|
||||
|
||||
...
|
||||
|
||||
Session Summary
|
||||
══════════════════════════════════════════════
|
||||
Total iterations: 12
|
||||
Tasks completed: 12
|
||||
Exit reason: all_complete
|
||||
|
||||
Session files saved to specs/todo-app/.plan2code-loop:
|
||||
- config.json (session configuration)
|
||||
- scratchpad.md (LLM-managed notes)
|
||||
- iteration.log (history)
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Link for global usage
|
||||
npm link
|
||||
|
||||
# Unlink
|
||||
npm unlink
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── bin/plan2code-loop.ts # CLI entry point
|
||||
├── index.ts # Main exports
|
||||
├── controller.ts # Loop orchestration
|
||||
├── cli.ts # Interactive prompts
|
||||
├── agents/ # Agent implementations
|
||||
│ ├── claude-code.ts
|
||||
│ └── copilot-cli.ts
|
||||
├── prompt/ # Prompt building
|
||||
│ ├── templates.ts
|
||||
│ └── builder.ts
|
||||
├── state/ # Session state management
|
||||
│ └── manager.ts
|
||||
├── spec/ # Spec utilities (for CLI display)
|
||||
│ └── utils.ts
|
||||
└── utils/ # Utilities
|
||||
├── completion.ts # Marker parsing
|
||||
└── logger.ts
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "plan2code-loop",
|
||||
"version": "1.0.0",
|
||||
"description": "Plan2Code Loop - Autonomous spec-driven implementation CLI",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"plan2code-loop": "./dist/bin/plan2code-loop.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"cli",
|
||||
"ai",
|
||||
"agent",
|
||||
"automation",
|
||||
"claude",
|
||||
"copilot",
|
||||
"spec-driven"
|
||||
],
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"start": "node dist/bin/plan2code-loop.js",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/prompts": "^8.1.0",
|
||||
"chalk": "^5.6.2",
|
||||
"execa": "^9.6.1",
|
||||
"fs-extra": "^11.3.3",
|
||||
"ora": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^25.0.3",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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();
|
||||
@@ -0,0 +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();
|
||||
@@ -0,0 +1,19 @@
|
||||
export type {
|
||||
Agent,
|
||||
AgentConfig,
|
||||
AgentExecutionOptions,
|
||||
AgentExecutionResult,
|
||||
ModelOption,
|
||||
} from './types.js';
|
||||
|
||||
export { agentRegistry } from './registry.js';
|
||||
export { claudeCodeAgent } from './claude-code.js';
|
||||
export { copilotCliAgent } from './copilot-cli.js';
|
||||
|
||||
// Register all agents
|
||||
import { agentRegistry } from './registry.js';
|
||||
import { claudeCodeAgent } from './claude-code.js';
|
||||
import { copilotCliAgent } from './copilot-cli.js';
|
||||
|
||||
agentRegistry.register(claudeCodeAgent);
|
||||
agentRegistry.register(copilotCliAgent);
|
||||
@@ -0,0 +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();
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface ModelOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
name: string;
|
||||
displayName: string;
|
||||
command: string;
|
||||
models: ModelOption[];
|
||||
defaultModel: string;
|
||||
flags: {
|
||||
prompt: string;
|
||||
model: string;
|
||||
skipPermissions: string;
|
||||
silent?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentExecutionOptions {
|
||||
prompt: string;
|
||||
model: string;
|
||||
timeout: number; // milliseconds
|
||||
verbose: boolean;
|
||||
cwd: string;
|
||||
signal?: AbortSignal; // For cancellation
|
||||
}
|
||||
|
||||
export interface AgentExecutionResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
timedOut: boolean;
|
||||
cancelled: boolean;
|
||||
duration: number; // milliseconds
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
config: AgentConfig;
|
||||
execute(options: AgentExecutionOptions): Promise<AgentExecutionResult>;
|
||||
isAvailable(): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +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();
|
||||
@@ -0,0 +1,228 @@
|
||||
import path from 'path';
|
||||
import { confirm, input, select } from '@inquirer/prompts';
|
||||
import { agentRegistry } from './agents/index.js';
|
||||
import { StateManager, type SessionConfig } from './state/index.js';
|
||||
import { detectSpecDirectories, getSpecProgress } from './spec/utils.js';
|
||||
import { logger } from './utils/index.js';
|
||||
|
||||
export interface SessionSetupResult {
|
||||
config: SessionConfig;
|
||||
isResume: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and select a spec directory
|
||||
*/
|
||||
async function selectSpec(cwd: string = process.cwd()): Promise<string | null> {
|
||||
// Auto-detect spec directories
|
||||
const specDirs = await detectSpecDirectories(cwd);
|
||||
|
||||
if (specDirs.length === 0) {
|
||||
logger.error('No spec directories found!');
|
||||
logger.info('Expected: specs/<feature>/overview.md');
|
||||
logger.info('');
|
||||
logger.info('To get started:');
|
||||
logger.info('1. Create a spec directory: mkdir -p specs/my-feature');
|
||||
logger.info('2. Create overview.md with phases listed as checkboxes');
|
||||
logger.info('3. Create phase-1.md, phase-2.md, etc. with task checkboxes');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (specDirs.length === 1) {
|
||||
const spec = specDirs[0];
|
||||
const progress = await getSpecProgress(spec);
|
||||
logger.info(`Found spec: ${path.relative(cwd, spec)}`);
|
||||
logger.dim(` Feature: ${progress.featureName}`);
|
||||
logger.dim(` Phases: ${progress.totalPhases}`);
|
||||
return spec;
|
||||
}
|
||||
|
||||
// Multiple specs - let user choose
|
||||
const choices = await Promise.all(
|
||||
specDirs.map(async (spec) => {
|
||||
const progress = await getSpecProgress(spec);
|
||||
const relativePath = path.relative(cwd, spec);
|
||||
return {
|
||||
name: `${progress.featureName} (${progress.totalPhases} phases) - ${relativePath}`,
|
||||
value: spec,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const selectedSpec = await select({
|
||||
message: 'Select spec to implement:',
|
||||
choices,
|
||||
});
|
||||
|
||||
return selectedSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select AI agent
|
||||
*/
|
||||
async function selectAgent(): Promise<string> {
|
||||
const availableAgents = await agentRegistry.getAvailable();
|
||||
|
||||
if (availableAgents.length === 0) {
|
||||
logger.error('No AI agents detected on your system!');
|
||||
console.log();
|
||||
logger.info('Please install at least one of the following:');
|
||||
console.log();
|
||||
logger.bold('Claude Code:');
|
||||
logger.dim(' npm install -g @anthropic-ai/claude-code');
|
||||
console.log();
|
||||
logger.bold('GitHub Copilot CLI:');
|
||||
logger.dim(' gh extension install github/gh-copilot');
|
||||
console.log();
|
||||
throw new Error('No agents available. Please install an AI agent and try again.');
|
||||
}
|
||||
|
||||
if (availableAgents.length === 1) {
|
||||
const agent = availableAgents[0];
|
||||
logger.info(`Using ${agent.config.displayName} (only available agent)`);
|
||||
return agent.config.name;
|
||||
}
|
||||
|
||||
const agentName = await select({
|
||||
message: 'Select AI agent:',
|
||||
choices: availableAgents.map((agent) => ({
|
||||
name: agent.config.displayName,
|
||||
value: agent.config.name,
|
||||
})),
|
||||
});
|
||||
|
||||
return agentName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for JIRA ticket ID
|
||||
*/
|
||||
async function promptJiraTicketId(): Promise<string | undefined> {
|
||||
const ticketId = await input({
|
||||
message: 'JIRA Ticket ID (optional, for commit messages):',
|
||||
});
|
||||
|
||||
return ticketId.trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select maximum iterations
|
||||
*/
|
||||
async function selectMaxIterations(): Promise<number> {
|
||||
const choices = [15, 30, 50, 75, 100, 125, 150, 200];
|
||||
|
||||
const max = await select({
|
||||
message: 'Maximum iterations:',
|
||||
choices: choices.map((n) => ({
|
||||
name: n.toString(),
|
||||
value: n,
|
||||
})),
|
||||
default: 100,
|
||||
});
|
||||
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle existing session - returns action to take
|
||||
*/
|
||||
async function handleExistingSession(
|
||||
stateManager: StateManager,
|
||||
specPath: string
|
||||
): Promise<'continue' | 'fresh' | 'new'> {
|
||||
const state = await stateManager.detectSessionState(specPath);
|
||||
|
||||
if (state === 'new') {
|
||||
return 'new';
|
||||
}
|
||||
|
||||
if (state === 'continue') {
|
||||
const config = await stateManager.readConfig();
|
||||
const lastIter = config?.currentIteration ?? 0;
|
||||
|
||||
logger.info(`Found existing session at iteration ${lastIter}`);
|
||||
|
||||
const continueSession = await confirm({
|
||||
message: 'Continue previous session?',
|
||||
default: true,
|
||||
});
|
||||
|
||||
return continueSession ? 'continue' : 'fresh';
|
||||
}
|
||||
|
||||
if (state === 'changed') {
|
||||
logger.warning('Spec has changed since last session.');
|
||||
|
||||
const startFresh = await confirm({
|
||||
message: 'Start fresh? (This will clear previous progress)',
|
||||
default: false,
|
||||
});
|
||||
|
||||
return startFresh ? 'fresh' : 'continue';
|
||||
}
|
||||
|
||||
return 'new';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup session via interactive prompts
|
||||
*/
|
||||
export async function setupSession(
|
||||
stateManager: StateManager
|
||||
): Promise<SessionSetupResult | null> {
|
||||
// Show welcome
|
||||
logger.welcome();
|
||||
|
||||
// Select spec
|
||||
const specPath = await selectSpec(process.cwd());
|
||||
if (!specPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set spec path on state manager for per-spec state directory
|
||||
stateManager.setSpecPath(specPath);
|
||||
|
||||
// Show spec progress
|
||||
const progress = await getSpecProgress(specPath);
|
||||
console.log();
|
||||
logger.header(`Spec: ${progress.featureName}`);
|
||||
logger.info(`Phases: ${progress.totalPhases}`);
|
||||
console.log();
|
||||
|
||||
// Check for existing session
|
||||
const sessionAction = await handleExistingSession(stateManager, specPath);
|
||||
|
||||
if (sessionAction === 'continue') {
|
||||
const existingConfig = await stateManager.readConfig();
|
||||
if (existingConfig) {
|
||||
logger.info('Resuming previous session...');
|
||||
return { config: existingConfig, isResume: true };
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionAction === 'fresh') {
|
||||
await stateManager.clearState();
|
||||
}
|
||||
|
||||
// Collect new session configuration
|
||||
const jiraTicketId = await promptJiraTicketId();
|
||||
const agent = await selectAgent();
|
||||
const maxIterations = await selectMaxIterations();
|
||||
|
||||
const config: SessionConfig = {
|
||||
agent,
|
||||
model: 'default',
|
||||
maxIterations,
|
||||
specPath,
|
||||
timeout: 30,
|
||||
verbose: false,
|
||||
startedAt: new Date().toISOString(),
|
||||
currentIteration: 0,
|
||||
jiraTicketId,
|
||||
};
|
||||
|
||||
// Initialize session
|
||||
await stateManager.initializeNewSession(config);
|
||||
|
||||
return { config, isResume: false };
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { agentRegistry, type Agent, type AgentExecutionResult } from './agents/index.js';
|
||||
import { StateManager, type SessionConfig, type IterationLogEntry } from './state/index.js';
|
||||
import { buildLoopPrompt } from './prompt/index.js';
|
||||
import { checkForCompletion, logger, type CompletionCheckResult } from './utils/index.js';
|
||||
|
||||
export interface TaskCompleteInfo {
|
||||
marker: string;
|
||||
taskId?: string;
|
||||
taskName?: string;
|
||||
}
|
||||
|
||||
export interface ControllerOptions {
|
||||
config: SessionConfig;
|
||||
stateManager: StateManager;
|
||||
onIteration?: (iteration: number, max: number) => void;
|
||||
onTaskComplete?: (info: TaskCompleteInfo) => void | Promise<void>;
|
||||
onLoopComplete?: () => void;
|
||||
}
|
||||
|
||||
export interface LoopResult {
|
||||
completed: boolean;
|
||||
iterations: number;
|
||||
finalMarker?: string;
|
||||
exitReason: 'all_complete' | 'max_iterations' | 'interrupted' | 'error';
|
||||
tasksCompleted: number;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class Controller {
|
||||
private readonly config: SessionConfig;
|
||||
private readonly stateManager: StateManager;
|
||||
private readonly agent: Agent;
|
||||
private readonly onIteration?: (iteration: number, max: number) => void;
|
||||
private readonly onTaskComplete?: (info: TaskCompleteInfo) => void | Promise<void>;
|
||||
private readonly onLoopComplete?: () => void;
|
||||
private interrupted = false;
|
||||
private abortController: AbortController | null = null;
|
||||
private tasksCompleted = 0;
|
||||
|
||||
constructor(options: ControllerOptions) {
|
||||
this.config = options.config;
|
||||
this.stateManager = options.stateManager;
|
||||
this.onIteration = options.onIteration;
|
||||
this.onTaskComplete = options.onTaskComplete;
|
||||
this.onLoopComplete = options.onLoopComplete;
|
||||
|
||||
const agent = agentRegistry.get(this.config.agent);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent not found: ${this.config.agent}`);
|
||||
}
|
||||
this.agent = agent;
|
||||
}
|
||||
|
||||
private async buildPrompt(): Promise<string> {
|
||||
return buildLoopPrompt({
|
||||
specPath: this.config.specPath,
|
||||
iteration: this.config.currentIteration + 1,
|
||||
maxIterations: this.config.maxIterations,
|
||||
stateManager: this.stateManager,
|
||||
});
|
||||
}
|
||||
|
||||
private async executeIteration(prompt: string): Promise<AgentExecutionResult> {
|
||||
const timeoutMs = this.config.timeout * 60 * 1000;
|
||||
|
||||
// Create new AbortController for this iteration
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const result = await this.agent.execute({
|
||||
prompt,
|
||||
model: this.config.model,
|
||||
timeout: timeoutMs,
|
||||
verbose: this.config.verbose,
|
||||
cwd: process.cwd(),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
this.abortController = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
private createLogEntry(
|
||||
result: AgentExecutionResult,
|
||||
status: IterationLogEntry['status'],
|
||||
marker?: string
|
||||
): IterationLogEntry {
|
||||
return {
|
||||
iteration: this.config.currentIteration + 1,
|
||||
timestamp: new Date().toISOString(),
|
||||
duration: result.duration,
|
||||
exitCode: result.exitCode,
|
||||
status,
|
||||
completionMarker: marker,
|
||||
};
|
||||
}
|
||||
|
||||
private formatTaskDisplay(completion: CompletionCheckResult): string {
|
||||
if (completion.taskId && completion.taskName) {
|
||||
return `Task ${completion.taskId}: ${completion.taskName}`;
|
||||
} else if (completion.taskId) {
|
||||
return `Task ${completion.taskId}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private displayIterationResult(result: AgentExecutionResult, iterNum: number, completion: CompletionCheckResult): void {
|
||||
const duration = Math.round(result.duration / 1000);
|
||||
const taskDisplay = this.formatTaskDisplay(completion);
|
||||
|
||||
// Show iteration completion with task info if available
|
||||
if (taskDisplay) {
|
||||
logger.iteration(iterNum, this.config.maxIterations, `${taskDisplay} (${duration}s)`);
|
||||
} else {
|
||||
logger.iteration(iterNum, this.config.maxIterations, `completed in ${duration}s`);
|
||||
}
|
||||
|
||||
// Verbose mode: show full output
|
||||
if (this.config.verbose) {
|
||||
console.log();
|
||||
logger.dim('--- Agent Output ---');
|
||||
console.log(result.stdout);
|
||||
if (result.stderr) {
|
||||
logger.dim('--- Agent Stderr ---');
|
||||
console.log(result.stderr);
|
||||
}
|
||||
logger.dim('--- End Output ---');
|
||||
console.log();
|
||||
} else if (result.exitCode !== 0 && result.stderr.trim()) {
|
||||
logger.error(` ${result.stderr.trim().split('\n')[0]}`);
|
||||
}
|
||||
}
|
||||
|
||||
async run(): Promise<LoopResult> {
|
||||
logger.header('Starting Plan2Code Loop');
|
||||
logger.info(`Agent: ${this.agent.config.displayName}`);
|
||||
logger.info(`Model: ${this.config.model}`);
|
||||
logger.info(`Spec: ${this.config.specPath}`);
|
||||
logger.info(`Max iterations: ${this.config.maxIterations}`);
|
||||
console.log();
|
||||
|
||||
while (this.config.currentIteration < this.config.maxIterations) {
|
||||
if (this.interrupted) {
|
||||
return {
|
||||
completed: false,
|
||||
iterations: this.config.currentIteration,
|
||||
exitReason: 'interrupted',
|
||||
tasksCompleted: this.tasksCompleted,
|
||||
};
|
||||
}
|
||||
|
||||
const iterNum = this.config.currentIteration + 1;
|
||||
this.onIteration?.(iterNum, this.config.maxIterations);
|
||||
|
||||
console.log();
|
||||
logger.info(`Iteration ${iterNum}/${this.config.maxIterations}`);
|
||||
|
||||
// Build prompt - simple, just spec path and iteration info
|
||||
const prompt = await this.buildPrompt();
|
||||
|
||||
const spinner = logger.spinner('Waiting for agent response');
|
||||
const startTime = Date.now();
|
||||
|
||||
// Update spinner with elapsed time every second
|
||||
const elapsedInterval = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||||
spinner.text = `Waiting for agent response (${elapsed}s)`;
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
const result = await this.executeIteration(prompt);
|
||||
clearInterval(elapsedInterval);
|
||||
spinner.stop();
|
||||
|
||||
// Check if cancelled
|
||||
if (result.cancelled || this.interrupted) {
|
||||
logger.info('Agent process cancelled');
|
||||
|
||||
const entry: IterationLogEntry = {
|
||||
iteration: iterNum,
|
||||
timestamp: new Date().toISOString(),
|
||||
duration: result.duration,
|
||||
exitCode: -1,
|
||||
status: 'interrupted',
|
||||
};
|
||||
await this.stateManager.appendIterationLog(entry);
|
||||
|
||||
return {
|
||||
completed: false,
|
||||
iterations: this.config.currentIteration,
|
||||
exitReason: 'interrupted',
|
||||
tasksCompleted: this.tasksCompleted,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for completion markers in output (now includes task info)
|
||||
const completion = checkForCompletion(result.stdout + result.stderr);
|
||||
|
||||
// Determine status
|
||||
let status: IterationLogEntry['status'] = 'running';
|
||||
if (completion.completed) {
|
||||
if (completion.marker === 'TASK_BLOCKED') {
|
||||
status = 'blocked';
|
||||
} else {
|
||||
status = 'completed';
|
||||
}
|
||||
} else if (result.timedOut) {
|
||||
status = 'timeout';
|
||||
} else if (result.exitCode !== 0) {
|
||||
status = 'error';
|
||||
}
|
||||
|
||||
// Log iteration
|
||||
const logEntry = this.createLogEntry(result, status, completion.marker);
|
||||
await this.stateManager.appendIterationLog(logEntry);
|
||||
|
||||
// Display iteration result with task info from completion marker
|
||||
this.displayIterationResult(result, iterNum, completion);
|
||||
|
||||
// Note: Scratchpad updates are now handled by the LLM directly
|
||||
|
||||
// Handle completion markers
|
||||
if (completion.completed) {
|
||||
const taskDisplay = this.formatTaskDisplay(completion);
|
||||
|
||||
if (completion.marker === 'TASK_COMPLETE') {
|
||||
this.tasksCompleted++;
|
||||
await this.onTaskComplete?.({
|
||||
marker: completion.marker,
|
||||
taskId: completion.taskId,
|
||||
taskName: completion.taskName,
|
||||
});
|
||||
logger.success(taskDisplay ? `Completed: ${taskDisplay}` : 'Task completed!');
|
||||
} else if (completion.marker === 'TASK_BLOCKED') {
|
||||
const blockInfo = completion.taskId
|
||||
? `Task ${completion.taskId} blocked: ${completion.reason || 'Unknown reason'}`
|
||||
: `Task blocked: ${completion.reason || 'Unknown reason'}`;
|
||||
logger.warning(blockInfo);
|
||||
} else if (completion.marker === 'LOOP_COMPLETE') {
|
||||
// Commit any final changes before completing
|
||||
this.tasksCompleted++;
|
||||
await this.onTaskComplete?.({
|
||||
marker: completion.marker,
|
||||
taskId: completion.taskId,
|
||||
taskName: completion.taskName || 'Final implementation complete',
|
||||
});
|
||||
this.onLoopComplete?.();
|
||||
logger.success('All tasks complete!');
|
||||
return {
|
||||
completed: true,
|
||||
iterations: iterNum,
|
||||
finalMarker: 'LOOP_COMPLETE',
|
||||
exitReason: 'all_complete',
|
||||
tasksCompleted: this.tasksCompleted,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Increment iteration and update spec hash (so resume doesn't see false changes)
|
||||
await this.stateManager.incrementIteration();
|
||||
await this.stateManager.updateSpecHash(this.config.specPath);
|
||||
this.config.currentIteration++;
|
||||
|
||||
// Handle timeout
|
||||
if (result.timedOut) {
|
||||
logger.warning(`Iteration ${iterNum} timed out, continuing...`);
|
||||
}
|
||||
|
||||
// Handle error (but continue - LLM might recover)
|
||||
if (result.exitCode !== 0 && !result.timedOut && !completion.completed) {
|
||||
logger.warning(`Iteration ${iterNum} exited with code ${result.exitCode}, continuing...`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
clearInterval(elapsedInterval);
|
||||
spinner.stop();
|
||||
logger.error(`Iteration ${iterNum} failed: ${error}`);
|
||||
|
||||
// Log the error
|
||||
const entry: IterationLogEntry = {
|
||||
iteration: iterNum,
|
||||
timestamp: new Date().toISOString(),
|
||||
duration: 0,
|
||||
exitCode: -1,
|
||||
status: 'error',
|
||||
};
|
||||
await this.stateManager.appendIterationLog(entry);
|
||||
|
||||
return {
|
||||
completed: false,
|
||||
iterations: this.config.currentIteration,
|
||||
exitReason: 'error',
|
||||
tasksCompleted: this.tasksCompleted,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Max iterations reached
|
||||
logger.warning('Max iterations reached');
|
||||
return {
|
||||
completed: false,
|
||||
iterations: this.config.currentIteration,
|
||||
exitReason: 'max_iterations',
|
||||
tasksCompleted: this.tasksCompleted,
|
||||
};
|
||||
}
|
||||
|
||||
interrupt(): void {
|
||||
this.interrupted = true;
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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}`);
|
||||
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';
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { StateManager } from '../state/index.js';
|
||||
import { LOOP_PROMPT_TEMPLATE } from './templates.js';
|
||||
|
||||
export interface PromptContext {
|
||||
specPath: string;
|
||||
iteration: number;
|
||||
maxIterations: number;
|
||||
stateManager: StateManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prompt for the AI agent
|
||||
* Simple: just pass spec path and let the LLM discover the next task
|
||||
*/
|
||||
export async function buildLoopPrompt(context: PromptContext): Promise<string> {
|
||||
const { specPath, iteration, maxIterations, stateManager } = 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();
|
||||
|
||||
// Simple template substitution
|
||||
const prompt = LOOP_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)');
|
||||
|
||||
return prompt;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
buildLoopPrompt,
|
||||
type PromptContext,
|
||||
} from './builder.js';
|
||||
|
||||
export { LOOP_PROMPT_TEMPLATE } from './templates.js';
|
||||
@@ -0,0 +1,85 @@
|
||||
export const LOOP_PROMPT_TEMPLATE = `# PLAN-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 unchecked prerequisite (\`- [ ]\`)
|
||||
- If found, that is your task for this iteration
|
||||
- Verify/complete it, then mark \`[x]\` or \`[?]\`
|
||||
6. Only if ALL prerequisites are complete (\`[x]\` or \`[?]\`), find the FIRST unchecked task
|
||||
7. That is your ONE task - implement ONLY that task
|
||||
|
||||
## Checkbox States
|
||||
- \`[ ]\` = incomplete/pending (do the FIRST one you find)
|
||||
- \`[x]\` = complete (skip)
|
||||
- \`[?]\` = assumed complete, couldn't verify (skip)
|
||||
- \`[!]\` = blocked (skip)
|
||||
|
||||
## 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, append to \`{{specPath}}/.plan2code-loop/scratchpad.md\`:
|
||||
- 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.
|
||||
`;
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
detectSpecDirectories,
|
||||
getSpecProgress,
|
||||
} from './utils.js';
|
||||
@@ -0,0 +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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface SessionConfig {
|
||||
agent: string; // "claude-code" | "copilot-cli"
|
||||
model: string; // Selected model
|
||||
maxIterations: number; // 5-50
|
||||
timeout: number; // Minutes per iteration
|
||||
verbose: boolean;
|
||||
specPath: string; // Path to the spec directory
|
||||
startedAt: string; // ISO timestamp
|
||||
currentIteration: number;
|
||||
jiraTicketId?: string; // JIRA ticket ID for commit messages
|
||||
}
|
||||
|
||||
export interface IterationLogEntry {
|
||||
iteration: number;
|
||||
timestamp: string; // ISO timestamp
|
||||
duration: number; // milliseconds
|
||||
exitCode: number;
|
||||
status: 'running' | 'completed' | 'error' | 'timeout' | 'interrupted' | 'blocked';
|
||||
completionMarker?: string;
|
||||
}
|
||||
|
||||
export type SessionState = 'new' | 'continue' | 'changed';
|
||||
|
||||
export const DEFAULT_CONFIG: Partial<SessionConfig> = {
|
||||
maxIterations: 100,
|
||||
timeout: 30,
|
||||
verbose: false,
|
||||
currentIteration: 0,
|
||||
};
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
type SessionConfig,
|
||||
type IterationLogEntry,
|
||||
type SessionState,
|
||||
DEFAULT_CONFIG,
|
||||
} from './config.js';
|
||||
|
||||
export { StateManager } from './manager.js';
|
||||
export { computeHash, hashesMatch } from './hash.js';
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Completion markers for plan2code-loop
|
||||
const COMPLETION_MARKERS = ['TASK_COMPLETE', 'TASK_BLOCKED', 'LOOP_COMPLETE'] as const;
|
||||
|
||||
export type CompletionMarker = (typeof COMPLETION_MARKERS)[number];
|
||||
|
||||
export interface CompletionCheckResult {
|
||||
completed: boolean;
|
||||
marker?: CompletionMarker;
|
||||
taskId?: string; // e.g., "1.1", "2.3"
|
||||
taskName?: string; // e.g., "Initialize project structure"
|
||||
reason?: string; // For TASK_BLOCKED: reason
|
||||
}
|
||||
|
||||
export function checkForCompletion(output: string): CompletionCheckResult {
|
||||
// Check for LOOP_COMPLETE first (highest priority)
|
||||
if (output.includes('LOOP_COMPLETE')) {
|
||||
return { completed: true, marker: 'LOOP_COMPLETE' };
|
||||
}
|
||||
|
||||
// Check for TASK_COMPLETE with task info
|
||||
// Format: TASK_COMPLETE: 1.1 - Task description
|
||||
// Or: TASK_COMPLETE: 1.1: Task description
|
||||
// Or: TASK_COMPLETE[1.1]: Task description
|
||||
const completeMatch = output.match(/TASK_COMPLETE[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/i);
|
||||
if (completeMatch) {
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'TASK_COMPLETE',
|
||||
taskId: completeMatch[1],
|
||||
taskName: completeMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Simple TASK_COMPLETE without structured info - try to extract task from context
|
||||
if (output.includes('TASK_COMPLETE')) {
|
||||
// Try to find task info nearby
|
||||
const taskMatch = output.match(/(?:task|completed?)\s+(\d+\.\d+)[:\s]+([^\n]{5,80})/i);
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'TASK_COMPLETE',
|
||||
taskId: taskMatch?.[1],
|
||||
taskName: taskMatch?.[2]?.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Check for TASK_BLOCKED with task info and reason
|
||||
// Format: TASK_BLOCKED: 1.1 - Reason why blocked
|
||||
const blockedWithTaskMatch = output.match(/TASK_BLOCKED[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/i);
|
||||
if (blockedWithTaskMatch) {
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'TASK_BLOCKED',
|
||||
taskId: blockedWithTaskMatch[1],
|
||||
reason: blockedWithTaskMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// TASK_BLOCKED with just reason (no task ID)
|
||||
const blockedMatch = output.match(/TASK_BLOCKED:\s*(.+?)(?:\n|$)/);
|
||||
if (blockedMatch) {
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'TASK_BLOCKED',
|
||||
reason: blockedMatch[1].trim()
|
||||
};
|
||||
}
|
||||
|
||||
// Simple TASK_BLOCKED without any info
|
||||
if (output.includes('TASK_BLOCKED')) {
|
||||
return { completed: true, marker: 'TASK_BLOCKED', reason: 'No reason provided' };
|
||||
}
|
||||
|
||||
return { completed: false };
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { execa } from 'execa';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export interface GitCommitOptions {
|
||||
taskName: string;
|
||||
jiraTicketId?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if directory is a git repository
|
||||
*/
|
||||
export async function isGitRepo(cwd: string): Promise<boolean> {
|
||||
const result = await execa('git', ['rev-parse', '--git-dir'], { cwd, reject: false });
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a git repository if one doesn't exist
|
||||
*/
|
||||
export async function ensureGitRepo(cwd: string): Promise<boolean> {
|
||||
if (await isGitRepo(cwd)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info('Initializing git repository...');
|
||||
const result = await execa('git', ['init'], { cwd, reject: false });
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
logger.error(`Failed to initialize git repo: ${result.stderr}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.success('Git repository initialized');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Required entries for the .gitignore file
|
||||
*/
|
||||
const REQUIRED_GITIGNORE_ENTRIES = ['specs/', 'specs--completed/', 'nul'];
|
||||
|
||||
/**
|
||||
* Ensure .gitignore exists with required entries
|
||||
*/
|
||||
export function ensureGitignore(cwd: string): void {
|
||||
const gitignorePath = join(cwd, '.gitignore');
|
||||
let content = '';
|
||||
|
||||
if (existsSync(gitignorePath)) {
|
||||
content = readFileSync(gitignorePath, 'utf-8');
|
||||
}
|
||||
|
||||
const lines = content.split('\n').map(line => line.trim());
|
||||
const missingEntries = REQUIRED_GITIGNORE_ENTRIES.filter(entry => !lines.includes(entry));
|
||||
|
||||
if (missingEntries.length > 0) {
|
||||
const needsNewline = content.length > 0 && !content.endsWith('\n');
|
||||
const newContent = content + (needsNewline ? '\n' : '') + missingEntries.join('\n') + '\n';
|
||||
writeFileSync(gitignorePath, newContent);
|
||||
logger.dim(`Added to .gitignore: ${missingEntries.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a local git commit for a completed task
|
||||
*/
|
||||
export async function createTaskCommit(options: GitCommitOptions): Promise<boolean> {
|
||||
const { taskName, jiraTicketId, cwd = process.cwd() } = options;
|
||||
|
||||
logger.dim(`Git commit check in: ${cwd}`);
|
||||
|
||||
try {
|
||||
// Ensure we have a git repo
|
||||
if (!await ensureGitRepo(cwd)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure .gitignore exists with required entries
|
||||
ensureGitignore(cwd);
|
||||
|
||||
// Check if there are any changes to commit
|
||||
const statusResult = await execa('git', ['status', '--porcelain'], { cwd, reject: false });
|
||||
|
||||
// Debug: show what git status returned
|
||||
if (statusResult.stdout?.trim()) {
|
||||
logger.dim(`Git status found changes:\n${statusResult.stdout.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
if (statusResult.exitCode !== 0) {
|
||||
logger.error(`Git status failed: ${statusResult.stderr}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!statusResult.stdout?.trim()) {
|
||||
logger.dim('No changes to commit');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stage all changes
|
||||
const addResult = await execa('git', ['add', '-A'], { cwd, reject: false });
|
||||
if (addResult.exitCode !== 0) {
|
||||
logger.error(`Git add failed: ${addResult.stderr}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build commit message
|
||||
let commitMessage = taskName;
|
||||
if (jiraTicketId) {
|
||||
commitMessage = `${taskName}\n\n${jiraTicketId}`;
|
||||
}
|
||||
|
||||
// Create the commit
|
||||
const commitResult = await execa('git', ['commit', '-m', commitMessage], { cwd, reject: false });
|
||||
|
||||
if (commitResult.exitCode !== 0) {
|
||||
// Check if it's just "nothing to commit" vs actual error
|
||||
if (commitResult.stdout?.includes('nothing to commit') || commitResult.stderr?.includes('nothing to commit')) {
|
||||
logger.dim('No changes to commit');
|
||||
return false;
|
||||
}
|
||||
logger.error(`Git commit failed: ${commitResult.stderr || commitResult.stdout}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.success(`Created commit: ${taskName}${jiraTicketId ? ` (${jiraTicketId})` : ''}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create commit: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export { logger, MASCOT, type Logger } from './logger.js';
|
||||
export {
|
||||
checkForCompletion,
|
||||
type CompletionMarker,
|
||||
type CompletionCheckResult
|
||||
} from './completion.js';
|
||||
export {
|
||||
executeCommand,
|
||||
type ExecuteOptions,
|
||||
type ExecuteResult
|
||||
} from './process.js';
|
||||
export {
|
||||
createTaskCommit,
|
||||
isGitRepo,
|
||||
ensureGitRepo,
|
||||
type GitCommitOptions
|
||||
} from './git.js';
|
||||
@@ -0,0 +1,126 @@
|
||||
import chalk from 'chalk';
|
||||
import ora, { type Ora } from 'ora';
|
||||
|
||||
// mascot - our friendly robot assistant
|
||||
export const MASCOT = {
|
||||
// Full mascot for headers
|
||||
full: [
|
||||
' ╭───╮ ',
|
||||
' │ ● │ ',
|
||||
' │ ◡ │ ',
|
||||
' ╰───╯ ',
|
||||
],
|
||||
// Mini mascot for inline use
|
||||
mini: '(◉‿◉)',
|
||||
// Waving mascot for greetings
|
||||
wave: [
|
||||
' ╭───╮ ',
|
||||
' │ ● │ ',
|
||||
' │ ◡ │ ',
|
||||
' ╰───╯ ',
|
||||
],
|
||||
// Celebration mascot for completion
|
||||
celebrate: [
|
||||
' ╭───╮ ',
|
||||
' │ ★ │ ',
|
||||
' │ ◡ │ ',
|
||||
' ╰───╯ ',
|
||||
],
|
||||
};
|
||||
|
||||
export const logger = {
|
||||
info: (message: string) => console.log(chalk.blue('i'), message),
|
||||
success: (message: string) => console.log(chalk.green('+'), message),
|
||||
warning: (message: string) => console.log(chalk.yellow('!'), message),
|
||||
error: (message: string) => console.log(chalk.red('x'), message),
|
||||
|
||||
dim: (message: string) => console.log(chalk.dim(message)),
|
||||
bold: (message: string) => console.log(chalk.bold(message)),
|
||||
|
||||
header: (message: string) => {
|
||||
console.log();
|
||||
console.log(chalk.cyan.bold(message));
|
||||
console.log(chalk.cyan('-'.repeat(message.length)));
|
||||
},
|
||||
|
||||
task: (phase: number, taskId: string, description: string) => {
|
||||
console.log(
|
||||
chalk.cyan(`[Phase ${phase}]`),
|
||||
chalk.yellow(`Task ${taskId}:`),
|
||||
chalk.white(description)
|
||||
);
|
||||
},
|
||||
|
||||
iteration: (num: number, max: number, status: string) => {
|
||||
console.log(
|
||||
chalk.cyan(`[${num}/${max}]`),
|
||||
chalk.white(status)
|
||||
);
|
||||
},
|
||||
|
||||
specContent: (content: string) => {
|
||||
console.log(chalk.dim('-'.repeat(50)));
|
||||
console.log(chalk.white(content));
|
||||
console.log(chalk.dim('-'.repeat(50)));
|
||||
},
|
||||
|
||||
spinner: (text: string): Ora => ora({ text, color: 'cyan' }).start(),
|
||||
|
||||
// Display mascot with optional message
|
||||
mascot: (variant: keyof typeof MASCOT = 'full', message?: string) => {
|
||||
console.log();
|
||||
const mascot = MASCOT[variant];
|
||||
if (Array.isArray(mascot)) {
|
||||
const mascotLines = [...mascot];
|
||||
if (message) {
|
||||
// Add message next to mascot (at the "mouth" line)
|
||||
mascotLines[4] = mascotLines[4] + ' ' + chalk.cyan(message);
|
||||
}
|
||||
mascotLines.forEach((line) => console.log(chalk.yellow(line)));
|
||||
} else {
|
||||
// Mini variant
|
||||
console.log(chalk.yellow(mascot), message ? chalk.cyan(message) : '');
|
||||
}
|
||||
console.log();
|
||||
},
|
||||
|
||||
// Welcome banner with mascot
|
||||
welcome: () => {
|
||||
console.log();
|
||||
console.log(chalk.cyan.bold('═'.repeat(50)));
|
||||
MASCOT.wave.forEach((line, i) => {
|
||||
if (i === 4) {
|
||||
console.log(chalk.yellow(line) + ' ' + chalk.cyan.bold("Hi!"));
|
||||
} else if (i === 5) {
|
||||
console.log(chalk.yellow(line) + ' ' + chalk.dim('I\'m Your Plan2Code assistant'));
|
||||
} else {
|
||||
console.log(chalk.yellow(line));
|
||||
}
|
||||
});
|
||||
console.log(chalk.cyan.bold('═'.repeat(50)));
|
||||
console.log();
|
||||
},
|
||||
|
||||
// Completion celebration with reminder
|
||||
allPhasesComplete: () => {
|
||||
console.log();
|
||||
console.log(chalk.green.bold('═'.repeat(50)));
|
||||
MASCOT.celebrate.forEach((line, i) => {
|
||||
if (i === 4) {
|
||||
console.log(chalk.yellow(line) + ' ' + chalk.green.bold('All phases complete!'));
|
||||
} else if (i === 5) {
|
||||
console.log(chalk.yellow(line) + ' ' + chalk.cyan('Great work!'));
|
||||
} else {
|
||||
console.log(chalk.yellow(line));
|
||||
}
|
||||
});
|
||||
console.log(chalk.green.bold('═'.repeat(50)));
|
||||
console.log();
|
||||
console.log(chalk.cyan.bold('Next Step:'));
|
||||
console.log(chalk.white(' Return to your AI Agent and run the'), chalk.yellow.bold('/plan2code-4--finalize'), chalk.white('step.'));
|
||||
console.log(chalk.dim(' This will ensure quality, completeness, and proper documentation.'));
|
||||
console.log();
|
||||
},
|
||||
};
|
||||
|
||||
export type Logger = typeof logger;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { execa, type Options as ExecaOptions } from 'execa';
|
||||
|
||||
const MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
function truncateOutput(output: string, maxSize: number): string {
|
||||
if (output.length > maxSize) {
|
||||
return output.slice(0, maxSize) + '\n...[truncated]';
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export interface ExecuteOptions {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
timeout: number; // milliseconds
|
||||
env?: Record<string, string>;
|
||||
signal?: AbortSignal; // For cancellation
|
||||
stdin?: string; // Input to pass via stdin as string
|
||||
stdinFile?: string; // Path to file to pipe as stdin
|
||||
}
|
||||
|
||||
export interface ExecuteResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
timedOut: boolean;
|
||||
cancelled: boolean;
|
||||
duration: number; // milliseconds
|
||||
}
|
||||
|
||||
export async function executeCommand(
|
||||
options: ExecuteOptions
|
||||
): Promise<ExecuteResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const execaOptions: ExecaOptions = {
|
||||
cwd: options.cwd,
|
||||
timeout: options.timeout,
|
||||
env: { ...process.env, ...options.env },
|
||||
reject: false,
|
||||
all: true,
|
||||
};
|
||||
|
||||
// Add cancellation signal if provided
|
||||
if (options.signal) {
|
||||
(execaOptions as any).cancelSignal = options.signal;
|
||||
}
|
||||
|
||||
// Add stdin input if provided (string or file)
|
||||
if (options.stdin) {
|
||||
(execaOptions as any).input = options.stdin;
|
||||
} else if (options.stdinFile) {
|
||||
(execaOptions as any).inputFile = options.stdinFile;
|
||||
}
|
||||
|
||||
const result = await execa(options.command, options.args, execaOptions);
|
||||
|
||||
return {
|
||||
stdout: truncateOutput(result.stdout || '', MAX_OUTPUT_SIZE),
|
||||
stderr: truncateOutput(result.stderr || '', MAX_OUTPUT_SIZE),
|
||||
exitCode: result.exitCode ?? 1,
|
||||
timedOut: result.timedOut ?? false,
|
||||
cancelled: result.isCanceled ?? false,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
} catch (error: any) {
|
||||
// Check if this was a cancellation
|
||||
const isCancelled = error?.isCanceled || options.signal?.aborted;
|
||||
|
||||
return {
|
||||
stdout: error?.stdout || '',
|
||||
stderr: error?.stderr || (error instanceof Error ? error.message : String(error)),
|
||||
exitCode: isCancelled ? -1 : 1,
|
||||
timedOut: false,
|
||||
cancelled: isCancelled,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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"]
|
||||
}
|
||||
@@ -0,0 +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: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user