tycono 0.1.65 → 0.1.67

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 (35) hide show
  1. package/bin/tycono.ts +13 -4
  2. package/package.json +1 -1
  3. package/src/api/src/create-server.ts +5 -1
  4. package/src/api/src/engine/agent-loop.ts +17 -6
  5. package/src/api/src/engine/context-assembler.ts +156 -48
  6. package/src/api/src/engine/knowledge-gate.ts +335 -0
  7. package/src/api/src/engine/llm-adapter.ts +7 -1
  8. package/src/api/src/engine/runners/claude-cli.ts +98 -116
  9. package/src/api/src/engine/runners/types.ts +2 -0
  10. package/src/api/src/engine/tools/executor.ts +3 -5
  11. package/src/api/src/routes/active-sessions.ts +143 -0
  12. package/src/api/src/routes/coins.ts +137 -0
  13. package/src/api/src/routes/execute.ts +158 -48
  14. package/src/api/src/routes/knowledge.ts +30 -0
  15. package/src/api/src/routes/operations.ts +48 -11
  16. package/src/api/src/routes/sessions.ts +1 -1
  17. package/src/api/src/routes/setup.ts +68 -1
  18. package/src/api/src/routes/speech.ts +334 -143
  19. package/src/api/src/services/activity-stream.ts +1 -1
  20. package/src/api/src/services/job-manager.ts +185 -9
  21. package/src/api/src/services/port-registry.ts +222 -0
  22. package/src/api/src/services/scaffold.ts +90 -0
  23. package/src/api/src/services/session-store.ts +75 -5
  24. package/src/web/dist/assets/index-BMR4T6Uy.js +109 -0
  25. package/src/web/dist/assets/index-C5M-8dqq.css +1 -0
  26. package/src/web/dist/assets/{preview-app-qIFqrb-y.js → preview-app-BJAaiJcV.js} +1 -1
  27. package/src/web/dist/index.html +2 -2
  28. package/templates/skills/_manifest.json +6 -0
  29. package/templates/skills/agent-browser/SKILL.md +159 -0
  30. package/templates/skills/agent-browser/meta.json +19 -0
  31. package/templates/teams/agency.json +3 -3
  32. package/templates/teams/research.json +3 -3
  33. package/templates/teams/startup.json +3 -3
  34. package/src/web/dist/assets/index-B3dNhn76.js +0 -101
  35. package/src/web/dist/assets/index-C7IEX_o_.css +0 -1
@@ -8,6 +8,8 @@ import { setActivity, updateActivity, completeActivity } from './activity-tracke
8
8
  import type { RunnerResult } from '../engine/runners/types.js';
9
9
  import { estimateCost } from './pricing.js';
10
10
  import { readConfig, getConversationLimits } from './company-config.js';
11
+ import { postKnowledgingCheck, type KnowledgeDebtItem } from '../engine/knowledge-gate.js';
12
+ import { getSession, updateMessage as updateSessionMessage, appendMessageEvent } from './session-store.js';
11
13
 
12
14
  /* ─── Types ──────────────────────────────── */
13
15
 
@@ -29,6 +31,12 @@ export interface Job {
29
31
  error?: string;
30
32
  /** Which role should respond when status is awaiting_input */
31
33
  targetRole?: string;
34
+ /** Selective dispatch scope — only these roles can be dispatched to */
35
+ targetRoles?: string[];
36
+ /** Knowledge debt items detected by Post-Knowledging check */
37
+ knowledgeDebt?: KnowledgeDebtItem[];
38
+ /** D-014: Session this job belongs to */
39
+ sessionId?: string;
32
40
  }
33
41
 
