tycono 0.1.56 → 0.1.57

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.1.56",
3
+ "version": "0.1.57",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  import { AnthropicProvider, type LLMProvider, type LLMMessage, type ToolResult, type MessageContent } from './llm-adapter.js';
2
2
  import { type OrgTree, getSubordinates } from './org-tree.js';
3
3
  import { assembleContext, type TeamStatus } from './context-assembler.js';
4
- import { validateDispatch } from './authority-validator.js';
4
+ import { validateDispatch, validateConsult } from './authority-validator.js';
5
5
  import { getToolsForRole } from './tools/definitions.js';
6
6
  import { executeTool, type ToolExecutorOptions } from './tools/executor.js';
7
7
  import { type TokenLedger } from '../services/token-ledger.js';
@@ -30,6 +30,7 @@ export interface AgentConfig {
30
30
  onText?: (text: string) => void;
31
31
  onToolExec?: (name: string, input: Record<string, unknown>) => void;
32
32
  onDispatch?: (roleId: string, task: string) => void;
33
+ onConsult?: (roleId: string, question: string) => void;
33
34
  onTurnComplete?: (turn: number) => void;
34
35
  }
35
36
 
@@ -56,6 +57,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
56
57
  onText,
57
58
  onToolExec,
58
59
  onDispatch: onDispatchCallback,
60
+ onConsult: onConsultCallback,
59
61
  onTurnComplete,
60
62
  } = config;
61
63
 
@@ -130,6 +132,47 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
130
132
  totalInput += subResult.totalTokens.input;
131
133
  totalOutput += subResult.totalTokens.output;
132
134
 
135
+ return subResult.output;
136
+ },
137
+ onConsult: async (targetRoleId: string, question: string) => {
138
+ // Authority check
139
+ const authResult = validateConsult(orgTree, roleId, targetRoleId);
140
+ if (!authResult.allowed) {
141
+ return `Consult rejected: ${authResult.reason}`;
142
+ }
143
+
144
+ // Circular consult detection
145
+ if (visitedRoles.has(targetRoleId)) {
146
+ return `[CONSULT BLOCKED] Circular consult detected: ${roleId} → ${targetRoleId}. Chain: ${[...visitedRoles].join(' → ')}`;
147
+ }
148
+
149
+ onConsultCallback?.(targetRoleId, question);
150
+
151
+ // Run sub-agent in read-only mode for the consulted role
152
+ const consultTask = `[Consultation from ${roleId}] ${question}\n\nAnswer this question based on your role's expertise and knowledge. Be concise and specific.`;
153
+ const subResult = await runAgentLoop({
154
+ companyRoot,
155
+ roleId: targetRoleId,
156
+ task: consultTask,
157
+ sourceRole: roleId,
158
+ orgTree,
159
+ readOnly: true, // Consult is always read-only
160
+ maxTurns: Math.min(maxTurns, 10), // Limit consult turns
161
+ llm,
162
+ depth: depth + 1,
163
+ visitedRoles: new Set(visitedRoles),
164
+ abortSignal,
165
+ jobId: config.jobId,
166
+ model: config.model,
167
+ tokenLedger: config.tokenLedger,
168
+ onText: (text) => onText?.(`[consult:${targetRoleId}] ${text}`),
169
+ onToolExec,
170
+ });
171
+
172
+ // Aggregate sub-agent tokens
173
+ totalInput += subResult.totalTokens.input;
174
+ totalOutput += subResult.totalTokens.output;
175
+
133
176
  return subResult.output;
134
177
  },
135
178
  };
@@ -1,4 +1,4 @@
1
- import { type OrgTree, canDispatchTo, getChainOfCommand } from './org-tree.js';
1
+ import { type OrgTree, canDispatchTo, canConsult, getChainOfCommand } from './org-tree.js';
2
2
 
3
3
  /* ─── Types ──────────────────────────────────── */
4
4
 
@@ -47,6 +47,37 @@ export function validateDispatch(
47
47
  return { allowed: true, reason: 'Dispatch authorized' };
48
48
  }
49
49
 
