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.
- package/README.md +375 -676
- package/agents/ctx-arch-mapper.md +5 -3
- package/agents/ctx-auditor.md +5 -3
- package/agents/ctx-codex-reviewer.md +214 -0
- package/agents/ctx-concerns-mapper.md +5 -3
- package/agents/ctx-criteria-suggester.md +6 -4
- package/agents/ctx-debugger.md +5 -3
- package/agents/ctx-designer.md +488 -114
- package/agents/ctx-discusser.md +5 -3
- package/agents/ctx-executor.md +5 -3
- package/agents/ctx-handoff.md +6 -4
- package/agents/ctx-learner.md +5 -3
- package/agents/ctx-mapper.md +4 -3
- package/agents/ctx-ml-analyst.md +600 -0
- package/agents/ctx-ml-engineer.md +933 -0
- package/agents/ctx-ml-reviewer.md +485 -0
- package/agents/ctx-ml-scientist.md +626 -0
- package/agents/ctx-parallelizer.md +4 -3
- package/agents/ctx-planner.md +5 -3
- package/agents/ctx-predictor.md +4 -3
- package/agents/ctx-qa.md +5 -3
- package/agents/ctx-quality-mapper.md +5 -3
- package/agents/ctx-researcher.md +5 -3
- package/agents/ctx-reviewer.md +6 -4
- package/agents/ctx-team-coordinator.md +5 -3
- package/agents/ctx-tech-mapper.md +5 -3
- package/agents/ctx-verifier.md +5 -3
- package/bin/ctx.js +199 -27
- package/commands/brand.md +309 -0
- package/commands/ctx.md +10 -10
- package/commands/design.md +304 -0
- package/commands/experiment.md +251 -0
- package/commands/help.md +57 -7
- package/commands/init.md +25 -0
- package/commands/metrics.md +1 -1
- package/commands/milestone.md +1 -1
- package/commands/ml-status.md +197 -0
- package/commands/monitor.md +1 -1
- package/commands/train.md +266 -0
- package/commands/visual-qa.md +559 -0
- package/commands/voice.md +1 -1
- package/hooks/post-tool-use.js +39 -0
- package/hooks/pre-tool-use.js +94 -0
- package/hooks/subagent-stop.js +32 -0
- package/package.json +9 -3
- package/plugin.json +46 -0
- package/skills/ctx-design-system/SKILL.md +572 -0
- package/skills/ctx-ml-experiment/SKILL.md +334 -0
- package/skills/ctx-ml-pipeline/SKILL.md +437 -0
- package/skills/ctx-orchestrator/SKILL.md +91 -0
- package/skills/ctx-review-gate/SKILL.md +147 -0
- package/skills/ctx-state/SKILL.md +100 -0
- package/skills/ctx-visual-qa/SKILL.md +587 -0
- package/src/agents.js +109 -0
- package/src/auto.js +287 -0
- package/src/capabilities.js +226 -0
- package/src/commits.js +94 -0
- package/src/config.js +112 -0
- package/src/context.js +241 -0
- package/src/handoff.js +156 -0
- package/src/hooks.js +218 -0
- package/src/install.js +125 -50
- package/src/lifecycle.js +194 -0
- package/src/metrics.js +198 -0
- package/src/pipeline.js +269 -0
- package/src/review-gate.js +338 -0
- package/src/runner.js +120 -0
- package/src/skills.js +143 -0
- package/src/state.js +267 -0
- package/src/worktree.js +244 -0
- package/templates/PRD.json +1 -1
- package/templates/config.json +4 -237
- package/workflows/ctx-router.md +0 -485
- package/workflows/map-codebase.md +0 -329
package/src/lifecycle.js
ADDED
|
@@ -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
|
+
}
|
package/src/pipeline.js
ADDED
|
@@ -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 };
|