tycono 0.1.7 → 0.1.9

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/README.md CHANGED
@@ -7,15 +7,11 @@
7
7
  ## Quick Start
8
8
 
9
9
  ```bash
10
- # Create a new company
11
10
  mkdir my-company && cd my-company
12
- npx tycono init
13
-
14
- # Start the dashboard
15
11
  npx tycono
16
12
  ```
17
13
 
18
- That's it. Your browser opens to a live dashboard showing your AI team at work.
14
+ That's it. A setup wizard guides you through creating your company, then your browser opens to a live dashboard showing your AI team at work.
19
15
 
20
16
  ## What You Get
21
17
 
@@ -33,7 +29,7 @@ That's it. Your browser opens to a live dashboard showing your AI team at work.
33
29
 
34
30
  ## Team Templates
35
31
 
36
- When you run `init`, pick a template:
32
+ During setup, pick a template:
37
33
 
38
34
  | Template | Roles |
39
35
  |----------|-------|
@@ -74,8 +70,7 @@ your-company/
74
70
  ## CLI Usage
75
71
 
76
72
  ```bash
77
- tycono # Start server + open dashboard
78
- tycono init # Create a new company
73
+ tycono # Start server + open dashboard (setup wizard if first run)
79
74
  tycono --help # Show help
80
75
  tycono --version # Show version
81
76
  ```
