ctx-cc 3.5.0 → 4.0.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 +34 -289
- package/agents/ctx-arch-mapper.md +5 -3
- package/agents/ctx-auditor.md +5 -3
- 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 +168 -27
- package/commands/brand.md +309 -0
- package/commands/design.md +304 -0
- package/commands/experiment.md +251 -0
- package/commands/help.md +57 -7
- 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 +93 -0
- package/hooks/subagent-stop.js +32 -0
- package/package.json +9 -3
- package/plugin.json +45 -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 +111 -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 +171 -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 +119 -51
- package/src/lifecycle.js +194 -0
- package/src/metrics.js +198 -0
- package/src/pipeline.js +269 -0
- package/src/review-gate.js +244 -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 +1 -237
- package/workflows/ctx-router.md +0 -485
- package/workflows/map-codebase.md +0 -329
package/src/agents.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse YAML-like frontmatter from a markdown file.
|
|
6
|
+
* Returns { attrs: {}, body: string } or null if no frontmatter.
|
|
7
|
+
*/
|
|
8
|
+
export function parseFrontmatter(content) {
|
|
9
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
10
|
+
if (!match) return null;
|
|
11
|
+
|
|
12
|
+
const attrs = {};
|
|
13
|
+
for (const line of match[1].split('\n')) {
|
|
14
|
+
const sep = line.indexOf(':');
|
|
15
|
+
if (sep === -1) continue;
|
|
16
|
+
const key = line.slice(0, sep).trim();
|
|
17
|
+
const val = line.slice(sep + 1).trim();
|
|
18
|
+
attrs[key] = val;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return { attrs, body: match[2] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Derive a CLI command name from an agent filename.
|
|
26
|
+
* ctx-planner.md → plan
|
|
27
|
+
* ctx-arch-mapper.md → arch-map
|
|
28
|
+
* ctx-quality-mapper.md → quality-map
|
|
29
|
+
*/
|
|
30
|
+
export function deriveCommandName(filename) {
|
|
31
|
+
const base = filename.replace(/\.md$/, '').replace(/^ctx-/, '');
|
|
32
|
+
|
|
33
|
+
// Special mapping: *-mapper → *-map (shorter CLI names)
|
|
34
|
+
if (base.endsWith('-mapper')) return base.replace(/-mapper$/, '-map');
|
|
35
|
+
// Special mapping: *-suggester → base (e.g. criteria-suggester → criteria)
|
|
36
|
+
if (base.endsWith('-suggester')) return base.replace(/-suggester$/, '');
|
|
37
|
+
// Special mapping: *-coordinator → coordinate
|
|
38
|
+
if (base.endsWith('-coordinator')) return base.replace(/-coordinator$/, '');
|
|
39
|
+
|
|
40
|
+
// Common verb forms for shorter commands
|
|
41
|
+
const verbMap = {
|
|
42
|
+
'planner': 'plan',
|
|
43
|
+
'executor': 'execute',
|
|
44
|
+
'researcher': 'research',
|
|
45
|
+
'debugger': 'debug',
|
|
46
|
+
'reviewer': 'review',
|
|
47
|
+
'designer': 'design',
|
|
48
|
+
'verifier': 'verify',
|
|
49
|
+
'discusser': 'discuss',
|
|
50
|
+
'learner': 'learn',
|
|
51
|
+
'predictor': 'predict',
|
|
52
|
+
'auditor': 'audit',
|
|
53
|
+
'parallelizer': 'parallelize',
|
|
54
|
+
'mapper': 'map',
|
|
55
|
+
'handoff': 'handoff',
|
|
56
|
+
'team': 'team',
|
|
57
|
+
'qa': 'qa',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return verbMap[base] || base;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Discover all agents from a directory.
|
|
65
|
+
* Returns an array of { file, command, name, description, tools, color }.
|
|
66
|
+
*/
|
|
67
|
+
export function discoverAgents(agentsDir) {
|
|
68
|
+
if (!fs.existsSync(agentsDir)) return [];
|
|
69
|
+
|
|
70
|
+
const agents = [];
|
|
71
|
+
const entries = fs.readdirSync(agentsDir).filter(f => f.startsWith('ctx-') && f.endsWith('.md')).sort();
|
|
72
|
+
|
|
73
|
+
for (const file of entries) {
|
|
74
|
+
try {
|
|
75
|
+
const content = fs.readFileSync(path.join(agentsDir, file), 'utf-8');
|
|
76
|
+
const fm = parseFrontmatter(content);
|
|
77
|
+
if (!fm || !fm.attrs.name) {
|
|
78
|
+
process.stderr.write(`Warning: skipping ${file} (missing or invalid frontmatter)\n`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
agents.push({
|
|
83
|
+
file,
|
|
84
|
+
command: deriveCommandName(file),
|
|
85
|
+
name: fm.attrs.name,
|
|
86
|
+
description: fm.attrs.description || '',
|
|
87
|
+
tools: fm.attrs.tools || '',
|
|
88
|
+
color: fm.attrs.color || 'white',
|
|
89
|
+
});
|
|
90
|
+
} catch (err) {
|
|
91
|
+
process.stderr.write(`Warning: skipping ${file} (${err.message})\n`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return agents;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Format agents as a table for display.
|
|
100
|
+
*/
|
|
101
|
+
export function formatAgentTable(agents) {
|
|
102
|
+
const maxCmd = Math.max(12, ...agents.map(a => a.command.length));
|
|
103
|
+
const header = ` ${'Command'.padEnd(maxCmd)} Description`;
|
|
104
|
+
const sep = ` ${'─'.repeat(maxCmd)} ${'─'.repeat(50)}`;
|
|
105
|
+
|
|
106
|
+
const rows = agents.map(a => ` ${a.command.padEnd(maxCmd)} ${a.description}`);
|
|
107
|
+
|
|
108
|
+
return [header, sep, ...rows].join('\n');
|
|
109
|
+
}
|
package/src/auto.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { readState, writeState, initState, transitionPhase } from './state.js';
|
|
4
|
+
import { executePipeline } from './pipeline.js';
|
|
5
|
+
import { runReviewGate, isReviewGateEnabled } from './review-gate.js';
|
|
6
|
+
import { selectStory, listPendingStories } from './lifecycle.js';
|
|
7
|
+
import { commitTask } from './commits.js';
|
|
8
|
+
|
|
9
|
+
const STOP_FILE = 'STOP';
|
|
10
|
+
const AUTO_LOG = 'AUTO-LOG.md';
|
|
11
|
+
|
|
12
|
+
const DEFAULTS = {
|
|
13
|
+
maxIterationsPerStory: 5,
|
|
14
|
+
maxTotalTimeMs: 2 * 60 * 60 * 1000, // 2 hours
|
|
15
|
+
pipeline: ['plan', 'execute'],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run autonomous execution loop across all P1 stories (or a single story).
|
|
20
|
+
*
|
|
21
|
+
* Options:
|
|
22
|
+
* ctxDir, projectDir, agentsDir, config
|
|
23
|
+
* storyId — single story to process (null = all P1)
|
|
24
|
+
* retryFailed — only retry previously failed stories
|
|
25
|
+
* streaming — stream agent output
|
|
26
|
+
* timeout — per-agent timeout in ms
|
|
27
|
+
* onEvent — callback({ type, story, message, ... })
|
|
28
|
+
*/
|
|
29
|
+
export async function runAutoLoop({ ctxDir, projectDir, agentsDir, config = {}, storyId = null, retryFailed = false, streaming = true, timeout = 300000, onEvent = null }) {
|
|
30
|
+
const maxIterations = config.maxIterationsPerStory || DEFAULTS.maxIterationsPerStory;
|
|
31
|
+
const maxTime = config.maxTotalTimeMs || DEFAULTS.maxTotalTimeMs;
|
|
32
|
+
const startTime = Date.now();
|
|
33
|
+
|
|
34
|
+
// Initialize auto log
|
|
35
|
+
const logPath = path.join(ctxDir, AUTO_LOG);
|
|
36
|
+
appendLog(logPath, `# CTX Auto Loop — ${new Date().toISOString()}\n`);
|
|
37
|
+
|
|
38
|
+
// Determine stories to process
|
|
39
|
+
const stories = resolveStories(ctxDir, storyId, retryFailed);
|
|
40
|
+
if (stories.length === 0) {
|
|
41
|
+
emit(onEvent, { type: 'no_stories', message: 'No stories to process.' });
|
|
42
|
+
return { completed: [], failed: [], skipped: [], totalTime: 0 };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
emit(onEvent, { type: 'start', storyCount: stories.length, maxIterations, maxTime });
|
|
46
|
+
appendLog(logPath, `\nProcessing ${stories.length} stories. Max ${maxIterations} iterations each.\n`);
|
|
47
|
+
|
|
48
|
+
const completed = [];
|
|
49
|
+
const failed = [];
|
|
50
|
+
const skipped = [];
|
|
51
|
+
|
|
52
|
+
for (const story of stories) {
|
|
53
|
+
// Check stop file
|
|
54
|
+
if (shouldStop(ctxDir)) {
|
|
55
|
+
emit(onEvent, { type: 'stopped', message: 'STOP file detected. Halting after current story.' });
|
|
56
|
+
appendLog(logPath, `\n⏹ Stopped by STOP file at ${new Date().toISOString()}\n`);
|
|
57
|
+
skipped.push(...stories.slice(stories.indexOf(story)));
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check time limit
|
|
62
|
+
if (Date.now() - startTime > maxTime) {
|
|
63
|
+
emit(onEvent, { type: 'timeout', message: `Time limit (${maxTime / 1000 / 60}min) exceeded.` });
|
|
64
|
+
appendLog(logPath, `\n⏱ Time limit exceeded at ${new Date().toISOString()}\n`);
|
|
65
|
+
skipped.push(...stories.slice(stories.indexOf(story)));
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
emit(onEvent, { type: 'story_start', story: story.id, title: story.title });
|
|
70
|
+
appendLog(logPath, `\n## ${story.id} — ${story.title}\nStarted: ${new Date().toISOString()}\n`);
|
|
71
|
+
|
|
72
|
+
const result = await processStory({
|
|
73
|
+
story, ctxDir, projectDir, agentsDir, config,
|
|
74
|
+
maxIterations, streaming, timeout, onEvent, logPath,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (result.success) {
|
|
78
|
+
completed.push(story.id);
|
|
79
|
+
appendLog(logPath, `Result: ✓ COMPLETED (${result.iterations} iterations)\n`);
|
|
80
|
+
} else {
|
|
81
|
+
failed.push({ id: story.id, reason: result.reason });
|
|
82
|
+
appendLog(logPath, `Result: ✗ FAILED — ${result.reason}\n`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Write summary
|
|
87
|
+
const totalTime = Date.now() - startTime;
|
|
88
|
+
const summary = buildSummary(completed, failed, skipped, totalTime);
|
|
89
|
+
appendLog(logPath, `\n---\n${summary}`);
|
|
90
|
+
|
|
91
|
+
emit(onEvent, { type: 'complete', completed, failed, skipped, totalTime });
|
|
92
|
+
|
|
93
|
+
// Clean up stop file if it exists
|
|
94
|
+
cleanupStopFile(ctxDir);
|
|
95
|
+
|
|
96
|
+
return { completed, failed, skipped, totalTime };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create a STOP file to gracefully halt the auto loop.
|
|
101
|
+
*/
|
|
102
|
+
export function createStopFile(ctxDir) {
|
|
103
|
+
const stopPath = path.join(ctxDir, STOP_FILE);
|
|
104
|
+
fs.writeFileSync(stopPath, `Stop requested at ${new Date().toISOString()}\n`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Format auto loop results for display.
|
|
109
|
+
*/
|
|
110
|
+
export function formatAutoResult({ completed, failed, skipped, totalTime }) {
|
|
111
|
+
const lines = [];
|
|
112
|
+
const mins = Math.round(totalTime / 1000 / 60);
|
|
113
|
+
|
|
114
|
+
lines.push(` Total time: ${mins} minutes`);
|
|
115
|
+
lines.push('');
|
|
116
|
+
|
|
117
|
+
if (completed.length > 0) {
|
|
118
|
+
lines.push(` ✓ Completed (${completed.length}):`);
|
|
119
|
+
for (const id of completed) lines.push(` ${id}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (failed.length > 0) {
|
|
123
|
+
lines.push(` ✗ Failed (${failed.length}):`);
|
|
124
|
+
for (const f of failed) lines.push(` ${f.id} — ${f.reason}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (skipped.length > 0) {
|
|
128
|
+
lines.push(` ○ Skipped (${skipped.length}):`);
|
|
129
|
+
for (const s of skipped) lines.push(` ${s.id || s}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (failed.length > 0) {
|
|
133
|
+
lines.push('');
|
|
134
|
+
lines.push(' Retry failed: ctx-cc auto --retry-failed');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return lines.join('\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- internal ---
|
|
141
|
+
|
|
142
|
+
async function processStory({ story, ctxDir, projectDir, agentsDir, config, maxIterations, streaming, timeout, onEvent, logPath }) {
|
|
143
|
+
// Select story
|
|
144
|
+
selectStory(ctxDir, story.id);
|
|
145
|
+
|
|
146
|
+
for (let iteration = 1; iteration <= maxIterations; iteration++) {
|
|
147
|
+
emit(onEvent, { type: 'iteration', story: story.id, iteration, max: maxIterations });
|
|
148
|
+
appendLog(logPath, ` Iteration ${iteration}/${maxIterations}: `);
|
|
149
|
+
|
|
150
|
+
// Run pipeline: plan → execute
|
|
151
|
+
try {
|
|
152
|
+
transitionPhase(ctxDir, 'init'); // Reset to init for fresh pipeline
|
|
153
|
+
const pipeResult = await executePipeline({
|
|
154
|
+
steps: ['plan', 'execute'],
|
|
155
|
+
message: `Implement story ${story.id}: ${story.title}\n\n${story.description || ''}\n\nAcceptance criteria:\n${(story.acceptanceCriteria || []).map((c, i) => `${i + 1}. ${c}`).join('\n')}`,
|
|
156
|
+
ctxDir, projectDir, agentsDir,
|
|
157
|
+
streaming, timeout,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (pipeResult.failed) {
|
|
161
|
+
appendLog(logPath, `pipeline failed at ${pipeResult.failed}\n`);
|
|
162
|
+
if (iteration === maxIterations) {
|
|
163
|
+
return { success: false, iterations: iteration, reason: `Pipeline failed: ${pipeResult.error}` };
|
|
164
|
+
}
|
|
165
|
+
continue; // Retry
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
appendLog(logPath, `pipeline error: ${err.message}\n`);
|
|
169
|
+
if (iteration === maxIterations) {
|
|
170
|
+
return { success: false, iterations: iteration, reason: err.message };
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Run review gate (if enabled)
|
|
176
|
+
if (isReviewGateEnabled(config)) {
|
|
177
|
+
try {
|
|
178
|
+
const reviewResult = await runReviewGate({
|
|
179
|
+
ctxDir, projectDir, agentsDir, streaming, timeout, config,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (reviewResult.escalated) {
|
|
183
|
+
return { success: false, iterations: iteration, reason: 'Review loop exceeded — human review required.' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!reviewResult.passed) {
|
|
187
|
+
appendLog(logPath, `review failed (cycle ${reviewResult.cycle})\n`);
|
|
188
|
+
if (iteration === maxIterations) {
|
|
189
|
+
return { success: false, iterations: iteration, reason: `Review failed: ${reviewResult.feedback}` };
|
|
190
|
+
}
|
|
191
|
+
continue; // Retry with feedback
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
appendLog(logPath, `review error: ${err.message}\n`);
|
|
195
|
+
// Review errors don't block — continue
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// If we get here, story passed
|
|
200
|
+
appendLog(logPath, `passed\n`);
|
|
201
|
+
|
|
202
|
+
// Commit
|
|
203
|
+
commitTask({
|
|
204
|
+
projectDir, ctxDir,
|
|
205
|
+
agentName: 'auto',
|
|
206
|
+
taskId: story.id,
|
|
207
|
+
taskTitle: story.title,
|
|
208
|
+
criteriaIds: story.acceptanceCriteria || [],
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Mark story as passed in PRD
|
|
212
|
+
markStoryPassed(ctxDir, story.id);
|
|
213
|
+
|
|
214
|
+
return { success: true, iterations: iteration, reason: null };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { success: false, iterations: maxIterations, reason: `Max iterations (${maxIterations}) exceeded.` };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function resolveStories(ctxDir, storyId, retryFailed) {
|
|
221
|
+
if (storyId) {
|
|
222
|
+
// Single story mode
|
|
223
|
+
try {
|
|
224
|
+
const prd = JSON.parse(fs.readFileSync(path.join(ctxDir, 'PRD.json'), 'utf-8'));
|
|
225
|
+
const story = (prd.stories || []).find(s => s.id === storyId);
|
|
226
|
+
return story ? [story] : [];
|
|
227
|
+
} catch {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const pending = listPendingStories(ctxDir);
|
|
233
|
+
|
|
234
|
+
if (retryFailed) {
|
|
235
|
+
// Read auto log for failed stories
|
|
236
|
+
const state = readState(ctxDir);
|
|
237
|
+
const failedIds = new Set((state?.autoFailedStories || []).map(f => f.id || f));
|
|
238
|
+
return pending.filter(s => failedIds.has(s.id));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// All P1 stories first, then P2, etc.
|
|
242
|
+
return pending.sort((a, b) => (a.priority || 99) - (b.priority || 99));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function markStoryPassed(ctxDir, storyId) {
|
|
246
|
+
try {
|
|
247
|
+
const prdPath = path.join(ctxDir, 'PRD.json');
|
|
248
|
+
const prd = JSON.parse(fs.readFileSync(prdPath, 'utf-8'));
|
|
249
|
+
const story = (prd.stories || []).find(s => s.id === storyId);
|
|
250
|
+
if (story) {
|
|
251
|
+
story.passes = true;
|
|
252
|
+
story.verifiedAt = new Date().toISOString();
|
|
253
|
+
prd.metadata = prd.metadata || {};
|
|
254
|
+
prd.metadata.passedStories = (prd.metadata.passedStories || 0) + 1;
|
|
255
|
+
fs.writeFileSync(prdPath, JSON.stringify(prd, null, 2) + '\n');
|
|
256
|
+
}
|
|
257
|
+
} catch {}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function shouldStop(ctxDir) {
|
|
261
|
+
return fs.existsSync(path.join(ctxDir, STOP_FILE));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function cleanupStopFile(ctxDir) {
|
|
265
|
+
const stopPath = path.join(ctxDir, STOP_FILE);
|
|
266
|
+
try { fs.unlinkSync(stopPath); } catch {}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function appendLog(logPath, text) {
|
|
270
|
+
fs.appendFileSync(logPath, text);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function buildSummary(completed, failed, skipped, totalTime) {
|
|
274
|
+
const mins = Math.round(totalTime / 1000 / 60);
|
|
275
|
+
return [
|
|
276
|
+
`## Summary`,
|
|
277
|
+
`- Completed: ${completed.length}`,
|
|
278
|
+
`- Failed: ${failed.length}`,
|
|
279
|
+
`- Skipped: ${skipped.length}`,
|
|
280
|
+
`- Total time: ${mins} minutes`,
|
|
281
|
+
`- Finished: ${new Date().toISOString()}`,
|
|
282
|
+
].join('\n') + '\n';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function emit(fn, event) {
|
|
286
|
+
if (fn) fn(event);
|
|
287
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default capability manifests per agent category.
|
|
6
|
+
* Defines which tools each agent type is allowed to use.
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_CAPABILITIES = {
|
|
9
|
+
// Planning agents — read-only + write plans
|
|
10
|
+
planning: {
|
|
11
|
+
agents: ['ctx-planner.md', 'ctx-predictor.md', 'ctx-criteria-suggester.md', 'ctx-parallelizer.md'],
|
|
12
|
+
allowed: ['Read', 'Glob', 'Grep', 'Write', 'Agent', 'AskUserQuestion'],
|
|
13
|
+
denied: ['Edit', 'Bash', 'NotebookEdit'],
|
|
14
|
+
reason: 'Planning agents should not modify code directly.',
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
// Execution agents — full code access, no orchestration
|
|
18
|
+
execution: {
|
|
19
|
+
agents: ['ctx-executor.md', 'ctx-debugger.md'],
|
|
20
|
+
allowed: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'],
|
|
21
|
+
denied: ['Agent'],
|
|
22
|
+
reason: 'Execution agents should not spawn other agents.',
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
// Review agents — read + run tests, no modifications
|
|
26
|
+
review: {
|
|
27
|
+
agents: ['ctx-reviewer.md', 'ctx-auditor.md', 'ctx-verifier.md'],
|
|
28
|
+
allowed: ['Read', 'Glob', 'Grep', 'Bash'],
|
|
29
|
+
denied: ['Write', 'Edit', 'NotebookEdit'],
|
|
30
|
+
reason: 'Review agents should not modify code.',
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// Mapper agents — read-only analysis
|
|
34
|
+
mapping: {
|
|
35
|
+
agents: ['ctx-mapper.md', 'ctx-arch-mapper.md', 'ctx-tech-mapper.md', 'ctx-quality-mapper.md', 'ctx-concerns-mapper.md'],
|
|
36
|
+
allowed: ['Read', 'Glob', 'Grep', 'Bash', 'Write'],
|
|
37
|
+
denied: ['Edit'],
|
|
38
|
+
reason: 'Mapper agents analyze but should not modify existing code.',
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
// Knowledge agents — read + web research
|
|
42
|
+
knowledge: {
|
|
43
|
+
agents: ['ctx-researcher.md', 'ctx-learner.md'],
|
|
44
|
+
allowed: ['Read', 'Glob', 'Grep', 'Bash', 'Write', 'WebSearch', 'WebFetch'],
|
|
45
|
+
denied: ['Edit'],
|
|
46
|
+
reason: 'Knowledge agents gather info but should not modify code.',
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
// Coordination agents — state management
|
|
50
|
+
coordination: {
|
|
51
|
+
agents: ['ctx-team-coordinator.md', 'ctx-handoff.md', 'ctx-discusser.md'],
|
|
52
|
+
allowed: ['Read', 'Write', 'Glob', 'Grep', 'AskUserQuestion'],
|
|
53
|
+
denied: ['Edit', 'Bash'],
|
|
54
|
+
reason: 'Coordination agents manage state, not code.',
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
// Design agents — read + generate
|
|
58
|
+
design: {
|
|
59
|
+
agents: ['ctx-designer.md'],
|
|
60
|
+
allowed: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash'],
|
|
61
|
+
denied: [],
|
|
62
|
+
reason: 'Design agents may need full access for asset generation.',
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
// QA agents — full read + browser
|
|
66
|
+
qa: {
|
|
67
|
+
agents: ['ctx-qa.md'],
|
|
68
|
+
allowed: ['Read', 'Write', 'Glob', 'Grep', 'Bash'],
|
|
69
|
+
denied: ['Edit'],
|
|
70
|
+
reason: 'QA agents test but should not fix code.',
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load capability manifest from file, or return defaults.
|
|
76
|
+
*/
|
|
77
|
+
export function loadCapabilityManifest(ctxDir) {
|
|
78
|
+
const manifestPath = path.join(ctxDir, 'capability-manifest.json');
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
81
|
+
} catch {
|
|
82
|
+
return DEFAULT_CAPABILITIES;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Find the category for a given agent file.
|
|
88
|
+
*/
|
|
89
|
+
export function findAgentCategory(agentFile, manifest = DEFAULT_CAPABILITIES) {
|
|
90
|
+
for (const [category, config] of Object.entries(manifest)) {
|
|
91
|
+
if (config.agents.includes(agentFile)) {
|
|
92
|
+
return { category, ...config };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if a tool is allowed for an agent.
|
|
100
|
+
* Returns { allowed: boolean, reason: string|null }.
|
|
101
|
+
*/
|
|
102
|
+
export function checkToolAllowed(agentFile, toolName, manifest = DEFAULT_CAPABILITIES) {
|
|
103
|
+
const category = findAgentCategory(agentFile, manifest);
|
|
104
|
+
if (!category) {
|
|
105
|
+
// Unknown agent — allow everything (permissive for custom agents)
|
|
106
|
+
return { allowed: true, reason: null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (category.denied.includes(toolName)) {
|
|
110
|
+
return {
|
|
111
|
+
allowed: false,
|
|
112
|
+
reason: `Tool "${toolName}" denied for ${category.category} agents. ${category.reason}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { allowed: true, reason: null };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Generate a PreToolUse hook command that enforces capability restrictions.
|
|
121
|
+
* Returns the hook command string.
|
|
122
|
+
*/
|
|
123
|
+
export function generateCapabilityHookCommand(ctxDir) {
|
|
124
|
+
return `node -e "
|
|
125
|
+
const fs=require('fs'),p=require('path');
|
|
126
|
+
const tool=process.env.TOOL_NAME||'';
|
|
127
|
+
const agent=process.env.CURRENT_AGENT||'';
|
|
128
|
+
if(!agent||!tool)process.exit(0);
|
|
129
|
+
const mPath=p.join('${ctxDir}','capability-manifest.json');
|
|
130
|
+
let manifest;
|
|
131
|
+
try{manifest=JSON.parse(fs.readFileSync(mPath,'utf-8'));}catch{process.exit(0);}
|
|
132
|
+
for(const[cat,cfg]of Object.entries(manifest)){
|
|
133
|
+
if(cfg.agents.includes(agent)&&cfg.denied.includes(tool)){
|
|
134
|
+
console.error('CTX: Tool '+tool+' blocked for '+cat+' agent '+agent);
|
|
135
|
+
const logDir=p.join('${ctxDir}','violations.log');
|
|
136
|
+
fs.appendFileSync(logDir,new Date().toISOString()+' | '+agent+' | '+tool+' | BLOCKED\\n');
|
|
137
|
+
process.exit(2);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
"`.replace(/\n\s*/g, ' ').trim();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Save default capability manifest to .ctx/ for customization.
|
|
145
|
+
*/
|
|
146
|
+
export function saveCapabilityManifest(ctxDir) {
|
|
147
|
+
const manifestPath = path.join(ctxDir, 'capability-manifest.json');
|
|
148
|
+
if (!fs.existsSync(ctxDir)) fs.mkdirSync(ctxDir, { recursive: true });
|
|
149
|
+
fs.writeFileSync(manifestPath, JSON.stringify(DEFAULT_CAPABILITIES, null, 2) + '\n');
|
|
150
|
+
return manifestPath;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Format capabilities for display.
|
|
155
|
+
*/
|
|
156
|
+
export function formatCapabilities(manifest = DEFAULT_CAPABILITIES) {
|
|
157
|
+
const lines = [];
|
|
158
|
+
for (const [category, config] of Object.entries(manifest)) {
|
|
159
|
+
lines.push(` ${category}:`);
|
|
160
|
+
lines.push(` Agents: ${config.agents.map(a => a.replace('ctx-', '').replace('.md', '')).join(', ')}`);
|
|
161
|
+
lines.push(` Allowed: ${config.allowed.join(', ')}`);
|
|
162
|
+
if (config.denied.length > 0) {
|
|
163
|
+
lines.push(` Denied: ${config.denied.join(', ')}`);
|
|
164
|
+
}
|
|
165
|
+
lines.push(` Reason: ${config.reason}`);
|
|
166
|
+
lines.push('');
|
|
167
|
+
}
|
|
168
|
+
return lines.join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export { DEFAULT_CAPABILITIES };
|
package/src/commits.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { readState, writeState, recordCompletedTask } from './state.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create an atomic git commit for a completed task.
|
|
6
|
+
*
|
|
7
|
+
* Checks that there are staged or unstaged changes before committing.
|
|
8
|
+
* Commit message format: ctx(<agent>): <task-title>
|
|
9
|
+
* Body includes acceptance criteria satisfied.
|
|
10
|
+
*
|
|
11
|
+
* Returns { committed: boolean, hash: string|null, error: string|null }
|
|
12
|
+
*/
|
|
13
|
+
export function commitTask({ projectDir, ctxDir, agentName, taskId, taskTitle, criteriaIds = [] }) {
|
|
14
|
+
try {
|
|
15
|
+
// Check for changes
|
|
16
|
+
const status = execSync('git status --porcelain', {
|
|
17
|
+
cwd: projectDir, encoding: 'utf-8', timeout: 5000,
|
|
18
|
+
}).trim();
|
|
19
|
+
|
|
20
|
+
if (!status) {
|
|
21
|
+
return { committed: false, hash: null, error: 'No changes to commit.' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Stage all changes (excluding .ctx/ state files to avoid noise)
|
|
25
|
+
execSync('git add -A -- . ":!.ctx/STATE.json" ":!.ctx/STATE.lock" ":!.ctx/HANDOFF.json"', {
|
|
26
|
+
cwd: projectDir, timeout: 5000,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Build commit message
|
|
30
|
+
const subject = `ctx(${agentName}): ${taskTitle}`;
|
|
31
|
+
const body = buildCommitBody(taskId, criteriaIds);
|
|
32
|
+
const message = `${subject}\n\n${body}`;
|
|
33
|
+
|
|
34
|
+
// Commit
|
|
35
|
+
execSync(`git commit -m ${shellEscape(message)}`, {
|
|
36
|
+
cwd: projectDir, encoding: 'utf-8', timeout: 10000,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Get commit hash
|
|
40
|
+
const hash = execSync('git rev-parse --short HEAD', {
|
|
41
|
+
cwd: projectDir, encoding: 'utf-8', timeout: 5000,
|
|
42
|
+
}).trim();
|
|
43
|
+
|
|
44
|
+
// Record in state
|
|
45
|
+
recordCompletedTask(ctxDir, taskId, taskTitle, criteriaIds);
|
|
46
|
+
|
|
47
|
+
// Log commit in agent history
|
|
48
|
+
const state = readState(ctxDir);
|
|
49
|
+
if (state) {
|
|
50
|
+
state.lastCommit = { hash, taskId, taskTitle, committedAt: new Date().toISOString() };
|
|
51
|
+
writeState(ctxDir, state);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { committed: true, hash, error: null };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { committed: false, hash: null, error: err.message };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Show CTX commit log for the current story.
|
|
62
|
+
*/
|
|
63
|
+
export function getCtxCommitLog(projectDir, limit = 20) {
|
|
64
|
+
try {
|
|
65
|
+
const log = execSync(`git log --oneline --grep="^ctx(" -${limit} --no-color`, {
|
|
66
|
+
cwd: projectDir, encoding: 'utf-8', timeout: 5000,
|
|
67
|
+
}).trim();
|
|
68
|
+
return log || 'No CTX commits found.';
|
|
69
|
+
} catch {
|
|
70
|
+
return 'No CTX commits found.';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- internal ---
|
|
75
|
+
|
|
76
|
+
function buildCommitBody(taskId, criteriaIds) {
|
|
77
|
+
const lines = [];
|
|
78
|
+
if (taskId) lines.push(`Task: ${taskId}`);
|
|
79
|
+
if (criteriaIds.length > 0) {
|
|
80
|
+
lines.push('');
|
|
81
|
+
lines.push('Acceptance criteria satisfied:');
|
|
82
|
+
for (const id of criteriaIds) {
|
|
83
|
+
lines.push(` - ${id}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
lines.push('');
|
|
87
|
+
lines.push('Co-Authored-By: Claude <noreply@anthropic.com>');
|
|
88
|
+
return lines.join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function shellEscape(str) {
|
|
92
|
+
// Use $'...' syntax for strings with newlines
|
|
93
|
+
return "$'" + str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n') + "'";
|
|
94
|
+
}
|