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/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
- }