tycono 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,11 +6,8 @@
6
6
  "scripts": {
7
7
  "dev": "tsx watch src/server.ts",
8
8
  "start": "tsx src/server.ts",
9
- "test": "vitest run",
10
- "test:watch": "vitest",
11
- "test:unit": "vitest run tests/unit/",
12
- "test:integration": "vitest run tests/integration/",
13
- "test:live": "LIVE=1 vitest run --config vitest.live.config.ts"
9
+ "test": "vitest run tests/smoke.test.ts",
10
+ "test:watch": "vitest tests/smoke.test.ts"
14
11
  },
15
12
  "dependencies": {
16
13
  "@anthropic-ai/sdk": "^0.78.0",
@@ -25,6 +22,8 @@
25
22
  "@types/cors": "^2.8.17",
26
23
  "@types/express": "^5.0.0",
27
24
  "@types/node": "^22.13.4",
25
+ "@types/supertest": "^7.2.0",
26
+ "supertest": "^7.2.2",
28
27
  "tsx": "^4.19.3",
29
28
  "typescript": "^5.7.3",
30
29
  "vitest": "^4.0.18"
@@ -25,6 +25,7 @@ import { knowledgeRouter } from './routes/knowledge.js';
25
25
  import { preferencesRouter } from './routes/preferences.js';
26
26
  import { saveRouter } from './routes/save.js';
27
27
  import { speechRouter } from './routes/speech.js';
28
+ import { costRouter } from './routes/cost.js';
28
29
  import { importKnowledge } from './services/knowledge-importer.js';
29
30
  import { AnthropicProvider, type LLMProvider } from './engine/llm-adapter.js';
30
31
  import { readConfig } from './services/company-config.js';
@@ -100,6 +101,51 @@ function handleImportKnowledge(req: http.IncomingMessage, res: http.ServerRespon
100
101
  export function createHttpServer(): http.Server {
101
102
  cleanupStaleActivities();
102
103
 
104
+ const app = createExpressApp();
105
+
106
+ const server = http.createServer((req, res) => {
107
+ const url = req.url ?? '';
108
+ const method = req.method ?? '';
109
+
110
+ // SSE 엔드포인트: Express 우회하여 raw HTTP로 처리
111
+ if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs') || url === '/api/setup/import-knowledge') && method === 'POST') {
112
+ setExecCors(req, res);
113
+ if (url === '/api/setup/import-knowledge') {
114
+ handleImportKnowledge(req, res);
115
+ } else {
116
+ handleExecRequest(req, res);
117
+ }
118
+ return;
119
+ }
120
+
121
+ // CORS preflight for exec/jobs endpoints
122
+ if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs')) && method === 'OPTIONS') {
123
+ setExecCors(req, res);
124
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
125
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
126
+ res.writeHead(204);
127
+ res.end();
128
+ return;
129
+ }
130
+
131
+ // Non-SSE exec/jobs endpoints (GET, DELETE)
132
+ if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs')) && (method === 'GET' || method === 'DELETE')) {
133
+ setExecCors(req, res);
134
+ handleExecRequest(req, res);
135
+ return;
136
+ }
137
+
138
+ // 나머지는 Express 처리
139
+ (app as (req: http.IncomingMessage, res: http.ServerResponse) => void)(req, res);
140
+ });
141
+
142
+ server.timeout = 0;
143
+ server.requestTimeout = 0;
144
+
145
+ return server;
146
+ }
147
+
148
+ export function createExpressApp(): express.Application {
103
149
  const app = express();
104
150
 
105
151
  app.use(cors({ origin: corsOrigin }));
@@ -122,7 +168,7 @@ export function createHttpServer(): http.Server {
122
168
  if (match) companyName = match[1].trim();
123
169
  } catch { /* ignore */ }
124
170
  }
125
- res.json({ initialized, companyName, engine: config.engine || process.env.EXECUTION_ENGINE || 'none', companyRoot: COMPANY_ROOT });
171
+ res.json({ initialized, companyName, engine: config.engine || process.env.EXECUTION_ENGINE || 'none', companyRoot: COMPANY_ROOT, codeRoot: config.codeRoot || null, hasApiKey: !!process.env.ANTHROPIC_API_KEY });
126
172
  });
127
173
 
128
174
  app.use('/api/roles', rolesRouter);