34
42
  export interface JobInfo {
@@ -42,6 +50,8 @@ export interface JobInfo {
42
50
  createdAt: string;
43
51
  /** Which role should respond when status is awaiting_input */
44
52
  targetRole?: string;
53
+ /** Final output text (available when status is done) */
54
+ output?: string;
45
55
  }
46
56
 
47
57
  export interface StartJobParams {
@@ -54,6 +64,10 @@ export interface StartJobParams {
54
64
  model?: string;
55
65
  /** If true, this is a continuation from CEO reply — skip question detection */
56
66
  isContinuation?: boolean;
67
+ /** Selective dispatch: only these roles are allowed as dispatch targets */
68
+ targetRoles?: string[];
69
+ /** D-014: Link this job to a session (internal tracking) */
70
+ sessionId?: string;
57
71
  }
58
72
 
59
73
  /* ─── Helpers ────────────────────────────── */
@@ -155,6 +169,8 @@ class JobManager {
155
169
  parentJobId: params.parentJobId,
156
170
  childJobIds: [],
157
171
  createdAt: new Date().toISOString(),
172
+ targetRoles: params.targetRoles,
173
+ sessionId: params.sessionId,
158
174
  };
159
175
 
160
176
  this.jobs.set(jobId, job);
@@ -209,35 +225,71 @@ class JobManager {
209
225
  sourceRole: params.sourceRole ?? 'ceo',
210
226
  orgTree,
211
227
  readOnly: params.readOnly,
212
- maxTurns: limits.hardLimit, // Runner backup safety net = Harness hardLimit
228
+ maxTurns: limits.hardLimit,
213
229
  model,
214
230
  jobId,
215
231
  teamStatus,
232
+ targetRoles: params.targetRoles,
216
233
  },
217
234
  {
218
235
  onText: (text) => {
219
236
  updateActivity(params.roleId, text);
220
237
  stream.emit('text', params.roleId, { text });
238
+ // D-014: Update linked session message content
239
+ if (job.sessionId) {
240
+ this.updateSessionRoleMessage(job, text);
241
+ }
221
242
  },
222
243
  onThinking: (text) => {
223
244
  stream.emit('thinking', params.roleId, { text });
245
+ // D-014 SCA-010: Embed thinking event in session message
246
+ if (job.sessionId) {
247
+ this.embedSessionEvent(job, 'thinking', { text: text.slice(0, 200) });
248
+ }
224
249
  },
225
250
  onToolUse: (name, input) => {
226
251
  stream.emit('tool:start', params.roleId, {
227
252
  name,
228
253
  input: input ? summarizeInput(input) : undefined,
229
254
  });
255
+ // D-014 SCA-010: Embed tool event in session message
256
+ if (job.sessionId) {
257
+ this.embedSessionEvent(job, 'tool:start', {
258
+ name,
259
+ input: input ? summarizeInput(input) : undefined,
260
+ });
261
+ }
230
262
  },
231
263
  onDispatch: (subRoleId, subTask) => {
264
+ // 2-layer defense: block dispatch to roles outside targetRoles scope
265
+ if (params.targetRoles && params.targetRoles.length > 0) {
266
+ if (!params.targetRoles.includes(subRoleId)) {
267
+ console.warn(`[JobManager] Dispatch blocked: ${params.roleId} → ${subRoleId} (not in targetRoles)`);
268
+ stream.emit('stderr', params.roleId, {
269
+ message: `Dispatch to ${subRoleId} blocked — not in active target scope for this wave.`,
270
+ });
271
+ return;
272
+ }
273
+ }
232
274
  // Create child job — startJob() auto-emits dispatch:start
233
275
  // on parent stream when parentJobId is set.
234
- this.startJob({
276
+ // Propagate targetRoles to child jobs for cascading enforcement
277
+ const childJob = this.startJob({
235
278
  type: 'assign',
236
279
  roleId: subRoleId,
237
280
  task: subTask,
238
281
  sourceRole: params.roleId,
239
282
  parentJobId: jobId,
283
+ targetRoles: params.targetRoles,
240
284
  });
285
+ // D-014 SCA-010: Embed dispatch event in session message
286
+ if (job.sessionId) {
287
+ this.embedSessionEvent(job, 'dispatch:start', {
288
+ roleId: subRoleId,
289
+ task: subTask,
290
+ childJobId: childJob.id,
291
+ });
292
+ }
241
293
  },
242
294
  onConsult: (subRoleId, question) => {
243
295
  // Create child job in read-only mode for consultation
@@ -348,9 +400,45 @@ class JobManager {
348
400
  targetRole,
349
401
  });
350
402
  } else {
403
+ // ─── Post-Knowledging check ───
404
+ // Extract changed .md files from tool calls and check for knowledge debt
405
+ const changedMdFiles = result.toolCalls
406
+ .filter(tc => (tc.name === 'write_file' || tc.name === 'edit_file') && tc.input && typeof tc.input.path === 'string')
407
+ .map(tc => String(tc.input!.path))
408
+ .filter(p => p.endsWith('.md'));
409
+
410
+ if (changedMdFiles.length > 0) {
411
+ try {
412
+ const pkResult = postKnowledgingCheck(COMPANY_ROOT, changedMdFiles);
413
+ if (!pkResult.pass) {
414
+ job.knowledgeDebt = pkResult.debt;
415
+ console.log(
416
+ `[Post-K] Job ${jobId} (${params.roleId}): ${pkResult.debt.length} knowledge debt item(s)`,
417
+ );
418
+ for (const d of pkResult.debt) {
419
+ console.log(` [Post-K] ${d.type}: ${d.message}`);
420
+ }
421
+ // Include debt info in done event
422
+ (doneData as Record<string, unknown>).knowledgeDebt = pkResult.debt.map(d => ({
423
+ type: d.type,
424
+ file: d.file,
425
+ message: d.message,
426
+ }));
427
+ }
428
+ } catch (err) {
429
+ console.warn('[Post-K] Check failed:', err);
430
+ }
431
+ }
432
+
351
433
  job.status = 'done';
352
434
  completeActivity(params.roleId);
353
435
  stream.emit('job:done', params.roleId, doneData);
436
+ // D-014: Update linked session message with final results
437
+ if (job.sessionId) {
438
+ this.finalizeSessionMessage(job, 'done', result);
439
+ }
440
+ // Cleanup orphaned child jobs (awaiting_input with no parent to respond)
441
+ this.cleanupOrphanedChildren(job.id);
354
442
  }
355
443
  })
356
444
  .catch((err: Error) => {
@@ -374,11 +462,93 @@ class JobManager {
374
462
  completeActivity(params.roleId);
375
463
 
376
464
  stream.emit('job:error', params.roleId, { message: err.message });
465
+ // D-014: Mark linked session message as error
466
+ if (job.sessionId) {
467
+ this.finalizeSessionMessage(job, 'error');
468
+ }
377
469
  });
378
470
 
379
471
  return job;
380
472
  }
381
473
 
474
+ /* ─── D-014: Session ↔ Job bridge ───────── */
475
+
476
+ /** Accumulated text content for session messages linked to jobs */
477
+ private sessionMsgContent = new Map<string, string>();
478
+
479
+ /** Update session role message content as text streams in */
480
+ private updateSessionRoleMessage(job: Job, text: string): void {
481
+ if (!job.sessionId) return;
482
+ const session = getSession(job.sessionId);
483
+ if (!session) return;
484
+
485
+ // Find the role message linked to this job
486
+ const roleMsg = session.messages.find(m => m.jobId === job.id && m.from === 'role');
487
+ if (!roleMsg) return;
488
+
489
+ const key = `${job.sessionId}:${roleMsg.id}`;
490
+ const current = (this.sessionMsgContent.get(key) ?? '') + text;
491
+ this.sessionMsgContent.set(key, current);
492
+
493
+ updateSessionMessage(job.sessionId, roleMsg.id, { content: current });
494
+ }
495
+
496
+ /** Embed an activity event into the session message linked to this job (SCA-010) */
497
+ private embedSessionEvent(job: Job, type: string, data: Record<string, unknown>): void {
498
+ if (!job.sessionId) return;
499
+ const session = getSession(job.sessionId);
500
+ if (!session) return;
501
+
502
+ const roleMsg = session.messages.find(m => m.jobId === job.id && m.from === 'role');
503
+ if (!roleMsg) return;
504
+
505
+ const event: ActivityEvent = {
506
+ seq: (roleMsg.events?.length ?? 0) + 1,
507
+ ts: new Date().toISOString(),
508
+ type: type as ActivityEvent['type'],
509
+ roleId: job.roleId,
510
+ data,
511
+ };
512
+ appendMessageEvent(job.sessionId, roleMsg.id, event);
513
+ }
514
+
515
+ /** Finalize session message when job completes or errors */
516
+ private finalizeSessionMessage(job: Job, status: 'done' | 'error', result?: RunnerResult): void {
517
+ if (!job.sessionId) return;
518
+ const session = getSession(job.sessionId);
519
+ if (!session) return;
520
+
521
+ const roleMsg = session.messages.find(m => m.jobId === job.id && m.from === 'role');
522
+ if (!roleMsg) return;
523
+
524
+ const key = `${job.sessionId}:${roleMsg.id}`;
525
+ const finalContent = this.sessionMsgContent.get(key) ?? roleMsg.content;
526
+ this.sessionMsgContent.delete(key);
527
+
528
+ updateSessionMessage(job.sessionId, roleMsg.id, {
529
+ content: finalContent,
530
+ status,
531
+ ...(result && {
532
+ turns: result.turns,
533
+ tokens: result.totalTokens,
534
+ }),
535
+ });
536
+ }
537
+
538
+ /** Cleanup orphaned child jobs when parent completes */
539
+ private cleanupOrphanedChildren(parentJobId: string): void {
540
+ for (const job of this.jobs.values()) {
541
+ if (job.parentJobId === parentJobId && job.status === 'awaiting_input') {
542
+ job.status = 'done';
543
+ completeActivity(job.roleId);
544
+ job.stream.emit('job:done', job.roleId, {
545
+ output: '[Auto-closed] Parent job completed',
546
+ turns: 0,
547
+ });
548
+ }
549
+ }
550
+ }
551
+
382
552
  /** Get a job by ID (in-memory or reconstruct from file) */
383
553
  getJob(id: string): Job | undefined {
384
554
  return this.jobs.get(id);
@@ -426,6 +596,7 @@ class JobManager {
426
596
  childJobIds: job.childJobIds,
427
597
  createdAt: job.createdAt,
428
598
  targetRole: job.targetRole,
599
+ output: job.result?.output,
429
600
  };
430
601
  }
431
602
 
@@ -465,18 +636,19 @@ class JobManager {
465
636
  return true;
466
637
  }
467
638
 
468
- /** Reply to an awaiting_input job → creates a continuation job */
639
+ /** Reply to an awaiting_input or done job → creates a continuation job */
469
640
  replyToJob(id: string, response: string, responderRole?: string): Job | null {
470
641
  const job = this.jobs.get(id);
471
- if (!job || job.status !== 'awaiting_input') return null;
642
+ if (!job || (job.status !== 'awaiting_input' && job.status !== 'done')) return null;
472
643
 
644
+ const isFollowUp = job.status === 'done';
473
645
  const effectiveResponder = responderRole ?? job.targetRole ?? 'ceo';
474
646
 
475
647
  // Mark previous job as done (don't emit job:done — the stream stays open
476
648
  // for the continuation job which will emit its own job:done when finished)
477
649
  job.status = 'done';
478
- completeActivity(job.roleId);
479
- job.stream.emit('job:reply', job.roleId, { response, responderRole: effectiveResponder });
650
+ if (!isFollowUp) completeActivity(job.roleId);
651
+ job.stream.emit('job:reply', job.roleId, { response, responderRole: effectiveResponder, isFollowUp });
480
652
 
481
653
  // Build continuation prompt with previous context
482
654
  const prevOutput = job.result?.output ?? '';
@@ -486,16 +658,20 @@ class JobManager {
486
658
 
487
659
  // Use the actual responder role name in the prompt
488
660
  const responderLabel = effectiveResponder === 'ceo' ? 'CEO' : effectiveResponder.toUpperCase();
489
- const continuationTask = `[Continuation — previous output follows]\n${contextSummary}\n\n[${responderLabel} Response]\n${response}`;
661
+ const continuationTask = isFollowUp
662
+ ? `[CEO Follow-up Directive]\n${response}\n\n[Previous context — your earlier report follows]\n${contextSummary}`
663
+ : `[Continuation — previous output follows]\n${contextSummary}\n\n[${responderLabel} Response]\n${response}`;
490
664
 
491
- // Create new job for same role (mark as continuation to skip question detection)
665
+ // Create new job for same role
666
+ // Follow-ups (from done state) should allow question detection since they're fresh directives
667
+ // Continuations (from awaiting_input) skip question detection to avoid infinite loops
492
668
  const newJob = this.startJob({
493
669
  type: job.type,
494
670
  roleId: job.roleId,
495
671
  task: continuationTask,
496
672
  sourceRole: effectiveResponder,
497
673
  parentJobId: job.id,
498
- isContinuation: true,
674
+ isContinuation: !isFollowUp,
499
675
  });
500
676
 
501
677
  job.childJobIds.push(newJob.id);
@@ -0,0 +1,222 @@
1
+ /**
2
+ * port-registry.ts — Session port allocation and tracking
3
+ *
4
+ * Manages port assignments for parallel dev server sessions.
5
+ * Each job/session gets unique API + Vite ports to avoid conflicts.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import net from 'node:net';
10
+ import { COMPANY_ROOT } from './file-reader.js';
11
+
12
+ /* ─── Types ──────────────────────────────── */
13
+
14
+ export interface PortAllocation {
15
+ api: number;
16
+ vite: number;
17
+ hmr?: number;
18
+ }
19
+
20
+ export interface SessionPort {
21
+ sessionId: string;
22
+ roleId: string;
23
+ task: string;
24
+ ports: PortAllocation;
25
+ worktreePath?: string;
26
+ pid?: number;
27
+ startedAt: string;
28
+ status: 'active' | 'idle' | 'dead';
29
+ }
30
+
31
+ interface RegistryFile {
32
+ sessions: SessionPort[];
33
+ }
34
+
35
+ /* ─── Port Pools ─────────────────────────── */
36
+
37
+ const API_PORT_START = 3001;
38
+ const VITE_PORT_START = 5173;
39
+ const HMR_PORT_START = 24678;
40
+ const POOL_SIZE = 10;
41
+
42
+ /* ─── Helpers ────────────────────────────── */
43
+
44
+ function getRegistryPath(): string {
45
+ return path.join(COMPANY_ROOT, '.tycono', 'port-registry.json');
46
+ }
47
+
48
+ function readRegistry(): RegistryFile {
49
+ const filePath = getRegistryPath();
50
+ try {
51
+ if (fs.existsSync(filePath)) {
52
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
53
+ }
54
+ } catch { /* ignore corrupt file */ }
55
+ return { sessions: [] };
56
+ }
57
+
58
+ function writeRegistry(data: RegistryFile): void {
59
+ const filePath = getRegistryPath();
60
+ const dir = path.dirname(filePath);
61
+ if (!fs.existsSync(dir)) {
62
+ fs.mkdirSync(dir, { recursive: true });
63
+ }
64
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
65
+ }
66
+
67
+ function isProcessAlive(pid: number): boolean {
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ function isPortAvailable(port: number): Promise<boolean> {
77
+ return new Promise((resolve) => {
78
+ const server = net.createServer();
79
+ server.once('error', () => resolve(false));
80
+ server.once('listening', () => {
81
+ server.close();
82
+ resolve(true);
83
+ });
84
+ server.listen(port, '127.0.0.1');
85
+ });
86
+ }
87
+
88
+ /* ─── PortRegistry ───────────────────────── */
89
+
90
+ class PortRegistry {
91
+ /** Allocate ports for a new session */
92
+ async allocate(sessionId: string, roleId: string, task: string): Promise<PortAllocation> {
93
+ const registry = readRegistry();
94
+ const usedApi = new Set(registry.sessions.map(s => s.ports.api));
95
+ const usedVite = new Set(registry.sessions.map(s => s.ports.vite));
96
+ const usedHmr = new Set(registry.sessions.filter(s => s.ports.hmr).map(s => s.ports.hmr!));
97
+
98
+ // Find first available port in pool
99
+ let api = 0;
100
+ let vite = 0;
101
+ let hmr = 0;
102
+
103
+ for (let i = 0; i < POOL_SIZE; i++) {
104
+ const candidate = API_PORT_START + i;
105
+ if (!usedApi.has(candidate) && await isPortAvailable(candidate)) {
106
+ api = candidate;
107
+ break;
108
+ }
109
+ }
110
+
111
+ for (let i = 0; i < POOL_SIZE; i++) {
112
+ const candidate = VITE_PORT_START + i;
113
+ if (!usedVite.has(candidate) && await isPortAvailable(candidate)) {
114
+ vite = candidate;
115
+ break;
116
+ }
117
+ }
118
+
119
+ for (let i = 0; i < POOL_SIZE; i++) {
120
+ const candidate = HMR_PORT_START + i;
121
+ if (!usedHmr.has(candidate) && await isPortAvailable(candidate)) {
122
+ hmr = candidate;
123
+ break;
124
+ }
125
+ }
126
+
127
+ // Fallback: let OS pick
128
+ if (!api) api = 0;
129
+ if (!vite) vite = 0;
130
+
131
+ const ports: PortAllocation = { api, vite };
132
+ if (hmr) ports.hmr = hmr;
133
+
134
+ const session: SessionPort = {
135
+ sessionId,
136
+ roleId,
137
+ task: task.slice(0, 80),
138
+ ports,
139
+ startedAt: new Date().toISOString(),
140
+ status: 'active',
141
+ };
142
+
143
+ registry.sessions.push(session);
144
+ writeRegistry(registry);
145
+
146
+ return ports;
147
+ }
148
+
149
+ /** Release ports when a session ends */
150
+ release(sessionId: string): boolean {
151
+ const registry = readRegistry();
152
+ const before = registry.sessions.length;
153
+ registry.sessions = registry.sessions.filter(s => s.sessionId !== sessionId);
154
+ if (registry.sessions.length < before) {
155
+ writeRegistry(registry);
156
+ return true;
157
+ }
158
+ return false;
159
+ }
160
+
161
+ /** Update session info (e.g., set PID, worktree path) */
162
+ update(sessionId: string, patch: Partial<Pick<SessionPort, 'pid' | 'worktreePath' | 'status' | 'task'>>): boolean {
163
+ const registry = readRegistry();
164
+ const session = registry.sessions.find(s => s.sessionId === sessionId);
165
+ if (!session) return false;
166
+
167
+ if (patch.pid !== undefined) session.pid = patch.pid;
168
+ if (patch.worktreePath !== undefined) session.worktreePath = patch.worktreePath;
169
+ if (patch.status !== undefined) session.status = patch.status;
170
+ if (patch.task !== undefined) session.task = patch.task.slice(0, 80);
171
+
172
+ writeRegistry(registry);
173
+ return true;
174
+ }
175
+
176
+ /** Get all sessions */
177
+ getAll(): SessionPort[] {
178
+ return readRegistry().sessions;
179
+ }
180
+
181
+ /** Get a specific session */
182
+ get(sessionId: string): SessionPort | null {
183
+ return readRegistry().sessions.find(s => s.sessionId === sessionId) ?? null;
184
+ }
185
+
186
+ /** Detect and clean up dead sessions (PID gone) */
187
+ cleanup(): { cleaned: SessionPort[]; remaining: SessionPort[] } {
188
+ const registry = readRegistry();
189
+ const cleaned: SessionPort[] = [];
190
+ const remaining: SessionPort[] = [];
191
+
192
+ for (const session of registry.sessions) {
193
+ if (session.pid && !isProcessAlive(session.pid)) {
194
+ session.status = 'dead';
195
+ cleaned.push(session);
196
+ } else {
197
+ remaining.push(session);
198
+ }
199
+ }
200
+
201
+ if (cleaned.length > 0) {
202
+ registry.sessions = remaining;
203
+ writeRegistry(registry);
204
+ }
205
+
206
+ return { cleaned, remaining };
207
+ }
208
+
209
+ /** Get summary stats */
210
+ getSummary(): { active: number; totalPorts: number } {
211
+ const sessions = this.getAll();
212
+ const active = sessions.filter(s => s.status === 'active').length;
213
+ return {
214
+ active,
215
+ totalPorts: active * 2, // api + vite per session
216
+ };
217
+ }
218
+ }
219
+
220
+ /* ─── Export singleton ───────────────────── */
221
+
222
+ export const portRegistry = new PortRegistry();
@@ -6,6 +6,7 @@
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import { execSync } from 'node:child_process';
9
10
  import { writeConfig } from './company-config.js';
10
11
  import type { CompanyConfig } from './company-config.js';
11
12
 
@@ -61,6 +62,12 @@ export function getAvailableTeams(): string[] {
61
62
  .map(f => f.replace('.json', ''));
62
63
  }
63
64
 
65
+ export interface SkillToolDef {
66
+ package: string;
67
+ binary: string;
68
+ installCmd: string;
69
+ }
70
+
64
71
  export interface SkillMeta {
65
72
  id: string;
66
73
  name: string;
@@ -72,6 +79,7 @@ export interface SkillMeta {
72
79
  compatibleRoles: string[];
73
80
  dependencies: string[];
74
81
  files: string[];
82
+ tools?: SkillToolDef[];
75
83
  }
76
84
 
77
85
  /**
@@ -94,6 +102,88 @@ export function getAvailableSkills(): SkillMeta[] {
94
102
  return skills;
95
103
  }
96
104
 
105
+ /**
106
+ * Check if a CLI binary is available on the system
107
+ */
108
+ function isBinaryInstalled(binary: string): boolean {
109
+ try {
110
+ execSync(`which ${binary}`, { stdio: 'ignore', timeout: 5000 });
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Collect all tools required by a set of skills
119
+ */
120
+ export function getRequiredTools(skillIds: string[]): Array<SkillToolDef & { skillId: string; installed: boolean }> {
121
+ const tools: Array<SkillToolDef & { skillId: string; installed: boolean }> = [];
122
+ const seen = new Set<string>();
123
+
124
+ for (const skillId of skillIds) {
125
+ const metaPath = path.join(TEMPLATES_DIR, 'skills', skillId, 'meta.json');
126
+ if (!fs.existsSync(metaPath)) continue;
127
+
128
+ const meta: SkillMeta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
129
+ if (!meta.tools?.length) continue;
130
+
131
+ for (const tool of meta.tools) {
132
+ if (seen.has(tool.package)) continue;
133
+ seen.add(tool.package);
134
+ tools.push({
135
+ ...tool,
136
+ skillId,
137
+ installed: isBinaryInstalled(tool.binary),
138
+ });
139
+ }
140
+ }
141
+
142
+ return tools;
143
+ }
144
+
145
+ export interface ToolInstallCallbacks {
146
+ onChecking?: (tool: string) => void;
147
+ onInstalling?: (tool: string) => void;
148
+ onInstalled?: (tool: string) => void;
149
+ onSkipped?: (tool: string, reason: string) => void;
150
+ onError?: (tool: string, error: string) => void;
151
+ onDone?: (stats: { installed: number; skipped: number; failed: number }) => void;
152
+ }
153
+
154
+ /**
155
+ * Install CLI tools required by skills
156
+ */
157
+ export function installSkillTools(skillIds: string[], callbacks?: ToolInstallCallbacks): void {
158
+ const tools = getRequiredTools(skillIds);
159
+ let installed = 0;
160
+ let skipped = 0;
161
+ let failed = 0;
162
+
163
+ for (const tool of tools) {
164
+ callbacks?.onChecking?.(tool.package);
165
+
166
+ if (tool.installed) {
167
+ callbacks?.onSkipped?.(tool.package, 'already installed');
168
+ skipped++;
169
+ continue;
170
+ }
171
+
172
+ callbacks?.onInstalling?.(tool.package);
173
+ try {
174
+ execSync(tool.installCmd, { stdio: 'ignore', timeout: 120000 });
175
+ callbacks?.onInstalled?.(tool.package);
176
+ installed++;
177
+ } catch (err) {
178
+ const msg = err instanceof Error ? err.message : 'install failed';
179
+ callbacks?.onError?.(tool.package, msg);
180
+ failed++;
181
+ }
182
+ }
183
+
184
+ callbacks?.onDone?.({ installed, skipped, failed });
185
+ }
186
+
97
187
  function renderTemplate(template: string, vars: Record<string, string>): string {
98
188
  let result = template;
99
189
  for (const [key, value] of Object.entries(vars)) {