cc-pipeline 0.5.2 → 0.6.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.
Files changed (45) hide show
  1. package/bin/cc-pipeline.js +15 -5
  2. package/package.json +19 -2
  3. package/src/agents/{base.js → base.ts} +35 -13
  4. package/src/agents/bash.ts +63 -0
  5. package/src/agents/claudecode.ts +142 -0
  6. package/src/agents/codex.ts +132 -0
  7. package/src/claudecode.test.ts +260 -0
  8. package/src/{cli.js → cli.ts} +32 -5
  9. package/src/codex.test.ts +230 -0
  10. package/src/commands/{init.js → init.ts} +1 -1
  11. package/src/commands/{reset.js → reset.ts} +1 -1
  12. package/src/commands/run.ts +5 -0
  13. package/src/commands/{status.js → status.ts} +4 -3
  14. package/src/commands/{update.js → update.ts} +26 -5
  15. package/src/config.test.ts +318 -0
  16. package/src/{config.js → config.ts} +38 -9
  17. package/src/{engine.js → engine.ts} +100 -48
  18. package/src/events.ts +15 -0
  19. package/src/init.test.ts +274 -0
  20. package/src/logger.test.ts +165 -0
  21. package/src/{logger.js → logger.ts} +3 -3
  22. package/src/prompts.test.ts +173 -0
  23. package/src/{prompts.js → prompts.ts} +2 -2
  24. package/src/resume.test.ts +247 -0
  25. package/src/signal.test.ts +190 -0
  26. package/src/smoke.test.ts +88 -0
  27. package/src/state.test.ts +375 -0
  28. package/src/{state.js → state.ts} +5 -5
  29. package/src/tui/App.ts +356 -0
  30. package/src/tui/index.ts +38 -0
  31. package/src/update.test.ts +61 -0
  32. package/src/usage.ts +121 -0
  33. package/templates/pipeline/CLAUDE.md +3 -3
  34. package/templates/pipeline/prompts/commit.md +51 -11
  35. package/templates/pipeline/workflow.yaml +27 -20
  36. package/BUILD_SUMMARY.md +0 -104
  37. package/docs/AGENT-TEAMS-RESEARCH.md +0 -84
  38. package/docs/ATTRACTOR-COMPARISON.md +0 -195
  39. package/docs/IDEAS.md +0 -24
  40. package/docs/SYSTEM-PROMPTS-RESEARCH.md +0 -86
  41. package/docs/brief-example.png +0 -0
  42. package/src/agents/bash.js +0 -50
  43. package/src/agents/claude-interactive.js +0 -268
  44. package/src/agents/claude-piped.js +0 -81
  45. package/src/commands/run.js +0 -5
@@ -1,8 +1,18 @@
1
1
  #!/usr/bin/env node
2
+ // Register tsx as an in-process ESM loader, then import and run the TypeScript CLI
3
+ // directly in this process. This keeps everything single-process so signal handlers
4
+ // registered by the engine work correctly (no subprocess forwarding needed).
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dirname, join } from 'node:path';
2
7
 
3
- import { run } from '../src/cli.js';
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
4
9
 
5
- run(process.argv.slice(2)).catch(err => {
6
- console.error(err.message);
7
- process.exit(1);
8
- });
10
+ // Load tsx's programmatic ESM registration API
11
+ const { register } = await import(
12
+ join(__dirname, '../node_modules/tsx/dist/esm/api/index.mjs')
13
+ );
14
+ register();
15
+
16
+ // Now TypeScript imports resolve correctly in this process
17
+ const { run } = await import('../src/cli.ts');
18
+ await run(process.argv.slice(2));
package/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "cc-pipeline",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Autonomous Claude Code pipeline engine. Install into any repo, write a BRIEF.md, and let Claude build your project phase by phase.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cc-pipeline": "bin/cc-pipeline.js"
8
8
  },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "templates/"
13
+ ],
9
14
  "scripts": {
10
- "test": "node --test src/**/*.test.js"
15
+ "start": "tsx src/cli.ts",
16
+ "test": "node --import tsx/esm --test 'src/**/*.test.ts'",
17
+ "build": "tsc"
11
18
  },
12
19
  "repository": {
13
20
  "type": "git",
@@ -31,6 +38,16 @@
31
38
  "node": ">=18"
32
39
  },
