clearctx 3.0.0 → 3.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.
package/src/mcp-server.js CHANGED
@@ -48,6 +48,9 @@ const DiffEngine = require('./diff-engine');
48
48
  const BriefingGenerator = require('./briefing-generator');
49
49
  const DecisionJournal = require('./decision-journal');
50
50
  const PatternRegistry = require('./pattern-registry');
51
+
52
+ // Skills system
53
+ const skillRegistry = require('./skill-registry');
51
54
  const StaleDetector = require('./stale-detector');
52
55
 
53
56
  // Capture version at load time — used to detect stale server processes
@@ -518,6 +521,7 @@ const TOOLS = [
518
521
  permission_mode: { type: 'string', enum: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], description: 'Permission mode. Use bypassPermissions to allow sessions to write files without approval (default: bypassPermissions)' },
519
522
  team: { type: 'string', description: 'Team name (default: "default")' },
520
523
  work_dir: { type: 'string', description: 'Working directory for the session (default: current directory)' },
524
+ skills: { type: 'array', items: { type: 'string' }, description: 'Optional expertise skill IDs to inject into worker (e.g., ["postgresql", "nodejs-backend"]). Use skill_detect to auto-match.' }
521
525
  },
522
526
  required: ['name', 'prompt'],
523
527
  },
@@ -1265,6 +1269,45 @@ const TOOLS = [
1265
1269
  },
1266
1270
  required: ['projectPath', 'patternId']
1267
1271
  }
1272
+ },
1273
+
1274
+ // ══════════════════════════════════════════════════════════════════════════
1275
+ // Skills System — expertise domain skills for workers
1276
+ // ══════════════════════════════════════════════════════════════════════════
1277
+
1278
+ {
1279
+ name: 'skill_list',
1280
+ description: 'List all available expertise skills with descriptions, domains, and keywords.',
1281
+ inputSchema: {
1282
+ type: 'object',
1283
+ properties: {
1284
+ team: { type: 'string', description: 'Team name (default: "default")' }
1285
+ }
1286
+ }
1287
+ },
1288
+ {
1289
+ name: 'skill_get',
1290
+ description: 'Read a specific expertise skill\'s full content including Worker Context, Conventions, Patterns, and Anti-Patterns.',
1291
+ inputSchema: {
1292
+ type: 'object',
1293
+ properties: {
1294
+ skillId: { type: 'string', description: 'Skill ID (e.g., "postgresql", "nodejs-backend", "react-frontend")' },
1295
+ section: { type: 'string', description: 'Optional: extract only a specific section ("worker-context", "conventions", "patterns", "anti-patterns", "integration"). Omit for full content.' }
1296
+ },
1297
+ required: ['skillId']
1298
+ }
1299
+ },
1300
+ {
1301
+ name: 'skill_detect',
1302
+ description: 'Auto-detect which expertise skills are relevant for a given task description. Returns up to 3 matched skills ranked by relevance.',
1303
+ inputSchema: {
1304
+ type: 'object',
1305
+ properties: {
1306
+ task: { type: 'string', description: 'Task description to match skills against' },
1307
+ role: { type: 'string', description: 'Optional worker role for role-based skill matching fallback' }
1308
+ },
1309
+ required: ['task']
1310
+ }
1268
1311
  }
1269
1312
  ];
1270
1313
 
@@ -1539,6 +1582,63 @@ async function executeTool(toolName, args) {
1539
1582
  return textResult(JSON.stringify(result, null, 2));
1540
1583
  }
1541
1584
 
