tycono-server 0.1.0-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.
Files changed (84) hide show
  1. package/bin/cli.js +35 -0
  2. package/bin/server.ts +160 -0
  3. package/package.json +50 -0
  4. package/src/api/package.json +31 -0
  5. package/src/api/src/create-app.ts +90 -0
  6. package/src/api/src/create-server.ts +251 -0
  7. package/src/api/src/engine/agent-loop.ts +738 -0
  8. package/src/api/src/engine/authority-validator.ts +149 -0
  9. package/src/api/src/engine/context-assembler.ts +912 -0
  10. package/src/api/src/engine/index.ts +27 -0
  11. package/src/api/src/engine/knowledge-gate.ts +365 -0
  12. package/src/api/src/engine/llm-adapter.ts +304 -0
  13. package/src/api/src/engine/org-tree.ts +270 -0
  14. package/src/api/src/engine/role-lifecycle.ts +369 -0
  15. package/src/api/src/engine/runners/claude-cli.ts +796 -0
  16. package/src/api/src/engine/runners/direct-api.ts +66 -0
  17. package/src/api/src/engine/runners/index.ts +30 -0
  18. package/src/api/src/engine/runners/types.ts +95 -0
  19. package/src/api/src/engine/skill-template.ts +134 -0
  20. package/src/api/src/engine/tools/definitions.ts +201 -0
  21. package/src/api/src/engine/tools/executor.ts +611 -0
  22. package/src/api/src/routes/active-sessions.ts +134 -0
  23. package/src/api/src/routes/coins.ts +153 -0
  24. package/src/api/src/routes/company.ts +57 -0
  25. package/src/api/src/routes/cost.ts +141 -0
  26. package/src/api/src/routes/engine.ts +220 -0
  27. package/src/api/src/routes/execute.ts +1075 -0
  28. package/src/api/src/routes/git.ts +211 -0
  29. package/src/api/src/routes/knowledge.ts +378 -0
  30. package/src/api/src/routes/operations.ts +309 -0
  31. package/src/api/src/routes/preferences.ts +63 -0
  32. package/src/api/src/routes/presets.ts +123 -0
  33. package/src/api/src/routes/projects.ts +82 -0
  34. package/src/api/src/routes/quests.ts +41 -0
  35. package/src/api/src/routes/roles.ts +112 -0
  36. package/src/api/src/routes/save.ts +152 -0
  37. package/src/api/src/routes/sessions.ts +288 -0
  38. package/src/api/src/routes/setup.ts +437 -0
  39. package/src/api/src/routes/skills.ts +357 -0
  40. package/src/api/src/routes/speech.ts +959 -0
  41. package/src/api/src/routes/supervision.ts +136 -0
  42. package/src/api/src/routes/sync.ts +165 -0
  43. package/src/api/src/server.ts +59 -0
  44. package/src/api/src/services/activity-stream.ts +184 -0
  45. package/src/api/src/services/activity-tracker.ts +115 -0
  46. package/src/api/src/services/claude-md-manager.ts +94 -0
  47. package/src/api/src/services/company-config.ts +115 -0
  48. package/src/api/src/services/database.ts +77 -0
  49. package/src/api/src/services/digest-engine.ts +313 -0
  50. package/src/api/src/services/execution-manager.ts +1036 -0
  51. package/src/api/src/services/file-reader.ts +77 -0
  52. package/src/api/src/services/git-save.ts +614 -0
  53. package/src/api/src/services/job-manager.ts +16 -0
  54. package/src/api/src/services/knowledge-importer.ts +466 -0
  55. package/src/api/src/services/markdown-parser.ts +173 -0
  56. package/src/api/src/services/port-registry.ts +222 -0
  57. package/src/api/src/services/preferences.ts +150 -0
  58. package/src/api/src/services/preset-loader.ts +149 -0
  59. package/src/api/src/services/pricing.ts +34 -0
  60. package/src/api/src/services/scaffold.ts +546 -0
  61. package/src/api/src/services/session-store.ts +340 -0
  62. package/src/api/src/services/supervisor-heartbeat.ts +897 -0
  63. package/src/api/src/services/team-recommender.ts +382 -0
  64. package/src/api/src/services/token-ledger.ts +127 -0
  65. package/src/api/src/services/wave-messages.ts +194 -0
  66. package/src/api/src/services/wave-multiplexer.ts +356 -0
  67. package/src/api/src/services/wave-tracker.ts +359 -0
  68. package/src/api/src/utils/role-level.ts +31 -0
  69. package/src/core/scaffolder.ts +620 -0
  70. package/src/shared/types.ts +224 -0
  71. package/templates/CLAUDE.md.tmpl +239 -0
  72. package/templates/company.md.tmpl +17 -0
  73. package/templates/gitignore.tmpl +28 -0
  74. package/templates/roles.md.tmpl +8 -0
  75. package/templates/skills/_manifest.json +23 -0
  76. package/templates/skills/agent-browser/SKILL.md +159 -0
  77. package/templates/skills/agent-browser/meta.json +19 -0
  78. package/templates/skills/akb-linter/SKILL.md +125 -0
  79. package/templates/skills/akb-linter/meta.json +12 -0
  80. package/templates/skills/knowledge-gate/SKILL.md +120 -0
  81. package/templates/skills/knowledge-gate/meta.json +12 -0
  82. package/templates/teams/agency.json +58 -0
  83. package/templates/teams/research.json +58 -0
  84. package/templates/teams/startup.json +58 -0
