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.
Files changed (74) hide show
  1. package/README.md +375 -676
  2. package/agents/ctx-arch-mapper.md +5 -3
  3. package/agents/ctx-auditor.md +5 -3
  4. package/agents/ctx-codex-reviewer.md +214 -0
  5. package/agents/ctx-concerns-mapper.md +5 -3
  6. package/agents/ctx-criteria-suggester.md +6 -4
  7. package/agents/ctx-debugger.md +5 -3
  8. package/agents/ctx-designer.md +488 -114
  9. package/agents/ctx-discusser.md +5 -3
  10. package/agents/ctx-executor.md +5 -3
  11. package/agents/ctx-handoff.md +6 -4
  12. package/agents/ctx-learner.md +5 -3
  13. package/agents/ctx-mapper.md +4 -3
  14. package/agents/ctx-ml-analyst.md +600 -0
  15. package/agents/ctx-ml-engineer.md +933 -0
  16. package/agents/ctx-ml-reviewer.md +485 -0
  17. package/agents/ctx-ml-scientist.md +626 -0
  18. package/agents/ctx-parallelizer.md +4 -3
  19. package/agents/ctx-planner.md +5 -3
  20. package/agents/ctx-predictor.md +4 -3
  21. package/agents/ctx-qa.md +5 -3
  22. package/agents/ctx-quality-mapper.md +5 -3
  23. package/agents/ctx-researcher.md +5 -3
  24. package/agents/ctx-reviewer.md +6 -4
  25. package/agents/ctx-team-coordinator.md +5 -3
  26. package/agents/ctx-tech-mapper.md +5 -3
  27. package/agents/ctx-verifier.md +5 -3
  28. package/bin/ctx.js +199 -27
  29. package/commands/brand.md +309 -0
  30. package/commands/ctx.md +10 -10
  31. package/commands/design.md +304 -0
  32. package/commands/experiment.md +251 -0
  33. package/commands/help.md +57 -7
  34. package/commands/init.md +25 -0
  35. package/commands/metrics.md +1 -1
  36. package/commands/milestone.md +1 -1
  37. package/commands/ml-status.md +197 -0
  38. package/commands/monitor.md +1 -1
  39. package/commands/train.md +266 -0
  40. package/commands/visual-qa.md +559 -0
  41. package/commands/voice.md +1 -1
  42. package/hooks/post-tool-use.js +39 -0
  43. package/hooks/pre-tool-use.js +94 -0
  44. package/hooks/subagent-stop.js +32 -0
  45. package/package.json +9 -3
  46. package/plugin.json +46 -0
  47. package/skills/ctx-design-system/SKILL.md +572 -0
  48. package/skills/ctx-ml-experiment/SKILL.md +334 -0
  49. package/skills/ctx-ml-pipeline/SKILL.md +437 -0
  50. package/skills/ctx-orchestrator/SKILL.md +91 -0
  51. package/skills/ctx-review-gate/SKILL.md +147 -0
  52. package/skills/ctx-state/SKILL.md +100 -0
  53. package/skills/ctx-visual-qa/SKILL.md +587 -0
  54. package/src/agents.js +109 -0
  55. package/src/auto.js +287 -0
  56. package/src/capabilities.js +226 -0
  57. package/src/commits.js +94 -0
  58. package/src/config.js +112 -0
  59. package/src/context.js +241 -0
  60. package/src/handoff.js +156 -0
  61. package/src/hooks.js +218 -0
  62. package/src/install.js +125 -50
  63. package/src/lifecycle.js +194 -0
  64. package/src/metrics.js +198 -0
  65. package/src/pipeline.js +269 -0
  66. package/src/review-gate.js +338 -0
  67. package/src/runner.js +120 -0
  68. package/src/skills.js +143 -0
  69. package/src/state.js +267 -0
  70. package/src/worktree.js +244 -0
  71. package/templates/PRD.json +1 -1
  72. package/templates/config.json +4 -237
  73. package/workflows/ctx-router.md +0 -485
  74. package/workflows/map-codebase.md +0 -329
