ctx-cc 3.5.0 → 4.1.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 (74) hide show
  1. package/README.md +375 -676
  2. package/agents/ctx-arch-mapper.md +5 -3
  3. package/agents/ctx-auditor.md +5 -3
  4. package/agents/ctx-codex-reviewer.md +214 -0
  5. package/agents/ctx-concerns-mapper.md +5 -3
  6. package/agents/ctx-criteria-suggester.md +6 -4
  7. package/agents/ctx-debugger.md +5 -3
  8. package/agents/ctx-designer.md +488 -114
  9. package/agents/ctx-discusser.md +5 -3
  10. package/agents/ctx-executor.md +5 -3
  11. package/agents/ctx-handoff.md +6 -4
  12. package/agents/ctx-learner.md +5 -3
  13. package/agents/ctx-mapper.md +4 -3
  14. package/agents/ctx-ml-analyst.md +600 -0
  15. package/agents/ctx-ml-engineer.md +933 -0
  16. package/agents/ctx-ml-reviewer.md +485 -0
  17. package/agents/ctx-ml-scientist.md +626 -0
  18. package/agents/ctx-parallelizer.md +4 -3
  19. package/agents/ctx-planner.md +5 -3
  20. package/agents/ctx-predictor.md +4 -3
  21. package/agents/ctx-qa.md +5 -3
  22. package/agents/ctx-quality-mapper.md +5 -3
  23. package/agents/ctx-researcher.md +5 -3
  24. package/agents/ctx-reviewer.md +6 -4
  25. package/agents/ctx-team-coordinator.md +5 -3
  26. package/agents/ctx-tech-mapper.md +5 -3
  27. package/agents/ctx-verifier.md +5 -3
  28. package/bin/ctx.js +199 -27
  29. package/commands/brand.md +309 -0
  30. package/commands/ctx.md +10 -10
  31. package/commands/design.md +304 -0
  32. package/commands/experiment.md +251 -0
  33. package/commands/help.md +57 -7
  34. package/commands/init.md +25 -0
  35. package/commands/metrics.md +1 -1
  36. package/commands/milestone.md +1 -1
  37. package/commands/ml-status.md +197 -0
  38. package/commands/monitor.md +1 -1
  39. package/commands/train.md +266 -0
  40. package/commands/visual-qa.md +559 -0
  41. package/commands/voice.md +1 -1
  42. package/hooks/post-tool-use.js +39 -0
  43. package/hooks/pre-tool-use.js +94 -0
  44. package/hooks/subagent-stop.js +32 -0
  45. package/package.json +9 -3
  46. package/plugin.json +46 -0
  47. package/skills/ctx-design-system/SKILL.md +572 -0
  48. package/skills/ctx-ml-experiment/SKILL.md +334 -0
  49. package/skills/ctx-ml-pipeline/SKILL.md +437 -0
  50. package/skills/ctx-orchestrator/SKILL.md +91 -0
  51. package/skills/ctx-review-gate/SKILL.md +147 -0
  52. package/skills/ctx-state/SKILL.md +100 -0
  53. package/skills/ctx-visual-qa/SKILL.md +587 -0
  54. package/src/agents.js +109 -0
  55. package/src/auto.js +287 -0
  56. package/src/capabilities.js +226 -0
  57. package/src/commits.js +94 -0
  58. package/src/config.js +112 -0
  59. package/src/context.js +241 -0
  60. package/src/handoff.js +156 -0
  61. package/src/hooks.js +218 -0
  62. package/src/install.js +125 -50
  63. package/src/lifecycle.js +194 -0
  64. package/src/metrics.js +198 -0
  65. package/src/pipeline.js +269 -0
  66. package/src/review-gate.js +338 -0
  67. package/src/runner.js +120 -0
  68. package/src/skills.js +143 -0
  69. package/src/state.js +267 -0
  70. package/src/worktree.js +244 -0
  71. package/templates/PRD.json +1 -1
  72. package/templates/config.json +4 -237
  73. package/workflows/ctx-router.md +0 -485
  74. package/workflows/map-codebase.md +0 -329
