ctx-cc 4.1.1 → 4.1.3
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/hooks/pre-tool-use.js +4 -2
- package/package.json +3 -3
- package/plugin.json +1 -1
- package/src/install.js +37 -20
- 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/context.js
DELETED
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { execSync } from 'child_process';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Default context profiles per agent category.
|
|
7
|
-
* Each profile lists which context sources the agent receives.
|
|
8
|
-
*
|
|
9
|
-
* Sources:
|
|
10
|
-
* state — .ctx/STATE.json
|
|
11
|
-
* prd — .ctx/PRD.json (active story only)
|
|
12
|
-
* plan — .ctx/phases/<story>/PLAN.md
|
|
13
|
-
* repoMap — .ctx/REPO-MAP.md
|
|
14
|
-
* gitDiff — git diff (staged + unstaged)
|
|
15
|
-
* gitLog — recent commits
|
|
16
|
-
* fileTree — directory listing
|
|
17
|
-
* readme — README.md first 100 lines
|
|
18
|
-
*/
|
|
19
|
-
const DEFAULT_PROFILES = {
|
|
20
|
-
// Planning agents — need requirements and architecture, not code diffs
|
|
21
|
-
plan: ['state', 'prd', 'repoMap', 'readme'],
|
|
22
|
-
predict: ['state', 'prd', 'repoMap', 'fileTree'],
|
|
23
|
-
criteria: ['state', 'prd'],
|
|
24
|
-
discuss: ['state', 'prd', 'repoMap'],
|
|
25
|
-
parallelize: ['state', 'plan'],
|
|
26
|
-
|
|
27
|
-
// Execution agents — need the plan and relevant source files
|
|
28
|
-
execute: ['state', 'plan', 'repoMap'],
|
|
29
|
-
debug: ['state', 'gitDiff', 'gitLog', 'repoMap'],
|
|
30
|
-
|
|
31
|
-
// Review agents — need the diff, not the full tree
|
|
32
|
-
review: ['state', 'gitDiff', 'prd'],
|
|
33
|
-
audit: ['state', 'gitDiff'],
|
|
34
|
-
verify: ['state', 'prd', 'gitDiff'],
|
|
35
|
-
|
|
36
|
-
// Mapper agents — need filesystem overview
|
|
37
|
-
map: ['fileTree', 'readme'],
|
|
38
|
-
'arch-map': ['fileTree', 'readme'],
|
|
39
|
-
'tech-map': ['fileTree', 'readme'],
|
|
40
|
-
'quality-map': ['fileTree'],
|
|
41
|
-
'concerns-map': ['fileTree', 'gitLog'],
|
|
42
|
-
|
|
43
|
-
// Knowledge agents
|
|
44
|
-
research: ['state', 'prd', 'repoMap'],
|
|
45
|
-
learn: ['state', 'repoMap'],
|
|
46
|
-
design: ['state', 'prd', 'readme'],
|
|
47
|
-
|
|
48
|
-
// Coordination agents
|
|
49
|
-
handoff: ['state'],
|
|
50
|
-
team: ['state'],
|
|
51
|
-
qa: ['state', 'prd', 'readme'],
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
// Estimated tokens per source (conservative)
|
|
55
|
-
const SOURCE_TOKEN_ESTIMATES = {
|
|
56
|
-
state: 500,
|
|
57
|
-
prd: 1000,
|
|
58
|
-
plan: 1500,
|
|
59
|
-
repoMap: 2000,
|
|
60
|
-
gitDiff: 3000,
|
|
61
|
-
gitLog: 500,
|
|
62
|
-
fileTree: 1000,
|
|
63
|
-
readme: 800,
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Load context manifest from file, or return defaults.
|
|
68
|
-
*/
|
|
69
|
-
export function loadContextManifest(ctxDir) {
|
|
70
|
-
const manifestPath = path.join(ctxDir, 'context-manifest.json');
|
|
71
|
-
try {
|
|
72
|
-
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
73
|
-
} catch {
|
|
74
|
-
return DEFAULT_PROFILES;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Get the context profile for an agent command.
|
|
80
|
-
* Checks: agent frontmatter 'context-profile' → manifest → defaults.
|
|
81
|
-
*/
|
|
82
|
-
export function getContextProfile(agentCommand, manifest = DEFAULT_PROFILES) {
|
|
83
|
-
return manifest[agentCommand] || ['state', 'repoMap']; // fallback: minimal
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Build context string for an agent based on its profile.
|
|
88
|
-
* Returns { context: string, estimatedTokens: number, warnings: string[] }.
|
|
89
|
-
*/
|
|
90
|
-
export function buildContext(agentCommand, projectDir, ctxDir, options = {}) {
|
|
91
|
-
const manifest = loadContextManifest(ctxDir);
|
|
92
|
-
const sources = getContextProfile(agentCommand, manifest);
|
|
93
|
-
const tokenLimit = options.tokenLimit || 100_000;
|
|
94
|
-
|
|
95
|
-
const sections = [];
|
|
96
|
-
let estimatedTokens = 0;
|
|
97
|
-
const warnings = [];
|
|
98
|
-
|
|
99
|
-
for (const source of sources) {
|
|
100
|
-
const { content, tokens } = loadSource(source, projectDir, ctxDir);
|
|
101
|
-
if (content) {
|
|
102
|
-
sections.push(content);
|
|
103
|
-
estimatedTokens += tokens;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (estimatedTokens > tokenLimit * 0.8) {
|
|
108
|
-
warnings.push(`Context payload ~${estimatedTokens} tokens (${Math.round(estimatedTokens / tokenLimit * 100)}% of limit). Consider reducing context sources.`);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return {
|
|
112
|
-
context: sections.join('\n\n---\n\n'),
|
|
113
|
-
estimatedTokens,
|
|
114
|
-
warnings,
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Estimate token count from a string (chars / 4 approximation).
|
|
120
|
-
*/
|
|
121
|
-
export function estimateTokens(text) {
|
|
122
|
-
return Math.ceil((text || '').length / 4);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// --- Source loaders ---
|
|
126
|
-
|
|
127
|
-
function loadSource(source, projectDir, ctxDir) {
|
|
128
|
-
const loaders = {
|
|
129
|
-
state: () => safeRead(path.join(ctxDir, 'STATE.json'), '## Project State\n```json\n', '\n```'),
|
|
130
|
-
prd: () => loadPrdStory(ctxDir),
|
|
131
|
-
plan: () => loadCurrentPlan(ctxDir),
|
|
132
|
-
repoMap: () => safeRead(path.join(ctxDir, 'REPO-MAP.md'), '## Repository Map\n'),
|
|
133
|
-
gitDiff: () => loadGitDiff(projectDir),
|
|
134
|
-
gitLog: () => loadGitLog(projectDir),
|
|
135
|
-
fileTree: () => loadFileTree(projectDir),
|
|
136
|
-
readme: () => loadReadme(projectDir),
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const loader = loaders[source];
|
|
140
|
-
if (!loader) return { content: null, tokens: 0 };
|
|
141
|
-
|
|
142
|
-
try {
|
|
143
|
-
const content = loader();
|
|
144
|
-
return { content, tokens: estimateTokens(content) };
|
|
145
|
-
} catch {
|
|
146
|
-
return { content: null, tokens: 0 };
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function safeRead(filePath, prefix = '', suffix = '') {
|
|
151
|
-
try {
|
|
152
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
153
|
-
return `${prefix}${content}${suffix}`;
|
|
154
|
-
} catch {
|
|
155
|
-
return null;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function loadPrdStory(ctxDir) {
|
|
160
|
-
try {
|
|
161
|
-
const prd = JSON.parse(fs.readFileSync(path.join(ctxDir, 'PRD.json'), 'utf-8'));
|
|
162
|
-
const current = prd.metadata?.currentStory;
|
|
163
|
-
if (!current) return `## PRD\nProject: ${prd.project?.name || 'unknown'}\nNo active story.`;
|
|
164
|
-
|
|
165
|
-
const story = (prd.stories || []).find(s => s.id === current);
|
|
166
|
-
if (!story) return null;
|
|
167
|
-
|
|
168
|
-
return [
|
|
169
|
-
`## Active Story: ${story.id} — ${story.title}`,
|
|
170
|
-
story.description,
|
|
171
|
-
'',
|
|
172
|
-
'### Acceptance Criteria',
|
|
173
|
-
...(story.acceptanceCriteria || []).map((c, i) => `${i + 1}. ${c}`),
|
|
174
|
-
].join('\n');
|
|
175
|
-
} catch {
|
|
176
|
-
return null;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function loadCurrentPlan(ctxDir) {
|
|
181
|
-
try {
|
|
182
|
-
const state = JSON.parse(fs.readFileSync(path.join(ctxDir, 'STATE.json'), 'utf-8'));
|
|
183
|
-
const storyId = state.activeStory;
|
|
184
|
-
if (!storyId) return null;
|
|
185
|
-
|
|
186
|
-
const planPath = path.join(ctxDir, 'phases', storyId, 'PLAN.md');
|
|
187
|
-
return safeRead(planPath, '## Execution Plan\n');
|
|
188
|
-
} catch {
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function loadGitDiff(projectDir) {
|
|
194
|
-
try {
|
|
195
|
-
const diff = execSync('git diff HEAD --stat --no-color 2>/dev/null && echo "---" && git diff HEAD --no-color 2>/dev/null', {
|
|
196
|
-
cwd: projectDir, encoding: 'utf-8', timeout: 10000, maxBuffer: 1024 * 1024,
|
|
197
|
-
}).trim();
|
|
198
|
-
if (!diff || diff === '---') return null;
|
|
199
|
-
// Truncate if too large
|
|
200
|
-
const maxChars = 12000; // ~3000 tokens
|
|
201
|
-
const truncated = diff.length > maxChars ? diff.slice(0, maxChars) + '\n\n... (diff truncated)' : diff;
|
|
202
|
-
return `## Git Diff\n\`\`\`diff\n${truncated}\n\`\`\``;
|
|
203
|
-
} catch {
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function loadGitLog(projectDir) {
|
|
209
|
-
try {
|
|
210
|
-
const log = execSync('git log --oneline -10 --no-color 2>/dev/null', {
|
|
211
|
-
cwd: projectDir, encoding: 'utf-8', timeout: 5000,
|
|
212
|
-
}).trim();
|
|
213
|
-
return log ? `## Recent Commits\n\`\`\`\n${log}\n\`\`\`` : null;
|
|
214
|
-
} catch {
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function loadFileTree(projectDir) {
|
|
220
|
-
try {
|
|
221
|
-
const tree = execSync(
|
|
222
|
-
'find . -type f -not -path "./.git/*" -not -path "./node_modules/*" -not -path "./.ctx/*" -not -path "./.next/*" -not -path "./dist/*" -not -path "./.cache/*" | head -200 | sort',
|
|
223
|
-
{ cwd: projectDir, encoding: 'utf-8', timeout: 5000, maxBuffer: 512 * 1024 }
|
|
224
|
-
).trim();
|
|
225
|
-
return tree ? `## File Tree\n\`\`\`\n${tree}\n\`\`\`` : null;
|
|
226
|
-
} catch {
|
|
227
|
-
return null;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function loadReadme(projectDir) {
|
|
232
|
-
try {
|
|
233
|
-
const readme = fs.readFileSync(path.join(projectDir, 'README.md'), 'utf-8');
|
|
234
|
-
const lines = readme.split('\n').slice(0, 100);
|
|
235
|
-
return `## README (first 100 lines)\n${lines.join('\n')}`;
|
|
236
|
-
} catch {
|
|
237
|
-
return null;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
export { DEFAULT_PROFILES };
|
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 };
|