tycono 0.3.45-beta.2 → 0.3.45-beta.3

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 (113) hide show
  1. package/README.md +191 -162
  2. package/bin/tycono.ts +42 -10
  3. package/package.json +21 -15
  4. package/packages/server/bin/cli.js +35 -0
  5. package/packages/server/bin/server.ts +183 -0
  6. package/{src → packages/server/src}/api/src/create-server.ts +11 -3
  7. package/{src → packages/server/src}/api/src/engine/agent-loop.ts +30 -7
  8. package/{src → packages/server/src}/api/src/engine/context-assembler.ts +122 -57
  9. package/{src → packages/server/src}/api/src/engine/llm-adapter.ts +10 -7
  10. package/{src → packages/server/src}/api/src/engine/org-tree.ts +43 -3
  11. package/{src → packages/server/src}/api/src/engine/runners/claude-cli.ts +37 -15
  12. package/{src → packages/server/src}/api/src/engine/runners/types.ts +6 -0
  13. package/{src → packages/server/src}/api/src/engine/tools/executor.ts +65 -9
  14. package/{src → packages/server/src}/api/src/routes/execute.ts +221 -17
  15. package/packages/server/src/api/src/services/claude-md-manager.ts +190 -0
  16. package/{src → packages/server/src}/api/src/services/company-config.ts +1 -0
  17. package/{src → packages/server/src}/api/src/services/digest-engine.ts +4 -1
  18. package/packages/server/src/api/src/services/dispatch-classifier.ts +179 -0
  19. package/{src → packages/server/src}/api/src/services/execution-manager.ts +227 -21
  20. package/{src → packages/server/src}/api/src/services/file-reader.ts +4 -1
  21. package/packages/server/src/api/src/services/preset-loader.ts +310 -0
  22. package/{src → packages/server/src}/api/src/services/supervisor-heartbeat.ts +89 -9
  23. package/{src → packages/server/src}/api/src/services/wave-multiplexer.ts +18 -8
  24. package/{src → packages/server/src}/api/src/services/wave-tracker.ts +25 -0
  25. package/packages/server/src/core/scaffolder.ts +620 -0
  26. package/{src → packages/server/src}/shared/types.ts +3 -1
  27. package/packages/server/templates/CLAUDE.md.tmpl +152 -0
  28. package/packages/server/templates/agentic-knowledge-base.md +355 -0
  29. package/src/api/src/services/claude-md-manager.ts +0 -94
  30. package/src/api/src/services/preset-loader.ts +0 -149
  31. package/templates/CLAUDE.md.tmpl +0 -239
  32. /package/{src/web → packages/pixel}/dist/assets/index-BJyiMGkM.js +0 -0
  33. /package/{src/web → packages/pixel}/dist/assets/index-BOuHc64o.css +0 -0
  34. /package/{src/web → packages/pixel}/dist/assets/index-DDPzbp9E.js +0 -0
  35. /package/{src/web → packages/pixel}/dist/assets/index-DVKWFwwK.css +0 -0
  36. /package/{src/web → packages/pixel}/dist/assets/preview-app-DZ6WxhDc.js +0 -0
  37. /package/{src/web → packages/pixel}/dist/index.html +0 -0
  38. /package/{src/web → packages/pixel}/dist/tyconoforge.js +0 -0
  39. /package/{src → packages/server/src}/api/package.json +0 -0
  40. /package/{src → packages/server/src}/api/src/create-app.ts +0 -0
  41. /package/{src → packages/server/src}/api/src/engine/authority-validator.ts +0 -0
  42. /package/{src → packages/server/src}/api/src/engine/index.ts +0 -0
  43. /package/{src → packages/server/src}/api/src/engine/knowledge-gate.ts +0 -0
  44. /package/{src → packages/server/src}/api/src/engine/role-lifecycle.ts +0 -0
  45. /package/{src → packages/server/src}/api/src/engine/runners/direct-api.ts +0 -0
  46. /package/{src → packages/server/src}/api/src/engine/runners/index.ts +0 -0
  47. /package/{src → packages/server/src}/api/src/engine/skill-template.ts +0 -0
  48. /package/{src → packages/server/src}/api/src/engine/tools/definitions.ts +0 -0
  49. /package/{src → packages/server/src}/api/src/routes/active-sessions.ts +0 -0
  50. /package/{src → packages/server/src}/api/src/routes/coins.ts +0 -0
  51. /package/{src → packages/server/src}/api/src/routes/company.ts +0 -0
  52. /package/{src → packages/server/src}/api/src/routes/cost.ts +0 -0
  53. /package/{src → packages/server/src}/api/src/routes/engine.ts +0 -0
  54. /package/{src → packages/server/src}/api/src/routes/git.ts +0 -0
  55. /package/{src → packages/server/src}/api/src/routes/knowledge.ts +0 -0
  56. /package/{src → packages/server/src}/api/src/routes/operations.ts +0 -0
  57. /package/{src → packages/server/src}/api/src/routes/preferences.ts +0 -0
  58. /package/{src → packages/server/src}/api/src/routes/presets.ts +0 -0
  59. /package/{src → packages/server/src}/api/src/routes/projects.ts +0 -0
  60. /package/{src → packages/server/src}/api/src/routes/quests.ts +0 -0
  61. /package/{src → packages/server/src}/api/src/routes/roles.ts +0 -0
  62. /package/{src → packages/server/src}/api/src/routes/save.ts +0 -0
  63. /package/{src → packages/server/src}/api/src/routes/sessions.ts +0 -0
  64. /package/{src → packages/server/src}/api/src/routes/setup.ts +0 -0
  65. /package/{src → packages/server/src}/api/src/routes/skills.ts +0 -0
  66. /package/{src → packages/server/src}/api/src/routes/speech.ts +0 -0
  67. /package/{src → packages/server/src}/api/src/routes/supervision.ts +0 -0
  68. /package/{src → packages/server/src}/api/src/routes/sync.ts +0 -0
  69. /package/{src → packages/server/src}/api/src/server.ts +0 -0
  70. /package/{src → packages/server/src}/api/src/services/activity-stream.ts +0 -0
  71. /package/{src → packages/server/src}/api/src/services/activity-tracker.ts +0 -0
  72. /package/{src → packages/server/src}/api/src/services/database.ts +0 -0
  73. /package/{src → packages/server/src}/api/src/services/git-save.ts +0 -0
  74. /package/{src → packages/server/src}/api/src/services/job-manager.ts +0 -0
  75. /package/{src → packages/server/src}/api/src/services/knowledge-importer.ts +0 -0
  76. /package/{src → packages/server/src}/api/src/services/markdown-parser.ts +0 -0
  77. /package/{src → packages/server/src}/api/src/services/port-registry.ts +0 -0
  78. /package/{src → packages/server/src}/api/src/services/preferences.ts +0 -0
  79. /package/{src → packages/server/src}/api/src/services/pricing.ts +0 -0
  80. /package/{src → packages/server/src}/api/src/services/scaffold.ts +0 -0
  81. /package/{src → packages/server/src}/api/src/services/session-store.ts +0 -0
  82. /package/{src → packages/server/src}/api/src/services/team-recommender.ts +0 -0
  83. /package/{src → packages/server/src}/api/src/services/token-ledger.ts +0 -0
  84. /package/{src → packages/server/src}/api/src/services/wave-messages.ts +0 -0
  85. /package/{src → packages/server/src}/api/src/utils/role-level.ts +0 -0
  86. /package/{templates → packages/server/templates}/company.md.tmpl +0 -0
  87. /package/{templates → packages/server/templates}/gitignore.tmpl +0 -0
  88. /package/{templates → packages/server/templates}/roles.md.tmpl +0 -0
  89. /package/{templates → packages/server/templates}/skills/_manifest.json +0 -0
  90. /package/{templates → packages/server/templates}/skills/agent-browser/SKILL.md +0 -0
  91. /package/{templates → packages/server/templates}/skills/agent-browser/meta.json +0 -0
  92. /package/{templates → packages/server/templates}/skills/akb-linter/SKILL.md +0 -0
  93. /package/{templates → packages/server/templates}/skills/akb-linter/meta.json +0 -0
  94. /package/{templates → packages/server/templates}/skills/knowledge-gate/SKILL.md +0 -0
  95. /package/{templates → packages/server/templates}/skills/knowledge-gate/meta.json +0 -0
  96. /package/{templates → packages/server/templates}/teams/agency.json +0 -0
  97. /package/{templates → packages/server/templates}/teams/research.json +0 -0
  98. /package/{templates → packages/server/templates}/teams/startup.json +0 -0
  99. /package/{src/tui → packages/tui/src}/api.ts +0 -0
  100. /package/{src/tui → packages/tui/src}/app.tsx +0 -0
  101. /package/{src/tui → packages/tui/src}/components/CommandMode.tsx +0 -0
  102. /package/{src/tui → packages/tui/src}/components/OrgTree.tsx +0 -0
  103. /package/{src/tui → packages/tui/src}/components/PanelMode.tsx +0 -0
  104. /package/{src/tui → packages/tui/src}/components/SetupWizard.tsx +0 -0
  105. /package/{src/tui → packages/tui/src}/components/StatusBar.tsx +0 -0
  106. /package/{src/tui → packages/tui/src}/components/StreamView.tsx +0 -0
  107. /package/{src/tui → packages/tui/src}/hooks/useApi.ts +0 -0
  108. /package/{src/tui → packages/tui/src}/hooks/useCommand.ts +0 -0
  109. /package/{src/tui → packages/tui/src}/hooks/useSSE.ts +0 -0
  110. /package/{src/tui → packages/tui/src}/index.tsx +0 -0
  111. /package/{src/tui → packages/tui/src}/store.ts +0 -0
  112. /package/{src/tui → packages/tui/src}/theme.ts +0 -0
  113. /package/{src/tui → packages/tui/src}/utils/markdown.tsx +0 -0
