tycono 0.1.65 → 0.1.66

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-BDLT2xew.js +109 -0
  25. package/src/web/dist/assets/index-LvS5V8aP.css +1 -0
  26. package/src/web/dist/assets/{preview-app-qIFqrb-y.js → preview-app-AJtyaM6L.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
@@ -0,0 +1,335 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { glob } from 'glob';
4
+
5
+ /* ─── Types ──────────────────────────────────── */
6
+
7
+ export interface RelatedDoc {
8
+ path: string;
9
+ matches: number;
10
+ preview: string;
11
+ }
12
+
13
+ export interface KnowledgeDebtItem {
14
+ type: 'missing-crosslink' | 'missing-hub' | 'stale-doc' | 'orphan-doc' | 'broken-link';
15
+ file: string;
16
+ message: string;
17
+ }
18
+
19
+ export interface PostKnowledgingResult {
20
+ pass: boolean;
21
+ debt: KnowledgeDebtItem[];
22
+ newDocs: string[];
23
+ modifiedDocs: string[];
24
+ }
25
+
26
+ export interface DecayReport {
27
+ health: number;
28
+ orphanDocs: string[];
29
+ brokenLinks: Array<{ file: string; link: string }>;
30
+ totalDocs: number;
31
+ linkedDocs: number;
32
+ }
33
+
34
+ /* ─── Pre-Knowledging: Keyword Extraction ────── */
35
+
36
+ /** Extract meaningful keywords from task directive for knowledge search */
37
+ export function extractKeywords(text: string): string[] {
38
+ // Remove common stop words and short words
39
+ const stopWords = new Set([
40
+ // English
41
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
42
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
43
+ 'should', 'may', 'might', 'shall', 'can', 'need', 'must', 'to', 'of',
44
+ 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through',
45
+ 'and', 'but', 'or', 'not', 'no', 'if', 'then', 'else', 'when', 'up',
46
+ 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some',
47
+ 'such', 'than', 'too', 'very', 'just', 'about', 'above', 'after',
48
+ 'this', 'that', 'these', 'those', 'it', 'its', 'my', 'your', 'our',
49
+ 'what', 'which', 'who', 'how', 'use', 'make', 'get', 'set',
50
+ // Korean common particles/verbs
51
+ '해', '하고', '하는', '해줘', '해라', '하세요', '합니다', '된', '되는',
52
+ '이', '그', '저', '것', '거', '을', '를', '에', '에서', '으로', '로',
53
+ '와', '과', '는', '은', '가', '의', '도', '만', '좀', '더',
54
+ // Task-specific
55
+ 'ceo', 'wave', 'continuation', 'previous', 'context', 'response',
56
+ 'read', 'write', 'file', 'update', 'check', 'implement',
57
+ ]);
58
+
59
+ // Strip markdown, brackets, special chars
60
+ const cleaned = text
61
+ .replace(/\[.*?\]/g, ' ')
62
+ .replace(/[#*`_\->\[\](){}|]/g, ' ')
63
+ .replace(/https?:\/\/\S+/g, ' ')
64
+ .replace(/[^\w\sㄱ-힣]/g, ' ');
65
+
66
+ const words = cleaned
67
+ .split(/\s+/)
68
+ .map(w => w.toLowerCase().trim())
69
+ .filter(w => w.length >= 3 && !stopWords.has(w));
70
+
71
+ // Deduplicate and take top keywords by frequency
72
+ const freq = new Map<string, number>();
73
+ for (const w of words) {
74
+ freq.set(w, (freq.get(w) ?? 0) + 1);
75
+ }
76
+
77
+ return [...freq.entries()]
78
+ .sort((a, b) => b[1] - a[1])
79
+ .slice(0, 8)
80
+ .map(([word]) => word);
81
+ }
82
+
83
+ /* ─── Pre-Knowledging: Related Doc Search ────── */
84
+
85
+ /** Search knowledge/ and architecture/ for docs related to given keywords */
86
+ export function searchRelatedDocs(companyRoot: string, keywords: string[]): RelatedDoc[] {
87
+ if (keywords.length === 0) return [];
88
+
89
+ const searchDirs = ['knowledge', 'architecture', 'projects'];
90
+ const results: RelatedDoc[] = [];
91
+
92
+ for (const dir of searchDirs) {
93
+ const dirPath = path.join(companyRoot, dir);
94
+ if (!fs.existsSync(dirPath)) continue;
95
+
96
+ const files = glob.sync('**/*.md', {
97
+ cwd: dirPath,
98
+ ignore: ['**/journal/**'],
99
+ });
100
+
101
+ for (const file of files) {
102
+ const filePath = path.join(dirPath, file);
103
+ try {
104
+ const content = fs.readFileSync(filePath, 'utf-8');
105
+ const lowerContent = content.toLowerCase();
106
+
107
+ let matches = 0;
108
+ for (const kw of keywords) {
109
+ // Count occurrences (case insensitive)
110
+ const regex = new RegExp(kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
111
+ const found = lowerContent.match(regex);
112
+ if (found) matches += found.length;
113
+ }
114
+
115
+ if (matches >= 2) {
116
+ // Extract title from first heading
117
+ const titleMatch = content.match(/^#\s+(.+)/m);
118
+ const title = titleMatch ? titleMatch[1].trim() : file;
119
+ const relativePath = path.join(dir, file);
120
+
121
+ results.push({
122
+ path: relativePath,
123
+ matches,
124
+ preview: title,
125
+ });
126
+ }
127
+ } catch {
128
+ // Skip unreadable files
129
+ }
130
+ }
131
+ }
132
+
133
+ // Sort by match count descending, take top 5
134
+ return results
135
+ .sort((a, b) => b.matches - a.matches)
136
+ .slice(0, 5);
137
+ }
138
+
139
+ /* ─── Knowledge Gate: Auto-search on new .md ─── */
140
+
141
+ /** Build an enhanced AKB warning with auto-search results for a new .md file */
142
+ export function buildKnowledgeGateWarning(
143
+ companyRoot: string,
144
+ filePath: string,
145
+ content: string,
146
+ ): string {
147
+ // Extract keywords from file name + first 5 lines
148
+ const fileName = path.basename(filePath, '.md').replace(/[-_]/g, ' ');
149
+ const firstLines = content.split('\n').slice(0, 5).join(' ');
150
+ const keywords = extractKeywords(`${fileName} ${firstLines}`);
151
+
152
+ const related = searchRelatedDocs(companyRoot, keywords);
153
+
154
+ let warning = '\n\n[AKB Knowledge Gate] 새 .md 파일입니다.\n';
155
+
156
+ if (related.length > 0) {
157
+ warning += '\n📚 관련 문서 발견:\n';
158
+ for (const doc of related) {
159
+ warning += ` - ${doc.path} — "${doc.preview}" (${doc.matches} matches)\n`;
160
+ }
161
+ warning += '\n→ 70%+ 중복이면 기존 문서에 추가하세요.\n';
162
+ warning += '→ 새 문서라면 반드시:\n';
163
+ } else {
164
+ warning += '\n관련 문서를 찾지 못했습니다. 새 문서 생성이 적절합니다.\n';
165
+ warning += '반드시:\n';
166
+ }
167
+
168
+ warning += ' (1) 관련 문서 섹션에 cross-link를 추가하세요\n';
169
+ warning += ' (2) 해당 폴더의 Hub 파일에 등록하세요\n';
170
+
171
+ return warning;
172
+ }
173
+
174
+ /* ─── Post-Knowledging: Verification ─────────── */
175
+
176
+ /** Check if a .md file has a cross-link section with at least 1 link */
177
+ export function hasCrossLinks(content: string): boolean {
178
+ // Look for "관련 문서" or "Related" section with markdown links
179
+ const crossLinkSection = content.match(/##\s*(관련 문서|Related|References|See Also)/i);
180
+ if (!crossLinkSection) return false;
181
+
182
+ // Check for at least one markdown link after the section header
183
+ const sectionStart = content.indexOf(crossLinkSection[0]);
184
+ const afterSection = content.slice(sectionStart);
185
+ return /\[.+?\]\(.+?\)/.test(afterSection);
186
+ }
187
+
188
+ /** Check if a file is registered in its folder's Hub document */
189
+ export function isRegisteredInHub(companyRoot: string, filePath: string): boolean {
190
+ const dir = path.dirname(filePath);
191
+ const dirName = path.basename(dir);
192
+ const hubPath = path.join(companyRoot, dir, `${dirName}.md`);
193
+
194
+ if (!fs.existsSync(hubPath)) return true; // No hub = no enforcement
195
+
196
+ const hubContent = fs.readFileSync(hubPath, 'utf-8');
197
+ const fileName = path.basename(filePath);
198
+
199
+ // Check if the file is mentioned in the hub (by filename or relative path)
200
+ return hubContent.includes(fileName) || hubContent.includes(`./${fileName}`);
201
+ }
202
+
203
+ /** Run Post-Knowledging checks on changed files */
204
+ export function postKnowledgingCheck(
205
+ companyRoot: string,
206
+ changedFiles: string[],
207
+ ): PostKnowledgingResult {
208
+ const debt: KnowledgeDebtItem[] = [];
209
+ const newDocs: string[] = [];
210
+ const modifiedDocs: string[] = [];
211
+
212
+ for (const file of changedFiles) {
213
+ // Only check .md files (skip journals)
214
+ if (!file.endsWith('.md') || file.includes('journal/')) continue;
215
+
216
+ const absolute = path.resolve(companyRoot, file);
217
+ if (!fs.existsSync(absolute)) continue;
218
+
219
+ const content = fs.readFileSync(absolute, 'utf-8');
220
+
221
+ // Categorize
222
+ // We can't tell new vs modified from just file list, so check if it's a knowledge/architecture doc
223
+ if (file.startsWith('knowledge/') || file.startsWith('architecture/') || file.startsWith('projects/')) {
224
+ modifiedDocs.push(file);
225
+ }
226
+
227
+ // Check cross-links
228
+ if (!hasCrossLinks(content)) {
229
+ debt.push({
230
+ type: 'missing-crosslink',
231
+ file,
232
+ message: `"${file}" has no cross-link section (## 관련 문서)`,
233
+ });
234
+ }
235
+
236
+ // Check Hub registration
237
+ if (!isRegisteredInHub(companyRoot, file)) {
238
+ debt.push({
239
+ type: 'missing-hub',
240
+ file,
241
+ message: `"${file}" is not registered in its Hub document`,
242
+ });
243
+ }
244
+ }
245
+
246
+ return {
247
+ pass: debt.length === 0,
248
+ debt,
249
+ newDocs,
250
+ modifiedDocs,
251
+ };
252
+ }
253
+
254
+ /* ─── Decay Detection ────────────────────────── */
255
+
256
+ /** Scan for orphan docs (not registered in Hub) and broken links */
257
+ export function detectDecay(companyRoot: string): DecayReport {
258
+ const searchDirs = ['knowledge', 'architecture'];
259
+ const orphanDocs: string[] = [];
260
+ const brokenLinks: Array<{ file: string; link: string }> = [];
261
+ let totalDocs = 0;
262
+ let linkedDocs = 0;
263
+
264
+ for (const dir of searchDirs) {
265
+ const dirPath = path.join(companyRoot, dir);
266
+ if (!fs.existsSync(dirPath)) continue;
267
+
268
+ const hubName = `${dir}.md`;
269
+ const hubPath = path.join(dirPath, hubName);
270
+ const hubContent = fs.existsSync(hubPath) ? fs.readFileSync(hubPath, 'utf-8') : '';
271
+
272
+ const files = glob.sync('*.md', { cwd: dirPath });
273
+
274
+ for (const file of files) {
275
+ if (file === hubName) continue; // Skip hub itself
276
+ totalDocs++;
277
+
278
+ // Check if registered in hub
279
+ if (hubContent && !hubContent.includes(file) && !hubContent.includes(`./${file}`)) {
280
+ orphanDocs.push(path.join(dir, file));
281
+ } else {
282
+ linkedDocs++;
283
+ }
284
+
285
+ // Check for broken links in the file
286
+ const filePath = path.join(dirPath, file);
287
+ try {
288
+ const content = fs.readFileSync(filePath, 'utf-8');
289
+ const linkRegex = /\[.*?\]\(\.\/(.*?\.md)\)/g;
290
+ let match;
291
+ while ((match = linkRegex.exec(content)) !== null) {
292
+ const linkedFile = match[1];
293
+ const linkedPath = path.join(dirPath, linkedFile);
294
+ if (!fs.existsSync(linkedPath)) {
295
+ // Also check if it's a relative path from parent
296
+ const parentLinkedPath = path.join(companyRoot, dir, linkedFile);
297
+ if (!fs.existsSync(parentLinkedPath)) {
298
+ brokenLinks.push({
299
+ file: path.join(dir, file),
300
+ link: linkedFile,
301
+ });
302
+ }
303
+ }
304
+ }
305
+
306
+ // Also check ../relative links
307
+ const parentLinkRegex = /\[.*?\]\(\.\.\/(.*?\.md)\)/g;
308
+ while ((match = parentLinkRegex.exec(content)) !== null) {
309
+ const linkedFile = match[1];
310
+ const linkedPath = path.join(companyRoot, linkedFile);
311
+ if (!fs.existsSync(linkedPath)) {
312
+ brokenLinks.push({
313
+ file: path.join(dir, file),
314
+ link: `../${linkedFile}`,
315
+ });
316
+ }
317
+ }
318
+ } catch {
319
+ // Skip unreadable
320
+ }
321
+ }
322
+ }
323
+
324
+ const health = totalDocs > 0
325
+ ? Math.round(((totalDocs - orphanDocs.length - brokenLinks.length) / totalDocs) * 100)
326
+ : 100;
327
+
328
+ return {
329
+ health: Math.max(0, Math.min(100, health)),
330
+ orphanDocs,
331
+ brokenLinks,
332
+ totalDocs,
333
+ linkedDocs,
334
+ };
335
+ }
@@ -52,12 +52,17 @@ export interface StreamCallbacks {
52
52
  * - AnthropicProvider: @anthropic-ai/sdk 기반 (기본)
53
53
  * - (향후) OpenAIProvider, OllamaProvider, MockProvider
54
54
  */
55
+ export interface ChatOptions {
56
+ maxTokens?: number;
57
+ }
58
+
55
59
  export interface LLMProvider {
56
60
  chat(
57
61
  systemPrompt: string,
58
62
  messages: LLMMessage[],
59
63
  tools?: ToolDefinition[],
60
64
  signal?: AbortSignal,
65
+ options?: ChatOptions,
61
66
  ): Promise<LLMResponse>;
62
67
 
63
68
  chatStream?(
@@ -89,10 +94,11 @@ export class AnthropicProvider implements LLMProvider {
89
94
  messages: LLMMessage[],
90
95
  tools?: ToolDefinition[],
91
96
  signal?: AbortSignal,
97
+ options?: ChatOptions,
92
98
  ): Promise<LLMResponse> {
93
99
  const params: Anthropic.MessageCreateParamsNonStreaming = {
94
100
  model: this.model,
95
- max_tokens: 8192,
101
+ max_tokens: options?.maxTokens ?? 8192,
96
102
  system: systemPrompt,
97
103
  messages: messages.map((m) => ({
98
104
  role: m.role,
@@ -16,7 +16,7 @@ const DISPATCH_SCRIPT = `#!/usr/bin/env python3
16
16
  3가지 모드:
17
17
  dispatch <roleId> "<task>" — Job 시작 (즉시 반환, 대기하지 않음)
18
18
  dispatch --check <jobId> — Job 상태 및 결과 조회
19
- dispatch --wait <roleId> "<task>" — Job 시작 + 완료 대기 (최대 90초)
19
+ dispatch --wait <roleId> "<task>" — Job 시작 + 완료 대기 (최대 300초)
20
20
 
21
21
  환경변수:
22
22
  DISPATCH_API_URL — API 서버 URL (default: http://localhost:3001)
@@ -31,23 +31,35 @@ api = os.environ.get('DISPATCH_API_URL', 'http://localhost:3001')
31
31
  def log(msg):
32
32
  print(msg, flush=True)
33
33
 
34
- def get_result(job_id):
35
- try:
36
- history = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}/history', timeout=10).read())
37
- events = history.get('events', [])
38
- text_parts = []
39
- for e in events:
40
- if e['type'] == 'text':
41
- text_parts.append(e['data'].get('text', ''))
42
- elif e['type'] == 'job:error':
43
- text_parts.append('\\nERROR: ' + e['data'].get('message', ''))
44
- return ''.join(text_parts) or '(No text output)'
45
- except Exception as e:
46
- return f'ERROR: Failed to get result: {e}'
34
+ def get_result(job_id, retries=3):
35
+ for attempt in range(retries):
36
+ try:
37
+ history = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}/history', timeout=10).read())
38
+ events = history.get('events', [])
39
+ text_parts = []
40
+ for e in events:
41
+ if e['type'] == 'text':
42
+ text_parts.append(e['data'].get('text', ''))
43
+ elif e['type'] == 'job:error':
44
+ text_parts.append('\\nERROR: ' + e['data'].get('message', ''))
45
+ result = ''.join(text_parts)
46
+ if result:
47
+ return result
48
+ if attempt < retries - 1:
49
+ log(f' Result empty, retrying in 2s... (attempt {attempt + 1}/{retries})')
50
+ time.sleep(2)
51
+ except Exception as e:
52
+ if attempt == retries - 1:
53
+ return f'ERROR: Failed to get result: {e}'
54
+ time.sleep(2)
55
+ return '(No text output — activity stream may still be writing. Check again with --check)'
56
+
57
+ def get_job_info(job_id):
58
+ info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=5).read())
59
+ return info
47
60
 
48
61
  def get_status(job_id):
49
- info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=5).read())
50
- return info.get('status', 'unknown')
62
+ return get_job_info(job_id).get('status', 'unknown')
51
63
 
52
64
  def start_job(role_id, task):
53
65
  parent_job = os.environ.get('DISPATCH_PARENT_JOB', '')
@@ -63,64 +75,38 @@ def start_job(role_id, task):
63
75
  resp = json.loads(urllib.request.urlopen(req, timeout=10).read())
64
76
  return resp['jobId']
65
77
 
66
- def poll_until_done(job_id, role_id, max_wait=90):
67
- waited = 0
68
- while waited < max_wait:
69
- try:
70
- status = get_status(job_id)
71
- if status == 'done':
72
- log('')
73
- log(f'=== {role_id.upper()} Result (done) ===')
74
- log(get_result(job_id))
75
- return
76
- elif status == 'error':
77
- log('')
78
- log(f'=== {role_id.upper()} Result (error) ===')
79
- log(get_result(job_id))
80
- return
81
- elif status == 'awaiting_input':
82
- log('')
83
- log(f'{role_id.upper()} is asking a question (awaiting_input).')
84
- log(f'Check details: python3 "$DISPATCH_CMD" --check {job_id}')
85
- return
86
- except Exception:
87
- pass
88
- time.sleep(5)
89
- waited += 5
90
- if waited % 15 == 0:
91
- log(f' ... {role_id} still working ({waited}s)')
92
- log('')
93
- log(f'{role_id.upper()} is still working after {waited}s.')
94
- log(f'Check result later: python3 "$DISPATCH_CMD" --check {job_id}')
95
-
96
78
  # Mode: --check <jobId>
97
79
  if len(sys.argv) >= 3 and sys.argv[1] == '--check':
98
80
  job_id = sys.argv[2]
99
81
  try:
100
- status = get_status(job_id)
82
+ info = get_job_info(job_id)
83
+ status = info.get('status', 'unknown')
101
84
  if status == 'running':
102
- log(f'Job {job_id} is still running. Try again later.')
85
+ log(f'Status: RUNNING — {job_id} is still working. Check again in 10-30s.')
103
86
  elif status == 'awaiting_input':
104
- log(f'Job {job_id} is awaiting input (subordinate asked a question).')
105
- log(get_result(job_id))
87
+ log(f'Status: AWAITING_INPUT subordinate has a question.')
88
+ log(info.get('output', '') or get_result(job_id))
89
+ elif status == 'done':
90
+ log(f'Status: DONE')
91
+ log(info.get('output', '') or get_result(job_id))
92
+ elif status == 'error':
93
+ log(f'Status: ERROR')
94
+ log(info.get('output', '') or get_result(job_id))
106
95
  else:
107
- log(f'=== Job {job_id}: {status} ===')
108
- log(get_result(job_id))
96
+ log(f'Status: {status}')
109
97
  except Exception as e:
110
98
  log(f'ERROR: {e}')
111
99
  sys.exit(0)
112
100
 
113
- # Mode: --wait <roleId> "<task>" (start + wait)
114
- wait_mode = False
101
+ # Mode: dispatch <roleId> "<task>" (always immediate return)
115
102
  args = sys.argv[1:]
103
+ # Accept --wait for backwards compat but ignore it (always async now)
116
104
  if args and args[0] == '--wait':
117
- wait_mode = True
118
105
  args = args[1:]
119
106
 
120
107
  # Usage check
121
108
  if len(args) < 2:
122
- log('Usage: dispatch <roleId> "<task>" — Start job (immediate return)')
123
- log(' dispatch --wait <roleId> "<task>" — Start job + wait for result')
109
+ log('Usage: dispatch <roleId> "<task>" — Start job (returns immediately)')
124
110
  log(' dispatch --check <jobId> — Check job status/result')
125
111
  subs = os.environ.get('DISPATCH_SUBORDINATES', '')
126
112
  if subs:
@@ -140,14 +126,11 @@ except Exception as e:
140
126
  log(f'=== Dispatched to {role_id.upper()} ===')
141
127
  log(f'Task: {task[:120]}')
142
128
  log(f'Job ID: {job_id}')
143
-
144
- if wait_mode:
145
- log(f'Waiting for completion...')
146
- poll_until_done(job_id, role_id)
147
- else:
148
- log('')
149
- log(f'Job started. Check result with:')
150
- log(f' python3 "$DISPATCH_CMD" --check {job_id}')
129
+ log(f'')
130
+ log(f'⛔ Job is running async. Use --check to poll for result:')
131
+ log(f' python3 "$DISPATCH_CMD" --check {job_id}')
132
+ log(f'')
133
+ log(f'DO NOT re-dispatch the same task. Poll with --check every 10-30s until status is DONE.')
151
134
  `;
152
135
 
153
136
  /* ─── Consult Bridge Script (Python3) ────── */
@@ -172,37 +155,50 @@ api = os.environ.get('CONSULT_API_URL', os.environ.get('DISPATCH_API_URL', 'http
172
155
  def log(msg):
173
156
  print(msg, flush=True)
174
157
 
175
- def get_result(job_id):
176
- try:
177
- history = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}/history', timeout=10).read())
178
- events = history.get('events', [])
179
- text_parts = []
180
- for e in events:
181
- if e['type'] == 'text':
182
- text_parts.append(e['data'].get('text', ''))
183
- elif e['type'] == 'job:error':
184
- text_parts.append('\\nERROR: ' + e['data'].get('message', ''))
185
- return ''.join(text_parts) or '(No text output)'
186
- except Exception as e:
187
- return f'ERROR: Failed to get result: {e}'
158
+ def get_result(job_id, retries=3):
159
+ for attempt in range(retries):
160
+ try:
161
+ history = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}/history', timeout=10).read())
162
+ events = history.get('events', [])
163
+ text_parts = []
164
+ for e in events:
165
+ if e['type'] == 'text':
166
+ text_parts.append(e['data'].get('text', ''))
167
+ elif e['type'] == 'job:error':
168
+ text_parts.append('\\nERROR: ' + e['data'].get('message', ''))
169
+ result = ''.join(text_parts)
170
+ if result:
171
+ return result
172
+ if attempt < retries - 1:
173
+ log(f' Result empty, retrying in 2s... (attempt {attempt + 1}/{retries})')
174
+ time.sleep(2)
175
+ except Exception as e:
176
+ if attempt == retries - 1:
177
+ return f'ERROR: Failed to get result: {e}'
178
+ time.sleep(2)
179
+ return '(No text output — activity stream may still be writing. Check again with --check)'
180
+
181
+ def get_job_info(job_id):
182
+ info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=5).read())
183
+ return info
188
184
 
189
185
  def get_status(job_id):
190
- info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=5).read())
191
- return info.get('status', 'unknown')
186
+ return get_job_info(job_id).get('status', 'unknown')
192
187
 
193
188
  # Mode: --check <jobId>
194
189
  if len(sys.argv) >= 3 and sys.argv[1] == '--check':
195
190
  job_id = sys.argv[2]
196
191
  try:
197
- status = get_status(job_id)
192
+ info = get_job_info(job_id)
193
+ status = info.get('status', 'unknown')
198
194
  if status == 'running':
199
195
  log(f'Job {job_id} is still running. Try again later.')
200
196
  elif status == 'awaiting_input':
201
197
  log(f'Job {job_id} is awaiting input.')
202
- log(get_result(job_id))
198
+ log(info.get('output', '') or get_result(job_id))
203
199
  else:
204
200
  log(f'=== Job {job_id}: {status} ===')
205
- log(get_result(job_id))
201
+ log(info.get('output', '') or get_result(job_id))
206
202
  except Exception as e:
207
203
  log(f'ERROR: {e}')
208
204
  sys.exit(0)
@@ -240,37 +236,11 @@ except Exception as e:
240
236
  log(f'=== Consulting {role_id.upper()} ===')
241
237
  log(f'Question: {question[:120]}')
242
238
  log(f'Job ID: {job_id}')
243
- log(f'Waiting for answer...')
244
-
245
- # Wait for completion (max ~90s consult is read-only, usually fast)
246
- waited = 0
247
- while waited < 90:
248
- try:
249
- status = get_status(job_id)
250
- if status == 'done':
251
- log('')
252
- log(f'=== {role_id.upper()} Answer ===')
253
- log(get_result(job_id))
254
- sys.exit(0)
255
- elif status == 'error':
256
- log('')
257
- log(f'=== {role_id.upper()} Error ===')
258
- log(get_result(job_id))
259
- sys.exit(1)
260
- elif status == 'awaiting_input':
261
- log('')
262
- log(f'{role_id.upper()} needs clarification. Check: python3 "$CONSULT_CMD" --check {job_id}')
263
- sys.exit(0)
264
- except Exception:
265
- pass
266
- time.sleep(5)
267
- waited += 5
268
- if waited % 15 == 0:
269
- log(f' ... {role_id} still thinking ({waited}s)')
270
-
271
- log('')
272
- log(f'{role_id.upper()} is still thinking after {waited}s.')
273
- log(f'Check result later: python3 "$CONSULT_CMD" --check {job_id}')
239
+ log(f'')
240
+ log(f'Consult job started. Use --check to get the answer:')
241
+ log(f' python3 "$CONSULT_CMD" --check {job_id}')
242
+ log(f'')
243
+ log(f'Poll every 10s until status is DONE.')
274
244
  `;
275
245
 
276
246
  /* ─── Claude CLI Runner ──────────────────────── */
@@ -286,7 +256,7 @@ log(f'Check result later: python3 "$CONSULT_CMD" --check {job_id}')
286
256
  */
287
257
  export class ClaudeCliRunner implements ExecutionRunner {
288
258
  execute(config: RunnerConfig, callbacks: RunnerCallbacks): RunnerHandle {
289
- const { companyRoot, roleId, task, sourceRole, orgTree, readOnly = false, teamStatus, attachments } = config;
259
+ const { companyRoot, roleId, task, sourceRole, orgTree, readOnly = false, teamStatus, attachments, targetRoles } = config;
290
260
 
291
261
  // Note: Claude CLI doesn't support inline image attachments.
292
262
  // Images will be ignored with a warning if passed.
@@ -295,7 +265,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
295
265
  }
296
266
 
297
267
  // 1. Context Assembly
298
- const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus });
268
+ const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus, targetRoles });
299
269
 
300
270
  // 2. System prompt를 임시 파일로 저장 (CLI arg 길이 제한 대비)
301
271
  const tmpDir = path.join(os.tmpdir(), 'tycono-engine');
@@ -354,6 +324,14 @@ export class ClaudeCliRunner implements ExecutionRunner {
354
324
  taskPrompt,
355
325
  ];
356
326
 
327
+ // Disallow Agent and Task tools to force use of dispatch bridge
328
+ // For roles with subordinates (C-Level), also disallow Edit/Write to enforce delegation
329
+ const disallowed = ['Agent', 'Task'];
330
+ if (subordinates.length > 0 && !readOnly) {
331
+ disallowed.push('Edit', 'Write', 'NotebookEdit');
332
+ }
333
+ args.push('--disallowed-tools', ...disallowed);
334
+
357
335
  // 7. 프로세스 생성 — 중첩 세션 방지를 위해 CLAUDECODE 환경변수 제거
358
336
  const cleanEnv = { ...process.env };
359
337
  delete cleanEnv.CLAUDECODE;
@@ -375,6 +353,10 @@ export class ClaudeCliRunner implements ExecutionRunner {
375
353
  // Use codeRoot as cwd if configured, otherwise fall back to companyRoot
376
354
  const companyConfig = readConfig(companyRoot);
377
355
  const cwd = companyConfig.codeRoot || companyRoot;
356
+
357
+ // Inject repo paths so agents never confuse repos
358
+ cleanEnv.TYCONO_CODE_ROOT = companyConfig.codeRoot || '';
359
+ cleanEnv.TYCONO_AKB_ROOT = companyRoot;
378
360
  console.log(`[Runner] Spawning claude -p: role=${roleId}, model=${modelName}, maxTurns=${maxTurns}, jobId=${config.jobId ?? 'none'}, cwd=${cwd}, subordinates=[${subordinates.join(',')}]`);
379
361
 
380
362
  const proc = spawn('claude', args, {