package/src/agents.js ADDED
@@ -0,0 +1,109 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Parse YAML-like frontmatter from a markdown file.
6
+ * Returns { attrs: {}, body: string } or null if no frontmatter.
7
+ */
8
+ export function parseFrontmatter(content) {
9
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
10
+ if (!match) return null;
11
+
12
+ const attrs = {};
13
+ for (const line of match[1].split('\n')) {
14
+ const sep = line.indexOf(':');
15
+ if (sep === -1) continue;
16
+ const key = line.slice(0, sep).trim();
17
+ const val = line.slice(sep + 1).trim();
18
+ attrs[key] = val;
19
+ }
20
+
21
+ return { attrs, body: match[2] };
22
+ }
23
+
24
+ /**
25
+ * Derive a CLI command name from an agent filename.
26
+ * ctx-planner.md → plan
27
+ * ctx-arch-mapper.md → arch-map
28
+ * ctx-quality-mapper.md → quality-map
29
+ */
30
+ export function deriveCommandName(filename) {
31
+ const base = filename.replace(/\.md$/, '').replace(/^ctx-/, '');
32
+
33
+ // Special mapping: *-mapper → *-map (shorter CLI names)
34
+ if (base.endsWith('-mapper')) return base.replace(/-mapper$/, '-map');
35
+ // Special mapping: *-suggester → base (e.g. criteria-suggester → criteria)
36
+ if (base.endsWith('-suggester')) return base.replace(/-suggester$/, '');
37
+ // Special mapping: *-coordinator → coordinate
38
+ if (base.endsWith('-coordinator')) return base.replace(/-coordinator$/, '');
39
+
40
+ // Common verb forms for shorter commands
41
+ const verbMap = {
42
+ 'planner': 'plan',
43
+ 'executor': 'execute',
44
+ 'researcher': 'research',
45
+ 'debugger': 'debug',
46
+ 'reviewer': 'review',
47
+ 'designer': 'design',
48
+ 'verifier': 'verify',
49
+ 'discusser': 'discuss',
50
+ 'learner': 'learn',
51
+ 'predictor': 'predict',
52
+ 'auditor': 'audit',
53
+ 'parallelizer': 'parallelize',
54
+ 'mapper': 'map',
55
+ 'handoff': 'handoff',
56
+ 'team': 'team',
57
+ 'qa': 'qa',
58
+ };
59
+
60
+ return verbMap[base] || base;
61
+ }
62
+
63
+ /**
64
+ * Discover all agents from a directory.
65
+ * Returns an array of { file, command, name, description, tools, color }.
66
+ */
67
+ export function discoverAgents(agentsDir) {
68
+ if (!fs.existsSync(agentsDir)) return [];
69
+
70
+ const agents = [];
71
+ const entries = fs.readdirSync(agentsDir).filter(f => f.startsWith('ctx-') && f.endsWith('.md')).sort();
72
+
73
+ for (const file of entries) {
74
+ try {
75
+ const content = fs.readFileSync(path.join(agentsDir, file), 'utf-8');
76
+ const fm = parseFrontmatter(content);
77
+ if (!fm || !fm.attrs.name) {
78
+ process.stderr.write(`Warning: skipping ${file} (missing or invalid frontmatter)\n`);
79
+ continue;
80
+ }
81
+
82
+ agents.push({
83
+ file,
84
+ command: deriveCommandName(file),
85
+ name: fm.attrs.name,
86
+ description: fm.attrs.description || '',
87
+ tools: fm.attrs.tools || '',
88
+ color: fm.attrs.color || 'white',
89
+ });
90
+ } catch (err) {
91
+ process.stderr.write(`Warning: skipping ${file} (${err.message})\n`);
92
+ }
93
+ }
94
+
95
+ return agents;
96
+ }
97
+
98
+ /**
99
+ * Format agents as a table for display.
100
+ */
101
+ export function formatAgentTable(agents) {
102
+ const maxCmd = Math.max(12, ...agents.map(a => a.command.length));
103
+ const header = ` ${'Command'.padEnd(maxCmd)} Description`;
104
+ const sep = ` ${'─'.repeat(maxCmd)} ${'─'.repeat(50)}`;
105
+
106
+ const rows = agents.map(a => ` ${a.command.padEnd(maxCmd)} ${a.description}`);
107
+
108
+ return [header, sep, ...rows].join('\n');
109
+ }
package/src/auto.js ADDED
@@ -0,0 +1,287 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { readState, writeState, initState, transitionPhase } from './state.js';
4
+ import { executePipeline } from './pipeline.js';
5
+ import { runReviewGate, isReviewGateEnabled } from './review-gate.js';
6
+ import { selectStory, listPendingStories } from './lifecycle.js';
7
+ import { commitTask } from './commits.js';
8
+
9
+ const STOP_FILE = 'STOP';
10
+ const AUTO_LOG = 'AUTO-LOG.md';
11
+
12
+ const DEFAULTS = {
13
+ maxIterationsPerStory: 5,
14
+ maxTotalTimeMs: 2 * 60 * 60 * 1000, // 2 hours
15
+ pipeline: ['plan', 'execute'],
16
+ };
17
+
18
+ /**
19
+ * Run autonomous execution loop across all P1 stories (or a single story).
20
+ *
21
+ * Options:
22
+ * ctxDir, projectDir, agentsDir, config
23
+ * storyId — single story to process (null = all P1)
24
+ * retryFailed — only retry previously failed stories
25
+ * streaming — stream agent output
26
+ * timeout — per-agent timeout in ms
27
+ * onEvent — callback({ type, story, message, ... })
28
+ */
29
+ export async function runAutoLoop({ ctxDir, projectDir, agentsDir, config = {}, storyId = null, retryFailed = false, streaming = true, timeout = 300000, onEvent = null }) {
30
+ const maxIterations = config.maxIterationsPerStory || DEFAULTS.maxIterationsPerStory;
31
+ const maxTime = config.maxTotalTimeMs || DEFAULTS.maxTotalTimeMs;
32
+ const startTime = Date.now();
33
+
34
+ // Initialize auto log
35
+ const logPath = path.join(ctxDir, AUTO_LOG);
36
+ appendLog(logPath, `# CTX Auto Loop — ${new Date().toISOString()}\n`);
37
+
38
+ // Determine stories to process
39
+ const stories = resolveStories(ctxDir, storyId, retryFailed);
40
+ if (stories.length === 0) {
41
+ emit(onEvent, { type: 'no_stories', message: 'No stories to process.' });
42
+ return { completed: [], failed: [], skipped: [], totalTime: 0 };
43
+ }
44
+
45
+ emit(onEvent, { type: 'start', storyCount: stories.length, maxIterations, maxTime });
46
+ appendLog(logPath, `\nProcessing ${stories.length} stories. Max ${maxIterations} iterations each.\n`);
47
+
48
+ const completed = [];
49
+ const failed = [];
50
+ const skipped = [];
51
+
52
+ for (const story of stories) {
53
+ // Check stop file
54
+ if (shouldStop(ctxDir)) {
55
+ emit(onEvent, { type: 'stopped', message: 'STOP file detected. Halting after current story.' });
56
+ appendLog(logPath, `\n⏹ Stopped by STOP file at ${new Date().toISOString()}\n`);
57
+ skipped.push(...stories.slice(stories.indexOf(story)));
58
+ break;
59
+ }
60
+
61
+ // Check time limit
62
+ if (Date.now() - startTime > maxTime) {
63
+ emit(onEvent, { type: 'timeout', message: `Time limit (${maxTime / 1000 / 60}min) exceeded.` });
64
+ appendLog(logPath, `\n⏱ Time limit exceeded at ${new Date().toISOString()}\n`);
65
+ skipped.push(...stories.slice(stories.indexOf(story)));
66
+ break;
67
+ }
68
+
69
+ emit(onEvent, { type: 'story_start', story: story.id, title: story.title });
70
+ appendLog(logPath, `\n## ${story.id} — ${story.title}\nStarted: ${new Date().toISOString()}\n`);
71
+
72
+ const result = await processStory({
73
+ story, ctxDir, projectDir, agentsDir, config,
74
+ maxIterations, streaming, timeout, onEvent, logPath,
75
+ });
76
+
77
+ if (result.success) {
78
+ completed.push(story.id);
79
+ appendLog(logPath, `Result: ✓ COMPLETED (${result.iterations} iterations)\n`);
80
+ } else {
81
+ failed.push({ id: story.id, reason: result.reason });
82
+ appendLog(logPath, `Result: ✗ FAILED — ${result.reason}\n`);
83
+ }
84
+ }
85
+
86
+ // Write summary
87
+ const totalTime = Date.now() - startTime;
88
+ const summary = buildSummary(completed, failed, skipped, totalTime);
89
+ appendLog(logPath, `\n---\n${summary}`);
90
+
91
+ emit(onEvent, { type: 'complete', completed, failed, skipped, totalTime });
92
+
93
+ // Clean up stop file if it exists
94
+ cleanupStopFile(ctxDir);
95
+
96
+ return { completed, failed, skipped, totalTime };
97
+ }
98
+
99
+ /**
100
+ * Create a STOP file to gracefully halt the auto loop.
101
+ */
102
+ export function createStopFile(ctxDir) {
103
+ const stopPath = path.join(ctxDir, STOP_FILE);
104
+ fs.writeFileSync(stopPath, `Stop requested at ${new Date().toISOString()}\n`);
105
+ }
106
+
107
+ /**
108
+ * Format auto loop results for display.
109
+ */
110
+ export function formatAutoResult({ completed, failed, skipped, totalTime }) {
111
+ const lines = [];
112
+ const mins = Math.round(totalTime / 1000 / 60);
113
+
114
+ lines.push(` Total time: ${mins} minutes`);
115
+ lines.push('');
116
+
117
+ if (completed.length > 0) {
118
+ lines.push(` ✓ Completed (${completed.length}):`);
119
+ for (const id of completed) lines.push(` ${id}`);
120
+ }
121
+
122
+ if (failed.length > 0) {
123
+ lines.push(` ✗ Failed (${failed.length}):`);
124
+ for (const f of failed) lines.push(` ${f.id} — ${f.reason}`);
125
+ }
126
+
127
+ if (skipped.length > 0) {
128
+ lines.push(` ○ Skipped (${skipped.length}):`);
129
+ for (const s of skipped) lines.push(` ${s.id || s}`);
130
+ }
131
+
132
+ if (failed.length > 0) {
133
+ lines.push('');
134
+ lines.push(' Retry failed: ctx-cc auto --retry-failed');
135
+ }
136
+
137
+ return lines.join('\n');
138
+ }
139
+
140
+ // --- internal ---
141
+
142
+ async function processStory({ story, ctxDir, projectDir, agentsDir, config, maxIterations, streaming, timeout, onEvent, logPath }) {
143
+ // Select story
144
+ selectStory(ctxDir, story.id);
145
+
146
+ for (let iteration = 1; iteration <= maxIterations; iteration++) {
147
+ emit(onEvent, { type: 'iteration', story: story.id, iteration, max: maxIterations });
148
+ appendLog(logPath, ` Iteration ${iteration}/${maxIterations}: `);
149
+
150
+ // Run pipeline: plan → execute
151
+ try {
152
+ transitionPhase(ctxDir, 'init'); // Reset to init for fresh pipeline
153
+ const pipeResult = await executePipeline({
154
+ steps: ['plan', 'execute'],
155
+ message: `Implement story ${story.id}: ${story.title}\n\n${story.description || ''}\n\nAcceptance criteria:\n${(story.acceptanceCriteria || []).map((c, i) => `${i + 1}. ${c}`).join('\n')}`,
156
+ ctxDir, projectDir, agentsDir,
157
+ streaming, timeout,
158
+ });
159
+
160
+ if (pipeResult.failed) {
161
+ appendLog(logPath, `pipeline failed at ${pipeResult.failed}\n`);
162
+ if (iteration === maxIterations) {
163
+ return { success: false, iterations: iteration, reason: `Pipeline failed: ${pipeResult.error}` };
164
+ }
165
+ continue; // Retry
166
+ }
167
+ } catch (err) {
168
+ appendLog(logPath, `pipeline error: ${err.message}\n`);
169
+ if (iteration === maxIterations) {
170
+ return { success: false, iterations: iteration, reason: err.message };
171
+ }
172
+ continue;
173
+ }
174
+
175
+ // Run review gate (if enabled)
176
+ if (isReviewGateEnabled(config)) {
177
+ try {
178
+ const reviewResult = await runReviewGate({
179
+ ctxDir, projectDir, agentsDir, streaming, timeout, config,
180
+ });
181
+
182
+ if (reviewResult.escalated) {
183
+ return { success: false, iterations: iteration, reason: 'Review loop exceeded — human review required.' };
184
+ }
185
+
186
+ if (!reviewResult.passed) {
187
+ appendLog(logPath, `review failed (cycle ${reviewResult.cycle})\n`);
188
+ if (iteration === maxIterations) {
189
+ return { success: false, iterations: iteration, reason: `Review failed: ${reviewResult.feedback}` };
190
+ }
191
+ continue; // Retry with feedback
192
+ }
193
+ } catch (err) {
194
+ appendLog(logPath, `review error: ${err.message}\n`);
195
+ // Review errors don't block — continue
196
+ }
197
+ }
198
+
199
+ // If we get here, story passed
200
+ appendLog(logPath, `passed\n`);
201
+
202
+ // Commit
203
+ commitTask({
204
+ projectDir, ctxDir,
205
+ agentName: 'auto',
206
+ taskId: story.id,
207
+ taskTitle: story.title,
208
+ criteriaIds: story.acceptanceCriteria || [],
209
+ });
210
+
211
+ // Mark story as passed in PRD
212
+ markStoryPassed(ctxDir, story.id);
213
+
214
+ return { success: true, iterations: iteration, reason: null };
215
+ }
216
+
217
+ return { success: false, iterations: maxIterations, reason: `Max iterations (${maxIterations}) exceeded.` };
218
+ }
219
+
220
+ function resolveStories(ctxDir, storyId, retryFailed) {
221
+ if (storyId) {
222
+ // Single story mode
223
+ try {
224
+ const prd = JSON.parse(fs.readFileSync(path.join(ctxDir, 'PRD.json'), 'utf-8'));
225
+ const story = (prd.stories || []).find(s => s.id === storyId);
226
+ return story ? [story] : [];
227
+ } catch {
228
+ return [];
229
+ }
230
+ }
231
+
232
+ const pending = listPendingStories(ctxDir);
233
+
234
+ if (retryFailed) {
235
+ // Read auto log for failed stories
236
+ const state = readState(ctxDir);
237
+ const failedIds = new Set((state?.autoFailedStories || []).map(f => f.id || f));
238
+ return pending.filter(s => failedIds.has(s.id));
239
+ }
240
+
241
+ // All P1 stories first, then P2, etc.
242
+ return pending.sort((a, b) => (a.priority || 99) - (b.priority || 99));
243
+ }
244
+
245
+ function markStoryPassed(ctxDir, storyId) {
246
+ try {
247
+ const prdPath = path.join(ctxDir, 'PRD.json');
248
+ const prd = JSON.parse(fs.readFileSync(prdPath, 'utf-8'));
249
+ const story = (prd.stories || []).find(s => s.id === storyId);
250
+ if (story) {
251
+ story.passes = true;
252
+ story.verifiedAt = new Date().toISOString();
253
+ prd.metadata = prd.metadata || {};
254
+ prd.metadata.passedStories = (prd.metadata.passedStories || 0) + 1;
255
+ fs.writeFileSync(prdPath, JSON.stringify(prd, null, 2) + '\n');
256
+ }
257
+ } catch {}
258
+ }
259
+
260
+ function shouldStop(ctxDir) {
261
+ return fs.existsSync(path.join(ctxDir, STOP_FILE));
262
+ }
263
+
264
+ function cleanupStopFile(ctxDir) {
265
+ const stopPath = path.join(ctxDir, STOP_FILE);
266
+ try { fs.unlinkSync(stopPath); } catch {}
267
+ }
268
+
269
+ function appendLog(logPath, text) {
270
+ fs.appendFileSync(logPath, text);
271
+ }
272
+
273
+ function buildSummary(completed, failed, skipped, totalTime) {
274
+ const mins = Math.round(totalTime / 1000 / 60);
275
+ return [
276
+ `## Summary`,
277
+ `- Completed: ${completed.length}`,
278
+ `- Failed: ${failed.length}`,
279
+ `- Skipped: ${skipped.length}`,
280
+ `- Total time: ${mins} minutes`,
281
+ `- Finished: ${new Date().toISOString()}`,
282
+ ].join('\n') + '\n';
283
+ }
284
+
285
+ function emit(fn, event) {
286
+ if (fn) fn(event);
287
+ }
@@ -0,0 +1,226 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Schema version for the on-disk capability manifest.
6
+ * Bump when adding categories, renaming fields, or changing policy semantics
7
+ * so stale project manifests can be detected and regenerated.
8
+ */
9
+ export const MANIFEST_VERSION = 1;
10
+
11
+ /**
12
+ * Default capability manifests per agent category.
13
+ * Defines which tools each ctx-* agent category is allowed to use.
14
+ *
15
+ * The runtime enforcement point is `hooks/pre-tool-use.js`, which reads
16
+ * `.ctx/capability-manifest.json` (written at project init from this table)
17
+ * and blocks tool calls whose name appears in the agent's `denied` list.
18
+ *
19
+ * `allowed` is the declared whitelist and is used for documentation and tests;
20
+ * the hook itself is denylist-driven so unknown tools default to permissive.
21
+ *
22
+ * Iterators over a loaded manifest MUST skip keys starting with `_`
23
+ * (reserved for metadata like `_version`).
24
+ */
25
+ const DEFAULT_CAPABILITIES = {
26
+ // Planning agents — read-only + write plans
27
+ planning: {
28
+ agents: ['ctx-planner.md', 'ctx-predictor.md', 'ctx-criteria-suggester.md', 'ctx-parallelizer.md'],
29
+ allowed: ['Read', 'Glob', 'Grep', 'Write', 'Agent', 'AskUserQuestion'],
30
+ denied: ['Edit', 'Bash', 'NotebookEdit'],
31
+ reason: 'Planning agents should not modify code directly.',
32
+ },
33
+
34
+ // Execution agents — full code access, no orchestration
35
+ execution: {
36
+ agents: ['ctx-executor.md', 'ctx-debugger.md'],
37
+ allowed: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'],
38
+ denied: ['Agent'],
39
+ reason: 'Execution agents should not spawn other agents.',
40
+ },
41
+
42
+ // Review agents — read + run tests + Codex cross-review, no modifications
43
+ review: {
44
+ agents: ['ctx-reviewer.md', 'ctx-verifier.md', 'ctx-codex-reviewer.md', 'ctx-ml-reviewer.md'],
45
+ allowed: ['Read', 'Glob', 'Grep', 'Bash', 'mcp__codex__codex'],
46
+ denied: ['Write', 'Edit', 'NotebookEdit'],
47
+ reason: 'Review agents should not modify code.',
48
+ },
49
+
50
+ // Audit agents — write audit trails, but never modify source
51
+ audit: {
52
+ agents: ['ctx-auditor.md'],
53
+ allowed: ['Read', 'Write', 'Bash', 'Glob', 'Grep'],
54
+ denied: ['Edit', 'Agent', 'NotebookEdit'],
55
+ reason: 'Audit agents record trails but should not modify source or spawn agents.',
56
+ },
57
+
58
+ // Mapper agents — read-only analysis
59
+ mapping: {
60
+ agents: ['ctx-mapper.md', 'ctx-arch-mapper.md', 'ctx-tech-mapper.md', 'ctx-quality-mapper.md', 'ctx-concerns-mapper.md'],
61
+ allowed: ['Read', 'Glob', 'Grep', 'Bash', 'Write'],
62
+ denied: ['Edit'],
63
+ reason: 'Mapper agents analyze but should not modify existing code.',
64
+ },
65
+
66
+ // Knowledge agents — read + web research
67
+ knowledge: {
68
+ agents: ['ctx-researcher.md', 'ctx-learner.md'],
69
+ allowed: ['Read', 'Glob', 'Grep', 'Bash', 'Write', 'WebSearch', 'WebFetch'],
70
+ denied: ['Edit'],
71
+ reason: 'Knowledge agents gather info but should not modify code.',
72
+ },
73
+
74
+ // Coordination agents — state management
75
+ coordination: {
76
+ agents: ['ctx-team-coordinator.md', 'ctx-handoff.md', 'ctx-discusser.md'],
77
+ allowed: ['Read', 'Write', 'Glob', 'Grep', 'AskUserQuestion'],
78
+ denied: ['Edit', 'Bash'],
79
+ reason: 'Coordination agents manage state, not code.',
80
+ },
81
+
82
+ // Design agents — read + generate
83
+ design: {
84
+ agents: ['ctx-designer.md'],
85
+ allowed: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash'],
86
+ denied: [],
87
+ reason: 'Design agents may need full access for asset generation.',
88
+ },
89
+
90
+ // QA agents — full read + browser
91
+ qa: {
92
+ agents: ['ctx-qa.md'],
93
+ allowed: ['Read', 'Write', 'Glob', 'Grep', 'Bash'],
94
+ denied: ['Edit'],
95
+ reason: 'QA agents test but should not fix code.',
96
+ },
97
+
98
+ // ML agents — implement and analyze ML pipelines
99
+ ml: {
100
+ agents: ['ctx-ml-scientist.md', 'ctx-ml-engineer.md', 'ctx-ml-analyst.md'],
101
+ allowed: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'],
102
+ denied: ['Agent', 'NotebookEdit'],
103
+ reason: 'ML agents implement and analyze pipelines but should not orchestrate.',
104
+ },
105
+ };
106
+
107
+ /**
108
+ * Find the category for a given agent file.
109
+ * Skips metadata keys (prefix `_`) so a versioned on-disk manifest still works.
110
+ */
111
+ export function findAgentCategory(agentFile, manifest = DEFAULT_CAPABILITIES) {
112
+ for (const [category, config] of Object.entries(manifest)) {
113
+ if (category.startsWith('_')) continue;
114
+ if (config.agents.includes(agentFile)) {
115
+ return { category, ...config };
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Check if a tool is allowed for an agent.
123
+ * Denylist-driven (matches the runtime hook in hooks/pre-tool-use.js).
124
+ * Unknown agents are permissive by default.
125
+ *
126
+ * Returns { allowed: boolean, reason: string|null }.
127
+ */
128
+ export function checkToolAllowed(agentFile, toolName, manifest = DEFAULT_CAPABILITIES) {
129
+ const category = findAgentCategory(agentFile, manifest);
130
+ if (!category) {
131
+ return { allowed: true, reason: null };
132
+ }
133
+
134
+ if (category.denied.includes(toolName)) {
135
+ return {
136
+ allowed: false,
137
+ reason: `Tool "${toolName}" denied for ${category.category} agents. ${category.reason}`,
138
+ };
139
+ }
140
+
141
+ return { allowed: true, reason: null };
142
+ }
143
+
144
+ /**
145
+ * Save the capability manifest to `<ctxDir>/capability-manifest.json`.
146
+ * Called from the install flow to seed the template and from the project
147
+ * init command to materialize the manifest that the PreToolUse hook reads.
148
+ */
149
+ export function saveCapabilityManifest(ctxDir) {
150
+ const manifestPath = path.join(ctxDir, 'capability-manifest.json');
151
+ if (!fs.existsSync(ctxDir)) fs.mkdirSync(ctxDir, { recursive: true });
152
+ const payload = { _version: MANIFEST_VERSION, ...DEFAULT_CAPABILITIES };
153
+ fs.writeFileSync(manifestPath, JSON.stringify(payload, null, 2) + '\n');
154
+ return manifestPath;
155
+ }
156
+
157
+ /**
158
+ * Read the `_version` field from an on-disk manifest.
159
+ * Returns 0 for pre-versioned manifests, null if file missing/invalid.
160
+ * Callers compare against MANIFEST_VERSION to decide whether to regenerate.
161
+ */
162
+ export function readManifestVersion(ctxDir) {
163
+ const manifestPath = path.join(ctxDir, 'capability-manifest.json');
164
+ try {
165
+ const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
166
+ return typeof data._version === 'number' ? data._version : 0;
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Migrate an existing project's capability manifest to the current version.
174
+ * - Missing: writes a fresh manifest, returns { action: 'created' }.
175
+ * - Current: no-op, returns { action: 'current' }.
176
+ * - Stale: backs up old manifest as `capability-manifest.v<N>.backup.json`
177
+ * and regenerates, returns { action: 'migrated', backup }.
178
+ *
179
+ * Used by the `ctx-cc update-manifest` CLI subcommand so projects that
180
+ * predate MANIFEST_VERSION can pick up policy changes without re-initting.
181
+ */
182
+ export function updateProjectManifest(ctxDir) {
183
+ const manifestPath = path.join(ctxDir, 'capability-manifest.json');
184
+ const current = readManifestVersion(ctxDir);
185
+
186
+ if (current === null) {
187
+ saveCapabilityManifest(ctxDir);
188
+ return { action: 'created', from: null, to: MANIFEST_VERSION, path: manifestPath };
189
+ }
190
+
191
+ if (current === MANIFEST_VERSION) {
192
+ return { action: 'current', from: current, to: current, path: manifestPath };
193
+ }
194
+
195
+ const backupPath = path.join(ctxDir, `capability-manifest.v${current}.backup.json`);
196
+ fs.copyFileSync(manifestPath, backupPath);
197
+ saveCapabilityManifest(ctxDir);
198
+ return {
199
+ action: 'migrated',
200
+ from: current,
201
+ to: MANIFEST_VERSION,
202
+ path: manifestPath,
203
+ backup: backupPath,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Format capabilities for display.
209
+ */
210
+ export function formatCapabilities(manifest = DEFAULT_CAPABILITIES) {
211
+ const lines = [];
212
+ for (const [category, config] of Object.entries(manifest)) {
213
+ if (category.startsWith('_')) continue;
214
+ lines.push(` ${category}:`);
215
+ lines.push(` Agents: ${config.agents.map(a => a.replace('ctx-', '').replace('.md', '')).join(', ')}`);
216
+ lines.push(` Allowed: ${config.allowed.join(', ')}`);
217
+ if (config.denied.length > 0) {
218
+ lines.push(` Denied: ${config.denied.join(', ')}`);
219
+ }
220
+ lines.push(` Reason: ${config.reason}`);
221
+ lines.push('');
222
+ }
223
+ return lines.join('\n');
224
+ }
225
+
226
+ export { DEFAULT_CAPABILITIES };