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.
@@ -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
+ }