@@ -0,0 +1,738 @@
1
+ import { AnthropicProvider, type LLMProvider, type LLMMessage, type ToolResult, type MessageContent } from './llm-adapter.js';
2
+ import { type OrgTree, getSubordinates } from './org-tree.js';
3
+ import { assembleContext, type TeamStatus } from './context-assembler.js';
4
+ import { validateDispatch, validateConsult } from './authority-validator.js';
5
+ import { getToolsForRole } from './tools/definitions.js';
6
+ import { executeTool, type ToolExecutorOptions } from './tools/executor.js';
7
+ import { type TokenLedger } from '../services/token-ledger.js';
8
+ import { estimateCost } from '../services/pricing.js';
9
+ import { type ImageAttachment } from './runners/types.js';
10
+
11
+ /* ─── Types ──────────────────────────────────── */
12
+
13
+ export interface AgentConfig {
14
+ companyRoot: string;
15
+ roleId: string;
16
+ task: string;
17
+ sourceRole: string;
18
+ orgTree: OrgTree;
19
+ readOnly?: boolean;
20
+ maxTurns?: number;
21
+ codeRoot?: string; // EG-001: code project root for bash_execute
22
+ llm?: LLMProvider;
23
+ depth?: number; // Current dispatch depth (default 0)
24
+ visitedRoles?: Set<string>; // Circular dispatch detection
25
+ abortSignal?: AbortSignal; // Abort signal for cancellation
26
+ teamStatus?: TeamStatus; // Current team member statuses
27
+ sessionId: string; // D-014: Session ID for token tracking (required)
28
+ model?: string; // LLM model name for cost tracking
29
+ tokenLedger?: TokenLedger; // Token usage ledger (optional)
30
+ attachments?: ImageAttachment[]; // Image attachments for vision
31
+ targetRoles?: string[]; // Selective dispatch scope
32
+ presetId?: string; // Wave-scoped preset for knowledge injection
33
+ // Callbacks
34
+ onText?: (text: string) => void;
35
+ onToolExec?: (name: string, input: Record<string, unknown>) => void;
36
+ onDispatch?: (roleId: string, task: string) => void;
37
+ onConsult?: (roleId: string, question: string) => void;
38
+ onTurnComplete?: (turn: number) => void;
39
+ /** Trace: emitted when system prompt is assembled */
40
+ onPromptAssembled?: (systemPrompt: string, userTask: string) => void;
41
+ /** Supervision: abort a running session */
42
+ onAbortSession?: (sessionId: string) => boolean;
43
+ /** Supervision: amend a running session with new instructions */
44
+ onAmendSession?: (sessionId: string, instruction: string) => boolean;
45
+ }
46
+
47
+ export interface AgentResult {
48
+ output: string;
49
+ turns: number;
50
+ totalTokens: { input: number; output: number };
51
+ toolCalls: Array<{ name: string; input: Record<string, unknown> }>;
52
+ dispatches: Array<{ roleId: string; task: string; result: string }>;
53
+ }
54
+
55
+ /* ─── EG-006: Context Compression ────────────── */
56
+
57
+ /**
58
+ * Compress older messages to reduce token usage.
59
+ *
60
+ * SV-9 Enhancement: Zone-based compression for supervision sessions.
61
+ * - Zone A (pinned): first 2 messages (system prompt + original task + plan) — never compress
62
+ * - Zone B (rolling): middle messages — heartbeat ticks get aggressive compression
63
+ * - Zone C (recent): last 4 messages — preserve for LLM context
64
+ *
65
+ * Heartbeat-specific: consecutive quiet ticks are merged into a single line.
66
+ */
67
+ function compressMessages(messages: LLMMessage[]): void {
68
+ if (messages.length <= 6) return;
69
+
70
+ // Zone A: first 2, Zone C: last 4
71
+ const keepHead = 2;
72
+ const keepTail = 4;
73
+ const compressRange = messages.slice(keepHead, messages.length - keepTail);
74
+
75
+ // Track consecutive quiet heartbeat ticks for merging
76
+ let quietTickStart = -1;
77
+ let quietTickCount = 0;
78
+
79
+ for (let idx = 0; idx < compressRange.length; idx++) {
80
+ const msg = compressRange[idx];
81
+
82
+ if (typeof msg.content === 'string') {
83
+ // Check if this is a heartbeat quiet tick result
84
+ const isQuietTick = msg.content.includes('sessions progressing normally') && msg.content.includes('No anomalies');
85
+
86
+ if (isQuietTick) {
87
+ quietTickCount++;
88
+ if (quietTickStart === -1) quietTickStart = idx;
89
+
90
+ // Merge consecutive quiet ticks
91
+ if (quietTickCount > 1) {
92
+ msg.content = `[Quiet ticks merged: ${quietTickCount} ticks, no anomalies]`;
93
+ }
94
+ continue;
95
+ }
96
+
97
+ // Reset quiet tick counter on non-quiet content
98
+ quietTickStart = -1;
99
+ quietTickCount = 0;
100
+
101
+ // Truncate long text content
102
+ if (msg.content.length > 500) {
103
+ msg.content = msg.content.slice(0, 300) + '\n\n[... compressed ...]';
104
+ }
105
+ } else if (Array.isArray(msg.content)) {
106
+ for (let i = 0; i < msg.content.length; i++) {
107
+ const block = msg.content[i] as Record<string, unknown>;
108
+ if (block.type === 'tool_result') {
109
+ const content = typeof block.content === 'string' ? block.content : '';
110
+
111
+ // Heartbeat digest results: compress more aggressively
112
+ const isDigest = content.includes('Supervision Digest') || content.includes('sessions progressing normally');
113
+ const maxLen = isDigest ? 150 : 300;
114
+
115
+ if (content.length > maxLen) {
116
+ block.content = content.slice(0, maxLen - 50) + '\n[... compressed, was ' + content.length + ' chars]';
117
+ }
118
+ } else if (block.type === 'text' && typeof block.text === 'string' && block.text.length > 500) {
119
+ block.text = (block.text as string).slice(0, 300) + '\n[... compressed ...]';
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ /* ─── Agent Loop ─────────────────────────────── */
127
+
128
+ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
129
+ const {
130
+ companyRoot,
131
+ roleId,
132
+ task,
133
+ sourceRole,
134
+ orgTree,
135
+ readOnly = false,
136
+ maxTurns = 20,
137
+ abortSignal,
138
+ onText,
139
+ onToolExec,
140
+ onDispatch: onDispatchCallback,
141
+ onConsult: onConsultCallback,
142
+ onTurnComplete,
143
+ } = config;
144
+
145
+ // Depth and circular dispatch guard
146
+ const depth = config.depth ?? 0;
147
+ const visitedRoles = config.visitedRoles ?? new Set<string>();
148
+
149
+ // Depth limit check
150
+ if (depth >= 3) {
151
+ return {
152
+ output: `[DISPATCH BLOCKED] Max dispatch depth (3) exceeded. Role: ${roleId}`,
153
+ turns: 0,
154
+ totalTokens: { input: 0, output: 0 },
155
+ toolCalls: [],
156
+ dispatches: [],
157
+ };
158
+ }
159
+
160
+ // Mark current role as visited
161
+ visitedRoles.add(roleId);
162
+
163
+ const llm = config.llm ?? new AnthropicProvider();
164
+
165
+ // 1. Assemble context
166
+ const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus: config.teamStatus, targetRoles: config.targetRoles, presetId: config.presetId });
167
+
168
+ // Trace: capture assembled prompt for debugging
169
+ config.onPromptAssembled?.(context.systemPrompt, task);
170
+
171
+ // 2. Determine tools
172
+ const subordinates = getSubordinates(orgTree, roleId);
173
+ const hasBash = !readOnly && !!config.codeRoot;
174
+ const node = orgTree.nodes.get(roleId);
175
+ const heartbeatEnabled = node?.heartbeat?.enabled === true && subordinates.length > 0;
176
+ // Peers = other roles with the same reportsTo (same parent in org tree)
177
+ const parentId = node?.reportsTo;
178
+ const hasPeers = parentId
179
+ ? (getSubordinates(orgTree, parentId).filter(id => id !== roleId).length > 0)
180
+ : false;
181
+ const tools = getToolsForRole(subordinates.length > 0, readOnly, hasBash, heartbeatEnabled, hasPeers);
182
+
183
+ // 3. Set up tool executor
184
+ const toolExecOptions: ToolExecutorOptions = {
185
+ companyRoot,
186
+ roleId,
187
+ orgTree,
188
+ codeRoot: config.codeRoot,
189
+ sessionId: config.sessionId,
190
+ onToolExec,
191
+ onAbortSession: config.onAbortSession,
192
+ onAmendSession: config.onAmendSession,
193
+ onDispatch: async (targetRoleId: string, subTask: string) => {
194
+ // Recursive dispatch — validate, then run sub-agent
195
+ const authResult = validateDispatch(orgTree, roleId, targetRoleId);
196
+ if (!authResult.allowed) {
197
+ return `Dispatch rejected: ${authResult.reason}`;
198
+ }
199
+
200
+ // Circular dispatch detection
201
+ if (visitedRoles.has(targetRoleId)) {
202
+ return `[DISPATCH BLOCKED] Circular dispatch detected: ${roleId} → ${targetRoleId}. Chain: ${[...visitedRoles].join(' → ')}`;
203
+ }
204
+
205
+ onDispatchCallback?.(targetRoleId, subTask);
206
+
207
+ // Run sub-agent (recursive) — pass depth+1 and a copy of visitedRoles
208
+ const subResult = await runAgentLoop({
209
+ companyRoot,
210
+ roleId: targetRoleId,
211
+ task: subTask,
212
+ sourceRole: roleId,
213
+ orgTree,
214
+ readOnly: false,
215
+ maxTurns: Math.min(maxTurns, 15), // Limit sub-agent turns
216
+ codeRoot: config.codeRoot,
217
+ llm,
218
+ depth: depth + 1,
219
+ visitedRoles: new Set(visitedRoles), // Copy for parallel dispatch support
220
+ abortSignal,
221
+ sessionId: config.sessionId,
222
+ model: config.model,
223
+ tokenLedger: config.tokenLedger,
224
+ onText: (text) => onText?.(`[${targetRoleId}] ${text}`),
225
+ onToolExec,
226
+ });
227
+
228
+ // Aggregate sub-agent tokens into parent totals
229
+ totalInput += subResult.totalTokens.input;
230
+ totalOutput += subResult.totalTokens.output;
231
+
232
+ return subResult.output;
233
+ },
234
+ onConsult: async (targetRoleId: string, question: string) => {
235
+ // Authority check
236
+ const authResult = validateConsult(orgTree, roleId, targetRoleId);
237
+ if (!authResult.allowed) {
238
+ return `Consult rejected: ${authResult.reason}`;
239
+ }
240
+
241
+ // Circular consult detection
242
+ if (visitedRoles.has(targetRoleId)) {
243
+ return `[CONSULT BLOCKED] Circular consult detected: ${roleId} → ${targetRoleId}. Chain: ${[...visitedRoles].join(' → ')}`;
244
+ }
245
+
246
+ onConsultCallback?.(targetRoleId, question);
247
+
248
+ // Run sub-agent in read-only mode for the consulted role
249
+ const consultTask = `[Consultation from ${roleId}] ${question}\n\nAnswer this question based on your role's expertise and knowledge. Be concise and specific.`;
250
+ const subResult = await runAgentLoop({
251
+ companyRoot,
252
+ roleId: targetRoleId,
253
+ task: consultTask,
254
+ sourceRole: roleId,
255
+ orgTree,
256
+ readOnly: true, // Consult is always read-only
257
+ maxTurns: Math.min(maxTurns, 10), // Limit consult turns
258
+ llm,
259
+ depth: depth + 1,
260
+ visitedRoles: new Set(visitedRoles),
261
+ abortSignal,
262
+ sessionId: config.sessionId,
263
+ model: config.model,
264
+ tokenLedger: config.tokenLedger,
265
+ onText: (text) => onText?.(`[consult:${targetRoleId}] ${text}`),
266
+ onToolExec,
267
+ });
268
+
269
+ // Aggregate sub-agent tokens
270
+ totalInput += subResult.totalTokens.input;
271
+ totalOutput += subResult.totalTokens.output;
272
+
273
+ return subResult.output;
274
+ },
275
+ };
276
+
277
+ // 4. Run the loop
278
+ // Build initial user message with optional image attachments
279
+ const userContent: MessageContent[] = [];
280
+
281
+ // Add image attachments first (if any)
282
+ if (config.attachments && config.attachments.length > 0) {
283
+ for (const att of config.attachments) {
284
+ userContent.push({
285
+ type: 'image',
286
+ source: {
287
+ type: 'base64',
288
+ media_type: att.mediaType,
289
+ data: att.data,
290
+ },
291
+ } as unknown as MessageContent);
292
+ }
293
+ }
294
+
295
+ // Add text content
296
+ userContent.push({ type: 'text', text: task });
297
+
298
+ const messages: LLMMessage[] = [
299
+ { role: 'user', content: userContent.length === 1 ? task : userContent },
300
+ ];
301
+
302
+ let turns = 0;
303
+ let totalInput = 0;
304
+ let totalOutput = 0;
305
+ const allToolCalls: AgentResult['toolCalls'] = [];
306
+ const dispatches: AgentResult['dispatches'] = [];
307
+ const outputParts: string[] = [];
308
+
309
+ // EG-006/007: Context compression + token budget
310
+ const COMPRESS_THRESHOLD = 100_000;
311
+ const TOKEN_WARN_THRESHOLD = 200_000; // Warn at 200K total tokens
312
+ let tokenWarningEmitted = false;
313
+
314
+ while (turns < maxTurns) {
315
+ // Check abort signal before each turn
316
+ if (abortSignal?.aborted) break;
317
+
318
+ turns++;
319
+
320
+ // EG-006: Compress old messages when token budget exceeded
321
+ if (totalInput > COMPRESS_THRESHOLD && messages.length > 4) {
322
+ compressMessages(messages);
323
+ }
324
+
325
+ // Call LLM
326
+ const response = await llm.chat(context.systemPrompt, messages, tools, abortSignal);
327
+ totalInput += response.usage.inputTokens;
328
+ totalOutput += response.usage.outputTokens;
329
+
330
+ // EG-007: Token budget warning
331
+ if (!tokenWarningEmitted && (totalInput + totalOutput) > TOKEN_WARN_THRESHOLD) {
332
+ tokenWarningEmitted = true;
333
+ const cost = estimateCost(totalInput, totalOutput, config.model ?? 'unknown');
334
+ onText?.(`\n\n⚠️ [Token Budget Warning] This task has used ${totalInput.toLocaleString()} input + ${totalOutput.toLocaleString()} output tokens (~$${cost.toFixed(3)}). Consider wrapping up.\n\n`);
335
+ }
336
+
337
+ // Record token usage
338
+ config.tokenLedger?.record({
339
+ ts: new Date().toISOString(),
340
+ sessionId: config.sessionId,
341
+ roleId,
342
+ model: config.model ?? 'unknown',
343
+ inputTokens: response.usage.inputTokens,
344
+ outputTokens: response.usage.outputTokens,
345
+ });
346
+
347
+ // Process response content
348
+ const assistantContent: MessageContent[] = response.content;
349
+ messages.push({ role: 'assistant', content: assistantContent });
350
+
351
+ // Extract text parts
352
+ for (const block of response.content) {
353
+ if (block.type === 'text' && block.text) {
354
+ outputParts.push(block.text);
355
+ onText?.(block.text);
356
+ }
357
+ }
358
+
359
+ // If no tool use, we're done
360
+ if (response.stopReason === 'end_turn' || response.stopReason !== 'tool_use') {
361
+ break;
362
+ }
363
+
364
+ // Process tool calls
365
+ const toolCalls = response.content.filter(
366
+ (b): b is MessageContent & { type: 'tool_use' } => b.type === 'tool_use'
367
+ );
368
+
369
+ // EG-004: Parallel tool execution for independent tools
370
+ // dispatch/consult/heartbeat run sequentially (recursive agent calls / blocking)
371
+ // All other tools run in parallel via Promise.all()
372
+ const sequentialTools = new Set(['dispatch', 'consult', 'heartbeat_watch']);
373
+ const parallelCalls = toolCalls.filter(tc => !sequentialTools.has(tc.name));
374
+ const sequentialCalls = toolCalls.filter(tc => sequentialTools.has(tc.name));
375
+
376
+ // Record all tool calls
377
+ for (const tc of toolCalls) {
378
+ allToolCalls.push({ name: tc.name, input: tc.input });
379
+ }
380
+
381
+ // Run parallel tools concurrently
382
+ const parallelResults = await Promise.all(
383
+ parallelCalls.map(tc =>
384
+ executeTool({ id: tc.id, name: tc.name, input: tc.input }, toolExecOptions)
385
+ )
386
+ );
387
+
388
+ // Run sequential tools one by one
389
+ const sequentialResults: ToolResult[] = [];
390
+ for (const tc of sequentialCalls) {
391
+ const result = await executeTool(
392
+ { id: tc.id, name: tc.name, input: tc.input },
393
+ toolExecOptions,
394
+ );
395
+ sequentialResults.push(result);
396
+
397
+ // Track dispatches
398
+ if (tc.name === 'dispatch' && !result.is_error) {
399
+ dispatches.push({
400
+ roleId: String(tc.input.roleId),
401
+ task: String(tc.input.task),
402
+ result: result.content,
403
+ });
404
+ }
405
+ }
406
+
407
+ // EG-005: Merge results in original tool_use_id order
408
+ const resultMap = new Map<string, ToolResult>();
409
+ for (const r of [...parallelResults, ...sequentialResults]) {
410
+ resultMap.set(r.tool_use_id, r);
411
+ }
412
+ const toolResults = toolCalls.map(tc => resultMap.get(tc.id)!);
413
+
414
+ // Track dispatches from parallel results too
415
+ for (const tc of parallelCalls) {
416
+ if (tc.name === 'dispatch') {
417
+ const r = resultMap.get(tc.id)!;
418
+ if (!r.is_error) {
419
+ dispatches.push({
420
+ roleId: String(tc.input.roleId),
421
+ task: String(tc.input.task),
422
+ result: r.content,
423
+ });
424
+ }
425
+ }
426
+ }
427
+
428
+ // Send tool results back
429
+ messages.push({
430
+ role: 'user',
431
+ content: toolResults.map((r) => ({
432
+ type: 'tool_result' as const,
433
+ tool_use_id: r.tool_use_id,
434
+ content: r.content,
435
+ is_error: r.is_error,
436
+ })) as unknown as MessageContent[],
437
+ });
438
+
439
+ onTurnComplete?.(turns);
440
+ }
441
+
442
+ // ── Post-execution phases (depth 0 only) ──
443
+ if (!readOnly && depth === 0 && turns > 0) {
444
+ const node = orgTree.nodes.get(roleId);
445
+ const isCLevel = node?.level === 'c-level';
446
+
447
+ // Phase A: C-Level Supervision Loop — review dispatches, update knowledge, dispatch next
448
+ if (isCLevel && dispatches.length > 0) {
449
+ const dispatchSummary = dispatches.map((d, i) =>
450
+ `${i + 1}. **${d.roleId}**: "${d.task.slice(0, 80)}"\n Result: ${d.result.slice(0, 300)}`,
451
+ ).join('\n\n');
452
+
453
+ // Build list of already-dispatched tasks to prevent re-dispatch
454
+ const dispatchedList = dispatches.map(d => `- ${d.roleId}: "${d.task.slice(0, 100)}"`).join('\n');
455
+
456
+ const supervisionPrompt = [
457
+ '[SUPERVISION LOOP] Your subordinates have completed their tasks. Follow the C-Level Protocol:',
458
+ '',
459
+ '## Subordinate Results',
460
+ dispatchSummary,
461
+ '',
462
+ '## Already Dispatched (DO NOT re-dispatch these)',
463
+ dispatchedList,
464
+ '',
465
+ '⛔ **Do NOT re-dispatch the same or similar task to the same role.** If a subordinate already completed a task, accept the result and move on.',
466
+ '⛔ **If the result is satisfactory, do NOT dispatch again.** Only re-dispatch if the result clearly fails acceptance criteria with SPECIFIC feedback on what to fix.',
467
+ '',
468
+ '## Required Actions (do ALL of these):',
469
+ '',
470
+ '### 1. Review',
471
+ 'Does each result meet the acceptance criteria? If clearly unsatisfactory, re-dispatch with SPECIFIC fix instructions (not the same task again).',
472
+ '',
473
+ '### 2. Knowledge Update (The Loop Step ④)',
474
+ 'Record any new decisions, findings, or analysis in appropriate AKB documents:',
475
+ '- Update your journal (`roles/' + roleId + '/journal/`)',
476
+ '- Update relevant project docs if needed',
477
+ '- Update knowledge/ if there are reusable insights',
478
+ '',
479
+ '### 3. Task Update (The Loop Step ⑤)',
480
+ 'Update task status in the relevant tasks.md or project documents.',
481
+ 'Mark completed items as DONE. Identify the NEXT task to dispatch.',
482
+ '',
483
+ '### 4. Next Dispatch (ONLY if there is genuinely NEW work)',
484
+ 'If there are DIFFERENT remaining tasks (e.g., QA after Engineer, or a DIFFERENT backlog item):',
485
+ '- Dispatch the NEXT DIFFERENT task to the appropriate subordinate',
486
+ '- If all work from the directive is done, synthesize a final report for your superior',
487
+ '- **If the subordinate already completed the requested work, report success — do NOT re-dispatch**',
488
+ '',
489
+ 'Execute these actions now using your tools (Read, Edit, Bash, dispatch).',
490
+ ].join('\n');
491
+
492
+ // Run supervision loop (up to 3 additional rounds of tool use)
493
+ messages.push({ role: 'user', content: supervisionPrompt });
494
+ const maxSupervisionRounds = 3;
495
+ for (let round = 0; round < maxSupervisionRounds && turns < maxTurns; round++) {
496
+ if (abortSignal?.aborted) break;
497
+ turns++;
498
+
499
+ const supResponse = await llm.chat(context.systemPrompt, messages, tools, abortSignal);
500
+ totalInput += supResponse.usage.inputTokens;
501
+ totalOutput += supResponse.usage.outputTokens;
502
+ config.tokenLedger?.record({
503
+ ts: new Date().toISOString(),
504
+ sessionId: config.sessionId,
505
+ roleId,
506
+ model: config.model ?? 'unknown',
507
+ inputTokens: supResponse.usage.inputTokens,
508
+ outputTokens: supResponse.usage.outputTokens,
509
+ });
510
+
511
+ messages.push({ role: 'assistant', content: supResponse.content });
512
+ for (const block of supResponse.content) {
513
+ if (block.type === 'text' && block.text) {
514
+ outputParts.push(block.text);
515
+ onText?.(block.text);
516
+ }
517
+ }
518
+
519
+ // If no tool calls, supervision is done
520
+ if (supResponse.stopReason !== 'tool_use') break;
521
+
522
+ // Execute tool calls
523
+ const supToolCalls = supResponse.content.filter(
524
+ (b): b is MessageContent & { type: 'tool_use' } => b.type === 'tool_use',
525
+ );
526
+ const supResults: ToolResult[] = [];
527
+ for (const tc of supToolCalls) {
528
+ allToolCalls.push({ name: tc.name, input: tc.input });
529
+ const result = await executeTool(
530
+ { id: tc.id, name: tc.name, input: tc.input },
531
+ toolExecOptions,
532
+ );
533
+ supResults.push(result);
534
+
535
+ // Track additional dispatches from supervision
536
+ if (tc.name === 'dispatch' && !result.is_error) {
537
+ dispatches.push({
538
+ roleId: String(tc.input.roleId),
539
+ task: String(tc.input.task),
540
+ result: result.content,
541
+ });
542
+ }
543
+ }
544
+
545
+ messages.push({
546
+ role: 'user',
547
+ content: supResults.map((r) => ({
548
+ type: 'tool_result' as const,
549
+ tool_use_id: r.tool_use_id,
550
+ content: r.content,
551
+ is_error: r.is_error,
552
+ })) as unknown as MessageContent[],
553
+ });
554
+
555
+ onTurnComplete?.(turns);
556
+ }
557
+ }
558
+
559
+ // Detect file changes once — used by Phase B and Post-K
560
+ const hasFileChanges = allToolCalls.some((tc) =>
561
+ ['write', 'edit', 'bash'].includes(tc.name.toLowerCase()),
562
+ );
563
+
564
+ // Phase B: Member Self-Verification — type checking + visual verification
565
+ // Any non-C-Level role that made file changes gets verification (no hardcoded role IDs)
566
+ if (!isCLevel && hasFileChanges) {
567
+ const verifyPrompt = [
568
+ '[AUTO-VERIFICATION] 작업이 완료되었습니다. 아래 검증을 수행하세요:',
569
+ '1. `cd src/api && npx tsc --noEmit` — 타입 에러 확인',
570
+ '2. `cd src/web && npx tsc --noEmit` — 프론트엔드 타입 에러 확인',
571
+ '3. UI/CSS 변경이 있었다면 Playwright MCP로 스크린샷을 촬영하여 시각 검증',
572
+ '검증 결과를 간단히 보고하세요.',
573
+ ].join('\n');
574
+
575
+ messages.push({ role: 'user', content: verifyPrompt });
576
+
577
+ if (turns < maxTurns) {
578
+ turns++;
579
+ const verifyResponse = await llm.chat(context.systemPrompt, messages, tools, abortSignal);
580
+ totalInput += verifyResponse.usage.inputTokens;
581
+ totalOutput += verifyResponse.usage.outputTokens;
582
+ config.tokenLedger?.record({
583
+ ts: new Date().toISOString(),
584
+ sessionId: config.sessionId,
585
+ roleId,
586
+ model: config.model ?? 'unknown',
587
+ inputTokens: verifyResponse.usage.inputTokens,
588
+ outputTokens: verifyResponse.usage.outputTokens,
589
+ });
590
+
591
+ messages.push({ role: 'assistant', content: verifyResponse.content });
592
+ for (const block of verifyResponse.content) {
593
+ if (block.type === 'text' && block.text) {
594
+ outputParts.push(block.text);
595
+ onText?.(block.text);
596
+ }
597
+ }
598
+
599
+ // Execute verification tool calls if needed
600
+ if (verifyResponse.stopReason === 'tool_use') {
601
+ const verifyToolCalls = verifyResponse.content.filter(
602
+ (b): b is MessageContent & { type: 'tool_use' } => b.type === 'tool_use',
603
+ );
604
+ const verifyResults: ToolResult[] = [];
605
+ for (const tc of verifyToolCalls) {
606
+ allToolCalls.push({ name: tc.name, input: tc.input });
607
+ const result = await executeTool(
608
+ { id: tc.id, name: tc.name, input: tc.input },
609
+ toolExecOptions,
610
+ );
611
+ verifyResults.push(result);
612
+ }
613
+ messages.push({
614
+ role: 'user',
615
+ content: verifyResults.map((r) => ({
616
+ type: 'tool_result' as const,
617
+ tool_use_id: r.tool_use_id,
618
+ content: r.content,
619
+ is_error: r.is_error,
620
+ })) as unknown as MessageContent[],
621
+ });
622
+
623
+ if (turns < maxTurns) {
624
+ turns++;
625
+ const summaryResponse = await llm.chat(context.systemPrompt, messages, tools, abortSignal);
626
+ totalInput += summaryResponse.usage.inputTokens;
627
+ totalOutput += summaryResponse.usage.outputTokens;
628
+ config.tokenLedger?.record({
629
+ ts: new Date().toISOString(),
630
+ sessionId: config.sessionId,
631
+ roleId,
632
+ model: config.model ?? 'unknown',
633
+ inputTokens: summaryResponse.usage.inputTokens,
634
+ outputTokens: summaryResponse.usage.outputTokens,
635
+ });
636
+ for (const block of summaryResponse.content) {
637
+ if (block.type === 'text' && block.text) {
638
+ outputParts.push(block.text);
639
+ onText?.(block.text);
640
+ }
641
+ }
642
+ }
643
+ }
644
+
645
+ onTurnComplete?.(turns);
646
+ }
647
+ }
648
+
649
+ // ── Post-K: ④⑤ Knowledge/Task update (KP-008) ──
650
+ // ALL roles (C-Level and members) update journal/tasks after significant work.
651
+ // Runs for: members who made file changes, C-Level who dispatched work.
652
+ if (hasFileChanges || dispatches.length > 0) {
653
+ const postKPrompt = [
654
+ '[POST-KNOWLEDGING] 작업이 완료되었습니다. The Loop 마무리를 수행하세요:',
655
+ '',
656
+ '## ④ Knowledge 업데이트 (The Loop Step 4)',
657
+ '다음 중 해당하는 항목을 수행하세요:',
658
+ '- 본인 journal 업데이트 (`knowledge/roles/' + roleId + '/journal/YYYY-MM-DD.md` — 오늘 날짜 파일)',
659
+ '- 구현 중 새로 발견한 패턴/아키텍처 결정이 있다면 관련 문서 업데이트',
660
+ ' (예: knowledge/architecture/web-app-ia.md, knowledge/architecture/session-worktree-isolation.md 등)',
661
+ '- 중요한 기술 결정은 knowledge/decisions/ 또는 knowledge/architecture/ 반영',
662
+ '',
663
+ '## ⑤ Task 상태 갱신 (The Loop Step 5)',
664
+ '- `projects/tycono-platform/tasks.md` (또는 관련 tasks 파일)에서 완료한 태스크 상태를 DONE으로 변경',
665
+ '- 다음 작업이 있다면 식별하여 메모',
666
+ '',
667
+ '⛔ **필수**: ④와 ⑤를 모두 수행해야 The Loop이 완료됩니다.',
668
+ '이제 ④⑤를 수행하세요 (Read, Edit 도구 사용).',
669
+ ].join('\n');
670
+
671
+ messages.push({ role: 'user', content: postKPrompt });
672
+
673
+ // Run Post-K loop (최대 2턴)
674
+ const maxPostKRounds = 2;
675
+ for (let round = 0; round < maxPostKRounds && turns < maxTurns; round++) {
676
+ if (abortSignal?.aborted) break;
677
+ turns++;
678
+
679
+ const postKResponse = await llm.chat(context.systemPrompt, messages, tools, abortSignal);
680
+ totalInput += postKResponse.usage.inputTokens;
681
+ totalOutput += postKResponse.usage.outputTokens;
682
+ config.tokenLedger?.record({
683
+ ts: new Date().toISOString(),
684
+ sessionId: config.sessionId,
685
+ roleId,
686
+ model: config.model ?? 'unknown',
687
+ inputTokens: postKResponse.usage.inputTokens,
688
+ outputTokens: postKResponse.usage.outputTokens,
689
+ });
690
+
691
+ messages.push({ role: 'assistant', content: postKResponse.content });
692
+ for (const block of postKResponse.content) {
693
+ if (block.type === 'text' && block.text) {
694
+ outputParts.push(block.text);
695
+ onText?.(block.text);
696
+ }
697
+ }
698
+
699
+ // If no tool calls, Post-K is done
700
+ if (postKResponse.stopReason !== 'tool_use') break;
701
+
702
+ // Execute Post-K tool calls
703
+ const postKToolCalls = postKResponse.content.filter(
704
+ (b): b is MessageContent & { type: 'tool_use' } => b.type === 'tool_use',
705
+ );
706
+ const postKResults: ToolResult[] = [];
707
+ for (const tc of postKToolCalls) {
708
+ allToolCalls.push({ name: tc.name, input: tc.input });
709
+ const result = await executeTool(
710
+ { id: tc.id, name: tc.name, input: tc.input },
711
+ toolExecOptions,
712
+ );
713
+ postKResults.push(result);
714
+ }
715
+
716
+ messages.push({
717
+ role: 'user',
718
+ content: postKResults.map((r) => ({
719
+ type: 'tool_result' as const,
720
+ tool_use_id: r.tool_use_id,
721
+ content: r.content,
722
+ is_error: r.is_error,
723
+ })) as unknown as MessageContent[],
724
+ });
725
+
726
+ onTurnComplete?.(turns);
727
+ }
728
+ }
729
+ }
730
+
731
+ return {
732
+ output: outputParts.join('\n'),
733
+ turns,
734
+ totalTokens: { input: totalInput, output: totalOutput },
735
+ toolCalls: allToolCalls,
736
+ dispatches,
737
+ };
738
+ }