33
40
  "dependencies": {
41
+ "@anthropic-ai/claude-agent-sdk": "^0.2.50",
42
+ "ink": "^6.8.0",
43
+ "react": "^19.2.4",
44
+ "tsx": "^4.21.0",
34
45
  "yaml": "^2.7.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^25.3.0",
49
+ "@types/react": "^19.2.14",
50
+ "@types/react-dom": "^19.2.3",
51
+ "typescript": "^5.9.3"
35
52
  }
36
53
  }
@@ -1,36 +1,65 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
 
3
+ export interface AgentContext {
4
+ projectDir: string;
5
+ config: any;
6
+ logFile: string | null;
7
+ }
8
+
9
+ export interface AgentResult {
10
+ exitCode: number;
11
+ outputPath: string | null;
12
+ error?: string;
13
+ usage?: { costUSD: number };
14
+ }
15
+
16
+ export interface StepDef {
17
+ name: string;
18
+ agent: string;
19
+ prompt?: string;
20
+ model?: string;
21
+ command?: string;
22
+ skipUnless?: string;
23
+ output?: string;
24
+ testGate?: boolean;
25
+ description?: string;
26
+ continueOnError?: boolean;
27
+ }
28
+
3
29
  /**
4
30
  * Shared state for tracking the current child process
5
31
  * The engine signal handler needs access to this to kill the process on Ctrl-C
6
32
  */
