ctx-cc 4.1.0 → 4.1.2

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.
@@ -1,338 +0,0 @@
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 DELETED
@@ -1,120 +0,0 @@
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
- }