@@ -0,0 +1,183 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import net from 'node:net';
4
+ import { execSync } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ const VERSION = JSON.parse(
11
+ fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8')
12
+ ).version;
13
+
14
+ function printHelp(): void {
15
+ console.log(`
16
+ tycono-server v${VERSION}
17
+
18
+ AI team orchestration server. Headless by default.
19
+
20
+ Usage:
21
+ tycono-server [path] Start API server (default: current directory)
22
+ tycono-server --help Show this help
23
+ tycono-server --version Show version
24
+
25
+ Examples:
26
+ tycono-server Start in current directory
27
+ tycono-server ./my-company Start with company directory
28
+ PORT=3001 tycono-server Use specific port
29
+
30
+ Environment:
31
+ PORT Server port (default: auto-detect free port)
32
+ COMPANY_ROOT Company directory (default: current directory)
33
+ `);
34
+ }
35
+
36
+ function findFreePort(): Promise<number> {
37
+ return new Promise((resolve, reject) => {
38
+ const server = net.createServer();
39
+ server.listen(0, () => {
40
+ const addr = server.address();
41
+ if (addr && typeof addr === 'object') {
42
+ const port = addr.port;
43
+ server.close(() => resolve(port));
44
+ } else {
45
+ reject(new Error('Could not find free port'));
46
+ }
47
+ });
48
+ server.on('error', reject);
49
+ });
50
+ }
51
+
52
+ function detectEngine(): string {
53
+ try {
54
+ execSync('claude --version', { stdio: 'pipe' });
55
+ return 'claude-cli';
56
+ } catch {}
57
+ if (process.env.ANTHROPIC_API_KEY) return 'direct-api';
58
+ return 'none';
59
+ }
60
+
61
+ export async function main(args: string[]): Promise<void> {
62
+ const command = args[0];
63
+
64
+ if (command === '--help' || command === '-h') {
65
+ printHelp();
66
+ return;
67
+ }
68
+
69
+ if (command === '--version' || command === '-v') {
70
+ console.log(VERSION);
71
+ return;
72
+ }
73
+
74
+ // Load .env
75
+ const dotenvPath = path.resolve(process.cwd(), '.env');
76
+ if (fs.existsSync(dotenvPath)) {
77
+ const { config } = await import('dotenv');
78
+ config({ path: dotenvPath });
79
+ }
80
+
81
+ // Company root
82
+ if (command && !command.startsWith('-')) {
83
+ process.env.COMPANY_ROOT = path.resolve(command);
84
+ }
85
+ if (!process.env.COMPANY_ROOT) {
86
+ process.env.COMPANY_ROOT = process.cwd();
87
+ }
88
+
89
+ // Check for knowledge/ subdirectory pattern
90
+ const knowledgeDir = path.join(process.env.COMPANY_ROOT, 'knowledge');
91
+ if (fs.existsSync(path.join(knowledgeDir, 'CLAUDE.md'))) {
92
+ // Company root is the parent of knowledge/
93
+ } else if (!fs.existsSync(path.join(process.env.COMPANY_ROOT, 'CLAUDE.md'))) {
94
+ // Scan one level deep
95
+ try {
96
+ const entries = fs.readdirSync(process.env.COMPANY_ROOT, { withFileTypes: true });
97
+ for (const entry of entries) {
98
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
99
+ if (fs.existsSync(path.join(process.env.COMPANY_ROOT, entry.name, '.tycono', 'config.json'))) {
100
+ process.env.COMPANY_ROOT = path.join(process.env.COMPANY_ROOT, entry.name);
101
+ break;
102
+ }
103
+ }
104
+ } catch {}
105
+ }
106
+
107
+ process.env.NODE_ENV = 'production';
108
+
109
+ // Detect engine
110
+ const engine = detectEngine();
111
+ process.env.EXECUTION_ENGINE = engine;
112
+
113
+ // Port
114
+ const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort();
115
+ process.env.PORT = String(port);
116
+
117
+ // Company name
118
+ let companyName = 'My Company';
119
+ try {
120
+ const claudeMdPath = path.join(process.env.COMPANY_ROOT, 'CLAUDE.md');
121
+ if (fs.existsSync(claudeMdPath)) {
122
+ const content = fs.readFileSync(claudeMdPath, 'utf-8');
123
+ const match = content.match(/^#\s+(.+)/m);
124
+ if (match) companyName = match[1].trim();
125
+ }
126
+ } catch {}
127
+
128
+ const url = `http://localhost:${port}`;
129
+
130
+ console.log(` tycono-server v${VERSION}`);
131
+ console.log(` API: ${url}`);
132
+ console.log(` Company: ${companyName}`);
133
+ console.log(` Engine: ${engine}`);
134
+ console.log(` PID: ${process.pid}`);
135
+
136
+ // Write headless.json for discovery
137
+ const headlessInfo = { port, pid: process.pid, url, startedAt: new Date().toISOString() };
138
+ const headlessPath = path.join(process.env.COMPANY_ROOT, '.tycono', 'headless.json');
139
+ try {
140
+ fs.mkdirSync(path.dirname(headlessPath), { recursive: true });
141
+ fs.writeFileSync(headlessPath, JSON.stringify(headlessInfo, null, 2));
142
+ } catch {}
143
+
144
+ // Start server
145
+ const { createHttpServer } = await import('../src/api/src/create-server.js');
146
+ const server = createHttpServer();
147
+
148
+ const host = process.env.HOST || '0.0.0.0';
149
+ server.listen(port, host);
150
+
151
+ // Graceful shutdown with active wave guard (BUG-CONCURRENT protection)
152
+ let forceShutdown = false;
153
+ let forceTimer: ReturnType<typeof setTimeout> | null = null;
154
+
155
+ const doShutdown = () => {
156
+ console.log('\n Shutting down...');
157
+ try { fs.unlinkSync(headlessPath); } catch {}
158
+ server.close(() => process.exit(0));
159
+ setTimeout(() => process.exit(1), 5000);
160
+ };
161
+
162
+ const shutdown = async () => {
163
+ try {
164
+ const { getActiveWaveCount } = await import('../src/api/src/create-server.js');
165
+ const activeCount = getActiveWaveCount();
166
+
167
+ if (activeCount > 0 && !forceShutdown) {
168
+ console.log(`\n ⚠️ ${activeCount} active wave(s) running. Press Ctrl+C again within 5s to force shutdown.`);
169
+ forceShutdown = true;
170
+ forceTimer = setTimeout(() => { forceShutdown = false; }, 5000);
171
+ return;
172
+ }
173
+ } catch {
174
+ // If we can't check, proceed with shutdown
175
+ }
176
+
177
+ if (forceTimer) clearTimeout(forceTimer);
178
+ doShutdown();
179
+ };
180
+
181
+ process.on('SIGINT', shutdown);
182
+ process.on('SIGTERM', doShutdown);
183
+ }
@@ -39,11 +39,18 @@ import { AnthropicProvider, type LLMProvider } from './engine/llm-adapter.js';
39
39
  import { readConfig } from './services/company-config.js';
40
40
  import { ensureClaudeMd } from './services/claude-md-manager.js';
41
41
 
42
+ import { supervisorHeartbeat } from './services/supervisor-heartbeat.js';
43
+
42
44
  const __filename = fileURLToPath(import.meta.url);
43
45
  const __dirname = path.dirname(__filename);
44
46
  const isProd = process.env.NODE_ENV === 'production';
45
47
  const corsOrigin = isProd ? true : /^http:\/\/localhost:\d+$/;
46
48
 
49
+ /** Get count of active waves (for shutdown guard) */
50
+ export function getActiveWaveCount(): number {
51
+ return supervisorHeartbeat.listActive().length;
52
+ }
53
+
47
54
  /* ─── Raw HTTP handler for import-knowledge SSE ─── */
48
55
 
49
56
  function handleImportKnowledge(req: http.IncomingMessage, res: http.ServerResponse): void {
@@ -101,7 +108,8 @@ export function createHttpServer(): http.Server {
101
108
  const app = createExpressApp();
102
109
 
103
110
  const server = http.createServer((req, res) => {
104
- const url = req.url ?? '';
111
+ const rawUrl = req.url ?? '';
112
+ const url = rawUrl.split('?')[0]; // Strip query string for route matching
105
113
  const method = req.method ?? '';
106
114
 
107
115
  // GET /api/waves/active — restore active waves after refresh
@@ -150,8 +158,8 @@ export function createHttpServer(): http.Server {
150
158
  return;
151
159
  }
152
160
 
153
- // Non-SSE exec/jobs endpoints (GET, DELETE)
154
- if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs')) && (method === 'GET' || method === 'DELETE')) {
161
+ // Non-SSE exec/jobs/waves endpoints (GET, DELETE)
162
+ if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs') || url.startsWith('/api/waves/')) && (method === 'GET' || method === 'DELETE')) {
155
163
  setExecCors(req, res);
156
164
  handleExecRequest(req, res);
157
165
  return;
@@ -1,4 +1,4 @@
1
- import { AnthropicProvider, type LLMProvider, type LLMMessage, type ToolResult, type MessageContent } from './llm-adapter.js';
1
+ import { AnthropicProvider, type LLMProvider, type LLMMessage, type ToolResult, type MessageContent, type SystemPrompt } 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
4
  import { validateDispatch, validateConsult } from './authority-validator.js';
@@ -30,6 +30,7 @@ export interface AgentConfig {
30
30
  attachments?: ImageAttachment[]; // Image attachments for vision
31
31
  targetRoles?: string[]; // Selective dispatch scope
32
32
  presetId?: string; // Wave-scoped preset for knowledge injection
33
+ priorDispatches?: Array<{ roleId: string; task: string; result: string }>; // Handoff summary
33
34
  // Callbacks
34
35
  onText?: (text: string) => void;
35
36
  onToolExec?: (name: string, input: Record<string, unknown>) => void;
@@ -163,11 +164,32 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
163
164
  const llm = config.llm ?? new AnthropicProvider();
164
165
 
165
166
  // 1. Assemble context
166
- const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus: config.teamStatus, targetRoles: config.targetRoles, presetId: config.presetId });
167
+ const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus: config.teamStatus, targetRoles: config.targetRoles, presetId: config.presetId, priorDispatches: config.priorDispatches });
167
168
 
168
169
  // Trace: capture assembled prompt for debugging
169
170
  config.onPromptAssembled?.(context.systemPrompt, task);
170
171
 
172
+ // Build cached system prompt from structured sections (for Anthropic prompt caching)
173
+ const cachedPrompt: SystemPrompt = (() => {
174
+ const blocks: Array<{ type: 'text'; text: string; cache_control?: { type: 'ephemeral' } }> = [];
175
+ let staticBuf = '';
176
+ for (const s of context.sections) {
177
+ if (s.cacheable) {
178
+ staticBuf += (staticBuf ? '\n\n---\n\n' : '') + s.text;
179
+ } else {
180
+ if (staticBuf) {
181
+ blocks.push({ type: 'text', text: staticBuf, cache_control: { type: 'ephemeral' } });
182
+ staticBuf = '';
183
+ }
184
+ blocks.push({ type: 'text', text: s.text });
185
+ }
186
+ }
187
+ if (staticBuf) {
188
+ blocks.push({ type: 'text', text: staticBuf, cache_control: { type: 'ephemeral' } });
189
+ }
190
+ return blocks.length > 0 ? blocks : context.systemPrompt;
191
+ })();
192
+
171
193
  // 2. Determine tools
172
194
  const subordinates = getSubordinates(orgTree, roleId);
173
195
  const hasBash = !readOnly && !!config.codeRoot;
@@ -221,6 +243,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
221
243
  sessionId: config.sessionId,
222
244
  model: config.model,
223
245
  tokenLedger: config.tokenLedger,
246
+ priorDispatches: dispatches, // Handoff: pass accumulated results to sub-agent
224
247
  onText: (text) => onText?.(`[${targetRoleId}] ${text}`),
225
248
  onToolExec,
226
249
  });
