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,134 @@
1
+ /**
2
+ * active-sessions.ts — Active session visibility API
3
+ *
4
+ * Exposes session + port + worktree state for both UI and AI agents.
5
+ */
6
+ import { Router } from 'express';
7
+ import { portRegistry } from '../services/port-registry.js';
8
+ import { executionManager } from '../services/execution-manager.js';
9
+ import { getSession } from '../services/session-store.js';
10
+
11
+ export const activeSessionsRouter = Router();
12
+
13
+ /**
14
+ * GET /api/active-sessions
15
+ */
16
+ activeSessionsRouter.get('/', (_req, res) => {
17
+ const sessions = portRegistry.getAll();
18
+
19
+ const enriched = sessions.map(s => {
20
+ const exec = executionManager.getActiveExecution(s.sessionId);
21
+ const session = getSession(s.sessionId);
22
+ return {
23
+ ...s,
24
+ waveId: session?.waveId ?? null,
25
+ messageStatus: exec?.status ?? null,
26
+ roleName: exec?.roleId ?? s.roleId,
27
+ alive: s.pid ? isAlive(s.pid) : null,
28
+ };
29
+ });
30
+
31
+ res.json({
32
+ sessions: enriched,
33
+ summary: portRegistry.getSummary(),
34
+ });
35
+ });
36
+
37
+ /**
38
+ * GET /api/active-sessions/:id
39
+ */
40
+ activeSessionsRouter.get('/:id', (req, res) => {
41
+ const session = portRegistry.get(req.params.id);
42
+ if (!session) {
43
+ res.status(404).json({ error: 'Session not found' });
44
+ return;
45
+ }
46
+
47
+ const exec = executionManager.getActiveExecution(session.sessionId);
48
+
49
+ res.json({
50
+ ...session,
51
+ messageStatus: exec?.status ?? null,
52
+ roleName: exec?.roleId ?? session.roleId,
53
+ alive: session.pid ? isAlive(session.pid) : null,
54
+ execution: exec ? { id: exec.id, status: exec.status, roleId: exec.roleId, task: exec.task } : null,
55
+ });
56
+ });
57
+
58
+ /**
59
+ * DELETE /api/active-sessions/:id
60
+ */
61
+ activeSessionsRouter.delete('/:id', (req, res) => {
62
+ const sessionId = req.params.id;
63
+ const session = portRegistry.get(sessionId);
64
+
65
+ if (!session) {
66
+ res.status(404).json({ error: 'Session not found' });
67
+ return;
68
+ }
69
+
70
+ executionManager.abortSession(sessionId);
71
+ portRegistry.release(sessionId);
72
+
73
+ res.json({ ok: true, released: session.ports });
74
+ });
75
+
76
+ /**
77
+ * POST /api/active-sessions/cleanup
78
+ */
79
+ activeSessionsRouter.post('/cleanup', (_req, res) => {
80
+ const result = portRegistry.cleanup();
81
+ res.json({
82
+ cleaned: result.cleaned.length,
83
+ remaining: result.remaining.length,
84
+ sessions: result.cleaned.map(s => ({
85
+ sessionId: s.sessionId,
86
+ roleId: s.roleId,
87
+ ports: s.ports,
88
+ })),
89
+ });
90
+ });
91
+
92
+ /**
93
+ * POST /api/active-sessions/register
94
+ */
95
+ activeSessionsRouter.post('/register', async (req, res) => {
96
+ const { sessionId, roleId, task, pid, worktreePath } = req.body;
97
+
98
+ if (!sessionId || !roleId) {
99
+ res.status(400).json({ error: 'sessionId and roleId are required' });
100
+ return;
101
+ }
102
+
103
+ const existing = portRegistry.get(sessionId);
104
+ if (existing) {
105
+ res.json({ ok: true, ports: existing.ports, existing: true });
106
+ return;
107
+ }
108
+
109
+ const ports = await portRegistry.allocate(
110
+ sessionId,
111
+ roleId,
112
+ task || 'Manual session',
113
+ );
114
+
115
+ if (pid || worktreePath) {
116
+ portRegistry.update(sessionId, {
117
+ pid: pid ?? undefined,
118
+ worktreePath: worktreePath ?? undefined,
119
+ });
120
+ }
121
+
122
+ res.json({ ok: true, ports, existing: false });
123
+ });
124
+
125
+ /* ─── Helpers ────────────────────────────── */
126
+
127
+ function isAlive(pid: number): boolean {
128
+ try {
129
+ process.kill(pid, 0);
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
@@ -0,0 +1,153 @@
1
+ import { Router, Request, Response, NextFunction } from 'express';
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { COMPANY_ROOT } from '../services/file-reader.js';
5
+
6
+ export const coinsRouter = Router();
7
+
8
+ /* ── Types ── */
9
+
10
+ interface CoinTransaction {
11
+ ts: string;
12
+ amount: number;
13
+ reason: string;
14
+ ref?: string; // questId, jobId, etc.
15
+ }
16
+
17
+ interface CoinsData {
18
+ balance: number;
19
+ totalEarned: number;
20
+ totalSpent: number;
21
+ transactions: CoinTransaction[];
22
+ }
23
+
24
+ /* ── Persistence ── */
25
+
26
+ const COINS_FILE = () => join(COMPANY_ROOT, '.tycono', 'coins.json');
27
+
28
+ const DEFAULT_DATA: CoinsData = {
29
+ balance: 0,
30
+ totalEarned: 0,
31
+ totalSpent: 0,
32
+ transactions: [],
33
+ };
34
+
35
+ function readCoins(): CoinsData {
36
+ try {
37
+ if (existsSync(COINS_FILE())) {
38
+ return JSON.parse(readFileSync(COINS_FILE(), 'utf-8'));
39
+ }
40
+ } catch { /* use defaults */ }
41
+ return { ...DEFAULT_DATA, transactions: [] };
42
+ }
43
+
44
+ function writeCoins(data: CoinsData) {
45
+ mkdirSync(join(COMPANY_ROOT, '.tycono'), { recursive: true });
46
+ writeFileSync(COINS_FILE(), JSON.stringify(data, null, 2) + '\n');
47
+ }
48
+
49
+ /* ── Internal API (for server-side use) ── */
50
+
51
+ export function earnCoinsInternal(amount: number, reason: string, ref?: string): { balance: number; skipped: boolean } {
52
+ const data = readCoins();
53
+ // Idempotency
54
+ if (ref && data.transactions.some(t => t.ref === ref && t.amount > 0)) {
55
+ return { balance: data.balance, skipped: true };
56
+ }
57
+ const tx: CoinTransaction = { ts: new Date().toISOString(), amount, reason, ref };
58
+ data.balance += amount;
59
+ data.totalEarned += amount;
60
+ data.transactions.push(tx);
61
+ writeCoins(data);
62
+ return { balance: data.balance, skipped: false };
63
+ }
64
+
65
+ /* ── Routes ── */
66
+
67
+ // GET /api/coins — current balance + summary
68
+ coinsRouter.get('/', (_req: Request, res: Response, next: NextFunction) => {
69
+ try {
70
+ res.json(readCoins());
71
+ } catch (err) { next(err); }
72
+ });
73
+
74
+ // POST /api/coins/earn — add coins
75
+ coinsRouter.post('/earn', (req: Request, res: Response, next: NextFunction) => {
76
+ try {
77
+ const { amount, reason, ref } = req.body;
78
+ if (typeof amount !== 'number' || amount <= 0) {
79
+ res.status(400).json({ error: 'amount must be a positive number' });
80
+ return;
81
+ }
82
+ const data = readCoins();
83
+ // Idempotency: skip if same ref already earned (prevents double quest rewards)
84
+ if (ref && data.transactions.some(t => t.ref === ref && t.amount > 0)) {
85
+ res.json({ ok: true, balance: data.balance, skipped: true });
86
+ return;
87
+ }
88
+ const tx: CoinTransaction = {
89
+ ts: new Date().toISOString(),
90
+ amount,
91
+ reason: reason || 'earn',
92
+ ref,
93
+ };
94
+ data.balance += amount;
95
+ data.totalEarned += amount;
96
+ data.transactions.push(tx);
97
+ writeCoins(data);
98
+ res.json({ ok: true, balance: data.balance, transaction: tx });
99
+ } catch (err) { next(err); }
100
+ });
101
+
102
+ // POST /api/coins/spend — deduct coins
103
+ coinsRouter.post('/spend', (req: Request, res: Response, next: NextFunction) => {
104
+ try {
105
+ const { amount, reason, ref } = req.body;
106
+ if (typeof amount !== 'number' || amount <= 0) {
107
+ res.status(400).json({ error: 'amount must be a positive number' });
108
+ return;
109
+ }
110
+ const data = readCoins();
111
+ if (data.balance < amount) {
112
+ res.status(400).json({ error: 'insufficient balance', balance: data.balance, required: amount });
113
+ return;
114
+ }
115
+ const tx: CoinTransaction = {
116
+ ts: new Date().toISOString(),
117
+ amount: -amount,
118
+ reason: reason || 'spend',
119
+ ref,
120
+ };
121
+ data.balance -= amount;
122
+ data.totalSpent += amount;
123
+ data.transactions.push(tx);
124
+ writeCoins(data);
125
+ res.json({ ok: true, balance: data.balance, transaction: tx });
126
+ } catch (err) { next(err); }
127
+ });
128
+
129
+ // POST /api/coins/migrate — initial coin grant for existing users
130
+ coinsRouter.post('/migrate', (req: Request, res: Response, next: NextFunction) => {
131
+ try {
132
+ const data = readCoins();
133
+ // Only migrate once
134
+ if (data.totalEarned > 0) {
135
+ res.json({ ok: true, skipped: true, balance: data.balance });
136
+ return;
137
+ }
138
+ const { completedQuests = 0 } = req.body;
139
+ const grantAmount = completedQuests > 0 ? completedQuests * 2000 : 5000;
140
+ const reason = completedQuests > 0 ? `migration: ${completedQuests} quests × 2,000` : 'welcome bonus';
141
+ const tx: CoinTransaction = {
142
+ ts: new Date().toISOString(),
143
+ amount: grantAmount,
144
+ reason,
145
+ ref: 'migration',
146
+ };
147
+ data.balance = grantAmount;
148
+ data.totalEarned = grantAmount;
149
+ data.transactions.push(tx);
150
+ writeCoins(data);
151
+ res.json({ ok: true, balance: data.balance, granted: grantAmount, reason });
152
+ } catch (err) { next(err); }
153
+ });
@@ -0,0 +1,57 @@
1
+ import { Router, Request, Response, NextFunction } from 'express';
2
+ import YAML from 'yaml';
3
+ import { readFile, fileExists } from '../services/file-reader.js';
4
+ import { parseMarkdownTable, extractBoldKeyValues } from '../services/markdown-parser.js';
5
+
6
+ export const companyRouter = Router();
7
+
8
+ // GET /api/company — 회사 기본 정보
9
+ companyRouter.get('/', (_req: Request, res: Response, next: NextFunction) => {
10
+ try {
11
+ const companyContent = readFile('knowledge/company.md');
12
+ const kv = extractBoldKeyValues(companyContent);
13
+
14
+ // blockquote에서 미션 추출
15
+ const missionMatch = companyContent.match(/^>\s*(.+)/m);
16
+ const mission = missionMatch ? missionMatch[1].trim() : '';
17
+
18
+ // Role 목록
19
+ const rolesContent = readFile('knowledge/roles/roles.md');
20
+ const roleRows = parseMarkdownTable(rolesContent);
21
+ const roles = roleRows
22
+ .filter(row => (row.id ?? '').toLowerCase() !== 'ceo')
23
+ .map(row => {
24
+ const id = row.id ?? '';
25
+ let name = row.role ?? row.name ?? '';
26
+
27
+ // role.yaml의 name이 있으면 우선 사용 (커스텀 이름 반영)
28
+ const yamlPath = `knowledge/roles/${id}/role.yaml`;
29
+ if (id && fileExists(yamlPath)) {
30
+ try {
31
+ const raw = YAML.parse(readFile(yamlPath)) as Record<string, unknown>;
32
+ if (raw.name) name = raw.name as string;
33
+ } catch { /* fallback to roles.md name */ }
34
+ }
35
+
36
+ return {
37
+ id,
38
+ name,
39
+ level: row.level ?? '',
40
+ reportsTo: row.reports_to ?? '',
41
+ status: row.상태 ?? row.status ?? '',
42
+ };
43
+ });
44
+
45
+ const company = {
46
+ name: companyContent.split('\n').find(l => l.startsWith('# '))?.replace(/^#\s+/, '') ?? '',
47
+ domain: kv['도메인'] ?? kv['domain'] ?? '',
48
+ founded: kv['설립일'] ?? kv['founded'] ?? '',
49
+ mission,
50
+ roles,
51
+ };
52
+
53
+ res.json(company);
54
+ } catch (err) {
55
+ next(err);
56
+ }
57
+ });
@@ -0,0 +1,141 @@
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
+ /* @deprecated D-014: use /api/cost/sessions/:sessionId */
65
+
66
+ costRouter.get('/jobs/:jobId', (req: Request, res: Response, next: NextFunction) => {
67
+ try {
68
+ const jobId = req.params.jobId as string;
69
+ const ledger = getTokenLedger(COMPANY_ROOT);
70
+ const summary = ledger.query({ jobId });
71
+
72
+ if (summary.entries.length === 0) {
73
+ res.status(404).json({ error: `No cost data found for job ${jobId}` });
74
+ return;
75
+ }
76
+
77
+ let totalCostUsd = 0;
78
+ for (const entry of summary.entries) {
79
+ totalCostUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
80
+ }
81
+
82
+ res.json({
83
+ jobId,
84
+ totalInputTokens: summary.totalInput,
85
+ totalOutputTokens: summary.totalOutput,
86
+ totalCostUsd,
87
+ entries: summary.entries.map((e) => ({
88
+ ts: e.ts,
89
+ roleId: e.roleId,
90
+ model: e.model,
91
+ inputTokens: e.inputTokens,
92
+ outputTokens: e.outputTokens,
93
+ costUsd: estimateCost(e.inputTokens, e.outputTokens, e.model),
94
+ })),
95
+ });
96
+ } catch (err) {
97
+ next(err);
98
+ }
99
+ });
100
+
101
+ /* ── D-014: GET /api/cost/sessions/:sessionId ───── */
102
+
103
+ costRouter.get('/sessions/:sessionId', (req: Request, res: Response, next: NextFunction) => {
104
+ try {
105
+ const sessionId = req.params.sessionId as string;
106
+ const ledger = getTokenLedger(COMPANY_ROOT);
107
+
108
+ // D-014: Try sessionId field first, fall back to jobId for legacy entries
109
+ let summary = ledger.query({ sessionId });
110
+ if (summary.entries.length === 0) {
111
+ summary = ledger.query({ jobId: sessionId });
112
+ }
113
+
114
+ if (summary.entries.length === 0) {
115
+ res.status(404).json({ error: `No cost data found for session ${sessionId}` });
116
+ return;
117
+ }
118
+
119
+ let totalCostUsd = 0;
120
+ for (const entry of summary.entries) {
121
+ totalCostUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
122
+ }
123
+
124
+ res.json({
125
+ sessionId,
126
+ totalInputTokens: summary.totalInput,
127
+ totalOutputTokens: summary.totalOutput,
128
+ totalCostUsd,
129
+ entries: summary.entries.map((e) => ({
130
+ ts: e.ts,
131
+ roleId: e.roleId,
132
+ model: e.model,
133
+ inputTokens: e.inputTokens,
134
+ outputTokens: e.outputTokens,
135
+ costUsd: estimateCost(e.inputTokens, e.outputTokens, e.model),
136
+ })),
137
+ });
138
+ } catch (err) {
139
+ next(err);
140
+ }
141
+ });
@@ -0,0 +1,220 @@
1
+ import { Router, Request, Response, NextFunction } from 'express';
2
+ import { COMPANY_ROOT } from '../services/file-reader.js';
3
+ import {
4
+ buildOrgTree,
5
+ assembleContext,
6
+ validateDispatch,
7
+ RoleLifecycleManager,
8
+ formatOrgChart,
9
+ } from '../engine/index.js';
10
+ import { createRunner } from '../engine/runners/index.js';
11
+
12
+ export const engineRouter = Router();
13
+
14
+ /* ─── GET /api/engine/org — Org tree ─────────── */
15
+
16
+ engineRouter.get('/org', (_req: Request, res: Response, next: NextFunction) => {
17
+ try {
18
+ const tree = buildOrgTree(COMPANY_ROOT);
19
+
20
+ // Serialize Map to plain object
21
+ const nodes: Record<string, unknown> = {};
22
+ for (const [id, node] of tree.nodes) {
23
+ nodes[id] = {
24
+ id: node.id,
25
+ name: node.name,
26
+ level: node.level,
27
+ reportsTo: node.reportsTo,
28
+ children: node.children,
29
+ };
30
+ }
31
+
32
+ res.json({
33
+ root: tree.root,
34
+ nodes,
35
+ chart: formatOrgChart(tree),
36
+ });
37
+ } catch (err) {
38
+ next(err);
39
+ }
40
+ });
41
+
42
+ /* ─── GET /api/engine/context/:roleId — Preview assembled context ── */
43
+
44
+ engineRouter.get('/context/:roleId', (req: Request, res: Response, next: NextFunction) => {
45
+ try {
46
+ const roleId = String(req.params.roleId ?? '');
47
+ const sourceRole = String(req.query.source ?? 'ceo');
48
+ const task = String(req.query.task ?? '(preview — no task specified)');
49
+
50
+ const tree = buildOrgTree(COMPANY_ROOT);
51
+ const context = assembleContext(COMPANY_ROOT, roleId as string, task, sourceRole, tree);
52
+
53
+ res.json({
54
+ targetRole: context.targetRole,
55
+ sourceRole: context.sourceRole,
56
+ metadata: context.metadata,
57
+ systemPromptLength: context.systemPrompt.length,
58
+ systemPromptPreview: context.systemPrompt.slice(0, 3000),
59
+ task: context.task,
60
+ });
61
+ } catch (err) {
62
+ next(err);
63
+ }
64
+ });
65
+
66
+ /* ─── POST /api/engine/dispatch/validate — Check dispatch authority ── */
67
+
68
+ engineRouter.post('/dispatch/validate', (req: Request, res: Response, next: NextFunction) => {
69
+ try {
70
+ const { sourceRole, targetRole } = req.body;
71
+
72
+ if (!sourceRole || !targetRole) {
73
+ res.status(400).json({ error: 'sourceRole and targetRole are required' });
74
+ return;
75
+ }
76
+
77
+ const tree = buildOrgTree(COMPANY_ROOT);
78
+ const result = validateDispatch(tree, sourceRole, targetRole);
79
+
80
+ res.json(result);
81
+ } catch (err) {
82
+ next(err);
83
+ }
84
+ });
85
+
86
+ /* ─── POST /api/engine/roles — Create a new role ── */
87
+
88
+ engineRouter.post('/roles', async (req: Request, res: Response, next: NextFunction) => {
89
+ try {
90
+ const def = req.body;
91
+
92
+ if (!def.id || !def.name || !def.reportsTo) {
93
+ res.status(400).json({ error: 'id, name, and reportsTo are required' });
94
+ return;
95
+ }
96
+
97
+ const manager = new RoleLifecycleManager(COMPANY_ROOT);
98
+ await manager.createRole(def);
99
+
100
+ res.status(201).json({ ok: true, roleId: def.id });
101
+ } catch (err) {
102
+ next(err);
103
+ }
104
+ });
105
+
106
+ /* ─── PATCH /api/engine/roles/:id — Update a role ── */
107
+
108
+ engineRouter.patch('/roles/:id', async (req: Request, res: Response, next: NextFunction) => {
109
+ try {
110
+ const id = String(req.params.id);
111
+ const changes = req.body;
112
+
113
+ if (!changes || Object.keys(changes).length === 0) {
114
+ res.status(400).json({ error: 'No changes provided' });
115
+ return;
116
+ }
117
+
118
+ const manager = new RoleLifecycleManager(COMPANY_ROOT);
119
+ await manager.updateRole(id, changes);
120
+
121
+ res.json({ ok: true, roleId: id });
122
+ } catch (err) {
123
+ next(err);
124
+ }
125
+ });
126
+
127
+ /* ─── DELETE /api/engine/roles/:id — Remove a role ── */
128
+
129
+ engineRouter.delete('/roles/:id', async (req: Request, res: Response, next: NextFunction) => {
130
+ try {
131
+ const id = String(req.params.id);
132
+ const manager = new RoleLifecycleManager(COMPANY_ROOT);
133
+ await manager.removeRole(id);
134
+
135
+ res.json({ ok: true, removed: id });
136
+ } catch (err) {
137
+ next(err);
138
+ }
139
+ });
140
+
141
+ /* ─── GET /api/engine/roles/validate — Validate all roles ── */
142
+
143
+ engineRouter.get('/roles/validate', (_req: Request, res: Response, next: NextFunction) => {
144
+ try {
145
+ const manager = new RoleLifecycleManager(COMPANY_ROOT);
146
+ const results = manager.validateAll();
147
+
148
+ const output: Record<string, unknown> = {};
149
+ for (const [id, result] of results) {
150
+ output[id] = result;
151
+ }
152
+
153
+ res.json(output);
154
+ } catch (err) {
155
+ next(err);
156
+ }
157
+ });
158
+
159
+ /* ─── POST /api/engine/roles/:id/skill/regenerate — Regenerate SKILL.md ── */
160
+
161
+ engineRouter.post('/roles/:id/skill/regenerate', async (req: Request, res: Response, next: NextFunction) => {
162
+ try {
163
+ const id = String(req.params.id);
164
+ const manager = new RoleLifecycleManager(COMPANY_ROOT);
165
+ await manager.regenerateSkill(id);
166
+
167
+ res.json({ ok: true, roleId: id });
168
+ } catch (err) {
169
+ next(err);
170
+ }
171
+ });
172
+
173
+ /* ─── POST /api/engine/ask/:roleId — Ask a role a question (read-only) ── */
174
+
175
+ engineRouter.post('/ask/:roleId', async (req: Request, res: Response, next: NextFunction) => {
176
+ try {
177
+ const roleId = String(req.params.roleId);
178
+ const { question, sourceRole } = req.body;
179
+
180
+ if (!question) {
181
+ res.status(400).json({ error: 'question is required' });
182
+ return;
183
+ }
184
+
185
+ const source = sourceRole || 'ceo';
186
+ const orgTree = buildOrgTree(COMPANY_ROOT);
187
+
188
+ if (!orgTree.nodes.has(roleId)) {
189
+ res.status(404).json({ error: `Role not found: ${roleId}` });
190
+ return;
191
+ }
192
+
193
+ // Ask is read-only: no authority check required (anyone can ask anyone)
194
+ const handle = createRunner().execute(
195
+ {
196
+ companyRoot: COMPANY_ROOT,
197
+ roleId,
198
+ task: `[Question from ${source}] ${question}`,
199
+ sourceRole: source,
200
+ orgTree,
201
+ readOnly: true,
202
+ maxTurns: 5,
203
+ sessionId: `ask-${Date.now()}`,
204
+ },
205
+ {},
206
+ );
207
+
208
+ const result = await handle.promise;
209
+
210
+ res.json({
211
+ roleId,
212
+ question,
213
+ answer: result.output,
214
+ turns: result.turns,
215
+ tokens: result.totalTokens,
216
+ });
217
+ } catch (err) {
218
+ next(err);
219
+ }
220
+ });