50
+ /**
51
+ * Validate whether a source role can consult (ask a question to) a target role.
52
+ * Allowed: peers (same parent), direct manager, or subordinates.
53
+ */
54
+ export function validateConsult(
55
+ orgTree: OrgTree,
56
+ sourceRole: string,
57
+ targetRole: string,
58
+ ): AuthResult {
59
+ if (sourceRole === targetRole) {
60
+ return { allowed: false, reason: `Cannot consult self (${sourceRole})` };
61
+ }
62
+
63
+ if (!orgTree.nodes.has(sourceRole) && sourceRole !== 'ceo') {
64
+ return { allowed: false, reason: `Source role not found: ${sourceRole}` };
65
+ }
66
+
67
+ if (!orgTree.nodes.has(targetRole)) {
68
+ return { allowed: false, reason: `Target role not found: ${targetRole}` };
69
+ }
70
+
71
+ if (!canConsult(orgTree, sourceRole, targetRole)) {
72
+ return {
73
+ allowed: false,
74
+ reason: `${sourceRole} cannot consult ${targetRole}. Only peers (same manager), direct manager, or subordinates are allowed.`,
75
+ };
76
+ }
77
+
78
+ return { allowed: true, reason: 'Consult authorized' };
79
+ }
80
+
50
81
  /**
51
82
  * Validate whether a role can perform a write operation to a given path.
52
83
  * Checks the knowledge.writes scope from role.yaml.
@@ -8,6 +8,7 @@ import {
8
8
  getSubordinates,
9
9
  getChainOfCommand,
10
10
  formatOrgChart,
11
+ canConsult,
11
12
  } from './org-tree.js';
12
13
 
13
14
  /* ─── Types ──────────────────────────────────── */
@@ -113,6 +114,12 @@ export function assembleContext(
113
114
  sections.push(buildDispatchSection(orgTree, roleId, subordinates, options?.teamStatus));
114
115
  }
115
116
 
117
+ // Consult 도구 안내 (상담 가능한 Role이 있는 경우)
118
+ const consultSection = buildConsultSection(orgTree, roleId);
119
+ if (consultSection) {
120
+ sections.push(consultSection);
121
+ }
122
+
116
123
  // Language preference
117
124
  const prefs = readPreferences(companyRoot);
118
125
  const lang = prefs.language ?? 'auto';
@@ -514,3 +521,48 @@ Every dispatch MUST include:
514
521
 
515
522
  return section;
516
523
  }
524
+
525
+ function buildConsultSection(orgTree: OrgTree, roleId: string): string | null {
526
+ // Build list of roles this agent can consult
527
+ const consultable: string[] = [];
528
+ for (const [id] of orgTree.nodes) {
529
+ if (id !== roleId && canConsult(orgTree, roleId, id)) {
530
+ consultable.push(id);
531
+ }
532
+ }
533
+
534
+ if (consultable.length === 0) return null;
535
+
536
+ const roleList = consultable.map((id) => {
537
+ const n = orgTree.nodes.get(id);
538
+ if (!n) return `- \`${id}\``;
539
+ const firstLine = n.persona.split('\n')[0] || n.name;
540
+ return `- **${n.name}** (\`${id}\`): ${firstLine}`;
541
+ }).join('\n');
542
+
543
+ return `# Consult (Ask Colleagues)
544
+
545
+ You can ask questions to other roles using the \`consult\` tool:
546
+
547
+ ${roleList}
548
+
549
+ ## How to Consult
550
+
551
+ Use the \`consult\` tool:
552
+ \`\`\`json
553
+ { "roleId": "designer", "question": "What color scheme are you using for the dashboard?" }
554
+ \`\`\`
555
+
556
+ The consulted role will answer your question in read-only mode and return the response to you.
557
+
558
+ ## When to Use
559
+ - Need technical decisions or clarifications from your manager
560
+ - Need design/implementation details from a peer
561
+ - Need domain expertise from another team member
562
+ - Unsure about architecture or conventions — ask before guessing
563
+
564
+ ## Rules
565
+ - The consulted role answers in **read-only mode** (no file modifications)
566
+ - Keep questions specific and concise for better answers
567
+ - Don't consult for tasks that should be dispatched (use dispatch for work assignments)`;
568
+ }
@@ -1,11 +1,11 @@
1
1
  // Context Engine — public API
