tycono 0.3.43 → 0.3.45-beta.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.3.43",
3
+ "version": "0.3.45-beta.0",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -94,7 +94,7 @@ function handleImportKnowledge(req: http.IncomingMessage, res: http.ServerRespon
94
94
 
95
95
  export function createHttpServer(): http.Server {
96
96
  // Only cleanup/ensure if a company is already initialized (avoid creating dirs in CWD)
97
- if (COMPANY_ROOT && fs.existsSync(path.join(COMPANY_ROOT, 'CLAUDE.md'))) {
97
+ if (COMPANY_ROOT && fs.existsSync(path.join(COMPANY_ROOT, 'knowledge', 'CLAUDE.md'))) {
98
98
  ensureClaudeMd(COMPANY_ROOT);
99
99
  }
100
100
 
@@ -300,8 +300,8 @@ function loadCompanyRules(companyRoot: string): string | null {
300
300
  parts.push(fs.readFileSync(claudeMdPath, 'utf-8'));
301
301
  }
302
302
 
303
- // 2. User custom rules (.tycono/custom-rules.md — user owned)
304
- const customPath = path.join(companyRoot, '.tycono', 'custom-rules.md');
303
+ // 2. User custom rules (knowledge/custom-rules.md — user owned, git tracked)
304
+ const customPath = path.join(companyRoot, 'knowledge', 'custom-rules.md');
305
305
  if (fs.existsSync(customPath)) {
306
306
  const custom = fs.readFileSync(customPath, 'utf-8').trim();
307
307
  if (custom) {
@@ -401,7 +401,7 @@ domain: tech|market|process|strategy|financial|competitor|general
401
401
  }
402
402
 
403
403
  function loadSkillMd(companyRoot: string, roleId: string): string | null {
404
- const skillPath = path.join(companyRoot, '.claude', 'skills', roleId, 'SKILL.md');
404
+ const skillPath = path.join(companyRoot, 'knowledge', '.claude', 'skills', roleId, 'SKILL.md');
405
405
  if (!fs.existsSync(skillPath)) return null;
406
406
  return fs.readFileSync(skillPath, 'utf-8');
407
407
  }
@@ -411,7 +411,7 @@ function loadSharedSkills(companyRoot: string, skillIds?: string[]): string | nu
411
411
 
412
412
  const sections: string[] = [];
413
413
  for (const skillId of skillIds) {
414
- const skillPath = path.join(companyRoot, '.claude', 'skills', '_shared', skillId, 'SKILL.md');
414
+ const skillPath = path.join(companyRoot, 'knowledge', '.claude', 'skills', '_shared', skillId, 'SKILL.md');
415
415
  if (!fs.existsSync(skillPath)) continue;
416
416
  const content = fs.readFileSync(skillPath, 'utf-8');
417
417
  // Extract just the key sections (skip frontmatter)
@@ -517,13 +517,15 @@ export class ClaudeCliRunner implements ExecutionRunner {
517
517
  }
518
518
 
519
519
  const modelName = config.model ?? 'claude-opus-4-6';
520
- // Use codeRoot as cwd — auto-creates ../{name}-code/ if not configured
521
520
  const codeRoot = resolveCodeRoot(companyRoot);
522
- const cwd = codeRoot;
521
+ // Run claude -p inside knowledge/ — CLAUDE.md is there, grep searches knowledge only
522
+ const knowledgeDir = path.join(companyRoot, 'knowledge');
523
+ const cwd = fs.existsSync(knowledgeDir) ? knowledgeDir : companyRoot;
523
524
 
524
- // Inject repo paths so agents never confuse repos
525
+ // Inject paths so agents can reference code + AKB via absolute paths
525
526
  cleanEnv.TYCONO_CODE_ROOT = codeRoot;
526
527
  cleanEnv.TYCONO_AKB_ROOT = companyRoot;
528
+ cleanEnv.TYCONO_KNOWLEDGE_ROOT = knowledgeDir;
527
529
  console.log(`[Runner] Spawning claude -p: role=${roleId}, model=${modelName}, maxTurns=${maxTurns}, sessionId=${config.sessionId}, cwd=${cwd}, subordinates=[${subordinates.join(',')}]`);
528
530
 
529
531
  const proc = spawn('claude', args, {
@@ -5,7 +5,7 @@
5
5
  * - Version tracking via .tycono/rules-version
6
6
  * - Auto-regeneration on version mismatch (server startup)
7
7
  * - Backup of pre-existing CLAUDE.md (first time only)
8
- * - Stub creation for .tycono/custom-rules.md
8
+ * - Stub creation for knowledge/custom-rules.md
9
9
  */
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
@@ -42,13 +42,14 @@ function generateClaudeMd(version: string): string {
42
42
  *
43
43
  * Called on server startup. Compares .tycono/rules-version with package version.
44
44
  * If different, regenerates CLAUDE.md from template (safe because CLAUDE.md
45
- * contains 0% user data — all user customization is in .tycono/custom-rules.md).
45
+ * contains 0% user data — all user customization is in knowledge/custom-rules.md).
46
46
  */
47
47
  export function ensureClaudeMd(companyRoot: string): void {
48
48
  const tyconoDir = path.join(companyRoot, '.tycono');
49
49
  const rulesVersionPath = path.join(tyconoDir, 'rules-version');
50
- const claudeMdPath = path.join(companyRoot, 'CLAUDE.md');
51
- const customRulesPath = path.join(tyconoDir, 'custom-rules.md');
50
+ const claudeMdPath = path.join(companyRoot, 'knowledge', 'CLAUDE.md');
51
+ const knowledgeDir = path.join(companyRoot, 'knowledge');
52
+ const customRulesPath = path.join(knowledgeDir, 'custom-rules.md');
52
53
  const backupPath = path.join(tyconoDir, 'CLAUDE.md.backup');
53
54
 
54
55
  // Skip if not initialized (no .tycono/ directory)
@@ -5,10 +5,10 @@ import { glob } from 'glob';
5
5
 
6
6
  function findCompanyRoot(): string {
7
7
  if (process.env.COMPANY_ROOT) return process.env.COMPANY_ROOT;
8
- // Walk up from cwd to find CLAUDE.md (repo root marker)
8
+ // Walk up from cwd to find knowledge/CLAUDE.md (project root marker)
9
9
  let dir = process.cwd();
10
10
  while (dir !== path.dirname(dir)) {
11
- if (fs.existsSync(path.join(dir, 'CLAUDE.md'))) return dir;
11
+ if (fs.existsSync(path.join(dir, 'knowledge', 'CLAUDE.md'))) return dir;
12
12
  dir = path.dirname(dir);
13
13
  }
14
14
  return process.cwd();
@@ -90,7 +90,7 @@ function loadPresetFromDir(presetDir: string): LoadedPreset | null {
90
90
  }
91
91
 
92
92
  /**
93
- * Load all presets from company/presets/ + auto-generated default.
93
+ * Load all presets from knowledge/presets/ + auto-generated default.
94
94
  * Returns [default, ...installed] — default is always first.
95
95
  */
96
96
  export function loadPresets(companyRoot: string): LoadedPreset[] {
@@ -112,7 +112,7 @@ export function loadPresets(companyRoot: string): LoadedPreset[] {
112
112
 
113
113
  presets.push(defaultPreset);
114
114
 
115
- // 2. Installed presets from company/presets/{name}/preset.yaml
115
+ // 2. Installed presets from knowledge/presets/{name}/preset.yaml
116
116
  const presetsDir = path.join(companyRoot, PRESETS_DIR);
117
117
  if (fs.existsSync(presetsDir)) {
118
118
  const entries = fs.readdirSync(presetsDir, { withFileTypes: true });
@@ -262,13 +262,13 @@ function renderTemplate(template: string, vars: Record<string, string>): string
262
262
  }
263
263
 
264
264
  /**
265
- * Copy a skill from templates/skills/ to the target AKB's .claude/skills/_shared/
265
+ * Copy a skill from templates/skills/ to the target AKB's knowledge/.claude/skills/_shared/
266
266
  */
267
267
  function installSkill(root: string, skillId: string): boolean {
268
268
  const srcDir = path.join(TEMPLATES_DIR, 'skills', skillId);
269
269
  if (!fs.existsSync(srcDir)) return false;
270
270
 
271
- const destDir = path.join(root, '.claude', 'skills', '_shared', skillId);
271
+ const destDir = path.join(root, 'knowledge', '.claude', 'skills', '_shared', skillId);
272
272
  fs.mkdirSync(destDir, { recursive: true });
273
273
 
274
274
  // Copy SKILL.md
@@ -317,30 +317,33 @@ export function scaffold(config: ScaffoldConfig): string[] {
317
317
  'knowledge', 'knowledge/roles', 'knowledge/projects',
318
318
  'knowledge/architecture', 'knowledge/methodologies',
319
319
  'knowledge/decisions', 'knowledge/presets',
320
+ 'knowledge/.claude/skills', 'knowledge/.claude/skills/_shared',
320
321
  '.tycono/waves', '.tycono/sessions', '.tycono/standup',
321
322
  '.tycono/activity-streams', '.tycono/cost', '.tycono/activity',
322
- '.claude/skills', '.claude/skills/_shared', '.tycono',
323
+ '.tycono',
324
+ 'apps',
323
325
  ];
324
326
  for (const dir of dirs) {
325
327
  fs.mkdirSync(path.join(root, dir), { recursive: true });
326
328
  created.push(dir + '/');
327
329
  }
328
330
 
329
- // Write CLAUDE.md (no variable substitution 100% Tycono managed)
331
+ // Write CLAUDE.md knowledge/ only (AI agent's cwd)
330
332
  const claudeTmpl = loadTemplate('CLAUDE.md.tmpl');
331
333
  const pkgVersion = getPackageVersion();
332
- fs.writeFileSync(path.join(root, 'CLAUDE.md'), claudeTmpl.replaceAll('{{VERSION}}', pkgVersion));
333
- created.push('CLAUDE.md');
334
+ const claudeContent = claudeTmpl.replaceAll('{{VERSION}}', pkgVersion);
335
+ fs.writeFileSync(path.join(root, 'knowledge', 'CLAUDE.md'), claudeContent);
336
+ created.push('knowledge/CLAUDE.md');
334
337
 
335
338
  // Write .tycono/rules-version
336
339
  fs.writeFileSync(path.join(root, '.tycono', 'rules-version'), pkgVersion);
337
340
  created.push('.tycono/rules-version');
338
341
 
339
- // Write .tycono/custom-rules.md (empty stub — user owned)
340
- const customRulesPath = path.join(root, '.tycono', 'custom-rules.md');
342
+ // Write knowledge/custom-rules.md (empty stub — user owned, git tracked)
343
+ const customRulesPath = path.join(root, 'knowledge', 'custom-rules.md');
341
344
  if (!fs.existsSync(customRulesPath)) {
342
345
  fs.writeFileSync(customRulesPath, `# Custom Rules\n\n> Company-specific rules, constraints, and processes.\n> This file is owned by you — Tycono will never overwrite it.\n\n<!-- Add your custom rules below -->\n`);
343
- created.push('.tycono/custom-rules.md');
346
+ created.push('knowledge/custom-rules.md');
344
347
  }
345
348
 
346
349
  // Write knowledge/company.md
@@ -363,7 +366,7 @@ export function scaffold(config: ScaffoldConfig): string[] {
363
366
 
364
367
  // Write .tycono/config.json (engine + API key + codeRoot)
365
368
  const slug = config.companyName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'my-company';
366
- const defaultCodeRoot = path.join(path.dirname(root), `${slug}-code`);
369
+ const defaultCodeRoot = path.join(root, 'apps');
367
370
  if (config.apiKey) {
368
371
  const companyConfig: CompanyConfig = {
369
372
  engine: 'direct-api',
@@ -482,7 +485,7 @@ export function scaffold(config: ScaffoldConfig): string[] {
482
485
 
483
486
  function createRole(root: string, role: TeamRole): void {
484
487
  const roleDir = path.join(root, 'knowledge', 'roles', role.id);
485
- const skillDir = path.join(root, '.claude', 'skills', role.id);
488
+ const skillDir = path.join(root, 'knowledge', '.claude', 'skills', role.id);
486
489
  const journalDir = path.join(roleDir, 'journal');
487
490
 
488
491
  fs.mkdirSync(roleDir, { recursive: true });
@@ -0,0 +1,382 @@
1
+ /**
2
+ * team-recommender.ts — Recommend team composition for a Wave directive
3
+ *
4
+ * Analyzes the directive text + org tree to suggest optimal team size.
5
+ * Uses Haiku for AI classification, falls back to keyword heuristic.
6
+ *
7
+ * Returns ranked options: Quick / Standard / Full + custom saved teams.
8
+ */
9
+ import { buildOrgTree, getSubordinates, getDescendants, type OrgTree, type OrgNode } from '../engine/org-tree.js';
10
+ import { readConfig } from './company-config.js';
11
+ import { COMPANY_ROOT } from './file-reader.js';
12
+ import { ClaudeCliProvider } from '../engine/llm-adapter.js';
13
+ import Anthropic from '@anthropic-ai/sdk';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ /* ─── Types ──────────────────────────────────── */
18
+
19
+ export interface TeamOption {
20
+ id: string;
21
+ name: string;
22
+ description: string;
23
+ roles: string[]; // role IDs to include as targetRoles
24
+ roleDetails: { id: string; name: string; level: string; subordinates: string[] }[];
25
+ totalAgents: number; // total agents including subordinates
26
+ estimatedSpeed: 'fast' | 'medium' | 'slow';
27
+ recommended?: boolean;
28
+ }
29
+
30
+ export interface TeamRecommendation {
31
+ directive: string;
32
+ analysis: {
33
+ domains: string[]; // detected domains: code, design, planning, testing, business
34
+ complexity: 'simple' | 'moderate' | 'complex';
35
+ reasoning: string;
36
+ };
37
+ options: TeamOption[];
38
+ customTeams: SavedTeam[];
39
+ recommendedId: string; // ID of the recommended option
40
+ }
41
+
42
+ export interface SavedTeam {
43
+ id: string;
44
+ name: string;
45
+ roles: string[];
46
+ createdAt: string;
47
+ usageCount: number;
48
+ }
49
+
50
+ interface TeamPresetsFile {
51
+ teams: SavedTeam[];
52
+ }
53
+
54
+ /* ─── Constants ──────────────────────────────── */
55
+
56
+ const TEAM_PRESETS_PATH = () => path.join(COMPANY_ROOT, '.tycono', 'team-presets.json');
57
+
58
+ const CLASSIFY_SYSTEM = `You analyze a CEO directive and determine what domains of work it requires.
59
+
60
+ Reply with a JSON object (no markdown, no explanation):
61
+ {
62
+ "domains": ["code", "design"],
63
+ "complexity": "moderate",
64
+ "reasoning": "Needs implementation + visual design but scope is clear"
65
+ }
66
+
67
+ Domain options: "code", "design", "planning", "testing", "business", "research", "writing"
68
+ Complexity options: "simple" (one person can do it), "moderate" (2-3 roles needed), "complex" (full team coordination)
69
+
70
+ Examples:
71
+ "버그 고쳐" → {"domains":["code"],"complexity":"simple","reasoning":"Single code fix, engineer only"}
72
+ "랜딩페이지 만들어" → {"domains":["code","design"],"complexity":"moderate","reasoning":"Frontend + design needed"}
73
+ "타워 디펜스 게임 만들어" → {"domains":["code","design","planning"],"complexity":"moderate","reasoning":"Game needs balance design + visual + code"}
74
+ "Q2 사업 전략 수립하고 신규 기능 개발해" → {"domains":["code","design","planning","business"],"complexity":"complex","reasoning":"Cross-domain: business strategy + product development"}
75
+ "README 업데이트해" → {"domains":["writing"],"complexity":"simple","reasoning":"Documentation only, single agent task"}`;
76
+
77
+ /* ─── Domain → Role Mapping ──────────────────── */
78
+
79
+ /**
80
+ * Map detected domains to required role capabilities.
81
+ * This uses the org tree to find which roles handle which domains.
82
+ */
83
+ function mapDomainsToRoles(domains: string[], orgTree: OrgTree): Set<string> {
84
+ const needed = new Set<string>();
85
+
86
+ // Domain → role mapping based on typical role responsibilities
87
+ const DOMAIN_ROLES: Record<string, string[]> = {
88
+ code: ['engineer', 'cto'],
89
+ design: ['designer'],
90
+ planning: ['pm'],
91
+ testing: ['qa'],
92
+ business: ['cbo', 'data-analyst'],
93
+ research: ['cbo', 'data-analyst'],
94
+ writing: ['engineer'], // simple writing = engineer can handle
95
+ };
96
+
97
+ for (const domain of domains) {
98
+ const candidates = DOMAIN_ROLES[domain] ?? [];
99
+ for (const roleId of candidates) {
100
+ if (orgTree.nodes.has(roleId)) {
101
+ needed.add(roleId);
102
+ }
103
+ }
104
+ }
105
+
106
+ return needed;
107
+ }
108
+
109
+ /**
110
+ * Build team options from org tree based on analysis.
111
+ */
112
+ function buildOptions(
113
+ orgTree: OrgTree,
114
+ neededRoles: Set<string>,
115
+ complexity: 'simple' | 'moderate' | 'complex',
116
+ ): { options: TeamOption[]; recommendedId: string } {
117
+ const ceo = orgTree.nodes.get('ceo')!;
118
+ const cLevelIds = ceo.children;
119
+ const options: TeamOption[] = [];
120
+
121
+ // Helper: build role details for an option
122
+ const buildRoleDetails = (roleIds: string[]) => {
123
+ return roleIds.map(id => {
124
+ const node = orgTree.nodes.get(id);
125
+ return {
126
+ id,
127
+ name: node?.name ?? id,
128
+ level: node?.level ?? 'member',
129
+ subordinates: getSubordinates(orgTree, id),
130
+ };
131
+ });
132
+ };
133
+
134
+ const totalAgents = (roleIds: string[]) => {
135
+ let count = 0;
136
+ for (const id of roleIds) {
137
+ count += 1 + getDescendants(orgTree, id).length;
138
+ }
139
+ return count;
140
+ };
141
+
142
+ // ── Quick: minimum viable team (just the most needed C-Level) ──
143
+ // Find the C-Level that covers the most needed roles
144
+ let quickCLevel: string | null = null;
145
+ let maxCoverage = 0;
146
+
147
+ for (const cId of cLevelIds) {
148
+ const descendants = new Set([cId, ...getDescendants(orgTree, cId)]);
149
+ const coverage = [...neededRoles].filter(r => descendants.has(r)).length;
150
+ if (coverage > maxCoverage) {
151
+ maxCoverage = coverage;
152
+ quickCLevel = cId;
153
+ }
154
+ }
155
+
156
+ if (quickCLevel) {
157
+ const quickRoles = [quickCLevel];
158
+ options.push({
159
+ id: 'quick',
160
+ name: '⚡ Quick',
161
+ description: `${orgTree.nodes.get(quickCLevel)?.name ?? quickCLevel} 팀만 투입. 빠르고 저비용.`,
162
+ roles: quickRoles,
163
+ roleDetails: buildRoleDetails(quickRoles),
164
+ totalAgents: totalAgents(quickRoles),
165
+ estimatedSpeed: 'fast',
166
+ });
167
+ }
168
+
169
+ // ── Standard: C-Levels that cover all needed roles ──
170
+ const standardCLevels: string[] = [];
171
+ const coveredRoles = new Set<string>();
172
+
173
+ for (const cId of cLevelIds) {
174
+ const descendants = new Set([cId, ...getDescendants(orgTree, cId)]);
175
+ const covers = [...neededRoles].filter(r => descendants.has(r) && !coveredRoles.has(r));
176
+ if (covers.length > 0) {
177
+ standardCLevels.push(cId);
178
+ covers.forEach(r => coveredRoles.add(r));
179
+ }
180
+ }
181
+
182
+ if (standardCLevels.length > 0 && standardCLevels.length < cLevelIds.length) {
183
+ options.push({
184
+ id: 'standard',
185
+ name: '⭐ Standard',
186
+ description: `필요 역할만 투입. ${standardCLevels.map(id => orgTree.nodes.get(id)?.name ?? id).join(' + ')}.`,
187
+ roles: standardCLevels,
188
+ roleDetails: buildRoleDetails(standardCLevels),
189
+ totalAgents: totalAgents(standardCLevels),
190
+ estimatedSpeed: 'medium',
191
+ });
192
+ }
193
+
194
+ // ── Full: all C-Levels ──
195
+ options.push({
196
+ id: 'full',
197
+ name: '🔥 Full Team',
198
+ description: '전체 팀 투입. 느리지만 가장 정교한 결과.',
199
+ roles: cLevelIds,
200
+ roleDetails: buildRoleDetails(cLevelIds),
201
+ totalAgents: totalAgents(cLevelIds),
202
+ estimatedSpeed: 'slow',
203
+ });
204
+
205
+ // Determine recommendation
206
+ let recommendedId: string;
207
+ if (complexity === 'simple' && options.find(o => o.id === 'quick')) {
208
+ recommendedId = 'quick';
209
+ } else if (complexity === 'complex') {
210
+ recommendedId = 'full';
211
+ } else {
212
+ recommendedId = options.find(o => o.id === 'standard')?.id ?? options[0].id;
213
+ }
214
+
215
+ // Mark recommended
216
+ for (const opt of options) {
217
+ opt.recommended = opt.id === recommendedId;
218
+ }
219
+
220
+ return { options, recommendedId };
221
+ }
222
+
223
+ /* ─── AI Classification ──────────────────────── */
224
+
225
+ interface AnalysisResult {
226
+ domains: string[];
227
+ complexity: 'simple' | 'moderate' | 'complex';
228
+ reasoning: string;
229
+ }
230
+
231
+ async function classifyDirective(text: string): Promise<AnalysisResult> {
232
+ try {
233
+ const config = readConfig(COMPANY_ROOT);
234
+ const engine = config.engine || process.env.EXECUTION_ENGINE || 'claude-cli';
235
+
236
+ let reply: string;
237
+ if (engine === 'claude-cli') {
238
+ const provider = new ClaudeCliProvider({ model: 'claude-haiku-4-5-20251001' });
239
+ const response = await provider.chat(
240
+ CLASSIFY_SYSTEM,
241
+ [{ role: 'user', content: text }],
242
+ );
243
+ reply = response.content.find(c => c.type === 'text')?.text?.trim() ?? '';
244
+ } else if (process.env.ANTHROPIC_API_KEY) {
245
+ const client = new Anthropic();
246
+ const response = await client.messages.create({
247
+ model: 'claude-haiku-4-5-20251001',
248
+ max_tokens: 200,
249
+ system: CLASSIFY_SYSTEM,
250
+ messages: [{ role: 'user', content: text }],
251
+ });
252
+ reply = (response.content[0] as { type: 'text'; text: string }).text.trim();
253
+ } else {
254
+ return classifyDirectiveFallback(text);
255
+ }
256
+
257
+ // Parse JSON response
258
+ const parsed = JSON.parse(reply) as AnalysisResult;
259
+ if (Array.isArray(parsed.domains) && parsed.complexity && parsed.reasoning) {
260
+ return parsed;
261
+ }
262
+ return classifyDirectiveFallback(text);
263
+ } catch (err) {
264
+ console.warn('[TeamRecommender] AI classification failed, using fallback:', err);
265
+ return classifyDirectiveFallback(text);
266
+ }
267
+ }
268
+
269
+ function classifyDirectiveFallback(text: string): AnalysisResult {
270
+ const t = text.toLowerCase();
271
+ const domains: string[] = [];
272
+
273
+ // Keyword-based domain detection
274
+ if (/코드|구현|개발|빌드|build|implement|develop|fix|bug|api|서버|deploy|refactor/.test(t)) domains.push('code');
275
+ if (/디자인|design|ui|ux|css|스타일|비주얼|visual|레이아웃/.test(t)) domains.push('design');
276
+ if (/기획|plan|prd|스펙|spec|기능\s*정의|요구사항|밸런/.test(t)) domains.push('planning');
277
+ if (/테스트|test|qa|검증|verify|플레이테스트/.test(t)) domains.push('testing');
278
+ if (/사업|business|매출|revenue|시장|market|경쟁|전략|strategy|pricing/.test(t)) domains.push('business');
279
+ if (/조사|research|분석|analy|리서치/.test(t)) domains.push('research');
280
+ if (/문서|doc|readme|작성|write|블로그|blog/.test(t)) domains.push('writing');
281
+
282
+ // Fallback: if no domain detected, assume code
283
+ if (domains.length === 0) domains.push('code');
284
+
285
+ // Complexity based on domain count and text length
286
+ let complexity: 'simple' | 'moderate' | 'complex';
287
+ if (domains.length <= 1 && t.length < 50) {
288
+ complexity = 'simple';
289
+ } else if (domains.length >= 3 || t.length > 200) {
290
+ complexity = 'complex';
291
+ } else {
292
+ complexity = 'moderate';
293
+ }
294
+
295
+ return {
296
+ domains,
297
+ complexity,
298
+ reasoning: `Detected ${domains.length} domain(s) via keyword matching: ${domains.join(', ')}`,
299
+ };
300
+ }
301
+
302
+ /* ─── Custom Teams CRUD ──────────────────────── */
303
+
304
+ function readTeamPresets(): TeamPresetsFile {
305
+ const filePath = TEAM_PRESETS_PATH();
306
+ if (!fs.existsSync(filePath)) return { teams: [] };
307
+ try {
308
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as TeamPresetsFile;
309
+ } catch {
310
+ return { teams: [] };
311
+ }
312
+ }
313
+
314
+ function writeTeamPresets(data: TeamPresetsFile): void {
315
+ const filePath = TEAM_PRESETS_PATH();
316
+ const dir = path.dirname(filePath);
317
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
318
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
319
+ }
320
+
321
+ export function getSavedTeams(): SavedTeam[] {
322
+ return readTeamPresets().teams;
323
+ }
324
+
325
+ export function saveCustomTeam(name: string, roles: string[]): SavedTeam {
326
+ const data = readTeamPresets();
327
+ const team: SavedTeam = {
328
+ id: `custom-${Date.now()}`,
329
+ name,
330
+ roles,
331
+ createdAt: new Date().toISOString(),
332
+ usageCount: 0,
333
+ };
334
+ data.teams.push(team);
335
+ writeTeamPresets(data);
336
+ return team;
337
+ }
338
+
339
+ export function deleteCustomTeam(teamId: string): boolean {
340
+ const data = readTeamPresets();
341
+ const idx = data.teams.findIndex(t => t.id === teamId);
342
+ if (idx === -1) return false;
343
+ data.teams.splice(idx, 1);
344
+ writeTeamPresets(data);
345
+ return true;
346
+ }
347
+
348
+ export function incrementTeamUsage(teamId: string): void {
349
+ const data = readTeamPresets();
350
+ const team = data.teams.find(t => t.id === teamId);
351
+ if (team) {
352
+ team.usageCount += 1;
353
+ writeTeamPresets(data);
354
+ }
355
+ }
356
+
357
+ /* ─── Main: Recommend ────────────────────────── */
358
+
359
+ export async function recommendTeam(directive: string): Promise<TeamRecommendation> {
360
+ // 1. Build org tree
361
+ const orgTree = buildOrgTree(COMPANY_ROOT);
362
+
363
+ // 2. Classify directive
364
+ const analysis = await classifyDirective(directive);
365
+
366
+ // 3. Map domains to needed roles
367
+ const neededRoles = mapDomainsToRoles(analysis.domains, orgTree);
368
+
369
+ // 4. Build team options
370
+ const { options, recommendedId } = buildOptions(orgTree, neededRoles, analysis.complexity);
371
+
372
+ // 5. Load custom teams
373
+ const customTeams = getSavedTeams();
374
+
375
+ return {
376
+ directive,
377
+ analysis,
378
+ options,
379
+ customTeams,
380
+ recommendedId,
381
+ };
382
+ }