scalix 1.0.0 → 1.2.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/dist/skills.js ADDED
@@ -0,0 +1,269 @@
1
+ import fs from 'node:fs/promises';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { logger } from './logger.js';
5
+ import { PROJECT_LOOKUP_DIRS } from './constants.js';
6
+ const SKILL_DIRS = PROJECT_LOOKUP_DIRS.map(d => `${d}/skills`);
7
+ function parseSkillFrontmatter(content) {
8
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
9
+ if (!match)
10
+ return null;
11
+ const meta = {};
12
+ let currentKey = '';
13
+ let isList = false;
14
+ for (const line of match[1].split('\n')) {
15
+ const trimmed = line.trim();
16
+ if (trimmed.startsWith('- ') && isList && currentKey) {
17
+ const arr = meta[currentKey];
18
+ if (Array.isArray(arr))
19
+ arr.push(trimmed.slice(2).trim());
20
+ else
21
+ meta[currentKey] = [trimmed.slice(2).trim()];
22
+ continue;
23
+ }
24
+ const idx = line.indexOf(':');
25
+ if (idx > 0) {
26
+ currentKey = line.slice(0, idx).trim();
27
+ const value = line.slice(idx + 1).trim();
28
+ if (value === '' || value === '[]') {
29
+ meta[currentKey] = [];
30
+ isList = true;
31
+ }
32
+ else {
33
+ meta[currentKey] = value;
34
+ isList = false;
35
+ }
36
+ }
37
+ }
38
+ const name = meta.name || '';
39
+ const description = meta.description || '';
40
+ if (!name)
41
+ return null;
42
+ return {
43
+ meta: {
44
+ name,
45
+ description,
46
+ version: meta.version || '1.0.0',
47
+ paths: Array.isArray(meta.paths) ? meta.paths : (meta.paths ? [meta.paths] : []),
48
+ triggers: Array.isArray(meta.triggers) ? meta.triggers : (meta.triggers ? [meta.triggers] : []),
49
+ },
50
+ body: match[2],
51
+ };
52
+ }
53
+ async function discoverResources(skillDir) {
54
+ const resources = { scripts: [], references: [], examples: [], assets: [] };
55
+ const dirs = ['scripts', 'references', 'examples', 'assets'];
56
+ for (const dir of dirs) {
57
+ const fullDir = path.join(skillDir, dir);
58
+ try {
59
+ const entries = await fs.readdir(fullDir, { withFileTypes: true });
60
+ resources[dir] = entries
61
+ .filter(e => e.isFile())
62
+ .map(e => path.join(fullDir, e.name));
63
+ }
64
+ catch { }
65
+ }
66
+ return resources;
67
+ }
68
+ export async function loadSkills(cwd = process.cwd()) {
69
+ const skills = [];
70
+ for (const dir of SKILL_DIRS) {
71
+ const fullDir = path.resolve(cwd, dir);
72
+ let entries;
73
+ try {
74
+ entries = await fs.readdir(fullDir, { withFileTypes: true });
75
+ }
76
+ catch {
77
+ continue;
78
+ }
79
+ for (const entry of entries) {
80
+ if (!entry.isDirectory()) {
81
+ if (entry.isFile() && entry.name === 'SKILL.md') {
82
+ const skill = await loadSkillFile(path.join(fullDir, entry.name), fullDir);
83
+ if (skill)
84
+ skills.push(skill);
85
+ }
86
+ continue;
87
+ }
88
+ const skillFile = path.join(fullDir, entry.name, 'SKILL.md');
89
+ if (existsSync(skillFile)) {
90
+ const skill = await loadSkillFile(skillFile, path.join(fullDir, entry.name));
91
+ if (skill)
92
+ skills.push(skill);
93
+ }
94
+ }
95
+ }
96
+ skills.push(...getBuiltinSkills());
97
+ return skills;
98
+ }
99
+ async function loadSkillFile(filePath, skillDir) {
100
+ try {
101
+ const raw = await fs.readFile(filePath, 'utf-8');
102
+ const parsed = parseSkillFrontmatter(raw);
103
+ if (!parsed)
104
+ return null;
105
+ const resources = await discoverResources(skillDir);
106
+ return {
107
+ name: parsed.meta.name,
108
+ description: parsed.meta.description,
109
+ version: parsed.meta.version || '1.0.0',
110
+ paths: parsed.meta.paths || [],
111
+ triggers: parsed.meta.triggers || [],
112
+ content: parsed.body.trim(),
113
+ source: filePath,
114
+ resources,
115
+ builtin: false,
116
+ };
117
+ }
118
+ catch (err) {
119
+ logger.warn('Failed to load skill', { path: filePath, error: err.message });
120
+ return null;
121
+ }
122
+ }
123
+ export function getSkillsForPath(skills, filePath) {
124
+ const normalized = filePath.replace(/\\/g, '/');
125
+ return skills.filter(skill => skill.paths.some(p => {
126
+ const pattern = p.replace(/\\/g, '/');
127
+ if (pattern.endsWith('/**')) {
128
+ return normalized.startsWith(pattern.slice(0, -3));
129
+ }
130
+ if (pattern.endsWith('/*')) {
131
+ const dir = pattern.slice(0, -2);
132
+ return normalized.startsWith(dir) && !normalized.slice(dir.length + 1).includes('/');
133
+ }
134
+ return normalized.startsWith(pattern);
135
+ }));
136
+ }
137
+ export function getSkillByTrigger(skills, input) {
138
+ const lower = input.toLowerCase();
139
+ return skills.find(skill => skill.triggers.some(t => lower.includes(t.toLowerCase()))) || null;
140
+ }
141
+ export function getSkillContent(skill, level = 'full') {
142
+ if (level === 'metadata') {
143
+ return `**${skill.name}** (v${skill.version}) — ${skill.description}`;
144
+ }
145
+ let content = skill.content;
146
+ if (level === 'resources') {
147
+ const resourceFiles = [
148
+ ...skill.resources.scripts,
149
+ ...skill.resources.references,
150
+ ...skill.resources.examples,
151
+ ];
152
+ for (const filePath of resourceFiles) {
153
+ if (existsSync(filePath)) {
154
+ try {
155
+ const fileContent = readFileSync(filePath, 'utf-8');
156
+ const relPath = path.basename(filePath);
157
+ content += `\n\n<resource path="${relPath}">\n${fileContent}\n</resource>`;
158
+ }
159
+ catch { }
160
+ }
161
+ }
162
+ }
163
+ return content;
164
+ }
165
+ export function skillsSummary(skills) {
166
+ if (skills.length === 0)
167
+ return 'No skills available.';
168
+ const lines = ['Available skills:'];
169
+ for (const s of skills) {
170
+ const paths = s.paths.length > 0 ? ` [paths: ${s.paths.join(', ')}]` : '';
171
+ const builtin = s.builtin ? ' (built-in)' : '';
172
+ lines.push(` ${s.name} — ${s.description}${paths}${builtin}`);
173
+ }
174
+ return lines.join('\n');
175
+ }
176
+ function getBuiltinSkills() {
177
+ const emptyResources = { scripts: [], references: [], examples: [], assets: [] };
178
+ return [
179
+ {
180
+ name: 'feature-dev',
181
+ description: 'Guided 7-phase feature development workflow',
182
+ version: '1.0.0',
183
+ paths: [],
184
+ triggers: ['new feature', 'implement feature', 'build feature', 'add feature'],
185
+ content: `# Feature Development Workflow
186
+
187
+ Follow this 7-phase process for developing new features:
188
+
189
+ ## Phase 1: Requirements
190
+ - Clarify the feature scope and acceptance criteria
191
+ - Identify affected files and components
192
+ - List edge cases and error scenarios
193
+
194
+ ## Phase 2: Design
195
+ - Propose the technical approach
196
+ - Identify new files, modified files, and interfaces
197
+ - Consider backwards compatibility
198
+
199
+ ## Phase 3: Implementation
200
+ - Write the core implementation
201
+ - Follow existing code conventions
202
+ - Keep changes minimal and focused
203
+
204
+ ## Phase 4: Testing
205
+ - Write unit tests for new functions
206
+ - Test edge cases identified in Phase 1
207
+ - Run existing test suite to check for regressions
208
+
209
+ ## Phase 5: Integration
210
+ - Wire up the feature with existing code
211
+ - Update configuration if needed
212
+ - Add feature flags if appropriate
213
+
214
+ ## Phase 6: Documentation
215
+ - Update relevant documentation
216
+ - Add inline comments only where the "why" is non-obvious
217
+ - Update CHANGELOG if one exists
218
+
219
+ ## Phase 7: Review
220
+ - Run linter and type checker
221
+ - Review diff for accidental changes
222
+ - Verify the feature works end-to-end`,
223
+ source: 'built-in',
224
+ resources: emptyResources,
225
+ builtin: true,
226
+ },
227
+ {
228
+ name: 'code-review',
229
+ description: 'Systematic code review of current changes',
230
+ version: '1.0.0',
231
+ paths: [],
232
+ triggers: ['review code', 'code review', 'review changes', 'review my changes'],
233
+ content: `# Code Review
234
+
235
+ Review the current changes systematically:
236
+
237
+ 1. **Run \`git diff\`** to see all uncommitted changes
238
+ 2. **Security**: Check for hardcoded secrets, SQL injection, XSS, path traversal
239
+ 3. **Correctness**: Verify logic, edge cases, error handling
240
+ 4. **Style**: Check naming, formatting, consistency with codebase conventions
241
+ 5. **Performance**: Look for N+1 queries, unnecessary allocations, blocking I/O
242
+ 6. **Testing**: Are new functions tested? Are existing tests still valid?
243
+ 7. **Summary**: Provide a structured review with findings categorized as Critical / Important / Minor`,
244
+ source: 'built-in',
245
+ resources: emptyResources,
246
+ builtin: true,
247
+ },
248
+ {
249
+ name: 'commit-push-pr',
250
+ description: 'Commit changes, push, and create a pull request',
251
+ version: '1.0.0',
252
+ paths: [],
253
+ triggers: ['commit and push', 'create pr', 'open pr', 'make a pr'],
254
+ content: `# Commit, Push & PR
255
+
256
+ 1. **Stage changes**: Review \`git status\` and stage relevant files
257
+ 2. **Commit**: Write a concise commit message (imperative mood, <72 chars)
258
+ 3. **Push**: Push to the remote branch
259
+ 4. **PR**: Create a pull request with:
260
+ - Clear title (matches commit message style)
261
+ - Description summarizing what changed and why
262
+ - Link to related issues if applicable
263
+ - List any manual testing done`,
264
+ source: 'built-in',
265
+ resources: emptyResources,
266
+ builtin: true,
267
+ },
268
+ ];
269
+ }
package/dist/sub-agent.js CHANGED
@@ -5,15 +5,21 @@ import { webTools } from './tools-web.js';
5
5
  import { advancedTools } from './tools-advanced.js';