2
- export { buildOrgTree, canDispatchTo, getSubordinates, getDescendants, getChainOfCommand, formatOrgChart, refreshOrgTree } from './org-tree.js';
2
+ export { buildOrgTree, canDispatchTo, canConsult, getSubordinates, getDescendants, getChainOfCommand, formatOrgChart, refreshOrgTree } from './org-tree.js';
3
3
  export type { OrgTree, OrgNode, Authority, KnowledgeAccess } from './org-tree.js';
4
4
 
5
5
  export { assembleContext } from './context-assembler.js';
6
6
  export type { AssembledContext } from './context-assembler.js';
7
7
 
8
- export { validateDispatch, validateWrite, validateRead } from './authority-validator.js';
8
+ export { validateDispatch, validateConsult, validateWrite, validateRead } from './authority-validator.js';
9
9
  export type { AuthResult } from './authority-validator.js';
10
10
 
11
11
  export { RoleLifecycleManager } from './role-lifecycle.js';
@@ -195,6 +195,24 @@ export function canDispatchTo(tree: OrgTree, source: string, target: string): bo
195
195
  return descendants.includes(target);
196
196
  }
197
197
 
198
+ /** Can source consult (ask a question to) target? Peers, direct manager, or subordinates. */
199
+ export function canConsult(tree: OrgTree, source: string, target: string): boolean {
200
+ if (source === target) return false;
201
+ const sourceNode = tree.nodes.get(source);
202
+ const targetNode = tree.nodes.get(target);
203
+ if (!sourceNode || !targetNode) return false;
204
+
205
+ // 1. Peers — same parent
206
+ if (sourceNode.reportsTo === targetNode.reportsTo) return true;
207
+
208
+ // 2. Direct manager
209
+ if (sourceNode.reportsTo === target) return true;
210
+
211
+ // 3. Subordinates (same as dispatch scope)
212
+ const descendants = getDescendants(tree, source);
213
+ return descendants.includes(target);
214
+ }
215
+
198
216
  /** Refresh tree (re-read all role.yaml files) */
