ctx-cc 4.1.1 → 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.
- package/README.md +5 -5
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/auto.js +0 -287
- package/src/commits.js +0 -94
- package/src/context.js +0 -241
- package/src/handoff.js +0 -156
- package/src/hooks.js +0 -218
- package/src/lifecycle.js +0 -194
- package/src/metrics.js +0 -198
- package/src/pipeline.js +0 -269
- package/src/review-gate.js +0 -338
- package/src/runner.js +0 -120
- package/src/state.js +0 -267
- package/src/worktree.js +0 -244
package/src/handoff.js
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { readState } from './state.js';
|
|
4
|
-
|
|
5
|
-
const HANDOFF_FILE = 'HANDOFF.json';
|
|
6
|
-
const HANDOFFS_DIR = 'handoffs';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Create a handoff file from current state.
|
|
10
|
-
* Serializes state + generates a human-readable context summary.
|
|
11
|
-
*/
|
|
12
|
-
export function createHandoff(ctxDir) {
|
|
13
|
-
const state = readState(ctxDir);
|
|
14
|
-
if (!state) {
|
|
15
|
-
throw new Error('No STATE.json found. Nothing to pause.');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const handoff = {
|
|
19
|
-
version: '4.0',
|
|
20
|
-
createdAt: new Date().toISOString(),
|
|
21
|
-
state: { ...state },
|
|
22
|
-
summary: generateSummary(state),
|
|
23
|
-
nextAction: getNextAction(state),
|
|
24
|
-
decisions: extractDecisions(ctxDir),
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
// Archive existing handoff if present
|
|
28
|
-
archiveHandoff(ctxDir);
|
|
29
|
-
|
|
30
|
-
// Write new handoff
|
|
31
|
-
const handoffPath = path.join(ctxDir, HANDOFF_FILE);
|
|
32
|
-
fs.writeFileSync(handoffPath, JSON.stringify(handoff, null, 2) + '\n');
|
|
33
|
-
|
|
34
|
-
return handoff;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Read a handoff file. Returns null if not found.
|
|
39
|
-
*/
|
|
40
|
-
export function readHandoff(ctxDir) {
|
|
41
|
-
const handoffPath = path.join(ctxDir, HANDOFF_FILE);
|
|
42
|
-
try {
|
|
43
|
-
return JSON.parse(fs.readFileSync(handoffPath, 'utf-8'));
|
|
44
|
-
} catch {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Resume from a handoff. Restores state and returns context for continuation.
|
|
51
|
-
*/
|
|
52
|
-
export function resumeFromHandoff(ctxDir) {
|
|
53
|
-
const handoff = readHandoff(ctxDir);
|
|
54
|
-
if (!handoff) {
|
|
55
|
-
throw new Error('No paused session found. Run /ctx:pause first to create a checkpoint.');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Clean up handoff file after resume
|
|
59
|
-
const handoffPath = path.join(ctxDir, HANDOFF_FILE);
|
|
60
|
-
archiveHandoff(ctxDir);
|
|
61
|
-
|
|
62
|
-
return handoff;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Format handoff for human-readable display.
|
|
67
|
-
*/
|
|
68
|
-
export function formatHandoff(handoff) {
|
|
69
|
-
if (!handoff) return ' No paused session found.';
|
|
70
|
-
|
|
71
|
-
const lines = [
|
|
72
|
-
` Paused at: ${handoff.createdAt}`,
|
|
73
|
-
` Phase: ${handoff.state?.phase || 'unknown'}`,
|
|
74
|
-
` Story: ${handoff.state?.activeStory || 'none'} ${handoff.state?.storyTitle ? `— ${handoff.state.storyTitle}` : ''}`,
|
|
75
|
-
` Tasks done: ${(handoff.state?.completedTasks || []).length}`,
|
|
76
|
-
'',
|
|
77
|
-
' Summary:',
|
|
78
|
-
` ${handoff.summary}`,
|
|
79
|
-
'',
|
|
80
|
-
` Next action: ${handoff.nextAction}`,
|
|
81
|
-
];
|
|
82
|
-
|
|
83
|
-
if (handoff.decisions && handoff.decisions.length > 0) {
|
|
84
|
-
lines.push('', ' Key decisions:');
|
|
85
|
-
for (const d of handoff.decisions) {
|
|
86
|
-
lines.push(` - ${d}`);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return lines.join('\n');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// --- internal ---
|
|
94
|
-
|
|
95
|
-
function generateSummary(state) {
|
|
96
|
-
const parts = [`Phase: ${state.phase}`];
|
|
97
|
-
|
|
98
|
-
if (state.activeStory) {
|
|
99
|
-
parts.push(`Working on: ${state.activeStory} ${state.storyTitle ? `(${state.storyTitle})` : ''}`);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const taskCount = (state.completedTasks || []).length;
|
|
103
|
-
if (taskCount > 0) {
|
|
104
|
-
parts.push(`Completed ${taskCount} task(s)`);
|
|
105
|
-
const lastTask = state.completedTasks[taskCount - 1];
|
|
106
|
-
parts.push(`Last completed: ${lastTask.title}`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const agentCount = (state.agentHistory || []).length;
|
|
110
|
-
if (agentCount > 0) {
|
|
111
|
-
const lastAgent = state.agentHistory[agentCount - 1];
|
|
112
|
-
parts.push(`Last agent: ${lastAgent.agent} (${lastAgent.taskSummary})`);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return parts.join('. ');
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function getNextAction(state) {
|
|
119
|
-
const actions = {
|
|
120
|
-
init: 'Run /ctx:plan to create an execution plan',
|
|
121
|
-
plan: 'Run /ctx:execute to start building',
|
|
122
|
-
execute: 'Continue execution or run /ctx:verify',
|
|
123
|
-
verify: 'Check verification results and advance',
|
|
124
|
-
complete: 'Pick next story from PRD',
|
|
125
|
-
};
|
|
126
|
-
return actions[state.phase] || 'Run /ctx:status to check state';
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function extractDecisions(ctxDir) {
|
|
130
|
-
// Try to read CONTEXT.md for decisions
|
|
131
|
-
try {
|
|
132
|
-
const contextPath = path.join(ctxDir, 'CONTEXT.md');
|
|
133
|
-
const content = fs.readFileSync(contextPath, 'utf-8');
|
|
134
|
-
const decisions = [];
|
|
135
|
-
for (const line of content.split('\n')) {
|
|
136
|
-
if (line.startsWith('- ') && (line.includes('decision') || line.includes('chose') || line.includes('decided'))) {
|
|
137
|
-
decisions.push(line.slice(2));
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return decisions;
|
|
141
|
-
} catch {
|
|
142
|
-
return [];
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function archiveHandoff(ctxDir) {
|
|
147
|
-
const handoffPath = path.join(ctxDir, HANDOFF_FILE);
|
|
148
|
-
if (!fs.existsSync(handoffPath)) return;
|
|
149
|
-
|
|
150
|
-
const archiveDir = path.join(ctxDir, HANDOFFS_DIR);
|
|
151
|
-
if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
|
|
152
|
-
|
|
153
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
154
|
-
const archivePath = path.join(archiveDir, `HANDOFF-${timestamp}.json`);
|
|
155
|
-
fs.renameSync(handoffPath, archivePath);
|
|
156
|
-
}
|
package/src/hooks.js
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { readState, writeState } from './state.js';
|
|
4
|
-
|
|
5
|
-
const CTX_HOOK_MARKER = '// CTX_HOOKS';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Hook definitions that CTX generates for Claude Code settings.json.
|
|
9
|
-
* Each hook has: event, matcher (optional), command, description.
|
|
10
|
-
*/
|
|
11
|
-
const HOOK_DEFINITIONS = {
|
|
12
|
-
// Record agent completion in STATE.json
|
|
13
|
-
subagentCompletion: {
|
|
14
|
-
event: 'SubagentStop',
|
|
15
|
-
command: `node -e "
|
|
16
|
-
const fs=require('fs'),p=require('path');
|
|
17
|
-
const d=p.join(process.cwd(),'.ctx','STATE.json');
|
|
18
|
-
if(!fs.existsSync(d))process.exit(0);
|
|
19
|
-
const s=JSON.parse(fs.readFileSync(d,'utf-8'));
|
|
20
|
-
const h=s.agentHistory||[];
|
|
21
|
-
for(let i=h.length-1;i>=0;i--){
|
|
22
|
-
if(!h[i].completedAt){h[i].completedAt=new Date().toISOString();break;}
|
|
23
|
-
}
|
|
24
|
-
s.session={...s.session,lastActivity:new Date().toISOString()};
|
|
25
|
-
fs.writeFileSync(d,JSON.stringify(s,null,2)+'\\n');
|
|
26
|
-
"`,
|
|
27
|
-
description: 'Record agent completion in STATE.json',
|
|
28
|
-
configKey: 'hooks.subagentCompletion',
|
|
29
|
-
default: true,
|
|
30
|
-
},
|
|
31
|
-
|
|
32
|
-
// Block commits without test file changes
|
|
33
|
-
blockCommitWithoutTests: {
|
|
34
|
-
event: 'PreToolUse',
|
|
35
|
-
matcher: 'Bash',
|
|
36
|
-
command: `node -e "
|
|
37
|
-
const cmd=process.env.TOOL_INPUT||'';
|
|
38
|
-
if(!/git commit/.test(cmd))process.exit(0);
|
|
39
|
-
const {execSync}=require('child_process');
|
|
40
|
-
try{
|
|
41
|
-
const diff=execSync('git diff --cached --name-only',{encoding:'utf-8'});
|
|
42
|
-
const files=diff.trim().split('\\n');
|
|
43
|
-
const hasCode=files.some(f=>/\\.(js|ts|jsx|tsx|py|go|rs)$/.test(f));
|
|
44
|
-
const hasTest=files.some(f=>/\\.(test|spec)\\.|__tests__/.test(f));
|
|
45
|
-
if(hasCode&&!hasTest){
|
|
46
|
-
console.error('CTX: Commit blocked — code changes without tests.');
|
|
47
|
-
console.error('Files: '+files.filter(f=>/\\.(js|ts|jsx|tsx|py|go|rs)$/.test(f)).join(', '));
|
|
48
|
-
process.exit(2);
|
|
49
|
-
}
|
|
50
|
-
}catch{}
|
|
51
|
-
"`,
|
|
52
|
-
description: 'Block commits without corresponding test changes',
|
|
53
|
-
configKey: 'hooks.blockCommitWithoutTests',
|
|
54
|
-
default: false,
|
|
55
|
-
},
|
|
56
|
-
|
|
57
|
-
// Enforce TDD mode (strict/warn/off)
|
|
58
|
-
tddEnforcement: {
|
|
59
|
-
event: 'PreToolUse',
|
|
60
|
-
matcher: 'Bash',
|
|
61
|
-
command: `node -e "
|
|
62
|
-
const cmd=process.env.TOOL_INPUT||'';
|
|
63
|
-
if(!/git commit/.test(cmd))process.exit(0);
|
|
64
|
-
const fs=require('fs'),p=require('path');
|
|
65
|
-
const cfgPath=p.join(process.cwd(),'.ctx','config.json');
|
|
66
|
-
let mode='off';
|
|
67
|
-
try{const c=JSON.parse(fs.readFileSync(cfgPath,'utf-8'));mode=c.hooks?.tddMode||'off';}catch{}
|
|
68
|
-
if(mode==='off')process.exit(0);
|
|
69
|
-
const {execSync}=require('child_process');
|
|
70
|
-
try{
|
|
71
|
-
const diff=execSync('git diff --cached --name-only',{encoding:'utf-8'});
|
|
72
|
-
const files=diff.trim().split('\\n');
|
|
73
|
-
const hasCode=files.some(f=>/\\.(js|ts|jsx|tsx|py|go|rs)$/.test(f)&&!/\\.(test|spec)\\.|__tests__/.test(f));
|
|
74
|
-
const hasTest=files.some(f=>/\\.(test|spec)\\.|__tests__/.test(f));
|
|
75
|
-
if(hasCode&&!hasTest){
|
|
76
|
-
if(mode==='strict'){
|
|
77
|
-
console.error('CTX TDD: Commit blocked — write tests first.');
|
|
78
|
-
process.exit(2);
|
|
79
|
-
}else if(mode==='warn'){
|
|
80
|
-
console.error('CTX TDD Warning: Code changes without tests.');
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}catch{}
|
|
84
|
-
"`,
|
|
85
|
-
description: 'TDD enforcement (strict/warn/off)',
|
|
86
|
-
configKey: 'hooks.tddMode',
|
|
87
|
-
default: 'off',
|
|
88
|
-
},
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Generate Claude Code hooks section for settings.json.
|
|
93
|
-
* Reads current config to determine which hooks are enabled.
|
|
94
|
-
*
|
|
95
|
-
* Returns an array of hook objects ready for settings.json.
|
|
96
|
-
*/
|
|
97
|
-
export function generateHooks(config = {}) {
|
|
98
|
-
const hooks = [];
|
|
99
|
-
const hooksConfig = config.hooks || {};
|
|
100
|
-
|
|
101
|
-
for (const [key, def] of Object.entries(HOOK_DEFINITIONS)) {
|
|
102
|
-
const configValue = getNestedValue(hooksConfig, key);
|
|
103
|
-
const enabled = configValue !== undefined ? configValue : def.default;
|
|
104
|
-
|
|
105
|
-
// Skip disabled hooks
|
|
106
|
-
if (enabled === false || enabled === 'off') continue;
|
|
107
|
-
|
|
108
|
-
const hook = {
|
|
109
|
-
type: def.event,
|
|
110
|
-
command: `${CTX_HOOK_MARKER} ${def.command.replace(/\n\s*/g, ' ').trim()}`,
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
if (def.matcher) {
|
|
114
|
-
hook.matcher = def.matcher;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
hooks.push(hook);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return hooks;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Write hooks to .claude/settings.json, preserving user's existing hooks.
|
|
125
|
-
*/
|
|
126
|
-
export function syncHooks(settingsDir, config = {}) {
|
|
127
|
-
const settingsPath = path.join(settingsDir, 'settings.json');
|
|
128
|
-
let settings = {};
|
|
129
|
-
|
|
130
|
-
// Read existing settings
|
|
131
|
-
try {
|
|
132
|
-
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
133
|
-
} catch {}
|
|
134
|
-
|
|
135
|
-
// Preserve user hooks (non-CTX)
|
|
136
|
-
const existingHooks = settings.hooks || {};
|
|
137
|
-
const userHooks = {};
|
|
138
|
-
|
|
139
|
-
for (const [event, hookList] of Object.entries(existingHooks)) {
|
|
140
|
-
if (Array.isArray(hookList)) {
|
|
141
|
-
userHooks[event] = hookList.filter(h => !h.command?.includes(CTX_HOOK_MARKER));
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Generate CTX hooks
|
|
146
|
-
const ctxHooks = generateHooks(config);
|
|
147
|
-
|
|
148
|
-
// Merge: group by event type
|
|
149
|
-
const merged = { ...userHooks };
|
|
150
|
-
for (const hook of ctxHooks) {
|
|
151
|
-
const event = hook.type;
|
|
152
|
-
if (!merged[event]) merged[event] = [];
|
|
153
|
-
merged[event].push({
|
|
154
|
-
matcher: hook.matcher || undefined,
|
|
155
|
-
command: hook.command,
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Clean up empty arrays
|
|
160
|
-
for (const [key, val] of Object.entries(merged)) {
|
|
161
|
-
if (Array.isArray(val) && val.length === 0) delete merged[key];
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
settings.hooks = merged;
|
|
165
|
-
|
|
166
|
-
// Write back
|
|
167
|
-
if (!fs.existsSync(settingsDir)) fs.mkdirSync(settingsDir, { recursive: true });
|
|
168
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
169
|
-
|
|
170
|
-
return { hooksGenerated: ctxHooks.length, settingsPath };
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* List all available hooks with their current status.
|
|
175
|
-
*/
|
|
176
|
-
export function listHooks(config = {}) {
|
|
177
|
-
const hooksConfig = config.hooks || {};
|
|
178
|
-
const result = [];
|
|
179
|
-
|
|
180
|
-
for (const [key, def] of Object.entries(HOOK_DEFINITIONS)) {
|
|
181
|
-
const configValue = getNestedValue(hooksConfig, key);
|
|
182
|
-
const status = configValue !== undefined ? configValue : def.default;
|
|
183
|
-
|
|
184
|
-
result.push({
|
|
185
|
-
key,
|
|
186
|
-
event: def.event,
|
|
187
|
-
description: def.description,
|
|
188
|
-
configKey: def.configKey,
|
|
189
|
-
status: status === false ? 'disabled' : status === true ? 'enabled' : String(status),
|
|
190
|
-
default: def.default,
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return result;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Format hooks list for display.
|
|
199
|
-
*/
|
|
200
|
-
export function formatHooksList(hooks) {
|
|
201
|
-
const lines = [];
|
|
202
|
-
const maxKey = Math.max(20, ...hooks.map(h => h.configKey.length));
|
|
203
|
-
|
|
204
|
-
for (const h of hooks) {
|
|
205
|
-
const icon = h.status === 'disabled' || h.status === 'off' ? '○' : '●';
|
|
206
|
-
lines.push(` ${icon} ${h.configKey.padEnd(maxKey)} ${h.status.padEnd(10)} ${h.description}`);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
return lines.join('\n');
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// --- internal ---
|
|
213
|
-
|
|
214
|
-
function getNestedValue(obj, key) {
|
|
215
|
-
return key.split('.').reduce((o, k) => o?.[k], obj);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export { HOOK_DEFINITIONS, CTX_HOOK_MARKER };
|
package/src/lifecycle.js
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
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
|
-
}
|