7
33
  class AgentState extends EventEmitter {
34
+ currentChild: any;
35
+ interrupted: boolean;
36
+
8
37
  constructor() {
9
38
  super();
10
39
  this.currentChild = null;
11
40
  this.interrupted = false;
12
41
  }
13
42
 
14
- setChild(child) {
43
+ setChild(child: any): void {
15
44
  this.currentChild = child;
16
45
  }
17
46
 
18
- getChild() {
47
+ getChild(): any {
19
48
  return this.currentChild;
20
49
  }
21
50
 
22
- clearChild() {
51
+ clearChild(): void {
23
52
  this.currentChild = null;
24
53
  }
25
54
 
26
- setInterrupted(value) {
55
+ setInterrupted(value: boolean): void {
27
56
  this.interrupted = value;
28
57
  if (value) {
29
58
  this.emit('interrupt');
30
59
  }
31
60
  }
32
61
 
33
- isInterrupted() {
62
+ isInterrupted(): boolean {
34
63
  return this.interrupted;
35
64
  }
36
65
  }
@@ -41,16 +70,9 @@ export const agentState = new AgentState();
41
70
  /**
42
71
  * Base interface for all agents.
43
72
  * All agents must implement the run() method with this signature.
44
- *
45
- * @param {number} phase - Current phase number
46
- * @param {object} step - Step definition from workflow.yaml
47
- * @param {string} promptPath - Relative path to prompt file (for claude agents)
48
- * @param {string} model - Model name to use
49
- * @param {object} context - { projectDir, config, logFile }
50
- * @returns {Promise<{exitCode: number, outputPath: string|null}>}
51
73
  */
52
74
  export class BaseAgent {
53
- async run(phase, step, promptPath, model, context) {
75
+ async run(phase: number, step: StepDef, promptPath: string | null, model: string, context: AgentContext): Promise<AgentResult> {
54
76
  throw new Error('Agent must implement run() method');
55
77
  }
56
78
  }
@@ -0,0 +1,63 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { appendFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { BaseAgent, agentState, AgentContext, AgentResult, StepDef } from './base.js';
5
+
6
+ /**
7
+ * Bash agent - executes shell commands
8
+ * Ports run_bash from run.sh: substitute {{PHASE}} and execute via spawn
9
+ */
10
+ export class BashAgent extends BaseAgent {
11
+ async run(phase: number, step: StepDef, promptPath: string | null, model: string, context: AgentContext): Promise<AgentResult> {
12
+ const { command } = step;
13
+
14
+ if (!command) {
15
+ throw new Error('Bash agent requires a command in step definition');
16
+ }
17
+
18
+ // Substitute {{PHASE}} placeholder
19
+ const cmd = command.replace(/\{\{PHASE\}\}/g, phase.toString());
20
+
21
+ console.log(` Executing: ${cmd}`);
22
+
23
+ const outputPath = join(context.projectDir, '.pipeline', 'step-output.log');
24
+ writeFileSync(outputPath, `$ ${cmd}\n`, 'utf-8');
25
+
26
+ return new Promise((resolve) => {
27
+ // Pipe stdout/stderr so output is captured for TUI and not swallowed
28
+ const child = spawn(cmd, {
29
+ shell: true,
30
+ stdio: ['inherit', 'pipe', 'pipe'],
31
+ cwd: context.projectDir
32
+ });
33
+
34
+ // Track child process for signal handling
35
+ agentState.setChild(child);
36
+
37
+ const onData = (chunk: Buffer) => {
38
+ try { appendFileSync(outputPath, chunk.toString(), 'utf-8'); } catch (_) {}
39
+ };
40
+
41
+ child.stdout?.on('data', onData);
42
+ child.stderr?.on('data', onData);
43
+
44
+ child.on('close', (code) => {
45
+ agentState.clearChild();
46
+ resolve({
47
+ exitCode: code ?? 1,
48
+ outputPath
49
+ });
50
+ });
51
+
52
+ child.on('error', (err) => {
53
+ agentState.clearChild();
54
+ const msg = `Bash agent error: ${err.message}\n`;
55
+ try { appendFileSync(outputPath, msg, 'utf-8'); } catch (_) {}
56
+ resolve({
57
+ exitCode: 1,
58
+ outputPath
59
+ });
60
+ });
61
+ });
62
+ }
63
+ }
@@ -0,0 +1,142 @@
1
+ import { writeFileSync, appendFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { query } from '@anthropic-ai/claude-agent-sdk';
4
+ import { BaseAgent, agentState, AgentContext, AgentResult, StepDef } from './base.js';
5
+ import { generatePrompt } from '../prompts.js';
6
+ import { pipelineEvents } from '../events.js';
7
+
8
+ /**
9
+ * ClaudeCode Agent
10
+ * Runs all AI pipeline steps via the Agent SDK query() API.
11
+ * Handles spec/research/plan/review/reflect (text streaming) and
12
+ * build/fix (tool-heavy) steps with the same implementation.
13
+ */
14
+ export class ClaudeCodeAgent extends BaseAgent {
15
+ async run(phase: number, step: StepDef, promptPath: string | null, model: string, context: AgentContext): Promise<AgentResult> {
16
+ const { projectDir, config, logFile } = context;
17
+ const pipelineDir = join(projectDir, '.pipeline');
18
+ const outputPath = join(pipelineDir, 'step-output.log');
19
+
20
+ const promptText = generatePrompt(projectDir, config, phase, promptPath);
21
+ writeFileSync(join(pipelineDir, 'current-prompt.md'), promptText, 'utf-8');
22
+
23
+ if (agentState.interrupted) {
24
+ return { exitCode: 130, outputPath };
25
+ }
26
+
27
+ const controller = new AbortController();
28
+ const onInterrupt = () => controller.abort();
29
+ agentState.on('interrupt', onInterrupt);
30
+
31
+ // Clear output file so TUI file-tailer sees only this step's content
32
+ writeFileSync(outputPath, '', 'utf-8');
33
+
34
+ const logLine = (msg: string) => {
35
+ if (logFile) {
36
+ try { appendFileSync(logFile, msg + '\n', 'utf-8'); } catch (_) {}
37
+ }
38
+ };
39
+ const appendOutput = (line: string) => {
40
+ try { appendFileSync(outputPath, line + '\n', 'utf-8'); } catch (_) {}
41
+ };
42
+
43
+ const outputChunks: string[] = [];
44
+ let stepCostUSD = 0;
45
+
46
+ try {
47
+ const queryOptions: any = {
48
+ maxTurns: 500,
49
+ permissionMode: 'bypassPermissions',
50
+ // Unset CLAUDECODE to prevent SDK conflict when running inside Claude Code
51
+ env: { ...process.env, CLAUDECODE: undefined },
52
+ hooks: {
53
+ PreToolUse: [{ hooks: [async (data: any) => {
54
+ const line = `[tool:start] ${data.tool_name} ${JSON.stringify(data.tool_input ?? {}).slice(0, 120)}`;
55
+ logLine(line);
56
+ appendOutput(line);
57
+ }] }],
58
+ PostToolUse: [{ hooks: [async (data: any) => {
59
+ const success = !data.tool_response?.is_error;
60
+ const line = `[tool:done] ${data.tool_name} ${success ? '✓' : '✗'}`;
61
+ logLine(line);
62
+ appendOutput(line);
63
+ }] }],
64
+ SubagentStart: [{ hooks: [async (data: any) => {
65
+ const line = `[subagent:start] ${data.agent_id ?? ''}`;
66
+ logLine(line);
67
+ appendOutput(line);
68
+ }] }],
69
+ SubagentStop: [{ hooks: [async (data: any) => {
70
+ const line = `[subagent:done] ${data.agent_id ?? ''}`;
71
+ logLine(line);
72
+ appendOutput(line);
73
+ }] }],
74
+ Stop: [{ hooks: [async (_data: any) => {
75
+ logLine(`[session:stop]`);
76
+ }] }],
77
+ },
78
+ };
79
+
80
+ if (model && model !== 'default') {
81
+ queryOptions.model = model;
82
+ }
83
+
84
+ queryOptions.abortController = controller;
85
+ for await (const event of query({
86
+ prompt: promptText,
87
+ options: queryOptions,
88
+ })) {
89
+ if ((event as any).type === 'assistant' && (event as any).message?.role === 'assistant') {
90
+ for (const block of (event as any).message.content ?? []) {
91
+ if ((block as any).type === 'text') {
92
+ const text: string = (block as any).text;
93
+ outputChunks.push(text);
94
+ // Stream text lines to output file for TUI file-tailing
95
+ const textLines = text
96
+ .split('\n')
97
+ .map(l => l.trim())
98
+ .filter(l => l.length > 0)
99
+ .map(l => '[text] ' + l)
100
+ .join('\n');
101
+ if (textLines) appendOutput(textLines);
102
+ pipelineEvents.emit('text:chunk', { phase, step: step.name, text });
103
+ }
104
+ }
105
+ }
106
+ if ((event as any).type === 'result') {
107
+ stepCostUSD = (event as any).total_cost_usd ?? 0;
108
+ const reason = (event as any).stop_reason ?? 'end_turn';
109
+ pipelineEvents.emit('session:stop', { phase, step: step.name, reason });
110
+ }
111
+ }
112
+ } catch (err: any) {
113
+ agentState.off('interrupt', onInterrupt);
114
+
115
+ if (agentState.interrupted || controller.signal.aborted) {
116
+ appendOutput(outputChunks.join('\n'));
117
+ return { exitCode: 130, outputPath };
118
+ }
119
+
120
+ const errorText = `Error: ${err.message}\n${err.stack ?? ''}`;
121
+ writeFileSync(outputPath, errorText, 'utf-8');
122
+ return { exitCode: 1, outputPath };
123
+ }
124
+
125
+ agentState.off('interrupt', onInterrupt);
126
+
127
+ // Write final collected output (for steps without streaming, e.g. build)
128
+ if (outputChunks.length > 0) {
129
+ writeFileSync(outputPath, outputChunks.join('\n'), 'utf-8');
130
+ }
131
+
132
+ if (agentState.interrupted) {
133
+ return { exitCode: 130, outputPath };
134
+ }
135
+
136
+ return { exitCode: 0, outputPath, usage: { costUSD: stepCostUSD } };
137
+ }
138
+ }
139
+
140
+ export function createAgent(): ClaudeCodeAgent {
141
+ return new ClaudeCodeAgent();
142
+ }
@@ -0,0 +1,132 @@
1
+ import { spawn, ChildProcess } from 'node:child_process';
2
+ import { appendFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { BaseAgent, agentState, AgentContext, AgentResult, StepDef } from './base.js';
5
+ import { generatePrompt } from '../prompts.js';
6
+
7
+ const MAX_ATTEMPTS = 3;
8
+ const INACTIVITY_MS = 5 * 60 * 1000; // 5 minutes
9
+ const INACTIVITY_CHECK_MS = 30_000; // poll every 30 s
10
+
11
+ /**
12
+ * Codex agent — shells out to the `codex` CLI with --yolo.
13
+ * Always runs in auto-approval mode. Model can be set per-step or left to
14
+ * the codex CLI default.
15
+ *
16
+ * An inactivity timeout kills and retries the process if no output is seen
17
+ * for INACTIVITY_MS. After MAX_ATTEMPTS consecutive inactivity failures the
18
+ * step is marked as an error with all accumulated output preserved.
19
+ *
20
+ * Usage in workflow.yaml:
21
+ * agent: codex
22
+ * model: o4-mini # optional — omit to use codex's default model
23
+ */
24
+ export class CodexAgent extends BaseAgent {
25
+ async run(phase: number, step: StepDef, promptPath: string | null, model: string, context: AgentContext): Promise<AgentResult> {
26
+ const { projectDir } = context;
27
+ const pipelineDir = join(projectDir, '.pipeline');
28
+ const outputPath = join(pipelineDir, 'step-output.log');
29
+
30
+ const promptText = generatePrompt(projectDir, context.config, phase, promptPath);
31
+ writeFileSync(join(pipelineDir, 'current-prompt.md'), promptText, 'utf-8');
32
+ writeFileSync(outputPath, '', 'utf-8');
33
+
34
+ if (agentState.interrupted) {
35
+ return { exitCode: 130, outputPath };
36
+ }
37
+
38
+ const args = ['exec', '--yolo'];
39
+ if (model && model !== 'default') {
40
+ args.push('--model', model);
41
+ }
42
+ args.push(promptText);
43
+
44
+ const header = `$ codex exec --yolo${model && model !== 'default' ? ` --model ${model}` : ''} "<prompt>"\n`;
45
+ writeFileSync(outputPath, header, 'utf-8');
46
+
47
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
48
+ if (agentState.interrupted) {
49
+ return { exitCode: 130, outputPath };
50
+ }
51
+
52
+ if (attempt > 1) {
53
+ const sep = `\n--- codex: attempt ${attempt}/${MAX_ATTEMPTS} (previous attempt had no activity for ${INACTIVITY_MS / 60000} min) ---\n\n`;
54
+ try { appendFileSync(outputPath, sep, 'utf-8'); } catch (_) {}
55
+ }
56
+
57
+ const { exitCode, timedOut } = await this.spawnOnce(args, projectDir, outputPath);
58
+
59
+ if (!timedOut) {
60
+ // Normal exit (success or non-inactivity error) — return immediately.
61
+ return { exitCode, outputPath };
62
+ }
63
+
64
+ // Inactivity timeout — log and loop to next attempt.
65
+ const msg = `[codex: no output for ${INACTIVITY_MS / 60000} min — attempt ${attempt}/${MAX_ATTEMPTS} aborted]\n`;
66
+ try { appendFileSync(outputPath, msg, 'utf-8'); } catch (_) {}
67
+ }
68
+
69
+ // All attempts exhausted.
70
+ try {
71
+ appendFileSync(outputPath, `\n[codex: all ${MAX_ATTEMPTS} attempts timed out due to inactivity — giving up]\n`, 'utf-8');
72
+ } catch (_) {}
73
+ return { exitCode: 1, outputPath };
74
+ }
75
+
76
+ private spawnOnce(
77
+ args: string[],
78
+ projectDir: string,
79
+ outputPath: string,
80
+ ): Promise<{ exitCode: number; timedOut: boolean }> {
81
+ return new Promise((resolve) => {
82
+ let settled = false;
83
+ let lastActivity = Date.now();
84
+
85
+ const settle = (result: { exitCode: number; timedOut: boolean }) => {
86
+ if (settled) return;
87
+ settled = true;
88
+ clearInterval(inactivityTimer);
89
+ agentState.clearChild();
90
+ resolve(result);
91
+ };
92
+
93
+ const child: ChildProcess = spawn('codex', args, {
94
+ shell: false,
95
+ stdio: ['inherit', 'pipe', 'pipe'],
96
+ cwd: projectDir,
97
+ });
98
+
99
+ agentState.setChild(child);
100
+
101
+ const onData = (chunk: Buffer) => {
102
+ lastActivity = Date.now();
103
+ try { appendFileSync(outputPath, chunk.toString(), 'utf-8'); } catch (_) {}
104
+ };
105
+
106
+ child.stdout?.on('data', onData);
107
+ child.stderr?.on('data', onData);
108
+
109
+ const inactivityTimer = setInterval(() => {
110
+ if (Date.now() - lastActivity < INACTIVITY_MS) return;
111
+ // No output for too long — kill the process.
112
+ try { process.kill(child.pid!, 'SIGTERM'); } catch (_) {}
113
+ setTimeout(() => {
114
+ try { process.kill(child.pid!, 'SIGKILL'); } catch (_) {}
115
+ }, 2000);
116
+ settle({ exitCode: 1, timedOut: true });
117
+ }, INACTIVITY_CHECK_MS);
118
+
119
+ child.on('close', (code) => settle({ exitCode: code ?? 1, timedOut: false }));
120
+
121
+ child.on('error', (err) => {
122
+ const msg = `Codex agent error: ${err.message}\n`;
123
+ try { appendFileSync(outputPath, msg, 'utf-8'); } catch (_) {}
124
+ settle({ exitCode: 1, timedOut: false });
125
+ });
126
+ });
127
+ }
128
+ }
129
+
130
+ export function createAgent(): CodexAgent {
131
+ return new CodexAgent();
132
+ }