199
217
  export function refreshOrgTree(companyRoot: string): OrgTree {
200
218
  return buildOrgTree(companyRoot);
@@ -116,6 +116,113 @@ else:
116
116
  log(f'Check result later: python3 "$DISPATCH_CMD" --check {job_id}')
117
117
  `;
118
118
 
119
+ /* ─── Consult Bridge Script (Python3) ────── */
120
+
121
+ const CONSULT_SCRIPT = `#!/usr/bin/env python3
122
+ """consult-bridge: CLI runner가 다른 Role에게 질문하는 브릿지 스크립트.
123
+
124
+ 사용법:
125
+ consult <roleId> "<question>" — Job 시작 (readOnly) + 결과 대기
126
+ consult --check <jobId> — 완료된 Job 결과 조회
127
+
128
+ 환경변수:
129
+ CONSULT_API_URL — API 서버 URL (default: http://localhost:3001)
130
+ CONSULT_PARENT_JOB — 부모 Job ID (자동 설정)
131
+ CONSULT_SOURCE_ROLE — 현재 Role ID (자동 설정)
132
+ """
133
+ import sys, os, json, time, urllib.request, urllib.error
134
+ sys.stdout.reconfigure(line_buffering=True)
135
+
136
+ api = os.environ.get('CONSULT_API_URL', os.environ.get('DISPATCH_API_URL', 'http://localhost:3001'))
137
+
138
+ def log(msg):
139
+ print(msg, flush=True)
140
+
141
+ def get_result(job_id):
142
+ try:
143
+ history = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}/history', timeout=10).read())
144
+ events = history.get('events', [])
145
+ text_parts = []
146
+ for e in events:
147
+ if e['type'] == 'text':
148
+ text_parts.append(e['data'].get('text', ''))
149
+ elif e['type'] == 'job:error':
150
+ text_parts.append('\\nERROR: ' + e['data'].get('message', ''))
151
+ return ''.join(text_parts) or '(No text output)'
152
+ except Exception as e:
153
+ return f'ERROR: Failed to get result: {e}'
154
+
155
+ # Mode: --check <jobId>
156
+ if len(sys.argv) >= 3 and sys.argv[1] == '--check':
157
+ job_id = sys.argv[2]
158
+ try:
159
+ info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=10).read())
160
+ status = info.get('status', 'unknown')
161
+ if status == 'running':
162
+ log(f'Job {job_id} is still running. Try again later.')
163
+ else:
164
+ log(f'=== Job {job_id}: {status} ===')
165
+ log(get_result(job_id))
166
+ except Exception as e:
167
+ log(f'ERROR: {e}')
168
+ sys.exit(0)
169
+
170
+ # Mode: consult <roleId> "<question>"
171
+ if len(sys.argv) < 3:
172
+ log('Usage: consult <roleId> "<question>"')
173
+ log(' consult --check <jobId>')
174
+ sys.exit(1)
175
+
176
+ role_id = sys.argv[1]
177
+ question = ' '.join(sys.argv[2:])
178
+ parent_job = os.environ.get('CONSULT_PARENT_JOB', os.environ.get('DISPATCH_PARENT_JOB', ''))
179
+ source_role = os.environ.get('CONSULT_SOURCE_ROLE', os.environ.get('DISPATCH_SOURCE_ROLE', 'ceo'))
180
+
181
+ # Start job (readOnly + consult type)
182
+ task = f'[Consultation from {source_role}] {question}\\n\\nAnswer this question based on your role\\'s expertise and knowledge. Be concise and specific.'
183
+ body = json.dumps({
184
+ 'type': 'consult',
185
+ 'roleId': role_id,
186
+ 'task': task,
187
+ 'sourceRole': source_role,
188
+ 'readOnly': True,
189
+ 'parentJobId': parent_job if parent_job else None,
190
+ }).encode()
191
+
192
+ try:
193
+ req = urllib.request.Request(f'{api}/api/jobs', body, {'Content-Type': 'application/json'})
194
+ resp = json.loads(urllib.request.urlopen(req, timeout=10).read())
195
+ job_id = resp['jobId']
196
+ except Exception as e:
197
+ log(f'ERROR: Failed to start consult job: {e}')
198
+ sys.exit(1)
199
+
200
+ log(f'=== Consulting {role_id.upper()} ===')
201
+ log(f'Question: {question[:120]}')
202
+ log(f'Job ID: {job_id}')
203
+
204
+ # Wait for completion (max ~100s)
205
+ status = 'running'
206
+ waited = 0
207
+ while waited < 100:
208
+ try:
209
+ info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=5).read())
210
+ status = info.get('status', 'unknown')
211
+ if status in ('done', 'error'):
212
+ break
213
+ except Exception:
214
+ pass
215
+ time.sleep(3)
216
+ waited += 3
217
+
218
+ if status in ('done', 'error'):
219
+ log(f'\\n=== {role_id.upper()} Answer ({status}) ===')
220
+ log(get_result(job_id))
221
+ else:
222
+ log(f'\\n{role_id.upper()} is still thinking (waited {waited}s).')
223
+ log(f'Check result later: python3 "$CONSULT_CMD" --check {job_id}')
224
+ `;
225
+
119
226
  /* ─── Claude CLI Runner ──────────────────────── */
120
227
 
121
228
  /**
@@ -163,6 +270,10 @@ export class ClaudeCliRunner implements ExecutionRunner {
163
270
  fs.writeFileSync(dispatchScript, DISPATCH_SCRIPT, { mode: 0o755 });
164
271
  }
165
272
 
273
+ // Consult Bridge — available to ALL roles (not just managers)
274
+ const consultScript = path.join(tmpDir, `consult-${roleId}-${Date.now()}.py`);
275
+ fs.writeFileSync(consultScript, CONSULT_SCRIPT, { mode: 0o755 });
276
+
166
277
  // 5. Playwright MCP 설정 — 각 runner 인스턴스가 독립 브라우저 사용
167
278
  const runnerOutputDir = path.join(tmpDir, `playwright-${roleId}-${Date.now()}`);
168
279
  fs.mkdirSync(runnerOutputDir, { recursive: true });
@@ -207,6 +318,8 @@ export class ClaudeCliRunner implements ExecutionRunner {
207
318
  }
208
319
  // dispatch 명령어 경로를 PATH에 추가하지 않고 절대 경로로 사용
209
320
  cleanEnv.DISPATCH_CMD = dispatchScript;
321
+ cleanEnv.CONSULT_CMD = consultScript;
322
+ cleanEnv.CONSULT_SOURCE_ROLE = roleId;
210
323
 
211
324
  const modelName = config.model ?? 'claude-sonnet-4-5';
212
325
  // Use codeRoot as cwd if configured, otherwise fall back to companyRoot
@@ -245,6 +358,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
245
358
  resolved = true;
246
359
  try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
247
360
  try { fs.unlinkSync(dispatchScript); } catch { /* ignore */ }
361
+ try { fs.unlinkSync(consultScript); } catch { /* ignore */ }
248
362
  try { fs.rmSync(runnerOutputDir, { recursive: true, force: true }); } catch { /* ignore */ }
249
363
  resolve({
250
364
  output,
@@ -339,6 +453,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
339
453
  // 임시 파일 정리
340
454
  try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
341
455
  try { fs.unlinkSync(dispatchScript); } catch { /* ignore */ }
456
+ try { fs.unlinkSync(consultScript); } catch { /* ignore */ }
342
457
  try { fs.rmSync(runnerOutputDir, { recursive: true, force: true }); } catch { /* ignore */ }
343
458
 
344
459
  // 비정상 종료 시에도 결과 반환 (output이 있을 수 있으므로)
@@ -356,6 +471,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
356
471
  resolved = true;
357
472
  try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
358
473
  try { fs.unlinkSync(dispatchScript); } catch { /* ignore */ }
474
+ try { fs.unlinkSync(consultScript); } catch { /* ignore */ }
359
475
  try { fs.rmSync(runnerOutputDir, { recursive: true, force: true }); } catch { /* ignore */ }
360
476
  reject(err);
361
477
  });
@@ -44,6 +44,7 @@ export class DirectApiRunner implements ExecutionRunner {
44
44
  onText: (text) => callbacks.onText?.(text),
45
45
  onToolExec: (name, input) => callbacks.onToolUse?.(name, input),
46
46
  onDispatch: (roleId, task) => callbacks.onDispatch?.(roleId, task),
47
+ onConsult: (roleId, question) => callbacks.onConsult?.(roleId, question),
47
48
  onTurnComplete: (turn) => callbacks.onTurnComplete?.(turn),
48
49
  }).then((agentResult): RunnerResult => ({
49
50
  output: agentResult.output,
@@ -46,6 +46,7 @@ export interface RunnerCallbacks {
46
46
  onThinking?: (text: string) => void;
47
47
  onToolUse?: (tool: string, input?: Record<string, unknown>) => void;
48
48
  onDispatch?: (roleId: string, task: string) => void;
49
+ onConsult?: (roleId: string, question: string) => void;
49
50
  onTurnComplete?: (turn: number) => void;
50
51
  onError?: (error: string) => void;
51
52
  }
@@ -89,6 +89,22 @@ export const DISPATCH_TOOL: ToolDefinition = {
89
89
  },
90
90
  };
91
91
 
92
+ /**
93
+ * 상담 도구 — 모든 Role에게 제공 (동료/상관/부하에게 질문)
94
+ */
95
+ export const CONSULT_TOOL: ToolDefinition = {
96
+ name: 'consult',
97
+ description: 'Ask a question to another role (peer, manager, or subordinate) and wait for their answer. The consulted role will respond in read-only mode. Use when you need information, expertise, or a decision from a colleague.',
98
+ input_schema: {
99
+ type: 'object',
100
+ properties: {
101
+ roleId: { type: 'string', description: 'Target role ID to consult (e.g., "designer", "cto")' },
102
+ question: { type: 'string', description: 'The question to ask' },
103
+ },
104
+ required: ['roleId', 'question'],
105
+ },
106
+ };
107
+
92
108
  /**
93
109
  * Role에 따른 도구 목록 반환
94
110
  */
@@ -97,7 +113,7 @@ export function getToolsForRole(hasSubordinates: boolean, readOnly: boolean): To
97
113
  return [...READ_TOOLS];
98
114
  }
99
115
 
100
- const tools = [...READ_TOOLS, ...WRITE_TOOLS];
116
+ const tools = [...READ_TOOLS, ...WRITE_TOOLS, CONSULT_TOOL];
101
117
 
102
118
  if (hasSubordinates) {
103
119
  tools.push(DISPATCH_TOOL);
@@ -12,6 +12,7 @@ export interface ToolExecutorOptions {
12
12
  roleId: string;
13
13
  orgTree: OrgTree;
14
14
  onDispatch?: (roleId: string, task: string) => Promise<string>;
15
+ onConsult?: (roleId: string, question: string) => Promise<string>;
15
16
  onToolExec?: (name: string, input: Record<string, unknown>) => void;
16
17
  }
17
18
 
@@ -21,7 +22,7 @@ export async function executeTool(
21
22
  toolCall: ToolCall,
22
23
  options: ToolExecutorOptions,
23
24
  ): Promise<ToolResult> {
24
- const { companyRoot, roleId, orgTree, onDispatch, onToolExec } = options;
25
+ const { companyRoot, roleId, orgTree, onDispatch, onConsult, onToolExec } = options;
25
26
  const { id, name, input } = toolCall;
26
27
 
27
28
  onToolExec?.(name, input);
@@ -40,6 +41,8 @@ export async function executeTool(
40
41
  return editFile(id, input, companyRoot, roleId, orgTree);
41
42
  case 'dispatch':
42
43
  return await dispatchTask(id, input, onDispatch);
44
+ case 'consult':
45
+ return await consultTask(id, input, onConsult);
43
46
  default:
44
47
  return { tool_use_id: id, content: `Unknown tool: ${name}`, is_error: true };
45
48
  }
@@ -286,3 +289,23 @@ async function dispatchTask(
286
289
  const result = await onDispatch(roleId, task);
287
290
  return { tool_use_id: id, content: result };
288
291
  }
292
+
293
+ async function consultTask(
294
+ id: string,
295
+ input: Record<string, unknown>,
296
+ onConsult?: (roleId: string, question: string) => Promise<string>,
297
+ ): Promise<ToolResult> {
298
+ const roleId = String(input.roleId ?? '');
299
+ const question = String(input.question ?? '');
300
+
301
+ if (!roleId || !question) {
302
+ return { tool_use_id: id, content: 'Error: roleId and question are required', is_error: true };
303
+ }
304
+
305
+ if (!onConsult) {
306
+ return { tool_use_id: id, content: 'Error: consult not available in this context', is_error: true };
307
+ }
308
+
309
+ const result = await onConsult(roleId, question);
310
+ return { tool_use_id: id, content: result };
311
+ }
@@ -292,7 +292,9 @@ function handleReplyToJob(jobId: string, body: Record<string, unknown>, res: Ser
292
292
  return;
293
293
  }
294
294
 
295
- const newJob = jobManager.replyToJob(jobId, message);
295
+ const responderRole = body.responderRole as string | undefined;
296
+
297
+ const newJob = jobManager.replyToJob(jobId, message, responderRole);
296
298
  if (!newJob) {
297
299
  jsonResponse(res, 400, { error: 'Job not found or not awaiting input' });
298
300
  return;
@@ -7,18 +7,39 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
 
10
+ export interface ConversationLimits {
11
+ /** Harness 레벨 경고 턴 수 (기본 50). 도달 시 turn:warning 이벤트 발생. */
12
+ softLimit: number;
13
+ /** Harness 레벨 강제 종료 턴 수 (기본 200). 도달 시 Runner abort. */
14
+ hardLimit: number;
15
+ }
16
+
10
17
  export interface CompanyConfig {
11
18
  engine: 'claude-cli' | 'direct-api';
12
19
  model?: string;
13
20
  apiKey?: string;
14
21
  codeRoot?: string; // 코드 프로젝트 경로 (AKB와 분리된 코드 repo)
22
+ conversationLimits?: Partial<ConversationLimits>;
15
23
  }
16
24
 
17
25
  export const TYCONO_DIR = '.tycono';
18
26
  const CONFIG_DIR = TYCONO_DIR;
19
27
  const CONFIG_FILE = 'config.json';
28
+ const DEFAULT_CONVERSATION_LIMITS: ConversationLimits = {
29
+ softLimit: 50,
30
+ hardLimit: 200,
31
+ };
32
+
20
33
  const DEFAULT_CONFIG: CompanyConfig = { engine: 'claude-cli' };
21
34
 
35
+ /** Resolve conversation limits with defaults. */
36
+ export function getConversationLimits(config: CompanyConfig): ConversationLimits {
37
+ return {
38
+ ...DEFAULT_CONVERSATION_LIMITS,
39
+ ...config.conversationLimits,
40
+ };
41
+ }
42
+
22
43
  function configPath(companyRoot: string): string {
23
44
  return path.join(companyRoot, CONFIG_DIR, CONFIG_FILE);
24
45
  }