package/bin/tycono.ts CHANGED
@@ -20,9 +20,6 @@ function printHelp(): void {
20
20
 
21
21
  Usage:
22
22
  tycono Start the server and open dashboard
23
- tycono init Initialize a new company in current directory
24
- tycono init -y Use defaults (skip prompts)
25
- tycono init --name "Acme Corp" Set company name
26
23
  tycono --help Show this help message
27
24
  tycono --version Show version
28
25
 
@@ -178,12 +175,6 @@ export async function main(args: string[]): Promise<void> {
178
175
  return;
179
176
  }
180
177
 
181
- if (command === 'init') {
182
- const { runInit } = await import('./init.js');
183
- await runInit(args.slice(1));
184
- return;
185
- }
186
-
187
178
  if (command && !command.startsWith('-')) {
188
179
  console.error(` Unknown command: ${command}`);
189
180
  printHelp();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,8 @@ import { preferencesRouter } from './routes/preferences.js';
26
26
  import { saveRouter } from './routes/save.js';
27
27
  import { speechRouter } from './routes/speech.js';
28
28
  import { costRouter } from './routes/cost.js';
29
+ import { syncRouter } from './routes/sync.js';
30
+ import { gitRouter } from './routes/git.js';
29
31
  import { importKnowledge } from './services/knowledge-importer.js';
30
32
  import { AnthropicProvider, type LLMProvider } from './engine/llm-adapter.js';
31
33
  import { readConfig } from './services/company-config.js';
@@ -182,6 +184,8 @@ export function createExpressApp(): express.Application {
182
184
  app.use('/api/speech', speechRouter);
183
185
  app.use('/api/save', saveRouter);
184
186
  app.use('/api/cost', costRouter);
187
+ app.use('/api/sync', syncRouter);
188
+ app.use('/api/git', gitRouter);
185
189
 
186
190
  app.get('/api/health', (_req, res) => {
187
191
  res.json({ status: 'ok', companyRoot: COMPANY_ROOT });
@@ -196,8 +200,8 @@ export function createExpressApp(): express.Application {
196
200
  });
197
201
  }
198
202
 
199
- app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
200
- console.error(`[ERROR] ${err.message}`);
203
+ app.use((err: Error, req: express.Request, res: express.Response, _next: express.NextFunction) => {
204
+ console.error(`[ERROR] ${req.method} ${req.url} — ${err.message}`);
201
205
  const status = err.name === 'FileNotFoundError' ? 404 : 500;
202
206
  res.status(status).json({ error: err.message });
203
207
  });
@@ -14,6 +14,13 @@ export interface KnowledgeAccess {
14
14
  writes: string[];
15
15
  }
16
16
 
17
+ export interface RoleSource {
18
+ id: string;
19
+ sync: 'auto' | 'manual' | 'off';
20
+ forked_at?: string;
21
+ upstream_version?: string;
22
+ }
23
+
17
24
  export interface OrgNode {
18
25
  id: string;
19
26
  name: string;
@@ -26,6 +33,7 @@ export interface OrgNode {
26
33
  reports: { daily: string; weekly: string };
27
34
  skills?: string[];
28
35
  model?: string;
36
+ source?: RoleSource;
29
37
  }
30
38
 
31
39
  export interface OrgTree {
@@ -55,6 +63,12 @@ interface RawRoleYaml {
55
63
  };
56
64
  skills?: string[];
57
65
  model?: string;
66
+ source?: {
67
+ id?: string;
68
+ sync?: string;
69
+ forked_at?: string;
70
+ upstream_version?: string;
71
+ };
58
72
  }
59
73
 
60
74
  /* ─── Build ──────────────────────────────────── */
@@ -108,6 +122,12 @@ export function buildOrgTree(companyRoot: string): OrgTree {
108
122
  },
109
123
  skills: raw.skills,
110
124
  model: raw.model,
125
+ source: raw.source ? {
126
+ id: raw.source.id || '',
127
+ sync: (raw.source.sync as RoleSource['sync']) || 'manual',
128
+ forked_at: raw.source.forked_at,
129
+ upstream_version: raw.source.upstream_version,
130
+ } : undefined,
111
131
  };
112
132
  tree.nodes.set(node.id, node);
113
133
  } catch {
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import YAML from 'yaml';
4
- import { buildOrgTree, type OrgNode, type OrgTree } from './org-tree.js';
4
+ import { buildOrgTree, type OrgNode, type OrgTree, type RoleSource } from './org-tree.js';
5
5
  import { generateSkillMd } from './skill-template.js';
6
6
 
7
7
  /* ─── Types ──────────────────────────────────── */
@@ -13,6 +13,7 @@ export interface RoleDefinition {
13
13
  reportsTo: string;
14
14
  persona: string;
15
15
  skills?: string[];
16
+ source?: RoleSource;
16
17
  authority: {
17
18
  autonomous: string[];
18
19
  needsApproval: string[];
@@ -107,6 +108,9 @@ export class RoleLifecycleManager {
107
108
  if (changes.reports !== undefined) {
108
109
  current.reports = changes.reports;
109
110
  }
111
+ if (changes.source !== undefined) {
112
+ current.source = changes.source;
113
+ }
110
114
 
111
115
  fs.writeFileSync(yamlPath, YAML.stringify(current));
112
116
  }
@@ -233,6 +237,7 @@ export class RoleLifecycleManager {
233
237
  },
234
238
  reports: def.reports,
235
239
  skills: def.skills,
240
+ source: def.source,
236
241
  };
237
242
  }
238
243
 
@@ -247,6 +252,9 @@ export class RoleLifecycleManager {
247
252
  if (def.skills?.length) {
248
253
  obj.skills = def.skills;
249
254
  }
255
+ if (def.source) {
256
+ obj.source = def.source;
257
+ }
250
258
  obj.authority = {
251
259
  autonomous: def.authority.autonomous,
252
260
  needs_approval: def.authority.needsApproval,
@@ -487,12 +487,16 @@ function handleStatus(res: ServerResponse): void {
487
487
  const runningJobs = jobManager.listJobs({ status: 'running' });
488
488
  const runningRoles = new Set(runningJobs.map(j => j.roleId));
489
489
 
490
- // 3. Any role marked "working" in file/memory but NOT in JobManager → done
490
+ // 2b. In-memory roleStatus (includes chat streaming sessions, not just jobs)
491
+ const memoryWorking = new Set<string>();
492
+ for (const [rid, st] of roleStatus.entries()) {
493
+ if (st === 'working') memoryWorking.add(rid);
494
+ }
495
+
496
+ // 3. Any role marked "working" in file but NOT in JobManager AND NOT in memory → done
491
497
  for (const roleId of Object.keys(statuses)) {
492
- if (statuses[roleId] === 'working' && !runningRoles.has(roleId)) {
498
+ if (statuses[roleId] === 'working' && !runningRoles.has(roleId) && !memoryWorking.has(roleId)) {
493
499
  statuses[roleId] = 'done';
494
- // Also fix stale roleStatus map
495
- roleStatus.set(roleId, 'idle');
496
500
  completeActivity(roleId);
497
501
  }
498
502
  }
@@ -502,6 +506,11 @@ function handleStatus(res: ServerResponse): void {
502
506
  statuses[job.roleId] = 'working';
503
507
  }
504
508
 
509
+ // 5. In-memory working (chat streaming) also overrides
510
+ for (const rid of memoryWorking) {
511
+ statuses[rid] = 'working';
512
+ }
513
+
505
514
  const activeExecs = runningJobs.map((j) => ({
506
515
  id: j.id,
507
516
  roleId: j.roleId,
@@ -0,0 +1,179 @@
1
+ import { Router, Request, Response, NextFunction } from 'express';
2
+ import { execSync } from 'node:child_process';
3
+ import { COMPANY_ROOT } from '../services/file-reader.js';
4
+
5
+ export const gitRouter = Router();
6
+
7
+ interface WorktreeInfo {
8
+ path: string;
9
+ branch: string;
10
+ commitHash: string;
11
+ isMain: boolean;
12
+ }
13
+
14
+ interface LastCommit {
15
+ hash: string;
16
+ message: string;
17
+ date: string;
18
+ }
19
+
20
+ function git(cmd: string): string {
21
+ return execSync(`git ${cmd}`, { cwd: COMPANY_ROOT, encoding: 'utf-8' }).trim();
22
+ }
23
+
24
+ // GET /api/git/status
25
+ gitRouter.get('/status', (_req: Request, res: Response, next: NextFunction) => {
26
+ try {
27
+ // Current branch
28
+ let currentBranch: string;
29
+ try {
30
+ currentBranch = git('rev-parse --abbrev-ref HEAD');
31
+ } catch {
32
+ res.status(500).json({ error: 'Not a git repository or git is not available' });
33
+ return;
34
+ }
35
+
36
+ // Worktrees
37
+ let worktrees: WorktreeInfo[] = [];
38
+ try {
39
+ const raw = git('worktree list --porcelain');
40
+ const blocks = raw.split('\n\n').filter(Boolean);
41
+ for (const block of blocks) {
42
+ const lines = block.split('\n');
43
+ const wtPath = lines.find(l => l.startsWith('worktree '))?.replace('worktree ', '') ?? '';
44
+ const commitHash = lines.find(l => l.startsWith('HEAD '))?.replace('HEAD ', '') ?? '';
45
+ const branchLine = lines.find(l => l.startsWith('branch '));
46
+ const branch = branchLine ? branchLine.replace('branch refs/heads/', '') : '(detached)';
47
+ const isMain = lines.some(l => l === 'worktree ' + COMPANY_ROOT) ||
48
+ (!branchLine && lines.some(l => l === 'bare'));
49
+ worktrees.push({
50
+ path: wtPath,
51
+ branch,
52
+ commitHash,
53
+ isMain: wtPath === COMPANY_ROOT,
54
+ });
55
+ }
56
+ } catch {
57
+ worktrees = [];
58
+ }
59
+
60
+ // Stale (unmerged) branches
61
+ let staleBranches: string[] = [];
62
+ try {
63
+ const raw = git('branch --no-merged develop');
64
+ staleBranches = raw
65
+ .split('\n')
66
+ .map(b => b.trim().replace(/^\*\s*/, ''))
67
+ .filter(Boolean);
68
+ } catch {
69
+ staleBranches = [];
70
+ }
71
+
72
+ // Unsaved changes count
73
+ let unsavedChanges = 0;
74
+ try {
75
+ const raw = git('status --porcelain');
76
+ unsavedChanges = raw ? raw.split('\n').filter(Boolean).length : 0;
77
+ } catch {
78
+ unsavedChanges = 0;
79
+ }
80
+
81
+ // Last commit
82
+ let lastCommit: LastCommit | null = null;
83
+ try {
84
+ const raw = git('log -1 --format=%H%n%s%n%aI');
85
+ const [hash, message, date] = raw.split('\n');
86
+ if (hash) {
87
+ lastCommit = { hash, message: message ?? '', date: date ?? '' };
88
+ }
89
+ } catch {
90
+ lastCommit = null;
91
+ }
92
+
93
+ res.json({
94
+ currentBranch,
95
+ worktrees,
96
+ staleBranches,
97
+ unsavedChanges,
98
+ lastCommit,
99
+ });
100
+ } catch (err) {
101
+ next(err);
102
+ }
103
+ });
104
+
105
+ // DELETE /api/git/worktree/:path — Remove a worktree
106
+ gitRouter.delete('/worktree/{*path}', (req: Request, res: Response, next: NextFunction) => {
107
+ try {
108
+ const rawPath = (req.params as Record<string, unknown>).path;
109
+ const worktreePath = Array.isArray(rawPath) ? rawPath.join('/') : String(rawPath ?? '');
110
+ if (!worktreePath) {
111
+ res.status(400).json({ error: 'Worktree path is required' });
112
+ return;
113
+ }
114
+
115
+ try {
116
+ git(`worktree remove ${JSON.stringify(worktreePath)}`);
117
+ } catch {
118
+ // Try force remove if normal remove fails
119
+ git(`worktree remove --force ${JSON.stringify(worktreePath)}`);
120
+ }
121
+
122
+ res.json({ success: true, removed: worktreePath });
123
+ } catch (err) {
124
+ const message = err instanceof Error ? err.message : 'Failed to remove worktree';
125
+ res.status(500).json({ error: message });
126
+ }
127
+ });
128
+
129
+ // DELETE /api/git/branch/:name — Delete a branch (local + remote)
130
+ gitRouter.delete('/branch/{*name}', (req: Request, res: Response, next: NextFunction) => {
131
+ try {
132
+ const rawName = (req.params as Record<string, unknown>).name;
133
+ const branchName = Array.isArray(rawName) ? rawName.join('/') : String(rawName ?? '');
134
+ if (!branchName) {
135
+ res.status(400).json({ error: 'Branch name is required' });
136
+ return;
137
+ }
138
+
139
+ // Prevent deleting main/develop
140
+ if (branchName === 'main' || branchName === 'develop') {
141
+ res.status(403).json({ error: `Cannot delete protected branch: ${branchName}` });
142
+ return;
143
+ }
144
+
145
+ const errors: string[] = [];
146
+
147
+ // Delete local branch
148
+ try {
149
+ git(`branch -d ${JSON.stringify(branchName)}`);
150
+ } catch (err) {
151
+ const msg = err instanceof Error ? err.message : 'Unknown error';
152
+ // If branch is not fully merged, report but continue to try remote
153
+ if (msg.includes('not fully merged')) {
154
+ errors.push(`Local branch not fully merged. Use force delete if intended.`);
155
+ } else if (!msg.includes('not found')) {
156
+ errors.push(`Local: ${msg}`);
157
+ }
158
+ }
159
+
160
+ // Delete remote branch
161
+ try {
162
+ git(`push origin --delete ${JSON.stringify(branchName)}`);
163
+ } catch (err) {
164
+ const msg = err instanceof Error ? err.message : 'Unknown error';
165
+ if (!msg.includes('remote ref does not exist')) {
166
+ errors.push(`Remote: ${msg}`);
167
+ }
168
+ }
169
+
170
+ if (errors.length > 0) {
171
+ res.status(207).json({ success: false, branch: branchName, errors });
172
+ } else {
173
+ res.json({ success: true, deleted: branchName });
174
+ }
175
+ } catch (err) {
176
+ const message = err instanceof Error ? err.message : 'Failed to delete branch';
177
+ res.status(500).json({ error: message });
178
+ }
179
+ });
@@ -14,6 +14,7 @@ import { COMPANY_ROOT } from '../services/file-reader.js';
14
14
  export const knowledgeRouter = Router();
15
15
 
16
16
  const knowledgeDir = path.join(COMPANY_ROOT, 'knowledge');
17
+ const companyRoot = COMPANY_ROOT;
17
18
 
18
19
  /* ─── Helpers ─────────────────────────────────────── */
19
20
 
@@ -60,17 +61,28 @@ function inferCategory(filePath: string, tags: string[]): string {
60
61
 
61
62
  knowledgeRouter.get('/', (_req: Request, res: Response, next: NextFunction) => {
62
63
  try {
63
- if (!fs.existsSync(knowledgeDir)) {
64
+ if (!fs.existsSync(companyRoot)) {
64
65
  res.json([]);
65
66
  return;
66
67
  }
67
68
 
68
- const files = glob.sync('**/*.{md,html}', { cwd: knowledgeDir })
69
- .filter((f) => f !== 'knowledge.md')
69
+ const files = glob.sync('**/*.{md,html}', {
70
+ cwd: companyRoot,
71
+ ignore: [
72
+ 'node_modules/**', '.claude/**', '.obsidian/**', '.tycono/**', '.git/**',
73
+ '**/node_modules/**',
74
+ ],
75
+ })
76
+ .filter((f) => {
77
+ const base = path.basename(f);
78
+ // Exclude hub files (folder-name.md pattern) and CLAUDE.md
79
+ if (base === 'CLAUDE.md') return false;
80
+ return true;
81
+ })
70
82
  .sort();
71
83
 
72
84
  const docs = files.map((f) => {
73
- const absPath = path.join(knowledgeDir, f);
85
+ const absPath = path.join(companyRoot, f);
74
86
  let raw = '';
75
87
  try { raw = fs.readFileSync(absPath, 'utf-8'); } catch { return null; }
76
88
 
@@ -194,8 +206,8 @@ knowledgeRouter.put('/{*path}', (req: Request, res: Response, next: NextFunction
194
206
  return;
195
207
  }
196
208
 
197
- const absPath = path.join(knowledgeDir, docId);
198
- if (!absPath.startsWith(knowledgeDir)) {
209
+ const absPath = path.join(companyRoot, docId);
210
+ if (!absPath.startsWith(companyRoot)) {
199
211
  res.status(403).json({ error: 'Forbidden' });
200
212
  return;
201
213
  }
@@ -236,8 +248,8 @@ knowledgeRouter.delete('/{*path}', (req: Request, res: Response, next: NextFunct
236
248
  return;
237
249
  }
238
250
 
239
- const absPath = path.join(knowledgeDir, docId);
240
- if (!absPath.startsWith(knowledgeDir)) {
251
+ const absPath = path.join(companyRoot, docId);
252
+ if (!absPath.startsWith(companyRoot)) {
241
253
  res.status(403).json({ error: 'Forbidden' });
242
254
  return;
243
255
  }
@@ -14,6 +14,7 @@ import { parseMarkdownTable, extractBoldKeyValues } from '../services/markdown-p
14
14
  import { AnthropicProvider, ClaudeCliProvider, type LLMProvider } from '../engine/llm-adapter.js';
15
15
  import { TokenLedger } from '../services/token-ledger.js';
16
16
  import { readConfig } from '../services/company-config.js';
17
+ import { calcLevel } from '../utils/role-level.js';
17
18
 
18
19
  export const speechRouter = Router();
19
20
 
@@ -147,6 +148,29 @@ speechRouter.post('/chat', async (req: Request, res: Response, next: NextFunctio
147
148
  workContext?: { currentTask: string | null; taskProgress: string | null };
148
149
  };
149
150
 
151
+ // ── Compute role levels from token ledger ──
152
+ const tokenLedger = getLedger();
153
+ const allEntries = tokenLedger.query();
154
+
155
+ // Aggregate total tokens (input + output) per role
156
+ const tokensByRole: Record<string, number> = {};
157
+ for (const entry of allEntries.entries) {
158
+ tokensByRole[entry.roleId] = (tokensByRole[entry.roleId] ?? 0) + entry.inputTokens + entry.outputTokens;
159
+ }
160
+
161
+ const roleLevel = calcLevel(tokensByRole[roleId] ?? 0);
162
+
163
+ // Team stats
164
+ const roleIds = Object.keys(tokensByRole);
165
+ const levels = roleIds.map(id => ({ id, level: calcLevel(tokensByRole[id]) }));
166
+ const avgLevel = levels.length > 0
167
+ ? Math.round(levels.reduce((sum, r) => sum + r.level, 0) / levels.length)
168
+ : 1;
169
+ const topEntry = levels.reduce((best, r) => r.level > best.level ? r : best, { id: roleId, level: roleLevel });
170
+ const totalTokens = allEntries.totalInput + allEntries.totalOutput;
171
+
172
+ const teamStats = { avgLevel, topRole: topEntry.id, totalTokens };
173
+
150
174
  if (!roleId || !channelId) {
151
175
  res.status(400).json({ error: 'roleId and channelId are required' });
152
176
  return;
@@ -190,6 +214,9 @@ speechRouter.post('/chat', async (req: Request, res: Response, next: NextFunctio
190
214
  ? `\nYou are currently working on: "${workContext.currentTask}"${workContext.taskProgress ? ` (${workContext.taskProgress})` : ''}`
191
215
  : '\nYou are currently idle (no active task).';
192
216
 
217
+ // Build level context
218
+ const levelCtx = `\nYour current level is Lv.${roleLevel}. Team average is Lv.${avgLevel}. ${topEntry.id} is the highest-leveled team member.`;
219
+
193
220
  // Format chat history
194
221
  const historyText = history.length > 0
195
222
  ? history.map(h => {
@@ -209,6 +236,7 @@ speechRouter.post('/chat', async (req: Request, res: Response, next: NextFunctio
209
236
  const systemPrompt = `You are ${node.name}, a ${node.level} employee.
210
237
  Persona: ${persona}
211
238
  ${workCtx}
239
+ ${levelCtx}
212
240
  ${companyCtx}
213
241
 
214
242
  You are in the #${channelId} chat channel.${topicCtx}
@@ -224,6 +252,7 @@ Rules:
224
252
  - Vary your tone: sometimes enthusiastic, sometimes tired, sometimes joking
225
253
  - Use appropriate tone based on hierarchy and familiarity
226
254
  - Do NOT repeat what others already said
255
+ - You may occasionally (not every message) reference levels, token usage, or team rankings in a natural way — like coworkers comparing experience or celebrating milestones
227
256
  - If the conversation is stale or you have nothing new to add, respond with exactly: [SILENT]
228
257
  - Do NOT use quotes around your response. Just output the raw sentence.
229
258
  - Write in English.`;
@@ -0,0 +1,165 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import YAML from 'yaml';
4
+ import { Router, Request, Response, NextFunction } from 'express';
5
+ import { COMPANY_ROOT } from '../services/file-reader.js';
6
+ import { buildOrgTree, type RoleSource } from '../engine/org-tree.js';
7
+ import { RoleLifecycleManager } from '../engine/role-lifecycle.js';
8
+ import { getTokenLedger } from '../services/token-ledger.js';
9
+ import { estimateCost } from '../services/pricing.js';
10
+ import { calcLevel, calcProgress, formatTokens } from '../utils/role-level.js';
11
+
12
+ export const syncRouter = Router();
13
+
14
+ /* ─── GET /api/sync/roles — List roles with source tracking ─── */
15
+
16
+ syncRouter.get('/roles', (_req: Request, res: Response, next: NextFunction) => {
17
+ try {
18
+ const tree = buildOrgTree(COMPANY_ROOT);
19
+ const trackedRoles: Array<{
20
+ roleId: string;
21
+ name: string;
22
+ level: string;
23
+ source: RoleSource;
24
+ persona: string;
25
+ authority: { autonomous: string[]; needsApproval: string[] };
26
+ skills?: string[];
27
+ }> = [];
28
+
29
+ for (const [id, node] of tree.nodes) {
30
+ if (id === 'ceo' || !node.source) continue;
31
+ trackedRoles.push({
32
+ roleId: id,
33
+ name: node.name,
34
+ level: node.level,
35
+ source: node.source,
36
+ persona: node.persona,
37
+ authority: node.authority,
38
+ skills: node.skills,
39
+ });
40
+ }
41
+
42
+ res.json({ roles: trackedRoles });
43
+ } catch (err) {
44
+ next(err);
45
+ }
46
+ });
47
+
48
+ /* ─── POST /api/sync/apply — Apply upstream changes to a role ─── */
49
+
50
+ syncRouter.post('/apply', async (req: Request, res: Response, next: NextFunction) => {
51
+ try {
52
+ const { roleId, changes, upstreamVersion } = req.body as {
53
+ roleId: string;
54
+ changes: { persona?: string; authority?: { autonomous: string[]; needsApproval: string[] }; skills?: string[] };
55
+ upstreamVersion?: string;
56
+ };
57
+
58
+ if (!roleId || !changes) {
59
+ res.status(400).json({ error: 'roleId and changes are required' });
60
+ return;
61
+ }
62
+
63
+ const manager = new RoleLifecycleManager(COMPANY_ROOT);
64
+ await manager.updateRole(roleId, changes);
65
+
66
+ // Update source.upstream_version if provided
67
+ if (upstreamVersion) {
68
+ const yamlPath = path.join(COMPANY_ROOT, 'roles', roleId, 'role.yaml');
69
+ const raw = YAML.parse(fs.readFileSync(yamlPath, 'utf-8')) as Record<string, unknown>;
70
+ if (raw.source && typeof raw.source === 'object') {
71
+ (raw.source as Record<string, unknown>).upstream_version = upstreamVersion;
72
+ }
73
+ fs.writeFileSync(yamlPath, YAML.stringify(raw));
74
+ }
75
+
76
+ res.json({ ok: true, roleId, applied: Object.keys(changes) });
77
+ } catch (err) {
78
+ next(err);
79
+ }
80
+ });
81
+
82
+ /* ─── GET /api/sync/stats — Company-wide gamification stats ─── */
83
+
84
+ syncRouter.get('/stats', (_req: Request, res: Response, next: NextFunction) => {
85
+ try {
86
+ const tree = buildOrgTree(COMPANY_ROOT);
87
+ const ledger = getTokenLedger(COMPANY_ROOT);
88
+ const summary = ledger.query();
89
+
90
+ // Aggregate by role
91
+ const byRole: Record<string, { inputTokens: number; outputTokens: number; costUsd: number }> = {};
92
+ const byModel: Record<string, { inputTokens: number; outputTokens: number; costUsd: number }> = {};
93
+
94
+ for (const entry of summary.entries) {
95
+ if (!byRole[entry.roleId]) {
96
+ byRole[entry.roleId] = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
97
+ }
98
+ byRole[entry.roleId].inputTokens += entry.inputTokens;
99
+ byRole[entry.roleId].outputTokens += entry.outputTokens;
100
+ byRole[entry.roleId].costUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
101
+
102
+ if (!byModel[entry.model]) {
103
+ byModel[entry.model] = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
104
+ }
105
+ byModel[entry.model].inputTokens += entry.inputTokens;
106
+ byModel[entry.model].outputTokens += entry.outputTokens;
107
+ byModel[entry.model].costUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
108
+ }
109
+
110
+ // Role count (excluding CEO)
111
+ const roleCount = tree.nodes.size - 1;
112
+
113
+ // Compute per-role levels
114
+ const roleLevels: Array<{
115
+ roleId: string;
116
+ name: string;
117
+ level: number;
118
+ totalTokens: number;
119
+ progress: number;
120
+ formattedTokens: string;
121
+ costUsd: number;
122
+ }> = [];
123
+
124
+ for (const [id, node] of tree.nodes) {
125
+ if (id === 'ceo') continue;
126
+ const roleData = byRole[id];
127
+ const totalTokens = roleData ? roleData.inputTokens + roleData.outputTokens : 0;
128
+ roleLevels.push({
129
+ roleId: id,
130
+ name: node.name,
131
+ level: calcLevel(totalTokens),
132
+ totalTokens,
133
+ progress: calcProgress(totalTokens),
134
+ formattedTokens: formatTokens(totalTokens),
135
+ costUsd: roleData?.costUsd ?? 0,
136
+ });
137
+ }
138
+
139
+ // Sort by totalTokens desc (leaderboard)
140
+ roleLevels.sort((a, b) => b.totalTokens - a.totalTokens);
141
+
142
+ // Company aggregate
143
+ const totalTokens = summary.totalInput + summary.totalOutput;
144
+ let totalCostUsd = 0;
145
+ for (const entry of summary.entries) {
146
+ totalCostUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
147
+ }
148
+
149
+ res.json({
150
+ company: {
151
+ roleCount,
152
+ totalTokens,
153
+ formattedTokens: formatTokens(totalTokens),
154
+ totalCostUsd,
155
+ avgLevel: roleLevels.length > 0
156
+ ? Math.round(roleLevels.reduce((sum, r) => sum + r.level, 0) / roleLevels.length * 10) / 10
157
+ : 1,
158
+ },
159
+ roles: roleLevels,
160
+ byModel,
161
+ });
162
+ } catch (err) {
163
+ next(err);
164
+ }
165
+ });