@@ -135,6 +181,7 @@ export function createHttpServer(): http.Server {
135
181
  app.use('/api/preferences', preferencesRouter);
136
182
  app.use('/api/speech', speechRouter);
137
183
  app.use('/api/save', saveRouter);
184
+ app.use('/api/cost', costRouter);
138
185
 
139
186
  app.get('/api/health', (_req, res) => {
140
187
  res.json({ status: 'ok', companyRoot: COMPANY_ROOT });
@@ -155,53 +202,14 @@ export function createHttpServer(): http.Server {
155
202
  res.status(status).json({ error: err.message });
156
203
  });
157
204
 
158
- function setExecCors(req: http.IncomingMessage, res: http.ServerResponse): void {
159
- const origin = req.headers.origin;
160
- if (!origin) return;
161
- if (isProd || /^http:\/\/localhost:\d+$/.test(origin)) {
162
- res.setHeader('Access-Control-Allow-Origin', origin);
163
- res.setHeader('Access-Control-Allow-Credentials', 'true');
164
- }
165
- }
166
-
167
- const server = http.createServer((req, res) => {
168
- const url = req.url ?? '';
169
- const method = req.method ?? '';
170
-
171
- // SSE 엔드포인트: Express 우회하여 raw HTTP로 처리
172
- if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs') || url === '/api/setup/import-knowledge') && method === 'POST') {
173
- setExecCors(req, res);
174
- if (url === '/api/setup/import-knowledge') {
175
- handleImportKnowledge(req, res);
176
- } else {
177
- handleExecRequest(req, res);
178
- }
179
- return;
180
- }
181
-
182
- // CORS preflight for exec/jobs endpoints
183
- if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs')) && method === 'OPTIONS') {
184
- setExecCors(req, res);
185
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
186
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
187
- res.writeHead(204);
188
- res.end();
189
- return;
190
- }
191
-
192
- // Non-SSE exec/jobs endpoints (GET, DELETE)
193
- if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs')) && (method === 'GET' || method === 'DELETE')) {
194
- setExecCors(req, res);
195
- handleExecRequest(req, res);
196
- return;
197
- }
198
-
199
- // 나머지는 Express 처리
200
- (app as (req: http.IncomingMessage, res: http.ServerResponse) => void)(req, res);
201
- });
202
-
203
- server.timeout = 0;
204
- server.requestTimeout = 0;
205
+ return app;
206
+ }
205
207
 
206
- return server;
208
+ function setExecCors(req: http.IncomingMessage, res: http.ServerResponse): void {
209
+ const origin = req.headers.origin;
210
+ if (!origin) return;
211
+ if (isProd || /^http:\/\/localhost:\d+$/.test(origin)) {
212
+ res.setHeader('Access-Control-Allow-Origin', origin);
213
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
214
+ }
207
215
  }
