cc-pipeline 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BUILD_SUMMARY.md +104 -0
- package/LICENSE +21 -0
- package/README.md +288 -0
- package/bin/cc-pipeline.js +8 -0
- package/docs/AGENT-TEAMS-RESEARCH.md +84 -0
- package/docs/IDEAS.md +24 -0
- package/docs/SYSTEM-PROMPTS-RESEARCH.md +86 -0
- package/docs/brief-example.png +0 -0
- package/package.json +36 -0
- package/src/agents/base.js +56 -0
- package/src/agents/bash.js +50 -0
- package/src/agents/claude-interactive.js +248 -0
- package/src/agents/claude-piped.js +81 -0
- package/src/cli.js +72 -0
- package/src/commands/init.js +55 -0
- package/src/commands/reset.js +46 -0
- package/src/commands/run.js +5 -0
- package/src/commands/status.js +49 -0
- package/src/commands/update.js +42 -0
- package/src/config.js +101 -0
- package/src/engine.js +326 -0
- package/src/logger.js +57 -0
- package/src/prompts.js +49 -0
- package/src/state.js +154 -0
- package/templates/BRIEF.md.example +28 -0
- package/templates/CLAUDE.md +67 -0
- package/templates/pipeline/CLAUDE.md +102 -0
- package/templates/pipeline/prompts/build.md +78 -0
- package/templates/pipeline/prompts/commit.md +40 -0
- package/templates/pipeline/prompts/fix.md +58 -0
- package/templates/pipeline/prompts/plan.md +105 -0
- package/templates/pipeline/prompts/reflect.md +70 -0
- package/templates/pipeline/prompts/research.md +76 -0
- package/templates/pipeline/prompts/review.md +111 -0
- package/templates/pipeline/prompts/spec.md +114 -0
- package/templates/pipeline/prompts/status.md +74 -0
- package/templates/pipeline/workflow.yaml +81 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync, cpSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const TEMPLATES_DIR = join(__dirname, '..', '..', 'templates');
|
|
7
|
+
|
|
8
|
+
export function update(projectDir) {
|
|
9
|
+
const pipelineDir = join(projectDir, '.pipeline');
|
|
10
|
+
|
|
11
|
+
if (!existsSync(pipelineDir)) {
|
|
12
|
+
console.error(' ❌ No .pipeline/ directory found. Run `cc-pipeline init` first.');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
console.log('🔄 Updating pipeline templates...\n');
|
|
17
|
+
|
|
18
|
+
// Update prompts
|
|
19
|
+
const promptsDir = join(pipelineDir, 'prompts');
|
|
20
|
+
cpSync(join(TEMPLATES_DIR, 'pipeline', 'prompts'), promptsDir, { recursive: true, force: true });
|
|
21
|
+
console.log(' ✅ Updated .pipeline/prompts/');
|
|
22
|
+
|
|
23
|
+
// Update .pipeline/CLAUDE.md
|
|
24
|
+
const pipelineClaudeMd = join(pipelineDir, 'CLAUDE.md');
|
|
25
|
+
cpSync(join(TEMPLATES_DIR, 'pipeline', 'CLAUDE.md'), pipelineClaudeMd);
|
|
26
|
+
console.log(' ✅ Updated .pipeline/CLAUDE.md');
|
|
27
|
+
|
|
28
|
+
// Update root CLAUDE.md (pipeline section)
|
|
29
|
+
const rootClaudeMd = join(projectDir, 'CLAUDE.md');
|
|
30
|
+
if (!existsSync(rootClaudeMd)) {
|
|
31
|
+
cpSync(join(TEMPLATES_DIR, 'CLAUDE.md'), rootClaudeMd);
|
|
32
|
+
console.log(' ✅ Created CLAUDE.md');
|
|
33
|
+
} else {
|
|
34
|
+
console.log(' ⏭️ CLAUDE.md exists — skipped (edit manually if needed)');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(`
|
|
38
|
+
⚠️ workflow.yaml was NOT changed (your customizations are preserved).
|
|
39
|
+
If you need the latest default workflow, delete .pipeline/workflow.yaml
|
|
40
|
+
and run \`cc-pipeline init\` again.
|
|
41
|
+
`);
|
|
42
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import YAML from 'yaml';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Load and parse the workflow configuration from .pipeline/workflow.yaml
|
|
7
|
+
* Normalizes snake_case YAML keys to camelCase JS properties
|
|
8
|
+
* @param {string} projectDir - The project directory path
|
|
9
|
+
* @returns {object} Normalized config object
|
|
10
|
+
*/
|
|
11
|
+
export function loadConfig(projectDir) {
|
|
12
|
+
const workflowPath = join(projectDir, '.pipeline', 'workflow.yaml');
|
|
13
|
+
|
|
14
|
+
if (!existsSync(workflowPath)) {
|
|
15
|
+
throw new Error(`Workflow file not found: ${workflowPath}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const rawContent = readFileSync(workflowPath, 'utf-8');
|
|
19
|
+
const raw = YAML.parse(rawContent);
|
|
20
|
+
|
|
21
|
+
// Normalize top-level config
|
|
22
|
+
const config = {
|
|
23
|
+
name: raw.name || 'Unnamed Pipeline',
|
|
24
|
+
version: raw.version || 1,
|
|
25
|
+
phasesDir: raw.phases_dir || 'docs/phases',
|
|
26
|
+
steps: [],
|
|
27
|
+
usageCheck: raw.usage_check || { when: 'phase_boundary' }
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Normalize steps array
|
|
31
|
+
if (raw.steps && Array.isArray(raw.steps)) {
|
|
32
|
+
config.steps = raw.steps.map(step => ({
|
|
33
|
+
name: step.name,
|
|
34
|
+
description: step.description || '',
|
|
35
|
+
agent: step.agent,
|
|
36
|
+
prompt: step.prompt,
|
|
37
|
+
model: step.model,
|
|
38
|
+
skipUnless: step.skip_unless,
|
|
39
|
+
output: step.output,
|
|
40
|
+
testGate: step.test_gate,
|
|
41
|
+
command: step.command
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return config;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Find a step by name
|
|
50
|
+
* @param {object} config - The workflow configuration
|
|
51
|
+
* @param {string} name - The step name to find
|
|
52
|
+
* @returns {object|null} The step object or null if not found
|
|
53
|
+
*/
|
|
54
|
+
export function getStepByName(config, name) {
|
|
55
|
+
return config.steps.find(step => step.name === name) || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Find the index of a step by name
|
|
60
|
+
* @param {object} config - The workflow configuration
|
|
61
|
+
* @param {string} name - The step name to find
|
|
62
|
+
* @returns {number} The step index or -1 if not found
|
|
63
|
+
*/
|
|
64
|
+
export function getStepIndex(config, name) {
|
|
65
|
+
return config.steps.findIndex(step => step.name === name);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get the next step name after the current step
|
|
70
|
+
* @param {object} config - The workflow configuration
|
|
71
|
+
* @param {string} currentStepName - The current step name
|
|
72
|
+
* @returns {string} The next step name or 'done' if at the end
|
|
73
|
+
*/
|
|
74
|
+
export function getNextStep(config, currentStepName) {
|
|
75
|
+
const currentIndex = getStepIndex(config, currentStepName);
|
|
76
|
+
|
|
77
|
+
if (currentIndex === -1) {
|
|
78
|
+
throw new Error(`Step not found: ${currentStepName}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const nextIndex = currentIndex + 1;
|
|
82
|
+
|
|
83
|
+
if (nextIndex >= config.steps.length) {
|
|
84
|
+
return 'done';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return config.steps[nextIndex].name;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get the first step name
|
|
92
|
+
* @param {object} config - The workflow configuration
|
|
93
|
+
* @returns {string} The first step name
|
|
94
|
+
*/
|
|
95
|
+
export function getFirstStep(config) {
|
|
96
|
+
if (!config.steps || config.steps.length === 0) {
|
|
97
|
+
throw new Error('No steps defined in workflow');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return config.steps[0].name;
|
|
101
|
+
}
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { getCurrentState, appendEvent, deriveResumePoint } from './state.js';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { printBanner } from './logger.js';
|
|
6
|
+
import { agentState } from './agents/base.js';
|
|
7
|
+
import { BashAgent } from './agents/bash.js';
|
|
8
|
+
import { ClaudePipedAgent } from './agents/claude-piped.js';
|
|
9
|
+
import { ClaudeInteractiveAgent } from './agents/claude-interactive.js';
|
|
10
|
+
|
|
11
|
+
const MAX_PHASES = 20;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Main pipeline engine loop.
|
|
15
|
+
*
|
|
16
|
+
* Logic from run.sh lines 549-636:
|
|
17
|
+
* - Load config from workflow.yaml
|
|
18
|
+
* - Derive current state from JSONL
|
|
19
|
+
* - Print banner
|
|
20
|
+
* - Loop through phases, executing steps
|
|
21
|
+
* - Check for PROJECT COMPLETE in reflections
|
|
22
|
+
* - Handle phase limits and MAX_PHASES
|
|
23
|
+
* - Signal handling for clean shutdown
|
|
24
|
+
*
|
|
25
|
+
* @param {string} projectDir - Absolute path to project root
|
|
26
|
+
* @param {object} options - { phases?: number, model?: string }
|
|
27
|
+
*/
|
|
28
|
+
export async function runEngine(projectDir, options = {}) {
|
|
29
|
+
const pipelineDir = join(projectDir, '.pipeline');
|
|
30
|
+
const logFile = join(pipelineDir, 'pipeline.jsonl');
|
|
31
|
+
const workflowFile = join(pipelineDir, 'workflow.yaml');
|
|
32
|
+
|
|
33
|
+
// Validate pipeline exists
|
|
34
|
+
if (!existsSync(workflowFile)) {
|
|
35
|
+
throw new Error('No .pipeline/workflow.yaml found. Run `cc-pipeline init` first.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Load config
|
|
39
|
+
const config = loadConfig(projectDir);
|
|
40
|
+
|
|
41
|
+
// Derive current state
|
|
42
|
+
const state = getCurrentState(logFile);
|
|
43
|
+
const resumePoint = deriveResumePoint(logFile, config.steps);
|
|
44
|
+
|
|
45
|
+
// Print banner
|
|
46
|
+
printBanner(config, projectDir, state);
|
|
47
|
+
console.log(`\nResumed state: phase=${resumePoint.phase} step=${resumePoint.stepName} status=${state.status}`);
|
|
48
|
+
|
|
49
|
+
// Signal handling — track interrupted state, kill child processes, and allow cancelling sleep
|
|
50
|
+
let interrupted = false;
|
|
51
|
+
let cancelSleep = null;
|
|
52
|
+
let phase = resumePoint.phase;
|
|
53
|
+
let currentStepName = resumePoint.stepName;
|
|
54
|
+
|
|
55
|
+
const handleSignal = (signal) => {
|
|
56
|
+
if (interrupted) return; // Already handling a signal, ignore duplicates
|
|
57
|
+
console.log(`\nReceived ${signal}, shutting down gracefully...`);
|
|
58
|
+
interrupted = true;
|
|
59
|
+
agentState.setInterrupted(true);
|
|
60
|
+
if (cancelSleep) cancelSleep();
|
|
61
|
+
|
|
62
|
+
// Write interrupted event
|
|
63
|
+
appendEvent(logFile, { event: 'interrupted', phase, step: currentStepName });
|
|
64
|
+
|
|
65
|
+
// Kill current child process if any
|
|
66
|
+
const child = agentState.getChild();
|
|
67
|
+
if (child && child.pid) {
|
|
68
|
+
console.log(`Terminating child process ${child.pid}...`);
|
|
69
|
+
|
|
70
|
+
// Send SIGTERM first
|
|
71
|
+
try {
|
|
72
|
+
process.kill(child.pid, 'SIGTERM');
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Process may have already exited
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Send SIGKILL after 2 seconds if still running
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
try {
|
|
80
|
+
process.kill(child.pid, 'SIGKILL');
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// Process already exited, ignore
|
|
83
|
+
}
|
|
84
|
+
}, 2000);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Exit after cleanup — use correct exit code per signal
|
|
88
|
+
const exitCode = signal === 'SIGTERM' ? 143 : 130;
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
process.exit(exitCode);
|
|
91
|
+
}, 3000);
|
|
92
|
+
};
|
|
93
|
+
process.on('SIGINT', handleSignal);
|
|
94
|
+
process.on('SIGTERM', handleSignal);
|
|
95
|
+
|
|
96
|
+
const cleanup = () => {
|
|
97
|
+
process.removeListener('SIGINT', handleSignal);
|
|
98
|
+
process.removeListener('SIGTERM', handleSignal);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
// Main loop (phase and currentStepName already declared above for signal handler)
|
|
103
|
+
let phasesRun = 0;
|
|
104
|
+
|
|
105
|
+
const phaseLimit = options.phases || 0;
|
|
106
|
+
|
|
107
|
+
while (phase <= MAX_PHASES) {
|
|
108
|
+
// Check for PROJECT COMPLETE
|
|
109
|
+
if (phase > 1) {
|
|
110
|
+
const prevPhaseDir = join(projectDir, config.phasesDir, `phase-${phase - 1}`);
|
|
111
|
+
const reflectFile = join(prevPhaseDir, 'REFLECTIONS.md');
|
|
112
|
+
if (existsSync(reflectFile)) {
|
|
113
|
+
const firstLine = readFileSync(reflectFile, 'utf8').split('\n')[0];
|
|
114
|
+
if (firstLine && /PROJECT COMPLETE/i.test(firstLine)) {
|
|
115
|
+
appendEvent(logFile, { event: 'project_complete', phase: phase - 1 });
|
|
116
|
+
console.log(`PROJECT COMPLETE detected in phase ${phase - 1} reflections.`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Execute all steps in phase
|
|
123
|
+
let stepIndex = config.steps.findIndex(s => s.name === currentStepName);
|
|
124
|
+
if (stepIndex === -1) stepIndex = 0;
|
|
125
|
+
|
|
126
|
+
for (let i = stepIndex; i < config.steps.length; i++) {
|
|
127
|
+
if (interrupted) {
|
|
128
|
+
// Signal handler already wrote the interrupted event
|
|
129
|
+
throw new Error('Pipeline interrupted by signal');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const stepDef = config.steps[i];
|
|
133
|
+
currentStepName = stepDef.name;
|
|
134
|
+
|
|
135
|
+
// Retry logic: 3 attempts with backoff (0s, 30s, 60s)
|
|
136
|
+
const retryDelays = [0, 30000, 60000];
|
|
137
|
+
let lastResult = null;
|
|
138
|
+
|
|
139
|
+
for (let attempt = 0; attempt < retryDelays.length; attempt++) {
|
|
140
|
+
if (attempt > 0) {
|
|
141
|
+
const delaySec = retryDelays[attempt] / 1000;
|
|
142
|
+
console.log(`\n ⏳ Retry ${attempt}/2 for step "${stepDef.name}" in ${delaySec}s...`);
|
|
143
|
+
await new Promise(resolve => {
|
|
144
|
+
const timer = setTimeout(resolve, retryDelays[attempt]);
|
|
145
|
+
cancelSleep = () => { clearTimeout(timer); resolve(); };
|
|
146
|
+
});
|
|
147
|
+
cancelSleep = null;
|
|
148
|
+
if (interrupted) throw new Error('Pipeline interrupted by signal');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
lastResult = await runStep(phase, stepDef, projectDir, config, logFile, options);
|
|
152
|
+
|
|
153
|
+
// If interrupted during step execution, bail immediately
|
|
154
|
+
if (interrupted) throw new Error('Pipeline interrupted by signal');
|
|
155
|
+
|
|
156
|
+
if (lastResult === 'ok' || lastResult === 'skipped') break;
|
|
157
|
+
|
|
158
|
+
// Log retry
|
|
159
|
+
if (attempt < retryDelays.length - 1) {
|
|
160
|
+
appendEvent(logFile, {
|
|
161
|
+
event: 'step_retry',
|
|
162
|
+
phase,
|
|
163
|
+
step: stepDef.name,
|
|
164
|
+
attempt: attempt + 1,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// If still failed after all retries, stop the pipeline
|
|
170
|
+
if (lastResult === 'error') {
|
|
171
|
+
console.error(`\n ❌ Step "${stepDef.name}" failed after 3 attempts. Pipeline stopped.`);
|
|
172
|
+
console.error(` Run \`cc-pipeline run\` to retry from this step.`);
|
|
173
|
+
appendEvent(logFile, {
|
|
174
|
+
event: 'pipeline_stopped',
|
|
175
|
+
phase,
|
|
176
|
+
step: stepDef.name,
|
|
177
|
+
reason: 'max_retries_exceeded',
|
|
178
|
+
});
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Phase complete
|
|
184
|
+
appendEvent(logFile, { event: 'phase_complete', phase });
|
|
185
|
+
|
|
186
|
+
phasesRun++;
|
|
187
|
+
|
|
188
|
+
// Check phase limit
|
|
189
|
+
if (phaseLimit > 0 && phasesRun >= phaseLimit) {
|
|
190
|
+
console.log(`Completed ${phasesRun} phase(s) as requested. Stopping.`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Advance to next phase
|
|
195
|
+
phase++;
|
|
196
|
+
currentStepName = config.steps[0].name;
|
|
197
|
+
|
|
198
|
+
// Brief pause between phases (interruptible)
|
|
199
|
+
await new Promise(resolve => {
|
|
200
|
+
const timer = setTimeout(resolve, 5000);
|
|
201
|
+
cancelSleep = () => { clearTimeout(timer); resolve(); };
|
|
202
|
+
});
|
|
203
|
+
cancelSleep = null;
|
|
204
|
+
if (interrupted) {
|
|
205
|
+
// Signal handler already wrote the interrupted event
|
|
206
|
+
throw new Error('Pipeline interrupted by signal');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
console.log(`Hit MAX_PHASES (${MAX_PHASES}). Stopping.`);
|
|
211
|
+
} finally {
|
|
212
|
+
cleanup();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Execute a single pipeline step.
|
|
218
|
+
*
|
|
219
|
+
* @param {number} phase - Current phase number
|
|
220
|
+
* @param {object} stepDef - Step definition from workflow.yaml
|
|
221
|
+
* @param {string} projectDir - Project root directory
|
|
222
|
+
* @param {object} config - Full config object
|
|
223
|
+
* @param {string} logFile - Path to JSONL log
|
|
224
|
+
*/
|
|
225
|
+
async function runStep(phase, stepDef, projectDir, config, logFile, options = {}) {
|
|
226
|
+
const { name: stepName, agent, skipUnless, output, testGate } = stepDef;
|
|
227
|
+
|
|
228
|
+
// Check skipUnless condition
|
|
229
|
+
if (skipUnless) {
|
|
230
|
+
const checkFile = join(projectDir, config.phasesDir, `phase-${phase}`, skipUnless);
|
|
231
|
+
if (!existsSync(checkFile)) {
|
|
232
|
+
appendEvent(logFile, {
|
|
233
|
+
event: 'step_skip',
|
|
234
|
+
phase,
|
|
235
|
+
step: stepName,
|
|
236
|
+
reason: `${skipUnless} not found`,
|
|
237
|
+
});
|
|
238
|
+
console.log(`Skipping ${stepName} (${skipUnless} not found)`);
|
|
239
|
+
return 'skipped';
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// CLI --model overrides workflow.yaml per-step model
|
|
244
|
+
const model = options.model || stepDef.model || 'default';
|
|
245
|
+
appendEvent(logFile, {
|
|
246
|
+
event: 'step_start',
|
|
247
|
+
phase,
|
|
248
|
+
step: stepName,
|
|
249
|
+
agent,
|
|
250
|
+
model,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
console.log(`\nRunning step: ${stepName} (phase ${phase}, agent: ${agent})`);
|
|
254
|
+
|
|
255
|
+
// Route to agent and execute
|
|
256
|
+
let result;
|
|
257
|
+
const context = { projectDir, config, logFile };
|
|
258
|
+
const promptPath = stepDef.prompt || null;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
switch (agent) {
|
|
262
|
+
case 'bash': {
|
|
263
|
+
const bashAgent = new BashAgent();
|
|
264
|
+
result = await bashAgent.run(phase, stepDef, promptPath, model, context);
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case 'claude-piped': {
|
|
268
|
+
const pipedAgent = new ClaudePipedAgent();
|
|
269
|
+
result = await pipedAgent.run(phase, stepDef, promptPath, model, context);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case 'claude-interactive':
|
|
273
|
+
case 'codex-interactive': {
|
|
274
|
+
const interactiveAgent = new ClaudeInteractiveAgent();
|
|
275
|
+
result = await interactiveAgent.run(phase, stepDef, promptPath, model, context);
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
default:
|
|
279
|
+
throw new Error(`Unknown agent: ${agent}`);
|
|
280
|
+
}
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.error(`Error executing agent ${agent}: ${err.message}`);
|
|
283
|
+
result = { exitCode: 1, outputPath: null, error: err.message };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Log step done
|
|
287
|
+
const status = result.exitCode === 0 ? 'ok' : 'error';
|
|
288
|
+
appendEvent(logFile, {
|
|
289
|
+
event: 'step_done',
|
|
290
|
+
phase,
|
|
291
|
+
step: stepName,
|
|
292
|
+
agent,
|
|
293
|
+
status,
|
|
294
|
+
exitCode: result.exitCode,
|
|
295
|
+
...(result.error && { error: result.error }),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Validate output if specified
|
|
299
|
+
if (output) {
|
|
300
|
+
const outputFile = join(projectDir, config.phasesDir, `phase-${phase}`, output);
|
|
301
|
+
if (existsSync(outputFile)) {
|
|
302
|
+
appendEvent(logFile, {
|
|
303
|
+
event: 'output_verified',
|
|
304
|
+
phase,
|
|
305
|
+
step: stepName,
|
|
306
|
+
file: output,
|
|
307
|
+
});
|
|
308
|
+
} else {
|
|
309
|
+
appendEvent(logFile, {
|
|
310
|
+
event: 'output_missing',
|
|
311
|
+
phase,
|
|
312
|
+
step: stepName,
|
|
313
|
+
file: output,
|
|
314
|
+
});
|
|
315
|
+
console.log(`WARNING: Expected output ${output} not found after ${stepName}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Test gate (placeholder)
|
|
320
|
+
if (testGate === true) {
|
|
321
|
+
console.log(` [STUB] Would run test gate for ${stepName}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return status;
|
|
325
|
+
}
|
|
326
|
+
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ANSI color codes
|
|
2
|
+
const COLORS = {
|
|
3
|
+
reset: '\x1b[0m',
|
|
4
|
+
cyan: '\x1b[36m',
|
|
5
|
+
yellow: '\x1b[33m',
|
|
6
|
+
red: '\x1b[31m',
|
|
7
|
+
dim: '\x1b[2m',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Print startup banner with box-drawing characters.
|
|
12
|
+
* @param {object} config - Workflow config from yaml
|
|
13
|
+
* @param {string} projectDir - Project directory path
|
|
14
|
+
* @param {object} currentState - Current state { phase, step, status }
|
|
15
|
+
*/
|
|
16
|
+
export function printBanner(config, projectDir, currentState) {
|
|
17
|
+
const workflowName = config.name || 'Pipeline';
|
|
18
|
+
const projectName = projectDir.split('/').pop();
|
|
19
|
+
const BOX_WIDTH = 60;
|
|
20
|
+
|
|
21
|
+
// Helper to pad line with spaces and add right border.
|
|
22
|
+
// All ANSI codes must be included in `text`; this function only handles padding.
|
|
23
|
+
const boxLine = (text) => {
|
|
24
|
+
const stripped = text.replace(/\x1b\[[0-9;]*m/g, ''); // Remove ANSI codes for length calc
|
|
25
|
+
const padding = ' '.repeat(Math.max(0, BOX_WIDTH - 2 - stripped.length));
|
|
26
|
+
return `║ ${text}${padding}${COLORS.reset} ║`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const lines = [
|
|
30
|
+
'╔' + '═'.repeat(BOX_WIDTH) + '╗',
|
|
31
|
+
boxLine(`${COLORS.cyan}${workflowName}${COLORS.reset}`),
|
|
32
|
+
boxLine(`Project: ${projectName}`),
|
|
33
|
+
boxLine(''),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Show steps with current step highlighted
|
|
37
|
+
lines.push(boxLine(`${COLORS.yellow}Pipeline Steps:${COLORS.reset}`));
|
|
38
|
+
config.steps.forEach((step, idx) => {
|
|
39
|
+
const isCurrent = currentState && step.name === currentState.step;
|
|
40
|
+
const marker = isCurrent ? `${COLORS.cyan}▶` : ' ';
|
|
41
|
+
const stepText = isCurrent
|
|
42
|
+
? `${COLORS.cyan}${marker} ${idx + 1}. ${step.name}${COLORS.reset}`
|
|
43
|
+
: `${marker} ${idx + 1}. ${step.name}`;
|
|
44
|
+
lines.push(boxLine(stepText));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Show phase and status
|
|
48
|
+
if (currentState && currentState.phase) {
|
|
49
|
+
lines.push(boxLine(''));
|
|
50
|
+
const phaseText = `${COLORS.dim}Phase: ${currentState.phase} | Status: ${currentState.status}${COLORS.reset}`;
|
|
51
|
+
lines.push(boxLine(phaseText));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
lines.push('╚' + '═'.repeat(BOX_WIDTH) + '╝');
|
|
55
|
+
|
|
56
|
+
lines.forEach(line => console.log(line));
|
|
57
|
+
}
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate a prompt by reading the template and substituting placeholders
|
|
6
|
+
* @param {string} projectDir - The project directory path
|
|
7
|
+
* @param {object} config - The workflow configuration
|
|
8
|
+
* @param {number} phase - The current phase number
|
|
9
|
+
* @param {string} promptPath - The relative path to the prompt template (e.g., "prompts/spec.md")
|
|
10
|
+
* @returns {string} The generated prompt with substitutions
|
|
11
|
+
*/
|
|
12
|
+
export function generatePrompt(projectDir, config, phase, promptPath) {
|
|
13
|
+
const promptFile = join(projectDir, '.pipeline', promptPath);
|
|
14
|
+
|
|
15
|
+
if (!existsSync(promptFile)) {
|
|
16
|
+
throw new Error(`Prompt file not found: ${promptFile}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Read the template
|
|
20
|
+
let prompt = readFileSync(promptFile, 'utf-8');
|
|
21
|
+
|
|
22
|
+
// Substitute {{PHASE}}
|
|
23
|
+
prompt = prompt.replace(/\{\{PHASE\}\}/g, phase.toString());
|
|
24
|
+
|
|
25
|
+
// Substitute {{PREV_REFLECTIONS}}
|
|
26
|
+
let prevReflections = '';
|
|
27
|
+
if (phase > 1) {
|
|
28
|
+
const prevPhase = phase - 1;
|
|
29
|
+
const prevReflectPath = join(projectDir, config.phasesDir, `phase-${prevPhase}`, 'REFLECTIONS.md');
|
|
30
|
+
if (existsSync(prevReflectPath)) {
|
|
31
|
+
prevReflections = `Previous phase reflections (read this file): ${prevReflectPath}`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
prompt = prompt.replace(/\{\{PREV_REFLECTIONS\}\}/g, prevReflections);
|
|
35
|
+
|
|
36
|
+
// Substitute {{BRIEF}}
|
|
37
|
+
let briefContent = '';
|
|
38
|
+
const briefPath = join(projectDir, 'BRIEF.md');
|
|
39
|
+
if (existsSync(briefPath)) {
|
|
40
|
+
briefContent = readFileSync(briefPath, 'utf-8');
|
|
41
|
+
}
|
|
42
|
+
prompt = prompt.replace(/\{\{BRIEF\}\}/g, briefContent);
|
|
43
|
+
|
|
44
|
+
// Substitute {{FILE_TREE}} - placeholder for now
|
|
45
|
+
const fileTree = '(file tree generation not yet implemented)';
|
|
46
|
+
prompt = prompt.replace(/\{\{FILE_TREE\}\}/g, fileTree);
|
|
47
|
+
|
|
48
|
+
return prompt;
|
|
49
|
+
}
|