1585
+ // ── Skills System handlers ──
1586
+ case 'skill_list': {
1587
+ const skills = skillRegistry.listSkills();
1588
+ return textResult(JSON.stringify({
1589
+ skills,
1590
+ count: skills.length,
1591
+ message: `${skills.length} expertise skills available. Use skill_get to read full content, or skill_detect to auto-match skills to a task.`
1592
+ }, null, 2));
1593
+ }
1594
+
1595
+ case 'skill_get': {
1596
+ const { skillId, section } = args;
1597
+ const content = skillRegistry.loadSkill(skillId);
1598
+ if (!content) {
1599
+ return errorResult(`Skill "${skillId}" not found. Use skill_list to see available skills.`);
1600
+ }
1601
+ if (section) {
1602
+ // Extract specific section
1603
+ const sectionMap = {
1604
+ 'worker-context': '## Worker Context',
1605
+ 'conventions': '## Conventions',
1606
+ 'patterns': '## Common Patterns',
1607
+ 'anti-patterns': '## Anti-Patterns',
1608
+ 'integration': '## Integration Notes'
1609
+ };
1610
+ const heading = sectionMap[section];
1611
+ if (!heading) {
1612
+ return errorResult(`Unknown section "${section}". Valid: worker-context, conventions, patterns, anti-patterns, integration`);
1613
+ }
1614
+ const regex = new RegExp(`${heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n([\\s\\S]*?)(?=\\n## |$)`);
1615
+ const match = content.match(regex);
1616
+ return textResult(JSON.stringify({
1617
+ skillId,
1618
+ section,
1619
+ content: match ? match[1].trim() : 'Section not found in skill.'
1620
+ }, null, 2));
1621
+ }
1622
+ return textResult(JSON.stringify({ skillId, content }, null, 2));
1623
+ }
1624
+
1625
+ case 'skill_detect': {
1626
+ const { task, role } = args;
1627
+ const detected = skillRegistry.detectSkills(task, role);
1628
+ const skills = detected.map(id => {
1629
+ const all = skillRegistry.listSkills();
1630
+ const skill = all.find(s => s.id === id);
1631
+ return skill || { id, description: 'Unknown skill' };
1632
+ });
1633
+ return textResult(JSON.stringify({
1634
+ detected: skills,
1635
+ count: skills.length,
1636
+ message: skills.length > 0
1637
+ ? `Detected ${skills.length} relevant skill(s): ${detected.join(', ')}. Use team_spawn with skills parameter to inject into workers.`
1638
+ : 'No skills matched. Provide more detail or assign skills manually.'
1639
+ }, null, 2));
1640
+ }
1641
+
1542
1642
  default:
1543
1643
  return errorResult(`Unknown tool: ${toolName}`);
1544
1644
  }