@@ -1,5 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { readPreferences } from '../services/preferences.js';
4
+ import { readConfig } from '../services/company-config.js';
3
5
  import {
4
6
  type OrgTree,
5
7
  type OrgNode,
@@ -97,7 +99,13 @@ export function assembleContext(
97
99
  sections.push('# CEO Decisions (전사 공지)\n\n' + ceoDecisions);
98
100
  }
99
101
 
100
- // 9. Task는 별도 필드로 분리
102
+ // 9. Code Root (코드 프로젝트 경로)
103
+ const config = readConfig(companyRoot);
104
+ if (config.codeRoot) {
105
+ sections.push(`# Code Project\n\nThe code repository is located at: \`${config.codeRoot}\`\nUse this path when working with source code (reading, writing, building, testing).`);
106
+ }
107
+
108
+ // 10. Task는 별도 필드로 분리
101
109
  const subordinates = getSubordinates(orgTree, roleId);
102
110
 
103
111
  // Dispatch 도구 안내 (하위 Role이 있는 경우)
@@ -105,6 +113,38 @@ export function assembleContext(
105
113
  sections.push(buildDispatchSection(orgTree, roleId, subordinates, options?.teamStatus));
106
114
  }
107
115
 
116
+ // Language preference
117
+ const prefs = readPreferences(companyRoot);
118
+ const lang = prefs.language ?? 'auto';
119
+ if (lang !== 'auto') {
120
+ const langNames: Record<string, string> = { en: 'English', ko: 'Korean', ja: 'Japanese' };
121
+ sections.push(`# Language\n\nAlways respond in **${langNames[lang] ?? lang}**. All output — reports, analysis, code comments, status updates — must be in ${langNames[lang] ?? lang}.`);
122
+ }
123
+
124
+ // Execution behavior rules (prevents infinite exploration loops in -p mode)
125
+ sections.push(`# Execution Rules (CRITICAL)
126
+
127
+ ## Interpreting Tasks
128
+ - A [CEO Wave] is a directive from the CEO. Interpret it based on your role's expertise.
129
+ - If the directive is vague, focus on what YOUR ROLE can contribute. Don't try to cover everything.
130
+ - Break ambiguous directives into concrete actions within your authority scope.
131
+ - If you truly cannot determine what to do, state your interpretation and proceed with it.
132
+
133
+ ## Efficiency
134
+ - Read ONLY files directly relevant to your task. Do NOT explore the codebase broadly.
135
+ - If a file doesn't exist at the expected path, try at most 2 alternatives, then move on.
136
+ - Do NOT use \`find\` or \`ls\` to scan entire directory trees. Use the Project Structure above.
137
+ - Never \`sleep\` or poll in loops. If something isn't ready, report it and move on.
138
+
139
+ ## When Stuck
140
+ - If you cannot find what you need after 3 search attempts, STOP searching immediately.
141
+ - Do NOT retry the same failing command or approach.
142
+ - Summarize what you found, what you couldn't find, and deliver your best answer with what you have.
143
+
144
+ ## Output
145
+ - Always produce a concrete deliverable: code change, report, analysis, or clear status update.
146
+ - End with a brief summary of what you did and any unresolved items.`);
147
+
108
148
  const systemPrompt = sections.join('\n\n---\n\n');
109
149
 
110
150
  return {
@@ -127,25 +167,11 @@ function loadCompanyRules(companyRoot: string): string | null {
127
167
  const claudeMdPath = path.join(companyRoot, 'CLAUDE.md');
128
168
  if (!fs.existsSync(claudeMdPath)) return null;
129
169
 
130
- const content = fs.readFileSync(claudeMdPath, 'utf-8');
131
-
132
- // Extract key sections: AI 작업 규칙, AKB 관리
133
- // We keep it focused on operational rules, not the full CLAUDE.md
134
- const sections: string[] = [];
135
-
136
- // Extract AKB rules section
137
- const akbMatch = content.match(/### AKB 관리 의무[\s\S]*?(?=\n---|\n## [^#])/);
138
- if (akbMatch) {
139
- sections.push(akbMatch[0].trim());
140
- }
141
-
142
- // Extract Git rules
143
- const gitMatch = content.match(/### Git 규칙[\s\S]*?(?=\n###|\n---|\n## [^#])/);
144
- if (gitMatch) {
145
- sections.push(gitMatch[0].trim());
146
- }
147
-
148
- return sections.length > 0 ? sections.join('\n\n') : content.slice(0, 2000);
170
+ // Give the full CLAUDE.md — it contains the routing table, folder structure,
171
+ // Hub-first principle, and other navigation info that roles need to work effectively.
172
+ // Previously we extracted only AKB + Git rules, but that stripped out the most
173
+ // practically useful parts (routing table, folder structure, skill principle).
174
+ return fs.readFileSync(claudeMdPath, 'utf-8');
149
175
  }
150
176
 
151
177
  function buildOrgContextSection(orgTree: OrgTree, node: OrgNode): string {
@@ -1,3 +1,4 @@
1
+ import { spawn } from 'node:child_process';
1
2
  import Anthropic from '@anthropic-ai/sdk';
2
3
 
3
4
  /* ─── Types ──────────────────────────────────── */
@@ -211,6 +212,78 @@ export class AnthropicProvider implements LLMProvider {
211
212
  }
212
213
  }
213
214
 
215
+ /* ─── Claude CLI Provider ───────────────────── */
216
+
217
+ /**
218
+ * Claude CLI (`claude -p`)를 LLMProvider로 사용.
219
+ * Claude Max 구독 기반 — API 키 불필요.
220
+ * Chat pipeline (speech) 등 간단한 텍스트 생성에 사용.
221
+ */
222
+ export class ClaudeCliProvider implements LLMProvider {
223
+ private model: string;
224
+
225
+ constructor(options?: { model?: string }) {
226
+ this.model = options?.model || 'claude-haiku-4-5-20251001';
227
+ }
228
+
229
+ async chat(
230
+ systemPrompt: string,
231
+ messages: LLMMessage[],
232
+ _tools?: ToolDefinition[],
233
+ signal?: AbortSignal,
234
+ ): Promise<LLMResponse> {
235
+ // Build user message from messages array
236
+ const userText = messages
237
+ .filter(m => m.role === 'user')
238
+ .map(m => typeof m.content === 'string' ? m.content : m.content.filter(c => c.type === 'text').map(c => (c as { type: 'text'; text: string }).text).join(''))
239
+ .join('\n');
240
+
241
+ return new Promise((resolve, reject) => {
242
+ const args = [
243
+ '-p',
244
+ '--system-prompt', systemPrompt,
245
+ '--model', this.model,
246
+ '--max-turns', '1',
247
+ '--output-format', 'text',
248
+ userText,
249
+ ];
250
+
251
+ const cleanEnv = { ...process.env };
252
+ delete cleanEnv.CLAUDECODE;
253
+
254
+ const proc = spawn('claude', args, {
255
+ env: cleanEnv,
256
+ stdio: ['ignore', 'pipe', 'pipe'],
257
+ });
258
+
259
+ let stdout = '';
260
+ let stderr = '';
261
+
262
+ proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
263
+ proc.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
264
+
265
+ if (signal) {
266
+ signal.addEventListener('abort', () => proc.kill('SIGTERM'), { once: true });
267
+ }
268
+
269
+ proc.on('close', (code) => {
270
+ const text = stdout.trim();
271
+ if (code !== 0 && !text) {
272
+ reject(new Error(`claude-cli exited with code ${code}: ${stderr}`));
273
+ return;
274
+ }
275
+ resolve({
276
+ content: [{ type: 'text', text }],
277
+ stopReason: 'end_turn',
278
+ usage: { inputTokens: 0, outputTokens: 0 },
279
+ });
280
+ });
281
+
282
+ proc.on('error', reject);
283
+ });
284
+ }
285
+ }
286
+
214
287
  /* ─── Backwards Compatibility ────────────────── */
215
288
 
216
289
  /** @deprecated Use AnthropicProvider instead */
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import { assembleContext } from '../context-assembler.js';
6
6
  import { getSubordinates } from '../org-tree.js';
7
+ import { readConfig } from '../../services/company-config.js';
7
8
  import type { ExecutionRunner, RunnerConfig, RunnerCallbacks, RunnerHandle, RunnerResult } from './types.js';
8
9
 
9
10
  /* ─── Dispatch Bridge Script (Python3) ────── */
@@ -169,6 +170,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
169
170
  });
170
171
 
171
172
  // 6. CLI args 구성
173
+ const maxTurns = config.maxTurns ?? 25;
172
174
  const args = [
173
175
  '-p',
174
176
  '--system-prompt', fs.readFileSync(promptFile, 'utf-8'),
@@ -176,6 +178,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
176
178
  '--verbose',
177
179
  '--dangerously-skip-permissions',
178
180
  '--model', config.model ?? 'claude-sonnet-4-5',
181
+ '--max-turns', String(maxTurns),
179
182
  '--mcp-config', mcpConfig,
180
183
  '--strict-mcp-config',
181
184
  taskPrompt,
@@ -197,10 +200,13 @@ export class ClaudeCliRunner implements ExecutionRunner {
197
200
  cleanEnv.DISPATCH_CMD = dispatchScript;
198
201
 
199
202
  const modelName = config.model ?? 'claude-sonnet-4-5';
200
- console.log(`[Runner] Spawning claude -p: role=${roleId}, model=${modelName}, jobId=${config.jobId ?? 'none'}, subordinates=[${subordinates.join(',')}]`);
203
+ // Use codeRoot as cwd if configured, otherwise fall back to companyRoot
204
+ const companyConfig = readConfig(companyRoot);
205
+ const cwd = companyConfig.codeRoot || companyRoot;
206
+ console.log(`[Runner] Spawning claude -p: role=${roleId}, model=${modelName}, maxTurns=${maxTurns}, jobId=${config.jobId ?? 'none'}, cwd=${cwd}, subordinates=[${subordinates.join(',')}]`);
201
207
 
202
208
  const proc = spawn('claude', args, {
203
- cwd: companyRoot,
209
+ cwd,
204
210
  env: cleanEnv,
205
211
  stdio: ['ignore', 'pipe', 'pipe'],
206
212
  });
@@ -0,0 +1,98 @@
1
+ import { Router, Request, Response, NextFunction } from 'express';
2
+ import { COMPANY_ROOT } from '../services/file-reader.js';
3
+ import { getTokenLedger } from '../services/token-ledger.js';
4
+ import { estimateCost } from '../services/pricing.js';
5
+
6
+ export const costRouter = Router();
7
+
8
+ /* ── W-T601: GET /api/cost/summary ───────── */
9
+
10
+ costRouter.get('/summary', (req: Request, res: Response, next: NextFunction) => {
11
+ try {
12
+ const from = req.query.from as string | undefined;
13
+ const to = req.query.to as string | undefined;
14
+
15
+ const ledger = getTokenLedger(COMPANY_ROOT);
16
+ const summary = ledger.query({ from, to });
17
+
18
+ // Role-by-role aggregation
19
+ const byRole: Record<string, { inputTokens: number; outputTokens: number; costUsd: number }> = {};
20
+ // Model-by-model aggregation
21
+ const byModel: Record<string, { inputTokens: number; outputTokens: number; costUsd: number }> = {};
22
+
23
+ for (const entry of summary.entries) {
24
+ // By role
25
+ if (!byRole[entry.roleId]) {
26
+ byRole[entry.roleId] = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
27
+ }
28
+ byRole[entry.roleId].inputTokens += entry.inputTokens;
29
+ byRole[entry.roleId].outputTokens += entry.outputTokens;
30
+ byRole[entry.roleId].costUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
31
+
32
+ // By model
33
+ if (!byModel[entry.model]) {
34
+ byModel[entry.model] = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
35
+ }
36
+ byModel[entry.model].inputTokens += entry.inputTokens;
37
+ byModel[entry.model].outputTokens += entry.outputTokens;
38
+ byModel[entry.model].costUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
39
+ }
40
+
41
+ const totalCostUsd = estimateCost(summary.totalInput, summary.totalOutput, '');
42
+
43
+ // Compute total cost from individual entries (more accurate with mixed models)
44
+ let totalCostFromEntries = 0;
45
+ for (const entry of summary.entries) {
46
+ totalCostFromEntries += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
47
+ }
48
+
49
+ res.json({
50
+ from: from ?? null,
51
+ to: to ?? null,
52
+ totalInputTokens: summary.totalInput,
53
+ totalOutputTokens: summary.totalOutput,
54
+ totalCostUsd: totalCostFromEntries,
55
+ byRole,
56
+ byModel,
57
+ });
58
+ } catch (err) {
59
+ next(err);
60
+ }
61
+ });
62
+
63
+ /* ── W-T602: GET /api/cost/jobs/:jobId ───── */
64
+
65
+ costRouter.get('/jobs/:jobId', (req: Request, res: Response, next: NextFunction) => {
66
+ try {
67
+ const jobId = req.params.jobId as string;
68
+ const ledger = getTokenLedger(COMPANY_ROOT);
69
+ const summary = ledger.query({ jobId });
70
+
71
+ if (summary.entries.length === 0) {
72
+ res.status(404).json({ error: `No cost data found for job ${jobId}` });
73
+ return;
74
+ }
75
+
76
+ let totalCostUsd = 0;
77
+ for (const entry of summary.entries) {
78
+ totalCostUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
79
+ }
80
+
81
+ res.json({
82
+ jobId,
83
+ totalInputTokens: summary.totalInput,
84
+ totalOutputTokens: summary.totalOutput,
85
+ totalCostUsd,
86
+ entries: summary.entries.map((e) => ({
87
+ ts: e.ts,
88
+ roleId: e.roleId,
89
+ model: e.model,
90
+ inputTokens: e.inputTokens,
91
+ outputTokens: e.outputTokens,
92
+ costUsd: estimateCost(e.inputTokens, e.outputTokens, e.model),
93
+ })),
94
+ });
95
+ } catch (err) {
96
+ next(err);
97
+ }
98
+ });
@@ -477,20 +477,27 @@ function handleWave(body: Record<string, unknown>, req: IncomingMessage, res: Se
477
477
  function handleStatus(res: ServerResponse): void {
478
478
  const statuses: Record<string, string> = {};
479
479
 
480
- for (const [roleId, status] of roleStatus) {
481
- statuses[roleId] = status;
482
- }
483
-
484
- // Merge with file-backed activity tracker
480
+ // 1. File-backed activity tracker (baseline)
485
481
  const fileActivities = getAllActivities();
486
482
  for (const activity of fileActivities) {
487
- if (!statuses[activity.roleId] || statuses[activity.roleId] === 'idle') {
488
- statuses[activity.roleId] = activity.status;
489
- }
483
+ statuses[activity.roleId] = activity.status;
490
484
  }
491
485
 
492
- // Merge JobManager running jobs
486
+ // 2. JobManager running jobs are the source of truth for "working"
493
487
  const runningJobs = jobManager.listJobs({ status: 'running' });
488
+ const runningRoles = new Set(runningJobs.map(j => j.roleId));
489
+
490
+ // 3. Any role marked "working" in file/memory but NOT in JobManager → done
491
+ for (const roleId of Object.keys(statuses)) {
492
+ if (statuses[roleId] === 'working' && !runningRoles.has(roleId)) {
493
+ statuses[roleId] = 'done';
494
+ // Also fix stale roleStatus map
495
+ roleStatus.set(roleId, 'idle');
496
+ completeActivity(roleId);
497
+ }
498
+ }
499
+
500
+ // 4. Running jobs override everything
494
501
  for (const job of runningJobs) {
495
502
  statuses[job.roleId] = 'working';
496
503
  }
@@ -14,7 +14,7 @@ import type { ScaffoldConfig } from '../services/scaffold.js';
14
14
  import { importKnowledge } from '../services/knowledge-importer.js';
15
15
  import { AnthropicProvider, type LLMProvider } from '../engine/llm-adapter.js';
16
16
  import { jobManager } from '../services/job-manager.js';
17
- import { applyConfig, readConfig } from '../services/company-config.js';
17
+ import { applyConfig, readConfig, writeConfig } from '../services/company-config.js';
18
18
 
19
19
  export const setupRouter = Router();
20
20
 
@@ -81,7 +81,7 @@ setupRouter.post('/validate-path', (req, res) => {
81
81
  * POST /api/setup/scaffold
82
82
  */
83
83
  setupRouter.post('/scaffold', (req, res) => {
84
- const { companyName, description, apiKey, team, existingProjectPath, knowledgePaths } = req.body;
84
+ const { companyName, description, apiKey, team, existingProjectPath, knowledgePaths, codeRoot } = req.body;
85
85
 
86
86
  if (!companyName || typeof companyName !== 'string') {
87
87
  res.status(400).json({ error: 'companyName is required' });
@@ -105,7 +105,11 @@ setupRouter.post('/scaffold', (req, res) => {
105
105
 
106
106
  process.env.COMPANY_ROOT = projectRoot;
107
107
  // Load config.json written by scaffold and apply to process.env
108
- applyConfig(projectRoot);
108
+ const scaffoldConfig = applyConfig(projectRoot);
109
+ // Save codeRoot if provided
110
+ if (codeRoot && typeof codeRoot === 'string') {
111
+ writeConfig(projectRoot, { ...scaffoldConfig, codeRoot });
112
+ }
109
113
  jobManager.refreshRunner();
110
114
 
111
115
  res.json({ ok: true, companyName, projectRoot, created });
@@ -197,7 +201,7 @@ setupRouter.post('/connect-akb', (req, res) => {
197
201
  applyConfig(resolved);
198
202
  jobManager.refreshRunner();
199
203
 
200
- res.json({ ok: true, companyName, companyRoot: resolved, engine: config.engine });
204
+ res.json({ ok: true, companyName, companyRoot: resolved, engine: config.engine, codeRoot: config.codeRoot || null });
201
205
  });
202
206
 
203
207
  /**
@@ -250,6 +254,31 @@ setupRouter.post('/import-knowledge', (req, res) => {
250
254
  });
251
255
  });
252
256
 
257
+ /**
258
+ * POST /api/setup/code-root
259
+ * Set or update the codeRoot config field.
260
+ */
261
+ setupRouter.post('/code-root', (req, res) => {
262
+ const { codeRoot: newCodeRoot } = req.body;
263
+ const companyRoot = process.env.COMPANY_ROOT || process.cwd();
264
+
265
+ if (!newCodeRoot || typeof newCodeRoot !== 'string') {
266
+ res.status(400).json({ ok: false, error: 'codeRoot path is required' });
267
+ return;
268
+ }
269
+
270
+ const resolved = path.resolve(newCodeRoot);
271
+ if (!fs.existsSync(resolved)) {
272
+ res.status(400).json({ ok: false, error: 'Path does not exist' });
273
+ return;
274
+ }
275
+
276
+ const config = readConfig(companyRoot);
277
+ writeConfig(companyRoot, { ...config, codeRoot: resolved });
278
+
279
+ res.json({ ok: true, codeRoot: resolved });
280
+ });
281
+
253
282
  /**
254
283
  * GET /api/setup/teams
255
284
  */