6
6
  import { logger } from './logger.js';
7
7
  import { SUB_AGENT_DEFAULTS, CHEAP_MODEL_INDICATORS } from './constants.js';
8
+ import { AgentRegistry, generateAgentId } from './agent-registry.js';
9
+ import { createWorktree, removeWorktree, mergeWorktree } from './worktree.js';
8
10
  const READ_ONLY_TOOL_NAMES = new Set([
9
11
  'get_current_time', 'calculate', 'read_file', 'read_file_lines',
10
12
  'list_files', 'search_files', 'find_files', 'tree',
11
13
  'git_status', 'git_diff', 'web_search', 'monitor',
12
14
  ]);
13
- function selectTools(level) {
14
- // Include advanced tools but exclude 'agent' to prevent recursive spawning
15
+ function selectTools(level, allowAgentTool = false) {
15
16
  const all = [...defaultTools, ...extraTools, ...webTools, ...advancedTools]
16
- .filter((t) => (t.function?.name || t.name) !== 'agent');
17
+ .filter((t) => {
18
+ const name = t.function?.name || t.name;
19
+ if (name === 'agent' && !allowAgentTool)
20
+ return false;
21
+ return true;
22
+ });
17
23
  switch (level) {
18
24
  case 'read-only':
19
25
  return all.filter((t) => READ_ONLY_TOOL_NAMES.has(t.function?.name || t.name));
@@ -26,10 +32,32 @@ function selectTools(level) {
26
32
  }
27
33
  export async function spawnSubAgent(parentConfig, prompt, options = {}) {
28
34
  const model = options.model || parentConfig.models[parentConfig.models.length - 1];
29
- const tools = selectTools(options.tools);
35
+ const allowAgentTool = options.allowAgentTool ?? false;
36
+ const tools = selectTools(options.tools, allowAgentTool);
30
37
  const timeout = options.timeout ?? SUB_AGENT_DEFAULTS.timeoutMs;
38
+ const subAgentId = generateAgentId();
39
+ const parentId = options.parentAgentId ?? null;
40
+ const description = options.agentDescription ?? 'sub-agent';
41
+ const registry = AgentRegistry.getInstance();
42
+ const spawnCheck = registry.canSpawn(parentId);
43
+ if (!spawnCheck.allowed) {
44
+ throw new Error(`Cannot spawn agent: ${spawnCheck.reason}`);
45
+ }
31
46
  const permissionMode = options.permissionMode
32
- ?? (options.tools === 'read-only' ? 'plan' : options.tools === 'all' ? 'auto' : 'default');
47
+ ?? (options.tools === 'read-only' ? 'plan' : parentConfig.permissionMode ?? 'default');
48
+ // Worktree isolation for write-capable agents
49
+ const shouldIsolate = options.isolate !== false && options.tools !== 'read-only';
50
+ let worktreeInfo = null;
51
+ if (shouldIsolate) {
52
+ try {
53
+ worktreeInfo = await createWorktree(subAgentId, process.cwd());
54
+ logger.info('Agent isolated in worktree', { agentId: subAgentId, path: worktreeInfo.path });
55
+ }
56
+ catch (err) {
57
+ logger.warn('Worktree creation failed, running without isolation', { error: err.message });
58
+ }
59
+ }
60
+ const isReadOnly = options.tools === 'read-only';
33
61
  const subAgent = createAgent({
34
62
  apiKey: parentConfig.apiKey,
35
63
  baseUrl: parentConfig.baseUrl,
@@ -43,6 +71,11 @@ export async function spawnSubAgent(parentConfig, prompt, options = {}) {
43
71
  maxContextTokens: SUB_AGENT_DEFAULTS.maxContextTokens,
44
72
  compressionThreshold: SUB_AGENT_DEFAULTS.compressionThreshold,
45
73
  },
74
+ agentId: subAgentId,
75
+ parentAgentId: parentId,
76
+ agentDescription: description,
77
+ groundingCheck: true,
78
+ contextRetrieval: !isReadOnly,
46
79
  });
47
80
  let steps = 0;
48
81
  let tokenEstimate = 0;
@@ -56,18 +89,60 @@ export async function spawnSubAgent(parentConfig, prompt, options = {}) {
56
89
  });
57
90
  try {
58
91
  const response = await Promise.race([subAgent.send(prompt), timeoutPromise]);
59
- logger.info('Sub-agent completed', { model, steps, tokens: tokenEstimate });
60
- return { response, model, tokenEstimate, steps };
92
+ logger.info('Sub-agent completed', { agentId: subAgentId, model, steps, tokens: tokenEstimate });
93
+ let mergeResult;
94
+ if (worktreeInfo) {
95
+ try {
96
+ mergeResult = await mergeWorktree(worktreeInfo, process.cwd());
97
+ logger.info('Worktree merge result', {
98
+ agentId: subAgentId,
99
+ merged: mergeResult.merged,
100
+ commits: mergeResult.commitCount,
101
+ conflicts: mergeResult.conflicts?.length ?? 0,
102
+ });
103
+ }
104
+ catch (err) {
105
+ logger.warn('Worktree merge failed', { error: err.message });
106
+ }
107
+ }
108
+ return {
109
+ response,
110
+ model,
111
+ tokenEstimate,
112
+ steps,
113
+ agentId: subAgentId,
114
+ worktree: mergeResult ? {
115
+ merged: mergeResult.merged,
116
+ conflicts: mergeResult.conflicts,
117
+ commitCount: mergeResult.commitCount,
118
+ } : undefined,
119
+ };
61
120
  }
62
121
  catch (err) {
63
122
  subAgent.abort();
64
- logger.error('Sub-agent failed', { model, error: err.message });
123
+ logger.error('Sub-agent failed', { agentId: subAgentId, model, error: err.message });
65
124
  throw err;
66
125
  }
67
126
  finally {
68
127
  clearTimeout(timer);
128
+ if (worktreeInfo) {
129
+ try {
130
+ await removeWorktree(worktreeInfo, process.cwd());
131
+ }
132
+ catch (err) {
133
+ logger.warn('Worktree cleanup failed', { error: err.message });
134
+ }
135
+ }
69
136
  }
70
137
  }
138
+ export async function spawnParallelAgents(parentConfig, requests) {
139
+ logger.info('Spawning parallel agents', { count: requests.length });
140
+ return Promise.allSettled(requests.map(r => spawnSubAgent(parentConfig, r.prompt, {
141
+ ...r.options,
142
+ allowAgentTool: r.options?.allowAgentTool ?? true,
143
+ agentDescription: r.description,
144
+ })));
145
+ }
71
146
  export function pickCheapestModel(models) {
72
147
  const cheapIndicators = CHEAP_MODEL_INDICATORS;
73
148
  for (const model of [...models].reverse()) {
@@ -0,0 +1,105 @@
1
+ import { EventEmitter } from 'eventemitter3';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { logger } from './logger.js';
4
+ export class TaskBoard extends EventEmitter {
5
+ static instance = null;
6
+ tasks = new Map();
7
+ constructor() {
8
+ super();
9
+ }
10
+ static getInstance() {
11
+ if (!TaskBoard.instance) {
12
+ TaskBoard.instance = new TaskBoard();
13
+ }
14
+ return TaskBoard.instance;
15
+ }
16
+ static resetInstance() {
17
+ if (TaskBoard.instance) {
18
+ TaskBoard.instance.removeAllListeners();
19
+ TaskBoard.instance.tasks.clear();
20
+ }
21
+ TaskBoard.instance = null;
22
+ }
23
+ create(agentId, title, description) {
24
+ const task = {
25
+ id: randomUUID().slice(0, 8),
26
+ title,
27
+ description,
28
+ status: 'open',
29
+ assignee: null,
30
+ createdBy: agentId,
31
+ createdAt: Date.now(),
32
+ updatedAt: Date.now(),
33
+ };
34
+ this.tasks.set(task.id, task);
35
+ this.emit('task:created', task);
36
+ logger.debug('Task created', { id: task.id, title, by: agentId });
37
+ return task;
38
+ }
39
+ claim(taskId, agentId) {
40
+ const task = this.tasks.get(taskId);
41
+ if (!task) {
42
+ return { error: `Task "${taskId}" not found` };
43
+ }
44
+ if (task.status !== 'open') {
45
+ return { error: `Task "${taskId}" is not open (status: ${task.status}, assignee: ${task.assignee})` };
46
+ }
47
+ task.assignee = agentId;
48
+ task.status = 'claimed';
49
+ task.updatedAt = Date.now();
50
+ this.emit('task:claimed', task);
51
+ logger.debug('Task claimed', { id: taskId, by: agentId });
52
+ return task;
53
+ }
54
+ update(taskId, agentId, updates) {
55
+ const task = this.tasks.get(taskId);
56
+ if (!task) {
57
+ return { error: `Task "${taskId}" not found` };
58
+ }
59
+ if (task.assignee && task.assignee !== agentId && task.createdBy !== agentId) {
60
+ return { error: `Agent "${agentId}" is not the assignee or creator of task "${taskId}"` };
61
+ }
62
+ if (updates.status) {
63
+ task.status = updates.status;
64
+ if (updates.status === 'in_progress' && !task.assignee) {
65
+ task.assignee = agentId;
66
+ }
67
+ }
68
+ if (updates.result !== undefined) {
69
+ task.result = updates.result;
70
+ }
71
+ task.updatedAt = Date.now();
72
+ if (task.status === 'completed' || task.status === 'failed') {
73
+ this.emit('task:completed', task);
74
+ }
75
+ else {
76
+ this.emit('task:updated', task);
77
+ }
78
+ logger.debug('Task updated', { id: taskId, status: task.status, by: agentId });
79
+ return task;
80
+ }
81
+ list(filter) {
82
+ let tasks = [...this.tasks.values()];
83
+ if (filter?.status) {
84
+ tasks = tasks.filter(t => t.status === filter.status);
85
+ }
86
+ if (filter?.assignee) {
87
+ tasks = tasks.filter(t => t.assignee === filter.assignee);
88
+ }
89
+ return tasks;
90
+ }
91
+ get(taskId) {
92
+ return this.tasks.get(taskId);
93
+ }
94
+ getStats() {
95
+ const all = [...this.tasks.values()];
96
+ return {
97
+ total: all.length,
98
+ open: all.filter(t => t.status === 'open').length,
99
+ claimed: all.filter(t => t.status === 'claimed').length,
100
+ inProgress: all.filter(t => t.status === 'in_progress').length,
101
+ completed: all.filter(t => t.status === 'completed').length,
102
+ failed: all.filter(t => t.status === 'failed').length,
103
+ };
104
+ }
105
+ }
@@ -0,0 +1,73 @@
1
+ // Terminal notification passthrough for iTerm2, Kitty, Ghostty, and tmux
2
+ function detectTerminal() {
3
+ const term = process.env.TERM_PROGRAM?.toLowerCase() || '';
4
+ const tmux = !!process.env.TMUX;
5
+ if (tmux)
6
+ return 'tmux';
7
+ if (term === 'iterm.app' || term === 'iterm2')
8
+ return 'iterm2';
9
+ if (term === 'kitty')
10
+ return 'kitty';
11
+ if (term === 'ghostty')
12
+ return 'ghostty';
13
+ return 'generic';
14
+ }
15
+ function wrapForTmux(seq) {
16
+ return `\x1bPtmux;\x1b${seq}\x1b\\`;
17
+ }
18
+ function sendOSC(code, payload) {
19
+ const terminal = detectTerminal();
20
+ const seq = `\x1b]${code};${payload}\x07`;
21
+ if (terminal === 'tmux') {
22
+ process.stdout.write(wrapForTmux(seq));
23
+ }
24
+ else {
25
+ process.stdout.write(seq);
26
+ }
27
+ }
28
+ export function sendNotification(title, body) {
29
+ const terminal = detectTerminal();
30
+ switch (terminal) {
31
+ case 'iterm2':
32
+ sendOSC(9, body ? `${title}: ${body}` : title);
33
+ break;
34
+ case 'kitty':
35
+ sendOSC(99, `i=1:d=0:p=title;${title}`);
36
+ if (body) {
37
+ sendOSC(99, `i=1:d=1:p=body;${body}`);
38
+ }
39
+ else {
40
+ sendOSC(99, `i=1:d=1:p=body;`);
41
+ }
42
+ break;
43
+ case 'ghostty':
44
+ sendOSC(777, `notify;${title};${body || ''}`);
45
+ break;
46
+ case 'tmux': {
47
+ const tmuxPayload = body ? `${title}: ${body}` : title;
48
+ sendOSC(9, tmuxPayload);
49
+ break;
50
+ }
51
+ case 'generic':
52
+ // OSC 9 is widely supported (ConEmu, Windows Terminal, etc.)
53
+ sendOSC(9, body ? `${title}: ${body}` : title);
54
+ break;
55
+ }
56
+ }
57
+ export function sendBell() {
58
+ process.stdout.write('\x07');
59
+ }
60
+ export function setTerminalTitle(title) {
61
+ sendOSC(0, title);
62
+ }
63
+ export function notifyTaskComplete(taskName) {
64
+ sendNotification('Scalix', `${taskName} complete`);
65
+ sendBell();
66
+ }
67
+ export function notifyError(message) {
68
+ sendNotification('Scalix Error', message);
69
+ sendBell();
70
+ }
71
+ export function getTerminalType() {
72
+ return detectTerminal();
73
+ }