mirror of
https://github.com/jparkerweb/plan2code.git
synced 2026-09-17 16:22:23 -07:00
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:
@@ -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
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
+522
-522
File diff suppressed because it is too large
Load Diff
+96
-96
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export {
|
||||
detectSpecDirectories,
|
||||
getSpecProgress,
|
||||
} from './utils.js';
|
||||
export {
|
||||
detectSpecDirectories,
|
||||
getSpecProgress,
|
||||
} from './utils.js';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user