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
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { readState, writeState, logAgentInvocation, completeAgentInvocation } from './state.js';
|
|
4
|
+
import { buildContext } from './context.js';
|
|
5
|
+
import { runAgent } from './runner.js';
|
|
6
|
+
|
|
7
|
+
const MAX_REVIEW_CYCLES = 3;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run the three-stage review gate.
|
|
11
|
+
*
|
|
12
|
+
* Stage 1: ctx-reviewer checks spec compliance (acceptance criteria)
|
|
13
|
+
* Stage 2: ctx-reviewer (quality framing) checks code quality (security, performance, style)
|
|
14
|
+
* Stage 3: ctx-codex-reviewer performs cross-model review via OpenAI Codex MCP
|
|
15
|
+
* (only runs if Stages 1 and 2 pass, and `config.codexReview !== false`).
|
|
16
|
+
*
|
|
17
|
+
* If any stage fails, returns feedback for re-execution.
|
|
18
|
+
* Max cycles before requiring human intervention.
|
|
19
|
+
*
|
|
20
|
+
* Options:
|
|
21
|
+
* ctxDir, projectDir, agentsDir, streaming, timeout, config
|
|
22
|
+
*
|
|
23
|
+
* Returns:
|
|
24
|
+
* { passed, stage1, stage2, stage3, cycle, feedback, escalated }
|
|
25
|
+
*/
|
|
26
|
+
export async function runReviewGate({ ctxDir, projectDir, agentsDir, streaming = true, timeout = 300000, config = {} }) {
|
|
27
|
+
const state = readState(ctxDir);
|
|
28
|
+
if (!state) throw new Error('No STATE.json found.');
|
|
29
|
+
|
|
30
|
+
const maxCycles = config.maxReviewCycles || MAX_REVIEW_CYCLES;
|
|
31
|
+
const reviewState = state.reviewGate || { cycle: 0, history: [] };
|
|
32
|
+
reviewState.cycle += 1;
|
|
33
|
+
|
|
34
|
+
if (reviewState.cycle > maxCycles) {
|
|
35
|
+
return {
|
|
36
|
+
passed: false,
|
|
37
|
+
stage1: null,
|
|
38
|
+
stage2: null,
|
|
39
|
+
cycle: reviewState.cycle,
|
|
40
|
+
feedback: `Review loop exceeded (${maxCycles} cycles). Human review required.`,
|
|
41
|
+
escalated: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Stage 1: Spec compliance (reviewer)
|
|
46
|
+
const stage1 = await runReviewStage({
|
|
47
|
+
stageName: 'spec-compliance',
|
|
48
|
+
agentFile: 'ctx-reviewer.md',
|
|
49
|
+
agentCommand: 'review',
|
|
50
|
+
prompt: buildReviewPrompt(state, 'spec'),
|
|
51
|
+
ctxDir, projectDir, agentsDir, streaming, timeout,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Stage 2: Code quality — only if Stage 1 passes. Reuses ctx-reviewer with quality framing;
|
|
55
|
+
// ctx-auditor is an audit-trail agent, not a code reviewer, so using it here was a miscast.
|
|
56
|
+
let stage2 = null;
|
|
57
|
+
if (stage1.passed) {
|
|
58
|
+
stage2 = await runReviewStage({
|
|
59
|
+
stageName: 'code-quality',
|
|
60
|
+
agentFile: 'ctx-reviewer.md',
|
|
61
|
+
agentCommand: 'review',
|
|
62
|
+
prompt: buildReviewPrompt(state, 'quality'),
|
|
63
|
+
ctxDir, projectDir, agentsDir, streaming, timeout,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Stage 3: Cross-model review via Codex — only if Stages 1 and 2 pass and not disabled.
|
|
68
|
+
// The agent may return VERDICT: SKIP (trivial changes, MCP unavailable, rate-limited);
|
|
69
|
+
// SKIP is treated as pass-through so infrastructure issues never block the gate.
|
|
70
|
+
// Across retry cycles we pipe the prior Codex threadId forward so the agent can
|
|
71
|
+
// reuse the cheaper codex-reply path instead of starting a fresh session.
|
|
72
|
+
let stage3 = null;
|
|
73
|
+
if (stage1.passed && stage2 && stage2.passed && config.codexReview !== false) {
|
|
74
|
+
const priorThreadId = priorCodexThreadId(reviewState);
|
|
75
|
+
stage3 = await runReviewStage({
|
|
76
|
+
stageName: 'codex-review',
|
|
77
|
+
agentFile: 'ctx-codex-reviewer.md',
|
|
78
|
+
agentCommand: 'review',
|
|
79
|
+
prompt: buildReviewPrompt(state, 'codex', { priorThreadId }),
|
|
80
|
+
ctxDir, projectDir, agentsDir, streaming, timeout,
|
|
81
|
+
});
|
|
82
|
+
const { skipped, threadId } = parseStage3Markers(stage3.output);
|
|
83
|
+
stage3.threadId = threadId;
|
|
84
|
+
if (skipped) {
|
|
85
|
+
stage3.passed = true;
|
|
86
|
+
stage3.skipped = true;
|
|
87
|
+
stage3.issues = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// stage2 defaults to false when null (stage1 failed → never ran → not passed).
|
|
92
|
+
// stage3 defaults to true when null (disabled or earlier stage failed → absence is not a fail).
|
|
93
|
+
const passed =
|
|
94
|
+
stage1.passed &&
|
|
95
|
+
(stage2 ? stage2.passed : false) &&
|
|
96
|
+
(stage3 ? stage3.passed : true);
|
|
97
|
+
|
|
98
|
+
// Build feedback for re-execution if failed
|
|
99
|
+
let feedback = null;
|
|
100
|
+
if (!passed) {
|
|
101
|
+
const issues = [];
|
|
102
|
+
if (!stage1.passed) issues.push(`Spec compliance: ${stage1.issues}`);
|
|
103
|
+
if (stage2 && !stage2.passed) issues.push(`Code quality: ${stage2.issues}`);
|
|
104
|
+
if (stage3 && !stage3.passed) issues.push(`Codex review: ${stage3.issues}`);
|
|
105
|
+
feedback = issues.join('\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const stage3History = stage3
|
|
109
|
+
? {
|
|
110
|
+
passed: stage3.passed,
|
|
111
|
+
issues: stage3.issues,
|
|
112
|
+
skipped: stage3.skipped || false,
|
|
113
|
+
threadId: stage3.threadId || null,
|
|
114
|
+
}
|
|
115
|
+
: null;
|
|
116
|
+
|
|
117
|
+
// Record in state
|
|
118
|
+
reviewState.history.push({
|
|
119
|
+
cycle: reviewState.cycle,
|
|
120
|
+
timestamp: new Date().toISOString(),
|
|
121
|
+
stage1: { passed: stage1.passed, issues: stage1.issues },
|
|
122
|
+
stage2: stage2 ? { passed: stage2.passed, issues: stage2.issues } : null,
|
|
123
|
+
stage3: stage3History,
|
|
124
|
+
result: passed ? 'pass' : 'fail',
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
state.reviewGate = reviewState;
|
|
128
|
+
writeState(ctxDir, state);
|
|
129
|
+
|
|
130
|
+
// Save review results
|
|
131
|
+
saveReviewResult(ctxDir, state.activeStory, reviewState);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
passed,
|
|
135
|
+
stage1: { passed: stage1.passed, issues: stage1.issues },
|
|
136
|
+
stage2: stage2 ? { passed: stage2.passed, issues: stage2.issues } : null,
|
|
137
|
+
stage3: stage3History,
|
|
138
|
+
cycle: reviewState.cycle,
|
|
139
|
+
feedback,
|
|
140
|
+
escalated: false,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check if the review gate is enabled in config.
|
|
146
|
+
*/
|
|
147
|
+
export function isReviewGateEnabled(config) {
|
|
148
|
+
return config.reviewGate !== false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Parse Stage 3 output markers.
|
|
153
|
+
* - `skipped` is true when the agent emitted `VERDICT: SKIP` (trivial change,
|
|
154
|
+
* MCP unavailable, auth expired, rate-limited).
|
|
155
|
+
* - `threadId` is the value after `THREAD: <id>`, used to resume cheaper
|
|
156
|
+
* `codex-reply` sessions across review cycles.
|
|
157
|
+
*
|
|
158
|
+
* Exported for unit testing; consumed by runReviewGate internally.
|
|
159
|
+
*/
|
|
160
|
+
export function parseStage3Markers(output) {
|
|
161
|
+
const text = output || '';
|
|
162
|
+
const skipped = /verdict:\s*skip/i.test(text);
|
|
163
|
+
const threadMatch = /THREAD:\s*([^\s]+)/i.exec(text);
|
|
164
|
+
return { skipped, threadId: threadMatch ? threadMatch[1] : null };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get review history from state.
|
|
169
|
+
*/
|
|
170
|
+
export function getReviewHistory(ctxDir) {
|
|
171
|
+
const state = readState(ctxDir);
|
|
172
|
+
return state?.reviewGate?.history || [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Format review gate result for display.
|
|
177
|
+
*/
|
|
178
|
+
export function formatReviewResult(result) {
|
|
179
|
+
if (!result) return ' No review results.';
|
|
180
|
+
|
|
181
|
+
const lines = [];
|
|
182
|
+
const icon = result.passed ? '✓' : '✗';
|
|
183
|
+
lines.push(` Review cycle ${result.cycle}: ${icon} ${result.passed ? 'PASSED' : 'FAILED'}`);
|
|
184
|
+
|
|
185
|
+
if (result.stage1) {
|
|
186
|
+
const s1Icon = result.stage1.passed ? '✓' : '✗';
|
|
187
|
+
lines.push(` ${s1Icon} Stage 1 (spec compliance): ${result.stage1.passed ? 'pass' : result.stage1.issues || 'fail'}`);
|
|
188
|
+
}
|
|
189
|
+
if (result.stage2) {
|
|
190
|
+
const s2Icon = result.stage2.passed ? '✓' : '✗';
|
|
191
|
+
lines.push(` ${s2Icon} Stage 2 (code quality): ${result.stage2.passed ? 'pass' : result.stage2.issues || 'fail'}`);
|
|
192
|
+
}
|
|
193
|
+
if (result.stage3) {
|
|
194
|
+
if (result.stage3.skipped) {
|
|
195
|
+
lines.push(` ○ Stage 3 (codex review): skipped`);
|
|
196
|
+
} else {
|
|
197
|
+
const s3Icon = result.stage3.passed ? '✓' : '✗';
|
|
198
|
+
lines.push(` ${s3Icon} Stage 3 (codex review): ${result.stage3.passed ? 'pass' : result.stage3.issues || 'fail'}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (result.escalated) {
|
|
203
|
+
lines.push('');
|
|
204
|
+
lines.push(' ⚠ Review loop exceeded — human review required.');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (result.feedback) {
|
|
208
|
+
lines.push('');
|
|
209
|
+
lines.push(' Feedback for re-execution:');
|
|
210
|
+
for (const line of result.feedback.split('\n')) {
|
|
211
|
+
lines.push(` ${line}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return lines.join('\n');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- internal ---
|
|
219
|
+
|
|
220
|
+
async function runReviewStage({ stageName, agentFile, agentCommand, prompt, ctxDir, projectDir, agentsDir, streaming, timeout }) {
|
|
221
|
+
const agentPath = path.join(agentsDir, agentFile);
|
|
222
|
+
|
|
223
|
+
if (!fs.existsSync(agentPath)) {
|
|
224
|
+
return { passed: true, issues: null, output: 'Agent not found — skipping.' };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
logAgentInvocation(ctxDir, agentFile, `Review gate: ${stageName}`);
|
|
228
|
+
|
|
229
|
+
const { context } = buildContext(agentCommand, projectDir, ctxDir);
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const result = await runAgent({
|
|
233
|
+
agentPath,
|
|
234
|
+
message: prompt,
|
|
235
|
+
streaming,
|
|
236
|
+
timeout,
|
|
237
|
+
context,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
completeAgentInvocation(ctxDir, agentFile);
|
|
241
|
+
|
|
242
|
+
const output = result.stdout || '';
|
|
243
|
+
const passed = parseReviewVerdict(output);
|
|
244
|
+
const issues = passed ? null : extractIssues(output);
|
|
245
|
+
|
|
246
|
+
return { passed, issues, output: output.slice(0, 2000) };
|
|
247
|
+
} catch (err) {
|
|
248
|
+
completeAgentInvocation(ctxDir, agentFile);
|
|
249
|
+
return { passed: false, issues: `Agent error: ${err.message}`, output: '' };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function priorCodexThreadId(reviewState) {
|
|
254
|
+
const hist = reviewState?.history;
|
|
255
|
+
if (!Array.isArray(hist)) return null;
|
|
256
|
+
for (let i = hist.length - 1; i >= 0; i--) {
|
|
257
|
+
const tid = hist[i]?.stage3?.threadId;
|
|
258
|
+
if (tid) return tid;
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function buildReviewPrompt(state, type, opts = {}) {
|
|
264
|
+
if (type === 'spec') {
|
|
265
|
+
return [
|
|
266
|
+
'Review the recent code changes for SPEC COMPLIANCE.',
|
|
267
|
+
'Check against the acceptance criteria for the current story.',
|
|
268
|
+
`Active story: ${state.activeStory || 'unknown'}`,
|
|
269
|
+
'',
|
|
270
|
+
'Output format:',
|
|
271
|
+
'VERDICT: PASS or FAIL',
|
|
272
|
+
'ISSUES: (list if FAIL)',
|
|
273
|
+
'',
|
|
274
|
+
'Be strict but fair. Focus on acceptance criteria satisfaction.',
|
|
275
|
+
].join('\n');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (type === 'codex') {
|
|
279
|
+
const lines = [
|
|
280
|
+
'Stage 3 — cross-model review via OpenAI Codex.',
|
|
281
|
+
'Stages 1 (spec) and 2 (quality) already passed under Claude review.',
|
|
282
|
+
`Active story: ${state.activeStory || 'unknown'}`,
|
|
283
|
+
];
|
|
284
|
+
if (opts.priorThreadId) {
|
|
285
|
+
lines.push(`Prior Codex thread: ${opts.priorThreadId} — reuse via mcp__codex__codex-reply if context is still relevant.`);
|
|
286
|
+
}
|
|
287
|
+
lines.push(
|
|
288
|
+
'',
|
|
289
|
+
'Run your playbook and output VERDICT: PASS | FAIL | SKIP on the final line. Append `THREAD: <id>` if a new thread was opened.',
|
|
290
|
+
);
|
|
291
|
+
return lines.join('\n');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return [
|
|
295
|
+
'Review the recent code changes for CODE QUALITY.',
|
|
296
|
+
'Check: security vulnerabilities, performance issues, code style, error handling.',
|
|
297
|
+
'',
|
|
298
|
+
'Output format:',
|
|
299
|
+
'VERDICT: PASS or FAIL',
|
|
300
|
+
'ISSUES: (list if FAIL)',
|
|
301
|
+
'',
|
|
302
|
+
'Be strict on security. Be reasonable on style.',
|
|
303
|
+
].join('\n');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function parseReviewVerdict(output) {
|
|
307
|
+
if (!output) return false;
|
|
308
|
+
const lower = output.toLowerCase();
|
|
309
|
+
// Look for explicit PASS verdict
|
|
310
|
+
if (/verdict:\s*pass/i.test(output)) return true;
|
|
311
|
+
// Look for explicit FAIL verdict
|
|
312
|
+
if (/verdict:\s*fail/i.test(output)) return false;
|
|
313
|
+
// Heuristic: if no critical issues mentioned, assume pass
|
|
314
|
+
if (lower.includes('all criteria') && lower.includes('pass')) return true;
|
|
315
|
+
if (lower.includes('no issues found')) return true;
|
|
316
|
+
// Default: fail (conservative)
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function extractIssues(output) {
|
|
321
|
+
if (!output) return 'No output from reviewer.';
|
|
322
|
+
// Try to extract issues section
|
|
323
|
+
const issueMatch = output.match(/ISSUES?:\s*([\s\S]*?)(?=\n\n|$)/i);
|
|
324
|
+
if (issueMatch) return issueMatch[1].trim().slice(0, 500);
|
|
325
|
+
// Fallback: first 300 chars
|
|
326
|
+
return output.slice(0, 300);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function saveReviewResult(ctxDir, storyId, reviewState) {
|
|
330
|
+
const reviewDir = path.join(ctxDir, 'reviews');
|
|
331
|
+
if (!fs.existsSync(reviewDir)) fs.mkdirSync(reviewDir, { recursive: true });
|
|
332
|
+
|
|
333
|
+
const filename = `${storyId || 'unknown'}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
|
334
|
+
fs.writeFileSync(
|
|
335
|
+
path.join(reviewDir, filename),
|
|
336
|
+
JSON.stringify(reviewState, null, 2) + '\n'
|
|
337
|
+
);
|
|
338
|
+
}
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { spawn, execSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check if the claude CLI is available.
|
|
9
|
+
* Returns the path to the binary, or null.
|
|
10
|
+
*/
|
|
11
|
+
export function findClaudeBinary() {
|
|
12
|
+
try {
|
|
13
|
+
const result = execSync('which claude 2>/dev/null || where claude 2>/dev/null', {
|
|
14
|
+
encoding: 'utf-8',
|
|
15
|
+
timeout: 5000,
|
|
16
|
+
}).trim();
|
|
17
|
+
return result || null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run an agent by reading its markdown file and invoking claude.
|
|
25
|
+
*
|
|
26
|
+
* Options:
|
|
27
|
+
* agentPath - absolute path to the agent .md file
|
|
28
|
+
* message - user message to pass to the agent
|
|
29
|
+
* streaming - stream output to stdout (default true)
|
|
30
|
+
* timeout - timeout in ms (default 300000)
|
|
31
|
+
* context - optional project context string to prepend
|
|
32
|
+
* onStderr - callback for stderr lines
|
|
33
|
+
*
|
|
34
|
+
* Returns { exitCode, stdout, stderr } (stdout only populated when !streaming)
|
|
35
|
+
*/
|
|
36
|
+
export function runAgent({ agentPath, message, streaming = true, timeout = DEFAULT_TIMEOUT_MS, context = '', onStderr = null }) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
// Validate agent file exists
|
|
39
|
+
if (!fs.existsSync(agentPath)) {
|
|
40
|
+
reject(new Error(`Agent file not found: ${agentPath}`));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check claude binary
|
|
45
|
+
const claudeBin = findClaudeBinary();
|
|
46
|
+
if (!claudeBin) {
|
|
47
|
+
reject(new Error(
|
|
48
|
+
'Claude Code CLI not found. Install from https://claude.ai/download\n' +
|
|
49
|
+
'Then ensure "claude" is available in your PATH.'
|
|
50
|
+
));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const instructions = fs.readFileSync(agentPath, 'utf-8');
|
|
55
|
+
const fullPrompt = context
|
|
56
|
+
? `${instructions}\n\n---\nProject Context:\n${context}\n\n---\nUser Request: ${message}`
|
|
57
|
+
: `${instructions}\n\n---\nUser Request: ${message}`;
|
|
58
|
+
|
|
59
|
+
const args = ['--print', '-p', fullPrompt];
|
|
60
|
+
if (streaming) args.unshift('--stream');
|
|
61
|
+
|
|
62
|
+
const child = spawn(claudeBin, args, {
|
|
63
|
+
stdio: streaming ? ['ignore', 'inherit', 'pipe'] : ['ignore', 'pipe', 'pipe'],
|
|
64
|
+
env: { ...process.env },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
let stdout = '';
|
|
68
|
+
let stderr = '';
|
|
69
|
+
|
|
70
|
+
if (!streaming && child.stdout) {
|
|
71
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (child.stderr) {
|
|
75
|
+
child.stderr.on('data', (chunk) => {
|
|
76
|
+
const text = chunk.toString();
|
|
77
|
+
stderr += text;
|
|
78
|
+
if (onStderr) onStderr(text);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Timeout handling
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
child.kill('SIGTERM');
|
|
85
|
+
// Give 5s for graceful shutdown, then force kill
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
if (!child.killed) child.kill('SIGKILL');
|
|
88
|
+
}, 5000);
|
|
89
|
+
reject(new Error(`Agent timed out after ${timeout / 1000}s. Increase timeout with: ctx config set agentTimeout ${timeout / 1000 * 2}`));
|
|
90
|
+
}, timeout);
|
|
91
|
+
|
|
92
|
+
// Forward SIGINT to child
|
|
93
|
+
const sigintHandler = () => {
|
|
94
|
+
child.kill('SIGTERM');
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
};
|
|
97
|
+
process.on('SIGINT', sigintHandler);
|
|
98
|
+
|
|
99
|
+
child.on('close', (code) => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
102
|
+
|
|
103
|
+
if (code !== 0 && code !== null) {
|
|
104
|
+
const errMsg = stderr.trim();
|
|
105
|
+
reject(new Error(
|
|
106
|
+
`Agent exited with code ${code}${errMsg ? `\n[stderr] ${errMsg}` : ''}`
|
|
107
|
+
));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
resolve({ exitCode: code ?? 0, stdout, stderr });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
child.on('error', (err) => {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
117
|
+
reject(new Error(`Failed to spawn claude: ${err.message}`));
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { parseFrontmatter, discoverAgents } from './agents.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Maximum tokens for a skill description (loaded at startup).
|
|
7
|
+
* Full instructions are loaded on-demand when the skill is invoked.
|
|
8
|
+
*/
|
|
9
|
+
const MAX_DESCRIPTION_TOKENS = 100;
|
|
10
|
+
const CHARS_PER_TOKEN = 4;
|
|
11
|
+
const MAX_DESCRIPTION_CHARS = MAX_DESCRIPTION_TOKENS * CHARS_PER_TOKEN;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Analyze agent descriptions and report which exceed the token budget.
|
|
15
|
+
* Returns { agents: [...], totalTokens, overBudget: [...] }
|
|
16
|
+
*/
|
|
17
|
+
export function analyzeDescriptions(agentsDir) {
|
|
18
|
+
const agents = discoverAgents(agentsDir);
|
|
19
|
+
const results = [];
|
|
20
|
+
let totalTokens = 0;
|
|
21
|
+
|
|
22
|
+
for (const agent of agents) {
|
|
23
|
+
const tokens = Math.ceil(agent.description.length / CHARS_PER_TOKEN);
|
|
24
|
+
totalTokens += tokens;
|
|
25
|
+
results.push({
|
|
26
|
+
file: agent.file,
|
|
27
|
+
command: agent.command,
|
|
28
|
+
description: agent.description,
|
|
29
|
+
tokens,
|
|
30
|
+
overBudget: tokens > MAX_DESCRIPTION_TOKENS,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const overBudget = results.filter(r => r.overBudget);
|
|
35
|
+
|
|
36
|
+
return { agents: results, totalTokens, overBudget };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generate a compact skill description from a verbose one.
|
|
41
|
+
* Truncates to ~100 tokens while preserving key info.
|
|
42
|
+
*/
|
|
43
|
+
export function compactDescription(description) {
|
|
44
|
+
if (description.length <= MAX_DESCRIPTION_CHARS) return description;
|
|
45
|
+
|
|
46
|
+
// Strategy: take first sentence, trim to budget
|
|
47
|
+
const firstSentence = description.split(/\.\s/)[0];
|
|
48
|
+
if (firstSentence.length <= MAX_DESCRIPTION_CHARS) {
|
|
49
|
+
return firstSentence + '.';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return description.slice(0, MAX_DESCRIPTION_CHARS - 3) + '...';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generate a skill-format agent file from existing agent markdown.
|
|
57
|
+
* The skill format has:
|
|
58
|
+
* - Compact description in frontmatter (~100 tokens)
|
|
59
|
+
* - Full instructions in body (loaded on-demand)
|
|
60
|
+
*
|
|
61
|
+
* Returns the new file content.
|
|
62
|
+
*/
|
|
63
|
+
export function convertToSkillFormat(agentContent) {
|
|
64
|
+
const parsed = parseFrontmatter(agentContent);
|
|
65
|
+
if (!parsed) return agentContent; // Not a valid agent file
|
|
66
|
+
|
|
67
|
+
const attrs = parsed.attrs;
|
|
68
|
+
const compactDesc = compactDescription(attrs.description || '');
|
|
69
|
+
|
|
70
|
+
// Rebuild frontmatter with compact description
|
|
71
|
+
const newFrontmatter = Object.entries(attrs)
|
|
72
|
+
.map(([key, val]) => {
|
|
73
|
+
if (key === 'description') return `description: ${compactDesc}`;
|
|
74
|
+
return `${key}: ${val}`;
|
|
75
|
+
})
|
|
76
|
+
.join('\n');
|
|
77
|
+
|
|
78
|
+
return `---\n${newFrontmatter}\n---\n${parsed.body}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Convert all agents in a directory to skill format.
|
|
83
|
+
* Writes new files to outputDir (or modifies in place if same as input).
|
|
84
|
+
* Returns { converted: number, skipped: number, totalTokensSaved: number }
|
|
85
|
+
*/
|
|
86
|
+
export function convertAllAgents(agentsDir, outputDir = null) {
|
|
87
|
+
const targetDir = outputDir || agentsDir;
|
|
88
|
+
if (targetDir !== agentsDir && !fs.existsSync(targetDir)) {
|
|
89
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const files = fs.readdirSync(agentsDir).filter(f => f.startsWith('ctx-') && f.endsWith('.md'));
|
|
93
|
+
let converted = 0;
|
|
94
|
+
let skipped = 0;
|
|
95
|
+
let tokensSaved = 0;
|
|
96
|
+
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
const content = fs.readFileSync(path.join(agentsDir, file), 'utf-8');
|
|
99
|
+
const parsed = parseFrontmatter(content);
|
|
100
|
+
if (!parsed) { skipped++; continue; }
|
|
101
|
+
|
|
102
|
+
const oldTokens = Math.ceil((parsed.attrs.description || '').length / CHARS_PER_TOKEN);
|
|
103
|
+
const newContent = convertToSkillFormat(content);
|
|
104
|
+
const newParsed = parseFrontmatter(newContent);
|
|
105
|
+
const newTokens = Math.ceil((newParsed?.attrs.description || '').length / CHARS_PER_TOKEN);
|
|
106
|
+
|
|
107
|
+
fs.writeFileSync(path.join(targetDir, file), newContent);
|
|
108
|
+
tokensSaved += Math.max(0, oldTokens - newTokens);
|
|
109
|
+
converted++;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { converted, skipped, totalTokensSaved: tokensSaved };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Calculate total upfront context cost of all agent descriptions.
|
|
117
|
+
*/
|
|
118
|
+
export function calculateUpfrontTokens(agentsDir) {
|
|
119
|
+
const { totalTokens, agents } = analyzeDescriptions(agentsDir);
|
|
120
|
+
return { totalTokens, agentCount: agents.length };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Format analysis results for display.
|
|
125
|
+
*/
|
|
126
|
+
export function formatAnalysis(analysis) {
|
|
127
|
+
const lines = [];
|
|
128
|
+
lines.push(` Total agents: ${analysis.agents.length}`);
|
|
129
|
+
lines.push(` Total description tokens: ${analysis.totalTokens}`);
|
|
130
|
+
lines.push(` Over budget (>${MAX_DESCRIPTION_TOKENS} tokens): ${analysis.overBudget.length}`);
|
|
131
|
+
|
|
132
|
+
if (analysis.overBudget.length > 0) {
|
|
133
|
+
lines.push('');
|
|
134
|
+
lines.push(' Over-budget agents:');
|
|
135
|
+
for (const a of analysis.overBudget) {
|
|
136
|
+
lines.push(` ${a.command.padEnd(15)} ${a.tokens} tokens (${a.tokens - MAX_DESCRIPTION_TOKENS} over)`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { MAX_DESCRIPTION_TOKENS, MAX_DESCRIPTION_CHARS };
|