@@ -0,0 +1,194 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { readState, writeState, transitionPhase, initState, logAgentInvocation } from './state.js';
4
+ import { discoverAgents } from './agents.js';
5
+ import { buildContext } from './context.js';
6
+ import { runAgent } from './runner.js';
7
+ import { loadConfig } from './config.js';
8
+
9
+ /**
10
+ * Phase-to-agent mapping.
11
+ * When `ctx next` advances to a phase, this agent is spawned.
12
+ */
13
+ const PHASE_AGENTS = {
14
+ plan: 'ctx-planner.md',
15
+ execute: 'ctx-executor.md',
16
+ verify: 'ctx-verifier.md',
17
+ };
18
+
19
+ /**
20
+ * Determine the next phase from the current state.
21
+ * Returns { nextPhase, agent, message } or { error }.
22
+ */
23
+ export function determineNext(state) {
24
+ if (!state) {
25
+ return { error: 'No STATE.json found. Run /ctx:init first.' };
26
+ }
27
+
28
+ const phaseMap = {
29
+ init: 'plan',
30
+ plan: 'execute',
31
+ execute: 'verify',
32
+ verify: null, // depends on verification result
33
+ complete: null, // needs next story
34
+ };
35
+
36
+ // Verify phase: check if criteria passed
37
+ if (state.phase === 'verify') {
38
+ // If there are failed criteria, go back to execute
39
+ if (state.verificationResult === 'fail') {
40
+ return {
41
+ nextPhase: 'execute',
42
+ agent: PHASE_AGENTS.execute,
43
+ message: `Fix verification failures and re-implement. Previous failures: ${JSON.stringify(state.verificationFailures || [])}`,
44
+ };
45
+ }
46
+ // If passed, complete
47
+ return {
48
+ nextPhase: 'complete',
49
+ agent: null,
50
+ message: 'All acceptance criteria passed. Story complete.',
51
+ };
52
+ }
53
+
54
+ // Complete phase: need to pick next story
55
+ if (state.phase === 'complete') {
56
+ return {
57
+ nextPhase: 'init',
58
+ agent: null,
59
+ message: 'Story complete. Select next story from PRD.',
60
+ needsStorySelection: true,
61
+ };
62
+ }
63
+
64
+ const nextPhase = phaseMap[state.phase];
65
+ if (!nextPhase) {
66
+ return { error: `Cannot determine next phase from "${state.phase}".` };
67
+ }
68
+
69
+ return {
70
+ nextPhase,
71
+ agent: PHASE_AGENTS[nextPhase] || null,
72
+ message: `Advancing to ${nextPhase} phase.`,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Execute the `ctx next` command.
78
+ * Determines next phase, transitions state, spawns appropriate agent.
79
+ *
80
+ * Options:
81
+ * ctxDir - .ctx directory path
82
+ * projectDir - project root
83
+ * agentsDir - agents directory path
84
+ * streaming - stream agent output (default true)
85
+ * config - loaded config object
86
+ */
87
+ export async function executeNext({ ctxDir, projectDir, agentsDir, streaming = true, config = {} }) {
88
+ const state = readState(ctxDir);
89
+ const next = determineNext(state);
90
+
91
+ if (next.error) {
92
+ throw new Error(next.error);
93
+ }
94
+
95
+ if (next.needsStorySelection) {
96
+ return { action: 'select_story', message: next.message };
97
+ }
98
+
99
+ // Complete phase — no agent needed
100
+ if (!next.agent) {
101
+ if (next.nextPhase === 'complete') {
102
+ transitionPhase(ctxDir, 'complete');
103
+ return { action: 'completed', message: next.message };
104
+ }
105
+ transitionPhase(ctxDir, next.nextPhase);
106
+ return { action: 'transitioned', phase: next.nextPhase, message: next.message };
107
+ }
108
+
109
+ // Transition state
110
+ transitionPhase(ctxDir, next.nextPhase);
111
+
112
+ // Build agent-specific context
113
+ const agentCommand = agentFileToCommand(next.agent);
114
+ const { context, estimatedTokens, warnings } = buildContext(agentCommand, projectDir, ctxDir);
115
+
116
+ for (const w of warnings) {
117
+ process.stderr.write(`\x1b[33m Warning: ${w}\x1b[0m\n`);
118
+ }
119
+
120
+ // Log invocation
121
+ logAgentInvocation(ctxDir, next.agent, `Phase: ${next.nextPhase}`);
122
+
123
+ // Run agent
124
+ const agentPath = path.join(agentsDir, next.agent);
125
+ const timeout = (config.agentTimeout || 300) * 1000;
126
+
127
+ const result = await runAgent({
128
+ agentPath,
129
+ message: next.message,
130
+ streaming,
131
+ timeout,
132
+ context,
133
+ });
134
+
135
+ return {
136
+ action: 'agent_completed',
137
+ phase: next.nextPhase,
138
+ agent: next.agent,
139
+ exitCode: result.exitCode,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Select a story from PRD.json and set it as active.
145
+ * Returns the story object or null.
146
+ */
147
+ export function selectStory(ctxDir, storyId) {
148
+ const prdPath = path.join(ctxDir, 'PRD.json');
149
+ let prd;
150
+ try {
151
+ prd = JSON.parse(fs.readFileSync(prdPath, 'utf-8'));
152
+ } catch {
153
+ return null;
154
+ }
155
+
156
+ const story = (prd.stories || []).find(s => s.id === storyId);
157
+ if (!story) return null;
158
+
159
+ // Update PRD metadata
160
+ prd.metadata = prd.metadata || {};
161
+ prd.metadata.currentStory = storyId;
162
+ fs.writeFileSync(prdPath, JSON.stringify(prd, null, 2) + '\n');
163
+
164
+ // Update state
165
+ const state = readState(ctxDir) || {};
166
+ state.phase = 'init';
167
+ state.activeStory = storyId;
168
+ state.storyTitle = story.title;
169
+ state.completedTasks = [];
170
+ state.verificationResult = null;
171
+ state.verificationFailures = null;
172
+ writeState(ctxDir, state);
173
+
174
+ return story;
175
+ }
176
+
177
+ /**
178
+ * List unfinished stories from PRD.json.
179
+ */
180
+ export function listPendingStories(ctxDir) {
181
+ const prdPath = path.join(ctxDir, 'PRD.json');
182
+ try {
183
+ const prd = JSON.parse(fs.readFileSync(prdPath, 'utf-8'));
184
+ return (prd.stories || []).filter(s => !s.passes);
185
+ } catch {
186
+ return [];
187
+ }
188
+ }
189
+
190
+ // --- internal ---
191
+
192
+ function agentFileToCommand(agentFile) {
193
+ return agentFile.replace(/^ctx-/, '').replace(/\.md$/, '').replace(/er$/, '').replace(/or$/, '');
194
+ }
package/src/metrics.js ADDED
@@ -0,0 +1,198 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { execSync } from 'child_process';
4
+ import { readState } from './state.js';
5
+
6
+ /**
7
+ * Collect metrics from STATE.json history and git log.
8
+ * Returns a metrics object with all computed stats.
9
+ */
10
+ export function collectMetrics(ctxDir, projectDir) {
11
+ const state = readState(ctxDir);
12
+ const prd = loadPrd(ctxDir);
13
+ const gitStats = collectGitStats(projectDir);
14
+
15
+ const agentHistory = state?.agentHistory || [];
16
+ const completedTasks = state?.completedTasks || [];
17
+ const reviewHistory = state?.reviewGate?.history || [];
18
+
19
+ // Stories
20
+ const stories = prd?.stories || [];
21
+ const totalStories = stories.length;
22
+ const passedStories = stories.filter(s => s.passes).length;
23
+
24
+ // Agent metrics
25
+ const agentCounts = {};
26
+ let totalAgentDuration = 0;
27
+ for (const entry of agentHistory) {
28
+ const name = entry.agent?.replace('ctx-', '').replace('.md', '') || 'unknown';
29
+ agentCounts[name] = (agentCounts[name] || 0) + 1;
30
+ if (entry.invokedAt && entry.completedAt) {
31
+ totalAgentDuration += new Date(entry.completedAt) - new Date(entry.invokedAt);
32
+ }
33
+ }
34
+
35
+ // Review metrics
36
+ const totalReviews = reviewHistory.length;
37
+ const passedFirstTry = reviewHistory.filter(r => r.cycle === 1 && r.result === 'pass').length;
38
+ const avgCycles = totalReviews > 0
39
+ ? reviewHistory.reduce((sum, r) => sum + r.cycle, 0) / totalReviews
40
+ : 0;
41
+
42
+ // Token estimates (rough: based on context payloads)
43
+ const estimatedTokens = agentHistory.length * 15000; // ~15K per agent invocation average
44
+
45
+ // Time per story
46
+ const avgTimePerStory = completedTasks.length > 0 && state?.session?.startedAt
47
+ ? (Date.now() - new Date(state.session.startedAt).getTime()) / Math.max(passedStories, 1)
48
+ : 0;
49
+
50
+ return {
51
+ stories: {
52
+ total: totalStories,
53
+ passed: passedStories,
54
+ remaining: totalStories - passedStories,
55
+ completionRate: totalStories > 0 ? Math.round(passedStories / totalStories * 100) : 0,
56
+ },
57
+ agents: {
58
+ totalInvocations: agentHistory.length,
59
+ byType: agentCounts,
60
+ totalDurationMs: totalAgentDuration,
61
+ avgDurationMs: agentHistory.length > 0 ? Math.round(totalAgentDuration / agentHistory.length) : 0,
62
+ },
63
+ reviews: {
64
+ total: totalReviews,
65
+ passedFirstTry,
66
+ firstTryRate: totalReviews > 0 ? Math.round(passedFirstTry / totalReviews * 100) : 0,
67
+ avgCycles: Math.round(avgCycles * 10) / 10,
68
+ },
69
+ tasks: {
70
+ completed: completedTasks.length,
71
+ },
72
+ tokens: {
73
+ estimated: estimatedTokens,
74
+ estimatedCost: estimateTokenCost(estimatedTokens),
75
+ },
76
+ git: gitStats,
77
+ session: {
78
+ startedAt: state?.session?.startedAt || null,
79
+ lastActivity: state?.session?.lastActivity || null,
80
+ avgTimePerStoryMs: Math.round(avgTimePerStory),
81
+ },
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Format metrics for human-readable display.
87
+ */
88
+ export function formatMetrics(metrics) {
89
+ const lines = [];
90
+
91
+ // Stories
92
+ lines.push(' Stories');
93
+ lines.push(` Completed: ${metrics.stories.passed}/${metrics.stories.total} (${metrics.stories.completionRate}%)`);
94
+ lines.push(` Remaining: ${metrics.stories.remaining}`);
95
+ lines.push('');
96
+
97
+ // Agents
98
+ lines.push(' Agent Invocations');
99
+ lines.push(` Total: ${metrics.agents.totalInvocations}`);
100
+ if (Object.keys(metrics.agents.byType).length > 0) {
101
+ const sorted = Object.entries(metrics.agents.byType).sort((a, b) => b[1] - a[1]);
102
+ for (const [name, count] of sorted) {
103
+ lines.push(` ${name.padEnd(20)} ${count}`);
104
+ }
105
+ }
106
+ if (metrics.agents.avgDurationMs > 0) {
107
+ lines.push(` Avg duration: ${formatDuration(metrics.agents.avgDurationMs)}`);
108
+ }
109
+ lines.push('');
110
+
111
+ // Reviews
112
+ if (metrics.reviews.total > 0) {
113
+ lines.push(' Review Gate');
114
+ lines.push(` Reviews: ${metrics.reviews.total}`);
115
+ lines.push(` First-try pass: ${metrics.reviews.passedFirstTry} (${metrics.reviews.firstTryRate}%)`);
116
+ lines.push(` Avg cycles: ${metrics.reviews.avgCycles}`);
117
+ lines.push('');
118
+ }
119
+
120
+ // Tokens
121
+ if (metrics.tokens.estimated > 0) {
122
+ lines.push(' Token Usage (estimated)');
123
+ lines.push(` Total: ~${formatNumber(metrics.tokens.estimated)} tokens`);
124
+ lines.push(` Est. cost: ~$${metrics.tokens.estimatedCost.toFixed(2)}`);
125
+ lines.push('');
126
+ }
127
+
128
+ // Git
129
+ if (metrics.git.ctxCommits > 0) {
130
+ lines.push(' Git');
131
+ lines.push(` CTX commits: ${metrics.git.ctxCommits}`);
132
+ lines.push(` Total commits: ${metrics.git.totalCommits}`);
133
+ lines.push('');
134
+ }
135
+
136
+ // Session
137
+ if (metrics.session.avgTimePerStoryMs > 0) {
138
+ lines.push(' Session');
139
+ lines.push(` Avg per story: ${formatDuration(metrics.session.avgTimePerStoryMs)}`);
140
+ lines.push(` Started: ${metrics.session.startedAt || 'N/A'}`);
141
+ }
142
+
143
+ return lines.join('\n');
144
+ }
145
+
146
+ /**
147
+ * Format metrics as JSON for external processing.
148
+ */
149
+ export function formatMetricsJson(metrics) {
150
+ return JSON.stringify(metrics, null, 2);
151
+ }
152
+
153
+ // --- internal ---
154
+
155
+ function loadPrd(ctxDir) {
156
+ try {
157
+ return JSON.parse(fs.readFileSync(path.join(ctxDir, 'PRD.json'), 'utf-8'));
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ function collectGitStats(projectDir) {
164
+ try {
165
+ const ctxCommits = parseInt(
166
+ execSync('git log --oneline --grep="^ctx(" --no-color 2>/dev/null | wc -l', {
167
+ cwd: projectDir, encoding: 'utf-8', timeout: 5000,
168
+ }).trim(), 10) || 0;
169
+
170
+ const totalCommits = parseInt(
171
+ execSync('git rev-list --count HEAD 2>/dev/null', {
172
+ cwd: projectDir, encoding: 'utf-8', timeout: 5000,
173
+ }).trim(), 10) || 0;
174
+
175
+ return { ctxCommits, totalCommits };
176
+ } catch {
177
+ return { ctxCommits: 0, totalCommits: 0 };
178
+ }
179
+ }
180
+
181
+ function estimateTokenCost(tokens) {
182
+ // Rough estimate: $3/M input + $15/M output, assume 60/40 split
183
+ const inputTokens = tokens * 0.6;
184
+ const outputTokens = tokens * 0.4;
185
+ return (inputTokens / 1_000_000 * 3) + (outputTokens / 1_000_000 * 15);
186
+ }
187
+
188
+ function formatDuration(ms) {
189
+ if (ms < 1000) return `${ms}ms`;
190
+ if (ms < 60000) return `${Math.round(ms / 1000)}s`;
191
+ return `${Math.round(ms / 60000)}m`;
192
+ }
193
+
194
+ function formatNumber(n) {
195
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
196
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
197
+ return String(n);
198
+ }
@@ -0,0 +1,269 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { readState, writeState, transitionPhase, logAgentInvocation, completeAgentInvocation } from './state.js';
4
+ import { buildContext } from './context.js';
5
+ import { runAgent } from './runner.js';
6
+ import { discoverAgents } from './agents.js';
7
+
8
+ /**
9
+ * Valid pipeline sequences. Each step maps to an agent file.
10
+ */
11
+ const AGENT_FILES = {
12
+ plan: 'ctx-planner.md',
13
+ execute: 'ctx-executor.md',
14
+ verify: 'ctx-verifier.md',
15
+ review: 'ctx-reviewer.md',
16
+ audit: 'ctx-auditor.md',
17
+ research: 'ctx-researcher.md',
18
+ debug: 'ctx-debugger.md',
19
+ };
20
+
21
+ /**
22
+ * Validate that a pipeline sequence is logically valid.
23
+ * Returns { valid, error }.
24
+ */
25
+ export function validatePipeline(steps) {
26
+ if (!steps || steps.length === 0) {
27
+ return { valid: false, error: 'Pipeline requires at least one step.' };
28
+ }
29
+
30
+ for (const step of steps) {
31
+ if (!AGENT_FILES[step]) {
32
+ const available = Object.keys(AGENT_FILES).join(', ');
33
+ return { valid: false, error: `Unknown pipeline step "${step}". Available: ${available}` };
34
+ }
35
+ }
36
+
37
+ // Validate ordering constraints
38
+ const idx = (s) => steps.indexOf(s);
39
+ if (steps.includes('execute') && steps.includes('plan') && idx('execute') < idx('plan')) {
40
+ return { valid: false, error: 'Cannot execute before planning.' };
41
+ }
42
+ if (steps.includes('verify') && steps.includes('execute') && idx('verify') < idx('execute')) {
43
+ return { valid: false, error: 'Cannot verify before executing.' };
44
+ }
45
+
46
+ return { valid: true, error: null };
47
+ }
48
+
49
+ /**
50
+ * Parse a pipeline string like "plan,execute,verify" into steps.
51
+ */
52
+ export function parsePipelineString(str) {
53
+ return str.split(',').map(s => s.trim()).filter(Boolean);
54
+ }
55
+
56
+ /**
57
+ * Execute a pipeline of agents sequentially.
58
+ * Each agent's output (or summary) is passed as context to the next.
59
+ *
60
+ * Options:
61
+ * steps - array of step names: ['plan', 'execute', 'verify']
62
+ * message - initial user message
63
+ * ctxDir - .ctx directory path
64
+ * projectDir - project root
65
+ * agentsDir - agents directory
66
+ * streaming - stream output (default true)
67
+ * timeout - per-agent timeout in ms
68
+ * onStep - callback({ step, index, total, status })
69
+ *
70
+ * Returns { completed: string[], failed: string|null, outputs: object }
71
+ */
72
+ export async function executePipeline({ steps, message, ctxDir, projectDir, agentsDir, streaming = true, timeout = 300000, onStep = null }) {
73
+ const { valid, error } = validatePipeline(steps);
74
+ if (!valid) throw new Error(error);
75
+
76
+ // Initialize pipeline state
77
+ const pipelineState = {
78
+ steps,
79
+ currentStep: 0,
80
+ status: 'running',
81
+ startedAt: new Date().toISOString(),
82
+ completedSteps: [],
83
+ failedStep: null,
84
+ outputs: {},
85
+ };
86
+
87
+ // Save pipeline state
88
+ const state = readState(ctxDir);
89
+ if (state) {
90
+ state.pipeline = pipelineState;
91
+ writeState(ctxDir, state);
92
+ }
93
+
94
+ let previousOutput = message;
95
+
96
+ for (let i = 0; i < steps.length; i++) {
97
+ const step = steps[i];
98
+ const agentFile = AGENT_FILES[step];
99
+ const agentPath = path.join(agentsDir, agentFile);
100
+
101
+ // Notify callback
102
+ if (onStep) onStep({ step, index: i, total: steps.length, status: 'starting' });
103
+
104
+ // Update pipeline state
105
+ pipelineState.currentStep = i;
106
+ if (state) {
107
+ state.pipeline = pipelineState;
108
+ writeState(ctxDir, state);
109
+ }
110
+
111
+ // Build agent-specific context
112
+ const { context } = buildContext(step, projectDir, ctxDir);
113
+
114
+ // Compose prompt: previous output feeds into next agent
115
+ const agentMessage = i === 0
116
+ ? previousOutput
117
+ : `Previous agent (${steps[i - 1]}) output summary:\n${summarizeOutput(previousOutput)}\n\nOriginal request: ${message}`;
118
+
119
+ // Log invocation
120
+ logAgentInvocation(ctxDir, agentFile, `Pipeline step ${i + 1}/${steps.length}: ${step}`);
121
+
122
+ try {
123
+ const result = await runAgent({
124
+ agentPath,
125
+ message: agentMessage,
126
+ streaming,
127
+ timeout,
128
+ context,
129
+ });
130
+
131
+ completeAgentInvocation(ctxDir, agentFile);
132
+
133
+ // Store output for next agent
134
+ previousOutput = result.stdout || `Step "${step}" completed successfully.`;
135
+ pipelineState.outputs[step] = previousOutput.slice(0, 2000); // Cap stored output
136
+ pipelineState.completedSteps.push(step);
137
+
138
+ // Save step output to file for reference
139
+ saveStepOutput(ctxDir, step, i, previousOutput);
140
+
141
+ if (onStep) onStep({ step, index: i, total: steps.length, status: 'completed' });
142
+
143
+ } catch (err) {
144
+ pipelineState.failedStep = step;
145
+ pipelineState.status = 'failed';
146
+ pipelineState.failedAt = new Date().toISOString();
147
+ pipelineState.failureReason = err.message;
148
+
149
+ if (state) {
150
+ state.pipeline = pipelineState;
151
+ writeState(ctxDir, state);
152
+ }
153
+
154
+ if (onStep) onStep({ step, index: i, total: steps.length, status: 'failed' });
155
+
156
+ return {
157
+ completed: pipelineState.completedSteps,
158
+ failed: step,
159
+ error: err.message,
160
+ outputs: pipelineState.outputs,
161
+ };
162
+ }
163
+ }
164
+
165
+ // Pipeline complete
166
+ pipelineState.status = 'completed';
167
+ pipelineState.completedAt = new Date().toISOString();
168
+ if (state) {
169
+ state.pipeline = pipelineState;
170
+ writeState(ctxDir, state);
171
+ }
172
+
173
+ return {
174
+ completed: pipelineState.completedSteps,
175
+ failed: null,
176
+ error: null,
177
+ outputs: pipelineState.outputs,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Resume a failed pipeline from the failed step.
183
+ */
184
+ export async function resumePipeline({ ctxDir, projectDir, agentsDir, streaming = true, timeout = 300000, onStep = null }) {
185
+ const state = readState(ctxDir);
186
+ if (!state?.pipeline) {
187
+ throw new Error('No pipeline state found. Start a new pipeline with: ctx-cc pipeline <steps> <message>');
188
+ }
189
+
190
+ const { pipeline } = state;
191
+ if (pipeline.status !== 'failed') {
192
+ throw new Error(`Pipeline is "${pipeline.status}", not failed. Nothing to resume.`);
193
+ }
194
+
195
+ // Rebuild steps from the failed step onward
196
+ const failedIdx = pipeline.steps.indexOf(pipeline.failedStep);
197
+ const remainingSteps = pipeline.steps.slice(failedIdx);
198
+ const previousMessage = pipeline.outputs[pipeline.steps[failedIdx - 1]] || 'Resume from failure.';
199
+
200
+ return executePipeline({
201
+ steps: remainingSteps,
202
+ message: previousMessage,
203
+ ctxDir,
204
+ projectDir,
205
+ agentsDir,
206
+ streaming,
207
+ timeout,
208
+ onStep,
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Get pipeline status from state.
214
+ */
215
+ export function getPipelineStatus(ctxDir) {
216
+ const state = readState(ctxDir);
217
+ if (!state?.pipeline) return null;
218
+ return state.pipeline;
219
+ }
220
+
221
+ /**
222
+ * Format pipeline status for display.
223
+ */
224
+ export function formatPipelineStatus(pipeline) {
225
+ if (!pipeline) return ' No active pipeline.';
226
+
227
+ const lines = [];
228
+ lines.push(` Status: ${pipeline.status}`);
229
+ lines.push(` Steps: ${pipeline.steps.join(' → ')}`);
230
+ lines.push('');
231
+
232
+ for (let i = 0; i < pipeline.steps.length; i++) {
233
+ const step = pipeline.steps[i];
234
+ let icon = '○'; // pending
235
+ if (pipeline.completedSteps.includes(step)) icon = '✓';
236
+ if (step === pipeline.failedStep) icon = '✗';
237
+ if (i === pipeline.currentStep && pipeline.status === 'running') icon = '▸';
238
+
239
+ lines.push(` ${icon} ${step}`);
240
+ }
241
+
242
+ if (pipeline.failedStep) {
243
+ lines.push('');
244
+ lines.push(` Failed at: ${pipeline.failedStep}`);
245
+ lines.push(` Reason: ${pipeline.failureReason || 'unknown'}`);
246
+ lines.push(` Resume: ctx-cc pipeline resume`);
247
+ }
248
+
249
+ return lines.join('\n');
250
+ }
251
+
252
+ // --- internal ---
253
+
254
+ function summarizeOutput(output) {
255
+ if (!output) return 'No output.';
256
+ // Take first 1500 chars as summary
257
+ const maxLen = 1500;
258
+ if (output.length <= maxLen) return output;
259
+ return output.slice(0, maxLen) + '\n\n... (output truncated)';
260
+ }
261
+
262
+ function saveStepOutput(ctxDir, step, index, output) {
263
+ const pipeDir = path.join(ctxDir, 'pipeline');
264
+ if (!fs.existsSync(pipeDir)) fs.mkdirSync(pipeDir, { recursive: true });
265
+ const filePath = path.join(pipeDir, `${String(index + 1).padStart(2, '0')}-${step}.md`);
266
+ fs.writeFileSync(filePath, `# Pipeline Step: ${step}\n\n${output}\n`);
267
+ }
268
+
269
+ export { AGENT_FILES };