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.
- package/bin/tycono.ts +13 -4
- package/package.json +1 -1
- package/src/api/src/create-server.ts +5 -1
- package/src/api/src/engine/agent-loop.ts +17 -6
- package/src/api/src/engine/context-assembler.ts +156 -48
- package/src/api/src/engine/knowledge-gate.ts +335 -0
- package/src/api/src/engine/llm-adapter.ts +7 -1
- package/src/api/src/engine/runners/claude-cli.ts +98 -116
- package/src/api/src/engine/runners/types.ts +2 -0
- package/src/api/src/engine/tools/executor.ts +3 -5
- package/src/api/src/routes/active-sessions.ts +143 -0
- package/src/api/src/routes/coins.ts +137 -0
- package/src/api/src/routes/execute.ts +158 -48
- package/src/api/src/routes/knowledge.ts +30 -0
- package/src/api/src/routes/operations.ts +48 -11
- package/src/api/src/routes/sessions.ts +1 -1
- package/src/api/src/routes/setup.ts +68 -1
- package/src/api/src/routes/speech.ts +334 -143
- package/src/api/src/services/activity-stream.ts +1 -1
- package/src/api/src/services/job-manager.ts +185 -9
- package/src/api/src/services/port-registry.ts +222 -0
- package/src/api/src/services/scaffold.ts +90 -0
- package/src/api/src/services/session-store.ts +75 -5
- package/src/web/dist/assets/index-BDLT2xew.js +109 -0
- package/src/web/dist/assets/index-LvS5V8aP.css +1 -0
- package/src/web/dist/assets/{preview-app-qIFqrb-y.js → preview-app-AJtyaM6L.js} +1 -1
- package/src/web/dist/index.html +2 -2
- package/templates/skills/_manifest.json +6 -0
- package/templates/skills/agent-browser/SKILL.md +159 -0
- package/templates/skills/agent-browser/meta.json +19 -0
- package/templates/teams/agency.json +3 -3
- package/templates/teams/research.json +3 -3
- package/templates/teams/startup.json +3 -3
- package/src/web/dist/assets/index-B3dNhn76.js +0 -101
- 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 시작 + 완료 대기 (최대
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
+
info = get_job_info(job_id)
|
|
83
|
+
status = info.get('status', 'unknown')
|
|
101
84
|
if status == 'running':
|
|
102
|
-
log(f'
|
|
85
|
+
log(f'Status: RUNNING — {job_id} is still working. Check again in 10-30s.')
|
|
103
86
|
elif status == 'awaiting_input':
|
|
104
|
-
log(f'
|
|
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'
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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, {
|