@@ -323,7 +346,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
323
346
  }
324
347
 
325
348
  // Call LLM
326
- const response = await llm.chat(context.systemPrompt, messages, tools, abortSignal);
349
+ const response = await llm.chat(cachedPrompt, messages, tools, abortSignal);
327
350
  totalInput += response.usage.inputTokens;
328
351
  totalOutput += response.usage.outputTokens;
329
352
 
@@ -496,7 +519,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
496
519
  if (abortSignal?.aborted) break;
497
520
  turns++;
498
521
 
499
- const supResponse = await llm.chat(context.systemPrompt, messages, tools, abortSignal);
522
+ const supResponse = await llm.chat(cachedPrompt, messages, tools, abortSignal);
500
523
  totalInput += supResponse.usage.inputTokens;
501
524
  totalOutput += supResponse.usage.outputTokens;
502
525
  config.tokenLedger?.record({
@@ -576,7 +599,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
576
599
 
577
600
  if (turns < maxTurns) {
578
601
  turns++;
579
- const verifyResponse = await llm.chat(context.systemPrompt, messages, tools, abortSignal);
602
+ const verifyResponse = await llm.chat(cachedPrompt, messages, tools, abortSignal);
580
603
  totalInput += verifyResponse.usage.inputTokens;
581
604
  totalOutput += verifyResponse.usage.outputTokens;
582
605
  config.tokenLedger?.record({
@@ -622,7 +645,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
622
645
 
623
646
  if (turns < maxTurns) {
624
647
  turns++;
625
- const summaryResponse = await llm.chat(context.systemPrompt, messages, tools, abortSignal);
648
+ const summaryResponse = await llm.chat(cachedPrompt, messages, tools, abortSignal);
626
649
  totalInput += summaryResponse.usage.inputTokens;
627
650
  totalOutput += summaryResponse.usage.outputTokens;
628
651
  config.tokenLedger?.record({
@@ -676,7 +699,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
676
699
  if (abortSignal?.aborted) break;
677
700
  turns++;
678
701
 
679
- const postKResponse = await llm.chat(context.systemPrompt, messages, tools, abortSignal);
702
+ const postKResponse = await llm.chat(cachedPrompt, messages, tools, abortSignal);
680
703
  totalInput += postKResponse.usage.inputTokens;
681
704
  totalOutput += postKResponse.usage.outputTokens;
682
705
  config.tokenLedger?.record({
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import { readPreferences } from '../services/preferences.js';
4
5
  import { readConfig, resolveCodeRoot } from '../services/company-config.js';
@@ -14,8 +15,15 @@ import { extractKeywords, searchRelatedDocs } from './knowledge-gate.js';
14
15
 
15
16
  /* ─── Types ──────────────────────────────────── */
16
17
 
18
+ export interface PromptSection {
19
+ text: string;
20
+ cacheable: boolean;
21
+ }
22
+
17
23
  export interface AssembledContext {
18
24
  systemPrompt: string;
25
+ /** Structured sections for prompt caching — cacheable sections get cache_control */
26
+ sections: PromptSection[];
19
27
  task: string;
20
28
  sourceRole: string;
21
29
  targetRole: string;
@@ -51,60 +59,62 @@ export function assembleContext(
51
59
  task: string,
52
60
  sourceRole: string,
53
61
  orgTree: OrgTree,
54
- options?: { teamStatus?: TeamStatus; targetRoles?: string[]; presetId?: string },
62
+ options?: { teamStatus?: TeamStatus; targetRoles?: string[]; presetId?: string; priorDispatches?: Array<{ roleId: string; task: string; result: string }> },
55
63
  ): AssembledContext {
56
64
  const node = orgTree.nodes.get(roleId);
57
65
  if (!node) {
58
66
  throw new Error(`Role not found in org tree: ${roleId}`);
59
67
  }
60
68
 
61
- const sections: string[] = [];
69
+ const taggedSections: PromptSection[] = [];
70
+ // Helper: push with cacheability tag
71
+ const pushSection = (text: string, cacheable: boolean) => taggedSections.push({ text, cacheable });
62
72
 
63
- // 1. Company Rules (CLAUDE.md + custom-rules.md + company.md)
73
+ // 1. Company Rules (CLAUDE.md + custom-rules.md + company.md) — CACHEABLE
64
74
  const companyRules = loadCompanyRules(companyRoot);
65
75
  if (companyRules) {
66
- sections.push(companyRules);
76
+ pushSection(companyRules, true);
67
77
  }
68
78
 
69
- // 2. Org Context
70
- sections.push(buildOrgContextSection(orgTree, node));
79
+ // 2. Org Context — CACHEABLE (static per role)
80
+ pushSection(buildOrgContextSection(orgTree, node), true);
71
81
 
72
- // 3. Role Persona
73
- sections.push(buildPersonaSection(node));
82
+ // 3. Role Persona — CACHEABLE
83
+ pushSection(buildPersonaSection(node), true);
74
84
 
75
- // 4. Authority Rules
76
- sections.push(buildAuthoritySection(node));
85
+ // 4. Authority Rules — CACHEABLE
86
+ pushSection(buildAuthoritySection(node), true);
77
87
 
78
- // 5. Knowledge Scope
79
- sections.push(buildKnowledgeSection(node));
88
+ // 5. Knowledge Scope — CACHEABLE
89
+ pushSection(buildKnowledgeSection(node), true);
80
90
 
81
- // 6. SKILL.md (Role-specific + equipped shared skills)
91
+ // 6. SKILL.md (Role-specific + equipped shared skills) — CACHEABLE
82
92
  const skillContent = loadSkillMd(companyRoot, roleId);
83
93
  if (skillContent) {
84
- sections.push('# Skills & Tools\n\n' + skillContent);
94
+ pushSection('# Skills & Tools\n\n' + skillContent, true);
85
95
  }
86
96
 
87
- // 6b. Shared Skills (from role.yaml skills field)
97
+ // 6b. Shared Skills (from role.yaml skills field) — CACHEABLE
88
98
  const sharedSkills = loadSharedSkills(companyRoot, node.skills);
89
99
  if (sharedSkills) {
90
- sections.push('# Equipped Skills\n\n' + sharedSkills);
100
+ pushSection('# Equipped Skills\n\n' + sharedSkills, true);
91
101
  }
92
102
 
93
- // 7. Hub Docs (요약)
103
+ // 7. Hub Docs (요약) — CACHEABLE
94
104
  const hubSummary = loadHubSummaries(companyRoot, node);
95
105
  if (hubSummary) {
96
- sections.push('# Reference Documents\n\n' + hubSummary);
106
+ pushSection('# Reference Documents\n\n' + hubSummary, true);
97
107
  }
98
108
 
99
- // 8. CEO Decisions (전사 공지)
109
+ // 8. CEO Decisions (전사 공지) — CACHEABLE
100
110
  const ceoDecisions = loadCeoDecisions(companyRoot);
101
111
  if (ceoDecisions) {
102
- sections.push('# CEO Decisions (전사 공지)\n\n' + ceoDecisions);
112
+ pushSection('# CEO Decisions (전사 공지)\n\n' + ceoDecisions, true);
103
113
  }
104
114
 
105
- // 9. Code Root (코드 프로젝트 경로)
115
+ // 9. Code Root (코드 프로젝트 경로) — CACHEABLE (static per company)
106
116
  const codeRoot = resolveCodeRoot(companyRoot);
107
- sections.push(`# Code Project
117
+ pushSection(`# Code Project
108
118
 
109
119
  The code repository is located at: \`${codeRoot}\` (env: $TYCONO_CODE_ROOT)
110
120
  The AKB (knowledge) directory is at: \`${companyRoot}\` (env: $TYCONO_AKB_ROOT)
@@ -115,19 +125,36 @@ Use the code repository path for all source code work (reading, writing, buildin
115
125
  - Your cwd is already set to the code repository. When creating worktrees, use relative paths or \`$TYCONO_CODE_ROOT\`.
116
126
  - **NEVER run \`git worktree add\` in \`$TYCONO_AKB_ROOT\`** — the AKB directory is not a code repository.
117
127
  - Recommended worktree path: \`$TYCONO_CODE_ROOT/.worktrees/{branch-name}\`
118
- - Example: \`git worktree add .worktrees/feature-xyz -b feature/xyz\` (from cwd, which is already code repo)`);
128
+ - Example: \`git worktree add .worktrees/feature-xyz -b feature/xyz\` (from cwd, which is already code repo)`, true);
119
129
 
120
- // 10. Pre-Knowledging: 작업 관련 문서 자동 탐색
130
+ // 10. Pre-Knowledging: 작업 관련 문서 자동 탐색 — NOT cacheable (task-dependent)
121
131
  const preKSection = buildPreKnowledgingSection(companyRoot, task);
122
132
  if (preKSection) {
123
- sections.push(preKSection);
133
+ pushSection(preKSection, false);
124
134
  }
125
135
 
126
- // 11. Preset Knowledge (wave-scoped preset docs)
136
+ // 11. Preset Knowledge (wave-scoped preset docs) — CACHEABLE (static per wave)
127
137
  if (options?.presetId && options.presetId !== 'default') {
128
138
  const presetKnowledge = loadPresetKnowledge(companyRoot, options.presetId);
129
139
  if (presetKnowledge) {
130
- sections.push('# Preset Knowledge\n\n' + presetKnowledge);
140
+ pushSection('# Preset Knowledge\n\n' + presetKnowledge, true);
141
+ }
142
+ }
143
+
144
+ // 12. Handoff Summary — NOT cacheable (dynamic per dispatch)
145
+ if (options?.priorDispatches && options.priorDispatches.length > 0) {
146
+ const sameRole = options.priorDispatches.filter(d => d.roleId === roleId);
147
+ if (sameRole.length > 0) {
148
+ const summaries = sameRole.map((d, i) =>
149
+ `### Session ${i + 1}\n**Task**: ${d.task.slice(0, 200)}\n**Result**: ${d.result.slice(0, 500)}`
150
+ ).join('\n\n');
151
+ pushSection(`# Previous Session Results (Handoff Summary)
152
+
153
+ This role has been dispatched ${sameRole.length} time(s) before in this wave. **DO NOT repeat completed work.**
154
+
155
+ ${summaries}
156
+
157
+ > Build on these results. Skip investigation that was already done. Focus on NEW or UNFINISHED items only.`, false);
131
158
  }
132
159
  }
133
160
 
@@ -140,23 +167,23 @@ Use the code repository path for all source code work (reading, writing, buildin
140
167
  subordinates = subordinates.filter(id => options.targetRoles!.includes(id));
141
168
  }
142
169
 
143
- // Supervision prompt (SV-11, SV-12: C-Level heartbeat mode)
170
+ // Supervision prompt (SV-11, SV-12: C-Level heartbeat mode) — CACHEABLE
144
171
  const heartbeatEnabled = node.heartbeat?.enabled === true;
145
172
  if (heartbeatEnabled && subordinates.length > 0) {
146
- sections.push(buildSupervisionSection(node));
173
+ pushSection(buildSupervisionSection(node), true);
147
174
  }
148
175
 
149
- // Knowledge consistency management (C-Level responsibility)
176
+ // Knowledge consistency management (C-Level responsibility) — CACHEABLE
150
177
  if (node.level === 'c-level') {
151
- sections.push(buildKnowledgeManagementSection(roleId));
178
+ pushSection(buildKnowledgeManagementSection(roleId), true);
152
179
  }
153
180
 
154
- // Dispatch 도구 안내 (하위 Role이 있는 경우)
181
+ // Dispatch 도구 안내 (하위 Role이 있는 경우) — NOT cacheable (teamStatus varies)
155
182
  if (subordinates.length > 0) {
156
- sections.push(buildDispatchSection(orgTree, roleId, subordinates, options?.teamStatus));
183
+ pushSection(buildDispatchSection(orgTree, roleId, subordinates, options?.teamStatus), false);
157
184
  } else if (node.level === 'c-level') {
158
185
  // C-level with no subordinates — clarify authority boundaries
159
- sections.push(`# Team Structure
186
+ pushSection(`# Team Structure
160
187
 
161
188
  ⚠️ **You have no direct reports.** You are an individual contributor at the C-level.
162
189
 
@@ -164,21 +191,21 @@ Use the code repository path for all source code work (reading, writing, buildin
164
191
  - You CAN consult other roles for information (see Consult section below)
165
192
  - You MUST do the work yourself — research, analyze, write, decide
166
193
  - If implementation requires another role (e.g., engineering work), recommend it to CEO
167
- - Make decisions within your authority autonomously — do NOT ask CEO for decisions you can make yourself`);
194
+ - Make decisions within your authority autonomously — do NOT ask CEO for decisions you can make yourself`, true);
168
195
  }
169
196
 
170
- // Consult 도구 안내 (상담 가능한 Role이 있는 경우)
197
+ // Consult 도구 안내 CACHEABLE
171
198
  const consultSection = buildConsultSection(orgTree, roleId);
172
199
  if (consultSection) {
173
- sections.push(consultSection);
200
+ pushSection(consultSection, true);
174
201
  }
175
202
 
176
- // Language preference (default: English)
203
+ // Language preference CACHEABLE
177
204
  const prefs = readPreferences(companyRoot);
178
205
  const lang = prefs.language && prefs.language !== 'auto' ? prefs.language : 'en';
179
206
  const langNames: Record<string, string> = { en: 'English', ko: 'Korean (한국어)', ja: 'Japanese (日本語)' };
180
207
  const langName = langNames[lang] ?? lang;
181
- sections.push(`# Language (CRITICAL)
208
+ pushSection(`# Language (CRITICAL)
182
209
 
183
210
  You MUST respond in **${langName}**.
184
211
 
@@ -190,10 +217,10 @@ This applies to ALL output without exception:
190
217
  - Git commit messages and PR descriptions
191
218
 
192
219
  Code (variable names, comments in code) may remain in English for readability.
193
- Everything else MUST be in ${langName}.`);
220
+ Everything else MUST be in ${langName}.`, true);
194
221
 
195
- // Execution behavior rules (prevents infinite exploration loops in -p mode)
196
- sections.push(`# Execution Rules (CRITICAL)
222
+ // Execution behavior rules CACHEABLE
223
+ pushSection(`# Execution Rules (CRITICAL)
197
224
 
198
225
  ## Interpreting Tasks
199
226
  - A [CEO Wave] is a directive from the CEO. Interpret it based on your role's expertise.
@@ -229,12 +256,13 @@ Everything else MUST be in ${langName}.`);
229
256
  - Use a descriptive commit message: \`git commit -m "type(scope): description"\`
230
257
  - Common types: feat, fix, refactor, test, chore
231
258
  - This ensures your work is not lost in uncommitted changes.
232
- - Do NOT push — just commit locally. Your superior or the system handles push/PR.`);
259
+ - Do NOT push — just commit locally. Your superior or the system handles push/PR.`, true);
233
260
 
234
- const systemPrompt = sections.join('\n\n---\n\n');
261
+ const systemPrompt = taggedSections.map(s => s.text).join('\n\n---\n\n');
235
262
 
236
263
  return {
237
264
  systemPrompt,
265
+ sections: taggedSections,
238
266
  task,
239
267
  sourceRole,
240
268
  targetRole: roleId,
@@ -271,22 +299,43 @@ ${docList}
271
299
  }
272
300
 
273
301
  /**
274
- * Load knowledge docs from a preset's knowledge/ directory.
302
+ * Load knowledge docs from a preset/agency's knowledge/ directory.
303
+ * 2-Layer Knowledge: merges docs from multiple sources (deduplicated by filename).
304
+ *
305
+ * Search order:
306
+ * 1. {companyRoot}/knowledge/presets/{presetId}/knowledge/ (legacy/local presets)
307
+ * 2. {companyRoot}/.tycono/agencies/{presetId}/knowledge/ (local agency install)
308
+ * 3. ~/.tycono/agencies/{presetId}/knowledge/ (global agency install)
309
+ *
275
310
  * Returns concatenated content (capped at 2000 chars per doc).
276
311
  */
277
312
  function loadPresetKnowledge(companyRoot: string, presetId: string): string | null {
278
- const knowledgeDir = path.join(companyRoot, 'knowledge', 'presets', presetId, 'knowledge');
279
- if (!fs.existsSync(knowledgeDir)) return null;
280
-
313
+ const candidateDirs = [
314
+ path.join(companyRoot, 'knowledge', 'presets', presetId, 'knowledge'),
315
+ path.join(companyRoot, '.tycono', 'agencies', presetId, 'knowledge'),
316
+ path.join(os.homedir(), '.tycono', 'agencies', presetId, 'knowledge'),
317
+ ];
318
+
319
+ // Collect docs from all sources, deduplicate by filename (first wins)
320
+ const seenFiles = new Set<string>();
281
321
  const parts: string[] = [];
282
- try {
283
- const entries = fs.readdirSync(knowledgeDir).filter(f => f.endsWith('.md'));
284
- for (const file of entries.slice(0, 10)) { // Cap at 10 docs
285
- const content = fs.readFileSync(path.join(knowledgeDir, file), 'utf-8');
286
- const preview = content.slice(0, 2000);
287
- parts.push(`## ${file}\n\n${preview}${content.length > 2000 ? '\n\n... (truncated)' : ''}`);
288
- }
289
- } catch { /* ignore */ }
322
+
323
+ for (const knowledgeDir of candidateDirs) {
324
+ if (!fs.existsSync(knowledgeDir)) continue;
325
+ try {
326
+ const entries = fs.readdirSync(knowledgeDir).filter(f => f.endsWith('.md'));
327
+ for (const file of entries) {
328
+ if (seenFiles.has(file)) continue; // deduplicate: first source wins
329
+ seenFiles.add(file);
330
+ if (seenFiles.size > 10) break; // Cap at 10 docs total
331
+
332
+ const content = fs.readFileSync(path.join(knowledgeDir, file), 'utf-8');
333
+ const preview = content.slice(0, 2000);
334
+ parts.push(`## ${file}\n\n${preview}${content.length > 2000 ? '\n\n... (truncated)' : ''}`);
335
+ }
336
+ } catch { /* ignore unreadable dirs */ }
337
+ if (seenFiles.size >= 10) break;
338
+ }
290
339
 
291
340
  return parts.length > 0 ? parts.join('\n\n---\n\n') : null;
292
341
  }
@@ -606,7 +655,23 @@ python3 "$SUPERVISION_CMD" watch ses-aaa${subordinates.length > 1 ? ',ses-bbb' :
606
655
  ### ⛔ CRITICAL Rules
607
656
  - **NEVER re-dispatch the same task.** If --check shows RUNNING, just keep polling.
608
657
  - **NEVER dispatch and immediately finish.** The dispatch→check→review loop must continue until ALL work is complete.
609
- - **Save the job ID** from each dispatch to use with --check.`;
658
+ - **Save the job ID** from each dispatch to use with --check.
659
+
660
+ ### ⛔ Amend-First Rule (COST CRITICAL)
661
+ When a subordinate needs follow-up work on the SAME topic, **amend instead of re-dispatch**.
662
+ Re-dispatch creates a new session that reloads ALL context from scratch (expensive).
663
+ Amend sends instructions to the existing session — near-zero additional cost.
664
+
665
+ \`\`\`bash
666
+ # ❌ WRONG: re-dispatch (wastes tokens reloading context)
667
+ python3 "$DISPATCH_CMD" ${exampleSubId} "fix the bug again"
668
+
669
+ # ✅ CORRECT: amend existing session (keeps context, near-zero cost)
670
+ python3 "$SUPERVISION_CMD" amend ses-xxx "Critic found issue: [details]. Fix it."
671
+ \`\`\`
672
+
673
+ **When to amend**: Follow-up on same files/code the role already touched.
674
+ **When to dispatch new**: Genuinely unrelated new task scope.`;
610
675
 
611
676
  // C-level roles get mandatory delegation rules
612
677
  if (isCLevel) {