@@ -1935,7 +2035,27 @@ async function handleTeamSpawn(args) {
1935
2035
  const roster = teamHub.getRoster();
1936
2036
 
1937
2037
  // Build team system prompt (now includes role-specific guidance, examples, anti-patterns)
1938
- const teamSystemPrompt = buildTeamSystemPrompt(args.name, args.role, args.task, roster, teamName);
2038
+ let teamSystemPrompt = buildTeamSystemPrompt(args.name, args.role, args.task, roster, teamName);
2039
+
2040
+ // Role-based skill fallback (only if skills param not provided at all)
2041
+ // Explicitly passing skills: [] opts out of auto-injection
2042
+ if (args.skills === undefined && args.role) {
2043
+ const roleSkills = skillRegistry.getSkillsForRole(args.role);
2044
+ if (roleSkills.length > 0) {
2045
+ args.skills = roleSkills;
2046
+ // Log that we auto-detected
2047
+ log({ event: 'skill_auto_detect', worker: args.name, role: args.role, detected: roleSkills });
2048
+ }
2049
+ }
2050
+
2051
+ // Inject expertise skills if provided
2052
+ if (args.skills && Array.isArray(args.skills) && args.skills.length > 0) {
2053
+ const skillsContext = skillRegistry.getWorkerContext(args.skills);
2054
+ if (skillsContext) {
2055
+ teamSystemPrompt += '\n\n## EXPERTISE SKILLS\n\nThe following domain expertise has been assigned to you. Follow these patterns and conventions:\n\n' + skillsContext;
2056
+ log({ event: 'skills_injected', worker: args.name, skills: args.skills });
2057
+ }
2058
+ }
1939
2059
 
1940
2060
  // Spawn the session with team system prompt appended
1941
2061
  // Default to bypassPermissions so team sessions can write files without manual approval
@@ -1969,6 +2089,11 @@ async function handleTeamSpawn(args) {
1969
2089
  result.turns = response.turns;
1970
2090
  }
1971
2091
 
2092
+ // Add skill info if skills were injected
2093
+ if (args.skills && args.skills.length > 0) {
2094
+ result._skills_injected = args.skills;
2095
+ }
2096
+
1972
2097
  // Auto version check on first spawn
1973
2098
  const staleness = getCachedStalenessWarning();
1974
2099
  if (staleness) {
package/src/prompts.js CHANGED
@@ -687,6 +687,10 @@ When all workers are done:
687
687
  | Quick one-off task | \`delegate_task\` | \`team_spawn\` |
688
688
  | Need safety limits (cost/turns) | \`delegate_task\` | \`team_spawn\` |
689
689
  | Verify phase completion | \`phase_gate\` |
690
+ | Auto-detect skills for a task | \`skill_detect\` |
691
+ | List available skills | \`skill_list\` |
692
+ | Read a skill's content | \`skill_get\` |
693
+ | Spawn worker with skills | \`team_spawn\` with \`skills\` parameter |
690
694
  | Clean up between runs | \`team_reset\` |
691
695
 
692
696
  ## WHAT GOES WRONG (And How to Avoid It)
@@ -786,6 +790,33 @@ When done:
786
790
  6. Publish your output as an artifact with artifact_publish
787
791
  7. Broadcast completion with team_broadcast
788
792
  8. Update your status to idle"
793
+
794
+ ### Rule 8: Use expertise skills for domain work
795
+
796
+ When spawning workers, assign relevant expertise skills to improve output quality:
797
+
798
+ 1. Call \`skill_detect\` with the task description to auto-match skills
799
+ 2. Pass matched skills to \`team_spawn\` via the \`skills\` parameter:
800
+ \`\`\`
801
+ team_spawn({
802
+ name: "db-dev",
803
+ role: "database",
804
+ skills: ["postgresql"],
805
+ prompt: "Create the database schema..."
806
+ })
807
+ \`\`\`
808
+ 3. Override auto-detection with explicit skills when you know better
809
+ 4. Workers with skills produce higher-quality, convention-compliant output
810
+ 5. Available skills: postgresql, nodejs-backend, react-frontend, testing-qa, api-design, devops, security, typescript
811
+
812
+ **Role-based fallback:** If you don't specify skills but provide a role, the system auto-detects relevant skills:
813
+ - database → postgresql
814
+ - backend → nodejs-backend, api-design
815
+ - frontend → react-frontend
816
+ - QA/testing → testing-qa
817
+ - devops → devops
818
+ - security → security
819
+ - fullstack → nodejs-backend, react-frontend, typescript
789
820
  `;
790
821
  }
791
822
 
@@ -931,11 +962,25 @@ function getRolePrompt(role) {
931
962
  * @param {string} opts.teamName — Team name
932
963
  * @returns {string} Complete system prompt
933
964
  */
934
- function buildFullTeamPrompt({ name, role, task, roster, teamName }) {
965
+ function buildFullTeamPrompt({ name, role, task, roster, teamName, skills }) {
935
966
  const basePrompt = buildTeamWorkerPrompt(name, role, task, roster, teamName);
936
967
  const rolePrompt = getRolePrompt(role);
937
968
 
938
- return basePrompt + rolePrompt;
969
+ // Inject expertise skills if provided
970
+ let skillsPrompt = '';
971
+ if (skills && skills.length > 0) {
972
+ try {
973
+ const skillRegistry = require('./skill-registry');
974
+ const context = skillRegistry.getWorkerContext(skills);
975
+ if (context) {
976
+ skillsPrompt = '\n\n## DOMAIN EXPERTISE\n\nYou have been assigned the following expertise skills. Follow these patterns, conventions, and anti-patterns strictly:\n\n' + context + '\n';
977
+ }
978
+ } catch (e) {
979
+ // Skills not available — degrade gracefully
980
+ }
981
+ }
982
+
983
+ return basePrompt + skillsPrompt + rolePrompt;
939
984
  }
940
985
 
941
986
 
@@ -0,0 +1,182 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ // Cache for manifest
5
+ let manifestCache = null;
6
+
7
+ // Skills directory path (relative to this module)
8
+ const SKILLS_DIR = path.join(__dirname, '..', 'skills');
9
+
10
+ // Keyword triggers for skill detection
11
+ const SKILL_TRIGGERS = {
12
+ 'postgresql': ['postgres', 'postgresql', 'pg', 'database', 'schema', 'migration', 'sql', 'db'],
13
+ 'nodejs-backend': ['node', 'express', 'backend', 'server', 'api', 'rest', 'endpoint', 'middleware'],
14
+ 'react-frontend': ['react', 'frontend', 'component', 'ui', 'jsx', 'tsx', 'hook', 'state'],
15
+ 'testing-qa': ['test', 'testing', 'qa', 'jest', 'mocha', 'integration', 'unit test', 'e2e'],
16
+ 'api-design': ['api', 'rest', 'endpoint', 'openapi', 'swagger', 'route', 'pagination'],
17
+ 'devops': ['docker', 'ci/cd', 'deploy', 'pipeline', 'kubernetes', 'nginx', 'devops'],
18
+ 'security': ['auth', 'security', 'jwt', 'oauth', 'encryption', 'owasp', 'xss', 'csrf'],
19
+ 'typescript': ['typescript', 'ts', 'type', 'interface', 'generic', 'typed']
20
+ };
21
+
22
+ // Role-based skill mapping (keys should be lowercase for case-insensitive lookup)
23
+ const ROLE_SKILLS = {
24
+ 'database': ['postgresql'],
25
+ 'backend': ['nodejs-backend', 'api-design'],
26
+ 'frontend': ['react-frontend'],
27
+ 'qa': ['testing-qa'],
28
+ 'testing': ['testing-qa'],
29
+ 'fullstack': ['nodejs-backend', 'react-frontend', 'typescript'],
30
+ 'devops': ['devops'],
31
+ 'security': ['security']
32
+ };
33
+
34
+ /**
35
+ * Load the skills manifest (cached)
36
+ * @returns {Object} The manifest object
37
+ */
38
+ function loadManifest() {
39
+ if (manifestCache) {
40
+ return manifestCache;
41
+ }
42
+
43
+ const manifestPath = path.join(SKILLS_DIR, 'index.json');
44
+ const manifestData = fs.readFileSync(manifestPath, 'utf8');
45
+ manifestCache = JSON.parse(manifestData);
46
+ return manifestCache;
47
+ }
48
+
49
+ /**
50
+ * List all available skills
51
+ * @returns {Array} Array of skill objects from manifest
52
+ */
53
+ function listSkills() {
54
+ const manifest = loadManifest();
55
+ return manifest.skills;
56
+ }
57
+
58
+ /**
59
+ * Load a specific skill's full content
60
+ * @param {string} skillId - The skill ID (e.g., 'postgresql')
61
+ * @returns {string|null} The full SKILL.md content, or null if not found
62
+ */
63
+ function loadSkill(skillId) {
64
+ const manifest = loadManifest();
65
+ const skill = manifest.skills.find(s => s.id === skillId);
66
+
67
+ if (!skill) {
68
+ return null;
69
+ }
70
+
71
+ const skillPath = path.join(SKILLS_DIR, skillId, 'SKILL.md');
72
+
73
+ if (!fs.existsSync(skillPath)) {
74
+ return null;
75
+ }
76
+
77
+ return fs.readFileSync(skillPath, 'utf8');
78
+ }
79
+
80
+ /**
81
+ * Detect relevant skills from task description
82
+ * @param {string} taskDescription - The task text to analyze
83
+ * @returns {Array<string>} Up to 3 skill IDs ranked by relevance
84
+ */
85
+ function detectSkills(taskDescription) {
86
+ const lowerTask = taskDescription.toLowerCase();
87
+ const skillScores = {};
88
+
89
+ // Initialize scores
90
+ Object.keys(SKILL_TRIGGERS).forEach(skillId => {
91
+ skillScores[skillId] = 0;
92
+ });
93
+
94
+ // Check keyword matches
95
+ Object.entries(SKILL_TRIGGERS).forEach(([skillId, keywords]) => {
96
+ keywords.forEach(keyword => {
97
+ if (lowerTask.includes(keyword.toLowerCase())) {
98
+ skillScores[skillId]++;
99
+ }
100
+ });
101
+ });
102
+
103
+ // Check role matches
104
+ Object.entries(ROLE_SKILLS).forEach(([role, skillIds]) => {
105
+ if (lowerTask.includes(role.toLowerCase())) {
106
+ skillIds.forEach(skillId => {
107
+ skillScores[skillId] += 2; // Role matches get higher weight
108
+ });
109
+ }
110
+ });
111
+
112
+ // Sort by score and return top 3
113
+ const rankedSkills = Object.entries(skillScores)
114
+ .filter(([_, score]) => score > 0)
115
+ .sort((a, b) => b[1] - a[1])
116
+ .slice(0, 3)
117
+ .map(([skillId, _]) => skillId);
118
+
119
+ return rankedSkills;
120
+ }
121
+
122
+ /**
123
+ * Extract Worker Context sections from multiple skills
124
+ * @param {Array<string>} skillIds - Array of skill IDs to load
125
+ * @returns {string} Concatenated worker context content with headers
126
+ */
127
+ function getWorkerContext(skillIds) {
128
+ if (!Array.isArray(skillIds) || skillIds.length === 0) {
129
+ return '';
130
+ }
131
+
132
+ const manifest = loadManifest();
133
+ const contexts = [];
134
+
135
+ skillIds.forEach(skillId => {
136
+ const skill = manifest.skills.find(s => s.id === skillId);
137
+ if (!skill) {
138
+ return;
139
+ }
140
+
141
+ const skillContent = loadSkill(skillId);
142
+ if (!skillContent) {
143
+ return;
144
+ }
145
+
146
+ // Extract ONLY the "## Worker Context" section (not the full SKILL.md)
147
+ // This keeps injected content small enough for Windows CLI arg limits
148
+ const lines = skillContent.split(/\r?\n/);
149
+ const workerIdx = lines.findIndex(l => l.trim() === '## Worker Context');
150
+
151
+ if (workerIdx !== -1) {
152
+ const nextHeaderIdx = lines.slice(workerIdx + 1).findIndex(l => /^## /.test(l));
153
+ const endIdx = nextHeaderIdx !== -1 ? workerIdx + 1 + nextHeaderIdx : lines.length;
154
+ const contextContent = lines.slice(workerIdx + 1, endIdx).join('\n').trim();
155
+
156
+ if (contextContent) {
157
+ const skillName = skill.description.split(' — ')[0] || skill.description.split(',')[0];
158
+ contexts.push(`### ${skillName}\n${contextContent}`);
159
+ }
160
+ }
161
+ });
162
+
163
+ return contexts.join('\n\n');
164
+ }
165
+
166
+ /**
167
+ * Get skills for a given role using ROLE_SKILLS mapping
168
+ * @param {string} role - The worker role (case-insensitive)
169
+ * @returns {Array<string>} Skill IDs for the role, or empty array if no match
170
+ */
171
+ function getSkillsForRole(role) {
172
+ if (!role) return [];
173
+ return ROLE_SKILLS[role.toLowerCase()] || [];
174
+ }
175
+
176
+ module.exports = {
177
+ listSkills,
178
+ loadSkill,
179
+ detectSkills,
180
+ getSkillsForRole,
181
+ getWorkerContext
182
+ };
@@ -30,6 +30,9 @@
30
30
 
31
31
  const { spawn } = require('child_process');
32
32
  const { EventEmitter } = require('events');
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+ const os = require('os');
33
36
 
34
37
  // How many milliseconds to wait for a response before timing out
35
38
  const DEFAULT_TIMEOUT = 600000; // 10 minutes
@@ -219,6 +222,7 @@ class StreamSession extends EventEmitter {
219
222
  this.process.stdin.end();
220
223
  }
221
224
  }
225
+ this._cleanupSystemPromptFile();
222
226
  }
223
227
 
224
228
  /**
@@ -229,6 +233,7 @@ class StreamSession extends EventEmitter {
229
233
  this.status = 'stopped';
230
234
  this.process.kill('SIGTERM');
231
235
  }
236
+ this._cleanupSystemPromptFile();
232
237
  }
233
238
 
234
239
  /**
@@ -272,6 +277,16 @@ class StreamSession extends EventEmitter {
272
277
  // Private methods
273
278
  // ===========================================================================
274
279
 
280
+ /**
281
+ * Clean up the temp system prompt file if one was created.
282
+ */
283
+ _cleanupSystemPromptFile() {
284
+ if (this._systemPromptFile) {
285
+ try { fs.unlinkSync(this._systemPromptFile); } catch (_) {}
286
+ this._systemPromptFile = null;
287
+ }
288
+ }
289
+
275
290
  /**
276
291
  * Build claude CLI arguments for stream-json mode.
277
292
  */
@@ -299,9 +314,14 @@ class StreamSession extends EventEmitter {
299
314
  args.push('--allowedTools', ...this.allowedTools);
300
315
  }
301
316
 
302
- // System prompt
317
+ // System prompt — write to temp file to avoid Windows ENAMETOOLONG on long prompts
303
318
  if (this.systemPrompt) {
304
- args.push('--append-system-prompt', this.systemPrompt);
319
+ const tmpDir = path.join(os.tmpdir(), 'clearctx');
320
+ fs.mkdirSync(tmpDir, { recursive: true });
321
+ const tmpFile = path.join(tmpDir, `sysprompt-${this.name}-${Date.now()}.md`);
322
+ fs.writeFileSync(tmpFile, this.systemPrompt, 'utf8');
323
+ this._systemPromptFile = tmpFile;
324
+ args.push('--append-system-prompt-file', tmpFile);
305
325
  }
306
326
 
307
327
  // Budget limit